Skip to content

Commit 8787ef1

Browse files
LeadGoEngineerPaperclip-Paperclip
andcommitted
feat(compat): add formats bundle for §4 output-format conformance
`compat/formats` mirrors the `compat/dates` shape and machine-attests CONTRACT.md §4: --format is documented, unknown values exit non-zero on stderr with empty stdout (and no network call), --format json unmarshals as []any, --format csv emits ≥1 line (header row), and the default is byte-identical to --format markdown. The stubcli helper grows a --format codec so the data-path subtests (JSONIsArray / CSVHasHeader / DefaultIsMarkdown) self-test against an empty data set without any upstream. CONTRACT.md §7 lists the new bundle; the status table promotes both §4 rows (Markdown-default output, Single `--format` flag) from human to machine attestation. CONTRIBUTING.md documents the `compat/<section>` package convention now that N=2 bundles ship — deferred from QUA-14 review per LeadGoEngineer's follow-up offer. Co-Authored-By: Paperclip <[email protected]>
1 parent 4c588c1 commit 8787ef1

6 files changed

Lines changed: 421 additions & 41 deletions

File tree

CONTRACT.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ The contract exists so that, once you have used one of these CLIs, the others fe
1212
| Timezone policy |||| human |
1313
| Date flags (`--since` / `--until`) — surface |||| **machine** ([`compat/dates`](compat/README.md)) |
1414
| Date flags — local-midnight semantics |||| human |
15-
| Markdown-default output |||| human |
16-
| Single `--format` flag |||| human |
15+
| Markdown-default output |||| **machine** ([`compat/formats`](compat/README.md)) |
16+
| Single `--format` flag |||| **machine** ([`compat/formats`](compat/README.md)) |
1717
| `auth status` subcommand |||| human |
1818
| `prime` subcommand |||| human |
1919

@@ -104,8 +104,9 @@ Prime is short. It is not a man page. If it grows past one terminal screen, some
104104
Conformance to this contract is verified by [`compat/`](compat/README.md), a small black-box Go test library that lives in this repo and is imported by every `*-export-cli` from its own CI. The current bundles:
105105

106106
- [`compat/dates`](compat/README.md) — pins down §3: that `--since` / `--until` are documented in `--help`, that an invalid value exits non-zero with stderr-only error, and that flag handling makes no network request (per §5).
107+
- [`compat/formats`](compat/README.md) — pins down §4: that `--format` is documented, an unknown value exits non-zero with stderr-only error and no network call, `--format json` emits a parseable JSON array, `--format csv` emits at least a header row, and the default is byte-identical to `--format markdown`.
107108

108-
A new exporter is not "in" the family until its CI runs at least the `dates` bundle green. Existing exporters that have not yet wired up the bundle are tracked in the Status table's Attestation column.
109+
A new exporter is not "in" the family until its CI runs at least the `dates` and `formats` bundles green. Existing exporters that have not yet wired up a bundle are tracked in the Status table's Attestation column.
109110

110111
## 8. Versioning and releases
111112

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,13 @@ Rules:
7575

7676
- Anyone changing `CONTRACT.md` is also expected to update or add tests under `compat/` that exercise the new behavior against every `*-export-cli`.
7777
- The harness is deliberately black-box: it shells out to the binary and asserts on stdout, stderr, and exit code only. It must not import a CLI's internal packages.
78-
- One subpackage per contract section (`compat/dates`, future `compat/formats`, `compat/auth`, `compat/prime`). Each exposes a single entry point — `RunContract(t, runner)` — that exporters call from one build-tagged `_test.go` file.
78+
- One subpackage per contract section. The naming convention is `compat/<section>` where `<section>` is the CONTRACT.md section being attested — currently `compat/dates` (§2–§3) and `compat/formats` (§4); `compat/auth` (§5) and `compat/prime` (§6) are expected to follow. Each subpackage exposes a single entry point — `RunContract(t, runner)` — that exporters call from one build-tagged `_test.go` file.
7979
- Cobra-based exporters whose contract surface lives on subcommands set `compat.Runner.Subcommands`; section bundles dispatch per-subcommand under a `subcommand=NAME/...` subtree. Flat CLIs leave the field empty and the bundle runs against the root binary.
8080
- A PR that changes the contract without touching `compat/` is incomplete. Either update the tests in the same PR or open a follow-up issue and link it from the PR body before merging — the Lead Go Engineer holds the line on this.
8181
- Compat tests run in CI on every PR and on `main`. A failing compat test on `main` means at least one shipped CLI no longer matches the contract, and that's a release-blocker incident, not a flake.
8282
- The Status table in `CONTRACT.md` distinguishes **machine-attested** rows (covered by `compat/`) from **human-attested** rows (still verified by reviewer judgment). Promoting a row from human to machine attestation is itself a worthwhile PR.
8383

84-
**Bar for a new exporter:** the exporter's CI must build its binary and run `dates.RunContract` against it green. See [`compat/README.md`](compat/README.md) for the one-file integration pattern.
84+
**Bar for a new exporter:** the exporter's CI must build its binary and run `dates.RunContract` and `formats.RunContract` against it green. See [`compat/README.md`](compat/README.md) for the one-file integration pattern.
8585

8686
## License and sign-off
8787

compat/README.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ It is a Go module (`github.com/quantcli/common/compat`) under the `compat/` subd
77
## Design at a glance
88

99
- **Black-box.** The library never imports a CLI's internal packages. It shells out to the binary and asserts on stdout, stderr, and exit code.
10-
- **One subpackage per contract section.** Currently only `dates/` (CONTRACT §2–§3). `formats/`, `auth/`, `prime/` are expected to follow.
10+
- **One subpackage per contract section.** `dates/` (CONTRACT §2–§3) and `formats/` (CONTRACT §4) ship today. `auth/` and `prime/` are expected to follow. The convention is `compat/<section>` where `<section>` is the CONTRACT.md section being attested; each subpackage exposes a single `RunContract(t, runner)` entry point.
1111
- **Exporters consume via a build-tagged `_test.go`.** No production import; compat tests do not ship in the released binary.
1212
- **Hermetic by default.** `compat.Runner` runs the binary with an empty environment unless callers opt into specific variables. Subtests that need to assert "no network call" set proxy env vars to an unreachable address.
1313

@@ -26,6 +26,7 @@ import (
2626

2727
"github.com/quantcli/common/compat"
2828
"github.com/quantcli/common/compat/dates"
29+
"github.com/quantcli/common/compat/formats"
2930
)
3031

3132
func TestContractDates(t *testing.T) {
@@ -35,6 +36,14 @@ func TestContractDates(t *testing.T) {
3536
}
3637
dates.RunContract(t, compat.Runner{Binary: bin})
3738
}
39+
40+
func TestContractFormats(t *testing.T) {
41+
bin := os.Getenv("EXPORT_CLI_BIN")
42+
if bin == "" {
43+
t.Skip("EXPORT_CLI_BIN not set; skipping compat suite")
44+
}
45+
formats.RunContract(t, compat.Runner{Binary: bin})
46+
}
3847
```
3948

4049
### Cobra-based CLIs: date flags on subcommands
@@ -82,6 +91,19 @@ The exporter does not need a separate `go.mod` for compat tests — the standard
8291

8392
When `compat.Runner.Subcommands` is set, every row above runs once per declared subcommand under `subcommand=NAME/...`.
8493

94+
## What `formats.RunContract` covers today
95+
96+
| Subtest | Contract | What it asserts |
97+
|---|---|---|
98+
| `HelpDocumentsFormatFlag` | §4 | `--help` mentions `--format` and exits 0. |
99+
| `UnknownFormatFails` | §4 | `--format obviously-not-a-format` exits non-zero, writes to stderr, leaves stdout empty. |
100+
| `FlagValidationIsHermetic` | §5 | The unknown-format parse failure makes no successful outbound request. |
101+
| `JSONIsArray` | §4 | `--format json` exits 0 and emits stdout that unmarshals as `[]any`. |
102+
| `CSVHasHeader` | §4 | `--format csv` exits 0 and emits at least one non-empty line on stdout (the header row, present even on an empty result). |
103+
| `DefaultIsMarkdown` | §4 | No `--format` flag produces byte-identical stdout to `--format markdown`. |
104+
105+
`JSONIsArray`, `CSVHasHeader`, and `DefaultIsMarkdown` invoke the data path with no extra args beyond `--format`. Integrators whose data path needs credentials or other env to succeed pass them via `compat.Runner.Env`. As with `dates`, `subcommand=NAME/...` subtest groups fire when `Subcommands` is set.
106+
85107
## What it does NOT cover yet
86108

87109
The actual local-midnight semantics of `--since 2026-04-15` (the harmonization that just landed across crono/liftoff/withings) is still **human-attested** in the status table. Asserting it black-box requires either:
@@ -100,6 +122,6 @@ When that affordance lands, the test belongs here as `dates.LocalMidnightSemanti
100122

101123
## Self-test
102124

103-
This module has its own test that runs the suite against a stub CLI in `internal/stubcli/`. The stub is intentionally narrow — it exists so `go test ./...` from this module's root proves the library compiles and the assertions fire correctly, without depending on any of the real export-CLIs. Failures in the self-test mean the library has a bug; failures in an exporter's compat test mean the exporter drifted from the contract.
125+
This module has its own tests that run each bundle against a stub CLI in `internal/stubcli/`. The stub is intentionally narrow — it exists so `go test ./...` from this module's root proves the library compiles and the assertions fire correctly, without depending on any of the real export-CLIs. Failures in the self-test mean the library has a bug; failures in an exporter's compat test mean the exporter drifted from the contract.
104126

105-
The stub has two modes (`STUBCLI_MODE=flat` and `STUBCLI_MODE=cobra`). The flat-mode self-test exercises the original Runner shape; the cobra-mode self-test exercises `Subcommands`-based dispatch. In cobra mode, the stub's root `--help` deliberately omits `--since/--until`, so the cobra-mode self-test fails fast if `compat.Runner` ever stops prepending the subcommand. There is also a focused unit test for `Runner.WithSubcommand` using an `argecho` helper that just prints `os.Args`.
127+
The stub has two modes (`STUBCLI_MODE=flat` and `STUBCLI_MODE=cobra`). The flat-mode self-tests exercise the original Runner shape; the cobra-mode self-tests exercise `Subcommands`-based dispatch. In cobra mode, the stub's root `--help` deliberately omits `--since/--until/--format`, so the cobra-mode self-tests fail fast if `compat.Runner` ever stops prepending the subcommand. The stub emits an empty data set per `--format` codec (`[]` for json, a single header row for csv, nothing for markdown) so the formats bundle's data-path subtests run hermetically. There is also a focused unit test for `Runner.WithSubcommand` using an `argecho` helper that just prints `os.Args`.

compat/formats/formats.go

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// Package formats is the compat test bundle for §4 (output format) of
2+
// CONTRACT.md.
3+
//
4+
// What is machine-attested here:
5+
//
6+
// - The CLI documents `--format` in its `--help` output (root binary
7+
// for flat CLIs, or each declared subcommand for cobra-based CLIs
8+
// configured via compat.Runner.Subcommands).
9+
// - An unknown `--format` value exits non-zero with an error on
10+
// stderr and an empty stdout.
11+
// - The unknown-value parse failure performs no network request.
12+
// - `--format json` exits zero and emits a JSON value on stdout that
13+
// unmarshals as `[]any`.
14+
// - `--format csv` exits zero and emits at least one non-empty line
15+
// on stdout — the header row — even on an empty result set.
16+
// - The default (no `--format` flag) and `--format markdown` produce
17+
// byte-identical stdout. This is how the suite pins down "markdown
18+
// is the default" without having to parse markdown.
19+
//
20+
// The data-path subtests (JSONIsArray, CSVHasHeader, DefaultIsMarkdown)
21+
// invoke the CLI with no extra args beyond `--format`. Integrators
22+
// whose CLI requires extra args to succeed (e.g. credentials via env)
23+
// must arrange for those to be present via Runner.Env. Exporters
24+
// whose data path is not yet runnable from a clean CI environment
25+
// should wire the bundle in once it is — the parse-time subtests
26+
// alone are not enough to claim machine attestation for §4.
27+
//
28+
// Exporter usage:
29+
//
30+
// //go:build compat
31+
// package mycli_compat_test
32+
//
33+
// import (
34+
// "os"
35+
// "testing"
36+
// "github.com/quantcli/common/compat"
37+
// "github.com/quantcli/common/compat/formats"
38+
// )
39+
//
40+
// func TestContractFormats(t *testing.T) {
41+
// bin := os.Getenv("EXPORT_CLI_BIN")
42+
// if bin == "" { t.Skip("EXPORT_CLI_BIN not set") }
43+
// formats.RunContract(t, compat.Runner{Binary: bin})
44+
// }
45+
package formats
46+
47+
import (
48+
"context"
49+
"encoding/json"
50+
"strings"
51+
"testing"
52+
53+
"github.com/quantcli/common/compat"
54+
)
55+
56+
// RunContract runs the full output-format contract test bundle against
57+
// r. It is the only function exporters are expected to call.
58+
//
59+
// Each assertion is a t.Run subtest, so a failure in one does not mask
60+
// the others. If r.Subcommands is non-empty, RunContract iterates the
61+
// list and runs the assertions once per subcommand under a
62+
// "subcommand=NAME" t.Run group, mirroring compat/dates.
63+
func RunContract(t *testing.T, r compat.Runner) {
64+
t.Helper()
65+
if r.Binary == "" {
66+
t.Fatal("formats: compat.Runner.Binary is empty")
67+
}
68+
69+
if len(r.Subcommands) == 0 {
70+
runContractOne(t, r)
71+
return
72+
}
73+
for _, sub := range r.Subcommands {
74+
sub := sub
75+
t.Run("subcommand="+sub, func(t *testing.T) {
76+
runContractOne(t, r.WithSubcommand(sub))
77+
})
78+
}
79+
}
80+
81+
// runContractOne runs the §4 assertions against a single invocation
82+
// surface — either the root binary (when r has no subcommand prefix)
83+
// or a specific subcommand of it.
84+
func runContractOne(t *testing.T, r compat.Runner) {
85+
t.Helper()
86+
t.Run("HelpDocumentsFormatFlag", func(t *testing.T) {
87+
helpDocumentsFormatFlag(t, r)
88+
})
89+
t.Run("UnknownFormatFails", func(t *testing.T) {
90+
unknownFormatFails(t, r)
91+
})
92+
t.Run("FlagValidationIsHermetic", func(t *testing.T) {
93+
flagValidationIsHermetic(t, r)
94+
})
95+
t.Run("JSONIsArray", func(t *testing.T) {
96+
jsonIsArray(t, r)
97+
})
98+
t.Run("CSVHasHeader", func(t *testing.T) {
99+
csvHasHeader(t, r)
100+
})
101+
t.Run("DefaultIsMarkdown", func(t *testing.T) {
102+
defaultIsMarkdown(t, r)
103+
})
104+
}
105+
106+
// helpDocumentsFormatFlag asserts that the CLI documents `--format`
107+
// somewhere in its `--help` output. Like the dates equivalent, this
108+
// is the minimum binding between §4 and the binary: a CLI that quietly
109+
// drops the `--format` flag will fail this test.
110+
func helpDocumentsFormatFlag(t *testing.T, r compat.Runner) {
111+
t.Helper()
112+
res := r.MustRun(t, "--help")
113+
if res.ExitCode != 0 {
114+
t.Fatalf("--help exited %d, want 0; stderr=%q", res.ExitCode, res.StderrString())
115+
}
116+
combined := res.StdoutString() + "\n" + res.StderrString()
117+
if !strings.Contains(combined, "--format") {
118+
t.Errorf("--help output does not mention --format; got stdout=%q stderr=%q",
119+
res.StdoutString(), res.StderrString())
120+
}
121+
}
122+
123+
// unknownFormatFails asserts that a value like `--format frobnicate`
124+
// causes the CLI to exit non-zero with an error on stderr and an
125+
// empty stdout. The empty-stdout check is the §4 "stdout is data
126+
// only" rule: a parse failure must not contaminate the data stream.
127+
func unknownFormatFails(t *testing.T, r compat.Runner) {
128+
t.Helper()
129+
res, err := r.Run(context.Background(), "--format", unknownFormatValue)
130+
if err != nil {
131+
t.Fatalf("run failed: %v", err)
132+
}
133+
if res.ExitCode == 0 {
134+
t.Errorf("unknown --format accepted (exit 0); stdout=%q stderr=%q",
135+
res.StdoutString(), res.StderrString())
136+
}
137+
if len(res.Stdout) != 0 {
138+
t.Errorf("unknown --format produced stdout output (§4 violation): %q",
139+
res.StdoutString())
140+
}
141+
if strings.TrimSpace(res.StderrString()) == "" {
142+
t.Errorf("unknown --format produced no stderr message")
143+
}
144+
}
145+
146+
// flagValidationIsHermetic asserts that the unknown-format parse
147+
// failure does not dial out. Mirrors the dates bundle's parse-failure
148+
// hermetic test, but exercises the `--format` parse path explicitly
149+
// in case the CLI looks up format codecs differently than date
150+
// values.
151+
//
152+
// CONTRACT §5: "A CLI run with --help or with a flag-validation
153+
// failure must not make network requests."
154+
func flagValidationIsHermetic(t *testing.T, r compat.Runner) {
155+
t.Helper()
156+
res, err := r.WithEnv(noNetworkEnv()...).Run(context.Background(), "--format", unknownFormatValue)
157+
if err != nil {
158+
t.Fatalf("run failed under no-network env: %v", err)
159+
}
160+
if res.ExitCode == 0 {
161+
t.Errorf("unknown --format accepted under no-network env (exit 0); stderr=%q",
162+
res.StderrString())
163+
}
164+
}
165+
166+
// jsonIsArray asserts that `--format json` exits zero and emits
167+
// stdout that unmarshals as a JSON array (`[]any`). The check is
168+
// row-count agnostic: zero rows is `[]`, N rows is `[{…},…]`, both
169+
// pass. The §4 empty-result rule (`[]` on no data) is therefore
170+
// covered implicitly so long as the integrator's data path returns
171+
// successfully.
172+
func jsonIsArray(t *testing.T, r compat.Runner) {
173+
t.Helper()
174+
res := r.MustRun(t, "--format", "json")
175+
if res.ExitCode != 0 {
176+
t.Fatalf("--format json exited %d; stderr=%q", res.ExitCode, res.StderrString())
177+
}
178+
trimmed := strings.TrimSpace(res.StdoutString())
179+
if trimmed == "" {
180+
t.Fatalf("--format json produced empty stdout; want JSON array (`[]` for empty result per §4)")
181+
}
182+
var arr []any
183+
if err := json.Unmarshal([]byte(trimmed), &arr); err != nil {
184+
t.Errorf("--format json stdout is not a JSON array: %v; stdout=%q",
185+
err, res.StdoutString())
186+
}
187+
}
188+
189+
// csvHasHeader asserts that `--format csv` exits zero and emits at
190+
// least one non-empty line on stdout. §4 says an empty result is
191+
// success with "no rows" for CSV — the header row is still required,
192+
// so even a zero-row CSV must have one line.
193+
func csvHasHeader(t *testing.T, r compat.Runner) {
194+
t.Helper()
195+
res := r.MustRun(t, "--format", "csv")
196+
if res.ExitCode != 0 {
197+
t.Fatalf("--format csv exited %d; stderr=%q", res.ExitCode, res.StderrString())
198+
}
199+
if len(nonEmptyLines(res.StdoutString())) == 0 {
200+
t.Errorf("--format csv produced no header row on stdout; got %q",
201+
res.StdoutString())
202+
}
203+
}
204+
205+
// defaultIsMarkdown asserts that the default format (no `--format`
206+
// flag) produces byte-identical stdout to `--format markdown`. This is
207+
// the strongest behavioral statement of "markdown is the default"
208+
// available without parsing markdown.
209+
func defaultIsMarkdown(t *testing.T, r compat.Runner) {
210+
t.Helper()
211+
noFlag := r.MustRun(t)
212+
explicit := r.MustRun(t, "--format", "markdown")
213+
if noFlag.ExitCode != 0 {
214+
t.Fatalf("default (no --format) exited %d; stderr=%q",
215+
noFlag.ExitCode, noFlag.StderrString())
216+
}
217+
if explicit.ExitCode != 0 {
218+
t.Fatalf("--format markdown exited %d; stderr=%q",
219+
explicit.ExitCode, explicit.StderrString())
220+
}
221+
if noFlag.StdoutString() != explicit.StdoutString() {
222+
t.Errorf("default stdout differs from --format markdown stdout:\n no-flag: %q\n markdown: %q",
223+
noFlag.StdoutString(), explicit.StdoutString())
224+
}
225+
}
226+
227+
// unknownFormatValue is a sentinel that no contract-compliant CLI
228+
// should accept as a `--format` value. Kept as a named constant so a
229+
// future codec adoption (e.g. `yaml`) does not collide silently with
230+
// the negative-path probe.
231+
const unknownFormatValue = "obviously-not-a-format"
232+
233+
// nonEmptyLines splits s on '\n' and drops blank/whitespace-only
234+
// entries — including the trailing empty string from a final newline.
235+
func nonEmptyLines(s string) []string {
236+
var out []string
237+
for _, line := range strings.Split(s, "\n") {
238+
if strings.TrimSpace(line) != "" {
239+
out = append(out, line)
240+
}
241+
}
242+
return out
243+
}
244+
245+
// noNetworkEnv mirrors compat/dates: point every common HTTP proxy
246+
// env var at an unreachable address so any flag-validation path that
247+
// accidentally opens a connection fails or stalls. Kept locally
248+
// rather than exported from compat to keep the two bundles
249+
// independently auditable.
250+
func noNetworkEnv() []string {
251+
const unreachable = "http://127.0.0.1:1"
252+
return []string{
253+
"HTTP_PROXY=" + unreachable,
254+
"HTTPS_PROXY=" + unreachable,
255+
"http_proxy=" + unreachable,
256+
"https_proxy=" + unreachable,
257+
"NO_PROXY=",
258+
"no_proxy=",
259+
"TZ=UTC",
260+
}
261+
}

0 commit comments

Comments
 (0)