mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* feat(ingestion/routes): give Route nodes a (method, url) identity (#2289) Route node identity was URL-only, so a same-URL multi-verb pair (GET /x + POST /x) collapsed into a single node and the second verb's handler and execution flow were silently lost. Route identity is now (method, url) via routeNodeKey(method, url): a known, specific verb keys as "METHOD url", while a method-less route (filesystem routes — Next.js / Expo / PHP — and Laravel resource/apiResource) or a wildcard "*" route (e.g. Django function views) falls back to URL-only. The fallback is byte-identical to the previous URL-only ids, so only genuine declaration-style multi-verb routes split into separate nodes. The identity key is shared across the three phases that must agree on the Route node id: - routes phase: registry key + node id + handler-symbol lookup; the Route node still carries the bare URL as its display name. - call-processor: resolveRouteHandlerSymbols re-keyed by identity so each verb resolves its own handler; a verb-less fetch() consumer matches by URL and connects to every Route node at that URL (one per verb). - processes phase: ENTRY_POINT_OF targets the identity-keyed node id. Bumps INCREMENTAL_SCHEMA_VERSION 4 -> 5: persisted pre-v5 Route nodes use the old url-only ids, so an incremental top-up would strand them alongside new composite-keyed nodes — force a full re-analyze instead. Part of #2280. * fix(ingestion/routes): address PR #2302 review (P1/P2/P3) P1 — Schema v5 fast-path bypass (run-analyze.ts): Adds a schemaVersion-mismatch guard above the alreadyUpToDate early-return, mirroring the pdgModeMismatch slot. Without it, a same-commit re-analyze on a pre-v5 stamp returned alreadyUpToDate without ever reaching the isIncremental gate, defeating the v5 schema bump's migration intent. Regression test covers: analyze (stamps v5) → meta downgrade to v4 → same commit re-analyze must NOT early-return and meta restamps to v5. P2 — ENTRY_POINT_OF handler-aware linking (processes.ts): Pre-fix routesByFile fanned every same-file Route to every same-file process, cross-wiring same-file GET/POST handlers. Now reads handlerSymbolId off the Route graph node (the source of truth routes.ts stamps) into routesByHandlerId, with a routesWithoutHandlerByFile fallback — mirrors the Tool linking precedent 10 lines below. Two regression tests: weak form (only one handler has a process; sibling verb does not get spuriously attached) and strong form (both handlers form distinct processes; each Route links to exactly its own entryPoint, 2 edges not pre-fix 4). P2 — Roundtrip composite-id (route-{method,handler-symbol}-roundtrip): Both tests now seed the Route node with generateId('Route', routeNodeKey('POST', '/api/orders')) and run the Cypher MATCH against the composite id, exercising the literal-space-in-id through CSV→COPY→HANDLES_ROUTE_QUERY. A space-in-id escape regression would surface here instead of being silently swallowed by the extractor's catch. P3 — doc-drift + test if: - route-path.ts:4 — header updated to "(method, url) via routeNodeKey" - java.ts:684 — drop "Route nodes are URL-keyed"; #2289 closes that gap - manifest-extractor.ts:196 — explicit that Route node *id* is composite while route.name remains the bare URL - multi-verb-route-identity.test.ts:88 — forEachRelationship+if rewritten as a .filter().map() chain (no test-level conditional). New route-process-linking tests are also if-free. Validation: tsc clean, prettier clean, 9 touched suites / 43 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(ingestion/routes): drop routes.ts re-export, fix CI fast-path tests Two follow-ups on PR #2302's CHANGES_REQUESTED review: 1. Drop `routes.ts` re-export of `normalizeExtractedRoutePath` / `normalizeRouteMethod` / `routeNodeKey` (per @magyargergo's inline comment at routes.ts:153 — the symbols already live in `route-extractors/route-path.ts` and consumers should import them from the source, not via a routes-phase indirection that was kept only as a compat shim during the #2289 refactor). Updated the two remaining callers (blade-template-routes / spring-route-extractor- parity tests) to import directly from `route-extractors/route-path.js`. `call-processor.ts` and `processes.ts` already import from the source. 2. Fix two `run-analyze.test.ts` fast-path tests that started failing on CI after the schema-version mismatch guard landed ( "creates .gitnexus/.gitignore on the already-up-to-date fast path" and "reports isPrimaryBranch false for an up-to-date non-primary branch"). The test fixtures hand-built a RepoMeta with NO schemaVersion field; with the guard now checking `existingMeta.schemaVersion !== INCREMENTAL_SCHEMA_VERSION`, that pre-versioning shape was treated as a mismatch and forced a rebuild, short-circuiting the fast path the tests exercise. Stamp the current schemaVersion on those fixtures so they reflect the post-#2289 meta shape production actually writes (`runFullAnalysis` always stamps the field on git repos — see meta save site). Validation: tsc clean, prettier clean, 11 touched suites / 80 tests pass. --------- Co-authored-by: henry <zhangwei2017@unipus.cn> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
576e81442e
commit
8bef64baa1
22 changed files with 843 additions and 159 deletions
|
|
@ -678,16 +678,16 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
language: Java,
|
||||
// routeCoverage intentionally LEFT at the default 'partial' (#2138 Part 2).
|
||||
// The graph provider set is a strict *subset* of this scan()'s provider set —
|
||||
// ingestion does NOT emit a Route node for (1) a method-level array route
|
||||
// nested under a class-level array-form `@RequestMapping` (ingestion suppresses
|
||||
// it rather than drop the prefix; bare/scalar-prefixed array methods ARE now
|
||||
// emitted — see #2280), or (2) the 2nd verb of a same-URL GET+POST pair (Route
|
||||
// nodes are URL-keyed). Interface-inherited Spring routes ARE now emitted by
|
||||
// ingestion (#2288), so they are no longer a coverage gap. Declaring 'complete'
|
||||
// here would let the parse-skip drop those remaining group-only providers.
|
||||
// Java flips to 'complete' only once ingestion provider extraction matches this
|
||||
// scan (a follow-up: class-level array-form prefix support + per-verb Route
|
||||
// identity — tracked in #2280).
|
||||
// ingestion does NOT emit a Route node for a method-level array route nested
|
||||
// under a class-level array-form `@RequestMapping` (ingestion suppresses it
|
||||
// rather than drop the prefix; bare/scalar-prefixed array methods ARE now
|
||||
// emitted — see #2280). Interface-inherited Spring routes ARE now emitted by
|
||||
// ingestion (#2288), and same-URL multi-verb routes are now per-`(method,url)`
|
||||
// Route nodes (#2289), so they are no longer coverage gaps. Declaring
|
||||
// 'complete' here would let the parse-skip drop the remaining group-only
|
||||
// providers (the array-prefix gap above). Java flips to 'complete' only once
|
||||
// ingestion provider extraction matches this scan — class-level array-form
|
||||
// prefix support is the final follow-up tracked in #2280.
|
||||
// `hasConsumerSignals` below is kept ready for that flip.
|
||||
// Consumer signals this plugin's scan() can detect: RestTemplate / WebClient /
|
||||
// OkHttp / Java-HttpClient / Apache-HttpClient call sites, OpenFeign
|
||||
|
|
|
|||
|
|
@ -192,10 +192,12 @@ export class ManifestExtractor {
|
|||
try {
|
||||
let rows: Record<string, unknown>[];
|
||||
if (link.type === 'http') {
|
||||
// Route.name is the canonicalized URL path (see
|
||||
// core/ingestion/pipeline.ts ensureSlash + generateId('Route', ...)).
|
||||
// Normalize the manifest contract the same way so a user-written
|
||||
// "/api/orders" matches "api/orders" in the graph.
|
||||
// Route.name is the canonicalized URL path. Since #2289 a Route node's
|
||||
// *id* is `(method, url)`-composite (`routeNodeKey`), but `route.name`
|
||||
// continues to carry the bare URL so URL-keyed group queries like this
|
||||
// one keep working without a schema change. Normalize the manifest
|
||||
// contract the same way so a user-written "/api/orders" matches
|
||||
// "api/orders" in the graph.
|
||||
//
|
||||
// The contract may also use the explicit-method form "GET::/api/orders"
|
||||
// recommended by buildContractId. Strip the METHOD:: prefix before
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ import { yieldToEventLoop } from './utils/event-loop.js';
|
|||
import type { ExtractedRoute, ExtractedFetchCall } from './workers/parse-worker.js';
|
||||
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
||||
import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js';
|
||||
import { normalizeExtractedRoutePath } from './route-extractors/route-path.js';
|
||||
import {
|
||||
normalizeExtractedRoutePath,
|
||||
normalizeRouteMethod,
|
||||
routeNodeKey,
|
||||
} from './route-extractors/route-path.js';
|
||||
import { extractReturnTypeName } from './type-extractors/shared.js';
|
||||
|
||||
const MAX_EXPORTS_PER_FILE = 500;
|
||||
|
|
@ -246,11 +250,11 @@ export const processRoutesFromExtracted = async (
|
|||
};
|
||||
|
||||
/**
|
||||
* Resolve each route's handler to a real symbol UID, keyed by the normalized
|
||||
* route URL (the same key the routes phase uses for the `Route` node). This is
|
||||
* the Part 2 (#2138) groundwork that lets `HttpRouteExtractor.extractProvidersGraph`
|
||||
* read the handler symbol from the graph instead of re-parsing source via
|
||||
* `getDetections()`.
|
||||
* Resolve each route's handler to a real symbol UID, keyed by the route's
|
||||
* `(method, url)` identity (`routeNodeKey` — the same key the routes phase uses
|
||||
* for the `Route` node). This is the Part 2 (#2138) groundwork that lets
|
||||
* `HttpRouteExtractor.extractProvidersGraph` read the handler symbol from the
|
||||
* graph instead of re-parsing source via `getDetections()`.
|
||||
*
|
||||
* Two route shapes, one resolution target — `(filePath, name) → nodeId`:
|
||||
* - Laravel framework routes (`ExtractedRoute`) carry `controllerName` +
|
||||
|
|
@ -260,14 +264,18 @@ export const processRoutesFromExtracted = async (
|
|||
* `handlerName` (the decorated method, captured at extraction); resolve it
|
||||
* directly in the route's own file.
|
||||
*
|
||||
* First-writer-wins per URL, matching the routes phase's dedup (it keeps the
|
||||
* first route registered for a URL and counts the rest as duplicates). The first
|
||||
* route to claim a URL reserves it **even when its handler is unresolvable**, so
|
||||
* a later same-URL route can never stamp its handler onto the first route's Route
|
||||
* node (the routes phase made that first route the node-winner). Routes whose
|
||||
* handler cannot be *uniquely* resolved (no name, zero matches, or an ambiguous
|
||||
* same-name match) carry no `handlerSymbolId`; the extractor then falls back to
|
||||
* source scan for that route (fail-open, no regression, never a wrong handler).
|
||||
* First-writer-wins per route identity, matching the routes phase's dedup (it
|
||||
* keeps the first route registered for a `(method, url)` key and counts the rest
|
||||
* as duplicates). The first route to claim a key reserves it **even when its
|
||||
* handler is unresolvable**, so a later same-key route can never stamp its
|
||||
* handler onto the first route's Route node (the routes phase made that first
|
||||
* route the node-winner). Keying is `routeNodeKey(method, url)` (#2289): a
|
||||
* same-URL multi-verb pair (`GET /x` + `POST /x`) resolves two handlers, one per
|
||||
* node; method-less / wildcard routes key by URL alone, byte-identical to the
|
||||
* pre-#2289 behavior. Routes whose handler cannot be *uniquely* resolved (no
|
||||
* name, zero matches, or an ambiguous same-name match) carry no
|
||||
* `handlerSymbolId`; the extractor then falls back to source scan for that route
|
||||
* (fail-open, no regression, never a wrong handler).
|
||||
*/
|
||||
export function resolveRouteHandlerSymbols(
|
||||
model: SemanticModel,
|
||||
|
|
@ -275,9 +283,9 @@ export function resolveRouteHandlerSymbols(
|
|||
decoratorRoutes: readonly ExtractedDecoratorRoute[],
|
||||
): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
// URLs already claimed by an earlier route (resolved or not). Mirrors the
|
||||
// routes phase `addRoute` first-writer-wins so the handler we stamp always
|
||||
// belongs to the route that actually won the Route node.
|
||||
// Route identities already claimed by an earlier route (resolved or not).
|
||||
// Mirrors the routes phase `addRoute` first-writer-wins so the handler we
|
||||
// stamp always belongs to the route that actually won the Route node.
|
||||
const claimed = new Set<string>();
|
||||
|
||||
// Resolve a single same-file symbol by name, refusing to guess on ambiguity:
|
||||
|
|
@ -287,12 +295,18 @@ export function resolveRouteHandlerSymbols(
|
|||
return defs.length === 1 ? defs[0]?.nodeId : undefined;
|
||||
};
|
||||
|
||||
const claim = (routePath: string | null, prefix: string | null, symbolId: string | undefined) => {
|
||||
const claim = (
|
||||
routePath: string | null,
|
||||
prefix: string | null,
|
||||
httpMethod: string | null | undefined,
|
||||
symbolId: string | undefined,
|
||||
) => {
|
||||
if (!routePath) return;
|
||||
const url = normalizeExtractedRoutePath(routePath, prefix);
|
||||
if (claimed.has(url)) return; // first-writer-wins: later same-URL routes can't override
|
||||
claimed.add(url);
|
||||
if (symbolId) out.set(url, symbolId);
|
||||
const key = routeNodeKey(normalizeRouteMethod(httpMethod), url);
|
||||
if (claimed.has(key)) return; // first-writer-wins: later same-key routes can't override
|
||||
claimed.add(key);
|
||||
if (symbolId) out.set(key, symbolId);
|
||||
};
|
||||
|
||||
// Laravel framework routes — controller class + method name.
|
||||
|
|
@ -309,14 +323,14 @@ export function resolveRouteHandlerSymbols(
|
|||
}
|
||||
if (controllerDef) methodId = uniqueSymbolId(controllerDef.filePath, route.methodName);
|
||||
}
|
||||
claim(route.routePath, route.prefix ?? null, methodId);
|
||||
claim(route.routePath, route.prefix ?? null, route.httpMethod, methodId);
|
||||
}
|
||||
|
||||
// Decorator routes (Spring / FastAPI / generic) — the decorated handler in
|
||||
// the route's own file.
|
||||
for (const dr of decoratorRoutes) {
|
||||
const handlerId = dr.handlerName ? uniqueSymbolId(dr.filePath, dr.handlerName) : undefined;
|
||||
claim(dr.routePath, dr.prefix ?? null, handlerId);
|
||||
claim(dr.routePath, dr.prefix ?? null, dr.httpMethod, handlerId);
|
||||
}
|
||||
|
||||
return out;
|
||||
|
|
@ -465,19 +479,29 @@ export const extractConsumerAccessedKeys = (content: string): string[] => {
|
|||
* Create FETCHES edges from extracted fetch() calls to matching Route nodes.
|
||||
* When consumerContents is provided, extracts property access patterns from
|
||||
* consumer files and encodes them in the edge reason field.
|
||||
*
|
||||
* Matching stays URL-only (#2289): a verb-less consumer (a `fetch()` call has
|
||||
* no statically-known HTTP method) matches a route by URL and connects to
|
||||
* **every** Route node sharing that URL — i.e. both the `GET /x` and `POST /x`
|
||||
* nodes when a URL carries multiple verbs. `routeUrlToKeys` therefore maps each
|
||||
* route URL to the list of `routeNodeKey` identities at that URL; a single-verb
|
||||
* (or method-less) URL has a one-element list, keeping edges byte-identical to
|
||||
* the pre-#2289 behavior.
|
||||
*/
|
||||
export const processNextjsFetchRoutes = (
|
||||
graph: KnowledgeGraph,
|
||||
fetchCalls: ExtractedFetchCall[],
|
||||
routeRegistry: Map<string, string>, // routeURL → handlerFilePath
|
||||
routeUrlToKeys: Map<string, string[]>, // routeURL → route node keys at that URL
|
||||
consumerContents?: Map<string, string>, // filePath → file content
|
||||
) => {
|
||||
// Pre-count how many routes each consumer file matches (for confidence attribution)
|
||||
// Pre-count how many route URLs each consumer file matches (for confidence
|
||||
// attribution). Counts once per call that matches any URL — independent of how
|
||||
// many verbs share that URL — so the multi-fetch heuristic is unchanged.
|
||||
const routeCountByFile = new Map<string, number>();
|
||||
for (const call of fetchCalls) {
|
||||
const normalized = normalizeFetchURL(call.fetchURL);
|
||||
if (!normalized) continue;
|
||||
for (const [routeURL] of routeRegistry) {
|
||||
for (const routeURL of routeUrlToKeys.keys()) {
|
||||
if (routeMatches(normalized, routeURL)) {
|
||||
routeCountByFile.set(call.filePath, (routeCountByFile.get(call.filePath) ?? 0) + 1);
|
||||
break;
|
||||
|
|
@ -489,10 +513,9 @@ export const processNextjsFetchRoutes = (
|
|||
const normalized = normalizeFetchURL(call.fetchURL);
|
||||
if (!normalized) continue;
|
||||
|
||||
for (const [routeURL] of routeRegistry) {
|
||||
for (const [routeURL, routeKeys] of routeUrlToKeys) {
|
||||
if (routeMatches(normalized, routeURL)) {
|
||||
const sourceId = generateId('File', call.filePath);
|
||||
const routeNodeId = generateId('Route', routeURL);
|
||||
|
||||
// Extract consumer accessed keys if file content is available
|
||||
let reason = 'fetch-url-match';
|
||||
|
|
@ -512,14 +535,18 @@ export const processNextjsFetchRoutes = (
|
|||
reason = `${reason}|fetches:${fetchCount}`;
|
||||
}
|
||||
|
||||
graph.addRelationship({
|
||||
id: generateId('FETCHES', `${sourceId}->${routeNodeId}`),
|
||||
sourceId,
|
||||
targetId: routeNodeId,
|
||||
type: 'FETCHES',
|
||||
confidence: 0.9,
|
||||
reason,
|
||||
});
|
||||
// Connect to every Route node at this URL (one per verb).
|
||||
for (const routeKey of routeKeys) {
|
||||
const routeNodeId = generateId('Route', routeKey);
|
||||
graph.addRelationship({
|
||||
id: generateId('FETCHES', `${sourceId}->${routeNodeId}`),
|
||||
sourceId,
|
||||
targetId: routeNodeId,
|
||||
type: 'FETCHES',
|
||||
confidence: 0.9,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type { ToolsOutput } from './tools.js';
|
|||
import type { StructureOutput } from './structure.js';
|
||||
import { processProcesses, type ProcessDetectionResult } from '../process-processor.js';
|
||||
import { generateId } from '../../../lib/utils.js';
|
||||
import { routeNodeKey } from '../route-extractors/route-path.js';
|
||||
import { isDev } from '../utils/env.js';
|
||||
|
||||
import { logger } from '../../logger.js';
|
||||
|
|
@ -106,14 +107,39 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
|
|||
|
||||
// Link Route and Tool nodes to Processes
|
||||
if (routeRegistry.size > 0 || toolDefs.length > 0) {
|
||||
const routesByFile = new Map<string, string[]>();
|
||||
for (const [url, entry] of routeRegistry) {
|
||||
let list = routesByFile.get(entry.filePath);
|
||||
// Two-tier route lookup, mirroring the tool tables 10 lines below.
|
||||
// Routes whose handler resolved key by `handlerSymbolId` (read from
|
||||
// the Route node's graph properties — routes.ts stamps it there) and
|
||||
// link ONLY to the process whose entryPoint matches; routes without a
|
||||
// resolved handler fall back to a per-file bucket so we still attach
|
||||
// the Route node to a same-file process (best-effort).
|
||||
//
|
||||
// Pre-#2289-review-P2 this was a single per-file bucket: every verb
|
||||
// on a file's controller was linked to every process in that file,
|
||||
// cross-wiring same-file `GET /items` and `POST /items` to each
|
||||
// other's handler processes. The per-verb `handlerSymbolId` the
|
||||
// routes phase stamps on the Route node was never consulted.
|
||||
const routesByHandlerId = new Map<string, string[]>();
|
||||
const routesWithoutHandlerByFile = new Map<string, string[]>();
|
||||
for (const [, entry] of routeRegistry) {
|
||||
// Push the Route node identity (`routeNodeKey`), not the bare URL, so the
|
||||
// ENTRY_POINT_OF edge targets the same node id the routes phase created
|
||||
// (#2289: a same-URL GET/POST pair is two distinct Route nodes).
|
||||
const routeKey = routeNodeKey(entry.method, entry.url);
|
||||
// Source of truth for handlerSymbolId is the Route node in the
|
||||
// graph (routes.ts populates it from `routeHandlerSymbols`); the
|
||||
// routes phase runs before processes (see `deps`), so the node is
|
||||
// always present here.
|
||||
const routeNode = ctx.graph.getNode(generateId('Route', routeKey));
|
||||
const handlerSymbolId = routeNode?.properties.handlerSymbolId as string | undefined;
|
||||
const targetMap = handlerSymbolId ? routesByHandlerId : routesWithoutHandlerByFile;
|
||||
const bucketKey = handlerSymbolId ?? entry.filePath;
|
||||
let list = targetMap.get(bucketKey);
|
||||
if (!list) {
|
||||
list = [];
|
||||
routesByFile.set(entry.filePath, list);
|
||||
targetMap.set(bucketKey, list);
|
||||
}
|
||||
list.push(url);
|
||||
list.push(routeKey);
|
||||
}
|
||||
const toolsByHandlerId = new Map<string, string[]>();
|
||||
const toolsWithoutHandlerByFile = new Map<string, string[]>();
|
||||
|
|
@ -136,10 +162,12 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
|
|||
const entryFile = entryNode.properties.filePath;
|
||||
if (!entryFile) continue;
|
||||
|
||||
const routeURLs = routesByFile.get(entryFile);
|
||||
if (routeURLs) {
|
||||
for (const routeURL of routeURLs) {
|
||||
const routeNodeId = generateId('Route', routeURL);
|
||||
const exactRouteKeys = routesByHandlerId.get(proc.entryPointId);
|
||||
const fallbackRouteKeys = routesWithoutHandlerByFile.get(entryFile);
|
||||
const routeKeys = exactRouteKeys ?? fallbackRouteKeys;
|
||||
if (routeKeys) {
|
||||
for (const routeKey of routeKeys) {
|
||||
const routeNodeId = generateId('Route', routeKey);
|
||||
ctx.graph.addRelationship({
|
||||
id: generateId('ENTRY_POINT_OF', `${routeNodeId}->${proc.id}`),
|
||||
sourceId: routeNodeId,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,11 @@ import {
|
|||
compiledMatcherMatchesRoute,
|
||||
} from '../route-extractors/middleware.js';
|
||||
import { processNextjsFetchRoutes } from '../call-processor.js';
|
||||
import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
|
||||
import {
|
||||
normalizeExtractedRoutePath,
|
||||
normalizeRouteMethod,
|
||||
routeNodeKey,
|
||||
} from '../route-extractors/route-path.js';
|
||||
import { generateId } from '../../../lib/utils.js';
|
||||
import { readFileContents } from '../filesystem-walker.js';
|
||||
import { isDev } from '../utils/env.js';
|
||||
|
|
@ -43,6 +47,13 @@ const EXPO_NAV_PATTERNS = [
|
|||
export interface RouteEntry {
|
||||
filePath: string;
|
||||
source: string;
|
||||
/**
|
||||
* The route's URL path (leading-slash, prefix-joined). This is the Route
|
||||
* node's `name`. Stored explicitly because the registry is keyed by the
|
||||
* `(method, url)` identity (`routeNodeKey`), so the key is no longer the URL
|
||||
* — downstream URL consumers (middleware/fetch matching) read this instead.
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* HTTP verb for this route when ingestion knows it structurally
|
||||
* (Spring/Laravel framework routes and decorator routes carry
|
||||
|
|
@ -138,40 +149,6 @@ function escapeRegex(s: string): string {
|
|||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Re-exported for existing consumers/tests that import it from the routes phase.
|
||||
export { normalizeExtractedRoutePath };
|
||||
|
||||
/**
|
||||
* Canonicalize a route's HTTP verb for persistence on the Route node.
|
||||
* Returns an upper-cased standard method, or `undefined` when the value
|
||||
* is not a real HTTP verb. Laravel `Route::resource` / `apiResource`
|
||||
* surface `httpMethod` values like `resource` / `apiResource` (they
|
||||
* expand to several verbs at runtime), so they must not be stored as a
|
||||
* method — leaving them `undefined` keeps the column clean and lets the
|
||||
* contract extractor fall back to its source-scan path for those routes.
|
||||
*/
|
||||
const VALID_HTTP_METHODS = new Set([
|
||||
'GET',
|
||||
'POST',
|
||||
'PUT',
|
||||
'PATCH',
|
||||
'DELETE',
|
||||
'HEAD',
|
||||
'OPTIONS',
|
||||
'TRACE',
|
||||
'CONNECT',
|
||||
]);
|
||||
|
||||
export function normalizeRouteMethod(raw: string | null | undefined): string | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const verb = raw.trim().toUpperCase();
|
||||
// '*' marks a method-agnostic route (e.g. a Django function view handles any
|
||||
// verb). Preserve it so the contract layer emits a wildcard provider that
|
||||
// matches consumers of any method, instead of silently narrowing to GET.
|
||||
if (verb === '*') return '*';
|
||||
return VALID_HTTP_METHODS.has(verb) ? verb : undefined;
|
||||
}
|
||||
|
||||
export const routesPhase: PipelinePhase<RoutesOutput> = {
|
||||
name: 'routes',
|
||||
deps: ['parse'],
|
||||
|
|
@ -219,31 +196,45 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
if (expoAppPaths.has(p)) {
|
||||
const expoURL = expoFileToRouteURL(p);
|
||||
if (expoURL && !routeRegistry.has(expoURL)) {
|
||||
routeRegistry.set(expoURL, { filePath: p, source: 'expo-filesystem-route' });
|
||||
routeRegistry.set(expoURL, {
|
||||
filePath: p,
|
||||
source: 'expo-filesystem-route',
|
||||
url: expoURL,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const nextjsURL = nextjsFileToRouteURL(p);
|
||||
if (nextjsURL && !routeRegistry.has(nextjsURL)) {
|
||||
routeRegistry.set(nextjsURL, { filePath: p, source: 'nextjs-filesystem-route' });
|
||||
routeRegistry.set(nextjsURL, {
|
||||
filePath: p,
|
||||
source: 'nextjs-filesystem-route',
|
||||
url: nextjsURL,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (p.endsWith('.php')) {
|
||||
const phpURL = phpFileToRouteURL(p);
|
||||
if (phpURL && !routeRegistry.has(phpURL)) {
|
||||
routeRegistry.set(phpURL, { filePath: p, source: 'php-file-route' });
|
||||
routeRegistry.set(phpURL, { filePath: p, source: 'php-file-route', url: phpURL });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let duplicateRoutes = 0;
|
||||
const namedRouteRegistry = new Map<string, string>();
|
||||
const addRoute = (url: string, entry: RouteEntry) => {
|
||||
if (routeRegistry.has(url)) {
|
||||
// Routes are keyed by their `(method, url)` identity (#2289): a same-URL
|
||||
// multi-verb pair (`GET /x` + `POST /x`) is two entries, not one. Method-less
|
||||
// / wildcard routes key by URL (see `routeNodeKey`), so filesystem/resource
|
||||
// routes stay byte-identical. A true duplicate (same method AND url) is still
|
||||
// dropped.
|
||||
const addRoute = (url: string, entry: Omit<RouteEntry, 'url'>) => {
|
||||
const key = routeNodeKey(entry.method, url);
|
||||
if (routeRegistry.has(key)) {
|
||||
duplicateRoutes++;
|
||||
return;
|
||||
}
|
||||
routeRegistry.set(url, entry);
|
||||
routeRegistry.set(key, { ...entry, url });
|
||||
};
|
||||
for (const route of allExtractedRoutes) {
|
||||
if (!route.routePath) continue;
|
||||
|
|
@ -271,8 +262,8 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
const handlerPaths = [...routeRegistry.values()].map((e) => e.filePath);
|
||||
handlerContents = await readFileContents(ctx.repoPath, handlerPaths);
|
||||
|
||||
for (const [routeURL, entry] of routeRegistry) {
|
||||
const { filePath: handlerPath, source: routeSource, method: routeMethod } = entry;
|
||||
for (const [routeKey, entry] of routeRegistry) {
|
||||
const { filePath: handlerPath, source: routeSource, method: routeMethod, url } = entry;
|
||||
const content = handlerContents.get(handlerPath);
|
||||
|
||||
const { responseKeys, errorKeys } = content
|
||||
|
|
@ -284,13 +275,13 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
const mwResult = content ? extractMiddlewareChain(content) : undefined;
|
||||
const middleware = mwResult?.chain;
|
||||
|
||||
const routeNodeId = generateId('Route', routeURL);
|
||||
const handlerSymbolId = routeHandlerSymbols.get(routeURL);
|
||||
const routeNodeId = generateId('Route', routeKey);
|
||||
const handlerSymbolId = routeHandlerSymbols.get(routeKey);
|
||||
ctx.graph.addNode({
|
||||
id: routeNodeId,
|
||||
label: 'Route',
|
||||
properties: {
|
||||
name: routeURL,
|
||||
name: url,
|
||||
filePath: handlerPath,
|
||||
...(routeMethod ? { method: routeMethod } : {}),
|
||||
...(handlerSymbolId ? { handlerSymbolId } : {}),
|
||||
|
|
@ -344,13 +335,13 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
.filter((m): m is NonNullable<typeof m> => m !== null);
|
||||
|
||||
let linkedCount = 0;
|
||||
for (const [routeURL] of routeRegistry) {
|
||||
for (const [routeKey, entry] of routeRegistry) {
|
||||
const matches =
|
||||
compiled.length === 0 ||
|
||||
compiled.some((cm) => compiledMatcherMatchesRoute(cm, routeURL));
|
||||
compiled.some((cm) => compiledMatcherMatchesRoute(cm, entry.url));
|
||||
if (!matches) continue;
|
||||
|
||||
const routeNodeId = generateId('Route', routeURL);
|
||||
const routeNodeId = generateId('Route', routeKey);
|
||||
const existing = ctx.graph.getNode(routeNodeId);
|
||||
if (!existing) continue;
|
||||
|
||||
|
|
@ -487,13 +478,19 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
}
|
||||
|
||||
if (routeRegistry.size > 0 && allFetchCalls.length > 0) {
|
||||
const routeURLToFile = new Map<string, string>();
|
||||
for (const [url, entry] of routeRegistry) routeURLToFile.set(url, entry.filePath);
|
||||
// url → [route node keys at that url] (one per verb). A verb-less fetch()
|
||||
// consumer matches by URL and connects to every Route node at that URL.
|
||||
const routeUrlToKeys = new Map<string, string[]>();
|
||||
for (const [routeKey, entry] of routeRegistry) {
|
||||
const existing = routeUrlToKeys.get(entry.url);
|
||||
if (existing) existing.push(routeKey);
|
||||
else routeUrlToKeys.set(entry.url, [routeKey]);
|
||||
}
|
||||
|
||||
const consumerPaths = [...new Set(allFetchCalls.map((c) => c.filePath))];
|
||||
const consumerContents = await readFileContents(ctx.repoPath, consumerPaths);
|
||||
|
||||
processNextjsFetchRoutes(ctx.graph, allFetchCalls, routeURLToFile, consumerContents);
|
||||
processNextjsFetchRoutes(ctx.graph, allFetchCalls, routeUrlToKeys, consumerContents);
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
`🔗 Processed ${allFetchCalls.length} fetch() calls against ${routeRegistry.size} routes`,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@
|
|||
* Shared route-path normalization.
|
||||
*
|
||||
* Extracted from the routes phase so both the routes phase (which creates the
|
||||
* `Route` graph node, keyed by the normalized URL) and the parse phase (which
|
||||
* resolves each route's handler symbol and needs the SAME key to associate the
|
||||
* resolved id back to the route) can compute an identical route URL without a
|
||||
* phase-to-phase import cycle. Pure string logic, no dependencies.
|
||||
* `Route` graph node, keyed by `(method, url)` via `routeNodeKey` — #2289) and
|
||||
* the parse phase (which resolves each route's handler symbol and needs the
|
||||
* SAME key to associate the resolved id back to the route) can compute an
|
||||
* identical route identity without a phase-to-phase import cycle. Pure string
|
||||
* logic, no dependencies.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -19,3 +20,49 @@ export function normalizeExtractedRoutePath(routePath: string, prefix: string |
|
|||
const joined = prefixPart ? `/${prefixPart}${pathPart ? `/${pathPart}` : ''}` : `/${pathPart}`;
|
||||
return joined.replace(/\/+/g, '/') || '/';
|
||||
}
|
||||
|
||||
const VALID_HTTP_METHODS = new Set([
|
||||
'GET',
|
||||
'POST',
|
||||
'PUT',
|
||||
'PATCH',
|
||||
'DELETE',
|
||||
'HEAD',
|
||||
'OPTIONS',
|
||||
'TRACE',
|
||||
'CONNECT',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Canonicalize a route's HTTP verb for persistence on the Route node and for
|
||||
* the route identity key. Returns an upper-cased standard method, `'*'` for a
|
||||
* method-agnostic route (e.g. a Django function view), or `undefined` when the
|
||||
* value is not a real HTTP verb. Laravel `Route::resource` / `apiResource`
|
||||
* surface values like `resource` / `apiResource` (they expand to several verbs
|
||||
* at runtime), so they come back `undefined` — keeping the column clean and
|
||||
* letting the contract extractor fall back to its source-scan path.
|
||||
*/
|
||||
export function normalizeRouteMethod(raw: string | null | undefined): string | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const verb = raw.trim().toUpperCase();
|
||||
// '*' marks a method-agnostic route. Preserve it so the contract layer emits a
|
||||
// wildcard provider that matches consumers of any method.
|
||||
if (verb === '*') return '*';
|
||||
return VALID_HTTP_METHODS.has(verb) ? verb : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Route node identity (#2289): `(method, path)` when the verb is known and
|
||||
* specific, falling back to URL-only when the method is `undefined` (filesystem
|
||||
* routes, Laravel `resource`/`apiResource`) or `'*'` (method-agnostic routes,
|
||||
* e.g. Django function views). The URL-fallback keeps those byte-identical to
|
||||
* the pre-#2289 URL-only ids, so only genuine declaration-style multi-verb
|
||||
* routes (`GET /x` + `POST /x`) split into separate nodes.
|
||||
*
|
||||
* Used by the routes phase (node id + registry key), the processes phase
|
||||
* (ENTRY_POINT_OF), and the handler-symbol resolver — all three must key
|
||||
* identically.
|
||||
*/
|
||||
export function routeNodeKey(method: string | undefined, url: string): string {
|
||||
return method && method !== '*' ? `${method} ${url}` : url;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -749,6 +749,30 @@ export async function runFullAnalysis(
|
|||
options = { ...options, force: true };
|
||||
}
|
||||
|
||||
// ── schema-version mismatch forces full rebuild (#2289 P1) ────────
|
||||
// Mirrors the pdg-mode block above: a stamp from an older
|
||||
// INCREMENTAL_SCHEMA_VERSION (e.g. pre-v5 URL-only Route ids) cannot be
|
||||
// reconciled by an incremental top-up — same-commit re-analyze would
|
||||
// strand stale rows next to new-schema writes. MUST sit before the
|
||||
// alreadyUpToDate fast path below: an unchanged-commit clean tree would
|
||||
// otherwise early-return without ever reaching the `isIncremental` gate
|
||||
// that consults `schemaVersion`, defeating the bump's whole point.
|
||||
//
|
||||
// `schemaVersion === undefined` covers two cases that should still trip
|
||||
// this guard: a non-git repo (which never stamps the field) and very old
|
||||
// meta from before the field existed. Non-git repos take the
|
||||
// `currentCommit === ''` rebuild branch below regardless, so the redundant
|
||||
// force here is harmless; the friendlier `'pre-versioning'` log avoids a
|
||||
// user-visible "stamped vundefined" line in that edge case.
|
||||
if (existingMeta && existingMeta.schemaVersion !== INCREMENTAL_SCHEMA_VERSION) {
|
||||
const stampedVersion = existingMeta.schemaVersion ?? 'pre-versioning';
|
||||
log(
|
||||
`index schema changed (stamped v${stampedVersion}, this build is v${INCREMENTAL_SCHEMA_VERSION}); ` +
|
||||
`forcing a full rebuild so persisted rows match the current schema.`,
|
||||
);
|
||||
options = { ...options, force: true };
|
||||
}
|
||||
|
||||
// ── Early-return: already up to date ──────────────────────────────
|
||||
if (existingMeta && !options.force && existingMeta.lastCommit === currentCommit) {
|
||||
// Non-git folders have currentCommit = '' — always rebuild since we can't detect changes
|
||||
|
|
|
|||
|
|
@ -244,8 +244,14 @@ export interface RepoMeta {
|
|||
* so the engine would silently UNDER-REPORT return-value ascent on an
|
||||
* incremental top-up; force a full re-analyze instead (same contract as v2/v3).
|
||||
* This single bump covers the whole FU-C re-index window (and the later FU-B-2).
|
||||
* v5: `Route` node identity changed to `(method, url)` (#2289 — a same-URL
|
||||
* GET/POST pair is now two distinct Route nodes). Every declarative-route node
|
||||
* id moved from `Route:/x` to `Route:GET /x` (filesystem routes keep their
|
||||
* URL-only id). The incremental writeback preserves unchanged-file rows, so a
|
||||
* top-up against a pre-v5 index would strand old url-keyed Route nodes alongside
|
||||
* new composite-keyed ones — force a full re-analyze instead.
|
||||
*/
|
||||
export const INCREMENTAL_SCHEMA_VERSION = 4;
|
||||
export const INCREMENTAL_SCHEMA_VERSION = 5;
|
||||
|
||||
export interface IndexedRepo {
|
||||
repoPath: string;
|
||||
|
|
|
|||
7
gitnexus/test/fixtures/multi-verb-route-app/app/api/widgets/route.ts
vendored
Normal file
7
gitnexus/test/fixtures/multi-verb-route-app/app/api/widgets/route.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Next.js App Router filesystem route → /api/widgets (method-less identity).
|
||||
// Coexists with the Spring @GetMapping("/widgets") decorator route at the same
|
||||
// URL: the filesystem node keeps its URL-only id, the decorator node is keyed
|
||||
// `GET /api/widgets`.
|
||||
export async function GET() {
|
||||
return new Response('[]');
|
||||
}
|
||||
31
gitnexus/test/fixtures/multi-verb-route-app/src/main/java/com/example/ItemController.java
vendored
Normal file
31
gitnexus/test/fixtures/multi-verb-route-app/src/main/java/com/example/ItemController.java
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package com.example;
|
||||
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* Multi-verb route identity fixture (#2289).
|
||||
*
|
||||
* - GET /api/items and POST /api/items share a URL but are distinct
|
||||
* declarative routes → two Route nodes keyed `(method, url)`.
|
||||
* - GET /api/widgets overlaps a Next.js filesystem route at the same URL →
|
||||
* a method-keyed node coexisting with the URL-only filesystem node.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class ItemController {
|
||||
|
||||
@GetMapping("/items")
|
||||
public String listItems() {
|
||||
return "[]";
|
||||
}
|
||||
|
||||
@PostMapping("/items")
|
||||
public String createItem() {
|
||||
return "ok";
|
||||
}
|
||||
|
||||
@GetMapping("/widgets")
|
||||
public String getWidgets() {
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
17
gitnexus/test/fixtures/multi-verb-route-app/web/itemsClient.ts
vendored
Normal file
17
gitnexus/test/fixtures/multi-verb-route-app/web/itemsClient.ts
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Verb-less consumers: a fetch() call carries no statically-known HTTP method,
|
||||
// so each call matches by URL and must connect to EVERY Route node at that URL
|
||||
// (both GET /api/items and POST /api/items).
|
||||
export async function loadItems() {
|
||||
const res = await fetch('/api/items');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function addItem() {
|
||||
const res = await fetch('/api/items', { method: 'POST' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function loadWidgets() {
|
||||
const res = await fetch('/api/widgets');
|
||||
return res.json();
|
||||
}
|
||||
102
gitnexus/test/integration/multi-verb-route-identity.test.ts
Normal file
102
gitnexus/test/integration/multi-verb-route-identity.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* End-to-end coverage of multi-verb Route node identity (#2289).
|
||||
*
|
||||
* A declarative route's graph identity is `(method, url)`: a same-URL
|
||||
* `GET /x` + `POST /x` pair becomes TWO Route nodes (keyed `routeNodeKey`),
|
||||
* each carrying its own verb + resolved handler. Filesystem routes
|
||||
* (Next.js/Expo/PHP) have no structural verb, so they keep their URL-only id —
|
||||
* byte-identical to the pre-#2289 behavior — and coexist with a same-URL
|
||||
* decorator route as a separate node. A verb-less `fetch()` consumer matches by
|
||||
* URL and connects to EVERY Route node at that URL.
|
||||
*
|
||||
* Fixture: `test/fixtures/multi-verb-route-app/`
|
||||
* - ItemController.java: GET /api/items, POST /api/items, GET /api/widgets
|
||||
* - app/api/widgets/route.ts: Next.js filesystem route → /api/widgets
|
||||
* - web/itemsClient.ts: verb-less fetch() consumers of both URLs
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import { generateId } from '../../src/lib/utils.js';
|
||||
import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js';
|
||||
import type { PipelineResult } from '../../types/pipeline.js';
|
||||
|
||||
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'multi-verb-route-app');
|
||||
|
||||
const routeId = (method: string | undefined, url: string) =>
|
||||
generateId('Route', routeNodeKey(method, url));
|
||||
|
||||
describe('Multi-verb Route node identity (#2289)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(FIXTURE, () => {}, {});
|
||||
}, 60_000);
|
||||
|
||||
function routeNode(id: string) {
|
||||
return result.graph.getNode(id);
|
||||
}
|
||||
|
||||
it('splits a same-URL GET/POST pair into two distinct Route nodes', () => {
|
||||
const get = routeNode(routeId('GET', '/api/items'));
|
||||
const post = routeNode(routeId('POST', '/api/items'));
|
||||
|
||||
expect(get, 'GET /api/items Route node should exist').toBeTruthy();
|
||||
expect(post, 'POST /api/items Route node should exist').toBeTruthy();
|
||||
|
||||
// Both nodes keep the URL as their display name; the verb distinguishes them.
|
||||
expect(get!.properties.name).toBe('/api/items');
|
||||
expect(post!.properties.name).toBe('/api/items');
|
||||
expect(get!.properties.method).toBe('GET');
|
||||
expect(post!.properties.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('resolves each verb to its own handler symbol (re-keyed routeHandlerSymbols)', () => {
|
||||
const get = routeNode(routeId('GET', '/api/items'));
|
||||
const post = routeNode(routeId('POST', '/api/items'));
|
||||
|
||||
const getHandler = result.graph.getNode(String(get!.properties.handlerSymbolId));
|
||||
const postHandler = result.graph.getNode(String(post!.properties.handlerSymbolId));
|
||||
|
||||
expect(getHandler?.properties.name).toBe('listItems');
|
||||
expect(postHandler?.properties.name).toBe('createItem');
|
||||
});
|
||||
|
||||
it('keeps a filesystem route URL-only (byte-identical pre-#2289 id)', () => {
|
||||
const fsNode = routeNode(generateId('Route', '/api/widgets'));
|
||||
expect(fsNode, 'filesystem Route node /api/widgets should exist').toBeTruthy();
|
||||
expect(fsNode!.properties.name).toBe('/api/widgets');
|
||||
// Filesystem routes carry no structural verb.
|
||||
expect(fsNode!.properties.method).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets a filesystem route and a same-URL decorator route coexist as separate nodes', () => {
|
||||
const fsNode = routeNode(generateId('Route', '/api/widgets'));
|
||||
const decoratorNode = routeNode(routeId('GET', '/api/widgets'));
|
||||
|
||||
expect(fsNode, 'URL-only filesystem node').toBeTruthy();
|
||||
expect(decoratorNode, 'GET /api/widgets decorator node').toBeTruthy();
|
||||
// Distinct ids — no collision, no first-writer-wins eviction across keys.
|
||||
expect(fsNode!.id).not.toBe(decoratorNode!.id);
|
||||
expect(decoratorNode!.properties.method).toBe('GET');
|
||||
});
|
||||
|
||||
it('connects a verb-less fetch() consumer to every Route node at the URL', () => {
|
||||
const consumerFileId = generateId('File', 'web/itemsClient.ts');
|
||||
// Collect FETCHES targets without a test-level conditional: pass the
|
||||
// shape filter as a Set membership check on the relationship type +
|
||||
// sourceId, then materialize unique targetIds.
|
||||
const fetchTargets = new Set(
|
||||
result.graph.relationships
|
||||
.filter((r) => r.type === 'FETCHES' && r.sourceId === consumerFileId)
|
||||
.map((r) => r.targetId),
|
||||
);
|
||||
|
||||
// /api/items: the verb-less consumer reaches BOTH the GET and POST nodes.
|
||||
expect(fetchTargets.has(routeId('GET', '/api/items'))).toBe(true);
|
||||
expect(fetchTargets.has(routeId('POST', '/api/items'))).toBe(true);
|
||||
// /api/widgets: reaches both the filesystem node and the decorator node.
|
||||
expect(fetchTargets.has(generateId('Route', '/api/widgets'))).toBe(true);
|
||||
expect(fetchTargets.has(routeId('GET', '/api/widgets'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
FIXTURES,
|
||||
getRelationships,
|
||||
getNodesByLabel,
|
||||
getNodesByLabelFull,
|
||||
runPipelineFromRepo,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
|
@ -40,11 +41,15 @@ describe('Express/Hono route detection', () => {
|
|||
expect(itemsRoute!.sourceFilePath).toContain('app.js');
|
||||
});
|
||||
|
||||
it('detects multiple HTTP methods on same path as single route', () => {
|
||||
// /api/users has GET and POST but route registry deduplicates by path
|
||||
const routes = getNodesByLabel(result, 'Route');
|
||||
const usersRoutes = routes.filter((r) => r === '/api/users');
|
||||
expect(usersRoutes).toHaveLength(1);
|
||||
it('splits same-path GET/POST into one Route node per verb (#2289)', () => {
|
||||
// /api/users carries both GET and POST. Route identity is now `(method, url)`,
|
||||
// so the pair becomes TWO Route nodes — each keeps `/api/users` as its display
|
||||
// name and is distinguished by its `method` property. (Pre-#2289 the registry
|
||||
// deduplicated by URL and collapsed them into a single node.)
|
||||
const usersNodes = getNodesByLabelFull(result, 'Route').filter((n) => n.name === '/api/users');
|
||||
expect(usersNodes).toHaveLength(2);
|
||||
const methods = usersNodes.map((n) => n.properties.method).sort();
|
||||
expect(methods).toEqual(['GET', 'POST']);
|
||||
});
|
||||
|
||||
it('detects router.get() routes (not just app.get())', () => {
|
||||
|
|
|
|||
|
|
@ -19,18 +19,25 @@ import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
|||
import { buildTestGraph } from '../helpers/test-graph.js';
|
||||
import { streamAllCSVsToDisk } from '../../src/core/lbug/csv-generator.js';
|
||||
import { HANDLES_ROUTE_QUERY } from '../../src/core/group/extractors/http-route-extractor.js';
|
||||
import { generateId } from '../../src/lib/utils.js';
|
||||
import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js';
|
||||
|
||||
const HANDLER_UID = 'Method:OrderController.java:create';
|
||||
// Composite Route id — post-#2289 the routes phase emits this shape for a
|
||||
// method-bearing declarative route, so the CSV→COPY round-trip below
|
||||
// exercises both the new column AND the new id format.
|
||||
const ROUTE_ID = generateId('Route', routeNodeKey('POST', '/api/orders'));
|
||||
|
||||
withTestLbugDB('route-handler-symbol-roundtrip', (handle) => {
|
||||
it('persists Route.handlerSymbolId through CSV→COPY and HANDLES_ROUTE_QUERY returns it', async () => {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// 1. Route node carrying a resolved handlerSymbolId (what the routes phase
|
||||
// now stamps when resolveRouteHandlerSymbols resolves the handler).
|
||||
// 1. Route node carrying a resolved handlerSymbolId, keyed by the
|
||||
// composite `(method, url)` id the routes phase now stamps when
|
||||
// `resolveRouteHandlerSymbols` resolves the handler.
|
||||
const graph = buildTestGraph([
|
||||
{
|
||||
id: 'Route:/api/orders',
|
||||
id: ROUTE_ID,
|
||||
label: 'Route',
|
||||
name: '/api/orders',
|
||||
filePath: 'OrderController.java',
|
||||
|
|
@ -50,10 +57,12 @@ withTestLbugDB('route-handler-symbol-roundtrip', (handle) => {
|
|||
await fs.mkdir(repoDir, { recursive: true });
|
||||
await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||||
|
||||
// Sanity: route.csv header + row include the handlerSymbolId column/value.
|
||||
// Sanity: route.csv header + row include the handlerSymbolId column/value,
|
||||
// and the composite id round-trips into the CSV verbatim.
|
||||
const routeCsv = await fs.readFile(path.join(csvDir, 'route.csv'), 'utf-8');
|
||||
expect(routeCsv.split('\n')[0]).toContain('handlerSymbolId');
|
||||
expect(routeCsv).toContain(HANDLER_UID);
|
||||
expect(routeCsv).toContain(ROUTE_ID);
|
||||
|
||||
// 3. COPY the Route node via the production COPY query.
|
||||
const routeCsvPath = path.join(csvDir, 'route.csv').replace(/\\/g, '/');
|
||||
|
|
@ -64,7 +73,7 @@ withTestLbugDB('route-handler-symbol-roundtrip', (handle) => {
|
|||
`CREATE (:File {id: 'File:OrderController.java', name: 'OrderController.java', filePath: 'OrderController.java'})`,
|
||||
);
|
||||
await adapter.executeQuery(
|
||||
`MATCH (f:File {id: 'File:OrderController.java'}), (r:Route {id: 'Route:/api/orders'})
|
||||
`MATCH (f:File {id: 'File:OrderController.java'}), (r:Route {id: '${ROUTE_ID}'})
|
||||
CREATE (f)-[:CodeRelation {type: 'HANDLES_ROUTE', confidence: 1.0, reason: 'framework-route', step: 0}]->(r)`,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -24,16 +24,26 @@ import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
|||
import { buildTestGraph } from '../helpers/test-graph.js';
|
||||
import { streamAllCSVsToDisk } from '../../src/core/lbug/csv-generator.js';
|
||||
import { HANDLES_ROUTE_QUERY } from '../../src/core/group/extractors/http-route-extractor.js';
|
||||
import { generateId } from '../../src/lib/utils.js';
|
||||
import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js';
|
||||
|
||||
// Composite Route id — what the routes phase emits post-#2289 for a
|
||||
// method-bearing declarative route. Hand-pinning the pre-#2289 URL-only
|
||||
// `Route:/api/orders` shape would no longer cover the production
|
||||
// CSV→COPY→`HANDLES_ROUTE_QUERY` path the COPY query has to load.
|
||||
const ROUTE_ID = generateId('Route', routeNodeKey('POST', '/api/orders'));
|
||||
|
||||
withTestLbugDB('route-method-roundtrip', (handle) => {
|
||||
it('persists Route.method through CSV→COPY and HANDLES_ROUTE_QUERY returns it', async () => {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// 1. Build a graph with a single framework Route node carrying `method`,
|
||||
// mirroring what the routes phase now emits for a Spring controller.
|
||||
// keyed by the composite `(method, url)` id the routes phase now emits
|
||||
// so the CSV row + COPY load exercise the post-#2289 id shape (a value
|
||||
// containing a literal space — `Route:POST /api/orders`).
|
||||
const graph = buildTestGraph([
|
||||
{
|
||||
id: 'Route:/api/orders',
|
||||
id: ROUTE_ID,
|
||||
label: 'Route',
|
||||
name: '/api/orders',
|
||||
filePath: 'OrderController.java',
|
||||
|
|
@ -53,10 +63,13 @@ withTestLbugDB('route-method-roundtrip', (handle) => {
|
|||
await fs.mkdir(repoDir, { recursive: true });
|
||||
await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||||
|
||||
// Sanity: the generated route.csv header + row include the method column.
|
||||
// Sanity: the generated route.csv header + row include the method column,
|
||||
// and the composite id (with its literal space) round-trips into the CSV
|
||||
// — a space-in-id COPY failure on the new id format would surface here.
|
||||
const routeCsv = await fs.readFile(path.join(csvDir, 'route.csv'), 'utf-8');
|
||||
expect(routeCsv.split('\n')[0]).toContain('method');
|
||||
expect(routeCsv).toContain('POST');
|
||||
expect(routeCsv).toContain(ROUTE_ID);
|
||||
|
||||
// 3. COPY the Route node into the real DB via the production COPY query
|
||||
// (exercises the new `method` column in getCopyQuery('Route')).
|
||||
|
|
@ -68,7 +81,7 @@ withTestLbugDB('route-method-roundtrip', (handle) => {
|
|||
`CREATE (:File {id: 'File:OrderController.java', name: 'OrderController.java', filePath: 'OrderController.java'})`,
|
||||
);
|
||||
await adapter.executeQuery(
|
||||
`MATCH (f:File {id: 'File:OrderController.java'}), (r:Route {id: 'Route:/api/orders'})
|
||||
`MATCH (f:File {id: 'File:OrderController.java'}), (r:Route {id: '${ROUTE_ID}'})
|
||||
CREATE (f)-[:CodeRelation {type: 'HANDLES_ROUTE', confidence: 1.0, reason: 'framework-route', step: 0}]->(r)`,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@ import path from 'path';
|
|||
import {
|
||||
extractTemplateStaticFetchCalls,
|
||||
isTemplateRouteCandidate,
|
||||
normalizeExtractedRoutePath,
|
||||
routesPhase,
|
||||
} from '../../src/core/ingestion/pipeline-phases/routes.js';
|
||||
import {
|
||||
normalizeExtractedRoutePath,
|
||||
routeNodeKey,
|
||||
} from '../../src/core/ingestion/route-extractors/route-path.js';
|
||||
import type { ParseOutput } from '../../src/core/ingestion/pipeline-phases/parse.js';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import { generateId } from '../../src/lib/utils.js';
|
||||
|
|
@ -144,9 +147,12 @@ describe('Blade/template static route extraction', () => {
|
|||
new Map([['parse', { phaseName: 'parse', output: parseOutput, durationMs: 0 }]]),
|
||||
);
|
||||
|
||||
expect(output.routeRegistry.get('/admin/orders')).toEqual({
|
||||
// The registry is keyed by the `(method, url)` identity (#2289); the URL
|
||||
// is carried on the entry's `url` field (the Route node's display name).
|
||||
expect(output.routeRegistry.get(routeNodeKey('POST', '/admin/orders'))).toEqual({
|
||||
filePath: 'routes/web.php',
|
||||
source: 'framework-route',
|
||||
url: '/admin/orders',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -73,21 +73,25 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => {
|
|||
});
|
||||
|
||||
describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
|
||||
it('INCREMENTAL_SCHEMA_VERSION is bumped to 4 (CALL_SUMMARY re-index window)', () => {
|
||||
expect(INCREMENTAL_SCHEMA_VERSION).toBe(4);
|
||||
it('INCREMENTAL_SCHEMA_VERSION is bumped to 5 (multi-verb Route identity re-index window)', () => {
|
||||
expect(INCREMENTAL_SCHEMA_VERSION).toBe(5);
|
||||
});
|
||||
|
||||
it('a pre-v4 (v3) stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
|
||||
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
|
||||
// The reuse gate at run-analyze.ts:920 is exactly this strict equality on
|
||||
// the persisted `existingMeta.schemaVersion` (a plain number, possibly
|
||||
// absent on a legacy stamp). Replicate it as a typed predicate.
|
||||
const passesReuseGate = (stampedSchemaVersion: number | undefined): boolean =>
|
||||
stampedSchemaVersion === INCREMENTAL_SCHEMA_VERSION;
|
||||
// A pre-v4 (v3) index has no CALL_SUMMARY edges → must NOT reuse → full re-analyze.
|
||||
// A pre-v4 (v3) index has no CALL_SUMMARY edges → must NOT reuse.
|
||||
expect(passesReuseGate(3)).toBe(false);
|
||||
// A pre-v5 (v4) index predates the multi-verb Route identity change → its
|
||||
// persisted Route nodes use the old url-only ids, so an incremental top-up
|
||||
// would strand them → must NOT reuse.
|
||||
expect(passesReuseGate(4)).toBe(false);
|
||||
// A legacy stamp with no schemaVersion at all is likewise rejected.
|
||||
expect(passesReuseGate(undefined)).toBe(false);
|
||||
// A current-version stamp passes the gate (incremental top-up eligible).
|
||||
expect(passesReuseGate(4)).toBe(true);
|
||||
expect(passesReuseGate(5)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -224,4 +224,45 @@ describe('runFullAnalysis — incremental orchestration', () => {
|
|||
await repo.cleanup();
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
// Regression for #2289 review P1: a pre-v5 stamp (e.g. v4 with url-only
|
||||
// Route ids) re-analyzed on the SAME commit must NOT early-return on the
|
||||
// `alreadyUpToDate` fast path — otherwise the v5 schema bump's
|
||||
// re-keyed-Route migration is silently bypassed and stale URL-only Route
|
||||
// rows persist alongside any new composite-keyed writes. The schemaVersion
|
||||
// gate (mirrors pdgModeMismatch's slot above the fast path) must force a
|
||||
// full rebuild before lastCommit-equality short-circuits the pipeline.
|
||||
it('a pre-v5 schemaVersion stamp forces a full rebuild on an unchanged-commit re-analyze', async () => {
|
||||
const repo = await setupMiniRepo();
|
||||
try {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
// First run stamps schemaVersion = INCREMENTAL_SCHEMA_VERSION (v5).
|
||||
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
||||
const { storagePath } = getStoragePaths(repo.dbPath);
|
||||
const meta = await loadMeta(storagePath);
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta!.schemaVersion).toBe(INCREMENTAL_SCHEMA_VERSION);
|
||||
|
||||
// Simulate a repo indexed at the SAME commit by a pre-v5 GitNexus
|
||||
// build: rewrite meta.json with schemaVersion = 4. lastCommit and
|
||||
// working tree are untouched, so without the schemaVersion gate the
|
||||
// run-analyze fast path would early-return `alreadyUpToDate=true`
|
||||
// and never touch the stale Route rows.
|
||||
const downgraded: RepoMeta = { ...meta!, schemaVersion: 4 };
|
||||
await saveMeta(storagePath, downgraded);
|
||||
|
||||
const reanalyzed = await runFullAnalysis(
|
||||
repo.dbPath,
|
||||
{ skipAgentsMd: true },
|
||||
{ onProgress: () => {} },
|
||||
);
|
||||
// Pipeline actually ran (schemaVersion mismatch → force=true).
|
||||
expect(reanalyzed.alreadyUpToDate).toBeUndefined();
|
||||
// And the meta is stamped back to v5 (the rebuild path runs saveMeta).
|
||||
const restamped = await loadMeta(storagePath);
|
||||
expect(restamped!.schemaVersion).toBe(INCREMENTAL_SCHEMA_VERSION);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
}, 300_000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,15 +4,18 @@
|
|||
* Pins the P2 fixes from the review:
|
||||
* - ambiguity → fail-open: a same-name lookup returning ≠1 yields NO
|
||||
* handlerSymbolId (never an arbitrary `[0]` guess).
|
||||
* - first-writer-wins reservation: the first route to claim a URL reserves it
|
||||
* even when its handler is unresolvable, so a later same-URL route can't
|
||||
* stamp its handler onto the (node-winning) first route's slot.
|
||||
* - happy path: a uniquely-resolvable handler is stamped, keyed by the
|
||||
* normalized URL.
|
||||
* - first-writer-wins reservation: the first route to claim a route identity
|
||||
* reserves it even when its handler is unresolvable, so a later same-identity
|
||||
* route can't stamp its handler onto the (node-winning) first route's slot.
|
||||
* - happy path: a uniquely-resolvable handler is stamped, keyed by the route's
|
||||
* `(method, url)` identity (`routeNodeKey`).
|
||||
* - multi-verb identity (#2289): `GET /x` and `POST /x` are distinct keys, so
|
||||
* each verb's handler is resolved independently.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createSemanticModel } from '../../src/core/ingestion/model/index.js';
|
||||
import { resolveRouteHandlerSymbols } from '../../src/core/ingestion/call-processor.js';
|
||||
import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js';
|
||||
import type { ExtractedDecoratorRoute } from '../../src/core/ingestion/workers/parse-worker.js';
|
||||
import type { ExtractedRoute } from '../../src/core/ingestion/route-extractors/laravel.js';
|
||||
|
||||
|
|
@ -31,13 +34,15 @@ function decoratorRoute(overrides: Partial<ExtractedDecoratorRoute> = {}): Extra
|
|||
}
|
||||
|
||||
describe('resolveRouteHandlerSymbols — decorator routes', () => {
|
||||
it('uniquely-resolvable handler is stamped, keyed by normalized URL', () => {
|
||||
const GET_ORDERS = routeNodeKey('GET', '/orders');
|
||||
|
||||
it('uniquely-resolvable handler is stamped, keyed by (method, url) identity', () => {
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add(FILE, 'list', 'method:OrderController.list', 'Method');
|
||||
|
||||
const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute()]);
|
||||
|
||||
expect(out.get('/orders')).toBe('method:OrderController.list');
|
||||
expect(out.get(GET_ORDERS)).toBe('method:OrderController.list');
|
||||
});
|
||||
|
||||
it('ambiguous same-name handler (overloads) → fail-open, no stamp', () => {
|
||||
|
|
@ -48,7 +53,7 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => {
|
|||
|
||||
const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute()]);
|
||||
|
||||
expect(out.has('/orders')).toBe(false);
|
||||
expect(out.has(GET_ORDERS)).toBe(false);
|
||||
});
|
||||
|
||||
it('unknown handler name → fail-open, no stamp', () => {
|
||||
|
|
@ -56,10 +61,10 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => {
|
|||
|
||||
const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute({ handlerName: 'ghost' })]);
|
||||
|
||||
expect(out.has('/orders')).toBe(false);
|
||||
expect(out.has(GET_ORDERS)).toBe(false);
|
||||
});
|
||||
|
||||
it('same-URL collision: an unresolvable first route reserves the slot so a later resolvable route cannot stamp it', () => {
|
||||
it('same-identity collision: an unresolvable first route reserves the slot so a later resolvable route cannot stamp it', () => {
|
||||
const model = createSemanticModel();
|
||||
// Only the SECOND route's handler exists in the model.
|
||||
model.symbols.add(FILE, 'second', 'method:OrderController.second', 'Method');
|
||||
|
|
@ -68,20 +73,20 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => {
|
|||
model,
|
||||
[],
|
||||
[
|
||||
// First route at /orders is unresolvable (no such symbol) — but it is the
|
||||
// route the routes phase makes the Route-node winner, so its slot must be
|
||||
// reserved (empty), NOT filled by the later same-URL route.
|
||||
// First route at GET /orders is unresolvable (no such symbol) — but it is
|
||||
// the route the routes phase makes the Route-node winner, so its slot must
|
||||
// be reserved (empty), NOT filled by the later same-identity route.
|
||||
decoratorRoute({ handlerName: 'first_missing' }),
|
||||
decoratorRoute({ handlerName: 'second' }),
|
||||
],
|
||||
);
|
||||
|
||||
// Reservation holds: the URL carries no (wrong) handler. Pre-fix this would
|
||||
// have stamped `method:OrderController.second` onto the first route's node.
|
||||
expect(out.has('/orders')).toBe(false);
|
||||
// Reservation holds: the identity carries no (wrong) handler. Pre-fix this
|
||||
// would have stamped `method:OrderController.second` onto the first node.
|
||||
expect(out.has(GET_ORDERS)).toBe(false);
|
||||
});
|
||||
|
||||
it('first-writer-wins among resolvable same-URL routes', () => {
|
||||
it('first-writer-wins among resolvable same-identity routes', () => {
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add(FILE, 'winner', 'method:OrderController.winner', 'Method');
|
||||
model.symbols.add(FILE, 'loser', 'method:OrderController.loser', 'Method');
|
||||
|
|
@ -92,7 +97,27 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => {
|
|||
[decoratorRoute({ handlerName: 'winner' }), decoratorRoute({ handlerName: 'loser' })],
|
||||
);
|
||||
|
||||
expect(out.get('/orders')).toBe('method:OrderController.winner');
|
||||
expect(out.get(GET_ORDERS)).toBe('method:OrderController.winner');
|
||||
});
|
||||
|
||||
it('multi-verb same URL (#2289): GET /orders and POST /orders resolve to distinct keys', () => {
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add(FILE, 'list', 'method:OrderController.list', 'Method');
|
||||
model.symbols.add(FILE, 'create', 'method:OrderController.create', 'Method');
|
||||
|
||||
const out = resolveRouteHandlerSymbols(
|
||||
model,
|
||||
[],
|
||||
[
|
||||
decoratorRoute({ httpMethod: 'GET', handlerName: 'list' }),
|
||||
decoratorRoute({ httpMethod: 'POST', decoratorName: 'PostMapping', handlerName: 'create' }),
|
||||
],
|
||||
);
|
||||
|
||||
// Two independent identities — neither evicts the other (the pre-#2289
|
||||
// URL-only key would have dropped POST /orders as a duplicate of GET /orders).
|
||||
expect(out.get(routeNodeKey('GET', '/orders'))).toBe('method:OrderController.list');
|
||||
expect(out.get(routeNodeKey('POST', '/orders'))).toBe('method:OrderController.create');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -123,7 +148,7 @@ describe('resolveRouteHandlerSymbols — Laravel framework routes', () => {
|
|||
|
||||
const out = resolveRouteHandlerSymbols(model, [laravelRoute()], []);
|
||||
|
||||
expect(out.get('/orders')).toBe('method:OrderController.index');
|
||||
expect(out.get(routeNodeKey('GET', '/orders'))).toBe('method:OrderController.index');
|
||||
});
|
||||
|
||||
it('ambiguous controller short-name (>1) → fail-open, no stamp', () => {
|
||||
|
|
@ -143,6 +168,6 @@ describe('resolveRouteHandlerSymbols — Laravel framework routes', () => {
|
|||
|
||||
const out = resolveRouteHandlerSymbols(model, [laravelRoute()], []);
|
||||
|
||||
expect(out.has('/orders')).toBe(false);
|
||||
expect(out.has(routeNodeKey('GET', '/orders'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
279
gitnexus/test/unit/route-process-linking.test.ts
Normal file
279
gitnexus/test/unit/route-process-linking.test.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/**
|
||||
* Unit coverage for ENTRY_POINT_OF re-keying (#2289).
|
||||
*
|
||||
* The processes phase links a Route node to the execution flow rooted at its
|
||||
* handler file. After the multi-verb identity change the edge must target the
|
||||
* `(method, url)` node id (`routeNodeKey`), not the bare URL — so a same-URL
|
||||
* GET/POST pair produces TWO distinct ENTRY_POINT_OF edges, one per verb node.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import { processesPhase } from '../../src/core/ingestion/pipeline-phases/processes.js';
|
||||
import { generateId } from '../../src/lib/utils.js';
|
||||
import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js';
|
||||
import type {
|
||||
PhaseResult,
|
||||
PipelineContext,
|
||||
} from '../../src/core/ingestion/pipeline-phases/types.js';
|
||||
import type { KnowledgeGraph } from '../../src/core/graph/types.js';
|
||||
import type { GraphNode, GraphRelationship, NodeLabel } from 'gitnexus-shared';
|
||||
|
||||
function makeCtx(graph: KnowledgeGraph, repoPath = 'D:/tmp/repo'): PipelineContext {
|
||||
return { repoPath, graph, onProgress: () => {}, pipelineStart: 0 };
|
||||
}
|
||||
|
||||
function phaseResult<T>(phaseName: string, output: T): PhaseResult<T> {
|
||||
return { phaseName, output, durationMs: 0 };
|
||||
}
|
||||
|
||||
function addNode(
|
||||
graph: KnowledgeGraph,
|
||||
id: string,
|
||||
label: NodeLabel,
|
||||
name: string,
|
||||
filePath: string,
|
||||
) {
|
||||
graph.addNode({
|
||||
id,
|
||||
label,
|
||||
properties: { name, filePath, startLine: 1, endLine: 1, isExported: true, content: '' },
|
||||
} satisfies GraphNode);
|
||||
}
|
||||
|
||||
function addCall(graph: KnowledgeGraph, sourceId: string, targetId: string) {
|
||||
graph.addRelationship({
|
||||
id: `${sourceId}->${targetId}`,
|
||||
sourceId,
|
||||
targetId,
|
||||
type: 'CALLS',
|
||||
confidence: 1,
|
||||
reason: 'direct',
|
||||
} satisfies GraphRelationship);
|
||||
}
|
||||
|
||||
// Mirror what `routes.ts` puts on the graph: a Route node carries
|
||||
// `handlerSymbolId` in its properties when `routeHandlerSymbols` could
|
||||
// resolve the handler. processes.ts now reads that field off the graph
|
||||
// node (#2289 review P2), so multi-verb tests must seed it here.
|
||||
function addRouteNode(
|
||||
graph: KnowledgeGraph,
|
||||
routeKey: string,
|
||||
url: string,
|
||||
filePath: string,
|
||||
method: string,
|
||||
handlerSymbolId: string,
|
||||
) {
|
||||
graph.addNode({
|
||||
id: generateId('Route', routeKey),
|
||||
label: 'Route',
|
||||
properties: { name: url, filePath, method, handlerSymbolId },
|
||||
} as GraphNode);
|
||||
}
|
||||
|
||||
describe('Route → process linking (ENTRY_POINT_OF) re-keying', () => {
|
||||
it('emits one ENTRY_POINT_OF edge per verb node for a same-URL GET/POST pair', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const filePath = 'OrderController.java';
|
||||
const entry = 'Function:OrderController.listOrders';
|
||||
const helper = 'Function:OrderController.helper';
|
||||
const leaf = 'Function:OrderController.leaf';
|
||||
|
||||
addNode(graph, generateId('File', filePath), 'File', 'OrderController.java', filePath);
|
||||
addNode(graph, entry, 'Function', 'listOrders', filePath);
|
||||
addNode(graph, helper, 'Function', 'helper', filePath);
|
||||
addNode(graph, leaf, 'Function', 'leaf', filePath);
|
||||
// 3-step call chain rooted in the handler file → forms a process.
|
||||
addCall(graph, entry, helper);
|
||||
addCall(graph, helper, leaf);
|
||||
|
||||
// Two routes sharing /orders, distinct verbs (mirrors the routes phase
|
||||
// registry shape: keyed by routeNodeKey, each entry carries url + method).
|
||||
const routeRegistry = new Map([
|
||||
[
|
||||
routeNodeKey('GET', '/orders'),
|
||||
{ filePath, source: 'decorator-GetMapping', url: '/orders', method: 'GET' },
|
||||
],
|
||||
[
|
||||
routeNodeKey('POST', '/orders'),
|
||||
{ filePath, source: 'decorator-PostMapping', url: '/orders', method: 'POST' },
|
||||
],
|
||||
]);
|
||||
|
||||
await processesPhase.execute(
|
||||
makeCtx(graph),
|
||||
new Map([
|
||||
['structure', phaseResult('structure', { totalFiles: 1 })],
|
||||
['communities', phaseResult('communities', { communityResult: { memberships: [] } })],
|
||||
['routes', phaseResult('routes', { routeRegistry })],
|
||||
['tools', phaseResult('tools', { toolDefs: [] })],
|
||||
]),
|
||||
);
|
||||
|
||||
// ENTRY_POINT_OF edges whose target is a Process. The processes phase emits
|
||||
// these by Route node id (it does not require the Route node to pre-exist);
|
||||
// toolDefs is empty here, so every such edge is a route→process link.
|
||||
const routeEntryEdges = graph.relationships.filter(
|
||||
(r) => r.type === 'ENTRY_POINT_OF' && graph.getNode(r.targetId)?.label === 'Process',
|
||||
);
|
||||
const sources = new Set(routeEntryEdges.map((r) => r.sourceId));
|
||||
|
||||
// Both composite-keyed Route nodes anchor the flow — not the bare `Route:/orders`.
|
||||
expect(sources.has(generateId('Route', routeNodeKey('GET', '/orders')))).toBe(true);
|
||||
expect(sources.has(generateId('Route', routeNodeKey('POST', '/orders')))).toBe(true);
|
||||
// The pre-#2289 URL-only id must NOT be used.
|
||||
expect(sources.has(generateId('Route', '/orders'))).toBe(false);
|
||||
});
|
||||
|
||||
// Regression for #2289 review P2 (weak form): pre-fix the linker built
|
||||
// `routesByFile` and fanned every same-file Route to every same-file
|
||||
// process, so a same-file `GET /items` + `POST /items` pair where ONLY
|
||||
// `listItems` has a detected process would still attach BOTH Route nodes
|
||||
// to that single process. Post-fix `routesByHandlerId` keys by the Route
|
||||
// node's `handlerSymbolId` (read from graph properties) and only the
|
||||
// verb whose handler is the process's entryPoint links — the other verb
|
||||
// (with no detected process for its handler) links to nothing.
|
||||
it('does not cross-wire same-file sibling verbs when only one handler has a detected process', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const filePath = 'ItemController.java';
|
||||
const listItems = 'Function:ItemController.listItems';
|
||||
const createItem = 'Function:ItemController.createItem';
|
||||
const helper = 'Function:ItemController.helper';
|
||||
const leaf = 'Function:ItemController.leaf';
|
||||
|
||||
addNode(graph, generateId('File', filePath), 'File', 'ItemController.java', filePath);
|
||||
addNode(graph, listItems, 'Function', 'listItems', filePath);
|
||||
addNode(graph, createItem, 'Function', 'createItem', filePath);
|
||||
addNode(graph, helper, 'Function', 'helper', filePath);
|
||||
addNode(graph, leaf, 'Function', 'leaf', filePath);
|
||||
// Only `listItems` has a 3-step chain → forms a process whose entryPoint
|
||||
// is `listItems`. `createItem` has no calls, so no process is rooted there.
|
||||
addCall(graph, listItems, helper);
|
||||
addCall(graph, helper, leaf);
|
||||
|
||||
// Both routes share /items, distinct verbs. The registry carries
|
||||
// url + method only; `handlerSymbolId` lives on the Route graph node
|
||||
// (mirrors what routes.ts does — see addRouteNode below).
|
||||
const routeRegistry = new Map([
|
||||
[
|
||||
routeNodeKey('GET', '/items'),
|
||||
{ filePath, source: 'decorator-GetMapping', url: '/items', method: 'GET' },
|
||||
],
|
||||
[
|
||||
routeNodeKey('POST', '/items'),
|
||||
{ filePath, source: 'decorator-PostMapping', url: '/items', method: 'POST' },
|
||||
],
|
||||
]);
|
||||
addRouteNode(graph, routeNodeKey('GET', '/items'), '/items', filePath, 'GET', listItems);
|
||||
addRouteNode(graph, routeNodeKey('POST', '/items'), '/items', filePath, 'POST', createItem);
|
||||
|
||||
await processesPhase.execute(
|
||||
makeCtx(graph),
|
||||
new Map([
|
||||
['structure', phaseResult('structure', { totalFiles: 1 })],
|
||||
['communities', phaseResult('communities', { communityResult: { memberships: [] } })],
|
||||
['routes', phaseResult('routes', { routeRegistry })],
|
||||
['tools', phaseResult('tools', { toolDefs: [] })],
|
||||
]),
|
||||
);
|
||||
|
||||
const routeEntryEdges = graph.relationships.filter(
|
||||
(r) => r.type === 'ENTRY_POINT_OF' && graph.getNode(r.targetId)?.label === 'Process',
|
||||
);
|
||||
const sources = new Set(routeEntryEdges.map((r) => r.sourceId));
|
||||
const getNodeId = generateId('Route', routeNodeKey('GET', '/items'));
|
||||
const postNodeId = generateId('Route', routeNodeKey('POST', '/items'));
|
||||
|
||||
// GET → listItems process: the only handler-matched link that should fire.
|
||||
expect(sources.has(getNodeId)).toBe(true);
|
||||
// POST's handlerSymbolId (createItem on the POST Route node) has no
|
||||
// matching process, so POST must link to NOTHING. Pre-fix this would
|
||||
// have wrongly attached POST to the listItems process via filePath
|
||||
// fan-out.
|
||||
expect(sources.has(postNodeId)).toBe(false);
|
||||
});
|
||||
|
||||
// Regression for #2289 review P2 (strong form, mirrors reviewer's exact
|
||||
// trigger): "one controller file with `GET /items -> listItems()` and
|
||||
// `POST /items -> createItem()`, each with its own detected process."
|
||||
// Pre-fix `routesByFile` collapses both routes under `ItemController.java`
|
||||
// and links every routeKey to every process whose entry is in that file —
|
||||
// producing the 4-edge cross-wire (GET→listItemsProc, GET→createItemProc,
|
||||
// POST→listItemsProc, POST→createItemProc). Post-fix only the 2 matched
|
||||
// edges fire (GET→listItemsProc, POST→createItemProc), and route_map /
|
||||
// impact can no longer attribute the POST flow to GET or vice versa.
|
||||
it('links each verb to ONLY its own per-handler process when both handlers form distinct processes', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const filePath = 'ItemController.java';
|
||||
const listItems = 'Function:ItemController.listItems';
|
||||
const createItem = 'Function:ItemController.createItem';
|
||||
const listHelper = 'Function:ItemController.listHelper';
|
||||
const listLeaf = 'Function:ItemController.listLeaf';
|
||||
const createHelper = 'Function:ItemController.createHelper';
|
||||
const createLeaf = 'Function:ItemController.createLeaf';
|
||||
|
||||
addNode(graph, generateId('File', filePath), 'File', 'ItemController.java', filePath);
|
||||
addNode(graph, listItems, 'Function', 'listItems', filePath);
|
||||
addNode(graph, createItem, 'Function', 'createItem', filePath);
|
||||
addNode(graph, listHelper, 'Function', 'listHelper', filePath);
|
||||
addNode(graph, listLeaf, 'Function', 'listLeaf', filePath);
|
||||
addNode(graph, createHelper, 'Function', 'createHelper', filePath);
|
||||
addNode(graph, createLeaf, 'Function', 'createLeaf', filePath);
|
||||
// Two independent 3-step chains → two processes, one per verb's handler.
|
||||
addCall(graph, listItems, listHelper);
|
||||
addCall(graph, listHelper, listLeaf);
|
||||
addCall(graph, createItem, createHelper);
|
||||
addCall(graph, createHelper, createLeaf);
|
||||
|
||||
const routeRegistry = new Map([
|
||||
[
|
||||
routeNodeKey('GET', '/items'),
|
||||
{ filePath, source: 'decorator-GetMapping', url: '/items', method: 'GET' },
|
||||
],
|
||||
[
|
||||
routeNodeKey('POST', '/items'),
|
||||
{ filePath, source: 'decorator-PostMapping', url: '/items', method: 'POST' },
|
||||
],
|
||||
]);
|
||||
addRouteNode(graph, routeNodeKey('GET', '/items'), '/items', filePath, 'GET', listItems);
|
||||
addRouteNode(graph, routeNodeKey('POST', '/items'), '/items', filePath, 'POST', createItem);
|
||||
|
||||
await processesPhase.execute(
|
||||
makeCtx(graph),
|
||||
new Map([
|
||||
['structure', phaseResult('structure', { totalFiles: 1 })],
|
||||
['communities', phaseResult('communities', { communityResult: { memberships: [] } })],
|
||||
['routes', phaseResult('routes', { routeRegistry })],
|
||||
['tools', phaseResult('tools', { toolDefs: [] })],
|
||||
]),
|
||||
);
|
||||
|
||||
const getNodeId = generateId('Route', routeNodeKey('GET', '/items'));
|
||||
const postNodeId = generateId('Route', routeNodeKey('POST', '/items'));
|
||||
|
||||
// Resolve each Route → its linked processes' entryPointIds (not the
|
||||
// synthetic Process ids), so verb-precision can be asserted without
|
||||
// test-level conditionals: filter on edge shape, then map to the
|
||||
// target Process node's entryPointId, then bucket by sourceId.
|
||||
const routeProcessEdges = graph.relationships.filter(
|
||||
(r) =>
|
||||
r.type === 'ENTRY_POINT_OF' &&
|
||||
(r.sourceId === getNodeId || r.sourceId === postNodeId) &&
|
||||
graph.getNode(r.targetId)?.label === 'Process',
|
||||
);
|
||||
const entriesByRoute = new Map<string, Set<string>>(
|
||||
routeProcessEdges.map((r) => [
|
||||
r.sourceId,
|
||||
new Set([String(graph.getNode(r.targetId)?.properties.entryPointId ?? '')]),
|
||||
]),
|
||||
);
|
||||
// (Each route here has exactly one matched process, so the Map's
|
||||
// last-writer-wins is fine; if the count grows, switch to a reducer.)
|
||||
|
||||
// GET Route links to EXACTLY the listItems-rooted process — not createItem's.
|
||||
expect(entriesByRoute.get(getNodeId)).toEqual(new Set([listItems]));
|
||||
// POST Route links to EXACTLY the createItem-rooted process — not listItems's.
|
||||
expect(entriesByRoute.get(postNodeId)).toEqual(new Set([createItem]));
|
||||
// Total route→process edges: 2 (one per verb), not the pre-fix 4-edge cross-wire.
|
||||
expect(routeProcessEdges.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,7 +7,12 @@ import {
|
|||
deriveEmbeddingCap,
|
||||
DEFAULT_EMBEDDING_NODE_LIMIT,
|
||||
} from '../../src/core/embedding-mode.js';
|
||||
import { getStoragePaths, saveMeta, type RepoMeta } from '../../src/storage/repo-manager.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
saveMeta,
|
||||
INCREMENTAL_SCHEMA_VERSION,
|
||||
type RepoMeta,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import { taintModelVersion } from '../../src/core/ingestion/taint/typescript-model.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
|
|
@ -40,6 +45,10 @@ describe('run-analyze module', () => {
|
|||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: currentCommit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
// Stamp current schema version so the run-analyze schema-mismatch
|
||||
// guard (#2289 P1) does not force a rebuild and short-circuit the
|
||||
// alreadyUpToDate fast path this test exercises.
|
||||
schemaVersion: INCREMENTAL_SCHEMA_VERSION,
|
||||
};
|
||||
await saveMeta(storagePath, meta);
|
||||
|
||||
|
|
@ -79,12 +88,16 @@ describe('run-analyze module', () => {
|
|||
}).trim();
|
||||
|
||||
// Flat slot owned by main; feature/x has its own up-to-date branch index.
|
||||
// Both metas stamp the current schema version so the run-analyze
|
||||
// schema-mismatch guard (#2289 P1) does not force a rebuild before the
|
||||
// fast path runs.
|
||||
const flat = getStoragePaths(tmpRepo.dbPath);
|
||||
await saveMeta(flat.storagePath, {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: commit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
branch: 'main',
|
||||
schemaVersion: INCREMENTAL_SCHEMA_VERSION,
|
||||
});
|
||||
const branch = getStoragePaths(tmpRepo.dbPath, 'feature/x');
|
||||
await saveMeta(path.dirname(branch.metaPath), {
|
||||
|
|
@ -92,6 +105,7 @@ describe('run-analyze module', () => {
|
|||
lastCommit: commit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
branch: 'feature/x',
|
||||
schemaVersion: INCREMENTAL_SCHEMA_VERSION,
|
||||
});
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import Parser from 'tree-sitter';
|
|||
import Java from 'tree-sitter-java';
|
||||
import { extractSpringRoutes } from '../../src/core/ingestion/route-extractors/spring.js';
|
||||
import { JAVA_HTTP_PLUGIN } from '../../src/core/group/extractors/http-patterns/java.js';
|
||||
import { normalizeExtractedRoutePath } from '../../src/core/ingestion/pipeline-phases/routes.js';
|
||||
import { normalizeExtractedRoutePath } from '../../src/core/ingestion/route-extractors/route-path.js';
|
||||
|
||||
function parse(code: string): Parser.Tree {
|
||||
const parser = new Parser();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue