import { describe, it, expect } from 'vitest'; import { CORPUS, corpusSuite } from './corpusCheckout'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { parse } from 'yaml'; import { REFERENCE_PROPERTY_LIST, NON_REFERENCE_ID_PROPERTIES, NESTED_REFERENCE_BLOCKS, COLLECTIONS_KEYED_BY_REFERENCE, resolveReferenceKind, isTokenForm, } from '@beehexa/hexasync-template-index'; /** * The reference registry against the REAL corpus (Story 3.7 AC 2, FR-49). * * Replaces `schemaAnnotations.spec.ts`, which was deleted along with the `x-hexasync-*` annotations * it guarded. That file held the only corpus-level assertion about references, and removing it * without a replacement would have left the registry with unit tests only — which is how the * registry was wrong twice. * * **The assertion that matters is the one whose absence caused the bug.** The earlier derivation * asked *"is this value some component's id?"* and never *"does it point into the collection we * claim?"* — so `connectionId` scored 100% while pointing at `connections`, a collection that does * not exist in a single authored file. Every check below therefore asserts a TARGET COLLECTION or a * VALUE SHAPE, never merely "this is an id". * * Reads YAML directly rather than composing: this is about what the corpus AUTHORS, and composing * 113 projects to count property names would cost minutes to learn nothing more. */ const suite = corpusSuite( 'references', "The registry's corpus assertions cannot run.", 'A registry validated only by unit tests is how `connections` survived two revisions.', ); /** Authored YAML only — `output.yaml` is generated and `__configs` is a build artefact. */ function authoredYaml(root: string): string[] { const found: string[] = []; const walk = (dir: string) => { for (const entry of readdirSync(dir)) { if ( entry === 'node_modules' || entry === '.git' || entry === '__configs' ) { continue; } const full = join(dir, entry); if (statSync(full).isDirectory()) { walk(full); continue; } if (!/\.ya?ml$/i.test(entry)) continue; if (entry === 'output.yaml' || entry === 'oldOutput.yaml') continue; found.push(full); } }; walk(root); return found; } /** * ONE pass over the corpus, because there is only one corpus. * * ~7,600 authored files, and re-parsing them per assertion took a minute per test. Everything the * suite needs — the declared collection names and the observed values of a handful of properties — * comes out of a single walk. */ const WANTED = new Set([ 'entityId', 'targetId', 'entityType', 'targetType', ...NON_REFERENCE_ID_PROPERTIES, ]); function scanCorpus(files: readonly string[]) { const collections = new Set(); const values = new Map(); /** * FR-47 / Story 1.7 — the population the `connectors`-entry exception affects, tallied in THIS pass. * * A first draft counted these in its own `it`, which re-parsed all ~7,600 files and blew the 5 s test * timeout at 15 s — precisely what the comment above this function warns about. */ const connectionEntries = { total: 0, withModern: 0, withLegacy: 0 }; const record = (key: string, value: unknown) => { for (const one of Array.isArray(value) ? value : [value]) { if (typeof one === 'string' || typeof one === 'number') { const bucket = values.get(key); if (bucket) bucket.push(String(one)); else values.set(key, [String(one)]); } } }; // Iterative, so a deeply nested flow document cannot overflow the stack mid-suite. const visit = (root: unknown) => { const stack: unknown[] = [root]; while (stack.length > 0) { const node = stack.pop(); if (Array.isArray(node)) { for (const item of node) stack.push(item); continue; } if (!node || typeof node !== 'object') continue; for (const [key, value] of Object.entries( node as Record, )) { if (WANTED.has(key)) record(key, value); stack.push(value); } } }; for (const file of files) { let doc: unknown; try { doc = parse(readFileSync(file, 'utf8')); } catch { continue; // an unparseable authored file is not this suite's subject } if (doc && typeof doc === 'object' && !Array.isArray(doc)) { for (const key of Object.keys(doc as Record)) { collections.add(key); } const connectors = (doc as Record).connectors; if (Array.isArray(connectors)) { for (const entry of connectors) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; const record_ = entry as Record; const nonBlank = (key: string) => record_[key] !== undefined && record_[key] !== null && String(record_[key]).trim() !== ''; connectionEntries.total++; if (nonBlank('connectorId')) connectionEntries.withModern++; if (nonBlank('providerId')) connectionEntries.withLegacy++; } } } visit(doc); } return { collections, values, connectionEntries }; } suite('the reference registry against the real corpus', () => { const files = authoredYaml(CORPUS); const { collections, values, connectionEntries } = scanCorpus(files); it('reads a corpus at all, so nothing below passes vacuously', () => { expect(files.length).toBeGreaterThan(1_000); expect(collections.has('objects')).toBe(true); }); it('names only collections the corpus actually declares', () => { // THE regression guard. `connectionId` and `fromConnectionId` pointed at `connections`, which // zero authored files declare, and the old measurement could not see it because it only asked // whether the values were ids. Every fixed target, every discriminator outcome and every nested // block is checked against what the corpus really has. const targets = new Set(); for (const entry of REFERENCE_PROPERTY_LIST) { if (entry.collection) targets.add(entry.collection); if (entry.discriminator) { for (const value of entry.discriminator.byValue.values()) { if (value) targets.add(value); } if (entry.discriminator.fallback) targets.add(entry.discriminator.fallback); } } for (const block of NESTED_REFERENCE_BLOCKS) { if (block.collection) targets.add(block.collection); } for (const target of COLLECTIONS_KEYED_BY_REFERENCE.values()) { targets.add(target); } const undeclared = [...targets].filter((t) => !collections.has(t)); expect(undeclared).toEqual([]); }); it('sends every connection spelling to the collection that holds those ids', () => { // `connectors` exists and `connections` does not — the ids these properties carry are // `connectors[*].id`. expect(collections.has('connectors')).toBe(true); expect(collections.has('connections')).toBe(false); for (const property of [ 'connectionId', 'fromConnectionId', 'connectorId', ]) { expect( resolveReferenceKind(property, '**AcmeConnectionId**', {})?.collection, ).toBe('connectors'); } }); /** * FR-47 / Story 1.7 — the exception's blast radius, measured rather than argued. * * `connectorId` names a connection in a step argument and is a connection's own PLATFORM identity on a * `connectors[]` entry. The registry now declines the second position. AC-5 asks for the delta over the * corpus before shipping, and *"no occurrence that resolves cleanly today starts failing."* * * The delta is provably zero, for a reason worth recording: until FR-2 landed, the schema REJECTED * `connectorId` on a connection, so nobody could write it. The exception therefore removes findings from a * population of size **0** and takes nothing away from the 413 step-argument occurrences that resolve. It * exists for the population FR-3's rename is about to create — which is exactly why it must ship first. */ it('measures the population the connectors-entry exception affects (FR-47)', () => { const { total, withModern, withLegacy } = connectionEntries; // eslint-disable-next-line no-console console.log( `[FR-47] connection entries: ${total}, ` + `with connectorId: ${withModern} (the population the exception silences), ` + `with providerId: ${withLegacy}`, ); // Guards against a vacuous measurement. expect(total).toBeGreaterThan(100); expect(withLegacy).toBeGreaterThan(100); /** * THE DELTA — rewritten by the Epic 1 review, which caught the previous assertion * (`expect(withModern).toBeLessThanOrEqual(total)`) being true for every possible input. A bound that * cannot fail is not a bound. * * What AC-5 actually needs is that the exception silences ONLY connection entries and nothing else. So this * evaluates the registry at both positions over the real corpus population: every entry-level occurrence * must decline, and the step-argument position must still resolve. When FR-3's quick fix moves the corpus * to the modern spelling, `withModern` grows — and this assertion keeps holding, because it is about the * rule rather than about the count. */ for (const value of [ '**AcmeConnectionId**', '667c2966-88e8-466a-b7e1-872f076c8c5c', ]) { expect( resolveReferenceKind('connectorId', value, {}, 'connectors'), `a connection's own connectorId must decline (${value})`, ).toBeUndefined(); expect( resolveReferenceKind('connectorId', value, {}, 'objects')?.collection, `a step argument's connectorId must still resolve (${value})`, ).toBe('connectors'); } // The population it applies to is RECORDED (logged above), not pinned: it is 0 today only because the // schema rejected the spelling until FR-2, and it is expected to grow. // The half that keeps the narrowing honest, asserted at corpus scale rather than only on fixtures: // the same property in a step argument is untouched. expect( resolveReferenceKind('connectorId', '**AcmeConnectionId**', {}, 'objects') ?.collection, ).toBe('connectors'); expect( resolveReferenceKind( 'connectorId', '**AcmeConnectionId**', {}, 'connectors', ), ).toBeUndefined(); }); it('declines a value that is not token form, which the graph could never match anyway', () => { // AUTHORED files write these as compose-time tokens almost without exception: measured // entityId 175 token / 1 raw UUID, targetId 151 / 0. The hundreds of UUIDs in the corpus sit in // GENERATED `output.yaml`, where compose has already substituted the token for the real id — // and the index never reads those files, so they were never at risk of being flagged. // // The rule stands on a simpler footing: the graph keys components in TOKEN form (FR-45), so a // raw UUID cannot match a component however hard it tries. Declining is therefore the accurate // answer, and distinct from "a reference that does not resolve". for (const property of ['entityId', 'targetId']) { const observed = values.get(property) ?? []; const tokens = observed.filter((v) => isTokenForm(v)); const others = observed.filter((v) => !isTokenForm(v)); // The premise, asserted rather than assumed: authored usage is overwhelmingly token form. expect(tokens.length).toBeGreaterThan(100); expect(others.length).toBeLessThan(tokens.length / 10); for (const value of others) { expect( resolveReferenceKind(property, value, { entityType: 'TASK' }), ).toBeUndefined(); } // A token in the same position IS a reference, so this is a shape test and not a refusal. expect( resolveReferenceKind(property, '**Some_Task_Id**', { entityType: 'TASK', })?.collection, ).toBe('objects'); } }); it('resolves entityId by the entityType written beside it', () => { const seen = new Set(values.get('entityType') ?? []); // Every entityType the corpus writes must be a value the registry has an answer for — // "unmapped" is treated as not-a-reference, so an unmeasured new type would silently stop // resolving rather than announce itself. for (const value of seen) { const kind = resolveReferenceKind('entityId', '**X**', { entityType: value, }); if (value === 'TASK') expect(kind?.collection).toBe('objects'); // SELF and PROFILE reference no component in this project. else expect(kind).toBeUndefined(); } expect(seen.has('TASK')).toBe(true); expect(seen.has('SELF')).toBe(true); }); it('resolves targetId by the targetType written beside it', () => { const seen = new Set(values.get('targetType') ?? []); expect(seen.size).toBeGreaterThan(0); for (const value of seen) { const kind = resolveReferenceKind('targetId', '**X**', { targetType: value, }); // Whatever it resolves to must be a collection the corpus declares — the same rule as above, // applied to the discriminated outcomes specifically. if (kind?.collection) expect(collections.has(kind.collection)).toBe(true); } }); it('still refuses the id-shaped properties measured at zero', () => { for (const property of NON_REFERENCE_ID_PROPERTIES) { expect( resolveReferenceKind(property, '**Anything**', {}), ).toBeUndefined(); // And they really are written in the corpus, so this is a live exclusion rather than a list // of names nobody uses. expect(REFERENCE_PROPERTY_LIST.map((r) => r.property)).not.toContain( property, ); } }); it('covers the nested block form, which outnumbers the flat one', () => { // `table:`/`puller:`/`pusher:` blocks carry the majority of the corpus's real references, so a // registry that only knew flat `*Id` names would miss most of the graph's edges. for (const block of NESTED_REFERENCE_BLOCKS) { expect(block.collection).toBeDefined(); expect(collections.has(block.collection!)).toBe(true); } expect(NESTED_REFERENCE_BLOCKS.map((b) => b.property).sort()).toEqual([ 'puller', 'pusher', 'table', ]); }); it('keys objectAssociations by a collection the corpus declares', () => { // The KEY case: an `objectAssociations` entry is keyed BY the taskId it describes. expect(COLLECTIONS_KEYED_BY_REFERENCE.get('objectAssociations')).toBe( 'objects', ); expect(collections.has('objectAssociations')).toBe(true); }); });