From 1338e72e96609fe279968dc2a5ef0afd566f08a6 Mon Sep 17 00:00:00 2001 From: chungyeong Date: Sun, 10 May 2026 01:11:37 +0900 Subject: [PATCH] feat: add artifact schema registry --- docs/plan.md | 2 + .../artifacts/common/final-report@1.json | 73 ++++ docs/schemas/artifacts/dev/phase-plan@1.json | 73 ++++ docs/schemas/artifacts/dev/spec@1.json | 38 ++ packages/core/package.json | 1 + packages/core/src/artifact-schema.test.ts | 366 +++++++++++++++++ packages/core/src/artifact-schema.ts | 388 ++++++++++++++++++ packages/core/src/index.ts | 1 + pnpm-lock.yaml | 34 ++ 9 files changed, 976 insertions(+) create mode 100644 docs/schemas/artifacts/common/final-report@1.json create mode 100644 docs/schemas/artifacts/dev/phase-plan@1.json create mode 100644 docs/schemas/artifacts/dev/spec@1.json create mode 100644 packages/core/src/artifact-schema.test.ts create mode 100644 packages/core/src/artifact-schema.ts diff --git a/docs/plan.md b/docs/plan.md index feb4ed4..95317ef 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1478,6 +1478,8 @@ Fatal: - `workspace_permissions` - `internal_state_corruption` - `template_load_failed` +- `artifact_schema_unknown` +- `artifact_schema_load_failed` - `migration_pending` - `config_invalid` diff --git a/docs/schemas/artifacts/common/final-report@1.json b/docs/schemas/artifacts/common/final-report@1.json new file mode 100644 index 0000000..2a94cfe --- /dev/null +++ b/docs/schemas/artifacts/common/final-report@1.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "common/final-report@1", + "title": "Devflow Final Report", + "type": "object", + "required": [ + "runId", + "templateHash", + "bindings", + "inputs", + "phases", + "approvals", + "findings", + "commands", + "artifacts", + "events", + "unresolved", + "endedAt", + "status" + ], + "properties": { + "runId": { "type": "string", "format": "uuid" }, + "templateHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "bindings": { + "type": "array", + "items": { + "type": "object", + "required": ["roleId", "personaHash", "backend"], + "properties": { + "roleId": { "type": "string", "minLength": 1 }, + "personaHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "backend": { "type": "string", "enum": ["codex", "claude", "fake"] } + } + } + }, + "inputs": { "type": "object" }, + "phases": { "type": "array" }, + "approvals": { "type": "array" }, + "findings": { "type": "array" }, + "commands": { + "type": "array", + "items": { + "type": "object", + "required": ["kind", "argv", "exit_code"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "argv": { + "type": "array", + "items": { "type": "string" } + }, + "exit_code": { "type": ["integer", "null"] } + } + } + }, + "artifacts": { "type": "array" }, + "events": { + "type": "object", + "required": ["tail"], + "properties": { + "tail": { + "type": "array", + "maxItems": 200 + } + } + }, + "unresolved": { "type": "array" }, + "endedAt": { "type": "string", "format": "utc-date-time" }, + "status": { + "type": "string", + "enum": ["completed", "failed", "aborted"] + } + } +} diff --git a/docs/schemas/artifacts/dev/phase-plan@1.json b/docs/schemas/artifacts/dev/phase-plan@1.json new file mode 100644 index 0000000..4bce4ce --- /dev/null +++ b/docs/schemas/artifacts/dev/phase-plan@1.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "dev/phase-plan@1", + "title": "Devflow Phase Plan", + "type": "object", + "additionalProperties": false, + "required": ["phases"], + "properties": { + "phases": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["key", "title", "objective", "roles"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "objective": { "type": "string", "minLength": 1 }, + "roles": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "expectedArtifact": { + "type": "object", + "additionalProperties": false, + "required": ["path", "schema"], + "properties": { + "path": { + "type": "string", + "pattern": "^(?!.*:)(?!.*(?:^|/)\\.{1,2}(?:/|$))[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$" + }, + "schema": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]*/[a-z][a-z0-9_-]*@[1-9][0-9]*$" + } + } + }, + "gates": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "tasks": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title", "role", "writeSet"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "minLength": 1 }, + "writeSet": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^(?!/)(?!.*:)(?!.*[!{}()\\[\\]\\\\\\r\\n])(?!.*(?:^|/)\\.{1,2}(?:/|$)).+$" + } + }, + "dependsOn": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + } + } + } + } + } + } +} diff --git a/docs/schemas/artifacts/dev/spec@1.json b/docs/schemas/artifacts/dev/spec@1.json new file mode 100644 index 0000000..aaadc03 --- /dev/null +++ b/docs/schemas/artifacts/dev/spec@1.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "dev/spec@1", + "title": "Devflow Development Specification", + "type": "object", + "additionalProperties": false, + "required": ["summary", "requirements", "acceptanceCriteria", "risks"], + "properties": { + "summary": { "type": "string", "minLength": 1 }, + "requirements": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "description"], + "properties": { + "id": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]*$" }, + "description": { "type": "string", "minLength": 1 }, + "priority": { "type": "string", "enum": ["low", "medium", "high"] } + } + } + }, + "acceptanceCriteria": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "risks": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "openQuestions": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/packages/core/package.json b/packages/core/package.json index 79f3459..e40720b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -12,6 +12,7 @@ "test": "vitest run" }, "dependencies": { + "ajv": "8.17.1", "dotenv": "17.4.2", "yaml": "2.6.1", "zod": "3.24.1" diff --git a/packages/core/src/artifact-schema.test.ts b/packages/core/src/artifact-schema.test.ts new file mode 100644 index 0000000..ca0e8f4 --- /dev/null +++ b/packages/core/src/artifact-schema.test.ts @@ -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" }); + }); +}); diff --git a/packages/core/src/artifact-schema.ts b/packages/core/src/artifact-schema.ts new file mode 100644 index 0000000..d282d8e --- /dev/null +++ b/packages/core/src/artifact-schema.ts @@ -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>(); + +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 { + 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 = { + 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 = { + 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 { + const ajv = new Ajv2020({ allErrors: true, strict: true }); + addArtifactFormats(ajv); + const schemas = new Map(); + + 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(); + 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; + 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): 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(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.", + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 142c801..5e27efc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ +export * from "./artifact-schema.js"; export * from "./config.js"; export * from "./binding.js"; export * from "./enums.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b367dcd..2a8dfc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: packages/core: dependencies: + ajv: + specifier: 8.17.1 + version: 8.17.1 dotenv: specifier: 17.4.2 version: 17.4.2 @@ -1263,6 +1266,9 @@ packages: '@vitest/utils@2.1.8': resolution: {integrity: sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1510,6 +1516,12 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1573,6 +1585,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + lefthook-darwin-arm64@2.1.6: resolution: {integrity: sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ==} cpu: [arm64] @@ -1793,6 +1808,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -2779,6 +2798,13 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -3044,6 +3070,10 @@ snapshots: expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.2: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -3106,6 +3136,8 @@ snapshots: joycon@3.1.1: {} + json-schema-traverse@1.0.0: {} + lefthook-darwin-arm64@2.1.6: optional: true @@ -3279,6 +3311,8 @@ snapshots: readdirp@4.1.2: {} + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {}