import { describe, it, expect, afterEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { parse } from 'yaml'; import { isAbsoluteUri, resolveUri } from '@beehexa/hexasync-template-model'; import { nodeTemplateReader } from '@beehexa/hexasync-template-io-node'; import { resolveMdIncludes, mergeToMain, prefetchSources, type MissingInclude, } from '@beehexa/hexasync-template-compose'; import { composeProfile, composeDescription } from '../composeCommand'; 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); } /** * The prefetched map `resolveMdIncludes` now reads from (Story 1.6). * * Built with the SAME resolution the production code uses, rather than by joining paths by * hand — a key spelled even slightly differently would make every lookup miss, and the test * would then be asserting nothing about resolution at all. */ function sourcesFrom(baseDir: string, refs: string[]) { return new Map( refs.map((ref) => { const resolved = isAbsoluteUri(ref) ? ref : resolveUri(baseDir, ref); return [resolved, fs.readFileSync(resolved, 'utf8')] as const; }), ); } describe('resolveMdIncludes — inline !md[path] directives', () => { it('inlines a relative markdown file into a string value', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'md-')); write(path.join(tmp, 'Task.description.md'), '# Title\n\nLong body.\n'); const node = { description: '!md[./Task.description.md]' }; const out = resolveMdIncludes( node, tmp, path.join(tmp, 'Task.yaml'), sourcesFrom(tmp, ['./Task.description.md']), ); expect(out.description).toBe('# Title\n\nLong body.'); }); it('resolves an absolute path', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'md-')); const abs = path.join(tmp, 'abs.md'); write(abs, 'ABSOLUTE'); const out = resolveMdIncludes( { description: `!md[${abs}]` }, '/nonexistent-base', 'f.yaml', sourcesFrom('/nonexistent-base', [abs]), ); expect(out.description).toBe('ABSOLUTE'); }); it('recurses into nested arrays/objects (e.g. metrics[x].description)', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'md-')); write(path.join(tmp, 'm.md'), 'metric doc'); const node = { id: 'T', metrics: [{ id: 'M', description: '!md[./m.md]' }], }; const out = resolveMdIncludes( node, tmp, path.join(tmp, 'Task.yaml'), sourcesFrom(tmp, ['./m.md']), ); expect(out.metrics[0].description).toBe('metric doc'); }); it('leaves plain strings and non-string values untouched', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'md-')); const node = { a: 'hello', b: 42, c: null, d: true }; const out = resolveMdIncludes(node, tmp, 'f.yaml', new Map()); expect(out).toEqual({ a: 'hello', b: 42, c: null, d: true }); }); it('only resolves the whole-value form, not an embedded reference', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'md-')); write(path.join(tmp, 'x.md'), 'X'); const out = resolveMdIncludes( { description: 'see !md[./x.md] here' }, tmp, 'f.yaml', sourcesFrom(tmp, ['./x.md']), ); expect(out.description).toBe('see !md[./x.md] here'); }); it('tolerates surrounding whitespace around the directive', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'md-')); write(path.join(tmp, 'x.md'), 'TRIMMED'); const out = resolveMdIncludes( { description: ' !md[./x.md] ' }, tmp, 'f.yaml', sourcesFrom(tmp, ['./x.md']), ); expect(out.description).toBe('TRIMMED'); }); /** * ⛔ A MISSING TARGET NO LONGER THROWS (`2608.20.32`, 2026-08-20) — the value becomes empty and the miss is * reported through `onMissingInclude`, for rule `MDI-1` to raise. The full reasoning, and the release in which * `MDI-1` began reaching this CLI's own report (`2608.21.2`), are recorded in `composePort.spec.ts` beside the * same change. */ it('reports a missing target through the hook and resolves it to empty', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'md-')); const missed: MissingInclude[] = []; // Synchronous still: prefetch is what reads, so the engine can only report an absence. const out = resolveMdIncludes( { description: '!md[./missing.md]' }, tmp, path.join(tmp, 'Task.yaml'), new Map(), { onMissingInclude: (m) => void missed.push(m) }, ); expect(out.description).toBe(''); expect(missed).toHaveLength(1); expect(missed[0]!.ref).toBe('./missing.md'); // The path the author is sent to, resolved against the referencing file's folder. expect(missed[0]!.path).toBe(resolveUri(tmp, './missing.md')); expect(missed[0]!.sourceFile).toBe(path.join(tmp, 'Task.yaml')); }); }); describe('mergeToMain — !md resolves per file (relative to that file) before merge', () => { it('inlines a partial description relative to the partial folder', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'md-')); write(path.join(tmp, 'partials/Task.description.md'), 'FROM PARTIAL'); write( path.join(tmp, 'partials/Task.yaml'), `objects:\n - id: T\n description: "!md[./Task.description.md]"\n`, ); const files = [ { path: path.join(tmp, 'partials/Task.yaml'), replacements: [] }, ]; const merged = mergeToMain( '{}\n', files, undefined, undefined, await prefetchSources(files, nodeTemplateReader()), ); const out = parse(merged); expect(out.objects[0].description).toBe('FROM PARTIAL'); }); it('resolves !md in the base main.yaml via baseDir', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'md-')); write(path.join(tmp, 'root.md'), 'ROOT DESC'); const base = `objects:\n - id: T\n description: "!md[./root.md]"\n`; const merged = mergeToMain( base, [], undefined, tmp, await prefetchSources([], nodeTemplateReader(), tmp, base), ); const out = parse(merged); expect(out.objects[0].description).toBe('ROOT DESC'); }); it('each generation resolves its own !md, and later-wins overrides earlier (C then B)', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'md-')); // C (deeper, merged first) write(path.join(tmp, 'C/Desc.md'), 'C-DESC'); write(path.join(tmp, 'C/extraOnlyInC.md'), 'C-EXTRA'); write( path.join(tmp, 'C/Task.yaml'), `objects:\n - id: T\n description: "!md[./Desc.md]"\n note: "!md[./extraOnlyInC.md]"\n`, ); // B (shallower, merged later — wins on shared fields) write(path.join(tmp, 'B/Desc.md'), 'B-DESC'); write( path.join(tmp, 'B/Task.yaml'), `objects:\n - id: T\n description: "!md[./Desc.md]"\n`, ); const files = [ { path: path.join(tmp, 'C/Task.yaml'), replacements: [] }, { path: path.join(tmp, 'B/Task.yaml'), replacements: [] }, ]; const merged = mergeToMain( '{}\n', files, undefined, undefined, await prefetchSources(files, nodeTemplateReader()), ); const out = parse(merged); expect(out.objects[0].description).toBe('B-DESC'); // B overrides C expect(out.objects[0].note).toBe('C-EXTRA'); // C-only field preserved }); }); describe('composeDescription — resolve !md in a single file only', () => { it('inlines includes and writes .output.yaml next to the source', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'md-')); write(path.join(tmp, 'Task.description.md'), '# Doc\n\nBody.\n'); const src = path.join(tmp, 'Task.yaml'); write( src, `objects:\n - id: T\n name: task\n description: "!md[./Task.description.md]"\n`, ); const outPath = await composeDescription(src); expect(outPath).toBe(path.join(tmp, 'Task.output.yaml')); const out = parse(fs.readFileSync(outPath, 'utf8')); expect(out.objects[0].description).toBe('# Doc\n\nBody.'); expect(out.objects[0].name).toBe('task'); }); it('does NOT merge, inherit, or substitute variables — only resolves !md', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'md-')); write(path.join(tmp, 'd.md'), 'uses **KeepToken**'); const src = path.join(tmp, 'C.yaml'); write( src, `objects:\n - id: "**KeepToken**"\n description: "!md[./d.md]"\n`, ); const outPath = await composeDescription(src); const out = parse(fs.readFileSync(outPath, 'utf8')); // variable tokens are left untouched (no substitution) in both id and body expect(out.objects[0].id).toBe('**KeepToken**'); expect(out.objects[0].description).toBe('uses **KeepToken**'); // no report is produced expect(fs.existsSync(path.join(tmp, 'C.output.report.md'))).toBe(false); }); it('throws when the target file does not exist', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'md-')); await expect( composeDescription(path.join(tmp, 'nope.yaml')), ).rejects.toThrow(/File not found/); }); }); describe('composeProfile — !md works end-to-end through project inheritance', () => { it('inlines descriptions from both inherited and local projects', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'md-')); // base project: task T with an md description write(path.join(tmp, 'base/partials/main.yaml'), `externals: []\n`); write(path.join(tmp, 'base/partials/T.base.md'), 'BASE TASK DOC'); write( path.join(tmp, 'base/partials/T.yaml'), `objects:\n - id: T\n name: task\n description: "!md[./T.base.md]"\n`, ); // integration inherits base and overrides T's description with its own md write( path.join(tmp, 'integration/partials/main.yaml'), `externals:\n - type: project\n path: ../../base\n weight: 10\n`, ); write(path.join(tmp, 'integration/partials/T.local.md'), 'LOCAL TASK DOC'); write( path.join(tmp, 'integration/partials/Local.yaml'), `objects:\n - id: T\n description: "!md[./T.local.md]"\n`, ); await composeProfile(path.join(tmp, 'integration'), false); const out = parse( fs.readFileSync(path.join(tmp, 'integration/output.yaml'), 'utf8'), ); const t = out.objects.find((o: any) => o.id === 'T'); expect(t.name).toBe('task'); // inherited field expect(t.description).toBe('LOCAL TASK DOC'); // local md wins }); });