also looking at this
feat(ios): haptic, whose reason expired when ios_config landed
#130
3 files
+236
-78
| @@ -643,6 +643,113 @@ test "the gate table has no entry for an action Zig does not serve" { | ||
| 643 | 643 | } |
| 644 | 644 | } |
| 645 | 645 | |
| 646 | /// The `.reason` text of every `.status = .unavailable` entry, by action. | |
| 647 | /// | |
| 648 | /// The reason is what a reader trusts, so it is what has to be checked. A | |
| 649 | /// declaration can be resolved two ways here: `.reason = "..."` inline, and | |
| 650 | /// `.reason = some_const` naming a constant elsewhere in the file — both are | |
| 651 | /// in use, and a scan that handled only the first would silently skip the | |
| 652 | /// second rather than fail. | |
| 653 | fn collectUnavailableReasons(allocator: std.mem.Allocator) !std.StringHashMap([]const u8) { | |
| 654 | var map = std.StringHashMap([]const u8).init(allocator); | |
| 655 | errdefer map.deinit(); | |
| 656 | ||
| 657 | for (zig_sources) |source| { | |
| 658 | // Bounded to the manifest array, not the whole file: prose quotes | |
| 659 | // `.status = .unavailable` — `bridge_mobile_haptics.zig`'s header does, | |
| 660 | // describing the declaration it removed — and a scan that read comments | |
| 661 | // would attribute that sentence to whichever entry preceded it. | |
| 662 | const table_start = std.mem.indexOf(u8, source, "pub const capability_actions = [_]capabilities.ActionDecl{") orelse | |
| 663 | continue; | |
| 664 | const table_end = std.mem.indexOfPos(u8, source, table_start, "\n};") orelse continue; | |
| 665 | const table = source[table_start..table_end]; | |
| 666 | ||
| 667 | var search: usize = 0; | |
| 668 | while (std.mem.indexOfPos(u8, table, search, ".status = .unavailable")) |at| { | |
| 669 | search = at + 1; | |
| 670 | ||
| 671 | // The entry names its action as `A.some_const`; resolve it back | |
| 672 | // through the `A` block to the string the spec spells. | |
| 673 | const name_at = std.mem.lastIndexOf(u8, table[0..at], ".name = A.") orelse continue; | |
| 674 | const const_start = name_at + ".name = A.".len; | |
| 675 | var const_end = const_start; | |
| 676 | while (const_end < table.len and (std.ascii.isAlphanumeric(table[const_end]) or | |
| 677 | table[const_end] == '_')) : (const_end += 1) | |
| 678 | {} | |
| 679 | ||
| 680 | var needle_buf: [96]u8 = undefined; | |
| 681 | const member = table[const_start..const_end]; | |
| 682 | if (member.len + 16 > needle_buf.len) continue; | |
| 683 | const decl = try std.fmt.bufPrint(&needle_buf, "const {s} = \"", .{member}); | |
| 684 | const decl_at = std.mem.indexOf(u8, source, decl) orelse continue; | |
| 685 | const value_start = decl_at + decl.len; | |
| 686 | const value_end = std.mem.indexOfScalarPos(u8, source, value_start, '"') orelse continue; | |
| 687 | const action = source[value_start..value_end]; | |
| 688 | ||
| 689 | // The reason, inline or by name. | |
| 690 | const reason_at = std.mem.indexOfPos(u8, table, at, ".reason = ") orelse continue; | |
| 691 | const reason_start = reason_at + ".reason = ".len; | |
| 692 | if (table[reason_start] == '"') { | |
| 693 | const reason_end = std.mem.indexOfScalarPos(u8, table, reason_start + 1, '"') orelse continue; | |
| 694 | try map.put(action, table[reason_start + 1 .. reason_end]); | |
| 695 | } else { | |
| 696 | var ident_end = reason_start; | |
| 697 | while (ident_end < table.len and (std.ascii.isAlphanumeric(table[ident_end]) or | |
| 698 | table[ident_end] == '_')) : (ident_end += 1) | |
| 699 | {} | |
| 700 | var const_buf: [96]u8 = undefined; | |
| 701 | const ident = table[reason_start..ident_end]; | |
| 702 | if (ident.len + 10 > const_buf.len) continue; | |
| 703 | const const_decl = try std.fmt.bufPrint(&const_buf, "const {s} =", .{ident}); | |
| 704 | const body_at = std.mem.indexOf(u8, source, const_decl) orelse continue; | |
| 705 | const body_end = std.mem.indexOfScalarPos(u8, source, body_at, ';') orelse continue; | |
| 706 | // The whole initialiser, `++` concatenation included. | |
| 707 | try map.put(action, source[body_at..body_end]); | |
| 708 | } | |
| 709 | } | |
| 710 | } | |
| 711 | return map; | |
| 712 | } | |
| 713 | ||
| 714 | test "no refusal blames a config flag Zig demonstrably reads" { | |
| 715 | // The rot this exists to catch, in the exact shape it took. `haptic` was | |
| 716 | // declared `.status = .unavailable` because `config.enableHaptics` "is not | |
| 717 | // visible to Zig"; `gateFor`'s table said Zig reads that same flag and | |
| 718 | // refuses the action when it is off. Both were written honestly, months | |
| 719 | // apart, and nothing put them side by side — so the refusal outlived its | |
| 720 | // reason and a working implementation sat unused two hundred lines below it. | |
| 721 | // | |
| 722 | // Deliberately narrower than "gated and unavailable", which is not a | |
| 723 | // contradiction: `lockOrientation` is both, and its reason is about a root | |
| 724 | // view controller rather than about reading a flag. What cannot stand is a | |
| 725 | // refusal *naming* a flag the gate table proves Zig reads. | |
| 726 | var gates = try collectZigGates(testing.allocator); | |
| 727 | defer gates.deinit(); | |
| 728 | ||
| 729 | var reasons = try collectUnavailableReasons(testing.allocator); | |
| 730 | defer reasons.deinit(); | |
| 731 | ||
| 732 | // Non-vacuity: the scan reads each manifest through two indirections — the | |
| 733 | // `A.` constant and its declaration — and either could stop matching. | |
| 734 | try testing.expect(reasons.count() >= 3); | |
| 735 | ||
| 736 | var it = reasons.iterator(); | |
| 737 | while (it.next()) |entry| { | |
| 738 | const flag = gates.get(entry.key_ptr.*) orelse continue; | |
| 739 | if (std.mem.indexOf(u8, entry.value_ptr.*, flag) != null) { | |
| 740 | std.debug.print( | |
| 741 | "`{s}` is declared unavailable and its reason names `{s}`, which `gateFor` " ++ | |
| 742 | "maps it to.\n" ++ | |
| 743 | " The manifest says Zig cannot read that flag; the gate table says it does " ++ | |
| 744 | "and refuses the action when it is off.\n" ++ | |
| 745 | " One of them is out of date.\n", | |
| 746 | .{ entry.key_ptr.*, flag }, | |
| 747 | ); | |
| 748 | return error.RefusalBlamesAFlagZigReads; | |
| 749 | } | |
| 750 | } | |
| 751 | } | |
| 752 | ||
| 646 | 753 | test "every flag Zig can read is a flag the spec's config actually has" { |
| 647 | 754 | // A misspelled key is not a one-flag bug. A key that is not in the file |
| 648 | 755 | // reads as missing, and one missing key makes the whole decode throw, so |