feat(engine): LINK-FILE-URL — a link into someone's home directory

Second of the approved §5 checks. A `file:///Users/ktg/...` link is dead
for every reader but its author, and it publishes that author's directory
layout on a surface meant for strangers.

The SCHEME is not the rule, and measuring first is why. Across the corpus
there are 40 such links, and they split 18/22: a documented convention
example (`[Brief](file:///Users/ktg/.../brief.html)` — the same two lines
copy-pasted into nine CLAUDE.md files) versus real machine paths. Firing
on `file:` would have been wrong 45% of the time on its first run, which
is the ratio that gets a gate switched off. A further 22 links use bare
placeholders (`file:///abs/path.html`) and are not leaks either.

The discriminator is not tuned to this corpus: `...` is not a path
segment, so a target containing `/.../` cannot resolve on ANY machine and
is by construction an illustration.

Level follows the established reader rule — root is the shop window
(ERROR), below it live session plans and agent working files (WARN).

Measured on the registered corpus: exactly one finding, guard's
CLAUDE.md:56, a real absolute path to a file that is IN the repo and
should have been linked as `docs/BRIEF.md`. Every one of the 18
illustrations stayed silent.

Honest limit: three repos holding 21 of the 22 real leaks
(from-ai-to-chitta, wiki-advise, claude-code-llm-wiki) are NOT in the
register, so no check runs against them at all. That is register
freshness (`--refresh`), not this check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lb7XmJGLnFSX9U7tgS7fKk
This commit is contained in:
Kjell Tore Guttormsen 2026-08-12 21:27:29 +02:00
commit fa0cfe07d5
3 changed files with 72 additions and 3 deletions

View file

@ -927,6 +927,11 @@ function isFixturePath(path) {
.some((seg) => seg === 'test' || seg === 'tests' || seg === 'fixtures' || seg.includes('golden'));
}
// A home directory is what makes a `file:` URL a leak rather than a scheme the
// gate declines to resolve. Anchored on the two roots a real machine path
// starts with; a bare `file:///abs/path.html` placeholder is not one.
const FILE_URL_LEAK = /^file:\/\/\/?(Users|home)\//i;
// Relative file links only. Anchor resolution depends on per-renderer heading
// slug rules and is a rabbit hole; external URLs need the network. Both are
// deliberately out — a check that is sometimes wrong teaches people to ignore it.
@ -945,8 +950,27 @@ export function checkInternalLinks({ files, present }) {
stripCode(text).split('\n').forEach((line, i) => {
for (const m of line.matchAll(/\[[^\]]*\]\(([^)\s]+)\)/g)) {
const target = m[1];
// Any scheme at all, not just http — `file:`, `vscode:`, `ftp:` are all
// somebody else's to resolve.
// A `file:` URL naming a real home directory is the one scheme that is
// NOT somebody else's to resolve: it is dead for every reader but its
// author, and it publishes that author's directory layout.
//
// The scheme alone is not the rule. Measured over the corpus, 40 such
// links split 18/22 between a documented convention example — the same
// two lines copy-pasted into nine CLAUDE.md files — and real machine
// paths in three repos. `...` is not a path segment, so a target
// containing `/.../` cannot resolve on ANY machine and is by
// construction an illustration, not a leak.
if (FILE_URL_LEAK.test(target) && !/\/\.\.\.\//.test(target)) {
findings.push({
level: linkLevelFor(path),
code: 'LINK-FILE-URL',
bucket: 'broken',
msg: `${path}:${i + 1}\`${target}\` is a link into a local filesystem: dead for every reader but its author, and it publishes the author's directory layout. Link the repository-relative path, or the published URL.`,
});
continue;
}
// Any other scheme, not just http — `vscode:`, `ftp:` are all somebody
// else's to resolve.
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || /^[#<]/.test(target)) continue;
const clean = target.split('#')[0];
if (!clean) continue;