mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* feat(routes): extract Spring method-level array-form routes in ingestion + extractor parity test (#2138 follow-up) ingestion's `extractSpringRoutes` (route-extractors/spring.ts) matched only a single string literal on `@(Get|...)Mapping`, so the array form `@GetMapping({"/a","/b"})` produced no graph Route node — while the group-layer `java.ts` scan did match it. That divergence was the root of the #2265 array-form parse-skip gap. - spring.ts: add the array-form alternation `[(string_literal) @value (element_value_array_initializer (string_literal) @value)]` to the two method-declaration query branches (positional + `path=`/`value=`), mirroring the group query. A multi-element array yields one match per element, so the Phase 2 loop emits one route per path with no other change. Class-level `@RequestMapping` array prefixes remain single-literal (rare; left to a follow-up). - test: spring-route-parity runs one shared Java fixture through BOTH extractors (ingestion `extractSpringRoutes` + group `JAVA_HTTP_PLUGIN.scan`) and asserts identical provider {method,path} sets — the parity guard the maintainer asked for in #2078, so the two Spring extractors can't silently drift again (verified: reverting the array branch turns the parity test red). * fix(ingestion/routes): suppress wrong unprefixed route under class-array @RequestMapping; cover named-array + class-array parity Addresses PR review on #2281: - P2 class-array wrong-route: class branches now match the array form only to detect it; a method-level array route under a class-level array-form @RequestMapping is suppressed rather than emitted with a dropped prefix, so ingestion stays a strict subset of the group scan. Scalar method paths under an array class prefix are unchanged (pre-existing). Full class-array cross-product support tracked in a follow-up. - P2 named-array coverage: added value={...}/path={...} parity cases, a consumes/produces array false-positive case, and a dedicated empty-provider-set assertion. - P3 stale comments: updated the routeCoverage comment in java.ts and the route-parse-skip test note; narrowed the parity test drift claim. routeCoverage stays 'partial'. --------- Co-authored-by: henry <zhangwei2017@unipus.cn> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
255 lines
10 KiB
TypeScript
255 lines
10 KiB
TypeScript
/**
|
|
* #2138 Part 2 · parse-skip proof + P1 regression guards.
|
|
*
|
|
* The win: for a file whose provider routes are fully covered by the graph in a
|
|
* `routeCoverage: 'complete'` language, `HttpRouteExtractor` skips the source
|
|
* scan AND the tree-sitter parse — the graph is authoritative. We spy the real
|
|
* `parseSourceSafe` to COUNT parses (deterministic, not wall-time).
|
|
*
|
|
* PHP/Laravel is the language used for the *win* scenarios: ingestion's Laravel
|
|
* route extraction is a superset of the group PHP scan, so PHP is `'complete'`.
|
|
*
|
|
* Java is deliberately `'partial'` (the graph provider set is a strict subset of
|
|
* the group Java scan — array-form, interface-inherited, and same-URL multi-verb
|
|
* routes have no graph Route node). The Java cases below are REGRESSION GUARDS:
|
|
* they prove those group-only routes survive because Java is never parse-skipped.
|
|
* If someone flips Java to `'complete'` without making ingestion provider-
|
|
* complete, these tests fail — exactly the #2138 P1 data-loss class.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
// Count real parses by wrapping the actual parseSourceSafe.
|
|
const parseCalls: string[] = [];
|
|
vi.mock('../../src/core/tree-sitter/safe-parse.js', async (importActual) => {
|
|
const actual = await importActual<typeof import('../../src/core/tree-sitter/safe-parse.js')>();
|
|
return {
|
|
...actual,
|
|
parseSourceSafe: (parser: unknown, src: unknown) => {
|
|
parseCalls.push(typeof src === 'string' ? src : '<non-string>');
|
|
return (actual.parseSourceSafe as (p: unknown, s: unknown) => unknown)(parser, src);
|
|
},
|
|
};
|
|
});
|
|
|
|
import { HttpRouteExtractor } from '../../src/core/group/extractors/http-route-extractor.js';
|
|
|
|
const repo = { name: 'r', url: 'r' } as never;
|
|
|
|
beforeEach(() => {
|
|
parseCalls.length = 0;
|
|
});
|
|
|
|
function mkRepo(files: Record<string, string>): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-parse-skip-'));
|
|
for (const [name, content] of Object.entries(files)) {
|
|
fs.writeFileSync(path.join(dir, name), content);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
/** HANDLES_ROUTE rows from a compact spec; CONTAINS/FETCHES return empty. */
|
|
function makeDb(
|
|
rows: Array<{ file: string; routePath: string; method: string; resolved: boolean }>,
|
|
) {
|
|
return vi.fn(async (query: string) => {
|
|
if (query.includes('HANDLES_ROUTE')) {
|
|
return rows.map((r, i) => ({
|
|
fileId: `File:${r.file}`,
|
|
filePath: r.file,
|
|
routePath: r.routePath,
|
|
routeMethod: r.method,
|
|
handlerSymbolId: r.resolved ? `Method:${r.file}:h${i}` : '',
|
|
routeSource: 'framework-route',
|
|
}));
|
|
}
|
|
return []; // CONTAINS (basename fallback is fine) + FETCHES (no consumers)
|
|
});
|
|
}
|
|
|
|
const providerPaths = (out: Awaited<ReturnType<HttpRouteExtractor['extract']>>) =>
|
|
out.filter((c) => c.role === 'provider').map((c) => `${c.meta.method}::${c.meta.path}`);
|
|
|
|
// ── PHP / Laravel — the `'complete'` language where the skip engages ──────────
|
|
|
|
const ROUTES_A = `<?php
|
|
Route::get('/api/a/list', 'AController@list');
|
|
`;
|
|
const ROUTES_B = `<?php
|
|
Route::post('/api/b/make', 'BController@make');
|
|
`;
|
|
|
|
describe('HttpRouteExtractor — PHP parse-skip for graph-covered files (#2138)', () => {
|
|
it('baseline: with no graph, every PHP file is parsed', async () => {
|
|
const dir = mkRepo({ 'routes_a.php': ROUTES_A, 'routes_b.php': ROUTES_B });
|
|
try {
|
|
const out = await new HttpRouteExtractor().extract(null, dir, repo);
|
|
expect(providerPaths(out)).toEqual(
|
|
expect.arrayContaining(['GET::/api/a/list', 'POST::/api/b/make']),
|
|
);
|
|
expect(parseCalls.length).toBeGreaterThanOrEqual(2);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('fully covered: zero PHP files parsed (the win)', async () => {
|
|
const dir = mkRepo({ 'routes_a.php': ROUTES_A, 'routes_b.php': ROUTES_B });
|
|
try {
|
|
const out = await new HttpRouteExtractor().extract(
|
|
makeDb([
|
|
{ file: 'routes_a.php', routePath: '/api/a/list', method: 'GET', resolved: true },
|
|
{ file: 'routes_b.php', routePath: '/api/b/make', method: 'POST', resolved: true },
|
|
]),
|
|
dir,
|
|
repo,
|
|
);
|
|
const providers = out.filter((c) => c.role === 'provider');
|
|
expect(providers.map((c) => c.meta.path)).toEqual(
|
|
expect.arrayContaining(['/api/a/list', '/api/b/make']),
|
|
);
|
|
expect(providers.every((c) => c.meta.extractionStrategy === 'graph_assisted')).toBe(true);
|
|
expect(parseCalls.length).toBe(0);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('mixed: an unresolved route falls back to a scan; the resolved file stays skipped', async () => {
|
|
const dir = mkRepo({ 'routes_a.php': ROUTES_A, 'routes_b.php': ROUTES_B });
|
|
try {
|
|
await new HttpRouteExtractor().extract(
|
|
makeDb([
|
|
{ file: 'routes_a.php', routePath: '/api/a/list', method: 'GET', resolved: true },
|
|
{ file: 'routes_b.php', routePath: '/api/b/make', method: 'POST', resolved: false },
|
|
]),
|
|
dir,
|
|
repo,
|
|
);
|
|
expect(parseCalls.some((s) => s.includes('/api/b/make'))).toBe(true); // B scanned
|
|
expect(parseCalls.some((s) => s.includes('/api/a/list'))).toBe(false); // A skipped
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('provider-covered file that ALSO calls out is still parsed (consumer not dropped)', async () => {
|
|
// routes_c.php is a Laravel provider AND a Laravel Http:: consumer.
|
|
const ROUTES_C = `<?php
|
|
Route::get('/api/c/list', 'CController@list');
|
|
Http::get('/api/inventory');
|
|
`;
|
|
const dir = mkRepo({ 'routes_c.php': ROUTES_C });
|
|
try {
|
|
const out = await new HttpRouteExtractor().extract(
|
|
makeDb([{ file: 'routes_c.php', routePath: '/api/c/list', method: 'GET', resolved: true }]),
|
|
dir,
|
|
repo,
|
|
);
|
|
expect(out.some((c) => c.role === 'provider' && c.meta.path === '/api/c/list')).toBe(true);
|
|
// The Http:: consumer lives only in source — it MUST survive because the
|
|
// consumer signal kept the file in the scan set (so it was parsed).
|
|
expect(parseCalls.some((s) => s.includes('/api/inventory'))).toBe(true);
|
|
expect(out.some((c) => c.role === 'consumer' && c.meta.path === '/api/inventory')).toBe(true);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Java — `'partial'`, so the P1 group-only shapes must never be dropped ─────
|
|
|
|
describe('HttpRouteExtractor — Java parse-skip P1 regression guards (#2138)', () => {
|
|
it('array-form @GetMapping({"/a","/b"}) survives a co-located resolved route', async () => {
|
|
const AC = `package com.example;
|
|
import org.springframework.web.bind.annotation.*;
|
|
@RestController
|
|
public class AController {
|
|
@GetMapping("/covered") public Object covered() { return null; }
|
|
@GetMapping({"/a","/b"}) public Object multi() { return null; }
|
|
}
|
|
`;
|
|
const dir = mkRepo({ 'AController.java': AC });
|
|
try {
|
|
// The mock DB resolves only /covered; it deliberately omits the array-form
|
|
// routes to exercise the source-scan fallback. (Ingestion now DOES emit
|
|
// array-form Route nodes under a scalar/absent class prefix — see #2280 —
|
|
// but this test asserts the group extractor still recovers them via scan
|
|
// when the graph happens to lack them, which is what 'partial' guarantees.)
|
|
const out = await new HttpRouteExtractor().extract(
|
|
makeDb([
|
|
{ file: 'AController.java', routePath: '/covered', method: 'GET', resolved: true },
|
|
]),
|
|
dir,
|
|
repo,
|
|
);
|
|
const paths = providerPaths(out);
|
|
// The array-form routes are absent from this mock graph but survive via source scan.
|
|
expect(paths).toEqual(expect.arrayContaining(['GET::/a', 'GET::/b']));
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('same-URL multi-verb (GET+POST /orders) keeps both verbs', async () => {
|
|
const OC = `package com.example;
|
|
import org.springframework.web.bind.annotation.*;
|
|
@RestController
|
|
public class OrderController {
|
|
@GetMapping("/orders") public Object list() { return null; }
|
|
@PostMapping("/orders") public Object make() { return null; }
|
|
}
|
|
`;
|
|
const dir = mkRepo({ 'OrderController.java': OC });
|
|
try {
|
|
// Ingestion's URL-keyed Route node collapses to one verb; resolve only GET.
|
|
const out = await new HttpRouteExtractor().extract(
|
|
makeDb([
|
|
{ file: 'OrderController.java', routePath: '/orders', method: 'GET', resolved: true },
|
|
]),
|
|
dir,
|
|
repo,
|
|
);
|
|
const paths = providerPaths(out);
|
|
expect(paths).toEqual(expect.arrayContaining(['GET::/orders', 'POST::/orders']));
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('interface-inherited Spring route survives on the implementing controller', async () => {
|
|
const IFACE = `package com.example;
|
|
import org.springframework.web.bind.annotation.*;
|
|
@RequestMapping("/orders")
|
|
public interface OrderApi {
|
|
@GetMapping("/{id}") Object get(Long id);
|
|
}
|
|
`;
|
|
const CTRL = `package com.example;
|
|
import org.springframework.web.bind.annotation.*;
|
|
@RestController
|
|
public class OrderController implements OrderApi {
|
|
@GetMapping("/direct") public Object direct() { return null; }
|
|
public Object get(Long id) { return null; }
|
|
}
|
|
`;
|
|
const dir = mkRepo({ 'OrderApi.java': IFACE, 'OrderController.java': CTRL });
|
|
try {
|
|
// Graph resolves only the controller-direct route; the inherited route is
|
|
// composed only by the group scanProject pass.
|
|
const out = await new HttpRouteExtractor().extract(
|
|
makeDb([
|
|
{ file: 'OrderController.java', routePath: '/direct', method: 'GET', resolved: true },
|
|
]),
|
|
dir,
|
|
repo,
|
|
);
|
|
const paths = providerPaths(out);
|
|
expect(paths).toEqual(expect.arrayContaining(['GET::/orders/{param}']));
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|