Skip to content

docs(testing): repair 333 root-relative hrefs that never resolved - #280

Merged
wshallwshall merged 2 commits into
mainfrom
claude/testing-plan-broken-hrefs
Aug 7, 2026
Merged

docs(testing): repair 333 root-relative hrefs that never resolved#280
wshallwshall merged 2 commits into
mainfrom
claude/testing-plan-broken-hrefs

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Authored by the session in worktree sweet-dhawan-57cbc8; pushed and opened by the coordinator at its
request. 14 files, +256/-256, all under docs/testing/master-test-plan/.

Links were written as ](messagefoundry/pipeline/alerts.py) from a file three levels deep, so they
resolved against docs/testing/master-test-plan/messagefoundry/.... The targets are real; only the prefix
was wrong.

Why this is a DIFFERENT defect from #279, which matters for how #1095 gets closed

In #279 the link resolved and the item behind it had moved -- invisible to any link checker. Here the
link never resolved at all. This half is linter-catchable, and the repo runs no link checker, which
is how 333 accumulated. Closing one does not close the other.

Coordinator verification

claim verified
tip 014496e7 yes
base 4d5f6304, equals origin/main, not stacked on #279 yes -- merge-base equals origin/main; a02208d6 is not an ancestor
14 files, +256/-256, all under docs/testing/master-test-plan/ yes -- 0 files outside that path
zero edits to docs/BACKLOG.md, BACKLOG-CLOSED.md, docs/CONNECTIONS.md yes -- 0 each
disjoint from #279 yes -- 0 overlapping files

Doc-drift guards skip on a docs-only PR (.md is in the noncode allowlist), so they were run locally
against this exact SHA in a detached worktree: test_doc_ref_handle, test_crit2_inline_doc_drift,
test_docs_runbooks, test_docs_security_pathways, test_security_doc_drift,
test_backlog_status_check -- 93 passed.

Left alone deliberately, so nobody "finishes" it wrongly

  • 24 hrefs shaped ](messagefoundry/pipeline/alerts.py:27) -- the file:line convention inside a
    link target. No prefix makes those resolve; fixing means dropping the line number or adopting GitHub's
    #L27 form, which is a convention decision, not a repair. 23 of the 24 are in 15-alerting.
  • 08-publishing-and-promotion.md:43 cites ../VERSION-CONTROL.md, which exists nowhere in the tree.
  • Withheld directories (docs/security, reviews, marketing) untouched -- the plan states at :653
    that a missing path there is a deliberate publishing boundary, not a defect.

A bug the author shipped and caught, reproduced here because it generalises

The first pass used href.lstrip("./"), which strips a character set, not a prefix -- so
.github/workflows/ci.yml became github/workflows/ci.yml, did not exist, and 28 real dotfile links
were silently misclassified as "target missing"
, indistinguishable from genuine misses in the output.
Caught only by reading the miss list and noticing .gitignore in it. If you normalise paths anywhere,
do not use lstrip.

Verification, as reported

Broken links under docs/testing/ went 357 to 24, and the 24 are enumerated in the commit message
rather than given as a count. A planted broken href was run through the checker after the fix to prove it
still reports a miss. Fenced code skipped, CRLF preserved.

Scale correction for #1095 -- read before scoping it

The item says the broken-href class is 13 sites. That was measured only against BACKLOG.md-targeting
links. Repo-wide the real figure is 635 broken relative links across 19 files; this PR clears 333.

The largest remaining block is 270 inside docs/archive/backlog/BACKLOG-CLOSED.md, and its root cause
deserves its own item: archiving moves an item two directories deeper and does not rewrite its relative
links
, so adr/0083-....md is correct in docs/BACKLOG.md and broken the moment it lands in
docs/archive/backlog/. Fixing those 270 by hand without fixing the archival step just re-creates them at
the next archival. Deliberately not touched -- it is a contended ledger file and the real fix is process,
not text.

Merge ordering

Auto-merge deliberately NOT armed, same reason as #279: this base already equals current main, so
landing it now would knock merge train #274 (seven PRs, mid-CI) BEHIND and cost it a full cycle. Armed
once the trains land.

… (BACKLOG #1095)

Links throughout docs/testing/master-test-plan/ were written as if from the repo
root -- ](messagefoundry/pipeline/alerts.py), ](tests/), ](.github/workflows/ci.yml).
From a file three levels deep those resolve to
docs/testing/master-test-plan/messagefoundry/... and simply 404. The targets are
real; only the prefix was wrong. Each is rewritten to its true shortest relative
path.

This is the CATCHABLE half of #1095, and it is the half a link checker would
find -- which matters, because this repo runs no link checker at all. It is a
different defect from the citation rot in PR #279: there the link resolved and
the item behind it had moved; here the link never resolved.

333 hrefs across 14 files. 256 insertions / 256 deletions, line-for-line, all
under docs/testing/. No prose changed.

GUARDS, each protecting a specific way this could have done damage:
  - FENCED CODE SKIPPED. A path inside a ``` block is sample output being shown,
    not a link to follow. (Zero were found in fences here, but the guard ran.)
  - ONLY hrefs whose repo-root reading actually EXISTS were touched. Where the
    target is absent either way the link is broken for a different reason and
    was left, not papered over.
  - WITHHELD DIRECTORIES NEVER TOUCHED. The plan states at :653 that
    docs/security/, docs/reviews/ and docs/marketing/ are gitignored
    post-cutover and that a missing path there is expected, not a defect.
    Rewriting those would convert a deliberate publishing boundary into a
    apparent repair. Zero were matched, and the exclusion is in the script so a
    re-run stays safe.

LEFT ALONE, and named so nobody "finishes" it wrongly: 24 hrefs of the form
](messagefoundry/pipeline/alerts.py:27) -- the repo's file:line citation
convention embedded in a link target. The :27 suffix means they cannot resolve
as a path no matter what prefix is applied; fixing them means either dropping
the line number or adopting GitHub's #L27 anchor form, and that is a convention
decision, not a repair. 23 are in 15-alerting-and-observability.md.
One genuine miss remains: 08-publishing-and-promotion.md:43 cites
../VERSION-CONTROL.md, which does not exist anywhere in the tree.

A BUG IN MY OWN SCRIPT, caught and recorded because the failure was silent:
the first pass used href.lstrip("./") to strip a leading "./". lstrip takes a
CHARACTER SET, so ".github/workflows/ci.yml" lost its leading dot and became
"github/workflows/ci.yml", which does not exist -- so 28 real dotfile links were
misclassified as "target missing" and skipped. They looked identical to genuine
misses in the output. Caught only by reading the list of supposed misses and
noticing .gitignore among them. Fixed to strip one leading "./" and no more.

VERIFICATION
  - Broken links under docs/testing/ went 357 -> 24, and the 24 are enumerated
    above rather than summarised as a count.
  - A deliberately planted broken href was run through the same checker after
    the fix to prove it still reports a miss; a checker that has stopped seeing
    the class would also report zero.
  - No mixed line endings introduced; CRLF preserved.
  - Every changed file is under docs/testing/; staged by explicit path.

Cut from origin/main, NOT from the PR #279 branch: the two change sets touch
disjoint files and stacking would have widened an open PR.
@wshallwshall
wshallwshall merged commit 481925d into main Aug 7, 2026
33 checks passed
@wshallwshall
wshallwshall deleted the claude/testing-plan-broken-hrefs branch August 7, 2026 21:38
wshallwshall added a commit that referenced this pull request Aug 7, 2026
The three genuine breaks left repo-wide once the false positives were removed:

ADR slug rot in docs/BACKLOG.md:3073. Two ADRs merged under different titles than
the ones cited -- 0094 as granular-expiry-only-tls-relaxation, 0118 as
secure-by-default-security-configuration-section. The number is the identity, so
these repoint with confidence; the same class as the three the archive pass found.

One survivor of the 333-href repair (#280) in the same subtree:
08-publishing-and-promotion.md cited ../VERSION-CONTROL.md where the file is two
levels up, not one.

Four BACKLOG.md:<line> anchors in the archive become item references. They are
here rather than on the docs-only branch because this PR already edits this file
within three lines of them, and adjacent hunks conflict. Root cause of the whole
anchor class: commit 4ea1501 (the master test plan) is NOT a descendant of
03f1fbd (the 185-item archival) -- parallel branches, so anchors written against
an 8,742-line ledger landed beside a commit that cut it to 3,858. Each of the four
was recovered from its citing prose and adversarially re-verified against the item
it names; the line numbers were dead, the item numbers survive archival, which is
the point.
wshallwshall added a commit that referenced this pull request Aug 7, 2026
… file #1098-#1100 (BACKLOG #1095) (#283)

* docs: convert 21 dead BACKLOG line anchors to item references

A `BACKLOG.md:<line>` anchor cannot survive an actively edited ledger. The file
moved 6,318 -> 6,616 lines during this work alone, and of 43 anchors repo-wide,
every one examined had drifted onto unrelated text. Five that were past EOF a day
earlier are now IN range and land on plausible-looking wrong items, which is worse
than dangling. Item numbers survive both editing and archival, so that is what
these become.

Root cause, and it is not gradual drift: commit 4ea1501 (the master test plan) is
NOT a descendant of 03f1fbd (the 185-item archival). They were parallel branches.
The plan's anchors were authored against an 8,742-line ledger and landed beside a
commit that cut it to 3,858, so they were stale on arrival, not over time.

Recovery was per-site reading with the citing prose as the primary evidence -- it
usually names the item inline -- backed by resolving each anchor against the
pre-archival ledger at 03f1fbd^. Every mapping was then put to an adversarial
reviewer instructed to refute by default. 32 of 43 survived; the 11 refusals were
not noise and are deliberately NOT converted here:

- Eight sites where the citing CLAIM is dead, not just the pointer. The alerting
  chapter still asserts #139 and PHI.md "currently contradict each other" when
  #139 was corrected 2026-08-01 and the code fixed by #323 on 2026-08-02; the
  ranked-backlog row for #338 is a pre-shipping snapshot. Repointing those would
  relaunder a false present-tense claim as a fresh, durable-looking reference.
  They need a content fix, which is a different change.
- Six anchors that cannot be attributed to any item, including one pointing at an
  un-numbered narrative bullet. Left uniform rather than half-repaired: uniform
  staleness is at least detectable, and a confident wrong pointer is not.
- Three that are not citations at all -- two inside a fenced transcript in #1083
  reproducing scanner output, and #347's own Source paragraph narrating the
  falsification test that made the status checker fail on purpose.

One mapping was recovered after the review: 16-security-phi:891's "no dormancy
contingency" quotes #89's title ("hl7apy security hardening -- dormant-upstream
contingency") almost verbatim. One proposal was refuted only on fragment
uniqueness, not on the item; applying line-scoped rather than globally makes it
safe, which is how all of these were applied.

* docs(testing): move the line number out of 23 source-link targets

The repo's file_path:line_number citation convention had leaked inside the href:
[`pipeline/alerts.py:27`](messagefoundry/pipeline/alerts.py:27). No prefix makes
that resolve. Owner's ruling was to move the line out of the target and keep it in
the link text, where it already sits -- no second convention, and nothing lost.

These carry a second defect that hid behind the first. The targets were also
root-relative, the class #280 repaired across 333 hrefs in this same subtree; they
were skipped there because link_check.py deliberately skips ":<line>" targets, so
each defect concealed the other. Both are fixed here: the line comes out and the
../../../ prefix goes in, matching the form #280 established in these files.

All 23 targets were confirmed to exist before rewriting. Repo-wide there are now
zero file:line hrefs, so --include-line-cites reports nothing new -- the checker's
skip is now a safety net rather than a suppression.

* backlog: correct #1095's own counts, and file #1098, #1099, #1100

#1095 recorded four measured counts and three of them were wrong -- in the way the
item itself is about. Its href figure of 13 was low by two orders of magnitude
against at least 629 repaired sites. Its two anchor counts moved between filing and
repair, and not because anything improved: this file grew 6,318 -> 6,616 lines, so
five anchors that were safely past EOF came back INTO range and now land on
plausible-looking wrong items. A dangling pointer degrades into a confident one as
the file grows, which is the sharpest argument for the item-number convention.

Root cause of the anchor class recorded, because it is not gradual drift: commit
4ea1501 (the master test plan) is not a descendant of 03f1fbd (the 185-item
archival). Parallel branches, so anchors written against an 8,742-line ledger
landed beside a commit that cut it to 3,858 -- stale on arrival. It also explains
the anchor citing line 8429, absurd against 6,616 and ordinary against 8,742.

The markdown-only scope note is discharged rather than left open. The predicted
sweep of harness/, ide/src/, messagefoundry/, scripts/, tests/, packaging/ and
.github/workflows/ ran over 1,219 files: 68 nonexistent paths, none of them a
rotted citation -- test fixtures, withheld directories, and past-tense historical
comments. The prediction was wrong and the reason is kept: citations in code are
written about the past, prose citations are written as pointers.

Three items filed, numbers allocated with alloc.ps1:

#1098 -- the coordination hook prints a session UUID in the column a commit SHA
occupies. Small, but it is an instrument answering a different question from the
one its header asks, with nothing in the output saying so.

#1099 -- #1094 says "the archival pass generates the anchor". There is no archival
tooling at all; the move is manual. The sentence sits in the archive as settled
record and points maintenance at a generator that was never built.

#1100 -- the nine sites where the citing CLAIM is dead, not just the pointer,
split out of #1095 rather than repointed. The alerting chapter still sets exit
criteria requiring that #139 and PHI.md be made to agree when #139 was corrected
2026-08-01 and the code fixed by #323 on 2026-08-02. Converting those pointers
would attach a durable reference to a false claim, which is worse than leaving the
stale anchor visible. Found by the adversarial pass: every one was proposed as a
clean repoint by a first reader and refuted by a second who checked the claim
against the code rather than against the anchor.
wshallwshall added a commit that referenced this pull request Aug 8, 2026
…nk-resolution gate (BACKLOG #1095) (#281)

* docs: fix the 270 links archiving broke, and gate the cause (BACKLOG #1095)

ROOT CAUSE, measured rather than inferred. Closing a backlog item moves its text
VERBATIM from docs/BACKLOG.md into docs/archive/backlog/BACKLOG-CLOSED.md -- two
directories deeper -- and nothing rewrites its relative links. A link written
"adr/0083-x.md" was correct while the item lived in docs/ and resolves to
docs/archive/backlog/adr/0083-x.md the moment it lands.

The proof is the distribution, not the story: 267 of the archive's 270 broken
links resolve CLEANLY when read from docs/. That is what pins the cause to the
move rather than to authoring error. The other 3 are ADR slug rot -- the ADR
merged under a different title than the one cited:
    0080-tls-syslog-forwarding.md            -> 0080-offbox-forwarding-tls-defaults.md
    0144-handler-config-taint-rules.md       -> 0144-security-lint-gate-over-admin-authored-router-handler-config.md
    0065-mount-the-web-console-in-process.md -> 0065-web-ops-dashboard.md
None was ever committed under the cited name, so those citations were written
from working titles that changed before the ADR merged. Repointed by ADR NUMBER,
and only where exactly one docs/adr/NNNN-* exists.

All 270 repaired. 208 insertions / 208 deletions in the archive, line-for-line.

THERE IS NOTHING TO FIX UPSTREAM. I looked for the archival tooling before
writing a guard: none exists. The move is manual, so #1094's remark that "the
archival pass generates the anchor" describes a generator that was never built.
A guard is therefore the only thing that can catch the next one.

THE GUARD
  scripts/docs/link_check.py -- resolves every relative markdown link. Repo-wide
  capable, takes an optional subtree. Prints WHAT IT SCANNED next to the verdict,
  because a green run over three files reads identically to a green run over the
  repo.

  Deliberately does NOT check: absolute URLs (reachability is a network question),
  fragments (heading slugs churn on every retitle), the withheld directories
  docs/security|reviews|marketing (gitignored post-cutover -- the master test plan
  states a missing path there is a publishing boundary, not a defect), fenced code
  (a path inside a fence is sample output), and file.py:27 citation targets (they
  cannot resolve whatever prefix is used; repairing them is a convention decision).
  A checker that overreaches is one people delete.

  tests/test_archive_link_resolution.py asserts the archive is clean, and:
    - refuses to pass on an empty scan (>= 1 file, >= 200 links), the same
      anti-narrowing floor backlog_status_check.py uses. Without it the test
      passes just as happily when a path change makes it read nothing.
    - PLANTS A BROKEN LINK in a throwaway repo and asserts the checker reports
      exactly it. A green result is evidence only if the checker can see the class.
    - pins the withheld-directory exemption.

SCOPE IS THE ARCHIVE ONLY, deliberately. Repo-wide the checker still reports 341
unresolved, because PRs #279 and #280 are not merged yet. A gate that is red on
arrival gets suppressed rather than fixed -- the same reasoning the ledger gate
records -- so this asserts only the subtree that is clean. Widening it is a
one-line change to _SUBTREE once the rest lands.

A BUG MY OWN TEST CAUGHT, recorded because it would have shipped silently: the
first version compared the RAW HREF against the withheld prefixes. A relative
link "../../security/X.md" shares no prefix with the repo-root form
"docs/security/", so the exemption matched nothing and would have flagged every
withheld link the moment the gate widened. Only the planted-case test found it.

VERIFICATION
  - Broken relative links in the archive: 270 -> 0, over 277 links checked.
  - A known-missing path run through the same checker afterwards to prove it
    still reports a miss.
  - ruff format, ruff check, mypy strict clean; bandit passes with the repo's
    standard nosec B603 B607 form on the three fixed-argv git calls.
  - The 7 doc-drift guards run by hand (99 passed) -- they are SKIPPED on a
    docs-only PR because .md is in the noncode allowlist and fire on the push to
    main afterwards, so a green PR check is not evidence there.
  - backlog_status_check.py: OK, 365 items, unchanged.
  - Cut from origin/main. Touches no file in PR #279 or #280, and neither of
    those touched the archive, so there is no conflict in either direction.

* fix(docs): widen the link gate to the repo, after removing three false positives

The gate was archive-only because a repo-wide assertion was red over pre-existing
breakage, and a gate red on arrival gets suppressed rather than fixed. This makes
it repo-wide. Three of the eight failures standing in the way were not defects.

Inline code. The checker already skips fenced blocks on the grounds that a path
inside them is being shown, not offered. A backtick span is the same argument at
smaller scale, and four sites turn on it: a regex whose character class contains
"](", two VS Code command: URIs -- one quoted in ADR 0110 as an attack payload --
and ADR 0160 quoting the very link it records as removed. Repointing any of them
would corrupt the text. Tested by POSITION, not shape, so the dominant idiom
[`x.md`](../x.md) keeps being checked; the test asserts both halves, because a
shape rule would stop checking most of the docs while staying green. Cost,
measured: 10 links of 5,344 no longer checked, five of them previously passing.

docs/releases/ is gitignored by ADR 0160 Phase 1, like docs/security/ and its
siblings. One archived throughput doc still cites the v0.1 plan that moved out.

.claude/ is the one to remember. It is gitignored but PRESENT in a long-lived
local checkout, so a repo-wide run passes there and fails on CI's clean clone. The
first measurement of this class was taken in such a checkout and undercounted it
by exactly 7 links; widening on that number would have put the gate red on arrival
-- the outcome the first paragraph exists to avoid. Read a gate result as a fact
about the configuration it ran in.

The exemption list is pinned as a set, so adding a prefix is a deliberate act with
a failing test attached rather than a silent widening of what goes unchecked.
Verified by planting a break in a real doc outside the archive and watching the
widened gate fail, then restoring: 5,327 links across 347 files, zero unresolved.

* docs: repair the last three unresolved links, and four dead line anchors

The three genuine breaks left repo-wide once the false positives were removed:

ADR slug rot in docs/BACKLOG.md:3073. Two ADRs merged under different titles than
the ones cited -- 0094 as granular-expiry-only-tls-relaxation, 0118 as
secure-by-default-security-configuration-section. The number is the identity, so
these repoint with confidence; the same class as the three the archive pass found.

One survivor of the 333-href repair (#280) in the same subtree:
08-publishing-and-promotion.md cited ../VERSION-CONTROL.md where the file is two
levels up, not one.

Four BACKLOG.md:<line> anchors in the archive become item references. They are
here rather than on the docs-only branch because this PR already edits this file
within three lines of them, and adjacent hunks conflict. Root cause of the whole
anchor class: commit 4ea1501 (the master test plan) is NOT a descendant of
03f1fbd (the 185-item archival) -- parallel branches, so anchors written against
an 8,742-line ledger landed beside a commit that cut it to 3,858. Each of the four
was recovered from its citing prose and adversarially re-verified against the item
it names; the line numbers were dead, the item numbers survive archival, which is
the point.

* ci(docs): run the link gate on docs-only PRs, where the links actually break

Widening the gate to the repo left a hole I put there. `pytest` is gated on
`code == 'true'`, so on a docs-only PR the new repo-wide assertion did not run at
all -- it would have fired on the push to main afterwards, after the merge. The
PRs most likely to break a relative markdown link are exactly the ones that were
skipping the check.

ci.yml already documents this class and keeps an ungated list for it, citing four
docs-only PRs that merged on 2026-08-04 with citation errors no guard caught --
one of them about inbound citations of `docs/releases/`, which is the same
directory this gate now treats as withheld. The gate belongs on that list; it is
added to both the printed manifest and the pytest invocation, so the "print what
you scanned" property is preserved.

Verified by running the step's exact command line: 184 passed, 89 skipped.

Also renames test_archive_link_resolution.py -> test_link_resolution.py. The old
name was accurate when the scope was `docs/archive/backlog` and became a false
claim the moment the assertion went repo-wide -- a file whose name understates
what it enforces is the same defect as a commit message that understates its own
change. Nothing outside the file referenced it by name.

* fix(docs): resolve links against the tracked set only, never the filesystem

The gate now gives the same answer in a long-lived local checkout and on CI's
clean clone BY CONSTRUCTION, rather than by enumerating the paths where the two
differ. For a repo-wide invariant that is the difference between a control and a
coin flip, and it is the property the PR body was about to claim on the strength
of an exemption list that did not provide it.

The old resolver accepted `target in tracked OR (root/target).exists()`. That
second arm passes anything merely PRESENT, and gitignored-but-present paths are
exactly the set a developer has and CI does not. It is the mechanism of the
.claude/ finding, not a defence against it: 7 docs link to .claude/settings.json,
which passed here and would have failed on the runner, and the first repo-wide
measurement undercounted by exactly those 7.

Dropping the fallback outright would have been wrong, and measuring said so before
I proposed it: `git ls-files` lists files and NEVER directories, so 122 legitimate
directory links (docs/adr, environments, .github/workflows) resolve only via the
filesystem arm. So tracked_paths() derives every ancestor prefix of every tracked
file and resolves against files plus derived directories. Repo-wide result is
unchanged at 5,327 links, zero unresolved.

Two tests pin the pair, and the first is demonstrated rather than argued: a file
PRESENT ON DISK but untracked must fail (verified by re-running the old resolver
against the same planted repo -- it reports 0 failures where the new one reports
1), and a directory containing a tracked file must resolve.

.claude/ stays in WITHHELD as POLICY, not as protection, and its comment now says
so. It was added as protection; keeping that framing after the structural fix
would be a compensating control resting on a premise that is no longer why it
holds (SDS-3.7). Removing the entry would now make those 7 links fail honestly and
identically in every checkout.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant