1111// Client "handled" sets are extracted with deliberately simple parsing over
1212// the mobile app sources: Swift `switch <x>.event { case "..." }` blocks plus
1313// `.event == "..."` comparisons, and Kotlin `when (event) { "..." -> }` blocks
14- // plus `event == "..."` comparisons scoped to `fun handle*Event(...)` bodies
15- // so predicate helpers outside the dispatch path do not count as coverage. Events a client intentionally does not
16- // consume live in scripts/protocol-event-coverage.allowlist.json with a
17- // one-line reason. New gateway events that no client handles (and are not
18- // allowlisted) fail the check .
14+ // plus `event == "..."` comparisons scoped to `fun handle*Event(...)` bodies.
15+ // Swift case labels may use qualified static string constants; those are
16+ // resolved across the scanned source tree so deleting the real handler cannot
17+ // hide behind an allowlist entry. Events a client intentionally does not
18+ // consume live in scripts/protocol-event-coverage.allowlist.json .
1919import fs from "node:fs" ;
2020import path from "node:path" ;
2121import { fileURLToPath } from "node:url" ;
@@ -42,6 +42,10 @@ const MIN_EXPECTED_GATEWAY_EVENTS = 10;
4242const GATEWAY_EVENTS_BLOCK_RE = / e x p o r t c o n s t G A T E W A Y _ E V E N T S = \[ ( [ \s \S ] * ?) \] ; / u;
4343const SWIFT_EVENT_SWITCH_RE = / \b s w i t c h \s + \w + (?: \. \w + ) * \. e v e n t \s * \{ / u;
4444const SWIFT_CASE_LABEL_RE = / ^ \s * c a s e \s + ( .+ ?) : / u;
45+ const SWIFT_TYPE_DECLARATION_RE =
46+ / ^ \s * (?: (?: p r i v a t e | f i l e p r i v a t e | i n t e r n a l | p u b l i c ) \s + ) ? (?: e n u m | s t r u c t | c l a s s | a c t o r | e x t e n s i o n ) \s + ( [ A - Z a - z _ ] \w * ) [ ^ { ] * \x7b / u;
47+ const SWIFT_STATIC_STRING_CONSTANT_RE = / ^ \s * s t a t i c \s + l e t \s + ( [ A - Z a - z _ ] \w * ) \s * = \s * " ( [ ^ " ] + ) " / u;
48+ const SWIFT_QUALIFIED_CONSTANT_RE = / \b ( [ A - Z a - z _ ] \w * \. [ A - Z a - z _ ] \w * ) \b / gu;
4549const KOTLIN_EVENT_WHEN_RE = / \b w h e n \s * \( \s * e v e n t \s * \) \s * \{ / u;
4650// Kotlin gateway handlers follow the `handle*Event` naming convention
4751// (handleEvent, handleGatewayEvent, handleExecApprovalGatewayEvent, ...).
@@ -148,16 +152,57 @@ function pushStringLiterals(segment, names) {
148152}
149153
150154/**
151- * Extracts event names a Swift source handles: string-literal case labels of
152- * `switch <x>.event` blocks plus `.event == "..."` comparisons. Case labels
153- * built from constants are invisible to this extractor and need an allowlist
154- * entry explaining that.
155+ * Extracts qualified static string constants declared at Swift type scope.
156+ * Type qualification avoids resolving unrelated constants that share a short
157+ * member name elsewhere in the app.
155158 */
156- export function extractSwiftHandledEvents ( source ) {
159+ export function extractSwiftStaticStringConstants ( source ) {
160+ const constants = new Map ( ) ;
161+ const lines = source . split ( "\n" ) ;
162+ for ( let i = 0 ; i < lines . length ; i += 1 ) {
163+ const declaration = SWIFT_TYPE_DECLARATION_RE . exec ( lines [ i ] ) ;
164+ if ( ! declaration ) {
165+ continue ;
166+ }
167+ const typeName = declaration [ 1 ] ;
168+ let depth = 0 ;
169+ for ( let j = i ; j < lines . length ; j += 1 ) {
170+ const line = lines [ j ] ;
171+ if ( depth === 1 ) {
172+ const constant = SWIFT_STATIC_STRING_CONSTANT_RE . exec ( line ) ;
173+ if ( constant ) {
174+ constants . set ( `${ typeName } .${ constant [ 1 ] } ` , constant [ 2 ] ) ;
175+ }
176+ }
177+ const braceSource = sanitizeLineForBraces ( line ) ;
178+ for ( const char of braceSource ) {
179+ if ( char === "{" ) {
180+ depth += 1 ;
181+ } else if ( char === "}" ) {
182+ depth -= 1 ;
183+ }
184+ }
185+ if ( j > i && depth <= 0 ) {
186+ break ;
187+ }
188+ }
189+ }
190+ return constants ;
191+ }
192+
193+ /** Extracts Swift gateway-event case labels, including qualified constants. */
194+ export function extractSwiftHandledEvents ( source , constants = new Map ( ) ) {
157195 const names = collectBlockCaseLabels ( source , SWIFT_EVENT_SWITCH_RE , ( line , sink ) => {
158196 const label = SWIFT_CASE_LABEL_RE . exec ( line ) ;
159197 if ( label ) {
160198 pushStringLiterals ( label [ 1 ] , sink ) ;
199+ const constantReferences = sanitizeLineForBraces ( label [ 1 ] ) ;
200+ for ( const reference of constantReferences . matchAll ( SWIFT_QUALIFIED_CONSTANT_RE ) ) {
201+ const value = constants . get ( reference [ 1 ] ) ;
202+ if ( value ) {
203+ sink . push ( value ) ;
204+ }
205+ }
161206 }
162207 } ) ;
163208 for ( const comparison of source . matchAll ( SWIFT_EVENT_COMPARISON_RE ) ) {
@@ -325,8 +370,9 @@ function loadAllowlist(rootDir, fsImpl) {
325370}
326371
327372function collectClientHandledEvents ( params ) {
328- const { rootDir, roots, extension, extract, sentinels, fsImpl } = params ;
373+ const { rootDir, roots, extension, extract, buildExtractContext , sentinels, fsImpl } = params ;
329374 const handled = new Set ( ) ;
375+ const sources = new Map ( ) ;
330376 for ( const root of roots ) {
331377 const rootPath = path . resolve ( rootDir , root ) ;
332378 if ( ! fsImpl . existsSync ( rootPath ) ) {
@@ -335,14 +381,18 @@ function collectClientHandledEvents(params) {
335381 ) ;
336382 }
337383 for ( const filePath of listFilesRecursive ( rootPath , extension , fsImpl ) ) {
338- for ( const event of extract ( fsImpl . readFileSync ( filePath , "utf8" ) ) ) {
339- handled . add ( event ) ;
340- }
384+ sources . set ( filePath , fsImpl . readFileSync ( filePath , "utf8" ) ) ;
385+ }
386+ }
387+ const extractContext = buildExtractContext ?. ( sources . values ( ) ) ;
388+ for ( const source of sources . values ( ) ) {
389+ for ( const event of extract ( source , extractContext ) ) {
390+ handled . add ( event ) ;
341391 }
342392 }
343393 for ( const sentinel of sentinels ) {
344394 const source = readRequiredFile ( rootDir , sentinel , fsImpl ) ;
345- if ( extract ( source ) . size === 0 ) {
395+ if ( extract ( source , extractContext ) . size === 0 ) {
346396 throw new Error (
347397 `Sentinel dispatch file ${ sentinel } no longer matches any event names; ` +
348398 "its event handling likely moved or changed shape. Update scripts/check-protocol-event-coverage.mjs." ,
@@ -352,6 +402,20 @@ function collectClientHandledEvents(params) {
352402 return handled ;
353403}
354404
405+ function collectSwiftStaticStringConstants ( sources ) {
406+ const constants = new Map ( ) ;
407+ for ( const source of sources ) {
408+ for ( const [ name , value ] of extractSwiftStaticStringConstants ( source ) ) {
409+ const existing = constants . get ( name ) ;
410+ if ( existing !== undefined && existing !== value ) {
411+ throw new Error ( `Conflicting Swift string constant values for ${ name } .` ) ;
412+ }
413+ constants . set ( name , value ) ;
414+ }
415+ }
416+ return constants ;
417+ }
418+
355419/**
356420 * Runs the full coverage check against a repo checkout and returns error
357421 * strings plus a summary for logging.
@@ -373,6 +437,7 @@ export function collectProtocolEventCoverageErrors(params = {}) {
373437 roots : IOS_SCAN_ROOTS ,
374438 extension : ".swift" ,
375439 extract : extractSwiftHandledEvents ,
440+ buildExtractContext : collectSwiftStaticStringConstants ,
376441 sentinels : [ IOS_SENTINEL_FILE ] ,
377442 fsImpl,
378443 } ) ,
0 commit comments