import { lstatSync, readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { BOARD_PATH, boardStateFor, readBoardState } from "./board-state.ts"; import { type Diagnostic, discoverDocumentBundles, indexBundleDocuments, parseSystemConfig, readCurrentPhase, requireProjectRoot, type SystemConfig, type SystemDocumentError, } from "./documents.ts"; export type TestReference = Readonly<{ path: string; spec: string }>; export type DoctorResult = Readonly<{ root: string; diagnostics: readonly Diagnostic[]; references: readonly TestReference[]; exitCode: 0 | 1; }>; const SYSTEM_DIRECTORIES = [".system"] as const; const SYSTEM_FILES = [ ".system/config.json", BOARD_PATH, ".system/SYSTEM.md", ".system/MISSION.md", ".system/RULES.md", ] as const; const TEST_DIRECTORIES = ["test", "tests", "__tests__"] as const; const TEST_REFERENCE = /SYSTEM-SPEC:\s*([A-Z][A-Z0-9]{1,7}-SPEC-[A-Z0-9_-]{8})/g; const diagnostic = ( code: string, diagnosticPath: string, message: string, ): Diagnostic => ({ code, path: diagnosticPath, message }); const compareText = (left: string, right: string) => left < right ? -1 : left > right ? 1 : 0; const compareDiagnostics = (left: Diagnostic, right: Diagnostic) => compareText(left.path, right.path) || compareText(left.code, right.code) || compareText(left.message, right.message); const relativePath = (root: string, target: string) => path.relative(root, target).split(path.sep).join("/"); const isDirectory = (target: string) => lstatSync(target, { throwIfNoEntry: false })?.isDirectory() === true; const isFile = (target: string) => lstatSync(target, { throwIfNoEntry: false })?.isFile() === true; const collectTestReferences = (root: string): TestReference[] => { const references: TestReference[] = []; const visited = new Set(); const scan = (directory: string) => { const resolved = path.resolve(directory); if (visited.has(resolved) || !isDirectory(resolved)) return; visited.add(resolved); for (const entry of readdirSync(resolved, { withFileTypes: true }).sort( (left, right) => left.name.localeCompare(right.name), )) { if ([".git", ".jj", ".system", "node_modules"].includes(entry.name)) continue; const target = path.join(resolved, entry.name); if (entry.isDirectory()) { scan(target); continue; } if (!entry.isFile()) continue; for (const match of readFileSync(target, "utf8").matchAll( TEST_REFERENCE, )) { const spec = match[1]; if (spec) references.push({ path: relativePath(root, target), spec }); } } }; for (const directory of TEST_DIRECTORIES) scan(path.join(root, directory)); return references.sort( (left, right) => left.path.localeCompare(right.path) || left.spec.localeCompare(right.spec), ); }; export function runDoctor(cwd: string): DoctorResult { let root: string; try { root = requireProjectRoot(cwd); } catch (error) { const value = error as SystemDocumentError; return { root: path.resolve(cwd), diagnostics: [diagnostic(value.code, path.resolve(cwd), value.message)], references: [], exitCode: 1, }; } const diagnostics: Diagnostic[] = []; for (const relative of SYSTEM_DIRECTORIES) if (!isDirectory(path.join(root, relative))) diagnostics.push( diagnostic( "filesystem.missing", relative, `Missing directory ${relative}`, ), ); for (const relative of SYSTEM_FILES) if (!isFile(path.join(root, relative))) diagnostics.push( diagnostic("filesystem.missing", relative, `Missing file ${relative}`), ); let config: SystemConfig | undefined; const configPath = path.join(root, ".system/config.json"); if (isFile(configPath)) { try { config = parseSystemConfig(readFileSync(configPath, "utf8")); } catch (error) { const value = error as SystemDocumentError; diagnostics.push( diagnostic(value.code, ".system/config.json", value.message), ); } } const discovery = discoverDocumentBundles(root); diagnostics.push(...discovery.diagnostics); let specIds = new Set(); if (config) { const index = indexBundleDocuments(discovery.sources, config.prefix); diagnostics.push(...index.diagnostics); if (isFile(path.join(root, BOARD_PATH))) { try { const board = readBoardState(root, config.prefix); const documentIds = new Set( index.documents.map((document) => document.metadata.id), ); for (const id of Object.keys(board.items)) if (!documentIds.has(id)) diagnostics.push( diagnostic( "board.dangling-item", BOARD_PATH, `Board references missing ${id}`, ), ); for (const spec of index.documents) { if ( spec.metadata.type !== "spec" || boardStateFor(board, spec.metadata) !== "done" ) continue; const unfinished = index.documents.filter( (document) => document.metadata.type === "plan" && document.metadata.spec === spec.metadata.id && boardStateFor(board, document.metadata) !== "done", ); if (unfinished.length) diagnostics.push( diagnostic( "board.spec-plans", BOARD_PATH, `Spec ${spec.metadata.id} is done while plans remain todo: ${unfinished.map((document) => document.metadata.id).join(", ")}`, ), ); } } catch (error) { const value = error as SystemDocumentError; diagnostics.push(diagnostic(value.code, BOARD_PATH, value.message)); } } try { readCurrentPhase(root, config); } catch (error) { const value = error as SystemDocumentError; diagnostics.push( diagnostic(value.code, ".system/config.json", value.message), ); } specIds = new Set( index.documents .filter((document) => document.metadata.type === "spec") .map((document) => document.metadata.id), ); } const references = collectTestReferences(root); for (const reference of references) if (config && !specIds.has(reference.spec)) diagnostics.push( diagnostic( "test.dangling-reference", reference.path, `Test references missing ${reference.spec}`, ), ); diagnostics.sort(compareDiagnostics); return { root, diagnostics, references, exitCode: diagnostics.length === 0 ? 0 : 1, }; }