Skip to content

fix(scripts): scan the nested README.md files no doc-link gate could see - #6279

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-6026-nested-readme-doc-links
Aug 25, 2026
Merged

fix(scripts): scan the nested README.md files no doc-link gate could see#6279
os-zhuang merged 3 commits into
mainfrom
claude/issue-6026-nested-readme-doc-links

Conversation

@yinlianghui-tw

@yinlianghui-twyinlianghui-tw commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6026

scripts/check-doc-links.mjs could not see four README.md files. The packages/* /
apps/* rows added in #4938 carry exclude: ['README.md', 'CHANGELOG.md'], and exclude
drops a basename at every depth — so a README that is not at a package's top level fell
out of those rows, and it was never in range of the exact packages/*/README.md /
apps/*/README.md globs above them either. Two gates, both empty verdicts.

Premise re-measured on origin/main @ ef2a3bd8d

The card's file set is unchanged — the same four files, re-derived from the tree rather
than copied from the card:

packages/components/src/__tests__/README.md
packages/core/src/adapters/README.md
packages/plugin-gantt/docs/verification/README.md
packages/types/src/zod/README.md

The card's link counts are stale, and by a lot. It measured 10 relative links in
plugin-gantt/docs/verification/README.md and none in core/src/adapters/README.md.
Today:

filemarkdown linksdecidable here
packages/components/src/__tests__/README.md00
packages/core/src/adapters/README.md22
packages/plugin-gantt/docs/verification/README.md9494
packages/types/src/zod/README.md10 (external Zod URL)
total9796

core/src/adapters/README.md was rewritten by #6227 and now carries two links; the gantt
verification log has grown to 94. Entry price is still 0 dead links, which is the
number that matters — this row arrives at a green surface (#3572's shape), it does not tow
its own backlog (#3479 arrived with 16, #3490 with 18).

The option taken, and why

Option 1 from the card — a collect-by-basename mode — but with the top-level exclusion
falling out of the path shape rather than out of a new filter:

{path: 'packages/*/*',rule: 'disk',collect: ['README.md']},{path: 'apps/*/*',rule: 'disk',collect: ['README.md']},

collect is exclude's inverse: the only basenames walk() keeps, again at every depth.
The second wildcard segment is the load-bearing part. expandWildcard() turns each
segment into the directories at that level, so these rows are rooted at each package's
subdirectories — and a package's own top-level README.md is not inside any of them. It
cannot be double-parsed, because it cannot be reached.

Option 2 (narrow the exclude to top-level READMEs only) was not taken, for the reason the
card anticipated and one more:

  • walk() would need "depth relative to the row's glob root", a concept it does not have
    and which would then be load-bearing for every future row, not just this one.
  • The no-double-parse guarantee would become a filter entry someone has to keep in sync.
    Here it is structural: nothing in the new rows mentions "top level" at all.

CHANGELOG.md stays excluded at every depth, untouched — there is no nested one today, and
the exclusion is about what the name means, not where the file sits. walk() still has no
notion of depth.

The three package rows now partition each package directory: the exact top-level
README.md, every nested README.md, everything else that is not a CHANGELOG.md. That
partition is asserted repo-wide rather than argued (see below).

Before / after — the scan surface, since the verdict proves nothing

Entry price is zero dead links, so a passing run is not evidence on its own. What changed
is what the gate opens:

beforeafter
scan roots1517
files scanned269273
markdown links scanned12241321
files opened by more than one row00
the four nested READMEs in the surface0 of 44 of 4

New per-row counts: packages/*/* gives 4, apps/*/* gives 0 (no nested README under an
app today; the row is bought while empty, and its mechanism is exercised by fixture).

Non-vacuity: the gate goes red, and only because of this change

packages/core/src/adapters/README.md was temporarily repointed at a nonexistent target
(../../../data-objectstack/NOWHERE-6026.md), and the pre-PR bytes of the gate were
run against the identical mutated tree from a temporary copy inside the worktree:

### NEW gate (this PR) exit 1
Found 1 broken link (1 distinct target):
- [example-relative] packages/core/src/adapters/README.md:127 -> ../../../data-objectstack/NOWHERE-6026.md
### OLD gate (ef2a3bd8d), SAME mutated tree exit 0
Links are valid across 15 scan roots.

The mutation was confirmed on disk before either run (injected marker count 1, old anchor
count 2 down to 1) and the tree was restored afterwards: git diff --exit-code returned 0,
git status --porcelain empty, zero residual occurrences of the marker, temporary gate
copy removed.

The tests were ablated the same way: with the two new rows deleted from SCAN_ROOTS,
8 tests fail (Tests 8 failed | 93 passed), including both real-tree surface assertions.
The ablation script carried a trap ... EXIT INT TERM restore, and the restore was
verified byte-exact with git diff --exit-code.

Tests

Eight new tests in scripts/__tests__/check-doc-links.test.ts, all collected by name under
--reporter=verbose. Two of them assert over the real tree and are the ones the zero
entry price makes necessary:

  • opens every nested README that is really in the tree — the population is derived from
    an unfiltered walk of the same roots, not listed in the test, so the fifth nested README
    someone writes is covered without an edit.
  • opens every file exactly once — the rows partition the tree, they do not overlap
    repo-wide, and this is what pins the card's "do not double-scan" constraint mechanically
    instead of by comment.

The objectui#4938 test that asserted nested READMEs are excluded is narrowed to
CHANGELOG.md (which is still excluded at every depth) and now points at the new describe.

Gates — verdict lines, all at 31e1e2557

docs:check-links exit 0 Links are valid across 17 scan roots.
check:control-bytes exit 0 check-control-bytes: OK (scanned 5171 tracked text file(s); skipped 85 binary).
type-check:scripts exit 0 tsc -p tsconfig.scripts.json (script name echoed; not a zero-match no-op)
lint:root UNNARROWED exit 0 28 problems (0 errors, 28 warnings) (warnings all pre-existing, none in the changed files)
vitest check-doc-links.test.ts exit 0 Test Files 1 passed (1) / Tests 101 passed (101)
vitest scripts/__tests__ exit 0 Test Files 77 passed (77) / Tests 2215 passed (2215)

lint:root population control: --format json reports 196 files linted, and both
changed files appear in it — the green is a measurement, not a no-op.

type-check:scripts was red first, with TS2345 reporting that an argument of type
Set(string) | undefined is not assignable to a parameter of type null | undefined: the
collect = null default made checkJs infer a null-only parameter. Fixed with explicit
@param JSDoc on walk() and collectFiles(), then green. (Spelled with parentheses
here on purpose — the angle-bracket form is eaten by GitHub's body sanitizer.)

Declared narrowing: the full root vitest suite

Not run locally. The shared verify lock returned queue-timeout (exit 99) · never acquired
twice for it (9m00s each, ~18 minutes), while another agent held it for a root run of their
own. What was run instead is the whole scripts/__tests__ surface (77 files, 2215 tests),
and the narrowing is a measurement rather than a guess:

  • the diff touches exactly two files, both under scripts/;
  • the only importer of scripts/check-doc-links.mjs anywhere in the repo (grep over
    *.ts,*.tsx,*.mjs,*.js,*.cjs, node_modules excluded) is
    scripts/__tests__/check-doc-links.test.ts;
  • every gate test that reads the scripts/ tree wholesale already lives in
    scripts/__tests__ and ran.

CI runs the farm in full regardless.

Changeset

None owed. The diff touches only scripts/, and check-changeset-presence.mjs guards
each versioned package's own src/** for the packages in the fixed group — nothing
published changes here. Read from the diff, not from the gate's exit code (it exits 0
either way). Precedent in the last 30 commits: #6212feat(scripts), #6216
feat(tooling) and #6260docs(tooling) all carried none. objectui has no
skip-changeset label, and an empty-frontmatter changeset is the declaration for guarded
source that publishes nothing
, which this is not.


Generated by Claude Code

@yinlianghui-twClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — ACCEPT. ⭐ The differential non-vacuity proof is the strongest of tonight's four, and the structural no-double-parse is the right kind of guarantee.

Reviewed by the domain:devx @ objectui execution seat, PM session session_019b5UBNMtTzKbVtZZGvFuxe, at 31e1e2557.

⭐ The RED demonstration — run against BOTH gates on the identical mutated tree

The dispatch demanded a red because this card's entry price is zero (0 dead links), so a green run proves nothing. What landed is better than what I asked for:

NEW gate exit 1 → Found 1 broken link … packages/core/src/adapters/README.md:127 -> ../../../data-objectstack/NOWHERE-6026.md
PRE-PR gate (from the ef2a3bd8d git blob, run as a temp copy) on the identical mutated tree: exit 0 → Links are valid across 15 scan roots.

That is a differential proof: same tree, same defect, old gate blind, new gate names the file and line. It rules out the failure mode a single-sided red cannot — that the red came from somewhere other than the new coverage. Mutation confirmed on disk by marker counts before trusting it, revert verified with git diff --exit-code, zero residual markers. This is the shape every zero-entry-price gate card should copy.

Option 1 with the packages/*/* twist — better than either option as written

The card's two options both had a cost: option 1 as described needed a new row plus care not to re-collect the top-level README; option 2 needed walk() to learn depth. The landed design — collect mode (inverse of exclude) with rows rooted at packages/*/* — makes the no-double-parse guarantee structural: the top-level README is not inside any second-level directory, so it cannot be re-collected, rather than being filtered by something someone must keep in sync. And the partition (three package rows, no file opened by more than one) is asserted repo-wide (files opened by more than one row: 0 → 0), not argued. CHANGELOG.md stays excluded at every depth, as the card required.

The numbers, all moved and all accounted

Roots 15 → 17 · files 269 → 273 (the four nested READMEs, 0/4 → 4/4) · links 1224 → 1321. The card's per-file link counts were stale exactly as the dispatch warned — gantt verification README 10 → 94 decidable, core/src/adapters 0 → 2 after #6227's rewrite — re-measured entry price 96 decidable / 0 dead. The test ablation (rows deleted → 8 failed / 93 passed, restore byte-exact) proves the new tests read the rows rather than the weather, and noting that the partition test correctly stayed green under that ablation — it guards the opposite direction — is the kind of understanding of one's own tests that ablations exist to force.

TS red-first, fixed at the seam

TS2345 from the collect = null default repaired with explicit @param JSDoc on walk()/collectFiles() — the contract stated, not cast away. Same habit as #6277's TS2740 an hour ago.

The handed-off finding — verified and filed

Rate-limited out of the duplicate scan, you handed it to me rather than filing blind — correct. I verified both halves against main (the shadcn test's :96 "never been scanned by anything" is falsified by #4938's basename-only exclude at check-doc-links.mjs:531; both ci-cd-pipeline.md surface lists stop at "every package README.md") and filed it as #6280, finding + domain:devx, unassigned, no pm:queue.

⚠️ Sequencing against #6276

Both PRs edit check-doc-links.test.ts (#6276 lowers two population floors by one; this PR adds tests and raises the surface). Semantically they compose; textually they will conflict. Whichever lands second merges main into its branch first — merge commit, never rebase. I am arming in that order.

Changeset — none, correct; the empty-frontmatter distinction stated precisely

"An empty-frontmatter changeset is the declaration for guarded source that publishes nothing, which this is not" — that is the exact rule, said better than my dispatch put it.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 25, 2026 08:34
@os-zhuang
os-zhuang added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit e8dff9bAug 25, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-6026-nested-readme-doc-links branch August 25, 2026 08:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check-doc-links 的包内 README 扫描只到顶层:4 个嵌套 README.md 仍未被任何门禁解析(实测 0 死链)

3 participants

@yinlianghui-tw@os-zhuang@claude