repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/the-system/doctor.ts
Rawimport { 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,
};
}