diff --git a/docs/CLI.md b/docs/CLI.md index f389498..9f9b805 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -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 ] @@ -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-is-still-accurate] @@ -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 diff --git a/drift.lock b/drift.lock index 81c75c3..51bff1e 100644 --- a/drift.lock +++ b/drift.lock @@ -1,15 +1,15 @@ -.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 @@ -17,6 +17,3 @@ 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 diff --git a/examples/broken-links/drift.lock b/examples/broken-links/drift.lock new file mode 100644 index 0000000..e69de29 diff --git a/examples/relink-gate/auth.ts b/examples/relink-gate/auth.ts index d7c3799..049ffe1 100644 --- a/examples/relink-gate/auth.ts +++ b/examples/relink-gate/auth.ts @@ -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); } diff --git a/examples/relink-gate/drift.lock b/examples/relink-gate/drift.lock new file mode 100644 index 0000000..ff70e7e --- /dev/null +++ b/examples/relink-gate/drift.lock @@ -0,0 +1 @@ +doc.md -> auth.ts#login sig:fde13635c2922d43 diff --git a/examples/symbol-anchor/drift.lock b/examples/symbol-anchor/drift.lock new file mode 100644 index 0000000..9cf782f --- /dev/null +++ b/examples/symbol-anchor/drift.lock @@ -0,0 +1,2 @@ +doc.md -> config.ts#DatabaseConfig sig:a643fafff54d00ed +doc.md -> config.ts#createPool sig:3294e048b6c144ae diff --git a/src/commands/link.zig b/src/commands/link.zig index c709e11..e9ceb7e 100644 --- a/src/commands/link.zig +++ b/src/commands/link.zig @@ -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| { @@ -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"); @@ -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, @@ -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, diff --git a/src/commands/lint.zig b/src/commands/lint.zig index 482d618..7d80076 100644 --- a/src/commands/lint.zig +++ b/src/commands/lint.zig @@ -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); } @@ -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), diff --git a/src/commands/unlink.zig b/src/commands/unlink.zig index 612ad54..6481a61 100644 --- a/src/commands/unlink.zig +++ b/src/commands/unlink.zig @@ -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; diff --git a/src/main.zig b/src/main.zig index 5f921e7..6f259f9 100644 --- a/src/main.zig +++ b/src/main.zig @@ -51,6 +51,7 @@ const format_params = clap.parseParamsComptime( const check_params = clap.parseParamsComptime( \\--format \\--changed + \\--silent \\ ); @@ -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 ]\n", .{}); + fatal(&stderr_w.interface, "usage: drift check [--format text|json] [--changed ] [--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 {}; @@ -292,7 +300,7 @@ fn printUsage(w: *std.io.Writer) void { \\Usage: drift [options] \\ \\Commands: - \\ check Check all docs for staleness [--format text|json] [--changed ] + \\ check Check all docs for staleness [--format text|json] [--changed ] [--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 diff --git a/test/helpers.zig b/test/helpers.zig index 0c48a52..d780b6f 100644 --- a/test/helpers.zig +++ b/test/helpers.zig @@ -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); diff --git a/test/integration/link_test.zig b/test/integration/link_test.zig index e2d6a0f..f740e48 100644 --- a/test/integration/link_test.zig +++ b/test/integration/link_test.zig @@ -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" { @@ -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" { @@ -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"); +} diff --git a/test/integration/lint_test.zig b/test/integration/lint_test.zig index 614540b..73c7cd9 100644 --- a/test/integration/lint_test.zig +++ b/test/integration/lint_test.zig @@ -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"); +}