298 lines
8.5 KiB
TypeScript
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");
|
|
}
|