gitnexus 1.6.10-rc.216 → 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.
@@ -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
- if (!annNode || !node || !valueNode)
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
- scan(tree) {
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)
@@ -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>;
@@ -8,6 +8,27 @@
8
8
  * The providers table in `languages/index.ts` uses `satisfies Record<SupportedLanguages, LanguageProvider>`
9
9
  * so adding a language to the enum without creating a provider is a compiler error.
10
10
  */
11
+ /**
12
+ * Everything a language needs to provide.
13
+ * Required fields must be explicitly set; optional fields have defaults
14
+ * applied by defineLanguage().
15
+ */
16
+ /**
17
+ * Should the parse worker run {@link LanguageProviderConfig.extractModuleConstants}
18
+ * on this file?
19
+ *
20
+ * Exported so the DECISION is testable without booting a worker. It encodes the
21
+ * one rule that is easy to get backwards: a provider that declares no
22
+ * `moduleConstantHeuristic` harvests unconditionally. Writing the gate as
23
+ * `provider.moduleConstantHeuristic?.(content)` reads `undefined` as "skip" and
24
+ * silently disables the hook for every provider without a heuristic — which is
25
+ * exactly how Python's already-shipped harvest was turned off (#2391/#2980).
26
+ */
27
+ export function shouldHarvestModuleConstants(provider, content) {
28
+ if (!provider.extractModuleConstants)
29
+ return false;
30
+ return !provider.moduleConstantHeuristic || provider.moduleConstantHeuristic(content);
31
+ }
11
32
  const DEFAULTS = {
12
33
  mroStrategy: 'first-wins',
13
34
  };
@@ -13,6 +13,7 @@ import { defineLanguage } from '../language-provider.js';
13
13
  import { createLeadingDocDescriptionExtractor } from '../utils/ast-helpers.js';
14
14
  import { javaTypeConfig } from '../type-extractors/jvm.js';
15
15
  import { extractSpringRoutes, extractSpringTypes } from '../route-extractors/spring.js';
16
+ import { extractJavaModuleConstants, foldJavaOperands, isJavaConstantFile, } from '../route-extractors/java-const-resolver.js';
16
17
  import { javaExportChecker } from '../export-detection.js';
17
18
  import { createImportResolver } from '../import-resolvers/resolver-factory.js';
18
19
  import { javaImportConfig } from '../import-resolvers/configs/jvm.js';
@@ -186,4 +187,24 @@ export const javaProvider = defineLanguage({
186
187
  // ── Route extraction ──
187
188
  extractDecoratorRoutes: extractSpringRoutes,
188
189
  extractRouteInheritanceTypes: extractSpringTypes,
190
+ // ── #2980: constant harvest + qualified-ref fold for non-literal mapping
191
+ // paths (`@PostMapping(ApiPaths.SAVE_V1)`) — kept behind provider hooks so
192
+ // the shared ingestion layers stay language-agnostic. The heuristic is
193
+ // SYNTAX-driven (field/import shape), never a class-name pattern: constant
194
+ // classes are routinely named `ApiPaths`/`Routes`/`Paths`, which a
195
+ // `*Constants`-style gate would silently drop (review round-2 High finding).
196
+ extractModuleConstants: extractJavaModuleConstants,
197
+ // One gate, shared with the group side's `prepareRepo` pre-pass so the two
198
+ // subsystems cannot disagree about which files define constants (see
199
+ // JAVA_CONSTANT_FILE_RE — the previous divergence dropped constant
200
+ // INTERFACES on this side only, which cost the graph its Route nodes while
201
+ // the group still published the contract).
202
+ moduleConstantHeuristic: (content) => isJavaConstantFile(content) ||
203
+ // `import com.winning.opt.common.ApiPaths;` — ANY class import can bind a
204
+ // constant ref (`ApiPaths.X` at an annotation site), so gate on the
205
+ // general import shape, not on the imported name. Ingestion-only: this
206
+ // side needs the importing controller's own import table, which the group
207
+ // side instead derives lazily from the tree it already holds.
208
+ /\bimport\s+(?:static\s+)?[\w.]+\s*;/.test(content),
209
+ foldRoutePathOperands: foldJavaOperands,
189
210
  });
@@ -28,6 +28,7 @@ import { createPythonCfgVisitor } from '../cfg/visitors/python.js';
28
28
  import { emitPythonScopeCaptures, pythonFunctionDefinitionLabel, interpretPythonImport, interpretPythonTypeBinding, pythonArityCompatibility, pythonBindingScopeFor, pythonImportOwningScope, pythonMergeBindings, pythonReceiverBinding, resolvePythonImportTarget, } from './python/index.js';
29
29
  import { extractDjangoRoutes } from '../route-extractors/django.js';
30
30
  import { discoverDjangoRootUrls } from '../route-extractors/django-root-discovery.js';
31
+ import { extractPythonModuleConstants } from '../route-extractors/python-const-resolver.js';
31
32
  const BUILT_INS = new Set([
32
33
  'print',
33
34
  'len',
@@ -134,4 +135,16 @@ export const pythonProvider = defineLanguage({
134
135
  receiverBinding: pythonReceiverBinding,
135
136
  arityCompatibility: pythonArityCompatibility,
136
137
  resolveImportTarget: resolvePythonImportTarget,
138
+ // ── #2391 constant harvest, provider-hook form (#2980): module-level string
139
+ // constants + from-imports for non-literal decorator route paths. Bare-name
140
+ // refs fold through the shared resolver (no foldRoutePathOperands needed).
141
+ // No `moduleConstantHeuristic`: Python harvests unconditionally, exactly as
142
+ // #2391 shipped it. A content gate was tried here and removed on review — it
143
+ // required `NAME` immediately followed by `=`, so it silently dropped the two
144
+ // idiomatic typed-FastAPI shapes (`API: str = "/api"`,
145
+ // `API: Final[str] = "/api"`) and every composed constant whose RHS starts
146
+ // with an identifier (`USERS = BASE + "/users"`), i.e. it REGRESSED routes
147
+ // that already resolve on main. The worker treats a missing heuristic as
148
+ // default-open; only Java opts into a gate, where the cost actually bites.
149
+ extractModuleConstants: extractPythonModuleConstants,
137
150
  });
@@ -25,7 +25,7 @@ import { getLanguageFromFilename, } from '../../../_shared/index.js';
25
25
  import { readFileContents } from '../filesystem-walker.js';
26
26
  import { isLanguageAvailable, isGrammarRuntimeSkipped, createParserForLanguage, } from '../../tree-sitter/parser-loader.js';
27
27
  import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
28
- import { getProvider, providers } from '../languages/index.js';
28
+ import { getProvider, getProviderForFile, providers } from '../languages/index.js';
29
29
  import { SCOPE_RESOLVERS } from '../scope-resolution/pipeline/registry.js';
30
30
  import { DATA_ROUTE_TABLE_SOURCE } from '../route-extractors/data-route-table.js';
31
31
  import { createWorkerPool, workerPoolDisabledByEnv, resolveAutoPoolSize, WorkerPoolInitializationError, WorkerPoolDisabledError, } from '../workers/worker-pool.js';
@@ -1058,8 +1058,15 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1058
1058
  resolvedRoutes.push(dr);
1059
1059
  continue;
1060
1060
  }
1061
+ // Provider-driven fold (#2980): languages with qualified-ref semantics
1062
+ // (Java `ApiPaths.X` / `com.example.ApiPaths.X`) fold through their
1063
+ // provider hook; everything else uses the shared language-agnostic
1064
+ // operand fold. No language names in the shared layer.
1065
+ const fold = getProviderForFile(dr.filePath)?.foldRoutePathOperands;
1061
1066
  const value = dr.routePathOperands
1062
- ? resolveOperands(dr.filePath, dr.routePathOperands, repoConstants)
1067
+ ? fold
1068
+ ? fold(dr.filePath, dr.routePathOperands, repoConstants)
1069
+ : resolveOperands(dr.filePath, dr.routePathOperands, repoConstants)
1063
1070
  : null;
1064
1071
  if (value === null) {
1065
1072
  skipped++;
@@ -23,6 +23,12 @@
23
23
  * (intra-procedural, function-local, def→use reachability — not module-level
24
24
  * cross-file value folding).
25
25
  */
26
+ /** Max length of a folded path. Real route paths are well under this; a fold that
27
+ * exceeds it is a pathological self-multiplying concat (`X = A + A; A = B + B; …`)
28
+ * whose true value is genuinely huge — building it risks a `RangeError`/heap OOM,
29
+ * so we floor to `null` (skip) instead (#2393). The depth cap bounds recursion but
30
+ * NOT output size, which grows multiplicatively; this bounds the output. */
31
+ export declare const MAX_FOLD_LENGTH = 8192;
26
32
  /**
27
33
  * One term of a constant's right-hand side. A `+`-concatenation
28
34
  * (`A + "/b" + C`) becomes an ordered `Operand[]`; a bare literal is a
@@ -31,7 +31,7 @@ const MAX_RESOLVE_DEPTH = 8;
31
31
  * whose true value is genuinely huge — building it risks a `RangeError`/heap OOM,
32
32
  * so we floor to `null` (skip) instead (#2393). The depth cap bounds recursion but
33
33
  * NOT output size, which grows multiplicatively; this bounds the output. */
34
- const MAX_FOLD_LENGTH = 8192;
34
+ export const MAX_FOLD_LENGTH = 8192;
35
35
  /**
36
36
  * Fold an operand list to its concatenated literal, or `null` if any operand is
37
37
  * unresolvable (an unknown name, a non-string term, a cycle, or a depth overrun).
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Java binding for the language-agnostic constant resolver (#2391 core).
3
+ *
4
+ * Supplies the two Java-specific pieces the shared fold in
5
+ * `constant-resolver.ts` needs — {@link resolveJavaImport} (import-specifier →
6
+ * file, honoring JVM package/classpath rules) and
7
+ * {@link extractJavaModuleConstants} (tree → {@link ModuleConstants}) — plus a
8
+ * pre-bound {@link resolveJavaConstant} wrapper so callers stay
9
+ * language-oblivious. The reusable fold, the cycle guard, and the depth cap
10
+ * all live in the agnostic core.
11
+ *
12
+ * Java constant shape (one per type declaration; nested classes flatten into
13
+ * the same file-level namespace, mirroring how `Outer.CONST` and a top-level
14
+ * `CONST` are indistinguishable at the fold layer):
15
+ *
16
+ * public class ApiPathConstants {
17
+ * public static final String DIAGNOSIS_SAVE_V1 = "/api/v1/diagnosis/add";
18
+ * public static final String API_CIS_SAVE_SUMMARY = API_CIS_V1 + "summary/save";
19
+ * }
20
+ *
21
+ * Reference shapes at annotation sites this binding resolves:
22
+ * @PostMapping(ApiPathConstants.DIAGNOSIS_SAVE_V1) // qualified
23
+ * @PostMapping(com.winning.opt.X.ApiPathConstants.Y) // FQN-qualified
24
+ * @PostMapping(DIAGNOSIS_SAVE_V1) // static-imported
25
+ * @PostMapping(API_CIS_V1 + "summary/save") // inline concat
26
+ *
27
+ * Which ANNOTATIONS count as routes is a separate question this module has no
28
+ * say in: `spring-shared.ts` holds an exact-name map, so a vendor alias like
29
+ * `@WinPostMapping` yields no route on this base regardless of how its value
30
+ * folds (#2883). Folding and alias recognition compose; neither implies the
31
+ * other.
32
+ *
33
+ * Import shapes consumed:
34
+ * import com.winning.opt.diagnosis.api.constants.ApiPathConstants;
35
+ * import static com.winning.opt.diagnosis.api.constants.ApiPathConstants.API_CIS_V1;
36
+ *
37
+ * Keying (KTD4 parity with the Python binding): the repo map is keyed by
38
+ * unique POSIX file path. A Java import `com.a.b.CONSTS` resolves to the file
39
+ * whose path ends with `com/a/b/CONSTS.java`; when 2+ files share that suffix
40
+ * the import is ambiguous and returns null (skip floor), never a wrong path.
41
+ */
42
+ import type Parser from 'tree-sitter';
43
+ import { type ImportResolver, type ModuleConstants, type Operand, type RepoConstants } from './constant-resolver.js';
44
+ export type { ImportBinding, ModuleConstants, Operand, RepoConstants, } from './constant-resolver.js';
45
+ export declare function isJavaConstantFile(source: string): boolean;
46
+ /**
47
+ * The Java {@link ImportResolver}: map a fully-qualified import specifier to
48
+ * the unique file key it refers to, or null when it cannot be pinned to
49
+ * exactly one file.
50
+ *
51
+ * `com.winning.opt.X.ApiPathConstants` → the file key ending in
52
+ * `com/winning/opt/X/ApiPathConstants.java`. Because the repo map is
53
+ * file-path-keyed and Maven multi-module trees repeat package roots across
54
+ * modules (`winning-opt-a/.../api/constants/ApiPathConstants.java` and
55
+ * `winning-opt-b/.../api/constants/ApiPathConstants.java`), suffix matching
56
+ * stays UNIQUE-suffix: an import whose full package+class path matches N files
57
+ * in N different modules cannot be pinned, so it returns null — the skip floor
58
+ * this module promises, never a wrong path.
59
+ *
60
+ * A nearest-shared-directory tie-break was tried here and removed on review:
61
+ * javac resolves duplicate FQNs by CLASSPATH ORDER, not directory proximity, so
62
+ * a `src/test` fixture copy or a module that merely sits closer in the tree can
63
+ * outrank the real dependency and yield a silently wrong literal. In a resolver
64
+ * whose whole contract is skip-or-correct, a plausible guess is the one answer
65
+ * that cannot be allowed.
66
+ */
67
+ export declare const resolveJavaImport: ImportResolver;
68
+ /**
69
+ * Parse a Java constant initializer into an operand list, or null when it is
70
+ * not a foldable string expression. Handles a bare string literal, a bare
71
+ * identifier (`X = Y`), qualified/static-import-free references
72
+ * (`X = CONSTS.Y` — recorded as ONE ref named `CONSTS.Y`), and
73
+ * left-associative `+` chains of the three. Everything else — numbers, calls,
74
+ * ternaries, method refs, `String.format`, enum constants — returns null,
75
+ * which makes the constant unresolvable (→ skip floor), never a wrong value.
76
+ */
77
+ export declare function parseJavaConstOperands(node: Parser.SyntaxNode | null | undefined, depth?: number): Operand[] | null;
78
+ /**
79
+ * Extract the file-level string constants and import bindings of one parsed
80
+ * Java file into the {@link ModuleConstants} shape the resolver consumes.
81
+ *
82
+ * Constants: every `static final String NAME = …` field of every type
83
+ * declaration in the file (nested classes included — their simple names
84
+ * would collide at the fold layer, but qualified refs carry the class name
85
+ * so nesting only matters for same-name fields, which flatten last-wins).
86
+ * Interface constants (`String NAME = "…"`) are implicitly static final and
87
+ * are collected too.
88
+ *
89
+ * References to OTHER constants via qualified names (`ApiPathConstants.X`)
90
+ * are stored as refs named `ApiPathConstants.X`; at the fold layer such a ref
91
+ * resolves through the import map (`ApiPathConstants` → module) followed by
92
+ * field lookup in the target file's OWN class-name-qualified namespace. To
93
+ * support that, constant names are ALSO recorded under
94
+ * `<DeclaringClass>.<FIELD>` (both spellings share one entry).
95
+ *
96
+ * Last-wins in source order; a non-foldable rebind (`X = compute()`) drops X
97
+ * to unresolvable rather than keeping a stale literal.
98
+ */
99
+ export declare function extractJavaModuleConstants(tree: Parser.Tree): ModuleConstants;
100
+ /**
101
+ * Resolve a single Java constant referenced in `fileKey` to its literal string
102
+ * value, folding `+` concatenation and following import chains via
103
+ * {@link resolveJavaImport}, or null when it cannot be fully folded.
104
+ *
105
+ * `name` may be simple (`DIAGNOSIS_SAVE_V1`, resolved via static import or
106
+ * same-file constant) or qualified (`ApiPathConstants.DIAGNOSIS_SAVE_V1`,
107
+ * resolved via the class import + the target file's qualified alias).
108
+ */
109
+ export declare function resolveJavaConstant(fileKey: string, name: string, repo: RepoConstants, depth?: number): string | null;
110
+ /**
111
+ * Fold an inline operand list (e.g. `API_CIS_V1 + "summary/save"`) against
112
+ * `fileKey`, or null when any piece is unresolvable (skip floor).
113
+ */
114
+ export declare function foldJavaOperands(fileKey: string, operands: readonly Operand[], repo: RepoConstants): string | null;