import { existsSync, readFileSync, readdirSync } from "fs"; import { execSync } from "child_process"; import { join, resolve, dirname } from "path"; import { fileURLToPath } from "url"; import { checkVersionCompatibility } from "../../lexicon-manifest"; import { debug } from "../debug"; import { loadPlugins, resolveProjectLexicons } from "../plugins"; import { loadCapabilityPlugin } from "../../components/capability-plugin-loader"; import { isCapabilityPlugin } from "../../components/capability-plugin"; export interface DoctorCheck { name: string; status: "pass" | "fail" | "warn"; message?: string; } export interface DoctorReport { checks: DoctorCheck[]; success: boolean; } export async function doctorCommand(path: string): Promise { const checks: DoctorCheck[] = []; const projectPath = path || "."; // Check 0: Node.js is installed try { const nodeVersion = execSync("node --version", { encoding: "utf-8" }).trim(); checks.push({ name: "node-installed", status: "pass", message: nodeVersion }); } catch (e) { debug("node version check failed:", e); checks.push({ name: "node-installed", status: "fail", message: "Node.js is not installed — see https://nodejs.org" }); } // Check 1: Config exists and parses const configPaths = [ join(projectPath, "chant.config.json"), join(projectPath, "chant.config.ts"), ]; let config: Record | null = null; const configFound = configPaths.find(p => existsSync(p)); if (!configFound) { checks.push({ name: "config-exists", status: "fail", message: "No chant.config.json or chant.config.ts found" }); } else { try { if (configFound.endsWith(".json")) { config = JSON.parse(readFileSync(configFound, "utf-8")); } checks.push({ name: "config-exists", status: "pass" }); } catch (err) { checks.push({ name: "config-exists", status: "fail", message: `Config parse error: ${err instanceof Error ? err.message : String(err)}` }); } } // Check 2: src/ directory exists with .ts files const srcDir = join(projectPath, "src"); if (!existsSync(srcDir)) { checks.push({ name: "src-directory", status: "fail", message: "src/ directory not found" }); } else { try { const tsFiles = (readdirSync(srcDir, { recursive: true }) as string[]).filter( (f) => f.endsWith(".ts") ); if (tsFiles.length === 0) { checks.push({ name: "src-directory", status: "warn", message: "src/ exists but contains no .ts files" }); } else { checks.push({ name: "src-directory", status: "pass" }); } } catch (e) { debug("src directory read failed:", e); checks.push({ name: "src-directory", status: "fail", message: "Cannot read src/ directory" }); } } // Check 3: .chant/types/core/ exists and is not empty const coreTypesDir = join(projectPath, ".chant", "types", "core"); if (!existsSync(coreTypesDir)) { checks.push({ name: "core-types", status: "fail", message: ".chant/types/core/ not found — run chant update" }); } else { try { const files = readdirSync(coreTypesDir); if (files.length === 0) { checks.push({ name: "core-types", status: "fail", message: ".chant/types/core/ is empty" }); } else { checks.push({ name: "core-types", status: "pass" }); } } catch (e) { debug("core types directory read failed:", e); checks.push({ name: "core-types", status: "fail", message: "Cannot read .chant/types/core/" }); } } // Check 4-6: Per-lexicon checks const lexicons = config?.lexicons as string[] | undefined; if (lexicons && Array.isArray(lexicons)) { for (const lex of lexicons) { const lexDir = join(projectPath, ".chant", "types", `lexicon-${lex}`); if (!existsSync(lexDir)) { checks.push({ name: `lexicon-${lex}-types`, status: "fail", message: `.chant/types/lexicon-${lex}/ not found — run chant update` }); } else { const files = readdirSync(lexDir); if (files.length === 0) { checks.push({ name: `lexicon-${lex}-types`, status: "fail", message: `.chant/types/lexicon-${lex}/ is empty` }); } else { checks.push({ name: `lexicon-${lex}-types`, status: "pass" }); } } // Check manifest version compatibility const manifestPath = join(lexDir, "manifest.json"); if (existsSync(manifestPath)) { try { const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); if (manifest.chantVersion) { let currentVersion = "0.0.8"; try { const pkgDir = dirname(dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url)))))); const corePkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf-8")); currentVersion = corePkg.version ?? currentVersion; } catch { /* fallback */ } if (!checkVersionCompatibility(manifest.chantVersion, currentVersion)) { checks.push({ name: `lexicon-${lex}-compat`, status: "warn", message: `Lexicon ${lex} requires chant ${manifest.chantVersion}` }); } else { checks.push({ name: `lexicon-${lex}-compat`, status: "pass" }); } } } catch (e) { debug(`manifest read failed for lexicon ${lex}:`, e); } } } } // Check 7: No stale/orphaned lexicon directories const typesDir = join(projectPath, ".chant", "types"); if (existsSync(typesDir)) { try { const dirs = readdirSync(typesDir); for (const dir of dirs) { if (dir === "core") continue; if (!dir.startsWith("lexicon-")) continue; const lexName = dir.replace("lexicon-", ""); if (lexicons && !lexicons.includes(lexName)) { checks.push({ name: `stale-${dir}`, status: "warn", message: `Orphaned directory .chant/types/${dir}/ — lexicon "${lexName}" not in config` }); } } } catch (e) { debug("types directory read failed:", e); } } // Check 8: tsconfig.json does NOT have paths (they break runtime resolution) const tsconfigPath = join(projectPath, "tsconfig.json"); if (existsSync(tsconfigPath)) { try { // Simple JSON parse — tsconfig may have comments, but we try const raw = readFileSync(tsconfigPath, "utf-8"); // Strip single-line comments for basic parsing const cleaned = raw.replace(/\/\/.*$/gm, ""); const tsconfig = JSON.parse(cleaned); if (tsconfig.compilerOptions?.paths) { checks.push({ name: "tsconfig-paths", status: "warn", message: "tsconfig.json has compilerOptions.paths — these break runtime module resolution (tsx follows them). Remove the paths block." }); } else { checks.push({ name: "tsconfig-paths", status: "pass" }); } } catch (e) { debug("tsconfig.json parse failed:", e); checks.push({ name: "tsconfig-paths", status: "warn", message: "Could not parse tsconfig.json" }); } } // Check 8b: package.json declares "type": "module" (#1421) // // Sibling of the tsconfig-paths check above, and filed for the same reason: // both are project settings that silently break runtime module resolution. // // chant's core is ESM. When the project is CJS — `"type": "commonjs"`, or no // `type` field at all — tsx loads project source through the CommonJS // transform, so the project's `require` of `params.ts` and core's `import` of // it produce two separate module records. `setBuildParams` mutates one object // in place; project source reads the other, and sees `{}`. // // The result is a silent wrong answer, not a failure: chant prints // `[param] tier = "prod" (cli)` and then emits the graph for the default. It // hits `chant graph` (always the run path) and `chant build --no-fold`; plain // `chant build` escapes only because folding substitutes parameters // statically and never reads the shared object. const projectPkgPath = join(projectPath, "package.json"); if (existsSync(projectPkgPath)) { try { const pkg = JSON.parse(readFileSync(projectPkgPath, "utf-8")) as { type?: string }; if (pkg.type === "module") { checks.push({ name: "package-type-module", status: "pass" }); } else { const found = pkg.type ? `"type": "${pkg.type}"` : "no `type` field"; checks.push({ name: "package-type-module", status: "warn", message: `package.json has ${found} — chant is ESM, and a CommonJS project reads build ` + `parameters as empty on the run path (\`chant graph\`, \`chant build --no-fold\`). ` + `Declarations conditioned on \`params.\` silently take their default branch. ` + `Set "type": "module".`, }); } } catch (e) { debug("project package.json parse failed:", e); checks.push({ name: "package-type-module", status: "warn", message: "Could not parse package.json" }); } } // Check 9: .mcp.json exists and has chant entry const mcpPath = join(projectPath, ".mcp.json"); if (!existsSync(mcpPath)) { checks.push({ name: "mcp-config", status: "warn", message: ".mcp.json not found — run chant agent setup" }); } else { try { const mcp = JSON.parse(readFileSync(mcpPath, "utf-8")); if (!mcp.mcpServers?.chant) { checks.push({ name: "mcp-config", status: "warn", message: ".mcp.json missing mcpServers.chant entry" }); } else { checks.push({ name: "mcp-config", status: "pass" }); } } catch (e) { debug(".mcp.json parse failed:", e); checks.push({ name: "mcp-config", status: "fail", message: ".mcp.json is invalid JSON" }); } } // Check: Lexicon project docs/ directory const isLexiconProject = existsSync(join(projectPath, "src", "plugin.ts")); if (isLexiconProject) { if (existsSync(join(projectPath, "docs"))) { checks.push({ name: "lexicon-docs", status: "pass" }); } else { checks.push({ name: "lexicon-docs", status: "warn", message: "docs/ directory not found — run `just docs` to generate" }); } } // Check: Skills installed for each plugin try { const lexiconNames = await resolveProjectLexicons(resolve(projectPath)); const plugins = await loadPlugins(lexiconNames); for (const plugin of plugins) { if (!plugin.skills) continue; const skills = plugin.skills(); if (skills.length === 0) continue; let missing = 0; for (const skill of skills) { if (!existsSync(join(projectPath, "skills", skill.name, "SKILL.md"))) { missing++; } } if (missing === 0) { checks.push({ name: `skills-${plugin.name}`, status: "pass", message: `${skills.length} skill(s) installed` }); } else if (missing < skills.length) { checks.push({ name: `skills-${plugin.name}`, status: "warn", message: `${missing}/${skills.length} skill(s) missing — run chant update` }); } else { checks.push({ name: `skills-${plugin.name}`, status: "warn", message: `No skills installed — run chant update` }); } } } catch (e) { debug("skills check failed:", e); } // Check: Capability plugin packages (#559, epic #551) — each name in // config.capabilities must load and satisfy the CapabilityPlugin contract // (../../components/capability-plugin.ts). A malformed capability package // (missing/broken `capabilities()`, or the package failing to resolve at // all) is reported as a `fail`, mirroring how a missing lexicon type // directory fails above rather than warning. const capabilityNames = config?.capabilities as string[] | undefined; if (capabilityNames && Array.isArray(capabilityNames)) { for (const name of capabilityNames) { try { const plugin = await loadCapabilityPlugin(name); if (!isCapabilityPlugin(plugin)) { checks.push({ name: `capability-${name}`, status: "fail", message: `Capability plugin "${name}" does not satisfy the CapabilityPlugin contract (missing name/version/capabilities())`, }); continue; } const capabilities = plugin.capabilities(); if (!Array.isArray(capabilities)) { checks.push({ name: `capability-${name}`, status: "fail", message: `Capability plugin "${name}": capabilities() did not return an array`, }); continue; } checks.push({ name: `capability-${name}`, status: "pass", message: `${capabilities.length} capability(ies) registered`, }); } catch (e) { debug(`capability plugin check failed for ${name}:`, e); checks.push({ name: `capability-${name}`, status: "fail", message: `Capability plugin "${name}" could not be loaded: ${e instanceof Error ? e.message : String(e)}`, }); } } } return { checks, success: checks.every(c => c.status !== "fail"), }; }