import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { Persona } from "./persona.js"; import { assertNoReferencedRegistryDeletions, buildRegistrySeedPlan, loadPersonaFiles, loadTemplateFiles, personaHash, templateHash, } from "./registry-loader.js"; function makeRoot() { return mkdtempSync(join(tmpdir(), "devflow-registry-")); } describe("registry loader", () => { it("loads versioned persona YAML files and computes stable hashes", () => { const root = makeRoot(); const dir = join(root, "personas"); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "fake_developer@1.yaml"), [ "name: fake_developer", "version: 1", "backend: fake", "capabilities:", " - code_edit", "maxRiskLevel: medium", ].join("\n"), ); const [entry] = loadPersonaFiles(dir); expect(entry?.name).toBe("fake_developer"); expect(entry?.version).toBe(1); expect(entry?.path).toBe(realpathSync(join(dir, "fake_developer@1.yaml"))); expect(entry?.hash).toMatch(/^[a-f0-9]{64}$/); expect(entry?.hash).toBe(personaHash(entry?.definition)); }); it("rejects non-canonical template filenames and filename identity mismatches", () => { const root = makeRoot(); const dir = join(root, "templates"); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "development@1.yaml"), [ "name: development", "version: 1", "roles:", " - id: implementer", " requiredCapabilities:", " - code_edit", "phases:", " - key: spec", " title: Spec", " risk: low", " roles:", " - implementer", ].join("\n"), ); writeFileSync( join(dir, "development@01.yaml"), ["name: development", "version: 1", "roles: []", "phases: []"].join("\n"), ); writeFileSync( join(dir, "actual_name@2.yml"), ["name: actual_name", "version: 2", "roles: []", "phases: []"].join("\n"), ); expect(() => loadTemplateFiles(dir)).toThrow(/registry filename/); const validDir = join(root, "valid-templates"); mkdirSync(validDir); writeFileSync(join(validDir, "development@1.yaml"), readDevelopmentTemplate()); const [entry] = loadTemplateFiles(validDir); expect(entry?.hash).toBe(templateHash(entry?.definition)); const mismatchDir = join(root, "mismatched-templates"); mkdirSync(mismatchDir); writeFileSync(join(mismatchDir, "wrong@1.yaml"), readDevelopmentTemplate()); expect(() => loadTemplateFiles(mismatchDir)).toThrow(/identity mismatch/); }); it("rejects registry versions outside the database integer range", () => { const root = makeRoot(); const personaDir = join(root, "personas"); mkdirSync(personaDir); writeFileSync( join(personaDir, "fake@2147483648.yaml"), [ "name: fake", "version: 2147483648", "backend: fake", "capabilities: []", "maxRiskLevel: low", ].join("\n"), ); expect(() => loadPersonaFiles(personaDir)).toThrow(/less than or equal/); }); it("rejects unknown template and persona keys instead of silently stripping them", () => { const root = makeRoot(); const personaDir = join(root, "personas"); const templateDir = join(root, "templates"); mkdirSync(personaDir); mkdirSync(templateDir); writeFileSync( join(personaDir, "fake@1.yaml"), [ "name: fake", "version: 1", "backend: fake", "capabilities: []", "maxRiskLevel: low", "typo: accepted", ].join("\n"), ); writeFileSync( join(templateDir, "development@1.yaml"), ["name: development", "version: 1", "roles: []", "phases: []", "typo: accepted"].join("\n"), ); expect(() => loadPersonaFiles(personaDir)).toThrow(/Unrecognized key/); expect(() => loadTemplateFiles(templateDir)).toThrow(/Unrecognized key/); }); it("rejects persona model config values that cannot be content-hashed as JSON", () => { const root = makeRoot(); const personaDir = join(root, "personas"); mkdirSync(personaDir); writeFileSync( join(personaDir, "fake@1.yaml"), [ "name: fake", "version: 1", "backend: fake", "capabilities: []", "maxRiskLevel: low", "modelConfig:", " temperature: .nan", ].join("\n"), ); expect(() => loadPersonaFiles(personaDir)).toThrow(/finite|number|expected plain JSON object/); }); it("rejects persona model config keys that would mutate object prototypes", () => { const root = makeRoot(); const personaDir = join(root, "personas"); mkdirSync(personaDir); writeFileSync( join(personaDir, "fake@1.yaml"), [ "name: fake", "version: 1", "backend: fake", "capabilities: []", "maxRiskLevel: low", "modelConfig:", ' "__proto__":', " x: 1", ].join("\n"), ); expect(() => loadPersonaFiles(personaDir)).toThrow(/reserved object key/); }); it("rejects non-plain programmatic model config objects", () => { class ModelConfig { readonly inherited = true; } expect(() => Persona.parse({ name: "fake", version: 1, backend: "fake", capabilities: [], maxRiskLevel: "low", modelConfig: new ModelConfig(), }), ).toThrow(/expected plain JSON object/); }); it("rejects programmatic model config arrays with non-index keys", () => { const array = [1] as unknown[] & { extra?: number }; array.extra = 2; expect(() => Persona.parse({ name: "fake", version: 1, backend: "fake", capabilities: [], maxRiskLevel: "low", modelConfig: { array }, }), ).toThrow(/expected JSON array/); }); it("rejects symlinked registry files", () => { const root = makeRoot(); const dir = join(root, "personas"); mkdirSync(dir); const target = join(root, "target.yaml"); writeFileSync( target, ["name: fake", "version: 1", "backend: fake", "capabilities: []", "maxRiskLevel: low"].join( "\n", ), ); symlinkSync(target, join(dir, "fake@1.yaml")); expect(() => loadPersonaFiles(dir)).toThrow(/not a symlink/); }); it("builds seed actions and fails on published hash mismatch", () => { const root = makeRoot(); const personaDir = join(root, "personas"); mkdirSync(personaDir); writeFileSync( join(personaDir, "fake@1.yaml"), ["name: fake", "version: 1", "backend: fake", "capabilities: []", "maxRiskLevel: low"].join( "\n", ), ); const [entry] = loadPersonaFiles(personaDir); if (!entry) { throw new Error("expected persona registry entry"); } expect(buildRegistrySeedPlan([entry], [])).toEqual({ unchanged: [], inserts: [entry], missingReferenced: [], missingUnreferenced: [], }); expect(() => buildRegistrySeedPlan( [entry], [{ name: "fake", version: 1, hash: "different", referencedByRun: false }], ), ).toThrow(/published registry entry was modified/); }); it("reports published registry rows that no longer have YAML files", () => { const plan = buildRegistrySeedPlan( [], [ { name: "unused", version: 1, hash: "abc", referencedByRun: false }, { name: "referenced", version: 1, hash: "def", referencedByRun: true }, ], ); expect(plan).toEqual({ inserts: [], missingReferenced: [{ name: "referenced", version: 1, hash: "def", referencedByRun: true }], missingUnreferenced: [{ name: "unused", version: 1, hash: "abc", referencedByRun: false }], unchanged: [], }); }); it("rejects referenced published registry deletions", () => { const plan = buildRegistrySeedPlan( [], [{ name: "referenced", version: 1, hash: "def", referencedByRun: true }], ); expect(() => assertNoReferencedRegistryDeletions("persona", plan)).toThrow(/referenced@1/); }); }); function readDevelopmentTemplate() { return [ "name: development", "version: 1", "roles:", " - id: implementer", " requiredCapabilities:", " - code_edit", "phases:", " - key: spec", " title: Spec", " risk: low", " roles:", " - implementer", ].join("\n"); }