1- import assert from " node:assert/strict" ;
2- import fs from " node:fs" ;
3- import path from " node:path" ;
4- import { parseSync } from " oxc-parser" ;
5- import { test } from " vitest" ;
1+ import assert from ' node:assert/strict' ;
2+ import fs from ' node:fs' ;
3+ import path from ' node:path' ;
4+ import { parseSync } from ' oxc-parser' ;
5+ import { test } from ' vitest' ;
66
77// ADR 0011 Layer-2 guard: interaction response payloads have exactly ONE
88// construction site — buildInteractionResponseData in
@@ -18,26 +18,19 @@ import { test } from "vitest";
1818// stricter, structural rule: each touch case is exactly one `return await
1919// <touch handler>(...)`, so no response can be constructed there at all.
2020
21- const INTERACTION_INTERNAL_DIR = path . resolve ( import . meta. dirname , ".." ) ;
22- const BUILDER_FILE = "interaction-touch-response.ts" ;
23- const DISPATCHER_FILE = "interaction.ts" ;
24- const TOUCH_DISPATCH_COMMANDS = [
25- "press" ,
26- "click" ,
27- "longpress" ,
28- "hover" ,
29- "fill" ,
30- ] as const ;
21+ const INTERACTION_INTERNAL_DIR = path . resolve ( import . meta. dirname , '..' ) ;
22+ const BUILDER_FILE = 'interaction-touch-response.ts' ;
23+ const DISPATCHER_FILE = 'interaction.ts' ;
24+ const TOUCH_DISPATCH_COMMANDS = [ 'press' , 'click' , 'longpress' , 'hover' , 'fill' ] as const ;
3125const TOUCH_HANDLER_MODULE = / ^ \. \/ i n t e r a c t i o n - t o u c h [ \w - ] * \. t s $ / ;
3226
3327function touchHandlerSourceFiles ( ) : string [ ] {
3428 return fs
3529 . readdirSync ( INTERACTION_INTERNAL_DIR )
3630 . filter (
3731 ( file ) =>
38- ( file . startsWith ( "interaction-touch" ) ||
39- file === "interaction-common.ts" ) &&
40- file . endsWith ( ".ts" ) &&
32+ ( file . startsWith ( 'interaction-touch' ) || file === 'interaction-common.ts' ) &&
33+ file . endsWith ( '.ts' ) &&
4134 file !== BUILDER_FILE ,
4235 ) ;
4336}
@@ -55,18 +48,11 @@ const ALLOWED_RHS = [
5548
5649function findHandRolledResponseData ( source : string ) : string [ ] {
5750 // Collapse whitespace so multi-line hand-rolled literals cannot hide.
58- const collapsed = source . replaceAll ( / \s + / g, " " ) ;
51+ const collapsed = source . replaceAll ( / \s + / g, ' ' ) ;
5952 const offenders : string [ ] = [ ] ;
6053 const assignment = / \b r e s p o n s e D a t a \s * [: = ] \s * / g;
61- for (
62- let match = assignment . exec ( collapsed ) ;
63- match ;
64- match = assignment . exec ( collapsed )
65- ) {
66- const rhs = collapsed . slice (
67- match . index + match [ 0 ] . length ,
68- match . index + match [ 0 ] . length + 160 ,
69- ) ;
54+ for ( let match = assignment . exec ( collapsed ) ; match ; match = assignment . exec ( collapsed ) ) {
55+ const rhs = collapsed . slice ( match . index + match [ 0 ] . length , match . index + match [ 0 ] . length + 160 ) ;
7056 if ( ! ALLOWED_RHS . some ( ( pattern ) => pattern . test ( rhs ) ) ) {
7157 offenders . push ( rhs . slice ( 0 , 80 ) ) ;
7258 }
@@ -77,11 +63,7 @@ function findHandRolledResponseData(source: string): string[] {
7763type AstNode = Record < string , unknown > & { type : string } ;
7864
7965function isAstNode ( value : unknown ) : value is AstNode {
80- return (
81- typeof value === "object" &&
82- value !== null &&
83- typeof ( value as AstNode ) . type === "string"
84- ) ;
66+ return typeof value === 'object' && value !== null && typeof ( value as AstNode ) . type === 'string' ;
8567}
8668
8769function * walkAst ( value : unknown ) : Generator < AstNode > {
@@ -92,14 +74,14 @@ function* walkAst(value: unknown): Generator<AstNode> {
9274 if ( ! isAstNode ( value ) ) return ;
9375 yield value ;
9476 for ( const [ key , child ] of Object . entries ( value ) ) {
95- if ( key !== " type" ) yield * walkAst ( child ) ;
77+ if ( key !== ' type' ) yield * walkAst ( child ) ;
9678 }
9779}
9880
9981function touchHandlerImports ( program : AstNode ) : Set < string > {
10082 const names = new Set < string > ( ) ;
10183 for ( const node of walkAst ( program ) ) {
102- if ( node . type !== " ImportDeclaration" ) continue ;
84+ if ( node . type !== ' ImportDeclaration' ) continue ;
10385 const source = node . source as AstNode ;
10486 if ( ! TOUCH_HANDLER_MODULE . test ( String ( source . value ) ) ) continue ;
10587 for ( const specifier of node . specifiers as AstNode [ ] ) {
@@ -109,13 +91,11 @@ function touchHandlerImports(program: AstNode): Set<string> {
10991 return names ;
11092}
11193
112- function touchCommandOf (
113- switchCase : AstNode ,
114- ) : ( typeof TOUCH_DISPATCH_COMMANDS ) [ number ] | null {
94+ function touchCommandOf ( switchCase : AstNode ) : ( typeof TOUCH_DISPATCH_COMMANDS ) [ number ] | null {
11595 const literal = switchCase . test ;
116- if ( ! isAstNode ( literal ) || literal . type !== " Literal" ) return null ;
96+ if ( ! isAstNode ( literal ) || literal . type !== ' Literal' ) return null ;
11797 const command = literal . value ;
118- return typeof command === " string" &&
98+ return typeof command === ' string' &&
11999 ( TOUCH_DISPATCH_COMMANDS as readonly string [ ] ) . includes ( command )
120100 ? ( command as ( typeof TOUCH_DISPATCH_COMMANDS ) [ number ] )
121101 : null ;
@@ -127,22 +107,21 @@ function delegationOf(
127107 handlers : ReadonlySet < string > ,
128108) : { handler : string } | { violation : string } {
129109 const consequent = switchCase . consequent as AstNode [ ] ;
130- if ( consequent . length !== 1 || consequent [ 0 ] ?. type !== " ReturnStatement" ) {
131- return { violation : " the case body is not exactly one return statement" } ;
110+ if ( consequent . length !== 1 || consequent [ 0 ] ?. type !== ' ReturnStatement' ) {
111+ return { violation : ' the case body is not exactly one return statement' } ;
132112 }
133113 const awaited = consequent [ 0 ] . argument ;
134- if ( ! isAstNode ( awaited ) || awaited . type !== " AwaitExpression" ) {
135- return { violation : " the case does not return an awaited call" } ;
114+ if ( ! isAstNode ( awaited ) || awaited . type !== ' AwaitExpression' ) {
115+ return { violation : ' the case does not return an awaited call' } ;
136116 }
137117 const call = awaited . argument ;
138- if ( ! isAstNode ( call ) || call . type !== " CallExpression" ) {
139- return { violation : " the case does not return an awaited call" } ;
118+ if ( ! isAstNode ( call ) || call . type !== ' CallExpression' ) {
119+ return { violation : ' the case does not return an awaited call' } ;
140120 }
141121 const callee = call . callee ;
142- if ( ! isAstNode ( callee ) || callee . type !== " Identifier" ) {
122+ if ( ! isAstNode ( callee ) || callee . type !== ' Identifier' ) {
143123 return {
144- violation :
145- "the case calls something other than an imported touch handler" ,
124+ violation : 'the case calls something other than an imported touch handler' ,
146125 } ;
147126 }
148127 const handler = String ( callee . name ) ;
@@ -159,43 +138,37 @@ function delegationOf(
159138 * local `responseData`, an inline literal, a second statement — is a violation.
160139 */
161140function touchDispatchViolations ( source : string ) : string [ ] {
162- const program = parseSync ( DISPATCHER_FILE , source )
163- . program as unknown as AstNode ;
141+ const program = parseSync ( DISPATCHER_FILE , source ) . program as unknown as AstNode ;
164142 const handlers = touchHandlerImports ( program ) ;
165143 const seen = new Map < string , number > ( ) ;
166144 const violations : string [ ] = [ ] ;
167145 for ( const node of walkAst ( program ) ) {
168- if ( node . type !== " SwitchCase" ) continue ;
146+ if ( node . type !== ' SwitchCase' ) continue ;
169147 const command = touchCommandOf ( node ) ;
170148 if ( command === null ) continue ;
171149 seen . set ( command , ( seen . get ( command ) ?? 0 ) + 1 ) ;
172150 const delegation = delegationOf ( node , handlers ) ;
173- if ( " violation" in delegation ) {
151+ if ( ' violation' in delegation ) {
174152 violations . push ( `case '${ command } ': ${ delegation . violation } ` ) ;
175153 }
176154 }
177155 for ( const command of TOUCH_DISPATCH_COMMANDS ) {
178156 const count = seen . get ( command ) ?? 0 ;
179157 if ( count !== 1 )
180- violations . push (
181- `case '${ command } ': expected exactly one case, found ${ count } ` ,
182- ) ;
158+ violations . push ( `case '${ command } ': expected exactly one case, found ${ count } ` ) ;
183159 }
184160 return violations ;
185161}
186162
187- test ( " interaction responses are only constructed by buildInteractionResponseData" , ( ) => {
163+ test ( ' interaction responses are only constructed by buildInteractionResponseData' , ( ) => {
188164 const files = touchHandlerSourceFiles ( ) ;
189165 assert . ok (
190- files . includes ( " interaction-touch-press.ts" ) ,
191- " guard lost sight of interaction-touch-press.ts — update touchHandlerSourceFiles()" ,
166+ files . includes ( ' interaction-touch-press.ts' ) ,
167+ ' guard lost sight of interaction-touch-press.ts — update touchHandlerSourceFiles()' ,
192168 ) ;
193169 const offenders : string [ ] = [ ] ;
194170 for ( const file of files ) {
195- const source = fs . readFileSync (
196- path . join ( INTERACTION_INTERNAL_DIR , file ) ,
197- "utf8" ,
198- ) ;
171+ const source = fs . readFileSync ( path . join ( INTERACTION_INTERNAL_DIR , file ) , 'utf8' ) ;
199172 for ( const offender of findHandRolledResponseData ( source ) ) {
200173 offenders . push ( `${ file } : responseData = ${ offender } ...` ) ;
201174 }
@@ -206,48 +179,39 @@ test("interaction responses are only constructed by buildInteractionResponseData
206179 `Hand-rolled interaction responseData found. Route it through ` +
207180 `buildInteractionResponseData (${ BUILDER_FILE } ) so identity extras ` +
208181 `(evidence, refLabel, selectorChain, hints) cannot be dropped per-branch:\n` +
209- offenders . map ( ( offender ) => ` - ${ offender } ` ) . join ( "\n" ) ,
182+ offenders . map ( ( offender ) => ` - ${ offender } ` ) . join ( '\n' ) ,
210183 ) ;
211184} ) ;
212185
213- test ( "every touch command case in the dispatcher only delegates to a touch handler" , ( ) => {
214- const source = fs . readFileSync (
215- path . join ( INTERACTION_INTERNAL_DIR , DISPATCHER_FILE ) ,
216- "utf8" ,
217- ) ;
186+ test ( 'every touch command case in the dispatcher only delegates to a touch handler' , ( ) => {
187+ const source = fs . readFileSync ( path . join ( INTERACTION_INTERNAL_DIR , DISPATCHER_FILE ) , 'utf8' ) ;
218188 const violations = touchDispatchViolations ( source ) ;
219189 assert . deepEqual (
220190 violations ,
221191 [ ] ,
222192 `The touch dispatch switch in ${ DISPATCHER_FILE } must only delegate. Move the logic into ` +
223193 `an interaction-touch-*.ts handler, which the responseData guard above scans:\n` +
224- violations . map ( ( violation ) => ` - ${ violation } ` ) . join ( "\n" ) ,
194+ violations . map ( ( violation ) => ` - ${ violation } ` ) . join ( '\n' ) ,
225195 ) ;
226196} ) ;
227197
228- test ( " the guard itself flags a hand-rolled responseData literal" , ( ) => {
198+ test ( ' the guard itself flags a hand-rolled responseData literal' , ( ) => {
229199 assert . equal (
230- findHandRolledResponseData (
231- "const responseData = { ...backendResult, x, y };" ,
232- ) . length ,
200+ findHandRolledResponseData ( 'const responseData = { ...backendResult, x, y };' ) . length ,
233201 1 ,
234202 ) ;
235203 assert . equal (
236- findHandRolledResponseData (
237- 'const responseData = result.kind === "ref" ? { a: 1 } : built;' ,
238- ) . length ,
204+ findHandRolledResponseData ( 'const responseData = result.kind === "ref" ? { a: 1 } : built;' )
205+ . length ,
239206 1 ,
240207 ) ;
241208 assert . equal (
242209 findHandRolledResponseData (
243- " const responseData = buildInteractionResponseData({ source }).responseData;" ,
210+ ' const responseData = buildInteractionResponseData({ source }).responseData;' ,
244211 ) . length ,
245212 0 ,
246213 ) ;
247- assert . equal (
248- findHandRolledResponseData ( "finalize({ result, responseData });" ) . length ,
249- 0 ,
250- ) ;
214+ assert . equal ( findHandRolledResponseData ( 'finalize({ result, responseData });' ) . length , 0 ) ;
251215} ) ;
252216
253217const DISPATCHER_PREAMBLE = `
@@ -283,11 +247,11 @@ const DELEGATING_CASES = `
283247 return await dispatchFillViaRuntime(params);
284248` ;
285249
286- test ( " the dispatcher guard accepts a switch whose touch cases only delegate" , ( ) => {
250+ test ( ' the dispatcher guard accepts a switch whose touch cases only delegate' , ( ) => {
287251 assert . deepEqual ( touchDispatchViolations ( dispatcher ( DELEGATING_CASES ) ) , [ ] ) ;
288252} ) ;
289253
290- test ( " the dispatcher guard rejects a hand-rolled responseData hidden behind a nested switch" , ( ) => {
254+ test ( ' the dispatcher guard rejects a hand-rolled responseData hidden behind a nested switch' , ( ) => {
291255 const source = dispatcher (
292256 DELEGATING_CASES . replace (
293257 `case 'press':\n return await dispatchTargetedTouchViaRuntime(params, 'press');` ,
@@ -308,7 +272,7 @@ test("the dispatcher guard rejects a hand-rolled responseData hidden behind a ne
308272 ] ) ;
309273} ) ;
310274
311- test ( " the dispatcher guard rejects a touch case that returns something other than a handler call" , ( ) => {
275+ test ( ' the dispatcher guard rejects a touch case that returns something other than a handler call' , ( ) => {
312276 const inline = dispatcher (
313277 DELEGATING_CASES . replace (
314278 `return await dispatchFillViaRuntime(params);` ,
@@ -330,11 +294,11 @@ test("the dispatcher guard rejects a touch case that returns something other tha
330294 ] ) ;
331295} ) ;
332296
333- test ( " the dispatcher guard notices a touch command that left the switch" , ( ) => {
297+ test ( ' the dispatcher guard notices a touch command that left the switch' , ( ) => {
334298 const source = dispatcher (
335299 DELEGATING_CASES . replace (
336300 `case 'longpress':\n return await dispatchTargetedTouchViaRuntime(params, 'longpress');` ,
337- "" ,
301+ '' ,
338302 ) ,
339303 ) ;
340304 assert . deepEqual ( touchDispatchViolations ( source ) , [
0 commit comments