diff --git a/.github/workflows/generate-check.yml b/.github/workflows/generate-check.yml new file mode 100644 index 0000000..b443759 --- /dev/null +++ b/.github/workflows/generate-check.yml @@ -0,0 +1,36 @@ +name: generate-check + +# The plugin directories are generated from core/ and lang/. Every PR must keep +# them identical to the rendering, keep the generated gate passing its fixture +# matrix, and keep the generator itself green. +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: tools/ldd-gen/go.mod + - uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Plugin directories match their rendering + run: task check + - name: No hard residue in core and the Residue section is current + run: task lint-core + - name: Generated gate passes its fixture matrix + run: task test-gate + - name: Documentation gate + run: task docs:check + - name: Generator tests + working-directory: tools/ldd-gen + run: go test ./... + - uses: golangci/golangci-lint-action@v8 + with: + working-directory: tools/ldd-gen diff --git a/CLAUDE.md b/CLAUDE.md index 3f23e04..22a35cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,9 @@ +## Generated plugin +`go-linter-driven-development/` is generated from `core/` and `lang/go/` by +`tools/ldd-gen`. Never edit it by hand: edit the sources, run `task generate`, and +commit both. `task check` fails on any drift. The templating contract and the list of +files that stay Go-only are in core/README.md. + ## Documentation @AGENTS.md @docs/index.md diff --git a/README.md b/README.md index 5a6ed4a..1dbcdac 100644 --- a/README.md +++ b/README.md @@ -66,14 +66,14 @@ Team members then install with the same `/plugin install` commands above. ## Developing the Plugins -1. Clone the repo and edit the plugin files (`rules/`, `skills/`, `agents/`, `commands/`). +1. Clone the repo. The Go plugin directory is generated: edit the sources under `core/` (language-neutral text) and `lang/go/` (the Go binding), then run `task generate` and commit both. `task check` fails when the plugin directory drifts from its sources. How the pieces fit: [core/README.md](core/README.md) and [docs/generator.md](docs/generator.md). 2. Test locally by adding the checkout as a marketplace: ``` /plugin marketplace add ./ai-coding-rules /plugin install go-linter-driven-development@ai-coding-rules ``` After changes, uninstall/reinstall the plugin to pick them up. -3. For the Go plugin, follow its architecture contract — each fact lives once: rule content goes in `rules/`, worked case studies in `examples/`, skills only sequence and route. See the [plugin README](go-linter-driven-development/README.md#architecture-rules-as-data). +3. For the Go plugin, follow its architecture contract — each fact lives once: rule content goes in `core/rules/`, worked case studies in `lang/go/passthrough/examples/`, skills only sequence and route. See the [plugin README](go-linter-driven-development/README.md#architecture-rules-as-data). 4. Behavior changes to the Go plugin are measured, not eyeballed: behavioral evals run it on a deliberately bad fixture project and compare against a recorded baseline. Start at [docs/index.md](docs/index.md) — the harness, the fixture, how to write a case, the runner, and how to read a baseline. The cases, fixture, runner and baselines live in [buzzdan/ldd-evals](https://github.com/buzzdan/ldd-evals); `scripts/evals.sh` runs them against this checkout. 5. Open a PR; releases are tagged per plugin (e.g. [`go-ldd-v2.0.0`](https://github.com/buzzdan/ai-coding-rules/releases/tag/go-ldd-v2.0.0)). diff --git a/Taskfile.yaml b/Taskfile.yaml index bc00df4..59bcc27 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -1,6 +1,45 @@ version: '3' +# The Go plugin directory is generated: edit core/ and lang/go/, then run +# `task generate` and commit both. CI runs `task check`, `task lint-core`, `task docs:check` +# and `task test-gate`, plus the generator's tests and linter. + tasks: + generate: + desc: Render core/ + lang// into its plugin directory (BINDING=go) + dir: tools/ldd-gen + vars: + BINDING: '{{.BINDING | default "go"}}' + cmds: + - go run . -root {{.ROOT_DIR}} -lang {{.BINDING}} + + check: + desc: Fail when any plugin directory on disk differs from its rendering + dir: tools/ldd-gen + cmds: + - go run . -root {{.ROOT_DIR}} -check + + lint-core: + desc: Report language residue in core/, refresh the Residue section of core/README.md, fail on hard hits or a stale section + dir: tools/ldd-gen + cmds: + - go run . lint-core -root {{.ROOT_DIR}} -write + - | + git -C {{.ROOT_DIR}} diff --exit-code -- core/README.md \ + || { echo "lint-core - the Residue section of core/README.md was rewritten; commit it"; exit 1; } + + test-gate: + desc: Run the repo-brain gate's fixture matrix against the generated gate script + cmds: + - bash go-linter-driven-development/scripts/check-repo-brain_test.sh + + test-gen: + desc: Unit tests and lint for the generator + dir: tools/ldd-gen + cmds: + - go test ./... + - golangci-lint run + docs:check: desc: Run the repo-brain documentation gate over this repository (pass --fix after --) cmds: diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..7cf2bc4 --- /dev/null +++ b/core/README.md @@ -0,0 +1,132 @@ +# Core + +`core/` holds the language-neutral text of the linter-driven-development plugins: +rules, skills, agents, commands and the repo-brain gate, written once and rendered +per language. `lang//` holds one binding per language: a `profile.yaml` with +the scalars the templates substitute, the snippet files they include, whole-file +overrides, and `passthrough/` for files copied into the plugin unchanged. + +`tools/ldd-gen` renders `core/` plus a binding into the plugin directory the +marketplace serves. Edit sources here or under `lang/`, run `task generate`, and +commit both; `task check` fails when a plugin directory differs from its rendering. + +## Templating + +Go `text/template` with the default delimiters. Core files use only two constructs: + +- `{{.Plugin}}`, `{{.Lang}}`, `{{.CmdPrefix}}`, `{{.SrcGlob}}`, `{{.TestGlob}}`, + `{{.ProjectMarker}}`, `{{.Nolint}}`, `{{.CommentPrefix}}`, `{{.DefaultTest}}`, + `{{.DefaultLint}}`, `{{.DefaultLintFix}}` — scalars from `profile.yaml`. +- `{{include "rules/R1/canonical-example.md"}}` — the body of that file under + `lang//`, with exactly one trailing newline removed. + +File-level rules the generator applies without template syntax: + +- `lang//overrides/` replaces the core file of the same path (the + path as written in `core/`, before file-name templating). The override is rendered as + a template like the file it replaces and carries its own executable bit. An override + with no matching core file is an error. +- File names are templates too: `core/commands/{{.CmdPrefix}}-analyze.md` renders + to `commands/go-ldd-analyze.md` for the Go binding. The profile's `cmd_prefix` is + limited to lower-case letters, digits and dashes, and a rendered path that is not a + clean path inside the plugin is an error. `commands/wire-repo-brain.md` carries no + prefix, so two installed bindings would both offer `/wire-repo-brain`; the second + binding decides whether that command gets the prefix. +- This README is documentation for `core/` itself and is never rendered. Editor and OS + droppings (`.DS_Store`, `._*`, `*.swp`, `*~`, `.#*`, `Thumbs.db`, `desktop.ini`) and + any file or directory whose name the profile's `ignore` lists without a slash are never + read as sources either. +- `task generate` refuses to write when two bindings name the same plugin, when the + rendering has no named `.claude-plugin/plugin.json`, or when the target directory + already holds files and its manifest names a different plugin. A wrong `plugin` name + in a profile therefore cannot delete another plugin or any other directory. + +Include files carry no leading or trailing blank lines; the surrounding template +owns them. That is what keeps a rendered file byte-identical to a hand-written one. + +## What stays in the binding + +Some Go-owned files have no portable text worth extracting, or their seams are not +clear yet. They sit under `lang/go/passthrough/` and are copied into the plugin +unchanged. Promotion into `core/` happens when a second language needs the text: + +- `skills/documentation/reference.md`: the Comment Value Toolbox is portable + guidance, the godoc menus and testable-example template are Go, and the templates + after them are portable but every worked example is Go code. +- `scripts/check-repo-brain_test.sh`: the fixture matrix is the language adapter's + contract, but besides the conformant fixture several cases embed Go source, + `go.mod` files and Go-specific path patterns. +- `skills/testing/reference.md` and `skills/testing/examples/`: a Go test-harness + catalogue. +- `examples/`: worked case studies written as Go code. +- `README.md`, `CHANGELOG.md`, `.claude-plugin/plugin.json`, `hooks/`: describe or + configure the Go plugin itself. + +## Residue + +`task lint-core` scans `core/` for language-specific text and rewrites this section. +Hard residue is a plugin-name or command-prefix literal, a `golangci` reference, a +source-file glob or a nolint directive: each has a profile scalar or an include, so a +hit is a missed substitution and fails the check. Soft residue is Go vocabulary that +reads fine in a Go plugin (`nil`, `ctx`, goroutines, `interface`, linter and library +names such as `exhaustive` or `testify`); it is the list of words a second language +binding must decide how to render, by scalar, include or whole-file override. + + +Hard residue: none. No plugin-name or command-prefix literal, golangci +reference, source-file glob or nolint directive is left in core/. + +Soft residue by token (212 lines): + +| Token | Lines | Files | +|---|---:|---:| +| interface | 45 | 12 | +| .go suffix | 30 | 8 | +| godoc | 22 | 6 | +| goroutine | 18 | 5 | +| nil | 18 | 6 | +| ctx | 15 | 4 | +| struct | 13 | 9 | +| Go linter name | 12 | 5 | +| context. | 12 | 5 | +| httptest | 10 | 3 | +| Go (the word) | 7 | 5 | +| sync. | 7 | 4 | +| Go library | 6 | 3 | +| func | 5 | 3 | +| init() | 4 | 2 | +| wantErr | 4 | 3 | +| pkg_test | 3 | 2 | +| Go code fence | 2 | 1 | +| go test / go vet | 2 | 2 | + +Soft residue by file (212 lines): + +| File | Lines | Tokens | +|---|---:|---:| +| `rules/R10-concurrency-safety.md` | 29 | 8 | +| `rules/R11-conditional-dispatch.md` | 20 | 5 | +| `rules/R6-test-only-interfaces.md` | 18 | 3 | +| `skills/pre-commit-review/SKILL.md` | 17 | 7 | +| `rules/R2-self-validating-types.md` | 16 | 4 | +| `rules/R8-no-globals.md` | 11 | 5 | +| `rules/R9-repo-brain.md` | 11 | 4 | +| `skills/testing/SKILL.md` | 11 | 6 | +| `rules/R5-vertical-slice.md` | 10 | 1 | +| `rules/R7-test-placement.md` | 10 | 7 | +| `skills/code-designing/SKILL.md` | 10 | 5 | +| `skills/documentation/SKILL.md` | 8 | 3 | +| `maxims.md` | 7 | 3 | +| `skills/refactoring/SKILL.md` | 7 | 6 | +| `agents/rule-hunter.md` | 5 | 1 | +| `skills/refactoring/reference.md` | 5 | 5 | +| `skills/linter-driven-development/SKILL.md` | 4 | 4 | +| `agents/comment-critic.md` | 3 | 1 | +| `commands/{{.CmdPrefix}}-analyze.md` | 2 | 2 | +| `rules/R12-mutation-discipline.md` | 2 | 1 | +| `rules/R4-helper-placement.md` | 2 | 3 | +| `agents/lint-fixer.md` | 1 | 1 | +| `agents/overabstraction-skeptic.md` | 1 | 1 | +| `commands/wire-repo-brain.md` | 1 | 1 | +| `rules/R1-primitive-obsession.md` | 1 | 1 | + diff --git a/core/agents/comment-critic.md b/core/agents/comment-critic.md new file mode 100644 index 0000000..2c91d40 --- /dev/null +++ b/core/agents/comment-critic.md @@ -0,0 +1,121 @@ +--- +name: comment-critic +description: | + WHEN: Spawned programmatically by the documentation skill (after it writes godocs + and feature docs) and by the pre-commit-review skill (when the diff contains + comment lines), receiving a payload — R9's comment policy section and the Comment + Value Toolbox catalog — pasted into the spawn prompt. + Not auto-triggered by user requests. + Read-only adversarial reviewer with a single obsession: comment value. Judges + every comment in the diff against the three-test standard (toolbox-value, tier + budget, plain English); every non-KEEP verdict names the toolbox item the + replacement should deliver. +tools: + - Read + - Grep + - Glob + - Bash +--- + +You are the comment critic. Writers produce comments; your job is to make each one +prove it earns its lines. A comment survives you only by passing all three tests. + +**Inputs (in your spawn prompt):** the diff scope (changed-file list or diff +range), plus your payload — R9's comment policy section (the Comment Value +Toolbox kinds, the three-test standard, the tier table and budget accounting) and +the toolbox catalog with worked examples. The payload is your entire doctrine; +apply it, never improvise your own standard. + +**Read-only:** Bash is for inspection only — `git diff`, grep. Never edit. + +**Scope:** EVERY comment in the diff — godoc comments, in-body comments, and test +comments. Directives ({{include "agents/comment-critic/directives.md"}}) are not comments; skip +them. + +**Critique protocol, per comment:** +1. Read the comment BEFORE the surrounding code, and note whether you understood + it standing alone. This ordering is itself the self-standing half of test 3: + a comment you only understood after reading the code fails ("if I need to + read the code to understand the comment, the comment adds negative value"). +2. Classify the symbol's tier (helper / contract / crossroads) from its role in + the code — Read the surrounding code, don't guess from the name. +3. Run the three tests from the payload, in order: toolbox-value (floor per line, + then ceiling for the whole comment against the tier), budget, plain English + + self-standing (the empathy test — judge it for a fresh graduate whose first + language may not be English; unexplained acronyms and insider jargon fail). +4. For any failure, decide the smallest verdict that fixes it: cut lines (TRIM), + replace content (REWRITE), or remove entirely (DELETE). +5. Every TRIM/REWRITE ships the proposed replacement text, and the proposal names + the toolbox item it delivers ("swap narrated implementation for the boundary + contract this parsing constructor needs"). A bare "too long" is not a verdict. + +**Provenance is not value (the 5-year reader lens):** PR numbers, review-item +citations, "the previous behavior" narration, and "matching what +did" fail the floor even when the surrounding WHY is good — a reader five years +out cares how the product behaves now, not which review round shaped it. Verdict +TRIM (cut the provenance tail) or REWRITE (restate the history as present-tense +rationale: "silently picking one of the TLS options could apply a mode the +caller did not ask for"). An incident/ticket reference survives only when it IS +the rationale for a constraint. + +**Decoder-ring references are provenance in a different costume:** +plan/decision/test-plan IDs ("T-04-02", "D-07"), requirement tags +("REQ-SVC-01"), spec section refs ("spec §4"). They fail even when the token +resolves inside a repo doc — a reader without the decoder ring gets nothing. +REWRITE: the fact as plain prose, the doc via one trailing See-edge, the ID +gone. + +**Jargon in the symbol name:** your verdicts are about comments, and a rename +is not yours to order. But when the empathy test fails because the jargon +lives in the symbol name itself ("DTO", "mgr"), say so — append a +`note: symbol name carries the jargon — recommend rename (e.g. userDTO → +userResponse)` line to the verdict block so the caller can route it. + +**Repo idiom is not a WHY:** before crediting a rationale that justifies a +mechanical pattern (a pointer field for "omitted vs explicit zero", the +standard error-wrapping style), grep the repo for the same pattern. If it +appears across packages uncommented, this comment restates a repo-wide +convention — verdict DELETE; the convention's home is the coding-standards doc, +not a use site. + +**Unexported symbols: the question is existence, not size.** For a comment on +an unexported function, type, constant, or variable, the default verdict is +DELETE — the name should carry it, and a name that cannot is an R3 +rename/extraction lead, not a comment's job. The comment survives only as +**ONE line delivering a very high-value toolbox item** (an ordering +constraint, an external library quirk, the WHY of a magic number, the +package's one real policy). A multi-line private comment is TRIMmed down to +that one line only when such a line exists in it; otherwise DELETE. Never +propose growing a private comment. When the spawn prompt carries the +private-comment-noise case-file path, Read it for nine worked verdicts. + +**Review-defense narration is not a WHY:** lines that argue with an imagined +reviewer ("bounds-checked: it never indexes an empty slice", "deliberately +narrow — not a generalized table") fail the floor. The code shows its own +safety; a design choice worth defending is defended in the feature doc. + +**Boundary with R3:** an in-body comment that names what the next block does is an +extraction candidate, not a rewrite candidate — verdict `DELETE → route R3` +(the fix is a function named after the comment, which is R3-storifying's +territory, not yours). + +**Boundary with R9's Q5:** you judge comments that exist. A naked exported symbol +with no comment at all is Q5's finding, not yours — do not invent ADD verdicts. + +**Verdict schema — one block per comment:** + +``` +file:line | kind (godoc/in-body/test) | KEEP / TRIM / REWRITE / DELETE (/ DELETE → route R3) + evidence: + proposal: +``` + +End with a tally line: `critic: reviewed — KEEP · TRIM · REWRITE · DELETE`. +A fully clean diff still reports the tally (`critic: 12 reviewed — 12 KEEP`). + +**Bias statement:** you exist because comment noise burns reviewer attention — the +reader pays for every line. When uncertain whether a line delivers a toolbox +value, it fails: the writer already had its chance, and a deleted mediocre comment +costs nothing while a shipped one taxes every future reader. But the ceiling test +cuts the other way too: do not reward a short comment that dodged its symbol's one +important fact — a crossroads without its WHY is a REWRITE, not a KEEP. diff --git a/core/agents/lint-fixer.md b/core/agents/lint-fixer.md new file mode 100644 index 0000000..74085aa --- /dev/null +++ b/core/agents/lint-fixer.md @@ -0,0 +1,44 @@ +--- +name: lint-fixer +description: | + WHEN: Spawned programmatically by the linter-driven-development skill in Phase 3 to + run the lint-fix loop in an isolated context, keeping the loop's token noise out of + the main conversation. Not auto-triggered by user requests. + Fixes mechanical lint issues; escalates complexity/design failures with a rule + route instead of redesigning. +tools: + - Bash + - Read + - Edit + - Grep +--- + +You are the lint fixer: a mechanic, not a designer. + +**Loop:** +1. Run the linter: `task lintwithfix` if a Taskfile/Makefile defines it, else + `{{.DefaultLintFix}}`. +2. Read the remaining issues. Classify each: mechanical → fix it; design → escalate. +3. Apply targeted mechanical fixes (Read the site first, Edit minimally). +4. Re-run. Repeat until green or only escalations remain. If two consecutive runs + show no progress, stop and escalate what's left — do not thrash. + +**Escalation contract (the core of this job):** {{include "agents/lint-fixer/mechanical-issues.md"}} Complexity and design failures you do NOT redesign — refactoring is +a design act that belongs to the main context. Return them as escalations routed by +this table: + +{{include "agents/lint-fixer/routing-table.md"}} + +**Hard limits:** +{{include "agents/lint-fixer/hard-limits.md"}} + +**Report format:** +``` +FIXED: x , ... +ESCALATED: at , ... +LINT STATUS: green | escalations pending () +``` + +FIXED counts every issue resolved since the first run — including those the +linter's `--fix` pass auto-fixed (diff the first run's issue list against the +final one), not only your hand edits. diff --git a/core/agents/overabstraction-skeptic.md b/core/agents/overabstraction-skeptic.md new file mode 100644 index 0000000..6160514 --- /dev/null +++ b/core/agents/overabstraction-skeptic.md @@ -0,0 +1,50 @@ +--- +name: overabstraction-skeptic +description: | + WHEN: Spawned programmatically by the pre-commit-review skill after hunters report, + receiving the type/package-extraction findings plus a payload (R1's juiciness + scorecard and the CIDR over-abstraction case file) pasted into the spawn prompt. + Not auto-triggered by user requests. + Read-only devil's advocate: tries to kill each proposed extraction; every + refutation must ship a cheaper alternative. +tools: + - Read + - Grep + - Glob + - Bash +--- + +You are the over-abstraction skeptic. Hunters propose type/package extractions; your +job is to KILL each one. An extraction survives you only by earning its score. + +**Inputs (in your spawn prompt):** the extraction findings under review, plus your +payload — the juiciness scorecard and a worked rejection case file. The payload is +your entire doctrine; apply it, never improvise your own scoring. + +**Read-only:** Bash is for inspection only — `git diff`, grep counts. Never edit. + +**Refute-by-scorecard protocol, per finding:** +1. Verify the hunter's claims before granting points: Grep the actual usage count, + Read the proposed type's would-be call sites. Unverified claims score zero. +2. Score the proposed extraction against the pasted scorecard. Score 0-1 → REFUTED. +3. Check the payload's over-abstraction trap signals (a lone method that merely + unwraps, no invariant made unrepresentable, ceremony over clarity). Any signal + present → argue it explicitly in the verdict. + +**The refinement (mandatory):** a refutation is never a bare "no". Name the need the +proposal was groping toward, then meet it more cheaply — better naming when the need +is clarity; private fields + accessors when the real need is controlled mutation +rather than validation or logic. The case file in your payload is the template for +what a correct refutation looks like. + +**Verdict schema — one line per finding:** +- `CONFIRMED (score N: )` +- `REFUTED (score N: ) → cheaper alternative: ` + +**Bias statement:** you exist to prevent wrap-every-string over-extraction. On a +marginal score, lean REFUTED — a missed extraction is cheaper to fix later than a +premature one is to unwind. Your doctrine has names — cite them in verdicts where +they carry the argument: *"duplication is far cheaper than the wrong abstraction"* +(Sandi Metz), *"you aren't gonna need it"* (XP), *"a little copying is better than a +little dependency"* and *"the bigger the interface, the weaker the abstraction"* +(Rob Pike). A named principle is an argument; "feels unnecessary" is not. diff --git a/core/agents/rule-hunter.md b/core/agents/rule-hunter.md new file mode 100644 index 0000000..dd7dc8e --- /dev/null +++ b/core/agents/rule-hunter.md @@ -0,0 +1,58 @@ +--- +name: rule-hunter +description: | + WHEN: Spawned programmatically by the pre-commit-review skill — one hunter per rule, + in parallel — with a full rule file (rules/R*.md) pasted into the spawn prompt. + Not auto-triggered by user requests. + Read-only, single-obsession reviewer: hunts violations of exactly one rule across a + diff scope and returns evidence-backed findings. +tools: + - Read + - Grep + - Glob + - Bash +--- + +You are a rule hunter with exactly one obsession. + +**Inputs (in your spawn prompt):** ONE rule file pasted in full (your entire rulebook), +a diff scope (changed-file list or `git diff` range), and pre-filter grep hits as +starting leads. Your obsession is that rule; ignore every other concern — other +hunters own them. Never report a violation of a rule you were not given. + +**Read-only:** Bash is for inspection only — `git diff`, `git log`, and the rule's +detection commands. Never edit files, never run tests or fixers. + +**Method:** +1. Interrogate each pre-filter lead with the rule's falsifying questions. +2. Hunt beyond the leads: run the rule's detection commands yourself across the full + diff scope — the pre-filter is a lead generator, not a limit. +3. When uncertain whether a lead meets the violation criterion, Read a case file the + rule cites (the spawn prompt resolves cited case files to absolute paths) and compare + against it. + +**Evidence protocol:** A finding exists only when a falsifying question is answered +with evidence — `file:line` plus the offending code excerpt or command output. No +verdicts without evidence. If evidence is absent, there is no finding. Never justify +a finding by a design maxim or general principle ("tell don't ask", "law of +Demeter") — maxims propose, evidence disposes; only your rule's detection commands +convict. + +**Output — one block per finding:** +`rule | file:line | evidence (falsifying-question answers) | proposed fix pattern (named from the rule's Fix pattern section) | effort (S/M/L)` +Final line always: `R: finding(s)`, or when clean: +`R: hunted clean — leads checked, detection commands run across full scope`. + +**Worked example (analysis style only — your pasted rule governs the substance):** +``` +Lead (pre-filter): user/service.go:14 matched inline check on a domain primitive. +Q1 (rule): validated inline instead of via a constructor? + Read user/service.go:10-16 → `if !strings.Contains(email, "@") { return errors.New(...) }` + → YES: domain concept checked in a service method, no ParseX/NewX owns it. +Q2 (rule): same predicate enforced elsewhere? + Grep: `strings.Contains(email, "@")` --include='{{.SrcGlob}}' + → user/repository.go:45 — second copy. Two owners of one rule. +Finding: +R | user/service.go:14 | inline domain validation; duplicate predicate at +user/repository.go:45 (Q1: yes, Q2: 2 hits) | Replace Primitive with Domain Type | M +``` diff --git a/core/commands/wire-repo-brain.md b/core/commands/wire-repo-brain.md new file mode 100644 index 0000000..18175eb --- /dev/null +++ b/core/commands/wire-repo-brain.md @@ -0,0 +1,54 @@ +--- +name: wire-repo-brain +description: Wire the full documentation network in one pass — code comments → docs → index.md → CLAUDE.md +argument-hint: "[path to repo or sub-project root (default: cwd)]" +allowed-tools: + - Read + - Grep + - Glob + - Bash + - Write + - Edit + - Skill({{.Plugin}}:documentation) +--- + +Wire this repo's **repo brain** end to end, in a single pass. + +Invoke `Skill({{.Plugin}}:documentation)` and run its **BOOTSTRAP +mode** against `$ARGUMENTS` (default: the current repo root). The skill's protocol is +authoritative; this command adds nothing to it. One pass delivers the whole chain: + +1. Doc root discovered (`.ai/` → `.ainav/` → `docs/`; per sub-project in a monorepo) +2. Existing docs inventoried and classified (stale docs indexed with a ⚠️ flag); + OKF frontmatter verified-or-added on content docs, stripped from indexes + (un-inferable types reported) +3. `index.md` built — short, grouped, one line per doc copied from each doc's + `description`; the root index carries only `okf_version` + (directory-shaped map of maps past ~300 lines) +4. AGENTS.md routing block authored once (root, and nested per sub-project in a + monorepo); CLAUDE.md embeds it (`@AGENTS.md`) + the `@/index.md` import +5. `/conventions.md` created/verified (listed first in the index) and the + plugin's `scripts/check-repo-brain.sh` installed — the report suggests the CI + one-liner +6. **Upward edges wired**: every confidently-anchorable doc gets its one-line + `{{.CommentPrefix}} See /.md ...` edge on its front-door symbol +7. R9 confirmation pass — Q1–Q3 and Q7 via the installed script — + the advisory + findings report (broken edges, edge-policy violations, rung-2 gaps, + stale/unwired docs, types needing a human call) + +**What this command does NOT do** (by design — the skill's constraints): +- Generate or rewrite content docs — gaps are reported for FEATURE mode to fill + (conventions.md and the copied check script are the two sanctioned artifacts) +- Decide the fate of stale docs — refresh / remove / keep-as-roadmap is your call +- Add CI workflows — the report only suggests `bash scripts/check-repo-brain.sh` +- Touch anything beyond doc files, `index.md`, `conventions.md`, + CLAUDE.md/AGENTS.md, the copied check script, and one-line godoc edge additions + {{include "commands/wire-repo-brain/edge-verify.md"}} + +{{include "commands/wire-repo-brain/language-scope.md"}} + +When it finishes, review the report, then `git diff` — the changes should read as +pure documentation-network wiring. Re-run any time: the pass is idempotent (existing +index lines are refreshed from frontmatter; existing edges, wiring, conventions, and +the script are verified, not duplicated — a repo wired by an older plugin version +converges to the current rules in one pass). diff --git a/core/commands/{{.CmdPrefix}}-analyze.md b/core/commands/{{.CmdPrefix}}-analyze.md new file mode 100644 index 0000000..f4fd772 --- /dev/null +++ b/core/commands/{{.CmdPrefix}}-analyze.md @@ -0,0 +1,101 @@ +--- +name: {{.CmdPrefix}}-analyze +description: Run quality analysis only - tests + plain lint + hunter/skeptic review, combined report, no auto-fix +argument-hint: "[file_pattern]" +allowed-tools: + - Read + - Grep + - Bash + - Agent + - Skill({{.Plugin}}:pre-commit-review) +--- + +Run comprehensive quality analysis: tests, a report-only linter pass, and the +hunter/skeptic design review — combined into one report, with NO changes to your code. + +> **🔍 READ-ONLY COMMAND** +> This command performs analysis only and makes NO changes to your code. +> For auto-fix capability, use `/{{.CmdPrefix}}-quickfix` instead. + +Execute these steps: + +## Step 1: Discover Project Commands + +Search project documentation to find test and lint commands: + +{{include "commands/analyze/discover-commands.md"}} + +## Step 2: Identify Files to Analyze + +!`git status --porcelain` +!`git diff --name-only --diff-filter=ACMR HEAD` + +{{include "commands/analyze/file-scope.md"}} + +## Step 3: Run the Three Quality Gates (report-only) + +1. **Tests**: `Bash([discovered test command])` +2. **Linter (report-only)**: `Bash([discovered lint command, no --fix])` — surfaces + what needs refactoring without changing anything. (The `lint-fixer` agent, which + auto-fixes, is intentionally NOT used here — this command never edits.) +3. **Design review**: invoke `Skill({{.Plugin}}:pre-commit-review)` + in FULL mode over the file scope. It grep-prefilters the diff against rules R1–R12, + spawns one parallel `rule-hunter` per rule with hits, runs the + `overabstraction-skeptic` over every type/package-extraction proposal, and returns + evidence-backed findings. It reports — it never edits. + +## Step 4: Display Combined Report + +Merge the three gates into one report: + +- ✅/❌ **Tests**: pass/fail status with coverage +- ✅/❌ **Linter**: clean / error count (with file:line and the failing linter) +- ✅/⚠️ **Review**: clean / findings, categorized as the pre-commit-review report returns them: + - 🐛 **Bugs** — fail at runtime regardless of rule (incl. R10 goroutine leaks and unguarded concurrent writes) + - 🔴 **Design Debt** — R1, R2, R4, R5, R6, R7, R8, R10's non-crash findings, R11, R12 (advisory) + - 🟡 **Readability Debt** — R3, R9, unclear naming + - 🟢 **Polish** — minor idiomatic improvements, the skeptic's cheaper alternatives +- 🎯 **Clustered issues**: where a linter failure and a review finding land at the same + file:line, note the shared root cause and the single fix that resolves both. + +Each finding carries evidence (`file:line` + the falsifying-question answer or command +output) and cites the owning rule's Fix pattern (`rules/R*.md`) for HOW to fix — this +command does not apply the fix. + +## Example Usage + +```bash +# Analyze all changed files (default) +/{{.CmdPrefix}}-analyze + +# Analyze specific package +/{{.CmdPrefix}}-analyze ./pkg/parser/ + +# Analyze specific file +/{{.CmdPrefix}}-analyze ./pkg/parser/parser.go +``` + +## Use Cases + +- ✅ Quick quality check before committing +- ✅ Understand what issues exist without making changes +- ✅ Get a combined view of tests + linter + design review +- ✅ See where a linter failure and a design finding share one root cause +- ✅ Identify high-impact fixes (multiple issues at the same location) + +## Comparison with Other Commands + +| Command | Purpose | Auto-Fix | Spawns agents | +|---------|---------|----------|---------------| +| `/{{.CmdPrefix}}-autopilot` | Complete workflow (Phases 1–5) | ✅ Yes | lint-fixer, rule-hunter, overabstraction-skeptic | +| `/{{.CmdPrefix}}-quickfix` | Quality-gates loop until green | ✅ Yes | lint-fixer, rule-hunter, overabstraction-skeptic | +| `/{{.CmdPrefix}}-review` | Commit-readiness check | ❌ No | rule-hunter, overabstraction-skeptic (report-only) | +| `/{{.CmdPrefix}}-analyze` | Tests + lint + review, combined report | ❌ No | rule-hunter, overabstraction-skeptic (report-only) | +| `/{{.CmdPrefix}}-status` | Show workflow status | N/A | none | + +## Notes + +- Read-only: no auto-fix, just analysis and reporting. +- For auto-fix capability, use `/{{.CmdPrefix}}-quickfix` instead. +- For a leaner commit-readiness pass, use `/{{.CmdPrefix}}-review` instead. +- For the complete workflow with design and implementation, use `/{{.CmdPrefix}}-autopilot`. diff --git a/core/commands/{{.CmdPrefix}}-autopilot.md b/core/commands/{{.CmdPrefix}}-autopilot.md new file mode 100644 index 0000000..181e145 --- /dev/null +++ b/core/commands/{{.CmdPrefix}}-autopilot.md @@ -0,0 +1,22 @@ +--- +name: {{.CmdPrefix}}-autopilot +description: Start complete linter-driven autopilot workflow (Phases 1-5, incl. the autonomous PREPARE sub-phase 1.5) +argument-hint: "" +allowed-tools: + - Skill({{.Plugin}}:linter-driven-development) +--- + +**Use the Skill tool** to invoke `Skill({{.Plugin}}:linter-driven-development)` to run the complete workflow — Phases 1–5 plus the autonomous PREPARE sub-phase (1.5) — from design through commit-ready. + +⏱️ **Estimated Duration**: 5-15 minutes (depends on feature complexity and issues found) + +The skill runs, in order: +1. **Pre-Flight** — verify {{.Lang}} project, discover test/lint commands, list the behaviors to deliver +2. **Phase 1 DESIGN** — @code-designing produces a DESIGN PLAN for your approval (no code before OK) +3. **Phase 1.5 PREPARE** — autonomous preparatory refactoring: survey the plan's touch points, four gates decide (multiply/safe/bounded/skeptic), reshape via @refactoring in its own commit(s) — no pause for approval +4. **Phase 2 IMPLEMENT** — per behavior: RED (one failing test) → GREEN (minimum code) → REFACTOR (package-scoped lint + rule greps → @refactoring) +5. **Phase 3 FULL LINT** — one full-repo run via the `lint-fixer` agent (isolated context); mechanical fixes done, design failures escalated back to Phase 2's REFACTOR via @refactoring +6. **Phase 4 REVIEW** — per completed slice, @pre-commit-review orchestrates parallel `rule-hunter` agents + the `overabstraction-skeptic`; advisory findings only +7. **Phase 5 SHIP** — @documentation, then a commit-ready summary you approve + +This is the full workflow — use for implementing features or fixes from start to finish. diff --git a/core/commands/{{.CmdPrefix}}-prepare.md b/core/commands/{{.CmdPrefix}}-prepare.md new file mode 100644 index 0000000..56f2d5c --- /dev/null +++ b/core/commands/{{.CmdPrefix}}-prepare.md @@ -0,0 +1,42 @@ +--- +name: {{.CmdPrefix}}-prepare +description: Preparatory refactoring — reshape the code a planned change will touch, so the feature lands add-only +argument-hint: " [files]" +allowed-tools: + - Skill({{.Plugin}}:refactoring) + - Skill({{.Plugin}}:code-designing) + - Skill({{.Plugin}}:testing) + - Agent +--- + +Run standalone preparatory refactoring (Fowler: "make the change easy, then make the +easy change") for a change you're about to implement — without entering the full +five-phase workflow. + +⏱️ **Estimated Duration**: 2–10 minutes (zero findings is the common case and takes seconds) + +**Input**: a description of the impending change ("add an SMS channel to alerts", +"extend the exporter with histogram support"), optionally scoped to files. No +description → ask for one; preparation without a change in hand is just cleanup and +belongs to `/{{.CmdPrefix}}-quickfix`. + +**Steps** (the gate definitions live in @linter-driven-development +`` — apply them from there, never from memory): + +1. **Locate touch points**: from the change description, identify the files, + functions, and packages the change will extend or integrate with (grep for the + named concepts; when ambiguous, invoke @code-designing briefly to sketch the + landing zone). +2. **Survey**: run the rule detection greps (`../rules/R*.md` Falsifying questions) + scoped to the touch points. +3. **Gate autonomously**: MULTIPLY → SAFE → BOUNDED → SKEPTICIZED, per + @linter-driven-development ``. No user questions — the gates + decide; deferred findings are reported, not asked about. +4. **Apply survivors** via `Skill({{.Plugin}}:refactoring)` in + ``: characterization tests first on uncovered paths, full suite + green after every move, prep work in its own commit(s). +5. **Emit the PREPARATION LOG** and state the landing shape ("adding the SMS channel + is now one new file + one ParseChannel case"). + +Use this before starting work on a known change. For fixing code that already fails +gates, use `/{{.CmdPrefix}}-quickfix`; for a read-only assessment, `/{{.CmdPrefix}}-analyze`. diff --git a/core/commands/{{.CmdPrefix}}-quickfix.md b/core/commands/{{.CmdPrefix}}-quickfix.md new file mode 100644 index 0000000..d1b91f0 --- /dev/null +++ b/core/commands/{{.CmdPrefix}}-quickfix.md @@ -0,0 +1,32 @@ +--- +name: {{.CmdPrefix}}-quickfix +description: Run quality gates loop until all green (tests+linter+review → fix → repeat) +argument-hint: "[file_pattern]" +allowed-tools: + - Skill({{.Plugin}}:linter-driven-development) +--- + +Execute the quality-gates loop for already-implemented code that needs cleanup. + +⏱️ **Estimated Duration**: 2-5 minutes (depends on number of issues found) + +**Use the Skill tool** to invoke `Skill({{.Plugin}}:linter-driven-development)`. Because the code already exists, it skips the design and TDD-implementation phases (Phases 1–2) and runs the quality gates until green: + +**Phase 3 — TESTS + FULL LINT** (via the `lint-fixer` agent, Agent tool, isolated context) +- Discover project test/lint commands (`task test` / `make test` / `{{.DefaultTest}}`; lint from Taskfile/Makefile or `{{.DefaultLintFix}}`) +- Run the discovered test command first; all tests must pass before the lint pass proceeds (a test failure is a fix target, not a skip) +- One full-repo lint run; mechanical issues are `FIXED` in place +- Design-level failures come back `ESCALATED` with a rule route +- Route each escalation through @refactoring (its `` maps linter failure → owning rule's Fix pattern); package-size escalations follow @refactoring `` +- Repeat until the agent reports `LINT STATUS: green` + +**Phase 4 — REVIEW** (via @pre-commit-review, per completed slice) +- @pre-commit-review orchestrates parallel `rule-hunter` agents + the `overabstraction-skeptic` against the diff; it reports, never edits +- Findings return categorized (Bugs / Design Debt / Readability Debt / Polish), all advisory +- 🔗 CLUSTER entries (≥2 rules converging on one anchor) are fixed design-first: @code-designing (cluster-scoped) produces one mini plan, @refactoring implements it — never member-by-member +- Fix bugs and user-accepted singleton findings via @refactoring, then re-invoke @pre-commit-review in INCREMENTAL mode + +**Loop until**: +✅ Tests pass | ✅ `LINT STATUS: green` | ✅ @pre-commit-review INCREMENTAL delta clean (or findings explicitly deferred) + +Use this when code is already written but needs to pass quality gates. It goes straight to fixing issues — no design or TDD implementation. diff --git a/core/commands/{{.CmdPrefix}}-review.md b/core/commands/{{.CmdPrefix}}-review.md new file mode 100644 index 0000000..8157755 --- /dev/null +++ b/core/commands/{{.CmdPrefix}}-review.md @@ -0,0 +1,38 @@ +--- +name: {{.CmdPrefix}}-review +description: Check if code is commit-ready (final verification, no auto-fix) +argument-hint: "[file_pattern]" +allowed-tools: + - Read + - Grep + - Bash + - Agent + - Skill({{.Plugin}}:pre-commit-review) +--- + +Run final verification checks **without** the auto-fix loop. + +> **🔍 READ-ONLY COMMAND** +> This command performs verification only and makes NO changes to your code. +> For auto-fix capability, use `/{{.CmdPrefix}}-quickfix` instead. + +!`git status --porcelain` +!`git diff --stat` + +Execute these steps: + +1. **Discover commands** from project docs (README, CLAUDE.md, Makefile, etc.) +2. **Run in read-only mode**: + - Tests: Bash([PROJECT_TEST_COMMAND]) + - Linter: Bash([PROJECT_LINT_COMMAND] **without `--fix`** — report only, e.g. `{{.DefaultLint}}`) + - Review: invoke `Skill({{.Plugin}}:pre-commit-review)` in FULL mode. It orchestrates parallel `rule-hunter` agents + the `overabstraction-skeptic` and reports — it never edits. +3. **Generate commit readiness report**: + - ✅/❌ Tests: [pass/fail] + coverage + - ✅/❌ Linter: [clean/errors] + - ✅/⚠️ Review: [clean/findings — Bugs / Design Debt / Readability Debt / Polish] + - 📝 Files in scope: [list with +/- lines] + - 💡 Suggested commit message + +**Does NOT auto-fix anything** — just reports current state. Every review finding is advisory. + +Use when you want to verify code is ready without making changes. This is the Phase 4 review plus a plain tests/lint pass, with no auto-fix loop. diff --git a/core/commands/{{.CmdPrefix}}-status.md b/core/commands/{{.CmdPrefix}}-status.md new file mode 100644 index 0000000..05b93f5 --- /dev/null +++ b/core/commands/{{.CmdPrefix}}-status.md @@ -0,0 +1,39 @@ +--- +name: {{.CmdPrefix}}-status +description: Show current workflow status and progress +argument-hint: "" +allowed-tools: + - Read + - Bash(git *) +--- + +!`git status --porcelain` +!`git diff --stat` + +Display current implementation status: + +📍 Current Context: + - Active plan: [file path or "conversation"] + - Current behavior/slice: [which behavior's TDD cycle, if mid-implementation] + - Phase: [pre-flight / 1 DESIGN / 1.5 PREPARE / 2 IMPLEMENT (RED→GREEN→REFACTOR) / 3 FULL LINT / 4 REVIEW / 5 SHIP] + +📊 Last Results: + Tests: [status + coverage] + Linter: [status + error count — lint-fixer FIXED/ESCALATED or LINT STATUS: green] + Review: [status + finding count — Bugs / Design / Readability / Polish] + +📝 Files Modified: + [list with +/- lines] + +🎯 Next Action: + [What happens next in the workflow] + +## Suggested Next Steps + +Based on current status: +- **Tests failing?** → Fix tests, then run `/{{.CmdPrefix}}-analyze` +- **Linter errors?** → Run `/{{.CmdPrefix}}-quickfix` for auto-fix loop +- **Code complete?** → Run `/{{.CmdPrefix}}-review` for commit readiness check +- **Starting new work?** → Run `/{{.CmdPrefix}}-autopilot` for full workflow + +Perfect for: "where are we?", "what's the status?", "what's next?" diff --git a/core/maxims.md b/core/maxims.md new file mode 100644 index 0000000..6aec575 --- /dev/null +++ b/core/maxims.md @@ -0,0 +1,253 @@ +# Maxims — the layer above the rules + +Rules (`rules/R1–R12`) are **compiled judgment**: each has a detection command, a +violation criterion, and a fix pattern — an agent can convict with evidence. A maxim +is the **question that generates such answers** in situations no rule anticipated. +"Tell, don't ask" existed before R11; R11 is what it looks like compiled for +kind-conditionals. This file holds the questions — both the ones already compiled +(with pointers to their rules) and the ones still uncompiled, waiting to earn +detection commands. + +## The contract: maxims propose, evidence disposes + +Maxims live where the plugin exercises **judgment**; they are banned where it must +produce **evidence**: + +- **@code-designing** interrogates the plan with these questions — design happens + before a diff exists, so questions are the only tool available there. +- **@refactoring's escalation path** uses them as vocabulary for *why* code resists + ("every caller asks this struct three questions and then decides — the design + wants Tell-Don't-Ask"). +- **The over-abstraction skeptic** cites the abstraction-economics maxims by name in + its verdicts. +- **Rule hunters NEVER cite maxims.** A finding exists only as a rule violation with + `file:line` evidence. A maxim may generate a hypothesis; only a rule's detection + command can convict. This is what keeps the review credible. + +**Graduation:** when a maxim keeps generating findings that no rule can express, that +is the signal to compile it — write the R-file, give it falsifying questions with +detection commands, and move its entry here from *uncompiled* to *compiled*. R11 and +R4's feature-envy question are graduates of exactly this path. + +--- + +## Behavior and ownership + +### Tell, don't ask +— Andy Hunt & Dave Thomas, *The Pragmatic Programmer* + +**Ask:** what will the caller *do* with the value it is requesting — and does that +decision belong on the type that owns the value? + +**Compiled into:** `rules/R11-conditional-dispatch.md` (asking what a value *is*), +`rules/R4-helper-placement.md` Q6 (feature envy — asking for data to decide with). + +### Talk to your friends, not your friends' friends +— The Law of Demeter (Karl Lieberherr) + +**Ask:** is this caller navigating a path (`a.B().C().D()`) it has no business +knowing? First apply Tell-Don't-Ask — moving the *behavior* usually dissolves the +chain. What survives is data egress at a boundary, where a one-shot adapter mapping +is the honest form. + +**Compiled into:** `rules/R4-helper-placement.md` — Q6 (feature envy) plus the +message-chain and middle-man bullets in its Fix pattern (Hide Delegate subordinated +to Tell-Don't-Ask; Remove Middle Man as the per-method ceremony verdict; the +domain-type embedding trap). Uncompiled residue: detection commands for +forward-heavy types and boundary-crossing chains — graduates to an R4 falsifying +question if the hunter keeps stumbling over them. + +## State and construction + +### Make illegal states unrepresentable +— Yaron Minsky + +**Ask:** can this type hold a value its methods would have to defend against? Delete +the possibility, not the symptom. + +**Compiled into:** `rules/R2-self-validating-types.md`, and its aliasing corollary +`rules/R12-mutation-discipline.md` (a leaked internal reference re-legalizes the +illegal state). + +### Parse, don't validate +— Alexis King + +**Ask:** does this check produce a *more-typed value* (`ParseX(raw) (X, error)`), or +just a boolean the next caller must remember? Validation that returns proof is +parsing; validation that returns advice is a latent re-check. + +**Compiled into:** `rules/R2-self-validating-types.md`; `rules/R3-storifying.md` +Split Phase (phase 1 as the parse). + +### Make the zero value useful +— Rob Pike, Go Proverbs + +**Ask:** could `var x T` just work (`bytes.Buffer`, `sync.Mutex`)? Held in +deliberate tension with R2: **mechanism types** earn zero-value usefulness; +**validated domain types** earn constructors — a type that needs invariants cannot +also promise a useful zero. Decide which family a new type is in; don't split the +difference. + +**Uncompiled** — lives here as a design-time question. + +## Abstraction economics + +### Every indirection must earn its keep +— this plugin's own synthesis (the generalized juiciness test) + +**Ask:** what does this indirection *own* — a validation, a decision, a second +production implementation, a deleted duplication, a real race, a real escaping +alias? If the answer is nothing, it is ceremony: delete it. + +**Compiled into:** every inverse trap in the rule set — one principle at six +granularities: type (R1's scorecard and ceremony wrappers), interface (R6's +earned-interface test), method (R4's middle-man bullet), dispatch (R11's unearned +abstractions), guard (R10's decorative mutexes), copy (R12's ceremony copies). The +`overabstraction-skeptic` is its enforcement agent. Nuance: the R1 scorecard is the +*prospective* form (score before the type is born); the inverse traps are the +*retrospective* form (this indirection exists — does it still own anything?). + +### Duplication is far cheaper than the wrong abstraction +— Sandi Metz + +**Ask:** is this extraction *earning* its indirection today, with the callers in +hand — or is it a bet on imagined futures? + +**Compiled into:** the `overabstraction-skeptic` agent (its bias statement is this +maxim), `rules/R1-primitive-obsession.md`'s inverse trap and scorecard. + +### You aren't gonna need it (YAGNI) +— Extreme Programming + +**Ask:** does anything *present* require this flexibility? + +**Compiled into:** `rules/R6-test-only-interfaces.md` (an interface is earned by a +second production implementation, never by "for the future"). + +### Three strikes and you refactor +— Don Roberts, via *Refactoring* + +**Ask:** how many real occurrences exist *right now*? One is an instance, two is a +coincidence, three is a pattern. + +**Compiled into:** `rules/R1-primitive-obsession.md` scorecard usage points. + +### A little copying is better than a little dependency +— Rob Pike, Go Proverbs + +**Ask:** does sharing these four lines couple two features that would otherwise +evolve independently? Promotion to a shared package is a *dependency*, and +dependencies cost more than duplication until the third strike. + +**Compiled into:** partially the skeptic + `rules/R4-helper-placement.md`'s ladder. +Uncompiled residue: the explicit copy-first default for tiny cross-feature helpers. + +### The bigger the interface, the weaker the abstraction +— Rob Pike, Go Proverbs + +**Ask:** could this interface be one method (`io.Reader`)? Would a consumer with +half the methods still satisfy every caller? + +**Compiled into:** partially `rules/R6-test-only-interfaces.md` ("small and +cohesive"). Uncompiled residue: earned interfaces that accrete methods. + +## Process and economics + +### Make the change easy, then make the easy change +— Kent Beck + +**Ask:** does the code's current shape fight the change in hand? Reshape first, in +its own commit — bounded by what the change touches. + +**Compiled into:** @linter-driven-development `` and +@refactoring ``. + +### If a test is hard to write, the design is wrong +— Steve Freeman & Nat Pryce, *Growing Object-Oriented Software* + +**Ask:** what is the test's pain telling you? Huge fixtures → the unit is too big; +global mutation → a seam is missing; mocks everywhere → the boundaries are wrong. +Never silence test pain with test machinery. + +**Compiled into:** the RED-friction escape hatch (@linter-driven-development +Phase 2), `rules/R6-test-only-interfaces.md`, `rules/R7-test-placement.md`. + +### If it hurts, do it more often +— Martin Fowler + +**Ask:** is this pain a batch-size problem? Deferred checks compound; per-cycle +checks stay trivial. + +**Compiled into:** the five-phase cadence itself — package-scoped lint every +RED→GREEN→REFACTOR cycle instead of one bulk reckoning at the end. + +### Premature optimization is the root of all evil +— Donald Knuth + +**Ask:** is there a profile? Clarity first; optimize the measured 3%, never the +imagined 30%. (R12's ceremony-copy inverse is one instance: don't "optimize" *or* +"defend" without evidence of need.) + +**Uncompiled** — no rule owns profile-first optimization discipline yet. + +### When in Rome, code as the Romans do +— folk wisdom; sharpened by a repo owner's hard review of a large generated PR + +**Ask:** does this diff arrive in the host repo's existing style, or does it +import mine? A new test mechanism, dependency, framework, or convention — however +good — is an *adoption decision* that belongs to the repo owner, not a side +effect of a feature PR. Strong opinions travel as a discussion or a separate PR, +never as a bundled surprise. Reviewers forgive imperfect code in the house style +far more readily than perfect code in a foreign one. + +**Compiled into:** @pre-commit-review's when-in-Rome check (step 1) and its +🟠 New Practice finding category. + +## Clarity and knowledge + +### Clear is better than clever +— Rob Pike, Go Proverbs + +**Ask:** will a reader get this in 10–15 seconds? Cleverness is a cost paid by every +future reader. + +**Compiled into:** `rules/R3-storifying.md` and the plugin's readability philosophy. + +### Empathy is a core engineering value +— borrowed from a reviewer's coding standards ("coding in a way that facilitates +maintenance by peers who may be less skilled or experienced") + +**Ask:** could a fresh graduate whose first language may not be English read this +comment — before reading the code — and understand it? If you need to read the +code to understand the comment, the comment adds negative value. Codenames, +unexplained acronyms, and cross-reference webs serve the writer's bookkeeping, +not the reader. + +**Compiled into:** `rules/R9-repo-brain.md`'s plain-English/empathy test (test 3 +of the three-test standard: persona, acronym ban, self-standing requirement) and +the comment-critic's read-comment-first protocol. + +### Once and only once +— Kent Beck (and DRY: "every piece of knowledge has a single, unambiguous, +authoritative representation" — Hunt & Thomas) + +**Ask:** how many places own this fact? Note it is about *knowledge*, not lines: +two similar-looking blocks encoding different decisions are not duplication, and +one decision spread across five switches is (R11) — count owners, not text. + +**Compiled into:** `rules/R1-primitive-obsession.md` Q2, +`rules/R11-conditional-dispatch.md`, and this plugin's own architecture contract +("one fact per fact"). + +## Architecture + +### Depend in the direction of stability +— Robert C. Martin + +**Ask:** which side of this boundary changes more often — and does the arrow point +from volatile to stable? A move that makes a stable package import a volatile +consumer is wrong even when it looks cleaner. + +**Compiled into:** `rules/R8-no-globals.md` (downward imports), +`examples/switch-to-polymorphism.md`'s dependency-direction rejection (via +`rules/R11-conditional-dispatch.md`). diff --git a/core/rules/R1-primitive-obsession.md b/core/rules/R1-primitive-obsession.md new file mode 100644 index 0000000..e7f2e07 --- /dev/null +++ b/core/rules/R1-primitive-obsession.md @@ -0,0 +1,101 @@ +# R1 — Primitive Obsession + +## Principle + +Domain concepts must not travel as raw `string`/`int`/`bool`/`[]T`. When a primitive +carries validation rules, behavior, or a domain name, it becomes a type with a +validating constructor and named methods. The inverse binds equally: a wrapper that +adds no validation, no logic, and no invariant is over-abstraction — score before you wrap. + +## Why + +A rule enforced on a primitive is enforced at every call site and owned by none: the +check gets duplicated, drifts, and is skipped exactly once — in the code path that +ships the bug. Logic trapped on primitives is also untestable in isolation: you must +construct whatever large object happens to hold the primitive. A domain type gives the +rule one owner (the constructor — see `R2-self-validating-types.md`), gives the +behavior a name, makes invalid values unrepresentable downstream, and turns the logic +into a leaf that unit-tests with literals. Where the extracted type then lives is +`R4-helper-placement.md`. + +## Canonical example + +{{include "rules/R1/canonical-example.md"}} + +## Design guidance + +A primitive should become a type when it has validation rules, has behavior attached, +represents a domain concept, is used in multiple places, or when passing an invalid +value would be a bug. When the call is not obvious, score it. + +### Juiciness scoring + +This scorecard lives here and only here — other rules and skills cite it, never +restate it. + +**Behavioral (rich behavior):** +- Complex validation (regex, ranges, business rules): +3 +- Multiple meaningful methods (≥2): +2 +- State transitions/transformations: +2 +- Format conversions: +1 + +**Structural (organizing complexity):** +- Parsing unstructured data into fields: +3 +- Grouping related data that travels together: +2 +- Making implicit structure explicit: +2 +- Replacing `map[string]interface{}`: +2 + +**Usage (simplifies code):** +- Used in 5+ places: +2 +- Used in 3-4 places: +1 +- Significantly simplifies calling code: +1 +- Makes tests cleaner: +1 + +**Verdict:** +- Score ≥4: HIGH priority — clear win, create the type. +- Score 2-3: MEDIUM priority — judgment call, present to the user. +- Score 0-1: LOW priority — do not create the type; that is over-engineering. + +### The over-abstraction trap + +The failure mode symmetric to primitive obsession is wrapping a primitive that has +nothing to own: no validation, no invariant, one method that merely unwraps. The +honest test: is `x.Field.IsSet()` *significantly* clearer than a well-named field or +accessor? If the real need is controlled mutation rather than validation or logic, +private fields with accessors beat a wrapper type. Deep worked case — the tried +extraction, the rejection rationale, and the cheaper alternatives: +`../examples/overabstraction-cidr.md`. + +### Placement + +A juicy type must also land in the right package — feature-scoped versus +domain-generic. That decision is `R4-helper-placement.md`; the canonical example's +Stage 2 shows it applied. + +## Fix pattern + +- **Replace Primitive with Domain Type**: introduce `ParseX(raw) (X, error)` + (`R2-self-validating-types.md`); migrate call sites so raw values cross into `X` + exactly once, at the boundary. +- **Extract Collection Type**: when logic loops over `[]primitive` or `[]DTO`, wrap + the slice (`type Ports []Port`) and move the loop into a named query method. +- **Replace Sentinel with comma-ok**: `return 0` / `return ""` meaning + absence/invalidity → `(X, bool)` or `(X, error)`. +- **Name enum strings**: `if status == "READY"` → `type Status string` with + `const StatusReady Status = "READY"`. +- **Introduce Parameter Object** (Fowler): the same group of parameters traveling + through multiple signatures (`host string, port int, useTLS bool`) becomes one + type — that is the scorecard's "grouping related data that travels together" made + concrete. Prefer passing the whole object over re-exploding its fields at the next + call (Preserve Whole Object). +- **Over-abstraction found instead?** Apply the cheaper alternative — better naming, + or private fields + accessors — per `../examples/overabstraction-cidr.md`. +- Multi-rule refactoring procedure (sequencing extraction with storifying): + `../skills/refactoring/reference.md`. Forward design of the new types: + @code-designing. + +## Falsifying questions + +Answer each with evidence (`file:line`, command output) — never a bare verdict. + +{{include "rules/R1/falsifying-questions.md"}} diff --git a/core/rules/R10-concurrency-safety.md b/core/rules/R10-concurrency-safety.md new file mode 100644 index 0000000..be30181 --- /dev/null +++ b/core/rules/R10-concurrency-safety.md @@ -0,0 +1,87 @@ +# R10 — Concurrency Safety + +## Principle + +Every goroutine has an owner and a provable exit path; shared mutable state is owned +by one type and guarded where it lives; production code never sleeps to pace or +synchronize cancellable work. Concurrency is designed at construction time — who owns +the state, who stops the goroutine — never patched in afterward. + +## Why + +This rule owns exactly what static analysis cannot prove. The race detector finds +races only at runtime, only on paths a test happens to exercise; no linter can see +that a `for { <-ch }` goroutine has no way out. The failures are the worst kind: +a leaked goroutine accumulates silently until memory or file descriptors run out; an +unsynchronized concurrent map write is a **fatal runtime crash**, not an error; a bare +`time.Sleep` in a retry loop holds a cancelled request hostage for the full backoff. +Each defect also has a design meaning — a goroutine nobody can stop has no owner +(`R2-self-validating-types.md`: construction is where ownership is established), and +state written from two goroutines without a guard is the sideways-access sin of +`R8-no-globals.md` in concurrent form. The mechanical neighbors of this rule belong to +the linter, not to prose: `errcheck` owns ignored errors, `bodyclose` owns unclosed +bodies, `govet copylocks` owns copied locks. R10 hunts the residue no tool can catch. + +## Canonical example + +{{include "rules/R10/canonical-example.md"}} + +## Design guidance + +- **Whoever starts a goroutine owns its shutdown.** Starting a goroutine is + acquiring a resource: the constructor/function that spawns it must hand back a way + to stop it (a `ctx` it honors) and a way to wait for it (`Wait`/`Close`, a closed + `done` channel, or `errgroup`). Fire-and-forget goroutines are acceptable only in + `main`-adjacent wiring that lives as long as the process — and work that must + legitimately outlive a request detaches honestly with `context.WithoutCancel` + (keeps values/tracing, drops cancellation), never by manufacturing a fresh context. +- **Every blocking loop selects on its exit.** A `for` loop containing a channel + receive, send, or sleep gets a `select` with a `ctx.Done()` (or closed-channel) + case. A blocking operation with no exit case is a leak with a delay on it. +- **State and its guard are one unit.** Shared mutable state lives on one type with + the mutex declared directly above the fields it guards, and every access goes + through that type's methods. A mutex in one place guarding data in another is a + convention, not a guarantee. (Whether that type is worth extracting is R1's + scorecard; that it must not be a package global is R8.) +- **Prefer handing off to sharing.** If the design can pass values through a channel + or confine state to a single goroutine, no mutex is needed at all — reach for a + guard only when sharing is the honest requirement. +- **Pick the right guard.** A counter or flag touched from multiple goroutines can + be an `atomic` typed value (`atomic.Int64`, `atomic.Bool`) instead of a mutex; + `sync.Map` only for append-only or disjoint-key caches — a plain map + mutex is + the default (per `sync.Map`'s own doc). A mutex whose only job is one-time + initialization is `sync.OnceFunc`/`sync.OnceValue` wearing a costume. +- **Production code does not sleep.** Backoff, pacing, and polling are + `time.After`/`time.Ticker` inside a `select` with `ctx.Done()`; sustained rate + limiting belongs to `golang.org/x/time/rate` (`Limiter.Wait(ctx)`). A bare + `time.Sleep` on a cancellable path ignores cancellation by construction. (Sleeps + in tests are `R7-test-placement.md` Q6; startup jitter in `main`-adjacent wiring + gets the same exemption as fire-and-forget above.) +{{include "rules/R10/linter-neighbors.md"}} +- Forward design of the owning types: @code-designing. `ctx` threading discipline: + `R8-no-globals.md`. + +## Fix pattern + +- **Inject the Exit Path**: add a `ctx.Done()` (or closed-channel) case to the + goroutine's blocking loop; thread `ctx` from the caller (the Thread `ctx` move in + `R8-no-globals.md`). For fan-out result sends, either `select` on `ctx.Done()` + around the send or size the channel buffer to the number of senders — so no + sender can block forever after the caller returns early. +- **Make the Goroutine Joinable**: return an owner with `Wait`/`Close`, or use + `errgroup.Group`/`sync.WaitGroup` held by the caller — on Go 1.25+ prefer + `wg.Go(fn)`/`g.Go(fn)` over manual `Add`/`Done` (the pairing bugs `go vet` now + flags) — spawn and join in the same hands. +- **Extract Synchronized Owner**: move shared state plus its mutex onto one type; + all access via methods. This proposes a new type — score it with R1's scorecard + and expect the over-abstraction skeptic to challenge it. +- **Replace Sleep with Timer Select**: `select { case <-time.After(d): case + <-ctx.Done(): return ctx.Err() }` — or a `time.Ticker` for polling loops. +- **Delete Unearned Guards**: a mutex on state that only one goroutine ever touches + is ceremony — remove it (the concurrency mirror of R1's over-abstraction trap). + +## Falsifying questions + +Answer each with evidence (`file:line`, command output) — never a bare verdict. + +{{include "rules/R10/falsifying-questions.md"}} diff --git a/core/rules/R11-conditional-dispatch.md b/core/rules/R11-conditional-dispatch.md new file mode 100644 index 0000000..ea5fc66 --- /dev/null +++ b/core/rules/R11-conditional-dispatch.md @@ -0,0 +1,108 @@ +# R11 — Conditional Dispatch (Anti-IF) + +## Principle + +A conditional that asks what a value *is* — a type switch, or a switch/if-chain on a +kind/status/mode discriminator — may exist **once**. The second copy of that +discriminator is a missing polymorphic type: the variants want to be implementations +of an interface (or entries in a dispatch map), chosen once at the boundary, so +downstream code *tells* the value what to do instead of asking what it is. One +well-placed, exhaustive switch is not a defect; a duplicated one always is. + +## Why + +Every `if (new kind) { new code }` doubles the execution paths through the function — +five conditionals means 32 paths to reason about and test. Worse, kind-switches +replicate: the same `switch msg.Channel` appears in send, validate, format, and retry +code, and adding a variant means finding and editing every copy — the one you miss is +the bug that ships. The compiler cannot help: an if-chain has no notion of +completeness, so a forgotten variant falls through silently. Dispatching once — +constructing the right implementation at the boundary (`R2-self-validating-types.md` +owns "validate once at the edge"; this rule is its behavioral twin: *decide* once at +the edge) — collapses N switches into one construction site, makes each variant a +leaf that unit-tests in isolation, and turns "add a variant" into "add a type" with +zero edits to existing code. This idea comes from the Anti-IF movement (Cirillo, +2007): the enemy is not `if`, it is the duplicated kind-conditional. + +## Canonical example + +{{include "rules/R11/canonical-example.md"}} + +## Design guidance + +- **The trigger is duplication, not existence.** Count the sites that inspect the same + discriminator. One site — keep the switch (make it exhaustive). Two or more — + the variants are a type family; dispatch. +- **Decide once, at the edge.** The one legitimate inspection of the raw discriminator + is the constructor/parser that picks the implementation + (`R2-self-validating-types.md` for the constructor discipline). Downstream code + holds the chosen behavior and never re-asks. The corollary: a type switch over an + interface the same package owns is always a re-ask — the decision was made when + the value was constructed; cases that unpack the variants' fields are behavior + asking to live on the interface (`../examples/switch-to-polymorphism.md`). +- **Dispatch requires owning the output.** An interface method can only be written + in the package that declares the interface, and it cannot reference another + package's unexported types. When the switch's output format belongs to a consumer + (a private wire request in a client package) and the variants live in a shared API + package, the move is unavailable — and forcing it (exporting the wire type, + per-consumer `fillRequest` methods on domain types) inverts the dependency. + There the switch is the honest boundary tax: shrink it to pure dispatch (one + converter call per case) and stop. Worked counter-case, including the fill-style + method shape for when the move IS available: + `../examples/switch-to-polymorphism.md`. +- **Interface vs strategy map.** Variants with several behaviors or state → interface + with one type per variant. Variants that differ by a single function → a map + (`var renderers = map[Format]func(Alert) string{...}`) — a map lookup with a + comma-ok check is a dispatch, not a conditional. Either way the decision has one + owner. +- **Null object over nil-checks.** A scattered `if x != nil { x.Log(...) }` is the + same disease with two variants. Construct a do-nothing implementation + (`type NopLogger struct{}`) once; delete every guard. (R2's "nil is not a value" + covers the constructor side.) +- **Flag arguments are two functions.** `func Render(a Alert, short bool)` forces + every caller through a conditional the callee then unpicks. Split into `Render` and + `RenderShort`, or make the variant a type. +- **A kept switch must be exhaustive.** When one switch over a closed enum stays + (single site, trivial variance), name the enum (`R1-primitive-obsession.md`, + "Name enum strings"), drop the `default`, and let the `exhaustive` linter prove + completeness — the linter then does what the if-chain never could: fail the build + when a variant is added but not handled. +- **The over-abstraction trap, dispatch edition.** An interface with one production + implementation is R6's territory; two trivial implementations behind one switch at + one site score LOW on R1's juiciness scorecard — keep the conditional. Conditionals + on *state/values* (`if n > threshold`, `if err != nil`, guard clauses per + `R3-storifying.md`) are healthy control flow, not dispatch — this rule never + touches them. + +## Fix pattern + +- **Replace Duplicated Switch with Interface Dispatch**: define the interface from the + union of what all copies of the switch do (one method per switching site is a + starting point, then collapse); one type per variant; move each `case` body into + its variant; introduce `ParseX(raw) (X, error)` as the single decision point and + migrate call sites to method calls. When the dispatch produces an output that + carries fields the variants don't own (shared name/TLS on a wire request), give + the interface a fill-style method (`fillUpdate(req *T)`) instead of a constructor — + the caller owns the shared fields, each variant fills its own + (`../examples/switch-to-polymorphism.md`). +- **Replace If-Chain with Strategy Map**: single-behavior variance → package-level + `map[Kind]func(...)` (or a field), comma-ok on lookup at the boundary only. +- **Introduce Null Object**: absent-collaborator nil-checks → a no-op implementation + constructed by default; delete the guards. +- **Split Flag Argument**: boolean/enum parameter that selects behavior → two named + functions, or a variant type chosen by the caller's constructor. +- **Keep the Single Exhaustive Switch**: one site, closed enum → named enum type (R1), + no `default`, `exhaustive` linter enforcing completeness. This is the rule's + sanctioned form — record it as the decision, not a TODO. +- New types this creates must pass R1's juiciness scorecard, land per + `R4-helper-placement.md`, and never become test-only interfaces + (`R6-test-only-interfaces.md`). Rejection case law — juiciness (the switch stays, + goes exhaustive): `../examples/anti-if-dispatch.md`; dependency direction (the + move is unavailable across the package boundary): + `../examples/switch-to-polymorphism.md`. + +## Falsifying questions + +Answer each with evidence (`file:line`, command output) — never a bare verdict. + +{{include "rules/R11/falsifying-questions.md"}} diff --git a/core/rules/R12-mutation-discipline.md b/core/rules/R12-mutation-discipline.md new file mode 100644 index 0000000..8a75741 --- /dev/null +++ b/core/rules/R12-mutation-discipline.md @@ -0,0 +1,84 @@ +# R12 — Mutation Discipline (Encapsulated State) + +## Principle + +A validated value changes state only through methods that own its invariants — never +through leaked internals. Constructors copy the slices and maps they are given; +queries return copies (or iterators), not the internal reference; a method is a query +or a modifier, not both; and a type with a validating constructor exposes no setter +that skips the validation. This rule adapts Fowler's *Mutable Data* smell family +(Refactoring, 2nd ed.: Encapsulate Collection, Separate Query from Modifier, Remove +Setting Method, Split Variable) to Go, where slices and maps are references into +shared backing storage. + +## Why + +R2's payoff — validate once, trust the value everywhere after — is void the moment an +internal slice escapes. In Go, `return g.perms` does not return the permissions; it +returns a mutable alias into them. The caller can sort, truncate, or overwrite the +"validated" state without calling a single method, so no grep for setters and no +review of the type's own file will ever find the write that broke the invariant. The +same aliasing runs backward: a constructor that stores a caller's slice without +copying has handed its state to code it has never met. Setters reopen the constructor +from the side, mixed query/modifiers make every call site a potential hidden write, +and a variable reused for two meanings makes both untraceable. Mutation is not the +defect — *unowned* mutation is: every state change must pass through code that knows +the invariants. + +## Canonical example + +{{include "rules/R12/canonical-example.md"}} + +## Design guidance + +- **Copy at both edges.** A constructor clones slice/map arguments (or builds fresh + ones, as `dedupeAndValidate` does); a query returns `slices.Clone`/`maps.Clone` or + an iterator (`iter.Seq`). Between the edges, methods mutate freely — that interior + is exactly what the type owns. +- **Iterators beat copies for read paths.** When callers only range, expose + `iter.Seq[T]` (or a `Each(func(T) bool)` walker) — no alias escapes and no copy is + paid. Return a copy only when callers legitimately need their own collection. +- **A method is a query or a modifier.** A caller who wants the value must be able to + get it without causing the side effect (Fowler: Separate Query from Modifier). + `R3-storifying.md`'s Honest Rename is the naming half — a mutator must sound like + one; this rule owns the structural half — when call sites need the query alone, + split the method in two. +- **No setters around a validating constructor.** A `SetPort(n)` that assigns without + checking is a hole in `ParsePort`'s wall. If post-construction change is a real + requirement, the mutator validates exactly as the constructor does — or returns a + new value (`WithPort(n) (Server, error)`). If it isn't a real requirement, there is + no setter. (`R2-self-validating-types.md` owns construction; this rule owns the + paths that could bypass it afterward.) +- **One variable, one purpose.** A variable reassigned to mean something new + (`size := len(x)` … `size = size * unitPrice`) hides a phase change inside a name. + Split it into two named variables (Fowler: Split Variable); if the phases are big, + that's `R3-storifying.md`'s extraction signal. +- **The inverse trap: ceremony copies.** Cloning a slice that never escapes the + function, or copying inside a hot loop "to be safe," is defensive noise — the + mirror of R1's ceremony wrappers. Copy where an alias crosses an ownership + boundary (constructor arguments, query returns on validated types), not + everywhere a slice appears. Local, short-lived sharing inside one function is + fine and idiomatic. + +## Fix pattern + +- **Copy on the Way In**: constructor stores `slices.Clone(arg)` / `maps.Clone(arg)` + (or builds its own collection) instead of the caller's reference. +- **Copy on the Way Out / Encapsulate Collection**: queries on validated types return + clones or `iter.Seq` iterators; delete call-site mutations of the returned value or + convert them into named methods on the type (`Sorted()`, `Without(p)`). +- **Separate Query from Modifier**: split a method that both returns data and mutates + into a pure query and a command; migrate each call site to the half it actually + uses. (Pure renaming cases stay with R3's Honest Rename.) +- **Remove Setting Method**: delete the setter; route the change through a validating + mutator, a `WithX` copy-constructor, or full reconstruction via `ParseX`. +- **Split Variable**: one assignment per meaning; new meaning, new name. +- New named methods this creates (`Sorted()`, `WithX`) must earn their place — + score against `R1-primitive-obsession.md` before adding; a method nobody calls + twice is ceremony. + +## Falsifying questions + +Answer each with evidence (`file:line`, command output) — never a bare verdict. + +{{include "rules/R12/falsifying-questions.md"}} diff --git a/core/rules/R2-self-validating-types.md b/core/rules/R2-self-validating-types.md new file mode 100644 index 0000000..90f3456 --- /dev/null +++ b/core/rules/R2-self-validating-types.md @@ -0,0 +1,96 @@ +# R2 — Self-Validating Types + +## Principle + +A type validates its own invariants in its constructor — the only way to obtain a +value — and every method thereafter trusts the receiver. Validation ownership never +sits upstream: a type that relies on callers to have validated for it is not +self-validating, whatever its fields look like. + +## Why + +Constructor validation makes invalid values unrepresentable. Without it, every method +must defend against bad state, forgetting one check is a latent panic, and the +defensive noise buries the actual logic. With it, nil-checks, emptiness checks, and +range checks vanish from the entire downstream call graph — the payoff compounds with +every method and every caller. Errors also surface at the boundary where the bad data +entered, carrying context, instead of deep in an unrelated call stack. + +## Canonical example + +{{include "rules/R2/canonical-example.md"}} + +## Design guidance + +- **Constructors are the only entry.** `ParseX(raw) (X, error)` for values built from + unstructured input, `NewX(deps) (X, error)` for composed objects (constructors may + carry other names — any public function returning the type qualifies). Fields stay + private: a struct-literal or zero-value path around the constructor is a hole in + the type. + +- **Validation ownership.** A type never relies on upstream validation. "The handler + already checked it" is not an invariant — handlers change, new call sites appear, + and the type outlives both. A comment reading "caller must ensure X" is the + signature of a type that does not own itself: move that sentence into the + constructor as code. + + ```go + // ❌ relies on callers to validate + type Config struct { + Host string // every caller must remember: if host == "" ... + Port int + } + + // ✅ owns its own validation + func NewConfig(host string, port int) (Config, error) { + if host == "" { return Config{}, errors.New("host required") } + if port <= 0 || port > 65535 { return Config{}, errors.New("invalid port") } + return Config{host: host, port: port}, nil + } + ``` + +- **Trust composed values.** Once you hold a `Port`, it is valid — never re-check it + downstream, and never re-validate it in a composing constructor. Each type owns + exactly its own invariants: + + ```go + // ❌ re-validates what Host already guarantees + func NewAddress(host Host, port Port) (Address, error) { + if host == "" { return Address{}, errors.New("host required") } // Host owns this + return Address{host: host, port: port}, nil + } + + // ✅ trusts composed self-validating types — nothing left to check, no error to return + func NewAddress(host Host, port Port) Address { + return Address{host: host, port: port} + } + ``` + +- **Nil is not a value.** Never return nil for non-error values — return an error + instead. Error positions are exempt: `nil, err` and `val, nil` are fine because the + real value is the other one. Never pass nil into a function; then functions do not + check parameters for nil. + +- **No defensive coding.** Check arguments in the constructor so that methods contain + zero nil/emptiness checks on their own fields. A method validating its receiver is + validation in the wrong place. + +## Fix pattern + +- **Add validating constructor**: make fields private, add `NewX`/`ParseX` returning + `(X, error)`, migrate every literal-construction site through it. +- **Hoist method checks into the constructor**: collect the field checks scattered + across methods, run them once at construction, delete them from the methods. +- **Delete re-validation of composed types**: if every parameter is itself + self-validating and there is nothing left to check, the constructor loses its + `error` return entirely. +- **Replace nil returns**: `(X, error)` for failures, `(X, bool)` for absence — see + the sentinel move in `R1-primitive-obsession.md`. +- Forward design of new types: @code-designing. The primitive extraction that usually + precedes this rule: `R1-primitive-obsession.md`. + +## Falsifying questions + +Answer each with evidence (`file:line`, command output) — never a bare verdict. + +{{include "rules/R2/falsifying-questions.md"}} diff --git a/core/rules/R3-storifying.md b/core/rules/R3-storifying.md new file mode 100644 index 0000000..e204ee5 --- /dev/null +++ b/core/rules/R3-storifying.md @@ -0,0 +1,73 @@ +# R3 — Storifying (Single Level of Abstraction) + +## Principle + +A top-level function reads like a story: every step is a named call at the same +conceptual level, and the whole flow is graspable at a glance. Method calls never mix +with string/index manipulation in the same body. A comment that names a block of code +is a function name waiting to be extracted. + +## Why + +Mixed abstraction levels bury the business flow: the reader must mentally execute +low-level details to reconstruct what the function *means*, and the linter measures +that cost as cognitive complexity. Steps that are inlined instead of named cannot be +tested independently — the only test surface is the whole tangle, with its I/O and +state attached. Storifying does two things at once: the orchestration becomes a +readable, low-complexity narration, and the extracted steps become named units that +either stay as focused helpers or graduate into leaf types +(`R1-primitive-obsession.md`) with 100% unit coverage. Most of a codebase's logic +should end up in those leaves; the story functions above them should be thin. + +## Canonical example + +{{include "rules/R3/canonical-example.md"}} + +## Design guidance + +- **One conceptual level per function.** A function states *what* happens; the *how* + lives one level down behind a named call. If you can explain the flow in 3–5 steps, + the code should be those 3–5 calls. +- **Comments naming blocks are extraction orders.** `// validate input`, + `// build query`, `// already added. skip` — extract a function and name it after + the comment; the comment then disappears because the name carries it. +- **Extracted steps want owners.** When an extracted step operates on data it could + own, don't leave it a free function — make it a method on a type (a leaf, + `R1-primitive-obsession.md`); where that type then lives is + `R4-helper-placement.md`. Storifying is how leaf types are discovered. +- **Boolean flags tracking loop state** (`addrIP4Added`, `isClusterCIDRSet`) signal a + collection or domain type waiting to absorb the loop. +- **Honest naming.** A name must reveal side effects: `align`/`upsert`/`set` mutate; + `parse`/`validate`/`is` must not. A `validateX` that mutates is a storifying bug + even if the flow reads well. +- **Size and shape limits**: functions under 50 LOC, at most 2 nesting levels; deeply + nested if/else becomes early returns or extracted functions. + +## Fix pattern + +- **Extract Function named after the comment**: each commented block becomes a call; + the story is what remains. +- **Extract Leaf Type**: when extracted steps share data (loop flags, accumulated + state), move them onto a new type — see `../examples/storify-leaf-type.md` for the + full move, and `R1-primitive-obsession.md` to score whether the type is warranted. +- **Replace Nesting with Early Returns**: invert conditions, return early, flatten to + ≤2 levels. +- **Split Phase** (Fowler): when one function interleaves decoding/parsing with + computation — wire fields and business decisions in the same body — split it into + phase 1, which parses input into an intermediate domain structure, and phase 2, + which computes over that structure alone. The intermediate type is a leaf + candidate (score per `R1-primitive-obsession.md`); when phase 1 validates, it is a + `ParseX` constructor and the move collapses into `R2-self-validating-types.md`. + Split Phase differs from Extract Function: extraction names a step in place, Split + Phase introduces a data structure *between* the steps so each phase can change — + and be tested — without the other. +- **Honest Rename**: mutating helpers get mutating names (`parseIP4` → `alignIPv4`). +- Multi-rule sequencing (storify first or extract first, and when to stop): + `../skills/refactoring/reference.md`. Forward design of the new types: + @code-designing. + +## Falsifying questions + +Answer each with evidence (`file:line`, command output) — never a bare verdict. + +{{include "rules/R3/falsifying-questions.md"}} diff --git a/core/rules/R4-helper-placement.md b/core/rules/R4-helper-placement.md new file mode 100644 index 0000000..ff74f2c --- /dev/null +++ b/core/rules/R4-helper-placement.md @@ -0,0 +1,112 @@ +# R4 — Helper Visibility & Placement + +## Principle + +Every extraction raises a second question: where does the helper live? The answer is +decided by two axes — juiciness (the scorecard in `R1-primitive-obsession.md`; cite +it, never re-derive it) and scope (feature-specific versus domain-generic). Three +rungs: unexported in place, feature sub-package, shared domain package. Never test +privates, and never export a helper into its parent package just so a test can reach +it. + +## Why + +Wrong placement rots in both directions. Helpers exported into the parent package for +testability pollute its API — callers see symbols that exist only for tests, and the +package's real surface becomes unreadable. Juicy helpers buried as unexported code +either go untested or push the team into testing privates, breaking the +public-API-only discipline (`R7-test-placement.md`). And role-named dumping grounds +(`util`, `helpers`, `common`) accrete unrelated code that nobody can find, name, or +own. Placement is what lets extraction deliver its promise: isolated, literal-input +unit tests against a legitimate public API. + +## Canonical example + +{{include "rules/R4/canonical-example.md"}} + +## Design guidance + +### The placement ladder + +1. **Trivial helper** → unexported, same package, tested only through the parent's + public API. +2. **Juicy + feature-scoped** → vertical-slice feature sub-package (e.g. `kubefwd/`) + *if the feature has enough substance to be a package*; types exported there. See + `R5-vertical-slice.md`. +3. **Juicy + domain-generic** → shared domain-named library package: + `internal/pkg/` (default) or `pkg/` (public). Granularity: a + package is a domain *vocabulary* (`networking`), not a single noun (`kubeport`), + never a role (`util`/`helpers`/`common`). + +"Juicy" is the verdict of R1's scorecard — `R1-primitive-obsession.md` is its only +home. + +### The promotion signal + +**The urge to unit-test a helper directly means it deserves its own package.** Never +act on that urge by testing privates, and never by exporting the helper into the +parent. The urge is data: it says the helper has enough behavior to be a unit of its +own — so give it a real home (rung 2 or 3) where its exported API is legitimately +testable with literal inputs. + +### The reuse objection + +"Nobody else uses it, so it can't justify a package." Wrong premise: reuse is not the +only justification for extraction — isolated testability and readability count on +their own. And the pollution worry it hides is solved by *placement*, not by inlining +the logic back: a helper in its own domain package pollutes nothing. + +### Granularity + +Name shared packages after a domain vocabulary with room for siblings: `networking` +can grow `Port`, `Ports`, addresses, CIDRs. A single-noun package (`kubeport`) is a +vocabulary of one — fold it into the vocabulary it belongs to. A role name (`util`, +`helpers`, `common`) describes no domain at all and is never acceptable. + +## Fix pattern + +- **Demote (rung 1)**: a helper exported from its parent only so tests can reach it → + unexport it, delete the direct tests, cover it through the parent's public API. +- **Promote to feature sub-package (rung 2)**: a juicy, feature-scoped helper being + tested through awkward big-object setups → move it into the feature's + vertical-slice sub-package (`R5-vertical-slice.md`), export it there, test its + public API directly. +- **Promote to domain package (rung 3)**: a juicy, domain-generic helper → create or + extend `internal/pkg/`; move the generic types; leave feature policy home + as a thin storified method (see Stage 2 of `R1-primitive-obsession.md`'s canonical + example). +- **Split policy from vocabulary during promotion**: feature constants and preference + logic stay in the feature; only the domain-generic types and queries move. +- **Move Method to the Envied Type** (Fowler: Feature Envy → Move Function): a + function that reads another type's data more than its own belongs on that type — + move it there, then place the enriched type on the ladder as usual. If the envied + type is foreign (another module's DTO), wrap it first (`R1-primitive-obsession.md`) + and hang the behavior on the wrapper. +- **A message chain is a placement signal, not a wrapper order** (Fowler: Message + Chains). Before "fixing" `order.Customer().Address().City()` by adding a + `CustomerCity()` forwarder, ask what the caller *does* with the endpoint (Tell, + Don't Ask — `../maxims.md`): a decision or computation → that behavior moves onto + the chain's owner (Move Method to the Envied Type, above), where the chain + collapses into a one-hop walk of the type's own composition — which was never the + problem. Only data egress at a boundary (rendering, serialization, wire mapping) + legitimately keeps the chain, and there it lives as a one-shot mapping inside the + adapter, not scattered through domain code. +- **A type speaks for its parts only when it has something to add** (Fowler: Middle + Man). A method whose entire body is `return o.x.Method()` — no decision, no + combination, no invariant — is R1's ceremony verdict applied per method: the + indirection owns nothing, so it goes. A type whose surface is mostly such forwards + is a worse copy of its field's API — delete the forwards and hand callers the part + (`o.Customer()`), keeping only delegations that carry a rule (`ShippingAddress()` + choosing gift recipient over buyer earns its place; `CustomerEmail()` does not). + The Go accelerant: embedding a domain type (`type Order struct{ Customer }`) + manufactures this smell in one line by promoting the entire foreign API onto the + outer type — embed for genuine is-a (interface embedding, `sync` primitives per + `R10-concurrency-safety.md`), never to save typing `o.customer.`. +- Multi-rule extraction sequencing: `../skills/refactoring/reference.md`. Forward + design of the promoted package: @code-designing. + +## Falsifying questions + +Answer each with evidence (`file:line`, command output) — never a bare verdict. + +{{include "rules/R4/falsifying-questions.md"}} diff --git a/core/rules/R5-vertical-slice.md b/core/rules/R5-vertical-slice.md new file mode 100644 index 0000000..b2d3d46 --- /dev/null +++ b/core/rules/R5-vertical-slice.md @@ -0,0 +1,91 @@ +# R5 — Vertical Slice Architecture + +## Principle + +Group code by feature and role, not by technical layer: bad — `domain/rotator`, +`services/rotator`; good — `rotator/parser.go`, `rotator/handler.go`. All code for a +feature lives in one package, internally separated by role within it. Package names +are flatcase domain vocabulary — never a layer or role name. + +## Why + +Horizontal layering scatters one feature across `handlers/`, `services/`, +`domain/` — understanding or changing the feature means touching N directories, and +every feature couples to every other through the shared layer packages. Layer +packages also accrete: `services/` grows a file per feature until nobody owns its +API. A vertical slice colocates the whole behavior: it can be read top to bottom, +extracted or deleted as a unit, and worked on in parallel without cross-team merge +conflicts. The slice's internal files are named by role (`parser.go`, `handler.go`, +`repository.go`), so the layer separation survives — inside the feature boundary +instead of above it. + +## Canonical example + +{{include "rules/R5/canonical-example.md"}} + +## Design guidance + +### Package naming method + +- **Flatcase**: `wekatrace`, never `wekaTrace` or `weka_trace`. +- **Domain vocabulary, not a single noun**: a package name should have room for + siblings — `networking` can grow ports, addresses, CIDRs; `kubeport` is a + vocabulary of one. (This is rung 3 of `R4-helper-placement.md`; the placement ladder + lives there — cite it, don't re-derive it.) +- **Never a role name**: `util`, `utils`, `helpers`, `common`, `shared`, `misc`, + `domain`, `services` — a role describes no domain and becomes a dumping ground. +- **Avoid stdlib/common-library collisions**: `metrics` forces aliases on every + importer; prefer a specific name like `wekametrics`. +- **Ergonomic symbols**: the package provides context — `rotator.Parser`, not + `rotator.RotatorParser`; `version.Info`, not `version.VersionInfo`. + +### Inside the slice + +Separate by role and responsibility within the feature package: `parser.go`, +`handler.go`, `repository.go`. Types with logic get their own file named after the +type. When a slice grows a juicy sub-concern, it becomes a feature sub-package +(rung 2 of `R4-helper-placement.md`); when a concern turns out to be domain-generic, +it promotes to a shared domain package (rung 3) — placement is R4's decision. + +### Migration template + +New features are always built as vertical slices. Existing layer-structured code +migrates incrementally — never as a big bang, and never leaving one feature in both +shapes. Track the migration in `docs/architecture/vertical-slice-migration.md`: + +```markdown +# Vertical Slice Migration Plan +## Current State: [horizontal/mixed description] +## Target: Vertical slices in internal/[feature]/ +## Strategy: New features vertical, refactor existing incrementally +## Progress: [x] rotator (this PR), [ ] health, [ ] verification +``` + +Per feature: create `internal//`, move the feature's files from each layer +directory into it (renamed by role: `rotator_service.go` → `service.go`), fix +imports, delete the emptied layer files. **Never mix**: `rotator/service.go` and +`services/rotator_service.go` must not coexist for the same feature. + +### Advisory posture + +Architecture findings advise, they don't block: a team may accept horizontal +layering for real reasons (time constraints, an agreed convention). The finding's +job is evidence and a migration path, not a veto. Role-named packages, by contrast, +are never acceptable (`R4-helper-placement.md`). + +## Fix pattern + +- **Slice out a feature**: apply the migration template above — one feature per + iteration, each iteration a working, deployable state. +- **Rename layer files by role during the move**: `_service.go` → + `service.go`; the package name now carries the feature. +- **Split a generic package by owner**: for each symbol in a `util`/`common` + package, find its real feature or domain vocabulary and move it there + (`R4-helper-placement.md` decides which rung). +- Forward design of a new slice's packages and types: @code-designing. + +## Falsifying questions + +Answer each with evidence (`file:line`, command output) — never a bare verdict. + +{{include "rules/R5/falsifying-questions.md"}} diff --git a/core/rules/R6-test-only-interfaces.md b/core/rules/R6-test-only-interfaces.md new file mode 100644 index 0000000..ee85e5f --- /dev/null +++ b/core/rules/R6-test-only-interfaces.md @@ -0,0 +1,69 @@ +# R6 — Test-Only Interfaces + +## Principle + +An interface whose only non-test implementation is a single concrete type exists to +enable a mock — delete it and depend on the concrete type. Don't create interfaces +until you need them; a test fake is not a need. An interface is justified only by a +real second production implementation or a verified import cycle. + +## Why + +This is the exact failure that slips past reviews: a reasonable-looking interface +with a comment "explaining" it (usually "avoids an import cycle" or "for testing"), +one production implementation, and a test double as the only other implementer. The +interface adds an indirection every reader must resolve, detaches the consumer from +the real type's documentation and behavior, and — worst — licenses the test to +exercise a hand-written double instead of the real collaborator, so the test proves +nothing about production wiring. A hand-written struct that only satisfies a +production interface to stand in for the real collaborator IS a mock, whatever the +file calls it. A "fake" is a real implementation with fake *data* — embedded DB, +`httptest` server, temp dir. Orchestrators are tested by wiring their real +collaborators (`R7-test-placement.md`); they never need injection seams carved for +doubles. + +## Canonical example + +{{include "rules/R6/canonical-example.md"}} + +## Design guidance + +- **Interfaces are earned by a second production implementation** — an in-memory + repository that production code can also use, a second backend, a real plug point. + Until that exists, depend on the concrete type. +- **"For testing" never justifies an interface.** The test's job is to wire real + collaborators over fake data (real store over embedded DB, real client against + `httptest`) — `R7-test-placement.md` places the test; @testing has the harness + patterns. +- **"Avoids an import cycle" is a claim, not a fact — verify it.** A real cycle + exists only if the dependency's package imports the consumer's package back. If + the grep (below) shows no back-import, the comment is cover for a test seam. +- **A real cycle is a layering bug, not an interface opportunity.** Move the package + so the dependency direction is downward; don't invert the arrow with an interface + whose only purpose is to break the cycle a double rides in on. +- **When an interface is genuinely needed**, define it at the point of use (in the + consumer's package), keep it small and cohesive, and expect every implementation + to be production code. The worked case of an *earned* interface — multiple + production implementations replacing a growing type switch, sealed by an + unexported method: `../examples/switch-to-polymorphism.md` (dispatch discipline: + `R11-conditional-dispatch.md`). + +## Fix pattern + +- **Inline the interface**: replace the interface field/parameter with the concrete + type; delete the interface declaration. +- **Rewrite the test around real collaborators**: construct the real dependency over + fake data (embedded DB, temp dir, `httptest` server) and exercise the consumer's + public API (@testing for harness patterns; placement per + `R7-test-placement.md`). +- **Delete the double**: the fake struct in `*{{.TestGlob}}` / `fakes/` / `mocks/` / + `testutil*` goes with the interface. +- **If a verified cycle exists, fix the layering**: extract the shared vocabulary + into a lower package both can import, or move the consumer — the dependency arrow + must point downward. + +## Falsifying questions + +Answer each with evidence (`file:line`, command output) — never a bare verdict. + +{{include "rules/R6/falsifying-questions.md"}} diff --git a/core/rules/R7-test-placement.md b/core/rules/R7-test-placement.md new file mode 100644 index 0000000..f5375b3 --- /dev/null +++ b/core/rules/R7-test-placement.md @@ -0,0 +1,73 @@ +# R7 — Test Placement + +## Principle + +Every behavior is tested at the lowest rung of the composition ladder that contains +it: rung 0 is pure leaf types (unit tests with literal inputs, 100% coverage, public +API only, `pkg_test` package); each rung above adds exactly one real production +layer; only the true external boundary is ever faked. Orchestrating types get +integration-style tests that cover the seams between their real collaborators — some +overlap with leaf coverage is fine; leaf behavior tested *only* from above is not. + +## Why + +A behavior tested above its lowest rung pays for machinery the behavior doesn't +need: big-object construction, harnesses, fakes — and when it fails, the failure +points at the orchestration, not at the leaf that owns the bug. Tested at its rung, +the same behavior is a table of literals that pinpoints its owner. The placement +rule is also the enforcement arm of the design rule: if a leaf behavior *cannot* be +tested with literals, the logic is trapped in an orchestrator and R1/R3 extraction +is owed (`R1-primitive-obsession.md` Stage 3 shows the payoff — a K8s fixture test +collapsing into a slice-literal test). Discipline inside the tests matters for the +same reason: a conditional inside `t.Run` means one case is really two, and a test +asserting on a fake's internals verifies the double, not the system. The full +composition ladder and harness patterns live in @testing; this rule is the placement +and review contract. + +## Canonical example + +{{include "rules/R7/canonical-example.md"}} + +## Design guidance + +- **Leaf types (rung 0)**: 100% unit coverage; constructed only through their public + constructors; inputs are literals; `pkg_test` package so privates are unreachable. + Most of the codebase's logic should live here (`R1-primitive-obsession.md`). +- **Orchestrating types**: integration-style tests wiring real collaborators — real + store over an embedded DB, real client against `httptest` — never + interface-injected doubles (`R6-test-only-interfaces.md`). They cover the seams; + overlapping a leaf's happy path while doing so is acceptable. +- **Fake only the true external boundary** — the API you don't control — and fake it + with a real server speaking the real protocol (`httptest`), wired via URL/config. +- **Complexity 1 inside every `t.Run`**: no if/else, no switch. The `wantErr bool` + pattern is the canonical violation — it folds success and error cases into one + table and pays with a conditional. Split into `TestX_Success` and `TestX_Error` + functions. +- **The urge to test a private is a placement signal**, never a license: it means + the helper deserves its own package (`R4-helper-placement.md`), where its public + API is legitimately testable. +- **Mechanics**: named struct fields in every table (the linter reorders fields); + no `time.Sleep` — channels or wait groups; testify suites only for real + infrastructure setup, not plain unit tests. +- Full ladder, harness patterns, and dependency levels (in-memory → binary → + containers): @testing. + +## Fix pattern + +- **Move the behavior down a rung**: rewrite the big-object test as a leaf unit test + with literal inputs; if the leaf doesn't exist yet, that is an R1/R3 extraction + first (`../examples/storify-leaf-type.md` shows the pair). +- **Split `wantErr` tables**: one `_Success` function asserting values, one `_Error` + function asserting errors — complexity 1 in both. +- **Replace doubles with real collaborators**: delete the mock, wire the real + dependency over fake data (`R6-test-only-interfaces.md`; @testing for harnesses). +- **Replace sleep with synchronization**: channel + `select`/timeout, or + `sync.WaitGroup`. +- **Delete private-function tests**: cover through the parent's public API, or + promote the helper (`R4-helper-placement.md`). + +## Falsifying questions + +Answer each with evidence (`file:line`, command output) — never a bare verdict. + +{{include "rules/R7/falsifying-questions.md"}} diff --git a/core/rules/R8-no-globals.md b/core/rules/R8-no-globals.md new file mode 100644 index 0000000..8a2b383 --- /dev/null +++ b/core/rules/R8-no-globals.md @@ -0,0 +1,75 @@ +# R8 — No Globals / Dependency Rejection + +## Principle + +Dependencies are passed down from the caller, never reached sideways: no +package-level mutable state, no `init()` writing state, no singletons fetched from +inside business logic, no `context.Background()` in library code — `ctx` flows from +caller to callee. Globals are acceptable only at entry points (`main`, handler +setup, application wiring), where they are read once and injected downward. + +## Why + +A global is a hidden parameter of every function that touches it. Hidden parameters +make code untestable except by mutating shared state — which forbids parallel tests, +lets state leak between tests, and hides from the reader what a function actually +needs. `env.Configs.X` reached from deep inside a publisher couples every caller to +one config struct and makes swapping the value per-test or per-environment +impossible without global writes. `context.Background()` deep in a call chain is the +same sin in context form: it severs cancellation, timeouts, and tracing from the +request that is actually running. Passing dependencies down turns each type into an +island of clean code: constructor-injected (`R2-self-validating-types.md`), fully +testable with fake data, parallel-safe. Not every global is a defect: loggers +designed to be global (`slog`, `zerolog`), constants, and `var Err... = errors.New` +sentinels are fine — the target is mutable state and configuration reached sideways. + +## Canonical example + +{{include "rules/R8/canonical-example.md"}} + +## Design guidance + +- **Reject the dependency upward.** A function that needs a value takes it — as a + constructor argument on its type, or a parameter. The caller then faces the same + choice, and the requirement bubbles up until it reaches an entry point that + legitimately owns configuration. +- **Work bottom-up, one island at a time.** Start at the deepest usage (furthest + from `main`), extract a clean constructor-injected type, and stop the iteration + there — each step is a working, deployable state. Don't attempt a big-bang purge. +- **Pragmatic endpoint.** Globals at `main()`, handler setup, and top-level + factories are acceptable; globals in business logic, data access, and library + code are not. The goal is not zero globals — it is globals only where wiring + happens. +- **`ctx` flows down.** Every function doing I/O takes `ctx context.Context` from + its caller. `context.Background()` belongs in `main`/`TestMain`/tests — never in + library code; a library that manufactures its own root context has silently opted + out of cancellation. +- **`init()` computes nothing observable.** An `init()` that writes package state is + a hidden constructor with no error path and no injection point — replace it with + an explicit constructor called from the edge. +- **Singletons are wiring, not access.** A `sync.Once`-guarded package instance + reached from business logic is a global with extra steps; construct once at the + edge and pass it down. +- Constructor injection and validation of the injected deps: + `R2-self-validating-types.md`. Forward design of the extracted types: + @code-designing. + +## Fix pattern + +- **Extract Clean Island**: at the deepest global usage, create a type whose + constructor takes the value (`NewNATSClient(addr)`); move the logic onto it. +- **Push the Global Up One Level**: each caller now constructs or receives the + island; repeat per level until the global is read only at entry points. Full + progression: `../examples/dependency-rejection.md`. +- **Replace `init()` with a constructor**: delete the `init()`, expose + `NewX(...) (X, error)`, call it from the wiring code. +- **Thread `ctx`**: add `ctx context.Context` as the first parameter down the chain; + delete `context.Background()` from library code. +- Multi-rule sequencing with extraction/storifying: + `../skills/refactoring/reference.md`. + +## Falsifying questions + +Answer each with evidence (`file:line`, command output) — never a bare verdict. + +{{include "rules/R8/falsifying-questions.md"}} diff --git a/core/rules/R9-repo-brain.md b/core/rules/R9-repo-brain.md new file mode 100644 index 0000000..6a792aa --- /dev/null +++ b/core/rules/R9-repo-brain.md @@ -0,0 +1,327 @@ +# R9 — Repo Brain (Documentation Network) + +## Principle + +Documentation is a network ranked by the documentation ladder: storified code → +godoc comments → repo docs → the index, each fact placed at the lowest rung that can +carry it, higher rungs summarizing and pointing down, never duplicating. Two +invariants hold the network together: **reachability** (every doc is reachable from +the root: CLAUDE.md → index.md → doc — no orphans) and **bidirectionality** (code +points up at its feature doc; docs point down at code via greppable symbols; the +index points everywhere). The doc root itself is an Open Knowledge Format (OKF +v0.2) bundle: content docs carry YAML frontmatter, a file's path is its identity, +and every index line is drift-checked against the `description` one level down +(bundle policy below). + +## Why + +Each rung of the documentation ladder has its own drift economics. Rung 0 cannot +drift — the code *is* the behavior. Rung 1 drifts slowly: a godoc comment lives +beside its symbol and gets reviewed with every diff that touches it. Rung 2 drifts +on its own unless networked: nothing in a normal diff forces `docs/` open, so a +feature doc rots silently — *unless* an edge from the changed code names it and an +index line makes it findable. Rung 3 barely drifts because it is short and +drift-checked (Q7). Placing a fact above its lowest viable rung therefore buys drift for +nothing; placing it below (cramming architecture into a comment) buries it where no +overview reader looks. + +The network exists for cold starts. A fresh Claude session — or a new engineer — +enters the repo through one of three doors: a grep hit on a symbol, a file open, or +CLAUDE.md at session start. From any door, full context must be two hops away: +symbol → its godoc → the feature doc; CLAUDE.md → index.md → the feature doc. An +unlinked doc is an unread doc, and unread docs rot — orphaning is not a tidiness +problem, it is the mechanism by which documentation dies. Drift-detection depends on +the same wiring: only a **literal, greppable** edge can be mechanically verified +(Q2 below); a prose paraphrase of code structure can be wrong forever without +anyone noticing. + +## Canonical example + +{{include "rules/R9/canonical-example.md"}} + +## Design guidance + +Forward guidance — what @documentation applies when writing docs after a feature. + +### The documentation ladder + +| Rung | Layer | Drift | Owns | +|---|---|---|---| +| 0 | Storified code | none — it IS the behavior | the story; names carry context (owned by `R3-storifying.md`, cited not restated) | +| 1 | Code comments (godoc) | low — lives beside the code, reviewed with diffs | the WHY within a tiered 1–5 prose-line budget (policy below); network edges: `See docs/.md` | +| 2 | Repo docs | medium — drifts unless networked | feature/architecture docs in the doc root; point back down via greppable symbol references (edge policy below) | +| 3 | The map | minimal — short and drift-checked (Q7) | `index.md` in the doc root: one line per doc, grouped by topic; wired into CLAUDE.md / AGENTS.md | + +(The rung metaphor deliberately mirrors the testing composition ladder in @testing: +lowest rung that can carry it, always.) + +**Placement rule:** document each fact at the lowest rung of the documentation +ladder that can carry it; higher rungs summarize and point down, never duplicate. +Good overlap: the index says "capped-jitter retries", the feature doc explains the +cap math, the godoc states the incident — each level adds detail. Bad overlap: the +same paragraph pasted at two rungs; it *will* drift. Before writing any comment, +first ask whether a rename or extraction (`R3-storifying.md`) makes it unnecessary — +rung 0 beats rung 1. + +### Comment policy (rung 1 — tiered budget) + +The WHY is the default content of a doc comment, and it lives inside a hard budget: +**1–5 prose lines**, scaled to the symbol's importance. Not all symbols are born +equal — the budget forces each comment to carry only the most important facts *for +that symbol*; everything else moves up to the feature doc (rung 2), where depth is +cheap, and the `See docs/.md` edge carries the pointer. This is the +placement rule made operational at write time. + +**The Comment Value Toolbox** — a comment earns its lines by delivering one or +more of these values (the growable catalog with worked examples lives in +@documentation's reference.md; this list is the normative set of kinds): + +- **WHY, not WHAT** — rationale, incident, constraint the code cannot carry +- **Wider context** — where this sits architecturally; what depends on it +- **Important use cases / flows** — when to reach for it +- **Boundary contract** — dos/don'ts, valid inputs, error behavior +- **Guarantees** — thread safety, nil handling, invariants +- **Network edge** — `See docs/.md` wiring a critical point into the + repo brain + +**The three-test standard** — every comment (and every rung-2 doc, for test 3) +must pass all three; @documentation applies them at write time and its +comment-critic agent enforces them adversarially after writing: + +1. **Toolbox-value test**, two-sided: + - *Floor (delete test):* a prose line that delivers none of the toolbox values + is trash — cut it. Named failure modes: restated identifiers, generic filler + ("provides validation functionality"), narrated implementation, menu sections + filled without earning their place, **review-defense narration** — the + writer arguing with an imagined reviewer ("bounds-checked: it never indexes + an empty slice", "deliberately narrow — not a generalized table"); a design + choice that needs defending is defended in the feature doc, not at the code + line — **restated repo idiom** — a comment + justifying a convention the repo already applies everywhere (a pointer field + meaning "omitted vs explicit zero", the standard error-wrapping style); the + convention is documented once at rung 2 (coding standards), never + re-explained at each use site — and **provenance and decoder-ring + references** — PR numbers, review items, plan/decision/test-plan IDs + ("T-04-02", "D-07"), requirement tags ("REQ-SVC-01"), spec section refs + ("spec §4"), "the previous behavior" narration, "matching what + did". A decoder-ring token fails even when it resolves inside a + repo doc: the reader gets the fact as plain prose and the doc through the + one See-edge — never through a code they must look up. + The floor's lens is the 5-year reader test: a reader five years out cares how + the product behaves NOW, never which PR or review round produced it. History + is rewritten as present-tense rationale ("silently picking one of the TLS + options could apply a mode the caller did not ask for"), and an + incident/ticket reference survives only when it IS the rationale for a + constraint — never as provenance. + - *Ceiling (smart choice):* the comment as a whole must carry the + highest-value toolbox items for that symbol's tier within its budget. A + crossroads whose five lines are all boundary trivia while the architectural + WHY is missing fails, even though each line individually "adds something". +2. **Budget test** — the comment fits its tier budget (accounting and tiers + below). +3. **Plain-English test (the empathy test)** — write for a fresh graduate whose + first language may not be English: everyday words, short sentences, one idea + per sentence. A comment that needs a dictionary fails even when true and + within budget. Failure modes: fancy vocabulary where a common word exists + ("utilize" → "use", "leverages" → "uses"), stacked clauses, academic phrasing, + and unexplained acronyms or insider jargon ("DTO", "tristate") — in the + comment AND in the symbol name it documents. + The test has a second half, **self-standing**: the comment must be + understandable BEFORE reading the code. If the reader must read the code — or + another comment ("see X's doc comment for why") — to understand this comment, + it has negative value. State the fact in place; forward references to other + comments fail. + +**Budget accounting** — prose lines count; these are free: + +- blank `//` separator lines +- the `See docs/.md` network-edge line — free ONLY as its own trailing + line; a doc reference woven into a prose sentence is not an edge, it is clutter + in that sentence's line count +- short inline example lines, bounded at 2–4 lines — anything bigger belongs in an + `Example_*` testable example + +**Role-based tiers** — judge the tier from the symbol's role in the code: + +| Tier | Role signals | Budget | Typical content | +|---|---|---|---| +| **Helper** | small method, plain constructor, obvious accessor | 0–1 prose line | one-line summary; tiny example only if it clarifies | +| **Contract** | parsing constructor (`ParsePolicy`, `ParsePort`), self-validating type, ordinary exported API | 2–3 prose lines | WHY + boundary contract; dos/don'ts example (free) | +| **Crossroads** | entry point, orchestrator, state machine, feature front door | up to 5 prose lines | WHY, architectural context, use cases + See-edge | + +**Visibility default — unexported symbols get no comment.** The tier table +prices exported API. An unexported function, type, constant, or variable +defaults to **zero** comment lines: the name is the documentation, and a name +that needs a comment wants a rename or an extraction first +(`R3-storifying.md`). The special case is **one line carrying a very +high-value toolbox item** — a constraint or fact the code cannot carry: an +ordering requirement, an external library quirk, the WHY of a magic number, +the package's one real policy. If a private symbol seems to need more than +that one line, the knowledge belongs to the exported symbol that uses it, the +package doc, or the feature doc. Case file: +`../examples/private-comment-noise.md` — nine commented private helpers; +four survive as one-liners, five get nothing. + +**Two bounded escape hatches:** + +- **Package docs in `doc.go`**: a package that genuinely earns more (data-flow + sketch, core-types list, design decisions all pulling their weight) moves its + package godoc to a dedicated `doc.go`, bounded at ~20–30 lines. A package comment + inline in a regular file stays within the standard budget. +- **Crossroads expand recommendation**: the writer never self-exceeds the 5-line + cap. When a critical crossroads would benefit from richer inline godoc beyond the + doc reference, write within budget and append an optional, end-of-report + recommendation — `consider expanding 's godoc inline — ` — + for a human to decide later (@documentation's report carries it). + +Never fill a template for its own sake — @documentation's templates are menus to +pick from, not forms to fill, and the tier budget caps how much of a menu any one +symbol can order. The one near-constant is the network edge: keep the +`See docs/.md` reference whenever a feature doc exists. + +Boundary with `R3-storifying.md`, stated precisely: block comments *inside* +function bodies are R3's (its Q3 — each is an extraction candidate, and the fix is +a function named after the comment); doc comments *on* exported symbols are this +rule's (Q4 below — they must carry why/context, not restate the identifier). + +### Edge conventions + +- **Code → docs** (rung 1 → rung 2): a literal relative path in the comment — + `See docs/retry-policy.md` — always on its own trailing line, never braided + into the summary sentence (the first sentence stays clean: "Package accounts + registers the /accounts REST endpoints.", then the See-line). Paths to docs + are fine; docs move rarely and Q2 verifies them mechanically. +- **Docs → code** (rung 2 → rungs 0–1): cite by **exported symbol** (`Policy`, + `ParsePolicy`); a **package or directory path** (`retry/`) only when a location + is genuinely needed; **file paths never, line numbers never** — they are the most + churn-prone coordinates in the repo (`R5-vertical-slice.md`'s slice reshaping + renames and splits files as a matter of course). A renamed exported symbol is a + deliberate, repo-wide-grep act, and a symbol is one grep or IDE-jump from its + file — symbols are the stable coordinates. Cite the **shortest token that greps + uniquely**: bare symbol by default; package-qualify only when the bare name is + ambiguous under repo-wide grep. Headers especially — a header is a landmark, not + a coordinate dump. +- **Symbol-less artifacts** (examples/, scripts/, testdata/, configs) are cited by + **directory**, paired with the exported symbols the artifact demonstrates when + any exist — a bare directory link loses drift detection; the paired symbols + restore it. +- **Test references** cite the test **package** (`the logger/ package tests`), + optionally its suite entry point (`TestSpanLoggerAPISuite`) — never individual + test functions. Test functions have no external callers and no deprecation + pressure, making them the repo's least stable symbols. Name one only when the + doc's point is that specific test's design. +- **Every docs→code edge is a literal, greppable token** — never a prose paraphrase + of code structure. Greppable edges make drift mechanically detectable (Q2); + paraphrases fail silently. +- **Edge density stays low**: entry points and key players only. The doc maps the + front doors; it does not mirror the tree. ASCII trees are welcome as orientation + devices at **package/directory granularity** — a directories-only tree is just a + set of package-path citations; file-level leaf entries are the violation. Prune + the leaves, keep the tree. +- **Links are one-way — write an edge only when no structure implies it.** A doc's + parent is `index.md` in its own directory, derivable from the path alone: never + write a child→parent backlink, and never a `related:` frontmatter key — body + links ARE the machine-readable graph. Lateral doc→doc links go inline, with the + relationship stated in the sentence that carries the link ("auth retries use the + capped-jitter policy — [retry-policy.md](retry-policy.md)"). The one axis no + structure carries is code↔docs — which is exactly why those edges are written in + both directions and grep-verified (Q2). There is no `## Related` section: + a relationship that cannot find a sentence in the body is not worth an edge. + +### Frontmatter — the doc root as an OKF bundle (rungs 2–3) + +The doc root conforms to Open Knowledge Format v0.2 (markdown bundle: one concept +per file, path = identity, links form the graph). R9 applies a **stricter profile** +on top; every key it requires is a valid OKF key, so the bundle stays consumable by +any OKF tool. + +- **Content docs** carry required `type` (`feature` / `architecture` / `guide` — + the spec's one required key) and `description` (one line — it IS the doc's + index line). Optional: `title` (the H1 is the title; the key never replaces + it), `generated` (OKF's provenance key: ISO 8601, last substantive update), + `tags`, and lifecycle keys `status: draft|stable|deprecated` and `stale_after` + — the frontmatter-native form of the ⚠️ stale flag. +- **Indexes carry no frontmatter** — OKF reserves `index.md` and keeps it bare, + with one spec-sanctioned exception: the root index carries `okf_version: "0.2"` + and nothing else. R9 requires that key (profile rule); any other key on any + index is a violation. +- **Drift-check rule**: a content doc's index line IS its `description`, copied + verbatim, prefixed ⚠️ when its lifecycle says so (`status: deprecated`, or + `stale_after` in the past) or when a bootstrap pass classified it stale. The + description is the single source; the conformance gate (Q7) fails when an index + line drifts from it. Sub-index lines in the root map are authored (a bare + sub-index has no `description` to copy) — keep them short. +- **Never emit `log.md`** — OKF reserves it for change history; this rule is + behavior-not-history, so the file must not exist in a doc root. +- **Broken links stay violations** — internally, OKF's dangling-link tolerance + would silence the drift alarm. The *(planned)* marker (Q2) is the one + sanctioned form of a not-yet-written reference. +- Copy-pasteable templates (content doc, root index, conventions doc) live in + @documentation's reference.md; only the policy lives here. + +### The index (rung 3) and the root + +- `index.md` lives in the doc root and MUST stay short: a concise reference guide, + **one line per doc**, grouped by topic. It is the map, not a doc. +- Past ~300 lines it becomes a **map of maps**, and the split is directory-shaped: + each topic becomes a subdirectory with its own bare `index.md` (OKF's + per-directory reserved file), and the root index shrinks to one short authored + line per sub-index. The imported root stays cheap and every doc is still two hops away + (the root map is hop 0 — it rides in with the CLAUDE.md import). The split moves + files, so it lands in the **same commit** as the Q2-driven rewrite of code-side + `See docs/...` paths — a moved doc with a stale code edge is a broken network + between commits. +- **Root wiring**: the routing block is authored ONCE, in AGENTS.md — a short + plain block (start at the index; conventions in `/conventions.md`) at + the repo root and, in a monorepo, nested per sub-project (closest file wins). + It serves every tool that reads AGENTS.md instead of CLAUDE.md. CLAUDE.md never + duplicates it: it embeds AGENTS.md via `@AGENTS.md` and adds the + `@/index.md` import (e.g. `@docs/index.md`) so the map itself is in + context at session start. +- **`/conventions.md` is the self-hosting doc** (`type: guide`): the + network's own maintenance rules — frontmatter templates, link rules, the + never-list — written for a contributor without this plugin. It is listed FIRST + in the index, one pointer line. + +### Doc root discovery and monorepos + +- Discovery order: `.ai/` → `.ainav/` → `docs/`. Use the first that exists; create + `docs/` if none does. +- Monorepo: each sub-project (its own `{{.ProjectMarker}}` or equivalent sub-project boundary) + gets its own doc root and index; the repo-root index links the sub-indexes. + Mechanical discovery is keyed on `{{.ProjectMarker}}` — a sub-project in another language + keeps its docs reachable through the root index, but is outside the gate's + bundle checks and Q2's code↔docs verification. Everything that touches code + (sub-project discovery, the declaration set, the code-edge grep, the + file-path ban, the symbol shapes) is supplied per language by the gate's + adapter block; this build carries the Go adapter. +- Nesting inside a doc root is allowed; the index (or a sub-index) covers every + file in it. + +## Fix pattern + +- **Push the fact down a rung**: delete the comment; make a rename or extraction + carry the knowledge instead (`R3-storifying.md`). The cheapest doc is a name. +- **Convert WHAT to WHY or delete**: rewrite the doc comment to carry context the + code cannot (rationale, incident, constraint, contract) — or remove it; a comment + that restates the identifier is negative-value. +- **Rewire orphan doc**: add its one line to `index.md` *and* add a code-side edge + (`See docs/.md`) from the package or type it describes — both invariants, + reachability and bidirectionality, in one move. +- **Wire the root**: add or repair the AGENTS.md routing block (plain lines + pointing at the index and `conventions.md` — authored once, there) and + CLAUDE.md's two imports: `@AGENTS.md` and `@/index.md`. CLAUDE.md + never restates the routing prose. +- **Add missing frontmatter**: verify-or-add the required keys on any content doc + that lacks them; copy the `description` into the doc's index line. Strip any + frontmatter an index carries beyond the root's `okf_version`. A `type` that + cannot be inferred from the doc's content is reported for a human call, never + guessed silently. +- **Update the stale doc with the behavior change**: rewrite the affected section to + describe current behavior — never append a changelog entry (the + behavior-not-history discipline lives in @documentation). + +## Falsifying questions + +Answer each with evidence (`file:line`, command output) — never a bare verdict. +{{include "rules/R9/falsifying-questions.md"}} diff --git a/core/scripts/check-repo-brain.sh b/core/scripts/check-repo-brain.sh new file mode 100755 index 0000000..02793f7 --- /dev/null +++ b/core/scripts/check-repo-brain.sh @@ -0,0 +1,539 @@ +#!/usr/bin/env bash +# Repo-brain conformance gate for the {{.Plugin}} plugin. +# +# Runs R9's mechanical falsifying questions over every doc root so CI — and +# developers without the plugin — can hold the documentation network's +# invariants. What it enforces is the R9 profile: a strict superset of OKF +# v0.2 (rules/R9-repo-brain.md is normative; /conventions.md is the +# in-repo copy). Installed into target repos by the documentation skill's +# BOOTSTRAP pass (/wire-repo-brain). +# +# Usage: bash scripts/check-repo-brain.sh [--fix] [repo-root] (default: cwd) +# CI: one line — bash scripts/check-repo-brain.sh +# --fix: rewrite drifted index lines from each target doc's `description` +# (the one mechanical repair; everything else stays report-only) +# +# Doc roots are discovered at the repo root AND at every sub-project (a +# directory holding the language's project marker — see the adapter below), +# using R9's order: .ai/ -> .ainav/ -> docs/. +# +# Language scope: the driver (everything outside the adapter block) is +# language-agnostic — Q1, Q3, Q7, doc links, the --fix rewriter. Everything +# that touches code — sub-project discovery, the declaration set, the +# code-edge grep, the file:line ban, the symbol token shapes — comes from the +# adapter block. This build carries the {{.Lang}} adapter; with no code files, the +# code<->docs checks are skipped and the rest still runs. +# +# Checks (numbering follows rules/R9-repo-brain.md's falsifying questions): +# Q1 orphans — every doc is reachable from its bundle's root index, +# transitively through sub-indexes +# Q2 edges — code→docs paths resolve; doc links resolve; doc-cited +# exported symbols grep in the repo; no file:line +# citations (URL spans stripped before the test) +# Q3 root wiring — CLAUDE.md or AGENTS.md in the root's owning project +# carries the exact /index.md path (a monorepo +# sub-root may instead be linked from the repo-root +# index); AGENTS.md missing the reference is an advisory +# Q7 bundle contract— content docs carry terminated frontmatter with +# type (feature|architecture|guide) and a non-empty +# description; indexes carry NO frontmatter except the +# root index's lone okf_version (required there, exactly +# one, valued "0.2"); no `related:` key; no log.md; +# every index line's text matches the target's +# `description` when it has one (⚠️ lines exempt; --fix +# rewrites drifted lines); a target stale by lifecycle +# (status: deprecated, or stale_after in the past) whose +# index line lacks ⚠️ is an advisory +# +# Heuristics (documented, deliberate): +# - links are inline-markdown only (`[name](path.md)`, optional "title" +# stripped); reference-style links are not checked. +# - docs→code checks backticked tokens shaped like the language's exported +# identifiers (adapter: LANG_SYMBOL_RE, LANG_QUALIFIED_RE) that contain a +# lowercase letter; other backticks (paths, flags, ALL-CAPS initialisms, +# ) are skipped. +# - resolution is against a declaration set built ONCE per run from the +# language's code files (adapter: lang_declarations), which carries +# ownership pairs: a qualified `pkg.Sym` or `Type.Method` resolves only +# against its declaring package or receiver — never against a same-named +# member elsewhere. A token missing from the set still resolves when it +# appears as a whole word in any non-markdown repo file (config keys, +# alert names, test helpers). A `pkg.Sym` whose package is not declared in +# this repo is external (stdlib, dependencies) and exempt. +# - lines carrying the ⚠️ stale flag or a *(planned)* marker are exempt from +# symbol resolution and the description copy check (R9 Q2/Q7 exemptions); +# the file:line ban has no exemption beyond URL spans, fenced code blocks, +# and glob patterns (a span containing `*` is a pattern, not a citation). +# - fenced code blocks (``` or ~~~, indented up to 3 spaces; toggle, not +# length-matched) are skipped for symbol resolution and the file:line ban. +# +# Exit codes: 0 clean (or repo has no doc root yet — advisory no-op) +# 1 one or more violations (details on stderr, summary last) +# 2 usage error, or an internal scanner failure (a failed scan is +# inconclusive, never silently clean) +# +# Uses only POSIX-portable tools: find, grep, sed, awk, head, sort, wc. No jq/python. +# awk programs avoid interval expressions ({m,n}) — mawk, Debian's default, rejects them. + +set -u + +FIX=0 +if [[ "${1:-}" == "--fix" ]]; then + FIX=1 + shift +fi +REPO_ROOT="${1:-$(pwd)}" +if [[ ! -d "$REPO_ROOT" ]]; then + echo "check-repo-brain: not a directory: $REPO_ROOT" >&2 + exit 2 +fi +cd "$REPO_ROOT" || exit 2 + +{{include "scripts/repo-brain-adapter.sh"}} + +# ---------- doc-root discovery: repo root + every sub-project ---------- +discover_docroot() { # -> docroot path or '' + local base="$1" d p + for d in .ai .ainav docs; do + if [[ "$base" == "." ]]; then p="$d"; else p="$base/$d"; fi + [[ -d "$p" ]] && { printf '%s\n' "$p"; return; } + done +} + +PROJS=() +ROOTS=() +seen_roots=" " +add_root() { # + local r + r=$(discover_docroot "$1") + [[ -z "$r" ]] && return + case "$seen_roots" in *" $r "*) return ;; esac + seen_roots="$seen_roots$r " + PROJS+=("$1") + ROOTS+=("$r") +} +add_root "." +while IFS= read -r p; do + add_root "$p" +done < <(lang_project_dirs) + +if (( ${#ROOTS[@]} == 0 )); then + echo "check-repo-brain: no doc root (.ai/, .ainav/, docs/) at the repo root or any $LANG_PROJECT_MARKER sub-project — nothing to check yet; run /wire-repo-brain to bootstrap" + exit 0 +fi +ROOT_BUNDLE="" +for i in "${!PROJS[@]}"; do + [[ "${PROJS[$i]}" == "." ]] && ROOT_BUNDLE="${ROOTS[$i]}" +done + +violations=0 +CUR_DOCROOT="${ROOTS[0]}" +fail() { + echo " $1 — see $CUR_DOCROOT/conventions.md" >&2 + violations=$((violations + 1)) +} +note() { echo " advisory: $1"; } + +fixed=0 +# fix_index_line — rewrite the text after " — " on one line +fix_index_line() { + local f="$1" n="$2" tmp="$1.repobrain.tmp" + NEWDESC="$3" awk -v n="$n" ' + NR == n { i = index($0, " — "); if (i > 0) $0 = substr($0, 1, i - 1) " — " ENVIRON["NEWDESC"] } + { print } + ' "$f" > "$tmp" && mv "$tmp" "$f" +} + +# canon -> physical path with .. resolved (empty if parent dir missing) +canon() { + local dir base + dir=$(dirname "$1") + base=$(basename "$1") + (cd "$dir" 2>/dev/null && printf '%s/%s\n' "$(pwd -P)" "$base") +} + +# resolve_link -> absolute path on stdout +# rc 0: resolved · rc 1: URL/anchor — nothing to check · rc 2: local target +# whose parent directory does not exist (a broken link, never a skip) +resolve_link() { + local from="$1" target="$2" docroot="$3" out + target="${target%%#*}" + target="${target%% *}" # strip optional "title" + [[ -z "$target" || "$target" == *"://"* ]] && return 1 + if [[ "$target" == /* ]]; then + out=$(canon "$docroot/${target#/}") # bundle-relative (OKF) + else + out=$(canon "$(dirname "$from")/$target") + fi + [[ -z "$out" ]] && return 2 + printf '%s\n' "$out" +} + +# frontmatter helpers ----------------------------------------------------- +fm_close_line() { # -> line number of closing --- (or '') + awk 'NR > 1 && /^---$/ { print NR; exit }' "$1" +} +fm_block() { # -> frontmatter body + sed -n "2,$(( $2 - 1 ))p" "$1" +} +desc_of() { # -> description value ('' if none) + local close + [[ "$(head -1 "$1" 2>/dev/null)" == "---" ]] || return 0 + close=$(fm_close_line "$1") + [[ -z "$close" ]] && return 0 + fm_block "$1" "$close" | grep -m1 '^description:' \ + | sed -e 's/^description:[[:space:]]*//' -e 's/[[:space:]]*$//' +} +TODAY=$(date +%F) +lifecycle_stale() { # -> reason when frontmatter marks the doc stale ('' otherwise) + local close fm sa + [[ "$(head -1 "$1" 2>/dev/null)" == "---" ]] || return 0 + close=$(fm_close_line "$1") + [[ -z "$close" ]] && return 0 + fm=$(fm_block "$1" "$close") + if printf '%s\n' "$fm" | grep -q '^status:[[:space:]]*deprecated'; then + printf 'status: deprecated' + return 0 + fi + sa=$(printf '%s\n' "$fm" | grep -m1 '^stale_after:' \ + | sed -e 's/^stale_after:[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/"//g') + [[ -n "$sa" && "$sa" < "$TODAY" ]] && printf 'stale_after: %s' "$sa" # ISO dates sort lexically + return 0 +} + +have_code=0 +lang_has_code && have_code=1 + +# ---------- declaration set: built once, queried per token ---------- +DECLS="" PKGS="" +if (( have_code )); then + DECLS=$(mktemp) PKGS=$(mktemp) DECL_ALL=$(mktemp) + trap 'rm -f "$DECLS" "$PKGS"' EXIT + if ! ( set -o pipefail; lang_declarations | sort -u > "$DECL_ALL" ); then + echo "check-repo-brain: internal scanner error — the declaration scan failed; treating the run as inconclusive" >&2 + exit 2 + fi + grep '^pkg:' "$DECL_ALL" | sed 's/^pkg://' > "$PKGS" + grep -v '^pkg:' "$DECL_ALL" > "$DECLS" + rm -f "$DECL_ALL" +fi + +is_repo_pkg() { grep -qxF "$1" "$PKGS" 2>/dev/null; } + +# One fence-aware awk pass per bundle extracts everything Q2's doc scan needs: +# P — a file:line citation outside fences/URLs/globs +# S — a backticked symbol-shaped token to resolve +# Tokens are then resolved as SETS (one grep against the declaration file, one +# repo-wide word grep for the whole unresolved batch) — never per token. +# Language-specific shapes arrive as -v variables: file_ext, file_re, sym_re, qual_re. +DOCSCAN_AWK=' +FNR == 1 { fence = 0 } +{ + line = $0 + if (line ~ /^ ? ? ?(```|~~~)/) { fence = 1 - fence; next } + if (fence) next + gsub(/[A-Za-z][A-Za-z0-9+.\-]*:\/\/[^ )>]*/, "", line) + pf = 0 + if (index(line, file_ext) > 0) { + n = split(line, sp, /[^A-Za-z0-9_*\/.~-]+/) + for (i = 1; i <= n; i++) { + s = sp[i] + sub(/\.+$/, "", s) + if (s ~ /\*/) continue + if (s ~ file_re) { pf = 1; break } + } + } + if (!pf && line ~ /(^|[^A-Za-z0-9_])line [0-9]+/) pf = 1 + if (pf) print "P\t" FILENAME "\t" FNR + if (index(line, "`") == 0) next + if (index(line, "⚠") > 0) next + if (index(line, "*(planned)*") > 0) next + m = split(line, seg, /`/) + for (i = 2; i <= m; i += 2) { + t = seg[i] + if (t !~ /[a-z]/) continue + if (t ~ sym_re || t ~ qual_re) + print "S\t" FILENAME "\t" FNR "\t" t + } +} +' + +# ---------- Q2: code→docs edges (repo-wide; resolved from repo root, then the +# citing file's own sub-project) ---------- +docroot_for_file() { # -> docroot of the longest matching project dir + local f="${1#./}" best="" i + for i in "${!PROJS[@]}"; do + local p="${PROJS[$i]}" + [[ "$p" == "." ]] && { [[ -z "$best" ]] && best="${ROOTS[$i]}"; continue; } + case "$f" in "$p"/*) best="${ROOTS[$i]}" ;; esac + done + printf '%s\n' "${best:-${ROOTS[0]}}" +} + +if (( have_code )); then + while IFS= read -r hit; do + file="${hit%%:*}"; rest="${hit#*:}"; line="${rest%%:*}"; target="${rest#*:}" + [[ -f "$target" ]] && continue + proj_ok=0 + for i in "${!PROJS[@]}"; do + p="${PROJS[$i]}"; [[ "$p" == "." ]] && continue + case "${file#./}" in "$p"/*) [[ -f "$p/$target" ]] && proj_ok=1 ;; esac + done + if (( ! proj_ok )); then + CUR_DOCROOT=$(docroot_for_file "$file") + fail "[Q2] $file:$line — code edge points at missing $target" + fi + done < <(lang_code_edges) +fi + +# ---------- per-bundle checks ---------- +check_bundle() { # + local proj="$1" docroot="$2" + CUR_DOCROOT="$docroot" + local root_index="$docroot/index.md" + local root_index_c + root_index_c=$(canon "$root_index") + + # --- Q1: transitive reachability from the bundle's root index --- + local reachable="" visited="" queue=("$root_index") + while (( ${#queue[@]} > 0 )); do + local idx="${queue[0]}"; queue=("${queue[@]:1}") + local idx_c; idx_c=$(canon "$idx") + case "$visited" in *"$idx_c"$'\n'*) continue ;; esac + visited="$visited$idx_c"$'\n' + [[ -f "$idx" ]] || continue + while IFS= read -r raw; do + local t="${raw#](}"; t="${t%)}" + [[ "$t" == *.md* ]] || continue + local resolved + resolved=$(resolve_link "$idx" "$t" "$docroot") || continue # broken links are Q2's report + reachable="$reachable$resolved"$'\n' + [[ "$(basename "$resolved")" == "index.md" ]] && queue+=("$resolved") + done < <(grep -oE '\]\([^)]+\)' "$idx" 2>/dev/null) + done + if [[ ! -f "$root_index" ]]; then + fail "[Q1] $docroot — no index.md: the bundle has no map" + fi + while IFS= read -r doc; do + [[ "$(basename "$doc")" == "log.md" ]] && continue # its own Q7 ban reports it + local c; c=$(canon "$doc") + [[ "$c" == "$root_index_c" ]] && continue + case "$reachable" in *"$c"$'\n'*) ;; *) + fail "[Q1] $doc — orphan: not reachable from $root_index" ;; + esac + done < <(find "$docroot" -type f -name '*.md') + + # --- Q2: every doc link resolves (a missing parent directory is just as + # broken as a missing file — only URLs/anchors are exempt) --- + while IFS= read -r md; do + while IFS= read -r raw; do + local t="${raw#](}"; t="${t%)}" + [[ "$t" == *.md* ]] || continue + local resolved rc=0 + resolved=$(resolve_link "$md" "$t" "$docroot") || rc=$? + (( rc == 1 )) && continue + if (( rc == 2 )) || [[ ! -f "$resolved" ]]; then + fail "[Q2] $md — link target does not exist: $t" + fi + done < <(grep -oE '\]\([^)]+\)' "$md" 2>/dev/null) + done < <(find "$docroot" -type f -name '*.md') + + # --- Q2: doc scan — file:line ban + docs→code symbol resolution. + # One awk pass extracts; resolution is set-based (see DOCSCAN_AWK above). --- + local scan; scan=$(mktemp) + if ! ( set -o pipefail + find "$docroot" -type f -name '*.md' -print0 \ + | xargs -0 awk -v file_ext="$LANG_FILE_EXT" -v file_re="$LANG_FILE_RE" \ + -v sym_re="$LANG_SYMBOL_RE" -v qual_re="$LANG_QUALIFIED_RE" \ + "$DOCSCAN_AWK" > "$scan" ); then + echo "check-repo-brain: internal scanner error — the doc scan failed in $docroot; treating the run as inconclusive" >&2 + rm -f "$scan" + exit 2 + fi + local f ln + while IFS=$'\t' read -r _ f ln; do + fail "[Q2] $f:$ln — cites a file path or line number (churn-prone coordinate)" + done < <(grep $'^P\t' "$scan") + if (( have_code )) && grep -q $'^S\t' "$scan"; then + local toks check members unres bad + toks=$(mktemp) check=$(mktemp) members=$(mktemp) unres=$(mktemp) bad=$(mktemp) + grep $'^S\t' "$scan" | cut -f4 | sort -u > "$toks" + # tokens to resolve (external pkg.Sym exempt). A qualified token resolves + # as the PAIR itself — the declaration set carries pkg.Ident / Type.Method + # ownership pairs, so `retry.Nope` never rides on a Nope declared elsewhere. + local t p m + while IFS= read -r t; do + case "$t" in + *.*) + p="${t%%.*}" + case "$p" in + [a-z]*) is_repo_pkg "$p" || continue ;; # external package (stdlib, deps) — exempt + esac + printf '%s\t%s\n' "$t" "$t" ;; + *) printf '%s\t%s\n' "$t" "$t" ;; + esac + done < "$toks" > "$check" + cut -f2 "$check" | sort -u > "$members" + grep -vxF -f "$DECLS" "$members" > "$unres" || true + if [[ -s "$unres" ]]; then + # ONE repo-wide word grep for the whole unresolved batch + local found; found=$(mktemp) + grep -rIhoFw --exclude-dir=vendor --exclude-dir=.git --exclude='*.md' \ + -f "$unres" . 2>/dev/null | sort -u > "$found" + grep -vxF -f "$found" "$unres" > "$bad" || true + rm -f "$found" + fi + if [[ -s "$bad" ]]; then + local full mem tok + while IFS=$'\t' read -r full mem; do + grep -qxF "$mem" "$bad" || continue + while IFS=$'\t' read -r _ f ln tok; do + [[ "$tok" == "$full" ]] \ + && fail "[Q2] $f:$ln — backticked \`$full\` does not resolve (${mem} not declared or found in the repo)" + done < <(grep $'^S\t' "$scan") + done < "$check" + fi + rm -f "$toks" "$check" "$members" "$unres" "$bad" + fi + rm -f "$scan" + + # --- Q3: root wiring (exact path; sub-roots may ride the repo-root index) --- + local rel="$docroot" + [[ "$proj" != "." ]] && rel="${docroot#$proj/}" + local claude="CLAUDE.md" agents="AGENTS.md" + [[ "$proj" != "." ]] && { claude="$proj/CLAUDE.md"; agents="$proj/AGENTS.md"; } + local wired_claude=0 wired_agents=0 + [[ -f "$claude" ]] && grep -qF -- "$rel/index.md" "$claude" && wired_claude=1 + [[ -f "$agents" ]] && grep -qF -- "$rel/index.md" "$agents" && wired_agents=1 + if (( ! wired_claude && ! wired_agents )); then + local via_root=0 + if [[ "$proj" != "." && -n "$ROOT_BUNDLE" ]]; then + grep -rqF -- "$docroot/index.md" "$ROOT_BUNDLE" --include='*.md' 2>/dev/null && via_root=1 + fi + if (( ! via_root )); then + fail "[Q3] $proj — neither $claude nor $agents references $rel/index.md" + fi + elif (( ! wired_agents )); then + note "[Q3] $agents lacks the $rel/index.md routing reference — AGENTS.md-reading tools start blind" + fi + + # --- Q7: bundle contract --- + while IFS= read -r md; do + local close fm key + [[ "$(basename "$md")" == "log.md" ]] && continue # its own Q7 ban reports it + if [[ "$(basename "$md")" == "index.md" ]]; then + local is_root=0 + [[ "$(canon "$md")" == "$root_index_c" ]] && is_root=1 + if [[ "$(head -1 "$md" 2>/dev/null)" != "---" ]]; then + # bare index — conformant, except the root must carry okf_version + (( is_root )) && fail "[Q7] $md — root index missing its okf_version frontmatter" + continue + fi + close=$(fm_close_line "$md") + if [[ -z "$close" ]]; then + fail "[Q7] $md — unterminated frontmatter (no closing ---)" + continue + fi + if (( ! is_root )); then + fail "[Q7] $md — frontmatter on a sub-index (indexes stay bare; okf_version belongs to the root alone)" + continue + fi + fm=$(fm_block "$md" "$close") + local nver + nver=$(printf '%s\n' "$fm" | grep -c '^okf_version:') + if (( nver == 0 )); then + fail "[Q7] $md — root index missing 'okf_version:'" + elif (( nver > 1 )); then + fail "[Q7] $md — duplicate okf_version keys (exactly one, valued \"0.2\")" + elif ! printf '%s\n' "$fm" | grep -qx 'okf_version: "0.2"'; then + fail "[Q7] $md — okf_version must be exactly \"0.2\" (the spec version the R9 profile is built on)" + fi + local extra + extra=$(printf '%s\n' "$fm" | grep -E '^[A-Za-z_-]+:' | grep -v '^okf_version:' | head -1) + [[ -n "$extra" ]] \ + && fail "[Q7] $md — root index frontmatter carries '${extra%%:*}:' (okf_version is the only allowed key)" + continue + fi + if [[ "$(head -1 "$md" 2>/dev/null)" != "---" ]]; then + fail "[Q7] $md — no frontmatter block (first line must be ---)" + continue + fi + close=$(fm_close_line "$md") + if [[ -z "$close" ]]; then + fail "[Q7] $md — unterminated frontmatter (no closing ---)" + continue + fi + fm=$(fm_block "$md" "$close") + if printf '%s\n' "$fm" | grep -q '^related:'; then + fail "[Q7] $md — 'related:' frontmatter key (links live in the body)" + fi + if printf '%s\n' "$fm" | grep -q '^type:'; then + printf '%s\n' "$fm" | grep -qE '^type:[[:space:]]*(feature|architecture|guide)[[:space:]]*$' \ + || fail "[Q7] $md — 'type:' must be feature, architecture, or guide (R9 profile)" + else + fail "[Q7] $md — frontmatter missing 'type:'" + fi + if printf '%s\n' "$fm" | grep -q '^description:'; then + printf '%s\n' "$fm" | grep -qE '^description:[[:space:]]*[^[:space:]]' \ + || fail "[Q7] $md — empty 'description:' (it IS the doc's index line)" + else + fail "[Q7] $md — frontmatter missing 'description:'" + fi + done < <(find "$docroot" -type f -name '*.md') + + # --- Q7: drift check — index line text == target's description (⚠️ exempt; + # --fix rewrites drifted lines after the read loop, never during it) --- + while IFS= read -r idx; do + local lineno=0 line fixes="" + while IFS= read -r line; do + lineno=$((lineno + 1)) + case "$line" in *'⚠️'*) continue ;; esac + printf '%s' "$line" | grep -qE '^- \[[^]]*\]\([^)]*\.md[^)]*\) — ' || continue + local t; t=$(printf '%s' "$line" | sed -E 's/^- \[[^]]*\]\(([^)]*)\).*/\1/') + local tail="${line#* — }" + tail=$(printf '%s' "$tail" | sed -e 's/[[:space:]]*$//') + local resolved + resolved=$(resolve_link "$idx" "$t" "$docroot") || continue # broken links are Q2's report + [[ -f "$resolved" ]] || continue + local lc; lc=$(lifecycle_stale "$resolved") + [[ -n "$lc" ]] \ + && note "[Q7] $idx:$lineno — $(basename "$resolved") is stale by lifecycle ($lc) but its index line carries no ⚠️ flag" + local desc; desc=$(desc_of "$resolved") + [[ -z "$desc" ]] && continue + if [[ "$tail" != "$desc" ]]; then + if (( FIX )); then + fixes="${fixes}${lineno}"$'\x1f'"${desc}"$'\n' + else + fail "[Q7] $idx:$lineno — index line drifted from $(basename "$resolved")'s description" + fi + fi + done < "$idx" + if [[ -n "$fixes" ]]; then + local n d + while IFS=$'\x1f' read -r n d; do + [[ -z "$n" ]] && continue + fix_index_line "$idx" "$n" "$d" + fixed=$((fixed + 1)) + echo " fixed: $idx:$n — index line rewritten from its target's description" + done <<< "$fixes" + fi + done < <(find "$docroot" -type f -name 'index.md') + + # --- Q7: no log.md --- + while IFS= read -r lg; do + fail "[Q7] $lg — log.md is reserved for change history; docs describe current behavior" + done < <(find "$docroot" -type f -name 'log.md') +} + +for i in "${!PROJS[@]}"; do + check_bundle "${PROJS[$i]}" "${ROOTS[$i]}" +done + +# ---------- summary ---------- +(( fixed > 0 )) && echo "check-repo-brain: rewrote $fixed drifted index line(s)" +if (( violations > 0 )); then + echo "check-repo-brain: $violations violation(s) — rules: /conventions.md" >&2 + exit 1 +fi +echo "check-repo-brain: clean (${ROOTS[*]})" +exit 0 diff --git a/core/skills/code-designing/SKILL.md b/core/skills/code-designing/SKILL.md new file mode 100644 index 0000000..cf37cbd --- /dev/null +++ b/core/skills/code-designing/SKILL.md @@ -0,0 +1,160 @@ +--- +name: code-designing +description: | + FORWARD view over rules/ — domain type design and architectural planning for {{.Lang}} code BEFORE it exists. + Use when planning new features, designing self-validating types, preventing primitive obsession, or when refactoring reveals need for new types. + Dispatches into the Design guidance sections of rules/R1-R8 and R10-R12. +allowed-tools: + - Skill({{.Plugin}}:testing) +--- + + +The design phase applied BEFORE code exists. This skill is a thin directional view: +every design principle lives exactly once in `../../rules/` — this protocol says +which rule to open at which design step, and what shape the output takes. + +Backward counterpart (fixing code that already fails lint/review): @refactoring. + + + +**CRITICAL**: When this skill says "Use @skill-name", you MUST invoke it with the +**Skill tool** — do not just mention it. + +| Notation | Skill Tool Call | +|----------|-----------------| +| @testing | `Skill({{.Plugin}}:testing)` | + + + +- Planning a new feature (before writing code) +- Refactoring reveals need for new types (@refactoring escalates here) +{{include "skills/code-designing/linter-triggers.md"}} +- A Phase 4 review CLUSTER (≥2 hunters converging on one anchor — + @linter-driven-development routes it here) → **cluster-scoped mode**: skip + `` and the user-OK step (acceptance was inherited when the + findings were accepted); design only the one concept the cluster names — its + type or dispatch shape (R11), constructor (R2), mutation surface (R12), and + placement (R4) — so every member finding resolves as a consequence of that one + design. Return the mini DESIGN PLAN to the caller; @refactoring implements it. + + + + + +**Default: vertical slice architecture** — `../../rules/R5-vertical-slice.md`. + +{{include "skills/code-designing/layout-scan.md"}} + +1. **Pure vertical** → continue the pattern: implement as `internal//`. +2. **Pure horizontal** → propose starting migration (template in R5's Design + guidance); implement the new feature as the first vertical slice. +3. **Mixed** → check `docs/architecture/vertical-slice-migration.md`, continue as a slice. + +Architecture advises, it doesn't veto (R5's advisory posture). Ask the user: +Option A — vertical slice (recommended); Option B — match the existing pattern +(time pressure and team conventions are valid reasons). + + + +What is the problem domain? The main concepts/entities? The invariants and rules? +How does this fit the existing architecture? + + + +Design happens before a diff exists — no detection command can run yet, so questions +are the tool. Interrogate the plan with `../../maxims.md` (the questions live there, +once; ask them, don't restate them): + +- Every function that receives another type's data → **Tell, don't ask**: does the + decision it makes belong on that type? +- Every planned interface → **The bigger the interface, the weaker the abstraction**: + could it be one method? +- Every planned type → **Make illegal states unrepresentable** vs **Make the zero + value useful**: validated domain type (constructor) or mechanism type (useful + zero)? Pick a family. +- Every planned check → **Parse, don't validate**: does it return a more-typed value + or a boolean someone must remember? +- Every shared helper → **A little copying is better than a little dependency**: is + the third strike actually here? + +Answers shape the plan; they are never findings. Maxims propose, evidence disposes — +the review phases convict only via rules (`maxims.md`, contract section). + + + +For each concept in the design, open the rule that owns the question and apply its +**Design guidance** section: + +| Rule | When designing, apply... | +|------|--------------------------| +| `../../rules/R1-primitive-obsession.md` | Which primitives become types — score every candidate with R1's juiciness scorecard; reject ceremony wrappers (over-abstraction trap). | +| `../../rules/R2-self-validating-types.md` | Constructor-only entry, validation ownership, trusting composed values, nil is not a value, no defensive checks in methods. | +| `../../rules/R3-storifying.md` | Plan orchestration functions as 3–5 named steps at one conceptual level; honest names for mutators. | +| `../../rules/R4-helper-placement.md` | WHERE each helper/type lands — the placement ladder (unexported → feature sub-package → shared domain package). | +| `../../rules/R5-vertical-slice.md` | Package structure and naming: feature slices with roles inside, flatcase domain vocabulary, migration template. | +| `../../rules/R6-test-only-interfaces.md` | Default dependencies to concrete types; an interface must be earned by a second production implementation or a grep-verified import cycle. | +| `../../rules/R7-test-placement.md` | The test plan per type: leaf types 100% unit coverage via public constructors; orchestrators integration-tested over real collaborators. | +| `../../rules/R8-no-globals.md` | Dependencies injected via constructors, `ctx` threaded from callers, globals only at entry points. | +| `../../rules/R10-concurrency-safety.md` | Every planned goroutine gets an owner (stop + wait) and an exit path at construction time; shared state designed with its guard on one type — or designed away via handoff/confinement. | +| `../../rules/R11-conditional-dispatch.md` | How each kind/variant family dispatches: behavior-heavy or open set → interface chosen once at the boundary; single-behavior variance → strategy map; single-site closed enum → one exhaustive switch (named enum per R1). | +| `../../rules/R12-mutation-discipline.md` | Each type's mutation surface: constructors copy slice/map arguments; queries return copies or iterators, never internal references; no setters around validating constructors; query and modifier as separate methods. | + + + +Before presenting the plan, verify against the rules (cite, don't restate): + +- [ ] No primitive obsession; every proposed type scored, ceremony rejected (R1) +- [ ] Types are self-validating; composed types trusted, never re-validated (R2) +- [ ] Orchestration planned as a story; most logic pushed into leaf types (R3, R7) +- [ ] **Placement decided** for every helper and type via the ladder — unexported helper vs feature sub-package vs domain package (`../../rules/R4-helper-placement.md`) +- [ ] Vertical slice structure; package names are flatcase domain vocabulary, never roles/containers (R5) +- [ ] No test-only interfaces: every interface has a second production implementation OR breaks a real import cycle, verified by grepping the import direction (detection command in `../../rules/R6-test-only-interfaces.md`); otherwise depend on the concrete type +- [ ] Import direction strictly downward: leaf types ← sub-packages ← parent ← cmd/ (cycle-breaking move in @refactoring ``) +- [ ] Dependencies constructor-injected and validated; ctx flows down; no new globals (R8, R2) +- [ ] Every goroutine has an owner and exit path; shared state guarded where it lives, or confined (R10) +- [ ] Every kind/variant family has ONE dispatch owner — interface, strategy map, or a single exhaustive switch; no discriminator inspected in two places (R11) +- [ ] Every validated type's mutation surface is closed: slice/map arguments copied in, internal collections never returned by reference, no unvalidated setters (R12) + + + + + +``` +DESIGN PLAN + +Feature: [Feature Name] + +Core Domain Types (leaf): +- [Type] ([underlying]) — invariant it owns; juiciness verdict (R1) + +Orchestrating Types: +- [Type] — dependencies (concrete unless R6-justified), methods + +{{include "skills/code-designing/package-structure.md"}} + +Placement Decisions (R4): +- [helper/type] → rung 1/2/3 and why + +Design Decisions: +- [decision] — rationale, citing the owning rule + +Integration Points: +- Consumed by / depends on / events + +Next Steps: +1. Create types with validating constructors +2. Write unit tests for each leaf type → use @testing skill +3. Implement orchestrators; integration tests over real collaborators +``` + + + +Design phase is complete when ALL are true: + +- [ ] Architecture pattern analyzed (vertical/horizontal/mixed) and user chose an option +- [ ] Core domain types identified, each with its validation rules and R1 score +- [ ] Placement decision recorded for every new type/helper (R4 ladder) +- [ ] Package structure follows R5 (slices, naming, downward imports) +- [ ] Design checklist answered satisfactorily (every box cites its rule) +- [ ] Design plan presented in the output format above + diff --git a/core/skills/documentation/SKILL.md b/core/skills/documentation/SKILL.md new file mode 100644 index 0000000..d8c5fd8 --- /dev/null +++ b/core/skills/documentation/SKILL.md @@ -0,0 +1,235 @@ +--- +name: documentation +description: | + The repo-brain author/maintainer: writes behavior-focused documentation and wires it + into the documentation network defined by rules/R9-repo-brain.md. + FEATURE mode (default): after feature implementation or bug fixes — invoked by + @linter-driven-development (Phase 5) — to document HOW THE PRODUCT BEHAVES and wire + it into the network. + BOOTSTRAP mode: on request ("set up docs", "create an index", "make this repo + AI-navigable", /wire-repo-brain) or when FEATURE mode finds no doc root — discovers + the doc root, verifies-or-adds OKF frontmatter, builds index.md, wires + CLAUDE.md/AGENTS.md, wires missing code→docs edges, installs conventions.md and + the conformance check script, reports gaps. + NOT a changelog - documents current behavior, not change history. +allowed-tools: + - Read + - Grep + - Glob + - Bash + - Write + - Edit + - Agent +--- + + +Author and maintain the repo brain: a documentation network where any entry point — a +grep hit on a symbol, a file open, CLAUDE.md at session start — reaches full context +within two hops. Everything normative (the documentation ladder, both network +invariants, the comment policy, the edge policy, the index policy, the OKF +frontmatter/bundle policy, root wiring, doc-root discovery) lives ONCE in +`../../rules/R9-repo-brain.md`; this skill is the +actor that applies it. Templates live in `reference.md` — they are menus, never forms. + + + +**The 5-Year Reader Test**: someone reading this in 5 years doesn't care that "we +fixed a bug where X happened" — they want to know how X works NOW. This holds for +comments as much as docs: a PR number, review item, or "the previous behavior" +narration in a godoc is provenance, not behavior (R9's floor names it a failure +mode). + +**Behavior over history**: document what the product DOES, not what changed. A bug fix +updates the affected section to describe correct behavior; it never appends a "Fixed:" +entry (worked examples in reference.md, "Bug Fix Documentation"). + +**Conciseness over completeness**: a focused doc that gets read beats an exhaustive +doc that gets skipped. + +**Plain words over clever words**: everyday English, short sentences, one idea per +sentence. Write for a fresh graduate whose first language may not be English — a +doc that needs a dictionary fails even when it is true (R9's plain-English/empathy +test; applies to godocs and feature docs alike). + +**Comments stand alone**: a reader must understand the comment BEFORE reading the +code. If reading the code is required to understand the comment, the comment adds +negative value — no decoder-ring IDs, no forward references to other comments +(R9's empathy test, second half). + + + +**FEATURE** is the default: run it after a feature or bug fix lands (ldd Phase 5). +Switch to **BOOTSTRAP** when the user asks to wire a repo ("set up docs", "create an +index", "make this repo AI-navigable") or when FEATURE step 1 finds no doc root. +Skip entirely for individual commits and internal refactors that change no behavior — +unless an R9 Q6 check shows a doc citing the reshaped code. + + + +1. **Scope**: establish what shipped — the feature's commits/diff, which packages and + entry points it touches. +2. **Place each fact on the documentation ladder**: apply R9's rung table and + placement rule (`../../rules/R9-repo-brain.md`, Design guidance). Before writing + any comment, first check whether a rename or extraction makes it unnecessary. +3. **Rung 1 — godoc**: write/refresh doc comments per R9's comment policy — + **exported symbols only by default**: an unexported symbol gets NO comment + (R9's visibility default) unless one line carries a very high-value toolbox + item the code cannot show; never more than that one line. Every comment + must pass R9's three-test standard BEFORE it is written (toolbox-value, + tier budget, plain English), with content picked FROM the Comment Value Toolbox + (catalog in reference.md) for the symbol's tier: 1–5 prose lines, helper / + contract / crossroads; overflow moves to the feature doc. Keep the + `See docs/.md` edge wherever a feature doc exists. A package that + earns more moves its godoc to `doc.go` (R9's ~20–30 line bound). A crossroads + that deserves richer inline godoc stays within budget and gets an expand + recommendation in the report — never extra lines. Add testable examples + (`Example_*`) for complex/core types. +4. **Rung 2 — feature doc**: create/update `/.md` from the + reference.md template, with OKF frontmatter (required keys — R9's bundle + policy); lateral doc links inline, each in a sentence stating the + relationship (R9 edge policy — no `Related` section); + key players as `Symbol | Role | Package`; entry points cite symbols — never + file paths or line numbers (R9 edge policy). Bug fix → update the existing + doc's affected section; do not create a new doc. +5. **Rung 3 — the map**: add/refresh the doc's one line in `index.md` — copied + from the doc's `description` (R9 drift-check rule); verify root wiring + (`@/index.md` import in CLAUDE.md, AGENTS.md routing block). +6. **Self-check**: run R9's falsifying-question detections on the touched scope — + Q1–Q3 and Q7 mechanically (orphans, broken edges in both directions, unwired + root, bundle contract — the repo's `scripts/check-repo-brain.sh` runs all four + in one pass when installed), Q4–Q6 over the diff (WHAT-comments, naked exported + API, silently-changed doc). The detection commands live in R9; never restate + them. Fix every hit before reporting. +7. **Comment critique**: spawn the `comment-critic` agent (Agent tool) on the full diff — + not just the comments this run wrote; in-body comments left by earlier phases + are in scope too. Its spawn prompt MUST contain: (a) R9's comment-policy + section pasted verbatim (toolbox kinds, three-test standard, tiers, budget + accounting, visibility default); (b) reference.md's Comment Value Toolbox + catalog pasted verbatim; (c) the absolute path to + `../../examples/private-comment-noise.md`; (d) the diff scope. Apply every non-KEEP verdict (this skill is the rung-1 + fixer): DELETE and TRIM as returned; REWRITE using the critic's proposal; + `DELETE → route R3` verdicts are deleted here and reported as R3 leads for the + caller — never fixed here (extraction is @refactoring's move). Then re-spawn + the critic ONCE to confirm clean; a still-dirty re-critique is reported as-is, + never looped further. +8. **Report** in the FEATURE output format below. + + + +1. **Discover doc root(s)** per R9's discovery order (`.ai/` → `.ainav/` → `docs/`; + create `docs/` if none exists). Monorepo → one doc root + index per sub-project. +2. **Inventory existing docs**, classify each (feature / architecture / guide / + stale — classification table in reference.md), and **verify-or-add frontmatter** + (migration guidance in reference.md): a doc already conformant is left alone; an + un-inferable `type` goes to the advisory report, never guessed. +3. **Build or rebuild `index.md`**: bare except the root's `okf_version`, grouped + by topic, one line per doc — each line copied from the doc's `description` + (R9 drift-check rule); past ~300 lines it becomes a directory-shaped map of + maps, and the split lands in the same commit as the `See docs/...` path + rewrite (R9 index policy; templates in reference.md). +4. **Wire the root**: author the routing block once, in AGENTS.md — repo root + and, in a monorepo, nested per sub-project — then wire CLAUDE.md with the + `@AGENTS.md` embed plus the `@/index.md` import (create a minimal + CLAUDE.md section if none exists; never restate the routing prose there). + Add or verify; snippets in reference.md. +5. **Teach and enforce**: create-or-verify `/conventions.md` (template in + reference.md) — the ONE content file bootstrap generates (network + infrastructure, not a content doc) — listed FIRST in the index; copy the + plugin's `scripts/check-repo-brain.sh` into the target repo's `scripts/` + (verify-or-copy — a diverged copy is reported, never overwritten). The report + suggests CI wiring as plain `bash scripts/check-repo-brain.sh`; never add a + workflow file. +6. **Wire missing upward edges**: for each indexed (non-stale) doc with no code-side + edge, add ONE line — `{{.CommentPrefix}} See /.md ...` — to the front-door anchor's + existing doc comment (anchor heuristic in reference.md), {{include "skills/documentation/edge-verification.md"}} Wiring only: never rewrite the comment around it, never wire a stale + doc (its ⚠️ index flag is the finding), and skip — as a reported gap — any doc + whose anchor you cannot identify with confidence. +7. **Confirm and report**: re-run R9 Q1–Q3 and Q7 as confirmation — via the + installed script — a Q1 hit (a doc with no index line) means step 3 didn't land, + a Q3 hit means step 4 didn't, a Q7 hit means step 2 or 5 didn't; repair any + before reporting, and verify every edge added in step 6 resolves. The ADVISORY + findings list carries Q2 hits plus rung-2 gaps (two-signal criterion in + reference.md), any doc left unwired in step 6, and any `type` needing a human + call. Bootstrap wires and maps; it NEVER mass-generates content docs — those are + written incrementally by FEATURE mode. + + + +FEATURE mode: +``` +DOCUMENTATION COMPLETE — FEATURE mode +Feature: + +Artifacts: +- /.md (created/updated) +- godoc: +- testable examples: +- index.md: + +Network edges added: +- code→docs: /.md +- docs→code: +- root: @/index.md in CLAUDE.md (verified/added) + +R9 self-check: Q1–Q3, Q7 clean · Q4–Q6 clean over diff + (or per hit: : — fixed by ) + +Comment critic: reviewed — deleted · trimmed · rewritten · + clean on re-critique (or: ) + R3 leads (in-body extraction candidates, for the caller): (omit when none) + +Expand recommendations (optional — omit the section when none): +- — consider expanding its godoc inline beyond the doc reference: + +``` + +BOOTSTRAP mode: +``` +BOOTSTRAP COMPLETE +Doc root(s): +Index: /index.md built — docs, groups; map of maps: +Frontmatter: verified, added +Root wiring: CLAUDE.md @import · AGENTS.md routing block +Conventions: /conventions.md +Check script: scripts/check-repo-brain.sh — suggest CI: bash scripts/check-repo-brain.sh +Upward edges: wired — (), ... + +Advisory findings (reported, not fixed — FEATURE mode writes content): +- unwired: — indexed, but no confident front-door anchor; needs a human call +- broken edge: (unresolved) +- gap: +- stale: — indexed with ⚠️ flag; cites unresolved ; not edge-wired +- type?: — class not inferable; needs a human call +- diverged script: scripts/check-repo-brain.sh differs from the plugin's — not overwritten +``` + + + +- Every fact sits at its lowest viable rung of the documentation ladder; nothing + duplicated across rungs (R9 placement rule). +- New/updated docs joined the network: indexed, root-wired, edges in both directions. +- FEATURE: the R9 self-check ran and every hit was fixed before reporting. +- FEATURE: the comment-critic ran over the full diff, every non-KEEP verdict was + applied (R3 routes reported, not fixed), and the one re-critique confirmed clean + — or the remainder is reported as-is. +- BOOTSTRAP: root(s) + index + root wiring + conventions.md + check + script exist; frontmatter verified-or-added on every content doc; every + confidently-anchorable doc has an upward edge; gaps reported; zero content docs + generated (conventions.md and the copied script are the two sanctioned + artifacts). +- All prose passes the 5-year reader test; zero changelog-style entries. + + + +This skill MUST NOT: +- Restate R9 content — the documentation ladder, invariants, and policies are cited, + never copied. +- Append change history to docs — current behavior only, always. +- Mass-generate content docs in BOOTSTRAP mode — advisory gap report only + (conventions.md and the copied check script are the two sanctioned artifacts). +- Fill templates for their own sake — reference.md's templates are menus; R9's + comment policy decides what earns its place. +- Spawn anything other than `comment-critic`, loop the critique more than one + fix-and-recheck round, or fix `DELETE → route R3` verdicts itself (extraction + belongs to @refactoring). + diff --git a/core/skills/linter-driven-development/SKILL.md b/core/skills/linter-driven-development/SKILL.md new file mode 100644 index 0000000..65b3945 --- /dev/null +++ b/core/skills/linter-driven-development/SKILL.md @@ -0,0 +1,228 @@ +--- +name: linter-driven-development +description: | + META ORCHESTRATOR for any {{.Lang}} code change that should end in a commit (features, bug fixes, refactors). + WHEN: User requests {{.Lang}} code work (implement, fix, add, refactor), mentions "@ldd"/"ldd", or runs a /{{.CmdPrefix}}-* command in a {{.Lang}} project. + Runs the five-phase workflow (PREPARE is an autonomous sub-phase, 1.5): DESIGN → PREPARE → IMPLEMENT (per-behavior TDD loop) → FULL LINT (lint-fixer agent) → REVIEW (per slice) → SHIP. +allowed-tools: + - Skill({{.Plugin}}:code-designing) + - Skill({{.Plugin}}:testing) + - Skill({{.Plugin}}:refactoring) + - Skill({{.Plugin}}:pre-commit-review) + - Skill({{.Plugin}}:documentation) + - Agent +--- + + +Top-level protocol for {{.Lang}} implementation work: five phases plus the autonomous +PREPARE sub-phase (1.5), where Phase 2 is a per-behavior TDD loop. Rule knowledge lives once in `../../rules/` — this skill never +restates it; it sequences the thin skills (which dispatch into the rules) and the +lint-fixer agent, at the cadence each check's economics demand. + + + +- User requests {{.Lang}} code work (implement, fix, add, refactor, update, change) and the + project is {{.Lang}} (`{{.ProjectMarker}}` or `.go` files present) +- User mentions "ldd" or "@ldd" +- A `/{{.CmdPrefix}}-*` command invokes this skill +On trigger, announce: **"Using {{.CmdPrefix}} workflow for this {{.Lang}} code work"** and run pre-flight. + + + +"Invoke @skill-name" means: call the **Skill tool**. Never just mention the skill, +never read its file directly. + +| Notation | Skill Tool Call | +|----------|-----------------| +| @code-designing | `Skill({{.Plugin}}:code-designing)` | +| @testing | `Skill({{.Plugin}}:testing)` | +| @refactoring | `Skill({{.Plugin}}:refactoring)` | +| @pre-commit-review | `Skill({{.Plugin}}:pre-commit-review)` | +| @documentation | `Skill({{.Plugin}}:documentation)` | + +The lint-fixer agent is spawned with the **Agent tool**: +`subagent_type: "{{.Plugin}}:lint-fixer"`. + + + +``` +1 DESIGN @code-designing → DESIGN PLAN → user OK +1.5 PREPARE survey plan's touch points → four gates decide autonomously → + (@refactoring, preparatory mode) → prep commit(s) · PREPARATION LOG (record, no stop) +2 IMPLEMENT — per behavior: + ┌─> RED one failing test, lowest rung (@testing) + │ test resists? → prep signal → @refactoring (preparatory) → re-enter RED + │ GREEN minimum code to pass — no design work + │ REFACTOR pkg-scoped lint + rule greps; hits → (@refactoring) + └── next behavior until all done +3 FULL LINT ONE run via lint-fixer agent (Agent tool) + mechanical → FIXED · design → ESCALATED → back to 2's REFACTOR +4 REVIEW per completed slice: @pre-commit-review → fix → INCREMENTAL re-run +5 SHIP @documentation → commit summary → user commits +``` + + + +{{include "skills/linter-driven-development/pre-flight.md"}} +3. **List the behaviors** this change delivers — each becomes one Phase 2 TDD cycle. + No plan or unclear scope → Phase 1 produces the plan; unclear intent → ask. + + + +Invoke @code-designing. It runs the architecture scan, scores candidate domain types, +records an R4 placement decision for every helper/type, and presents a DESIGN PLAN +for user OK. **Do not start Phase 2 until the user approves the plan** — the RED +tests target this designed public API, which is how the design reaches GREEN. + + + +Preparatory refactoring (Fowler: "make the change easy, then make the easy change"): +reshape what the approved plan is about to touch, BEFORE the first RED, so the +feature lands as add-only. Runs **autonomously** — the four gates below decide; +this phase never asks the user. + +**Survey**: for each file/package the DESIGN PLAN touches (integration points, +functions it extends, packages receiving new code), run the rule detection greps from +the `../../rules/R*.md` Falsifying questions, scoped to those files only. Same +commands as the REFACTOR step, different premise: this code is probably lint-green +and can still be hostile to the plan. + +**Four gates per finding — all mechanical, no user questions:** + +1. **MULTIPLY** — would landing the plan add an instance of this violation or force a + workaround (a new case in an already-duplicated switch, R11; new behavior on a raw + primitive, R1; a new step in an at-limit function, R3; new code testable only by + mutating a global, R8)? No → not preparation; leave it for Phase 4's advisory + report. +2. **SAFE** — are the paths to reshape covered (`go test -cover` on the touched + packages)? Uncovered → write characterization tests through the public API first + (@testing); they are the move's safety net and keep their value after. When the + missing test seam IS the finding (globals block testing), the prep move creates + the seam — R8's Extract Clean Island exists for exactly this. +3. **BOUNDED** — effort S/M (hunter scale) → proceed. L → defer to Phase 4's report + as `PREP-DEFERRED`, UNLESS gate 2 showed the feature cannot be tested at all + without it — then it is not preparation but a design-plan gap: return to Phase 1. +4. **SKEPTICIZED** — any prep move that creates a type/interface/package is judged by + the `overabstraction-skeptic` (Agent tool; payload per @pre-commit-review step 3), with + one sharpening in the spawn prompt: the justification is the approved plan in + hand, not an imagined future — score the extraction as if the feature already + existed. REFUTED → apply the cheaper alternative or defer. + +**Apply** the survivors via @refactoring (``); full test suite and +lint green after every move; land the prep work as its own commit(s) before the first +RED — the Two Hats at commit granularity, and the reviewer sees reshaping and feature +separately. + +**Emit a PREPARATION LOG** — a record, not a question; the loop continues: + +``` +PREPARATION LOG +Touch points surveyed: [files] · findings: N +Applied: [rule → move → commit] (gates: multiply ✓ safe ✓ bounded ✓ skeptic ✓/n-a) +Deferred to Phase 4: [finding — failed gate] +Feature landing shape after prep: [add-only / near-add-only / unchanged] +``` + +Zero findings passing the gates is the common case — say so in one line and move on. + +Inverse trap: reshaping files the plan does not touch is litter-pickup wearing prep's +clothes — a different activity on a different budget; pre-building abstractions this +plan does not need is speculative generality — gate 4 exists to kill it. + + + +One TDD cycle per behavior: + +**RED** — write ONE failing test for the behavior. Place it by the composition +ladder — the lowest rung that contains the behavior (@testing, +``). Run it; confirm it fails for the right reason. + +If the test *resists* — fixture surgery, mutating globals to reach the behavior, +driving three layers to observe one seam — do not force it: that friction is a prep +signal Phase 1.5's survey missed. Suspend the cycle, route the friction through the +same four PREPARE gates, apply via @refactoring (``), land the prep +commit, re-enter RED. Autonomous, like Phase 1.5 — no user question. + +**GREEN** — minimum code to pass. Explicitly allowed to be ugly; no design polish in +this step. **Never invoke @code-designing from GREEN**: the design already happened +in Phase 1 and reaches GREEN through the RED test's shape. If GREEN reveals the +design is wrong (a type doesn't fit, a hidden concept emerges): finish the cycle, +then route through REFACTOR → @refactoring → its escalation to @code-designing. +Design revision is a deliberate checkpoint, never a mid-GREEN detour. + +**REFACTOR (linter-driven)** — on the code just written: +{{include "skills/linter-driven-development/pkg-lint.md"}} +2. Cheap rule greps: run the detection commands from the **Falsifying questions** + sections of the `../../rules/R*.md` files relevant to what was written. +Any hit → invoke @refactoring: its `` routes each failure to the +owning rule's Fix pattern. The linter says WHAT to refactor; the rules say HOW. +Fix now — these findings are mechanical and local: cheapest at this moment, +compounding if deferred. + +Loop to the next behavior until all behaviors are done. + + + +Delegate ONE full lint run to the lint-fixer agent (Agent tool, isolated context — the +fix loop's token noise stays out of this conversation). Full-repo lint catches what +package-scoped runs cannot: cross-package issues and whole-file/whole-package rules +(file-length-limit, package-size zones). + +The agent returns `FIXED` (mechanical — done) and `ESCALATED` (design-level, each +with a rule route from its embedded routing table). Route every escalation back +through the Phase 2 REFACTOR step — invoke @refactoring with the routes; **never +auto-redesign here**. Package-size escalations follow @refactoring +`` (decomposition lands in its own commit). Repeat Phase 3 +until the agent reports `LINT STATUS: green`. + + + +Per completed vertical slice (multi-slice work reviews each slice as it completes), +invoke @pre-commit-review — it orchestrates parallel rule hunters plus the +over-abstraction skeptic; it spawns agents and reports, **never edits**. + +NOT mid-implementation (its `` contract): GREEN-step code is supposed to +look under-designed, so reviewing it produces false positives — and the hunters' +fresh-context value only pays on finished work. The REFACTOR-step greps are the +mid-implementation net; this pass is the verification net. + +Findings return categorized (Bugs / Design Debt / Readability Debt / Polish), all +advisory. Fix bugs and user-accepted findings via @refactoring — except accepted R9 +(documentation-network) findings, whose fixer is @documentation — then re-invoke +@pre-commit-review in INCREMENTAL mode until the delta reports clean. + +**Cluster routing**: report entries marked 🔗 CLUSTER (≥2 hunters converging on one +anchor) are fixed design-first, never member-by-member — partial fixes undo each +other (R1 names an enum that R11's move then replaces; R2 places validation that +R11's move relocates). Invoke @code-designing in cluster-scoped mode (it skips the +architecture scan and the user-OK gate — acceptance was inherited when the cluster's +findings were accepted; output is a mini DESIGN PLAN for the one concept the cluster +names), then @refactoring implements that plan; the member findings resolve as +consequences of one design. Singleton findings route directly to @refactoring as +before. + + + +1. Invoke @documentation (FEATURE mode): godoc + feature docs, wired into the + documentation network (index line, edges both directions, root import), plus its + R9 self-check over the diff and its comment-critic critique loop (the critic + reviews every comment in the diff against R9's three-test standard; + @documentation applies the verdicts and re-critiques once — R3 routes from the + critic go back through @refactoring like any R3 finding). +2. Present the ship summary: tests green (`{{.DefaultTest}}`), lint green (Phase 3), + review delta (Phase 4), files changed, suggested commit message. +3. User decides: commit as-is · fix deferred advisory findings first · defer. + + + +- [ ] Design plan user-approved before the first RED +- [ ] PREPARE ran its survey over the plan's touch points; every applied prep move + passed all four gates and landed in its own commit; PREPARATION LOG emitted +- [ ] Every behavior completed a RED → GREEN → REFACTOR cycle +- [ ] Package-scoped lint + rule greps clean after each cycle +- [ ] lint-fixer reported `LINT STATUS: green`; all escalations resolved via @refactoring +- [ ] @pre-commit-review INCREMENTAL delta clean, or findings explicitly deferred by user +- [ ] @documentation (FEATURE mode) done — docs wired into the network, R9 self-check + clean, comment-critic critique loop applied and confirmed clean (or remainder + reported); commit summary presented and user chose an action + diff --git a/core/skills/pre-commit-review/SKILL.md b/core/skills/pre-commit-review/SKILL.md new file mode 100644 index 0000000..0922ba0 --- /dev/null +++ b/core/skills/pre-commit-review/SKILL.md @@ -0,0 +1,255 @@ +--- +name: pre-commit-review +description: | + ADVISORY pre-commit review that orchestrates parallel single-obsession rule hunters, an over-abstraction skeptic, and a comment critic against the diff. + Spawns read-only agents (rule-hunter, overabstraction-skeptic, comment-critic); NEVER edits code. + Invoked by @linter-driven-development (Phase 4), by @refactoring (after pattern application), or manually for standalone code review. + Categorizes findings as Bugs, New Practice (when in Rome), Design Debt, Readability Debt, or Polish Opportunities. Does NOT block commits. +allowed-tools: + - Read + - Grep + - Bash + - Agent +--- + + +Verify a finished diff against the plugin's rules (R1–R12) with evidence, by orchestrating +parallel single-obsession `rule-hunter` agents, one `overabstraction-skeptic`, and one +`comment-critic`. +Pure orchestration and reporting: this skill may spawn agents but never edits code, never +fixes findings, and never blocks a commit. Rule knowledge lives once in `../../rules/`; +agents receive it as spawn-time payload — they do not invoke skills. + + + +Run pre-commit, per completed vertical slice — NEVER mid-implementation. GREEN-step TDD +code is supposed to look under-designed; reviewing it produces false positives. The +per-cycle detection greps in the REFACTOR step (see @refactoring) are the +mid-implementation net; this pass is the verification net on finished work. + + + +- **Diff scope**: staged changes by default (`git diff --cached --name-only -- '{{.SrcGlob}}'`), + or an explicit file list / diff range from the caller. +- **Mode**: `FULL` (first run) or `INCREMENTAL` (re-run after fixes — requires the + previous report's findings). + + + + + +In-context, cheap — no agents yet. For each rule below, read its rule file's +**Falsifying questions** section and run the detection commands there against the diff +scope (changed files only). The commands live in the rule files; never restate them here. +A rule with zero hits is skipped — no hunter spawned for it. + +| Rule | File | Hunt focus | +|------|------|------------| +| R1 | `../../rules/R1-primitive-obsession.md` | domain concepts as raw primitives; sentinel returns; ceremony wrappers (inverse) | +| R2 | `../../rules/R2-self-validating-types.md` | invalid-state construction; defensive re-checks; nil as a value | +| R3 | `../../rules/R3-storifying.md` | mixed abstraction levels; comments naming unextracted blocks | +| R4 | `../../rules/R4-helper-placement.md` | helper visibility/placement off the placement ladder | +| R5 | `../../rules/R5-vertical-slice.md` | horizontal layering; role-named packages | +| R6 | `../../rules/R6-test-only-interfaces.md` | interfaces whose only second implementer is a test double | +| R7 | `../../rules/R7-test-placement.md` | internal test packages; wantErr conditionals; wrong-rung tests; sleeps | +| R8 | `../../rules/R8-no-globals.md` | package-level state; `context.Background()` in library code | +| R9 | `../../rules/R9-repo-brain.md` | orphan docs; broken doc edges (both directions); WHAT-comments on exported API; unwired root; bundle-contract breaks (missing frontmatter, index timestamps, log.md) | +| R10 | `../../rules/R10-concurrency-safety.md` | goroutines without exit paths or owners; unguarded shared-state writes; production sleeps; decorative mutexes | +| R11 | `../../rules/R11-conditional-dispatch.md` | one discriminator switched in ≥2 places; type switches in domain logic; unknown-kind defaults away from the boundary; flag arguments; unearned dispatch abstractions (inverse) | +| R12 | `../../rules/R12-mutation-discipline.md` | internal slices/maps returned by reference; constructors aliasing caller collections; query/modifier hybrids; setters around validating constructors; ceremony copies (inverse) | + +{{include "skills/pre-commit-review/nolint-finding.md"}} + +Also in-context — the **when-in-Rome check**: a diff must arrive in the host repo's +existing style, not import a new one. Flag anything the diff introduces that the repo +does not already use: a new test mechanism (golden files, snapshot testing, a new +assertion library), a new dependency in `{{.ProjectMarker}}`, a new tool or config file, edits to +repo-level convention files (CLAUDE.md, coding standards, lint config) bundled into a +feature diff, or a directory layout unlike its siblings. Detection is comparative: +for each candidate, grep the repo *outside* the diff for prior use — zero prior use +is the finding. These are not style crimes; they are adoption decisions that belong +to the repo owner. The fix is a discussion or a separate PR, never silent inclusion. + + + +For every rule with pre-filter hits, spawn one `rule-hunter` agent — all hunters in a +single message, in parallel. Each spawn prompt MUST contain: + +1. **The rule file's FULL content, pasted** — the hunter's entire rulebook and single + obsession. Never a path reference alone; never more than one rule per hunter. +2. **The diff scope** — the changed-file list or `git diff` range. +3. **That rule's pre-filter hits** — as starting leads (the hunter re-runs the + detection commands itself; leads are a starting point, not a limit). + +If the rule cites a case file by plugin-relative path (e.g. `../examples/*.md`), resolve +it to an absolute path and include that path in the spawn prompt — the hunter runs in the +reviewed project's cwd and cannot resolve plugin-relative paths on its own. + +Each hunter returns one block per finding: +`rule | file:line | evidence (falsifying-question answers) | proposed fix pattern | effort (S/M/L)` +plus a final tally line (`R: finding(s)` or a hunted-clean line). + + + +Collect ALL type/package-extraction findings — every R1/R2/R4 "create a type/package" +proposal, R10 "Extract Synchronized Owner" proposals, and R11 "Interface Dispatch" / +"Strategy Map" proposals — and spawn one `overabstraction-skeptic`. Its spawn prompt MUST contain: + +1. The extraction findings under review — the hunter blocks pasted verbatim. +2. Payload: the **Juiciness scoring** and **The over-abstraction trap** sections of + `../../rules/R1-primitive-obsession.md`, pasted. +3. Payload: the FULL content of `../../examples/overabstraction-cidr.md`, pasted. + +Verdicts per finding: `CONFIRMED (score + verified evidence)` or +`REFUTED (score 0–1 + reason) → cheaper alternative`. A refuted proposal does not ship; +when its cheaper alternative (better naming, private fields + accessors, or R11's +Keep the Single Exhaustive Switch) is still worth doing, report the alternative as +🟢 Polish. When R11 dispatch proposals are under review, additionally paste the FULL +content of both R11 case files — `../../examples/anti-if-dispatch.md` (Move 3 is the +juiciness rejection: the switch stays, goes exhaustive) and +`../../examples/switch-to-polymorphism.md` (the dependency-direction rejection: the +move is unavailable when the consumer owns the output format; the switch shrinks to +pure dispatch). Only findings the skeptic cannot kill ship as extraction +findings. Non-extraction findings (R3, R5–R9, and R1/R2/R10/R11 findings that propose +no new type) skip the skeptic and go straight to the report — R9 findings (orphans, +broken edges, WHAT-comments, unwired root) propose no type extractions. + + + +When the diff contains comment lines — prefilter: +{{include "skills/pre-commit-review/critic-prefilter.md"}} +(any hit qualifies; directives don't count) — spawn one `comment-critic` alongside +the skeptic (same message when both run). Its spawn prompt MUST contain: + +1. Payload: R9's **Comment policy** section (`../../rules/R9-repo-brain.md`, + Design guidance) pasted verbatim — the Comment Value Toolbox kinds, the + three-test standard, the tier table, budget accounting, and the visibility + default. +2. Payload: the **Comment Value Toolbox** catalog section of + `../documentation/reference.md` (resolve to an absolute path) pasted verbatim. +3. The absolute path to `../../examples/private-comment-noise.md` — the critic + reads it when judging comments on unexported symbols. +4. The diff scope. + +It judges every comment in the diff (godoc, in-body, test) against the three-test +standard and returns per-comment verdicts (`KEEP / TRIM / REWRITE / DELETE`, or +`DELETE → route R3` for in-body extraction candidates) with evidence and proposed +replacement text. Non-KEEP verdicts land in the report as 🟡 Readability Debt; +`DELETE → route R3` verdicts merge with any R3 hunter findings on the same lines +(one finding, not two). The critic is advisory like everything else — accepted +verdicts are fixed by @documentation (the rung-1 fixer), except R3 routes, which +go to @refactoring. + + + +Merge surviving findings into one report. + +**Cluster pass (before categorizing):** group surviving findings by shared anchor — +the same type, field/discriminator, or function named in ≥2 findings from *different* +rules. Each hunter is single-obsession and blind to the others, so independent +convergence on one anchor is evidence that a domain concept is missing there — the +cluster is a juiciness scorecard that filled itself in (R1 hunter sees the raw +primitive, R11 the duplicated switch, R2 the ownerless validation: one disease, four +jurisdictions). Render each cluster as a first-class entry above the categories: + +``` +🔗 CLUSTER: Alert.Channel (4 findings: R1, R11, R2, R7) + Hypothesis: missing domain concept — a Channel type wants to exist + Routing: design-first — @code-designing (cluster-scoped), then @refactoring + implements; do NOT fix members independently (partial fixes undo each other) +``` + +Member findings still appear under their categories below, tagged +`[cluster: ]`. Clustering is *reporting* — this skill still never edits and +never invokes fix skills; the caller routes. + +Category mapping: + +- 🐛 **Bugs** — will fail at runtime regardless of rule (nil returned as a value, + cancellation swallowed by `context.Background()`, R10 goroutine leaks and + unguarded concurrent writes): fix immediately. +- 🟠 **New Practice (when in Rome)** — the when-in-Rome check's findings: a + mechanism, dependency, framework, or convention the host repo does not already + use, introduced without discussion. Advisory like everything else, but flag it + loudly: reviewers reject these threads hardest, and the fix (owner buy-in or a + separate PR) is cheap before pushing and expensive after. +- 🔴 **Design Debt** — R1, R2, R4, R6, R7, R8, R10's non-crash findings (production + sleeps, fire-and-forget ownership, mutex placement), R11 (duplicated discriminators, + boundary leaks), R12 (leaked mutable internals, unvalidated setters), and R5 + (advisory — never blocks; the user may have valid reasons): fix before commit + recommended. +- 🟡 **Readability Debt** — R3, R9, unclear naming, and the comment-critic's + non-KEEP verdicts (trash or over-budget or hard-to-read comments): improves + maintainability. +- 🟢 **Polish** — minor idiomatic improvements, the skeptic's cheaper alternatives. + +Every finding carries evidence — `file:line` plus the falsifying-question answer or +command output — never a bare verdict. Effort carries over from the hunter (S/M/L). +Fix routing is each rule file's **Fix pattern** section; cite it, don't restate it. +Issues noticed outside the diff scope go in a BROADER CONTEXT section, not as findings. + + + + + +**FULL (first run):** pre-filter all twelve rules over the whole diff scope; report every +surviving finding. + +**INCREMENTAL (re-run after fixes):** diff scope = only files changed since the last +review. Run steps 1–3 on that scope, compare against the previous findings, and report a +delta: ✅ **Fixed** (previous finding no longer reproducible — re-run its detection +command to confirm), ⚠️ **Remaining** (still evidenced), 🆕 **New** (introduced by the +fixes). Use after @refactoring applies fixes or whenever the caller iterates. + + + +``` +📊 CODE REVIEW REPORT +Scope: user/service.go, user/auth.go (+ tests) · Mode: FULL +Hunters: R1 (2 leads), R2 (1), R3 (1) · R4–R8 skipped (no pre-filter hits) +Skeptic: 1 extraction CONFIRMED, 1 REFUTED (score 1 → rename instead) +Critic: 14 comments reviewed — 11 KEEP · 2 REWRITE · 1 DELETE + +🔴 DESIGN DEBT +user/service.go:67 | session token travels as raw string; emptiness check inline + (R1 Q1: yes; Q2: same predicate at user/auth.go:41 — two owners) | Replace + Primitive with Domain Type: SessionToken — skeptic CONFIRMED (score 5) | M +user/auth.go:34 | Authenticator.HashCost exported; methods re-check its range + (R2 Q1: literal construction possible; Q2: re-check at auth.go:52) | validating + constructor NewAuthenticator | S + +🟡 READABILITY DEBT +user/auth.go:89 | Authenticate() mixes auth flow with bcrypt byte handling + (R3 Q1: two abstraction levels in one body) | Extract Step: comparePassword | S +user/service.go:15 | godoc restates the name ("UserService provides user services") + (critic: toolbox-value floor — no toolbox item delivered) | REWRITE → wider + context: "Every user mutation flows through this service — auth, quota, and + audit hooks attach here." | S + +🟢 POLISH +user/auth.go:12 | ComparePasswordWithHash → PasswordMatches — skeptic's cheaper + alternative to REFUTED PasswordHash wrapper (score 1: only method unwraps) | S + +📝 BROADER CONTEXT +user/service.go:23 — email still a raw string (outside diff scope; same R1 pattern). + +Caller decides: commit as-is · fix 🔴 first · fix all. Findings are advisory. +``` + + + +This skill MUST NOT: +- Edit code, fix findings, or invoke fix skills (@refactoring, @code-designing, @testing) +- Run the linter or tests — the caller does (see @linter-driven-development) +- Block commits — every finding is advisory; the caller decides what to fix +- Restate rule content — rules live once in `../../rules/`; paste them as spawn payload + and cite them in findings +- Spawn anything other than `rule-hunter`, `overabstraction-skeptic`, and + `comment-critic` + + + +1. **@linter-driven-development** — Phase 4, pre-commit / per completed vertical slice +2. **@refactoring** — after applying patterns, to validate design quality (INCREMENTAL) +3. **User** — manual standalone review before commit + diff --git a/core/skills/refactoring/SKILL.md b/core/skills/refactoring/SKILL.md new file mode 100644 index 0000000..4ac2af3 --- /dev/null +++ b/core/skills/refactoring/SKILL.md @@ -0,0 +1,155 @@ +--- +name: refactoring +description: | + BACKWARD view over rules/ — routes linter and review failures to the rule whose Fix pattern owns the repair. + Use when linter fails with complexity issues (cyclomatic, cognitive, maintainability) or when code feels hard to read/maintain. + Also runs PREPARATORY mode: reshape code an approved plan touches, before the first RED, so the feature lands add-only. + Applies storifying, type extraction, function extraction, conditional-dispatch, and mutation-discipline patterns via rules/R1-R8 and R10-R12. +allowed-tools: + - Skill({{.Plugin}}:code-designing) + - Skill({{.Plugin}}:testing) + - Skill({{.Plugin}}:pre-commit-review) +--- + + +Fix code that already fails lint or review. This skill is a thin directional view: +every fix pattern lives exactly once in `../../rules/` — this protocol routes each +failure to its owning rule, sequences multi-rule work via `reference.md`, and loops +until green. Operates autonomously — no user confirmation between patterns. + +Forward counterpart (designing before code exists): @code-designing. + + + +**CRITICAL**: When this skill says "Invoke @skill-name", you MUST invoke it with the +**Skill tool** — do not just mention it. + +| Notation | Skill Tool Call | +|----------|-----------------| +| @code-designing | `Skill({{.Plugin}}:code-designing)` | +| @testing | `Skill({{.Plugin}}:testing)` | +| @pre-commit-review | `Skill({{.Plugin}}:pre-commit-review)` | + + + +{{include "skills/refactoring/routing-table.md"}} + + + +Each named refactoring move is owned by one rule's **Fix pattern** section — apply it +from there, never from memory: + +| Move | Owner | +|------|-------| +| Extract Function (named after the comment), Early Returns, Honest Rename, Extract Leaf Type | `../../rules/R3-storifying.md` | +| Replace Primitive with Domain Type, Extract Collection Type, Replace Sentinel with comma-ok, Name enum strings, Over-abstraction rejection | `../../rules/R1-primitive-obsession.md` | +| Add validating constructor, Hoist method checks, Delete re-validation, Replace nil returns | `../../rules/R2-self-validating-types.md` | +| Demote helper (rung 1), Promote to feature/domain package (rungs 2–3), Split policy from vocabulary | `../../rules/R4-helper-placement.md` | +| Slice out a feature, Rename layer files by role, Split a generic package by owner | `../../rules/R5-vertical-slice.md` | +| Inline the interface, Rewrite test around real collaborators, Delete the double | `../../rules/R6-test-only-interfaces.md` | +| Move test down a rung, Split `wantErr` tables, Replace sleep with synchronization | `../../rules/R7-test-placement.md` | +| Extract Clean Island, Push Global Up One Level, Replace `init()` with constructor, Thread `ctx` | `../../rules/R8-no-globals.md` | +| Inject the Exit Path, Make the Goroutine Joinable, Extract Synchronized Owner, Replace Sleep with Timer Select, Delete Unearned Guards | `../../rules/R10-concurrency-safety.md` | +| Replace Duplicated Switch with Interface Dispatch, Replace If-Chain with Strategy Map, Introduce Null Object, Split Flag Argument, Keep the Single Exhaustive Switch | `../../rules/R11-conditional-dispatch.md` | +| Copy on the Way In, Copy on the Way Out / Encapsulate Collection, Separate Query from Modifier, Remove Setting Method, Split Variable | `../../rules/R12-mutation-discipline.md` | + +**Multi-rule procedures** (sequencing, god-object decomposition, package +decomposition): `reference.md` in this directory. + +**Case law** (deep worked studies): +- Storify → leaf type discovery: `../../examples/storify-leaf-type.md` +- Over-abstraction rejection + cheaper alternatives: `../../examples/overabstraction-cidr.md` +- Incremental global elimination: `../../examples/dependency-rejection.md` +- Duplicated kind-switch → interface dispatch (and the kept-switch rejection): `../../examples/anti-if-dispatch.md` +- Type switch over an owned interface → fill-style method (and the dependency-direction rejection): `../../examples/switch-to-polymorphism.md` + + + +{{include "skills/refactoring/file-and-package-routing.md"}} + + + +Fowler's preparatory refactoring — "make the change easy, then make the easy change": +reshape code an approved plan is about to touch, before the first RED, so the feature +lands as add-only. Invoked by @linter-driven-development (Phase 1.5, or Phase 2 RED +friction) or `/{{.CmdPrefix}}-prepare`, with a DESIGN PLAN, the touch-point file list, and +findings that already passed the four PREPARE gates (multiply / safe / bounded / +skeptic — the gates live in @linter-driven-development ``; this +mode trusts their verdicts and re-runs none of them). Fully autonomous — no user +confirmation, same as the rest of this skill. + +Differences from failure-driven operation: + +- **The trigger is the plan, not the linter.** Targets are usually lint-green; + "still failing → next move" does not apply. Route each finding by its rule (the + same `` rules own the same fix patterns) and apply. +- **Safety before motion.** Uncovered paths get characterization tests through the + public API first (@testing); the full suite — not just the touched package — runs + green after every move, because prep edits existing behavior by definition. +- **Stopping criterion — landing shape, not lint.** Stop when the planned change + lands as add-only or near-add-only: a new variant = one new file plus one case at + the dispatch boundary (R11); new behavior = a method on an existing type (R1); new + code = testable without touching globals (R8). Re-check against the plan after + each move; shape reached → STOP, even with findings left — those were never + preparation and belong to Phase 4's advisory report. +- **Commits are segregated.** Prep work lands in its own commit(s), never mixed with + feature code — the reviewer sees behavior-preserving reshaping and new behavior as + separate diffs. + + + +1. Receive trigger (from @linter-driven-development, from the caller acting on accepted + @pre-commit-review findings, or manual). +2. Route each failure via ``; apply the owning rule's Fix pattern, + least-invasive move first (sequencing in `reference.md`). +3. Re-run the linter immediately — no user confirmation. +4. Still failing → next move in the sequence. Repeat until green. +5. **Escalation**: complexity failures that keep recurring mean a new type or design + is needed — invoke @code-designing. Patterns exhausted → report what was tried and + escalate to the user for architectural guidance. Frame the escalation in maxim + vocabulary (`../../maxims.md`) — name *why* the code resists ("every caller asks + this struct three questions and then decides — the design wants Tell-Don't-Ask"), + not just which linter stayed red. + + + +{{include "skills/refactoring/testing-integration.md"}} + + + +{{include "skills/refactoring/nolint-prohibition.md"}} + + + +STOP when ALL are met: linter passes (0 issues); functions <50 LOC, nesting ≤2; +no red-zone packages; code reads like a story; no juicy extraction left (R1 scorecard +says LOW on every remaining candidate). If linter passes AND code is readable → STOP; +over-engineering signs (one-method types, pass-through functions) mean you went too far. + + + +``` +REFACTORING APPLIED + +Failures Routed: +1. [linter] → [rule] → [move applied]: [what changed] + +Types Created (R1 verdict): [Type] — [why juicy] → @testing invoked +Types Rejected (not juicy): [Type] — [cheaper alternative used] + +Metrics: cyclomatic [before]→[after], LOC [before]→[after], nesting [before]→[after] +Files Modified: [file] (+X, -Y) + +STATUS: [linter green / still failing: N issues / escalated to @code-designing] +``` + + + +**Invoked by**: @linter-driven-development (Phase 1.5 / RED friction → ``; +Phase 3, lint failures), or the caller acting +on accepted @pre-commit-review findings (@linter-driven-development Phase 4 accepted +findings, or the user) — @pre-commit-review reports only and never invokes fix skills. +**Invokes**: @code-designing (new types/design needed), @testing (after every extraction +— mandatory), @pre-commit-review (after lint passes). **Loop**: lint fails → @refactoring +→ re-lint → @pre-commit-review → repeat until both pass. + diff --git a/core/skills/refactoring/reference.md b/core/skills/refactoring/reference.md new file mode 100644 index 0000000..d86fc7c --- /dev/null +++ b/core/skills/refactoring/reference.md @@ -0,0 +1,123 @@ +# Multi-Rule Refactoring Procedures + +Single-rule moves live in the rules' **Fix pattern** sections +(`../../rules/R1-primitive-obsession.md` … `R8-no-globals.md`) — apply them from +there. This file holds only the procedures that genuinely span multiple rules. +Deep worked case law: `../../examples/`. + +## Sequencing: which pattern first, and when to stop + +Spans R3 × R1 × R2 × R4. Apply least-invasive first; re-run the linter after each move. + +1. **Storify first** (R3). Mixed abstraction levels are the most common root cause of + complexity failures, and storifying *reveals* the structure the later moves need: + comment-named blocks become functions, boolean loop flags surface as candidate + types. Never extract a type from a function you haven't storified — you'll extract + the wrong seams. +2. **Early returns** (R3) — invert conditions, flatten nesting to ≤2 levels. +3. **Extract function** (R3) — split remaining long bodies by responsibility. +4. **Extract type** (R1 + R2) — only when extracted steps share data (loop flags, + accumulated state) or named behavior runs on a primitive. Score the candidate with + R1's scorecard *before* creating it; the new type gets a validating constructor + per R2. Worked pair of moves 1+4: `../../examples/storify-leaf-type.md`. +5. **Place it** (R4) — the ladder decides where the extraction lands: unexported + helper, feature sub-package, or shared domain package. + +**When to stop**: linter green + top-level reads like a story + every remaining type +candidate scores LOW on R1's scorecard → STOP. Warning signs you went past the +sweet spot: types with one method that merely unwraps, functions that only call +another function, more abstraction layers than domain concepts. The worked rejection: +`../../examples/overabstraction-cidr.md`. + +**Cohesion > coupling**: put logic where it belongs even if that adds a dependency. + +## God-object decomposition + +Spans R3 × R1 × R4. **Trigger**: a type with >15 methods or >500 LOC. + +1. **Storify the methods first** (R3) — reveals the hidden method clusters. +2. **Extract generic logic into leaf types** (R1): string/URL/path handling, retry + and timeout logic, date formatting, validation. These become independently + testable islands and often turn out reusable — place them per R4's ladder. +3. **Group the remaining methods by noun** — user methods → `UserService`, cache + methods → `CacheService` — and extract each group into a focused service type. +4. **Compose the services in an orchestrator** that delegates, not implements. + +Key insight: step 2 usually reveals the god object was mixing infrastructure concerns +with domain logic — that mix, not size, is the disease. Forward design of the +composition: @code-designing. + +## Package decomposition + +Spans R5 × R4 × R1 × R2. **Trigger**: package-size red zone (≥13 non-test `.go` +files at one directory level) or yellow zone (8–12) — detection command and zone +table in SKILL.md ``. + +**A package-size violation is a design review, not a mechanical file split.** File +count is the symptom; the disease is usually missing domain types or multiple +vertical slices sharing one package. Run the 3 steps *in order*: + +### Step 1 — Does the package name reflect a real-world domain concept? + +Role names and generic containers (never acceptable — the list and naming method are +R5's Design guidance) get renamed *first*; the split follows from the new model. + +Naming method for the split — model the real-world relationship: +- The **parent** names the actor/system (the thing that does the work). +- The **sub-package** names the domain object (the thing acted upon) — that's where + your `pkg.Type` call sites live. +- A worker HAS a job → `worker/` + `worker/job/` (`job.ID`, `job.Status`); a compiler + HAS tokens → `compiler/` + `compiler/token/`. +- Test: say `pkg.Type` out loud. `job.ID` sounds right; `domain.ID` sounds like Java. + +### Step 2 — Are the existing types well-scoped? + +Look *inside* the package before looking at the file list: +- **Primitive obsession** (R1): `apiKey string`, `timeout int` fields with validation + scattered through top-level functions → extract self-validating types (R2) with + the behavior attached. +- **Big structs with disjoint method sets**: methods `A() B()` use fields `x y` while + `D() E()` use `z w` — two types fused together; split them. +- **Top-level functions that belong on a type**: `func normalizeFoo(s string) string` + wants to be `(f Foo) Normalize()`. + +Extracting types often shrinks the package below threshold with no sub-package split. +Invoke @code-designing to validate the extractions. + +### Step 3 — Only now, decide the physical split + +- Multiple vertical slices in one package → extract sub-packages (Step 1 naming). +- One slice with undermodeled internals → types into their own files, possibly a leaf + sub-package for pure domain types. +- Often: both. + +**Persistence naming**: `Store`, not `Repository` (Go-idiomatic, concrete). Each +sub-package gets its own Store with focused queries; constructor everywhere: +`NewStore(db *sql.DB, opts ...StoreOption)`. + +**Function stutter**: when moving a function into a named package, drop the prefix — +the package provides context. `jira.SanitizeTicketJSON()` → `sanitize.TicketJSON()`; +`job.NewJobID()` → `job.ParseID()`. + +**Import direction** (strictly downward — prevents cycles): + +``` +leaf types (domain) ← (nothing) +sub-packages ← leaf types +parent ← leaf types + sub-packages +cmd/ ← everything +``` + +If the parent needs sub-package logic AND the sub-package needs parent types → +extract the shared types into a leaf sub-package both can import. Never invert the +arrow with an interface (`../../rules/R6-test-only-interfaces.md`). + +**Phased migration** (each phase must pass tests + linter): +1. Extract leaf types first (domain sub-package) — biggest import update, zero + behavior change. +2. Extract the simplest sub-package (e.g. pure UPDATE queries, no shared scanner). +3. Extract complex sub-packages (minimal duplication of shared utilities is allowed). +4. Rename the parent last — update all remaining imports. + +**PR strategy**: land the decomposition in its own PR, then rebase the feature on the +decomposed structure. Never mix feature changes with package moves. diff --git a/core/skills/testing/SKILL.md b/core/skills/testing/SKILL.md new file mode 100644 index 0000000..3a64ebe --- /dev/null +++ b/core/skills/testing/SKILL.md @@ -0,0 +1,136 @@ +--- +name: testing +description: | + Use when creating leaf types, after refactoring, during implementation, or when testing advice is needed. + Automatically invoked to write tests for new types, or use as testing expert advisor. + Covers the composition ladder from rung-0 unit tests to whole-system tests, with emphasis on real in-memory dependencies. + Ensures 100% coverage on leaf types with public API testing. +--- + + +Principles and patterns for writing effective {{.Lang}} tests. +Writes tests autonomously based on code structure and type design, and serves as testing expert advisor. + +**Reference**: See `reference.md` for comprehensive testutils patterns and DSL examples. + + + +{{include "skills/testing/quick-start.md"}} + + + + +- **Automatically invoked** by @linter-driven-development in Phase 2's RED step — one failing test per behavior, placed by the composition ladder +- **Automatically invoked** by @refactoring when new isolated types are created +- **Automatically invoked** by @code-designing after designing new types +- **After creating new leaf types** - Types that should have 100% unit test coverage +- **After extracting functions** during refactoring that create testable units + + + +- User explicitly requests tests to be written +- User asks for testing advice, recommendations, or "what to do" +- When testing strategy is unclear (table-driven vs testify suites) +- When choosing between dependency levels (in-memory vs binary vs test-containers) +- When adding tests to existing untested code +- When user needs testing expert guidance or consultation + + + + +**Test only the public API** +- Use `pkg_test` package name +- Test types through their constructors +- No testing private methods/functions — the urge to unit-test an unexported helper directly is a promotion signal: give the helper its own package (`../../rules/R4-helper-placement.md`), never test privates. + +**No mocks — and a struct that only satisfies a production interface in a test IS a mock** +- A "fake" is a *real implementation with fake data* (embedded DB, `httptest` server, fake binary, temp dir) — NOT a struct written to satisfy a dependency interface. +- Terminology: the banned "mock" is an interface-injected struct double. The "in-memory mock servers" elsewhere in this skill (testutils DSL, `httptest` wrappers) are fakes in this sense — real servers speaking the real protocol with configurable fake data — and remain the recommended stand-in for external APIs you don't control (wired via URL/config, never via a production interface). +- Use in-memory implementations (fastest, no external deps), HTTP test servers (httptest), temp files/directories, or the real dependency. +- **Orchestrators are tested by wiring their real collaborators** (real Store/Evaluator over embedded DB + `httptest` external services), never by injecting doubles. +- If you are tempted to add an interface so a test can inject a fake, stop — that interface is a test-only smell. Depend on the concrete type instead (see @code-designing and `../../rules/R6-test-only-interfaces.md`). + +**Coverage targets** +- Rung 0 (leaf types): 100% unit test coverage +- Higher rungs (orchestrating types): cover the delta each rung adds — its seams and emergent behaviors +- Critical workflows: top-rung (system) tests + +**Assertions**: testify is the default, but project convention wins (e.g. goweka uses stdlib assertions) — match the codebase you're in. + + + +Tests sit on a ladder of real composition, not a pyramid of layer percentages. + +**Rung 0 — pure leaf types.** No I/O, no goroutines, no production dependencies. +Tests are plain constructions plus assertions: slice literals, value tables. +100% coverage is expected here — leaf types own most of the logic. + +**Each rung above adds exactly one real production layer** — the real +implementation, never a mock. In-memory/in-process infrastructure counts as the +real layer: httptest server, bufconn gRPC, in-memory NATS, temp files, embedded +VictoriaMetrics. + +**Fake only the true external boundary** — the thing you genuinely cannot run +in-process (a third-party SaaS API, a hardware device). Everything inside the +boundary composes real. + +**Placement rule: test each behavior at the lowest rung that contains it.** A +behavior expressible at rung 0 never gets tested through a rung-2 harness. + +**Each rung tests its delta plus emergent behaviors**: the wiring/seams that rung +adds and behaviors that only exist through composition — not a re-test of +lower-rung logic (some overlap with leaf coverage is acceptable for orchestrators, +per `../../rules/R7-test-placement.md`). + +The **top rung** is the whole system composed: black-box tests from `tests/` via +CLI/API, only the external boundary faked. + +**Obligation table** — a template; adapt the rows per project and keep the adapted +table in the project docs: + +| Kind of change | Owes a test at | +|---|---| +| New leaf type, or new behavior on one | Rung 0 | +| New seam between components X and Y | Rung 1 — the first rung containing the seam | +| New wiring through an infrastructure layer (queue, DB, RPC) | The rung that adds that layer | +| New externally observable behavior | Top rung | + +The ladder is defined here; the placement review contract (falsifying questions) +lives in `../../rules/R7-test-placement.md`. + + + +{{include "skills/testing/reusable-infrastructure.md"}} + + + + + +{{include "skills/testing/unit-tests-workflow.md"}} + + + +{{include "skills/testing/integration-tests-workflow.md"}} + + + +{{include "skills/testing/system-tests-workflow.md"}} + + + + + +{{include "skills/testing/key-patterns.md"}} + + + +{{include "skills/testing/output-format.md"}} + + + +{{include "skills/testing/checklists.md"}} + + + +{{include "skills/testing/success-criteria.md"}} + diff --git a/docs/generator.md b/docs/generator.md new file mode 100644 index 0000000..e7f129a --- /dev/null +++ b/docs/generator.md @@ -0,0 +1,41 @@ +--- +type: guide +description: how the Go plugin directory is generated from core/ and lang/go/, and the checks that keep it honest +--- +# Plugin Generator + +The directory the marketplace serves, `go-linter-driven-development/`, is not edited +by hand. `tools/ldd-gen` renders it from two sources: `core/`, the language-neutral +text of the rules, skills, agents, commands and the repo-brain gate, and `lang/go/`, +the Go binding. The templating contract (the scalars, the include construct, overrides, +file-name templating) and the residue backlog live in [core/README.md](../core/README.md). + +## Working on the plugin + +1. Edit a file under `core/` or `lang/go/`. Text that is the same for every language + belongs in `core/`; text that names Go tools, globs or idioms belongs in the binding + as a `profile.yaml` scalar or an include file. +2. Run `task generate`. It renders the binding over the plugin directory: every file + the generator owns is rewritten, and files it no longer produces are removed. +3. Commit the sources and the generated directory together. + +`task check` renders every binding to memory and compares it with the plugin +directory on disk; any missing, extra or changed file, or a changed executable bit, +fails, and changed files print a unified diff. CI runs it on every pull request, +together with `task lint-core` (no hard residue in `core/`, and the Residue section of +its README is current), `task docs:check`, `task test-gate` (the generated gate passes +its own fixture matrix), and the generator's unit tests and linter. The fixture matrix +uses GNU `sed`, so on macOS two of its cases fail while the same run passes on Linux. +Paths listed under `ignore` in the profile, such as eval cases copied under the +plugin's `evals/` directory at run time, are left alone by both `check` and +`generate`; a file the generator produces inside such a directory is still checked. + +## The generator + +`ldd-gen` is a small Go module under `tools/ldd-gen` with its own `go.mod`, so the +repository root stays free of any Go module or workspace file: the plugin's own +package-size hook stays quiet here, and Go modules checked out below the root (the +evals clone, the eval fixture) build normally. The tasks run the tool from its own +directory and pass the repository root with `-root`. Inside, `Profile` parses and validates the binding's scalars, `Binding` resolves +includes, overrides and passthrough files, `Render` produces the plugin tree in +memory, and `Compare` and `Write` sync that tree with the directory on disk. diff --git a/docs/index.md b/docs/index.md index bff00e8..eb3c1d5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,7 @@ okf_version: "0.2" # Repo Map - [conventions.md](conventions.md) — how to maintain this doc root (read before editing docs) +- [generator.md](generator.md) — how the Go plugin directory is generated from core/ and lang/go/, and the checks that keep it honest **Behavioral evals of the Go plugin** - [eval-harness.md](eval-harness.md) — what the behavioral evals are and how a run flows from case to verdict; `ldd-eval`, tiers, results diff --git a/go-linter-driven-development/CHANGELOG.md b/go-linter-driven-development/CHANGELOG.md index 4eb1d56..9865dcc 100644 --- a/go-linter-driven-development/CHANGELOG.md +++ b/go-linter-driven-development/CHANGELOG.md @@ -5,6 +5,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versio ## [Unreleased] +### Internal + +- **Generated plugin directory**: the plugin's source now lives in the + repository's `core/` (language-neutral text) and `lang/go/` (the Go binding: + profile scalars, include snippets and files copied through), and + `tools/ldd-gen` renders them into this directory. The rendered files are + byte-identical to the hand-written ones they replace; nothing an installed + plugin sees changes, and the version stays 2.10.0. + ### Added - **Behavioral evals**: the plugin's skills, agents and workflow are measured by diff --git a/lang/go/agents/comment-critic/directives.md b/lang/go/agents/comment-critic/directives.md new file mode 100644 index 0000000..4f2239b --- /dev/null +++ b/lang/go/agents/comment-critic/directives.md @@ -0,0 +1 @@ +`//go:`, `//nolint`, `// Output:` diff --git a/lang/go/agents/lint-fixer/hard-limits.md b/lang/go/agents/lint-fixer/hard-limits.md new file mode 100644 index 0000000..d8de280 --- /dev/null +++ b/lang/go/agents/lint-fixer/hard-limits.md @@ -0,0 +1,4 @@ +- Never add `nolint` directives — not even for issues you escalate. +- Never edit `.golangci.yaml`. +- Never touch test semantics: you may fix lint inside `_test.go` files, but never + weaken, remove, or reorder assertions. diff --git a/lang/go/agents/lint-fixer/mechanical-issues.md b/lang/go/agents/lint-fixer/mechanical-issues.md new file mode 100644 index 0000000..2d17aea --- /dev/null +++ b/lang/go/agents/lint-fixer/mechanical-issues.md @@ -0,0 +1,7 @@ +mechanical issues you fix — +formatting, import ordering, unused vars/params, unchecked errors (`errcheck`), +error wrapping (`wrapcheck`: `fmt.Errorf("context: %w", err)`), constant extraction +(`goconst` — mechanical ONLY when the repeated value is not an enum-shaped domain +concept; enum-shaped hits like `== "READY"` status strings escalate, see the table), +renames (`varnamelen`, `misspell`), simple style fixes (revive +`early-return`). diff --git a/lang/go/agents/lint-fixer/routing-table.md b/lang/go/agents/lint-fixer/routing-table.md new file mode 100644 index 0000000..de3c01d --- /dev/null +++ b/lang/go/agents/lint-fixer/routing-table.md @@ -0,0 +1,14 @@ +| Linter failure | Route | +|---|---| +| `gocyclo` / `cyclop` | rules/R3-storifying.md (via @refactoring) | +| `gocognit` | rules/R3-storifying.md (via @refactoring) | +| `funlen` | rules/R3-storifying.md (via @refactoring) | +| `nestif` | rules/R3-storifying.md (via @refactoring) | +| `maintidx` | rules/R3-storifying.md + rules/R1-primitive-obsession.md | +| `dupl` | rules/R1-primitive-obsession.md (extract shared type/logic); duplicated switches on one discriminator → rules/R11-conditional-dispatch.md | +| `exhaustive` (missing enum cases) | rules/R11-conditional-dispatch.md (via @refactoring) | +| revive `file-length-limit`; package-size hook failures (`hooks/check-package-sizes.sh`) | rules/R5-vertical-slice.md | +| `gochecknoglobals` / `gochecknoinits` | rules/R8-no-globals.md | +| `ireturn` / interface lint on single-impl interfaces | rules/R6-test-only-interfaces.md | +| `go test -race` failures; `govet` `copylocks` | rules/R10-concurrency-safety.md (via @refactoring) | +| `goconst` (enum-shaped strings) | rules/R1-primitive-obsession.md ("Name enum strings" move) | diff --git a/lang/go/commands/analyze/discover-commands.md b/lang/go/commands/analyze/discover-commands.md new file mode 100644 index 0000000..ad1712f --- /dev/null +++ b/lang/go/commands/analyze/discover-commands.md @@ -0,0 +1,16 @@ +1. **Read project files** in order of preference: + - `CLAUDE.md` (project-specific instructions) + - `README.md` (project documentation) + - `Makefile` (look for `test:` and `lint:` targets) + - `Taskfile.yaml` (look for `test:` and `lint:` tasks) + - `.golangci.yaml` (linter configuration) + +2. **Extract commands**: + - **Test command**: `go test ./... -cover`, `make test`, `task test` + - **Lint command (report-only)**: this command must NOT fix. Strip any `--fix` + flag and run the linter in report mode: `golangci-lint run` (or the project's + lint command with `--fix` removed). + +3. **Fallback to defaults** if not found: + - Test: `go test ./...` + - Lint: `golangci-lint run` (no `--fix`) diff --git a/lang/go/commands/analyze/file-scope.md b/lang/go/commands/analyze/file-scope.md new file mode 100644 index 0000000..8d7c94a --- /dev/null +++ b/lang/go/commands/analyze/file-scope.md @@ -0,0 +1,10 @@ +**If arguments provided** (`$ARGUMENTS`): +- Use as file pattern (e.g., `./pkg/parser/*.go`, `./pkg/parser/`) +- Validate files exist with glob/ls + +**Otherwise** (default behavior): +- Use git to find changed files: + ```bash + git diff --name-only --diff-filter=ACMR HEAD | grep '\.go$' + ``` +- If no git repository or no changes, analyze all `.go` files in the project (excluding vendor/, testdata/) diff --git a/lang/go/commands/wire-repo-brain/edge-verify.md b/lang/go/commands/wire-repo-brain/edge-verify.md new file mode 100644 index 0000000..0d3d505 --- /dev/null +++ b/lang/go/commands/wire-repo-brain/edge-verify.md @@ -0,0 +1 @@ +(verified with `go vet` after each) diff --git a/lang/go/commands/wire-repo-brain/language-scope.md b/lang/go/commands/wire-repo-brain/language-scope.md new file mode 100644 index 0000000..9444093 --- /dev/null +++ b/lang/go/commands/wire-repo-brain/language-scope.md @@ -0,0 +1,8 @@ +**Language scope**: this is the Go plugin, so code↔docs verification is +Go-first. On a repo with no Go, the pass still delivers the whole structure +layer (frontmatter, index, drift check, conventions, routing, CI gate on +structure) — but code→docs edges, symbol drift detection, and the file-path +ban only cover `.go` files, and doc roots are only discovered at the repo root +and `go.mod` sub-projects (a TS/Python sub-project's own docs/ is not wired — +it is reported, not silently skipped). Non-Go CamelCase symbols cited in +covered docs still resolve via the gate's whole-word fallback. diff --git a/lang/go/passthrough/.claude-plugin/plugin.json b/lang/go/passthrough/.claude-plugin/plugin.json new file mode 100644 index 0000000..dc78570 --- /dev/null +++ b/lang/go/passthrough/.claude-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "name": "go-linter-driven-development", + "version": "2.10.0", + "description": "Rules-as-data linter-driven development workflow for Go: 12 single-source rule files, thin directional skills (design, TDD implementation, refactoring, testing, review, documentation), a hunter/skeptic/critic review architecture with parallel single-rule reviewers, an over-abstraction skeptic, and a comment critic enforcing the Comment Value Toolbox, plus an OKF-conformant repo brain with a shippable CI conformance gate", + "author": { + "name": "Dan Mordechay" + }, + "repository": "https://github.com/buzzdan/ai-coding-rules", + "license": "MIT", + "keywords": [ + "go", + "golang", + "linter-driven-development", + "testing", + "refactoring", + "code-review", + "design-patterns", + "clean-code" + ] +} diff --git a/lang/go/passthrough/CHANGELOG.md b/lang/go/passthrough/CHANGELOG.md new file mode 100644 index 0000000..9865dcc --- /dev/null +++ b/lang/go/passthrough/CHANGELOG.md @@ -0,0 +1,482 @@ +# Changelog + +All notable changes to the `go-linter-driven-development` plugin are documented here. +Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Internal + +- **Generated plugin directory**: the plugin's source now lives in the + repository's `core/` (language-neutral text) and `lang/go/` (the Go binding: + profile scalars, include snippets and files copied through), and + `tools/ldd-gen` renders them into this directory. The rendered files are + byte-identical to the hand-written ones they replace; nothing an installed + plugin sees changes, and the version stays 2.10.0. + +### Added + +- **Behavioral evals**: the plugin's skills, agents and workflow are measured by + eval cases in the `claude plugin eval` format that run the plugin against a + deliberately bad Go service planting every R1–R12 falsifying question at least + once, with a control per rule. The cases, fixture, runner and baselines live in + the separate [buzzdan/ldd-evals](https://github.com/buzzdan/ldd-evals) + repository so an installed plugin carries none of them. This repository keeps + `evals/README.md` as a pointer, `scripts/evals.sh` to clone and run a tier + against the current checkout, and `docs/` describing the mechanism. The first + baseline, on plugin 2.10.0 at 681fdb0, is recorded there under + `baselines/go-2.10.0-681fdb0/`. Nothing a user installs changes; the plugin + version stays 2.10.0. + +## [2.10.0] - 2026-08-20 + +The repo brain had one audience: a session with this plugin installed. In a +monorepo most contributors — and their agents — don't have it, so the network's +rules lived nowhere they could find and nothing enforced them. This release +makes the doc root a standard, self-describing artifact: an Open Knowledge +Format (OKF v0.2) bundle any tool can consume, with its own maintenance manual +inside and a CI gate outside. + +### Added + +- **OKF v0.2 bundle conformance (R9)**: content docs carry YAML frontmatter — + required `type` (the spec's one required key) and `description`; optional + `title`/`generated` (OKF's provenance key)/`tags` and lifecycle + `status`/`stale_after`, the frontmatter-native form of the ⚠️ stale flag. + Indexes stay bare, as the spec reserves them; the root index carries only + `okf_version`. R9 is a stricter profile of the spec built from spec-valid + keys, so the bundle stays consumable by any OKF tool. New falsifying + question **Q7** checks the bundle contract mechanically. +- **Drift-check rule (R9)**: a doc's index line IS its `description`, copied + verbatim — the description is the single source, and the conformance gate + fails when the copy drifts; `check-repo-brain.sh --fix` rewrites drifted + lines from the descriptions (the one mechanical repair the gate performs). + The map of maps is directory-shaped (per-topic subdirectories with their + own bare index.md), and the split lands in the same commit as the + `See docs/...` path rewrite. +- **One-way link policy (R9)**: write an edge only when no structure implies + it — no child→parent backlinks, no `related:` frontmatter key, no `## Related` + section; lateral links go inline with the reason in the sentence. `log.md` is + never emitted. +- **Self-hosting conventions doc**: BOOTSTRAP creates `/conventions.md` + (template in reference.md) — the network's maintenance rules written for + contributors without this plugin, listed first in the index. +- **AGENTS.md routing block**: upgraded from "fallback when CLAUDE.md is + absent" to first-class multi-tool coverage — root and nested per sub-project + (closest file wins), pointing every AGENTS.md-reading agent at the index and + conventions.md. Authored once: CLAUDE.md embeds it via `@AGENTS.md` (plus the + `@/index.md` map import) instead of duplicating the routing prose. +- **New `scripts/check-repo-brain.sh`**: dependency-free conformance gate + running Q1–Q3 and Q7 over every doc root (repo root plus each go.mod + sub-project): transitive reachability from the root index, both edge + directions, the file:line ban (URL spans, fenced code blocks, and glob + patterns exempt), exact-path root wiring (missing AGENTS.md routing is an + advisory, matched as a fixed string so dotted doc roots never ride a + look-alike path), the full frontmatter contract (termination; `type` valued + feature/architecture/guide; a non-empty `description`; bare indexes; + root-only `okf_version`, exactly one, valued `"0.2"`), every index line + checked against its target's `description`, and the lifecycle advisory (a + target stale by `status: deprecated` or a past `stale_after` whose index + line lacks ⚠️). A link into a missing directory is a broken link, never a + skip. Docs→code resolution is set-based and Go-shaped: one pass builds the + repo's declaration set — single-line and grouped `type (`/`var (`/`const (` + declarations, functions, methods, plus `pkg.Ident`/`Type.Method` ownership + pairs, so a qualified token resolves only against its actual owner; a + token missing there still resolves as a whole word in any non-markdown repo + file (config keys, alert names); external `pkg.Sym` (stdlib, dependencies) + is exempt. Dogfooded on a 1,271-file production repo: ~55 s per run, zero + false-positive classes left. BOOTSTRAP installs it into + target repos and suggests the + one-line CI wiring. Exit 0 clean/not-adopted, 1 violations, 2 usage error or + internal scanner failure (a failed scan is inconclusive, never silently + clean); every failure message points at conventions.md. Everything + language-specific in the gate sits in one delimited adapter block behind a + small `LANG_*` / `lang_*` contract; the driver around it (Q1, Q3, Q7, doc + links, `--fix`) is language-agnostic. Portable across awks (gawk, mawk, BSD + awk). +- **New `scripts/check-repo-brain_test.sh`**: the gate's fixture matrix as a + committed test — 35 cases across Q1/Q2/Q3/Q7, `--fix` round-trips, usage + errors, and the no-code scope, built from POSIX tools in a temp dir. Its + differential mode (`GATE_REF=`) fails on any output, exit-code, + or `--fix` difference between two versions of the gate — the contract a + refactor, or another language's adapter, must meet. + +### Changed + +- **BOOTSTRAP is now a migration pass too**: frontmatter is verified-or-added + on content docs (never duplicated) and stripped from indexes, so a network + wired by an older plugin version converges to the current rules in one + idempotent re-run; an un-inferable `type` goes to the advisory report, never + guessed. +- **Feature Doc Template's `Related` section removed**: lateral doc links go + inline, in the sentence that states the relationship — a relationship that + cannot find a sentence in the body is not worth an edge. + +## [2.9.1] - 2026-07-23 + +A real 143-line file surfaced the gap v2.9.0 left open: every one of its nine +unexported symbols carried a comment — one of them 22 prose lines — because the +tier budget caps a comment's size but nothing decided whether it should exist. + +### Added + +- **Visibility default (R9)**: unexported symbols get NO comment. The name is + the documentation; a name that needs a comment wants a rename or an + extraction (R3) first. The special case is ONE line carrying a very + high-value toolbox item — an ordering constraint, an external library + quirk, the WHY of a magic number, the package's one real policy. Anything + more belongs to the exported caller, the package doc, or the feature doc. + The tier table now explicitly prices exported API only. +- **New case file `examples/private-comment-noise.md`**: the nine-helper file + with per-symbol verdicts (four one-liners survive, five comments deleted; + ~68 comment lines become 4) and the full "after". Its path rides in the + comment-critic's spawn payload from both @pre-commit-review and + @documentation. +- **New anti-toolbox entry: review-defense narration** — the writer arguing + with an imagined reviewer ("bounds-checked: it never indexes an empty + slice", "deliberately narrow — not a generalized table"). The code shows + its own safety; a design choice worth defending is defended in the feature + doc. +- **Critic bias for unexported symbols**: the question is existence, not + size — default verdict DELETE; TRIM down to the one high-value line only + when it already exists in the comment; never propose growing a private + comment. +- **@documentation writes accordingly**: FEATURE step 3 now writes godocs for + exported symbols only by default. + +## [2.9.0] - 2026-07-23 + +Lessons from a repo owner's hard review of a large generated PR: the reviewer +could merge the code but "could not reasonably ask another human to maintain +the result". Two themes: comments that need the code (or a decoder ring) to be +understood, and a feature PR that silently adopted new engineering practices. + +### Added + +- **Empathy test — the plain-English test grows teeth** (R9 test 3, renamed + plain-English/empathy test). New persona anchor: write for a fresh graduate + whose first language may not be English. New failure modes: unexplained + acronyms and insider jargon ("DTO", "tristate") in comments AND in the symbol + names they document; and **self-standing** — a comment must be understandable + BEFORE reading the code ("if I need to read the code to understand the + comment, the comment adds negative value"); forward references to other + comments fail. The comment-critic now reads each comment before the + surrounding code so the self-standing check is built into its protocol. +- **Decoder-ring references join the provenance anti-toolbox** (R9 floor + + toolbox catalog + critic): plan/decision/test-plan IDs ("T-04-02", "D-07"), + requirement tags ("REQ-SVC-01"), spec section refs ("spec §4") fail even + when they resolve inside a repo doc — the fact goes in the comment as plain + prose, the doc through one See-edge, the ID stays in the doc. Worked ❌/✅ + example (`userResponse`) added to the catalog. +- **Restated repo idiom is a floor failure mode** (R9 + catalog + critic): a + comment justifying a convention the repo applies everywhere (pointer field = + "omitted vs explicit zero") is noise even though it is technically a WHY — + the convention lives once at rung 2. The critic greps the repo for the same + pattern before crediting such a rationale. +- **See-edge placement rule** (R9): the `See docs/.md` line is free + under the budget ONLY as its own trailing line; a doc reference braided into + the summary sentence is clutter, not an edge. +- **When-in-Rome check** (@pre-commit-review step 1 + new 🟠 New Practice + category + new maxim): anything the diff introduces that the host repo does + not already use — test mechanisms (golden files), dependencies, tools, config + conventions, repo-level file edits bundled into a feature diff — is flagged + as an adoption decision that belongs to the repo owner; the fix is a + discussion or a separate PR, never silent inclusion. Detection is + comparative: grep the repo outside the diff for prior use. +- **Two new maxims**: "Empathy is a core engineering value" (borrowed from a + reviewer's coding standards) under Clarity and knowledge; "When in Rome, + code as the Romans do" under Process and economics. +- **Name-jargon note in the critic**: renames are not the critic's to order, + but when the empathy test fails because the jargon lives in the symbol name + itself ("DTO"), the verdict block carries a rename recommendation for the + caller to route. (Added after a live run where the critic rewrote a comment + but left the jargon name unremarked.) + +## [2.8.0] - 2026-07-20 + +### Added + +- **Comment Value Toolbox + comment-critic — comments now have an adversarial + reviewer.** Reviewers reported ldd-written comments as noise: restated code, + filled templates, no context. Root cause was architectural: @pre-commit-review + runs in Phase 4, @documentation writes godocs in Phase 5 — comments were never + adversarially reviewed, only self-checked by their writer. + - **The Comment Value Toolbox**: a named, growable catalog of the ways a comment + delivers value — WHY-not-WHAT, wider context, use cases/flows, boundary + contract, guarantees, network edge. Normative kinds list in R9's comment + policy; the catalog with worked examples lives in @documentation's + reference.md and is designed to grow. + - **Three-test standard** (normative in R9): toolbox-value (floor: a prose line + delivering no toolbox value is cut; ceiling: the comment must carry the + highest-value items for its symbol's tier — short-but-dodging fails too), + tier budget (from v2.7.0), and plain English (everyday words, short + sentences — not all readers are native speakers; applies to godocs AND + feature docs). The floor names **provenance** a failure mode — PR numbers, + review items, "the previous behavior" narration — under the 5-year reader + lens: readers care how the product behaves now, not which review round + produced it; history is rewritten as present-tense rationale, and + incident/ticket refs survive only as rationale. Falsifying questions + unchanged. + - **New `comment-critic` agent**: read-only, single obsession — comment value. + Judges every comment in the diff (godoc, in-body, test) against the three + tests; verdicts KEEP / TRIM / REWRITE / DELETE with evidence; every rewrite + names the toolbox item it delivers. In-body block-narration comments route to + R3 (extraction candidates). Adversarial default: uncertain → fails. + - **@documentation closes the timing gap**: write-time three-test gate in + step 3, then a critique loop — spawn the critic on the full diff, auto-apply + every non-KEEP verdict, re-critique once to confirm; report carries the delta + (N deleted · M trimmed · K rewritten). Philosophy gains "plain words over + clever words". + - **@pre-commit-review** spawns the critic on the standalone/PR path whenever + the diff contains comments; verdicts land as 🟡 Readability Debt, advisory. + +### Changed + +- **`Task` → `Agent` tool rename** across all skill and command frontmatter and + prose: Claude Code renamed the subagent-spawning tool in v2.1.63; `Task` still + works as a backward-compat alias, but the plugin now uses the canonical name. + +## [2.7.0] - 2026-07-20 + +### Changed + +- **Tiered godoc budget — R9's comment policy now has hard numbers.** Generated + godoc comments were verbose enough to slow human review; "menus, not forms" was + guidance without a bound. R9's rung-1 comment policy is now a **1–5 prose-line + budget scaled by the symbol's role**: helpers 0–1 line, contract types (parsing + constructors, self-validating types) 2–3, crossroads (entry points, orchestrators, + feature front doors) up to 5. Blank `//` lines, the `See docs/.md` edge, + and short inline examples (2–4 lines) are free; overflow moves to the feature + doc — the placement rule made operational at write time. Two bounded escape + hatches: a package that genuinely earns more moves its godoc to a dedicated + `doc.go` (~20–30 lines), and a crossroads that deserves richer inline godoc gets + an optional **expand recommendation** in @documentation's FEATURE report instead + of extra lines — a human decides. Guidance-only: falsifying questions, hunters, + and the skeptic are unchanged. + - `@documentation` FEATURE step 3 applies the budget; its report format gains an + optional `Expand recommendations` section. + - reference.md godoc menus annotated with tier budgets; new `doc.go` hatch note, + checklist budget items, and an over-budget → trimmed-to-tier worked example. + - Root `coding_rules.md` Comments section aligned with the budget. + +## [2.6.0] - 2026-07-10 + +### Added + +- **Finding clusters — design-first routing for related review findings.** Hunters + are single-obsession and blind to each other, so when findings from ≥2 different + rules converge on one anchor (the same type, field/discriminator, or function), + that independent convergence is evidence of a missing domain concept — a + juiciness scorecard that filled itself in. Fixing such findings member-by-member + produces partial patches that undo each other (R1 names an enum that R11's move + then replaces; R2 places validation that R11's move relocates). + - `@pre-commit-review` step 4 gains a **cluster pass**: convergent findings are + reported as first-class 🔗 CLUSTER entries with a root-cause hypothesis and a + design-first routing note; members stay in their categories, tagged. Clustering + is reporting — the skill still never edits. + - The orchestrator's Phase 4 gains **cluster routing**: clusters go to + `@code-designing` in a new **cluster-scoped mode** (skips the architecture scan + and user-OK gate — acceptance was inherited when the findings were accepted; + designs only the one concept the cluster names), then `@refactoring` implements + the mini plan; member findings resolve as consequences of one design. + Singletons route directly to `@refactoring`, unchanged. + - `/go-ldd-quickfix` Phase 4 follows the same routing. + +## [2.5.0] - 2026-07-10 + +### Added + +- **`maxims.md` — the uncompiled layer above the rules.** Rules are compiled + judgment (detection command + violation criterion + fix pattern); maxims are the + named questions that *generate* such rules in situations no rule anticipated: + "Tell, don't ask", the Law of Demeter, "Make illegal states unrepresentable", + "Parse, don't validate", "Duplication is far cheaper than the wrong abstraction", + "YAGNI", "Three strikes", "A little copying is better than a little dependency", + "The bigger the interface, the weaker the abstraction", "Make the zero value + useful" (held in explicit tension with R2), "Make the change easy…", "If a test + is hard to write, the design is wrong", "If it hurts, do it more often", + "Premature optimization…", "Clear is better than clever", "Once and only once", + "Depend in the direction of stability". Each entry: the quote, attribution, the + question it makes you ask, and the rules that compile it (or *uncompiled* status). +- **The contract: maxims propose, evidence disposes.** Maxims are wired into the + three judgment points and banned from the evidence path: + - @code-designing gains `` — the plan is questioned with the + maxims before types are committed (design has no diff to grep; questions are + the only tool there) + - @refactoring's escalation now frames *why code resists* in maxim vocabulary + - the `overabstraction-skeptic` cites Metz/Pike/YAGNI by name in verdicts — a + named principle is an argument, "feels unnecessary" is not + - the `rule-hunter` evidence protocol explicitly forbids maxim-justified findings +- **Graduation path**: a maxim that keeps generating findings no rule can express + gets compiled into an R-file — R11 and R4's feature-envy question are graduates + of exactly this path. +- **The Message Chains ↔ Middle Man pair** (Fowler's opposing smells), compiled to + its post-Tell-Don't-Ask residue as two R4 fix-pattern bullets: a chain is a + placement signal (move the behavior; only boundary egress keeps the chain, inside + the adapter), and a pure-forward method is R1's ceremony verdict applied per + method (delete forwards that own no rule; domain-type embedding manufactures the + smell in one line). No R13 — after the behavior moves, nothing detectable + remains, so the pair is guidance, not a rule. +- **House maxim: "Every indirection must earn its keep"** — the generalized + juiciness test, named as the plugin's own synthesis: one principle at six + granularities (type/interface/method/dispatch/guard/copy), enforcement agent the + `overabstraction-skeptic`; each rule's inverse trap is its retrospective form. + +## [2.4.0] - 2026-07-10 + +### Added + +- **Phase 1.5 PREPARE — autonomous preparatory refactoring** (Fowler: "make the + change easy, then make the easy change"). After the user approves the design plan, + the orchestrator surveys the plan's touch points with the existing rule detection + greps and reshapes what the change is about to hit — before the first RED, in its + own commit(s). Decisions are made by four mechanical gates, never by stopping to + ask: **MULTIPLY** (only violations the plan would multiply qualify), **SAFE** + (characterization tests first on uncovered paths), **BOUNDED** (S/M efforts + proceed, L defers to the Phase 4 report), **SKEPTICIZED** (any extraction is + judged by the over-abstraction skeptic, scored against the plan in hand). + Autopilot never pauses; the PREPARATION LOG is a record, not a question. +- **RED-friction escape hatch** (Phase 2): a RED test that resists — fixture + surgery, global mutation, driving layers to reach a seam — is a prep signal the + survey missed; suspend the cycle, run the same gates, land the prep commit, + re-enter RED. +- **`@refactoring` ``**: same routing table and moves, different + trigger (the plan, not the linter — targets are usually lint-green) and different + stopping criterion: not "linter green" but "the feature now lands add-only"; + full suite green after every move; prep commits segregated from feature commits. +- **`/go-ldd-prepare [files]`** — standalone entry: describe the impending + change, get the survey + gates + reshaping without the full five-phase workflow. + +## [2.3.0] - 2026-07-10 + +The Fowler wave — adopting the *Refactoring* (2nd ed.) ideas the rule set didn't +already embody. (Much of the catalog was already here under other names: Extract +Function → R3, Replace Primitive with Object → R1, Repeated Switches → R11, +Speculative Generality → the over-abstraction skeptic, the Two Hats → the +RED→GREEN→REFACTOR loop.) + +### Added + +- **`rules/R12-mutation-discipline.md`** — Fowler's Mutable Data smell family with + Go aliasing teeth: a validated value changes state only through methods that own + its invariants. In Go, `return g.perms` returns a mutable alias into the + "validated" state — R2's validate-once guarantee is void the moment an internal + slice escapes. R12 owns: + - Copy on the Way In — constructors clone slice/map arguments (or build fresh) + - Copy on the Way Out / Encapsulate Collection — queries return clones or `iter.Seq` iterators, never internal references + - Separate Query from Modifier — split hybrids; pure naming cases stay with R3's Honest Rename + - Remove Setting Method — no unvalidated mutation paths around a validating constructor (construction itself stays R2's) + - Split Variable — one assignment per meaning + - the inverse trap: ceremony copies of data that never escapes, mirroring R1's over-abstraction symmetry +- **Split Phase move in R3** (Fowler's opening example): a function interleaving + parsing with computation splits into phase 1 producing an intermediate domain + structure and phase 2 consuming it — distinct from Extract Function because it + introduces a data structure *between* the steps; collapses into R2's `ParseX` + when phase 1 validates. +- **Data Clumps question in R1** (Introduce Parameter Object / Preserve Whole + Object): the same parameter group in ≥2 signatures is a type asking to exist — + the scorecard already rewarded grouping; now a falsifying question hunts it. +- **Feature Envy in R4** (Move Function): a new fix pattern (Move Method to the + Envied Type) and falsifying question — a function reading a foreign type's data + more than its own moves onto that type, then re-places via the ladder. +- Wiring: pre-commit-review hunts R12 (🔴 Design Debt), code-designing dispatches + R12 at design time (closed mutation surface in the checklist), refactoring's + pattern index owns the five R12 moves. R12 has no owning linter (like R9) — no + routing-table row. + + + +## [2.2.0] - 2026-07-10 + +### Added + +- **`rules/R11-conditional-dispatch.md`** — the Anti-IF rule, adapting the Anti-IF + movement's core insight (Cirillo, 2007) to the rules-as-data architecture: a + conditional that asks what a value *is* may exist once; the second copy of a + kind/type discriminator is a missing polymorphic type. R11 owns: + - Replace Duplicated Switch with Interface Dispatch — variants become leaf types, the decision moves to a `ParseX` boundary constructor (R2's behavioral twin: *decide* once at the edge) + - Replace If-Chain with Strategy Map — single-behavior variance dispatches through a map, comma-ok at the boundary only + - Introduce Null Object and Split Flag Argument + - the sanctioned form: Keep the Single Exhaustive Switch — one site over a closed enum stays, named per R1 and proven complete by the `exhaustive` linter + - the inverse trap: dispatch abstractions that delete no duplication are ceremony — one switching site with trivial variance keeps its switch (mirroring R1's over-abstraction symmetry and R6's earned-interface test) +- **`examples/anti-if-dispatch.md`** — case law: a three-site channel switch (already + drifted) collapsed to interface dispatch, the strategy-map variant, and the worked + rejection where the skeptic kills the extraction and the switch goes exhaustive + instead. Pasted to the skeptic alongside R11 dispatch proposals. +- **`examples/switch-to-polymorphism.md`** — second R11 case file (real production + code), the type-switch sibling of anti-if-dispatch: an already-polymorphic value + un-dispatched by a field-unpacking type switch becomes a fill-style interface + method. Covers the tempting wrong fix (extract-per-case as ceiling, not cure), + fill-don't-construct ownership, the earned + sealed interface (R6), and the + dependency-direction rejection — orthogonal to the juiciness rejection — where + the consumer owns the wire format and the switch legitimately stays as pure + dispatch. +- Wiring: pre-commit-review hunts R11 (🔴 Design Debt), its dispatch proposals face + the over-abstraction skeptic with the new case file as payload, code-designing + dispatches R11 at design time (one dispatch owner per variant family in the + checklist), refactoring + lint-fixer route `exhaustive` failures and + discriminator-shaped `dupl` hits to it. + +## [2.1.0] - 2026-07-07 + +### Added + +- **`rules/R10-concurrency-safety.md`** — restores the concurrency coverage that v2.0.0 dropped when the generalist reviewer was retired (v1's "Design Bugs" checklist §8 and anti-patterns §9 had no rule home). R10 owns what static analysis cannot prove: + - every goroutine has an owner (stop + wait) and a provable exit path + - shared mutable state is guarded where it lives (mutex next to the data, or confined/handed off) + - no `time.Sleep` on cancellable production paths — timer `select` with `ctx.Done()` + - the inverse trap: guards and goroutines that are ceremony (single-goroutine mutexes) get deleted, mirroring R1's over-abstraction symmetry +- Wiring: pre-commit-review hunts R10 (leaks/races categorized as 🐛 Bugs; sleeps/ownership/mutex-placement as 🔴 Design Debt), its "Extract Synchronized Owner" proposals face the over-abstraction skeptic, code-designing dispatches R10 at design time, refactoring + lint-fixer route `go test -race` failures and `govet copylocks` to it. + +### Explicitly out of R10's scope + +Mechanical error-handling checks (ignored errors, unclosed response bodies, copied locks) stay with the linter — `errcheck`, `bodyclose`, `govet` — per the "linter says WHAT" division. Judgment-level silent-failure review (fallback legitimacy, error-message quality) is served by external review tooling, not duplicated as a rule. + +## [2.0.0] - 2026-07-07 + +The **rules-as-data** release. The unit of knowledge is now the rule, not the phase: each design principle lives exactly once in `rules/`, and every skill, agent, and command is a thin view or worker over those rules. + +### Breaking + +- **Removed the `quality-analyzer` and `go-code-reviewer` agents.** Anything that invoked them directly ("use the quality-analyzer agent", custom commands, references in your CLAUDE.md) will fail with "unknown agent". + **Migration:** `/go-ldd-analyze` replaces quality-analyzer's combined tests+lint+review report; `@pre-commit-review` replaces go-code-reviewer with the hunter/skeptic review. + +### Added + +- **`rules/` — R1–R9, the single source of truth.** Each rule file states its Principle, Why, a canonical before/after, Design guidance (forward), a Fix pattern (backward), and Falsifying questions with grep-able detection commands: + R1 primitive obsession · R2 self-validating types · R3 storifying · R4 helper placement · R5 vertical slice · R6 test-only interfaces · R7 test placement · R8 no globals · R9 repo-brain (documentation network). +- **`examples/` — case law.** Deep worked studies cited by the rules: `storify-leaf-type`, `overabstraction-cidr`, `dependency-rejection`. +- **New agents** (spawned programmatically, payload-fed, isolated contexts): + - `rule-hunter` — single-obsession reviewer; gets ONE rule file pasted in full, hunts only that rule across the diff, returns evidence-backed findings. + - `overabstraction-skeptic` — devil's advocate that tries to kill every "extract a type/package" proposal using R1's juiciness scorecard; refuted proposals ship a cheaper alternative instead. + - `lint-fixer` — runs the full-repo lint-fix loop in an isolated context (token noise stays out of your conversation); fixes mechanical issues, escalates design failures with a rule route. +- **`/wire-repo-brain [path]` command** — bootstrap the R9 documentation network on an existing repo in one pass: code→docs edges, `index.md`, CLAUDE.md wiring. +- **Composition-ladder testing model** in `@testing`: test each behavior at the lowest rung that contains it; rung-tagged reusable patterns in the testing reference. + +### Changed + +- **Review is now hunter/skeptic and advisory.** `@pre-commit-review` grep-prefilters the diff per rule, spawns one parallel `rule-hunter` per rule with hits, then the skeptic pass; the merged report categorizes findings (🐛 Bugs / 🔴 Design Debt / 🟡 Readability / 🟢 Polish) and **never blocks a commit**. Expect more parallel subagent activity during review than v1's single reviewer. +- **The orchestrator runs a per-behavior RED→GREEN→REFACTOR loop** (Phase 2) with package-scoped lint each cycle, instead of phase-batched implementation. Full-repo lint runs once, in Phase 3, via `lint-fixer`. +- **Skills were thinned to directional views** (~100–150 lines) that sequence and route into the rules — `@code-designing` is the forward view (design before code exists), `@refactoring` the backward view (linter failure → owning rule's Fix pattern). Duplicated reference content was deleted; skill names are unchanged. +- **`@documentation` is now the R9 repo-brain author** with FEATURE mode (Phase 5, document behavior after a change) and BOOTSTRAP mode (wire a repo's documentation network). +- Commands (`/go-ldd-analyze`, `/go-ldd-autopilot`, `/go-ldd-quickfix`, `/go-ldd-review`, `/go-ldd-status`) updated to the new phase model; names and file-targeting behavior unchanged. + +### Unchanged — no action required on upgrade + +- Plugin name, all six skill names, all five pre-existing slash commands, auto-detection triggers, and the zero-configuration promise (test/lint commands discovered from Makefile/Taskfile/README). +- The opt-in package-size hook. + +## [1.0.0] - 2025-10-28 + +Initial release as a Claude Code plugin: five-phase linter-driven workflow (design, TDD, lint, review, document) with skills, the `quality-analyzer`/`go-code-reviewer` agents, `/go-ldd-*` commands, and the package-size hook. + +Notable unversioned improvements between 1.0.0 and 2.0.0: auto-pilot mode and review agent commands, evidence-based review with test-only interface detection, self-validation ownership rule, improved lint-failure flow, and making the package-size hook opt-in. + +[2.6.0]: https://github.com/buzzdan/ai-coding-rules/releases/tag/go-ldd-v2.6.0 +[2.5.0]: https://github.com/buzzdan/ai-coding-rules/releases/tag/go-ldd-v2.5.0 +[2.4.0]: https://github.com/buzzdan/ai-coding-rules/releases/tag/go-ldd-v2.4.0 +[2.3.0]: https://github.com/buzzdan/ai-coding-rules/releases/tag/go-ldd-v2.3.0 +[2.2.0]: https://github.com/buzzdan/ai-coding-rules/releases/tag/go-ldd-v2.2.0 +[2.1.0]: https://github.com/buzzdan/ai-coding-rules/releases/tag/go-ldd-v2.1.0 +[2.0.0]: https://github.com/buzzdan/ai-coding-rules/releases/tag/go-ldd-v2.0.0 +[1.0.0]: https://github.com/buzzdan/ai-coding-rules/commit/746ae7d diff --git a/lang/go/passthrough/README.md b/lang/go/passthrough/README.md new file mode 100644 index 0000000..19f34db --- /dev/null +++ b/lang/go/passthrough/README.md @@ -0,0 +1,320 @@ +# Go Linter-Driven Development + +**Stop fighting your linter. Let it guide you to better code.** + +This Claude Code plugin turns Go development into a smooth, test-first workflow where quality gates don't slow you down — they guide your design. Instead of manually running tests, fixing linter errors one by one, and wondering if your design is solid, the plugin sequences design, TDD, linting, and an evidence-based design review at the cadence each check's economics demand. + +### The Problem It Solves + +You've been there: write some code, run tests (they pass!), run the linter... 15 errors. Fix those. Run again. More errors. Fix complexity here, function length there. Finally get it green — but is the design actually good? + +The linter tells you **WHAT** to change (complexity 18, function too long). This plugin's rules tell you **HOW** — each linter failure routes to the one rule whose fix pattern owns the repair. And the checks a linter *can't* run — primitive obsession, mixed abstraction levels, test-only interfaces — are hunted by fresh-context review agents against your actual diff. + +## Architecture: Rules as Data + +The organising idea of v2: **the rule is the unit, not the phase.** Each design principle lives exactly **once**, as data, in `rules/`. Everything else is a thin view over those rules or an agent that receives a rule as a payload. + +``` +go-linter-driven-development/ +├── maxims.md the uncompiled layer — named design questions above the rules +├── rules/ R1-primitive-obsession … R12-mutation-discipline (single source of truth) +├── examples/ storify-leaf-type · overabstraction-cidr · dependency-rejection · +│ anti-if-dispatch · switch-to-polymorphism (case law) +├── skills/ linter-driven-development · code-designing · refactoring · +│ pre-commit-review · testing · documentation (thin directional views) +├── agents/ rule-hunter · overabstraction-skeptic · lint-fixer (isolated workers) +├── commands/ go-ldd-analyze · autopilot · quickfix · prepare · review · status · wire-repo-brain +├── scripts/ check-repo-brain.sh — repo-brain conformance gate, installed into target repos by /wire-repo-brain +└── hooks/ package-size gate +``` + +**Five layers, one fact per fact:** + +- **[`maxims.md`](maxims.md)** — the layer above the rules: named design maxims ("Tell, don't ask", "Duplication is far cheaper than the wrong abstraction", "Make the change easy…") as *questions*, each pointing at the rules that compile it. Maxims live only at the judgment points — design interrogation (@code-designing), escalation vocabulary (@refactoring), the skeptic's doctrine — and are banned from hunters: **maxims propose, evidence disposes.** A maxim that keeps convicting graduates into a rule. +- **[`rules/`](rules/)** — R1–R12, each a self-contained hunter payload. A rule file states its Principle, Why, a real-world canonical before/after, Design guidance (forward), a Fix pattern (backward), and Falsifying questions (each phrased to *disprove* compliance, with a grep/count detection command). A rule's content is normative in its file and nowhere else — everything else points at it. +- **[`examples/`](examples/)** — deep worked case studies (full before/after code + the reasoning). Rules cite them by relative path instead of inlining long studies. +- **[`skills/`](skills/)** — thin directional views (~100–150 lines) that *sequence* and *route* into the rules. They never restate rule content. +- **[`agents/`](agents/)** — read-only or mechanical workers spawned in isolated contexts. **Agents get knowledge as spawn-time payload — the relevant rule file's content is pasted into the prompt. Agents do NOT invoke skills.** + +### The Five-Phase Flow + +The [`@linter-driven-development`](skills/linter-driven-development/SKILL.md) skill is the meta-orchestrator. It sequences the thin skills and the `lint-fixer` agent at the cadence each check's economics demand: + +``` +1 DESIGN @code-designing → DESIGN PLAN → user OK +1.5 PREPARE preparatory refactoring (Fowler): survey the plan's touch points, + four autonomous gates decide, @refactoring reshapes → prep commit(s), no user stop +2 IMPLEMENT per behavior: + ┌─> RED one failing test, lowest rung on the composition ladder (@testing) + │ test resists? → late prep signal → same gates → prep commit → re-enter + │ GREEN minimum code to pass — no design work + │ REFACTOR package-scoped lint + rule greps; any hit → @refactoring + └── next behavior until all done +3 FULL LINT ONE full-repo run via the lint-fixer agent (isolated context) + mechanical → FIXED · design → ESCALATED → back to Phase 2's REFACTOR (@refactoring) +4 REVIEW per completed slice: @pre-commit-review spawns hunters + skeptic → advisory report +5 SHIP @documentation → commit summary → user commits +``` + +Design happens once, up front (Phase 1); the RED test's shape carries that design into GREEN. PREPARE makes the change easy before making the easy change — reshaping only what the plan touches and only violations the plan would multiply, gated autonomously (the over-abstraction skeptic judges any extraction) so autopilot never stops to ask. The cheap per-cycle greps in Phase 2's REFACTOR are the mid-implementation net; the Phase 4 hunter/skeptic pass is the verification net on finished work. + +### The Hunter / Skeptic Review Model + +Phase 4 ([`@pre-commit-review`](skills/pre-commit-review/SKILL.md)) is pure orchestration — it spawns agents and reports, but **never edits code and never blocks a commit**: + +1. **Grep pre-filter** (in-context, cheap): run each rule's detection commands against the diff. A rule with zero hits gets no hunter. +2. **Parallel hunters**: for every rule with hits, spawn one [`rule-hunter`](agents/rule-hunter.md) — single obsession, single rule file pasted in full as its entire rulebook. Each returns evidence-backed findings (`rule | file:line | falsifying-question answers | fix pattern | effort`). +3. **Skeptic pass**: every "create a type/package" proposal goes to one [`overabstraction-skeptic`](agents/overabstraction-skeptic.md), which tries to *kill* each extraction using R1's juiciness scorecard and the CIDR case file. A refuted proposal ships only its cheaper alternative (better naming, private fields + accessors). +4. **Merged report**: surviving findings categorized as 🐛 Bugs / 🔴 Design Debt / 🟡 Readability Debt / 🟢 Polish. All advisory — the caller decides what to fix. Findings from *different* rules converging on one anchor (the same type, field, or function) are additionally reported as a 🔗 **CLUSTER** — each hunter is blind to the others, so independent convergence is evidence of a missing domain concept, and the cluster routes design-first (`@code-designing` scoped to the concept, then `@refactoring` implements) instead of being fixed member-by-member. + +Isolated contexts matter: the `lint-fixer` loop's token noise stays out of your conversation, and each hunter's fresh context is exactly what makes its findings trustworthy on finished work. + +## Link Map + +**Rules → file** (single source of truth): + +| Rule | File | Enforces | +|------|------|----------| +| R1 | [`rules/R1-primitive-obsession.md`](rules/R1-primitive-obsession.md) | Domain concepts as types, not raw primitives (incl. juiciness scoring) | +| R2 | [`rules/R2-self-validating-types.md`](rules/R2-self-validating-types.md) | Validate in the constructor; no invalid states, nil not a value | +| R3 | [`rules/R3-storifying.md`](rules/R3-storifying.md) | One abstraction level per function; extract named steps | +| R4 | [`rules/R4-helper-placement.md`](rules/R4-helper-placement.md) | Helper visibility/placement on the placement ladder | +| R5 | [`rules/R5-vertical-slice.md`](rules/R5-vertical-slice.md) | Group by feature, not layer; file-per-type | +| R6 | [`rules/R6-test-only-interfaces.md`](rules/R6-test-only-interfaces.md) | No interface whose only second implementer is a test double | +| R7 | [`rules/R7-test-placement.md`](rules/R7-test-placement.md) | `pkg_test` only, no wantErr conditionals, right-rung tests, no sleeps | +| R8 | [`rules/R8-no-globals.md`](rules/R8-no-globals.md) | No package-level state; no `context.Background()` in library code | +| R9 | [`rules/R9-repo-brain.md`](rules/R9-repo-brain.md) | Documentation network: fact at its lowest rung, reachable from the root, edges both directions; index wired into CLAUDE.md; doc root is an OKF bundle (frontmatter, drift-checked index lines) | +| R10 | [`rules/R10-concurrency-safety.md`](rules/R10-concurrency-safety.md) | Goroutines with owners and exit paths; shared state guarded where it lives; no production sleeps | +| R11 | [`rules/R11-conditional-dispatch.md`](rules/R11-conditional-dispatch.md) | One dispatch owner per kind/variant family (Anti-IF): duplicated kind-switches become interface/map dispatch chosen once at the boundary; a single switch stays and goes exhaustive | +| R12 | [`rules/R12-mutation-discipline.md`](rules/R12-mutation-discipline.md) | Mutation only through invariant-owning methods: constructors copy collections in, queries copy (or iterate) out, no query/modifier hybrids, no setters around validating constructors | + +**Examples → rules demonstrated** (case law): + +| Example | Demonstrates | +|---------|--------------| +| [`examples/storify-leaf-type.md`](examples/storify-leaf-type.md) | R3, R1, R2 — storifying a fat function; extracting a self-validating leaf type | +| [`examples/overabstraction-cidr.md`](examples/overabstraction-cidr.md) | R1 — when an extraction is over-abstraction (the skeptic's payload) | +| [`examples/dependency-rejection.md`](examples/dependency-rejection.md) | R8 — dependency rejection: eliminating globals by threading dependencies | +| [`examples/anti-if-dispatch.md`](examples/anti-if-dispatch.md) | R11 — duplicated kind-switch → interface dispatch / strategy map, plus the kept-switch rejection (the skeptic's dispatch payload) | +| [`examples/switch-to-polymorphism.md`](examples/switch-to-polymorphism.md) | R11, R6 — type switch over an owned interface → fill-style method; the earned/sealed interface; the dependency-direction rejection | + +**Skills → role** (thin views): + +| Skill | Role | +|-------|------| +| [`@linter-driven-development`](skills/linter-driven-development/SKILL.md) | Meta-orchestrator — sequences the five phases (plus the autonomous PREPARE sub-phase, 1.5) | +| [`@code-designing`](skills/code-designing/SKILL.md) | FORWARD view — which rule to open at each design step (Phase 1) | +| [`@refactoring`](skills/refactoring/SKILL.md) | BACKWARD view — routes each linter/review failure to its owning rule's Fix pattern; preparatory mode reshapes ahead of a planned change (Phase 1.5) | +| [`@pre-commit-review`](skills/pre-commit-review/SKILL.md) | Orchestrates the hunter/skeptic review (Phase 4); reports, never edits | +| [`@testing`](skills/testing/SKILL.md) | The composition ladder — test each behavior at the lowest rung that contains it | +| [`@documentation`](skills/documentation/SKILL.md) | Repo-brain author (R9) — behavior docs + network wiring, OKF conformance + conventions self-hosting; FEATURE mode (Phase 5) / BOOTSTRAP mode | + +**Agents → spawned by** (payload-fed, isolated): + +| Agent | Spawned by | Gets as payload | Edits? | +|-------|-----------|-----------------|--------| +| [`rule-hunter`](agents/rule-hunter.md) | `@pre-commit-review` (one per rule with hits, in parallel) | ONE full `rules/R*.md` file + diff scope | No (read-only) | +| [`overabstraction-skeptic`](agents/overabstraction-skeptic.md) | `@pre-commit-review` (after hunters report) | R1 juiciness scorecard + `examples/overabstraction-cidr.md` | No (read-only) | +| [`lint-fixer`](agents/lint-fixer.md) | `@linter-driven-development` (Phase 3) | routing table (linter failure → rule) | Yes (mechanical only; escalates design) | + +## Slash Commands + +| Command | Purpose | Auto-Fix | File targeting | +|---------|---------|----------|----------------| +| [`/go-ldd-autopilot`](commands/go-ldd-autopilot.md) | Full workflow (Phases 1–5) | ✅ Yes | — | +| [`/go-ldd-quickfix [files]`](commands/go-ldd-quickfix.md) | Quality-gates loop until green (code exists) | ✅ Yes | ✅ Optional | +| [`/go-ldd-prepare [files]`](commands/go-ldd-prepare.md) | Preparatory refactoring: reshape what a planned change touches, so it lands add-only | ✅ Yes | ✅ Optional | +| [`/go-ldd-analyze [files]`](commands/go-ldd-analyze.md) | 🔍 Tests + lint + review, combined report | ❌ No | ✅ Optional | +| [`/go-ldd-review [files]`](commands/go-ldd-review.md) | 🔍 Commit-readiness check | ❌ No | ✅ Optional | +| [`/go-ldd-status`](commands/go-ldd-status.md) | Show current phase + progress | N/A | — | +| [`/wire-repo-brain [path]`](commands/wire-repo-brain.md) | Wire the documentation network in one pass: frontmatter → upward edges → docs → index.md → CLAUDE.md/AGENTS.md + conventions.md + conformance script (@documentation BOOTSTRAP) | ✅ Wiring only | ✅ Optional | + +## How Auto-Detection Works + +When you request Go code work (e.g., "implement feature X", "fix bug in handler.go"), Claude detects that the linter-driven-development skill applies and **asks for permission**: + +``` +Use skill "go-linter-driven-development:linter-driven-development"? +Claude may use instructions, code, or files from this Skill. + +Do you want to proceed? +❯ 1. Yes + 2. Yes, and don't ask again for this skill in [current-directory] + 3. No, and tell Claude what to do differently +``` + +**Recommended:** Select option 2 on first use — the skill then runs automatically in that directory. + +**Triggers auto-detection:** +- Action verbs: `implement`, `fix`, `build`, `add`, `refactor`, `update`, `change`, `modify` +- Working in a Go project (detects `go.mod` or `.go` files) +- Mentions "ldd" or "@ldd" + +On trigger, the skill announces **"Using go-ldd workflow for this Go code work"** and runs pre-flight. + +## Why Linter-Driven Development? + +### Code Written for Understanding, Not Just Execution + +The philosophy: **if code takes more than 10–15 seconds to understand, it's too complex.** + +Modern development involves two readers: +- **Humans** — limited by working memory (4–7 items, Miller's Law) +- **AI** — works on heuristics from clean, well-documented code + +Clean, storified code with clear abstractions gives both lower cognitive load and better heuristics. + +### The Three Pillars of Maintainability + +Linter rules enforce objective quality standards: + +**1. Cyclomatic Complexity ≤ 10** — independent execution paths; higher = more places for bugs to hide. +**2. Cognitive Complexity ≤ 15** — human effort to understand; penalizes nesting and mixed abstractions. +**3. High Maintainability Index** — composite metric predicting long-term code health. + +### How Linter Rules Drive Design + +Each linter failure has an owning rule with a fix pattern — the mapping is [`@refactoring`](skills/refactoring/SKILL.md)'s routing table; the design-decision linters (`argument-limit`, `function-result-limit`) route via [`@code-designing`](skills/code-designing/SKILL.md): + +**`gochecknoglobals`** → R8: dependency injection instead of global state +**`gocognit` / `gocyclo`** → R3: extract named steps, reduce nesting +**`funlen`** → R3: functions < 50 LOC, single responsibility +**`argument-limit` / `function-result-limit`** → R1: options/result types +**`file-length-limit` / package-size zones** → R5: file-per-type, sub-packages + +Design decisions aren't subjective — they're driven by measurable quality metrics, and each metric routes to a named fix. + +## Installation + +**Step 1: Add the marketplace** +``` +/plugin marketplace add buzzdan/ai-coding-rules +``` + +**Step 2: Install the plugin** +``` +/plugin install go-linter-driven-development@ai-coding-rules +``` + +**Verify installation:** +``` +/plugin list +``` +Should show: `go-linter-driven-development (enabled)` + +## Quick Start + +**Zero configuration required.** The plugin discovers your project's test and lint commands from `README.md`, `CLAUDE.md`, `Makefile`, or `Taskfile.yaml`. Just install and go. + +### The Easiest Way: Just Talk to It + +Tell Claude what you want: + +``` +"implement step 1" +"ready to start coding" +"do the next task" +"execute the authentication feature" +``` + +The plugin recognizes these phrases and **automatically engages the five-phase workflow**. You don't need to remember commands. + +### Want More Control? Use Slash Commands + +```bash +# Starting fresh? Full workflow (5-15 min) +/go-ldd-autopilot + +# Code is written, just needs to pass linter/tests? (2-5 min) +/go-ldd-quickfix + +# Want to see what's wrong before deciding whether to fix? (read-only) +/go-ldd-analyze + +# About to commit, want one final check? (read-only) +/go-ldd-review + +# Lost track of where we are? +/go-ldd-status +``` + +**File targeting:** commands marked `[files]` accept an optional pattern to scope the run: + +```bash +/go-ldd-analyze ./pkg/parser/ +/go-ldd-quickfix ./pkg/handler.go +/go-ldd-review ./cmd/main.go +``` + +### Need Just One Piece? Use Individual Skills + +Skills are expert consultants you can call on demand: + +``` +"Use @code-designing to plan types for payment processing" +"Use @testing to structure tests for UserService" +"Use @refactoring to reduce complexity in HandleRequest" +"Use @pre-commit-review to validate this code" +"Use @documentation to document the auth feature" +``` + +## How the Plugin Categorizes Issues + +The [`@pre-commit-review`](skills/pre-commit-review/SKILL.md) report groups findings by urgency — all advisory, none block a commit: + +- 🐛 **Bugs** — fail at runtime regardless of rule (fix immediately) +- 🔴 **Design Debt** — R1, R2, R4, R5, R6, R7, R8, R11, R12 (fix before commit recommended) +- 🟡 **Readability Debt** — R3, R9, unclear naming (improves maintainability) +- 🟢 **Polish** — minor idiomatic improvements, the skeptic's cheaper alternatives + +Every finding carries evidence (`file:line` + the falsifying-question answer or command output) and cites its rule's Fix pattern for HOW to fix. + +## The Design Principles Behind This + +The plugin follows opinionated Go best practices, each with an owning rule: + +**Design:** no primitive obsession (R1), self-validating types (R2), vertical slices (R5), no globals (R8), owned goroutines and guarded shared state (R10), one dispatch owner per variant family — the Anti-IF rule (R11), closed mutation surfaces — no leaked aliases or unvalidated setters (R12). +**Testing:** test the public API via `pkg_test` (R7), the composition ladder over the pyramid, real in-memory dependencies over mocks, no test-only interfaces (R6). +**Refactoring:** storify top-level functions (R3), helpers on the placement ladder (R4), let the linter say WHAT and the rules say HOW. +**Documentation:** a networked repo brain — each fact at its lowest rung, reachable from the root, edges pointing both ways (R9). + +## v2 Changes + +Earlier versions organised knowledge by *phase* and centralised analysis in two generalist agents: **`quality-analyzer`** (a parallel tests+linter+review orchestrator) and **`go-code-reviewer`** (a single design reviewer that loaded the review skill for guidance). v2 replaces both: + +- Knowledge moved out of the skills and into `rules/` as data — stated once, cited everywhere. +- The single design reviewer became **parallel single-obsession `rule-hunter` agents** plus the **`overabstraction-skeptic`**, each fed the relevant rule file as a spawn-time payload rather than loading a skill. +- The lint loop moved into the isolated **`lint-fixer`** agent. + +If you have muscle memory for `quality-analyzer` or `go-code-reviewer`, the closest v2 entry points are `/go-ldd-analyze` (read-only combined report) and `@pre-commit-review` (the hunter/skeptic review). + +## Updating + +``` +/plugin update go-linter-driven-development@ai-coding-rules +``` + +See [CHANGELOG.md](CHANGELOG.md) for what changed between versions — including the v2.0.0 breaking changes and migration notes. + +## Uninstalling + +``` +/plugin +``` +Select "go-linter-driven-development" and choose "Uninstall". + +## Need Help or Want to Contribute? + +**Documentation:** Full details in the [main repository](https://github.com/buzzdan/ai-coding-rules) + +**Found a bug or have an idea?** [Open an issue](https://github.com/buzzdan/ai-coding-rules/issues) + +**Want to contribute?** PRs welcome — typos, examples, or improvements to the rules and skills. + +## License + +MIT — Use it however you want! + +--- + +**Happy coding!** May your linter always be green and your complexity always be low. 🚀 diff --git a/lang/go/passthrough/evals/README.md b/lang/go/passthrough/evals/README.md new file mode 100644 index 0000000..eb726a9 --- /dev/null +++ b/lang/go/passthrough/evals/README.md @@ -0,0 +1,2 @@ +The behavioral evals for this plugin live in [buzzdan/ldd-evals](https://github.com/buzzdan/ldd-evals) under `go/`; run them from this repository with `scripts/evals.sh` (or `task evals:run`), and read how they work from [docs/index.md](../../docs/index.md). +Everything else under this directory is ignored: a run copies the cases here for the `claude plugin eval` gate, and nothing under it is committed. diff --git a/lang/go/passthrough/examples/anti-if-dispatch.md b/lang/go/passthrough/examples/anti-if-dispatch.md new file mode 100644 index 0000000..3a56e1e --- /dev/null +++ b/lang/go/passthrough/examples/anti-if-dispatch.md @@ -0,0 +1,258 @@ +# Anti-IF Dispatch Case: One Decision, One Owner + +Demonstrates: R11 (edges into R1, R2, R6) + +A worked study of the R11 moves on a realistic alert-notification slice: a string +discriminator inspected in three files becomes an interface chosen once at the +boundary; a single-function variance becomes a strategy map instead; and the inverse +case — where the skeptic kills the extraction and the switch *stays* — is worked to +its cheaper alternative. The compact excerpt lives in +`../rules/R11-conditional-dispatch.md`; this file is the full case law. + +## The disease: a decision with three owners + +The alert feature delivers over email, Slack, or PagerDuty. `Alert.Channel` is a raw +string (already an R1 smell), and three sites ask what it is: + +```go +// ❌ alert/send.go +func Send(a Alert) error { + switch a.Channel { + case "email": + return smtpSend(a.Recipient, renderEmail(a)) + case "slack": + return slackPost(a.Recipient, renderSlack(a)) + case "pagerduty": + return pdCreateIncident(a.Recipient, a.Summary) + default: + return fmt.Errorf("unknown channel %q", a.Channel) + } +} + +// ❌ alert/validate.go — drifted: pagerduty was never added here +func validRecipient(a Alert) bool { + switch a.Channel { + case "email": + return strings.Contains(a.Recipient, "@") + case "slack": + return strings.HasPrefix(a.Recipient, "#") + } + return false +} + +// ❌ alert/retry.go — the same decision wearing an if-chain +func retryDelay(a Alert) time.Duration { + if a.Channel == "pagerduty" { + return 0 + } + if a.Channel == "slack" { + return 5 * time.Second + } + return time.Minute +} +``` + +Why this is a defect and not a style choice: + +- **The drift already happened.** `validRecipient` returns `false` for `"pagerduty"` + — not by decision, but because the second copy of the switch was not in view when + the third channel landed. Duplicated discriminators drift the same way duplicated + validation predicates drift (R1's Q2). +- **Adding SMS is a scavenger hunt.** Three known sites, plus whatever a grep misses + (test helpers, a metrics label formatter). The compiler flags none of them: an + if-chain has no completeness, and a switch with a `default` swallows the new case + silently. +- **"Unknown channel" leaks everywhere.** Every switching site carries the + `default:`/fall-through arm, so every function's signature and tests carry the + maybe-unknown concept — the behavioral twin of R1's maybe-invalid port. +- **Nothing unit-tests in isolation.** Slack's recipient rule is only reachable by + driving `validRecipient` with a fully built `Alert`. + +## Move 1 — Replace Duplicated Switch with Interface Dispatch + +Define the interface from the union of what the copies do: `Send` switches on +delivery, `validRecipient` on addressing, `retryDelay` on retry policy — three +methods, one type per variant: + +```go +// alert/channel.go +type Channel interface { + Send(a Alert) error + ValidRecipient(recipient string) bool + RetryDelay() time.Duration +} + +// ParseChannel is the single decision point. This switch is ALLOWED — +// it is the one place the raw string may be inspected (R11), exactly as +// a ParseX constructor is the one place a raw value is validated (R2). +func ParseChannel(name string) (Channel, error) { + switch name { + case "email": + return Email{}, nil + case "slack": + return Slack{}, nil + case "pagerduty": + return PagerDuty{}, nil + default: + return nil, fmt.Errorf("unknown channel %q", name) + } +} +``` + +```go +// alert/slack.go — each variant is a leaf type owning ALL its behavior +type Slack struct{} + +func (Slack) Send(a Alert) error { return slackPost(a.Recipient, renderSlack(a)) } +func (Slack) ValidRecipient(r string) bool { return strings.HasPrefix(r, "#") } +func (Slack) RetryDelay() time.Duration { return 5 * time.Second } +``` + +`Alert` now holds a `Channel`, constructed at the boundary (the HTTP handler or +config loader calls `ParseChannel` and fails fast there). The three switching sites +collapse to method calls: + +```go +func Send(a Alert) error { return a.Channel.Send(a) } +func validRecipient(a Alert) bool { return a.Channel.ValidRecipient(a.Recipient) } +func retryDelay(a Alert) time.Duration { return a.Channel.RetryDelay() } +``` + +(And once they are one-liners, the wrappers themselves usually dissolve into their +callers — the story functions call the methods directly, R3.) + +What was deleted, not relocated: every `default:` arm downstream. An `Alert` that +exists holds a `Channel` that exists; "unknown channel" is unrepresentable past +`ParseChannel`. Adding SMS is now one new file (`sms.go`) plus one `case` in +`ParseChannel` — no existing file changes. + +**R6 check, answered explicitly:** this interface is *earned* — it has three +production implementations on day one. R6 forbids interfaces whose only second +implementer is a test double; a dispatch interface born with one variant "for the +future" fails that test and should stay a switch until the second variant is real. + +### The testing payoff + +Each variant unit-tests as a leaf with literals — no `Alert` construction, no +switch-driving: + +```go +func TestSlack_ValidRecipient(t *testing.T) { + assert.True(t, alert.Slack{}.ValidRecipient("#oncall")) + assert.False(t, alert.Slack{}.ValidRecipient("oncall")) +} + +func TestParseChannel_Unknown(t *testing.T) { + _, err := alert.ParseChannel("carrier-pigeon") + require.Error(t, err) +} +``` + +The drift bug (`pagerduty` missing from `validRecipient`) can no longer be written: +there is no second place to forget. + +## Move 2 — Replace If-Chain with Strategy Map + +Not every variance deserves an interface. Suppose only *rendering* varies by an +output format, in one behavioral dimension: + +```go +// ❌ before: if-chain in the middle of business logic +func render(a Alert, format string) string { + if format == "json" { + return renderJSON(a) + } + if format == "text" { + return renderText(a) + } + return renderMarkdown(a) // silent default — is "yaml" markdown? nobody decided +} +``` + +One behavior → a map, not three types: + +```go +type Format string + +const ( + FormatJSON Format = "json" + FormatText Format = "text" + FormatMarkdown Format = "markdown" +) + +var renderers = map[Format]func(Alert) string{ + FormatJSON: renderJSON, + FormatText: renderText, + FormatMarkdown: renderMarkdown, +} + +func ParseFormat(raw string) (Format, error) { + if _, ok := renderers[Format(raw)]; !ok { + return "", fmt.Errorf("unknown format %q", raw) + } + return Format(raw), nil +} + +func render(a Alert, f Format) string { return renderers[f](a) } +``` + +The lookup *is* the dispatch; the comma-ok check lives once, in `ParseFormat`, at the +boundary. The silent markdown default — an undecided decision — became an explicit +error. (The map is package-level immutable data, the sanctioned shape under R8; +naming the enum is R1's "Name enum strings" move.) + +## Move 3 — the rejection: when the switch stays + +The skeptic's side of R11, worked honestly. The same codebase has this: + +```go +// alert/severity.go — the ONLY site that inspects Severity +func (s Severity) Color() string { + switch s { + case SeverityInfo: + return "blue" + case SeverityWarning: + return "yellow" + case SeverityCritical: + return "red" + } + return "" +} +``` + +A dispatch-happy reading says: three variants, extract `type Severity interface` +with `Info`, `Warning`, `Critical` types. Score it before moving (R1 scorecard, via +the over-abstraction skeptic): + +- Duplication of the discriminator: **1 site** (grep `switch .*Severity` → one hit) — +0 +- Behavioral variance: one method, returns a constant string — trivial — +0 +- Would the interface be earned (R6)? Three implementations, but each is an empty + struct wrapping one literal — ceremony + +Verdict: **REFUTED.** The extraction would turn 12 readable lines into three files +and an interface for zero deletion — no duplicated switch exists to delete. The +cheaper alternative is R11's sanctioned form, **Keep the Single Exhaustive Switch**: + +```go +func (s Severity) Color() string { + switch s { // exhaustive: linter fails the build when a Severity is added unhandled + case SeverityInfo: + return "blue" + case SeverityWarning: + return "yellow" + case SeverityCritical: + return "red" + } + return "" +} +``` + +with `exhaustive` enabled in `.golangci.yaml`. Now the linter provides what dispatch +would have: adding `SeverityFatal` fails the build at this switch instead of falling +through to `""`. That is the whole benefit, at none of the cost. + +**The dividing line, restated:** dispatch is bought with the *deletion of duplicated +decisions*. Three sites collapsed to one boundary — clear win (Move 1). One +single-dimension variance — a map (Move 2). One site, trivial variance — the switch +stays, made exhaustive (Move 3). If nothing gets deleted, the abstraction is +ceremony. diff --git a/lang/go/passthrough/examples/dependency-rejection.md b/lang/go/passthrough/examples/dependency-rejection.md new file mode 100644 index 0000000..54ff4df --- /dev/null +++ b/lang/go/passthrough/examples/dependency-rejection.md @@ -0,0 +1,261 @@ +# Dependency Rejection Case: Incremental Global Elimination + +Demonstrates: R8 + +A real refactoring where `env.Configs.*` globals reached from deep inside the +codebase (20+ access points) were eliminated incrementally — one clean island at a +time, pushing each global up one level per iteration until only the entry points +touched configuration. This is the case law for R8: the rejection move, why sideways +access resists testing, and the pragmatic stopping point. + +This pattern differs from the other refactorings in one important way: it is **not a +one-time fix**. It is an incremental journey — start at the bottom (leaf code), +create one clean island at a time, push globals toward `main()`, and accept globals +at the top. + +## Which globals are the problem + +Some globals are designed to be global and are fine: loggers (`slog`, `zerolog`), +constants and enums, `var Err... = errors.New` sentinels. The problem is +configuration and mutable state reached sideways: + +- `env.Configs.NATsAddress`, `env.Configs.DBHost`, `env.Configs.RedisURL` — any + `env.Configs.*` scattered through business logic. + +Why these are defects: they make code untestable except by mutating shared state, +create hidden dependencies invisible in any signature, forbid parallel tests, and +weld every caller to one config struct. + +## Before — global chaos + +```go +// Global config accessed everywhere +package env + +var Configs struct { + NATsAddress string + DBHost string + RedisURL string +} + +// ❌ deep in the messaging code +package messaging + +func PublishEvent(event Event) error { + conn, err := nats.Connect(env.Configs.NATsAddress) // global reached from a leaf + if err != nil { + return fmt.Errorf("connect failed: %w", err) + } + defer conn.Close() + + data, err := json.Marshal(event) + if err != nil { + return err + } + return conn.Publish(event.Topic, data) +} + +// ❌ in the order service — more sideways access +package order + +func ProcessOrder(orderID string) error { + db := connectDB(env.Configs.DBHost) + defer db.Close() + + return messaging.PublishEvent(orderCreatedEvent) // hides its NATS dependency +} +``` + +And the testing nightmare the globals cause: + +```go +func TestPublishEvent(t *testing.T) { + // ❌ must mutate shared state + originalAddr := env.Configs.NATsAddress + env.Configs.NATsAddress = "nats://test:4222" + defer func() { env.Configs.NATsAddress = originalAddr }() + + // ❌ cannot run in parallel — the global is shared + // ❌ state leaks between tests + // ❌ testing two addresses means two mutations of the same variable +} +``` + +Inventory: `env.Configs.NATsAddress` in 12 locations, `env.Configs.DBHost` in 8 — +20 sideways accesses, zero types testable without global writes. + +## Step 1 — map the dependency chain + +``` +main() + └─ HTTP handlers (entry points) + ├─ OrderService.ProcessOrder() [USES env.Configs.DBHost] + │ └─ messaging.PublishEvent() [USES env.Configs.NATsAddress] + │ └─ messaging.PublishBatch() [USES env.Configs.NATsAddress] + └─ UserService.CreateUser() + └─ messaging.PublishEvent() [USES env.Configs.NATsAddress] +``` + +The deepest usage — furthest from `main()` — is `messaging.PublishEvent`/ +`PublishBatch`. **Start there.** Bottom-up matters: extracting the leaf first means +each iteration produces a finished, testable island; top-down would thread +parameters through layers that still read globals underneath. + +## Step 2 — create the first clean island + +The rejection move: the function stops *fetching* the value and starts *being given* +it — as a constructor-injected field on a new type. + +```go +// ✅ clean type with injected dependency +type NATSClient struct { + natsAddress string // injected, not global +} + +func NewNATSClient(natsAddress string) *NATSClient { + return &NATSClient{natsAddress: natsAddress} +} + +func (c *NATSClient) PublishEvent(event Event) error { + conn, err := nats.Connect(c.natsAddress) // uses the injected value + if err != nil { + return fmt.Errorf("connect failed: %w", err) + } + defer conn.Close() + + data, err := json.Marshal(event) + if err != nil { + return err + } + return conn.Publish(event.Topic, data) +} +``` + +Island #1 is done: `NATSClient` is 100% testable with no globals in sight. + +## Step 3 — push the global up one level + +`messaging` no longer reads the global — its callers now face the dependency. Apply +the same move to them: + +```go +// ✅ OrderService receives its dependencies +package order + +type OrderService struct { + dbHost string // injected + natsClient *NATSClient // clean dependency +} + +func NewOrderService(dbHost string, natsClient *NATSClient) *OrderService { + return &OrderService{dbHost: dbHost, natsClient: natsClient} +} + +func (s *OrderService) ProcessOrder(orderID string) error { + db := connectDB(s.dbHost) + defer db.Close() + + return s.natsClient.PublishEvent(orderCreatedEvent) +} +``` + +Island #2. The globals have moved up one level — they are now read by whoever +constructs `OrderService`. + +## Step 4 — stop at the entry points + +```go +// ✅ the global is read ONLY here, at wiring time +package api + +type OrderHandler struct { + orderService *OrderService +} + +func SetupOrderHandler() *OrderHandler { + natsClient := NewNATSClient(env.Configs.NATsAddress) + orderService := NewOrderService(env.Configs.DBHost, natsClient) + return &OrderHandler{orderService: orderService} +} + +func (h *OrderHandler) HandleCreateOrder(w http.ResponseWriter, r *http.Request) { + err := h.orderService.ProcessOrder(orderID) // all clean code from here down + // ... +} +``` + +Final state: 2 global accesses (both in setup functions), down from 20. Everything +below the handlers is constructor-injected. + +## The test payoff + +```go +func TestNATSClient_PublishEvent(t *testing.T) { + t.Parallel() // ✅ possible now — no shared state + + testNATS := startTestNATS(t) // real NATS test server, fake data + defer testNATS.Stop() + + client := messaging.NewNATSClient(testNATS.URL()) // clean injection + + err := client.PublishEvent(testEvent) + require.NoError(t, err) +} + +func TestNATSClient_PublishEvent_ConnectError(t *testing.T) { + t.Parallel() + + client := messaging.NewNATSClient("nats://nonexistent:4222") + + err := client.PublishEvent(testEvent) + assert.Error(t, err) +} +``` + +Contrast with the before-test: no save/mutate/restore dance, no ordering hazards, +parallel by default, and testing a second address is just constructing a second +client. The stand-in is a *real* NATS test server — a fake in the legitimate sense +(real implementation, fake data), not an interface-injected double. + +Testability before: 0 types testable without global mutation, parallel tests +impossible. After: 3 clean islands (`NATSClient`, `OrderService`, `UserService`), +100% coverage on them, fully parallel. + +## Why sideways access resists testing + +A global read is an input the test cannot supply through the code's own surface. To +control it, the test must write the shared variable — which serializes the whole +test binary around that variable, leaks values into unrelated tests, and still only +supports one value at a time. Constructor injection turns the same input into an +argument: each test builds its own instance, values never collide, and the +dependency is visible in the signature where reviewers and callers can see it. + +## The incremental progression + +``` +Iteration 1: extract NATSClient — global accesses 20 → 14, islands: 1 +Iteration 2: extract OrderService — global accesses 14 → 8, islands: 2 +Iteration 3: extract UserService — global accesses 8 → 4, islands: 3 +Iteration 4: push to handler setup — global accesses 4 → 2 ✅ done +``` + +Every iteration is a working, tested, deployable state. No big-bang refactoring — +if the work stops after iteration 2, the codebase is still strictly better than it +started. + +## The decision points + +1. **Bottom-up, not top-down.** Start at the deepest usage; each extraction is + complete on its own. Top-down threading leaves half-injected layers that read + globals underneath the new parameters. +2. **The endpoint is pragmatic, not zero.** Globals at `main()`, handler setup, and + top-level factories are acceptable — that is where configuration legitimately + lives. Globals in business logic, data access, and library code are not. The goal + is globals only where wiring happens. +3. **Don't "fix" the globals that aren't broken.** Loggers designed for global use, + constants, and error sentinels stay. Spending iterations wrapping `slog` is + ceremony, not rejection. +4. **Rejection pairs with self-validation.** Once dependencies arrive through + constructors, the constructor is the natural place to validate them + (`../rules/R2-self-validating-types.md`) — the island trusts its fields + thereafter. diff --git a/lang/go/passthrough/examples/overabstraction-cidr.md b/lang/go/passthrough/examples/overabstraction-cidr.md new file mode 100644 index 0000000..fe7c9fa --- /dev/null +++ b/lang/go/passthrough/examples/overabstraction-cidr.md @@ -0,0 +1,148 @@ +# Over-Abstraction Case: The CIDRPresence Wrapper + +Demonstrates: R1 + +A real refactoring where an extraction was tried, rejected, and replaced with two +cheaper alternatives. This is the case law for R1's over-abstraction trap: what a +correct refutation of a proposed type looks like. + +## The setting + +During the refactor of a K3s configuration function (`alignCIDRArgs`, originally 60 +lines mixing string parsing, boolean flag tracking, and triplicated switch cases), +two booleans tracked related state: + +```go +var ( + isClusterCIDRSet bool + isServerCIDRSet bool +) +// ... a parsing loop sets them ... +if isClusterCIDRSet && isServerCIDRSet { + return // both set, nothing to do +} +``` + +Grouping them into a `CIDRConfig` domain type was a clear win (related data that +travels together, a query method that reads like English). The trap appeared one +step further: the temptation to wrap each boolean in its own type. + +## The extraction that was tried + +```go +// CIDRPresence — a wrapper that adds NO value +type CIDRPresence bool + +const ( + cidrPresent CIDRPresence = true +) + +func (p CIDRPresence) IsSet() bool { + return bool(p) // just unwraps the bool! +} + +type CIDRConfig struct { + ClusterCIDR CIDRPresence // wrapped bool + ServiceCIDR CIDRPresence // wrapped bool +} + +func (c CIDRConfig) AreBothSet() bool { + return c.ClusterCIDR.IsSet() && c.ServiceCIDR.IsSet() +} +``` + +## Why it was rejected + +1. **8 lines of code** for a trivial wrapper. +2. **One method** that just unwraps: `return bool(p)`. +3. **No type safety gained** — still just a bool underneath; nothing invalid is made + unrepresentable. +4. **Not more readable.** Compare `config.ClusterCIDR.IsSet()` (wrapper) with + `config.ClusterCIDRSet` (good naming). The honest question — is the method call + *significantly* clearer? — answers itself: no. +5. **No validation, no logic, no invariants** — pure ceremony. On R1's scorecard this + scores 0-1: LOW priority, do not create the type. +6. **Increases cognitive load** — one more type to understand, for nothing. + +The rejection also identified the *real* need hiding under the proposal: **controlled +mutation**. Only the parsing code should be able to set these flags — and the wrapper +type does not deliver that (its fields were still freely settable). Naming the actual +need is what makes the cheaper alternatives findable. + +## Cheaper alternative 1 — better naming + +When the need is only clarity, rename and stop: + +```go +type CIDRConfig struct { + ClusterCIDRSet bool + ServiceCIDRSet bool +} +``` + +`config.ClusterCIDRSet` reads exactly as well as `config.ClusterCIDR.IsSet()`, at +zero ceremony. Acceptable when mutation discipline isn't a concern (small, disciplined +surface; short-lived value). + +## Cheaper alternative 2 — private fields + accessors (chosen) + +When the need is controlled mutation rather than validation or logic, private fields +with read-only accessors deliver compiler-enforced safety without a wrapper: + +```go +// CIDRConfig — which CIDR configurations are present. +// Private fields: can only be set by ParseCIDRConfig. +type CIDRConfig struct { + clusterCIDRSet bool + serviceCIDRSet bool +} + +func (c CIDRConfig) ClusterCIDRSet() bool { return c.clusterCIDRSet } +func (c CIDRConfig) ServiceCIDRSet() bool { return c.serviceCIDRSet } + +func (c CIDRConfig) AreBothSet() bool { + return c.clusterCIDRSet && c.serviceCIDRSet +} +``` + +Why this beat the wrapper: + +- **Same safety** — the compiler enforces that only the parser (in the same package) + can set the values; external code gets read-only access. +- **4 fewer lines** than the `CIDRPresence` approach. +- **Same readability** — `ClusterCIDRSet()` is just as clear as `ClusterCIDR.IsSet()`. +- **No wrapper ceremony** — the fields are what they are: bools. + +## The decision, tabulated + +| Approach | Types | Readability | Safety | Ceremony | Verdict | +|----------|-------|-------------|--------|----------|---------| +| `CIDRPresence` wrapper | 6 | Good | Low | High | ❌ Over-abstraction | +| Public bool fields (naming) | 5 | Good | Low | Low | ⚠️ Acceptable for disciplined scope | +| Private bools + accessors | 5 | Good | **High** | Low | ✅ Chosen | + +## The decision questions + +Before creating a wrapper type, ask: + +1. Does it have >1 meaningful method with logic — not just unwrapping? +2. Does it enforce invariants or validation? +3. Is the need actually *controlled mutation*? → private fields + accessors, not a + wrapper. +4. Is the method call **significantly** clearer than good naming? +5. Does it hide complex implementation? + +Mostly NO → use primitives with good naming, or private fields when mutation must be +controlled. (Score it with R1's scorecard; this wrapper scores 0.) + +## The skeptic's operating rule + +**A refutation must always propose the cheaper alternative — never just "no".** + +Rejecting `CIDRPresence` was legitimate only because the rejection came with a design +that met the real need (controlled mutation) at lower cost. A bare "don't create the +type" would have left the original defect — uncontrolled mutation of the flags — in +place. The skeptic's job is therefore two moves, always together: name the need the +proposal was groping toward, then meet it more cheaply — better naming when the need +is clarity, private fields + accessors when the need is controlled mutation, and a +real type (per R1) only when the need is validation or behavior. diff --git a/lang/go/passthrough/examples/private-comment-noise.md b/lang/go/passthrough/examples/private-comment-noise.md new file mode 100644 index 0000000..119a4d8 --- /dev/null +++ b/lang/go/passthrough/examples/private-comment-noise.md @@ -0,0 +1,213 @@ +# Comment Noise Case: Nine Private Helpers, Nine Comments + +Demonstrates: R9 (comment policy — the visibility default) + +A real 143-line file from a JSON-RPC-over-HTTP client (anonymized), written by an +LLM flow before the visibility default existed. It detects which codec decoded a +reply body: JSON, or the client's configured non-JSON codec (msgpack). Every one +of its nine unexported symbols carries a comment; the file has more comment lines +than code lines. Each comment, judged alone, "delivers a toolbox value". The file +as a whole is unreadable — a human reviewer of a sibling PR called the style +"utterly lacking empathy for the reader". + +This is the case law for R9's visibility default: **unexported symbols get no +comment; the special case is one line carrying a very high-value toolbox item.** + +## The before — representative excerpts + +A 5-line comment on a private constant, with cross-repo provenance: + +```go +// nonJSONLeadingByteFloor is the lowest leading byte a non-JSON wire +// frame can start with in the leading-byte detection heuristic: +// JSON-RPC 2.0 envelopes always start with '{' (0x7b), and msgpack maps +// (fixmap, map16, map32) always start at 0x80 or above — the same +// boundary the legacy client's detectCodec uses. +nonJSONLeadingByteFloor byte = 0x80 +``` + +Decoder rings and review-defense narration on another constant: + +```go +// msgpackAliasContentType is the second literal spelling D-04 requires +// this package to accept as msgpack, alongside jsonrpc.ContentTypeMsgpack +// ("application/msgpack"). Named as a single constant — not a table — +// per Pitfall 3: a third wire format would earn its own narrow check, +// not a generalized alias registry. +msgpackAliasContentType = "application/x-msgpack" +``` + +Six lines on a one-line function, with forward references to its callers: + +```go +// trimUTF8BOM strips a leading UTF-8 BOM from data, returning data unchanged +// when no BOM is present. Used both to classify a reply's byte verdict +// (detectReplyCodec) and, for a JSON verdict, to decode it +// (Client.ParseResponse): encoding/json treats a BOM as an invalid leading +// byte rather than whitespace, so leaving it in would still fail to decode +// even after correct classification. +func trimUTF8BOM(data []byte) []byte { + return bytes.TrimPrefix(data, utf8BOM) +} +``` + +A caller list that rots on the next caller: + +```go +// normalizeContentType strips any ";"-delimited parameters (e.g. +// "; charset=binary"), trims surrounding whitespace, and lowercases the +// result — the shared normalization step both isMsgpackAliasContentType and +// replyCodecAndMismatch's header cross-check use (see codec_event.go). +func normalizeContentType(headerContentType string) string { +``` + +And the centerpiece: **22 prose lines on an unexported function** — over 4× the +budget of an exported crossroads — ending in a sixty-word sentence: + +```go +// replyCodecAndMismatch returns the byte-verdict codec (identical to +// detectReplyCodec) plus a bool that is true when the normalized +// Content-Type header disagrees with that byte verdict. The byte verdict +// always governs the actual decode (D-04: bytes-first); mismatch is only a +// signal for the caller's CodecEvent, never a second decode selector. +// +// headerSaysNonJSON is true when the header exactly names nonJSON's own +// content type, OR — the msgpack-alias carve-out D-04 requires — the +// header is either msgpack spelling AND nonJSON's content type IS msgpack +// (the alias never claims agreement for an unrelated non-JSON codec). +// +// An empty body never mismatches: it is not a valid non-JSON envelope +// regardless of what the header claims. +// +// A Client with no distinct non-JSON codec configured (nonJSON is the +// jsonrpc.JSONCodec{} default) never mismatches either: with nothing but +// JSON to disagree with, an ordinary JSON reply's own "Content-Type: +// application/json" header would otherwise satisfy headerSaysNonJSON's +// literal string comparison against nonJSON.ContentType() (also +// "application/json"), producing a false-positive mismatch on every +// zero-config JSON call — a bug this guard forecloses rather than lets a +// caller's mismatch check ever observe. +func replyCodecAndMismatch(data []byte, nonJSON jsonrpc.Codec, headerContentType string) (jsonrpc.Codec, bool) { +``` + +## The verdicts + +All nine symbols are unexported, so the question is existence, not size: + +| Symbol | Before | Verdict | Why | +|---|---|---|---| +| `nonJSONLeadingByteFloor` | 5 lines | one-liner survives | the WHY of the magic number ('{' is 0x7b; msgpack maps start at 0x80) — the code cannot carry it | +| `msgpackAliasContentType` | 5 lines | DELETE | the name and value say it; "D-04 requires" is a decoder ring; "not a table, per Pitfall 3" is review-defense narration | +| `utf8BOM` | 6 lines | one-liner survives | an ordering constraint: 0xEF ≥ the floor, so the BOM check must run before the leading-byte check | +| `trimUTF8BOM` | 6 lines | DELETE | the name is the documentation; the encoding/json quirk belongs to `replyBytesForDecode`, its only decode-side caller | +| `detectReplyCodec` | 7 lines | DELETE | narrated implementation ("Bounds-checked: it never indexes an empty slice") plus cross-repo provenance | +| `replyBytesForDecode` | 8 lines | one-liner survives | an external library quirk: encoding/json treats a BOM as an invalid leading byte, not whitespace | +| `normalizeContentType` | 4 lines | DELETE | the name says it; the caller list rots | +| `isMsgpackAliasContentType` | 5 lines | DELETE | the name says it; decoder rings and review-defense again | +| `replyCodecAndMismatch` | 22 lines | one-liner survives | the package's one real policy: bytes pick the decoder, the header is only a signal — the rest moves to the feature doc | + +Four one-liners survive out of nine comments; roughly 68 comment lines become 4. + +## The after + +```go +package jrpchttp + +import ( + "bytes" + "strings" + + "example.com/rpc/jsonrpc" +) + +const ( + // JSON envelopes start with '{' (0x7b); msgpack maps start at 0x80 or above. + nonJSONLeadingByteFloor byte = 0x80 + + msgpackAliasContentType = "application/x-msgpack" +) + +// A BOM's lead byte (0xEF) is above the floor, so strip it before the leading-byte check. +var utf8BOM = []byte{0xEF, 0xBB, 0xBF} + +func trimUTF8BOM(data []byte) []byte { + return bytes.TrimPrefix(data, utf8BOM) +} + +func detectReplyCodec(data []byte, nonJSON jsonrpc.Codec) jsonrpc.Codec { + data = trimUTF8BOM(data) + if len(data) == 0 { + return jsonrpc.JSONCodec{} + } + if data[0] >= nonJSONLeadingByteFloor { + return nonJSON + } + + return jsonrpc.JSONCodec{} +} + +// encoding/json treats a leading BOM as an invalid byte, not whitespace, so JSON strips it. +func replyBytesForDecode(codec jsonrpc.Codec, rawResponse []byte) []byte { + if _, isJSON := codec.(jsonrpc.JSONCodec); isJSON { + return trimUTF8BOM(rawResponse) + } + + return rawResponse +} + +func normalizeContentType(headerContentType string) string { + contentType := headerContentType + if idx := strings.IndexByte(contentType, ';'); idx >= 0 { + contentType = contentType[:idx] + } + + return strings.ToLower(strings.TrimSpace(contentType)) +} + +func isMsgpackAliasContentType(headerContentType string) bool { + switch normalizeContentType(headerContentType) { + case string(jsonrpc.ContentTypeMsgpack), msgpackAliasContentType: + return true + default: + return false + } +} + +// The body bytes always pick the decoder; a disagreeing Content-Type header is only reported, never trusted. +func replyCodecAndMismatch(data []byte, nonJSON jsonrpc.Codec, headerContentType string) (jsonrpc.Codec, bool) { + codec := detectReplyCodec(data, nonJSON) + if len(data) == 0 || nonJSON.ContentType() == jsonrpc.ContentTypeJSON { + return codec, false + } + + headerSaysNonJSON := normalizeContentType( + headerContentType, + ) == normalizeContentType( + string(nonJSON.ContentType()), + ) || + (isMsgpackAliasContentType(headerContentType) && nonJSON.ContentType() == jsonrpc.ContentTypeMsgpack) + bodySaysNonJSON := codec.ContentType() != jsonrpc.ContentTypeJSON + + return codec, headerSaysNonJSON != bodySaysNonJSON +} +``` + +The knowledge that was worth keeping and did not fit a one-liner — the +msgpack-alias carve-out, why a JSON-only client never reports a mismatch — moves +to the feature doc, where the exported caller's godoc points with its See-edge. +The exported API (`Client.ParseResponse`, the mismatch event) is where a reader +meets this package; that is where the tier budgets and the See-edge live. + +## The lesson + +The tier budget caps how big a comment can be; only the visibility default +decides whether it should exist at all. Before this case, "Helper: 0–1 lines" +read as permission, and a writer in fill-the-menu mode gave every private symbol +its tier maximum — nine comments, each locally justified, jointly unreadable. +The default for unexported symbols is **zero**: the name is the documentation, +and a name that needs a comment wants a rename or an extraction first. The +special case is **one line carrying a very high-value toolbox item** — an +ordering constraint, an external library quirk, the WHY of a magic number, the +package's one real policy. If a private symbol seems to need more than that one +line, the knowledge belongs to the exported symbol that uses it, the package +doc, or the feature doc. diff --git a/lang/go/passthrough/examples/storify-leaf-type.md b/lang/go/passthrough/examples/storify-leaf-type.md new file mode 100644 index 0000000..c5b843a --- /dev/null +++ b/lang/go/passthrough/examples/storify-leaf-type.md @@ -0,0 +1,299 @@ +# Storify + Leaf Type Case: From Fat Function to Lean Orchestration + +Demonstrates: R3, R1, R2 + +A real refactoring from a production codebase: a 48-line function mixing iteration, +validation, collection, and mutation becomes a 3-step story, with the juicy logic +extracted into a leaf type that unit-tests without mocks. This is the case law for +R3's core move — storifying discovers the leaf type — and for what the developer +actually shipped, including the imperfections and the next steps they left on the +table. + +## The setting + +`upsertIfaceAddrHost` must inspect a network interface, pick usable global-unicast +IPv4/IPv6 addresses, and reconcile the `Config`'s IP fields with what it found. + +## Before + +```go +// upsertIfaceAddrHost sets any IP from iface or returns error if provided IP not match to the interface +func (c *Config) upsertIfaceAddrHost(iface net.Interface) error { + addr, err := iface.Addrs() + if err != nil { + return fmt.Errorf("network addr: %w", err) + } + var ( + addrIP4Added bool + addrIP6Added bool + ) + for _, a := range addr { + ipnet, ok := a.(*net.IPNet) + if !ok || !ipnet.IP.IsGlobalUnicast() { + logger.Debug().Str("addr", a.String()).Msg("Not a global unicast address") + continue + } + if ipnet.IP.To4() == nil { // validate IP6 + if addrIP6Added { // already added. skip + continue + } + if !c.parseIP6(ipnet) { + return fmt.Errorf("IP6 %q address is not valid", c.IP6) + } + logger.Debug().Str("ip6", c.IP6).Msg("set IP6") + addrIP6Added = true + continue + } + if addrIP4Added { + continue // already added. skip + } + if !c.parseIP4(ipnet) { + return fmt.Errorf("IP4 %q address is not valid", c.IP4) + } + logger.Debug().Str("ip4", c.IP6).Msg("set IP4") + addrIP4Added = true + } + + if !addrIP4Added && !addrIP6Added { + return fmt.Errorf("IP address is not valid. IP4: %q, IP6: %q", c.IP4, c.IP6) + } + + return nil +} + +func (c *Config) parseIP4(ipnet *net.IPNet) bool { + if c.IP4 == ipnet.IP.To4().String() { + return true + } + if c.IP4 == anyIPv4 || c.IP4 == "" { + // use first ip found from interface + c.IP4 = ipnet.IP.To4().String() + return true + } + return false +} + +func (c *Config) parseIP6(ipnet *net.IPNet) bool { + if c.IP6 == ipnet.IP.To16().String() { + return true + } + if c.IP6 == anyIPv6 || c.IP6 == "" { + // use first ip found from interface + c.IP6 = ipnet.IP.To16().String() + return true + } + return false +} +``` + +## The smells, named + +1. **Fat function** — 48 lines, cyclomatic complexity 12, cognitive complexity 18: + collection, validation, and config mutation crammed into one body. +2. **Mixed abstraction levels (R3)** — type assertions and `To4()` bit-fiddling in + the same body as the business decision "is this configuration valid". +3. **Boolean flags tracking loop state** — `addrIP4Added`/`addrIP6Added` are set + inside the loop and read after it: the classic signature of a collection type + waiting to absorb the loop. +4. **Comments naming blocks** — `// validate IP6`, `// already added. skip`: each is + an extraction order (R3), a function name written as prose. +5. **Dishonest names** — `parseIP4`/`parseIP6` mutate `c.IP4`/`c.IP6`; "parse" + promises read-only. (Note the real-world bug it helped hide: the before code logs + `Str("ip4", c.IP6)` — a copy-paste slip that a smaller, honest function would + have made glaring.) +6. **No leaf types (R1)** — all logic lives on the big `Config`, so nothing is + testable without constructing a `net.Interface` scenario. + +The core problem: the juicy logic (which addresses count, how many of each family +to keep) is trapped inside an orchestration function. The fix is not to reshuffle +the fat function — it is to give that logic an owner. + +## Step 1 — separate orchestration from logic + +The function does three things: **collect** candidate IPs from the interface +(logic), **validate** the result (logic), **align** the config with what was found +(orchestration + logic). Collection and validation don't need `Config` at all — +that's the leaf type. + +## After — the storified orchestrator + +```go +// upsertIfaceAddrHost sets any IP from iface or returns error if provided IP not match to the interface +func (c *Config) upsertIfaceAddrHost(iface net.Interface) error { + addr, err := iface.Addrs() + if err != nil { + return fmt.Errorf("network addr: %w", err) + } + + ipConfig := collectIPConfigFrom(addr) + + if err = c.AlignIPs(ipConfig); err != nil { + return fmt.Errorf("align config IPs err: %w", err) + } + + return nil +} + +func collectIPConfigFrom(addresses []net.Addr) IPConfig { + var ipConfig IPConfig + for _, a := range addresses { + ipConfig.AddAddress(a) + } + return ipConfig +} +``` + +Read aloud: get addresses → collect them into an IPConfig → align our config with +what we collected. No nested ifs, no `continue`, no boolean flags — every line at +one altitude. + +## After — the extracted leaf type + +```go +// IPConfig collects the first usable global-unicast IPv4 and IPv6 address. +type IPConfig struct { + IP4 string + IP6 string +} + +func (c *IPConfig) AddAddress(a net.Addr) { + ipnet, ok := a.(*net.IPNet) + if !ok || !ipnet.IP.IsGlobalUnicast() { + logger.Debug().Str("addr", a.String()).Msg("Not a global unicast address") + return + } + + if ipnet.IP.To4() != nil { + if len(c.IP4) > 0 { + return // already added + } + c.IP4 = ipnet.IP.To4().String() + return + } + + if len(c.IP6) > 0 { + return // already added + } + c.IP6 = ipnet.IP.To16().String() +} + +func (c *IPConfig) Validate() error { + if len(c.IP4) == 0 && len(c.IP6) == 0 { + return errors.New("IP addresses are not found") + } + return nil +} +``` + +The boolean flags are gone: "already added" is now a question the collected state +answers (`len(c.IP4) > 0`), and the `continue`s became early `return`s — each address +is handled by one small decision tree instead of steering a shared loop. + +## After — the alignment side, honestly named + +```go +func (c *Config) AlignIPs(ipConfig IPConfig) error { + if err := ipConfig.Validate(); err != nil { + return fmt.Errorf("ip config is not valid: %w", err) + } + + if err := c.alignIPv4(ipConfig.IP4); err != nil { + return fmt.Errorf("align IPv4 err: %w", err) + } + if err := c.alignIPv6(ipConfig.IP6); err != nil { + return fmt.Errorf("align IPv6 err: %w", err) + } + return nil +} + +func (c *Config) alignIPv4(ip string) error { + if c.IPv4 == ip { + return nil // matches interface + } + if c.IPv4 == anyIPv4 || c.IPv4 == "" { + c.IPv4 = ip // use first ip found from interface + return nil + } + return fmt.Errorf("existing IPv4 [%s] mismatch configured [%s]", ip, c.IPv4) +} + +func (c *Config) alignIPv6(ip string) error { + if c.IPv6 == ip { + return nil + } + if c.IPv6 == anyIPv6 || c.IPv6 == "" { + c.IPv6 = ip + return nil + } + return fmt.Errorf("existing IPv6 [%s] mismatch configured [%s]", ip, c.IPv6) +} +``` + +`parseIP4` → `alignIPv4`: "align" admits the mutation that "parse" hid, and the +boolean returns became errors that say *what* mismatched. + +## The test payoff + +Before, exercising any of this meant mocking `net.Interface` — building a network +scenario to check "keep the first IPv4". After, the leaf is tested with constructed +addresses and no orchestration in sight: + +```go +func TestIPConfig_AddAddress_KeepsFirstIPv4(t *testing.T) { + var cfg netconfig.IPConfig + + cfg.AddAddress(ipv4Addr(t, "192.168.1.1")) + cfg.AddAddress(ipv4Addr(t, "192.168.1.2")) // second one is ignored + + assert.Equal(t, "192.168.1.1", cfg.IP4) +} + +func TestIPConfig_Validate_Error(t *testing.T) { + var cfg netconfig.IPConfig // nothing collected + + assert.Error(t, cfg.Validate()) +} + +func ipv4Addr(t *testing.T, ip string) net.Addr { + t.Helper() + return &net.IPNet{IP: net.ParseIP(ip), Mask: net.CIDRMask(24, 32)} +} +``` + +100% coverage on `IPConfig` costs a handful of literal-input cases. The +orchestrator (`upsertIfaceAddrHost` + `AlignIPs`) keeps an integration-style test +covering the seam — collection feeding alignment — per R7. + +## Metrics + +| | Before | After | +|---|---|---| +| Main function | 48 lines | 12 lines | +| Cyclomatic complexity | 12 | max 6 per function | +| Cognitive complexity | 18 | under threshold | +| Testable without mocking `net.Interface` | nothing | all of `IPConfig` | + +## Decision points + +1. **Storifying discovered the type.** The extraction order was: name the steps + (collect → validate → align), then notice that "collect" carries its own state — + the loop flags — and give that state an owner. R3 and R1 are one move here, not + two. +2. **Honest naming was part of the refactor, not polish.** Renaming + `parse*` → `align*` changed what readers expect the function to do; the + copy-paste logging bug in the before code is the kind of defect dishonest names + incubate. +3. **This is real shipped code, not an ideal.** The developer stopped here, and two + improvements remain on the table: + - **R2 is not fully paid.** `IPConfig` has exported fields and a separate + `Validate()` that `AlignIPs` must remember to call — validation the type does + not own. The stricter move: make collection the constructor, + `collectIPConfigFrom(addresses) (IPConfig, error)`, fold `Validate` into it, + and unexport the fields behind accessors. Then an invalid `IPConfig` cannot + reach `AlignIPs` at all (see `../rules/R2-self-validating-types.md`). + - **The IP strings are still primitives.** `IP4 string` re-checks emptiness at + each use; a `netip.Addr`-backed type would delete those checks. Score it before + wrapping (`../rules/R1-primitive-obsession.md`). + + Good refactoring knows when to stop — but a review citing this case should name + these as the next iterations, not treat the shipped state as the ceiling. diff --git a/lang/go/passthrough/examples/switch-to-polymorphism.md b/lang/go/passthrough/examples/switch-to-polymorphism.md new file mode 100644 index 0000000..6810d1b --- /dev/null +++ b/lang/go/passthrough/examples/switch-to-polymorphism.md @@ -0,0 +1,258 @@ +# Switch-to-Polymorphism Case: The Ever-Growing Export Switch + +Demonstrates: R11, R6 (edges into R3) + +Adapted from production code. `../examples/anti-if-dispatch.md` works R11's canonical +disease — a *raw* discriminator (a kind string) inspected at three sites. This case +is the type-switch sibling: a value that is **already polymorphic** (an interface, +dispatched once at construction) gets *un-dispatched* by a type switch that unpacks +its fields. The decision was made when the value was built; the switch asks it again. + +Two things make this case worth its own file. First, the *obvious* refactoring +(extract each case body into a helper) is a trap — it shrinks the function but +preserves the disease. Second, the rejection axis here is different from +`anti-if-dispatch.md`'s Move 3: there the skeptic kills an extraction on juiciness +(one site, trivial variance); here the counter is **dependency direction** — a +situation where the dispatch move is physically unavailable and the switch is the +honest answer. + +## Before — the hump that grows forever + +`Patch` is an interface with one method (`Type()`) and four concrete +implementations, one per export destination. The converter interrogates each +concrete type and shovels its fields into a flat wire request: + +```go +func fromUpdateArg(arg UpdateArg) updateExportRequest { + req := updateExportRequest{Name: arg.Name} + req.Type = arg.Patch.Type().String() + + switch p := arg.Patch.(type) { + case SplunkPatch: + if p.Token != nil { + s := string(*p.Token) + req.Token = &s + } + case S3Patch: + req.S3Bucket = p.Bucket + req.S3Key = p.Key + req.S3Region = p.Region + if p.Secret != nil { + s := string(*p.Secret) + req.S3Secret = &s + } + case KafkaPatch: + req.KafkaTopic = p.Topic + req.KafkaUseSASL = p.UseSASL + req.KafkaSASLUsername = p.Username + req.KafkaKeyField = p.KeyField + if p.Mechanism != nil { + m := p.Mechanism.String() + req.KafkaSASLMechanism = &m + } + if p.Password != nil { + s := string(*p.Password) + req.KafkaSASLPassword = &s + } + case SyslogPatch: + if p.Mode != nil { + m := p.Mode.String() + req.SyslogMode = &m + } + if p.RFC != nil { + r := p.RFC.String() + req.SyslogRFC = &r + } + if p.Facility != nil { + f := p.Facility.String() + req.SyslogFacility = &f + } + } + + req.setTLS(arg.TLS.Expand()) + return req +} +``` + +Three defects, and only one of them is size: + +- **The decision is asked twice (R11).** Whoever constructed `UpdateArg` already + chose `KafkaPatch` — the value is an interface *because* that decision was made. + The type switch re-asks it. A type switch over an interface the same package owns + is always a second ask; "decide once at the edge" was violated the moment the + cases appeared. +- **Ask-and-unpack.** The knowledge of *how a Splunk patch serializes* lives in the + consumer, not on `SplunkPatch`. Each variant's wire mapping has no owner. +- **Silent growth failure.** Adding a `PubSubPatch` and forgetting this switch + compiles clean and ships a request carrying only `Name` and `Type` — a runtime + no-op with no compiler, linter, or test to catch it unless someone remembers to + write one. (Mixed in, an R3 note: the business flow — identity → payload → TLS — + is buried under nil-deref-convert plumbing repeated nine times.) + +## The tempting wrong fix — extract each case body + +The reflexive move is Extract Function per case: + +```go +case KafkaPatch: + fillKafka(&req, p) +case SyslogPatch: + fillSyslog(&req, p) +``` + +The function gets shorter and each case reads better — and nothing real changed. +The switch still exists, still grows a case per destination forever, and a +forgotten case is still a silent no-op. This is the ceiling of function +extraction, not the fix. + +The falsifying question that breaks the frame: **why are we switching on type and +extracting data at all?** A type switch whose cases all do the same *kind* of work +(map my fields onto that struct) is behavior asking to live on the types. The +cased types already share an interface — the switch is a hand-rolled vtable. + +## After — the interface owns the behavior + +Add the fill behavior to the interface the concrete types already implement: + +```go +// Patch is implemented by each export-destination patch type. +// fillUpdate writes the destination-specific fields onto the wire request; +// shared fields (Name, Type, TLS) belong to the caller. +type Patch interface { + Type() ExportType + fillUpdate(req *updateExportRequest) +} +``` + +The orchestrator collapses to a three-beat story (R3): identity, payload, TLS. + +```go +func fromUpdateArg(arg UpdateArg) updateExportRequest { + req := updateExportRequest{ + Name: arg.Name, + Type: arg.Patch.Type().String(), + } + arg.Patch.fillUpdate(&req) + req.setTLS(arg.TLS.Expand()) + return req +} +``` + +Each destination owns its own mapping, in its own file (`splunk.go`, `s3.go`, +`kafka.go`, `syslog.go`): + +```go +func (p SplunkPatch) fillUpdate(req *updateExportRequest) { + req.Token = optSecret(p.Token) +} + +func (p S3Patch) fillUpdate(req *updateExportRequest) { + req.S3Bucket = p.Bucket + req.S3Key = p.Key + req.S3Region = p.Region + req.S3Secret = optSecret(p.Secret) +} + +func (p KafkaPatch) fillUpdate(req *updateExportRequest) { + req.KafkaTopic = p.Topic + req.KafkaUseSASL = p.UseSASL + req.KafkaSASLUsername = p.Username + req.KafkaKeyField = p.KeyField + req.KafkaSASLMechanism = optStringer(p.Mechanism) + req.KafkaSASLPassword = optSecret(p.Password) +} + +func (p SyslogPatch) fillUpdate(req *updateExportRequest) { + req.SyslogMode = optStringer(p.Mode) + req.SyslogRFC = optStringer(p.RFC) + req.SyslogFacility = optStringer(p.Facility) +} +``` + +Two tiny helpers kill the repeated nil-deref-convert dance that padded every case: + +```go +// optStringer converts an optional enum to its optional wire-string form. +func optStringer[T fmt.Stringer](v *T) *string { + if v == nil { + return nil + } + s := (*v).String() + return &s +} + +// optSecret unwraps an optional Secret for the wire request. +func optSecret(s *Secret) *string { + if s == nil { + return nil + } + v := string(*s) + return &v +} +``` + +An insert path is the same move: `fillInsert(req *insertExportRequest)` on the same +interface, and `fromInsertArg` becomes the same three-beat story. + +## The payoffs + +1. **Compile-time enforcement replaces a silent no-op.** A new `PubSubPatch` + without `fillUpdate` no longer builds. The growth failure mode moved from + "runtime request missing its payload" to "compiler error at the moment of + authorship" — the strongest possible catch point. (This is R11's exhaustiveness + payoff without the `exhaustive` linter: interface satisfaction *is* the + completeness proof.) +2. **Adding a destination is a new file, not an edit.** `fromUpdateArg` is frozen + at three beats; the switch version grows a hump per destination forever. +3. **The story survives (R3).** The orchestrator states *what* happens; each + destination's *how* lives one level down, on the type that owns the data. +4. **The interface is earned, and sealed.** Four production implementations — this + passes R6's earned-interface test (contrast: an interface whose only second + implementer is a test double). The unexported method is a bonus: no code outside + the package can implement `Patch`, so the implementation set is closed and the + compiler-enforcement guarantee in payoff 1 cannot be bypassed. + +## Fill, don't construct + +Note the method signature: `fillUpdate(req *updateExportRequest)`, not +`ToUpdateRequest() updateExportRequest`. The request carries fields the patch does +not own — `Name`, `Type`, TLS come from the surrounding argument. A constructor +method would either return a partial request the caller must merge (field-by-field +merging re-creates the original mess) or need the rest of the argument passed in +(the patch learns about its container). Filling keeps ownership honest: the caller +owns the shared fields, each patch owns its own. + +## The boundary counter — when the switch must stay + +This move has one precondition: **the package that owns the case types must also +legitimately own the output format.** Here both `Patch` and `updateExportRequest` +live in one package (a client whose API surface and wire format are the same +concern), so the method is natural. + +When the patch types live in a shared API package and the wire request is one +consumer's private detail, the move is unavailable and wrong: + +- Physically: an interface method cannot reference another package's unexported + type, and exporting the wire type just to enable the method inverts the + dependency. +- Architecturally: with multiple consumers (CLI, gateway, store), per-consumer + `fillRequest` methods accrete every consumer's serialization onto the domain + types — interface pollution from the opposite direction. + +In that situation the type switch at the consumer's boundary is idiomatic Go — the +honest tax of keeping the domain package transport-ignorant, and precisely the +boundary-adapter exemption in R11's falsifying questions. Then, and only then, the +"tempting wrong fix" above becomes the right ceiling: shrink the switch to pure +dispatch (one `fillKafka(&req, p)`-style converter per case, zero inline +field-fiddling) and stop. + +Note this rejection is orthogonal to the juiciness rejection in +`anti-if-dispatch.md` Move 3: there the extraction *could* be written but isn't +worth it; here it *cannot* be written where it belongs, at any price. + +The decision test, two questions in order: + +1. *Why am I switching on type and unpacking fields?* → the behavior wants to live + on the types (R11). +2. *Does the types' package own this output format?* → yes: interface method, the + switch dies. No: thin dispatch switch, and the boundary earns its keep. diff --git a/lang/go/passthrough/hooks/check-package-sizes.sh b/lang/go/passthrough/hooks/check-package-sizes.sh new file mode 100755 index 0000000..01028d1 --- /dev/null +++ b/lang/go/passthrough/hooks/check-package-sizes.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Portable package-size gate for the go-linter-driven-development plugin. +# +# Fires as a PostToolUse hook after Write / Edit / MultiEdit. Scans Go source +# roots under $CLAUDE_PROJECT_DIR and counts non-test, non-generated .go files +# per directory at one level deep. +# +# Thresholds (also documented in the refactoring and pre-commit-review skills): +# >=13 files = RED -> exit 2, stderr is fed back to Claude as a blocking +# error so the violation is acknowledged before more +# code lands in the oversized package. +# 8-12 files = YELLOW -> exit 0 with stdout advisory, no block. +# <=7 files = GREEN -> silent, exit 0. +# +# Guards: +# - no-op unless $CLAUDE_PROJECT_DIR/go.mod exists (not a Go project) +# - no-op if none of internal/, cmd/, pkg/ exist +# +# Uses only POSIX-portable tools: find, wc, tr, sort. No jq / python / task. + +set -u + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" + +[[ -f "$PROJECT_DIR/go.mod" ]] || exit 0 + +YELLOW_MIN=8 +RED_MIN=13 + +roots=() +for d in internal cmd pkg; do + [[ -d "$PROJECT_DIR/$d" ]] && roots+=("$PROJECT_DIR/$d") +done + +(( ${#roots[@]} == 0 )) && exit 0 + +red_lines=() +yellow_lines=() + +while IFS= read -r dir; do + [[ -z "$dir" ]] && continue + count=$(find "$dir" -maxdepth 1 -type f -name '*.go' \ + -not -name '*_test.go' \ + -not -name '*_gen.go' \ + -not -name '*.pb.go' \ + 2>/dev/null | wc -l | tr -d ' ') + + rel=${dir#$PROJECT_DIR/} + if (( count >= RED_MIN )); then + red_lines+=(" $rel: $count non-test .go files (RED zone, must decompose)") + elif (( count >= YELLOW_MIN )); then + yellow_lines+=(" $rel: $count non-test .go files (YELLOW zone, design review before next file)") + fi +done < <(find "${roots[@]}" -type d \ + -not -path '*/vendor/*' \ + -not -path '*/testdata/*' \ + 2>/dev/null | sort -u) + +if (( ${#red_lines[@]} > 0 )); then + { + echo "⛔ Package size gate — RED zone detected:" + printf '%s\n' "${red_lines[@]}" + echo "" + echo "These packages MUST be decomposed before more code lands." + echo "Apply the 3-step design review (see refactoring skill ):" + echo " 1. Does the package name reflect a real-world domain concept (not a role/container)?" + echo " 2. Are types well-scoped, or are there big structs hiding sub-types or primitive-obsession fields?" + echo " 3. Only after the type review, decide: sub-packages, new leaf types, or both." + } >&2 + exit 2 +fi + +if (( ${#yellow_lines[@]} > 0 )); then + echo "⚠️ Package size gate — YELLOW zone:" + printf '%s\n' "${yellow_lines[@]}" + echo "Design review recommended before the next file lands in these packages." +fi + +exit 0 diff --git a/lang/go/passthrough/hooks/hooks.json b/lang/go/passthrough/hooks/hooks.json new file mode 100644 index 0000000..deffac9 --- /dev/null +++ b/lang/go/passthrough/hooks/hooks.json @@ -0,0 +1,3 @@ +{ + "hooks": {} +} diff --git a/lang/go/passthrough/scripts/check-repo-brain_test.sh b/lang/go/passthrough/scripts/check-repo-brain_test.sh new file mode 100644 index 0000000..d7f5e12 --- /dev/null +++ b/lang/go/passthrough/scripts/check-repo-brain_test.sh @@ -0,0 +1,602 @@ +#!/usr/bin/env bash +# Fixture matrix for check-repo-brain.sh — the gate's own conformance test. +# +# Each case builds a throwaway repo under a temp dir, runs the gate against it, +# and asserts the exit code plus the presence/absence of specific report lines. +# The matrix doubles as the language-adapter contract: a binding for another +# language must pass every case here once its adapter is spliced in (the +# code<->docs cases then use that language's files instead of .go). +# +# Usage: bash scripts/check-repo-brain_test.sh [path/to/check-repo-brain.sh] +# (default: the sibling check-repo-brain.sh) +# GATE_REF=old.sh bash scripts/check-repo-brain_test.sh — differential +# mode: also runs the reference script on each fixture and fails on any +# difference in output, exit code, or files written by --fix +# Exit: 0 all cases pass · 1 any failure (details on stdout) +# +# Uses only POSIX-portable tools, like the script under test. + +set -u + +HERE=$(cd "$(dirname "$0")" && pwd) +GATE="${1:-$HERE/check-repo-brain.sh}" +[[ -f "$GATE" ]] || { echo "no such script: $GATE" >&2; exit 2; } +GATE=$(cd "$(dirname "$GATE")" && pwd)/$(basename "$GATE") + +pass=0 failed=0 +CASE="" +OUT="" ERR="" CODE=0 + +# ---------- harness ---------- +begin() { CASE="$1"; REPO=$(mktemp -d); } +finish() { rm -rf "$REPO"; } + +# GATE_REF=/path/to/other/check-repo-brain.sh — differential mode: every run is +# repeated with the reference script on an identical copy of the fixture, and +# any difference in stdout, stderr, or exit code fails the case. Use it to prove +# a refactor of the gate (or a new language adapter) changed no behavior. +GATE_REF="${GATE_REF:-}" +diffs=0 + +run_gate() { # [--fix] + local ref_repo="" ref_out="" ref_err="" ref_code=0 + if [[ -n "$GATE_REF" ]]; then + ref_repo=$(mktemp -d); cp -R "$REPO/." "$ref_repo/" + fi + OUT=$(cd "$REPO" && bash "$GATE" "$@" . 2>"$REPO/.stderr"); CODE=$? + ERR=$(cat "$REPO/.stderr"); rm -f "$REPO/.stderr" + if [[ -n "$GATE_REF" ]]; then + ref_out=$(cd "$ref_repo" && bash "$GATE_REF" "$@" . 2>"$ref_repo/.stderr"); ref_code=$? + ref_err=$(cat "$ref_repo/.stderr"); rm -f "$ref_repo/.stderr" + if [[ "$OUT" != "$ref_out" || "$ERR" != "$ref_err" || "$CODE" != "$ref_code" ]]; then + diffs=$((diffs + 1)) + echo "DIFF $CASE — output differs from reference ($GATE_REF)" + diff <(printf 'exit=%s\n%s\n%s\n' "$ref_code" "$ref_out" "$ref_err") \ + <(printf 'exit=%s\n%s\n%s\n' "$CODE" "$OUT" "$ERR") | sed 's/^/ /' + elif (( $# > 0 )) && [[ "$1" == "--fix" ]]; then + diff -r "$ref_repo" "$REPO" >/dev/null || { diffs=$((diffs + 1)); echo "DIFF $CASE — --fix wrote different files than the reference"; } + fi + rm -rf "$ref_repo" + fi +} + +ok() { pass=$((pass + 1)); echo "PASS $CASE"; } +bad() { failed=$((failed + 1)); echo "FAIL $CASE — $1"; echo " exit=$CODE"; printf '%s\n' "$OUT" "$ERR" | sed 's/^/ | /'; } + +expect_exit() { (( CODE == $1 )) || { bad "expected exit $1"; return 1; }; } +expect_has() { # — in stdout or stderr + printf '%s\n%s\n' "$OUT" "$ERR" | grep -qF -- "$1" || { bad "missing: $1"; return 1; } +} +expect_not() { + printf '%s\n%s\n' "$OUT" "$ERR" | grep -qF -- "$1" && { bad "unexpected: $1"; return 1; } + return 0 +} +expect_count() { # + local n; n=$(printf '%s\n%s\n' "$OUT" "$ERR" | grep -cF -- "$1") + (( n == $2 )) || { bad "expected $2 of '$1', got $n"; return 1; } +} + +# ---------- fixture builders ---------- +# A minimal conformant repo: one Go package declaring the symbols the docs cite, +# a docs/ bundle with root index + one feature doc, CLAUDE.md and AGENTS.md wired. +mk_conformant() { + mkdir -p "$REPO/docs" "$REPO/retry" + cat > "$REPO/go.mod" <<'EOF' +module example.com/fixture + +go 1.22 +EOF + cat > "$REPO/retry/policy.go" <<'EOF' +package retry + +import "time" + +// Policy bounds retries. See docs/retry-policy.md for the jitter decision. +type Policy struct { + maxAttempts int + baseDelay time.Duration +} + +func ParsePolicy(maxAttempts int, base time.Duration) (Policy, error) { + return Policy{maxAttempts: maxAttempts, baseDelay: base}, nil +} + +func (p Policy) Do(op func() error) error { return op() } + +const ( + DefaultAttempts = 3 + defaultBase = time.Second +) + +var ( + ErrExhausted, ErrCancelled = errString("exhausted"), errString("cancelled") +) + +type errString string + +func (e errString) Error() string { return string(e) } +EOF + cat > "$REPO/docs/index.md" <<'EOF' +--- +okf_version: "0.2" +--- +# Repo map + +**Resilience** +- [retry-policy.md](retry-policy.md) — why retries use capped full jitter; `Policy` API +EOF + cat > "$REPO/docs/retry-policy.md" <<'EOF' +--- +type: feature +description: why retries use capped full jitter; `Policy` API +--- +# Retry policy + +Entry point: `Policy.Do`. Construction: `ParsePolicy` caps the attempt count at +`DefaultAttempts`. Sentinels: `ErrExhausted`, `ErrCancelled`. Package: `retry`. +EOF + cat > "$REPO/CLAUDE.md" <<'EOF' +# Fixture + +@AGENTS.md +@docs/index.md +EOF + cat > "$REPO/AGENTS.md" <<'EOF' +Start at docs/index.md. Conventions: docs/conventions.md. +EOF +} + +append_index() { printf '%s\n' "$1" >> "$REPO/docs/index.md"; } + +mk_doc() { # [body...] + local name="$1" desc="$2"; shift 2 + { + printf -- '---\ntype: feature\ndescription: %s\n---\n# %s\n\n' "$desc" "$name" + printf '%s\n' "$@" + } > "$REPO/docs/$name.md" +} + +# ========================= cases ========================= + +# --- baseline --- +begin "clean conformant bundle exits 0" +mk_conformant; run_gate +expect_exit 0 && expect_has "check-repo-brain: clean" && expect_not "[Q" && ok +finish + +begin "no doc root is an advisory no-op (exit 0)" +mkdir -p "$REPO/src"; : > "$REPO/go.mod"; run_gate +expect_exit 0 && expect_has "nothing to check yet" && ok +finish + +begin "bad repo root is a usage error (exit 2)" +begin_dir_missing="$REPO/does-not-exist" +OUT=$(bash "$GATE" "$begin_dir_missing" 2>&1); CODE=$?; ERR="" +expect_exit 2 && expect_has "not a directory" && ok +finish + +# --- Q1 reachability --- +begin "Q1 orphan doc is reported" +mk_conformant; mk_doc orphan "an unlinked doc" "Nothing links here."; run_gate +expect_exit 1 && expect_has "[Q1] docs/orphan.md — orphan" && ok +finish + +begin "Q1 map of maps: doc reachable through a bare sub-index" +mk_conformant +mkdir -p "$REPO/docs/ops" +cat > "$REPO/docs/ops/index.md" <<'EOF' +# Ops + +- [runbook.md](runbook.md) — how to page on-call for retries +EOF +mkdir -p "$REPO/docs/ops"; cat > "$REPO/docs/ops/runbook.md" <<'EOF' +--- +type: guide +description: how to page on-call for retries +--- +# Runbook + +Use `Policy.Do`. +EOF +append_index "- [ops/index.md](ops/index.md) — operations sub-map" +run_gate +expect_exit 0 && expect_not "[Q1]" && expect_not "[Q7] docs/ops/index.md" && ok +finish + +begin "Q1 missing root index is reported" +mk_conformant; rm "$REPO/docs/index.md"; run_gate +expect_exit 1 && expect_has "[Q1] docs — no index.md" && ok +finish + +# --- Q2 links and edges --- +begin "Q2 dangling doc→doc link is reported" +mk_conformant +mk_doc auth "auth flow" "Retries follow [retry-policy.md](retry-policy.md) and [missing.md](missing.md)." +append_index "- [auth.md](auth.md) — auth flow" +run_gate +expect_exit 1 && expect_has "[Q2] docs/auth.md — link target does not exist: missing.md" && ok +finish + +begin "Q2 code→docs edge pointing at a missing doc is reported" +mk_conformant +cat >> "$REPO/retry/policy.go" <<'EOF' + +// Backoff computes the delay. See docs/backoff.md. +func Backoff(n int) int { return n } +EOF +run_gate +expect_exit 1 && expect_has "[Q2] ./retry/policy.go:" && expect_has "missing docs/backoff.md" && ok +finish + +begin "Q2 code→docs edge resolving inside a go.mod sub-project passes" +mk_conformant +mkdir -p "$REPO/svc/docs" "$REPO/svc/pkg" +printf 'module example.com/svc\n\ngo 1.22\n' > "$REPO/svc/go.mod" +cat > "$REPO/svc/pkg/x.go" <<'EOF' +package pkg + +// Thing does things. See docs/thing.md. +type Thing struct{} +EOF +printf -- '---\nokf_version: "0.2"\n---\n# svc map\n\n- [thing.md](thing.md) — the thing\n' > "$REPO/svc/docs/index.md" +printf -- '---\ntype: feature\ndescription: the thing\n---\n# Thing\n\n`Thing` is declared in `pkg`.\n' > "$REPO/svc/docs/thing.md" +append_index "- [svc/docs/index.md](../svc/docs/index.md) — svc sub-project map" +run_gate +expect_exit 0 && expect_not "[Q2]" && expect_not "[Q3] svc" && ok +finish + +begin "Q2 file:line citation is banned" +mk_conformant +mk_doc where "where things live" "The parser lives in retry/policy.go:42 and uses \`Policy\`." +append_index "- [where.md](where.md) — where things live" +run_gate +expect_exit 1 && expect_has "[Q2] docs/where.md:" && expect_has "cites a file path or line number" && ok +finish + +begin "Q2 file-path ban exempts URLs, fenced blocks, and glob patterns" +mk_conformant +mk_doc paths "path exemptions" \ + "Docs: https://pkg.go.dev/example.com/fixture/retry#Policy.Do and [src](https://github.com/x/y/blob/main/retry/policy.go)." \ + "" \ + '```go' \ + "// retry/policy.go:12 — inside a fence" \ + '```' \ + "" \ + "Generated files match \`*_gen.go\` and \`*.pb.go\`." +append_index "- [paths.md](paths.md) — path exemptions" +run_gate +expect_exit 0 && expect_not "cites a file path" && ok +finish + +begin "Q2 unresolved backticked symbol is reported" +mk_conformant +mk_doc ghost "a ghost symbol" "Uses \`SnapshotRunner\` and \`Policy\`." +append_index "- [ghost.md](ghost.md) — a ghost symbol" +run_gate +expect_exit 1 && expect_has "backticked \`SnapshotRunner\` does not resolve" && expect_not "\`Policy\` does not resolve" && ok +finish + +begin "Q2 grouped var/const declarations and methods resolve" +mk_conformant +mk_doc decls "declaration shapes" "Constants \`DefaultAttempts\`; sentinels \`ErrExhausted\` and \`ErrCancelled\`; method \`Policy.Do\`; bare method \`Do\`." +append_index "- [decls.md](decls.md) — declaration shapes" +run_gate +expect_exit 0 && expect_not "does not resolve" && ok +finish + +begin "Q2 package-qualified tokens: repo package resolves, external package exempt" +mk_conformant +mk_doc qual "qualified tokens" "Call \`retry.ParsePolicy\`, then \`time.Duration\` and \`http.Client\` are stdlib; \`retry.Nope\` is not declared." +append_index "- [qual.md](qual.md) — qualified tokens" +run_gate +expect_exit 1 && expect_has "\`retry.Nope\` does not resolve" && expect_not "time.Duration" && expect_not "http.Client" && expect_not "retry.ParsePolicy" && ok +finish + +begin "Q2 whole-word fallback resolves non-declared tokens found in repo files" +mk_conformant +printf 'alerts:\n - name: RetryBudgetExhausted\n' > "$REPO/alerts.yaml" +mk_doc alerts "alert names" "Fires \`RetryBudgetExhausted\` when \`Policy\` gives up." +append_index "- [alerts.md](alerts.md) — alert names" +run_gate +expect_exit 0 && expect_not "does not resolve" && ok +finish + +begin "Q2 ALL-CAPS initialisms, placeholders, and dotted config names are skipped" +mk_conformant +mk_doc caps "skipped token shapes" "Set \`TTL\` and \`\`; the linter config is \`.golangci.yaml\`; \`HTTP\` is fine." +append_index "- [caps.md](caps.md) — skipped token shapes" +run_gate +expect_exit 0 && expect_not "does not resolve" && ok +finish + +begin "Q2 ⚠️ stale and *(planned)* lines are exempt from symbol resolution" +mk_conformant +mk_doc roadmap "roadmap" "Coming: \`Circuit\` *(planned)*." "Old: ⚠️ \`LegacyPolicy\` was removed." +append_index "- [roadmap.md](roadmap.md) — roadmap" +run_gate +expect_exit 0 && expect_not "does not resolve" && ok +finish + +# --- Q3 root wiring --- +begin "Q3 unwired root is reported" +mk_conformant; rm "$REPO/CLAUDE.md" "$REPO/AGENTS.md"; run_gate +expect_exit 1 && expect_has "[Q3] . — neither CLAUDE.md nor AGENTS.md references docs/index.md" && ok +finish + +begin "Q3 CLAUDE.md wired but AGENTS.md missing is an advisory, not a violation" +mk_conformant; rm "$REPO/AGENTS.md"; run_gate +expect_exit 0 && expect_has "advisory: [Q3] AGENTS.md lacks" && ok +finish + +begin "Q3 a bare 'index.md' mention does not count as wiring" +mk_conformant +printf '# Fixture\n\nsee index.md\n' > "$REPO/CLAUDE.md"; rm "$REPO/AGENTS.md"; run_gate +expect_exit 1 && expect_has "[Q3]" && ok +finish + +begin "Q3 a sub-project root may be wired through the repo-root index instead" +mk_conformant +mkdir -p "$REPO/svc/docs" +printf 'module example.com/svc\n\ngo 1.22\n' > "$REPO/svc/go.mod" +printf -- '---\nokf_version: "0.2"\n---\n# svc map\n' > "$REPO/svc/docs/index.md" +append_index "- [svc/docs/index.md](../svc/docs/index.md) — svc sub-project map" +run_gate +expect_exit 0 && expect_not "[Q3] svc" && ok +finish + +# --- Q7 bundle contract --- +begin "Q7 content doc without frontmatter is reported" +mk_conformant +printf '# Bare\n\nNo frontmatter.\n' > "$REPO/docs/bare.md" +append_index "- [bare.md](bare.md) — bare" +run_gate +expect_exit 1 && expect_has "[Q7] docs/bare.md — no frontmatter block" && ok +finish + +begin "Q7 unterminated frontmatter is reported" +mk_conformant +printf -- '---\ntype: feature\ndescription: never closed\n# Oops\n' > "$REPO/docs/open.md" +append_index "- [open.md](open.md) — never closed" +run_gate +expect_exit 1 && expect_has "[Q7] docs/open.md — unterminated frontmatter" && ok +finish + +begin "Q7 missing required key is reported" +mk_conformant +printf -- '---\ntype: feature\n---\n# No description\n' > "$REPO/docs/nodesc.md" +append_index "- [nodesc.md](nodesc.md) — no description" +run_gate +expect_exit 1 && expect_has "[Q7] docs/nodesc.md — frontmatter missing 'description:'" && ok +finish + +begin "Q7 optional keys (generated, tags, status, stale_after) are accepted" +mk_conformant +cat > "$REPO/docs/opt.md" <<'EOF' +--- +type: guide +description: optional keys present +title: Optional +generated: 2026-08-20T00:00:00Z +tags: [a, b] +status: stable +stale_after: 2099-01-01 +--- +# Optional +EOF +append_index "- [opt.md](opt.md) — optional keys present" +run_gate +expect_exit 0 && expect_not "[Q7]" && ok +finish + +begin "Q7 related: key is reported" +mk_conformant +printf -- '---\ntype: feature\ndescription: has related\nrelated: [retry-policy.md]\n---\n# Rel\n' > "$REPO/docs/rel.md" +append_index "- [rel.md](rel.md) — has related" +run_gate +expect_exit 1 && expect_has "[Q7] docs/rel.md — 'related:' frontmatter key" && ok +finish + +begin "Q7 frontmatter on a sub-index is reported" +mk_conformant +mkdir -p "$REPO/docs/ops" +printf -- '---\ntype: index\n---\n# Ops\n' > "$REPO/docs/ops/index.md" +append_index "- [ops/index.md](ops/index.md) — ops" +run_gate +expect_exit 1 && expect_has "[Q7] docs/ops/index.md — frontmatter on a sub-index" && ok +finish + +begin "Q7 root index missing okf_version is reported" +mk_conformant +printf '# Repo map\n\n- [retry-policy.md](retry-policy.md) — why retries use capped full jitter; `Policy` API\n' > "$REPO/docs/index.md" +run_gate +expect_exit 1 && expect_has "[Q7] docs/index.md — root index missing its okf_version" && ok +finish + +begin "Q7 root index with an extra key is reported" +mk_conformant +sed -i 's/^okf_version: "0.2"$/okf_version: "0.2"\ntimestamp: 2026-01-01/' "$REPO/docs/index.md" +run_gate +expect_exit 1 && expect_has "root index frontmatter carries 'timestamp:'" && ok +finish + +begin "Q7 log.md is reported exactly once (not also as an orphan)" +mk_conformant +printf '# log\n' > "$REPO/docs/log.md" +run_gate +expect_exit 1 && expect_count "docs/log.md" 1 && expect_has "[Q7] docs/log.md — log.md is reserved" && ok +finish + +begin "Q7 drifted index line fails; --fix rewrites it; re-check is clean" +mk_conformant +sed -i 's/ — why retries use capped full jitter; `Policy` API$/ — retries, old wording/' "$REPO/docs/index.md" +run_gate +if expect_exit 1 && expect_has "[Q7] docs/index.md:" && expect_has "drifted from retry-policy.md's description"; then + run_gate --fix + if expect_exit 0 && expect_has "fixed: docs/index.md:" && expect_has "rewrote 1 drifted index line"; then + if grep -qF -- '— why retries use capped full jitter; `Policy` API' "$REPO/docs/index.md"; then + run_gate + expect_exit 0 && expect_not "[Q7]" && ok + else + bad "--fix did not write the description back" + fi + fi +fi +finish + +begin "Q7 --fix touches only the drifted line" +mk_conformant +mk_doc second "second doc" "Body." +append_index "- [second.md](second.md) — stale text" +append_index "- [retry-policy.md](retry-policy.md) — why retries use capped full jitter; \`Policy\` API" +before=$(grep -c '' "$REPO/docs/index.md") +run_gate --fix +after=$(grep -c '' "$REPO/docs/index.md") +if (( before == after )) && grep -qF -- '— second doc' "$REPO/docs/index.md" \ + && (( $(grep -cF -- 'why retries use capped full jitter' "$REPO/docs/index.md") == 2 )); then + expect_exit 0 && ok +else + bad "--fix changed line count or other lines" +fi +finish + +begin "Q7 ⚠️-flagged index lines are exempt from the drift check" +mk_conformant +append_index "- ⚠️ [retry-policy.md](retry-policy.md) — recorded stale, text differs on purpose" +run_gate +expect_exit 0 && expect_not "drifted" && ok +finish + +begin "Q7 index line to a bare sub-index (no description) is skipped by the drift check" +mk_conformant +mkdir -p "$REPO/docs/ops"; printf '# Ops\n' > "$REPO/docs/ops/index.md" +append_index "- [ops/index.md](ops/index.md) — authored sub-map line" +run_gate +expect_exit 0 && expect_not "drifted" && ok +finish + +# --- ownership: a qualified token resolves only against its owner --- +begin "Q2 qualified token does not ride on a same-named member of another package" +mk_conformant +mkdir -p "$REPO/other" +cat > "$REPO/other/other.go" <<'EOF' +package other + +// Nope exists here, not in retry. +func Nope() {} +EOF +mk_doc owners "ownership pairs" "Real: \`other.Nope\` and \`retry.ParsePolicy\`. Fake: \`retry.Nope\` — Nope lives in other." +append_index "- [owners.md](owners.md) — ownership pairs" +run_gate +expect_exit 1 && expect_has "\`retry.Nope\` does not resolve" \ + && expect_not "\`other.Nope\` does not resolve" && expect_not "retry.ParsePolicy" && ok +finish + +begin "Q2 Type.Method does not ride on a same-named method of another receiver" +mk_conformant +mk_doc recv "receiver pairs" "Real: \`Policy.Do\`. Fake: \`Policy.Error\` — Error is errString's method." +append_index "- [recv.md](recv.md) — receiver pairs" +run_gate +expect_exit 1 && expect_has "\`Policy.Error\` does not resolve" && expect_not "\`Policy.Do\` does not resolve" && ok +finish + +# --- lifecycle advisory --- +begin "Q7 lifecycle-stale target without a flagged index line is an advisory, not a violation" +mk_conformant +cat > "$REPO/docs/legacy.md" <<'EOF' +--- +type: feature +description: the legacy exporter path +stale_after: 2020-01-01 +--- +# Legacy + +Uses `Policy`. +EOF +append_index "- [legacy.md](legacy.md) — the legacy exporter path" +run_gate +expect_exit 0 && expect_has "stale by lifecycle (stale_after: 2020-01-01)" && ok +finish + +begin "Q7 lifecycle advisory is silent when the index line already carries the flag" +mk_conformant +cat > "$REPO/docs/gone.md" <<'EOF' +--- +type: feature +description: the removed batching mode +status: deprecated +--- +# Gone + +Uses `Policy`. +EOF +append_index "- ⚠️ [gone.md](gone.md) — the removed batching mode" +run_gate +expect_exit 0 && expect_not "stale by lifecycle" && ok +finish + +# --- value checks --- +begin "Q7 a type outside feature/architecture/guide is reported" +mk_conformant +printf -- '---\ntype: nonsense\ndescription: a mistyped doc\n---\n# X\n' > "$REPO/docs/mistyped.md" +append_index "- [mistyped.md](mistyped.md) — a mistyped doc" +run_gate +expect_exit 1 && expect_has "'type:' must be feature, architecture, or guide" && ok +finish + +begin "Q7 empty description is reported" +mk_conformant +printf -- '---\ntype: feature\ndescription:\n---\n# X\n' > "$REPO/docs/blank.md" +append_index "- [blank.md](blank.md) — anything" +run_gate +expect_exit 1 && expect_has "empty 'description:'" && ok +finish + +begin "Q7 okf_version must be exactly \"0.2\", once" +mk_conformant +printf -- '---\nokf_version: 9.9\n---\n# Repo map\n\n- [retry-policy.md](retry-policy.md) — why retries use capped full jitter; \`Policy\` API\n' > "$REPO/docs/index.md" +run_gate +expect_exit 1 && expect_has 'okf_version must be exactly "0.2"' && ok +finish + +begin "Q7 duplicate okf_version keys are reported" +mk_conformant +printf -- '---\nokf_version: "0.2"\nokf_version: "0.2"\n---\n# Repo map\n\n- [retry-policy.md](retry-policy.md) — why retries use capped full jitter; \`Policy\` API\n' > "$REPO/docs/index.md" +run_gate +expect_exit 1 && expect_has "duplicate okf_version" && ok +finish + +# --- wiring is matched as a fixed string, and broken parents are broken links --- +begin "Q3 a dotted doc root never matches a look-alike path (xai vs .ai)" +mkdir -p "$REPO/.ai" +printf -- '---\nokf_version: "0.2"\n---\n# Map\n' > "$REPO/.ai/index.md" +printf 'Start at xai/index.md.\n' > "$REPO/CLAUDE.md" +run_gate +expect_exit 1 && expect_has "[Q3]" && ok +finish + +begin "Q2 link into a missing directory is a broken link, not a skip" +mk_conformant +mk_doc holes "a doc with a broken link" "See [x](missing-dir/x.md) for nothing." +append_index "- [holes.md](holes.md) — a doc with a broken link" +run_gate +expect_exit 1 && expect_has "link target does not exist: missing-dir/x.md" && ok +finish + +# --- language scope --- +begin "repo with a doc root but no code files runs the structure checks only" +mk_conformant; rm -rf "$REPO/retry" +mk_doc ghost "a ghost symbol" "Uses \`SnapshotRunner\`." +append_index "- [ghost.md](ghost.md) — a ghost symbol" +run_gate +expect_exit 0 && expect_not "does not resolve" && expect_has "check-repo-brain: clean" && ok +finish + +# ========================= summary ========================= +echo +if [[ -n "$GATE_REF" ]]; then + echo "check-repo-brain_test: $pass passed, $failed failed, $diffs differ from reference" + (( failed == 0 && diffs == 0 )) +else + echo "check-repo-brain_test: $pass passed, $failed failed" + (( failed == 0 )) +fi diff --git a/lang/go/passthrough/skills/documentation/reference.md b/lang/go/passthrough/skills/documentation/reference.md new file mode 100644 index 0000000..52236c5 --- /dev/null +++ b/lang/go/passthrough/skills/documentation/reference.md @@ -0,0 +1,959 @@ +# Documentation Reference + +Menus, templates, checklists, and worked examples for the @documentation skill. +Normative policy — the documentation ladder, network invariants, comment policy, edge +policy, index policy, root wiring, doc-root discovery — lives ONCE in +`../../rules/R9-repo-brain.md`; nothing here overrides it. + +## Contents + +- [Comment Value Toolbox](#comment-value-toolbox) — the growable catalog of ways a comment delivers value +- [Godoc Menus](#godoc-menus) — package, type, function menus; testable examples +- [Frontmatter Templates (OKF Bundle)](#frontmatter-templates-okf-bundle) — content doc, root index +- [Feature Doc Template](#feature-doc-template) — frontmatter, symbol-cited key players +- [The Index and Root Wiring](#the-index-and-root-wiring) — index.md, map of maps, CLAUDE.md import, AGENTS.md routing block +- [Conventions Doc (Self-Hosting)](#conventions-doc-self-hosting) — the `conventions.md` template bootstrap installs +- [Doc Roots and Monorepos](#doc-roots-and-monorepos) +- [Bootstrap Classification](#bootstrap-classification) — feature / architecture / guide / stale; frontmatter migration; rung-2 gap criterion; upward-edge anchor heuristic +- [Checklists](#checklists) — feature docs, code comments, quality gates +- [Guidelines](#guidelines) — bug-fix documentation, managing documentation size +- [Examples](#examples) — good vs bad worked examples + +> The former "Documentation Layers" section (layer tables, decision tree, overlap +> rules, cross-reference conventions) now lives normatively in +> `../../rules/R9-repo-brain.md` — see its Design guidance (rung table, placement +> rule, edge conventions). + +--- + +## Comment Value Toolbox + +The catalog behind R9's toolbox-value test. The normative list of kinds lives in +R9's comment policy; **this catalog is the growable half** — when a new kind of +valuable comment proves itself, add it here with a worked example. Two consumers: +the writer picks from this toolbox when composing a comment (step 3), and the +`comment-critic` cites the specific toolbox item a rewrite should deliver ("swap +narrated implementation for the boundary contract this parsing constructor +needs"). + +Each entry: when it earns its place, and what it looks like. + +### WHY, not WHAT + +Rationale, incident, or constraint the code cannot carry. The default value — when +in doubt, this is the one to reach for. + +```go +// ❌ ParseAddress parses an address string. (restates the name) +// ✅ ParseAddress rejects ports below 1024: the collector runs unprivileged. +``` + +### Wider context + +Where this sits architecturally; what depends on it. Earns its place on crossroads +symbols — the reader landing here from a grep needs to know what they're standing +on. + +```go +// ❌ Queue is a queue used by the system. (generic filler) +// ✅ Every exporter ships through this queue — backpressure starts here. +``` + +### Important use cases / flows + +When to reach for this symbol instead of its neighbors. Earns its place when the +choice is not obvious from the names alone. + +```go +// ✅ Use Snapshot for reads during a rebalance; direct reads block until it ends. +``` + +### Boundary contract + +Dos/don'ts, valid inputs, error behavior. Earns its place on parsing constructors +and any API whose caller needs the contract before the first call. + +```go +// ✅ Accepts "3x100ms"-style specs. Zero attempts and negative delays are rejected. +``` + +### Guarantees + +Thread safety, nil handling, invariants — promises the signature cannot express. + +```go +// ✅ Safe for concurrent use; callbacks run outside the lock. +``` + +### Network edge + +The `See docs/.md` line wiring a critical point into the repo brain. +Near-constant: keep it whenever a feature doc exists (R9 edge policy). Free under +the budget. + +```go +// ✅ See docs/retry-policy.md for the incident and the cap math. +``` + +### What never earns a line: provenance and decoder-ring references + +The anti-toolbox. PR numbers, review items, "the previous behavior" narration, +"matching what did" — change history, not behavior. The 5-year +reader test (R9's floor): a reader five years out cares how the product behaves +NOW, never which PR or review round produced it. Rewrite history as +present-tense rationale; keep an incident/ticket reference only when it IS the +rationale for a constraint. + +```go +// ❌ ...must be rejected, matching the legacy backend ("more than one TLS +// option passed"), REST v2 (which forwarded the conflict), and the CLI's +// own client-side mutual exclusion. Silently resolving by precedence — +// the previous behavior — picks a TLS mode the caller didn't ask for +// (PR #481 review item 5a). + +// ✅ Passing more than one TLS option is rejected (422): silently picking +// one of them could apply a TLS mode the caller did not ask for. +``` + +**Decoder-ring references** are the same failure in a different costume: +plan/decision/test-plan IDs ("T-04-02", "D-07"), requirement tags +("REQ-SVC-01"), spec section refs ("spec §4"). They fail even when the token +resolves inside a repo doc — a reader without the decoder ring gets nothing. +The fact goes in the comment as plain prose; the doc gets ONE trailing +See-edge; the ID stays in the doc. + +```go +// ❌ userResponse is the flat REST response shape for a user account +// (spec §4). It deliberately has NO field for the password hash — the +// response omission is structural (T-04-02), not a zero-value +// coincidence ... additive to the {uid} addressing scheme (D-06/D-07). + +// ✅ userResponse is the flat REST response shape for a user account. +// It has no field for the password hash or the API token, so a response +// can never leak them. +// See docs/accounts-api.md. +``` + +### What never earns a line: restated repo idiom + +A comment justifying a convention the repo already applies everywhere: a +pointer field meaning "omitted vs explicit zero", the standard error-wrapping +style, the usual table-driven test shape. Each use site inherits the +convention silently; the explanation lives once at rung 2 (coding standards). +If every site carried it, the real content would drown — and the one genuine +WHY nearby would read as more boilerplate. + +```go +// ❌ // Enabled is a pointer so an omitted field is distinguishable from an +// // explicit false. +// Enabled *bool `json:"enabled,omitempty"` + +// ✅ Enabled *bool `json:"enabled,omitempty"` +``` + +Test before crediting a WHY: grep the repo for the same pattern. If it appears +across packages with no comment, this comment restates an idiom — cut it. + +### What never earns a line: review-defense narration + +The writer arguing with an imagined reviewer: defending a design choice nobody +at the code line asked about, or promising the code is safe instead of letting +the code show it. These lines are written for the PR review round; five years +later they read as noise. A design choice that genuinely needs defending is +defended in the feature doc. + +```go +// ❌ Named as a single constant — not a table: a third wire format would +// earn its own narrow check, not a generalized alias registry. +// ❌ Bounds-checked: it never indexes an empty slice. + +// ✅ (nothing — the code shows the bounds check; the design defense moves +// to the feature doc if it is worth keeping at all) +``` + +### The visibility default: private symbols get no comment + +Not an anti-entry — the existence rule the whole toolbox sits under (normative +in R9). The toolbox and tier menus price comments on **exported** API. An +unexported symbol defaults to **zero** comment lines; its name is the +documentation. The special case is one line carrying a very high-value toolbox +item: an ordering constraint, an external library quirk, the WHY of a magic +number, the package's one real policy. Case file with nine worked verdicts: +`../../examples/private-comment-noise.md`. + +```go +// ❌ trimUTF8BOM strips a leading UTF-8 BOM from data, returning data +// unchanged when no BOM is present. Used both to classify a reply's +// byte verdict (detectReplyCodec) and, for a JSON verdict, to decode +// it (Client.ParseResponse)... +func trimUTF8BOM(data []byte) []byte { return bytes.TrimPrefix(data, utf8BOM) } + +// ✅ (nothing — the name says it) + +// ✅ special case, one high-value line: +// A BOM's lead byte (0xEF) is above the floor, so strip it before the leading-byte check. +var utf8BOM = []byte{0xEF, 0xBB, 0xBF} +``` + +--- + +## Godoc Menus + +**These are MENUS, not forms** (normative: R9's tiered comment-budget policy — +**1–5 prose lines** scaled to the symbol's role; blank `//` lines, the See-edge, and +short inline examples of 2–4 lines are free). The menus price **exported** API +only — unexported symbols default to no comment at all (R9's visibility +default; special case: one very-high-value line). The WHY is the default +content; the tier caps how much of a menu any one symbol can order: + +- **Helper** (small method, plain constructor, obvious accessor) → 0–1 line, or + nothing; a tiny example only if it clarifies. +- **Contract** (parsing constructor like `ParsePort`, self-validating type, ordinary + exported API) → 2–3 lines; a dos/don'ts example is free and often earns its place. +- **Crossroads** (entry point, orchestrator, state machine, feature front door) → + up to 5 lines: WHY, architectural context, use cases. + +Overflow never stays inline — it moves to the feature doc; the +`See docs/.md` edge (kept whenever the doc exists) carries the pointer. A +crossroads that deserves more than 5 lines inline gets an expand recommendation in +the FEATURE report instead of extra lines — a human decides (R9's escape hatch). + +What fills the chosen menu lines comes from the [Comment Value Toolbox](#comment-value-toolbox) +above — every prose line must deliver one of its values, in plain English (R9's +three-test standard). + +### Package Godoc Menu + +Pick only the lines this package needs: + +```go +// Package [name] provides [high-level purpose]. <- always (one line) +// +// [1-2 sentences: what problem this solves] <- usually +// +// Main data flow: <- only if non-obvious +// Input -> Validation -> Processing -> Output +// +// Core types: <- multi-type packages only +// - Type1: [key responsibility] +// +// Design decisions: <- only where rationale exists +// - [Key decision and why] +// +// See docs/[feature].md for architecture and usage. <- whenever the doc exists +package name +``` + +**The `doc.go` hatch (R9):** when a package genuinely earns more than the standard +budget — flow sketch, core-types list, and design decisions all pulling their +weight — move the package godoc to a dedicated `doc.go`, bounded at ~20–30 lines. +A package comment inline in a regular file stays within the standard tier budget. + +### Type Godoc Menu + +Pick per symbol kind (hints above): + +```go +// TypeName is [one-line domain meaning]. <- always +// +// [WHY it exists: rationale, incident, constraint — <- the default content +// context the code cannot carry] +// +// Constraints: <- self-validating types +// - [validation rules, thread-safety guarantees] +// +// Use cases / flow: <- logic-heavy types only +// [when to reach for it, or a short flow sketch] +// +// Example: <- parsing constructors: +// p, err := ParsePolicy("3x100ms") // valid dos/don'ts inputs +// _, err = ParsePolicy("0x") // rejected: zero attempts +// +// See docs/[feature].md for the full picture. <- whenever the doc exists +type TypeName struct { + // ... +} +``` + +### Function Godoc Menu + +Only for non-obvious behavior; a small method or plain constructor gets one line, or +nothing: + +```go +// FunctionName [does what] for [purpose]. <- always, if documented at all +// +// [Error conditions, non-obvious behavior, <- only when non-obvious +// performance characteristics] +// +// See docs/[feature].md#section for the detailed flow. <- whenever the doc exists +func FunctionName(ctx context.Context, input InputType) (OutputType, error) { + // ... +} +``` + +### Testable Example Template + +```go +// Example_TypeName demonstrates typical usage of TypeName. +func Example_TypeName() { + id, _ := NewUserID("usr_123") + fmt.Println(id) + // Output: usr_123 +} + +// Example_TypeName_validation shows validation behavior. +func Example_TypeName_validation() { + _, err := NewUserID("") + fmt.Println(err != nil) + // Output: true +} +``` + +Testable examples show happy-path usage. Keep simple — complex scenarios belong in +feature docs. + +--- + +## Frontmatter Templates (OKF Bundle) + +Content docs start with YAML frontmatter (R9's bundle policy — the one non-menu +part of any template: the required keys are not optional). A doc's index line IS +its `description`, so write the description as the index line you want. + +**Content doc** (feature / architecture / guide): + +```yaml +--- +type: feature +description: why retries use capped full jitter; `Policy` API +# optional: +# title: Retry policy # the H1 is the title; this key never replaces it +# generated: 2026-08-20T00:00:00Z # OKF provenance: last substantive update +# tags: [resilience, retry] +# status: stable # draft | stable | deprecated +# stale_after: 2027-01-01 # past this date the index line gets the ⚠️ flag +--- +``` + +**Indexes carry no frontmatter** (OKF keeps reserved `index.md` bare). The one +exception is the root index (`/index.md`), which carries the bundle +version — and nothing else: + +```yaml +--- +okf_version: "0.2" +--- +``` + +--- + +## Feature Doc Template + +The sections are a menu too: a small feature may need only Problem & Solution and +Entry Points. The frontmatter block is the exception — its required keys always +ship (Frontmatter Templates above). All code citations follow R9's edge policy: exported symbols +first — the shortest token that greps uniquely, package-qualified only on ambiguity; +package or directory paths when a location is genuinely needed (directories for +symbol-less artifacts like examples/, paired with the symbols they demonstrate); +file paths and line numbers never. + +```markdown +--- +type: feature +description: [the index line — one line, what and why; key symbols] +--- +# [Feature Name] + +## Problem & Solution +**Problem**: [What user/system problem does this solve?] + +**Solution**: [High-level approach taken] + +## Entry Points +Where execution begins — the front door to this feature, cited by symbol: +- `POST /api/users` → `UserHandler.Create` — creates new user +- `UserCreatedEvent` → `NotificationListener.OnUserCreated` — triggers welcome email +- `cli user create` → `CreateUserCommand.Run` — CLI entry point + +## Key Players +The main actors that make this feature work (entry points + key players only — the +doc maps the front doors, per R9's edge policy): + +| Symbol | Role | Package | +|--------|------|---------| +| `UserService` | Orchestrates user operations | `user/` | +| `UserRepository` | Persists user data | `user/` | +| `UserID` | Self-validating identifier | `user/` | + +## Architecture + +### Design Decisions +- **Why [decision]**: [Rationale — connects to coding principles] +- **Why [pattern]**: [Rationale] + +### Data Flow +[Step-by-step description] +Input → Validation → Processing → Storage → Output + +### Integration Points +- **Consumed by**: [What uses this feature] +- **Depends on**: [What this feature uses] + +## Usage + +### Basic Usage +[Common case with real, runnable code] + +### Advanced Scenarios +[Edge cases — only if they exist] + +## Testing Strategy +- **Unit tests**: [What's covered, approach — cite the test package or its suite + entry point, never individual test functions (R9 edge policy)] +- **Integration tests**: [What's covered, approach] + +## Future Considerations +- [Known limitations, potential extensions] +``` + +Lateral doc→doc links go inline, in the sentence that explains the relationship +(R9 edge policy). There is no `## Related` section — a relationship that cannot +find a sentence in the body is not worth an edge. + +--- + +## The Index and Root Wiring + +### index.md Template + +A short reference guide: grouped by topic, ONE line per doc (size and style are +normative in R9's index policy). Each line IS the linked doc's `description` — +copied verbatim, and the conformance gate fails when the copy drifts; the ⚠️ flag +rides in from the doc's lifecycle keys or a stale classification (R9's +drift-check rule): + +```markdown +--- +okf_version: "0.2" +--- +# Repo Map + +- [conventions.md](conventions.md) — how to maintain this doc root (read before editing docs) + +**Resilience** +- [retry-policy.md](retry-policy.md) — why retries use capped full jitter; `Policy` API + +**Users** +- [user-management.md](user-management.md) — user lifecycle; `UserService`, `UserID` +- [notifications.md](notifications.md) — welcome and alert delivery; `Notifier` +``` + +### Map of Maps (past ~300 lines) + +The split is directory-shaped: each topic becomes a subdirectory with its own +bare `index.md`, and the root index shrinks to one short authored line per +sub-index (a bare sub-index has no `description` to copy — R9). The split moves +files — it lands in the same commit as the rewrite of the code-side +`See docs/...` paths: + +```markdown +- [Resilience](resilience/index.md) — retries, circuit breaking, timeouts +- [Users](users/index.md) — identity, sessions, notifications +``` + +Each sub-index follows the one-line-per-doc form above, with no frontmatter +(`okf_version` is the root's alone). + +### CLAUDE.md Wiring Snippet + +CLAUDE.md never restates the routing prose — it embeds AGENTS.md (the single +authored routing block, below) and imports the map: + +```markdown +## Documentation +@AGENTS.md +@docs/index.md +``` + +The `@` imports put the routing block and the map in context at session start. + +### AGENTS.md Routing Block + +The routing block is authored once, here — for every tool that reads AGENTS.md, +with CLAUDE.md embedding this file rather than duplicating it. At the repo root +and, in a monorepo, nested per sub-project (agents use the closest file, so each +sub-project's block names its own doc root): + +```markdown +## Documentation +Docs live in docs/ — start at docs/index.md, the map of all repo docs. +Before adding or editing anything under docs/, read docs/conventions.md +(frontmatter, link rules, what never to do). +When you change exported API behavior, update the doc that cites it and its +index line. Check your work: bash scripts/check-repo-brain.sh +``` + +--- + +## Conventions Doc (Self-Hosting) + +`/conventions.md` is the network's own maintenance manual, written for a +contributor without this plugin — the ONE content file bootstrap generates (network +infrastructure, not a content doc). Listed FIRST in the index. Template: + +```markdown +--- +type: guide +description: how to maintain this doc root (read before editing docs) +--- +# Doc Conventions + +This directory is the repo's documentation network — an OKF bundle. Markdown files +with YAML frontmatter; `index.md` is the map; links form the graph. Rules: + +## Frontmatter +Every content doc here starts with frontmatter (copy-paste, fill in): + + --- + type: feature # feature | architecture | guide + description: + --- + +Optional on content docs: `title`, `generated` (ISO 8601, last substantive +update), `tags`, `status: draft|stable|deprecated`, `stale_after: `. +Index files carry NO frontmatter — except the root `index.md`, which carries +only `okf_version`. + +## Links +- The index line for a doc IS its `description` — the description is the single + source: update it in the doc's frontmatter, copy it to `index.md`, and the + conformance gate fails when the two drift. +- Cite code by exported symbol (`` or `.`), never by file + path or line number. Backticks are a promise: a backticked symbol must grep in + this repo (mark future ones *(planned)* and write them without backticks). +- Link related docs inline, in the sentence that explains the relationship. + Links are one-way: never add a link back to `index.md` or a parent. + Use inline links only — `[name](path.md)`; reference-style links are not + checked by the conformance gate. There is no `## Related` section. + +## Never +- No `log.md`, no changelog sections — docs describe current behavior, not history. +- No `related:` key in frontmatter — links live in the body. +- No frontmatter on index files (the root's `okf_version` is the one exception). +- No file paths or line numbers as code references. + +## Check your work +Run `bash scripts/check-repo-brain.sh` from the repo root — it verifies the rules +above mechanically and points at this file when something breaks. +`--fix` rewrites drifted index lines from each doc's `description`. +Code↔docs checks cover Go files; docs about other languages get the structure +checks (reachability, frontmatter, index drift) but no symbol verification. +``` + +--- + +## Doc Roots and Monorepos + +- Discovery order is normative in R9: `.ai/` → `.ainav/` → `docs/` (create `docs/` if + none exists). +- `.ai/` and `.ainav/` are AI-navigation conventions — when a repo already uses one, + it IS the doc root; do not create a parallel `docs/`. +- Monorepo: each sub-project (own `go.mod` or equivalent boundary) gets its own doc + root + `index.md`; the repo-root index links the sub-indexes (map-of-maps form + above). +- Nesting inside a doc root is allowed as long as the index (or a sub-index) covers + every file — R9's reachability invariant. + +--- + +## Bootstrap Classification + +Classify each inventoried doc; the class decides its index line, grouping, and +frontmatter `type` (stale is a lifecycle, not a type — it keeps the class it would +otherwise have, expressed via `status`/`stale_after` plus the flagged line): + +| Class | Signals | Index treatment | Frontmatter `type` | +|-------|---------|-----------------|--------------------| +| **feature** | describes one capability's behavior; cites its symbols | group under its topic | `feature` | +| **architecture** | cross-feature structure, system-wide patterns | its own "Architecture" group | `architecture` | +| **guide** | setup, how-to, onboarding, runbooks | "Guides" group | `guide` | +| **stale** | cites symbols/packages that no longer resolve; describes removed behavior | index with a FLAGGED line (below); the flag is the advisory finding | its underlying class | + +**Stale never means unindexed** — R9's Q1 reachability invariant always wins. A stale +doc gets a flagged index line naming the unresolved symbol: + +```markdown +- [auth.md](auth.md) — ⚠️ stale: cites unresolved `TokenVerifier` +``` + +The flag names the unresolved symbol; it cannot tell an aspirational doc (written +ahead of the code) from a doc for deleted code — choosing refresh (FEATURE mode) / +remove / keep-as-roadmap is the user's call, made from the advisory report. +Bootstrap never decides. + +When unsure between feature and architecture: one capability → feature; the seams +between capabilities → architecture. + +### Frontmatter Migration (Brownfield) + +An existing network without frontmatter — wired by hand, or by a plugin version +before the OKF layer — is just another brownfield state. **Verify-or-add, never +duplicate**: a doc that already has conformant frontmatter is left alone; a doc +without gets the required keys, with `description` written as its index line +(add optional `generated` from the doc's last substantive git touch when +evident). An index +carrying frontmatter (written by hand, or by an older plugin version) gets it +stripped — the root keeps only `okf_version`. A `type` the +classification table cannot settle goes to the advisory report +(`type?: — class not inferable`) — never guessed silently. Same for +`conventions.md` and the check script: create or verify, and report a diverged +script rather than overwriting it. + +### Rung-2 Gap Criterion (BOOTSTRAP) + +FEATURE mode anchors R9 Q5 on the diff; bootstrap has no diff. Report a rung-2 gap +on exactly two greppable signals — nothing fuzzier: + +- **(a) Dangling intent**: a live code→docs edge points at a missing doc — the edge + is evidence a doc was intended (surfaces from the Q2 code→docs grep). +- **(b) Undocumented front door**: a package with entry points has no doc citing any + of its exported symbols. + +### Upward-Edge Anchor Heuristic (BOOTSTRAP step 5) + +Each indexed doc gets at most ONE upward edge (low density — R9 edge policy). The +anchor is the doc's front door, chosen in this order: + +1. **The doc's central exported symbol** — the type or constructor the doc most + centrally describes: usually the first symbol its index line cites, or the type + in the doc's title (`spanlogger-api.md` → the `SpanLogger` type). +2. **The package godoc** — when the doc spans a whole package rather than one + symbol (`versioning.md` → `package version`'s doc comment). +3. **No confident anchor** → do not guess. Report the doc as `unwired` in the + advisory findings; a wrong edge is worse than a missing one (it survives Q2 — + it resolves — while pointing readers somewhere unhelpful). + +Mechanics: append the edge as the final line of the anchor's EXISTING doc comment — +`// See /.md for .` Never restructure the +comment around it; never create a doc comment solely to host an edge (a naked symbol +is a Q5 finding for FEATURE mode, not a wiring target); confirm the package still +vets after the edit. + +--- + +## Checklists + +### Feature Documentation Checklist + +- [ ] Frontmatter present with the required keys (`type`, `description` — R9's + bundle policy); `description` reads as the index line +- [ ] Clear problem statement and high-level solution approach +- [ ] Entry points listed, cited by symbol (e.g. `POST /users` → `UserHandler.Create`) +- [ ] Key players table with Symbol, Role, and Package — no file paths, no line numbers +- [ ] Design decisions explained with rationale, connected to coding principles +- [ ] Data flow and integration points documented +- [ ] Usage examples are runnable and copy-pasteable +- [ ] Lateral doc links are inline, each in a sentence stating the relationship — + no `## Related` section +- [ ] Doc has its one line in `index.md` — copied from its `description` — and at + least one code-side edge names it +- [ ] No `log.md`, no changelog sections, no `related:` frontmatter key + +### Code Comments Checklist + +- [ ] Every comment survived the placement test: would a rename or extraction make it + unnecessary? (R9 placement rule) +- [ ] Every prose line delivers a Comment Value Toolbox item (floor), and the comment + carries the highest-value items for its symbol's tier (ceiling) — R9's + toolbox-value test +- [ ] Plain English throughout: everyday words, short sentences, no unexplained + acronyms or insider jargon — written for a fresh graduate whose first + language may not be English (R9's plain-English/empathy test) +- [ ] Every comment is self-standing: understandable BEFORE reading the code, no + forward references to other comments (R9's empathy test, second half) +- [ ] No repo-idiom restating: a convention the repo applies everywhere is never + re-justified at a use site (documented once at rung 2) +- [ ] No review-defense narration: design choices are not defended at the code + line ("bounds-checked", "deliberately narrow — not a table") +- [ ] Unexported symbols carry no comment — except the special case of ONE line + with a very high-value toolbox item (R9's visibility default) +- [ ] No decoder-ring references: no plan/decision/test-plan IDs, requirement + tags, or spec section refs — facts as prose, the doc via one See-edge +- [ ] Exported symbols carry WHY — rationale, incident, constraint — never a restated + identifier +- [ ] Every doc comment fits its tier budget — helper 0–1 / contract 2–3 / + crossroads ≤5 prose lines (R9's tiered comment policy); overflow moved to the + feature doc, `doc.go` (~20–30 lines) used for package docs that earn it +- [ ] Menu sections included only where they earn their place for that symbol, + within the tier budget +- [ ] Crossroads that deserve richer inline godoc got an expand recommendation in + the report — never extra lines beyond budget +- [ ] `See docs/.md` edge present wherever a feature doc exists — on its + own trailing line, never woven into the summary sentence +- [ ] Testable examples: at least one `Example_*` per complex/core type; runnable; + happy path only; `// Output:` comments included + +### Quality Gates + +**Clarity Test** +- Can someone unfamiliar with the code read this and understand the feature? +- Are design decisions explained, not just described? + +**AI Test** +- Can AI use this to fix a bug without reading all implementation code? +- Are integration points, invariants, and assumptions explicit? + +**Maintenance Test** +- If the feature needs extension, is it clear where to add code? +- Are limitations and future considerations noted? + +**Example Test** +- Can examples be copy-pasted and run with minimal setup? +- Do they demonstrate real-world usage patterns? + +--- + +## Guidelines + +### Bug Fix Documentation + +Bug fixes should NEVER add changelog-style entries. Instead, update existing docs to +reflect correct behavior. + +**Approach:** +1. Find the existing documentation for the affected behavior +2. Update it to describe the CORRECT behavior +3. If no docs exist, write behavior docs as if the bug never happened + +**Example — Email Validation Bug:** +``` +❌ DON'T ADD: +## Bug Fixes +- Fixed: Email validation now correctly rejects addresses without TLD + +✅ DO UPDATE existing "Validation" section: +## Validation +Email addresses must include a valid TLD (e.g., .com, .org). +Invalid formats return ErrInvalidEmail with descriptive message. +``` + +**Example — Parser Edge Case:** +``` +❌ DON'T ADD: +## v1.2.3 Changes +- Fixed edge case where empty input caused panic + +✅ DO UPDATE existing "Input Handling" section: +## Input Handling +Empty input returns ErrEmptyInput. All inputs are validated before parsing. +``` + +**Why this matters:** someone reading docs in 5 years wants to know "How does +validation work?" — they don't care that it was broken once. + +### Managing Documentation Size + +Two distinct size rules — don't conflate them: +- **A single doc past ~500 lines** → split it into a folder (below). +- **`index.md` past ~300 lines** → map of maps (R9's index policy; form above). + +**Split signals:** doc exceeds ~500 lines; multiple distinct topics competing for +attention; hard to find specific information. + +**Folder structure for a large feature:** +``` +docs/ +├── index.md # root map — links feature-name/index.md +└── feature-name/ + ├── index.md # sub-index: one line per sub-doc + ├── architecture.md # detailed architecture, diagrams + └── usage.md # examples and patterns +``` + +The sub-index follows the same one-line-per-doc form; the root index links it, so +every sub-doc stays two hops from CLAUDE.md (R9's reachability invariant). + +**When NOT to split:** the feature is cohesive and flows logically; splitting would +create orphaned fragments; sub-docs would be too thin to stand alone. + +--- + +## Examples + +### Anti-Patterns + +#### ❌ Changelog-Style Entries +```markdown +## v1.2.3 Changes +- Fixed bug where validation allowed empty strings +- Updated error messages for clarity +``` +*Why bad?*: readers need current behavior, not history. + +#### ✅ Behavior-Focused Documentation +```markdown +## Validation +- Input must be non-empty string matching pattern `^[a-z]+$` +- Invalid input returns ErrInvalidInput with descriptive message +- Empty input is explicitly rejected (not silently ignored) +``` + +--- + +#### ❌ Over-Budget Godoc (depth at the wrong rung) +```go +// Scheduler coordinates periodic report generation across tenants. +// It was introduced after the v2 incident where per-tenant cron jobs +// drifted and overlapped, causing duplicate report emails. +// The scheduler holds a min-heap of next-run times and wakes on the +// earliest deadline. Each tick it drains all due tenants, submits +// them to the worker pool, and re-heaps with jittered next-run times. +// Jitter is +/-10% to avoid thundering herd on shared storage. +// Thread safety: all public methods lock the internal mutex; callbacks +// run outside the lock. Do not call Schedule from inside a callback. +// See docs/report-scheduling.md. +type Scheduler struct { /* ... */ } +``` +*Why bad?*: nine prose lines — the heap mechanics and tick flow are implementation +narration (rung-2 material at best) drowning the two facts a reader at this symbol +actually needs. Reviewers scroll past comments like this, then miss the one that +matters. + +#### ✅ Trimmed to Tier (crossroads: ≤5 prose lines) +```go +// Scheduler coordinates periodic report generation across tenants. +// It exists because independent per-tenant cron jobs drifted and overlapped +// (duplicate report emails — the v2 incident); one coordinator with jittered +// next-run times replaced them. +// Do not call Schedule from inside a callback — callbacks run outside the lock. +// See docs/report-scheduling.md for the tick flow and jitter math. +type Scheduler struct { /* ... */ } +``` +*The overflow moved, not died*: tick flow, heap mechanics, and jitter math now live +in `docs/report-scheduling.md`; the comment keeps the WHY, the one caller-facing +constraint, and the edge that points at the depth. + +--- + +#### ❌ Implementation Details Without Context +```markdown +## Implementation +The CreateUser function calls validateEmail and then repo.Save. +It returns an error if validation fails. +``` +*Why bad?*: describes WHAT code does without WHY. + +#### ✅ Context-Rich Explanation +```markdown +## Design Decision: Validation Before Persistence +CreateUser validates email format before database operations to: +1. Fail fast — avoid unnecessary database round-trips +2. Provide clear error messages — users get immediate feedback +3. Maintain data quality — only valid emails in database + +Email validation is separate from UserID validation because emails +may need external verification (MX record checks) in the future, +while UserIDs are purely format-based. +``` + +--- + +#### ❌ Feature List Without Purpose +```markdown +## Components +- UserID type +- Email type +- UserService +``` +*Why bad?*: no explanation of relationships or rationale. + +#### ✅ Purpose-Driven Structure +```markdown +## Architecture + +### Type Safety Layer (Primitive Obsession Prevention) +- **UserID**: self-validating identifier (prevents empty/malformed IDs) +- **Email**: self-validating email (prevents invalid formats, RFC 5322) + +These types ensure validation happens once at construction, not repeatedly +throughout the codebase. + +### Business Logic Layer +- **UserService**: orchestrates user operations — depends on Repository for + persistence and Notifier for communication; contains no infrastructure code. + +This vertical slice structure keeps all user logic contained in one package: +"group by feature and role, not technical layer." +``` + +--- + +#### ❌ Code Dump as "Example" +```markdown +## Usage +See the user package tests for usage examples. +``` +*Why bad?*: forces the reader to hunt through test code. + +#### ✅ Inline Runnable Example +```go +// Create validated types +id, err := user.NewUserID("usr_12345") +if err != nil { + panic(err) // invalid ID format +} + +email, err := user.NewEmail("alice@example.com") +if err != nil { + panic(err) // invalid email format +} + +// Create and use the service +svc, _ := user.NewUserService(repo, notifier) +err = svc.CreateUser(ctx, user.User{ID: id, Email: email, Name: "Alice"}) +``` + +### Common Documentation Scenarios + +**New domain type** — document why it exists (what primitive obsession it prevents), +what it validates, how to construct it, where it's used. + +**New service/orchestrator** — document what business operations it provides, what +dependencies it requires (and why), integration points. + +**New integration point** — document what external system is integrated and why, how +data flows in/out, error handling and retry/fallback behavior. + +**Refactored architecture** — document what problem the refactor solved, what changed +architecturally, why this approach was chosen. + +### AI-Friendly Documentation Patterns + +**For feature extensions** — established patterns, natural extension points, +constraints to maintain: +```markdown +## Extension Points +- **New validation rules**: add to the NewUserID constructor +- **New storage backends**: implement the Repository interface +- **New notification channels**: implement the Notifier interface +``` + +**For understanding data flow** — entry points, transformation steps, outcomes: +```markdown +## Data Flow +1. HTTP handler receives POST /users → CreateUserRequest +2. Request validation → NewUserID, NewEmail (self-validating types) +3. UserService.CreateUser → validates business rules +4. Repository.Save → persists to database +5. Notifier.SendWelcome → sends welcome email (async) +6. Returns: User struct or validation/business error +``` + +**Design invariants** — document invariants that must be maintained: +```markdown +## Design Invariants +- UserID must always be non-empty after construction +- Email validation follows RFC 5322 +- UserService assumes repository is never nil (validated in constructor) +``` diff --git a/lang/go/passthrough/skills/testing/examples/grpc-bufconn.md b/lang/go/passthrough/skills/testing/examples/grpc-bufconn.md new file mode 100644 index 0000000..71a9d5c --- /dev/null +++ b/lang/go/passthrough/skills/testing/examples/grpc-bufconn.md @@ -0,0 +1,320 @@ +# gRPC Testing with bufconn and Rich Client Mocks + +Rung: 1 (first real layer over leaf types — real gRPC transport in-process via bufconn) + +## When to Use This Example + +Use this when: +- Testing gRPC servers +- Need bidirectional streaming tests +- Want in-memory gRPC (no network I/O) +- Testing server-client interactions +- Need rich DSL for readable tests + +**Dependency Level**: Level 1 (In-Memory) - Uses `bufconn` for in-memory gRPC connections + +**Key Insight**: When testing a **gRPC server**, mock the **clients** that connect to it. When testing a **gRPC client**, mock the **server**. + +## Implementation + +### Rich gRPC Client Mock with DSL + +When your **System Under Test (SUT) is a gRPC server**, create rich client mocks: + +```go +// internal/testutils/grpc_client_mock.go +package testutils + +import ( + "context" + "io" + "sync" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + pb "myproject/grpc_api/gen/go/traces/v1" +) + +// TaskmonOnCluster is a rich gRPC client mock with DSL for testing gRPC servers. +// It connects to your gRPC server and provides helper methods for assertions. +type TaskmonOnCluster struct { + clusterRoutingKey string + taskmonID string + stream pb.RemoteTracesService_StreamTracesClient + mu sync.RWMutex + receivedQuery *pb.TracesQuery + receivedQueriesPayloads []string +} + +// OpenTaskmonToWekaHomeStream creates a gRPC client mock that connects to your server. +// This is the constructor for the mock - returns a rich DSL object. +func OpenTaskmonToWekaHomeStream( + ctx context.Context, + client pb.RemoteTracesServiceClient, + clusterRoutingKey, taskmonID string, +) (*TaskmonOnCluster, error) { + // Inject metadata (like session tokens) into context + md := metadata.Pairs("X-Taskmon-session-token", clusterRoutingKey) + ctx = metadata.NewOutgoingContext(ctx, md) + + // Open streaming connection to the server (your SUT) + stream, err := client.StreamTraces(ctx, grpc.Header(&md)) + if err != nil { + return nil, err + } + + return &TaskmonOnCluster{ + stream: stream, + clusterRoutingKey: clusterRoutingKey, + taskmonID: taskmonID, + receivedQueriesPayloads: []string{}, + }, nil +} + +// SessionToken returns the session token (useful for assertions) +func (m *TaskmonOnCluster) SessionToken() string { + return m.clusterRoutingKey +} + +// Close closes the stream (idempotent) +func (m *TaskmonOnCluster) Close() { + if m.stream == nil { + return + } + m.stream.CloseSend() +} + +// ListenToStreamAndAssert is a helper that listens to server messages and asserts. +// This makes tests read like documentation! +func (m *TaskmonOnCluster) ListenToStreamAndAssert( + t *testing.T, + expectedQueryPayload, + resultPayload string, +) { + for { + query, err := m.stream.Recv() + if err == io.EOF { + break + } + require.NoError(t, err, "Failed to receive query from server") + + // Store received data (thread-safe) + m.mu.Lock() + m.receivedQuery = query + m.receivedQueriesPayloads = append(m.receivedQueriesPayloads, string(query.TracesQueryPayload)) + m.mu.Unlock() + + // Assert expected payload + require.Equal(t, expectedQueryPayload, string(query.TracesQueryPayload)) + + // Send response back to server + response := &pb.TracesFromServer{ + TraceServerRoute: query.TraceServerRoute, + TracesPayload: []byte(resultPayload), + MessageId: query.MessageId, + } + err = m.stream.Send(response) + require.NoError(t, err, "Failed to send response") + } +} + +// LastReceivedQuery returns the last received query (thread-safe) +func (m *TaskmonOnCluster) LastReceivedQuery() *pb.TracesQuery { + m.mu.RLock() + defer m.mu.RUnlock() + return m.receivedQuery +} + +// ReceivedQueriesPayloads returns all received payloads (thread-safe) +func (m *TaskmonOnCluster) ReceivedQueriesPayloads() []string { + m.mu.RLock() + defer m.mu.RUnlock() + return m.receivedQueriesPayloads +} +``` + +## Usage in Integration Tests + +### Complete Test Suite Example + +```go +//go:build integration + +package integration_test + +import ( + "context" + "net" + "testing" + "time" + + "github.com/stretchr/testify/suite" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + pb "myproject/grpc_api/gen/go/traces/v1" + "myproject/internal/remotetraces" + "myproject/internal/testutils" +) + +type RemoteTracesTestSuite struct { + suite.Suite + lis *bufconn.Listener // In-memory gRPC connection + ctx context.Context + natsServer *nserver.Server // In-memory NATS +} + +func (suite *RemoteTracesTestSuite) SetupSuite() { + suite.ctx = context.Background() + + // Start in-memory NATS server (Level 1) + natsServer, err := testutils.RunNATsServer() + suite.Require().NoError(err) + suite.natsServer = natsServer + + // Connect to NATS + natsAddress := "nats://" + natsServer.Addr().String() + nc, err := natsremotetraces.ConnectToRemoteTracesSession(suite.ctx, natsAddress, 2, 2, 10) + suite.Require().NoError(err) + + // ** System Under Test: gRPC Server ** + // Use bufconn for in-memory gRPC (no network I/O!) + suite.lis = bufconn.Listen(1024 * 1024) + s := grpc.NewServer() + + // Your gRPC server implementation + remoteTracesServer := remotetraces.NewGRPCServer(nc, 10, 10, time.Second) + pb.RegisterRemoteTracesServiceServer(s, remoteTracesServer) + + go func() { + if err := s.Serve(suite.lis); err != nil { + suite.NoError(err) + } + }() +} + +func (suite *RemoteTracesTestSuite) bufDialer(ctx context.Context, _ string) (net.Conn, error) { + return suite.lis.DialContext(ctx) +} + +func (suite *RemoteTracesTestSuite) TestStreamTraces() { + // Create gRPC client (connects to your server) + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(suite.bufDialer), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + suite.Require().NoError(err) + defer conn.Close() + + client := pb.NewRemoteTracesServiceClient(conn) + + // Create rich gRPC client mock (testutils DSL!) + clusterRoutingKey := "test-cluster-123" + taskmonMock, err := testutils.OpenTaskmonToWekaHomeStream( + suite.ctx, client, clusterRoutingKey, "taskmon-1") + suite.Require().NoError(err) + defer taskmonMock.Close() + + expectedQuery := "fetch_traces_query" + expectedResult := "traces_result_data" + + // Start listening (this makes the test readable!) + go taskmonMock.ListenToStreamAndAssert(suite.T(), expectedQuery, expectedResult) + + // Send query to server (via NATS or HTTP API) + // ... your test logic here ... + + // Assert using helper methods + suite.Eventually(func() bool { + return taskmonMock.LastReceivedQuery() != nil && + string(taskmonMock.LastReceivedQuery().TracesQueryPayload) == expectedQuery + }, 5*time.Second, 500*time.Millisecond) +} + +func TestRemoteTracesTestSuite(t *testing.T) { + suite.Run(t, new(RemoteTracesTestSuite)) +} +``` + +## Why This Pattern is Excellent + +1. **Rich DSL** - `OpenTaskmonToWekaHomeStream()` returns friendly object with helper methods +2. **Helper Methods** - `ListenToStreamAndAssert()`, `LastReceivedQuery()`, `ReceivedQueriesPayloads()` +3. **Thread-Safe** - Mutex protects shared state for concurrent access +4. **Readable Tests** - Tests read like documentation, clear intent +5. **In-Memory** - Uses `bufconn` (no network I/O, pure Go) +6. **Reusable** - Same mock for unit, integration, and system tests +7. **Event-Driven** - Can add channels for connection events if needed + +## Key Design Principles + +### Testing Direction + +- **Testing a server?** → Mock the **clients** that connect to it +- **Testing a client?** → Mock the **server** it connects to + +### DSL Benefits + +- Use rich DSL objects with helper methods +- Make tests read like documentation +- Hide complexity behind clean interfaces +- Provide thread-safe state tracking +- Enable fluent assertions + +### In-Memory with bufconn + +`bufconn` provides an in-memory, full-duplex network connection: +- No network I/O overhead +- No port allocation needed +- Faster than TCP loopback +- Perfect for CI/CD +- Deterministic behavior + +## Benefits + +- **No Docker required** - Pure Go, works anywhere +- **No binary downloads** - Everything in-memory +- **No network I/O** - Unless testing actual network code +- **Perfect for CI/CD** - Fast, reliable, no external dependencies +- **Lightning fast** - Microsecond startup time +- **Thread-safe** - Concurrent test execution safe + +## Alternative: Testing gRPC Clients + +If you're testing a **gRPC client**, mock the **server** instead: + +```go +// internal/testutils/grpc_server_mock.go +type MockGRPCServer struct { + pb.UnimplementedRemoteTracesServiceServer + mu sync.Mutex + receivedQueries []*pb.TracesQuery +} + +func (m *MockGRPCServer) StreamTraces(stream pb.RemoteTracesService_StreamTracesServer) error { + // Mock server implementation + // Store received queries, send responses + // ... + return nil +} + +// Usage +server := testutils.NewMockGRPCServer() +lis := bufconn.Listen(1024 * 1024) +s := grpc.NewServer() +pb.RegisterRemoteTracesServiceServer(s, server) +// ... test your client against this mock server +``` + +## Key Takeaways + +1. **bufconn is Level 1** - In-memory, no external dependencies +2. **Mock the opposite end** - Server → mock clients, Client → mock server +3. **Rich DSL makes tests readable** - Helper methods, clear intent +4. **Thread-safe state tracking** - Use mutexes for concurrent access +5. **Reusable across test levels** - Same infrastructure everywhere +6. **Check for official test harnesses first** - Many libraries provide them (like NATS) diff --git a/lang/go/passthrough/skills/testing/examples/httptest-dsl.md b/lang/go/passthrough/skills/testing/examples/httptest-dsl.md new file mode 100644 index 0000000..ddde564 --- /dev/null +++ b/lang/go/passthrough/skills/testing/examples/httptest-dsl.md @@ -0,0 +1,283 @@ +# HTTP Test Server with DSL Pattern + +Rung: 1 (first real layer over leaf types — real HTTP through an in-process httptest server) + +## When to Use This Example + +Use this when: +- Testing HTTP clients or APIs +- Need simple, readable HTTP mocking +- Want to avoid complex mock frameworks +- Testing REST APIs, webhooks, or HTTP integrations + +**Dependency Level**: Level 1 (In-Memory) - Uses stdlib `httptest.Server` + +## Basic httptest.Server Pattern + +### Simple HTTP Mock + +```go +func TestAPIClient(t *testing.T) { + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Mock API response + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + })) + defer server.Close() + + // Use real HTTP client with test server URL + client := NewAPIClient(server.URL) + result, err := client.GetStatus() + + assert.NoError(t, err) + assert.Equal(t, "ok", result.Status) +} +``` + +## DSL Pattern for Readable Tests + +### Without DSL (Verbose) + +```go +func TestUserAPI(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/users/1" { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]string{"id": "1", "name": "Alice"}) + } else if r.Method == "POST" && r.URL.Path == "/users" { + // ... more complex logic + } else { + w.WriteHeader(404) + } + }) + server := httptest.NewServer(handler) + defer server.Close() + // ... test +} +``` + +### With DSL (Readable) + +```go +func TestUserAPI(t *testing.T) { + mockAPI := httpserver.New(). + OnGET("/users/1"). + RespondJSON(200, User{ID: "1", Name: "Alice"}). + OnPOST("/users"). + WithBodyMatcher(hasRequiredFields). + RespondJSON(201, User{ID: "2", Name: "Bob"}). + Build() + defer mockAPI.Close() + + // Test reads like documentation! + client := NewAPIClient(mockAPI.URL()) + user, err := client.GetUser("1") + // ... assertions +} +``` + +## Implementing the DSL + +### Basic DSL Structure + +```go +// internal/testutils/httpserver/server.go +package httpserver + +import ( + "encoding/json" + "net/http" + "net/http/httptest" +) + +type MockServer struct { + routes map[string]map[string]mockRoute // method -> path -> handler + server *httptest.Server +} + +type mockRoute struct { + statusCode int + response any + matcher func(*http.Request) bool +} + +func New() *MockServerBuilder { + return &MockServerBuilder{ + routes: make(map[string]map[string]mockRoute), + } +} + +type MockServerBuilder struct { + routes map[string]map[string]mockRoute +} + +func (b *MockServerBuilder) OnGET(path string) *RouteBuilder { + return &RouteBuilder{ + builder: b, + method: "GET", + path: path, + } +} + +func (b *MockServerBuilder) OnPOST(path string) *RouteBuilder { + return &RouteBuilder{ + builder: b, + method: "POST", + path: path, + } +} + +type RouteBuilder struct { + builder *MockServerBuilder + method string + path string + statusCode int + response any + matcher func(*http.Request) bool +} + +func (r *RouteBuilder) RespondJSON(statusCode int, response any) *MockServerBuilder { + if r.builder.routes[r.method] == nil { + r.builder.routes[r.method] = make(map[string]mockRoute) + } + r.builder.routes[r.method][r.path] = mockRoute{ + statusCode: statusCode, + response: response, + matcher: r.matcher, + } + return r.builder +} + +func (r *RouteBuilder) WithBodyMatcher(matcher func(*http.Request) bool) *RouteBuilder { + r.matcher = matcher + return r +} + +func (b *MockServerBuilder) Build() *MockServer { + mock := &MockServer{routes: b.routes} + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methodRoutes, ok := mock.routes[r.Method] + if !ok { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + route, ok := methodRoutes[r.URL.Path] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + + if route.matcher != nil && !route.matcher(r) { + w.WriteHeader(http.StatusBadRequest) + return + } + + w.WriteHeader(route.statusCode) + json.NewEncoder(w).Encode(route.response) + }) + + mock.server = httptest.NewServer(handler) + return mock +} + +func (m *MockServer) URL() string { + return m.server.URL +} + +func (m *MockServer) Close() { + m.server.Close() +} +``` + +## Simple In-Memory Patterns + +### In-Memory Repository + +```go +// user/inmem.go +package user + +type InMemoryRepository struct { + mu sync.RWMutex + users map[UserID]User +} + +func NewInMemoryRepository() *InMemoryRepository { + return &InMemoryRepository{ + users: make(map[UserID]User), + } +} + +func (r *InMemoryRepository) Save(ctx context.Context, u User) error { + r.mu.Lock() + defer r.mu.Unlock() + r.users[u.ID] = u + return nil +} + +func (r *InMemoryRepository) Get(ctx context.Context, id UserID) (*User, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + u, ok := r.users[id] + if !ok { + return nil, ErrNotFound + } + return &u, nil +} +``` + +### Test Email Sender + +```go +// user/test_emailer.go +package user + +import ( + "bytes" + "fmt" + "sync" +) + +type TestEmailer struct { + mu sync.Mutex + buffer bytes.Buffer +} + +func NewTestEmailer() *TestEmailer { + return &TestEmailer{} +} + +func (e *TestEmailer) Send(to Email, subject, body string) error { + e.mu.Lock() + defer e.mu.Unlock() + + fmt.Fprintf(&e.buffer, "To: %s\nSubject: %s\n%s\n\n", to, subject, body) + return nil +} + +func (e *TestEmailer) SentEmails() string { + e.mu.Lock() + defer e.mu.Unlock() + return e.buffer.String() +} +``` + +## Benefits + +- **Simple** - Built on stdlib, no external dependencies +- **Readable** - DSL makes tests self-documenting +- **Fast** - In-memory, microsecond startup +- **Flexible** - Easy to extend with new methods +- **Reusable** - Same pattern for all HTTP testing + +## Key Takeaways + +1. **Start with httptest.Server** - Simple and powerful +2. **Add DSL for readability** - When tests get complex +3. **Keep implementations simple** - In-memory maps, buffers +4. **Thread-safe** - Use mutexes for concurrent access +5. **Test your test infrastructure** - It's production code diff --git a/lang/go/passthrough/skills/testing/examples/integration-patterns.md b/lang/go/passthrough/skills/testing/examples/integration-patterns.md new file mode 100644 index 0000000..7ce0b19 --- /dev/null +++ b/lang/go/passthrough/skills/testing/examples/integration-patterns.md @@ -0,0 +1,250 @@ +# Integration Test Patterns + +Rung: 1-2 (orchestrators wired to real collaborators; multi-component workflows sit one rung higher) + +## Purpose + +Integration tests verify that components work together correctly. They test the seams between packages, ensure proper data flow, and validate that integrated components behave as expected. + +**When to Write**: After unit testing individual components, test how they interact. + +## File Organization + +### Option 1: In Package with Build Tags (Preferred) + +```go +//go:build integration + +package user_test + +import ( + "testing" + "myproject/internal/testutils" +) + +func TestUserService_Integration(t *testing.T) { + // Integration test +} +``` + +### Option 2: Separate Package + +``` +user/ +├── user.go +├── user_test.go # Unit tests +└── integration/ + └── user_integration_test.go # Integration tests +``` + +## Pattern 1: Service + Repository (In-Memory) + +**Use when**: Testing service logic with data persistence + +```go +//go:build integration + +package user_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "myproject/user" +) + +func TestUserService_CreateAndRetrieve(t *testing.T) { + // Setup: In-memory repository (Level 1) + repo := user.NewInMemoryRepository() + svc := user.NewUserService(repo, nil) + + ctx := context.Background() + + // Create user + userID, _ := user.NewUserID("usr_123") + email, _ := user.NewEmail("alice@example.com") + newUser := user.User{ + ID: userID, + Name: "Alice", + Email: email, + } + + err := svc.CreateUser(ctx, newUser) + require.NoError(t, err) + + // Retrieve user + retrieved, err := svc.GetUser(ctx, userID) + require.NoError(t, err) + require.Equal(t, "Alice", retrieved.Name) + require.Equal(t, email, retrieved.Email) +} +``` + +## Pattern 2: Testing with Real External Service + +**Use when**: Need to test against real service behavior (Victoria Metrics, NATS, etc.) + +```go +//go:build integration + +package metrics_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "myproject/internal/testutils" + "myproject/metrics" +) + +func TestMetricsIngest_WithVictoriaMetrics(t *testing.T) { + // Start real Victoria Metrics (Level 2 - binary) + vmServer, err := testutils.RunVictoriaMetricsServer() + require.NoError(t, err) + defer vmServer.Shutdown() + + // Create service with real dependency + svc := metrics.NewIngester(vmServer.WriteURL()) + + // Test ingestion + err = svc.IngestMetric(context.Background(), "test_metric", 42.0) + require.NoError(t, err) + + // Force flush and verify + vmServer.ForceFlush(context.Background()) + results, err := testutils.QueryVictoriaMetrics(vmServer.QueryURL(), "test_metric") + require.NoError(t, err) + require.Len(t, results, 1) +} +``` + +## Pattern 3: Multi-Component Workflow + +**Use when**: Testing complete workflows across multiple components + +```go +//go:build integration + +package workflow_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/suite" + "myproject/internal/testutils" + "myproject/user" + "myproject/notification" +) + +type UserWorkflowSuite struct { + suite.Suite + userRepo *user.InMemoryRepository + emailer *user.TestEmailer + natsServer *nserver.Server + userService *user.UserService + notifSvc *notification.NotificationService +} + +func (s *UserWorkflowSuite) SetupSuite() { + // Setup in-memory NATS (Level 1) + natsServer, err := testutils.RunNATsServer() + s.Require().NoError(err) + s.natsServer = natsServer + + // Setup components + s.userRepo = user.NewInMemoryRepository() + s.emailer = user.NewTestEmailer() + s.userService = user.NewUserService(s.userRepo, s.emailer) + + natsAddr := "nats://" + natsServer.Addr().String() + s.notifSvc = notification.NewService(natsAddr) +} + +func (s *UserWorkflowSuite) TearDownSuite() { + s.natsServer.Shutdown() +} + +func (s *UserWorkflowSuite) TestCreateUser_TriggersNotification() { + ctx := context.Background() + + // Subscribe to notifications + received := make(chan string, 1) + s.notifSvc.Subscribe("user.created", func(msg string) { + received <- msg + }) + + // Create user + userID, _ := user.NewUserID("usr_123") + email, _ := user.NewEmail("alice@example.com") + newUser := user.User{ID: userID, Name: "Alice", Email: email} + + err := s.userService.CreateUser(ctx, newUser) + s.Require().NoError(err) + + // Verify notification sent + select { + case msg := <-received: + s.Contains(msg, "Alice") + case <-time.After(2 * time.Second): + s.Fail("timeout waiting for notification") + } + + // Verify email sent + emails := s.emailer.SentEmails() + s.Contains(emails, "alice@example.com") +} + +func TestUserWorkflowSuite(t *testing.T) { + suite.Run(t, new(UserWorkflowSuite)) +} +``` + +## Dependency Priority + +1. **Level 1: In-Memory** (Preferred) - httptest, in-memory maps, NATS harness +2. **Level 2: Binary** (When needed) - Victoria Metrics, standalone services +3. **Level 3: Test-containers** (Last resort) - Docker containers, slow startup + +## Best Practices + +### DO: +- Test seams between components +- Use in-memory implementations when possible +- Test happy path and error scenarios +- Use testify suites for complex setup +- Focus on data flow and integration points + +### DON'T: +- Don't test business logic (that's unit tests) +- Don't use heavy mocking (use real implementations) +- Don't require Docker unless absolutely necessary +- Don't duplicate unit test coverage +- Don't skip cleanup (always defer) + +## Running Integration Tests + +```bash +# Skip integration tests (default) +go test ./... + +# Run with integration tests +go test -tags=integration ./... + +# Run only integration tests +go test -tags=integration ./... -run Integration + +# With coverage +go test -tags=integration -coverprofile=coverage.out ./... +``` + +## Key Takeaways + +1. **Test component interactions** - Not individual units +2. **Prefer real implementations** - Over mocks when possible +3. **Use build tags** - Keep unit tests fast +4. **Reuse testutils** - Same infrastructure across tests +5. **Test workflows** - Not just individual operations diff --git a/lang/go/passthrough/skills/testing/examples/jsonrpc-mock.md b/lang/go/passthrough/skills/testing/examples/jsonrpc-mock.md new file mode 100644 index 0000000..330a8d7 --- /dev/null +++ b/lang/go/passthrough/skills/testing/examples/jsonrpc-mock.md @@ -0,0 +1,265 @@ +# JSON-RPC Server Mock with DSL + +Rung: 1 (first real layer over leaf types — real JSON-RPC over httptest; fake data only at the external boundary) + +## When to Use This Example + +Use this when: +- Testing JSON-RPC clients +- Need to mock JSON-RPC server responses +- Want configurable mock behavior per method +- Need to track and assert on received requests +- Testing with OpenTelemetry trace propagation + +**Dependency Level**: Level 1 (In-Memory) - Uses `httptest.Server` for in-memory HTTP + +**Key Insight**: When testing a **JSON-RPC client**, mock the **server** it calls. Use rich DSL for readable test setup. + +## Implementation + +### Rich JSON-RPC Server Mock + +```go +// internal/testutils/jrpc_server_mock.go +package testutils + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/gorilla/rpc/v2/json2" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" +) + +var ErrMethodNotFound = errors.New("method not found") + +// TraceQuery holds received JSON-RPC queries for assertions +type TraceQuery struct { + Method string + Params string +} + +// JrpcTraceServerMock is a rich JSON-RPC server mock with DSL. +// Uses httptest.Server for in-memory HTTP (Level 1). +type JrpcTraceServerMock struct { + tracer trace.Tracer + server *httptest.Server + mockResponses map[string]any // method -> response + queriesReceived []TraceQuery // for assertions +} + +// StartJrpcTraceServerMock starts an in-memory JSON-RPC server. +// Returns a rich DSL object for configuring mock responses. +func StartJrpcTraceServerMock() *JrpcTraceServerMock { + mock := &JrpcTraceServerMock{ + mockResponses: make(map[string]any), + tracer: otel.Tracer("trace-server-mock"), + } + + mux := mock.createHTTPHandlers() + mock.server = httptest.NewServer(mux) + + return mock +} + +// AddMockResponse configures the mock to return a response for a method. +// This is the DSL - chain multiple calls for different methods! +func (m *JrpcTraceServerMock) AddMockResponse(method string, response any) { + m.mockResponses[method] = response +} + +// GetQueriesReceived returns all queries received (for assertions) +func (m *JrpcTraceServerMock) GetQueriesReceived() []TraceQuery { + return m.queriesReceived +} + +// Close shuts down the server (idempotent) +func (m *JrpcTraceServerMock) Close() { + m.server.Close() +} + +// Address returns the server address (for client configuration) +func (m *JrpcTraceServerMock) Address() string { + return m.server.Listener.Addr().String() +} + +func (m *JrpcTraceServerMock) createHTTPHandlers() *http.ServeMux { + mux := http.NewServeMux() + codec := json2.NewCodec() + + mux.HandleFunc("/reader", func(w http.ResponseWriter, r *http.Request) { + // Extract OpenTelemetry context for realistic testing + reqCtx := r.Context() + reqCtx = otel.GetTextMapPropagator().Extract(reqCtx, propagation.HeaderCarrier(r.Header)) + reqCtx, span := m.tracer.Start(reqCtx, "jrpc-trace-server", + trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + receivedReq := codec.NewRequest(r) + method, err := receivedReq.Method() + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + // Check if we have a mock response configured + if response, exists := m.mockResponses[method]; exists { + args := struct{}{} + if err := receivedReq.ReadRequest(&args); err != nil { + receivedReq.WriteError(w, http.StatusBadRequest, err) + return + } + + // Store query for assertions + m.queriesReceived = append(m.queriesReceived, TraceQuery{ + Method: method, + Params: fmt.Sprintf("%+v", args), + }) + + // Write mock response + receivedReq.WriteResponse(w, response) + return + } + + // Method not configured + params := []string{} + receivedReq.ReadRequest(¶ms) + receivedReq.WriteError(w, http.StatusBadRequest, ErrMethodNotFound) + }) + + return mux +} +``` + +## Usage Examples + +### Setup in Test Suite + +```go +func (suite *TaskmonTestSuite) SetupSuite() { + // Start in-memory JSON-RPC server mock (Level 1) + suite.jrpcServerMock = testutils.StartJrpcTraceServerMock() + + // Configure mock responses using DSL + suite.jrpcServerMock.AddMockResponse("protocol", struct { + Version string `json:"version"` + Date string `json:"date"` + }{ + Version: "3.18.0", + Date: "Sep-04-2018", + }) + + suite.jrpcServerMock.AddMockResponse("get_traces", struct { + Traces []string `json:"traces"` + }{ + Traces: []string{"trace1", "trace2"}, + }) + + // Configure your client to use the mock server + client := jrpc.NewClient(suite.jrpcServerMock.Address() + "/reader") +} + +func (suite *TaskmonTestSuite) TearDownSuite() { + suite.jrpcServerMock.Close() +} +``` + +### Test with Assertions + +```go +func (suite *TaskmonTestSuite) TestProtocolVersion() { + // Call your code that makes JSON-RPC requests + version, err := suite.taskmon.GetProtocolVersion() + suite.Require().NoError(err) + suite.Equal("3.18.0", version.Version) + + // Assert on received queries + queries := suite.jrpcServerMock.GetQueriesReceived() + suite.Require().Len(queries, 1) + suite.Equal("protocol", queries[0].Method) +} + +func (suite *TaskmonTestSuite) TestGetTraces() { + // Call your code + traces, err := suite.taskmon.GetTraces() + suite.Require().NoError(err) + suite.Equal([]string{"trace1", "trace2"}, traces) + + // Verify the right method was called + queries := suite.jrpcServerMock.GetQueriesReceived() + suite.Require().Len(queries, 2) // protocol + get_traces + suite.Equal("get_traces", queries[1].Method) +} +``` + +## Why This Pattern is Excellent + +1. **Rich DSL** - `AddMockResponse()` for easy, readable configuration +2. **Readable Setup** - Tests are self-documenting, clear intent +3. **In-Memory** - Uses `httptest.Server` (Level 1, no network I/O) +4. **Query Tracking** - `GetQueriesReceived()` for assertions on what was called +5. **OpenTelemetry Integration** - Realistic trace propagation for observability testing +6. **Idempotent Cleanup** - Safe to call `Close()` multiple times +7. **Flexible** - Configure any method/response combination dynamically + +## Key Design Principles + +### DSL for Configuration + +Mock setup should read like configuration: +```go +mock.AddMockResponse("method_name", expectedResponse) +mock.AddMockResponse("another_method", anotherResponse) +``` + +### Query Tracking for Assertions + +Always track what was received: +- Method names called +- Parameters passed +- Order of calls +- Number of calls + +### Built on httptest.Server + +httptest.Server provides: +- In-memory HTTP (no network I/O) +- Automatic address allocation +- Clean lifecycle management +- Standard library, no dependencies + +## Pattern Comparison + +| Pattern | Use When | +|---------|----------| +| **httptest.Server** | Simple HTTP mocking | +| **NATS test harness** | Need real NATS (pub/sub) | +| **gRPC client mock** | Testing gRPC **server** | +| **JSON-RPC server mock** | Testing JSON-RPC **client** | + +## Benefits + +- **In-Memory** - No network I/O, pure Go +- **Fast** - Microsecond startup time +- **Configurable** - Dynamic response configuration per test +- **Trackable** - Full visibility into received requests +- **OpenTelemetry-aware** - Realistic trace propagation +- **Reusable** - Same infrastructure across test levels + +## Key Takeaways + +1. **Mock servers should have rich DSL** - Makes setup readable +2. **Track received requests** - Essential for assertions +3. **Use httptest.Server** - Perfect for HTTP-based protocols +4. **Make setup read like configuration** - Self-documenting tests +5. **Support trace propagation** - Realistic observability testing +6. **Idempotent cleanup** - Safe resource management diff --git a/lang/go/passthrough/skills/testing/examples/nats-in-memory.md b/lang/go/passthrough/skills/testing/examples/nats-in-memory.md new file mode 100644 index 0000000..269c55c --- /dev/null +++ b/lang/go/passthrough/skills/testing/examples/nats-in-memory.md @@ -0,0 +1,177 @@ +# NATS In-Memory Test Server + +Rung: 1 (first real layer over leaf types — in-memory NATS via the official test harness) + +## When to Use This Example + +Use this when: +- Testing message queue integrations with NATS +- Need pub/sub functionality in tests +- Want fast, in-memory NATS server (no Docker, no binary) +- Testing event-driven architectures + +**Dependency Level**: Level 1 (In-Memory) - Pure Go, official test harness + +## Implementation + +### Setup Test Infrastructure + +Many official SDKs provide test harnesses. Here's NATS: + +```go +// internal/testutils/nats.go +package testutils + +import ( + nserver "github.com/nats-io/nats-server/v2/server" + natsserver "github.com/nats-io/nats-server/v2/test" + "github.com/projectdiscovery/freeport" +) + +// RunNATsServer runs a NATS server in-memory for testing. +// Uses the official NATS SDK test harness - no binary download needed! +func RunNATsServer() (*nserver.Server, error) { + opts := natsserver.DefaultTestOptions + + // Allocate free port to prevent conflicts in parallel tests + tcpPort, err := freeport.GetFreePort("127.0.0.1", freeport.TCP) + if err != nil { + return nil, err + } + + opts.Port = tcpPort.Port + + // Start NATS server in-memory (pure Go!) + return natsserver.RunServer(&opts), nil +} + +// RunNATsServerWithJetStream runs NATS with JetStream enabled +func RunNATsServerWithJetStream() (*nserver.Server, error) { + opts := natsserver.DefaultTestOptions + + tcpPort, err := freeport.GetFreePort("127.0.0.1", freeport.TCP) + if err != nil { + return nil, err + } + + opts.Port = tcpPort.Port + opts.JetStream = true + + return natsserver.RunServer(&opts), nil +} +``` + +## Usage in Integration Tests + +### Basic Pub/Sub Test + +```go +//go:build integration + +package integration_test + +import ( + "context" + "testing" + "time" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/require" + "myproject/internal/testutils" +) + +func TestNATSPubSub_Integration(t *testing.T) { + // Start NATS server in-memory (Level 1 - pure Go!) + natsServer, err := testutils.RunNATsServer() + require.NoError(t, err) + defer natsServer.Shutdown() + + // Connect to in-memory NATS + natsAddress := "nats://" + natsServer.Addr().String() + nc, err := nats.Connect(natsAddress) + require.NoError(t, err) + defer nc.Close() + + // Test pub/sub + received := make(chan string, 1) + _, err = nc.Subscribe("test.subject", func(msg *nats.Msg) { + received <- string(msg.Data) + }) + require.NoError(t, err) + + // Publish message + err = nc.Publish("test.subject", []byte("hello")) + require.NoError(t, err) + + // Wait for message + select { + case msg := <-received: + require.Equal(t, "hello", msg) + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for message") + } +} +``` + +### Real-World Usage Example (gRPC + NATS) + +```go +// tests/gointegration/remote_traces_test.go +type RemoteTracesTestSuite struct { + suite.Suite + natsServer *nserver.Server + natsAddress string + nc *nats.Conn + // ... other fields +} + +func (suite *RemoteTracesTestSuite) SetupSuite() { + // Start NATS server in-memory + natsServer, err := testutils.RunNATsServer() + suite.Require().NoError(err) + + suite.natsServer = natsServer + suite.natsAddress = "nats://" + natsServer.Addr().String() + + // Connect application to in-memory NATS + suite.nc, err = natsremotetraces.ConnectToRemoteTracesSession( + suite.ctx, suite.natsAddress, numWorkers, numWorkers, channelSize) + suite.Require().NoError(err) + + // Start gRPC server with NATS backend + // ... rest of setup +} + +func (suite *RemoteTracesTestSuite) TearDownSuite() { + suite.nc.Close() + suite.natsServer.Shutdown() // Clean shutdown +} + +func (suite *RemoteTracesTestSuite) TestMessageFlow() { + // Test your application logic that uses NATS + // ... +} +``` + +## Why This is Excellent + +- **Pure Go** - NATS server imported as library (no binary download) +- **Official** - Uses NATS SDK's official test harness +- **Fast** - Starts in microseconds +- **Reliable** - Same behavior as production NATS +- **Portable** - Works anywhere Go runs +- **No Docker** - No external dependencies +- **Parallel-Safe** - Free port allocation prevents conflicts + +## Other Libraries with Test Harnesses + +- **Redis**: `github.com/alicebob/miniredis` - Pure Go in-memory Redis +- **NATS**: `github.com/nats-io/nats-server/v2/test` (shown above) +- **PostgreSQL**: `github.com/jackc/pgx/v5/pgxpool` with pgx mock +- **MongoDB**: `github.com/tryvium-travels/memongo` - In-memory MongoDB + +## Key Takeaways + +1. **Check for official test harnesses first** - Many popular libraries provide them +2. **Use free port allocation** - Prevents conflicts in parallel tests +3. **Clean shutdown** - Always call `Shutdown()` in teardown +4. **Reusable infrastructure** - Same setup for unit, integration, and system tests diff --git a/lang/go/passthrough/skills/testing/examples/system-patterns.md b/lang/go/passthrough/skills/testing/examples/system-patterns.md new file mode 100644 index 0000000..9a09ecf --- /dev/null +++ b/lang/go/passthrough/skills/testing/examples/system-patterns.md @@ -0,0 +1,290 @@ +# System Test Patterns + +Rung: top (the whole system composed, black-box via CLI/API; only the true external boundary faked) + +## Purpose + +System tests are black-box tests that verify the entire application works correctly from an external perspective. They test via CLI or API, simulating real user interactions. + +**Location**: `tests/` directory at project root (separate from package code) + +## Principles + +### Black Box Testing +- Test only via public interfaces (CLI, API) +- No access to internal packages +- Simulate real user behavior +- Test critical workflows end-to-end + +### Independence in Go +- Strive for pure Go tests (no Docker required) +- Use in-memory mocks from `testutils` +- Binary dependencies when needed +- Avoid docker-compose in CI + +## CLI Testing Patterns + +### Pattern 1: Simple Command Execution + +```go +// tests/cli_test.go +package tests + +import ( + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCLI_Version(t *testing.T) { + // Execute CLI command + cmd := exec.Command("./myapp", "version") + output, err := cmd.CombinedOutput() + + require.NoError(t, err) + require.Contains(t, string(output), "myapp version") +} + +func TestCLI_Help(t *testing.T) { + cmd := exec.Command("./myapp", "--help") + output, err := cmd.CombinedOutput() + + require.NoError(t, err) + require.Contains(t, string(output), "Usage:") +} +``` + +### Pattern 2: CLI with In-Memory Mocks + +```go +// tests/cli_metrics_test.go +package tests + +import ( + "context" + "os/exec" + "testing" + + "github.com/stretchr/testify/require" + "myproject/internal/testutils" +) + +func TestCLI_MetricsIngest(t *testing.T) { + // Start Victoria Metrics (Level 2 - binary) + vmServer, err := testutils.RunVictoriaMetricsServer() + require.NoError(t, err) + defer vmServer.Shutdown() + + // Test CLI against real Victoria Metrics + cmd := exec.Command("./myapp", "ingest", + "--metrics-url", vmServer.WriteURL(), + "--metric-name", "cli_test_metric", + "--value", "100") + + output, err := cmd.CombinedOutput() + require.NoError(t, err) + require.Contains(t, string(output), "Metric ingested successfully") + + // Verify with helpers + vmServer.ForceFlush(context.Background()) + results, err := testutils.QueryVictoriaMetrics(vmServer.QueryURL(), "cli_test_metric") + require.NoError(t, err) + require.Len(t, results, 1) +} +``` + +### Pattern 3: CLI with File System + +```go +// tests/cli_config_test.go +package tests + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCLI_ConfigFile(t *testing.T) { + // Create temp directory + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.yaml") + + // Write config file + configContent := ` +server: + port: 8080 + host: localhost +` + err := os.WriteFile(configPath, []byte(configContent), 0644) + require.NoError(t, err) + + // Test CLI with config file + cmd := exec.Command("./myapp", "start", "--config", configPath, "--dry-run") + output, err := cmd.CombinedOutput() + + require.NoError(t, err) + require.Contains(t, string(output), "Server would start on localhost:8080") +} +``` + +## API Testing Patterns + +### Pattern 1: HTTP API with In-Memory Mocks + +```go +// tests/api_test.go +package tests + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os/exec" + "testing" + "time" + + "github.com/stretchr/testify/require" + "myproject/internal/testutils" +) + +func TestAPI_UserWorkflow(t *testing.T) { + // Start in-memory NATS (Level 1) + natsServer, err := testutils.RunNATsServer() + require.NoError(t, err) + defer natsServer.Shutdown() + + natsAddr := "nats://" + natsServer.Addr().String() + + // Start API server + cmd := exec.Command("./myapp", "serve", + "--port", "0", // Random free port + "--nats-url", natsAddr) + + // Start in background + err = cmd.Start() + require.NoError(t, err) + defer cmd.Process.Kill() + + // Wait for API to be ready + time.Sleep(500 * time.Millisecond) + + // Get actual port (from logs or endpoint) + apiURL := "http://localhost:8080" // Or parse from logs + + // Test API workflow + // 1. Create user + createReq := map[string]string{ + "name": "Alice", + "email": "alice@example.com", + } + body, _ := json.Marshal(createReq) + + resp, err := http.Post(apiURL+"/users", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + + // Parse response + var createResp map[string]string + json.NewDecoder(resp.Body).Decode(&createResp) + userID := createResp["id"] + + // 2. Retrieve user + resp, err = http.Get(apiURL + "/users/" + userID) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var user map[string]string + json.NewDecoder(resp.Body).Decode(&user) + require.Equal(t, "Alice", user["name"]) +} +``` + +## Architecture for Independence + +### Dependency Injection Pattern + +Design your application to accept dependency URLs: + +```go +// cmd/myapp/main.go +func main() { + // Allow overriding dependencies via flags + natsURL := flag.String("nats-url", "nats://localhost:4222", "NATS server URL") + metricsURL := flag.String("metrics-url", "http://localhost:8428", "Metrics server URL") + flag.Parse() + + // Use provided URLs (allows in-memory mocks in tests) + app := app.New(*natsURL, *metricsURL) + app.Run() +} +``` + +### Test with In-Memory Dependencies + +```go +// tests/app_test.go +func TestApp_WithMocks(t *testing.T) { + // Start all mocks + natsServer, _ := testutils.RunNATsServer() + defer natsServer.Shutdown() + + vmServer, _ := testutils.RunVictoriaMetricsServer() + defer vmServer.Shutdown() + + // Test app with mocked dependencies (pure Go, no Docker!) + cmd := exec.Command("./myapp", "serve", + "--nats-url", "nats://"+natsServer.Addr().String(), + "--metrics-url", vmServer.WriteURL()) + + // ... test application +} +``` + +## Running System Tests + +```bash +# Build application first +go build -o myapp ./cmd/myapp + +# Run system tests +go test -v ./tests/... + +# With coverage +go test -v -coverprofile=coverage.out ./tests/... + +# Specific test +go test -v ./tests/... -run TestCLI_MetricsIngest +``` + +## Best Practices + +### DO: +- Test via CLI/API only (black box) +- Use in-memory mocks from testutils +- Test critical end-to-end workflows +- Build binary before running tests +- Use temp directories for file operations + +### DON'T: +- Don't import internal packages +- Don't test every edge case (that's unit/integration tests) +- Don't require Docker in CI +- Don't use sleep for timing (use polling/channels) +- Don't skip cleanup + +## Key Takeaways + +1. **Black box only** - Test via public interfaces +2. **Independent in Go** - No Docker required +3. **Use testutils mocks** - Reuse infrastructure +4. **Test critical paths** - Not every scenario +5. **Fast execution** - Should run quickly in CI diff --git a/lang/go/passthrough/skills/testing/examples/test-organization.md b/lang/go/passthrough/skills/testing/examples/test-organization.md new file mode 100644 index 0000000..47758f1 --- /dev/null +++ b/lang/go/passthrough/skills/testing/examples/test-organization.md @@ -0,0 +1,262 @@ +# Test Organization and File Structure + +Rung: all (organization and build-tag scaffolding for every rung, 0 through top) + +## File Organization + +### Basic Structure + +``` +user/ +├── user.go +├── user_test.go # Unit tests for user.go +├── service.go +├── service_test.go # Unit tests for service.go +├── repository.go +└── repository_test.go # Unit tests for repository.go +``` + +### With Integration and System Tests + +``` +project/ +├── user/ +│ ├── user.go +│ ├── user_test.go # Unit tests (pkg_test) +│ ├── service.go +│ ├── service_test.go # Unit tests (pkg_test) +│ └── integration_test.go # Integration tests with //go:build integration +├── internal/ +│ └── testutils/ # Reusable test infrastructure +│ ├── nats.go # In-memory NATS server +│ ├── victoria.go # Victoria Metrics binary management +│ └── httpserver/ # HTTP mock DSL +│ ├── server.go +│ └── server_test.go # Test the infrastructure! +└── tests/ # System tests (black box) + ├── cli_test.go # CLI testing via exec.Command + └── api_test.go # API testing via HTTP client +``` + +## Package Naming + +### Use `pkg_test` for Unit Tests + +```go +// ✅ External package - tests public API only +package user_test + +import ( + "testing" + "github.com/yourorg/project/user" +) + +func TestService_CreateUser(t *testing.T) { + // Test through public API + svc, _ := user.NewUserService(repo, notifier) + err := svc.CreateUser(ctx, testUser) + // ... +} +``` + +### Avoid Same Package Testing + +```go +// ❌ Same package - can test private methods (don't do this) +package user + +import "testing" + +func TestInternalValidation(t *testing.T) { + // Testing private function - bad practice + result := validateEmailInternal("test@example.com") + // ... +} +``` + +## Build Tags for Integration Tests + +### Using Build Tags + +```go +//go:build integration + +package user_test + +import ( + "context" + "testing" + "myproject/internal/testutils" +) + +func TestUserService_Integration(t *testing.T) { + // Integration test with real dependencies + natsServer, _ := testutils.RunNATsServer() + defer natsServer.Shutdown() + + // Test with real NATS + // ... +} +``` + +### Running Tests + +```bash +# Run only unit tests (default - no build tags) +go test ./... + +# Run unit + integration tests +go test -tags=integration ./... + +# Run specific package integration tests +go test -tags=integration ./user + +# Run system tests only +go test ./tests/... + +# Run all tests +go test -tags=integration ./... +``` + +## Makefile/Taskfile Integration + +### Taskfile.yml Example + +```yaml +version: '3' + +tasks: + test: + desc: Run unit tests + cmds: + - go test -v -race ./... + + test:integration: + desc: Run integration tests + cmds: + - go test -v -race -tags=integration ./... + + test:system: + desc: Run system tests + cmds: + - go test -v -race ./tests/... + + test:all: + desc: Run all tests + cmds: + - task: test:integration + - task: test:system + + test:coverage: + desc: Run tests with coverage + cmds: + - go test -v -race -coverprofile=coverage.out ./... + - go tool cover -html=coverage.out -o coverage.html +``` + +### Makefile Example + +```makefile +.PHONY: test test-integration test-system test-all coverage + +test: + go test -v -race ./... + +test-integration: + go test -v -race -tags=integration ./... + +test-system: + go test -v -race ./tests/... + +test-all: test-integration test-system + +coverage: + go test -v -race -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html +``` + +## Test File Naming + +### Unit Tests +- `*_test.go` - Standard test files +- Located next to the code being tested +- Use `pkg_test` package name + +### Integration Tests +- `integration_test.go` or `*_integration_test.go` +- Use `//go:build integration` tag +- Can be in same directory or separate `integration/` folder +- Use `pkg_test` package name + +### System Tests +- `*_test.go` in `tests/` directory at project root +- No build tags needed (separate directory) +- Use `tests` or `main_test` package name + +## CI/CD Integration + +### GitHub Actions Example + +```yaml +name: Tests + +on: [push, pull_request] + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v4 + with: + go-version: '1.21' + - name: Run unit tests + run: go test -v -race ./... + + integration-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v4 + with: + go-version: '1.21' + - name: Run integration tests + run: go test -v -race -tags=integration ./... + + system-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v4 + with: + go-version: '1.21' + - name: Build application + run: go build -o myapp ./cmd/myapp + - name: Run system tests + run: go test -v -race ./tests/... +``` + +## testutils Package Structure + +``` +internal/testutils/ +├── nats.go # NATS in-memory server helpers +├── victoria.go # Victoria Metrics binary management +├── prometheus.go # Prometheus payload helpers +├── grpc_client_mock.go # gRPC client mock with DSL +├── jrpc_server_mock.go # JSON-RPC server mock with DSL +└── httpserver/ # HTTP mock server with DSL + ├── server.go + ├── server_test.go # Test the infrastructure! + ├── dsl.go + └── README.md +``` + +## Key Principles + +1. **Co-locate unit tests** - Next to the code being tested +2. **Use pkg_test package** - Forces public API testing +3. **Build tags for integration** - Keep unit tests fast by default +4. **Separate system tests** - In `tests/` directory +5. **Test your test infrastructure** - Treat testutils as production code +6. **Reusable infrastructure** - Share across all test levels diff --git a/lang/go/passthrough/skills/testing/examples/victoria-metrics.md b/lang/go/passthrough/skills/testing/examples/victoria-metrics.md new file mode 100644 index 0000000..5279fcc --- /dev/null +++ b/lang/go/passthrough/skills/testing/examples/victoria-metrics.md @@ -0,0 +1,568 @@ +# Victoria Metrics Binary Test Server + +Rung: 1 (one real layer over the code under test — the real VictoriaMetrics binary; out-of-process is Dependency Level 2, an orthogonal heaviness axis, not extra composition depth) + +## When to Use This Example + +Use this when: +- Testing Prometheus Remote Write integrations +- Need real Victoria Metrics for testing metrics ingestion +- Testing PromQL queries +- Want production-like behavior without Docker +- Testing metrics pipelines end-to-end + +**Dependency Level**: Level 2 (Binary) - Standalone executable via `exec.Command` + +**Why Binary Instead of In-Memory:** +- Victoria Metrics is complex; reimplementing as in-memory mock isn't practical +- Need real PromQL engine behavior +- Need actual data persistence and querying +- Binary startup is fast (< 1 second) and requires no Docker + +## Implementation + +### Victoria Server Infrastructure + +This example shows how to download, manage, and run Victoria Metrics binary for testing: + +```go +// internal/testutils/victoria.go +package testutils + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "time" + + "github.com/projectdiscovery/freeport" +) + +const ( + DefaultVictoriaMetricsVersion = "v1.128.0" + VictoriaMetricsVersionEnvVar = "TEST_VICTORIA_METRICS_VERSION" +) + +var ( + ErrVictoriaMetricsNotHealthy = errors.New("victoria metrics did not become healthy") + ErrDownloadFailed = errors.New("download failed") + + // binaryDownloadMu protects concurrent downloads (prevent race conditions) + binaryDownloadMu sync.Mutex +) + +// VictoriaServer represents a running Victoria Metrics test instance +type VictoriaServer struct { + cmd *exec.Cmd + port int + dataPath string + writeURL string + queryURL string + version string + binaryPath string + shutdownOnce sync.Once + shutdownErr error +} + +// WriteURL returns the URL for writing metrics (Prometheus Remote Write endpoint) +func (vs *VictoriaServer) WriteURL() string { + return vs.writeURL +} + +// QueryURL returns the URL for querying metrics (Prometheus-compatible query endpoint) +func (vs *VictoriaServer) QueryURL() string { + return vs.queryURL +} + +// Port returns the port Victoria Metrics is listening on +func (vs *VictoriaServer) Port() int { + return vs.port +} + +// ForceFlush forces Victoria Metrics to flush buffered samples from memory to disk, +// making them immediately queryable. This is useful for testing to avoid waiting +// for the automatic flush cycle. +func (vs *VictoriaServer) ForceFlush(ctx context.Context) error { + url := fmt.Sprintf("http://localhost:%d/internal/force_flush", vs.port) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create force flush request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to force flush: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("force flush failed: status %d", resp.StatusCode) + } + + return nil +} + +// Shutdown stops Victoria Metrics and cleans up resources. +// Safe to call multiple times (idempotent). +func (vs *VictoriaServer) Shutdown() error { + vs.shutdownOnce.Do(func() { + if vs.cmd == nil || vs.cmd.Process == nil { + return + } + + // Send interrupt signal for graceful shutdown + if err := vs.cmd.Process.Signal(os.Interrupt); err != nil { + vs.shutdownErr = err + return + } + + // Wait for process to exit (with timeout) + done := make(chan error, 1) + go func() { + done <- vs.cmd.Wait() + }() + + select { + case <-time.After(5 * time.Second): + vs.cmd.Process.Kill() + vs.shutdownErr = errors.New("shutdown timeout") + case err := <-done: + if err != nil && err.Error() != "signal: interrupt" { + vs.shutdownErr = err + } + } + + // Cleanup data directory + if vs.dataPath != "" { + os.RemoveAll(vs.dataPath) + } + }) + return vs.shutdownErr +} + +// RunVictoriaMetricsServer starts a Victoria Metrics instance for testing. +// It downloads the binary if needed, starts the server, and waits for it to be healthy. +func RunVictoriaMetricsServer() (*VictoriaServer, error) { + version := getVictoriaMetricsVersion() + + // Ensure binary exists (downloads if missing) + binaryPath, err := ensureVictoriaBinary(version) + if err != nil { + return nil, err + } + + // Get free port (prevents conflicts in parallel tests) + freePort, err := freeport.GetFreePort("127.0.0.1", freeport.TCP) + if err != nil { + return nil, fmt.Errorf("failed to get free port: %w", err) + } + port := freePort.Port + + // Create temporary data directory + dataPath, err := os.MkdirTemp("", "victoria-metrics-test-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp directory: %w", err) + } + + // Start Victoria Metrics + cmd := exec.Command( + binaryPath, + fmt.Sprintf("-httpListenAddr=:%d", port), + "-storageDataPath="+dataPath, + "-retentionPeriod=1d", + "-inmemoryDataFlushInterval=1ms", // Force immediate data flush for testing + ) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + os.RemoveAll(dataPath) + return nil, fmt.Errorf("failed to start victoria metrics: %w", err) + } + + baseURL := fmt.Sprintf("http://localhost:%d", port) + server := &VictoriaServer{ + cmd: cmd, + port: port, + dataPath: dataPath, + writeURL: baseURL + "/api/v1/write", + queryURL: baseURL + "/api/v1/query", + version: version, + binaryPath: binaryPath, + } + + // Wait for server to become healthy + if err := waitForHealth(baseURL); err != nil { + server.Shutdown() + return nil, err + } + + return server, nil +} + +func getVictoriaMetricsVersion() string { + if version := os.Getenv(VictoriaMetricsVersionEnvVar); version != "" { + return version + } + return DefaultVictoriaMetricsVersion +} + +// ensureVictoriaBinary ensures the Victoria Metrics binary exists, downloading if necessary. +// Thread-safe with double-check locking to prevent race conditions. +func ensureVictoriaBinary(version string) (string, error) { + binaryName := fmt.Sprintf("victoria-metrics-%s-%s-%s", version, runtime.GOOS, getVMArch()) + binaryPath := filepath.Join(".bin", binaryName) + + // Quick check without lock (optimization) + if _, err := os.Stat(binaryPath); err == nil { + return binaryPath, nil + } + + // Acquire lock to prevent concurrent downloads + binaryDownloadMu.Lock() + defer binaryDownloadMu.Unlock() + + // Double-check after acquiring lock (another goroutine might have downloaded it) + if _, err := os.Stat(binaryPath); err == nil { + return binaryPath, nil + } + + // Create .bin directory + if err := os.MkdirAll(".bin", 0755); err != nil { + return "", fmt.Errorf("failed to create .bin directory: %w", err) + } + + // Download to temporary location with unique name + tempPath := fmt.Sprintf("%s.tmp.%d", binaryPath, os.Getpid()) + defer os.Remove(tempPath) + + downloadURL := fmt.Sprintf( + "https://github.com/VictoriaMetrics/VictoriaMetrics/releases/download/%s/victoria-metrics-%s-%s-%s.tar.gz", + version, runtime.GOOS, getVMArch(), version, + ) + + if err := downloadAndExtract(downloadURL, tempPath); err != nil { + return "", fmt.Errorf("failed to download: %w", err) + } + + if err := os.Chmod(tempPath, 0755); err != nil { + return "", fmt.Errorf("failed to make binary executable: %w", err) + } + + // Atomic rename - only one goroutine succeeds if multiple try + if err := os.Rename(tempPath, binaryPath); err != nil { + // If rename fails, check if another goroutine succeeded + if _, statErr := os.Stat(binaryPath); statErr == nil { + return binaryPath, nil // Another goroutine won the race + } + return "", fmt.Errorf("failed to rename binary: %w", err) + } + + return binaryPath, nil +} + +func getVMArch() string { + switch runtime.GOARCH { + case "amd64": + return "amd64" + case "arm64": + return "arm64" + default: + return runtime.GOARCH + } +} + +func waitForHealth(baseURL string) error { + healthURL := baseURL + "/health" + maxRetries := 30 + retryInterval := time.Second + + ctx := context.Background() + for range maxRetries { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) + if err != nil { + time.Sleep(retryInterval) + continue + } + + resp, err := http.DefaultClient.Do(req) + if err == nil { + statusOK := resp.StatusCode == http.StatusOK + resp.Body.Close() + if statusOK { + return nil + } + } + + time.Sleep(retryInterval) + } + + return ErrVictoriaMetricsNotHealthy +} +``` + +### Helper Functions for Prometheus/Victoria Metrics Testing + +Add practical helpers that make tests clear and maintainable: + +```go +// internal/testutils/prometheus.go +package testutils + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "testing" + "time" + + "github.com/gogo/protobuf/proto" + "github.com/golang/snappy" + "github.com/prometheus/prometheus/prompb" + "github.com/stretchr/testify/require" +) + +var ( + ErrQueryFailed = errors.New("victoria metrics query failed") + ErrQueryNonSuccess = errors.New("query returned non-success status") +) + +// CreatePrometheusPayload creates a valid Prometheus Remote Write payload +// with a sample metric. The payload is protobuf-encoded and snappy-compressed, +// ready to be sent to Victoria Metrics' /api/v1/write endpoint. +func CreatePrometheusPayload(metricName string, value float64, labels map[string]string) ([]byte, error) { + // Create timestamp (current time in milliseconds) + timestampMs := time.Now().UnixMilli() + + // Build label pairs + labelPairs := make([]prompb.Label, 0, len(labels)+1) + labelPairs = append(labelPairs, prompb.Label{ + Name: "__name__", + Value: metricName, + }) + for name, val := range labels { + labelPairs = append(labelPairs, prompb.Label{ + Name: name, + Value: val, + }) + } + + // Create a single time series with one sample + timeseries := []prompb.TimeSeries{ + { + Labels: labelPairs, + Samples: []prompb.Sample{ + { + Value: value, + Timestamp: timestampMs, + }, + }, + }, + } + + // Create WriteRequest + writeRequest := &prompb.WriteRequest{ + Timeseries: timeseries, + } + + // Marshal to protobuf + data, err := proto.Marshal(writeRequest) + if err != nil { + return nil, fmt.Errorf("failed to marshal protobuf: %w", err) + } + + // Compress with snappy + compressed := snappy.Encode(nil, data) + + return compressed, nil +} + +// VMQueryResult represents a single result from a Victoria Metrics query. +type VMQueryResult struct { + Metric map[string]string // label name -> label value + Value []any // [timestamp, value_string] +} + +// VMQueryResponse represents the full Victoria Metrics API response. +type VMQueryResponse struct { + Status string `json:"status"` + Data struct { + ResultType string `json:"result_type"` + Result []VMQueryResult `json:"result"` + } `json:"data"` +} + +// QueryVictoriaMetrics executes a PromQL query against Victoria Metrics. +// The query is performed via the /api/v1/query endpoint with time buffer +// for clock skew and delayed indexing. +func QueryVictoriaMetrics(queryURL, query string) ([]VMQueryResult, error) { + // Query with current time + 1 minute to catch any clock skew or delayed indexing + currentTime := time.Now().Add(1 * time.Minute) + fullURL := fmt.Sprintf("%s?query=%s&time=%d", queryURL, url.QueryEscape(query), currentTime.Unix()) + + // Execute HTTP request + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute query: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: %s", ErrQueryFailed, resp.Status) + } + + // Read response body + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Parse JSON response + var queryResp VMQueryResponse + if err := json.Unmarshal(bodyBytes, &queryResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if queryResp.Status != "success" { + return nil, fmt.Errorf("%w: %s", ErrQueryNonSuccess, queryResp.Status) + } + + return queryResp.Data.Result, nil +} + +// AssertLabelExists checks if at least one result contains a label with the given name and value. +// Fails the test if the label is not found. +func AssertLabelExists(t *testing.T, results []VMQueryResult, labelName, labelValue string) { + t.Helper() + + for _, result := range results { + if val, exists := result.Metric[labelName]; exists && val == labelValue { + return // Found it! + } + } + + // Label not found - fail with helpful message + require.Fail(t, "Label not found", + "Expected to find label %s=%s in query results, but it was not present", + labelName, labelValue) +} +``` + +## Usage Examples + +### Integration Test + +```go +// internal/api/stats/prometheus_ingest_test.go +func TestPrometheusIngest_WithVictoriaMetrics(t *testing.T) { + // Start real Victoria Metrics server (Level 2) + vmServer, err := testutils.RunVictoriaMetricsServer() + require.NoError(t, err) + defer vmServer.Shutdown() + + // Create valid Prometheus payload using helper + payload, err := testutils.CreatePrometheusPayload("test_metric", 42.0, map[string]string{ + "service": "api", + "env": "test", + }) + require.NoError(t, err) + + // Send to Victoria Metrics + req := httptest.NewRequest(http.MethodPost, vmServer.WriteURL(), bytes.NewBuffer(payload)) + req.Header.Set("Content-Type", "application/x-protobuf") + req.Header.Set("Content-Encoding", "snappy") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Force flush to make data queryable immediately + err = vmServer.ForceFlush(context.Background()) + require.NoError(t, err) + + // Query using helper + results, err := testutils.QueryVictoriaMetrics(vmServer.QueryURL(), `test_metric{service="api"}`) + require.NoError(t, err) + require.Len(t, results, 1) + + // Assert using helper + testutils.AssertLabelExists(t, results, "env", "test") +} +``` + +### System Test + +```go +// tests/prometheus_ingestion_test.go +func TestE2E_PrometheusIngestion(t *testing.T) { + // Same Victoria Metrics infrastructure! + vmServer, err := testutils.RunVictoriaMetricsServer() + require.NoError(t, err) + defer vmServer.Shutdown() + + // Test CLI against real Victoria Metrics + cmd := exec.Command("./myapp", "ingest", + "--metrics-url", vmServer.WriteURL(), + "--metric-name", "cli_test_metric", + "--value", "100") + + output, err := cmd.CombinedOutput() + require.NoError(t, err) + assert.Contains(t, string(output), "Metric ingested successfully") + + // Verify with helpers + vmServer.ForceFlush(context.Background()) + results, err := testutils.QueryVictoriaMetrics(vmServer.QueryURL(), "cli_test_metric") + require.NoError(t, err) + require.Len(t, results, 1) +} +``` + +## Key Features + +- **Binary download with OS/arch detection** - Works on macOS/Linux, amd64/arm64 +- **Thread-safe download** - Mutex + double-check locking prevents race conditions +- **Free port allocation** - Prevents conflicts in parallel tests +- **Idempotent shutdown** - Safe to call multiple times with `sync.Once` +- **Resource cleanup** - Proper temp directory and process cleanup +- **Helper functions** - `ForceFlush()` for immediate data availability +- **Prometheus helpers** - Create payloads, query, assert on results + +## Benefits + +- **Production-like testing** - Testing against REAL Victoria Metrics, not mocks +- **Reusable** - Same `testutils` infrastructure for unit, integration, and system tests +- **Readable** - Helper functions make tests read like documentation +- **No Docker** - No Docker required, works in any environment +- **Fast** - Binary starts in < 1 second +- **Portable** - Works anywhere Go runs +- **Maintainable** - Changes to test infrastructure are centralized + +## Key Takeaways + +1. **Binary level is good for complex services** - When in-memory is too complex +2. **Download management is critical** - Thread-safe, cached, version-controlled +3. **Helper functions make tests readable** - DSL for common operations +4. **Reuse across test levels** - Same infrastructure for unit, integration, system +5. **Force flush is essential** - Make data immediately queryable in tests diff --git a/lang/go/passthrough/skills/testing/reference.md b/lang/go/passthrough/skills/testing/reference.md new file mode 100644 index 0000000..418d91a --- /dev/null +++ b/lang/go/passthrough/skills/testing/reference.md @@ -0,0 +1,676 @@ +# Testing Reference + +Complete guide to Go testing principles and patterns. + +## Contents + +- [Core Testing Principles](#core-testing-principles) +- [Table-Driven Tests](#table-driven-tests) · [Testify Suites](#testify-suites) +- [Synchronization in Tests](#synchronization-in-tests) · [Test Organization](#test-organization) +- [Real Implementation Patterns](#real-implementation-patterns) · [Testable Examples](#testable-examples-godoc-examples) +- [Testing Checklist](#testing-checklist) · [Summary](#summary) +- Pattern index (by rung): [1 In-Memory Harness](#pattern-1-in-memory-test-harness-rung-1) · [2 Binary Dependency](#pattern-2-binary-dependency-management-rung-1) · [3 Fake Server DSL](#pattern-3-fake-server-with-generic-dsl-rung-1) · [4 Bidirectional Streaming](#pattern-4-bidirectional-streaming-with-rich-dsl-rung-1) · [5 HTTP DSL/Builder](#pattern-5-http-dsl-and-builder-pattern-rung-1) · [6 Test Organization](#pattern-6-test-organization-and-structure-all-rungs) · [7 Integration Workflows](#pattern-7-integration-test-workflows-rungs-1-2) · [8 System Test](#pattern-8-system-test-black-box-top-rung) +- [How Claude Should Use These Files](#how-claude-should-use-these-files) · [Final Notes](#final-notes) + +## Core Testing Principles + +### 1. Test Only Public API +- **Use `pkg_test` package name** - Forces external perspective +- **Test types via constructors** - No direct struct initialization +- **No testing private methods** - the urge to test a helper directly is a promotion signal: give it its own package (see `../../rules/R4-helper-placement.md`) — never export it into the parent just for tests + +```go +// ✅ Good +package user_test + +import "github.com/yourorg/project/user" + +func TestService_CreateUser(t *testing.T) { + svc, _ := user.NewUserService(repo, notifier) + err := svc.CreateUser(ctx, testUser) + // ... +} +``` + +### 2. Avoid Mocks - Use Real Implementations + +Instead of mocks, use: +- **HTTP test servers** (`httptest` package) +- **Temp files/directories** (`os.CreateTemp`, `os.MkdirTemp`) +- **In-memory databases** (SQLite in-memory, or custom implementations) +- **Test implementations** (TestEmailer that writes to buffer) + +**Benefits:** +- Tests are more reliable +- Tests verify actual behavior +- Easier to maintain + +**What the fake cannot catch:** with fake-backed tests (a mock server standing in +for the true external boundary), the author writes both sides of the wire — so +contract drift between your client and the real service is invisible: the fake +keeps agreeing with the client no matter how wrong both are. Mitigate it: +- Cross-check the fake against the source of truth (API spec, proto file, recorded + real responses) whenever either side changes. +- Keep one real smoke test against the actual service (tagged/optional in CI) so + drift eventually surfaces. + +### 3. Coverage Strategy — by rung + +The composition ladder is defined in SKILL.md; coverage follows it: + +- **Rung 0 (leaf types)**: 100% unit test coverage — core logic must be bulletproof, and rung-0 tables are the cheapest tests you will ever write. +- **Higher rungs (orchestrators, composed layers)**: cover the delta each rung adds — the seams/wiring and behaviors that only exist through composition — not a re-test of lower-rung logic. Some overlap with leaf coverage is acceptable for orchestrators. +- **Goal**: most logic in leaf types, so most coverage lives at rung 0. + +--- + +## Table-Driven Tests + +### When to Use +- Each test case has **cyclomatic complexity = 1** +- No conditionals inside t.Run() +- Simple, focused testing scenarios + +### ✅ Correct Pattern: Separate Functions + +**Always separate success and error cases.** Folding them into one table forces a +conditional inside `t.Run()` — the canonical violation (an error-flag bool field) +and its detection commands live in `../../rules/R7-test-placement.md` +(falsifying question 1). The split looks like this: + +```go +// ✅ Success cases - Complexity = 1 +func TestNewUserID_Success(t *testing.T) { + tests := []struct { + name string + input string + want UserID + }{ + {name: "valid ID", input: "usr_123", want: UserID("usr_123")}, + {name: "with numbers", input: "usr_456", want: UserID("usr_456")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewUserID(tt.input) + require.NoError(t, err) // ✅ No conditionals + assert.Equal(t, tt.want, got) + }) + } +} + +// ✅ Error cases - Complexity = 1 +func TestNewUserID_Error(t *testing.T) { + tests := []struct { + name string + input string + }{ + {name: "empty ID", input: ""}, + {name: "whitespace only", input: " "}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewUserID(tt.input) + assert.Error(t, err) // ✅ No conditionals + }) + } +} +``` + +### Critical Rule: Named Struct Fields + +**ALWAYS use named struct fields** - Linter reorders fields, breaking unnamed initialization: + +```go +// ❌ BAD - Breaks when linter reorders fields +tests := []struct { + name string + input int + want string +}{ + {"test1", 42, "result"}, // Will break +} + +// ✅ GOOD - Works regardless of field order +tests := []struct { + name string + input int + want string +}{ + {name: "test1", input: 42, want: "result"}, // Always works +} +``` + +--- + +## Testify Suites + +### When to Use + +ONLY for complex test infrastructure setup: +- Mock HTTP servers +- Database connections +- OpenTelemetry testing setup +- Temporary files/directories needing cleanup +- Shared expensive setup/teardown + +### When NOT to Use +- Simple unit tests (use table-driven instead) +- Tests without complex setup + +### Pattern + +```go +package user_test + +import ( + "net/http/httptest" + "testing" + "github.com/stretchr/testify/suite" +) + +type ServiceSuite struct { + suite.Suite + server *httptest.Server + svc *user.UserService +} + +func (s *ServiceSuite) SetupSuite() { + s.server = httptest.NewServer(testHandler) +} + +func (s *ServiceSuite) TearDownSuite() { + s.server.Close() +} + +func (s *ServiceSuite) SetupTest() { + s.svc = user.NewUserService(s.server.URL) +} + +func (s *ServiceSuite) TestCreateUser() { + err := s.svc.CreateUser(ctx, testUser) + s.NoError(err) +} + +func TestServiceSuite(t *testing.T) { + suite.Run(t, new(ServiceSuite)) +} +``` + +--- + +## Synchronization in Tests + +### Never Use time.Sleep + +Use channels or WaitGroups instead. + +### Use Channels + +```go +func TestAsyncOperation(t *testing.T) { + done := make(chan struct{}) + + go func() { + doAsyncWork() + close(done) + }() + + select { + case <-done: + // Success + case <-time.After(1 * time.Second): + t.Fatal("timeout") + } +} +``` + +### Use WaitGroups + +```go +func TestConcurrentOperations(t *testing.T) { + var wg sync.WaitGroup + results := make([]string, 10) + + for i := 0; i < 10; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + results[index] = doWork(index) + }(i) + } + + wg.Wait() + // Assert on results +} +``` + +--- + +## Test Organization + +### File Structure + +``` +user/ +├── user.go +├── user_test.go # Tests for user.go (pkg_test) +├── service.go +├── service_test.go # Tests for service.go (pkg_test) +``` + +### Package Naming + +```go +// ✅ External package - tests public API only +package user_test + +import ( + "testing" + "github.com/yourorg/project/user" +) +``` + +--- + +## Real Implementation Patterns + +### In-Memory Repository + +```go +package user + +type InMemoryRepository struct { + mu sync.RWMutex + users map[UserID]User +} + +func NewInMemoryRepository() *InMemoryRepository { + return &InMemoryRepository{ + users: make(map[UserID]User), + } +} + +func (r *InMemoryRepository) Save(ctx context.Context, u User) error { + r.mu.Lock() + defer r.mu.Unlock() + r.users[u.ID] = u + return nil +} + +func (r *InMemoryRepository) Get(ctx context.Context, id UserID) (*User, error) { + r.mu.RLock() + defer r.mu.RUnlock() + u, ok := r.users[id] + if !ok { + return nil, ErrNotFound + } + return &u, nil +} +``` + +### Test Email Sender + +```go +package user + +import ( + "bytes" + "fmt" + "sync" +) + +type TestEmailer struct { + mu sync.Mutex + buffer bytes.Buffer +} + +func NewTestEmailer() *TestEmailer { + return &TestEmailer{} +} + +func (e *TestEmailer) Send(to Email, subject, body string) error { + e.mu.Lock() + defer e.mu.Unlock() + fmt.Fprintf(&e.buffer, "To: %s\nSubject: %s\n%s\n\n", to, subject, body) + return nil +} + +func (e *TestEmailer) SentEmails() string { + e.mu.Lock() + defer e.mu.Unlock() + return e.buffer.String() +} +``` + +--- + +## Testable Examples (GoDoc Examples) + +### When to Add +- Non-trivial types +- Types with validation +- Common usage patterns + +### Pattern + +```go +// Example_UserID demonstrates basic usage. +func Example_UserID() { + id, _ := user.NewUserID("usr_123") + fmt.Println(id) + // Output: usr_123 +} + +// Example_UserID_validation shows validation behavior. +func Example_UserID_validation() { + _, err := user.NewUserID("") + fmt.Println(err != nil) + // Output: true +} +``` + +--- + +## Testing Checklist + +### Before Considering Tests Complete + +**Structure:** +- [ ] Tests in `pkg_test` package +- [ ] Testing public API only +- [ ] Table-driven tests use named fields +- [ ] No conditionals in test cases + +**Implementation:** +- [ ] Using real implementations, not mocks +- [ ] No time.Sleep (using channels/waitgroups) +- [ ] Testify suites only for complex setup + +**Coverage:** +- [ ] Rung 0 (leaf types): 100% unit test coverage +- [ ] Higher rungs: each rung's delta covered (seams, emergent behaviors) +- [ ] Happy path, edge cases, error cases covered + +--- + +## Summary + +**The Golden Rule**: Cyclomatic complexity = 1 in all test cases + +**Test Structure Choices:** +- **Table-driven tests**: Simple, focused scenarios +- **Testify suites**: Complex infrastructure setup only + +**Test Philosophy:** +- Test only public API (`pkg_test` package) +- Use real implementations, not mocks +- Rung 0 (leaf types): 100% coverage +- Higher rungs: cover each rung's delta (seams, emergent behaviors) + +**Common Pitfalls to Avoid:** +- ❌ Testing private methods +- ❌ Heavy mocking +- ❌ time.Sleep in tests +- ❌ Conditionals in test cases +- ❌ Unnamed struct fields in table tests + +--- + +# Example Files - Reusable Testing Patterns + +The following example files contain **transferable patterns** that apply to many scenarios, not just the specific technologies shown. Claude should read these files based on the **pattern needed**, not the specific technology mentioned. Each pattern is tagged with the composition-ladder rung it serves (matching the `Rung:` tag inside each file; ladder defined in SKILL.md). Rung measures composition depth — how many real production layers the test composes — and is orthogonal to dependency heaviness (in-memory vs binary vs containers): a real binary wired directly to the code under test is still one real layer, Rung 1. + +## Pattern 1: In-Memory Test Harness (Rung 1) + +**File**: `examples/nats-in-memory.md` + +**Pattern**: Using official test harnesses from Go libraries + +**When to read:** +- Need to test with ANY service that provides an official Go test harness +- Testing message queues, databases, caches, or any service with in-memory test mode +- Want to avoid Docker but need realistic service behavior + +**Applies to:** +- **NATS** (shown in example) - Message queue with official test harness +- **Redis** - `github.com/alicebob/miniredis` pure Go in-memory Redis +- **MongoDB** - `github.com/tryvium-travels/memongo` in-memory MongoDB +- **PostgreSQL** - `github.com/jackc/pgx/v5` with pgx mock +- **Any Go library with test package** - Check if dependency has `/test` package + +**Key techniques to adapt:** +- Wrapping official harness with clean API +- Free port allocation for parallel tests +- Clean lifecycle management (Setup/Teardown) +- Thread-safe initialization + +--- + +## Pattern 2: Binary Dependency Management (Rung 1) + +**File**: `examples/victoria-metrics.md` + +**Pattern**: Download, manage, and run ANY standalone binary for testing + +**When to read:** +- Need to test against ANY external binary executable +- No in-memory option available +- Want production-like testing without Docker + +**Applies to:** +- **Victoria Metrics** (shown in example) - Metrics database +- **Prometheus** - Metrics and alerting +- **Grafana** - Dashboards and visualization +- **Any database binaries** - PostgreSQL, MySQL, Redis, etc. +- **Any CLI tools** - Language servers, formatters, linters +- **Custom binaries** - Your own services or third-party tools + +**Key techniques to adapt:** +- OS/ARCH detection (`runtime.GOOS`, `runtime.GOARCH`) +- Thread-safe binary downloads with double-check locking +- Health check polling with retries +- Graceful shutdown with `sync.Once` +- Free port allocation +- Temp directory management +- Version management via environment variables + +--- + +## Pattern 3: Fake Server with Generic DSL (Rung 1) + +**File**: `examples/jsonrpc-mock.md` + +**Pattern**: Building generic fake servers — real `httptest` servers with configurable responses — using `AddMockResponse()` (the DSL keeps the real API's historical name; per SKILL.md terminology these are fakes, not interface-injected mocks) + +**When to read:** +- Need to fake ANY request/response protocol +- Want readable test setup with DSL +- Testing clients that call external APIs + +**Applies to:** +- **JSON-RPC** (shown in example) - RPC over HTTP +- **REST APIs** - Use same pattern with route matching +- **GraphQL** - Configure response per query +- **gRPC** - Adapt for protobuf messages +- **WebSocket** - Mock message responses +- **Any HTTP-based protocol** - SOAP, XML-RPC, custom protocols + +**Key techniques to adapt:** +- Generic `AddMockResponse(identifier, response)` pattern +- Using `httptest.Server` as foundation +- Query/request tracking for assertions +- Configuration-based response mapping +- Thread-safe response storage + +--- + +## Pattern 4: Bidirectional Streaming with Rich DSL (Rung 1) + +**File**: `examples/grpc-bufconn.md` + +**Pattern**: In-memory bidirectional communication with rich client/server mocks + +**When to read:** +- Testing ANY bidirectional streaming protocol +- Need full-duplex communication in tests +- Want to avoid network I/O + +**Applies to:** +- **gRPC** (shown in example) - Uses bufconn for in-memory +- **WebSockets** - Adapt bufconn pattern +- **TCP streams** - Custom protocols over TCP +- **Unix sockets** - Inter-process communication +- **Any streaming protocol** - Server-Sent Events, HTTP/2 streams + +**Key techniques to adapt:** +- `bufconn` for in-memory connections (gRPC-specific, but concept applies) +- Rich mock objects with helper methods +- Thread-safe state tracking with mutexes +- Assertion helpers (`ListenToStreamAndAssert()`) +- When testing **server** → mock the **clients** +- When testing **client** → mock the **server** + +--- + +## Pattern 5: HTTP DSL and Builder Pattern (Rung 1) + +**File**: `examples/httptest-dsl.md` + +**Pattern**: Building readable test infrastructure with DSL wrappers over stdlib + +**When to read:** +- Want to wrap ANY test infrastructure with clean DSL +- Need fluent, readable test setup +- Building reusable test utilities + +**Applies to:** +- **HTTP mocking** (shown in example) - httptest.Server wrapper +- **Any test infrastructure** - Databases, queues, file systems +- **Test data builders** - Fluent APIs for creating test data +- **Custom test harnesses** - Wrapping complex setups + +**Key techniques to adapt:** +- Builder pattern with method chaining +- Fluent API design (`OnGET().RespondJSON()`) +- Separating configuration from execution +- Type-safe builders with Go generics +- Hiding complexity behind clean interfaces + +--- + +## Pattern 6: Test Organization and Structure (All rungs) + +**File**: `examples/test-organization.md` + +**When to read:** +- Setting up test structure for new projects +- Adding build tags for integration tests +- Configuring CI/CD for tests +- Creating testutils package structure + +**Universal patterns** (not technology-specific): +- File organization (`pkg_test` package naming) +- Build tags (`//go:build integration`) +- Makefile/Taskfile structure +- CI/CD configuration +- testutils package layout + +--- + +## Pattern 7: Integration Test Workflows (Rungs 1-2) + +**File**: `examples/integration-patterns.md` + +**When to read:** +- Testing component interactions across package boundaries +- Need patterns for Service + Repository testing +- Testing workflows that span multiple components + +**Universal patterns:** +- Pattern 1: Service + Repository with in-memory deps +- Pattern 2: Testing with real external services +- Pattern 3: Multi-component workflow with testify suites +- Dependency priority (in-memory > binary > test-containers) + +--- + +## Pattern 8: System Test (Black Box, Top rung) + +**File**: `examples/system-patterns.md` + +**When to read:** +- Writing black-box end-to-end tests +- Testing via CLI or API +- Need tests that work without Docker + +**Universal patterns:** +- CLI testing with `exec.Command` +- API testing with HTTP client +- Dependency injection architecture +- Pure Go testing (no Docker) + +--- + +## How Claude Should Use These Files + +### Pattern-Based Reading Rules + +**When user needs to test with external dependencies:** + +1. **Has official Go test harness?** → Read `nats-in-memory.md` + - "Test with Redis/MongoDB/PostgreSQL/NATS" + - "Avoid Docker but need real service" + - Look for inspiration on wrapping official harnesses + +2. **Need to download/run binary?** → Read `victoria-metrics.md` + - "Test with Prometheus/Grafana/any binary" + - "Manage binary dependencies" + - Learn OS/ARCH detection, download patterns, health checks + +3. **Need to mock request/response?** → Read `jsonrpc-mock.md` + - "Mock REST/GraphQL/RPC/any HTTP API" + - "Build mock with DSL" + - Learn generic `AddMockResponse()` pattern + +4. **Need bidirectional streaming?** → Read `grpc-bufconn.md` + - "Test gRPC/WebSocket/streaming protocol" + - "In-memory bidirectional communication" + - Learn rich mock patterns, thread-safe state + +5. **Want readable test DSL?** → Read `httptest-dsl.md` + - "Build fluent test API" + - "Wrap test infrastructure" + - Learn builder pattern, method chaining + +**When user asks about test structure:** +- "How should I organize tests?" → Read `test-organization.md` +- "How do I write integration tests?" → Read `integration-patterns.md` +- "How do I write system tests?" → Read `system-patterns.md` + +### Key Principle + +**Examples show specific technologies (NATS, Victoria Metrics, JSON-RPC) but teach transferable patterns.** + +Claude should: +1. Identify the **pattern needed** (harness, binary, mock DSL, etc.) +2. Read the **example file** that demonstrates that pattern +3. **Adapt the techniques** to the user's specific technology +4. Use the example as a **template**, not a literal solution + +### Default Behavior (No Example Needed) + +For simple scenarios, use the core patterns in this file: +- Basic table-driven tests → Use patterns from this file +- Simple testify suites → Use patterns from this file +- Basic synchronization → Use patterns from this file +- Simple in-memory implementations → Use InMemoryRepository/TestEmailer from this file + +**Read example files when patterns/techniques are needed, not just for specific tech.** + +--- + +## Final Notes + +This reference provides core testing principles and patterns. For detailed implementations and complete examples, refer to the example files listed above. Each example file is self-contained and can be read independently based on your testing needs. diff --git a/lang/go/profile.yaml b/lang/go/profile.yaml new file mode 100644 index 0000000..ff407e1 --- /dev/null +++ b/lang/go/profile.yaml @@ -0,0 +1,23 @@ +# The Go binding: every scalar a core template substitutes as {{.Name}}, plus +# the output paths the generator does not own. Field names are the yaml keys +# of Vars in tools/ldd-gen. +plugin: go-linter-driven-development +lang: Go +cmd_prefix: go-ldd +src_glob: "*.go" +test_glob: "_test.go" +project_marker: go.mod +nolint: "//nolint" +comment_prefix: "//" +default_test: go test ./... +default_lint: golangci-lint run +default_lint_fix: golangci-lint run --fix +# Paths in the plugin directory the generator does not own. A pattern with a +# slash matches a path or one of its parent directories; a pattern without a +# slash matches a file or directory name anywhere, in the plugin directory and +# in the sources alike. An eval run copies its cases under evals/ at run time; +# the pointer README there is copied through from passthrough/evals/README.md +# and is still checked. +ignore: + - evals/* + - .DS_Store diff --git a/lang/go/rules/R1/canonical-example.md b/lang/go/rules/R1/canonical-example.md new file mode 100644 index 0000000..1784d3e --- /dev/null +++ b/lang/go/rules/R1/canonical-example.md @@ -0,0 +1,174 @@ +Real PR code (weka/goweka#951). `kubeService` wraps a Kubernetes Service DTO and must +pick the management port: prefer the port named `weka-api`, else fall back to the +first valid port. + +### Before + +```go +func (s kubeService) managementPort() int32 { + for _, p := range s.Spec.Ports { + if p.Name == "weka-api" && p.Port > 0 && p.Port <= 65535 { + return p.Port + } + } + for _, p := range s.Spec.Ports { + if p.Port > 0 && p.Port <= 65535 { + return p.Port + } + } + return 0 +} +``` + +Four defects in twelve lines: + +- The validity rule `p.Port > 0 && p.Port <= 65535` is duplicated across the two + loops — two copies that can drift independently. +- Two abstractions exist only as unnamed boolean expressions: "valid port" and + "named management port". +- The logic lives on a K8s DTO, so it is testable only by constructing a + `kubeService` around a full Service object. +- `return 0` is a sentinel: validity is encoded in-band, and every caller must know + that `0` means "none". + +### Stage 1 — self-validating types with constructors + +```go +// ServicePort is the wire DTO. Its fields stay exported with json tags — +// unexported fields with json tags silently fail to unmarshal (encoding/json +// skips them without error, and every port reads as zero). +type ServicePort struct { + Name string `json:"name"` + Port int32 `json:"port"` +} + +// Port is a named, validated service port. It cannot exist out of range, +// so no downstream code ever re-checks it. +type Port struct { + name string + number int32 +} + +func ParsePort(name string, number int32) (Port, error) { + if number <= 0 || number > 65535 { + return Port{}, fmt.Errorf("port %q: %d out of range 1-65535", name, number) + } + return Port{name: name, number: number}, nil +} + +func (p Port) Name() string { return p.name } +func (p Port) Number() int32 { return p.number } + +// Ports is a collection of valid ports. +type Ports []Port + +// ParsePorts drops invalid wire entries — a documented decision that mirrors +// the original skip-and-fall-back semantics: an invalid port was never chosen +// before; now it never exists. +func ParsePorts(wire []ServicePort) Ports { + ports := make(Ports, 0, len(wire)) + for _, w := range wire { + p, err := ParsePort(w.Name, w.Port) + if err != nil { + continue + } + ports = append(ports, p) + } + return ports +} + +func (ps Ports) FirstNamed(name string) (Port, bool) { + for _, p := range ps { + if p.name == name { + return p, true + } + } + return Port{}, false +} + +func (ps Ports) First() (Port, bool) { + if len(ps) == 0 { + return Port{}, false + } + return ps[0], true +} + +// Management prefers the port named "weka-api", else the first valid port. +// (Stage 2 relocates this method — the "weka-api" preference is feature +// policy, not networking vocabulary.) +func (ps Ports) Management() (Port, bool) { + if p, ok := ps.FirstNamed("weka-api"); ok { + return p, true + } + return ps.First() +} +``` + +The payoff, stated plainly: notice what was **not** written. There is no `IsValid()` +method and no validity loop anywhere. Self-validation does not move the +`> 0 && <= 65535` check somewhere tidier — it **deletes the concept of a +maybe-invalid port from downstream logic**. Every `Port` inside a `Ports` is valid by +construction, so "find the first valid port" collapses to "find the first port". And +`Management()` returns `(Port, bool)` comma-ok — never a `0` sentinel that smuggles +validity back in-band. + +### Stage 2 — placement (R4 rung 3) + +`Port`, `Ports`, `FirstNamed`, `First` say nothing about Kubernetes or Weka — they +are generic networking vocabulary, so they move to `internal/pkg/networking` +(rung 3 of `R4-helper-placement.md`). The wire adapter `ParsePorts` knows the K8s +DTO, so it stays with the feature. The feature policy stays home as a four-line +storified method: + +```go +func (s kubeService) managementPort() (networking.Port, bool) { + if p, ok := s.ports.FirstNamed(kubeWekaAPIPort); ok { return p, true } + return s.ports.First() +} +``` + +Teaching point: **promote only the domain-generic parts.** The `"weka-api"` constant +is feature policy and stays in the feature — a shared package that knows one +feature's port names is not shared vocabulary, it is leaked policy. + +### Stage 3 — testing contrast + +Before, exercising `managementPort()` meant constructing a `kubeService` around a +full Service fixture — building a Kubernetes object to check a range predicate. +After, the logic is a leaf and its rung-0 unit tests (the composition ladder's +bottom rung — see @testing) are slice literals against `networking.Ports`; no +big-object construction: + +```go +func TestPorts_FirstNamed(t *testing.T) { + api := mustPort(t, "weka-api", 14000) + web := mustPort(t, "http", 80) + + got, ok := networking.Ports{web, api}.FirstNamed("weka-api") + + require.True(t, ok) + assert.Equal(t, api, got) +} + +func mustPort(t *testing.T, name string, number int32) networking.Port { + t.Helper() + p, err := networking.ParsePort(name, number) + require.NoError(t, err) + return p +} +``` + +### The opposite failure: don't over-extract + +```go +// ❌ Ceremony, not a type: no rule, no behavior — the only method unwraps. +type ReplicaCount int + +func (c ReplicaCount) Int() int { return int(c) } +``` + +Score it against the scorecard below: no validation (+0), no meaningful methods (+0), +one call site (+0) → Score 0. Keep the `int`; if you want a name, a well-named +variable or an unexported helper in the same package is the whole answer +(`R4-helper-placement.md`, rung 1). Deep worked rejection with the cheaper +alternatives: `../examples/overabstraction-cidr.md`. diff --git a/lang/go/rules/R1/falsifying-questions.md b/lang/go/rules/R1/falsifying-questions.md new file mode 100644 index 0000000..0aef417 --- /dev/null +++ b/lang/go/rules/R1/falsifying-questions.md @@ -0,0 +1,38 @@ +1. **Does the diff validate a primitive inline instead of constructing a type?** + Detection: `grep -nE 'if [a-zA-Z_.]+ (==|!=) ""|if [a-zA-Z_.]+ (<=?|>=?) [0-9]' $(git diff --name-only -- '*.go')` + Violation: an emptiness/range/format check on a parameter or DTO field that names + a domain concept (port, id, email, path, addr), outside a `ParseX`/`NewX` + constructor. + +2. **Is the same predicate enforced in more than one place?** + Detection: for each predicate found above, grep its normalized form across the + package, e.g. `grep -rn '> 0 && .*<= 65535' --include='*.go' .` — count hits. + Violation: ≥2 hits — the rule has no single owner; a type is missing. + +3. **Does named behavior run on a bare primitive?** Loops/switches over `[]string`, + string-literal status comparisons, format logic on a `string` field. + Detection: `grep -rnE '== "[A-Z_]+"' --include='*.go' .` for enum-shaped + comparisons; inspect diff for loops whose body interprets a primitive. + Violation: behavior attached to a bare primitive where a named method on a type + would carry it. + +4. **Does any function return a sentinel to mean "not found / invalid"?** + Detection: grep the diff for `return 0`, `return ""`, `return -1` in functions + whose signature has no `bool` or `error` result. + Violation: validity encoded in-band — requires comma-ok or `(X, error)`. + +5. **Do the same parameters travel together across signatures?** + Detection: for each changed function with ≥3 parameters, grep the package for the + same parameter-name pair/trio in other signatures, e.g. + `grep -rnE 'func .*host string.*port int' --include='*.go' .` + Violation: the same group of ≥2–3 parameters co-occurs in ≥2 signatures — a data + clump; Introduce Parameter Object (score it: grouping-that-travels is +2 on the + scorecard, plus its usage points). + +6. **Inverse — is a NEW type in the diff mere ceremony?** + Detection: count its methods (`grep -c 'func ([a-z0-9]* *\*\?)' `) and + check whether any method does more than unwrap or rename the primitive; score it + with the scorecard above. + Violation: Score 0-1, or the only method is `return (x)` — + over-abstraction; the finding must cite the cheaper alternative + (`../examples/overabstraction-cidr.md`). diff --git a/lang/go/rules/R10/canonical-example.md b/lang/go/rules/R10/canonical-example.md new file mode 100644 index 0000000..9231150 --- /dev/null +++ b/lang/go/rules/R10/canonical-example.md @@ -0,0 +1,93 @@ +### Before — unowned goroutine, no exit path + +```go +func StartWorker(workChan <-chan Work) { + go func() { + for { + work := <-workChan + process(work) + // No way to exit this goroutine — it outlives every caller. + } + }() +} +``` + +Three defects: the goroutine loops forever (leak — when `workChan` goes quiet it +blocks on the receive until process exit), nobody holds a handle to stop or wait for +it (fire-and-forget: `StartWorker` returns nothing), and cancellation cannot reach it +(no `ctx` — the R8 sin, one level deeper). + +### After — owned, cancellable, joinable + +```go +type Worker struct { + done chan struct{} +} + +// StartWorker owns the goroutine it spawns: the returned Worker can stop it +// (via ctx) and wait for it (via Wait). +func StartWorker(ctx context.Context, workChan <-chan Work) *Worker { + w := &Worker{done: make(chan struct{})} + go func() { + defer close(w.done) + for { + select { + case work, ok := <-workChan: + if !ok { + return // channel closed — exit, don't spin on zero values + } + process(work) + case <-ctx.Done(): + return // clean exit — cancellation reaches the loop + } + } + }() + return w +} + +// Wait blocks until the worker's goroutine has fully exited. +func (w *Worker) Wait() { <-w.done } +``` + +### Second case — uncancellable backoff + unguarded shared write + +Found by a real hunter pass (2026-07-07): a deploy retry loop that paces with a bare +sleep and records results in an unsynchronized package-level map. + +```go +// ❌ Before +for attempt := 0; attempt < 3; attempt++ { + resp, err := http.Post(d.endpoint+"/deploy", "application/json", bytes.NewReader(raw)) + if err != nil { + time.Sleep(time.Duration(attempt+1) * time.Second) // cancelled caller waits anyway + continue + } + // ... + GlobalRegistry[name] = version // fatal crash if two Deploys race +} + +// ✅ After — backoff selects on ctx; state owned by one guarded type +for attempt := 0; attempt < 3; attempt++ { + resp, err := d.post(ctx, raw) + if err != nil { + if err := sleepCtx(ctx, backoff(attempt)); err != nil { + return err // cancellation cuts the backoff short + } + continue + } + // ... + d.registry.Record(name, version) // mutex lives inside Registry, next to the map +} + +func sleepCtx(ctx context.Context, d time.Duration) error { + // time.After is fine when go.mod declares go 1.23+ (the behavior is gated on + // the module's go directive, not the toolchain): an unfired timer is + // garbage-collected once unreferenced, so an early ctx exit does not retain it. + select { + case <-time.After(d): + return nil + case <-ctx.Done(): + return ctx.Err() + } +} +``` diff --git a/lang/go/rules/R10/falsifying-questions.md b/lang/go/rules/R10/falsifying-questions.md new file mode 100644 index 0000000..65778ae --- /dev/null +++ b/lang/go/rules/R10/falsifying-questions.md @@ -0,0 +1,51 @@ +1. **Does every goroutine started in the diff have a provable exit path?** + Detection: `grep -nE '\bgo\s+[a-zA-Z_][A-Za-z0-9_.]*\(|\.Go\(' ` — + catches `go func(...)`, method values (`go s.run()`), package-qualified calls, + and `errgroup`/`WaitGroup` `.Go(...)` spawns. For each hit, read the goroutine + body: a `for` loop or blocking channel op must have a `ctx.Done()`/closed-channel + `select` case, or the work must be provably bounded. + Violation: an unbounded loop or a blocking send/receive with no exit case — the + goroutine leaks. A loop with a `default:` case is equally a violation: it spins + at 100% CPU — block on the channels or a `Ticker` instead. + +2. **Can the code that starts a goroutine also stop it and wait for it?** + Detection: for each `go` site, check what the spawning function returns/exposes: + a `ctx` it honors plus a `Wait`/`Close`/`done`-channel, or an + `errgroup`/`WaitGroup` the caller holds. + Violation: fire-and-forget in library code — no caller can join the goroutine at + shutdown; leaks and lost errors are invisible. + +3. **Is shared mutable state written from a goroutine without a guard?** + Detection: for each `go func`, list writes to captured variables, receiver + fields, and maps (`grep -n -A20 'go func' `); cross-check that each written + location is guarded — `grep -nE 'sync\.(RW)?Mutex|sync\.Map|atomic\.|chan ' ` + (atomic typed values and `sync.Map` are legitimate guards for the state they + cover) — or confined to a single goroutine. Run `go test -race ./...` where tests exist, but + treat a quiet race detector as absence of evidence, not evidence of absence. + Violation: any write reachable from two goroutines with no mutex/channel + ownership — for maps this is a fatal crash, not a race that merely corrupts. + +4. **Does each mutex live next to the data it guards, and is the lock taken on + every access?** + Detection: `grep -nE -B1 -A5 'sync\.(RW)?Mutex' ` — the guarded + fields must sit in the same struct, and every method touching them must lock; + grep the field names across the package for unlocked access paths. + Violation: a mutex guarding fields it doesn't live beside, or any access path + that skips the lock — the guard is decorative. + +5. **Does production code sleep?** + Detection: `grep -n 'time\.Sleep' | grep -v _test.go` + Violation: any hit on a cancellable path — backoff/pacing/polling must be a + timer `select` with `ctx.Done()`, sustained pacing a `rate.Limiter.Wait(ctx)`. + Exempt: startup jitter in `main`-adjacent wiring. (Test sleeps are R7's Q6, not + this rule.) + +6. **Inverse — is a guard or goroutine ceremony?** + Detection: for each NEW mutex or goroutine in the diff, grep the package for a + second goroutine that ever touches the guarded state + (`grep -rnE '\bgo\s+[a-zA-Z_][A-Za-z0-9_.]*\(|\.Go\(' `) or for a + caller that needed the work to be asynchronous. + Violation: a mutex on single-goroutine state, or a goroutine whose caller + immediately blocks waiting for it — delete the ceremony; concurrency has the same + over-abstraction trap as R1. A mutex guarding only one-time initialization is + the same finding with a named fix: `sync.OnceFunc`/`sync.OnceValue`. diff --git a/lang/go/rules/R10/linter-neighbors.md b/lang/go/rules/R10/linter-neighbors.md new file mode 100644 index 0000000..75eb145 --- /dev/null +++ b/lang/go/rules/R10/linter-neighbors.md @@ -0,0 +1,3 @@ +- **The linter owns the mechanical neighbors.** Ignored errors (`errcheck`), + unclosed response bodies (`bodyclose`), copied locks (`govet copylocks`) — enforce + these in `.golangci.yaml`; do not re-hunt them here. diff --git a/lang/go/rules/R11/canonical-example.md b/lang/go/rules/R11/canonical-example.md new file mode 100644 index 0000000..e48463c --- /dev/null +++ b/lang/go/rules/R11/canonical-example.md @@ -0,0 +1,88 @@ +A notifier must deliver alerts over email, Slack, or PagerDuty. The channel is decided +by a string field, and three parts of the codebase ask which one it is. + +### Before + +```go +// ❌ alert/send.go — first copy of the discriminator +func Send(a Alert) error { + switch a.Channel { + case "email": + return smtpSend(a.Recipient, renderEmail(a)) + case "slack": + return slackPost(a.Recipient, renderSlack(a)) + case "pagerduty": + return pdCreateIncident(a.Recipient, a.Summary) + default: + return fmt.Errorf("unknown channel %q", a.Channel) + } +} + +// ❌ alert/validate.go — second copy, drifting already: nobody added pagerduty here +func validRecipient(a Alert) bool { + switch a.Channel { + case "email": + return strings.Contains(a.Recipient, "@") + case "slack": + return strings.HasPrefix(a.Recipient, "#") + } + return false +} + +// ❌ alert/retry.go — third copy, as an if-chain this time +func retryDelay(a Alert) time.Duration { + if a.Channel == "pagerduty" { + return 0 + } + if a.Channel == "slack" { + return 5 * time.Second + } + return time.Minute +} +``` + +Three owners of one decision, already inconsistent: `validRecipient` silently returns +`false` for PagerDuty because the second copy was never updated. Adding SMS means +finding all three (and the fourth one hiding in a test helper). Every function also +carries the `default:` error path — the "maybe-unknown channel" concept leaks into +each call site, the behavioral twin of R1's maybe-invalid port. + +### After + +```go +// Channel is the behavior, not a string. Each variant is a leaf type. +type Channel interface { + Send(a Alert) error + ValidRecipient(recipient string) bool + RetryDelay() time.Duration +} + +// ParseChannel is the ONLY place the raw string is inspected — +// the decision is made once, at the boundary, like R2's ParsePort. +func ParseChannel(name string) (Channel, error) { + switch name { + case "email": + return Email{}, nil + case "slack": + return Slack{}, nil + case "pagerduty": + return PagerDuty{}, nil + default: + return nil, fmt.Errorf("unknown channel %q", name) + } +} + +type Slack struct{} + +func (Slack) Send(a Alert) error { return slackPost(a.Recipient, renderSlack(a)) } +func (Slack) ValidRecipient(r string) bool { return strings.HasPrefix(r, "#") } +func (Slack) RetryDelay() time.Duration { return 5 * time.Second } +``` + +The three switches are gone — call sites read `a.Channel.Send(a)`, +`a.Channel.RetryDelay()`. There is no `default:` anywhere downstream: an `Alert` that +exists holds a `Channel` that exists, so "unknown channel" is unrepresentable past +the boundary. Adding SMS is one new type plus one `case` in `ParseChannel` — existing +files untouched, and each channel's behavior unit-tests as a leaf with literals. +Full worked study including the strategy-map variant and the rejection counter-case: +`../examples/anti-if-dispatch.md`. diff --git a/lang/go/rules/R11/falsifying-questions.md b/lang/go/rules/R11/falsifying-questions.md new file mode 100644 index 0000000..d7ed6f8 --- /dev/null +++ b/lang/go/rules/R11/falsifying-questions.md @@ -0,0 +1,41 @@ +1. **Is the same discriminator inspected in more than one place?** + Detection: list discriminators in the diff — + `grep -nE 'switch [a-zA-Z_.]+\.(Type|Kind|Status|Mode|Channel|Format|Level)\b' $(git diff --name-only -- '*.go')` + and if-chain forms `grep -nE 'if [a-zA-Z_.]+\.(Type|Kind|Status|Mode|Channel|Format|Level) ==' ...`; + then count each across the package: `grep -rn 'switch .*\.' --include='*.go' . | wc -l` (plus `== ` comparisons on the same field). + Violation: ≥2 sites inspecting one discriminator — the decision has no single + owner; route to Interface Dispatch or Strategy Map. + +2. **Does a type switch dispatch on concrete types outside a boundary?** + Detection: `grep -rn 'switch .* := .*\.(type)' --include='*.go' .` — for each hit, + is it in a `ParseX`/decoder/boundary adapter, or in business logic? + Violation: a type switch in domain logic whose cases call variant-specific + behavior or unpack the variants' fields — the behavior belongs on the variants. + A switch over an interface the *same package* owns is a violation even at a + single site and even in a converter: the decision was already made at + construction, and interface satisfaction gives the completeness proof a + switch can't (`../examples/switch-to-polymorphism.md`). The boundary exemption + applies only when the output format belongs to a *different* package than the + cased types (that example's boundary counter) — there, the finding is limited + to shrinking the switch to pure dispatch. `errors.As`/`errors.Is` chains and + decode/unmarshal of foreign types are not this pattern. + +3. **Does a `default:` (or trailing `else`) handle "unknown kind" away from the boundary?** + Detection: for each switch found in Q1, check the `default` arm for + `errors.New`/`fmt.Errorf`/panic on an unknown-kind message. + Violation: unknown-kind errors deep in the call graph — the maybe-unknown concept + leaked past construction; dispatch should have been chosen at `ParseX`. + +4. **Does a boolean parameter select between behaviors?** + Detection: `grep -nE 'func .*\(.*\b(is|use|with|enable|skip)[A-Za-z]* bool' $(git diff --name-only -- '*.go')`; + check whether the function branches on it near the top. + Violation: a flag argument whose branches share little code — Split Flag Argument. + +5. **Inverse — is a NEW dispatch abstraction in the diff unearned?** + Detection: for each new interface/strategy map in the diff, count production + implementations/entries and the number of sites the old conditional occupied + (`git log -p` or the pre-diff file). + Violation: one switching site with trivial variance replaced by an interface — + score it (R1 scorecard); if LOW, the finding is the *extraction*, and the fix is + Keep the Single Exhaustive Switch. An interface whose second implementation exists + only in tests is an R6 violation, not a dispatch win. diff --git a/lang/go/rules/R12/canonical-example.md b/lang/go/rules/R12/canonical-example.md new file mode 100644 index 0000000..20df864 --- /dev/null +++ b/lang/go/rules/R12/canonical-example.md @@ -0,0 +1,79 @@ +`Grants` guarantees a non-empty, deduplicated permission set — enforced in the +constructor per R2. + +### Before + +```go +type Grants struct { + perms []Permission // constructor guarantees: non-empty, deduplicated +} + +func ParseGrants(raw []string) (Grants, error) { + perms, err := dedupeAndValidate(raw) + if err != nil { + return Grants{}, err + } + return Grants{perms: perms}, nil +} + +// ❌ returns a mutable alias into the validated state +func (g Grants) All() []Permission { return g.perms } +``` + +```go +// ❌ a distant caller, months later +perms := user.Grants.All() +sort.Slice(perms, func(i, j int) bool { ... }) // reorders internal state +perms[0] = PermissionNone // corrupts it — no method called +``` + +The constructor's guarantee is now a lie, and nothing in `grants.go` changed. The +write that broke the invariant lives in a file the type's owner has never seen; no +detection aimed at the type itself can find it. The backward version is just as +silent: + +```go +// ❌ constructor stores the caller's slice +func NewSchedule(days []Weekday) (Schedule, error) { + if len(days) == 0 { + return Schedule{}, errors.New("schedule: no days") + } + return Schedule{days: days}, nil +} + +days := []Weekday{Monday} +s, _ := NewSchedule(days) +days[0] = Sunday // s just changed. NewSchedule's validation saw a different value. +``` + +### After + +```go +func ParseGrants(raw []string) (Grants, error) { + perms, err := dedupeAndValidate(raw) // freshly built here — no shared alias + if err != nil { + return Grants{}, err + } + return Grants{perms: perms}, nil +} + +// All returns a copy; callers may do anything with it. +func (g Grants) All() []Permission { return slices.Clone(g.perms) } + +// Or expose iteration instead of the collection (no copy, no alias). +// iter.Seq / slices.Values require Go 1.23+; on older Go, a walker method +// (func (g Grants) Each(yield func(Permission) bool)) is the same move. +func (g Grants) Each() iter.Seq[Permission] { return slices.Values(g.perms) } + +func NewSchedule(days []Weekday) (Schedule, error) { + if len(days) == 0 { + return Schedule{}, errors.New("schedule: no days") + } + return Schedule{days: slices.Clone(days)}, nil // copy on the way in +} +``` + +Now every mutation path runs through the type. The caller's `sort.Slice` reorders its +own copy; the caller's `days[0] = Sunday` changes a slice `Schedule` no longer +shares. The invariant has exactly one set of doors, and the constructor guards all of +them. diff --git a/lang/go/rules/R12/falsifying-questions.md b/lang/go/rules/R12/falsifying-questions.md new file mode 100644 index 0000000..de03f5b --- /dev/null +++ b/lang/go/rules/R12/falsifying-questions.md @@ -0,0 +1,43 @@ +1. **Does a method return an internal slice or map by reference?** + Detection: for each type in the diff with a validating constructor, list its + slice/map fields (`grep -A8 'type struct' `), then + `grep -nE 'return [a-z][a-zA-Z]*\.(|)$' ` — a bare + `return x.field` with no `Clone`/copy/iterator around it. + Violation: an internal reference escapes a validated type — Copy on the Way Out. + +2. **Does a constructor store a caller-provided slice/map without copying?** + Detection: inside each `ParseX`/`NewX` in the diff, check the struct literal for a + slice/map field assigned directly from a parameter identifier + (`grep -nE '\s*[,}]' within the return literal). + Violation: the type's state aliases memory the caller still holds — Copy on the + Way In. (A collection built inside the constructor, like `dedupeAndValidate`'s + result, is fine — no one else holds it.) + +3. **Does one method both return domain data and mutate the receiver?** + Detection: for each changed method with a non-error return value, + grep its body for assignments to receiver fields (`. =`, + `append(.` ). + Violation: a query/modifier hybrid where any call site discards the return value + or calls it only for the effect — Separate Query from Modifier. (If every caller + genuinely needs both halves atomically — `sync`-guarded pop-and-report — it is + one operation; name it as a mutator per R3 and move on.) + +4. **Can a validated type be mutated around its constructor?** + Detection: `grep -rnE 'func \([a-z][a-zA-Z]* \*?[A-Z][a-zA-Z]*\) Set[A-Z]' --include='*.go' .` + for setters; for each hit, does the receiver type have a `ParseX`/`NewX` that + validates, and does the setter re-check? + Violation: a setter that assigns unchecked on a constructor-validated type — + Remove Setting Method. (Exported mutable fields on such types are R2's Q1.) + +5. **Is one variable reassigned to mean something different?** + Detection: read each changed function; for every reassignment (`x = ...` after + `x := ...`), ask whether the right-hand side computes the same concept. + Violation: two meanings under one name — Split Variable; cite both assignments. + +6. **Inverse — does the diff copy defensively where no alias escapes?** + Detection: for each new `slices.Clone`/`maps.Clone`/manual copy loop in the diff, + trace the copied value: does the source or the copy ever cross a function + boundary or outlive the call? + Violation: cloning data that provably never escapes, or copying per-iteration in + a loop the profile cares about — ceremony; delete the copy and note why sharing + is safe. diff --git a/lang/go/rules/R2/canonical-example.md b/lang/go/rules/R2/canonical-example.md new file mode 100644 index 0000000..07f98b2 --- /dev/null +++ b/lang/go/rules/R2/canonical-example.md @@ -0,0 +1,53 @@ +Compact excerpt from the Port case (`R1-primitive-obsession.md` has the full +three-stage study — extraction, placement, testing): + +```go +// Port cannot exist out of range — the constructor is the only entry. +type Port struct { + name string + number int32 +} + +func ParsePort(name string, number int32) (Port, error) { + if number <= 0 || number > 65535 { + return Port{}, fmt.Errorf("port %q: %d out of range 1-65535", name, number) + } + return Port{name: name, number: number}, nil +} +``` + +Before this type existed, `p.Port > 0 && p.Port <= 65535` was duplicated across two +loops at the use site. After, there is no `IsValid()` and no re-check anywhere: the +concept of a maybe-invalid port is deleted from downstream logic, not relocated. + +The same pattern for a composed object — validate dependencies once, then trust: + +```go +// ❌ every method defends +type UserService struct { + Repo Repository // exported, might be nil +} + +func (s *UserService) CreateUser(ctx context.Context, u User) error { + if s.Repo == nil { // repeated in every method; forget one → panic + return errors.New("repo is nil") + } + return s.Repo.Save(ctx, u) +} + +// ✅ constructor validates once; methods trust the receiver +type UserService struct { + repo Repository // private +} + +func NewUserService(repo Repository) (*UserService, error) { + if repo == nil { + return nil, errors.New("repo is required") + } + return &UserService{repo: repo}, nil +} + +func (s *UserService) CreateUser(ctx context.Context, u User) error { + return s.repo.Save(ctx, u) // no checks — an invalid service cannot exist +} +``` diff --git a/lang/go/rules/R2/falsifying-questions.md b/lang/go/rules/R2/falsifying-questions.md new file mode 100644 index 0000000..06ecdb1 --- /dev/null +++ b/lang/go/rules/R2/falsifying-questions.md @@ -0,0 +1,42 @@ +1. **Can the type exist in an invalid state?** + Detection: for each new/changed type with invariants, + `grep -rn '{' --include='*.go' . | grep -v _test.go` for literal + construction outside its own file; check whether invariant-bearing fields are + exported. + Violation: any literal-construction site or exported invariant-bearing field + gives callers a path around the constructor. + +2. **Do methods re-check what the constructor should guarantee?** + Detection: `grep -nE 'if [a-z][a-zA-Z]*\.[a-zA-Z]+ == nil|if len\([a-z][a-zA-Z]*\.[a-zA-Z]+\) == 0' ` + inside method bodies. + Violation: a method validating its own receiver's fields — the check belongs in + the constructor. + +3. **Does a constructor re-validate a composed self-validating type?** + Detection: read each `NewX`/`ParseX` in the diff; for every parameter whose type + has its own constructor, grep the body for checks on that parameter. + Violation: re-validating a value that could only ever exist valid. + +4. **Does the type rely on upstream validation?** + Detection: `grep -rn 'caller must\|assumes valid\|already validated' --include='*.go' .`; + also flag exported fields consumed by logic in a package that defines no + constructor for the type. + Violation: any invariant enforced — or merely documented — outside the type + itself. + +5. **Does anything return or accept nil as a value?** + Detection: `grep -nE 'return nil$|return nil, nil' ` — exempt + `return nil, err` and `return val, nil`. + Violation: nil returned for a non-error value, or a function nil-checking a + parameter instead of the value being guaranteed by construction. + +6. **Does any call site pass a nil literal as a non-error argument?** + Detection: `grep -nE '\(nil[,)]|, nil[,)]' ` — exempt error + positions (`return X, nil`), comparisons (`== nil`, `!= nil`), and stdlib + idioms where nil is the documented sentinel (`http.NewRequest(..., nil)` for + a bodyless request, marshaling a nil slice/map). + Violation: nil passed where a value is expected. Q5 catches the return side + and Q2 catches the callee that defends; this catches the caller when the + callee does neither and simply panics later. Fix on the callee's side: make + nil unrepresentable — a concrete non-pointer parameter, or a validating + constructor that rejects nil (see the UserService example above). diff --git a/lang/go/rules/R3/canonical-example.md b/lang/go/rules/R3/canonical-example.md new file mode 100644 index 0000000..9410f54 --- /dev/null +++ b/lang/go/rules/R3/canonical-example.md @@ -0,0 +1,75 @@ +Real production code. `upsertIfaceAddrHost` must pick usable IPv4/IPv6 addresses from +a network interface and align config state with them. + +### Before + +```go +func (c *Config) upsertIfaceAddrHost(iface net.Interface) error { + addr, err := iface.Addrs() + if err != nil { + return fmt.Errorf("network addr: %w", err) + } + var ( + addrIP4Added bool + addrIP6Added bool + ) + for _, a := range addr { + ipnet, ok := a.(*net.IPNet) + if !ok || !ipnet.IP.IsGlobalUnicast() { + continue + } + if ipnet.IP.To4() == nil { // validate IP6 + if addrIP6Added { // already added. skip + continue + } + if !c.parseIP6(ipnet) { + return fmt.Errorf("IP6 %q address is not valid", c.IP6) + } + addrIP6Added = true + continue + } + if addrIP4Added { + continue // already added. skip + } + if !c.parseIP4(ipnet) { + return fmt.Errorf("IP4 %q address is not valid", c.IP4) + } + addrIP4Added = true + } + if !addrIP4Added && !addrIP6Added { + return fmt.Errorf("IP address is not valid. IP4: %q, IP6: %q", c.IP4, c.IP6) + } + return nil +} +``` + +48 lines, cognitive complexity 18: type assertions, boolean flags tracking loop +state, three nesting levels, `continue`-driven control flow — and the actual policy +(collect one IPv4 and one IPv6, then reconcile with config) is nowhere stated. The +comments `// validate IP6` and `// already added. skip` are naming blocks that want +to be functions. `parseIP4`/`parseIP6` mutate `c` — the name hides the side effect. + +### After + +```go +func (c *Config) upsertIfaceAddrHost(iface net.Interface) error { + addr, err := iface.Addrs() + if err != nil { + return fmt.Errorf("network addr: %w", err) + } + + ipConfig := collectIPConfigFrom(addr) + + if err = c.AlignIPs(ipConfig); err != nil { + return fmt.Errorf("align config IPs err: %w", err) + } + return nil +} +``` + +Read aloud: get addresses → collect them into an IPConfig → align config with what +was collected. Every line is the same altitude. The collection and validation logic +moved into an `IPConfig` leaf type that unit-tests with literals; the mutating +helpers were renamed `alignIPv4`/`alignIPv6` — "align" admits the side effect that +"parse" hid. Full worked study, including the leaf type and the test payoff: +`../examples/storify-leaf-type.md`. diff --git a/lang/go/rules/R3/falsifying-questions.md b/lang/go/rules/R3/falsifying-questions.md new file mode 100644 index 0000000..cf31b06 --- /dev/null +++ b/lang/go/rules/R3/falsifying-questions.md @@ -0,0 +1,31 @@ +1. **Does any changed function exceed the size/shape limits?** + Detection: run the complexity linters (`gocyclo`, `gocognit` via + `golangci-lint run`) on the changed files; or count — + `awk '/^func /,/^}/' ` per function for LOC, eyeball nesting depth. + Violation: > 50 LOC or > 2 nesting levels — the function is doing more than + narrating. + +2. **Does one body mix abstraction levels?** + Detection: read each changed function and list its statements' altitudes: a named + method/function call is high; string/index/slice manipulation, type assertions, + and protocol details are low. + Violation: both altitudes in the same body — e.g. `strings.SplitN` three lines + from a business decision. Cite the two lines. + +3. **Do block comments narrate sections inside a function body?** + Detection: `grep -n '^\s*//' ` within function bodies (not doc comments + above declarations). + Violation: a comment naming what the next block does — each is a candidate + extraction point; the fix is a function named after the comment. + +4. **Do boolean flags track state across a loop?** + Detection: `grep -nE 'var \(|:= false|:= true' ` near `for` loops; + look for flags set inside the loop and read after it. + Violation: flag-driven loops — a collection/domain type should absorb the loop + (see `../examples/storify-leaf-type.md`). + +5. **Does any function name lie about side effects?** + Detection: for each `parse*`/`validate*`/`is*`/`get*` function in the diff, check + the body for assignments to receiver fields or parameters. + Violation: a read-sounding name that mutates — rename to a mutating verb or split + the query from the mutation. diff --git a/lang/go/rules/R4/canonical-example.md b/lang/go/rules/R4/canonical-example.md new file mode 100644 index 0000000..25b31b5 --- /dev/null +++ b/lang/go/rules/R4/canonical-example.md @@ -0,0 +1,32 @@ +From the Port case (`R1-primitive-obsession.md` carries the full three-stage study). +After extraction, `Port`/`Ports`/`FirstNamed`/`First` say nothing about Kubernetes or +Weka: juicy (range validation, collection queries) and domain-generic → rung 3, +`internal/pkg/networking`. The feature keeps a four-line storified policy method: + +```go +func (s kubeService) managementPort() (networking.Port, bool) { + if p, ok := s.ports.FirstNamed(kubeWekaAPIPort); ok { return p, true } + return s.ports.First() +} +``` + +Only the domain-generic parts were promoted: the `"weka-api"` constant is feature +policy and stays in the feature. A shared package that knows one feature's port names +is not shared vocabulary — it is leaked policy. + +The rung-1 contrast — a trivial helper that stays put: + +```go +// Trivial: one caller, no domain vocabulary, no rules of its own. +// Stays unexported; covered through the parent's public API. +func parseK3SArgument(arg string) (key, value string, ok bool) { + parts := strings.SplitN(arg, "=", 2) + if len(parts) != 2 { + return "", "", false + } + return parts[0], parts[1], true +} +``` + +There is no urge to test this directly — and that absence is the point: the promotion +signal (below) never fires. diff --git a/lang/go/rules/R4/falsifying-questions.md b/lang/go/rules/R4/falsifying-questions.md new file mode 100644 index 0000000..04d0c50 --- /dev/null +++ b/lang/go/rules/R4/falsifying-questions.md @@ -0,0 +1,40 @@ +1. **Is a symbol exported only so tests can reach it?** + Detection: for each newly exported func/type, + `grep -rn '' --include='*.go' . | grep -v _test.go` — count non-test + references outside its defining file. + Violation: zero production call sites outside the package while `*_test.go` + references exist — it was exported for tests; demote (rung 1) or promote + (rung 2/3). + +2. **Are unexported helpers tested directly?** + Detection: `grep -rL '^package .*_test$' --include='*_test.go' .` to find + internal test packages, then grep those files for calls to lowercase functions + defined in the package. + Violation: any direct test of a private helper — that urge is the promotion + signal; give the helper its own package instead. + +3. **Does a new shared package have a role name?** + Detection: `ls internal/pkg pkg 2>/dev/null | grep -iE '^(util|utils|helpers|helper|common|shared|misc)$'` + Violation: any hit — packages are named for a domain vocabulary, never a role. + +4. **Is a new shared package a single noun rather than a vocabulary?** + Detection: `grep -c '^type [A-Z]' internal/pkg//*.go` and ask whether + plausible domain siblings exist under the name. + Violation: a package named after its one type (`kubeport`) with no room for + siblings — fold into a vocabulary package (`networking`) or keep at rung 1/2. + +5. **Did feature policy leak into a shared package?** + Detection: grep the shared package for feature-owned literals and constants, e.g. + `grep -rn '"weka-' internal/pkg/`. + Violation: any feature-specific literal or preference decision inside a + domain-generic package — policy stays in the feature (Stage 2 of + `R1-primitive-obsession.md`). + +6. **Does a changed function envy another type's data?** + Detection: for each changed function/method, count field/method accesses per + value: `grep -o '\.[a-zA-Z]*' | sort | uniq -c` versus the same + count for its most-touched parameter or field. + Violation: accesses on one foreign value outnumber accesses on the receiver (or + on all local data, for a free function) and the foreign type is yours to extend — + Move Method to the Envied Type, then re-place via the ladder. A function that + merely *reads* a foreign DTO once to adapt it at a boundary is not envy. diff --git a/lang/go/rules/R5/canonical-example.md b/lang/go/rules/R5/canonical-example.md new file mode 100644 index 0000000..e56245a --- /dev/null +++ b/lang/go/rules/R5/canonical-example.md @@ -0,0 +1,33 @@ +### Before — feature scattered across layers + +``` +project/ +├── domain/ +│ └── rotator.go +├── services/ +│ └── rotator_service.go +├── repository/ +│ └── rotator_repository.go +└── handlers/ + └── rotator_handler.go +``` + +Changing rotation policy touches four directories; the `services` package's API is +the union of every feature's service; `domain` and `services` are role names that +describe no domain at all. + +### After — one slice, roles inside + +``` +project/ +└── rotator/ + ├── rotator.go # domain type + ├── parser.go # role: parsing + ├── handler.go # role: HTTP + ├── repository.go # role: persistence + └── rotator_test.go +``` + +The whole feature is one `ls`. Each type with logic sits in its own file named after +the type; the package name is the feature's domain word, and file names carry the +roles. diff --git a/lang/go/rules/R5/falsifying-questions.md b/lang/go/rules/R5/falsifying-questions.md new file mode 100644 index 0000000..39c5e6e --- /dev/null +++ b/lang/go/rules/R5/falsifying-questions.md @@ -0,0 +1,29 @@ +1. **Is any package named after a layer or role?** + Detection: `grep -rn 'package \(util\|utils\|helpers\|common\|shared\|misc\|domain\|services\|handlers\|models\)$' --include='*.go' .` + and `find . -type d \( -name 'util*' -o -name 'helpers' -o -name 'common' -o -name 'domain' -o -name 'services' -o -name 'handlers' -o -name 'models' -o -name 'repositories' \)` + Violation: any hit — the package describes a role, not a domain. + +2. **Is one feature's code spread across ≥2 layer directories?** + Detection: for each feature noun in the diff, + `grep -rln '' --include='*.go' . | xargs -n1 dirname | sort -u` — count + distinct layer-named directories. + Violation: the same feature living in `handlers/` and `services/` (etc.) — it is + horizontally scattered. + +3. **Does the diff add a new file into a layer directory instead of a slice?** + Detection: `git diff --name-only --diff-filter=A -- '*.go'` — check each new + path's directory against the layer names above. + Violation: new feature code placed in a layer directory — new code is always + sliced, even mid-migration. + +4. **Do both shapes coexist for one feature?** + Detection: `ls / services/ handlers/ 2>/dev/null | grep -i ` + Violation: `/service.go` alongside `services/_service.go` — + the never-mix rule; finish the feature's migration in this change or don't start + it. + +5. **Is a mixed-architecture repo missing its migration plan?** + Detection: layer directories exist alongside slices, and + `ls docs/architecture/vertical-slice-migration.md` fails. + Violation: mixed state with no documented strategy/progress — add the template + above. diff --git a/lang/go/rules/R6/canonical-example.md b/lang/go/rules/R6/canonical-example.md new file mode 100644 index 0000000..6c08779 --- /dev/null +++ b/lang/go/rules/R6/canonical-example.md @@ -0,0 +1,33 @@ +### Before — interface exists only for a test fake + +```go +// service.go — one prod impl (*worker.Store); the interface exists for the test +type Leaves interface { + FindLatest(ctx context.Context, id ID) (Job, error) +} + +type Service struct { leaves Leaves } + +// service_test.go — the ONLY other implementer is a mock +type fakeLeaves struct{ job Job } + +func (f *fakeLeaves) FindLatest(context.Context, ID) (Job, error) { return f.job, nil } +``` + +### After — concrete dependency, tested by wiring the real collaborator + +```go +// service.go — concrete; no cycle (the worker package does not import this one) +type Service struct { leaves *worker.Store } + +// service_test.go — construct the REAL Store over embedded Postgres + a fake +// (httptest) external service +func (s *Suite) TestRerun() { + svc, _ := NewService(s.store, s.evaluator, s.jiraClient) // real objects, fake data + // ... exercise svc's public method, assert on real state +} +``` + +The test now covers the seam it claims to cover: the real `Store`'s queries run +against a real database. The interface, its indirection, and the double are all +deleted. diff --git a/lang/go/rules/R6/falsifying-questions.md b/lang/go/rules/R6/falsifying-questions.md new file mode 100644 index 0000000..41c1dfc --- /dev/null +++ b/lang/go/rules/R6/falsifying-questions.md @@ -0,0 +1,33 @@ +1. **How many production implementations does each new/changed interface have?** + Detection: for each method of the interface, + `grep -rn 'func (.*) (' --include='*.go' . | grep -v _test.go` — list the + implementing types. + Violation: exactly one production implementation — the interface is a candidate + smell; proceed to Q2. + +2. **Is the only other implementer a test double?** + Detection: `grep -rn 'func (.*) (' --include='*_test.go' .` plus the same + grep over test-support packages (`fakes/`, `mocks/`, `testutil*`). + Violation: yes — one production implementation + a double = test-only interface; + delete it and test the real type. + +3. **Would depending on the concrete type cause a REAL import cycle?** + Detection — do not trust a "cycle" comment; check the import direction: + ```bash + # a real cycle exists only if the dependency package imports the consumer back: + grep -rn '"/"' /*.go # no match ⇒ no cycle ⇒ interface unjustified + ``` + Violation: no back-import found — the justification is false; the interface + exists for a test. + +4. **Does a consumer take an interface while every production call site passes the + same concrete type?** + Detection: `grep -rn 'New(' --include='*.go' . | grep -v _test.go` — + inspect the argument's type at each production call site. + Violation: one concrete type at every production call site — the interface + parameter is a seam for doubles; take the concrete type. + +5. **Does the diff justify a new interface with "for testing" or "import cycle"?** + Detection: `grep -rn -B2 'interface {' | grep -iE 'for test|import cycle|mock'` + Violation: any hit — the comment is itself a finding; verify with Q1–Q3 and + expect deletion. diff --git a/lang/go/rules/R7/canonical-example.md b/lang/go/rules/R7/canonical-example.md new file mode 100644 index 0000000..9822725 --- /dev/null +++ b/lang/go/rules/R7/canonical-example.md @@ -0,0 +1,61 @@ +### Before — anti-patterns stacked + +```go +package user // same package — can reach privates + +func TestValidateEmailInternal(t *testing.T) { // testing a private + assert.True(t, validateEmailInternal("test@example.com")) +} + +func TestCreateUser(t *testing.T) { // doubles instead of collaborators + mockRepo := &MockRepository{} + mockRepo.On("Save", mock.Anything).Return(nil) + + svc := &UserService{Repo: mockRepo} // literal construction, no constructor + err := svc.CreateUser("123", "test@example.com") + assert.NoError(t, err) + mockRepo.AssertExpectations(t) // asserts on the fake, not on behavior +} + +func TestAsyncOperation(t *testing.T) { + go doAsyncWork() + time.Sleep(100 * time.Millisecond) // flaky + assert.True(t, workCompleted) +} +``` + +### After — right rung, real collaborators, observable behavior + +```go +package user_test // external package — public API only + +func TestService_CreateUser(t *testing.T) { + repo := user.NewInMemoryRepository() // real implementation, fake data + emailer := user.NewTestEmailer() + + svc, err := user.NewUserService(repo, emailer) + require.NoError(t, err) + + err = svc.CreateUser(context.Background(), testUser) + require.NoError(t, err) + + retrieved, err := svc.GetUser(context.Background(), testUser.ID) // verify via public API + require.NoError(t, err) + assert.Equal(t, testUser.Email, retrieved.Email) +} + +func TestAsyncOperation(t *testing.T) { + done := make(chan struct{}) + go func() { doAsyncWork(); close(done) }() + + select { + case <-done: + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for async work") + } +} +``` + +Email validation itself is a leaf behavior — it belongs one rung down, as a unit +test on `ParseEmail` with literal strings, not inside the service test and not as a +private-function test. diff --git a/lang/go/rules/R7/falsifying-questions.md b/lang/go/rules/R7/falsifying-questions.md new file mode 100644 index 0000000..87581dc --- /dev/null +++ b/lang/go/rules/R7/falsifying-questions.md @@ -0,0 +1,35 @@ +1. **Does any `t.Run` body contain a conditional?** + Detection: `grep -rn -A6 't.Run(' --include='*_test.go' . | grep -nE 'if |switch '` + and `grep -rn 'wantErr' --include='*_test.go' .` + Violation: any conditional inside a case, or a `wantErr bool` field — success and + error cases are fused; split the functions. + +2. **Is any test in the internal package?** + Detection: `grep -rn '^package ' --include='*_test.go' . | grep -v '_test$'` + Violation: a test package without the `_test` suffix — it can reach privates; + move to `pkg_test` and test the public API. + +3. **Does a test construct a big object to exercise a leaf behavior?** + Detection: read each new/changed test — compare the setup (fixtures, services, + servers) against the assertion's subject; count setup lines vs. the one predicate + actually checked. + Violation: heavyweight construction whose assertions target logic a leaf type + owns (or should own) — move the test down a rung, extracting the leaf if needed. + +4. **Does a new behavior's test sit above the lowest rung that contains it?** + Detection: for each new public method on a leaf type, + `grep -rn '' --include='*_test.go' .` — is it exercised directly, or only + through an orchestrator's test? + Violation: leaf behavior reached only from above — add the rung-0 test; the + orchestrator test keeps only the seam. + +5. **Does a test assert on a fake's internals rather than observable behavior?** + Detection: `grep -rn 'AssertExpectations\|AssertCalled\|\.calls\b' --include='*_test.go' .`; + also flag assertions reading fields of a test double instead of querying the + system under test. + Violation: the test verifies the double — assert on real state via the public API + (and the double itself is likely an R6 finding). + +6. **Does any test sleep to synchronize?** + Detection: `grep -rn 'time.Sleep' --include='*_test.go' .` + Violation: any hit — replace with channels/wait groups. diff --git a/lang/go/rules/R8/canonical-example.md b/lang/go/rules/R8/canonical-example.md new file mode 100644 index 0000000..1ecb315 --- /dev/null +++ b/lang/go/rules/R8/canonical-example.md @@ -0,0 +1,55 @@ +Real refactoring — `env.Configs.NATsAddress` was read in 12 places deep in the +codebase. + +### Before — sideways access + +```go +package messaging + +func PublishEvent(event Event) error { + conn, err := nats.Connect(env.Configs.NATsAddress) // global reached from a leaf + if err != nil { + return fmt.Errorf("connect failed: %w", err) + } + defer conn.Close() + // ... +} + +// the test must mutate shared state — and cannot run in parallel +func TestPublishEvent(t *testing.T) { + env.Configs.NATsAddress = "nats://test:4222" // leaks into every other test + // ... +} +``` + +### After — dependency rejected upward, injected at the edge + +```go +package messaging + +type NATSClient struct { + natsAddress string // injected, not global +} + +func NewNATSClient(natsAddress string) *NATSClient { + return &NATSClient{natsAddress: natsAddress} +} + +func (c *NATSClient) PublishEvent(event Event) error { + conn, err := nats.Connect(c.natsAddress) + // ... +} + +// package api — the global is read ONLY at the entry point +func SetupOrderHandler() *OrderHandler { + natsClient := NewNATSClient(env.Configs.NATsAddress) + orderService := NewOrderService(env.Configs.DBHost, natsClient) + return &OrderHandler{orderService: orderService} +} +``` + +The test constructs a client against a local test NATS server — no global writes, +`t.Parallel()` works. The refactoring is incremental: one clean island at a time, +pushing the global up one level per iteration, from 20 scattered accesses down to 2 +at the entry points. Full worked case — the dependency map, the island-by-island +progression, and the test payoff: `../examples/dependency-rejection.md`. diff --git a/lang/go/rules/R8/falsifying-questions.md b/lang/go/rules/R8/falsifying-questions.md new file mode 100644 index 0000000..f4134ee --- /dev/null +++ b/lang/go/rules/R8/falsifying-questions.md @@ -0,0 +1,33 @@ +1. **Does any package declare mutable state at package level?** + Detection: `grep -rn '^var ' --include='*.go' . | grep -v _test.go` — then + exclude const-like declarations (`var Err... = errors.New(...)` sentinels, + compile-time interface checks `var _ I = ...`). + Violation: a package-level `var` that is written after initialization or holds + configuration/state — reject it into a constructor-injected field. + +2. **Does any `init()` write state?** + Detection: `grep -rn 'func init()' --include='*.go' .` — read each body for + assignments to package-level variables or registrations with side effects. + Violation: `init()` mutating package state — replace with an explicit + constructor called at the edge. + +3. **Does library code manufacture its own context?** + Detection: `grep -rn 'context.Background()\|context.TODO()' --include='*.go' . | grep -v _test.go | grep -v 'cmd/\|main.go'` + Violation: any hit outside `main`/wiring — the function must take `ctx` from its + caller. + +4. **Is a singleton reached sideways?** + Detection: `grep -rn 'sync.Once' --include='*.go' .` — check whether the guarded + instance is a package-level var returned by a getter that business logic calls. + Violation: `GetX()`-style access from inside logic — construct at the edge, pass + down. + +5. **Does deep code read a global config?** + Detection: `grep -rn 'env\.Configs\|os.Getenv' --include='*.go' . | grep -v _test.go | grep -v 'cmd/\|main.go\|setup'` + Violation: config reads outside entry-point wiring — each is a dependency to + reject upward (`../examples/dependency-rejection.md`). + +6. **Do tests mutate globals to run?** + Detection: `grep -rn 'env.Configs.* =' --include='*_test.go' .` + Violation: a test writing shared state to inject a value — the production code + under test has a hidden dependency; fix the production code, not the test. diff --git a/lang/go/rules/R9/canonical-example.md b/lang/go/rules/R9/canonical-example.md new file mode 100644 index 0000000..bcd39e8 --- /dev/null +++ b/lang/go/rules/R9/canonical-example.md @@ -0,0 +1,122 @@ +A retry feature shipped months ago. The knowledge exists — and is unreachable. + +### Before — the knowledge is there, the network is not + +``` +repo/ +├── CLAUDE.md # build commands only; no reference to docs/ +├── docs/ +│ └── retry-policy.md # explains the jitter decision; nothing links to it +└── retry/ + └── policy.go +``` + +```go +package retry + +type Policy struct { // exported, no doc comment + maxAttempts int + baseDelay time.Duration +} + +func (p Policy) Do(ctx context.Context, op Op) error { + // loop over attempts and back off between failures + for attempt := 1; attempt <= p.maxAttempts; attempt++ { + if err := op(ctx); err == nil { + return nil + } + delay := p.baseDelay * time.Duration(1< +The retry loop lives in retry/policy.go around line 40; it uses full jitter. +``` + +Four breaks, one per rung: the in-body comment narrates WHAT the next lines do +(a rung-0 failure — the block wants to be an extracted, named function, which is +`R3-storifying.md`'s territory); `Policy` is a naked exported type, so a grep hit +on it dead-ends with zero context (rung 1); `docs/retry-policy.md` is an orphan — +no index lists it, no comment cites it, and it cites code by **file path and line +number**, coordinates that the next refactor invalidates (rung 2); and CLAUDE.md +imports nothing, so a fresh session starts blind (rung 3). + +### After — the same knowledge, networked + +``` +repo/ +├── CLAUDE.md # @docs/index.md +├── docs/ +│ ├── index.md # one line per doc, grouped by topic +│ └── retry-policy.md # points down at symbols, not files +└── retry/ + └── policy.go +``` + +```go +// Policy is a capped exponential-backoff retry policy with full jitter. +// Jitter is deliberate: synchronized clients retrying in lockstep re-overloaded +// the upstream API after every blip. See docs/retry-policy.md for the incident +// and the cap math. +type Policy struct { + maxAttempts int + baseDelay time.Duration +} + +func ParsePolicy(maxAttempts int, baseDelay time.Duration) (Policy, error) { + if maxAttempts < 1 || baseDelay <= 0 { + return Policy{}, ErrInvalidPolicy + } + return Policy{maxAttempts: maxAttempts, baseDelay: baseDelay}, nil +} + +func (p Policy) Do(ctx context.Context, op Op) error { + for attempt := range p.attempts() { + if err := op(ctx); err == nil { + return nil + } + p.backOff(ctx, attempt) + } + return ErrExhausted +} +``` + +```markdown + +--- +type: feature +description: why retries use capped full jitter; `Policy` API +--- +Entry point: `Policy.Do`. Construction: `ParsePolicy` — validates the cap +against the base delay, so an unbounded backoff cannot exist. +``` + +```markdown + +--- +okf_version: "0.2" +--- +# Repo map + +**Resilience** +- [retry-policy.md](retry-policy.md) — why retries use capped full jitter; `Policy` API +``` + +```markdown + +@docs/index.md +``` + +Every break healed at its rung: storifying killed the WHAT-comment — the extracted +names `attempts`/`backOff` carry it (`R3-storifying.md`); `Policy`'s godoc states +the WHY the code cannot (the incident) and carries the upward edge to the feature +doc; the doc points down with the greppable tokens `Policy.Do` and `ParsePolicy` — +no path, no line number — and is listed in the index; CLAUDE.md imports the index, +so the whole map is in context at session start. Grep `Policy` or open CLAUDE.md: +either way, the jitter incident is two hops away. And the index line has one +source of truth: it IS `retry-policy.md`'s `description`, copied verbatim — the +conformance gate (Q7) fails the moment the copy drifts. diff --git a/lang/go/rules/R9/falsifying-questions.md b/lang/go/rules/R9/falsifying-questions.md new file mode 100644 index 0000000..a615b42 --- /dev/null +++ b/lang/go/rules/R9/falsifying-questions.md @@ -0,0 +1,93 @@ +Determine the doc root first (discovery order above); `` below is that +directory. Q1–Q3 and Q7 are fully mechanical: the plugin ships them as +`scripts/check-repo-brain.sh` (installed into the repo by the bootstrap pass), so +one command answers all four. + +1. **Is any doc an orphan?** + Detection: `find -name '*.md' ! -name 'index.md'` versus the link + targets extracted from `index.md` and any sub-indexes, e.g. + `grep -oE '\]\([^)]+\.md\)' /index.md`. + Violation: a doc file no index references — unreachable from the root, so + unread, so rotting. Cite the file and the index that should list it. + +2. **Is any edge broken — in either direction?** + Detection, code→docs: `grep -rnoE '(docs|\.ai|\.ainav)/[A-Za-z0-9._/-]+\.md' --include='*.go' .` + plus `.md`-to-`.md` links inside ``; `test -f` each target. + Detection, docs→code: build the repo's declaration set once — single-line + and grouped `type (` / `var (` / `const (` declarations, functions, and + methods — and resolve each backticked symbol against it. A token missing + from the set still resolves when it appears as a whole word in any + non-markdown repo file (config keys, alert names, test helpers). A + package-qualified `pkg.Sym` whose package is not declared in this repo is + external (stdlib, dependencies) and exempt. For a cited package or + directory path, `test -d` it. + Violation: any unresolved target in either direction. Additionally, a doc citing + a **file path or line number** is itself a violation of the edge policy — + regardless of whether the coordinate currently resolves. Exempt from the + ban: URL spans (e.g. pkg.go.dev links), fenced code blocks, and glob + patterns (a span containing `*` is a pattern, not a citation). + Two exemptions, both scoped to symbol resolution and both per-LINE — a line + carrying either marker is skipped whole (the file-path ban has no exemption + beyond URLs): a line carrying the ⚠️ stale flag (cites an unresolved + `Symbol`) is a recorded finding, not a broken edge — the decision to + refresh, remove, or keep it is the user's. And backticks are a resolvability + contract — a future/roadmap symbol is written in prose or explicitly marked + *(planned)*, and a *(planned)*-marked line is exempt from resolution. + +3. **Is the root unwired?** + Detection: for each doc root, `grep -l '/index.md' CLAUDE.md AGENTS.md + 2>/dev/null` in the root's owning project directory — the exact path, never a + bare `index.md` mention. A monorepo sub-root also counts as wired when the + repo-root index links into it. + Violation: no hit anywhere — the map exists but is not in context at session + start; the `@/index.md` import is missing. + Advisory: CLAUDE.md is wired but AGENTS.md lacks the routing reference — every + tool that reads AGENTS.md instead of CLAUDE.md starts blind. + +4. **Does a doc comment on an exported symbol state WHAT instead of WHY?** + Detection: for each exported declaration in the diff + (`grep -nE '^(type|func) [A-Z]' `), read its doc comment and + compare its tokens against the identifier and the first lines of the body — a + comment whose content is recoverable from the name or the code adds nothing. + Violation: the comment restates the identifier (`// Policy is a policy`) or + narrates the implementation, instead of carrying rationale, constraints, or + context the code cannot. Boundary: block comments *inside* function bodies are + NOT this question — they are `R3-storifying.md` Q3 (extraction candidates). + +5. **Is new exported API naked, or a feature-sized change undocumented at rung 2?** + Detection: in the diff, `grep -nE '^(type|func) [A-Z]'` on added lines and check + each for a preceding `//` doc comment; separately, compare new packages or entry + points in the diff against `` contents. + Violation: a new exported type or package with no doc comment; or a + feature-sized diff (new package, new entry point) with no doc-root entry — + the knowledge shipped without joining the network. + +6. **Did behavior change silently under an existing doc?** *(advisory)* + Detection: map the diff's changed packages to docs that cite their symbols or + package paths (grep `` for the package name and its exported symbols); + check whether any such doc is in the diff. + Violation (advisory): a package with a citing feature doc changed and the doc + did not — flag it with the doc's path as evidence; the fix is updating the + affected section, never appending history. + +7. **Does any file break the bundle contract?** + Detection: every content `.md` under `` starts with a terminated + frontmatter block (first line `---`, a closing `---` follows) carrying + `type` valued `feature` / `architecture` / `guide` and a non-empty + `description`. Index files carry NO frontmatter — except the root index, + whose block is exactly one `okf_version: "0.2"` (required there, forbidden + everywhere else). `grep -rn '^related:'` over doc-root + frontmatter; `find -name 'log.md'`. For every index line shaped + `- [doc](path) — text`, compare the text against the target's `description` + when it has one (⚠️-flagged lines exempt — recorded findings, not copies; + bare sub-index targets have no `description` and are skipped); the script's + `--fix` flag rewrites drifted lines from the descriptions. + Violation: a missing or unterminated frontmatter block on a content doc; a + missing or invalid required key (a `type` outside the three classes, an + empty `description`); frontmatter on a sub-index; any key besides + `okf_version` on the root index; a missing, duplicated, or mis-valued root + `okf_version`; a `related:` key anywhere; a `log.md` anywhere in the doc + root; an index line that drifted from the `description` it copies. + Advisory branch: a doc whose `stale_after` is in the past (or + `status: deprecated`) with no ⚠️ on its index line — recorded staleness the + map does not show; the fix is re-copying the line (drift-check rule above). diff --git a/lang/go/scripts/repo-brain-adapter.sh b/lang/go/scripts/repo-brain-adapter.sh new file mode 100644 index 0000000..6dd4735 --- /dev/null +++ b/lang/go/scripts/repo-brain-adapter.sh @@ -0,0 +1,112 @@ +# ======================= language adapter: go ======================= +# Everything language-specific lives between these two marker comments. The +# driver below calls only the lang_* functions and LANG_* variables defined +# here; a build of this gate for another language replaces this block and +# nothing else. The fixture matrix (check-repo-brain_test.sh) is the contract +# every adapter must pass. +# +# Contract: +# LANG_PROJECT_MARKER file whose directory is a sub-project with its own doc root +# LANG_CODE_GLOB find(1) -name pattern for the language's code files +# LANG_FILE_EXT substring that flags a possible file citation (cheap pre-check) +# LANG_FILE_RE awk regex a citation span must match to be banned +# LANG_SYMBOL_RE awk regex for a bare backticked symbol worth resolving +# LANG_QUALIFIED_RE awk regex for a package-qualified `pkg.Sym` token +# lang_project_dirs stdout: one sub-project directory per line, repo root excluded +# lang_has_code exit 0 iff the repo holds at least one code file +# lang_declarations stdout: "pkg:" for every package/module; every +# declared identifier; and ownership pairs — "pkg.Ident" +# for the declaring package and "Type.Method" for the +# receiver — one per line (duplicates are fine) +# lang_code_edges stdout: "file:line:target" for every docs-path citation +# inside code files (grep -rno shape) +# +# Go: sub-projects are go.mod directories; exported identifiers start with an +# upper-case letter; the declaration set covers single-line and grouped +# `type (`/`var (`/`const (` declarations, functions, and methods. +LANG_PROJECT_MARKER="go.mod" +LANG_CODE_GLOB='*.go' +LANG_FILE_EXT=".go" +LANG_FILE_RE='\\.go(:[0-9]+)?$' +LANG_SYMBOL_RE='^[A-Z][A-Za-z0-9]*$' +LANG_QUALIFIED_RE='^[A-Za-z][A-Za-z0-9_]*\\.[A-Z][A-Za-z0-9]*$' + +lang_project_dirs() { + local m p + while IFS= read -r m; do + p=$(dirname "$m"); p="${p#./}" + [[ "$p" == "." || -z "$p" ]] && continue + printf '%s\n' "$p" + done < <(find . -name "$LANG_PROJECT_MARKER" -not -path '*/vendor/*' -not -path './.git/*' 2>/dev/null | sort) +} + +lang_has_code() { + find . -name "$LANG_CODE_GLOB" -not -path './vendor/*' -not -path '*/vendor/*' -not -path './.git/*' \ + -print -quit 2>/dev/null | grep -q . +} + +# Go declarations: package names (pkg:), every declared identifier, and +# ownership pairs — pkg.Ident for the declaring package, Type.Method for the +# receiver — from single-line and grouped type/var/const declarations, +# functions, and methods. The pairs let a qualified doc token resolve only +# against its actual owner, never against a same-named member elsewhere. +LANG_DECL_AWK=' +function emit(id) { + print id + if (curpkg != "") print curpkg "." id +} +FNR == 1 { curpkg = ""; inblock = "" } +inblock != "" { + if ($0 ~ /^\)/) { inblock = ""; next } + s = $0; sub(/^[ \t]+/, "", s) + if (s ~ /^[A-Za-z_]/) { + t = s; sub(/[ \t=([].*$/, "", t) + n = split(t, parts, ",") + for (i = 1; i <= n; i++) { + p = parts[i]; gsub(/[ \t]/, "", p) + if (p ~ /^[A-Za-z_][A-Za-z0-9_]*$/) emit(p) + } + } + next +} +/^package [A-Za-z_]/ { s = $0; sub(/^package /, "", s); sub(/[^A-Za-z0-9_].*$/, "", s); curpkg = s; print "pkg:" s; next } +/^(type|var|const) \(/ { inblock = "y"; next } +/^func \(/ { + r = $0; sub(/^func \(/, "", r); sub(/\).*$/, "", r) + gsub(/\*/, "", r); sub(/^[ \t]+/, "", r); sub(/[ \t]+$/, "", r) + nr = split(r, rp, /[ \t]+/); rt = rp[nr] + sub(/\[.*$/, "", rt) + s = $0; sub(/^func \([^)]*\)[ \t]*/, "", s); sub(/[ \t([].*$/, "", s) + if (s ~ /^[A-Za-z_][A-Za-z0-9_]*$/) { + emit(s) + if (rt ~ /^[A-Za-z_][A-Za-z0-9_]*$/) print rt "." s + } + next +} +/^func [A-Za-z_]/ { + s = $0; sub(/^func /, "", s); sub(/[ \t([].*$/, "", s) + if (s ~ /^[A-Za-z_][A-Za-z0-9_]*$/) emit(s) + next +} +/^(type|var|const) [A-Za-z_]/ { + s = $0; sub(/^(type|var|const) /, "", s) + t = s; sub(/[ \t=([].*$/, "", t) + n = split(t, parts, ",") + for (i = 1; i <= n; i++) { + p = parts[i]; gsub(/[ \t]/, "", p) + if (p ~ /^[A-Za-z_][A-Za-z0-9_]*$/) emit(p) + } + next +} +' + +lang_declarations() { + find . -name "$LANG_CODE_GLOB" -not -path '*/vendor/*' -not -path './.git/*' -print0 2>/dev/null \ + | xargs -0 awk "$LANG_DECL_AWK" +} + +lang_code_edges() { + grep -rnoE '(docs|\.ai|\.ainav)/[A-Za-z0-9._/-]+\.md' \ + --include="$LANG_CODE_GLOB" --exclude-dir=vendor --exclude-dir=.git . 2>/dev/null +} +# ===================== end language adapter: go ===================== diff --git a/lang/go/skills/code-designing/layout-scan.md b/lang/go/skills/code-designing/layout-scan.md new file mode 100644 index 0000000..2162465 --- /dev/null +++ b/lang/go/skills/code-designing/layout-scan.md @@ -0,0 +1,2 @@ +Scan the codebase structure: vertical (`internal/feature/{handler,service}.go`) vs +horizontal (`internal/{handlers,services}/feature.go`)? diff --git a/lang/go/skills/code-designing/linter-triggers.md b/lang/go/skills/code-designing/linter-triggers.md new file mode 100644 index 0000000..4086947 --- /dev/null +++ b/lang/go/skills/code-designing/linter-triggers.md @@ -0,0 +1,5 @@ +- Linter failures that need a design decision, not a mechanical fix: + - `argument-limit` (>4 params) → design an options struct (grouping data that travels together — score it per `../../rules/R1-primitive-obsession.md`) + - `function-result-limit` (>3 returns) / `confusing-results` → design a named result type (same R1 scoring) + - `file-length-limit` (>450 lines) → split juicy types into their own files (juiciness per R1; file-per-type per `../../rules/R5-vertical-slice.md`); a single god type routes to @refactoring's god-object decomposition procedure first + - Package-size yellow/red zone → re-model with sub-packages *before* the zone escalates (@refactoring ``) diff --git a/lang/go/skills/code-designing/package-structure.md b/lang/go/skills/code-designing/package-structure.md new file mode 100644 index 0000000..39486e1 --- /dev/null +++ b/lang/go/skills/code-designing/package-structure.md @@ -0,0 +1,5 @@ +Package Structure: +[feature]/ + ├── [type].go # each juicy type in its own file + ├── service.go + └── handler.go diff --git a/lang/go/skills/documentation/edge-verification.md b/lang/go/skills/documentation/edge-verification.md new file mode 100644 index 0000000..ab21e11 --- /dev/null +++ b/lang/go/skills/documentation/edge-verification.md @@ -0,0 +1,4 @@ +then confirm the package + still vets. Go files only — the gate verifies edges in `.go` files alone, so an + edge in another language is unverifiable; report such docs as unwired instead of + improvising. diff --git a/lang/go/skills/linter-driven-development/pkg-lint.md b/lang/go/skills/linter-driven-development/pkg-lint.md new file mode 100644 index 0000000..1a6a444 --- /dev/null +++ b/lang/go/skills/linter-driven-development/pkg-lint.md @@ -0,0 +1 @@ +1. Package-scoped lint (fast): `golangci-lint run .//...` diff --git a/lang/go/skills/linter-driven-development/pre-flight.md b/lang/go/skills/linter-driven-development/pre-flight.md new file mode 100644 index 0000000..880355d --- /dev/null +++ b/lang/go/skills/linter-driven-development/pre-flight.md @@ -0,0 +1,3 @@ +1. **Verify Go project**: `go.mod` in root or parent directories. +2. **Discover commands** (README.md, CLAUDE.md, Makefile, Taskfile.yaml, in that + order): test + lint commands. Fallbacks: `go test ./...`, `golangci-lint run --fix`. diff --git a/lang/go/skills/pre-commit-review/critic-prefilter.md b/lang/go/skills/pre-commit-review/critic-prefilter.md new file mode 100644 index 0000000..684830a --- /dev/null +++ b/lang/go/skills/pre-commit-review/critic-prefilter.md @@ -0,0 +1 @@ +`git diff --cached -- '*.go' | grep -E '^\+.*//' | grep -vE '//(go:|nolint| Output:)'` diff --git a/lang/go/skills/pre-commit-review/nolint-finding.md b/lang/go/skills/pre-commit-review/nolint-finding.md new file mode 100644 index 0000000..f341ec5 --- /dev/null +++ b/lang/go/skills/pre-commit-review/nolint-finding.md @@ -0,0 +1,3 @@ +Also in-context: a new `//nolint` directive or `.golangci.yaml` exclusion in the diff is +itself a finding — the change must justify, with evidence, that the rule genuinely does +not apply. diff --git a/lang/go/skills/refactoring/file-and-package-routing.md b/lang/go/skills/refactoring/file-and-package-routing.md new file mode 100644 index 0000000..3b8bc31 --- /dev/null +++ b/lang/go/skills/refactoring/file-and-package-routing.md @@ -0,0 +1,20 @@ +**`file-length-limit` (>450 lines):** + +| File pattern | Action | +|---|---| +| Multiple juicy types | Route to @code-designing — one juicy type per file (juiciness per R1) | +| Single god type (>15 methods) | `reference.md` → god-object decomposition, then @code-designing for the composition | +| Long functions, few types | Storify → extract functions (R3) | + + +**Package-size zones** — count non-test `.go` files per directory: + +``` +find -maxdepth 1 -type f -name '*.go' -not -name '*_test.go' -not -name '*_gen.go' -not -name '*.pb.go' | wc -l +``` + +≤7 green — fine. 8–12 yellow — design review *before the next file lands*. ≥13 red — +**must decompose**. Either zone: run the 3-step design review in `reference.md` → +"Package decomposition" (it is a *design* review — missing domain types are the +disease, file count the symptom). Invoke @code-designing to validate extracted types. + diff --git a/lang/go/skills/refactoring/nolint-prohibition.md b/lang/go/skills/refactoring/nolint-prohibition.md new file mode 100644 index 0000000..7376136 --- /dev/null +++ b/lang/go/skills/refactoring/nolint-prohibition.md @@ -0,0 +1,10 @@ +**NEVER add `//nolint` to avoid refactoring.** Handle the error, validate at the +boundary, or reduce the complexity. Before finishing, scan all uncommitted files: + +```bash +changed_files=$({ git diff --name-only; git diff --cached --name-only; } | sort -u) +[ -n "$changed_files" ] && printf '%s\n' "$changed_files" | xargs grep "//nolint" 2>/dev/null +``` + +Any hit → remove the directive and fix properly. Genuine false positives belong in +`.golangci.yaml` exclusions — with user approval, never unilaterally. diff --git a/lang/go/skills/refactoring/routing-table.md b/lang/go/skills/refactoring/routing-table.md new file mode 100644 index 0000000..3bc1a5e --- /dev/null +++ b/lang/go/skills/refactoring/routing-table.md @@ -0,0 +1,17 @@ +Normative linter→rule routing. (The lint-fixer agent embeds a compact copy of this +table in `../../agents/lint-fixer.md` — keep them consistent.) + +| Linter failure | Route | +|---|---| +| `gocyclo` / `cyclop` | `../../rules/R3-storifying.md` | +| `gocognit` | `../../rules/R3-storifying.md` | +| `funlen` | `../../rules/R3-storifying.md` | +| `nestif` | `../../rules/R3-storifying.md` | +| `maintidx` | `../../rules/R3-storifying.md` + `../../rules/R1-primitive-obsession.md` | +| `dupl` | `../../rules/R1-primitive-obsession.md` (extract shared type/logic); duplicated blocks that switch on the same kind/type discriminator → `../../rules/R11-conditional-dispatch.md` | +| `exhaustive` (missing enum cases) | `../../rules/R11-conditional-dispatch.md` — handle the case at the single dispatch site; a second switch appearing is the R11 violation itself | +| revive `file-length-limit`; package-size hook failures (`hooks/check-package-sizes.sh`) | `../../rules/R5-vertical-slice.md` — mechanics in `` below | +| `gochecknoglobals` / `gochecknoinits` | `../../rules/R8-no-globals.md` | +| `ireturn` / interface lint on single-impl interfaces | `../../rules/R6-test-only-interfaces.md` | +| `go test -race` failures; `govet` `copylocks` | `../../rules/R10-concurrency-safety.md` | +| `wrapcheck`, `errcheck`, `goconst`, revive `early-return`, renames | Mechanical — fix directly (`fmt.Errorf("context: %w", err)`, handle the error, extract constant, invert & return early). Enum-shaped `goconst` strings → R1's "Name enum strings" move. | diff --git a/lang/go/skills/refactoring/testing-integration.md b/lang/go/skills/refactoring/testing-integration.md new file mode 100644 index 0000000..0fbe2e8 --- /dev/null +++ b/lang/go/skills/refactoring/testing-integration.md @@ -0,0 +1,4 @@ +**MANDATORY** after creating new types or extracting functions: +1. List created types: `grep -RnE "^type[[:space:]]+\w+" --include="*.go" .` +2. Missing tests for any of them → STOP and invoke @testing. +3. Coverage: `go test -cover ./...` — leaf types must show 100% (R7). diff --git a/lang/go/skills/testing/checklists.md b/lang/go/skills/testing/checklists.md new file mode 100644 index 0000000..7a46fe0 --- /dev/null +++ b/lang/go/skills/testing/checklists.md @@ -0,0 +1,35 @@ + +- [ ] All unit tests in pkg_test package +- [ ] Testing public API only (no private methods) +- [ ] Table-driven tests use named struct fields +- [ ] No conditionals in test cases (complexity = 1) +- [ ] Using in-memory implementations from testutils +- [ ] No time.Sleep (using channels/waitgroups) +- [ ] Leaf types have 100% coverage + + + +- [ ] Test seams between components +- [ ] Use in-memory or binary dependencies (avoid Docker) +- [ ] Build tags for optional execution (`//go:build integration`) +- [ ] Cover happy path and error scenarios across boundaries +- [ ] Real or testutils implementations (minimal mocking) + + + +- [ ] Located in tests/ folder at project root +- [ ] Black box testing via CLI/API +- [ ] Appropriate dependency level chosen (in-memory, binary, or test-containers) +- [ ] Tests critical end-to-end workflows +- [ ] Dependencies documented (what's needed to run tests) +- [ ] CI-compatible (either fast in-memory or containerized setup) + + + +- [ ] Reusable mocks in internal/testutils/ +- [ ] Test infrastructure has its own tests +- [ ] DSL provides readable test setup +- [ ] Can be exposed as CLI for manual testing + + +See reference.md for complete testing guidelines and examples. diff --git a/lang/go/skills/testing/integration-tests-workflow.md b/lang/go/skills/testing/integration-tests-workflow.md new file mode 100644 index 0000000..d93b2fa --- /dev/null +++ b/lang/go/skills/testing/integration-tests-workflow.md @@ -0,0 +1,18 @@ +**Purpose**: Middle rungs — each adds one real layer; test the seams and emergent behaviors that layer brings + +1. **Identify integration points** - Where packages/components interact +2. **Choose dependencies** - Prefer: in-memory > binary > test-containers +3. **Write tests** - In `pkg_test` or `integration_test.go` with build tags +4. **Test workflows** - Cover happy path and error scenarios across boundaries +5. **Use real or testutils implementations** - Avoid heavy mocking + +**File organization:** +```go +//go:build integration + +package user_test + +// Test Service + Repository + real/mock dependencies +``` + +See reference.md for integration test patterns with dependencies. diff --git a/lang/go/skills/testing/key-patterns.md b/lang/go/skills/testing/key-patterns.md new file mode 100644 index 0000000..f494ec9 --- /dev/null +++ b/lang/go/skills/testing/key-patterns.md @@ -0,0 +1,18 @@ +**Table-Driven Tests (Cyclomatic Complexity = 1):** +- **NEVER use wantErr bool** - Splits test logic, adds conditionals +- **Max complexity = 1 inside t.Run()** - No if/else, no switch, no conditionals +- Separate success and error test functions (TestFoo_Success, TestFoo_Error) +- Always use named struct fields (linter reorders fields) +- Canonical violation, detection commands, and split pattern: `../../rules/R7-test-placement.md`; worked example in reference.md + +**Testify Suites:** +- Only for complex infrastructure (HTTP servers, DBs, OpenTelemetry) +- SetupSuite/TearDownSuite for expensive shared setup +- SetupTest/TearDownTest for per-test isolation + +**Synchronization:** +- Never use time.Sleep (flaky, slow) +- Use channels with select/timeout for async operations +- Use sync.WaitGroup for concurrent operations + +See reference.md for complete patterns with code examples. diff --git a/lang/go/skills/testing/output-format.md b/lang/go/skills/testing/output-format.md new file mode 100644 index 0000000..5f4f478 --- /dev/null +++ b/lang/go/skills/testing/output-format.md @@ -0,0 +1,37 @@ +After writing tests: + +``` +TESTING COMPLETE + +Unit Tests: +- user/user_id_test.go: 100% (4 test cases) +- user/email_test.go: 100% (6 test cases) +- user/service_test.go: 100% (8 test cases) + +Integration Tests: +- user/integration_test.go: 3 workflows tested +- Dependencies: In-memory DB, httptest mock server + +System Tests: +- tests/cli_test.go: 2 end-to-end workflows (in-memory mocks) +- tests/api_test.go: 1 full API workflow (binary executable) +- tests/db_test.go: 1 database workflow (test-containers) + +Test Infrastructure: +- internal/testutils/httpserver: In-memory mock API with DSL +- internal/testutils/mockdb: In-memory database mock +- internal/testutils/containers: Test-container helpers + +Test Execution: +$ go test ./... # All tests (in-memory only) +$ go test -tags=integration ./... # Include integration tests +$ go test ./tests/... # System tests (may need containers) + +All tests pass +100% coverage on leaf types + +Next Steps: +1. Run linter: task lintwithfix +2. If linter fails → use @refactoring skill +3. If linter passes → use @pre-commit-review skill +``` diff --git a/lang/go/skills/testing/quick-start.md b/lang/go/skills/testing/quick-start.md new file mode 100644 index 0000000..67a2a54 --- /dev/null +++ b/lang/go/skills/testing/quick-start.md @@ -0,0 +1,7 @@ +1. **Find the lowest rung** that contains the behavior (see composition_ladder) +2. **Choose structure**: table-driven (simple) or testify suites (complex setup) +3. **Write in pkg_test package** - test public API only +4. **Compose real layers** - in-memory/in-process implementations from testutils +5. **Avoid pitfalls**: No time.Sleep, no conditionals in test cases + +Ready after tests? Run linter: `task lintwithfix` diff --git a/lang/go/skills/testing/reusable-infrastructure.md b/lang/go/skills/testing/reusable-infrastructure.md new file mode 100644 index 0000000..d890a5d --- /dev/null +++ b/lang/go/skills/testing/reusable-infrastructure.md @@ -0,0 +1,15 @@ +Build shared test infrastructure in `internal/testutils/`: +- In-memory mock servers with DSL (HTTP, DB, file system) +- Reusable across all test levels +- Test the infrastructure itself! +- Can expose as CLI tools for manual testing + +**Dependency Priority** (choose appropriate level): +1. **In-memory** (fastest): Pure Go, httptest, in-memory DB - use when testing your code's logic +2. **Binary** (isolated): Standalone executable via exec.Command - use when testing against real service +3. **Test-containers** (realistic): Programmatic Docker from Go - use when you need real external services +4. **Docker-compose** (full stack): For complex multi-service scenarios + +Choose based on what you're testing, not dogmatically. In-memory is fastest but sometimes you need real services. + +See reference.md for comprehensive testutils patterns and DSL examples. diff --git a/lang/go/skills/testing/success-criteria.md b/lang/go/skills/testing/success-criteria.md new file mode 100644 index 0000000..46504be --- /dev/null +++ b/lang/go/skills/testing/success-criteria.md @@ -0,0 +1,11 @@ +Testing is complete when ALL of the following are true: + +- [ ] All unit tests in pkg_test package testing public API only +- [ ] Table-driven tests use named struct fields +- [ ] No wantErr bool - success and error cases in separate test functions +- [ ] Cyclomatic complexity = 1 inside t.Run() (no if/else, no switch) +- [ ] Leaf types have 100% coverage +- [ ] Integration tests cover component seams +- [ ] System tests in tests/ folder with appropriate dependency level +- [ ] No time.Sleep (using channels/waitgroups) +- [ ] Tests pass and linter approves diff --git a/lang/go/skills/testing/system-tests-workflow.md b/lang/go/skills/testing/system-tests-workflow.md new file mode 100644 index 0000000..db86996 --- /dev/null +++ b/lang/go/skills/testing/system-tests-workflow.md @@ -0,0 +1,45 @@ +**Purpose**: Top rung — black box test the entire system, critical end-to-end workflows + +1. **Place in tests/ folder** - At project root, separate from packages +2. **Test via CLI/API** - exec.Command for CLI, HTTP client for APIs +3. **Choose dependency level** based on what you're testing: + - **In-memory**: Fastest, use when testing your code's behavior + - **Binary**: exec.Command to run real executables in separate process + - **Test-containers**: When you need real external services (DB, message queue) +4. **Test critical workflows** - User journeys, not every edge case + +**Example with in-memory mock:** +```go +// tests/cli_test.go - Testing CLI against mock API +func TestCLI_UserWorkflow(t *testing.T) { + mockAPI := testutils.NewMockServer(). + OnGET("/users/1").RespondJSON(200, user). + Build() // In-memory httptest.Server + defer mockAPI.Close() + + cmd := exec.Command("./myapp", "get-user", "1", + "--api-url", mockAPI.URL()) + output, err := cmd.CombinedOutput() + // Assert on output +} +``` + +**Example with binary executable:** +```go +// tests/integration_test.go - Testing against real service binary +func TestSystem_WithRealService(t *testing.T) { + // Start service binary in background + svc := exec.Command("./myservice", "--port", "8080") + svc.Start() + defer svc.Process.Kill() + + // Wait for service to be ready + waitForHealthy(t, "http://localhost:8080/health") + + // Run tests against real service + resp, err := http.Get("http://localhost:8080/api/users") + // Assert on response +} +``` + +See reference.md for comprehensive system test patterns including test-containers. diff --git a/lang/go/skills/testing/unit-tests-workflow.md b/lang/go/skills/testing/unit-tests-workflow.md new file mode 100644 index 0000000..aea905f --- /dev/null +++ b/lang/go/skills/testing/unit-tests-workflow.md @@ -0,0 +1,14 @@ +**Purpose**: Rung 0 — test leaf types in isolation, 100% coverage target + +1. **Identify leaf types** - Self-contained types with logic +2. **Choose structure** - Table-driven (simple) or testify suites (complex setup) +3. **Write in pkg_test package** - Test public API only +4. **Use in-memory implementations** - From testutils or local implementations +5. **Avoid pitfalls** - No time.Sleep, no conditionals in cases, no private method tests + +**Test structure:** +- Table-driven: Separate success/error test functions (complexity = 1) +- Testify suites: Only for complex infrastructure setup (HTTP servers, DBs) +- Always use named struct fields (linter reorders fields) + +See reference.md for detailed patterns and examples. diff --git a/tools/ldd-gen/.golangci.yaml b/tools/ldd-gen/.golangci.yaml new file mode 100644 index 0000000..1ee912e --- /dev/null +++ b/tools/ldd-gen/.golangci.yaml @@ -0,0 +1,86 @@ +version: "2" + +linters: + default: standard + enable: + - errcheck + - exhaustive + - funlen + - gochecknoglobals + - gochecknoinits + - gocognit + - gocyclo + - goconst + - misspell + - nestif + - revive + - wrapcheck + settings: + exhaustive: + default-signifies-exhaustive: true + gocognit: + min-complexity: 15 + gocyclo: + min-complexity: 10 + funlen: + lines: 50 + statements: 40 + nestif: + min-complexity: 2 + goconst: + min-len: 3 + min-occurrences: 3 + errcheck: + # Report output goes to a writer the caller chose; a failed write there + # has nowhere better to be reported. + exclude-functions: + - fmt.Fprintf + - fmt.Fprintln + wrapcheck: + # Errors from this module's own packages already carry their context. + ignore-package-globs: + - github.com/buzzdan/ai-coding-rules/tools/ldd-gen/* + revive: + rules: + - name: argument-limit + arguments: [4] + - name: function-result-limit + arguments: [3] + - name: early-return + - name: bare-return + - name: blank-imports + - name: context-as-argument + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unused-parameter + - name: unreachable-code + - name: redefines-builtin-id + exclusions: + rules: + # Tests are long and repeat literals by design. + - path: _test\.go + linters: + - funlen + - goconst + - gocognit + - gocyclo + +formatters: + enable: + - gofmt + - goimports diff --git a/tools/ldd-gen/go.mod b/tools/ldd-gen/go.mod new file mode 100644 index 0000000..7aed643 --- /dev/null +++ b/tools/ldd-gen/go.mod @@ -0,0 +1,13 @@ +module github.com/buzzdan/ai-coding-rules/tools/ldd-gen + +go 1.25 + +require ( + github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect +) diff --git a/tools/ldd-gen/go.sum b/tools/ldd-gen/go.sum new file mode 100644 index 0000000..c4c1710 --- /dev/null +++ b/tools/ldd-gen/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/ldd-gen/internal/binding/binding.go b/tools/ldd-gen/internal/binding/binding.go new file mode 100644 index 0000000..5a8b60d --- /dev/null +++ b/tools/ldd-gen/internal/binding/binding.go @@ -0,0 +1,87 @@ +// Package binding is one language's view of the generator inputs under +// lang//: the profile, the snippet files core templates splice in with +// {{include "..."}}, whole-file overrides of core templates, and the files +// copied through to the plugin untouched. +package binding + +import ( + "errors" + "fmt" + "io/fs" + "strings" + "testing/fstest" + + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/profile" +) + +const ( + profileFile = "profile.yaml" + overridesDir = "overrides" + passthroughDir = "passthrough" +) + +// Binding reads a language directory. Every path it accepts is relative to +// that directory, so the same binding works from a checkout or a test FS. +type Binding struct { + fsys fs.FS + profile profile.Profile +} + +// Load parses profile.yaml at the root of fsys. The profile is required; a +// language directory without one is not a binding. +func Load(fsys fs.FS) (Binding, error) { + data, err := fs.ReadFile(fsys, profileFile) + if err != nil { + return Binding{}, fmt.Errorf("binding: %w", err) + } + p, err := profile.Parse(data) + if err != nil { + return Binding{}, err + } + return Binding{fsys: fsys, profile: p}, nil +} + +// Profile returns the parsed profile.yaml. +func (b Binding) Profile() profile.Profile { return b.profile } + +// Include returns the body of a snippet file with exactly one trailing newline +// removed. The template around the include owns the surrounding blank lines, +// which is what lets a rendered file match the original byte for byte. +func (b Binding) Include(name string) (string, error) { + data, err := fs.ReadFile(b.fsys, name) + if err != nil { + return "", fmt.Errorf("include %q: %w", name, err) + } + return strings.TrimSuffix(string(data), "\n"), nil +} + +// Overrides returns the tree of whole-file replacements for core templates, +// keyed by the core path they replace. A binding without an overrides +// directory yields an empty tree. +func (b Binding) Overrides() (fs.FS, error) { + return b.optionalDir(overridesDir) +} + +// Passthrough returns the tree of files copied into the plugin unchanged. A +// binding without a passthrough directory yields an empty tree. +func (b Binding) Passthrough() (fs.FS, error) { + return b.optionalDir(passthroughDir) +} + +func (b Binding) optionalDir(name string) (fs.FS, error) { + info, err := fs.Stat(b.fsys, name) + if errors.Is(err, fs.ErrNotExist) { + return fstest.MapFS{}, nil // an empty tree that walks cleanly + } + if err != nil { + return nil, fmt.Errorf("%s: %w", name, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("%s: not a directory", name) + } + sub, err := fs.Sub(b.fsys, name) + if err != nil { + return nil, fmt.Errorf("%s: %w", name, err) + } + return sub, nil +} diff --git a/tools/ldd-gen/internal/binding/binding_test.go b/tools/ldd-gen/internal/binding/binding_test.go new file mode 100644 index 0000000..58d9024 --- /dev/null +++ b/tools/ldd-gen/internal/binding/binding_test.go @@ -0,0 +1,148 @@ +package binding_test + +import ( + "io/fs" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/binding" +) + +const profileYAML = `plugin: p +lang: L +cmd_prefix: x +src_glob: "*.x" +test_glob: "_test.x" +project_marker: x.mod +nolint: "#nolint" +comment_prefix: "#" +default_test: xtest +default_lint: xlint +default_lint_fix: xlint --fix +` + +func langFS() fstest.MapFS { + return fstest.MapFS{ + "profile.yaml": {Data: []byte(profileYAML)}, + "rules/R1/example.md": {Data: []byte("body line 1\nbody line 2\n")}, + "rules/R1/two-newlines.md": {Data: []byte("body\n\n")}, + "overrides/rules/R6.md": {Data: []byte("replaced\n")}, + "passthrough/README.md": {Data: []byte("readme\n")}, + "passthrough/hooks/hook.sh": {Data: []byte("#!/bin/sh\n"), Mode: 0o755}, + "passthrough/.claude-plugin/p": {Data: []byte("{}\n")}, + } +} + +func TestLoad(t *testing.T) { + t.Parallel() + b, err := binding.Load(langFS()) + require.NoError(t, err) + assert.Equal(t, "p", b.Profile().Plugin) +} + +func TestLoad_Errors(t *testing.T) { + t.Parallel() + cases := []struct { + name string + fsys fstest.MapFS + want string + }{ + {name: "no profile", fsys: fstest.MapFS{}, want: "profile.yaml"}, + {name: "invalid profile", fsys: fstest.MapFS{"profile.yaml": {Data: []byte("plugin: x\n")}}, want: "missing"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := binding.Load(tc.fsys) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + }) + } +} + +func TestInclude(t *testing.T) { + t.Parallel() + b, err := binding.Load(langFS()) + require.NoError(t, err) + cases := []struct { + name string + file string + want string + }{ + {name: "trims exactly one trailing newline", file: "rules/R1/example.md", want: "body line 1\nbody line 2"}, + {name: "keeps the second newline", file: "rules/R1/two-newlines.md", want: "body\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := b.Include(tc.file) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestInclude_Missing(t *testing.T) { + t.Parallel() + b, err := binding.Load(langFS()) + require.NoError(t, err) + _, err = b.Include("rules/R9/nope.md") + require.Error(t, err) + assert.Contains(t, err.Error(), "rules/R9/nope.md") +} + +func TestOverrides(t *testing.T) { + t.Parallel() + b, err := binding.Load(langFS()) + require.NoError(t, err) + ov, err := b.Overrides() + require.NoError(t, err) + data, err := fs.ReadFile(ov, "rules/R6.md") + require.NoError(t, err) + assert.Equal(t, "replaced\n", string(data)) + _, err = fs.ReadFile(ov, "rules/R1.md") + require.ErrorIs(t, err, fs.ErrNotExist) +} + +func TestPassthrough(t *testing.T) { + t.Parallel() + b, err := binding.Load(langFS()) + require.NoError(t, err) + pt, err := b.Passthrough() + require.NoError(t, err) + data, err := fs.ReadFile(pt, "hooks/hook.sh") + require.NoError(t, err) + assert.Equal(t, "#!/bin/sh\n", string(data)) + info, err := fs.Stat(pt, "hooks/hook.sh") + require.NoError(t, err) + assert.NotZero(t, info.Mode()&0o111) +} + +func TestOptionalDirs_Absent(t *testing.T) { + t.Parallel() + b, err := binding.Load(fstest.MapFS{"profile.yaml": {Data: []byte(profileYAML)}}) + require.NoError(t, err) + pt, err := b.Passthrough() + require.NoError(t, err) + _, err = fs.ReadFile(pt, "anything") + require.ErrorIs(t, err, fs.ErrNotExist) + ov, err := b.Overrides() + require.NoError(t, err) + _, err = fs.ReadFile(ov, "anything") + require.ErrorIs(t, err, fs.ErrNotExist) +} + +func TestOptionalDirs_NotADirectory(t *testing.T) { + t.Parallel() + b, err := binding.Load(fstest.MapFS{ + "profile.yaml": {Data: []byte(profileYAML)}, + "passthrough": {Data: []byte("a file, not a directory\n")}, + }) + require.NoError(t, err) + _, err = b.Passthrough() + require.Error(t, err) + assert.Contains(t, err.Error(), "not a directory") +} diff --git a/tools/ldd-gen/internal/check/check.go b/tools/ldd-gen/internal/check/check.go new file mode 100644 index 0000000..b31b386 --- /dev/null +++ b/tools/ldd-gen/internal/check/check.go @@ -0,0 +1,240 @@ +// Package check compares a rendered plugin tree with the plugin directory on +// disk and writes the tree back to it. The directory is the generator's golden +// copy: any byte the generator would change is a finding. +package check + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "syscall" + + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/render" +) + +// Kind says how a path on disk disagrees with the rendered tree. +type Kind string + +// The four ways a file on disk can disagree with its rendering. +const ( + Missing Kind = "missing" // rendered, not on disk + Extra Kind = "extra" // on disk, not rendered, not ignored + Changed Kind = "changed" // bytes differ + ExecChanged Kind = "exec-changed" // same bytes, different executable bit +) + +// Difference is one path where the directory and the rendered tree disagree. +type Difference struct { + Path string + Kind Kind +} + +// Ignore reports output paths the generator does not own, such as eval cases a +// run copies into the plugin directory. +type Ignore func(rel string) bool + +// Compare lists every difference between the rendered tree and dir, sorted by +// path. An empty result means the directory is exactly what the generator +// would produce. +func Compare(want render.Tree, dir string, ignore Ignore) ([]Difference, error) { + have, err := readDir(dir, IgnoredAndUnowned(want, ignore)) + if err != nil { + return nil, err + } + var diffs []Difference + for path, w := range want { + h, ok := have[path] + if !ok { + diffs = append(diffs, Difference{Path: path, Kind: Missing}) + continue + } + if kind, differs := compareFile(w, h); differs { + diffs = append(diffs, Difference{Path: path, Kind: kind}) + } + } + for path := range have { + if _, ok := want[path]; !ok { + diffs = append(diffs, Difference{Path: path, Kind: Extra}) + } + } + sort.Slice(diffs, func(i, j int) bool { return diffs[i].Path < diffs[j].Path }) + return diffs, nil +} + +// IgnoredAndUnowned narrows ignore to paths the tree does not produce: a file +// the generator owns is always compared, even inside an ignored directory. +func IgnoredAndUnowned(tree render.Tree, ignore Ignore) Ignore { + return func(rel string) bool { + _, owned := tree[rel] + return !owned && ignore(rel) + } +} + +func compareFile(want, have render.File) (Kind, bool) { + if !bytes.Equal(want.Data, have.Data) { + return Changed, true + } + if want.Exec != have.Exec { + return ExecChanged, true + } + return "", false +} + +// Write replaces dir with the rendered tree: every file the generator owns is +// written, every other file that is not ignored is removed, and directories +// left empty are dropped. Ignored files and directories are never touched. +func Write(tree render.Tree, dir string, ignore Ignore) error { + skip := IgnoredAndUnowned(tree, ignore) + have, err := readDir(dir, skip) + if err != nil { + return err + } + for path := range have { + if _, keep := tree[path]; !keep { + if err := os.Remove(filepath.Join(dir, path)); err != nil { + return fmt.Errorf("remove: %w", err) + } + } + } + for path, f := range tree { + if err := writeFile(filepath.Join(dir, path), f); err != nil { + return err + } + } + return removeEmptyDirs(dir, ignore) +} + +func writeFile(path string, f render.File) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + mode := os.FileMode(0o644) + if f.Exec { + mode = 0o755 + } + if err := os.WriteFile(path, f.Data, mode); err != nil { + return fmt.Errorf("write: %w", err) + } + // WriteFile only applies mode to new files; existing ones keep theirs. + if err := os.Chmod(path, mode); err != nil { + return fmt.Errorf("chmod: %w", err) + } + return nil +} + +func removeEmptyDirs(dir string, ignore Ignore) error { + var dirs []string + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || !d.IsDir() || path == dir { + return err + } + rel, _ := filepath.Rel(dir, path) + if ignore(filepath.ToSlash(rel)) { + return fs.SkipDir + } + dirs = append(dirs, path) + return nil + }) + if err != nil { + return fmt.Errorf("walk: %w", err) + } + // Deepest first, so a directory whose only child was empty goes too. + sort.Sort(sort.Reverse(sort.StringSlice(dirs))) + for _, d := range dirs { + if err := os.Remove(d); err != nil && !isNotEmpty(err) { + return fmt.Errorf("rmdir: %w", err) + } + } + return nil +} + +// POSIX lets rmdir report a non-empty directory as either error. +func isNotEmpty(err error) bool { + return errors.Is(err, syscall.ENOTEMPTY) || errors.Is(err, syscall.EEXIST) +} + +// A missing directory reads as empty, so the first generation can create it. +func readDir(dir string, skip Ignore) (render.Tree, error) { + tree := render.Tree{} + fsys := os.DirFS(dir) + err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + if path == "." && errors.Is(err, fs.ErrNotExist) { + return fs.SkipAll + } + return err + } + if d.IsDir() || skip(path) { + return nil + } + f, err := readFile(fsys, path) + if err != nil { + return err + } + tree[path] = f + return nil + }) + if err != nil { + return nil, fmt.Errorf("read %s: %w", dir, err) + } + return tree, nil +} + +func readFile(fsys fs.FS, path string) (render.File, error) { + data, err := fs.ReadFile(fsys, path) + if err != nil { + return render.File{}, fmt.Errorf("read: %w", err) + } + info, err := fs.Stat(fsys, path) + if err != nil { + return render.File{}, fmt.Errorf("stat: %w", err) + } + return render.File{Data: data, Exec: info.Mode()&0o111 != 0}, nil +} + +// Report prints one line per difference and, for changed files, a unified +// diff from the file on disk to the rendering when the diff command is +// installed. When it is not, the line says so instead of staying silent. +func Report(w io.Writer, diffs []Difference, want render.Tree, dir string) { + for _, d := range diffs { + fmt.Fprintf(w, "%-13s %s\n", d.Kind, d.Path) + if d.Kind == Changed { + if err := unifiedDiff(w, filepath.Join(dir, d.Path), want[d.Path].Data); err != nil { + fmt.Fprintf(w, "(diff unavailable: %v)\n", err) + } + } + } +} + +func unifiedDiff(w io.Writer, onDisk string, rendered []byte) error { + tmp, err := os.CreateTemp("", "ldd-gen-*") + if err != nil { + return fmt.Errorf("temp file: %w", err) + } + defer removeTemp(tmp.Name()) + if _, err := tmp.Write(rendered); err != nil { + return fmt.Errorf("temp file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("temp file: %w", err) + } + cmd := exec.Command("diff", "-u", onDisk, tmp.Name()) + cmd.Stdout = w + err = cmd.Run() + var exit *exec.ExitError + if errors.As(err, &exit) && exit.ExitCode() == 1 { + return nil // diff exits 1 when the files differ, which is the point + } + if err != nil { + return fmt.Errorf("diff: %w", err) + } + return nil +} + +func removeTemp(path string) { _ = os.Remove(path) } diff --git a/tools/ldd-gen/internal/check/check_test.go b/tools/ldd-gen/internal/check/check_test.go new file mode 100644 index 0000000..3d5ecc6 --- /dev/null +++ b/tools/ldd-gen/internal/check/check_test.go @@ -0,0 +1,125 @@ +package check_test + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/check" + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/render" +) + +func none(string) bool { return false } + +func ignoreEvalsTree(rel string) bool { return strings.HasPrefix(rel, "evals/") } + +func sample() render.Tree { + return render.Tree{ + "README.md": {Data: []byte("readme\n")}, + "rules/R1.md": {Data: []byte("r1\n")}, + "hooks/hook.sh": {Data: []byte("#!/bin/sh\n"), Exec: true}, + "evals/README.md": {Data: []byte("pointer\n")}, + } +} + +func write(t *testing.T, dir, rel, content string) { + t.Helper() + path := filepath.Join(dir, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) +} + +func TestWriteThenCompare_Identical(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, check.Write(sample(), dir, none)) + diffs, err := check.Compare(sample(), dir, none) + require.NoError(t, err) + assert.Empty(t, diffs) + info, err := os.Stat(filepath.Join(dir, "hooks/hook.sh")) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&0o111) +} + +func TestCompare_MissingDirIsAllMissing(t *testing.T) { + t.Parallel() + diffs, err := check.Compare(render.Tree{"a.md": {}}, filepath.Join(t.TempDir(), "nope"), none) + require.NoError(t, err) + assert.Equal(t, []check.Difference{{Path: "a.md", Kind: check.Missing}}, diffs) +} + +func TestCompare_Differences(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, check.Write(sample(), dir, none)) + write(t, dir, "rules/R1.md", "edited\n") + write(t, dir, "rules/R2.md", "extra\n") + write(t, dir, "evals/cases/x/prompt.md", "ignored\n") + write(t, dir, "evals/README.md", "pointer edited\n") + require.NoError(t, os.Remove(filepath.Join(dir, "README.md"))) + require.NoError(t, os.Chmod(filepath.Join(dir, "hooks/hook.sh"), 0o644)) + + diffs, err := check.Compare(sample(), dir, ignoreEvalsTree) + require.NoError(t, err) + assert.Equal(t, []check.Difference{ + {Path: "README.md", Kind: check.Missing}, + {Path: "evals/README.md", Kind: check.Changed}, + {Path: "hooks/hook.sh", Kind: check.ExecChanged}, + {Path: "rules/R1.md", Kind: check.Changed}, + {Path: "rules/R2.md", Kind: check.Extra}, + }, diffs, "a generated file inside an ignored directory is still compared") +} + +func TestWrite_RemovesStaleKeepsIgnored(t *testing.T) { + t.Parallel() + dir := t.TempDir() + write(t, dir, "old/stale.md", "stale\n") + write(t, dir, "evals/cases/x/prompt.md", "keep\n") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "evals/results"), 0o755)) + write(t, dir, "hooks/hook.sh", "old hook\n") + + require.NoError(t, check.Write(sample(), dir, ignoreEvalsTree)) + + assert.NoFileExists(t, filepath.Join(dir, "old/stale.md")) + assert.NoDirExists(t, filepath.Join(dir, "old")) + assert.FileExists(t, filepath.Join(dir, "evals/cases/x/prompt.md")) + assert.DirExists(t, filepath.Join(dir, "evals/results"), "an empty ignored directory is left alone") + info, err := os.Stat(filepath.Join(dir, "hooks/hook.sh")) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&0o111) + diffs, err := check.Compare(sample(), dir, ignoreEvalsTree) + require.NoError(t, err) + assert.Empty(t, diffs) +} + +func TestReport(t *testing.T) { + t.Parallel() + dir := t.TempDir() + write(t, dir, "rules/R1.md", "on disk\n") + tree := render.Tree{"rules/R1.md": {Data: []byte("rendered\n")}} + diffs := []check.Difference{ + {Path: "rules/R1.md", Kind: check.Changed}, + {Path: "rules/R2.md", Kind: check.Extra}, + } + var out bytes.Buffer + check.Report(&out, diffs, tree, dir) + assert.Contains(t, out.String(), "changed rules/R1.md") + assert.Contains(t, out.String(), "extra rules/R2.md") + assert.Contains(t, out.String(), "-on disk") + assert.Contains(t, out.String(), "+rendered") +} + +func TestReport_DiffUnavailable(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + dir := t.TempDir() + write(t, dir, "a.md", "x\n") + var out bytes.Buffer + check.Report(&out, []check.Difference{{Path: "a.md", Kind: check.Changed}}, render.Tree{"a.md": {Data: []byte("y\n")}}, dir) + assert.Contains(t, out.String(), "changed a.md") + assert.Contains(t, out.String(), "diff unavailable") +} diff --git a/tools/ldd-gen/internal/gen/gen.go b/tools/ldd-gen/internal/gen/gen.go new file mode 100644 index 0000000..62ae956 --- /dev/null +++ b/tools/ldd-gen/internal/gen/gen.go @@ -0,0 +1,273 @@ +// Package gen ties the generator to one repository checkout: it finds core/ +// and lang//, renders each binding, and either writes the plugin +// directory or reports how the directory on disk differs from the rendering. +package gen + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/binding" + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/check" + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/profile" + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/render" + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/residue" +) + +const ( + coreDir = "core" + langDir = "lang" + coreReadme = "core/README.md" + // pluginManifest marks a Claude Code plugin directory. Generate refuses to + // write anywhere that lacks it, since writing deletes unowned files. + pluginManifest = ".claude-plugin/plugin.json" +) + +// Repo is a checkout of this repository, addressed by its root. +type Repo struct { + root string +} + +// Open validates that root holds both core/ and lang/. Without core/ a +// rendering would be empty and writing it would delete every templated file, +// so a root missing either directory is refused up front. +func Open(root string) (Repo, error) { + for _, dir := range []string{coreDir, langDir} { + if err := requireDir(filepath.Join(root, dir)); err != nil { + return Repo{}, err + } + } + return Repo{root: root}, nil +} + +func requireDir(path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("open: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("open: %s is not a directory", path) + } + return nil +} + +// Langs lists every binding directory under lang/, sorted. +func (r Repo) Langs() ([]string, error) { + entries, err := os.ReadDir(filepath.Join(r.root, langDir)) + if err != nil { + return nil, fmt.Errorf("langs: %w", err) + } + var langs []string + for _, e := range entries { + if e.IsDir() { + langs = append(langs, e.Name()) + } + } + sort.Strings(langs) + return langs, nil +} + +// Render compiles core/ with one binding and returns the tree together with +// the binding's profile, which names the output directory. +func (r Repo) Render(lang string) (render.Tree, profile.Profile, error) { + dir := filepath.Join(langDir, lang) + b, err := binding.Load(os.DirFS(filepath.Join(r.root, dir))) + if err != nil { + return nil, profile.Profile{}, fmt.Errorf("%s: %w", dir, err) + } + tree, err := render.Render(os.DirFS(filepath.Join(r.root, coreDir)), b) + if err != nil { + return nil, profile.Profile{}, fmt.Errorf("%s: %w", dir, err) + } + return tree, b.Profile(), nil +} + +// Generate renders one binding and writes it over its plugin directory. +// Writing deletes files the rendering does not own, so four checks run +// first: no two bindings may name the same plugin directory; the rendering +// must carry a plugin manifest; that manifest's name must equal the profile's +// plugin, because Skill and subagent references are built from the profile +// while Claude Code namespaces by the manifest; and a directory already on +// disk must either carry a manifest with the same name or hold nothing the +// write would touch. +func (r Repo) Generate(lang string) error { + if err := r.requireUniquePlugins(); err != nil { + return err + } + tree, p, err := r.Render(lang) + if err != nil { + return err + } + name, err := manifestName(tree[pluginManifest].Data) + if err != nil { + return fmt.Errorf("generate: the %s rendering: %w", lang, err) + } + if name != p.Plugin { + return fmt.Errorf("generate: %s renders a manifest named %q but its profile says plugin %q; cross-plugin references would not resolve", lang, name, p.Plugin) + } + dir := filepath.Join(r.root, p.Plugin) + if err := requireOwnedDir(dir, name, check.IgnoredAndUnowned(tree, p.Ignored)); err != nil { + return err + } + return check.Write(tree, dir, p.Ignored) +} + +// requireUniquePlugins fails when two bindings render into one directory: the +// second would overwrite the first's plugin with its own rendering. Every +// binding must load for this, so a half-written binding blocks generation +// of the others until its profile is complete. +func (r Repo) requireUniquePlugins() error { + langs, err := r.Langs() + if err != nil { + return err + } + owner := map[string]string{} + for _, lang := range langs { + b, err := binding.Load(os.DirFS(filepath.Join(r.root, langDir, lang))) + if err != nil { + return fmt.Errorf("%s: %w", filepath.Join(langDir, lang), err) + } + plugin := b.Profile().Plugin + if other, dup := owner[plugin]; dup { + return fmt.Errorf("generate: bindings %s and %s both name plugin %q", other, lang, plugin) + } + owner[plugin] = lang + } + return nil +} + +// manifestName reads the plugin name from a .claude-plugin/plugin.json body. +// A rendering without a named manifest is not a plugin and is never written. +func manifestName(data []byte) (string, error) { + var m struct { + Name string `json:"name"` + } + if len(data) == 0 { + return "", fmt.Errorf("no %s", pluginManifest) + } + if err := json.Unmarshal(data, &m); err != nil { + return "", fmt.Errorf("%s: %w", pluginManifest, err) + } + if m.Name == "" { + return "", fmt.Errorf("%s has no name", pluginManifest) + } + return m.Name, nil +} + +// requireOwnedDir accepts a directory whose manifest names the same plugin as +// the rendering, or one that holds nothing the write would delete or replace: +// it does not exist yet, or every file in it is ignored and unowned. +func requireOwnedDir(dir, name string, skip check.Ignore) error { + onDisk, err := os.ReadFile(filepath.Join(dir, pluginManifest)) + if err == nil { + return requireSameName(dir, name, onDisk) + } + touched, err := hasFile(dir, skip) + if err != nil { + return err + } + if touched { + return fmt.Errorf("generate: %s is not a plugin directory (no %s); refusing to write", dir, pluginManifest) + } + return nil +} + +func requireSameName(dir, name string, onDisk []byte) error { + existing, err := manifestName(onDisk) + if err != nil { + return fmt.Errorf("generate: %s: %w", dir, err) + } + if existing != name { + return fmt.Errorf("generate: %s belongs to plugin %q, the rendering is plugin %q; refusing to write", dir, existing, name) + } + return nil +} + +func hasFile(dir string, skip check.Ignore) (bool, error) { + found := false + err := fs.WalkDir(os.DirFS(dir), ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + if path == "." && errors.Is(err, fs.ErrNotExist) { + return fs.SkipAll + } + return err + } + if d.IsDir() || skip(path) { + return nil + } + found = true + return fs.SkipAll + }) + if err != nil { + return false, fmt.Errorf("generate: %w", err) + } + return found, nil +} + +// Check renders every binding and compares each with its plugin directory, +// writing findings to w. It returns the number of differences. A repository +// with no binding, or with two bindings for one plugin, is an error, not a +// pass. +func (r Repo) Check(w io.Writer) (int, error) { + langs, err := r.Langs() + if err != nil { + return 0, err + } + if len(langs) == 0 { + return 0, errors.New("check: no binding under lang/") + } + if err := r.requireUniquePlugins(); err != nil { + return 0, err + } + total := 0 + for _, lang := range langs { + n, err := r.checkOne(w, lang) + if err != nil { + return total, err + } + total += n + } + return total, nil +} + +func (r Repo) checkOne(w io.Writer, lang string) (int, error) { + tree, p, err := r.Render(lang) + if err != nil { + return 0, err + } + dir := filepath.Join(r.root, p.Plugin) + diffs, err := check.Compare(tree, dir, p.Ignored) + if err != nil { + return 0, err + } + if len(diffs) > 0 { + fmt.Fprintf(w, "%s: %d difference(s) between the rendering and %s/\n", lang, len(diffs), p.Plugin) + check.Report(w, diffs, tree, dir) + } + return len(diffs), nil +} + +// LintCore scans core/ for language residue and writes the report to w. With +// write set, the Residue section of core/README.md is rewritten from the +// report. It returns the number of hard hits; the caller fails the run when +// that is not zero. +func (r Repo) LintCore(w io.Writer, write bool) (int, error) { + report, err := residue.Scan(os.DirFS(filepath.Join(r.root, coreDir))) + if err != nil { + return 0, err + } + fmt.Fprintln(w, strings.TrimSuffix(report.Markdown(), "\n")) + if write { + if err := residue.WriteReadme(filepath.Join(r.root, coreReadme), report); err != nil { + return len(report.Hard), err + } + } + return len(report.Hard), nil +} diff --git a/tools/ldd-gen/internal/gen/gen_test.go b/tools/ldd-gen/internal/gen/gen_test.go new file mode 100644 index 0000000..533a673 --- /dev/null +++ b/tools/ldd-gen/internal/gen/gen_test.go @@ -0,0 +1,248 @@ +package gen_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/gen" +) + +const profileYAML = `plugin: out-plugin +lang: Lang +cmd_prefix: p-ldd +src_glob: "*.p" +test_glob: "_test.p" +project_marker: p.mod +nolint: "#nolint" +comment_prefix: "#" +default_test: ptest +default_lint: plint +default_lint_fix: plint --fix +ignore: ["evals/*"] +` + +func write(t *testing.T, root, rel, content string) { + t.Helper() + path := filepath.Join(root, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) +} + +func miniRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + write(t, root, "lang/p/profile.yaml", profileYAML) + write(t, root, "lang/p/rules/R1/example.md", "example\n") + write(t, root, "lang/p/passthrough/CHANGELOG.md", "log\n") + write(t, root, "lang/p/passthrough/.claude-plugin/plugin.json", `{"name": "out-plugin"}`+"\n") + write(t, root, "lang/README.md", "not a binding\n") + write(t, root, "core/README.md", "about core\n\n\nold\n\n") + write(t, root, "core/rules/R1.md", "{{include \"rules/R1/example.md\"}} in {{.Lang}}\n") + return root +} + +func TestOpen_Errors(t *testing.T) { + t.Parallel() + noCore := t.TempDir() + write(t, noCore, "lang/p/profile.yaml", profileYAML) + fileAsCore := t.TempDir() + write(t, fileAsCore, "core", "a file\n") + write(t, fileAsCore, "lang/p/profile.yaml", profileYAML) + cases := []struct { + name string + root string + }{ + {name: "empty root", root: t.TempDir()}, + {name: "lang without core", root: noCore}, + {name: "core is a file", root: fileAsCore}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := gen.Open(tc.root) + require.Error(t, err) + }) + } +} + +func TestGenerateThenCheck(t *testing.T) { + t.Parallel() + root := miniRepo(t) + repo, err := gen.Open(root) + require.NoError(t, err) + + langs, err := repo.Langs() + require.NoError(t, err) + assert.Equal(t, []string{"p"}, langs, "files under lang/ are not bindings") + + var out bytes.Buffer + n, err := repo.Check(&out) + require.NoError(t, err) + assert.Equal(t, 3, n, "nothing generated yet: all outputs are missing") + + write(t, root, "out-plugin/evals/cases/x.md", "copied in by an eval run\n") + require.NoError(t, repo.Generate("p")) + got, err := os.ReadFile(filepath.Join(root, "out-plugin/rules/R1.md")) + require.NoError(t, err) + assert.Equal(t, "example in Lang\n", string(got)) + assert.FileExists(t, filepath.Join(root, "out-plugin/evals/cases/x.md"), "generate leaves ignored files alone") + + out.Reset() + n, err = repo.Check(&out) + require.NoError(t, err) + assert.Zero(t, n) + assert.Empty(t, out.String()) +} + +func TestCheck_ReportsEdits(t *testing.T) { + t.Parallel() + root := miniRepo(t) + repo, err := gen.Open(root) + require.NoError(t, err) + require.NoError(t, repo.Generate("p")) + write(t, root, "out-plugin/rules/R1.md", "hand edit\n") + + var out bytes.Buffer + n, err := repo.Check(&out) + require.NoError(t, err) + assert.Equal(t, 1, n) + assert.Contains(t, out.String(), "changed rules/R1.md") + assert.NotContains(t, out.String(), "evals/cases") +} + +func TestCheck_NoBindingIsAnError(t *testing.T) { + t.Parallel() + root := t.TempDir() + write(t, root, "core/README.md", "core\n") + write(t, root, "lang/README.md", "no bindings here\n") + repo, err := gen.Open(root) + require.NoError(t, err) + _, err = repo.Check(&bytes.Buffer{}) + require.Error(t, err) +} + +func TestGenerate_RefusesNonPluginTargets(t *testing.T) { + t.Parallel() + root := miniRepo(t) + write(t, root, "out-plugin/README.md", "some unrelated directory\n") + repo, err := gen.Open(root) + require.NoError(t, err) + err = repo.Generate("p") + require.Error(t, err) + assert.Contains(t, err.Error(), "not a plugin directory") + assert.FileExists(t, filepath.Join(root, "out-plugin/README.md"), "nothing was deleted") + + noManifest := miniRepo(t) + require.NoError(t, os.Remove(filepath.Join(noManifest, "lang/p/passthrough/.claude-plugin/plugin.json"))) + repo, err = gen.Open(noManifest) + require.NoError(t, err) + err = repo.Generate("p") + require.Error(t, err) + assert.Contains(t, err.Error(), "no .claude-plugin/plugin.json") + + otherPlugin := miniRepo(t) + write(t, otherPlugin, "out-plugin/.claude-plugin/plugin.json", `{"name": "someone-else"}`+"\n") + write(t, otherPlugin, "out-plugin/README.md", "theirs\n") + repo, err = gen.Open(otherPlugin) + require.NoError(t, err) + err = repo.Generate("p") + require.Error(t, err) + assert.Contains(t, err.Error(), `belongs to plugin "someone-else"`) + assert.FileExists(t, filepath.Join(otherPlugin, "out-plugin/README.md")) +} + +func TestGenerate_ManifestMustNameTheProfilePlugin(t *testing.T) { + t.Parallel() + root := miniRepo(t) + write(t, root, "lang/p/passthrough/.claude-plugin/plugin.json", `{"name": "copied-from-go"}`+"\n") + repo, err := gen.Open(root) + require.NoError(t, err) + err = repo.Generate("p") + require.Error(t, err) + assert.Contains(t, err.Error(), `manifest named "copied-from-go"`) +} + +func TestGenerate_RefusesTwoBindingsForOnePlugin(t *testing.T) { + t.Parallel() + root := miniRepo(t) + write(t, root, "lang/q/profile.yaml", profileYAML) + repo, err := gen.Open(root) + require.NoError(t, err) + err = repo.Generate("p") + require.Error(t, err) + assert.Contains(t, err.Error(), `both name plugin "out-plugin"`) + _, err = repo.Check(&bytes.Buffer{}) + require.Error(t, err) + assert.Contains(t, err.Error(), `both name plugin "out-plugin"`) +} + +func TestGenerate_IgnoredOwnedFileBlocksAForeignDir(t *testing.T) { + t.Parallel() + root := miniRepo(t) + write(t, root, "lang/p/passthrough/evals/README.md", "pointer\n") + write(t, root, "out-plugin/evals/README.md", "someone else's file at an owned, ignored path\n") + repo, err := gen.Open(root) + require.NoError(t, err) + require.Error(t, repo.Generate("p"), "an owned path is overwritten, so it counts as touched") +} + +func TestGenerate_EmptyTargetIsFine(t *testing.T) { + t.Parallel() + root := miniRepo(t) + require.NoError(t, os.MkdirAll(filepath.Join(root, "out-plugin"), 0o755)) + repo, err := gen.Open(root) + require.NoError(t, err) + require.NoError(t, repo.Generate("p")) +} + +func TestRender_UnknownLang(t *testing.T) { + t.Parallel() + repo, err := gen.Open(miniRepo(t)) + require.NoError(t, err) + _, _, err = repo.Render("nope") + require.Error(t, err) + assert.Contains(t, err.Error(), "lang/nope") +} + +func TestLintCore(t *testing.T) { + t.Parallel() + root := miniRepo(t) + repo, err := gen.Open(root) + require.NoError(t, err) + + var out bytes.Buffer + hard, err := repo.LintCore(&out, false) + require.NoError(t, err) + assert.Zero(t, hard) + readme, err := os.ReadFile(filepath.Join(root, "core/README.md")) + require.NoError(t, err) + assert.Contains(t, string(readme), "\nold\n", "without -write the README is untouched") + + write(t, root, "core/rules/R2.md", "run golangci-lint\n") + out.Reset() + hard, err = repo.LintCore(&out, true) + require.NoError(t, err) + assert.Equal(t, 1, hard) + assert.Contains(t, out.String(), "rules/R2.md:1") + readme, err = os.ReadFile(filepath.Join(root, "core/README.md")) + require.NoError(t, err) + assert.Contains(t, string(readme), "Hard residue (1)") + assert.NotContains(t, string(readme), "\nold\n") +} + +// Runs against the real checkout: each plugin directory must equal its +// rendering exactly. +func TestGolden(t *testing.T) { + t.Parallel() + repo, err := gen.Open(filepath.Join("..", "..", "..", "..")) + require.NoError(t, err) + var out bytes.Buffer + n, err := repo.Check(&out) + require.NoError(t, err) + assert.Zero(t, n, out.String()) +} diff --git a/tools/ldd-gen/internal/profile/profile.go b/tools/ldd-gen/internal/profile/profile.go new file mode 100644 index 0000000..1e99eed --- /dev/null +++ b/tools/ldd-gen/internal/profile/profile.go @@ -0,0 +1,152 @@ +// Package profile reads a language binding's profile.yaml: the scalar values +// that core templates substitute, and the output paths the generator leaves +// alone because something else owns them at run time. +package profile + +import ( + "bytes" + "errors" + "fmt" + "path" + "reflect" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +// Vars holds every scalar a core template may reference as {{.Name}}. Each +// field is required; an empty one means the binding forgot to say how the +// language spells that concept, so parsing fails instead of rendering blanks. +type Vars struct { + Plugin string `yaml:"plugin"` + Lang string `yaml:"lang"` + CmdPrefix string `yaml:"cmd_prefix"` + SrcGlob string `yaml:"src_glob"` + TestGlob string `yaml:"test_glob"` + ProjectMarker string `yaml:"project_marker"` + Nolint string `yaml:"nolint"` + CommentPrefix string `yaml:"comment_prefix"` + DefaultTest string `yaml:"default_test"` + DefaultLint string `yaml:"default_lint"` + DefaultLintFix string `yaml:"default_lint_fix"` +} + +// Profile is a parsed profile.yaml. Plugin doubles as the output directory +// name, so one binding always renders into exactly one plugin directory. +type Profile struct { + Vars `yaml:",inline"` + Ignore []string `yaml:"ignore"` +} + +// Parse decodes profile.yaml strictly: unknown keys, empty scalars, a plugin +// name that is not a plain directory name, and malformed ignore patterns are +// errors. The generator deletes files under the plugin directory that it +// does not own, so a profile that could point it at the wrong place, or +// switch its ignore list off, must not load. +func Parse(data []byte) (Profile, error) { + var p Profile + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&p); err != nil { + return Profile{}, fmt.Errorf("profile: %w", err) + } + if err := p.validate(); err != nil { + return Profile{}, err + } + return p, nil +} + +func (p Profile) validate() error { + if err := p.Vars.validate(); err != nil { + return err + } + if !isPlainName(p.Plugin) { + return fmt.Errorf("profile: plugin %q must be a plain directory name", p.Plugin) + } + if !isSlug(p.CmdPrefix) { + return fmt.Errorf("profile: cmd_prefix %q must be lower-case letters, digits and dashes", p.CmdPrefix) + } + for _, pattern := range p.Ignore { + if _, err := path.Match(pattern, ""); err != nil { + return fmt.Errorf("profile: ignore pattern %q: %w", pattern, err) + } + } + return nil +} + +// isPlainName accepts one directory name: no separators, not "." or "..", +// and not hidden. The generator deletes inside this directory, so a value +// like "/" or "../x" must never reach it. +func isPlainName(name string) bool { + return name != "" && !strings.ContainsAny(name, `/\`) && name != "." && name != ".." && !strings.HasPrefix(name, ".") +} + +// isSlug accepts the shape Claude Code expects in a slash-command name. The +// command prefix becomes part of output file names, so it must not carry +// path separators or anything a command loader would not match. +func isSlug(name string) bool { + return slugPattern.MatchString(name) +} + +var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +func (v Vars) validate() error { + rv := reflect.ValueOf(v) + rt := rv.Type() + var missing []string + for i := range rt.NumField() { + if rv.Field(i).String() == "" { + missing = append(missing, rt.Field(i).Tag.Get("yaml")) + } + } + if len(missing) > 0 { + return errors.New("profile: missing " + strings.Join(missing, ", ")) + } + return nil +} + +// Ignored reports whether an output path that the generator did not produce +// is tolerated. A pattern with a slash matches the path itself or any of its +// parent directories, so "evals/*" covers everything below evals/cases/. A +// pattern without a slash matches a single name anywhere, so ".DS_Store" +// covers that file in every directory. +func (p Profile) Ignored(rel string) bool { + for _, pattern := range p.Ignore { + if matches(pattern, rel) { + return true + } + } + return false +} + +// IgnoredName reports whether a bare file name matches one of the patterns +// without a slash. Those patterns name OS and editor droppings such as +// .DS_Store, so the generator skips them as sources too; path patterns like +// "evals/*" apply to the plugin directory only. +func (p Profile) IgnoredName(name string) bool { + for _, pattern := range p.Ignore { + if strings.Contains(pattern, "/") { + continue + } + if ok, _ := path.Match(pattern, name); ok { + return true + } + } + return false +} + +func matches(pattern, rel string) bool { + byName := !strings.Contains(pattern, "/") + for prefix := rel; prefix != "." && prefix != "/"; prefix = path.Dir(prefix) { + candidate := prefix + if byName { + candidate = path.Base(prefix) + } + // Parse validated every pattern, so Match cannot fail here. + if ok, _ := path.Match(pattern, candidate); ok { + return true + } + } + return false +} diff --git a/tools/ldd-gen/internal/profile/profile_test.go b/tools/ldd-gen/internal/profile/profile_test.go new file mode 100644 index 0000000..ba1e012 --- /dev/null +++ b/tools/ldd-gen/internal/profile/profile_test.go @@ -0,0 +1,111 @@ +package profile_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/profile" +) + +const full = `plugin: go-linter-driven-development +lang: Go +cmd_prefix: go-ldd +src_glob: "*.go" +test_glob: "_test.go" +project_marker: go.mod +nolint: "//nolint" +comment_prefix: "//" +default_test: go test ./... +default_lint: golangci-lint run +default_lint_fix: golangci-lint run --fix +ignore: + - evals/* + - .DS_Store +` + +func TestParse_Success(t *testing.T) { + t.Parallel() + p, err := profile.Parse([]byte(full)) + require.NoError(t, err) + assert.Equal(t, "go-linter-driven-development", p.Plugin) + assert.Equal(t, "go-ldd", p.CmdPrefix) + assert.Equal(t, "golangci-lint run --fix", p.DefaultLintFix) + assert.Equal(t, []string{"evals/*", ".DS_Store"}, p.Ignore) +} + +func TestParse_Errors(t *testing.T) { + t.Parallel() + withPlugin := func(name string) string { + return "plugin: " + name + full[len("plugin: go-linter-driven-development"):] + } + withPrefix := func(prefix string) string { + return strings.Replace(full, "cmd_prefix: go-ldd", "cmd_prefix: "+prefix, 1) + } + cases := []struct { + name string + input string + want string + }{ + {name: "missing scalar", input: "plugin: x\nlang: Go\n", want: "missing cmd_prefix"}, + {name: "unknown key", input: full + "extra: 1\n", want: "field extra not found"}, + {name: "not yaml", input: "plugin: [", want: "profile:"}, + {name: "empty file", input: "", want: "profile:"}, + {name: "plugin is a path", input: withPlugin("lang/go"), want: "plain directory name"}, + {name: "plugin is dot", input: withPlugin("."), want: "plain directory name"}, + {name: "plugin is parent", input: withPlugin(".."), want: "plain directory name"}, + {name: "plugin is absolute", input: withPlugin("/tmp"), want: "plain directory name"}, + {name: "plugin is the root", input: withPlugin("/"), want: "plain directory name"}, + {name: "plugin is hidden", input: withPlugin(".git"), want: "plain directory name"}, + {name: "plugin has a backslash", input: withPlugin(`a\b`), want: "plain directory name"}, + {name: "bad ignore pattern", input: full + " - 'evals/['\n", want: `ignore pattern "evals/["`}, + {name: "cmd_prefix escapes", input: withPrefix("../../evil"), want: "cmd_prefix"}, + {name: "cmd_prefix with slash", input: withPrefix("go/ldd"), want: "cmd_prefix"}, + {name: "cmd_prefix upper case", input: withPrefix("Go-LDD"), want: "cmd_prefix"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := profile.Parse([]byte(tc.input)) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + }) + } +} + +func TestIgnored(t *testing.T) { + t.Parallel() + p, err := profile.Parse([]byte(full)) + require.NoError(t, err) + cases := []struct { + name string + rel string + want bool + }{ + {name: "direct child", rel: "evals/cases.yaml", want: true}, + {name: "nested", rel: "evals/cases/trigger/prompt.md", want: true}, + {name: "name pattern at root", rel: ".DS_Store", want: true}, + {name: "name pattern nested", rel: "rules/.DS_Store", want: true}, + {name: "name pattern on a directory", rel: "rules/.DS_Store/x", want: true}, + {name: "produced file elsewhere", rel: "rules/R1.md", want: false}, + {name: "prefix without slash", rel: "evalsx/y.md", want: false}, + {name: "the ignored directory itself", rel: "evals", want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, p.Ignored(tc.rel)) + }) + } +} + +func TestIgnoredName(t *testing.T) { + t.Parallel() + p, err := profile.Parse([]byte(full)) + require.NoError(t, err) + assert.True(t, p.IgnoredName(".DS_Store")) + assert.False(t, p.IgnoredName(".mcp.json"), "hidden is not the same as ignored") + assert.False(t, p.IgnoredName("evals"), "path patterns do not apply to bare names") +} diff --git a/tools/ldd-gen/internal/render/render.go b/tools/ldd-gen/internal/render/render.go new file mode 100644 index 0000000..ea68a07 --- /dev/null +++ b/tools/ldd-gen/internal/render/render.go @@ -0,0 +1,199 @@ +// Package render compiles core templates plus one language binding into the +// plugin tree: every output path with its bytes and executable bit. The tree +// is produced in memory so the same rendering can be written to disk or +// compared against the plugin directory. +package render + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "path" + "strings" + "text/template" + + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/binding" +) + +// README.md documents core/ itself; every other core file is a template. +const coreReadme = "README.md" + +// droppings are names editors and operating systems leave next to sources. +// They are never templates or passthrough files, whatever a profile says. +func droppings() []string { + return []string{".DS_Store", "._*", "*.swp", "*~", ".#*", "Thumbs.db", "desktop.ini"} +} + +// isDropping reports whether a file or directory name is an editor or OS +// dropping, or a name the profile ignores. +func isDropping(name string, profileIgnores func(string) bool) bool { + for _, pattern := range droppings() { + if ok, _ := path.Match(pattern, name); ok { + return true + } + } + return profileIgnores(name) +} + +// File is one rendered plugin file. Exec mirrors the source file's executable +// bit so hooks and gate scripts stay runnable after generation. +type File struct { + Data []byte + Exec bool +} + +// Tree maps plugin-relative output paths to their rendered files. +type Tree map[string]File + +// Render templates every core file through the binding, then adds the +// binding's passthrough files. Two mistakes are errors rather than silent +// wins: an output path claimed twice (a passthrough copy shadowing a +// template) and an override with no core file to replace. +func Render(core fs.FS, b binding.Binding) (Tree, error) { + overrides, err := b.Overrides() + if err != nil { + return nil, err + } + r := renderer{binding: b, overrides: overrides, tree: Tree{}, seen: map[string]bool{}} + skip := func(name string) bool { return isDropping(name, b.Profile().IgnoredName) } + if err := walkFiles(core, skip, r.renderCore); err != nil { + return nil, err + } + if err := walkFiles(overrides, skip, r.checkOverrideHasCoreFile); err != nil { + return nil, err + } + pt, err := b.Passthrough() + if err != nil { + return nil, err + } + if err := walkFiles(pt, skip, r.copyPassthrough); err != nil { + return nil, err + } + return r.tree, nil +} + +type renderer struct { + binding binding.Binding + overrides fs.FS + tree Tree + seen map[string]bool // core paths visited, to detect orphan overrides +} + +func (r *renderer) renderCore(core fs.FS, path string) error { + if path == coreReadme { + return nil + } + r.seen[path] = true + src, exec, err := r.source(core, path) + if err != nil { + return err + } + out, err := r.execute(path, string(src)) + if err != nil { + return err + } + outPath, err := r.execute(path+" (name)", path) + if err != nil { + return err + } + return r.add(string(outPath), File{Data: out, Exec: exec}) +} + +// source picks the override when the binding has one, else the core file, +// and reports the executable bit of whichever file it read. +func (r *renderer) source(core fs.FS, path string) ([]byte, bool, error) { + fsys := core + _, err := fs.Stat(r.overrides, path) + switch { + case err == nil: + fsys = r.overrides + case !errors.Is(err, fs.ErrNotExist): + return nil, false, fmt.Errorf("override %s: %w", path, err) + } + data, err := fs.ReadFile(fsys, path) + if err != nil { + return nil, false, fmt.Errorf("core: %w", err) + } + exec, err := isExecutable(fsys, path) + return data, exec, err +} + +func (r *renderer) checkOverrideHasCoreFile(_ fs.FS, path string) error { + if !r.seen[path] { + return fmt.Errorf("override %q has no core file to replace", path) + } + return nil +} + +func (r *renderer) execute(name, text string) ([]byte, error) { + funcs := template.FuncMap{"include": r.binding.Include} + tmpl, err := template.New(name).Funcs(funcs).Parse(text) + if err != nil { + return nil, fmt.Errorf("template %s: %w", name, err) + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, r.binding.Profile().Vars); err != nil { + return nil, fmt.Errorf("template %s: %w", name, err) + } + return buf.Bytes(), nil +} + +func (r *renderer) copyPassthrough(fsys fs.FS, path string) error { + data, err := fs.ReadFile(fsys, path) + if err != nil { + return fmt.Errorf("passthrough: %w", err) + } + exec, err := isExecutable(fsys, path) + if err != nil { + return err + } + return r.add(path, File{Data: data, Exec: exec}) +} + +// add records one output. A path that is not already clean and relative +// (a templated file name could smuggle in ".." or a leading slash) is refused, +// so nothing can ever be written outside the plugin directory. +func (r *renderer) add(outPath string, f File) error { + if outPath != path.Clean(outPath) || path.IsAbs(outPath) || outPath == "." || strings.HasPrefix(outPath, "../") { + return fmt.Errorf("output %q is not a clean path inside the plugin", outPath) + } + if _, dup := r.tree[outPath]; dup { + return fmt.Errorf("output %q is produced by two sources", outPath) + } + r.tree[outPath] = f + return nil +} + +func isExecutable(fsys fs.FS, path string) (bool, error) { + info, err := fs.Stat(fsys, path) + if err != nil { + return false, fmt.Errorf("stat %s: %w", path, err) + } + return info.Mode()&0o111 != 0, nil +} + +// walkFiles calls visit for every regular file, skipping files and whole +// directories whose name skip accepts. A root that does not exist is an +// error: rendering nothing from a missing core/ would delete the plugin. +func walkFiles(fsys fs.FS, skip func(name string) bool, visit func(fs.FS, string) error) error { + err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if path != "." && skip(d.Name()) { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + if d.IsDir() { + return nil + } + return visit(fsys, path) + }) + if err != nil { + return fmt.Errorf("walk: %w", err) + } + return nil +} diff --git a/tools/ldd-gen/internal/render/render_test.go b/tools/ldd-gen/internal/render/render_test.go new file mode 100644 index 0000000..42a638e --- /dev/null +++ b/tools/ldd-gen/internal/render/render_test.go @@ -0,0 +1,190 @@ +package render_test + +import ( + "maps" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/binding" + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/render" +) + +const profileYAML = `plugin: p-plugin +lang: Lang +cmd_prefix: p-ldd +src_glob: "*.p" +test_glob: "_test.p" +project_marker: p.mod +nolint: "#nolint" +comment_prefix: "#" +default_test: ptest +default_lint: plint +default_lint_fix: plint --fix +ignore: [".DS_Store", "__pycache__"] +` + +func lang(t *testing.T, extra fstest.MapFS) binding.Binding { + t.Helper() + fsys := fstest.MapFS{ + "profile.yaml": {Data: []byte(profileYAML)}, + "rules/R1/example.md": {Data: []byte("example body\n")}, + } + maps.Copy(fsys, extra) + b, err := binding.Load(fsys) + require.NoError(t, err) + return b +} + +func TestRender_Substitutions(t *testing.T) { + t.Parallel() + core := fstest.MapFS{ + "README.md": {Data: []byte("core docs, never rendered\n")}, + "rules/R1.md": {Data: []byte("# R1\n\n## Example\n\n{{include \"rules/R1/example.md\"}}\n\nUse {{.SrcGlob}}.\n")}, + "commands/{{.CmdPrefix}}-analyze.md": {Data: []byte("Skill({{.Plugin}}:x)\n")}, + "scripts/gate.sh": {Data: []byte("#!/bin/sh\n"), Mode: 0o755}, + } + tree, err := render.Render(core, lang(t, nil)) + require.NoError(t, err) + + assert.NotContains(t, tree, "README.md") + assert.Equal(t, "# R1\n\n## Example\n\nexample body\n\nUse *.p.\n", string(tree["rules/R1.md"].Data)) + assert.Equal(t, "Skill(p-plugin:x)\n", string(tree["commands/p-ldd-analyze.md"].Data)) + assert.True(t, tree["scripts/gate.sh"].Exec) + assert.False(t, tree["rules/R1.md"].Exec) +} + +func TestRender_Override(t *testing.T) { + t.Parallel() + core := fstest.MapFS{ + "rules/R6.md": {Data: []byte("core text\n")}, + "scripts/gate.sh": {Data: []byte("#!/bin/sh\n"), Mode: 0o755}, + } + b := lang(t, fstest.MapFS{ + "overrides/rules/R6.md": {Data: []byte("{{.Lang}} text\n")}, + "overrides/scripts/gate.sh": {Data: []byte("#!/bin/sh\necho override\n")}, + }) + tree, err := render.Render(core, b) + require.NoError(t, err) + assert.Equal(t, "Lang text\n", string(tree["rules/R6.md"].Data)) + assert.Equal(t, "#!/bin/sh\necho override\n", string(tree["scripts/gate.sh"].Data)) + assert.False(t, tree["scripts/gate.sh"].Exec, "the override's own mode wins, not the core file's") +} + +func TestRender_Passthrough(t *testing.T) { + t.Parallel() + core := fstest.MapFS{} + b := lang(t, fstest.MapFS{ + "passthrough/CHANGELOG.md": {Data: []byte("{{ not a template }}\n")}, + "passthrough/hooks/hook.sh": {Data: []byte("#!/bin/sh\n"), Mode: 0o755}, + }) + tree, err := render.Render(core, b) + require.NoError(t, err) + assert.Equal(t, "{{ not a template }}\n", string(tree["CHANGELOG.md"].Data)) + assert.True(t, tree["hooks/hook.sh"].Exec) +} + +func TestRender_EmptyCoreRendersOnlyPassthrough(t *testing.T) { + t.Parallel() + b := lang(t, fstest.MapFS{"passthrough/README.md": {Data: []byte("r\n")}}) + tree, err := render.Render(fstest.MapFS{}, b) + require.NoError(t, err) + assert.Len(t, tree, 1) +} + +func TestRender_HiddenInputFilesAreSkipped(t *testing.T) { + t.Parallel() + core := fstest.MapFS{ + ".DS_Store": {Data: []byte("\x00\x00{{ binary junk")}, + "rules/.DS_Store": {Data: []byte("junk")}, + "rules/R1.md": {Data: []byte("r1\n")}, + } + b := lang(t, fstest.MapFS{ + "passthrough/.DS_Store": {Data: []byte("junk")}, + "passthrough/.claude-plugin/plugin.json": {Data: []byte("{}\n")}, + "overrides/.DS_Store": {Data: []byte("junk")}, + }) + tree, err := render.Render(core, b) + require.NoError(t, err) + assert.Equal(t, []string{".claude-plugin/plugin.json", "rules/R1.md"}, sortedKeys(tree)) +} + +func sortedKeys(tree render.Tree) []string { + keys := make([]string, 0, len(tree)) + for k := range tree { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func TestRender_MissingCoreIsAnError(t *testing.T) { + t.Parallel() + missing := os.DirFS(filepath.Join(t.TempDir(), "nope")) + _, err := render.Render(missing, lang(t, nil)) + require.Error(t, err) +} + +func TestRender_Errors(t *testing.T) { + t.Parallel() + cases := []struct { + name string + core fstest.MapFS + lang fstest.MapFS + want string + }{ + { + name: "unknown variable", + core: fstest.MapFS{"a.md": {Data: []byte("{{.Nope}}\n")}}, + want: "template a.md", + }, + { + name: "missing include", + core: fstest.MapFS{"a.md": {Data: []byte("{{include \"rules/R9/x.md\"}}\n")}}, + want: "rules/R9/x.md", + }, + { + name: "passthrough shadows a template", + core: fstest.MapFS{"a.md": {Data: []byte("x\n")}}, + lang: fstest.MapFS{"passthrough/a.md": {Data: []byte("y\n")}}, + want: "two sources", + }, + { + name: "bad template syntax", + core: fstest.MapFS{"a.md": {Data: []byte("{{ include }\n")}}, + want: "template a.md", + }, + { + name: "override without a core file", + core: fstest.MapFS{"a.md": {Data: []byte("x\n")}}, + lang: fstest.MapFS{"overrides/b.md": {Data: []byte("y\n")}}, + want: `override "b.md" has no core file`, + }, + { + name: "templated file name escaping the plugin", + core: fstest.MapFS{"commands/{{.Lang}}/x.md": {Data: []byte("x\n")}}, + lang: fstest.MapFS{"profile.yaml": {Data: []byte(strings.Replace(profileYAML, "lang: Lang", "lang: ../../evil", 1))}}, + want: "not a clean path", + }, + { + name: "override of the core README is never used", + core: fstest.MapFS{"README.md": {Data: []byte("x\n")}}, + lang: fstest.MapFS{"overrides/README.md": {Data: []byte("y\n")}}, + want: `override "README.md" has no core file`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := render.Render(tc.core, lang(t, tc.lang)) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + }) + } +} diff --git a/tools/ldd-gen/internal/residue/residue.go b/tools/ldd-gen/internal/residue/residue.go new file mode 100644 index 0000000..314bac7 --- /dev/null +++ b/tools/ldd-gen/internal/residue/residue.go @@ -0,0 +1,271 @@ +// Package residue finds language-specific text left in core/. Hard tokens are +// names a binding scalar or include should have replaced, so one is a missed +// substitution. Soft tokens are inline idioms (nil, ctx, goroutine) that a +// second language binding will have to resolve; their report is the backlog +// for that binding, written into core/README.md between two markers. +package residue + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "os" + "regexp" + "sort" + "strings" +) + +// BeginMarker and EndMarker mark where the generated Residue section of +// core/README.md starts and ends. +const ( + BeginMarker = "" + EndMarker = "" +) + +// README.md holds this report, so scanning it would hit its own output. +const coreReadme = "README.md" + +// Token is one pattern the scan looks for, named the way the report shows it. +type Token struct { + Name string + re *regexp.Regexp +} + +func token(name, pattern string) Token { + return Token{Name: name, re: regexp.MustCompile(pattern)} +} + +// HardTokens are the spellings that a plugin loader or a second language would +// choke on and that a profile scalar or an include always replaces: the plugin +// name, the command prefix, the linter name, source globs and the nolint +// directive. A hit is a missed substitution and fails lint-core. Other scalars +// (the project marker, the test command) also appear as plain prose in Go +// idioms, so they stay soft. +func HardTokens() []Token { + return []Token{ + token("plugin name literal", `go-linter-driven-development`), + token("command prefix literal", `\bgo-ldd\b`), + token("golangci", `golangci`), + token("*.go glob", `\*\.go\b`), + token("_test.go", `_test\.go`), + token("//nolint", `//nolint`), + } +} + +// SoftTokens are reported, never fatal. +func SoftTokens() []Token { + return []Token{ + token("Go code fence", "```go"), + token("go.mod", `go\.mod`), + token(".go suffix", `\.go\b`), + token("go test / go vet", `\bgo (test|vet)\b`), + token("godoc", `godoc`), + token("Go (the word)", `\bGo\b`), + token("Go linter name", `\b(errcheck|bodyclose|govet|exhaustive|gocognit|gocyclo|funlen|nestif|ireturn|dupl|gochecknoglobals|gochecknoinits|maintidx|cyclop|wrapcheck|goconst|varnamelen|misspell|revive)\b`), + token("Go library", `\b(testify|golang\.org/|errgroup)\b`), + token("nil", `\bnil\b`), + token("goroutine", `goroutine`), + token("ctx", `\bctx\b`), + token("context.", `\bcontext\.`), + token("sync.", `\bsync\.`), + token("pkg_test", `pkg_test`), + token("func", `\bfunc `), + token("struct", `\bstruct\b`), + token("interface", `\binterface\b`), + token("init()", `\binit\(\)`), + token("wantErr", `wantErr`), + token("httptest", `httptest`), + } +} + +// Hit is one token found on one line of one core file. +type Hit struct { + Path string + Line int + Token string + Text string +} + +// Report is the result of a scan, hard and soft hits kept apart. +type Report struct { + Hard []Hit + Soft []Hit +} + +// Scan walks every file under core except its README and records each token +// hit. Attributed quotes in maxims.md (lines starting with an em dash) are +// exempt: a quoted Go proverb is portable text. +func Scan(core fs.FS) (Report, error) { + var r Report + err := fs.WalkDir(core, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || path == coreReadme { + return err + } + data, err := fs.ReadFile(core, path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + r.scanFile(path, string(data)) + return nil + }) + if err != nil { + return Report{}, fmt.Errorf("residue: %w", err) + } + r.sort() + return r, nil +} + +func (r *Report) scanFile(path, text string) { + exemptQuotes := path == "maxims.md" + hard, soft := HardTokens(), SoftTokens() + for i, line := range strings.Split(text, "\n") { + if exemptQuotes && strings.HasPrefix(line, "— ") { + continue + } + r.Hard = append(r.Hard, hitsOn(hard, path, i+1, line)...) + r.Soft = append(r.Soft, hitsOn(soft, path, i+1, line)...) + } +} + +func hitsOn(tokens []Token, path string, n int, line string) []Hit { + var hits []Hit + for _, t := range tokens { + if t.re.MatchString(line) { + hits = append(hits, Hit{Path: path, Line: n, Token: t.Name, Text: strings.TrimSpace(line)}) + } + } + return hits +} + +func (r *Report) sort() { + sortHits(r.Hard) + sortHits(r.Soft) +} + +func sortHits(h []Hit) { + sort.SliceStable(h, func(i, j int) bool { + if h[i].Path != h[j].Path { + return h[i].Path < h[j].Path + } + if h[i].Line != h[j].Line { + return h[i].Line < h[j].Line + } + return h[i].Token < h[j].Token + }) +} + +// Markdown renders the report as the body of the README's Residue section: +// the hard hits line by line (they must reach zero), then soft hits counted +// per token and per file. +func (r Report) Markdown() string { + var b strings.Builder + writeHard(&b, r.Hard) + byToken := grouping{ + title: "Soft residue by token", column: "Token", detail: "Files", + rowKey: func(h Hit) string { return h.Token }, countedValue: func(h Hit) string { return h.Path }, + } + byFile := grouping{ + title: "Soft residue by file", column: "File", detail: "Tokens", + rowKey: func(h Hit) string { return "`" + h.Path + "`" }, countedValue: func(h Hit) string { return h.Token }, + } + writeCounts(&b, r.Soft, byToken) + writeCounts(&b, r.Soft, byFile) + return b.String() +} + +func writeHard(b *strings.Builder, hits []Hit) { + if len(hits) == 0 { + fmt.Fprint(b, "Hard residue: none. No plugin-name or command-prefix literal, golangci\nreference, source-file glob or nolint directive is left in core/.\n") + return + } + fmt.Fprintf(b, "Hard residue (%d) — each is a missed substitution:\n\n", len(hits)) + for _, h := range hits { + fmt.Fprintf(b, "- `%s:%d` %s: `%s`\n", h.Path, h.Line, h.Token, h.Text) + } +} + +type grouping struct { + title string + column string + detail string + rowKey func(Hit) string + countedValue func(Hit) string +} + +// writeCounts prints one table: per row key, the number of distinct lines +// that carry a hit, and the number of distinct counted values behind them. +func writeCounts(b *strings.Builder, hits []Hit, g grouping) { + lines := map[string]map[string]bool{} + values := map[string]map[string]bool{} + allLines := map[string]bool{} + for _, h := range hits { + k := g.rowKey(h) + line := fmt.Sprintf("%s:%d", h.Path, h.Line) + addTo(lines, k, line) + addTo(values, k, g.countedValue(h)) + allLines[line] = true + } + keys := sortedKeys(lines) + fmt.Fprintf(b, "\n%s (%d lines):\n\n| %s | Lines | %s |\n|---|---:|---:|\n", g.title, len(allLines), g.column, g.detail) + for _, k := range keys { + fmt.Fprintf(b, "| %s | %d | %d |\n", k, len(lines[k]), len(values[k])) + } +} + +func addTo(sets map[string]map[string]bool, key, value string) { + if sets[key] == nil { + sets[key] = map[string]bool{} + } + sets[key][value] = true +} + +// sortedKeys orders rows by line count, largest first, then by name. +func sortedKeys(lines map[string]map[string]bool) []string { + keys := make([]string, 0, len(lines)) + for k := range lines { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if len(lines[keys[i]]) != len(lines[keys[j]]) { + return len(lines[keys[i]]) > len(lines[keys[j]]) + } + return keys[i] < keys[j] + }) + return keys +} + +// WriteReadme replaces the text between the two markers in the README at path +// with the report. The markers must both be present, in order. An unchanged +// README is left untouched, so a repeated run never dirties the checkout. +func WriteReadme(path string, r Report) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("residue: %w", err) + } + updated, err := Splice(data, r.Markdown()) + if err != nil { + return err + } + if bytes.Equal(updated, data) { + return nil + } + if err := os.WriteFile(path, updated, 0o644); err != nil { + return fmt.Errorf("residue: %w", err) + } + return nil +} + +// Splice returns readme with the section between the markers replaced by body. +func Splice(readme []byte, body string) ([]byte, error) { + begin := bytes.Index(readme, []byte(BeginMarker)) + end := bytes.Index(readme, []byte(EndMarker)) + if begin < 0 || end < 0 || end < begin { + return nil, errors.New("residue: README lacks the residue markers") + } + var out bytes.Buffer + out.Write(readme[:begin+len(BeginMarker)]) + out.WriteString("\n" + body) + out.Write(readme[end:]) + return out.Bytes(), nil +} diff --git a/tools/ldd-gen/internal/residue/residue_test.go b/tools/ldd-gen/internal/residue/residue_test.go new file mode 100644 index 0000000..a4fe4ce --- /dev/null +++ b/tools/ldd-gen/internal/residue/residue_test.go @@ -0,0 +1,159 @@ +package residue_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/residue" +) + +func TestScan(t *testing.T) { + t.Parallel() + core := fstest.MapFS{ + "README.md": {Data: []byte("golangci lives here and is not a hit\n")}, + "rules/R1.md": {Data: []byte("Run `golangci-lint run`.\nA goroutine and a nil.\nfine line\n")}, + "maxims.md": {Data: []byte("— Rob Pike, Go Proverbs\nGo is mentioned here\n")}, + } + r, err := residue.Scan(core) + require.NoError(t, err) + + assert.Equal(t, []residue.Hit{ + {Path: "rules/R1.md", Line: 1, Token: "golangci", Text: "Run `golangci-lint run`."}, + }, r.Hard) + assert.Equal(t, []residue.Hit{ + {Path: "maxims.md", Line: 2, Token: "Go (the word)", Text: "Go is mentioned here"}, + {Path: "rules/R1.md", Line: 2, Token: "goroutine", Text: "A goroutine and a nil."}, + {Path: "rules/R1.md", Line: 2, Token: "nil", Text: "A goroutine and a nil."}, + }, r.Soft, "sorted by path, line, then token") +} + +func scanLine(t *testing.T, line string) residue.Report { + t.Helper() + r, err := residue.Scan(fstest.MapFS{"x.md": {Data: []byte(line + "\n")}}) + require.NoError(t, err) + return r +} + +func TestHardTokens(t *testing.T) { + t.Parallel() + cases := []struct { + name string + line string + want string // token name, or "" when the line must be clean + }{ + {name: "plugin name with colon", line: "Skill(go-linter-driven-development:testing)", want: "plugin name literal"}, + {name: "plugin name bare", line: "see go-linter-driven-development/rules", want: "plugin name literal"}, + {name: "command prefix", line: "run /go-ldd-review", want: "command prefix literal"}, + {name: "golangci", line: "`.golangci.yaml`", want: "golangci"}, + {name: "source glob", line: "--include='*.go'", want: "*.go glob"}, + {name: "test suffix", line: "in `*_test.go` files", want: "_test.go"}, + {name: "nolint", line: "never add //nolint", want: "//nolint"}, + {name: "near miss glob", line: "*.gold files", want: ""}, + {name: "near miss nolint", line: "the nolint prohibition", want: ""}, + {name: "near miss prefix", line: "a go-lddx thing", want: ""}, + {name: "template scalar", line: "Skill({{.Plugin}}:testing) and /{{.CmdPrefix}}-review", want: ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + r := scanLine(t, tc.line) + if tc.want == "" { + assert.Empty(t, r.Hard) + return + } + require.Len(t, r.Hard, 1) + assert.Equal(t, tc.want, r.Hard[0].Token) + }) + } +} + +func TestSoftTokens_LinterAndLibraryNames(t *testing.T) { + t.Parallel() + r := scanLine(t, "the `exhaustive` linter and testify") + var names []string + for _, h := range r.Soft { + names = append(names, h.Token) + } + assert.ElementsMatch(t, []string{"Go linter name", "Go library"}, names) +} + +func TestMarkdown(t *testing.T) { + t.Parallel() + r := residue.Report{ + Soft: []residue.Hit{ + {Path: "a.md", Line: 1, Token: "nil"}, + {Path: "a.md", Line: 1, Token: "ctx"}, + {Path: "a.md", Line: 2, Token: "nil"}, + {Path: "b.md", Line: 1, Token: "ctx"}, + }, + } + md := r.Markdown() + assert.Contains(t, md, "Hard residue: none.") + assert.Contains(t, md, "Soft residue by token (3 lines)", "a line with two tokens counts once") + tokenTable := strings.TrimSpace(md[strings.Index(md, "| Token |"):strings.Index(md, "Soft residue by file")]) + assert.Equal(t, "| Token | Lines | Files |\n|---|---:|---:|\n| ctx | 2 | 2 |\n| nil | 2 | 1 |", tokenTable, "ordered by lines, then name") + assert.Contains(t, md, "| File | Lines | Tokens |") + assert.Contains(t, md, "| `a.md` | 2 | 2 |") + assert.Contains(t, md, "| `b.md` | 1 | 1 |") +} + +func TestMarkdown_Hard(t *testing.T) { + t.Parallel() + r := residue.Report{Hard: []residue.Hit{{Path: "a.md", Line: 3, Token: "golangci", Text: "x golangci y"}}} + assert.Contains(t, r.Markdown(), "- `a.md:3` golangci: `x golangci y`") +} + +func TestWriteReadme(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "README.md") + readme := "# Core\n\n## Residue\n\n" + residue.BeginMarker + "\nold\n" + residue.EndMarker + "\n\ntail\n" + require.NoError(t, os.WriteFile(path, []byte(readme), 0o644)) + + require.NoError(t, residue.WriteReadme(path, residue.Report{})) + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(got), residue.BeginMarker+"\nHard residue: none.") + assert.Contains(t, string(got), residue.EndMarker+"\n\ntail\n") + assert.NotContains(t, string(got), "old") + + info, err := os.Stat(path) + require.NoError(t, err) + require.NoError(t, os.Chmod(path, 0o444)) + require.NoError(t, residue.WriteReadme(path, residue.Report{}), "an unchanged README is not rewritten") + again, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, info.ModTime(), again.ModTime()) +} + +func TestWriteReadme_Errors(t *testing.T) { + t.Parallel() + require.Error(t, residue.WriteReadme(filepath.Join(t.TempDir(), "missing.md"), residue.Report{})) + path := filepath.Join(t.TempDir(), "README.md") + require.NoError(t, os.WriteFile(path, []byte("no markers\n"), 0o644)) + require.Error(t, residue.WriteReadme(path, residue.Report{})) +} + +func TestSplice_Errors(t *testing.T) { + t.Parallel() + cases := []struct { + name string + readme string + }{ + {name: "no markers", readme: "no markers"}, + {name: "reversed markers", readme: residue.EndMarker + "\n" + residue.BeginMarker + "\n"}, + {name: "only begin", readme: residue.BeginMarker + "\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := residue.Splice([]byte(tc.readme), "body") + require.Error(t, err) + }) + } +} diff --git a/tools/ldd-gen/main.go b/tools/ldd-gen/main.go new file mode 100644 index 0000000..55ec8c5 --- /dev/null +++ b/tools/ldd-gen/main.go @@ -0,0 +1,109 @@ +// Command ldd-gen renders the linter-driven-development plugins from core/ +// (language-neutral templates) and lang// (one binding per language), +// and checks that the plugin directories on disk match that rendering. +// +// Usage: +// +// ldd-gen -lang go render lang/go into its plugin directory +// ldd-gen -check render every binding; exit 1 on any difference +// ldd-gen lint-core [-write] report language residue left in core/; exit 1 +// on hard hits; -write refreshes core/README.md +// +// Every form takes -root (default "."), the repository root, after the +// subcommand when there is one. +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + + "github.com/buzzdan/ai-coding-rules/tools/ldd-gen/internal/gen" +) + +func main() { + if err := run(os.Args[1:], os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "ldd-gen:", err) + os.Exit(1) + } +} + +var ( + errDifferences = errors.New("a plugin directory differs from its rendering; edit core/ or lang//, never the plugin directory, then run `task generate` and commit both") + errHardResidue = errors.New("hard residue in core/: each hit needs a profile scalar or an include") + errBothModes = errors.New("-check renders every binding; do not combine it with -lang") + errNoMode = errors.New("pass -lang , -check, or lint-core") +) + +func run(args []string, out io.Writer) error { + if len(args) > 0 && args[0] == "lint-core" { + return lintCore(args[1:], out) + } + fs := flag.NewFlagSet("ldd-gen", flag.ContinueOnError) + fs.SetOutput(out) + root := fs.String("root", ".", "repository root") + lang := fs.String("lang", "", "binding under lang/ to render into its plugin directory") + doCheck := fs.Bool("check", false, "compare every binding's rendering with its plugin directory") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("flags: %w", err) + } + switch { + case *doCheck && *lang != "": + return errBothModes + case *doCheck: + return checkAll(*root, out) + case *lang != "": + return generate(*root, *lang) + default: + fs.Usage() + return errNoMode + } +} + +func generate(root, lang string) error { + repo, err := gen.Open(root) + if err != nil { + return err + } + return repo.Generate(lang) +} + +func checkAll(root string, out io.Writer) error { + repo, err := gen.Open(root) + if err != nil { + return err + } + n, err := repo.Check(out) + if err != nil { + return err + } + if n > 0 { + return errDifferences + } + fmt.Fprintln(out, "ldd-gen: every plugin directory matches its rendering") + return nil +} + +func lintCore(args []string, out io.Writer) error { + fs := flag.NewFlagSet("ldd-gen lint-core", flag.ContinueOnError) + fs.SetOutput(out) + root := fs.String("root", ".", "repository root") + write := fs.Bool("write", false, "rewrite the Residue section of core/README.md") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("flags: %w", err) + } + repo, err := gen.Open(*root) + if err != nil { + return err + } + hard, err := repo.LintCore(out, *write) + if err != nil { + return err + } + if hard > 0 { + return errHardResidue + } + return nil +} diff --git a/tools/ldd-gen/main_test.go b/tools/ldd-gen/main_test.go new file mode 100644 index 0000000..7bd8882 --- /dev/null +++ b/tools/ldd-gen/main_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const profileYAML = `plugin: out-plugin +lang: Lang +cmd_prefix: p-ldd +src_glob: "*.p" +test_glob: "_test.p" +project_marker: p.mod +nolint: "#nolint" +comment_prefix: "#" +default_test: ptest +default_lint: plint +default_lint_fix: plint --fix +` + +func write(t *testing.T, root, rel, content string) { + t.Helper() + path := filepath.Join(root, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) +} + +func miniRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + write(t, root, "lang/p/profile.yaml", profileYAML) + write(t, root, "core/README.md", "# Core\n\n\n\n") + write(t, root, "core/rules/R1.md", "{{.Lang}} rule\n") + write(t, root, "lang/p/passthrough/.claude-plugin/plugin.json", `{"name": "out-plugin"}`+"\n") + return root +} + +func TestRun_GenerateThenCheck(t *testing.T) { + t.Parallel() + root := miniRepo(t) + var out bytes.Buffer + + err := run([]string{"-root", root, "-check"}, &out) + require.ErrorIs(t, err, errDifferences) + assert.Contains(t, out.String(), "missing rules/R1.md") + + require.NoError(t, run([]string{"-root", root, "-lang", "p"}, &out)) + + out.Reset() + require.NoError(t, run([]string{"-root", root, "-check"}, &out)) + assert.Contains(t, out.String(), "every plugin directory matches") +} + +func TestRun_LintCore(t *testing.T) { + t.Parallel() + root := miniRepo(t) + var out bytes.Buffer + require.NoError(t, run([]string{"lint-core", "-root", root, "-write"}, &out)) + assert.Contains(t, out.String(), "Hard residue: none") + readme, err := os.ReadFile(filepath.Join(root, "core/README.md")) + require.NoError(t, err) + assert.Contains(t, string(readme), "\nHard residue: none") + + write(t, root, "core/rules/R2.md", "never add //nolint\n") + out.Reset() + err = run([]string{"lint-core", "-root", root}, &out) + require.ErrorIs(t, err, errHardResidue) + assert.Contains(t, out.String(), "rules/R2.md:1") +} + +func TestRun_Errors(t *testing.T) { + t.Parallel() + root := miniRepo(t) + cases := []struct { + name string + args []string + want error + }{ + {name: "no mode", args: []string{"-root", root}, want: errNoMode}, + {name: "check and lang together", args: []string{"-root", root, "-check", "-lang", "p"}, want: errBothModes}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := run(tc.args, &bytes.Buffer{}) + require.ErrorIs(t, err, tc.want) + }) + } +} + +func TestRun_BadFlagAndBadRoot(t *testing.T) { + t.Parallel() + require.Error(t, run([]string{"-nope"}, &bytes.Buffer{})) + require.Error(t, run([]string{"lint-core", "-nope"}, &bytes.Buffer{})) + require.Error(t, run([]string{"-root", t.TempDir(), "-check"}, &bytes.Buffer{})) + require.Error(t, run([]string{"-root", t.TempDir(), "-lang", "p"}, &bytes.Buffer{})) + require.Error(t, run([]string{"lint-core", "-root", t.TempDir()}, &bytes.Buffer{})) +}