import { describe, it, expect } from 'vitest'; import { CORPUS, corpusSuite } from './corpusCheckout'; import { nodeTemplateReader } from '@beehexa/hexasync-template-io-node'; import { composeProject, searchMain } from '@beehexa/hexasync-template-compose'; import { buildEffectiveGraph } from '@beehexa/hexasync-template-index'; import { createValidationContext, validationContextFromGraph, runValidation, } from '@beehexa/hexasync-template-validate'; import { createFsResolverIO } from '../commands/compose/composeCommand'; /** * Phase 2 Story 1.1b AC 3 — the editor and the CLI produce the IDENTICAL issue set. * * This is counter-metric C3, and it is the only test in the repo that can hold it. The two paths * genuinely differ in construction: the CLI derives each component's source file from the compose * TRACER's depth-0 events, while the graph carries provenance on the components. Two records of the * same fact, and nothing but a comparison over real projects can say whether they agree. * * Over the CORPUS rather than fixtures, deliberately. A fixture proves the adapter runs; only real * projects have the inheritance depth, the value-aliased tokens, the same-id-in-two-collections cases * and the 4,182 nested reference blocks that make the two constructions capable of disagreeing. */ const suite = corpusSuite( 'parity', 'The editor/CLI parity comparison cannot run, so counter-metric C3 is unverified.', '"The editor and CI cannot disagree" asserted by a test that did not run is not a guarantee.', ); /** * Projects spanning the corpus's real shapes. * * One standalone, one deeply inheriting connector template, one large project — the same spread the * NFR-5 measurements use, so a disagreement that only appears with inheritance depth is reachable. * * `maileg/eu` was added for Story 1.5. It is the only shape in this list that carries a DUP-1 * collision, and it inherits `002-components/hexasync-audit`, so it exercises both halves of that * rule at once — the value-aliased pair it must report and the shared inherited Task/Table ids it * must not. A rule verified only by the CLI's own harness is a rule whose editor behaviour is * assumed, and C3 is the assumption this file exists to remove. */ const PROJECTS = [ '001-projects/aquavn', '003-templates/sapoomni-v3-connector', '001-projects/hapas', '001-projects/variux/maileg/eu', ] as const; /** An issue reduced to what a developer would notice, so the comparison is about findings. */ const shapeOf = ( issues: readonly { ruleId: string; parentKey?: string; idOrKey?: string; message: string; }[], ) => issues .map( (i) => `${i.ruleId} ${i.parentKey ?? '-'}/${i.idOrKey ?? '-'} ${i.message}`, ) .sort(); suite('the editor and the CLI agree about validity (C3)', () => { for (const relative of PROJECTS) { it(`finds the same issues in ${relative}`, async () => { const reader = nodeTemplateReader(); const io = createFsResolverIO(); const root = `${CORPUS}/${relative}`; const { mainYml, componentPath } = await searchMain(root, reader); // --- The CLI's path: compose, then a context built from the tracer. const composed = await composeProject({ componentPath, mainYml, io, reader, raw: false, }); const cliCtx = createValidationContext({ projectTitle: relative, tokenForm: composed.tokenForm, outputForm: composed.output, variables: composed.variables, raw: false, projects: composed.projects, reportDir: componentPath, rootMainPath: `${componentPath}/main.yaml`, tracer: composed.tracer, relative: (_from, to) => to, unadmittedVariableFiles: composed.unadmittedVariableFiles, libraryVariables: composed.libraryVariableSets, overwrites: composed.tracer .overwrites() .filter((o) => o.depth === 0 && o.hadComponents), }); // --- The editor's path: Tier 2's graph, then a context built from provenance. const graph = await buildEffectiveGraph( componentPath, componentPath, mainYml, { io, reader, }, ); const editorCtx = validationContextFromGraph({ projectTitle: relative, tokenForm: graph.mergedValue, // The composer's own substituted form, so VAR-1 compares like with like. outputForm: composed.output, variables: graph.variables, raw: false, componentPath, components: graph.effective.map((c) => ({ collection: c.collection, id: c.id, sourceFile: c.provenance.sourceFile, })), projects: composed.projects, // Carried on the graph now. Dropping it made the editor find FEWER issues than the CLI — // VAR-3 and VAR-4 both report on it, and only the resolver can know it. unadmittedVariableFiles: graph.unadmittedVariableFiles, libraryVariables: graph.libraryVariableSets, overwrites: graph.overwrites, }); const fromCli = shapeOf(runValidation(cliCtx)); const fromEditor = shapeOf(runValidation(editorCtx)); // The premise, asserted so a project that happens to be clean cannot pass this vacuously: a // comparison of two empty sets proves nothing about whether the constructions agree. expect(fromCli.length + fromEditor.length).toBeGreaterThan(0); expect(fromEditor).toEqual(fromCli); // Story 1.5. Equality above is satisfied when NEITHER side finds a DUP-1, so the one project // that carries one says so explicitly — otherwise adding this project proves nothing about the // rule it was added for. if (relative === '001-projects/variux/maileg/eu') { expect(fromEditor.filter((s) => s.startsWith('DUP-1 '))).toHaveLength( 2, ); } }, 120_000); } it('attributes a component to its own file, never to a folder', async () => { const reader = nodeTemplateReader(); const io = createFsResolverIO(); const { mainYml, componentPath } = await searchMain( `${CORPUS}/003-templates/sapoomni-v3-connector`, reader, ); const graph = await buildEffectiveGraph( componentPath, componentPath, mainYml, { io, reader, }, ); const ctx = validationContextFromGraph({ projectTitle: 'x', tokenForm: graph.mergedValue, variables: graph.variables, componentPath, components: graph.effective.map((c) => ({ collection: c.collection, id: c.id, sourceFile: c.provenance.sourceFile, })), }); // Every attribution has to be a FILE. A diagnostic on a folder URI is dropped silently by the // editor, which is a defect the Phase 1 review found in the index's own failure path. const attributions = graph.effective.map((c) => ctx.sourceFileFor(c.collection, c.id), ); expect( attributions.every((f) => typeof f === 'string' && /\.ya?ml$/.test(f)), ).toBe(true); // And a component the graph could not attribute falls back to main.yaml rather than to nothing. expect(ctx.sourceFileFor('objects', 'DOES_NOT_EXIST')).toBe( `${componentPath}/main.yaml`, ); }, 120_000); });