import { describe, it, expect, afterEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { parse } from 'yaml'; import { composeProfile } from '../composeCommand'; import { referenceRule, collectIds, isDynamic, isRefEmpty, } from '@beehexa/hexasync-template-validate'; import { ValidationContext } from '@beehexa/hexasync-template-validate'; let tmp = ''; afterEach(() => { if (tmp && fs.existsSync(tmp)) fs.rmSync(tmp, { recursive: true, force: true }); tmp = ''; }); function write(p: string, content: string) { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, content); } const ctx = (tokenForm: any): ValidationContext => ({ tokenForm }) as ValidationContext; const ruleIds = (tokenForm: any) => referenceRule.run(ctx(tokenForm)).map((i) => i.ruleId); describe('reference helpers', () => { it('collectIds gathers ids per collection incl. nested metric groups', () => { const idx = collectIds({ pullers: [{ id: 'P' }], objects: [{ id: 'O' }], metricGroups: [{ key: 'G1', children: [{ key: 'G2' }] }], }); expect([...idx.pullers]).toEqual(['P']); expect([...idx.objects]).toEqual(['O']); expect(idx.metricGroups.has('G2')).toBe(true); }); it('isDynamic / isRefEmpty', () => { expect(isDynamic('{{ x }}')).toBe(true); expect(isDynamic('**Id**')).toBe(false); expect(isRefEmpty('')).toBe(true); expect(isRefEmpty(null)).toBe(true); expect(isRefEmpty('x')).toBe(false); }); }); describe('referenceRule — dangling edges', () => { it('REF-9 webhook target objectIds → missing object (critical)', () => { const tf = { objects: [{ id: 'O1' }], webhooks: [ { id: 'W', connectionId: 'C', targets: { t: { objectIds: ['O_MISSING'] } }, }, ], connectors: [ // A real connector id: CONN-3 (Story 2.1) checks connections, so a bare `{ id }` is a // connection that names no connector — which this scaffold was only ever "valid" for because // nothing validated connections yet. { id: 'C', connectorId: '667c2966-88e8-466a-b7e1-872f076c8c5c', name: 'Shopify', }, ], }; const issues = referenceRule.run(ctx(tf)); const ref9 = issues.find((i) => i.ruleId === 'REF-9'); expect(ref9).toBeTruthy(); expect(ref9!.severity).toBe('critical'); expect(ref9!.parentKey).toBe('webhooks'); expect(ref9!.idOrKey).toBe('W'); }); it('REF-1/REF-2 association puller/pusher missing (high)', () => { const tf = { pullers: [{ id: 'P_OK' }], pushers: [], tables: [{ id: 'T' }], objectAssociations: { TASK1: { puller: { id: 'P_MISSING', resultKey: 'O_MISSING' }, pusher: { id: 'PUSH_MISSING' }, table: { id: 'T' }, }, }, objects: [], }; const ids = ruleIds(tf); expect(ids).toContain('REF-1'); // puller missing expect(ids).toContain('REF-2'); // pusher missing expect(ids).not.toContain('REF-3'); // table T exists expect(ids).not.toContain('REF-4'); // resultKey is NOT an object ref — not validated }); it('REF-1 suppressed when the task is fed by a DIRECT_INDEX step elsewhere', () => { // A DIRECT_INDEX step in another puller writes rows straight into TASK1, so TASK1 is // populated even though its association names a puller that is not defined. Not a defect. const fed = { pullers: [ { id: 'P_FEEDER', pullSteps: [ { key: 'SAVE_DIRECT_INDEX', displayType: 'DIRECT_INDEX', data: { objectIds: ['TASK1'], dataPath: '$.items[*]' }, }, ], }, ], tables: [{ id: 'T' }], objectAssociations: { TASK1: { puller: { id: 'P_MISSING' }, table: { id: 'T' } }, }, objects: [{ id: 'TASK1' }], }; expect(ruleIds(fed)).not.toContain('REF-1'); // Same dangling association but nothing feeds TASK1 → REF-1 still fires (true positive). const orphaned = { ...fed, pullers: [{ id: 'P_FEEDER', pullSteps: [] }] }; expect(ruleIds(orphaned)).toContain('REF-1'); // A pusher's DIRECT_INDEX step (via data.objectId singular) also feeds the task. const fedByPusher = { pushers: [ { id: 'PUSH1', pushSteps: [ { key: 'SAVE', displayType: 'DIRECT_INDEX', data: { objectId: 'TASK1', dataPath: '$.x[*]' }, }, ], }, ], objectAssociations: { TASK1: { puller: { id: 'P_MISSING' } } }, objects: [{ id: 'TASK1' }], }; expect(ruleIds(fedByPusher)).not.toContain('REF-1'); }); it('REF-5 transformations/validations keyed to missing object (high)', () => { const tf = { objects: [{ id: 'O1' }], transformations: { O1: [], O_MISSING: [] }, validations: { O1: [] }, }; const issues = referenceRule .run(ctx(tf)) .filter((i) => i.ruleId === 'REF-5'); expect(issues).toHaveLength(1); expect(issues[0].idOrKey).toBe('O_MISSING'); }); it('REF-13 step object refs (in a puller); connection edges are NOT checked', () => { const tf = { objects: [{ id: 'O1' }], connectors: [ // A real connector id: CONN-3 (Story 2.1) checks connections, so a bare `{ id }` is a // connection that names no connector — which this scaffold was only ever "valid" for because // nothing validated connections yet. { id: 'C', connectorId: '667c2966-88e8-466a-b7e1-872f076c8c5c', name: 'Shopify', }, ], pullers: [ { id: 'P', pullSteps: [ { key: 'S1', connectorId: 'C_MISSING' }, // deferred: no connection check { key: 'S2', displayType: 'DIRECT_INDEX', data: { objectIds: ['O_MISSING'] }, }, ], }, ], }; const ids = ruleIds(tf); expect(ids).toContain('REF-13'); // object ref still validated expect(ids).not.toContain('REF-12'); // step connectorId deferred to v2 }); it('does not crash on a cyclic metricGroups.children graph (YAML anchor)', () => { // The cycle guard lives in walkMetricGroups, exercised via REF-7 key collection. const g: any = { key: 'G' }; g.children = [g]; // self-referential cycle const tf = { metricGroups: [g], objects: [{ id: 'O', metrics: [{ id: 'M', groups: ['G'] }] }], }; expect(() => referenceRule.run(ctx(tf))).not.toThrow(); // 'G' resolves (defined in the cyclic tree) so the metric is not flagged. expect(ruleIds(tf).filter((r) => r === 'REF-7')).toEqual([]); }); it('skips non-string/non-primitive reference values (no "[object Object]")', () => { const tf = { objects: [{ id: 'O1' }], webhooks: [ { id: 'W', targets: { t: { objectIds: [{ nested: true }] } } }, ], }; expect(referenceRule.run(ctx(tf))).toEqual([]); }); it('REF-15 skips entityId when entityType is not object-like', () => { const objectLike = { startupTasks: [{ key: 'S', metadata: { entityId: 'GHOST' } }], objects: [], }; const referenceType = { startupTasks: [ { key: 'S', metadata: { entityType: 'REFERENCE', entityId: 'REFX' } }, ], objects: [], }; expect(ruleIds(objectLike)).toContain('REF-15'); // default entityType = TASK → checked expect(ruleIds(referenceType)).not.toContain('REF-15'); // REFERENCE → skipped }); it('REF-7 resolves metric groups defined per-object (objects[].metricGroups), not only top-level', () => { const tf = { objects: [ { id: 'O', metricGroups: [{ key: 'ALL' }, { key: 'PHONE' }], metrics: [ { id: 'M1', groups: ['ALL'] }, { id: 'M2', groups: ['PHONE', 'MISSING_GROUP'] }, ], }, ], }; const ref7 = referenceRule.run(ctx(tf)).filter((i) => i.ruleId === 'REF-7'); // ALL + PHONE resolve (per-object); only MISSING_GROUP is flagged expect(ref7).toHaveLength(1); expect(ref7[0].message).toContain('MISSING_GROUP'); }); it('REF compares resolved values: value-aliased tokens (different token, same value) do NOT flag', () => { // The association references the puller by one token; the puller is defined under a // different token that resolves to the SAME uuid — a real value-alias. No REF-1. const tf = { pullers: [{ id: '**Inventory_Puller_Id**' }], objectAssociations: { TASK1: { puller: { id: '**InventoryPuller**' } }, }, }; const ctxWithVars = { tokenForm: tf, variables: { '**Inventory_Puller_Id**': 'a611640c', '**InventoryPuller**': 'a611640c', }, } as unknown as ValidationContext; expect( referenceRule.run(ctxWithVars).filter((i) => i.ruleId === 'REF-1'), ).toEqual([]); // Sanity: a token that resolves to a value no puller has IS still flagged. const bad = { ...ctxWithVars, variables: { '**Inventory_Puller_Id**': 'a611640c', '**InventoryPuller**': 'DIFFERENT', }, } as unknown as ValidationContext; expect( referenceRule.run(bad).filter((i) => i.ruleId === 'REF-1'), ).toHaveLength(1); }); it('does not double-report an undeclared-variable reference (VAR-1 owns residual tokens)', () => { // The pusher.id reference is an undeclared variable → it survives as a residual **token**. // VAR-1 flags "unresolved token" (CRITICAL); REF-2 must NOT also flag it as dangling. const tf = { pushers: [{ id: 'REAL_PUSHER' }], objectAssociations: { TASK1: { pusher: { id: '**Undeclared_Pusher_Id**' } }, }, transformations: { '**Undeclared_Object_Id**': [] }, objects: [], }; const withVars = { tokenForm: tf, variables: {}, // nothing declared → tokens stay residual } as unknown as ValidationContext; const ids = referenceRule.run(withVars).map((i) => i.ruleId); expect(ids).not.toContain('REF-2'); // residual token → skipped, VAR-1 owns it expect(ids).not.toContain('REF-5'); // same for the transformations map key }); it('skips dynamic {{…}} and unset references; clean profile → no issues', () => { const tf = { objects: [{ id: 'O1' }], connectors: [ // A real connector id: CONN-3 (Story 2.1) checks connections, so a bare `{ id }` is a // connection that names no connector — which this scaffold was only ever "valid" for because // nothing validated connections yet. { id: 'C', connectorId: '667c2966-88e8-466a-b7e1-872f076c8c5c', name: 'Shopify', }, ], webhooks: [ { id: 'W', connectionId: 'C', targets: { t: { objectIds: ['O1', '{{ dynamic }}'] } }, }, ], }; expect(referenceRule.run(ctx(tf))).toEqual([]); }); }); describe('composeProfile — REF integrated into the report', () => { it('flags a dangling webhook objectId as CRITICAL in the report', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ref-')); write(path.join(tmp, 'proj/partials/main.yaml'), `externals: []\n`); write( path.join(tmp, 'proj/partials/W.yaml'), `connectors:\n - id: C\n connectorId: 667c2966-88e8-466a-b7e1-872f076c8c5c\n name: Shopify\nobjects:\n - id: O1\n name: t\nwebhooks:\n - id: W\n connectionId: C\n targets:\n t:\n objectIds:\n - GHOST\n`, ); await composeProfile(path.join(tmp, 'proj'), false); const md = fs.readFileSync( path.join(tmp, 'proj/output.validation.report.md'), 'utf8', ); expect(md).toContain('CRITICAL'); expect(md).toContain('REF-9'); expect(md).toContain('GHOST'); }); it('a fully-valid project reports only that catalog checks could not run', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ref-')); write(path.join(tmp, 'proj/partials/main.yaml'), `externals: []\n`); write( path.join(tmp, 'proj/partials/W.yaml'), `connectors:\n - id: C\n connectorId: 667c2966-88e8-466a-b7e1-872f076c8c5c\n name: Shopify\nobjects:\n - id: O1\n name: t\nwebhooks:\n - id: W\n connectionId: C\n targets:\n t:\n objectIds:\n - O1\n`, ); await composeProfile(path.join(tmp, 'proj'), false); const md = fs.readFileSync( path.join(tmp, 'proj/output.validation.report.md'), 'utf8', ); /** * CHANGED by Story 2.2, and the change is the point. * * This asserted `✓ No validation issues`. A bare `hexasync compose` has no connector catalog — it ships in * the VS Code extension's asset tree by decision (`addendum.md` §2) — so four of the five CONN bands cannot * run here, and `CONN-0` says so out loud rather than letting silence pass for success. Story 1.5 shipping a * deprecation that reached no surface, and a corpus gate reporting green having asserted nothing, are what * that notice exists to prevent. * * So the assertion is now stronger than it was: the project is valid AND the one thing the report says is * the thing that is true — that a check was skipped, not that everything passed. */ expect(md).toContain('CONN-0'); expect(md).toContain('Connector-catalog checks were skipped'); /** * Asserted on the FINDING markers, not on bare rule ids: `CONN-0`'s own appendix entry names the four checks * it skipped, so a `toContain('CONN-1')` matches that prose and fails for the wrong reason. Caught on the * first run of this assertion. */ expect(md, 'a valid project has no error-or-above finding').not.toMatch( /\*\*\[(HIGH|CRITICAL)\]/, ); for (const band of ['CONN-1', 'CONN-2', 'CONN-3', 'CONN-4', 'CONN-5']) { expect( md, `${band} must not be FLAGGED on a valid project`, ).not.toContain(`[\`${band}\`](#${band.toLowerCase()})`); } }); });