Skip to content

Commit e62cf76

Browse files
authored
fix(ci): catch native-only mobile protocol drift (#100278)
* fix(ci): cover native-only protocol event drift * fix(ci): ignore quoted Swift event constants
1 parent 06bf430 commit e62cf76

5 files changed

Lines changed: 143 additions & 19 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,10 @@ jobs:
424424
}
425425
EOF
426426
427+
- name: Check mobile protocol event coverage
428+
if: ${{ steps.manifest.outputs.run_node == 'true' || steps.manifest.outputs.run_ios_build == 'true' || steps.manifest.outputs.run_android_job == 'true' }}
429+
run: node scripts/check-protocol-event-coverage.mjs
430+
427431
# Run dependency-free security checks in parallel with scope detection so the
428432
# main Node jobs do not have to wait for Python/pre-commit setup.
429433
security-fast:
@@ -1206,7 +1210,6 @@ jobs:
12061210
case "$TASK" in
12071211
guards)
12081212
pnpm check:no-conflict-markers
1209-
pnpm check:protocol-coverage
12101213
pnpm tool-display:check
12111214
pnpm check:host-env-policy:swift
12121215
pnpm dup:check:coverage

scripts/check-protocol-event-coverage.mjs

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
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.
1919
import fs from "node:fs";
2020
import path from "node:path";
2121
import { fileURLToPath } from "node:url";
@@ -42,6 +42,10 @@ const MIN_EXPECTED_GATEWAY_EVENTS = 10;
4242
const GATEWAY_EVENTS_BLOCK_RE = /export const GATEWAY_EVENTS = \[([\s\S]*?)\];/u;
4343
const SWIFT_EVENT_SWITCH_RE = /\bswitch\s+\w+(?:\.\w+)*\.event\s*\{/u;
4444
const SWIFT_CASE_LABEL_RE = /^\s*case\s+(.+?):/u;
45+
const SWIFT_TYPE_DECLARATION_RE =
46+
/^\s*(?:(?:private|fileprivate|internal|public)\s+)?(?:enum|struct|class|actor|extension)\s+([A-Za-z_]\w*)[^{]*\x7b/u;
47+
const SWIFT_STATIC_STRING_CONSTANT_RE = /^\s*static\s+let\s+([A-Za-z_]\w*)\s*=\s*"([^"]+)"/u;
48+
const SWIFT_QUALIFIED_CONSTANT_RE = /\b([A-Za-z_]\w*\.[A-Za-z_]\w*)\b/gu;
4549
const KOTLIN_EVENT_WHEN_RE = /\bwhen\s*\(\s*event\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

327372
function 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
}),

scripts/protocol-event-coverage.allowlist.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@
1313
"device.pair.requested": "Device pairing flows poll via device.pair.* methods on iOS.",
1414
"device.pair.resolved": "Device pairing flows poll via device.pair.* methods on iOS.",
1515
"voicewake.routing.changed": "iOS only consumes voicewake.changed trigger updates; routing changes are not surfaced.",
16-
"exec.approval.requested": "Handled in NodeAppModel via ExecApprovalNotificationBridge constants; the literal-only extractor cannot see constant case labels.",
17-
"exec.approval.resolved": "Handled in NodeAppModel via ExecApprovalNotificationBridge constants; the literal-only extractor cannot see constant case labels.",
1816
"plugin.approval.requested": "Plugin approval prompts are not implemented on iOS.",
1917
"plugin.approval.resolved": "Plugin approval prompts are not implemented on iOS.",
2018
"terminal.data": "Embedded terminal is a web/desktop surface; iOS has no terminal client.",

test/scripts/check-protocol-event-coverage.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
extractGatewayEventNames,
66
extractKotlinHandledEvents,
77
extractSwiftHandledEvents,
8+
extractSwiftStaticStringConstants,
89
} from "../../scripts/check-protocol-event-coverage.mjs";
910

1011
const GATEWAY_LIST_FIXTURE = `
@@ -51,6 +52,11 @@ describe("extractGatewayEventNames", () => {
5152

5253
describe("extractSwiftHandledEvents", () => {
5354
it("collects switch case literals and comparisons, skipping nested and non-event code", () => {
55+
const constants = extractSwiftStaticStringConstants(`
56+
enum SomeBridge {
57+
static let requestedKind = "exec.approval.requested"
58+
}
59+
`);
5460
const source = `
5561
static func mapEventFrame(_ evt: EventFrame) -> Event? {
5662
switch evt.event {
@@ -80,14 +86,50 @@ describe("extractSwiftHandledEvents", () => {
8086
}
8187
if evt.event == "connect.challenge" { return }
8288
`;
83-
const handled = extractSwiftHandledEvents(source);
89+
const handled = extractSwiftHandledEvents(source, constants);
8490
expect([...handled].toSorted()).toEqual([
8591
"chat",
8692
"connect.challenge",
93+
"exec.approval.requested",
8794
"session.message",
8895
"tick",
8996
]);
9097
});
98+
99+
it("extracts only type-scoped static string constants", () => {
100+
const constants = extractSwiftStaticStringConstants(`
101+
enum ApprovalBridge {
102+
static let requestedKind = "exec.approval.requested"
103+
private static let nested = makeValue {
104+
"not.an.event"
105+
}
106+
}
107+
let requestedKind = "wrong.global.value"
108+
`);
109+
110+
expect([...constants]).toEqual([["ApprovalBridge.requestedKind", "exec.approval.requested"]]);
111+
});
112+
113+
it("does not resolve qualified constants inside quoted case labels", () => {
114+
const constants = extractSwiftStaticStringConstants(`
115+
enum ApprovalBridge {
116+
static let requestedKind = "exec.approval.requested"
117+
}
118+
`);
119+
const handled = extractSwiftHandledEvents(
120+
`
121+
switch evt.event {
122+
case "ApprovalBridge.requestedKind":
123+
return .approval
124+
default:
125+
return nil
126+
}
127+
`,
128+
constants,
129+
);
130+
131+
expect(handled).toEqual(new Set(["ApprovalBridge.requestedKind"]));
132+
});
91133
});
92134

93135
describe("extractKotlinHandledEvents", () => {

test/scripts/ci-workflow-guards.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,22 @@ describe("ci workflow guards", () => {
623623
expect(preflightGuards).toContain("pnpm deps:patches:check");
624624
});
625625

626+
it("runs mobile protocol coverage for Node and native-only changes", () => {
627+
const workflow = readCiWorkflow();
628+
const coverageStep = workflow.jobs.preflight.steps.find(
629+
(step) => step.name === "Check mobile protocol event coverage",
630+
);
631+
const checkShardRun = workflow.jobs["check-shard"].steps.find(
632+
(step) => step.name === "Run check shard",
633+
).run;
634+
635+
expect(coverageStep.run).toBe("node scripts/check-protocol-event-coverage.mjs");
636+
expect(coverageStep.if).toContain("steps.manifest.outputs.run_node == 'true'");
637+
expect(coverageStep.if).toContain("steps.manifest.outputs.run_ios_build == 'true'");
638+
expect(coverageStep.if).toContain("steps.manifest.outputs.run_android_job == 'true'");
639+
expect(checkShardRun).not.toContain("check:protocol-coverage");
640+
});
641+
626642
it("does not rebuild Control UI after build:ci-artifacts", () => {
627643
const workflow = readCiWorkflow();
628644
const buildArtifactSteps = workflow.jobs["build-artifacts"].steps;

0 commit comments

Comments
 (0)