feat: add artifact schema registry
This commit is contained in:
366
packages/core/src/artifact-schema.test.ts
Normal file
366
packages/core/src/artifact-schema.test.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
clearArtifactSchemaCacheForTests,
|
||||
loadSchema,
|
||||
validateArtifact,
|
||||
} from "./artifact-schema.js";
|
||||
import { DevflowError } from "./errors.js";
|
||||
|
||||
const artifactRoot = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../docs/schemas/artifacts",
|
||||
);
|
||||
const repoRoot = resolve(artifactRoot, "../../..");
|
||||
const hash64 = "a".repeat(64);
|
||||
const runId = "00000000-0000-4000-8000-000000000001";
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
describe("artifact schema registry", () => {
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
clearArtifactSchemaCacheForTests();
|
||||
});
|
||||
|
||||
it("loads the first locked artifact schemas from docs/schemas/artifacts", () => {
|
||||
clearArtifactSchemaCacheForTests();
|
||||
|
||||
expect(loadSchema("dev/spec@1", { root: artifactRoot })).toMatchObject({
|
||||
$id: "dev/spec@1",
|
||||
});
|
||||
expect(loadSchema("dev/phase-plan@1", { root: artifactRoot })).toMatchObject({
|
||||
$id: "dev/phase-plan@1",
|
||||
});
|
||||
expect(loadSchema("common/final-report@1", { root: artifactRoot })).toMatchObject({
|
||||
$id: "common/final-report@1",
|
||||
});
|
||||
expect(Object.isFrozen(loadSchema("dev/spec@1", { root: artifactRoot }))).toBe(true);
|
||||
});
|
||||
|
||||
it("finds the default schema root from package subdirectories", () => {
|
||||
process.chdir(resolve(repoRoot, "packages/core"));
|
||||
|
||||
expect(loadSchema("dev/spec@1")).toMatchObject({ $id: "dev/spec@1" });
|
||||
});
|
||||
|
||||
it("validates dev/spec@1 artifacts and returns compact validation errors", () => {
|
||||
expect(
|
||||
validateArtifact(
|
||||
"dev/spec@1",
|
||||
{
|
||||
summary: "Add a binding algorithm",
|
||||
requirements: [{ id: "REQ-1", description: "Bind every role" }],
|
||||
acceptanceCriteria: ["All roles have bindings"],
|
||||
risks: [],
|
||||
},
|
||||
{ root: artifactRoot },
|
||||
),
|
||||
).toEqual({ ok: true });
|
||||
|
||||
const result = validateArtifact(
|
||||
"dev/spec@1",
|
||||
{
|
||||
summary: "Missing requirements",
|
||||
requirements: [],
|
||||
acceptanceCriteria: [],
|
||||
risks: [],
|
||||
},
|
||||
{ root: artifactRoot },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.map((error) => error.keyword)).toContain("minItems");
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
instancePath: expect.any(String),
|
||||
schemaPath: expect.any(String),
|
||||
params: expect.any(Object),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("validates dev/phase-plan@1 artifacts", () => {
|
||||
expect(
|
||||
validateArtifact(
|
||||
"dev/phase-plan@1",
|
||||
{
|
||||
phases: [
|
||||
{
|
||||
key: "implement",
|
||||
title: "Implement",
|
||||
objective: "Implement the requested behavior",
|
||||
roles: ["implementer"],
|
||||
expectedArtifact: {
|
||||
path: "artifacts/spec.json",
|
||||
schema: "dev/spec@1",
|
||||
},
|
||||
tasks: [
|
||||
{
|
||||
id: "task-1",
|
||||
title: "Edit code",
|
||||
role: "implementer",
|
||||
writeSet: ["packages/core/src/**", "**/*.ts"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ root: artifactRoot },
|
||||
),
|
||||
).toEqual({ ok: true });
|
||||
|
||||
const invalidSchemaId = validateArtifact(
|
||||
"dev/phase-plan@1",
|
||||
{
|
||||
phases: [
|
||||
{
|
||||
key: "implement",
|
||||
title: "Implement",
|
||||
objective: "Implement the requested behavior",
|
||||
roles: ["implementer"],
|
||||
expectedArtifact: {
|
||||
path: "artifacts/spec.json",
|
||||
schema: "../secret@1",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ root: artifactRoot },
|
||||
);
|
||||
|
||||
expect(invalidSchemaId.ok).toBe(false);
|
||||
if (!invalidSchemaId.ok) {
|
||||
expect(invalidSchemaId.errors.map((error) => error.keyword)).toContain("pattern");
|
||||
}
|
||||
|
||||
for (const path of [
|
||||
"../../secrets.json",
|
||||
"/etc/passwd",
|
||||
"artifacts/../outside.json",
|
||||
"C:/outside.json",
|
||||
"ok\n/../../outside.json",
|
||||
"ok\r/../../outside.json",
|
||||
]) {
|
||||
const invalidPath = validateArtifact(
|
||||
"dev/phase-plan@1",
|
||||
{
|
||||
phases: [
|
||||
{
|
||||
key: "implement",
|
||||
title: "Implement",
|
||||
objective: "Implement the requested behavior",
|
||||
roles: ["implementer"],
|
||||
expectedArtifact: {
|
||||
path,
|
||||
schema: "dev/spec@1",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ root: artifactRoot },
|
||||
);
|
||||
|
||||
expect(invalidPath.ok).toBe(false);
|
||||
if (!invalidPath.ok) {
|
||||
expect(invalidPath.errors.map((error) => error.keyword)).toContain("pattern");
|
||||
}
|
||||
}
|
||||
|
||||
const missingWriteSet = validateArtifact(
|
||||
"dev/phase-plan@1",
|
||||
{
|
||||
phases: [
|
||||
{
|
||||
key: "implement",
|
||||
title: "Implement",
|
||||
objective: "Implement the requested behavior",
|
||||
roles: ["implementer"],
|
||||
tasks: [
|
||||
{
|
||||
id: "task-1",
|
||||
title: "Edit code",
|
||||
role: "implementer",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ root: artifactRoot },
|
||||
);
|
||||
|
||||
expect(missingWriteSet.ok).toBe(false);
|
||||
if (!missingWriteSet.ok) {
|
||||
expect(missingWriteSet.errors.map((error) => error.keyword)).toContain("required");
|
||||
}
|
||||
|
||||
for (const writeSet of [
|
||||
"../../**",
|
||||
"/etc/**",
|
||||
"src/../secrets/**",
|
||||
"C:/outside/**",
|
||||
"src\n/../../outside",
|
||||
"src\r/../../outside",
|
||||
"..\\outside\\**",
|
||||
"!/etc/**",
|
||||
"!../*",
|
||||
"{../*,packages/core/src/**}",
|
||||
"{..,packages}/**",
|
||||
"@(../*)",
|
||||
"!(../*)",
|
||||
"src/[ab]/**",
|
||||
]) {
|
||||
const invalidWriteSet = validateArtifact(
|
||||
"dev/phase-plan@1",
|
||||
{
|
||||
phases: [
|
||||
{
|
||||
key: "implement",
|
||||
title: "Implement",
|
||||
objective: "Implement the requested behavior",
|
||||
roles: ["implementer"],
|
||||
tasks: [
|
||||
{
|
||||
id: "task-1",
|
||||
title: "Edit code",
|
||||
role: "implementer",
|
||||
writeSet: [writeSet],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ root: artifactRoot },
|
||||
);
|
||||
|
||||
expect(invalidWriteSet.ok).toBe(false);
|
||||
if (!invalidWriteSet.ok) {
|
||||
expect(invalidWriteSet.errors.map((error) => error.keyword)).toContain("pattern");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("validates common/final-report@1 minimum fields", () => {
|
||||
expect(
|
||||
validateArtifact(
|
||||
"common/final-report@1",
|
||||
{
|
||||
runId,
|
||||
templateHash: hash64,
|
||||
bindings: [{ roleId: "implementer", personaHash: hash64, backend: "fake" }],
|
||||
inputs: {},
|
||||
phases: [],
|
||||
approvals: [],
|
||||
findings: [],
|
||||
commands: [{ kind: "test", argv: ["pnpm", "test"], exit_code: 0 }],
|
||||
artifacts: [],
|
||||
events: { tail: [] },
|
||||
unresolved: [],
|
||||
endedAt: "2026-05-09T00:00:00.000Z",
|
||||
status: "completed",
|
||||
},
|
||||
{ root: artifactRoot },
|
||||
),
|
||||
).toEqual({ ok: true });
|
||||
|
||||
const result = validateArtifact(
|
||||
"common/final-report@1",
|
||||
{
|
||||
runId: "not-a-uuid",
|
||||
templateHash: hash64,
|
||||
bindings: [{ roleId: "implementer", personaHash: hash64, backend: "fake" }],
|
||||
inputs: {},
|
||||
phases: [],
|
||||
approvals: [],
|
||||
findings: [],
|
||||
commands: [],
|
||||
artifacts: [],
|
||||
events: { tail: [] },
|
||||
unresolved: [],
|
||||
endedAt: "2026-99-99T99:99:99Z",
|
||||
status: "executing",
|
||||
},
|
||||
{ root: artifactRoot },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.map((error) => error.keyword)).toEqual(
|
||||
expect.arrayContaining(["format", "enum"]),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails fatally for unknown or malformed schema ids", () => {
|
||||
expect(() => loadSchema("dev/unknown@1", { root: artifactRoot })).toThrow(DevflowError);
|
||||
expect(() => loadSchema("../secret@1", { root: artifactRoot })).toThrow(
|
||||
/artifact_schema_unknown/,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails fatally when schema files are malformed", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "devflow-artifact-schemas-"));
|
||||
const devDir = join(root, "dev");
|
||||
mkdirSync(devDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(devDir, "bad@1.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id: "dev/wrong@1",
|
||||
type: "object",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() => loadSchema("dev/bad@1", { root })).toThrow(/artifact_schema_load_failed/);
|
||||
});
|
||||
|
||||
it("wraps registry root, JSON parse, and path layout load failures", () => {
|
||||
expect(() =>
|
||||
loadSchema("dev/spec@1", { root: join(tmpdir(), "missing-artifact-root") }),
|
||||
).toThrow(/artifact_schema_load_failed/);
|
||||
|
||||
const badJsonRoot = mkdtempSync(join(tmpdir(), "devflow-artifact-schemas-"));
|
||||
mkdirSync(join(badJsonRoot, "dev"), { recursive: true });
|
||||
writeFileSync(join(badJsonRoot, "dev", "bad@1.json"), "{");
|
||||
expect(() => loadSchema("dev/bad@1", { root: badJsonRoot })).toThrow(
|
||||
/artifact_schema_load_failed/,
|
||||
);
|
||||
|
||||
const badPathRoot = mkdtempSync(join(tmpdir(), "devflow-artifact-schemas-"));
|
||||
mkdirSync(join(badPathRoot, "bad"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(badPathRoot, "bad", "schema.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id: "bad/schema",
|
||||
type: "object",
|
||||
}),
|
||||
);
|
||||
expect(() => loadSchema("dev/missing@1", { root: badPathRoot })).toThrow(
|
||||
/artifact_schema_load_failed/,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not load schemas from a target-controlled cwd shadow root", () => {
|
||||
const shadowRoot = mkdtempSync(join(tmpdir(), "devflow-shadow-schemas-"));
|
||||
const shadowSchemaDir = join(shadowRoot, "docs", "schemas", "artifacts", "dev");
|
||||
mkdirSync(shadowSchemaDir, { recursive: true });
|
||||
writeFileSync(join(shadowRoot, "package.json"), JSON.stringify({ name: "devflow" }));
|
||||
writeFileSync(
|
||||
join(shadowSchemaDir, "spec@1.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id: "dev/spec@1",
|
||||
title: "SHADOW",
|
||||
type: "object",
|
||||
}),
|
||||
);
|
||||
|
||||
process.chdir(shadowRoot);
|
||||
|
||||
expect(loadSchema("dev/spec@1")).toMatchObject({ title: "Devflow Development Specification" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user