Files
dev-puppeteer/packages/core/src/registry-loader.test.ts
2026-05-09 23:56:10 +09:00

298 lines
8.5 KiB
TypeScript

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");
}