import { describe, it, expect } from 'vitest'; import { CORPUS, corpusSuite } from './corpusCheckout'; import { existsSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { nodeTemplateReader } from '@beehexa/hexasync-template-io-node'; import { searchMain, resolve, prefetchSources, mergeToMainValue, type OverwriteRecord, } from '@beehexa/hexasync-template-compose'; import { buildEffectiveGraph } from '@beehexa/hexasync-template-index'; import { createFsResolverIO } from '../commands/compose/composeCommand'; /** * The wholesale-overwrite hook against the REAL corpus (Phase 2 Story 1.9 AC 4, FR-56). * * **This file does not prove OVW-1 fires, because on today's corpus it cannot.** Of 336 wholesale * array overwrites across 104 projects, exactly 0 drop id-bearing components: 2 are at depth 0 and * both replace id-less config arrays, and the remaining 334 are nested. So the corpus contains no * real inheritance loss of this kind, and the rule is correct and inert on it. * * Said plainly rather than dressed up, because the alternative — a test that constructs a loss and * calls it corpus evidence — would claim verification the corpus does not provide. The rule's own * behaviour is unit-tested; what this file guards is the thing unit tests cannot reach: * * - the `onOverwrite` hook actually FIRES during a real merge. It exists because the index builds * without the CLI's tracer, which is where these records were recorded, so before Story 1.9 the * editor could not see them at all. If the hook regresses, the count drops to zero here. * - and today's corpus really has no qualifying loss, so a future one shows up as a FAILURE of the * last assertion rather than as a silent new diagnostic nobody expected. */ const suite = corpusSuite( 'overwrite', 'The wholesale-overwrite hook cannot be exercised against real merges.', 'Only real inheritance chains produce these records, so a green run that never read the corpus proves the hook nothing.', ); function projectRoots(root: string, depth = 0): string[] { if (depth > 6) return []; const out: string[] = []; for (const entry of readdirSync(root)) { if (entry === 'node_modules' || entry === '.git' || entry === '__configs') continue; const full = join(root, entry); if (!statSync(full).isDirectory()) continue; if ( existsSync(join(full, 'main.yaml')) || existsSync(join(full, 'partials', 'main.yaml')) ) { out.push(full); continue; } out.push(...projectRoots(full, depth + 1)); } return out; } let scan: Promise<{ merged: number; records: OverwriteRecord[]; graphKept: number; }>; function scanCorpus() { scan ??= (async () => { const reader = nodeTemplateReader(); const io = createFsResolverIO(); const records: OverwriteRecord[] = []; let merged = 0; let graphKept = 0; for (const root of projectRoots(CORPUS)) { try { const { mainYml, componentPath } = await searchMain(root, reader); const resolved = await resolve(componentPath, mainYml, io); const sources = await prefetchSources( resolved.worklist as never, reader, componentPath, mainYml, ); mergeToMainValue( mainYml, resolved.worklist as never, undefined, componentPath, sources, undefined, { onOverwrite: (record) => records.push(record) }, ); merged++; // And what the GRAPH keeps, which is the filtered set the rule sees. const graph = await buildEffectiveGraph(root, componentPath, mainYml, { io, reader, }); graphKept += graph.overwrites.length; } catch { /* a project that will not resolve is Story 1.3's business, not this file's */ } } return { merged, records, graphKept }; })(); return scan; } suite('the wholesale-overwrite hook over the real corpus', () => { it('fires during real merges, which is what the editor could not see before', async () => { const { merged, records } = await scanCorpus(); // 104 projects, 336 records at the time of writing. The hook is the whole delivery here: the // records were only ever on the tracer, and the index deliberately builds without one. expect(merged).toBeGreaterThan(80); expect(records.length).toBeGreaterThan(50); }, 900_000); it('records the DEPTH, so a nested config array is not mistaken for a lost component', async () => { const { records } = await scanCorpus(); const nested = records.filter((r) => r.depth > 0); // The overwhelming majority are nested — 334 of 336. Without `depth` every one of them would look // like a top-level component collection being replaced, and the rule would bury its real case // under a hundred-fold of noise. expect(nested.length).toBeGreaterThan(records.length / 2); }, 900_000); it('keeps only real top-level losses on the graph — currently none', async () => { const { records, graphKept } = await scanCorpus(); const qualifying = records.filter((r) => r.depth === 0 && r.hadComponents); // The honest state of the corpus: 0 of 336 overwrites drop an id-bearing component. The two // depth-0 records both replace id-less config arrays, which is ordinary customization. // // Asserted as an EQUALITY rather than a floor, deliberately. If the corpus ever gains a real // wholesale loss this test fails and someone reads it, instead of a new diagnostic appearing in // everyone's Problems panel with no explanation. expect(qualifying).toHaveLength(0); // And the graph keeps exactly the qualifying set, so the rule sees neither more nor less. expect(graphKept).toBe(qualifying.length); }, 900_000); });