mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
cc521decb8
* test(structure): per-package eager-closure budgets (#1960) ADR-0019 requires platform-package façades to stay implementation-lazy and is explicit that a startup threshold alone is not a substitute for preserving the loading shape. #1950 built the AST-level walker (eager-import-closure.fixtures.ts) and proved the planted-red procedure on one file (session-teardown.ts's android-helper denylist); this generalizes it into a data-driven budget table so any workspace-package façade -- or a designated hub module -- can get an eager-closure ceiling without a bespoke test. Seeds a budget for every packages/*/src/facades/*.ts file (discovered the same way package-boundaries.test.ts discovers façades, not hand-listed) from its measured current closure size, plus a platform-implementation denylist for façades whose contract is implementation-neutral vocabulary. Also demonstrates the mechanism on two designated hub modules (cli.ts, session-teardown.ts) alongside their existing, more specific ad hoc pins. Closes #1960 * test(structure): derive facade roots from manifests, add edge chains, reseed tight Review findings on #1965: 1. Discovery scanned only `packages/*/src/facades/*.ts`, which omits every package that publishes its entry surface straight from the manifest — including all six `packages/platform-*/src/index.ts` façades, the exact subject of ADR-0019's implementation-laziness rule. Discovery now derives from `readWorkspacePackages(...).exportTargets` and then adds `/src/facades/` files, reusing the R11 helper rather than reimplementing it so the two gates cannot disagree about what an entry surface is. The table grows from 14 façades + 2 hubs to 95 entry surfaces + 8 hubs. 2. Budgets carried a few files of slack each. They are now exact ratchets with no headroom, matching how the repo pins R9/R10 and test-file size: growth is allowed, it just has to be a visible number change in the diff of the PR that causes it. Every budget is reseeded from post-#1969 measurement. 3. Violations printed a flat sorted set, which named the offender but not the route. `eagerClosureGraphOf` records each file's discoverer, so failures now print the transitive chain entry -> ... -> offender. `eagerClosureOf` keeps its contract and is expressed in terms of the new walk; per-file edges are memoized, which also cuts the existing pins' runtime (cli closure test 2336ms -> ~550ms). The platform-package façades evaluate exactly one module each — themselves — so their budget of 1 is the tightest statement of "metadata-eager, implementation-lazy" the walker can make. * test(structure): make the eager-closure pins exact, bounded, and single-owner Second review pass on #1965 found four holes, two of which were places the PR text claimed a property the code did not have. 1. Rows were documented as exact ratchets but asserted with `<=`, so a shrink silently became headroom a later regression could grow back into. The comparison is now equality, in a pure `classifyBudget` with a separate message for each direction ("lower its pin to N in this PR so the ratchet keeps the gain"), matching test-file-size-ratchet.ts and the R9/R10 pins. 2. An over-pin failure printed a chain per evaluated module — 361 of them for src/cli.ts. It now prints a bounded attribution: the entry's heaviest direct edges (capped at 4) with a couple of representative deep routes each, ranked so a newly added import sorts first. The comment states plainly that this attributes by shortest import route and does NOT diff against a recorded baseline; naming a true delta would mean checking in ~1,500 module paths and rewriting them on every contracts refactor. 3. Discovery reimplemented a one-level `src/facades` scan while canonical R11 discovery is recursive, so a nested façade file could be covered by R11 and silently missing here. `facadeEntryFiles` is now a single exported owner in package-boundaries.ts that both R11's façade gate and this table consume. 4. Rows were converted to Sets before any uniqueness check, so a duplicate was unobservable. The table is now two `Record<string, number>` literals keyed by path, making an in-record duplicate a TypeScript error (ts1117); the only remaining case — one path in both records — is asserted on the array. Each of the four holes gets a test that fails when the rule is broken, since a tree that happens to satisfy its pins cannot distinguish a correct rule from a vacuous one. Writing those found a real bug in the duplicate check itself (`Set.add` returns the Set, so the filter never matched). Pins reseeded on04e4c23b9. * test(structure): restore tracked-only layering discovery and bound platform diagnostics Two findings from the third #1965 review. 1. R11 REGRESSION, introduced by the previous commit in this PR. Extracting one recursive facade-discovery owner was right, but it was implemented as a raw filesystem walk, which silently changed R11 from tracked-source discovery (listSourceFiles) to "whatever is on disk". A layering gate describes committed state, so an uncommitted scratch facade under a scanned path must be invisible; instead it could fail both R11 and the budget gate on a contributor's working directory. Worse, the test asserted the new behaviour, pinning the regression in place. The tracked-source enumerator now lives in a leaf module, scripts/layering/tracked-sources.ts, that both check.ts and package-boundaries.ts consume (check.ts imports package-boundaries.ts, so the dependency cannot run the other way). facadeEntryFiles reads listTrackedProductionSources, keeping the single recursive owner while restoring tracked-only scope. The temp-dir test is replaced by a real git fixture following platform-package-repository.test.ts: a tracked nested facade that must be discovered, plus untracked siblings — one beside it and one beside the nested file — that must not be. Reverting to the filesystem walk turns it red. 2. Only the numeric-pressure diagnostic was bounded; the platform-implementation assertion still emitted every offender with a full chain, so one eager platform subtree buried the single import that caused it under hundreds of lines. Both diagnostics now share one renderer that groups targets by the entry's own import they arrived through, caps edges and routes, and states the omitted counts. A wide planted-red fixture (300 offenders under one owning edge plus extra edges) proves the cap engages rather than assuming it. src/cli.ts reseeded 361 -> 362 for drift from the rebase onto7aaa559e2. * test(structure): close the manifest half of tracked-only discovery The previous commit intersected the `src/facades/` walk with tracked sources but left manifest-derived entry surfaces bypassing that intersection entirely — the same bug class, on the path it did not close. Two distinct leaks: 1. `readWorkspacePackages` enumerated `packages/` with `readdirSync`, so an uncommitted scratch package contributed a name, export targets, and dependency edges to every R11 rule built on it, not just to facade discovery. Closed at the source: it now reads tracked manifests via `listTrackedPackageManifests`, mirroring how R13's `readTrackedPlatformPackageDeclarations` has always enumerated its own. 2. A TRACKED manifest can still name an uncommitted target in its working-tree content, which the trailing `existsSync` admitted. `facadeEntryFiles` now intersects manifest-derived targets with the tracked production set as well, so every path it returns is committed whatever its origin. Three fixtures, each verified failing before the fix and passing after: - `readWorkspacePackages reads tracked manifests only` (in R11's own suite, since leak 1 affects every rule that reads packages, not only this gate) — red when manifest enumeration reverts to `readdirSync`. - `an untracked PACKAGE contributes no entry surface` — red when both fixes are reverted. - `a dirty manifest naming an UNTRACKED target contributes no entry surface` — red when the target intersection is dropped. The shared git fixture builder is factored out of the existing discovery test so all four use one committed baseline. Pins reseeded on8d280a208: six new contracts entry surfaces from #1993, which the exhaustiveness gate caught on its own, plus dispatch 100 -> 94 and platform-runtime 31 -> 36.
599 lines
26 KiB
TypeScript
599 lines
26 KiB
TypeScript
// Import-direction lint — enforces the folder DAG established by the Phase-5
|
|
// folder moves. The policies and their tests below are the source of truth.
|
|
//
|
|
// Ranked target spine, as rank groups lowest 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:
|
|
// { contracts, request, selectors, platforms } ◄ core ◄ { commands, cli-schema }
|
|
// ◄ { client, daemon-server } ◄ daemon-client ◄ cli
|
|
// (authoritative ranks: `TARGET_DAG_RANK` in model.ts. The former rank-0 kernel
|
|
// zone lives in packages/kernel since #1490 W0; R11 owns its boundary.)
|
|
//
|
|
// This gate enforces five things, across four scopes:
|
|
// - GLOBALLY, across every production source file: the R2-R3 move rules and
|
|
// rejection of all production static value-import cycles (R4). R1 kernel-sink
|
|
// retired with the kernel's move to packages/kernel (#1490 W0); R8
|
|
// zero-dep-job-closure retired with the last `install-deps: false` job
|
|
// (#1781 A6) — its invariant has no subjects, and both numbers are spent, so
|
|
// a new rule takes the next free id rather than reusing them.
|
|
// - 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 TYPE GRAPH: the largest type-level import cycle is pinned by
|
|
// equality (R9). R4 keeps the value graph acyclic, so these cycles are free at
|
|
// runtime but bound what can be read in isolation; growth fails, and so does a
|
|
// baseline left above the measured size.
|
|
// - Across the DAEMON MODULARITY MIGRATION: R7 ownership pressure and external
|
|
// daemon/types.ts importers only shrink, R9 zone membership cannot grow or absorb
|
|
// engine files, and planned logical modules start with zero forbidden/internal imports (R10).
|
|
// - Over the WORKSPACE PACKAGES: no root back-imports, no relative tunnelling past
|
|
// an exports map, and every workspace specifier declared + exports-named (R11).
|
|
// - Over BIN.TS'S ALIAS RESOLUTION: it must delegate to the one alias registry instead of
|
|
// re-declaring a parallel mapping of its own (R12) — the same "delegate to your single
|
|
// owner" shape as R7's SessionState ownership, applied to bin.ts's `--help` fast path.
|
|
// - Over PLATFORM PACKAGE COMPOSITION: six private metadata façades meet at the exact root
|
|
// composition file; premature implementation loading and forbidden cross-boundary edges fail (R13).
|
|
// - Over COMMAND-ATOMIC RUNTIME CUTOVERS: one parametrized gate reads the migrated-command
|
|
// table (appstate R22, shutdown R23, boot R20, apps R21, install/deploy R24-R27,
|
|
// lifecycle R28-R31, devices R17, logs R14, network R15, record R16, snapshot R32, diff R33,
|
|
// viewport R34, get R36, is R37 — R35 reserved for find) and proves each command keeps
|
|
// exactly one platform-execution path — retired routes, admission, modules, and widened
|
|
// runtime access cannot coexist with its operation-fact-derived descriptor and handler.
|
|
// - Over CONTRACTS PRODUCTION SOURCE: contracts owns vocabulary only — host, process, and timer
|
|
// mechanics belong in capture-kit or an adapter (R18).
|
|
// Only `(root)` is unranked among src/ zones (see `UNRANKED_ZONES` in model.ts):
|
|
// it holds entrypoints and composition roots. Extracted workspace package zones
|
|
// are classified separately and held behind R11 instead of the src folder spine.
|
|
|
|
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,
|
|
sessionStateFieldCount,
|
|
SESSION_STATE_FIELD_OWNERS,
|
|
STORE_OWNED_SESSION_STATE_FIELDS,
|
|
} from './session-state.ts';
|
|
import {
|
|
ALIAS_REGISTRY_FILE,
|
|
aliasResolverLocalName,
|
|
BIN_FILE,
|
|
localAliasLiterals,
|
|
registryAliasTokens,
|
|
usageTextDelegationFailure,
|
|
} from './bin-alias-fast-path.ts';
|
|
import {
|
|
backEdgePair,
|
|
findValueImportCycles,
|
|
largestTypeCycleMembers,
|
|
resolveImportEdges,
|
|
topFolder,
|
|
typeInversionPair,
|
|
type LayeringViolation,
|
|
type ResolvedImportEdge,
|
|
} from './model.ts';
|
|
import { checkDaemonModularityRatchets, daemonModularitySummary } from './daemon-modularity.ts';
|
|
import {
|
|
checkPackageBoundaries,
|
|
packageBoundariesSummary,
|
|
workspaceSpecifierTargets,
|
|
} from './package-boundaries.ts';
|
|
import {
|
|
checkPlatformPackagePolicy,
|
|
platformPackagePolicySummary,
|
|
} from './platform-package-policy.ts';
|
|
import {
|
|
checkRuntimeCommandCutover,
|
|
runtimeCommandCutoverSummary,
|
|
} from './runtime-command-cutover-policy.ts';
|
|
import {
|
|
listUntrackedProductionTypeScriptFiles,
|
|
readTrackedPlatformPackageDeclarations,
|
|
} from './platform-package-repository.ts';
|
|
import { policyLead, policyViolation, ZONE_POLICIES } from './zone-policy.ts';
|
|
import { contractsImplementationAuthorityViolations } from './contracts-implementation-policy.ts';
|
|
import { selectorPipelineOwnershipViolations } from './selector-pipeline-ownership.ts';
|
|
import { recordRuntimeRegistryJoinViolations } from './record-runtime-registry-policy.ts';
|
|
import { listTrackedProductionSources, listTrackedTypeScriptFiles } from './tracked-sources.ts';
|
|
|
|
const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
encoding: 'utf8',
|
|
}).trim();
|
|
|
|
export function listTypeScriptFiles(): string[] {
|
|
return listTrackedTypeScriptFiles(repoRoot);
|
|
}
|
|
|
|
export function listSourceFiles(): string[] {
|
|
return listTrackedProductionSources(repoRoot);
|
|
}
|
|
|
|
function readSources(files: readonly string[]): Map<string, string> {
|
|
return new Map(files.map((file) => [file, fs.readFileSync(path.join(repoRoot, file), 'utf8')]));
|
|
}
|
|
|
|
// R1-R3 are declared as a policy table in zone-policy.ts. This walks it; the boundaries
|
|
// themselves are data, so adding one is a table entry rather than a fourth predicate.
|
|
function checkLayeringRules(edges: readonly ResolvedImportEdge[]): LayeringViolation[] {
|
|
const violations: LayeringViolation[] = [];
|
|
for (const edge of edges) {
|
|
const fromZone = topFolder(edge.file);
|
|
const toZone = topFolder(edge.target);
|
|
if (fromZone === toZone) continue;
|
|
const ctx = { file: edge.file, fromZone, toZone, imp: edge };
|
|
for (const policy of ZONE_POLICIES) {
|
|
const hint = policyViolation(policy, ctx);
|
|
if (hint === null) continue;
|
|
violations.push({
|
|
rule: policy.rule,
|
|
file: edge.file,
|
|
line: edge.line,
|
|
message: `${policyLead(ctx)} ${hint}`,
|
|
});
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
function checkCycles(edges: readonly ResolvedImportEdge[]): LayeringViolation[] {
|
|
return findValueImportCycles(edges).map((cycle) => ({
|
|
rule: 'R4 value-import-cycle',
|
|
file: cycle[0]!,
|
|
line: 1,
|
|
message: `production value-import cycle: ${cycle.join(' -> ')}`,
|
|
}));
|
|
}
|
|
|
|
function checkContractsImplementationAuthority(
|
|
sources: ReadonlyMap<string, string>,
|
|
): LayeringViolation[] {
|
|
return contractsImplementationAuthorityViolations(
|
|
[...sources].map(([path, source]) => ({ path, source })),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The record registry-join scan is a descriptor-shape check, not a cutover claim, so it
|
|
* stays its own module. Record's daemon-mechanics scan is the row's lifecycle proof and
|
|
* runs from the cutover table. Both report under R16.
|
|
*/
|
|
function checkRecordRuntimeRegistryJoin(sources: ReadonlyMap<string, string>): LayeringViolation[] {
|
|
const production = [...sources].map(([file, source]) => ({ path: file, source }));
|
|
return recordRuntimeRegistryJoinViolations(production).map((violation) => {
|
|
const separator = violation.indexOf(': ');
|
|
return {
|
|
rule: 'R16 record-runtime-cutover',
|
|
file: separator < 0 ? '(record runtime)' : violation.slice(0, separator),
|
|
line: 1,
|
|
message: separator < 0 ? violation : violation.slice(separator + 2),
|
|
};
|
|
});
|
|
}
|
|
|
|
function checkBackEdges(edges: readonly ResolvedImportEdge[]): LayeringViolation[] {
|
|
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. Down to 7, and every one of the 7 is now a deliberate
|
|
// architectural position rather than a misplaced declaration:
|
|
//
|
|
// commands/mcp -> client (4) `AgentDeviceClient`, used as an opaque handle ("the client this
|
|
// command runs against"). It cannot move below `commands/` because
|
|
// the facade is BUILT from the command surface's own projection
|
|
// registry: AgentDeviceClient -> AgentDeviceCommandClient ->
|
|
// ProjectedNavigationCommandClient -> NAVIGATION_COMMAND_PROJECTIONS
|
|
// in commands/system/. That is a genuine zone-level cycle, and
|
|
// breaking it means deciding where the projection registry belongs —
|
|
// a design call, not a file move. R5 is zero here: nothing imports
|
|
// the client at runtime, only its type.
|
|
//
|
|
// core -> daemon-server (2) `DaemonCommandDescriptor`, which is STATED IN TERMS OF the daemon's
|
|
// own server-private `DaemonRequest` (`refFrameEffect`,
|
|
// `allowSessionlessDefaultDevice`, `skipSessionlessProviderDevice`
|
|
// are all `(req: DaemonRequest) => …`). It therefore cannot be
|
|
// declared below the daemon, and having core/ re-declare a parallel
|
|
// 13-field shape would trade one erased edge for a second source of
|
|
// truth. Zones that only need to CLASSIFY a command take
|
|
// `contracts/dispatched-command.ts` instead. ADR 0003/0008.
|
|
//
|
|
// commands -> daemon-server (1) `DaemonCommandRoute` = `keyof typeof DAEMON_ROUTE_HANDLERS`, so
|
|
// it is COMPUTED FROM the daemon's handler table and cannot exist
|
|
// below it. `commands/command-explain.ts` uses it to key an
|
|
// exhaustive `Record<DaemonCommandRoute, string>` of owner files; a
|
|
// hand-written union in contracts/ would drop that exhaustiveness.
|
|
//
|
|
// See docs/dependency-graph-findings.md §0 for the long form. The counts may only go DOWN. Fixing edges without lowering the number fails too, so the baseline
|
|
// cannot quietly stop describing the tree.
|
|
//
|
|
// Exported so scripts/depgraph can assert its own graph build reproduces it — see the
|
|
// baseline-parity test there. The gate remains the authority; the report follows.
|
|
export const TYPE_INVERSION_BASELINE: Readonly<Record<string, number>> = {
|
|
'commands -> client': 3,
|
|
'commands -> daemon-server': 1,
|
|
'core -> daemon-server': 2,
|
|
'mcp -> client': 1,
|
|
};
|
|
|
|
function checkTypeInversions(edges: readonly ResolvedImportEdge[]): LayeringViolation[] {
|
|
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: LayeringViolation[] = [];
|
|
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>): LayeringViolation[] {
|
|
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: LayeringViolation[] = [];
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* R12: bin.ts's `--help` fast path must delegate command-alias resolution to the one alias
|
|
* registry instead of re-declaring its own mapping. See bin-alias-fast-path.ts for why the
|
|
* three facts below, together, are what closes the gap the original drift exploited — import
|
|
* presence and literal absence alone still pass a bin.ts that imports the resolver and never
|
|
* calls it (or calls it on something unrelated) while `buildCommandUsageText(helpTarget)` runs
|
|
* raw, which is exactly the P2 a maintainer review caught. Fact 3 is what closes that: EVERY
|
|
* `buildCommandUsageText` call must receive the imported resolver applied to the fast path's own
|
|
* help-target binding, with neither name shadowed by a local declaration. The universal
|
|
* quantifier is the follow-up P2 — an existential one is satisfied by a decoy call that resolves
|
|
* an unrelated literal while the shipped call still runs raw.
|
|
*/
|
|
function checkBinAliasFastPath(sources: ReadonlyMap<string, string>): LayeringViolation[] {
|
|
const registrySource = sources.get(ALIAS_REGISTRY_FILE);
|
|
const binSource = sources.get(BIN_FILE);
|
|
if (!registrySource || !binSource) {
|
|
const missing = !registrySource ? ALIAS_REGISTRY_FILE : BIN_FILE;
|
|
return [
|
|
{
|
|
rule: 'R12 bin-alias-fast-path',
|
|
file: missing,
|
|
line: 1,
|
|
message: `${missing} is missing, so bin.ts's alias delegation cannot be checked.`,
|
|
},
|
|
];
|
|
}
|
|
|
|
const violations: LayeringViolation[] = [];
|
|
const resolverLocalName = aliasResolverLocalName(binSource);
|
|
if (resolverLocalName === null) {
|
|
violations.push({
|
|
rule: 'R12 bin-alias-fast-path',
|
|
file: BIN_FILE,
|
|
line: 1,
|
|
message:
|
|
'does not hold a value import of normalizeCliCommandAlias from ' +
|
|
`${ALIAS_REGISTRY_FILE} — the --help fast path cannot delegate alias resolution to the ` +
|
|
'registry without it.',
|
|
});
|
|
} else {
|
|
const delegationFailure = usageTextDelegationFailure(binSource, resolverLocalName);
|
|
if (delegationFailure !== null) {
|
|
violations.push({
|
|
rule: 'R12 bin-alias-fast-path',
|
|
file: BIN_FILE,
|
|
line: 1,
|
|
message:
|
|
`imports normalizeCliCommandAlias (locally ${resolverLocalName}) but ` +
|
|
`${delegationFailure}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const localLiterals = localAliasLiterals(binSource, registryAliasTokens(registrySource));
|
|
if (localLiterals.length > 0) {
|
|
violations.push({
|
|
rule: 'R12 bin-alias-fast-path',
|
|
file: BIN_FILE,
|
|
line: 1,
|
|
message:
|
|
`contains the registry's own alias token(s) (${localLiterals.join(', ')}) as string ` +
|
|
'literals — a local alias-mapping table, hand-rolled instead of delegated to ' +
|
|
`${ALIAS_REGISTRY_FILE}. Delegate through normalizeCliCommandAlias instead of ` +
|
|
're-declaring the mapping.',
|
|
});
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
function report(
|
|
files: readonly string[],
|
|
violations: readonly LayeringViolation[],
|
|
typeCycle: number,
|
|
): number {
|
|
if (violations.length === 0) {
|
|
process.stdout.write(
|
|
`Layering guard: OK — ${files.length} source files satisfy R2-R3 and contain no ` +
|
|
`value-import cycles (both checked globally); the ranked target spine contains no ` +
|
|
`back-edges (only the composition root is unranked among src zones), 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); the largest type-level cycle is ${typeCycle} files ` +
|
|
`(R9); ${daemonModularitySummary()}; ` +
|
|
`${packageBoundariesSummary(repoRoot)}; ${platformPackagePolicySummary()}; ` +
|
|
`${runtimeCommandCutoverSummary()}; and bin.ts imports normalizeCliCommandAlias, ` +
|
|
`actually passes it into buildCommandUsageText, and holds no local alias literals ` +
|
|
`(R12).\n`,
|
|
);
|
|
return 0;
|
|
}
|
|
|
|
const byRule = new Map<string, LayeringViolation[]>();
|
|
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;
|
|
}
|
|
|
|
/** Everything the guard reads once per run, so a rule takes one argument whatever it needs. */
|
|
export type LayeringContext = Readonly<{
|
|
sourceFiles: readonly string[];
|
|
sources: ReadonlyMap<string, string>;
|
|
allTypeScriptSources: ReadonlyMap<string, string>;
|
|
edges: readonly ResolvedImportEdge[];
|
|
typeCycleMembers: readonly string[];
|
|
}>;
|
|
|
|
export type LayeringRule = (context: LayeringContext) => LayeringViolation[];
|
|
|
|
/**
|
|
* The rules this guard runs. Registering one is writing a key here, which is why the list is data
|
|
* rather than a hand-written array of spreads: an object cannot hold the same key twice, so a rule
|
|
* cannot be run — and reported, and ::error-annotated — twice by a copy-paste. `LayeringRuleId`
|
|
* then makes a missing key a type error rather than a silently retired rule.
|
|
*
|
|
* Order is the reporting order: report() groups by rule in first-seen order.
|
|
*/
|
|
export const LAYERING_RULE_IDS = [
|
|
'zone-policies',
|
|
'value-import-cycles',
|
|
'runtime-command-cutover',
|
|
'record-runtime-registry-join',
|
|
'contracts-implementation-authority',
|
|
'selector-pipeline-ownership',
|
|
'back-edges',
|
|
'type-spine-inversions',
|
|
'session-state-ownership',
|
|
'daemon-modularity-ratchets',
|
|
'bin-alias-fast-path',
|
|
'package-boundaries',
|
|
'platform-package-policy',
|
|
] as const;
|
|
|
|
export type LayeringRuleId = (typeof LAYERING_RULE_IDS)[number];
|
|
|
|
export const LAYERING_RULES: Readonly<Record<LayeringRuleId, LayeringRule>> = {
|
|
'zone-policies': (context) => checkLayeringRules(context.edges),
|
|
'value-import-cycles': (context) => checkCycles(context.edges),
|
|
'runtime-command-cutover': (context) => checkRuntimeCommandCutover(context.sources),
|
|
'record-runtime-registry-join': (context) => checkRecordRuntimeRegistryJoin(context.sources),
|
|
'contracts-implementation-authority': (context) =>
|
|
checkContractsImplementationAuthority(context.sources),
|
|
'selector-pipeline-ownership': (context) =>
|
|
selectorPipelineOwnershipViolations(context.edges, workspaceSpecifierTargets(repoRoot)),
|
|
'back-edges': (context) => checkBackEdges(context.edges),
|
|
'type-spine-inversions': (context) => checkTypeInversions(context.edges),
|
|
'session-state-ownership': (context) => checkSessionStateOwnership(context.sources),
|
|
'daemon-modularity-ratchets': (context) =>
|
|
checkDaemonModularityRatchets(context.edges, context.typeCycleMembers),
|
|
'bin-alias-fast-path': (context) => checkBinAliasFastPath(context.sources),
|
|
'package-boundaries': () => checkPackageBoundaries(repoRoot),
|
|
'platform-package-policy': (context) =>
|
|
checkPlatformPackagePolicy(
|
|
context.allTypeScriptSources,
|
|
readTrackedPlatformPackageDeclarations(repoRoot),
|
|
{ untrackedProductionFiles: listUntrackedProductionTypeScriptFiles(repoRoot) },
|
|
),
|
|
};
|
|
|
|
export function main(): number {
|
|
const sourceFiles = listSourceFiles();
|
|
const sources = readSources(sourceFiles);
|
|
const allTypeScriptSources = readSources(listTypeScriptFiles());
|
|
const edges = resolveImportEdges(sources, workspaceSpecifierTargets(repoRoot));
|
|
// Computed once and threaded: the rule and the success line must report the same number.
|
|
const typeCycleMembers = largestTypeCycleMembers(edges);
|
|
const typeCycle = typeCycleMembers.length;
|
|
const context: LayeringContext = {
|
|
sourceFiles,
|
|
sources,
|
|
allTypeScriptSources,
|
|
edges,
|
|
typeCycleMembers,
|
|
};
|
|
const violations = Object.values(LAYERING_RULES).flatMap((rule) => rule(context));
|
|
return report(sourceFiles, violations, typeCycle);
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
process.exit(main());
|
|
}
|