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: diff --git a/scripts/depgraph/import-graph.ts b/scripts/depgraph/import-graph.ts index b3cb50ffac..e17a9ea826 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,21 @@ 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; +}; + +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. @@ -37,7 +54,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 +73,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); + 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.test.ts b/scripts/depgraph/model.test.ts index 46c99b501f..a9ca7deac4 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 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';", + }), + ); + 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 fe723a212a..3743ed370d 100644 --- a/scripts/depgraph/model.ts +++ b/scripts/depgraph/model.ts @@ -16,9 +16,8 @@ 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'; - -export type EdgeKind = 'value' | 'type' | 'dynamic'; +import { edgeKind, importEdgeId, importGraph, VALUE_EDGES } from './import-graph.ts'; +import type { EdgeKind } from './import-graph.ts'; export const AUTHORITY_LABELS = [ 'vocabulary', @@ -164,12 +163,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/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..4f44ee1346 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 { 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'; +import { + importGraphFromResolvedEdges, + STATIC_EDGES, + VALUE_EDGES, +} from '../depgraph/import-graph.ts'; export type ImportEdge = { spec: string; @@ -397,91 +403,32 @@ 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)!)); - } - } - - 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[] { - 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 graph = importGraphFromResolvedEdges(edges, VALUE_EDGES); + const selfCycleFiles = new Set( + graph.edges.filter((edge) => edge.sourceId === edge.targetId).map((edge) => edge.sourceId), + ); + 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); + 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); } - stack.pop(); - active.delete(file); - return null; - } + // 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]; + }); - 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 +513,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..9f226590ab 100644 --- a/scripts/layering/provider-snapshot-presentation-policy.test.ts +++ b/scripts/layering/provider-snapshot-presentation-policy.test.ts @@ -77,6 +77,44 @@ 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';\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'], + [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..c104a349d3 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 = @@ -38,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; @@ -49,14 +57,14 @@ export function providerSnapshotPresentationViolations( function presentationImportViolations( providerFile: string, edgesByFile: ReadonlyMap, + graph: ReturnType, ): LayeringViolation[] { const origins = new Map(); const visited = new Set([providerFile]); - const queue = [providerFile]; const violations: LayeringViolation[] = []; - 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 +80,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/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 }; 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..e61e54eeca 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. */ @@ -113,16 +97,16 @@ 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 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);