gitnexus 1.6.9-rc.21 → 1.6.9-rc.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/group/extractors/http-patterns/java.js +10 -10
- package/dist/core/group/extractors/manifest-extractor.js +6 -4
- package/dist/core/ingestion/call-processor.d.ts +26 -14
- package/dist/core/ingestion/call-processor.js +55 -37
- package/dist/core/ingestion/pipeline-phases/processes.js +37 -9
- package/dist/core/ingestion/pipeline-phases/routes.d.ts +7 -3
- package/dist/core/ingestion/pipeline-phases/routes.js +39 -51
- package/dist/core/ingestion/route-extractors/route-path.d.ts +28 -4
- package/dist/core/ingestion/route-extractors/route-path.js +50 -4
- package/dist/core/run-analyze.js +35 -0
- package/dist/core/search/fts-indexes.js +35 -19
- package/dist/core/search/fts-schema.js +37 -4
- package/dist/storage/repo-manager.d.ts +8 -2
- package/dist/storage/repo-manager.js +8 -2
- package/package.json +1 -1
- package/scripts/cross-platform-tests.ts +1 -0
|
@@ -576,16 +576,16 @@ export const JAVA_HTTP_PLUGIN = {
|
|
|
576
576
|
language: Java,
|
|
577
577
|
// routeCoverage intentionally LEFT at the default 'partial' (#2138 Part 2).
|
|
578
578
|
// The graph provider set is a strict *subset* of this scan()'s provider set —
|
|
579
|
-
// ingestion does NOT emit a Route node for
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
// emitted — see #2280)
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
// here would let the parse-skip drop
|
|
586
|
-
// Java flips to 'complete' only once
|
|
587
|
-
// scan
|
|
588
|
-
//
|
|
579
|
+
// ingestion does NOT emit a Route node for a method-level array route nested
|
|
580
|
+
// under a class-level array-form `@RequestMapping` (ingestion suppresses it
|
|
581
|
+
// rather than drop the prefix; bare/scalar-prefixed array methods ARE now
|
|
582
|
+
// emitted — see #2280). Interface-inherited Spring routes ARE now emitted by
|
|
583
|
+
// ingestion (#2288), and same-URL multi-verb routes are now per-`(method,url)`
|
|
584
|
+
// Route nodes (#2289), so they are no longer coverage gaps. Declaring
|
|
585
|
+
// 'complete' here would let the parse-skip drop the remaining group-only
|
|
586
|
+
// providers (the array-prefix gap above). Java flips to 'complete' only once
|
|
587
|
+
// ingestion provider extraction matches this scan — class-level array-form
|
|
588
|
+
// prefix support is the final follow-up tracked in #2280.
|
|
589
589
|
// `hasConsumerSignals` below is kept ready for that flip.
|
|
590
590
|
// Consumer signals this plugin's scan() can detect: RestTemplate / WebClient /
|
|
591
591
|
// OkHttp / Java-HttpClient / Apache-HttpClient call sites, OpenFeign
|
|
@@ -151,10 +151,12 @@ export class ManifestExtractor {
|
|
|
151
151
|
try {
|
|
152
152
|
let rows;
|
|
153
153
|
if (link.type === 'http') {
|
|
154
|
-
// Route.name is the canonicalized URL path
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
154
|
+
// Route.name is the canonicalized URL path. Since #2289 a Route node's
|
|
155
|
+
// *id* is `(method, url)`-composite (`routeNodeKey`), but `route.name`
|
|
156
|
+
// continues to carry the bare URL so URL-keyed group queries like this
|
|
157
|
+
// one keep working without a schema change. Normalize the manifest
|
|
158
|
+
// contract the same way so a user-written "/api/orders" matches
|
|
159
|
+
// "api/orders" in the graph.
|
|
158
160
|
//
|
|
159
161
|
// The contract may also use the explicit-method form "GET::/api/orders"
|
|
160
162
|
// recommended by buildContractId. Strip the METHOD:: prefix before
|
|
@@ -59,11 +59,11 @@ export declare function buildExportedTypeMapFromGraph(graph: KnowledgeGraph, sym
|
|
|
59
59
|
*/
|
|
60
60
|
export declare const processRoutesFromExtracted: (graph: KnowledgeGraph, extractedRoutes: ExtractedRoute[], model: SemanticModel, onProgress?: (current: number, total: number) => void) => Promise<void>;
|
|
61
61
|
/**
|
|
62
|
-
* Resolve each route's handler to a real symbol UID, keyed by the
|
|
63
|
-
*
|
|
64
|
-
* the Part 2 (#2138) groundwork that lets
|
|
65
|
-
* read the handler symbol from the
|
|
66
|
-
* `getDetections()`.
|
|
62
|
+
* Resolve each route's handler to a real symbol UID, keyed by the route's
|
|
63
|
+
* `(method, url)` identity (`routeNodeKey` — the same key the routes phase uses
|
|
64
|
+
* for the `Route` node). This is the Part 2 (#2138) groundwork that lets
|
|
65
|
+
* `HttpRouteExtractor.extractProvidersGraph` read the handler symbol from the
|
|
66
|
+
* graph instead of re-parsing source via `getDetections()`.
|
|
67
67
|
*
|
|
68
68
|
* Two route shapes, one resolution target — `(filePath, name) → nodeId`:
|
|
69
69
|
* - Laravel framework routes (`ExtractedRoute`) carry `controllerName` +
|
|
@@ -73,14 +73,18 @@ export declare const processRoutesFromExtracted: (graph: KnowledgeGraph, extract
|
|
|
73
73
|
* `handlerName` (the decorated method, captured at extraction); resolve it
|
|
74
74
|
* directly in the route's own file.
|
|
75
75
|
*
|
|
76
|
-
* First-writer-wins per
|
|
77
|
-
* first route registered for a
|
|
78
|
-
* route to claim a
|
|
79
|
-
* a later same-
|
|
80
|
-
* node (the routes phase made that first
|
|
81
|
-
*
|
|
82
|
-
* same-
|
|
83
|
-
*
|
|
76
|
+
* First-writer-wins per route identity, matching the routes phase's dedup (it
|
|
77
|
+
* keeps the first route registered for a `(method, url)` key and counts the rest
|
|
78
|
+
* as duplicates). The first route to claim a key reserves it **even when its
|
|
79
|
+
* handler is unresolvable**, so a later same-key route can never stamp its
|
|
80
|
+
* handler onto the first route's Route node (the routes phase made that first
|
|
81
|
+
* route the node-winner). Keying is `routeNodeKey(method, url)` (#2289): a
|
|
82
|
+
* same-URL multi-verb pair (`GET /x` + `POST /x`) resolves two handlers, one per
|
|
83
|
+
* node; method-less / wildcard routes key by URL alone, byte-identical to the
|
|
84
|
+
* pre-#2289 behavior. Routes whose handler cannot be *uniquely* resolved (no
|
|
85
|
+
* name, zero matches, or an ambiguous same-name match) carry no
|
|
86
|
+
* `handlerSymbolId`; the extractor then falls back to source scan for that route
|
|
87
|
+
* (fail-open, no regression, never a wrong handler).
|
|
84
88
|
*/
|
|
85
89
|
export declare function resolveRouteHandlerSymbols(model: SemanticModel, extractedRoutes: readonly ExtractedRoute[], decoratorRoutes: readonly ExtractedDecoratorRoute[]): Map<string, string>;
|
|
86
90
|
/**
|
|
@@ -97,6 +101,14 @@ export declare const extractConsumerAccessedKeys: (content: string) => string[];
|
|
|
97
101
|
* Create FETCHES edges from extracted fetch() calls to matching Route nodes.
|
|
98
102
|
* When consumerContents is provided, extracts property access patterns from
|
|
99
103
|
* consumer files and encodes them in the edge reason field.
|
|
104
|
+
*
|
|
105
|
+
* Matching stays URL-only (#2289): a verb-less consumer (a `fetch()` call has
|
|
106
|
+
* no statically-known HTTP method) matches a route by URL and connects to
|
|
107
|
+
* **every** Route node sharing that URL — i.e. both the `GET /x` and `POST /x`
|
|
108
|
+
* nodes when a URL carries multiple verbs. `routeUrlToKeys` therefore maps each
|
|
109
|
+
* route URL to the list of `routeNodeKey` identities at that URL; a single-verb
|
|
110
|
+
* (or method-less) URL has a one-element list, keeping edges byte-identical to
|
|
111
|
+
* the pre-#2289 behavior.
|
|
100
112
|
*/
|
|
101
|
-
export declare const processNextjsFetchRoutes: (graph: KnowledgeGraph, fetchCalls: ExtractedFetchCall[],
|
|
113
|
+
export declare const processNextjsFetchRoutes: (graph: KnowledgeGraph, fetchCalls: ExtractedFetchCall[], routeUrlToKeys: Map<string, string[]>, // routeURL → route node keys at that URL
|
|
102
114
|
consumerContents?: Map<string, string>) => void;
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { generateId } from '../../lib/utils.js';
|
|
18
18
|
import { yieldToEventLoop } from './utils/event-loop.js';
|
|
19
19
|
import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js';
|
|
20
|
-
import { normalizeExtractedRoutePath } from './route-extractors/route-path.js';
|
|
20
|
+
import { normalizeExtractedRoutePath, normalizeRouteMethod, routeNodeKey, } from './route-extractors/route-path.js';
|
|
21
21
|
import { extractReturnTypeName } from './type-extractors/shared.js';
|
|
22
22
|
const MAX_EXPORTS_PER_FILE = 500;
|
|
23
23
|
const MAX_TYPE_NAME_LENGTH = 256;
|
|
@@ -215,11 +215,11 @@ export const processRoutesFromExtracted = async (graph, extractedRoutes, model,
|
|
|
215
215
|
onProgress?.(extractedRoutes.length, extractedRoutes.length);
|
|
216
216
|
};
|
|
217
217
|
/**
|
|
218
|
-
* Resolve each route's handler to a real symbol UID, keyed by the
|
|
219
|
-
*
|
|
220
|
-
* the Part 2 (#2138) groundwork that lets
|
|
221
|
-
* read the handler symbol from the
|
|
222
|
-
* `getDetections()`.
|
|
218
|
+
* Resolve each route's handler to a real symbol UID, keyed by the route's
|
|
219
|
+
* `(method, url)` identity (`routeNodeKey` — the same key the routes phase uses
|
|
220
|
+
* for the `Route` node). This is the Part 2 (#2138) groundwork that lets
|
|
221
|
+
* `HttpRouteExtractor.extractProvidersGraph` read the handler symbol from the
|
|
222
|
+
* graph instead of re-parsing source via `getDetections()`.
|
|
223
223
|
*
|
|
224
224
|
* Two route shapes, one resolution target — `(filePath, name) → nodeId`:
|
|
225
225
|
* - Laravel framework routes (`ExtractedRoute`) carry `controllerName` +
|
|
@@ -229,20 +229,24 @@ export const processRoutesFromExtracted = async (graph, extractedRoutes, model,
|
|
|
229
229
|
* `handlerName` (the decorated method, captured at extraction); resolve it
|
|
230
230
|
* directly in the route's own file.
|
|
231
231
|
*
|
|
232
|
-
* First-writer-wins per
|
|
233
|
-
* first route registered for a
|
|
234
|
-
* route to claim a
|
|
235
|
-
* a later same-
|
|
236
|
-
* node (the routes phase made that first
|
|
237
|
-
*
|
|
238
|
-
* same-
|
|
239
|
-
*
|
|
232
|
+
* First-writer-wins per route identity, matching the routes phase's dedup (it
|
|
233
|
+
* keeps the first route registered for a `(method, url)` key and counts the rest
|
|
234
|
+
* as duplicates). The first route to claim a key reserves it **even when its
|
|
235
|
+
* handler is unresolvable**, so a later same-key route can never stamp its
|
|
236
|
+
* handler onto the first route's Route node (the routes phase made that first
|
|
237
|
+
* route the node-winner). Keying is `routeNodeKey(method, url)` (#2289): a
|
|
238
|
+
* same-URL multi-verb pair (`GET /x` + `POST /x`) resolves two handlers, one per
|
|
239
|
+
* node; method-less / wildcard routes key by URL alone, byte-identical to the
|
|
240
|
+
* pre-#2289 behavior. Routes whose handler cannot be *uniquely* resolved (no
|
|
241
|
+
* name, zero matches, or an ambiguous same-name match) carry no
|
|
242
|
+
* `handlerSymbolId`; the extractor then falls back to source scan for that route
|
|
243
|
+
* (fail-open, no regression, never a wrong handler).
|
|
240
244
|
*/
|
|
241
245
|
export function resolveRouteHandlerSymbols(model, extractedRoutes, decoratorRoutes) {
|
|
242
246
|
const out = new Map();
|
|
243
|
-
//
|
|
244
|
-
// routes phase `addRoute` first-writer-wins so the handler we
|
|
245
|
-
// belongs to the route that actually won the Route node.
|
|
247
|
+
// Route identities already claimed by an earlier route (resolved or not).
|
|
248
|
+
// Mirrors the routes phase `addRoute` first-writer-wins so the handler we
|
|
249
|
+
// stamp always belongs to the route that actually won the Route node.
|
|
246
250
|
const claimed = new Set();
|
|
247
251
|
// Resolve a single same-file symbol by name, refusing to guess on ambiguity:
|
|
248
252
|
// exactly one match → its nodeId; zero or many → undefined (fail-open).
|
|
@@ -250,15 +254,16 @@ export function resolveRouteHandlerSymbols(model, extractedRoutes, decoratorRout
|
|
|
250
254
|
const defs = model.symbols.lookupExactAll(filePath, name);
|
|
251
255
|
return defs.length === 1 ? defs[0]?.nodeId : undefined;
|
|
252
256
|
};
|
|
253
|
-
const claim = (routePath, prefix, symbolId) => {
|
|
257
|
+
const claim = (routePath, prefix, httpMethod, symbolId) => {
|
|
254
258
|
if (!routePath)
|
|
255
259
|
return;
|
|
256
260
|
const url = normalizeExtractedRoutePath(routePath, prefix);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
261
|
+
const key = routeNodeKey(normalizeRouteMethod(httpMethod), url);
|
|
262
|
+
if (claimed.has(key))
|
|
263
|
+
return; // first-writer-wins: later same-key routes can't override
|
|
264
|
+
claimed.add(key);
|
|
260
265
|
if (symbolId)
|
|
261
|
-
out.set(
|
|
266
|
+
out.set(key, symbolId);
|
|
262
267
|
};
|
|
263
268
|
// Laravel framework routes — controller class + method name.
|
|
264
269
|
for (const route of extractedRoutes) {
|
|
@@ -276,13 +281,13 @@ export function resolveRouteHandlerSymbols(model, extractedRoutes, decoratorRout
|
|
|
276
281
|
if (controllerDef)
|
|
277
282
|
methodId = uniqueSymbolId(controllerDef.filePath, route.methodName);
|
|
278
283
|
}
|
|
279
|
-
claim(route.routePath, route.prefix ?? null, methodId);
|
|
284
|
+
claim(route.routePath, route.prefix ?? null, route.httpMethod, methodId);
|
|
280
285
|
}
|
|
281
286
|
// Decorator routes (Spring / FastAPI / generic) — the decorated handler in
|
|
282
287
|
// the route's own file.
|
|
283
288
|
for (const dr of decoratorRoutes) {
|
|
284
289
|
const handlerId = dr.handlerName ? uniqueSymbolId(dr.filePath, dr.handlerName) : undefined;
|
|
285
|
-
claim(dr.routePath, dr.prefix ?? null, handlerId);
|
|
290
|
+
claim(dr.routePath, dr.prefix ?? null, dr.httpMethod, handlerId);
|
|
286
291
|
}
|
|
287
292
|
return out;
|
|
288
293
|
}
|
|
@@ -421,16 +426,26 @@ export const extractConsumerAccessedKeys = (content) => {
|
|
|
421
426
|
* Create FETCHES edges from extracted fetch() calls to matching Route nodes.
|
|
422
427
|
* When consumerContents is provided, extracts property access patterns from
|
|
423
428
|
* consumer files and encodes them in the edge reason field.
|
|
429
|
+
*
|
|
430
|
+
* Matching stays URL-only (#2289): a verb-less consumer (a `fetch()` call has
|
|
431
|
+
* no statically-known HTTP method) matches a route by URL and connects to
|
|
432
|
+
* **every** Route node sharing that URL — i.e. both the `GET /x` and `POST /x`
|
|
433
|
+
* nodes when a URL carries multiple verbs. `routeUrlToKeys` therefore maps each
|
|
434
|
+
* route URL to the list of `routeNodeKey` identities at that URL; a single-verb
|
|
435
|
+
* (or method-less) URL has a one-element list, keeping edges byte-identical to
|
|
436
|
+
* the pre-#2289 behavior.
|
|
424
437
|
*/
|
|
425
|
-
export const processNextjsFetchRoutes = (graph, fetchCalls,
|
|
438
|
+
export const processNextjsFetchRoutes = (graph, fetchCalls, routeUrlToKeys, // routeURL → route node keys at that URL
|
|
426
439
|
consumerContents) => {
|
|
427
|
-
// Pre-count how many
|
|
440
|
+
// Pre-count how many route URLs each consumer file matches (for confidence
|
|
441
|
+
// attribution). Counts once per call that matches any URL — independent of how
|
|
442
|
+
// many verbs share that URL — so the multi-fetch heuristic is unchanged.
|
|
428
443
|
const routeCountByFile = new Map();
|
|
429
444
|
for (const call of fetchCalls) {
|
|
430
445
|
const normalized = normalizeFetchURL(call.fetchURL);
|
|
431
446
|
if (!normalized)
|
|
432
447
|
continue;
|
|
433
|
-
for (const
|
|
448
|
+
for (const routeURL of routeUrlToKeys.keys()) {
|
|
434
449
|
if (routeMatches(normalized, routeURL)) {
|
|
435
450
|
routeCountByFile.set(call.filePath, (routeCountByFile.get(call.filePath) ?? 0) + 1);
|
|
436
451
|
break;
|
|
@@ -441,10 +456,9 @@ consumerContents) => {
|
|
|
441
456
|
const normalized = normalizeFetchURL(call.fetchURL);
|
|
442
457
|
if (!normalized)
|
|
443
458
|
continue;
|
|
444
|
-
for (const [routeURL] of
|
|
459
|
+
for (const [routeURL, routeKeys] of routeUrlToKeys) {
|
|
445
460
|
if (routeMatches(normalized, routeURL)) {
|
|
446
461
|
const sourceId = generateId('File', call.filePath);
|
|
447
|
-
const routeNodeId = generateId('Route', routeURL);
|
|
448
462
|
// Extract consumer accessed keys if file content is available
|
|
449
463
|
let reason = 'fetch-url-match';
|
|
450
464
|
if (consumerContents) {
|
|
@@ -461,14 +475,18 @@ consumerContents) => {
|
|
|
461
475
|
if (fetchCount > 1) {
|
|
462
476
|
reason = `${reason}|fetches:${fetchCount}`;
|
|
463
477
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
478
|
+
// Connect to every Route node at this URL (one per verb).
|
|
479
|
+
for (const routeKey of routeKeys) {
|
|
480
|
+
const routeNodeId = generateId('Route', routeKey);
|
|
481
|
+
graph.addRelationship({
|
|
482
|
+
id: generateId('FETCHES', `${sourceId}->${routeNodeId}`),
|
|
483
|
+
sourceId,
|
|
484
|
+
targetId: routeNodeId,
|
|
485
|
+
type: 'FETCHES',
|
|
486
|
+
confidence: 0.9,
|
|
487
|
+
reason,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
472
490
|
break;
|
|
473
491
|
}
|
|
474
492
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { getPhaseOutput } from './types.js';
|
|
12
12
|
import { processProcesses } from '../process-processor.js';
|
|
13
13
|
import { generateId } from '../../../lib/utils.js';
|
|
14
|
+
import { routeNodeKey } from '../route-extractors/route-path.js';
|
|
14
15
|
import { isDev } from '../utils/env.js';
|
|
15
16
|
import { logger } from '../../logger.js';
|
|
16
17
|
export const processesPhase = {
|
|
@@ -78,14 +79,39 @@ export const processesPhase = {
|
|
|
78
79
|
});
|
|
79
80
|
// Link Route and Tool nodes to Processes
|
|
80
81
|
if (routeRegistry.size > 0 || toolDefs.length > 0) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
// Two-tier route lookup, mirroring the tool tables 10 lines below.
|
|
83
|
+
// Routes whose handler resolved key by `handlerSymbolId` (read from
|
|
84
|
+
// the Route node's graph properties — routes.ts stamps it there) and
|
|
85
|
+
// link ONLY to the process whose entryPoint matches; routes without a
|
|
86
|
+
// resolved handler fall back to a per-file bucket so we still attach
|
|
87
|
+
// the Route node to a same-file process (best-effort).
|
|
88
|
+
//
|
|
89
|
+
// Pre-#2289-review-P2 this was a single per-file bucket: every verb
|
|
90
|
+
// on a file's controller was linked to every process in that file,
|
|
91
|
+
// cross-wiring same-file `GET /items` and `POST /items` to each
|
|
92
|
+
// other's handler processes. The per-verb `handlerSymbolId` the
|
|
93
|
+
// routes phase stamps on the Route node was never consulted.
|
|
94
|
+
const routesByHandlerId = new Map();
|
|
95
|
+
const routesWithoutHandlerByFile = new Map();
|
|
96
|
+
for (const [, entry] of routeRegistry) {
|
|
97
|
+
// Push the Route node identity (`routeNodeKey`), not the bare URL, so the
|
|
98
|
+
// ENTRY_POINT_OF edge targets the same node id the routes phase created
|
|
99
|
+
// (#2289: a same-URL GET/POST pair is two distinct Route nodes).
|
|
100
|
+
const routeKey = routeNodeKey(entry.method, entry.url);
|
|
101
|
+
// Source of truth for handlerSymbolId is the Route node in the
|
|
102
|
+
// graph (routes.ts populates it from `routeHandlerSymbols`); the
|
|
103
|
+
// routes phase runs before processes (see `deps`), so the node is
|
|
104
|
+
// always present here.
|
|
105
|
+
const routeNode = ctx.graph.getNode(generateId('Route', routeKey));
|
|
106
|
+
const handlerSymbolId = routeNode?.properties.handlerSymbolId;
|
|
107
|
+
const targetMap = handlerSymbolId ? routesByHandlerId : routesWithoutHandlerByFile;
|
|
108
|
+
const bucketKey = handlerSymbolId ?? entry.filePath;
|
|
109
|
+
let list = targetMap.get(bucketKey);
|
|
84
110
|
if (!list) {
|
|
85
111
|
list = [];
|
|
86
|
-
|
|
112
|
+
targetMap.set(bucketKey, list);
|
|
87
113
|
}
|
|
88
|
-
list.push(
|
|
114
|
+
list.push(routeKey);
|
|
89
115
|
}
|
|
90
116
|
const toolsByHandlerId = new Map();
|
|
91
117
|
const toolsWithoutHandlerByFile = new Map();
|
|
@@ -109,10 +135,12 @@ export const processesPhase = {
|
|
|
109
135
|
const entryFile = entryNode.properties.filePath;
|
|
110
136
|
if (!entryFile)
|
|
111
137
|
continue;
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
138
|
+
const exactRouteKeys = routesByHandlerId.get(proc.entryPointId);
|
|
139
|
+
const fallbackRouteKeys = routesWithoutHandlerByFile.get(entryFile);
|
|
140
|
+
const routeKeys = exactRouteKeys ?? fallbackRouteKeys;
|
|
141
|
+
if (routeKeys) {
|
|
142
|
+
for (const routeKey of routeKeys) {
|
|
143
|
+
const routeNodeId = generateId('Route', routeKey);
|
|
116
144
|
ctx.graph.addRelationship({
|
|
117
145
|
id: generateId('ENTRY_POINT_OF', `${routeNodeId}->${proc.id}`),
|
|
118
146
|
sourceId: routeNodeId,
|
|
@@ -11,10 +11,16 @@
|
|
|
11
11
|
* @output routeRegistry, handlerContents
|
|
12
12
|
*/
|
|
13
13
|
import type { PipelinePhase } from './types.js';
|
|
14
|
-
import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
|
|
15
14
|
export interface RouteEntry {
|
|
16
15
|
filePath: string;
|
|
17
16
|
source: string;
|
|
17
|
+
/**
|
|
18
|
+
* The route's URL path (leading-slash, prefix-joined). This is the Route
|
|
19
|
+
* node's `name`. Stored explicitly because the registry is keyed by the
|
|
20
|
+
* `(method, url)` identity (`routeNodeKey`), so the key is no longer the URL
|
|
21
|
+
* — downstream URL consumers (middleware/fetch matching) read this instead.
|
|
22
|
+
*/
|
|
23
|
+
url: string;
|
|
18
24
|
/**
|
|
19
25
|
* HTTP verb for this route when ingestion knows it structurally
|
|
20
26
|
* (Spring/Laravel framework routes and decorator routes carry
|
|
@@ -35,6 +41,4 @@ export interface TemplateFetchCall {
|
|
|
35
41
|
}
|
|
36
42
|
export declare const isTemplateRouteCandidate: (filePath: string) => boolean;
|
|
37
43
|
export declare function extractTemplateStaticFetchCalls(filePath: string, content: string, namedRouteUrls?: ReadonlyMap<string, string>): TemplateFetchCall[];
|
|
38
|
-
export { normalizeExtractedRoutePath };
|
|
39
|
-
export declare function normalizeRouteMethod(raw: string | null | undefined): string | undefined;
|
|
40
44
|
export declare const routesPhase: PipelinePhase<RoutesOutput>;
|
|
@@ -18,7 +18,7 @@ import { phpFileToRouteURL } from '../route-extractors/php.js';
|
|
|
18
18
|
import { extractResponseShapes, extractPHPResponseShapes, } from '../route-extractors/response-shapes.js';
|
|
19
19
|
import { extractMiddlewareChain, extractNextjsMiddlewareConfig, compileMatcher, compiledMatcherMatchesRoute, } from '../route-extractors/middleware.js';
|
|
20
20
|
import { processNextjsFetchRoutes } from '../call-processor.js';
|
|
21
|
-
import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
|
|
21
|
+
import { normalizeExtractedRoutePath, normalizeRouteMethod, routeNodeKey, } from '../route-extractors/route-path.js';
|
|
22
22
|
import { generateId } from '../../../lib/utils.js';
|
|
23
23
|
import { readFileContents } from '../filesystem-walker.js';
|
|
24
24
|
import { isDev } from '../utils/env.js';
|
|
@@ -92,39 +92,6 @@ export function extractTemplateStaticFetchCalls(filePath, content, namedRouteUrl
|
|
|
92
92
|
function escapeRegex(s) {
|
|
93
93
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
94
94
|
}
|
|
95
|
-
// Re-exported for existing consumers/tests that import it from the routes phase.
|
|
96
|
-
export { normalizeExtractedRoutePath };
|
|
97
|
-
/**
|
|
98
|
-
* Canonicalize a route's HTTP verb for persistence on the Route node.
|
|
99
|
-
* Returns an upper-cased standard method, or `undefined` when the value
|
|
100
|
-
* is not a real HTTP verb. Laravel `Route::resource` / `apiResource`
|
|
101
|
-
* surface `httpMethod` values like `resource` / `apiResource` (they
|
|
102
|
-
* expand to several verbs at runtime), so they must not be stored as a
|
|
103
|
-
* method — leaving them `undefined` keeps the column clean and lets the
|
|
104
|
-
* contract extractor fall back to its source-scan path for those routes.
|
|
105
|
-
*/
|
|
106
|
-
const VALID_HTTP_METHODS = new Set([
|
|
107
|
-
'GET',
|
|
108
|
-
'POST',
|
|
109
|
-
'PUT',
|
|
110
|
-
'PATCH',
|
|
111
|
-
'DELETE',
|
|
112
|
-
'HEAD',
|
|
113
|
-
'OPTIONS',
|
|
114
|
-
'TRACE',
|
|
115
|
-
'CONNECT',
|
|
116
|
-
]);
|
|
117
|
-
export function normalizeRouteMethod(raw) {
|
|
118
|
-
if (typeof raw !== 'string')
|
|
119
|
-
return undefined;
|
|
120
|
-
const verb = raw.trim().toUpperCase();
|
|
121
|
-
// '*' marks a method-agnostic route (e.g. a Django function view handles any
|
|
122
|
-
// verb). Preserve it so the contract layer emits a wildcard provider that
|
|
123
|
-
// matches consumers of any method, instead of silently narrowing to GET.
|
|
124
|
-
if (verb === '*')
|
|
125
|
-
return '*';
|
|
126
|
-
return VALID_HTTP_METHODS.has(verb) ? verb : undefined;
|
|
127
|
-
}
|
|
128
95
|
export const routesPhase = {
|
|
129
96
|
name: 'routes',
|
|
130
97
|
deps: ['parse'],
|
|
@@ -162,30 +129,44 @@ export const routesPhase = {
|
|
|
162
129
|
if (expoAppPaths.has(p)) {
|
|
163
130
|
const expoURL = expoFileToRouteURL(p);
|
|
164
131
|
if (expoURL && !routeRegistry.has(expoURL)) {
|
|
165
|
-
routeRegistry.set(expoURL, {
|
|
132
|
+
routeRegistry.set(expoURL, {
|
|
133
|
+
filePath: p,
|
|
134
|
+
source: 'expo-filesystem-route',
|
|
135
|
+
url: expoURL,
|
|
136
|
+
});
|
|
166
137
|
continue;
|
|
167
138
|
}
|
|
168
139
|
}
|
|
169
140
|
const nextjsURL = nextjsFileToRouteURL(p);
|
|
170
141
|
if (nextjsURL && !routeRegistry.has(nextjsURL)) {
|
|
171
|
-
routeRegistry.set(nextjsURL, {
|
|
142
|
+
routeRegistry.set(nextjsURL, {
|
|
143
|
+
filePath: p,
|
|
144
|
+
source: 'nextjs-filesystem-route',
|
|
145
|
+
url: nextjsURL,
|
|
146
|
+
});
|
|
172
147
|
continue;
|
|
173
148
|
}
|
|
174
149
|
if (p.endsWith('.php')) {
|
|
175
150
|
const phpURL = phpFileToRouteURL(p);
|
|
176
151
|
if (phpURL && !routeRegistry.has(phpURL)) {
|
|
177
|
-
routeRegistry.set(phpURL, { filePath: p, source: 'php-file-route' });
|
|
152
|
+
routeRegistry.set(phpURL, { filePath: p, source: 'php-file-route', url: phpURL });
|
|
178
153
|
}
|
|
179
154
|
}
|
|
180
155
|
}
|
|
181
156
|
let duplicateRoutes = 0;
|
|
182
157
|
const namedRouteRegistry = new Map();
|
|
158
|
+
// Routes are keyed by their `(method, url)` identity (#2289): a same-URL
|
|
159
|
+
// multi-verb pair (`GET /x` + `POST /x`) is two entries, not one. Method-less
|
|
160
|
+
// / wildcard routes key by URL (see `routeNodeKey`), so filesystem/resource
|
|
161
|
+
// routes stay byte-identical. A true duplicate (same method AND url) is still
|
|
162
|
+
// dropped.
|
|
183
163
|
const addRoute = (url, entry) => {
|
|
184
|
-
|
|
164
|
+
const key = routeNodeKey(entry.method, url);
|
|
165
|
+
if (routeRegistry.has(key)) {
|
|
185
166
|
duplicateRoutes++;
|
|
186
167
|
return;
|
|
187
168
|
}
|
|
188
|
-
routeRegistry.set(
|
|
169
|
+
routeRegistry.set(key, { ...entry, url });
|
|
189
170
|
};
|
|
190
171
|
for (const route of allExtractedRoutes) {
|
|
191
172
|
if (!route.routePath)
|
|
@@ -212,8 +193,8 @@ export const routesPhase = {
|
|
|
212
193
|
if (routeRegistry.size > 0) {
|
|
213
194
|
const handlerPaths = [...routeRegistry.values()].map((e) => e.filePath);
|
|
214
195
|
handlerContents = await readFileContents(ctx.repoPath, handlerPaths);
|
|
215
|
-
for (const [
|
|
216
|
-
const { filePath: handlerPath, source: routeSource, method: routeMethod } = entry;
|
|
196
|
+
for (const [routeKey, entry] of routeRegistry) {
|
|
197
|
+
const { filePath: handlerPath, source: routeSource, method: routeMethod, url } = entry;
|
|
217
198
|
const content = handlerContents.get(handlerPath);
|
|
218
199
|
const { responseKeys, errorKeys } = content
|
|
219
200
|
? handlerPath.endsWith('.php')
|
|
@@ -222,13 +203,13 @@ export const routesPhase = {
|
|
|
222
203
|
: { responseKeys: undefined, errorKeys: undefined };
|
|
223
204
|
const mwResult = content ? extractMiddlewareChain(content) : undefined;
|
|
224
205
|
const middleware = mwResult?.chain;
|
|
225
|
-
const routeNodeId = generateId('Route',
|
|
226
|
-
const handlerSymbolId = routeHandlerSymbols.get(
|
|
206
|
+
const routeNodeId = generateId('Route', routeKey);
|
|
207
|
+
const handlerSymbolId = routeHandlerSymbols.get(routeKey);
|
|
227
208
|
ctx.graph.addNode({
|
|
228
209
|
id: routeNodeId,
|
|
229
210
|
label: 'Route',
|
|
230
211
|
properties: {
|
|
231
|
-
name:
|
|
212
|
+
name: url,
|
|
232
213
|
filePath: handlerPath,
|
|
233
214
|
...(routeMethod ? { method: routeMethod } : {}),
|
|
234
215
|
...(handlerSymbolId ? { handlerSymbolId } : {}),
|
|
@@ -272,12 +253,12 @@ export const routesPhase = {
|
|
|
272
253
|
.map(compileMatcher)
|
|
273
254
|
.filter((m) => m !== null);
|
|
274
255
|
let linkedCount = 0;
|
|
275
|
-
for (const [
|
|
256
|
+
for (const [routeKey, entry] of routeRegistry) {
|
|
276
257
|
const matches = compiled.length === 0 ||
|
|
277
|
-
compiled.some((cm) => compiledMatcherMatchesRoute(cm,
|
|
258
|
+
compiled.some((cm) => compiledMatcherMatchesRoute(cm, entry.url));
|
|
278
259
|
if (!matches)
|
|
279
260
|
continue;
|
|
280
|
-
const routeNodeId = generateId('Route',
|
|
261
|
+
const routeNodeId = generateId('Route', routeKey);
|
|
281
262
|
const existing = ctx.graph.getNode(routeNodeId);
|
|
282
263
|
if (!existing)
|
|
283
264
|
continue;
|
|
@@ -401,12 +382,19 @@ export const routesPhase = {
|
|
|
401
382
|
}
|
|
402
383
|
}
|
|
403
384
|
if (routeRegistry.size > 0 && allFetchCalls.length > 0) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
385
|
+
// url → [route node keys at that url] (one per verb). A verb-less fetch()
|
|
386
|
+
// consumer matches by URL and connects to every Route node at that URL.
|
|
387
|
+
const routeUrlToKeys = new Map();
|
|
388
|
+
for (const [routeKey, entry] of routeRegistry) {
|
|
389
|
+
const existing = routeUrlToKeys.get(entry.url);
|
|
390
|
+
if (existing)
|
|
391
|
+
existing.push(routeKey);
|
|
392
|
+
else
|
|
393
|
+
routeUrlToKeys.set(entry.url, [routeKey]);
|
|
394
|
+
}
|
|
407
395
|
const consumerPaths = [...new Set(allFetchCalls.map((c) => c.filePath))];
|
|
408
396
|
const consumerContents = await readFileContents(ctx.repoPath, consumerPaths);
|
|
409
|
-
processNextjsFetchRoutes(ctx.graph, allFetchCalls,
|
|
397
|
+
processNextjsFetchRoutes(ctx.graph, allFetchCalls, routeUrlToKeys, consumerContents);
|
|
410
398
|
if (isDev) {
|
|
411
399
|
logger.info(`🔗 Processed ${allFetchCalls.length} fetch() calls against ${routeRegistry.size} routes`);
|
|
412
400
|
}
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Shared route-path normalization.
|
|
3
3
|
*
|
|
4
4
|
* Extracted from the routes phase so both the routes phase (which creates the
|
|
5
|
-
* `Route` graph node, keyed by
|
|
6
|
-
* resolves each route's handler symbol and needs the
|
|
7
|
-
* resolved id back to the route) can compute an
|
|
8
|
-
* phase-to-phase import cycle. Pure string
|
|
5
|
+
* `Route` graph node, keyed by `(method, url)` via `routeNodeKey` — #2289) and
|
|
6
|
+
* the parse phase (which resolves each route's handler symbol and needs the
|
|
7
|
+
* SAME key to associate the resolved id back to the route) can compute an
|
|
8
|
+
* identical route identity without a phase-to-phase import cycle. Pure string
|
|
9
|
+
* logic, no dependencies.
|
|
9
10
|
*/
|
|
10
11
|
/**
|
|
11
12
|
* Join a route's path with its (optional) prefix into a normalized,
|
|
@@ -13,3 +14,26 @@
|
|
|
13
14
|
* slashes and strips trailing ones; an empty result degrades to `/`.
|
|
14
15
|
*/
|
|
15
16
|
export declare function normalizeExtractedRoutePath(routePath: string, prefix: string | null): string;
|
|
17
|
+
/**
|
|
18
|
+
* Canonicalize a route's HTTP verb for persistence on the Route node and for
|
|
19
|
+
* the route identity key. Returns an upper-cased standard method, `'*'` for a
|
|
20
|
+
* method-agnostic route (e.g. a Django function view), or `undefined` when the
|
|
21
|
+
* value is not a real HTTP verb. Laravel `Route::resource` / `apiResource`
|
|
22
|
+
* surface values like `resource` / `apiResource` (they expand to several verbs
|
|
23
|
+
* at runtime), so they come back `undefined` — keeping the column clean and
|
|
24
|
+
* letting the contract extractor fall back to its source-scan path.
|
|
25
|
+
*/
|
|
26
|
+
export declare function normalizeRouteMethod(raw: string | null | undefined): string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* The Route node identity (#2289): `(method, path)` when the verb is known and
|
|
29
|
+
* specific, falling back to URL-only when the method is `undefined` (filesystem
|
|
30
|
+
* routes, Laravel `resource`/`apiResource`) or `'*'` (method-agnostic routes,
|
|
31
|
+
* e.g. Django function views). The URL-fallback keeps those byte-identical to
|
|
32
|
+
* the pre-#2289 URL-only ids, so only genuine declaration-style multi-verb
|
|
33
|
+
* routes (`GET /x` + `POST /x`) split into separate nodes.
|
|
34
|
+
*
|
|
35
|
+
* Used by the routes phase (node id + registry key), the processes phase
|
|
36
|
+
* (ENTRY_POINT_OF), and the handler-symbol resolver — all three must key
|
|
37
|
+
* identically.
|
|
38
|
+
*/
|
|
39
|
+
export declare function routeNodeKey(method: string | undefined, url: string): string;
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Shared route-path normalization.
|
|
3
3
|
*
|
|
4
4
|
* Extracted from the routes phase so both the routes phase (which creates the
|
|
5
|
-
* `Route` graph node, keyed by
|
|
6
|
-
* resolves each route's handler symbol and needs the
|
|
7
|
-
* resolved id back to the route) can compute an
|
|
8
|
-
* phase-to-phase import cycle. Pure string
|
|
5
|
+
* `Route` graph node, keyed by `(method, url)` via `routeNodeKey` — #2289) and
|
|
6
|
+
* the parse phase (which resolves each route's handler symbol and needs the
|
|
7
|
+
* SAME key to associate the resolved id back to the route) can compute an
|
|
8
|
+
* identical route identity without a phase-to-phase import cycle. Pure string
|
|
9
|
+
* logic, no dependencies.
|
|
9
10
|
*/
|
|
10
11
|
/**
|
|
11
12
|
* Join a route's path with its (optional) prefix into a normalized,
|
|
@@ -18,3 +19,48 @@ export function normalizeExtractedRoutePath(routePath, prefix) {
|
|
|
18
19
|
const joined = prefixPart ? `/${prefixPart}${pathPart ? `/${pathPart}` : ''}` : `/${pathPart}`;
|
|
19
20
|
return joined.replace(/\/+/g, '/') || '/';
|
|
20
21
|
}
|
|
22
|
+
const VALID_HTTP_METHODS = new Set([
|
|
23
|
+
'GET',
|
|
24
|
+
'POST',
|
|
25
|
+
'PUT',
|
|
26
|
+
'PATCH',
|
|
27
|
+
'DELETE',
|
|
28
|
+
'HEAD',
|
|
29
|
+
'OPTIONS',
|
|
30
|
+
'TRACE',
|
|
31
|
+
'CONNECT',
|
|
32
|
+
]);
|
|
33
|
+
/**
|
|
34
|
+
* Canonicalize a route's HTTP verb for persistence on the Route node and for
|
|
35
|
+
* the route identity key. Returns an upper-cased standard method, `'*'` for a
|
|
36
|
+
* method-agnostic route (e.g. a Django function view), or `undefined` when the
|
|
37
|
+
* value is not a real HTTP verb. Laravel `Route::resource` / `apiResource`
|
|
38
|
+
* surface values like `resource` / `apiResource` (they expand to several verbs
|
|
39
|
+
* at runtime), so they come back `undefined` — keeping the column clean and
|
|
40
|
+
* letting the contract extractor fall back to its source-scan path.
|
|
41
|
+
*/
|
|
42
|
+
export function normalizeRouteMethod(raw) {
|
|
43
|
+
if (typeof raw !== 'string')
|
|
44
|
+
return undefined;
|
|
45
|
+
const verb = raw.trim().toUpperCase();
|
|
46
|
+
// '*' marks a method-agnostic route. Preserve it so the contract layer emits a
|
|
47
|
+
// wildcard provider that matches consumers of any method.
|
|
48
|
+
if (verb === '*')
|
|
49
|
+
return '*';
|
|
50
|
+
return VALID_HTTP_METHODS.has(verb) ? verb : undefined;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The Route node identity (#2289): `(method, path)` when the verb is known and
|
|
54
|
+
* specific, falling back to URL-only when the method is `undefined` (filesystem
|
|
55
|
+
* routes, Laravel `resource`/`apiResource`) or `'*'` (method-agnostic routes,
|
|
56
|
+
* e.g. Django function views). The URL-fallback keeps those byte-identical to
|
|
57
|
+
* the pre-#2289 URL-only ids, so only genuine declaration-style multi-verb
|
|
58
|
+
* routes (`GET /x` + `POST /x`) split into separate nodes.
|
|
59
|
+
*
|
|
60
|
+
* Used by the routes phase (node id + registry key), the processes phase
|
|
61
|
+
* (ENTRY_POINT_OF), and the handler-symbol resolver — all three must key
|
|
62
|
+
* identically.
|
|
63
|
+
*/
|
|
64
|
+
export function routeNodeKey(method, url) {
|
|
65
|
+
return method && method !== '*' ? `${method} ${url}` : url;
|
|
66
|
+
}
|
package/dist/core/run-analyze.js
CHANGED
|
@@ -353,6 +353,20 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
|
|
|
353
353
|
}
|
|
354
354
|
try {
|
|
355
355
|
await initLbug(lbugPath);
|
|
356
|
+
// Gate on FTS availability BEFORE touching any index. createSearchFTSIndexes
|
|
357
|
+
// now DROPs each index before recreating it (so schema changes reach existing
|
|
358
|
+
// DBs); if the extension were unavailable, the drops would run and leave the
|
|
359
|
+
// DB index-less, only failing at the create step. Fail loudly first — mirrors
|
|
360
|
+
// the analyze path's `if (ftsAvailable)` gate below — so an unavailable
|
|
361
|
+
// extension never destroys the existing indexes.
|
|
362
|
+
const repairFtsAvailable = await loadFTSExtension(undefined, {
|
|
363
|
+
policy: resolveAnalyzeInstallPolicy(),
|
|
364
|
+
});
|
|
365
|
+
if (!repairFtsAvailable) {
|
|
366
|
+
throw new Error('Cannot repair FTS indexes: the LadybugDB FTS extension is unavailable ' +
|
|
367
|
+
'(not pre-installed and could not be installed on this machine). ' +
|
|
368
|
+
'Run `gitnexus doctor` to install it, then retry `--repair-fts`.');
|
|
369
|
+
}
|
|
356
370
|
progress('fts', 85, 'Repairing search indexes...');
|
|
357
371
|
await createSearchFTSIndexes({
|
|
358
372
|
onIndexStart: options.verbose
|
|
@@ -420,6 +434,27 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
|
|
|
420
434
|
`Tip: set \`pdg: ${pdgOn}\` in .gitnexusrc to pin the mode across runs.`);
|
|
421
435
|
options = { ...options, force: true };
|
|
422
436
|
}
|
|
437
|
+
// ── schema-version mismatch forces full rebuild (#2289 P1) ────────
|
|
438
|
+
// Mirrors the pdg-mode block above: a stamp from an older
|
|
439
|
+
// INCREMENTAL_SCHEMA_VERSION (e.g. pre-v5 URL-only Route ids) cannot be
|
|
440
|
+
// reconciled by an incremental top-up — same-commit re-analyze would
|
|
441
|
+
// strand stale rows next to new-schema writes. MUST sit before the
|
|
442
|
+
// alreadyUpToDate fast path below: an unchanged-commit clean tree would
|
|
443
|
+
// otherwise early-return without ever reaching the `isIncremental` gate
|
|
444
|
+
// that consults `schemaVersion`, defeating the bump's whole point.
|
|
445
|
+
//
|
|
446
|
+
// `schemaVersion === undefined` covers two cases that should still trip
|
|
447
|
+
// this guard: a non-git repo (which never stamps the field) and very old
|
|
448
|
+
// meta from before the field existed. Non-git repos take the
|
|
449
|
+
// `currentCommit === ''` rebuild branch below regardless, so the redundant
|
|
450
|
+
// force here is harmless; the friendlier `'pre-versioning'` log avoids a
|
|
451
|
+
// user-visible "stamped vundefined" line in that edge case.
|
|
452
|
+
if (existingMeta && existingMeta.schemaVersion !== INCREMENTAL_SCHEMA_VERSION) {
|
|
453
|
+
const stampedVersion = existingMeta.schemaVersion ?? 'pre-versioning';
|
|
454
|
+
log(`index schema changed (stamped v${stampedVersion}, this build is v${INCREMENTAL_SCHEMA_VERSION}); ` +
|
|
455
|
+
`forcing a full rebuild so persisted rows match the current schema.`);
|
|
456
|
+
options = { ...options, force: true };
|
|
457
|
+
}
|
|
423
458
|
// ── Early-return: already up to date ──────────────────────────────
|
|
424
459
|
if (existingMeta && !options.force && existingMeta.lastCommit === currentCommit) {
|
|
425
460
|
// Non-git folders have currentCommit = '' — always rebuild since we can't detect changes
|
|
@@ -1,32 +1,48 @@
|
|
|
1
|
-
import { createFTSIndex } from '../lbug/lbug-adapter.js';
|
|
1
|
+
import { createFTSIndex, dropFTSIndex } from '../lbug/lbug-adapter.js';
|
|
2
2
|
import { FTS_INDEXES } from './fts-schema.js';
|
|
3
3
|
export async function createSearchFTSIndexes(options) {
|
|
4
4
|
for (const { table, indexName, properties } of FTS_INDEXES) {
|
|
5
5
|
options?.onIndexStart?.(table, indexName);
|
|
6
|
+
// Drop first so the live `properties` always win. `createFTSIndex` is
|
|
7
|
+
// idempotent-by-name (skips when the index already exists), so without the
|
|
8
|
+
// drop a schema change — e.g. adding `description` (#2299) — would never
|
|
9
|
+
// reach an existing `.lbug` DB on an incremental re-analyze or `--repair-fts`;
|
|
10
|
+
// the old name+content index would silently persist. `dropFTSIndex` no-ops
|
|
11
|
+
// when the index is absent (first-ever analyze) and clears the per-connection
|
|
12
|
+
// memo so the create below actually runs.
|
|
13
|
+
// ponytail: this rebuilds every FTS index on every analyze instead of
|
|
14
|
+
// skipping when present; FTS build is proportional to symbol-table size and
|
|
15
|
+
// runs inside the existing FTS phase. Gate on a stored schema fingerprint if
|
|
16
|
+
// this rebuild cost ever shows up in analyze profiles.
|
|
17
|
+
await dropFTSIndex(table, indexName);
|
|
6
18
|
await createFTSIndex(table, indexName, [...properties]);
|
|
7
19
|
options?.onIndexReady?.(table, indexName);
|
|
8
20
|
}
|
|
9
21
|
}
|
|
10
22
|
export async function verifySearchFTSIndexes(executeQuery) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
// Read the catalog once and check each configured index both EXISTS and
|
|
24
|
+
// covers its expected columns. A queryability-only probe (CALL QUERY_FTS_INDEX
|
|
25
|
+
// ... catch) is not enough: a stale `name+content`-only index left on a
|
|
26
|
+
// pre-#2299 DB stays queryable yet silently misses `description`, so the probe
|
|
27
|
+
// would pass while doc-comment search is still broken (#2299). SHOW_INDEXES
|
|
28
|
+
// exposes `property_names` (STRING[]) per index, so we assert coverage directly.
|
|
29
|
+
const rows = await executeQuery('CALL SHOW_INDEXES() RETURN *');
|
|
30
|
+
const propsByIndex = new Map();
|
|
31
|
+
for (const row of rows) {
|
|
32
|
+
if (typeof row !== 'object' || row === null)
|
|
33
|
+
continue;
|
|
34
|
+
const record = row;
|
|
35
|
+
const indexName = record.index_name;
|
|
36
|
+
const propertyNames = record.property_names;
|
|
37
|
+
if (typeof indexName !== 'string' || !Array.isArray(propertyNames))
|
|
38
|
+
continue;
|
|
39
|
+
propsByIndex.set(indexName, propertyNames.filter((p) => typeof p === 'string'));
|
|
40
|
+
}
|
|
17
41
|
const missing = [];
|
|
18
|
-
for (const { table, indexName } of FTS_INDEXES) {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
CALL QUERY_FTS_INDEX('${safeTable}', '${safeIndex}', '__gitnexus_fts_probe__', conjunctive := false)
|
|
23
|
-
RETURN score
|
|
24
|
-
LIMIT 1
|
|
25
|
-
`;
|
|
26
|
-
try {
|
|
27
|
-
await executeQuery(probe);
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
42
|
+
for (const { table, indexName, properties } of FTS_INDEXES) {
|
|
43
|
+
const actual = propsByIndex.get(indexName);
|
|
44
|
+
// Absent from the catalog, or present but not covering every expected column.
|
|
45
|
+
if (!actual || !properties.every((p) => actual.includes(p))) {
|
|
30
46
|
missing.push(`${table}.${indexName}`);
|
|
31
47
|
}
|
|
32
48
|
}
|
|
@@ -1,7 +1,40 @@
|
|
|
1
|
+
// Shared by both index creation (`createSearchFTSIndexes`) and querying
|
|
2
|
+
// (`searchFTSFromLbug` / `verifySearchFTSIndexes`) — the single source of truth
|
|
3
|
+
// for which tables/columns are full-text searchable. Adding `description` here
|
|
4
|
+
// makes doc comments (Javadoc/KDoc/JSDoc/Doxygen/godoc/RDoc) keyword-searchable
|
|
5
|
+
// once they are populated by `descriptionExtractor` (#2270/#2286, issue #2299).
|
|
6
|
+
//
|
|
7
|
+
// IMPORTANT: every property must be a real column on its table (see
|
|
8
|
+
// `core/lbug/schema.ts`). `File` has no `description` column, so it stays
|
|
9
|
+
// name+content. All other entries below carry a `description` column.
|
|
10
|
+
//
|
|
11
|
+
// Tables beyond the original 5 mirror `EMBEDDABLE_LABELS` (embeddings/types.ts):
|
|
12
|
+
// indexing the same set keeps a symbol's doc comment both keyword- and
|
|
13
|
+
// semantically-searchable.
|
|
14
|
+
const FTS_PROPERTIES = ['name', 'content', 'description'];
|
|
1
15
|
export const FTS_INDEXES = [
|
|
16
|
+
// File has no `description` column — keep it name+content only.
|
|
2
17
|
{ table: 'File', indexName: 'file_fts', properties: ['name', 'content'] },
|
|
3
|
-
|
|
4
|
-
{ table: '
|
|
5
|
-
{ table: '
|
|
6
|
-
{ table: '
|
|
18
|
+
// Original 5 (minus File) gain `description`.
|
|
19
|
+
{ table: 'Function', indexName: 'function_fts', properties: FTS_PROPERTIES },
|
|
20
|
+
{ table: 'Class', indexName: 'class_fts', properties: FTS_PROPERTIES },
|
|
21
|
+
{ table: 'Method', indexName: 'method_fts', properties: FTS_PROPERTIES },
|
|
22
|
+
{ table: 'Interface', indexName: 'interface_fts', properties: FTS_PROPERTIES },
|
|
23
|
+
// Remaining EMBEDDABLE_LABELS symbol tables — all CODE_ELEMENT_BASE-shaped
|
|
24
|
+
// (or a superset), so all carry name + content + description columns.
|
|
25
|
+
{ table: 'Constructor', indexName: 'constructor_fts', properties: FTS_PROPERTIES },
|
|
26
|
+
{ table: 'Struct', indexName: 'struct_fts', properties: FTS_PROPERTIES },
|
|
27
|
+
{ table: 'Enum', indexName: 'enum_fts', properties: FTS_PROPERTIES },
|
|
28
|
+
{ table: 'Trait', indexName: 'trait_fts', properties: FTS_PROPERTIES },
|
|
29
|
+
{ table: 'Impl', indexName: 'impl_fts', properties: FTS_PROPERTIES },
|
|
30
|
+
{ table: 'Macro', indexName: 'macro_fts', properties: FTS_PROPERTIES },
|
|
31
|
+
{ table: 'Namespace', indexName: 'namespace_fts', properties: FTS_PROPERTIES },
|
|
32
|
+
{ table: 'TypeAlias', indexName: 'type_alias_fts', properties: FTS_PROPERTIES },
|
|
33
|
+
{ table: 'Typedef', indexName: 'typedef_fts', properties: FTS_PROPERTIES },
|
|
34
|
+
{ table: 'Const', indexName: 'const_fts', properties: FTS_PROPERTIES },
|
|
35
|
+
{ table: 'Property', indexName: 'property_fts', properties: FTS_PROPERTIES },
|
|
36
|
+
{ table: 'Record', indexName: 'record_fts', properties: FTS_PROPERTIES },
|
|
37
|
+
{ table: 'Union', indexName: 'union_fts', properties: FTS_PROPERTIES },
|
|
38
|
+
{ table: 'Static', indexName: 'static_fts', properties: FTS_PROPERTIES },
|
|
39
|
+
{ table: 'Variable', indexName: 'variable_fts', properties: FTS_PROPERTIES },
|
|
7
40
|
];
|
|
@@ -217,8 +217,14 @@ export interface RepoMeta {
|
|
|
217
217
|
* so the engine would silently UNDER-REPORT return-value ascent on an
|
|
218
218
|
* incremental top-up; force a full re-analyze instead (same contract as v2/v3).
|
|
219
219
|
* This single bump covers the whole FU-C re-index window (and the later FU-B-2).
|
|
220
|
-
|
|
221
|
-
|
|
220
|
+
* v5: `Route` node identity changed to `(method, url)` (#2289 — a same-URL
|
|
221
|
+
* GET/POST pair is now two distinct Route nodes). Every declarative-route node
|
|
222
|
+
* id moved from `Route:/x` to `Route:GET /x` (filesystem routes keep their
|
|
223
|
+
* URL-only id). The incremental writeback preserves unchanged-file rows, so a
|
|
224
|
+
* top-up against a pre-v5 index would strand old url-keyed Route nodes alongside
|
|
225
|
+
* new composite-keyed ones — force a full re-analyze instead.
|
|
226
|
+
*/
|
|
227
|
+
export declare const INCREMENTAL_SCHEMA_VERSION = 5;
|
|
222
228
|
export interface IndexedRepo {
|
|
223
229
|
repoPath: string;
|
|
224
230
|
storagePath: string;
|
|
@@ -74,8 +74,14 @@ export const registryPathEquals = (a, b) => process.platform === 'win32' ? a.toL
|
|
|
74
74
|
* so the engine would silently UNDER-REPORT return-value ascent on an
|
|
75
75
|
* incremental top-up; force a full re-analyze instead (same contract as v2/v3).
|
|
76
76
|
* This single bump covers the whole FU-C re-index window (and the later FU-B-2).
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
* v5: `Route` node identity changed to `(method, url)` (#2289 — a same-URL
|
|
78
|
+
* GET/POST pair is now two distinct Route nodes). Every declarative-route node
|
|
79
|
+
* id moved from `Route:/x` to `Route:GET /x` (filesystem routes keep their
|
|
80
|
+
* URL-only id). The incremental writeback preserves unchanged-file rows, so a
|
|
81
|
+
* top-up against a pre-v5 index would strand old url-keyed Route nodes alongside
|
|
82
|
+
* new composite-keyed ones — force a full re-analyze instead.
|
|
83
|
+
*/
|
|
84
|
+
export const INCREMENTAL_SCHEMA_VERSION = 5;
|
|
79
85
|
const GITNEXUS_DIR = '.gitnexus';
|
|
80
86
|
const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`;
|
|
81
87
|
// ─── Local Storage Helpers ─────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -70,6 +70,7 @@ const LBUG_NATIVE = [
|
|
|
70
70
|
'test/integration/local-backend-calltool.test.ts',
|
|
71
71
|
'test/integration/search-core.test.ts',
|
|
72
72
|
'test/integration/search-pool.test.ts',
|
|
73
|
+
'test/integration/fts-description-search.test.ts',
|
|
73
74
|
'test/integration/staleness-and-stability.test.ts',
|
|
74
75
|
'test/integration/analyze-wal-checkpoint-failure.test.ts',
|
|
75
76
|
];
|