feat(steps): a reply that sets no status is a 200; inline Express handlers keep their replies

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
This commit is contained in:
Colby McHenry
2026-08-29 12:00:04 -05:00
co-authored by Claude Fable 5
parent 77dc0ad2eb
commit 02430ccc32
7 changed files with 144 additions and 9 deletions
+28
View File
@@ -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<string> = 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
+35 -1
View File
@@ -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<string>();
@@ -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];
+22
View File
@@ -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[] = [];
+5 -2
View File
@@ -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)));