Andrew Ridgway bec1eaac87
Some checks failed
Test / test (push) Has been cancelled
first pass at the newspaper builder
2026-09-14 11:57:22 +10:00

477 lines
16 KiB
TypeScript

#!/usr/bin/env bun
import { existsSync, mkdirSync, realpathSync } from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import {
errorMessage,
harnessDir,
isoTimestamp,
parseArgs,
resolveProjectDir,
} from "./aidlc-lib.ts";
import {
adaptLegacyResult,
buildBundle,
mergeFindings,
runDoctorAnalysis,
type DoctorAnalysis,
} from "./aidlc-doctor-bundle.ts";
import {
collectDoctorReport,
type DoctorCheck,
type DoctorReport,
} from "./aidlc-utility.ts";
import {
cachedUpdateState,
refreshUpdateState,
type UpdateState,
} from "./aidlc-update.ts";
import { collectPluginStatus } from "./aidlc-plugin.ts";
import { scanWindowsUninstallJournals } from "./aidlc-windows-uninstall.ts";
import {
aidlcInvocation,
discoverProjectHarnesses,
} from "./aidlc-runtime-paths.ts";
import {
configureColor,
dim,
failVerdict,
fixLabel,
heading,
okVerdict,
success,
warnVerdict,
} from "./aidlc-color.ts";
import {
isModelHarness,
modelPolicyDoctorIssues,
} from "./aidlc-model-policy.ts";
import {
flagsDoctorCheck,
providerDoctorCheck,
settingsDoctorChecks,
workspaceSiblingDoctorCheck,
} from "./aidlc-config-diagnostics.ts";
import {
modelPolicyForHarness,
resolveAidlcSettings,
} from "./aidlc-settings.ts";
function windowsRecoveryCheck(): DoctorCheck | null {
if (process.platform !== "win32") return null;
const recovery = scanWindowsUninstallJournals();
const paths = [
...recovery.pending.map((item) => item.path),
...recovery.invalid,
];
return {
pass: paths.length === 0,
label: paths.length === 0
? "Windows uninstall recovery: no pending continuations"
: `Windows uninstall recovery: ${recovery.pending.length} pending and ${recovery.invalid.length} invalid continuation(s): ${paths.join(", ")}`,
fix: paths.length === 0
? undefined
: "finish active AI-DLC commands, then run `aidlc version` to resume cleanup",
};
}
export async function doctorUpdateState(
flags: Record<string, string>,
interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
): Promise<UpdateState> {
const explicit = flags["check-updates"] === "true";
const mayRefresh = interactive &&
flags.json !== "true" &&
flags.quiet !== "true";
let update = cachedUpdateState();
if (
explicit ||
(mayRefresh &&
(update.stale === true ||
["stale", "absent", "unavailable"].includes(update.state)))
) {
update = await refreshUpdateState(explicit ? 15_000 : 750, {
offline: flags.offline === "true" ? true : undefined,
baseUrl: flags["release-base-url"],
caBundle: flags["ca-bundle"],
});
}
return update;
}
function updateCheck(state: UpdateState): DoctorCheck {
const invoke = aidlcInvocation();
return {
pass: state.state === "current",
severity: state.state === "current" || state.state === "invalid-config"
? undefined
: "warn",
label: `Update: ${state.message}`,
fix: state.state === "behind"
? `run \`${invoke} update\``
: state.state === "invalid-config"
? `run \`${invoke} config list --global\` and correct the invalid update setting`
: state.state === "current"
? undefined
: `run \`${invoke} update --check\``,
};
}
function pluginCheck(projectDir: string, verbose: boolean): DoctorCheck {
const { statuses } = collectPluginStatus(projectDir);
const attention = statuses.filter((status) => status.action === "attention");
const drift = statuses.filter((status) => status.action === "sync");
const detail = verbose && statuses.length > 0
? ` - ${statuses.map((status) => `${status.key ?? "host"}:${status.state}`).join(", ")}`
: "";
if (attention.length > 0) {
return {
pass: false,
severity: "warn",
label: `Plugins: ${attention.length} need attention${detail}`,
fix: attention.map((status) => status.message).join("; "),
};
}
if (drift.length > 0) {
return {
pass: false,
severity: "warn",
label: `Plugins: ${drift.length} require sync${detail}`,
fix: "run `aidlc config`",
};
}
return {
pass: true,
label: statuses.length === 0
? "Plugins: no AIDLC plugins installed"
: `Plugins: composed state is current${detail}`,
};
}
export function modelsPolicyCheck(projectDir: string, verbose: boolean): DoctorCheck {
const harnesses = discoverProjectHarnesses(projectDir);
if (harnesses.length === 0) {
return {
pass: true,
label: "Models: no installed project harness",
};
}
const resolved = resolveAidlcSettings(projectDir);
const issues: string[] = [];
for (const harness of harnesses) {
if (!isModelHarness(harness.distribution)) {
issues.push(`unsupported harness policy surface: ${harness.distribution}`);
continue;
}
try {
issues.push(
...modelPolicyDoctorIssues(
harness.root,
harness.distribution,
modelPolicyForHarness(resolved.models, harness.distribution),
)
.map((issue) => `${harness.distribution}: ${issue}`),
);
} catch (error) {
issues.push(
`${harness.distribution}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
const detail = verbose && issues.length > 0 ? ` - ${issues.join("; ")}` : "";
if (issues.length > 0) {
return {
pass: false,
severity: "warn",
label: `Models: ${issues.length} policy issue(s)${detail}`,
fix: issues.join("; "),
};
}
return {
pass: true,
label: "Models: recorded policy is expressible",
};
}
function humanReport(
report: DoctorReport,
analysis: DoctorAnalysis,
projectDir: string,
verbose: boolean,
): string {
const harness = discoverProjectHarnesses(projectDir)[0];
const productNames: Record<string, string> = {
claude: "Claude Code",
codex: "Codex CLI",
copilot: "GitHub Copilot",
cursor: "Cursor",
kiro: "Kiro CLI",
"kiro-ide": "Kiro IDE",
opencode: "opencode",
};
const frameworkPattern =
/^(?:Agent filename|Scope filename|Cycle detection|Orphan stage|Uncompiled stage|Enabled stage compile coverage|Scope validation|Schema validation|Graph references|Keyword overlap|Rule drift|Paired sensor coverage|Stage graph|Scope grid|Sensor |Required sections|Upstream coverage|Traceability|Linter|Type check)/i;
const machinePattern =
/^(?:Update:|Windows uninstall|Runtime hook PATH|Harness CLI|Installed runtime|Command pointer|Rollback target|Project pin registry|Transaction staging|Transaction recovery|Settings global)/i;
const machine = report.checks.filter((check) => machinePattern.test(check.label));
const framework = report.checks.filter((check) => frameworkPattern.test(check.label));
const project = report.checks.filter((check) =>
!machine.includes(check) && !framework.includes(check)
);
const status = (check: DoctorCheck): "ok" | "warn" | "fail" =>
check.severity === "warn" ? "warn" : check.pass ? "ok" : "fail";
const out = process.stdout;
const colorVerdict = (verdict: "ok" | "warn" | "fail"): string => {
const padded = verdict.padEnd(5);
return verdict === "warn"
? warnVerdict(padded, out)
: verdict === "fail"
? failVerdict(padded, out)
: okVerdict(padded, out);
};
const invoke = aidlcInvocation();
const fallbackFix =
`run \`${invoke} doctor --verbose\`, correct the named condition, then rerun \`${invoke} doctor\``;
const renderCheck = (check: DoctorCheck): string => {
const verdict = status(check);
let row = ` ${colorVerdict(verdict)} ${check.label}\n`;
if (verdict !== "ok") {
row += ` ${fixLabel("fix:", out)} ${check.fix ?? fallbackFix}\n`;
}
return row;
};
const findings = analysis.findings.filter((finding) => finding.severity !== "info");
const findingRows = findings.map((finding) =>
` ${warnVerdict("warn ", out)} [${finding.id}] ${finding.summary}\n` +
` ${fixLabel("fix:", out)} ${finding.remedy ?? fallbackFix}\n`
);
const renderSection = (
checks: readonly DoctorCheck[],
extraAttentionRows: readonly string[] = [],
): string => {
if (verbose) {
return `${checks.map(renderCheck).join("")}${extraAttentionRows.join("")}`;
}
const passing = checks.filter((check) => status(check) === "ok");
const attention = checks.filter((check) => status(check) !== "ok");
let rows = attention.map(renderCheck).join("");
rows += extraAttentionRows.join("");
if (passing.length > 0) {
const allClean = attention.length === 0 && extraAttentionRows.length === 0;
rows += ` ${okVerdict("ok ", out)} ${allClean ? "all " : ""}${passing.length} checks passed\n`;
}
return rows;
};
let output = `${heading("AI-DLC doctor", out)}\n\n`;
output += `${heading("Machine", out)}\n`;
output += renderSection(machine);
output += `\n${heading(`Project${
harness
? ` (${harness.harnessDir}, ${productNames[harness.distribution] ?? harness.distribution})`
: ""
}`, out)}\n`;
output += renderSection(project, findingRows);
output += `\n${heading("Framework integrity", out)}\n`;
output += renderSection(framework);
const visibleWarnings = report.warnings + findings.length;
const problems = `${report.failed} problem${report.failed === 1 ? "" : "s"}`;
const warnings = `${visibleWarnings} warning${visibleWarnings === 1 ? "" : "s"}`;
output += `\n${
report.failed > 0 ? failVerdict(problems, out) : problems
}, ${visibleWarnings > 0 ? warnVerdict(warnings, out) : warnings}.\n`;
if (visibleWarnings > 0) {
output += "Warnings are advisory - if everything works, ignore them.\n";
}
if (report.failed === 0 && visibleWarnings === 0) {
output += `${success("Your install is ready.", out)}\n`;
}
if (!verbose) {
output += `${dim(
`Run '${aidlcInvocation()} doctor --verbose' to see every check.`,
out,
)}\n`;
}
return output;
}
// A filesystem-safe UTC timestamp token (isoTimestamp has colons that some
// filesystems reject in names): 2026-07-14T15:26:31Z \u2192 20260714T152631Z.
function fsSafeTimestamp(): string {
return isoTimestamp().replace(/[-:]/g, "").replace(/\.\d+/, "");
}
function canonicalFuturePath(path: string): string {
let cursor = resolve(path);
const suffix: string[] = [];
while (!existsSync(cursor)) {
const parent = dirname(cursor);
if (parent === cursor) break;
suffix.unshift(basename(cursor));
cursor = parent;
}
const base = existsSync(cursor) ? realpathSync(cursor) : cursor;
return suffix.reduce((current, entry) => join(current, entry), base);
}
function withinRoot(path: string, root: string): boolean {
const rel = relative(root, path);
return rel === "" ||
(!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
}
function resolveExportParent(
projectDir: string,
flags: Record<string, string>,
): string {
if (flags.output === "true") {
throw new Error("--output requires a directory path (e.g. --output ./aidlc-report)");
}
const projectRoot = canonicalFuturePath(projectDir);
const output = canonicalFuturePath(
flags.output
? flags.output
: join(projectDir, "aidlc", "diagnostics"),
);
if (!withinRoot(output, projectRoot)) {
throw new Error("--output must stay inside the selected project directory");
}
return output;
}
function emitDoctorUsage(flags: Record<string, string>, message: string): void {
if (flags.json === "true") {
process.stdout.write(`${JSON.stringify({
schemaVersion: 1,
ok: false,
code: 2,
status: "usage",
message,
})}\n`);
} else {
process.stdout.write(`${message}\n`);
}
process.exitCode = 2;
}
// --export: after the live report, write a redacted diagnostic report from
// the SAME analysis this run already computed (issue #575). No second read,
// no cached diagnosis. The export write never changes doctor's exit code.
// `--export` is a bare boolean flag; accept it whether the arg parser recorded
// it as "true" (bare) or a stray token followed it, so a trailing word can
// never silently disable the export.
function writeExport(
outParent: string,
report: DoctorReport,
analysis: DoctorAnalysis,
): void {
try {
const tsToken = fsSafeTimestamp();
mkdirSync(outParent, { recursive: true });
// Merge the legacy environment/config checks (bun present, hooks wired,
// settings intact) into the exported analysis so report.md/report.json
// carry the SAME findings the live report shows \u2014 the bundle exists so
// the maintainer does NOT need the user's project, so a failing env check
// must reach it. The live render and the exit code are untouched; this
// only enriches what buildBundle serializes. (Arden round-3 #1.)
const analysisForExport = {
...analysis,
findings: mergeFindings(report.checks.map(adaptLegacyResult), analysis.findings),
};
const exported = buildBundle(outParent, analysisForExport, tsToken);
let out = "\nDiagnostic report created:\n";
out += ` ${exported.archivePath ?? exported.bundleDir}\n\n`;
out += "Findings:\n";
const topFindings = exported.findings.filter((f) => f.severity !== "info").slice(0, 20);
if (topFindings.length === 0) {
out += " (no errors or warnings)\n";
} else {
for (const f of topFindings) out += ` ${f.severity.toUpperCase()} ${f.id}\n`;
}
out += "\nNo source files or artifact bodies were included.\n";
if (exported.manualShareNote) out += `\n${exported.manualShareNote}\n`;
process.stdout.write(out);
} catch (e) {
// Export failure must not mask the live doctor result; report and go on.
process.stdout.write(`\nDiagnostic report could not be created: ${errorMessage(e)}\n`);
}
}
export async function main(argv: string[]): Promise<void> {
configureColor(argv);
const { flags } = parseArgs(argv);
const projectDir = resolveProjectDir(flags["project-dir"]);
const exporting = "export" in flags;
if (flags.output !== undefined && !exporting) {
emitDoctorUsage(flags, "--output requires --export");
return;
}
if (
exporting &&
(flags.json === "true" || flags.quiet === "true")
) {
emitDoctorUsage(flags, "--export cannot be combined with --json or --quiet");
return;
}
let exportParent: string | undefined;
if (exporting) {
try {
exportParent = resolveExportParent(projectDir, flags);
} catch (error) {
emitDoctorUsage(flags, errorMessage(error));
return;
}
}
const update = await doctorUpdateState(flags);
const checks: DoctorCheck[] = [];
const recovery = windowsRecoveryCheck();
if (recovery) checks.push(recovery);
checks.push(updateCheck(update));
checks.push(pluginCheck(projectDir, flags.verbose === "true"));
checks.push(...settingsDoctorChecks(projectDir));
checks.push(modelsPolicyCheck(projectDir, flags.verbose === "true"));
checks.push(flagsDoctorCheck(projectDir, harnessDir()));
checks.push(providerDoctorCheck(projectDir, harnessDir()));
checks.push(workspaceSiblingDoctorCheck(projectDir, harnessDir()));
const report = await collectDoctorReport(projectDir, checks);
// One fresh analysis, shared by the live report AND the --export writer
// (issue #575): the structured condition->remedy findings and the
// reconstructed timeline are computed ONCE here, so the live output and the
// export can never diverge. The analysis performs no writes.
const analysis = runDoctorAnalysis(projectDir);
const code = update.state === "invalid-config"
? 2
: report.failed > 0
? 1
: 0;
if (flags.json === "true") {
process.stdout.write(`${JSON.stringify({
schemaVersion: 1,
ok: code === 0,
code,
status: report.failed > 0
? "failed"
: report.warnings > 0
? "warning"
: "ok",
message: `${report.passed} passed, ${report.warnings} warnings, ${report.failed} failed`,
data: report,
})}\n`);
} else if (flags.quiet === "true") {
process.stdout.write(
`${report.passed} passed, ${report.warnings} warnings, ${report.failed} failed\n`,
);
} else {
process.stdout.write(
humanReport(report, analysis, projectDir, flags.verbose === "true"),
);
}
if (exportParent) writeExport(exportParent, report, analysis);
process.exitCode = code;
}
if (import.meta.main) {
void main(process.argv.slice(2));
}