mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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*\(/;
|
||||
|
||||
Reference in New Issue
Block a user