test(cz-cli): replay real CLI history against the parser - #70
test(cz-cli): replay real CLI history against the parser#70suibianwanwank wants to merge 1 commit into
Conversation
Adds a regression suite that replays every (command, flag-set) combination
real users have run on this lineage and asserts the current parser still
accepts it, so a deleted flag or a narrowed choices list goes red with the
usage count of what it broke.
script/export-history-matrix.ts otel_logs -> matrix.json (manual, needs
the czcli profile; never runs in CI)
test/support/cli-surface.ts command/option surface walked out of
yargs' own registry, not --help
test/support/history-replay.ts post-validation middleware + sentinel:
real parse, no handler, no side effects
test/history-argv.test.ts 2,757 combos + 407 choices values
test/history-effect.test.ts 35 wire assertions, for flags that parse
but no longer reach the request
test/history-regression/ fixture and triage list
The fixture is derived from logs that contain plaintext credentials and
customer SQL, so redaction is enforced twice: the queries persist positional
text never and flag values only for closed enums, and the writer refuses to
emit a field whose string does not match the shape that field may hold.
Triage entries excuse a failure only when every argument it blames is named.
yargs reports all of an invocation's unknown arguments in one message, so
matching any single one of them would have let the entry for `setup
--partition` also excuse the next flag deleted from that path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // A recorded token beyond what the declaration can hold is a flag value or a | ||
| // word of a quoted SQL statement, never an argument of this command: `cz-cli sql | ||
| // "show create table t"` records show/create/table as positional tokens. | ||
| const capacity = declared.some((p) => p.variadic) ? rest.length : declared.length | ||
| const keptTokens = rest.slice(0, capacity) | ||
| const argv = [...matched, ...keptTokens] |
There was a problem hiding this comment.
HIGH (confidence: high) — a deleted subcommand under a command group replays as green, so the COMMAND_REMOVED verdict is unreachable for the case it was written for.
const capacity = declared.some((p) => p.variadic) ? rest.length : declared.length
const keptTokens = rest.slice(0, capacity)Walking it through for task list --like x after someone deletes task list:
resolveCommandmatchestask, leavesrest = ["list"].taskis declared ascli.command("task", "Manage Studio tasks", …)(src/commands/task.ts:1219) — the declaration string carries no positionals, soparsePositionals("task")is[]andcapacity === 0.listlands indroppedTokens;argvbecomes["task", "--like=x"].commandGroupends in.demandCommand(1, humanMsg)withhumanMsg = "Missing subcommand for 'task'. …"(src/command-group.ts:105), whose fail handler throwsSubcommandHelpShown(src/command-group.ts:52).replayArgvmaps that toSUBCOMMAND_HELP, which is inACCEPTED(test/history-argv.test.ts:53). Test passes.
The group already has .strictCommands(), so had list reached argv the parser would have said Unknown commands: list and classifyFailure would have produced COMMAND_REMOVED correctly. The token never gets there. The same applies at depth 3 (analytics-agent domain create → analytics-agent domain bare → pass), and queryUndeclaredSubcommands() in the export script documents this exact collapse for a different purpose, so the mechanism is known.
This matters because it is one of the three regressions the suite advertises: "the suite stays green until someone deletes a flag, narrows a choices list, or renames a subcommand" (known-changes.ts:8-10).
The distinction the harness needs is already available: tokens from entry.sub / entry.sub2 were whitelisted against this tree's own commandTokens at export time (cli-surface.ts:14-17), so they were declared commands when the fixture was written — unlike tokens recovered from _positional, which the dropping logic is legitimately there to handle. Suggest threading that provenance through tokensOf/buildArgv and returning COMMAND_REMOVED when a token of sub/sub2 origin fails to resolve, instead of dropping it.
Separately, droppedTokens and resolvedPath are computed and returned but never asserted on and not printed by describeEntry (history-argv.test.ts:63-75) — so today a token vanishing is invisible even in the failure output.
| and array_contains(${sqlArray(CHOICE_KEYS)}, fk) | ||
| and not array_contains(${sqlArray([...SENSITIVE_KEYS])}, lower(fk)) | ||
| and fv is not null and length(fv) <= 40 and fv rlike '^[A-Za-z0-9_.:+-]+$' |
There was a problem hiding this comment.
MEDIUM (confidence: high) — the value-persistence whitelist is keyed on option name unioned across the whole tree, so free-text values from other commands get written verbatim into the committed fixture.
and array_contains(${sqlArray(CHOICE_KEYS)}, fk)
and not array_contains(${sqlArray([...SENSITIVE_KEYS])}, lower(fk))
and fv is not null and length(fv) <= 40 and fv rlike '^[A-Za-z0-9_.:+-]+$'CHOICE_KEYS is [...surface.choicesByKey.keys()], and choicesByKey unions choices by option key across every node (cli-surface.ts:198-208). Membership on one command therefore admits the same key everywhere. The docblock above this function and the header's redaction contract both state the stronger property:
Flag values are persisted only for options that declare
choices(closed, low-cardinality enums) … a closed enum is low-cardinality by construction and cannot carry customer data.
That does not hold as written. type enters CHOICE_KEYS from analytics-agent knowledge (src/commands/analytics-agent.ts:2796, choices: ["text", "dictionary"]), while --type is a plain unconstrained string on datasource list (src/commands/datasource.ts:354) and on task list / task run-stats (src/commands/task.ts:4218, :4374). Both show up in the fixture as a result: datasource list --type lakehouse (matrix.json:78096) and task list --type 29 (matrix.json:79408-79412).
Nothing leaked here — the surviving values are all enum-shaped, and the ≤40 char + ^[A-Za-z0-9_.:+-]+$ shape check is doing real work. But the shape check is the only thing standing between a free-text option and the committed file, and it would happily pass a table name, a workspace name, or a bare identifier. Given the header calls redaction "a hard requirement," the control should match the claim.
The replay side already has the per-path version of this predicate (isDeclaredOption, history-replay.ts:284), and DeclaredOption.choices is per-node — so scoping the whitelist to (command path, key) pairs is expressible with what's already built. As a bonus, history-argv.test.ts:126-131 then stops skipping the triples it can't use, which is currently dead fixture weight.
| "test:history": "bun test test/history-argv.test.ts test/history-effect.test.ts", | ||
| "test:all": "bun test test/classify-error.test.ts && bun run test:history && bun test/e2e-routing.ts && bun test/e2e-help.ts && bun test/e2e.ts", |
There was a problem hiding this comment.
MEDIUM (confidence: high) — nothing automated runs this suite, so the "fails with the usage count of what you broke" guarantee only fires if someone runs it by hand.
"test:history": "bun test test/history-argv.test.ts test/history-effect.test.ts",
"test:all": "bun test test/classify-error.test.ts && bun run test:history && …",Tracing both routes into it:
- CI's unit job runs
bun turbo test(.github/workflows/test.yml:68).turbo.jsondeclares no generictesttask — onlyopencode#test,@opencode-ai/core#test,@opencode-ai/app#test,@opencode-ai/ui#test,@opencode-ai/session-ui#test. There is no@clickzetta/cli#test, so this package's"test": "bun test --timeout 30000"is not part of that run, and no other workflow invokes cz-cli tests (onlyrelease-cos.yml:285touches the package, and that is a build). test:allreaches it, but its only automated caller istest:ci, which starts withbuild:local—cp ../opencode/dist/cz-cli-darwin-arm64/…pluscodesign. That is a macOS-arm64 developer command, not something CI can run.
The no-CI-for-cz-cli situation is pre-existing, not introduced here. It is worth deciding now, though, because this suite's whole value proposition is being a tripwire, and a tripwire nobody walks through catches nothing. Adding a "@clickzetta/cli#test": {} task to turbo.json would put it in the existing linux/windows unit matrix.
Two things to confirm if you do wire it up, neither of which I can measure without running it: the argv layer generates one test per fixture entry (2,000+) plus one per value case, each constructing a fresh yargs tree via registerCommands(createCli(argv)), and Tier B sets setDefaultTimeout(20_000). The unit job's budget is timeout-minutes: 20 for all packages.
| if (options.fillRequired) { | ||
| const supplied = new Set(flags.flatMap((flag) => spellings(flag.key))) | ||
| for (const option of new Set(byName.values())) { | ||
| if (!option.demanded || supplied.has(option.key)) continue | ||
| argv.push(`--${option.key}=${synthesizeValue(option.key, option)}`) | ||
| } |
There was a problem hiding this comment.
LOW (confidence: high, currently latent) — fillRequired compares alias spellings against the canonical key only, so an aliased mandatory option would be supplied twice.
const supplied = new Set(flags.flatMap((flag) => spellings(flag.key)))
for (const option of new Set(byName.values())) {
if (!option.demanded || supplied.has(option.key)) continue
argv.push(`--${option.key}=${synthesizeValue(option.key, option)}`)
}supplied holds every spelling of the historical key; the guard tests option.key. If a value case names an option by its alias — say -f for a demandOption: true --file — supplied contains f/f but not file, so the loop pushes --file=<synthesized> alongside the --f=<historical value> built above. Both resolve to the same argv key, the later token wins, and the expectation at line 250 then checks for the historical value that got overwritten — reporting FLAG_NOT_IN_ARGV for an option that parsed fine.
Not reachable today: I found no option in src/commands/ combining alias with demandOption, so this only bites when someone adds one. Resolving through the existing lookup(byName, flag.key) and seeding supplied with the canonical key (and its aliases) closes it.
| "stream, which the fixture deliberately does not persist, so they replay here without it. The highest-" + | ||
| "impact divergence found (135 invocations, 4 users) — see .claude/reports/history-regression.md.", |
There was a problem hiding this comment.
LOW (confidence: high) — this points at a file that is not in the repo.
"stream, which the fixture deliberately does not persist, so they replay here without it. The highest-" +
"impact divergence found (135 invocations, 4 users) — see .claude/reports/history-regression.md.",.claude/reports/ does not exist at the repo root or under packages/cz-cli, and this PR does not add it. The reason field is the audit trail that justifies suppressing a failure — the file header calls adding an entry "a deliberate act" — so for the highest-impact suppression in the list the supporting evidence should be reachable by whoever reads this next. Either commit the report or inline the couple of sentences that matter.
| if (!scratchFile) { | ||
| const dir = mkdtempSync(join(tmpdir(), "cz-history-replay-")) | ||
| scratchFile = join(dir, "input.sql") | ||
| writeFileSync(scratchFile, "select 1\n") | ||
| } | ||
| return scratchFile |
There was a problem hiding this comment.
LOW (confidence: high) — the scratch directory is never removed.
const dir = mkdtempSync(join(tmpdir(), "cz-history-replay-"))
scratchFile = join(dir, "input.sql")
writeFileSync(scratchFile, "select 1\n")One cz-history-replay-* directory accumulates in os.tmpdir() per test process. test/preload.ts already owns this pattern for the test home — it creates the temp dir and removes it in afterAll — so an afterAll(() => rmSync(dir, { recursive: true, force: true })) here would match the convention.
Same shape in the export script: runSql writes each query to $TMPDIR/cz-history-q<N>.sql (script/export-history-matrix.ts:88) and leaves it behind. Lower stakes since that is a manual command and the contents are the generated query rather than customer data.
| users: number | ||
| firstSeen: string | ||
| lastSeen: string | ||
| positionalCountMin: number |
There was a problem hiding this comment.
LOW (confidence: high) — positionalCountMin is persisted for every entry but never read.
positionalCountMin: numberIt is declared here and written by the export script (querySignatures, npos_min), but no assertion, verdict, or failure message consumes it — nothing outside this interface references the name. Every other field on MatrixEntry has a consumer: usageCount orders the tests, users/firstSeen/lastSeen/versions all land in describeEntry.
Worth deciding rather than leaving ambiguous, because it is 2,000+ extra lines in an 80,000-line committed fixture. It would be genuinely useful if it were wired up: an entry whose positionalCountMin exceeds what resolveCommand can place is precisely the signal that positionals were dropped, which is the gap I flagged in history-replay.ts around capacity. Otherwise drop the field from the export.
| "pat = 'pat'", | ||
| "workspace = 'ws'", | ||
| "instance = 'inst'", | ||
| "service = 'uat-api.clickzetta.com'", |
There was a problem hiding this comment.
LOW (confidence: medium) — this tier runs real handlers, and the fixture profile points them at what looks like a real internal endpoint.
"service = 'uat-api.clickzetta.com'",Safe as written: test/preload.ts installs the fetch boundary before any src/ import, and once a test registers any handler an unmatched request throws rather than falling through (test/support/fetch-boundary.ts:17-19). The catch-all match: () => true in beforeEach covers everything, so nothing escapes. I also confirmed telemetry cannot leak — OTEL_DEFAULTS.endpoint is build-time injected and empty in source, so trackCommand short-circuits (src/telemetry.ts:149).
The concern is only that the safety margin is one beforeEach wide, on the one test file in the suite that executes handlers against the network boundary. If a future edit registers handlers per-test instead of in beforeEach, or an early failure path fires a request before registration, these resolve against a host that may actually answer. A deliberately unroutable value (invalid.example, 127.0.0.1:1) costs nothing here — the assertions are all about request shape — and makes the failure mode a connection refusal instead of a live UAT call. Same for analysis_agent_endpoint, which already uses example.clickzetta.com.
|
Review summary Test-only PR: 8 files, all under A. Upstream invasiveness — no issues found No file under The approach is notably non-invasive where it could easily have gone the other way: the replay harness reaches the parser through B. Clean fix, or a hole around the problem — one finding Mostly the right shape. The surface is read out of yargs own command registry instead of scraped from The one finding is the token-dropping rule in Two smaller items in this category: the value whitelist claims a per-option property it enforces per-name across the whole tree (inline, MEDIUM), and C. Regression risk — low, with one caveat Nothing in What does change is developer and CI surface:
On the safety claim the suite rests on: Tier A really is parse-only. Handlers cannot run because the capture middleware throws first, No new cross-package dependency edge: everything imports within Remaining LOW items: All of these are suggestions — accept or reject as you see fit. |
No description provided.