Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 68 additions & 10 deletions .claude/skills/declscope-adoption/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ name: declscope-adoption
description: Adopt declscope on an existing Go codebase and drive its diagnostics to zero. Read this when introducing declscope to a repository, when clearing a declscope baseline, or when a declscope diagnostic is hard to act on. Covers reading the diagnostics as structure, the remedy for each shape, and the measurement traps that produce false confidence.
license: MIT
x-embedded-by: declscope
x-embedded-version: 0.12.0
x-embedded-at: "2026-09-24T07:04:57Z"
x-embedded-digest: "sha256:3ff360b2bf9880e00a36ba22f0de09062eb5545f8fccf2a48874b717712d6f58"
x-embedded-version: 0.13.1
x-embedded-at: "2026-09-25T04:22:46Z"
x-embedded-digest: "sha256:f344c4e28af4379d944b3b6563f74727999fae4218804bb2bf0b57fa31d53093"
---

# Adopting declscope

Written against **declscope 0.12.0**. Check the version first: this describes how that release behaves, not how an older one does.
Written against **declscope 0.13.1**. Check the version first: this describes how that release behaves, not how an older one does.

```bash
declscope -V=full
Expand Down Expand Up @@ -102,6 +102,60 @@ done

Every count in the rest of this skill assumes `qualify: ondemand` with `exported: true`. That is what the numbers were taken under, not a recommendation.

## Shrink the exported surface first

**`declscope shrink` reports the exported declarations of `internal/` packages that nothing outside their package uses.** With `-fix` it unexports them. The analyzer cannot answer this: it reads one package, and any importer might use an exported name. Inside `internal/`, Go limits the importers to one directory tree, so `shrink` loads the whole module and sees every one of them.

An exported name inside `internal/` claims that another package depends on it. Where nothing does, the claim is false, and it hides the declaration from the rest of declscope. An exported declaration takes package scope by default, so no boundary is ever reported on it. Unexported, it takes `private`, and the analyzer checks who reaches it.

**It is a subcommand, not a rule the analyzer runs.** `go vet` and golangci-lint never report it, and it has no config key. A clean `declscope ./...` says nothing about it.

### Run it before the analyzer

**Run `shrink`, and apply its fixes, before you work on the analyzer's reports.** A declaration it unexports becomes private to its namespace. Wherever another file of the package uses it, the analyzer then reports a boundary crossing that was not there before. Fixing in the other order means a second round.

This happened when declscope held itself to `shrink`. The fix unexported twelve declarations, and nine of them were used from other files of their package. The analyzer then needed nine `//declscope:package` directives to state those crossings.

| Step | Command |
| --- | --- |
| 1. Read what `shrink` reports | `declscope shrink ./...` |
| 2. Unexport, once the owner agrees | `declscope shrink -fix ./...` |
| 3. Confirm the build | `go vet ./...` and `go test ./...` |
| 4. Read what the analyzer now reports | `declscope ./...` |
| 5. State or move each new crossing | See [What each shape means](#what-each-shape-means) |

Ask before step 2, the same as any other change. The fix renames every identifier naming the declaration, all inside its own package, and the doc comment that opens with the name.

### Reading what it reports

| Report | What to do |
| --- | --- |
| `... uses it` and nothing more | The fix is offered. Apply it with `-fix` |
| `... (no fix: <reason>)` | A use may exist that `shrink` cannot prove, or the rename is unsafe. **Do not unexport it by hand.** Read the reason first |
| `... only the external tests of <pkg> use it` | Keep it exported. Add `//declscope:ignore overexported // <why>` when the tests use it on purpose |
| `declscope shrink: not judged: <pkg>: <reason>` on stderr | That package was not checked. It is not clean |

**A package not judged is not a package with nothing to report.**

`shrink` stands down wherever an importer could be unseen. That is outside `internal/`, in `package main`, and beside assembly or cgo. It is also under an `internal/` that a nested module's path extends. The stderr line names each such package, and the exit status ignores it.

Silence a report with `//declscope:ignore overexported` and a reason. A bare `//declscope:ignore` does not reach this rule. `shrink` reports an ignore that silenced nothing, as the analyzer does for its own.

**Deleting unused code is not `shrink`'s job.** Once a declaration is unexported, staticcheck's `unused` and gopls' `unusedfunc` report it when nothing uses it. Run them after `shrink`, not before.

### Keep it in CI

Run it before the analyzer there too, so that a failure reads in the order it is fixed. It exits 3 when it reports anything.

```yaml
- run: declscope shrink ./...
- run: declscope ./...
```

**Names written as strings are outside what `shrink` can see.**

A template can name a field, and a constant can go to `reflect.Value.MethodByName`. A script can read the symbol table. Each uses a declaration by name. When the value reaches them through an interface, `shrink` already treats it as used. When it does not, add the ignore with the reason.

## The two kinds of report

declscope reports two things. **Read them separately.**
Expand All @@ -110,6 +164,7 @@ declscope reports two things. **Read them separately.**
| --- | --- |
| `boundary` | A file reaches a declaration another file holds. A property of the code |
| `qualify` | A name does not carry its file's namespace. A convention |
| `overexported` | An exported name inside `internal/` that nothing outside its package uses. Only `declscope shrink` reports it, and it goes [first](#shrink-the-exported-surface-first) |

Boundary first. It is the one that points at structure.

Expand Down Expand Up @@ -309,6 +364,8 @@ go build ./... && declscope ./... # never read a bare count without this

**A zero may be the filter, not the code.** A `filter.only` anywhere in the chain can leave a package with nothing to read. A package nothing was read from reports nothing. `declscope` says so only when a nested `only` was cancelled by one above it, so the quiet cases stay quiet. `declscope inspect` lists the files each namespace was built from (`namespaces[].files`); a package whose files are missing from it is one the filter removed.

**A clean analyzer says nothing about `shrink`.** The analyzer never reports `overexported`, and `shrink` never reports what the analyzer does. Run both, `shrink` first.

**A zero from `boundary` may be the switch, not the code.** `rules.boundary: off` silences the rule entirely, and the run looks like a clean repository. Read every config before reporting a count, the same way you would for `qualify`.

**`-fix` widens; it does not draw boundaries.** On a codebase with boundary findings, `declscope -fix ./...` inserts `//declscope:package` above every crossed declaration — the wholesale widening step 2 of the order of work exists to avoid. Run `-fix -diff` first and read it. Its place in an adoption is renaming, after the structure is settled, and only where `names[].fixable` is true.
Expand All @@ -332,9 +389,10 @@ cp -r repo /tmp/try-a # and measure there
## Order of work

1. `declscope survey ./...`, and read Checks in force before any count
2. Clear `boundary` by moving the boundary, not by widening everything
3. Re-measure with `survey`. Naming often falls with it, since merging two namespaces into one takes `ondemand` out of force
4. Fix the file names that do not match their contents
5. Rename what is left, in natural word order
6. Delete the baseline
7. Check the core count, and `go build`, `go test` and `declscope` in that order
2. If the repository has `internal/` packages, run `declscope shrink ./...` and settle it before anything else. Its fixes add boundary reports, and nothing that follows adds reports back
3. Clear `boundary` by moving the boundary, not by widening everything
4. Re-measure with `survey`. Naming often falls with it, since merging two namespaces into one takes `ondemand` out of force
5. Fix the file names that do not match their contents
6. Rename what is left, in natural word order
7. Delete the baseline
8. Check the core count, and `go build`, `go test`, `declscope shrink` and `declscope` in that order
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ jobs:
- name: Set up mise
uses: jdx/mise-action@v4

# Before the analyzer: a declaration shrink unexports becomes private to
# its file's namespace, so a use from another file is then a boundary
# crossing the analyzer reports.
- name: Run declscope shrink
run: declscope shrink ./...

- name: Run declscope
run: declscope ./...

Expand Down
32 changes: 17 additions & 15 deletions internal/cli/commands/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,30 +25,32 @@ import (
"github.com/mpyw/sql-http-proxy/internal/server"
)

// ShutdownTimeout is the maximum time to wait for graceful shutdown.
const ShutdownTimeout = 30 * time.Second
// shutdownTimeout is the maximum time to wait for graceful shutdown.
const shutdownTimeout = 30 * time.Second

// Request header limits. These are stricter than the net/http defaults
// (1MiB / 500 values) because every endpoint here is a SQL query keyed off a
// small set of parameters - no legitimate client needs a large header block.
const (
// MaxHeaderBytes caps the total size of the request header block.
MaxHeaderBytes = 64 * 1024
// MaxHeaderValueCount caps the number of header values in a request,
// maxHeaderBytes caps the total size of the request header block.
maxHeaderBytes = 64 * 1024
// maxHeaderValueCount caps the number of header values in a request,
// bounding the per-connection allocation a client can force.
MaxHeaderValueCount = 100
maxHeaderValueCount = 100
)

// ReadHeaderTimeout is the maximum time allowed to read request headers.
// readHeaderTimeout is the maximum time allowed to read request headers.
// Only the header phase is bounded: bodies and responses are left untimed so
// that large uploads and slow queries are not cut off mid-flight.
const ReadHeaderTimeout = 10 * time.Second
const readHeaderTimeout = 10 * time.Second

// Version is set by goreleaser via ldflags.
//
//declscope:ignore overexported // .goreleaser.yaml sets it with -X, which names it by its exported path
var Version = "dev"

// MakeApp creates a new CLI application instance.
func MakeApp() *cli.Command {
// makeApp creates a new CLI application instance.
func makeApp() *cli.Command {
return &cli.Command{
Name: "sql-http-proxy",
Usage: "YAML configuration-based HTTP to SQL proxy server",
Expand Down Expand Up @@ -122,9 +124,9 @@ func action(ctx context.Context, cmd *cli.Command) error {
srv := &http.Server{
Addr: listen,
Handler: mux,
ReadHeaderTimeout: ReadHeaderTimeout,
MaxHeaderBytes: MaxHeaderBytes,
MaxHeaderValueCount: MaxHeaderValueCount,
ReadHeaderTimeout: readHeaderTimeout,
MaxHeaderBytes: maxHeaderBytes,
MaxHeaderValueCount: maxHeaderValueCount,
}

// Channel to receive server errors
Expand All @@ -151,7 +153,7 @@ func action(ctx context.Context, cmd *cli.Command) error {
}

// Graceful shutdown with timeout
shutdownCtx, cancel := context.WithTimeout(context.Background(), ShutdownTimeout)
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()

slog.Info("Shutting down server...")
Expand All @@ -164,4 +166,4 @@ func action(ctx context.Context, cmd *cli.Command) error {
}

// App is the main CLI application.
var App = MakeApp()
var App = makeApp()
8 changes: 4 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// config.go is the unit this package is named for: the Config document and
// its parsing. Its API is read as config.Parse / config.Config, so its names
// its parsing. Its API is read as config.Config, so its names
// stay unprefixed.
//
//declscope:core
Expand Down Expand Up @@ -236,8 +236,8 @@ func (cfg *Config) ValidateTransforms() error {
return err
}

// Parse parses configuration from YAML bytes.
func Parse(data []byte) (Config, error) {
// parse parses configuration from YAML bytes.
func parse(data []byte) (Config, error) {
// Parse YAML to generic interface for schema validation
var raw any
if err := yaml.Unmarshal(data, &raw); err != nil {
Expand Down Expand Up @@ -280,5 +280,5 @@ func ParseFile(filename string) (Config, error) {
if err != nil {
return Config{}, fmt.Errorf("failed to open file: %w", err)
}
return Parse(data)
return parse(data)
}
34 changes: 17 additions & 17 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ queries:
path: /user
sql: SELECT * FROM users WHERE id = :id
`
cfg, err := Parse([]byte(yaml))
cfg, err := parse([]byte(yaml))
require.NoError(t, err)
assert.Equal(t, "postgres://localhost:5432/db", cfg.DSN())
assert.Len(t, cfg.Queries, 1)
Expand All @@ -260,15 +260,15 @@ queries:
id: 1
name: Alice
`
cfg, err := Parse([]byte(yaml))
cfg, err := parse([]byte(yaml))
require.NoError(t, err)
assert.Len(t, cfg.Queries, 1)
assert.NotNil(t, cfg.Queries[0].Mock)
})

t.Run("invalid yaml", func(t *testing.T) {
yaml := `invalid: yaml: syntax`
_, err := Parse([]byte(yaml))
_, err := parse([]byte(yaml))
require.Error(t, err)
})

Expand All @@ -278,7 +278,7 @@ queries:
- path: /user
sql: SELECT * FROM users
`
_, err := Parse([]byte(yaml))
_, err := parse([]byte(yaml))
require.Error(t, err)
assert.Contains(t, err.Error(), "type")
})
Expand All @@ -290,7 +290,7 @@ queries:
path: /user
sql: SELECT * FROM users
`
_, err := Parse([]byte(yaml))
_, err := parse([]byte(yaml))
require.Error(t, err)
})

Expand All @@ -304,7 +304,7 @@ queries:
object:
id: 1
`
_, err := Parse([]byte(yaml))
_, err := parse([]byte(yaml))
require.Error(t, err)
assert.Contains(t, err.Error(), "sql")
assert.Contains(t, err.Error(), "mock")
Expand All @@ -318,7 +318,7 @@ queries:
- type: one
path: /user
`
_, err := Parse([]byte(yaml))
_, err := parse([]byte(yaml))
require.Error(t, err)
})

Expand All @@ -333,7 +333,7 @@ queries:
path: /user
sql: SELECT 1
`
cfg, err := Parse([]byte(yaml))
cfg, err := parse([]byte(yaml))
require.NoError(t, err)
assert.Equal(t, "postgres://myhost:5433/db", cfg.DSN())
})
Expand All @@ -347,7 +347,7 @@ queries:
path: /user
sql: SELECT 1
`
cfg, err := Parse([]byte(yaml))
cfg, err := parse([]byte(yaml))
require.NoError(t, err)
assert.Equal(t, "postgres://localhost:5432/db", cfg.DSN())
})
Expand All @@ -365,7 +365,7 @@ queries:
- id: 1
- id: 2
`
_, err := Parse([]byte(yaml))
_, err := parse([]byte(yaml))
require.Error(t, err)
assert.Contains(t, err.Error(), "filter")
assert.Contains(t, err.Error(), "array")
Expand All @@ -380,7 +380,7 @@ queries:
object:
id: 1
`
_, err := Parse([]byte(yaml))
_, err := parse([]byte(yaml))
require.Error(t, err)
assert.Contains(t, err.Error(), "object")
assert.Contains(t, err.Error(), "many")
Expand All @@ -393,7 +393,7 @@ mutations:
path: /delete
mock: true
`
cfg, err := Parse([]byte(yaml))
cfg, err := parse([]byte(yaml))
require.NoError(t, err)
require.Len(t, cfg.Mutations, 1)
require.NotNil(t, cfg.Mutations[0].Mock)
Expand All @@ -409,7 +409,7 @@ mutations:
object:
id: 1
`
_, err := Parse([]byte(yaml))
_, err := parse([]byte(yaml))
require.Error(t, err)
// type: none only allows mock: true, not mock: {object: ...}
assert.Contains(t, err.Error(), "validation")
Expand All @@ -425,7 +425,7 @@ queries:
- id: 1
array_js: "return [{id: 1}]"
`
_, err := Parse([]byte(yaml))
_, err := parse([]byte(yaml))
require.Error(t, err)
assert.Contains(t, err.Error(), "one source")
})
Expand All @@ -441,7 +441,7 @@ queries:
1,Alice
2,Bob
`
_, err := Parse([]byte(yaml))
_, err := parse([]byte(yaml))
require.Error(t, err)
assert.Contains(t, err.Error(), "filter")
assert.Contains(t, err.Error(), "csv")
Expand Down Expand Up @@ -487,7 +487,7 @@ queries:
path: /user
sql: SELECT * FROM users WHERE id = :id
`
_, err := Parse([]byte(yaml))
_, err := parse([]byte(yaml))
require.Error(t, err)
assert.Contains(t, err.Error(), "database.dsn")
})
Expand All @@ -501,7 +501,7 @@ queries:
object:
id: 1
`
cfg, err := Parse([]byte(yaml))
cfg, err := parse([]byte(yaml))
require.NoError(t, err)
assert.Empty(t, cfg.DSN())
})
Expand Down
2 changes: 1 addition & 1 deletion internal/config/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ var (
)

// formatValidationError converts a jsonschema ValidationError into a user-friendly message.
// It is the one entry point config.go (Parse) takes into this unit.
// It is the one entry point config.go (parse) takes into this unit.
//
//declscope:package
func formatValidationError(err *jsonschema.ValidationError) string {
Expand Down
Loading
Loading