fix(cli)!: validate --output against a per-command format contract - #127
Conversation
…alth `validate --all` folded `.stem` health diagnostics and directory structural verdicts into the same `results` slice and `summary.total` as documents, so `total` was not a record count: a three-document corpus with two health findings reported 5 while `query --count`, `tree --field root.total` and `stats --field total` all reported 3 on the same path. Those pseudo-records also survived a `--where` filter they carry no frontmatter to match, and an emptied or renamed path reported `total: 1, valid: 1` — the `stem-files-exist` entry — which a CI gate read as green. Two of the twelve health checks were unreachable through the command. Stem health ran first, then `index.Scan` failed and every Phase-1 diagnostic was discarded: `yaml-valid` and `stem-files-exist` are exactly the checks that fire when the scan cannot succeed, so a consumer asking for JSON got a raw Go error on stderr and no JSON at all. The envelope also changed shape by invocation: a bare `rootline/validate` object for one file, `rootline/validate-batch` for several, and zero bytes for `--staged` with an empty index — breaking the pre-commit hook idiom `rootline validate --staged | jq -e '.summary.invalid == 0'`. Now every invocation emits `rootline/validate-batch` version 2 with six always-present keys: `results` (documents), `structural` (directories), `stem_health` (`.stem` files, with `error`/`warn`/`info` severity), `notices` (run-level, keyed by a stable `code`), `drift_warnings`, and `summary`. The populations are disjoint and counted on their own axes; splitting them changed where a verdict is reported, never whether it counts, so an error on any axis still exits 1. Also fixed, in the same surface: - `nested-root-marker` is delivered at `info` as authored. The severity mapper handled only `pass` and `fail`, promoting it to a warning that failed `--strict` with no way to suppress a supported configuration. - `monotonic-violations` names the category it detected. Type widening, required loosening, severity loosening and structural loosening all rendered as `(type change: ...)`, and structural paths were truncated to the field `structural`, making `min_children` and `max_children` indistinguishable. BREAKING CHANGE: `validate` emits `rootline/validate-batch` version 2 for every invocation. Read a single-file verdict as `.results[0]`; `--field valid` becomes `--field "results[].valid"`. `.stem` findings move from `results[]` to `stem_health[]`, directory verdicts to `structural[]`, and `summary.total` is now a record count. See the upgrade table in docs/validate.md. Closes #68
--output advertised json|jsonl|csv|table and validated none of it. The only root PersistentPreRunE was boundaryPreflight, and all 23 consumers were bare equality tests, so -o sdlkfj, -o JSON and -o "" each exited 0 with whatever the command's default branch happened to be. Three failures shared that root cause. jsonl and csv fell through to JSON on validate, stats, describe and explain. On tree and graph the test was inverted (== "json"), so those same values fell through to an ASCII tree or a Graphviz document — contradicting graph's own help, which binds the diagram to -o table. And graph --check returned before any format dispatch, accepting an --output it could never honour. cmd/rootline/output.go now holds the advertised enum and a per-command-path table of what each command implements; rootPreflight rejects an unknown value, then an unsupported one, before the command body runs. A central table rather than 23 switches: a switch per command is a default arm per command to forget, and TestCommandOutputFormats_CoversEveryCommand fails CI when a new command has no entry. Unsupported pairs reject rather than gain a writer — describe has no defensible CSV shape, and a wrong CSV is worse than a refusal. tree and graph additionally get their dispatch corrected so the diagram is reachable only from -o table. graph --check rejects an explicitly-set --output (cmd.Flags().Changed), which leaves the documented default invocation, and every CI pipeline calling it, untouched. BREAKING CHANGE: input the CLI previously accepted and discarded now exits 1. Callers passing an unknown --output, or jsonl/csv to a command that never implemented them, must correct the flag. Emitting a rootline/graph-check envelope is deliberately out of scope: docs/graph.md publishes the text-only contract and a new kind needs its own shape agreed. Refs #63
Why this PR shows zero checksThis PR targets Local evidence in place of CIEvery CI job was reproduced locally against this PR's head (
Commit hygiene: exactly one commit, subject identical to the PR title, breaking marker This is orchestrator-side verification, not a substitute for the real checks. Once #130 lands and this branch picks up the fixed workflow, GitHub-side checks should appear. |
Master carries #124's validate envelope after its squash-merge, so this branch's copies of those hunks are already upstream and merged clean; the net diff against master is now this PR's own change only. Resolved on the merits: - CHANGELOG.md, CLAUDE.md, docs/graph.md — additive on both sides; both entries kept. Adds the BREAKING CHANGELOG entry this branch never had, since its previous CHANGELOG hunks all belonged to #124. - cmd/rootline/staged_test.go — kept master's TestGetStagedFilesIgnoresAmbientGitScope alongside this branch's envelope assertions in TestValidateStagedNoFiles.
Reconcile this branch with master after its former parent (#127, the --output format contract) was squash-merged, alongside the validate envelope (#124) and the repair-surface contract (#133). All conflicts were additive: CHANGELOG.md, CLAUDE.md, docs/graph.md and .claude/skills/rootline/ref-query.md keep both sides, and cmd/rootline/staged_test.go keeps master's new TestGetStagedFilesIgnoresAmbientGitScope next to the existing empty-index assertions. The resulting diff against master is exactly this PR's own change: field-name validation for --where (warning) and --sort (error).
Slice A of 3 for #63. Stacked on #124 (
pablontiv/w5-issue68-json-envelope), which rewritescmd/rootline/validate.go; this branch touches it too.Root cause
--outputadvertised a four-value contract and validated none of it. The only rootPersistentPreRunEwasboundaryPreflight, which never looks atoutputFormat, and all 23 consumers incmd/rootline/*.gowere bare equality tests with nodefaultarm anywhere. So an unhonourable value never failed — it just picked whichever branch the command fell through to.Three of the issue's sub-defects are the same root cause seen from different commands:
-o sdlkfj,-o JSON,-o ""all exit 0.validate,stats,describe,explainareif outputFormat == "table" {…}; return outputJSON(...), sojsonlandcsvemit JSON.tree.goandgraph.gotest== "json"instead of== "table", so anything else renders a diagram. This contradictsgraph's own help, which binds DOT to-o table.Plus the related
graph --checkcase: it returns before the format dispatch, so-o jsonis accepted and disregarded.Approach: one table, not 23 switches
The issue proposes replacing each equality test with an explicit switch. Rejected for two reasons. Twenty-three switches is twenty-three places for the next command to forget its
defaultarm — which is exactly how this arrived. And six of the files involved (migrate.go,schema.go,analyze.go, …) are live in other work units right now.Instead,
cmd/rootline/output.godeclares the advertised enum plus a per-command-path table of what each command actually implements.rootPreflightrejects an unknown value, then a value this command does not support, before the body runs — so the fix reaches commands whose bodies still carry the old bare test.TestCommandOutputFormats_CoversEveryCommandwalks the whole cobra tree and fails if any command has no entry, so a new command cannot ship without deciding.formatAgnosticis the explicit "this command ignores--output" value, distinguishable from a missing entry.treeandgraphstill get their bodies corrected, because an inverted test is a bug in its own right.Decisions
describeemits a nested schema envelope with no defensible flat shape. A refusal is honest and reversible; a wrong CSV is neither. This is the issue's own recommendation.graph --check+ explicit--outputerrors; the default is untouched.docs/graph.md:90publishes--checkas a text-plus-exit-code validator, so emitting a newrootline/graph-checkenvelope is a contract change needing its ownkind— out of scope here, as the issue says. But accepting-and-discarding is precisely the defect being fixed. The test is oncmd.Flags().Changed("output"), not on the value: the default-o jsonis not a request, sorootline graph docs/ --check— what CI runs today — is unchanged.Evidence
Fixture is the one in the issue body, built from scratch.
Before
After
Unchanged, as intended:
Gates
just check(gofmt + golangci-lint + build)just test(go test ./... -race)just coverage-checkcmd/rootline86.6%, total 89.5%, every package above its floorBreaking change
Input the CLI previously accepted and discarded now exits 1. The commit subject carries
!so the squash-merge produces the right bump. Concretely: an unknown--outputvalue,jsonl/csvon any command butquery, and an explicit--outputalongsidegraph --check.Not in this slice
--wherefield warning and §7--sortfield validation → slice B.--fieldrepeatability and §6--fieldon non-JSON output → slice C.Refs #63