fix(synthesis): link a Spring or Laravel test's request to the route it reaches (#2100)

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 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-29 16:24:49 +00:00
committed by GitHub
parent ffced05e3c
commit 0419a51636
5 changed files with 634 additions and 5 deletions
+1
View File
@@ -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
+300
View File
@@ -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<string, string>): 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 = '<project><dependencies><dependency><artifactId>spring-boot-starter-web</artifactId></dependency></dependencies></project>';
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<?php\n',
'composer.json': '{"require":{"laravel/framework":"^11.0"}}',
'routes/api.php': `<?php
Route::post('me', [AuthController::class, 'login']);
Route::delete('songs/{song}', [SongController::class, 'destroy']);
Route::put('songs/{song}/rating', [SongController::class, 'rate']);
Route::get('settings', [SettingController::class, 'show']);
`,
'routes/web.php': `<?php
Route::get('/books/{slug}', [BookController::class, 'show']);
Route::get('/about', [PageController::class, 'about']);
`,
'app/Http/Controllers/AuthController.php': '<?php\nclass AuthController { public function login() {} }\n',
'app/Http/Controllers/SongController.php': '<?php\nclass SongController { public function destroy() {} public function rate() {} }\n',
'app/Http/Controllers/BookController.php': '<?php\nclass BookController { public function show() {} }\n',
'app/Http/Controllers/PageController.php': '<?php\nclass PageController { public function about() {} }\n',
'app/Http/Controllers/SettingController.php': '<?php\nclass SettingController { public function show() {} }\n',
};
describe('Laravel tests reach their controllers', () => {
it("links $this requests — Json, chained helpers, json() / call(), interpolation and concatenation", async () => {
const { cg } = await indexed({
...LARAVEL,
'tests/Feature/SongTest.php': `<?php
class SongTest extends TestCase {
public function testLogin() { $this->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': `<?php
trait MakesHttpRequests {
private function jsonAs($user, string $method, $uri, array $data = []) {
return $this->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': `<?php
class AuthTest extends TestCase {
use MakesHttpRequests;
public function testLoginViaHelper() { $this->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': `<?php
use function Pest\\Laravel\\{get};
it('shows the about page', function () {
get('/about')->assertOk();
});
`,
'tests/Feature/NotRequestsTest.php': `<?php
class NotRequestsTest extends TestCase {
public function testContainer() { $this->app->get('settings'); }
public function testNamedRoute() { $this->get(route('about')); }
}
`,
'app/Services/Proxy.php': "<?php\nclass Proxy { public function run() { $this->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': `<?php\nclass ManyTest extends TestCase {\n${tests}\n}\n`,
});
try {
const route = cg.getNodesByKind('route').find((n) => 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();
}
});
});
+5 -1
View File
@@ -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);
+6 -2
View File
@@ -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) },
+322 -2
View File
@@ -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`(?<![\w$.])(?:MockMvcRequestBuilders\s*\.\s*)?(${TEST_VERBS}|multipart)\s*\(`, 'g');
const MOCKMVC_REQUEST = /(?<![\w$.])(?:MockMvcRequestBuilders\s*\.\s*)?request\s*\(\s*HttpMethod\s*\.\s*([A-Z]+)\s*,/g;
const KOTLIN_MOCKMVC = new RegExp(String.raw`\b\w*[mM]ockMvc\s*\.\s*(${TEST_VERBS})\s*\(`, 'g');
const WEB_TEST_CLIENT = new RegExp(String.raw`\.\s*(${TEST_VERBS})\s*\(\s*\)\s*\.\s*uri\s*\(`, 'g');
const REST_TEMPLATE = /\b\w*[Rr]estTemplate\s*\.\s*(getForEntity|getForObject|postForEntity|postForObject|postForLocation|put|delete|patchForObject|exchange)\s*\(/g;
const REST_ASSURED = new RegExp(String.raw`(?:\.\s*when\s*\(\s*\)\s*|\bRestAssured\s*)\.\s*(${TEST_VERBS})\s*\(`, 'g');
const LARAVEL_REQUEST = 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`(?<![\w$>:\\])(${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<string, boolean>, 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<string, boolean>): 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<Edge[]> {
const routes = httpRoutes(ctx);
if (routes.length === 0) return [];
const sites: TestSite[] = [];
const helpers = new Map<string, boolean>();
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<string>();
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*\(/;