From 5c8e8b832b93ab0323800a8cc7c109402508d617 Mon Sep 17 00:00:00 2001 From: Anton Dzyatkovsky Date: Wed, 26 Aug 2026 14:21:11 -0700 Subject: [PATCH] fix(cli): apply the documented `mex timeline --type` filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mex timeline --type ` was silently ignored. Commander stores the flag as `opts.type`, but `runTimeline` reads `TimelineOpts.kind`, so spreading the parsed options left `kind` undefined and the filter matched every event. Two visible effects: mex timeline --type decision # listed notes and risks too mex timeline --type session_start # exit 0, listed everything The second one is the worse half: `mex log --type session_start` rejects that value with `Unknown event type "session_start". Use decision, note, risk, or todo.`, while `timeline` accepted it and answered with unfiltered output. `--since`, `--limit`, and `--json` were unaffected — only the kind option was misnamed across the boundary. The `log` command in the same file already does this mapping (`{ kind: opts.type }`); `timeline` did not. Tests: - test/cli.test.ts now asserts the handler is called with `kind`, and the in-file program copy mirrors the real wiring. The previous assertion (`type: "risk"`) encoded the bug. - a spawn test drives the built `dist/cli.js` end to end: `--type decision` returns 1 of 2 events, and `--type session_start` exits 1 with the same message `mex log` prints. - test/events.test.ts covers `runTimeline` filtering and unknown-kind rejection directly. Reverting only the `src/cli.ts` change turns the spawn test red with "expected 2 events to have a length of 1". Assisted-by: Claude Code / claude-opus-5 Machine: MacBook-Anton Account: a Operator: robot:git-s3-docs-fix-lane --- src/cli.ts | 10 +++++++- test/cli.test.ts | 60 ++++++++++++++++++++++++++++++++++++++++++--- test/events.test.ts | 20 +++++++++++++++ 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index f86f7096..6582d871 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -316,7 +316,15 @@ program try { const config = loadConfig(); const { runTimeline } = await import("./events.js"); - await runTimeline(config, opts); + // `--type` is the user-facing flag name; TimelineOpts calls it `kind`. + // Pass it across explicitly — spreading `opts` leaves `kind` undefined + // and the filter silently matches everything. + await runTimeline(config, { + json: opts.json, + since: opts.since, + kind: opts.type, + limit: opts.limit, + }); } catch (err) { console.error((err as Error).message); process.exit(1); diff --git a/test/cli.test.ts b/test/cli.test.ts index 78ddb390..5ac84fd4 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -78,7 +78,12 @@ function buildProgram(): Command { .action(async (opts) => { try { const { runTimeline } = await import("../src/events.js"); - await runTimeline(config, opts); + await runTimeline(config, { + json: opts.json, + since: opts.since, + kind: opts.type, + limit: opts.limit, + }); } catch (err) { console.error((err as Error).message); process.exit(1); @@ -176,7 +181,12 @@ describe("mex timeline parsing", () => { const program = buildProgram(); await program.parseAsync(["node", "mex", "timeline", "--limit", "5"]); - expect(runTimeline).toHaveBeenCalledWith(config, { limit: 5 }); + expect(runTimeline).toHaveBeenCalledWith(config, { + json: undefined, + since: undefined, + kind: undefined, + limit: 5, + }); }); it("rejects invalid --limit values", async () => { @@ -189,7 +199,7 @@ describe("mex timeline parsing", () => { } }); - it("passes --json, --since, and --type through to the timeline handler", async () => { + it("maps --type onto the handler's `kind` option", async () => { const program = buildProgram(); await program.parseAsync([ "node", @@ -205,7 +215,8 @@ describe("mex timeline parsing", () => { expect(runTimeline).toHaveBeenCalledWith(config, { json: true, since: "30d", - type: "risk", + kind: "risk", + limit: undefined, }); }); }); @@ -272,6 +283,47 @@ describe("built CLI main-module guard", () => { rmSync(fixture, { recursive: true, force: true }); } }); + + it("filters the timeline by --type through the real binary", () => { + const fixture = mkdtempSync(join(tmpdir(), "mex-timeline-")); + const env = { ...process.env, NO_COLOR: "1" }; + try { + const mexPath = join(fixture, ".mex"); + mkdirSync(join(mexPath, "events"), { recursive: true }); + writeFileSync(join(mexPath, "ROUTER.md"), ""); + writeFileSync( + join(mexPath, "events", "decisions.jsonl"), + [ + { timestamp: "2026-05-14T00:00:00.000Z", kind: "decision", message: "picked sqlite", files: [] }, + { timestamp: "2026-05-15T00:00:00.000Z", kind: "risk", message: "wasm heap pressure", files: [] }, + ] + .map((e) => JSON.stringify(e)) + .join("\n") + "\n", + ); + + const result = spawnSync( + process.execPath, + [cliPath, "timeline", "--type", "decision", "--json"], + { cwd: fixture, encoding: "utf8", env }, + ); + expect(result.status).toBe(0); + + const { events } = JSON.parse(result.stdout) as { events: { kind: string }[] }; + expect(events).toHaveLength(1); + expect(events[0].kind).toBe("decision"); + + // An unknown kind must fail the way `mex log` does, not quietly list everything. + const unknown = spawnSync( + process.execPath, + [cliPath, "timeline", "--type", "session_start"], + { cwd: fixture, encoding: "utf8", env }, + ); + expect(unknown.status).toBe(1); + expect(unknown.stderr).toContain('Unknown event type "session_start"'); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } + }, 30_000); }); describe("mex --version", () => { diff --git a/test/events.test.ts b/test/events.test.ts index 595e17a7..6c2aa02d 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -70,4 +70,24 @@ describe("events", () => { await runTimeline(config, { json: true }); expect(spy.mock.calls.at(-1)?.[0]).toContain('"events"'); }); + + it("timeline keeps only the requested kind", async () => { + appendEvent(config, "picked sqlite", { kind: "decision" }); + appendEvent(config, "wasm heap pressure", { kind: "risk" }); + appendEvent(config, "plain note", { kind: "note" }); + + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + await runTimeline(config, { json: true, kind: "decision" }); + + const { events } = JSON.parse(spy.mock.calls.at(-1)?.[0] as string); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ kind: "decision", message: "picked sqlite" }); + }); + + it("timeline rejects an unknown kind instead of returning everything", async () => { + appendEvent(config, "plain note", { kind: "note" }); + await expect(runTimeline(config, { kind: "session_start" })).rejects.toThrow( + /Unknown event type "session_start"/, + ); + }); });