feat: add artifact schema registry

This commit is contained in:
chungyeong
2026-05-10 01:11:37 +09:00
parent 0d90cd97b6
commit 1338e72e96
9 changed files with 976 additions and 0 deletions

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

View File

@@ -0,0 +1,388 @@
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import {
Ajv2020,
type ErrorObject,
type FormatDefinition,
type ValidateFunction,
} from "ajv/dist/2020.js";
import { DevflowError } from "./errors.js";
import type { JsonObject, JsonValue } from "./persona.js";
export type JsonSchema = JsonObject;
export interface ValidationError {
instancePath: string;
schemaPath: string;
keyword: string;
message?: string;
params: JsonObject;
}
export interface ArtifactSchemaOptions {
root?: string;
}
interface CompiledArtifactSchema {
id: string;
schema: JsonSchema;
validate: ValidateFunction;
path: string;
}
const schemaIdPattern = /^[a-z][a-z0-9_-]*\/[a-z][a-z0-9_-]*@[1-9]\d*$/;
const schemaRootSegments = ["docs", "schemas", "artifacts"] as const;
const registries = new Map<string, Map<string, CompiledArtifactSchema>>();
export function loadSchema(id: string, options: ArtifactSchemaOptions = {}): JsonSchema {
assertSchemaId(id);
const schema = registryFor(options).get(id);
if (!schema) {
throw artifactSchemaUnknown(id);
}
return schema.schema;
}
export function validateArtifact(
id: string,
data: unknown,
options: ArtifactSchemaOptions = {},
): { ok: true } | { ok: false; errors: ValidationError[] } {
assertSchemaId(id);
const schema = registryFor(options).get(id);
if (!schema) {
throw artifactSchemaUnknown(id);
}
if (schema.validate(data)) {
return { ok: true };
}
return {
ok: false,
errors: (schema.validate.errors ?? []).map(toValidationError),
};
}
export function clearArtifactSchemaCacheForTests(): void {
registries.clear();
}
function registryFor(options: ArtifactSchemaOptions): Map<string, CompiledArtifactSchema> {
const root = resolveRegistryRoot(options.root ?? findDefaultSchemaRoot());
const cached = registries.get(root);
if (cached) {
return cached;
}
const registry = loadRegistry(root);
registries.set(root, registry);
return registry;
}
function resolveRegistryRoot(root: string): string {
try {
return realpathSync(resolve(root));
} catch (error) {
throw artifactSchemaLoadFailed(root, error);
}
}
function findDefaultSchemaRoot(): string {
const moduleDirectory = currentModuleDirectory();
if (moduleDirectory === undefined) {
throw artifactSchemaLoadFailed(
"default",
new Error("Could not resolve current module directory for artifact schemas"),
);
}
const packageRoot = findCorePackageRoot(moduleDirectory);
if (packageRoot === undefined) {
throw artifactSchemaLoadFailed(
"default",
new Error("Could not find @devflow/core package root for artifact schemas"),
);
}
return resolve(packageRoot, "../..", ...schemaRootSegments);
}
function currentModuleDirectory(): string | undefined {
const stack = new Error().stack?.split("\n").slice(1) ?? [];
for (const line of stack) {
const match = line.match(/\(?((?:file:\/\/)?\/[^):]+\.(?:cjs|mjs|js|ts)):\d+:\d+\)?/);
if (!match?.[1]) {
continue;
}
const path = match[1].startsWith("file://") ? fileURLToPath(match[1]) : match[1];
return dirname(path);
}
return undefined;
}
function findCorePackageRoot(startDirectory: string): string | undefined {
let current = resolve(startDirectory);
while (true) {
if (isCorePackageRoot(current)) {
return current;
}
const parent = dirname(current);
if (parent === current) {
return undefined;
}
current = parent;
}
}
function isCorePackageRoot(directory: string): boolean {
const packageJsonPath = join(directory, "package.json");
if (!existsSync(packageJsonPath)) {
return false;
}
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown };
return packageJson.name === "@devflow/core";
} catch {
return false;
}
}
function addArtifactFormats(ajv: Ajv2020): void {
ajv.addFormat("uuid", uuidFormat);
ajv.addFormat("utc-date-time", utcDateTimeFormat);
}
const uuidFormat: FormatDefinition<string> = {
type: "string",
validate: (value: string) =>
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(value),
};
const utcDateTimeFormat: FormatDefinition<string> = {
type: "string",
validate: isUtcDateTime,
};
function isUtcDateTime(value: string): boolean {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?Z$/);
if (!match) {
return false;
}
const [, yearText, monthText, dayText, hourText, minuteText, secondText, millisecondText] = match;
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
const hour = Number(hourText);
const minute = Number(minuteText);
const second = Number(secondText);
const millisecond = millisecondText === undefined ? 0 : Number(millisecondText);
const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millisecond));
return (
date.getUTCFullYear() === year &&
date.getUTCMonth() === month - 1 &&
date.getUTCDate() === day &&
date.getUTCHours() === hour &&
date.getUTCMinutes() === minute &&
date.getUTCSeconds() === second &&
date.getUTCMilliseconds() === millisecond
);
}
function loadRegistry(root: string): Map<string, CompiledArtifactSchema> {
const ajv = new Ajv2020({ allErrors: true, strict: true });
addArtifactFormats(ajv);
const schemas = new Map<string, JsonSchemaFile>();
for (const file of readSchemaFiles(root, root)) {
if (schemas.has(file.id)) {
throw artifactSchemaLoadFailed(file.id, new Error(`Duplicate artifact schema id ${file.id}`));
}
schemas.set(file.id, file);
}
const registry = new Map<string, CompiledArtifactSchema>();
for (const file of schemas.values()) {
try {
registry.set(file.id, {
id: file.id,
schema: deepFreeze(file.schema),
validate: ajv.compile(file.schema),
path: file.path,
});
} catch (error) {
throw artifactSchemaLoadFailed(file.id, error);
}
}
return registry;
}
interface JsonSchemaFile {
id: string;
schema: JsonSchema;
path: string;
}
function readSchemaFiles(root: string, directory: string): JsonSchemaFile[] {
const files: JsonSchemaFile[] = [];
let entryNames: string[];
try {
entryNames = readdirSync(directory).sort();
} catch (error) {
throw artifactSchemaLoadFailed(directory, error);
}
for (const entryName of entryNames) {
const path = join(directory, entryName);
let stat: ReturnType<typeof lstatSync>;
try {
stat = lstatSync(path);
} catch (error) {
throw artifactSchemaLoadFailed(path, error);
}
if (stat.isSymbolicLink()) {
throw artifactSchemaLoadFailed(
entryName,
new Error("Artifact schema path must not be a symlink"),
);
}
if (stat.isDirectory()) {
files.push(...readSchemaFiles(root, path));
continue;
}
if (!entryName.endsWith(".json")) {
continue;
}
const canonicalPath = resolveSchemaFilePath(path);
const id = schemaIdFromPath(root, canonicalPath);
const parsed = parseSchemaFile(id, canonicalPath);
if (!isJsonObject(parsed)) {
throw artifactSchemaLoadFailed(id, new Error("Artifact schema must be a JSON object"));
}
if (parsed.$id !== id) {
throw artifactSchemaLoadFailed(id, new Error(`Artifact schema $id must equal ${id}`));
}
files.push({ id, schema: parsed, path: canonicalPath });
}
return files;
}
function schemaIdFromPath(root: string, path: string) {
const relativePath = relative(root, path).split(sep).join("/");
const id = relativePath.replace(/\.json$/, "");
if (!schemaIdPattern.test(id)) {
throw artifactSchemaLoadFailed(id, new Error(`Invalid artifact schema path ${relativePath}`));
}
return id;
}
function assertSchemaId(id: string) {
if (!schemaIdPattern.test(id)) {
throw artifactSchemaUnknown(id);
}
}
function toValidationError(error: ErrorObject): ValidationError {
return {
instancePath: error.instancePath,
schemaPath: error.schemaPath,
keyword: error.keyword,
...(error.message === undefined ? {} : { message: error.message }),
params: toJsonObject(error.params),
};
}
function toJsonObject(value: Record<string, unknown>): JsonObject {
return Object.fromEntries(
Object.entries(value).map(([key, childValue]) => [key, toJsonValue(childValue)]),
);
}
function toJsonValue(value: unknown): JsonValue {
if (value === null || typeof value === "string" || typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : String(value);
}
if (Array.isArray(value)) {
return value.map(toJsonValue);
}
if (isJsonObject(value)) {
return toJsonObject(value);
}
return String(value);
}
function isJsonObject(value: unknown): value is JsonObject {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function resolveSchemaFilePath(path: string): string {
try {
return realpathSync(path);
} catch (error) {
throw artifactSchemaLoadFailed(path, error);
}
}
function parseSchemaFile(id: string, path: string): unknown {
try {
return JSON.parse(readFileSync(path, "utf8")) as unknown;
} catch (error) {
throw artifactSchemaLoadFailed(id, error);
}
}
function deepFreeze<T extends JsonValue>(value: T): T {
if (value !== null && typeof value === "object") {
Object.freeze(value);
for (const child of Object.values(value)) {
deepFreeze(child);
}
}
return value;
}
function artifactSchemaUnknown(id: string) {
return new DevflowError(`artifact_schema_unknown:${id}`, {
class: "fatal",
code: "artifact_schema_unknown",
recoveryHint: `Add docs/schemas/artifacts/${id}.json or update the template schema id.`,
});
}
function artifactSchemaLoadFailed(id: string, cause: unknown) {
return new DevflowError(`artifact_schema_load_failed:${id}`, {
class: "fatal",
code: "artifact_schema_load_failed",
cause,
recoveryHint: "Fix the artifact schema JSON document.",
});
}

View File

@@ -1,3 +1,4 @@
export * from "./artifact-schema.js";
export * from "./config.js";
export * from "./binding.js";
export * from "./enums.js";