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>
279 lines
12 KiB
TypeScript
279 lines
12 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|