Jelajahi Sumber

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
Colby McHenry 6 hari lalu
induk
melakukan
02430ccc32

+ 2 - 0
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.

+ 23 - 1
__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<Parameters<typeof classifyEffect>[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();
+  });
+});

+ 29 - 5
__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],
+    ]);
   });
 });
 

+ 28 - 0
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<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
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<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 - 0
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[] = [];

+ 5 - 2
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)));