import { describe, it, expect } from 'vitest'; import { memoryReader } from '@beehexa/hexasync-template-compose'; import { searchMain, mergeToMain, resolveMdIncludes, prefetchSources, type MissingInclude, } from '@beehexa/hexasync-template-compose'; import { createFsResolverIO } from '../composeCommand'; import { resolve } from '@beehexa/hexasync-template-compose'; /** * Story 1.5 AC 3 / Story 1.6 — the compose path reads through the port, not the filesystem. * * Every path here is under `/vfs`, which does not exist. That is the assertion: if any of these * still reached a real disk, the read would fail rather than quietly succeed, so a green run is * itself the proof that no disk was touched. `memoryReader.reads` then pins down exactly which * URIs were asked for, which a coverage number cannot. */ describe('compose reads through the injected port (Story 1.5 AC 3)', () => { it('searchMain locates and reads main.yaml with no filesystem at all', async () => { const reader = memoryReader({ '/vfs/p/partials/main.yaml': `pushers:\n - id: FROM_MEMORY\n`, }); const { mainYml, componentPath } = await searchMain('/vfs/p', reader); expect(mainYml).toContain('FROM_MEMORY'); expect(componentPath).toBe('/vfs/p/partials'); expect(reader.reads).toContain('/vfs/p/partials/main.yaml'); }); it('searchMain still reports the zero-main.yaml case through the port', async () => { const reader = memoryReader({ '/vfs/p/partials/A.yaml': `pushers: []\n` }); await expect(searchMain('/vfs/p', reader)).rejects.toThrow( /no main\.yaml file in the project/, ); }); it('mergeToMain merges partial content served from memory', async () => { const reader = memoryReader({ '/vfs/p/partials/A.yaml': `pushers:\n - id: IN_MEMORY_A\n`, }); const files = [{ path: '/vfs/p/partials/A.yaml', replacements: [] }]; const sources = await prefetchSources(files, reader); const merged = mergeToMain( `pushers:\n - id: BASE\n`, files, undefined, undefined, sources, ); expect(merged).toContain('IN_MEMORY_A'); expect(reader.reads).toEqual(['/vfs/p/partials/A.yaml']); }); it('a partial the reader does not have is named, not merged as empty', async () => { const reader = memoryReader({}); // Prefetch is where the absence is discovered, and where it must be named. await expect( prefetchSources( [{ path: '/vfs/p/partials/Missing.yaml', replacements: [] }], reader, ), ).rejects.toThrow( /Could not read partial: \/vfs\/p\/partials\/Missing\.yaml/, ); }); it('resolveMdIncludes inlines a Markdown include served from memory', async () => { const reader = memoryReader({ '/vfs/p/docs/intro.md': 'Hello from memory.\n\n', }); const sources = await prefetchSources( [], reader, '/vfs/p', 'description: "!md[./docs/intro.md]"\n', ); const out = resolveMdIncludes( { description: '!md[./docs/intro.md]' }, '/vfs/p', '/vfs/p/Task.yaml', sources, ); // Trailing whitespace is stripped, as the filesystem path always did. expect(out.description).toBe('Hello from memory.'); expect(reader.reads).toEqual(['/vfs/p/docs/intro.md']); }); /** * ⛔ THE CONTRACT CHANGED UPSTREAM (`2608.20.32`, 2026-08-20): a missing `!md` target NO LONGER THROWS. * * `resolveMdIncludes` yields an EMPTY value and reports the miss through `onMissingInclude`. The reasoning is * the package's own: one author mid-edit with a path that does not exist yet used to take down the whole * project's compose — no output, and no report, since every validation rule reads the COMPOSED document, so * the one artefact that could have named the missing file was the thing that never ran. * * The loudness is not abandoned, it MOVED: the hook feeds rule `MDI-1` (HIGH, "the description include … was * not found, so this description is empty"). * * ⚠️ IT REACHES THIS CLI AS OF `2608.21.2` — and for one release it did not, which is the more useful half of * this note. `MDI-1` reads `ValidationContext.missingIncludes`, and that was populated only by * `template-index`'s graph path (the extension's): `composeProject` registered no hook and returned no such * fact, and `buildValidationModel` accepted none. So between `2608.20.32` and `2608.21.2` a missing * description include was SILENT here — empty description, clean report — where before the change it had been * a thrown error. Both ends were fixed upstream and `composeCommand.ts` forwards `result.missingIncludes`; * `validationReport.spec.ts` asserts the whole chain through the report a user reads, which is the only test * that would have failed during the outage. * * What stays true regardless: this level THROWS NOTHING. The value is empty and the hook is the only * announcement, so a caller that registers none still composes and still gets no finding — asserted below, * because that is the shape a future consumer will meet. */ it('a missing include names its referencing file through the hook, rather than throwing', async () => { const reader = memoryReader({}); const missed: MissingInclude[] = []; const out = resolveMdIncludes( { description: '!md[./nope.md]' }, '/vfs/p', '/vfs/p/Task.yaml', new Map(), { onMissingInclude: (m) => void missed.push(m) }, ); // EMPTY, never the directive left half-resolved: a `!md[...]` reaching a renderer or a published // report is the second failure mode the package's comment names. expect(out.description).toBe(''); // The referencing YAML is what the author must edit, so it is what the hook names. expect(missed).toEqual([ { ref: './nope.md', path: '/vfs/p/nope.md', sourceFile: '/vfs/p/Task.yaml', }, ]); // A caller that registers NO hook still gets the empty value and still composes — silent in the // report, visible in the artefact. This is the gap noted above, asserted rather than implied. expect( resolveMdIncludes( { description: '!md[./nope.md]' }, '/vfs/p', '/vfs/p/Task.yaml', new Map(), ).description, ).toBe(''); // Prefetch itself stays quiet (corrected 2026-08-02). It scans RAW text and cannot tell a // real directive from one embedded in prose or sitting in a comment, so throwing there // aborted composes that used to succeed. Discovery collects candidates; the resolver, which // can see the value's shape, is what reports the ones that actually matter. await expect( prefetchSources([], reader, '/vfs/p', 'description: "!md[./nope.md]"\n'), ).resolves.toBeInstanceOf(Map); }); it('the resolver globs and reads through an injected reader end to end', async () => { const reader = memoryReader({ '/vfs/int/partials/main.yaml': `externals:\n - type: project\n path: ../../base\n`, '/vfs/int/partials/Local.yaml': `pushers:\n - id: LOCAL\n`, '/vfs/base/partials/main.yaml': `externals: []\n`, '/vfs/base/partials/Base.yaml': `pushers:\n - id: BASE\n`, }); const io = createFsResolverIO(reader); const { mainYml, componentPath } = await io.searchMain('/vfs/int'); const result = await resolve(componentPath, mainYml, io); const paths = result.worklist.map((w) => w.path); expect(paths).toContain('/vfs/base/partials/Base.yaml'); expect(paths).toContain('/vfs/int/partials/Local.yaml'); expect(result.hasInheritance).toBe(true); }); it('an absent variables.yaml is an ordinary answer, not a failure', async () => { const reader = memoryReader({ '/vfs/p/partials/main.yaml': `externals: []\n`, }); const io = createFsResolverIO(reader); // The port returns `undefined`; ResolverIO's contract is `null`. A leaked `undefined` // here would be truthy-checked the same way, so the difference only shows up as a // variables set that silently parses the string "undefined". expect( await io.readFileIfExists('/vfs/p/partials/variables.yaml'), ).toBeNull(); }); });