diff --git a/packages/factory/package.json b/packages/factory/package.json index fadc61e0..1e01c031 100644 --- a/packages/factory/package.json +++ b/packages/factory/package.json @@ -8,6 +8,7 @@ "dev": "tsx src/server/index.ts & pnpm vite --host", "dev:server": "tsx --watch src/server/index.ts", "dev:client": "pnpm vite --host", + "validate:run-index": "tsx src/scripts/validate-run-index.ts", "ws": "node --import tsx ../../scripts/run-workspace-script.ts" }, "dependencies": { @@ -15,7 +16,8 @@ "excalibur": "0.32.0", "express": "5.2.1", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "zod": "4.3.6" }, "devDependencies": { "@testing-library/dom": "10.4.1", diff --git a/packages/factory/src/scripts/__tests__/validate-run-index.test.ts b/packages/factory/src/scripts/__tests__/validate-run-index.test.ts new file mode 100644 index 00000000..feedd16f --- /dev/null +++ b/packages/factory/src/scripts/__tests__/validate-run-index.test.ts @@ -0,0 +1,365 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ValidationResult } from '../validate-run-index.js'; +import { findRunIndexFiles, main, reportResults, validateFile } from '../validate-run-index.js'; + +const { mockedReadFile, mockedReaddir, mockedStat } = vi.hoisted(() => ({ + mockedReadFile: vi.fn(), + mockedReaddir: vi.fn(), + mockedStat: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + default: { readFile: mockedReadFile, readdir: mockedReaddir, stat: mockedStat }, + readFile: mockedReadFile, + readdir: mockedReaddir, + stat: mockedStat, +})); + +function minimalValid(): Record & { context: Record } { + return { + version: 2, + context: { + runId: 'test-run', + projectSlug: 'test', + projectRoot: '/test', + branch: 'main', + task: 'test task', + startedAt: '2026-01-01T00:00:00Z', + status: 'in_progress', + phases: {}, + }, + config: {}, + }; +} + +/** Creates a minimal Dirent-like object suitable for mocked readdir results. */ +function makeDirent( + name: string, + isDir: boolean, +): { + name: string; + isDirectory: () => boolean; + isFile: () => boolean; +} { + return { + name, + isDirectory: () => isDir, + isFile: () => !isDir, + }; +} + +/** Creates a minimal Stats-like object suitable for mocked stat results. */ +function makeStats(isDir: boolean): { isDirectory: () => boolean } { + return { isDirectory: () => isDir }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// -- validateFile ----------------------------------------------------------- + +describe('validateFile', () => { + it('returns valid result for correct run-index.json', async () => { + mockedReadFile.mockResolvedValue(JSON.stringify(minimalValid())); + + const result = await validateFile('/path/to/run-index.json'); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.filePath).toBe('/path/to/run-index.json'); + }); + + it('returns invalid result with field paths for bad data', async () => { + const bad = { ...minimalValid(), version: 1 }; + mockedReadFile.mockResolvedValue(JSON.stringify(bad)); + + const result = await validateFile('/path/to/run-index.json'); + + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('includes field path in error message for nested failures', async () => { + const base = minimalValid(); + const bad = { ...base, context: { ...base.context, status: 'unknown' } }; + mockedReadFile.mockResolvedValue(JSON.stringify(bad)); + + const result = await validateFile('/path/to/run-index.json'); + + expect(result.valid).toBe(false); + const joined = result.errors.join('\n'); + expect(joined).toContain('context.status'); + }); + + it('throws on invalid JSON', async () => { + mockedReadFile.mockResolvedValue('not json'); + + await expect(validateFile('/path/to/run-index.json')).rejects.toThrow(); + }); + + it('formats error without path prefix for top-level failures', async () => { + mockedReadFile.mockResolvedValue(JSON.stringify(null)); + + const result = await validateFile('/path/to/run-index.json'); + + expect(result.valid).toBe(false); + // Top-level error should not have a ":" prefix from an empty path + for (const error of result.errors) { + expect(error).not.toMatch(/^\s+:/); + } + }); +}); + +// -- findRunIndexFiles ------------------------------------------------------ + +describe('findRunIndexFiles', () => { + it('finds run-index.json files in nested subdirectories', async () => { + // /root + // /a/run-index.json + // /b/c/run-index.json + // /other.json + mockedReaddir + .mockResolvedValueOnce([makeDirent('a', true), makeDirent('b', true), makeDirent('other.json', false)]) + .mockResolvedValueOnce([makeDirent('run-index.json', false)]) + .mockResolvedValueOnce([makeDirent('c', true)]) + .mockResolvedValueOnce([makeDirent('run-index.json', false)]); + + const result = await findRunIndexFiles('/root'); + + expect(result).toEqual(['/root/a/run-index.json', '/root/b/c/run-index.json']); + }); + + it('returns empty array for directory with no matching files', async () => { + mockedReaddir.mockResolvedValueOnce([makeDirent('status.json', false), makeDirent('config.yaml', false)]); + + const result = await findRunIndexFiles('/root'); + + expect(result).toEqual([]); + }); + + it('returns empty array for empty directory', async () => { + mockedReaddir.mockResolvedValueOnce([]); + + const result = await findRunIndexFiles('/root'); + + expect(result).toEqual([]); + }); + + it('sorts results alphabetically', async () => { + // /root + // /z/run-index.json + // /a/run-index.json + mockedReaddir + .mockResolvedValueOnce([makeDirent('z', true), makeDirent('a', true)]) + .mockResolvedValueOnce([makeDirent('run-index.json', false)]) + .mockResolvedValueOnce([makeDirent('run-index.json', false)]); + + const result = await findRunIndexFiles('/root'); + + expect(result).toEqual(['/root/a/run-index.json', '/root/z/run-index.json']); + }); + + it('logs warning and continues when readdir fails for a subdirectory', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + mockedReaddir + .mockResolvedValueOnce([makeDirent('accessible', true), makeDirent('denied', true)]) + .mockResolvedValueOnce([makeDirent('run-index.json', false)]) + .mockRejectedValueOnce(new Error('EACCES: permission denied')); + + const result = await findRunIndexFiles('/root'); + + expect(result).toEqual(['/root/accessible/run-index.json']); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('cannot read directory /root/denied')); + }); +}); + +// -- reportResults ---------------------------------------------------------- + +describe('reportResults', () => { + it('handles empty results array', () => { + const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined); + + const results: ValidationResult[] = []; + + const failCount = reportResults(results); + + expect(failCount).toBe(0); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('0 passed, 0 failed out of 0')); + }); + + it('reports passing files', () => { + const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined); + + const results: ValidationResult[] = [{ filePath: '/a/run-index.json', valid: true, errors: [] }]; + + const failCount = reportResults(results); + + expect(failCount).toBe(0); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('PASS')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1 passed, 0 failed')); + }); + + it('reports failing files with errors', () => { + const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined); + + const results: ValidationResult[] = [ + { filePath: '/a/run-index.json', valid: false, errors: [' context.status: bad'] }, + ]; + + const failCount = reportResults(results); + + expect(failCount).toBe(1); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('FAIL')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('0 passed, 1 failed')); + }); + + it('reports correct summary for mixed results', () => { + const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined); + + const results: ValidationResult[] = [ + { filePath: '/a/run-index.json', valid: true, errors: [] }, + { filePath: '/b/run-index.json', valid: false, errors: [' error'] }, + { filePath: '/c/run-index.json', valid: true, errors: [] }, + ]; + + const failCount = reportResults(results); + + expect(failCount).toBe(1); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('2 passed, 1 failed out of 3')); + }); +}); + +// -- main CLI entry point --------------------------------------------------- + +describe('main', () => { + let originalArgv: string[]; + + afterEach(() => { + process.argv = originalArgv; + process.exitCode = undefined; + }); + + function setArgs(...args: string[]): void { + originalArgv = process.argv; + process.argv = ['node', 'validate-run-index.ts', ...args]; + } + + it('sets exitCode=1 and prints usage when no argument provided', async () => { + originalArgv = process.argv; + process.argv = ['node', 'validate-run-index.ts']; + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await main(); + + expect(process.exitCode).toBe(1); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Usage')); + }); + + it('sets exitCode=1 when path does not exist', async () => { + setArgs('/nonexistent'); + mockedStat.mockRejectedValue(new Error('ENOENT: no such file')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await main(); + + expect(process.exitCode).toBe(1); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Cannot access path /nonexistent')); + }); + + it('includes error message when stat fails with permission error', async () => { + setArgs('/restricted'); + mockedStat.mockRejectedValue(new Error('EACCES: permission denied')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await main(); + + expect(process.exitCode).toBe(1); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('EACCES')); + }); + + it('exits 0 for directory with no run-index.json files', async () => { + setArgs('/empty-dir'); + mockedStat.mockResolvedValue(makeStats(true)); + mockedReaddir.mockResolvedValue([]); + vi.spyOn(console, 'info').mockImplementation(() => undefined); + + await main(); + + expect(process.exitCode).toBeUndefined(); + }); + + it('exits 0 for directory with valid files', async () => { + setArgs('/dir'); + mockedStat.mockResolvedValue(makeStats(true)); + mockedReaddir.mockResolvedValue([makeDirent('run-index.json', false)]); + mockedReadFile.mockResolvedValue(JSON.stringify(minimalValid())); + vi.spyOn(console, 'info').mockImplementation(() => undefined); + + await main(); + + expect(process.exitCode).toBeUndefined(); + }); + + it('sets exitCode=1 for directory with invalid files', async () => { + setArgs('/dir'); + mockedStat.mockResolvedValue(makeStats(true)); + mockedReaddir.mockResolvedValue([makeDirent('run-index.json', false)]); + mockedReadFile.mockResolvedValue(JSON.stringify({ version: 1 })); + vi.spyOn(console, 'info').mockImplementation(() => undefined); + + await main(); + + expect(process.exitCode).toBe(1); + }); + + it('exits 0 for single valid file', async () => { + setArgs('/path/run-index.json'); + mockedStat.mockResolvedValue(makeStats(false)); + mockedReadFile.mockResolvedValue(JSON.stringify(minimalValid())); + vi.spyOn(console, 'info').mockImplementation(() => undefined); + + await main(); + + expect(process.exitCode).toBeUndefined(); + }); + + it('sets exitCode=1 for single invalid file', async () => { + setArgs('/path/run-index.json'); + mockedStat.mockResolvedValue(makeStats(false)); + mockedReadFile.mockResolvedValue(JSON.stringify({ version: 1 })); + vi.spyOn(console, 'info').mockImplementation(() => undefined); + + await main(); + + expect(process.exitCode).toBe(1); + }); + + it('reports Invalid JSON prefix when file contains malformed JSON', async () => { + setArgs('/path/run-index.json'); + mockedStat.mockResolvedValue(makeStats(false)); + mockedReadFile.mockResolvedValue('not valid json'); + const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined); + + await main(); + + expect(process.exitCode).toBe(1); + const allCalls = consoleSpy.mock.calls.flat().join(' '); + expect(allCalls).toContain('Invalid JSON'); + }); + + it('reports Read error prefix when readFile throws a non-JSON error', async () => { + setArgs('/path/run-index.json'); + mockedStat.mockResolvedValue(makeStats(false)); + mockedReadFile.mockRejectedValue(new Error('EACCES: permission denied')); + const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined); + + await main(); + + expect(process.exitCode).toBe(1); + const allCalls = consoleSpy.mock.calls.flat().join(' '); + expect(allCalls).toContain('Read error'); + }); +}); diff --git a/packages/factory/src/scripts/validate-run-index.ts b/packages/factory/src/scripts/validate-run-index.ts new file mode 100644 index 00000000..b118c408 --- /dev/null +++ b/packages/factory/src/scripts/validate-run-index.ts @@ -0,0 +1,134 @@ +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { v2RunIndexSchema } from '../server/adapters/schemas/run-index-schema.js'; + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// -- core validation logic (exported for testing) ---------------------------- + +export interface ValidationResult { + filePath: string; + valid: boolean; + errors: string[]; +} + +/** Validate a single run-index.json file against the V2 schema. */ +export async function validateFile(filePath: string): Promise { + const content = await readFile(filePath, 'utf8'); + const raw: unknown = JSON.parse(content); + const result = v2RunIndexSchema.safeParse(raw); + + if (result.success) { + return { filePath, valid: true, errors: [] }; + } + + const errors = result.error.issues.map((issue) => { + const path = issue.path.join('.'); + return path ? ` ${path}: ${issue.message}` : ` ${issue.message}`; + }); + + return { filePath, valid: false, errors }; +} + +/** Recursively find all run-index.json files under a directory. */ +export async function findRunIndexFiles(dirPath: string): Promise { + const found: string[] = []; + + async function walk(dir: string): Promise { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (error) { + console.error(`Warning: cannot read directory ${dir}: ${getErrorMessage(error)}`); + return; + } + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + } else if (entry.name === 'run-index.json') { + found.push(fullPath); + } + } + } + + await walk(dirPath); + found.sort((a, b) => a.localeCompare(b)); + return found; +} + +/** Format and print validation results. Returns the count of failures. */ +export function reportResults(results: ValidationResult[]): number { + let passed = 0; + let failed = 0; + + for (const result of results) { + if (result.valid) { + console.info(`\u2713 PASS: ${result.filePath}`); + passed++; + } else { + console.info(`\u2717 FAIL: ${result.filePath}`); + for (const error of result.errors) { + console.info(error); + } + failed++; + } + } + + const total = passed + failed; + console.info(`\n${passed} passed, ${failed} failed out of ${total} files`); + return failed; +} + +// -- CLI entry point --------------------------------------------------------- + +/** CLI entry point. Exported for testing. */ +export async function main(): Promise { + const targetPath = process.argv[2]; + if (!targetPath) { + console.error('Usage: validate-run-index '); + process.exitCode = 1; + return; + } + + let fileStats; + try { + fileStats = await stat(targetPath); + } catch (error) { + console.error(`Cannot access path ${targetPath}: ${getErrorMessage(error)}`); + process.exitCode = 1; + return; + } + + const filePaths = fileStats.isDirectory() ? await findRunIndexFiles(targetPath) : [targetPath]; + + if (filePaths.length === 0) { + console.info('No run-index.json files found.'); + return; + } + + const results: ValidationResult[] = []; + for (const filePath of filePaths) { + try { + results.push(await validateFile(filePath)); + } catch (error) { + const prefix = error instanceof SyntaxError ? 'Invalid JSON' : 'Read error'; + results.push({ filePath, valid: false, errors: [` ${prefix}: ${getErrorMessage(error)}`] }); + } + } + + const failCount = reportResults(results); + if (failCount > 0) { + process.exitCode = 1; + } +} + +// Run only when executed directly (not when imported by tests). +const isDirectRun = resolve(fileURLToPath(import.meta.url)) === resolve(process.argv[1] ?? ''); +if (isDirectRun) { + await main(); +} diff --git a/packages/factory/src/server/adapters/__tests__/status-adapter.test.ts b/packages/factory/src/server/adapters/__tests__/status-adapter.test.ts index bde7e2e1..925f672e 100644 --- a/packages/factory/src/server/adapters/__tests__/status-adapter.test.ts +++ b/packages/factory/src/server/adapters/__tests__/status-adapter.test.ts @@ -312,6 +312,14 @@ describe('parseStatusFile', () => { await expect(parseStatusFile('/path/to/status.json')).rejects.toThrow('Invalid status.json'); }); + it('converts completedAt: null to undefined', async () => { + mockJson({ ...minimalValid(), completedAt: null }); + + const result = await parseStatusFile('/path/to/status.json'); + + expect(result.completedAt).toBeUndefined(); + }); + it('rejects non-boolean externalPlan', async () => { mockJson({ ...minimalValid(), externalPlan: 'yes' }); @@ -392,10 +400,11 @@ describe('parseStatusFile', () => { await expect(parseStatusFile('/path/to/status.json')).rejects.toThrow('Invalid status.json'); }); - it('accepts null phase entry (skipped phase)', async () => { + it('accepts null phase entry (phase not yet reached)', async () => { mockedReadFile.mockResolvedValue(JSON.stringify({ ...minimalValid(), phases: { architecture: null } })); const result = await parseStatusFile('/path/to/status.json'); + expect(result.phases).toEqual({ architecture: null }); }); diff --git a/packages/factory/src/server/adapters/schemas/__tests__/run-index-schema.test.ts b/packages/factory/src/server/adapters/schemas/__tests__/run-index-schema.test.ts new file mode 100644 index 00000000..cf7f9a16 --- /dev/null +++ b/packages/factory/src/server/adapters/schemas/__tests__/run-index-schema.test.ts @@ -0,0 +1,443 @@ +import { describe, expect, it } from 'vitest'; + +import { + artifactEntrySchema, + criticalitySchema, + phaseDecisionMapSchema, + phaseDecisionSchema, + phaseEntrySchema, + phasesSchema, + phaseStatusSchema, + runStatusSchema, + v2ConfigSchema, + v2ContextSchema, + v2RunIndexSchema, +} from '../run-index-schema.js'; + +// -- fixtures ---------------------------------------------------------------- + +function minimalContext(): Record { + return { + runId: 'test-run', + projectSlug: 'test', + projectRoot: '/test', + branch: 'main', + task: 'test task', + startedAt: '2026-01-01T00:00:00Z', + status: 'in_progress', + phases: {}, + }; +} + +function minimalV2(): Record { + return { + version: 2, + context: minimalContext(), + config: {}, + }; +} + +function fullArtifact(): Record { + return { + filename: 'architecture.md', + role: 'Architecture document', + roleType: 'architecture', + agent: 'architect', + type: 'markdown', + phase: 'architecture', + createdAt: '2026-01-01T00:00:00Z', + }; +} + +// -- enum schemas ------------------------------------------------------------ + +describe('runStatusSchema', () => { + it.each(['in_progress', 'completed', 'failed', 'needs_manual_review'])('accepts "%s"', (value) => { + expect(runStatusSchema.safeParse(value).success).toBe(true); + }); + + it.each(['pending', 'running', 'cancelled', 'COMPLETED', '', 'unknown'])('rejects "%s"', (value) => { + expect(runStatusSchema.safeParse(value).success).toBe(false); + }); +}); + +describe('phaseStatusSchema', () => { + it.each(['completed', 'skipped', 'failed', 'in_progress', 'approved'])('accepts "%s"', (value) => { + expect(phaseStatusSchema.safeParse(value).success).toBe(true); + }); + + it.each(['pending', 'running', 'cancelled', 'COMPLETED', ''])('rejects "%s"', (value) => { + expect(phaseStatusSchema.safeParse(value).success).toBe(false); + }); +}); + +describe('criticalitySchema', () => { + it.each(['none', 'low', 'medium', 'high'])('accepts "%s"', (value) => { + expect(criticalitySchema.safeParse(value).success).toBe(true); + }); + + it.each(['critical', 'extreme', 'NONE', ''])('rejects "%s"', (value) => { + expect(criticalitySchema.safeParse(value).success).toBe(false); + }); +}); + +// -- phase entry ------------------------------------------------------------- + +describe('phaseEntrySchema', () => { + it('accepts empty object', () => { + expect(phaseEntrySchema.safeParse({}).success).toBe(true); + }); + + it('accepts valid status', () => { + expect(phaseEntrySchema.safeParse({ status: 'completed' }).success).toBe(true); + }); + + it('rejects invalid status', () => { + expect(phaseEntrySchema.safeParse({ status: 'bad' }).success).toBe(false); + }); + + it('accepts null status', () => { + expect(phaseEntrySchema.safeParse({ status: null }).success).toBe(true); + }); + + it.each(['status', 'criticality', 'finalCriticality', 'aggregatedCriticality'])('accepts null for %s', (field) => { + expect(phaseEntrySchema.safeParse({ [field]: null }).success).toBe(true); + }); + + it.each(['status', 'criticality', 'finalCriticality', 'aggregatedCriticality'])( + 'accepts undefined (absent) for %s', + (field) => { + const data: Record = { otherField: 'value' }; + // Field is absent, which .partial() + .nullish() should allow. + expect(data).not.toHaveProperty(field); + expect(phaseEntrySchema.safeParse(data).success).toBe(true); + }, + ); + + it('accepts valid criticality', () => { + expect(phaseEntrySchema.safeParse({ criticality: 'high' }).success).toBe(true); + }); + + it('rejects invalid criticality', () => { + expect(phaseEntrySchema.safeParse({ criticality: 'extreme' }).success).toBe(false); + }); + + it('accepts valid finalCriticality', () => { + expect(phaseEntrySchema.safeParse({ finalCriticality: 'low' }).success).toBe(true); + }); + + it('rejects invalid finalCriticality', () => { + expect(phaseEntrySchema.safeParse({ finalCriticality: 'extreme' }).success).toBe(false); + }); + + it('accepts valid aggregatedCriticality', () => { + expect(phaseEntrySchema.safeParse({ aggregatedCriticality: 'medium' }).success).toBe(true); + }); + + it('rejects invalid aggregatedCriticality', () => { + expect(phaseEntrySchema.safeParse({ aggregatedCriticality: 'extreme' }).success).toBe(false); + }); + + it('passes through unknown keys', () => { + const result = phaseEntrySchema.safeParse({ + status: 'completed', + impactLevel: 'high', + artifact: 'architecture.md', + stepCount: 7, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveProperty('impactLevel', 'high'); + expect(result.data).toHaveProperty('artifact', 'architecture.md'); + expect(result.data).toHaveProperty('stepCount', 7); + } + }); +}); + +// -- phases record ----------------------------------------------------------- + +describe('phasesSchema', () => { + it('accepts empty object', () => { + expect(phasesSchema.safeParse({}).success).toBe(true); + }); + + it('accepts null phase entry (phase not yet reached)', () => { + expect(phasesSchema.safeParse({ architecture: null }).success).toBe(true); + }); + + it('accepts valid phase entry', () => { + expect(phasesSchema.safeParse({ architecture: { status: 'completed' } }).success).toBe(true); + }); + + it('rejects string phase entry', () => { + expect(phasesSchema.safeParse({ architecture: 'completed' }).success).toBe(false); + }); + + it('rejects non-object phases', () => { + expect(phasesSchema.safeParse('phases').success).toBe(false); + }); + + it('accepts unknown phase names', () => { + expect(phasesSchema.safeParse({ customPhase: { status: 'in_progress' } }).success).toBe(true); + }); +}); + +// -- phase decisions --------------------------------------------------------- + +describe('phaseDecisionSchema', () => { + it('accepts entry with run and reason', () => { + expect(phaseDecisionSchema.safeParse({ run: true, reason: 'Required' }).success).toBe(true); + }); + + it('accepts entry with run only', () => { + expect(phaseDecisionSchema.safeParse({ run: true }).success).toBe(true); + }); + + it('rejects entry missing run', () => { + expect(phaseDecisionSchema.safeParse({ reason: 'Missing run' }).success).toBe(false); + }); + + it('rejects entry with non-boolean run', () => { + expect(phaseDecisionSchema.safeParse({ run: 'yes' }).success).toBe(false); + }); + + it('rejects entry with non-string reason', () => { + expect(phaseDecisionSchema.safeParse({ run: true, reason: 42 }).success).toBe(false); + }); + + it('passes through unknown keys like disposition', () => { + const result = phaseDecisionSchema.safeParse({ + run: true, + disposition: 'executed', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveProperty('disposition', 'executed'); + } + }); +}); + +describe('phaseDecisionMapSchema', () => { + it('accepts undefined', () => { + expect(phaseDecisionMapSchema.safeParse(undefined).success).toBe(true); + }); + + it('accepts valid map', () => { + const map = { architecture: { run: true, reason: 'Test' } }; + expect(phaseDecisionMapSchema.safeParse(map).success).toBe(true); + }); + + it('rejects non-object value', () => { + expect(phaseDecisionMapSchema.safeParse('decisions').success).toBe(false); + }); + + it('rejects entry that is not an object', () => { + expect(phaseDecisionMapSchema.safeParse({ architecture: 'should run' }).success).toBe(false); + }); +}); + +// -- artifact entry ---------------------------------------------------------- + +describe('artifactEntrySchema', () => { + it('accepts entry with all required fields', () => { + expect(artifactEntrySchema.safeParse(fullArtifact()).success).toBe(true); + }); + + it('accepts entry with optional iteration and note', () => { + const entry = { ...fullArtifact(), iteration: 1, note: 'Initial' }; + expect(artifactEntrySchema.safeParse(entry).success).toBe(true); + }); + + it('accepts iteration: 0', () => { + const entry = { ...fullArtifact(), iteration: 0 }; + expect(artifactEntrySchema.safeParse(entry).success).toBe(true); + }); + + it('rejects entry missing required field', () => { + const { filename: _, ...rest } = fullArtifact(); + expect(artifactEntrySchema.safeParse(rest).success).toBe(false); + }); + + it('rejects entry with non-number iteration', () => { + const entry = { ...fullArtifact(), iteration: 'one' }; + expect(artifactEntrySchema.safeParse(entry).success).toBe(false); + }); + + it('rejects entry with non-string note', () => { + const entry = { ...fullArtifact(), note: 42 }; + expect(artifactEntrySchema.safeParse(entry).success).toBe(false); + }); + + it('rejects entry that is not an object', () => { + expect(artifactEntrySchema.safeParse('not an object').success).toBe(false); + }); +}); + +// -- v2 context -------------------------------------------------------------- + +describe('v2ContextSchema', () => { + it('accepts minimal valid context', () => { + expect(v2ContextSchema.safeParse(minimalContext()).success).toBe(true); + }); + + it('accepts context with optional ticketId', () => { + const ctx = { ...minimalContext(), ticketId: 'CODY-35' }; + const result = v2ContextSchema.safeParse(ctx); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.ticketId).toBe('CODY-35'); + } + }); + + it('accepts completedAt as string', () => { + const ctx = { ...minimalContext(), completedAt: '2026-01-01T12:00:00Z' }; + expect(v2ContextSchema.safeParse(ctx).success).toBe(true); + }); + + it('accepts completedAt as null', () => { + const ctx = { ...minimalContext(), completedAt: null }; + expect(v2ContextSchema.safeParse(ctx).success).toBe(true); + }); + + it('accepts completedAt as undefined (absent)', () => { + const ctx = minimalContext(); + expect(v2ContextSchema.safeParse(ctx).success).toBe(true); + expect(ctx).not.toHaveProperty('completedAt'); + }); + + it('rejects missing required field', () => { + const { runId: _, ...ctx } = minimalContext(); + const result = v2ContextSchema.safeParse(ctx); + expect(result.success).toBe(false); + }); + + it('rejects invalid status', () => { + const ctx = { ...minimalContext(), status: 'invalid' }; + const result = v2ContextSchema.safeParse(ctx); + expect(result.success).toBe(false); + }); + + it('produces meaningful error paths', () => { + const ctx = { ...minimalContext(), status: 'unknown' }; + const result = v2ContextSchema.safeParse(ctx); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((i) => i.path.join('.')); + expect(paths).toContain('status'); + } + }); +}); + +// -- v2 config --------------------------------------------------------------- + +describe('v2ConfigSchema', () => { + it('accepts empty config', () => { + expect(v2ConfigSchema.safeParse({}).success).toBe(true); + }); + + it('accepts all optional fields', () => { + const config = { + externalPlan: true, + mergeBaseSha: 'abc', + diffBase: 'origin/main', + maxReviewRounds: 3, + fixLowFindings: false, + mode: 'orchestrated', + model: 'claude-opus-4-6', + }; + expect(v2ConfigSchema.safeParse(config).success).toBe(true); + }); + + it('rejects non-boolean externalPlan', () => { + expect(v2ConfigSchema.safeParse({ externalPlan: 'yes' }).success).toBe(false); + }); + + it('rejects non-number maxReviewRounds', () => { + expect(v2ConfigSchema.safeParse({ maxReviewRounds: '3' }).success).toBe(false); + }); + + it('passes through unknown keys like pipeline and models', () => { + const config = { + mode: 'orchestrated', + pipeline: ['architecture', 'planning', 'implementation'], + models: { default: 'sonnet', coder: 'opus' }, + }; + const result = v2ConfigSchema.safeParse(config); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveProperty('pipeline'); + expect(result.data).toHaveProperty('models'); + } + }); +}); + +// -- v2 run-index ------------------------------------------------------------ + +describe('v2RunIndexSchema', () => { + it('accepts minimal valid v2', () => { + expect(v2RunIndexSchema.safeParse(minimalV2()).success).toBe(true); + }); + + it('enforces version: 2 literal', () => { + const v1 = { ...minimalV2(), version: 1 }; + expect(v2RunIndexSchema.safeParse(v1).success).toBe(false); + }); + + it('rejects missing context', () => { + expect(v2RunIndexSchema.safeParse({ version: 2, config: {} }).success).toBe(false); + }); + + it('rejects missing config', () => { + expect(v2RunIndexSchema.safeParse({ version: 2, context: minimalContext() }).success).toBe(false); + }); + + it('accepts with artifacts array', () => { + const v2 = { ...minimalV2(), artifacts: [fullArtifact()] }; + expect(v2RunIndexSchema.safeParse(v2).success).toBe(true); + }); + + it('accepts without artifacts field', () => { + expect(v2RunIndexSchema.safeParse(minimalV2()).success).toBe(true); + }); + + it('accepts empty artifacts array', () => { + const v2 = { ...minimalV2(), artifacts: [] }; + expect(v2RunIndexSchema.safeParse(v2).success).toBe(true); + }); + + it('rejects non-array artifacts', () => { + const v2 = { ...minimalV2(), artifacts: 'not-an-array' }; + expect(v2RunIndexSchema.safeParse(v2).success).toBe(false); + }); + + it('rejects artifact with missing required fields', () => { + const v2 = { ...minimalV2(), artifacts: [{ filename: 'test.md' }] }; + expect(v2RunIndexSchema.safeParse(v2).success).toBe(false); + }); + + it('rejects mixed array with one valid and one invalid artifact', () => { + const v2 = { + ...minimalV2(), + artifacts: [fullArtifact(), { filename: 'incomplete.md' }], + }; + const result = v2RunIndexSchema.safeParse(v2); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((i) => i.path.join('.')); + expect(paths.some((p) => p.startsWith('artifacts.1'))).toBe(true); + } + }); + + it('produces meaningful error paths for nested failures', () => { + const v2 = { + ...minimalV2(), + context: { ...minimalContext(), status: 'unknown' }, + }; + const result = v2RunIndexSchema.safeParse(v2); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((i) => i.path.join('.')); + expect(paths).toContain('context.status'); + } + }); +}); diff --git a/packages/factory/src/server/adapters/schemas/__tests__/status-json-schema.test.ts b/packages/factory/src/server/adapters/schemas/__tests__/status-json-schema.test.ts new file mode 100644 index 00000000..3bae099d --- /dev/null +++ b/packages/factory/src/server/adapters/schemas/__tests__/status-json-schema.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'vitest'; + +import { v1StatusSchema } from '../status-json-schema.js'; + +// -- fixtures ---------------------------------------------------------------- + +function minimalValid(): Record { + return { + runId: 'test-run', + projectSlug: 'test', + projectRoot: '/test', + branch: 'main', + task: 'test task', + startedAt: '2026-01-01T00:00:00Z', + status: 'in_progress', + phases: {}, + }; +} + +// -- valid inputs ------------------------------------------------------------ + +describe('v1StatusSchema', () => { + describe('valid inputs', () => { + it('accepts minimal valid v1', () => { + expect(v1StatusSchema.safeParse(minimalValid()).success).toBe(true); + }); + + it('accepts all optional fields', () => { + const full = { + ...minimalValid(), + ticketId: 'CODY-1', + completedAt: '2026-01-02T00:00:00Z', + externalPlan: true, + mergeBaseSha: 'abc123', + diffBase: 'origin/main', + maxReviewRounds: 3, + fixLowFindings: false, + phaseDecision: { + architecture: { run: true, reason: 'Required' }, + }, + }; + expect(v1StatusSchema.safeParse(full).success).toBe(true); + }); + + it('accepts completedAt as string', () => { + const data = { ...minimalValid(), completedAt: '2026-01-02T00:00:00Z' }; + expect(v1StatusSchema.safeParse(data).success).toBe(true); + }); + + it('accepts completedAt as null', () => { + const data = { ...minimalValid(), completedAt: null }; + expect(v1StatusSchema.safeParse(data).success).toBe(true); + }); + + it('accepts completedAt as undefined (absent)', () => { + const data = minimalValid(); + expect(data).not.toHaveProperty('completedAt'); + expect(v1StatusSchema.safeParse(data).success).toBe(true); + }); + + it('accepts phaseDecision with missing reason', () => { + const data = { + ...minimalValid(), + phaseDecision: { architecture: { run: true } }, + }; + expect(v1StatusSchema.safeParse(data).success).toBe(true); + }); + + it('accepts null phase entry', () => { + const data = { + ...minimalValid(), + phases: { architecture: null }, + }; + expect(v1StatusSchema.safeParse(data).success).toBe(true); + }); + + it('accepts phase entry with unknown keys', () => { + const data = { + ...minimalValid(), + phases: { + architecture: { status: 'completed', impactLevel: 'high', artifact: 'arch.md' }, + }, + }; + expect(v1StatusSchema.safeParse(data).success).toBe(true); + }); + }); + + // -- run status validation ------------------------------------------------- + + describe('run status validation', () => { + it.each(['in_progress', 'completed', 'failed', 'needs_manual_review'])('accepts valid status "%s"', (status) => { + expect(v1StatusSchema.safeParse({ ...minimalValid(), status }).success).toBe(true); + }); + + it.each(['pending', 'running', 'cancelled', 'COMPLETED', '', 'unknown'])( + 'rejects invalid status "%s"', + (status) => { + expect(v1StatusSchema.safeParse({ ...minimalValid(), status }).success).toBe(false); + }, + ); + + it('rejects non-string status', () => { + expect(v1StatusSchema.safeParse({ ...minimalValid(), status: 42 }).success).toBe(false); + }); + }); + + // -- optional field type validation ---------------------------------------- + + describe('optional field type validation', () => { + it('rejects non-string ticketId', () => { + expect(v1StatusSchema.safeParse({ ...minimalValid(), ticketId: 123 }).success).toBe(false); + }); + + it('rejects non-string completedAt', () => { + expect(v1StatusSchema.safeParse({ ...minimalValid(), completedAt: true }).success).toBe(false); + }); + + it('rejects non-boolean externalPlan', () => { + expect(v1StatusSchema.safeParse({ ...minimalValid(), externalPlan: 'yes' }).success).toBe(false); + }); + + it('rejects non-string mergeBaseSha', () => { + expect(v1StatusSchema.safeParse({ ...minimalValid(), mergeBaseSha: 42 }).success).toBe(false); + }); + + it('rejects non-string diffBase', () => { + expect(v1StatusSchema.safeParse({ ...minimalValid(), diffBase: false }).success).toBe(false); + }); + + it('rejects non-number maxReviewRounds', () => { + expect(v1StatusSchema.safeParse({ ...minimalValid(), maxReviewRounds: '3' }).success).toBe(false); + }); + + it('rejects non-boolean fixLowFindings', () => { + expect(v1StatusSchema.safeParse({ ...minimalValid(), fixLowFindings: 'true' }).success).toBe(false); + }); + }); + + // -- phases validation ----------------------------------------------------- + + describe('phases validation', () => { + it('rejects null phases', () => { + expect(v1StatusSchema.safeParse({ ...minimalValid(), phases: null }).success).toBe(false); + }); + + it('rejects array phases', () => { + expect(v1StatusSchema.safeParse({ ...minimalValid(), phases: [] }).success).toBe(false); + }); + + it('rejects string phases', () => { + expect(v1StatusSchema.safeParse({ ...minimalValid(), phases: 'phases' }).success).toBe(false); + }); + + it('rejects phase with invalid status', () => { + const data = { + ...minimalValid(), + phases: { architecture: { status: 'invalid_status' } }, + }; + expect(v1StatusSchema.safeParse(data).success).toBe(false); + }); + + it('rejects phase with invalid criticality', () => { + const data = { + ...minimalValid(), + phases: { holisticReview: { status: 'completed', criticality: 'critical' } }, + }; + expect(v1StatusSchema.safeParse(data).success).toBe(false); + }); + + it('rejects phase with invalid finalCriticality', () => { + const data = { + ...minimalValid(), + phases: { review: { status: 'approved', finalCriticality: 'extreme' } }, + }; + expect(v1StatusSchema.safeParse(data).success).toBe(false); + }); + + it('accepts phase with valid criticality values', () => { + const data = { + ...minimalValid(), + phases: { + holisticReview: { status: 'completed', criticality: 'low' }, + review: { status: 'approved', finalCriticality: 'none' }, + }, + }; + expect(v1StatusSchema.safeParse(data).success).toBe(true); + }); + }); + + // -- phase decisions validation -------------------------------------------- + + describe('phaseDecision validation', () => { + it('accepts undefined phaseDecision', () => { + expect(v1StatusSchema.safeParse(minimalValid()).success).toBe(true); + }); + + it('rejects non-object phaseDecision', () => { + const data = { ...minimalValid(), phaseDecision: 'decisions' }; + expect(v1StatusSchema.safeParse(data).success).toBe(false); + }); + + it('rejects phaseDecision entry missing run', () => { + const data = { + ...minimalValid(), + phaseDecision: { architecture: { reason: 'Missing run' } }, + }; + expect(v1StatusSchema.safeParse(data).success).toBe(false); + }); + + it('rejects phaseDecision entry with wrong types', () => { + const data = { + ...minimalValid(), + phaseDecision: { architecture: { run: 'yes', reason: 42 } }, + }; + expect(v1StatusSchema.safeParse(data).success).toBe(false); + }); + + it('rejects phaseDecision entry that is not an object', () => { + const data = { + ...minimalValid(), + phaseDecision: { architecture: 'should run' }, + }; + expect(v1StatusSchema.safeParse(data).success).toBe(false); + }); + }); + + // -- error handling -------------------------------------------------------- + + describe('error handling', () => { + it('rejects non-object input', () => { + expect(v1StatusSchema.safeParse('not an object').success).toBe(false); + }); + + it('rejects empty object', () => { + expect(v1StatusSchema.safeParse({}).success).toBe(false); + }); + + it('rejects null', () => { + expect(v1StatusSchema.safeParse(null).success).toBe(false); + }); + + it('rejects array', () => { + expect(v1StatusSchema.safeParse([]).success).toBe(false); + }); + + it('produces meaningful error paths', () => { + const result = v1StatusSchema.safeParse({ ...minimalValid(), status: 'unknown' }); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((i) => i.path.join('.')); + expect(paths).toContain('status'); + } + }); + }); +}); diff --git a/packages/factory/src/server/adapters/schemas/run-index-schema.ts b/packages/factory/src/server/adapters/schemas/run-index-schema.ts new file mode 100644 index 00000000..8ab05cb4 --- /dev/null +++ b/packages/factory/src/server/adapters/schemas/run-index-schema.ts @@ -0,0 +1,102 @@ +import { z } from 'zod'; + +/** Valid run-level statuses. */ +export const runStatusSchema = z.enum(['in_progress', 'completed', 'failed', 'needs_manual_review']); + +/** Valid phase-level statuses. */ +export const phaseStatusSchema = z.enum(['completed', 'skipped', 'failed', 'in_progress', 'approved']); + +/** Valid criticality levels. */ +export const criticalitySchema = z.enum(['none', 'low', 'medium', 'high']); + +/** + * A single phase entry. Known enum fields are validated when present; + * unknown keys pass through to preserve forward compatibility. + * + * Fields use `.nullish()` because production data contains explicit `null` + * values (meaning "phase not yet reached") as distinct from `undefined` + * (field absent). `.partial()` makes every field optional so keys can be + * omitted entirely. + */ +export const phaseEntrySchema = z + .object({ + status: phaseStatusSchema.nullish(), + criticality: criticalitySchema.nullish(), + finalCriticality: criticalitySchema.nullish(), + aggregatedCriticality: criticalitySchema.nullish(), + }) + .partial() + .loose(); + +/** + * Map of phase names to phase entries. Values may be `null` (phase not yet + * reached) or a phase-entry object. + */ +export const phasesSchema = z.record(z.string(), z.union([z.null(), phaseEntrySchema])); + +/** + * A single phase-decision entry (run gate). Uses `.loose()` so that + * extra fields (e.g. `disposition`) pass through without rejection. + */ +export const phaseDecisionSchema = z + .object({ + run: z.boolean(), + reason: z.string().optional(), + }) + .loose(); + +/** Optional map of phase names to phase decisions. */ +export const phaseDecisionMapSchema = z.record(z.string(), phaseDecisionSchema).optional(); + +/** A single artifact entry with 7 required string fields plus optional metadata. */ +export const artifactEntrySchema = z.object({ + filename: z.string(), + role: z.string(), + roleType: z.string(), + agent: z.string(), + type: z.string(), + phase: z.string(), + createdAt: z.string(), + iteration: z.number().optional(), + note: z.string().optional(), +}); + +/** V2 context block: run metadata plus phases and decisions. */ +export const v2ContextSchema = z.object({ + runId: z.string(), + projectSlug: z.string(), + ticketId: z.string().optional(), + projectRoot: z.string(), + branch: z.string(), + task: z.string(), + startedAt: z.string(), + completedAt: z.string().nullish(), + status: runStatusSchema, + phases: phasesSchema, + phaseDecisions: phaseDecisionMapSchema, +}); + +/** + * V2 config block: all fields optional. Uses `.loose()` so that extra + * fields (e.g. `pipeline`, `models`) pass through without rejection, + * ensuring forward compatibility as config options evolve. + */ +export const v2ConfigSchema = z + .object({ + externalPlan: z.boolean().optional(), + mergeBaseSha: z.string().optional(), + diffBase: z.string().optional(), + maxReviewRounds: z.number().optional(), + fixLowFindings: z.boolean().optional(), + mode: z.string().optional(), + model: z.string().optional(), + }) + .loose(); + +/** Top-level V2 run-index.json schema. */ +export const v2RunIndexSchema = z.object({ + version: z.literal(2), + context: v2ContextSchema, + config: v2ConfigSchema, + artifacts: z.array(artifactEntrySchema).optional(), +}); diff --git a/packages/factory/src/server/adapters/schemas/status-json-schema.ts b/packages/factory/src/server/adapters/schemas/status-json-schema.ts new file mode 100644 index 00000000..fef44252 --- /dev/null +++ b/packages/factory/src/server/adapters/schemas/status-json-schema.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +import { phaseDecisionMapSchema, phasesSchema, runStatusSchema } from './run-index-schema.js'; + +export { criticalitySchema, phaseStatusSchema, runStatusSchema } from './run-index-schema.js'; + +/** + * V1 status.json schema. Flat structure with `phaseDecision` (singular) + * instead of the V2 `phaseDecisions` (plural). The singular name is the + * historical V1 convention; `normalizeV1()` in status-adapter.ts maps it + * to the canonical plural form. No `mode`, `model`, or `artifacts` fields. + */ +export const v1StatusSchema = z.object({ + runId: z.string(), + projectSlug: z.string(), + ticketId: z.string().optional(), + projectRoot: z.string(), + branch: z.string(), + task: z.string(), + startedAt: z.string(), + completedAt: z.string().nullish(), + status: runStatusSchema, + externalPlan: z.boolean().optional(), + mergeBaseSha: z.string().optional(), + diffBase: z.string().optional(), + maxReviewRounds: z.number().optional(), + fixLowFindings: z.boolean().optional(), + phases: phasesSchema, + phaseDecision: phaseDecisionMapSchema, +}); diff --git a/packages/factory/src/server/adapters/status-adapter.ts b/packages/factory/src/server/adapters/status-adapter.ts index 23635b1e..794a0b64 100644 --- a/packages/factory/src/server/adapters/status-adapter.ts +++ b/packages/factory/src/server/adapters/status-adapter.ts @@ -9,10 +9,8 @@ import type { RunStatus, } from '../../shared/types/canonical.js'; import { isEnoent } from '../type-guards.js'; - -const VALID_RUN_STATUSES = new Set(['in_progress', 'completed', 'failed', 'needs_manual_review']); -const VALID_PHASE_STATUSES = new Set(['completed', 'skipped', 'failed', 'in_progress', 'approved']); -const VALID_CRITICALITIES = new Set(['none', 'low', 'medium', 'high']); +import { v2RunIndexSchema } from './schemas/run-index-schema.js'; +import { v1StatusSchema } from './schemas/status-json-schema.js'; /** Try v2 (run-index.json) first, fall back to v1 (status.json). */ export async function parseRunData(runPath: string): Promise { @@ -50,7 +48,7 @@ interface V1StatusObject { branch: string; task: string; startedAt: string; - completedAt: string | undefined; + completedAt: string | null | undefined; status: RunStatus; externalPlan: boolean | undefined; mergeBaseSha: string | undefined; @@ -62,9 +60,10 @@ interface V1StatusObject { } function normalizeV1(raw: V1StatusObject): CanonicalRunStatus { - const { phaseDecision, ...rest } = raw; + const { phaseDecision, completedAt, ...rest } = raw; return { ...rest, + completedAt: completedAt ?? undefined, mode: undefined, model: undefined, phaseDecisions: phaseDecision, @@ -142,181 +141,12 @@ function normalizeV2(raw: V2RunIndex): CanonicalRunStatus { }; } -// -- v2 validation ----------------------------------------------------------- +// -- validation via Zod schemas ---------------------------------------------- function isValidRunIndex(raw: unknown): raw is V2RunIndex { - if (!isRecord(raw)) return false; - if (raw.version !== 2) return false; - if (!isRecord(raw.context) || !isValidContext(raw.context)) return false; - if (!isRecord(raw.config) || !isValidConfig(raw.config)) return false; - if (!isValidArtifactsArray(raw.artifacts)) return false; - return true; -} - -function isValidContext(context: Record): boolean { - if (!hasValidRequiredFields(context)) return false; - if (!isOptionalString(context.ticketId)) return false; - if (!isOptionalNullableString(context.completedAt)) return false; - if (!isValidPhasesObject(context.phases)) return false; - if (!isValidPhaseDecisionMap(context.phaseDecisions)) return false; - return true; -} - -function isOptionalNullableString(value: unknown): boolean { - return value === undefined || value === null || typeof value === 'string'; -} - -function isValidConfig(config: Record): boolean { - if (!isOptionalBoolean(config.externalPlan)) return false; - if (!isOptionalString(config.mergeBaseSha)) return false; - if (!isOptionalString(config.diffBase)) return false; - if (!isOptionalNumber(config.maxReviewRounds)) return false; - if (!isOptionalBoolean(config.fixLowFindings)) return false; - if (!isOptionalString(config.mode)) return false; - if (!isOptionalString(config.model)) return false; - return true; -} - -function isValidArtifactsArray(value: unknown): boolean { - if (value === undefined) return true; - if (!Array.isArray(value)) return false; - for (const entry of value) { - if (!isValidArtifactEntry(entry)) return false; - } - return true; -} - -function isValidArtifactEntry(value: unknown): boolean { - if (!isRecord(value)) return false; - if (!hasValidArtifactRequiredFields(value)) return false; - if (!isOptionalNumber(value.iteration)) return false; - if (!isOptionalString(value.note)) return false; - return true; -} - -function hasValidArtifactRequiredFields(entry: Record): boolean { - if (typeof entry.filename !== 'string') return false; - if (typeof entry.role !== 'string') return false; - if (typeof entry.roleType !== 'string') return false; - if (typeof entry.agent !== 'string') return false; - if (typeof entry.type !== 'string') return false; - if (typeof entry.phase !== 'string') return false; - if (typeof entry.createdAt !== 'string') return false; - return true; -} - -// -- shared validation helpers ----------------------------------------------- - -function hasValidRequiredFields(raw: Record): boolean { - if (typeof raw.runId !== 'string') return false; - if (typeof raw.projectSlug !== 'string') return false; - if (typeof raw.projectRoot !== 'string') return false; - if (typeof raw.branch !== 'string') return false; - if (typeof raw.task !== 'string') return false; - if (typeof raw.startedAt !== 'string') return false; - if (typeof raw.status !== 'string' || !VALID_RUN_STATUSES.has(raw.status)) return false; - return true; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function isOptionalString(value: unknown): value is string | undefined { - return value === undefined || typeof value === 'string'; -} - -function isOptionalNumber(value: unknown): value is number | undefined { - return value === undefined || typeof value === 'number'; -} - -function isOptionalBoolean(value: unknown): value is boolean | undefined { - return value === undefined || typeof value === 'boolean'; -} - -function isValidPhaseStatus(value: unknown): boolean { - return typeof value === 'string' && VALID_PHASE_STATUSES.has(value); -} - -function isValidCriticality(value: unknown): boolean { - return typeof value === 'string' && VALID_CRITICALITIES.has(value); -} - -function isValidPhaseDecisionMap(value: unknown): boolean { - if (value === undefined) { - return true; - } - if (!isRecord(value)) { - return false; - } - for (const entry of Object.values(value)) { - if (!isRecord(entry)) return false; - if (typeof entry.run !== 'boolean') return false; - if (!isOptionalString(entry.reason)) return false; - } - return true; -} - -function isNullOrValid(value: unknown, check: (v: unknown) => boolean): boolean { - return value === null || check(value); -} - -function isValidPhaseEntry(phase: unknown): boolean { - if (!isRecord(phase)) { - return false; - } - if ('status' in phase && !isNullOrValid(phase.status, isValidPhaseStatus)) { - return false; - } - if ('criticality' in phase && !isNullOrValid(phase.criticality, isValidCriticality)) { - return false; - } - if ('finalCriticality' in phase && !isNullOrValid(phase.finalCriticality, isValidCriticality)) { - return false; - } - if ('aggregatedCriticality' in phase && !isNullOrValid(phase.aggregatedCriticality, isValidCriticality)) { - return false; - } - return true; -} - -function isValidPhasesObject(value: unknown): boolean { - if (!isRecord(value)) { - return false; - } - for (const phase of Object.values(value)) { - if (phase === undefined || phase === null) { - continue; - } - if (!isValidPhaseEntry(phase)) { - return false; - } - } - return true; -} - -// -- v1 validation ----------------------------------------------------------- - -function hasValidOptionalFields(raw: Record): boolean { - if (!isOptionalString(raw.ticketId)) return false; - if (!isOptionalString(raw.completedAt)) return false; - if (!isOptionalBoolean(raw.externalPlan)) return false; - if (!isOptionalString(raw.mergeBaseSha)) return false; - if (!isOptionalString(raw.diffBase)) return false; - if (!isOptionalNumber(raw.maxReviewRounds)) return false; - if (!isOptionalBoolean(raw.fixLowFindings)) return false; - return true; + return v2RunIndexSchema.safeParse(raw).success; } function isValidStatusObject(raw: unknown): raw is V1StatusObject { - if (!isRecord(raw)) { - return false; - } - - if (!hasValidRequiredFields(raw)) return false; - if (!hasValidOptionalFields(raw)) return false; - if (!isValidPhasesObject(raw.phases)) return false; - if (!isValidPhaseDecisionMap(raw.phaseDecision)) return false; - - return true; + return v1StatusSchema.safeParse(raw).success; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8e9e91b..683d719c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + zod: + specifier: 4.3.6 + version: 4.3.6 devDependencies: '@testing-library/dom': specifier: 10.4.1 @@ -3260,6 +3263,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: '@acemir/cssom@0.9.31': {} @@ -6728,3 +6734,5 @@ snapshots: yaml@2.8.0: {} yocto-queue@0.1.0: {} + + zod@4.3.6: {}