gitnexus 1.6.10-rc.215 → 1.6.10-rc.217
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 +187 -8
- package/dist/core/ingestion/import-resolvers/php.js +64 -37
- package/dist/core/ingestion/language-config.d.ts +9 -0
- package/dist/core/ingestion/language-config.js +92 -12
- package/dist/core/ingestion/language-provider.d.ts +58 -0
- package/dist/core/ingestion/language-provider.js +21 -0
- package/dist/core/ingestion/languages/java.js +21 -0
- package/dist/core/ingestion/languages/php/import-target.d.ts +3 -4
- package/dist/core/ingestion/languages/php/import-target.js +65 -49
- package/dist/core/ingestion/languages/python.js +13 -0
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +9 -2
- package/dist/core/ingestion/route-extractors/constant-resolver.d.ts +6 -0
- package/dist/core/ingestion/route-extractors/constant-resolver.js +1 -1
- package/dist/core/ingestion/route-extractors/java-const-resolver.d.ts +114 -0
- package/dist/core/ingestion/route-extractors/java-const-resolver.js +569 -0
- package/dist/core/ingestion/route-extractors/spring.js +105 -5
- package/dist/core/ingestion/workers/parse-worker.js +13 -6
- package/dist/storage/parse-cache.js +32 -1
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import Java from 'tree-sitter-java';
|
|
|
2
2
|
import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-sitter-scanner.js';
|
|
3
3
|
import { springAnnotationHttpMethods, intersectSpringHttpMethods, isRouteMemberKey, findEnclosingClass, joinPath, } from '../../../ingestion/route-extractors/spring-shared.js';
|
|
4
4
|
import { REST_TEMPLATE_TO_HTTP, WEB_CLIENT_SHORT_TO_HTTP, WEB_CLIENT_LONG_VERB_RE, EXCHANGE_ANNOTATION_TO_HTTP, parseRequestLine, pushPrefix, scanSpringInheritanceProject, OPENFEIGN_FRAMEWORK, HTTP_INTERFACE_FRAMEWORK, FEIGN_CONFIDENCE, REQUEST_LINE_CONFIDENCE, EXCHANGE_CONFIDENCE, } from './spring-consumer-shared.js';
|
|
5
|
+
import { extractJavaModuleConstants, foldJavaOperands, isJavaConstantFile, parseJavaConstOperands, } from '../../../ingestion/route-extractors/java-const-resolver.js';
|
|
5
6
|
import { extractStaticPathExpression, inferOkHttpMethod, inferHttpClientMethod, okHttpUrlRootsAtBuilder, httpClientUriRootsAtNewBuilder, httpClientChainHasUriCall, } from './java-static-path.js';
|
|
6
7
|
/**
|
|
7
8
|
* Java HTTP plugin. Handles:
|
|
@@ -123,6 +124,34 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
|
|
|
123
124
|
key: (identifier) @key
|
|
124
125
|
value: [(string_literal) @value (element_value_array_initializer (string_literal) @value)]))))
|
|
125
126
|
name: (identifier) @member) @node
|
|
127
|
+
(class_declaration
|
|
128
|
+
(modifiers
|
|
129
|
+
(annotation
|
|
130
|
+
name: [(identifier) (scoped_identifier)] @ann
|
|
131
|
+
arguments: (annotation_argument_list [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr])))) @node
|
|
132
|
+
(class_declaration
|
|
133
|
+
(modifiers
|
|
134
|
+
(annotation
|
|
135
|
+
name: [(identifier) (scoped_identifier)] @ann
|
|
136
|
+
arguments: (annotation_argument_list
|
|
137
|
+
(element_value_pair
|
|
138
|
+
key: (identifier) @key
|
|
139
|
+
value: [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr]))))) @node
|
|
140
|
+
(method_declaration
|
|
141
|
+
(modifiers
|
|
142
|
+
(annotation
|
|
143
|
+
name: [(identifier) (scoped_identifier)] @ann
|
|
144
|
+
arguments: (annotation_argument_list [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr])))
|
|
145
|
+
name: (identifier) @member) @node
|
|
146
|
+
(method_declaration
|
|
147
|
+
(modifiers
|
|
148
|
+
(annotation
|
|
149
|
+
name: [(identifier) (scoped_identifier)] @ann
|
|
150
|
+
arguments: (annotation_argument_list
|
|
151
|
+
(element_value_pair
|
|
152
|
+
key: (identifier) @key
|
|
153
|
+
value: [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr]))))
|
|
154
|
+
name: (identifier) @member) @node
|
|
126
155
|
]
|
|
127
156
|
`,
|
|
128
157
|
},
|
|
@@ -407,6 +436,7 @@ function scanRouteAnnotations(tree) {
|
|
|
407
436
|
// feeds the OpenFeign *consumer* path in scan(). An interface carrying both
|
|
408
437
|
// `@RequestMapping` and `@FeignClient(path)` lands a different value in each.
|
|
409
438
|
const prefixByTypeId = new Map();
|
|
439
|
+
const typesWithUnfoldablePrefix = new Set();
|
|
410
440
|
const feignPrefixByInterfaceId = new Map();
|
|
411
441
|
const httpExchangePrefixByTypeId = new Map();
|
|
412
442
|
const methodRoutes = [];
|
|
@@ -422,7 +452,10 @@ function scanRouteAnnotations(tree) {
|
|
|
422
452
|
const annNode = captures.ann;
|
|
423
453
|
const node = captures.node;
|
|
424
454
|
const valueNode = captures.value;
|
|
425
|
-
|
|
455
|
+
// A non-literal annotation value (constant ref / `+`-concat) is captured
|
|
456
|
+
// as @value_expr instead of @value — one of the two must be present.
|
|
457
|
+
const valueExprNode = captures.value_expr;
|
|
458
|
+
if (!annNode || !node || (!valueNode && !valueExprNode))
|
|
426
459
|
continue;
|
|
427
460
|
// Discrimination is on the trailing segment only (`simpleName`), so a
|
|
428
461
|
// non-Spring annotation whose last segment collides with a route annotation
|
|
@@ -446,7 +479,7 @@ function scanRouteAnnotations(tree) {
|
|
|
446
479
|
const feignHttpMethod = httpMethods.length === 1 ? (httpMethods[0] === '*' ? 'GET' : httpMethods[0]) : null;
|
|
447
480
|
if (!isRouteMemberKey(keyNode))
|
|
448
481
|
continue;
|
|
449
|
-
const rawPath = unquoteLiteral(valueNode.text);
|
|
482
|
+
const rawPath = valueNode ? unquoteLiteral(valueNode.text) : null;
|
|
450
483
|
if (rawPath !== null) {
|
|
451
484
|
for (const httpMethod of httpMethods) {
|
|
452
485
|
methodRoutes.push({
|
|
@@ -458,11 +491,36 @@ function scanRouteAnnotations(tree) {
|
|
|
458
491
|
});
|
|
459
492
|
}
|
|
460
493
|
}
|
|
494
|
+
else {
|
|
495
|
+
// Non-literal path (a constant reference or `+`-concatenation).
|
|
496
|
+
// Defer to scan(): the fold needs the repo-wide constant map built
|
|
497
|
+
// by prepareRepo. Capture the operand list now; resolution happens
|
|
498
|
+
// in scan() against JavaRepoContext, and an unresolvable operand
|
|
499
|
+
// list leaves the route skipped (KTD5 skip floor).
|
|
500
|
+
const operands = parseJavaConstOperands(valueExprNode);
|
|
501
|
+
if (operands !== null) {
|
|
502
|
+
for (const httpMethod of httpMethods) {
|
|
503
|
+
methodRoutes.push({
|
|
504
|
+
methodNode: node,
|
|
505
|
+
methodName: captures.member?.text ?? null,
|
|
506
|
+
httpMethod,
|
|
507
|
+
rawPath: '',
|
|
508
|
+
feignHttpMethod,
|
|
509
|
+
pathOperands: operands,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
461
514
|
}
|
|
462
515
|
else if (ann === 'RequestLine') {
|
|
463
516
|
// Feign packs verb + path in one literal; its only named argument is `value`.
|
|
464
517
|
if (keyNode && keyNode.text !== 'value')
|
|
465
518
|
continue;
|
|
519
|
+
// A constant-valued `@RequestLine` arrives as @value_expr, not @value —
|
|
520
|
+
// `valueNode` is undefined in that shape. Skip rather than dereference
|
|
521
|
+
// (constant folding for Feign verb+path literals is out of scope here).
|
|
522
|
+
if (!valueNode)
|
|
523
|
+
continue;
|
|
466
524
|
const raw = unquoteLiteral(valueNode.text);
|
|
467
525
|
const parsed = raw !== null ? parseRequestLine(raw) : null;
|
|
468
526
|
if (parsed) {
|
|
@@ -479,7 +537,7 @@ function scanRouteAnnotations(tree) {
|
|
|
479
537
|
// (`accept`, `contentType`, …) are not routes.
|
|
480
538
|
if (keyNode && keyNode.text !== 'url' && keyNode.text !== 'value')
|
|
481
539
|
continue;
|
|
482
|
-
const rawPath = unquoteLiteral(valueNode.text);
|
|
540
|
+
const rawPath = valueNode ? unquoteLiteral(valueNode.text) : null;
|
|
483
541
|
if (rawPath !== null) {
|
|
484
542
|
exchangeRoutes.push({
|
|
485
543
|
methodNode: node,
|
|
@@ -496,6 +554,11 @@ function scanRouteAnnotations(tree) {
|
|
|
496
554
|
if (ann === 'RequestMapping') {
|
|
497
555
|
if (!isRouteMemberKey(keyNode))
|
|
498
556
|
continue;
|
|
557
|
+
if (!valueNode) {
|
|
558
|
+
// Constant-valued class prefix — see `typesWithUnfoldablePrefix`.
|
|
559
|
+
typesWithUnfoldablePrefix.add(node.id);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
499
562
|
const prefix = unquoteLiteral(valueNode.text);
|
|
500
563
|
if (prefix !== null) {
|
|
501
564
|
pushPrefix(prefixByTypeId, node.id, prefix);
|
|
@@ -508,7 +571,7 @@ function scanRouteAnnotations(tree) {
|
|
|
508
571
|
// Feign's `name`/`value` identify a service, not a path — only `path` is a prefix.
|
|
509
572
|
if (!keyNode || keyNode.text !== 'path')
|
|
510
573
|
continue;
|
|
511
|
-
const prefix = unquoteLiteral(valueNode.text);
|
|
574
|
+
const prefix = valueNode ? unquoteLiteral(valueNode.text) : null;
|
|
512
575
|
if (prefix !== null)
|
|
513
576
|
pushPrefix(feignPrefixByInterfaceId, node.id, prefix);
|
|
514
577
|
}
|
|
@@ -517,7 +580,7 @@ function scanRouteAnnotations(tree) {
|
|
|
517
580
|
// (or positionally). Applies to its `@(Get|...)Exchange` consumer methods.
|
|
518
581
|
if (keyNode && keyNode.text !== 'url' && keyNode.text !== 'value')
|
|
519
582
|
continue;
|
|
520
|
-
const prefix = unquoteLiteral(valueNode.text);
|
|
583
|
+
const prefix = valueNode ? unquoteLiteral(valueNode.text) : null;
|
|
521
584
|
if (prefix !== null)
|
|
522
585
|
pushPrefix(httpExchangePrefixByTypeId, node.id, prefix);
|
|
523
586
|
}
|
|
@@ -566,6 +629,7 @@ function scanRouteAnnotations(tree) {
|
|
|
566
629
|
}
|
|
567
630
|
return {
|
|
568
631
|
prefixByTypeId,
|
|
632
|
+
typesWithUnfoldablePrefix,
|
|
569
633
|
feignPrefixByInterfaceId,
|
|
570
634
|
httpExchangePrefixByTypeId,
|
|
571
635
|
methodRoutes: constrainedMethodRoutes,
|
|
@@ -608,9 +672,22 @@ function collectImplementedInterfaces(typeNode) {
|
|
|
608
672
|
return out;
|
|
609
673
|
}
|
|
610
674
|
function collectSpringTypes(filePath, tree) {
|
|
611
|
-
const { prefixByTypeId, methodRoutes } = scanRouteAnnotations(tree);
|
|
675
|
+
const { prefixByTypeId, typesWithUnfoldablePrefix, methodRoutes } = scanRouteAnnotations(tree);
|
|
612
676
|
const routesByMethodId = new Map();
|
|
613
677
|
for (const route of methodRoutes) {
|
|
678
|
+
// Constant-valued class prefix: no single prefix string exists here, so the
|
|
679
|
+
// inheritance view would publish this route unprefixed. Skip — same rule as
|
|
680
|
+
// scan() and as ingestion (R4 parity).
|
|
681
|
+
const owner = findEnclosingClass(route.methodNode);
|
|
682
|
+
if (owner && typesWithUnfoldablePrefix.has(owner.id))
|
|
683
|
+
continue;
|
|
684
|
+
// A constant-referencing route still carries `rawPath: ''` here — folding
|
|
685
|
+
// happens in scan() against the repo constant map, which this
|
|
686
|
+
// inheritance-view collector has no access to. Emitting it as an empty
|
|
687
|
+
// path would publish `POST /`-shaped noise into the shared type view;
|
|
688
|
+
// skip instead (ingestion keeps the same skip floor — R4 parity).
|
|
689
|
+
if (route.pathOperands)
|
|
690
|
+
continue;
|
|
614
691
|
const routes = routesByMethodId.get(route.methodNode.id) ?? [];
|
|
615
692
|
routes.push({ method: route.httpMethod, path: route.rawPath });
|
|
616
693
|
routesByMethodId.set(route.methodNode.id, routes);
|
|
@@ -671,19 +748,121 @@ export const JAVA_HTTP_PLUGIN = {
|
|
|
671
748
|
hasConsumerSignals(content) {
|
|
672
749
|
return /\brestTemplate\b|\bwebClient\b|Request\.Builder|HttpRequest|HttpMethod\.|new\s+Http(Get|Post|Put|Delete|Patch)\b|@RequestLine|@FeignClient|Exchange/.test(content);
|
|
673
750
|
},
|
|
674
|
-
|
|
751
|
+
prepareRepo(args) {
|
|
752
|
+
// Build the repo-wide Java string-constant map once per extract() run
|
|
753
|
+
// (mirrors the Python binding's cost-gated pre-pass). A cheap content
|
|
754
|
+
// gate keeps literal-only repos at zero parses: only files containing a
|
|
755
|
+
// `static final String` declaration are parsed for constants.
|
|
756
|
+
try {
|
|
757
|
+
// The orchestrator hands over a bare Parser (no language set yet);
|
|
758
|
+
// bind Java explicitly — Python's prepareRepo does the same — otherwise
|
|
759
|
+
// parseSourceSafe spins to its 15 s budget per file.
|
|
760
|
+
args.parser.setLanguage(Java);
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
// fall through: a parser that rejects binding cannot produce a constant
|
|
764
|
+
// map; per-file try/catch below then skips everything harmlessly.
|
|
765
|
+
}
|
|
766
|
+
const constants = new Map();
|
|
767
|
+
for (const rel of args.files) {
|
|
768
|
+
if (!rel.endsWith('.java'))
|
|
769
|
+
continue;
|
|
770
|
+
try {
|
|
771
|
+
const src = args.readFile(rel);
|
|
772
|
+
// Cheap content gate: only constant-DEFINITION candidates get parsed
|
|
773
|
+
// here (~hundreds of files). Import-only files (every controller)
|
|
774
|
+
// are deliberately NOT parsed in this pass — scan() lazily extracts
|
|
775
|
+
// the importing file's own import table from the tree it already
|
|
776
|
+
// holds when a constant-referencing route actually needs the fold.
|
|
777
|
+
// A gate that also matched `import ...;` would parse the entire
|
|
778
|
+
// repository here (tens of thousands of files) just to build import
|
|
779
|
+
// tables the fold can derive per-file on demand.
|
|
780
|
+
//
|
|
781
|
+
// The predicate is the SHARED one the ingestion provider uses, so the
|
|
782
|
+
// two subsystems agree on which files define constants. Its previous
|
|
783
|
+
// local spelling missed `final static String` and lowercase interface
|
|
784
|
+
// names, and admitted an interface that ingestion's gate rejected.
|
|
785
|
+
if (!src || !isJavaConstantFile(src)) {
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
const tree = args.parseSource(args.parser, src);
|
|
789
|
+
if (!tree)
|
|
790
|
+
continue;
|
|
791
|
+
const mc = extractJavaModuleConstants(tree);
|
|
792
|
+
if (mc.literals.size > 0 || mc.exprs.size > 0 || mc.imports.size > 0) {
|
|
793
|
+
constants.set(rel, mc);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
// Per-file resilience: one unreadable/oversized/ill-formed file must
|
|
798
|
+
// not forfeit the whole repo's constant map (a missing constants
|
|
799
|
+
// class only degrades refs that pointed at it).
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return { constants };
|
|
804
|
+
},
|
|
805
|
+
scan(tree, repoContext, fileRel) {
|
|
675
806
|
const out = [];
|
|
807
|
+
const javaCtx = repoContext;
|
|
676
808
|
// ─── Spring providers + OpenFeign consumers (one query pass) ────
|
|
677
809
|
// `scanRouteAnnotations` resolves every route-defining annotation —
|
|
678
810
|
// class/interface prefixes, method shortcut/`@RequestMapping`s and native
|
|
679
811
|
// `@RequestLine`s — from a single `matches()` pass over the tree.
|
|
680
|
-
const { prefixByTypeId, feignPrefixByInterfaceId, httpExchangePrefixByTypeId, methodRoutes, requestLines, exchangeRoutes, } = scanRouteAnnotations(tree);
|
|
812
|
+
const { prefixByTypeId, typesWithUnfoldablePrefix, feignPrefixByInterfaceId, httpExchangePrefixByTypeId, methodRoutes, requestLines, exchangeRoutes, } = scanRouteAnnotations(tree);
|
|
681
813
|
// A `@(Get|...)Mapping` inside a `@FeignClient` interface is an OpenFeign
|
|
682
814
|
// *consumer* (it describes a remote call); the same annotation inside a
|
|
683
815
|
// class is a Spring *provider*. A mapping on a non-Feign interface has no
|
|
684
816
|
// enclosing class and is dropped here — interface→controller inheritance is
|
|
685
817
|
// handled by `scanProject`.
|
|
818
|
+
// Lazy per-file constants view. prepareRepo only indexes constant-
|
|
819
|
+
// DEFINING files (cheap gate); an importing controller is absent from
|
|
820
|
+
// that map. When a route actually references a constant, extract THIS
|
|
821
|
+
// file's import table from the tree scan() already holds (zero extra
|
|
822
|
+
// parses) and overlay it for the fold. Files whose routes are all
|
|
823
|
+
// literal — the overwhelming majority — never pay this cost.
|
|
824
|
+
let foldConstants;
|
|
825
|
+
const getFoldConstants = () => {
|
|
826
|
+
if (foldConstants !== undefined)
|
|
827
|
+
return foldConstants;
|
|
828
|
+
foldConstants = javaCtx?.constants;
|
|
829
|
+
if (!javaCtx?.constants || !fileRel)
|
|
830
|
+
return foldConstants;
|
|
831
|
+
if (javaCtx.constants.has(fileRel))
|
|
832
|
+
return foldConstants;
|
|
833
|
+
try {
|
|
834
|
+
const mc = extractJavaModuleConstants(tree);
|
|
835
|
+
if (mc.imports.size > 0) {
|
|
836
|
+
const merged = new Map(javaCtx.constants);
|
|
837
|
+
merged.set(fileRel, mc);
|
|
838
|
+
foldConstants = merged;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
// fold falls back to the repo-wide map (imports stay unresolved)
|
|
843
|
+
}
|
|
844
|
+
return foldConstants;
|
|
845
|
+
};
|
|
686
846
|
for (const route of methodRoutes) {
|
|
847
|
+
// A constant-valued CLASS prefix cannot be folded here, so every method
|
|
848
|
+
// route under such a class is suppressed rather than emitted at a wrong
|
|
849
|
+
// (unprefixed) path — the same rule `classesWithArrayPrefix` already
|
|
850
|
+
// encodes for the array form, and the same rule ingestion applies.
|
|
851
|
+
const owner = findEnclosingClass(route.methodNode);
|
|
852
|
+
if (owner && typesWithUnfoldablePrefix.has(owner.id))
|
|
853
|
+
continue;
|
|
854
|
+
// Non-literal route path: fold the operand list against the repo-wide
|
|
855
|
+
// constant map. Skip (never a guessed path) when the fold fails or the
|
|
856
|
+
// repo context is absent (context-less fallback scanning).
|
|
857
|
+
if (route.pathOperands && javaCtx && fileRel) {
|
|
858
|
+
const resolved = foldJavaOperands(fileRel, route.pathOperands, getFoldConstants());
|
|
859
|
+
if (resolved === null)
|
|
860
|
+
continue;
|
|
861
|
+
route.rawPath = resolved;
|
|
862
|
+
}
|
|
863
|
+
else if (route.pathOperands) {
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
687
866
|
const enclosingInterface = findEnclosingInterface(route.methodNode);
|
|
688
867
|
if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) {
|
|
689
868
|
if (!route.feignHttpMethod)
|
|
@@ -36,12 +36,23 @@ export function resolvePhpImportInternal(importPath, composerConfig, allFiles, n
|
|
|
36
36
|
return null;
|
|
37
37
|
if (composerConfig) {
|
|
38
38
|
const sorted = getSortedPsr4(composerConfig);
|
|
39
|
+
const authoritativePsr4 = composerConfig.authoritativePsr4 ?? new Set(sorted.map(([namespace]) => namespace));
|
|
40
|
+
let matchedAuthoritativeNamespace = false;
|
|
41
|
+
let hasAuthoritativeCatchAllNamespace = false;
|
|
42
|
+
const ownershipPath = normalized.replace(/^\/+/, '');
|
|
39
43
|
for (const [nsPrefix, dirPrefix] of sorted) {
|
|
40
|
-
const nsPrefixSlash = nsPrefix.replace(/\\/g, '/');
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
const nsPrefixSlash = nsPrefix.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
45
|
+
const isCatchAll = nsPrefixSlash === '';
|
|
46
|
+
if (isCatchAll ||
|
|
47
|
+
ownershipPath.startsWith(nsPrefixSlash + '/') ||
|
|
48
|
+
ownershipPath === nsPrefixSlash) {
|
|
49
|
+
const isAuthoritative = authoritativePsr4.has(nsPrefix);
|
|
50
|
+
matchedAuthoritativeNamespace ||= isAuthoritative;
|
|
51
|
+
hasAuthoritativeCatchAllNamespace ||= isAuthoritative && isCatchAll;
|
|
52
|
+
const remainder = ownershipPath.slice(nsPrefixSlash.length).replace(/^\//, '');
|
|
43
53
|
// 1. Try class-style PSR-4: full path → file (e.g. App\Models\User → app/Models/User.php)
|
|
44
|
-
const
|
|
54
|
+
const mappedPath = dirPrefix === '' ? remainder : dirPrefix + (remainder ? '/' + remainder : '');
|
|
55
|
+
const filePath = mappedPath + '.php';
|
|
45
56
|
if (allFiles.has(filePath))
|
|
46
57
|
return filePath;
|
|
47
58
|
if (index) {
|
|
@@ -51,44 +62,60 @@ export function resolvePhpImportInternal(importPath, composerConfig, allFiles, n
|
|
|
51
62
|
}
|
|
52
63
|
// 2. Function/constant fallback: strip last segment (symbol name), scan namespace directory.
|
|
53
64
|
// e.g. App\Models\getUser → directory app/Models/, find first .php file in that dir.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
65
|
+
// A root/catch-all mapping cannot safely infer a symbol's declaring
|
|
66
|
+
// file from an arbitrary sibling. The higher-level PHP resolver has
|
|
67
|
+
// parsed symbol-kind and declaration evidence for function/const
|
|
68
|
+
// imports; class imports must not inherit this directory heuristic.
|
|
69
|
+
if (!isCatchAll && dirPrefix !== '') {
|
|
70
|
+
const lastSlash = remainder.lastIndexOf('/');
|
|
71
|
+
const relativeNamespace = lastSlash >= 0 ? remainder.slice(0, lastSlash) : '';
|
|
72
|
+
const nsDir = relativeNamespace === '' ? dirPrefix : `${dirPrefix}/${relativeNamespace}`;
|
|
73
|
+
// Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan.
|
|
74
|
+
//
|
|
75
|
+
// An EMPTY bucket is a final answer, not a miss to retry with the scan
|
|
76
|
+
// below — which is what the `else` restores, and what this comment
|
|
77
|
+
// always claimed. Re-scanning on empty was the last per-import
|
|
78
|
+
// workspace traversal left in PHP resolution after #2901: any `use`
|
|
79
|
+
// matching a PSR-4 prefix whose directory holds no direct `.php` child
|
|
80
|
+
// (`App\Legacy\Ghost`) paid a full pass, measured at 201 traversals for
|
|
81
|
+
// 200 imports.
|
|
82
|
+
//
|
|
83
|
+
// The bucket is a superset of what the scan can find, for BOTH index
|
|
84
|
+
// shapes that reach here. A root-anchored direct child `nsDir/<x>.php`
|
|
85
|
+
// has its directory exactly equal to `nsDir`, and `nsDir` is always one
|
|
86
|
+
// of that directory's own suffixes — so the shared `dirMap` (keyed on
|
|
87
|
+
// every directory suffix) necessarily contains it, as does the
|
|
88
|
+
// root-anchored parity index `languages/php/import-target.ts` builds.
|
|
89
|
+
// Empty superset therefore implies empty scan, and control falls
|
|
90
|
+
// through to the next PSR-4 prefix exactly as before.
|
|
91
|
+
if (index) {
|
|
92
|
+
const candidates = index.getFilesInDir(nsDir, '.php');
|
|
93
|
+
if (candidates.length > 0)
|
|
94
|
+
return candidates[0];
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
// Linear scan, only when a SuffixIndex is genuinely unavailable.
|
|
98
|
+
const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/';
|
|
99
|
+
for (const f of allFiles) {
|
|
100
|
+
if (f.startsWith(nsDirPrefix) &&
|
|
101
|
+
f.endsWith('.php') &&
|
|
102
|
+
!f.slice(nsDirPrefix.length).includes('/')) {
|
|
103
|
+
return f;
|
|
104
|
+
}
|
|
87
105
|
}
|
|
88
106
|
}
|
|
89
107
|
}
|
|
90
108
|
}
|
|
91
109
|
}
|
|
110
|
+
// A non-empty PSR-4 map is authoritative for namespaces it does not own.
|
|
111
|
+
// Preserve the existing mapped-namespace fallback behavior; #2962 is the
|
|
112
|
+
// conservative external-namespace gate, not a rewrite of mapped lookup.
|
|
113
|
+
// A catch-all owns every namespace, so its misses remain authoritative.
|
|
114
|
+
if (authoritativePsr4.size > 0 &&
|
|
115
|
+
!composerConfig.hasUnmodeledAutoload &&
|
|
116
|
+
(!matchedAuthoritativeNamespace || hasAuthoritativeCatchAllNamespace)) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
92
119
|
}
|
|
93
120
|
// Fallback: suffix matching (works without composer.json)
|
|
94
121
|
const pathParts = normalized.split('/').filter(Boolean);
|
|
@@ -14,10 +14,19 @@ export interface GoModuleConfig {
|
|
|
14
14
|
export interface ComposerConfig {
|
|
15
15
|
/** Map of namespace prefix -> directory (e.g., "App\\" -> "app/") */
|
|
16
16
|
psr4: Map<string, string>;
|
|
17
|
+
/** Production `autoload.psr-4` prefixes that may gate external namespaces.
|
|
18
|
+
* Absent on legacy/manual configs, where every mapping remains authoritative. */
|
|
19
|
+
authoritativePsr4?: ReadonlySet<string>;
|
|
20
|
+
/** True when Composer also declares an autoload mechanism this resolver does not model. */
|
|
21
|
+
hasUnmodeledAutoload?: boolean;
|
|
17
22
|
/** PSR-4 entries sorted by namespace length descending (longest match wins).
|
|
18
23
|
* Cached once at config load time to avoid re-sorting on every import. */
|
|
19
24
|
psr4Sorted?: readonly [string, string][];
|
|
20
25
|
}
|
|
26
|
+
/** Parse one Composer manifest without performing I/O. */
|
|
27
|
+
export declare function parseComposerConfig(value: unknown, baseDir?: string): ComposerConfig | null;
|
|
28
|
+
/** Merge package-local Composer manifests into one repository-relative config. */
|
|
29
|
+
export declare function mergeComposerConfigs(configs: readonly ComposerConfig[]): ComposerConfig | null;
|
|
21
30
|
/** C# project config parsed from .csproj files */
|
|
22
31
|
export interface CSharpProjectConfig {
|
|
23
32
|
/** Root namespace from <RootNamespace> or assembly name (default: project directory name) */
|
|
@@ -5,6 +5,93 @@ import path from 'path';
|
|
|
5
5
|
import { isDev } from './utils/env.js';
|
|
6
6
|
import { mapConcurrent } from '../../lib/utils.js';
|
|
7
7
|
import { logger } from '../logger.js';
|
|
8
|
+
function normalizeComposerDirectory(baseDir, directory) {
|
|
9
|
+
const normalizedBase = baseDir.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
10
|
+
const normalizedDirectory = directory
|
|
11
|
+
.replace(/\\/g, '/')
|
|
12
|
+
.replace(/^(?:\.\/)+/, '')
|
|
13
|
+
.replace(/\/+$/, '');
|
|
14
|
+
if (normalizedBase === '')
|
|
15
|
+
return normalizedDirectory;
|
|
16
|
+
if (normalizedDirectory === '')
|
|
17
|
+
return normalizedBase;
|
|
18
|
+
return path.posix.normalize(`${normalizedBase}/${normalizedDirectory}`);
|
|
19
|
+
}
|
|
20
|
+
/** Parse one Composer manifest without performing I/O. */
|
|
21
|
+
export function parseComposerConfig(value, baseDir = '') {
|
|
22
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
23
|
+
return null;
|
|
24
|
+
const composer = value;
|
|
25
|
+
const autoload = composer.autoload;
|
|
26
|
+
const autoloadDev = composer['autoload-dev'];
|
|
27
|
+
if (autoload === undefined && autoloadDev === undefined)
|
|
28
|
+
return null;
|
|
29
|
+
const psr4 = new Map();
|
|
30
|
+
const authoritativePsr4 = new Set();
|
|
31
|
+
let hasUnmodeledAutoload = false;
|
|
32
|
+
const addSection = (sectionValue, authoritative) => {
|
|
33
|
+
if (typeof sectionValue !== 'object' || sectionValue === null || Array.isArray(sectionValue)) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const section = sectionValue;
|
|
37
|
+
if ('psr-0' in section || 'classmap' in section)
|
|
38
|
+
hasUnmodeledAutoload = true;
|
|
39
|
+
const rawPsr4 = section['psr-4'];
|
|
40
|
+
if (typeof rawPsr4 !== 'object' || rawPsr4 === null || Array.isArray(rawPsr4))
|
|
41
|
+
return;
|
|
42
|
+
for (const [namespace, directories] of Object.entries(rawPsr4)) {
|
|
43
|
+
const stringDirectories = Array.isArray(directories)
|
|
44
|
+
? directories.filter((entry) => typeof entry === 'string')
|
|
45
|
+
: typeof directories === 'string'
|
|
46
|
+
? [directories]
|
|
47
|
+
: [];
|
|
48
|
+
if (stringDirectories.length === 0)
|
|
49
|
+
continue;
|
|
50
|
+
if (stringDirectories.length > 1)
|
|
51
|
+
hasUnmodeledAutoload = true;
|
|
52
|
+
const normalizedNamespace = namespace.replace(/\\+$/, '');
|
|
53
|
+
const normalizedDirectory = normalizeComposerDirectory(baseDir, stringDirectories[0]);
|
|
54
|
+
const existing = psr4.get(normalizedNamespace);
|
|
55
|
+
if (existing !== undefined && existing !== normalizedDirectory) {
|
|
56
|
+
hasUnmodeledAutoload = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (existing === undefined)
|
|
60
|
+
psr4.set(normalizedNamespace, normalizedDirectory);
|
|
61
|
+
if (authoritative)
|
|
62
|
+
authoritativePsr4.add(normalizedNamespace);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
// Production mappings win duplicate prefixes. Development mappings remain
|
|
66
|
+
// usable for test code but do not establish authority for the external gate.
|
|
67
|
+
addSection(autoload, true);
|
|
68
|
+
addSection(autoloadDev, false);
|
|
69
|
+
return { psr4, authoritativePsr4, hasUnmodeledAutoload };
|
|
70
|
+
}
|
|
71
|
+
/** Merge package-local Composer manifests into one repository-relative config. */
|
|
72
|
+
export function mergeComposerConfigs(configs) {
|
|
73
|
+
if (configs.length === 0)
|
|
74
|
+
return null;
|
|
75
|
+
const psr4 = new Map();
|
|
76
|
+
const authoritativePsr4 = new Set();
|
|
77
|
+
let hasUnmodeledAutoload = false;
|
|
78
|
+
for (const config of configs) {
|
|
79
|
+
hasUnmodeledAutoload ||= config.hasUnmodeledAutoload === true;
|
|
80
|
+
for (const [namespace, directory] of config.psr4) {
|
|
81
|
+
const existing = psr4.get(namespace);
|
|
82
|
+
if (existing !== undefined && existing !== directory) {
|
|
83
|
+
hasUnmodeledAutoload = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (existing === undefined)
|
|
87
|
+
psr4.set(namespace, directory);
|
|
88
|
+
}
|
|
89
|
+
for (const namespace of config.authoritativePsr4 ?? config.psr4.keys()) {
|
|
90
|
+
authoritativePsr4.add(namespace);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { psr4, authoritativePsr4, hasUnmodeledAutoload };
|
|
94
|
+
}
|
|
8
95
|
/** Project the one-pass {@link CSharpProjectScan} into the
|
|
9
96
|
* {@link CSharpNamespaceEvidence} both import-resolution legs thread to the
|
|
10
97
|
* #1881 gate — one shape, two carriers (`ImportConfigs.csharpNamespaces` for
|
|
@@ -85,20 +172,13 @@ export async function loadComposerConfig(repoRoot) {
|
|
|
85
172
|
try {
|
|
86
173
|
const composerPath = path.join(repoRoot, 'composer.json');
|
|
87
174
|
const raw = await fs.readFile(composerPath, 'utf-8');
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const merged = { ...psr4Raw, ...psr4Dev };
|
|
92
|
-
const psr4 = new Map();
|
|
93
|
-
for (const [ns, dir] of Object.entries(merged)) {
|
|
94
|
-
const nsNorm = ns.replace(/\\+$/, '');
|
|
95
|
-
const dirNorm = dir.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
96
|
-
psr4.set(nsNorm, dirNorm);
|
|
97
|
-
}
|
|
175
|
+
const config = parseComposerConfig(JSON.parse(raw));
|
|
176
|
+
if (config === null)
|
|
177
|
+
return null;
|
|
98
178
|
if (isDev) {
|
|
99
|
-
logger.info(`📦 Loaded ${psr4.size} PSR-4 mappings from composer.json`);
|
|
179
|
+
logger.info(`📦 Loaded ${config.psr4.size} PSR-4 mappings from composer.json`);
|
|
100
180
|
}
|
|
101
|
-
return
|
|
181
|
+
return config;
|
|
102
182
|
}
|
|
103
183
|
catch {
|
|
104
184
|
return null;
|
|
@@ -23,6 +23,7 @@ import type { CfgVisitor } from './cfg/types.js';
|
|
|
23
23
|
import type { NodeLabel } from '../../_shared/index.js';
|
|
24
24
|
import type { ExtractedRoute } from './route-extractors/laravel.js';
|
|
25
25
|
import type { SharedSpringType } from './route-extractors/spring-shared.js';
|
|
26
|
+
import type { ModuleConstants, Operand, RepoConstants } from './route-extractors/constant-resolver.js';
|
|
26
27
|
import type Parser from 'tree-sitter';
|
|
27
28
|
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
|
28
29
|
/** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */
|
|
@@ -39,6 +40,18 @@ export interface AstFrameworkPatternConfig {
|
|
|
39
40
|
* Required fields must be explicitly set; optional fields have defaults
|
|
40
41
|
* applied by defineLanguage().
|
|
41
42
|
*/
|
|
43
|
+
/**
|
|
44
|
+
* Should the parse worker run {@link LanguageProviderConfig.extractModuleConstants}
|
|
45
|
+
* on this file?
|
|
46
|
+
*
|
|
47
|
+
* Exported so the DECISION is testable without booting a worker. It encodes the
|
|
48
|
+
* one rule that is easy to get backwards: a provider that declares no
|
|
49
|
+
* `moduleConstantHeuristic` harvests unconditionally. Writing the gate as
|
|
50
|
+
* `provider.moduleConstantHeuristic?.(content)` reads `undefined` as "skip" and
|
|
51
|
+
* silently disables the hook for every provider without a heuristic — which is
|
|
52
|
+
* exactly how Python's already-shipped harvest was turned off (#2391/#2980).
|
|
53
|
+
*/
|
|
54
|
+
export declare function shouldHarvestModuleConstants(provider: Pick<LanguageProvider, 'extractModuleConstants' | 'moduleConstantHeuristic'>, content: string): boolean;
|
|
42
55
|
interface LanguageProviderConfig {
|
|
43
56
|
readonly id: SupportedLanguages;
|
|
44
57
|
/** File extensions that map to this language (e.g., ['.ts', '.tsx']) */
|
|
@@ -266,6 +279,51 @@ interface LanguageProviderConfig {
|
|
|
266
279
|
* Default: undefined (no interface-inheritance route resolution).
|
|
267
280
|
*/
|
|
268
281
|
readonly extractRouteInheritanceTypes?: (tree: Parser.Tree, filePath: string) => SharedSpringType[];
|
|
282
|
+
/**
|
|
283
|
+
* Harvest this file's module-level string constants (#2391 core, #2980 Java
|
|
284
|
+
* parity) into the language-agnostic {@link ModuleConstants} shape, so the
|
|
285
|
+
* parse phase can resolve non-literal decorator route paths cross-file.
|
|
286
|
+
*
|
|
287
|
+
* The worker calls this when BOTH hold:
|
|
288
|
+
* - the provider declares no `moduleConstantHeuristic`, or the one it
|
|
289
|
+
* declares matched — syntax-driven, e.g. a `static final String` field or
|
|
290
|
+
* a constants-bearing import; NEVER a class-name pattern like
|
|
291
|
+
* `*Constants`, which silently drops route constants living in classes
|
|
292
|
+
* named e.g. `ApiPaths`/`Routes`, and
|
|
293
|
+
* - the extraction yields something resolvable (a literal, an expression, or
|
|
294
|
+
* an import binding), keeping the aggregate bounded on large repos.
|
|
295
|
+
*
|
|
296
|
+
* Default: undefined (no constant harvest; non-literal route paths of this
|
|
297
|
+
* language floor to skip).
|
|
298
|
+
*/
|
|
299
|
+
readonly extractModuleConstants?: (tree: Parser.Tree) => ModuleConstants;
|
|
300
|
+
/**
|
|
301
|
+
* Cheap content heuristic deciding whether the worker should run
|
|
302
|
+
* {@link extractModuleConstants} on a file. Guards the harvest cost on huge
|
|
303
|
+
* repos: files that cannot contribute (no constant-bearing syntax) are not
|
|
304
|
+
* walked. Must be syntax-driven (field/import shape), not identifier
|
|
305
|
+
* pattern-matching on class names.
|
|
306
|
+
*
|
|
307
|
+
* Default: undefined — harvest EVERY file of this language. A gate is opt-in
|
|
308
|
+
* because getting it wrong silently drops routes that already resolve, and a
|
|
309
|
+
* missed gate only costs time. Declare one only where the cost bites (Java's
|
|
310
|
+
* Maven monorepos) and only after checking it against every shape
|
|
311
|
+
* {@link extractModuleConstants} accepts.
|
|
312
|
+
*/
|
|
313
|
+
readonly moduleConstantHeuristic?: (content: string) => boolean;
|
|
314
|
+
/**
|
|
315
|
+
* Fold one file's non-literal route-path operand list
|
|
316
|
+
* (`routePathExpr`/`routePathOperands` of an `ExtractedDecoratorRoute`)
|
|
317
|
+
* against the repo-wide, file-path-keyed constant map, or null when it cannot
|
|
318
|
+
* be fully folded (skip floor — never a phantom path). Languages whose
|
|
319
|
+
* qualified refs resolve through class imports (`Outer.CONST`,
|
|
320
|
+
* `com.example.ApiPaths.USERS`) need this hook because the shared fold has no
|
|
321
|
+
* notion of qualified names; Python's bare-name refs use the shared default.
|
|
322
|
+
*
|
|
323
|
+
* Default: undefined (the parse phase falls back to the shared
|
|
324
|
+
* language-agnostic operand fold).
|
|
325
|
+
*/
|
|
326
|
+
readonly foldRoutePathOperands?: (filePath: string, operands: readonly Operand[], repo: RepoConstants) => string | null;
|
|
269
327
|
/** Built-in/stdlib names that should be filtered from the call graph for this language.
|
|
270
328
|
* Default: undefined (no language-specific filtering). */
|
|
271
329
|
readonly builtInNames?: ReadonlySet<string>;
|