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
13 changes: 12 additions & 1 deletion CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,18 @@ Every subcommand that selects a window of data accepts:
| Absolute | `2026-04-15` | Local midnight that calendar day |
| Relative | `7d`, `4w`, `6m`, `1y` | N units before today's local midnight |

Window semantics are half-open `[since, until_exclusive)` internally, but `--until` is inclusive of the named day from the user's point of view: the CLI adds 24h to the parsed value before filtering, so `--until 2026-04-15` includes all 24 hours of April 15.
Window semantics are half-open `[since, until_exclusive)` internally, but `--until` is inclusive of the named day from the user's point of view: the exclusive bound is the next local calendar midnight. A local day may be 23 or 25 hours when daylight saving time changes.

### Resolved window diagnostic

Every window-selecting subcommand accepts `--print-resolved-window` (default
false). When set, it resolves its normal date arguments and defaults, prints
exactly `since=VALUE until_exclusive=VALUE` followed by a newline, and exits
without credentials or network requests. Each finite VALUE is an RFC3339
timestamp with its local offset; an absent bound is `unbounded`. The reported
half-open window must describe the same dates used by the export path. For an
inclusive calendar-day API, the exclusive end represents the following local
midnight. Invalid dates fail with a nonzero exit and no diagnostic on stdout.

Relative durations snap to local midnight, **not** "this many days before the current instant". `--since 30d` returns the same window whether you run it at 9am or 11pm.

Expand Down
16 changes: 14 additions & 2 deletions compat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,12 @@ formats.RunContract(t, compat.Runner{

## 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:
The actual local-midnight semantics of `--since 2026-04-15` remains **human-attested** until exporters adopt the resolved-window check in CI. Black-box coverage can use 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`.
The opt-in `dates.RunResolvedWindow` check below supports the first approach.

## Adding a new contract test

Expand All @@ -175,3 +175,15 @@ This module has its own tests that run each bundle against a stub CLI in `intern
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`.

The stub additionally honors `STUBCLI_FORMATS` (comma-separated) to restrict which codecs it will accept at parse time — used by the formats bundle's `TestRunContract_PartialCodec_SkipsCSV` self-test. Setting `STUBCLI_FORMATS=markdown,json` makes the stub reject `--format csv`, so the partial-codec self-test proves the `SupportedFormats` skip is actually load-bearing: without it, the test would fail at `CSVHasHeader` because the stub rejects csv.

## Proposed resolved-window diagnostic

Exporters adopting the proposed `--print-resolved-window` flag can call
`dates.RunResolvedWindow(t, runner)` separately from `RunContract` while
rollout is in progress. The command prints exactly one line:
`since=VALUE until_exclusive=VALUE`, followed by a newline. Finite bounds are
RFC3339 timestamps in local time; absent bounds are `unbounded`. The upper bound is the next local calendar midnight for an
explicit inclusive `--until`. It exits successfully without credentials or
network access. The diagnostic must use the same resolved dates as the
export path. These checks cover parsing and the reported window, not API
requests or data filtering, which need exporter-specific tests.
28 changes: 28 additions & 0 deletions compat/dates/dates.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,31 @@ func noNetworkEnv() []string {
"TZ=UTC",
}
}

// RunResolvedWindow checks the optional offline window diagnostic on each
// declared command. Call it separately while exporters adopt the proposal.
func RunResolvedWindow(t *testing.T, r compat.Runner) {
t.Helper()
subs := r.Subcommands
if len(subs) == 0 {
subs = []string{""}
}
cases := []struct{ zone, day, start, end string }{
{"Pacific/Honolulu", "2026-04-15", "2026-04-15T00:00:00-10:00", "2026-04-16T00:00:00-10:00"},
{"Pacific/Kiritimati", "2026-04-15", "2026-04-15T00:00:00+14:00", "2026-04-16T00:00:00+14:00"},
{"America/New_York", "2026-03-08", "2026-03-08T00:00:00-05:00", "2026-03-09T00:00:00-04:00"},
{"America/New_York", "2026-11-01", "2026-11-01T00:00:00-04:00", "2026-11-02T00:00:00-05:00"},
}
for _, sub := range subs {
for _, tc := range cases {
t.Run(sub+"/"+tc.zone+"/"+tc.day, func(t *testing.T) {
runner := r.WithSubcommand(sub).WithEnv(noNetworkEnv()...).WithEnv("TZ=" + tc.zone)
res := runner.MustRun(t, "--since", tc.day, "--until", tc.day, "--print-resolved-window")
want := "since=" + tc.start + " until_exclusive=" + tc.end + "\n"
if res.ExitCode != 0 || res.StdoutString() != want || res.StderrString() != "" {
t.Errorf("exit=%d stdout=%q stderr=%q; want exit 0 stdout=%q and empty stderr", res.ExitCode, res.StdoutString(), res.StderrString(), want)
}
})
}
}
}
6 changes: 6 additions & 0 deletions compat/dates/dates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,9 @@ func buildStub(t *testing.T) string {
}
return out
}

func TestResolvedWindowAgainstStub(t *testing.T) {
bin := buildStub(t)
dates.RunResolvedWindow(t, compat.Runner{Binary: bin})
dates.RunResolvedWindow(t, compat.Runner{Binary: bin, Env: []string{"STUBCLI_MODE=cobra"}, Subcommands: []string{"biometrics"}})
}
17 changes: 17 additions & 0 deletions compat/internal/stubcli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import (
"fmt"
"os"
"strings"
"time"
)

const cobraSubcommand = "biometrics"
Expand Down Expand Up @@ -155,6 +156,7 @@ func parseAndEmit(progName string, args []string) {
fs.SetOutput(os.Stderr)
since := fs.String("since", "", "inclusive lower bound")
until := fs.String("until", "", "inclusive upper bound")
window := fs.Bool("print-resolved-window", false, "print resolved window")
format := fs.String("format", "markdown", "output format (markdown|json|csv)")
if err := fs.Parse(args); err != nil {
os.Exit(2)
Expand All @@ -173,6 +175,21 @@ func parseAndEmit(progName string, args []string) {
os.Exit(2)
}

if *window {
start, err := time.ParseInLocation("2006-01-02", *since, time.Local)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
end, err := time.ParseInLocation("2006-01-02", *until, time.Local)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
fmt.Printf("since=%s until_exclusive=%s\n", start.Format(time.RFC3339), end.AddDate(0, 0, 1).Format(time.RFC3339))
return
}

// STUBCLI_NEEDS_TOKEN=1 simulates a credentialless-CI failure:
// flags parsed cleanly, but the data call fails because no token
// is available. Mirrors the shape real credentialed exporters
Expand Down
Loading