From bae326a7a38796ee08dcd2d259523db189b4dddf Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 13 Feb 2026 22:14:44 -0800 Subject: [PATCH 1/8] feat(cli): add schemaVersion envelope to all --json outputs (#205) Add outputJson(command, data) helper that injects schemaVersion: 1 and command fields into every JSON output. Replace all 15 console.log(JSON.stringify) call sites. Wrap bare arrays: nodes --json returns { nodes: [...] }, review --json returns { pending: [...] }. --- src/cli/commands.js | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/cli/commands.js b/src/cli/commands.js index 5dee7e94..6f81e56f 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -24,6 +24,18 @@ import { getPendingSuggestions, acceptSuggestion, rejectSuggestion, skipSuggesti import { computeDiff } from '../diff.js'; import { success, error, info, warning, formatEdge, formatView, formatNode, formatNodeList, formatStatus, formatExportResult, formatImportResult, formatDoctorResult, formatSuggestions, formatReviewItem, formatDecisionSummary, formatAtStatus, formatDiff } from './format.js'; +/** + * Write structured JSON to stdout with schemaVersion and command fields. + * CLI layer is authoritative — schemaVersion is always forced last. + * + * @param {string} command - Command name for downstream routing + * @param {object} data - Payload from the source module + */ +function outputJson(command, data) { + const out = { ...data, schemaVersion: 1, command }; + console.log(JSON.stringify(out, null, 2)); +} + /** * Initialize a git-mind graph in the current repo. * @param {string} cwd @@ -212,7 +224,7 @@ export async function nodes(cwd, opts = {}) { return; } if (opts.json) { - console.log(JSON.stringify(node, null, 2)); + outputJson('nodes', node); } else { console.log(formatNode(node)); } @@ -225,7 +237,7 @@ export async function nodes(cwd, opts = {}) { : await getNodes(graph); if (opts.json) { - console.log(JSON.stringify(nodeList, null, 2)); + outputJson('nodes', { nodes: nodeList }); return; } @@ -253,7 +265,7 @@ export async function status(cwd, opts = {}) { const result = await computeStatus(graph); if (opts.json) { - console.log(JSON.stringify(result, null, 2)); + outputJson('status', result); } else { console.log(formatStatus(result)); } @@ -294,7 +306,7 @@ export async function at(cwd, ref, opts = {}) { const statusResult = await computeStatus(graph); if (opts.json) { - console.log(JSON.stringify({ + outputJson('at', { ref, sha: sha.slice(0, 8), fullSha: sha, @@ -302,7 +314,7 @@ export async function at(cwd, ref, opts = {}) { nearest: epoch.nearest ?? false, recordedAt: epoch.recordedAt, status: statusResult, - }, null, 2)); + }); } else { console.log(formatAtStatus(ref, sha, epoch, statusResult)); } @@ -324,7 +336,7 @@ export async function importCmd(cwd, filePath, opts = {}) { const result = await importFile(graph, filePath, { dryRun: opts.dryRun }); if (opts.json) { - console.log(JSON.stringify(result, null, 2)); + outputJson('import', result); } else { console.log(formatImportResult(result)); } @@ -350,7 +362,7 @@ export async function importMarkdownCmd(cwd, pattern, opts = {}) { const result = await importFromMarkdown(graph, cwd, pattern, { dryRun: opts.dryRun }); if (opts.json) { - console.log(JSON.stringify(result, null, 2)); + outputJson('import', result); } else { console.log(formatImportResult(result)); } @@ -378,7 +390,7 @@ export async function exportCmd(cwd, opts = {}) { const result = await exportToFile(graph, opts.file, { format, prefix: opts.prefix }); if (opts.json) { - console.log(JSON.stringify(result, null, 2)); + outputJson('export', result); } else { console.log(formatExportResult(result)); } @@ -387,7 +399,7 @@ export async function exportCmd(cwd, opts = {}) { const data = await exportGraph(graph, { prefix: opts.prefix }); if (opts.json) { - console.log(JSON.stringify(data, null, 2)); + outputJson('export', data); } else { const output = serializeExport(data, format); process.stdout.write(output); @@ -419,7 +431,7 @@ export async function mergeCmd(cwd, opts = {}) { }); if (opts.json) { - console.log(JSON.stringify(result, null, 2)); + outputJson('merge', result); } else { if (result.dryRun) { console.log(info(`Dry run: would merge ${result.nodes} node(s), ${result.edges} edge(s) from ${result.repoName}`)); @@ -449,7 +461,7 @@ export async function doctor(cwd, opts = {}) { } if (opts.json) { - console.log(JSON.stringify(fixResult ? { ...result, fix: fixResult } : result, null, 2)); + outputJson('doctor', fixResult ? { ...result, fix: fixResult } : result); } else { console.log(formatDoctorResult(result, fixResult)); } @@ -477,7 +489,7 @@ export async function suggest(cwd, opts = {}) { }); if (opts.json) { - console.log(JSON.stringify(result, null, 2)); + outputJson('suggest', result); } else { console.log(formatSuggestions(result)); } @@ -525,7 +537,7 @@ export async function review(cwd, opts = {}) { const result = { processed: 1, decisions: [decision] }; if (opts.json) { - console.log(JSON.stringify(result, null, 2)); + outputJson('review', result); } else { console.log(formatDecisionSummary(result)); } @@ -535,7 +547,7 @@ export async function review(cwd, opts = {}) { const result = await batchDecision(graph, opts.batch); if (opts.json) { - console.log(JSON.stringify(result, null, 2)); + outputJson('review', result); } else { console.log(formatDecisionSummary(result)); } @@ -551,7 +563,7 @@ export async function review(cwd, opts = {}) { } if (opts.json) { - console.log(JSON.stringify(pending, null, 2)); + outputJson('review', { pending }); return; } @@ -608,7 +620,7 @@ export async function diff(cwd, refA, refB, opts = {}) { const result = await computeDiff(cwd, refA, refB, { prefix: opts.prefix }); if (opts.json) { - console.log(JSON.stringify(result, null, 2)); + outputJson('diff', result); } else { console.log(formatDiff(result)); } From 436af7e4fc5cc2e25354d3985b49a07094f35b07 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 13 Feb 2026 22:14:50 -0800 Subject: [PATCH 2/8] feat(contracts): add JSON Schema files for CLI outputs (#205) Create 13 Draft 2020-12 JSON Schema files in docs/contracts/cli/ covering every --json command output. Strict additionalProperties: false at top level; open objects for extensible fields (node properties, prefix maps). Add CLI_CONTRACTS.md with version policy, command-to-schema table, programmatic validation example, and migration guide. --- docs/contracts/CLI_CONTRACTS.md | 83 +++++++++++ docs/contracts/cli/at.schema.json | 60 ++++++++ docs/contracts/cli/diff.schema.json | 147 ++++++++++++++++++++ docs/contracts/cli/doctor.schema.json | 63 +++++++++ docs/contracts/cli/export-data.schema.json | 44 ++++++ docs/contracts/cli/export-file.schema.json | 23 +++ docs/contracts/cli/import.schema.json | 32 +++++ docs/contracts/cli/merge.schema.json | 17 +++ docs/contracts/cli/node-detail.schema.json | 23 +++ docs/contracts/cli/node-list.schema.json | 17 +++ docs/contracts/cli/review-batch.schema.json | 36 +++++ docs/contracts/cli/review-list.schema.json | 29 ++++ docs/contracts/cli/status.schema.json | 47 +++++++ docs/contracts/cli/suggest.schema.json | 34 +++++ 14 files changed, 655 insertions(+) create mode 100644 docs/contracts/CLI_CONTRACTS.md create mode 100644 docs/contracts/cli/at.schema.json create mode 100644 docs/contracts/cli/diff.schema.json create mode 100644 docs/contracts/cli/doctor.schema.json create mode 100644 docs/contracts/cli/export-data.schema.json create mode 100644 docs/contracts/cli/export-file.schema.json create mode 100644 docs/contracts/cli/import.schema.json create mode 100644 docs/contracts/cli/merge.schema.json create mode 100644 docs/contracts/cli/node-detail.schema.json create mode 100644 docs/contracts/cli/node-list.schema.json create mode 100644 docs/contracts/cli/review-batch.schema.json create mode 100644 docs/contracts/cli/review-list.schema.json create mode 100644 docs/contracts/cli/status.schema.json create mode 100644 docs/contracts/cli/suggest.schema.json diff --git a/docs/contracts/CLI_CONTRACTS.md b/docs/contracts/CLI_CONTRACTS.md new file mode 100644 index 00000000..aa84a637 --- /dev/null +++ b/docs/contracts/CLI_CONTRACTS.md @@ -0,0 +1,83 @@ +# CLI JSON Contracts + +Every `--json` output from the git-mind CLI includes a standard envelope: + +```json +{ + "schemaVersion": 1, + "command": "status", + ... +} +``` + +- **`schemaVersion`** — Increments only on breaking changes to the JSON structure. Non-breaking additions (new optional fields) do not increment it. +- **`command`** — The command that produced this output. Useful for routing in pipelines that handle multiple git-mind outputs. + +## Version Policy + +| Scenario | schemaVersion change | +|----------|---------------------| +| New optional field added | No change | +| Field renamed or removed | Increment | +| Field type changed | Increment | +| Array wrapped in object | Increment | +| New command added | No change (new schema) | + +## Command-to-Schema Table + +| Command | Schema File | +|---------|-------------| +| `nodes --id --json` | [`node-detail.schema.json`](cli/node-detail.schema.json) | +| `nodes --json` | [`node-list.schema.json`](cli/node-list.schema.json) | +| `status --json` | [`status.schema.json`](cli/status.schema.json) | +| `at --json` | [`at.schema.json`](cli/at.schema.json) | +| `import --json` | [`import.schema.json`](cli/import.schema.json) | +| `import --from-markdown --json` | [`import.schema.json`](cli/import.schema.json) | +| `export --json` (stdout) | [`export-data.schema.json`](cli/export-data.schema.json) | +| `export --file --json` | [`export-file.schema.json`](cli/export-file.schema.json) | +| `merge --json` | [`merge.schema.json`](cli/merge.schema.json) | +| `doctor --json` | [`doctor.schema.json`](cli/doctor.schema.json) | +| `suggest --json` | [`suggest.schema.json`](cli/suggest.schema.json) | +| `review --json` | [`review-list.schema.json`](cli/review-list.schema.json) | +| `review --batch --json` | [`review-batch.schema.json`](cli/review-batch.schema.json) | + +## Programmatic Validation + +```javascript +import Ajv from 'ajv/dist/2020.js'; +import schema from './docs/contracts/cli/status.schema.json' with { type: 'json' }; + +const ajv = new Ajv({ strict: true, allErrors: true }); +const validate = ajv.compile(schema); + +const output = JSON.parse(execSync('git mind status --json')); +if (!validate(output)) { + console.error(validate.errors); +} +``` + +## Migration Guide + +### `nodes --json` (Breaking) + +The output changed from a bare JSON array to a wrapped object. + +```bash +# Before (v2.0.0-alpha.5 and earlier): +git mind nodes --json | jq '.[]' + +# After: +git mind nodes --json | jq '.nodes[]' +``` + +### `review --json` (Breaking) + +The output changed from a bare JSON array to a wrapped object. + +```bash +# Before (v2.0.0-alpha.5 and earlier): +git mind review --json | jq '.[].source' + +# After: +git mind review --json | jq '.pending[].source' +``` diff --git a/docs/contracts/cli/at.schema.json b/docs/contracts/cli/at.schema.json new file mode 100644 index 00000000..ae5f982e --- /dev/null +++ b/docs/contracts/cli/at.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/at.schema.json", + "title": "git-mind at --json", + "description": "Time-travel output from `git mind at --json`", + "type": "object", + "required": ["schemaVersion", "command", "ref", "sha", "fullSha", "tick", "nearest", "status"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "at" }, + "ref": { "type": "string" }, + "sha": { "type": "string", "minLength": 8, "maxLength": 8 }, + "fullSha": { "type": "string", "pattern": "^[0-9a-f]+$" }, + "tick": { "type": "integer", "minimum": 0 }, + "nearest": { "type": "boolean" }, + "recordedAt": { "type": ["string", "null"] }, + "status": { + "type": "object", + "required": ["nodes", "edges", "health"], + "additionalProperties": false, + "properties": { + "nodes": { + "type": "object", + "required": ["total", "byPrefix"], + "additionalProperties": false, + "properties": { + "total": { "type": "integer", "minimum": 0 }, + "byPrefix": { + "type": "object", + "additionalProperties": { "type": "integer", "minimum": 0 } + } + } + }, + "edges": { + "type": "object", + "required": ["total", "byType"], + "additionalProperties": false, + "properties": { + "total": { "type": "integer", "minimum": 0 }, + "byType": { + "type": "object", + "additionalProperties": { "type": "integer", "minimum": 0 } + } + } + }, + "health": { + "type": "object", + "required": ["blockedItems", "lowConfidence", "orphanNodes"], + "additionalProperties": false, + "properties": { + "blockedItems": { "type": "integer", "minimum": 0 }, + "lowConfidence": { "type": "integer", "minimum": 0 }, + "orphanNodes": { "type": "integer", "minimum": 0 } + } + } + } + } + } +} diff --git a/docs/contracts/cli/diff.schema.json b/docs/contracts/cli/diff.schema.json new file mode 100644 index 00000000..5f36b82a --- /dev/null +++ b/docs/contracts/cli/diff.schema.json @@ -0,0 +1,147 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/diff.schema.json", + "title": "git-mind diff --json", + "description": "Graph diff result from `git mind diff .. --json`", + "type": "object", + "required": ["schemaVersion", "command", "from", "to", "nodes", "edges", "summary", "stats"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "diff" }, + "from": { "$ref": "#/$defs/diffEndpoint" }, + "to": { "$ref": "#/$defs/diffEndpoint" }, + "nodes": { + "type": "object", + "required": ["added", "removed", "total"], + "additionalProperties": false, + "properties": { + "added": { "type": "array", "items": { "type": "string" } }, + "removed": { "type": "array", "items": { "type": "string" } }, + "total": { + "type": "object", + "required": ["before", "after"], + "additionalProperties": false, + "properties": { + "before": { "type": "integer", "minimum": 0 }, + "after": { "type": "integer", "minimum": 0 } + } + } + } + }, + "edges": { + "type": "object", + "required": ["added", "removed", "total"], + "additionalProperties": false, + "properties": { + "added": { + "type": "array", + "items": { "$ref": "#/$defs/edgeDiffEntry" } + }, + "removed": { + "type": "array", + "items": { "$ref": "#/$defs/edgeDiffEntry" } + }, + "total": { + "type": "object", + "required": ["before", "after"], + "additionalProperties": false, + "properties": { + "before": { "type": "integer", "minimum": 0 }, + "after": { "type": "integer", "minimum": 0 } + } + } + } + }, + "summary": { + "type": "object", + "required": ["nodesByPrefix", "edgesByType"], + "additionalProperties": false, + "properties": { + "nodesByPrefix": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["before", "after"], + "additionalProperties": false, + "properties": { + "before": { "type": "integer", "minimum": 0 }, + "after": { "type": "integer", "minimum": 0 } + } + } + }, + "edgesByType": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["before", "after"], + "additionalProperties": false, + "properties": { + "before": { "type": "integer", "minimum": 0 }, + "after": { "type": "integer", "minimum": 0 } + } + } + } + } + }, + "stats": { + "type": "object", + "required": ["materializeMs", "diffMs", "nodeCount", "edgeCount"], + "additionalProperties": false, + "properties": { + "sameTick": { "type": "boolean" }, + "materializeMs": { + "type": "object", + "required": ["a", "b"], + "additionalProperties": false, + "properties": { + "a": { "type": "integer", "minimum": 0 }, + "b": { "type": "integer", "minimum": 0 } + } + }, + "diffMs": { "type": "integer", "minimum": 0 }, + "nodeCount": { + "type": "object", + "required": ["a", "b"], + "additionalProperties": false, + "properties": { + "a": { "type": "integer", "minimum": 0 }, + "b": { "type": "integer", "minimum": 0 } + } + }, + "edgeCount": { + "type": "object", + "required": ["a", "b"], + "additionalProperties": false, + "properties": { + "a": { "type": "integer", "minimum": 0 }, + "b": { "type": "integer", "minimum": 0 } + } + } + } + } + }, + "$defs": { + "diffEndpoint": { + "type": "object", + "required": ["ref", "sha", "tick", "nearest"], + "additionalProperties": false, + "properties": { + "ref": { "type": "string" }, + "sha": { "type": "string", "minLength": 8, "maxLength": 8 }, + "tick": { "type": "integer", "minimum": 0 }, + "nearest": { "type": "boolean" } + } + }, + "edgeDiffEntry": { + "type": "object", + "required": ["source", "target", "type"], + "additionalProperties": false, + "properties": { + "source": { "type": "string", "minLength": 1 }, + "target": { "type": "string", "minLength": 1 }, + "type": { "type": "string" } + } + } + } +} diff --git a/docs/contracts/cli/doctor.schema.json b/docs/contracts/cli/doctor.schema.json new file mode 100644 index 00000000..a0c95f02 --- /dev/null +++ b/docs/contracts/cli/doctor.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/doctor.schema.json", + "title": "git-mind doctor --json", + "description": "Graph integrity check result from `git mind doctor --json`", + "type": "object", + "required": ["schemaVersion", "command", "issues", "summary", "clean"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "doctor" }, + "issues": { + "type": "array", + "items": { + "type": "object", + "required": ["type", "severity", "message", "affected"], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": ["dangling-edge", "orphan-milestone", "orphan-node", "low-confidence"] + }, + "severity": { + "type": "string", + "enum": ["error", "warning", "info"] + }, + "message": { "type": "string" }, + "affected": { + "type": "array", + "items": { "type": "string" } + }, + "source": { "type": "string" }, + "target": { "type": "string" }, + "edgeType": { "type": "string" } + } + } + }, + "summary": { + "type": "object", + "required": ["errors", "warnings", "info"], + "additionalProperties": false, + "properties": { + "errors": { "type": "integer", "minimum": 0 }, + "warnings": { "type": "integer", "minimum": 0 }, + "info": { "type": "integer", "minimum": 0 } + } + }, + "clean": { "type": "boolean" }, + "fix": { + "type": "object", + "required": ["fixed", "skipped", "details"], + "additionalProperties": false, + "properties": { + "fixed": { "type": "integer", "minimum": 0 }, + "skipped": { "type": "integer", "minimum": 0 }, + "details": { + "type": "array", + "items": { "type": "string" } + } + } + } + } +} diff --git a/docs/contracts/cli/export-data.schema.json b/docs/contracts/cli/export-data.schema.json new file mode 100644 index 00000000..67592ffe --- /dev/null +++ b/docs/contracts/cli/export-data.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/export-data.schema.json", + "title": "git-mind export --json (stdout)", + "description": "Graph data export from `git mind export --json` (stdout mode)", + "type": "object", + "required": ["schemaVersion", "command", "version", "nodes", "edges"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "export" }, + "version": { "type": "integer", "const": 1 }, + "nodes": { + "type": "array", + "items": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "properties": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": false + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "required": ["source", "target", "type"], + "properties": { + "source": { "type": "string", "minLength": 1 }, + "target": { "type": "string", "minLength": 1 }, + "type": { "type": "string" }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, + "rationale": { "type": "string" } + }, + "additionalProperties": false + } + } + } +} diff --git a/docs/contracts/cli/export-file.schema.json b/docs/contracts/cli/export-file.schema.json new file mode 100644 index 00000000..ddae759a --- /dev/null +++ b/docs/contracts/cli/export-file.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/export-file.schema.json", + "title": "git-mind export --file --json", + "description": "File export result from `git mind export --file --json`", + "type": "object", + "required": ["schemaVersion", "command", "stats", "path"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "export" }, + "stats": { + "type": "object", + "required": ["nodes", "edges"], + "additionalProperties": false, + "properties": { + "nodes": { "type": "integer", "minimum": 0 }, + "edges": { "type": "integer", "minimum": 0 } + } + }, + "path": { "type": "string", "minLength": 1 } + } +} diff --git a/docs/contracts/cli/import.schema.json b/docs/contracts/cli/import.schema.json new file mode 100644 index 00000000..f97d2fce --- /dev/null +++ b/docs/contracts/cli/import.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/import.schema.json", + "title": "git-mind import --json", + "description": "Import result from `git mind import --json` or `git mind import --from-markdown --json`", + "type": "object", + "required": ["schemaVersion", "command", "valid", "errors", "warnings", "stats"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "import" }, + "valid": { "type": "boolean" }, + "errors": { + "type": "array", + "items": { "type": "string" } + }, + "warnings": { + "type": "array", + "items": { "type": "string" } + }, + "stats": { + "type": "object", + "required": ["nodes", "edges"], + "additionalProperties": false, + "properties": { + "nodes": { "type": "integer", "minimum": 0 }, + "edges": { "type": "integer", "minimum": 0 } + } + }, + "dryRun": { "type": "boolean" } + } +} diff --git a/docs/contracts/cli/merge.schema.json b/docs/contracts/cli/merge.schema.json new file mode 100644 index 00000000..7fa8c4e0 --- /dev/null +++ b/docs/contracts/cli/merge.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/merge.schema.json", + "title": "git-mind merge --json", + "description": "Merge result from `git mind merge --json`", + "type": "object", + "required": ["schemaVersion", "command", "nodes", "edges", "repoName"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "merge" }, + "nodes": { "type": "integer", "minimum": 0 }, + "edges": { "type": "integer", "minimum": 0 }, + "repoName": { "type": "string", "minLength": 1 }, + "dryRun": { "type": "boolean" } + } +} diff --git a/docs/contracts/cli/node-detail.schema.json b/docs/contracts/cli/node-detail.schema.json new file mode 100644 index 00000000..ad9d7ee4 --- /dev/null +++ b/docs/contracts/cli/node-detail.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/node-detail.schema.json", + "title": "git-mind nodes --id --json", + "description": "Single node detail output from `git mind nodes --id --json`", + "type": "object", + "required": ["schemaVersion", "command", "id", "prefix", "prefixClass", "properties"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "nodes" }, + "id": { "type": "string", "minLength": 1 }, + "prefix": { "type": ["string", "null"] }, + "prefixClass": { + "type": "string", + "enum": ["canonical", "system", "unknown"] + }, + "properties": { + "type": "object", + "additionalProperties": true + } + } +} diff --git a/docs/contracts/cli/node-list.schema.json b/docs/contracts/cli/node-list.schema.json new file mode 100644 index 00000000..062bc93d --- /dev/null +++ b/docs/contracts/cli/node-list.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/node-list.schema.json", + "title": "git-mind nodes --json", + "description": "Node list output from `git mind nodes --json`", + "type": "object", + "required": ["schemaVersion", "command", "nodes"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "nodes" }, + "nodes": { + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/docs/contracts/cli/review-batch.schema.json b/docs/contracts/cli/review-batch.schema.json new file mode 100644 index 00000000..3a4f677a --- /dev/null +++ b/docs/contracts/cli/review-batch.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/review-batch.schema.json", + "title": "git-mind review --batch --json", + "description": "Batch review result from `git mind review --batch --json`", + "type": "object", + "required": ["schemaVersion", "command", "processed", "decisions"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "review" }, + "processed": { "type": "integer", "minimum": 0 }, + "decisions": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "action", "source", "target", "edgeType", "confidence", "timestamp"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "action": { + "type": "string", + "enum": ["accept", "reject", "adjust", "skip"] + }, + "source": { "type": "string", "minLength": 1 }, + "target": { "type": "string", "minLength": 1 }, + "edgeType": { "type": "string" }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, + "rationale": { "type": "string" }, + "timestamp": { "type": "integer" }, + "reviewer": { "type": "string" } + } + } + } + } +} diff --git a/docs/contracts/cli/review-list.schema.json b/docs/contracts/cli/review-list.schema.json new file mode 100644 index 00000000..345f20e0 --- /dev/null +++ b/docs/contracts/cli/review-list.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/review-list.schema.json", + "title": "git-mind review --json", + "description": "Pending review list from `git mind review --json`", + "type": "object", + "required": ["schemaVersion", "command", "pending"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "review" }, + "pending": { + "type": "array", + "items": { + "type": "object", + "required": ["source", "target", "type", "confidence"], + "additionalProperties": false, + "properties": { + "source": { "type": "string", "minLength": 1 }, + "target": { "type": "string", "minLength": 1 }, + "type": { "type": "string" }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, + "rationale": { "type": "string" }, + "createdAt": { "type": "string" } + } + } + } + } +} diff --git a/docs/contracts/cli/status.schema.json b/docs/contracts/cli/status.schema.json new file mode 100644 index 00000000..694a81b0 --- /dev/null +++ b/docs/contracts/cli/status.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/status.schema.json", + "title": "git-mind status --json", + "description": "Graph status dashboard output from `git mind status --json`", + "type": "object", + "required": ["schemaVersion", "command", "nodes", "edges", "health"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "status" }, + "nodes": { + "type": "object", + "required": ["total", "byPrefix"], + "additionalProperties": false, + "properties": { + "total": { "type": "integer", "minimum": 0 }, + "byPrefix": { + "type": "object", + "additionalProperties": { "type": "integer", "minimum": 0 } + } + } + }, + "edges": { + "type": "object", + "required": ["total", "byType"], + "additionalProperties": false, + "properties": { + "total": { "type": "integer", "minimum": 0 }, + "byType": { + "type": "object", + "additionalProperties": { "type": "integer", "minimum": 0 } + } + } + }, + "health": { + "type": "object", + "required": ["blockedItems", "lowConfidence", "orphanNodes"], + "additionalProperties": false, + "properties": { + "blockedItems": { "type": "integer", "minimum": 0 }, + "lowConfidence": { "type": "integer", "minimum": 0 }, + "orphanNodes": { "type": "integer", "minimum": 0 } + } + } + } +} diff --git a/docs/contracts/cli/suggest.schema.json b/docs/contracts/cli/suggest.schema.json new file mode 100644 index 00000000..bed3165c --- /dev/null +++ b/docs/contracts/cli/suggest.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/suggest.schema.json", + "title": "git-mind suggest --json", + "description": "AI-powered edge suggestion result from `git mind suggest --json`", + "type": "object", + "required": ["schemaVersion", "command", "suggestions", "errors", "prompt"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "suggest" }, + "suggestions": { + "type": "array", + "items": { + "type": "object", + "required": ["source", "target", "type", "confidence"], + "additionalProperties": false, + "properties": { + "source": { "type": "string", "minLength": 1 }, + "target": { "type": "string", "minLength": 1 }, + "type": { "type": "string" }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, + "rationale": { "type": "string" } + } + } + }, + "errors": { + "type": "array", + "items": { "type": "string" } + }, + "prompt": { "type": ["string", "null"] }, + "rejectedCount": { "type": "integer", "minimum": 0 } + } +} From 9480b4b911f3a07700eac00c65dcf5e229a4c188 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 13 Feb 2026 22:14:55 -0800 Subject: [PATCH 3/8] test(contracts): add schema validation tests (#205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ajv (devDep) for Draft 2020-12 JSON Schema validation. test/contracts.test.js: 17 unit tests — schema compilation, envelope requirements, sample payloads, optional field handling. test/contracts.integration.test.js: 7 CLI canary tests — execute the real binary and validate output against schemas. 366 tests across 22 files, all green. --- package-lock.json | 100 ++++++++-- package.json | 1 + test/contracts.integration.test.js | 145 ++++++++++++++ test/contracts.test.js | 308 +++++++++++++++++++++++++++++ 4 files changed, 542 insertions(+), 12 deletions(-) create mode 100644 test/contracts.integration.test.js create mode 100644 test/contracts.test.js diff --git a/package-lock.json b/package-lock.json index 3b239f15..a9b594e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuroglyph/git-mind", - "version": "2.0.0-alpha.0", + "version": "2.0.0-alpha.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuroglyph/git-mind", - "version": "2.0.0-alpha.0", + "version": "2.0.0-alpha.3", "license": "Apache-2.0", "dependencies": { "@git-stunts/git-warp": "^10.3.2", @@ -19,6 +19,7 @@ "git-mind": "bin/git-mind.js" }, "devDependencies": { + "ajv": "^8.17.1", "eslint": "^9.0.0", "prettier": "^3.0.0", "vitest": "^3.0.0" @@ -664,6 +665,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.39.2", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", @@ -1457,16 +1482,16 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -2136,6 +2161,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2169,6 +2211,13 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -2281,6 +2330,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2654,9 +2720,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, @@ -3316,6 +3382,16 @@ "node": ">=6" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", diff --git a/package.json b/package.json index 79dc4557..43ac5196 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "js-yaml": "^4.1.1" }, "devDependencies": { + "ajv": "^8.17.1", "eslint": "^9.0.0", "prettier": "^3.0.0", "vitest": "^3.0.0" diff --git a/test/contracts.integration.test.js b/test/contracts.integration.test.js new file mode 100644 index 00000000..06ba9aea --- /dev/null +++ b/test/contracts.integration.test.js @@ -0,0 +1,145 @@ +/** + * @module test/contracts.integration + * CLI canary tests — execute the real CLI binary and validate output + * against JSON Schema contracts. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execSync, execFileSync } from 'node:child_process'; +import Ajv from 'ajv/dist/2020.js'; + +const BIN = join(import.meta.dirname, '..', 'bin', 'git-mind.js'); +const SCHEMA_DIR = join(import.meta.dirname, '..', 'docs', 'contracts', 'cli'); +const FIXTURE = join(import.meta.dirname, 'fixtures', 'echo-seed.yaml'); + +/** Load a schema by filename. */ +async function loadSchema(name) { + return JSON.parse(await readFile(join(SCHEMA_DIR, name), 'utf-8')); +} + +/** Run the CLI and return parsed JSON output. */ +function runCli(args, cwd) { + const stdout = execFileSync(process.execPath, [BIN, ...args], { + cwd, + encoding: 'utf-8', + timeout: 30_000, + env: { ...process.env, NO_COLOR: '1' }, + }); + return JSON.parse(stdout); +} + +describe('CLI schema contract canaries', () => { + let tempDir; + let ajv; + + beforeAll(async () => { + ajv = new Ajv({ strict: true, allErrors: true }); + + // Create a temp repo with seeded graph data + tempDir = await mkdtemp(join(tmpdir(), 'gitmind-contract-')); + execSync('git init', { cwd: tempDir, stdio: 'ignore' }); + execSync('git config user.email "test@test.com"', { cwd: tempDir, stdio: 'ignore' }); + execSync('git config user.name "Test"', { cwd: tempDir, stdio: 'ignore' }); + + // Initialize graph + execFileSync(process.execPath, [BIN, 'init'], { cwd: tempDir, stdio: 'ignore' }); + + // Import the echo-seed fixture + execFileSync(process.execPath, [BIN, 'import', FIXTURE], { cwd: tempDir, stdio: 'ignore' }); + }); + + afterAll(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('status --json validates against status.schema.json', async () => { + const schema = await loadSchema('status.schema.json'); + const output = runCli(['status', '--json'], tempDir); + + expect(output.schemaVersion).toBe(1); + expect(output.command).toBe('status'); + + const validate = ajv.compile(schema); + expect(validate(output), JSON.stringify(validate.errors)).toBe(true); + }); + + it('nodes --json validates against node-list.schema.json', async () => { + const schema = await loadSchema('node-list.schema.json'); + const output = runCli(['nodes', '--json'], tempDir); + + expect(output.schemaVersion).toBe(1); + expect(output.command).toBe('nodes'); + expect(Array.isArray(output.nodes)).toBe(true); + + const validate = ajv.compile(schema); + expect(validate(output), JSON.stringify(validate.errors)).toBe(true); + }); + + it('nodes --id --json validates against node-detail.schema.json', async () => { + // Get a known node from the list first + const listOutput = runCli(['nodes', '--json'], tempDir); + const knownId = listOutput.nodes[0]; + expect(knownId).toBeDefined(); + + const schema = await loadSchema('node-detail.schema.json'); + const output = runCli(['nodes', '--id', knownId, '--json'], tempDir); + + expect(output.schemaVersion).toBe(1); + expect(output.command).toBe('nodes'); + expect(output.id).toBe(knownId); + + const validate = ajv.compile(schema); + expect(validate(output), JSON.stringify(validate.errors)).toBe(true); + }); + + it('doctor --json validates against doctor.schema.json', async () => { + const schema = await loadSchema('doctor.schema.json'); + const output = runCli(['doctor', '--json'], tempDir); + + expect(output.schemaVersion).toBe(1); + expect(output.command).toBe('doctor'); + + const validate = ajv.compile(schema); + expect(validate(output), JSON.stringify(validate.errors)).toBe(true); + }); + + it('export --json validates against export-data.schema.json', async () => { + const schema = await loadSchema('export-data.schema.json'); + const output = runCli(['export', '--json'], tempDir); + + expect(output.schemaVersion).toBe(1); + expect(output.command).toBe('export'); + expect(output.version).toBe(1); + + const validate = ajv.compile(schema); + expect(validate(output), JSON.stringify(validate.errors)).toBe(true); + }); + + it('export --file --json validates against export-file.schema.json', async () => { + const schema = await loadSchema('export-file.schema.json'); + const outPath = join(tempDir, 'export-test.yaml'); + const output = runCli(['export', '--file', outPath, '--json'], tempDir); + + expect(output.schemaVersion).toBe(1); + expect(output.command).toBe('export'); + expect(output.path).toBe(outPath); + + const validate = ajv.compile(schema); + expect(validate(output), JSON.stringify(validate.errors)).toBe(true); + }); + + it('import --dry-run --json validates against import.schema.json', async () => { + const schema = await loadSchema('import.schema.json'); + const output = runCli(['import', '--dry-run', FIXTURE, '--json'], tempDir); + + expect(output.schemaVersion).toBe(1); + expect(output.command).toBe('import'); + expect(output.valid).toBe(true); + + const validate = ajv.compile(schema); + expect(validate(output), JSON.stringify(validate.errors)).toBe(true); + }); +}); diff --git a/test/contracts.test.js b/test/contracts.test.js new file mode 100644 index 00000000..e5780c78 --- /dev/null +++ b/test/contracts.test.js @@ -0,0 +1,308 @@ +/** + * @module test/contracts + * Schema contract unit tests — validates JSON Schema files compile, + * require envelope fields, and accept/reject expected payloads. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { readdir, readFile } from 'node:fs/promises'; +import { join, basename } from 'node:path'; +import Ajv from 'ajv/dist/2020.js'; + +const SCHEMA_DIR = join(import.meta.dirname, '..', 'docs', 'contracts', 'cli'); + +/** Load all schema files from the contracts directory. */ +async function loadSchemas() { + const files = await readdir(SCHEMA_DIR); + const schemas = []; + for (const file of files.filter(f => f.endsWith('.schema.json'))) { + const content = JSON.parse(await readFile(join(SCHEMA_DIR, file), 'utf-8')); + schemas.push({ file, schema: content }); + } + return schemas; +} + +/** Sample valid payloads for each schema, keyed by filename. */ +const VALID_SAMPLES = { + 'node-detail.schema.json': { + schemaVersion: 1, + command: 'nodes', + id: 'task:build-ui', + prefix: 'task', + prefixClass: 'canonical', + properties: { status: 'done' }, + }, + 'node-list.schema.json': { + schemaVersion: 1, + command: 'nodes', + nodes: ['task:a', 'task:b'], + }, + 'status.schema.json': { + schemaVersion: 1, + command: 'status', + nodes: { total: 5, byPrefix: { task: 3, spec: 2 } }, + edges: { total: 4, byType: { implements: 2, 'relates-to': 2 } }, + health: { blockedItems: 0, lowConfidence: 1, orphanNodes: 0 }, + }, + 'at.schema.json': { + schemaVersion: 1, + command: 'at', + ref: 'HEAD~3', + sha: 'abcdef12', + fullSha: 'abcdef1234567890', + tick: 42, + nearest: false, + recordedAt: '2026-02-13T00:00:00Z', + status: { + nodes: { total: 3, byPrefix: { task: 3 } }, + edges: { total: 1, byType: { implements: 1 } }, + health: { blockedItems: 0, lowConfidence: 0, orphanNodes: 0 }, + }, + }, + 'import.schema.json': { + schemaVersion: 1, + command: 'import', + valid: true, + errors: [], + warnings: [], + stats: { nodes: 5, edges: 3 }, + dryRun: false, + }, + 'export-data.schema.json': { + schemaVersion: 1, + command: 'export', + version: 1, + nodes: [{ id: 'task:a' }, { id: 'task:b', properties: { status: 'done' } }], + edges: [{ source: 'task:a', target: 'task:b', type: 'blocks', confidence: 1.0 }], + }, + 'export-file.schema.json': { + schemaVersion: 1, + command: 'export', + stats: { nodes: 5, edges: 3 }, + path: '/tmp/export.yaml', + }, + 'merge.schema.json': { + schemaVersion: 1, + command: 'merge', + nodes: 10, + edges: 8, + repoName: 'owner/repo', + dryRun: false, + }, + 'doctor.schema.json': { + schemaVersion: 1, + command: 'doctor', + issues: [ + { + type: 'orphan-node', + severity: 'info', + message: 'Node task:orphan is not connected to any edge', + affected: ['task:orphan'], + }, + ], + summary: { errors: 0, warnings: 0, info: 1 }, + clean: false, + }, + 'suggest.schema.json': { + schemaVersion: 1, + command: 'suggest', + suggestions: [ + { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.8, rationale: 'test' }, + ], + errors: [], + prompt: 'Given the following graph...', + rejectedCount: 0, + }, + 'review-list.schema.json': { + schemaVersion: 1, + command: 'review', + pending: [ + { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }, + ], + }, + 'review-batch.schema.json': { + schemaVersion: 1, + command: 'review', + processed: 1, + decisions: [ + { + id: 'decision:1234-abcd', + action: 'accept', + source: 'task:a', + target: 'spec:b', + edgeType: 'implements', + confidence: 1.0, + timestamp: 1739500000, + }, + ], + }, + 'diff.schema.json': { + schemaVersion: 1, + command: 'diff', + from: { ref: 'HEAD~1', sha: 'aabbccdd', tick: 10, nearest: false }, + to: { ref: 'HEAD', sha: '11223344', tick: 20, nearest: false }, + nodes: { added: ['task:new'], removed: [], total: { before: 5, after: 6 } }, + edges: { added: [], removed: [], total: { before: 3, after: 3 } }, + summary: { + nodesByPrefix: { task: { before: 5, after: 6 } }, + edgesByType: { implements: { before: 3, after: 3 } }, + }, + stats: { + materializeMs: { a: 10, b: 12 }, + diffMs: 5, + nodeCount: { a: 5, b: 6 }, + edgeCount: { a: 3, b: 3 }, + }, + }, +}; + +describe('CLI JSON Schema contracts', () => { + let schemas; + let ajv; + + beforeAll(async () => { + schemas = await loadSchemas(); + ajv = new Ajv({ strict: true, allErrors: true }); + }); + + it('has exactly 13 schema files', () => { + expect(schemas).toHaveLength(13); + }); + + describe('schema compilation', () => { + it('every .schema.json compiles as valid JSON Schema', async () => { + for (const { file, schema } of schemas) { + const validate = ajv.compile(schema); + expect(validate).toBeDefined(); + } + }); + }); + + describe('envelope requirements', () => { + it('every schema requires schemaVersion', async () => { + for (const { file, schema } of schemas) { + expect(schema.required, `${file} missing required array`).toContain('schemaVersion'); + } + }); + + it('every schema requires command', async () => { + for (const { file, schema } of schemas) { + expect(schema.required, `${file} missing command in required`).toContain('command'); + } + }); + + it('every schema defines schemaVersion as integer const 1', async () => { + for (const { file, schema } of schemas) { + const sv = schema.properties?.schemaVersion; + expect(sv, `${file} missing schemaVersion property`).toBeDefined(); + expect(sv.type, `${file} schemaVersion type`).toBe('integer'); + expect(sv.const, `${file} schemaVersion const`).toBe(1); + } + }); + }); + + describe('sample validation', () => { + it('valid sample passes each schema', async () => { + for (const { file, schema } of schemas) { + const sample = VALID_SAMPLES[file]; + expect(sample, `missing valid sample for ${file}`).toBeDefined(); + const validate = ajv.compile(schema); + const valid = validate(structuredClone(sample)); + expect(valid, `${file}: ${JSON.stringify(validate.errors)}`).toBe(true); + } + }); + + it('missing schemaVersion rejected by every schema', async () => { + for (const { file, schema } of schemas) { + const sample = structuredClone(VALID_SAMPLES[file]); + delete sample.schemaVersion; + const validate = ajv.compile(schema); + expect(validate(sample), `${file} should reject missing schemaVersion`).toBe(false); + } + }); + + it('missing command rejected by every schema', async () => { + for (const { file, schema } of schemas) { + const sample = structuredClone(VALID_SAMPLES[file]); + delete sample.command; + const validate = ajv.compile(schema); + expect(validate(sample), `${file} should reject missing command`).toBe(false); + } + }); + + it('wrong schemaVersion rejected by every schema', async () => { + for (const { file, schema } of schemas) { + const sample = structuredClone(VALID_SAMPLES[file]); + sample.schemaVersion = 99; + const validate = ajv.compile(schema); + expect(validate(sample), `${file} should reject schemaVersion 99`).toBe(false); + } + }); + }); + + describe('optional fields', () => { + it('doctor schema accepts output with fix field', async () => { + const schema = schemas.find(s => s.file === 'doctor.schema.json')?.schema; + const sample = structuredClone(VALID_SAMPLES['doctor.schema.json']); + sample.fix = { fixed: 1, skipped: 0, details: ['Removed dangling edge'] }; + const validate = ajv.compile(schema); + expect(validate(sample)).toBe(true); + }); + + it('doctor schema accepts output without fix field', async () => { + const schema = schemas.find(s => s.file === 'doctor.schema.json')?.schema; + const sample = structuredClone(VALID_SAMPLES['doctor.schema.json']); + const validate = ajv.compile(schema); + expect(validate(sample)).toBe(true); + }); + + it('at schema accepts null recordedAt', async () => { + const schema = schemas.find(s => s.file === 'at.schema.json')?.schema; + const sample = structuredClone(VALID_SAMPLES['at.schema.json']); + sample.recordedAt = null; + const validate = ajv.compile(schema); + expect(validate(sample)).toBe(true); + }); + + it('at schema accepts missing recordedAt', async () => { + const schema = schemas.find(s => s.file === 'at.schema.json')?.schema; + const sample = structuredClone(VALID_SAMPLES['at.schema.json']); + delete sample.recordedAt; + const validate = ajv.compile(schema); + expect(validate(sample)).toBe(true); + }); + + it('diff schema accepts sameTick in stats', async () => { + const schema = schemas.find(s => s.file === 'diff.schema.json')?.schema; + const sample = structuredClone(VALID_SAMPLES['diff.schema.json']); + sample.stats.sameTick = true; + const validate = ajv.compile(schema); + expect(validate(sample)).toBe(true); + }); + + it('import schema accepts dryRun field', async () => { + const schema = schemas.find(s => s.file === 'import.schema.json')?.schema; + const sample = structuredClone(VALID_SAMPLES['import.schema.json']); + sample.dryRun = true; + const validate = ajv.compile(schema); + expect(validate(sample)).toBe(true); + }); + + it('review-list schema accepts optional rationale and createdAt', async () => { + const schema = schemas.find(s => s.file === 'review-list.schema.json')?.schema; + const sample = structuredClone(VALID_SAMPLES['review-list.schema.json']); + sample.pending[0].rationale = 'test rationale'; + sample.pending[0].createdAt = '2026-01-01T00:00:00Z'; + const validate = ajv.compile(schema); + expect(validate(sample)).toBe(true); + }); + + it('suggest schema accepts null prompt', async () => { + const schema = schemas.find(s => s.file === 'suggest.schema.json')?.schema; + const sample = structuredClone(VALID_SAMPLES['suggest.schema.json']); + sample.prompt = null; + const validate = ajv.compile(schema); + expect(validate(sample)).toBe(true); + }); + }); +}); From 8accc4b7c52728eebad9ba3d30851dd91ec3a93a Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 13 Feb 2026 22:15:01 -0800 Subject: [PATCH 4/8] docs: update GUIDE and CHANGELOG for schemaVersion (#205) Generalize JSON output section in GUIDE.md to cover all --json commands. Add [Unreleased] CHANGELOG entry with breaking change notes and migration snippets for nodes --json and review --json. --- CHANGELOG.md | 18 ++++++++++++++++++ GUIDE.md | 7 +++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index defbf6ed..1177b2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`schemaVersion: 1` and `command` fields in all `--json` outputs** — Every CLI command that supports `--json` now includes a `schemaVersion` (integer, currently `1`) and `command` (string) field in its output envelope. The CLI layer is authoritative via the `outputJson()` helper (#205) +- **JSON Schema files** — 13 Draft 2020-12 JSON Schema files in `docs/contracts/cli/` for programmatic validation of every `--json` output. Strict `additionalProperties: false` at top level; open objects where extensibility is intentional (node properties, prefix maps) (#205) +- **Contract validation tests** — `test/contracts.test.js` (17 unit tests) validates schema compilation, envelope requirements, sample payloads, and optional field handling. `test/contracts.integration.test.js` (7 CLI canary tests) executes the real binary and validates output against schemas using `ajv` (#205) +- **CLI Contracts documentation** — `docs/contracts/CLI_CONTRACTS.md` with version policy, command-to-schema table, programmatic validation example, and migration guide (#205) + +### Breaking + +- **`nodes --json` output wrapped** — Previously returned a bare JSON array; now returns `{ schemaVersion: 1, command: "nodes", nodes: [...] }`. Migration: `jq '.[]'` → `jq '.nodes[]'` (#205) +- **`review --json` output wrapped** — Previously returned a bare JSON array; now returns `{ schemaVersion: 1, command: "review", pending: [...] }`. Migration: `jq '.[].source'` → `jq '.pending[].source'` (#205) + +### Changed + +- **Test count** — 366 tests across 22 files (was 342 across 20) + ## [2.0.0-alpha.5] - 2026-02-13 ### Added diff --git a/GUIDE.md b/GUIDE.md index f75905f0..85628be6 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -624,13 +624,16 @@ git mind diff HEAD~5..HEAD --prefix module ### JSON output and versioning -The `--json` output includes a `schemaVersion` field (currently `1`). Breaking changes to the JSON structure will increment this version, so downstream tools can detect incompatible output. +All `--json` outputs include a standard envelope with `schemaVersion` (currently `1`) and `command` fields. Breaking changes to any JSON structure will increment `schemaVersion`, so downstream tools can detect incompatible output. ```bash -git mind diff HEAD~5..HEAD --json | jq '.schemaVersion' +git mind status --json | jq '.schemaVersion, .command' # 1 +# "status" ``` +JSON Schema files for every command are published in [`docs/contracts/cli/`](docs/contracts/CLI_CONTRACTS.md). See the [CLI Contracts guide](docs/contracts/CLI_CONTRACTS.md) for validation examples and migration notes. + ### Nearest-epoch fallback If a ref doesn't have an exact epoch marker, the diff engine walks up the commit ancestry to find the nearest one. When this happens, the TTY output shows a warning icon next to the endpoint. From 806f0d83c0b99692ddf3bbcb6b054dd6ca83608d Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 13 Feb 2026 22:24:40 -0800 Subject: [PATCH 5/8] chore: bump to v3.0.0 (#205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking changes to nodes --json and review --json output format (bare arrays → wrapped objects with schemaVersion envelope). --- CHANGELOG.md | 3 ++- package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1177b2b5..7d60d329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [3.0.0] - 2026-02-13 ### Added @@ -237,6 +237,7 @@ Complete rewrite from C23 to Node.js on `@git-stunts/git-warp`. - Docker-based CI/CD - All C-specific documentation +[3.0.0]: https://github.com/neuroglyph/git-mind/releases/tag/v3.0.0 [2.0.0-alpha.5]: https://github.com/neuroglyph/git-mind/releases/tag/v2.0.0-alpha.5 [2.0.0-alpha.4]: https://github.com/neuroglyph/git-mind/releases/tag/v2.0.0-alpha.4 [2.0.0-alpha.3]: https://github.com/neuroglyph/git-mind/releases/tag/v2.0.0-alpha.3 diff --git a/package.json b/package.json index 43ac5196..84201b43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@neuroglyph/git-mind", - "version": "2.0.0-alpha.3", + "version": "3.0.0", "description": "A project knowledge graph tool built on git-warp", "type": "module", "license": "Apache-2.0", From 05436f6a4fa5e0b7272b9cd45884fedd8a691c43 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 14 Feb 2026 00:12:17 -0800 Subject: [PATCH 6/8] fix: address round-1 review feedback (#205) - Add missing diff --json row to CLI_CONTRACTS.md command table - Fix version references to "prior to v3.0.0" - Add nodes command disambiguation note - Guard afterAll rm against undefined tempDir - Wrap runCli JSON.parse in try/catch for better diagnostics - Remove unnecessary async from test callbacks --- docs/contracts/CLI_CONTRACTS.md | 7 +++++-- test/contracts.integration.test.js | 8 ++++++-- test/contracts.test.js | 32 +++++++++++++++--------------- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/docs/contracts/CLI_CONTRACTS.md b/docs/contracts/CLI_CONTRACTS.md index aa84a637..0a98a4ea 100644 --- a/docs/contracts/CLI_CONTRACTS.md +++ b/docs/contracts/CLI_CONTRACTS.md @@ -40,6 +40,9 @@ Every `--json` output from the git-mind CLI includes a standard envelope: | `suggest --json` | [`suggest.schema.json`](cli/suggest.schema.json) | | `review --json` | [`review-list.schema.json`](cli/review-list.schema.json) | | `review --batch --json` | [`review-batch.schema.json`](cli/review-batch.schema.json) | +| `diff .. --json` | [`diff.schema.json`](cli/diff.schema.json) | + +> **Note:** `nodes --id` and `nodes` (list) both emit `"command": "nodes"`. To select the correct schema, check for a top-level `id` field (node-detail) vs. a `nodes` array (node-list). ## Programmatic Validation @@ -63,7 +66,7 @@ if (!validate(output)) { The output changed from a bare JSON array to a wrapped object. ```bash -# Before (v2.0.0-alpha.5 and earlier): +# Before (prior to v3.0.0): git mind nodes --json | jq '.[]' # After: @@ -75,7 +78,7 @@ git mind nodes --json | jq '.nodes[]' The output changed from a bare JSON array to a wrapped object. ```bash -# Before (v2.0.0-alpha.5 and earlier): +# Before (prior to v3.0.0): git mind review --json | jq '.[].source' # After: diff --git a/test/contracts.integration.test.js b/test/contracts.integration.test.js index 06ba9aea..2ae9260c 100644 --- a/test/contracts.integration.test.js +++ b/test/contracts.integration.test.js @@ -28,7 +28,11 @@ function runCli(args, cwd) { timeout: 30_000, env: { ...process.env, NO_COLOR: '1' }, }); - return JSON.parse(stdout); + try { + return JSON.parse(stdout); + } catch (err) { + throw new Error(`Failed to parse CLI output for [${args.join(' ')}]:\n${stdout}`, { cause: err }); + } } describe('CLI schema contract canaries', () => { @@ -52,7 +56,7 @@ describe('CLI schema contract canaries', () => { }); afterAll(async () => { - await rm(tempDir, { recursive: true, force: true }); + if (tempDir) await rm(tempDir, { recursive: true, force: true }); }); it('status --json validates against status.schema.json', async () => { diff --git a/test/contracts.test.js b/test/contracts.test.js index e5780c78..129fa7a7 100644 --- a/test/contracts.test.js +++ b/test/contracts.test.js @@ -170,7 +170,7 @@ describe('CLI JSON Schema contracts', () => { }); describe('schema compilation', () => { - it('every .schema.json compiles as valid JSON Schema', async () => { + it('every .schema.json compiles as valid JSON Schema', () => { for (const { file, schema } of schemas) { const validate = ajv.compile(schema); expect(validate).toBeDefined(); @@ -179,19 +179,19 @@ describe('CLI JSON Schema contracts', () => { }); describe('envelope requirements', () => { - it('every schema requires schemaVersion', async () => { + it('every schema requires schemaVersion', () => { for (const { file, schema } of schemas) { expect(schema.required, `${file} missing required array`).toContain('schemaVersion'); } }); - it('every schema requires command', async () => { + it('every schema requires command', () => { for (const { file, schema } of schemas) { expect(schema.required, `${file} missing command in required`).toContain('command'); } }); - it('every schema defines schemaVersion as integer const 1', async () => { + it('every schema defines schemaVersion as integer const 1', () => { for (const { file, schema } of schemas) { const sv = schema.properties?.schemaVersion; expect(sv, `${file} missing schemaVersion property`).toBeDefined(); @@ -202,7 +202,7 @@ describe('CLI JSON Schema contracts', () => { }); describe('sample validation', () => { - it('valid sample passes each schema', async () => { + it('valid sample passes each schema', () => { for (const { file, schema } of schemas) { const sample = VALID_SAMPLES[file]; expect(sample, `missing valid sample for ${file}`).toBeDefined(); @@ -212,7 +212,7 @@ describe('CLI JSON Schema contracts', () => { } }); - it('missing schemaVersion rejected by every schema', async () => { + it('missing schemaVersion rejected by every schema', () => { for (const { file, schema } of schemas) { const sample = structuredClone(VALID_SAMPLES[file]); delete sample.schemaVersion; @@ -221,7 +221,7 @@ describe('CLI JSON Schema contracts', () => { } }); - it('missing command rejected by every schema', async () => { + it('missing command rejected by every schema', () => { for (const { file, schema } of schemas) { const sample = structuredClone(VALID_SAMPLES[file]); delete sample.command; @@ -230,7 +230,7 @@ describe('CLI JSON Schema contracts', () => { } }); - it('wrong schemaVersion rejected by every schema', async () => { + it('wrong schemaVersion rejected by every schema', () => { for (const { file, schema } of schemas) { const sample = structuredClone(VALID_SAMPLES[file]); sample.schemaVersion = 99; @@ -241,7 +241,7 @@ describe('CLI JSON Schema contracts', () => { }); describe('optional fields', () => { - it('doctor schema accepts output with fix field', async () => { + it('doctor schema accepts output with fix field', () => { const schema = schemas.find(s => s.file === 'doctor.schema.json')?.schema; const sample = structuredClone(VALID_SAMPLES['doctor.schema.json']); sample.fix = { fixed: 1, skipped: 0, details: ['Removed dangling edge'] }; @@ -249,14 +249,14 @@ describe('CLI JSON Schema contracts', () => { expect(validate(sample)).toBe(true); }); - it('doctor schema accepts output without fix field', async () => { + it('doctor schema accepts output without fix field', () => { const schema = schemas.find(s => s.file === 'doctor.schema.json')?.schema; const sample = structuredClone(VALID_SAMPLES['doctor.schema.json']); const validate = ajv.compile(schema); expect(validate(sample)).toBe(true); }); - it('at schema accepts null recordedAt', async () => { + it('at schema accepts null recordedAt', () => { const schema = schemas.find(s => s.file === 'at.schema.json')?.schema; const sample = structuredClone(VALID_SAMPLES['at.schema.json']); sample.recordedAt = null; @@ -264,7 +264,7 @@ describe('CLI JSON Schema contracts', () => { expect(validate(sample)).toBe(true); }); - it('at schema accepts missing recordedAt', async () => { + it('at schema accepts missing recordedAt', () => { const schema = schemas.find(s => s.file === 'at.schema.json')?.schema; const sample = structuredClone(VALID_SAMPLES['at.schema.json']); delete sample.recordedAt; @@ -272,7 +272,7 @@ describe('CLI JSON Schema contracts', () => { expect(validate(sample)).toBe(true); }); - it('diff schema accepts sameTick in stats', async () => { + it('diff schema accepts sameTick in stats', () => { const schema = schemas.find(s => s.file === 'diff.schema.json')?.schema; const sample = structuredClone(VALID_SAMPLES['diff.schema.json']); sample.stats.sameTick = true; @@ -280,7 +280,7 @@ describe('CLI JSON Schema contracts', () => { expect(validate(sample)).toBe(true); }); - it('import schema accepts dryRun field', async () => { + it('import schema accepts dryRun field', () => { const schema = schemas.find(s => s.file === 'import.schema.json')?.schema; const sample = structuredClone(VALID_SAMPLES['import.schema.json']); sample.dryRun = true; @@ -288,7 +288,7 @@ describe('CLI JSON Schema contracts', () => { expect(validate(sample)).toBe(true); }); - it('review-list schema accepts optional rationale and createdAt', async () => { + it('review-list schema accepts optional rationale and createdAt', () => { const schema = schemas.find(s => s.file === 'review-list.schema.json')?.schema; const sample = structuredClone(VALID_SAMPLES['review-list.schema.json']); sample.pending[0].rationale = 'test rationale'; @@ -297,7 +297,7 @@ describe('CLI JSON Schema contracts', () => { expect(validate(sample)).toBe(true); }); - it('suggest schema accepts null prompt', async () => { + it('suggest schema accepts null prompt', () => { const schema = schemas.find(s => s.file === 'suggest.schema.json')?.schema; const sample = structuredClone(VALID_SAMPLES['suggest.schema.json']); sample.prompt = null; From d21d5d38cf7095e3650f797761000e4066c551cf Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 14 Feb 2026 00:28:36 -0800 Subject: [PATCH 7/8] fix: address round-2 review feedback (#205) - Replace hardcoded schema count (13) with dynamic sample coverage check - Precompile all schemas once in beforeAll Map, reuse across tests - Fix dryRun test to verify field optionality (delete vs set true) - Add existsSync guard for fixture file in beforeAll - Improve nodes[0] assertion with descriptive diagnostic message - Add review --json integration canary test --- test/contracts.integration.test.js | 18 ++++++++- test/contracts.test.js | 62 +++++++++++++++--------------- 2 files changed, 48 insertions(+), 32 deletions(-) diff --git a/test/contracts.integration.test.js b/test/contracts.integration.test.js index 2ae9260c..f919a7ce 100644 --- a/test/contracts.integration.test.js +++ b/test/contracts.integration.test.js @@ -5,6 +5,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { existsSync } from 'node:fs'; import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -52,6 +53,9 @@ describe('CLI schema contract canaries', () => { execFileSync(process.execPath, [BIN, 'init'], { cwd: tempDir, stdio: 'ignore' }); // Import the echo-seed fixture + if (!existsSync(FIXTURE)) { + throw new Error(`Fixture not found: ${FIXTURE}`); + } execFileSync(process.execPath, [BIN, 'import', FIXTURE], { cwd: tempDir, stdio: 'ignore' }); }); @@ -85,8 +89,8 @@ describe('CLI schema contract canaries', () => { it('nodes --id --json validates against node-detail.schema.json', async () => { // Get a known node from the list first const listOutput = runCli(['nodes', '--json'], tempDir); + expect(listOutput.nodes.length, 'seed fixture should produce at least one node — check echo-seed.yaml import').toBeGreaterThan(0); const knownId = listOutput.nodes[0]; - expect(knownId).toBeDefined(); const schema = await loadSchema('node-detail.schema.json'); const output = runCli(['nodes', '--id', knownId, '--json'], tempDir); @@ -146,4 +150,16 @@ describe('CLI schema contract canaries', () => { const validate = ajv.compile(schema); expect(validate(output), JSON.stringify(validate.errors)).toBe(true); }); + + it('review --json validates against review-list.schema.json', async () => { + const schema = await loadSchema('review-list.schema.json'); + const output = runCli(['review', '--json'], tempDir); + + expect(output.schemaVersion).toBe(1); + expect(output.command).toBe('review'); + expect(Array.isArray(output.pending)).toBe(true); + + const validate = ajv.compile(schema); + expect(validate(output), JSON.stringify(validate.errors)).toBe(true); + }); }); diff --git a/test/contracts.test.js b/test/contracts.test.js index 129fa7a7..ff073951 100644 --- a/test/contracts.test.js +++ b/test/contracts.test.js @@ -159,21 +159,29 @@ const VALID_SAMPLES = { describe('CLI JSON Schema contracts', () => { let schemas; let ajv; + /** @type {Map} */ + let validators; beforeAll(async () => { schemas = await loadSchemas(); ajv = new Ajv({ strict: true, allErrors: true }); + validators = new Map(); + for (const { file, schema } of schemas) { + validators.set(file, ajv.compile(schema)); + } }); - it('has exactly 13 schema files', () => { - expect(schemas).toHaveLength(13); + it('every schema file has a valid sample', () => { + expect(schemas.length).toBeGreaterThan(0); + for (const { file } of schemas) { + expect(VALID_SAMPLES[file], `missing VALID_SAMPLES entry for ${file}`).toBeDefined(); + } }); describe('schema compilation', () => { it('every .schema.json compiles as valid JSON Schema', () => { - for (const { file, schema } of schemas) { - const validate = ajv.compile(schema); - expect(validate).toBeDefined(); + for (const { file } of schemas) { + expect(validators.get(file), `${file} failed to compile`).toBeDefined(); } }); }); @@ -203,38 +211,38 @@ describe('CLI JSON Schema contracts', () => { describe('sample validation', () => { it('valid sample passes each schema', () => { - for (const { file, schema } of schemas) { + for (const { file } of schemas) { const sample = VALID_SAMPLES[file]; expect(sample, `missing valid sample for ${file}`).toBeDefined(); - const validate = ajv.compile(schema); + const validate = validators.get(file); const valid = validate(structuredClone(sample)); expect(valid, `${file}: ${JSON.stringify(validate.errors)}`).toBe(true); } }); it('missing schemaVersion rejected by every schema', () => { - for (const { file, schema } of schemas) { + for (const { file } of schemas) { const sample = structuredClone(VALID_SAMPLES[file]); delete sample.schemaVersion; - const validate = ajv.compile(schema); + const validate = validators.get(file); expect(validate(sample), `${file} should reject missing schemaVersion`).toBe(false); } }); it('missing command rejected by every schema', () => { - for (const { file, schema } of schemas) { + for (const { file } of schemas) { const sample = structuredClone(VALID_SAMPLES[file]); delete sample.command; - const validate = ajv.compile(schema); + const validate = validators.get(file); expect(validate(sample), `${file} should reject missing command`).toBe(false); } }); it('wrong schemaVersion rejected by every schema', () => { - for (const { file, schema } of schemas) { + for (const { file } of schemas) { const sample = structuredClone(VALID_SAMPLES[file]); sample.schemaVersion = 99; - const validate = ajv.compile(schema); + const validate = validators.get(file); expect(validate(sample), `${file} should reject schemaVersion 99`).toBe(false); } }); @@ -242,66 +250,58 @@ describe('CLI JSON Schema contracts', () => { describe('optional fields', () => { it('doctor schema accepts output with fix field', () => { - const schema = schemas.find(s => s.file === 'doctor.schema.json')?.schema; const sample = structuredClone(VALID_SAMPLES['doctor.schema.json']); sample.fix = { fixed: 1, skipped: 0, details: ['Removed dangling edge'] }; - const validate = ajv.compile(schema); + const validate = validators.get('doctor.schema.json'); expect(validate(sample)).toBe(true); }); it('doctor schema accepts output without fix field', () => { - const schema = schemas.find(s => s.file === 'doctor.schema.json')?.schema; const sample = structuredClone(VALID_SAMPLES['doctor.schema.json']); - const validate = ajv.compile(schema); + const validate = validators.get('doctor.schema.json'); expect(validate(sample)).toBe(true); }); it('at schema accepts null recordedAt', () => { - const schema = schemas.find(s => s.file === 'at.schema.json')?.schema; const sample = structuredClone(VALID_SAMPLES['at.schema.json']); sample.recordedAt = null; - const validate = ajv.compile(schema); + const validate = validators.get('at.schema.json'); expect(validate(sample)).toBe(true); }); it('at schema accepts missing recordedAt', () => { - const schema = schemas.find(s => s.file === 'at.schema.json')?.schema; const sample = structuredClone(VALID_SAMPLES['at.schema.json']); delete sample.recordedAt; - const validate = ajv.compile(schema); + const validate = validators.get('at.schema.json'); expect(validate(sample)).toBe(true); }); it('diff schema accepts sameTick in stats', () => { - const schema = schemas.find(s => s.file === 'diff.schema.json')?.schema; const sample = structuredClone(VALID_SAMPLES['diff.schema.json']); sample.stats.sameTick = true; - const validate = ajv.compile(schema); + const validate = validators.get('diff.schema.json'); expect(validate(sample)).toBe(true); }); - it('import schema accepts dryRun field', () => { - const schema = schemas.find(s => s.file === 'import.schema.json')?.schema; + it('import schema allows missing dryRun (optional)', () => { const sample = structuredClone(VALID_SAMPLES['import.schema.json']); - sample.dryRun = true; - const validate = ajv.compile(schema); + delete sample.dryRun; + const validate = validators.get('import.schema.json'); expect(validate(sample)).toBe(true); }); it('review-list schema accepts optional rationale and createdAt', () => { - const schema = schemas.find(s => s.file === 'review-list.schema.json')?.schema; const sample = structuredClone(VALID_SAMPLES['review-list.schema.json']); sample.pending[0].rationale = 'test rationale'; sample.pending[0].createdAt = '2026-01-01T00:00:00Z'; - const validate = ajv.compile(schema); + const validate = validators.get('review-list.schema.json'); expect(validate(sample)).toBe(true); }); it('suggest schema accepts null prompt', () => { - const schema = schemas.find(s => s.file === 'suggest.schema.json')?.schema; const sample = structuredClone(VALID_SAMPLES['suggest.schema.json']); sample.prompt = null; - const validate = ajv.compile(schema); + const validate = validators.get('suggest.schema.json'); expect(validate(sample)).toBe(true); }); }); From e621e0d3157b1ce513601278ead2fcffd0893a0b Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 14 Feb 2026 00:29:08 -0800 Subject: [PATCH 8/8] docs: update CHANGELOG test counts for round-2 fixes (#205) --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d60d329..7fa15f4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`schemaVersion: 1` and `command` fields in all `--json` outputs** — Every CLI command that supports `--json` now includes a `schemaVersion` (integer, currently `1`) and `command` (string) field in its output envelope. The CLI layer is authoritative via the `outputJson()` helper (#205) - **JSON Schema files** — 13 Draft 2020-12 JSON Schema files in `docs/contracts/cli/` for programmatic validation of every `--json` output. Strict `additionalProperties: false` at top level; open objects where extensibility is intentional (node properties, prefix maps) (#205) -- **Contract validation tests** — `test/contracts.test.js` (17 unit tests) validates schema compilation, envelope requirements, sample payloads, and optional field handling. `test/contracts.integration.test.js` (7 CLI canary tests) executes the real binary and validates output against schemas using `ajv` (#205) +- **Contract validation tests** — `test/contracts.test.js` (17 unit tests) validates schema compilation, envelope requirements, sample payloads, and optional field handling. `test/contracts.integration.test.js` (8 CLI canary tests) executes the real binary and validates output against schemas using `ajv` (#205) - **CLI Contracts documentation** — `docs/contracts/CLI_CONTRACTS.md` with version policy, command-to-schema table, programmatic validation example, and migration guide (#205) ### Breaking @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Test count** — 366 tests across 22 files (was 342 across 20) +- **Test count** — 367 tests across 22 files (was 342 across 20) ## [2.0.0-alpha.5] - 2026-02-13