diff --git a/docs/convention.md b/docs/convention.md index e7dc43a..7b08154 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 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 b742ca8..b7e8ba4 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,11 +283,65 @@ function pathPart(href: string): string { return href; } +// 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("/"); + 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; + } + try { + const result = spawnSync( + "git", + [ + "--literal-pathspecs", + "ls-files", + "--others", + "--exclude-standard", + "--directory", + "--no-empty-directory", + "-z", + "--", + target, + ], + { encoding: "utf8" }, + ); + return result.status === 0 && result.stdout.split("\0").some(Boolean); + } catch { + return false; + } +} + export function docsLinkErrors(config: DocsLinksConfig): string[] { const bad: 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 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; @@ -338,8 +392,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. Name the cause; do not promise a remedy. + bad.push( + 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 557127a..4e6f22b 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"; @@ -96,6 +96,109 @@ test("docs links reports broken inline, image, reference, and malformed destinat ]); }); +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)", + "[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); 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)", + ]); +}); + +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"); + 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: untracked link target (nested); the link resolves only once it is tracked", + ]); +}); + +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[empty](emptydir)\n[twice](emptydir)\n"); + track(dir); + mkdirSync(join(dir, "emptydir"), { recursive: true }); + + assert.deepEqual(check(dir), [ + "README.md: broken link (file%00.md)", + "README.md: broken link (emptydir)", + "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", () => { + 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"); + 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); the link resolves only once it is tracked", + ]); +}); + test("docs links skips tracked symlink leaves and reports missing tracked Markdown", () => { const dir = repository(); put(dir, "README.md", "# Readme\n");