diff --git a/packages/spec/scripts/lib/repo-walk-testkit.test.ts b/packages/spec/scripts/lib/repo-walk-testkit.test.ts new file mode 100644 index 00000000000..2df00c2aa5e --- /dev/null +++ b/packages/spec/scripts/lib/repo-walk-testkit.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { gitFreeEnv } from '../../../../scripts/git-env.mjs'; +import { listRepoWalkFiles } from './repo-walk-testkit'; + +// ─── the pin for `repo-walk-testkit.ts`: a generated page that names a retired key leaves the walks green ── +// +// The four repo-radius walks that went red on a docs-built checkout +// (`api/export-job-family-retirement`, `system/compliance-families-retirement`, +// `integration/connector-connection-timeout-retirement`, +// `data/currency-mode-family-closure.pin`) take their file list from +// `listRepoWalkFiles`. This file holds that list to "what git does not ignore". +// +// ## Why the fixture lives in a throwaway repository, not in the real tree +// +// The fixture is a page under `content/docs/protocol-upgrade/` that names a +// retired key. In THIS repository that directory cannot hold a committed file, +// measured both ways at `cb9841aa2`: +// +// - committed, it turns `check:merge-driver` red: `scripts/regen-artifacts.mjs` +// declares `content/docs/protocol-upgrade/**` untracked, and +// `scripts/git-merge-regen.mjs` answers "declared untracked, but git tracks +// 1 file(s)"; +// - present under any name the generator does not write, it breaks the docs +// build: `gen:upgrade-guide` refuses a directory holding a foreign file. +// +// So the fixture sits at the same path inside a throwaway repository whose +// `.gitignore` carries the real repository's two lines, beside the same page +// where git keeps it (the control), and the last case binds the two: the real +// checkout ignores those very paths. Same shape as `scripts/check-role-word.mjs`'s +// skipped-subtree battery: a page inside the skipped tree, the same page outside +// it, and a sibling whose name only starts with it. + +const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + +/** The docs build's generated page for protocol 18, and the publish lane's single-file copy. */ +const GUIDE_PAGE = 'content/docs/protocol-upgrade/18.md'; +const GUIDE_COPY = 'packages/spec/protocol-upgrade-guide.md'; + +/** + * The retired key, assembled at runtime: the connector walk reads every `.ts` + * under `packages/`, so spelling it whole in this file would make this file + * its offender. + */ +const RETIRED_KEY = ['connector', 'connectionTimeoutMs'].join('.'); + +/** + * THE FIXTURE — the generated protocol-18 page's line that turned + * `integration/connector-connection-timeout-retirement` red (its offender + * line read "authors" and the member read), excerpted. The key sits outside + * inline code, which is the shape that walk flags. + */ +const FIXTURE = [ + '## Protocol 17 → 18', + '', + ` - Why not automatic: ADR-0049 enforce-or-remove: retire ${RETIRED_KEY}. The spec key is tombstoned.`, + '', +].join('\n'); + +/** Fixture git only: no inherited `GIT_*`, no global or system config. */ +const HERMETIC_ENV: NodeJS.ProcessEnv = { ...gitFreeEnv(), GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' }; +const GIT_ARGS = ['-c', 'user.name=t', '-c', 'user.email=t@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'gc.auto=0']; + +const git = (args: string[], cwd: string, env: NodeJS.ProcessEnv = HERMETIC_ENV) => { + const r = spawnSync('git', [...GIT_ARGS, ...args], { cwd, encoding: 'utf8', env }); + if (r.error) throw r.error; + return r; +}; +const mustGit = (args: string[], cwd: string) => { + const r = git(args, cwd); + if (r.status !== 0) throw new Error(`git ${args.join(' ')} exited ${r.status}: ${r.stderr}`); +}; + +describe('repo-walk-testkit — a repo-radius walk reads what git does not ignore', () => { + let sandbox = ''; + const put = (rel: string, text: string) => { + fs.mkdirSync(path.dirname(path.join(sandbox, rel)), { recursive: true }); + fs.writeFileSync(path.join(sandbox, rel), text); + }; + /** The listing for each root, as `/`-separated paths relative to `base`. */ + const listUnder = (base: string, roots: string[], skipDir?: (name: string) => boolean) => + roots.flatMap((root) => + listRepoWalkFiles(path.join(base, root), { skipDir }).map((full) => path.relative(base, full).split(path.sep).join('/')), + ); + + beforeAll(() => { + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'os-repo-walk-testkit-')); + mustGit(['init', '-q'], sandbox); + // The listing runs with the caller's own git config; a personal global + // excludes file must not decide what this sandbox ignores. + mustGit(['config', 'core.excludesFile', '/dev/null'], sandbox); + // The real repository's two lines for the guide's generated outputs, plus one ignored tree to force-add into. + put('.gitignore', `${path.posix.dirname(GUIDE_PAGE)}/\n${GUIDE_COPY}\ngenerated/\n`); + + put('content/docs/guides/upgrade.md', FIXTURE); // the control: the same page where git keeps it + put('packages/spec/README.md', FIXTURE); // the control beside the single-file copy + put('content/docs/dist/bundle.md', FIXTURE); // tracked, under a directory the caller skips by name + put('content/docs/deleted.md', FIXTURE); // tracked, then deleted from the work tree + put('content/generated/forced.md', FIXTURE); // tracked although its directory is ignored + mustGit(['add', '-A'], sandbox); + mustGit(['add', '-f', 'content/generated/forced.md'], sandbox); + mustGit(['commit', '-q', '-m', 'fixture'], sandbox); + fs.rmSync(path.join(sandbox, 'content/docs/deleted.md')); + + put(GUIDE_PAGE, FIXTURE); // THE FIXTURE: generated, ignored, never committed + put(GUIDE_COPY, FIXTURE); // the publish lane's copy, ignored the same way + put('content/docs/protocol-upgrade-draft/18.md', FIXTURE); // new, not ignored, not yet added + }); + + afterAll(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it('the fixture is not inert: it names the retired key outside inline code', () => { + expect(FIXTURE.replace(/`[^`\n]*`/g, '')).toContain(RETIRED_KEY); + }); + + it('a page naming a retired key under the gitignored upgrade-guide tree is not listed; the same page is wherever git keeps it', () => { + // Positive control: the excluded pages are ON DISK, so their absence below is the ignore working. + for (const rel of [GUIDE_PAGE, GUIDE_COPY]) expect(fs.readFileSync(path.join(sandbox, rel), 'utf8')).toBe(FIXTURE); + + const listed = listUnder(sandbox, ['content', 'packages'], (name) => name === 'dist'); + expect(listed).toEqual([ + 'content/docs/guides/upgrade.md', // tracked: the control + // untracked and NOT ignored: a page not yet added is still judged, and the + // exclusion is a path, never a string prefix of `protocol-upgrade` + 'content/docs/protocol-upgrade-draft/18.md', + 'content/generated/forced.md', // tracked wins over `.gitignore`, as in CI's checkout + 'packages/spec/README.md', // tracked: the control beside the single-file copy + ]); + // Left out, each for its own reason: GUIDE_PAGE and GUIDE_COPY are ignored, + // `content/docs/dist/` is skipped by name, `content/docs/deleted.md` is gone. + }); + + it('loud, never empty: a missing root, a root that is a file, and a tree git cannot read all throw', () => { + expect(() => listUnder(sandbox, ['content/no-such-root'])).toThrow(/ENOENT/); + expect(() => listUnder(sandbox, ['packages/spec/README.md'])).toThrow(/not a directory/); + // A `.git` file pointing nowhere: git refuses (exit 128), whatever encloses the temp dir. + const broken = fs.mkdtempSync(path.join(os.tmpdir(), 'os-repo-walk-testkit-broken-')); + try { + fs.mkdirSync(path.join(broken, 'content')); + fs.writeFileSync(path.join(broken, 'content', 'page.md'), FIXTURE); + fs.writeFileSync(path.join(broken, '.git'), `gitdir: ${path.join(broken, 'missing')}\n`); + expect(() => listUnder(broken, ['content'])).toThrow(/exited 128/); + } finally { + fs.rmSync(broken, { recursive: true, force: true }); + } + }); + + it('the real checkout ignores the guide\'s two generated outputs, and keeps an authored docs page', () => { + const ignored = (rel: string) => git(['check-ignore', '-q', '--', rel], REPO_ROOT, gitFreeEnv()).status; + expect(ignored(GUIDE_PAGE), `${GUIDE_PAGE} must be gitignored here`).toBe(0); + expect(ignored(GUIDE_COPY), `${GUIDE_COPY} must be gitignored here`).toBe(0); + // Control: a tracked, authored page answers "not ignored", so the two zeros above are not a blanket answer. + expect(ignored('content/docs/meta.json')).toBe(1); + // Live on a checkout that has built the docs, vacuous on a fresh one (the + // sandbox cases above are what can fail everywhere): nothing listed lies + // under the generated tree, and the listing reaches the docs tree at all. + const listed = listRepoWalkFiles(path.join(REPO_ROOT, 'content')).map((full) => + path.relative(REPO_ROOT, full).split(path.sep).join('/'), + ); + expect(listed).toContain('content/docs/meta.json'); + expect(listed.filter((rel) => rel.startsWith(`${path.posix.dirname(GUIDE_PAGE)}/`))).toEqual([]); + }); +}); diff --git a/packages/spec/scripts/lib/repo-walk-testkit.ts b/packages/spec/scripts/lib/repo-walk-testkit.ts new file mode 100644 index 00000000000..7e3d5ac986b --- /dev/null +++ b/packages/spec/scripts/lib/repo-walk-testkit.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * repo-walk-testkit.ts — the file list a repo-radius tree walk reads. + * + * The retirement pins in `src/` end in a "tree-scoped absence" leg: they walk + * whole repo roots (`packages`, `examples`, `skills`, `content`, `scripts`, or + * a subset) and fail on any surviving reference to what was retired. Each one + * used to walk the FILESYSTEM with its own `readdirSync` recursion, so it read + * whatever happened to be on disk — including generated output that `.gitignore` + * keeps out of git, and that CI's checkout therefore never holds. + * + * ## The measured wrong answer this closes + * + * `@objectstack/docs`' build runs `gen:upgrade-guide`, which writes the + * protocol upgrade guide into `content/docs/protocol-upgrade/` (gitignored). The + * guide names the keys and defs a major retired, because that is its job. On a + * checkout that had built the docs, four walks went red on that page with no + * source file wrong — `api/export-job-family-retirement`, + * `system/compliance-families-retirement`, + * `integration/connector-connection-timeout-retirement` and + * `data/currency-mode-family-closure.pin` (its rule B) — while CI stayed green + * over the same commit. The single-file copy the publish lane writes, + * `packages/spec/protocol-upgrade-guide.md` (gitignored too), reddened three of + * the same four the same way. A local verdict that differs from CI's costs a dev + * a round chasing a defect that does not exist. + * + * ## The reading: what git does not ignore + * + * `git ls-files --cached --others --exclude-standard` — every tracked file, plus + * every untracked file that is NOT ignored. That is exactly what `git add -A` + * would commit, so it is exactly what can reach CI: + * + * - a tracked file is listed whatever `.gitignore` says (CI checks it out); + * - a new file not yet added is still listed, so a dev's own fresh page is + * judged before it is pushed, as it was by the filesystem walk; + * - generated, gitignored output is never listed — the upgrade guide's pages, + * its single-file copy, `json-schema/`, tsup's `*.bundled_*.mjs`, and + * whatever the next generator writes under an ignored path — with no list + * of names here to keep in step with `.gitignore`. `.gitignore` is the one + * register of "generated, not authored", and turbo's input hashing already + * reads the tree through it, so the walk and its cache key now agree. + * + * ⛔ It narrows and never widens: each caller's `skipDir` rule (its old + * `SKIPPED_DIRS`, plus dot-directories where it skipped those) still applies to + * every directory below a root, so a tracked file the filesystem walk never + * entered stays out. Measured at `cb9841aa2` over the five-root radius, on a + * built checkout: the git reading added 0 files and dropped 1,537, every one + * ignored (`packages/spec/json-schema/` and the generated migration registry). + * + * ## Loud, never empty + * + * An empty listing reads as a clean tree, so nothing here may produce one by + * accident: the walked directory must exist as a directory (the `readdirSync` + * it replaces threw on a missing root the same way, and a renamed root must not + * quietly cover nothing), and any non-zero git exit — outside a checkout, or a + * `.git` pointing nowhere — throws with git's own message. ⛔ Never an empty + * answer in place of an error. + * + * A path git lists that is gone from disk (a tracked file deleted in the work + * tree, or one removed between the listing and the stat) cannot hold a + * surviving reference, so it is left out; every other stat failure is + * re-raised. + * + * The git child runs with `gitFreeEnv()` (scripts/git-env.mjs): under a git + * hook the inherited `GIT_DIR` / `GIT_INDEX_FILE` outrank `cwd`, and the listing + * must describe the checkout the walked directory lies in. + * + * It lives in `scripts/lib/` for the reason `export-origins-testkit.ts` records: + * the BUILD config compiles `src/` as CommonJS, and a shared test helper there + * would enter the emitting program. The pin is `repo-walk-testkit.test.ts`. + */ + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { gitFreeEnv } from '../../../../scripts/git-env.mjs'; + +/** Tracked files, plus untracked files git does not ignore — what `git add -A` would commit. */ +const GIT_VISIBLE_LISTING = ['ls-files', '-z', '--cached', '--others', '--exclude-standard']; + +export interface RepoWalkOptions { + /** + * A directory BELOW the walked directory whose subtree the walk never enters, + * judged by the directory's own name at any depth — the filesystem walk's + * `SKIPPED_DIRS` rule, unchanged. The walked directory's own path is never judged. + */ + skipDir?: (name: string) => boolean; +} + +/** + * Every file below `dir` that git does not ignore, as absolute paths in the + * order of their path below `dir` — the files a `readdirSync` descent from + * `dir` would reach, minus what `.gitignore` keeps out of git. + * + * Takes ONE directory, the way the descent it replaces did, so a caller keeps + * its `for (const root of WALK_ROOTS)` loop over `path.join(REPO_ROOT, root)`: + * that is the spelling `check:cross-package-test-inputs` folds into the walked + * roots it holds against the declared globs. + */ +export function listRepoWalkFiles(dir: string, options: RepoWalkOptions = {}): string[] { + const skipDir = options.skipDir ?? (() => false); + if (!fs.statSync(dir).isDirectory()) { + throw new Error(`listRepoWalkFiles: ${dir} is not a directory`); + } + // With `cwd` inside the checkout and no pathspec, git lists the subtree below + // `cwd`, each path relative to it. + const listing = spawnSync('git', GIT_VISIBLE_LISTING, { + cwd: dir, + encoding: 'utf8', + env: gitFreeEnv(), + maxBuffer: 1 << 28, + }); + if (listing.error) throw listing.error; + if (listing.status !== 0) { + throw new Error( + `listRepoWalkFiles: \`git ${GIT_VISIBLE_LISTING.join(' ')}\` in ${dir} exited ` + + `${listing.status}: ${listing.stderr.trim()}`, + ); + } + const files: string[] = []; + for (const rel of [...new Set(listing.stdout.split('\0').filter(Boolean))].sort()) { + if (rel.split('/').slice(0, -1).some(skipDir)) continue; + const full = path.join(dir, rel); + let stat: fs.Stats; + try { + stat = fs.lstatSync(full); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') continue; + throw err; + } + // `isFile()` is false for a symlink and for a nested repository's directory + // entry, as `Dirent.isFile()` was in the walk this replaces. + if (stat.isFile()) files.push(full); + } + return files; +} diff --git a/packages/spec/src/api/export-job-family-retirement.test.ts b/packages/spec/src/api/export-job-family-retirement.test.ts index 7b7894fcbb6..33407fb8525 100644 --- a/packages/spec/src/api/export-job-family-retirement.test.ts +++ b/packages/spec/src/api/export-job-family-retirement.test.ts @@ -11,6 +11,7 @@ import { exportNamesOf, holdersOf, } from '../../scripts/lib/export-origins-testkit'; +import { listRepoWalkFiles } from '../../scripts/lib/repo-walk-testkit'; import { CONVERSIONS_BY_MAJOR } from '../conversions/registry'; import { MIGRATIONS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR } from '../migrations/registry'; @@ -321,29 +322,25 @@ describe('tree-scoped absence: nothing inside the declared radius references a r it('no reference survives inside the declared radius outside the retirement kit', () => { const offenders: string[] = []; let visited = 0; - const walk = (dir: string) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); + // What git does not ignore, never the raw filesystem: generated, gitignored + // output (the docs build's upgrade guide names every retired def) is not a + // source and never reaches CI's checkout (`scripts/lib/repo-walk-testkit.ts`). + const skipDir = (name: string) => SKIPPED_DIRS.has(name) || name.startsWith('.'); + for (const root of WALK_ROOTS) { + for (const full of listRepoWalkFiles(path.join(REPO_ROOT, root), { skipDir })) { const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); - if (entry.isDirectory()) { - if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; - walk(full); - continue; - } - if (!entry.isFile()) continue; - const ext = path.extname(entry.name); - if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) continue; - if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + const name = path.basename(full); + if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(path.extname(name))) continue; + if (name === 'CHANGELOG.md') continue; // release prose records the removal if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; - if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + if (TSUP_BUNDLED_CONFIG.test(name)) continue; visited += 1; const text = readIfPresent(full); if (text === undefined) continue; const m = REFERENCE.exec(text); if (m) offenders.push(`${rel} references \`${m[0].trim()}\``); } - }; - for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + } // Anti-vacuity: the walk really covered the tree. expect(visited).toBeGreaterThan(1000); expect(offenders, 'a reference to a retired name means the retirement is being undone — nothing served, bound or consumed the family').toEqual([]); diff --git a/packages/spec/src/data/currency-mode-family-closure.pin.test.ts b/packages/spec/src/data/currency-mode-family-closure.pin.test.ts index c08028acff1..cb044c12c0c 100644 --- a/packages/spec/src/data/currency-mode-family-closure.pin.test.ts +++ b/packages/spec/src/data/currency-mode-family-closure.pin.test.ts @@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import { listRepoWalkFiles } from '../../scripts/lib/repo-walk-testkit'; + // ─── [#20126] the currency-mode family: its one enumerating closure pin ──────── // // The contract. `CurrencyConfigSchema` (`./field.zod.ts`) declares @@ -100,18 +102,6 @@ const EXAMPLES_NON_CODE = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); const EXAMPLES_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; const RELEASES = 'content/docs/releases/'; -function walk(rel: string, out: string[]): void { - const abs = path.join(REPO_ROOT, rel); - for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { - const child = `${rel}/${entry.name}`; - if (entry.isDirectory()) { - if (!SKIPPED_DIRS.has(entry.name)) walk(child, out); - } else if (entry.isFile()) { - out.push(child); - } - } -} - function inCorpusB(rel: string): boolean { const ext = path.extname(rel); if (!TEXT_EXT.has(ext)) return false; @@ -125,8 +115,16 @@ function inCorpusA(rel: string): boolean { return (rel.startsWith('skills/') && rel.endsWith('.md')) || (rel.startsWith('content/docs/') && rel.endsWith('.mdx')); } +// What git does not ignore, never the raw filesystem: the docs build's +// gitignored upgrade guide summarises a whole major in one paragraph, where the +// date record and a currency change share a line, and CI's checkout never holds +// it (`scripts/lib/repo-walk-testkit.ts`). const ALL_FILES: string[] = []; -for (const root of WALK_ROOTS) walk(root, ALL_FILES); +for (const root of WALK_ROOTS) { + for (const full of listRepoWalkFiles(path.join(REPO_ROOT, root), { skipDir: (name) => SKIPPED_DIRS.has(name) })) { + ALL_FILES.push(path.relative(REPO_ROOT, full).split(path.sep).join('/')); + } +} const CORPUS_A = ALL_FILES.filter(inCorpusA); const CORPUS_B = ALL_FILES.filter(inCorpusB); const read = (rel: string): string => fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); diff --git a/packages/spec/src/integration/connector-connection-timeout-retirement.test.ts b/packages/spec/src/integration/connector-connection-timeout-retirement.test.ts index 7a020825be6..f7c5962f28f 100644 --- a/packages/spec/src/integration/connector-connection-timeout-retirement.test.ts +++ b/packages/spec/src/integration/connector-connection-timeout-retirement.test.ts @@ -69,6 +69,7 @@ import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; import { MIGRATIONS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; import { ObjectStackSchema } from '../stack.zod'; import { ConnectorSchema, DeclarativeConnectorEntrySchema, type Connector } from './connector.zod'; +import { listRepoWalkFiles } from '../../scripts/lib/repo-walk-testkit'; /** A well-formed catalog descriptor — every required key, none of the retired one. */ const WELL_FORMED = { @@ -389,8 +390,10 @@ describe('tree-scoped absence: nothing inside the declared radius still authors 'content/docs/releases/', '.changeset/', // GITIGNORED build output (`.gitignore` line for `packages/spec/json-schema/`), - // reached only because this is a FILESYSTEM walk rather than a git walk. - // `retiredKey()` emits the tombstoned property into the generated JSON + // reached while this was a FILESYSTEM walk; the git listing the walk now + // reads (`scripts/lib/repo-walk-testkit.ts`) leaves it out, and the entry + // stays because its reason is the CONTENT, not the ignore. `retiredKey()` + // emits the tombstoned property into the generated JSON // Schema, exactly as it already does for the two retired siblings on this // same schema — measured: `Connector.json` carries `rateLimitConfig` and // `errorMapping` the same way. Excluding it costs no coverage: the source @@ -457,29 +460,25 @@ describe('tree-scoped absence: nothing inside the declared radius still authors it('no authoring survives inside the declared radius outside the retirement kit', () => { const offenders: string[] = []; let visited = 0; - const walk = (dir: string) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); + // What git does not ignore, never the raw filesystem: generated, gitignored + // output (the docs build's upgrade guide names the retired key) is not a + // source and never reaches CI's checkout (`scripts/lib/repo-walk-testkit.ts`). + const skipDir = (name: string) => SKIPPED_DIRS.has(name) || name.startsWith('.'); + for (const root of WALK_ROOTS) { + for (const full of listRepoWalkFiles(path.join(REPO_ROOT, root), { skipDir })) { const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); - if (entry.isDirectory()) { - if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; - walk(full); - continue; - } - if (!entry.isFile()) continue; - const ext = path.extname(entry.name); - if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) continue; - if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + const name = path.basename(full); + if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(path.extname(name))) continue; + if (name === 'CHANGELOG.md') continue; // release prose records the removal if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; - if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + if (TSUP_BUNDLED_CONFIG.test(name)) continue; visited += 1; const text = readIfPresent(full, rel); if (text === undefined) continue; const m = AUTHORING.exec(stripInlineCode(text)); if (m) offenders.push(`${rel} authors \`${m[0].trim()}\``); } - }; - for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + } // Anti-vacuity: the walk really covered the tree. expect(visited).toBeGreaterThan(1000); expect(offenders, 'an authoring of the retired key means the retirement is being undone').toEqual([]); diff --git a/packages/spec/src/system/compliance-families-retirement.test.ts b/packages/spec/src/system/compliance-families-retirement.test.ts index f350b5038cd..81ae759e4b5 100644 --- a/packages/spec/src/system/compliance-families-retirement.test.ts +++ b/packages/spec/src/system/compliance-families-retirement.test.ts @@ -11,6 +11,7 @@ import { exportNamesOf, holdersOf, } from '../../scripts/lib/export-origins-testkit'; +import { listRepoWalkFiles } from '../../scripts/lib/repo-walk-testkit'; import { MIGRATIONS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; // ─── [#15513] the incident-response, training and change-management families are RETIRED WHOLE ── @@ -376,9 +377,12 @@ describe('tree-scoped absence: nothing inside the declared radius references a r * tsup bundles `tsup.config.ts` to `tsup.config.bundled_.mjs` beside * it, loads it, and deletes it. `.gitignore` already declares the class * (`*.bundled_*.mjs`), so it is not an authored source and never was in this - * pin's radius — but the walk is a FILESYSTEM walk, not a git walk, so it - * enumerated it anyway. That cost two different wrong answers, both - * non-deterministic and neither about a retirement: + * pin's radius — but the walk was then a FILESYSTEM walk, not a git walk, so + * it enumerated it anyway. (It now lists only what git does not ignore — + * `scripts/lib/repo-walk-testkit.ts` — which leaves the bundle out too; this + * exclusion stays as the narrow, tested guard for a name.) That cost two + * different wrong answers, both non-deterministic and neither about a + * retirement: * * - a CRASH. `test:repo` dependsOn `["^build"]` — UPSTREAM builds only, never * its own package's — so `@objectstack/spec#build` runs CONCURRENTLY with @@ -466,29 +470,25 @@ describe('tree-scoped absence: nothing inside the declared radius references a r it('no reference survives inside the declared radius outside the retirement kit', () => { const offenders: string[] = []; let visited = 0; - const walk = (dir: string) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); + // What git does not ignore, never the raw filesystem: generated, gitignored + // output (the docs build's upgrade guide names every retired def) is not a + // source and never reaches CI's checkout (`scripts/lib/repo-walk-testkit.ts`). + const skipDir = (name: string) => SKIPPED_DIRS.has(name) || name.startsWith('.'); + for (const root of WALK_ROOTS) { + for (const full of listRepoWalkFiles(path.join(REPO_ROOT, root), { skipDir })) { const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); - if (entry.isDirectory()) { - if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; - walk(full); - continue; - } - if (!entry.isFile()) continue; - const ext = path.extname(entry.name); - if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) continue; - if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + const name = path.basename(full); + if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(path.extname(name))) continue; + if (name === 'CHANGELOG.md') continue; // release prose records the removal if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; - if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + if (TSUP_BUNDLED_CONFIG.test(name)) continue; visited += 1; const text = readIfPresent(full, rel); if (text === undefined) continue; const m = REFERENCE.exec(text); if (m) offenders.push(`${rel} references \`${m[0].trim()}\``); } - }; - for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + } // Anti-vacuity: the walk really covered the tree. expect(visited).toBeGreaterThan(1000); expect(offenders, 'a reference to a retired name means the retirement is being undone — the three families were retired whole, none roadmapped').toEqual([]); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 270b7ba9126..caf671b02a5 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -9,6 +9,7 @@ "scripts/escape-mdx.test.ts", "scripts/export-list.test.ts", "scripts/file-description.test.ts", + "scripts/lib/repo-walk-testkit.test.ts", "scripts/liveness/evidence.test.ts", "scripts/liveness/proof-registry.test.ts", "scripts/publish-smoke-boot-failure.test.ts",