Skip to content

Commit 4eb030b

Browse files
committed
fix(ci): cover native-only protocol event drift
1 parent 824d8af commit 4eb030b

5 files changed

Lines changed: 121 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: 79 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,56 @@ 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+
for (const reference of label[1].matchAll(SWIFT_QUALIFIED_CONSTANT_RE)) {
200+
const value = constants.get(reference[1]);
201+
if (value) {
202+
sink.push(value);
203+
}
204+
}
161205
}
162206
});
163207
for (const comparison of source.matchAll(SWIFT_EVENT_COMPARISON_RE)) {
@@ -325,8 +369,9 @@ function loadAllowlist(rootDir, fsImpl) {
325369
}
326370

327371
function collectClientHandledEvents(params) {
328-
const { rootDir, roots, extension, extract, sentinels, fsImpl } = params;
372+
const { rootDir, roots, extension, extract, buildExtractContext, sentinels, fsImpl } = params;
329373
const handled = new Set();
374+
const sources = new Map();
330375
for (const root of roots) {
331376
const rootPath = path.resolve(rootDir, root);
332377
if (!fsImpl.existsSync(rootPath)) {
@@ -335,14 +380,18 @@ function collectClientHandledEvents(params) {
335380
);
336381
}
337382
for (const filePath of listFilesRecursive(rootPath, extension, fsImpl)) {
338-
for (const event of extract(fsImpl.readFileSync(filePath, "utf8"))) {
339-
handled.add(event);
340-
}
383+
sources.set(filePath, fsImpl.readFileSync(filePath, "utf8"));
384+
}
385+
}
386+
const extractContext = buildExtractContext?.(sources.values());
387+
for (const source of sources.values()) {
388+
for (const event of extract(source, extractContext)) {
389+
handled.add(event);
341390
}
342391
}
343392
for (const sentinel of sentinels) {
344393
const source = readRequiredFile(rootDir, sentinel, fsImpl);
345-
if (extract(source).size === 0) {
394+
if (extract(source, extractContext).size === 0) {
346395
throw new Error(
347396
`Sentinel dispatch file ${sentinel} no longer matches any event names; ` +
348397
"its event handling likely moved or changed shape. Update scripts/check-protocol-event-coverage.mjs.",
@@ -352,6 +401,20 @@ function collectClientHandledEvents(params) {
352401
return handled;
353402
}
354403

404+
function collectSwiftStaticStringConstants(sources) {
405+
const constants = new Map();
406+
for (const source of sources) {
407+
for (const [name, value] of extractSwiftStaticStringConstants(source)) {
408+
const existing = constants.get(name);
409+
if (existing !== undefined && existing !== value) {
410+
throw new Error(`Conflicting Swift string constant values for ${name}.`);
411+
}
412+
constants.set(name, value);
413+
}
414+
}
415+
return constants;
416+
}
417+
355418
/**
356419
* Runs the full coverage check against a repo checkout and returns error
357420
* strings plus a summary for logging.
@@ -373,6 +436,7 @@ export function collectProtocolEventCoverageErrors(params = {}) {
373436
roots: IOS_SCAN_ROOTS,
374437
extension: ".swift",
375438
extract: extractSwiftHandledEvents,
439+
buildExtractContext: collectSwiftStaticStringConstants,
376440
sentinels: [IOS_SENTINEL_FILE],
377441
fsImpl,
378442
}),

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: 22 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,29 @@ 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+
});
91112
});
92113

93114
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)