import { describe, it, expect, afterEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { composeProfile } from '../composeCommand'; import { duplicatesRule } 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 run = (tf: any) => duplicatesRule.run(ctx(tf)); describe('duplicatesRule — DUP-3 step keys', () => { it('flags duplicate step keys in a puller workflow (critical)', () => { const tf = { pullers: [ { id: 'P', pullSteps: [ { key: 'S', note: 'one' }, { key: 'S', note: 'two' }, { key: 'T' }, ], }, ], }; const issues = run(tf).filter((i) => i.ruleId === 'DUP-3'); expect(issues).toHaveLength(1); expect(issues[0].severity).toBe('critical'); expect(issues[0].parentKey).toBe('pullers'); expect(issues[0].idOrKey).toBe('P'); expect(issues[0].message).toContain('"S"'); expect(issues[0].locateValue).toBe('S'); }); it('flags duplicate step keys in a webhook target', () => { const tf = { webhooks: [ { id: 'W', targets: { t: { pullSteps: [{ key: 'A' }, { key: 'A' }] } }, }, ], }; const ids = run(tf).map((i) => i.ruleId); expect(ids).toContain('DUP-3'); }); it('ignores dynamic/empty step keys', () => { const tf = { pushers: [ { id: 'X', pushSteps: [{ key: '{{v}}' }, { key: '{{v}}' }, { key: '' }, {}], }, ], }; expect(run(tf)).toEqual([]); }); }); describe("duplicatesRule — a pusher's final stage is checked ONCE", () => { /** * ⛔ Epic 4 close review, 2026-08-12. `finalSteps` appeared TWICE in the pushers phase array, so `checkStepDupes` * ran twice over the same list and every duplicate key there produced two identical findings — nothing dedupes * issues downstream, so an author saw the same defect reported twice in the report and in the editor. * * This is why the copy-paste survived: the stage was ADDED correctly in the same commit, and every assertion that * asked "is `finalSteps` covered?" passed. Only counting catches it. */ it('reports one DUP-3 for one pair of duplicate keys, not two', () => { const issues = run({ pushers: [ { id: 'W', finalSteps: [{ key: 'CLEANUP' }, { key: 'CLEANUP' }] }, ], }).filter((i) => i.ruleId === 'DUP-3'); expect(issues).toHaveLength(1); expect(issues[0].message).toContain('CLEANUP'); expect(issues[0].message).toContain('finalSteps'); }); it("still reports each of a pusher's four stages exactly once", () => { // The other half of the guard: a phase dropped from the array is as wrong as one listed twice, and this epic // counted SIX instances of a stage silently missing from a hand-kept list. const dupes = [{ key: 'D' }, { key: 'D' }]; const issues = run({ pushers: [ { id: 'W', beforePushSteps: dupes, pushSteps: dupes, afterPushSteps: dupes, finalSteps: dupes, }, ], }).filter((i) => i.ruleId === 'DUP-3'); expect(issues).toHaveLength(4); expect(issues.map((i) => i.message).join(' ')).toContain('finalSteps'); }); }); describe('duplicatesRule — DUP-4 nested ids', () => { it('flags duplicate metric ids on an object (medium)', () => { const tf = { objects: [{ id: 'O', metrics: [{ id: 'M' }, { id: 'M' }, { id: 'N' }] }], }; const issues = run(tf).filter((i) => i.ruleId === 'DUP-4'); expect(issues).toHaveLength(1); expect(issues[0].severity).toBe('medium'); expect(issues[0].idOrKey).toBe('O'); expect(issues[0].message).toContain('metric id "M"'); }); it('flags duplicate transformation ids within a task array', () => { const tf = { transformations: { O1: [{ id: 'X' }, { id: 'X' }] }, }; const issues = run(tf).filter((i) => i.ruleId === 'DUP-4'); expect(issues).toHaveLength(1); expect(issues[0].parentKey).toBe('transformations'); expect(issues[0].idOrKey).toBe('O1'); expect(issues[0].message).toContain('transformation id "X"'); }); it('does not treat non-primitive ids as duplicates (no "[object Object]")', () => { const tf = { objects: [ { id: 'O', metrics: [{ id: { x: 1 } }, { id: { y: 2 } }, { id: [] }] }, ], }; expect(run(tf)).toEqual([]); }); it('a clean profile yields no duplicate issues', () => { const tf = { pullers: [{ id: 'P', pullSteps: [{ key: 'A' }, { key: 'B' }] }], objects: [{ id: 'O', metrics: [{ id: 'M' }] }], }; expect(run(tf)).toEqual([]); }); }); describe('composeProfile — DUP integrated into the report', () => { it('reports duplicate step keys as CRITICAL (they survive the merge)', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dup-')); write(path.join(tmp, 'proj/partials/main.yaml'), `externals: []\n`); write( path.join(tmp, 'proj/partials/P.yaml'), `pullers:\n - id: P\n pullSteps:\n - key: S\n note: one\n - key: S\n note: two\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('DUP-3'); }); }); describe('duplicatesRule — DUP-5 cross-list step keys', () => { it('flags a step key reused across a puller step-lists (high)', () => { const tf = { pullers: [ { id: 'P', steps: [{ key: 'FETCH' }, { key: 'SAVE' }], finalSteps: [{ key: 'FETCH' }], // FETCH collides across steps + finalSteps }, ], }; const issues = run(tf).filter((i) => i.ruleId === 'DUP-5'); expect(issues).toHaveLength(1); expect(issues[0].severity).toBe('high'); expect(issues[0].idOrKey).toBe('P'); expect(issues[0].locateValue).toBe('FETCH'); expect(issues[0].message).toContain('multiple step-lists'); }); it('does not flag distinct keys across lists', () => { const tf = { pullers: [ { id: 'P', steps: [{ key: 'A' }], finalSteps: [{ key: 'B' }], onErrorSteps: [{ key: 'C' }], }, ], }; expect(run(tf).filter((i) => i.ruleId === 'DUP-5')).toHaveLength(0); }); it('does NOT flag the same step key in two different webhook TARGETS', () => { const tf = { webhooks: [ { id: 'W', targets: { t1: { pullSteps: [{ key: 'X' }] }, t2: { pullSteps: [{ key: 'X' }] }, }, }, ], }; // A webhook target is its own execution scope: keys are unique WITHIN a target, not across them. // This test asserted the opposite until the reviewer who owns the runtime corrected it, and the // corpus agreed — 16 of DUP-5's 30 findings were webhooks, every one the same authoring pattern // repeated across four unrelated projects. expect(run(tf).filter((i) => i.ruleId === 'DUP-5')).toEqual([]); }); it('still flags a duplicate key WITHIN one webhook target', () => { const tf = { webhooks: [ { id: 'W', targets: { t1: { pullSteps: [{ key: 'X' }, { key: 'X' }] } }, }, ], }; // The narrowing must not lose the real case: two steps with one key inside a single target is // ambiguous routing, and DUP-3 owns it. const issues = run(tf).filter((i) => i.ruleId === 'DUP-3'); expect(issues).toHaveLength(1); expect(issues[0].locateValue).toBe('X'); }); }); /** * DUP-1 — Phase 2 Story 1.5 (FR-57). * * The case the merge cannot collapse: two DIFFERENT keys whose variable values coincide. The corpus * carries four of them and every one is a harmless alias, so the `high` path — a collision whose two * declarations genuinely differ — exists only here. Stated rather than glossed: these are the tests * that carry that severity, and there is no real-world instance behind it. */ const withVars = (tokenForm: any, variables: Record) => duplicatesRule .run({ tokenForm, variables } as ValidationContext) .filter((i) => i.ruleId === 'DUP-1'); describe('duplicatesRule — DUP-1 resolved-id collisions', () => { const ALIASED = { '**ConnA**': 'c-1', '**ConnB**': 'c-1', }; it('flags two token names bound to one id, at BOTH locations', () => { const issues = withVars( { connectors: [ { id: '**ConnA**', name: 'Self' }, { id: '**ConnB**', name: 'Self' }, ], }, ALIASED, ); // AC 1 says "at both locations". One issue per token is what lets the publisher put a squiggle // on each declaration — a single finding would send the developer to one of the two files and // leave the other looking innocent. expect(issues.map((i) => i.idOrKey).sort()).toEqual([ '**ConnA**', '**ConnB**', ]); expect(new Set(issues.map((i) => i.parentKey))).toEqual( new Set(['connectors']), ); }); it('grades an identical pair LOW and a diverging pair HIGH', () => { const identical = withVars( { connectors: [ { id: '**ConnA**', name: 'Self', options: [{ key: 'K', value: 1 }] }, { id: '**ConnB**', name: 'Self', options: [{ key: 'K', value: 1 }] }, ], }, ALIASED, ); const diverging = withVars( { connectors: [ { id: '**ConnA**', name: 'Self', options: [{ key: 'K', value: 1 }] }, { id: '**ConnB**', name: 'Self', options: [{ key: 'K', value: 2 }] }, ], }, ALIASED, ); // The split IS the rule. Without it, four real projects each get two errors for declaring the // same connector twice — and a rule wrong on every occurrence gets switched off, taking the // diverging case with it. expect(new Set(identical.map((i) => i.severity))).toEqual(new Set(['low'])); expect(new Set(diverging.map((i) => i.severity))).toEqual( new Set(['high']), ); expect(diverging[0].why).toContain('silently discarded'); }); it('ignores field ORDER when deciding whether two declarations match', () => { const issues = withVars( { connectors: [ { id: '**ConnA**', name: 'Self', providerId: 'p' }, { providerId: 'p', name: 'Self', id: '**ConnB**' }, ], }, ALIASED, ); // Same component, written in a different order. Grading this `high` would report data loss where // there is none — YAML key order is not semantics. expect(new Set(issues.map((i) => i.severity))).toEqual(new Set(['low'])); }); it('compares content through the variable pool, not as raw text', () => { const issues = withVars( { connectors: [ { id: '**ConnA**', providerId: '**ProviderId**' }, { id: '**ConnB**', providerId: 'p-1' }, ], }, { ...ALIASED, '**ProviderId**': 'p-1' }, ); // `sample-profile`'s real shape, and the false positive it produced. Compared as text the two // providerIds differ; resolved, they are the same value. This is the same mistake // `makeTokenResolver` was written to prevent, made one level deeper than the id. expect(new Set(issues.map((i) => i.severity))).toEqual(new Set(['low'])); }); it('says nothing when two keys resolve to DIFFERENT ids', () => { expect( withVars( { connectors: [{ id: '**ConnA**' }, { id: '**ConnB**' }], }, { '**ConnA**': 'c-1', '**ConnB**': 'c-2' }, ), ).toEqual([]); }); it('says nothing about a project with no variables at all', () => { // Identity resolution. A synthetic token-form fixture with no pool must not have every token // collapse to itself and collide — which is what a resolver returning '' would produce. expect( withVars({ connectors: [{ id: '**ConnA**' }, { id: '**ConnB**' }] }, {}), ).toEqual([]); }); it('does not treat the same id in two DIFFERENT collections as a collision', () => { // A puller and a table sharing a GUID happens three times in the corpus. Whether HexaSync // namespaces ids per entity type is not something this rule knows, and reporting it would be // asserting an answer that has not been established. expect( withVars( { pullers: [{ id: '**ConnA**' }], tables: [{ id: '**ConnB**' }] }, ALIASED, ), ).toEqual([]); }); it('flags a colliding pair of MAP keys, where the loser is dropped wholesale', () => { const issues = withVars( { objectAssociations: { '**ConnA**': { taskId: 'a' }, '**ConnB**': { taskId: 'b' }, }, }, ALIASED, ); // A map collection is worse than an array one: two keys resolving to one key means the output // map has a single entry, so an entire association block vanishes rather than being merged. expect(issues).toHaveLength(2); expect(new Set(issues.map((i) => i.severity))).toEqual(new Set(['high'])); }); it('holds back on ids containing a runtime expression, deliberately', () => { // Two DIFFERENT ids that token-resolve to the same string, both carrying a `{{ }}` expression. const issues = withVars( { connectors: [{ id: '{{ env.A }}**S1**' }, { id: '{{ env.A }}**S2**' }], }, { '**S1**': 'x', '**S2**': 'x' }, ); // Reported as a LIMITATION, not a virtue. These two really do compose to the same string, so a // stricter rule would flag them — but the id still contains an expression `core.api` evaluates // at runtime, and every other rule in the DUP family and in `reference.ts` declines on dynamic // values for that reason. NFR-1 is the tiebreaker: the editor does not invent semantics the // runtime lacks, and "these two expressions must produce the same entity" is such an invention. // The first version of this test asserted `[]` over two IDENTICAL dynamic ids and passed with // the guard deleted, because the token-dedup already absorbed that case. expect(issues).toEqual([]); }); it('skips non-scalar ids rather than colliding them on "[object Object]"', () => { // TWO of them, because that is what discriminates: one alone can collide with nothing. Without // the guard both coerce to the same string and the rule reports a duplicate of nothing. expect( withVars( { connectors: [ { id: { nested: true } }, { id: { other: true } }, { id: '' }, { id: null }, {}, ], }, {}, ), ).toEqual([]); }); it('skips ids whose variable resolves to an empty value', () => { // Two tokens both bound to '' would otherwise collide on the empty string — a finding whose // real defect is the empty binding, which VAR-2 owns. expect( withVars( { connectors: [{ id: '**Blank1**' }, { id: '**Blank2**' }] }, { '**Blank1**': '', '**Blank2**': '' }, ), ).toEqual([]); }); });