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:
parent
ddfc628761
commit
fa0cfe07d5
3 changed files with 72 additions and 3 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -758,6 +758,51 @@ test('org-profile requires no headings at all', () => {
|
|||
assert.equal(f.filter((x) => x.level === 'ERROR').length, 0);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- file: URL links
|
||||
|
||||
// A `file:///Users/ktg/...` link is dead for every reader but its author, and
|
||||
// it publishes the author's directory layout. The scheme alone is NOT the rule:
|
||||
// measured across the corpus, 40 such links split 18/22 between a documented
|
||||
// convention example (`[Brief](file:///Users/ktg/.../brief.html)`, the same two
|
||||
// lines copy-pasted into nine CLAUDE.md files) and real machine paths (22, in
|
||||
// three repos). Firing on the scheme would have been wrong 45% of the time on
|
||||
// the first run — the ratio that gets a gate switched off.
|
||||
//
|
||||
// The discriminator is not tuned to this corpus: `...` is not a real path
|
||||
// segment, so a path containing `/.../` cannot resolve on ANY machine and is by
|
||||
// construction an illustration.
|
||||
test('a file: URL naming a real home directory is a finding', () => {
|
||||
const files = { 'README.md': '[my notes](file:///Users/ktg/repos/x/notes.md)' };
|
||||
const f = checkInternalLinks({ files, present: ['README.md'] });
|
||||
const hit = f.find((x) => x.code === 'LINK-FILE-URL');
|
||||
assert.equal(hit.level, 'ERROR');
|
||||
assert.equal(hit.bucket, 'broken');
|
||||
assert.match(hit.msg, /README\.md:1/);
|
||||
});
|
||||
|
||||
test('an elided file: path is an illustration, not a leak — it resolves nowhere by construction', () => {
|
||||
const files = { 'README.md': '[Brief](file:///Users/ktg/.../brief.html)' };
|
||||
assert.equal(checkInternalLinks({ files, present: ['README.md'] }).some((x) => x.code === 'LINK-FILE-URL'), false);
|
||||
});
|
||||
|
||||
test('a placeholder file: path is not a leak either', () => {
|
||||
const files = { 'README.md': '[Open in browser](file:///abs/path.html)' };
|
||||
assert.equal(checkInternalLinks({ files, present: ['README.md'] }).some((x) => x.code === 'LINK-FILE-URL'), false);
|
||||
});
|
||||
|
||||
// Same reader rule as every other link: the root is the shop window, below it
|
||||
// live session plans and agent working files.
|
||||
test('a file: URL below the root is a WARN, not an ERROR', () => {
|
||||
const files = { 'docs/plan.md': '[notes](file:///home/ktg/notes.md)' };
|
||||
const f = checkInternalLinks({ files, present: ['docs/plan.md'] });
|
||||
assert.equal(f.find((x) => x.code === 'LINK-FILE-URL').level, 'WARN');
|
||||
});
|
||||
|
||||
test('other schemes stay somebody else\'s to resolve', () => {
|
||||
const files = { 'README.md': '[spec](https://example.com/x) [editor](vscode://file/x)' };
|
||||
assert.equal(checkInternalLinks({ files, present: ['README.md'] }).some((x) => x.code === 'LINK-FILE-URL'), false);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- tag integrity
|
||||
|
||||
// A lightweight tag is a branch-like ref: it can be moved to a different commit
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue