diff --git a/README.md b/README.md index ef4b8f3..4200745 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ hono request [file] [options] - `--runtime ` - runtime to execute the app: `node` (default), `bun`, `deno`, or `workerd` - `-i, --include` - Include status and headers in the output (with `--plain`) - `-I, --head` - Show only status and headers in the output (with `--plain`) +- `--compact` - One-line JSON without the headers - `-e, --external ` - Mark package as external (can be used multiple times) **Examples:** @@ -244,6 +245,7 @@ hono batch [file] **Options:** - `-H, --header
` - Shared headers for every step +- `--compact` - Print only the failed steps and the summary, as one-line JSON - `-e, --external ` - Mark package as external (can be used multiple times) ```bash @@ -265,7 +267,7 @@ Print the current behavior of the app as batch JSONL lines, to stdout — no fil hono snapshot [file] ``` -Paramless GET routes are executed and their actual response becomes the `expect`. Param and non-GET routes are printed without one, to fill in. One probe line records the current response for a path that matches no route. Capture before a refactor, then rerun the lines with `hono batch` until `failed` is 0. +Paramless GET routes are executed and their actual response becomes the `expect` (`--status-only` captures only the status codes — much smaller on a large app; the probe line keeps its body either way). Param and non-GET routes are printed without one, to fill in. One probe line records the current response for a path that matches no route. Capture before a refactor, then rerun the lines with `hono batch` until `failed` is 0. Unlike `routes`, this command sends real requests to the app — middleware runs. `routes` never sends a request. diff --git a/docs/agent-dx-log.md b/docs/agent-dx-log.md index 4e7e718..6b4f1b1 100644 --- a/docs/agent-dx-log.md +++ b/docs/agent-dx-log.md @@ -3,6 +3,25 @@ How measurements from [honojs/agent-dx](https://github.com/honojs/agent-dx) changed Hono CLI. Newest first. +## 2026-09-07: The loop works — now make it cheap + +**Experiment**: `next.5` re-measurements. The spec-in-the-request +condition: 3/3 at a 79k median (file-parity, beats self-authoring). +The snapshot workflow on a 27-route split refactor: baseline 3/5 → +5/5 with one AGENTS.md line, snapshot used in every run — but 0/5 +from the skill alone (a refactor request does not fire it, again). + +**Findings**: the verification loop wins on correctness and loses on +tokens: the snapshot run cost 262k vs a 149k baseline, mostly full +bodies printed for 27 routes, echoed back through the batch results. + +**Changes**: cost controls, all opt-in flags. `hono batch --compact` +prints only the failed steps and the summary, as one-line JSON. +`hono snapshot --status-only` captures status codes without bodies — +except the not-found probe line, which keeps its body: a dropped +notFound handler still answers 404, only the body changes. +`hono request --compact` drops the headers and prints one line. + ## 2026-09-06: The spec travels in the conversation, not in a file **Experiment**: the `expect` re-run (`next.4`): a ready-made diff --git a/src/commands/batch/index.test.ts b/src/commands/batch/index.test.ts index ab09a36..914240d 100644 --- a/src/commands/batch/index.test.ts +++ b/src/commands/batch/index.test.ts @@ -67,6 +67,32 @@ describe('batchCommand', () => { }) }) + it('should print only the failed steps and the summary with --compact', async () => { + const fs = await import('node:fs') + vi.mocked(fs.readFileSync).mockReturnValue( + '{"path":"/data","expect":{"status":200}}\n{"path":"/data","expect":{"status":404}}' + ) + await program.parseAsync(['node', 'test', 'batch', 'steps.jsonl', 'test-app.js', '--compact']) + const raw = consoleLogSpy.mock.calls[0][0] as string + expect(raw).not.toContain('\n ') + expect(JSON.parse(raw)).toEqual({ + ok: true, + data: { + steps: [ + { + method: 'GET', + path: '/data', + status: 200, + body: { ok: 1 }, + pass: false, + expect: { status: 404 }, + }, + ], + summary: { total: 2, passed: 1, failed: 1 }, + }, + }) + }) + it('should reject the app and the batch both from stdin', async () => { await program.parseAsync(['node', 'test', 'batch', '-', '-']) const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts index 4245db6..56ace29 100644 --- a/src/commands/batch/index.ts +++ b/src/commands/batch/index.ts @@ -23,6 +23,7 @@ EOF`, '"save" stores a value from the response body by dot path (e.g. {"id":".id"}), and later steps use it as {{id}}. A whole-variable string like "{{id}}" keeps the saved type.', 'Declare the acceptance criteria in "expect": {"status":201} and/or {"body":{...}} (a deep partial match — declared fields must match, extra response fields are ignored). Turn the spec into batch lines and rerun until "failed" is 0 — comparing a spec table by eye misses lines.', 'A shared header from -H goes to every step. Prefer a heredoc over writing a file: the lines live in your context.', + '--compact prints only the failed steps and the summary — use it when you only need the failed: 0 loop.', 'hono snapshot prints the current behavior of an app in this format — capture before a refactor, rerun after.', ], } @@ -30,6 +31,7 @@ EOF`, interface BatchOptions { header?: string[] external?: string[] + compact: boolean } export function batchCommand(program: Command) { @@ -46,6 +48,7 @@ export function batchCommand(program: Command) { }, [] as string[] ) + .option('--compact', 'Print only the failed steps and the summary', false) .option( '-e, --external ', 'Mark package as external (can be used multiple times)', @@ -68,7 +71,18 @@ export function batchCommand(program: Command) { const input = source === '-' ? await readStdin() : readBatchFile(source) const steps = parseBatch(input) for await (const app of getBuildIterator(file, false, options.external || [])) { - printResult(await runBatch(app, steps, parseHeaders(options.header))) + const result = await runBatch(app, steps, parseHeaders(options.header)) + if (options.compact) { + printResult( + { + steps: result.steps.filter((step) => !step.pass), + summary: result.summary, + }, + true + ) + } else { + printResult(result) + } } }) ) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index c502f38..62e6c21 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -124,6 +124,19 @@ describe('requestCommand', () => { expect(output.error.suggestions).toEqual(['hono request /data -X GET']) }) + it('should print one-line JSON without headers with --compact', async () => { + const mockApp = new Hono() + mockApp.get('/data', (c) => c.json({ ok: 1 })) + setupBasicMocks('test-app.js', mockApp) + await program.parseAsync(['node', 'test', 'request', '/data', 'test-app.js', '--compact']) + const raw = consoleLogSpy.mock.calls[0][0] + expect(raw).not.toContain('\n') + expect(JSON.parse(raw)).toEqual({ + ok: true, + data: { status: 200, body: { ok: 1 } }, + }) + }) + it('should output a text body as a string in the envelope', async () => { const mockApp = new Hono() const text = 'Hello, World!' diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index 5837322..9bcc009 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -40,6 +40,7 @@ export const agentContext: CommandAgentContext = { '--trace adds matchedRoutes to the output: which middleware and handler matched, and which one responded. Use it to debug an unexpected response. A 404 result includes a suggestion to run it.', 'A JSON response body is embedded as an object. A binary body becomes null with "binary": true — save it with -o.', 'For several requests, or a flow that keeps state, use hono batch. To capture the current behavior of the app, use hono snapshot.', + '--compact prints one-line JSON without the headers — cheaper to read when you only need the status and body.', ], } @@ -56,6 +57,7 @@ interface RequestOptions { include: boolean head: boolean external?: string[] + compact: boolean } export function requestCommand(program: Command) { @@ -84,6 +86,7 @@ export function requestCommand(program: Command) { 'runtime to execute the app (node | bun | deno | workerd)', 'node' ) + .option('--compact', 'One-line JSON without the headers', false) .option('-i, --include', 'Include protocol and headers in the output (with --plain)', false) .option('-I, --head', 'Show only protocol and headers in the output (with --plain)', false) .option( @@ -122,6 +125,11 @@ export function requestCommand(program: Command) { ) } + if (options.compact && options.plain) { + throw new CliError('INVALID_OPTION', 'Cannot use --compact with --plain', { + suggestions: ['Drop one of them'], + }) + } if (options.trace && options.plain) { throw new CliError('INVALID_OPTION', 'Cannot use --trace with --plain', { suggestions: ['Drop --plain. The trace is part of the JSON output'], @@ -219,17 +227,20 @@ const printResponse = async ( // it helps, so point at it right there. const suggestTrace = result.status === 404 && !options.trace && options.runtime === 'node' - printResult({ - status: result.status, - headers: result.headers, - body: isBinaryData ? null : parseBody(result.body, contentType), - ...(isBinaryData ? { binary: true } : {}), - ...(savedTo ? { savedTo } : {}), - ...(suggestTrace - ? { suggestions: [`See which routes matched: hono request ${path} --trace`] } - : {}), - ...extra, - }) + printResult( + { + status: result.status, + ...(options.compact ? {} : { headers: result.headers }), + body: isBinaryData ? null : parseBody(result.body, contentType), + ...(isBinaryData ? { binary: true } : {}), + ...(savedTo ? { savedTo } : {}), + ...(suggestTrace + ? { suggestions: [`See which routes matched: hono request ${path} --trace`] } + : {}), + ...extra, + }, + options.compact + ) } const printPlain = ( diff --git a/src/commands/snapshot/index.ts b/src/commands/snapshot/index.ts index 2a2493c..eadbbc1 100644 --- a/src/commands/snapshot/index.ts +++ b/src/commands/snapshot/index.ts @@ -14,12 +14,14 @@ export const agentContext: CommandAgentContext = { 'Paramless GET routes are executed and their actual status and body become the "expect". Param and non-GET routes are printed without one, for you to fill in — the tool does not invent intent.', 'One probe line records the current response for a path that matches no route.', 'Capture before a refactor, then rerun the lines with hono batch until "failed" is 0.', + '--status-only captures only the status codes — much smaller on a large app. The probe line keeps its body either way: a dropped notFound handler still answers 404, only the body changes.', 'Unlike routes, this command sends real requests to the app — middleware runs.', ], } interface SnapshotOptions { external?: string[] + statusOnly: boolean } export function snapshotCommand(program: Command) { @@ -27,6 +29,7 @@ export function snapshotCommand(program: Command) { .command('snapshot') .description('Print the current behavior as batch JSONL lines') .argument('[file]', 'Path to the Hono app file') + .option('--status-only', 'Capture only the status codes, not the bodies', false) .option( '-e, --external ', 'Mark package as external (can be used multiple times)', @@ -38,7 +41,7 @@ export function snapshotCommand(program: Command) { .action( handleErrors(async (file: string | undefined, options: SnapshotOptions) => { for await (const app of getBuildIterator(file, false, options.external || [])) { - console.log((await snapshotLines(app)).join('\n')) + console.log((await snapshotLines(app, options.statusOnly)).join('\n')) } }) ) diff --git a/src/commands/snapshot/snapshot.test.ts b/src/commands/snapshot/snapshot.test.ts index 5b6c2f1..341f5eb 100644 --- a/src/commands/snapshot/snapshot.test.ts +++ b/src/commands/snapshot/snapshot.test.ts @@ -35,6 +35,16 @@ describe('snapshotLines', () => { }) }) + it('captures only the status with statusOnly, except the probe line', async () => { + const lines = (await snapshotLines(app(), true)).map((l) => JSON.parse(l)) + expect(lines).toContainEqual({ path: '/users', expect: { status: 200 } }) + expect(lines).toContainEqual({ path: '/health', expect: { status: 200 } }) + expect(lines).toContainEqual({ + path: '/__no_such_path__', + expect: { status: 404, body: '404 Not Found' }, + }) + }) + it('every line is valid batch input', async () => { const { parseBatch } = await import('../batch/batch.js') const lines = await snapshotLines(app()) diff --git a/src/commands/snapshot/snapshot.ts b/src/commands/snapshot/snapshot.ts index 8dd8c62..a6e871d 100644 --- a/src/commands/snapshot/snapshot.ts +++ b/src/commands/snapshot/snapshot.ts @@ -8,20 +8,24 @@ import { inspectRoutes } from 'hono/dev' * printed without an `expect`, for the caller to fill in. One probe * line records the current not-found behavior as a fact. */ -export const snapshotLines = async (app: Hono): Promise => { +export const snapshotLines = async (app: Hono, statusOnly = false): Promise => { const lines: string[] = [] const routes = inspectRoutes(app).filter((route) => !route.isMiddleware) for (const route of routes) { const isParamless = !route.path.includes(':') && !route.path.includes('*') if (route.method === 'GET' && isParamless) { - lines.push(JSON.stringify({ path: route.path, expect: await capture(app, route.path) })) + const captured = await capture(app, route.path) + const expect = statusOnly ? { status: captured.status } : captured + lines.push(JSON.stringify({ path: route.path, expect })) } else { const method = route.method === 'GET' ? {} : { method: route.method } lines.push(JSON.stringify({ ...method, path: route.path })) } } + // The probe keeps its body even with --status-only: a dropped + // notFound handler still answers 404, only the body changes. lines.push( JSON.stringify({ path: '/__no_such_path__', diff --git a/src/utils/output.ts b/src/utils/output.ts index 81aea78..8b430a1 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -66,8 +66,8 @@ export const formatArgumentsError = (message: string): string => { return formatError(new CliError('INVALID_ARGUMENTS', cleaned, { suggestions })) } -export const printResult = (data: unknown): void => { - console.log(formatResult(data)) +export const printResult = (data: unknown, compact = false): void => { + console.log(compact ? JSON.stringify({ ok: true, data }) : formatResult(data)) } /**