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 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 {};