Skip to content

Commit 1882283

Browse files
LeadGoEngineerPaperclip-Paperclip
andcommitted
feat(compat): scaffold cross-CLI conformance library + dates bundle
Adds `compat/` as a stdlib-only Go module (`github.com/quantcli/common/compat`) that every `*-export-cli` can import to machine-attest its conformance to CONTRACT.md. The first bundle, `compat/dates`, pins down four assertions tied to CONTRACT §3 and §5: - `--help` documents `--since` and `--until` and exits 0. - An invalid `--since` value exits non-zero with stderr-only error. - `--help` is hermetic (no network, even with proxies forced unreachable). - A flag-validation failure is hermetic for the same reason. What is intentionally NOT yet machine-attested: the local-midnight semantics of `--since 2026-04-15`. Asserting that black-box requires either a `--print-resolved-window`-style affordance on every CLI (a substantive contract change, out of scope here) or per-upstream recorded HTTP fixtures (heavy). A follow-up issue will propose the affordance. The library is self-tested in `quantcli/common`'s CI against a tiny contract-compliant stub in `compat/internal/stubcli`, so a regression in the library itself is caught here before any real exporter wires it up. Docs: - `CONTRACT.md` gains an §7 "Conformance" section and an Attestation column on the Status table (machine vs human). - `CONTRIBUTING.md` replaces the "harness is being scaffolded" caveat with the real integration recipe. - `compat/README.md` is the exporter-facing how-to. CI: - Adds a new `compat` job to `.github/workflows/ci.yml` that runs `go vet` and `go test` inside the compat module when `compat/go.mod` is present. The existing `go` job (which detects a root-level `go.mod`) is untouched, so this is additive. Refs CONTRACT.md §3, §5, §7. Refs QUA-8. Co-Authored-By: Paperclip <[email protected]>
1 parent 9dd5520 commit 1882283

9 files changed

Lines changed: 547 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: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,18 @@ 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.
7679
- 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.
7780
- 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.
81+
- 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.
7882

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.
83+
**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.
8084

8185
## License and sign-off
8286

compat/README.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
Then in your CI workflow:
41+
42+
```yaml
43+
- name: build
44+
run: go build -o /tmp/cli .
45+
- name: compat tests
46+
env:
47+
EXPORT_CLI_BIN: /tmp/cli
48+
run: go test -tags=compat ./...
49+
```
50+
51+
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.
52+
53+
## What `dates.RunContract` covers today
54+
55+
| Subtest | Contract | What it asserts |
56+
|---|---|---|
57+
| `HelpDocumentsDateFlags` | §3 | `--help` mentions `--since` and `--until` and exits 0. |
58+
| `InvalidSinceValueFails` | §3, §4 | `--since obviously-not-a-date` exits non-zero, writes to stderr, leaves stdout empty. |
59+
| `HelpIsHermetic` | §5 | `--help` succeeds with all HTTP proxies pointed at an unreachable address. |
60+
| `FlagValidationIsHermetic` | §5 | A parse failure also produces no successful outbound request. |
61+
62+
## What it does NOT cover yet
63+
64+
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:
65+
66+
- a `--print-resolved-window`-style affordance on every CLI (a substantive contract change, intentionally out of scope of the first compat-test cut), or
67+
- per-upstream recorded HTTP fixtures (heavy, and pinned to specific API shapes).
68+
69+
When that affordance lands, the test belongs here as `dates.LocalMidnightSemantics`.
70+
71+
## Adding a new contract test
72+
73+
1. Decide which CONTRACT.md section it pins down. If no subpackage exists for that section, create one (`compat/<section>/`).
74+
2. Write the assertion as a function that takes `*testing.T` and `compat.Runner`.
75+
3. Wire it into the section's `RunContract`. Subtests are `t.Run`-scoped so one failure does not mask the rest.
76+
4. Update this README's table and `CONTRACT.md`'s status-attestation note in the same PR.
77+
78+
## Self-test
79+
80+
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.

compat/compat.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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+
41+
// Result captures everything observable about one CLI invocation. All
42+
// compat-test assertions operate on these three fields.
43+
type Result struct {
44+
Stdout []byte
45+
Stderr []byte
46+
ExitCode int
47+
}
48+
49+
// StdoutString returns Stdout as a string.
50+
func (r Result) StdoutString() string { return string(r.Stdout) }
51+
52+
// StderrString returns Stderr as a string.
53+
func (r Result) StderrString() string { return string(r.Stderr) }
54+
55+
// Run invokes the binary with the given args and returns its observable
56+
// output. A non-zero exit code is NOT returned as an error — compat tests
57+
// frequently assert on non-zero exits, so the caller decides what counts
58+
// as a failure. ctx cancellation, process-start failure, and timeouts are
59+
// returned as errors.
60+
func (r Runner) Run(ctx context.Context, args ...string) (Result, error) {
61+
if r.Binary == "" {
62+
return Result{}, errors.New("compat: Runner.Binary is empty")
63+
}
64+
timeout := r.Timeout
65+
if timeout == 0 {
66+
timeout = 10 * time.Second
67+
}
68+
runCtx, cancel := context.WithTimeout(ctx, timeout)
69+
defer cancel()
70+
71+
cmd := exec.CommandContext(runCtx, r.Binary, args...)
72+
// Default to an empty env so tests are hermetic. Callers opt into
73+
// passing TZ, HOME, etc. via Runner.Env.
74+
if r.Env != nil {
75+
cmd.Env = append([]string(nil), r.Env...)
76+
} else {
77+
cmd.Env = []string{}
78+
}
79+
80+
var stdout, stderr bytes.Buffer
81+
cmd.Stdout = &stdout
82+
cmd.Stderr = &stderr
83+
84+
err := cmd.Run()
85+
res := Result{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
86+
if err == nil {
87+
res.ExitCode = 0
88+
return res, nil
89+
}
90+
var exitErr *exec.ExitError
91+
if errors.As(err, &exitErr) {
92+
res.ExitCode = exitErr.ExitCode()
93+
return res, nil
94+
}
95+
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
96+
return res, fmt.Errorf("compat: %s timed out after %s", r.Binary, timeout)
97+
}
98+
return res, fmt.Errorf("compat: failed to run %s: %w", r.Binary, err)
99+
}
100+
101+
// MustRun is the testing-helper equivalent of Run: it fails the test on
102+
// any non-exit-code error (timeout, missing binary, etc.) and returns the
103+
// Result on success.
104+
func (r Runner) MustRun(t *testing.T, args ...string) Result {
105+
t.Helper()
106+
res, err := r.Run(context.Background(), args...)
107+
if err != nil {
108+
t.Fatalf("compat: %v", err)
109+
}
110+
return res
111+
}
112+
113+
// WithEnv returns a copy of r with environment variable KEY=VALUE pairs
114+
// appended. Useful for setting TZ on a per-test basis without mutating
115+
// the receiver.
116+
func (r Runner) WithEnv(kv ...string) Runner {
117+
out := r
118+
out.Env = append(append([]string(nil), r.Env...), kv...)
119+
return out
120+
}

0 commit comments

Comments
 (0)