diff --git a/.claude/skills/drift/SKILL.md b/.claude/skills/drift/SKILL.md index 0a8dfd0..3586048 100644 --- a/.claude/skills/drift/SKILL.md +++ b/.claude/skills/drift/SKILL.md @@ -97,15 +97,28 @@ Someone changed bound code without updating docs. Read the lint output to see wh ## Anchor syntax Bindings in `drift.lock`: -``` -docs/auth.md -> src/auth/login.ts sig:a1b2c3d4e5f6a7b8 -docs/auth.md -> src/auth/provider.ts#AuthConfig sig:c3d4e5f6a7b8a1b2 -docs/overview.md -> docs/auth.md#authentication sig:b3c4d5e6f7a8b9c0 +```toml +version = 1 + +[[bindings]] +doc = "docs/auth.md" +target = "src/auth/login.ts" +sig = "a1b2c3d4e5f6a7b8" + +[[bindings]] +doc = "docs/auth.md" +target = "src/auth/provider.ts#AuthConfig" +sig = "c3d4e5f6a7b8a1b2" + +[[bindings]] +doc = "docs/overview.md" +target = "docs/auth.md#authentication" +sig = "b3c4d5e6f7a8b9c0" ``` Anchors can target code files, code symbols (`file#Symbol`), or doc headings (`doc.md#heading-slug`). Heading fragments use GitHub-style slugs (lowercase, hyphens). -`drift link` writes bindings to `drift.lock` with content signatures (`sig:`). Content signatures are AST fingerprints of the target, so staleness detection works without querying VCS history. This means `drift link` works on uncommitted files — no need to commit first. +`drift link` writes bindings to `drift.lock` with content signatures (`sig = ""`). Content signatures are AST fingerprints of the target, so staleness detection works without querying VCS history. This means `drift link` works on uncommitted files — no need to commit first. When relinking a stale anchor, `drift link` refuses and prints both sides (doc section and current code) so you can review the change. Pass `--doc-is-still-accurate` to confirm the doc doesn't need updates. @@ -113,10 +126,16 @@ When relinking a stale anchor, `drift link` refuses and prints both sides (doc s ## Cross-repo docs (origin) -Docs installed from other repos (like this skill) carry `origin:` on their bindings in `drift.lock` so `drift check` skips their anchors in consumer repos. If you're writing a doc that will be distributed to other repos, add origin to prevent false positives: +Docs installed from other repos (like this skill) carry `origin` on their bindings in `drift.lock` so `drift check` skips their anchors in consumer repos. If you're writing a doc that will be distributed to other repos, add origin to prevent false positives: -``` -docs/skill.md -> src/main.ts sig:a1b2c3d4e5f6a7b8 origin:github:your-org/your-repo +```toml +version = 1 + +[[bindings]] +doc = "docs/skill.md" +target = "src/main.ts" +origin = "github:your-org/your-repo" +sig = "a1b2c3d4e5f6a7b8" ``` ## Staleness diff --git a/README.md b/README.md index a267593..ccab8df 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,18 @@ drift link docs/auth.md After linking, bindings live in `drift.lock` at the repo root: -``` -docs/auth.md -> src/auth/login.ts sig:e4f8a2c10b3d7890 -docs/auth.md -> src/auth/provider.ts#AuthConfig sig:1a2b3c4d5e6f7890 +```toml +version = 1 + +[[bindings]] +doc = "docs/auth.md" +target = "src/auth/login.ts" +sig = "e4f8a2c10b3d7890" + +[[bindings]] +doc = "docs/auth.md" +target = "src/auth/provider.ts#AuthConfig" +sig = "1a2b3c4d5e6f7890" ``` You can also use inline references in the doc body — `@./src/auth/provider.ts#AuthConfig` — and `drift link` will stamp those in the lockfile too. @@ -77,8 +86,8 @@ You can also use inline references in the doc body — `@./src/auth/provider.ts# Every anchor has three parts: ``` -src/auth/provider.ts #AuthConfig sig:1a2b3c4d5e6f7890 -└── file path ──────┘ └─ symbol ─┘ └──── signature ────┘ +src/auth/provider.ts #AuthConfig sig = "1a2b3c4d5e6f7890" +└── file path ──────┘ └─ symbol ─┘ └──────── signature ────────┘ ``` - **Path** — the file you're binding to, relative to the repo root. @@ -87,10 +96,16 @@ src/auth/provider.ts #AuthConfig sig:1a2b3c4d5e6f7890 ### Cross-repo docs (origin) -Docs that travel across repo boundaries — installed skills, vendored docs, shared templates — can declare where their anchors belong via a trailing `origin:` field: +Docs that travel across repo boundaries — installed skills, vendored docs, shared templates — can declare where their anchors belong via an `origin` field: -``` -docs/skill.md -> src/main.zig sig:a1b2c3d4e5f67890 origin:github:fiberplane/drift +```toml +version = 1 + +[[bindings]] +doc = "docs/skill.md" +target = "src/main.zig" +origin = "github:fiberplane/drift" +sig = "a1b2c3d4e5f67890" ``` When `origin` doesn't match the current repo, `drift check` skips those anchors instead of reporting false "file not found" errors. Anchors without `origin` are always checked. @@ -109,7 +124,7 @@ drift refs Reverse lookup — which docs reference a given file ## How staleness works -Each anchor's `sig:` field records a fingerprint of the code at the time it was linked. `drift check` recomputes the fingerprint from the current file and compares. For supported languages (TypeScript, Python, Rust, Go, Zig, Java), comparison is syntax-aware — drift parses with tree-sitter and hashes a normalized AST fingerprint (node kinds + token text, no whitespace or position data). Reformatting won't trigger false positives. Symbol-level anchors (`#AuthConfig`) narrow this to just that declaration's subtree. Unsupported languages fall back to raw content comparison. +Each anchor's `sig` field records a fingerprint of the code at the time it was linked. `drift check` recomputes the fingerprint from the current file and compares. For supported languages (TypeScript, Python, Rust, Go, Zig, Java), comparison is syntax-aware — drift parses with tree-sitter and hashes a normalized AST fingerprint (node kinds + token text, no whitespace or position data). Reformatting won't trigger false positives. Symbol-level anchors (`#AuthConfig`) narrow this to just that declaration's subtree. Unsupported languages fall back to raw content comparison. No VCS history is needed for staleness detection — `drift check` works entirely from the stored signature and current file content. @@ -154,7 +169,7 @@ For faster CI runs, use `--changed` to scope checking to docs affected by the fi - run: drift check --changed src/auth ``` -`fetch-depth: 0` is recommended — drift uses VCS history for blame info on stale anchors. With `sig:` provenance (the default), staleness detection itself doesn't need history. The setup action auto-detects platform, downloads the right binary from GitHub releases, and verifies its checksum before installing. +`fetch-depth: 0` is recommended — drift uses VCS history for blame info on stale anchors. With `sig` provenance (the default), staleness detection itself doesn't need history. The setup action auto-detects platform, downloads the right binary from GitHub releases, and verifies its checksum before installing. ## Development diff --git a/docs/CLI.md b/docs/CLI.md index 3e03683..5a936d7 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -10,7 +10,7 @@ Check all docs for staleness. The primary command. Exits 1 if any anchor is stal drift check [--format text|json] [--changed ] [--silent] ``` -Reads bindings from `drift.lock`, recomputes content signatures for each target, and compares against the stored `sig:` values. Reports stale anchors with reasons. +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 ` 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. @@ -45,7 +45,7 @@ vendor/shared-skill.md - `STALE` means a lockfile anchor's target has changed since the signature was recorded. - `BROKEN` means a plain markdown link in the doc points to a file that doesn't exist — no lockfile entry is needed for this check. -Anchors with an `origin:` field that doesn't match the current repo are skipped — they reference files in a different repository. +Anchors with an `origin` field that doesn't match the current repo are skipped — they reference files in a different repository. ## drift status @@ -71,7 +71,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. 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. +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 TOML 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] @@ -92,7 +92,7 @@ $ drift link docs/overview.md docs/auth.md#Authentication added docs/overview.md -> docs/auth.md#Authentication sig:d4e5f6a7b8c9d0e1 ``` -**Blanket mode** — refreshes all `sig:` values for that doc in `drift.lock`: +**Blanket mode** — refreshes all `sig` values for that doc in `drift.lock`: ``` $ drift link docs/auth.md diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 040bf8b..7af7c49 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -28,13 +28,13 @@ The tradeoff is that docs are no longer self-contained — the lockfile must tra ## 4. Content signatures in lockfile -`drift link` stores provenance as `sig:<16-char-hex>` — a content-addressed fingerprint of the anchor's target. The fingerprint is the same normalized syntax hash that staleness detection computes (XxHash3 of the tree-sitter AST walk, or raw XxHash3 for unsupported languages). +`drift link` stores provenance as a `sig` lockfile field containing a 16-character content-addressed fingerprint of the anchor's target. The fingerprint is the same normalized syntax hash that staleness detection computes (XxHash3 of the tree-sitter AST walk, or raw XxHash3 for unsupported languages). The original design (Decision 4 in prior versions) rejected lockfiles on the grounds that they introduce an escape hatch — `drift lock` could silence lint warnings without updating the doc. This concern doesn't apply to the current design because: - The lockfile stores signatures, not "this is fine" flags. A stale signature still reports as stale. There is no way to mark an anchor as "accepted" — the only way to make it pass is to run `drift link`, which recomputes the signature from current code. - The lockfile is deterministically generated by `drift link` — it's not a manual override mechanism. -- Content signatures make drift VCS-independent. `drift lint` with `sig:` never shells out to git for staleness — it reads the file, hashes it, compares. This makes shallow clones, fresh clones, and detached-HEAD states work without history. +- Content signatures make drift VCS-independent. `drift lint` with `sig` provenance never shells out to git for staleness — it reads the file, hashes it, compares. This makes shallow clones, fresh clones, and detached-HEAD states work without history. - Detached HEAD, rebases, and force pushes don't invalidate provenance. A git SHA can become unreachable; a content fingerprint is always recomputable from the current file. - The staleness check is a pure function of the file's content, not of VCS state. Behavior is deterministic. @@ -76,29 +76,32 @@ Starting with 6 languages: TypeScript, Python, Rust, Go, Zig, Java. More can be ## 9. Content signatures as provenance format -Content signatures (`sig:<16-char-hex>`) are the provenance format for all anchors. See Decision 4 for why they live in the lockfile and how they make drift VCS-independent for staleness detection. +Content signatures (`sig = "<16-char-hex>"`) are the provenance format for all lockfile anchors. See Decision 4 for why they live in the lockfile and how they make drift VCS-independent for staleness detection. ## 10. Origin-qualified anchors -Anchors can carry an `origin:github:owner/repo` field in the lockfile. At lint time, drift resolves the current repo's identity from `git remote get-url origin`, normalizes it to `github:owner/repo`, and compares. If an anchor's origin doesn't match, it is skipped — it belongs to a different repository. +Anchors can carry an `origin = "github:owner/repo"` field in the lockfile. At lint time, drift resolves the current repo's identity from `git remote get-url origin`, normalizes it to `github:owner/repo`, and compares. If an anchor's origin doesn't match, it is skipped — it belongs to a different repository. This solves the problem of docs traveling across repository boundaries. Shared skill files, vendored documentation, and monorepo imports all contain anchors that point at files in the source repo, not the consuming repo. Without origin qualification, `drift lint` would report these as STALE (file not found) every time, creating noise. With it, foreign anchors are silently skipped and only local anchors are checked. -Origin is opt-in. Anchors without `origin:` are always checked. The normalized format (`github:owner/repo`) is derived from the git remote URL, handling SSH, HTTPS, and SSH URL formats uniformly. +Origin is opt-in. Anchors without `origin` are always checked. The normalized format (`github:owner/repo`) is derived from the git remote URL, handling SSH, HTTPS, and SSH URL formats uniformly. -## 11. Flat line-oriented lockfile format +## 11. TOML array-of-tables lockfile format -`drift.lock` uses a flat, line-oriented format where each line is one binding: ` -> ...`. Alternatives considered: +`drift.lock` version 1 uses TOML `[[bindings]]` tables. The file starts with `version = 1`; each table contains `doc`, `target`, and metadata keys such as `sig` and `origin`: -- **YAML** — would require a YAML parser drift doesn't have. Adds a dependency for a config file format, not a data format. -- **JSON** — noisy diffs (trailing commas, bracket lines), painful merge conflicts. JSON merge conflicts require understanding structure to resolve. -- **INI-style sections** — grouping bindings under `[doc-path]` headers complicates conflict resolution because a conflict in the header affects all entries in the section. +```toml +version = 1 -The flat format was chosen because: -- Every line is independent — `sort -u` resolves merge conflicts mechanically. -- No parser dependencies — `splitSequence(" -> ")` for the doc/rest boundary, `splitScalar(' ')` for target and key:value pairs. -- Both paths on every line — grep works in both directions for quick shell queries (`grep 'src/auth' drift.lock` finds all bindings involving auth code). -- Trailing key:value pairs extend naturally without format version bumps. New metadata fields (e.g. a future `hash-algo:`) are just another pair on the line. +[[bindings]] +doc = "docs/auth.md" +target = "src/auth/login.ts" +sig = "a1b2c3d4e5f6a7b8" +``` + +The TOML format replaced the original one-line ` -> ...` format after property tests showed it substantially reduces spurious merge conflicts on disjoint edits while remaining parseable by standard TOML tools. Bindings are serialized canonically with metadata fields sorted by key and blocks sorted by `(doc, target)` (with metadata as a deterministic tie-breaker for duplicate bindings). + +Parser rules intentionally stay small and lockfile-specific: `version = 1` is mandatory for TOML lockfiles; blank lines and full-line `#` comments are ignored; block order is not significant on read; keys are bare `[A-Za-z0-9_-]+`; and values use single-line TOML basic strings with the common escapes (`\\b`, `\\t`, `\\n`, `\\f`, `\\r`, `\\\"`, `\\\\`). General TOML features such as inline comments, dotted keys, multiline strings, arbitrary top-level keys, and non-`bindings` tables are rejected. The reader can still import the legacy line format so existing lockfiles are upgraded to TOML on the next write. ## 12. Arena-oriented memory management @@ -149,7 +152,7 @@ This matters because drift aims to support multiple VCS backends. Asking git for Markdown docs are parsed with tree-sitter during lint using the two-parser `tree-sitter-markdown` grammar (block grammar for document structure, inline grammar for link extraction). This serves two purposes: - **Broken link detection**: all `[text](relative/path.md)` links in drift-managed docs are extracted from `inline_link` nodes and checked for file existence. Missing targets are reported as `BROKEN` — no lockfile entry needed. -- **Doc-to-doc anchors**: a lockfile binding like `docs/overview.md -> docs/auth.md#Authentication sig:...` resolves the heading in the target doc via the block grammar's `section`/`atx_heading` nodes, then fingerprints the section content for staleness detection. Headings are structurally equivalent to code symbols. +- **Doc-to-doc anchors**: a lockfile binding whose `target` is `docs/auth.md#Authentication` resolves the heading in the target doc via the block grammar's `section`/`atx_heading` nodes, then fingerprints the section content for staleness detection. Headings are structurally equivalent to code symbols. We use tree-sitter for link extraction rather than regex because: - The inline grammar handles all link variants (inline links, reference links, autolinks) and naturally excludes links inside code blocks and code spans diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 0d94c83..f2607f7 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -18,30 +18,45 @@ During `drift lint`, each doc is also parsed to extract markdown links. Any rela ### Anchors -An anchor is a declared relationship between a doc and a code artifact. Anchors are stored as lines in `drift.lock`, not in the doc files. +An anchor is a declared relationship between a doc and a code artifact. Anchors are stored as TOML `[[bindings]]` tables in `drift.lock`, not in the doc files. -``` -docs/auth.md -> src/auth/login.ts sig:e4f8a2c10b3d7890 -docs/auth.md -> src/auth/provider.ts#AuthConfig sig:1a2b3c4d5e6f7890 +```toml +version = 1 + +[[bindings]] +doc = "docs/auth.md" +target = "src/auth/login.ts" +sig = "e4f8a2c10b3d7890" + +[[bindings]] +doc = "docs/auth.md" +target = "src/auth/provider.ts#AuthConfig" +sig = "1a2b3c4d5e6f7890" ``` -Each line is a binding: a doc path, an arrow separator, a target path (optionally with `#Symbol`), and trailing key:value metadata. The `sig:` field records a content signature — a normalized fingerprint of the target at the time the anchor was last verified. +Each table is a binding: a doc path, a target path (optionally with `#Symbol`), and metadata fields. The `sig` field records a content signature — a normalized fingerprint of the target at the time the anchor was last verified. -Anchors can be file-level (`src/auth/login.ts`) or symbol-level (`src/auth/provider.ts#AuthConfig`). Bare targets without a `sig:` field are valid — they declare a binding without provenance. +Anchors can be file-level (`src/auth/login.ts`) or symbol-level (`src/auth/provider.ts#AuthConfig`). Bare targets without a `sig` field are valid — they declare a binding without provenance. -`drift link` produces `sig:` provenance by default. Content signatures are VCS-independent — they encode a fingerprint of the code itself, so staleness detection works without querying git history. +`drift link` produces `sig` provenance by default. Content signatures are VCS-independent — they encode a fingerprint of the code itself, so staleness detection works without querying git history. ### Origin-Qualified Anchors -An anchor can carry an `origin:` field to declare which repository it belongs to: +An anchor can carry an `origin` field to declare which repository it belongs to: -``` -docs/auth.md -> src/auth/login.ts sig:e4f8a2c10b3d7890 origin:github:fiberplane/drift +```toml +version = 1 + +[[bindings]] +doc = "docs/auth.md" +target = "src/auth/login.ts" +origin = "github:fiberplane/drift" +sig = "e4f8a2c10b3d7890" ``` -When `drift lint` runs, it resolves the current repo's identity from `git remote get-url origin` and normalizes it to `github:owner/repo` format. If an anchor's `origin:` doesn't match the current repo, it is reported as `SKIP` — it belongs to a different repository and can't be checked locally. +When `drift lint` runs, it resolves the current repo's identity from `git remote get-url origin` and normalizes it to `github:owner/repo` format. If an anchor's `origin` doesn't match the current repo, it is reported as `SKIP` — it belongs to a different repository and can't be checked locally. -This lets docs travel across repo boundaries (vendored docs, shared skill files, monorepo imports) without producing false STALE reports. Anchors without an `origin:` field are always checked — origin qualification is opt-in. +This lets docs travel across repo boundaries (vendored docs, shared skill files, monorepo imports) without producing false STALE reports. Anchors without an `origin` field are always checked — origin qualification is opt-in. ### Symbol-Level Anchors @@ -80,21 +95,21 @@ Broken link detection uses the same tree-sitter parse that doc-to-doc anchor res ## Staleness Detection -Provenance is per-anchor: each anchor's `sig:` field in the lockfile records when the anchor was last verified. +Provenance is per-anchor: each anchor's `sig` field in the lockfile records when the anchor was last verified. -### Content signatures (`sig:`) — primary format +### Content signatures (`sig`) — primary format -`drift link` computes a normalized syntax fingerprint of each anchor's target and stores it as a 16-character hex string in the lockfile: `docs/auth.md -> src/auth/login.ts sig:a1b2c3d4e5f6a7b8`. At lint time, drift recomputes the fingerprint from the current file on disk and compares it to the stored value. If they match the anchor is fresh; if they differ it is stale. +`drift link` computes a normalized syntax fingerprint of each anchor's target and stores it as a 16-character hex string in the lockfile's `sig` field. At lint time, drift recomputes the fingerprint from the current file on disk and compares it to the stored value. If they match the anchor is fresh; if they differ it is stale. Content signatures are VCS-independent — they work in fresh clones, shallow clones, and detached-HEAD states without querying git history. For supported tree-sitter languages, the fingerprint is based on the normalized syntax tree so formatting-only changes do not trigger staleness. ### Detection algorithm 1. Read `drift.lock` to get all bindings -2. For each anchor, extract its `sig:` value +2. For each anchor, extract its `sig` value 3. Recompute the fingerprint from the current file on disk 4. Compare — if they differ, the anchor is stale -5. If no `sig:` field — the anchor has no provenance, report as stale +5. If no `sig` field — the anchor has no provenance, report as stale File reads are cached per lint run (`FileCache` in `main.zig`). When multiple anchors reference the same file, the content is read once. @@ -167,7 +182,7 @@ docs/auth.md Every command creates two arena allocators backed by the GPA in `main()`. The **run arena** owns command-lifetime data (lockfile, file cache, result model). The **scratch arena** owns per-item temporaries (path resolution, subprocess output) and is reset between loop iterations. Fixed-width formatting uses stack buffers. Only OS/C resources (child processes, tree-sitter parsers) need explicit `deinit()`. See Decision 12 in `DECISIONS.md` for the full ruleset. Additional modules: -- `lockfile.zig` — read, write, and query `drift.lock` bindings; line-oriented parser and serializer +- `lockfile.zig` — read, write, and query `drift.lock` bindings; TOML parser and serializer - `markdown.zig` — markdown parsing via tree-sitter (block + inline grammars): link extraction, heading resolution, section fingerprinting - `main.zig` — CLI entry point, argument parsing, subcommand dispatch - `commands/lint.zig` — lint engine: file/content caching, anchor staleness checks, report formatting @@ -178,7 +193,7 @@ Additional modules: ### lockfile.zig -Reads and writes `drift.lock`. The file is line-oriented: each non-blank, non-comment line is a binding in the format ` -> ...`. Parsing is two splits: `splitSequence(" -> ")` for the doc/rest boundary, then `splitScalar(' ')` for target and trailing key:value pairs. Writing canonicalizes each binding before output: metadata fields are sorted by key, then all rendered lines are sorted lexically and written with a trailing newline. +Reads and writes `drift.lock`. The on-disk format is TOML array-of-tables: each `[[bindings]]` block contains `doc`, `target`, and metadata keys such as `sig` and `origin`. Parsing skips blank lines and comments, accepts bindings in any order, and also imports the legacy line format for upgrade-on-write compatibility. Writing canonicalizes each binding before output: metadata fields are sorted by key, then blocks are sorted by doc/target and separated by one blank line. Discovery: walks up from cwd checking for `drift.lock` at each directory. The lockfile's directory becomes the project root for resolving relative paths. @@ -207,22 +222,31 @@ No libgit2, no jj library. `GitCatFile` keeps a single `git cat-file --batch` pr ### drift.lock -The lockfile is a flat, line-oriented file at the repo root. Every binding between a doc and a code target is one line. +The lockfile is a TOML file at the repo root. Every binding between a doc and a code target is one `[[bindings]]` table. -``` +```toml # drift.lock — managed by drift, do not edit manually -docs/auth.md -> src/auth/login.ts sig:e4f8a2c10b3d7890 -docs/auth.md -> src/auth/provider.ts#AuthConfig sig:1a2b3c4d5e6f7890 origin:github:fiberplane/drift -docs/overview.md -> docs/auth.md#Authentication sig:b3c4d5e6f7a8b9c0 -docs/payments.md -> src/payments/stripe.ts sig:9a8b7c6d5e4f3210 +version = 1 + +[[bindings]] +doc = "docs/auth.md" +target = "src/auth/login.ts" +sig = "e4f8a2c10b3d7890" + +[[bindings]] +doc = "docs/auth.md" +target = "src/auth/provider.ts#AuthConfig" +origin = "github:fiberplane/drift" +sig = "1a2b3c4d5e6f7890" ``` Format rules: -- One binding per line: ` -> ...` -- Sorted lexically by full line content -- Trailing key:value pairs for extensible metadata (`sig:`, `origin:`, future fields) +- `version = 1` declares the lockfile schema version +- One binding per `[[bindings]]` block with `doc`, `target`, and metadata string fields +- Blocks sorted by `(doc, target)` with deterministic tie-breaking for duplicate bindings - Metadata fields are serialized in key order so semantically equivalent bindings produce identical bytes -- Lines starting with `#` are comments, blank lines ignored +- Values are single-line TOML basic strings; drift escapes `\\b`, `\\t`, `\\n`, `\\f`, `\\r`, `\\\"`, and `\\\\` +- Lines starting with `#` are comments, blank lines ignored; inline comments and general TOML tables are outside the lockfile subset - Discovery: walk up from cwd until `drift.lock` is found ### .drift/config.yaml diff --git a/drift.lock b/drift.lock index ff63a74..854ea9d 100644 --- a/drift.lock +++ b/drift.lock @@ -1,19 +1,98 @@ -.claude/skills/drift/SKILL.md -> src/main.zig origin:github:fiberplane/drift sig:b6166454e541e8a4 -.claude/skills/drift/SKILL.md -> src/vcs.zig origin:github:fiberplane/drift sig:84da70be235ca9d4 -CLAUDE.md -> build.zig sig:2dccb33f6b790afa -CLAUDE.md -> src/main.zig sig:b6166454e541e8a4 -docs/CLI.md -> src/commands/link.zig sig:7bd7f824afc30e0b -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:6fc5c159297244d1 -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 -docs/RELEASING.md -> .github/workflows/release.yml sig:19b334776bec1eda -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:c80e398e38ece09d +version = 1 + +[[bindings]] +doc = ".claude/skills/drift/SKILL.md" +target = "src/main.zig" +origin = "github:fiberplane/drift" +sig = "0e63465cf6539ff3" + +[[bindings]] +doc = ".claude/skills/drift/SKILL.md" +target = "src/vcs.zig" +origin = "github:fiberplane/drift" +sig = "84da70be235ca9d4" + +[[bindings]] +doc = "CLAUDE.md" +target = "build.zig" +sig = "2dccb33f6b790afa" + +[[bindings]] +doc = "CLAUDE.md" +target = "src/main.zig" +sig = "0e63465cf6539ff3" + +[[bindings]] +doc = "docs/CLI.md" +target = "src/commands/link.zig" +sig = "7bd7f824afc30e0b" + +[[bindings]] +doc = "docs/CLI.md" +target = "src/commands/lint.zig" +sig = "fe7bd5a687f3e917" + +[[bindings]] +doc = "docs/CLI.md" +target = "src/commands/refs.zig" +sig = "f623b7774086094e" + +[[bindings]] +doc = "docs/CLI.md" +target = "src/commands/status.zig" +sig = "eade166d24a20b81" + +[[bindings]] +doc = "docs/CLI.md" +target = "src/commands/unlink.zig" +sig = "0dbe1ee3315211b5" + +[[bindings]] +doc = "docs/DESIGN.md" +target = "src/context.zig" +sig = "82d9da38ea486f36" + +[[bindings]] +doc = "docs/DESIGN.md" +target = "src/lockfile.zig" +sig = "55bc77a2853cb654" + +[[bindings]] +doc = "docs/DESIGN.md" +target = "src/main.zig" +sig = "0e63465cf6539ff3" + +[[bindings]] +doc = "docs/DESIGN.md" +target = "src/symbols.zig" +sig = "bbe250d43609daa1" + +[[bindings]] +doc = "docs/DESIGN.md" +target = "src/vcs.zig" +sig = "84da70be235ca9d4" + +[[bindings]] +doc = "docs/RELEASING.md" +target = ".github/workflows/ci.yml" +sig = "c14a23e6547d575f" + +[[bindings]] +doc = "docs/RELEASING.md" +target = ".github/workflows/release.yml" +sig = "19b334776bec1eda" + +[[bindings]] +doc = "docs/RELEASING.md" +target = "cliff.toml" +sig = "d2a8e301fe4b788e" + +[[bindings]] +doc = "docs/check-json-schema.md" +target = "docs/schemas/drift.check.v1.json" +sig = "4e6d23b9945aebb1" + +[[bindings]] +doc = "docs/check-json-schema.md" +target = "src/payload/drift_check_v1.zig" +sig = "c80e398e38ece09d" diff --git a/src/lockfile.zig b/src/lockfile.zig index 6d6cf67..fe202d1 100644 --- a/src/lockfile.zig +++ b/src/lockfile.zig @@ -60,6 +60,7 @@ pub const DocBindings = struct { pub const ParseError = error{ InvalidBindingLine, InvalidMetadataField, + UnsupportedLockfileVersion, }; /// `run` holds durable lockfile state; `scratch` holds walk temporaries and the lockfile file buffer (reset by caller). @@ -133,6 +134,13 @@ pub fn readAtPath(io: std.Io, run: std.mem.Allocator, scratch: std.mem.Allocator } pub fn parseInto(allocator: std.mem.Allocator, content: []const u8, bindings: *std.ArrayList(Binding)) !void { + if (containsTomlSyntax(content)) { + try parseTomlInto(allocator, content, bindings); + return; + } + + // Compatibility reader for pre-TOML lockfiles. All writes use V1 TOML, so + // old files are upgraded on the next mutation. var lines = std.mem.splitScalar(u8, content, '\n'); while (lines.next()) |line| { const trimmed = std.mem.trim(u8, line, " \t\r"); @@ -191,45 +199,80 @@ fn lessThanMetadataByKey(_: void, a: MetadataField, b: MetadataField) bool { return std.mem.order(u8, a.key, b.key) == .lt; } -/// Serializes one binding as ` -> [: ...]` with -/// metadata sorted by key so the on-disk form is a function of semantic state -/// only, not of `setField` insertion order. Uses `scratch` for a sort buffer. -fn renderLineToWriter(scratch: std.mem.Allocator, writer: *std.Io.Writer, binding: Binding) !void { - try writer.print("{s} -> {s}", .{ binding.doc_path, binding.target }); - if (binding.metadata.items.len == 0) return; +fn isValidTomlBareKey(key: []const u8) bool { + if (key.len == 0) return false; + for (key) |c| switch (c) { + 'A'...'Z', 'a'...'z', '0'...'9', '_', '-' => {}, + else => return false, + }; + return true; +} + +fn writeTomlString(writer: *std.Io.Writer, value: []const u8) !void { + if (!std.unicode.utf8ValidateSlice(value)) return error.InvalidMetadataField; + + try writer.writeByte('"'); + for (value) |c| switch (c) { + '\x08' => try writer.writeAll("\\b"), + '\t' => try writer.writeAll("\\t"), + '\n' => try writer.writeAll("\\n"), + '\x0c' => try writer.writeAll("\\f"), + '\r' => try writer.writeAll("\\r"), + '"' => try writer.writeAll("\\\""), + '\\' => try writer.writeAll("\\\\"), + 0x00...0x07, 0x0b, 0x0e...0x1f, 0x7f => return error.InvalidMetadataField, + else => try writer.writeByte(c), + }; + try writer.writeByte('"'); +} + +fn renderTomlBindingToWriter(scratch: std.mem.Allocator, writer: *std.Io.Writer, binding: Binding) !void { + try writer.writeAll("[[bindings]]\n"); + try writer.writeAll("doc = "); + try writeTomlString(writer, binding.doc_path); + try writer.writeByte('\n'); + try writer.writeAll("target = "); + try writeTomlString(writer, binding.target); + try writer.writeByte('\n'); const sorted = try scratch.dupe(MetadataField, binding.metadata.items); defer scratch.free(sorted); std.mem.sort(MetadataField, sorted, {}, lessThanMetadataByKey); for (sorted) |field| { - try writer.print(" {s}:{s}", .{ field.key, field.value }); + if (!isValidTomlBareKey(field.key) or std.mem.eql(u8, field.key, "doc") or std.mem.eql(u8, field.key, "target") or std.mem.eql(u8, field.key, "version")) return error.InvalidMetadataField; + try writer.print("{s} = ", .{field.key}); + try writeTomlString(writer, field.value); + try writer.writeByte('\n'); } } -/// Writes sorted lockfile lines to `writer`. Uses `scratch` for sort temporaries. +/// Writes sorted V1 TOML lockfile tables to `writer`. Uses `scratch` for sort temporaries. pub fn serializeToWriter(scratch: std.mem.Allocator, writer: *std.Io.Writer, bindings: []const Binding) !void { - var lines: std.ArrayList([]const u8) = .empty; + try writer.writeAll("version = 1\n"); + if (bindings.len != 0) try writer.writeByte('\n'); + + var blocks: std.ArrayList([]const u8) = .empty; defer { - for (lines.items) |line| scratch.free(line); - lines.deinit(scratch); + for (blocks.items) |block| scratch.free(block); + blocks.deinit(scratch); } for (bindings) |binding| { - var row: std.Io.Writer.Allocating = .init(scratch); - errdefer row.deinit(); - try renderLineToWriter(scratch, &row.writer, binding); - try lines.append(scratch, try row.toOwnedSlice()); + var block: std.Io.Writer.Allocating = .init(scratch); + errdefer block.deinit(); + try renderTomlBindingToWriter(scratch, &block.writer, binding); + try blocks.append(scratch, try block.toOwnedSlice()); } - std.mem.sort([]const u8, lines.items, {}, struct { + std.mem.sort([]const u8, blocks.items, {}, struct { fn lessThan(_: void, a: []const u8, b: []const u8) bool { return std.mem.order(u8, a, b) == .lt; } }.lessThan); - for (lines.items) |line| { - try writer.writeAll(line); - try writer.writeByte('\n'); + for (blocks.items, 0..) |block, i| { + if (i != 0) try writer.writeByte('\n'); + try writer.writeAll(block); } } @@ -249,6 +292,148 @@ pub fn writeFile(io: std.Io, lockfile: *const Lockfile, scratch: std.mem.Allocat try serializeToWriter(scratch, &fw.interface, lockfile.bindings.items); } +fn containsTomlSyntax(content: []const u8) bool { + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (std.mem.eql(u8, trimmed, "[[bindings]]") or isTopLevelVersionLine(trimmed)) return true; + } + return false; +} + +fn isTopLevelVersionLine(line: []const u8) bool { + const equals = std.mem.findScalar(u8, line, '=') orelse return false; + const key = std.mem.trim(u8, line[0..equals], " \t"); + return std.mem.eql(u8, key, "version"); +} + +const PendingBinding = struct { + doc_path: ?[]const u8 = null, + target: ?[]const u8 = null, + metadata: std.ArrayList(MetadataField) = .empty, + + fn deinit(self: *PendingBinding, allocator: std.mem.Allocator) void { + if (self.doc_path) |doc_path| allocator.free(doc_path); + if (self.target) |target| allocator.free(target); + for (self.metadata.items) |field| { + allocator.free(field.key); + allocator.free(field.value); + } + self.metadata.deinit(allocator); + self.* = .{}; + } + + fn finish(self: *PendingBinding) !Binding { + const doc_path = self.doc_path orelse return error.InvalidBindingLine; + const target = self.target orelse return error.InvalidBindingLine; + const metadata = self.metadata; + self.* = .{}; + return .{ .doc_path = doc_path, .target = target, .metadata = metadata }; + } +}; + +fn parseTomlInto(allocator: std.mem.Allocator, content: []const u8, bindings: *std.ArrayList(Binding)) !void { + var pending: ?PendingBinding = null; + errdefer if (pending) |*p| p.deinit(allocator); + var version_seen = false; + + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len == 0 or trimmed[0] == '#') continue; + + if (std.mem.eql(u8, trimmed, "[[bindings]]")) { + if (!version_seen) return error.InvalidBindingLine; + if (pending) |*p| try bindings.append(allocator, try p.finish()); + pending = .{}; + continue; + } + + if (pending == null) { + if (isTopLevelVersionLine(trimmed)) { + if (version_seen) return error.InvalidBindingLine; + try parseLockfileVersion(trimmed); + version_seen = true; + continue; + } + return error.InvalidBindingLine; + } + try parseTomlFieldInto(allocator, trimmed, &pending.?); + } + + if (!version_seen) return error.InvalidBindingLine; + if (pending) |*p| try bindings.append(allocator, try p.finish()); +} + +fn parseLockfileVersion(line: []const u8) !void { + const equals = std.mem.findScalar(u8, line, '=') orelse return error.InvalidBindingLine; + const raw_value = std.mem.trim(u8, line[equals + 1 ..], " \t"); + const version = std.fmt.parseUnsigned(u32, raw_value, 10) catch return error.InvalidBindingLine; + if (version != 1) return error.UnsupportedLockfileVersion; +} + +fn parseTomlFieldInto(allocator: std.mem.Allocator, line: []const u8, pending: *PendingBinding) !void { + const equals = std.mem.findScalar(u8, line, '=') orelse return error.InvalidMetadataField; + const key = std.mem.trim(u8, line[0..equals], " \t"); + const raw_value = std.mem.trim(u8, line[equals + 1 ..], " \t"); + if (!isValidTomlBareKey(key) or std.mem.eql(u8, key, "version")) return error.InvalidMetadataField; + + const value = try parseTomlString(allocator, raw_value); + errdefer allocator.free(value); + + if (std.mem.eql(u8, key, "doc")) { + if (pending.doc_path != null) return error.InvalidBindingLine; + pending.doc_path = value; + } else if (std.mem.eql(u8, key, "target")) { + if (pending.target != null) return error.InvalidBindingLine; + pending.target = value; + } else { + for (pending.metadata.items) |field| { + if (std.mem.eql(u8, field.key, key)) return error.InvalidMetadataField; + } + const owned_key = try allocator.dupe(u8, key); + errdefer allocator.free(owned_key); + try pending.metadata.append(allocator, .{ + .key = owned_key, + .value = value, + }); + } +} + +fn parseTomlString(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { + if (raw.len < 2 or raw[0] != '"' or raw[raw.len - 1] != '"') return error.InvalidMetadataField; + + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(allocator); + + var i: usize = 1; + while (i < raw.len - 1) : (i += 1) { + const c = raw[i]; + if (c == '\\') { + i += 1; + if (i >= raw.len - 1) return error.InvalidMetadataField; + switch (raw[i]) { + 'b' => try out.append(allocator, '\x08'), + 't' => try out.append(allocator, '\t'), + 'n' => try out.append(allocator, '\n'), + 'f' => try out.append(allocator, '\x0c'), + 'r' => try out.append(allocator, '\r'), + '"' => try out.append(allocator, '"'), + '\\' => try out.append(allocator, '\\'), + else => return error.InvalidMetadataField, + } + } else { + if (c == '"' or c == '\n' or c == '\r' or c < 0x20 or c == 0x7f) return error.InvalidMetadataField; + try out.append(allocator, c); + } + } + + const value = try out.toOwnedSlice(allocator); + errdefer allocator.free(value); + if (!std.unicode.utf8ValidateSlice(value)) return error.InvalidMetadataField; + return value; +} + fn parseLine(allocator: std.mem.Allocator, line: []const u8) !Binding { const arrow = std.mem.find(u8, line, " -> ") orelse return error.InvalidBindingLine; const doc_path = std.mem.trim(u8, line[0..arrow], " \t"); @@ -334,7 +519,153 @@ test "parseInto reads bindings and metadata" { try std.testing.expectEqualStrings("github:fiberplane/drift", bindings.items[1].fieldValue("origin").?); } -test "serialize sorts lines and appends trailing newline" { +test "parseInto reads V1 TOML tables, comments, and unordered metadata" { + const allocator = std.testing.allocator; + const content = + "version = 1\n" ++ + "# 00\n" ++ + "[[bindings]]\n" ++ + "target = \"src/auth/provider.ts#AuthConfig\"\n" ++ + "origin = \"github:fiberplane/drift\"\n" ++ + "doc = \"docs/auth.md\"\n" ++ + "sig = \"1a2b3c4d5e6f7890\"\n" ++ + "\n" ++ + "# arbitrary V17 bucket/header-compatible comment\n" ++ + "[[bindings]]\n" ++ + "doc = \"docs/auth.md\"\n" ++ + "target = \"src/auth/login.ts\"\n" ++ + "sig = \"a1b2c3d4e5f6a7b8\"\n"; + + var bindings: std.ArrayList(Binding) = .empty; + defer { + for (bindings.items) |*binding| { + allocator.free(binding.doc_path); + allocator.free(binding.target); + for (binding.metadata.items) |field| { + allocator.free(field.key); + allocator.free(field.value); + } + binding.metadata.deinit(allocator); + } + bindings.deinit(allocator); + } + + try parseInto(allocator, content, &bindings); + try std.testing.expectEqual(@as(usize, 2), bindings.items.len); + try std.testing.expectEqualStrings("docs/auth.md", bindings.items[0].doc_path); + try std.testing.expectEqualStrings("src/auth/provider.ts#AuthConfig", bindings.items[0].target); + try std.testing.expectEqualStrings("github:fiberplane/drift", bindings.items[0].fieldValue("origin").?); + try std.testing.expectEqualStrings("a1b2c3d4e5f6a7b8", bindings.items[1].fieldValue("sig").?); +} + +test "parseInto accepts empty V1 TOML lockfile" { + const allocator = std.testing.allocator; + var bindings: std.ArrayList(Binding) = .empty; + defer bindings.deinit(allocator); + + try parseInto(allocator, "version = 1\n# comment only\n", &bindings); + try std.testing.expectEqual(@as(usize, 0), bindings.items.len); +} + +test "parseInto decodes TOML basic-string escapes" { + const allocator = std.testing.allocator; + const content = + "version = 1\n" ++ + "[[bindings]]\n" ++ + "doc = \"docs/quote\\\"slash\\\\tab\\t.md\"\n" ++ + "target = \"src/line\\ncarriage\\r.ts\"\n" ++ + "note = \"backspace\\bformfeed\\f\"\n"; + + var bindings: std.ArrayList(Binding) = .empty; + defer { + for (bindings.items) |*binding| { + allocator.free(binding.doc_path); + allocator.free(binding.target); + for (binding.metadata.items) |field| { + allocator.free(field.key); + allocator.free(field.value); + } + binding.metadata.deinit(allocator); + } + bindings.deinit(allocator); + } + + try parseInto(allocator, content, &bindings); + try std.testing.expectEqual(@as(usize, 1), bindings.items.len); + try std.testing.expectEqualStrings("docs/quote\"slash\\tab\t.md", bindings.items[0].doc_path); + try std.testing.expectEqualStrings("src/line\ncarriage\r.ts", bindings.items[0].target); + try std.testing.expectEqualStrings("backspace\x08formfeed\x0c", bindings.items[0].fieldValue("note").?); +} + +test "parseInto rejects TOML edge cases outside the lockfile subset" { + const allocator = std.testing.allocator; + + const cases = [_][]const u8{ + "[[bindings]]\ndoc = \"docs/a.md\"\ntarget = \"src/a.ts\"\n", // version is mandatory for TOML lockfiles + "version = 2\n", // unsupported version + "version = 1 # inline comment\n", // inline comments are outside the subset + "version = 1\nname = \"drift\"\n", // no unknown top-level keys + "version = 1\n[[bindings]]\ndoc = \"a\"\ndoc = \"b\"\ntarget = \"t\"\n", // duplicate doc + "version = 1\n[[bindings]]\ndoc = \"a\"\ntarget = \"t\"\nsig = \"a\"\nsig = \"b\"\n", // duplicate metadata + "version = 1\n[[bindings]]\ndoc = \"a\"\ntarget = \"t\"\nbad.key = \"x\"\n", // dotted keys + "version = 1\n[[bindings]]\ndoc = \"a\"\ntarget = \"t\"\nversion = \"1\"\n", // version is top-level only + "version = 1\n[[bindings]]\ndoc = \"a\"\ntarget = \"unterminated\n", // malformed string + "version = 1\n[[bindings]]\ndoc = \"a\"\ntarget = \"bad\\u1234\"\n", // unicode escapes not supported yet + }; + + for (cases) |content| { + var bindings: std.ArrayList(Binding) = .empty; + defer bindings.deinit(allocator); + + parseInto(allocator, content, &bindings) catch |err| switch (err) { + error.InvalidBindingLine, error.InvalidMetadataField, error.UnsupportedLockfileVersion => continue, + else => return err, + }; + return error.TestExpectedError; + } +} + +test "serialize escapes TOML basic strings" { + const allocator = std.testing.allocator; + + var metadata: std.ArrayList(MetadataField) = .empty; + defer { + for (metadata.items) |field| { + allocator.free(field.key); + allocator.free(field.value); + } + metadata.deinit(allocator); + } + try metadata.append(allocator, .{ + .key = try allocator.dupe(u8, "note"), + .value = try allocator.dupe(u8, "quote\" slash\\ tab\t line\n carriage\r"), + }); + + var bindings = [_]Binding{.{ + .doc_path = try allocator.dupe(u8, "docs/quote\".md"), + .target = try allocator.dupe(u8, "src/slash\\.ts"), + .metadata = metadata, + }}; + defer { + allocator.free(bindings[0].doc_path); + allocator.free(bindings[0].target); + } + + const content = try serialize(allocator, &bindings); + defer allocator.free(content); + + try std.testing.expectEqualStrings( + "version = 1\n" ++ + "\n" ++ + "[[bindings]]\n" ++ + "doc = \"docs/quote\\\".md\"\n" ++ + "target = \"src/slash\\\\.ts\"\n" ++ + "note = \"quote\\\" slash\\\\ tab\\t line\\n carriage\\r\"\n", + content, + ); +} + +test "serialize sorts tables and appends trailing newline" { const allocator = std.testing.allocator; var bindings: std.ArrayList(Binding) = .empty; @@ -366,7 +697,15 @@ test "serialize sorts lines and appends trailing newline" { defer allocator.free(content); try std.testing.expectEqualStrings( - "docs/a.md -> src/a.ts\ndocs/z.md -> src/z.ts\n", + "version = 1\n" ++ + "\n" ++ + "[[bindings]]\n" ++ + "doc = \"docs/a.md\"\n" ++ + "target = \"src/a.ts\"\n" ++ + "\n" ++ + "[[bindings]]\n" ++ + "doc = \"docs/z.md\"\n" ++ + "target = \"src/z.ts\"\n", content, ); } @@ -431,7 +770,14 @@ test "serialize emits metadata sorted by key regardless of insertion order" { try std.testing.expectEqualStrings(out_forward, out_reverse); try std.testing.expectEqualStrings( - "docs/x.md -> src/x.ts lang:ts origin:x sig:abc\n", + "version = 1\n" ++ + "\n" ++ + "[[bindings]]\n" ++ + "doc = \"docs/x.md\"\n" ++ + "target = \"src/x.ts\"\n" ++ + "lang = \"ts\"\n" ++ + "origin = \"x\"\n" ++ + "sig = \"abc\"\n", out_forward, ); } diff --git a/src/main.zig b/src/main.zig index 48a5956..838c6e4 100644 --- a/src/main.zig +++ b/src/main.zig @@ -343,6 +343,7 @@ fn exitWithError(stderr_w: *std.Io.Writer, err: anyerror) noreturn { const message: []const u8 = switch (err) { error.InvalidBindingLine => "malformed binding in drift.lock", error.InvalidMetadataField => "malformed metadata field in drift.lock", + error.UnsupportedLockfileVersion => "unsupported drift.lock version", error.OutOfMemory => "out of memory", else => @errorName(err), }; diff --git a/test/integration/link_test.zig b/test/integration/link_test.zig index f740e48..e3a7003 100644 --- a/test/integration/link_test.zig +++ b/test/integration/link_test.zig @@ -30,7 +30,9 @@ test "link adds new file binding to drift.lock" { const lock_content = try repo.readFile("drift.lock"); defer allocator.free(lock_content); - try helpers.expectContains(lock_content, "docs/doc.md -> src/new.ts sig:"); + try helpers.expectContains(lock_content, "doc = \"docs/doc.md\"\n"); + try helpers.expectContains(lock_content, "target = \"src/new.ts\"\n"); + try helpers.expectContains(lock_content, "sig = "); try helpers.expectNotContains(lock_content, "doc:"); const doc_content = try repo.readFile("docs/doc.md"); @@ -54,7 +56,9 @@ test "link adds symbol binding to drift.lock" { const lock_content = try repo.readFile("drift.lock"); defer allocator.free(lock_content); - try helpers.expectContains(lock_content, "docs/doc.md -> src/lib.ts#myFunction sig:"); + try helpers.expectContains(lock_content, "doc = \"docs/doc.md\"\n"); + try helpers.expectContains(lock_content, "target = \"src/lib.ts#myFunction\"\n"); + try helpers.expectContains(lock_content, "sig = "); } test "link stores markdown heading bindings using slug fragments" { @@ -74,7 +78,9 @@ test "link stores markdown heading bindings using slug fragments" { const lock_content = try repo.readFile("drift.lock"); defer allocator.free(lock_content); - try helpers.expectContains(lock_content, "docs/overview.md -> docs/auth.md#token-validation sig:"); + try helpers.expectContains(lock_content, "doc = \"docs/overview.md\"\n"); + try helpers.expectContains(lock_content, "target = \"docs/auth.md#token-validation\"\n"); + try helpers.expectContains(lock_content, "sig = "); } test "link rejects missing markdown heading target" { @@ -187,7 +193,9 @@ test "link blanket mode relinks with --doc-is-still-accurate override" { const after = try repo.readFile("drift.lock"); defer allocator.free(after); try std.testing.expect(!std.mem.eql(u8, before, after)); - try helpers.expectContains(after, "docs/doc.md -> src/main.ts sig:"); + try helpers.expectContains(after, "doc = \"docs/doc.md\"\n"); + try helpers.expectContains(after, "target = \"src/main.ts\"\n"); + try helpers.expectContains(after, "sig = "); } test "link no longer migrates legacy frontmatter anchors" { @@ -227,7 +235,9 @@ test "link uses nested drift.lock when doc is in nested scope" { // 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:"); + try helpers.expectContains(nested_lock, "doc = \"doc.md\"\n"); + try helpers.expectContains(nested_lock, "target = \"code.ts\"\n"); + try helpers.expectContains(nested_lock, "sig = "); const root_lock = try repo.readFile("drift.lock"); defer allocator.free(root_lock); diff --git a/test/integration/lint_test.zig b/test/integration/lint_test.zig index 03e0c8b..5080bcd 100644 --- a/test/integration/lint_test.zig +++ b/test/integration/lint_test.zig @@ -480,8 +480,12 @@ test "check --format json reports verification_state partial when one doc skips defer allocator.free(existing_lock); const combined_lock = try std.fmt.allocPrint( allocator, - "{s}docs/skip.md -> src/skip.ts sig:deadbeefdeadbeef origin:github:other/repo\n", - .{existing_lock}, + "{s}\n[[bindings]]\n" ++ + "doc = \"docs/skip.md\"\n" ++ + "target = \"src/skip.ts\"\n" ++ + "origin = \"github:other/repo\"\n" ++ + "sig = \"deadbeefdeadbeef\"\n", + .{std.mem.trim(u8, existing_lock, "\n")}, ); defer allocator.free(combined_lock); try repo.writeFile("drift.lock", combined_lock); @@ -633,7 +637,6 @@ test "lint reports broken relative markdown links" { try helpers.expectContains(result.stdout, "1 broken link"); } - test "lint checks broken links in discovered docs without drift bindings" { const allocator = std.testing.allocator; var repo = try helpers.TempRepo.init(allocator);