c5be8085
build: add pinned linting and reliable development checks
a73x 2026-09-06 09:23
Commit message
.gitignore
| Old | New | ||
|---|---|---|---|
| @@ -21,3 +21,6 @@ deps/quic/work | |||
| 21 | 21 | ||
| 22 | # Shadow SDK from deps/mac-sdk.sh, Darwin only. | 22 | # Shadow SDK from deps/mac-sdk.sh, Darwin only. |
| 23 | deps/mac-sdk | 23 | deps/mac-sdk |
| 24 | |||
| 25 | # Pinned linter release cache (tools/fetch-zlint.sh). | ||
| 26 | deps/zlint/ | ||
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -31,7 +31,7 @@ SHA256 ?= shasum -a 256 | |||
| 31 | endif | 31 | endif |
| 32 | MUX_TARGET ?= x86_64-linux-musl | 32 | MUX_TARGET ?= x86_64-linux-musl |
| 33 | 33 | ||
| 34 | .PHONY: build check ci test daemon-test client-test native-core-test e2e soak bench agent native native-e2e native-stress throughput vm coverage deps clean clean-deps xversion xversion-build install release release-mac mac-sdk mac xos provision-mac | 34 | .PHONY: build lint check ci test daemon-test client-test native-core-test e2e soak bench agent native native-e2e native-stress throughput vm coverage deps clean clean-deps xversion xversion-build install release release-mac mac-sdk mac xos provision-mac |
| 35 | 35 | ||
| 36 | # The QUIC stack (deps/quic) is built on demand by build.zig, so no target | 36 | # The QUIC stack (deps/quic) is built on demand by build.zig, so no target |
| 37 | # here needs to depend on this one. It exists to make the one-time cost | 37 | # here needs to depend on this one. It exists to make the one-time cost |
| @@ -245,12 +245,16 @@ xos: build | |||
| 245 | $(ZIG) build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe -p $(XOSDIR) | 245 | $(ZIG) build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe -p $(XOSDIR) |
| 246 | ./test/xos.sh $(XOSDIR)/bin/mux zig-out/bin/mux zig-out/bin/ptyclient | 246 | ./test/xos.sh $(XOSDIR)/bin/mux zig-out/bin/mux zig-out/bin/ptyclient |
| 247 | 247 | ||
| 248 | lint: | ||
| 249 | sh tools/lint.sh | ||
| 250 | |||
| 248 | # `zig build check` grades the tree; bans.sh grades the grader. The folder | 251 | # `zig build check` grades the tree; bans.sh grades the grader. The folder |
| 249 | # rules are the one gate whose failure mode is silence — a needle list | 252 | # rules are the one gate whose failure mode is silence — a needle list |
| 250 | # edited down to nothing, or a folder dropped from a rule's list, leaves a | 253 | # edited down to nothing, or a folder dropped from a rule's list, leaves a |
| 251 | # green tree asserting rules that no longer bite — so one planted needle per | 254 | # green tree asserting rules that no longer bite — so one planted needle per |
| 252 | # rule runs here, after the gate it is checking. Two seconds. | 255 | # rule runs here, after the gate it is checking. Two seconds. |
| 253 | check: mac-sdk | 256 | check: mac-sdk |
| 257 | $(MAKE) lint | ||
| 254 | $(ZIG) build check | 258 | $(ZIG) build check |
| 255 | sh test/bans.sh $(ZIG) | 259 | sh test/bans.sh $(ZIG) |
| 256 | 260 | ||
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -392,10 +392,9 @@ fn checkSourceBans(b: *std.Build) void { | |||
| 392 | /// container-level `test` opens at column 0 and its `}` closes there and | 392 | /// container-level `test` opens at column 0 and its `}` closes there and |
| 393 | /// nowhere else. | 393 | /// nowhere else. |
| 394 | /// | 394 | /// |
| 395 | /// The escape hatch is a line saying `folder rule N exemption:` and why. It | 395 | /// A `// folder rule N exemption: reason` exempts only the following line. |
| 396 | /// is per FILE and deliberately blunt: a file that has to spell one of these | 396 | /// Keeping the allowance beside the occurrence prevents one legitimate escape |
| 397 | /// is a design fact worth one visible line, not a per-site suppression | 397 | /// sequence from permitting unrelated terminal calls elsewhere in the file. |
| 398 | /// nobody reads. | ||
| 399 | const SourceBan = struct { | 398 | const SourceBan = struct { |
| 400 | rule: []const u8, | 399 | rule: []const u8, |
| 401 | folders: []const []const u8, | 400 | folders: []const []const u8, |
| @@ -490,6 +489,7 @@ const source_bans = [_]SourceBan{ | |||
| 490 | 489 | ||
| 491 | fn checkSourceBan(b: *std.Build, ban: SourceBan) void { | 490 | fn checkSourceBan(b: *std.Build, ban: SourceBan) void { |
| 492 | const exempt = b.fmt("folder rule {s} exemption:", .{ban.rule}); | 491 | const exempt = b.fmt("folder rule {s} exemption:", .{ban.rule}); |
| 492 | const marker = b.fmt("// {s}", .{exempt}); | ||
| 493 | for (ban.folders) |sub| { | 493 | for (ban.folders) |sub| { |
| 494 | var paths: std.ArrayList([]const u8) = .empty; | 494 | var paths: std.ArrayList([]const u8) = .empty; |
| 495 | zigFilesIn(b, sub, &paths); | 495 | zigFilesIn(b, sub, &paths); |
| @@ -501,12 +501,21 @@ fn checkSourceBan(b: *std.Build, ban: SourceBan) void { | |||
| 501 | if (excepted) continue; | 501 | if (excepted) continue; |
| 502 | const src = b.build_root.handle.readFileAlloc(b.allocator, path, 4 << 20) catch |e| | 502 | const src = b.build_root.handle.readFileAlloc(b.allocator, path, 4 << 20) catch |e| |
| 503 | fatal("folder rule {s}: cannot read {s} ({s})", .{ ban.rule, path, @errorName(e) }); | 503 | fatal("folder rule {s}: cannot read {s} ({s})", .{ ban.rule, path, @errorName(e) }); |
| 504 | if (std.mem.indexOf(u8, src, exempt) != null) continue; | ||
| 505 | var in_test = false; | 504 | var in_test = false; |
| 506 | var lineno: usize = 0; | 505 | var lineno: usize = 0; |
| 506 | var exempt_next = false; | ||
| 507 | var it = std.mem.splitScalar(u8, src, '\n'); | 507 | var it = std.mem.splitScalar(u8, src, '\n'); |
| 508 | while (it.next()) |line| { | 508 | while (it.next()) |line| { |
| 509 | lineno += 1; | 509 | lineno += 1; |
| 510 | const allowed = exempt_next; | ||
| 511 | exempt_next = false; | ||
| 512 | const trimmed = std.mem.trim(u8, line, " \t\r"); | ||
| 513 | if (std.mem.startsWith(u8, trimmed, marker)) { | ||
| 514 | if (std.mem.trim(u8, trimmed[marker.len..], " \t").len == 0) | ||
| 515 | fatal("folder rule {s} broken: {s}:{d}: exemption needs a reason", .{ ban.rule, path, lineno }); | ||
| 516 | exempt_next = true; | ||
| 517 | continue; | ||
| 518 | } | ||
| 510 | if (in_test) { | 519 | if (in_test) { |
| 511 | if (std.mem.eql(u8, std.mem.trimRight(u8, line, "\r"), "}")) in_test = false; | 520 | if (std.mem.eql(u8, std.mem.trimRight(u8, line, "\r"), "}")) in_test = false; |
| 512 | continue; | 521 | continue; |
| @@ -517,6 +526,7 @@ fn checkSourceBan(b: *std.Build, ban: SourceBan) void { | |||
| 517 | in_test = true; | 526 | in_test = true; |
| 518 | continue; | 527 | continue; |
| 519 | } | 528 | } |
| 529 | if (allowed) continue; | ||
| 520 | // Lower-cased once per line, because the bytes a rule bans | 530 | // Lower-cased once per line, because the bytes a rule bans |
| 521 | // have more than one spelling: `\X1B[` is the escape the | 531 | // have more than one spelling: `\X1B[` is the escape the |
| 522 | // needle names, and case is the only thing between them. | 532 | // needle names, and case is the only thing between them. |
| @@ -526,7 +536,7 @@ fn checkSourceBan(b: *std.Build, ban: SourceBan) void { | |||
| 526 | if (std.mem.indexOf(u8, lower, n) != null) fatal( | 536 | if (std.mem.indexOf(u8, lower, n) != null) fatal( |
| 527 | "folder rule {s} broken: {s}:{d} spells `{s}` outside a test " ++ | 537 | "folder rule {s} broken: {s}:{d} spells `{s}` outside a test " ++ |
| 528 | "block — {s}. Move it, or write one line `// folder rule " ++ | 538 | "block — {s}. Move it, or write one line `// folder rule " ++ |
| 529 | "{s} exemption: <why>` in the file", | 539 | "{s} exemption: <why>` immediately before this line", |
| 530 | .{ ban.rule, path, lineno, n, ban.why, ban.rule }, | 540 | .{ ban.rule, path, lineno, n, ban.why, ban.rule }, |
| 531 | ); | 541 | ); |
| 532 | } | 542 | } |
| @@ -680,16 +690,8 @@ fn checkOneRootReaches(b: *std.Build, root: []const u8, subdir: []const u8, pref | |||
| 680 | } | 690 | } |
| 681 | } | 691 | } |
| 682 | 692 | ||
| 683 | /// The gates that are shell get a gate of their own. test/*.sh and tools/*.sh | 693 | /// Shell syntax and ShellCheck are required by check; missing tools fail the |
| 684 | /// ARE the e2e, agent, soak and valgrind checks; nothing type-checks them, so | 694 | /// step instead of silently reducing coverage on a fresh machine. |
| 685 | /// an unbalanced quote turns a gate into a script that dies on line 3 — and a | ||
| 686 | /// suite that never ran is the one failure mode a green tree cannot show. | ||
| 687 | /// `sh -n` parses without executing a single line, so the whole set costs | ||
| 688 | /// milliseconds. Globbed, never listed: a script added tomorrow is covered by | ||
| 689 | /// this step without anybody remembering to add it. shellcheck runs too when | ||
| 690 | /// it happens to be on PATH, at error severity only — a linter that is not | ||
| 691 | /// installed everywhere must never be the difference between a green tree and | ||
| 692 | /// a red one, and style opinions are not what this gate is for. | ||
| 693 | fn shellGate(b: *std.Build, step: *std.Build.Step) void { | 695 | fn shellGate(b: *std.Build, step: *std.Build.Step) void { |
| 694 | var paths: [64][]const u8 = undefined; | 696 | var paths: [64][]const u8 = undefined; |
| 695 | var n: usize = 0; | 697 | var n: usize = 0; |
| @@ -723,13 +725,11 @@ fn shellGate(b: *std.Build, step: *std.Build.Step) void { | |||
| 723 | run.expectExitCode(0); | 725 | run.expectExitCode(0); |
| 724 | step.dependOn(&run.step); | 726 | step.dependOn(&run.step); |
| 725 | } | 727 | } |
| 726 | if (b.findProgram(&.{"shellcheck"}, &.{})) |sc| { | 728 | const run = b.addSystemCommand(&.{ "shellcheck", "--severity=error" }); |
| 727 | const run = b.addSystemCommand(&.{ sc, "--severity=error" }); | 729 | for (paths[0..n]) |p| run.addFileArg(b.path(p)); |
| 728 | for (paths[0..n]) |p| run.addFileArg(b.path(p)); | 730 | run.setName("shellcheck (severity=error)"); |
| 729 | run.setName("shellcheck (severity=error)"); | 731 | run.expectExitCode(0); |
| 730 | run.expectExitCode(0); | 732 | step.dependOn(&run.step); |
| 731 | step.dependOn(&run.step); | ||
| 732 | } else |_| {} | ||
| 733 | } | 733 | } |
| 734 | 734 | ||
| 735 | /// Collect `<sub>/*.zig`, sorted, into `paths`. Globbed rather than listed for | 735 | /// Collect `<sub>/*.zig`, sorted, into `paths`. Globbed rather than listed for |
| @@ -1082,37 +1082,24 @@ pub fn build(b: *std.Build) void { | |||
| 1082 | } | 1082 | } |
| 1083 | } | 1083 | } |
| 1084 | 1084 | ||
| 1085 | // web/verify.js drives mux_core.wasm through the page's real call | 1085 | // The web ABI check is part of every test run; Node is a test dependency. |
| 1086 | // sequence, and it was the only check of the wasm ABI that `make test` | 1086 | const verify = b.addSystemCommand(&.{"node"}); |
| 1087 | // did not run — so an export the JS shell depends on could be renamed, | 1087 | // Both as FILE args, not strings: a string argument is not an input |
| 1088 | // or its return contract changed, with every Zig test still green. | 1088 | // the build graph hashes, so a doctored verify.js would have stayed |
| 1089 | // Gated on node rather than required: the wasm core builds and the Zig | 1089 | // cached and the check would have been decorative. (Caught by |
| 1090 | // suite passes without it, and a missing node must not turn `make test` | 1090 | // doctoring one, exactly as the fix round asked.) |
| 1091 | // red on a machine that never opens a browser. | 1091 | verify.addFileArg(b.path("web/verify.js")); |
| 1092 | if (b.findProgram(&.{"node"}, &.{})) |node| { | 1092 | verify.addFileArg(wasm_exe.getEmittedBin()); |
| 1093 | const verify = b.addSystemCommand(&.{node}); | 1093 | // Hashed as INPUTS though they are not argv: verify.js reads both at |
| 1094 | // Both as FILE args, not strings: a string argument is not an input | 1094 | // runtime (the shell's call list and the page), so without these a |
| 1095 | // the build graph hashes, so a doctored verify.js would have stayed | 1095 | // mux.js change replays a stale cached pass — which is exactly how |
| 1096 | // cached and the check would have been decorative. (Caught by | 1096 | // the dataset.tileId harness gap shipped green. |
| 1097 | // doctoring one, exactly as the fix round asked.) | 1097 | verify.addFileInput(b.path("web/mux.js")); |
| 1098 | verify.addFileArg(b.path("web/verify.js")); | 1098 | verify.addFileInput(b.path("web/index.html")); |
| 1099 | verify.addFileArg(wasm_exe.getEmittedBin()); | 1099 | verify.setName("verify wasm ABI (web/verify.js)"); |
| 1100 | // Hashed as INPUTS though they are not argv: verify.js reads both at | 1100 | // stdio is the assertion: a failing check exits non-zero. |
| 1101 | // runtime (the shell's call list and the page), so without these a | 1101 | verify.expectExitCode(0); |
| 1102 | // mux.js change replays a stale cached pass — which is exactly how | 1102 | test_step.dependOn(&verify.step); |
| 1103 | // the dataset.tileId harness gap shipped green. | ||
| 1104 | verify.addFileInput(b.path("web/mux.js")); | ||
| 1105 | verify.addFileInput(b.path("web/index.html")); | ||
| 1106 | verify.setName("verify wasm ABI (web/verify.js)"); | ||
| 1107 | // stdio is the assertion: a failing check exits non-zero. | ||
| 1108 | verify.expectExitCode(0); | ||
| 1109 | test_step.dependOn(&verify.step); | ||
| 1110 | } else |_| { | ||
| 1111 | std.debug.print( | ||
| 1112 | "build: node not found — skipping web/verify.js (the wasm ABI check)\n", | ||
| 1113 | .{}, | ||
| 1114 | ); | ||
| 1115 | } | ||
| 1116 | 1103 | ||
| 1117 | const e2e = b.addSystemCommand(&.{"test/e2e.sh"}); | 1104 | const e2e = b.addSystemCommand(&.{"test/e2e.sh"}); |
| 1118 | e2e.addArtifactArg(mux_exe); | 1105 | e2e.addArtifactArg(mux_exe); |
src/cli/flags.zig
| Old | New | ||
|---|---|---|---|
| @@ -3,7 +3,7 @@ | |||
| 3 | //! The grammar is `--flag VALUE`, with `--` ending flag parsing. Semantic | 3 | //! The grammar is `--flag VALUE`, with `--` ending flag parsing. Semantic |
| 4 | //! validation remains with the caller or with a field type that implements | 4 | //! validation remains with the caller or with a field type that implements |
| 5 | //! `parseCLI`. | 5 | //! `parseCLI`. |
| 6 | // folder rule 5 exemption: `/bin/sh` is the fallback executable name passed as | 6 | // Rationale: `/bin/sh` is the fallback executable name passed as |
| 7 | // argv[0], not a command string interpreted with `sh -c`. | 7 | // argv[0], not a command string interpreted with `sh -c`. |
| 8 | 8 | ||
| 9 | const std = @import("std"); | 9 | const std = @import("std"); |
| @@ -252,6 +252,7 @@ const DemoOptions = struct { | |||
| 252 | _cmd: u8 = 0, | 252 | _cmd: u8 = 0, |
| 253 | vt: bool = false, | 253 | vt: bool = false, |
| 254 | sock: ?[]const u8 = null, | 254 | sock: ?[]const u8 = null, |
| 255 | // folder rule 5 exemption: This is the fallback shell executable, invoked directly as argv. | ||
| 255 | shell: []const u8 = "/bin/sh", | 256 | shell: []const u8 = "/bin/sh", |
| 256 | cols: u16 = 80, | 257 | cols: u16 = 80, |
| 257 | quic_idle_ms: u32 = 15_000, | 258 | quic_idle_ms: u32 = 15_000, |
src/cli/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,7 @@ | |||
| 1 | //! `mux d` — the daemon mode. `start` hosts the session; `dump` prints the | 1 | //! `mux d` — the daemon mode. `start` hosts the session; `dump` prints the |
| 2 | //! authoritative grid over the protocol (debug aid, also used by e2e); | 2 | //! authoritative grid over the protocol (debug aid, also used by e2e); |
| 3 | //! `proxy` exposes the session socket over stdio for `mux --via`. | 3 | //! `proxy` exposes the session socket over stdio for `mux --via`. |
| 4 | // folder rule 5 exemption: spawning the user's shell is the daemon's purpose; | 4 | // Rationale: spawning the user's shell is the daemon's purpose; |
| 5 | // `/bin/sh` is the executable fallback when `$SHELL` is unset. | 5 | // `/bin/sh` is the executable fallback when `$SHELL` is unset. |
| 6 | 6 | ||
| 7 | const std = @import("std"); | 7 | const std = @import("std"); |
| @@ -590,6 +590,7 @@ fn run(alloc: std.mem.Allocator, o: DaemonArguments, sock_path: []const u8) !u8 | |||
| 590 | const shell_z: [:0]const u8 = if (o.shell) |s| | 590 | const shell_z: [:0]const u8 = if (o.shell) |s| |
| 591 | try alloc.dupeZ(u8, s) | 591 | try alloc.dupeZ(u8, s) |
| 592 | else | 592 | else |
| 593 | // folder rule 5 exemption: The daemon invokes the session shell directly as argv. | ||
| 593 | try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh"); | 594 | try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh"); |
| 594 | defer alloc.free(shell_z); | 595 | defer alloc.free(shell_z); |
| 595 | 596 | ||
src/cli/webhub_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -15,7 +15,6 @@ const webhub = @import("webhub"); | |||
| 15 | const hosts = @import("client").hosts; | 15 | const hosts = @import("client").hosts; |
| 16 | const build_options = @import("build_options"); | 16 | const build_options = @import("build_options"); |
| 17 | const xdg = @import("xdg"); | 17 | const xdg = @import("xdg"); |
| 18 | const sockpath = @import("sockpath"); | ||
| 19 | const cliflags = @import("cliflags"); | 18 | const cliflags = @import("cliflags"); |
| 20 | 19 | ||
| 21 | const usage = | 20 | const usage = |
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -7,7 +7,6 @@ | |||
| 7 | //! pump. What crosses the seam is `Transport` — opened by whoever holds the | 7 | //! pump. What crosses the seam is `Transport` — opened by whoever holds the |
| 8 | //! tty, `adopt`ed by the thread that will own it — and nothing else. | 8 | //! tty, `adopt`ed by the thread that will own it — and nothing else. |
| 9 | const std = @import("std"); | 9 | const std = @import("std"); |
| 10 | const Replica = @import("term").replica.Replica; | ||
| 11 | const proto = @import("term").protocol; | 10 | const proto = @import("term").protocol; |
| 12 | const TmpDir = @import("testtmp").TmpDir; | 11 | const TmpDir = @import("testtmp").TmpDir; |
| 13 | const quic = @import("quic"); | 12 | const quic = @import("quic"); |
src/client/keymap.zig
| Old | New | ||
|---|---|---|---|
| @@ -5,7 +5,7 @@ | |||
| 5 | //! Scope: printable input, control characters, arrows and nav keys, function | 5 | //! Scope: printable input, control characters, arrows and nav keys, function |
| 6 | //! keys, the xterm modifier-encoded CSI variants, bracketed paste. Deferred: | 6 | //! keys, the xterm modifier-encoded CSI variants, bracketed paste. Deferred: |
| 7 | //! kitty/CSI-u. Platform-free — this must compile for wasm32-freestanding. | 7 | //! kitty/CSI-u. Platform-free — this must compile for wasm32-freestanding. |
| 8 | // folder rule 4 exemption: turning a key event into VT bytes is this module's whole contract, and it does so with no terminal in sight. | 8 | // Rationale: turning a key event into VT bytes is this module's whole contract, and it does so with no terminal in sight. |
| 9 | 9 | ||
| 10 | const std = @import("std"); | 10 | const std = @import("std"); |
| 11 | 11 | ||
| @@ -108,6 +108,7 @@ pub fn encode(ev: Event, buf: []u8) []const u8 { | |||
| 108 | .enter => return altable(ev.mods, "\r", buf), | 108 | .enter => return altable(ev.mods, "\r", buf), |
| 109 | .tab => { | 109 | .tab => { |
| 110 | // Shift+Tab is backtab, its own sequence; plain Tab is a byte. | 110 | // Shift+Tab is backtab, its own sequence; plain Tab is a byte. |
| 111 | // folder rule 4 exemption: Key encoding produces VT bytes without accessing a terminal. | ||
| 111 | if (ev.mods.shift) return copy("\x1b[Z", buf); | 112 | if (ev.mods.shift) return copy("\x1b[Z", buf); |
| 112 | return altable(ev.mods, "\t", buf); | 113 | return altable(ev.mods, "\t", buf); |
| 113 | }, | 114 | }, |
| @@ -167,6 +168,7 @@ fn altable(mods: Mods, base: []const u8, buf: []u8) []const u8 { | |||
| 167 | /// parameter form (SS3 has nowhere to put a parameter). | 168 | /// parameter form (SS3 has nowhere to put a parameter). |
| 168 | fn introKey(mods: Mods, intro: u8, final: u8, buf: []u8) []const u8 { | 169 | fn introKey(mods: Mods, intro: u8, final: u8, buf: []u8) []const u8 { |
| 169 | if (mods.any()) | 170 | if (mods.any()) |
| 171 | // folder rule 4 exemption: Key encoding produces VT bytes without accessing a terminal. | ||
| 170 | return std.fmt.bufPrint(buf, "\x1b[1;{d}{c}", .{ mods.param(), final }) catch unreachable; | 172 | return std.fmt.bufPrint(buf, "\x1b[1;{d}{c}", .{ mods.param(), final }) catch unreachable; |
| 171 | buf[0] = 0x1b; | 173 | buf[0] = 0x1b; |
| 172 | buf[1] = intro; | 174 | buf[1] = intro; |
| @@ -183,7 +185,9 @@ fn cursorKey(mods: Mods, final: u8, buf: []u8) []const u8 { | |||
| 183 | /// CSI tilde form: ESC [ <n> ~, or ESC [ <n> ; <mods> ~ when modified. | 185 | /// CSI tilde form: ESC [ <n> ~, or ESC [ <n> ; <mods> ~ when modified. |
| 184 | fn tildeKey(mods: Mods, n: u8, buf: []u8) []const u8 { | 186 | fn tildeKey(mods: Mods, n: u8, buf: []u8) []const u8 { |
| 185 | if (!mods.any()) | 187 | if (!mods.any()) |
| 188 | // folder rule 4 exemption: Key encoding produces VT bytes without accessing a terminal. | ||
| 186 | return std.fmt.bufPrint(buf, "\x1b[{d}~", .{n}) catch unreachable; | 189 | return std.fmt.bufPrint(buf, "\x1b[{d}~", .{n}) catch unreachable; |
| 190 | // folder rule 4 exemption: Key encoding produces VT bytes without accessing a terminal. | ||
| 187 | return std.fmt.bufPrint(buf, "\x1b[{d};{d}~", .{ n, mods.param() }) catch unreachable; | 191 | return std.fmt.bufPrint(buf, "\x1b[{d};{d}~", .{ n, mods.param() }) catch unreachable; |
| 188 | } | 192 | } |
| 189 | 193 | ||
| @@ -192,7 +196,9 @@ fn ss3Key(mods: Mods, final: u8, buf: []u8) []const u8 { | |||
| 192 | return introKey(mods, 'O', final, buf); | 196 | return introKey(mods, 'O', final, buf); |
| 193 | } | 197 | } |
| 194 | 198 | ||
| 199 | // folder rule 4 exemption: Key encoding produces VT bytes without accessing a terminal. | ||
| 195 | pub const paste_begin = "\x1b[200~"; | 200 | pub const paste_begin = "\x1b[200~"; |
| 201 | // folder rule 4 exemption: Key encoding produces VT bytes without accessing a terminal. | ||
| 196 | pub const paste_end = "\x1b[201~"; | 202 | pub const paste_end = "\x1b[201~"; |
| 197 | 203 | ||
| 198 | // --------------------------------------------------------------------------- | 204 | // --------------------------------------------------------------------------- |
src/engine/engine.zig
| Old | New | ||
|---|---|---|---|
| @@ -7,7 +7,7 @@ | |||
| 7 | //! replica of a session without linking a terminal emulator. `delta.zig` is | 7 | //! replica of a session without linking a terminal emulator. `delta.zig` is |
| 8 | //! its child: deciding which rows a client is missing needs the engine, and | 8 | //! its child: deciding which rows a client is missing needs the engine, and |
| 9 | //! nothing else does. | 9 | //! nothing else does. |
| 10 | // folder rule 4 exemption: ghostty-vt's grid is fed and read in VT bytes — this module IS the VT, and its selection formatter emits SGR. | 10 | // Rationale: ghostty-vt's grid is fed and read in VT bytes — this module IS the VT, and its selection formatter emits SGR. |
| 11 | const std = @import("std"); | 11 | const std = @import("std"); |
| 12 | const vt = @import("ghostty-vt"); | 12 | const vt = @import("ghostty-vt"); |
| 13 | const proto = @import("term").protocol; | 13 | const proto = @import("term").protocol; |
| @@ -375,6 +375,7 @@ pub const Engine = struct { | |||
| 375 | // and tabstops (HTS walks the cursor) *after* the screen section's | 375 | // and tabstops (HTS walks the cursor) *after* the screen section's |
| 376 | // CUP, so the dump's final cursor position is wrong. Re-assert it. | 376 | // CUP, so the dump's final cursor position is wrong. Re-assert it. |
| 377 | const cur = self.cursorPos(); | 377 | const cur = self.cursorPos(); |
| 378 | // folder rule 4 exemption: The authoritative VT engine produces and consumes escape sequences. | ||
| 378 | try aw.writer.print("\x1b[{d};{d}H", .{ cur.y + 1, cur.x + 1 }); | 379 | try aw.writer.print("\x1b[{d};{d}H", .{ cur.y + 1, cur.x + 1 }); |
| 379 | return try aw.toOwnedSlice(); | 380 | return try aw.toOwnedSlice(); |
| 380 | } | 381 | } |
| @@ -688,6 +689,7 @@ pub const Engine = struct { | |||
| 688 | /// and the protocol allows it. | 689 | /// and the protocol allows it. |
| 689 | fn reportSize(self: *Engine) void { | 690 | fn reportSize(self: *Engine) void { |
| 690 | var buf: [48]u8 = undefined; | 691 | var buf: [48]u8 = undefined; |
| 692 | // folder rule 4 exemption: The authoritative VT engine produces and consumes escape sequences. | ||
| 691 | const rep = std.fmt.bufPrint(&buf, "\x1b[48;{d};{d};0;0t", .{ self.term.rows, self.term.cols }) catch return; | 693 | const rep = std.fmt.bufPrint(&buf, "\x1b[48;{d};{d};0;0t", .{ self.term.rows, self.term.cols }) catch return; |
| 692 | self.pty_out.appendSlice(self.alloc, rep) catch {}; | 694 | self.pty_out.appendSlice(self.alloc, rep) catch {}; |
| 693 | } | 695 | } |
src/gui/font.zig
| Old | New | ||
|---|---|---|---|
| @@ -168,7 +168,7 @@ pub const Face = struct { | |||
| 168 | if (c.FT_Load_Char(hs[0].face, 'M', c.FT_LOAD_DEFAULT) != 0) return error.GlyphLoad; | 168 | if (c.FT_Load_Char(hs[0].face, 'M', c.FT_LOAD_DEFAULT) != 0) return error.GlyphLoad; |
| 169 | const w = @max(@as(i64, @intCast((hs[0].face.*.glyph.*.advance.x + 63) >> 6)), 1); | 169 | const w = @max(@as(i64, @intCast((hs[0].face.*.glyph.*.advance.x + 63) >> 6)), 1); |
| 170 | const h = @max(@as(i64, @intCast((m.height + 63) >> 6)), 1); | 170 | const h = @max(@as(i64, @intCast((m.height + 63) >> 6)), 1); |
| 171 | const asc = @as(i64, @intCast((m.ascender + 63) >> 6)); | 171 | const asc: i64 = @intCast((m.ascender + 63) >> 6); |
| 172 | return .{ .lib = lib, .handles = hs, .cell_w = @intCast(w), .cell_h = @intCast(h), .ascent = @intCast(std.math.clamp(asc, 1, h)), .pixels = px }; | 172 | return .{ .lib = lib, .handles = hs, .cell_w = @intCast(w), .cell_h = @intCast(h), .ascent = @intCast(std.math.clamp(asc, 1, h)), .pixels = px }; |
| 173 | } | 173 | } |
| 174 | pub fn deinit(self: *Face) void { | 174 | pub fn deinit(self: *Face) void { |
src/server/server_test_harness.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,4 @@ | |||
| 1 | const std = @import("std"); | 1 | const std = @import("std"); |
| 2 | const Engine = @import("engine").Engine; | ||
| 3 | const Grid = @import("term").grid.Grid; | 2 | const Grid = @import("term").grid.Grid; |
| 4 | pub const Pty = @import("pty").Pty; | 3 | pub const Pty = @import("pty").Pty; |
| 5 | const proto = @import("term").protocol; | 4 | const proto = @import("term").protocol; |
src/server/server_test_session.zig
| Old | New | ||
|---|---|---|---|
| @@ -15,13 +15,12 @@ const Server = srv_mod.Server; | |||
| 15 | const boundUdpPort = srv_mod.boundUdpPort; | 15 | const boundUdpPort = srv_mod.boundUdpPort; |
| 16 | const max_sessions = srv_mod.max_sessions; | 16 | const max_sessions = srv_mod.max_sessions; |
| 17 | const shutdown_flag = &srv_mod.shutdown_flag; | 17 | const shutdown_flag = &srv_mod.shutdown_flag; |
| 18 | const applyFrame = h.applyFrame; | ||
| 19 | const awaitFrame = h.awaitFrame; | 18 | const awaitFrame = h.awaitFrame; |
| 20 | const awaitFrameOn = h.awaitFrameOn; | 19 | const awaitFrameOn = h.awaitFrameOn; |
| 21 | const connectedPair = h.connectedPair; | 20 | const connectedPair = h.connectedPair; |
| 22 | const firstStateFrame = h.firstStateFrame; | 21 | const firstStateFrame = h.firstStateFrame; |
| 23 | 22 | ||
| 24 | // folder rule 5 exemption: a test's daemon needs a shell to spawn, and this | 23 | // Rationale: a test's daemon needs a shell to spawn, and this |
| 25 | // file's fixture helpers sit outside the `test` blocks the rule skips. | 24 | // file's fixture helpers sit outside the `test` blocks the rule skips. |
| 26 | 25 | ||
| 27 | /// The teardown on the failing branch is not tidiness: a Server that got built | 26 | /// The teardown on the failing branch is not tidiness: a Server that got built |
| @@ -29,6 +28,7 @@ const firstStateFrame = h.firstStateFrame; | |||
| 29 | /// test runner's stdout — the build never sees EOF and hangs instead of | 28 | /// test runner's stdout — the build never sees EOF and hangs instead of |
| 30 | /// printing a failure. | 29 | /// printing a failure. |
| 31 | fn expectInitRefused(alloc: std.mem.Allocator, path: []const u8, want: anyerror) !void { | 30 | fn expectInitRefused(alloc: std.mem.Allocator, path: []const u8, want: anyerror) !void { |
| 31 | // folder rule 5 exemption: This fixture invokes a shell to exercise session behavior. | ||
| 32 | if (Server.init(alloc, .{ .sock_path = path, .shell = "/bin/sh" })) |built| { | 32 | if (Server.init(alloc, .{ .sock_path = path, .shell = "/bin/sh" })) |built| { |
| 33 | var stolen = built; | 33 | var stolen = built; |
| 34 | stolen.deinit(); | 34 | stolen.deinit(); |
src/tui/wall_host.zig
| Old | New | ||
|---|---|---|---|
| @@ -8,7 +8,6 @@ const std = @import("std"); | |||
| 8 | const proto = @import("term").protocol; | 8 | const proto = @import("term").protocol; |
| 9 | const client = @import("client"); | 9 | const client = @import("client"); |
| 10 | const hosts = @import("client").hosts; | 10 | const hosts = @import("client").hosts; |
| 11 | const handoff = @import("client").handoff; | ||
| 12 | const wall_layout = @import("wall_layout.zig"); | 11 | const wall_layout = @import("wall_layout.zig"); |
| 13 | const wv = @import("wallview.zig"); | 12 | const wv = @import("wallview.zig"); |
| 14 | const Shared = wv.Shared; | 13 | const Shared = wv.Shared; |
src/tui/wall_layout.zig
| Old | New | ||
|---|---|---|---|
| @@ -11,7 +11,6 @@ const layout = @import("client").layout; | |||
| 11 | const wall_host = @import("wall_host.zig"); | 11 | const wall_host = @import("wall_host.zig"); |
| 12 | const wv = @import("wallview.zig"); | 12 | const wv = @import("wallview.zig"); |
| 13 | const Host = wall_host.Host; | 13 | const Host = wall_host.Host; |
| 14 | const Resolved = wall_host.Resolved; | ||
| 15 | const Shared = wv.Shared; | 14 | const Shared = wv.Shared; |
| 16 | const Tile = wv.Tile; | 15 | const Tile = wv.Tile; |
| 17 | const Wall = wv.Wall; | 16 | const Wall = wv.Wall; |
src/tui/wall_picker.zig
| Old | New | ||
|---|---|---|---|
| @@ -18,7 +18,6 @@ const wall_pump = @import("wall_pump.zig"); | |||
| 18 | const wv = @import("wallview.zig"); | 18 | const wv = @import("wallview.zig"); |
| 19 | const Host = wall_host.Host; | 19 | const Host = wall_host.Host; |
| 20 | const Shared = wv.Shared; | 20 | const Shared = wv.Shared; |
| 21 | const Tile = wv.Tile; | ||
| 22 | const Wall = wv.Wall; | 21 | const Wall = wv.Wall; |
| 23 | 22 | ||
| 24 | /// The widest row the picker draws. A spelling past it is cut, never | 23 | /// The widest row the picker draws. A spelling past it is cut, never |
src/tui/wall_test_host.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,5 @@ | |||
| 1 | //! The hosts file, the host table and the poller (wall_host.zig). | 1 | //! The hosts file, the host table and the poller (wall_host.zig). |
| 2 | const std = @import("std"); | 2 | const std = @import("std"); |
| 3 | const proto = @import("term").protocol; | ||
| 4 | const client = @import("client"); | 3 | const client = @import("client"); |
| 5 | const hosts = @import("client").hosts; | 4 | const hosts = @import("client").hosts; |
| 6 | const TmpDir = @import("testtmp").TmpDir; | 5 | const TmpDir = @import("testtmp").TmpDir; |
src/tui/wall_test_layout.zig
| Old | New | ||
|---|---|---|---|
| @@ -10,7 +10,6 @@ const wall_layout = @import("wall_layout.zig"); | |||
| 10 | const wv = @import("wallview.zig"); | 10 | const wv = @import("wallview.zig"); |
| 11 | const Host = wall_host.Host; | 11 | const Host = wall_host.Host; |
| 12 | const ResizeWitness = fixture.ResizeWitness; | 12 | const ResizeWitness = fixture.ResizeWitness; |
| 13 | const Resolved = wall_host.Resolved; | ||
| 14 | const Shared = wv.Shared; | 13 | const Shared = wv.Shared; |
| 15 | const Tile = wv.Tile; | 14 | const Tile = wv.Tile; |
| 16 | const WallScreen = fixture.WallScreen; | 15 | const WallScreen = fixture.WallScreen; |
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,7 +12,6 @@ const spawn = @import("spawn"); | |||
| 12 | const proxy = @import("proxy"); | 12 | const proxy = @import("proxy"); |
| 13 | const grid_mod = @import("term").grid; | 13 | const grid_mod = @import("term").grid; |
| 14 | const paint = @import("paint.zig"); | 14 | const paint = @import("paint.zig"); |
| 15 | const select = @import("select.zig"); | ||
| 16 | // Counters ride out through `Shared` because a detached pump never reaches | 15 | // Counters ride out through `Shared` because a detached pump never reaches |
| 17 | // a `Core.deinit`. | 16 | // a `Core.deinit`. |
| 18 | // The chord table and the prediction hooks a focused tile shares with the | 17 | // The chord table and the prediction hooks a focused tile shares with the |
| @@ -23,7 +22,6 @@ const layout = @import("client").layout; | |||
| 23 | // the daemon socket too: one spelling of the runtime directory, not a | 22 | // the daemon socket too: one spelling of the runtime directory, not a |
| 24 | // second getenv beside it. | 23 | // second getenv beside it. |
| 25 | const sockpath = @import("sockpath"); | 24 | const sockpath = @import("sockpath"); |
| 26 | const TmpDir = @import("testtmp").TmpDir; | ||
| 27 | const wall_host = @import("wall_host.zig"); | 25 | const wall_host = @import("wall_host.zig"); |
| 28 | const wall_layout = @import("wall_layout.zig"); | 26 | const wall_layout = @import("wall_layout.zig"); |
| 29 | const wall_picker = @import("wall_picker.zig"); | 27 | const wall_picker = @import("wall_picker.zig"); |
test/bans.sh
| Old | New | ||
|---|---|---|---|
| @@ -107,6 +107,15 @@ must_break 5 src/engine 'const probe = "/bin/sh";' | |||
| 107 | must_break 6 src/server 'const probe = std.posix.fork();' | 107 | must_break 6 src/server 'const probe = std.posix.fork();' |
| 108 | must_break 7 src/server 'const probe = std.os.linux.O.RDONLY;' | 108 | must_break 7 src/server 'const probe = std.os.linux.O.RDONLY;' |
| 109 | 109 | ||
| 110 | # An exemption permits one occurrence, never the rest of the file. | ||
| 111 | must_skip src/client '// folder rule 4 exemption: fixture checks a permitted occurrence. | ||
| 112 | const permitted = "isatty";' | ||
| 113 | must_break 4 src/client '// folder rule 4 exemption: fixture checks a permitted occurrence. | ||
| 114 | const permitted = "isatty"; | ||
| 115 | const forbidden = "tcgetattr";' | ||
| 116 | must_break 4 src/client '// folder rule 4 exemption: | ||
| 117 | const forbidden = "isatty";' | ||
| 118 | |||
| 110 | # A container-level `test` opens at column 0 and its `}` closes there, | 119 | # A container-level `test` opens at column 0 and its `}` closes there, |
| 111 | # which is the line arithmetic checkSourceBan relies on and `zig fmt | 120 | # which is the line arithmetic checkSourceBan relies on and `zig fmt |
| 112 | # --check` already guarantees. | 121 | # --check` already guarantees. |
tools/fetch-zlint.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,38 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # Cache the exact release asset; verify cached copies as well as downloads. | ||
| 3 | set -eu | ||
| 4 | ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) | ||
| 5 | case "$(uname -s)-$(uname -m)" in | ||
| 6 | Linux-x86_64) asset=zlint-linux-x86_64; digest=3290bd511d37e4f6ccca3621b9894cd6c378195cdaac27520d0bd894058b2b9b ;; | ||
| 7 | Linux-aarch64|Linux-arm64) asset=zlint-linux-aarch64; digest=4d8a55ca5267fbd9cec46e09def320c1c30e5322e28707a3b15ff05cbf244673 ;; | ||
| 8 | Darwin-arm64|Darwin-aarch64) asset=zlint-macos-aarch64; digest=520924b1c4898b37ed98270b0774f657729e3c9775997482c6f8f3fe75051144 ;; | ||
| 9 | Darwin-x86_64) asset=zlint-macos-x86_64; digest=ba51351036752bcba3bf01808c24bf8eb48123e1d2ad11d7bc82c1dcc10dc30b ;; | ||
| 10 | *) echo "zlint: unsupported host $(uname -s)-$(uname -m)" >&2; exit 1 ;; | ||
| 11 | esac | ||
| 12 | cache="$ROOT/deps/zlint" | ||
| 13 | binary="$cache/zlint" | ||
| 14 | checksum() { | ||
| 15 | if command -v sha256sum >/dev/null 2>&1; then | ||
| 16 | sha256sum "$1" | awk '{print $1}' | ||
| 17 | else | ||
| 18 | shasum -a 256 "$1" | awk '{print $1}' | ||
| 19 | fi | ||
| 20 | } | ||
| 21 | if [ -f "$binary" ] && [ "$(checksum "$binary")" = "$digest" ]; then | ||
| 22 | chmod +x "$binary" | ||
| 23 | exit 0 | ||
| 24 | fi | ||
| 25 | mkdir -p "$cache" | ||
| 26 | staging=$(mktemp "$cache/download.XXXXXX") | ||
| 27 | trap 'rm -f "$staging"' EXIT | ||
| 28 | trap 'exit 130' INT | ||
| 29 | trap 'exit 143' TERM | ||
| 30 | url="https://github.com/DonIsaac/zlint/releases/download/v0.9.1/$asset" | ||
| 31 | echo "zlint: fetching $url" >&2 | ||
| 32 | curl --fail --location --proto '=https' --proto-redir '=https' --silent --show-error --retry 3 --connect-timeout 15 --max-time 180 "$url" -o "$staging" | ||
| 33 | [ "$(checksum "$staging")" = "$digest" ] || { | ||
| 34 | echo "zlint: SHA-256 mismatch for $asset" >&2 | ||
| 35 | exit 1 | ||
| 36 | } | ||
| 37 | chmod +x "$staging" | ||
| 38 | mv -f "$staging" "$binary" | ||
tools/isolated-run.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,27 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # A short runtime path fits the unix-socket limit on both supported OSes. | ||
| 3 | set -eu | ||
| 4 | [ "$#" -ge 2 ] || { echo 'usage: isolated-run.sh PATH_TO_MUX COMMAND [ARG ...]' >&2; exit 2; } | ||
| 5 | CDPATH= | ||
| 6 | export CDPATH | ||
| 7 | MUX=$(cd -- "$(dirname -- "$1")" && pwd)/$(basename -- "$1") | ||
| 8 | [ -x "$MUX" ] || { echo "isolated-run: not executable: $MUX" >&2; exit 2; } | ||
| 9 | shift | ||
| 10 | scratch=$(mktemp -d /tmp/mux-rig.XXXXXX) | ||
| 11 | XDG_STATE_HOME="$scratch/state" | ||
| 12 | XDG_RUNTIME_DIR="$scratch/run" | ||
| 13 | XDG_CONFIG_HOME="$scratch/config" | ||
| 14 | XDG_CACHE_HOME="$scratch/cache" | ||
| 15 | export MUX XDG_STATE_HOME XDG_RUNTIME_DIR XDG_CONFIG_HOME XDG_CACHE_HOME | ||
| 16 | cleanup() { | ||
| 17 | result=$? | ||
| 18 | trap - EXIT | ||
| 19 | "$MUX" d stop >/dev/null 2>&1 || : | ||
| 20 | rm -rf "$scratch" | ||
| 21 | exit "$result" | ||
| 22 | } | ||
| 23 | trap cleanup EXIT | ||
| 24 | trap 'exit 130' INT | ||
| 25 | trap 'exit 143' TERM | ||
| 26 | mkdir -m 700 "$XDG_STATE_HOME" "$XDG_RUNTIME_DIR" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" | ||
| 27 | "$@" | ||
tools/lint.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,16 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # An explicit file list prevents hidden worktree paths and vendored deps | ||
| 3 | # from changing which files the linter sees. | ||
| 4 | set -eu | ||
| 5 | ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) | ||
| 6 | cd "$ROOT" | ||
| 7 | sh tools/fetch-zlint.sh | ||
| 8 | files=$(mktemp "${TMPDIR:-/tmp}/mux-lint.XXXXXX") | ||
| 9 | trap 'rm -f "$files"' EXIT | ||
| 10 | trap 'exit 130' INT | ||
| 11 | trap 'exit 143' TERM | ||
| 12 | find src test tools -type f -name '*.zig' -print > "$files" | ||
| 13 | printf '%s\n' build.zig >> "$files" | ||
| 14 | LC_ALL=C sort -o "$files" "$files" | ||
| 15 | [ -s "$files" ] || { echo 'lint: no Zig files found' >&2; exit 1; } | ||
| 16 | deps/zlint/zlint --stdin --deny-warnings < "$files" | ||
tools/run-logged.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,10 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # Preserve the command's status while keeping noisy gate output reviewable. | ||
| 3 | set -u | ||
| 4 | [ "$#" -ge 2 ] || { echo 'usage: run-logged.sh LOG COMMAND [ARG ...]' >&2; exit 2; } | ||
| 5 | log=$1 | ||
| 6 | shift | ||
| 7 | "$@" > "$log" 2>&1 | ||
| 8 | result=$? | ||
| 9 | tail -n 30 "$log" | ||
| 10 | exit "$result" | ||
zlint.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,11 @@ | |||
| 1 | { | ||
| 2 | "rules": { | ||
| 3 | "avoid-as": "error", | ||
| 4 | "unused-decls": "error", | ||
| 5 | "homeless-try": "error", | ||
| 6 | "empty-file": "error", | ||
| 7 | "no-unresolved": "error", | ||
| 8 | "must-return-ref": "error", | ||
| 9 | "returned-stack-reference": "error" | ||
| 10 | } | ||
| 11 | } | ||