import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; /** * THE CORPUS IS REQUIRED HERE, AND CI DOES NOT RUN THESE AT ALL (2026-08-07). * * Nine spec files assert against the real templates corpus, which lives in a **different repository**. Each * carried its own copy of the same guard, keyed on `process.env.CI`: * * if (process.env.CI) throw new Error('No checkout at /config/workspaces/templates…'); * * Backwards on both halves. A runner checks out one repository, so CI is the one place the corpus can never * be — and the first release tag paid for it: eight suites threw at module load and `yarn test` could not pass * anywhere. Meanwhile a developer, who does have the checkout, was the one permitted to skip. * * The rule, as asked for: * * * **CI excludes them by SELECTION** — `yarn test:ci` runs the `unit` project; these are the `corpus` * project and are never collected there. * * **Outside CI they are REQUIRED** — an absent checkout fails at module load. No skip, because every * headline number in Phase 2 lives in these files and nowhere else, and a skip that reports green cannot * be told apart from a pass. * * This file asserts both halves: the requirement, against a mocked-away filesystem, and the exclusion, against * `vitest.config.ts` itself. */ /** * The include-list(s) that `test:ci` does not select, read out of the config as one string. * * ⛔ THIS READ TWO LISTS UNTIL 2026-08-19, and it is one now for a reason that is not a relaxation. There was a second * project, `ASSETS`, holding the single spec that read the extension's schema assets under * `HEXASYNC_VSCODE_ASSETS` — a different absence, which must never be answered by the templates corpus's setup file. * Epic 2 batch 2a moved `stageSchemaParity.spec.ts` to `hexasync-templates-vscode-ext` with * `hexasync-template-worker-flow`, nothing left here reads that variable, and `vitest.config.ts` deleted the project * rather than keep one that can never collect a file. The block this function used to read no longer exists, so * demanding it would fail for a reason that has nothing to do with registration. * * ⚠️ **A NEW ABSENCE STILL GETS ITS OWN PROJECT AND ITS OWN ENTRY HERE.** Asserting only `CORPUS` is what pushed a * schema-parity guard behind the templates checkout in the first place, and sent whoever hit it to clone the wrong * repository. If you add a spec reading a second sibling checkout: new project, new list, add it to this array. * * Sliced FROM each declaration, never from the file: a bare `indexOf('];')` finds the `INCLUDE` array that precedes * them and yields an empty slice, which would make every assertion using it pass or fail for the wrong reason. */ const registeredLists = (): string => { const config = readFileSync('vitest.config.ts', 'utf8'); return ['const CORPUS = ['] .map((decl) => { const from = config.indexOf(decl); expect(from, `vitest.config.ts must declare ${decl}…`).toBeGreaterThan( -1, ); return config.slice(from, config.indexOf('];', from)); }) .join('\n'); }; describe('an absent checkout is a failure, not a skip', () => { beforeEach(() => { vi.resetModules(); delete process.env.HEXASYNC_TEMPLATES; }); afterEach(() => vi.doUnmock('node:fs')); /** The helper, re-imported against a filesystem that holds no corpus. */ const withoutCorpus = async () => { // Mocked because the machine this runs on HAS the corpus: without it, the only branch reachable from here // is the one that was never broken. vi.doMock('node:fs', () => ({ existsSync: () => false })); return import('./corpusCheckout'); }; it('throws at module load rather than skipping green', async () => { const { corpusSuite } = await withoutCorpus(); // The shape every corpus file uses. expect(() => corpusSuite('probe', 'Nothing can run.', 'And that would matter.'), ).toThrow(/No templates checkout/); }); it('says what was lost, in the caller’s own words', async () => { const { corpusSuite } = await withoutCorpus(); // The messages are why each file passes its own text: "the corpus is missing" is not actionable, // "DUP-1's severity split was derived from it" is. expect(() => corpusSuite( 'probe', "DUP-1's corpus assertions cannot run.", 'A green run proves nothing.', ), ).toThrow( /DUP-1's corpus assertions cannot run\. A green run proves nothing\./, ); }); it('says how to fix it BOTH ways — clone it, or stop selecting these', async () => { const { corpusSuite } = await withoutCorpus(); // A failure that does not name `yarn test:ci` sends whoever hits it on CI hunting for a checkout they are // never going to have. const thrown = () => corpusSuite('probe', 'x', 'y'); expect(thrown).toThrow(/different repository/); expect(thrown).toThrow(/HEXASYNC_TEMPLATES/); expect(thrown).toThrow(/yarn test:ci/); }); it('never consults process.env.CI', () => { const source = readFileSync( 'apps/cli/src/__tests__/corpusCheckout.ts', 'utf8', ); // The original defect, at its source. Being on a runner is not an input to this decision — the runner does // not load these files at all. expect(source.replace(/\/\*[\s\S]*?\*\//g, '')).not.toContain( 'process.env.CI', ); }); }); /** * The corpus files, the one decision, and the exclusion that keeps CI green. * * Nine identical guards drifted into eight identical bugs, which is what a duplicated policy does. A new * corpus spec written from the old template fails here rather than quietly restoring the behaviour that broke * the release — and one added without being listed in the `corpus` project fails here too, because then the * `unit` project would collect it and CI would load it. */ describe('every corpus spec defers to the shared decision, and CI excludes them', () => { const dir = 'apps/cli/src/__tests__'; const SELF = 'corpusCheckout.spec.ts'; const sourceOf = (name: string) => readFileSync(join(dir, name), 'utf8'); /** * Every spec that depends on the corpus — found by what it IMPORTS. * * Not by searching for `HEXASYNC_TEMPLATES`: the conversion removed that name from the eight files it * fixed, so a detector keyed on it found three. A detector that gets weaker as the code gets better is * worse than none. */ const corpusSpecs = readdirSync(dir).filter( (name) => name.endsWith('.spec.ts') && name !== SELF && sourceOf(name).includes("from './corpusCheckout'"), ); it('finds them all, so nothing below passes vacuously', () => { /** * An anti-vacuity FLOOR, not a census. * * This asserted `toHaveLength(9)` — eight that threw on the release tag plus `prefetchConcurrency`, which * skipped silently. A literal count made adding a corpus spec fail this test for no reason, which is the * opposite of what it is for: the assertion that actually matters is "every one of them is listed in the * corpus project" below, and that one is exhaustive by construction. This just proves the scan found * something. */ expect(corpusSpecs.length).toBeGreaterThanOrEqual(9); }); it('keys no guard on process.env.CI', () => { for (const spec of corpusSpecs) { expect(sourceOf(spec), `${spec} decides for itself`).not.toContain( 'process.env.CI', ); } }); it('re-derives the corpus path nowhere', () => { for (const spec of corpusSpecs) { // A second `process.env.HEXASYNC_TEMPLATES ?? …` is a second default to keep in step, and the default // is a path on one developer's machine. expect(sourceOf(spec), `${spec} re-derives CORPUS`).not.toMatch( /process\.env\.HEXASYNC_TEMPLATES\s*\?\?/, ); } }); it('lists every one of them in the corpus project, so `test:ci` collects none', () => { const listed = registeredLists(); for (const spec of corpusSpecs) { // Unlisted means the `unit` project picks it up, which means CI loads it, which means CI fails — the // exact breakage this replaced. expect(listed, `${spec} is missing from the corpus project`).toContain( spec, ); } }); it('excludes the sibling-checkout project from the CI script, and keeps a way to run it', () => { const scripts = JSON.parse(readFileSync('package.json', 'utf8')) .scripts as Record; expect(scripts['test:ci']).toBe('vitest run --project unit'); // Without this, "excluded on CI" quietly becomes "never run anywhere". expect(scripts['test:corpus']).toBe('vitest run --project corpus'); /** * ⛔ `test:assets` was asserted here too, for the THIRD absence (`HEXASYNC_VSCODE_ASSETS`) split out of `corpus` * on 2026-08-12. Epic 2 batch 2a took that spec — and with it the whole `assets` project — to * `hexasync-templates-vscode-ext`, which owns the schemas it reads. There is no script to assert about and no * project for it to run. If a spec reading a second sibling checkout ever comes back, so does the pair. */ expect(scripts['test:assets']).toBeUndefined(); }); }); /** * Every form in which a spec can decline to run — one pattern, asserted below against both what it must catch and the * prose it must not. * * `skipIf`/`runIf` are the ones that matter: they take the CONDITION, which is exactly how an absent-checkout skip gets * written, and `CLAUDE.md` names `describe.skipIf(!existsSync(...))` as the forbidden form. `todo` and a bare * `ctx.skip()` are here because both also turn "could not measure" into a green line. */ const SKIPPING_FORM = /(\b(describe|it|test)\.(skip|skipIf|runIf|todo)\b|\bctx\.skip\s*\()/; describe('a spec OUTSIDE this directory that reads a sibling checkout is caught too', () => { /** * ⛔ The gap this closes, found while writing Story 4.4's golden suite. * * The scan above reads exactly one directory, `apps/cli/src/__tests__`. Three specs under `packages/` — * `renderCorpus.spec.ts`, `referenceParity.spec.ts`, `stageSchemaParity.spec.ts` — read a sibling checkout, decided * their own absence policy with `describe.skip`, and were **not registered in the corpus project**. So the `unit` * project collected them, CI loaded them, and they reported **green by skipping** — the precise outcome the whole * corpus rule exists to prevent. * * They escaped because the guard could not see them, which is the same shape as the dead compose-contract guard and * the unguarded `hexasync.layer`: a check that passes by not looking. * * They cannot import `corpusCheckout` — a package importing an app is a layering violation, and a relative import * escaping the package is the shape the accident takes — nothing catches it today (D.46). So this enforced **registration** only, and * accepted their skips on the grounds that *"their skip is visible in the run summary and stated in their suite * titles"*. * * ⚠️ **That acceptance is withdrawn (2026-08-12).** A visible skip is still a green run, which is the one thing the * corpus rule exists to forbid, and the last test in this suite now fails any of them that skips. The layering * constraint was real and did not need breaking: `vitest.corpus-setup.ts` hangs off the `corpus` project, so it needs * no import from anywhere. */ /** * ⛔ `'packages'` LEFT THIS LIST WITH THE LAST PACKAGE — Epic 2 Story 2.4, 2026-08-19. * * It did not merely stop contributing files: `readdirSync` on an absent directory is `ENOENT`, so all four * tests in this suite threw over a directory this repository is now correct not to have. The fourteen * `@beehexa/hexasync-template-*` packages are in `hexasync-templates-vscode-ext`, where * `test/externalDependencyRegistration.spec.ts` walks `test` AND `packages` and enforces the same rule * over the specs that travelled — including the four sibling-reading ones (`renderCorpus`, * `stageSchemaParity`, `referenceParity`, `contextCorpus`) that batch 2a took. * * ⚠️ The WIDENING this root was added for is not withdrawn — see the note on the assertion below. */ const WALK_ROOTS = ['apps']; const SELF = 'corpusCheckout.spec.ts'; /** Every `*.spec.ts` anywhere in the repo, with its text. */ const allSpecs = (): { path: string; name: string; text: string }[] => { const found: { path: string; name: string; text: string }[] = []; const walk = (dir: string): void => { for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name === 'dist') continue; const child = join(dir, entry.name); if (entry.isDirectory()) { walk(child); continue; } if (!entry.name.endsWith('.spec.ts') || entry.name === SELF) continue; found.push({ path: child, name: entry.name, text: readFileSync(child, 'utf8'), }); } }; for (const root of WALK_ROOTS) walk(root); return found; }; /** * A spec that READS a sibling checkout — the templates corpus, or the planning repo's fixtures. * * Two signals, both required: an absolute sibling path AND an actual filesystem call. Detecting on the path alone * gave a false positive on `uriPath.spec.ts`, which names `file:///config/workspaces/templates/…` as **test data** * for the URI algebra and reads nothing — so a path is evidence of intent, not of a dependency. * * Keyed on the path rather than an env-var NAME deliberately: a variable can be renamed and a detector keyed on it * silently narrows, which is the mistake the scan above already records about its own history. */ const SIBLINGS = [ // Every repository beside this one. A spec reading any of them depends on a checkout it does not own. '/config/workspaces/templates', '/config/workspaces/hexasync-ideation-hub', '/config/workspaces/hexasync-templates-vscode-ext', '/config/workspaces/dashboard-v2', '/config/workspaces/core.api', ]; /** * Importing either shared guard, with OR WITHOUT the file extension. * * ⛔ Story 4.4 review, HIGH-2. This matched the literal `from './goldenCheckout'` — closing quote included — while * `goldenFlowsCorpus.spec.ts` imports `from './goldenCheckout.js'`. So the guard could not see **the spec added by * the story that widened it**, and un-registering that spec from `CORPUS` failed nothing. The third * passes-by-not-looking defect in this epic, reproduced inside the commit fixing two others. * * The ten older specs import extensionless, which is the only reason the corpus half worked — so adding `.js` to any * one of them would silently have dropped it: the very "detector that gets weaker as the code gets better" this file * warns about above. */ const IMPORTS_GUARD = /from '\.\/(corpusCheckout|goldenCheckout)(\.js)?'/; const readsSiblingCheckout = (text: string) => { if (IMPORTS_GUARD.test(text)) return true; /** * `fs/promises` counts. `adapterParity.spec.ts` already uses that style, so a spec written the same way while * reading a sibling path evaded the sync-only list — demonstrated with a three-line spec. */ const touchesDisk = /\b(readFileSync|readdirSync|existsSync|statSync|readFile|readdir|opendir)\b/.test( text, ) || text.includes('node:fs'); /** * ⛔ Absolute AND relative. `SIBLINGS` holds absolute paths, and Epic 7 added three specs reading * `'../hexasync-templates-vscode-ext/assets/...'` — so `text.includes()` was false for every one of them, they * stayed in the `unit` project, and CI would have run them against a checkout it does not have. Repointing one at * an absent directory reproduced it: 4 failed, ENOENT. * * That is the FOURTH passes-by-not-looking defect this file has recorded, and the second about itself. The * detector now matches the sibling by NAME, whichever shape the path is written in. */ const RELATIVE_SIBLING = new RegExp( `\\.\\./(${SIBLINGS.map((path) => path.split('/').pop()!.replace('.', '\\.')).join('|')})/`, ); return ( touchesDisk && (SIBLINGS.some((path) => text.includes(path)) || RELATIVE_SIBLING.test(text)) ); }; const dependents = () => allSpecs().filter((s) => readsSiblingCheckout(s.text)); it('finds them, so nothing below passes vacuously', () => { const found = dependents(); expect( found.length, 'no spec reads a sibling checkout — the detector has stopped working', ).toBeGreaterThan(8); /** * The WALK reaches outside `apps/cli/src/__tests__`, which is the whole point of widening it. * * ⛔ TWICE REPOINTED, AND THE SUBJECT IS THE SAME CLAIM BOTH TIMES. It first asserted * `found.some(s => s.path.startsWith('packages/'))` — that a sibling-reading spec is FOUND under * `packages/` — and Epic 2 batch 2a made that false honestly: the four package specs that read a sibling * (`renderCorpus`, `stageSchemaParity`, `referenceParity`, `contextCorpus`) all left with their packages. * It was narrowed to `allSpecs().some(startsWith('packages/'))`: not "a dependent is there" but "the walk * collects from there at all", which was the half that could silently regress. * * ⚠️ Story 2.4 took `io-node` and `packages/` no longer exists, so THAT form is false too — and this time * the directory cannot come back. The claim is repointed to what widening the walk actually bought, in * terms of a root this repository still has: it collects specs from OUTSIDE * `apps/cli/src/__tests__`, which is the one directory the narrower scan earlier in this file reads. The * eight compose specs under `apps/cli/src/commands/compose/__tests__/` are what make that non-vacuous, and * a traversal that stopped descending would fail here exactly as it would have before. */ const SELF_DIR = join('apps', 'cli', 'src', '__tests__'); expect( allSpecs().filter((s) => !s.path.startsWith(SELF_DIR)).length, `the walk collects nothing outside ${SELF_DIR} — WALK_ROOTS or the traversal is broken, and the ` + `narrower scan earlier in this file already covers that directory`, ).toBeGreaterThan(5); // And it sees the golden suite specifically, which it could not: its import carries a `.js` extension. expect( found.map((s) => s.name), 'the golden suite is invisible to the detector again', ).toContain('goldenFlowsCorpus.spec.ts'); }); it('lists every one in a project `test:ci` does not select', () => { const listed = registeredLists(); for (const spec of dependents()) { expect( listed, `${spec.path} reads a sibling checkout but is not in the corpus project — the unit project will collect ` + `it, CI will load it, and it will report green by skipping or fail for want of a checkout`, ).toContain(spec.name); } }); it('keys no guard on process.env.CI, wherever it lives', () => { for (const spec of dependents()) { expect(spec.text, `${spec.path} decides for itself`).not.toContain( 'process.env.CI', ); } }); /** * ⛔ The half registration did NOT close, left open by Story 4.4 and closed on 2026-08-12. * * Registration stopped CI collecting these, which was the release-breaking half. The other half stayed broken and * this file said so: *"their skip is visible in the run summary and stated in their suite titles"*. It was not * enough. With the corpus absent, `renderCorpus` and `referenceParity` reported **passed** having rendered nothing, * and `CLAUDE.md`'s table promises development **fails**. A suite title nobody reads is the same class of thing as * the `console.warn` vitest swallowed. * * Two absences, two owners, no skips: the templates corpus is decided once for the whole project in * `vitest.corpus-setup.ts`, and `stageSchemaParity.spec.ts` throws on its own variable * (`HEXASYNC_VSCODE_ASSETS`) because a different absence must not report the corpus's name. */ it('skips nothing — a corpus spec fails when its checkout is absent', () => { for (const spec of dependents()) { // Comments stripped first: this file and `stageSchemaParity.spec.ts` both DISCUSS `describe.skip` in prose, and // a detector that trips on its own explanation is the mistake the vscode-ext corpus guard made once already. const code = spec.text .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\/.*$/gm, ''); expect( code, `${spec.path} skips when its checkout is absent, so it reports green having asserted nothing — throw at ` + 'module load instead, or let `vitest.corpus-setup.ts` decide it for the whole project', ).not.toMatch(SKIPPING_FORM); } }); /** * ⛔ The guard above was one character from useless, and a reviewer proved it the same day it was written. * * It matched `/\b(describe|it|test)\.skip\b/`, which cannot see **`describe.skipIf`** — `\b` fails against `skipI` — * and `CLAUDE.md` in both repos names `describe.skipIf(!existsSync(...))` as the forbidden form BY NAME. Demonstrated * end to end: replacing `goldenSuite(...)` in `goldenFlowsCorpus.spec.ts` with `describe.skipIf(!existsSync(...))` * left this file 13/13 green, and `HEXASYNC_CONNECTIONS_FEATURE=/nonexistent … --project corpus goldenFlowsCorpus` * then reported `1 skipped / 16 skipped`, **exit 0**. * * That matters most where the setup file cannot help: it decides ONE of the three absences (`HEXASYNC_TEMPLATES`). * For the golden fixtures and the schema assets this regex is the only line of defence. * * So the pattern is asserted directly, against the forms it must catch and the prose it must not. */ it('recognises every way a spec can decline to run', () => { const caught = [ 'describe.skip("x", () => {})', 'describe.skipIf(!existsSync(CORPUS))("x", () => {})', 'it.skip("x", () => {})', 'it.skipIf(!available)("x", () => {})', 'test.skip("x", () => {})', 'describe.runIf(available)("x", () => {})', 'it.todo("measure this later")', 'ctx.skip("no checkout")', ]; for (const form of caught) { expect(form, `${form} must be recognised as declining to run`).toMatch( SKIPPING_FORM, ); } const allowed = [ // The words appear in prose all over these files; a detector that trips on its own explanation is the mistake // the extension's corpus guard already made once. 'const suite = describe;', 'it("skips nothing — a corpus spec fails when its checkout is absent", () => {})', 'expect(thrown).toThrow(/skipped/);', ]; for (const form of allowed) { expect(form, `${form} is not a skip`).not.toMatch(SKIPPING_FORM); } }); });