Skip to content

fix(lint): the architecture gate fails closed on an edge it cannot see#120

Merged
wmadden-electric merged 2 commits into
mainfrom
claude/beautiful-hodgkin-3e83fa
Jul 17, 2026
Merged

fix(lint): the architecture gate fails closed on an edge it cannot see#120
wmadden-electric merged 2 commits into
mainfrom
claude/beautiful-hodgkin-3e83fa

Conversation

@wmadden-electric

Copy link
Copy Markdown
Contributor

pnpm lint:deps did not guard newly added files. A new file under packages/ matched no glob in architecture.config.json, so it joined no module group and no domain/layer/plane rule applied to it. A layering violation landed with a green gate. This makes the gate fail closed and proves the fix fires.

Found during the SPI-inversion project (#117), which fixed classification for its own new file and deliberately left the mechanism alone.

The audit found more than expected

28 of 136 source files (21%) had no architecture rule applied to them — not just hypothetical future files. The storage and streams packages were unclassified entirely, including storage-server.ts (calls Bun.serve) and *-entrypoint.ts — execution-plane runtime code with zero plane rules. Also unclassified: 6 files in 1-extensions/target, and 4 public files in composer-prisma-cloud/src.

Classifying them surfaced no hidden violations. The code was fine; only the lint was blind.

Cause #2 works differently than reported

A missing paths entry doesn't fail to resolve. It resolves successfully through the node_modules symlink to the package's exports map → built dist → which the cruiser excludes. The edge is silently dropped.

This matters: it means a dependency-cruiser couldNotResolve rule does not catch it (tried, discarded). Six specifiers were affected — including @internal/storage and @internal/streams — and aliasing them made 12 previously invisible edges visible (540 → 552 deps). All clean.

Why a check, not a glob default

A glob-based default classification was considered and rejected. storage/src/testing.ts re-exports Bun.serve boot code, so it is execution — but a path-based default would call it shared. A wrong default is worse than no rule: a file mislabeled shared also makes control → it edges pass when they should fail. That is a false-negative factory, and it contradicts ADR-0005 (we don't guess).

Both causes are the same shape — an edge the cruiser cannot see — so both live in one check, scripts/lint-architecture-coverage.mjs:

  1. Classification — every source file under packages/ must match a glob in architecture.config.json.
  2. Aliasing — every workspace specifier must be aliased to source in tsconfig.depcruise.json.

The check imports the cruiser's own exclude list and shares its normalizeGlob. If it used its own copies it could report a file as classified while the cruiser still applied no rule.

Proven with a control

Control Result
New public file importing an execution module, unclassified (the #117 scenario) 🔴 coverage check
Same file classified as control 🔴 plane rule fires
Remove one paths entry, violation still correctly classified 🔴 coverage check — cruiser alone reports green

The third is the decisive one: it proves cause #2 is independently sufficient. With a real, correctly-classified control → execution violation present, the cruiser alone says ✔ no dependency violations found.

The control caught a real bug in this PR. The first version of the check used git ls-files, which lists only tracked files — so a brand-new file was invisible to it. It printed "135 files checked" and passed while the probe sat right there: the exact blind spot being closed, reintroduced in the fix for it. Now uses --others --exclude-standard. A fix that can't be shown to fire is the same bug again.

All controls removed. 10 unit tests lock the behavior in.

Also fixed

The cruiser cruised files its exclude list meant to skip: \.test\. missed .vitest.ts, and vitest\.config missed vitest.conformance.deployed.config.ts — a third instance of the same enumeration bug. tsconfig.depcruise.json is now a lint-staged trigger, since dropping a paths entry hides violations.

Review focus

The 28 plane classifications are judgement calls, made using the cron package as precedent. Worth your eyes, particularly storage-testing.ts / streams-testing.ts as execution (they re-export server boot code) — two independent agents disagreed on exactly that call.

Verification

  • pnpm lint:deps — green (213 modules, 552 deps, 135 files classified, 171 specifiers checked)
  • pnpm test:scripts — 121/121 pass
  • Both commits green in isolation (bisectable); biome clean

🤖 Generated with Claude Code

wmadden-electric and others added 2 commits July 17, 2026 15:54
28 of 136 source files matched no glob in architecture.config.json, so they
joined no module group and no domain/layer/plane rule applied to them. The
storage and streams packages were unclassified entirely, including their
entrypoint and server files, which boot the deployed runtime.

Six workspace specifiers were also missing from tsconfig.depcruise.json. Those
resolve through node_modules to the package exports map, which points at built
dist; dist is excluded, so the cruiser dropped the edge and could not rule on
it. Aliasing them to source makes 12 previously invisible edges visible. All
of them turn out to be clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
A new file under packages/ joined no module group and no rule applied to it, so
a plane violation in it passed with a green gate. That was the default for
every file added to the repo. A missing paths entry did the same thing by a
different route: the import resolved to excluded dist and the edge vanished.

lint-architecture-coverage.mjs now fails when a source file is unclassified or
a workspace specifier is unaliased. It is a check rather than a default
classification because neither can be guessed: a plane is a judgement about
when code runs, and a file wrongly defaulted to shared would also weaken the
rules applied to files importing it (ADR-0005). It imports the cruisers own
exclude list and shares its normalizeGlob, so the two cannot disagree about
what is checked.

The cruiser also cruised files its exclude list meant to skip: .test. missed
.vitest.ts and vitest.config missed vitest.conformance.deployed.config.ts.

Proven with a deliberate control: a new public file importing an execution
module is red when unclassified and red on the plane rule once classified.
Removing one paths entry turns the cruiser green on that same real violation,
which the coverage check catches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@pkg-pr-new

pkg-pr-new Bot commented Jul 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@prisma/composer@120
npm i https://pkg.pr.new/@prisma/composer-prisma-cloud@120

commit: 2e4db4a

@wmadden-electric
wmadden-electric merged commit d7600c4 into main Jul 17, 2026
15 checks passed
@wmadden-electric
wmadden-electric deleted the claude/beautiful-hodgkin-3e83fa branch July 17, 2026 14:03
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