Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/convention.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 64 additions & 2 deletions src/checks/docsLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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<string>): 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;
}
Comment thread
altaywtf marked this conversation as resolved.
try {
const result = spawnSync(
Comment thread
altaywtf marked this conversation as resolved.
"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<string, boolean>();
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;
Expand Down Expand Up @@ -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)) {
Comment thread
altaywtf marked this conversation as resolved.
// 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})`,
);
}
}
}
Expand Down
105 changes: 104 additions & 1 deletion test/docs-links.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
Expand Down