mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
fcaa6c995c
* refactor(contracts): declare the public API vocabulary below its consumers
The layering gate's largest remaining cluster was 28 type-only inversions from a
single edge: `commands/` declaring itself in terms of `client/client-types.ts`.
R2 forbids the reverse import, so a shape both surfaces need has to sit below
both. The command/device vocabulary — connection config, the device and session
views, and every per-command Options/Result — now lives in
`contracts/client-api.ts`; `client/client-types.ts` keeps the `AgentDeviceClient`
facade and re-exports the rest through one wildcard.
R6 total: 42 -> 18. No new inversion in any pair.
The published surface is unchanged, and that is verified rather than asserted:
the built `index.d.ts` exports the same 216 type names as main, byte-identical.
Eight shapes deliberately did NOT move, because each is stated in terms of a
HIGHER-ranked zone: `ScrollOptions` (ScrollInputDirection, commands/), the four
navigation Options plus `AgentDeviceCommandClient` (navigation-projection,
commands/), and the two Metro result aliases (metro/). Declaring those in
contracts/ would trade 28 commands->client edges for contracts->commands and
contracts->metro ones — the foundation depending on the layers above it, worse in
kind even though fewer in number. This is measured, not assumed: moving the whole
file to contracts/ first took the gate from 42 to 48, which is how the floor was
found.
Two keystone moves made the other 84 movable:
- `RemoteConnectionProfileFields` joined its sibling `CloudProviderProfileFields`
in contracts/remote-config-fields.ts. It was the root of the base chain
(AgentDeviceClientConfig -> AgentDeviceRequestOverrides ->
DeviceCommandBaseOptions -> every per-command Options), so one rank-4
declaration was pinning ~80 shapes up with it.
- `DaemonBatchStep` moved to contracts/batch-step.ts. Its `runtime` field was
written `DaemonRequest['runtime']`, dragging the whole daemon request type in to
say `SessionRuntimeHints` — the same type, three zones lower.
`CompanionTunnelScope`/`MetroBridgeScope` also moved to contracts/, since the
vocabulary needs the scope shape and it sat next to client-local env-var names.
Six pass-through re-exports in client-types.ts are suppressed per-name with the
reason inline: they exist only to publish contracts/kernel types through the
package entrypoint wildcard, every internal consumer imports them from the
declaring module, so "no consumer" is correct and not actionable — deleting them
would remove names from the public types.
`pnpm check` green, 4488 unit tests. Findings doc records the sequencing for the
last 5: the upstream declarations have to come down before the shapes that need
them can.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
* docs: drop the graph viewer, keep the query that replaces it
The rendered dependency-graph viewer is not being merged (PR #1409 closed). It cost
~2200 lines plus a Fallow exemption for a 920-line canvas renderer, and nobody —
human or agent — reached a conclusion from the picture. Every finding in this
document came from short queries against the gate's own model.
This file pointed at the `claude/depgraph-viewer` branch for the tooling, which
would have dangled once that branch is deleted. Replaced with the thing that was
actually load-bearing: a throwaway probe script, inlined, that re-derives the
numbers from `scripts/layering/model.ts` and nothing else. Verified verbatim — it
reproduces TYPE_INVERSION_BASELINE exactly, which is also the check that tells you
whether either side has gone stale.
Two numbers in the summary table were stale, describing an intermediate state
rather than what shipped: R6 said "35 across 4" (actually 18 across 5 after the
vocabulary move) and ranked coverage said "729 of 894" (actually 888 of 901). Both
corrected, along with the file/edge counts in the header.
Also notes the deduplication detail that makes the query agree with the gate: each
file pair counts once, so a raw edge count reads higher.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
* refactor(contracts): move the four keystones that pinned the rest of the inversions
R6 type-only spine inversions: 18 -> 7, and every one of the 7 that remains is a
deliberate architectural position rather than a misplaced declaration.
Four keystones moved to contracts/, each of which was pinning a much larger set:
- `CommandFlags` (was core/dispatch-context.ts). One rank-2 declaration holding the
daemon's request type and every recorded action above it. Its last non-contracts
dependency was `DaemonBatchStep`, already moved in 3fdbfe0.
- `SessionAction` (was daemon/types.ts). replay/ (6 modules) and compat/maestro/
read and write session scripts; declaring the shape inside the daemon made both
depend on the server to describe a file format neither asks it to produce. The
daemon still owns the recording — only the shape moved.
- `TargetAnnotationV1` shape (was replay/target-identity.ts). ADR 0012 target
evidence, written by 8 daemon modules and read by commands/; the parsing and
classification logic stays in replay/.
- `ScrollInputDirection` and the Metro prepare/reload result payloads, which
unblocked `ScrollOptions` and `MetroPrepareResult`/`MetroReloadResult`.
`DaemonRequest` also split into the three shapes it had been conflating: the
kernel WIRE shape (`flags?: Record<string, unknown>`, because a process boundary
cannot enforce a vocabulary), the new `contracts/command-request.ts`
`CommandRequest` (wire shape with flags typed — what a command surface needs), and
the daemon's own refinement (+ `internal?: DaemonRequestInternal`, carrying
SessionState callbacks and the admitted lease). core/command-descriptor/ had been
importing the third to read `command`, `positionals` and `flags`.
Two things deliberately NOT moved, because moving them would add coupling rather
than remove it, and the baseline now argues both:
- `DaemonCommandDescriptor`/`DaemonCommandRoute` — the route type is
`keyof typeof DAEMON_ROUTE_HANDLERS`, derived from what the server implements.
Moving it down means re-declaring route names in contracts plus a gate to prove
the handler map still covers them. ADR 0003/0008 own that boundary.
- `AgentDeviceClient` — used as an opaque handle by 4 files. The facade is built
from commands/'s own NAVIGATION_COMMAND_PROJECTIONS, so this is a genuine
zone-level cycle; breaking it is a design call about where that registry belongs.
R5 is zero here: nothing imports the client at runtime, only its type.
Also records the largest structural finding, which R6 does not measure: cycles by
edge kind are 1 (value only), 87 (value + type-only), 1 (value + dynamic), 213
(all). At runtime the graph is a clean DAG; the 87-file type-level cluster means
no one of those files' types can be read in isolation. Hubs are
runtime-contract.ts, commands/runtime-types.ts, backend.ts,
commands/runtime-common.ts. Not attempted here — it is a different and much larger
change.
`pnpm check` green, 4488 unit tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
* feat(layering): ratchet type-cycle growth (R9), and rule out a narrower client port
R9: the largest strongly-connected component over value + type-only edges may not
grow. R4 keeps the VALUE graph acyclic, so every cycle counted here is created by
type-only imports - free at runtime, invisible to R5/R6, and the largest single
obstacle to reading a subsystem in isolation: inside a component of 102 files, no
file has a self-contained slice.
Baseline set to 102, which is what THIS branch achieves - main carries 107 and the
boundary moves here bring it to 102. An earlier revision baselined 87, measured
against an older main; after rebasing onto f19864e the real figure was 102 and the
new rule fired on its own stale baseline. Worth stating because the failure looked
like a regression and was not: attribution showed main at 107 and this branch
reducing it, which is the check working rather than complaining.
Growth-only, deliberately unlike R6. Reducing 102 is a real refactor rather than a
file move, so a hard equality would turn every unrelated improvement into a baseline
edit. A shrunk tree is reported in the success line instead of failing. Verified at
the new baseline by adding one type-only import that closes a loop and watching 102
become 108 and the gate reject it.
The refactor itself is still not attempted. Hubs by in-component dependents are
runtime-contract.ts, commands/runtime-types.ts, backend.ts,
commands/runtime-common.ts; a pass starts there.
Separately, investigated the narrower-port idea for the 4 remaining -> client
inversions and it does not work. Measured first:
files NAMING AgentDeviceClient (the inversions) 4
files CALLING client methods 26
distinct facade namespaces reached 13
The narrowness is an artifact of where the type is named, not of what is used.
Making those four generic over the client type pushes the concrete type into the 26
implementations, turning 4 inversions into up to 26. A port spanning 13 namespaces
is the whole facade, so it would either duplicate the public API shape - a second
source of truth for it - or derive from the facade and carry the same dependency.
So the four are the minimum number of naming sites rather than an accident: they are
the choke point. Recorded as a position with the numbers behind it. The remaining
option is the question underneath it - whether NAVIGATION_COMMAND_PROJECTIONS
belongs in commands/ - and that is a design decision about the command surface, not
a dependency cleanup.
pnpm check green, 4535 unit tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
* fix(layering): test R9, specify its floor, and drop two duplications
Adversarial self-review of #1435 found four things worth fixing.
R9 shipped with no unit test. Every other rule in this gate has one (R5 back-edges,
R6 inversions, R7 session state, R8 zero-dep closures); R9's only verification was a
manual injection CI cannot repeat. Added tests for the three distinctions it depends
on: a type-only loop counts, a dynamic-only loop does not, a value loop still does.
Writing that test immediately found an undocumented edge case, which is the argument
for it. largestTypeCycleSize returns 1 for an acyclic graph that has non-dynamic
edges but 0 when every edge is dynamic, because only edge-participating files enter
the walk. Immaterial to a growth ratchet, but an inconsistent floor nobody had
written down. Now specified in the doc comment and pinned by the test, so 0 and 1
cannot later be read as a meaningful difference.
largestTypeCycleMembers was exported with no consumer - speculative API, and
scripts/layering is in Fallow's ignorePatterns so nothing would have flagged it.
Same pattern review caught on the previous head with MaestroRuntimeFlags and
TargetRect. Made module-private.
ResolvedMetroKind was declared twice after the Metro payload move: exported from
contracts/metro.ts and still private in metro/client-metro.ts. client-metro.ts now
imports it.
The gate computed the SCC twice per run, once in the rule and once for the success
line. Computed once and threaded, so the two can no longer disagree.
Also re-verified the claim this PR rests on, with a stronger check than the one in
the body: comparing DECLARATION names in index.d.ts counts inlined internals, and by
that measure this branch appears to lose five names (PrepareMetroRuntimeResult,
ReloadMetroResult, ResolvedMetroKind, SCROLL_INPUT_DIRECTIONS, ScrollInputDirection).
All five are declared-but-not-exported helpers. The real surface - exported names
across all eleven published entrypoints - is 69 on both sides, identical. Also proved
DaemonRequest structurally equal to its pre-split shape with a type-level assertion
rather than by reasoning, and confirmed SessionAction, CommandFlags and
TargetAnnotationV1 moved byte-identically.
pnpm check green, 4535 unit tests, 24 layering tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
* refactor(contracts): one file per command family, one name per request
Addresses review on #1435.
contracts/client-api.ts was 1,064 LOC and grouped session, app, interaction,
replay, observability and recording contracts together, so it answered no one
question and crossed the >1,000-LOC architecture-debt tripwire in AGENTS.md:124.
Split it into 14 domain-family files by the command families that already exist
(client-connection, client-device-view, client-session, client-lease, client-app,
client-capture, client-target, client-gesture, client-selector-read,
client-replay, client-observability, client-settings, client-system,
client-request); the four Metro client shapes went into the existing
contracts/metro.ts so one file answers the Metro question. Largest resulting
file is 137 LOC. client/client-types.ts re-exports one wildcard per family, so
the published import path is unchanged.
Published surface verified unchanged against main two ways: the exported-name
set of all 11 published entrypoints is identical (70 names), and every
declaration in the built index.d.ts is byte-identical after normalization -- 0
names added, 0 shapes changed. index.d.ts got smaller (1,726 -> 1,682 lines):
10 declarations main duplicated into it now resolve through a shared chunk.
Also, from re-examining the two findings the review flagged as blind spots:
- CommandRequest was a third name for "a request" that no consumer needed.
Every core/command-descriptor/ use read only command/positionals/flags, in two
spellings (the full type and a Pick of it). Replaced by
contracts/dispatched-command.ts DispatchedCommand -- those three fields and
nothing else, with command/positionals Picked from the wire type so they
cannot drift. daemon/types.ts DaemonRequest now extends the wire shape
directly. Two request shapes again, at two ranks.
- The 7 remaining R6 inversions each get a mechanical reason rather than an
appeal to an ADR: the 4 AgentDeviceClient edges are a real zone-level cycle
(client-types.ts imports ProjectedNavigationCommandClient from commands/), and
no narrower port exists (26 call sites across 13 namespaces); the 2
DaemonCommandDescriptor edges are unavoidable because that shape is stated in
terms of the server-private DaemonRequest; the 1 DaemonCommandRoute edge is
unavoidable because the type is computed from the daemon's handler table.
Cleanups found on the way: three doc comments this branch had orphaned from
their declarations (SettleCommandOptions, RecordControlOptions,
ReloadMetroResult -- the last had drifted onto an unrelated type it
misdescribed) are reattached; intra-contracts imports normalized from
'../contracts/x.ts' to './x.ts', which is what the duplicate-import lint caught;
and stale references to the deleted file removed from the docs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
---------
Co-authored-by: Claude <noreply@anthropic.com>
406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
import path from 'node:path';
|
|
|
|
export type ImportEdge = {
|
|
spec: string;
|
|
dynamic: boolean;
|
|
typeOnly: boolean;
|
|
line: number;
|
|
};
|
|
|
|
export type ResolvedImportEdge = ImportEdge & {
|
|
file: string;
|
|
target: string;
|
|
fromZone: string;
|
|
toZone: string;
|
|
};
|
|
|
|
export type BackEdgeMap = Record<string, string[]>;
|
|
|
|
// The ranked target spine. Back-edge detection is defined ONLY between two ranked
|
|
// zones: an edge whose source outranks its target (lower number imports higher) is a
|
|
// spine back-edge. Zones NOT in this map are intentionally unranked (see
|
|
// `UNRANKED_ZONES`); the gate does not rank them, so ranking their edges would claim a
|
|
// back-edge guarantee the code does not make. Every production zone must be either
|
|
// ranked here or listed as unranked — `unclassifiedZones` and `model.test.ts` guard
|
|
// that no zone is silently unclassified.
|
|
const TARGET_DAG_RANK = new Map([
|
|
['kernel', 0],
|
|
['cloud-webdriver', 1],
|
|
['contracts', 1],
|
|
['platforms', 1],
|
|
['recording', 1],
|
|
['replay', 1],
|
|
['request', 1],
|
|
['screenshot-diff', 1],
|
|
['selectors', 1],
|
|
['snapshot', 1],
|
|
['utils', 1],
|
|
['core', 2],
|
|
['providers', 2],
|
|
['cli-schema', 3],
|
|
['commands', 3],
|
|
['mcp', 3],
|
|
['client', 4],
|
|
['compat', 4],
|
|
['daemon-server', 4],
|
|
['metro', 4],
|
|
['remote', 4],
|
|
['sdk', 4],
|
|
['daemon-client', 5],
|
|
['cli', 6],
|
|
]);
|
|
|
|
export const RANKED_ZONES: ReadonlySet<string> = new Set(TARGET_DAG_RANK.keys());
|
|
|
|
/**
|
|
* Spine rank of a zone, or `null` when the zone is intentionally unranked. The gate compares
|
|
* ranks internally; this is exported for the dependency-graph report, which records the rank per
|
|
* zone so a consumer can tell an inversion from an ordinary edge without re-deriving the spine.
|
|
*/
|
|
export function zoneRank(zone: string): number | null {
|
|
return TARGET_DAG_RANK.get(zone) ?? null;
|
|
}
|
|
|
|
// The one zone deliberately left OUT of the ranked spine. It is NOT unenforced: every file
|
|
// in it is still subject to the global production value-import cycle rejection (R4) and the
|
|
// R1-R3 move rules. It opts out of spine back-edge ranking because `(root)` holds the
|
|
// entrypoints and the composition roots that wire the command surface into the daemon —
|
|
// and R2 forbids `daemon/` from importing `commands/`, so those files must sit outside the
|
|
// spine by construction, composing it from above.
|
|
//
|
|
// The satellite zones used to be listed here too, on the grounds that ranking them would
|
|
// invent an order the architecture had not committed to. Once `utils` joined the spine and
|
|
// `(root)` was emptied of shared contracts, every one of them turned out to have a
|
|
// consistent rank already — so the order was there, just unasserted.
|
|
export const UNRANKED_ZONES: ReadonlySet<string> = new Set(['(root)']);
|
|
|
|
export type ZoneClassification = 'ranked' | 'unranked' | 'unclassified';
|
|
|
|
export function classifyZone(zone: string): ZoneClassification {
|
|
if (RANKED_ZONES.has(zone)) return 'ranked';
|
|
if (UNRANKED_ZONES.has(zone)) return 'unranked';
|
|
return 'unclassified';
|
|
}
|
|
|
|
function scanDynamicImports(line: string, lineNo: number): ImportEdge[] {
|
|
const edges: ImportEdge[] = [];
|
|
const re = /import\s*\(\s*['"]([^'"]+)['"]/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = re.exec(line))) {
|
|
edges.push({ spec: match[1]!, dynamic: true, typeOnly: false, line: lineNo });
|
|
}
|
|
return edges;
|
|
}
|
|
|
|
function scanSideEffectImport(line: string, lineNo: number): ImportEdge | null {
|
|
const match = /^\s*import\s+['"]([^'"]+)['"]/.exec(line);
|
|
return match ? { spec: match[1]!, dynamic: false, typeOnly: false, line: lineNo } : null;
|
|
}
|
|
|
|
function statementIsTypeOnly(statement: string): boolean {
|
|
if (/^\s*(?:import|export)\s+type\b/.test(statement)) return true;
|
|
const named = /\{([\s\S]*?)\}/.exec(statement);
|
|
if (!named) return false;
|
|
const prefix = statement
|
|
.slice(0, named.index)
|
|
.replace(/^\s*(?:import|export)\s+/, '')
|
|
.trim()
|
|
.replace(/,$/, '')
|
|
.trim();
|
|
if (prefix.length > 0) return false;
|
|
const specifiers = named[1]!
|
|
.split(',')
|
|
.map((specifier) => specifier.trim())
|
|
.filter(Boolean);
|
|
return specifiers.length > 0 && specifiers.every((specifier) => /^type\b/.test(specifier));
|
|
}
|
|
|
|
function scanFromImport(lines: string[], index: number): ImportEdge | null {
|
|
const fromMatch = /(?:^|[\s;}])from\s+['"]([^'"]+)['"]/.exec(lines[index]!);
|
|
if (!fromMatch) return null;
|
|
|
|
let start = index;
|
|
while (start >= 0 && !/^\s*(?:import|export)\b/.test(lines[start]!)) start--;
|
|
if (start < 0) return null;
|
|
|
|
const statement = lines.slice(start, index + 1).join('\n');
|
|
return {
|
|
spec: fromMatch[1]!,
|
|
dynamic: false,
|
|
typeOnly: statementIsTypeOnly(statement),
|
|
line: start + 1,
|
|
};
|
|
}
|
|
|
|
export function parseImports(source: string): ImportEdge[] {
|
|
const lines = source.split('\n');
|
|
const edges: ImportEdge[] = [];
|
|
for (let index = 0; index < lines.length; index++) {
|
|
edges.push(...scanDynamicImports(lines[index]!, index + 1));
|
|
const sideEffect = scanSideEffectImport(lines[index]!, index + 1);
|
|
if (sideEffect) {
|
|
edges.push(sideEffect);
|
|
continue;
|
|
}
|
|
const fromImport = scanFromImport(lines, index);
|
|
if (fromImport) edges.push(fromImport);
|
|
}
|
|
return edges;
|
|
}
|
|
|
|
export function topFolder(file: string): string {
|
|
const match = /^src\/([^/]+)\//.exec(file);
|
|
return match ? match[1]! : '(root)';
|
|
}
|
|
|
|
export function targetDagZone(file: string): string {
|
|
if (file.startsWith('src/daemon/client/')) return 'daemon-client';
|
|
if (file.startsWith('src/daemon/')) return 'daemon-server';
|
|
return topFolder(file);
|
|
}
|
|
|
|
// The set of zones every production file resolves into. A zone that is neither ranked
|
|
// nor listed as intentionally unranked is an unclassified drift signal.
|
|
export function collectZones(files: readonly string[]): Set<string> {
|
|
return new Set(files.map(targetDagZone));
|
|
}
|
|
|
|
// Zones present in `files` that are neither ranked nor intentionally unranked. A new
|
|
// `src/<folder>/` must be classified deliberately; leaving it unclassified would let
|
|
// its back-edges silently escape the ranked spine. Empty means the partition holds.
|
|
export function unclassifiedZones(files: readonly string[]): string[] {
|
|
return [...collectZones(files)].filter((zone) => classifyZone(zone) === 'unclassified').sort();
|
|
}
|
|
|
|
function resolveTargetFile(
|
|
fromFile: string,
|
|
spec: string,
|
|
sourceFiles: ReadonlySet<string>,
|
|
): string | null {
|
|
if (!spec.startsWith('.')) return null;
|
|
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec));
|
|
if (!resolved.startsWith('src/')) return null;
|
|
const candidates = [
|
|
resolved,
|
|
resolved.replace(/\.js$/, '.ts'),
|
|
`${resolved}.ts`,
|
|
path.posix.join(resolved, 'index.ts'),
|
|
];
|
|
return candidates.find((candidate) => sourceFiles.has(candidate)) ?? null;
|
|
}
|
|
|
|
export function resolveImportEdges(sources: ReadonlyMap<string, string>): ResolvedImportEdge[] {
|
|
const sourceFiles = new Set(sources.keys());
|
|
const edges: ResolvedImportEdge[] = [];
|
|
for (const [file, source] of sources) {
|
|
for (const edge of parseImports(source)) {
|
|
const target = resolveTargetFile(file, edge.spec, sourceFiles);
|
|
if (!target) continue;
|
|
edges.push({
|
|
...edge,
|
|
file,
|
|
target,
|
|
fromZone: targetDagZone(file),
|
|
toZone: targetDagZone(target),
|
|
});
|
|
}
|
|
}
|
|
return edges;
|
|
}
|
|
|
|
export function findValueImportCycles(edges: readonly ResolvedImportEdge[]): string[][] {
|
|
const graph = new Map<string, Set<string>>();
|
|
for (const edge of edges) {
|
|
if (edge.dynamic || edge.typeOnly) continue;
|
|
const targets = graph.get(edge.file) ?? new Set<string>();
|
|
targets.add(edge.target);
|
|
graph.set(edge.file, targets);
|
|
if (!graph.has(edge.target)) graph.set(edge.target, new Set());
|
|
}
|
|
|
|
const indexByFile = new Map<string, number>();
|
|
const lowLinkByFile = new Map<string, number>();
|
|
const stack: string[] = [];
|
|
const onStack = new Set<string>();
|
|
const components: string[][] = [];
|
|
let nextIndex = 0;
|
|
|
|
function visit(file: string): void {
|
|
const index = nextIndex++;
|
|
indexByFile.set(file, index);
|
|
lowLinkByFile.set(file, index);
|
|
stack.push(file);
|
|
onStack.add(file);
|
|
|
|
for (const target of graph.get(file) ?? []) {
|
|
if (!indexByFile.has(target)) {
|
|
visit(target);
|
|
lowLinkByFile.set(file, Math.min(lowLinkByFile.get(file)!, lowLinkByFile.get(target)!));
|
|
} else if (onStack.has(target)) {
|
|
lowLinkByFile.set(file, Math.min(lowLinkByFile.get(file)!, indexByFile.get(target)!));
|
|
}
|
|
}
|
|
|
|
if (lowLinkByFile.get(file) !== indexByFile.get(file)) return;
|
|
const component: string[] = [];
|
|
let member: string;
|
|
do {
|
|
member = stack.pop()!;
|
|
onStack.delete(member);
|
|
component.push(member);
|
|
} while (member !== file);
|
|
const selfCycle = component.length === 1 && graph.get(file)?.has(file);
|
|
if (component.length > 1 || selfCycle) components.push(component);
|
|
}
|
|
|
|
for (const file of graph.keys()) {
|
|
if (!indexByFile.has(file)) visit(file);
|
|
}
|
|
return components
|
|
.map((component) => findCyclePath(component, graph))
|
|
.sort((left, right) => left[0]!.localeCompare(right[0]!));
|
|
}
|
|
|
|
function findCyclePath(
|
|
component: readonly string[],
|
|
graph: ReadonlyMap<string, Set<string>>,
|
|
): string[] {
|
|
const members = new Set(component);
|
|
const visited = new Set<string>();
|
|
const active = new Map<string, number>();
|
|
const stack: string[] = [];
|
|
|
|
function visit(file: string): string[] | null {
|
|
visited.add(file);
|
|
active.set(file, stack.length);
|
|
stack.push(file);
|
|
for (const target of graph.get(file) ?? []) {
|
|
if (!members.has(target)) continue;
|
|
const activeIndex = active.get(target);
|
|
if (activeIndex !== undefined) return [...stack.slice(activeIndex), target];
|
|
if (!visited.has(target)) {
|
|
const path = visit(target);
|
|
if (path) return path;
|
|
}
|
|
}
|
|
stack.pop();
|
|
active.delete(file);
|
|
return null;
|
|
}
|
|
|
|
for (const file of [...component].sort()) {
|
|
if (visited.has(file)) continue;
|
|
const path = visit(file);
|
|
if (path) return path;
|
|
}
|
|
throw new Error(`Expected a cycle inside strongly connected component: ${component.join(', ')}`);
|
|
}
|
|
|
|
function spineInversionPair(edge: ResolvedImportEdge): string | null {
|
|
if (edge.fromZone === edge.toZone) return null;
|
|
const fromRank = TARGET_DAG_RANK.get(edge.fromZone);
|
|
const toRank = TARGET_DAG_RANK.get(edge.toZone);
|
|
if (fromRank === undefined || toRank === undefined || fromRank >= toRank) return null;
|
|
return `${edge.fromZone} -> ${edge.toZone}`;
|
|
}
|
|
|
|
export function backEdgePair(edge: ResolvedImportEdge): string | null {
|
|
if (edge.dynamic || edge.typeOnly) return null;
|
|
return spineInversionPair(edge);
|
|
}
|
|
|
|
// The same ranking applied to TYPE-ONLY edges (R6). R5 deliberately ignores them —
|
|
// a type-only import costs nothing at runtime and does not affect cold start — but a
|
|
// type-only edge still says "this zone is declared in terms of that one", and that IS a
|
|
// boundary claim. Ranking them found 61 inversions the gate had never seen, which is why
|
|
// they are ratcheted rather than merely reported: see `TYPE_INVERSION_BASELINE`.
|
|
export function typeInversionPair(edge: ResolvedImportEdge): string | null {
|
|
if (edge.dynamic || !edge.typeOnly) return null;
|
|
return spineInversionPair(edge);
|
|
}
|
|
|
|
export function collectBackEdges(edges: readonly ResolvedImportEdge[]): BackEdgeMap {
|
|
const identitiesByPair = new Map<string, Set<string>>();
|
|
for (const edge of edges) {
|
|
const pair = backEdgePair(edge);
|
|
if (!pair) continue;
|
|
const identities = identitiesByPair.get(pair) ?? new Set<string>();
|
|
identities.add(`${edge.file} -> ${edge.target}`);
|
|
identitiesByPair.set(pair, identities);
|
|
}
|
|
return Object.fromEntries(
|
|
[...identitiesByPair]
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([pair, identities]) => [pair, [...identities].sort()]),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Size of the largest strongly-connected component over value AND type-only edges.
|
|
*
|
|
* R4 keeps the VALUE graph acyclic, so any cycle here is created by type-only imports. That costs
|
|
* nothing at runtime — types are erased — but it bounds what can be read in isolation: every file
|
|
* in the component transitively references every other one's declarations, so none of them has a
|
|
* self-contained slice. Dynamic edges are excluded deliberately: a dynamic import is a lazy seam,
|
|
* and a loop through one is not a comprehension barrier in the same way.
|
|
*
|
|
* Returned as a single number because that is all R9 ratchets.
|
|
*
|
|
* Floor semantics, which are specified rather than incidental: only files that participate in at
|
|
* least one non-dynamic edge are considered, so an acyclic graph reports 1 (every such file is its
|
|
* own trivial component) and a graph whose only edges are dynamic reports 0 (no file enters the
|
|
* walk). Both are immaterial to a growth ratchet, but they are pinned in model.test.ts so nobody
|
|
* later reads 0 and 1 as a meaningful difference.
|
|
*/
|
|
export function largestTypeCycleSize(edges: readonly ResolvedImportEdge[]): number {
|
|
return largestTypeCycleMembers(edges).length;
|
|
}
|
|
|
|
/** Members of the largest value+type strongly-connected component, sorted. */
|
|
function largestTypeCycleMembers(edges: readonly ResolvedImportEdge[]): string[] {
|
|
const successors = new Map<string, string[]>();
|
|
for (const edge of edges) {
|
|
if (edge.dynamic) continue;
|
|
const list = successors.get(edge.file) ?? [];
|
|
list.push(edge.target);
|
|
successors.set(edge.file, list);
|
|
}
|
|
|
|
const index = new Map<string, number>();
|
|
const lowLink = new Map<string, number>();
|
|
const stack: string[] = [];
|
|
const onStack = new Set<string>();
|
|
let next = 0;
|
|
let biggest: string[] = [];
|
|
|
|
function visit(file: string): void {
|
|
index.set(file, next);
|
|
lowLink.set(file, next);
|
|
next++;
|
|
stack.push(file);
|
|
onStack.add(file);
|
|
|
|
for (const target of successors.get(file) ?? []) {
|
|
if (!index.has(target)) {
|
|
visit(target);
|
|
lowLink.set(file, Math.min(lowLink.get(file)!, lowLink.get(target)!));
|
|
} else if (onStack.has(target)) {
|
|
lowLink.set(file, Math.min(lowLink.get(file)!, index.get(target)!));
|
|
}
|
|
}
|
|
|
|
if (lowLink.get(file) !== index.get(file)) return;
|
|
const component: string[] = [];
|
|
let member: string;
|
|
do {
|
|
member = stack.pop()!;
|
|
onStack.delete(member);
|
|
component.push(member);
|
|
} while (member !== file);
|
|
if (component.length > biggest.length) biggest = component;
|
|
}
|
|
|
|
for (const file of successors.keys()) if (!index.has(file)) visit(file);
|
|
return biggest.sort();
|
|
}
|