From 68b6f2913e7ed3f3788b4c84fb8c0adc2219120a Mon Sep 17 00:00:00 2001 From: Altay Date: Wed, 2 Sep 2026 13:50:01 +0300 Subject: [PATCH 1/7] fix(docs-links): name a present-but-untracked link target A doc linking to a file that exists on disk but is not yet tracked was reported as a broken link, which reads as if the file were missing. The rule stays (targets must be tracked); the message now says the target is untracked and that staging it resolves the link. Missing targets still report as broken. --- docs/convention.md | 4 +++- src/checks/docsLinks.ts | 18 +++++++++++++++++- test/docs-links.test.ts | 12 ++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/convention.md b/docs/convention.md index e7dc43a..3085d56 100644 --- a/docs/convention.md +++ b/docs/convention.md @@ -202,7 +202,9 @@ that cutover is done; set it true once the pin and lockfile match. optional titles, and Markdown escapes; code spans/fences and external or fragment-only destinations are ignored. Checked Markdown filenames must use portable `/` separators; literal backslashes are reported as non-portable. - Targets must be tracked, so a gitignored-but-present file does not pass. + Targets must be tracked, so a gitignored-but-present file does not pass; + a present-but-untracked target is reported as such so the fix (stage it) + is clear. - **Package manager (opt-in)**: convention workspaces use pnpm. `init` writes a Corepack `packageManager` pin, pnpm scripts, and `"packageManager": { "enforce": true }`. Existing configs without the diff --git a/src/checks/docsLinks.ts b/src/checks/docsLinks.ts index b742ca8..1163657 100644 --- a/src/checks/docsLinks.ts +++ b/src/checks/docsLinks.ts @@ -283,6 +283,14 @@ function pathPart(href: string): string { return href; } +function existsInWorkspace(target: string): boolean { + try { + return workspaceLstat(".", target, "link target") !== undefined; + } catch { + return false; + } +} + export function docsLinkErrors(config: DocsLinksConfig): string[] { const bad: string[] = []; const result = spawnSync("git", ["ls-files", "-z"], { encoding: "utf8" }); @@ -338,8 +346,16 @@ export function docsLinkErrors(config: DocsLinksConfig): string[] { } if (href.startsWith("/")) continue; const target = posix.normalize(posix.join(posix.dirname(file), href)).replace(/\/+$/, ""); - if (target === ".." || target.startsWith("../") || !isTracked(target)) { + if (target === ".." || target.startsWith("../")) { bad.push(`${file}: broken link (${raw})`); + } else if (!isTracked(target)) { + // The rule is deliberate: a present-but-untracked target would break + // for everyone else. Say so instead of calling it missing. + bad.push( + existsInWorkspace(target) + ? `${file}: untracked link target (${raw}); stage it so the link resolves for others` + : `${file}: broken link (${raw})`, + ); } } } diff --git a/test/docs-links.test.ts b/test/docs-links.test.ts index 557127a..7708924 100644 --- a/test/docs-links.test.ts +++ b/test/docs-links.test.ts @@ -96,6 +96,18 @@ test("docs links reports broken inline, image, reference, and malformed destinat ]); }); +test("docs links names a present-but-untracked target instead of calling it broken", () => { + const dir = repository(); + put(dir, "README.md", "[new script](scripts/new.ts)\n[gone](scripts/gone.ts)\n"); + track(dir); + put(dir, "scripts/new.ts", "export {};\n"); + + assert.deepEqual(check(dir), [ + "README.md: untracked link target (scripts/new.ts); stage it so the link resolves for others", + "README.md: broken link (scripts/gone.ts)", + ]); +}); + test("docs links skips tracked symlink leaves and reports missing tracked Markdown", () => { const dir = repository(); put(dir, "README.md", "# Readme\n"); From 23338510699f3a62397c61ba654fb28c1a733023 Mon Sep 17 00:00:00 2001 From: Altay Date: Wed, 2 Sep 2026 14:54:38 +0300 Subject: [PATCH 2/7] fix(docs-links): only call a target stageable when git would accept it Use git ls-files --others --exclude-standard as the source of stageable paths instead of an lstat existence check. A gitignored-but-present target and a case-mismatched spelling of a tracked file both stay broken links, so the remediation is only offered when git add would actually resolve it. --- src/checks/docsLinks.ts | 28 ++++++++++++++++++++-------- test/docs-links.test.ts | 19 +++++++++++++++++-- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/checks/docsLinks.ts b/src/checks/docsLinks.ts index 1163657..853e993 100644 --- a/src/checks/docsLinks.ts +++ b/src/checks/docsLinks.ts @@ -283,12 +283,15 @@ function pathPart(href: string): string { return href; } -function existsInWorkspace(target: string): boolean { - try { - return workspaceLstat(".", target, "link target") !== undefined; - } catch { - return false; - } +// Untracked paths git would accept from `git add`: present, not ignored, and +// spelled exactly as git sees them (a case-mismatched tracked file is not +// listed here on case-insensitive filesystems). +function stageablePaths(): Set { + const result = spawnSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { + encoding: "utf8", + }); + if (result.status !== 0) return new Set(); + return new Set(result.stdout.split("\0").filter(Boolean)); } export function docsLinkErrors(config: DocsLinksConfig): string[] { @@ -296,6 +299,15 @@ export function docsLinkErrors(config: DocsLinksConfig): string[] { const result = spawnSync("git", ["ls-files", "-z"], { encoding: "utf8" }); if (result.status !== 0) return ["could not list tracked files"]; const tracked = new Set(result.stdout.split("\0").filter(Boolean)); + const stageable = stageablePaths(); + const isStageable = (target: string): boolean => { + if (stageable.has(target)) return true; + const prefix = `${target}/`; + for (const file of stageable) { + if (file.startsWith(prefix)) return true; + } + return false; + }; const isTracked = (target: string): boolean => { if (target === ".") return tracked.size > 0; if (tracked.has(target)) return true; @@ -350,9 +362,9 @@ export function docsLinkErrors(config: DocsLinksConfig): string[] { bad.push(`${file}: broken link (${raw})`); } else if (!isTracked(target)) { // The rule is deliberate: a present-but-untracked target would break - // for everyone else. Say so instead of calling it missing. + // for everyone else. Say so only when staging would actually work. bad.push( - existsInWorkspace(target) + isStageable(target) ? `${file}: untracked link target (${raw}); stage it so the link resolves for others` : `${file}: broken link (${raw})`, ); diff --git a/test/docs-links.test.ts b/test/docs-links.test.ts index 7708924..7f80da5 100644 --- a/test/docs-links.test.ts +++ b/test/docs-links.test.ts @@ -96,14 +96,29 @@ test("docs links reports broken inline, image, reference, and malformed destinat ]); }); -test("docs links names a present-but-untracked target instead of calling it broken", () => { +test("docs links names a stageable untracked target instead of calling it broken", () => { const dir = repository(); - put(dir, "README.md", "[new script](scripts/new.ts)\n[gone](scripts/gone.ts)\n"); + put( + dir, + "README.md", + [ + "[new script](scripts/new.ts)", + "[new dir](generated)", + "[ignored](build/out.js)", + "[gone](scripts/gone.ts)", + "", + ].join("\n"), + ); + put(dir, ".gitignore", "build/\n"); track(dir); put(dir, "scripts/new.ts", "export {};\n"); + put(dir, "generated/index.md", "# generated\n"); + put(dir, "build/out.js", "// ignored\n"); assert.deepEqual(check(dir), [ "README.md: untracked link target (scripts/new.ts); stage it so the link resolves for others", + "README.md: untracked link target (generated); stage it so the link resolves for others", + "README.md: broken link (build/out.js)", "README.md: broken link (scripts/gone.ts)", ]); }); From fa8cb0998bbb4f1d5c2f2024bd3315db7c8fe133 Mon Sep 17 00:00:00 2001 From: Altay Date: Wed, 2 Sep 2026 15:03:49 +0300 Subject: [PATCH 3/7] fix(docs-links): scope the stageable check to one target Ask git about the single link target instead of listing every untracked path, so a large untracked tree cannot overflow the spawn buffer and turn into an empty set. Reject case-folded collisions with tracked paths and embedded repositories (listed as dir/), since git add would not produce a tree that checks out everywhere. --- src/checks/docsLinks.ts | 44 +++++++++++++++++++++++------------------ test/docs-links.test.ts | 18 +++++++++++++++++ 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/checks/docsLinks.ts b/src/checks/docsLinks.ts index 853e993..7ea5a38 100644 --- a/src/checks/docsLinks.ts +++ b/src/checks/docsLinks.ts @@ -283,15 +283,29 @@ function pathPart(href: string): string { return href; } -// Untracked paths git would accept from `git add`: present, not ignored, and -// spelled exactly as git sees them (a case-mismatched tracked file is not -// listed here on case-insensitive filesystems). -function stageablePaths(): Set { - const result = spawnSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { - encoding: "utf8", - }); - if (result.status !== 0) return new Set(); - return new Set(result.stdout.split("\0").filter(Boolean)); +// Whether `git add ` would make the link resolve: the path is +// untracked and not ignored, is not an embedded repository (git lists those +// as `dir/` and refuses to add them), and does not collide by case with a +// tracked path (git would stage it, but the tree could not be checked out on +// a case-insensitive filesystem). Scoped to one pathspec so the listing stays +// small regardless of how much untracked content the worktree holds. +function isStageable(target: string, trackedFolded: Set): boolean { + const folded = target.toLowerCase(); + if (trackedFolded.has(folded)) return false; + const prefix = `${folded}/`; + for (const file of trackedFolded) { + if (file.startsWith(prefix)) return false; + } + const result = spawnSync( + "git", + ["ls-files", "--others", "--exclude-standard", "-z", "--", target], + { encoding: "utf8" }, + ); + if (result.status !== 0) return false; + return result.stdout + .split("\0") + .filter(Boolean) + .some((entry) => !entry.endsWith("/")); } export function docsLinkErrors(config: DocsLinksConfig): string[] { @@ -299,15 +313,7 @@ export function docsLinkErrors(config: DocsLinksConfig): string[] { const result = spawnSync("git", ["ls-files", "-z"], { encoding: "utf8" }); if (result.status !== 0) return ["could not list tracked files"]; const tracked = new Set(result.stdout.split("\0").filter(Boolean)); - const stageable = stageablePaths(); - const isStageable = (target: string): boolean => { - if (stageable.has(target)) return true; - const prefix = `${target}/`; - for (const file of stageable) { - if (file.startsWith(prefix)) return true; - } - return false; - }; + const trackedFolded = new Set([...tracked].map((file) => file.toLowerCase())); const isTracked = (target: string): boolean => { if (target === ".") return tracked.size > 0; if (tracked.has(target)) return true; @@ -364,7 +370,7 @@ export function docsLinkErrors(config: DocsLinksConfig): string[] { // The rule is deliberate: a present-but-untracked target would break // for everyone else. Say so only when staging would actually work. bad.push( - isStageable(target) + isStageable(target, trackedFolded) ? `${file}: untracked link target (${raw}); stage it so the link resolves for others` : `${file}: broken link (${raw})`, ); diff --git a/test/docs-links.test.ts b/test/docs-links.test.ts index 7f80da5..8a955ac 100644 --- a/test/docs-links.test.ts +++ b/test/docs-links.test.ts @@ -123,6 +123,24 @@ test("docs links names a stageable untracked target instead of calling it broken ]); }); +test("docs links keeps case collisions and embedded repositories on the broken-link message", () => { + const dir = repository(); + put(dir, "README.md", "[case](guide.md)\n[embedded](nested)\n"); + put(dir, "Guide.md", "# guide\n"); + track(dir); + // On a case-insensitive filesystem this overwrites Guide.md; on a + // case-sensitive one it creates a distinct untracked file. Both must stay + // broken: staging cannot produce a tree that checks out everywhere. + put(dir, "guide.md", "# guide\n"); + mkdirSync(join(dir, "nested"), { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: join(dir, "nested") }); + + assert.deepEqual(check(dir), [ + "README.md: broken link (guide.md)", + "README.md: broken link (nested)", + ]); +}); + test("docs links skips tracked symlink leaves and reports missing tracked Markdown", () => { const dir = repository(); put(dir, "README.md", "# Readme\n"); From 176c9be6ce8ef5d60536c2182c8b72550fa3b57d Mon Sep 17 00:00:00 2001 From: Altay Date: Wed, 2 Sep 2026 15:13:40 +0300 Subject: [PATCH 4/7] fix(docs-links): treat the link target as a literal, bounded pathspec Pass the target under --literal-pathspecs so metacharacters in a link are not expanded as a git pattern. List with --directory --no-empty-directory so a large untracked directory collapses to one entry instead of every descendant, and reject it only when it is an embedded repository. Compare against tracked paths with portablePathIdentity so Unicode case aliases collide the same way the registry check treats them. --- src/checks/docsLinks.ts | 58 +++++++++++++++++++++++++++-------------- test/docs-links.test.ts | 13 +++++++++ 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/checks/docsLinks.ts b/src/checks/docsLinks.ts index 7ea5a38..55f84e6 100644 --- a/src/checks/docsLinks.ts +++ b/src/checks/docsLinks.ts @@ -4,7 +4,7 @@ // backtick/tilde fenced blocks. import { spawnSync } from "node:child_process"; import { posix } from "node:path"; -import type { DocsLinksConfig } from "../config.ts"; +import { portablePathIdentity, type DocsLinksConfig } from "../config.ts"; import { readWorkspaceText, workspaceLstat } from "../lib/workspaceFs.ts"; type Destination = { offset: number; raw: string }; @@ -283,29 +283,47 @@ function pathPart(href: string): string { return href; } -// Whether `git add ` would make the link resolve: the path is -// untracked and not ignored, is not an embedded repository (git lists those -// as `dir/` and refuses to add them), and does not collide by case with a -// tracked path (git would stage it, but the tree could not be checked out on -// a case-insensitive filesystem). Scoped to one pathspec so the listing stays -// small regardless of how much untracked content the worktree holds. -function isStageable(target: string, trackedFolded: Set): boolean { - const folded = target.toLowerCase(); - if (trackedFolded.has(folded)) return false; - const prefix = `${folded}/`; - for (const file of trackedFolded) { - if (file.startsWith(prefix)) return false; +// Whether `git add ` would make the link resolve for everyone: the +// path is untracked and not ignored, is not an embedded repository (git will +// not add one), and does not collide with a tracked path under the portable +// identity (git would stage it, but the tree could not be checked out on a +// case-insensitive filesystem). The target is a literal pathspec, and +// directories collapse to a single `dir/` entry so output stays bounded. +function isStageable(target: string, trackedIdentities: Set): boolean { + const identity = portablePathIdentity(target); + if (trackedIdentities.has(identity)) return false; + const prefix = `${identity}/`; + for (const tracked of trackedIdentities) { + if (tracked.startsWith(prefix)) return false; } const result = spawnSync( "git", - ["ls-files", "--others", "--exclude-standard", "-z", "--", target], + [ + "--literal-pathspecs", + "ls-files", + "--others", + "--exclude-standard", + "--directory", + "--no-empty-directory", + "-z", + "--", + target, + ], { encoding: "utf8" }, ); if (result.status !== 0) return false; - return result.stdout - .split("\0") - .filter(Boolean) - .some((entry) => !entry.endsWith("/")); + const entries = result.stdout.split("\0").filter(Boolean); + if (entries.length === 0) return false; + // A directory entry is stageable unless it is an embedded repository. + return entries.every((entry) => !entry.endsWith("/") || !isEmbeddedRepository(entry)); +} + +function isEmbeddedRepository(directory: string): boolean { + try { + return workspaceLstat(".", `${directory}.git`, "embedded repository marker") !== undefined; + } catch { + return false; + } } export function docsLinkErrors(config: DocsLinksConfig): string[] { @@ -313,7 +331,7 @@ export function docsLinkErrors(config: DocsLinksConfig): string[] { const result = spawnSync("git", ["ls-files", "-z"], { encoding: "utf8" }); if (result.status !== 0) return ["could not list tracked files"]; const tracked = new Set(result.stdout.split("\0").filter(Boolean)); - const trackedFolded = new Set([...tracked].map((file) => file.toLowerCase())); + const trackedIdentities = new Set([...tracked].map(portablePathIdentity)); const isTracked = (target: string): boolean => { if (target === ".") return tracked.size > 0; if (tracked.has(target)) return true; @@ -370,7 +388,7 @@ export function docsLinkErrors(config: DocsLinksConfig): string[] { // The rule is deliberate: a present-but-untracked target would break // for everyone else. Say so only when staging would actually work. bad.push( - isStageable(target, trackedFolded) + isStageable(target, trackedIdentities) ? `${file}: untracked link target (${raw}); stage it so the link resolves for others` : `${file}: broken link (${raw})`, ); diff --git a/test/docs-links.test.ts b/test/docs-links.test.ts index 8a955ac..81c1369 100644 --- a/test/docs-links.test.ts +++ b/test/docs-links.test.ts @@ -141,6 +141,19 @@ test("docs links keeps case collisions and embedded repositories on the broken-l ]); }); +test("docs links treats link targets as literal paths, not git patterns", () => { + const dir = repository(); + put(dir, "README.md", "[glob](missing%2A.md)\n[dir](assets)\n"); + track(dir); + put(dir, "missing1.md", "# not the link target\n"); + put(dir, "assets/a.png", "png"); + + assert.deepEqual(check(dir), [ + "README.md: broken link (missing%2A.md)", + "README.md: untracked link target (assets); stage it so the link resolves for others", + ]); +}); + test("docs links skips tracked symlink leaves and reports missing tracked Markdown", () => { const dir = repository(); put(dir, "README.md", "# Readme\n"); From ab8dccf593db43c1f719b6e99ba39711c69391df Mon Sep 17 00:00:00 2001 From: Altay Date: Wed, 2 Sep 2026 15:24:26 +0300 Subject: [PATCH 5/7] fix(docs-links): let git decide addability Replace the untracked-listing prediction with git add --dry-run under --literal-pathspecs, which refuses ignored paths, missing paths, and embedded repositories at any depth without touching the index. Guard the two cases git accepts but a portable tree cannot hold: components that alias the .git directory, and portable-identity collisions with tracked paths in either direction. Reject NUL bytes before spawning. --- src/checks/docsLinks.ts | 48 +++++++++++++++-------------------------- test/docs-links.test.ts | 34 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/checks/docsLinks.ts b/src/checks/docsLinks.ts index 55f84e6..3bd34d3 100644 --- a/src/checks/docsLinks.ts +++ b/src/checks/docsLinks.ts @@ -283,44 +283,30 @@ function pathPart(href: string): string { return href; } -// Whether `git add ` would make the link resolve for everyone: the -// path is untracked and not ignored, is not an embedded repository (git will -// not add one), and does not collide with a tracked path under the portable -// identity (git would stage it, but the tree could not be checked out on a -// case-insensitive filesystem). The target is a literal pathspec, and -// directories collapse to a single `dir/` entry so output stays bounded. +// Whether `git add ` would make the link resolve for everyone. Git +// itself is the authority on addability (`add --dry-run` refuses ignored +// paths, missing paths, and embedded repositories at any depth without +// touching the index). Two cases git accepts but a portable tree cannot +// hold are rejected first: components aliasing the `.git` directory, and +// collisions with tracked paths under the portable identity in either +// direction (a tracked file `Guide` blocks `guide/sub/x.md` and vice versa). function isStageable(target: string, trackedIdentities: Set): boolean { + if (target.includes("\0")) return false; const identity = portablePathIdentity(target); - if (trackedIdentities.has(identity)) return false; + const components = identity.split("/"); + if (components.some((component) => component === ".GIT")) return false; + for (let depth = 1; depth <= components.length; depth += 1) { + if (trackedIdentities.has(components.slice(0, depth).join("/"))) return false; + } const prefix = `${identity}/`; for (const tracked of trackedIdentities) { if (tracked.startsWith(prefix)) return false; } - const result = spawnSync( - "git", - [ - "--literal-pathspecs", - "ls-files", - "--others", - "--exclude-standard", - "--directory", - "--no-empty-directory", - "-z", - "--", - target, - ], - { encoding: "utf8" }, - ); - if (result.status !== 0) return false; - const entries = result.stdout.split("\0").filter(Boolean); - if (entries.length === 0) return false; - // A directory entry is stageable unless it is an embedded repository. - return entries.every((entry) => !entry.endsWith("/") || !isEmbeddedRepository(entry)); -} - -function isEmbeddedRepository(directory: string): boolean { try { - return workspaceLstat(".", `${directory}.git`, "embedded repository marker") !== undefined; + const result = spawnSync("git", ["--literal-pathspecs", "add", "--dry-run", "--", target], { + encoding: "utf8", + }); + return result.status === 0; } catch { return false; } diff --git a/test/docs-links.test.ts b/test/docs-links.test.ts index 81c1369..46ee1cb 100644 --- a/test/docs-links.test.ts +++ b/test/docs-links.test.ts @@ -141,6 +141,40 @@ test("docs links keeps case collisions and embedded repositories on the broken-l ]); }); +test("docs links rejects targets git would refuse or a portable tree cannot hold", () => { + const dir = repository(); + put( + dir, + "README.md", + ["[nul](file%00.md)", "[gitdir](docs/.Git/x.md)", "[deep embedded](generated)", ""].join("\n"), + ); + track(dir); + put(dir, "docs/.Git/x.md", "# alias of the git directory\n"); + put(dir, "generated/a.md", "# fine on its own\n"); + mkdirSync(join(dir, "generated", "nested"), { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: join(dir, "generated", "nested") }); + + assert.deepEqual(check(dir), [ + "README.md: broken link (file%00.md)", + "README.md: broken link (docs/.Git/x.md)", + "README.md: broken link (generated)", + ]); +}); + +test("docs links rejects an untracked target below a tracked path that aliases it", () => { + const dir = repository(); + put(dir, "README.md", "[ancestor](guide/sub/file.md)\n"); + put(dir, "Guide", "# tracked file\n"); + track(dir); + // Keep `Guide` in the index but clear the working copy so the colliding + // directory can exist on a case-insensitive filesystem too; on Linux both + // would coexist and git would stage the file. + rmSync(join(dir, "Guide")); + put(dir, "guide/sub/file.md", "# collides with tracked Guide\n"); + + assert.deepEqual(check(dir), ["README.md: broken link (guide/sub/file.md)"]); +}); + test("docs links treats link targets as literal paths, not git patterns", () => { const dir = repository(); put(dir, "README.md", "[glob](missing%2A.md)\n[dir](assets)\n"); From be96c716b397ee1a71b852841aa7ce1c87819dce Mon Sep 17 00:00:00 2001 From: Altay Date: Wed, 2 Sep 2026 15:37:17 +0300 Subject: [PATCH 6/7] fix(docs-links): decide untracked without git's check-in pipeline git add --dry-run runs configured clean filters, so validation could execute commands. Go back to a literal, filter-free ls-files --others listing collapsed to one entry per directory, and stop promising that staging will succeed: the message now says the target is untracked and resolves only once tracked. Empty and ignored-only directories, .git aliases, embedded repositories, and portable-identity collisions with tracked paths stay broken links. --- docs/convention.md | 4 +-- src/checks/docsLinks.ts | 59 ++++++++++++++++++++++++++++++----------- test/docs-links.test.ts | 40 ++++++++++++++++++---------- 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/docs/convention.md b/docs/convention.md index 3085d56..7b08154 100644 --- a/docs/convention.md +++ b/docs/convention.md @@ -203,8 +203,8 @@ that cutover is done; set it true once the pin and lockfile match. fragment-only destinations are ignored. Checked Markdown filenames must use portable `/` separators; literal backslashes are reported as non-portable. Targets must be tracked, so a gitignored-but-present file does not pass; - a present-but-untracked target is reported as such so the fix (stage it) - is clear. + a present-but-untracked target is reported as untracked rather than + broken. The check never runs Git's check-in pipeline to decide that. - **Package manager (opt-in)**: convention workspaces use pnpm. `init` writes a Corepack `packageManager` pin, pnpm scripts, and `"packageManager": { "enforce": true }`. Existing configs without the diff --git a/src/checks/docsLinks.ts b/src/checks/docsLinks.ts index 3bd34d3..ccd421a 100644 --- a/src/checks/docsLinks.ts +++ b/src/checks/docsLinks.ts @@ -283,14 +283,16 @@ function pathPart(href: string): string { return href; } -// Whether `git add ` would make the link resolve for everyone. Git -// itself is the authority on addability (`add --dry-run` refuses ignored -// paths, missing paths, and embedded repositories at any depth without -// touching the index). Two cases git accepts but a portable tree cannot -// hold are rejected first: components aliasing the `.git` directory, and -// collisions with tracked paths under the portable identity in either -// direction (a tracked file `Guide` blocks `guide/sub/x.md` and vice versa). -function isStageable(target: string, trackedIdentities: Set): boolean { +// Whether the target is an untracked, non-ignored path git knows about, so +// the diagnostic can say "untracked" instead of "broken". This deliberately +// does not claim `git add` will succeed: deciding that would mean running +// git's check-in pipeline (clean filters can execute commands), listing +// every descendant of a directory, and reasoning about index validity. The +// listing is filter-free, literal, and collapses directories to one entry so +// output stays bounded. Portable-identity collisions with tracked paths and +// `.git` aliases are still reported as broken, since no commit could make +// those links resolve on a case-insensitive filesystem. +function isUntrackedTarget(target: string, trackedIdentities: Set): boolean { if (target.includes("\0")) return false; const identity = portablePathIdentity(target); const components = identity.split("/"); @@ -303,10 +305,37 @@ function isStageable(target: string, trackedIdentities: Set): boolean { if (tracked.startsWith(prefix)) return false; } try { - const result = spawnSync("git", ["--literal-pathspecs", "add", "--dry-run", "--", target], { - encoding: "utf8", - }); - return result.status === 0; + const result = spawnSync( + "git", + [ + "--literal-pathspecs", + "ls-files", + "--others", + "--exclude-standard", + "--directory", + "--no-empty-directory", + "-z", + "--", + target, + ], + { encoding: "utf8" }, + ); + if (result.status !== 0) return false; + const entries = result.stdout.split("\0").filter(Boolean); + // A directory entry is an embedded repository when it carries its own + // .git; git lists it as untracked but will never add it. + return ( + entries.length > 0 && + entries.every((entry) => !entry.endsWith("/") || !isEmbeddedRepository(entry)) + ); + } catch { + return false; + } +} + +function isEmbeddedRepository(directory: string): boolean { + try { + return workspaceLstat(".", `${directory}.git`, "embedded repository marker") !== undefined; } catch { return false; } @@ -372,10 +401,10 @@ export function docsLinkErrors(config: DocsLinksConfig): string[] { bad.push(`${file}: broken link (${raw})`); } else if (!isTracked(target)) { // The rule is deliberate: a present-but-untracked target would break - // for everyone else. Say so only when staging would actually work. + // for everyone else. Name the cause; do not promise a remedy. bad.push( - isStageable(target, trackedIdentities) - ? `${file}: untracked link target (${raw}); stage it so the link resolves for others` + isUntrackedTarget(target, trackedIdentities) + ? `${file}: untracked link target (${raw}); the link resolves only once it is tracked` : `${file}: broken link (${raw})`, ); } diff --git a/test/docs-links.test.ts b/test/docs-links.test.ts index 46ee1cb..53c7ccd 100644 --- a/test/docs-links.test.ts +++ b/test/docs-links.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test } from "vite-plus/test"; @@ -116,8 +116,8 @@ test("docs links names a stageable untracked target instead of calling it broken put(dir, "build/out.js", "// ignored\n"); assert.deepEqual(check(dir), [ - "README.md: untracked link target (scripts/new.ts); stage it so the link resolves for others", - "README.md: untracked link target (generated); stage it so the link resolves for others", + "README.md: untracked link target (scripts/new.ts); the link resolves only once it is tracked", + "README.md: untracked link target (generated); the link resolves only once it is tracked", "README.md: broken link (build/out.js)", "README.md: broken link (scripts/gone.ts)", ]); @@ -141,24 +141,36 @@ test("docs links keeps case collisions and embedded repositories on the broken-l ]); }); -test("docs links rejects targets git would refuse or a portable tree cannot hold", () => { +test("docs links keeps targets no commit could make portable on the broken-link message", () => { const dir = repository(); - put( - dir, - "README.md", - ["[nul](file%00.md)", "[gitdir](docs/.Git/x.md)", "[deep embedded](generated)", ""].join("\n"), - ); + put(dir, "README.md", "[nul](file%00.md)\n[gitdir](docs/.Git/x.md)\n[empty](emptydir)\n"); track(dir); put(dir, "docs/.Git/x.md", "# alias of the git directory\n"); - put(dir, "generated/a.md", "# fine on its own\n"); - mkdirSync(join(dir, "generated", "nested"), { recursive: true }); - execFileSync("git", ["init", "-q"], { cwd: join(dir, "generated", "nested") }); + mkdirSync(join(dir, "emptydir"), { recursive: true }); assert.deepEqual(check(dir), [ "README.md: broken link (file%00.md)", "README.md: broken link (docs/.Git/x.md)", - "README.md: broken link (generated)", + "README.md: broken link (emptydir)", + ]); +}); + +test("docs links does not run git's check-in pipeline while validating", () => { + const dir = repository(); + put(dir, "README.md", "[new](notes/new.md)\n"); + put(dir, ".gitattributes", "*.md filter=marker\n"); + track(dir); + execFileSync( + "git", + ["config", "filter.marker.clean", `sh -c 'touch "${join(dir, "FILTER_RAN")}"; cat'`], + { cwd: dir }, + ); + put(dir, "notes/new.md", "# untracked\n"); + + assert.deepEqual(check(dir), [ + "README.md: untracked link target (notes/new.md); the link resolves only once it is tracked", ]); + assert.equal(existsSync(join(dir, "FILTER_RAN")), false); }); test("docs links rejects an untracked target below a tracked path that aliases it", () => { @@ -184,7 +196,7 @@ test("docs links treats link targets as literal paths, not git patterns", () => assert.deepEqual(check(dir), [ "README.md: broken link (missing%2A.md)", - "README.md: untracked link target (assets); stage it so the link resolves for others", + "README.md: untracked link target (assets); the link resolves only once it is tracked", ]); }); From c54fe2a2c4aa78847285230a510f9cc2f50d1e2f Mon Sep 17 00:00:00 2001 From: Altay Date: Wed, 2 Sep 2026 15:48:12 +0300 Subject: [PATCH 7/7] fix(docs-links): classify untracked targets from git's listing only The untracked diagnostic is a classification, not a promise that git add will succeed, so stop re-deriving git's path rules in the check: a .git marker inside an untracked directory is that directory's business, and .git aliases (case, HFS-ignorable characters) are enforced by git at add time. Cache the classification per distinct target so a file with many links spawns git once per target rather than once per occurrence. --- src/checks/docsLinks.ts | 49 ++++++++++++++++++----------------------- test/docs-links.test.ts | 11 +++++---- 2 files changed, 27 insertions(+), 33 deletions(-) diff --git a/src/checks/docsLinks.ts b/src/checks/docsLinks.ts index ccd421a..b7e8ba4 100644 --- a/src/checks/docsLinks.ts +++ b/src/checks/docsLinks.ts @@ -283,20 +283,20 @@ function pathPart(href: string): string { return href; } -// Whether the target is an untracked, non-ignored path git knows about, so -// the diagnostic can say "untracked" instead of "broken". This deliberately -// does not claim `git add` will succeed: deciding that would mean running -// git's check-in pipeline (clean filters can execute commands), listing -// every descendant of a directory, and reasoning about index validity. The -// listing is filter-free, literal, and collapses directories to one entry so -// output stays bounded. Portable-identity collisions with tracked paths and -// `.git` aliases are still reported as broken, since no commit could make -// those links resolve on a case-insensitive filesystem. +// Whether git itself lists the target as an untracked, non-ignored path, so +// the diagnostic can say "untracked" instead of "broken". This is a +// classification, not a promise that `git add` will succeed: deciding that +// would mean entering git's check-in pipeline (clean filters execute +// commands) and re-deriving its path validity rules. The listing is +// filter-free, literal, and collapses directories to one entry so output is +// bounded. Portable-identity collisions with tracked paths stay broken: the +// link is spelled differently from the file git tracks, and no commit of the +// untracked spelling could check out next to it on a case-insensitive +// filesystem. function isUntrackedTarget(target: string, trackedIdentities: Set): boolean { if (target.includes("\0")) return false; const identity = portablePathIdentity(target); const components = identity.split("/"); - if (components.some((component) => component === ".GIT")) return false; for (let depth = 1; depth <= components.length; depth += 1) { if (trackedIdentities.has(components.slice(0, depth).join("/"))) return false; } @@ -320,22 +320,7 @@ function isUntrackedTarget(target: string, trackedIdentities: Set): bool ], { encoding: "utf8" }, ); - if (result.status !== 0) return false; - const entries = result.stdout.split("\0").filter(Boolean); - // A directory entry is an embedded repository when it carries its own - // .git; git lists it as untracked but will never add it. - return ( - entries.length > 0 && - entries.every((entry) => !entry.endsWith("/") || !isEmbeddedRepository(entry)) - ); - } catch { - return false; - } -} - -function isEmbeddedRepository(directory: string): boolean { - try { - return workspaceLstat(".", `${directory}.git`, "embedded repository marker") !== undefined; + return result.status === 0 && result.stdout.split("\0").some(Boolean); } catch { return false; } @@ -347,6 +332,16 @@ export function docsLinkErrors(config: DocsLinksConfig): string[] { if (result.status !== 0) return ["could not list tracked files"]; const tracked = new Set(result.stdout.split("\0").filter(Boolean)); const trackedIdentities = new Set([...tracked].map(portablePathIdentity)); + // One git process per distinct target, not per link occurrence. + const untrackedByTarget = new Map(); + const isUntracked = (target: string): boolean => { + let known = untrackedByTarget.get(target); + if (known === undefined) { + known = isUntrackedTarget(target, trackedIdentities); + untrackedByTarget.set(target, known); + } + return known; + }; const isTracked = (target: string): boolean => { if (target === ".") return tracked.size > 0; if (tracked.has(target)) return true; @@ -403,7 +398,7 @@ export function docsLinkErrors(config: DocsLinksConfig): string[] { // The rule is deliberate: a present-but-untracked target would break // for everyone else. Name the cause; do not promise a remedy. bad.push( - isUntrackedTarget(target, trackedIdentities) + isUntracked(target) ? `${file}: untracked link target (${raw}); the link resolves only once it is tracked` : `${file}: broken link (${raw})`, ); diff --git a/test/docs-links.test.ts b/test/docs-links.test.ts index 53c7ccd..4e6f22b 100644 --- a/test/docs-links.test.ts +++ b/test/docs-links.test.ts @@ -123,7 +123,7 @@ test("docs links names a stageable untracked target instead of calling it broken ]); }); -test("docs links keeps case collisions and embedded repositories on the broken-link message", () => { +test("docs links keeps case collisions broken and reports embedded repositories as untracked", () => { const dir = repository(); put(dir, "README.md", "[case](guide.md)\n[embedded](nested)\n"); put(dir, "Guide.md", "# guide\n"); @@ -137,20 +137,19 @@ test("docs links keeps case collisions and embedded repositories on the broken-l assert.deepEqual(check(dir), [ "README.md: broken link (guide.md)", - "README.md: broken link (nested)", + "README.md: untracked link target (nested); the link resolves only once it is tracked", ]); }); -test("docs links keeps targets no commit could make portable on the broken-link message", () => { +test("docs links keeps NUL and empty-directory targets on the broken-link message", () => { const dir = repository(); - put(dir, "README.md", "[nul](file%00.md)\n[gitdir](docs/.Git/x.md)\n[empty](emptydir)\n"); + put(dir, "README.md", "[nul](file%00.md)\n[empty](emptydir)\n[twice](emptydir)\n"); track(dir); - put(dir, "docs/.Git/x.md", "# alias of the git directory\n"); mkdirSync(join(dir, "emptydir"), { recursive: true }); assert.deepEqual(check(dir), [ "README.md: broken link (file%00.md)", - "README.md: broken link (docs/.Git/x.md)", + "README.md: broken link (emptydir)", "README.md: broken link (emptydir)", ]); });