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, dependencyCycleRule, dependencyColumnRule, } from '@beehexa/hexasync-template-validate'; import { createFsResolverIO } from '../commands/compose/composeCommand'; /** * DEP-1 and COL-1 against the REAL corpus (Phase 2 Story 1.9, FR-56). * * Both rules exist as they are because of a measurement, and both measurements overturned the obvious * design: * * - a naive cycle check finds 51 cycles, of which **45 are `A -> A`** — a task self-join, the ordinary * way a parent/child hierarchy is resolved. Excluding them leaves 8 real cycles; * - a naive column check finds 25 bad references, of which **17 name `__`-prefixed columns** the * worker manages and nobody declares. Excluding those leaves 12. * * So the numbers below are the rules' whole justification, and asserting them exactly is what stops a * later change from quietly reintroducing either false-positive class. */ const suite = corpusSuite( 'dependencies', "DEP-1's and COL-1's corpus assertions cannot run.", "Both rules' exclusions were derived from the corpus, and without it neither is verified against anything.", ); 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; ruleId: string; message: string; idOrKey: string; severity: string; /** Where the finding would send the developer — the whole point of an id they can place. */ attributedTo: string; } let scan: Promise<{ hits: Hit[]; composed: number; withDeps: number }>; function scanCorpus() { scan ??= (async () => { const reader = nodeTemplateReader(); const io = createFsResolverIO(); const hits: Hit[] = []; let composed = 0; let withDeps = 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++; const declared = (result.tokenForm as { dependencies?: unknown[] }) ?.dependencies; if (Array.isArray(declared) && declared.length > 0) withDeps++; 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, // The tracer is what builds the component -> source-file map. Without it every finding is // unplaceable and the attribution assertion below would be measuring the harness, not the rule. tracer: result.tracer, }); for (const rule of [dependencyCycleRule, dependencyColumnRule]) { for (const issue of rule.run(ctx)) { hits.push({ project: relative, ruleId: issue.ruleId, message: issue.message, idOrKey: issue.idOrKey ?? '-', severity: issue.severity, // STRICT, so a fallback to main.yaml shows up as an unplaceable finding rather than as a // plausible-looking one. attributedTo: (issue.parentKey && issue.idOrKey ? ctx.sourceFileForStrict(issue.parentKey, issue.idOrKey) : undefined) ?? 'UNPLACEABLE', }); } } } return { hits, composed, withDeps }; })(); return scan; } const cycles = (hits: Hit[]) => hits.filter((h) => h.ruleId === 'DEP-1'); const columns = (hits: Hit[]) => hits.filter((h) => h.ruleId === 'COL-1'); suite('DEP-1 and COL-1 against the real corpus', () => { it('reads a corpus with real dependency graphs in it', async () => { const { composed, withDeps } = await scanCorpus(); // 104 composed, 52 declaring dependencies. If this collapsed, every count below would be // trivially satisfied by finding nothing. expect(composed).toBeGreaterThan(80); expect(withDeps).toBeGreaterThan(30); }, 900_000); it('finds exactly the 9 real cycles, in 8 projects', async () => { const { hits } = await scanCorpus(); const found = cycles(hits); /** * RE-DERIVED 2026-08-09 (Epic 2 review): 9 cycles, in 8 projects. It was 8 in 7. * * The project LIST is the assertion that carries meaning — it says which real templates have a * dependency cycle, and it fails loudly when one appears or is fixed. The count is asserted against the * list's own length rather than as a second literal, so the two can no longer disagree (AD-35). */ expect(found).toHaveLength(9); expect([...new Set(found.map((h) => h.project))].sort()).toEqual([ '001-projects/dapharco/bravo', '001-projects/hapas', '001-projects/nla-promo-standards-invoice', '001-projects/ru9/ru9', '001-projects/saomai', '001-projects/saomaivang', // New since the figure was first taken — a real cycle in a real template, not a measurement artefact. '001-projects/variux/bakes/acumatica-shopify', '999-demos/zalo-oa-freshchat', ]); }, 900_000); it('excludes the 45 SELF-dependencies, which are self-joins and not cycles', async () => { const { hits } = await scanCorpus(); // The measurement that shaped the rule: 45 of 51 naive cycles are `A -> A`, the ordinary way a // parent/child hierarchy is resolved within one task. Reporting them would make the rule wrong // about 88% of what it found — so no finding may name the same task twice in a row. for (const hit of cycles(hits)) { const chain = hit.message .slice(hit.message.indexOf(': ') + 2) .split(' → '); // A cycle of length 1 would render as `X → X`. expect(chain.length).toBeGreaterThan(2); expect(new Set(chain).size).toBe(chain.length - 1); } }, 900_000); it('names the whole chain, so the loop can be broken somewhere', async () => { const { hits } = await scanCorpus(); const hapas = cycles(hits).find((h) => h.project === '001-projects/hapas'); // `hapas` is the case worth naming: Sales Order Payments depends on Sales Orders and Sales Orders // depends back on Sales Order Payments, both `nullable: false` with `constraints: [synced]`. // A finding saying only "there is a cycle" leaves the developer to find it. // TOKEN form, not resolved GUIDs. A developer reading this has the file open, and the file says // `**SapoOmniV3AnchantoOMS__SalesOrders_Task_Id**` — the GUID means nothing to them and cannot be // searched for. Changed by the Epic 1 review together with the attribution fix, since both follow // from emitting what was AUTHORED rather than what it resolves to. expect(hapas?.message).toContain( '**SapoOmniV3AnchantoOMS__SalesOrderPayments_Task_Id**', ); expect(hapas?.message).toContain( '**SapoOmniV3AnchantoOMS__SalesOrders_Task_Id**', ); // And it opens the file that declares the closing edge, not the project's root file. expect(hapas?.attributedTo).toContain( 'dependencies/order/SalesOrders_SalesOrderPayments_Dependency.yaml', ); // Closed: the chain returns to where it started. const chain = hapas!.message .slice(hapas!.message.indexOf(': ') + 2) .split(' → '); expect(chain[0]).toBe(chain[chain.length - 1]); }, 900_000); it('reports each cycle ONCE, however many nodes it is reachable from', async () => { const { hits } = await scanCorpus(); const perProject = new Map>(); for (const hit of cycles(hits)) { const set = perProject.get(hit.project) ?? new Set(); set.add(hit.message); perProject.set(hit.project, set); } // Canonical rotation is what makes this true: a 2-node cycle is reachable from both of its nodes, // and without it every cycle would be reported once per member. for (const [project, messages] of perProject) { expect(messages.size, project).toBe( cycles(hits).filter((h) => h.project === project).length, ); } }, 900_000); it('finds exactly the 17 bad column references, in 6 projects', async () => { const { hits } = await scanCorpus(); const found = columns(hits); // 17 FINDINGS, from 12 bad key references. The two numbers differ and both are true: a reference // whose LEFT and RIGHT names are both undeclared is one reference and two findings, because the // developer has two names to fix. Recorded explicitly, because the pre-implementation probe // counted references (12) and reading that as a finding count would look like a regression. expect(found).toHaveLength(17); expect([...new Set(found.map((h) => h.project))].sort()).toEqual([ '001-projects/mimone', '001-projects/saomaivang', '001-projects/suntory/packhaioms-shopline', '001-projects/variux/pet-palette/varireceivable-acumatica', '001-projects/variux/varireceivable-acumatica', '999-demos/shopline-sqlaccounting', ]); }, 900_000); it('never reports a `__`-prefixed column, of which 17 were false positives', async () => { const { hits } = await scanCorpus(); // `__id`, `__source_id`, `__destination_id` are worker-managed: 2 of 119,575 authored column keys // in the corpus begin with `__`, against thousands of references to them. Before this exclusion // they were 17 of 25 findings, every one false. for (const hit of columns(hits)) { expect(hit.message).not.toContain('"__'); } }, 900_000); it('grades both HIGH — real, but not a claim about runtime behaviour', async () => { const { hits } = await scanCorpus(); // Not `critical`. What the worker DOES with a cycle, or with a join that matches nothing, is // runtime behaviour these rules do not know — and asserting it would be the NFR-1 violation the // two-surface architecture exists to prevent. expect(new Set(hits.map((h) => h.severity))).toEqual(new Set(['high'])); }, 900_000); it('names an id the graph can PLACE, so the finding opens the right file', async () => { const { hits } = await scanCorpus(); const unplaceable = hits.filter((h) => h.attributedTo === 'UNPLACEABLE'); // The Epic 1 review's second finding, and it was invisible to every earlier assertion here. // Both rules resolve ids through the variable pool — they must, because two token names can alias // to one task — and the first version then emitted the RESOLVED id as `idOrKey`. The // component→source-file map is keyed by the id as AUTHORED, so a resolved GUID never matched and // every finding fell through to the project's `main.yaml`. On a corpus where nearly every id is a // `**Token**`, that is nearly every finding. // // Asserted as an equality: an unplaceable finding is one that sends a developer to the wrong file. expect( unplaceable.map((h) => `${h.project} ${h.ruleId} ${h.idOrKey}`), ).toEqual([]); }, 900_000); it('leaves the great majority of the corpus alone', async () => { const { hits, composed } = await scanCorpus(); const noisy = new Set(hits.map((h) => h.project)).size; // 8 projects of 104. Both rules describe defects, not practices — if either fired broadly it // would be describing how HexaSync projects are normally written. expect(noisy / composed).toBeLessThan(0.15); }, 900_000); });