ADR-857 rollout phase 1. Move the CLI I/O primitives — output(), error(), ERROR_REASON, setJsonErrorMode/getJsonErrorMode, and the output() large-payload temp-file spillover helpers (GSD_TEMP_DIR, ensureGsdTempDir, reapStaleTempFiles) — out of the 2271-line core.cts god-module into a new, small src/io.cts. core.cts re-exports them so existing consumers are unaffected (behavior-preserving). Repoint src/profile-pipeline.cts to import output/error/reapStaleTempFiles from io directly; graphify/intel/audit were verified not to import these symbols. Net: the leaf feature modules no longer depend on core just for I/O — the enabling first cut toward Capability extraction. New-CLI-module checklist: .gitignore (bin/lib/io.cjs), eslint.config.mjs ignores, INVENTORY.md count 90→91 + io.cjs row, INVENTORY-MANIFEST.json, ARCHITECTURE.md core.cjs/io.cjs rows, CONTEXT.md "I/O Module" glossary entry. Adds tests/io.test.cjs (28 behavioral tests incl. shim-identity and the @file: spillover branch). Gates: lint, code-review, security-review, codex adversarial-review, and gsd-test-both (14742 pass on Mac + Linux Docker, 0 fail) all green. Closes #859 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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/<stem>/` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-<stem>/` 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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 `<decisions>` 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 |
|
||||
|
||||
@@ -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',
|
||||
|
||||
150
src/core.cts
150
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: <message>" 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 ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
174
src/io.cts
Normal file
174
src/io.cts
Normal file
@@ -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: <message>" 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,
|
||||
};
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
341
tests/io.test.cjs
Normal file
341
tests/io.test.cjs
Normal file
@@ -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: <msg>" 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 <tmpdir>/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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user