feat(cli): implement iterate test + experiments with local persistence#479
feat(cli): implement iterate test + experiments with local persistence#479forgou37 wants to merge 1 commit into
Conversation
Replaces stubs with real file-based persistence. - iterate test <hypothesis> creates Experiment in ~/.config/sh1pt/iterate-experiments.json - iterate experiments lists active/paused/ended with formatted output or --json - --end/--pause/--resume mutate status, --winner records outcome Closes profullstack#442
Greptile SummaryThis PR replaces CLI stubs for
Confidence Score: 3/5Safe to merge as a stub-replacement, but three new command paths can silently persist invalid or incomplete data to the user's config file. The --winner field accepts any string and writes it directly to persisted JSON — passing --winner foo corrupts the stored experiment record. The --traffic option accepts out-of-range values like -10 or 200 with no rejection. Passing --winner or --note without --end silently discards the user's intent with no feedback. packages/cli/src/commands/iterate.ts — specifically the --winner validation path and --traffic bounds check in the experiments action handler. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[sh1pt iterate experiments] --> B{mutation flag?}
B -->|--end id| C[find experiment by id]
B -->|--pause id| D[find experiment by id]
B -->|--resume id| E[find experiment by id]
B -->|none| F{--json?}
C --> G{found?}
G -->|no| H[error + exit 1]
G -->|yes| I[set status=ended / winner / note]
I --> J[saveExperiments]
D --> L{found?}
L -->|no| H
L -->|yes| M[set status=paused]
M --> J
E --> N{found?}
N -->|no| H
N -->|yes| O[set status=active]
O --> J
F -->|yes| P[print JSON grouped by status]
F -->|no| Q{any experiments?}
Q -->|no| R[print empty hint]
Q -->|yes| S[print table by status]
T[sh1pt iterate test hypothesis] --> U[loadExperiments]
U --> V[create Experiment / random hex id]
V --> W[saveExperiments]
W --> X[print created summary]
style H fill:#f66,color:#fff
Reviews (1): Last reviewed commit: "feat(cli): implement iterate test + expe..." | Re-trigger Greptile |
| .action(async (opts: { | ||
| json?: boolean; | ||
| end?: string; | ||
| pause?: string; | ||
| resume?: string; | ||
| winner?: 'A' | 'B' | 'inconclusive'; | ||
| note?: string; | ||
| }) => { | ||
| const experiments = await loadExperiments(); | ||
| const now = new Date().toISOString(); | ||
|
|
||
| // Mutating actions | ||
| if (opts.end) { | ||
| const exp = experiments.find(e => e.id === opts.end); | ||
| if (!exp) { console.error(kleur.red(`experiment "${opts.end}" not found`)); process.exit(1); } | ||
| exp.status = 'ended'; | ||
| exp.updatedAt = now; | ||
| if (opts.winner) exp.winner = opts.winner; | ||
| if (opts.note) exp.note = opts.note; | ||
| await saveExperiments(experiments); | ||
| console.log(kleur.yellow(`ended: ${opts.end}`) + (opts.winner ? kleur.dim(` · winner=${opts.winner}`) : '')); | ||
| return; |
There was a problem hiding this comment.
--winner and --note silently discarded without --end
--winner and --note are only consumed inside the if (opts.end) branch. If a user runs sh1pt iterate experiments --winner A --note "done" (forgetting to supply --end <id>), the handler falls through to the listing path — the options are silently ignored and no winner is recorded. The user gets a normal experiments list with no indication their flags were dropped, leading them to believe the winner was persisted when it was not.
| if (!exp) { console.error(kleur.red(`experiment "${opts.end}" not found`)); process.exit(1); } | ||
| exp.status = 'ended'; | ||
| exp.updatedAt = now; | ||
| if (opts.winner) exp.winner = opts.winner; |
There was a problem hiding this comment.
--winner value not validated at runtime
Commander passes option values as plain strings; the TypeScript union type 'A' | 'B' | 'inconclusive' is compile-time only and offers no protection at runtime. A user who passes --winner foo will have the string "foo" written directly to the persisted JSON file, silently corrupting the winner field. A runtime guard is needed before the value is stored.
| if (opts.winner) exp.winner = opts.winner; | |
| const validWinners = ['A', 'B', 'inconclusive'] as const; | |
| if (opts.winner) { | |
| if (!(validWinners as readonly string[]).includes(opts.winner)) { | |
| console.error(kleur.red(`invalid --winner "${opts.winner}" — must be A, B, or inconclusive`)); | |
| process.exit(1); | |
| } | |
| exp.winner = opts.winner as 'A' | 'B' | 'inconclusive'; | |
| } |
| const experiments = await loadExperiments(); | ||
| const now = new Date().toISOString(); | ||
| const id = randomBytes(4).toString('hex'); |
There was a problem hiding this comment.
--traffic value not validated for valid range
Commander's Number coercion accepts any numeric string, including negative values and values above 100. Storing traffic: -10 or traffic: 200 is nonsensical for a traffic-split percentage and will display misleadingly (e.g., "-10% to B"). A simple range guard prevents invalid values from being persisted.
| const experiments = await loadExperiments(); | |
| const now = new Date().toISOString(); | |
| const id = randomBytes(4).toString('hex'); | |
| const experiments = await loadExperiments(); | |
| const traffic = opts.traffic ?? 50; | |
| if (traffic <= 0 || traffic >= 100) { | |
| console.error(kleur.red(`--traffic must be between 1 and 99 (got ${traffic})`)); | |
| process.exit(1); | |
| } | |
| const now = new Date().toISOString(); | |
| const id = randomBytes(4).toString('hex'); |
| const experimentsCmd = iterateCmd | ||
| .command('experiments') |
There was a problem hiding this comment.
experimentsCmd variable assigned but never used
The return value of .command('experiments') is stored in experimentsCmd, but the variable is never referenced again — the subcommand is already wired into iterateCmd as a side effect of the .command() call itself. The const binding is dead code.
| const experimentsCmd = iterateCmd | |
| .command('experiments') | |
| iterateCmd | |
| .command('experiments') |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| if (opts.end) { | ||
| const exp = experiments.find(e => e.id === opts.end); | ||
| if (!exp) { console.error(kleur.red(`experiment "${opts.end}" not found`)); process.exit(1); } | ||
| exp.status = 'ended'; | ||
| exp.updatedAt = now; | ||
| if (opts.winner) exp.winner = opts.winner; | ||
| if (opts.note) exp.note = opts.note; | ||
| await saveExperiments(experiments); | ||
| console.log(kleur.yellow(`ended: ${opts.end}`) + (opts.winner ? kleur.dim(` · winner=${opts.winner}`) : '')); | ||
| return; | ||
| } | ||
|
|
||
| if (opts.pause) { | ||
| const exp = experiments.find(e => e.id === opts.pause); | ||
| if (!exp) { console.error(kleur.red(`experiment "${opts.pause}" not found`)); process.exit(1); } | ||
| exp.status = 'paused'; | ||
| exp.updatedAt = now; | ||
| await saveExperiments(experiments); | ||
| console.log(kleur.yellow(`paused: ${opts.pause}`)); | ||
| return; | ||
| } | ||
|
|
||
| if (opts.resume) { | ||
| const exp = experiments.find(e => e.id === opts.resume); | ||
| if (!exp) { console.error(kleur.red(`experiment "${opts.resume}" not found`)); process.exit(1); } | ||
| exp.status = 'active'; | ||
| exp.updatedAt = now; | ||
| await saveExperiments(experiments); | ||
| console.log(kleur.green(`resumed: ${opts.resume}`)); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Conflicting mutation flags silently discard one operation
When a user passes two mutation flags at once — e.g., --end id1 --pause id2 — only the first branch (--end) executes and returns early. The --pause operation is silently dropped. Since these flags target different experiment IDs, the user might reasonably expect both to take effect. At minimum, an error should be emitted when multiple mutation flags are provided simultaneously.
Closes #442
Summary
Replaces the stubs in
sh1pt iterate testandsh1pt iterate experimentswith real file-based persistence, following the same atomic-write pattern used byiterate goals.Before
After
Changes
Experimentinterface withid,hypothesis,variants,traffic,minSample,status,winner,notefieldsEXPERIMENTS_FILEat~/.config/sh1pt/iterate-experiments.jsonloadExperiments()/saveExperiments()with same atomic tmp-rename pattern as goalsiterate test <hypothesis>creates an entry with a random 8-char hex iditerate experimentslists all experiments grouped by status--end,--pause,--resumeflags mutate status and persist--winner A|B|inconclusiveand--noterecorded on end--jsonoutputs{ active, paused, ended }arrays