From 02430ccc3279dc2c21a2ba70cebddfcef88aac0e Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sat, 29 Aug 2026 12:00:04 -0500 Subject: [PATCH] feat(steps): a reply that sets no status is a 200; inline Express handlers keep their replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - effects.ts implicitResponseStatus: a body-sending reply with no status in its chain (res.json / send / render, reply.send, c.json, NextResponse.json, JSONResponse / jsonify / render_template, Rails render, Laravel response()->json) is a 200; a variable status, end, sendStatus and redirects stay as they were - branch-guards callSiteInTree: a status set by the statement just before the reply (`res.status(202); res.json(user)`) is that reply's — looked back within the block, only a statement that IS the status call counts - steps.ts: explicit chain/args → set-before → implicit 200 - express.ts: an inline handler's reply calls (`res.status(404).json(…)`, `res.json(user)`) are references at their own line and column instead of framework noise, so the route's own reply box exists - tests: servers fixture (inline route's 200 beside the service's 404; a 202 set before), ui-effects - CHANGELOG Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC --- CHANGELOG.md | 2 ++ __tests__/ui-effects.test.ts | 24 ++++++++++++++++- __tests__/ui-steps-api-servers.test.ts | 34 ++++++++++++++++++++---- src/graph/branch-guards.ts | 28 ++++++++++++++++++++ src/resolution/frameworks/express.ts | 36 +++++++++++++++++++++++++- src/ui-server/api/effects.ts | 22 ++++++++++++++++ src/ui-server/api/steps.ts | 7 +++-- 7 files changed, 144 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea12ec0..5ab74ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **A server action written through a wrapper starts its transition on the right page.** `export const signIn = validatedAction(schema, async (data) => { … redirect('/dashboard') })` — the arrow inside is no symbol of its own — now belongs to `signIn` on the Screens tab, and `signIn` is attributed to the page whose component hands it to `useActionState(signIn, …)`, read from the source when the graph holds no such edge. +- **A reply that sets no status is a 200.** `res.json(user)`, `res.send(…)`, `reply.send(…)`, `NextResponse.json(…)`, a `JSONResponse` or `jsonify(…)` with no status in the chain now count as `200`, so an endpoint's response box reads `200 · 401` instead of `401` alone and the success row carries its code; a status set by the statement just before (`res.status(202); res.json(user)`) is that reply's. An Express handler written inline at the registration keeps its own replies too — they were filtered out with the framework noise. + - **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges. - **Production code under a `samples` or `examples` package path is no longer treated as test code.** A Kotlin or Java project whose package path runs through `com/google/samples/…` (Now in Android, for one) had nearly every file counted as a fixture, so the Map opened on `build-logic`, the entry points hid the app, and dead-code and test badges were wrong. Only the project layout above a `src/` folder decides now; the package path below it never does. diff --git a/__tests__/ui-effects.test.ts b/__tests__/ui-effects.test.ts index 25bd3d6..26fef52 100644 --- a/__tests__/ui-effects.test.ts +++ b/__tests__/ui-effects.test.ts @@ -4,7 +4,7 @@ * access a database call names; the status a response site sends. */ import { describe, it, expect } from 'vitest'; -import { classifyEffect, responseStatus } from '../src/ui-server/api/effects'; +import { classifyEffect, implicitResponseStatus, responseStatus } from '../src/ui-server/api/effects'; const c = (text: string, language?: string, extra: Partial[0]> = {}) => classifyEffect({ text, kind: 'calls', language: language as never, project: 'api', ...extra }); @@ -244,3 +244,25 @@ describe('responseStatus', () => { expect(responseStatus('res.json', '')).toBeNull(); }); }); + +describe('implicitResponseStatus', () => { + it('a body-sending reply that sets no status is a 200', () => { + expect(implicitResponseStatus('res.json')).toBe(200); + expect(implicitResponseStatus('res.send')).toBe(200); + expect(implicitResponseStatus('res.render')).toBe(200); + expect(implicitResponseStatus('reply.send')).toBe(200); + expect(implicitResponseStatus('c.json')).toBe(200); + expect(implicitResponseStatus('NextResponse.json')).toBe(200); + expect(implicitResponseStatus('JSONResponse')).toBe(200); + expect(implicitResponseStatus('jsonify')).toBe(200); + }); + it('is null when the chain sets a status — literal or not — or ends without a body', () => { + expect(implicitResponseStatus('res.status(404).json')).toBeNull(); + expect(implicitResponseStatus('res.status(code).json')).toBeNull(); + expect(implicitResponseStatus('res.sendStatus(204)')).toBeNull(); + expect(implicitResponseStatus('res.end')).toBeNull(); + expect(implicitResponseStatus('res.redirect')).toBeNull(); + expect(implicitResponseStatus('NotFoundException')).toBeNull(); + expect(implicitResponseStatus('prisma.user.create')).toBeNull(); + }); +}); diff --git a/__tests__/ui-steps-api-servers.test.ts b/__tests__/ui-steps-api-servers.test.ts index 6206440..6e95b5e 100644 --- a/__tests__/ui-steps-api-servers.test.ts +++ b/__tests__/ui-steps-api-servers.test.ts @@ -58,6 +58,11 @@ beforeAll(async () => { '}\n' + 'async function sendVerification(user) {\n' + ' await transporter.sendMail({ to: user.email })\n' + + '}\n' + + 'export async function acceptUser(req, res) {\n' + + ' const user = await prisma.user.update({ where: { id: req.params.id }, data: { accepted: true } })\n' + + ' res.status(202)\n' + + ' res.json(user)\n' + '}\n' ); write( @@ -65,9 +70,10 @@ beforeAll(async () => { "import { Router } from 'express'\n" + "import { authenticate } from './auth'\n" + "import { validate } from './validate'\n" + - "import { createUser, getUser } from './users.service'\n" + + "import { createUser, getUser, acceptUser } from './users.service'\n" + 'const router = Router()\n' + "router.post('/users', authenticate, validate(userSchema), createUser)\n" + + "router.post('/users/:id/accept', authenticate, acceptUser)\n" + "router.get('/users/:id', authenticate, async (req, res) => {\n" + ' const user = await getUser(req.params.id)\n' + ' res.json(user)\n' + @@ -316,11 +322,29 @@ describe('Express', () => { expect(anchor.trigger).toMatchObject({ kind: 'request', name: 'GET', of: '/users/:id', after: ['authenticate'] }); const db = effect(p, 'database')!; expect(db.effect).toMatchObject({ model: 'user', access: 'read', by: { name: 'getUser' } }); + // `res.json(user)` in the inline handler sets no status: a 200, the + // route's own reply box; the service's `NotFoundError` is `getUser`'s box. + const replies = p.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'response'); + expect(replies.map((s) => [s.effect!.by.name, s.label]).sort()).toEqual([ + ['GET /users/:id', '200'], + ['getUser', '404'], + ]); + const own = replies.find((s) => s.effect!.by.name === 'GET /users/:id')!; + expect(p.links.find((l) => l.to === own.id)!.sites[0]).toMatchObject({ text: 'res.json', args: 'user', status: 200 }); + const notFound = replies.find((s) => s.effect!.by.name === 'getUser')!; + const link = p.links.find((l) => l.to === notFound.id)!; + expect(link.sites[0]).toMatchObject({ text: 'NotFoundError', status: 404, when: '!user' }); + expect(link.via.map((v) => v.name)).toEqual(['getUser']); + }); + + it('a status set by the statement before the reply is that reply’s, not a 200', async () => { + const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /users/:id/accept').id })); const res = effect(p, 'response')!; - expect(res.label).toBe('404'); - const resLink = p.links.find((l) => l.to === res.id)!; - expect(resLink.sites[0]).toMatchObject({ text: 'NotFoundError', status: 404, when: '!user' }); - expect(resLink.via.map((v) => v.name)).toEqual(['getUser']); + expect(res.label).toBe('202'); + expect(p.links.find((l) => l.to === res.id)!.sites.map((x) => [x.text, x.status])).toEqual([ + ['res.status', 202], + ['res.json', 202], + ]); }); }); diff --git a/src/graph/branch-guards.ts b/src/graph/branch-guards.ts index c6e4ced..a58bdf7 100644 --- a/src/graph/branch-guards.ts +++ b/src/graph/branch-guards.ts @@ -501,9 +501,37 @@ export function callSiteInTree(root: SyntaxNode, source: string, line: number, c if (status === null && c.type !== 'comment') status = statusPropertyIn(c); } const text = parts.join(', '); + if (status === null) status = statusSetBefore(call, callee); return { callee, args: text.length > MAX_ARGS_TEXT ? `${text.slice(0, MAX_ARGS_TEXT - 1)}…` : text, argList: parts, ...(status !== null ? { status } : {}) }; } +const BODY_REPLY = /^(?:res|response|reply|rep|ctx|c|context)\.(?:json|jsonp|send|render|sendFile|download|end|text|html|body)$/; +const STATEMENT_BLOCKS: ReadonlySet = new Set(['statement_block', 'program', 'block', 'class_body', 'module']); +/** Statements looked back through for a status the reply's own chain does not carry. */ +const STATUS_LOOKBACK = 6; + +/** + * `res.status(202); res.json(user)` — the status set by an earlier statement + * in the same block, when the reply's own chain sets none. Only a statement + * that IS the status call counts (`res.status(404)` inside an `if` before it + * is another path, not this reply's); the first one found walking back wins. + */ +function statusSetBefore(call: SyntaxNode, callee: string): number | null { + const bare = callee.replace(/\([^()]*\)/g, ''); + if (!BODY_REPLY.test(bare) || /\b(?:status|code|sendStatus|writeHead)\(/.test(callee)) return null; + const receiver = bare.split('.')[0]!; + let statement: SyntaxNode | null = call; + while (statement.parent && !STATEMENT_BLOCKS.has(statement.parent.type)) statement = statement.parent; + const re = new RegExp(`^\\s*(?:await\\s+)?${receiver}\\s*\\.\\s*(?:status|code)\\s*\\(\\s*([1-5]\\d{2})\\s*\\)\\s*;?\\s*$|^\\s*${receiver}\\s*\\.\\s*statusCode\\s*=\\s*([1-5]\\d{2})\\s*;?\\s*$`); + let prev: SyntaxNode | null = statement.previousNamedSibling; + for (let i = 0; prev && i < STATUS_LOOKBACK; i++, prev = prev.previousNamedSibling) { + if (prev.type === 'comment') continue; + const m = re.exec(prev.text); + if (m) return Number(m[1] ?? m[2]); + } + return null; +} + /** The text of a call before its arguments, normalised to a member chain. */ function calleeChainText(call: SyntaxNode, container: SyntaxNode): string { // Kotlin and Swift wrap the arguments in a `call_suffix`; the callee is diff --git a/src/resolution/frameworks/express.ts b/src/resolution/frameworks/express.ts index c529ba2..8980d29 100644 --- a/src/resolution/frameworks/express.ts +++ b/src/resolution/frameworks/express.ts @@ -49,6 +49,34 @@ const RESERVED_CALLS = new Set([ 'Date', 'Math', 'JSON', 'Promise', 'require', 'fail', 'redirect', ]); +/** + * The replies an inline handler makes — `res.status(404).json({…})`, + * `res.json(user)`, `reply.send(…)`, `ctx.body = …` aside — as references the + * Steps view's effect table reads at their own line and column. The body's + * plain calls above skip these names as framework noise on purpose (they are + * not the business flow); for the endpoint's contract they are the point. + */ +const REPLY_CALL = /\b(res|response|reply|rep|ctx)\s*\.\s*(?:[A-Za-z_$][\w$]*\s*\([^()]*\)\s*\.\s*)*([A-Za-z_$][\w$]*)\s*\(/g; +function replyRefs(safe: string, bodyStart: number, bodyEnd: number, fromNodeId: string, filePath: string, language: 'typescript' | 'javascript'): UnresolvedRef[] { + const out: UnresolvedRef[] = []; + const body = safe.slice(bodyStart, bodyEnd); + REPLY_CALL.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = REPLY_CALL.exec(body)) !== null) { + const at = bodyStart + m.index; + out.push({ + fromNodeId, + referenceName: `${m[1]}.${m[2]}`, + referenceKind: 'calls', + line: safe.slice(0, at).split('\n').length, + column: at - (safe.lastIndexOf('\n', at - 1) + 1), + filePath, + language, + }); + } + return out; +} + export const expressResolver: FrameworkResolver = { name: 'express', languages: ['javascript', 'typescript'], @@ -169,9 +197,13 @@ export const expressResolver: FrameworkResolver = { const afterArrow = args.slice(arrowAt + 2); const braceAt = afterArrow.indexOf('{'); let body = afterArrow; + let bodyStart = openParen + 1 + arrowAt + 2; if (braceAt >= 0 && afterArrow.slice(0, braceAt).trim() === '') { const end = matchDelim(afterArrow, braceAt, '{', '}'); - if (end > braceAt) body = afterArrow.slice(braceAt + 1, end); + if (end > braceAt) { + body = afterArrow.slice(braceAt + 1, end); + bodyStart += braceAt + 1; + } } const callRe = /\b([A-Za-z_$][\w$]*)\s*\(/g; const seen = new Set(); @@ -190,6 +222,7 @@ export const expressResolver: FrameworkResolver = { language: lang, }); } + references.push(...replyRefs(safe, bodyStart, bodyStart + body.length, routeNode.id, filePath, lang)); } else { // Named handler: the LAST comma-separated arg (earlier ones are middleware). const parts = args.split(',').map((s) => s.trim()).filter(Boolean); @@ -248,6 +281,7 @@ export const expressResolver: FrameworkResolver = { seen.add(name); references.push({ fromNodeId: routeNode.id, referenceName: name, referenceKind: 'calls', line, column: 0, filePath, language: lang }); } + references.push(...replyRefs(safe, openParen + 1, closeParen, routeNode.id, filePath, lang)); } else { const parts = splitTopLevel(args).map((s) => s.trim()).filter(Boolean); const last = parts[parts.length - 1]; diff --git a/src/ui-server/api/effects.ts b/src/ui-server/api/effects.ts index ff09f2c..ac9aaa6 100644 --- a/src/ui-server/api/effects.ts +++ b/src/ui-server/api/effects.ts @@ -450,6 +450,28 @@ export function responseStatus(text: string, args: string | null | undefined, _k return null; } +/** + * The status a reply sends when it sets none — 200 — for the calls that send a + * body and default to it: Express / Koa / Fastify / Hono `res.json`, + * `res.send`, `res.render`, `reply.send`, `c.json`; `NextResponse.json`; + * Python's `JSONResponse`, `jsonify`, `render_template`, `HttpResponse`; + * Rails' `render`; Laravel's `response()->json`. Null when the chain sets a + * status of its own (`res.status(code).json` — a variable code is unknown, + * not 200), when the call ends a response without a body (`end`, + * `sendStatus`), or when the call is not one of these. + */ +export function implicitResponseStatus(text: string): number | null { + const call = normaliseCall(text); + if (/(?:^|\.)(?:status|sendStatus|code|Status|StatusCode|SendStatus|withStatus|with_status|writeHead)\(/.test(call)) return null; + const bare = call.replace(/\([^()]*\)/g, ''); + if (/^(?:res|response|reply|rep|ctx|c|context)(?:\.(?:type|set|header|headers|append|cookie|clearCookie|vary|location|links|format))*\.(?:json|jsonp|send|render|sendFile|download|text|html|body|stream|file|view)$/.test(bare)) return 200; + if (/^(?:NextResponse|Response)\.json$/.test(bare)) return 200; + if (/^(?:JSONResponse|HTMLResponse|PlainTextResponse|ORJSONResponse|UJSONResponse|jsonify|render_template|render|make_response|HttpResponse|JsonResponse|send_file|send_from_directory)$/.test(bare)) return 200; + if (/^(?:render|render_to_string|respond_with)$/.test(bare)) return 200; + if (/^response\(\)->(?:json|view)$|^response->json$|^view$/.test(call.replace(/\s+/g, ''))) return 200; + return null; +} + /** The abbreviated argument list split on its top-level commas. */ function splitArgs(args: string): string[] { const out: string[] = []; diff --git a/src/ui-server/api/steps.ts b/src/ui-server/api/steps.ts index 5f0e265..09e17b6 100644 --- a/src/ui-server/api/steps.ts +++ b/src/ui-server/api/steps.ts @@ -42,7 +42,7 @@ import type { Edge, Language, Node, UnresolvedReference } from '../../types'; import { badRequest, intParam, notFound } from './respond'; import { createSiteReader } from './when'; import type { SiteTrigger } from '../../graph/branch-guards'; -import { classifyEffect, responseStatus, type Effect } from './effects'; +import { classifyEffect, implicitResponseStatus, responseStatus, type Effect } from './effects'; import { looksLikeComponent, routeRoots } from './route-roots'; import { nextRouteForFile } from '../../resolution/frameworks/nextjs'; import { splitRouteName } from './routes'; @@ -639,7 +639,10 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS if (effect.category === 'response') { // `NextResponse.json(user, { status: 201 })`: the code sits in an object // the abbreviation reduced to its keys; the site reader kept it. - const status = responseStatus(text, args, ref.referenceKind) ?? (usable && typeof site.status === 'number' ? site.status : null); + // — and a body-sending reply that sets none is a 200, so a success row + // says so beside the 401s. + const status = + responseStatus(text, args, ref.referenceKind) ?? (usable && typeof site.status === 'number' ? site.status : null) ?? implicitResponseStatus(text); if (status !== null) wireSite.status = status; } link(step, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, trigger ?? (await triggerAt(fold.node, at)));