Skip to content

Commit 4c588c1

Browse files
Merge pull request #6 from quantcli/feat/compat-framework
feat(compat): scaffold cross-CLI conformance library + dates bundle
2 parents 6d5294a + b07b0d7 commit 4c588c1

11 files changed

Lines changed: 867 additions & 16 deletions

File tree

.github/workflows/ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,32 @@ jobs:
4444
- name: go test
4545
if: steps.detect.outputs.has_go == 'true'
4646
run: go test ./...
47+
48+
compat:
49+
name: compat module (vet + self-test)
50+
runs-on: ubuntu-latest
51+
steps:
52+
- uses: actions/checkout@v4
53+
- id: detect
54+
name: detect compat module
55+
run: |
56+
if [ -f compat/go.mod ]; then
57+
echo "has_compat=true" >> "$GITHUB_OUTPUT"
58+
else
59+
echo "has_compat=false" >> "$GITHUB_OUTPUT"
60+
echo "::notice::No compat/go.mod present; skipping compat job."
61+
fi
62+
- uses: actions/setup-go@v5
63+
if: steps.detect.outputs.has_compat == 'true'
64+
with:
65+
go-version-file: compat/go.mod
66+
# compat is stdlib-only; no module cache needed.
67+
cache: false
68+
- name: go vet (compat)
69+
if: steps.detect.outputs.has_compat == 'true'
70+
working-directory: compat
71+
run: go vet ./...
72+
- name: go test (compat self-test)
73+
if: steps.detect.outputs.has_compat == 'true'
74+
working-directory: compat
75+
run: go test ./...

CONTRACT.md

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,18 @@ The contract exists so that, once you have used one of these CLIs, the others fe
66

77
## Status
88

9-
| Section | crono-export | liftoff-export | withings-export |
10-
|---|---|---|---|
11-
| Repo naming (`{service}-export-cli`) ||||
12-
| Timezone policy ||||
13-
| Date flags (`--since` / `--until`) ||||
14-
| Markdown-default output ||||
15-
| Single `--format` flag ||||
16-
| `auth status` subcommand ||||
17-
| `prime` subcommand ||||
18-
19-
All sections shipped across all three CLIs (April 25, 2026).
9+
| Section | crono-export | liftoff-export | withings-export | Attestation |
10+
|---|---|---|---|---|
11+
| Repo naming (`{service}-export-cli`) |||| human |
12+
| Timezone policy |||| human |
13+
| Date flags (`--since` / `--until`) — surface |||| **machine** ([`compat/dates`](compat/README.md)) |
14+
| Date flags — local-midnight semantics |||| human |
15+
| Markdown-default output |||| human |
16+
| Single `--format` flag |||| human |
17+
| `auth status` subcommand |||| human |
18+
| `prime` subcommand |||| human |
19+
20+
All sections shipped across all three CLIs (April 25, 2026). The "Attestation" column tracks whether a contract section is verified by an automated [compat test](compat/README.md) on every PR or only by human review at merge time. Rows marked "human" are candidates for promotion to "machine" as the compat library grows.
2021

2122
---
2223

@@ -98,7 +99,15 @@ GOTCHAS non-obvious pitfalls
9899

99100
Prime is short. It is not a man page. If it grows past one terminal screen, something belongs in this contract instead.
100101

101-
## 7. Versioning and releases
102+
## 7. Conformance (the compat library)
103+
104+
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:
105+
106+
- [`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+
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+
110+
## 8. Versioning and releases
102111

103112
Semantic versioning. User-visible bug fix → patch. New subcommand or flag → minor. Removed/renamed flag → major. Releases cut via `gh release create` against the relevant tag; goreleaser builds binaries for darwin/linux/windows × amd64/arm64 and publishes the cask to `quantcli/homebrew-tap`.
104113

CONTRIBUTING.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,19 @@ Keep the per-CLI PR (the one that adds the CLI to the table) trivial and reviewa
6969

7070
## Compat tests
7171

72-
The contract is only as honest as the test that proves three CLIs behave the same. The compat-test expectation:
72+
The contract is only as honest as the test that proves three CLIs behave the same. The harness lives in [`compat/`](compat/README.md) as its own Go module (`github.com/quantcli/common/compat`); each exporter imports it and runs the relevant bundles against its own built binary in CI.
7373

74-
- Anyone changing `CONTRACT.md` is also expected to update or add tests in this repo that exercise the contract against the released binaries of every `*-export-cli`. The tests live in `compat/` (one suite per contract section: dates, formats, auth, prime).
75-
- The tests run pinned versions of each CLI binary, drive them with the same input fixtures, and assert that observable output (stdout, stderr, exit code, JSON shape) matches the spec. They are deliberately black-box: the goal is to catch divergence, not to test internals.
74+
Rules:
75+
76+
- Anyone changing `CONTRACT.md` is also expected to update or add tests under `compat/` that exercise the new behavior against every `*-export-cli`.
77+
- 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.
79+
- 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.
7680
- 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.
7781
- 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.
82+
- 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.
7883

79-
The `compat/` harness is being scaffolded — until it lands, document the test you *would* write in the PR body so the expectation stays visible.
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.
8085

8186
## License and sign-off
8287

compat/README.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# `compat/` — cross-CLI conformance library
2+
3+
This module is the machine-attested half of [`CONTRACT.md`](../CONTRACT.md). Every `*-export-cli` is expected to wire it into CI so the contract's "✓" status table stops being purely human-attested.
4+
5+
It is a Go module (`github.com/quantcli/common/compat`) under the `compat/` subdirectory of `quantcli/common`. Stdlib only.
6+
7+
## Design at a glance
8+
9+
- **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.
11+
- **Exporters consume via a build-tagged `_test.go`.** No production import; compat tests do not ship in the released binary.
12+
- **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.
13+
14+
## Usage from an exporter
15+
16+
Add a single file in your repo (e.g. `compat_test.go`) gated behind a build tag so it does not run as part of the default `go test ./...`:
17+
18+
```go
19+
//go:build compat
20+
21+
package main_test
22+
23+
import (
24+
"os"
25+
"testing"
26+
27+
"github.com/quantcli/common/compat"
28+
"github.com/quantcli/common/compat/dates"
29+
)
30+
31+
func TestContractDates(t *testing.T) {
32+
bin := os.Getenv("EXPORT_CLI_BIN")
33+
if bin == "" {
34+
t.Skip("EXPORT_CLI_BIN not set; skipping compat suite")
35+
}
36+
dates.RunContract(t, compat.Runner{Binary: bin})
37+
}
38+
```
39+
40+
### Cobra-based CLIs: date flags on subcommands
41+
42+
When `--since`/`--until` live on subcommands (the crono / liftoff / withings pattern), set `Subcommands` and the suite will dispatch per-subcommand:
43+
44+
```go
45+
func TestContractDates(t *testing.T) {
46+
bin := os.Getenv("EXPORT_CLI_BIN")
47+
if bin == "" {
48+
t.Skip("EXPORT_CLI_BIN not set; skipping compat suite")
49+
}
50+
dates.RunContract(t, compat.Runner{
51+
Binary: bin,
52+
Subcommands: []string{
53+
"biometrics", "exercises", "nutrition", "servings", "notes",
54+
},
55+
})
56+
}
57+
```
58+
59+
Each subcommand is verified under a `subcommand=NAME/...` subtree, so a regression in any single one fails as a named subtest instead of masking the rest.
60+
61+
### CI workflow
62+
63+
```yaml
64+
- name: build
65+
run: go build -o /tmp/cli .
66+
- name: compat tests
67+
env:
68+
EXPORT_CLI_BIN: /tmp/cli
69+
run: go test -tags=compat ./...
70+
```
71+
72+
The exporter does not need a separate `go.mod` for compat tests — the standard `require github.com/quantcli/common/compat vX.Y.Z` line in the exporter's existing `go.mod` is enough.
73+
74+
## What `dates.RunContract` covers today
75+
76+
| Subtest | Contract | What it asserts |
77+
|---|---|---|
78+
| `HelpDocumentsDateFlags` | §3 | `--help` mentions `--since` and `--until` and exits 0. |
79+
| `InvalidSinceValueFails` | §3, §4 | `--since obviously-not-a-date` exits non-zero, writes to stderr, leaves stdout empty. |
80+
| `HelpIsHermetic` | §5 | `--help` succeeds with all HTTP proxies pointed at an unreachable address. |
81+
| `FlagValidationIsHermetic` | §5 | A parse failure also produces no successful outbound request. |
82+
83+
When `compat.Runner.Subcommands` is set, every row above runs once per declared subcommand under `subcommand=NAME/...`.
84+
85+
## What it does NOT cover yet
86+
87+
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:
88+
89+
- a `--print-resolved-window`-style affordance on every CLI (a substantive contract change, intentionally out of scope of the first compat-test cut), or
90+
- per-upstream recorded HTTP fixtures (heavy, and pinned to specific API shapes).
91+
92+
When that affordance lands, the test belongs here as `dates.LocalMidnightSemantics`.
93+
94+
## Adding a new contract test
95+
96+
1. Decide which CONTRACT.md section it pins down. If no subpackage exists for that section, create one (`compat/<section>/`).
97+
2. Write the assertion as a function that takes `*testing.T` and `compat.Runner`.
98+
3. Wire it into the section's `RunContract`. Subtests are `t.Run`-scoped so one failure does not mask the rest.
99+
4. Update this README's table and `CONTRACT.md`'s status-attestation note in the same PR.
100+
101+
## Self-test
102+
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.
104+
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`.

compat/compat.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Package compat is the cross-CLI conformance test library for the
2+
// quantcli export-CLI contract. Each *-export-cli imports the subpackages
3+
// (e.g. compat/dates) and runs them against its own built binary in CI.
4+
//
5+
// The library is deliberately black-box: it shells out to the binary under
6+
// test and asserts on stdout, stderr, and exit code. It never imports a
7+
// CLI's internal packages. Adding a new contract test means adding a new
8+
// subpackage here; every exporter then picks it up by adding a one-line
9+
// test entry point.
10+
//
11+
// See CONTRACT.md in the parent repository for the surface this library
12+
// pins down.
13+
package compat
14+
15+
import (
16+
"bytes"
17+
"context"
18+
"errors"
19+
"fmt"
20+
"os/exec"
21+
"testing"
22+
"time"
23+
)
24+
25+
// Runner invokes a single *-export-cli binary in a controlled environment.
26+
// A zero-value Runner is not usable; Binary must be set to an absolute path.
27+
type Runner struct {
28+
// Binary is the absolute path to the export-cli binary under test.
29+
Binary string
30+
31+
// Env is the environment passed to the binary. If nil, an empty
32+
// environment is used. Tests should set this explicitly so behavior
33+
// does not depend on whatever happens to be in the CI runner's
34+
// environment (notably PATH, HOME, TZ, and any *_TOKEN credentials).
35+
Env []string
36+
37+
// Timeout is the per-invocation timeout. Zero means 10 seconds.
38+
Timeout time.Duration
39+
40+
// Subcommands declares the subcommands under which the contract
41+
// surface lives — for CLIs (typically cobra-based) where flags like
42+
// --since and --until are attached to data-producing subcommands
43+
// rather than the root binary. Examples: crono's `biometrics`,
44+
// `exercises`, `nutrition`, `servings`, `notes` each accept their
45+
// own --since/--until.
46+
//
47+
// Empty means the surface is on the root binary; section bundles
48+
// invoke the binary directly. Non-empty means each section's
49+
// RunContract iterates the list and verifies the contract once per
50+
// subcommand via t.Run, so a regression in any single subcommand
51+
// surfaces as a named subtest failure rather than masking the rest.
52+
//
53+
// The Runner itself does not look at this field; section bundles
54+
// (e.g. compat/dates) read it and dispatch via WithSubcommand.
55+
Subcommands []string
56+
57+
// subcommand, when non-empty, is prepended to args on every Run
58+
// call. Set via WithSubcommand; section bundles use it to dispatch
59+
// per-subcommand. Callers do not need to set it directly — set
60+
// Subcommands instead and let the bundle compose the dispatch.
61+
subcommand string
62+
}
63+
64+
// Result captures everything observable about one CLI invocation. All
65+
// compat-test assertions operate on these three fields.
66+
type Result struct {
67+
Stdout []byte
68+
Stderr []byte
69+
ExitCode int
70+
}
71+
72+
// StdoutString returns Stdout as a string.
73+
func (r Result) StdoutString() string { return string(r.Stdout) }
74+
75+
// StderrString returns Stderr as a string.
76+
func (r Result) StderrString() string { return string(r.Stderr) }
77+
78+
// Run invokes the binary with the given args and returns its observable
79+
// output. A non-zero exit code is NOT returned as an error — compat tests
80+
// frequently assert on non-zero exits, so the caller decides what counts
81+
// as a failure. ctx cancellation, process-start failure, and timeouts are
82+
// returned as errors.
83+
func (r Runner) Run(ctx context.Context, args ...string) (Result, error) {
84+
if r.Binary == "" {
85+
return Result{}, errors.New("compat: Runner.Binary is empty")
86+
}
87+
timeout := r.Timeout
88+
if timeout == 0 {
89+
timeout = 10 * time.Second
90+
}
91+
runCtx, cancel := context.WithTimeout(ctx, timeout)
92+
defer cancel()
93+
94+
fullArgs := args
95+
if r.subcommand != "" {
96+
fullArgs = append([]string{r.subcommand}, args...)
97+
}
98+
cmd := exec.CommandContext(runCtx, r.Binary, fullArgs...)
99+
// Default to an empty env so tests are hermetic. Callers opt into
100+
// passing TZ, HOME, etc. via Runner.Env.
101+
if r.Env != nil {
102+
cmd.Env = append([]string(nil), r.Env...)
103+
} else {
104+
cmd.Env = []string{}
105+
}
106+
107+
var stdout, stderr bytes.Buffer
108+
cmd.Stdout = &stdout
109+
cmd.Stderr = &stderr
110+
111+
err := cmd.Run()
112+
res := Result{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
113+
if err == nil {
114+
res.ExitCode = 0
115+
return res, nil
116+
}
117+
// Timeout check first. exec.CommandContext kills the process when
118+
// the deadline expires, which surfaces as an *exec.ExitError on the
119+
// signal path. The package contract promises a non-nil error on
120+
// timeout, so we must detect that case before falling through to
121+
// the ExitError handler — otherwise a hung CLI looks like a clean
122+
// non-zero exit to the caller.
123+
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
124+
return res, fmt.Errorf("compat: %s timed out after %s", r.Binary, timeout)
125+
}
126+
var exitErr *exec.ExitError
127+
if errors.As(err, &exitErr) {
128+
res.ExitCode = exitErr.ExitCode()
129+
return res, nil
130+
}
131+
return res, fmt.Errorf("compat: failed to run %s: %w", r.Binary, err)
132+
}
133+
134+
// MustRun is the testing-helper equivalent of Run: it fails the test on
135+
// any non-exit-code error (timeout, missing binary, etc.) and returns the
136+
// Result on success.
137+
func (r Runner) MustRun(t *testing.T, args ...string) Result {
138+
t.Helper()
139+
res, err := r.Run(context.Background(), args...)
140+
if err != nil {
141+
t.Fatalf("compat: %v", err)
142+
}
143+
return res
144+
}
145+
146+
// WithEnv returns a copy of r with environment variable KEY=VALUE pairs
147+
// appended. Useful for setting TZ on a per-test basis without mutating
148+
// the receiver.
149+
func (r Runner) WithEnv(kv ...string) Runner {
150+
out := r
151+
out.Env = append(append([]string(nil), r.Env...), kv...)
152+
return out
153+
}
154+
155+
// WithSubcommand returns a copy of r whose Run prepends sub as the
156+
// first command-line argument. Section bundles use this internally to
157+
// dispatch per-subcommand when Runner.Subcommands is non-empty;
158+
// integrators normally set Subcommands and let the bundle do it.
159+
//
160+
// Calling WithSubcommand again replaces (not stacks) the previous
161+
// value; nested subcommand paths are out of scope for the current
162+
// contract.
163+
func (r Runner) WithSubcommand(sub string) Runner {
164+
out := r
165+
out.subcommand = sub
166+
return out
167+
}
168+
169+
// Subcommand returns the subcommand that Run will prepend to args, or
170+
// the empty string if none is set. Section bundles use this in subtest
171+
// names so failures point at the offending subcommand.
172+
func (r Runner) Subcommand() string { return r.subcommand }

0 commit comments

Comments
 (0)