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
+23 -1
View File
@@ -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
View File
@@ -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 replys, 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],
]);
});
});