Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Reads bindings from `drift.lock`, recomputes content signatures for each target,

The `--changed <path>` 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.

Expand Down
8 changes: 4 additions & 4 deletions drift.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -20,7 +20,7 @@ sig = "2dccb33f6b790afa"
[[bindings]]
doc = "CLAUDE.md"
target = "src/main.zig"
sig = "3faa73e2bb344a79"
sig = "f3b812f15563f0a2"

[[bindings]]
doc = "docs/CLI.md"
Expand All @@ -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"
Expand Down Expand Up @@ -60,7 +60,7 @@ sig = "55bc77a2853cb654"
[[bindings]]
doc = "docs/DESIGN.md"
target = "src/main.zig"
sig = "3faa73e2bb344a79"
sig = "f3b812f15563f0a2"

[[bindings]]
doc = "docs/DESIGN.md"
Expand Down
40 changes: 30 additions & 10 deletions src/commands/lint.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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| {
Expand All @@ -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');
}
}
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 8 additions & 6 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
}
}
Expand Down
109 changes: 109 additions & 0 deletions test/integration/lint_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Loading