diff --git a/.gitignore b/.gitignore index bbc12e1b1..3d0c8ac23 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,7 @@ build/ /gsd-core/bin/lib/runtime-config-adapter-registry.cjs /gsd-core/bin/lib/command-routing-hub.cjs /gsd-core/bin/lib/core.cjs +/gsd-core/bin/lib/io.cjs /gsd-core/bin/lib/drift.cjs /gsd-core/bin/lib/cjs-command-router-adapter.cjs /gsd-core/bin/lib/phase-command-router.cjs diff --git a/CONTEXT.md b/CONTEXT.md index a5ebdd85f..c040e97f9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -106,6 +106,9 @@ Module owning validation for Installer Migration Module records and planned acti ### Installer Module Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Claude-specific permission helpers: `mergeClaudePermissions(settings)` — non-destructively appends GSD-owned allow/deny entries (see `GSD_CLAUDE_ALLOW_PERMISSIONS`, `GSD_CLAUDE_DENY_PERMISSIONS` constants) to a Claude Code settings object; called from `finishInstall` for `runtime === 'claude'` only; uninstall removes exactly these entries (#768). Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Hermes uses nested `skills/gsd//` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module. +### I/O Module +Module owning the tool's CLI I/O primitives: `output()` result emission (with large-payload temp-file spillover via `GSD_TEMP_DIR`/`ensureGsdTempDir`/`reapStaleTempFiles`), `error()` stderr emission with exit-code mapping, and the JSON-error-mode toggle (`setJsonErrorMode`/`getJsonErrorMode`, `ERROR_REASON`). Extracted from the Core module per ADR-857 rollout phase 1 (#859) so feature modules (`graphify`, `intel`, `audit`, `profile-pipeline`) depend on a small I/O seam instead of the core god-module; `core.cjs` re-exports the primitives for back-compat. Source of truth: `gsd-core/bin/lib/io.cjs` (generated from `src/io.cts`). + ### Package Identity Module [Planned] Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/get-shit-done-redux`), `binName` (`Object.keys(.bin)[0]` → `get-shit-done-redux`), `repoSlug` (parsed from `.repository.url` → `open-gsd/get-shit-done-redux`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `gsd-core/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b2b1b4e08..b3aa176c8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -342,7 +342,8 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | Module | Responsibility | | ---------------------- | --------------------------------------------------------------------------------------------------- | -| `core.cjs` | Error handling, output formatting, shared utilities; compatibility re-exports for planning helpers | +| `core.cjs` | Shared utilities; compatibility re-exports for planning and I/O (`io.cjs`) helpers | +| `io.cjs` | CLI I/O primitives — output/error emission, JSON-error mode, large-payload temp-file spillover | | `planning-workspace.cjs` | Planning seam (`planningDir`, `planningPaths`, active workstream routing, `.planning/.lock`) | | `state.cjs` | STATE.md parsing, updating, progression, metrics | | `phase.cjs` | Phase directory operations, decimal numbering, plan indexing | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 5d7854d75..4dde591fe 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-06-07", + "generated": "2026-06-08", "families": { "agents": [ "gsd-advisor-researcher", @@ -301,6 +301,7 @@ "installer-migration-report.cjs", "installer-migrations.cjs", "intel.cjs", + "io.cjs", "learnings.cjs", "legacy-cleanup.cjs", "milestone.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index ebce42500..69f15b57b 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (90 shipped) +## CLI Modules (91 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -396,7 +396,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `config-types.cjs` | TypeScript type definitions for the `model_policy` config block — `ModelPolicyConfig`, `TierEntry`, `RuntimeTiers`; compiled from `src/config-types.cts` at publish time (ADR-457) | | `configuration.cjs` | Configuration Module — canonical config loading, legacy-key normalization, defaults merge, and explicit on-disk migration; source of truth for both SDK and CJS consumers | | `context-utilization.cjs` | Pure classifier for `gsd-health --context` — turns (tokensUsed, contextWindow) into a `{ percent, state }` triage result against the 60%/70% fracture-point thresholds (#2792) | -| `core.cjs` | Error handling, output formatting, shared utilities, runtime fallbacks; compatibility re-exports for planning-workspace helpers | +| `core.cjs` | Shared utilities and runtime fallbacks; compatibility re-exports for planning-workspace and I/O (`io.cjs`) helpers | | `decisions.cjs` | Parses CONTEXT.md `` blocks; accepts numeric (D-42) and alphanumeric (D-INFRA-01) IDs; returns `{id, text, category, tags, trackable}` | | `docs.cjs` | Docs-update workflow init, Markdown scanning, monorepo detection | | `drift.cjs` | Post-execute codebase structural drift detector (#2003): classifies file changes into new-dir/barrel/migration/route categories and round-trips `last_mapped_commit` frontmatter | @@ -412,6 +412,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `installer-migration-report.cjs` | Installer migration report projection and blocked-action guard for install/update integration | | `installer-migrations.cjs` | Installer migration planning, artifact classification, install-state persistence, journaled apply, and rollback helpers | | `intel.cjs` | Codebase intel store backing `/gsd-map-codebase --query` and `gsd-intel-updater` | +| `io.cjs` | CLI I/O primitives — `output`/`error` emission, JSON-error mode, and large-payload temp-file spillover (extracted from `core.cjs`, ADR-857) | | `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` | | `legacy-cleanup.cjs` | Detect and remove leftover get-shit-done-cc artifacts; exports `planLegacyCleanup` (pure scan) and `applyLegacyCleanup` (thin IO applier) that root out stale files from the old package across every GSD-managed runtime config directory (#607) | | `milestone.cjs` | Milestone archival, requirements marking | diff --git a/eslint.config.mjs b/eslint.config.mjs index d88a62b24..c461b21c8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -89,6 +89,7 @@ export default tseslint.config( 'gsd-core/bin/lib/runtime-config-adapter-registry.cjs', 'gsd-core/bin/lib/command-routing-hub.cjs', 'gsd-core/bin/lib/core.cjs', + 'gsd-core/bin/lib/io.cjs', 'gsd-core/bin/lib/drift.cjs', 'gsd-core/bin/lib/cjs-command-router-adapter.cjs', 'gsd-core/bin/lib/phase-command-router.cjs', diff --git a/src/core.cts b/src/core.cts index a0203206e..dcfad3e29 100644 --- a/src/core.cts +++ b/src/core.cts @@ -9,7 +9,10 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { execGit, platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs'; +import { execGit, platformWriteSync, platformReadSync } from './shell-command-projection.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import ioModule = require('./io.cjs'); +const { output, error, ERROR_REASON, setJsonErrorMode, getJsonErrorMode, GSD_TEMP_DIR, reapStaleTempFiles } = ioModule; // eslint-disable-next-line @typescript-eslint/no-require-imports import modelProfiles = require('./model-profiles.cjs'); const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, VALID_PHASE_TYPES: _VALID_PHASE_TYPES, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles; @@ -76,151 +79,6 @@ function detectSubRepos(cwd: string): string[] { // findProjectRoot is now re-exported from the generated CJS module above. -// ─── Output helpers ─────────────────────────────────────────────────────────── - -/** - * Dedicated GSD temp directory: path.join(os.tmpdir(), 'gsd'). - * Created on first use. Keeps GSD temp files isolated from the system - * temp directory so reap scans only GSD files (#1975). - */ -const GSD_TEMP_DIR = path.join(os.tmpdir(), 'gsd'); - -function ensureGsdTempDir(): void { - platformEnsureDir(GSD_TEMP_DIR); -} - -interface ReapOptions { - maxAgeMs?: number; - dirsOnly?: boolean; -} - -/** - * Remove stale gsd-* temp files/dirs older than maxAgeMs (default: 5 minutes). - * Runs opportunistically before each new temp file write to prevent unbounded accumulation. - * @param prefix - filename prefix to match (e.g., 'gsd-') - * @param opts - * @param opts.maxAgeMs - max age in ms before removal (default: 5 min) - * @param opts.dirsOnly - if true, only remove directories (default: false) - */ -function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false }: ReapOptions = {}): void { - try { - ensureGsdTempDir(); - const now = Date.now(); - const entries = fs.readdirSync(GSD_TEMP_DIR); - for (const entry of entries) { - if (!entry.startsWith(prefix)) continue; - const fullPath = path.join(GSD_TEMP_DIR, entry); - try { - const stat = fs.statSync(fullPath); - if (now - stat.mtimeMs > maxAgeMs) { - if (stat.isDirectory()) { - fs.rmSync(fullPath, { recursive: true, force: true }); - } else if (!dirsOnly) { - fs.unlinkSync(fullPath); - } - } - } catch { - // File may have been removed between readdir and stat — ignore - } - } - } catch { - // Non-critical — don't let cleanup failures break output - } -} - -function output(result: unknown, raw: boolean, rawValue?: unknown): void { - let data: string; - if (raw && rawValue !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - data = String(rawValue); - } else { - const json = JSON.stringify(result, null, 2); - // Large payloads exceed Claude Code's Bash tool buffer (~50KB). - // Write to tmpfile and output the path prefixed with @file: so callers can detect it. - if (json.length > 50000) { - reapStaleTempFiles(); - ensureGsdTempDir(); - const tmpPath = path.join(GSD_TEMP_DIR, `gsd-${Date.now()}.json`); - platformWriteSync(tmpPath, json); - data = '@file:' + tmpPath; - } else { - data = json; - } - } - // process.stdout.write() is async when stdout is a pipe — process.exit() - // can tear down the process before the reader consumes the buffer. - // fs.writeSync(1, ...) blocks until the kernel accepts the bytes, and - // skipping process.exit() lets the event loop drain naturally. - fs.writeSync(1, data); -} - -/** - * Frozen enum of typed reason codes used by error() for structured errors. - * Each subcommand contributes its own codes; the enum exists so tests can - * assert against typed values instead of grepping stderr (#2974). - * - * Adding a new code: - * - Pick a snake_case lowercase value (the JSON wire form) - * - Group by subsystem prefix (CONFIG_*, SDK_*, etc) - * - Pass it to error(msg, ERROR_REASON.NEW_CODE) at the call site - */ -const ERROR_REASON = Object.freeze({ - // config-get / config-set - CONFIG_KEY_NOT_FOUND: 'config_key_not_found', - CONFIG_NO_FILE: 'config_no_file', - CONFIG_PARSE_FAILED: 'config_parse_failed', - CONFIG_INVALID_KEY: 'config_invalid_key', - // SDK / gsd-tools dispatch - SDK_FAIL_FAST: 'sdk_fail_fast', - SDK_UNKNOWN_COMMAND: 'sdk_unknown_command', - SDK_MISSING_ARG: 'sdk_missing_arg', - // workflow / phase - PHASE_NOT_FOUND: 'phase_not_found', - SUMMARY_NO_PLANNING: 'summary_no_planning', - // graphify - GRAPHIFY_NO_GRAPH: 'graphify_no_graph', - GRAPHIFY_INVALID_QUERY: 'graphify_invalid_query', - // hooks - HOOKS_OPT_OUT: 'hooks_opt_out', - // security-scan - SECURITY_SCAN_FAILED: 'security_scan_failed', - // generic - USAGE: 'usage', - UNKNOWN: 'unknown', -}); - -type ErrorReasonValue = typeof ERROR_REASON[keyof typeof ERROR_REASON]; - -/** - * Process-level flag: when true, error() emits structured JSON to stderr - * instead of plain "Error: " text. Set by gsd-tools.cjs when the - * CLI is invoked with `--json-errors`. Tests opt in to typed-IR error - * assertions by passing that flag and parsing the JSON. - * - * Default off so existing callers and human operators keep their plain-text - * diagnostics. The structured form is opt-in for tooling and tests (#2974). - */ -let _jsonErrorMode = false; -function setJsonErrorMode(v: unknown): void { _jsonErrorMode = !!v; } -function getJsonErrorMode(): boolean { return _jsonErrorMode; } - -/** - * Emit an error and exit. When the second argument is provided it must be - * a value from ERROR_REASON; tests can assert on `result.reason`. When the - * process is in JSON-error mode, stderr receives `{ ok: false, reason, - * message }` so callers can parse it; otherwise stderr keeps the plain - * text form for human operators. - */ -function error(message: string, reason: ErrorReasonValue = ERROR_REASON.UNKNOWN): never { - if (_jsonErrorMode) { - const payload = JSON.stringify({ ok: false, reason, message }) + '\n'; - fs.writeSync(2, payload); - } else { - fs.writeSync(2, 'Error: ' + message + '\n'); - } - process.exit(1); -} - // ─── File & Config utilities ────────────────────────────────────────────────── /** diff --git a/src/io.cts b/src/io.cts new file mode 100644 index 000000000..97c7a43a0 --- /dev/null +++ b/src/io.cts @@ -0,0 +1,174 @@ +/** + * CLI I/O primitives — output(), error(), ERROR_REASON, JSON-error mode, + * and the temp-file helpers that output() depends on. + * + * Extracted from core.cts (ADR-857 rollout phase 1 / issue #859). + * The hand-written bodies are preserved byte-for-behaviour; only the module + * boundary moved. core.cts re-exports every symbol here under its own + * `export =` object so existing consumers are unaffected. + * + * New imports should pull I/O primitives from io.cjs directly. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs'; + +// ─── Temp-file helpers (needed by output()) ────────────────────────────────── + +/** + * Dedicated GSD temp directory: path.join(os.tmpdir(), 'gsd'). + * Created on first use. Keeps GSD temp files isolated from the system + * temp directory so reap scans only GSD files (#1975). + */ +const GSD_TEMP_DIR = path.join(os.tmpdir(), 'gsd'); + +function ensureGsdTempDir(): void { + platformEnsureDir(GSD_TEMP_DIR); +} + +interface ReapOptions { + maxAgeMs?: number; + dirsOnly?: boolean; +} + +/** + * Remove stale gsd-* temp files/dirs older than maxAgeMs (default: 5 minutes). + * Runs opportunistically before each new temp file write to prevent unbounded accumulation. + * @param prefix - filename prefix to match (e.g., 'gsd-') + * @param opts + * @param opts.maxAgeMs - max age in ms before removal (default: 5 min) + * @param opts.dirsOnly - if true, only remove directories (default: false) + */ +function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false }: ReapOptions = {}): void { + try { + ensureGsdTempDir(); + const now = Date.now(); + const entries = fs.readdirSync(GSD_TEMP_DIR); + for (const entry of entries) { + if (!entry.startsWith(prefix)) continue; + const fullPath = path.join(GSD_TEMP_DIR, entry); + try { + const stat = fs.statSync(fullPath); + if (now - stat.mtimeMs > maxAgeMs) { + if (stat.isDirectory()) { + fs.rmSync(fullPath, { recursive: true, force: true }); + } else if (!dirsOnly) { + fs.unlinkSync(fullPath); + } + } + } catch { + // File may have been removed between readdir and stat — ignore + } + } + } catch { + // Non-critical — don't let cleanup failures break output + } +} + +// ─── Output helpers ─────────────────────────────────────────────────────────── + +function output(result: unknown, raw: boolean, rawValue?: unknown): void { + let data: string; + if (raw && rawValue !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + data = String(rawValue); + } else { + const json = JSON.stringify(result, null, 2); + // Large payloads exceed Claude Code's Bash tool buffer (~50KB). + // Write to tmpfile and output the path prefixed with @file: so callers can detect it. + if (json.length > 50000) { + reapStaleTempFiles(); + ensureGsdTempDir(); + const tmpPath = path.join(GSD_TEMP_DIR, `gsd-${Date.now()}.json`); + platformWriteSync(tmpPath, json); + data = '@file:' + tmpPath; + } else { + data = json; + } + } + // process.stdout.write() is async when stdout is a pipe — process.exit() + // can tear down the process before the reader consumes the buffer. + // fs.writeSync(1, ...) blocks until the kernel accepts the bytes, and + // skipping process.exit() lets the event loop drain naturally. + fs.writeSync(1, data); +} + +/** + * Frozen enum of typed reason codes used by error() for structured errors. + * Each subcommand contributes its own codes; the enum exists so tests can + * assert against typed values instead of grepping stderr (#2974). + * + * Adding a new code: + * - Pick a snake_case lowercase value (the JSON wire form) + * - Group by subsystem prefix (CONFIG_*, SDK_*, etc) + * - Pass it to error(msg, ERROR_REASON.NEW_CODE) at the call site + */ +const ERROR_REASON = Object.freeze({ + // config-get / config-set + CONFIG_KEY_NOT_FOUND: 'config_key_not_found', + CONFIG_NO_FILE: 'config_no_file', + CONFIG_PARSE_FAILED: 'config_parse_failed', + CONFIG_INVALID_KEY: 'config_invalid_key', + // SDK / gsd-tools dispatch + SDK_FAIL_FAST: 'sdk_fail_fast', + SDK_UNKNOWN_COMMAND: 'sdk_unknown_command', + SDK_MISSING_ARG: 'sdk_missing_arg', + // workflow / phase + PHASE_NOT_FOUND: 'phase_not_found', + SUMMARY_NO_PLANNING: 'summary_no_planning', + // graphify + GRAPHIFY_NO_GRAPH: 'graphify_no_graph', + GRAPHIFY_INVALID_QUERY: 'graphify_invalid_query', + // hooks + HOOKS_OPT_OUT: 'hooks_opt_out', + // security-scan + SECURITY_SCAN_FAILED: 'security_scan_failed', + // generic + USAGE: 'usage', + UNKNOWN: 'unknown', +}); + +type ErrorReasonValue = typeof ERROR_REASON[keyof typeof ERROR_REASON]; + +/** + * Process-level flag: when true, error() emits structured JSON to stderr + * instead of plain "Error: " text. Set by gsd-tools.cjs when the + * CLI is invoked with `--json-errors`. Tests opt in to typed-IR error + * assertions by passing that flag and parsing the JSON. + * + * Default off so existing callers and human operators keep their plain-text + * diagnostics. The structured form is opt-in for tooling and tests (#2974). + */ +let _jsonErrorMode = false; +function setJsonErrorMode(v: unknown): void { _jsonErrorMode = !!v; } +function getJsonErrorMode(): boolean { return _jsonErrorMode; } + +/** + * Emit an error and exit. When the second argument is provided it must be + * a value from ERROR_REASON; tests can assert on `result.reason`. When the + * process is in JSON-error mode, stderr receives `{ ok: false, reason, + * message }` so callers can parse it; otherwise stderr keeps the plain + * text form for human operators. + */ +function error(message: string, reason: ErrorReasonValue = ERROR_REASON.UNKNOWN): never { + if (_jsonErrorMode) { + const payload = JSON.stringify({ ok: false, reason, message }) + '\n'; + fs.writeSync(2, payload); + } else { + fs.writeSync(2, 'Error: ' + message + '\n'); + } + process.exit(1); +} + +export = { + GSD_TEMP_DIR, + ensureGsdTempDir, + reapStaleTempFiles, + output, + ERROR_REASON, + setJsonErrorMode, + getJsonErrorMode, + error, +}; diff --git a/src/profile-pipeline.cts b/src/profile-pipeline.cts index e7e721f30..7de1b232f 100644 --- a/src/profile-pipeline.cts +++ b/src/profile-pipeline.cts @@ -17,8 +17,8 @@ import path from 'node:path'; import os from 'node:os'; import readline from 'node:readline'; // eslint-disable-next-line @typescript-eslint/no-require-imports -import core = require('./core.cjs'); -const { output, error, reapStaleTempFiles } = core; +import ioModule = require('./io.cjs'); +const { output, error, reapStaleTempFiles } = ioModule; // ─── Types ──────────────────────────────────────────────────────────────────── diff --git a/tests/io.test.cjs b/tests/io.test.cjs new file mode 100644 index 000000000..3825be1c1 --- /dev/null +++ b/tests/io.test.cjs @@ -0,0 +1,341 @@ +/** + * Tests for src/io.cts (compiled to gsd-core/bin/lib/io.cjs). + * + * Verifies behavioural contracts of the extracted CLI I/O primitives: + * - output() writes expected structure to stdout + * - error() writes expected structure to stderr and exits + * - ERROR_REASON constants have the correct wire values + * - setJsonErrorMode/getJsonErrorMode toggle behaviour + * - core.cjs re-export shims resolve to the exact same objects as io.cjs + * + * ADR-857 phase 1 / issue #859. + */ + +const { test, describe, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); +const os = require('node:os'); +const fs = require('node:fs'); + +const io = require('../gsd-core/bin/lib/io.cjs'); +const core = require('../gsd-core/bin/lib/core.cjs'); + +// ─── ERROR_REASON constants ─────────────────────────────────────────────────── + +describe('ERROR_REASON', () => { + test('is a frozen object', () => { + assert.ok(Object.isFrozen(io.ERROR_REASON)); + }); + + test('contains expected wire values', () => { + assert.strictEqual(io.ERROR_REASON.CONFIG_KEY_NOT_FOUND, 'config_key_not_found'); + assert.strictEqual(io.ERROR_REASON.CONFIG_NO_FILE, 'config_no_file'); + assert.strictEqual(io.ERROR_REASON.CONFIG_PARSE_FAILED, 'config_parse_failed'); + assert.strictEqual(io.ERROR_REASON.CONFIG_INVALID_KEY, 'config_invalid_key'); + assert.strictEqual(io.ERROR_REASON.SDK_FAIL_FAST, 'sdk_fail_fast'); + assert.strictEqual(io.ERROR_REASON.SDK_UNKNOWN_COMMAND, 'sdk_unknown_command'); + assert.strictEqual(io.ERROR_REASON.SDK_MISSING_ARG, 'sdk_missing_arg'); + assert.strictEqual(io.ERROR_REASON.PHASE_NOT_FOUND, 'phase_not_found'); + assert.strictEqual(io.ERROR_REASON.SUMMARY_NO_PLANNING, 'summary_no_planning'); + assert.strictEqual(io.ERROR_REASON.GRAPHIFY_NO_GRAPH, 'graphify_no_graph'); + assert.strictEqual(io.ERROR_REASON.GRAPHIFY_INVALID_QUERY, 'graphify_invalid_query'); + assert.strictEqual(io.ERROR_REASON.HOOKS_OPT_OUT, 'hooks_opt_out'); + assert.strictEqual(io.ERROR_REASON.SECURITY_SCAN_FAILED, 'security_scan_failed'); + assert.strictEqual(io.ERROR_REASON.USAGE, 'usage'); + assert.strictEqual(io.ERROR_REASON.UNKNOWN, 'unknown'); + }); +}); + +// ─── setJsonErrorMode / getJsonErrorMode ───────────────────────────────────── + +describe('setJsonErrorMode / getJsonErrorMode', () => { + // Reset to false after each test so other tests are unaffected + afterEach(() => { + io.setJsonErrorMode(false); + }); + + test('defaults to false', () => { + io.setJsonErrorMode(false); // ensure clean state + assert.strictEqual(io.getJsonErrorMode(), false); + }); + + test('setJsonErrorMode(true) enables JSON error mode', () => { + io.setJsonErrorMode(true); + assert.strictEqual(io.getJsonErrorMode(), true); + }); + + test('setJsonErrorMode(false) disables JSON error mode', () => { + io.setJsonErrorMode(true); + io.setJsonErrorMode(false); + assert.strictEqual(io.getJsonErrorMode(), false); + }); + + test('setJsonErrorMode coerces truthy values', () => { + io.setJsonErrorMode(1); + assert.strictEqual(io.getJsonErrorMode(), true); + io.setJsonErrorMode(0); + assert.strictEqual(io.getJsonErrorMode(), false); + }); + + test('setJsonErrorMode coerces string truthy', () => { + io.setJsonErrorMode('yes'); + assert.strictEqual(io.getJsonErrorMode(), true); + io.setJsonErrorMode(''); + assert.strictEqual(io.getJsonErrorMode(), false); + }); +}); + +// ─── output() ──────────────────────────────────────────────────────────────── + +// output() writes directly to fd 1 and never calls process.exit, so we can +// test it by spawning a child process and capturing its stdout. + +describe('output()', () => { + const ioPath = path.resolve(__dirname, '../gsd-core/bin/lib/io.cjs'); + + test('emits JSON-serialised result to stdout', () => { + const script = ` + const io = require(${JSON.stringify(ioPath)}); + io.output({ ok: true, value: 42 }, false); + `; + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf-8' }); + assert.strictEqual(result.status, 0, `process exited non-zero: ${result.stderr}`); + const parsed = JSON.parse(result.stdout); + assert.deepStrictEqual(parsed, { ok: true, value: 42 }); + }); + + test('emits raw string value when raw=true and rawValue provided', () => { + const script = ` + const io = require(${JSON.stringify(ioPath)}); + io.output({ ignored: true }, true, 'raw-text-output'); + `; + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf-8' }); + assert.strictEqual(result.status, 0, `process exited non-zero: ${result.stderr}`); + assert.strictEqual(result.stdout, 'raw-text-output'); + }); + + test('falls back to JSON when raw=true but rawValue is undefined', () => { + const script = ` + const io = require(${JSON.stringify(ioPath)}); + io.output({ fallback: true }, true); + `; + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf-8' }); + assert.strictEqual(result.status, 0, `process exited non-zero: ${result.stderr}`); + const parsed = JSON.parse(result.stdout); + assert.deepStrictEqual(parsed, { fallback: true }); + }); + + test('emits null correctly', () => { + const script = ` + const io = require(${JSON.stringify(ioPath)}); + io.output(null, false); + `; + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf-8' }); + assert.strictEqual(result.status, 0, `process exited non-zero: ${result.stderr}`); + assert.strictEqual(result.stdout, 'null'); + }); + + test('large payload (>50000 chars) spills to @file: tempfile', (t) => { + // Build a payload whose serialized JSON exceeds 50000 chars. + // A string of 60000 'x' chars serializes to 60002 chars ("x...x"). + const largeString = 'x'.repeat(60000); + const payload = { large: largeString }; + const serialized = JSON.stringify(payload, null, 2); + assert.ok(serialized.length > 50000, 'precondition: payload must exceed 50000 chars'); + + const tmpFilesCreated = []; + + t.after(() => { + for (const p of tmpFilesCreated) { + try { fs.unlinkSync(p); } catch { /* ignore */ } + } + }); + + const script = ` + const io = require(${JSON.stringify(ioPath)}); + const largeString = 'x'.repeat(60000); + io.output({ large: largeString }, false); + `; + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf-8' }); + assert.strictEqual(result.status, 0, `process exited non-zero: ${result.stderr}`); + + const stdout = result.stdout.trim(); + assert.ok(stdout.startsWith('@file:'), `expected stdout to start with "@file:", got: ${stdout.slice(0, 80)}`); + + const tmpPath = stdout.slice('@file:'.length); + tmpFilesCreated.push(tmpPath); + + assert.ok(fs.existsSync(tmpPath), `expected temp file to exist at: ${tmpPath}`); + + const fileContents = fs.readFileSync(tmpPath, 'utf-8'); + const parsed = JSON.parse(fileContents); + assert.deepStrictEqual(parsed, payload); + + fs.unlinkSync(tmpPath); + tmpFilesCreated.length = 0; // already cleaned, skip t.after + }); +}); + +// ─── error() ───────────────────────────────────────────────────────────────── + +describe('error()', () => { + const ioPath = path.resolve(__dirname, '../gsd-core/bin/lib/io.cjs'); + + test('plain-text mode: writes "Error: " to stderr and exits 1', () => { + const script = ` + const io = require(${JSON.stringify(ioPath)}); + io.setJsonErrorMode(false); + io.error('something went wrong'); + `; + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf-8' }); + assert.strictEqual(result.status, 1); + assert.ok(result.stderr.includes('Error: something went wrong'), `stderr was: ${result.stderr}`); + assert.strictEqual(result.stdout, ''); + }); + + test('plain-text mode: default reason does not appear in stderr text', () => { + const script = ` + const io = require(${JSON.stringify(ioPath)}); + io.setJsonErrorMode(false); + io.error('no reason code expected'); + `; + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf-8' }); + assert.strictEqual(result.status, 1); + // plain mode does NOT include the reason field + assert.ok(!result.stderr.includes('"reason"'), `stderr unexpectedly contained reason: ${result.stderr}`); + }); + + test('JSON-error mode: writes structured JSON to stderr and exits 1', () => { + const script = ` + const io = require(${JSON.stringify(ioPath)}); + io.setJsonErrorMode(true); + io.error('structured error', io.ERROR_REASON.SDK_FAIL_FAST); + `; + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf-8' }); + assert.strictEqual(result.status, 1); + assert.strictEqual(result.stdout, ''); + const payload = JSON.parse(result.stderr.trim()); + assert.strictEqual(payload.ok, false); + assert.strictEqual(payload.reason, 'sdk_fail_fast'); + assert.strictEqual(payload.message, 'structured error'); + }); + + test('JSON-error mode: defaults reason to UNKNOWN when not supplied', () => { + const script = ` + const io = require(${JSON.stringify(ioPath)}); + io.setJsonErrorMode(true); + io.error('no reason given'); + `; + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf-8' }); + assert.strictEqual(result.status, 1); + const payload = JSON.parse(result.stderr.trim()); + assert.strictEqual(payload.reason, 'unknown'); + assert.strictEqual(payload.message, 'no reason given'); + }); + + test('all ERROR_REASON values round-trip through JSON-error mode', () => { + // spot-check a few variants + const cases = [ + ['config_key_not_found', 'CONFIG_KEY_NOT_FOUND'], + ['phase_not_found', 'PHASE_NOT_FOUND'], + ['usage', 'USAGE'], + ]; + for (const [expected, key] of cases) { + const script = ` + const io = require(${JSON.stringify(ioPath)}); + io.setJsonErrorMode(true); + io.error('test', io.ERROR_REASON.${key}); + `; + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf-8' }); + assert.strictEqual(result.status, 1, `key=${key}`); + const payload = JSON.parse(result.stderr.trim()); + assert.strictEqual(payload.reason, expected, `key=${key}`); + } + }); +}); + +// ─── GSD_TEMP_DIR / reapStaleTempFiles ─────────────────────────────────────── + +describe('GSD_TEMP_DIR', () => { + test('resolves to /gsd', () => { + assert.strictEqual(io.GSD_TEMP_DIR, path.join(os.tmpdir(), 'gsd')); + }); +}); + +describe('reapStaleTempFiles (via io)', () => { + const TEST_PREFIX = 'gsd-io-test-'; + + afterEach(() => { + // clean up any test files we created + try { + const entries = fs.readdirSync(io.GSD_TEMP_DIR); + for (const e of entries) { + if (e.startsWith(TEST_PREFIX)) { + const p = path.join(io.GSD_TEMP_DIR, e); + try { fs.unlinkSync(p); } catch { /* ignore */ } + } + } + } catch { /* ignore */ } + }); + + test('removes stale files beyond maxAgeMs', () => { + fs.mkdirSync(io.GSD_TEMP_DIR, { recursive: true }); + const stalePath = path.join(io.GSD_TEMP_DIR, TEST_PREFIX + 'stale.json'); + fs.writeFileSync(stalePath, '{}'); + // backdate mtime so it looks older than 1ms + const old = new Date(Date.now() - 10000); + fs.utimesSync(stalePath, old, old); + + io.reapStaleTempFiles(TEST_PREFIX, { maxAgeMs: 5000 }); + assert.ok(!fs.existsSync(stalePath), 'stale file should have been removed'); + }); + + test('keeps fresh files within maxAgeMs', () => { + fs.mkdirSync(io.GSD_TEMP_DIR, { recursive: true }); + const freshPath = path.join(io.GSD_TEMP_DIR, TEST_PREFIX + 'fresh.json'); + fs.writeFileSync(freshPath, '{}'); + // mtime is just now — well within a 1-hour window + io.reapStaleTempFiles(TEST_PREFIX, { maxAgeMs: 60 * 60 * 1000 }); + assert.ok(fs.existsSync(freshPath), 'fresh file should have been kept'); + }); + + test('does not throw when GSD_TEMP_DIR does not exist yet', () => { + // reap against a non-existent prefix — must not throw + assert.doesNotThrow(() => { + io.reapStaleTempFiles('gsd-io-nonexistent-prefix-xyz-', { maxAgeMs: 0 }); + }); + }); +}); + +// ─── core.cjs re-export shim parity ────────────────────────────────────────── + +describe('core.cjs re-export shims', () => { + test('core.output is the same function as io.output', () => { + assert.strictEqual(core.output, io.output); + }); + + test('core.error is the same function as io.error', () => { + assert.strictEqual(core.error, io.error); + }); + + test('core.ERROR_REASON is the same object as io.ERROR_REASON', () => { + assert.strictEqual(core.ERROR_REASON, io.ERROR_REASON); + }); + + test('core.setJsonErrorMode is the same function as io.setJsonErrorMode', () => { + assert.strictEqual(core.setJsonErrorMode, io.setJsonErrorMode); + }); + + test('core.getJsonErrorMode is the same function as io.getJsonErrorMode', () => { + assert.strictEqual(core.getJsonErrorMode, io.getJsonErrorMode); + }); + + test('core.reapStaleTempFiles is the same function as io.reapStaleTempFiles', () => { + assert.strictEqual(core.reapStaleTempFiles, io.reapStaleTempFiles); + }); + + test('core.GSD_TEMP_DIR is the same value as io.GSD_TEMP_DIR', () => { + assert.strictEqual(core.GSD_TEMP_DIR, io.GSD_TEMP_DIR); + }); +});