From 5486152058901850a7dbef2de277d8732f213c0c Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 25 Feb 2026 20:24:24 -0800 Subject: [PATCH 1/6] feat: add API surface stability test and deprecation protocol (#206) - test/api-surface.test.js snapshots all 97 public exports (names + types) - CI fails if exports are added, removed, or change type without updating snapshot - CONTRIBUTING.md documents the deprecation protocol (JSDoc, runtime warning, semver) Refs #206 --- CONTRIBUTING.md | 31 ++++++++ test/api-surface.test.js | 160 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 test/api-surface.test.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b14ab65..12498c39 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,6 +83,37 @@ test/ hooks.test.js — Directive parsing tests ``` +## Public API & Deprecation Protocol + +git-mind's public API is everything exported from `src/index.js`. A stability +test (`test/api-surface.test.js`) snapshots every export name and type — CI +will fail if the surface changes without an intentional update. + +### Making changes to the public API + +| Change | Semver | Process | +|--------|--------|---------| +| **Add** an export | minor | Add to `API_SNAPSHOT` in `test/api-surface.test.js` | +| **Remove** an export | **major** | Follow the deprecation protocol below | +| **Change** an export's type/signature | **major** | Follow the deprecation protocol below | + +### Deprecation protocol + +1. **Mark deprecated** — Add `@deprecated` JSDoc tag with a migration note and + the target removal version (at least one minor release away): + ```js + /** @deprecated Use newFunction() instead. Removal: v6.0.0 */ + export function oldFunction() { ... } + ``` +2. **Runtime warning** — Emit a one-time `console.warn` on first call: + ``` + [git-mind] oldFunction() is deprecated — use newFunction(). Removal: v6.0.0 + ``` +3. **Keep in snapshot** — The export stays in `test/api-surface.test.js` until + the major version that removes it. +4. **Remove** — In the next major version, delete the export, remove it from + the snapshot, and document the removal in CHANGELOG.md. + ## License By contributing, you agree that your contributions will be licensed under [Apache-2.0](LICENSE). diff --git a/test/api-surface.test.js b/test/api-surface.test.js new file mode 100644 index 00000000..30c3ba3d --- /dev/null +++ b/test/api-surface.test.js @@ -0,0 +1,160 @@ +/** + * API Surface Stability Test (#206) + * + * This test IS the public API snapshot. If an export is removed or its type + * changes, this test fails — forcing an intentional review before any + * breaking change ships. + * + * Rules: + * - Removing an export → test fails → requires semver-major bump + * - Changing an export's type → test fails → requires semver-major bump + * - Adding a new export → test fails → update snapshot, semver-minor bump + */ + +import { describe, it, expect } from 'vitest'; +import * as api from '../src/index.js'; + +/** + * Canonical snapshot of the public API surface. + * Sorted alphabetically. Each entry: [name, expectedType]. + * + * Last updated: v5.0.0 + */ +const API_SNAPSHOT = [ + ['ALL_PREFIXES', 'object'], + ['CANONICAL_PREFIXES', 'object'], + ['CROSS_REPO_ID_REGEX', 'object'], + ['DEFAULT_CONTEXT', 'object'], + ['EDGE_TYPES', 'object'], + ['LOW_CONFIDENCE_THRESHOLD', 'number'], + ['NODE_ID_MAX_LENGTH', 'number'], + ['NODE_ID_REGEX', 'object'], + ['SYSTEM_PREFIXES', 'object'], + ['acceptSuggestion', 'function'], + ['adjustSuggestion', 'function'], + ['batchDecision', 'function'], + ['buildCrossRepoId', 'function'], + ['buildPrompt', 'function'], + ['callAgent', 'function'], + ['classifyPrefix', 'function'], + ['classifyStatus', 'function'], + ['composeLenses', 'function'], + ['computeDiff', 'function'], + ['computeStatus', 'function'], + ['createContext', 'function'], + ['createEdge', 'function'], + ['declareView', 'function'], + ['defineView', 'function'], + ['defineLens', 'function'], + ['deleteContent', 'function'], + ['detectDanglingEdges', 'function'], + ['detectLowConfidenceEdges', 'function'], + ['detectOrphanMilestones', 'function'], + ['detectOrphanNodes', 'function'], + ['detectRepoIdentifier', 'function'], + ['diffSnapshots', 'function'], + ['exportGraph', 'function'], + ['exportToFile', 'function'], + ['extractCommitContext', 'function'], + ['extractContext', 'function'], + ['extractFileContext', 'function'], + ['extractGraphContext', 'function'], + ['extractPrefix', 'function'], + ['extractRepo', 'function'], + ['filterRejected', 'function'], + ['fixIssues', 'function'], + ['formatSuggestionsAsMarkdown', 'function'], + ['generateSuggestions', 'function'], + ['getContentMeta', 'function'], + ['getCurrentTick', 'function'], + ['getEpochForRef', 'function'], + ['getExtension', 'function'], + ['getNode', 'function'], + ['getPendingSuggestions', 'function'], + ['getReviewHistory', 'function'], + ['hasContent', 'function'], + ['importData', 'function'], + ['importFile', 'function'], + ['importFromMarkdown', 'function'], + ['initGraph', 'function'], + ['isCrossRepoId', 'function'], + ['isLowConfidence', 'function'], + ['listExtensions', 'function'], + ['listLenses', 'function'], + ['listViews', 'function'], + ['loadExtension', 'function'], + ['loadGraph', 'function'], + ['lookupEpoch', 'function'], + ['lookupNearestEpoch', 'function'], + ['mergeFromRepo', 'function'], + ['parseCrossRepoId', 'function'], + ['parseDirectives', 'function'], + ['parseFrontmatter', 'function'], + ['parseImportFile', 'function'], + ['parseReviewCommand', 'function'], + ['parseSuggestions', 'function'], + ['processCommit', 'function'], + ['qualifyNodeId', 'function'], + ['readContent', 'function'], + ['recordEpoch', 'function'], + ['registerBuiltinExtensions', 'function'], + ['registerExtension', 'function'], + ['rejectSuggestion', 'function'], + ['removeEdge', 'function'], + ['removeExtension', 'function'], + ['renderView', 'function'], + ['resetExtensions', 'function'], + ['resetLenses', 'function'], + ['resetViews', 'function'], + ['runDoctor', 'function'], + ['serializeExport', 'function'], + ['setNodeProperty', 'function'], + ['skipSuggestion', 'function'], + ['unsetNodeProperty', 'function'], + ['validateConfidence', 'function'], + ['validateEdge', 'function'], + ['validateEdgeType', 'function'], + ['validateExtension', 'function'], + ['validateImportData', 'function'], + ['validateNodeId', 'function'], + ['writeContent', 'function'], +]; + +describe('API Surface Stability (#206)', () => { + const actualExports = Object.keys(api).sort(); + + it('exports exactly the expected names', () => { + const expectedNames = API_SNAPSHOT.map(([name]) => name).sort(); + const added = actualExports.filter(n => !expectedNames.includes(n)); + const removed = expectedNames.filter(n => !actualExports.includes(n)); + + if (added.length > 0 || removed.length > 0) { + const parts = []; + if (added.length) parts.push(`Added exports: ${added.join(', ')}`); + if (removed.length) parts.push(`Removed exports: ${removed.join(', ')}`); + expect.fail( + `API surface changed — update test/api-surface.test.js\n${parts.join('\n')}\n` + + 'See CONTRIBUTING.md § Deprecation Protocol for the process.' + ); + } + }); + + it('each export has the expected type', () => { + const mismatches = []; + for (const [name, expectedType] of API_SNAPSHOT) { + const actual = typeof api[name]; + if (actual !== expectedType) { + mismatches.push(`${name}: expected ${expectedType}, got ${actual}`); + } + } + if (mismatches.length > 0) { + expect.fail( + `API type changes detected — this is a breaking change:\n${mismatches.join('\n')}` + ); + } + }); + + it('snapshot count matches actual export count', () => { + expect(actualExports.length).toBe(API_SNAPSHOT.length); + }); +}); From 7b9888c4d3dd96cd8e67cdb31b6b4bbeb9fd4cdd Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 25 Feb 2026 20:29:15 -0800 Subject: [PATCH 2/6] feat: add error taxonomy with GMIND_E_* codes and exit codes (#207) - src/errors.js: GmindError class, ERROR_CATALOG (13 codes), ExitCode enum - Exit codes: 0=success, 1=general, 2=usage, 3=validation, 4=not-found - CLI error boundary outputs structured JSON in --json mode: { error, errorCode, exitCode, hint, schemaVersion, command } - Converted all usage-error and not-found paths in bin/git-mind.js and src/cli/commands.js to throw GmindError with proper codes - GmindError, ExitCode, ERROR_CATALOG exported from public API - 12 unit tests for error module, API snapshot updated (100 exports) - 594 tests passing across 31 files Refs #207 --- bin/git-mind.js | 82 ++++++++++++++++++------------ src/cli/commands.js | 95 ++++++++++++++++++++++++----------- src/errors.js | 96 +++++++++++++++++++++++++++++++++++ src/index.js | 1 + test/api-surface.test.js | 3 ++ test/errors.test.js | 106 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 320 insertions(+), 63 deletions(-) create mode 100644 src/errors.js create mode 100644 test/errors.test.js diff --git a/bin/git-mind.js b/bin/git-mind.js index f76b6ce1..6e367147 100755 --- a/bin/git-mind.js +++ b/bin/git-mind.js @@ -9,6 +9,7 @@ import { init, link, view, list, remove, nodes, status, at, importCmd, importMar import { parseDiffRefs, collectDiffPositionals } from '../src/diff.js'; import { createContext } from '../src/context-envelope.js'; import { registerBuiltinExtensions } from '../src/extension.js'; +import { GmindError } from '../src/errors.js'; const args = process.argv.slice(2); const command = args[0]; @@ -114,6 +115,28 @@ Edge types: implements, augments, relates-to, blocks, belongs-to, const BOOLEAN_FLAGS = new Set(['json', 'fix', 'dry-run', 'validate', 'raw']); +/** + * Handle a GmindError at the CLI boundary. + * Sets exit code and outputs structured JSON when --json is active. + * Falls back to plain stderr for non-GmindError exceptions. + * + * @param {Error} err + * @param {{ json?: boolean }} [opts] + */ +function handleError(err, opts = {}) { + if (err instanceof GmindError) { + if (opts.json) { + console.log(JSON.stringify({ ...err.toJSON(), schemaVersion: 1, command: 'error' }, null, 2)); + } else { + console.error(err.message); + } + process.exitCode = err.exitCode; + } else { + console.error(err.message ?? String(err)); + process.exitCode = 1; + } +} + /** * Extract a ContextEnvelope from parsed flags. * Builds one only when context flags are present; otherwise returns null. @@ -179,8 +202,7 @@ switch (command) { const source = args[1]; const target = args[2]; if (!source || !target) { - console.error('Usage: git mind link [--type ]'); - process.exitCode = 1; + handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind link [--type ]')); break; } const flags = parseFlags(args.slice(3)); @@ -209,8 +231,7 @@ switch (command) { const rmSource = args[1]; const rmTarget = args[2]; if (!rmSource || !rmTarget) { - console.error('Usage: git mind remove [--type ]'); - process.exitCode = 1; + handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind remove [--type ]')); break; } const rmFlags = parseFlags(args.slice(3)); @@ -246,9 +267,9 @@ switch (command) { const setKey = args[2]; const setValue = args[3]; if (!setNodeId || !setKey || setValue === undefined || setValue.startsWith('--')) { - console.error('Usage: git mind set [--json]'); - console.error(' is positional and required (flags are not valid values)'); - process.exitCode = 1; + handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind set [--json]', { + hint: ' is positional and required (flags are not valid values)', + }), { json: args.includes('--json') }); break; } await set(cwd, setNodeId, setKey, setValue, { json: args.includes('--json') }); @@ -259,8 +280,7 @@ switch (command) { const unsetNodeId = args[1]; const unsetKey = args[2]; if (!unsetNodeId || !unsetKey) { - console.error('Usage: git mind unset '); - process.exitCode = 1; + handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind unset '), { json: args.includes('--json') }); break; } await unsetCmd(cwd, unsetNodeId, unsetKey, { json: args.includes('--json') }); @@ -280,8 +300,7 @@ switch (command) { case 'at': { const atRef = args[1]; if (!atRef || atRef.startsWith('--')) { - console.error('Usage: git mind at '); - process.exitCode = 1; + handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind at '), { json: args.includes('--json') }); break; } await at(cwd, atRef, { json: args.includes('--json') }); @@ -298,8 +317,10 @@ switch (command) { prefix: diffFlags.prefix, }); } catch (err) { - console.error(err.message); - process.exitCode = 1; + handleError( + err instanceof GmindError ? err : new GmindError('GMIND_E_USAGE', err.message, { cause: err }), + { json: diffFlags.json }, + ); } break; } @@ -316,8 +337,7 @@ switch (command) { const importPath = args.slice(1).find(a => !a.startsWith('--')); if (!importPath) { - console.error('Usage: git mind import [--dry-run] [--json] [--from-markdown ]'); - process.exitCode = 1; + handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind import [--dry-run] [--json] [--from-markdown ]'), { json: jsonMode }); break; } await importCmd(cwd, importPath, { dryRun, json: jsonMode }); @@ -355,8 +375,7 @@ switch (command) { case 'process-commit': if (!args[1]) { - console.error('Usage: git mind process-commit '); - process.exitCode = 1; + handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind process-commit ')); break; } await processCommitCmd(cwd, args[1]); @@ -403,8 +422,7 @@ switch (command) { const setNode = contentPositionals[0]; const fromFile = contentFlags.from; if (!setNode || !fromFile) { - console.error('Usage: git mind content set --from [--mime ] [--json]'); - process.exitCode = 1; + handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind content set --from [--mime ] [--json]'), { json: contentFlags.json }); break; } await contentSet(cwd, setNode, fromFile, { @@ -416,8 +434,7 @@ switch (command) { case 'show': { const showNode = contentPositionals[0]; if (!showNode) { - console.error('Usage: git mind content show [--raw] [--json]'); - process.exitCode = 1; + handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind content show [--raw] [--json]'), { json: contentFlags.json }); break; } await contentShow(cwd, showNode, { @@ -429,8 +446,7 @@ switch (command) { case 'meta': { const metaNode = contentPositionals[0]; if (!metaNode) { - console.error('Usage: git mind content meta [--json]'); - process.exitCode = 1; + handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind content meta [--json]'), { json: contentFlags.json }); break; } await contentMeta(cwd, metaNode, { json: contentFlags.json ?? false }); @@ -439,17 +455,16 @@ switch (command) { case 'delete': { const deleteNode = contentPositionals[0]; if (!deleteNode) { - console.error('Usage: git mind content delete [--json]'); - process.exitCode = 1; + handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind content delete [--json]'), { json: contentFlags.json }); break; } await contentDelete(cwd, deleteNode, { json: contentFlags.json ?? false }); break; } default: - console.error(`Unknown content subcommand: ${contentSubCmd ?? '(none)'}`); - console.error('Usage: git mind content '); - process.exitCode = 1; + handleError(new GmindError('GMIND_E_UNKNOWN_CMD', `Unknown content subcommand: ${contentSubCmd ?? '(none)'}`, { + hint: 'Usage: git mind content ', + })); } break; } @@ -478,9 +493,9 @@ switch (command) { break; } default: - console.error(`Unknown extension subcommand: ${subCmd ?? '(none)'}`); - console.error('Usage: git mind extension '); - process.exitCode = 1; + handleError(new GmindError('GMIND_E_UNKNOWN_CMD', `Unknown extension subcommand: ${subCmd ?? '(none)'}`, { + hint: 'Usage: git mind extension ', + })); } break; } @@ -493,9 +508,10 @@ switch (command) { default: if (command) { - console.error(`Unknown command: ${command}\n`); + handleError(new GmindError('GMIND_E_UNKNOWN_CMD', `Unknown command: ${command}`)); + console.error(''); } printUsage(); - process.exitCode = command ? 1 : 0; + if (!command) process.exitCode = 0; break; } diff --git a/src/cli/commands.js b/src/cli/commands.js index ccaa1a1c..c44cde1d 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -27,6 +27,7 @@ import { DEFAULT_CONTEXT } from '../context-envelope.js'; import { loadExtension, registerExtension, removeExtension, listExtensions, validateExtension } from '../extension.js'; import { writeContent, readContent, getContentMeta, deleteContent } from '../content.js'; import { success, error, info, formatEdge, formatView, formatNode, formatNodeList, formatStatus, formatExportResult, formatImportResult, formatDoctorResult, formatSuggestions, formatReviewItem, formatDecisionSummary, formatAtStatus, formatDiff, formatExtensionList, formatContentMeta } from './format.js'; +import { GmindError } from '../errors.js'; /** * Write structured JSON to stdout with schemaVersion and command fields. @@ -40,6 +41,32 @@ function outputJson(command, data) { console.log(JSON.stringify(out, null, 2)); } +/** + * Handle an error in a CLI command. In --json mode, emits a structured + * error envelope. Otherwise, prints to stderr via format.error(). + * + * @param {Error} err + * @param {{ json?: boolean }} [opts] + */ +function handleCommandError(err, opts = {}) { + if (err instanceof GmindError) { + if (opts.json) { + outputJson('error', err.toJSON()); + } else { + console.error(error(err.message)); + } + process.exitCode = err.exitCode; + } else { + if (opts.json) { + const wrapped = new GmindError('GMIND_E_INTERNAL', err.message, { cause: err }); + outputJson('error', wrapped.toJSON()); + } else { + console.error(error(err.message)); + } + process.exitCode = 1; + } +} + /** * Resolve a ContextEnvelope to a live graph-like object. * @@ -69,9 +96,9 @@ export async function resolveContext(cwd, envelope) { const resolver = await initGraph(cwd, { writerId: 'ctx-resolver' }); const result = await getEpochForRef(resolver, cwd, asOf); if (!result) { - throw new Error( - `No epoch marker found for "${asOf}" or any ancestor. ` + - `Run "git mind process-commit" to record epoch markers.`, + throw new GmindError('GMIND_E_NOT_FOUND', + `No epoch marker found for "${asOf}" or any ancestor`, + { hint: 'Run "git mind process-commit" to record epoch markers' }, ); } resolvedTick = result.epoch.tick; @@ -85,9 +112,9 @@ export async function resolveContext(cwd, envelope) { const observerId = `observer:${observer}`; const propsMap = await graph.getNodeProps(observerId); if (!propsMap) { - throw new Error( - `Observer '${observer}' not found. ` + - `Define it with: git mind set observer:${observer} match 'prefix:*'`, + throw new GmindError('GMIND_E_NOT_FOUND', + `Observer '${observer}' not found`, + { hint: `Define it with: git mind set observer:${observer} match 'prefix:*'` }, ); } const config = { match: propsMap.get('match') }; @@ -333,8 +360,10 @@ export async function nodes(cwd, opts = {}) { if (opts.id) { const node = await getNode(graph, opts.id); if (!node) { - console.error(error(`Node not found: ${opts.id}`)); - process.exitCode = 1; + handleCommandError( + new GmindError('GMIND_E_NODE_NOT_FOUND', `Node not found: ${opts.id}`), + { json: opts.json }, + ); return; } if (opts.json) { @@ -399,8 +428,7 @@ export async function status(cwd, opts = {}) { */ export async function at(cwd, ref, opts = {}) { if (!ref) { - console.error(error('Usage: git mind at ')); - process.exitCode = 1; + handleCommandError(new GmindError('GMIND_E_USAGE', 'Usage: git mind at '), { json: opts.json }); return; } @@ -409,8 +437,12 @@ export async function at(cwd, ref, opts = {}) { const result = await getEpochForRef(graph, cwd, ref); if (!result) { - console.error(error(`No epoch marker found for "${ref}" or any of its ancestors`)); - process.exitCode = 1; + handleCommandError( + new GmindError('GMIND_E_NOT_FOUND', `No epoch marker found for "${ref}" or any of its ancestors`, { + hint: 'Run "git mind process-commit" to record epoch markers', + }), + { json: opts.json }, + ); return; } @@ -535,8 +567,10 @@ export async function exportCmd(cwd, opts = {}) { */ export async function mergeCmd(cwd, opts = {}) { if (!opts.from) { - console.error(error('Usage: git mind merge --from [--repo-name ]')); - process.exitCode = 1; + handleCommandError( + new GmindError('GMIND_E_USAGE', 'Usage: git mind merge --from [--repo-name ]'), + { json: opts.json }, + ); return; } @@ -630,8 +664,10 @@ export async function review(cwd, opts = {}) { // Batch mode if (opts.batch) { if (opts.batch !== 'accept' && opts.batch !== 'reject') { - console.error(error('--batch must be "accept" or "reject"')); - process.exitCode = 1; + handleCommandError( + new GmindError('GMIND_E_USAGE', '--batch must be "accept" or "reject"'), + { json: opts.json }, + ); return; } @@ -639,14 +675,18 @@ export async function review(cwd, opts = {}) { if (opts.index !== undefined) { const pending = await getPendingSuggestions(graph); if (pending.length === 0) { - console.error(error('No pending suggestions to review.')); - process.exitCode = 1; + handleCommandError( + new GmindError('GMIND_E_NOT_FOUND', 'No pending suggestions to review'), + { json: opts.json }, + ); return; } const idx = opts.index - 1; // 1-indexed to 0-indexed if (idx < 0 || idx >= pending.length) { - console.error(error(`Index ${opts.index} out of range (1-${pending.length})`)); - process.exitCode = 1; + handleCommandError( + new GmindError('GMIND_E_USAGE', `Index ${opts.index} out of range (1-${pending.length})`), + { json: opts.json }, + ); return; } const suggestion = pending[idx]; @@ -737,8 +777,7 @@ export async function review(cwd, opts = {}) { */ export async function set(cwd, nodeId, key, value, opts = {}) { if (!nodeId || !key || value === undefined) { - console.error(error('Usage: git mind set ')); - process.exitCode = 1; + handleCommandError(new GmindError('GMIND_E_USAGE', 'Usage: git mind set '), { json: opts.json }); return; } @@ -770,8 +809,7 @@ export async function set(cwd, nodeId, key, value, opts = {}) { */ export async function unsetCmd(cwd, nodeId, key, opts = {}) { if (!nodeId || !key) { - console.error(error('Usage: git mind unset ')); - process.exitCode = 1; + handleCommandError(new GmindError('GMIND_E_USAGE', 'Usage: git mind unset '), { json: opts.json }); return; } @@ -981,8 +1019,7 @@ export function extensionList(_cwd, opts = {}) { */ export async function extensionValidate(_cwd, manifestPath, opts = {}) { if (!manifestPath) { - console.error(error('Usage: git mind extension validate ')); - process.exitCode = 1; + handleCommandError(new GmindError('GMIND_E_USAGE', 'Usage: git mind extension validate '), { json: opts.json }); return; } const result = await validateExtension(manifestPath); @@ -1006,8 +1043,7 @@ export async function extensionValidate(_cwd, manifestPath, opts = {}) { */ export async function extensionAdd(_cwd, manifestPath, opts = {}) { if (!manifestPath) { - console.error(error('Usage: git mind extension add ')); - process.exitCode = 1; + handleCommandError(new GmindError('GMIND_E_USAGE', 'Usage: git mind extension add '), { json: opts.json }); return; } try { @@ -1043,8 +1079,7 @@ export async function extensionAdd(_cwd, manifestPath, opts = {}) { */ export function extensionRemove(_cwd, name, opts = {}) { if (!name) { - console.error(error('Usage: git mind extension remove ')); - process.exitCode = 1; + handleCommandError(new GmindError('GMIND_E_USAGE', 'Usage: git mind extension remove '), { json: opts.json }); return; } try { diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 00000000..d1ce89c5 --- /dev/null +++ b/src/errors.js @@ -0,0 +1,96 @@ +/** + * @module errors + * Structured error taxonomy for git-mind (#207). + * + * Every user-facing error should be a GmindError with a code from the + * catalog. The CLI boundary uses the code to select the right exit code + * and, in --json mode, to emit a structured error envelope. + */ + +// ── Exit codes ────────────────────────────────────────────────── + +/** @enum {number} */ +export const ExitCode = Object.freeze({ + SUCCESS: 0, + GENERAL: 1, // catch-all / internal + USAGE: 2, // bad flags, missing args, unknown command + VALIDATION: 3, // schema / data validation failure + NOT_FOUND: 4, // node, edge, epoch, observer, content +}); + +// ── Error codes ───────────────────────────────────────────────── + +/** + * Error catalog. Each entry maps a GMIND_E_* code to its default + * exit code and a short human hint. + * + * @type {Record} + */ +export const ERROR_CATALOG = Object.freeze({ + // Usage + GMIND_E_USAGE: { exit: ExitCode.USAGE, hint: 'Check command usage with: git mind help' }, + GMIND_E_UNKNOWN_CMD: { exit: ExitCode.USAGE, hint: 'Run "git mind help" to see available commands' }, + + // Lifecycle + GMIND_E_NOT_INITIALIZED: { exit: ExitCode.GENERAL, hint: 'Run "git mind init" first' }, + + // Identity / validation + GMIND_E_INVALID_ID: { exit: ExitCode.VALIDATION, hint: 'Node IDs must match prefix:identifier format' }, + GMIND_E_INVALID_EDGE: { exit: ExitCode.VALIDATION, hint: 'See GRAPH_SCHEMA.md for valid edge types' }, + GMIND_E_VALIDATION: { exit: ExitCode.VALIDATION, hint: 'Check input data against the expected schema' }, + + // Not found + GMIND_E_NODE_NOT_FOUND: { exit: ExitCode.NOT_FOUND, hint: 'Use "git mind nodes" to list existing nodes' }, + GMIND_E_NOT_FOUND: { exit: ExitCode.NOT_FOUND, hint: 'The requested resource does not exist' }, + + // Domain + GMIND_E_IMPORT: { exit: ExitCode.GENERAL, hint: 'Check file format and run with --dry-run first' }, + GMIND_E_EXPORT: { exit: ExitCode.GENERAL, hint: 'Check file permissions and disk space' }, + GMIND_E_EXTENSION: { exit: ExitCode.GENERAL, hint: 'Run "git mind extension validate " to diagnose' }, + GMIND_E_CONTENT: { exit: ExitCode.GENERAL, hint: 'Check that the node exists and has content attached' }, + + // Catch-all + GMIND_E_INTERNAL: { exit: ExitCode.GENERAL, hint: 'This is a bug — please file an issue' }, +}); + +// ── GmindError class ──────────────────────────────────────────── + +export class GmindError extends Error { + /** + * @param {string} code - A GMIND_E_* constant from ERROR_CATALOG + * @param {string} message - Human-readable error description + * @param {object} [opts] + * @param {string} [opts.hint] - Override the catalog hint + * @param {number} [opts.exitCode] - Override the catalog exit code + * @param {Error} [opts.cause] - Root cause for chaining + */ + constructor(code, message, opts = {}) { + super(message, opts.cause ? { cause: opts.cause } : undefined); + this.name = 'GmindError'; + + const entry = ERROR_CATALOG[code]; + if (!entry) { + // Unknown code — fall back to INTERNAL + this.code = 'GMIND_E_INTERNAL'; + this.exitCode = ExitCode.GENERAL; + this.hint = `Unknown error code: ${code}`; + } else { + this.code = code; + this.exitCode = opts.exitCode ?? entry.exit; + this.hint = opts.hint ?? entry.hint; + } + } + + /** + * Structured envelope for --json error output. + * @returns {{ error: string, errorCode: string, exitCode: number, hint: string }} + */ + toJSON() { + return { + error: this.message, + errorCode: this.code, + exitCode: this.exitCode, + hint: this.hint, + }; + } +} diff --git a/src/index.js b/src/index.js index bdff0fc2..1c7d06f3 100644 --- a/src/index.js +++ b/src/index.js @@ -51,3 +51,4 @@ export { export { writeContent, readContent, getContentMeta, hasContent, deleteContent, } from './content.js'; +export { GmindError, ExitCode, ERROR_CATALOG } from './errors.js'; diff --git a/test/api-surface.test.js b/test/api-surface.test.js index 30c3ba3d..d5e0cd46 100644 --- a/test/api-surface.test.js +++ b/test/api-surface.test.js @@ -26,6 +26,8 @@ const API_SNAPSHOT = [ ['CROSS_REPO_ID_REGEX', 'object'], ['DEFAULT_CONTEXT', 'object'], ['EDGE_TYPES', 'object'], + ['ERROR_CATALOG', 'object'], + ['ExitCode', 'object'], ['LOW_CONFIDENCE_THRESHOLD', 'number'], ['NODE_ID_MAX_LENGTH', 'number'], ['NODE_ID_REGEX', 'object'], @@ -64,6 +66,7 @@ const API_SNAPSHOT = [ ['filterRejected', 'function'], ['fixIssues', 'function'], ['formatSuggestionsAsMarkdown', 'function'], + ['GmindError', 'function'], ['generateSuggestions', 'function'], ['getContentMeta', 'function'], ['getCurrentTick', 'function'], diff --git a/test/errors.test.js b/test/errors.test.js new file mode 100644 index 00000000..41c389df --- /dev/null +++ b/test/errors.test.js @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { GmindError, ExitCode, ERROR_CATALOG } from '../src/errors.js'; + +describe('GmindError (#207)', () => { + it('carries code, exitCode, hint, and message', () => { + const err = new GmindError('GMIND_E_NODE_NOT_FOUND', 'Node task:X not found'); + expect(err.message).toBe('Node task:X not found'); + expect(err.code).toBe('GMIND_E_NODE_NOT_FOUND'); + expect(err.exitCode).toBe(ExitCode.NOT_FOUND); + expect(err.hint).toBe('Use "git mind nodes" to list existing nodes'); + expect(err.name).toBe('GmindError'); + expect(err).toBeInstanceOf(Error); + }); + + it('allows hint override', () => { + const err = new GmindError('GMIND_E_USAGE', 'bad flag', { hint: 'Try --help' }); + expect(err.hint).toBe('Try --help'); + expect(err.exitCode).toBe(ExitCode.USAGE); + }); + + it('allows exitCode override', () => { + const err = new GmindError('GMIND_E_INTERNAL', 'boom', { exitCode: 99 }); + expect(err.exitCode).toBe(99); + }); + + it('chains cause', () => { + const root = new Error('disk full'); + const err = new GmindError('GMIND_E_EXPORT', 'export failed', { cause: root }); + expect(err.cause).toBe(root); + }); + + it('falls back to INTERNAL for unknown codes', () => { + const err = new GmindError('GMIND_E_UNKNOWN_BOGUS', 'wat'); + expect(err.code).toBe('GMIND_E_INTERNAL'); + expect(err.exitCode).toBe(ExitCode.GENERAL); + expect(err.hint).toContain('Unknown error code'); + }); + + it('toJSON() returns structured envelope', () => { + const err = new GmindError('GMIND_E_VALIDATION', 'bad schema'); + const json = err.toJSON(); + expect(json).toEqual({ + error: 'bad schema', + errorCode: 'GMIND_E_VALIDATION', + exitCode: ExitCode.VALIDATION, + hint: 'Check input data against the expected schema', + }); + }); + + it('is serializable via JSON.stringify', () => { + const err = new GmindError('GMIND_E_NOT_FOUND', 'missing'); + const parsed = JSON.parse(JSON.stringify(err)); + expect(parsed.errorCode).toBe('GMIND_E_NOT_FOUND'); + expect(parsed.error).toBe('missing'); + }); +}); + +describe('ExitCode', () => { + it('defines expected values', () => { + expect(ExitCode.SUCCESS).toBe(0); + expect(ExitCode.GENERAL).toBe(1); + expect(ExitCode.USAGE).toBe(2); + expect(ExitCode.VALIDATION).toBe(3); + expect(ExitCode.NOT_FOUND).toBe(4); + }); + + it('is frozen', () => { + expect(Object.isFrozen(ExitCode)).toBe(true); + }); +}); + +describe('ERROR_CATALOG', () => { + it('every entry has exit and hint', () => { + for (const [code, entry] of Object.entries(ERROR_CATALOG)) { + expect(typeof entry.exit).toBe('number'); + expect(typeof entry.hint).toBe('string'); + expect(entry.hint.length).toBeGreaterThan(0); + expect(code.startsWith('GMIND_E_')).toBe(true); + } + }); + + it('is frozen', () => { + expect(Object.isFrozen(ERROR_CATALOG)).toBe(true); + }); + + it('has entries for all documented error codes', () => { + const expected = [ + 'GMIND_E_USAGE', + 'GMIND_E_UNKNOWN_CMD', + 'GMIND_E_NOT_INITIALIZED', + 'GMIND_E_INVALID_ID', + 'GMIND_E_INVALID_EDGE', + 'GMIND_E_VALIDATION', + 'GMIND_E_NODE_NOT_FOUND', + 'GMIND_E_NOT_FOUND', + 'GMIND_E_IMPORT', + 'GMIND_E_EXPORT', + 'GMIND_E_EXTENSION', + 'GMIND_E_CONTENT', + 'GMIND_E_INTERNAL', + ]; + for (const code of expected) { + expect(ERROR_CATALOG).toHaveProperty(code); + } + }); +}); From c3add2f0102d6a6a82f5fabd5872e78bc3388787 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 25 Feb 2026 20:29:47 -0800 Subject: [PATCH 3/6] chore: bump version to 5.1.0, update CHANGELOG (#206, #207) Refs #206, #207 --- CHANGELOG.md | 12 ++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ffd058d..91d3476d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.1.0] - 2026-02-25 + +### Added + +- **API surface stability test** — `test/api-surface.test.js` snapshots all 100 public exports (names + types); CI fails on any undocumented API change (#206) +- **Deprecation protocol** — CONTRIBUTING.md documents the process for deprecating and removing public API exports (#206) +- **Error taxonomy** — `src/errors.js` with `GmindError` class, 13 `GMIND_E_*` error codes, and `ExitCode` enum (#207) +- **Structured exit codes** — 0=success, 1=general, 2=usage, 3=validation, 4=not-found (previously all errors were exit 1) (#207) +- **JSON error envelopes** — `--json` mode now outputs `{ error, errorCode, exitCode, hint }` for usage and not-found errors (#207) +- **Public API exports** — `GmindError`, `ExitCode`, `ERROR_CATALOG` exported from `src/index.js` (#207) + ## [5.0.0] - 2026-02-25 ### Breaking @@ -371,6 +382,7 @@ Complete rewrite from C23 to Node.js on `@git-stunts/git-warp`. - Docker-based CI/CD - All C-specific documentation +[5.1.0]: https://github.com/neuroglyph/git-mind/releases/tag/v5.1.0 [5.0.0]: https://github.com/neuroglyph/git-mind/releases/tag/v5.0.0 [4.0.1]: https://github.com/neuroglyph/git-mind/releases/tag/v4.0.1 [4.0.0]: https://github.com/neuroglyph/git-mind/releases/tag/v4.0.0 diff --git a/package-lock.json b/package-lock.json index d18a6fad..88ac1297 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuroglyph/git-mind", - "version": "5.0.0", + "version": "5.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuroglyph/git-mind", - "version": "5.0.0", + "version": "5.1.0", "license": "Apache-2.0", "dependencies": { "@git-stunts/git-warp": "^11.5.0", diff --git a/package.json b/package.json index 03136bf7..4beeffaa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@neuroglyph/git-mind", - "version": "5.0.0", + "version": "5.1.0", "description": "A project knowledge graph tool built on git-warp", "type": "module", "license": "Apache-2.0", From a9322e8302018b70c569a4f5eb86bb11eb8bcbdf Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 25 Feb 2026 20:50:30 -0800 Subject: [PATCH 4/6] fix: propagate error taxonomy through CLI boundary (#207) - Pass --json flag to handleError for unknown command/subcommand handlers - Replace generic catch blocks with handleCommandError in resolveContext commands (view, nodes, status, export, doctor) so GmindError exit codes propagate correctly (e.g. exit 4 for NOT_FOUND instead of exit 1) - Suppress usage dump when --json is active for unknown commands - Add error-propagation integration tests (9 tests) --- bin/git-mind.js | 14 ++-- src/cli/commands.js | 15 ++-- test/error-propagation.test.js | 123 +++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 16 deletions(-) create mode 100644 test/error-propagation.test.js diff --git a/bin/git-mind.js b/bin/git-mind.js index 6e367147..6305e938 100755 --- a/bin/git-mind.js +++ b/bin/git-mind.js @@ -464,7 +464,7 @@ switch (command) { default: handleError(new GmindError('GMIND_E_UNKNOWN_CMD', `Unknown content subcommand: ${contentSubCmd ?? '(none)'}`, { hint: 'Usage: git mind content ', - })); + }), { json: contentFlags.json ?? false }); } break; } @@ -495,7 +495,7 @@ switch (command) { default: handleError(new GmindError('GMIND_E_UNKNOWN_CMD', `Unknown extension subcommand: ${subCmd ?? '(none)'}`, { hint: 'Usage: git mind extension ', - })); + }), { json: extFlags.json ?? false }); } break; } @@ -506,12 +506,14 @@ switch (command) { printUsage(); break; - default: + default: { + const jsonMode = args.includes('--json'); if (command) { - handleError(new GmindError('GMIND_E_UNKNOWN_CMD', `Unknown command: ${command}`)); - console.error(''); + handleError(new GmindError('GMIND_E_UNKNOWN_CMD', `Unknown command: ${command}`), { json: jsonMode }); + if (!jsonMode) console.error(''); } - printUsage(); + if (!jsonMode) printUsage(); if (!command) process.exitCode = 0; break; + } } diff --git a/src/cli/commands.js b/src/cli/commands.js index c44cde1d..7475f83e 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -223,8 +223,7 @@ export async function view(cwd, viewSpec, opts = {}) { console.log(formatView(displayName, result)); } } catch (err) { - console.error(error(err.message)); - process.exitCode = 1; + handleCommandError(err, { json: opts.json }); } } @@ -393,8 +392,7 @@ export async function nodes(cwd, opts = {}) { console.log(info(`${nodeList.length} node(s):`)); console.log(formatNodeList(nodeList)); } catch (err) { - console.error(error(err.message)); - process.exitCode = 1; + handleCommandError(err, { json: opts.json }); } } @@ -415,8 +413,7 @@ export async function status(cwd, opts = {}) { console.log(formatStatus(result)); } } catch (err) { - console.error(error(err.message)); - process.exitCode = 1; + handleCommandError(err, { json: opts.json }); } } @@ -555,8 +552,7 @@ export async function exportCmd(cwd, opts = {}) { } } } catch (err) { - console.error(error(err.message)); - process.exitCode = 1; + handleCommandError(err, { json: opts.json }); } } @@ -623,8 +619,7 @@ export async function doctor(cwd, opts = {}) { process.exitCode = 1; } } catch (err) { - console.error(error(err.message)); - process.exitCode = 1; + handleCommandError(err, { json: opts.json }); } } diff --git a/test/error-propagation.test.js b/test/error-propagation.test.js new file mode 100644 index 00000000..495d3198 --- /dev/null +++ b/test/error-propagation.test.js @@ -0,0 +1,123 @@ +/** + * @module test/error-propagation + * Tests that GmindError exit codes and --json envelopes propagate correctly + * through the CLI boundary (#207). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync, spawnSync } from 'node:child_process'; + +const BIN = join(import.meta.dirname, '..', 'bin', 'git-mind.js'); + +/** Run CLI and return { status, stdout, stderr }. Does NOT throw on failure. */ +function runCli(args, cwd) { + const result = spawnSync(process.execPath, [BIN, ...args], { + cwd, + encoding: 'utf-8', + timeout: 30_000, + env: { ...process.env, NO_COLOR: '1' }, + }); + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + }; +} + +async function setupGitRepo() { + const dir = await mkdtemp(join(tmpdir(), 'gitmind-errprop-')); + execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' }); + return dir; +} + +describe('unknown command --json error envelope (#207)', () => { + let tempDir; + + beforeEach(async () => { + tempDir = await setupGitRepo(); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('git mind --json outputs a structured error envelope', () => { + const { status, stdout } = runCli(['boguscmd', '--json'], tempDir); + expect(status).toBe(2); // ExitCode.USAGE + const parsed = JSON.parse(stdout); + expect(parsed.errorCode).toBe('GMIND_E_UNKNOWN_CMD'); + expect(parsed.exitCode).toBe(2); + expect(parsed.error).toMatch(/Unknown command/); + expect(parsed.schemaVersion).toBe(1); + }); + + it('git mind without --json outputs plain text to stderr', () => { + const { status, stderr } = runCli(['boguscmd'], tempDir); + expect(status).toBe(2); // ExitCode.USAGE + expect(stderr).toMatch(/Unknown command/); + }); + + it('git mind content --json outputs structured envelope', () => { + const { status, stdout } = runCli(['content', 'bogussub', '--json'], tempDir); + expect(status).toBe(2); + const parsed = JSON.parse(stdout); + expect(parsed.errorCode).toBe('GMIND_E_UNKNOWN_CMD'); + expect(parsed.error).toMatch(/Unknown content subcommand/); + }); + + it('git mind extension --json outputs structured envelope', () => { + const { status, stdout } = runCli(['extension', 'bogussub', '--json'], tempDir); + expect(status).toBe(2); + const parsed = JSON.parse(stdout); + expect(parsed.errorCode).toBe('GMIND_E_UNKNOWN_CMD'); + expect(parsed.error).toMatch(/Unknown extension subcommand/); + }); +}); + +describe('resolveContext error propagation (#207)', () => { + let tempDir; + + beforeEach(async () => { + tempDir = await setupGitRepo(); + // Initialize a graph so init doesn't fail + execFileSync(process.execPath, [BIN, 'init'], { cwd: tempDir, stdio: 'ignore' }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('view --observer returns exit code 4 (NOT_FOUND)', () => { + const { status, stderr } = runCli(['view', 'roadmap', '--observer', 'ghost'], tempDir); + expect(status).toBe(4); // ExitCode.NOT_FOUND + expect(stderr).toMatch(/Observer.*not found/); + }); + + it('view --observer --json returns structured NOT_FOUND envelope', () => { + const { status, stdout } = runCli(['view', 'roadmap', '--observer', 'ghost', '--json'], tempDir); + expect(status).toBe(4); + const parsed = JSON.parse(stdout); + expect(parsed.errorCode).toBe('GMIND_E_NOT_FOUND'); + expect(parsed.exitCode).toBe(4); + }); + + it('nodes --observer returns exit code 4', () => { + const { status } = runCli(['nodes', '--observer', 'ghost'], tempDir); + expect(status).toBe(4); + }); + + it('status --observer returns exit code 4', () => { + const { status } = runCli(['status', '--observer', 'ghost'], tempDir); + expect(status).toBe(4); + }); + + it('doctor --observer returns exit code 4', () => { + const { status } = runCli(['doctor', '--observer', 'ghost'], tempDir); + expect(status).toBe(4); + }); +}); From 825742487276ff61c64d82e81c4be9c3243cd9c1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 25 Feb 2026 20:50:55 -0800 Subject: [PATCH 5/6] docs: update CHANGELOG; bump to v5.1.1 (#207) --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91d3476d..350013a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Error taxonomy propagation** — unknown command/subcommand handlers now pass `--json` flag to `handleError`, emitting structured JSON error envelopes instead of plain text (#207) +- **Exit code fidelity** — `view`, `nodes`, `status`, `export`, and `doctor` commands now propagate `GmindError.exitCode` (e.g. exit 4 for NOT_FOUND) instead of always returning exit 1 (#207) +- **Usage dump suppressed in JSON mode** — `git mind --json` no longer appends plain-text usage output after the JSON error envelope (#207) + ## [5.1.0] - 2026-02-25 ### Added diff --git a/package-lock.json b/package-lock.json index 88ac1297..62762abc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuroglyph/git-mind", - "version": "5.1.0", + "version": "5.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuroglyph/git-mind", - "version": "5.1.0", + "version": "5.1.1", "license": "Apache-2.0", "dependencies": { "@git-stunts/git-warp": "^11.5.0", diff --git a/package.json b/package.json index 4beeffaa..a155e967 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@neuroglyph/git-mind", - "version": "5.1.0", + "version": "5.1.1", "description": "A project knowledge graph tool built on git-warp", "type": "module", "license": "Apache-2.0", From 7ff89fcd19d5ea140d5dc409ed10292ba1128ec3 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 25 Feb 2026 20:57:11 -0800 Subject: [PATCH 6/6] fix: address CodeRabbit review feedback (#207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG: add [5.1.1] release section and link definition - test/api-surface.test.js: update stale "Last updated: v5.0.0" → v5.1.0 - CONTRIBUTING.md: fix markdown code block formatting (MD031, MD040) - test/error-propagation.test.js: add hint assertion for NOT_FOUND envelope - test/errors.test.js: add exitCode and hint assertions for JSON.stringify - bin/git-mind.js: add clarifying comment for --json no-command edge case --- CHANGELOG.md | 3 +++ CONTRIBUTING.md | 4 +++- bin/git-mind.js | 1 + test/api-surface.test.js | 2 +- test/error-propagation.test.js | 1 + test/errors.test.js | 2 ++ 6 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 350013a0..92ee409e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.1.1] - 2026-02-25 + ### Fixed - **Error taxonomy propagation** — unknown command/subcommand handlers now pass `--json` flag to `handleError`, emitting structured JSON error envelopes instead of plain text (#207) @@ -388,6 +390,7 @@ Complete rewrite from C23 to Node.js on `@git-stunts/git-warp`. - Docker-based CI/CD - All C-specific documentation +[5.1.1]: https://github.com/neuroglyph/git-mind/releases/tag/v5.1.1 [5.1.0]: https://github.com/neuroglyph/git-mind/releases/tag/v5.1.0 [5.0.0]: https://github.com/neuroglyph/git-mind/releases/tag/v5.0.0 [4.0.1]: https://github.com/neuroglyph/git-mind/releases/tag/v4.0.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12498c39..aa5e09ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -106,9 +106,11 @@ will fail if the surface changes without an intentional update. export function oldFunction() { ... } ``` 2. **Runtime warning** — Emit a one-time `console.warn` on first call: - ``` + + ```text [git-mind] oldFunction() is deprecated — use newFunction(). Removal: v6.0.0 ``` + 3. **Keep in snapshot** — The export stays in `test/api-surface.test.js` until the major version that removes it. 4. **Remove** — In the next major version, delete the export, remove it from diff --git a/bin/git-mind.js b/bin/git-mind.js index 6305e938..8b495dff 100755 --- a/bin/git-mind.js +++ b/bin/git-mind.js @@ -507,6 +507,7 @@ switch (command) { break; default: { + // No command: show usage (plain text) or exit silently (--json) with code 0. const jsonMode = args.includes('--json'); if (command) { handleError(new GmindError('GMIND_E_UNKNOWN_CMD', `Unknown command: ${command}`), { json: jsonMode }); diff --git a/test/api-surface.test.js b/test/api-surface.test.js index d5e0cd46..25789122 100644 --- a/test/api-surface.test.js +++ b/test/api-surface.test.js @@ -18,7 +18,7 @@ import * as api from '../src/index.js'; * Canonical snapshot of the public API surface. * Sorted alphabetically. Each entry: [name, expectedType]. * - * Last updated: v5.0.0 + * Last updated: v5.1.0 */ const API_SNAPSHOT = [ ['ALL_PREFIXES', 'object'], diff --git a/test/error-propagation.test.js b/test/error-propagation.test.js index 495d3198..bdccdce8 100644 --- a/test/error-propagation.test.js +++ b/test/error-propagation.test.js @@ -104,6 +104,7 @@ describe('resolveContext error propagation (#207)', () => { const parsed = JSON.parse(stdout); expect(parsed.errorCode).toBe('GMIND_E_NOT_FOUND'); expect(parsed.exitCode).toBe(4); + expect(parsed.hint).toBeDefined(); }); it('nodes --observer returns exit code 4', () => { diff --git a/test/errors.test.js b/test/errors.test.js index 41c389df..9897179e 100644 --- a/test/errors.test.js +++ b/test/errors.test.js @@ -52,6 +52,8 @@ describe('GmindError (#207)', () => { const parsed = JSON.parse(JSON.stringify(err)); expect(parsed.errorCode).toBe('GMIND_E_NOT_FOUND'); expect(parsed.error).toBe('missing'); + expect(parsed.exitCode).toBe(ExitCode.NOT_FOUND); + expect(parsed.hint).toBe('The requested resource does not exist'); }); });