Skip to content

Commit c0ccaa6

Browse files
committed
fix(registry): unresolvable CLI daemon-send targets fail the gate
An unknown literal or computed command target resolved to undefined and never entered the scan, so a dispatch could evade attribution by naming a target the gate could not read. Daemon-send envelopes are now located by their send call and an unresolvable target is reported instead of skipped. Part of #1739 (wave 0)
1 parent 6fd267e commit c0ccaa6

1 file changed

Lines changed: 129 additions & 8 deletions

File tree

src/core/command-descriptor/__tests__/platform-execution-cli-route.test.ts

Lines changed: 129 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import { commandDescriptors } from '../registry.ts';
1515
* only when D is `none` too.
1616
*
1717
* Scope is `src/cli.ts`, the composition root where injected callbacks are built.
18-
* The scan is total within it — a dispatch no route can claim fails the gate.
18+
* The scan is total within it in both directions: a dispatch no route can claim
19+
* fails, and a daemon send whose command target cannot be resolved to a registered
20+
* command also fails. An unresolvable target is a gate error rather than a skip,
21+
* because a skipped dispatch is indistinguishable from an absent one.
1922
*/
2023

2124
type AstNode = { type: string } & Record<string, unknown>;
@@ -47,8 +50,17 @@ type RouteScan = Readonly<{
4750
dispatches: readonly RouteDispatch[];
4851
/** Commands dispatched at a site no `command === '<name>'` route could claim. */
4952
unattributed: readonly string[];
53+
/**
54+
* Daemon sends whose `command` target is not a registered command literal —
55+
* a computed target, a variable, an unknown catalog key, or no target at all.
56+
* Reported by source line: the gate cannot reason about them, so it refuses them.
57+
*/
58+
unresolvedTargets: readonly string[];
5059
}>;
5160

61+
/** Calls that hand a request envelope to the daemon. */
62+
const DAEMON_SEND_CALLEES = new Set(['sendToDaemon']);
63+
5264
function isAstNode(value: unknown): value is AstNode {
5365
return typeof value === 'object' && value !== null && typeof (value as AstNode).type === 'string';
5466
}
@@ -96,14 +108,25 @@ function routedCommandOf(test: unknown): string | undefined {
96108

97109
/** `command: 'x'`, `command: INTERNAL_COMMANDS.x`, `command: PUBLIC_COMMANDS.x`. */
98110
function dispatchSiteOf(node: AstNode): DispatchSite | undefined {
99-
if (node.type !== 'Property' || node['computed'] === true) return undefined;
100-
if (identifierName(node['key']) !== 'command') return undefined;
101-
const command = dispatchTargetOf(node['value']);
111+
const commandProperty = commandPropertyOf(node);
112+
if (commandProperty === undefined) return undefined;
113+
const command = dispatchTargetOf(commandProperty['value']);
102114
if (command === undefined) return undefined;
103-
// A node without a source position cannot be matched against an attributed
104-
// site, so -1 keeps it distinct from every real offset and it stays unclaimed.
115+
return { command, offset: offsetOf(commandProperty) };
116+
}
117+
118+
function commandPropertyOf(node: AstNode): AstNode | undefined {
119+
if (node.type !== 'Property' || node['computed'] === true) return undefined;
120+
return identifierName(node['key']) === 'command' ? node : undefined;
121+
}
122+
123+
/**
124+
* A node without a source position cannot be matched against an attributed site, so
125+
* -1 keeps it distinct from every real offset and it stays unclaimed.
126+
*/
127+
function offsetOf(node: AstNode): number {
105128
const start = node['start'];
106-
return { command, offset: typeof start === 'number' ? start : -1 };
129+
return typeof start === 'number' ? start : -1;
107130
}
108131

109132
function dispatchTargetOf(value: unknown): string | undefined {
@@ -117,6 +140,37 @@ function dispatchTargetOf(value: unknown): string | undefined {
117140
return catalog === undefined || key === undefined ? undefined : COMMAND_CATALOGS[catalog]?.[key];
118141
}
119142

143+
/**
144+
* Every request envelope handed to the daemon, whether or not its command target can
145+
* be resolved. `command:` alone cannot mark a dispatch — diagnostics scopes and context
146+
* builders carry the same key — so the envelope is identified by the send call it is
147+
* passed to.
148+
*/
149+
function daemonSendEnvelopes(program: unknown): AstNode[] {
150+
const envelopes: AstNode[] = [];
151+
walk(program, (node) => {
152+
if (node.type !== 'CallExpression' || !isDaemonSendCallee(node['callee'])) return;
153+
// `sendToDaemon(request, options)`: only the first argument is the request.
154+
const args = node['arguments'];
155+
const request = Array.isArray(args) ? args[0] : undefined;
156+
if (isAstNode(request) && request.type === 'ObjectExpression') envelopes.push(request);
157+
});
158+
return envelopes;
159+
}
160+
161+
function isDaemonSendCallee(callee: unknown): boolean {
162+
if (!isAstNode(callee)) return false;
163+
if (callee.type === 'Identifier') return DAEMON_SEND_CALLEES.has(String(callee['name']));
164+
if (callee.type !== 'MemberExpression' || callee['computed'] === true) return false;
165+
const property = identifierName(callee['property']);
166+
return property !== undefined && DAEMON_SEND_CALLEES.has(property);
167+
}
168+
169+
function envelopeProperties(envelope: AstNode): AstNode[] {
170+
const properties = envelope['properties'];
171+
return Array.isArray(properties) ? properties.filter(isAstNode) : [];
172+
}
173+
120174
function localFunctionsByName(program: unknown): Map<string, AstNode> {
121175
const functions = new Map<string, AstNode>();
122176
walk(program, (node) => {
@@ -180,7 +234,39 @@ function scanCliRouteDispatches(sourceText: string): RouteScan {
180234
.filter(({ offset }) => !attributedOffsets.has(offset))
181235
.map(({ command }) => command);
182236

183-
return { dispatches, unattributed: [...new Set(unclaimed)].sort() };
237+
return {
238+
dispatches,
239+
unattributed: [...new Set(unclaimed)].sort(),
240+
unresolvedTargets: unresolvedDaemonSendTargets(program, sourceText),
241+
};
242+
}
243+
244+
/**
245+
* A daemon send whose command target the gate cannot resolve. Dropping these would
246+
* leave an evasion path: an unknown literal or a computed target would simply never
247+
* appear in the scan, so the gate reports them instead of skipping them.
248+
*/
249+
function unresolvedDaemonSendTargets(program: unknown, sourceText: string): string[] {
250+
const unresolved: string[] = [];
251+
for (const envelope of daemonSendEnvelopes(program)) {
252+
const commandProperty = envelopeProperties(envelope).find(
253+
(property) => commandPropertyOf(property) !== undefined,
254+
);
255+
if (commandProperty === undefined) {
256+
unresolved.push(`${lineOf(sourceText, offsetOf(envelope))}: daemon send declares no command`);
257+
continue;
258+
}
259+
if (dispatchTargetOf(commandProperty['value']) === undefined) {
260+
unresolved.push(
261+
`${lineOf(sourceText, offsetOf(commandProperty))}: daemon send target is not a registered command`,
262+
);
263+
}
264+
}
265+
return unresolved;
266+
}
267+
268+
function lineOf(sourceText: string, offset: number): number {
269+
return offset < 0 ? 0 : sourceText.slice(0, offset).split('\n').length;
184270
}
185271

186272
function dominanceFailures(scan: RouteScan, kindOf: PlatformExecutionKindOf): string[] {
@@ -206,6 +292,10 @@ describe('platform-execution coherence across CLI route delegation', () => {
206292
expect(scanCompositionRoot().unattributed).toEqual([]);
207293
});
208294

295+
test('every CLI daemon send resolves to a registered command', () => {
296+
expect(scanCompositionRoot().unresolvedTargets).toEqual([]);
297+
});
298+
209299
test('the composition root still carries the react-devtools runtime delegation', () => {
210300
expect(scanCompositionRoot().dispatches).toContainEqual({
211301
route: 'react-devtools',
@@ -250,4 +340,35 @@ describe('platform-execution coherence across CLI route delegation', () => {
250340
expect(scan.dispatches).toEqual([{ route: 'react-devtools', dispatched: 'runtime' }]);
251341
expect(scan.unattributed).toEqual(['runtime']);
252342
});
343+
344+
test('planted red: a stray dispatch with an unknown target surfaces', () => {
345+
const planted = `
346+
async function runCli(argv, deps) {
347+
if (command === 'react-devtools') {
348+
await runReactDevtoolsCli(ctx, deps);
349+
return;
350+
}
351+
await deps.sendToDaemon({ command: SOME_OTHER_CATALOG.hidden, positionals: [] });
352+
}
353+
async function runReactDevtoolsCli(ctx, deps) {
354+
await deps.sendToDaemon({ command: INTERNAL_COMMANDS.runtime, positionals: [] });
355+
}
356+
`;
357+
const scan = scanCliRouteDispatches(planted);
358+
359+
// The known routed dispatch still resolves, so the failure is the stray one alone.
360+
expect(scan.dispatches).toEqual([{ route: 'react-devtools', dispatched: 'runtime' }]);
361+
expect(scan.unresolvedTargets).toEqual(['7: daemon send target is not a registered command']);
362+
});
363+
364+
test.each([
365+
['a computed target', 'deps.sendToDaemon({ command: catalog[key], positionals: [] });'],
366+
['an unknown literal', "deps.sendToDaemon({ command: 'not-a-command', positionals: [] });"],
367+
['a variable target', 'deps.sendToDaemon({ command: chosen, positionals: [] });'],
368+
['no target at all', 'deps.sendToDaemon({ positionals: [] });'],
369+
])('planted red: %s is a gate error, not a skip', (_label, send) => {
370+
const scan = scanCliRouteDispatches(`async function runCli(argv, deps) { await ${send} }`);
371+
372+
expect(scan.unresolvedTargets).toHaveLength(1);
373+
});
253374
});

0 commit comments

Comments
 (0)