From 0419a516362ce87aede97fea548a23fcf5d39167 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 29 Sep 2026 16:24:49 +0000 Subject: [PATCH] fix(synthesis): link a Spring or Laravel test's request to the route it reaches (#2100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests reach a controller by URL, never by a call — MockMvc's `perform(post("/owners/new"))`, Laravel's `$this->postJson('api/me')` — so every controller those suites exercise read as untested: the viewer's "No test reaches this" and explore's `tests:` line both walk callers. petclinic's processCreationForm, hit three times by OwnerControllerTests, said no test reached it. A new synthesis pass (tier-synthesizer section 4, registered after the cross-tier pass, gated on java / kotlin / php) reads test files only: - Spring: MockMvc builders (with MockMvcRequestBuilders imported), `request(HttpMethod.X, …)`, the Kotlin `mockMvc.get(…)` DSL, WebTestClient `.get().uri(…)`, TestRestTemplate, RestAssured. - Laravel: `$this->get/post/…Json(…)`, `->json('POST', …)`, `->call(…)` on a chain rooted at `$this` that only calls methods (so `$this->app->get('config')` is not a request); Pest's helpers where the file imports Pest\Laravel; and a project's own verb-named helpers (koel's getAs → jsonAs → `$this->json($method, $uri)`), recognised by their body, never by name alone. Paths are read as built — Spring `{var}` templates, PHP / Kotlin interpolation and concatenation become holes — and matched with the cross-tier pass's own route matcher (a hole fills a parameter, never a literal). One `calls` edge per (test, route), `synthesizedBy: 'test-request'`. These edges are excluded from countIncomingEdges, so a well-tested endpoint never becomes a hub the Steps walk refuses to enter. Routes reached by a test: petclinic 16/17, realworld 18/19, BookStack 194/340, koel 105/134 (from 0 each). A random sample of edges checked against the test source was all correct. The pass costs 86 ms on BookStack and 68 ms on koel. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/test-request-edges.test.ts | 300 +++++++++++++++++++++++ src/db/queries.ts | 6 +- src/resolution/callback-synthesizer.ts | 8 +- src/resolution/tier-synthesizer.ts | 324 ++++++++++++++++++++++++- 5 files changed, 634 insertions(+), 5 deletions(-) create mode 100644 __tests__/test-request-edges.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a953fe77..3479d268 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes - Laravel routes are now named by the path a request takes: a leading `/` is added where the routes file leaves it out, `Route::prefix()` and `Route::group(['prefix' => …])` groups are applied, and routes in `routes/api.php` carry the `/api` prefix Laravel serves them under, read from your `RouteServiceProvider`, `bootstrap/app.php` or any file that mounts a routes file. A front-end `fetch('/api/…')` now connects to the Laravel route that serves it. Re-index Laravel projects after upgrading. +- Controllers that Spring or Laravel tests exercise by URL now count as tested. That covers MockMvc's `perform(post("/owners/new"))`, WebTestClient, TestRestTemplate, RestAssured, Laravel's `$this->postJson('api/me')`, Pest's `get('/about')`, and a project's own request helpers built on them. `codegraph_explore` now names the test suite that reaches such an endpoint, where before every one of them looked untested. Re-index Spring and Laravel projects after upgrading. ## [1.6.1] - 2026-09-29 diff --git a/__tests__/test-request-edges.test.ts b/__tests__/test-request-edges.test.ts new file mode 100644 index 00000000..7e62f2af --- /dev/null +++ b/__tests__/test-request-edges.test.ts @@ -0,0 +1,300 @@ +/** + * A Spring or Laravel test reaches a controller by URL, never by a call — + * MockMvc's `perform(post("/owners/new"))`, Laravel's `$this->postJson('api/me')` + * — so every controller those suites exercise used to read as untested: the + * viewer's "No test reaches this" and explore's `tests:` line both walk + * callers. The test-request pass links each test's request to the route it + * reaches, and the route's own edge reaches the handler. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import type { Node } from '../src/types'; +import { buildNode } from '../src/ui-server/api/node'; + +const roots: string[] = []; +afterEach(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise<{ cg: CodeGraph; root: string }> { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-requests-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return { cg: await CodeGraph.init(root, { index: true }), root }; +} + +/** Every test-request edge as `test → route`. */ +function requests(cg: CodeGraph): string[] { + const out: string[] = []; + for (const route of cg.getNodesByKind('route')) { + for (const { node, edge } of cg.getCallers(route.id)) { + if ((edge.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy === 'test-request') { + out.push(`${node.name} → ${route.name}`); + } + } + } + return out.sort(); +} + +const method = (cg: CodeGraph, name: string): Node => { + const n = cg.getNodesByKind('method').find((m) => m.name === name); + if (!n) throw new Error(`no method ${name}`); + return n; +}; + +const SPRING_POM = 'spring-boot-starter-web'; + +const SPRING_CONTROLLER = `package app.owner; +import org.springframework.web.bind.annotation.*; +@Controller +class OwnerController { + @GetMapping("/owners/new") + public String initCreationForm() { return "owners/form"; } + @PostMapping("/owners/new") + public String processCreationForm() { return "redirect:/owners"; } + @GetMapping("/owners/{ownerId}/edit") + public String initUpdateOwnerForm(@PathVariable int ownerId) { return "owners/form"; } + @GetMapping("/owners") + public String processFindForm() { return "owners/list"; } +} +`; + +describe('Spring tests reach their controllers', () => { + it('links MockMvc requests to their routes, URI templates and query strings included', async () => { + const { cg } = await indexed({ + 'pom.xml': SPRING_POM, + 'src/main/java/app/owner/OwnerController.java': SPRING_CONTROLLER, + 'src/test/java/app/owner/OwnerControllerTests.java': `package app.owner; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +class OwnerControllerTests { + private MockMvc mockMvc; + void testProcessCreationFormSuccess() throws Exception { + mockMvc.perform(post("/owners/new").param("firstName", "Joe")).andExpect(status().is3xxRedirection()); + } + void testInitUpdateOwnerForm() throws Exception { + mockMvc.perform(get("/owners/{ownerId}/edit", 1)).andExpect(status().isOk()); + } + void testFindByPage() throws Exception { + mockMvc.perform(get("/owners?page=1")).andExpect(status().isOk()); + } +} +`, + }); + try { + expect(requests(cg)).toEqual([ + 'testFindByPage → GET /owners', + 'testInitUpdateOwnerForm → GET /owners/{ownerId}/edit', + 'testProcessCreationFormSuccess → POST /owners/new', + ]); + } finally { + cg.close(); + } + }); + + it('turns the badge: the handler is reached by the test file, two hops up', async () => { + const { cg, root } = await indexed({ + 'pom.xml': SPRING_POM, + 'src/main/java/app/owner/OwnerController.java': SPRING_CONTROLLER, + 'src/test/java/app/owner/OwnerControllerTests.java': `package app.owner; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +class OwnerControllerTests { + void creates() throws Exception { mockMvc.perform(post("/owners/new")); } +} +`, + }); + try { + const view = (await buildNode(cg, root, method(cg, 'processCreationForm').id)) as { + tests: { reached: boolean; hops: number; files: string[] }; + }; + expect(view.tests.reached).toBe(true); + expect(view.tests.hops).toBe(2); + expect(view.tests.files).toEqual(['src/test/java/app/owner/OwnerControllerTests.java']); + // An untested handler still says so. + const untested = (await buildNode(cg, root, method(cg, 'processFindForm').id)) as { tests: { reached: boolean } }; + expect(untested.tests.reached).toBe(false); + } finally { + cg.close(); + } + }); + + it('reads WebTestClient, TestRestTemplate, RestAssured and the Kotlin MockMvc DSL', async () => { + const { cg } = await indexed({ + 'pom.xml': SPRING_POM, + 'src/main/java/app/owner/OwnerController.java': SPRING_CONTROLLER, + 'src/test/java/app/owner/ClientTests.java': `package app.owner; +class ClientTests { + void viaWebTestClient() { webTestClient.get().uri("/owners/{ownerId}/edit", 7).exchange(); } + void viaRestTemplate() { restTemplate.getForEntity("/owners", String.class); } + void viaExchange() { restTemplate.exchange("/owners/new", HttpMethod.POST, entity, String.class); } + void viaRestAssured() { given().contentType("json").when().get("/owners/new").then().statusCode(200); } +} +`, + 'src/test/kotlin/app/owner/OwnerDslTests.kt': `package app.owner +class OwnerDslTests { + fun viaKotlinDsl() { mockMvc.post("/owners/new") { param("name", "x") } } +} +`, + }); + try { + expect(requests(cg)).toEqual([ + 'viaExchange → POST /owners/new', + 'viaKotlinDsl → POST /owners/new', + 'viaRestAssured → GET /owners/new', + 'viaRestTemplate → GET /owners', + 'viaWebTestClient → GET /owners/{ownerId}/edit', + ]); + } finally { + cg.close(); + } + }); + + it('reads nothing without MockMvc builders, outside tests, or where a hole would stand for a literal', async () => { + const { cg } = await indexed({ + 'pom.xml': SPRING_POM, + 'src/main/java/app/owner/OwnerController.java': SPRING_CONTROLLER, + // A test helper that happens to be called get(): no MockMvcRequestBuilders import. + 'src/test/java/app/owner/HelperTests.java': `package app.owner; +class HelperTests { + String get(String key) { return key; } + void usesHelper() { get("/owners/new"); } +} +`, + // Production code is never a test source, whatever it calls. + 'src/main/java/app/owner/Client.java': `package app.owner; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +class Client { void send() { mockMvc.perform(post("/owners/new")); } } +`, + // `{ownerId}` is a hole: it fills a parameter, never the literal `new`. + 'src/test/java/app/owner/HoleTests.java': `package app.owner; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +class HoleTests { void showsOwner() { mockMvc.perform(get("/owners/{ownerId}", 1)); } } +`, + }); + try { + expect(requests(cg)).toEqual([]); + } finally { + cg.close(); + } + }); +}); + +const LARAVEL = { + artisan: '#!/usr/bin/env php\n { + it("links $this requests — Json, chained helpers, json() / call(), interpolation and concatenation", async () => { + const { cg } = await indexed({ + ...LARAVEL, + 'tests/Feature/SongTest.php': `postJson('api/me', ['email' => 'a@b.c']); } + public function testDelete() { $this->actingAs($user)->json('DELETE', 'api/songs/1'); } + public function testRate() { $this->withToken($t)->put("api/songs/{$song->id}/rating", []); } + public function testRateAgain() { $this->put("api/songs/$song->public_id/rating", []); } + public function testBook() { $this->get('/books/' . $book->slug)->assertOk(); } +} +`, + }); + try { + expect(requests(cg)).toEqual([ + 'testBook → GET /books/{slug}', + 'testDelete → DELETE /api/songs/{song}', + 'testLogin → POST /api/me', + 'testRate → PUT /api/songs/{song}/rating', + 'testRateAgain → PUT /api/songs/{song}/rating', + ]); + } finally { + cg.close(); + } + }); + + it("follows the project's own request helpers (koel's getAs → jsonAs → $this->json), and nothing else named for a verb", async () => { + const { cg } = await indexed({ + ...LARAVEL, + 'tests/Concerns/MakesHttpRequests.php': `withToken('t')->json($method, $uri, $data); + } + protected function postAs(string $url, array $data, $user = null) { return $this->jsonAs($user, 'post', $url, $data); } + protected function getFixture(string $name) { return file_get_contents(__DIR__ . '/' . $name); } +} +`, + 'tests/Feature/AuthTest.php': `postAs('api/me', []); } + public function testFixtureIsNotARequest() { $this->getFixture('settings'); } +} +`, + }); + try { + expect(requests(cg)).toEqual(['testLoginViaHelper → POST /api/me']); + } finally { + cg.close(); + } + }); + + it("links Pest's helpers from the test file, and ignores container lookups, route() names and app code", async () => { + const { cg } = await indexed({ + ...LARAVEL, + 'tests/Feature/AboutTest.php': `assertOk(); +}); +`, + 'tests/Feature/NotRequestsTest.php': `app->get('settings'); } + public function testNamedRoute() { $this->get(route('about')); } +} +`, + 'app/Services/Proxy.php': "get('/about'); } }\n", + }); + try { + expect(requests(cg)).toEqual(['AboutTest.php → GET /about']); + } finally { + cg.close(); + } + }); + + it("keeps test requests out of a route's fan-in, so a well-tested endpoint is never a hub", async () => { + const tests = Array.from({ length: 45 }, (_, i) => ` public function testAbout${i}() { $this->get('/about'); }`).join('\n'); + const { cg } = await indexed({ + ...LARAVEL, + 'tests/Feature/ManyTest.php': ` n.name === 'GET /about')!; + expect(requests(cg).filter((r) => r.endsWith('GET /about'))).toHaveLength(45); + expect(cg.getFanIn([route.id]).get(route.id) ?? 0).toBeLessThan(5); + } finally { + cg.close(); + } + }); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index 86215d06..443b8513 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -2197,7 +2197,11 @@ export class QueryBuilder { const placeholders = chunk.map(() => '?').join(','); const rows = this.db .prepare( - `SELECT target, COUNT(*) AS count FROM edges WHERE target IN (${placeholders}) GROUP BY target` + // A test's request onto a route (tier-synthesizer's `test-request`) + // is not a production caller: forty tests hitting one endpoint must + // not make it a hub the Steps walk refuses to enter. + `SELECT target, COUNT(*) AS count FROM edges WHERE target IN (${placeholders}) + AND (metadata IS NULL OR metadata NOT LIKE '%"synthesizedBy":"test-request"%') GROUP BY target` ) .all(...chunk) as Array<{ target: string; count: number }>; for (const row of rows) out.set(row.target, row.count); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 0dc21edf..63e31533 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -35,7 +35,7 @@ import { tanstackLinkEdges } from './tanstack-router-synthesizer'; import { vueRouterLinkEdges } from './vue-router-synthesizer'; import { svelteKitLinkEdges, svelteKitPageComponentEdges } from './sveltekit-synthesizer'; import { createYielder, type MaybeYield } from './cooperative-yield'; -import { crossTierEdges, hasCrossTierPattern } from './tier-synthesizer'; +import { crossTierEdges, hasCrossTierPattern, hasTestRequestPattern, testRequestEdges } from './tier-synthesizer'; import { enclosingFn, makeLineAt } from './synth-utils'; import { resolveImportPath } from './import-resolver'; import { crossesCodeBoundary } from './name-matcher'; @@ -3635,7 +3635,7 @@ export function hasSynthesisPattern(filePath: string, content: string): boolean /\b(?:struct|union|typedef|virtual|override)\b|#\s*(?:include|define|if)|=|->|\[/.test(content)) return true; if (/\b(?:class|interface|protocol|trait|impl|extends|implements|expect|actual)\b/.test(content)) return true; if (/\.go$/.test(filePath) && /\b(?:struct|interface)\b|\bfunc\s*\(/.test(content)) return true; - if (hasCrossTierPattern(content)) return true; + if (hasCrossTierPattern(content) || hasTestRequestPattern(filePath, content)) return true; if (/\b(?:render|build|setState|defineStore|createStore|createApi|Store|href|sendEvent|sendEventWithName)\b|<\/|\/>/.test(content)) return true; if (/\.(?:forEach|append|add|push|insert|fire|dispatchEvent|addListener|Use|GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Any|Handle)\s*\(/.test(content)) return true; if (/[\w$]\s*\[\s*[A-Za-z_$]/.test(content) || /\b(?:dispatch|commit)\s*\(/.test(content)) return true; @@ -3671,6 +3671,10 @@ export const SYNTH_PASSES: SynthPassDef[] = [ // Before the in-process emitter pass: the same (source, target) pair // keeps the more specific edge — the one that says which tier it crosses. { name: 'tierEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => crossTierEdges(c, y) }, + // A Spring / Laravel test's request (`mockMvc.perform(post("/x"))`, + // `$this->postJson('api/x')`) onto the route it reaches, so the handler + // counts as tested. + { name: 'testRequestEdges', gate: (has) => has('java', 'kotlin', 'php'), run: (_q, c, y) => testRequestEdges(c, y) }, { name: 'emitterEdges', gate: ALWAYS, run: (_q, c, y) => eventEmitterEdges(c, y) }, { name: 'renderEdges', gate: ALWAYS, run: (q, c, y) => reactRenderEdges(q, c, y) }, { name: 'jsxEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => reactJsxChildEdges(c, y) }, diff --git a/src/resolution/tier-synthesizer.ts b/src/resolution/tier-synthesizer.ts index be119f0a..9563cc2f 100644 --- a/src/resolution/tier-synthesizer.ts +++ b/src/resolution/tier-synthesizer.ts @@ -36,8 +36,10 @@ * `href` it was paired on, and `registeredAt` — the route registration, the * decorator, the `.on` — so a reader can check the pairing. Fan-out is capped * per event as the emitter pass caps it; an HTTP pairing needs no cap because - * it is exact. Test suites and generated files are never sources: a supertest - * call is the test's story, and forty of them would make the route a hub. + * it is exact. Test suites and generated files are never sources of these + * passes: a supertest call is the test's story, and forty of them would make + * the route a hub. The one exception is section 4, a Spring / Laravel test's + * request onto its route, whose edges are kept out of fan-in for that reason. */ import type { Edge, Language, Node } from '../types'; @@ -897,6 +899,324 @@ function pairEvents(dispatches: readonly Dispatch[], handlers: readonly Handler[ // The pass // ============================================================================= +// ============================================================================= +// 4. Test request → route +// ============================================================================= + +/* + * Spring and Laravel tests reach a controller by URL, never by a call: + * MockMvc's `perform(post("/owners/new"))`, WebTestClient's `.get().uri(…)`, + * TestRestTemplate, RestAssured, and Laravel's `$this->postJson('api/me')`. + * Without an edge every controller those suites exercise reads as untested — + * the viewer's "No test reaches this" and explore's `tests:` line both walk + * callers. One edge per (test, route), from the test method (the file, for a + * Pest closure) to the route; the route's own edge reaches the handler. + * + * These are the one kind of edge whose source is a test. They don't count + * toward a route's fan-in (countIncomingEdges), so a well-tested endpoint + * never becomes a hub the Steps walk refuses to enter. + */ + +const TEST_REQUEST_FILE = /\.(?:java|kt|kts|php)$/; +const TEST_VERBS = 'get|post|put|patch|delete|head|options'; +/** A bare `get("/x")` is MockMvc's only where the file imports its builders. */ +const MOCKMVC_BUILDERS = /\bMockMvcRequestBuilders\b/; +const MOCKMVC_BUILDER = new RegExp(String.raw`(?\s*(${TEST_VERBS})(Json)?\s*\(`, 'g'); +const LARAVEL_JSON_CALL = /->\s*(?:json|call)\s*\(\s*(['"])([A-Za-z]+)\1\s*,/g; +/** A project's own request helper, named for its verb: koel's `getAs($url, $user)`, `postAsAdmin(…)`. */ +const LARAVEL_HELPER = new RegExp(String.raw`->\s*((${TEST_VERBS})[A-Z]\w*)\s*\(`, 'g'); +const LARAVEL_BUILTIN_JSON = new RegExp(String.raw`^(?:${TEST_VERBS})Json$`); +/** A Laravel request made in a method body: the call a request helper must end in. */ +const LARAVEL_REQUEST_IN_BODY = new RegExp(String.raw`->\s*(?:(?:${TEST_VERBS})(?:Json)?|json|call)\s*\(`); +/** Pest's `get('/x')` / `postJson(…)` helpers, where the file imports them. */ +const PEST_HELPERS = /Pest\\Laravel/; +const PEST_REQUEST = new RegExp(String.raw`(?:\\])(${TEST_VERBS})(Json)?\s*\(`, 'g'); +const TEST_REQUEST_GATE = /[mM]ockMvc|[wW]ebTestClient\b|[rR]estTemplate\b|\bRestAssured\b|\.\s*when\s*\(\s*\)|\$this\s*->|Pest\\Laravel/; + +export function hasTestRequestPattern(filePath: string, content: string): boolean { + return TEST_REQUEST_FILE.test(filePath) && isTestPath(filePath) && TEST_REQUEST_GATE.test(content); +} + +interface TestSite { + from: Node; + line: number; + column: number; + callee: string; + method: string; + segs: string[]; + display: string; +} + +/** + * The string a request's path argument builds, starting at `i`: literals + * verbatim, each computed piece (a concatenated operand, an interpolation, a + * Spring `{var}` template) as one HOLE. Null unless it starts with a literal — + * `route('songs.index')` or `$url` names no path this pass can read. + */ +function readRequestPath(s: string, i: number, lang: 'java' | 'kotlin' | 'php'): string | null { + const concat = lang === 'php' ? '.' : '+'; + let out = ''; + let literal = false; + for (;;) { + while (i < s.length && /\s/.test(s[i]!)) i++; + const ch = s[i]; + if (ch === '"' || ch === "'") { + const q = ch; + const interpolates = q === '"' && lang !== 'java'; + for (i++; i < s.length && s[i] !== q; i++) { + const c = s[i]!; + if (c === '\\') { out += s[++i] ?? ''; continue; } + if (interpolates && c === '$' && /[{\w]/.test(s[i + 1] ?? '')) { + // `${expr}`, `$name`, PHP's `$user->id` / `$row['k']` + if (s[i + 1] === '{') i = closeBrace(s, i + 1); + else { + while (/\w/.test(s[i + 1] ?? '')) i++; + if (lang === 'php') { + while (s.startsWith('->', i + 1) && /\w/.test(s[i + 3] ?? '')) for (i += 2; /\w/.test(s[i + 1] ?? ''); i++); + } + } + out += HOLE; + continue; + } + if (interpolates && lang === 'php' && c === '{' && s[i + 1] === '$') { + i = closeBrace(s, i); + out += HOLE; + continue; + } + out += c; + } + if (i >= s.length) return null; + i++; + literal = true; + } else { + if (!literal) return null; + const end = operandEnd(s, i, concat); + if (end === i) break; + out += HOLE; + i = end; + } + while (i < s.length && /\s/.test(s[i]!)) i++; + if (s[i] === concat && !(concat === '.' && /\d/.test(s[i + 1] ?? ''))) { i++; continue; } + break; + } + if (!literal) return null; + // Spring's URI templates: `get("/owners/{ownerId}", id)`. + return lang === 'php' ? out : out.replace(/\{[^{}/]*\}/g, HOLE); +} + +/** Index of the `}` closing the `{` at `open` (or the end of `s`). */ +function closeBrace(s: string, open: number): number { + let depth = 0; + for (let i = open; i < s.length; i++) { + if (s[i] === '{') depth++; + else if (s[i] === '}' && --depth === 0) return i; + } + return s.length - 1; +} + +/** End of a concatenated operand starting at `i`: the next top-level operator, `,` or `)`. */ +function operandEnd(s: string, i: number, concat: string): number { + let depth = 0; + for (; i < s.length; i++) { + const c = s[i]!; + if (c === '"' || c === "'") { + for (i++; i < s.length && s[i] !== c; i++) if (s[i] === '\\') i++; + continue; + } + if (c === '(' || c === '[') depth++; + else if (c === ')' || c === ']') { + if (depth === 0) return i; + depth--; + } else if (depth === 0 && (c === ',' || c === ';' || (c === concat && !(concat === '.' && /\d/.test(s[i + 1] ?? ''))))) return i; + // `$user->id`: an arrow is part of the operand, not a concatenation. + if (c === '-' && s[i + 1] === '>') i++; + } + return i; +} + +/** A test URL as route segments: app-relative, holes as `*`; null when nothing literal is left. */ +function testRequestPath(raw: string): { segs: string[]; display: string } | null { + let p = raw; + const cut = p.search(/[?#]/); + if (cut >= 0) p = p.slice(0, cut); + const absolute = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/]*(\/.*)?$/i.exec(p); + if (absolute) p = absolute[1] ?? '/'; + const segs = p + .split('/') + .filter((s) => s.length > 0) + .map((s) => (s.includes(HOLE) ? '*' : s)); + if (segs.some((s) => s !== '*') === false && segs.length > 0) return null; + return { segs, display: '/' + segs.map((s) => (s === '*' ? '${…}' : s)).join('/') }; +} + +/** + * Whether `name` is a request helper in the project's tests: a PHP method + * defined in a test file whose body makes a Laravel request, directly or + * through one more `$this->helper(…)` (koel's `getAs` → `jsonAs` → + * `$this->json($method, $uri)`). The name alone never decides it. + */ +function isRequestHelper(ctx: ResolutionContext, name: string, memo: Map, depth = 0): boolean { + const known = memo.get(name); + if (known !== undefined) return known; + memo.set(name, false); // cycle guard + let ok = false; + for (const n of ctx.getNodesByName(name)) { + if (n.kind !== 'method' || n.language !== 'php' || !isTestPath(n.filePath)) continue; + const lines = ctx.getFileLines?.(n.filePath) ?? ctx.readFile(n.filePath)?.split(/\r?\n/) ?? null; + if (!lines) continue; + const body = lines.slice(n.startLine - 1, n.endLine).join('\n'); + if (LARAVEL_REQUEST_IN_BODY.test(body)) ok = true; + else if (depth === 0) { + for (const call of body.matchAll(/\$this\s*->\s*(\w+)\s*\(/g)) { + if (call[1] !== name && isRequestHelper(ctx, call[1]!, memo, depth + 1)) { ok = true; break; } + } + } + if (ok) break; + } + // A depth-limited "no" is not final; only remember it at the top level. + if (ok || depth === 0) memo.set(name, ok); + else memo.delete(name); + return ok; +} + +/** + * Whether the `->` at `arrow` ends a chain rooted at `$this` that only CALLS + * methods — `$this->actingAs($u)->withHeaders([…])->` — so `$this->app->get( + * 'config')` (a container lookup through a property) is never a request. + */ +function thisRootedCalls(s: string, arrow: number): boolean { + let from = arrow; + for (let tries = 0; tries < 4; tries++) { + const root = s.lastIndexOf('$this', from - 1); + if (root < 0) return false; + let i = root + '$this'.length; + for (;;) { + while (/\s/.test(s[i] ?? '')) i++; + if (i === arrow) return true; + if (!s.startsWith('->', i)) break; + i += 2; + while (/\s/.test(s[i] ?? '')) i++; + const ident = /^\w+/.exec(s.slice(i, i + 64)); + if (!ident) break; + i += ident[0].length; + while (/\s/.test(s[i] ?? '')) i++; + if (s[i] !== '(') break; + const close = closeParen(s, i); + if (close < 0) break; + i = close + 1; + } + from = root; + } + return false; +} + +function collectTestRequests(ctx: ResolutionContext, file: string, sites: TestSite[], helpers: Map): void { + const content = ctx.readFile(file); + if (!content || !TEST_REQUEST_GATE.test(content)) return; + const lang: 'java' | 'kotlin' | 'php' = file.endsWith('.php') ? 'php' : /\.kts?$/.test(file) ? 'kotlin' : 'java'; + const safe = stripCommentsForRegex(content, lang === 'php' ? 'php' : 'java'); + const lineOf = makeLineAt(safe, 1); + let nodes: Node[] | null = null; + const add = (index: number, argAt: number, method: string, callee: string): void => { + const raw = readRequestPath(safe, argAt, lang); + if (raw === null) return; + const path = testRequestPath(raw); + if (!path) return; + const line = lineOf(index); + nodes ??= ctx.getNodesInFile(file); + const from = enclosingFn(nodes, line) ?? nodes.find((n) => n.kind === 'file'); + if (!from) return; + const column = index - (safe.lastIndexOf('\n', index - 1) + 1); + sites.push({ from, line, column, callee, method: method.toUpperCase(), segs: path.segs, display: path.display }); + }; + const each = (re: RegExp, fn: (m: RegExpExecArray) => void): void => { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(safe)) !== null) fn(m); + }; + const openOf = (m: RegExpExecArray) => m.index + m[0].length; + + if (lang === 'php') { + each(LARAVEL_REQUEST, (m) => { + if (thisRootedCalls(safe, m.index)) add(m.index, openOf(m), m[1]!, `$this->${m[1]}${m[2] ?? ''}`); + }); + each(LARAVEL_JSON_CALL, (m) => { + if (thisRootedCalls(safe, m.index)) add(m.index, openOf(m), m[2]!, '$this->json'); + }); + each(LARAVEL_HELPER, (m) => { + const name = m[1]!; + if (LARAVEL_BUILTIN_JSON.test(name)) return; // getJson / postJson, read above + if (thisRootedCalls(safe, m.index) && isRequestHelper(ctx, name, helpers)) add(m.index, openOf(m), m[2]!, `$this->${name}`); + }); + if (PEST_HELPERS.test(safe)) each(PEST_REQUEST, (m) => add(m.index, openOf(m), m[1]!, `${m[1]}${m[2] ?? ''}`)); + return; + } + if (MOCKMVC_BUILDERS.test(safe)) { + each(MOCKMVC_BUILDER, (m) => add(m.index, openOf(m), m[1] === 'multipart' ? 'post' : m[1]!, m[1]!)); + each(MOCKMVC_REQUEST, (m) => add(m.index, openOf(m), m[1]!, 'request')); + } + each(KOTLIN_MOCKMVC, (m) => add(m.index, openOf(m), m[1]!, `mockMvc.${m[1]}`)); + each(WEB_TEST_CLIENT, (m) => add(m.index, openOf(m), m[1]!, `${m[1]}().uri`)); + each(REST_ASSURED, (m) => add(m.index, openOf(m), m[1]!, m[1]!)); + each(REST_TEMPLATE, (m) => { + const name = m[1]!; + let method: string | null = name.startsWith('get') ? 'GET' : name.startsWith('post') ? 'POST' + : name === 'put' ? 'PUT' : name === 'delete' ? 'DELETE' : name.startsWith('patch') ? 'PATCH' : null; + if (name === 'exchange') { + const args = argumentsAt(safe, openOf(m) - 1); + method = /HttpMethod\s*\.\s*([A-Z]+)/.exec(args?.[1] ?? '')?.[1] ?? null; + } + if (method) add(m.index, openOf(m), method, `restTemplate.${name}`); + }); +} + +/** Edges from a test's request to the route it reaches. */ +export async function testRequestEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise { + const routes = httpRoutes(ctx); + if (routes.length === 0) return []; + const sites: TestSite[] = []; + const helpers = new Map(); + let scanned = 0; + for (const file of ctx.getAllFiles()) { + if (!TEST_REQUEST_FILE.test(file) || !isTestPath(file)) continue; + if ((++scanned & 63) === 0) await onYield(); + collectTestRequests(ctx, file, sites, helpers); + } + const edges: Edge[] = []; + const seen = new Set(); + for (const site of sites) { + const route = matchHttp({ fn: site.from, file: site.from.filePath, line: site.line, column: site.column, callee: site.callee, + method: site.method, segs: site.segs, suffix: false, display: site.display }, routes); + if (!route) continue; + const key = `${site.from.id}>${route.node.id}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: site.from.id, + target: route.node.id, + kind: 'calls', + line: site.line, + column: site.column, + provenance: 'heuristic', + metadata: { + synthesizedBy: 'test-request', + channel: 'http', + callee: site.callee, + method: site.method, + href: site.display, + registeredAt: `${route.node.filePath}:${route.node.startLine}`, + }, + }); + } + return edges; +} + const HTTP_GATE = /\b(?:fetch|\$fetch|ofetch|axios|ky|got|useFetch|useSWR)\b|\.\s*(?:get|post|put|patch|delete|head|options|request|\$get|\$post)\s*[<(]/; const QUEUE_GATE = /\.\s*add\s*\(|@Processor\s*\(|\bnew\s+Worker\s*[<(]|\.\s*process\s*\(/; const EVENT_GATE = /\.\s*(?:emit|emitAsync|on|once)\s*\(|@OnEvent\s*\(|@SubscribeMessage\s*\(/;