mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
635 lines
28 KiB
TypeScript
635 lines
28 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 } ◄ 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 remaining R2 move rule 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; and the terminal
|
|
// concrete-platform boundary (R65), which rejects every import form into the retired
|
|
// src/platforms path or a platform package.
|
|
// - 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 REQUEST-BOUND RUNTIME EXECUTION: facts remain the only admission authority and daemon
|
|
// code cannot manufacture or repair a narrowed runtime proof (R66).
|
|
// - 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,
|
|
checkRetiredInteractionPaths,
|
|
checkRetiredSessionLifecyclePaths,
|
|
checkRetiredSessionObservabilityPaths,
|
|
daemonModularitySummary,
|
|
} from './daemon-modularity.ts';
|
|
import {
|
|
checkPackageBoundaries,
|
|
packageBoundariesSummary,
|
|
workspaceSpecifierTargets,
|
|
} from './package-boundaries.ts';
|
|
import {
|
|
checkPlatformPackagePolicy,
|
|
checkRetiredPlatformsZone,
|
|
platformPackagePolicySummary,
|
|
} from './platform-package-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 { substrateDomainShapeViolations } from './substrate-domain-shape.ts';
|
|
import { selectorPipelineOwnershipViolations } from './selector-pipeline-ownership.ts';
|
|
import { recordRuntimeRegistryJoinViolations } from './record-runtime-registry-policy.ts';
|
|
import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechanics-policy.ts';
|
|
import { checkDaemonPlatformBoundary } from './daemon-platform-boundary.ts';
|
|
import {
|
|
listTrackedPlatformZoneFiles,
|
|
listTrackedProductionSources,
|
|
listTrackedTypeScriptFiles,
|
|
} from './tracked-sources.ts';
|
|
import { runtimeExecutionIntegrityViolations } from './runtime-execution-policy.ts';
|
|
import { sourceExecutionCompatibilityViolations } from './source-execution-policy.ts';
|
|
import { sessionResourceOwnershipViolations } from './session-resource-ownership.ts';
|
|
import { replayOwnershipViolations } from './replay-ownership.ts';
|
|
import { applicationLifecycleOwnershipViolations } from './application-lifecycle-policy.ts';
|
|
import { iosSnapshotEngineOwnershipViolations } from './ios-snapshot-engine-policy.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 })),
|
|
);
|
|
}
|
|
|
|
/** Record's descriptor join and mechanics boundary are one permanent ownership rule. */
|
|
function checkRecordRuntimeOwnership(sources: ReadonlyMap<string, string>): LayeringViolation[] {
|
|
const production = [...sources].map(([file, source]) => ({ path: file, source }));
|
|
return [
|
|
...recordRuntimeRegistryJoinViolations(production),
|
|
...recordRuntimeDaemonMechanicsViolations(production),
|
|
].map((violation) => {
|
|
const separator = violation.indexOf(': ');
|
|
return {
|
|
rule: 'R16 record-runtime-ownership',
|
|
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 5, and every one of the 5 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.
|
|
//
|
|
// commands -> daemon-server (1) `DaemonCommandRoute` is declared in core so descriptors can
|
|
// name a route without importing the daemon. `command-explain.ts`
|
|
// still type-imports the re-export from `daemon-command-registry.ts`
|
|
// to key an exhaustive `Record<DaemonCommandRoute, string>` of
|
|
// owner files; that remaining inversion is the commands-zone
|
|
// consumer, not a second source of truth for the union.
|
|
//
|
|
// 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,
|
|
'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 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()}; ` +
|
|
`runtime facts remain the only device-command admission authority and daemon code cannot ` +
|
|
`manufacture narrowed runtime proof (R66); R65 keeps production src/daemon free of concrete ` +
|
|
`platform imports in every executable and type-only form; 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-execution-integrity',
|
|
'source-execution-compatibility',
|
|
'record-runtime-ownership',
|
|
'session-resource-ownership',
|
|
'application-lifecycle-ownership',
|
|
'contracts-implementation-authority',
|
|
'substrate-domain-shape',
|
|
'selector-pipeline-ownership',
|
|
'back-edges',
|
|
'type-spine-inversions',
|
|
'session-state-ownership',
|
|
'daemon-modularity-ratchets',
|
|
'daemon-platform-boundary',
|
|
'bin-alias-fast-path',
|
|
'package-boundaries',
|
|
'platform-package-policy',
|
|
'retired-platforms-zone',
|
|
'replay-ownership',
|
|
'ios-snapshot-engine-ownership',
|
|
] 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-execution-integrity': (context) => runtimeExecutionIntegrityViolations(context.sources),
|
|
'source-execution-compatibility': (context) =>
|
|
sourceExecutionCompatibilityViolations(context.sources),
|
|
'record-runtime-ownership': (context) => checkRecordRuntimeOwnership(context.sources),
|
|
'session-resource-ownership': (context) => sessionResourceOwnershipViolations(context.sources),
|
|
'application-lifecycle-ownership': (context) =>
|
|
applicationLifecycleOwnershipViolations(context.sources),
|
|
'contracts-implementation-authority': (context) =>
|
|
checkContractsImplementationAuthority(context.sources),
|
|
'substrate-domain-shape': (context) =>
|
|
substrateDomainShapeViolations(
|
|
[...context.allTypeScriptSources].map(([path, source]) => ({ path, source })),
|
|
),
|
|
'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),
|
|
...checkRetiredInteractionPaths(context.sourceFiles),
|
|
...checkRetiredSessionLifecyclePaths(context.sourceFiles),
|
|
...checkRetiredSessionObservabilityPaths(context.sourceFiles),
|
|
],
|
|
'daemon-platform-boundary': (context) =>
|
|
checkDaemonPlatformBoundary([...context.sources].map(([path, source]) => ({ path, source }))),
|
|
'bin-alias-fast-path': (context) => checkBinAliasFastPath(context.sources),
|
|
'package-boundaries': () => checkPackageBoundaries(repoRoot),
|
|
'platform-package-policy': (context) =>
|
|
checkPlatformPackagePolicy(
|
|
context.allTypeScriptSources,
|
|
readTrackedPlatformPackageDeclarations(repoRoot),
|
|
{ untrackedProductionFiles: listUntrackedProductionTypeScriptFiles(repoRoot) },
|
|
),
|
|
'retired-platforms-zone': () => checkRetiredPlatformsZone(listTrackedPlatformZoneFiles(repoRoot)),
|
|
'replay-ownership': (context) => replayOwnershipViolations(context.sourceFiles),
|
|
'ios-snapshot-engine-ownership': (context) =>
|
|
iosSnapshotEngineOwnershipViolations(
|
|
[...context.sources].map(([path, source]) => ({ path, source })),
|
|
),
|
|
};
|
|
|
|
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());
|
|
}
|