Skip to content

refactor(layering): declare R1-R3 as a policy table, and test them - #1449

Merged
thymikee merged 1 commit into
mainfrom
claude/codebase-dependency-graph-34kgk6
Jul 28, 2026
Merged

refactor(layering): declare R1-R3 as a policy table, and test them#1449
thymikee merged 1 commit into
mainfrom
claude/codebase-dependency-graph-34kgk6

Conversation

@thymikee

Copy link
Copy Markdown
Member

Summary

R1–R3 were three hand-written predicate functions. Each was short, but each buried its boundary in
control flow — you had to read the early-returns to learn that R3 tolerates dynamic imports, or that
R1 opens exactly one door. They are now data in scripts/layering/zone-policy.ts: which zones a
boundary governs, which import kinds it tolerates, which path prefixes are its declared seam, walked
by one small evaluator. A fourth zone boundary becomes a table entry rather than a fourth predicate
to keep consistent with the other three.

check.ts is 86 lines lighter. No behaviour change: 932 source files, R6 = 7, R7 = 41 fields,
R8 clean, R9 = 102 — all identical to before.

{
  rule: 'R3 platforms-seam',
  to: ['platforms'],
  tolerates: ['type-only', 'dynamic'],
  seam: ['src/core/interactors/', 'src/daemon/', 'src/sdk/'],
  seamExcept: ['src/daemon/client/'],
  hint: 'Only src/core/interactors/, the daemon server and the sdk barrel may statically …',
}

R1 is deliberately two entries rather than one with a special case, because "kernel may import
contracts type-only" and "kernel may import nothing else at all" are two statements. Writing them
separately is what makes the single open door visible — and a test pins that, so a future merge of
the two fails and says why.

The refactor exposed a real gap: R1–R3 had no unit test

checkLayeringRules had none. The only thing exercising these three rules was the real tree, which
is clean — so a rule that had silently stopped matching would have looked exactly like a rule being
obeyed.
zone-policy.test.ts adds 6 tests asserting each boundary fires and each documented
exemption holds, including that src/daemon/client/ is excluded from the daemon seam (the old code
expressed that as a negated path check nothing verified).

Verified end-to-end by injection, not just by unit test — one violation per rule plus an exempt file:

injected result
kernel/ → utils/ value import R1 fires
core/ → commands/ value import R2 fires
cli/ → platforms/ static value import R3 fires
daemon/client/ → platforms/ static value import R3 fires — seamExcept works
cli/ with a type-only and a dynamic platforms import correctly ignored

Gate reported 4 zone-policy violations (+2 from R5, independently) and exited 1. Cleaned up, back
to OK.

Also: eslint-plugin-boundaries under oxlint — spiked, works, not adopted

Recorded in docs/dependency-graph-findings.md §7 with the working config so nobody repeats the
investigation.

It does work. oxlint's jsPlugins loads npm ESLint plugins directly — no ESLint install, no
second config, no extra CI step. R1–R3 are all expressible and all three were confirmed firing on
injected violations. The two non-obvious settings:

need mechanism
ignore type-only edges importKind: "value" on the policy
ignore dynamic import() settings["boundaries/dependency-nodes"] = ["import", "export"]

Why not adopted. No single item is fatal; together they lose more than the declarative syntax
gains, and ZONE_POLICIES provides that syntax anyway:

  • No ratchet. The mechanism that took R6 from 61 → 7 across three PRs is a baseline that
    tolerates N and fails on N+1. A per-file lint rule has no cross-run aggregate. A new boundary with
    existing violations — a platforms facade would have ~89 — needs one disable comment per site.
  • It cannot replace the gate. R4 (cycles), R5/R6 (spine ranking + ratchet), R7 (field ownership —
    not an import rule at all), R8 (CI job closure) and R9 (cycle size) are whole-graph or non-import
    properties. Adopting it means the architecture is defined in two places.
  • Inline type specifiers are misread. import { type A, type B } from '…' is fully erased at
    runtime, but the plugin classifies it as a value import — its importKind is statement-level.
    One genuine false positive here: providers/limrun/android.ts flagged for R3, which the gate
    correctly ignores. 10 of 1,481 type-only edges use that form, so the blast radius is small, but
    statementIsTypeOnly handles both spellings and the plugin does not.
  • Message specificity regressed. Under the current non-deprecated selector syntax,
    {{dependency.type}} and {{file.type}} interpolated to empty, so violations read
    must not import commands/ with no zone named. ADR 0010 wants every error actionable.
  • Cost. 230 transitive packages, jsPlugins documented as alpha and not subject to semver, and
    five deprecation warnings from one config attempt (mode, rules, legacy selectors, legacy
    templates, rule-level importKind) spanning two major migrations.

Two things nearly fooled me and are worth knowing: an invalid rule option makes the plugin silently
report zero findings
— I briefly concluded "R3 is expressible!" from a rule that wasn't running
(oxlint does exit 1, so CI would catch it, but the lint output is clean). And my first two configs
produced 27 then 18 "violations" that were entirely classification bugs from mode/partialMatch
semantics. Injection testing caught both; reading the output would not have.

Worth re-evaluating under two conditions, both noted in the doc: a monorepo migration (per-package
ESLint configs change the calculus), or jsPlugins going stable with the ratchet gap addressable.

Validation

pnpm check green — lint, typecheck, R1–R9, depgraph:test, production exports, MCP metadata,
build, bundle owner files, Fallow, 4642 unit tests. The new test file is wired into both
check:layering and the Layering Guard CI job, so it cannot pass locally and be skipped in CI.

No dependency changes: the spike's plugin was removed and pnpm-lock.yaml is untouched.


🤖 Generated with Claude Code

https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur


Generated by Claude Code

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.87 MB 1.87 MB 0 B
JS gzip 598.9 kB 598.9 kB 0 B
npm tarball 714.6 kB 714.6 kB +13 B
npm unpacked 2.50 MB 2.50 MB +37 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 29.3 ms 28.2 ms -1.2 ms
CLI --help 60.2 ms 58.2 ms -1.9 ms

Top changed chunks: no changes in the largest emitted chunks.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-28 15:44 UTC

@thymikee

Copy link
Copy Markdown
Member Author

Code review is clean at 1e3176a: the declarative table preserves R1–R3 semantics, keeps the exact kernel/contracts, commands-floor, and platform-seam exemptions, introduces no runtime/ownership boundary change, and wires focused evaluator tests into the actual Layering Guard.

Exact-head targeted tests and the guard are green.

Not ready solely because GitHub reports a real package.json conflict with main. Resolve it by retaining main’s changed-line coverage scripts and this PR’s expanded check:layering command including zone-policy.test.ts, then rerun the exact-head layering/tooling checks. No ready-for-human label while conflicted.

@thymikee
thymikee force-pushed the claude/codebase-dependency-graph-34kgk6 branch from 1e3176a to 6557578 Compare July 28, 2026 15:01

Copy link
Copy Markdown
Member Author

Rebased onto b2b116f and pushed as 655757883. The conflict is resolved exactly as you specified.

The conflict was check:coverage-changed / check:coverage-changed:test from #1447 landing on the lines adjacent to my check:layering edit. Both sides retained — package.json is now a single-line diff against main:

-    "check:layering": "… --test scripts/layering/model.test.ts && … check.ts",
+    "check:layering": "… --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts && … check.ts",

with main's two coverage scripts untouched above it.

Re-ran the exact-head checks. pnpm check green: 4671 passed, 4 skipped, 0 failed. Layering guard OK over 932 source files — R6 = 7, R7 = 41 fields all classified, R8 clean, R9 = 102, all unchanged by the rebase. pnpm check:coverage-changed:test passes (17 tests).

One thing I checked because the new base makes it a fair question: #1447's changed-line coverage gate scopes to ^src/.*\.ts$ (isIncludable in scripts/coverage-changed/model.ts:160), so scripts/layering/zone-policy.ts is out of scope. That matters because zone-policy.test.ts runs under node --test rather than vitest and produces no lcov data — had the gate covered scripts/, this PR would have tripped it despite the new file being the best-tested part of the change. No action needed, but worth recording in case the gate's scope widens later.

Nothing else changed in the rebase — the six new commits on main touch replay, packaging smoke, error-code quizzes, Renovate config and the command-surface gate, none of which overlap scripts/layering/.


Generated by Claude Code

R1-R3 were three hand-written predicate functions. Each was short, but each
buried its boundary in control flow: you had to read the early-returns to learn
that R3 tolerates dynamic imports, or that R1 opens exactly one door. They are
now data in scripts/layering/zone-policy.ts -- which zones a boundary governs,
which import kinds it tolerates, which path prefixes are its declared seam --
walked by one small evaluator. A fourth zone boundary becomes a table entry
rather than a fourth predicate to keep consistent with the other three.

R1 is deliberately two entries rather than one with a special case, because
"kernel may import contracts type-only" and "kernel may import nothing else at
all" are two statements, and writing them separately is what makes the single
open door visible.

The refactor exposed a real gap: checkLayeringRules had NO unit test. The only
thing exercising R1-R3 was the real tree, which is clean, so a rule that had
silently stopped matching would have looked exactly like a rule being obeyed.
zone-policy.test.ts now asserts each boundary fires and each documented
exemption holds, including that src/daemon/client/ is excluded from the daemon
seam. Verified end-to-end by injecting one violation per rule plus an exempt
file: the gate reports 4 zone-policy violations (R3 twice, catching the
daemon/client case), ignores the type-only and dynamic edges, and exits 1.

Also records in docs/dependency-graph-findings.md the result of spiking
eslint-plugin-boundaries under oxlint's jsPlugins, so nobody repeats it. It
does work -- jsPlugins loads npm ESLint plugins with no ESLint install, and
R1-R3 are all expressible once you know importKind: "value" and
settings["boundaries/dependency-nodes"]. Not adopted: no ratchet mechanism (the
thing that took R6 from 61 to 7 incrementally), it cannot express R4-R9 so the
architecture would be defined in two places, it misreads inline `{ type Foo }`
specifiers as value imports (one false positive on providers/limrun/android.ts),
message interpolation renders empty under the current selector syntax, and it
costs 230 transitive packages on an API documented as alpha.

No behaviour change: 932 source files, R6 = 7, R7 = 41 fields, R8 clean,
R9 = 102, all identical to before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
@thymikee
thymikee force-pushed the claude/codebase-dependency-graph-34kgk6 branch from 6557578 to ffd8316 Compare July 28, 2026 15:07

Copy link
Copy Markdown
Member Author

Lint & Format was red on 655757883. Fixed and pushed as ffd8316fc — my error, and the interesting part is why it got past a green local run.

The failure: format:check flagged exactly the two new files. Nothing else.

scripts/layering/zone-policy.test.ts (0ms)
scripts/layering/zone-policy.ts (0ms)
Format issues found in above 2 files.

Two formatting nits only — a long assert.deepEqual(...) wrapped, and a short object parameter unwrapped onto one line. pnpm format fixed both; pnpm format:check now passes over all 1,800 files.

Why my local run missed it. I ran pnpm format before rebasing, when that script still took an explicit path list — and scripts/layering/ was never in it. #1444 simplified both scripts to repo-wide (oxfmt --write / --check with no paths), so after the rebase the new files came into scope for the first time. Formatting them was a rebase consequence I didn't re-check.

The gap worth flagging: pnpm check cannot catch this.

check:tooling = lint && typecheck && check:layering && depgraph:test
              && check:production-exports && check:mcp-metadata && build && check:bundle-owner-files

No format:check. So pnpm check is green while CI is red, which is the one failure mode a pre-push gate exists to prevent — and it has now bitten me twice on this repo (previously by running npx oxfmt, resolving to 0.60.0 against the pinned 0.42.0). Since #1444 made format:check repo-wide and it takes 873ms over 1,800 files, adding it to check:tooling is close to free.

I have deliberately not bundled that here — it changes a shared script on a PR that's otherwise a single-purpose refactor, and it wasn't part of your feedback. Happy to add it to this PR or open a one-liner separately, whichever you prefer.

Re-verified on ffd8316fc: pnpm format:check clean, pnpm check green — 4671 passed, 4 skipped, 0 failed, layering guard OK over 932 files with R6 = 7, R7 = 41, R8 clean, R9 = 102. The package.json conflict resolution from the rebase is unchanged.


Generated by Claude Code

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 28, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed clean at ffd8316fca861e40a7dd4172cd100f4666249567.

The policy evaluator preserves the R1–R3 contracts, including kernel’s sole type-only contracts escape, the daemon-client platform exclusion, and dynamic/type-only platform exemptions. The added tests prove both violations and documented exemptions, and all checks are green. Ready for human review.

@thymikee
thymikee merged commit ba1a5ef into main Jul 28, 2026
27 checks passed
@thymikee
thymikee deleted the claude/codebase-dependency-graph-34kgk6 branch July 28, 2026 15:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants