gitnexus 1.6.11-rc.27 → 1.6.11-rc.29

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.
Files changed (26) hide show
  1. package/dist/cli/analyze.js +2 -0
  2. package/dist/core/analyzer-identity.js +21 -3
  3. package/dist/core/incremental/spring-config-drift.d.ts +12 -0
  4. package/dist/core/incremental/spring-config-drift.js +46 -0
  5. package/dist/core/ingestion/frameworks/spring/config-bindings.d.ts +1 -0
  6. package/dist/core/ingestion/frameworks/spring/config-bindings.js +2 -1
  7. package/dist/core/ingestion/languages/java/analysis-features.d.ts +1 -1
  8. package/dist/core/ingestion/languages/java/analysis-features.js +14 -6
  9. package/dist/core/ingestion/languages/kotlin/capture-side-channel.d.ts +5 -0
  10. package/dist/core/ingestion/languages/kotlin/capture-side-channel.js +16 -0
  11. package/dist/core/ingestion/languages/kotlin/captures.js +3 -1
  12. package/dist/core/ingestion/languages/kotlin/scope-resolver.js +2 -0
  13. package/dist/core/ingestion/languages/kotlin/spring-config-bindings.d.ts +18 -0
  14. package/dist/core/ingestion/languages/kotlin/spring-config-bindings.js +411 -0
  15. package/dist/core/ingestion/model/field-registry.d.ts +3 -3
  16. package/dist/core/ingestion/model/field-registry.js +11 -7
  17. package/dist/core/ingestion/model/method-registry.d.ts +3 -3
  18. package/dist/core/ingestion/model/method-registry.js +13 -8
  19. package/dist/core/ingestion/model/type-registry.js +8 -4
  20. package/dist/core/ingestion/scope-resolution/graph-bridge/ids.js +28 -0
  21. package/dist/core/ingestion/scope-resolution/passes/callable-value-flow.d.ts +11 -1
  22. package/dist/core/ingestion/scope-resolution/passes/callable-value-flow.js +26 -6
  23. package/dist/core/ingestion/scope-resolution/pipeline/run.js +5 -2
  24. package/dist/core/lbug/csv-generator.js +22 -20
  25. package/dist/core/run-analyze.js +26 -0
  26. package/package.json +1 -1
@@ -524,6 +524,8 @@ const ANALYZE_CLI_ENV_KEYS = [
524
524
  'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
525
525
  'GITNEXUS_EMBEDDING_DEVICE',
526
526
  'GITNEXUS_ANALYZE_PROGRESS_ACTIVE',
527
+ 'GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS',
528
+ 'GITNEXUS_RESOLVE_DEF_GRAPH_ID_MEMO',
527
529
  'GITNEXUS_EMBEDDING_URL',
528
530
  'GITNEXUS_EMBEDDING_MODEL',
529
531
  'GITNEXUS_EMBEDDING_API_KEY',
@@ -13,12 +13,13 @@
13
13
  * different entry files inside the same build tree; alternating between them
14
14
  * must not make an otherwise identical index stale.
15
15
  */
16
- import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
16
+ import { accessSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
17
17
  import { createHash, randomBytes } from 'node:crypto';
18
18
  import { spawnSync } from 'node:child_process';
19
19
  import { isDeepStrictEqual } from 'node:util';
20
20
  import os from 'node:os';
21
21
  import path from 'node:path';
22
+ import { parseTruthyEnv } from './ingestion/utils/env.js';
22
23
  import { fileURLToPath } from 'node:url';
23
24
  export const ANALYZER_RUNNER_IDENTITY_SCHEMA_VERSION = 4;
24
25
  const BUILD_CANONICALIZATION = 'gitnexus-analyzer-build-v2';
@@ -1754,9 +1755,26 @@ function snapshotCacheGuardDirect(request) {
1754
1755
  return null;
1755
1756
  }
1756
1757
  }
1757
- function snapshotCacheGuards(requests) {
1758
+ function installTreeUnwritable(packageRoot, buildRoot) {
1759
+ for (const dir of [packageRoot, buildRoot]) {
1760
+ try {
1761
+ accessSync(dir, fsConstants.W_OK);
1762
+ }
1763
+ catch (err) {
1764
+ const code = err.code;
1765
+ if (code === 'EACCES' || code === 'EROFS')
1766
+ return true;
1767
+ }
1768
+ }
1769
+ return false;
1770
+ }
1771
+ function snapshotCacheGuards(requests, packageRoot, buildRoot) {
1758
1772
  if (requests.length < 128)
1759
1773
  return requests.map(snapshotCacheGuardDirect);
1774
+ if (parseTruthyEnv(process.env.GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS) ||
1775
+ installTreeUnwritable(packageRoot, buildRoot)) {
1776
+ return requests.map(snapshotCacheGuardDirect);
1777
+ }
1760
1778
  try {
1761
1779
  const probe = spawnSync(process.execPath, ['--input-type=commonjs', '-e', CACHE_GUARD_PROBE_SCRIPT], {
1762
1780
  input: JSON.stringify(requests),
@@ -1857,7 +1875,7 @@ function validateIdentityCache(cache, options) {
1857
1875
  return { mode, absolutePath };
1858
1876
  });
1859
1877
  options.onCacheValidationPass?.({ guardCount: requests.length });
1860
- const actual = snapshotCacheGuards(requests);
1878
+ const actual = snapshotCacheGuards(requests, cache.packageRoot, cache.buildRoot);
1861
1879
  const mismatch = actual.findIndex((result, index) => !isDeepStrictEqual(result, entries[index][1]));
1862
1880
  if (mismatch !== -1) {
1863
1881
  const [mode, absolutePath] = JSON.parse(entries[mismatch][0]);
@@ -0,0 +1,12 @@
1
+ import type { KnowledgeGraph } from '../graph/types.js';
2
+ export interface PersistedSpringConfigConsumerRow {
3
+ readonly id?: unknown;
4
+ readonly description?: unknown;
5
+ }
6
+ /**
7
+ * Find unchanged Spring consumer files whose unresolved markers changed.
8
+ *
9
+ * A removed config key also removes the old USES edge from the fresh graph, so
10
+ * ordinary new-graph boundary expansion cannot discover the consumer file.
11
+ */
12
+ export declare function collectSpringConfigConsumerDriftFiles(graph: KnowledgeGraph, persistedRows: readonly PersistedSpringConfigConsumerRow[]): Set<string>;
@@ -0,0 +1,46 @@
1
+ import { SPRING_CONFIG_UNRESOLVED_PREFIX } from '../ingestion/frameworks/spring/config-bindings.js';
2
+ const CONSUMER_LABELS = new Set(['Property', 'Class', 'Record']);
3
+ function unresolvedKeys(description) {
4
+ if (typeof description !== 'string')
5
+ return [];
6
+ return description
7
+ .split(';')
8
+ .map((part) => part.trim())
9
+ .filter((part) => part.startsWith(SPRING_CONFIG_UNRESOLVED_PREFIX))
10
+ .map((part) => part.slice(SPRING_CONFIG_UNRESOLVED_PREFIX.length))
11
+ .sort();
12
+ }
13
+ /**
14
+ * Find unchanged Spring consumer files whose unresolved markers changed.
15
+ *
16
+ * A removed config key also removes the old USES edge from the fresh graph, so
17
+ * ordinary new-graph boundary expansion cannot discover the consumer file.
18
+ */
19
+ export function collectSpringConfigConsumerDriftFiles(graph, persistedRows) {
20
+ const persistedById = new Map();
21
+ for (const row of persistedRows) {
22
+ if (typeof row.id !== 'string')
23
+ continue;
24
+ persistedById.set(row.id, unresolvedKeys(row.description));
25
+ }
26
+ const driftFiles = new Set();
27
+ graph.forEachNode((node) => {
28
+ if (!CONSUMER_LABELS.has(node.label))
29
+ return;
30
+ const filePath = node.properties.filePath;
31
+ if (typeof filePath !== 'string')
32
+ return;
33
+ const description = node.properties.description;
34
+ const persisted = persistedById.get(node.id);
35
+ if (persisted === undefined &&
36
+ (typeof description !== 'string' || !description.includes(SPRING_CONFIG_UNRESOLVED_PREFIX))) {
37
+ return;
38
+ }
39
+ const current = unresolvedKeys(description);
40
+ const prior = persisted ?? [];
41
+ if (current.length !== prior.length || current.some((key, index) => key !== prior[index])) {
42
+ driftFiles.add(filePath);
43
+ }
44
+ });
45
+ return driftFiles;
46
+ }
@@ -1,5 +1,6 @@
1
1
  import type { KnowledgeGraph } from '../../../graph/types.js';
2
2
  export declare const SPRING_CONFIG_DESCRIPTION = "Spring configuration property";
3
+ export declare const SPRING_CONFIG_UNRESOLVED_PREFIX = "Spring config unresolved: ";
3
4
  export interface SpringValueConsumer {
4
5
  readonly kind: 'value';
5
6
  readonly fieldName: string;
@@ -1,5 +1,6 @@
1
1
  import { generateId } from '../../../../lib/utils.js';
2
2
  export const SPRING_CONFIG_DESCRIPTION = 'Spring configuration property';
3
+ export const SPRING_CONFIG_UNRESOLVED_PREFIX = 'Spring config unresolved: ';
3
4
  function closestNode(candidates, filePath, name, line) {
4
5
  return candidates
5
6
  .filter((node) => node.properties.filePath === filePath && node.properties.name === name)
@@ -7,7 +8,7 @@ function closestNode(candidates, filePath, name, line) {
7
8
  Math.abs(Number(right.properties.startLine ?? 0) - line))[0];
8
9
  }
9
10
  function markUnresolved(node, key) {
10
- const marker = `Spring config unresolved: ${key}`;
11
+ const marker = `${SPRING_CONFIG_UNRESOLVED_PREFIX}${key}`;
11
12
  const existing = typeof node.properties.description === 'string' ? node.properties.description : '';
12
13
  if (existing.includes(marker))
13
14
  return;
@@ -1,5 +1,5 @@
1
1
  import type { AnalysisFeatureDescriptor } from '../../../analysis-features.js';
2
- /** Durable completeness contract for Java Spring configuration bindings. */
2
+ /** Durable completeness contract for Java and Kotlin Spring configuration bindings. */
3
3
  export declare const SPRING_CONFIG_BINDINGS_FEATURE: AnalysisFeatureDescriptor;
4
4
  /** Durable completeness contract for implicit Java record-component accessors. */
5
5
  export declare const JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE: AnalysisFeatureDescriptor;
@@ -2,14 +2,22 @@ function isSpringApplicationConfig(filePath) {
2
2
  const base = filePath.replaceAll('\\', '/').split('/').pop() ?? '';
3
3
  return /^application(?:-[^.]+)?\.(?:properties|ya?ml)$/i.test(base);
4
4
  }
5
- /** Durable completeness contract for Java Spring configuration bindings. */
5
+ /** Durable completeness contract for Java and Kotlin Spring configuration bindings. */
6
6
  export const SPRING_CONFIG_BINDINGS_FEATURE = {
7
7
  id: 'spring.config-bindings',
8
- version: 1,
9
- // Java sources need consumer extraction even without config files (missing
10
- // placeholders still get unresolved markers). Config-only repositories also
11
- // need a one-time rebuild to backfill language-agnostic Property nodes.
12
- appliesTo: (filePaths) => filePaths.some((filePath) => filePath.toLowerCase().endsWith('.java') || isSpringApplicationConfig(filePath)),
8
+ version: 2,
9
+ // Java and Kotlin sources need consumer extraction even without config files
10
+ // (missing placeholders still get unresolved markers). Config-only
11
+ // repositories also need a one-time rebuild to backfill language-agnostic
12
+ // Property nodes. Gradle Kotlin DSL is not a consumer source.
13
+ appliesTo: (filePaths) => filePaths.some((filePath) => {
14
+ const normalized = filePath.replaceAll('\\', '/').toLowerCase();
15
+ if (normalized.endsWith('.gradle.kts'))
16
+ return false;
17
+ return (normalized.endsWith('.java') ||
18
+ normalized.endsWith('.kt') ||
19
+ isSpringApplicationConfig(filePath));
20
+ }),
13
21
  };
14
22
  /** Durable completeness contract for implicit Java record-component accessors. */
15
23
  export const JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE = {
@@ -44,6 +44,7 @@ import type { KotlinSpringAopFact } from './spring-aop.js';
44
44
  import type { KotlinSpringConditionalFact } from './spring-conditionals.js';
45
45
  import type { KotlinSpringDiClassFact } from './spring-di.js';
46
46
  import type { KotlinSpringNonHttpHandlerFact } from './spring-non-http-handlers.js';
47
+ import type { KotlinSpringConfigConsumerFact } from './spring-config-bindings.js';
47
48
  /**
48
49
  * Plain JSON-serializable snapshot of the per-file Kotlin capture-time
49
50
  * side-channel. Carried opaquely on `ParsedFile.captureSideChannel`. The
@@ -68,6 +69,8 @@ export interface KotlinCaptureSideChannel {
68
69
  readonly springDynamicLookupFacts?: readonly SpringDynamicLookupFact[];
69
70
  /** Scheduled, event, messaging, and managed-job handler syntax captured per callable. */
70
71
  readonly springNonHttpHandlerFacts?: readonly KotlinSpringNonHttpHandlerFact[];
72
+ /** `@Value` / `@ConfigurationProperties` syntax captured per owner. */
73
+ readonly springConfigConsumerFacts?: readonly KotlinSpringConfigConsumerFact[];
71
74
  }
72
75
  export declare function clearKotlinClassAnnotationFacts(): void;
73
76
  export declare function setKotlinSpringAopFacts(filePath: string, facts: readonly KotlinSpringAopFact[]): void;
@@ -82,6 +85,8 @@ export declare function setKotlinSpringDynamicLookupFacts(filePath: string, fact
82
85
  export declare function getKotlinSpringDynamicLookupFacts(filePath: string): readonly SpringDynamicLookupFact[];
83
86
  export declare function setKotlinSpringNonHttpHandlerFacts(filePath: string, facts: readonly KotlinSpringNonHttpHandlerFact[]): void;
84
87
  export declare function getKotlinSpringNonHttpHandlerFacts(filePath: string): readonly KotlinSpringNonHttpHandlerFact[];
88
+ export declare function setKotlinSpringConfigConsumerFacts(filePath: string, facts: readonly KotlinSpringConfigConsumerFact[]): void;
89
+ export declare function getKotlinSpringConfigConsumerFacts(filePath: string): readonly KotlinSpringConfigConsumerFact[];
85
90
  /**
86
91
  * `LanguageProvider.collectCaptureSideChannel` implementation for Kotlin.
87
92
  * Returns `undefined` when this file recorded no side-channel state at all, so
@@ -46,6 +46,7 @@ const springConditionalFacts = new Map();
46
46
  const springDiFacts = new Map();
47
47
  const springDynamicLookupFacts = new Map();
48
48
  const springNonHttpHandlerFacts = new Map();
49
+ const springConfigConsumerFacts = new Map();
49
50
  export function clearKotlinClassAnnotationFacts() {
50
51
  classAnnotations.clear();
51
52
  springAopFacts.clear();
@@ -53,6 +54,7 @@ export function clearKotlinClassAnnotationFacts() {
53
54
  springDiFacts.clear();
54
55
  springDynamicLookupFacts.clear();
55
56
  springNonHttpHandlerFacts.clear();
57
+ springConfigConsumerFacts.clear();
56
58
  }
57
59
  export function setKotlinSpringAopFacts(filePath, facts) {
58
60
  if (facts.length === 0)
@@ -105,6 +107,15 @@ export function setKotlinSpringNonHttpHandlerFacts(filePath, facts) {
105
107
  export function getKotlinSpringNonHttpHandlerFacts(filePath) {
106
108
  return springNonHttpHandlerFacts.get(filePath) ?? [];
107
109
  }
110
+ export function setKotlinSpringConfigConsumerFacts(filePath, facts) {
111
+ if (facts.length === 0)
112
+ springConfigConsumerFacts.delete(filePath);
113
+ else
114
+ springConfigConsumerFacts.set(filePath, facts);
115
+ }
116
+ export function getKotlinSpringConfigConsumerFacts(filePath) {
117
+ return springConfigConsumerFacts.get(filePath) ?? [];
118
+ }
108
119
  /**
109
120
  * `LanguageProvider.collectCaptureSideChannel` implementation for Kotlin.
110
121
  * Returns `undefined` when this file recorded no side-channel state at all, so
@@ -118,6 +129,7 @@ export function collectKotlinCaptureSideChannel(filePath) {
118
129
  const diFacts = springDiFacts.get(filePath) ?? [];
119
130
  const dynamicLookupFacts = springDynamicLookupFacts.get(filePath) ?? [];
120
131
  const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? [];
132
+ const configConsumerFacts = springConfigConsumerFacts.get(filePath) ?? [];
121
133
  const packageFact = getKotlinPackageFact(filePath);
122
134
  if (companionScopes.length === 0 &&
123
135
  annotationFacts.length === 0 &&
@@ -126,6 +138,7 @@ export function collectKotlinCaptureSideChannel(filePath) {
126
138
  diFacts.length === 0 &&
127
139
  dynamicLookupFacts.length === 0 &&
128
140
  nonHttpHandlerFacts.length === 0 &&
141
+ configConsumerFacts.length === 0 &&
129
142
  packageFact === undefined) {
130
143
  return undefined;
131
144
  }
@@ -139,6 +152,7 @@ export function collectKotlinCaptureSideChannel(filePath) {
139
152
  ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
140
153
  ...(dynamicLookupFacts.length > 0 ? { springDynamicLookupFacts: dynamicLookupFacts } : {}),
141
154
  ...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}),
155
+ ...(configConsumerFacts.length > 0 ? { springConfigConsumerFacts: configConsumerFacts } : {}),
142
156
  };
143
157
  }
144
158
  /**
@@ -163,6 +177,7 @@ export function applyKotlinCaptureSideChannel(parsed) {
163
177
  setKotlinSpringDiFacts(parsed.filePath, []);
164
178
  setKotlinSpringDynamicLookupFacts(parsed.filePath, []);
165
179
  setKotlinSpringNonHttpHandlerFacts(parsed.filePath, []);
180
+ setKotlinSpringConfigConsumerFacts(parsed.filePath, []);
166
181
  setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
167
182
  return;
168
183
  }
@@ -175,5 +190,6 @@ export function applyKotlinCaptureSideChannel(parsed) {
175
190
  setKotlinSpringDiFacts(parsed.filePath, Array.isArray(data.springDiFacts) ? data.springDiFacts : []);
176
191
  setKotlinSpringDynamicLookupFacts(parsed.filePath, Array.isArray(data.springDynamicLookupFacts) ? data.springDynamicLookupFacts : []);
177
192
  setKotlinSpringNonHttpHandlerFacts(parsed.filePath, Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : []);
193
+ setKotlinSpringConfigConsumerFacts(parsed.filePath, Array.isArray(data.springConfigConsumerFacts) ? data.springConfigConsumerFacts : []);
178
194
  setKotlinPackageFact(parsed.filePath, isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT);
179
195
  }
@@ -10,11 +10,12 @@ import { normalizeKotlinType } from './interpret.js';
10
10
  import { synthesizeKotlinReceiverBinding } from './receiver-binding.js';
11
11
  import { getKotlinParser, getKotlinScopeQuery } from './query.js';
12
12
  import { markCompanionScope } from './companion-scopes.js';
13
- import { setKotlinClassAnnotationFacts, setKotlinSpringAopFacts, setKotlinSpringConditionalFacts, setKotlinSpringDiFacts, setKotlinSpringDynamicLookupFacts, setKotlinSpringNonHttpHandlerFacts, } from './capture-side-channel.js';
13
+ import { setKotlinClassAnnotationFacts, setKotlinSpringAopFacts, setKotlinSpringConditionalFacts, setKotlinSpringDiFacts, setKotlinSpringDynamicLookupFacts, setKotlinSpringNonHttpHandlerFacts, setKotlinSpringConfigConsumerFacts, } from './capture-side-channel.js';
14
14
  import { captureKotlinPackageFact } from './package-facts.js';
15
15
  import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
16
16
  import { synthesizeLombokAccessorCaptures } from './lombok-synthesizer.js';
17
17
  import { captureKotlinSpringDiClassFact } from './spring-di.js';
18
+ import { captureKotlinSpringConfigConsumerFacts } from './spring-config-bindings.js';
18
19
  import { captureKotlinSpringDynamicLookupFact } from './spring-dynamic-lookup.js';
19
20
  import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
20
21
  import { captureKotlinSpringAopFacts } from './spring-aop.js';
@@ -292,6 +293,7 @@ export function emitKotlinScopeCaptures(sourceText, filePath, cachedTree) {
292
293
  setKotlinSpringDiFacts(filePath, springDiFacts);
293
294
  setKotlinSpringDynamicLookupFacts(filePath, springDynamicLookupFacts);
294
295
  setKotlinSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts);
296
+ setKotlinSpringConfigConsumerFacts(filePath, captureKotlinSpringConfigConsumerFacts(tree.rootNode, filePath));
295
297
  out.push(...synthesizeLombokAccessorCaptures(tree.rootNode));
296
298
  out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS));
297
299
  return out;
@@ -15,6 +15,7 @@ import { clearKotlinPackageFacts } from './package-facts.js';
15
15
  import { attachKotlinSpringDiMetadata } from './spring-di.js';
16
16
  import { attachKotlinSpringConditionalMetadata } from './spring-conditionals.js';
17
17
  import { attachKotlinSpringNonHttpHandlerMetadata } from './spring-non-http-handlers.js';
18
+ import { attachKotlinSpringConfigBindings } from './spring-config-bindings.js';
18
19
  import { attachKotlinSpringDynamicLookup } from './spring-dynamic-lookup.js';
19
20
  /**
20
21
  * Kotlin scope resolver for RFC #909 Ring 3.
@@ -123,6 +124,7 @@ export const kotlinScopeResolver = {
123
124
  attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
124
125
  attachKotlinSpringNonHttpHandlerMetadata(graph, parsedFiles, nodeLookup, indexes);
125
126
  attachKotlinSpringDynamicLookup(graph, parsedFiles, nodeLookup, indexes);
127
+ attachKotlinSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes);
126
128
  },
127
129
  };
128
130
  /**
@@ -0,0 +1,18 @@
1
+ import type { KnowledgeGraph } from '../../../graph/types.js';
2
+ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
3
+ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
4
+ import { type ParsedFile, type ScopeId } from '../../../../_shared/index.js';
5
+ import { type SpringConfigConsumer } from '../../frameworks/spring/config-bindings.js';
6
+ import { type SyntaxNode } from '../../utils/ast-helpers.js';
7
+ export interface KotlinSpringConfigConsumerFact {
8
+ readonly consumer: SpringConfigConsumer;
9
+ readonly annotationName: string;
10
+ readonly classScopeId: ScopeId;
11
+ }
12
+ /** Collect config facts from the Kotlin parser's existing AST (no reparse). */
13
+ export declare function captureKotlinSpringConfigConsumerFacts(root: SyntaxNode, filePath: string): KotlinSpringConfigConsumerFact[];
14
+ /** Parse Kotlin consumers for focused unit tests; production reuses the worker AST. */
15
+ export declare function extractKotlinSpringConfigConsumers(source: string): SpringConfigConsumer[];
16
+ export declare function extractKotlinSpringConfigConsumerFacts(source: string): KotlinSpringConfigConsumerFact[];
17
+ /** Kotlin ScopeResolver post-resolution hook for Spring configuration consumers. */
18
+ export declare function attachKotlinSpringConfigBindings(graph: KnowledgeGraph, parsedFiles: readonly ParsedFile[], _nodeLookup: GraphNodeLookup, indexes: ScopeResolutionIndexes): void;
@@ -0,0 +1,411 @@
1
+ import { makeScopeId } from '../../../../_shared/index.js';
2
+ import { bindSpringConfigConsumers, } from '../../frameworks/spring/config-bindings.js';
3
+ import { createSpringAnnotationNameResolver } from '../../frameworks/spring/bean-candidates.js';
4
+ import { parseSpringAnnotationArguments, parseStaticStringLiteral, } from '../../frameworks/spring/annotation-arguments.js';
5
+ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
6
+ import { nodeToCapture } from '../../utils/ast-helpers.js';
7
+ import { getKotlinParser } from './query.js';
8
+ import { getKotlinSpringConfigConsumerFacts } from './capture-side-channel.js';
9
+ import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js';
10
+ const VALUE_ANNOTATION = 'org.springframework.beans.factory.annotation.Value';
11
+ const CONFIGURATION_PROPERTIES_ANNOTATION = 'org.springframework.boot.context.properties.ConfigurationProperties';
12
+ const VALUE_SIMPLE = 'Value';
13
+ const CONFIGURATION_PROPERTIES_SIMPLE = 'ConfigurationProperties';
14
+ const SKIP_USE_SITES = new Set(['get', 'property', 'file']);
15
+ const BIND_USE_SITES = new Set(['field', 'set', 'param']);
16
+ const OWNER_TYPES = new Set(['class_declaration', 'object_declaration', 'companion_object']);
17
+ const INTERPOLATION_TYPES = new Set([
18
+ 'interpolated_identifier',
19
+ 'interpolated_expression',
20
+ 'interpolation_expression_start',
21
+ ]);
22
+ const STRING_LITERAL_TYPES = new Set(['string_literal', 'character_literal']);
23
+ function firstDescendantOfType(node, type) {
24
+ const stack = [...node.namedChildren].reverse();
25
+ while (stack.length > 0) {
26
+ const current = stack.pop();
27
+ if (current === undefined)
28
+ continue;
29
+ if (current.type === type)
30
+ return current;
31
+ for (let index = current.namedChildren.length - 1; index >= 0; index--) {
32
+ const child = current.namedChildren[index];
33
+ if (child !== undefined)
34
+ stack.push(child);
35
+ }
36
+ }
37
+ return undefined;
38
+ }
39
+ function ownerName(declaration) {
40
+ if (declaration.type === 'companion_object') {
41
+ const named = declaration.namedChildren.find((child) => child.type === 'type_identifier');
42
+ return named?.text.trim() || 'Companion';
43
+ }
44
+ return (declaration.namedChildren.find((child) => child.type === 'type_identifier')?.text.trim() ??
45
+ declaration.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim());
46
+ }
47
+ function enclosingOwner(node) {
48
+ let current = node.parent;
49
+ while (current !== null) {
50
+ if (OWNER_TYPES.has(current.type))
51
+ return current;
52
+ current = current.parent;
53
+ }
54
+ return undefined;
55
+ }
56
+ function classScopeId(filePath, declaration) {
57
+ return makeScopeId({
58
+ filePath,
59
+ range: nodeToCapture('@scope.class', declaration).range,
60
+ kind: 'Class',
61
+ });
62
+ }
63
+ function collectKotlinImports(root) {
64
+ const exact = new Map();
65
+ const wildcard = new Set();
66
+ const localTypes = new Map();
67
+ for (const header of root.descendantsOfType('import_header')) {
68
+ const text = header.text.replace(/^import\s+/, '').trim();
69
+ const aliasMatch = text.match(/^([\w.]+)\s+as\s+(\w+)\s*$/);
70
+ if (aliasMatch !== null) {
71
+ exact.set(aliasMatch[2], aliasMatch[1]);
72
+ continue;
73
+ }
74
+ if (text.endsWith('.*'))
75
+ wildcard.add(text.slice(0, -2));
76
+ else {
77
+ const simple = text.slice(text.lastIndexOf('.') + 1);
78
+ if (simple.length > 0)
79
+ exact.set(simple, text);
80
+ }
81
+ }
82
+ for (const type of ['class_declaration', 'object_declaration']) {
83
+ for (const declaration of root.descendantsOfType(type)) {
84
+ const name = ownerName(declaration);
85
+ if (name) {
86
+ const declarations = localTypes.get(name) ?? [];
87
+ declarations.push(declaration);
88
+ localTypes.set(name, declarations);
89
+ }
90
+ }
91
+ }
92
+ return { exact, wildcard, localTypes };
93
+ }
94
+ function annotationFromNode(annotation) {
95
+ const nameNode = firstDescendantOfType(annotation, 'user_type') ??
96
+ firstDescendantOfType(annotation, 'type_identifier') ??
97
+ firstDescendantOfType(annotation, 'simple_identifier');
98
+ if (nameNode === undefined)
99
+ return null;
100
+ const useSiteTarget = annotation.namedChildren
101
+ .find((child) => child.type === 'use_site_target')
102
+ ?.text.replace(/:\s*$/, '')
103
+ .trim();
104
+ return {
105
+ name: nameNode.text.trim(),
106
+ node: annotation,
107
+ ...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }),
108
+ };
109
+ }
110
+ function annotationsOn(node) {
111
+ const annotations = [];
112
+ for (const child of node.namedChildren) {
113
+ if (child.type === 'annotation') {
114
+ const fact = annotationFromNode(child);
115
+ if (fact !== null)
116
+ annotations.push(fact);
117
+ continue;
118
+ }
119
+ if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers')
120
+ continue;
121
+ for (const nested of child.namedChildren) {
122
+ if (nested.type !== 'annotation')
123
+ continue;
124
+ const fact = annotationFromNode(nested);
125
+ if (fact !== null)
126
+ annotations.push(fact);
127
+ }
128
+ }
129
+ return annotations;
130
+ }
131
+ function simpleName(rawName) {
132
+ const parts = rawName.split('.');
133
+ return parts[parts.length - 1] ?? rawName;
134
+ }
135
+ function importedAs(imports, simple, fqn, wildcardPackage) {
136
+ if (imports.exact.get(simple) === fqn)
137
+ return true;
138
+ // An explicit import wins over a star import in Kotlin, so a conflicting
139
+ // binding for the same simple name rules the Spring annotation out even when
140
+ // its package is wildcard-imported.
141
+ return imports.exact.get(simple) === undefined && imports.wildcard.has(wildcardPackage);
142
+ }
143
+ function hasVisibleLocalType(imports, simple, annotation) {
144
+ for (const declaration of imports.localTypes.get(simple) ?? []) {
145
+ const declarationOwner = enclosingOwner(declaration);
146
+ if (declarationOwner === undefined)
147
+ return true;
148
+ let current = annotation;
149
+ while (current !== null) {
150
+ if (current.id === declarationOwner.id)
151
+ return true;
152
+ current = current.parent;
153
+ }
154
+ }
155
+ return false;
156
+ }
157
+ const SIMPLE_CONFIG_ANNOTATIONS = [
158
+ {
159
+ simple: VALUE_SIMPLE,
160
+ kind: 'value',
161
+ fqn: VALUE_ANNOTATION,
162
+ wildcardPackage: 'org.springframework.beans.factory.annotation',
163
+ },
164
+ {
165
+ simple: CONFIGURATION_PROPERTIES_SIMPLE,
166
+ kind: 'configuration-properties',
167
+ fqn: CONFIGURATION_PROPERTIES_ANNOTATION,
168
+ wildcardPackage: 'org.springframework.boot.context.properties',
169
+ },
170
+ ];
171
+ function configAnnotationKind(annotation, imports) {
172
+ const rawName = annotation.name;
173
+ if (rawName === VALUE_ANNOTATION)
174
+ return 'value';
175
+ if (rawName === CONFIGURATION_PROPERTIES_ANNOTATION)
176
+ return 'configuration-properties';
177
+ const simple = simpleName(rawName);
178
+ const aliased = imports.exact.get(simple);
179
+ if (aliased === VALUE_ANNOTATION)
180
+ return 'value';
181
+ if (aliased === CONFIGURATION_PROPERTIES_ANNOTATION)
182
+ return 'configuration-properties';
183
+ for (const candidate of SIMPLE_CONFIG_ANNOTATIONS) {
184
+ if (simple !== candidate.simple)
185
+ continue;
186
+ if (hasVisibleLocalType(imports, simple, annotation.node) && !imports.exact.has(simple)) {
187
+ return null;
188
+ }
189
+ return importedAs(imports, simple, candidate.fqn, candidate.wildcardPackage)
190
+ ? candidate.kind
191
+ : null;
192
+ }
193
+ return null;
194
+ }
195
+ function hasInterpolation(annotation) {
196
+ const stack = [...annotation.namedChildren];
197
+ while (stack.length > 0) {
198
+ const current = stack.pop();
199
+ if (current === undefined)
200
+ continue;
201
+ if (INTERPOLATION_TYPES.has(current.type))
202
+ return true;
203
+ stack.push(...current.namedChildren);
204
+ }
205
+ return false;
206
+ }
207
+ function decodeKotlinStringLiteral(literal) {
208
+ const raw = literal.startsWith('"""') && literal.endsWith('"""');
209
+ const delimiterLength = raw ? 3 : 1;
210
+ if (literal.length < delimiterLength * 2)
211
+ return null;
212
+ const body = literal.slice(delimiterLength, -delimiterLength);
213
+ if (!raw && /(?<!\\)\$\{/.test(body))
214
+ return null;
215
+ if (raw)
216
+ return body;
217
+ return body
218
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_match, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
219
+ .replace(/\\(["'\\$btnfr])/g, (_match, escaped) => {
220
+ const controls = {
221
+ b: '\b',
222
+ t: '\t',
223
+ n: '\n',
224
+ f: '\f',
225
+ r: '\r',
226
+ $: '$',
227
+ };
228
+ return controls[escaped] ?? escaped;
229
+ });
230
+ }
231
+ function kotlinStringLiterals(annotation) {
232
+ const literals = [];
233
+ const stack = [...annotation.namedChildren];
234
+ while (stack.length > 0) {
235
+ const current = stack.pop();
236
+ if (current === undefined)
237
+ continue;
238
+ if (STRING_LITERAL_TYPES.has(current.type)) {
239
+ const decoded = decodeKotlinStringLiteral(current.text);
240
+ if (decoded !== null)
241
+ literals.push(decoded);
242
+ continue;
243
+ }
244
+ stack.push(...current.namedChildren);
245
+ }
246
+ return literals;
247
+ }
248
+ function parseValuePlaceholderKeys(annotation) {
249
+ if (hasInterpolation(annotation))
250
+ return [];
251
+ const keys = new Set();
252
+ for (const literal of kotlinStringLiterals(annotation)) {
253
+ for (const match of literal.matchAll(/\$\{([^{}]+)\}/g)) {
254
+ const key = match[1].split(':', 1)[0].trim();
255
+ if (/^[A-Za-z0-9_.-]+$/.test(key))
256
+ keys.add(key);
257
+ }
258
+ }
259
+ return [...keys];
260
+ }
261
+ function parseConfigurationPropertiesPrefix(annotation) {
262
+ if (hasInterpolation(annotation))
263
+ return null;
264
+ const argumentsList = parseSpringAnnotationArguments(annotation.text);
265
+ if (argumentsList !== null) {
266
+ const named = argumentsList.filter((argument) => argument.name === 'prefix' || argument.name === 'value');
267
+ const positional = argumentsList.filter((argument) => argument.name === undefined);
268
+ const chosen = named.length === 1 ? named[0] : named.length === 0 ? positional[0] : undefined;
269
+ if (chosen !== undefined) {
270
+ const decoded = parseStaticStringLiteral(chosen.value);
271
+ if (decoded === null)
272
+ return null;
273
+ const prefix = decoded.replace(/^\.+|\.+$/g, '');
274
+ if (/^[A-Za-z0-9_.-]+$/.test(prefix))
275
+ return prefix;
276
+ return null;
277
+ }
278
+ if (argumentsList.length > 0)
279
+ return null;
280
+ }
281
+ const literals = kotlinStringLiterals(annotation);
282
+ if (literals.length !== 1)
283
+ return null;
284
+ const prefix = literals[0].trim().replace(/^\.+|\.+$/g, '');
285
+ return /^[A-Za-z0-9_.-]+$/.test(prefix) ? prefix : null;
286
+ }
287
+ function allowedUseSite(useSiteTarget) {
288
+ if (useSiteTarget === undefined)
289
+ return true;
290
+ if (SKIP_USE_SITES.has(useSiteTarget))
291
+ return false;
292
+ return BIND_USE_SITES.has(useSiteTarget);
293
+ }
294
+ function hasBindingPattern(parameter) {
295
+ return parameter.namedChildren.some((child) => child.type === 'binding_pattern_kind');
296
+ }
297
+ function propertyName(node) {
298
+ if (node.type === 'class_parameter') {
299
+ return node.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim();
300
+ }
301
+ const variable = node.namedChildren.find((child) => child.type === 'variable_declaration');
302
+ return variable?.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim();
303
+ }
304
+ function underFileAnnotation(node) {
305
+ let current = node;
306
+ while (current !== null) {
307
+ if (current.type === 'file_annotation')
308
+ return true;
309
+ current = current.parent;
310
+ }
311
+ return false;
312
+ }
313
+ function pushValueFacts(facts, member, filePath, imports) {
314
+ if (underFileAnnotation(member))
315
+ return;
316
+ const owner = enclosingOwner(member);
317
+ if (owner === undefined)
318
+ return;
319
+ const fieldName = propertyName(member);
320
+ if (fieldName === undefined)
321
+ return;
322
+ for (const annotation of annotationsOn(member)) {
323
+ if (!allowedUseSite(annotation.useSiteTarget))
324
+ continue;
325
+ if (configAnnotationKind(annotation, imports) !== 'value')
326
+ continue;
327
+ const keys = parseValuePlaceholderKeys(annotation.node);
328
+ if (keys.length === 0)
329
+ continue;
330
+ facts.push({
331
+ consumer: {
332
+ kind: 'value',
333
+ fieldName,
334
+ line: member.startPosition.row + 1,
335
+ keys,
336
+ },
337
+ annotationName: annotation.name,
338
+ classScopeId: classScopeId(filePath, owner),
339
+ });
340
+ }
341
+ }
342
+ /** Collect config facts from the Kotlin parser's existing AST (no reparse). */
343
+ export function captureKotlinSpringConfigConsumerFacts(root, filePath) {
344
+ const imports = collectKotlinImports(root);
345
+ const facts = [];
346
+ for (const property of root.descendantsOfType('property_declaration')) {
347
+ pushValueFacts(facts, property, filePath, imports);
348
+ }
349
+ for (const parameter of root.descendantsOfType('class_parameter')) {
350
+ if (!hasBindingPattern(parameter))
351
+ continue;
352
+ pushValueFacts(facts, parameter, filePath, imports);
353
+ }
354
+ for (const type of ['class_declaration', 'object_declaration']) {
355
+ for (const declaration of root.descendantsOfType(type)) {
356
+ const className = ownerName(declaration);
357
+ if (className === undefined)
358
+ continue;
359
+ for (const annotation of annotationsOn(declaration)) {
360
+ if (configAnnotationKind(annotation, imports) !== 'configuration-properties') {
361
+ continue;
362
+ }
363
+ const prefix = parseConfigurationPropertiesPrefix(annotation.node);
364
+ if (prefix === null)
365
+ continue;
366
+ facts.push({
367
+ consumer: {
368
+ kind: 'configuration-properties',
369
+ className,
370
+ line: declaration.startPosition.row + 1,
371
+ prefix,
372
+ },
373
+ annotationName: annotation.name,
374
+ classScopeId: classScopeId(filePath, declaration),
375
+ });
376
+ }
377
+ }
378
+ }
379
+ return facts;
380
+ }
381
+ /** Parse Kotlin consumers for focused unit tests; production reuses the worker AST. */
382
+ export function extractKotlinSpringConfigConsumers(source) {
383
+ const tree = parseSourceSafe(getKotlinParser(), source);
384
+ return captureKotlinSpringConfigConsumerFacts(tree.rootNode, '<memory>').map((fact) => fact.consumer);
385
+ }
386
+ export function extractKotlinSpringConfigConsumerFacts(source) {
387
+ const tree = parseSourceSafe(getKotlinParser(), source);
388
+ return captureKotlinSpringConfigConsumerFacts(tree.rootNode, '<memory>');
389
+ }
390
+ /** Kotlin ScopeResolver post-resolution hook for Spring configuration consumers. */
391
+ export function attachKotlinSpringConfigBindings(graph, parsedFiles, _nodeLookup, indexes) {
392
+ const resolveAnnotation = createSpringAnnotationNameResolver(indexes);
393
+ const recognizedAnnotations = new Set([VALUE_ANNOTATION, CONFIGURATION_PROPERTIES_ANNOTATION]);
394
+ const batches = [];
395
+ for (const parsed of parsedFiles) {
396
+ const consumers = [];
397
+ for (const fact of getKotlinSpringConfigConsumerFacts(parsed.filePath)) {
398
+ const classScope = indexes.scopeTree.getScope(fact.classScopeId);
399
+ if (classScope === undefined || classScope.kind !== 'Class')
400
+ continue;
401
+ const expectedAnnotation = fact.consumer.kind === 'value' ? VALUE_ANNOTATION : CONFIGURATION_PROPERTIES_ANNOTATION;
402
+ const enclosingScope = fact.consumer.kind === 'value' ? classScope.id : classScope.parent;
403
+ const resolved = resolveAnnotation(fact.annotationName, parsed, enclosingScope, recognizedAnnotations, isKotlinPackageSiblingVisibilityIncomplete(parsed.filePath));
404
+ if (resolved === expectedAnnotation)
405
+ consumers.push(fact.consumer);
406
+ }
407
+ if (consumers.length > 0)
408
+ batches.push({ filePath: parsed.filePath, consumers });
409
+ }
410
+ bindSpringConfigConsumers(graph, batches);
411
+ }
@@ -2,9 +2,9 @@
2
2
  * Field Registry
3
3
  *
4
4
  * Owner-scoped field/property index extracted from SymbolTable.
5
- * Stores Property / Variable / Const / Static symbols keyed by
6
- * `ownerNodeId\0fieldName` for O(1) lookup. Supports multiple defs
7
- * under the same (owner, name) — e.g. legacy Property plus a
5
+ * Stores Property / Variable / Const / Static symbols in a nested
6
+ * `Map<ownerNodeId, Map<fieldName, defs[]>>` for O(1) lookup. Supports
7
+ * multiple defs under the same (owner, name) — e.g. legacy Property plus a
8
8
  * scope-resolution Variable reconciliation entry.
9
9
  */
10
10
  import type { SymbolDefinition } from '../../../_shared/index.js';
@@ -2,9 +2,9 @@
2
2
  * Field Registry
3
3
  *
4
4
  * Owner-scoped field/property index extracted from SymbolTable.
5
- * Stores Property / Variable / Const / Static symbols keyed by
6
- * `ownerNodeId\0fieldName` for O(1) lookup. Supports multiple defs
7
- * under the same (owner, name) — e.g. legacy Property plus a
5
+ * Stores Property / Variable / Const / Static symbols in a nested
6
+ * `Map<ownerNodeId, Map<fieldName, defs[]>>` for O(1) lookup. Supports
7
+ * multiple defs under the same (owner, name) — e.g. legacy Property plus a
8
8
  * scope-resolution Variable reconciliation entry.
9
9
  */
10
10
  const EMPTY = Object.freeze([]);
@@ -14,20 +14,24 @@ const EMPTY = Object.freeze([]);
14
14
  export const createFieldRegistry = () => {
15
15
  const fieldByOwner = new Map();
16
16
  const lookupAllByOwner = (ownerNodeId, fieldName) => {
17
- return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`) ?? EMPTY;
17
+ return fieldByOwner.get(ownerNodeId)?.get(fieldName) ?? EMPTY;
18
18
  };
19
19
  const lookupFieldByOwner = (ownerNodeId, fieldName) => {
20
20
  const pool = lookupAllByOwner(ownerNodeId, fieldName);
21
21
  return pool.length === 0 ? undefined : pool[0];
22
22
  };
23
23
  const register = (ownerNodeId, fieldName, def) => {
24
- const key = `${ownerNodeId}\0${fieldName}`;
25
- const existing = fieldByOwner.get(key);
24
+ let byName = fieldByOwner.get(ownerNodeId);
25
+ if (!byName) {
26
+ byName = new Map();
27
+ fieldByOwner.set(ownerNodeId, byName);
28
+ }
29
+ const existing = byName.get(fieldName);
26
30
  if (existing) {
27
31
  existing.push(def);
28
32
  }
29
33
  else {
30
- fieldByOwner.set(key, [def]);
34
+ byName.set(fieldName, [def]);
31
35
  }
32
36
  };
33
37
  const clear = () => {
@@ -2,9 +2,9 @@
2
2
  * Method Registry
3
3
  *
4
4
  * Owner-scoped method index extracted from SymbolTable.
5
- * Stores Method/Constructor/Function-with-ownerId symbols keyed by
6
- * `ownerNodeId\0methodName` for O(1) lookup. Supports overloads
7
- * (array values) and arity-based filtering.
5
+ * Stores Method/Constructor/Function-with-ownerId symbols in a nested
6
+ * `Map<ownerNodeId, Map<methodName, defs[]>>` for O(1) lookup. Supports
7
+ * overloads (array values) and arity-based filtering.
8
8
  */
9
9
  import type { SymbolDefinition } from '../../../_shared/index.js';
10
10
  export interface MethodRegistry {
@@ -2,9 +2,9 @@
2
2
  * Method Registry
3
3
  *
4
4
  * Owner-scoped method index extracted from SymbolTable.
5
- * Stores Method/Constructor/Function-with-ownerId symbols keyed by
6
- * `ownerNodeId\0methodName` for O(1) lookup. Supports overloads
7
- * (array values) and arity-based filtering.
5
+ * Stores Method/Constructor/Function-with-ownerId symbols in a nested
6
+ * `Map<ownerNodeId, Map<methodName, defs[]>>` for O(1) lookup. Supports
7
+ * overloads (array values) and arity-based filtering.
8
8
  */
9
9
  // ---------------------------------------------------------------------------
10
10
  // Factory
@@ -19,8 +19,9 @@ export const createMethodRegistry = () => {
19
19
  // Set once when a Function+ownerId def lands here, powers the Tier 3
20
20
  // dedup fast-path. Monotonic: never unset except on `clear()`.
21
21
  let hasFunctionMethodsFlag = false;
22
+ const ownerDefs = (ownerNodeId, methodName) => methodByOwner.get(ownerNodeId)?.get(methodName);
22
23
  const lookupMethodByOwner = (ownerNodeId, methodName, argCount) => {
23
- const defs = methodByOwner.get(`${ownerNodeId}\0${methodName}`);
24
+ const defs = ownerDefs(ownerNodeId, methodName);
24
25
  if (!defs || defs.length === 0)
25
26
  return undefined;
26
27
  // Arity narrowing: when an argCount is provided and there are multiple
@@ -89,16 +90,20 @@ export const createMethodRegistry = () => {
89
90
  return methodsByName.get(name) ?? EMPTY;
90
91
  };
91
92
  const lookupAllByOwner = (ownerNodeId, methodName) => {
92
- return methodByOwner.get(`${ownerNodeId}\0${methodName}`) ?? EMPTY;
93
+ return ownerDefs(ownerNodeId, methodName) ?? EMPTY;
93
94
  };
94
95
  const register = (ownerNodeId, methodName, def) => {
95
- const key = `${ownerNodeId}\0${methodName}`;
96
- const existing = methodByOwner.get(key);
96
+ let owned = methodByOwner.get(ownerNodeId);
97
+ if (!owned) {
98
+ owned = new Map();
99
+ methodByOwner.set(ownerNodeId, owned);
100
+ }
101
+ const existing = owned.get(methodName);
97
102
  if (existing) {
98
103
  existing.push(def);
99
104
  }
100
105
  else {
101
- methodByOwner.set(key, [def]);
106
+ owned.set(methodName, [def]);
102
107
  }
103
108
  const byName = methodsByName.get(methodName);
104
109
  if (byName) {
@@ -24,7 +24,7 @@ export const createTypeRegistry = () => {
24
24
  return implByName.get(name) ?? [];
25
25
  };
26
26
  const lookupAllByOwner = (ownerNodeId, simpleName) => {
27
- return nestedByOwner.get(`${ownerNodeId}\0${simpleName}`) ?? EMPTY;
27
+ return nestedByOwner.get(ownerNodeId)?.get(simpleName) ?? EMPTY;
28
28
  };
29
29
  const registerClass = (name, qualifiedName, def) => {
30
30
  const existing = classByName.get(name);
@@ -52,13 +52,17 @@ export const createTypeRegistry = () => {
52
52
  }
53
53
  };
54
54
  const registerByOwner = (ownerNodeId, simpleName, def) => {
55
- const key = `${ownerNodeId}\0${simpleName}`;
56
- const existing = nestedByOwner.get(key);
55
+ let byName = nestedByOwner.get(ownerNodeId);
56
+ if (!byName) {
57
+ byName = new Map();
58
+ nestedByOwner.set(ownerNodeId, byName);
59
+ }
60
+ const existing = byName.get(simpleName);
57
61
  if (existing) {
58
62
  existing.push(def);
59
63
  }
60
64
  else {
61
- nestedByOwner.set(key, [def]);
65
+ byName.set(simpleName, [def]);
62
66
  }
63
67
  };
64
68
  const clear = () => {
@@ -22,6 +22,15 @@ import { isOverloadableCallable, isPositionQualifiedLocalLabel, } from '../../ut
22
22
  import { templateConstraintsIdTag } from '../../utils/template-arguments.js';
23
23
  import { parameterShapeIdTag } from '../../utils/method-props.js';
24
24
  import { definitionIdPosition } from '../utils/definition-id.js';
25
+ const defGraphIdMemoByLookup = new WeakMap();
26
+ const isResolveDefGraphIdMemoEnabled = () => {
27
+ const raw = process.env.GITNEXUS_RESOLVE_DEF_GRAPH_ID_MEMO;
28
+ if (raw === undefined || raw.trim() === '')
29
+ return true;
30
+ const value = raw.trim().toLowerCase();
31
+ return value !== '0' && value !== 'false' && value !== 'off' && value !== 'no';
32
+ };
33
+ const defGraphIdMemoKey = (filePath, def) => `${filePath}\0${def.nodeId ?? ''}\0${def.type ?? ''}\0${def.qualifiedName ?? ''}\0${def.parameterCount ?? ''}\0${(def.parameterTypes ?? []).join(',')}\0${(def.parameterTypeClasses ?? []).join(',')}\0${def.namespacePrefix ?? ''}\0${(def.templateArguments ?? []).join(',')}\0${templateConstraintsIdTag(def.templateConstraints)}`;
25
34
  /**
26
35
  * Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the
27
36
  * source ("caller"). A Variable / Property can be the TARGET of an
@@ -188,6 +197,25 @@ function siblingCallableLabel(label) {
188
197
  return undefined;
189
198
  }
190
199
  export function resolveDefGraphId(filePath, def, nodeLookup) {
200
+ if (!isResolveDefGraphIdMemoEnabled()) {
201
+ return resolveDefGraphIdUncached(filePath, def, nodeLookup);
202
+ }
203
+ const qn = def.qualifiedName;
204
+ if (qn === undefined || qn.length === 0)
205
+ return undefined;
206
+ let bucket = defGraphIdMemoByLookup.get(nodeLookup);
207
+ if (bucket === undefined) {
208
+ bucket = new Map();
209
+ defGraphIdMemoByLookup.set(nodeLookup, bucket);
210
+ }
211
+ const key = defGraphIdMemoKey(filePath, def);
212
+ if (bucket.has(key))
213
+ return bucket.get(key);
214
+ const resolved = resolveDefGraphIdUncached(filePath, def, nodeLookup);
215
+ bucket.set(key, resolved);
216
+ return resolved;
217
+ }
218
+ function resolveDefGraphIdUncached(filePath, def, nodeLookup) {
191
219
  const qn = def.qualifiedName;
192
220
  if (qn === undefined || qn.length === 0)
193
221
  return undefined;
@@ -3,7 +3,7 @@
3
3
  * values. Providers own syntax recognition; this pass consumes only the
4
4
  * JSON-safe facts carried by ParsedFile.
5
5
  */
6
- import type { ParsedFile, SymbolDefinition } from '../../../../_shared/index.js';
6
+ import type { CallableFlowExpectedSignature, ParsedFile, SymbolDefinition } from '../../../../_shared/index.js';
7
7
  import type { KnowledgeGraph } from '../../../graph/types.js';
8
8
  import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
9
9
  import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
@@ -44,6 +44,14 @@ export interface EmitCallableValueFlowInput {
44
44
  readonly isCallableValueTarget?: (def: SymbolDefinition) => boolean;
45
45
  readonly hasFileLocalCallableLinkage?: (def: SymbolDefinition) => boolean;
46
46
  readonly onWarn?: (warning: CallableValueFlowWarning) => void;
47
+ /** When set, skip a second `collectDeferredIndirectSites` walk. */
48
+ readonly deferredIndirectSites?: ReadonlySet<string>;
49
+ /** When set, skip a second `referenceSites` signature walk. */
50
+ readonly callSignaturesBySite?: ReadonlyMap<string, CallableFlowExpectedSignature>;
51
+ }
52
+ export interface DeferredIndirectCollection {
53
+ readonly sites: ReadonlySet<string>;
54
+ readonly callSignaturesBySite: ReadonlyMap<string, CallableFlowExpectedSignature>;
47
55
  }
48
56
  /** Position key shared with the existing free/reference skip-set contract. */
49
57
  export declare function callableFlowSiteKey(filePath: string, range: {
@@ -55,6 +63,8 @@ export declare function callableFlowSiteKey(filePath: string, range: {
55
63
  * Malformed/stale facts never suppress ordinary resolution.
56
64
  */
57
65
  export declare function collectDeferredIndirectSites(parsedFiles: readonly ParsedFile[], scopes?: ScopeResolutionIndexes): ReadonlySet<string>;
66
+ /** One `referenceSites` walk for deferred keys and call-signature evidence. */
67
+ export declare function collectDeferredIndirectCollection(parsedFiles: readonly ParsedFile[], scopes?: ScopeResolutionIndexes): DeferredIndirectCollection;
58
68
  export declare function emitCallableValueFlow(input: EmitCallableValueFlowInput): CallableValueFlowResult;
59
69
  /**
60
70
  * Pure — exported for `bench/callable-value-flow/measure.mjs`, which guards its
@@ -28,7 +28,12 @@ export function callableFlowSiteKey(filePath, range) {
28
28
  * Malformed/stale facts never suppress ordinary resolution.
29
29
  */
30
30
  export function collectDeferredIndirectSites(parsedFiles, scopes) {
31
+ return collectDeferredIndirectCollection(parsedFiles, scopes).sites;
32
+ }
33
+ /** One `referenceSites` walk for deferred keys and call-signature evidence. */
34
+ export function collectDeferredIndirectCollection(parsedFiles, scopes) {
31
35
  const out = new Set();
36
+ const callSignaturesBySite = new Map();
32
37
  const flowCells = new Set();
33
38
  if (scopes !== undefined) {
34
39
  for (const parsed of parsedFiles) {
@@ -41,9 +46,24 @@ export function collectDeferredIndirectSites(parsedFiles, scopes) {
41
46
  }
42
47
  }
43
48
  for (const parsed of parsedFiles) {
44
- const canonical = new Set(parsed.referenceSites
45
- .filter((site) => site.kind === 'call')
46
- .map((site) => callableFlowSiteKey(parsed.filePath, site.atRange)));
49
+ const canonical = new Set();
50
+ for (const site of parsed.referenceSites) {
51
+ if (site.kind !== 'call')
52
+ continue;
53
+ const key = callableFlowSiteKey(parsed.filePath, site.atRange);
54
+ canonical.add(key);
55
+ const signature = {
56
+ ...(site.arity !== undefined ? { parameterCount: site.arity } : {}),
57
+ ...(site.argumentTypes !== undefined ? { parameterTypes: site.argumentTypes } : {}),
58
+ ...(site.argumentTypeClasses !== undefined
59
+ ? { parameterTypeClasses: site.argumentTypeClasses }
60
+ : {}),
61
+ };
62
+ const previous = callSignaturesBySite.get(key);
63
+ if (previous === undefined || signatureEvidence(signature) > signatureEvidence(previous)) {
64
+ callSignaturesBySite.set(key, signature);
65
+ }
66
+ }
47
67
  for (const site of parsed.callableFlowSites ?? []) {
48
68
  if (site.kind !== 'invoke')
49
69
  continue;
@@ -58,7 +78,7 @@ export function collectDeferredIndirectSites(parsedFiles, scopes) {
58
78
  }
59
79
  }
60
80
  }
61
- return out;
81
+ return { sites: out, callSignaturesBySite };
62
82
  }
63
83
  function flowCellOperand(site) {
64
84
  switch (site.kind) {
@@ -80,7 +100,7 @@ function flowCellOperand(site) {
80
100
  export function emitCallableValueFlow(input) {
81
101
  const facts = [];
82
102
  const invokes = [];
83
- const canonicalInvokeKeys = collectDeferredIndirectSites(input.parsedFiles, input.scopes);
103
+ const canonicalInvokeKeys = input.deferredIndirectSites ?? collectDeferredIndirectSites(input.parsedFiles, input.scopes);
84
104
  let unmatchedInvokes = 0;
85
105
  for (const parsed of input.parsedFiles) {
86
106
  for (const site of parsed.callableFlowSites ?? []) {
@@ -347,7 +367,7 @@ export function emitCallableValueFlow(input) {
347
367
  addAddress(bindingKey(fact.filePath, fact.site.destination), bindingKey(fact.filePath, fact.site.source), `address:${fact.filePath}:${fact.site.source.name}`);
348
368
  }
349
369
  const formalsByGraphId = indexFormalsByGraphId(input.parsedFiles, input.scopes, input.nodeLookup, targetIndexes, aliasesByTargetId);
350
- const callSignaturesBySite = indexCallSignatures(input.parsedFiles);
370
+ const callSignaturesBySite = input.callSignaturesBySite ?? indexCallSignatures(input.parsedFiles);
351
371
  const dynamicCallees = new Map();
352
372
  const dynamicOverflow = new Set();
353
373
  const dynamicTargetHistory = new Map();
@@ -49,7 +49,7 @@ import { emitReturnShapeMemberAccesses } from '../passes/return-shape-members.js
49
49
  import { emitImportedValueReferences } from '../passes/imported-value-refs.js';
50
50
  import { createCalleeIdAccumulator, } from '../graph-bridge/callee-id-sink.js';
51
51
  import { emitImportEdges } from '../graph-bridge/imports-to-edges.js';
52
- import { callableFlowSiteKey, collectDeferredIndirectSites, emitCallableValueFlow, } from '../passes/callable-value-flow.js';
52
+ import { callableFlowSiteKey, collectDeferredIndirectCollection, emitCallableValueFlow, } from '../passes/callable-value-flow.js';
53
53
  import { findEnclosingClassDef, resolveInheritanceBaseInScope } from '../scope/walkers.js';
54
54
  import { heritageTypeArgumentsKey, } from '../utils/generic-instantiation.js';
55
55
  import { buildWorkspaceResolutionIndex } from '../workspace-index.js';
@@ -614,7 +614,8 @@ export function runScopeResolution(input, provider) {
614
614
  // ── Phase 4: emit graph edges (LOAD-BEARING ORDER — see I1) ────────────
615
615
  input.onProgress?.('linking symbols', files.length, files.length);
616
616
  const handledSites = new Set(preEmittedInheritanceSites);
617
- const deferredIndirectSites = collectDeferredIndirectSites(emitParsedFiles, indexes);
617
+ const deferredIndirectCollection = collectDeferredIndirectCollection(emitParsedFiles, indexes);
618
+ const deferredIndirectSites = deferredIndirectCollection.sites;
618
619
  const callableArgumentSites = new Set();
619
620
  if (input.pdg !== true && deferredIndirectSites.size > 0) {
620
621
  for (const parsed of emitParsedFiles) {
@@ -788,6 +789,8 @@ export function runScopeResolution(input, provider) {
788
789
  collapseByCallerTarget: provider.collapseMemberCallsByCallerTarget === true,
789
790
  isCallableValueTarget: provider.isCallableValueTarget,
790
791
  hasFileLocalCallableLinkage: provider.hasFileLocalCallableLinkage,
792
+ deferredIndirectSites,
793
+ callSignaturesBySite: deferredIndirectCollection.callSignaturesBySite,
791
794
  onWarn: (warning) => logger.warn(warning, 'callable-value-flow: candidate set exceeded the cap; no partial CALLS emitted'),
792
795
  });
793
796
  const importsEmitted = callableFlowOnly
@@ -152,14 +152,15 @@ export const isBinaryContent = (content) => {
152
152
  }
153
153
  return nonPrintable / end > 0.1;
154
154
  };
155
+ const EMPTY_PREPARED = { content: '', lines: [''], isBinary: false };
155
156
  /**
156
157
  * LRU content cache — avoids re-reading the same source file for every
157
158
  * symbol defined in it. Sized generously so most files stay cached during
158
- * the single-pass node iteration.
159
+ * the single-pass node iteration. Insertion order on the Map is LRU
160
+ * (delete+set on touch).
159
161
  */
160
162
  class FileContentCache {
161
163
  cache = new Map();
162
- accessOrder = [];
163
164
  maxSize;
164
165
  repoPath;
165
166
  constructor(repoPath, maxSize = 3000) {
@@ -168,36 +169,36 @@ class FileContentCache {
168
169
  }
169
170
  async get(relativePath) {
170
171
  if (!relativePath)
171
- return '';
172
+ return EMPTY_PREPARED;
172
173
  const cached = this.cache.get(relativePath);
173
174
  if (cached !== undefined) {
174
- // Move to end of accessOrder (LRU promotion)
175
- const idx = this.accessOrder.indexOf(relativePath);
176
- if (idx !== -1) {
177
- this.accessOrder.splice(idx, 1);
178
- this.accessOrder.push(relativePath);
179
- }
175
+ this.cache.delete(relativePath);
176
+ this.cache.set(relativePath, cached);
180
177
  return cached;
181
178
  }
182
179
  try {
183
180
  const fullPath = path.join(this.repoPath, relativePath);
184
181
  const content = await fs.readFile(fullPath, 'utf-8');
185
- this.set(relativePath, content);
186
- return content;
182
+ const prepared = {
183
+ content,
184
+ lines: content.split('\n'),
185
+ isBinary: isBinaryContent(content),
186
+ };
187
+ this.set(relativePath, prepared);
188
+ return prepared;
187
189
  }
188
190
  catch {
189
- this.set(relativePath, '');
190
- return '';
191
+ this.set(relativePath, EMPTY_PREPARED);
192
+ return EMPTY_PREPARED;
191
193
  }
192
194
  }
193
195
  set(key, value) {
194
- if (this.cache.size >= this.maxSize) {
195
- const oldest = this.accessOrder.shift();
196
- if (oldest)
196
+ if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
197
+ const oldest = this.cache.keys().next().value;
198
+ if (oldest !== undefined)
197
199
  this.cache.delete(oldest);
198
200
  }
199
201
  this.cache.set(key, value);
200
- this.accessOrder.push(key);
201
202
  }
202
203
  }
203
204
  /**
@@ -247,12 +248,13 @@ const formatFtsDescription = (description) => isBinaryContent(description) ? ''
247
248
  const EXACT_SYMBOL_CONTENT_LABELS = SYMBOL_NODE_LABELS;
248
249
  const extractContent = async (node, contentCache) => {
249
250
  const filePath = node.properties.filePath;
250
- const content = await contentCache.get(filePath);
251
+ const prepared = await contentCache.get(filePath);
252
+ const content = prepared.content;
251
253
  if (!content)
252
254
  return '';
253
255
  if (node.label === 'Folder')
254
256
  return '';
255
- if (isBinaryContent(content))
257
+ if (prepared.isBinary)
256
258
  return '[Binary file - content not stored]';
257
259
  // File content is stored in full — intentionally NOT length-capped here, so
258
260
  // text past the old 10KB cutoff stays FTS-searchable (#2317). It is already
@@ -266,7 +268,7 @@ const extractContent = async (node, contentCache) => {
266
268
  const endLine = node.properties.endLine;
267
269
  if (startLine === undefined || endLine === undefined)
268
270
  return '';
269
- const lines = content.split('\n');
271
+ const lines = prepared.lines;
270
272
  const exactSymbolContent = EXACT_SYMBOL_CONTENT_LABELS.has(node.label);
271
273
  const start = Math.max(0, exactSymbolContent ? startLine : startLine - 2);
272
274
  const end = Math.min(lines.length - 1, exactSymbolContent ? endLine : endLine + 2);
@@ -41,6 +41,7 @@ import { taintModelVersion } from './ingestion/taint/typescript-model.js';
41
41
  import { parseTruthyEnv, parsePositiveIntEnv } from './ingestion/utils/env.js';
42
42
  import { computeFileHashes, diffFileHashes } from '../storage/file-hash.js';
43
43
  import { extractChangedSubgraph, computeEffectiveWriteSet, } from './incremental/subgraph-extract.js';
44
+ import { collectSpringConfigConsumerDriftFiles, } from './incremental/spring-config-drift.js';
44
45
  import { shadowCandidatesFor } from './incremental/shadow-candidates.js';
45
46
  import { shouldEscalateIncrementalWrite } from './incremental/escalation-gate.js';
46
47
  import { ftsTablesAmong, incrementalFtsTablesFromGraph, nodeTablesForIncrementalDelete, shouldPreservePersistedDerivedGraph, } from './incremental/derived-writeback.js';
@@ -55,6 +56,8 @@ import { sanitizeDetectedBranch } from '../cli/analyze-config.js';
55
56
  import { EMBEDDING_TABLE_NAME, EMBEDDING_DIMS, STALE_HASH_SENTINEL, SCHEMA_FINGERPRINT, schemaFingerprintMismatch, isSchemaFingerprintShaped, embeddingDimsMismatch, } from './lbug/schema.js';
56
57
  import { isSpringBeanCandidateSourceFile } from './ingestion/frameworks/spring/bean-catalog.js';
57
58
  import { isSpringBeanFactoryDeclaration } from './ingestion/frameworks/spring/bean-factories.js';
59
+ import { SPRING_CONFIG_UNRESOLVED_PREFIX } from './ingestion/frameworks/spring/config-bindings.js';
60
+ import { classifySpringConfigFile } from './ingestion/pipeline-phases/spring-config.js';
58
61
  import { SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, SPRING_NON_HTTP_HANDLERS_FEATURE, } from './ingestion/frameworks/spring/analysis-features.js';
59
62
  import { JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, } from './ingestion/languages/java/analysis-features.js';
60
63
  import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, findAnalysisFeatureMismatches, resolveAnalysisFeatureVersions, } from './analysis-features.js';
@@ -1812,6 +1815,29 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1812
1815
  // and extractChangedSubgraph — asymmetry between the two would
1813
1816
  // leave stale rows or PK-conflict at COPY time.
1814
1817
  const effectiveWriteSet = computeEffectiveWriteSet(pipelineResult.graph, writableFiles);
1818
+ const springConfigChanged = hashDiff.toWrite.some((filePath) => classifySpringConfigFile(filePath) !== null) ||
1819
+ hashDiff.deleted.some((filePath) => classifySpringConfigFile(filePath) !== null);
1820
+ if (springConfigChanged) {
1821
+ const unresolvedPrefix = escapeCypherString(SPRING_CONFIG_UNRESOLVED_PREFIX);
1822
+ const persistedSpringConfigConsumers = (await executeQuery('MATCH (n:Property) ' +
1823
+ `WHERE n.description CONTAINS '${unresolvedPrefix}' ` +
1824
+ 'RETURN n.id AS id, n.description AS description ' +
1825
+ 'UNION ALL ' +
1826
+ 'MATCH (n:Class) ' +
1827
+ `WHERE n.description CONTAINS '${unresolvedPrefix}' ` +
1828
+ 'RETURN n.id AS id, n.description AS description ' +
1829
+ 'UNION ALL ' +
1830
+ 'MATCH (n:Record) ' +
1831
+ `WHERE n.description CONTAINS '${unresolvedPrefix}' ` +
1832
+ 'RETURN n.id AS id, n.description AS description'));
1833
+ const springConfigConsumerDriftFiles = collectSpringConfigConsumerDriftFiles(pipelineResult.graph, persistedSpringConfigConsumers);
1834
+ for (const filePath of springConfigConsumerDriftFiles)
1835
+ effectiveWriteSet.add(filePath);
1836
+ if (springConfigConsumerDriftFiles.size > 0) {
1837
+ log(`Incremental: +${springConfigConsumerDriftFiles.size} file(s) added for ` +
1838
+ 'Spring config consumer property drift');
1839
+ }
1840
+ }
1815
1841
  // `frameworkAnnotations` is derived from cross-file JVM visibility, so
1816
1842
  // an unchanged Class row can change when a same-package declaration is
1817
1843
  // added or removed without producing an IMPORTS edge. Compare the fresh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.11-rc.27",
3
+ "version": "1.6.11-rc.29",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",