Skip to content

Commit 6764951

Browse files
committed
chore(gates): reject dynamic-import destructure residue in R74
1 parent 2ab6596 commit 6764951

7 files changed

Lines changed: 131 additions & 27 deletions

‎scripts/layering/daemon-modularity.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function importEdge(file: string, target: string): ResolvedImportEdge {
2020
dynamic: false,
2121
typeOnly: true,
2222
symbols: [],
23+
bindingResidue: false,
2324
fromZone: targetDagZone(file),
2425
toZone: targetDagZone(target),
2526
};

‎scripts/layering/daemon-platform-runtime-inventory.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,39 @@ test('R74 rejects an expanded destructured dynamic import on a classified edge',
110110
);
111111
});
112112

113+
test('R74 rejects a rest binding next to a recorded dynamic-import binding', () => {
114+
const sources = {
115+
'src/platform-runtime-operation-host.ts':
116+
'export async function recoverLegacyAppLogMarkersAfterDaemonLock() { return {}; }\n' +
117+
'export async function sweepLegacyAppLogMarkers() { return {}; }\n',
118+
'src/daemon/server/daemon-runtime.ts':
119+
"const { recoverLegacyAppLogMarkersAfterDaemonLock, ...operationHost } = await import('../../platform-runtime-operation-host.ts');\n" +
120+
'void [recoverLegacyAppLogMarkersAfterDaemonLock, operationHost];\n',
121+
};
122+
const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts');
123+
assert.equal(found.length, 1);
124+
assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE);
125+
assert.match(found[0]!.message, /unnameable dynamic-import binding/);
126+
assert.match(
127+
found[0]!.message,
128+
/src\/daemon\/server\/daemon-runtime\.ts -> src\/platform-runtime-operation-host\.ts/,
129+
);
130+
});
131+
132+
test('R74 rejects a computed destructure key on a classified dynamic import', () => {
133+
const sources = {
134+
'src/platform-runtime-operation-host.ts':
135+
'export async function recoverLegacyAppLogMarkersAfterDaemonLock() { return {}; }\n',
136+
'src/daemon/server/daemon-runtime.ts':
137+
"const markerName = 'recoverLegacyAppLogMarkersAfterDaemonLock';\n" +
138+
'const { [markerName]: recover } = await import("../../platform-runtime-operation-host.ts");\n' +
139+
'void recover;\n',
140+
};
141+
const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts');
142+
assert.equal(found.length, 1);
143+
assert.match(found[0]!.message, /unnameable dynamic-import binding/);
144+
});
145+
113146
test('R74 rejects a namespace-form dynamic import that hides the recorded bindings', () => {
114147
const sources = {
115148
'src/platform-runtime-operation-host.ts':

‎scripts/layering/daemon-platform-runtime-inventory.ts‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ export type DaemonPlatformRuntimeEdge = Readonly<{
2222
/**
2323
* Exact named symbols across every edge of the pair; empty for side-effect imports and dynamic
2424
* imports that do not destructure named bindings (a destructured dynamic import records its
25-
* bindings, so widening the destructure is a drift, not a silent expansion).
25+
* bindings, so widening the destructure is a drift, not a silent expansion). A rest or computed
26+
* destructure binding cannot be recorded here: it exposes exports beyond this list and R74
27+
* rejects the edge.
2628
*/
2729
symbols: readonly string[];
2830
classification: DaemonPlatformRuntimeClassification;
@@ -202,8 +204,10 @@ function sorted(symbols: readonly string[]): string[] {
202204

203205
/**
204206
* Catches: unclassified daemon-to-root platform-runtime coupling regrowing — a new edge (or a
205-
* new symbol on an existing edge) that the #2278 audit never classified, and the mirror
206-
* failure, a classified edge that no longer exists and would silently admit its return.
207+
* new symbol on an existing edge) that the #2278 audit never classified, the mirror failure, a
208+
* classified edge that no longer exists and would silently admit its return, and a
209+
* dynamic-import destructure holding a rest or computed binding, which exposes every export
210+
* the inventory cannot name.
207211
* Evidence: #2278 measured 14 production edges in 9 daemon files at origin/main 6e22e266d7;
208212
* this table is that measurement, classified per ADR 0022.
209213
* Cost: attributed to the R74 rule registration in check.ts; not a standalone CI job.
@@ -214,14 +218,19 @@ function sorted(symbols: readonly string[]): string[] {
214218
export function checkDaemonPlatformRuntimeInventory(
215219
edges: readonly ResolvedImportEdge[],
216220
): LayeringViolation[] {
217-
const actual = new Map<string, { line: number; symbols: Set<string> }>();
221+
const actual = new Map<string, { line: number; symbols: Set<string>; residue: boolean }>();
218222
for (const edge of edges) {
219223
if (!edge.file.startsWith('src/daemon/')) continue;
220224
if (!isProductionSourceFile(edge.file)) continue;
221225
if (!isRootPlatformRuntimeTarget(edge.target)) continue;
222226
const key = keyOf(edge.file, edge.target);
223-
const entry = actual.get(key) ?? { line: edge.line, symbols: new Set<string>() };
227+
const entry = actual.get(key) ?? {
228+
line: edge.line,
229+
symbols: new Set<string>(),
230+
residue: false,
231+
};
224232
for (const symbol of edge.symbols) entry.symbols.add(symbol);
233+
entry.residue = entry.residue || edge.bindingResidue;
225234
actual.set(key, entry);
226235
}
227236

@@ -245,6 +254,18 @@ export function checkDaemonPlatformRuntimeInventory(
245254
continue;
246255
}
247256
seen.add(key);
257+
if (entry.residue) {
258+
violations.push({
259+
rule: DAEMON_PLATFORM_RUNTIME_RULE,
260+
file: key.split(' -> ')[0]!,
261+
line: entry.line,
262+
message:
263+
`unnameable dynamic-import binding for ${key}: a rest or computed destructure exposes ` +
264+
`bindings the inventory cannot name. Destructure every binding explicitly and record it ` +
265+
`in DAEMON_PLATFORM_RUNTIME_EDGES (${DAEMON_PLATFORM_RUNTIME_RULE}), or remove the coupling.`,
266+
});
267+
continue;
268+
}
248269
const expected = sorted(declaration.symbols);
249270
const measured = sorted([...entry.symbols]);
250271
if (expected.join('\u0000') !== measured.join('\u0000')) {

‎scripts/layering/layering-ast.ts‎

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,24 @@ function unwrapImportInit(node: unknown): Record<string, unknown> | null {
6666
return null;
6767
}
6868

69+
export type DestructuredDynamicImport = Readonly<{
70+
/** Export names captured from static (non-computed) property keys. */
71+
symbols: readonly string[];
72+
/** True when the pattern also holds a rest element or a computed key: the import surface is wider than `symbols`. */
73+
residue: boolean;
74+
}>;
75+
6976
/**
70-
* Named bindings captured by destructuring a dynamic import
77+
* Bindings captured by destructuring a dynamic import
7178
* (`const { a, b: local } = await import('...')` captures `a` and `b`), keyed by the import
72-
* expression's source offset. Namespace-form and bare dynamic imports capture nothing.
79+
* expression's source offset. A rest element or a computed key is a binding the scanner cannot
80+
* name and is reported as residue, never dropped: it exposes exports beyond `symbols`.
81+
* Namespace-form and bare dynamic imports capture nothing.
7382
*/
7483
export function destructuredDynamicImportBindings(
7584
program: unknown,
76-
): ReadonlyMap<number, readonly string[]> {
77-
const bindings = new Map<number, readonly string[]>();
85+
): ReadonlyMap<number, DestructuredDynamicImport> {
86+
const captures = new Map<number, DestructuredDynamicImport>();
7887
const visit = (node: unknown): void => {
7988
if (node === null || typeof node !== 'object') return;
8089
if (Array.isArray(node)) {
@@ -92,26 +101,32 @@ export function destructuredDynamicImportBindings(
92101
typeof id === 'object' &&
93102
(id as Record<string, unknown>).type === 'ObjectPattern'
94103
) {
95-
const names: string[] = [];
104+
const symbols: string[] = [];
105+
let residue = false;
96106
const properties = (id as Record<string, unknown>).properties;
97-
if (Array.isArray(properties)) {
107+
if (!Array.isArray(properties)) {
108+
residue = true;
109+
} else {
98110
for (const property of properties) {
99111
if (
100-
property !== null &&
101-
typeof property === 'object' &&
102-
(property as Record<string, unknown>).type === 'Property' &&
103-
(property as Record<string, unknown>).computed !== true
112+
property === null ||
113+
typeof property !== 'object' ||
114+
(property as Record<string, unknown>).type !== 'Property' ||
115+
(property as Record<string, unknown>).computed === true
104116
) {
105-
const name = propertyName((property as Record<string, unknown>).key);
106-
if (name) names.push(name);
117+
residue = true;
118+
continue;
107119
}
120+
const name = propertyName((property as Record<string, unknown>).key);
121+
if (name) symbols.push(name);
122+
else residue = true;
108123
}
109124
}
110-
bindings.set(init.start, names);
125+
captures.set(init.start, { symbols, residue });
111126
}
112127
}
113128
for (const child of Object.values(record)) visit(child);
114129
};
115130
visit(program);
116-
return bindings;
131+
return captures;
117132
}

‎scripts/layering/model.test.ts‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,29 @@ test('parseImports detects multiline dynamic imports', () => {
5656
const edges = parseImports(['void import(', " '../multiline.ts'", ');'].join('\n'));
5757

5858
assert.deepEqual(edges, [
59-
{ spec: '../multiline.ts', dynamic: true, typeOnly: false, line: 1, symbols: [] },
59+
{
60+
spec: '../multiline.ts',
61+
dynamic: true,
62+
typeOnly: false,
63+
line: 1,
64+
symbols: [],
65+
bindingResidue: false,
66+
},
6067
]);
6168
});
6269

6370
test('parseImports resolves constant-template dynamic imports', () => {
6471
const edges = parseImports('void import(`../template.ts`);');
6572

6673
assert.deepEqual(edges, [
67-
{ spec: '../template.ts', dynamic: true, typeOnly: false, line: 1, symbols: [] },
74+
{
75+
spec: '../template.ts',
76+
dynamic: true,
77+
typeOnly: false,
78+
line: 1,
79+
symbols: [],
80+
bindingResidue: false,
81+
},
6882
]);
6983
});
7084

@@ -74,15 +88,19 @@ test('parseImports captures destructured named bindings of dynamic imports, keye
7488
"const { a, 'b': c } = await import('./dyn.ts');",
7589
"const mod = await import('./dyn.ts');",
7690
"const wrapped = (await import('./dyn.ts')) as Mod;",
91+
'const { a, ...rest } = await import("./dyn.ts");',
92+
'const { [keyExpr]: named } = await import("./dyn.ts");',
7793
].join('\n'),
7894
);
7995

8096
assert.deepEqual(
81-
edges.map(({ spec, symbols }) => ({ spec, symbols })),
97+
edges.map(({ spec, symbols, bindingResidue }) => ({ spec, symbols, bindingResidue })),
8298
[
83-
{ spec: './dyn.ts', symbols: ['a', 'b'] },
84-
{ spec: './dyn.ts', symbols: [] },
85-
{ spec: './dyn.ts', symbols: [] },
99+
{ spec: './dyn.ts', symbols: ['a', 'b'], bindingResidue: false },
100+
{ spec: './dyn.ts', symbols: [], bindingResidue: false },
101+
{ spec: './dyn.ts', symbols: [], bindingResidue: false },
102+
{ spec: './dyn.ts', symbols: ['a'], bindingResidue: true },
103+
{ spec: './dyn.ts', symbols: [], bindingResidue: true },
86104
],
87105
);
88106
});

‎scripts/layering/model.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export type ImportEdge = {
1313
* dynamic imports that do not destructure named bindings.
1414
*/
1515
symbols: readonly string[];
16+
/**
17+
* True when a dynamic-import destructure holds a binding the scanner cannot name (a rest element
18+
* or a computed key); `symbols` then does not enumerate the full imported surface.
19+
*/
20+
bindingResidue: boolean;
1621
};
1722

1823
export type ResolvedImportEdge = ImportEdge & {
@@ -160,12 +165,14 @@ function scanDynamicImports(source: string): ImportEdge[] {
160165
const spec = literalSpecifier(node.source);
161166
if (spec === undefined) return;
162167
const start = node.start as number | undefined;
168+
const capture = typeof start === 'number' ? destructured.get(start) : undefined;
163169
edges.push({
164170
spec,
165171
dynamic: true,
166172
typeOnly: false,
167173
line: sourceLine(source, start),
168-
symbols: typeof start === 'number' ? [...(destructured.get(start) ?? [])] : [],
174+
symbols: capture ? [...capture.symbols] : [],
175+
bindingResidue: capture?.residue ?? false,
169176
});
170177
});
171178
return edges;
@@ -174,7 +181,14 @@ function scanDynamicImports(source: string): ImportEdge[] {
174181
function scanSideEffectImport(line: string, lineNo: number): ImportEdge | null {
175182
const match = /^\s*import\s+['"]([^'"]+)['"]/.exec(line);
176183
return match
177-
? { spec: match[1]!, dynamic: false, typeOnly: false, line: lineNo, symbols: [] }
184+
? {
185+
spec: match[1]!,
186+
dynamic: false,
187+
typeOnly: false,
188+
line: lineNo,
189+
symbols: [],
190+
bindingResidue: false,
191+
}
178192
: null;
179193
}
180194

@@ -238,6 +252,7 @@ function scanFromImport(lines: string[], index: number): ImportEdge | null {
238252
typeOnly: statementIsTypeOnly(normalizedStatement),
239253
line: start + 1,
240254
symbols: importedSymbols(normalizedStatement),
255+
bindingResidue: false,
241256
};
242257
}
243258

‎scripts/layering/zone-policy.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ function edge(file: string, fromZone: string, toZone: string, kind: Kind = {}) {
1919
dynamic: kind.dynamic ?? false,
2020
typeOnly: kind.typeOnly ?? false,
2121
symbols: [],
22+
bindingResidue: false,
2223
},
2324
};
2425
}

0 commit comments

Comments
 (0)