gitnexus 1.6.6-rc.84 → 1.6.6-rc.86
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/index.d.ts +1 -1
- package/dist/core/group/extractors/http-patterns/java.js +229 -60
- package/dist/core/group/extractors/http-patterns/kotlin.js +106 -11
- package/dist/core/group/extractors/http-patterns/types.d.ts +14 -0
- package/dist/core/group/extractors/http-route-extractor.js +55 -16
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HttpLanguagePlugin } from './types.js';
|
|
2
|
-
export type { HttpDetection, HttpLanguagePlugin, HttpRole } from './types.js';
|
|
2
|
+
export type { HttpDetection, HttpFileDetections, HttpLanguagePlugin, HttpRole, HttpScanInput, } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Glob for files worth scanning for HTTP routes. Kept alongside the
|
|
5
5
|
* registry so adding a new language widens the glob in one edit.
|
|
@@ -22,45 +22,64 @@ const METHOD_ANNOTATION_TO_HTTP = {
|
|
|
22
22
|
DeleteMapping: 'DELETE',
|
|
23
23
|
PatchMapping: 'PATCH',
|
|
24
24
|
};
|
|
25
|
-
// ─── Provider: Spring class-level @RequestMapping prefix
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// @RequestMapping("/api") → (annotation_argument_list (string_literal))
|
|
29
|
-
// @RequestMapping(path = "/api") → (annotation_argument_list (element_value_pair key:(identifier) value:(string_literal)))
|
|
30
|
-
// @RequestMapping(value = "/api") → same as above
|
|
31
|
-
//
|
|
32
|
-
// The named-argument pattern MUST constrain the `key` field to the route
|
|
33
|
-
// member names (`path`/`value`); without it, the query also captures
|
|
34
|
-
// non-route attributes such as `produces`, `consumes`, `headers`, `name`,
|
|
35
|
-
// `params` (their right-hand string literals would be mis-extracted as
|
|
36
|
-
// route prefixes — e.g. `produces = "application/json"` would corrupt
|
|
37
|
-
// every method route under that controller). The sibling
|
|
38
|
-
// `topic-patterns/java.ts` uses the same `key:` constraint approach.
|
|
39
|
-
const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({
|
|
40
|
-
name: 'java-spring-class-prefix',
|
|
25
|
+
// ─── Provider: Spring class/interface-level @RequestMapping prefix ───
|
|
26
|
+
const SPRING_TYPE_PREFIX_PATTERNS = compilePatterns({
|
|
27
|
+
name: 'java-spring-type-prefix',
|
|
41
28
|
language: Java,
|
|
42
29
|
patterns: [
|
|
43
30
|
{
|
|
44
31
|
meta: {},
|
|
45
32
|
query: `
|
|
46
|
-
|
|
47
|
-
(
|
|
48
|
-
(
|
|
49
|
-
|
|
50
|
-
|
|
33
|
+
[
|
|
34
|
+
(class_declaration
|
|
35
|
+
(modifiers
|
|
36
|
+
(annotation
|
|
37
|
+
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
|
38
|
+
arguments: (annotation_argument_list (string_literal) @prefix)))) @type
|
|
39
|
+
(interface_declaration
|
|
40
|
+
(modifiers
|
|
41
|
+
(annotation
|
|
42
|
+
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
|
43
|
+
arguments: (annotation_argument_list (string_literal) @prefix)))) @type
|
|
44
|
+
]
|
|
51
45
|
`,
|
|
52
46
|
},
|
|
53
47
|
{
|
|
54
48
|
meta: {},
|
|
55
49
|
query: `
|
|
56
|
-
|
|
57
|
-
(
|
|
58
|
-
(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
(
|
|
62
|
-
|
|
63
|
-
|
|
50
|
+
[
|
|
51
|
+
(class_declaration
|
|
52
|
+
(modifiers
|
|
53
|
+
(annotation
|
|
54
|
+
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
|
55
|
+
arguments: (annotation_argument_list
|
|
56
|
+
(element_value_pair
|
|
57
|
+
key: (identifier) @key (#match? @key "^(path|value)$")
|
|
58
|
+
value: (string_literal) @prefix))))) @type
|
|
59
|
+
(interface_declaration
|
|
60
|
+
(modifiers
|
|
61
|
+
(annotation
|
|
62
|
+
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
|
63
|
+
arguments: (annotation_argument_list
|
|
64
|
+
(element_value_pair
|
|
65
|
+
key: (identifier) @key (#match? @key "^(path|value)$")
|
|
66
|
+
value: (string_literal) @prefix))))) @type
|
|
67
|
+
]
|
|
68
|
+
`,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
const SPRING_TYPE_DECLARATION_PATTERNS = compilePatterns({
|
|
73
|
+
name: 'java-spring-type-declaration',
|
|
74
|
+
language: Java,
|
|
75
|
+
patterns: [
|
|
76
|
+
{
|
|
77
|
+
meta: {},
|
|
78
|
+
query: `
|
|
79
|
+
[
|
|
80
|
+
(class_declaration name: (identifier) @type_name) @type
|
|
81
|
+
(interface_declaration name: (identifier) @type_name) @type
|
|
82
|
+
]
|
|
64
83
|
`,
|
|
65
84
|
},
|
|
66
85
|
],
|
|
@@ -289,9 +308,9 @@ const APACHE_HTTP_CLIENT_PATTERNS = compilePatterns({
|
|
|
289
308
|
],
|
|
290
309
|
});
|
|
291
310
|
/**
|
|
292
|
-
* Find the nearest enclosing
|
|
293
|
-
* null if the node is top-level. Tree-sitter's
|
|
294
|
-
* one level at a time.
|
|
311
|
+
* Find the nearest enclosing class/interface declaration ancestor for
|
|
312
|
+
* a node, or null if the node is top-level. Tree-sitter's
|
|
313
|
+
* SyntaxNode.parent walks one level at a time.
|
|
295
314
|
*/
|
|
296
315
|
function findEnclosingClass(node) {
|
|
297
316
|
let cur = node.parent;
|
|
@@ -311,23 +330,6 @@ function findEnclosingInterface(node) {
|
|
|
311
330
|
}
|
|
312
331
|
return null;
|
|
313
332
|
}
|
|
314
|
-
function hasAnnotation(node, annotationName) {
|
|
315
|
-
for (const child of node.namedChildren) {
|
|
316
|
-
if (child.type !== 'modifiers')
|
|
317
|
-
continue;
|
|
318
|
-
for (const modifier of child.namedChildren) {
|
|
319
|
-
if (modifier.type !== 'annotation')
|
|
320
|
-
continue;
|
|
321
|
-
const nameNode = modifier.childForFieldName('name');
|
|
322
|
-
if (!nameNode)
|
|
323
|
-
continue;
|
|
324
|
-
const simpleName = nameNode.text.split('.').pop();
|
|
325
|
-
if (nameNode.text === annotationName || simpleName === annotationName)
|
|
326
|
-
return true;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
return false;
|
|
330
|
-
}
|
|
331
333
|
/**
|
|
332
334
|
* Join a class-level prefix and a method-level path into a single URL
|
|
333
335
|
* path. Mirrors the semantics of the original regex implementation:
|
|
@@ -341,22 +343,186 @@ function joinPath(prefix, methodPath) {
|
|
|
341
343
|
return `/${cleanSub}`;
|
|
342
344
|
return `/${cleanPrefix}/${cleanSub}`;
|
|
343
345
|
}
|
|
346
|
+
function getNodeName(node) {
|
|
347
|
+
return node.childForFieldName('name')?.text ?? null;
|
|
348
|
+
}
|
|
349
|
+
function hasAnnotation(node, names) {
|
|
350
|
+
const modifiers = node.namedChildren.find((child) => child.type === 'modifiers');
|
|
351
|
+
if (!modifiers)
|
|
352
|
+
return false;
|
|
353
|
+
const allowed = new Set(typeof names === 'string' ? [names] : names);
|
|
354
|
+
const stack = [...modifiers.namedChildren];
|
|
355
|
+
while (stack.length > 0) {
|
|
356
|
+
const cur = stack.pop();
|
|
357
|
+
const annotationName = cur.childForFieldName('name')?.text ?? '';
|
|
358
|
+
const simpleName = annotationName.split('.').pop() ?? annotationName;
|
|
359
|
+
if ((cur.type === 'annotation' || cur.type === 'marker_annotation') &&
|
|
360
|
+
(allowed.has(annotationName) || allowed.has(simpleName))) {
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
stack.push(...cur.namedChildren);
|
|
364
|
+
}
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
function collectTypePrefixes(tree) {
|
|
368
|
+
const prefixByTypeId = new Map();
|
|
369
|
+
for (const match of runCompiledPatterns(SPRING_TYPE_PREFIX_PATTERNS, tree)) {
|
|
370
|
+
const prefixNode = match.captures.prefix;
|
|
371
|
+
const typeNode = match.captures.type;
|
|
372
|
+
if (!prefixNode || !typeNode)
|
|
373
|
+
continue;
|
|
374
|
+
const prefix = unquoteLiteral(prefixNode.text);
|
|
375
|
+
if (prefix !== null)
|
|
376
|
+
prefixByTypeId.set(typeNode.id, prefix);
|
|
377
|
+
}
|
|
378
|
+
return prefixByTypeId;
|
|
379
|
+
}
|
|
380
|
+
function collectMethodRoutes(tree) {
|
|
381
|
+
const routesByMethodId = new Map();
|
|
382
|
+
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
|
|
383
|
+
const annNode = match.captures.ann;
|
|
384
|
+
const pathNode = match.captures.path;
|
|
385
|
+
const methodNode = match.captures.method;
|
|
386
|
+
if (!annNode || !pathNode || !methodNode)
|
|
387
|
+
continue;
|
|
388
|
+
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
|
|
389
|
+
if (!httpMethod)
|
|
390
|
+
continue;
|
|
391
|
+
const rawPath = unquoteLiteral(pathNode.text);
|
|
392
|
+
if (rawPath === null)
|
|
393
|
+
continue;
|
|
394
|
+
const routes = routesByMethodId.get(methodNode.id) ?? [];
|
|
395
|
+
routes.push({ method: httpMethod, path: rawPath });
|
|
396
|
+
routesByMethodId.set(methodNode.id, routes);
|
|
397
|
+
}
|
|
398
|
+
return routesByMethodId;
|
|
399
|
+
}
|
|
400
|
+
function collectDirectMethods(typeNode) {
|
|
401
|
+
const out = [];
|
|
402
|
+
const visit = (node) => {
|
|
403
|
+
for (const child of node.namedChildren) {
|
|
404
|
+
if (child.type === 'method_declaration') {
|
|
405
|
+
out.push(child);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (child !== typeNode &&
|
|
409
|
+
(child.type === 'class_declaration' || child.type === 'interface_declaration')) {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
visit(child);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
visit(typeNode);
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
function collectImplementedInterfaces(typeNode) {
|
|
419
|
+
const interfacesNode = typeNode.childForFieldName('interfaces');
|
|
420
|
+
if (!interfacesNode)
|
|
421
|
+
return [];
|
|
422
|
+
const out = [];
|
|
423
|
+
const visit = (node) => {
|
|
424
|
+
if (node.type === 'type_identifier' || node.type === 'scoped_type_identifier') {
|
|
425
|
+
out.push(node.text.split('.').pop() ?? node.text);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
for (const child of node.namedChildren)
|
|
429
|
+
visit(child);
|
|
430
|
+
};
|
|
431
|
+
visit(interfacesNode);
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
function collectSpringTypes(filePath, tree) {
|
|
435
|
+
const prefixByTypeId = collectTypePrefixes(tree);
|
|
436
|
+
const routesByMethodId = collectMethodRoutes(tree);
|
|
437
|
+
const out = [];
|
|
438
|
+
for (const match of runCompiledPatterns(SPRING_TYPE_DECLARATION_PATTERNS, tree)) {
|
|
439
|
+
const typeNode = match.captures.type;
|
|
440
|
+
const typeNameNode = match.captures.type_name;
|
|
441
|
+
if (!typeNode || !typeNameNode)
|
|
442
|
+
continue;
|
|
443
|
+
const kind = typeNode.type === 'interface_declaration' ? 'interface' : 'class';
|
|
444
|
+
const methods = collectDirectMethods(typeNode)
|
|
445
|
+
.map((methodNode) => ({
|
|
446
|
+
name: getNodeName(methodNode),
|
|
447
|
+
routes: routesByMethodId.get(methodNode.id) ?? [],
|
|
448
|
+
}))
|
|
449
|
+
.filter((method) => method.name !== null);
|
|
450
|
+
out.push({
|
|
451
|
+
filePath,
|
|
452
|
+
kind,
|
|
453
|
+
name: typeNameNode.text,
|
|
454
|
+
classPrefix: prefixByTypeId.get(typeNode.id) ?? '',
|
|
455
|
+
implementedInterfaces: kind === 'class' ? collectImplementedInterfaces(typeNode) : [],
|
|
456
|
+
isController: kind === 'class' && hasAnnotation(typeNode, ['RestController', 'Controller']),
|
|
457
|
+
methods,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
function scanSpringProject(files) {
|
|
463
|
+
const types = files.flatMap((file) => collectSpringTypes(file.filePath, file.tree));
|
|
464
|
+
const interfaceRoutes = new Map();
|
|
465
|
+
for (const type of types) {
|
|
466
|
+
if (type.kind !== 'interface')
|
|
467
|
+
continue;
|
|
468
|
+
if (interfaceRoutes.has(type.name)) {
|
|
469
|
+
interfaceRoutes.set(type.name, null);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const methodMap = new Map();
|
|
473
|
+
for (const method of type.methods) {
|
|
474
|
+
const routes = method.routes.map((route) => ({
|
|
475
|
+
method: route.method,
|
|
476
|
+
path: type.classPrefix ? joinPath(type.classPrefix, route.path) : route.path,
|
|
477
|
+
}));
|
|
478
|
+
if (routes.length > 0)
|
|
479
|
+
methodMap.set(method.name, routes);
|
|
480
|
+
}
|
|
481
|
+
interfaceRoutes.set(type.name, methodMap);
|
|
482
|
+
}
|
|
483
|
+
const detectionsByFile = new Map();
|
|
484
|
+
for (const type of types) {
|
|
485
|
+
if (type.kind !== 'class' || !type.isController)
|
|
486
|
+
continue;
|
|
487
|
+
for (const method of type.methods) {
|
|
488
|
+
if (method.routes.length > 0)
|
|
489
|
+
continue;
|
|
490
|
+
const inheritedRoutes = type.implementedInterfaces.flatMap((interfaceName) => {
|
|
491
|
+
const routeMap = interfaceRoutes.get(interfaceName);
|
|
492
|
+
if (!routeMap)
|
|
493
|
+
return [];
|
|
494
|
+
const routes = routeMap.get(method.name) ?? [];
|
|
495
|
+
return routes.map((route) => ({
|
|
496
|
+
method: route.method,
|
|
497
|
+
path: joinPath(type.classPrefix, route.path),
|
|
498
|
+
}));
|
|
499
|
+
});
|
|
500
|
+
for (const route of inheritedRoutes) {
|
|
501
|
+
const detections = detectionsByFile.get(type.filePath) ?? [];
|
|
502
|
+
detections.push({
|
|
503
|
+
role: 'provider',
|
|
504
|
+
framework: 'spring',
|
|
505
|
+
method: route.method,
|
|
506
|
+
path: route.path,
|
|
507
|
+
name: method.name,
|
|
508
|
+
confidence: 0.8,
|
|
509
|
+
});
|
|
510
|
+
detectionsByFile.set(type.filePath, detections);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return [...detectionsByFile.entries()].map(([filePath, detections]) => ({
|
|
515
|
+
filePath,
|
|
516
|
+
detections,
|
|
517
|
+
}));
|
|
518
|
+
}
|
|
344
519
|
export const JAVA_HTTP_PLUGIN = {
|
|
345
520
|
name: 'java-http',
|
|
346
521
|
language: Java,
|
|
347
522
|
scan(tree) {
|
|
348
523
|
const out = [];
|
|
349
524
|
// ─── Providers: Spring class prefix + method annotations ────────
|
|
350
|
-
const
|
|
351
|
-
for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) {
|
|
352
|
-
const prefixNode = match.captures.prefix;
|
|
353
|
-
const classNode = match.captures.class;
|
|
354
|
-
if (!prefixNode || !classNode)
|
|
355
|
-
continue;
|
|
356
|
-
const prefix = unquoteLiteral(prefixNode.text);
|
|
357
|
-
if (prefix !== null)
|
|
358
|
-
prefixByClassId.set(classNode.id, prefix);
|
|
359
|
-
}
|
|
525
|
+
const prefixByTypeId = collectTypePrefixes(tree);
|
|
360
526
|
const feignPrefixByInterfaceId = new Map();
|
|
361
527
|
for (const match of runCompiledPatterns(FEIGN_INTERFACE_PREFIX_PATTERNS, tree)) {
|
|
362
528
|
const prefixNode = match.captures.prefix;
|
|
@@ -395,7 +561,9 @@ export const JAVA_HTTP_PLUGIN = {
|
|
|
395
561
|
continue;
|
|
396
562
|
}
|
|
397
563
|
const enclosingClass = findEnclosingClass(methodNode);
|
|
398
|
-
|
|
564
|
+
if (!enclosingClass)
|
|
565
|
+
continue;
|
|
566
|
+
const prefix = prefixByTypeId.get(enclosingClass.id) ?? '';
|
|
399
567
|
const fullPath = joinPath(prefix, rawPath);
|
|
400
568
|
out.push({
|
|
401
569
|
role: 'provider',
|
|
@@ -528,4 +696,5 @@ export const JAVA_HTTP_PLUGIN = {
|
|
|
528
696
|
}
|
|
529
697
|
return out;
|
|
530
698
|
},
|
|
699
|
+
scanProject: scanSpringProject,
|
|
531
700
|
};
|
|
@@ -9,18 +9,22 @@ import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-s
|
|
|
9
9
|
* named annotation arguments (`@GetMapping(value = "/x")` and
|
|
10
10
|
* `@GetMapping(path = "/x")`) are supported.
|
|
11
11
|
*
|
|
12
|
-
* **Consumers**
|
|
12
|
+
* **Consumers** — four call-site patterns common in Kotlin
|
|
13
13
|
* Spring projects:
|
|
14
14
|
*
|
|
15
|
-
* 1. `restTemplate.getForObject("/x", ...)` and friends
|
|
16
|
-
* 2. `webClient.get().uri("/x")`
|
|
17
|
-
* 3. `Request.Builder().url("/x")` (
|
|
15
|
+
* 1. `restTemplate.getForObject("/x", ...)` and friends (#1855)
|
|
16
|
+
* 2. `webClient.get().uri("/x")` — short form (#1855)
|
|
17
|
+
* 3. `Request.Builder().url("/x")` — OkHttp (#1855)
|
|
18
|
+
* 4. `webClient.method(HttpMethod.X).uri("/y")` — long form (this PR)
|
|
18
19
|
*
|
|
19
|
-
* The long
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* The long form puts the verb on a sibling `call_expression` two hops
|
|
21
|
+
* away from the path. Rather than introducing imperative walk-up logic,
|
|
22
|
+
* we use a single deeper tree-sitter query that matches the full chain
|
|
23
|
+
* structurally — see `WEB_CLIENT_LONG_PATTERNS` below. The verb is
|
|
24
|
+
* captured directly as the `simple_identifier` of `HttpMethod.X`, so
|
|
25
|
+
* variable-bound verbs (`val verb = HttpMethod.PATCH; webClient.method(verb)...`)
|
|
26
|
+
* are intentionally NOT picked up — those need a graph-aware resolver
|
|
27
|
+
* and are out of scope for source-scan.
|
|
24
28
|
*
|
|
25
29
|
* tree-sitter-kotlin (fwcd) AST shapes used here:
|
|
26
30
|
* class_declaration
|
|
@@ -96,6 +100,15 @@ const WEB_CLIENT_SHORT_TO_HTTP = {
|
|
|
96
100
|
delete: 'DELETE',
|
|
97
101
|
patch: 'PATCH',
|
|
98
102
|
};
|
|
103
|
+
/**
|
|
104
|
+
* Allowed HTTP verbs for the WebClient long-form path
|
|
105
|
+
* `webClient.method(HttpMethod.X).uri("/y")`. Compiled once at module
|
|
106
|
+
* load (instead of inside the scan loop) per maintainer feedback on
|
|
107
|
+
* PR #1884. Mirrors the keys of `WEB_CLIENT_SHORT_TO_HTTP` above —
|
|
108
|
+
* keeping HEAD/OPTIONS/TRACE intentionally excluded for symmetry
|
|
109
|
+
* with the short form and the Java plugin.
|
|
110
|
+
*/
|
|
111
|
+
const WEB_CLIENT_LONG_VERB_RE = /^(GET|POST|PUT|DELETE|PATCH)$/;
|
|
99
112
|
/**
|
|
100
113
|
* Build the plugin only if the Kotlin grammar is available. Compiling
|
|
101
114
|
* the queries against a null grammar would throw at module load time
|
|
@@ -249,8 +262,9 @@ function buildKotlinPlugin(language) {
|
|
|
249
262
|
// - outer call's first value_argument is a string literal
|
|
250
263
|
//
|
|
251
264
|
// The long-form `webClient.method(HttpMethod.GET).uri("/x")` chain
|
|
252
|
-
// uses an extra navigation hop and an enum field access —
|
|
253
|
-
//
|
|
265
|
+
// uses an extra navigation hop and an enum field access — handled
|
|
266
|
+
// by `WEB_CLIENT_LONG_PATTERNS` below, separately so each query is
|
|
267
|
+
// straightforward to reason about.
|
|
254
268
|
const WEB_CLIENT_SHORT_PATTERNS = compilePatterns({
|
|
255
269
|
name: 'kotlin-web-client-short',
|
|
256
270
|
language,
|
|
@@ -273,6 +287,58 @@ function buildKotlinPlugin(language) {
|
|
|
273
287
|
},
|
|
274
288
|
],
|
|
275
289
|
});
|
|
290
|
+
// ─── Consumer: Spring WebClient (long form) ───────────────────────────
|
|
291
|
+
// The fluent long form passes the verb as a `HttpMethod.X` enum field
|
|
292
|
+
// access through `.method(...)`, then carries the path on a separate
|
|
293
|
+
// `.uri(...)` hop further down the chain:
|
|
294
|
+
//
|
|
295
|
+
// webClient.method(HttpMethod.GET).uri("/x").retrieve().awaitBody<T>()
|
|
296
|
+
//
|
|
297
|
+
// Compared to the short form there are two extra structural hops:
|
|
298
|
+
// - the inner `.method(...)` `call_expression` has a `value_argument`
|
|
299
|
+
// whose payload is itself a `navigation_expression` (HttpMethod → .GET)
|
|
300
|
+
// - the outer `.uri(...)` is reached via one more
|
|
301
|
+
// `navigation_expression` wrapping that inner call
|
|
302
|
+
//
|
|
303
|
+
// We capture the verb at the `simple_identifier` under `HttpMethod`'s
|
|
304
|
+
// `navigation_suffix`. That `simple_identifier` is the literal field
|
|
305
|
+
// name (`GET`, `POST`, ...) used in source — Kotlin enum fields by
|
|
306
|
+
// convention are upper-case, matching `HttpMethod` from
|
|
307
|
+
// `org.springframework.http`. We forward the captured text as-is.
|
|
308
|
+
//
|
|
309
|
+
// Variable-bound verbs (`val verb = HttpMethod.PATCH; webClient.method(verb)...`)
|
|
310
|
+
// do NOT match — they fail the `(navigation_expression ...)` shape
|
|
311
|
+
// because the value_argument carries a bare `simple_identifier` instead
|
|
312
|
+
// of a `HttpMethod.X` field access. This is intentional: source-scan
|
|
313
|
+
// can't follow the binding without graph context. Pinned by an
|
|
314
|
+
// anti-overreach test in the consumer suite.
|
|
315
|
+
const WEB_CLIENT_LONG_PATTERNS = compilePatterns({
|
|
316
|
+
name: 'kotlin-web-client-long',
|
|
317
|
+
language,
|
|
318
|
+
patterns: [
|
|
319
|
+
{
|
|
320
|
+
meta: {},
|
|
321
|
+
query: `
|
|
322
|
+
(call_expression
|
|
323
|
+
(navigation_expression
|
|
324
|
+
(call_expression
|
|
325
|
+
(navigation_expression
|
|
326
|
+
(simple_identifier) @obj (#eq? @obj "webClient")
|
|
327
|
+
(navigation_suffix
|
|
328
|
+
(simple_identifier) @method_call (#eq? @method_call "method")))
|
|
329
|
+
(call_suffix
|
|
330
|
+
(value_arguments
|
|
331
|
+
. (value_argument
|
|
332
|
+
(navigation_expression
|
|
333
|
+
(simple_identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod")
|
|
334
|
+
(navigation_suffix (simple_identifier) @verb))))))
|
|
335
|
+
(navigation_suffix (simple_identifier) @uri (#eq? @uri "uri")))
|
|
336
|
+
(call_suffix
|
|
337
|
+
(value_arguments . (value_argument . (string_literal) @path))))
|
|
338
|
+
`,
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
});
|
|
276
342
|
// ─── Consumer: OkHttp Request.Builder().url("/x") ─────────────────────
|
|
277
343
|
// Kotlin parses `Request.Builder()` as a `call_expression` whose
|
|
278
344
|
// callee is a `navigation_expression` (Request → .Builder), NOT as
|
|
@@ -425,6 +491,35 @@ function buildKotlinPlugin(language) {
|
|
|
425
491
|
confidence: 0.7,
|
|
426
492
|
});
|
|
427
493
|
}
|
|
494
|
+
// ─── Consumers: WebClient long form (.method(HttpMethod.X) → .uri) ─
|
|
495
|
+
for (const match of runCompiledPatterns(WEB_CLIENT_LONG_PATTERNS, tree)) {
|
|
496
|
+
const verbNode = match.captures.verb;
|
|
497
|
+
const pathNode = match.captures.path;
|
|
498
|
+
if (!verbNode || !pathNode)
|
|
499
|
+
continue;
|
|
500
|
+
// The captured text is the literal `HttpMethod.X` field name.
|
|
501
|
+
// Spring's `org.springframework.http.HttpMethod` defines GET,
|
|
502
|
+
// POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE — we only
|
|
503
|
+
// emit for the five verbs we already handle elsewhere, so
|
|
504
|
+
// exotic ones are silently skipped (consistent with the
|
|
505
|
+
// short form's WEB_CLIENT_SHORT_TO_HTTP guard). The accepted
|
|
506
|
+
// verb regex is hoisted to module scope (see
|
|
507
|
+
// `WEB_CLIENT_LONG_VERB_RE` near the top of this file).
|
|
508
|
+
const verbText = verbNode.text;
|
|
509
|
+
if (!WEB_CLIENT_LONG_VERB_RE.test(verbText))
|
|
510
|
+
continue;
|
|
511
|
+
const path = unquoteLiteral(pathNode.text);
|
|
512
|
+
if (path === null)
|
|
513
|
+
continue;
|
|
514
|
+
out.push({
|
|
515
|
+
role: 'consumer',
|
|
516
|
+
framework: 'spring-web-client',
|
|
517
|
+
method: verbText,
|
|
518
|
+
path,
|
|
519
|
+
name: null,
|
|
520
|
+
confidence: 0.7,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
428
523
|
// ─── Consumers: OkHttp Request.Builder().url("path") ────────────
|
|
429
524
|
for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) {
|
|
430
525
|
const pathNode = match.captures.path;
|
|
@@ -36,6 +36,14 @@ export interface HttpDetection {
|
|
|
36
36
|
/** Confidence in (0, 1]. Source-scan plugins typically use 0.7–0.8. */
|
|
37
37
|
confidence: number;
|
|
38
38
|
}
|
|
39
|
+
export interface HttpScanInput {
|
|
40
|
+
filePath: string;
|
|
41
|
+
tree: Parser.Tree;
|
|
42
|
+
}
|
|
43
|
+
export interface HttpFileDetections {
|
|
44
|
+
filePath: string;
|
|
45
|
+
detections: HttpDetection[];
|
|
46
|
+
}
|
|
39
47
|
/**
|
|
40
48
|
* One language-scoped HTTP plugin. The plugin owns the tree-sitter
|
|
41
49
|
* grammar and the `scan` function that translates a parsed tree into
|
|
@@ -90,4 +98,10 @@ export interface HttpLanguagePlugin {
|
|
|
90
98
|
* single-file plugins can keep their unary `scan(tree)` shape.
|
|
91
99
|
*/
|
|
92
100
|
scan(tree: Parser.Tree, repoContext?: RepoContext, fileRel?: string): HttpDetection[];
|
|
101
|
+
/**
|
|
102
|
+
* Optional project-level scan hook for language rules that require
|
|
103
|
+
* multiple files, such as Java controllers inheriting Spring mappings
|
|
104
|
+
* from annotated interfaces.
|
|
105
|
+
*/
|
|
106
|
+
scanProject?(files: readonly HttpScanInput[]): HttpFileDetections[];
|
|
93
107
|
}
|
|
@@ -4,7 +4,7 @@ import Parser from 'tree-sitter';
|
|
|
4
4
|
import { createIgnoreFilter } from '../../../config/ignore-service.js';
|
|
5
5
|
import { readSafe } from './fs-utils.js';
|
|
6
6
|
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
|
|
7
|
-
import { getPluginForFile, HTTP_SCAN_GLOB } from './http-patterns/index.js';
|
|
7
|
+
import { getPluginForFile, HTTP_SCAN_GLOB, } from './http-patterns/index.js';
|
|
8
8
|
/**
|
|
9
9
|
* Language-agnostic orchestrator for HTTP route (provider + consumer)
|
|
10
10
|
* contract extraction. Two strategies, in order of preference per role:
|
|
@@ -141,6 +141,9 @@ export class HttpRouteExtractor {
|
|
|
141
141
|
// both graph-assisted enrichment and source-scan emission.
|
|
142
142
|
const parser = new Parser();
|
|
143
143
|
const cachedDetections = new Map();
|
|
144
|
+
const cachedInputs = new Map();
|
|
145
|
+
const projectDetections = new Map();
|
|
146
|
+
let projectScanComplete = false;
|
|
144
147
|
// Per-plugin cross-file context (e.g. Python's FastAPI router →
|
|
145
148
|
// include_router(prefix=...) map). Built lazily on first
|
|
146
149
|
// `getDetections` call for a file the plugin handles, scoped to the
|
|
@@ -169,33 +172,45 @@ export class HttpRouteExtractor {
|
|
|
169
172
|
return undefined;
|
|
170
173
|
}
|
|
171
174
|
};
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
return cached;
|
|
175
|
+
const getScanInput = async (rel) => {
|
|
176
|
+
if (cachedInputs.has(rel))
|
|
177
|
+
return cachedInputs.get(rel) ?? null;
|
|
176
178
|
const plugin = getPluginForFile(rel);
|
|
177
179
|
if (!plugin) {
|
|
178
|
-
|
|
179
|
-
return
|
|
180
|
+
cachedInputs.set(rel, null);
|
|
181
|
+
return null;
|
|
180
182
|
}
|
|
181
183
|
const repoContext = await ensureRepoContext(plugin);
|
|
182
184
|
const content = readSafe(repoPath, rel);
|
|
183
185
|
if (!content) {
|
|
184
|
-
|
|
185
|
-
return
|
|
186
|
+
cachedInputs.set(rel, null);
|
|
187
|
+
return null;
|
|
186
188
|
}
|
|
187
189
|
try {
|
|
188
190
|
parser.setLanguage(plugin.language);
|
|
189
191
|
const tree = parseSourceSafe(parser, content);
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
const input = { filePath: rel, tree };
|
|
193
|
+
const item = { plugin, input, repoContext };
|
|
194
|
+
cachedInputs.set(rel, item);
|
|
195
|
+
return item;
|
|
193
196
|
}
|
|
194
197
|
catch {
|
|
195
|
-
|
|
196
|
-
return
|
|
198
|
+
cachedInputs.set(rel, null);
|
|
199
|
+
return null;
|
|
197
200
|
}
|
|
198
201
|
};
|
|
202
|
+
const getDetections = async (rel) => {
|
|
203
|
+
const cached = cachedDetections.get(rel);
|
|
204
|
+
if (cached)
|
|
205
|
+
return cached;
|
|
206
|
+
const scanInput = await getScanInput(rel);
|
|
207
|
+
const ownDetections = scanInput
|
|
208
|
+
? scanInput.plugin.scan(scanInput.input.tree, scanInput.repoContext, rel)
|
|
209
|
+
: [];
|
|
210
|
+
const detections = [...ownDetections, ...(projectDetections.get(rel) ?? [])];
|
|
211
|
+
cachedDetections.set(rel, detections);
|
|
212
|
+
return detections;
|
|
213
|
+
};
|
|
199
214
|
// Glob the source-scan file list at most once per extract() —
|
|
200
215
|
// both provider and consumer fallback paths share the same list.
|
|
201
216
|
let scannedFiles = null;
|
|
@@ -205,12 +220,36 @@ export class HttpRouteExtractor {
|
|
|
205
220
|
scannedFiles = await this.scanFiles(repoPath);
|
|
206
221
|
return scannedFiles;
|
|
207
222
|
};
|
|
223
|
+
const collectProjectDetections = async (files) => {
|
|
224
|
+
if (projectScanComplete)
|
|
225
|
+
return;
|
|
226
|
+
projectScanComplete = true;
|
|
227
|
+
const byPlugin = new Map();
|
|
228
|
+
for (const rel of files) {
|
|
229
|
+
const scanInput = await getScanInput(rel);
|
|
230
|
+
if (!scanInput?.plugin.scanProject)
|
|
231
|
+
continue;
|
|
232
|
+
const items = byPlugin.get(scanInput.plugin) ?? [];
|
|
233
|
+
items.push(scanInput.input);
|
|
234
|
+
byPlugin.set(scanInput.plugin, items);
|
|
235
|
+
}
|
|
236
|
+
for (const [plugin, inputs] of byPlugin) {
|
|
237
|
+
const results = plugin.scanProject?.(inputs) ?? [];
|
|
238
|
+
for (const result of results) {
|
|
239
|
+
const existing = projectDetections.get(result.filePath) ?? [];
|
|
240
|
+
projectDetections.set(result.filePath, [...existing, ...result.detections]);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
cachedDetections.clear();
|
|
244
|
+
};
|
|
245
|
+
const files = await getScannedFiles();
|
|
246
|
+
await collectProjectDetections(files);
|
|
208
247
|
const graphProviders = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, getDetections) : [];
|
|
209
248
|
// Source scan always runs to capture routes in languages/files not covered
|
|
210
249
|
// by graph edges; the glob and per-file parse results are cached above.
|
|
211
|
-
const providers = this.mergeGraphAndSourceContracts(graphProviders, await this.extractProvidersSourceScan(
|
|
250
|
+
const providers = this.mergeGraphAndSourceContracts(graphProviders, await this.extractProvidersSourceScan(files, getDetections));
|
|
212
251
|
const graphConsumers = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : [];
|
|
213
|
-
const consumers = this.mergeGraphAndSourceContracts(graphConsumers, await this.extractConsumersSourceScan(
|
|
252
|
+
const consumers = this.mergeGraphAndSourceContracts(graphConsumers, await this.extractConsumersSourceScan(files, getDetections));
|
|
214
253
|
return [...providers, ...consumers];
|
|
215
254
|
}
|
|
216
255
|
async scanFiles(repoPath) {
|
package/package.json
CHANGED