gitnexus 1.6.11-rc.21 → 1.6.11-rc.22

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.
@@ -62,6 +62,9 @@ export declare function validateGroupImpactParams(params: Record<string, unknown
62
62
  name: string;
63
63
  repoPath: string;
64
64
  target: string;
65
+ target_uid?: string;
66
+ file_path?: string;
67
+ kind?: string;
65
68
  direction: 'upstream' | 'downstream';
66
69
  maxDepth: number;
67
70
  crossDepth: number;
@@ -103,13 +103,21 @@ export function clampTimeout(timeoutMs) {
103
103
  export function validateGroupImpactParams(params) {
104
104
  const name = String(params.name ?? '').trim();
105
105
  const repoPath = String(params.repo ?? '').trim();
106
- const target = String(params.target ?? '').trim();
106
+ // Optional string, same helper shape as cross-trace's `str()`: empty/blank
107
+ // counts as absent so `target_uid: ''` degrades to the name lookup rather
108
+ // than a zero-ambiguity lookup of the empty uid. Parsed before the required
109
+ // check so UID-only callers (MCP impact schema requires `direction`, not
110
+ // `target`) are accepted.
111
+ const str = (v) => typeof v === 'string' && v.trim() !== '' ? v : undefined;
112
+ const targetName = String(params.target ?? '').trim();
113
+ const target_uidEarly = str(params.target_uid);
107
114
  if (!name)
108
115
  return { ok: false, error: 'name is required' };
109
116
  if (!repoPath)
110
117
  return { ok: false, error: 'repo is required (group repo path, e.g. app/backend)' };
111
- if (!target)
112
- return { ok: false, error: 'target is required' };
118
+ if (!targetName && !target_uidEarly)
119
+ return { ok: false, error: 'target or target_uid is required' };
120
+ const target = targetName || target_uidEarly;
113
121
  if (params.service !== undefined &&
114
122
  params.service !== null &&
115
123
  String(params.service).trim() === '') {
@@ -133,6 +141,9 @@ export function validateGroupImpactParams(params) {
133
141
  minConfidence = 1;
134
142
  const service = normalizeServicePrefix(params.service);
135
143
  const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined;
144
+ const target_uid = target_uidEarly;
145
+ const file_path = str(params.file_path);
146
+ const kind = str(params.kind);
136
147
  // Clamp at the validate boundary so the downstream `deadline` (line
137
148
  // ~366) and `safeLocalImpact`'s `setTimeout` both see a single
138
149
  // bounded value. Without this, the outer deadline budgeted Phase-2
@@ -150,6 +161,9 @@ export function validateGroupImpactParams(params) {
150
161
  name,
151
162
  repoPath,
152
163
  target,
164
+ target_uid,
165
+ file_path,
166
+ kind,
153
167
  direction,
154
168
  maxDepth,
155
169
  crossDepth,
@@ -448,7 +462,7 @@ export async function runGroupImpact(deps, params) {
448
462
  const parsed = validateGroupImpactParams(params);
449
463
  if (parsed.ok === false)
450
464
  return { error: parsed.error };
451
- const { name, repoPath, target, direction, maxDepth, crossDepth: _crossDepth, crossDepthWarning, relationTypes, includeTests, minConfidence, service: servicePrefix, subgroup, timeoutMs, } = parsed;
465
+ const { name, repoPath, target, target_uid, file_path, kind, direction, maxDepth, crossDepth: _crossDepth, crossDepthWarning, relationTypes, includeTests, minConfidence, service: servicePrefix, subgroup, timeoutMs, } = parsed;
452
466
  const groupDir = getGroupDir(deps.gitnexusDir, name);
453
467
  let config;
454
468
  try {
@@ -464,6 +478,14 @@ export async function runGroupImpact(deps, params) {
464
478
  return { error: resolved.error };
465
479
  const impactParams = {
466
480
  target,
481
+ // Selector params pass through to the member repo's impact (the port
482
+ // contract in service.ts documents them), so the single-repo tool's
483
+ // "re-call with target_uid to disambiguate" loop works unchanged in
484
+ // group mode. `undefined` keeps the call shape flat — same convention
485
+ // as the relationTypes line below.
486
+ target_uid,
487
+ file_path,
488
+ kind,
467
489
  direction,
468
490
  maxDepth,
469
491
  relationTypes: relationTypes && relationTypes.length > 0 ? relationTypes : undefined,
@@ -2,7 +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
+ import { expandJavaWildcardStaticImports, extractJavaModuleConstants, foldJavaOperands, isJavaConstantFile, parseJavaConstOperands, prepareJavaRouteConstants, } from '../../../ingestion/route-extractors/java-const-resolver.js';
6
6
  import { extractStaticPathExpression, inferOkHttpMethod, inferHttpClientMethod, okHttpUrlRootsAtBuilder, httpClientUriRootsAtNewBuilder, httpClientChainHasUriCall, } from './java-static-path.js';
7
7
  /**
8
8
  * Java HTTP plugin. Handles:
@@ -789,7 +789,10 @@ export const JAVA_HTTP_PLUGIN = {
789
789
  if (!tree)
790
790
  continue;
791
791
  const mc = extractJavaModuleConstants(tree);
792
- if (mc.literals.size > 0 || mc.exprs.size > 0 || mc.imports.size > 0) {
792
+ if (mc.literals.size > 0 ||
793
+ mc.exprs.size > 0 ||
794
+ mc.imports.size > 0 ||
795
+ (mc.wildcardImports?.length ?? 0) > 0) {
793
796
  constants.set(rel, mc);
794
797
  }
795
798
  }
@@ -800,7 +803,14 @@ export const JAVA_HTTP_PLUGIN = {
800
803
  continue;
801
804
  }
802
805
  }
803
- return { constants };
806
+ // On-demand static imports (`import static a.b.C.*`) were recorded as
807
+ // pending class FQNs during extraction; materialize their bare-name
808
+ // bindings now that the whole map exists. A wildcard's target is itself
809
+ // a constants file, so it is necessarily a map entry — anything else
810
+ // degrades to the fold's skip floor. In-place: each entry is owned by
811
+ // this map, and every file is expanded exactly once.
812
+ const constantIndex = prepareJavaRouteConstants(constants);
813
+ return { constants, constantIndex };
804
814
  },
805
815
  scan(tree, repoContext, fileRel) {
806
816
  const out = [];
@@ -832,8 +842,12 @@ export const JAVA_HTTP_PLUGIN = {
832
842
  return foldConstants;
833
843
  try {
834
844
  const mc = extractJavaModuleConstants(tree);
835
- if (mc.imports.size > 0) {
845
+ // A file carrying ONLY wildcard static imports has an empty import
846
+ // table pre-expansion — overlay it too, then materialize the promised
847
+ // bindings against the repo map before it becomes a fold target.
848
+ if (mc.imports.size > 0 || (mc.wildcardImports?.length ?? 0) > 0) {
836
849
  const merged = new Map(javaCtx.constants);
850
+ expandJavaWildcardStaticImports(mc, fileRel, merged, javaCtx.constantIndex);
837
851
  merged.set(fileRel, mc);
838
852
  foldConstants = merged;
839
853
  }
@@ -1260,6 +1260,7 @@ function buildKotlinPlugin(language) {
1260
1260
  if (mc.literals.size > 0 ||
1261
1261
  mc.exprs.size > 0 ||
1262
1262
  mc.imports.size > 0 ||
1263
+ (mc.wildcardImports?.length ?? 0) > 0 ||
1263
1264
  unfoldableDeclarationsOf(mc).size > 0) {
1264
1265
  // POSIX key (see `normalizeRel`); `readFile` above got the raw `rel`.
1265
1266
  constants.set(normalizeRel(rel), mc);
@@ -1303,6 +1304,7 @@ function buildKotlinPlugin(language) {
1303
1304
  if (mc.literals.size > 0 ||
1304
1305
  mc.exprs.size > 0 ||
1305
1306
  mc.imports.size > 0 ||
1307
+ (mc.wildcardImports?.length ?? 0) > 0 ||
1306
1308
  unfoldableDeclarationsOf(mc).size > 0) {
1307
1309
  foldIndex = overlayKotlinConstantIndex(kotlinCtx.index, fileKey, mc);
1308
1310
  }
@@ -1,3 +1,40 @@
1
- import type { CrossLink, StoredContract } from './types.js';
1
+ import type { CrossLink, CrossLinkEndpoint, StoredContract } from './types.js';
2
+ /**
3
+ * True when a link endpoint carries no resolved graph symbol — empty
4
+ * `symbolUid` or a missing/empty `symbolRef`.
5
+ *
6
+ * Sync marks a cross-link `degraded: true` when this holds for the PROVIDER
7
+ * endpoint (`to`): the contract boundary is proven, but the empty uid can
8
+ * never match a Phase-1 impact symbol id, so cross-repo fan-out across the
9
+ * link silently yields nothing (the classic case is a provider whose handler
10
+ * failed to resolve, leaving `symbolName` degraded to the file name with one
11
+ * pseudo-symbol carrying every route in that file). Consumer-side (`from`)
12
+ * emptiness is deliberately NOT degraded — several extractors (topics, grpc)
13
+ * legitimately emit consumer contracts without a per-call symbol, and the
14
+ * anchor that matters for far-side fan-out is the provider's.
15
+ *
16
+ * Kept next to the endpoint merge logic because `dedupeCrossLinks` must
17
+ * re-derive the flag after a merge: `mergeEndpoints` backfills `symbolUid`
18
+ * from the losing twin, which can invalidate a flag carried in from the winner.
19
+ *
20
+ * NOT unresolved: a deterministic `manifest::<repo>::<contractId>` synthetic
21
+ * uid (see `manifestSymbolUid`). Manifest endpoints fall back to it precisely
22
+ * when the graph has no symbol for them — its empty `symbolRef.filePath` would
23
+ * otherwise trip the check below — yet cross-impact anchors those links by
24
+ * design (#2722: the crossing is preserved with `fanout_status:
25
+ * 'not_attempted'` instead of silently yielding cross=0). The prefix is the
26
+ * canonical discriminator — real indexer uids never start with `manifest::`
27
+ * — and `cross-impact.ts` branches on the same test. Encoding the exemption
28
+ * HERE (not at the sync marking call site) keeps marking and the post-merge
29
+ * re-derivation from drifting apart, and keeps the flag's meaning exactly what
30
+ * `types.ts` documents: "distinct from manifest::… synthetic UIDs".
31
+ */
32
+ export declare function isUnresolvedEndpoint(endpoint: CrossLinkEndpoint): boolean;
33
+ /**
34
+ * Derive `degraded` from the provider endpoint. Present (`true`) only when
35
+ * unresolved; deleted otherwise so contracts.json stays "carried only when
36
+ * meaningful" (`'degraded' in link === false` for anchored links).
37
+ */
38
+ export declare function applyDegradedFlag(link: CrossLink): CrossLink;
2
39
  export declare function dedupeContracts(items: StoredContract[]): StoredContract[];
3
40
  export declare function dedupeCrossLinks(items: CrossLink[]): CrossLink[];
@@ -83,6 +83,59 @@ function crossLinkKey(link) {
83
83
  endpointKey(link.to),
84
84
  ].join('\0');
85
85
  }
86
+ /**
87
+ * True when a link endpoint carries no resolved graph symbol — empty
88
+ * `symbolUid` or a missing/empty `symbolRef`.
89
+ *
90
+ * Sync marks a cross-link `degraded: true` when this holds for the PROVIDER
91
+ * endpoint (`to`): the contract boundary is proven, but the empty uid can
92
+ * never match a Phase-1 impact symbol id, so cross-repo fan-out across the
93
+ * link silently yields nothing (the classic case is a provider whose handler
94
+ * failed to resolve, leaving `symbolName` degraded to the file name with one
95
+ * pseudo-symbol carrying every route in that file). Consumer-side (`from`)
96
+ * emptiness is deliberately NOT degraded — several extractors (topics, grpc)
97
+ * legitimately emit consumer contracts without a per-call symbol, and the
98
+ * anchor that matters for far-side fan-out is the provider's.
99
+ *
100
+ * Kept next to the endpoint merge logic because `dedupeCrossLinks` must
101
+ * re-derive the flag after a merge: `mergeEndpoints` backfills `symbolUid`
102
+ * from the losing twin, which can invalidate a flag carried in from the winner.
103
+ *
104
+ * NOT unresolved: a deterministic `manifest::<repo>::<contractId>` synthetic
105
+ * uid (see `manifestSymbolUid`). Manifest endpoints fall back to it precisely
106
+ * when the graph has no symbol for them — its empty `symbolRef.filePath` would
107
+ * otherwise trip the check below — yet cross-impact anchors those links by
108
+ * design (#2722: the crossing is preserved with `fanout_status:
109
+ * 'not_attempted'` instead of silently yielding cross=0). The prefix is the
110
+ * canonical discriminator — real indexer uids never start with `manifest::`
111
+ * — and `cross-impact.ts` branches on the same test. Encoding the exemption
112
+ * HERE (not at the sync marking call site) keeps marking and the post-merge
113
+ * re-derivation from drifting apart, and keeps the flag's meaning exactly what
114
+ * `types.ts` documents: "distinct from manifest::… synthetic UIDs".
115
+ */
116
+ export function isUnresolvedEndpoint(endpoint) {
117
+ if (endpoint.symbolUid.startsWith('manifest::'))
118
+ return false;
119
+ return (!endpoint.symbolUid ||
120
+ !endpoint.symbolRef ||
121
+ !endpoint.symbolRef.filePath ||
122
+ !endpoint.symbolRef.name);
123
+ }
124
+ /**
125
+ * Derive `degraded` from the provider endpoint. Present (`true`) only when
126
+ * unresolved; deleted otherwise so contracts.json stays "carried only when
127
+ * meaningful" (`'degraded' in link === false` for anchored links).
128
+ */
129
+ export function applyDegradedFlag(link) {
130
+ const next = { ...link };
131
+ if (isUnresolvedEndpoint(next.to)) {
132
+ next.degraded = true;
133
+ }
134
+ else {
135
+ delete next.degraded;
136
+ }
137
+ return next;
138
+ }
86
139
  export function dedupeContracts(items) {
87
140
  const deduped = new Map();
88
141
  for (const contract of items) {
@@ -104,12 +157,15 @@ export function dedupeCrossLinks(items) {
104
157
  const keepIncoming = link.confidence > existing.confidence;
105
158
  const primary = keepIncoming ? link : existing;
106
159
  const secondary = keepIncoming ? existing : link;
107
- deduped.set(key, {
160
+ const merged = {
108
161
  ...primary,
109
162
  confidence: Math.max(existing.confidence, link.confidence),
110
163
  from: mergeEndpoints(primary.from, secondary.from),
111
164
  to: mergeEndpoints(primary.to, secondary.to),
112
- });
165
+ };
166
+ // Re-derive after mergeEndpoints: a richer twin can backfill `to.symbolUid`
167
+ // and must not leave a stale `degraded` flag on an now-anchored link.
168
+ deduped.set(key, applyDegradedFlag(merged));
113
169
  }
114
- return [...deduped.values()];
170
+ return [...deduped.values()].map(applyDegradedFlag);
115
171
  }
@@ -15,6 +15,17 @@ export interface GroupToolPort {
15
15
  resolveRepo(repoParam?: string): Promise<GroupRepoHandle>;
16
16
  impact(repo: GroupRepoHandle, params: {
17
17
  target: string;
18
+ /**
19
+ * Target-selector params, same semantics as the single-repo `impact`
20
+ * tool: `target_uid` is the zero-ambiguity lookup (it wins over the
21
+ * name), `file_path`/`kind` narrow a name shared by several symbols
22
+ * (e.g. same-named Api/Impl/Controller layers). The port implementation
23
+ * consumes them directly; the Phase-1 caller in cross-impact.ts is
24
+ * responsible for threading them from the MCP `impact` args.
25
+ */
26
+ target_uid?: string;
27
+ file_path?: string;
28
+ kind?: string;
18
29
  direction: 'upstream' | 'downstream';
19
30
  maxDepth?: number;
20
31
  relationTypes?: string[];
@@ -341,6 +341,14 @@ export class GroupService {
341
341
  // can otherwise see contract counts that disagree with this payload, with
342
342
  // nothing here explaining why the write was skipped.
343
343
  registryOutcome: result.registryOutcome,
344
+ // Data-quality signals surfaced from the sync run: links whose provider
345
+ // endpoint never resolved to a graph symbol, per-repo extraction
346
+ // failures with reasons, and operator warnings (e.g. bridge.lbug write
347
+ // failed after contracts.json was written). Always present so MCP
348
+ // consumers can branch on them without existence checks.
349
+ degradedLinks: result.degradedLinks,
350
+ failedRepos: result.failedRepos,
351
+ warnings: result.warnings,
344
352
  };
345
353
  }
346
354
  async groupContracts(params) {
@@ -47,6 +47,27 @@ export interface SyncResult {
47
47
  * none of that repo's contracts are in `contracts`.
48
48
  */
49
49
  unreadableRepos: string[];
50
+ /**
51
+ * Cross-links whose provider endpoint has no resolved graph symbol
52
+ * (`degraded: true` on the link — see `isUnresolvedEndpoint`). The boundary
53
+ * is proven but cross-impact fan-out cannot anchor it; the usual remedy is
54
+ * re-analyzing the provider repo so its handlers resolve.
55
+ */
56
+ degradedLinks: number;
57
+ /**
58
+ * Repos whose per-repo extraction threw (init, an extractor, or the
59
+ * snapshot read). Each still lands in `unreadableRepos` (group path) —
60
+ * unchanged downstream semantics — but carries its failure reason here: the
61
+ * catch used to swallow the exception, leaving contracts already pushed by
62
+ * earlier extractors in this iteration as silent half-repo data. `repo` is
63
+ * that same group path (e.g. `app/backend`), not the registry display name.
64
+ */
65
+ failedRepos: Array<{
66
+ repo: string;
67
+ reason: string;
68
+ }>;
69
+ /** Operator-facing run warnings (e.g. bridge.lbug write failed after contracts.json was written). */
70
+ warnings: string[];
50
71
  repoSnapshots: Record<string, RepoSnapshot>;
51
72
  /**
52
73
  * Matching stages this run was asked to skip. Populated on EVERY outcome,
@@ -13,6 +13,7 @@ import { ManifestExtractor } from './extractors/manifest-extractor.js';
13
13
  import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js';
14
14
  import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js';
15
15
  import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
16
+ import { applyDegradedFlag } from './normalization.js';
16
17
  import { getContractRegistryPath, readContractRegistry, writeContractRegistry } from './storage.js';
17
18
  import { markBridgeProvenanceUnknown, refreshPreservedBridgeMeta, writeBridgeUnlocked, } from './bridge-db.js';
18
19
  import { withGroupSyncLock } from './group-lock.js';
@@ -127,6 +128,8 @@ export function partitionManifestWindows(links, knownRepos, maxResident) {
127
128
  }
128
129
  export async function syncGroup(config, opts) {
129
130
  const missingRepos = [];
131
+ const failedRepos = [];
132
+ const warnings = [];
130
133
  // Repos that ARE registered but that we could not extract from — the index
131
134
  // would not open, or an extractor threw partway and the repo's staged
132
135
  // contracts were dropped. Kept separate from `missingRepos` because the two
@@ -309,6 +312,10 @@ export async function syncGroup(config, opts) {
309
312
  // stack before logging it defeats the point.
310
313
  logger.warn({ err, repo: regName, groupPath, lbugPath }, "⚠️ Could not read this repo's index; its contracts are omitted from this sync.");
311
314
  unreadableRepos.push(groupPath);
315
+ failedRepos.push({
316
+ repo: groupPath,
317
+ reason: err instanceof Error ? err.message : String(err),
318
+ });
312
319
  // Forget the handle recorded above (present only if the failure came
313
320
  // after initLbug). Deferred manifest resolution derives its known-repo
314
321
  // set from this map, so leaving the entry here re-opens a repo this
@@ -459,7 +466,7 @@ export async function syncGroup(config, opts) {
459
466
  // manifest-declared link can also emit a matchType:'exact' CrossLink with the
460
467
  // same endpoints. Prefer the manifest version — it reflects operator intent
461
468
  // and carries matchType:'manifest' which downstream consumers may rely on.
462
- const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched, ...wildcard.matched]);
469
+ const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched, ...wildcard.matched]).map(applyDegradedFlag);
463
470
  const allContracts = autoContracts;
464
471
  const registry = {
465
472
  version: 1,
@@ -671,10 +678,12 @@ export async function syncGroup(config, opts) {
671
678
  'a lower bound rather than as complete.'
672
679
  : 'Its metadata could NOT be marked provenance-unknown, so those answers may still ' +
673
680
  'report as complete despite describing an older sync.';
674
- logger.warn({ err: msg, groupDir, bridgeProvenanceWithdrawn: withdrawn }, '⚠️ writeBridge failed; contracts.json is intact and is the canonical copy, ' +
681
+ const writeBridgeWarn = '⚠️ writeBridge failed; contracts.json is intact and is the canonical copy, ' +
675
682
  'but bridge.lbug was not replaced: cross-repo queries may still answer from ' +
676
683
  `the previous sync's contracts. ${provenanceNote} ` +
677
- 'Re-run `gitnexus group sync` to retry.');
684
+ 'Re-run `gitnexus group sync` to retry.';
685
+ logger.warn({ err: msg, groupDir, bridgeProvenanceWithdrawn: withdrawn }, writeBridgeWarn);
686
+ warnings.push(writeBridgeWarn);
678
687
  }
679
688
  }
680
689
  });
@@ -686,6 +695,9 @@ export async function syncGroup(config, opts) {
686
695
  unmatched: wildcard.remaining,
687
696
  missingRepos,
688
697
  unreadableRepos,
698
+ failedRepos,
699
+ warnings,
700
+ degradedLinks: crossLinks.filter((l) => l.degraded === true).length,
689
701
  repoSnapshots,
690
702
  registryOutcome,
691
703
  };
@@ -80,6 +80,19 @@ export interface CrossLink {
80
80
  contractId: string;
81
81
  matchType: MatchType;
82
82
  confidence: number;
83
+ /**
84
+ * `true` when the PROVIDER endpoint (`to`) has no resolved graph symbol —
85
+ * empty `symbolUid` / `symbolRef` at sync time (e.g. the handler failed to
86
+ * resolve and `symbolName` degraded to the file name). The contract boundary
87
+ * is still proven, but the link cannot anchor a cross-impact fan-out: an
88
+ * empty provider uid never matches a Phase-1 symbol id, and a downstream
89
+ * fan-out into it has no neighbor symbol to resolve. Derived once at the
90
+ * sync persistence boundary (`isUnresolvedEndpoint` in normalization.ts) and
91
+ * re-derived by `dedupeCrossLinks` when a merge backfills the uid. Absent on
92
+ * fully-anchored links. Distinct from manifest `manifest::…` synthetic UIDs,
93
+ * which have their own `fanout_status: 'not_attempted'` channel downstream.
94
+ */
95
+ degraded?: boolean;
83
96
  }
84
97
  export interface RepoSnapshot {
85
98
  indexedAt: string;
@@ -372,6 +372,15 @@ interface LanguageProviderConfig {
372
372
  * {@link extractModuleConstants} accepts.
373
373
  */
374
374
  readonly moduleConstantHeuristic?: (content: string) => boolean;
375
+ /**
376
+ * Prepare this language's harvested constants once the complete repo map is
377
+ * available and before route operands are folded. The parse phase passes only
378
+ * entries owned by this provider, so implementations can build one reusable
379
+ * language-specific index and may materialize deferred bindings in place.
380
+ *
381
+ * Default: undefined (the harvested constants are already fold-ready).
382
+ */
383
+ readonly prepareRouteConstants?: (repo: RepoConstants) => void;
375
384
  /**
376
385
  * Fold one file's non-literal route-path operand list
377
386
  * (`routePathExpr`/`routePathOperands` of an `ExtractedDecoratorRoute`)
@@ -723,6 +732,12 @@ export interface LanguageProvider extends Omit<LanguageProviderConfig, 'mroStrat
723
732
  /** Check if a name is a built-in/stdlib function that should be filtered from the call graph. */
724
733
  readonly isBuiltInName: (name: string) => boolean;
725
734
  }
735
+ /**
736
+ * Run each provider's repo-constant preparation hook once over only the files
737
+ * that provider owns. Values are shared with `repo`, so in-place preparation
738
+ * is visible to the subsequent fold without copying the complete map.
739
+ */
740
+ export declare function prepareRouteConstantsByProvider(repo: RepoConstants, providerForFile: (filePath: string) => Pick<LanguageProvider, 'prepareRouteConstants'> | null): void;
726
741
  /** Define a language provider — required fields must be supplied, optional fields get sensible defaults. */
727
742
  export declare function defineLanguage(config: LanguageProviderConfig): LanguageProvider;
728
743
  export {};
@@ -45,6 +45,28 @@ export function shouldHarvestModuleConstants(provider, content) {
45
45
  return false;
46
46
  return !provider.moduleConstantHeuristic || provider.moduleConstantHeuristic(content);
47
47
  }
48
+ /**
49
+ * Run each provider's repo-constant preparation hook once over only the files
50
+ * that provider owns. Values are shared with `repo`, so in-place preparation
51
+ * is visible to the subsequent fold without copying the complete map.
52
+ */
53
+ export function prepareRouteConstantsByProvider(repo, providerForFile) {
54
+ const slices = new Map();
55
+ for (const [filePath, constants] of repo) {
56
+ const provider = providerForFile(filePath);
57
+ if (!provider?.prepareRouteConstants)
58
+ continue;
59
+ let slice = slices.get(provider);
60
+ if (!slice) {
61
+ slice = new Map();
62
+ slices.set(provider, slice);
63
+ }
64
+ slice.set(filePath, constants);
65
+ }
66
+ for (const [provider, slice] of slices) {
67
+ provider.prepareRouteConstants?.(slice);
68
+ }
69
+ }
48
70
  const DEFAULTS = {
49
71
  mroStrategy: 'first-wins',
50
72
  };
@@ -13,7 +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
+ import { extractJavaModuleConstants, foldJavaOperands, isJavaConstantFile, prepareJavaRouteConstants, } from '../route-extractors/java-const-resolver.js';
17
17
  import { javaExportChecker } from '../export-detection.js';
18
18
  import { createImportResolver } from '../import-resolvers/resolver-factory.js';
19
19
  import { javaImportConfig } from '../import-resolvers/configs/jvm.js';
@@ -202,11 +202,10 @@ export const javaProvider = defineLanguage({
202
202
  // INTERFACES on this side only, which cost the graph its Route nodes while
203
203
  // the group still published the contract).
204
204
  moduleConstantHeuristic: (content) => isJavaConstantFile(content) ||
205
- // `import com.winning.opt.common.ApiPaths;` ANY class import can bind a
206
- // constant ref (`ApiPaths.X` at an annotation site), so gate on the
207
- // general import shape, not on the imported name. Ingestion-only: this
208
- // side needs the importing controller's own import table, which the group
209
- // side instead derives lazily from the tree it already holds.
210
- /\bimport\s+(?:static\s+)?[\w.]+\s*;/.test(content),
205
+ // Class imports and static (including on-demand) imports can bind a
206
+ // constant ref. Ordinary `import a.b.*;` is not a Java type import and is
207
+ // not expanded by extractJavaModuleConstants, so it must not harvest.
208
+ /\bimport\s+(?:static\s+[\w.]+(?:\.\*)?|[\w.]+)\s*;/.test(content),
209
+ prepareRouteConstants: prepareJavaRouteConstants,
211
210
  foldRoutePathOperands: foldJavaOperands,
212
211
  });
@@ -30,7 +30,8 @@ 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';
32
32
  import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
33
- import { resolveOperands, } from '../route-extractors/python-const-resolver.js';
33
+ import { resolveOperands } from '../route-extractors/python-const-resolver.js';
34
+ import { prepareRouteConstantsByProvider } from '../language-provider.js';
34
35
  import { resolveInheritedSpringRoutes, } from '../route-extractors/spring-shared.js';
35
36
  import fs from 'node:fs';
36
37
  import { effectiveRamBytes, memoryAutopilotDisabled } from '../utils/effective-ram.js';
@@ -1022,6 +1023,10 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1022
1023
  for (const { filePath, constants } of allModuleConstants) {
1023
1024
  repoConstants.set(filePath, constants);
1024
1025
  }
1026
+ // Let each language prepare only its own constants slice before folding.
1027
+ // This is where deferred wildcard bindings can be materialized once per
1028
+ // provider without naming a language in the shared parse phase.
1029
+ prepareRouteConstantsByProvider(repoConstants, getProviderForFile);
1025
1030
  const resolvedRoutes = [];
1026
1031
  let skipped = 0;
1027
1032
  for (const dr of allDecoratorRoutes) {
@@ -62,7 +62,23 @@ export interface ModuleConstants {
62
62
  readonly literals: Map<string, string>;
63
63
  readonly exprs: Map<string, readonly Operand[]>;
64
64
  readonly imports: Map<string, ImportBinding>;
65
+ /**
66
+ * On-demand (wildcard) import specifiers whose bound member names could not
67
+ * be enumerated at extract time — Java `import static a.b.C.*;`, Python
68
+ * `from m import *`. The agnostic fold never reads this (it has no way to
69
+ * enumerate a target module's exports); a language binding materializes the
70
+ * promised bindings from a repo-wide map after extraction — see
71
+ * `expandJavaWildcardStaticImports` in the Java binding — so they resolve
72
+ * through the plain `imports` path with no special cases in the fold.
73
+ */
74
+ readonly wildcardImports?: readonly string[];
65
75
  }
76
+ /**
77
+ * Declaration keys a language extractor found but could not fold. Java and
78
+ * Kotlin both use this metadata to keep lower-priority imports from replacing
79
+ * a real local declaration; other producers simply return the empty set.
80
+ */
81
+ export declare function unfoldableDeclarationsOf(mc: ModuleConstants | undefined): ReadonlySet<string>;
66
82
  /** Repo-wide map: unique file key (e.g. `app/constants.py`) → that file's
67
83
  * {@link ModuleConstants}. */
68
84
  export type RepoConstants = ReadonlyMap<string, ModuleConstants>;
@@ -32,6 +32,16 @@ const MAX_RESOLVE_DEPTH = 8;
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
34
  export const MAX_FOLD_LENGTH = 8192;
35
+ const NO_UNFOLDABLE_DECLARATIONS = new Set();
36
+ /**
37
+ * Declaration keys a language extractor found but could not fold. Java and
38
+ * Kotlin both use this metadata to keep lower-priority imports from replacing
39
+ * a real local declaration; other producers simply return the empty set.
40
+ */
41
+ export function unfoldableDeclarationsOf(mc) {
42
+ const declarations = mc?.unfoldableDeclarations;
43
+ return declarations instanceof Set ? declarations : NO_UNFOLDABLE_DECLARATIONS;
44
+ }
35
45
  /**
36
46
  * Fold an operand list to its concatenated literal, or `null` if any operand is
37
47
  * unresolvable (an unknown name, a non-string term, a cycle, or a depth overrun).
@@ -42,6 +42,10 @@
42
42
  import type Parser from 'tree-sitter';
43
43
  import { type ImportResolver, type ModuleConstants, type Operand, type RepoConstants } from './constant-resolver.js';
44
44
  export type { ImportBinding, ModuleConstants, Operand, RepoConstants, } from './constant-resolver.js';
45
+ export interface JavaModuleConstants extends ModuleConstants {
46
+ /** Declaration keys whose initializer exists but cannot be folded. */
47
+ readonly unfoldableDeclarations: ReadonlySet<string>;
48
+ }
45
49
  export declare function isJavaConstantFile(source: string): boolean;
46
50
  /**
47
51
  * The Java {@link ImportResolver}: map a fully-qualified import specifier to
@@ -96,7 +100,7 @@ export declare function parseJavaConstOperands(node: Parser.SyntaxNode | null |
96
100
  * Last-wins in source order; a non-foldable rebind (`X = compute()`) drops X
97
101
  * to unresolvable rather than keeping a stale literal.
98
102
  */
99
- export declare function extractJavaModuleConstants(tree: Parser.Tree): ModuleConstants;
103
+ export declare function extractJavaModuleConstants(tree: Parser.Tree): JavaModuleConstants;
100
104
  /**
101
105
  * Resolve a single Java constant referenced in `fileKey` to its literal string
102
106
  * value, folding `+` concatenation and following import chains via
@@ -112,3 +116,25 @@ export declare function resolveJavaConstant(fileKey: string, name: string, repo:
112
116
  * `fileKey`, or null when any piece is unresolvable (skip floor).
113
117
  */
114
118
  export declare function foldJavaOperands(fileKey: string, operands: readonly Operand[], repo: RepoConstants): string | null;
119
+ /**
120
+ * Constant-defining file keys used by Java import resolution.
121
+ *
122
+ * Build once per repo pass. Recomputing this set for every wildcard-importing
123
+ * controller makes expansion quadratic in controller count.
124
+ */
125
+ export declare function buildJavaConstantKeys(repo: RepoConstants): ReadonlySet<string>;
126
+ export interface JavaConstantIndex {
127
+ readonly keys: ReadonlySet<string>;
128
+ /** Every resolvable path suffix as a dotted module name; null means ambiguous. */
129
+ readonly byModule: ReadonlyMap<string, string | null>;
130
+ /** Direct members by constant-defining file, built once for all importers. */
131
+ readonly membersByFile: ReadonlyMap<string, ReadonlySet<string>>;
132
+ }
133
+ /**
134
+ * Build all Java import suffixes once, turning repeated wildcard target lookup
135
+ * from O(importers × constant files) into O(path segments + importers).
136
+ */
137
+ export declare function buildJavaConstantIndex(repo: RepoConstants): JavaConstantIndex;
138
+ export declare function expandJavaWildcardStaticImports(mc: ModuleConstants, _fileKey: string, repo: RepoConstants, index?: JavaConstantIndex): ModuleConstants;
139
+ /** Prepare every Java constants entry with one shared suffix index. */
140
+ export declare function prepareJavaRouteConstants(repo: RepoConstants): JavaConstantIndex;