import { describe, it, expect } from 'vitest'; import { CORPUS, corpusSuite } from './corpusCheckout'; import { existsSync } from 'node:fs'; import { nodeTemplateReader, uriFromPath, pathFromUri, } from '@beehexa/hexasync-template-io-node'; import type { TemplateReader } from '@beehexa/hexasync-template-ports'; import { Tier1IndexService, Tier2IndexService, TIER2_CANCELLED, projectRootOf, getProjectsInheriting, buildEffectiveGraph, resumeEffectiveGraph, getOutgoingRelations, getRelatedTask, type ComponentRelation, } from '@beehexa/hexasync-template-index'; import { createFsResolverIO } from '../commands/compose/composeCommand'; /** * Story 2.1 and 2.2 against the REAL corpus, and the NFR-4 measurement. * * The acceptance criteria name specific numbers — 113 projects, 27 under `variux`, 9 inheritors * of one audit file — and those numbers are the whole point. A synthetic fixture can prove the * algorithm; only the corpus can prove it is the algorithm the corpus needs. * * Lives in `apps/cli` rather than in `template-index` deliberately: reading a real filesystem is * an adapter's job, and `template-index` is core, where AD-3 forbids it. * * Skips when the corpus is absent, so a clone without the sibling checkout still runs green * rather than failing for a reason that is not about this code. */ const suite = corpusSuite( 'corpus', 'Every corpus assertion — the project counts, NFR-4, NFR-5 and NFR-6a — cannot run.', 'A skipped budget is indistinguishable from a met one, and this epic rejected a bound that could not fail on the grounds that it would be a comment rather than a budget.', ); /** * On CI, an absent corpus FAILS. Locally it skips loudly. * * Every headline number in this epic — 113, 27, 9, NFR-4, NFR-5's 1200 ms guard, NFR-6a's 300 ms — * lives only in this file, and `describe.skip` made all of them vanish while the run stayed green. * `.github/workflows/pr-checks.yml` checks out one repo and sets no `HEXASYNC_TEMPLATES`, so that * was every PR: the budgets were not merely unmet, they never executed. * * This epic rejected a 2500 ms bound on the grounds that "a bound that cannot fail is not a budget, * it is a comment". A bound that cannot RUN is the same thing, and the argument applies to the guard * as much as to the number inside it. So CI is told to provide the corpus or say out loud that it * cannot, rather than reporting success for assertions it never ran. */ /** * The reader the CLI composes with, wrapped to RECORD what it opened. * * The recording is the point for one AC: "parsing only `main.yaml` and no other YAML" cannot be * shown by counting projects, only by watching the reads. */ function corpusReader(): TemplateReader & { reads: string[] } { const base = nodeTemplateReader(); const reads: string[] = []; return { reads, read: (uri) => { reads.push(uri); return base.read(uri); }, exists: (uri) => base.exists(uri), glob: async (baseUri, pattern) => (await base.glob(baseUri, pattern)).map(pathFromUri), }; } suite('Tier 1 over the real corpus (Stories 2.1, 2.2, NFR-4)', () => { const build = async () => { const reader = corpusReader(); const service = new Tier1IndexService(reader); const snapshot = await service.rebuild(CORPUS); if (snapshot === null || typeof snapshot === 'symbol') { throw new Error( 'build was cancelled, which cannot happen for a single call', ); } return { service, snapshot, reader }; }; it('finds every PROFILE template, and opens no other YAML', async () => { const { snapshot, reader } = await build(); const profiles = snapshot.graph.projects.filter( (p) => p.kind === 'profile', ); /** * RE-DERIVED 2026-08-09 (Epic 2 review): **114**, one more than the AC's original 113. * * The count is one `main.yaml` per project, so it moves whenever the corpus gains or loses a template. * Expressed as a RELATION to the reads below rather than as two independent literals (AD-35): what the AC * is really about is that Tier 1 opens exactly one YAML per project and nothing else — a property that * survives the corpus growing, where a bare number needs editing every time it does. * * ⚠️ This spec was failing at **1** project, and had been since 2026-08-08 — long before this epic. The * cause was not drift: the corpus held one stray `hx-templates.json` containing `{}`, left behind when * corpus commit `c0e93e00f0` gitignored `**​/hx-*.json` and deleted the other 90. Scope markers are * AUTHORITATIVE when any exist, by design, so one ignored leftover file reduced a 114-project workspace * to a single project — for the CLI and for the editor alike. The markers are per-developer setup and * being gitignored is correct; a stray one is a local hazard, and this spec is what noticed. */ expect(profiles.length).toBeGreaterThan(100); // The second half of the AC, which counting projects cannot show: 7,729 YAML files exist // and Tier 1's affordability depends on reading one per project. The connector MARKER is JSON, // not YAML, so "no other YAML" still holds exactly. const yamlReads = reader.reads.filter((uri) => /\.ya?ml$/i.test(uri)); expect(yamlReads.filter((uri) => !uri.endsWith('/main.yaml'))).toEqual([]); // ONE per project, asserted as the relation rather than as a second copy of the number. expect(yamlReads).toHaveLength(profiles.length); }); it('tells connector roots apart from profiles by their MARKER, never by a path', async () => { const { snapshot } = await build(); const connectors = snapshot.graph.projects.filter( (p) => p.kind === 'connector', ); /** * ⚠️ REWRITTEN 2026-08-09 (Epic 2 review). This asserted `connectors.length >= 1` and could not pass. * * `CONNECTOR_TEMPLATE_DETECTOR` identifies a connector root by an `hx-connectors.json` marker and by * nothing else — deliberately, because *"a workspace may declare several connector roots anywhere, and a * path convention would silently miss every one that did not follow it."* Those markers are **per-developer * setup and gitignored** (corpus `c0e93e00f0`, confirmed by Jazz), so a marker-less checkout has zero * connector projects and the old assertion was really asserting a property of one developer's disk. * * What the corpus CAN prove is the half that matters and does not depend on local files: whatever is * classified `connector` got there through a marker, and no `main.yaml` was ever mistaken for one. The * marker-driven detection itself is pinned where a marker can actually be created — `tier1.spec.ts`, over a * synthetic filesystem, including two roots in unconventional folders. */ expect( connectors.every((p) => p.descriptorPath.endsWith('hx-connectors.json')), ).toBe(true); // The inverse, which is what "a connector folder must not look like no project at all" really needs: no // profile was classified from a connector marker either. expect( snapshot.graph.projects .filter((p) => p.kind === 'profile') .every((p) => p.descriptorPath.endsWith('/main.yaml')), ).toBe(true); }); it('finds the 27 projects nested under 001-projects/variux', async () => { const { snapshot } = await build(); // Trailing slash deliberately: a bare prefix would silently count a future // `001-projects/variux-legacy` as nested — the same sibling-prefix trap the production // code guards against and this test originally did not. const variuxRoot = `${pathFromUri(uriFromPath(`${CORPUS}/001-projects/variux`))}/`; const nested = snapshot.graph.projects.filter((p) => projectRootOf(p).startsWith(variuxRoot), ); // Arbitrary nesting (FR-39) is not a hypothetical: one folder holds a quarter of the corpus. expect(nested).toHaveLength(27); }); it('reports the 9 projects inheriting the audit puller (Story 2.2)', async () => { const { snapshot } = await build(); const inheritors = getProjectsInheriting( snapshot.graph, `${CORPUS}/002-components/hexasync-audit/sales-orders/SalesOrdersNotSyncedNotification_Puller.yaml`, ); // WHICH nine, not merely how many — nine wrong projects would satisfy a count. expect(inheritors).toHaveLength(9); expect(inheritors.every((id) => id.includes('/variux/'))).toBe(true); expect(new Set(inheritors).size).toBe(9); }); it('completes within the NFR-4 budget, and records the measurement', async () => { const { snapshot } = await build(); // Reported so the phase gate can CONFIRM the provisional 2 s rather than assume it. console.log( `[NFR-4] Tier 1 indexed ${snapshot.graph.projects.length} projects in ${snapshot.buildMs} ms (budget 2000 ms)`, ); expect(snapshot.buildMs).toBeLessThan(2000); }); it('never opens a file under a generated __configs path', async () => { const { snapshot, reader } = await build(); // The corpus contains ZERO `__configs/**/main.yaml`, so asserting "no such project" was // vacuous — it passed with the ignore list deleted. Asserting on the READS is not: there // are 20 YAML files under `__configs`, and none may be opened. expect(reader.reads.filter((uri) => uri.includes('/__configs/'))).toEqual( [], ); expect( snapshot.graph.projects.filter((p) => p.id.includes('__configs')), ).toHaveLength(0); }); it('resolves every type: project external the corpus actually declares', async () => { const { service, snapshot } = await build(); const projectEdges = snapshot.graph.edges.filter( (e) => e.kind === 'project', ); console.log( `[tier1] ${snapshot.graph.edges.length} edges · ${service.unresolvedEdges().length} unresolved · ${snapshot.graph.cycles.length} cycles`, ); // PINNED, not logged. This is the assertion the earlier version of this test was missing: // 4 of the corpus's 5 `type: project` externals are written as `.../connector/partials`, // and every one of them resolved to nothing while its main.yaml sat right there. Blast // radius silently returned EMPTY for three connector projects and nothing failed. expect(projectEdges.length).toBeGreaterThanOrEqual(5); expect(projectEdges.filter((e) => e.unresolved !== undefined)).toEqual([]); }); it('reports blast radius for a connector reached through the /partials spelling', async () => { const { snapshot } = await build(); const inheritors = getProjectsInheriting( snapshot.graph, `${CORPUS}/003-templates/sapoomni-v3-connector/partials/main.yaml`, ); // The regression this guards: with root-only matching this was []. expect(inheritors.length).toBeGreaterThan(0); }); it('records cycles rather than failing, whatever the corpus holds', async () => { const { snapshot } = await build(); expect(Array.isArray(snapshot.graph.cycles)).toBe(true); expect(snapshot.graph.projects.length).toBeGreaterThan(0); }); }); suite('Tier 2 over the real corpus (Story 3.1, NFR-5)', () => { /** * Three real projects spanning the corpus, so the figure is a curve rather than one number. * * The AC USED to say "roughly 68 for an average project" and was amended on 2026-08-02 to the * measured median of 99, range 19-213 — as was NFR-5's budget, which had been set against 68. The * amendment is why this comment no longer quotes the AC as though it still said that. */ const PROJECTS = [ { path: '001-projects/aquavn', label: 'small' }, { path: '003-templates/sapoomni-v3-connector', label: 'median' }, { path: '001-projects/hapas', label: 'large' }, ]; const buildFor = async (rel: string) => { const reader = corpusReader(); const io = createFsResolverIO(reader); const { mainYml, componentPath } = await io.searchMain(`${CORPUS}/${rel}`); const service = new Tier2IndexService(io, reader); const snapshot = await service.build({ projectId: componentPath, componentPath, mainYml, }); if (typeof snapshot === 'symbol') throw new Error('a single build of a real project must succeed'); return { snapshot, reader }; }; it('records the NFR-5 figure across small, median and large projects', async () => { for (const { path, label } of PROJECTS) { const { snapshot } = await buildFor(path); console.log( `[NFR-5] ${label.padEnd(6)} ${path} — ${snapshot.graph.worklist.length} worklist files, ` + `${snapshot.graph.effective.length} components, ${snapshot.buildMs} ms`, ); // The AC asks for the figure to be RECORDED for confirmation or formal revision at the // phase gate. It is recorded above; the assertion below is the revised bound, argued in // NOTES-epic-3.md rather than quietly chosen here. // // 1200, not 2500. The worst measured project is ~910 ms, and a guard set at 2500 would // have let performance regress by 2.7x while staying green — a bound that cannot fail is // not a budget, it is a comment. // // Re-measured after Story 1.8 added library-variable collection to the build: small 186 ms, // median 613 ms (was 559), large 749 ms (was 725). All three borrow nothing, so that is the // cost of LOOKING for a library and not finding one — the only cost every project pays. expect(snapshot.buildMs).toBeLessThan(1200); } }); it('reads only the worklist, never the workspace', async () => { const { snapshot, reader } = await buildFor( '003-templates/sapoomni-v3-connector', ); // The AC's real point: a project, not a corpus. 7,729 YAML files exist in the workspace. expect(reader.reads.length).toBeLessThan( snapshot.graph.worklist.length * 2 + 50, ); }); it('inherits real components with real provenance', async () => { const { snapshot } = await buildFor('003-templates/sapoomni-v3-misa-amis'); const inherited = snapshot.graph.effective.filter( (c) => c.provenance.generation > 0, ); // A three-project inheritance chain: the effective graph must contain what it inherits, // and each inherited component must point at the file it TRULY lives in. expect(inherited.length).toBeGreaterThan(0); expect( inherited.every((c) => c.provenance.sourceFile.includes('/partials/')), ).toBe(true); }); }); suite( 'Re-merging after an edit, on the real corpus (Story 3.5, NFR-6a)', () => { /** * Two shapes, because they behave completely differently and only measuring one would hide it. * * An INHERITING project has a handful of generation-0 files at the end of a long worklist, so * the boundary checkpoint makes the developer's own edit nearly free. A STANDALONE project has * its whole worklist at generation 0 — most of the corpus — so an edit can land anywhere, and * an edit near the FRONT genuinely has to re-merge almost everything. */ const graphFor = async (rel: string) => { const reader = corpusReader(); const io = createFsResolverIO(reader); const { mainYml, componentPath } = await io.searchMain( `${CORPUS}/${rel}`, ); const started = performance.now(); const graph = await buildEffectiveGraph( componentPath, componentPath, mainYml, { io, reader, checkpoints: true, }, ); return { graph, buildMs: performance.now() - started }; }; it('re-evaluates a generation-0 edit inside NFR-6a on an inheriting project', async () => { const { graph, buildMs } = await graphFor( '003-templates/sapoomni-v3-misa-amis', ); const own = graph.worklist.filter((w) => (w.generation ?? 0) === 0); const edited = own.at(-1)!; const text = graph.sources.get(edited.path)!; const started = performance.now(); const resumed = resumeEffectiveGraph( graph, graph.checkpoints, // A real edit: the same file with one more line of YAML comment, which changes the text // without changing the components, so the comparison below is exact. new Map([[edited.path, `${text}\n# edited\n`]]), ); const reMergeMs = performance.now() - started; console.log( `[NFR-6a] inheriting — ${graph.worklist.length} worklist files (${own.length} gen-0), ` + `full build ${buildMs.toFixed(0)} ms, re-merge from index ${resumed?.resumedFrom} ` + `in ${reMergeMs.toFixed(0)} ms`, ); expect(resumed).toBeDefined(); // The AC's case: generation-0 files merge last, so this is the cheapest re-merge there is. expect(reMergeMs).toBeLessThan(300); // And it is the same graph — the whole point. expect(resumed!.effective.length).toBe(graph.effective.length); }); it('records the curve for a standalone project, where the edit can land anywhere', async () => { const { graph, buildMs } = await graphFor( '003-templates/sapoomni-v3-connector', ); const n = graph.worklist.length; const figures: string[] = []; let worstCovered = 0; let resumedCount = 0; for (const fraction of [0.1, 0.5, 0.9, 0.99]) { const at = Math.min(n - 1, Math.floor(n * fraction)); const edited = graph.worklist[at]!; const text = graph.sources.get(edited.path)!; const started = performance.now(); const resumed = resumeEffectiveGraph( graph, graph.checkpoints, new Map([[edited.path, `${text}\n# edited\n`]]), ); const ms = performance.now() - started; figures.push( `${(fraction * 100).toFixed(0)}% (index ${at}) ${ resumed ? `${ms.toFixed(0)} ms from ${resumed.resumedFrom}` : 'full rebuild' }`, ); if (resumed) resumedCount++; // `>= (n - 1) / 2`, on the INDEX, so the midpoint is inside the covered half. // // This was `at >= n / 2`. With n = 99 that is `49 >= 49.5` — false — so the 50% sample was // excluded and `worstCovered` came from the 90% and 99% points only: the guard asserted the // back TENTH while claiming the back half. The 50% figure is ~177 ms, which is the number // the NFR-6a amendment actually rests on, and it was logged and never asserted. const midpoint = (n - 1) / 2; if (resumed && at >= midpoint) worstCovered = Math.max(worstCovered, ms); } console.log( `[NFR-6a] standalone — ${n} worklist files, all generation 0, full build ` + `${buildMs.toFixed(0)} ms; ${figures.join(' · ')}`, ); // FIRST assert the feature actually engaged. Without this the budget check below is // vacuous: with `resumeEffectiveGraph` stubbed to decline, every position falls back to a // full rebuild, `worstCovered` stays 0, and `expect(0).toBeLessThan(300)` passes green // while the story's whole mechanism is gone. Proven by mutation during the epic review — // and this is the only guard the epic has over the project shape that is most of the // corpus, so it certifying nothing was the more expensive half of the defect. expect(resumedCount).toBeGreaterThanOrEqual(3); expect(worstCovered).toBeGreaterThan(0); // Then the honest claim: an edit in the BACK HALF of a standalone worklist is inside // NFR-6a. An edit at the very front is not, and cannot be — later-wins merge means // everything after it genuinely has to be re-merged. The figures above record the curve // rather than hiding it behind a single number. expect(worstCovered).toBeLessThan(300); }); it('produces the same graph a full rebuild would, on real files', async () => { const { graph } = await graphFor('003-templates/sapoomni-v3-misa-amis'); const edited = graph.worklist .filter((w) => (w.generation ?? 0) === 0) .at(-1)!; const text = graph.sources.get(edited.path)!; const updates = new Map([[edited.path, `${text}\n# edited\n`]]); const resumed = resumeEffectiveGraph(graph, graph.checkpoints, updates)!; const reader = corpusReader(); const io = createFsResolverIO(reader); const { mainYml, componentPath } = await io.searchMain( `${CORPUS}/003-templates/sapoomni-v3-misa-amis`, ); const full = await buildEffectiveGraph( componentPath, componentPath, mainYml, { io, reader, }, ); const shape = (g: typeof full) => g.effective .map( (c) => `${c.collection}/${c.id}|${c.provenance.sourceFile}|${c.provenance.generation}|` + `${c.provenance.overrides}|${JSON.stringify(c.value)}`, ) .sort(); // 273 components on a real three-project inheritance chain, compared value by value. // A comment-only edit changes no component, so the resumed graph must equal the full one // exactly — which is the strongest available proof that the fold is genuinely resumable. expect(shape(resumed)).toEqual(shape(full)); }); }, ); suite('Associations wire Tasks to their entities, on the real corpus', () => { /** * The corpus's DOMINANT reference form, and one the graph originally could not see at all. * * `objectAssociations` is a map keyed by the objectId, each entry naming that object's table, * puller and pusher through NESTED blocks (`table: { id: … }`). Counted across the corpus's * authored partials: 1,575 `table:` blocks, 1,695 `puller:`, 912 `pusher:` — against 308 / 38 / * 48 occurrences of the flat `tableId:` / `pullerId:` / `pusherId:` form the reference registry * was first built on. About 10:1, and the registry saw none of the nested side. * * A synthetic fixture proves the traversal; only the corpus proves it is the traversal the * corpus needs — and before this landed, every one of these edges was invisible. */ it('discovers and resolves every association edge in three real projects', async () => { for (const rel of [ '003-templates/misa-amis-connector', '003-templates/sapoomni-v3-misa-amis', '001-projects/hapas', ]) { const reader = corpusReader(); const io = createFsResolverIO(reader); const found = await io.searchMain(`${CORPUS}/${rel}`); const graph = await buildEffectiveGraph( found.componentPath, found.componentPath, found.mainYml, { io, reader }, ); const associations = graph.effective.filter( (c) => c.collection === 'objectAssociations', ); expect(associations.length).toBeGreaterThan(0); let edges = 0; let dangling = 0; let withTask = 0; for (const association of associations) { const relations = (await getOutgoingRelations( graph, 'objectAssociations', association.id, )) as ComponentRelation[]; edges += relations.length; dangling += relations.filter((r) => !r.to).length; if (await getRelatedTask(graph, 'objectAssociations', association.id)) { withTask++; } } console.log( `[associations] ${rel} — ${associations.length} associations, ${edges} edges, ` + `${dangling} dangling, ${withTask} resolved to a Task`, ); // Every association names at least its Task (the key) and one entity. expect(edges).toBeGreaterThan(associations.length); // All of them land: these ids are token-form and the graph keys components in token form, // so a dangling one would mean the wiring is not being read the way the composer reads it. expect(dangling).toBe(0); // And every association belongs to a Task — which is what the key IS. expect(withTask).toBe(associations.length); } }, 300000); });