Skip to content

Commit 74ff5d0

Browse files
thymikeeclaude
andcommitted
fix(depgraph): expose incomplete impact attribution and dynamic dependents
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
1 parent 144bd59 commit 74ff5d0

4 files changed

Lines changed: 221 additions & 26 deletions

File tree

‎scripts/depgraph/README.md‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,29 @@ pnpm depgraph affected packages/host-kit/src/command.ts # bounded text
1717
pnpm depgraph affected packages/daemon/src/core/ref-frame.ts --json --limit 25
1818
```
1919

20-
Reverse reachability over the value-edge graph, plus the three lookups that used to follow it:
20+
Reverse reachability over the value-edge graph, plus the lookups that used to follow it:
2121
the `scripts/check-affected/` gate plan for the dependent set, the public commands whose handler
2222
chain reaches the file (value + dynamic edges, because handlers load through `import()`) with
2323
their owning live iOS scenarios when that manifest is in the tree, and the ADR 0011
2424
guarantee-matrix cells the file implements. See docs/agents/testing.md § "Before editing a shared
2525
module".
2626

27+
Two fields keep an empty or short list from reading as "nothing else is affected":
28+
29+
- `executableDependents` is a second reverse reachability over value + dynamic edges.
30+
`dependents` keeps its value-only meaning; `beyondValue` lists the files that only a dynamic
31+
import connects. Type-only edges stay out of both.
32+
- `commandAttribution` qualifies `commands` and `liveOwners`. `static-reachability` means the
33+
lists come from import edges alone. `partial` (`injected-runtime`) means the file is reachable
34+
from the root platform-runtime composition family (`src/platform-runtime*.ts`, the R76
35+
declaration). Daemon code cannot import that family (R65, R76) and reaches platform code through
36+
the injected gateway, so no import edge records which command drives it. The lists are then a
37+
lower bound.
38+
39+
Both are reachability, not removability and not coverage. `static-reachability` is not a
40+
completeness claim, and `partial` does not name the missing commands. `gates` is unchanged and
41+
still selected from the value dependents.
42+
2743
## When to reach for this
2844

2945
It pays for itself on three questions, and misleads on a fourth.

‎scripts/depgraph/affected-run.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,15 @@ import { selectChecks, type CheckPlan } from '../check-affected/model.ts';
3030
import {
3131
bound,
3232
collectDependents,
33+
collectExecutableDependents,
34+
commandAttribution,
3335
commandsReaching,
3436
formatBounded,
3537
guaranteeRowsForFile,
3638
rankByFanIn,
3739
weakDirectDependents,
3840
zoneBreakdown,
41+
type CommandAttribution,
3942
type CommandChain,
4043
type GuaranteeRow,
4144
} from './affected.ts';
@@ -75,6 +78,10 @@ export type BlastRadius = {
7578
/** Direct importers over the excluded edge kinds — reported, never mixed into the counts. */
7679
weakDirect: { type: number; dynamic: number };
7780
};
81+
/** Reverse reachability over value + dynamic edges; `beyondValue` is the part dynamic imports add. */
82+
executableDependents: { direct: string[]; transitive: string[]; beyondValue: string[] };
83+
/** How far `commands` and `liveOwners` can be trusted; applies to both. */
84+
commandAttribution: CommandAttribution;
7885
gates: { checks: string[]; failOpen: boolean };
7986
commands: CommandChain[];
8087
liveOwners: { manifest: string; present: boolean; owners: LiveOwner[] };
@@ -219,6 +226,7 @@ export async function computeBlastRadius(repoRoot: string, target: string): Prom
219226
}
220227

221228
const dependents = collectDependents(target, graph.edges);
229+
const executable = collectExecutableDependents(target, graph.edges);
222230
const plan: CheckPlan = selectChecks({ changedFiles: [target, ...dependents.all] });
223231
const commands = commandsReaching(target, commandChains(repoRoot), graph.edges);
224232
const live = await loadLiveOwners(repoRoot, commands);
@@ -235,6 +243,12 @@ export async function computeBlastRadius(repoRoot: string, target: string): Prom
235243
topByFanIn: rankByFanIn(dependents.all, nodes),
236244
weakDirect: weakDirectDependents(target, graph.edges),
237245
},
246+
executableDependents: {
247+
direct: executable.direct,
248+
transitive: executable.transitiveOnly,
249+
beyondValue: executable.beyondValue,
250+
},
251+
commandAttribution: commandAttribution(target, executable.all),
238252
gates: { checks: plan.checks, failOpen: plan.failOpen },
239253
commands,
240254
liveOwners: { manifest: LIVE_COVERAGE_MANIFEST, present: live.present, owners: live.owners },
@@ -244,26 +258,35 @@ export async function computeBlastRadius(repoRoot: string, target: string): Prom
244258

245259
/** Bounded text form — the shape an agent reads before spending its context on the edit. */
246260
export function formatBlastRadius(radius: BlastRadius, limit: number): string {
247-
const { dependents } = radius;
261+
const { dependents, executableDependents, commandAttribution: attribution } = radius;
262+
const partial = attribution.kind === 'partial';
248263
const lines = [
249264
`blast radius: ${radius.file} [zone ${radius.zone}, fan-in ${radius.fanIn}, fan-out ${radius.fanOut}]`,
250265
`dependents: ${dependents.direct.length} direct, ${dependents.transitive.length} transitive (value edges; ` +
251266
`${dependents.weakDirect.type} type-only and ${dependents.weakDirect.dynamic} dynamic direct importers not counted)`,
252267
` by zone: ${formatBounded(bound(dependents.byZone, limit), (entry) => `${entry.zone} ${entry.count}`)}`,
253268
` direct: ${formatBounded(bound(dependents.direct, limit), (file) => file)}`,
254269
` widest: ${formatBounded(bound(dependents.topByFanIn, limit), (entry) => `${entry.file} (fan-in ${entry.fanIn})`)}`,
270+
`executable dependents (value + dynamic edges): ${executableDependents.direct.length} direct, ${executableDependents.transitive.length} transitive; ` +
271+
`${executableDependents.beyondValue.length} reached only across a dynamic import: ` +
272+
formatBounded(bound(executableDependents.beyondValue, limit), (file) => file),
255273
`gates for this set${radius.gates.failOpen ? ' (fail-open: full set)' : ''}: ${radius.gates.checks.join(', ') || '(none)'}`,
256274
` run: pnpm check:affected --run`,
257275
`public commands whose handler chain reaches it (${radius.commands.length}): ` +
258276
formatBounded(bound(radius.commands, limit), (chain) => `${chain.command} (${chain.route})`),
277+
attribution.kind === 'partial'
278+
? ` attribution: PARTIAL (injected runtime) — reachable from ${formatBounded(bound(attribution.compositionRoots, limit), (file) => file)}; platform code runs behind the injected gateway, which no import edge records, so the command and live-owner lists are a lower bound`
279+
: ' attribution: static import reachability only — an empty list means no handler chain imports this file, not that no command work depends on it',
259280
];
260281

261282
if (!radius.liveOwners.present) {
262283
lines.push(
263284
`live scenario owners: ${radius.liveOwners.manifest} is not in this tree (lands with the iOS simulator e2e manifest) — no live claim available`,
264285
);
265286
} else {
266-
lines.push(`live scenario owners (${radius.liveOwners.owners.length}):`);
287+
lines.push(
288+
`live scenario owners (${radius.liveOwners.owners.length}${partial ? ', lower bound' : ''}):`,
289+
);
267290
for (const owner of bound(radius.liveOwners.owners, limit).shown) {
268291
lines.push(` ${owner.command} [${owner.level}] ${owner.owner} — ${owner.assertion}`);
269292
}

‎scripts/depgraph/affected.test.ts‎

Lines changed: 126 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { resolveImportEdges } from '../layering/model.ts';
88
import {
99
bound,
1010
collectDependents,
11+
collectExecutableDependents,
12+
commandAttribution,
1113
commandsReaching,
1214
formatBounded,
1315
guaranteeRowsForFile,
@@ -23,6 +25,7 @@ import {
2325
parseRouteEntries,
2426
} from './affected-run.ts';
2527
import { collapseEdges, type GraphNode } from './model.ts';
28+
import type { BlastRadius } from './affected-run.ts';
2629

2730
const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
2831
encoding: 'utf8',
@@ -67,6 +70,50 @@ test('collectDependents ignores type-only and dynamic dependents, which are coun
6770
assert.deepEqual(weakDirectDependents('src/core/target.ts', edges), { type: 1, dynamic: 1 });
6871
});
6972

73+
test('collectExecutableDependents reports what a dynamic import adds beyond value dependents', () => {
74+
const edges = edgesOf({
75+
'src/core/target.ts': 'export const t = 1;',
76+
'src/core/mid.ts': "import { t } from './target.ts';\nexport const m = t;",
77+
'src/core/lazy.ts': "export const load = () => import('./mid.ts');",
78+
'src/core/lazier.ts': "export const load = () => import('./lazy.ts');",
79+
'src/core/typed.ts': "import type { t } from './target.ts';\nexport type U = typeof t;",
80+
});
81+
82+
assert.deepEqual(collectDependents('src/core/target.ts', edges).all, ['src/core/mid.ts']);
83+
assert.deepEqual(collectExecutableDependents('src/core/target.ts', edges), {
84+
direct: ['src/core/mid.ts'],
85+
all: ['src/core/lazier.ts', 'src/core/lazy.ts', 'src/core/mid.ts'],
86+
transitiveOnly: ['src/core/lazier.ts', 'src/core/lazy.ts'],
87+
beyondValue: ['src/core/lazier.ts', 'src/core/lazy.ts'],
88+
});
89+
});
90+
91+
test('commandAttribution is partial only when the file is reachable from the platform-runtime composition root', () => {
92+
const edges = edgesOf({
93+
'packages/platform-apple/src/leaf.ts': 'export const leaf = 1;',
94+
'packages/platform-apple/src/runtime.ts':
95+
"import { leaf } from './leaf.ts';\nexport const r = leaf;",
96+
'src/platform-runtime-host.ts':
97+
"import { r } from '../packages/platform-apple/src/runtime.ts';\nexport const h = r;",
98+
'packages/daemon/src/handler.ts': 'export const handler = 1;',
99+
});
100+
101+
const injected = collectExecutableDependents('packages/platform-apple/src/leaf.ts', edges);
102+
assert.deepEqual(commandAttribution('packages/platform-apple/src/leaf.ts', injected.all), {
103+
kind: 'partial',
104+
reason: 'injected-runtime',
105+
compositionRoots: ['src/platform-runtime-host.ts'],
106+
});
107+
108+
const daemonOnly = collectExecutableDependents('packages/daemon/src/handler.ts', edges);
109+
assert.deepEqual(commandAttribution('packages/daemon/src/handler.ts', daemonOnly.all), {
110+
kind: 'static-reachability',
111+
});
112+
113+
// The composition root itself is injected-runtime territory even with no dependents.
114+
assert.equal(commandAttribution('src/platform-runtime.ts', []).kind, 'partial');
115+
});
116+
70117
test('zoneBreakdown and rankByFanIn order by weight, then name', () => {
71118
const nodes = nodesOf({
72119
'packages/daemon/src/a.ts': { zone: 'daemon-server', fanIn: 3 },
@@ -197,37 +244,73 @@ test('parseInvocation rejects a missing path, a second path, and a non-positive
197244
assert.throws(() => parseInvocation(['a.ts', '--limit', 'lots']), /positive integer/);
198245
});
199246

200-
test('text output bounds every list and says what it hid', () => {
201-
// This is an arbitrary report input used to exercise formatting, not a live source path.
202-
const text = formatBlastRadius(
203-
{
204-
file: 'src/utils/exec.ts',
205-
zone: 'utils',
206-
fanIn: 81,
207-
fanOut: 3,
208-
dependents: {
209-
direct: ['src/a.ts', 'src/b.ts', 'src/c.ts'],
210-
transitive: ['src/d.ts'],
211-
byZone: [{ zone: 'utils', count: 4 }],
212-
topByFanIn: [{ file: 'src/a.ts', fanIn: 7 }],
213-
weakDirect: { type: 2, dynamic: 1 },
214-
},
215-
gates: { checks: ['lint', 'typecheck'], failOpen: false },
216-
commands: [
217-
{ command: 'click', route: 'interaction', entry: 'packages/daemon/src/handlers/x.ts' },
218-
],
219-
liveOwners: { manifest: 'test/…/coverage-manifest.ts', present: false, owners: [] },
220-
guaranteeRows: [],
247+
function radiusFixture(overrides: Partial<BlastRadius> = {}): BlastRadius {
248+
// An arbitrary report input used to exercise formatting, not a live source path.
249+
return {
250+
file: 'src/utils/exec.ts',
251+
zone: 'utils',
252+
fanIn: 81,
253+
fanOut: 3,
254+
dependents: {
255+
direct: ['src/a.ts', 'src/b.ts', 'src/c.ts'],
256+
transitive: ['src/d.ts'],
257+
byZone: [{ zone: 'utils', count: 4 }],
258+
topByFanIn: [{ file: 'src/a.ts', fanIn: 7 }],
259+
weakDirect: { type: 2, dynamic: 1 },
221260
},
222-
2,
223-
);
261+
executableDependents: {
262+
direct: ['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/lazy.ts'],
263+
transitive: ['src/d.ts', 'src/lazier.ts'],
264+
beyondValue: ['src/lazy.ts', 'src/lazier.ts'],
265+
},
266+
commandAttribution: { kind: 'static-reachability' },
267+
gates: { checks: ['lint', 'typecheck'], failOpen: false },
268+
commands: [
269+
{ command: 'click', route: 'interaction', entry: 'packages/daemon/src/handlers/x.ts' },
270+
],
271+
liveOwners: { manifest: 'test/…/coverage-manifest.ts', present: false, owners: [] },
272+
guaranteeRows: [],
273+
...overrides,
274+
};
275+
}
276+
277+
test('text output bounds every list and says what it hid', () => {
278+
const text = formatBlastRadius(radiusFixture(), 2);
224279

225280
assert.match(text, /3 direct, 1 transitive .*2 type-only and 1 dynamic/);
226281
assert.match(text, /direct: {2}src\/a\.ts, src\/b\.ts \(\+1 more\)/);
282+
assert.match(
283+
text,
284+
/executable dependents .*: 4 direct, 2 transitive; 2 reached only across a dynamic import: src\/lazy\.ts, src\/lazier\.ts/,
285+
);
227286
assert.match(text, /gates for this set: lint, typecheck/);
228287
assert.match(text, /is not in this tree/);
229288
});
230289

290+
test('text output tells a known-empty command list from an incomplete one', () => {
291+
const noCommands = { commands: [] };
292+
const staticText = formatBlastRadius(radiusFixture(noCommands), 5);
293+
const partialText = formatBlastRadius(
294+
radiusFixture({
295+
...noCommands,
296+
commandAttribution: {
297+
kind: 'partial',
298+
reason: 'injected-runtime',
299+
compositionRoots: ['src/platform-runtime.ts', 'src/platform-runtime-host.ts', 'src/x.ts'],
300+
},
301+
}),
302+
2,
303+
);
304+
305+
assert.match(staticText, /\(0\): \(none\)\n {2}attribution: static import reachability only/);
306+
assert.doesNotMatch(staticText, /PARTIAL/);
307+
assert.match(
308+
partialText,
309+
/\(0\): \(none\)\n {2}attribution: PARTIAL \(injected runtime\) — reachable from src\/platform-runtime\.ts, src\/platform-runtime-host\.ts \(\+1 more\)/,
310+
);
311+
assert.doesNotMatch(partialText, /static import reachability only/);
312+
});
313+
231314
test('blast radius over the real tree agrees with check:affected and stays under 10s', async () => {
232315
const file = 'packages/host-kit/src/internal/exec.ts';
233316
const started = Date.now();
@@ -243,3 +326,23 @@ test('blast radius over the real tree agrees with check:affected and stays under
243326
const single = selectChecks({ changedFiles: [file] });
244327
for (const check of single.checks) assert.ok(radius.gates.checks.includes(check), check);
245328
});
329+
330+
test('the real platform snapshot route is flagged partial while a daemon handler is not', async () => {
331+
const platform = await computeBlastRadius(
332+
repoRoot,
333+
'packages/platform-apple/src/snapshot-route.ts',
334+
);
335+
assert.deepEqual(platform.commands, []);
336+
assert.equal(platform.commandAttribution.kind, 'partial');
337+
assert.ok(
338+
platform.executableDependents.beyondValue.length > 0,
339+
'platform files are reached from the composition root only across dynamic imports',
340+
);
341+
342+
const handler = await computeBlastRadius(
343+
repoRoot,
344+
'packages/daemon/src/sessions/handlers/session.ts',
345+
);
346+
assert.deepEqual(handler.commandAttribution, { kind: 'static-reachability' });
347+
assert.ok(handler.commands.length > 0);
348+
});

‎scripts/depgraph/affected.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
// gate's own model — the dependents reported here are exactly the edges CI enforces.
99

1010
import { genBFS, getPredecessors, type Graph, type TraversalDirection } from '@statelyai/graph';
11+
import { isRootPlatformRuntimeTarget } from '../layering/daemon-platform-runtime-inventory.ts';
1112
import { EXECUTABLE_EDGES, importGraph, VALUE_EDGES } from './import-graph.ts';
1213
import type { GraphEdge, GraphNode } from './model.ts';
1314

@@ -48,6 +49,36 @@ export function collectDependents(file: string, edges: readonly GraphEdge[]): De
4849
return { direct, all, transitiveOnly: all.filter((entry) => !directSet.has(entry)) };
4950
}
5051

52+
export type ExecutableDependentSet = DependentSet & {
53+
/** Executable dependents that value edges alone do not reach: the path crosses a dynamic import. */
54+
beyondValue: string[];
55+
};
56+
57+
/**
58+
* Reverse reachability over value and dynamic edges: every file whose code can run the target,
59+
* including handlers a route loads through `import()`. Kept apart from `collectDependents` so the
60+
* value count keeps its R4 meaning; type-only edges stay excluded because they never run.
61+
* Reachability here is not a removability claim.
62+
*/
63+
export function collectExecutableDependents(
64+
file: string,
65+
edges: readonly GraphEdge[],
66+
): ExecutableDependentSet {
67+
const executableGraph = importGraph(edges, EXECUTABLE_EDGES, [file]);
68+
const direct = getPredecessors(executableGraph, file)
69+
.map((node) => node.id)
70+
.sort();
71+
const all = [...reachable(executableGraph, file, 'incoming')].sort();
72+
const directSet = new Set(direct);
73+
const valueSet = new Set(collectDependents(file, edges).all);
74+
return {
75+
direct,
76+
all,
77+
transitiveOnly: all.filter((entry) => !directSet.has(entry)),
78+
beyondValue: all.filter((entry) => !valueSet.has(entry)),
79+
};
80+
}
81+
5182
/**
5283
* Direct importers over the edge kinds `collectDependents` leaves out. A pure vocabulary module
5384
* has zero value dependents and dozens of type-only ones; reporting only the value count there
@@ -132,6 +163,28 @@ export function commandsReaching(
132163
.sort((left, right) => left.command.localeCompare(right.command));
133164
}
134165

166+
/**
167+
* How far the `commands` and live-owner lists can be trusted as a statement about impact.
168+
* - `static-reachability`: the lists come from import edges alone; an empty list means no handler
169+
* chain imports the file, not that no command work depends on it.
170+
* - `partial`: the file is reachable from the root platform-runtime composition family, which
171+
* daemon code may not import (R65, R76); platform code runs behind the injected gateway and no
172+
* import edge records which command drives it, so the lists are a lower bound.
173+
*/
174+
export type CommandAttribution =
175+
| { kind: 'static-reachability' }
176+
| { kind: 'partial'; reason: 'injected-runtime'; compositionRoots: string[] };
177+
178+
export function commandAttribution(
179+
file: string,
180+
executableDependents: readonly string[],
181+
): CommandAttribution {
182+
const compositionRoots = [file, ...executableDependents].filter(isRootPlatformRuntimeTarget);
183+
return compositionRoots.length === 0
184+
? { kind: 'static-reachability' }
185+
: { kind: 'partial', reason: 'injected-runtime', compositionRoots };
186+
}
187+
135188
export type GuaranteeRow = {
136189
path: string;
137190
guarantee: string;

0 commit comments

Comments
 (0)