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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ application.
cancellation rules.
- Review the [JSON contracts](json-contracts.md) and [output contracts](output-contracts.md)
before building automation around command output.
- Use the [strict JSON consumer guide](strict-json-consumer.md) to validate
envelope and NDJSON records with a cross-language parser.

## Design principles

Expand Down
79 changes: 79 additions & 0 deletions docs/strict-json-consumer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Strict JSON consumer validation

Use a strict cross-language parser when a downstream application consumes
`base-cli` machine output. Python's `json` module is useful for local tooling,
but Node's built-in `JSON.parse` is a closer model for a consumer that must
reject malformed JSON before using the payload.

## Validate one JSON envelope

The repository's success fixture is a representative `base-cli.output` JSON
document. From the repository root, validate its syntax and stable top-level
contract with Node:

```bash
node -e '
const fs = require("node:fs");
const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const required = ["schema_version", "schema", "code", "type", "message", "details", "run_id"];
for (const key of required) {
if (!(key in payload)) throw new Error(`missing ${key}`);
}
if (payload.schema_version !== 1 || payload.schema !== "base-cli.output") {
throw new Error("unexpected base-cli output contract");
}
console.log(`${payload.schema} ${payload.code}`);
' tests/fixtures/contracts/output-success.json
```

The expected output is `base-cli.output ok`. `JSON.parse` validates syntax and
the small assertions validate the envelope identity; it does not make
undocumented nested fields stable. Consumers should treat `details.stdout` as
the captured string described by the [JSON contract](json-contracts.md), and
preserve the process exit status and stderr boundary.

## Validate NDJSON one record at a time

NDJSON is a stream of independent records, not one JSON envelope. Parse each
non-empty line separately and reject the stream if any line is malformed:

```bash
node -e '
const fs = require("node:fs");
const lines = fs.readFileSync(process.argv[1], "utf8").split(/\r?\n/).filter(Boolean);
for (const [index, line] of lines.entries()) {
const record = JSON.parse(line);
if (record.schema_version !== 1 || record.schema !== "base-cli.record") {
throw new Error(`unexpected record contract on line ${index + 1}`);
}
}
console.log(`validated ${lines.length} record(s)`);
' tests/fixtures/contracts/ndjson-record.json
```

The expected output is `validated 1 record(s)`. Do not concatenate NDJSON lines
and pass them to one `JSON.parse` call; consume one record per line and keep
diagnostics on stderr.

## Syntax failure versus contract failure

Consumers should distinguish two failures:

- malformed JSON: `JSON.parse` throws a `SyntaxError`; reject the complete
payload or record stream and report the producer's exit status;
- valid JSON with an invalid shape: parsing succeeds, but schema validation
must reject it. The repository's
repository-owned `scripts/validate_contract_fixtures.mjs` checks both the
valid fixtures and the intentionally invalid
`tests/fixtures/contracts/invalid-output-extra-field.json` fixture.

Run the repository-owned cross-language contract check with:

```bash
node scripts/validate_contract_fixtures.mjs
```

The schemas and fixtures are the source of truth. Do not add a new parser or
redefine the wire contract in an adopter guide; link the exact contract version
and record the `base-cli` release used for validation. Never place secrets or
private paths in fixtures or public failure reports.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ nav:
- argparse migration: migration-argparse.md
- Output contracts: output-contracts.md
- JSON contracts: json-contracts.md
- Strict JSON consumer: strict-json-consumer.md
- JSON Schema artifacts: schemas.md
- Typed user configuration: user-config-typing.md
- Entry-point extensions: extensions.md
Expand Down
1 change: 1 addition & 0 deletions scripts/validate_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"security-review.md",
"security-threat-model.md",
"schemas.md",
"strict-json-consumer.md",
"typer-adapter.md",
"user-config-typing.md",
}
Expand Down
27 changes: 27 additions & 0 deletions tests/test_strict_json_consumer_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from pathlib import Path

ROOT = Path(__file__).parents[1]
DOC = ROOT / "docs" / "strict-json-consumer.md"
INDEX = ROOT / "docs" / "index.md"
VALIDATOR = ROOT / "scripts" / "validate_docs.py"


def test_strict_json_consumer_guide_is_linked_from_the_documentation_index() -> None:
assert "strict-json-consumer.md" in INDEX.read_text(encoding="utf-8")
assert "strict-json-consumer.md" in VALIDATOR.read_text(encoding="utf-8")


def test_strict_json_consumer_guide_covers_json_and_ndjson_contract_boundaries() -> None:
text = DOC.read_text(encoding="utf-8")

for required in (
"JSON.parse",
"base-cli.output",
"base-cli.record",
"output-success.json",
"ndjson-record.json",
"invalid-output-extra-field.json",
"validate_contract_fixtures.mjs",
"stderr",
):
assert required in text
Loading