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
6 changes: 3 additions & 3 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## drift check / drift lint

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.
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>]
Expand Down Expand Up @@ -69,7 +69,7 @@ docs/payments.md (1 anchor)

## drift link

Add or refresh bindings in `drift.lock`. `drift link` computes a content signature (`sig:`) from the target file's current syntax fingerprint and writes it to the lockfile. Creates `drift.lock` if it doesn't exist.
Add or refresh bindings in `drift.lock`. `drift link` computes a content signature (`sig:`) from the target file's current syntax fingerprint and writes it to the lockfile. Creates `drift.lock` if it doesn't exist. The lockfile is discovered by walking up from the doc's directory, not from cwd — if a nested `drift.lock` exists closer to the doc, that lockfile is used.

```
drift link <doc-path> <file> [--doc-is-still-accurate]
Expand Down Expand Up @@ -103,7 +103,7 @@ Each anchor gets its own content signature computed from the current file on dis

## drift unlink

Remove a binding from `drift.lock`.
Remove a binding from `drift.lock`. Like `drift link`, the lockfile is discovered from the doc's directory.

```
drift unlink <doc-path> <file>
Expand Down
17 changes: 7 additions & 10 deletions drift.lock
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
.claude/skills/drift/SKILL.md -> src/main.zig sig:293b2dd6f549640e origin:github:fiberplane/drift
.claude/skills/drift/SKILL.md -> src/main.zig sig:647a31274655a84d origin:github:fiberplane/drift
.claude/skills/drift/SKILL.md -> src/vcs.zig sig:2468937f00d5305a origin:github:fiberplane/drift
CLAUDE.md -> build.zig sig:7194b38f39dbadba
CLAUDE.md -> src/main.zig sig:293b2dd6f549640e
docs/CLI.md -> src/commands/link.zig sig:152208e2069486aa
docs/CLI.md -> src/commands/lint.zig sig:a0237cda56055884
CLAUDE.md -> src/main.zig sig:647a31274655a84d
docs/CLI.md -> src/commands/link.zig sig:70e52c01fb9022a8
docs/CLI.md -> src/commands/lint.zig sig:b8ab0cd93909b888
docs/CLI.md -> src/commands/refs.zig sig:e3309a0d11c02bb0
docs/CLI.md -> src/commands/status.zig sig:ab9cee37b4b22644
docs/CLI.md -> src/commands/unlink.zig sig:590c53a3920551d3
docs/CLI.md -> src/commands/unlink.zig sig:6fc59e2a25f80fac
docs/DESIGN.md -> src/context.zig sig:70678dcc0872470d
docs/DESIGN.md -> src/lockfile.zig sig:f659df3e59325b71
docs/DESIGN.md -> src/main.zig sig:293b2dd6f549640e
docs/DESIGN.md -> src/lockfile.zig sig:23bc7256cff13942
docs/DESIGN.md -> src/main.zig sig:647a31274655a84d
docs/DESIGN.md -> src/symbols.zig sig:8e4a403c6f0130c3
docs/DESIGN.md -> src/vcs.zig sig:2468937f00d5305a
docs/RELEASING.md -> .github/workflows/ci.yml sig:e8440b1d7ee3e4ba
docs/RELEASING.md -> .github/workflows/release.yml sig:f74d66b6bd7f959c
docs/RELEASING.md -> cliff.toml sig:d2a8e301fe4b788e
docs/check-json-schema.md -> docs/schemas/drift.check.v1.json sig:4e6d23b9945aebb1
docs/check-json-schema.md -> src/payload/drift_check_v1.zig sig:d446ad53565b6916
examples/relink-gate/doc.md -> examples/relink-gate/auth.ts#login sig:c8bbc20bd89e5037 doc:d8a816f1e32c7dcf
examples/symbol-anchor/doc.md -> examples/symbol-anchor/config.ts#DatabaseConfig sig:a643fafff54d00ed doc:c0e40992f7a92864
examples/symbol-anchor/doc.md -> examples/symbol-anchor/config.ts#createPool sig:3294e048b6c144ae doc:c0e40992f7a92864
Empty file.
2 changes: 1 addition & 1 deletion examples/relink-gate/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export function login(username: string, password: string): string {
throw new Error("password required");
}
if (username.length < 3) {
throw new Error("username too short");
throw new Error("username must be more than 3 chars");
}
return createSession(username);
}
Expand Down
1 change: 1 addition & 0 deletions examples/relink-gate/drift.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
doc.md -> auth.ts#login sig:fde13635c2922d43
2 changes: 2 additions & 0 deletions examples/symbol-anchor/drift.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
doc.md -> config.ts#DatabaseConfig sig:a643fafff54d00ed
doc.md -> config.ts#createPool sig:3294e048b6c144ae
28 changes: 23 additions & 5 deletions src/commands/link.zig
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ pub fn run(
) !void {
const cwd_path = try std.fs.cwd().realpathAlloc(ctx.run_arena, ".");

var lf = try lockfile.discover(ctx.run_arena, ctx.scratch(), cwd_path);
const abs_doc_path = try std.fs.path.resolve(ctx.run_arena, &.{ cwd_path, doc_path });
const doc_dir = std.fs.path.dirname(abs_doc_path) orelse cwd_path;
var lf = try lockfile.discover(ctx.run_arena, ctx.scratch(), doc_dir);
ctx.resetScratch();

const doc_content = std.fs.cwd().readFileAlloc(ctx.run_arena, doc_path, 1024 * 1024) catch |err| {
Expand Down Expand Up @@ -52,8 +54,7 @@ pub fn run(
const binding = findBinding(lf.bindings.items, normalized_doc_path, normalized_target).?;
if (isDocGateBlocked(binding, old_sig, doc_is_still_accurate)) {
printStaleContext(ctx, stderr_w, lf.root_path, cwd_path, doc_path, doc_content, binding.target);
stderr_w.print("refused: {s} -> {s} — target changed since last link.\nReview the doc, then relink with --doc-is-still-accurate.\n", .{ doc_path, binding.target }) catch {};
return error.DocUnchanged;
if (!promptDocAccurate(stderr_w)) return error.DocUnchanged;
}
binding.removeField("doc");

Expand Down Expand Up @@ -89,13 +90,30 @@ pub fn run(

if (refused_count > 0) {
printBlanketRefusal(ctx, stderr_w, lf.root_path, cwd_path, doc_path, doc_content, lf.bindings.items, normalized_doc_path, refused_count);
return error.DocUnchanged;
if (!promptDocAccurate(stderr_w)) return error.DocUnchanged;
}

try lockfile.writeFile(&lf, ctx.scratch());
stdout_w.print("relinked all anchors in {s}\n", .{normalized_doc_path}) catch {};
}

/// In TTY mode, prompt the user to confirm the doc is still accurate.
/// In non-TTY mode, print the refusal message and return false.
fn promptDocAccurate(stderr_w: *std.io.Writer) bool {
const stdin = std.fs.File.stdin();
if (!stdin.isTty()) {
stderr_w.print("refused: target changed since last link.\nReview the doc, then relink with --doc-is-still-accurate.\n", .{}) catch {};
return false;
}
stderr_w.print("Doc is still accurate? [y/N] ", .{}) catch {};
stderr_w.flush() catch {};
var buf: [16]u8 = undefined;
const n = stdin.read(&buf) catch return false;
if (n == 0) return false;
const answer = std.mem.trimRight(u8, buf[0..n], "\r\n \t");
return answer.len > 0 and (answer[0] == 'y' or answer[0] == 'Y');
}

/// Returns true when a relink should be refused: target changed without review.
fn isDocGateBlocked(
binding: *lockfile.Binding,
Expand Down Expand Up @@ -144,7 +162,7 @@ fn printBlanketRefusal(
stderr_w.print(" STALE {s}\n", .{b.target}) catch {};
}

stderr_w.print("\nrefused: {d} anchor{s} in {s} — targets changed since last link.\nReview the doc, then relink with --doc-is-still-accurate.\n", .{
stderr_w.print("\n{d} stale anchor{s} in {s}\n", .{
refused_count,
if (refused_count == 1) "" else "s",
doc_path,
Expand Down
14 changes: 14 additions & 0 deletions src/commands/lint.zig
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ fn discoverDocGroups(
offset += rel_end + 1;

if (!std.mem.endsWith(u8, line, ".md")) continue;
if (hasNestedLockfile(root_path, line, allocator)) continue;
_ = try ensureDocGroup(allocator, &docs, line);
}

Expand All @@ -342,6 +343,19 @@ fn discoverDocGroups(
return docs;
}

/// Check if a relative path has a closer drift.lock than root_path.
/// Returns true if there's an intermediate drift.lock (the file belongs to a nested scope).
fn hasNestedLockfile(root_path: []const u8, rel_path: []const u8, allocator: std.mem.Allocator) bool {
var dir: []const u8 = std.fs.path.dirname(rel_path) orelse return false;

while (dir.len > 0) {
const candidate = std.fs.path.join(allocator, &.{ root_path, dir, "drift.lock" }) catch return false;
if (pathExists(candidate)) return true;
dir = std.fs.path.dirname(dir) orelse break;
}
return false;
}

fn ensureDocGroup(
allocator: std.mem.Allocator,
docs: *std.ArrayList(DocGroup),
Expand Down
4 changes: 3 additions & 1 deletion src/commands/unlink.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ pub fn run(ctx: CommandContext, stdout_w: *std.io.Writer, stderr_w: *std.io.Writ

const cwd_path = try std.fs.cwd().realpathAlloc(ctx.run_arena, ".");

var lf = try lockfile.discover(ctx.run_arena, ctx.scratch(), cwd_path);
const abs_doc_path = try std.fs.path.resolve(ctx.run_arena, &.{ cwd_path, doc_path });
const doc_dir = std.fs.path.dirname(abs_doc_path) orelse cwd_path;
var lf = try lockfile.discover(ctx.run_arena, ctx.scratch(), doc_dir);
ctx.resetScratch();

if (!lf.exists) return;
Expand Down
14 changes: 11 additions & 3 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const format_params = clap.parseParamsComptime(
const check_params = clap.parseParamsComptime(
\\--format <str>
\\--changed <str>
\\--silent
\\
);

Expand Down Expand Up @@ -160,15 +161,22 @@ pub fn main() !void {
var sub = parseExOrReport(&check_params, clap.parsers.default, allocator, &diag, &stderr_w.interface, &iter, clap_parse_all);
defer sub.deinit();
if (iter.next()) |_| {
fatal(&stderr_w.interface, "usage: drift check [--format text|json] [--changed <path>]\n", .{});
fatal(&stderr_w.interface, "usage: drift check [--format text|json] [--changed <path>] [--silent]\n", .{});
}
const format = parseFormat(sub.args.format, &stderr_w.interface);
const silent = sub.args.silent != 0;
var null_buf: [1]u8 = undefined;
var null_file = std.fs.openFileAbsolute("/dev/null", .{ .mode = .write_only }) catch
fatal(&stderr_w.interface, "error: cannot open /dev/null\n", .{});
defer null_file.close();
var null_w = null_file.writer(&null_buf);
var run_arena = std.heap.ArenaAllocator.init(allocator);
defer run_arena.deinit();
var scratch_arena = std.heap.ArenaAllocator.init(allocator);
defer scratch_arena.deinit();
const ctx = CommandContext{ .run_arena = run_arena.allocator(), .scratch_arena = &scratch_arena };
const run_status = lint.run(ctx, &stdout_w.interface, &stderr_w.interface, format, sub.args.changed) catch |err| switch (err) {
const out_w = if (silent) &null_w.interface else &stdout_w.interface;
const run_status = lint.run(ctx, out_w, &stderr_w.interface, format, sub.args.changed) catch |err| switch (err) {
error.LintCheckFailed => {
stdout_w.interface.flush() catch {};
stderr_w.interface.flush() catch {};
Expand Down Expand Up @@ -292,7 +300,7 @@ fn printUsage(w: *std.io.Writer) void {
\\Usage: drift <command> [options]
\\
\\Commands:
\\ check Check all docs for staleness [--format text|json] [--changed <path>]
\\ check Check all docs for staleness [--format text|json] [--changed <path>] [--silent]
\\ status Show all docs and their anchors [--format text|json]
\\ link Add anchors to a doc [--doc-is-still-accurate]
\\ unlink Remove anchors from a doc
Expand Down
17 changes: 17 additions & 0 deletions test/helpers.zig
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,23 @@ pub const TempRepo = struct {
return runProcess(self.allocator, argv, self.abs_path);
}

/// Run the drift binary with given arguments, cwd set to a subdirectory of the temp repo.
pub fn runDriftFromSubdir(self: *TempRepo, subdir: []const u8, args: []const []const u8) !ExecResult {
const drift_bin = build_options.drift_bin;

var argv_buf: [17][]const u8 = undefined;
argv_buf[0] = drift_bin;
for (args, 0..) |arg, i| {
argv_buf[i + 1] = arg;
}
const argv = argv_buf[0 .. args.len + 1];

const sub_path = try std.fs.path.join(self.allocator, &.{ self.abs_path, subdir });
defer self.allocator.free(sub_path);

return runProcess(self.allocator, argv, sub_path);
}

/// Get the short commit hash of HEAD. Caller owns returned memory.
pub fn getHeadRevision(self: *TempRepo, allocator: std.mem.Allocator) ![]const u8 {
const result = try runProcess(allocator, &.{ "git", "rev-parse", "--short", "HEAD" }, self.abs_path);
Expand Down
59 changes: 57 additions & 2 deletions test/integration/link_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ test "link blanket mode refuses relink when doc unchanged" {
defer result.deinit(allocator);
try helpers.expectExitCode(result.term, 1);
try helpers.expectContains(result.stderr, "refused:");
try helpers.expectContains(result.stderr, "targets changed since last link");
try helpers.expectContains(result.stderr, "--doc-is-still-accurate");
}

test "link blanket mode refuses relink even when doc changed" {
Expand All @@ -157,7 +157,7 @@ test "link blanket mode refuses relink even when doc changed" {
defer result.deinit(allocator);
try helpers.expectExitCode(result.term, 1);
try helpers.expectContains(result.stderr, "refused:");
try helpers.expectContains(result.stderr, "targets changed since last link");
try helpers.expectContains(result.stderr, "--doc-is-still-accurate");
}

test "link blanket mode relinks with --doc-is-still-accurate override" {
Expand Down Expand Up @@ -205,3 +205,58 @@ test "link no longer migrates legacy frontmatter anchors" {
try helpers.expectExitCode(result.term, 1);
try helpers.expectContains(result.stderr, "no bindings found for docs/doc.md");
}

test "link uses nested drift.lock when doc is in nested scope" {
const allocator = std.testing.allocator;
var repo = try helpers.TempRepo.init(allocator);
defer repo.cleanup();

try repo.writeFile("drift.lock", "");
try repo.writeFile("nested/drift.lock", "");
try repo.writeFile("nested/doc.md", "# Nested\n");
try repo.writeFile("nested/code.ts", "export const value = 1;\n");
try repo.commit("add root and nested scope");

// Run link from root, but doc is in nested/ — should write to nested/drift.lock
const result = try repo.runDrift(&.{ "link", "nested/doc.md", "nested/code.ts" });
defer result.deinit(allocator);

try helpers.expectExitCode(result.term, 0);
try helpers.expectContains(result.stdout, "added doc.md -> code.ts sig:");

// Verify binding is in nested/drift.lock, NOT root drift.lock
const nested_lock = try repo.readFile("nested/drift.lock");
defer allocator.free(nested_lock);
try helpers.expectContains(nested_lock, "doc.md -> code.ts sig:");

const root_lock = try repo.readFile("drift.lock");
defer allocator.free(root_lock);
try std.testing.expectEqualStrings("", root_lock);
}

test "unlink uses nested drift.lock when doc is in nested scope" {
const allocator = std.testing.allocator;
var repo = try helpers.TempRepo.init(allocator);
defer repo.cleanup();

try repo.writeFile("drift.lock", "");
try repo.writeFile("nested/drift.lock", "nested/doc.md -> nested/code.ts sig:deadbeefdeadbeef\n");

// Wait, unlink normalizes paths relative to lockfile root.
// Since nested/drift.lock root is nested/, the binding path is doc.md -> code.ts
try repo.writeFile("nested/drift.lock", "doc.md -> code.ts sig:deadbeefdeadbeef\n");
try repo.writeFile("nested/doc.md", "# Nested\n");
try repo.writeFile("nested/code.ts", "export const value = 1;\n");
try repo.commit("add root and nested scope with binding");

// Run unlink from root, but doc is in nested/ — should use nested/drift.lock
const result = try repo.runDrift(&.{ "unlink", "nested/doc.md", "nested/code.ts" });
defer result.deinit(allocator);

try helpers.expectExitCode(result.term, 0);
try helpers.expectContains(result.stdout, "removed doc.md -> code.ts from drift.lock");

const nested_lock = try repo.readFile("nested/drift.lock");
defer allocator.free(nested_lock);
try helpers.expectNotContains(nested_lock, "code.ts");
}
43 changes: 43 additions & 0 deletions test/integration/lint_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -798,3 +798,46 @@ test "lint --format json works as alias" {
try helpers.validateDriftCheckJson(allocator, result.stdout);
try helpers.expectContains(result.stdout, "drift.check.v1");
}

test "check from root skips docs in nested drift.lock scope" {
const allocator = std.testing.allocator;
var repo = try helpers.TempRepo.init(allocator);
defer repo.cleanup();

// Create root lockfile and a nested lockfile in nested/
try repo.writeFile("drift.lock", "");
try repo.writeFile("docs/root.md", "# Root\n");
try repo.writeFile("nested/drift.lock", "");
try repo.writeFile("nested/doc.md", "# Nested\n\nSee [missing](missing.md).\n");
try repo.commit("add root and nested scope");

// From root: should NOT report the broken link in nested/doc.md
const result = try repo.runDrift(&.{"check"});
defer result.deinit(allocator);

try helpers.expectExitCode(result.term, 0);
try helpers.expectNotContains(result.stdout, "nested/doc.md");
try helpers.expectNotContains(result.stdout, "BROKEN");
}

test "check from nested subdir with its own drift.lock only checks that scope" {
const allocator = std.testing.allocator;
var repo = try helpers.TempRepo.init(allocator);
defer repo.cleanup();

try repo.writeFile("drift.lock", "");
try repo.writeFile("docs/root.md", "# Root\n\nSee [missing](missing.md).\n");
try repo.writeFile("nested/drift.lock", "");
try repo.writeFile("nested/doc.md", "# Nested\n\nSee [also-missing](also-missing.md).\n");
try repo.commit("add root and nested scope");

// From nested/: should report the broken link in nested/doc.md
const result = try repo.runDriftFromSubdir("nested", &.{"check"});
defer result.deinit(allocator);

try helpers.expectExitCode(result.term, 1);
try helpers.expectContains(result.stdout, "doc.md");
try helpers.expectContains(result.stdout, "BROKEN");
// Should NOT contain docs from root scope
try helpers.expectNotContains(result.stdout, "docs/root.md");
}
Loading