From 16f585f6bfa7229451ef40ac811dba0b390d21f5 Mon Sep 17 00:00:00 2001 From: real-venus Date: Sun, 12 Jul 2026 09:32:09 -0700 Subject: [PATCH] feat(miner): validate config content in doctor, not just its path doctor only reported the discovered .gittensory-miner config file's path, so a malformed config silently degraded to defaults instead of being caught before a run. Add a config-content check that parses the discovered config with the tolerant goal-spec parser and surfaces its warnings, so doctor reports specific, actionable errors (and exits non-zero) for a malformed config. No config file is fine (defaults apply); a read failure is reported. runDoctorChecks/runDoctor now take an optional cwd (the config is discovered relative to it), defaulting to process.cwd(). Closes #4873 --- packages/gittensory-miner/lib/status.d.ts | 6 ++- packages/gittensory-miner/lib/status.js | 31 +++++++++++--- test/unit/miner-status.test.ts | 51 ++++++++++++++++++++++- 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/packages/gittensory-miner/lib/status.d.ts b/packages/gittensory-miner/lib/status.d.ts index 63a1205fd9..1d54e8c8af 100644 --- a/packages/gittensory-miner/lib/status.d.ts +++ b/packages/gittensory-miner/lib/status.d.ts @@ -25,9 +25,11 @@ export function collectStatus(env?: Record, cwd?: st export function runStatus(args?: string[], env?: Record, cwd?: string): number; -export function runDoctorChecks(env?: Record): DoctorCheck[]; +export function checkConfigContent(cwd: string, readImpl?: (path: string, encoding: "utf8") => string): DoctorCheck; -export function runDoctor(args?: string[], env?: Record): number; +export function runDoctorChecks(env?: Record, cwd?: string): DoctorCheck[]; + +export function runDoctor(args?: string[], env?: Record, cwd?: string): number; export function readInstalledEnginePackageVersionFromPaths( resolvedEntry: string, diff --git a/packages/gittensory-miner/lib/status.js b/packages/gittensory-miner/lib/status.js index 09efd516ce..0ab2586bf3 100644 --- a/packages/gittensory-miner/lib/status.js +++ b/packages/gittensory-miner/lib/status.js @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import { createRequire } from "node:module"; import { homedir } from "node:os"; import { join } from "node:path"; -import { CODING_AGENT_DRIVER_CONFIG_ENV, resolveFirstConfiguredCodingAgentDriverName } from "@jsonbored/gittensory-engine"; +import { CODING_AGENT_DRIVER_CONFIG_ENV, parseMinerGoalSpecContent, resolveFirstConfiguredCodingAgentDriverName } from "@jsonbored/gittensory-engine"; import { checkClaudeCliPresent, checkCodexCliPresent, @@ -11,7 +11,7 @@ import { findExecutableOnPath, } from "./laptop-init.js"; import { resolveMinerVersion } from "./version.js"; -import { checkStoreIntegrity } from "./store-maintenance.js"; +import { checkStoreIntegrity, describeError } from "./store-maintenance.js"; import { resolveEventLedgerDbPath } from "./event-ledger.js"; import { resolveGovernorLedgerDbPath } from "./governor-ledger.js"; import { resolvePredictionLedgerDbPath } from "./prediction-ledger.js"; @@ -294,9 +294,29 @@ function storeIntegrityChecks(env) { return stores.map(([name, dbPath]) => checkStoreIntegrity(`store-integrity:${name}`, dbPath)); } +/** Validate the discovered `.gittensory-miner` config's CONTENT (#4873), not just its path: parse it with the + * tolerant goal-spec parser and surface its warnings, so a malformed config is flagged by `doctor` rather than + * silently degrading to defaults. No config file is fine (defaults apply); a read failure is reported. `readImpl` + * is injectable for tests. */ +export function checkConfigContent(cwd, readImpl = readFileSync) { + const configPath = discoverConfigFile(cwd); + if (!configPath) { + return { name: "config-content", ok: true, detail: "no .gittensory-miner config found (using defaults)" }; + } + let warnings; + try { + warnings = parseMinerGoalSpecContent(readImpl(configPath, "utf8")).warnings; + } catch (error) { + return { name: "config-content", ok: false, detail: `${configPath}: ${describeError(error)}` }; + } + return warnings.length === 0 + ? { name: "config-content", ok: true, detail: `${configPath}: valid` } + : { name: "config-content", ok: false, detail: `${configPath}: ${warnings.join("; ")}` }; +} + /** Run the doctor checks. Returns an array of { name, ok, detail }; only writes a transient probe in the state dir, * never touches the network. */ -export function runDoctorChecks(env = process.env) { +export function runDoctorChecks(env = process.env, cwd = process.cwd()) { const nodeMajor = Number(process.versions.node.split(".")[0]); const requiredMajor = requiredNodeMajor(); const engineVersion = readEngineVersion(); @@ -317,12 +337,13 @@ export function runDoctorChecks(env = process.env) { checkDockerPresent(), checkClaudeCliPresent({ env }), checkCodexCliPresent({ env }), + checkConfigContent(cwd), ...storeIntegrityChecks(env), ]; } -export function runDoctor(args = [], env = process.env) { - const checks = runDoctorChecks(env); +export function runDoctor(args = [], env = process.env, cwd = process.cwd()) { + const checks = runDoctorChecks(env, cwd); const failed = checks.filter((check) => !check.ok); if (args.includes("--json")) { console.log(JSON.stringify({ ok: failed.length === 0, checks }, null, 2)); diff --git a/test/unit/miner-status.test.ts b/test/unit/miner-status.test.ts index 79fec77a2a..884c1d9978 100644 --- a/test/unit/miner-status.test.ts +++ b/test/unit/miner-status.test.ts @@ -5,6 +5,7 @@ import { resolveEventLedgerDbPath } from "../../packages/gittensory-miner/lib/ev import { afterEach, describe, expect, it, vi } from "vitest"; import { buildEngineVersionSkewCheck, + checkConfigContent, collectStatus, compareInstalledEngineVersion, readExpectedEnginePackageVersion, @@ -82,7 +83,8 @@ describe("gittensory-miner status/doctor (#2288)", () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); const env = { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") }; initLaptopState(env); - const checks = runDoctorChecks(env); + const cwd = tempRoot(); // a config-less working dir ⇒ config-content check is a clean pass + const checks = runDoctorChecks(env, cwd); expect(checks.every((check) => check.ok)).toBe(true); expect(checks.map((check) => check.name)).toEqual([ "node-version", @@ -93,6 +95,7 @@ describe("gittensory-miner status/doctor (#2288)", () => { "docker-present", "claude-cli-present", "codex-cli-present", + "config-content", "store-integrity:event-ledger", "store-integrity:governor-ledger", "store-integrity:prediction-ledger", @@ -101,7 +104,7 @@ describe("gittensory-miner status/doctor (#2288)", () => { "store-integrity:run-state", "store-integrity:plan-store", ]); - expect(runDoctor([], env)).toBe(0); + expect(runDoctor([], env, cwd)).toBe(0); expect(log).toHaveBeenCalled(); }); @@ -115,6 +118,50 @@ describe("gittensory-miner status/doctor (#2288)", () => { expect(runDoctor([], env)).toBe(1); // a failed check makes doctor exit non-zero }); + describe("checkConfigContent (#4873)", () => { + it("is a clean pass when no config file is present (defaults apply)", () => { + const result = checkConfigContent(tempRoot()); + expect(result).toMatchObject({ name: "config-content", ok: true }); + expect(result.detail).toContain("no .gittensory-miner config"); + }); + + it("passes a well-formed config", () => { + const cwd = tempRoot(); + writeFileSync(join(cwd, ".gittensory-miner.yml"), "wantedPaths:\n - src\n"); + const result = checkConfigContent(cwd); + expect(result.ok).toBe(true); + expect(result.detail).toContain("valid"); + }); + + it("flags a malformed config with the parser's specific warnings", () => { + const cwd = tempRoot(); + // wantedPaths must be a list; a scalar triggers a parser warning rather than a silent default. + writeFileSync(join(cwd, ".gittensory-miner.yml"), "wantedPaths: not-a-list\n"); + const result = checkConfigContent(cwd); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/wantedPaths/); + }); + + it("reports a read failure (config discovered but unreadable)", () => { + const cwd = tempRoot(); + writeFileSync(join(cwd, ".gittensory-miner.yml"), "wantedPaths:\n - src\n"); + const throwingRead = () => { + throw new Error("EACCES: permission denied"); + }; + const result = checkConfigContent(cwd, throwingRead); + expect(result.ok).toBe(false); + expect(result.detail).toContain("permission denied"); + }); + }); + + it("doctor flags a malformed config file (#4873)", () => { + const env = { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") }; + const cwd = tempRoot(); + writeFileSync(join(cwd, ".gittensory-miner.yml"), "wantedPaths: not-a-list\n"); + expect(runDoctorChecks(env, cwd).find((check) => check.name === "config-content")?.ok).toBe(false); + expect(runDoctor([], env, cwd)).toBe(1); + }); + it("engine version skew helpers compare installed vs expected semver", () => { expect(compareInstalledEngineVersion("0.2.0", "0.2.0")).toBe(0); expect(compareInstalledEngineVersion("0.1.0", "0.2.0")).toBe(-1);