From d9464f748f62e5b3b27ce366c0ffd14545356d11 Mon Sep 17 00:00:00 2001 From: Laurynas Keturakis Date: Tue, 21 Apr 2026 18:13:24 +0200 Subject: [PATCH 1/2] fix(check): surface stale report on stderr when --silent fails Previously --silent unconditionally routed stdout to a discarding writer, dropping the STALE lines and summary even on fail. Exit code was correct but the human-readable signal was gone, so CI and terminal users got no indication of what went stale. Split lint.run into compute/renderText/renderJson so the caller in main can decide where to render. In silent mode we skip stdout entirely on pass, and on fail we render the same report to stderr (text or json, matching --format). Non-silent behavior is unchanged. --- src/commands/lint.zig | 48 +++++++++++++++++++++++++++++++++++-------- src/main.zig | 31 +++++++++++++++++++++++----- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/commands/lint.zig b/src/commands/lint.zig index 43a06e1..ddc79c1 100644 --- a/src/commands/lint.zig +++ b/src/commands/lint.zig @@ -122,7 +122,7 @@ const DocCheckResult = struct { error_message: ?[]const u8 = null, }; -const CheckResult = struct { +pub const CheckResult = struct { repo: ?[]const u8, checked_at_ms: i64, docs: std.ArrayList(DocCheckResult), @@ -138,10 +138,18 @@ const CheckResult = struct { links_total: u32, links_broken: u32, + pub fn status(self: *const CheckResult) RunStatus { + return if (self.failed) .fail else .pass; + } + fn docsChecked(self: *const CheckResult) u32 { return self.docs_fresh + self.docs_stale; } + fn checkedAny(self: *const CheckResult) bool { + return self.docs.items.len > 0; + } + fn verificationState(self: *const CheckResult) []const u8 { const checked = self.docsChecked(); if (self.docs_total > 0 and checked == 0) return "none"; @@ -168,6 +176,25 @@ pub fn run( format: Format, changed_path: ?[]const u8, ) !RunStatus { + const result = try compute(ctx, stderr_w, changed_path); + switch (format) { + .text => try renderText(stdout_w, &result), + .json => try renderJson(ctx.run_arena, stdout_w, &result), + } + return result.status(); +} + +/// Run the lint check and return the computed result. Rendering is a separate +/// step so callers (e.g. `--silent` mode in `main`) can choose where — and +/// whether — to print the report based on the exit status. +/// +/// All allocations live in `ctx.run_arena`; the caller is responsible for the +/// arena's lifetime. +pub fn compute( + ctx: CommandContext, + stderr_w: *std.Io.Writer, + changed_path: ?[]const u8, +) !CheckResult { const cwd_path = try std.Io.Dir.cwd().realPathFileAlloc(ctx.io, ".", ctx.run_arena); const lf = try lockfile.discover(ctx.io, ctx.run_arena, ctx.scratch(), cwd_path); @@ -235,10 +262,8 @@ pub fn run( } try group.await(ctx.io); - var checked_any = false; for (results) |maybe| { const doc_result = maybe orelse continue; - checked_any = true; if (doc_result.error_message) |msg| { stderr_w.print("{s}", .{msg}) catch {}; return error.LintCheckFailed; @@ -246,12 +271,19 @@ pub fn run( mergeDocResult(ctx.run_arena, &result, doc_result) catch |err| return err; } - switch (format) { - .text => try writeResultsText(stdout_w, &result, checked_any), - .json => try writeResultsJson(ctx.run_arena, stdout_w, &result), - } + return result; +} + +/// Write the text report for an already-computed result. Safe to call more +/// than once (e.g. to stdout under normal conditions and to stderr when +/// `--silent` runs fail). +pub fn renderText(w: *std.Io.Writer, result: *const CheckResult) !void { + try writeResultsText(w, result, result.checkedAny()); +} - return if (result.failed) .fail else .pass; +/// Write the JSON report for an already-computed result. +pub fn renderJson(run_alloc: std.mem.Allocator, w: *std.Io.Writer, result: *const CheckResult) !void { + try writeResultsJson(run_alloc, w, result); } /// Per-doc check task, spawned once per `DocGroup` inside `Io.Group`. diff --git a/src/main.zig b/src/main.zig index b63f994..48a5956 100644 --- a/src/main.zig +++ b/src/main.zig @@ -165,15 +165,12 @@ pub fn main(init: std.process.Init) !void { } const format = parseFormat(sub.args.format, &stderr_w.interface); const silent = sub.args.silent != 0; - var null_buf: [1]u8 = undefined; - var discarding = std.Io.Writer.Discarding.init(&null_buf); var run_arena = std.heap.ArenaAllocator.init(gpa); defer run_arena.deinit(); var scratch_arena = std.heap.ArenaAllocator.init(gpa); defer scratch_arena.deinit(); const ctx = CommandContext{ .io = io, .run_arena = run_arena.allocator(), .scratch_arena = &scratch_arena }; - const out_w = if (silent) &discarding.writer else &stdout_w.interface; - const run_status = lint.run(ctx, out_w, &stderr_w.interface, format, sub.args.changed) catch |err| switch (err) { + const result = lint.compute(ctx, &stderr_w.interface, sub.args.changed) catch |err| switch (err) { error.LintCheckFailed => { stdout_w.interface.flush() catch {}; stderr_w.interface.flush() catch {}; @@ -181,7 +178,19 @@ pub fn main(init: std.process.Init) !void { }, else => exitWithError(&stderr_w.interface, err), }; - // Exit-on-stale lives here (not in lint.run) so all `defer`s in run unwind + const run_status = result.status(); + // In silent mode, the normal report is suppressed. When the run fails we + // redirect the same report to stderr so the user still gets the human- + // readable signal alongside the non-zero exit code. + const render_to_stdout = !silent; + const render_to_stderr_on_fail = silent and run_status == .fail; + if (render_to_stdout) { + renderCheckReport(ctx.run_arena, &stdout_w.interface, &result, format) catch |err| exitWithError(&stderr_w.interface, err); + } + if (render_to_stderr_on_fail) { + renderCheckReport(ctx.run_arena, &stderr_w.interface, &result, format) catch |err| exitWithError(&stderr_w.interface, err); + } + // Exit-on-stale lives here (not in lint.run) so all `defer`s above unwind // before the process dies. std.process.exit calls libc exit, which does not // run Zig defers — putting the exit in run leaks the result model. if (run_status == .fail) { @@ -312,6 +321,18 @@ fn printUsage(w: *std.Io.Writer) void { , .{}) catch {}; } +fn renderCheckReport( + run_alloc: std.mem.Allocator, + w: *std.Io.Writer, + result: *const lint.CheckResult, + format: lint.Format, +) !void { + switch (format) { + .text => try lint.renderText(w, result), + .json => try lint.renderJson(run_alloc, w, result), + } +} + fn fatal(stderr_w: *std.Io.Writer, comptime fmt: []const u8, args: anytype) noreturn { stderr_w.print(fmt, args) catch {}; stderr_w.flush() catch {}; From 0a81baa6d4debae1d985d63e50ba64cc520bbda9 Mon Sep 17 00:00:00 2001 From: Laurynas Keturakis Date: Tue, 21 Apr 2026 18:20:32 +0200 Subject: [PATCH 2/2] docs: document --silent flag and refresh drift.lock The prior commit changed --silent behavior (report now goes to stderr on failure) but left the flag undocumented in docs/CLI.md and the affected bindings stale in drift.lock. Adds the CLI prose and refreshes the four stale sigs (main.zig for CLAUDE.md / DESIGN.md / SKILL.md, lint.zig for CLI.md). --- docs/CLI.md | 4 +++- drift.lock | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index 9f9b805..3e03683 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -7,13 +7,15 @@ Check all docs for staleness. The primary command. Exits 1 if any anchor is stale or any link is broken. `drift lint` is an alias. Markdown files under directories with their own `drift.lock` are skipped — they belong to a nested scope. ``` -drift check [--format text|json] [--changed ] +drift check [--format text|json] [--changed ] [--silent] ``` Reads bindings from `drift.lock`, recomputes content signatures for each target, and compares against the stored `sig:` values. Reports stale anchors with reasons. The `--changed ` flag scopes checking to docs whose targets match the given path prefix. This enables efficient CI integration — a pipeline that knows which files changed can check only the affected docs without running a full lint. +The `--silent` flag suppresses the report on passing runs (exit 0, no output). When the run fails it still exits 1, and the same report (text or JSON, honoring `--format`) is written to **stderr** so the failure is observable in terminals and CI logs. Redirect stderr with `2>/dev/null` to silence the failure output as well; the exit code is unchanged. + The JSON output emits the `drift.check.v1` schema with summary counts, per-doc results, per-anchor reason codes, and (best-effort) git blame on stale anchors. The exit code is the same as the text path: 0 on pass, 1 on stale. Errors writing the JSON payload (broken pipe, encoder failure) exit non-zero rather than emitting a truncated document. See [`check-json-schema.md`](./check-json-schema.md) for the full schema. ``` diff --git a/drift.lock b/drift.lock index 4b896d9..cce053e 100644 --- a/drift.lock +++ b/drift.lock @@ -1,15 +1,15 @@ -.claude/skills/drift/SKILL.md -> src/main.zig sig:f2735440986d2477 origin:github:fiberplane/drift +.claude/skills/drift/SKILL.md -> src/main.zig sig:b6166454e541e8a4 origin:github:fiberplane/drift .claude/skills/drift/SKILL.md -> src/vcs.zig sig:84da70be235ca9d4 origin:github:fiberplane/drift CLAUDE.md -> build.zig sig:7194b38f39dbadba -CLAUDE.md -> src/main.zig sig:f2735440986d2477 +CLAUDE.md -> src/main.zig sig:b6166454e541e8a4 docs/CLI.md -> src/commands/link.zig sig:7bd7f824afc30e0b -docs/CLI.md -> src/commands/lint.zig sig:b5afc0405907cf64 +docs/CLI.md -> src/commands/lint.zig sig:fe7bd5a687f3e917 docs/CLI.md -> src/commands/refs.zig sig:f623b7774086094e docs/CLI.md -> src/commands/status.zig sig:eade166d24a20b81 docs/CLI.md -> src/commands/unlink.zig sig:0dbe1ee3315211b5 docs/DESIGN.md -> src/context.zig sig:82d9da38ea486f36 docs/DESIGN.md -> src/lockfile.zig sig:0e4b79237b6e4c8b -docs/DESIGN.md -> src/main.zig sig:f2735440986d2477 +docs/DESIGN.md -> src/main.zig sig:b6166454e541e8a4 docs/DESIGN.md -> src/symbols.zig sig:bbe250d43609daa1 docs/DESIGN.md -> src/vcs.zig sig:84da70be235ca9d4 docs/RELEASING.md -> .github/workflows/ci.yml sig:c14a23e6547d575f