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, composeProject } from '@beehexa/hexasync-template-compose'; import { createValidationContext, variableDriftRule, } from '@beehexa/hexasync-template-validate'; import { createFsResolverIO } from '../commands/compose/composeCommand'; /** * DRIFT-1 against the REAL corpus (Phase 2 Story 1.8, FR-60). * * The rule's whole judgement is "partial divergence, not full", and that judgement was derived from * the corpus rather than reasoned out. This pins it: the exact projects it fires on, and — just as * importantly — the 56 families it stays silent about because they match their library exactly. * * That silent majority is what makes the measurement meaningful. A rule that fired on all 59 * comparable families would be reporting "you reuse a library", which is the intended practice. */ const suite = corpusSuite( 'drift', "DRIFT-1's corpus assertions cannot run.", 'The partial-vs-full distinction came from the corpus, so a green run that never read it proves 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; } interface Hit { project: string; family: string; severity: string; message: string; } let scan: Promise<{ hits: Hit[]; composed: number; withLibraries: number }>; function scanCorpus() { scan ??= (async () => { const reader = nodeTemplateReader(); const io = createFsResolverIO(); const hits: Hit[] = []; let composed = 0; let withLibraries = 0; for (const root of projectRoots(CORPUS)) { const relative = root.slice(CORPUS.length + 1); let result; try { const { mainYml, componentPath } = await searchMain(root, reader); result = await composeProject({ componentPath, mainYml, io, reader, raw: false, }); } catch { continue; } composed++; if (result.libraryVariableSets.length > 0) withLibraries++; const ctx = createValidationContext({ projectTitle: relative, tokenForm: result.tokenForm, outputForm: result.output, variables: result.variables, raw: false, projects: [], reportDir: root, rootMainPath: `${root}/main.yaml`, relative: (_from, to) => to, libraryVariables: result.libraryVariableSets, }); for (const issue of variableDriftRule.run(ctx)) { hits.push({ project: relative, family: issue.idOrKey ?? '-', severity: issue.severity, message: issue.message, }); } } return { hits, composed, withLibraries }; })(); return scan; } suite('DRIFT-1 against the real corpus', () => { it('finds the libraries at all, so nothing below passes vacuously', async () => { const { composed, withLibraries } = await scanCorpus(); // 104 composed, 22 reaching a library that declares variables. If the locator broke, this drops // to zero and every assertion below would pass by finding nothing. expect(composed).toBeGreaterThan(80); expect(withLibraries).toBeGreaterThan(10); }, 900_000); it('reports exactly the two families that really drifted', async () => { const { hits } = await scanCorpus(); expect(hits.map((h) => `${h.project} ${h.family}`).sort()).toEqual([ // The case the story names: `_Puller_Id` regenerated, `_Task_Id`/`_Table_Id` not. '001-projects/variux/maileg/eu SalesOrdersNotSyncedNotification', // Found by the same measurement, with the identical shape. Not in the story, and real. '999-demos/ms-bc-shopify Shopify_Location', ]); }, 900_000); it('names both values, so the divergence can be judged (AC 2)', async () => { const { hits } = await scanCorpus(); const eu = hits.find((h) => h.project === '001-projects/variux/maileg/eu'); // Both sides in the message. "This family has drifted" without the values sends the developer to // diff two files by hand, which is the work the finding exists to save. expect(eu?.message).toContain('f4eade77-d75e-4c48-9c07-1779dcf9dace'); expect(eu?.message).toContain('b1da6b82-2eee-482b-80c5-0f1e9f90f913'); // And it says which role drifted, since that is what tells them what to regenerate. expect(eu?.message).toContain('Puller_Id'); }, 900_000); it('is a WARNING, never an error (AC 3)', async () => { const { hits } = await scanCorpus(); // Intentional divergence is legitimate and the rule cannot know intent. Reporting it as an error // would make a legitimate choice fail a build. expect(new Set(hits.map((h) => h.severity))).toEqual(new Set(['medium'])); }, 900_000); it('stays silent on a family that FULLY diverged', async () => { const { hits } = await scanCorpus(); // `999-demos/ms-bc-shopify` also copies `Shopify_Product`, and regenerated every id in it. That is // a deliberate independent deployment, not a half-finished copy — the distinction the rule is // built on. It must not appear. expect(hits.filter((h) => h.family === 'Shopify_Product')).toEqual([]); }, 900_000); it('says nothing about the 56 families that match their library exactly', async () => { const { hits, withLibraries } = await scanCorpus(); // The silent majority is the point. A rule firing on every comparable family would be reporting // "you reuse a library", which is the intended practice — and would be switched off within a day. expect(hits.length).toBeLessThan(withLibraries / 2); }, 900_000); });