Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,32 @@ jobs:
- name: go test
if: steps.detect.outputs.has_go == 'true'
run: go test ./...

compat:
name: compat module (vet + self-test)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: detect
name: detect compat module
run: |
if [ -f compat/go.mod ]; then
echo "has_compat=true" >> "$GITHUB_OUTPUT"
else
echo "has_compat=false" >> "$GITHUB_OUTPUT"
echo "::notice::No compat/go.mod present; skipping compat job."
fi
- uses: actions/setup-go@v5
if: steps.detect.outputs.has_compat == 'true'
with:
go-version-file: compat/go.mod
# compat is stdlib-only; no module cache needed.
cache: false
- name: go vet (compat)
if: steps.detect.outputs.has_compat == 'true'
working-directory: compat
run: go vet ./...
- name: go test (compat self-test)
if: steps.detect.outputs.has_compat == 'true'
working-directory: compat
run: go test ./...
33 changes: 21 additions & 12 deletions CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@ The contract exists so that, once you have used one of these CLIs, the others fe

## Status

| Section | crono-export | liftoff-export | withings-export |
|---|---|---|---|
| Repo naming (`{service}-export-cli`) | ✓ | ✓ | ✓ |
| Timezone policy | ✓ | ✓ | ✓ |
| Date flags (`--since` / `--until`) | ✓ | ✓ | ✓ |
| Markdown-default output | ✓ | ✓ | ✓ |
| Single `--format` flag | ✓ | ✓ | ✓ |
| `auth status` subcommand | ✓ | ✓ | ✓ |
| `prime` subcommand | ✓ | ✓ | ✓ |

All sections shipped across all three CLIs (April 25, 2026).
| Section | crono-export | liftoff-export | withings-export | Attestation |
|---|---|---|---|---|
| Repo naming (`{service}-export-cli`) | ✓ | ✓ | ✓ | human |
| Timezone policy | ✓ | ✓ | ✓ | human |
| Date flags (`--since` / `--until`) — surface | ✓ | ✓ | ✓ | **machine** ([`compat/dates`](compat/README.md)) |
| Date flags — local-midnight semantics | ✓ | ✓ | ✓ | human |
| Markdown-default output | ✓ | ✓ | ✓ | human |
| Single `--format` flag | ✓ | ✓ | ✓ | human |
| `auth status` subcommand | ✓ | ✓ | ✓ | human |
| `prime` subcommand | ✓ | ✓ | ✓ | human |

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.

---

Expand Down Expand Up @@ -98,7 +99,15 @@ GOTCHAS non-obvious pitfalls

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

## 7. Versioning and releases
## 7. Conformance (the compat library)

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:

- [`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).

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.

## 8. Versioning and releases

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`.

Expand Down
13 changes: 9 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,19 @@ Keep the per-CLI PR (the one that adds the CLI to the table) trivial and reviewa

## Compat tests

The contract is only as honest as the test that proves three CLIs behave the same. The compat-test expectation:
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.

- 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).
- 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.
Rules:

- Anyone changing `CONTRACT.md` is also expected to update or add tests under `compat/` that exercise the new behavior against every `*-export-cli`.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.

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

## License and sign-off

Expand Down
105 changes: 105 additions & 0 deletions compat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# `compat/` — cross-CLI conformance library

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.

It is a Go module (`github.com/quantcli/common/compat`) under the `compat/` subdirectory of `quantcli/common`. Stdlib only.

## Design at a glance

- **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.
- **One subpackage per contract section.** Currently only `dates/` (CONTRACT §2–§3). `formats/`, `auth/`, `prime/` are expected to follow.
- **Exporters consume via a build-tagged `_test.go`.** No production import; compat tests do not ship in the released binary.
- **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.

## Usage from an exporter

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 ./...`:

```go
//go:build compat

package main_test

import (
"os"
"testing"

"github.com/quantcli/common/compat"
"github.com/quantcli/common/compat/dates"
)

func TestContractDates(t *testing.T) {
bin := os.Getenv("EXPORT_CLI_BIN")
if bin == "" {
t.Skip("EXPORT_CLI_BIN not set; skipping compat suite")
}
dates.RunContract(t, compat.Runner{Binary: bin})
}
```

### Cobra-based CLIs: date flags on subcommands

When `--since`/`--until` live on subcommands (the crono / liftoff / withings pattern), set `Subcommands` and the suite will dispatch per-subcommand:

```go
func TestContractDates(t *testing.T) {
bin := os.Getenv("EXPORT_CLI_BIN")
if bin == "" {
t.Skip("EXPORT_CLI_BIN not set; skipping compat suite")
}
dates.RunContract(t, compat.Runner{
Binary: bin,
Subcommands: []string{
"biometrics", "exercises", "nutrition", "servings", "notes",
},
})
}
```

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.

### CI workflow

```yaml
- name: build
run: go build -o /tmp/cli .
- name: compat tests
env:
EXPORT_CLI_BIN: /tmp/cli
run: go test -tags=compat ./...
```

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.

## What `dates.RunContract` covers today

| Subtest | Contract | What it asserts |
|---|---|---|
| `HelpDocumentsDateFlags` | §3 | `--help` mentions `--since` and `--until` and exits 0. |
| `InvalidSinceValueFails` | §3, §4 | `--since obviously-not-a-date` exits non-zero, writes to stderr, leaves stdout empty. |
| `HelpIsHermetic` | §5 | `--help` succeeds with all HTTP proxies pointed at an unreachable address. |
| `FlagValidationIsHermetic` | §5 | A parse failure also produces no successful outbound request. |

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

## What it does NOT cover yet

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:

- a `--print-resolved-window`-style affordance on every CLI (a substantive contract change, intentionally out of scope of the first compat-test cut), or
- per-upstream recorded HTTP fixtures (heavy, and pinned to specific API shapes).

When that affordance lands, the test belongs here as `dates.LocalMidnightSemantics`.

## Adding a new contract test

1. Decide which CONTRACT.md section it pins down. If no subpackage exists for that section, create one (`compat/<section>/`).
2. Write the assertion as a function that takes `*testing.T` and `compat.Runner`.
3. Wire it into the section's `RunContract`. Subtests are `t.Run`-scoped so one failure does not mask the rest.
4. Update this README's table and `CONTRACT.md`'s status-attestation note in the same PR.

## Self-test

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.

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`.
172 changes: 172 additions & 0 deletions compat/compat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Package compat is the cross-CLI conformance test library for the
// quantcli export-CLI contract. Each *-export-cli imports the subpackages
// (e.g. compat/dates) and runs them against its own built binary in CI.
//
// The library is deliberately black-box: it shells out to the binary under
// test and asserts on stdout, stderr, and exit code. It never imports a
// CLI's internal packages. Adding a new contract test means adding a new
// subpackage here; every exporter then picks it up by adding a one-line
// test entry point.
//
// See CONTRACT.md in the parent repository for the surface this library
// pins down.
package compat

import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"testing"
"time"
)

// Runner invokes a single *-export-cli binary in a controlled environment.
// A zero-value Runner is not usable; Binary must be set to an absolute path.
type Runner struct {
// Binary is the absolute path to the export-cli binary under test.
Binary string

// Env is the environment passed to the binary. If nil, an empty
// environment is used. Tests should set this explicitly so behavior
// does not depend on whatever happens to be in the CI runner's
// environment (notably PATH, HOME, TZ, and any *_TOKEN credentials).
Env []string

// Timeout is the per-invocation timeout. Zero means 10 seconds.
Timeout time.Duration

// Subcommands declares the subcommands under which the contract
// surface lives — for CLIs (typically cobra-based) where flags like
// --since and --until are attached to data-producing subcommands
// rather than the root binary. Examples: crono's `biometrics`,
// `exercises`, `nutrition`, `servings`, `notes` each accept their
// own --since/--until.
//
// Empty means the surface is on the root binary; section bundles
// invoke the binary directly. Non-empty means each section's
// RunContract iterates the list and verifies the contract once per
// subcommand via t.Run, so a regression in any single subcommand
// surfaces as a named subtest failure rather than masking the rest.
//
// The Runner itself does not look at this field; section bundles
// (e.g. compat/dates) read it and dispatch via WithSubcommand.
Subcommands []string

// subcommand, when non-empty, is prepended to args on every Run
// call. Set via WithSubcommand; section bundles use it to dispatch
// per-subcommand. Callers do not need to set it directly — set
// Subcommands instead and let the bundle compose the dispatch.
subcommand string
}

// Result captures everything observable about one CLI invocation. All
// compat-test assertions operate on these three fields.
type Result struct {
Stdout []byte
Stderr []byte
ExitCode int
}

// StdoutString returns Stdout as a string.
func (r Result) StdoutString() string { return string(r.Stdout) }

// StderrString returns Stderr as a string.
func (r Result) StderrString() string { return string(r.Stderr) }

// Run invokes the binary with the given args and returns its observable
// output. A non-zero exit code is NOT returned as an error — compat tests
// frequently assert on non-zero exits, so the caller decides what counts
// as a failure. ctx cancellation, process-start failure, and timeouts are
// returned as errors.
func (r Runner) Run(ctx context.Context, args ...string) (Result, error) {
if r.Binary == "" {
return Result{}, errors.New("compat: Runner.Binary is empty")
}
timeout := r.Timeout
if timeout == 0 {
timeout = 10 * time.Second
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

fullArgs := args
if r.subcommand != "" {
fullArgs = append([]string{r.subcommand}, args...)
}
cmd := exec.CommandContext(runCtx, r.Binary, fullArgs...)
// Default to an empty env so tests are hermetic. Callers opt into
// passing TZ, HOME, etc. via Runner.Env.
if r.Env != nil {
cmd.Env = append([]string(nil), r.Env...)
} else {
cmd.Env = []string{}
}

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

err := cmd.Run()
res := Result{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
if err == nil {
res.ExitCode = 0
return res, nil
}
// Timeout check first. exec.CommandContext kills the process when
// the deadline expires, which surfaces as an *exec.ExitError on the
// signal path. The package contract promises a non-nil error on
// timeout, so we must detect that case before falling through to
// the ExitError handler — otherwise a hung CLI looks like a clean
// non-zero exit to the caller.
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
return res, fmt.Errorf("compat: %s timed out after %s", r.Binary, timeout)
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
res.ExitCode = exitErr.ExitCode()
return res, nil
}
return res, fmt.Errorf("compat: failed to run %s: %w", r.Binary, err)
}

// MustRun is the testing-helper equivalent of Run: it fails the test on
// any non-exit-code error (timeout, missing binary, etc.) and returns the
// Result on success.
func (r Runner) MustRun(t *testing.T, args ...string) Result {
t.Helper()
res, err := r.Run(context.Background(), args...)
if err != nil {
t.Fatalf("compat: %v", err)
}
return res
}

// WithEnv returns a copy of r with environment variable KEY=VALUE pairs
// appended. Useful for setting TZ on a per-test basis without mutating
// the receiver.
func (r Runner) WithEnv(kv ...string) Runner {
out := r
out.Env = append(append([]string(nil), r.Env...), kv...)
return out
}

// WithSubcommand returns a copy of r whose Run prepends sub as the
// first command-line argument. Section bundles use this internally to
// dispatch per-subcommand when Runner.Subcommands is non-empty;
// integrators normally set Subcommands and let the bundle do it.
//
// Calling WithSubcommand again replaces (not stacks) the previous
// value; nested subcommand paths are out of scope for the current
// contract.
func (r Runner) WithSubcommand(sub string) Runner {
out := r
out.subcommand = sub
return out
}

// Subcommand returns the subcommand that Run will prepend to args, or
// the empty string if none is set. Section bundles use this in subtest
// names so failures point at the offending subcommand.
func (r Runner) Subcommand() string { return r.subcommand }
Loading
Loading