Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/the-system/doctor.ts

Raw
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<string>();
	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<string>();
	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,
	};
}