135 lines
4.8 KiB
TypeScript
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,
|
|
};
|
|
}
|