From 64259473ff6df43cb0e2edefa1a30341d87284aa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 12:31:15 +0000 Subject: [PATCH 1/3] test(spec): repo-radius retirement walks read what git does not ignore The four tree-scoped absence walks (export-job family, compliance families, connector connectionTimeoutMs, currency-mode closure rule B) walked the raw filesystem, so a checkout that had built the docs read the generated, gitignored content/docs/protocol-upgrade/ pages and went red on correct sources; the publish lane's gitignored packages/spec/protocol-upgrade-guide.md reddened three of them the same way. They now take their file list from scripts/lib/repo-walk-testkit.ts: `git ls-files --cached --others --exclude-standard` per root, narrowed by each walk's unchanged skipDir rule, loud on a missing root or a git failure. The pin builds a throwaway repository carrying the real .gitignore lines, with a page naming a retired key under content/docs/protocol-upgrade/ (not listed) beside the same page where git keeps it (listed), and checks the real checkout ignores both generated outputs. Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude --- .../scripts/lib/repo-walk-testkit.test.ts | 167 ++++++++++++++++++ .../spec/scripts/lib/repo-walk-testkit.ts | 142 +++++++++++++++ .../api/export-job-family-retirement.test.ts | 40 ++--- .../currency-mode-family-closure.pin.test.ts | 21 +-- ...ctor-connection-timeout-retirement.test.ts | 45 +++-- .../compliance-families-retirement.test.ts | 49 +++-- packages/spec/vitest.repo-tests.json | 1 + 7 files changed, 377 insertions(+), 88 deletions(-) create mode 100644 packages/spec/scripts/lib/repo-walk-testkit.test.ts create mode 100644 packages/spec/scripts/lib/repo-walk-testkit.ts 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..6c69f91e9fb --- /dev/null +++ b/packages/spec/scripts/lib/repo-walk-testkit.test.ts @@ -0,0 +1,167 @@ +// 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 ("authors + * .connectionTimeoutMs"), 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 = (cwd: string, args: 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 = (cwd: string, ...args: string[]) => { + const r = git(cwd, args); + 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); + }; + + beforeAll(() => { + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'os-repo-walk-testkit-')); + mustGit(sandbox, 'init', '-q'); + // The listing runs with the caller's own git config; a personal global + // excludes file must not decide what this sandbox ignores. + mustGit(sandbox, 'config', 'core.excludesFile', '/dev/null'); + // 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(sandbox, 'add', '-A'); + mustGit(sandbox, 'add', '-f', 'content/generated/forced.md'); + mustGit(sandbox, 'commit', '-q', '-m', 'fixture'); + 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 = listRepoWalkFiles(sandbox, ['content', 'packages'], { skipDir: (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(() => listRepoWalkFiles(sandbox, ['content/no-such-root'])).toThrow(/ENOENT/); + expect(() => listRepoWalkFiles(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(() => listRepoWalkFiles(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(REPO_ROOT, ['check-ignore', '-q', '--', rel], 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(REPO_ROOT, ['content']); + 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..b18aba61bd1 --- /dev/null +++ b/packages/spec/scripts/lib/repo-walk-testkit.ts @@ -0,0 +1,142 @@ +// 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 + * + * Git answers a pathspec that matches nothing with nothing, at exit 0, so a + * renamed root would silently cover nothing. Each root is therefore required to + * exist as a directory (the `readdirSync` it replaces threw on a missing root + * the same way), and any non-zero git exit throws with git's own message — + * ⛔ never an empty answer, which would read as a clean tree. + * + * 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 `repoRoot` names. + * + * 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'] as const; + +export interface RepoWalkOptions { + /** + * A directory BELOW a root 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 root's own path segments are never judged. + */ + skipDir?: (name: string) => boolean; +} + +/** + * Every file under `roots` that git does not ignore, as `/`-separated paths + * relative to `repoRoot`, root by root, each root's files sorted. + * + * @param repoRoot the checkout's top level; every root is resolved against it. + * @param roots repo-relative directories (`'content'`, `'packages/spec/src'`). + */ +export function listRepoWalkFiles( + repoRoot: string, + roots: readonly string[], + options: RepoWalkOptions = {}, +): string[] { + const skipDir = options.skipDir ?? (() => false); + const files: string[] = []; + for (const root of roots) { + const rootDir = path.join(repoRoot, root); + if (!fs.statSync(rootDir).isDirectory()) { + throw new Error(`listRepoWalkFiles: walk root '${root}' is not a directory under ${repoRoot}`); + } + const listing = spawnSync('git', [...GIT_VISIBLE_LISTING, '--', root], { + cwd: repoRoot, + 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(' ')} -- ${root}\` in ${repoRoot} exited ` + + `${listing.status}: ${listing.stderr.trim()}`, + ); + } + const prefix = `${root.split(path.sep).join('/').replace(/\/+$/, '')}/`; + const listed = [...new Set(listing.stdout.split('\0').filter(Boolean))].sort(); + for (const rel of listed) { + if (!rel.startsWith(prefix)) { + throw new Error(`listRepoWalkFiles: git listed '${rel}' for walk root '${root}', which does not contain it`); + } + if (rel.slice(prefix.length).split('/').slice(0, -1).some(skipDir)) continue; + let stat: fs.Stats; + try { + stat = fs.lstatSync(path.join(repoRoot, rel)); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') continue; + throw err; + } + // `isFile()` is false for a symlink, as `Dirent.isFile()` was in the walk this replaces. + if (stat.isFile()) files.push(rel); + } + } + 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..b13a5ba135f 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,22 @@ 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); - 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 - if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; - if (TSUP_BUNDLED_CONFIG.test(entry.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)); + // 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 rel of listRepoWalkFiles(REPO_ROOT, WALK_ROOTS, { skipDir })) { + const name = path.posix.basename(rel); + 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(name)) continue; + visited += 1; + const text = readIfPresent(path.join(REPO_ROOT, rel)); + if (text === undefined) continue; + const m = REFERENCE.exec(text); + if (m) offenders.push(`${rel} references \`${m[0].trim()}\``); + } // 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..9b716f5b7fe 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,11 @@ function inCorpusA(rel: string): boolean { return (rel.startsWith('skills/') && rel.endsWith('.md')) || (rel.startsWith('content/docs/') && rel.endsWith('.mdx')); } -const ALL_FILES: string[] = []; -for (const root of WALK_ROOTS) walk(root, ALL_FILES); +// 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[] = listRepoWalkFiles(REPO_ROOT, WALK_ROOTS, { skipDir: (name) => SKIPPED_DIRS.has(name) }); 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..7fc58474a2a 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,9 @@ 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 +459,22 @@ 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); - 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 - if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; - if (TSUP_BUNDLED_CONFIG.test(entry.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)); + // 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 rel of listRepoWalkFiles(REPO_ROOT, WALK_ROOTS, { skipDir })) { + const name = path.posix.basename(rel); + 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(name)) continue; + visited += 1; + const text = readIfPresent(path.join(REPO_ROOT, rel), rel); + if (text === undefined) continue; + const m = AUTHORING.exec(stripInlineCode(text)); + if (m) offenders.push(`${rel} authors \`${m[0].trim()}\``); + } // 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..ccd845d6893 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,22 @@ 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); - 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 - if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; - if (TSUP_BUNDLED_CONFIG.test(entry.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)); + // 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 rel of listRepoWalkFiles(REPO_ROOT, WALK_ROOTS, { skipDir })) { + const name = path.posix.basename(rel); + 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(name)) continue; + visited += 1; + const text = readIfPresent(path.join(REPO_ROOT, rel), rel); + if (text === undefined) continue; + const m = REFERENCE.exec(text); + if (m) offenders.push(`${rel} references \`${m[0].trim()}\``); + } // 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", From d05b63df11b184ccc813c9b2b1fec4982b3b0c08 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 12:42:22 +0000 Subject: [PATCH 2/3] test(spec): the walk pin names the retired key only in assembled form The connector walk reads every .ts under packages/, so the pin's own comment spelling the member read made the pin its offender. Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude --- packages/spec/scripts/lib/repo-walk-testkit.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/spec/scripts/lib/repo-walk-testkit.test.ts b/packages/spec/scripts/lib/repo-walk-testkit.test.ts index 6c69f91e9fb..fe47136b1f6 100644 --- a/packages/spec/scripts/lib/repo-walk-testkit.test.ts +++ b/packages/spec/scripts/lib/repo-walk-testkit.test.ts @@ -55,9 +55,9 @@ const RETIRED_KEY = ['connector', 'connectionTimeoutMs'].join('.'); /** * THE FIXTURE — the generated protocol-18 page's line that turned - * `integration/connector-connection-timeout-retirement` red ("authors - * .connectionTimeoutMs"), excerpted. The key sits outside inline code, which - * is the shape that walk flags. + * `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', From 821bdf26252f8a632a86c5e32cf1b208b74b61bd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 12:49:35 +0000 Subject: [PATCH 3/3] test(spec): the walk helper takes one directory, so the inputs gate folds its roots check:cross-package-test-inputs reads a walk's roots from the first argument of the descent call. listRepoWalkFiles(REPO_ROOT, WALK_ROOTS) read as a walk from the repo root and went red; each walk now keeps its for-root-of-WALK_ROOTS loop over path.join(REPO_ROOT, root), and the helper lists one directory with git's cwd there, returning absolute paths. Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude --- .../scripts/lib/repo-walk-testkit.test.ts | 35 +++--- .../spec/scripts/lib/repo-walk-testkit.ts | 102 +++++++++--------- .../api/export-job-family-retirement.test.ts | 25 +++-- .../currency-mode-family-closure.pin.test.ts | 7 +- ...ctor-connection-timeout-retirement.test.ts | 28 ++--- .../compliance-families-retirement.test.ts | 25 +++-- 6 files changed, 120 insertions(+), 102 deletions(-) diff --git a/packages/spec/scripts/lib/repo-walk-testkit.test.ts b/packages/spec/scripts/lib/repo-walk-testkit.test.ts index fe47136b1f6..2df00c2aa5e 100644 --- a/packages/spec/scripts/lib/repo-walk-testkit.test.ts +++ b/packages/spec/scripts/lib/repo-walk-testkit.test.ts @@ -70,13 +70,13 @@ const FIXTURE = [ 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 = (cwd: string, args: string[], env: NodeJS.ProcessEnv = HERMETIC_ENV) => { +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 = (cwd: string, ...args: string[]) => { - const r = git(cwd, args); +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}`); }; @@ -86,13 +86,18 @@ describe('repo-walk-testkit — a repo-radius walk reads what git does not ignor 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(sandbox, 'init', '-q'); + 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(sandbox, 'config', 'core.excludesFile', '/dev/null'); + 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`); @@ -101,9 +106,9 @@ describe('repo-walk-testkit — a repo-radius walk reads what git does not ignor 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(sandbox, 'add', '-A'); - mustGit(sandbox, 'add', '-f', 'content/generated/forced.md'); - mustGit(sandbox, 'commit', '-q', '-m', 'fixture'); + 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 @@ -123,7 +128,7 @@ describe('repo-walk-testkit — a repo-radius walk reads what git does not ignor // 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 = listRepoWalkFiles(sandbox, ['content', 'packages'], { skipDir: (name) => name === 'dist' }); + 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 @@ -137,22 +142,22 @@ describe('repo-walk-testkit — a repo-radius walk reads what git does not ignor }); it('loud, never empty: a missing root, a root that is a file, and a tree git cannot read all throw', () => { - expect(() => listRepoWalkFiles(sandbox, ['content/no-such-root'])).toThrow(/ENOENT/); - expect(() => listRepoWalkFiles(sandbox, ['packages/spec/README.md'])).toThrow(/not a directory/); + 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(() => listRepoWalkFiles(broken, ['content'])).toThrow(/exited 128/); + 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(REPO_ROOT, ['check-ignore', '-q', '--', rel], gitFreeEnv()).status; + 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. @@ -160,7 +165,9 @@ describe('repo-walk-testkit — a repo-radius walk reads what git does not ignor // 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(REPO_ROOT, ['content']); + 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 index b18aba61bd1..7e3d5ac986b 100644 --- a/packages/spec/scripts/lib/repo-walk-testkit.ts +++ b/packages/spec/scripts/lib/repo-walk-testkit.ts @@ -50,11 +50,12 @@ * * ## Loud, never empty * - * Git answers a pathspec that matches nothing with nothing, at exit 0, so a - * renamed root would silently cover nothing. Each root is therefore required to - * exist as a directory (the `readdirSync` it replaces threw on a missing root - * the same way), and any non-zero git exit throws with git's own message — - * ⛔ never an empty answer, which would read as a clean tree. + * 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 @@ -63,7 +64,7 @@ * * 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 `repoRoot` names. + * 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 @@ -77,66 +78,61 @@ 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'] as const; +const GIT_VISIBLE_LISTING = ['ls-files', '-z', '--cached', '--others', '--exclude-standard']; export interface RepoWalkOptions { /** - * A directory BELOW a root 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 root's own path segments are never judged. + * 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 under `roots` that git does not ignore, as `/`-separated paths - * relative to `repoRoot`, root by root, each root's files sorted. + * 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. * - * @param repoRoot the checkout's top level; every root is resolved against it. - * @param roots repo-relative directories (`'content'`, `'packages/spec/src'`). + * 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( - repoRoot: string, - roots: readonly string[], - options: RepoWalkOptions = {}, -): string[] { +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 root of roots) { - const rootDir = path.join(repoRoot, root); - if (!fs.statSync(rootDir).isDirectory()) { - throw new Error(`listRepoWalkFiles: walk root '${root}' is not a directory under ${repoRoot}`); - } - const listing = spawnSync('git', [...GIT_VISIBLE_LISTING, '--', root], { - cwd: repoRoot, - 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(' ')} -- ${root}\` in ${repoRoot} exited ` + - `${listing.status}: ${listing.stderr.trim()}`, - ); - } - const prefix = `${root.split(path.sep).join('/').replace(/\/+$/, '')}/`; - const listed = [...new Set(listing.stdout.split('\0').filter(Boolean))].sort(); - for (const rel of listed) { - if (!rel.startsWith(prefix)) { - throw new Error(`listRepoWalkFiles: git listed '${rel}' for walk root '${root}', which does not contain it`); - } - if (rel.slice(prefix.length).split('/').slice(0, -1).some(skipDir)) continue; - let stat: fs.Stats; - try { - stat = fs.lstatSync(path.join(repoRoot, rel)); - } catch (err) { - if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') continue; - throw err; - } - // `isFile()` is false for a symlink, as `Dirent.isFile()` was in the walk this replaces. - if (stat.isFile()) files.push(rel); + 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 b13a5ba135f..33407fb8525 100644 --- a/packages/spec/src/api/export-job-family-retirement.test.ts +++ b/packages/spec/src/api/export-job-family-retirement.test.ts @@ -326,17 +326,20 @@ describe('tree-scoped absence: nothing inside the declared radius references a r // 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 rel of listRepoWalkFiles(REPO_ROOT, WALK_ROOTS, { skipDir })) { - const name = path.posix.basename(rel); - 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(name)) continue; - visited += 1; - const text = readIfPresent(path.join(REPO_ROOT, 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) { + for (const full of listRepoWalkFiles(path.join(REPO_ROOT, root), { skipDir })) { + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + 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(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()}\``); + } } // Anti-vacuity: the walk really covered the tree. expect(visited).toBeGreaterThan(1000); 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 9b716f5b7fe..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 @@ -119,7 +119,12 @@ function inCorpusA(rel: string): boolean { // 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[] = listRepoWalkFiles(REPO_ROOT, WALK_ROOTS, { skipDir: (name) => SKIPPED_DIRS.has(name) }); +const ALL_FILES: string[] = []; +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 7fc58474a2a..f7c5962f28f 100644 --- a/packages/spec/src/integration/connector-connection-timeout-retirement.test.ts +++ b/packages/spec/src/integration/connector-connection-timeout-retirement.test.ts @@ -392,7 +392,8 @@ describe('tree-scoped absence: nothing inside the declared radius still authors // GITIGNORED build output (`.gitignore` line for `packages/spec/json-schema/`), // 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 + // 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 @@ -463,17 +464,20 @@ describe('tree-scoped absence: nothing inside the declared radius still authors // 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 rel of listRepoWalkFiles(REPO_ROOT, WALK_ROOTS, { skipDir })) { - const name = path.posix.basename(rel); - 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(name)) continue; - visited += 1; - const text = readIfPresent(path.join(REPO_ROOT, rel), 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) { + for (const full of listRepoWalkFiles(path.join(REPO_ROOT, root), { skipDir })) { + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + 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(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()}\``); + } } // Anti-vacuity: the walk really covered the tree. expect(visited).toBeGreaterThan(1000); diff --git a/packages/spec/src/system/compliance-families-retirement.test.ts b/packages/spec/src/system/compliance-families-retirement.test.ts index ccd845d6893..81ae759e4b5 100644 --- a/packages/spec/src/system/compliance-families-retirement.test.ts +++ b/packages/spec/src/system/compliance-families-retirement.test.ts @@ -474,17 +474,20 @@ describe('tree-scoped absence: nothing inside the declared radius references a r // 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 rel of listRepoWalkFiles(REPO_ROOT, WALK_ROOTS, { skipDir })) { - const name = path.posix.basename(rel); - 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(name)) continue; - visited += 1; - const text = readIfPresent(path.join(REPO_ROOT, rel), 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) { + for (const full of listRepoWalkFiles(path.join(REPO_ROOT, root), { skipDir })) { + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + 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(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()}\``); + } } // Anti-vacuity: the walk really covered the tree. expect(visited).toBeGreaterThan(1000);