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

Open
#4,533 0 comments 0 reactions 0 assignees View on GitHub
priority:P2 reliability type:finding type:fix
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.