From dd2d2e7329fde0cbdc3f76f6640843b5d01f74f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 06:30:16 +0000 Subject: [PATCH 1/2] fix(scripts): scan nested README.md files in check-doc-links Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe --- scripts/check-doc-links.mjs | 128 +++++++++++++++++++++++++++++++----- 1 file changed, 112 insertions(+), 16 deletions(-) diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs index 80d16c7d64..fc9f63569a 100644 --- a/scripts/check-doc-links.mjs +++ b/scripts/check-doc-links.mjs @@ -441,6 +441,58 @@ * objectui#3536 — these files are read on GitHub (and, for a published * package, on npm), never served by the site. * + * ## Why this file changed again (objectui#6026): the nested READMEs + * + * The gap the section above names, closed. `exclude` leaves a basename out at + * EVERY depth, so `README.md` on the `packages/*` / `apps/*` rows hid four + * files nothing else could reach either — the rows above them are exact + * top-level globs, and a nested README is not at that path: + * + * 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 + * + * **Price: 4 files, 97 markdown links, 96 of them decidable here, 0 dead** — + * objectui#3572's shape, the one an extension should have: the row arrives at + * a surface already green rather than towing its own backlog (contrast + * objectui#3479's 16 dead targets and objectui#3490's 18). Nearly all of that + * is one file: `packages/plugin-gantt/docs/verification/README.md` carries 94 + * of the 97, a verification log of relative links to sibling scripts, source + * files and its own screenshots. The other three carry 3 links between them, + * one of which is external. + * + * Because the price is zero, a passing run proves nothing on its own — so the + * tests pin the SURFACE (these files are opened, each by exactly one row) and + * not only the verdict. + * + * ### The mechanism: `collect`, and a row rooted one directory down + * + * `collect` is `exclude`'s inverse — the ONLY basenames `walk()` keeps, again + * at every depth. The two new rows add a second wildcard segment to + * `packages/*` and `apps/*` (the table below carries the literal paths; a + * block comment cannot, because the two characters that close a wildcard + * segment also close a comment). `expandWildcard()` turns each segment into + * the directories at that level, so these rows are rooted at the SUBdirectories + * of each package/app — and a package's own top-level `README.md` is not + * inside any of them, so it cannot be reached from here at all. + * + * That is the whole no-double-parse guarantee, and it is structural rather + * than a filter someone has to keep in sync: nothing in these rows mentions + * "top level", and `walk()` still has no notion of depth. The alternative + * considered — narrowing the `exclude` on `packages/*` to the top-level README + * only — needed exactly that notion, and it would then have been load-bearing + * for every future row rather than for this one. + * + * Together the three package rows partition the surface: the exact top-level + * `README.md`, every nested `README.md`, and everything that is neither a + * README nor a `CHANGELOG.md`. Every file is opened by exactly one row — + * asserted over the real tree, not left to reading. + * + * `CHANGELOG.md` stays excluded at every depth, unchanged. There is no nested + * one today; the exclusion is about what the name means (changesets output, + * never authored prose), not about where the file sits. + * * ## Code spans are stripped before scanning * * Required, not tidiness. Extending the scan to relative hrefs turns markdown's @@ -491,6 +543,14 @@ const SITE_ORIGIN_RE = /^https?:\/\/(?:www\.)?objectui\.org(\/[^\s]*)?$/i; const UNSCANNED_DIRS = new Set(['node_modules', 'dist', 'build', '.next', '.turbo', '.git']); /** Shared empty exclude set — a fresh `Set()` per call would work identically, this just avoids allocating one on every `walk()`/`collectFiles()` call that has no row-level `exclude`. */ const EMPTY_EXCLUDE = new Set(); +/** + * Collecting BY basename and excluding BY basename are opposite readings of + * one list, so a row carrying both has no defensible meaning — and what it + * would produce is the silent failure this file refuses everywhere else, a + * surface quietly narrowed to nothing. Same stance as `expandWildcard()`: + * throw rather than guess (objectui#6026). + */ +const BOTH_FILTERS = 'A SCAN_ROOTS row may set `exclude` OR `collect`, never both'; /** * The scan surfaces, and the link semantics each one actually has. @@ -513,8 +573,12 @@ const EMPTY_EXCLUDE = new Set(); * objectui#4148 the app READMEs and the rest of the repo root. * * A row's `path` is a directory to walk, a single markdown file, or a pattern - * whose one wildcard SEGMENT stands for every directory at that level — see + * whose wildcard SEGMENTS each stand for every directory at that level — see * `expandWildcard()` below, which is all the glob syntax this table has. + * + * A directory row may narrow what the walk keeps, by basename, at every depth: + * `exclude` names what to leave out, `collect` names the only names to take. + * They are opposite readings of one list, and a row may carry at most one. */ export const SCAN_ROOTS = [ { path: 'content/docs', rule: 'docs' }, @@ -530,6 +594,15 @@ export const SCAN_ROOTS = [ // See "Why this file changed again (objectui#4938)" in the header. { path: 'packages/*', rule: 'disk', exclude: ['README.md', 'CHANGELOG.md'] }, { path: 'apps/*', rule: 'disk', exclude: ['README.md', 'CHANGELOG.md'] }, + // The nested `README.md` files: left out of the two rows above by their + // every-depth `exclude`, and never in range of the exact top-level globs + // above those, so nothing had ever opened them (objectui#6026). The SECOND + // wildcard segment is what keeps the top-level README out of these rows — + // they are rooted at each package/app's subdirectories, and the file the + // earlier row already scans is not inside any of them. See that card's + // section in the header. + { path: 'packages/*/*', rule: 'disk', collect: ['README.md'] }, + { path: 'apps/*/*', rule: 'disk', collect: ['README.md'] }, // The rest of the root-level markdown, completing that surface (objectui#4148). // `README.md`, `CONTRIBUTING.md` and `ROADMAP.md` are already above, in the // positions the rows that bought them left them in. @@ -542,7 +615,20 @@ export const SCAN_ROOTS = [ const blank = (text) => text.replace(/[^\n]/g, ' '); -export function walk(dir, files = [], exclude = EMPTY_EXCLUDE) { +/** + * Every markdown file under `dir`, at any depth, minus the directory names + * nothing of ours is ever the source of. + * + * The two basename filters are alternatives, and both apply at EVERY depth — + * `walk()` does not know how deep it has recursed, and deliberately still does + * not (objectui#6026: a row needing "top level only" says so with an extra + * wildcard segment in its `path`, not with a depth rule in here): + * + * - `exclude` — the names to leave out (objectui#4938); + * - `collect` — when given, the ONLY names to take, which is what makes + * `exclude` meaningless alongside it. + */ +export function walk(dir, files = [], exclude = EMPTY_EXCLUDE, collect = null) { let entries; try { entries = readdirSync(dir, { withFileTypes: true }); @@ -552,12 +638,12 @@ export function walk(dir, files = [], exclude = EMPTY_EXCLUDE) { for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { - if (!UNSCANNED_DIRS.has(entry.name)) walk(fullPath, files, exclude); + if (!UNSCANNED_DIRS.has(entry.name)) walk(fullPath, files, exclude, collect); continue; } - if (/\.(md|mdx)$/.test(entry.name) && !exclude.has(entry.name)) { - files.push(fullPath); - } + if (!/\.(md|mdx)$/.test(entry.name)) continue; + if (collect ? !collect.has(entry.name) : exclude.has(entry.name)) continue; + files.push(fullPath); } return files; } @@ -566,7 +652,10 @@ export function walk(dir, files = [], exclude = EMPTY_EXCLUDE) { * Expands the leftmost wildcard segment of a scan root into one path per * directory at that level, sorted so the scan order — and therefore the report * order — does not depend on the filesystem's (objectui#3622). `collectFiles` - * recurses, so a later wildcard segment expands on the next pass. + * recurses, so a later wildcard segment expands on the next pass — which is + * how the two-wildcard rows added by objectui#6026 come to mean "every + * subdirectory of every package/app", and therefore why a `README.md` sitting + * at a package's top level is outside those rows by construction. * * Deliberately not a glob library: a whole segment that is exactly `*`, and * nothing else. Anything richer THROWS rather than quietly matching nothing, @@ -605,20 +694,26 @@ function expandWildcard(pattern) { * * `exclude` (objectui#4938) names BASENAMES to leave out at every depth under a * directory root — `README.md` and `CHANGELOG.md` for the two package/app - * "everything else" rows below, so a directory walk can be added on top of the - * single-file README row it duplicates without re-judging the same file twice - * or pulling in a nested `README.md` this card never measured (see the - * header). It has no effect on a single-file root: a row names its own file - * outright, not by basename, so there is nothing for `exclude` to filter. + * "everything else" rows, so a directory walk can be added on top of the + * single-file README row it duplicates without re-judging the same file twice. + * + * `collect` (objectui#6026) is its inverse: the only basenames to KEEP, again + * at every depth — `README.md` for the two nested-README rows, whose paths + * carry a second wildcard segment so the top-level README the earlier rows own + * is not underneath them to begin with. + * + * Neither has any effect on a single-file root: a row names its own file + * outright, not by basename, so there is nothing to filter. */ -export function collectFiles(root, exclude = EMPTY_EXCLUDE) { - if (root.includes('*')) return expandWildcard(root).flatMap((expanded) => collectFiles(expanded, exclude)); +export function collectFiles(root, exclude = EMPTY_EXCLUDE, collect = null) { + if (collect && exclude.size > 0) throw new Error(`${BOTH_FILTERS}, and got both for "${root}".`); + if (root.includes('*')) return expandWildcard(root).flatMap((expanded) => collectFiles(expanded, exclude, collect)); try { if (statSync(root).isFile()) return /\.(md|mdx)$/.test(root) ? [root] : []; } catch { return []; } - return walk(root, [], exclude); + return walk(root, [], exclude, collect); } /** @@ -947,7 +1042,8 @@ export function collectBrokenLinks(repoRoot) { for (const scanRoot of SCAN_ROOTS) { const exclude = scanRoot.exclude ? new Set(scanRoot.exclude) : undefined; - for (const file of collectFiles(path.join(repoRoot, scanRoot.path), exclude)) { + const collect = scanRoot.collect ? new Set(scanRoot.collect) : undefined; + for (const file of collectFiles(path.join(repoRoot, scanRoot.path), exclude, collect)) { const source = stripCode(readFileSync(file, 'utf8')); MARKDOWN_LINK_RE.lastIndex = 0; let match; From 31e1e25576155b7a01c73b8b0ded4c87cd154295 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 06:56:48 +0000 Subject: [PATCH 2/2] test(scripts): pin the nested-README scan surface and the one-row-per-file partition Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe --- scripts/__tests__/check-doc-links.test.ts | 252 ++++++++++++++++++++-- scripts/check-doc-links.mjs | 11 + 2 files changed, 250 insertions(+), 13 deletions(-) diff --git a/scripts/__tests__/check-doc-links.test.ts b/scripts/__tests__/check-doc-links.test.ts index 0d18e79a1a..1a9e2a6c15 100644 --- a/scripts/__tests__/check-doc-links.test.ts +++ b/scripts/__tests__/check-doc-links.test.ts @@ -113,6 +113,19 @@ import { * left out (see the script's header). The live instance: a dead link in * `packages/components/src/renderers/complex/TIMELINE.md` pointing at an * example app deleted whole months earlier, fixed in the same PR as the row. + * + * objectui#6026 bought the gap objectui#4938 had measured and deliberately + * left: a `README.md` that is not at a package's top level was excluded from + * the new rows by basename at every depth, and was never in range of the exact + * top-level globs above them either, so four real files were seen by no gate. + * The last describe pins the row that takes them, and two things there are + * worth reading before editing it. First, ENTRY PRICE ZERO: the four files + * carried no dead link, so a green run proves nothing on its own — the tests + * therefore assert the SURFACE (derived from the tree, not from a list in this + * file) rather than the verdict. Second, the no-double-parse guarantee is now + * asserted repo-wide instead of argued: every file the scan opens is opened by + * exactly one row, which is what makes the new rows safe to sit alongside the + * two rows that already walk the same directories. */ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -165,6 +178,46 @@ function brokenHrefs(files: Record): string[] { return scan(repo).map((item) => item.href); } +/** A `SCAN_ROOTS` row, as this file reads it — the module is `.mjs`, so untyped. */ +interface ScanRoot { + readonly path: string; + readonly rule: string; + readonly exclude?: string[]; + readonly collect?: string[]; +} + +/** Every file the real scan opens, tagged with the row that opened it. */ +function scannedByRow(): { file: string; row: string }[] { + const opened: { file: string; row: string }[] = []; + for (const row of SCAN_ROOTS as ScanRoot[]) { + const files = collectFiles( + path.join(repoRoot, row.path), + row.exclude ? new Set(row.exclude) : undefined, + row.collect ? new Set(row.collect) : undefined, + ) as string[]; + for (const file of files) opened.push({ file: path.relative(repoRoot, file), row: row.path }); + } + return opened; +} + +/** + * The nested-README population, derived from the tree rather than listed here + * (objectui#6026). An UNFILTERED walk of the same two wildcard roots is the + * oracle: whatever markdown is really under a package or app directory, minus + * the top-level `README.md` each package row already owns — which is a path + * shape (`//README.md`), not a rule `walk()` knows. + */ +function nestedReadmes(): string[] { + const found: string[] = []; + for (const top of ['packages/*', 'apps/*']) { + for (const file of collectFiles(path.join(repoRoot, top)) as string[]) { + const segments = path.relative(repoRoot, file).split(path.sep); + if (segments[segments.length - 1] === 'README.md' && segments.length > 3) found.push(segments.join('/')); + } + } + return found.sort(); +} + afterAll(() => { for (const root of tempRoots) fs.rmSync(root, { recursive: true, force: true }); }); @@ -401,9 +454,13 @@ describe('the repo it guards', () => { // in `docs` would then leave the whole tree unopened and every test above // green. Every root carries its own floor for that reason. const scanned = Object.fromEntries( - SCAN_ROOTS.map((root: { path: string; rule: string; exclude?: string[] }) => [ + (SCAN_ROOTS as ScanRoot[]).map((root) => [ root.path, - collectFiles(path.join(repoRoot, root.path), root.exclude ? new Set(root.exclude) : undefined).length, + collectFiles( + path.join(repoRoot, root.path), + root.exclude ? new Set(root.exclude) : undefined, + root.collect ? new Set(root.collect) : undefined, + ).length, ]), ); @@ -418,6 +475,8 @@ describe('the repo it guards', () => { 'apps/*/README.md', 'packages/*', 'apps/*', + 'packages/*/*', + 'apps/*/*', 'AGENTS.md', 'CHANGELOG.md', 'CLAUDE.md', @@ -445,6 +504,15 @@ describe('the repo it guards', () => { // pick up the next file someone adds, not stay pinned to today's. expect(scanned['packages/*']).toBeGreaterThanOrEqual(12); expect(scanned['apps/*']).toBeGreaterThanOrEqual(3); + // objectui#6026. Four nested READMEs today, all under `packages/*`; the + // `apps/*` side of the pair is deliberately empty, which is why it gets NO + // count floor — a `toBe(0)` here would redden the first PR that writes a + // nested README under an app, and that PR is the row working. What keeps + // the empty row honest instead is the partition test in that card's + // describe: it derives the nested-README population from the tree and + // requires every one of them to be opened, so an `apps/*` row that stopped + // expanding would fail there the day it started mattering. + expect(scanned['packages/*/*']).toBeGreaterThanOrEqual(4); for (const rootFile of ['AGENTS.md', 'CHANGELOG.md', 'CLAUDE.md', 'LICENSE-THIRD-PARTY.md', 'QUICK_REFERENCE.md']) { expect(scanned[rootFile], `${rootFile} is a SCAN_ROOTS row that opened no file`).toBe(1); } @@ -475,6 +543,14 @@ describe('the repo it guards', () => { // of this pin too: it is what keeps the new rows from re-judging the // README rows right above them, or from silently widening onto the // per-package `CHANGELOG.md` files this card explicitly did not buy. + // + // objectui#6026 added the nested-README rows, `disk` again. Two parts of + // them are pinned here rather than left to reading: `collect` (the only + // basenames the walk keeps — the inverse of `exclude`, and the thing that + // stops these rows from becoming a second copy of the two above them), and + // the SECOND wildcard segment in each path, which is the whole reason the + // top-level README cannot be reached from these rows. A row quietly losing + // that segment would start double-parsing every package README. expect(SCAN_ROOTS).toEqual([ { path: 'content/docs', rule: 'docs' }, { path: 'examples', rule: 'disk' }, @@ -486,6 +562,8 @@ describe('the repo it guards', () => { { path: 'apps/*/README.md', rule: 'disk' }, { path: 'packages/*', rule: 'disk', exclude: ['README.md', 'CHANGELOG.md'] }, { path: 'apps/*', rule: 'disk', exclude: ['README.md', 'CHANGELOG.md'] }, + { path: 'packages/*/*', rule: 'disk', collect: ['README.md'] }, + { path: 'apps/*/*', rule: 'disk', collect: ['README.md'] }, { path: 'AGENTS.md', rule: 'disk' }, { path: 'CHANGELOG.md', rule: 'disk' }, { path: 'CLAUDE.md', rule: 'disk' }, @@ -1526,22 +1604,24 @@ describe('objectui#4938 — the rest of each package/app directory', () => { ]); }); - it('excludes README.md and CHANGELOG.md at EVERY depth, not only the top level', () => { + it('excludes CHANGELOG.md at EVERY depth, not only the top level', () => { // `exclude` (new with this row) is a basename filter `walk()` applies at // every level it recurses into, not only the directory the scan root - // itself names. A nested README.md — real on main today, e.g. - // `packages/core/src/adapters/README.md` — is therefore left alone by this - // row too: it falls outside what objectui#4938 measured ("non-README - // markdown"), a still-open gap filed as its own card rather than folded in - // here. A nested CHANGELOG.md is excluded for the same reason the - // top-level one is: neither is authored prose. + // itself names. A nested CHANGELOG.md is therefore excluded for the same + // reason the top-level one is: neither is authored prose, wherever it sits. + // + // `README.md` is on this same every-depth exclude and stays there — + // objectui#6026 did NOT narrow it. That card added rows of its own for the + // nested READMEs (last describe), so the file a nested README carries is + // judged by exactly one row, and it is not this one. The distinct hrefs + // below are what makes that visible: a rejection here names the row that + // produced it. expect( rejections({ - 'packages/core/src/adapters/README.md': '[gone](./NOWHERE.md)', - 'packages/core/src/adapters/CHANGELOG.md': '[also gone](./NOWHERE.md)', - 'packages/core/src/adapters/NOTES.md': '[also gone](./NOWHERE.md)', + 'packages/core/src/adapters/CHANGELOG.md': '[gone](./NOWHERE-changelog.md)', + 'packages/core/src/adapters/NOTES.md': '[gone](./NOWHERE-notes.md)', }), - ).toEqual([['./NOWHERE.md', 'example-relative']]); + ).toEqual([['./NOWHERE-notes.md', 'example-relative']]); }); it('does not re-report the top-level README the earlier row already scans', () => { @@ -1616,3 +1696,149 @@ describe('objectui#4938 — the rest of each package/app directory', () => { expect(decidable).toBeGreaterThanOrEqual(4); }); }); + +describe('objectui#6026 — the nested READMEs', () => { + /** + * The gap objectui#4938 measured and left open, named in its own header + * section: `exclude` drops a basename at EVERY depth, so `README.md` on the + * two rows above hid every README that is not at a package's top level — + * and the rows above THOSE are exact top-level globs, which such a file is + * not at. Four real files were therefore seen by no gate at all. + * + * Entry price was ZERO dead links (4 files, 97 markdown links, 96 decidable + * here). That makes a green run worthless as evidence on its own, and it is + * why the real-tree tests at the bottom of this describe assert the SCAN + * SURFACE — which files are opened, and by how many rows — instead of the + * verdict. The fixture tests above them are the ones that prove the rows can + * go red at all. + * + * The mechanism is `collect` (the inverse of `exclude`: the only basenames + * the walk keeps) plus a second wildcard segment in the row path. The second + * segment is what makes the no-double-parse guarantee structural: the rows + * are rooted at each package/app's SUBdirectories, so the top-level README + * the earlier row owns is not underneath them and no filter has to remember + * to leave it out. + */ + it('judges a dead link in a nested README, and accepts a live one', () => { + // The row's whole purpose, in both directions at once. + const repo = repoWith({ + 'packages/core/src/adapters/README.md': '[gone](./NOWHERE.md) and [live](./adapter.ts)', + 'packages/core/src/adapters/adapter.ts': 'export {};', + }); + + expect(scan(repo).map((item) => [path.relative(repo, item.file), item.href, item.reason])).toEqual([ + [path.join('packages', 'core', 'src', 'adapters', 'README.md'), './NOWHERE.md', 'example-relative'], + ]); + }); + + it('reaches a nested README at any depth, and under an app as well as a package', () => { + // `apps/*` has no nested README on main today, so the fixture is the only + // place that row's mechanism is exercised — buying the surface while it is + // empty is the cheapest this ever gets, and an empty row that was never + // shown to work is not a surface at all. + expect( + rejections({ + 'packages/types/src/zod/README.md': '[gone](./NOWHERE-zod.md)', + 'packages/plugin-gantt/docs/verification/deep/deeper/README.md': '[gone](./NOWHERE-deep.md)', + 'apps/console/src/pages/README.md': '[gone](./NOWHERE-app.md)', + }), + ).toEqual([ + // Scan order is SCAN_ROOTS order, then `expandWildcard()`'s sort — so + // `plugin-gantt` precedes `types`, and both precede the `apps` row. + ['./NOWHERE-deep.md', 'example-relative'], + ['./NOWHERE-zod.md', 'example-relative'], + ['./NOWHERE-app.md', 'example-relative'], + ]); + }); + + it('still does not re-report the top-level README — the second wildcard is what keeps it out', () => { + // The constraint this card must not break. `packages/*` + one more + // wildcard segment is rooted at `packages/core/src`, not `packages/core`, + // so the top-level README is outside these rows by construction rather + // than by a filter. One dead link, one report — not two. + expect( + rejections({ + 'packages/core/README.md': '[gone](./NOWHERE-top.md)', + 'packages/core/src/adapters/README.md': '[gone](./NOWHERE-nested.md)', + }), + ).toEqual([ + ['./NOWHERE-top.md', 'example-relative'], + ['./NOWHERE-nested.md', 'example-relative'], + ]); + }); + + it('takes only README.md — the other markdown beside it stays with the packages/* row', () => { + // `collect` is a whitelist, so the nested rows cannot widen onto a + // neighbouring file, and the CHANGELOG exclusion the row above owns is + // untouched by this card. Distinct hrefs so a double report would be + // visible rather than hidden behind two identical entries. + expect( + rejections({ + 'packages/core/src/adapters/README.md': '[gone](./NOWHERE-readme.md)', + 'packages/core/src/adapters/NOTES.md': '[gone](./NOWHERE-notes.md)', + 'packages/core/src/adapters/CHANGELOG.md': '[gone](./NOWHERE-changelog.md)', + }), + ).toEqual([ + ['./NOWHERE-notes.md', 'example-relative'], + ['./NOWHERE-readme.md', 'example-relative'], + ]); + }); + + it('does not walk into a dependency tree looking for READMEs', () => { + // A published dependency's own README is not ours to judge — same + // exclusion every other walk in this file applies. + expect( + rejections({ + 'packages/core/src/adapters/README.md': '[gone](./NOWHERE-ours.md)', + 'packages/core/node_modules/dep/src/README.md': '[gone](./NOWHERE-theirs.md)', + }), + ).toEqual([['./NOWHERE-ours.md', 'example-relative']]); + }); + + it('refuses a row that names both a collect list and an exclude list', () => { + // Opposite readings of one list. Guessing which was meant would narrow a + // surface silently, the one failure mode this gate must not have — same + // stance as `expandWildcard()` throwing on a partial-segment glob. + expect(() => + collectFiles(path.join(repoRoot, 'packages'), new Set(['CHANGELOG.md']), new Set(['README.md'])), + ).toThrow(/`exclude` OR `collect`/); + + expect( + (SCAN_ROOTS as ScanRoot[]).filter((row) => row.exclude && row.collect).map((row) => row.path), + 'a SCAN_ROOTS row carrying both filters has no defensible meaning', + ).toEqual([]); + }); + + it('opens every nested README that is really in the tree — the surface, not the verdict', () => { + // The assertion this card actually needs. Entry price was zero dead links, + // so the green above proves nothing on its own; what has to be true is + // that these files are now LOOKED AT. The population is derived from an + // unfiltered walk of the same roots rather than listed here, so the day + // someone writes the fifth nested README this test covers it without an + // edit — and a row that stopped expanding fails here rather than going + // quietly green. + const nested = nestedReadmes(); + const opened = new Set(scannedByRow().map((entry) => entry.file.split(path.sep).join('/'))); + + expect(nested.length, 'floor under the floor: an empty population would make the next line vacuous').toBeGreaterThanOrEqual(4); + expect(nested.filter((file) => !opened.has(file)), 'a nested README no SCAN_ROOTS row opens').toEqual([]); + }); + + it('opens every file exactly once — the rows partition the tree, they do not overlap', () => { + // The other half, and the constraint that makes it safe for three rows to + // walk the same package directory: a file opened twice is judged twice and + // reported twice. Repo-wide rather than package-only, because the cheapest + // way to break it is a new row elsewhere, not a change to these. + const rowsByFile = new Map(); + for (const entry of scannedByRow()) { + rowsByFile.set(entry.file, [...(rowsByFile.get(entry.file) ?? []), entry.row]); + } + + const twice = [...rowsByFile.entries()] + .filter(([, rows]) => rows.length > 1) + .map(([file, rows]) => `${file} <- ${rows.join(' + ')}`); + + expect(twice, 'these files are opened by more than one SCAN_ROOTS row').toEqual([]); + expect(rowsByFile.size, 'floor under the floor: nothing scanned would make the line above vacuous').toBeGreaterThanOrEqual(250); + }); +}); diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs index fc9f63569a..644aeeba59 100644 --- a/scripts/check-doc-links.mjs +++ b/scripts/check-doc-links.mjs @@ -627,6 +627,12 @@ const blank = (text) => text.replace(/[^\n]/g, ' '); * - `exclude` — the names to leave out (objectui#4938); * - `collect` — when given, the ONLY names to take, which is what makes * `exclude` meaningless alongside it. + * + * @param {string} dir the directory to walk + * @param {string[]} [files] accumulator, so the recursion has one array + * @param {Set} [exclude] basenames to leave out, at every depth + * @param {Set | null} [collect] the only basenames to take, at every depth + * @returns {string[]} */ export function walk(dir, files = [], exclude = EMPTY_EXCLUDE, collect = null) { let entries; @@ -704,6 +710,11 @@ function expandWildcard(pattern) { * * Neither has any effect on a single-file root: a row names its own file * outright, not by basename, so there is nothing to filter. + * + * @param {string} root a directory, a markdown file, or a wildcard pattern + * @param {Set} [exclude] basenames to leave out, at every depth + * @param {Set | null} [collect] the only basenames to take, at every depth + * @returns {string[]} */ export function collectFiles(root, exclude = EMPTY_EXCLUDE, collect = null) { if (collect && exclude.size > 0) throw new Error(`${BOTH_FILTERS}, and got both for "${root}".`);