diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ab74ef..43128e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **The Steps tab follows a web app across its tiers.** A page's `fetch('/api/users', { method: 'POST' })` (or an `axios` / `ky` / `got` / `$fetch` call, including one through a project instance made with `axios.create({ baseURL })`) now reaches the route that serves it in the same index — drawn as a crossing to the server (`⇢ POST /api/users`) with the handler named on the box and the registration site in the panel, a boundary by default and entered with *Continue through*, so the picture reads page → handler → the endpoint → its database write → its response. A job put on a BullMQ / Bull queue lands on the `@Process` method, `Worker` or `queue.process` handler that consumes it; a NestJS `EventEmitter2` event lands on its `@OnEvent` listeners (globs included); a socket message crosses from a client's `socket.emit` to the gateway's `@SubscribeMessage` and back from the server's `emit` to the component that registered `socket.on`; and a Next.js server action called from a client file is a crossing to the server by its `'use server'` directive. Each of these is a synthesized hop — dashed, with where it was wired up — and `codegraph_explore`'s Flow section names them too. Only a literal path or event name pairs: a variable url, a path no route serves, or one two routes serve alike produce nothing. Re-index to pick the new edges up. -- **The Steps tab now draws an API as well as an app.** Anchor on an endpoint — `POST /users` in Express, NestJS, Fastify, FastAPI, Flask, Django, Spring (Java or Kotlin), ASP.NET, Vapor or Gin — and the viewer starts at the handler the route runs (or at the route itself when the handler is an inline arrow), says what fires it (`FIRES FROM POST /users · after authenticate, validate(…)` — the middleware arguments at the registration, or the guard decorators on the method and its class, or a FastAPI `dependencies=[…]`), and draws what the request sets in motion: the database calls with the model and whether they read or write (`prisma.user.create({ data })` · `database · user · write`), jobs put on a queue, emails, payments, cache reads, token checks, calls to other services, files and processes — and the **responses**, one box per handler whose label is the status codes it can send (`201 · 404`) and whose panel rows are the endpoint's contract as the code has it: `WHEN NOT user → 404 · NotFoundException('no such user')`, `always → 201 · res.status(201).json(user)`. A queue consumer or a scheduled job anchored by name says the decorator that fires it (`@Process('email')`). The legend, the panel and the chooser use the project's own words — endpoint, data call, another tier — and the bare Steps tab lists an API's endpoints by router file when there are no screens. Re-index is not needed: everything new is read from the source at request time. +- **The Steps tab now draws an API as well as an app.** Anchor on an endpoint — `POST /users` in Express, NestJS, Fastify, FastAPI, Flask, Django, Spring (Java or Kotlin), ASP.NET, Vapor or Gin — and the viewer starts at the handler the route runs (or at the route itself when the handler is an inline arrow), says what fires it (`FIRES FROM POST /users · after authenticate, validate(…)` — the middleware arguments at the registration, or the guard decorators on the method and its class, or a FastAPI `dependencies=[…]`), and draws what the request sets in motion: the database calls with the model and whether they read or write (`prisma.user.create({ data })` · `database · user · write`), jobs put on a queue, emails, payments, cache reads, token checks, calls to other services, files and processes — and the **responses**, one box per status a handler can send (`201`, `404`) whose panel rows are the endpoint's contract as the code has it: `WHEN NOT user → 404 · NotFoundException('no such user')`, `always → 201 · res.status(201).json(user)`. A queue consumer or a scheduled job anchored by name says the decorator that fires it (`@Process('email')`). The legend, the panel and the chooser use the project's own words — endpoint, data call, another tier — and the bare Steps tab lists an API's endpoints by router file when there are no screens. Re-index is not needed: everything new is read from the source at request time. - **Calls are read as written, so the database is the database.** The index keeps only the last segment of a deep member call (`create` for `prisma.user.create`), and a bare name matches by name alone — often to the wrong `create`. The Steps walk now reads each call from the source as written, classifies `prisma.user.create`, `this.usersRepository.save`, `session.commit`, `owners.save` and `_context.TodoItems.Add` by the whole chain and by the receiver's declared type (`OwnerRepository owners`, `private readonly usersService: UsersService`, `val owners: OwnerRepository`, read from the class body), and follows `this.usersService.findByEmail(…)` into the class the type names instead of the name-only guess. A hop resolved this way says so in the panel. @@ -46,7 +46,9 @@ 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. +- **Each way an endpoint answers is its own box.** A handler that replies `200` or `401` now draws two reply boxes instead of one `200 · 401`, so each line from the handler carries its own condition on the picture — `→ user && (await user.matchPassword(password))` into the 200, `→ NOT (…)` into the 401 — the way a screen's transitions do; replies whose status the code does not spell out share one box labelled by the call. + +- **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 success reply has a box of its own beside the `401`'s and its 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. diff --git a/__tests__/ui-steps-api-servers.test.ts b/__tests__/ui-steps-api-servers.test.ts index 6e95b5e..38defa5 100644 --- a/__tests__/ui-steps-api-servers.test.ts +++ b/__tests__/ui-steps-api-servers.test.ts @@ -423,12 +423,12 @@ describe('ASP.NET endpoint groups', () => { const anchor = p.steps.find((s) => s.anchor)!; expect(anchor.sub).toBe('UpdateTodoItem'); expect(anchor.trigger).toMatchObject({ kind: 'request', name: 'PUT', of: '/api/TodoItems/{id}' }); - const res = effect(p, 'response')!; - expect(res.label).toBe('204 · 400'); - const rows = p.links.find((l) => l.to === res.id)!.sites.map((s) => [s.status, s.when]); - expect(rows).toEqual([ - [400, 'id != command.Id'], - [204, 'id == command.Id'], + // One box per outcome, each line carrying its own condition. + const replies = p.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'response'); + const outcomes = replies.map((s) => [s.label, p.links.find((l) => l.to === s.id)!.when]).sort(); + expect(outcomes).toEqual([ + ['204', 'id == command.Id'], + ['400', 'id != command.Id'], ]); }); }); @@ -444,12 +444,11 @@ describe('Spring', () => { expect(db.effect).toMatchObject({ model: 'Owner', access: 'write' }); const dbLink = p.links.find((l) => l.to === db.id)!; expect(dbLink.when).toBe('owner.getName() != null'); - const res = effect(p, 'response')!; - expect(res.label).toBe('201 · 400'); - const rows = p.links.find((l) => l.to === res.id)!.sites.map((s) => [s.status, s.when]); - expect(rows).toEqual([ - [400, 'owner.getName() == null'], - [201, 'owner.getName() != null'], + const replies = p.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'response'); + const outcomes = replies.map((s) => [s.label, s.effect!.statuses, p.links.find((l) => l.to === s.id)!.when]).sort(); + expect(outcomes).toEqual([ + ['201', [201], 'owner.getName() != null'], + ['400', [400], 'owner.getName() == null'], ]); }); }); diff --git a/docs/design/codegraph-ui-design-spec.md b/docs/design/codegraph-ui-design-spec.md index 2e3059b..fc37795 100644 --- a/docs/design/codegraph-ui-design-spec.md +++ b/docs/design/codegraph-ui-design-spec.md @@ -513,10 +513,13 @@ leaves the index through it (`OwnerRepository owners` in a Spring controller, `R from the class body, `graph/branch-guards.ts`'s `memberTypesInTree`, the index keeps none of it); and, in a project with endpoints, on a thrown web exception (`throw new NotFoundException(…)`, `raise HTTPException(…)`). The same declared type sends `this.usersService.findByEmail(…)` into the class the type names instead of the name-only guess the graph holds — the -panel says `by the receiver's declared type` on that hop. A **response** box is the endpoint's contract as the code has it: -its label is the status codes its sites send when they are literal (`201 · 404`, read out of `status(404)`, -`HttpStatus.CREATED`, `http.StatusNotFound`, `status_code=422`, `NotFoundException`, `TypedResults.NoContent`, `.notFound`), -and the panel prints one row per site — `WHEN NOT user → 404 · NotFoundException('no such user')`. The payload says what the +panel says `by the receiver's declared type` on that hop. A **response** box is one outcome of the endpoint's contract as the +code has it — one box per (function, status), so a handler answering 200 or 404 is two boxes and each line into them carries +its own condition on the picture, the Screens view's idiom; its label is the status when it is literal (read out of +`status(404)`, `HttpStatus.CREATED`, `http.StatusNotFound`, `status_code=422`, `NotFoundException`, `TypedResults.NoContent`, +`.notFound`, `{ status: 201 }`, a `res.status(202)` the statement before, or the 200 a body-sending reply implies), and the +panel prints one row per site — `WHEN NOT user → 404 · NotFoundException('no such user')`; the sites whose status the code does +not spell out share one box labelled by their call. The payload says what the index is a picture of (`project: 'app' | 'api' | 'web'`, from the routes: endpoints make an API, endpoints beside pages or navigation a web app) and the viewer's words follow it in one place (`kindWord` / `kindWords` in `steps-model.ts`): endpoint / page / screen, data call / store action, a call to another tier / to the server / a native call; a route that diff --git a/docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md b/docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md index ce53ddf..dd91dfb 100644 --- a/docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md +++ b/docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md @@ -211,7 +211,7 @@ row, and that the walk reaches the service and the repository call. *Built* — `src/ui-server/api/effects.ts` (`classifyEffect`, `responseStatus`; rules per language family, `process` and Android rows added beyond the plan; `effect.model` / `access`, `site.status`, a response box -labelled by its codes); tests `__tests__/ui-effects.test.ts`. Matching is on the call as written and on the +per status since 2026-08-29 — the outcome is the box's identity, the line's pill its condition); tests `__tests__/ui-effects.test.ts`. Matching is on the call as written and on the receiver's declared type when the call leaves the index through it — see the status note at the top. *Where:* `EFFECTS` in `steps.ts` (make it a module of its own, `api/effects.ts`, with a table per diff --git a/src/ui-server/api/steps.ts b/src/ui-server/api/steps.ts index 09e17b6..38b2b08 100644 --- a/src/ui-server/api/steps.ts +++ b/src/ui-server/api/steps.ts @@ -547,12 +547,16 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS // One box per (function, category): `uploadARCapture` makes one network // call, three storage calls and three telemetry calls — three boxes, each - // listing its calls, not seven. + // listing its calls, not seven. A reply is the exception: its identity is + // the outcome, so `authUser` answering 200 or 401 is two boxes — each + // line into them then carries its own condition on the picture, the + // Screens view's idiom — and the sites whose status cannot be read share + // one `response` box labelled by the call. const effectSub = (e: NonNullable, by: Node): string => [e.category, e.model, e.access, by.name].filter((x): x is string => !!x).join(' · '); - const effectStep = (by: Node, ref: { referenceName: string; line: number }, effect: Effect, depth: number): StepRecord | null => { + const effectStep = (by: Node, ref: { referenceName: string; line: number }, effect: Effect, depth: number, status: number | null = null): StepRecord | null => { const category = effect.category; - const id = `effect:${by.id}:${category}`; + const id = status !== null ? `effect:${by.id}:${category}:${status}` : `effect:${by.id}:${category}`; const existing = steps.get(id); if (existing) { const e = existing.effect!; @@ -631,20 +635,21 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS args, }); if (effect === null) return false; - const target = effectStep(fold.node, { referenceName: text, line: ref.line }, effect, step.depth + 1); + // A reply's status, read before its box exists — the box is per outcome. + // `NextResponse.json(user, { status: 201 })`: the code sits in an object + // the abbreviation reduced to its keys; the site reader kept it. And a + // body-sending reply that sets none is a 200, so a success has a box of + // its own beside the 401's. + const status = + effect.category === 'response' + ? (responseStatus(text, args, ref.referenceKind) ?? (usable && typeof site.status === 'number' ? site.status : null) ?? implicitResponseStatus(text)) + : null; + const target = effectStep(fold.node, { referenceName: text, line: ref.line }, effect, step.depth + 1, status); if (target === null) return true; const when = await whenAt(fold.node, at); const wireSite: WireStepSite = { file: posix(fold.node.filePath), line: ref.line, text, when: '' }; if (args !== null) wireSite.args = args; - 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. - // — 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; - } + if (status !== null) wireSite.status = status; link(step, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, trigger ?? (await triggerAt(fold.node, at))); return true; }; @@ -1099,8 +1104,10 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS for (const step of steps.values()) { if (step.kind !== 'effect' || !step.effect) continue; const sites = sitesByStep.get(step.id) ?? []; - // A response box is the endpoint's contract: the status codes it can - // send, when they are literal, are its label; the rows say when. + // A response box is one outcome of the endpoint's contract: its status, + // when literal, is its label (one per box by construction); the rows say + // when. The box of unreadable statuses holds none and is labelled by its + // call below. if (step.effect.category === 'response') { const statuses = [...new Set(sites.map((s) => s.status).filter((x): x is number => typeof x === 'number'))].sort((a, b) => a - b); if (statuses.length > 0) { diff --git a/ui/src/views/StepsView.svelte b/ui/src/views/StepsView.svelte index 3dd752b..613b023 100644 --- a/ui/src/views/StepsView.svelte +++ b/ui/src/views/StepsView.svelte @@ -635,7 +635,7 @@

{selectedInfo.step.effect.apis.join(' · ')}

{/if} {#if selectedInfo.step.effect?.category === 'response'} -

The endpoint’s contract as the code has it: each row below is one way it answers, with the condition it answers under.

+

{selectedInfo.step.effect.statuses?.length ? 'One way the endpoint answers — each row below is a site that sends it, with the condition it answers under; its other outcomes are the boxes beside it.' : 'Replies whose status the code does not spell out — each row below is one, with the condition it answers under.'}

{/if} {#if selectedInfo.step.events && selectedInfo.step.events.length > 1}

⇠ {selectedInfo.step.events.join(' · ')}