diff --git a/docs/CLI.md b/docs/CLI.md index 5a936d7..3250a95 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -14,7 +14,7 @@ Reads bindings from `drift.lock`, recomputes content signatures for each target, 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 `--silent` flag suppresses the report on passing runs (exit 0, no output). When the run fails it still exits 1. In text mode, only stale/broken doc blocks plus a failure summary such as `1 of 137 docs failed, 136 ok` are written to **stderr** so the actionable failures are visible without ok-doc noise while preserving pass/fail scale. In JSON mode, the complete `drift.check.v1` payload is written to **stderr** to preserve the schema contract. 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 3726b0f..b07b600 100644 --- a/drift.lock +++ b/drift.lock @@ -4,7 +4,7 @@ version = 1 doc = ".claude/skills/drift/SKILL.md" target = "src/main.zig" origin = "github:fiberplane/drift" -sig = "3faa73e2bb344a79" +sig = "f3b812f15563f0a2" [[bindings]] doc = ".claude/skills/drift/SKILL.md" @@ -20,7 +20,7 @@ sig = "2dccb33f6b790afa" [[bindings]] doc = "CLAUDE.md" target = "src/main.zig" -sig = "3faa73e2bb344a79" +sig = "f3b812f15563f0a2" [[bindings]] doc = "docs/CLI.md" @@ -30,7 +30,7 @@ sig = "3ae8f4ee2c85d8d8" [[bindings]] doc = "docs/CLI.md" target = "src/commands/lint.zig" -sig = "fe7bd5a687f3e917" +sig = "270d047d8cbaf238" [[bindings]] doc = "docs/CLI.md" @@ -60,7 +60,7 @@ sig = "55bc77a2853cb654" [[bindings]] doc = "docs/DESIGN.md" target = "src/main.zig" -sig = "3faa73e2bb344a79" +sig = "f3b812f15563f0a2" [[bindings]] doc = "docs/DESIGN.md" diff --git a/src/commands/lint.zig b/src/commands/lint.zig index ddc79c1..46d82ab 100644 --- a/src/commands/lint.zig +++ b/src/commands/lint.zig @@ -9,6 +9,7 @@ const target = @import("../target.zig"); const vcs = @import("../vcs.zig"); pub const Format = enum { text, json }; +pub const TextReportMode = enum { all, errors_only }; pub const RunStatus = enum { pass, fail }; pub const RunError = error{LintCheckFailed}; @@ -178,7 +179,7 @@ pub fn run( ) !RunStatus { const result = try compute(ctx, stderr_w, changed_path); switch (format) { - .text => try renderText(stdout_w, &result), + .text => try renderText(stdout_w, &result, .all), .json => try renderJson(ctx.run_arena, stdout_w, &result), } return result.status(); @@ -277,8 +278,8 @@ pub fn compute( /// 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()); +pub fn renderText(w: *std.Io.Writer, result: *const CheckResult, mode: TextReportMode) !void { + try writeResultsText(w, result, result.checkedAny(), mode); } /// Write the JSON report for an already-computed result. @@ -767,13 +768,17 @@ fn jsonAnchorFromOutcome(raw_target: []const u8, sig: ?[]const u8, parsed: targe }; } -fn writeResultsText(w: *std.Io.Writer, result: *const CheckResult, checked_any: bool) !void { +fn writeResultsText(w: *std.Io.Writer, result: *const CheckResult, checked_any: bool, mode: TextReportMode) !void { if (!checked_any) { try w.writeAll("ok\n"); return; } - for (result.docs.items, 0..) |doc, doc_index| { + var wrote_doc = false; + for (result.docs.items) |doc| { + if (mode == .errors_only and doc.result != .stale and doc.result != .broken) continue; + + if (wrote_doc) try w.writeByte('\n'); try w.print("{s}\n", .{doc.path}); for (doc.anchors.items) |row| { @@ -787,14 +792,15 @@ fn writeResultsText(w: *std.Io.Writer, result: *const CheckResult, checked_any: try w.writeAll(" ok\n"); } - if (doc_index + 1 < result.docs.items.len) { - try w.writeByte('\n'); - } + wrote_doc = true; } if (result.docs_stale > 0 or result.docs_fresh > 0 or result.links_broken > 0) { - if (result.docs.items.len > 0) try w.writeByte('\n'); - try writeSummaryText(w, result); + if (wrote_doc) try w.writeByte('\n'); + switch (mode) { + .all => try writeSummaryText(w, result), + .errors_only => try writeFailureSummaryText(w, result), + } try w.writeByte('\n'); } } @@ -846,6 +852,20 @@ fn writeSummaryText(w: *std.Io.Writer, result: *const CheckResult) !void { } } +fn writeFailureSummaryText(w: *std.Io.Writer, result: *const CheckResult) !void { + try w.print("{d} of {d} doc{s} failed", .{ result.docs_stale, result.docsChecked(), if (result.docsChecked() == 1) "" else "s" }); + + if (result.docs_fresh > 0) { + try w.print(", {d} ok", .{result.docs_fresh}); + } + if (result.links_broken > 0) { + try w.print(", {d} broken link{s}", .{ result.links_broken, if (result.links_broken == 1) "" else "s" }); + } + if (result.docs_skipped > 0) { + try w.print(", {d} skipped", .{result.docs_skipped}); + } +} + fn writeResultsJson(run_alloc: std.mem.Allocator, w: *std.Io.Writer, result: *const CheckResult) !void { const doc = try checkResultToDriftCheckV1(run_alloc, result); try drift_check_v1.writeJson(w, doc); diff --git a/src/main.zig b/src/main.zig index 273dfb2..b162b6b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -179,16 +179,17 @@ pub fn main(init: std.process.Init) !void { else => exitWithError(&stderr_w.interface, err), }; 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. + // In silent mode, passing runs stay quiet. When the run fails, text + // reports are filtered to only actionable stale/broken docs and written + // to stderr alongside the non-zero exit code. JSON stays complete to + // preserve the drift.check.v1 contract. 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); + renderCheckReport(ctx.run_arena, &stdout_w.interface, &result, format, .all) 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); + renderCheckReport(ctx.run_arena, &stderr_w.interface, &result, format, .errors_only) 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 @@ -326,9 +327,10 @@ fn renderCheckReport( w: *std.Io.Writer, result: *const lint.CheckResult, format: lint.Format, + text_mode: lint.TextReportMode, ) !void { switch (format) { - .text => try lint.renderText(w, result), + .text => try lint.renderText(w, result, text_mode), .json => try lint.renderJson(run_alloc, w, result), } } diff --git a/test/integration/lint_test.zig b/test/integration/lint_test.zig index 5080bcd..0459fce 100644 --- a/test/integration/lint_test.zig +++ b/test/integration/lint_test.zig @@ -844,3 +844,112 @@ test "check from nested subdir with its own drift.lock only checks that scope" { // Should NOT contain docs from root scope try helpers.expectNotContains(result.stdout, "docs/root.md"); } + +test "check --silent suppresses passing output" { + const allocator = std.testing.allocator; + var repo = try helpers.TempRepo.init(allocator); + defer repo.cleanup(); + + try repo.writeFile("docs/doc.md", "# Doc\n"); + try repo.writeFile("src/main.ts", "export const value = 1;\n"); + try repo.commit("add doc and source"); + + try linkDoc(&repo, "docs/doc.md", "src/main.ts"); + try repo.commit("link doc"); + + const result = try repo.runDrift(&.{ "check", "--silent" }); + defer result.deinit(allocator); + + try helpers.expectExitCode(result.term, 0); + try std.testing.expectEqualStrings("", result.stdout); + try std.testing.expectEqualStrings("", result.stderr); +} + +test "check --silent prints only stale and broken docs on failure" { + const allocator = std.testing.allocator; + var repo = try helpers.TempRepo.init(allocator); + defer repo.cleanup(); + + try repo.writeFile("docs/stale.md", "# Stale\n"); + try repo.writeFile("docs/ok.md", "# Ok\n"); + try repo.writeFile("docs/broken.md", "# Broken\n\nSee [missing](missing.md).\n"); + try repo.writeFile("src/stale.ts", "export const stale = 1;\n"); + try repo.writeFile("src/ok.ts", "export const ok = 1;\n"); + try repo.commit("add docs and sources"); + + try linkDoc(&repo, "docs/stale.md", "src/stale.ts"); + try linkDoc(&repo, "docs/ok.md", "src/ok.ts"); + try repo.commit("link docs"); + + try repo.writeFile("src/stale.ts", "export const stale = 2;\n"); + try repo.commit("make stale doc stale"); + + const result = try repo.runDrift(&.{ "check", "--silent" }); + defer result.deinit(allocator); + + try helpers.expectExitCode(result.term, 1); + try std.testing.expectEqualStrings("", result.stdout); + try helpers.expectContains(result.stderr, "docs/stale.md"); + try helpers.expectContains(result.stderr, "STALE src/stale.ts"); + try helpers.expectContains(result.stderr, "docs/broken.md"); + try helpers.expectContains(result.stderr, "BROKEN docs/missing.md (link target not found)"); + try helpers.expectContains(result.stderr, "2 of 3 docs failed, 1 ok, 1 broken link"); + try helpers.expectNotContains(result.stderr, "docs/ok.md"); + try helpers.expectNotContains(result.stderr, " ok\n"); +} + +test "check --silent composes with changed path filtering" { + const allocator = std.testing.allocator; + var repo = try helpers.TempRepo.init(allocator); + defer repo.cleanup(); + + try repo.writeFile("docs/auth.md", "# Auth\n"); + try repo.writeFile("docs/payments.md", "# Payments\n"); + try repo.writeFile("src/auth/login.ts", "export const login = true;\n"); + try repo.writeFile("src/payments/stripe.ts", "export const stripe = true;\n"); + try repo.commit("add docs and sources"); + + try linkDoc(&repo, "docs/auth.md", "src/auth/login.ts"); + try linkDoc(&repo, "docs/payments.md", "src/payments/stripe.ts"); + try repo.commit("link both docs"); + + try repo.writeFile("src/auth/login.ts", "export const login = false;\n"); + try repo.commit("modify auth source"); + + const result = try repo.runDrift(&.{ "check", "--silent", "--changed", "src/auth" }); + defer result.deinit(allocator); + + try helpers.expectExitCode(result.term, 1); + try std.testing.expectEqualStrings("", result.stdout); + try helpers.expectContains(result.stderr, "docs/auth.md"); + try helpers.expectContains(result.stderr, "1 of 1 doc failed"); + try helpers.expectNotContains(result.stderr, "docs/payments.md"); +} + +test "check --silent --format json keeps full payload on failure" { + const allocator = std.testing.allocator; + var repo = try helpers.TempRepo.init(allocator); + defer repo.cleanup(); + + try repo.writeFile("docs/stale.md", "# Stale\n"); + try repo.writeFile("docs/ok.md", "# Ok\n"); + try repo.writeFile("src/stale.ts", "export const stale = 1;\n"); + try repo.writeFile("src/ok.ts", "export const ok = 1;\n"); + try repo.commit("add docs and sources"); + + try linkDoc(&repo, "docs/stale.md", "src/stale.ts"); + try linkDoc(&repo, "docs/ok.md", "src/ok.ts"); + try repo.commit("link docs"); + + try repo.writeFile("src/stale.ts", "export const stale = 2;\n"); + try repo.commit("make stale doc stale"); + + const result = try repo.runDrift(&.{ "check", "--silent", "--format", "json" }); + defer result.deinit(allocator); + + try helpers.expectExitCode(result.term, 1); + try std.testing.expectEqualStrings("", result.stdout); + try helpers.validateDriftCheckJson(allocator, result.stderr); + try helpers.expectContains(result.stderr, "docs/stale.md"); + try helpers.expectContains(result.stderr, "docs/ok.md"); +}