From ad048b2c1e9cd2c9828ff26fe940bab2bcdbf899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 28 Jul 2026 15:16:59 +0000 Subject: [PATCH 1/2] feat(scripts): blast-radius query over the depgraph model (#1425) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/agents/testing.md | 30 ++++ package.json | 2 +- scripts/depgraph/README.md | 14 ++ scripts/depgraph/affected-run.ts | 275 ++++++++++++++++++++++++++++++ scripts/depgraph/affected.test.ts | 211 +++++++++++++++++++++++ scripts/depgraph/affected.ts | 187 ++++++++++++++++++++ scripts/depgraph/build.ts | 25 +-- scripts/depgraph/load.ts | 15 ++ 8 files changed, 747 insertions(+), 12 deletions(-) create mode 100644 scripts/depgraph/affected-run.ts create mode 100644 scripts/depgraph/affected.test.ts create mode 100644 scripts/depgraph/affected.ts create mode 100644 scripts/depgraph/load.ts diff --git a/docs/agents/testing.md b/docs/agents/testing.md index 232b7d173..6e9648f78 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -121,6 +121,36 @@ The plan documents the rule and changed path behind every selected check. Model and catalog live under `scripts/check-affected/`; the derivation is guarded by `pnpm check:affected:test` (the `Affected-check Selector` CI job). +## Before editing a shared module (`pnpm depgraph affected`) + +Before you touch a module other code depends on, run: + +```sh +pnpm depgraph affected src/utils/exec.ts # bounded text, for an agent's context budget +pnpm depgraph affected src/daemon/ref-frame.ts --json --limit 25 +``` + +The output tells you which gates to run and which live scenarios claim the behavior: + +- **dependents** — reverse reachability over the layering gate's value-edge graph + (`scripts/depgraph/model.ts`), split into direct and transitive, with a zone breakdown and the + widest dependents by their own fan-in. Type-only and dynamic dependents are excluded: a + type-only edge is free at runtime, and mixing them makes the count unactionable. +- **gates** — the check plan `scripts/check-affected/model.ts` selects for that dependent set. It + is the same selector `pnpm check:affected` runs, so the two cannot disagree; run them with + `pnpm check:affected --run`. +- **public commands whose handler chain reaches it** — the daemon route table + (`src/daemon/request-handler-chain.ts`) closed over value *and* dynamic edges, because handlers + are loaded through `import()`. +- **live scenario owners** — the iOS simulator coverage manifest's owning scenario for each of + those commands, when that manifest is in the tree. +- **guarantee-matrix rows** — the ADR 0011 cells (`src/contracts/interaction-guarantees.ts`) whose + `via` names the file, i.e. the guarantees your edit is the implementation of. + +Lists are bounded (`--limit`, default 10) and always disclose what they hid; `--json` is +unbounded. The query is read-only, runs in well under a second, and adds no CI work — its model is +covered by `pnpm depgraph:test` (the existing `Layering Guard` job). + ## Mutation ratchet over decision kernels Mutation score is the mechanical answer to "is this test load-bearing or decorative". A full-suite diff --git a/package.json b/package.json index 0da425b7d..e4eb45cc9 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "check:coverage-changed:test": "node --experimental-strip-types --test scripts/coverage-changed/model.test.ts scripts/coverage-changed/run.test.ts", "check:layering": "node --experimental-strip-types --test scripts/layering/model.test.ts && node --experimental-strip-types scripts/layering/check.ts", "depgraph": "node --experimental-strip-types scripts/depgraph/build.ts", - "depgraph:test": "node --experimental-strip-types --test scripts/depgraph/model.test.ts", + "depgraph:test": "node --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/affected.test.ts", "check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues", "check:bundle-owner-files": "node --experimental-strip-types scripts/check-bundle-owner-files.ts", "check:command-docs": "vitest run --project unit-core src/__tests__/command-doc-coverage.test.ts", diff --git a/scripts/depgraph/README.md b/scripts/depgraph/README.md index b3f34f5f7..82e5fe6e1 100644 --- a/scripts/depgraph/README.md +++ b/scripts/depgraph/README.md @@ -10,6 +10,20 @@ Emits the dependency graph of every production file under `src/` (tests excluded plus a short summary of what the layering gate does not enforce. There is no renderer: the productive artifact is the JSON, queried directly. +## Blast radius of one file + +```sh +pnpm depgraph affected src/utils/exec.ts # bounded text +pnpm depgraph affected src/daemon/ref-frame.ts --json --limit 25 +``` + +Reverse reachability over the value-edge graph, plus the three lookups that used to follow it: +the `scripts/check-affected/` gate plan for the dependent set, the public commands whose handler +chain reaches the file (value + dynamic edges, because handlers load through `import()`) with +their owning live iOS scenarios when that manifest is in the tree, and the ADR 0011 +guarantee-matrix cells the file implements. See docs/agents/testing.md § "Before editing a shared +module". + ## When to reach for this It pays for itself on three questions, and misleads on a fourth. diff --git a/scripts/depgraph/affected-run.ts b/scripts/depgraph/affected-run.ts new file mode 100644 index 000000000..7b5e10b82 --- /dev/null +++ b/scripts/depgraph/affected-run.ts @@ -0,0 +1,275 @@ +// `pnpm depgraph affected ` — the blast radius of one file in one query. +// +// pnpm depgraph affected src/utils/exec.ts +// pnpm depgraph affected src/daemon/ref-frame.ts --json --limit 20 +// +// Answers, from the sources of truth rather than a second copy of them: +// - dependents (direct / transitive, with a zone breakdown) — reverse reachability over the +// layering gate's own value-edge graph (scripts/depgraph/model.ts); +// - the gates that dependent set selects — `scripts/check-affected/model.ts`, the same +// selector `pnpm check:affected` runs, so the two cannot disagree; +// - the live iOS simulator scenarios that claim the public commands whose handler chain +// reaches the file — the coverage manifest, when it is present in the tree; +// - the ADR 0011 guarantee-matrix cells the file implements. +// +// Output is bounded on purpose (lists cut to `--limit` with an explicit "+N more"): the reader +// is usually an agent about to spend its context on the edit itself. `--json` is unbounded. + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { PUBLIC_COMMANDS } from '../../src/command-catalog.ts'; +import { + INTERACTION_DISPATCH_PATHS, + type InteractionPathId, +} from '../../src/contracts/interaction-guarantees.ts'; +import { DAEMON_COMMAND_DESCRIPTORS } from '../../src/daemon/daemon-command-registry.ts'; +import { selectChecks, type CheckPlan } from '../check-affected/model.ts'; +import { parseScriptArgs } from '../lib/cli-args.ts'; +import { + bound, + collectDependents, + commandsReaching, + formatBounded, + guaranteeRowsForFile, + rankByFanIn, + weakDirectDependents, + zoneBreakdown, + type CommandChain, + type GuaranteeRow, +} from './affected.ts'; +import { loadGraph } from './load.ts'; +import type { GraphNode } from './model.ts'; + +const USAGE = + 'Usage: pnpm depgraph affected [--json] [--limit ]\n' + + '\n' + + ' production file under src/ (repo-relative or absolute)\n' + + ' --json unbounded machine-readable output\n' + + ' --limit entries per list in text output (default 10)\n'; + +const HANDLER_CHAIN_FILE = 'src/daemon/request-handler-chain.ts'; + +/** + * The live iOS coverage manifest (#1408) is consumed through the narrowest shape this query + * needs — command name to owning scenario — so this lane does not depend on the rest of that + * manifest's vocabulary, and reports honestly when it is not in the tree yet. + */ +const LIVE_COVERAGE_MANIFEST = 'test/integration/ios-simulator-e2e/coverage-manifest.ts'; +const LIVE_COVERAGE_EXPORT = 'IOS_SIMULATOR_E2E_COVERAGE'; + +type LiveCoverageEntry = { level: string; owner: unknown; assertion: string }; +type LiveOwner = { command: string; level: string; owner: string; assertion: string }; + +export type BlastRadius = { + file: string; + zone: string; + fanIn: number; + fanOut: number; + dependents: { + direct: string[]; + transitive: string[]; + byZone: { zone: string; count: number }[]; + topByFanIn: { file: string; fanIn: number }[]; + /** Direct importers over the excluded edge kinds — reported, never mixed into the counts. */ + weakDirect: { type: number; dynamic: number }; + }; + gates: { checks: string[]; failOpen: boolean }; + commands: CommandChain[]; + liveOwners: { manifest: string; present: boolean; owners: LiveOwner[] }; + guaranteeRows: GuaranteeRow[]; +}; + +function parseInvocation(argv: readonly string[]): { file: string; json: boolean; limit: number } { + const positionals = argv.filter((arg) => !arg.startsWith('-')); + const flags = argv.filter((arg) => arg.startsWith('-')); + const values = parseScriptArgs(flags, USAGE, { + json: { type: 'boolean', default: false }, + limit: { type: 'string', default: '10' }, + }); + const target = positionals[0]; + if (target === undefined) throw new Error(`missing .\n${USAGE}`); + if (positionals.length > 1) throw new Error(`expected one , got ${positionals.length}.`); + const limit = Number(values.limit); + if (!Number.isInteger(limit) || limit < 1) throw new Error(`--limit must be a positive integer`); + return { file: target, json: Boolean(values.json), limit }; +} + +/** Accept an absolute path, a `./`-prefixed one, or the repo-relative form the graph uses. */ +function normalizeTarget(target: string, repoRoot: string): string { + const absolute = path.resolve(repoRoot, target); + return path.relative(repoRoot, absolute).split(path.sep).join('/'); +} + +/** + * Route to handler-entry module, read out of the daemon's own route table rather than + * duplicated here. A route the table stops declaring becomes a loud failure at the call site, + * which is the point: a silently empty command list would read as "nothing owns this file". + */ +export function parseRouteEntries(source: string, chainFile: string): Map { + const directory = path.posix.dirname(chainFile); + const entries = new Map(); + const specifierFor = (alias: string): string | undefined => + new RegExp(`import \\* as ${alias} from '([^']+)'`).exec(source)?.[1]; + + for (const match of source.matchAll( + /(\w+): defineDaemonRoute\(\{\s*load: (?:\(\) => import\('([^']+)'\)|async \(\) => (\w+))/g, + )) { + const specifier = match[2] ?? (match[3] ? specifierFor(match[3]) : undefined); + if (!specifier) continue; + entries.set(match[1]!, path.posix.normalize(path.posix.join(directory, specifier))); + } + return entries; +} + +function commandChains(repoRoot: string): CommandChain[] { + const source = fs.readFileSync(path.join(repoRoot, HANDLER_CHAIN_FILE), 'utf8'); + const routeEntries = parseRouteEntries(source, HANDLER_CHAIN_FILE); + const publicCommands = new Set(Object.values(PUBLIC_COMMANDS)); + return DAEMON_COMMAND_DESCRIPTORS.filter((descriptor) => + publicCommands.has(descriptor.command), + ).map((descriptor) => { + const entry = routeEntries.get(descriptor.route); + if (!entry) { + throw new Error( + `no handler entry for daemon route "${descriptor.route}" — the route table in ` + + `${HANDLER_CHAIN_FILE} changed shape; update parseRouteEntries in ` + + `scripts/depgraph/affected-run.ts.`, + ); + } + return { command: descriptor.command, route: descriptor.route, entry }; + }); +} + +/** Flatten the ADR 0011 matrix into one row per (path, guarantee) cell that names a module. */ +export function guaranteeRows(): GuaranteeRow[] { + const rows: GuaranteeRow[] = []; + for (const [pathId, contract] of Object.entries(INTERACTION_DISPATCH_PATHS) as [ + InteractionPathId, + (typeof INTERACTION_DISPATCH_PATHS)[InteractionPathId], + ][]) { + for (const [guarantee, cell] of Object.entries(contract.guarantees)) { + if (!('via' in cell)) continue; + rows.push({ path: pathId, guarantee, kind: cell.kind, via: cell.via }); + } + } + return rows; +} + +function liveOwnerName(owner: unknown): string { + if (typeof owner === 'string') return owner; + if (owner && typeof owner === 'object' && 'path' in owner) { + const evidence = owner as { path: string; test?: string }; + return evidence.test ? `${evidence.path} — ${evidence.test}` : evidence.path; + } + return '(unknown owner)'; +} + +async function loadLiveOwners( + repoRoot: string, + commands: readonly CommandChain[], +): Promise<{ present: boolean; owners: LiveOwner[] }> { + const manifestPath = path.join(repoRoot, LIVE_COVERAGE_MANIFEST); + if (!fs.existsSync(manifestPath)) return { present: false, owners: [] }; + const loaded = (await import(pathToFileURL(manifestPath).href)) as Record; + const manifest = loaded[LIVE_COVERAGE_EXPORT] as Record | undefined; + if (!manifest) return { present: false, owners: [] }; + return { + present: true, + owners: commands.flatMap((chain) => { + const entry = manifest[chain.command]; + if (!entry) return []; + return [ + { + command: chain.command, + level: entry.level, + owner: liveOwnerName(entry.owner), + assertion: entry.assertion, + }, + ]; + }), + }; +} + +export async function computeBlastRadius(repoRoot: string, target: string): Promise { + const graph = loadGraph(repoRoot); + const nodes = new Map(graph.nodes.map((node) => [node.id, node])); + const node = nodes.get(target); + if (!node) { + throw new Error( + `${target} is not a production source file in the layering graph ` + + `(the graph covers git-tracked src/**/*.ts, tests excluded).`, + ); + } + + const dependents = collectDependents(target, graph.edges); + const plan: CheckPlan = selectChecks({ changedFiles: [target, ...dependents.all] }); + const commands = commandsReaching(target, commandChains(repoRoot), graph.edges); + const live = await loadLiveOwners(repoRoot, commands); + + return { + file: target, + zone: node.zone, + fanIn: node.fanIn, + fanOut: node.fanOut, + dependents: { + direct: dependents.direct, + transitive: dependents.transitiveOnly, + byZone: zoneBreakdown(dependents.all, nodes), + topByFanIn: rankByFanIn(dependents.all, nodes), + weakDirect: weakDirectDependents(target, graph.edges), + }, + gates: { checks: plan.checks, failOpen: plan.failOpen }, + commands, + liveOwners: { manifest: LIVE_COVERAGE_MANIFEST, present: live.present, owners: live.owners }, + guaranteeRows: guaranteeRowsForFile(target, guaranteeRows()), + }; +} + +/** Bounded text form — the shape an agent reads before spending its context on the edit. */ +export function formatBlastRadius(radius: BlastRadius, limit: number): string { + const { dependents } = radius; + const lines = [ + `blast radius: ${radius.file} [zone ${radius.zone}, fan-in ${radius.fanIn}, fan-out ${radius.fanOut}]`, + `dependents: ${dependents.direct.length} direct, ${dependents.transitive.length} transitive (value edges; ` + + `${dependents.weakDirect.type} type-only and ${dependents.weakDirect.dynamic} dynamic direct importers not counted)`, + ` by zone: ${formatBounded(bound(dependents.byZone, limit), (entry) => `${entry.zone} ${entry.count}`)}`, + ` direct: ${formatBounded(bound(dependents.direct, limit), (file) => file)}`, + ` widest: ${formatBounded(bound(dependents.topByFanIn, limit), (entry) => `${entry.file} (fan-in ${entry.fanIn})`)}`, + `gates for this set${radius.gates.failOpen ? ' (fail-open: full set)' : ''}: ${radius.gates.checks.join(', ') || '(none)'}`, + ` run: pnpm check:affected --run`, + `public commands whose handler chain reaches it (${radius.commands.length}): ` + + formatBounded(bound(radius.commands, limit), (chain) => `${chain.command} (${chain.route})`), + ]; + + if (!radius.liveOwners.present) { + lines.push( + `live scenario owners: ${radius.liveOwners.manifest} is not in this tree (lands with the iOS simulator e2e manifest) — no live claim available`, + ); + } else { + lines.push(`live scenario owners (${radius.liveOwners.owners.length}):`); + for (const owner of bound(radius.liveOwners.owners, limit).shown) { + lines.push(` ${owner.command} [${owner.level}] ${owner.owner} — ${owner.assertion}`); + } + const hidden = bound(radius.liveOwners.owners, limit).hidden; + if (hidden > 0) lines.push(` (+${hidden} more)`); + } + + lines.push(`guarantee-matrix rows implemented here (${radius.guaranteeRows.length}):`); + for (const row of bound(radius.guaranteeRows, limit).shown) { + lines.push(` ${row.path}/${row.guarantee} [${row.kind}] ${row.via}`); + } + const hiddenRows = bound(radius.guaranteeRows, limit).hidden; + if (hiddenRows > 0) lines.push(` (+${hiddenRows} more)`); + + return `${lines.join('\n')}\n`; +} + +export async function runAffected(argv: readonly string[], repoRoot: string): Promise { + const { file, json, limit } = parseInvocation(argv); + const radius = await computeBlastRadius(repoRoot, normalizeTarget(file, repoRoot)); + process.stdout.write( + json ? `${JSON.stringify(radius, null, 2)}\n` : formatBlastRadius(radius, limit), + ); + return 0; +} diff --git a/scripts/depgraph/affected.test.ts b/scripts/depgraph/affected.test.ts new file mode 100644 index 000000000..e4550ef03 --- /dev/null +++ b/scripts/depgraph/affected.test.ts @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import { selectChecks } from '../check-affected/model.ts'; +import { resolveImportEdges } from '../layering/model.ts'; +import { + bound, + collectDependents, + commandsReaching, + formatBounded, + guaranteeRowsForFile, + rankByFanIn, + weakDirectDependents, + zoneBreakdown, +} from './affected.ts'; +import { + computeBlastRadius, + formatBlastRadius, + guaranteeRows, + parseRouteEntries, +} from './affected-run.ts'; +import { collapseEdges, type GraphNode } from './model.ts'; + +const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', +}).trim(); + +function edgesOf(entries: Record) { + return collapseEdges(resolveImportEdges(new Map(Object.entries(entries)))); +} + +function nodesOf(entries: Record>): Map { + return new Map( + Object.entries(entries).map(([id, node]) => [ + id, + { id, zone: 'core', loc: 1, fanIn: 0, fanOut: 0, cycle: -1, ...node }, + ]), + ); +} + +test('collectDependents separates direct importers from transitive ones', () => { + const edges = edgesOf({ + 'src/core/target.ts': 'export const t = 1;', + 'src/core/direct.ts': "export { t } from './target.ts';", + 'src/core/far.ts': "import { t } from './direct.ts';\nexport const f = t;", + 'src/core/unrelated.ts': 'export const u = 1;', + }); + + assert.deepEqual(collectDependents('src/core/target.ts', edges), { + direct: ['src/core/direct.ts'], + all: ['src/core/direct.ts', 'src/core/far.ts'], + transitiveOnly: ['src/core/far.ts'], + }); +}); + +test('collectDependents ignores type-only and dynamic dependents, which are counted separately', () => { + const edges = edgesOf({ + 'src/core/target.ts': 'export type T = string;\nexport const t = 1;', + 'src/core/typed.ts': "import type { T } from './target.ts';\nexport type U = T;", + 'src/core/lazy.ts': "export const load = () => import('./target.ts');", + }); + + assert.deepEqual(collectDependents('src/core/target.ts', edges).all, []); + assert.deepEqual(weakDirectDependents('src/core/target.ts', edges), { type: 1, dynamic: 1 }); +}); + +test('zoneBreakdown and rankByFanIn order by weight, then name', () => { + const nodes = nodesOf({ + 'src/daemon/a.ts': { zone: 'daemon-server', fanIn: 3 }, + 'src/daemon/b.ts': { zone: 'daemon-server', fanIn: 9 }, + 'src/core/c.ts': { zone: 'core', fanIn: 9 }, + }); + const files = ['src/daemon/a.ts', 'src/daemon/b.ts', 'src/core/c.ts']; + + assert.deepEqual(zoneBreakdown(files, nodes), [ + { zone: 'daemon-server', count: 2 }, + { zone: 'core', count: 1 }, + ]); + assert.deepEqual( + rankByFanIn(files, nodes).map((entry) => entry.file), + ['src/core/c.ts', 'src/daemon/b.ts', 'src/daemon/a.ts'], + ); +}); + +test('commandsReaching follows the dynamic import a route uses to load its handler', () => { + const edges = edgesOf({ + 'src/daemon/handlers/session.ts': + "import { helper } from '../../utils/helper.ts';\nexport const h = helper;", + 'src/daemon/handlers/find.ts': 'export const f = 1;', + 'src/utils/helper.ts': 'export const helper = 1;', + 'src/daemon/chain.ts': [ + "export const routes = { session: () => import('./handlers/session.ts'),", + " find: () => import('./handlers/find.ts') };", + ].join('\n'), + }); + const chains = [ + { command: 'open', route: 'session', entry: 'src/daemon/handlers/session.ts' }, + { command: 'find', route: 'find', entry: 'src/daemon/handlers/find.ts' }, + ]; + + assert.deepEqual( + commandsReaching('src/utils/helper.ts', chains, edges).map((chain) => chain.command), + ['open'], + ); + // The entry module itself counts as part of its own chain. + assert.deepEqual( + commandsReaching('src/daemon/handlers/find.ts', chains, edges).map((chain) => chain.command), + ['find'], + ); +}); + +test('guaranteeRowsForFile matches module-qualified cells only, on the whole path', () => { + const rows = [ + { + path: 'runtime-selector', + guarantee: 'disambiguation', + kind: 'runtime', + via: 'src/selectors/resolve.ts#resolveSelectorChain', + }, + { + path: 'runtime-ref', + guarantee: 'occlusion', + kind: 'runtime', + via: 'src/selectors/resolve-extra.ts#other', + }, + { + path: 'native-ref', + guarantee: 'errorTaxonomy', + kind: 'delegated', + via: 'runner errors fall back to tree resolution', + }, + ]; + + assert.deepEqual( + guaranteeRowsForFile('src/selectors/resolve.ts', rows).map((row) => row.guarantee), + ['disambiguation'], + ); +}); + +test('bounded lists disclose what they hid', () => { + assert.deepEqual(bound([1, 2, 3, 4], 2), { shown: [1, 2], hidden: 2 }); + assert.equal(formatBounded(bound([1, 2, 3, 4], 2), String), '1, 2 (+2 more)'); + assert.equal(formatBounded(bound([1, 2], 5), String), '1, 2'); + assert.equal(formatBounded(bound([], 5), String), '(none)'); +}); + +test('every daemon route in the real route table resolves to a handler entry', () => { + const chainFile = 'src/daemon/request-handler-chain.ts'; + const source = readFileSync(path.join(repoRoot, chainFile), 'utf8'); + const entries = parseRouteEntries(source, chainFile); + const declared = [...source.matchAll(/(\w+): defineDaemonRoute\(/g)].map((match) => match[1]!); + + assert.ok(declared.length > 0, 'route table shape changed: no defineDaemonRoute entries found'); + assert.deepEqual([...entries.keys()].sort(), [...declared].sort()); + assert.equal(entries.get('generic'), 'src/daemon/request-generic-dispatch.ts'); +}); + +test('the real guarantee matrix contributes module-qualified rows', () => { + const rows = guaranteeRows(); + assert.ok(rows.length > 0); + assert.ok( + rows.some((row) => row.via.startsWith('src/') && row.via.includes('#')), + 'expected at least one runtime cell naming a module', + ); +}); + +test('text output bounds every list and says what it hid', () => { + const text = formatBlastRadius( + { + file: 'src/utils/exec.ts', + zone: 'utils', + fanIn: 81, + fanOut: 3, + dependents: { + direct: ['src/a.ts', 'src/b.ts', 'src/c.ts'], + transitive: ['src/d.ts'], + byZone: [{ zone: 'utils', count: 4 }], + topByFanIn: [{ file: 'src/a.ts', fanIn: 7 }], + weakDirect: { type: 2, dynamic: 1 }, + }, + gates: { checks: ['lint', 'typecheck'], failOpen: false }, + commands: [{ command: 'click', route: 'interaction', entry: 'src/daemon/handlers/x.ts' }], + liveOwners: { manifest: 'test/…/coverage-manifest.ts', present: false, owners: [] }, + guaranteeRows: [], + }, + 2, + ); + + assert.match(text, /3 direct, 1 transitive .*2 type-only and 1 dynamic/); + assert.match(text, /direct: {2}src\/a\.ts, src\/b\.ts \(\+1 more\)/); + assert.match(text, /gates for this set: lint, typecheck/); + assert.match(text, /is not in this tree/); +}); + +test('blast radius over the real tree agrees with check:affected and stays under 10s', async () => { + const file = 'src/utils/exec.ts'; + const started = Date.now(); + const radius = await computeBlastRadius(repoRoot, file); + const elapsed = Date.now() - started; + + assert.ok(elapsed < 10_000, `blast radius took ${elapsed}ms`); + assert.ok(radius.dependents.direct.length > 0); + + // The gate plan is check:affected's own selection over the dependent set, so it can only ever + // be a superset of the plan for the file alone — a contradiction would mean an edit selected + // fewer gates the more files it touched. + const single = selectChecks({ changedFiles: [file] }); + for (const check of single.checks) assert.ok(radius.gates.checks.includes(check), check); +}); diff --git a/scripts/depgraph/affected.ts b/scripts/depgraph/affected.ts new file mode 100644 index 000000000..58fb1c3c6 --- /dev/null +++ b/scripts/depgraph/affected.ts @@ -0,0 +1,187 @@ +// Blast-radius model — "who depends on this file, and which gates and live scenarios own them". +// +// Pure functions only: every input (graph edges, command routes, guarantee matrix rows, live +// coverage manifest) is passed in by `affected-run.ts`, so the traversal and the bounded +// presentation are testable without touching the tree, git, or the daemon. +// +// The graph itself comes from `scripts/depgraph/model.ts`, which is extracted with the layering +// gate's own model — the dependents reported here are exactly the edges CI enforces. + +import type { GraphEdge, GraphNode } from './model.ts'; + +/** Reverse adjacency over value edges — the subgraph R4 keeps acyclic. */ +function valuePredecessors(edges: readonly GraphEdge[]): Map { + const predecessors = new Map(); + for (const edge of edges) { + if (edge.kind !== 'value') continue; + const list = predecessors.get(edge.to) ?? []; + list.push(edge.from); + predecessors.set(edge.to, list); + } + return predecessors; +} + +/** + * Forward adjacency over the edges a module actually executes: value imports plus dynamic + * ones. Dynamic edges matter here because the daemon loads every command handler through + * `import()` (`src/daemon/request-handler-chain.ts`) — dropping them would report that no + * command's handler chain reaches anything. + */ +function executableSuccessors(edges: readonly GraphEdge[]): Map { + const successors = new Map(); + for (const edge of edges) { + if (edge.kind === 'type') continue; + const list = successors.get(edge.from) ?? []; + list.push(edge.to); + successors.set(edge.from, list); + } + return successors; +} + +/** Breadth-first closure from `start`, excluding `start` itself. */ +function reachable(start: string, adjacency: ReadonlyMap): Set { + const seen = new Set([start]); + const queue = [start]; + for (let index = 0; index < queue.length; index++) { + for (const next of adjacency.get(queue[index]!) ?? []) { + if (seen.has(next)) continue; + seen.add(next); + queue.push(next); + } + } + seen.delete(start); + return seen; +} + +export type DependentSet = { + /** Files that import the target directly (value edges). */ + direct: string[]; + /** Every file that reaches the target over value edges, direct ones included. */ + all: string[]; + /** `all` minus `direct`, i.e. reachable only through at least one hop. */ + transitiveOnly: string[]; +}; + +/** + * Reverse reachability over value edges: everything that would recompile, retype, or re-run + * because of an edit to `file`. Type-only and dynamic dependents are deliberately excluded — + * they are a different question (a type-only edge is free at runtime) and mixing them would + * make the count unactionable. + */ +export function collectDependents(file: string, edges: readonly GraphEdge[]): DependentSet { + const predecessors = valuePredecessors(edges); + const direct = [...new Set(predecessors.get(file) ?? [])].sort(); + const all = [...reachable(file, predecessors)].sort(); + const directSet = new Set(direct); + return { direct, all, transitiveOnly: all.filter((entry) => !directSet.has(entry)) }; +} + +/** + * Direct importers over the edge kinds `collectDependents` leaves out. A pure vocabulary module + * has zero value dependents and dozens of type-only ones; reporting only the value count there + * would read as "nothing depends on this", which is the opposite of true. + */ +export function weakDirectDependents( + file: string, + edges: readonly GraphEdge[], +): { type: number; dynamic: number } { + let type = 0; + let dynamic = 0; + for (const edge of edges) { + if (edge.to !== file) continue; + if (edge.kind === 'type') type++; + if (edge.kind === 'dynamic') dynamic++; + } + return { type, dynamic }; +} + +export type ZoneCount = { zone: string; count: number }; + +/** Dependent count per zone, biggest first — "which boundaries does this edit cross". */ +export function zoneBreakdown( + files: readonly string[], + nodes: ReadonlyMap, +): ZoneCount[] { + const counts = new Map(); + for (const file of files) { + const zone = nodes.get(file)?.zone ?? '(unknown)'; + counts.set(zone, (counts.get(zone) ?? 0) + 1); + } + return [...counts] + .map(([zone, count]) => ({ zone, count })) + .sort((left, right) => right.count - left.count || left.zone.localeCompare(right.zone)); +} + +/** Dependents ordered by their own fan-in: the ones whose breakage spreads furthest. */ +export function rankByFanIn( + files: readonly string[], + nodes: ReadonlyMap, +): { file: string; fanIn: number }[] { + return files + .map((file) => ({ file, fanIn: nodes.get(file)?.fanIn ?? 0 })) + .sort((left, right) => right.fanIn - left.fanIn || left.file.localeCompare(right.file)); +} + +export type CommandChain = { + command: string; + /** Daemon route that owns the command (`src/daemon/daemon-command-registry.ts`). */ + route: string; + /** Handler entry module the route loads. */ + entry: string; +}; + +/** + * Public commands whose handler chain reaches `file`. The chain is the executable closure of + * the route's handler entry module, so a command claims the file when its handler can actually + * run the code — not merely when the names look related. + */ +export function commandsReaching( + file: string, + chains: readonly CommandChain[], + edges: readonly GraphEdge[], +): CommandChain[] { + const successors = executableSuccessors(edges); + const closures = new Map>(); + const closureFor = (entry: string): Set => { + let closure = closures.get(entry); + if (!closure) { + closure = reachable(entry, successors); + closure.add(entry); + closures.set(entry, closure); + } + return closure; + }; + return chains + .filter((chain) => closureFor(chain.entry).has(file)) + .sort((left, right) => left.command.localeCompare(right.command)); +} + +export type GuaranteeRow = { + path: string; + guarantee: string; + kind: string; + via: string; +}; + +/** + * ADR 0011 matrix rows implemented by `file`. A cell's `via` is + * `#` for runtime/runner cells and prose for delegated ones, so only the + * module-qualified form can be matched — prose cells belong to no file by construction. + */ +export function guaranteeRowsForFile(file: string, rows: readonly GuaranteeRow[]): GuaranteeRow[] { + return rows.filter((row) => row.via.split('#')[0] === file); +} + +/** A bounded slice plus the number of entries it hid, so counts never lie to the reader. */ +export type Bounded = { shown: T[]; hidden: number }; + +export function bound(items: readonly T[], limit: number): Bounded { + return { shown: items.slice(0, limit), hidden: Math.max(0, items.length - limit) }; +} + +/** `a, b, c (+4 more)` — the one place the "+N more" convention is spelled. */ +export function formatBounded(bounded: Bounded, render: (item: T) => string): string { + const rendered = bounded.shown.map(render).join(', '); + if (bounded.hidden === 0) return rendered || '(none)'; + return `${rendered} (+${bounded.hidden} more)`; +} diff --git a/scripts/depgraph/build.ts b/scripts/depgraph/build.ts index 044811b7d..7de35183d 100644 --- a/scripts/depgraph/build.ts +++ b/scripts/depgraph/build.ts @@ -15,9 +15,10 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { listSourceFiles } from '../layering/check.ts'; -import { resolveImportEdges, zoneRank } from '../layering/model.ts'; -import { buildGraph, computeLevels, type GraphData } from './model.ts'; +import { zoneRank } from '../layering/model.ts'; +import { runAffected } from './affected-run.ts'; +import { loadGraph } from './load.ts'; +import { computeLevels, type GraphData } from './model.ts'; const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', @@ -76,12 +77,7 @@ function edgeFlags(edge: GraphData['edges'][number]): number { } function buildPayload(): Payload { - const files = listSourceFiles(); - const sources = new Map( - files.map((file) => [file, fs.readFileSync(path.join(repoRoot, file), 'utf8')]), - ); - const resolved = resolveImportEdges(sources); - const graph = buildGraph(sources, resolved); + const graph = loadGraph(repoRoot); const levels = computeLevels(graph.nodes, graph.edges); const zoneIndex = new Map(graph.zones.map((zone, index) => [zone.id, index])); @@ -114,7 +110,11 @@ function buildPayload(): Payload { }; } -function main(argv: readonly string[]): number { +async function main(argv: readonly string[]): Promise { + // `affected` is the query subcommand (scripts/depgraph/affected-run.ts); with no subcommand + // this stays the whole-graph report. + if (argv[0] === 'affected') return await runAffected(argv.slice(1), repoRoot); + const outFlag = argv.indexOf('--out'); const jsonPath = outFlag >= 0 && argv[outFlag + 1] @@ -145,5 +145,8 @@ function main(argv: readonly string[]): number { } if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { - process.exit(main(process.argv.slice(2))); + process.exitCode = await main(process.argv.slice(2)).catch((error: unknown) => { + process.stderr.write(`depgraph: ${error instanceof Error ? error.message : error}\n`); + return 1; + }); } diff --git a/scripts/depgraph/load.ts b/scripts/depgraph/load.ts new file mode 100644 index 000000000..de13e1e6c --- /dev/null +++ b/scripts/depgraph/load.ts @@ -0,0 +1,15 @@ +// The one place the graph is read off disk, shared by the whole-graph report and the +// `affected` query so both describe the same tree by construction. + +import fs from 'node:fs'; +import path from 'node:path'; +import { listSourceFiles } from '../layering/check.ts'; +import { resolveImportEdges } from '../layering/model.ts'; +import { buildGraph, type GraphData } from './model.ts'; + +export function loadGraph(repoRoot: string): GraphData { + const sources = new Map( + listSourceFiles().map((file) => [file, fs.readFileSync(path.join(repoRoot, file), 'utf8')]), + ); + return buildGraph(sources, resolveImportEdges(sources)); +} From 61488f71dbdd627eb9b14f0862f6312fa19355b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 28 Jul 2026 15:39:22 +0000 Subject: [PATCH 2/2] fix(scripts): keep --limit bound to its value in depgraph affected Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/depgraph/affected-run.ts | 40 +++++++++++++++++++++++-------- scripts/depgraph/affected.test.ts | 21 ++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/scripts/depgraph/affected-run.ts b/scripts/depgraph/affected-run.ts index 7b5e10b82..ad316dcbe 100644 --- a/scripts/depgraph/affected-run.ts +++ b/scripts/depgraph/affected-run.ts @@ -24,8 +24,8 @@ import { type InteractionPathId, } from '../../src/contracts/interaction-guarantees.ts'; import { DAEMON_COMMAND_DESCRIPTORS } from '../../src/daemon/daemon-command-registry.ts'; +import { parseArgs } from 'node:util'; import { selectChecks, type CheckPlan } from '../check-affected/model.ts'; -import { parseScriptArgs } from '../lib/cli-args.ts'; import { bound, collectDependents, @@ -80,18 +80,33 @@ export type BlastRadius = { guaranteeRows: GuaranteeRow[]; }; -function parseInvocation(argv: readonly string[]): { file: string; json: boolean; limit: number } { - const positionals = argv.filter((arg) => !arg.startsWith('-')); - const flags = argv.filter((arg) => arg.startsWith('-')); - const values = parseScriptArgs(flags, USAGE, { - json: { type: 'boolean', default: false }, - limit: { type: 'string', default: '10' }, +export type Invocation = { file: string; json: boolean; limit: number } | { help: true }; + +/** + * `parseArgs` with positionals enabled rather than the shared `parseScriptArgs` helper, which + * rejects them: partitioning argv by a leading dash would detach `--limit` from its value. + */ +export function parseInvocation(argv: readonly string[]): Invocation { + const { values, positionals } = parseArgs({ + args: [...argv], + options: { + json: { type: 'boolean', default: false }, + limit: { type: 'string', default: '10' }, + help: { type: 'boolean', short: 'h', default: false }, + }, + allowPositionals: true, }); + if (values.help) return { help: true }; + const target = positionals[0]; if (target === undefined) throw new Error(`missing .\n${USAGE}`); - if (positionals.length > 1) throw new Error(`expected one , got ${positionals.length}.`); + if (positionals.length > 1) { + throw new Error(`expected one , got ${positionals.length}: ${positionals.join(' ')}`); + } const limit = Number(values.limit); - if (!Number.isInteger(limit) || limit < 1) throw new Error(`--limit must be a positive integer`); + if (!Number.isInteger(limit) || limit < 1) { + throw new Error(`--limit must be a positive integer, got "${values.limit}"`); + } return { file: target, json: Boolean(values.json), limit }; } @@ -266,7 +281,12 @@ export function formatBlastRadius(radius: BlastRadius, limit: number): string { } export async function runAffected(argv: readonly string[], repoRoot: string): Promise { - const { file, json, limit } = parseInvocation(argv); + const invocation = parseInvocation(argv); + if ('help' in invocation) { + process.stdout.write(USAGE); + return 0; + } + const { file, json, limit } = invocation; const radius = await computeBlastRadius(repoRoot, normalizeTarget(file, repoRoot)); process.stdout.write( json ? `${JSON.stringify(radius, null, 2)}\n` : formatBlastRadius(radius, limit), diff --git a/scripts/depgraph/affected.test.ts b/scripts/depgraph/affected.test.ts index e4550ef03..732718281 100644 --- a/scripts/depgraph/affected.test.ts +++ b/scripts/depgraph/affected.test.ts @@ -19,6 +19,7 @@ import { computeBlastRadius, formatBlastRadius, guaranteeRows, + parseInvocation, parseRouteEntries, } from './affected-run.ts'; import { collapseEdges, type GraphNode } from './model.ts'; @@ -166,6 +167,26 @@ test('the real guarantee matrix contributes module-qualified rows', () => { ); }); +test('a flag keeps its value whichever side of the path it is on', () => { + const expected = { file: 'src/utils/exec.ts', json: true, limit: 25 }; + assert.deepEqual(parseInvocation(['src/utils/exec.ts', '--json', '--limit', '25']), expected); + assert.deepEqual(parseInvocation(['--json', '--limit', '25', 'src/utils/exec.ts']), expected); + assert.deepEqual(parseInvocation(['src/utils/exec.ts', '--json', '--limit=25']), expected); + assert.deepEqual(parseInvocation(['src/utils/exec.ts']), { + file: 'src/utils/exec.ts', + json: false, + limit: 10, + }); + assert.deepEqual(parseInvocation(['--help']), { help: true }); +}); + +test('parseInvocation rejects a missing path, a second path, and a non-positive limit', () => { + assert.throws(() => parseInvocation(['--json']), /missing /); + assert.throws(() => parseInvocation(['a.ts', 'b.ts']), /expected one , got 2: a\.ts b\.ts/); + assert.throws(() => parseInvocation(['a.ts', '--limit', '0']), /positive integer/); + assert.throws(() => parseInvocation(['a.ts', '--limit', 'lots']), /positive integer/); +}); + test('text output bounds every list and says what it hid', () => { const text = formatBlastRadius( {