Files
Michał Pierzchała 56b72c5cf7 refactor(boundaries): put shared contracts below their consumers, gate the result (#1405)
* refactor(boundaries): move shared contracts below their consumers

Acts on the depgraph findings: type-only edges are invisible to R5, so
vocabulary that everything depends on had drifted above the zones that use it.

- contracts/: the four platform-plugin facet tags (LogBackend,
  RecordingBackendTag, PerfMetricsSamplerTag, PlatformGatedProviderResolverKey)
  now live beside the plugin contract itself, which also moves out of core/;
  NetworkEntry moves next to the command surface that renders it; and the
  click-button, recording-export-quality, interactor-types and
  runner-lease-context vocabularies move down out of core/.
- (root) drops from 29 files to 13: the internal *-contract/output/annotation
  modules move into contracts/, kernel/ (daemon-error, observability-redaction
  beside kernel/redaction), core/ (batch-policy, an ADR 0008 projection),
  commands/ (cli-command-aliases) and remote/ (upload-progress, upload-stream).
  What remains is entrypoints and the composition roots that R2 requires to
  sit outside the spine.
- utils/ joins the ranked spine at rank 1 after its only two upward files move
  to the zones they were reaching for (cli/resolve-cli-options,
  cli-schema/cli-config), putting ~336 value edges under the gate.
- Internal imports that routed types through the client-types re-export hub now
  name their real source.

Type-only spine inversions drop from 61 to 35; the remainder is two clusters
(client/client-types.ts and the ADR 0003 daemon facet). No behaviour change:
4470 unit tests and the layering gate pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* style: merge the duplicate contract imports the tag moves created

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* refactor(imports): name the declaring module, share find's argument rules

Two follow-ups from re-measuring the graph after the boundary moves.

1. 89 type imports across 79 files routed through a re-export hub in another
   zone: `CliFlags` reached through commands/cli-grammar/flag-types.ts (52) when
   it is declared in contracts/cli-flags.ts, the replay suite result types
   reached through daemon/types.ts when they are declared in contracts/replay.ts,
   the doctor types through a daemon handler module, and so on. Each hop invented
   a cross-zone edge the architecture never asked for — including every apparent
   replay -> daemon and utils -> commands dependency. They now name the module
   that declares them. Within-zone hops are left alone; those are a local style
   choice, not a boundary claim.

2. `find`'s three positional/flag checks existed in both daemon entry points with
   hand-repeated messages, and the copy in dispatchFindReadOnlyViaRuntime was
   unreachable — its only caller validates first. Both now call checkFindArgs in
   selectors/find.ts, beside parseFindArgs and isReadOnlyFindAction, for the
   reason that module's own comment already gives: so the two paths cannot
   disagree. The refusal is returned rather than thrown, because the two
   mechanisms are not observationally identical in the session event log.

Type-only spine inversions: 61 -> 35. 4470 unit tests and every gate pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* feat(layering): ratchet type-only spine inversions (R6)

R5 ignores type-only edges by design — they cost nothing at runtime and do not
affect cold start — so nothing was watching the direction they point. Ranking
them the same way found 61 inversions, including contracts/ and utils/ declared
in terms of rank-4 zones. 26 are fixed by the preceding commits; R6 pins the
rest per zone pair so they can only shrink, and a new pair fails outright rather
than being added to the baseline.

The two remaining clusters each need their own change, and the baseline says so:
the per-command Options/Result vocabulary declared inside the public Node-client
surface, and the ADR 0003 daemon facet shape that core's descriptor registry
composes.

Both ratchet directions are covered: growth fails, and shrinking without
lowering the number fails too, so the baseline cannot quietly stop describing
the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* docs: record the import-graph findings behind this refactor

A dated snapshot, not a normative document: when it disagrees with
scripts/layering/, the gate wins. The graph tool that produced it lives on the
claude/depgraph-viewer branch, deliberately out of this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* refactor(selectors): state the shared selector argument rules once

R2 (commands-floor) forbids the daemon from importing commands/, and that is the
right call: commands/ is the client-side surface — its only consumers are cli/,
cli-schema/, mcp/, client/ and the composition roots — while the daemon is the
executor on the other side of the wire. ADR 0008 protects exactly that seam.
Relaxing R2 would let the executor depend on a client projection and pull CLI
grammar and output formatting into the daemon's bundle.

But the rule does force duplication: the daemon must validate independently
because it accepts requests from any client, so 10 refusal messages existed in
both zones. The only place a shared rule can live is below both, and selectors/
already held the parsers (splitIsSelectorArgs, splitSelectorFromArgs,
isSupportedPredicate) and even the `is` predicate message — just not the checks
that use them.

Three drifts had already appeared in the `is` predicate rule alone:

- commands/interaction/selectors.ts re-implemented the predicate list as an
  inlined seven-way `!==` chain while importing the message and hint from
  selectors/predicates.ts, so adding a predicate to the shared list would not
  have reached the CLI grammar.
- That inlined chain compared the raw token, so the CLI rejected `is TEXT ...`
  while the daemon it hands the command to accepts it. The CLI now matches the
  executor; this is an intentional alignment, not an accident.
- isCommand raised the same refusal without IS_PREDICATE_USAGE_HINT, so whether
  an agent got recovery guidance depended on which layer noticed first — the
  failure mode ADR 0010's audit calls out.

checkIsPredicate, checkIsArgs, checkGetFormat, checkElementTargetArgs and
checkWaitText now hold those rules, each beside the parser it wraps, and report
a refusal rather than choosing how to raise it: the daemon returns a response,
the command surface throws. Those mechanisms are not interchangeable — they
write different session events — so the shared check stays out of that decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* feat(daemon): give ADR 0014's ref frame one transition, pin SessionState owners

`SessionStore.get()` returns the live record out of a private Map and `set()`
re-puts the same reference, so every `session.<field> = …` in the daemon is a
durable write to store-owned state: 57 of them across 17 files, against 26
`set()` calls that are therefore ceremonial. Nothing at the store boundary can
check what those writes are supposed to keep true.

Measuring which module writes which field showed the problem is narrower than
the raw count suggests — 16 of 27 fields already have exactly one writer. The
sharp case is ADR 0014's ref frame: `refFrameState`, `refFrameScope`,
`refFrameTree` and `refFrameGeneration` must move together or the frame is
incoherent (an `active` state with a stale tree resolves refs against a
namespace nobody authorized), yet complete issuance wrote them in ref-frame.ts
and partial issuance wrote the same four in session-snapshot.ts. ref-frame.ts's
own header claims to be "the single owner of the frame's transitions", and
session-snapshot.ts documented itself as the exception. Both forms now go
through `activateRefFrame`; they differ only in scope.

`recordSession` deliberately moves alone in two paths (recording without arming
a publication), so the save-script cluster gets no invented abstraction — it
gets ownership instead. R7 records every field's owner and stops the set from
growing quietly: a new SessionState field must declare one, a foreign write
fails naming the owner to call, and an owner that stops writing must be removed
so the table cannot drift into fiction. Field names are read out of the
`SessionState` declaration, so a daemon module with an unrelated local named
`session` — a provider or runner session — cannot trip it.

4475 unit tests and every gate pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* docs: record the reference semantics and refresh the findings

SessionStore.get/set now document that the record is handed out live, since that
is the fact behind R7. The findings snapshot picks up the resolved R2 question,
the ref-frame consolidation and the two new gate scopes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* refactor(boundaries): rank every satellite zone, extract the provider port

Second-order effect of the earlier rounds. With `utils` on the spine and
`(root)` emptied of shared contracts, the eleven zones that were unranked
"because ranking them would invent an order the architecture had not committed
to" turned out to have a consistent rank already — the order was there,
unasserted. Solving the constraint system showed one blocker: `utils/remote-config.ts`
projected a remote-config profile into `CliFlags` while reaching up into
`remote/`, and its only three consumers were in `cli/`. It moves there as
`cli/remote-config-flags.ts`, and every satellite zone joins the spine.

Ranked coverage goes from 730/895 files to 882/895. Only `(root)` stays out, and
now for one stated reason: R2 forbids `daemon/` from importing `commands/`, so
the files that wire them compose the spine from above.

Ranking them exposed 22 type-only inversions R6 had never been able to see, and
they were concentrated rather than scattered:

- The device-provider port. `providers/` and `cloud-webdriver/` implement what
  the daemon calls, so both sides name `DeviceLease`, `LeaseLifecycleProvider`,
  `LeaseLifecycleContext` and `DeviceInventoryProvider` — now declared in
  contracts/device-provider.ts, below both. The adapters also imported the
  daemon's NARROWED `DaemonRequest` while only ever reading `req.flags`; they now
  name the public one from kernel/contracts.
- `MetroPrepareKind` and the remote-config profile field groups move to
  contracts/ for the same reason: the command surface validates them and
  contracts/cli-flags.ts is composed from them.

Two clusters remain, ratcheted with their reasons in TYPE_INVERSION_BASELINE:
the client-types vocabulary, and `SessionAction`, which needs `CommandFlags` and
`DaemonBatchStep` to move with it.

Also fixes two things CI caught: the eight type re-exports my earlier import
redirection orphaned (none published through any src/sdk/* entrypoint, so no
public surface changes) and `isSupportedPredicate`, now module-private since
`checkIsPredicate` is the admission API. `fallow-baselines/health.json` is keyed
by path, so the moved cli-config entry moves with the file rather than being
regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* fix(selectors): use the admitted predicate, not the raw option

Review finding. `isCommand` called `checkIsPredicate` and then kept reading
`options.predicate` for the capture policy, the `exists` branch,
`evaluateIsPredicate`, the failure message and the returned result. Admission
normalizes case, so an upper-case predicate was let past the gate and then
evaluated against lower-case branches: `EXISTS` skipped its own branch and fell
through to the generic path, and the result echoed the raw token. I widened
admission at that surface without threading the normalized value through it —
the CLI-grammar surface in the same change does use the admitted value.

Every decision after admission now reads it.

Two tests, both verified to fail without the fix:

- a production-route regression driving `device.selectors.is` with
  `EXISTS`/`TEXT`, plus one pinning that an unknown predicate is still refused
  WITH the ADR 0010 usage hint;
- a surface parity gate (selectors/__tests__/is-argument-surface-parity.test.ts)
  in the repo's existing parity style, asserting the daemon and CLI-grammar
  surfaces reach the same verdict and hand the same normalized predicate
  downstream across an input table. A helper-only test cannot catch a surface
  that admits correctly and then discards the result, which is what happened
  here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* docs: name the pre-push gate, and the formatter's path allowlist

Both misses in this PR's review were process, not judgement, and the docs
pointed the wrong way for both.

AGENTS.md said "prefer the aggregate package.json scripts" without naming which
aggregate, and CONTRIBUTING listed `pnpm test` and the targeted checks but never
`pnpm check`. `check:tooling` looks like the gate and is a subset of it: it stops
before the Fallow audit, so the dead exports this PR introduced passed a clean
`check:tooling` and failed CI. Both files now name `pnpm check`, say what it
covers, and say what it cannot (the device matrix).

The same gap produced a second mistake twice: `oxfmt <path>` reformats whatever
you point it at, while the repo's `format` script is an allowlist that excludes
`scripts/` and every `.md`. One run reformatted 50 unrelated script files into a
commit; the next nearly did it to AGENTS.md. AGENTS.md now says to run
`pnpm format`, never `oxfmt <path>`.

It also records the rule that cost a CI cycle: Fallow's baselines are keyed by
path, so a renamed file needs its baseline entry moved, not the baselines
regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* revert: undo stray formatter output across docs and scripts

Three separate `oxfmt <path>` runs in this branch reformatted files the repo's
`format` script deliberately excludes: 55 files under scripts/maestro-conformance
plus scripts/perf, sync-mcp-metadata and the slow-test reporter, and 12 markdown
files including six ADRs and docs/agents/. All of it was whitespace, quote style
and markdown table padding — no content — but it inflated the diff a reviewer has
to read and would have rewritten prose ownership across files this change has no
business touching.

All 70 are back to their origin/main content, so the diff outside src/ is now
exactly this change's scope: three docs, scripts/layering, the Fallow baseline,
and five provider integration tests.

The rule this violated is now in AGENTS.md: run `pnpm format`, never
`oxfmt <path>`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* style: reformat two provider tests with the repo's pinned oxfmt

`pnpm format:check` failed in CI on the two files whose imports I merged by hand.
The repo pins oxfmt 0.42.0 as a devDependency and both `format` scripts invoke
`./node_modules/oxfmt/bin/oxfmt`; I had reformatted with `npx oxfmt`, which
resolved 0.60.0, and the two versions disagree about wrapping a 100-column import.

This is the rule AGENTS.md already states — run `pnpm format`, never oxfmt
directly — so there is nothing to add to the docs, only to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* fix(ci): install deps for the layering guard, and gate the zero-dep contract

The Layering Guard job failed with ERR_MODULE_NOT_FOUND on `oxc-parser`. The job
ran with `install-deps: false` — no `pnpm install`, so no `node_modules` — and R7
had started parsing the daemon with oxc-parser instead of matching assignment
operators with a regex. `pnpm check:layering` passed on every local run, because
locally `node_modules` is always there.

The job now installs dependencies. The alternative was to put R7 back on a regex,
which cannot see `??=` or a computed `session[key] =` write, so it would trade a
correct rule for a fast job.

That leaves the interesting part: the zero-dep contract is real for the jobs that
keep it, and it is invisible to every local run, which is the worst combination a
constraint can have. R8 makes it checkable. It reads the zero-dep job list out of
`.github/workflows/` rather than restating it — declaring a job zero-dep is what
puts it under the rule — walks each job's entry scripts and their whole
relative-import closure, and requires every specifier to be a Node builtin or
another repo file. A zero-dep job whose entry scripts the scan cannot identify
fails too, so the rule cannot be escaped by changing how the job invokes them.

Specifiers come from oxc-parser's module record, not a line scan. The closures
include `--test` files, and a test about imports naturally embeds import syntax in
a fixture string; the line scanner reported two such phantom violations in
model.test.ts before the switch, which is how a gate stops being trusted.

Verified by re-running the real gate against three injected regressions: the
layering job back on `install-deps: false` (reproduces the exact CI failure,
pointing at session-state.ts:24), a package import added to the still-zero-dep
affected-selector closure, and a zero-dep job whose run step names no script.

Also corrects the CONTEXT.md spine paragraph, which still described the satellite
zones as deliberately unranked after they had all joined the ranked spine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* fix(layering): make R7 exhaustive, and follow session records through aliases

Review finding: `SESSION_STATE_FIELD_OWNERS` covered 27 of `SessionState`'s 42
fields and nothing asserted parity, so a new field could be added and pass the
gate by being invisible to it. R7's advertised claim — "every SessionState write
is inside its declared owner" — was broader than what it checked.

Investigating that turned up a second, larger gap the finding did not name: the
scan only recognized a binding literally named `session`. The daemon names these
records by role, so `nextSession`, `provisionalSession`, `completedSession`,
`preRunSession` and `preEntrySession` were all invisible — and three of those
writes were genuine violations R7 existed to catch:

  src/daemon/snapshot-runtime.ts:256  nextSession.snapshotScopeSource
  src/daemon/snapshot-runtime.ts:265  nextSession.snapshotGeneration
  src/daemon/handlers/session-replay-runtime.ts:707
                                      preEntrySession.pendingRecordAndHeal

The first two are the #1076 versioned-ref invariant: the generation advances
exactly when the stored tree is replaced. That rule lived in `setSessionSnapshot`
and had acquired a second statement of itself in snapshot-runtime.ts, whose own
comment admitted the bypass. It now goes through `setSnapshotLineage` in the
owning module. The third clears a watermark stamped by session-replay-resume.ts;
`clearPendingRecordAndHealWatermark` puts the clear beside the stamp.

Gate changes:
- Binding detection accepts aliases, paired with the existing declared-field
  filter so an unrelated `…Session` local only registers if it also writes a
  field SessionState owns — where the remedy is the same anyway.
- `fieldClassificationDrift` asserts parity in all three directions:
  unclassified, in-both, and naming a field SessionState no longer declares.
- `STORE_OWNED_SESSION_STATE_FIELDS` classifies the 11 fields the store
  establishes at construction. It is a positive claim, so a direct write to one
  fails and names both remedies.
- Four fields the widened scan made visible (`lease`, `deviceClaim`, `appName`,
  `saveScriptComplete`) got real owners.

`nextSnapshotGeneration` is now module-private: replacing its only external call
site orphaned the export, which `pnpm check` caught via Fallow.

Verified against three injected regressions: a new SessionState field with no
direct write (the reviewer's exact scenario), a foreign write through an alias
binding, and a direct write to a store-established field. All three rejected.
`pnpm check` green, 4486 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* docs(daemon): correct the snapshot-lineage claim, and pin the real contract

Device verification of the snapshot-lineage route found that a ref pinned before
a `diff` keeps resolving with no pinned-ref warning. That is the designed ADR
0014 behaviour, not a regression — the comment describing it was wrong, and I
propagated it.

`main`'s comment in snapshot-runtime.ts said a diff "leaves client refs pinned to
the previous generation, which is exactly what the pinned warning diagnoses". The
counter and the authorization epoch are different clocks:

  - `diff` passes `issuesRefsToClient: false`, so it never reactivates the frame;
  - `resolveRefStalenessWarning` compares a pin against the frame EPOCH, not the
    observation counter, and its own comment says why — a capture that bumped the
    counter must not make a valid pin from the issuing frame look stale.

So advancing the counter is not the same as invalidating client refs, and the
observable the comment promised does not exist. I carried the sentence into
`setSnapshotLineage`'s doc when the transition moved, and then into a hardware
verification request, which cost a reviewer a device run against a false claim.

`setSnapshotLineage` itself is unchanged and was a pure move: same expressions,
same inputs as the inline assignments it replaced, so this route behaves exactly
as it does on main.

A comment that contradicts the code should be an assertion instead, so the
contract is now pinned in session-snapshot.test.ts: the diff advances the counter,
preserves the epoch, leaves the pre-diff pin resolving without a warning, and
still warns for a pin from a different frame. Verified to fail when the epoch
comparison is swapped for the counter. A second test covers the keep-current
branch, which had no coverage.

`pnpm check` green, 4488 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 08:08:34 +02:00

485 lines
19 KiB
TypeScript

// Import-direction lint — enforces the folder DAG established by the Phase-5
// folder moves (see CONTEXT.md, "Architecture: folder DAG + layering lint").
//
// Ranked target spine, as rank groups lowest (kernel sink) to highest. `A ◄ B` means B may not
// be outranked by A (the back-edge order the gate rejects), NOT that every displayed import exists:
// kernel ◄ { contracts, request, selectors, platforms } ◄ core ◄ { commands, cli-schema }
// ◄ { client, daemon-server } ◄ daemon-client ◄ cli
// (authoritative ranks: `TARGET_DAG_RANK` in model.ts)
//
// This gate enforces five things, across four scopes:
// - GLOBALLY, across every production source file: the R1-R3 move rules and
// rejection of all production static value-import cycles (R4).
// - Over the RANKED SPINE only: rejection of every spine back-edge (R5), i.e.
// an import whose source zone outranks its target zone, plus a ratchet on the
// same inversion measured over TYPE-ONLY edges (R6).
// - Over the DAEMON only: SessionState field ownership (R7), because the session
// record is store-owned mutable state that any daemon module can write.
// - Over the ZERO-DEP CI JOBS only: their scripts may import nothing that needs
// installing (R8), a constraint no local run can feel because `node_modules` is
// always there locally and never there in those jobs.
// Only `(root)` is unranked (see `UNRANKED_ZONES` in model.ts): it holds the
// entrypoints and the composition roots that wire the command surface into the
// daemon, which R2 forbids the daemon from importing, so they sit outside the
// spine by construction. Every other zone is ranked.
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
fieldClassificationDrift,
findSessionStateWrites,
sessionStateFields,
SESSION_STATE_FIELD_OWNERS,
STORE_OWNED_SESSION_STATE_FIELDS,
} from './session-state.ts';
import { uninstallableImports, zeroDepJobs } from './zero-dep-jobs.ts';
import {
backEdgePair,
findValueImportCycles,
resolveImportEdges,
topFolder,
typeInversionPair,
type ImportEdge,
type ResolvedImportEdge,
} from './model.ts';
type EdgeContext = {
file: string;
fromTop: string;
toTop: string;
imp: ImportEdge;
};
type Violation = {
rule: string;
file: string;
line: number;
message: string;
};
const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
encoding: 'utf8',
}).trim();
export function listSourceFiles(): string[] {
// `src/**/*.ts` only matches nested files; root-level `src/*.ts` (e.g.
// src/cli.ts, src/command-catalog.ts) needs its own pathspec or it silently
// drops out of cycle/back-edge analysis.
const out = execFileSync('git', ['ls-files', 'src/*.ts', 'src/**/*.ts'], {
cwd: repoRoot,
encoding: 'utf8',
});
return out.split('\n').filter(Boolean).filter(isProductionSourceFile);
}
function readSources(files: readonly string[]): Map<string, string> {
return new Map(files.map((file) => [file, fs.readFileSync(path.join(repoRoot, file), 'utf8')]));
}
function fileExists(file: string): boolean {
return fs.existsSync(path.join(repoRoot, file)) && fs.statSync(path.join(repoRoot, file)).isFile();
}
function readSourceOrNull(file: string): string | null {
return fileExists(file) ? fs.readFileSync(path.join(repoRoot, file), 'utf8') : null;
}
function isProductionSourceFile(file: string): boolean {
return file.endsWith('.ts') && !/(?:^|\/)__tests__\//.test(file) && !/\.test\.ts$/.test(file);
}
function isCoreInteractor(file: string): boolean {
return file.startsWith('src/core/interactors/');
}
function isDaemonServer(file: string): boolean {
return file.startsWith('src/daemon/') && !file.startsWith('src/daemon/client/');
}
function isSdkBarrel(file: string): boolean {
return file.startsWith('src/sdk/');
}
function ruleKernelSink(ctx: EdgeContext): Violation | null {
if (ctx.fromTop !== 'kernel') return null;
if (ctx.toTop === 'contracts' && ctx.imp.typeOnly) return null;
return {
rule: 'R1 kernel-sink',
file: ctx.file,
line: ctx.imp.line,
message:
`kernel must not import ${ctx.toTop}/ (imports '${ctx.imp.spec}'). ` +
`The only allowed kernel out-edge is a type-only re-export from contracts/.`,
};
}
const BELOW_COMMANDS = new Set(['kernel', 'platforms', 'core', 'daemon']);
function ruleCommandsFloor(ctx: EdgeContext): Violation | null {
if (ctx.toTop !== 'commands' || !BELOW_COMMANDS.has(ctx.fromTop)) return null;
return {
rule: 'R2 commands-floor',
file: ctx.file,
line: ctx.imp.line,
message:
`${ctx.fromTop}/ must not import the command surface commands/ ` +
`(imports '${ctx.imp.spec}'). Depend on shared kernel/contracts instead.`,
};
}
function rulePlatformsSeam(ctx: EdgeContext): Violation | null {
if (ctx.toTop !== 'platforms' || ctx.imp.dynamic || ctx.imp.typeOnly) return null;
if (isCoreInteractor(ctx.file) || isDaemonServer(ctx.file) || isSdkBarrel(ctx.file)) return null;
return {
rule: 'R3 platforms-seam',
file: ctx.file,
line: ctx.imp.line,
message:
`static value import of platforms/ from ${ctx.fromTop}/ (imports '${ctx.imp.spec}'). ` +
`Only src/core/interactors/ and the daemon server may statically import platforms/; ` +
`elsewhere use a dynamic import() or a type-only import to preserve CLI cold-start.`,
};
}
const RULES = [ruleKernelSink, ruleCommandsFloor, rulePlatformsSeam];
function checkLayeringRules(edges: readonly ResolvedImportEdge[]): Violation[] {
const violations: Violation[] = [];
for (const edge of edges) {
const fromTop = topFolder(edge.file);
const toTop = topFolder(edge.target);
if (fromTop === toTop) continue;
const ctx: EdgeContext = { file: edge.file, fromTop, toTop, imp: edge };
for (const rule of RULES) {
const violation = rule(ctx);
if (violation) violations.push(violation);
}
}
return violations;
}
function checkCycles(edges: readonly ResolvedImportEdge[]): Violation[] {
return findValueImportCycles(edges).map((cycle) => ({
rule: 'R4 value-import-cycle',
file: cycle[0]!,
line: 1,
message: `production value-import cycle: ${cycle.join(' -> ')}`,
}));
}
function checkBackEdges(edges: readonly ResolvedImportEdge[]): Violation[] {
const seen = new Set<string>();
return edges.flatMap((edge) => {
const pair = backEdgePair(edge);
const identity = `${edge.file} -> ${edge.target}`;
if (!pair || seen.has(identity)) return [];
seen.add(identity);
return [
{
rule: 'R5 zero-back-edges',
file: edge.file,
line: edge.line,
message: `${pair} back-edge: ${identity}. Move the shared contract below both owners.`,
},
];
});
}
// R6 ratchet: type-only spine inversions, per zone pair. R5 cannot see these (a
// type-only import is free at runtime), but "zone A is declared in terms of zone B"
// is still a boundary claim, and ranking type edges surfaced 61 of them. Two clusters
// remain, each needing its own change rather than a file move:
//
// commands/contracts/mcp the per-command Options/Result vocabulary, plus the client
// -> client facade itself, are declared inside the 1.2k-line public
// Node-client surface (client/client-types.ts) instead of in
// contracts/, where the 24 types it already re-exports live.
// core/commands -> daemon-server the ADR 0003 daemon facet: core's descriptor registry
// composes a shape the daemon owns. The daemon keeps the
// VALUES; only the shape needs to move below core.
// replay -> daemon-server `SessionAction`, the recorded-action shape replay reads and
// writes. It belongs in contracts/, but it references
// `CommandFlags` (core) which references `DaemonBatchStep`
// (core), so it moves as a chain of three, not one file.
//
// The counts may only go DOWN. Fixing edges without lowering the number fails too, so the
// baseline cannot quietly stop describing the tree.
const TYPE_INVERSION_BASELINE: Readonly<Record<string, number>> = {
'commands -> client': 28,
'commands -> daemon-server': 1,
'contracts -> client': 1,
'core -> daemon-server': 5,
'mcp -> client': 1,
'replay -> daemon-server': 6,
};
function checkTypeInversions(edges: readonly ResolvedImportEdge[]): Violation[] {
const seen = new Set<string>();
const countsByPair = new Map<string, number>();
const firstEdgeByPair = new Map<string, ResolvedImportEdge>();
for (const edge of edges) {
const pair = typeInversionPair(edge);
if (!pair) continue;
const identity = `${edge.file} -> ${edge.target}`;
if (seen.has(identity)) continue;
seen.add(identity);
countsByPair.set(pair, (countsByPair.get(pair) ?? 0) + 1);
if (!firstEdgeByPair.has(pair)) firstEdgeByPair.set(pair, edge);
}
const violations: Violation[] = [];
for (const [pair, count] of [...countsByPair].sort(([left], [right]) =>
left.localeCompare(right),
)) {
const allowed = TYPE_INVERSION_BASELINE[pair];
const edge = firstEdgeByPair.get(pair)!;
if (allowed === undefined) {
violations.push({
rule: 'R6 type-spine-inversion',
file: edge.file,
line: edge.line,
message:
`new type-only ${pair} inversion (${count} edge(s), e.g. ${edge.file} -> ${edge.target}). ` +
`Declare the shared type below both zones instead of adding it to TYPE_INVERSION_BASELINE.`,
});
continue;
}
if (count > allowed) {
violations.push({
rule: 'R6 type-spine-inversion',
file: edge.file,
line: edge.line,
message:
`type-only ${pair} inversions grew to ${count} (baseline ${allowed}). ` +
`Move the shared type below both zones; the baseline may only shrink.`,
});
}
}
for (const [pair, allowed] of Object.entries(TYPE_INVERSION_BASELINE)) {
const count = countsByPair.get(pair) ?? 0;
if (count >= allowed) continue;
const message =
count === 0
? `type-only ${pair} inversions are all gone — delete this entry from TYPE_INVERSION_BASELINE.`
: `type-only ${pair} inversions dropped to ${count} — lower TYPE_INVERSION_BASELINE to ${count}.`;
violations.push({
rule: 'R6 type-spine-inversion',
file: 'scripts/layering/check.ts',
line: 1,
message,
});
}
return violations;
}
function checkSessionStateOwnership(sources: ReadonlyMap<string, string>): Violation[] {
const types = sources.get('src/daemon/types.ts');
if (!types) {
return [
{
rule: 'R7 session-state-ownership',
file: 'src/daemon/types.ts',
line: 1,
message: 'daemon/types.ts is missing, so SessionState ownership cannot be checked.',
},
];
}
const fields = sessionStateFields(types);
const writes = findSessionStateWrites(sources, fields);
const violations: Violation[] = [];
const seenOwners = new Map<string, Set<string>>();
// Parity first: the rule is only exhaustive if every declared field is classified. A field
// that is in neither table would otherwise pass by being invisible to the scan, and R7 would
// quietly stop covering part of the type it claims to cover.
const DRIFT_MESSAGE: Readonly<Record<string, string>> = {
unclassified:
'is declared by SessionState but classified nowhere. Name its owning module in ' +
'SESSION_STATE_FIELD_OWNERS, or — if the store establishes it at construction and nothing ' +
'mutates it later — add it to STORE_OWNED_SESSION_STATE_FIELDS.',
both:
'is in both SESSION_STATE_FIELD_OWNERS and STORE_OWNED_SESSION_STATE_FIELDS. A field is ' +
'either store-established or owned by a writer, not both.',
'not-a-field':
'is classified but is no longer declared by SessionState — remove it from the table it ' +
'still appears in.',
};
for (const { field, problem } of fieldClassificationDrift(fields)) {
violations.push({
rule: 'R7 session-state-ownership',
file: 'scripts/layering/session-state.ts',
line: 1,
message: `session.${field} ${DRIFT_MESSAGE[problem]}`,
});
}
for (const write of writes) {
const owners = SESSION_STATE_FIELD_OWNERS[write.field];
const seen = seenOwners.get(write.field) ?? new Set<string>();
seen.add(write.file);
seenOwners.set(write.field, seen);
if (write.field === '[computed]') {
violations.push({
rule: 'R7 session-state-ownership',
file: write.file,
line: write.line,
message:
'computed write to a session field (`session[key] = …`). The field cannot be ' +
'attributed to an owner, so write the field by name, or move the write into the ' +
'module that owns the fields it can reach.',
});
continue;
}
if (owners === undefined) {
const storeOwned = STORE_OWNED_SESSION_STATE_FIELDS.has(write.field);
violations.push({
rule: 'R7 session-state-ownership',
file: write.file,
line: write.line,
message: storeOwned
? `session.${write.field} is classified store-established ` +
`(STORE_OWNED_SESSION_STATE_FIELDS), meaning nothing mutates it after construction — ` +
`but this is a direct write. Route it through the store, or move the field into ` +
`SESSION_STATE_FIELD_OWNERS with this module as its owner.`
: `session.${write.field} has no declared owner. SessionStore hands out the live ` +
`record, so this write is durable: name the owning module in ` +
`SESSION_STATE_FIELD_OWNERS (scripts/layering/session-state.ts).`,
});
continue;
}
if (!owners.includes(write.file)) {
violations.push({
rule: 'R7 session-state-ownership',
file: write.file,
line: write.line,
message:
`session.${write.field} is owned by ${owners.join(', ')}. Call the owner instead of ` +
`writing the field here, so whatever invariant it carries stays in one place.`,
});
}
}
// An owner that no longer writes its field is stale documentation; drop it so the table
// keeps describing the tree rather than a past version of it.
for (const [field, owners] of Object.entries(SESSION_STATE_FIELD_OWNERS)) {
const actual = seenOwners.get(field) ?? new Set<string>();
const stale = owners.filter((owner) => !actual.has(owner)).sort();
if (stale.length === 0) continue;
violations.push({
rule: 'R7 session-state-ownership',
file: 'scripts/layering/session-state.ts',
line: 1,
message:
`session.${field} is no longer written by ${stale.join(', ')} — remove ` +
`${stale.length === owners.length ? 'the entry' : 'those owners'} from ` +
`SESSION_STATE_FIELD_OWNERS.`,
});
}
return violations;
}
// R8: a CI job that runs with `install-deps: false` has no `node_modules`, so every script it
// reaches must import only Node builtins and other repo files. Locally the opposite is true —
// `node_modules` is always present — which is why this needs a gate rather than a convention.
function checkZeroDepJobs(): Violation[] {
const workflows = readSources(
execFileSync('git', ['ls-files', '.github/workflows/*.yml'], { cwd: repoRoot, encoding: 'utf8' })
.split('\n')
.filter(Boolean),
);
const violations: Violation[] = [];
for (const job of zeroDepJobs(workflows, fileExists)) {
// Fail closed: a zero-dep job whose commands the entry scan cannot recognize would
// otherwise be silently exempt from the rule it is the whole reason for.
if (job.entries.length === 0) {
violations.push({
rule: 'R8 zero-dep-job-closure',
file: job.workflow,
line: 1,
message:
`job '${job.job}' runs with install-deps: false but no entry script was found in its ` +
`run steps, so its import closure cannot be checked. Invoke the script by path, or ` +
`let the job install dependencies.`,
});
continue;
}
for (const bare of uninstallableImports(job, readSourceOrNull, fileExists)) {
violations.push({
rule: 'R8 zero-dep-job-closure',
file: bare.file,
line: bare.line,
message:
`'${bare.spec}' is a package, and job '${bare.job}' (${bare.workflow}) runs with ` +
`install-deps: false — nothing installs it, so this resolves locally and fails in CI. ` +
`Use a Node builtin, inline what you need, or drop install-deps: false from the job.`,
});
}
}
return violations;
}
function sessionStateFieldCount(): number {
return (
Object.keys(SESSION_STATE_FIELD_OWNERS).length + STORE_OWNED_SESSION_STATE_FIELDS.size
);
}
function report(files: readonly string[], violations: readonly Violation[]): number {
if (violations.length === 0) {
process.stdout.write(
`Layering guard: OK — ${files.length} source files satisfy R1-R3 and contain no ` +
`value-import cycles (both checked globally); the ranked target spine contains no ` +
`back-edges (only the composition root is unranked), and its type-only ` +
`inversions match the R6 ratchet (${Object.values(TYPE_INVERSION_BASELINE).reduce((sum, count) => sum + count, 0)} remaining); ` +
`all ${sessionStateFieldCount()} SessionState fields are classified and every write is ` +
`inside its declared owner (R7); and every zero-dep CI job resolves without ` +
`node_modules (R8).\n`,
);
return 0;
}
const byRule = new Map<string, Violation[]>();
for (const violation of violations) {
const group = byRule.get(violation.rule) ?? [];
group.push(violation);
byRule.set(violation.rule, group);
}
process.stderr.write(`Layering guard: ${violations.length} violation(s)\n\n`);
for (const [rule, group] of byRule) {
process.stderr.write(` [${rule}] ${group.length} violation(s):\n`);
for (const violation of group) {
process.stderr.write(` ${violation.file}:${violation.line}${violation.message}\n`);
process.stderr.write(
`::error file=${violation.file},line=${violation.line},title=Layering drift (${violation.rule})::${violation.message}\n`,
);
}
process.stderr.write('\n');
}
return 1;
}
export function main(): number {
const sourceFiles = listSourceFiles();
const sources = readSources(sourceFiles);
const edges = resolveImportEdges(sources);
const violations = [
...checkLayeringRules(edges),
...checkCycles(edges),
...checkBackEdges(edges),
...checkTypeInversions(edges),
...checkSessionStateOwnership(sources),
...checkZeroDepJobs(),
];
return report(sourceFiles, violations);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
process.exit(main());
}