Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ 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)
- **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 <bad-cmd> --json` no longer appends plain-text usage output after the JSON error envelope (#207)

## [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
Expand Down Expand Up @@ -371,6 +390,8 @@ 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
[4.0.0]: https://github.com/neuroglyph/git-mind/releases/tag/v4.0.0
Expand Down
33 changes: 33 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,39 @@ 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() { ... }
```
Comment on lines +100 to +107
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Markdown lint warning: fenced code block at line 104 needs a blank line before it.

Static analysis flags MD031 at lines 104 and 107. The deprecation example code block should be surrounded by blank lines:

📝 Proposed fix
 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:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### 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() { ... }
```
### Deprecation protocol
1. **Mark deprecated** — Add `@deprecated` JSDoc tag with a migration note and
the target removal version (at least one minor release away):
🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 104-104: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 107-107: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CONTRIBUTING.md` around lines 100 - 107, Insert a blank line before the
opening fenced code block and a blank line after the closing ``` in the "Mark
deprecated" example so the JSDoc example (the /** `@deprecated` Use newFunction()
instead. Removal: v6.0.0 */ and export function oldFunction() { ... }) is
separated from surrounding list items ("1. **Mark deprecated**" and "2.
**Runtime warning**") to satisfy MD031; locate the `@deprecated` example in the
deprecation protocol section and add the blank lines immediately before the
starting ``` and immediately after the ending ``` of that snippet.

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
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).
89 changes: 54 additions & 35 deletions bin/git-mind.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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;
}
}
Comment on lines +118 to +138
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Hardcoded command: 'error' in JSON envelope loses context about what was attempted.

The command field is statically set to 'error' rather than reflecting the actual CLI command that failed. Consumers parsing these JSON envelopes might benefit from knowing which command triggered the error (e.g., 'view', 'diff', 'content').

If this is a deliberate schema decision (always emit 'error' for error responses), then consider documenting why. Otherwise, consider threading the attempted command name through.

Additionally, for non-GmindError exceptions (line 135), you're accessing err.message directly—if err is a string thrown raw (e.g., throw 'oops'), this will be undefined. The ?? String(err) fallback handles it, but it's a subtle edge.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/git-mind.js` around lines 118 - 138, handleError currently hardcodes
command: 'error' in the JSON envelope and reads err.message directly for
non-GmindError throws; update handleError to accept/consume an attempted command
name (e.g., opts.command) and include that value in the JSON output (falling
back to process.argv[2] or 'unknown' if not provided) so the envelope reflects
which CLI command failed, and normalize the error message for non-GmindError
cases by deriving a safe string (e.g., use typeof err === 'string' ? err :
err.message ?? String(err)) before logging or setting process.exitCode;
reference handleError, GmindError, err, and opts.json when making this change.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional — error envelopes always use command: 'error' to distinguish them from success responses (e.g., command: 'view', command: 'nodes'). Threading the attempted command name would require changes across many code paths and could be a future enhancement, but it's not part of the current error taxonomy scope.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@flyingrobots, understood—thank you for clarifying the schema design. Using command: 'error' as a discriminator makes sense for consistent envelope structure. I appreciate the explanation!


✏️ Learnings added
Learnt from: flyingrobots
Repo: flyingrobots/git-mind PR: 297
File: bin/git-mind.js:118-138
Timestamp: 2026-02-26T04:58:06.175Z
Learning: In bin/git-mind.js and the error taxonomy, error JSON envelopes always use `command: 'error'` as a discriminator field to distinguish them from success responses (e.g., `command: 'view'`, `command: 'nodes'`). This is intentional schema design.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment on lines +126 to +138
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

handleError silently swallows stack traces for non-GmindError exceptions.

When err is not a GmindError, you emit only err.message to stderr. The full stack trace is discarded. In production CLI tools this is often acceptable, but for debugging internal errors during development, you lose valuable diagnostic info.

Consider logging the full stack when a debug flag is present:

🔧 Suggested enhancement
   } else {
-    console.error(err.message ?? String(err));
+    console.error(err.message ?? String(err));
+    if (process.env.GITMIND_DEBUG) console.error(err.stack);
     process.exitCode = 1;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/git-mind.js` around lines 126 - 138, handleError currently prints only
err.message for non-GmindError exceptions, losing the stack trace; modify
handleError (the handleError function) to emit the full stack when available if
a debug flag is set (e.g., opts.debug or process.env.DEBUG), and also include
the stack in the JSON output when opts.json is true; keep existing behavior for
normal runs (only message and exitCode) but when opts.debug is truthy print
console.error(err.stack ?? err.message) for non-GmindError and include a stack
field in the JSON payload for GmindError/non-GmindError cases when opts.json is
enabled.


/**
* Extract a ContextEnvelope from parsed flags.
* Builds one only when context flags are present; otherwise returns null.
Expand Down Expand Up @@ -179,8 +202,7 @@ switch (command) {
const source = args[1];
const target = args[2];
if (!source || !target) {
console.error('Usage: git mind link <source> <target> [--type <type>]');
process.exitCode = 1;
handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind link <source> <target> [--type <type>]'));
break;
}
const flags = parseFlags(args.slice(3));
Expand Down Expand Up @@ -209,8 +231,7 @@ switch (command) {
const rmSource = args[1];
const rmTarget = args[2];
if (!rmSource || !rmTarget) {
console.error('Usage: git mind remove <source> <target> [--type <type>]');
process.exitCode = 1;
handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind remove <source> <target> [--type <type>]'));
break;
}
const rmFlags = parseFlags(args.slice(3));
Expand Down Expand Up @@ -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 <nodeId> <key> <value> [--json]');
console.error(' <value> is positional and required (flags are not valid values)');
process.exitCode = 1;
handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind set <nodeId> <key> <value> [--json]', {
hint: '<value> is positional and required (flags are not valid values)',
}), { json: args.includes('--json') });
break;
Comment on lines +270 to 273
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Passing args.includes('--json') directly bypasses parseFlags — inconsistent pattern.

You parse flags with parseFlags(args.slice(3)) elsewhere, but here you're doing a raw args.includes('--json') on the full args array. This works, but it's inconsistent with the rest of the codebase where flags.json is used. If someone later adds a positional argument containing the literal string --json, this could misbehave (admittedly pathological).

For consistency, consider using parseFlags result:

♻️ Proposed refactor
   case 'set': {
     const setNodeId = args[1];
     const setKey = args[2];
     const setValue = args[3];
+    const setFlags = parseFlags(args.slice(4));
+    const jsonMode = setFlags.json === true;
     if (!setNodeId || !setKey || setValue === undefined || setValue.startsWith('--')) {
       handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind set <nodeId> <key> <value> [--json]', {
         hint: '<value> is positional and required (flags are not valid values)',
-      }), { json: args.includes('--json') });
+      }), { json: jsonMode });
       break;
     }
-    await set(cwd, setNodeId, setKey, setValue, { json: args.includes('--json') });
+    await set(cwd, setNodeId, setKey, setValue, { json: jsonMode });
     break;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/git-mind.js` around lines 270 - 273, The call to handleError is using
args.includes('--json') directly which is inconsistent with the rest of the
code; instead call parseFlags on the positional-slice (use
parseFlags(args.slice(3)) as elsewhere) to produce a flags object and pass
flags.json to handleError; ensure parseFlags is invoked before the GmindError
branch so handleError(..., { json: flags.json }) is used and preserves the same
behavior while avoiding matching literal positional values.

}
await set(cwd, setNodeId, setKey, setValue, { json: args.includes('--json') });
Expand All @@ -259,8 +280,7 @@ switch (command) {
const unsetNodeId = args[1];
const unsetKey = args[2];
if (!unsetNodeId || !unsetKey) {
console.error('Usage: git mind unset <nodeId> <key>');
process.exitCode = 1;
handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind unset <nodeId> <key>'), { json: args.includes('--json') });
break;
}
await unsetCmd(cwd, unsetNodeId, unsetKey, { json: args.includes('--json') });
Expand All @@ -280,8 +300,7 @@ switch (command) {
case 'at': {
const atRef = args[1];
if (!atRef || atRef.startsWith('--')) {
console.error('Usage: git mind at <ref>');
process.exitCode = 1;
handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind at <ref>'), { json: args.includes('--json') });
break;
}
await at(cwd, atRef, { json: args.includes('--json') });
Expand All @@ -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;
}
Expand All @@ -316,8 +337,7 @@ switch (command) {

const importPath = args.slice(1).find(a => !a.startsWith('--'));
if (!importPath) {
console.error('Usage: git mind import <file> [--dry-run] [--json] [--from-markdown <glob>]');
process.exitCode = 1;
handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind import <file> [--dry-run] [--json] [--from-markdown <glob>]'), { json: jsonMode });
break;
}
await importCmd(cwd, importPath, { dryRun, json: jsonMode });
Expand Down Expand Up @@ -355,8 +375,7 @@ switch (command) {

case 'process-commit':
if (!args[1]) {
console.error('Usage: git mind process-commit <sha>');
process.exitCode = 1;
handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind process-commit <sha>'));
break;
}
await processCommitCmd(cwd, args[1]);
Expand Down Expand Up @@ -403,8 +422,7 @@ switch (command) {
const setNode = contentPositionals[0];
const fromFile = contentFlags.from;
if (!setNode || !fromFile) {
console.error('Usage: git mind content set <node> --from <file> [--mime <type>] [--json]');
process.exitCode = 1;
handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind content set <node> --from <file> [--mime <type>] [--json]'), { json: contentFlags.json });
break;
}
await contentSet(cwd, setNode, fromFile, {
Expand All @@ -416,8 +434,7 @@ switch (command) {
case 'show': {
const showNode = contentPositionals[0];
if (!showNode) {
console.error('Usage: git mind content show <node> [--raw] [--json]');
process.exitCode = 1;
handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind content show <node> [--raw] [--json]'), { json: contentFlags.json });
break;
}
await contentShow(cwd, showNode, {
Expand All @@ -429,8 +446,7 @@ switch (command) {
case 'meta': {
const metaNode = contentPositionals[0];
if (!metaNode) {
console.error('Usage: git mind content meta <node> [--json]');
process.exitCode = 1;
handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind content meta <node> [--json]'), { json: contentFlags.json });
break;
}
await contentMeta(cwd, metaNode, { json: contentFlags.json ?? false });
Expand All @@ -439,17 +455,16 @@ switch (command) {
case 'delete': {
const deleteNode = contentPositionals[0];
if (!deleteNode) {
console.error('Usage: git mind content delete <node> [--json]');
process.exitCode = 1;
handleError(new GmindError('GMIND_E_USAGE', 'Usage: git mind content delete <node> [--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 <set|show|meta|delete>');
process.exitCode = 1;
handleError(new GmindError('GMIND_E_UNKNOWN_CMD', `Unknown content subcommand: ${contentSubCmd ?? '(none)'}`, {
hint: 'Usage: git mind content <set|show|meta|delete>',
}), { json: contentFlags.json ?? false });
}
break;
}
Expand Down Expand Up @@ -478,9 +493,9 @@ switch (command) {
break;
}
default:
console.error(`Unknown extension subcommand: ${subCmd ?? '(none)'}`);
console.error('Usage: git mind extension <list|validate|add|remove>');
process.exitCode = 1;
handleError(new GmindError('GMIND_E_UNKNOWN_CMD', `Unknown extension subcommand: ${subCmd ?? '(none)'}`, {
hint: 'Usage: git mind extension <list|validate|add|remove>',
}), { json: extFlags.json ?? false });
}
break;
}
Expand All @@ -491,11 +506,15 @@ switch (command) {
printUsage();
break;

default:
default: {
// No command: show usage (plain text) or exit silently (--json) with code 0.
const jsonMode = args.includes('--json');
if (command) {
console.error(`Unknown command: ${command}\n`);
handleError(new GmindError('GMIND_E_UNKNOWN_CMD', `Unknown command: ${command}`), { json: jsonMode });
if (!jsonMode) console.error('');
}
printUsage();
process.exitCode = command ? 1 : 0;
if (!jsonMode) printUsage();
if (!command) process.exitCode = 0;
break;
}
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@neuroglyph/git-mind",
"version": "5.0.0",
"version": "5.1.1",
"description": "A project knowledge graph tool built on git-warp",
"type": "module",
"license": "Apache-2.0",
Expand Down
Loading
Loading