import fs from "node:fs/promises"; import path from "node:path"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { findUp } from "./paths"; import { askProjectConfigTrust, sha256 } from "./trust"; import type { CodeQualityConfigFile, CodeQualityToolConfig, ConfigLayer, LoadedConfig, MatchableConfig } from "./types"; function defaultAgentDir(): string { return process.env.PI_AGENT_DIR ?? path.join(process.env.HOME ?? "", ".pi", "agent"); } function asObject(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; } function cleanStringArray(value: unknown): string[] | undefined { if (!Array.isArray(value)) return undefined; return value.filter((item): item is string => typeof item === "string"); } function cleanStringRecord(value: unknown): Record | undefined { const object = asObject(value); if (!object) return undefined; const out: Record = {}; for (const [key, recordValue] of Object.entries(object)) { if (typeof recordValue === "string") out[key] = recordValue; } return out; } function cleanNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function cleanBoolean(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } function cleanCommand(value: unknown) { const object = asObject(value); if (!object || typeof object.bin !== "string" || object.bin.trim() === "") return undefined; return { bin: object.bin, args: cleanStringArray(object.args), cwd: typeof object.cwd === "string" ? object.cwd : undefined, env: cleanStringRecord(object.env), config: typeof object.config === "string" ? object.config : undefined, timeoutMs: cleanNumber(object.timeoutMs), diagnosticExitCodes: Array.isArray(object.diagnosticExitCodes) ? object.diagnosticExitCodes.filter((item): item is number => typeof item === "number" && Number.isInteger(item)) : undefined, }; } function cleanMatchable(object: Record, extra: Omit): T | undefined { if (typeof object.id !== "string" || object.id.trim() === "") return undefined; return { id: object.id, enabled: cleanBoolean(object.enabled), include: cleanStringArray(object.include), exclude: cleanStringArray(object.exclude), rootMarkers: cleanStringArray(object.rootMarkers), maxFileSizeBytes: cleanNumber(object.maxFileSizeBytes), ...extra, } as T; } function parseCodeQualityItems(parsed: unknown): CodeQualityToolConfig[] { const root = asObject(parsed) as CodeQualityConfigFile | undefined; const tools = Array.isArray(root?.tools) ? root.tools : []; const out: CodeQualityToolConfig[] = []; for (const item of tools) { const object = asObject(item); if (!object) continue; const cleaned = cleanMatchable(object, { formatter: cleanCommand(object.formatter), linter: cleanCommand(object.linter), }); if (cleaned) out.push(cleaned); } return out; } async function readJsonLayer(options: { scope: "global" | "project"; filePath: string; parseItems: (parsed: unknown) => TItem[]; }): Promise | undefined> { let raw: string; try { raw = await fs.readFile(options.filePath, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } const parsed = JSON.parse(raw) as unknown; return { scope: options.scope, path: options.filePath, dir: path.dirname(options.filePath), raw, hash: sha256(raw), items: options.parseItems(parsed), }; } function mergeLayers(layers: ConfigLayer[]): TItem[] { const byId = new Map(); for (const layer of layers) { for (const item of layer.items) { if (item.enabled === false) { byId.delete(item.id); continue; } byId.set(item.id, item); } } return [...byId.values()]; } function binariesForCodeQuality(items: CodeQualityToolConfig[]): string[] { return items.flatMap((item) => [item.formatter?.bin, item.linter?.bin].filter((bin): bin is string => !!bin)); } export async function loadCodeQualityConfig(ctx: ExtensionContext): Promise> { const warnings: string[] = []; const layers: ConfigLayer[] = []; const globalPath = path.join(defaultAgentDir(), "code-quality.json"); try { const globalLayer = await readJsonLayer({ scope: "global", filePath: globalPath, parseItems: parseCodeQualityItems }); if (globalLayer) layers.push(globalLayer); } catch (error) { warnings.push(`Failed to load global code-quality config: ${(error as Error).message}`); } const projectPath = findUp(ctx.cwd, path.join(".pi", "code-quality.json")); if (projectPath) { try { const projectLayer = await readJsonLayer({ scope: "project", filePath: projectPath, parseItems: parseCodeQualityItems }); if (projectLayer) { const decision = await askProjectConfigTrust({ ctx, kind: "code-quality", configPath: projectLayer.path, hash: projectLayer.hash, binaries: binariesForCodeQuality(projectLayer.items), }); if (decision.trusted) layers.push(projectLayer); else warnings.push(`${projectLayer.path}: ${decision.reason ?? "project-local config rejected"}`); } } catch (error) { warnings.push(`Failed to load project code-quality config: ${(error as Error).message}`); } } return { items: mergeLayers(layers), layers, warnings }; }