ReviewOS

also looking at this

craft-native/craft

fix(bridge): stop discarding the fields the page actually sends

#82
Merged glennmichael123 wants to merge fix/bridge-field-names into main
7 files +375 -34
packages/zig/build.zigmodified+8-0
Changes to packages/zig/build.zig
@@ -807,6 +807,14 @@ pub fn build(b: *std.Build) void {
807807 "src/bridge_capabilities.zig",
808808 "src/bridge_capabilities_actions.zig",
809809 "src/js/craft-bridge.js",
810 // The bridges whose payload field names are checked against the page's
811 // — see `field_checked` in the test.
812 "src/bridge_fs.zig",
813 "src/bridge_window.zig",
814 "src/bridge_updater.zig",
815 "src/bridge_bluetooth.zig",
816 "src/bridge_serial.zig",
817 "src/bridge_local_server.zig",
810818 }) |source_path| {
811819 capability_conformance_tests.root_module.addAnonymousImport(source_path, .{
812820 .root_source_file = b.path(source_path),
packages/zig/src/bridge_app.zigmodified+20-5
Changes to packages/zig/src/bridge_app.zig
@@ -64,7 +64,7 @@ pub const AppBridge = struct {
6464 } else if (std.mem.eql(u8, action, A.set_badge)) {
6565 try self.setBadge(data);
6666 } else if (std.mem.eql(u8, action, A.bounce)) {
67 try self.bounce();
67 try self.bounce(data);
6868 } else {
6969 if (comptime builtin.mode == .debug)
7070 std.debug.print("[AppBridge] Unknown action: {s}\n", .{action});
@@ -244,17 +244,32 @@ pub const AppBridge = struct {
244244 }
245245 }
246246
247 fn bounce(self: *Self) !void {
247 /// `craft.app.bounce(type)` — 'critical' or 'informational'.
248 ///
249 /// This took no payload at all and always requested an informational
250 /// bounce, so `bounce('critical')` hopped the Dock icon once instead of
251 /// bouncing until the user activates the app — which is the entire
252 /// difference between the two, and the only reason to pass an argument.
253 fn bounce(self: *Self, data: ?[]const u8) !void {
248254 _ = self;
249255 if (builtin.os.tag == .macos) {
250256 const macos = ;
251257 const NSApplication = macos.getClass("NSApplication");
252258 const app = macos.msgSend0(NSApplication, "sharedApplication");
253259
254 // NSApplicationActivateIgnoringOtherApps + request user attention
255 // NSInformationalRequest = 10
260 // `NSRequestUserAttentionType`: critical bounces until the app is
261 // activated, informational bounces once.
262 const NSCriticalRequest: c_long = 0;
256263 const NSInformationalRequest: c_long = 10;
257 _ = macos.msgSend1(app, "requestUserAttention:", NSInformationalRequest);
264
265 const request = if (data) |json_data| blk: {
266 break :blk if (std.mem.indexOf(u8, json_data, "critical") != null)
267 NSCriticalRequest
268 else
269 NSInformationalRequest;
270 } else NSInformationalRequest;
271
272 _ = macos.msgSend1(app, "requestUserAttention:", request);
258273 }
259274 }
260275
packages/zig/src/bridge_bluetooth.zigmodified+21-16
Changes to packages/zig/src/bridge_bluetooth.zig
@@ -463,16 +463,28 @@ pub const BluetoothBridge = struct {
463463
464464 /// Connect to a Bluetooth device
465465 /// JSON: {"address": "XX:XX:XX:XX:XX:XX"}
466 fn connectDevice(self: *Self, data: []const u8) !void {
467 _ = self;
468 var address: []const u8 = "";
469
470 if (std.mem.indexOf(u8, data, "\"address\":\"")) |idx| {
471 const start = idx + 11;
472 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
473 address = data[start..end];
466 /// The device a connect/disconnect names.
467 ///
468 /// `craft.bluetooth.connectDevice(id)` sends `{"id":"…"}`, and both
469 /// handlers scanned only for `"address":"`. The needle never matched, the
470 /// address stayed empty, and the call failed its own `MissingData` check —
471 /// so connecting to a device found by `craft.bluetooth.onDeviceFound` was
472 /// impossible. `address` stays accepted for anything posting raw messages.
473 fn deviceAddress(data: []const u8) []const u8 {
474 for ([_][]const u8{ "\"id\":\"", "\"address\":\"" }) |key| {
475 if (std.mem.indexOf(u8, data, key)) |idx| {
476 const start = idx + key.len;
477 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
478 return data[start..end];
479 }
474480 }
475481 }
482 return "";
483 }
484
485 fn connectDevice(self: *Self, data: []const u8) !void {
486 _ = self;
487 const address = deviceAddress(data);
476488
477489 if (address.len == 0) return BridgeError.MissingData;
478490
@@ -497,14 +509,7 @@ pub const BluetoothBridge = struct {
497509 /// JSON: {"address": "XX:XX:XX:XX:XX:XX"}
498510 fn disconnectDevice(self: *Self, data: []const u8) !void {
499511 _ = self;
500 var address: []const u8 = "";
501
502 if (std.mem.indexOf(u8, data, "\"address\":\"")) |idx| {
503 const start = idx + 11;
504 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
505 address = data[start..end];
506 }
507 }
512 const address = deviceAddress(data);
508513
509514 if (address.len == 0) return BridgeError.MissingData;
510515
packages/zig/src/bridge_fs.zigmodified+25-6
Changes to packages/zig/src/bridge_fs.zig
@@ -152,7 +152,13 @@ pub const FSBridge = struct {
152152 /// JSON: {"path": "/path/to/file", "content": "data", "callbackId": "cb1"}
153153 fn writeFile(self: *Self, data: []const u8) !void {
154154 const path = json_utils.getString(data, "path") orelse "";
155 const content = json_utils.getString(data, "content") orelse "";
155 // `craft.fs.writeFile(path, data)` sends the bytes under `data`; this
156 // read `content`, which the page has never sent, so `orelse ""` fired
157 // on every call and the file was created empty. `content` stays
158 // accepted for anything posting raw bridge messages against the old
159 // spelling.
160 const content = json_utils.getString(data, "data") orelse
161 json_utils.getString(data, "content") orelse "";
156162 const callback_id = json_utils.getString(data, "callbackId") orelse "";
157163
158164 if (path.len == 0) return BridgeError.MissingData;
@@ -179,7 +185,10 @@ pub const FSBridge = struct {
179185 /// JSON: {"path": "/path/to/file", "content": "data", "callbackId": "cb1"}
180186 fn appendFile(self: *Self, data: []const u8) !void {
181187 const path = json_utils.getString(data, "path") orelse "";
182 const content = json_utils.getString(data, "content") orelse "";
188 // Same mismatch as `writeFile`: the page sends `data`, this read
189 // `content`, and the append wrote nothing while reporting success.
190 const content = json_utils.getString(data, "data") orelse
191 json_utils.getString(data, "content") orelse "";
183192 const callback_id = json_utils.getString(data, "callbackId") orelse "";
184193
185194 if (path.len == 0) return BridgeError.MissingData;
@@ -451,8 +460,13 @@ pub const FSBridge = struct {
451460 /// Copy file
452461 /// JSON: {"src": "/path/from", "dest": "/path/to", "callbackId": "cb1"}
453462 fn copy(self: *Self, data: []const u8) !void {
454 const src = json_utils.getString(data, "src") orelse "";
455 const dest = json_utils.getString(data, "dest") orelse "";
463 // `craft.fs.copy(from, to)` sends `from`/`to`; this read `src`/`dest`,
464 // so both were empty and the call failed its own path check while the
465 // caller's promise resolved.
466 const src = json_utils.getString(data, "from") orelse
467 json_utils.getString(data, "src") orelse "";
468 const dest = json_utils.getString(data, "to") orelse
469 json_utils.getString(data, "dest") orelse "";
456470 const callback_id = json_utils.getString(data, "callbackId") orelse "";
457471
458472 if (src.len == 0 or dest.len == 0) return BridgeError.MissingData;
@@ -473,8 +487,13 @@ pub const FSBridge = struct {
473487 /// Move/rename file
474488 /// JSON: {"src": "/path/from", "dest": "/path/to", "callbackId": "cb1"}
475489 fn move(self: *Self, data: []const u8) !void {
476 const src = json_utils.getString(data, "src") orelse "";
477 const dest = json_utils.getString(data, "dest") orelse "";
490 // `craft.fs.move(from, to)` sends `from`/`to`; this read `src`/`dest`,
491 // so both were empty and the call failed its own path check while the
492 // caller's promise resolved.
493 const src = json_utils.getString(data, "from") orelse
494 json_utils.getString(data, "src") orelse "";
495 const dest = json_utils.getString(data, "to") orelse
496 json_utils.getString(data, "dest") orelse "";
478497 const callback_id = json_utils.getString(data, "callbackId") orelse "";
479498
480499 if (src.len == 0 or dest.len == 0) return BridgeError.MissingData;
packages/zig/src/bridge_updater.zigmodified+10-2
Changes to packages/zig/src/bridge_updater.zig
@@ -213,7 +213,12 @@ pub const UpdaterBridge = struct {
213213
214214 const macos = ;
215215
216 // `craft.updater.setAutomaticChecks(on)` sends `{"value":…}`; this
217 // declared only `enabled`, so `ignore_unknown_fields` dropped the
218 // boolean and every call read the `false` default — automatic checks
219 // could be turned off but never on. `enabled` stays accepted.
216220 const EnabledParams = struct {
221 value: ?bool = null,
217222 enabled: bool = false,
218223 };
219224
@@ -222,7 +227,7 @@ pub const UpdaterBridge = struct {
222227 .allocate = .alloc_always,
223228 }) catch return BridgeError.InvalidJSON;
224229 defer parsed.deinit();
225 const enabled = parsed.value.enabled;
230 const enabled = parsed.value.value orelse parsed.value.enabled;
226231
227232 self.automatic_checks = enabled;
228233
@@ -242,7 +247,10 @@ pub const UpdaterBridge = struct {
242247
243248 const macos = ;
244249
250 // Same mismatch: the page sends `{"seconds":…}` and this declared only
251 // `interval`, so every interval silently stayed at the 24-hour default.
245252 const IntervalParams = struct {
253 seconds: ?u32 = null,
246254 interval: u32 = 86400,
247255 };
248256
@@ -251,7 +259,7 @@ pub const UpdaterBridge = struct {
251259 .allocate = .alloc_always,
252260 }) catch return BridgeError.InvalidJSON;
253261 defer parsed.deinit();
254 const interval = parsed.value.interval;
262 const interval = parsed.value.seconds orelse parsed.value.interval;
255263
256264 self.check_interval = interval;
257265
packages/zig/src/bridge_window.zigmodified+25-5
Changes to packages/zig/src/bridge_window.zig
@@ -216,7 +216,12 @@ pub const WindowBridge = struct {
216216 // a payload like `{"resize":false,"fullscreen":true}` was read as
217217 // `fullscreen=false` because the `:false` needle fired first.
218218 const should_be_fullscreen = if (data) |json_data|
219 json_utils.getBool(json_data, "fullscreen") orelse true
219 // `craft.window.setFullscreen(on)` sends `{"value":…}`; this read
220 // `fullscreen`, which the page has never sent, so `orelse true`
221 // fired on every call and `setFullscreen(false)` *entered*
222 // fullscreen. `fullscreen` stays accepted for raw callers.
223 json_utils.getBool(json_data, "value") orelse
224 json_utils.getBool(json_data, "fullscreen") orelse true
220225 else
221226 true;
222227
@@ -325,8 +330,19 @@ pub const WindowBridge = struct {
325330 // Parse vibrancy type from {"vibrancy": "..."}
326331 var vibrancy_type: []const u8 = "none";
327332 if (data) |json_data| {
328 if (std.mem.indexOf(u8, json_data, "\"vibrancy\":\"")) |idx| {
329 const start = idx + 12;
333 // `setVibrancy(material)` sends `{"material":"sidebar"}`;
334 // this scanned for `"vibrancy":"`, never matched, and left
335 // "none" — which takes the *removal* branch below, so the call
336 // stripped vibrancy instead of applying it.
337 const vkey = if (std.mem.indexOf(u8, json_data, "\"material\":\"") != null)
338 "\"material\":\""
339 else
340 "\"vibrancy\":\"";
341 if (std.mem.indexOf(u8, json_data, vkey)) |idx| {
342 // `vkey.len`, not a literal: the two spellings happen to
343 // be the same length, which is luck and not something the
344 // next key added here would inherit.
345 const start = idx + vkey.len;
330346 if (std.mem.indexOfPos(u8, json_data, start, "\"")) |end| {
331347 vibrancy_type = json_data[start..end];
332348 }
@@ -423,8 +439,12 @@ pub const WindowBridge = struct {
423439 var opacity: f64 = 1.0;
424440 if (data) |json_data| {
425441 // Parse {"opacity": 0.8}
426 if (std.mem.indexOf(u8, json_data, "\"opacity\":")) |idx| {
427 var start = idx + 10;
442 // The page sends `{"value":0.4}`; this scanned for `"opacity":`,
443 // never matched, and left the default 1.0 — so every opacity was
444 // fully opaque and each value was indistinguishable from the next.
445 const key = if (std.mem.indexOf(u8, json_data, "\"value\":") != null) "\"value\":" else "\"opacity\":";
446 if (std.mem.indexOf(u8, json_data, key)) |idx| {
447 var start = idx + key.len;
428448 while (start < json_data.len and (json_data[start] == ' ' or json_data[start] == '\t')) : (start += 1) {}
429449 var end = start;
430450 while (end < json_data.len and ((json_data[end] >= '0' and json_data[end] <= '9') or json_data[end] == '.')) : (end += 1) {}
packages/zig/test/capabilities_test.zigmodified+266-0
Changes to packages/zig/test/capabilities_test.zig
@@ -416,3 +416,269 @@ test "the manifest renders and parses" {
416416 try testing.expect(channels.count() > 0);
417417 try testing.expect(channels.get("craft:fs:change") != null);
418418}
419
420test "every bridge type the page can send is routed by the dispatcher" {
421 // The direction nothing checked. `test "the registry covers exactly the
422 // namespaces the dispatcher routes"` pins the registry against the
423 // dispatcher, and the channel test pins the events — but what the page can
424 // *call* was never compared against what native will answer.
425 //
426 // A new `craft.foo.bar()` added to the facade with no arm in
427 // `handleBridgeMessageJSON` posts a message that is parsed, matched
428 // against every arm, and dropped. `_send` resolves the moment the message
429 // leaves, so the caller gets a fulfilled promise and nothing happens —
430 // silent, and exactly the shape of #27, #47 and #69.
431 const chain = blk: {
432 const start = std.mem.indexOf(u8, dispatcher_source, "pub fn handleBridgeMessageJSON") orelse
433 return error.DispatcherNotFound;
434 const end = std.mem.indexOfPos(u8, dispatcher_source, start, "\npub fn handleBridgeMessage(") orelse
435 dispatcher_source.len;
436 break :blk dispatcher_source[start..end];
437 };
438
439 var checked: usize = 0;
440 var unrouted: usize = 0;
441
442 // `_send('type', 'action'` / `_req(` / `_post(` — the three ways the
443 // facade posts a message.
444 for ([_][]const u8{ "_send('", "_req('", "_post('" }) |opener| {
445 var search: usize = 0;
446 while (std.mem.indexOfPos(u8, bridge_js, search, opener)) |hit| {
447 search = hit + opener.len;
448 const rest = bridge_js[search..];
449 const close = std.mem.indexOfScalar(u8, rest, '\'') orelse continue;
450 const msg_type = rest[0..close];
451 if (msg_type.len == 0 or msg_type.len > 64) continue;
452
453 checked += 1;
454
455 var needle_buf: [128]u8 = undefined;
456 const needle = try std.fmt.bufPrint(&needle_buf, "msg_type, \"{s}\"", .{msg_type});
457 if (std.mem.indexOf(u8, chain, needle) == null) {
458 unrouted += 1;
459 std.debug.print(
460 "craft-bridge.js posts type '{s}' but handleBridgeMessageJSON does not route it\n",
461 .{msg_type},
462 );
463 }
464 }
465 }
466
467 // Floor: the facade posts hundreds of messages. If a refactor changes how
468 // they are written this scan finds none and would otherwise pass having
469 // compared nothing at all.
470 try testing.expect(checked >= 200);
471 try testing.expectEqual(@as(usize, 0), unrouted);
472}
473
474/// Bridges whose payload field names are checked against what the page sends.
475///
476/// A ratchet like `max_undeclared`: this list may only grow. Adding a bridge
477/// here is what "the fields on both sides agree" costs, and it is cheap —
478/// every entry below was added after the audit that found nine bridges reading
479/// keys the page has never sent.
480const field_checked = [_]struct {
481 /// The bridge type as the page names it in `_send`/`_req`.
482 namespace: []const u8,
483 source: []const u8,
484}{
485 .{ .namespace = "fs", .source = @embedFile("src/bridge_fs.zig") },
486 .{ .namespace = "window", .source = @embedFile("src/bridge_window.zig") },
487 .{ .namespace = "updater", .source = @embedFile("src/bridge_updater.zig") },
488 .{ .namespace = "bluetooth", .source = @embedFile("src/bridge_bluetooth.zig") },
489 .{ .namespace = "serial", .source = @embedFile("src/bridge_serial.zig") },
490 .{ .namespace = "localServer", .source = @embedFile("src/bridge_local_server.zig") },
491};
492
493/// The body of `fn <action>(...)`, from its signature to the next ` fn `.
494///
495/// Narrowing the search to the handler is what makes this catch a name used in
496/// the *wrong* handler — `writeFile` reading a key only `readFile` sends. A
497/// file-wide search cannot: the first version of this test passed against the
498/// exact bug it was written for, because the name still appeared elsewhere in
499/// the same file.
500fn handlerBody(source: []const u8, action: []const u8) ?[]const u8 {
501 var needle_buf: [96]u8 = undefined;
502 const needle = std.fmt.bufPrint(&needle_buf, "\n fn {s}(", .{action}) catch return null;
503 const start = std.mem.indexOf(u8, source, needle) orelse return null;
504 const after = start + needle.len;
505
506 // Ends at the next declaration *or* the next doc comment, whichever comes
507 // first. Stopping only at `fn ` swallows the following function's `///`
508 // block — and these bridges document their payloads in it, so
509 // `appendFile`'s `/// JSON: {"content": "data"}` leaked into `writeFile`'s
510 // span and made the check pass against the bug it was written for.
511 const next_fn = std.mem.indexOfPos(u8, source, after, "\n fn ") orelse source.len;
512 const next_doc = std.mem.indexOfPos(u8, source, after, "\n ///") orelse source.len;
513 return source[start..@min(next_fn, next_doc)];
514}
515
516/// Whether a bridge's source mentions `name` as a payload field.
517///
518/// Two spellings, because bridges read payloads two ways. Some scan for a
519/// literal needle — `json_utils.getString(data, "path")` — where the name
520/// appears quoted. Others parse into a struct, where it is an unquoted field
521/// declaration: `path: []const u8 = ""`. A check that knew only the first
522/// reports every struct-based bridge as broken, which is exactly what the
523/// first version of this test did.
524fn bridgeKnowsField(source: []const u8, name: []const u8) bool {
525 var quoted_buf: [96]u8 = undefined;
526 const quoted = std.fmt.bufPrint(&quoted_buf, "\"{s}\"", .{name}) catch return true;
527 if (std.mem.indexOf(u8, source, quoted) != null) return true;
528
529 // A struct field: the name at the start of an indented line, followed by a
530 // colon. Anchored on the newline so `path:` inside `filePath:` or a
531 // comment does not count.
532 var field_buf: [96]u8 = undefined;
533 const field = std.fmt.bufPrint(&field_buf, "\n {s}: ", .{name}) catch return true;
534 if (std.mem.indexOf(u8, source, field) != null) return true;
535
536 // A struct field at any indentation, identified by its default: every
537 // payload struct in these bridges declares one (`baud: u32 = 9600`).
538 //
539 // The default is what tells a field from a *parameter*. `fn writeFile(self:
540 // *Self, data: []const u8)` contains `data: `, and accepting that made the
541 // check pass against the exact bug it was written for — the handler was
542 // reading the wrong key while its own parameter supplied the name.
543 var nested_buf: [96]u8 = undefined;
544 const nested = std.fmt.bufPrint(&nested_buf, "{s}: ", .{name}) catch return true;
545 var search: usize = 0;
546 while (std.mem.indexOfPos(u8, source, search, nested)) |hit| {
547 search = hit + nested.len;
548 if (hit != 0) {
549 const before = source[hit - 1];
550 if (before != ' ' and before != '\n' and before != '\t') continue;
551 }
552 const line_end = std.mem.indexOfScalarPos(u8, source, hit, '\n') orelse source.len;
553 if (std.mem.indexOfScalarPos(u8, source[0..line_end], hit, '=') != null) return true;
554 }
555 return false;
556}
557
558/// Fields a handler legitimately does not name.
559///
560/// Narrowing the search to the handler is what makes the check catch a name
561/// read in the wrong place — but it also flags two honest patterns: a handler
562/// that reads the payload without keying off the field name, and one that
563/// delegates the parsing to a helper. Each exemption costs a line and a
564/// reason, which is the point: the list is short and every entry is arguable,
565/// rather than the check being weakened for everyone.
566const field_exempt = [_]struct {
567 namespace: []const u8,
568 action: []const u8,
569 field: []const u8,
570 why: []const u8,
571}{
572 .{
573 .namespace = "window",
574 .action = "setAlwaysOnTop",
575 .field = "value",
576 .why = "scans the payload for the literal \"false\", so it reads the boolean without naming the key",
577 },
578 .{ .namespace = "window", .action = "setResizable", .field = "value", .why = "same key-agnostic boolean scan" },
579 .{ .namespace = "window", .action = "setMovable", .field = "value", .why = "same key-agnostic boolean scan" },
580 .{ .namespace = "window", .action = "setHasShadow", .field = "value", .why = "same key-agnostic boolean scan" },
581 .{
582 .namespace = "bluetooth",
583 .action = "connectDevice",
584 .field = "id",
585 .why = "delegates to `deviceAddress`, which accepts both \"id\" and \"address\"",
586 },
587 .{ .namespace = "bluetooth", .action = "disconnectDevice", .field = "id", .why = "same helper" },
588};
589
590fn isExempt(namespace: []const u8, action: []const u8, field: []const u8) bool {
591 for (field_exempt) |e| {
592 if (std.mem.eql(u8, e.namespace, namespace) and
593 std.mem.eql(u8, e.action, action) and
594 std.mem.eql(u8, e.field, field)) return true;
595 }
596 return false;
597}
598
599test "every field the page sends is a name the handler actually looks for" {
600 // The bug class this exists for, from issue #69 and the audit that
601 // followed it: the page posts `{"data": "..."}`, the handler reads
602 // `"content"`, and nothing objects. Every bridge parses with
603 // `ignore_unknown_fields = true` or scans for a literal needle, so a name
604 // that does not match is not an error — the value is simply gone and the
605 // caller's promise resolves.
606 //
607 // It cost `craft.fs.writeFile` writing empty files, `setFullscreen(false)`
608 // entering fullscreen, `setVibrancy` removing vibrancy, and Bluetooth
609 // connects that could never name a device.
610 //
611 // The check is deliberately loose: it asks only whether the field name
612 // appears *somewhere* in the handling bridge, quoted. That is enough to
613 // catch a name the native side has never heard of, which is the whole
614 // failure, without trying to model which handler owns which action.
615 var checked: usize = 0;
616 var missing: usize = 0;
617
618 for ([_][]const u8{ "_send('", "_req('" }) |opener| {
619 var search: usize = 0;
620 while (std.mem.indexOfPos(u8, bridge_js, search, opener)) |hit| {
621 search = hit + opener.len;
622 const rest = bridge_js[search..];
623
624 const type_end = std.mem.indexOfScalar(u8, rest, '\'') orelse continue;
625 const msg_type = rest[0..type_end];
626
627 // Only the bridges that have opted in.
628 var source: ?[]const u8 = null;
629 for (field_checked) |entry| {
630 if (std.mem.eql(u8, entry.namespace, msg_type)) source = entry.source;
631 }
632 if (source == null) continue;
633
634 // The action, so the search can be narrowed to its handler.
635 const after_type = rest[type_end + 1 ..];
636 const a_open = std.mem.indexOf(u8, after_type, "'") orelse continue;
637 const a_rest = after_type[a_open + 1 ..];
638 const a_end = std.mem.indexOfScalar(u8, a_rest, '\'') orelse continue;
639 const action_name = a_rest[0..a_end];
640
641 // `fn <action>(` when the handler is named after the action, which
642 // is the convention throughout. Falls back to the whole file when
643 // it is not — looser, but never a false alarm.
644 const scope = handlerBody(source.?, action_name) orelse source.?;
645
646 // The `_stringify({ ... })` literal for this call, if it has one.
647 const call_end = std.mem.indexOfScalarPos(u8, bridge_js, search, '\n') orelse bridge_js.len;
648 const call = bridge_js[search..call_end];
649 const brace = std.mem.indexOf(u8, call, "_stringify({") orelse continue;
650 const obj_start = brace + "_stringify({".len;
651 const obj_end = std.mem.indexOfScalarPos(u8, call, obj_start, '}') orelse continue;
652 const obj = call[obj_start..obj_end];
653
654 // Field names: an identifier followed by a colon.
655 var i: usize = 0;
656 while (i < obj.len) {
657 while (i < obj.len and !std.ascii.isAlphabetic(obj[i])) i += 1;
658 const name_start = i;
659 while (i < obj.len and (std.ascii.isAlphanumeric(obj[i]) or obj[i] == '_')) i += 1;
660 if (i >= obj.len or name_start == i) break;
661 const name = obj[name_start..i];
662 var j = i;
663 while (j < obj.len and obj[j] == ' ') j += 1;
664 if (j >= obj.len or obj[j] != ':') continue;
665
666 checked += 1;
667 if (isExempt(msg_type, action_name, name)) continue;
668 if (!bridgeKnowsField(scope, name)) {
669 missing += 1;
670 std.debug.print(
671 "craft-bridge.js sends '{s}' to the {s} bridge, which never mentions that name —\n" ++
672 " the value is parsed and discarded, and the caller's promise still resolves.\n",
673 .{ name, msg_type },
674 );
675 }
676 }
677 }
678 }
679
680 // Floor: these six bridges carry dozens of fields between them. A scan
681 // that matched nothing would pass having compared nothing.
682 try testing.expect(checked >= 30);
683 try testing.expectEqual(@as(usize, 0), missing);
684}