diff --git a/scripts/kb-lint.sh b/scripts/kb-lint.sh index 4b8b933..72dd330 100755 --- a/scripts/kb-lint.sh +++ b/scripts/kb-lint.sh @@ -23,6 +23,7 @@ def pages(): # Names that a wikilink may point at: exactly the pages pages() reports. Deriving this # once keeps linting and link resolution from disagreeing about what exists. live = {p.stem for p in pages()} +live_paths = {p.resolve() for p in pages()} for p in pages(): rel = p.relative_to(kb) @@ -70,8 +71,16 @@ for p in pages(): text = re.sub(r"^```[^\n]*$^```[^\n]*$", "", p.read_text(errors="replace"), flags=re.M) rel = p.relative_to(kb) for m in re.finditer(r"\]\((?!https?:|#)([^)#\s]+\.md)", text): - target = (p.parent / m.group(1)).resolve() if not m.group(1).startswith("/") else (kb / m.group(1).lstrip("/")) - if not Path(target).is_file(): + target = (p.parent / m.group(1)).resolve() if not m.group(1).startswith("/") else (kb / m.group(1).lstrip("/")).resolve() + # A link INSIDE the bundle must land on a page that belongs to the bundle: `is_file()` + # alone also accepted a target parked in .AGENTTRASH/, which is gitignored — green here, + # a dead link on every machine that pulls (#57 again, in markdown clothing). A link that + # leaves the bundle is allowed: ../../AGENTS.md is the operating contract, not bundle + # content, and it is real on disk. + if kb in target.parents: + if target not in live_paths: + violations.append(f"{rel}: broken relative link -> {m.group(1)}") + elif not target.is_file(): violations.append(f"{rel}: broken relative link -> {m.group(1)}") for m in re.finditer(r"\[\[([^\]|#]+)", text): # wikilinks resolve by filename anywhere in the KB (Obsidian semantics) — but only diff --git a/tests/kb-lint.bats b/tests/kb-lint.bats index cc8dfc8..9f87af6 100755 --- a/tests/kb-lint.bats +++ b/tests/kb-lint.bats @@ -134,6 +134,21 @@ EOF [[ "$output" == *"broken wikilink -> [[log]]"* ]] } +@test "a markdown link into the quarantine is broken" { + # #57's failure mode wearing markdown instead of brackets: the relative-link check was a + # bare is_file(), so a link that lands on a page waiting in .AGENTTRASH/ passes this gate + # and points at nothing on every machine that pulls — the quarantine is gitignored. Both + # spellings count: relative, and bundle-absolute with a leading slash. + mkdir -p "$KB/.AGENTTRASH" + printf 'no frontmatter, quarantined\n' > "$KB/.AGENTTRASH/gone.md" + printf -- '---\ntype: reference\n---\nRelative: [gone](.AGENTTRASH/gone.md)\n' > "$KB/rel.md" + printf -- '---\ntype: reference\n---\nAbsolute: [gone](/.AGENTTRASH/gone.md)\n' > "$KB/abs.md" + run bash "$LINT" "$KB" + [ "$status" -ne 0 ] + [[ "$output" == *"rel.md: broken relative link -> .AGENTTRASH/gone.md"* ]] + [[ "$output" == *"abs.md: broken relative link -> /.AGENTTRASH/gone.md"* ]] +} + @test "a wikilink to a live page still resolves, in any directory" { # The narrowing above must cost nothing: filename-anywhere semantics is the point. printf -- '---\ntype: reference\n---\nSee [[good]].\n' > "$KB/sub/other.md"