From 737e9852e49dd0e5478c9fe7a6d6fc33ee1d4fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 10:40:26 +0200 Subject: [PATCH 1/6] refactor(graph): use shared engine for gate traversals --- scripts/depgraph/import-graph.ts | 34 +++- scripts/depgraph/model.ts | 3 +- scripts/fuzz/envelope.test.ts | 37 ++-- .../daemon-platform-runtime-inventory.ts | 24 +-- scripts/layering/model.ts | 172 +++++------------- ...vider-snapshot-presentation-policy.test.ts | 35 ++++ .../provider-snapshot-presentation-policy.ts | 14 +- scripts/mutation/ownership.test.ts | 10 +- scripts/mutation/ownership.ts | 70 +++---- 9 files changed, 197 insertions(+), 202 deletions(-) diff --git a/scripts/depgraph/import-graph.ts b/scripts/depgraph/import-graph.ts index b3cb50ffac..25a731b5ee 100644 --- a/scripts/depgraph/import-graph.ts +++ b/scripts/depgraph/import-graph.ts @@ -3,7 +3,8 @@ // a graph built here, so a kind filter is the only thing that distinguishes their subgraphs. import { createGraph, type Graph } from '@statelyai/graph'; -import type { EdgeKind, GraphEdge } from './model.ts'; + +export type EdgeKind = 'value' | 'type' | 'dynamic'; /** The subgraph R4 keeps acyclic: what a module needs before it can evaluate. */ export const VALUE_EDGES: ReadonlySet = new Set(['value']); @@ -14,6 +15,7 @@ export const VALUE_EDGES: ReadonlySet = new Set(['value']); * dynamic edges would cut every handler chain at its root. */ export const EXECUTABLE_EDGES: ReadonlySet = new Set(['value', 'dynamic']); +export const STATIC_EDGES: ReadonlySet = new Set(['value', 'type']); /** * Every collapsed edge kind, for the structural question ("does anything reference this file at @@ -24,6 +26,15 @@ export const EXECUTABLE_EDGES: ReadonlySet = new Set(['value', 'dynami */ export const ALL_EDGES: ReadonlySet = new Set(['value', 'type', 'dynamic']); +type ImportGraphEdge = { from: string; to: string; kind: EdgeKind }; + +type ResolvedGraphEdge = { + file: string; + target: string; + dynamic: boolean; + typeOnly: boolean; +}; + /** * Identity of one file pair, shared by `collapseEdges` and the graph's edge ids. NUL cannot occur * in a file path, so distinct pairs never share an id. @@ -37,7 +48,7 @@ export function importEdgeId(from: string, to: string): string { * isolated modules still exist as nodes. */ export function importGraph( - edges: readonly GraphEdge[], + edges: readonly ImportGraphEdge[], kinds: ReadonlySet, files: Iterable = [], ): Graph { @@ -56,3 +67,22 @@ export function importGraph( })), }); } + +/** Build a traversal graph from the normalized edges produced by the layering model. */ +export function importGraphFromResolvedEdges( + edges: readonly ResolvedGraphEdge[], + kinds: ReadonlySet, + files: Iterable = [], +): Graph { + const seen = new Set(); + const graphEdges: ImportGraphEdge[] = []; + for (const edge of edges) { + const kind: EdgeKind = edge.dynamic ? 'dynamic' : edge.typeOnly ? 'type' : 'value'; + if (!kinds.has(kind)) continue; + const id = importEdgeId(edge.file, edge.target); + if (seen.has(id)) continue; + seen.add(id); + graphEdges.push({ from: edge.file, to: edge.target, kind }); + } + return importGraph(graphEdges, kinds, files); +} diff --git a/scripts/depgraph/model.ts b/scripts/depgraph/model.ts index fe723a212a..63febf420b 100644 --- a/scripts/depgraph/model.ts +++ b/scripts/depgraph/model.ts @@ -17,8 +17,9 @@ import { import { ARCHITECTURE_OWNERSHIP, matchesDeclaredRoot } from '../layering/architecture-ownership.ts'; import { genPostorder, getSuccessors, getTransitiveReduction } from '@statelyai/graph'; import { importEdgeId, importGraph, VALUE_EDGES } from './import-graph.ts'; +import type { EdgeKind } from './import-graph.ts'; -export type EdgeKind = 'value' | 'type' | 'dynamic'; +export type { EdgeKind } from './import-graph.ts'; export const AUTHORITY_LABELS = [ 'vocabulary', diff --git a/scripts/fuzz/envelope.test.ts b/scripts/fuzz/envelope.test.ts index 94cdcb02eb..7d737af9aa 100644 --- a/scripts/fuzz/envelope.test.ts +++ b/scripts/fuzz/envelope.test.ts @@ -9,7 +9,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { genBFS } from '@statelyai/graph'; import { describe, expect, it } from 'vitest'; +import { parseImports } from '../layering/model.ts'; +import { importGraphFromResolvedEdges, STATIC_EDGES } from '../depgraph/import-graph.ts'; import { CASE_GENERATION_INPUTS, NON_GENERATING_MODULES } from './envelope.ts'; const FUZZ_DIR = path.dirname(fileURLToPath(import.meta.url)); @@ -17,26 +20,34 @@ const FUZZ_DIR = path.dirname(fileURLToPath(import.meta.url)); /** Roots of case generation: the arbitraries, the seeds, the loop, and the violation rule. */ const ROOTS = ['arbitraries.ts', 'generate.ts', 'targets.ts', 'invariant.ts'] as const; -function localImportsOf(file: string): string[] { - const source = fs.readFileSync(path.join(FUZZ_DIR, file), 'utf8'); - return [...source.matchAll(/from '\.\/([\w-]+\.ts)'/g)].map((match) => match[1]!); +function generationGraph() { + const sources = new Map( + fs + .readdirSync(FUZZ_DIR) + .filter((file) => file.endsWith('.ts')) + .map((file) => [file, fs.readFileSync(path.join(FUZZ_DIR, file), 'utf8')]), + ); + const edges = []; + for (const [file, source] of sources) { + if (file in NON_GENERATING_MODULES) continue; + for (const edge of parseImports(source)) { + if (!edge.spec.startsWith('./')) continue; + const target = path.posix.normalize(path.posix.join(path.posix.dirname(file), edge.spec)); + if (!sources.has(target)) continue; + edges.push({ file, target, dynamic: edge.dynamic, typeOnly: edge.typeOnly }); + } + } + return importGraphFromResolvedEdges(edges, STATIC_EDGES, sources.keys()); } +const graph = generationGraph(); + /** * Walk stops at a waived module: what a non-generating module imports cannot reach a case either * (the runner imports the target registry, which would otherwise drag the whole harness in). */ function generationClosure(): Set { - const seen = new Set(); - const queue = [...ROOTS]; - while (queue.length > 0) { - const file = queue.pop()!; - if (seen.has(file)) continue; - seen.add(file); - if (file in NON_GENERATING_MODULES) continue; - queue.push(...localImportsOf(file)); - } - return seen; + return new Set([...genBFS(graph, { from: ROOTS })].map(({ id }) => id)); } describe('configHash coverage', () => { diff --git a/scripts/layering/daemon-platform-runtime-inventory.ts b/scripts/layering/daemon-platform-runtime-inventory.ts index 2704c2573b..67cc9e1be5 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.ts @@ -1,5 +1,7 @@ +import { genDFS } from '@statelyai/graph'; import { isProductionSourceFile } from './tracked-sources.ts'; import type { LayeringViolation, ResolvedImportEdge } from './model.ts'; +import { ALL_EDGES, importGraphFromResolvedEdges } from '../depgraph/import-graph.ts'; // The classified inventory of every production daemon import that reaches the root platform-runtime // composition modules (#2278, ADR 0022, #2542). R65 already bans daemon imports of concrete platform @@ -52,27 +54,19 @@ export function isRootPlatformRuntimeTarget(target: string): boolean { export function computePlatformMechanicsHubs( edges: readonly ResolvedImportEdge[], ): ReadonlySet { - const importersByTarget = new Map(); const roots: string[] = []; + const rootSet = new Set(); for (const edge of edges) { - const importers = importersByTarget.get(edge.target); - if (importers === undefined) importersByTarget.set(edge.target, [edge.file]); - else importers.push(edge.file); - if (isRootPlatformRuntimeTarget(edge.target) && !roots.includes(edge.target)) { + if (isRootPlatformRuntimeTarget(edge.target) && !rootSet.has(edge.target)) { + rootSet.add(edge.target); roots.push(edge.target); } } - const hubs = new Set(roots); - const seen = new Set(roots); - const frontier = [...roots]; - while (frontier.length > 0) { - for (const importer of importersByTarget.get(frontier.pop()!) ?? []) { - if (seen.has(importer)) continue; - seen.add(importer); - frontier.push(importer); - if (!importer.startsWith('src/daemon/')) hubs.add(importer); - } + const hubs = new Set(); + const graph = importGraphFromResolvedEdges(edges, ALL_EDGES, roots); + for (const { id: importer } of genDFS(graph, { from: roots, direction: 'incoming' })) { + if (!importer.startsWith('src/daemon/')) hubs.add(importer); } return hubs; } diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 704c4afa7c..561cc60776 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -1,8 +1,14 @@ import path from 'node:path'; import { PLATFORMS } from '@agent-device/kernel/device'; +import { genCycles, getStronglyConnectedComponents } from '@statelyai/graph'; import { parseSync } from 'oxc-parser'; import { destructuredDynamicImportBindings, visitAst } from './layering-ast.ts'; import { declaredRootModuleZone } from './root-module-zones.ts'; +import { + importGraphFromResolvedEdges, + STATIC_EDGES, + VALUE_EDGES, +} from '../depgraph/import-graph.ts'; export type ImportEdge = { spec: string; @@ -397,91 +403,51 @@ export function resolveImportEdges( } export function findValueImportCycles(edges: readonly ResolvedImportEdge[]): string[][] { - const graph = new Map>(); - for (const edge of edges) { - if (edge.dynamic || edge.typeOnly) continue; - const targets = graph.get(edge.file) ?? new Set(); - targets.add(edge.target); - graph.set(edge.file, targets); - if (!graph.has(edge.target)) graph.set(edge.target, new Set()); - } - - const indexByFile = new Map(); - const lowLinkByFile = new Map(); - const stack: string[] = []; - const onStack = new Set(); - 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)!)); - } - } + const graph = importGraphFromResolvedEdges(edges, VALUE_EDGES); + const componentByFile = new Map(); + const cyclicComponents = new Set(); + const membersByComponent = new Map(); + const components = getStronglyConnectedComponents(graph); + const selfCycleFiles = new Set( + graph.edges.filter((edge) => edge.sourceId === edge.targetId).map((edge) => edge.sourceId), + ); - 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 [index, component] of components.entries()) { + const memberIds = component.map(({ id }) => id); + if (memberIds.length < 2 && !selfCycleFiles.has(memberIds[0]!)) continue; + cyclicComponents.add(index); + membersByComponent.set(index, memberIds); + for (const id of memberIds) componentByFile.set(id, index); } - for (const file of graph.keys()) { - if (!indexByFile.has(file)) visit(file); + if (cyclicComponents.size === 0) return []; + + const edgesByComponent = new Map(); + for (const edge of edges) { + if (edge.dynamic || edge.typeOnly) continue; + const component = componentByFile.get(edge.file); + if (component === undefined || componentByFile.get(edge.target) !== component) continue; + const componentEdges = edgesByComponent.get(component) ?? []; + componentEdges.push(edge); + edgesByComponent.set(component, componentEdges); } - return components - .map((component) => findCyclePath(component, graph)) - .sort((left, right) => left[0]!.localeCompare(right[0]!)); -} -function findCyclePath( - component: readonly string[], - graph: ReadonlyMap>, -): string[] { - const members = new Set(component); - const visited = new Set(); - const active = new Map(); - 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; - } + const cycles = [...cyclicComponents].map((component) => { + const componentGraph = importGraphFromResolvedEdges( + edgesByComponent.get(component) ?? [], + VALUE_EDGES, + membersByComponent.get(component), + ); + const firstCycle = genCycles(componentGraph).next(); + if (firstCycle.done) { + throw new Error( + `Expected a cycle inside strongly connected component: ${membersByComponent.get(component)!.join(', ')}`, + ); } - stack.pop(); - active.delete(file); - return null; - } + return [firstCycle.value.source.id, ...firstCycle.value.steps.map(({ node }) => node.id)]; + }); - 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(', ')}`); + return cycles.sort((left, right) => left[0]!.localeCompare(right[0]!)); } function spineInversionPair(edge: ResolvedImportEdge): string | null { @@ -566,48 +532,10 @@ export function largestTypeCycleSize(edges: readonly ResolvedImportEdge[]): numb /** Members of the largest value+type strongly-connected component, sorted. */ export function largestTypeCycleMembers(edges: readonly ResolvedImportEdge[]): string[] { - const successors = new Map(); - 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(); - const lowLink = new Map(); - const stack: string[] = []; - const onStack = new Set(); - 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(); + return getStronglyConnectedComponents(importGraphFromResolvedEdges(edges, STATIC_EDGES)) + .reduce((largest, component) => { + if (component.length > largest.length) largest = component.map(({ id }) => id); + return largest; + }, []) + .sort(); } diff --git a/scripts/layering/provider-snapshot-presentation-policy.test.ts b/scripts/layering/provider-snapshot-presentation-policy.test.ts index 1d0bb8a082..df0b5808e5 100644 --- a/scripts/layering/provider-snapshot-presentation-policy.test.ts +++ b/scripts/layering/provider-snapshot-presentation-policy.test.ts @@ -77,6 +77,41 @@ test('R73 rejects a provider import of the capture-kit presentation runtime subp ); }); +test('R73 preserves breadth-first origin and finding order through a shared import graph', () => { + const provider = 'packages/provider-webdriver/src/provider.ts'; + const first = 'src/graph-first.ts'; + const second = 'src/graph-second.ts'; + const join = 'src/graph-join.ts'; + const firstPresentation = 'packages/capture-kit/src/snapshot/first.ts'; + const secondPresentation = 'packages/capture-kit/src/snapshot/second.ts'; + const joinedPresentation = 'packages/capture-kit/src/snapshot/joined.ts'; + const sources = new Map([ + [provider, "import '../../../src/graph-first.ts';\nimport '../../../src/graph-second.ts';\n"], + [first, "import '../packages/capture-kit/src/snapshot/first.ts';\nimport './graph-join.ts';\n"], + [second, "import '../packages/capture-kit/src/snapshot/second.ts';\n"], + [join, "import '../packages/capture-kit/src/snapshot/joined.ts';\n"], + [firstPresentation, 'export const first = true;\n'], + [secondPresentation, 'export const second = true;\n'], + [joinedPresentation, 'export const joined = true;\n'], + ]); + const result = providerSnapshotPresentationViolations( + sources, + resolveImportEdges(sources, workspaceSpecifierTargets(repoRoot)), + ); + + assert.deepEqual( + result.map(({ line, message }) => ({ + line, + target: /reaches (.+?) before/.exec(message)?.[1], + })), + [ + { line: 1, target: firstPresentation }, + { line: 2, target: secondPresentation }, + { line: 1, target: joinedPresentation }, + ], + ); +}); + for (const planted of [ { name: 'a planted provider residue discard', diff --git a/scripts/layering/provider-snapshot-presentation-policy.ts b/scripts/layering/provider-snapshot-presentation-policy.ts index 45a9902ea6..8dd9690792 100644 --- a/scripts/layering/provider-snapshot-presentation-policy.ts +++ b/scripts/layering/provider-snapshot-presentation-policy.ts @@ -11,9 +11,11 @@ // exports map cannot replace it: it restricts external specifiers, not the transitive walk // into packages/capture-kit/src/snapshot/ or a provider-local `residue` property or assignment. +import { genBFS } from '@statelyai/graph'; import { parseSync } from 'oxc-parser'; import type { LayeringViolation, ResolvedImportEdge } from './model.ts'; import { memberPath, propertyName, visitAst } from './layering-ast.ts'; +import { ALL_EDGES, importGraphFromResolvedEdges } from '../depgraph/import-graph.ts'; export const PROVIDER_SNAPSHOT_PRESENTATION_RULE = 'R73 provider-snapshot-presentation-ownership'; export const IOS_SNAPSHOT_ACQUISITION_ENTRYPOINT = @@ -52,11 +54,16 @@ function presentationImportViolations( ): LayeringViolation[] { const origins = new Map(); const visited = new Set([providerFile]); - const queue = [providerFile]; const violations: LayeringViolation[] = []; + const reachableEdges = [...edgesByFile.values()].flat(); + const graph = importGraphFromResolvedEdges( + reachableEdges.filter((edge) => !isPresentationTarget(edge.file)), + ALL_EDGES, + [providerFile], + ); - while (queue.length > 0) { - const file = queue.shift()!; + for (const { id: file } of genBFS(graph, providerFile)) { + if (isPresentationTarget(file)) continue; for (const edge of edgesByFile.get(file) ?? []) { const origin = origins.get(file) ?? edge; if (isPresentationTarget(edge.target)) { @@ -72,7 +79,6 @@ function presentationImportViolations( if (visited.has(edge.target)) continue; visited.add(edge.target); origins.set(edge.target, origin); - queue.push(edge.target); } } return violations; diff --git a/scripts/mutation/ownership.test.ts b/scripts/mutation/ownership.test.ts index af966cbf90..9d3299eb3b 100644 --- a/scripts/mutation/ownership.test.ts +++ b/scripts/mutation/ownership.test.ts @@ -43,9 +43,15 @@ test('a kernel is owned by tests that reach it indirectly', () => { ); }); +test('reachability includes its query root when the file is absent from the tracked graph', () => { + assert.deepEqual( + [...reachableFrom('src/untracked-fixture.ts', repoRoot)], + ['src/untracked-fixture.ts'], + ); +}); + test('ownership is complete: every test reaching a kernel owns it', () => { const owned = ownedTestFiles(repoRoot); - const cache = new Map(); for (const module of KERNEL_MODULES) { const sources = mutatedSources(module, repoRoot); assert.ok(sources.length > 0, `${module.id} mutates nothing`); @@ -55,7 +61,7 @@ test('ownership is complete: every test reaching a kernel owns it', () => { `${module.id} has no owning tests — its score cannot be attributed`, ); for (const testFile of files) { - const reachable = reachableFrom(testFile, repoRoot, cache); + const reachable = reachableFrom(testFile, repoRoot); assert.ok( sources.some((source) => reachable.has(source)), `${testFile} owns ${module.id} without reaching it`, diff --git a/scripts/mutation/ownership.ts b/scripts/mutation/ownership.ts index ecd3d7379b..b24db35b13 100644 --- a/scripts/mutation/ownership.ts +++ b/scripts/mutation/ownership.ts @@ -20,11 +20,15 @@ import fs from 'node:fs'; import path from 'node:path'; +import { genBFS, type Graph } from '@statelyai/graph'; import { readWorkspacePackages, workspaceSpecifierTargets, } from '../layering/package-boundaries.ts'; +import { resolveImportEdges } from '../layering/model.ts'; +import { listTrackedTypeScriptFiles } from '../layering/tracked-sources.ts'; import { walkFiles } from '../lib/walk-files.ts'; +import { ALL_EDGES, importGraphFromResolvedEdges } from '../depgraph/import-graph.ts'; import { affectedModules, isKernelTestFile, @@ -55,52 +59,32 @@ function exportTargetsFor(repoRoot: string): Map { return cachedExportTargets.targets; } -/** - * Repository-relative modules a file imports: relative specifiers plus - * workspace package specifiers resolved through their `exports` maps. - */ -function importsOf(file: string, repoRoot: string, cache: Map): string[] { - const cached = cache.get(file); +const graphByRepository = new Map(); + +function ownershipGraph(repoRoot: string): Graph { + const cached = graphByRepository.get(repoRoot); if (cached) return cached; - const absolute = path.join(repoRoot, file); - const text = fs.existsSync(absolute) ? fs.readFileSync(absolute, 'utf8') : ''; - const specifiers = [...text.matchAll(/(?:from|import)\s*\(?\s*'(?[.@][^']+)'/g)].map( - (match) => match.groups!.spec, + const files = listTrackedTypeScriptFiles(repoRoot); + const sources = new Map( + files.map((file) => [file, fs.readFileSync(path.join(repoRoot, file), 'utf8')]), + ); + const graph = importGraphFromResolvedEdges( + resolveImportEdges(sources, exportTargetsFor(repoRoot)), + ALL_EDGES, + files, ); - const exportTargets = exportTargetsFor(repoRoot); - const resolved = [ - ...new Set( - specifiers.flatMap((specifier) => { - if (specifier.startsWith('@')) { - const target = exportTargets.get(specifier); - return target && fs.existsSync(path.join(repoRoot, target)) ? [target] : []; - } - const base = path.posix.normalize(path.posix.join(path.posix.dirname(file), specifier)); - return [base, `${base}.ts`, `${base}/index.ts`].filter((candidate) => - fs.existsSync(path.join(repoRoot, candidate)), - ); - }), - ), - ].filter((candidate) => candidate.endsWith('.ts')); - cache.set(file, resolved); - return resolved; + graphByRepository.set(repoRoot, graph); + return graph; +} + +function reachableInGraph(file: string, graph: Graph): Set { + const root = normalizePath(file); + return new Set([root, ...[...genBFS(graph, root)].map(({ id }) => id)]); } /** Every repository-relative module `file` reaches through the import graph. */ -export function reachableFrom( - file: string, - repoRoot: string, - cache: Map = new Map(), -): Set { - const seen = new Set(); - const queue = [normalizePath(file)]; - while (queue.length > 0) { - const current = queue.shift()!; - if (seen.has(current)) continue; - seen.add(current); - queue.push(...importsOf(current, repoRoot, cache)); - } - return seen; +export function reachableFrom(file: string, repoRoot: string): Set { + return reachableInGraph(file, ownershipGraph(repoRoot)); } /** The concrete sources Stryker mutates for a module. */ @@ -115,14 +99,14 @@ type Deriver = { /** A deriver with caches shared across files — one graph walk per module, not per query. */ export function ownershipDeriver(repoRoot: string): Deriver { - const importCache = new Map(); + const graph = ownershipGraph(repoRoot); const sources = KERNEL_MODULES.map((module) => ({ id: module.id, sources: new Set(mutatedSources(module, repoRoot)), })); return { ownersOf(testFile) { - const reachable = reachableFrom(testFile, repoRoot, importCache); + const reachable = reachableInGraph(testFile, graph); return sources .filter((entry) => [...entry.sources].some((source) => reachable.has(source))) .map((entry) => entry.id); From 721cf76721cf0265232da52daf81f39478da8a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 10:54:46 +0200 Subject: [PATCH 2/6] refactor(graph): address traversal review findings --- scripts/depgraph/import-graph.ts | 8 +++++++- scripts/depgraph/model.ts | 8 +------- .../provider-snapshot-presentation-policy.test.ts | 5 ++++- .../provider-snapshot-presentation-policy.ts | 15 ++++++++------- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/scripts/depgraph/import-graph.ts b/scripts/depgraph/import-graph.ts index 25a731b5ee..e17a9ea826 100644 --- a/scripts/depgraph/import-graph.ts +++ b/scripts/depgraph/import-graph.ts @@ -35,6 +35,12 @@ type ResolvedGraphEdge = { typeOnly: boolean; }; +export function edgeKind(edge: Pick): EdgeKind { + if (edge.dynamic) return 'dynamic'; + if (edge.typeOnly) return 'type'; + return 'value'; +} + /** * Identity of one file pair, shared by `collapseEdges` and the graph's edge ids. NUL cannot occur * in a file path, so distinct pairs never share an id. @@ -77,7 +83,7 @@ export function importGraphFromResolvedEdges( const seen = new Set(); const graphEdges: ImportGraphEdge[] = []; for (const edge of edges) { - const kind: EdgeKind = edge.dynamic ? 'dynamic' : edge.typeOnly ? 'type' : 'value'; + const kind = edgeKind(edge); if (!kinds.has(kind)) continue; const id = importEdgeId(edge.file, edge.target); if (seen.has(id)) continue; diff --git a/scripts/depgraph/model.ts b/scripts/depgraph/model.ts index 63febf420b..4ce460e4ba 100644 --- a/scripts/depgraph/model.ts +++ b/scripts/depgraph/model.ts @@ -16,7 +16,7 @@ import { } from '../layering/model.ts'; import { ARCHITECTURE_OWNERSHIP, matchesDeclaredRoot } from '../layering/architecture-ownership.ts'; import { genPostorder, getSuccessors, getTransitiveReduction } from '@statelyai/graph'; -import { importEdgeId, importGraph, VALUE_EDGES } from './import-graph.ts'; +import { edgeKind, importEdgeId, importGraph, VALUE_EDGES } from './import-graph.ts'; import type { EdgeKind } from './import-graph.ts'; export type { EdgeKind } from './import-graph.ts'; @@ -165,12 +165,6 @@ function countLines(source: string): number { return lines; } -function edgeKind(edge: ResolvedImportEdge): EdgeKind { - if (edge.dynamic) return 'dynamic'; - if (edge.typeOnly) return 'type'; - return 'value'; -} - /** * Deduplicate parsed import edges down to one edge per (from, to) pair, keeping the * strongest kind. A file that imports both a type and a value from the same module has one diff --git a/scripts/layering/provider-snapshot-presentation-policy.test.ts b/scripts/layering/provider-snapshot-presentation-policy.test.ts index df0b5808e5..9f226590ab 100644 --- a/scripts/layering/provider-snapshot-presentation-policy.test.ts +++ b/scripts/layering/provider-snapshot-presentation-policy.test.ts @@ -88,7 +88,10 @@ test('R73 preserves breadth-first origin and finding order through a shared impo const sources = new Map([ [provider, "import '../../../src/graph-first.ts';\nimport '../../../src/graph-second.ts';\n"], [first, "import '../packages/capture-kit/src/snapshot/first.ts';\nimport './graph-join.ts';\n"], - [second, "import '../packages/capture-kit/src/snapshot/second.ts';\n"], + [ + second, + "import '../packages/capture-kit/src/snapshot/second.ts';\nimport './graph-join.ts';\n", + ], [join, "import '../packages/capture-kit/src/snapshot/joined.ts';\n"], [firstPresentation, 'export const first = true;\n'], [secondPresentation, 'export const second = true;\n'], diff --git a/scripts/layering/provider-snapshot-presentation-policy.ts b/scripts/layering/provider-snapshot-presentation-policy.ts index 8dd9690792..c104a349d3 100644 --- a/scripts/layering/provider-snapshot-presentation-policy.ts +++ b/scripts/layering/provider-snapshot-presentation-policy.ts @@ -40,9 +40,15 @@ export function providerSnapshotPresentationViolations( } const violations: LayeringViolation[] = []; + const reachableEdges = [...edgesByFile.values()].flat(); + const graph = importGraphFromResolvedEdges( + reachableEdges.filter((edge) => !isPresentationTarget(edge.file)), + ALL_EDGES, + sources.keys(), + ); for (const file of sources.keys()) { if (!PROVIDER_SOURCE.test(file)) continue; - violations.push(...presentationImportViolations(file, edgesByFile)); + violations.push(...presentationImportViolations(file, edgesByFile, graph)); violations.push(...residueViolations(file, sources.get(file)!)); } return violations; @@ -51,16 +57,11 @@ export function providerSnapshotPresentationViolations( function presentationImportViolations( providerFile: string, edgesByFile: ReadonlyMap, + graph: ReturnType, ): LayeringViolation[] { const origins = new Map(); const visited = new Set([providerFile]); const violations: LayeringViolation[] = []; - const reachableEdges = [...edgesByFile.values()].flat(); - const graph = importGraphFromResolvedEdges( - reachableEdges.filter((edge) => !isPresentationTarget(edge.file)), - ALL_EDGES, - [providerFile], - ); for (const { id: file } of genBFS(graph, providerFile)) { if (isPresentationTarget(file)) continue; From ffafd8ab0260c41f332bfc38783df5b5b78d0bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 12:44:38 +0200 Subject: [PATCH 3/6] fix(mutation): keep tooling canary within budget --- scripts/mutation/modules.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/scripts/mutation/modules.ts b/scripts/mutation/modules.ts index beed008978..d0a6f24939 100644 --- a/scripts/mutation/modules.ts +++ b/scripts/mutation/modules.ts @@ -118,12 +118,11 @@ export function mutateGlobs(ids: readonly ModuleId[] = ALL_MODULE_IDS): string[] } /** - * The module a lane-tooling change proves itself against. `kernel-errors` is the - * cheapest real sweep in the registry (one file, ~183 mutants), so a change to - * the harness or the config runs actual mutants end to end without paying for - * the full sweep. + * The module a lane-tooling change proves itself against. Keep this to a real + * end-to-end sweep whose observed related-test scope fits the 30-minute job + * budget; mutant count alone does not capture replay cost. */ -export const LANE_CANARY: ModuleId = 'kernel-errors'; +export const LANE_CANARY: ModuleId = 'snapshot-occlusion'; /** One mutation job: a module, optionally one slice of it. */ export type ShardSpec = { name: string; module: ModuleId; shard?: string }; From 9d3eba862347772d653510f5b475edbc9592a121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 12:48:13 +0200 Subject: [PATCH 4/6] docs(mutation): describe budget-calibrated canary --- .github/workflows/mutation-affected.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/mutation-affected.yml b/.github/workflows/mutation-affected.yml index 7c0aef5391..2307c2f2cc 100644 --- a/.github/workflows/mutation-affected.yml +++ b/.github/workflows/mutation-affected.yml @@ -12,12 +12,12 @@ name: Mutation Affected # ownership instead would run the full ten-shard sweep on 24 of the last 40 # merged PRs. # -# On a harness diff the matrix is the canary (`LANE_CANARY`, the registry's -# cheapest real sweep — lane sources own no kernel, so derivation alone would -# select nothing) plus any kernel that same diff reaches through the import -# graph (scripts/mutation/ownership.ts): a kernel source, or a test that reaches -# one. Selected modules are sharded like the weekly sweep, so the PR's wall -# clock is one module rather than their sum. +# On a harness diff the matrix is the canary (`LANE_CANARY`, selected by +# observed end-to-end runtime within the job budget — lane sources own no kernel, +# so derivation alone would select nothing) plus any kernel that same diff +# reaches through the import graph (scripts/mutation/ownership.ts): a kernel +# source, or a test that reaches one. Selected modules are sharded like the +# weekly sweep, so the PR's wall clock is one module rather than their sum. on: pull_request: From 98b6e832c42edfa9a9a2c31efacf8bca0c3a1d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 15:35:09 +0200 Subject: [PATCH 5/6] fix(graph): preserve cycle representatives with shared traversal --- scripts/depgraph/model.test.ts | 26 ++++++++++++++++ scripts/depgraph/model.ts | 2 -- scripts/layering/model.ts | 55 +++++++++++----------------------- scripts/mutation/ownership.ts | 2 +- 4 files changed, 45 insertions(+), 40 deletions(-) diff --git a/scripts/depgraph/model.test.ts b/scripts/depgraph/model.test.ts index 46c99b501f..4a1b3f86ba 100644 --- a/scripts/depgraph/model.test.ts +++ b/scripts/depgraph/model.test.ts @@ -372,6 +372,32 @@ test('collectCycles separates gate-rejected value cycles from type-only and dyna ); }); +test('collectCycles keeps the representative and edge kinds for a chorded three-file SCC', () => { + const edges = resolveImportEdges( + sources({ + 'src/core/a.ts': "import './b.ts';\nimport './c.ts';", + 'src/core/b.ts': "import './c.ts';", + 'src/core/c.ts': "import './a.ts';\nimport type { B } from './b.ts';\nvoid import('./b.ts');", + }), + ); + assert.deepEqual(collectCycles(edges), [ + { kind: 'value', path: ['src/core/a.ts', 'src/core/b.ts', 'src/core/c.ts', 'src/core/a.ts'] }, + ]); +}); + +test('collectCycles preserves the first DFS back edge when a chord closes below the SCC root', () => { + const edges = resolveImportEdges( + sources({ + 'src/core/a.ts': "import './b.ts';", + 'src/core/b.ts': "import './c.ts';", + 'src/core/c.ts': "import './b.ts';\nimport './a.ts';", + }), + ); + assert.deepEqual(collectCycles(edges), [ + { kind: 'value', path: ['src/core/b.ts', 'src/core/c.ts', 'src/core/b.ts'] }, + ]); +}); + test('buildGraph reports zone membership, degrees, and cross-zone edge counts', () => { const files = sources({ 'packages/kernel/src/errors.ts': 'export const fail = 1;\n', diff --git a/scripts/depgraph/model.ts b/scripts/depgraph/model.ts index 4ce460e4ba..3743ed370d 100644 --- a/scripts/depgraph/model.ts +++ b/scripts/depgraph/model.ts @@ -19,8 +19,6 @@ import { genPostorder, getSuccessors, getTransitiveReduction } from '@statelyai/ import { edgeKind, importEdgeId, importGraph, VALUE_EDGES } from './import-graph.ts'; import type { EdgeKind } from './import-graph.ts'; -export type { EdgeKind } from './import-graph.ts'; - export const AUTHORITY_LABELS = [ 'vocabulary', 'capability', diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 561cc60776..4f44ee1346 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { PLATFORMS } from '@agent-device/kernel/device'; -import { genCycles, getStronglyConnectedComponents } from '@statelyai/graph'; +import { createGraph, genDFS, getStronglyConnectedComponents, getSubgraph } from '@statelyai/graph'; import { parseSync } from 'oxc-parser'; import { destructuredDynamicImportBindings, visitAst } from './layering-ast.ts'; import { declaredRootModuleZone } from './root-module-zones.ts'; @@ -404,47 +404,28 @@ export function resolveImportEdges( export function findValueImportCycles(edges: readonly ResolvedImportEdge[]): string[][] { const graph = importGraphFromResolvedEdges(edges, VALUE_EDGES); - const componentByFile = new Map(); - const cyclicComponents = new Set(); - const membersByComponent = new Map(); - const components = getStronglyConnectedComponents(graph); const selfCycleFiles = new Set( graph.edges.filter((edge) => edge.sourceId === edge.targetId).map((edge) => edge.sourceId), ); - - for (const [index, component] of components.entries()) { + const components = getStronglyConnectedComponents(graph).filter( + (component) => component.length > 1 || selfCycleFiles.has(component[0]!.id), + ); + const cycles = components.map((component) => { const memberIds = component.map(({ id }) => id); - if (memberIds.length < 2 && !selfCycleFiles.has(memberIds[0]!)) continue; - cyclicComponents.add(index); - membersByComponent.set(index, memberIds); - for (const id of memberIds) componentByFile.set(id, index); - } - - if (cyclicComponents.size === 0) return []; - - const edgesByComponent = new Map(); - for (const edge of edges) { - if (edge.dynamic || edge.typeOnly) continue; - const component = componentByFile.get(edge.file); - if (component === undefined || componentByFile.get(edge.target) !== component) continue; - const componentEdges = edgesByComponent.get(component) ?? []; - componentEdges.push(edge); - edgesByComponent.set(component, componentEdges); - } - - const cycles = [...cyclicComponents].map((component) => { - const componentGraph = importGraphFromResolvedEdges( - edgesByComponent.get(component) ?? [], - VALUE_EDGES, - membersByComponent.get(component), - ); - const firstCycle = genCycles(componentGraph).next(); - if (firstCycle.done) { - throw new Error( - `Expected a cycle inside strongly connected component: ${membersByComponent.get(component)!.join(', ')}`, - ); + const componentGraph = getSubgraph(graph, memberIds); + const firstEdgeByFile = new Map(); + for (const edge of componentGraph.edges) { + if (!firstEdgeByFile.has(edge.sourceId)) firstEdgeByFile.set(edge.sourceId, edge); } - return [firstCycle.value.source.id, ...firstCycle.value.steps.map(({ node }) => node.id)]; + // Inside an SCC every file has an outgoing edge. DFS finds its first back edge before + // backtracking, so following only the first outgoing edge preserves the reported cycle. + const firstEdgeGraph = createGraph({ + nodes: componentGraph.nodes, + edges: [...firstEdgeByFile.values()], + }); + const path = [...genDFS(firstEdgeGraph, [...memberIds].sort()[0]!)].map(({ id }) => id); + const target = firstEdgeByFile.get(path.at(-1)!)!.targetId; + return [...path.slice(path.indexOf(target)), target]; }); return cycles.sort((left, right) => left[0]!.localeCompare(right[0]!)); diff --git a/scripts/mutation/ownership.ts b/scripts/mutation/ownership.ts index b24db35b13..e61e54eeca 100644 --- a/scripts/mutation/ownership.ts +++ b/scripts/mutation/ownership.ts @@ -97,7 +97,7 @@ type Deriver = { ownersOf: (testFile: string) => ModuleId[]; }; -/** A deriver with caches shared across files — one graph walk per module, not per query. */ +/** Shares the tracked-source graph and mutated-source sets across test queries. */ export function ownershipDeriver(repoRoot: string): Deriver { const graph = ownershipGraph(repoRoot); const sources = KERNEL_MODULES.map((module) => ({ From 8ed2225f0811ad2932c34daf08942dfa20907a17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 15:47:23 +0200 Subject: [PATCH 6/6] test(depgraph): focus chord fixture on value-cycle selection --- scripts/depgraph/model.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/depgraph/model.test.ts b/scripts/depgraph/model.test.ts index 4a1b3f86ba..a9ca7deac4 100644 --- a/scripts/depgraph/model.test.ts +++ b/scripts/depgraph/model.test.ts @@ -372,12 +372,12 @@ test('collectCycles separates gate-rejected value cycles from type-only and dyna ); }); -test('collectCycles keeps the representative and edge kinds for a chorded three-file SCC', () => { +test('collectCycles keeps the value-cycle representative for a chorded three-file SCC', () => { const edges = resolveImportEdges( sources({ 'src/core/a.ts': "import './b.ts';\nimport './c.ts';", 'src/core/b.ts': "import './c.ts';", - 'src/core/c.ts': "import './a.ts';\nimport type { B } from './b.ts';\nvoid import('./b.ts');", + 'src/core/c.ts': "import './a.ts';", }), ); assert.deepEqual(collectCycles(edges), [