spec-kitty / spec-kitty/spec-kitty
`--json` commands leak prose on stdout outside a project beyond the doctor family (archive, materialize) — enforce the contract at the locate_project_root choke point
- Dominant language
- Python
- Stars
- 1.6k
- Forks
- 165
- Avg merge
- 14h 52m
- Merged PRs (30d)
- 303
Description
## What / for whom
The `--json` machine contract — "on every exit path, stdout is one parseable JSON object, never bare human prose" — is enforced per-command-family, not at the choke point, so it keeps regressing in new places. #1336 fixed it for the **charter** family, #2879 for **orchestrator-api**, and **#4246** for the **doctor** family (#4242). A landing-pass squad on #4246 confirmed the **same leak still lives in non-doctor `--json` commands**, so a consumer piping ` --json | jq` from outside a project still hits a parse error indistinguishable from a crash.
**For whom:** agents / CI / operators consuming `--json` output as a machine contract.
## Evidence
Both accept `--json` and print `Error: Not in a spec-kitty project` (Rich prose) on **stdout** on the not-in-project path, exit non-zero — not JSON:
- `src/specify_cli/cli/commands/archive.py:62,115`
- `src/specify_cli/cli/commands/materialize.py:55`
These are **not** doctor commands, so the doctor-scoped `_doctor_shared._emit_not_in_project` renderer #4246 introduced cannot serve them without either duplicating the renderer a fourth time or pulling a cross-command dependency. That is the signal: the leak is codebase-wide and the **true choke point is `locate_project_root` / the CLI not-in-project boundary**, not any per-family renderer.
## Suggested fix
Introduce one shared *resolve-root-or-emit-machine-error* seam at the `core.paths` / CLI boundary that **any** `--json` command consumes — a single place that turns a not-in-project outcome into either the canonical `{"ok": false, "error": {"code": "not_in_project", "message": "Not in a spec-kitty project"}}` JSON envelope (under `--json`) or the Rich prose line (human path), with the caller's own exit code. Then migrate the per-family renderers (charter, orchestrator-api, doctor's `_emit_not_in_project`, and the archive/materialize leaks) onto it. A contract test that enumerates every typer command accepting `--json`/`json_output` and asserts `json.loads(stdout)` succeeds on the repo-root-missing path (the test #1336's acceptance proposed but never delivered) would catch the whole class and prevent the next one.
## Scope note
This is design-bearing (a new shared seam + a repo-wide migration), so it is **not** a landing-pass fold — it needs its own mission. Filed per the #4246 landing runbook.
Related: #4246, #4242, #1336 (closed), #2879 (closed). Sibling finding (doctor-family-internal guard SSOT): filed separately under #2720.
Contributor guide
Research direction
Start at locate_project_root and the CLI not-in-project boundary, then inspect src/specify_cli/cli/commands/archive.py, materialize.py, and the existing doctor, charter, and orchestrator-api renderers. Define the shared machine-error seam and add a contract test covering every command that accepts --json or json_output. Done means the not-in-project path emits parseable canonical JSON for machine mode and preserves Rich prose for human mode with the caller's exit code.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- cli, testing-qa
- Issue type
- Refactor
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100