Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions packages/spec/scripts/lib/repo-walk-testkit.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
138 changes: 138 additions & 0 deletions packages/spec/scripts/lib/repo-walk-testkit.ts
Original file line number Diff line number Diff line change
@@ -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;
}
27 changes: 12 additions & 15 deletions packages/spec/src/api/export-job-family-retirement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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([]);
Expand Down
Loading
Loading