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
4 changes: 3 additions & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>]
drift check [--format text|json] [--changed <path>] [--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 <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 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
@@ -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
Expand Down
48 changes: 40 additions & 8 deletions src/commands/lint.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -235,23 +262,28 @@ 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;
}
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`.
Expand Down
31 changes: 26 additions & 5 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -165,23 +165,32 @@ 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 {};
std.process.exit(1);
},
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) {
Expand Down Expand Up @@ -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 {};
Expand Down
Loading