Skip to content

feat: derive the ADRs a repo already obeys, and make the skill linter a gate that actually runs - #555

Merged
sandstream merged 5 commits into
mainfrom
claude/web-kit-status-sync-p6fg9a
Aug 29, 2026
Merged

feat: derive the ADRs a repo already obeys, and make the skill linter a gate that actually runs#555
sandstream merged 5 commits into
mainfrom
claude/web-kit-status-sync-p6fg9a

Conversation

@sandstream

@sandstream sandstream commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Description

Two gates that existed but never ran, plus a new command for the repos you inherit.

kit adr derive recovers the architecture decisions a repo is already obeying — an absent import edge with a populated reverse is a decision someone made and everyone has obeyed since. Measured on kit itself: no subsystem directory has ever imported the command layer (utils → commands, 112 reverse edges across 32 files in scope), and it is in none of the five ADRs.

Then the same failure class as the ADR gate in #542, found the same way: kit skill test --gate has always exited 1 on a failing skill and nothing ever ran it — not CI, not kit review, not verify-suite.sh. Measured, kit's only shipped SKILL.md failed kit's own linter for as long as the linter had existed (no allowed-tools, so the skill implicitly claimed every tool) and no pipeline said a word.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Documentation update

Changes Made

kit adr derive — architecture recovery

  • src/adr-derive.ts — pure derivation, draft rendering, static discrimination.
  • src/commands/adr-derive.ts — argv, the repo-backed verification pass, output. Deliberately one module deeper than commands/adr.ts: flags are derived by walking a handler's imports one level, and review/baseline/standards embed adrCheck, so parsing argv there put --root/--min-support/--emit in their allowlists — flags they would accept and silently ignore. kit's own flag scanner caught it.
  • Drafts are emitted status: proposed, and evaluateAdr ignores every non-accepted ADR, so a derived file gates nothing until a human flips the status. kit proposes with evidence; it never decides that a habit was a decision.

The skill linter becomes a gate

  • skills/triage/SKILL.md declares allowed-tools: Bash with a ## Scope section saying why — the skill's whole action is one subprocess call to scripts/triage.py. This was an omission, not a considered choice: the only commit that ever touched the file was an unrelated release wizard, and no ADR or shared-memory entry mentions it. The module surface is pinned in .kit-skill.snapshot.json.
  • src/skill-run.ts — the embeddable gate over every SKILL.md under skills/ and .claude/skills/. A repo with no skills skips honestly (didNotRun stays false); a skipped check warns rather than passing.
  • kit review gains a fifth stage; ci.yml runs review --stages skill as a hard failure, so a second skill cannot be added un-gated. ci-adr-gate.test.ts pins the invocation the way it pins the ADR gate's.

One defect closed on the way

A mutation score over adr-derive.ts (8 mutations, 5 killed, 3 survived) found that dropping the ^ from a derived rule's regex broke no test. Unanchored, (?:\.\./)+commands/ matches mid-specifier, so a package path like @scope/pkg/../commands/x would be reported as a layering violation. One assertion added to the test that already owns that requirement — not a new test.

Testing

Proved by mutation rather than by inspection. Every claim below is a command that was run:

mutation result
drop the edge-exists guard in the deriver exactly the "no longer true" test fails
emit the draft accepted instead of proposed exactly the disarmed test fails
nested dir named after another bucket (constructed) utils → commands rejected, cited to src/utils/deep/z.ts:1
remove allowed-tools from the shipped skill kit review exit 1, "failed — skill"
widen the skill's scope silently snapshot drift, exit 1
delete the CI step / add continue-on-error the pin goes red
drop sibling comparison / render a skip as a pass / render "no skills" as a pass each breaks exactly the one test that should notice

End-to-end proof that a derived draft is inert until armed:

1. emit draft                      → adr list: [proposed]   adr check exit=0
2. inject a real violation         → adr check exit=0   ← proposed gates nothing
3. flip status: proposed→accepted  → ✗ ...askQuestion.ts:1 (ADR-9998)  exit=1
4. revert                          → adr check exit=0

Test Coverage

  • Added new tests
  • Updated existing tests (the stage-list assertions in review.test.ts / mcp-server.test.ts went from four stages to five)
  • All tests pass (npm test) — 4714 tests, 0 fail, 3 skipped, all three skips loud (0500 does not deny writes … running as root? ×2, semgrep not installed)

Manual Testing

  1. node dist/cli.js adr derive — 9 verified candidates, 0 rejected
  2. node dist/cli.js adr derive --emit utils/commands — draft parses, lists as proposed
  3. node dist/cli.js review --stages adr,skill,design — passes
  4. node dist/cli.js skill test skills/triage/SKILL.md --gate — exit 0

Breaking Changes

None. ReviewStageName gains "skill", which widens an existing union rather than changing one; a scoped --stages run is unaffected.

Checklist

  • My code follows the project's style guidelines
  • I've updated documentation as needed — every statement of review's stage list (README, CLAUDE.md, AGENTS.md, COMMANDS.md, MCP guide, MCP server, CLI help), since CLAUDE.md's four-stage line is what made the gap invisible
  • All new code has test coverage
  • All tests pass locally (npm test)
  • No new warnings or errors introduced — eslint clean on every new file
  • Commit messages follow conventions
  • No hardcoded secrets or sensitive data
  • Code has been self-reviewed

Performance Impact

  • No performance impact

commands/adr.ts loads the derive module and the repo map through a dynamic import, so adr check — which runs in the pre-commit hook — never pays for either.

Reviewer Notes

Nothing here accepts an ADR on kit's behalf. The nine derived candidates are proposals; accepting them is yours to decide, and the design would be undermined by the tool's author quietly accepting its own output. To arm one:

node dist/cli.js adr derive --emit utils/commands > docs/adr/0006-utils-does-not-import-commands.md
# then change `status: proposed` to `status: accepted`

Known limits, stated rather than left to be discovered: TS/JS + Python relative imports only; top-level buckets under one source root; files directly in the root excluded (they reach a sibling as ./x, not ../x); and a snapshot rather than a history — "never occurs" says the rule holds now, not that anyone intended it.

Two mutation survivors are recorded but not patched: ruleWouldFire's second internal assertion is redundant with its first, and the from === to guard in the edge-count loop is provably unobservable (the candidate loop skips the same case), which makes it dead defensiveness rather than a coverage gap. Neither is worth a test that locks behaviour nobody depends on.

🤖 Generated with Claude Code

claude added 3 commits August 29, 2026 21:46
…one before proposing it

Taking over a repo, the architecture decisions are in the code, not in docs/adr.
`kit adr derive` recovers them: an absent import edge with a populated reverse is a
decision someone made and everyone has obeyed since. Measured on kit itself, no
subsystem directory has ever imported the command layer (utils -> commands, 112
reverse edges across 32 files in scope) and it is in none of the five ADRs.

Two failure modes are checked before a candidate is ever shown, because a derived
rule that breaks CI on acceptance is worse than no rule, and one that can never go
red is worse still:

  - static discrimination: the emitted regex is tested against the specifier shape a
    real violation would use, so a rule that cannot fail is not proposed;
  - dynamic verification: each draft is rendered, parsed with the real parser, armed
    in memory and run through the same evaluator `adr check` uses, over the real repo
    with tests included. Anything that fires is dropped AND reported, never silently
    absent. This is not theoretical -- a bucket containing a nested directory named
    after another bucket makes `../commands/` resolve inside the bucket, which the
    graph cannot see and the evaluator can.

Drafts are emitted `status: proposed`, and evaluateAdr ignores every non-accepted
ADR, so a derived file gates nothing until a human flips the status. kit proposes
with evidence; it never decides that a habit was a decision.

The CLI half lives one module deeper than commands/adr.ts on purpose: flags are
derived by walking a handler's imports one level, and review/baseline/standards embed
adrCheck -- parsing argv in adr.ts would have put --root/--min-support/--emit in their
allowlists, flags they would accept and silently ignore.

Limits, stated rather than discovered later: TS/JS + Python relative imports only,
top-level buckets under one source root, files directly in the root excluded (they
reach a sibling as ./x, not ../x), and a snapshot rather than a history -- "never
occurs" says the rule holds now, not that anyone intended it.
…'s scope, wire it into review and CI

Same failure class as the ADR gate in #542, found the same way: `kit skill test --gate`
has always exited 1 on a failing skill, and nothing ever ran it. Not CI, not `kit
review`, not verify-suite.sh. Measured, kit's ONLY shipped SKILL.md failed kit's own
linter for as long as the linter had existed -- no `allowed-tools`, so the skill
implicitly claimed EVERY tool -- and no pipeline said a word. A tool that lints skills
while shipping one that fails its own lint is not a gate, it is advice.

Three parts, none of which works without the others:

1. skills/triage/SKILL.md declares `allowed-tools: Bash` and says why in a Scope
   section. The skill's whole action is one subprocess call to scripts/triage.py: it
   reads no files through the agent, fetches nothing, writes nothing. This was an
   omission rather than a considered choice -- the only commit that ever touched the
   file was an unrelated release wizard, and no ADR or shared-memory entry mentions it.
   The module surface is now pinned in .kit-skill.snapshot.json, so silently widening
   the declared privileges is drift and fails.

2. src/skill-run.ts is the embeddable gate: it discovers every SKILL.md under skills/
   and .claude/skills/, runs the four deterministic checks against each, and folds them
   into one result. Per-skill rows are named `<skill>: <check>` so a red row points at a
   file and a rule. A repo with no skills SKIPS honestly (didNotRun stays false --
   nothing was prevented from running); a skipped check WARNS rather than passing,
   because an unproven check is not a clean one. It judges module discipline only:
   whether a skill's output is any good is a model judgement and stays outside kit
   (ADR-0001).

3. `kit review` gains a fifth stage, and ci.yml runs it as a hard failure. The CI step
   invokes `review --stages skill` rather than a hard-coded path, so a second skill
   cannot be added un-gated. ci-adr-gate.test.ts now pins this invocation the way it
   pins the ADR gate's -- deleting the step, or adding continue-on-error, fails the
   suite.

Proved by mutation rather than by inspection: dropping `allowed-tools` and widening it
both turn `kit review` red with the stage named; deleting the CI step and neutering it
with continue-on-error both fail the pin; three mutations of the gate itself (drop
sibling comparison, render a skip as a pass, render "no skills" as a pass) each break
exactly the one test that should notice. While writing the pin I shipped a regex whose
leading \b could never match an alternative starting with `-`, so it passed while
matching nothing -- caught only by deleting the CI step and watching the test stay
green. That is the same defect class this whole change is about, one level up.

Every statement of review's stage list is updated with it (README, CLAUDE.md,
AGENTS.md, COMMANDS.md, MCP guide, MCP server, CLI help), since CLAUDE.md's four-stage
line is what made the gap invisible in the first place.
…n that survived

Running a real mutation score over adr-derive.ts (8 mutations) instead of only the two
I already suspected: five were killed, three survived unnoticed. One of the survivors
is a defect worth closing — dropping the `^` from the derived rule's regex broke no
test, and an unanchored `(?:\.\./)+commands/` matches mid-specifier, so a package path
like `@scope/pkg/../commands/x` would be reported as a layering violation. The anchor
was already correct; nothing proved it stayed correct.

One assertion added to the existing non-match test rather than a new test: the
requirement is "this rule does not match things that are not sibling imports", which
that test already owns.

The other two survivors are recorded rather than patched. `ruleWouldFire`'s second
internal assertion is redundant with its first. The `from === to` guard in the
edge-count loop is provably unobservable — the candidate loop skips the same case — so
removing it changes nothing, which makes it dead defensiveness rather than a coverage
gap. Neither is worth a test that locks behaviour nobody depends on.
@github-actions

Copy link
Copy Markdown

✅ Docker image built successfully

  • CLI image: docker.io/sandstream/kit:test

CI caught what the module split left behind: `kit check --category tests
--enforce-tests` reported `src/commands/adr-derive.ts` as 1 new untested file. The
tests for `deriveAdrs` were still sitting in `commands/adr.test.ts`, where the code
lived before it moved one module deeper to keep its flags out of review/baseline/
standards. The code migrated; the tests did not.

A move, not an addition — same three tests, same assertions, now in the file whose
name the coverage check derives from the source. Locally the check goes from "1 new
untested file(s) (83 total)" to "82 pre-existing (baseline-frozen)", exit 0.
@github-actions

Copy link
Copy Markdown

✅ Docker image built successfully

  • CLI image: docker.io/sandstream/kit:test

@github-actions

Copy link
Copy Markdown

✅ Docker image built successfully

  • CLI image: docker.io/sandstream/kit:test

@sandstream
sandstream merged commit f4d245f into main Aug 29, 2026
19 checks passed
@sandstream
sandstream deleted the claude/web-kit-status-sync-p6fg9a branch August 29, 2026 23:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants