import { SystemDocumentError } from "./project-root.ts"; export const SCHEMA_VERSION = 0; export const DOCUMENT_TYPES = [ "spec", "phase", "plan", "issue", "research", ] as const; export type DocumentType = (typeof DOCUMENT_TYPES)[number]; export type SystemConfig = Readonly<{ prefix: string; schemaVersion: number; title?: string; currentPhase?: string; }>; export type InitializationSystemConfig = Readonly<{ config: SystemConfig; sourceSchemaVersion?: number; }>; export type ParsedDocumentId = Readonly<{ id: string; prefix: string; type: DocumentType; token: string; }>; const PREFIX = /^[A-Z][A-Z0-9]{1,7}$/; const ID_CHARS = "[A-Z0-9_-]{8}"; const TYPE_BY_TOKEN = new Map( DOCUMENT_TYPES.map((type) => [type.toUpperCase(), type]), ); const objectValue = ( value: unknown, label: string, ): Record => { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new SystemDocumentError( "json.shape", `${label} must be a JSON object`, ); return value as Record; }; const duplicateTopLevelKeys = (source: string): string[] => { const keys: string[] = []; const duplicates = new Set(); let depth = 0; let index = 0; while (index < source.length) { const character = source[index]; if (character === "{") { depth += 1; index += 1; continue; } if (character === "}") { depth -= 1; index += 1; continue; } if (character !== '"') { index += 1; continue; } const start = index++; let escaped = false; while (index < source.length) { const current = source[index++]; if (!escaped && current === '"') break; escaped = !escaped && current === "\\"; if (current !== "\\") escaped = false; } if (depth !== 1) continue; let cursor = index; while (/\s/.test(source[cursor] ?? "")) cursor += 1; if (source[cursor] !== ":") continue; const key = JSON.parse(source.slice(start, index)) as string; if (keys.includes(key)) duplicates.add(key); keys.push(key); } return [...duplicates].sort(); }; export function parseSystemConfig(source: string): SystemConfig { let parsed: unknown; try { parsed = JSON.parse(source); } catch { throw new SystemDocumentError("config.invalid-json", "Invalid config JSON"); } const duplicates = duplicateTopLevelKeys(source); if (duplicates.length) throw new SystemDocumentError( "config.duplicate-key", `Duplicate config keys: ${duplicates.join(", ")}`, ); const value = objectValue(parsed, "Config"); if (value.schemaVersion !== SCHEMA_VERSION) throw new SystemDocumentError( "config.version", `Schema version ${String(value.schemaVersion)} does not match ${SCHEMA_VERSION}; run /system init`, ); const keys = Object.keys(value).sort(); const validKeys = ["currentPhase", "prefix", "schemaVersion", "title"]; if ( !("prefix" in value) || !("schemaVersion" in value) || keys.some((key) => !validKeys.includes(key)) ) throw new SystemDocumentError( "json.keys", `Expected keys prefix, schemaVersion[, title, currentPhase]; received ${keys.join(", ")}`, ); if (typeof value.prefix !== "string" || !PREFIX.test(value.prefix)) throw new SystemDocumentError("config.prefix", "Invalid project prefix"); if ( "title" in value && (typeof value.title !== "string" || value.title.trim() === "") ) throw new SystemDocumentError("config.title", "Invalid project title"); if ("currentPhase" in value) { if (typeof value.currentPhase !== "string") throw new SystemDocumentError( "config.current-phase", "Current phase must be a phase ID", ); const current = parseDocumentId(value.currentPhase, value.prefix); if (current.type !== "phase") throw new SystemDocumentError( "config.current-phase", "Current phase must be a phase ID", ); } return { prefix: value.prefix, schemaVersion: value.schemaVersion as number, ...(typeof value.title === "string" ? { title: value.title } : {}), ...(typeof value.currentPhase === "string" ? { currentPhase: value.currentPhase } : {}), }; } export function systemConfigForInitialization( source: string, ): InitializationSystemConfig { try { return { config: parseSystemConfig(source) }; } catch (error) { if ( !(error instanceof SystemDocumentError) || error.code !== "config.version" ) throw error; const parsed = objectValue(JSON.parse(source), "Config"); if ( duplicateTopLevelKeys(source).length > 0 || typeof parsed.schemaVersion !== "number" || !Number.isInteger(parsed.schemaVersion) ) throw error; const config = parseSystemConfig( JSON.stringify({ prefix: parsed.prefix, schemaVersion: SCHEMA_VERSION, ...("title" in parsed ? { title: parsed.title } : {}), ...("currentPhase" in parsed ? { currentPhase: parsed.currentPhase } : {}), }), ); return { config, sourceSchemaVersion: parsed.schemaVersion }; } } export function parseDocumentId( source: string, expectedPrefix?: string, ): ParsedDocumentId { const match = new RegExp( `^([A-Z][A-Z0-9]{1,7})-(SPEC|PHASE|PLAN|ISSUE|RESEARCH)-(${ID_CHARS})$`, ).exec(source); if (!match) throw new SystemDocumentError( "id.invalid", `Invalid document ID: ${source}`, ); if (expectedPrefix && match[1] !== expectedPrefix) throw new SystemDocumentError( "id.prefix", `Document ID must use prefix ${expectedPrefix}: ${source}`, ); return { id: source, prefix: match[1] as string, type: TYPE_BY_TOKEN.get(match[2]) as DocumentType, token: match[3] as string, }; }