Files
pi-dev-tools/src/runtime.ts
2026-04-10 23:11:54 +01:00

135 lines
4.8 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { loadDevToolsConfig } from "./config.ts";
import type { CapabilityGap, DiagnosticsState } from "./diagnostics/types.ts";
import { probeProject } from "./project-probe.ts";
import { resolveProfileForPath } from "./profiles.ts";
import { buildPromptBlock } from "./summary.ts";
export interface FormatResult {
status: "formatted" | "skipped" | "failed";
message?: string;
}
export interface DevToolsRuntime {
formatAfterMutation(absolutePath: string): Promise<FormatResult>;
noteMutation(absolutePath: string, formatResult: FormatResult): void;
setDiagnostics(path: string, state: DiagnosticsState): void;
recordCapabilityGap(path: string, message: string): void;
getPromptBlock(): string | undefined;
refreshDiagnosticsForPath(absolutePath: string, text?: string): Promise<void>;
}
type LoadedConfig = ReturnType<typeof loadDevToolsConfig>;
export function createDevToolsRuntime(deps: {
cwd: string;
agentDir: string;
loadConfig?: () => LoadedConfig;
knownPaths?: string[];
formatterRunner: { formatFile: (input: any) => Promise<FormatResult> };
lspBackend: { collectForFile: (input: any) => Promise<DiagnosticsState> };
commandBackend: { collect: (input: any) => Promise<DiagnosticsState> };
probeProject?: typeof probeProject;
}): DevToolsRuntime {
const diagnosticsByFile = new Map<string, DiagnosticsState>();
const capabilityGaps: CapabilityGap[] = [];
const getConfig = () => deps.loadConfig?.() ?? loadDevToolsConfig(deps.cwd, deps.agentDir);
function setDiagnostics(path: string, state: DiagnosticsState) {
diagnosticsByFile.set(path, state);
}
function recordCapabilityGap(path: string, message: string) {
if (!capabilityGaps.some((gap) => gap.path === path && gap.message === message)) {
capabilityGaps.push({ path, message });
}
}
function getPromptBlock() {
const config = getConfig();
const maxDiagnosticsPerFile = config?.defaults?.maxDiagnosticsPerFile ?? 10;
if (diagnosticsByFile.size === 0 && capabilityGaps.length === 0) return undefined;
return buildPromptBlock({
maxDiagnosticsPerFile,
diagnosticsByFile,
capabilityGaps,
});
}
async function refreshDiagnosticsForPath(absolutePath: string, text?: string) {
const config = getConfig();
if (!config) {
recordCapabilityGap(absolutePath, "No dev-tools config found.");
return;
}
const match = resolveProfileForPath(config, absolutePath, deps.cwd, deps.knownPaths ?? []);
if (!match) {
const probe = await (deps.probeProject ?? probeProject)({ cwd: deps.cwd });
recordCapabilityGap(absolutePath, `No profile matched. ${probe.summary}`);
return;
}
const fileText = text ?? await readFile(absolutePath, "utf8");
for (const backend of match.profile.diagnostics) {
if (backend.kind === "lsp") {
const lspResult = await deps.lspBackend.collectForFile({
key: `${match.profile.languageId ?? "plain"}:${match.workspaceRoot}`,
absolutePath,
workspaceRoot: match.workspaceRoot,
languageId: match.profile.languageId ?? "plaintext",
text: fileText,
command: backend.command,
});
if (lspResult.status === "ok") {
setDiagnostics(absolutePath, lspResult);
if (lspResult.message) recordCapabilityGap(absolutePath, lspResult.message);
return;
}
recordCapabilityGap(absolutePath, lspResult.message ?? "LSP diagnostics unavailable.");
continue;
}
const commandResult = await deps.commandBackend.collect({
absolutePath,
workspaceRoot: match.workspaceRoot,
backend,
timeoutMs: config.defaults?.diagnosticTimeoutMs,
});
setDiagnostics(absolutePath, commandResult);
return;
}
recordCapabilityGap(absolutePath, "No diagnostics backend succeeded.");
}
return {
async formatAfterMutation(absolutePath: string) {
const config = getConfig();
if (!config) return { status: "skipped" as const };
const match = resolveProfileForPath(config, absolutePath, deps.cwd, deps.knownPaths ?? []);
if (!match?.profile.formatter) return { status: "skipped" as const };
return deps.formatterRunner.formatFile({
absolutePath,
workspaceRoot: match.workspaceRoot,
formatter: match.profile.formatter,
timeoutMs: config.defaults?.formatTimeoutMs,
});
},
noteMutation(absolutePath: string, formatResult: FormatResult) {
if (formatResult.status === "failed") {
recordCapabilityGap(absolutePath, formatResult.message ?? "Formatter failed.");
}
void refreshDiagnosticsForPath(absolutePath);
},
setDiagnostics,
recordCapabilityGap,
getPromptBlock,
refreshDiagnosticsForPath,
};
}