a73x

c5be8085

build: add pinned linting and reliable development checks

a73x   2026-09-06 09:23

Commit message
build: add pinned linting and reliable development checks

.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 }