diff --git a/CHANGELOG.md b/CHANGELOG.md index defbf6ed..7fa15f4c 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). +## [3.0.0] - 2026-02-13 + +### 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` (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 + +- **`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** — 367 tests across 22 files (was 342 across 20) + ## [2.0.0-alpha.5] - 2026-02-13 ### Added @@ -219,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/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. diff --git a/docs/contracts/CLI_CONTRACTS.md b/docs/contracts/CLI_CONTRACTS.md new file mode 100644 index 00000000..0a98a4ea --- /dev/null +++ b/docs/contracts/CLI_CONTRACTS.md @@ -0,0 +1,86 @@ +# 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) | +| `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 + +```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 (prior to v3.0.0): +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 (prior to v3.0.0): +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 } + } +} 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..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", @@ -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/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)); } diff --git a/test/contracts.integration.test.js b/test/contracts.integration.test.js new file mode 100644 index 00000000..f919a7ce --- /dev/null +++ b/test/contracts.integration.test.js @@ -0,0 +1,165 @@ +/** + * @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 { existsSync } from 'node:fs'; +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' }, + }); + 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', () => { + 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 + if (!existsSync(FIXTURE)) { + throw new Error(`Fixture not found: ${FIXTURE}`); + } + execFileSync(process.execPath, [BIN, 'import', FIXTURE], { cwd: tempDir, stdio: 'ignore' }); + }); + + afterAll(async () => { + if (tempDir) 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); + expect(listOutput.nodes.length, 'seed fixture should produce at least one node — check echo-seed.yaml import').toBeGreaterThan(0); + const knownId = listOutput.nodes[0]; + + 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); + }); + + 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 new file mode 100644 index 00000000..ff073951 --- /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; + /** @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('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 } of schemas) { + expect(validators.get(file), `${file} failed to compile`).toBeDefined(); + } + }); + }); + + describe('envelope requirements', () => { + 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', () => { + 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', () => { + 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', () => { + for (const { file } of schemas) { + const sample = VALID_SAMPLES[file]; + expect(sample, `missing valid sample for ${file}`).toBeDefined(); + 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 } of schemas) { + const sample = structuredClone(VALID_SAMPLES[file]); + delete sample.schemaVersion; + 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 } of schemas) { + const sample = structuredClone(VALID_SAMPLES[file]); + delete sample.command; + 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 } of schemas) { + const sample = structuredClone(VALID_SAMPLES[file]); + sample.schemaVersion = 99; + const validate = validators.get(file); + expect(validate(sample), `${file} should reject schemaVersion 99`).toBe(false); + } + }); + }); + + describe('optional fields', () => { + it('doctor schema accepts output with fix field', () => { + const sample = structuredClone(VALID_SAMPLES['doctor.schema.json']); + sample.fix = { fixed: 1, skipped: 0, details: ['Removed dangling edge'] }; + const validate = validators.get('doctor.schema.json'); + expect(validate(sample)).toBe(true); + }); + + it('doctor schema accepts output without fix field', () => { + const sample = structuredClone(VALID_SAMPLES['doctor.schema.json']); + const validate = validators.get('doctor.schema.json'); + expect(validate(sample)).toBe(true); + }); + + it('at schema accepts null recordedAt', () => { + const sample = structuredClone(VALID_SAMPLES['at.schema.json']); + sample.recordedAt = null; + const validate = validators.get('at.schema.json'); + expect(validate(sample)).toBe(true); + }); + + it('at schema accepts missing recordedAt', () => { + const sample = structuredClone(VALID_SAMPLES['at.schema.json']); + delete sample.recordedAt; + const validate = validators.get('at.schema.json'); + expect(validate(sample)).toBe(true); + }); + + it('diff schema accepts sameTick in stats', () => { + const sample = structuredClone(VALID_SAMPLES['diff.schema.json']); + sample.stats.sameTick = true; + const validate = validators.get('diff.schema.json'); + expect(validate(sample)).toBe(true); + }); + + it('import schema allows missing dryRun (optional)', () => { + const sample = structuredClone(VALID_SAMPLES['import.schema.json']); + 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 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 = validators.get('review-list.schema.json'); + expect(validate(sample)).toBe(true); + }); + + it('suggest schema accepts null prompt', () => { + const sample = structuredClone(VALID_SAMPLES['suggest.schema.json']); + sample.prompt = null; + const validate = validators.get('suggest.schema.json'); + expect(validate(sample)).toBe(true); + }); + }); +});