@@ -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
2124type 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+
5264function 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`. */
98110function 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
109132function 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+
120174function 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
186272function 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