From f824bd2007fb59b13f2a23ae5943f813777c819a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Wed, 18 Mar 2026 11:25:32 -0600 Subject: [PATCH] refactor: extract shared JSON persistence utility, migrate metrics + routing-history + unit-runtime (#1206) Eliminates repeated try/catch JSON file load/save boilerplate across three modules by introducing loadJsonFile, loadJsonFileOrNull, and saveJsonFile in a shared json-persistence.ts utility. Co-authored-by: Claude Opus 4.6 (1M context) --- .../extensions/gsd/json-persistence.ts | 52 +++++++++++++++++++ src/resources/extensions/gsd/metrics.ts | 48 ++++++----------- .../extensions/gsd/routing-history.ts | 30 +++++------ src/resources/extensions/gsd/unit-runtime.ts | 25 +++++---- 4 files changed, 96 insertions(+), 59 deletions(-) create mode 100644 src/resources/extensions/gsd/json-persistence.ts diff --git a/src/resources/extensions/gsd/json-persistence.ts b/src/resources/extensions/gsd/json-persistence.ts new file mode 100644 index 000000000..8cb9da14b --- /dev/null +++ b/src/resources/extensions/gsd/json-persistence.ts @@ -0,0 +1,52 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +/** + * Load a JSON file with validation, returning a default on failure. + * Handles missing files, corrupt JSON, and schema mismatches uniformly. + */ +export function loadJsonFile( + filePath: string, + validate: (data: unknown) => data is T, + defaultFactory: () => T, +): T { + try { + if (!existsSync(filePath)) return defaultFactory(); + const raw = readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw); + return validate(parsed) ? parsed : defaultFactory(); + } catch { + return defaultFactory(); + } +} + +/** + * Load a JSON file with validation, returning null on failure. + * For callers that distinguish "no data" from "default data". + */ +export function loadJsonFileOrNull( + filePath: string, + validate: (data: unknown) => data is T, +): T | null { + try { + if (!existsSync(filePath)) return null; + const raw = readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw); + return validate(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** + * Save a JSON file, creating parent directories as needed. + * Non-fatal — swallows errors to prevent persistence from breaking operations. + */ +export function saveJsonFile(filePath: string, data: T): void { + try { + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8"); + } catch { + // Non-fatal — don't let persistence failures break operation + } +} diff --git a/src/resources/extensions/gsd/metrics.ts b/src/resources/extensions/gsd/metrics.ts index 805c8df71..197763e0b 100644 --- a/src/resources/extensions/gsd/metrics.ts +++ b/src/resources/extensions/gsd/metrics.ts @@ -13,11 +13,11 @@ * 4. On crash recovery or fresh start, the ledger is loaded from disk */ -import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import type { ExtensionContext } from "@gsd/pi-coding-agent"; import { gsdRoot } from "./paths.js"; import { getAndClearSkills } from "./skill-telemetry.js"; +import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js"; // Re-export from shared — canonical implementation lives in format-utils. export { formatTokenCount } from "../shared/mod.js"; @@ -502,45 +502,31 @@ function metricsPath(base: string): string { return join(gsdRoot(base), "metrics.json"); } +function isMetricsLedger(data: unknown): data is MetricsLedger { + return ( + typeof data === "object" && + data !== null && + (data as MetricsLedger).version === 1 && + Array.isArray((data as MetricsLedger).units) + ); +} + +function defaultLedger(): MetricsLedger { + return { version: 1, projectStartedAt: Date.now(), units: [] }; +} + /** * Load ledger from disk without initializing in-memory state. * Used by history/export commands outside of auto-mode. */ export function loadLedgerFromDisk(base: string): MetricsLedger | null { - try { - const raw = readFileSync(metricsPath(base), "utf-8"); - const parsed = JSON.parse(raw); - if (parsed.version === 1 && Array.isArray(parsed.units)) { - return parsed as MetricsLedger; - } - } catch { - // File doesn't exist or is corrupt - } - return null; + return loadJsonFileOrNull(metricsPath(base), isMetricsLedger); } function loadLedger(base: string): MetricsLedger { - try { - const raw = readFileSync(metricsPath(base), "utf-8"); - const parsed = JSON.parse(raw); - if (parsed.version === 1 && Array.isArray(parsed.units)) { - return parsed as MetricsLedger; - } - } catch { - // File doesn't exist or is corrupt — start fresh - } - return { - version: 1, - projectStartedAt: Date.now(), - units: [], - }; + return loadJsonFile(metricsPath(base), isMetricsLedger, defaultLedger); } function saveLedger(base: string, data: MetricsLedger): void { - try { - mkdirSync(gsdRoot(base), { recursive: true }); - writeFileSync(metricsPath(base), JSON.stringify(data, null, 2) + "\n", "utf-8"); - } catch { - // Don't let metrics failures break auto-mode - } + saveJsonFile(metricsPath(base), data); } diff --git a/src/resources/extensions/gsd/routing-history.ts b/src/resources/extensions/gsd/routing-history.ts index a4fe81ea7..ceeed6f32 100644 --- a/src/resources/extensions/gsd/routing-history.ts +++ b/src/resources/extensions/gsd/routing-history.ts @@ -2,10 +2,10 @@ // Tracks success/failure per tier per unit-type pattern to improve // classification accuracy over time. -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { gsdRoot } from "./paths.js"; import type { ComplexityTier } from "./types.js"; +import { loadJsonFile, saveJsonFile } from "./json-persistence.js"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -267,24 +267,20 @@ function historyPath(base: string): string { return join(gsdRoot(base), HISTORY_FILE); } +function isRoutingHistoryData(data: unknown): data is RoutingHistoryData { + return ( + typeof data === "object" && + data !== null && + (data as RoutingHistoryData).version === 1 && + typeof (data as RoutingHistoryData).patterns === "object" && + (data as RoutingHistoryData).patterns !== null + ); +} + function loadHistory(base: string): RoutingHistoryData { - try { - const raw = readFileSync(historyPath(base), "utf-8"); - const parsed = JSON.parse(raw); - if (parsed.version === 1 && parsed.patterns) { - return parsed as RoutingHistoryData; - } - } catch { - // File doesn't exist or is corrupt — start fresh - } - return createEmptyHistory(); + return loadJsonFile(historyPath(base), isRoutingHistoryData, createEmptyHistory); } function saveHistory(base: string, data: RoutingHistoryData): void { - try { - mkdirSync(gsdRoot(base), { recursive: true }); - writeFileSync(historyPath(base), JSON.stringify(data, null, 2) + "\n", "utf-8"); - } catch { - // Non-fatal — don't let history failures break auto-mode - } + saveJsonFile(historyPath(base), data); } diff --git a/src/resources/extensions/gsd/unit-runtime.ts b/src/resources/extensions/gsd/unit-runtime.ts index cb4fab2cc..04c027d5e 100644 --- a/src/resources/extensions/gsd/unit-runtime.ts +++ b/src/resources/extensions/gsd/unit-runtime.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { gsdRoot, @@ -8,6 +8,7 @@ import { resolveTaskFile, } from "./paths.js"; import { loadFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js"; +import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js"; export type UnitRuntimePhase = | "dispatched" @@ -46,6 +47,16 @@ export interface AutoUnitRuntimeRecord { lastRecoveryReason?: "idle" | "hard"; } +function isAutoUnitRuntimeRecord(data: unknown): data is AutoUnitRuntimeRecord { + return ( + typeof data === "object" && + data !== null && + (data as AutoUnitRuntimeRecord).version === 1 && + typeof (data as AutoUnitRuntimeRecord).unitType === "string" && + typeof (data as AutoUnitRuntimeRecord).unitId === "string" + ); +} + function runtimeDir(basePath: string): string { return join(gsdRoot(basePath), "runtime", "units"); } @@ -63,8 +74,6 @@ export function writeUnitRuntimeRecord( startedAt: number, updates: Partial = {}, ): AutoUnitRuntimeRecord { - const dir = runtimeDir(basePath); - mkdirSync(dir, { recursive: true }); const path = runtimePath(basePath, unitType, unitId); const prev = readUnitRuntimeRecord(basePath, unitType, unitId); const next: AutoUnitRuntimeRecord = { @@ -84,18 +93,12 @@ export function writeUnitRuntimeRecord( recoveryAttempts: updates.recoveryAttempts ?? prev?.recoveryAttempts ?? 0, lastRecoveryReason: updates.lastRecoveryReason ?? prev?.lastRecoveryReason, }; - writeFileSync(path, JSON.stringify(next, null, 2) + "\n", "utf-8"); + saveJsonFile(path, next); return next; } export function readUnitRuntimeRecord(basePath: string, unitType: string, unitId: string): AutoUnitRuntimeRecord | null { - const path = runtimePath(basePath, unitType, unitId); - if (!existsSync(path)) return null; - try { - return JSON.parse(readFileSync(path, "utf-8")) as AutoUnitRuntimeRecord; - } catch { - return null; - } + return loadJsonFileOrNull(runtimePath(basePath, unitType, unitId), isAutoUnitRuntimeRecord); } export function clearUnitRuntimeRecord(basePath: string, unitType: string, unitId: string): void {