import { existsSync } from 'node:fs'; import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import yaml from 'js-yaml'; import { beforeEach, describe, expect, it } from 'vitest'; import { PRD_GENERATED_VIEW_LABEL, PrdImportConflictError, PrdImportInvalidError, PrdNotFoundError, PrdService, PrdUpdateError, } from './index.js'; import type { PrdDocument } from './index.js'; // ── Helpers ────────────────────────────────────────────────────────────────── let projectDir: string; async function makeProject(): Promise { return mkdtemp(path.join(os.tmpdir(), 'prdy-service-')); } function service(): PrdService { return new PrdService({ projectPath: projectDir }); } function storeDir(): string { return path.join(projectDir, 'docs', 'prdy'); } /** Handcraft a full, schema-valid PRD document for import scenarios. */ function importFixture(overrides: Partial = {}): PrdDocument { return { id: 'imported-prd-20260101-000000', title: 'Imported PRD', status: 'draft', projectPath: '/tmp/elsewhere', template: 'software', version: 1, sections: [ { id: 'introduction', title: 'Introduction', fields: { context: '', objective: '' } }, { id: 'scope-non-goals', title: 'Scope / Non-Goals', fields: { inScope: '', outOfScope: '' }, }, ], missions: [], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', ...overrides, }; } async function writeImportFile(doc: PrdDocument): Promise { const filePath = path.join(projectDir, `${doc.id}.import.yaml`); await writeFile(filePath, yaml.dump(doc), 'utf8'); return filePath; } beforeEach(async () => { projectDir = await makeProject(); }); // ── Single authority store (AC: prdy path and mission path resolve to the // SAME store under docs/prdy/ with stable ids/versions) ──────────────────── describe('PrdService single authority store', () => { it('persists PRDs from the prdy path and the mission path into the same docs/prdy store', async () => { const direct = await service().create({ name: 'Direct PRD' }); const viaMission = await service().planForMission({ name: 'Mission PRD', missionId: 'mission-1', missionVersion: '2026-01-01T00:00:00.000Z', }); const files = await readdir(storeDir()); expect(files).toContain(`${direct.id}.yaml`); expect(files).toContain(`${viaMission.id}.yaml`); // A fresh service instance (new process equivalent) resolves both. const all = await service().list(); expect(all.map((doc) => doc.id).sort()).toEqual([direct.id, viaMission.id].sort()); // Stable versions: creation is v1; linkage writes do not bump content version. expect((await service().get(direct.id)).version).toBe(1); expect((await service().get(viaMission.id)).version).toBe(1); }); it('round-trips documents through the store with identity intact', async () => { const created = await service().create({ name: 'Round Trip', template: 'feature' }); const fresh = await service().get(created.id); expect(fresh).toEqual(created); expect(fresh.id).toBe(created.id); expect(fresh.template).toBe('feature'); expect(fresh.status).toBe('draft'); }); it('throws a typed error for unknown ids and empty stores', async () => { await expect(service().get('nope')).rejects.toBeInstanceOf(PrdNotFoundError); await expect(service().get()).rejects.toBeInstanceOf(PrdNotFoundError); }); }); // ── Mission linkage persistence (AC: linkage survives restart via fresh // service instances) ──────────────────────────────────────────────────────── describe('PrdService mission linkage', () => { it('persists linkage and reads it back from a fresh service instance', async () => { const created = await service().planForMission({ name: 'Linked PRD', missionId: 'mission-42', missionVersion: '2026-02-03T04:05:06.000Z', requirementIds: ['FR-1', 'FR-2'], }); // Fresh instance — nothing in memory from the creating call. const links = await service().listMissionLinks(created.id); expect(links).toHaveLength(1); expect(links[0]).toMatchObject({ missionId: 'mission-42', missionVersion: '2026-02-03T04:05:06.000Z', prdVersion: 1, requirementIds: ['FR-1', 'FR-2'], }); // Linkage is carried in the YAML authority file itself. const raw = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8'); const persisted = yaml.load(raw) as PrdDocument; expect(persisted.missions[0]?.missionId).toBe('mission-42'); expect(persisted.missions[0]?.requirementIds).toEqual(['FR-1', 'FR-2']); }); it('refreshes an existing linkage entry in place instead of duplicating', async () => { const created = await service().planForMission({ name: 'Relink PRD', missionId: 'mission-7', missionVersion: 'v1', }); await service().update({ id: created.id, sections: [{ id: 'introduction', fields: { objective: 'Ship it' } }], }); const relinked = await service().linkMission({ prdId: created.id, missionId: 'mission-7', missionVersion: 'v2', requirementIds: ['NFR-1'], }); expect(relinked.missions).toHaveLength(1); expect(relinked.missions[0]).toMatchObject({ missionVersion: 'v2', prdVersion: 2 }); }); it('does not bump the content version when writing linkage', async () => { const created = await service().create({ name: 'Stable Version' }); const linked = await service().linkMission({ prdId: created.id, missionId: 'm', missionVersion: 'v1', }); expect(linked.version).toBe(1); }); }); // ── Update semantics ────────────────────────────────────────────────────────── describe('PrdService update', () => { it('applies section patches and bumps the content version', async () => { const created = await service().create({ name: 'Updatable' }); const updated = await service().update({ id: created.id, sections: [{ id: 'introduction', fields: { context: 'Some context', objective: 'Goal' } }], }); expect(updated.version).toBe(2); expect(updated.sections[0]?.fields).toMatchObject({ context: 'Some context', objective: 'Goal', }); expect((await service().get(created.id)).version).toBe(2); }); it('refuses unknown section ids with a typed error', async () => { const created = await service().create({ name: 'Strict' }); await expect( service().update({ id: created.id, sections: [{ id: 'nope', fields: {} }] }), ).rejects.toBeInstanceOf(PrdUpdateError); }); }); // ── Markdown export is a labeled generated view, never authority ────────────── describe('PrdService exportMarkdown', () => { it('writes a generated view carrying the label and source identity', async () => { const created = await service().create({ name: 'Exported PRD' }); const result = await service().exportMarkdown({ id: created.id }); expect(result.filePath).toBe(path.join(storeDir(), `${created.id}.md`)); expect(result.content).toContain(PRD_GENERATED_VIEW_LABEL); expect(result.content).toContain(`prd-id: ${created.id}`); expect(result.content).toContain('prd-version: 1'); expect(result.content).toContain(`source-of-truth: docs/prdy/${created.id}.yaml`); }); it('reflects the current version after updates', async () => { const created = await service().create({ name: 'Versioned Export' }); await service().update({ id: created.id, sections: [{ id: 'introduction', fields: { objective: 'v2 goal' } }], }); const result = await service().exportMarkdown({ id: created.id }); expect(result.content).toContain('prd-version: 2'); }); it('NEGATIVE CONTROL: mutating the exported Markdown cannot change the authority', async () => { const created = await service().create({ name: 'Guarded PRD' }); const before = structuredClone(await service().get(created.id)); const result = await service().exportMarkdown({ id: created.id }); await writeFile( result.filePath, `\n# FAKE\nprd-id: fake-id\nprd-version: 99\n`, 'utf8', ); const after = await service().get(created.id); expect(after).toEqual(before); expect(after.version).toBe(1); expect(after.title).toBe(before.title); }); it('never parses Markdown files that sit in the store directory', async () => { const created = await service().create({ name: 'Decoy Guard' }); // A decoy .md file with invalid YAML must be invisible to the store. await writeFile(path.join(storeDir(), 'decoy.md'), 'not: [valid: yaml', 'utf8'); // And a decoy .yaml-named Markdown body must not silently validate either. await service().exportMarkdown({ id: created.id }); const listed = await service().list(); expect(listed.map((doc) => doc.id)).toEqual([created.id]); await expect(service().get(created.id)).resolves.toBeTruthy(); }); }); // ── Import: validated, conflict-aware, never silently merging ───────────────── describe('PrdService importDocument', () => { it('creates a valid import through the service, as draft — validity is not approval', async () => { const filePath = await writeImportFile(importFixture({ status: 'approved' })); const result = await service().importDocument({ filePath }); expect(result.kind).toBe('created'); expect(result.document.id).toBe('imported-prd-20260101-000000'); expect(result.document.status).toBe('draft'); // structural validity ≠ approval expect(result.document.version).toBe(1); const persisted = await service().get('imported-prd-20260101-000000'); expect(persisted.status).toBe('draft'); const files = await readdir(storeDir()); expect(files).toContain('imported-prd-20260101-000000.yaml'); }); it('reports identical content as a no-op without writing', async () => { const created = await service().create({ name: 'Existing PRD' }); const before = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8'); const filePath = await writeImportFile(importFixture({ ...created })); const result = await service().importDocument({ filePath }); expect(result.kind).toBe('identical'); const after = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8'); expect(after).toBe(before); }); it('refuses a conflicting import with a typed error, a proposed successor, and untouched bytes', async () => { const existing = await service().create({ name: 'Authority PRD' }); await service().linkMission({ prdId: existing.id, missionId: 'mission-keep', missionVersion: 'v1', requirementIds: ['FR-0'], }); const beforeBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8'); const divergent = importFixture({ ...existing, title: 'Divergent Title', sections: [ { id: 'introduction', title: 'Introduction', fields: { context: 'changed', objective: '' }, }, ], }); const filePath = await writeImportFile(divergent); const attempt = service().importDocument({ filePath }); let caught: unknown; try { await attempt; } catch (error) { caught = error; } expect(caught).toBeInstanceOf(PrdImportConflictError); const error = caught as PrdImportConflictError; expect(error.code).toBe('PRD_IMPORT_CONFLICT'); expect(error.existing.id).toBe(existing.id); expect(error.proposal.version).toBe(existing.version + 1); // successor proposal expect(error.proposal.status).toBe('draft'); // Original authority content untouched on disk. const afterBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8'); expect(afterBytes).toBe(beforeBytes); }); it('acceptSuccessor persists the proposal explicitly, carrying linkages forward', async () => { const existing = await service().create({ name: 'Successor Base' }); await service().linkMission({ prdId: existing.id, missionId: 'mission-keep', missionVersion: 'v1', }); const divergent = importFixture({ ...existing, title: 'Accepted Successor Title', }); const filePath = await writeImportFile(divergent); const successor = await service().acceptSuccessor({ filePath }); expect(successor.id).toBe(existing.id); expect(successor.version).toBe(existing.version + 1); expect(successor.title).toBe('Accepted Successor Title'); expect(successor.status).toBe('draft'); expect(successor.missions.map((m) => m.missionId)).toEqual(['mission-keep']); // Persisted for a fresh reader. const fresh = await service().get(existing.id); expect(fresh.version).toBe(2); expect(fresh.title).toBe('Accepted Successor Title'); }); it('refuses structurally-invalid imports with a typed error and creates nothing', async () => { const cases: Array<{ name: string; body: string }> = [ { name: 'missing-title.yaml', body: yaml.dump({ id: 'x', status: 'draft' }) }, { name: 'bad-status.yaml', body: yaml.dump(importFixture({ status: 'not-a-status' as PrdDocument['status'] })), }, { name: 'bad-version.yaml', body: yaml.dump(importFixture({ version: 0 })), }, { name: 'not-yaml.yaml', body: '::: not yaml [\n - {' }, ]; for (const fixture of cases) { const filePath = path.join(projectDir, fixture.name); await writeFile(filePath, fixture.body, 'utf8'); await expect(service().importDocument({ filePath })).rejects.toBeInstanceOf( PrdImportInvalidError, ); } // Nothing was created: the authority store does not even exist yet. await expect(readdir(storeDir())).rejects.toMatchObject({ code: 'ENOENT' }); }); it('acceptSuccessor refuses when there is no existing document to succeed', async () => { const filePath = await writeImportFile(importFixture()); await expect(service().acceptSuccessor({ filePath })).rejects.toBeInstanceOf(PrdNotFoundError); }); }); // ── No second writer: no code path reads exported Markdown back into authority ─ describe('no-second-writer invariant (source-level)', () => { // Resolve the package source dir whether vitest runs from the package root // (turbo/pnpm test) or from the worktree root. function resolveSrcDir(): string { const candidates = [path.resolve('src'), path.resolve('packages/prdy/src')]; return candidates.find((dir) => existsSync(path.join(dir, 'service.ts'))) ?? candidates[0]!; } const srcDir = resolveSrcDir(); const sourceFiles = [ 'cli.ts', 'index.ts', 'prd.ts', 'service.ts', 'templates.ts', 'types.ts', 'wizard.ts', ]; it('no source file in @mosaicstack/prdy reads a .md file', async () => { for (const file of sourceFiles) { const text = await readFile(path.join(srcDir, file), 'utf8'); const readLines = text .split('\n') .map((line) => line.trim()) .filter((line) => /readFile|readFileSync|createReadStream/.test(line)); for (const line of readLines) { expect(line.includes('.md'), `${file} reads a Markdown file: ${line}`).toBe(false); } } }); it('the mosaic prdy/mission adapters never read a .md file', async () => { const adapterDir = path.resolve(srcDir, '..', '..', 'mosaic', 'src', 'commands'); for (const file of ['prdy.ts', 'mission.ts']) { const text = await readFile(path.join(adapterDir, file), 'utf8'); expect(text.includes("'.md'") || text.includes('.md`'), `${file} references a .md path`).toBe( false, ); } }); });