From c1ee62854a245e33a8430a6b60b3aacfb4a20a8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:32:45 +0000 Subject: [PATCH 01/17] chore(deps)(deps-dev): bump @babel/types in /gitnexus-web (#2750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@babel/types](https://github.com/babel/babel/tree/HEAD/packages/babel-types) from 8.0.0 to 8.0.4. - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v8.0.4/packages/babel-types) --- updated-dependencies: - dependency-name: "@babel/types" dependency-version: 8.0.4 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- gitnexus-web/package-lock.json | 10 +++++----- gitnexus-web/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 8d8167366..78ba73142 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -47,7 +47,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@babel/types": "^8.0.0", + "@babel/types": "^8.0.4", "@playwright/test": "^1.61.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -255,14 +255,14 @@ } }, "node_modules/@babel/types": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0.tgz", - "integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.0" + "@babel/helper-validator-identifier": "^8.0.4" }, "engines": { "node": "^22.18.0 || >=24.11.0" diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index b53955040..2c6923f33 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -57,7 +57,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@babel/types": "^8.0.0", + "@babel/types": "^8.0.4", "@playwright/test": "^1.61.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", From 5ed9617ff4e8ccd3da8f17cc9424816c43dd1e17 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:59:03 +0000 Subject: [PATCH 02/17] chore(deps)(deps): bump @langchain/core in /gitnexus-web (#2749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@langchain/core](https://github.com/langchain-ai/langchainjs) from 1.2.2 to 1.2.3. - [Release notes](https://github.com/langchain-ai/langchainjs/releases) - [Commits](https://github.com/langchain-ai/langchainjs/compare/@langchain/core@1.2.2...@langchain/core@1.2.3) --- updated-dependencies: - dependency-name: "@langchain/core" dependency-version: 1.2.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar Co-authored-by: Abhigyan Patwari <126312502+abhigyanpatwari@users.noreply.github.com> --- gitnexus-web/package-lock.json | 8 ++++---- gitnexus-web/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 78ba73142..3ab270e90 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@langchain/anthropic": "^1.5.1", - "@langchain/core": "^1.2.2", + "@langchain/core": "^1.2.3", "@langchain/google-genai": "^2.2.0", "@langchain/langgraph": "^1.4.8", "@langchain/ollama": "^1.3.0", @@ -1139,9 +1139,9 @@ } }, "node_modules/@langchain/core": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.2.tgz", - "integrity": "sha512-KfjEOT6sCg0vvItagfEtGpmrGoLMGfma4Affb5BGEqPmS2YR3AxW54pABSkhQlzCehTB+0BnLquAe1lGF4J9zQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.3.tgz", + "integrity": "sha512-F+L5SsciykwDl7eDxacnhDTcWe1IF6jetzfkvI5PPfq6ogWHO7xcjU90SGh/3lqbbS0tgun+qF01KIqxawrCsA==", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.0.2", diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 2c6923f33..9468d38b1 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@langchain/anthropic": "^1.5.1", - "@langchain/core": "^1.2.2", + "@langchain/core": "^1.2.3", "@langchain/google-genai": "^2.2.0", "@langchain/langgraph": "^1.4.8", "@langchain/ollama": "^1.3.0", From de84ad6297f6823b5000863e9b00cacdc869776b Mon Sep 17 00:00:00 2001 From: MyShining <249674729@qq.com> Date: Fri, 31 Jul 2026 17:33:42 +0800 Subject: [PATCH 03/17] feat(spring): index @Bean factories and @Resource injection (#2740) * feat(spring): index Bean factories and Resource injection * fix(spring): address Bean and Resource review findings * refactor(lbug): keep relation pair parsing in router * test(lbug): preserve schema exports in WAL mocks * test(cache): align schema bump pin --------- Co-authored-by: Shining Co-authored-by: Gergo Magyar --- .github/workflows/ci-tests.yml | 1 + ARCHITECTURE.md | 2 +- gitnexus-shared/src/graph/types.ts | 4 +- .../src/core/ingestion/di-extractors/index.ts | 13 + .../core/ingestion/di-extractors/spring.ts | 9 +- .../frameworks/spring/analysis-features.ts | 2 +- .../frameworks/spring/annotation-arguments.ts | 199 +++++++++ .../frameworks/spring/bean-factories.ts | 112 +++++ .../frameworks/spring/di-metadata.ts | 204 ++++++++- .../frameworks/spring/resource-injection.ts | 113 +++++ .../ingestion/languages/java/spring-di.ts | 38 +- .../ingestion/languages/kotlin/spring-di.ts | 63 ++- .../src/core/ingestion/pipeline-phases/di.ts | 171 +++++++- gitnexus/src/core/lbug/csv-generator.ts | 13 +- gitnexus/src/core/lbug/rel-pair-routing.ts | 29 +- gitnexus/src/core/lbug/schema.ts | 1 + gitnexus/src/core/run-analyze.ts | 63 +++ gitnexus/src/mcp/local/bean-metadata.ts | 27 +- gitnexus/src/mcp/local/local-backend.ts | 2 + gitnexus/src/mcp/tools.ts | 6 +- gitnexus/src/storage/parse-cache.ts | 29 +- gitnexus/src/storage/repo-manager.ts | 11 +- .../test/integration/csv-pipeline.test.ts | 11 +- .../test/integration/spring-bean-mcp.test.ts | 22 + .../spring-bean-metadata-roundtrip.test.ts | 84 ++++ .../spring-bean-resource-benchmark.test.ts | 174 ++++++++ .../spring-bean-resource-pipeline.test.ts | 396 ++++++++++++++++++ .../spring-conditionals-pipeline.test.ts | 11 +- gitnexus/test/unit/analysis-features.test.ts | 8 +- .../unit/call-summary-schema-version.test.ts | 10 +- .../unit/incremental-orchestration.test.ts | 59 +++ .../test/unit/incremental-parse-cache.test.ts | 14 +- gitnexus/test/unit/ingestion/di.test.ts | 112 +++++ .../test/unit/lbug-adapter-wal-schema.test.ts | 23 +- gitnexus/test/unit/rel-pair-routing.test.ts | 40 +- gitnexus/test/unit/schema.test.ts | 4 + .../unit/spring-annotation-arguments.test.ts | 161 +++++++ gitnexus/test/unit/spring-bean-schema.test.ts | 6 +- 38 files changed, 2140 insertions(+), 107 deletions(-) create mode 100644 gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts create mode 100644 gitnexus/src/core/ingestion/frameworks/spring/bean-factories.ts create mode 100644 gitnexus/src/core/ingestion/frameworks/spring/resource-injection.ts create mode 100644 gitnexus/test/integration/spring-bean-resource-benchmark.test.ts create mode 100644 gitnexus/test/integration/spring-bean-resource-pipeline.test.ts create mode 100644 gitnexus/test/unit/spring-annotation-arguments.test.ts diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index c9448184f..18ac7fd89 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -563,6 +563,7 @@ jobs: test/integration/cobol-pipeline-benchmark.test.ts test/integration/csharp-pipeline-benchmark.test.ts test/integration/instance-ownership-pipeline-benchmark.test.ts + test/integration/spring-bean-resource-benchmark.test.ts test/integration/rust-pipeline-benchmark.test.ts test/integration/php-pipeline-benchmark.test.ts test/integration/ruby-pipeline-benchmark.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 20d8b719d..5913b17b0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -103,7 +103,7 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm] | `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator | | `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution | | `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges | -| `di` | `di.ts` | `mro` | INJECTS edges (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) | +| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes or factory Methods to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) | | `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) | | `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges | diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index cb53f5981..cf6d7252b 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -133,8 +133,8 @@ export type RelationshipType = * shared DI phase uses type heritage, qualifier names, and preferred * provider markers to resolve it. Ambiguous single injection is represented * by multiple lower-confidence edges instead of a fabricated exact target. - * Source = the consumer Class node (the one owning the injection site). - * Target = a concrete provider Class node. + * Source = the consumer Class, or a factory Method for its parameters. + * Target = a concrete provider Class or synthetic provider CodeElement. * Framework specifics live in the `reason` payload (e.g. * `Spring DI: @Autowired List`), not in this type contract. * Lets Cypher queries trace which beans the container injects into a given diff --git a/gitnexus/src/core/ingestion/di-extractors/index.ts b/gitnexus/src/core/ingestion/di-extractors/index.ts index 28582e635..5b679feb1 100644 --- a/gitnexus/src/core/ingestion/di-extractors/index.ts +++ b/gitnexus/src/core/ingestion/di-extractors/index.ts @@ -30,7 +30,13 @@ export interface DiInjectionMatch { namedSelection?: { name: string; reason: string; + /** Name-first frameworks may fall back to type only for implicit/default + * names. Explicit names remain strict. */ + fallbackToType?: boolean; }; + /** Most injection edges originate at the owning Class. Factory-method + * parameters preserve the Method as the semantic source. */ + edgeSource?: 'owner-class' | 'site'; /** Human-readable edge reason. Framework specifics (names, idioms, * collection wrapper, gating annotation) live in this payload so the * shared `di` phase stays framework-neutral. */ @@ -41,6 +47,13 @@ export interface DiInjectionMatch { export interface DiProviderMatch { /** Provider names and aliases that can satisfy a named injection. */ names: readonly string[]; + /** Optional type directly provided by a declaration node, such as a + * framework factory method whose node is not itself a Class. */ + providedTypeName?: string; + /** Graph node that declares this provider. The shared phase excludes a + * provider from injection into its own declaration site without knowing the + * framework-specific declaration model. */ + declaredByNodeId?: string; /** Present when the framework marks this as its preferred candidate. The * value is appended to the emitted edge reason when it disambiguates. */ preferenceReason?: string; diff --git a/gitnexus/src/core/ingestion/di-extractors/spring.ts b/gitnexus/src/core/ingestion/di-extractors/spring.ts index 44c5b1aca..ea5c8e727 100644 --- a/gitnexus/src/core/ingestion/di-extractors/spring.ts +++ b/gitnexus/src/core/ingestion/di-extractors/spring.ts @@ -262,11 +262,16 @@ function isInjectionMatch(value: unknown): value is DiInjectionMatch { typeof match.targetTypeName === 'string' && (match.cardinality === 'single' || match.cardinality === 'collection') && typeof match.reason === 'string' && + (match.edgeSource === undefined || + match.edgeSource === 'owner-class' || + match.edgeSource === 'site') && (namedSelection === undefined || (typeof namedSelection === 'object' && namedSelection !== null && typeof namedSelection.name === 'string' && - typeof namedSelection.reason === 'string')) + typeof namedSelection.reason === 'string' && + (namedSelection.fallbackToType === undefined || + typeof namedSelection.fallbackToType === 'boolean'))) ); } @@ -276,6 +281,8 @@ function isProviderMatch(value: unknown): value is DiProviderMatch { return ( Array.isArray(provider.names) && provider.names.every((name) => typeof name === 'string') && + (provider.providedTypeName === undefined || typeof provider.providedTypeName === 'string') && + (provider.declaredByNodeId === undefined || typeof provider.declaredByNodeId === 'string') && (provider.preferenceReason === undefined || typeof provider.preferenceReason === 'string') ); } diff --git a/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts b/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts index 83302e5f9..58edeca7d 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts @@ -4,7 +4,7 @@ import { isSpringBeanCandidateSourceFile } from './bean-catalog.js'; /** Durable completeness contract for Java/Kotlin Spring Bean evidence. */ export const SPRING_BEAN_INVENTORY_FEATURE: AnalysisFeatureDescriptor = { id: 'spring.bean-inventory', - version: 1, + version: 2, appliesTo: (filePaths) => filePaths.some(isSpringBeanCandidateSourceFile), }; diff --git a/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts b/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts new file mode 100644 index 000000000..eb05e44f2 --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts @@ -0,0 +1,199 @@ +export interface SpringAnnotationArgument { + readonly name?: string; + readonly value: string; +} + +function splitTopLevel(value: string, separator: string): string[] | null { + const parts: string[] = []; + const stack: string[] = []; + let quote: '"' | "'" | null = null; + let escaped = false; + let start = 0; + + // Angle brackets are deliberate: Java/Kotlin generic expressions may contain + // commas that are not annotation-argument separators. Ambiguous comparison + // expressions remain unsupported and fail closed instead of being mis-split. + const closing = new Map([ + ['(', ')'], + ['[', ']'], + ['{', '}'], + ['<', '>'], + ]); + + for (let index = 0; index < value.length; index++) { + const char = value[index]; + if (quote !== null) { + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + const closingChar = closing.get(char); + if (closingChar !== undefined) { + stack.push(closingChar); + continue; + } + if (stack[stack.length - 1] === char) { + stack.pop(); + continue; + } + if (char === separator && stack.length === 0) { + parts.push(value.slice(start, index).trim()); + start = index + 1; + } + } + if (quote !== null || stack.length > 0) return null; + parts.push(value.slice(start).trim()); + return parts; +} + +function topLevelAssignment(argument: string): number { + const stack: string[] = []; + let quote: '"' | "'" | null = null; + let escaped = false; + // Keep the same deliberate generic-delimiter policy as splitTopLevel. + const closing = new Map([ + ['(', ')'], + ['[', ']'], + ['{', '}'], + ['<', '>'], + ]); + + for (let index = 0; index < argument.length; index++) { + const char = argument[index]; + if (quote !== null) { + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + const closingChar = closing.get(char); + if (closingChar !== undefined) { + stack.push(closingChar); + continue; + } + if (stack[stack.length - 1] === char) { + stack.pop(); + continue; + } + if (char === '=' && stack.length === 0) return index; + } + return -1; +} + +/** Parse Java/Kotlin annotation arguments without evaluating constants. */ +export function parseSpringAnnotationArguments( + annotationText: string, +): readonly SpringAnnotationArgument[] | null { + const open = annotationText.indexOf('('); + if (open === -1) return []; + const close = annotationText.lastIndexOf(')'); + if (close < open || annotationText.slice(close + 1).trim().length > 0) return null; + const body = annotationText.slice(open + 1, close).trim(); + if (body.length === 0) return []; + const rawArguments = splitTopLevel(body, ','); + if (rawArguments === null || rawArguments.some((argument) => argument.length === 0)) return null; + + const parsed: SpringAnnotationArgument[] = []; + for (const raw of rawArguments) { + const assignment = topLevelAssignment(raw); + if (assignment === -1) { + parsed.push({ value: raw }); + continue; + } + const name = raw.slice(0, assignment).trim(); + const value = raw.slice(assignment + 1).trim(); + if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) || value.length === 0) return null; + parsed.push({ name, value }); + } + return parsed; +} + +export function parseStaticStringLiteral(value: string): string | null { + const trimmed = value.trim(); + const match = /^"((?:\\.|[^"\\])*)"$/s.exec(trimmed); + if (match === null) return null; + if (/(^|[^\\])\$(?:\{|[A-Za-z_])/.test(match[1])) return null; + try { + return JSON.parse(`"${match[1]}"`) as string; + } catch { + return null; + } +} + +/** Parse one string literal or a Java/Kotlin annotation string array. */ +export function parseStaticStringValues(value: string): readonly string[] | null { + const trimmed = value.trim(); + const pair = + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')); + const body = pair ? trimmed.slice(1, -1).trim() : trimmed; + if (body.length === 0) return []; + const parts = pair ? splitTopLevel(body, ',') : [body]; + if (parts === null) return null; + const strings: string[] = []; + for (const part of parts) { + const parsed = parseStaticStringLiteral(part); + if (parsed === null) return null; + strings.push(parsed); + } + return strings; +} + +/** Parse `Foo.class` or `Foo::class`; an Object/Any default returns an empty string. */ +export function parseStaticClassLiteral(value: string): string | null { + const compact = value.replace(/\s+/g, ''); + const match = /^([A-Za-z_$][A-Za-z0-9_$.]*)(?:\.class|::class)$/.exec(compact); + if (match === null) return null; + const typeName = match[1]; + if ( + typeName === 'Object' || + typeName === 'java.lang.Object' || + typeName === 'Any' || + typeName === 'kotlin.Any' + ) { + return ''; + } + return typeName; +} + +/** Normalize a declared bean type to the graph's simple/qualified raw type key. */ +export function normalizeSpringBeanType(rawType: string): string | null { + // Projection keywords are tokens only when separated from the following + // type. Strip them before whitespace compaction so valid lowercase type + // aliases such as `outputStream` and `inside.Type` remain intact. + let normalized = rawType + .replace(/^(?:out|in)\s+/, '') + .replace(/([<,])\s*(?:out|in)\s+/g, '$1') + .replace(/\s+/g, '') + .replace( + /^kotlin\.collections\.Mutable(List|Set|Collection|Map)(?=<|$)/, + 'kotlin.collections.$1', + ) + .replace(/^Mutable(List|Set|Collection|Map)(?=<|$)/, '$1') + .replace(/\?$/, ''); + const generic = normalized.indexOf('<'); + if (generic !== -1) { + if (!normalized.endsWith('>')) return null; + normalized = normalized.slice(0, generic); + } + if ( + normalized === 'void' || + normalized === 'Void' || + normalized === 'Unit' || + normalized === 'kotlin.Unit' + ) { + return null; + } + return /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(normalized) + ? normalized + : null; +} diff --git a/gitnexus/src/core/ingestion/frameworks/spring/bean-factories.ts b/gitnexus/src/core/ingestion/frameworks/spring/bean-factories.ts new file mode 100644 index 000000000..5e2b85f3a --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/bean-factories.ts @@ -0,0 +1,112 @@ +import type { GraphRelationship, ScopeId } from 'gitnexus-shared'; +import { parseSpringAnnotationArguments, parseStaticStringValues } from './annotation-arguments.js'; +import type { SpringDiAnnotationFact, SpringDiDependencyFact } from './di-metadata.js'; + +export const SPRING_BEAN_ANNOTATION = 'org.springframework.context.annotation.Bean'; +export const SPRING_BEAN_DECLARATION_ID_PREFIX = 'CodeElement:spring-bean:'; +export const SPRING_BEAN_FACTORY_REASON_PREFIX = 'spring-bean-factory:'; + +export interface SpringBeanFactoryMethodFact< + Annotation extends SpringDiAnnotationFact = SpringDiAnnotationFact, +> { + readonly callableScopeId: ScopeId; + readonly methodName: string; + readonly returnType?: string; + readonly annotations: readonly Annotation[]; + readonly dependencies: readonly SpringDiDependencyFact[]; +} + +export interface SpringBeanFactoryDeclaration { + readonly names: readonly string[]; + readonly namesKnown: boolean; + readonly providedType?: string; +} + +export interface SpringBeanFactoryMetadata { + readonly framework: 'spring'; + readonly role: 'factory-method'; + readonly annotation: typeof SPRING_BEAN_ANNOTATION; + readonly names: readonly string[]; + readonly providedType?: string; +} + +function simpleName(name: string): string { + const separator = name.lastIndexOf('.'); + return separator === -1 ? name : name.slice(separator + 1); +} + +export function hasSpringBeanFactorySyntax( + annotations: readonly Pick[], +): boolean { + return annotations.some((annotation) => simpleName(annotation.name) === 'Bean'); +} + +/** Resolve statically readable Bean names; dynamic constants remain explicitly unknown. */ +export function springBeanNames( + annotationText: string, + defaultMethodName: string, +): { readonly names: readonly string[]; readonly namesKnown: boolean } { + const argumentsList = parseSpringAnnotationArguments(annotationText); + if (argumentsList === null) return { names: [], namesKnown: false }; + const nameArguments = argumentsList.filter( + (argument) => + argument.name === undefined || argument.name === 'name' || argument.name === 'value', + ); + if (nameArguments.length === 0) return { names: [defaultMethodName], namesKnown: true }; + + const names = new Set(); + for (const argument of nameArguments) { + const values = parseStaticStringValues(argument.value); + if (values === null) return { names: [], namesKnown: false }; + for (const value of values) { + if (value.length > 0) names.add(value); + } + } + return { + names: names.size === 0 ? [defaultMethodName] : [...names], + namesKnown: true, + }; +} + +export function encodeSpringBeanFactoryReason(declaration: SpringBeanFactoryDeclaration): string { + return `${SPRING_BEAN_FACTORY_REASON_PREFIX}${JSON.stringify(declaration)}`; +} + +export function decodeSpringBeanFactoryReason( + reason: unknown, +): SpringBeanFactoryMetadata | undefined { + if (typeof reason !== 'string' || !reason.startsWith(SPRING_BEAN_FACTORY_REASON_PREFIX)) { + return undefined; + } + try { + const value = JSON.parse( + reason.slice(SPRING_BEAN_FACTORY_REASON_PREFIX.length), + ) as Partial; + if ( + !Array.isArray(value.names) || + !value.names.every((name) => typeof name === 'string') || + typeof value.namesKnown !== 'boolean' || + (value.providedType !== undefined && typeof value.providedType !== 'string') + ) { + return undefined; + } + return { + framework: 'spring', + role: 'factory-method', + annotation: SPRING_BEAN_ANNOTATION, + names: value.names, + ...(value.providedType === undefined ? {} : { providedType: value.providedType }), + }; + } catch { + return undefined; + } +} + +export function isSpringBeanFactoryDeclaration( + relationship: Pick, +): boolean { + return ( + relationship.type === 'DECLARES' && + relationship.reason.startsWith(SPRING_BEAN_FACTORY_REASON_PREFIX) + ); +} diff --git a/gitnexus/src/core/ingestion/frameworks/spring/di-metadata.ts b/gitnexus/src/core/ingestion/frameworks/spring/di-metadata.ts index 73544548e..80a1694f3 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/di-metadata.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/di-metadata.ts @@ -1,4 +1,5 @@ -import type { ParsedFile, ScopeId } from 'gitnexus-shared'; +import type { GraphNode, ParsedFile, ScopeId } from 'gitnexus-shared'; +import { generateId } from '../../../../lib/utils.js'; import type { KnowledgeGraph } from '../../../graph/types.js'; import type { DiInjectionMatch, DiProviderMatch } from '../../di-extractors/index.js'; import { @@ -8,10 +9,30 @@ import { SPRING_DI_PROVIDER_PROPERTY, } from '../../di-extractors/spring.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; -import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; +import { + resolveCallerGraphId, + resolveDefGraphId, +} from '../../scope-resolution/graph-bridge/ids.js'; import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; import { createSpringAnnotationNameResolver } from './bean-candidates.js'; import { SPRING_BEAN_STEREOTYPES } from './bean-catalog.js'; +import { + encodeSpringBeanFactoryReason, + SPRING_BEAN_ANNOTATION, + SPRING_BEAN_DECLARATION_ID_PREFIX, + springBeanNames, + type SpringBeanFactoryMethodFact, +} from './bean-factories.js'; +import { + normalizeSpringBeanType, + parseSpringAnnotationArguments, + parseStaticStringValues, +} from './annotation-arguments.js'; +import { + SPRING_RESOURCE_ANNOTATIONS, + springResourceDefaultName, + springResourceInjectionMatch, +} from './resource-injection.js'; export interface SpringDiAnnotationFact { readonly name: string; @@ -42,6 +63,7 @@ export interface SpringDiClassFact< readonly classScopeId: ScopeId; readonly classAnnotations: readonly Annotation[]; readonly injectionSites: readonly SpringDiInjectionSiteFact[]; + readonly beanFactoryMethods?: readonly SpringBeanFactoryMethodFact[]; } const INJECTION_ANNOTATIONS = new Set([ @@ -63,6 +85,8 @@ const RESOLVABLE_DI_ANNOTATIONS = new Set([ ...INJECTION_ANNOTATIONS, ...QUALIFIER_ANNOTATIONS, ...PRIMARY_ANNOTATIONS, + ...SPRING_RESOURCE_ANNOTATIONS, + SPRING_BEAN_ANNOTATION, ]); const CAPTURE_RELEVANT_ANNOTATIONS = new Set([ @@ -71,6 +95,7 @@ const CAPTURE_RELEVANT_ANNOTATIONS = new Set([ 'Qualifier', 'Named', 'Primary', + 'Resource', 'Component', 'Service', 'Repository', @@ -104,16 +129,12 @@ export function hasSpringStereotypeSyntax(annotations: readonly SpringDiAnnotati } function staticStringArgument(annotationText: string): string | undefined { - const args = annotationText.match(/\((.*)\)$/s)?.[1]?.trim(); - if (args === undefined) return undefined; - const value = args.replace(/^value\s*=\s*/, '').trim(); - const literal = value.match(/^"((?:\\.|[^"\\])*)"$/s); - if (literal === null) return undefined; - try { - return JSON.parse(`"${literal[1]}"`) as string; - } catch { - return undefined; - } + const argumentsList = parseSpringAnnotationArguments(annotationText); + if (argumentsList === null || argumentsList.length !== 1) return undefined; + const argument = argumentsList[0]; + if (argument.name !== undefined && argument.name !== 'value') return undefined; + const values = parseStaticStringValues(argument.value); + return values !== null && values.length === 1 ? values[0] : undefined; } function defaultBeanName(className: string): string { @@ -146,6 +167,7 @@ export interface SpringDiMetadataAdapter< annotation: Annotation, site: SpringDiInjectionSiteFact, ): boolean; + isFactoryQualifierAnnotationApplicable?(annotation: Annotation): boolean; } /** @@ -231,18 +253,174 @@ export function createSpringDiMetadataAttacher< classNode.properties[SPRING_DI_PROVIDER_PROPERTY] = provider; } + // Factory methods have no legacy member-collection fallback to suppress, + // so they do not participate in semanticallyOwnedMemberNames below. + for (const factory of fact.beanFactoryMethods ?? []) { + const factoryScope = indexes.scopeTree.getScope(factory.callableScopeId); + if (factoryScope === undefined) continue; + const beanAnnotation = factory.annotations.find( + (annotation) => resolveFact(annotation, factoryScope.parent) === SPRING_BEAN_ANNOTATION, + ); + if (beanAnnotation === undefined) continue; + const methodId = resolveCallerGraphId(factory.callableScopeId, indexes, nodeLookup, { + startLine: factoryScope.range.startLine, + startCol: factoryScope.range.startCol, + }); + if (methodId === undefined) continue; + const methodNode = graph.getNode(methodId); + if (methodNode === undefined || methodNode.label !== 'Method') continue; + + const names = springBeanNames(beanAnnotation.text, factory.methodName); + const providedType = + factory.returnType === undefined + ? undefined + : normalizeSpringBeanType(factory.returnType); + const declaration = { + names: names.names, + namesKnown: names.namesKnown, + ...(providedType === null || providedType === undefined ? {} : { providedType }), + }; + const reason = encodeSpringBeanFactoryReason(declaration); + const beanId = `${SPRING_BEAN_DECLARATION_ID_PREFIX}${methodNode.id}`; + const beanNode: GraphNode = { + id: beanId, + label: 'CodeElement', + properties: { + name: names.names[0] ?? factory.methodName, + filePath: methodNode.properties.filePath, + startLine: methodNode.properties.startLine, + endLine: methodNode.properties.endLine, + language: methodNode.properties.language, + description: + `Spring @Bean factory declaration from ${factory.methodName}` + + (providedType === null || providedType === undefined + ? '' + : ` providing ${providedType}`), + }, + }; + beanNode.properties[SPRING_DI_PROVIDER_PROPERTY] = { + names: names.names, + declaredByNodeId: methodNode.id, + ...(providedType === null || providedType === undefined + ? {} + : { providedTypeName: providedType }), + } satisfies DiProviderMatch; + graph.addNode(beanNode); + const fileId = generateId('File', parsed.filePath); + if (graph.getNode(fileId) !== undefined) { + graph.addRelationship({ + id: generateId('DEFINES', `${fileId}->${beanId}`), + sourceId: fileId, + targetId: beanId, + type: 'DEFINES', + confidence: 1, + reason: 'spring-bean:declaration', + }); + } + graph.addRelationship({ + id: generateId('DECLARES', `${methodNode.id}->${beanId}`), + sourceId: methodNode.id, + targetId: beanId, + type: 'DECLARES', + confidence: names.namesKnown ? 1 : 0.8, + reason, + }); + + const factoryMatches: DiInjectionMatch[] = []; + for (const dependency of factory.dependencies) { + const parsedType = adapter.parseInjectionType(dependency.rawType); + if (parsedType === null) continue; + let qualifierAnnotation: Annotation | undefined; + for (const annotation of dependency.annotations) { + if (adapter.isFactoryQualifierAnnotationApplicable?.(annotation) === false) continue; + const resolved = resolveFact(annotation, factoryScope.id); + if (resolved !== undefined && QUALIFIER_ANNOTATIONS.has(resolved)) { + qualifierAnnotation = annotation; + break; + } + } + const qualifier = + qualifierAnnotation === undefined + ? undefined + : staticStringArgument(qualifierAnnotation.text); + if (qualifierAnnotation !== undefined && qualifier === undefined) continue; + factoryMatches.push({ + targetTypeName: parsedType.targetTypeName, + cardinality: parsedType.cardinality, + edgeSource: 'site', + ...(qualifier === undefined + ? {} + : { + namedSelection: { + name: qualifier, + reason: `qualifier "${qualifier}"`, + }, + }), + reason: + `Spring DI: @Bean method ${factory.methodName} parameter ${dependency.name}: ` + + parsedType.displayType, + }); + } + if (factoryMatches.length > 0) { + const existing = methodNode.properties[SPRING_DI_INJECTION_SITES_PROPERTY]; + methodNode.properties[SPRING_DI_INJECTION_SITES_PROPERTY] = [ + ...(Array.isArray(existing) ? existing : []), + ...factoryMatches, + ]; + } + } + const matches: DiInjectionMatch[] = []; const semanticallyOwnedMemberNames = new Set(); for (const site of fact.injectionSites) { let injectionAnnotation: Annotation | undefined; + let resourceAnnotation: Annotation | undefined; for (const annotation of site.annotations) { if (adapter.isInjectionAnnotationApplicable?.(annotation, site) === false) continue; const resolved = resolveFact(annotation, classScope.id); if (resolved !== undefined && INJECTION_ANNOTATIONS.has(resolved)) { injectionAnnotation = annotation; - break; + } else if (resolved !== undefined && SPRING_RESOURCE_ANNOTATIONS.has(resolved)) { + resourceAnnotation = annotation; } } + if (injectionAnnotation !== undefined && resourceAnnotation !== undefined) { + // The annotation pair is semantically ambiguous, so emit no edge. + // It still owns a captured member: otherwise the legacy collection + // matcher sees the same Property and fans out behind this fail-closed + // decision. + if (site.kind === adapter.capturedMemberKind) { + semanticallyOwnedMemberNames.add(site.memberName); + } + continue; + } + + if (resourceAnnotation !== undefined) { + if (site.kind === adapter.capturedMemberKind) { + semanticallyOwnedMemberNames.add(site.memberName); + } + if (site.dependencies.length !== 1 || site.kind === 'constructor') continue; + const defaultName = springResourceDefaultName( + site.kind, + site.memberName, + site.dependencies.length, + ); + if (defaultName === null) continue; + const dependency = site.dependencies[0]; + const location = + site.kind === adapter.capturedMemberKind + ? site.memberName + : `${site.memberName} parameter ${dependency.name}`; + const resourceMatch = springResourceInjectionMatch( + resourceAnnotation.text, + defaultName, + dependency.rawType, + location, + ); + if (resourceMatch !== null) matches.push(resourceMatch); + continue; + } + if (injectionAnnotation === undefined) { if (!site.implicitConstructor || frameworkAnnotations.length === 0) continue; } else if (site.kind === adapter.capturedMemberKind) { diff --git a/gitnexus/src/core/ingestion/frameworks/spring/resource-injection.ts b/gitnexus/src/core/ingestion/frameworks/spring/resource-injection.ts new file mode 100644 index 000000000..99a563d7d --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/resource-injection.ts @@ -0,0 +1,113 @@ +import type { DiInjectionMatch } from '../../di-extractors/index.js'; +import { + normalizeSpringBeanType, + parseSpringAnnotationArguments, + parseStaticClassLiteral, + parseStaticStringValues, +} from './annotation-arguments.js'; + +export const SPRING_RESOURCE_ANNOTATIONS = new Set([ + 'jakarta.annotation.Resource', + 'javax.annotation.Resource', +]); + +function singleNamedArgument( + annotationText: string, + name: string, +): { readonly present: boolean; readonly value?: string } | null { + const argumentsList = parseSpringAnnotationArguments(annotationText); + if (argumentsList === null || argumentsList.some((argument) => argument.name === undefined)) { + return null; + } + const matches = argumentsList.filter((argument) => argument.name === name); + if (matches.length > 1) return null; + return matches.length === 0 ? { present: false } : { present: true, value: matches[0].value }; +} + +function staticSingleString(value: string | undefined): string | null { + if (value === undefined) return null; + const values = parseStaticStringValues(value); + return values !== null && values.length === 1 ? values[0] : null; +} + +function javaBeansDecapitalize(value: string): string { + if (value.length === 0) return value; + if ( + value.length > 1 && + value[0] !== value[0].toLowerCase() && + value[1] !== value[1].toLowerCase() + ) { + return value; + } + return value[0].toLowerCase() + value.slice(1); +} + +export function springResourceDefaultName( + siteKind: string, + memberName: string, + dependencyCount: number, +): string | null { + if (siteKind !== 'method') return memberName; + if (dependencyCount !== 1 || !/^set[A-Z_$]/.test(memberName) || memberName.length <= 3) { + return null; + } + return javaBeansDecapitalize(memberName.slice(3)); +} + +/** Build the conservative name-first Resource match shared by Java and Kotlin. */ +export function springResourceInjectionMatch( + annotationText: string, + defaultName: string, + rawDeclaredType: string, + location: string, +): DiInjectionMatch | null { + const nameArgument = singleNamedArgument(annotationText, 'name'); + const typeArgument = singleNamedArgument(annotationText, 'type'); + const lookupArgument = singleNamedArgument(annotationText, 'lookup'); + const mappedNameArgument = singleNamedArgument(annotationText, 'mappedName'); + if ( + nameArgument === null || + typeArgument === null || + lookupArgument === null || + mappedNameArgument === null + ) { + return null; + } + + for (const runtimeArgument of [lookupArgument, mappedNameArgument]) { + if (!runtimeArgument.present) continue; + const value = staticSingleString(runtimeArgument.value); + if (value === null || value.length > 0) return null; + } + + const declaredType = normalizeSpringBeanType(rawDeclaredType); + if (declaredType === null) return null; + let targetTypeName = declaredType; + if (typeArgument.present) { + const override = parseStaticClassLiteral(typeArgument.value ?? ''); + if (override === null) return null; + if (override.length > 0) targetTypeName = override; + } + + let selectedName = defaultName; + let explicitName = false; + if (nameArgument.present) { + const parsedName = staticSingleString(nameArgument.value); + if (parsedName === null) return null; + if (parsedName.length > 0) { + selectedName = parsedName; + explicitName = true; + } + } + + return { + targetTypeName, + cardinality: 'single', + namedSelection: { + name: selectedName, + reason: `${explicitName ? 'resource name' : 'default resource name'} "${selectedName}"`, + ...(!explicitName && !rawDeclaredType.includes('<') ? { fallbackToType: true } : {}), + }, + reason: `Spring DI: @Resource ${location}: ${targetTypeName}`, + }; +} diff --git a/gitnexus/src/core/ingestion/languages/java/spring-di.ts b/gitnexus/src/core/ingestion/languages/java/spring-di.ts index ee6f55783..c6dcbe261 100644 --- a/gitnexus/src/core/ingestion/languages/java/spring-di.ts +++ b/gitnexus/src/core/ingestion/languages/java/spring-di.ts @@ -8,6 +8,10 @@ import { type SpringDiDependencyFact, type SpringDiInjectionSiteFact, } from '../../frameworks/spring/di-metadata.js'; +import { + hasSpringBeanFactorySyntax, + type SpringBeanFactoryMethodFact, +} from '../../frameworks/spring/bean-factories.js'; import { parseSpringInjectionType } from '../../di-extractors/spring.js'; import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js'; @@ -30,6 +34,7 @@ export type JavaSpringDiClassFact = SpringDiClassFact< JavaAnnotationSyntaxFact, JavaSpringInjectionSiteKind >; +type JavaSpringBeanFactoryMethodFact = SpringBeanFactoryMethodFact; export function javaSpringAnnotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] { const facts: JavaAnnotationSyntaxFact[] = []; @@ -81,6 +86,7 @@ export function captureJavaSpringDiClassFact( if (body === null) return null; const classAnnotations = javaSpringAnnotationFacts(classNode); const injectionSites: JavaSpringInjectionSiteFact[] = []; + const beanFactoryMethods: JavaSpringBeanFactoryMethodFact[] = []; const constructors = body.namedChildren.filter( (child) => child.type === 'constructor_declaration', @@ -127,10 +133,31 @@ export function captureJavaSpringDiClassFact( } } else if (member.type === 'method_declaration') { const annotations = javaSpringAnnotationFacts(member); + const memberName = member.childForFieldName('name')?.text.trim() ?? ''; + const beanFactory = hasSpringBeanFactorySyntax(annotations); + if (beanFactory) { + const callableCapture = nodeToCapture('@spring-bean.factory', member); + const returnType = member.childForFieldName('type')?.text.trim(); + beanFactoryMethods.push({ + callableScopeId: makeScopeId({ + filePath, + range: callableCapture.range, + kind: 'Function', + }), + methodName: memberName, + ...(returnType === undefined ? {} : { returnType }), + annotations, + dependencies: dependenciesOf(member), + }); + } + // @Bean parameters are already represented on the factory Method. Do not + // also attach them to the owning configuration Class when the method has + // an otherwise relevant annotation such as @Autowired or @Qualifier. + if (beanFactory) continue; if (!hasSpringDiRelevantAnnotation(annotations)) continue; injectionSites.push({ kind: 'method', - memberName: member.childForFieldName('name')?.text.trim() ?? '', + memberName, implicitConstructor: false, annotations, dependencies: dependenciesOf(member), @@ -138,12 +165,19 @@ export function captureJavaSpringDiClassFact( } } - if (injectionSites.length === 0 && !hasSpringDiRelevantAnnotation(classAnnotations)) return null; + if ( + injectionSites.length === 0 && + beanFactoryMethods.length === 0 && + !hasSpringDiRelevantAnnotation(classAnnotations) + ) { + return null; + } const classCapture = nodeToCapture('@spring-di.class', classNode); return { classScopeId: makeScopeId({ filePath, range: classCapture.range, kind: 'Class' }), classAnnotations, injectionSites, + ...(beanFactoryMethods.length === 0 ? {} : { beanFactoryMethods }), }; } diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts index a4e7e0727..acb38360e 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts @@ -9,6 +9,10 @@ import { type SpringDiDependencyFact, type SpringDiInjectionSiteFact, } from '../../frameworks/spring/di-metadata.js'; +import { + hasSpringBeanFactorySyntax, + type SpringBeanFactoryMethodFact, +} from '../../frameworks/spring/bean-factories.js'; import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; import { getKotlinSpringDiFacts } from './capture-side-channel.js'; import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js'; @@ -31,6 +35,7 @@ export type KotlinSpringDiClassFact = SpringDiClassFact< KotlinAnnotationSyntaxFact, KotlinSpringInjectionSiteKind >; +type KotlinSpringBeanFactoryMethodFact = SpringBeanFactoryMethodFact; const KOTLIN_TYPE_NODES = new Set(['user_type', 'nullable_type', 'function_type']); @@ -86,6 +91,24 @@ function directTypeNode(node: SyntaxNode): SyntaxNode | undefined { return node.namedChildren.find((child) => KOTLIN_TYPE_NODES.has(child.type)); } +function kotlinBeanFactoryReturnType(functionNode: SyntaxNode): string | undefined { + const parametersIndex = functionNode.namedChildren.findIndex( + (child) => child.type === 'function_value_parameters', + ); + if (parametersIndex === -1) return undefined; + const afterParameters = functionNode.namedChildren.slice(parametersIndex + 1); + const explicit = afterParameters.find((child) => KOTLIN_TYPE_NODES.has(child.type)); + if (explicit !== undefined) return explicit.text.trim(); + + const body = afterParameters.find((child) => !KOTLIN_TYPE_NODES.has(child.type)); + if (body === undefined) return undefined; + const call = + body.type === 'call_expression' ? body : firstDescendantOfType(body, 'call_expression'); + const callee = call?.namedChildren.find((child) => child.type === 'simple_identifier'); + const inferred = callee?.text.trim(); + return inferred !== undefined && /^[A-Z_$]/.test(inferred) ? inferred : undefined; +} + function parameterDependency( parameter: SyntaxNode, precedingAnnotations: readonly KotlinAnnotationSyntaxFact[] = [], @@ -166,6 +189,7 @@ export function captureKotlinSpringDiClassFact( if (!isKotlinBeanCandidateClass(classNode)) return null; const classAnnotations = kotlinSpringAnnotationFacts(classNode); const injectionSites: KotlinSpringInjectionSiteFact[] = []; + const beanFactoryMethods: KotlinSpringBeanFactoryMethodFact[] = []; const body = classNode.namedChildren.find((child) => child.type === 'class_body'); const primaryConstructor = classNode.namedChildren.find( (child) => child.type === 'primary_constructor', @@ -224,10 +248,33 @@ export function captureKotlinSpringDiClassFact( }); } else if (member.type === 'function_declaration') { const annotations = kotlinSpringAnnotationFacts(member); - if (!hasSpringDiRelevantAnnotation(annotations)) continue; const name = member.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim() ?? ''; + const factoryAnnotations = annotations.filter( + (annotation) => annotation.useSiteTarget === undefined, + ); + const beanFactory = hasSpringBeanFactorySyntax(factoryAnnotations); + if (beanFactory) { + const callableCapture = nodeToCapture('@spring-bean.factory', member); + const returnType = kotlinBeanFactoryReturnType(member); + beanFactoryMethods.push({ + callableScopeId: makeScopeId({ + filePath, + range: callableCapture.range, + kind: 'Function', + }), + methodName: name, + ...(returnType === undefined ? {} : { returnType }), + annotations: factoryAnnotations, + dependencies: functionDependencies(member), + }); + } + // @Bean parameters are already represented on the factory Method. Do not + // also attach them to the owning configuration Class when the method has + // an otherwise relevant annotation such as @Autowired or @Qualifier. + if (beanFactory) continue; + if (!hasSpringDiRelevantAnnotation(annotations)) continue; injectionSites.push({ kind: 'method', memberName: name, @@ -239,12 +286,19 @@ export function captureKotlinSpringDiClassFact( } } - if (injectionSites.length === 0 && !hasSpringDiRelevantAnnotation(classAnnotations)) return null; + if ( + injectionSites.length === 0 && + beanFactoryMethods.length === 0 && + !hasSpringDiRelevantAnnotation(classAnnotations) + ) { + return null; + } const classCapture = nodeToCapture('@spring-di.class', classNode); return { classScopeId: makeScopeId({ filePath, range: classCapture.range, kind: 'Class' }), classAnnotations, injectionSites, + ...(beanFactoryMethods.length === 0 ? {} : { beanFactoryMethods }), }; } @@ -275,6 +329,10 @@ function isApplicableQualifierAnnotation( return annotation.useSiteTarget === 'param'; } +function isApplicableFactoryQualifierAnnotation(annotation: KotlinAnnotationSyntaxFact): boolean { + return annotation.useSiteTarget === undefined || annotation.useSiteTarget === 'param'; +} + function parseKotlinSpringInjectionType(rawType: string) { // Kotlin nullable suffixes, type projections, and mutable collection aliases // do not change the JVM bean type selected by Spring. Normalize only those @@ -298,4 +356,5 @@ export const attachKotlinSpringDiMetadata = createSpringDiMetadataAttacher< capturedMemberKind: 'property', isInjectionAnnotationApplicable: isApplicableInjectionAnnotation, isQualifierAnnotationApplicable: isApplicableQualifierAnnotation, + isFactoryQualifierAnnotationApplicable: isApplicableFactoryQualifierAnnotation, }); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/di.ts b/gitnexus/src/core/ingestion/pipeline-phases/di.ts index 1a8f789fc..12ee5cb3e 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/di.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/di.ts @@ -3,7 +3,7 @@ * * Framework-neutral dependency-injection resolution. Per-language resolvers * identify injection sites and provider metadata; this phase performs only - * graph-level type/heritage resolution and emits Class -> Class INJECTS edges. + * graph-level type/heritage resolution and emits owner/site -> provider INJECTS edges. * * @deps mro * @reads graph (Class/Interface/member nodes and heritage/ownership edges) @@ -50,6 +50,24 @@ interface PendingEdge { reason: string; } +type ProvidedTypesByLanguage = Map>>; + +function addProvidedType( + index: ProvidedTypesByLanguage, + language: string, + typeName: string, + providerId: string, +): void { + const byName = index.get(language) ?? new Map>(); + const names = new Set([typeName, typeName.slice(typeName.lastIndexOf('.') + 1)]); + for (const name of names) { + const providers = byName.get(name) ?? new Set(); + providers.add(providerId); + byName.set(name, providers); + } + index.set(language, byName); +} + function emptyNameIndex(): NameIndex { return { byQualifiedName: new Map(), bySimpleName: new Map() }; } @@ -98,6 +116,10 @@ export const diPhase: PipelinePhase = { const candidates: CandidateSite[] = []; const providers = new Map(); + const providedTypes = new Map>>(); + const providerNames = new Map>>(); + const providerNodes = new Map(); + const providersByDeclarer = new Map>(); ctx.graph.forEachNode((node) => { const language = node.properties.language; if (language === undefined || !isSupportedLanguage(language)) return; @@ -105,7 +127,25 @@ export const diPhase: PipelinePhase = { if (resolver === undefined) return; const provider = resolver.matchProvider(node); - if (provider !== null) providers.set(node.id, provider); + if (provider !== null) { + providers.set(node.id, provider); + providerNodes.set(node.id, node); + const namesByLanguage = providerNames.get(language) ?? new Map>(); + for (const name of provider.names) { + const namedProviders = namesByLanguage.get(name) ?? new Set(); + namedProviders.add(node.id); + namesByLanguage.set(name, namedProviders); + } + providerNames.set(language, namesByLanguage); + if (provider.providedTypeName !== undefined) { + addProvidedType(providedTypes, language, provider.providedTypeName, node.id); + } + if (provider.declaredByNodeId !== undefined) { + const declared = providersByDeclarer.get(provider.declaredByNodeId) ?? new Set(); + declared.add(node.id); + providersByDeclarer.set(provider.declaredByNodeId, declared); + } + } for (const match of resolver.matchInjectionSites(node)) { candidates.push({ ...match, siteNodeId: node.id, language }); } @@ -121,10 +161,19 @@ export const diPhase: PipelinePhase = { } const interfaceToImplementers = new Map>(); + const directSupertypes = new Map>(); for (const rel of ctx.graph.iterRelationshipsByType('IMPLEMENTS')) { const set = interfaceToImplementers.get(rel.targetId) ?? new Set(); set.add(rel.sourceId); interfaceToImplementers.set(rel.targetId, set); + const supertypes = directSupertypes.get(rel.sourceId) ?? new Set(); + supertypes.add(rel.targetId); + directSupertypes.set(rel.sourceId, supertypes); + } + for (const rel of ctx.graph.iterRelationshipsByType('EXTENDS')) { + const supertypes = directSupertypes.get(rel.sourceId) ?? new Set(); + supertypes.add(rel.targetId); + directSupertypes.set(rel.sourceId, supertypes); } const memberToClass = new Map(); @@ -137,7 +186,6 @@ export const diPhase: PipelinePhase = { const candidateLanguages = new Set(candidates.map((candidate) => candidate.language)); const interfacesByLanguage = new Map(); const classesByLanguage = new Map(); - const classNodes = new Map(); ctx.graph.forEachNode((node) => { if (node.label !== 'Class' && node.label !== 'Interface') return; const language = node.properties.language; @@ -146,9 +194,68 @@ export const diPhase: PipelinePhase = { const index = indexes.get(language) ?? emptyNameIndex(); addIndexedName(index, node); indexes.set(language, index); - if (node.label === 'Class') classNodes.set(node.id, node); + if (node.label === 'Class') providerNodes.set(node.id, node); }); + // A declaration returning a concrete class is assignable to every class or + // interface that type extends/implements. Expand once per language+type and + // register the declaration under those ancestor names. This keeps named + // selection fast (set intersection below) while allowing, for example, a + // `DefaultGateway` Bean to satisfy a named `Gateway` Resource site. + const assignableNamesByProvidedType = new Map(); + for (const [providerId, provider] of providers) { + if (provider.providedTypeName === undefined) continue; + const providerNode = providerNodes.get(providerId); + const language = providerNode?.properties.language; + if (typeof language !== 'string') continue; + const cacheKey = `${language}\0${provider.providedTypeName}`; + let assignableTypeNames = assignableNamesByProvidedType.get(cacheKey); + if (assignableTypeNames === undefined) { + const classEntry = resolveIndexedName( + classesByLanguage.get(language), + provider.providedTypeName, + ); + const interfaceEntry = resolveIndexedName( + interfacesByLanguage.get(language), + provider.providedTypeName, + ); + const rootTypeId = + typeof classEntry === 'string' && interfaceEntry === undefined + ? classEntry + : typeof interfaceEntry === 'string' && classEntry === undefined + ? interfaceEntry + : undefined; + const names = new Set(); + if (rootTypeId !== undefined) { + const queue = [rootTypeId]; + const visited = new Set(); + while (queue.length > 0) { + const typeId = queue.pop(); + if (typeId === undefined) continue; + if (visited.has(typeId)) continue; + visited.add(typeId); + const typeNode = ctx.graph.getNode(typeId); + if ( + (typeNode?.label === 'Class' || typeNode?.label === 'Interface') && + typeNode.properties.language === language + ) { + names.add(typeNode.properties.name); + const qualifiedName = typeNode.properties.qualifiedName; + if (typeof qualifiedName === 'string') names.add(qualifiedName); + } + for (const supertypeId of directSupertypes.get(typeId) ?? []) { + queue.push(supertypeId); + } + } + } + assignableTypeNames = [...names]; + assignableNamesByProvidedType.set(cacheKey, assignableTypeNames); + } + for (const typeName of assignableTypeNames) { + addProvidedType(providedTypes, language, typeName, providerId); + } + } + let ambiguousSkipped = 0; let ambiguousInjections = 0; const ambiguousTypeNames = new Set(); @@ -166,6 +273,8 @@ export const diPhase: PipelinePhase = { const consumerClassId = siteNode?.label === 'Class' ? siteNode.id : memberToClass.get(candidate.siteNodeId); if (consumerClassId === undefined) continue; + const edgeSourceId = + candidate.edgeSource === 'site' && siteNode !== undefined ? siteNode.id : consumerClassId; const classEntry = resolveIndexedName( classesByLanguage.get(candidate.language), @@ -196,39 +305,55 @@ export const diPhase: PipelinePhase = { if (typeof interfaceEntry === 'string') { for (const id of interfaceToImplementers.get(interfaceEntry) ?? []) structural.add(id); } + for (const id of providedTypes.get(candidate.language)?.get(candidate.targetTypeName) ?? []) { + structural.add(id); + } + for (const id of providersByDeclarer.get(edgeSourceId) ?? []) structural.delete(id); structural.delete(consumerClassId); + structural.delete(edgeSourceId); if (structural.size === 0) continue; - let viable = providerCandidates(structural, providers); const namedSelection = candidate.namedSelection; + let usedNamedSelection = false; + let selectionSuffix = ''; + let viable: string[]; if (namedSelection !== undefined) { - viable = viable.filter( - (id) => providers.get(id)?.names.includes(namedSelection.name) === true, - ); - if (viable.length === 0) continue; + const named = [ + ...(providerNames.get(candidate.language)?.get(namedSelection.name) ?? []), + ].filter((id) => structural.has(id)); + if (named.length > 0) { + viable = named; + usedNamedSelection = true; + selectionSuffix = `; ${namedSelection.reason}`; + } else if (namedSelection.fallbackToType === true) { + viable = providerCandidates(structural, providers); + selectionSuffix = `; ${namedSelection.reason} unmatched; type fallback`; + } else { + continue; + } + } else { + viable = providerCandidates(structural, providers); } if (candidate.cardinality === 'collection') { - const confidence = namedSelection === undefined ? 0.8 : 0.9; - const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`; + const confidence = usedNamedSelection ? 0.9 : 0.8; for (const targetId of viable) { queueEdge({ - sourceId: consumerClassId, + sourceId: edgeSourceId, targetId, confidence, - reason: candidate.reason + suffix, + reason: candidate.reason + selectionSuffix, }); } continue; } if (viable.length === 1) { - const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`; queueEdge({ - sourceId: consumerClassId, + sourceId: edgeSourceId, targetId: viable[0], - confidence: namedSelection === undefined ? 0.9 : 0.95, - reason: candidate.reason + suffix, + confidence: usedNamedSelection ? 0.95 : namedSelection === undefined ? 0.9 : 0.85, + reason: candidate.reason + selectionSuffix, }); continue; } @@ -237,28 +362,28 @@ export const diPhase: PipelinePhase = { const reason = providers.get(id)?.preferenceReason; return reason === undefined ? [] : [{ id, reason }]; }); - if (namedSelection === undefined && preferred.length === 1) { + if (!usedNamedSelection && preferred.length === 1) { const selected = preferred[0]; queueEdge({ - sourceId: consumerClassId, + sourceId: edgeSourceId, targetId: selected.id, confidence: 0.95, - reason: `${candidate.reason}; ${selected.reason}`, + reason: `${candidate.reason}${selectionSuffix}; ${selected.reason}`, }); continue; } ambiguousInjections++; const candidateNames = viable - .map((id) => classNodes.get(id)?.properties.name ?? id) + .map((id) => providerNodes.get(id)?.properties.name ?? id) .sort() .join(', '); for (const targetId of viable) { queueEdge({ - sourceId: consumerClassId, + sourceId: edgeSourceId, targetId, confidence: 0.5, - reason: `${candidate.reason}; ambiguous candidates: ${candidateNames}`, + reason: `${candidate.reason}${selectionSuffix}; ambiguous candidates: ${candidateNames}`, }); } } diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 7512273f2..18f91e723 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -17,12 +17,14 @@ import { createWriteStream, WriteStream } from 'fs'; import path from 'path'; import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; import { KnowledgeGraph } from '../graph/types.js'; -import { NodeTableName, NODE_TABLES } from './schema.js'; -import { RelPairRouter } from './rel-pair-routing.js'; +import { NodeTableName, NODE_TABLES, RELATION_SCHEMA } from './schema.js'; +import { parseRelationSchemaPairs, RelPairRouter } from './rel-pair-routing.js'; import { parseTruthyEnv } from '../ingestion/utils/env.js'; import { SYMBOL_NODE_LABELS } from '../ingestion/utils/symbol-labels.js'; import { applyCjkSegmentationIfEnabled } from '../search/cjk-segmentation.js'; +const DECLARED_RELATION_PAIRS = parseRelationSchemaPairs(RELATION_SCHEMA); + /** * Deterministic output ordering — optional (out-of-core / windowed-resolve * enabler). When `GITNEXUS_SORT_GRAPH_OUTPUT` is set, nodes and relationships @@ -785,7 +787,12 @@ export const streamAllCSVsToDisk = async ( // read once instead of twice. The router applies the SAME label-derivation + // validTables filter as the legacy splitRelCsvByLabelPair, so the per-pair // files are byte-identical (asserted by the differential test). - const relRouter = new RelPairRouter(csvDir, REL_CSV_HEADER, new Set(NODE_TABLES)); + const relRouter = new RelPairRouter( + csvDir, + REL_CSV_HEADER, + new Set(NODE_TABLES), + DECLARED_RELATION_PAIRS, + ); try { let emitted = 0; for (const rel of orderedRelationships(graph, sortOutput)) { diff --git a/gitnexus/src/core/lbug/rel-pair-routing.ts b/gitnexus/src/core/lbug/rel-pair-routing.ts index a10a817fa..9729b5bec 100644 --- a/gitnexus/src/core/lbug/rel-pair-routing.ts +++ b/gitnexus/src/core/lbug/rel-pair-routing.ts @@ -16,8 +16,11 @@ * router derives the label from the RAW id, while the oracle re-derives it via a * regex over the ESCAPED row — so for an id containing a `"` the router is the * more-correct path (it routes the edge to the right pair; the oracle's regex - * mis-buckets or drops it). `splitRelCsvByLabelPair` is retained as the - * differential oracle (the quote-in-id divergence is asserted explicitly). + * mis-buckets or drops it). Unlike the legacy oracle, the router also rejects a + * valid node-label pair absent from the relationship DDL. That distinction is + * load-bearing: silently writing such a CSV lets COPY fail late and can drop an + * otherwise valid edge during fallback. `splitRelCsvByLabelPair` is retained as + * the differential oracle (the quote-in-id divergence is asserted explicitly). * * Backpressure: at most one stream is awaited at a time (the caller routes * edges sequentially and awaits the returned drain promise before the next), @@ -45,6 +48,22 @@ export const getNodeLabel = (nodeId: string): string => { return nodeId.split(':')[0]; }; +/** + * Extract the FROM→TO pairs accepted by a relationship DDL. + * + * This belongs at the routing boundary: schema.ts owns the DDL, while the CSV + * router owns the fail-fast check that prevents writing a pair LadybugDB cannot + * COPY. Backticks quote schema labels and are not part of the graph label. + */ +export const parseRelationSchemaPairs = (relationSchema: string): ReadonlySet => + new Set( + [ + ...relationSchema.matchAll( + /\bFROM\s+`?([A-Za-z][A-Za-z0-9_]*)`?\s+TO\s+`?([A-Za-z][A-Za-z0-9_]*)`?/g, + ), + ].map((match) => `${match[1]}|${match[2]}`), + ); + export interface RelPairMeta { csvPath: string; rows: number; @@ -69,6 +88,7 @@ export class RelPairRouter { private readonly csvDir: string, private readonly header: string, private readonly validTables: Set, + private readonly declaredPairs: ReadonlySet, private readonly wsFactory: WriteStreamFactory = (p) => createWriteStream(p, 'utf-8'), ) {} @@ -104,6 +124,11 @@ export class RelPairRouter { } const pairKey = `${fromLabel}|${toLabel}`; + if (!this.declaredPairs.has(pairKey)) { + throw new Error( + `Relationship label pair ${fromLabel}→${toLabel} is not declared in the LadybugDB relation schema`, + ); + } const ws = this.streams.get(pairKey); if (ws === undefined) { // First edge for this pair: open the stream, write header + row. diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index c8943d719..5803776fa 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -316,6 +316,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM Class TO \`Namespace\`, FROM Class TO \`Typedef\`, FROM Class TO \`Property\`, + FROM Class TO CodeElement, FROM Method TO Function, FROM Method TO Method, FROM Method TO Class, diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 0fe83cc6b..0908175d8 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -139,6 +139,7 @@ import { sanitizeDetectedBranch } from '../cli/analyze-config.js'; import { EMBEDDING_TABLE_NAME } from './lbug/schema.js'; import { STALE_HASH_SENTINEL } from './lbug/schema.js'; import { isSpringBeanCandidateSourceFile } from './ingestion/frameworks/spring/bean-catalog.js'; +import { isSpringBeanFactoryDeclaration } from './ingestion/frameworks/spring/bean-factories.js'; import { SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, @@ -167,6 +168,12 @@ interface PersistedFrameworkAnnotationRow { readonly frameworkAnnotations?: unknown; } +interface PersistedSpringBeanDeclarationRow { + readonly id?: unknown; + readonly filePath?: unknown; + readonly reason?: unknown; +} + function stringList(value: unknown): readonly string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') @@ -200,6 +207,45 @@ function collectFrameworkAnnotationDriftFiles( return driftFiles; } +function collectSpringBeanDeclarationDriftFiles( + graph: KnowledgeGraph, + persistedRows: readonly PersistedSpringBeanDeclarationRow[], +): Set { + const persisted = new Map(); + for (const row of persistedRows) { + if ( + typeof row.id === 'string' && + typeof row.filePath === 'string' && + typeof row.reason === 'string' && + isSpringBeanFactoryDeclaration({ type: 'DECLARES', reason: row.reason }) + ) { + persisted.set(row.id, { filePath: row.filePath, reason: row.reason }); + } + } + + const current = new Map(); + for (const relationship of graph.relationships) { + if (relationship.type !== 'DECLARES') continue; + if (!isSpringBeanFactoryDeclaration(relationship)) continue; + const declaration = graph.getNode(relationship.targetId); + if (declaration === undefined || typeof declaration.properties.filePath !== 'string') continue; + current.set(declaration.id, { + filePath: declaration.properties.filePath, + reason: relationship.reason, + }); + } + + const driftFiles = new Set(); + for (const [id, value] of current) { + const prior = persisted.get(id); + if (prior === undefined || prior.reason !== value.reason) driftFiles.add(value.filePath); + } + for (const [id, value] of persisted) { + if (!current.has(id)) driftFiles.add(value.filePath); + } + return driftFiles; +} + // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- @@ -1855,6 +1901,23 @@ async function runFullAnalysisInner( 'framework annotation property drift', ); } + + const persistedSpringBeanDeclarations = (await executeQuery( + 'MATCH (m:Method)-[r:CodeRelation]->(b:CodeElement) ' + + "WHERE r.type = 'DECLARES' AND r.reason STARTS WITH 'spring-bean-factory:' " + + 'RETURN b.id AS id, b.filePath AS filePath, r.reason AS reason', + )) as PersistedSpringBeanDeclarationRow[]; + const springBeanDeclarationDriftFiles = collectSpringBeanDeclarationDriftFiles( + pipelineResult.graph, + persistedSpringBeanDeclarations, + ); + for (const filePath of springBeanDeclarationDriftFiles) effectiveWriteSet.add(filePath); + if (springBeanDeclarationDriftFiles.size > 0) { + log( + `Incremental: +${springBeanDeclarationDriftFiles.size} file(s) added for ` + + 'Spring Bean factory declaration drift', + ); + } } // Deduped: deleted entries may already appear via importer-BFS // expansion (the importer BFS can return a now-deleted path), which diff --git a/gitnexus/src/mcp/local/bean-metadata.ts b/gitnexus/src/mcp/local/bean-metadata.ts index cbb7e25ed..cd08ad327 100644 --- a/gitnexus/src/mcp/local/bean-metadata.ts +++ b/gitnexus/src/mcp/local/bean-metadata.ts @@ -3,15 +3,38 @@ import { deriveSpringBeanMetadata, type SpringBeanMetadata, } from '../../core/ingestion/frameworks/spring/bean-catalog.js'; +import { + decodeSpringBeanFactoryReason, + type SpringBeanFactoryMetadata, +} from '../../core/ingestion/frameworks/spring/bean-factories.js'; export async function queryClassBeanMetadata( lbugPath: string, symbolId: string, symbolType: string, -): Promise { - if (symbolType !== 'Class') return undefined; +): Promise { + if (symbolType !== 'Class' && symbolType !== 'Method' && symbolType !== 'CodeElement') { + return undefined; + } try { + if (symbolType !== 'Class') { + const pattern = + symbolType === 'Method' + ? 'MATCH (m:Method {id: $symbolId})-[r:CodeRelation]->(b:CodeElement)' + : 'MATCH (m:Method)-[r:CodeRelation]->(b:CodeElement {id: $symbolId})'; + const rows = await executeParameterized( + lbugPath, + `${pattern} + WHERE r.type = 'DECLARES' + RETURN r.reason AS reason + LIMIT 1`, + { symbolId }, + ); + const row = rows[0]; + return row === undefined ? undefined : decodeSpringBeanFactoryReason(row.reason ?? row[0]); + } + const rows = await executeParameterized( lbugPath, ` diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index dd9677944..694483a6d 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -2924,6 +2924,8 @@ export class LocalBackend { UNION ALL MATCH (n:\`Constructor\`) WHERE n.id IN $ids RETURN n.id AS id, 'Constructor' AS label UNION ALL + MATCH (n:\`CodeElement\`) WHERE n.id IN $ids RETURN n.id AS id, 'CodeElement' AS label + UNION ALL MATCH (n:\`Const\`) WHERE n.id IN $ids RETURN n.id AS id, 'Const' AS label UNION ALL MATCH (n:\`Variable\`) WHERE n.id IN $ids RETURN n.id AS id, 'Variable' AS label diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index b26b598ef..98687a7d8 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -233,8 +233,8 @@ EXAMPLES: • Find method overrides (MRO resolution): MATCH (winner:Method)-[r:CodeRelation {type: 'METHOD_OVERRIDES'}]->(loser:Method) RETURN winner.name, winner.filePath, loser.filePath, r.reason -• Find DI-injected implementations (beans injected into a consumer class): - MATCH (c:Class {name: 'OrderService'})-[r:CodeRelation]->(impl:Class) WHERE r.type = 'INJECTS' RETURN impl.name, r.reason +• Find DI-injected providers (provider Classes or synthetic factory declarations): + MATCH (c:Class {name: 'OrderService'})-[r:CodeRelation]->(provider) WHERE r.type = 'INJECTS' RETURN provider.name, r.reason • Detect diamond inheritance: MATCH (d:Class)-[:CodeRelation {type: 'EXTENDS'}]->(b1), (d)-[:CodeRelation {type: 'EXTENDS'}]->(b2), (b1)-[:CodeRelation {type: 'EXTENDS'}]->(a), (b2)-[:CodeRelation {type: 'EXTENDS'}]->(a) WHERE b1 <> b2 RETURN d.name, b1.name, b2.name, a.name @@ -534,7 +534,7 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep type: 'array', items: { type: 'string' }, description: - 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES (default: usage-based, ACCESSES excluded by default). DI fan-out (consumer→implementer) requires explicitly including INJECTS.', + 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES (default: usage-based, ACCESSES excluded by default). DI edges (consumer Class or factory Method → provider Class or declaration CodeElement) require explicitly including INJECTS.', }, includeTests: { type: 'boolean', description: 'Include test files (default: false)' }, minConfidence: { diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 3612bc9dd..1f18d2910 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -55,6 +55,21 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // the main thread (the #1983 OOM). Because the two stores share this version, // any future change to the `ParsedFile` serialization shape MUST bump // SCHEMA_BUMP so both invalidate in lockstep. +// v35: Java/Kotlin Spring @Bean factory and @Resource side-channel facts +// (#2413). ParsedFile results are content-addressed and replayed verbatim, so +// the feature/schema inventory alone cannot invalidate pre-#2413 captures. +// (Cut as v32 on this branch; `main` took 32 for #2742 and 33/34 for #2747 +// first, so this series is renumbered at merge time — see the v21 note.) +// v34: the receiver-chain capture is emitted by ALL 14 language emitters, not +// just TypeScript. v33 landed with the TypeScript-only emission; the rollout to +// the other 13 languages changed the capture set AGAIN, so a cache stamped 33 by +// an intermediate build of that series is not equivalent to one stamped at this +// commit — it would be treated as current while every non-TypeScript file +// replayed pre-rollout captures, leaving the feature silently inert for 13 of 14 +// languages. Bumped there so the version tracks the FINAL capture set rather +// than the first divergence. +// v33: TypeScript call matches carry `@reference.receiver-chain`, a compact +// encoding of a receiver that is itself an expression. // v32: Rust items are qualified by their enclosing `mod` chain (#2742). The // qualified name is computed in the parse worker, so a warm cache replays the // old unqualified ids verbatim and the collapse persists. @@ -128,19 +143,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // JLS 13.1 immediate-host chains (#2555). // v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity. // v16: direct callee identity. -// v34: the receiver-chain capture is emitted by ALL 14 language emitters, not -// just TypeScript. v33 landed with the TypeScript-only emission; the rollout to -// the other 13 languages changed the capture set AGAIN, so a cache stamped 33 by -// an intermediate build of this series is not equivalent to one stamped at this -// commit — it would be treated as current while every non-TypeScript file -// replayed pre-rollout captures, leaving the feature silently inert for 13 of 14 -// languages. Bumped here so the version tracks the FINAL capture set rather than -// the first divergence. -// v33: TypeScript call matches carry `@reference.receiver-chain`, a compact -// encoding of a receiver that is itself an expression. (Shipped as v32 on its own -// branch; `main` took 32 for #2742 first, so this series is renumbered — see the -// v21 note on re-checking against origin/main at MERGE time, not branch time.) -const SCHEMA_BUMP = 34; +const SCHEMA_BUMP = 35; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 560091a9d..f3d4df922 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -603,6 +603,15 @@ export interface RepoMeta { * graph for every unchanged file, which is exactly the missing-caller symptom * #2708 reported. Force a full re-analyze. * + * v29: Spring @Bean declarations are CodeElement providers and INJECTS may run + * from a consumer Class or factory Method to that CodeElement (#2413). The + * relation DDL gained Class→CodeElement; a pre-v29 database cannot persist that + * label pair, so force a one-time rebuild against the expanded schema. + * + * (This shipped as v25 on its own branch; `main` took 25 through 28 first, so it + * is renumbered at merge time. Re-check both constants against origin/main + * immediately before merging — this is the fifth time that collision has bitten.) + * * v26: unresolved-receiver member names are persisted * (`unresolvedReceiverMembers`) so `impact()`/`context()` can report * `epistemic: 'lower-bound'` instead of a confident `'exact'` when a call site @@ -623,7 +632,7 @@ export interface RepoMeta { * index holds ids an incremental top-up cannot reconcile and would simply * strand. Force a full re-analyze. */ -export const INCREMENTAL_SCHEMA_VERSION = 28; +export const INCREMENTAL_SCHEMA_VERSION = 29; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/integration/csv-pipeline.test.ts b/gitnexus/test/integration/csv-pipeline.test.ts index 6f76bfeaf..875eb166d 100644 --- a/gitnexus/test/integration/csv-pipeline.test.ts +++ b/gitnexus/test/integration/csv-pipeline.test.ts @@ -641,17 +641,18 @@ describe('streamAllCSVsToDisk — direct per-pair emit matches the split oracle' { id: 'Function:a.ts:f:1', label: 'Function', name: 'f', filePath: 'a.ts' }, { id: 'Function:a.ts:g:5', label: 'Function', name: 'g', filePath: 'a.ts' }, { id: 'comm_1', label: 'Community' as never, name: 'c1', filePath: '' }, - { id: 'comm_2', label: 'Community' as never, name: 'c2', filePath: '' }, { id: 'proc_1', label: 'Process' as never, name: 'p1', filePath: '' }, - { id: 'proc_2', label: 'Process' as never, name: 'p2', filePath: '' }, ], [ { sourceId: 'File:a.ts', targetId: 'Function:a.ts:f:1', type: 'CONTAINS' }, { sourceId: 'File:a.ts', targetId: 'Function:a.ts:g:5', type: 'CONTAINS' }, { sourceId: 'Function:a.ts:f:1', targetId: 'Function:a.ts:g:5', type: 'CALLS' }, - { sourceId: 'comm_1', targetId: 'comm_2', type: 'CONTAINS' }, - // proc_ prefix → Process label (getNodeLabel special case). - { sourceId: 'proc_1', targetId: 'proc_2', type: 'CONTAINS' }, + // comm_ target prefix → Community label (getNodeLabel special case); + // Function→Community is a real schema pair. + { sourceId: 'Function:a.ts:f:1', targetId: 'comm_1', type: 'MEMBER_OF' }, + // proc_ target prefix → Process label; Function→Process is likewise + // declared in the production relation schema. + { sourceId: 'Function:a.ts:g:5', targetId: 'proc_1', type: 'STEP_IN_PROCESS' }, // Invalid FROM label ('Bogus' ∉ NODE_TABLES) — skipped by both paths. { sourceId: 'Bogus:x', targetId: 'File:a.ts', type: 'CONTAINS' }, // Invalid TO label — exercises the OTHER branch of the skip condition. diff --git a/gitnexus/test/integration/spring-bean-mcp.test.ts b/gitnexus/test/integration/spring-bean-mcp.test.ts index 79030176c..4148b0d8b 100644 --- a/gitnexus/test/integration/spring-bean-mcp.test.ts +++ b/gitnexus/test/integration/spring-bean-mcp.test.ts @@ -14,12 +14,19 @@ const KOTLIN_BEAN_ID = 'Class:src/KotlinBillingService.kt:KotlinBillingService'; const PLAIN_ID = 'Class:src/PlainUtility.java:PlainUtility'; const NON_JAVA_ID = 'Class:src/AppProvider.ts:AppProvider'; const CONFLICT_ID = 'Class:src/ConflictingBean.java:ConflictingBean'; +const FACTORY_METHOD_ID = 'Method:src/AppConfiguration.java:AppConfiguration.billingService#0'; +const FACTORY_BEAN_ID = `CodeElement:spring-bean:${FACTORY_METHOD_ID}`; +const FACTORY_REASON = + 'spring-bean-factory:{"names":["billingService","billingAlias"],"namesKnown":true,"providedType":"BillingService"}'; const SEED = [ `CREATE (c:Class {id:'${BEAN_ID}', name:'BillingService', filePath:'src/BillingService.java', startLine:0, endLine:3, isExported:false, content:'class BillingService {}', description:'', frameworkAnnotations:['org.springframework.stereotype.Service']})`, `CREATE (c:Class {id:'${KOTLIN_BEAN_ID}', name:'KotlinBillingService', filePath:'src/KotlinBillingService.kt', startLine:0, endLine:3, isExported:false, content:'class KotlinBillingService', description:'', frameworkAnnotations:['org.springframework.stereotype.Service']})`, `CREATE (c:Class {id:'${PLAIN_ID}', name:'PlainUtility', filePath:'src/PlainUtility.java', startLine:0, endLine:1, isExported:false, content:'class PlainUtility {}', description:'', frameworkAnnotations:[]})`, `CREATE (c:Class {id:'${NON_JAVA_ID}', name:'AppProvider', filePath:'src/AppProvider.ts', startLine:0, endLine:1, isExported:true, content:'class AppProvider {}', description:'', frameworkAnnotations:['@nestjs/common.Injectable']})`, `CREATE (c:Class {id:'${CONFLICT_ID}', name:'ConflictingBean', filePath:'src/ConflictingBean.java', startLine:0, endLine:1, isExported:false, content:'class ConflictingBean {}', description:'', frameworkAnnotations:['org.springframework.stereotype.Service', 'org.springframework.stereotype.Component']})`, + `CREATE (m:Method {id:'${FACTORY_METHOD_ID}', name:'billingService', filePath:'src/AppConfiguration.java', startLine:4, endLine:6, isExported:false, content:'@Bean BillingService billingService()', description:'', parameterCount:0, returnType:'BillingService'})`, + `CREATE (b:CodeElement {id:'${FACTORY_BEAN_ID}', name:'billingService', filePath:'src/AppConfiguration.java', startLine:4, endLine:6, isExported:false, content:'', description:'Spring Bean factory declaration'})`, + `MATCH (m:Method {id:'${FACTORY_METHOD_ID}'}), (b:CodeElement {id:'${FACTORY_BEAN_ID}'}) CREATE (m)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${FACTORY_REASON}', step:0}]->(b)`, ]; withTestLbugDB( @@ -76,6 +83,21 @@ withTestLbugDB( expect(nonJava.symbol).not.toHaveProperty('bean'); expect(conflict.target).not.toHaveProperty('bean'); }); + + it('enriches both Bean factory methods and their synthetic declarations', async () => { + const methodContext = await backend.callTool('context', { uid: FACTORY_METHOD_ID }); + const declarationContext = await backend.callTool('context', { uid: FACTORY_BEAN_ID }); + const expectedFactoryBean = { + framework: 'spring', + role: 'factory-method', + annotation: 'org.springframework.context.annotation.Bean', + names: ['billingService', 'billingAlias'], + providedType: 'BillingService', + }; + + expect(methodContext.symbol.bean).toEqual(expectedFactoryBean); + expect(declarationContext.symbol.bean).toEqual(expectedFactoryBean); + }); }); }, { diff --git a/gitnexus/test/integration/spring-bean-metadata-roundtrip.test.ts b/gitnexus/test/integration/spring-bean-metadata-roundtrip.test.ts index 92b587648..148856c00 100644 --- a/gitnexus/test/integration/spring-bean-metadata-roundtrip.test.ts +++ b/gitnexus/test/integration/spring-bean-metadata-roundtrip.test.ts @@ -4,6 +4,7 @@ import { expect, it } from 'vitest'; import { buildTestGraph } from '../helpers/test-graph.js'; import { withTestLbugDB } from '../helpers/test-indexed-db.js'; import { streamAllCSVsToDisk } from '../../src/core/lbug/csv-generator.js'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; const CLASS_ID = 'Class:src/BillingService.java:BillingService'; const FRAMEWORK_MARKER = 'com.acme.FrameworkMarker'; @@ -122,4 +123,87 @@ withTestLbugDB('spring-bean-metadata-roundtrip', (handle) => { 'Cannot safely encode CSV string-list item', ); }); + + it('persists pipeline-produced Class and Method INJECTS edges to Bean declarations', async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const root = path.join(handle.tmpHandle.dbPath, 'spring-bean-injects-roundtrip'); + const repoDir = path.join(root, 'repo'); + const storageDir = path.join(root, 'storage'); + await fs.mkdir(repoDir, { recursive: true }); + await fs.mkdir(storageDir, { recursive: true }); + await Promise.all([ + fs.writeFile( + path.join(repoDir, 'Gateway.java'), + `package com.persisted; +public interface Gateway {} +`, + ), + fs.writeFile( + path.join(repoDir, 'DefaultGateway.java'), + `package com.persisted; +public class DefaultGateway implements Gateway {} +`, + ), + fs.writeFile( + path.join(repoDir, 'PersistedConfig.java'), + `package com.persisted; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +@Configuration +public class PersistedConfig { + @Bean Gateway persistedGateway() { return new DefaultGateway(); } + @Bean Object persistedAggregate(Gateway gateway) { return new Object(); } +} +`, + ), + fs.writeFile( + path.join(repoDir, 'PersistedConsumer.java'), + `package com.persisted; +import jakarta.annotation.Resource; +public class PersistedConsumer { + @Resource(name = "persistedGateway") Gateway selected; +} +`, + ), + ]); + + const { graph } = await runPipelineFromRepo(repoDir, () => {}, {}); + expect( + graph.relationships.some( + (relationship) => + relationship.type === 'INJECTS' && + graph.getNode(relationship.sourceId)?.properties.name === 'PersistedConsumer' && + graph.getNode(relationship.targetId)?.properties.name === 'persistedGateway', + ), + ).toBe(true); + expect( + graph.relationships.some( + (relationship) => + relationship.type === 'INJECTS' && + graph.getNode(relationship.sourceId)?.properties.name === 'persistedAggregate' && + graph.getNode(relationship.targetId)?.properties.name === 'persistedGateway', + ), + ).toBe(true); + + await adapter.loadGraphToLbug(graph, repoDir, storageDir); + + expect( + await adapter.executeQuery( + `MATCH (source:Class)-[r:CodeRelation]->(target:CodeElement) + WHERE r.type = 'INJECTS' + AND source.name = 'PersistedConsumer' + AND target.name = 'persistedGateway' + RETURN source.name AS source, target.name AS target`, + ), + ).toEqual([{ source: 'PersistedConsumer', target: 'persistedGateway' }]); + expect( + await adapter.executeQuery( + `MATCH (source:Method)-[r:CodeRelation]->(target:CodeElement) + WHERE r.type = 'INJECTS' + AND source.name = 'persistedAggregate' + AND target.name = 'persistedGateway' + RETURN source.name AS source, target.name AS target`, + ), + ).toEqual([{ source: 'persistedAggregate', target: 'persistedGateway' }]); + }, 90_000); }); diff --git a/gitnexus/test/integration/spring-bean-resource-benchmark.test.ts b/gitnexus/test/integration/spring-bean-resource-benchmark.test.ts new file mode 100644 index 000000000..f47849f01 --- /dev/null +++ b/gitnexus/test/integration/spring-bean-resource-benchmark.test.ts @@ -0,0 +1,174 @@ +/** + * Spring @Bean / @Resource scaling benchmark (#2413, #2633). + * + * The normal-CI tripwires protect the single-pass Java and Kotlin capture + * paths. The gated benchmark exercises the complete graph pipeline, including + * Bean provider indexing and Resource name-first lookup: + * + * GITNEXUS_BENCH=1 npx vitest run test/integration/spring-bean-resource-benchmark.test.ts + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { emitJavaScopeCaptures } from '../../src/core/ingestion/languages/java/captures.js'; +import { collectJavaCaptureSideChannel } from '../../src/core/ingestion/languages/java/capture-side-channel.js'; +import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/captures.js'; +import { collectKotlinCaptureSideChannel } from '../../src/core/ingestion/languages/kotlin/capture-side-channel.js'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; + +const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1'; + +interface CaptureResult { + elapsedMs: number; + captureCount: number; + factoryCount: number; + resourceCount: number; +} + +function denseJavaSource(size: number): string { + const factories = Array.from( + { length: size }, + (_, index) => ` @Bean Gateway bean${index}() { return new GatewayImpl(); }`, + ).join('\n'); + const consumers = Array.from( + { length: size }, + (_, index) => ` +class Consumer${index} { + @Resource(name = "bean${index}") Gateway dependency; +}`, + ).join('\n'); + return `package com.example; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +interface Gateway {} +class GatewayImpl implements Gateway {} +@Configuration +class BenchConfiguration { +${factories} +} +${consumers} +`; +} + +function denseKotlinSource(size: number): string { + const factories = Array.from( + { length: size }, + (_, index) => ` @Bean fun bean${index}(): Gateway = GatewayImpl()`, + ).join('\n'); + const consumers = Array.from( + { length: size }, + (_, index) => ` +class Consumer${index} { + @field:Resource(name = "bean${index}") + lateinit var dependency: Gateway +}`, + ).join('\n'); + return `package com.example +import jakarta.annotation.Resource +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +interface Gateway +class GatewayImpl : Gateway +@Configuration +class BenchConfiguration { +${factories} +} +${consumers} +`; +} + +function runJavaCapture(size: number, run: number): CaptureResult { + const filePath = `src/SpringBeanResource${size}_${run}.java`; + const start = performance.now(); + const captures = emitJavaScopeCaptures(denseJavaSource(size), filePath); + const elapsedMs = performance.now() - start; + const facts = collectJavaCaptureSideChannel(filePath)?.springDiFacts ?? []; + return { + elapsedMs, + captureCount: captures.length, + factoryCount: facts.reduce((count, fact) => count + (fact.beanFactoryMethods?.length ?? 0), 0), + resourceCount: facts.reduce((count, fact) => count + fact.injectionSites.length, 0), + }; +} + +function runKotlinCapture(size: number, run: number): CaptureResult { + const filePath = `src/SpringBeanResource${size}_${run}.kt`; + const start = performance.now(); + const captures = emitKotlinScopeCaptures(denseKotlinSource(size), filePath); + const elapsedMs = performance.now() - start; + const facts = collectKotlinCaptureSideChannel(filePath)?.springDiFacts ?? []; + return { + elapsedMs, + captureCount: captures.length, + factoryCount: facts.reduce((count, fact) => count + (fact.beanFactoryMethods?.length ?? 0), 0), + resourceCount: facts.reduce((count, fact) => count + fact.injectionSites.length, 0), + }; +} + +describe('Spring Bean/Resource capture O(n²) regression tripwire (#2413, #2633)', () => { + it('captures 400 Java factories and Resource sites within a coarse linear-time budget', () => { + runJavaCapture(4, 0); + const result = runJavaCapture(400, 1); + + expect(result.factoryCount).toBe(400); + expect(result.resourceCount).toBe(400); + expect(result.captureCount).toBeGreaterThan(3_200); + expect(result.elapsedMs).toBeLessThan(10_000); + }, 30_000); + + it('captures 400 Kotlin factories and Resource sites within a coarse linear-time budget', () => { + runKotlinCapture(4, 0); + const result = runKotlinCapture(400, 1); + + expect(result.factoryCount).toBe(400); + expect(result.resourceCount).toBe(400); + expect(result.captureCount).toBeGreaterThan(2_400); + expect(result.elapsedMs).toBeLessThan(10_000); + }, 30_000); +}); + +describe.skipIf(!BENCH_ENABLED)( + 'Spring Bean/Resource end-to-end scaling benchmark (#2413, #2633)', + () => { + it('keeps named Resource resolution sub-quadratic as providers and sites grow together', async () => { + const scales = [25, 50, 100, 200]; + const results: Array<{ + size: number; + elapsedMs: number; + declarations: number; + injections: number; + }> = []; + + for (const size of scales) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `spring-bean-resource-${size}-`)); + try { + fs.writeFileSync(path.join(dir, 'Application.java'), denseJavaSource(size)); + const start = performance.now(); + const result = await runPipelineFromRepo(dir, () => {}, {}); + const elapsedMs = performance.now() - start; + const declarations = [...result.graph.iterRelationshipsByType('DECLARES')].length; + const injections = [...result.graph.iterRelationshipsByType('INJECTS')].length; + results.push({ size, elapsedMs, declarations, injections }); + console.log( + ` pipeline n=${size}: ${elapsedMs.toFixed(1)}ms ` + + `(${declarations} declarations, ${injections} injections)`, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + + for (const result of results) { + expect(result.declarations).toBe(result.size); + expect(result.injections).toBe(result.size); + } + const first = results[0]; + const last = results[results.length - 1]; + const sizeRatio = last.size / first.size; + const wallRatio = last.elapsedMs / first.elapsedMs; + expect(wallRatio).toBeLessThan(Math.pow(sizeRatio, 1.5)); + }, 300_000); + }, +); diff --git a/gitnexus/test/integration/spring-bean-resource-pipeline.test.ts b/gitnexus/test/integration/spring-bean-resource-pipeline.test.ts new file mode 100644 index 000000000..eb403f358 --- /dev/null +++ b/gitnexus/test/integration/spring-bean-resource-pipeline.test.ts @@ -0,0 +1,396 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { decodeSpringBeanFactoryReason } from '../../src/core/ingestion/frameworks/spring/bean-factories.js'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; + +function nodeNames(result: PipelineResult): Map { + const names = new Map(); + result.graph.forEachNode((node) => names.set(node.id, String(node.properties.name))); + return names; +} + +function injectionDetails(result: PipelineResult) { + const names = nodeNames(result); + return result.graph.relationships + .filter((relationship) => relationship.type === 'INJECTS') + .map((relationship) => ({ + pair: `${names.get(relationship.sourceId)}->${names.get(relationship.targetId)}`, + confidence: relationship.confidence, + reason: relationship.reason, + })) + .sort((left, right) => left.pair.localeCompare(right.pair)); +} + +function beanDeclarations(result: PipelineResult) { + const names = nodeNames(result); + return result.graph.relationships + .filter((relationship) => relationship.type === 'DECLARES') + .flatMap((relationship) => { + const metadata = decodeSpringBeanFactoryReason(relationship.reason); + return metadata === undefined + ? [] + : [ + { + factory: names.get(relationship.sourceId), + bean: names.get(relationship.targetId), + metadata, + }, + ]; + }) + .sort((left, right) => String(left.factory).localeCompare(String(right.factory))); +} + +describe('Spring Bean factories and Resource injection pipeline (#2413, #2633)', () => { + let dir: string; + let result: PipelineResult; + + const sources: Record = { + 'Gateway.java': 'package com.example; public interface Gateway {}\n', + 'DefaultGateway.java': + 'package com.example; public class DefaultGateway implements Gateway {}\n', + 'ConcreteRepo.java': 'package com.example; public class ConcreteRepo {}\n', + 'ClassGateway.java': `package com.example; +import org.springframework.stereotype.Service; +@Service("classGateway") +public class ClassGateway implements Gateway {} +`, + 'AppConfiguration.java': `package com.example; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +@Configuration +public class AppConfiguration { + @Bean Gateway gateway() { return new DefaultGateway(); } + @Bean(name = {"slowGateway", "gatewayAlias"}) + Gateway namedGateway() { return new DefaultGateway(); } + @Bean Gateway setterGateway() { return new DefaultGateway(); } + @Bean DefaultGateway concreteGateway() { return new DefaultGateway(); } + @Bean Gateway selfAwareGateway(Gateway dependency) { return new DefaultGateway(); } + @Bean List gatewayList() { return List.of(); } + @Bean ConcreteRepo repo() { return new ConcreteRepo(); } + @Bean @Autowired + ConcreteRepo service(@Qualifier("gatewayAlias") Gateway gateway) { + return new ConcreteRepo(); + } + @Bean ConcreteRepo aggregate(List gateways) { + return new ConcreteRepo(); + } +} +`, + 'ExplicitResourceConsumer.java': `package com.example; +import jakarta.annotation.Resource; +public class ExplicitResourceConsumer { + @Resource(name = "slowGateway") Gateway selected; +} +`, + 'DefaultResourceConsumer.java': `package com.example; +import javax.annotation.Resource; +public class DefaultResourceConsumer { + @Resource Gateway gateway; +} +`, + 'SetterResourceConsumer.java': `package com.example; +import jakarta.annotation.Resource; +public class SetterResourceConsumer { + @Resource void setSetterGateway(Gateway value) {} +} +`, + 'CollectionResourceConsumer.java': `package com.example; +import java.util.List; +import javax.annotation.Resource; +public class CollectionResourceConsumer { + @Resource(name = "gatewayList") List gateways; +} +`, + 'ConcreteFactoryResourceConsumer.java': `package com.example; +import jakarta.annotation.Resource; +public class ConcreteFactoryResourceConsumer { + @Resource(name = "concreteGateway") Gateway gateway; +} +`, + 'GenericResourceConsumer.java': `package com.example; +import java.util.List; +import jakarta.annotation.Resource; +public class GenericResourceConsumer { + @Resource List missingGateways; +} +`, + 'TypeOverrideResourceConsumer.java': `package com.example; +import jakarta.annotation.Resource; +public class TypeOverrideResourceConsumer { + @Resource(type = ConcreteRepo.class) Object repo; +} +`, + 'FallbackResourceConsumer.java': `package com.example; +import jakarta.annotation.Resource; +public class FallbackResourceConsumer { + @Resource Gateway unknownGateway; +} +`, + 'MissingResourceConsumer.java': `package com.example; +import jakarta.annotation.Resource; +public class MissingResourceConsumer { + @Resource(name = "missing") Gateway gateway; +} +`, + 'RuntimeResourceConsumer.java': `package com.example; +import jakarta.annotation.Resource; +public class RuntimeResourceConsumer { + @Resource(lookup = "java:global/gateway") Gateway gateway; +} +`, + 'QualifierConsumer.java': `package com.example; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +public class QualifierConsumer { + @Autowired @Qualifier("gatewayAlias") Gateway gateway; +} +`, + 'ConflictingResourceConsumer.java': `package com.example; +import java.util.List; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Autowired; +public class ConflictingResourceConsumer { + @Autowired @Resource List gateways; +} +`, + 'ClassResourceConsumer.java': `package com.example; +import jakarta.annotation.Resource; +public class ClassResourceConsumer { + @Resource(name = "classGateway") Gateway gateway; +} +`, + 'LocalSpringNames.java': `package com.local; +@interface Bean {} +@interface Resource {} +interface LocalGateway {} +class LocalConfiguration { + @Bean LocalGateway localGateway() { return null; } +} +class LocalResourceConsumer { + @Resource LocalGateway localGateway; +} +`, + 'KGateway.kt': `package com.kotlin +interface KGateway +class KGatewayImpl : KGateway +class KRepo +`, + 'KClassGateway.kt': `package com.kotlin +import org.springframework.stereotype.Service +@Service("kotlinClassGateway") +class KClassGateway : KGateway +`, + 'KotlinConfiguration.kt': `package com.kotlin +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +@Configuration +class KotlinConfiguration { + @Bean fun kotlinGateway(): KGateway = KGatewayImpl() + @Bean(name = ["kotlinNamed", "kotlinAlias"]) + fun namedGateway(): KGateway = KGatewayImpl() + @Bean fun inferredGateway() = KGatewayImpl() + @Bean fun kotlinConcreteGateway() = KGatewayImpl() + @Bean fun kotlinSelfAwareGateway(dependency: KGateway): KGateway = KGatewayImpl() + private fun buildGateway(): KGateway = KGatewayImpl() + @Bean fun indirectGateway() = buildGateway() + @Bean fun kotlinList(): List = emptyList() + @Bean @Autowired + fun kotlinService(@param:Qualifier("kotlinAlias") gateway: KGateway): KRepo = KRepo() +} +`, + 'KotlinExplicitResourceConsumer.kt': `package com.kotlin +import jakarta.annotation.Resource +class KotlinExplicitResourceConsumer { + @field:Resource(name = "kotlinNamed") + lateinit var selected: KGateway +} +`, + 'KotlinDefaultResourceConsumer.kt': `package com.kotlin +import javax.annotation.Resource +class KotlinDefaultResourceConsumer { + @field:Resource + lateinit var kotlinGateway: KGateway +} +`, + 'KotlinSetterResourceConsumer.kt': `package com.kotlin +import jakarta.annotation.Resource +class KotlinSetterResourceConsumer { + @Resource fun setInferredGateway(value: KGatewayImpl) {} +} +`, + 'KotlinCollectionResourceConsumer.kt': `package com.kotlin +import jakarta.annotation.Resource +class KotlinCollectionResourceConsumer { + @set:Resource + var kotlinList: List? = null +} +`, + 'KotlinConcreteFactoryResourceConsumer.kt': `package com.kotlin +import jakarta.annotation.Resource +class KotlinConcreteFactoryResourceConsumer { + @field:Resource(name = "kotlinConcreteGateway") + lateinit var gateway: KGateway +} +`, + 'KotlinGenericResourceConsumer.kt': `package com.kotlin +import jakarta.annotation.Resource +class KotlinGenericResourceConsumer { + @field:Resource + lateinit var missingGateways: List +} +`, + 'KotlinConflictingResourceConsumer.kt': `package com.kotlin +import jakarta.annotation.Resource +import org.springframework.beans.factory.annotation.Autowired +class KotlinConflictingResourceConsumer { + @field:Autowired + @field:Resource + lateinit var gateways: List +} +`, + 'KotlinGetterResourceConsumer.kt': `package com.kotlin +import jakarta.annotation.Resource +class KotlinGetterResourceConsumer { + @get:Resource + var kotlinGateway: KGateway? = null +} +`, + 'KotlinClassResourceConsumer.kt': `package com.kotlin +import jakarta.annotation.Resource +class KotlinClassResourceConsumer { + @field:Resource(name = "kotlinClassGateway") + lateinit var gateway: KGateway +} +`, + 'KotlinLocalSpringNames.kt': `package com.kotlinlocal +annotation class Bean +annotation class Resource +interface LocalGateway +class LocalConfiguration { + @Bean fun localGateway(): LocalGateway = TODO() +} +class LocalResourceConsumer { + @field:Resource lateinit var localGateway: LocalGateway +} +`, + }; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-bean-resource-')); + for (const [fileName, source] of Object.entries(sources)) { + fs.writeFileSync(path.join(dir, fileName), source); + } + result = await runPipelineFromRepo(dir, () => {}, {}); + }, 90_000); + + afterAll(() => { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('creates Java and Kotlin Bean declarations with names, aliases, and returned types', () => { + const declarations = beanDeclarations(result); + expect( + declarations.find((declaration) => declaration.factory === 'gateway')?.metadata, + ).toMatchObject({ names: ['gateway'], providedType: 'Gateway' }); + expect( + declarations.find((declaration) => declaration.factory === 'namedGateway')?.metadata, + ).toMatchObject({ names: ['slowGateway', 'gatewayAlias'], providedType: 'Gateway' }); + expect( + declarations.find((declaration) => declaration.factory === 'kotlinGateway')?.metadata, + ).toMatchObject({ names: ['kotlinGateway'], providedType: 'KGateway' }); + expect( + declarations.find((declaration) => declaration.factory === 'inferredGateway')?.metadata, + ).toMatchObject({ names: ['inferredGateway'], providedType: 'KGatewayImpl' }); + const indirectGateway = declarations.find( + (declaration) => declaration.factory === 'indirectGateway', + ); + expect(indirectGateway?.metadata).toMatchObject({ names: ['indirectGateway'] }); + expect(indirectGateway?.metadata).not.toHaveProperty('providedType'); + expect( + declarations.some((declaration) => declaration.metadata.names.includes('localGateway')), + ).toBe(false); + }); + + it('uses Bean factory names and return types for Java Resource and Qualifier resolution', () => { + const details = injectionDetails(result); + const pairs = details.map((detail) => detail.pair); + expect(pairs).toContain('ExplicitResourceConsumer->slowGateway'); + expect(pairs).toContain('DefaultResourceConsumer->gateway'); + expect(pairs).toContain('SetterResourceConsumer->setterGateway'); + expect(pairs).toContain('CollectionResourceConsumer->gatewayList'); + expect(pairs).toContain('ConcreteFactoryResourceConsumer->concreteGateway'); + expect(pairs).toContain('TypeOverrideResourceConsumer->repo'); + expect(pairs).toContain('ClassResourceConsumer->ClassGateway'); + expect(pairs).toContain('QualifierConsumer->slowGateway'); + expect(pairs).toContain('service->slowGateway'); + expect(pairs.some((pair) => pair.startsWith('AppConfiguration->'))).toBe(false); + expect(pairs.filter((pair) => pair.startsWith('aggregate->'))).toEqual([ + 'aggregate->ClassGateway', + 'aggregate->concreteGateway', + 'aggregate->gateway', + 'aggregate->selfAwareGateway', + 'aggregate->setterGateway', + 'aggregate->slowGateway', + ]); + expect(pairs.some((pair) => pair === 'selfAwareGateway->selfAwareGateway')).toBe(false); + expect(pairs.some((pair) => pair.startsWith('selfAwareGateway->'))).toBe(true); + expect(pairs.filter((pair) => pair.startsWith('CollectionResourceConsumer->'))).toEqual([ + 'CollectionResourceConsumer->gatewayList', + ]); + }); + + it('uses default-name type fallback conservatively and keeps explicit/runtime misses unresolved', () => { + const details = injectionDetails(result); + const fallback = details.filter((detail) => + detail.pair.startsWith('FallbackResourceConsumer->'), + ); + expect(fallback.length).toBeGreaterThan(1); + expect(fallback.every((detail) => detail.confidence === 0.5)).toBe(true); + expect(fallback.every((detail) => detail.reason.includes('type fallback'))).toBe(true); + expect(details.some((detail) => detail.pair.startsWith('MissingResourceConsumer->'))).toBe( + false, + ); + expect(details.some((detail) => detail.pair.startsWith('RuntimeResourceConsumer->'))).toBe( + false, + ); + expect(details.some((detail) => detail.pair.startsWith('ConflictingResourceConsumer->'))).toBe( + false, + ); + expect(details.some((detail) => detail.pair.startsWith('GenericResourceConsumer->'))).toBe( + false, + ); + expect(details.some((detail) => detail.pair.startsWith('LocalResourceConsumer->'))).toBe(false); + }); + + it('provides Kotlin parity for explicit/default/setter/collection Resource sites', () => { + const pairs = injectionDetails(result).map((detail) => detail.pair); + expect(pairs).toContain('KotlinExplicitResourceConsumer->kotlinNamed'); + expect(pairs).toContain('KotlinDefaultResourceConsumer->kotlinGateway'); + expect(pairs).toContain('KotlinSetterResourceConsumer->inferredGateway'); + expect(pairs).toContain('KotlinCollectionResourceConsumer->kotlinList'); + expect(pairs).toContain('KotlinConcreteFactoryResourceConsumer->kotlinConcreteGateway'); + expect(pairs).toContain('KotlinClassResourceConsumer->KClassGateway'); + expect(pairs).toContain('kotlinService->kotlinNamed'); + expect(pairs.some((pair) => pair.startsWith('KotlinConfiguration->'))).toBe(false); + expect(pairs.some((pair) => pair.startsWith('KotlinGetterResourceConsumer->'))).toBe(false); + expect(pairs.some((pair) => pair.startsWith('KotlinGenericResourceConsumer->'))).toBe(false); + expect(pairs.some((pair) => pair.startsWith('KotlinConflictingResourceConsumer->'))).toBe( + false, + ); + expect(pairs.some((pair) => pair === 'kotlinSelfAwareGateway->kotlinSelfAwareGateway')).toBe( + false, + ); + expect(pairs.some((pair) => pair.startsWith('kotlinSelfAwareGateway->'))).toBe(true); + expect(pairs.filter((pair) => pair.startsWith('KotlinCollectionResourceConsumer->'))).toEqual([ + 'KotlinCollectionResourceConsumer->kotlinList', + ]); + }); +}); diff --git a/gitnexus/test/integration/spring-conditionals-pipeline.test.ts b/gitnexus/test/integration/spring-conditionals-pipeline.test.ts index 146d46399..2c387beff 100644 --- a/gitnexus/test/integration/spring-conditionals-pipeline.test.ts +++ b/gitnexus/test/integration/spring-conditionals-pipeline.test.ts @@ -242,7 +242,12 @@ com.duplicate.DuplicateAutoConfiguration }); it('uses metadata DECLARES evidence without claiming annotation-based registration', () => { - const targetNames = declarations + const metadataDeclarations = declarations.filter( + (edge) => + edge.reason === 'spring-auto-configuration-import' || + edge.reason === 'spring-auto-configuration-factory', + ); + const targetNames = metadataDeclarations .map((edge) => String(result.graph.getNode(edge.targetId)?.properties.name)) .sort(); expect(targetNames).toEqual([ @@ -254,13 +259,13 @@ com.duplicate.DuplicateAutoConfiguration 'StarterAutoConfiguration', ]); expect( - declarations.some( + metadataDeclarations.some( (edge) => result.graph.getNode(edge.targetId)?.properties.name === 'OrdinaryApplicationConfig', ), ).toBe(false); expect( - declarations.every((edge) => + metadataDeclarations.every((edge) => String(result.graph.getNode(edge.sourceId)?.properties.filePath).includes('META-INF'), ), ).toBe(true); diff --git a/gitnexus/test/unit/analysis-features.test.ts b/gitnexus/test/unit/analysis-features.test.ts index da1f136f6..2f537867d 100644 --- a/gitnexus/test/unit/analysis-features.test.ts +++ b/gitnexus/test/unit/analysis-features.test.ts @@ -25,13 +25,13 @@ describe('analysis feature versions', () => { }); expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({ 'graph.class-framework-annotations': 1, - 'spring.bean-inventory': 1, + 'spring.bean-inventory': 2, 'spring.conditionals-auto-configuration': 1, 'spring.config-bindings': 1, }); expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({ 'graph.class-framework-annotations': 1, - 'spring.bean-inventory': 1, + 'spring.bean-inventory': 2, 'spring.conditionals-auto-configuration': 1, }); expect( @@ -56,7 +56,7 @@ describe('analysis feature versions', () => { it('requires an exact, well-formed feature set', () => { const expected = { 'graph.class-framework-annotations': 1, - 'spring.bean-inventory': 1, + 'spring.bean-inventory': 2, }; expect(findAnalysisFeatureMismatches(expected, expected)).toEqual([]); @@ -66,7 +66,7 @@ describe('analysis feature versions', () => { ]); expect( findAnalysisFeatureMismatches( - { 'graph.class-framework-annotations': 1, 'spring.bean-inventory': 2 }, + { 'graph.class-framework-annotations': 1, 'spring.bean-inventory': 1 }, expected, ), ).toEqual(['version:spring.bean-inventory']); diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index 2404ad1e6..d0944f8cc 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 28 (structural receiver typing, all languages)', () => { + it('INCREMENTAL_SCHEMA_VERSION is bumped to 29 (Spring Bean relation schema, #2413)', () => { // Moves with every bump BY DESIGN — that is the point of pinning it. A // change that alters emitted ids or edges without bumping would otherwise // ship silently, and an existing index would keep serving the old graph // through the reuse gate below. - expect(INCREMENTAL_SCHEMA_VERSION).toBe(28); + expect(INCREMENTAL_SCHEMA_VERSION).toBe(29); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { @@ -198,7 +198,11 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // typing, and the fold still typed a local that merely shadowed a class name // as that class — so it carries pre-rollout edges AND fabricated ones. expect(passesReuseGate(27)).toBe(false); + // A pre-v29 (v28) index lacks Class→CodeElement relation schema support, + // so Spring @Bean injection edges (#2413) would be dropped during + // persistence → must NOT reuse. + expect(passesReuseGate(28)).toBe(false); // The current stamp passes the gate (incremental top-up eligible). - expect(passesReuseGate(28)).toBe(true); + expect(passesReuseGate(29)).toBe(true); }); }); diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index 489e3aeff..d3773e51f 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -107,6 +107,26 @@ async function setupKotlinSpringBeanIncrementalRepo() { return repo; } +async function setupSpringBeanFactoryIncrementalRepo() { + const repo = await createTempDir('gitnexus-incr-spring-bean-factory-'); + const src = path.join(repo.dbPath, 'src', 'com', 'other'); + await mkdir(src, { recursive: true }); + await writeFile( + path.join(src, 'WildcardConfiguration.java'), + 'package com.other;\n' + + 'import org.springframework.context.annotation.*;\n\n' + + '@Configuration class WildcardConfiguration {\n' + + ' @Bean Gateway gateway() { return new DefaultGateway(); }\n' + + '}\n' + + 'interface Gateway {}\n' + + 'class DefaultGateway implements Gateway {}\n', + 'utf-8', + ); + execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' }); + gitCommitAll(repo.dbPath, 'initial spring bean factory'); + return repo; +} + async function setupSpringConfigIncrementalRepo() { const repo = await createTempDir('gitnexus-incr-spring-config-'); const resources = path.join(repo.dbPath, 'src', 'main', 'resources'); @@ -185,6 +205,21 @@ async function countSpringAutoConfigurationDeclarations(repoPath: string): Promi } } +async function countSpringBeanFactoryDeclarations(repoPath: string): Promise { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const { lbugPath } = getStoragePaths(repoPath); + await adapter.initLbug(lbugPath); + try { + const rows = (await adapter.executeQuery( + `MATCH ()-[r:CodeRelation]->() WHERE r.type = 'DECLARES' ` + + `AND r.reason STARTS WITH 'spring-bean-factory:' RETURN count(r) AS c`, + )) as Array<{ c: number | bigint }>; + return Number(rows[0]?.c ?? 0); + } finally { + await adapter.closeLbug(); + } +} + async function countSpringAutoConfigurationSyntheticClasses(repoPath: string): Promise { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); @@ -592,6 +627,30 @@ describe('runFullAnalysis — incremental orchestration', () => { } }, 600_000); + it('rewrites unchanged Bean factory declarations when same-package shadowing changes', async () => { + const repo = await setupSpringBeanFactoryIncrementalRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + expect(await countSpringBeanFactoryDeclarations(repo.dbPath)).toBe(1); + + const shadow = path.join(repo.dbPath, 'src', 'com', 'other', 'Bean.java'); + await writeFile(shadow, 'package com.other;\npublic @interface Bean {}\n', 'utf-8'); + gitCommitAll(repo.dbPath, 'add same-package Bean annotation shadow'); + + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + expect(await countSpringBeanFactoryDeclarations(repo.dbPath)).toBe(0); + + await rm(shadow); + gitCommitAll(repo.dbPath, 'remove same-package Bean annotation shadow'); + + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + expect(await countSpringBeanFactoryDeclarations(repo.dbPath)).toBe(1); + } finally { + await repo.cleanup(); + } + }, 600_000); + // #2409: a large-fraction effective write set must escalate to the full DB // write plan (wipe + bulk COPY of the already-built graph) instead of the // surgical per-file writeback — at that size the surgical plan measured diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index fa58cd843..3b2d1dbf0 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -101,14 +101,12 @@ describe('fileContentHash', () => { }); describe('PARSE_CACHE_VERSION', () => { - // 32 -> 34 for the receiver-chain capture (#2747): 33 is the TypeScript-only - // emission, 34 the rollout to the other 13 emitters, which changed the capture - // set a second time. Updated deliberately — and this pin earned its keep twice - // over: it caught the #2742 collision when it arrived from main, and it caught - // this series again on rebase, where `main` had independently taken 32 for the - // very number this branch was already using. - it('pins SCHEMA_BUMP to 34 so concurrent bumps cannot silently collide (#2736)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(34); + // 34 -> 35 for the Spring @Bean/@Resource side-channel captures (#2413). + // Updated deliberately — this branch cut at 32 while `main` independently took + // 32 (#2742) and 33/34 (#2747), so the pin caught the collision again at merge + // time. Re-check against origin/main before merging, not at branch time. + it('pins SCHEMA_BUMP to 35 so concurrent bumps cannot silently collide (#2736)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(35); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { diff --git a/gitnexus/test/unit/ingestion/di.test.ts b/gitnexus/test/unit/ingestion/di.test.ts index 4bed5966e..2faa03341 100644 --- a/gitnexus/test/unit/ingestion/di.test.ts +++ b/gitnexus/test/unit/ingestion/di.test.ts @@ -18,6 +18,7 @@ import { diPhase } from '../../../src/core/ingestion/pipeline-phases/di.js'; import { parseSpringCollectionType, SPRING_DI_INJECTION_SITES_PROPERTY, + SPRING_DI_PROVIDER_PROPERTY, springDiFieldMatcher, } from '../../../src/core/ingestion/di-extractors/spring.js'; import { generateId } from '../../../src/lib/utils.js'; @@ -161,6 +162,30 @@ function addProperty( return propId; } +function addProviderDeclaration( + graph: KnowledgeGraph, + name: string, + providedTypeName: string, + declaredByNodeId?: string, +): string { + const id = generateId('CodeElement', `spring-bean:${name}`); + graph.addNode({ + id, + label: 'CodeElement', + properties: { + name, + filePath: 'src/AppConfiguration.java', + language: 'java', + [SPRING_DI_PROVIDER_PROPERTY]: { + names: [name], + providedTypeName, + ...(declaredByNodeId === undefined ? {} : { declaredByNodeId }), + }, + }, + }); + return id; +} + /** Collect all INJECTS relationships currently in the graph. */ function injectsEdges(graph: KnowledgeGraph) { return graph.relationships.filter((r) => r.type === 'INJECTS'); @@ -755,6 +780,93 @@ describe('di phase', () => { expect(output).toMatchObject({ injectsEdges: 2, ambiguousInjections: 1 }); }); + it('matches a named provider whose concrete provided type implements the requested interface', async () => { + const graph = createKnowledgeGraph(); + + addInterface(graph, 'Gateway'); + addClass(graph, 'DefaultGateway', 'java'); + addImplements(graph, 'DefaultGateway', 'Gateway'); + const providerId = addProviderDeclaration(graph, 'defaultGateway', 'DefaultGateway'); + const consumerId = addClass(graph, 'Consumer', 'java', 'Class', { + [SPRING_DI_INJECTION_SITES_PROPERTY]: [ + { + targetTypeName: 'Gateway', + cardinality: 'single', + namedSelection: { + name: 'defaultGateway', + reason: 'resource name "defaultGateway"', + }, + reason: 'Spring DI: @Resource gateway: Gateway', + }, + ], + }); + + const output = await diPhase.execute(makeCtx(graph), new Map()); + + expect(injectsEdges(graph)).toEqual([ + expect.objectContaining({ + sourceId: consumerId, + targetId: providerId, + confidence: 0.95, + }), + ]); + expect(output).toMatchObject({ injectsEdges: 1, ambiguousInjections: 0 }); + }); + + it('excludes the provider declared by a factory method from that method parameter injection', async () => { + const graph = createKnowledgeGraph(); + + addInterface(graph, 'Gateway'); + addClass(graph, 'DefaultGateway', 'java'); + addImplements(graph, 'DefaultGateway', 'Gateway'); + const configurationId = addClass(graph, 'AppConfiguration', 'java'); + const factoryMethodId = generateId('Method', 'AppConfiguration.gateway'); + graph.addNode({ + id: factoryMethodId, + label: 'Method', + properties: { + name: 'gateway', + filePath: 'src/AppConfiguration.java', + language: 'java', + [SPRING_DI_INJECTION_SITES_PROPERTY]: [ + { + targetTypeName: 'Gateway', + cardinality: 'single', + edgeSource: 'site', + reason: 'Spring DI: @Bean method gateway parameter dependency: Gateway', + }, + ], + }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${configurationId}->${factoryMethodId}`), + sourceId: configurationId, + targetId: factoryMethodId, + type: 'HAS_METHOD', + confidence: 1, + reason: '', + }); + const selfProviderId = addProviderDeclaration(graph, 'gateway', 'Gateway', factoryMethodId); + const siblingProviderId = addProviderDeclaration( + graph, + 'fallbackGateway', + 'Gateway', + generateId('Method', 'AppConfiguration.fallbackGateway'), + ); + + const output = await diPhase.execute(makeCtx(graph), new Map()); + + expect(injectsEdges(graph)).toEqual([ + expect.objectContaining({ + sourceId: factoryMethodId, + targetId: siblingProviderId, + confidence: 0.9, + }), + ]); + expect(injectsEdges(graph).some((edge) => edge.targetId === selfProviderId)).toBe(false); + expect(output).toMatchObject({ injectsEdges: 1, ambiguousInjections: 0 }); + }); + it('fails closed when one injection type name denotes both a class and an interface', async () => { const graph = createKnowledgeGraph(); diff --git a/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts b/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts index 3243c1cfd..5822728a8 100644 --- a/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts +++ b/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts @@ -30,6 +30,13 @@ const SCHEMA_MOCK = { SCHEMA_QUERIES: ['CREATE NODE TABLE IF NOT EXISTS File (id STRING, PRIMARY KEY(id))'], }; +const schemaMockFactory = async () => ({ + ...(await vi.importActual( + '../../src/core/lbug/schema.js', + )), + ...SCHEMA_MOCK, +}); + function makeFsMock(dbPath: string) { const ENOENT = Object.assign(new Error(`ENOENT: ${dbPath}`), { code: 'ENOENT' }); return { @@ -127,7 +134,7 @@ describe('doInitLbug WAL corruption guard — behavioural', () => { const db = { close: vi.fn(async () => {}) }; vi.doMock('fs/promises', () => makeFsMock(dbPath)); - vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: vi.fn(async () => ({ db, conn })), closeLbugConnection: vi.fn(async () => {}), @@ -182,7 +189,7 @@ describe('doInitLbug WAL corruption guard — behavioural', () => { const warnMock = vi.fn(); vi.doMock('fs/promises', () => makeFsMock(dbPath)); - vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: vi.fn(async () => ({ db, conn })), closeLbugConnection: vi.fn(async () => {}), @@ -240,7 +247,7 @@ describe('doInitLbug WAL corruption guard — behavioural', () => { const warnMock = vi.fn(); vi.doMock('fs/promises', () => fsMock); - vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: openLbugConnectionMock, closeLbugConnection: async (handle: { conn: typeof firstConn; db: typeof firstDb }) => { @@ -295,7 +302,7 @@ describe('doInitLbug WAL corruption guard — behavioural', () => { const warnMock = vi.fn(); vi.doMock('fs/promises', () => makeFsMock(dbPath)); - vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: openLbugConnectionMock, closeLbugConnection: vi.fn(async () => {}), @@ -366,7 +373,7 @@ describe('doInitLbug WAL corruption guard — behavioural', () => { const ensureMock = vi.fn(async () => false); vi.doMock('fs/promises', () => makeFsMock(dbPath)); - vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: openLbugConnectionMock, closeLbugConnection: async (handle: { @@ -450,7 +457,7 @@ describe('doInitLbug WAL corruption guard — behavioural', () => { const fsMock = makeFsMock(dbPath); vi.doMock('fs/promises', () => fsMock); - vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: openLbugConnectionMock, closeLbugConnection: async (handle: { conn: typeof readOnlyConn; db: typeof readOnlyDb }) => { @@ -504,7 +511,7 @@ describe('doInitLbug WAL corruption guard — behavioural', () => { const db = { close: vi.fn(async () => {}) }; vi.doMock('fs/promises', () => makeFsMock(dbPath)); - vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: vi.fn(async () => ({ db, conn })), closeLbugConnection: vi.fn(async () => {}), @@ -626,7 +633,7 @@ describe('Symmetric WAL-size gate during missing-shadow recovery (PR #1747 D2)', const warnMock = vi.fn(); vi.doMock('fs/promises', () => fsMock); - vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: openLbugConnectionMock, closeLbugConnection: async (handle: { conn: typeof firstConn; db: typeof firstDb }) => { diff --git a/gitnexus/test/unit/rel-pair-routing.test.ts b/gitnexus/test/unit/rel-pair-routing.test.ts index 8355014d7..2dd89d969 100644 --- a/gitnexus/test/unit/rel-pair-routing.test.ts +++ b/gitnexus/test/unit/rel-pair-routing.test.ts @@ -3,7 +3,11 @@ import { EventEmitter } from 'events'; import fs from 'fs'; import path from 'path'; import os from 'os'; -import { RelPairRouter, getNodeLabel } from '../../src/core/lbug/rel-pair-routing.js'; +import { + RelPairRouter, + getNodeLabel, + parseRelationSchemaPairs, +} from '../../src/core/lbug/rel-pair-routing.js'; /** * Unit tests for RelPairRouter (#2203 U2) — the production per-pair emit path. @@ -65,6 +69,7 @@ class MockWriteStream extends EventEmitter { const HEADER = '"from","to","type","confidence","reason","step"'; const VALID = new Set(['File', 'Function', 'Community', 'Process']); +const DECLARED = new Set(['File|Function', 'Function|Function', 'Community|Community']); const row = (from: string, to: string, type = 'CALLS'): string => `"${from}","${to}","${type}",1.0,"auto",0`; @@ -97,10 +102,24 @@ describe('getNodeLabel', () => { }); }); +describe('parseRelationSchemaPairs', () => { + it('extracts plain and quoted FROM→TO labels for router validation', () => { + expect( + parseRelationSchemaPairs(` + CREATE REL TABLE CodeRelation( + FROM Class TO CodeElement, + FROM \`Enum\` TO \`TypeAlias\`, + type STRING + ) + `), + ).toEqual(new Set(['Class|CodeElement', 'Enum|TypeAlias'])); + }); +}); + describe('RelPairRouter', () => { it('routes valid edges to per-pair files (header first) and skips invalid-label edges', async () => { const streams: MockWriteStream[] = []; - const router = new RelPairRouter(tmpDir, HEADER, VALID, mockFactory(streams)); + const router = new RelPairRouter(tmpDir, HEADER, VALID, DECLARED, mockFactory(streams)); const route = async (from: string, to: string) => { const p = router.route(from, to, row(from, to)); @@ -122,12 +141,25 @@ describe('RelPairRouter', () => { expect(streams.every((s) => s.ended)).toBe(true); }); + it('rejects a valid-label pair that is absent from the relation schema', () => { + const streams: MockWriteStream[] = []; + const router = new RelPairRouter(tmpDir, HEADER, VALID, DECLARED, mockFactory(streams)); + + expect(() => router.route('File:a', 'Community:1', row('File:a', 'Community:1'))).toThrow( + 'File→Community is not declared in the LadybugDB relation schema', + ); + expect(streams).toHaveLength(0); + expect(router.skipped).toBe(0); + expect(router.total).toBe(0); + }); + it('returns a drain promise under backpressure and completes once unblocked', async () => { const streams: MockWriteStream[] = []; const router = new RelPairRouter( tmpDir, HEADER, VALID, + DECLARED, mockFactory(streams, { blocked: true }), ); @@ -143,7 +175,7 @@ describe('RelPairRouter', () => { it('on a stream error: route() throws the real error, lastError exposes it, close() rejects + destroys', async () => { const streams: MockWriteStream[] = []; - const router = new RelPairRouter(tmpDir, HEADER, VALID, mockFactory(streams)); + const router = new RelPairRouter(tmpDir, HEADER, VALID, DECLARED, mockFactory(streams)); const first = router.route('File:a', 'Function:a:f:1', row('File:a', 'Function:a:f:1')); if (first) await first; @@ -162,7 +194,7 @@ describe('RelPairRouter', () => { it('destroy() tears down every open pair stream', async () => { const streams: MockWriteStream[] = []; - const router = new RelPairRouter(tmpDir, HEADER, VALID, mockFactory(streams)); + const router = new RelPairRouter(tmpDir, HEADER, VALID, DECLARED, mockFactory(streams)); const a = router.route('File:a', 'Function:a:f:1', row('File:a', 'Function:a:f:1')); if (a) await a; diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index 4a29417c4..43e6cb7ba 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -208,6 +208,10 @@ describe('LadybugDB Schema', () => { expect(RELATION_SCHEMA).toContain('FROM BasicBlock TO BasicBlock'); }); + it('persists consumer Class injection edges to synthetic provider declarations', () => { + expect(RELATION_SCHEMA).toContain('FROM Class TO CodeElement'); + }); + it('has all FROM/TO pairs needed for HAS_METHOD edges', () => { // HAS_METHOD sources: Class, Interface, Struct, Trait, Impl, Record // HAS_METHOD targets: Method, Constructor (Property is now HAS_PROPERTY) diff --git a/gitnexus/test/unit/spring-annotation-arguments.test.ts b/gitnexus/test/unit/spring-annotation-arguments.test.ts new file mode 100644 index 000000000..9304aa4d3 --- /dev/null +++ b/gitnexus/test/unit/spring-annotation-arguments.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeSpringBeanType, + parseSpringAnnotationArguments, + parseStaticClassLiteral, + parseStaticStringValues, +} from '../../src/core/ingestion/frameworks/spring/annotation-arguments.js'; +import { + decodeSpringBeanFactoryReason, + encodeSpringBeanFactoryReason, + springBeanNames, +} from '../../src/core/ingestion/frameworks/spring/bean-factories.js'; +import { + springResourceDefaultName, + springResourceInjectionMatch, +} from '../../src/core/ingestion/frameworks/spring/resource-injection.js'; + +describe('Spring annotation static arguments', () => { + it('parses Java and Kotlin named arrays without splitting nested values', () => { + expect( + parseSpringAnnotationArguments('@Bean(name = {"one", "two"}, value = ["three"])'), + ).toEqual([ + { name: 'name', value: '{"one", "two"}' }, + { name: 'value', value: '["three"]' }, + ]); + expect(parseStaticStringValues('["one", "two"]')).toEqual(['one', 'two']); + expect(parseStaticStringValues('NAMES')).toBeNull(); + }); + + it('keeps generic call expressions intact while splitting top-level arguments', () => { + expect( + parseSpringAnnotationArguments( + '@Bean(factory = helper>(left, right), name = ["one", "two"])', + ), + ).toEqual([ + { name: 'factory', value: 'helper>(left, right)' }, + { name: 'name', value: '["one", "two"]' }, + ]); + }); + + it('rejects Kotlin templates and accepts Java/Kotlin class literals', () => { + expect(parseStaticStringValues('"bean-${suffix}"')).toBeNull(); + expect(parseStaticClassLiteral('Concrete.class')).toBe('Concrete'); + expect(parseStaticClassLiteral('Concrete::class')).toBe('Concrete'); + expect(parseStaticClassLiteral('Object.class')).toBe(''); + }); + + it('normalizes Kotlin nullability, mutable collections, projections, and bean generics', () => { + expect(normalizeSpringBeanType('MutableList?')).toBe('List'); + expect(normalizeSpringBeanType('com.example.Gateway')).toBe('com.example.Gateway'); + expect(normalizeSpringBeanType('outputStream')).toBe('outputStream'); + expect(normalizeSpringBeanType('inside.Type')).toBe('inside.Type'); + expect(normalizeSpringBeanType('Unit')).toBeNull(); + }); +}); + +describe('Spring Bean factory metadata', () => { + it('uses the method default and recognizes Java/Kotlin aliases', () => { + expect(springBeanNames('@Bean', 'gateway')).toEqual({ + names: ['gateway'], + namesKnown: true, + }); + expect(springBeanNames('@Bean({"primary", "alias"})', 'gateway')).toEqual({ + names: ['primary', 'alias'], + namesKnown: true, + }); + expect(springBeanNames('@Bean(name = ["primary", "alias"])', 'gateway')).toEqual({ + names: ['primary', 'alias'], + namesKnown: true, + }); + expect(springBeanNames('@Bean(name = "")', 'gateway')).toEqual({ + names: ['gateway'], + namesKnown: true, + }); + expect(springBeanNames('@Bean(name = NAMES)', 'gateway')).toEqual({ + names: [], + namesKnown: false, + }); + }); + + it('round-trips the persisted DECLARES reason', () => { + const reason = encodeSpringBeanFactoryReason({ + names: ['gateway', 'alias'], + namesKnown: true, + providedType: 'Gateway', + }); + expect(decodeSpringBeanFactoryReason(reason)).toEqual({ + framework: 'spring', + role: 'factory-method', + annotation: 'org.springframework.context.annotation.Bean', + names: ['gateway', 'alias'], + providedType: 'Gateway', + }); + }); +}); + +describe('Spring Resource semantics', () => { + it('derives field/property and JavaBeans setter names', () => { + expect(springResourceDefaultName('field', 'gateway', 1)).toBe('gateway'); + expect(springResourceDefaultName('method', 'setGateway', 1)).toBe('gateway'); + expect(springResourceDefaultName('method', 'setURL', 1)).toBe('URL'); + expect(springResourceDefaultName('method', 'configure', 1)).toBeNull(); + expect(springResourceDefaultName('method', 'setGateway', 2)).toBeNull(); + }); + + it('keeps explicit names strict and default names type-fallback capable', () => { + const explicit = springResourceInjectionMatch( + '@Resource(name = "slowGateway")', + 'gateway', + 'Gateway', + 'gateway', + ); + expect(explicit).toMatchObject({ + targetTypeName: 'Gateway', + cardinality: 'single', + namedSelection: { name: 'slowGateway' }, + }); + expect(explicit?.namedSelection).not.toHaveProperty('fallbackToType'); + expect( + springResourceInjectionMatch('@Resource', 'gateway', 'Gateway', 'gateway'), + ).toMatchObject({ + namedSelection: { name: 'gateway', fallbackToType: true }, + }); + }); + + it('does not type-fallback an implicit-name generic Resource site', () => { + const generic = springResourceInjectionMatch( + '@Resource', + 'handlers', + 'List', + 'handlers', + ); + expect(generic).toMatchObject({ + targetTypeName: 'List', + namedSelection: { name: 'handlers' }, + }); + expect(generic?.namedSelection).not.toHaveProperty('fallbackToType'); + }); + + it('honors static type overrides and rejects runtime-only metadata', () => { + expect( + springResourceInjectionMatch('@Resource(type = Concrete::class)', 'repo', 'Any', 'repo'), + ).toMatchObject({ targetTypeName: 'Concrete' }); + expect( + springResourceInjectionMatch( + '@Resource(lookup = "java:global/repo")', + 'repo', + 'Concrete', + 'repo', + ), + ).toBeNull(); + expect( + springResourceInjectionMatch( + '@Resource(mappedName = MAPPED_NAME)', + 'repo', + 'Concrete', + 'repo', + ), + ).toBeNull(); + }); +}); diff --git a/gitnexus/test/unit/spring-bean-schema.test.ts b/gitnexus/test/unit/spring-bean-schema.test.ts index b9f4dde83..07882b2eb 100644 --- a/gitnexus/test/unit/spring-bean-schema.test.ts +++ b/gitnexus/test/unit/spring-bean-schema.test.ts @@ -21,10 +21,10 @@ describe('Spring Bean Class persistence schema', () => { it('meets the cache-version baselines required by the merged implementation', () => { const parseSchemaVersion = Number.parseInt(PARSE_CACHE_VERSION, 10); - expect(parseSchemaVersion).toBeGreaterThanOrEqual(22); - expect(INCREMENTAL_SCHEMA_VERSION).toBeGreaterThanOrEqual(8); + expect(parseSchemaVersion).toBeGreaterThanOrEqual(31); + expect(INCREMENTAL_SCHEMA_VERSION).toBeGreaterThanOrEqual(23); expect(CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version).toBe(1); - expect(SPRING_BEAN_INVENTORY_FEATURE.version).toBe(1); + expect(SPRING_BEAN_INVENTORY_FEATURE.version).toBe(2); expect(SPRING_CONDITIONALS_FEATURE.version).toBe(1); }); From e8e572fbff2006afdf165c5f7f26680677938b68 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:34:16 +0100 Subject: [PATCH 04/17] chore(deps)(deps): bump i18next from 26.3.0 to 26.3.6 in /gitnexus-web (#2751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [i18next](https://github.com/i18next/i18next) from 26.3.0 to 26.3.6. - [Release notes](https://github.com/i18next/i18next/releases) - [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/i18next/compare/v26.3.0...v26.3.6) --- updated-dependencies: - dependency-name: i18next dependency-version: 26.3.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- gitnexus-web/package-lock.json | 10 +++++----- gitnexus-web/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 3ab270e90..a2c175aae 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -26,7 +26,7 @@ "graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-noverlap": "^0.4.2", "graphology-utils": "^2.3.0", - "i18next": "^26.3.0", + "i18next": "^26.3.6", "i18next-browser-languagedetector": "^8.2.1", "langchain": "^1.4.6", "lru-cache": "^11.5.2", @@ -4932,9 +4932,9 @@ } }, "node_modules/i18next": { - "version": "26.3.0", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz", - "integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==", + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", "funding": [ { "type": "individual", @@ -4951,7 +4951,7 @@ ], "license": "MIT", "peerDependencies": { - "typescript": "^5 || ^6" + "typescript": "^5 || ^6 || ^7" }, "peerDependenciesMeta": { "typescript": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 9468d38b1..bb5860bc2 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -36,7 +36,7 @@ "graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-noverlap": "^0.4.2", "graphology-utils": "^2.3.0", - "i18next": "^26.3.0", + "i18next": "^26.3.6", "i18next-browser-languagedetector": "^8.2.1", "langchain": "^1.4.6", "lru-cache": "^11.5.2", From 909f2f85b625b532555d5484fa2f07036631f72b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:34:35 +0100 Subject: [PATCH 05/17] chore(deps): bump the codeql-action group across 1 directory with 3 updates (#2755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the codeql-action group with 3 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action). Updates `github/codeql-action/init` from 4.37.0 to 4.37.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81) Updates `github/codeql-action/analyze` from 4.37.0 to 4.37.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81) Updates `github/codeql-action/upload-sarif` from 4.37.0 to 4.37.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81) --- updated-dependencies: - dependency-name: github/codeql-action/analyze dependency-version: 4.37.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: codeql-action - dependency-name: github/codeql-action/init dependency-version: 4.37.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: codeql-action - dependency-name: github/codeql-action/upload-sarif dependency-version: 4.37.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: codeql-action ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- .github/workflows/trivy.yml | 2 +- .github/workflows/workflow-lint.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a72adeed3..d47d25a78 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -48,7 +48,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: languages: ${{ matrix.language }} queries: security-and-quality @@ -73,6 +73,6 @@ jobs: - '**/test/**/fixtures/**' - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index fe39a284a..da7117d77 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -53,6 +53,6 @@ jobs: retention-days: 5 - name: Upload to Security tab - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: sarif_file: results.sarif diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 60d233553..ecb394c99 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -76,7 +76,7 @@ jobs: exit-code: '0' - name: Upload to Security tab - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: sarif_file: trivy-${{ matrix.image.name }}.sarif category: trivy-${{ matrix.image.name }} diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index ba61226f7..eb9e73f1d 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -76,7 +76,7 @@ jobs: continue-on-error: true - name: Upload SARIF - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: sarif_file: zizmor.sarif category: zizmor From e0dc0c2d5eeef0fa71f5688e9961f5566fb6dcf1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:34:51 +0100 Subject: [PATCH 06/17] chore(deps): bump release-drafter/release-drafter from 7.5.1 to 7.6.0 (#2756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.5.1 to 7.6.0. - [Release notes](https://github.com/release-drafter/release-drafter/releases) - [Commits](https://github.com/release-drafter/release-drafter/compare/4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e...eada3c96a64734dd381cfbda23511034e328ddb0) --- updated-dependencies: - dependency-name: release-drafter/release-drafter dependency-version: 7.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- .github/workflows/pr-labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index a55c7046f..55e3bcc9e 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -108,7 +108,7 @@ jobs: # Pinned to v7.2.0. Verify SHA via: # gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0 # v7 removed `disable-releaser`; use `dry-run: true` to only autolabel. - - uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1 + - uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0 with: config-name: release-drafter.yml dry-run: true From d99d828a52d5b29c6f2f5cc334904c5e539c51b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:42:37 +0100 Subject: [PATCH 07/17] chore(deps)(deps): bump express-rate-limit in /gitnexus (#2764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) from 8.6.0 to 8.6.1. - [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases) - [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.6.0...v8.6.1) --- updated-dependencies: - dependency-name: express-rate-limit dependency-version: 8.6.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- gitnexus/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index fec56fb23..6ecb49d33 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -3006,9 +3006,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", - "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", "license": "MIT", "dependencies": { "debug": "^4.4.3", From 454d3834166e18478eda3f2b3d08025646b89b42 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Fri, 31 Jul 2026 11:58:47 +0100 Subject: [PATCH 08/17] fix(ingestion): join multi-line closure bindings on initializer startLine (#2735) (#2762) * fix(ingestion): join multi-line closure bindings on initializer startLine Graph-node captures sit on the outer binding wrapper while scope-resolution anchors on the inner callable; the line-only position join missed when those split across lines and fail-closed dropped the real CALLS edge (#2735). * fix(ingestion): unwrap Ruby call+block for multi-line lambda joins Cover Kotlin/Ruby/Dart multi-line closure CALLS in integration tests, and dig Ruby's call/block field so do-end bindings join on the block start line. * style(ingestion): format closure join changes * fix(ingestion): make closure position join language agnostic --- .../scope-resolution/graph-bridge/ids.ts | 21 ++-- .../src/core/ingestion/workers/callable-id.ts | 70 +++++++++++ .../core/ingestion/workers/parse-worker.ts | 34 +++-- gitnexus/src/storage/parse-cache.ts | 5 +- gitnexus/src/storage/repo-manager.ts | 7 +- .../closure-review-findings.test.ts | 119 ++++++++++++++++-- .../unit/call-summary-schema-version.test.ts | 9 +- .../test/unit/callable-id-lockstep.test.ts | 99 ++++++++++++++- .../test/unit/incremental-parse-cache.test.ts | 11 +- 9 files changed, 336 insertions(+), 39 deletions(-) diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index c4e1383c5..abe8b48e4 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -218,6 +218,11 @@ export function resolveDefGraphId( // without either side having to model the scope chain. Node ids are // 0-based, def ids 1-based. An `AMBIGUOUS_POSITION` tombstone (two // callables on one line) falls through to the name-based keys below. + // + // For a closure binding the two query channels still anchor on different + // AST nodes (outer wrapper vs inner callable), but the graph node's + // `startLine` follows the initializer (#2735) so this join matches even + // when the binding is split across lines. const line = defStartLine(def.nodeId, filePath); if (line !== undefined && isPositionQualifiedLocalLabel(def.type)) { const simple = simpleNameOf(qn); @@ -226,13 +231,15 @@ export function resolveDefGraphId( // FAIL CLOSED when a function-local of this name exists in the file (#2699 // follow-up). Falling through to the name keys would end at the label-agnostic, // first-write-wins `simpleKey` below and alias this def onto whichever same-named - // callable was registered first — reproducibly minting a FALSE edge for a - // multiline `const pick =` (the declaration and its initializer land on different - // lines, so the position join misses). A missing edge is the correct failure - // direction for a graph whose consumers include `impact`; a fabricated caller is - // not. Gated on `localNameKey` so this ONLY fires where the collision is real — - // a file with no such local keeps its previous fallback behaviour, which is what - // preserves legitimate anchor differences such as a Vue SFC's `lineOffset`. + // callable was registered first — reproducibly minting a FALSE edge. A missing + // edge is the correct failure direction for a graph whose consumers include + // `impact`; a fabricated caller is not. Gated on `localNameKey` so this ONLY + // fires where the collision is real — a file with no such local keeps its + // previous fallback behaviour, which is what preserves legitimate anchor + // differences such as a Vue SFC's `lineOffset`. + // + // Multi-line closure bindings are NOT this case anymore (#2735): their graph + // `startLine` follows the initializer, so the position key above hits. if (nodeLookup.get(localNameKey(filePath, def.type, simple)) !== undefined) { return undefined; } diff --git a/gitnexus/src/core/ingestion/workers/callable-id.ts b/gitnexus/src/core/ingestion/workers/callable-id.ts index 75d6bfcdd..cb8cea942 100644 --- a/gitnexus/src/core/ingestion/workers/callable-id.ts +++ b/gitnexus/src/core/ingestion/workers/callable-id.ts @@ -19,8 +19,78 @@ * in here is derived, and why only genuinely nested callables get one. */ +import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared'; import type { SyntaxNode } from '../utils/ast-helpers.js'; +import { definitionIdPosition } from '../scope-resolution/utils/definition-id.js'; +const LOCAL_IDENTITY_SUFFIX = /@\d+:\d+$/; + +function simpleDefinitionName(def: SymbolDefinition): string | undefined { + const qualifiedName = def.qualifiedName; + if (qualifiedName === undefined) return undefined; + const dot = qualifiedName.lastIndexOf('.'); + const tail = dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1); + return tail.replace(LOCAL_IDENTITY_SUFFIX, ''); +} + +function containsPosition(node: SyntaxNode, row: number, column: number): boolean { + const start = node.startPosition; + const end = node.endPosition; + if (row < start.row || row > end.row) return false; + if (row === start.row && column < start.column) return false; + if (row === end.row && column > end.column) return false; + return true; +} + +/** + * Zero-based start row that keys the graph-to-scope position join for a bound + * callable (#2735). + * + * Graph-node queries may anchor on an outer binding wrapper while the scope + * channel anchors on the inner callable. The join is line-only, so a multi-line + * binding needs the graph node's `startLine` to follow the semantic definition. + * + * `ParsedFile.localDefs` is the language-agnostic source of that position. + * Matching uses only the canonical label, name, and source range; shared worker + * code does not need to know grammar node types or initializer field names. + * + * Node ids stay on the binding wrapper via `localIdentity(definitionNode)`. + * Missing or ambiguous semantic matches retain the wrapper row, preserving the + * existing fail-closed behavior. + */ +export function boundCallableStartRow( + definitionNode: SyntaxNode, + nodeName: string, + nodeLabel: NodeLabel, + localDefs: readonly SymbolDefinition[] | undefined, + nameNode?: SyntaxNode | null, +): number { + if (localDefs === undefined) return definitionNode.startPosition.row; + + const origin = nameNode?.startPosition ?? definitionNode.startPosition; + let best: { row: number; distance: number } | undefined; + let tied = false; + + for (const def of localDefs) { + if (def.type !== nodeLabel || simpleDefinitionName(def) !== nodeName) continue; + const position = definitionIdPosition(def.nodeId, def.filePath); + if (position === undefined) continue; + + const row = position.line - 1; + if (!containsPosition(definitionNode, row, position.column)) continue; + + const distance = + Math.abs(row - origin.row) * 1_000_000 + Math.abs(position.column - origin.column); + if (best === undefined || distance < best.distance) { + best = { row, distance }; + tied = false; + } else if (distance === best.distance && row !== best.row) { + tied = true; + } + } + + return best !== undefined && !tied ? best.row : definitionNode.startPosition.row; +} /** * A function-local callable's own name segment: its name plus its declaration * position. diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index f467ef852..1914b0101 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -1,5 +1,9 @@ import { parentPort, threadId, workerData } from 'node:worker_threads'; -import { localIdentity, nestedCallableQualifiedName } from './callable-id.js'; +import { + boundCallableStartRow, + localIdentity, + nestedCallableQualifiedName, +} from './callable-id.js'; import Parser from 'tree-sitter'; import JavaScript from 'tree-sitter-javascript'; import TypeScript from 'tree-sitter-typescript'; @@ -2240,11 +2244,27 @@ const processFileGroup = ( } } - const startLine = definitionNode - ? definitionNode.startPosition.row + lineOffset - : nameNode - ? nameNode.startPosition.row + lineOffset - : lineOffset; + // #2735: for a bound callable the graph-node capture sits on the OUTER + // wrapper while scope-resolution anchors on the INNER expression. The + // position join is line-only, so `startLine` must follow the initializer + // (ids still use `definitionNode` via `localIdentity`). + const startRow = + definitionNode && + (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') + ? boundCallableStartRow( + definitionNode, + nodeName, + nodeLabel, + parsedFile?.localDefs, + nameNode, + ) + : definitionNode?.startPosition.row; + const startLine = + startRow !== undefined + ? startRow + lineOffset + : nameNode + ? nameNode.startPosition.row + lineOffset + : lineOffset; // Compute enclosing class BEFORE node ID — needed to qualify method IDs const needsOwner = @@ -2750,7 +2770,7 @@ const processFileGroup = ( properties: { name: nodeName, filePath: file.path, - startLine: definitionNode ? definitionNode.startPosition.row + lineOffset : startLine, + startLine, endLine: definitionNode ? definitionNode.endPosition.row + lineOffset : startLine, language: language, isExported: diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 1f18d2910..40a89f0f7 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -143,7 +143,10 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // JLS 13.1 immediate-host chains (#2555). // v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity. // v16: direct callee identity. -const SCHEMA_BUMP = 35; +// v36: bound-callable graph `startLine` follows the initializer so multi-line +// closure bindings join the scope channel (#2735). Warm cache would otherwise +// keep serving wrapper-line startLines and drop the CALLS edge. +const SCHEMA_BUMP = 36; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index f3d4df922..1f1808f66 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -631,8 +631,13 @@ export interface RepoMeta { * `#[cfg(test)] mod tests` makes that close to every Rust repo — so a pre-v25 * index holds ids an incremental top-up cannot reconcile and would simply * strand. Force a full re-analyze. + * + * v30: bound-callable graph `startLine` follows the initializer (#2735), so a + * multi-line closure binding joins the scope channel and emits its CALLS edge. + * Pre-v30 indexes keep the wrapper line on unchanged files and would keep + * failing closed (no edge) through the reuse gate. Force a full re-analyze. */ -export const INCREMENTAL_SCHEMA_VERSION = 29; +export const INCREMENTAL_SCHEMA_VERSION = 30; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/integration/closure-review-findings.test.ts b/gitnexus/test/integration/closure-review-findings.test.ts index 8879f7e57..0ed979afe 100644 --- a/gitnexus/test/integration/closure-review-findings.test.ts +++ b/gitnexus/test/integration/closure-review-findings.test.ts @@ -64,19 +64,14 @@ const nodeIdsContaining = async ( }; describeIfWorkerBuilt( - '#2699 review P1-1 — a closure that cannot be named never credits its parent', + '#2735 — a multi-line closure binding is a call SOURCE (not merely fail-closed)', () => { - it('a MULTI-LINE closure binding does not fabricate a call from the enclosing function', async () => { - // The two channels anchor on DIFFERENT nodes by design — graph-node on the - // outer wrapper, scope-resolution on the inner closure. On one line they - // share a row and the position join matches. Split across lines it misses, - // and before the fix `resolveCallerGraphId` CLIMBED to the enclosing scope, - // emitting `outer -> target` although `outer` calls nothing. That is a CALLS - // edge present nowhere in the source — the exact defect class #2699 exists - // to remove — so the bridge now fails closed at the owning callable. - // - // The single-line binding in the same fixture proves the fail-closed path - // did not simply delete the feature. + it('PHP: both single-line and multi-line bindings emit CALLS to target', async () => { + // Graph-node queries anchor `@definition.function` on the OUTER assignment; + // scope-resolution anchors `@declaration.function` on the INNER closure. + // #2699 made a miss fail closed (no fabricated `outer -> target`). #2735 + // makes the join hit by putting the graph node's `startLine` on the + // initializer, so the real `outer.$multi -> target` edge appears. const edges = await callEdges( 'ml.php', ' Function:ml.php:target']); + expect(edges).toEqual([ + 'Function:ml.php:outer.$multi@4:2 -> Function:ml.php:target', + 'Function:ml.php:outer.$single@3:2 -> Function:ml.php:target', + ]); + }); + + it('Rust: a wrapped closure binding emits CALLS to target', async () => { + const edges = await callEdges( + 'ml.rs', + 'fn target(x: i32) -> i32 { x }\nfn outer() -> i32 {\n' + + ' let handler =\n || target(1);\n handler()\n}\n', + ); + + expect(edges).toEqual([ + 'Function:ml.rs:outer -> Function:ml.rs:outer.handler@2:4', + 'Function:ml.rs:outer.handler@2:4 -> Function:ml.rs:target', + ]); + }); + + it('TypeScript: a multi-line const arrow binding emits CALLS to target', async () => { + const edges = await callEdges( + 'ml.ts', + 'function target(x: number): number { return x; }\nfunction outer(): number {\n' + + ' const single = (x: number) => target(x);\n' + + ' const multi =\n (x: number) => target(x);\n return single(1) + multi(2);\n}\n', + ); + + expect(edges).toEqual([ + 'Function:ml.ts:outer -> Function:ml.ts:outer.multi@3:2', + 'Function:ml.ts:outer -> Function:ml.ts:outer.single@2:2', + 'Function:ml.ts:outer.multi@3:2 -> Function:ml.ts:target', + 'Function:ml.ts:outer.single@2:2 -> Function:ml.ts:target', + ]); + }); + + it('Kotlin: a multi-line val lambda binding emits CALLS to target', async () => { + const edges = await callEdges( + 'ml.kt', + 'fun target(x: Int): Int = x\nfun outer(): Int {\n' + + ' val single = { x: Int -> target(x) }\n' + + ' val multi =\n { x: Int -> target(x) }\n return 1\n}\n', + ); + + expect(edges.some((e) => e.includes('multi') && e.endsWith('-> Function:ml.kt:target'))).toBe( + true, + ); + expect( + edges.some((e) => e.startsWith('Function:ml.kt:outer ->') && e.endsWith('target')), + ).toBe(false); + }); + + it('Ruby: a multi-line lambda do-end binding emits CALLS to target', async () => { + const edges = await callEdges( + 'ml.rb', + 'def target(x)\n x\nend\ndef outer\n' + + ' a = ->(x) { target(x) }\n' + + ' b =\n lambda do |y|\n target(y)\n end\nend\n', + ); + + expect(edges.some((e) => e.includes('.b@') && e.endsWith('-> Method:ml.rb:target#1'))).toBe( + true, + ); + expect(edges.some((e) => e.startsWith('Method:ml.rb:outer#0 ->'))).toBe(false); + }); + + it('Dart: a multi-line var closure binding emits CALLS to target', async () => { + const edges = await callEdges( + 'ml.dart', + 'int target(int x) => x;\nint outer() {\n' + + ' var single = (int x) => target(x);\n' + + ' var multi =\n (int x) => target(x);\n return 1;\n}\n', + ); + + expect( + edges.some((e) => e.includes('multi') && e.endsWith('-> Function:ml.dart:target')), + ).toBe(true); + expect( + edges.some((e) => e.startsWith('Function:ml.dart:outer ->') && e.endsWith('target')), + ).toBe(false); + }); + }, +); + +describeIfWorkerBuilt( + '#2699 review P1-1 — a closure that cannot be named never credits its parent', + () => { + it('a MULTI-LINE closure binding does not fabricate a call from the enclosing function', async () => { + // Retained as the fail-closed half of #2735: even when the join works, + // `outer` itself must not grow a CALLS edge to `target` — only the + // binding nodes do. + const edges = await callEdges( + 'ml.php', + ' e.startsWith('Function:ml.php:outer ->'))).toBe(false); + expect(edges).toContain('Function:ml.php:outer.$multi@4:2 -> Function:ml.php:target'); }); }, ); diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index d0944f8cc..146239be3 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 29 (Spring Bean relation schema, #2413)', () => { + it('INCREMENTAL_SCHEMA_VERSION is bumped to 30 (multi-line closure startLine join, #2735)', () => { // Moves with every bump BY DESIGN — that is the point of pinning it. A // change that alters emitted ids or edges without bumping would otherwise // ship silently, and an existing index would keep serving the old graph // through the reuse gate below. - expect(INCREMENTAL_SCHEMA_VERSION).toBe(29); + expect(INCREMENTAL_SCHEMA_VERSION).toBe(30); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { @@ -202,7 +202,10 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // so Spring @Bean injection edges (#2413) would be dropped during // persistence → must NOT reuse. expect(passesReuseGate(28)).toBe(false); + // A pre-v30 (v29) index keeps wrapper-line startLines for multi-line closure + // bindings (#2735), so the graph-to-scope join still drops the CALLS edge. + expect(passesReuseGate(29)).toBe(false); // The current stamp passes the gate (incremental top-up eligible). - expect(passesReuseGate(29)).toBe(true); + expect(passesReuseGate(30)).toBe(true); }); }); diff --git a/gitnexus/test/unit/callable-id-lockstep.test.ts b/gitnexus/test/unit/callable-id-lockstep.test.ts index cf7726f6b..5555286e6 100644 --- a/gitnexus/test/unit/callable-id-lockstep.test.ts +++ b/gitnexus/test/unit/callable-id-lockstep.test.ts @@ -26,12 +26,109 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { nestedCallableQualifiedName } from '../../src/core/ingestion/workers/callable-id.js'; +import { + boundCallableStartRow, + nestedCallableQualifiedName, +} from '../../src/core/ingestion/workers/callable-id.js'; +import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared'; import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; const nodeAt = (row: number, column: number): SyntaxNode => ({ startPosition: { row, column } }) as unknown as SyntaxNode; +function stubNode(opts: { + type: string; + id: number; + row?: number; + column?: number; + endRow?: number; + endColumn?: number; +}): SyntaxNode { + return { + type: opts.type, + id: opts.id, + startPosition: { row: opts.row ?? 0, column: opts.column ?? 0 }, + endPosition: { + row: opts.endRow ?? opts.row ?? 0, + column: opts.endColumn ?? opts.column ?? 0, + }, + } as unknown as SyntaxNode; +} + +const semanticDef = ( + filePath: string, + type: NodeLabel, + name: string, + line: number, + column: number, +): SymbolDefinition => ({ + nodeId: `def:${filePath}#${line}:${column}:${type}:${name}`, + filePath, + type, + qualifiedName: name, +}); + +describe('boundCallableStartRow - #2735 semantic position join', () => { + it('keeps the wrapper row when no semantic definition is available', () => { + const wrapper = stubNode({ + type: 'binding_wrapper', + id: 1, + row: 2, + column: 0, + endRow: 4, + endColumn: 1, + }); + + expect(boundCallableStartRow(wrapper, 'handler', 'Function', undefined)).toBe(2); + }); + + it('uses the matching semantic callable position inside the wrapper', () => { + const wrapper = stubNode({ + type: 'binding_wrapper', + id: 1, + row: 1, + column: 0, + endRow: 5, + endColumn: 10, + }); + const name = stubNode({ type: 'binding_name', id: 2, row: 1, column: 4 }); + const defs = [semanticDef('src/file.ext', 'Function', 'handler', 4, 8)]; + + expect(boundCallableStartRow(wrapper, 'handler', 'Function', defs, name)).toBe(3); + }); + + it('selects by canonical name and label rather than grammar shape', () => { + const wrapper = stubNode({ + type: 'binding_wrapper', + id: 1, + row: 1, + column: 0, + endRow: 8, + endColumn: 10, + }); + const defs = [ + semanticDef('src/file.ext', 'Function', 'sibling', 3, 4), + semanticDef('src/file.ext', 'Method', 'handler', 4, 4), + semanticDef('src/file.ext', 'Function', 'handler', 6, 4), + ]; + + expect(boundCallableStartRow(wrapper, 'handler', 'Function', defs)).toBe(5); + }); + + it('ignores a same-named semantic definition outside the wrapper', () => { + const wrapper = stubNode({ + type: 'binding_wrapper', + id: 1, + row: 4, + column: 0, + endRow: 6, + endColumn: 10, + }); + const defs = [semanticDef('src/file.ext', 'Function', 'handler', 2, 0)]; + + expect(boundCallableStartRow(wrapper, 'handler', 'Function', defs)).toBe(4); + }); +}); describe('nestedCallableQualifiedName — the shared nested-callable id rule', () => { it('qualifies by the enclosing callable AND the declaration position', () => { expect(nestedCallableQualifiedName('run', nodeAt(3, 2), 'save')).toBe('run.save@3:2'); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 3b2d1dbf0..c9560f43d 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -101,12 +101,11 @@ describe('fileContentHash', () => { }); describe('PARSE_CACHE_VERSION', () => { - // 34 -> 35 for the Spring @Bean/@Resource side-channel captures (#2413). - // Updated deliberately — this branch cut at 32 while `main` independently took - // 32 (#2742) and 33/34 (#2747), so the pin caught the collision again at merge - // time. Re-check against origin/main before merging, not at branch time. - it('pins SCHEMA_BUMP to 35 so concurrent bumps cannot silently collide (#2736)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(35); + // 35 -> 36 for the bound-callable start-line join (#2735). Updated + // deliberately after main independently took 35 for Spring side-channel + // captures (#2413), so the pin continues to catch concurrent bump collisions. + it('pins SCHEMA_BUMP to 36 so concurrent bumps cannot silently collide (#2736)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(36); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { From 51095c19f8a7c641a136549711065f816a383ff6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:05:18 +0100 Subject: [PATCH 09/17] chore(deps): bump actions/setup-python from 6.3.0 to 7.0.0 (#2757) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.3.0 to 7.0.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/ece7cb06caefa5fff74198d8649806c4678c61a1...5fda3b95a4ea91299a34e894583c3862153e4b97) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-tree-sitter-prebuilds.yml | 2 +- .github/workflows/triage-sweep.yml | 2 +- .github/workflows/workflow-lint.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-tree-sitter-prebuilds.yml b/.github/workflows/build-tree-sitter-prebuilds.yml index b2555b521..06e2b4c9f 100644 --- a/.github/workflows/build-tree-sitter-prebuilds.yml +++ b/.github/workflows/build-tree-sitter-prebuilds.yml @@ -358,7 +358,7 @@ jobs: - name: Ensure Python (arm64 Windows only) if: matrix.platform_arch == 'win32-arm64' - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' diff --git a/.github/workflows/triage-sweep.yml b/.github/workflows/triage-sweep.yml index cab0f9a73..4f39f94f2 100644 --- a/.github/workflows/triage-sweep.yml +++ b/.github/workflows/triage-sweep.yml @@ -66,7 +66,7 @@ jobs: fetch-depth: 1 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' cache: pip diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index eb9e73f1d..e013a0bf6 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -58,7 +58,7 @@ jobs: persist-credentials: false - name: Setup Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' From 84f584449de02376a8ffc096dceac2e8f732cab5 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Sat, 1 Aug 2026 06:02:47 +0100 Subject: [PATCH 10/17] fix(python): resolve classes through module imports (#2770) --- ARCHITECTURE.md | 1 + .../scope-resolution/finalize-algorithm.ts | 17 ++- gitnexus-shared/src/scope-resolution/types.ts | 5 + .../python-scope/baseline-fingerprint.txt | 2 +- gitnexus/bench/python-scope/measure.mjs | 3 + .../core/ingestion/finalize-orchestrator.ts | 1 + .../languages/python/arity-metadata.ts | 2 + .../ingestion/languages/python/captures.ts | 8 ++ .../languages/python/import-target.ts | 100 +++++++++++++++- .../core/ingestion/languages/python/index.ts | 6 +- .../languages/python/scope-resolver.ts | 14 ++- .../src/core/ingestion/scope-extractor.ts | 22 ++++ .../contract/scope-resolver.ts | 11 ++ .../passes/compound-receiver.ts | 86 ++++++++++++-- .../passes/receiver-bound-calls.ts | 9 +- .../scope-resolution/pipeline/run.ts | 2 + .../scope/namespace-targets.ts | 14 +-- gitnexus/src/storage/repo-manager.ts | 7 +- .../python-from-module-alias/decoy/models.py | 3 + .../python-from-module-alias/pkg/__init__.py | 0 .../python-from-module-alias/pkg/app.py | 15 +++ .../python-from-module-alias/pkg/models.py | 6 + .../expected-captures.json | 16 +++ .../test/integration/resolvers/python.test.ts | 38 ++++++ .../unit/call-summary-schema-version.test.ts | 9 +- .../finalize-orchestrator.test.ts | 28 +++++ .../python/python-captures-golden.test.ts | 4 + .../python-import-target-parity.test.ts | 71 ++++++++++- ...thon-module-namespace-construction.test.ts | 110 ++++++++++++++++++ 29 files changed, 575 insertions(+), 35 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/python-from-module-alias/decoy/models.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/__init__.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/app.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/models.py create mode 100644 gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5913b17b0..86ad6c426 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -247,6 +247,7 @@ Single interface a language implements to plug into the pipeline. Contract fully | `populateOwners(parsed)` | Fill deferred `ownerId` fields on method defs (captures can't always know the owning class at parse time) | | `buildMro(graph, parsed, nodeLookup)` | Produce `mroByClassDefId: Map` — C3, Ruby-mixin, or first-wins per language | | `resolveImportTarget(target, fromFile, allFiles)` | `(rawImportPath, sourceFile) → targetFilePath` (PEP-328 for Python, etc.) | +| `isNamespaceImport(parsedImport, targetFile, fromFile)` | Optionally reclassify a resolved named import as a namespace handle when the imported symbol is itself a module | | `mergeBindings(existing, incoming, scopeId)` | Shadowing / LEGB precedence | | `arityCompatibility` | Provider consumed by registry during `MethodRegistry.lookup` Step 2 | | `importEdgeReason` | Confidence-tier string for IMPORTS edge reason field | diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index f381bb12e..e50337af3 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -96,6 +96,16 @@ export interface FinalizeHooks { parsedImport?: ParsedImport, ): string | readonly string[] | null; + /** + * Reclassify syntax that names an imported symbol as a namespace import + * after target resolution proves the symbol is itself a module. + */ + readonly isNamespaceImport?: ( + parsedImport: ParsedImport, + targetFile: string, + fromFile: string, + ) => boolean; + /** * For a wildcard `import * from M`, return the names visible in the * exporting module scope `M`. The finalize pass looks each name up in @@ -389,7 +399,10 @@ function makeEdgeDrafts( localName: extractLocalName(parsed), targetFile: tf, targetExportedName: extractExportedName(parsed), - kind: edgeKindFor(parsed), + kind: + hooks.isNamespaceImport?.(parsed, tf, file.filePath) === true + ? 'namespace' + : edgeKindFor(parsed), }; return { source: parsed, @@ -461,7 +474,7 @@ function tryFinalize( // languages emit a synthetic module-def), pick it up as the `targetDefId` // so consumers can reach the module as a symbol — but its absence is not // a failure. - if (draft.source.kind === 'namespace') { + if (draft.base.kind === 'namespace') { const moduleDef = findExportByName(targetModule.localDefs, extractExportedName(draft.source)); return { ...draft.base, diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 180cb74a6..dbefe60d7 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -415,6 +415,11 @@ export interface Scope { /** Local type facts visible from this scope (parameter annotations, `self` binding, etc.). */ readonly typeBindings: ReadonlyMap; + /** Lexically bound names that may have no definition or type fact of their + * own (for example, an untyped function parameter). Consumers use this only + * as a shadowing barrier; it never resolves a symbol by itself. */ + readonly lexicalNames?: ReadonlySet; + /** Receiver names this scope BINDS rather than inherits — `this`, `self`, … (#2701). * * A receiver walk (`findReceiverTypeBinding`) that reaches such a scope diff --git a/gitnexus/bench/python-scope/baseline-fingerprint.txt b/gitnexus/bench/python-scope/baseline-fingerprint.txt index 2c682ce69..b27ff7a41 100644 --- a/gitnexus/bench/python-scope/baseline-fingerprint.txt +++ b/gitnexus/bench/python-scope/baseline-fingerprint.txt @@ -1 +1 @@ -ec879302c3257418edb52c3bc2c1ac0e43d40e742319c493218b7d0ff3466483 +b57c5479f158c41a6328fa8d61c234aaec88fc41780252d2fbd7baca21491aa2 diff --git a/gitnexus/bench/python-scope/measure.mjs b/gitnexus/bench/python-scope/measure.mjs index 1ecd43b16..7254c3543 100644 --- a/gitnexus/bench/python-scope/measure.mjs +++ b/gitnexus/bench/python-scope/measure.mjs @@ -42,6 +42,9 @@ const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'la function canonicalizeMatch(match) { const parts = []; for (const tag of Object.keys(match)) { + // Scope-only lexical shadow metadata is correctness-tested separately and + // does not alter capture matching or the benchmark's scaling contract. + if (tag === '@scope.lexical-names') continue; const cap = match[tag]; const r = cap.range; parts.push(`${tag}|${cap.text}|${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`); diff --git a/gitnexus/src/core/ingestion/finalize-orchestrator.ts b/gitnexus/src/core/ingestion/finalize-orchestrator.ts index 02717a0b9..3146aff26 100644 --- a/gitnexus/src/core/ingestion/finalize-orchestrator.ts +++ b/gitnexus/src/core/ingestion/finalize-orchestrator.ts @@ -201,6 +201,7 @@ function collectReferenceSites(parsedFiles: readonly ParsedFile[]) { function withDefaultHooks(partial: Partial): FinalizeHooks { return { resolveImportTarget: partial.resolveImportTarget ?? (() => null), + isNamespaceImport: partial.isNamespaceImport, expandsWildcardTo: partial.expandsWildcardTo ?? (() => []), mergeBindings: partial.mergeBindings ?? diff --git a/gitnexus/src/core/ingestion/languages/python/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/python/arity-metadata.ts index 87e53f4f4..d4da7dfd7 100644 --- a/gitnexus/src/core/ingestion/languages/python/arity-metadata.ts +++ b/gitnexus/src/core/ingestion/languages/python/arity-metadata.ts @@ -22,6 +22,7 @@ interface PythonArityMetadata { readonly parameterCount: number | undefined; readonly requiredParameterCount: number | undefined; readonly parameterTypes: readonly string[] | undefined; + readonly parameterNames: readonly string[]; } export function computePythonArityMetadata(fnNode: SyntaxNode): PythonArityMetadata { @@ -50,5 +51,6 @@ export function computePythonArityMetadata(fnNode: SyntaxNode): PythonArityMetad parameterCount, requiredParameterCount, parameterTypes: types.length > 0 ? types : undefined, + parameterNames: params.map((parameter) => parameter.name), }; } diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index 0b0e98115..a6d19fdc4 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -146,6 +146,14 @@ export function emitPythonScopeCaptures( const scopeNode = nodeMap['@scope.function']!; const fnNode = scopeNode.type === 'function_definition' ? scopeNode : null; if (fnNode !== null) { + const parameterNames = computePythonArityMetadata(fnNode).parameterNames; + if (parameterNames.length > 0) { + grouped['@scope.lexical-names'] = syntheticCapture( + '@scope.lexical-names', + fnNode, + JSON.stringify(parameterNames), + ); + } const synth = synthesizeReceiverTypeBinding(fnNode); if (synth !== null) out.push(synth); out.push(...synthesizeConstructorFieldTypeBindings(fnNode)); diff --git a/gitnexus/src/core/ingestion/languages/python/import-target.ts b/gitnexus/src/core/ingestion/languages/python/import-target.ts index 0899353fc..d30823b92 100644 --- a/gitnexus/src/core/ingestion/languages/python/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/python/import-target.ts @@ -10,7 +10,7 @@ * `linkStatus: 'unresolved'`. */ -import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import { resolvePythonImportInternal } from '../../import-resolvers/python.js'; import { recordPythonFileIndexBuild } from './index-stats.js'; @@ -20,6 +20,9 @@ export interface PythonResolveContext { * through to `getPythonFileIndex`'s `WeakMap` key (built once per run, not * copied per import). The whole resolver chain only reads the set. */ readonly allFilePaths: ReadonlySet; + /** Optional parsed workspace used to preserve a package's explicit export + * when it collides with a same-named concrete submodule. */ + readonly parsedFiles?: readonly ParsedFile[]; } export function resolvePythonImportTarget( @@ -48,6 +51,40 @@ export function resolvePythonImportTarget( if (parsedImport.kind === 'dynamic-unresolved') return null; if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null; + const submoduleTarget = pythonImportedSubmoduleTarget(parsedImport); + if ( + submoduleTarget !== null && + (parsedImport.kind === 'named' || parsedImport.kind === 'alias') + ) { + // Python's IMPORT_FROM first reads an attribute already exported by the + // package and only loads a same-named submodule when that attribute is + // absent. Preserve that precedence when parsed workspace facts are + // available; the flag suppresses this submodule probe in the recursive + // base-package lookup. + const packageTarget = resolvePythonImportTarget( + { ...parsedImport, targetIncludesImportedName: true }, + workspaceIndex, + ); + if ( + packageTarget !== null && + pythonFileExportsName(packageTarget, parsedImport.importedName, ctx.parsedFiles) + ) { + return packageTarget; + } + + const submodule = resolvePythonImportTarget( + { + kind: 'namespace', + localName: parsedImport.localName, + importedName: parsedImport.importedName, + targetRaw: submoduleTarget, + }, + workspaceIndex, + ); + if (submodule !== null) return submodule; + if (packageTarget !== null) return packageTarget; + } + // PEP-328 relative + single-segment proximity bare imports. const internal = resolvePythonImportInternal( ctx.fromFile, @@ -85,6 +122,22 @@ export function resolvePythonImportTarget( return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths, ctx.fromFile); } +function pythonFileExportsName( + targetFile: string, + importedName: string, + parsedFiles: readonly ParsedFile[] | undefined, +): boolean { + if (parsedFiles === undefined) return false; + const parsed = parsedFiles.find((file) => file.filePath === targetFile); + if (parsed === undefined) return false; + return parsed.localDefs.some((def) => { + const qualifiedName = def.qualifiedName; + if (qualifiedName === undefined || qualifiedName.length === 0) return false; + const dot = qualifiedName.lastIndexOf('.'); + return (dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1)) === importedName; + }); +} + /** * Resolve `package/sub/module` style paths (already dot-flattened) to a * concrete file in `allFilePaths`. Tries the exact path first, then walks @@ -341,3 +394,48 @@ function getPythonFileIndex(allFilePaths: ReadonlySet): PythonFileIndex PYTHON_FILE_INDEX_CACHE.set(allFilePaths, index); return index; } + +function pythonImportedSubmoduleTarget(parsedImport: ParsedImport): string | null { + if (parsedImport.kind !== 'named' && parsedImport.kind !== 'alias') return null; + if (parsedImport.targetIncludesImportedName === true) return null; + const separator = parsedImport.targetRaw.endsWith('.') ? '' : '.'; + return parsedImport.targetRaw + separator + parsedImport.importedName; +} + +/** + * A named Python import is a namespace handle only when its resolved file is + * the concrete submodule formed by appending the imported name. This keeps + * ordinary symbol imports on the named-binding path. + */ +export function isPythonImportedModule( + parsedImport: ParsedImport, + targetFile: string, + fromFile: string, +): boolean { + const submoduleTarget = pythonImportedSubmoduleTarget(parsedImport); + if (submoduleTarget === null) return false; + + const normalizedTarget = targetFile.replace(/\\/g, '/'); + let pathLike: string; + + if (submoduleTarget.startsWith('.')) { + const match = submoduleTarget.match(/^(\.+)(.*)$/); + if (match === null) return false; + const ascend = match[1].length - 1; + const base = fromFile.replace(/\\/g, '/').split('/').slice(0, -1); + if (ascend > base.length) return false; + const relativeParts = match[2].split('.').filter(Boolean); + pathLike = [...base.slice(0, base.length - ascend), ...relativeParts].join('/'); + } else { + pathLike = submoduleTarget.replace(/\./g, '/'); + } + + const moduleFile = pathLike + '.py'; + const packageFile = pathLike + '/__init__.py'; + return ( + normalizedTarget === moduleFile || + normalizedTarget === packageFile || + normalizedTarget.endsWith('/' + moduleFile) || + normalizedTarget.endsWith('/' + packageFile) + ); +} diff --git a/gitnexus/src/core/ingestion/languages/python/index.ts b/gitnexus/src/core/ingestion/languages/python/index.ts index dc7e84f64..5cb5bda4d 100644 --- a/gitnexus/src/core/ingestion/languages/python/index.ts +++ b/gitnexus/src/core/ingestion/languages/python/index.ts @@ -76,7 +76,11 @@ export { getPythonCaptureCacheStats, resetPythonCaptureCacheStats } from './cach export { interpretPythonImport, interpretPythonTypeBinding } from './interpret.js'; export { pythonMergeBindings } from './merge-bindings.js'; export { pythonArityCompatibility } from './arity.js'; -export { resolvePythonImportTarget, type PythonResolveContext } from './import-target.js'; +export { + isPythonImportedModule, + resolvePythonImportTarget, + type PythonResolveContext, +} from './import-target.js'; export { pythonBindingScopeFor, pythonFunctionDefinitionLabel, diff --git a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts index 8ede6b395..4aecc28b4 100644 --- a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts @@ -19,6 +19,7 @@ import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers. import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import { pythonProvider } from '../python.js'; import { + isPythonImportedModule, pythonArityCompatibility, pythonMergeBindings, resolvePythonImportTarget, @@ -32,22 +33,29 @@ const pythonScopeResolver: ScopeResolver = { languageProvider: pythonProvider, importEdgeReason: 'python-scope: import', - resolveImportTarget: (targetRaw, fromFile, allFilePaths) => { + resolveImportTarget: (targetRaw, fromFile, allFilePaths, _resolutionConfig, context) => { // Pass the orchestrator's stable run-level `ReadonlySet` straight through // (no per-import copy). The Python resolver chain only reads the set, and // `getPythonFileIndex` memoizes its index on the set's identity via a // WeakMap — so the index is built once per run and reused across every // import. Copying here (the previous `new Set(allFilePaths)`) handed a // fresh identity to every import, defeating that cache (PR #1918 review P1). - const ws: PythonResolveContext = { fromFile, allFilePaths }; + const ws: PythonResolveContext = { + fromFile, + allFilePaths, + parsedFiles: context?.parsedFiles, + }; // `WorkspaceIndex` is an opaque `unknown` placeholder in the // shared contract, so `ws` passes structurally without a cast. return resolvePythonImportTarget( - { kind: 'named', localName: '_', importedName: '_', targetRaw }, + context?.parsedImport ?? { kind: 'namespace', localName: '_', importedName: '_', targetRaw }, ws, ); }, + isNamespaceImport: (parsedImport, targetFile, fromFile) => + isPythonImportedModule(parsedImport, targetFile, fromFile), + // Python LEGB precedence: local > import/namespace/reexport > wildcard. // The per-scope id is unused by pythonMergeBindings (tier ordering // is computed purely from BindingRef.origin), so we don't need to diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 449ac47af..a8f55edc3 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -150,6 +150,7 @@ export function extract( d.range, d.filePath, d.ownsReceivers, + d.lexicalNames, ); } } @@ -314,6 +315,7 @@ interface ScopeDraft { readonly ownedDefs: SymbolDefinition[]; readonly imports: ImportEdge[]; readonly typeBindings: Map; + readonly lexicalNames?: ReadonlySet; /** See `Scope.ownsReceivers` — set once at pass 1, never mutated. */ readonly ownsReceivers?: ReadonlySet; } @@ -371,6 +373,7 @@ function draftToScope(draft: ScopeDraft): Scope { ownedDefs: Object.freeze(draft.ownedDefs.slice()), imports: Object.freeze(draft.imports.slice()), typeBindings: new Map(draft.typeBindings), + lexicalNames: draft.lexicalNames, ownsReceivers: draft.ownsReceivers, }; } @@ -448,6 +451,7 @@ function pass1BuildScopes( cand.range, filePath, provider.scopeOwnsReceivers?.(cand.match), + parseScopeLexicalNames(cand.match), ), ); stack.push(cand); @@ -494,6 +498,7 @@ function makeDraft( range: Range, filePath: string, ownsReceivers?: ReadonlySet, + lexicalNames?: ReadonlySet, ): ScopeDraft { return { id, @@ -505,10 +510,26 @@ function makeDraft( ownedDefs: [], imports: [], typeBindings: new Map(), + lexicalNames, ownsReceivers, }; } +function parseScopeLexicalNames(match: CaptureMatch): ReadonlySet | undefined { + const raw = match['@scope.lexical-names']?.text; + if (raw === undefined) return undefined; + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return undefined; + const names = parsed.filter( + (name): name is string => typeof name === 'string' && name.length > 0, + ); + return names.length > 0 ? new Set(names) : undefined; + } catch { + return undefined; + } +} + // ─── Pass 2: attach declarations + local bindings ────────────────────────── function pass2AttachDeclarations( @@ -1490,6 +1511,7 @@ function rangesEqual(a: Range, b: Range): boolean { * change. */ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ + '@scope.lexical-names', '@declaration.name', '@declaration.qualified_name', '@import.name', diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 6ba945fcf..174190464 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -361,6 +361,17 @@ export interface ScopeResolver { context?: ImportResolutionContext, ): string | readonly string[] | null; + /** + * Optionally reclassify an import as a namespace handle after target + * resolution proves the imported name is itself a module. Returning false + * retains ordinary named-binding behavior. + */ + readonly isNamespaceImport?: ( + parsedImport: ParsedImport, + targetFile: string, + fromFile: string, + ) => boolean; + /** * Enumerate names visible through a wildcard import after the target * module scope has been linked. Languages that do not support diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 39a040a7e..6b77da5dd 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -30,6 +30,7 @@ import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js'; import { findClassBindingInScope, findEnclosingClassDef, + findExportedDef, findExportedDefByName, findReceiverTypeBinding, isClassLike, @@ -100,6 +101,8 @@ interface ResolveCompoundReceiverOptions { * rather than re-declared, so a future sub-field cannot be added there * and silently ignored here (#2708). */ readonly constructionSyntax?: ScopeResolver['constructionSyntax']; + /** Verified namespace handles visible in the current file. */ + readonly namespaceTargets?: ReadonlyMap; /** Compact receiver chain for THIS site (`ReferenceSite.receiverChain`), when * the language's capture emitter produced one. Present ⇒ the structural fold * is tried before the text cascade; absent ⇒ behaviour is exactly as before. @@ -146,6 +149,42 @@ function escapeForRegExp(literal: string): string { return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +/** True when a local declaration between the call site and its module scope + * shadows a file-level namespace import with the same name. Namespace targets + * are collected per file, so callers must apply this lexical guard before + * trusting them at an inner scope. */ +function isNamespaceNameShadowed( + namespaceName: string, + inScope: ScopeId, + scopes: ScopeResolutionIndexes, +): boolean { + let currentId: ScopeId | null = inScope; + const visited = new Set(); + while (currentId !== null) { + if (visited.has(currentId)) return true; + visited.add(currentId); + const scope = scopes.scopeTree.getScope(currentId); + if (scope === undefined) return true; + if ( + scope.kind !== 'Object' && + (scope.bindings.has(namespaceName) || + scope.typeBindings.has(namespaceName) || + scope.lexicalNames?.has(namespaceName) === true || + scope.ownedDefs.some((def) => { + const qualifiedName = def.qualifiedName; + if (qualifiedName === undefined) return false; + const dot = qualifiedName.lastIndexOf('.'); + return (dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1)) === namespaceName; + })) + ) { + return true; + } + if (scope.kind === 'Module') return false; + currentId = scope.parent; + } + return true; +} + /** * Type of a construction expression's callee — the class it constructs. * @@ -164,6 +203,7 @@ function resolveConstructionExpressionClass( fnExpr: string, inScope: ScopeId, scopes: ScopeResolutionIndexes, + index: WorkspaceResolutionIndex, options: ResolveCompoundReceiverOptions, ): SymbolDefinition | undefined { const syntax = options.constructionSyntax; @@ -190,14 +230,30 @@ function resolveConstructionExpressionClass( // name). if (calleeName === undefined || calleeName.length === 0) return undefined; - const direct = findClassBindingInScope(inScope, calleeName, scopes); - if (direct !== undefined && isClassLike(direct.type)) return direct; - // Generic construction — `new Box()` arrives here as `Box`, // which names no class binding. Retry on the base name, the same // normalization `resolveClassBindingForName` in `receiver-bound-calls` // already applies for typed receivers (#2708). const baseName = stripTemplateArguments(calleeName).trim(); + const lastDot = baseName.lastIndexOf('.'); + if (lastDot !== -1) { + const namespaceName = baseName.slice(0, lastDot); + const exportedName = baseName.slice(lastDot + 1); + const namespaceFiles = options.namespaceTargets?.get(namespaceName) ?? []; + // A verified namespace is authoritative: do not fall through to the + // workspace-wide simple-name heuristics on either a miss or ambiguity. + if (namespaceFiles.length > 0) { + if (isNamespaceNameShadowed(namespaceName, inScope, scopes)) return undefined; + const namespaceMatches = namespaceFiles + .map((targetFile) => findExportedDef(targetFile, exportedName, index)) + .filter((def): def is SymbolDefinition => def !== undefined && isClassLike(def.type)); + return namespaceMatches.length === 1 ? namespaceMatches[0] : undefined; + } + } + + const direct = findClassBindingInScope(inScope, calleeName, scopes); + if (direct !== undefined && isClassLike(direct.type)) return direct; + if (baseName.length > 0 && baseName !== calleeName) { const viaBaseName = findClassBindingInScope(inScope, baseName, scopes); if (viaBaseName !== undefined && isClassLike(viaBaseName.type)) return viaBaseName; @@ -206,8 +262,8 @@ function resolveConstructionExpressionClass( // Qualified callee — `new ns.Service()` / `new Outer.Inner()`. Prefer an // unambiguous qualified-name match, then fall back to the trailing simple // name the way receiver resolution does elsewhere (#2708). - const lastDot = baseName.lastIndexOf('.'); if (lastDot === -1) return undefined; + const qualifiedIds = scopes.qualifiedNames.get(baseName); if (qualifiedIds.length === 1) { const qualified = scopes.defs.get(qualifiedIds[0]!); @@ -503,7 +559,7 @@ export function resolveCompoundReceiverClass( // the dot-split below routes it into member resolution (#2708). const keyword = options.constructionSyntax?.keyword; if (keyword !== undefined && new RegExp(`^${escapeForRegExp(keyword)}\\s`).test(fnExpr)) { - return resolveConstructionExpressionClass(fnExpr, inScope, scopes, options); + return resolveConstructionExpressionClass(fnExpr, inScope, scopes, index, options); } const lastDot = fnExpr.lastIndexOf('.'); @@ -525,7 +581,7 @@ export function resolveCompoundReceiverClass( // read a type off; the return-type path above cannot help either, // because a class has no return-type binding. Type it from the // class the callee names (#2708). - return resolveConstructionExpressionClass(fnExpr, inScope, scopes, options); + return resolveConstructionExpressionClass(fnExpr, inScope, scopes, index, options); } // `obj.method()` — resolve obj's class, look up method's return @@ -540,7 +596,15 @@ export function resolveCompoundReceiverClass( options, depth + 1, ); - if (objClass === undefined) return undefined; + if (objClass === undefined) { + // A verified namespace-qualified bare constructor is syntactically + // indistinguishable from an untyped member call here. Only the namespace + // map makes the constructor interpretation safe. + if (options.namespaceTargets?.has(objExpr) === true) { + return resolveConstructionExpressionClass(fnExpr, inScope, scopes, index, options); + } + return undefined; + } // Does `objExpr` name the CLASS ITSELF (`Factory.new`) rather than a // value whose type is that class (`factory.new`)? Only the former is @@ -743,7 +807,13 @@ export function resolveCompoundReceiverClass( // seeded and the whole chain resolved to nothing. A constructed value is an // instance, so `currentIsClassConstant` correctly stays false here. if (currentClass === undefined) { - currentClass = resolveConstructionExpressionClass(headMemberName, inScope, scopes, options); + currentClass = resolveConstructionExpressionClass( + headMemberName, + inScope, + scopes, + index, + options, + ); } for (let i = 1; i < parts.length && currentClass !== undefined; i++) { diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index ee2cd6a21..038ad2437 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -257,6 +257,7 @@ export function emitReceiverBoundCalls( for (const parsed of parsedFiles) { const namespaceTargets = collectNamespaceTargets(parsed, scopes); + const fileCompoundOpts = { ...compoundOpts, namespaceTargets }; // Per-file resolved-callee-id capture context (#2227 U2). Built once per // file; `undefined` when the sink is absent (pdg off) so the `tryEmitEdge` // capture is a no-op and emission stays byte-identical (R4). @@ -409,7 +410,7 @@ export function emitReceiverBoundCalls( index, // Group A: the receiver IS this site's expression, so the site's // captured chain describes it and the structural fold applies. - { ...compoundOpts, receiverChain: site.receiverChain }, + { ...fileCompoundOpts, receiverChain: site.receiverChain }, ); compoundReceiverUnresolved = currentClass === undefined; if (currentClass !== undefined) { @@ -938,7 +939,7 @@ export function emitReceiverBoundCalls( typeRef.declaredAtScope, scopes, index, - compoundOpts, + fileCompoundOpts, ); if (ownerDef === undefined && !typeRef.rawName.includes('(')) { ownerDef = resolveCompoundReceiverClass( @@ -946,7 +947,7 @@ export function emitReceiverBoundCalls( typeRef.declaredAtScope, scopes, index, - compoundOpts, + fileCompoundOpts, ); } if (ownerDef !== undefined) { @@ -1049,7 +1050,7 @@ export function emitReceiverBoundCalls( scopes, index, // Group A, same reasoning as Case 0 above. - { ...compoundOpts, receiverChain: site.receiverChain }, + { ...fileCompoundOpts, receiverChain: site.receiverChain }, ); } if (ownerDef !== undefined) { diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 435f74354..aa6268d52 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -605,6 +605,8 @@ export function runScopeResolution( parsedFiles, parsedImport, }), + isNamespaceImport: (parsedImport, targetFile, fromFile) => + provider.isNamespaceImport?.(parsedImport, targetFile, fromFile) ?? false, expandsWildcardTo: (targetModuleScope) => provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [], mergeBindings: (existing, incoming, scopeId) => diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts index 6ee0e2a16..f82a01be2 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts @@ -15,8 +15,10 @@ * * Next-consumer contract: any language with namespace-style imports * (TypeScript `import * as X`, Java static import, Ruby `require`) - * uses this directly. `ParsedImport.kind === 'namespace'` is the - * cross-language hook. + * uses this directly. The finalized `ImportEdge.kind === 'namespace'` + * classification is authoritative; providers may produce it directly from + * syntax or reclassify a named import after target resolution proves it names + * a module. * * Scope-chain concern (verified 2026-04-21): `pythonImportOwningScope` * documents that function-local and class-body imports bind to the @@ -43,14 +45,8 @@ export function collectNamespaceTargets( const moduleEdges = scopes.imports.get(parsed.moduleScope); if (moduleEdges === undefined) return out; - const namespaceLocals = new Set(); - for (const imp of parsed.parsedImports) { - if (imp.kind === 'namespace') namespaceLocals.add(imp.localName); - } - for (const edge of moduleEdges) { - if (edge.targetFile === null) continue; - if (!namespaceLocals.has(edge.localName)) continue; + if (edge.targetFile === null || edge.kind !== 'namespace') continue; let targets = out.get(edge.localName); if (targets === undefined) { targets = []; diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 1f1808f66..6b8b6f19b 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -636,8 +636,13 @@ export interface RepoMeta { * multi-line closure binding joins the scope channel and emits its CALLS edge. * Pre-v30 indexes keep the wrapper line on unchanged files and would keep * failing closed (no edge) through the reuse gate. Force a full re-analyze. + * + * v31: Python named imports that resolve to concrete submodules are finalized + * as namespace edges (#2746), enabling qualified constructor and method CALLS + * edges. Pre-v31 indexes retain the old package-target/missing-edge graph for + * unchanged files through the reuse gate. Force a full re-analyze. */ -export const INCREMENTAL_SCHEMA_VERSION = 30; +export const INCREMENTAL_SCHEMA_VERSION = 31; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/decoy/models.py b/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/decoy/models.py new file mode 100644 index 000000000..946dfdf8f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/decoy/models.py @@ -0,0 +1,3 @@ +class User: + def save(self): + return "wrong" diff --git a/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/__init__.py b/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/app.py b/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/app.py new file mode 100644 index 000000000..7338fd54f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/app.py @@ -0,0 +1,15 @@ +from pkg import models +from pkg.models import User + + +def inline_save(db): + return models.User(db).save() + + +def assigned_save(db): + user = models.User(db) + return user.save() + + +def direct_save(db): + return User(db).save() diff --git a/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/models.py b/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/models.py new file mode 100644 index 000000000..0a11644b0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/models.py @@ -0,0 +1,6 @@ +class User: + def __init__(self, db): + self.db = db + + def save(self): + return self.db diff --git a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json index 86af1fb87..e9131d748 100644 --- a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json @@ -331,6 +331,22 @@ "captureGroups": 41, "digest": "e8807a9969732197feb04204d200d5810b895c006f1424a7a6f5f0d5762ef49a" }, + "python-from-module-alias/decoy/models.py": { + "captureGroups": 7, + "digest": "bc8f2332e9a4a683f3fdae966a0d8a8f534a310ddc438a7a4ebc040e97d642b8" + }, + "python-from-module-alias/pkg/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-from-module-alias/pkg/app.py": { + "captureGroups": 23, + "digest": "afb50d3e98def472f9215e986dd4f964eedd197a9acfc8048d92b882e8fe868a" + }, + "python-from-module-alias/pkg/models.py": { + "captureGroups": 14, + "digest": "fe52542a27d98c57d7acd48212dc301bdb448ad5868afb68c98cede9299dd1e5" + }, "python-function-local-import-chain/app.py": { "captureGroups": 9, "digest": "ad4d45976ca10c3fc3b7bf498ee23397797368a5d2817311d0e95e943af89199" diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index a6405247a..46adea764 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -2336,6 +2336,44 @@ describe('Python module import CALLS resolution (Issue #337)', () => { }); }); +// --------------------------------------------------------------------------- +// Module reached through `from pkg import models` (#2746) +// --------------------------------------------------------------------------- + +describe('Python from-import module alias resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'python-from-module-alias'), () => {}); + }, 60000); + + it('links the imported module rather than the package initializer', () => { + const imports = getRelationships(result, 'IMPORTS'); + const appImports = imports.filter((edge) => edge.sourceFilePath === 'pkg/app.py'); + + expect(appImports.length).toBeGreaterThan(0); + expect(appImports.every((edge) => edge.targetFilePath === 'pkg/models.py')).toBe(true); + }); + + it('resolves inline and assigned calls through the module alias', () => { + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.sourceFilePath === 'pkg/app.py', + ); + + expect(calls.filter((edge) => edge.target === 'User')).toHaveLength(2); + expect(calls.filter((edge) => edge.target === 'save')).toHaveLength(3); + expect(calls.every((edge) => edge.targetFilePath === 'pkg/models.py')).toBe(true); + }); + + it('does not bind the same-named class from an unrelated module', () => { + const wrongCalls = getRelationships(result, 'CALLS').filter( + (edge) => edge.sourceFilePath === 'pkg/app.py' && edge.targetFilePath === 'decoy/models.py', + ); + + expect(wrongCalls).toHaveLength(0); + }); +}); + // --------------------------------------------------------------------------- // External dotted imports: framework modules like django.apps must not resolve // to unrelated local basename matches such as accounts/apps.py or config/urls.py. diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index 146239be3..1adbb2a7e 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 30 (multi-line closure startLine join, #2735)', () => { + it('INCREMENTAL_SCHEMA_VERSION is bumped to 31 (Python module-import resolution, #2746)', () => { // Moves with every bump BY DESIGN — that is the point of pinning it. A // change that alters emitted ids or edges without bumping would otherwise // ship silently, and an existing index would keep serving the old graph // through the reuse gate below. - expect(INCREMENTAL_SCHEMA_VERSION).toBe(30); + expect(INCREMENTAL_SCHEMA_VERSION).toBe(31); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { @@ -205,7 +205,10 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // A pre-v30 (v29) index keeps wrapper-line startLines for multi-line closure // bindings (#2735), so the graph-to-scope join still drops the CALLS edge. expect(passesReuseGate(29)).toBe(false); + // A pre-v31 (v30) index treats `from pkg import models` as a named package + // import, so unchanged files retain the old missing qualified CALLS edges. + expect(passesReuseGate(30)).toBe(false); // The current stamp passes the gate (incremental top-up eligible). - expect(passesReuseGate(30)).toBe(true); + expect(passesReuseGate(31)).toBe(true); }); }); diff --git a/gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts b/gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts index 4118461fb..02cc1355f 100644 --- a/gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts +++ b/gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts @@ -160,6 +160,34 @@ describe('finalizeScopeModel: cross-file imports', () => { expect(appImports[0]!.targetDefId).toBe('def:User'); }); + it('finalizes a provider-classified module symbol as a namespace edge', () => { + const modelsFile = mkFile('pkg/models.py', { + localDefs: [mkDef('def:User', 'pkg/models.py', 'pkg.models.User')], + }); + const appFile = mkFile('pkg/app.py', { + parsedImports: [ + { + kind: 'named', + localName: 'models', + importedName: 'models', + targetRaw: 'pkg', + }, + ], + }); + + const out = finalizeScopeModel([appFile, modelsFile], { + hooks: { + resolveImportTarget: () => 'pkg/models.py', + isNamespaceImport: () => true, + }, + }); + + const edge = out.imports.get(appFile.moduleScope)?.[0]; + expect(edge?.kind).toBe('namespace'); + expect(edge?.linkStatus).toBeUndefined(); + expect(edge?.targetModuleScope).toBe(modelsFile.moduleScope); + expect(out.stats.unresolvedEdges).toBe(0); + }); it('leaves imports unresolved when no resolveImportTarget is supplied (default hook)', () => { // Default `resolveImportTarget: () => null` — every import ends up // with `linkStatus: 'unresolved'`. This is the zero-provider case diff --git a/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts b/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts index f4fb3e756..7944b82d5 100644 --- a/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts +++ b/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts @@ -53,6 +53,10 @@ type Snapshot = Record; function canonicalizeMatch(match: CaptureMatch): string { const parts: string[] = []; for (const tag of Object.keys(match)) { + // Scope-only lexical shadow metadata has dedicated extraction/resolution + // tests. Exclude it from the historical capture fingerprint so adding + // untyped parameter barriers does not rebaseline every Python fixture. + if (tag === '@scope.lexical-names') continue; const cap = match[tag]!; const r = cap.range; parts.push(`${tag}|${cap.text}|${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`); diff --git a/gitnexus/test/unit/scope-resolution/python/python-import-target-parity.test.ts b/gitnexus/test/unit/scope-resolution/python/python-import-target-parity.test.ts index f52b8eed2..658221bfe 100644 --- a/gitnexus/test/unit/scope-resolution/python/python-import-target-parity.test.ts +++ b/gitnexus/test/unit/scope-resolution/python/python-import-target-parity.test.ts @@ -11,13 +11,20 @@ * index regression fails CI rather than silently changing resolved edges. */ import { describe, it, expect } from 'vitest'; -import { resolvePythonImportTarget } from '../../../../src/core/ingestion/languages/python/index.js'; -import type { ParsedImport } from 'gitnexus-shared'; +import { + isPythonImportedModule, + resolvePythonImportTarget, +} from '../../../../src/core/ingestion/languages/python/index.js'; +import type { ParsedFile, ParsedImport } from 'gitnexus-shared'; function mkImport(targetRaw: string): ParsedImport { return { kind: 'absolute', targetRaw, isRelative: false, names: [] } as unknown as ParsedImport; } +function mkNamed(targetRaw: string, importedName: string): ParsedImport { + return { kind: 'named', targetRaw, localName: importedName, importedName }; +} + function resolve(fromFile: string, files: string[], targetRaw: string): string | null { return resolvePythonImportTarget(mkImport(targetRaw), { fromFile, @@ -150,4 +157,64 @@ describe('resolvePythonImportTarget — index parity', () => { // A package whose only file is non-.py is not a repo candidate → null. expect(resolve('app/main.py', [...files, 'tsonly/widget.ts'], 'tsonly.widget')).toBeNull(); }); + + it('prefers a concrete imported submodule over the package initializer (#2746)', () => { + const parsed = mkNamed('pkg', 'models'); + const target = resolvePythonImportTarget(parsed, { + fromFile: 'pkg/app.py', + allFilePaths: new Set(['pkg/__init__.py', 'pkg/models.py']), + }); + + expect(target).toBe('pkg/models.py'); + expect(isPythonImportedModule(parsed, target ?? '', 'pkg/app.py')).toBe(true); + }); + + it('preserves an explicit package export over a same-named submodule', () => { + const parsed = mkNamed('pkg', 'models'); + const packageFile = { + filePath: 'pkg/__init__.py', + localDefs: [{ qualifiedName: 'models' }], + } as unknown as ParsedFile; + const target = resolvePythonImportTarget(parsed, { + fromFile: 'pkg/app.py', + allFilePaths: new Set(['pkg/__init__.py', 'pkg/models.py']), + parsedFiles: [packageFile], + }); + + expect(target).toBe('pkg/__init__.py'); + expect(isPythonImportedModule(parsed, target ?? '', 'pkg/app.py')).toBe(false); + }); + + it('keeps ordinary symbols on the named-import path', () => { + const parsed = mkNamed('pkg.models', 'User'); + const target = resolvePythonImportTarget(parsed, { + fromFile: 'pkg/app.py', + allFilePaths: new Set(['pkg/__init__.py', 'pkg/models.py']), + }); + + expect(target).toBe('pkg/models.py'); + expect(isPythonImportedModule(parsed, target ?? '', 'pkg/app.py')).toBe(false); + }); + + it('recognizes relative from-import submodules', () => { + const parsed = mkNamed('.', 'models'); + const target = resolvePythonImportTarget(parsed, { + fromFile: 'pkg/app.py', + allFilePaths: new Set(['pkg/__init__.py', 'pkg/models.py']), + }); + + expect(target).toBe('pkg/models.py'); + expect(isPythonImportedModule(parsed, target ?? '', 'pkg/app.py')).toBe(true); + }); + + it('falls back to a package export when no same-named submodule exists', () => { + const parsed = mkNamed('pkg', 'exported'); + const target = resolvePythonImportTarget(parsed, { + fromFile: 'pkg/app.py', + allFilePaths: new Set(['pkg/__init__.py']), + }); + + expect(target).toBe('pkg/__init__.py'); + expect(isPythonImportedModule(parsed, target ?? '', 'pkg/app.py')).toBe(false); + }); }); diff --git a/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts b/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts new file mode 100644 index 000000000..75b3303c4 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import type { ParsedFile } from 'gitnexus-shared'; +import { pythonScopeResolver } from '../../../../src/core/ingestion/languages/python/scope-resolver.js'; +import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js'; +import { finalizeScopeModel } from '../../../../src/core/ingestion/finalize-orchestrator.js'; +import { resolveCompoundReceiverClass } from '../../../../src/core/ingestion/scope-resolution/passes/compound-receiver.js'; +import { collectNamespaceTargets } from '../../../../src/core/ingestion/scope-resolution/scope/namespace-targets.js'; +import { buildWorkspaceResolutionIndex } from '../../../../src/core/ingestion/scope-resolution/workspace-index.js'; + +const SOURCES = new Map([ + [ + 'pkg/app.py', + `from pkg import models +from decoy import Missing + +def valid(): + return models.User() + +def missing(): + return models.Missing() + +def shadowed(models): + return models.User() + +def locally_shadowed(): + models = object() + return models.User() +`, + ], + [ + 'pkg/models.py', + `class User: + pass +`, + ], + ['pkg/__init__.py', '# package marker\n'], + [ + 'decoy.py', + `class Missing: + pass +`, + ], +]); + +function build() { + const parsedFiles: ParsedFile[] = []; + for (const [filePath, source] of SOURCES) { + const parsed = extractParsedFile(pythonScopeResolver.languageProvider, source, filePath); + if (parsed === undefined) throw new Error(`scope extraction failed for ${filePath}`); + pythonScopeResolver.populateOwners(parsed); + parsedFiles.push(parsed); + } + + const allFilePaths = new Set(parsedFiles.map((file) => file.filePath)); + const scopes = finalizeScopeModel(parsedFiles, { + hooks: { + resolveImportTarget: (targetRaw, fromFile, _workspaceIndex, parsedImport) => + pythonScopeResolver.resolveImportTarget(targetRaw, fromFile, allFilePaths, undefined, { + parsedFiles, + parsedImport, + }), + isNamespaceImport: (parsedImport, targetFile, fromFile) => + pythonScopeResolver.isNamespaceImport?.(parsedImport, targetFile, fromFile) ?? false, + mergeBindings: (existing, incoming, scopeId) => + pythonScopeResolver.mergeBindings(existing, incoming, scopeId), + }, + }); + const index = buildWorkspaceResolutionIndex(parsedFiles); + const app = parsedFiles.find((file) => file.filePath === 'pkg/app.py'); + if (app === undefined) throw new Error('missing app fixture'); + const namespaceTargets = collectNamespaceTargets(app, scopes); + + const resolveIn = (functionName: string, expression: string) => { + const functionScope = app.scopes.find( + (scope) => + scope.kind === 'Function' && + scope.ownedDefs.some((def) => def.qualifiedName === functionName), + ); + if (functionScope === undefined) throw new Error(`missing scope for ${functionName}`); + return resolveCompoundReceiverClass(expression, functionScope.id, scopes, index, { + constructionSyntax: { bare: true }, + namespaceTargets, + }); + }; + + return { resolveIn }; +} + +describe('Python module namespace construction', () => { + const { resolveIn } = build(); + + it('resolves an exported class from the verified module target', () => { + expect(resolveIn('valid', 'models.User()')).toMatchObject({ + filePath: 'pkg/models.py', + qualifiedName: 'User', + }); + }); + + it('fails closed when the module lacks the requested class', () => { + expect(resolveIn('missing', 'models.Missing()')).toBeUndefined(); + }); + + it('does not reuse a file-level namespace when a parameter shadows it', () => { + expect(resolveIn('shadowed', 'models.User()')).toBeUndefined(); + }); + + it('does not reuse a file-level namespace when a local shadows it', () => { + expect(resolveIn('locally_shadowed', 'models.User()')).toBeUndefined(); + }); +}); From 064832f50cf1ff65e9d63f52d5d052452e34285f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 1 Aug 2026 08:42:51 +0100 Subject: [PATCH 11/17] fix(mcp): resolve false FTS-missing warnings in the query tool (#2773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp): surface resolved repo/branch/indexed-at in the FTS-degraded warning Turns the generic "FTS indexes missing" message into a diagnostic that reveals what this MCP session actually resolved, so a CLI/MCP mismatch or stale-connection theory is visible in the warning text itself instead of requiring a separate debugging round-trip (#2767). * fix(mcp): stop swallowing real FTS query errors behind the missing-index message queryFTSViaExecutor previously collapsed 'index genuinely missing' and 'a real query/connection error occurred' into the same silent null, so a real failure could masquerade as the generic FTS-degraded message with no diagnostic trail — even when it happened on only some of the per-table queries while others succeeded. Classifies the failure (mirroring queryFTS's own check for this exact cypher call), always logs a non-benign error server-side regardless of overall outcome, and surfaces it (redacted) in the client warning only when every table failed (#2767). * fix(mcp): give --repair-fts a dedicated freshness signal for warm readers --repair-fts intentionally never restamps indexedAt (it doesn't regenerate the graph), so a long-lived MCP session's pool staleness check had no explicit signal that a repair happened, only the incidental file-identity delta. Reuses the existing (forensic-only) capabilities.fts.status field: repair-fts now stamps just that sub-field (everything else byte-identical), and ensureInitialized compares it as a third, independent reinit trigger alongside the existing stamp/identity checks, seeded at cold init too so a fresh process's first warm check doesn't false-trigger (#2767). * test(mcp): warm session picks up an out-of-band --repair-fts rebuild (#2767) New end-to-end integration test: a real writable LadybugDB session builds an index WITHOUT FTS, a real LocalBackend observes 'FTS indexes missing' through the real pool, a separate writable session performs the exact repair-fts writes (real createSearchFTSIndexes + the #2767 capability-only meta stamp), and the SAME still-warm backend re-queries successfully without a restart — closing the one end-to-end gap no existing test covered. Running this against the real engine surfaced a second real message shape for a missing FTS index ("doesn't have an index with name X", not just "does not exist") that the U2 classifier didn't recognize — fixed classifyFtsQueryError to match both, with a regression test pinning the exact observed string. * fix(review): address code-review findings on the #2767 FTS fix - Anchor classifyFtsQueryError to the exception class (mirroring isBenignDropFtsIndexError) instead of a bare substring search, so a real, differently-classed error that happens to echo the benign phrase in its body (e.g. an echoed user query) can't be misclassified as a benign missing-index (adversarial review). - Re-read the on-disk meta immediately before the --repair-fts capability stamp write instead of reusing the pre-rebuild snapshot, so a concurrent writer (e.g. the HTTP server's background embedding checkpoint job) landing mid-repair isn't silently reverted. - Surface a client-facing partial-result warning (mirroring the existing enrichmentDegraded convention) when some FTS tables succeed but at least one hits a real error, instead of only logging it server-side. - Update RepoMeta.capabilities' stale 'no programmatic readers' docstring now that ensureInitialized reads capabilities.fts.status. - Widen the warm-session integration test's polling deadline for more margin over the production 5s staleness-check throttle. * fix(ci): drop the cold-init loadMeta call ensureInitialized never needed It stole the mocked loadMeta call an unrelated upstream PDG test depends on (test/integration/impact-pdg-statement-precise.test.ts queues a single mockResolvedValueOnce for its own PDG-config read; the extra call consumed that slot before the PDG code ran, so it fell through to the mock's null default and epistemic came back undefined instead of 'pdg-intra-procedural'). Cold init now leaves lastObservedFtsStatus unseeded — the cost is at most one redundant initLbug call on the first warm check, which no-ops via a single fs.stat when nothing actually changed, not a real reopen. * fix(review): address tri-review findings on the #2767 FTS fix Fixes two P1s (misleading repair-fts advice on real query errors; embedding-checkpoint job silently reverting the capabilities.fts stamp for up to its 30-minute lifetime), five P2/P3s (stale indexedAt in warnings, extension-unavailable noise, mismatched log severity, a table-missing vs index-missing conflation confirmed against a live LadybugDB, and a reinit-watermark latching bug), and the four residual items already self-disclosed in this PR's description (shared FTS error classifier, consolidated per-pool observed-state map, a redactPaths whitespace gap, and an isolated ftsCapsChanged test). A /simplify pass afterward caught one more real bug: the extension-unavailable short-circuit only guarded the MCP pool path, so the CLI-path fix above it started surfacing spurious non-benign errors for the same expected degraded state the pool path stays silent on — now both paths agree. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Sonnet 5 --- gitnexus/src/core/lbug/lbug-adapter.ts | 58 ++++- gitnexus/src/core/run-analyze.ts | 41 +++ gitnexus/src/core/search/bm25-index.ts | 102 ++++++-- gitnexus/src/core/search/fts-indexes.ts | 76 +++++- gitnexus/src/mcp/local/local-backend.ts | 153 ++++++++--- gitnexus/src/server/api.ts | 14 +- gitnexus/src/storage/repo-manager.ts | 9 +- .../fts-repair-warm-session.test.ts | 169 ++++++++++++ gitnexus/test/unit/bm25-search.test.ts | 179 +++++++++++++ ...nsure-initialized-reinit-watermark.test.ts | 139 ++++++++++ .../test/unit/fts-degraded-warning.test.ts | 87 +++++++ .../test/unit/query-degraded-signal.test.ts | 102 +++++++- .../test/unit/run-analyze-fts-repair.test.ts | 240 ++++++++++++++++++ 13 files changed, 1304 insertions(+), 65 deletions(-) create mode 100644 gitnexus/test/integration/fts-repair-warm-session.test.ts create mode 100644 gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 86f763ffa..d107f5e79 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -3077,6 +3077,55 @@ export const ensureFTSIndex = async ( } }; +export type FtsQueryFailureClass = 'missing-index' | 'missing-table' | 'other'; + +/** + * Classify a `QUERY_FTS_INDEX` failure so a genuinely-missing index (normal — + * this table's FTS index hasn't been built yet) is distinguished from a real + * query-time error that would otherwise look identical (#2767), and from the + * table itself being missing (schema drift / a corrupted or partial DB — a + * much more serious condition than an unbuilt index). + * + * tri-review Residual-1: this used to be a second, independently-maintained + * classifier living in `core/search/bm25-index.ts` (re-exported from there + * for backward compatibility), duplicating this function's job for the + * IDENTICAL `QUERY_FTS_INDEX` cypher call. `queryFTS` below now uses this + * same classifier for its own catch instead of a bare, unanchored + * `.includes('does not exist')` check that could not tell "index missing" + * from "table missing" apart, and silently swallowed both alike. + * + * Three real message shapes were confirmed empirically against a live + * `CALL QUERY_FTS_INDEX(...)`: + * `"Prepare failed: Binder exception: Table doesn't have an index with + * name ."` — the table exists, only its FTS index is missing (normal, + * benign — `missing-index`) — `"Prepare failed: Binder exception: Table + * does not exist."` — the TABLE ITSELF is missing (`missing-table`) — and a + * `Catalog exception: function QUERY_FTS_INDEX is not defined...` when the + * FTS extension isn't loaded at all (`other`; mirrors the confirmed + * `DROP_FTS_INDEX` shape in {@link isBenignDropFtsIndexError}'s doc comment). + * + * Anchored to the exception class (after stripping the optional "Prepare + * failed: " wrapper LadybugDB adds for statement-preparation failures), + * mirroring `isBenignDropFtsIndexError`'s START-of-message anchor: a bare + * substring search would misclassify a genuine, differently-classed error + * (e.g. a `Runtime exception` from the FTS parser that echoes the user's + * own search text back into its message) as benign whenever that echoed + * text happened to contain "does not exist" — silently dropping a real + * error, the exact #2767 failure mode this function exists to prevent. + */ +export const classifyFtsQueryError = (message: string): FtsQueryFailureClass => { + const PREPARE_FAILED_PREFIX = 'Prepare failed: '; + const body = message.startsWith(PREPARE_FAILED_PREFIX) + ? message.slice(PREPARE_FAILED_PREFIX.length) + : message; + if (!body.startsWith('Binder exception:') && !body.startsWith('Catalog exception:')) { + return 'other'; + } + if (body.includes("doesn't have an index")) return 'missing-index'; + if (body.includes('does not exist')) return 'missing-table'; + return 'other'; +}; + /** * Query a full-text search index * @param tableName - The node table name @@ -3121,8 +3170,13 @@ export const queryFTS = async ( }; }); } catch (e: any) { - // Return empty if index doesn't exist yet - if (e.message?.includes('does not exist')) { + // Return empty only for a genuinely-missing index — the ordinary, + // expected case. A missing TABLE (schema drift) or any other real error + // rethrows instead of being silently swallowed (tri-review Residual-1 / + // NEW-6 — this used to be a bare `.includes('does not exist')` check + // that could not tell the two apart). + const message = e instanceof Error ? e.message : String(e); + if (classifyFtsQueryError(message) === 'missing-index') { return []; } throw e; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 0908175d8..754c1e6b7 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -1031,6 +1031,47 @@ async function runFullAnalysisInner( ); } await ensureGitNexusIgnored(repoPath); + // #2767: stamp ONLY capabilities.fts so a long-lived MCP session's + // ensureInitialized() has an explicit, correctly-scoped signal that FTS + // changed — indexedAt/lastCommit/runnerIdentity/stats are copied through + // untouched (see the "must not claim a new analyzer identity" comment + // below). capabilities is forensic/no-programmatic-readers-until-now, so + // graph/vectorSearch are backfilled with conservative, honest defaults + // when a legacy meta.json predates this field entirely — repair-fts + // never touched them and cannot claim a capability it did not verify. + // Best-effort: a write failure must not turn an already-successful FTS + // rebuild into a reported repair failure. + try { + // Re-read the on-disk meta immediately before writing, rather than + // reusing `existingMeta` (captured before the FTS rebuild ran, which + // can span real wall-clock time). Another writer to this same + // gitnexus.json in the interim — e.g. the HTTP server's background + // embedding-checkpoint job — must not have its update silently + // reverted by this stamp overwriting a stale snapshot. Falls back to + // `existingMeta` only if the file became unreadable in that window. + const latestMeta = (await loadMeta(metaDir)) ?? existingMeta; + await saveMeta(metaDir, { + ...latestMeta, + capabilities: { + graph: latestMeta.capabilities?.graph ?? { + provider: 'ladybugdb', + status: 'available', + }, + fts: { provider: 'ladybugdb-fts', status: 'available' }, + vectorSearch: latestMeta.capabilities?.vectorSearch ?? { + provider: 'exact-scan', + status: 'unavailable', + exactScanLimit: 0, + }, + }, + }); + } catch (err) { + log( + `FTS capability stamp write failed (non-critical, repair itself succeeded${ + err instanceof Error ? `: ${err.message}` : '' + }); continuing.`, + ); + } progress('fts', 90, 'Search indexes ready'); progress('done', 100, 'Done'); return { diff --git a/gitnexus/src/core/search/bm25-index.ts b/gitnexus/src/core/search/bm25-index.ts index 19196b11b..44cfe4ce2 100644 --- a/gitnexus/src/core/search/bm25-index.ts +++ b/gitnexus/src/core/search/bm25-index.ts @@ -5,8 +5,14 @@ * Always reads from the database (no cached state to drift). */ -import { queryFTS } from '../lbug/lbug-adapter.js'; +// tri-review Residual-1: `classifyFtsQueryError` now lives in lbug-adapter.ts +// (see its doc comment) so `queryFTS`'s own catch can share the SAME +// classifier instead of maintaining a second, independently-drifting copy +// for the identical `QUERY_FTS_INDEX` cypher call. +import { queryFTS, classifyFtsQueryError } from '../lbug/lbug-adapter.js'; import { normalizeFtsText } from '../lbug/csv-generator.js'; +import { getExtensionCapabilities } from '../lbug/extension-loader.js'; +import { redactPaths } from './fts-indexes.js'; import { FTS_INDEXES } from './fts-schema.js'; import { applyCjkSegmentationIfEnabled, @@ -24,12 +30,40 @@ export interface FTSSearchResponse { results: BM25SearchResult[]; /** True when at least one FTS index query succeeded (index exists). */ ftsAvailable: boolean; + /** + * Redacted (via {@link redactPaths}) message(s) from per-table + * `QUERY_FTS_INDEX` calls that failed for a reason OTHER than "index + * doesn't exist" (#2767) — a real query/connection error was previously + * indistinguishable from a genuinely-missing index. Populated whenever ANY + * table hit a non-benign error, regardless of whether other tables + * succeeded, so a caller can always log it; whether to also surface it in + * a client-facing warning is a caller decision (see `LocalBackend.query()`, + * which only does so when every table failed). + */ + nonBenignErrors?: string[]; +} + +/** + * Optional-field shape rather than a discriminated union: this project builds + * with `strict: false` (no `strictNullChecks`), under which TypeScript's + * control-flow narrowing across an `if/else` on a boolean discriminant is + * unreliable (verified empirically — narrows correctly under `strict: true`, + * fails under `strict: false`). `rows` present means success; `rows` absent + * means failure, with `benign`/`message` describing why. + */ +interface FTSQueryOutcome { + rows?: Array<{ filePath: string; score: number; nodeId: string }>; + benign?: boolean; + message?: string; } /** * Execute a single FTS query via a custom executor (for MCP connection pool). - * Returns `null` when the query fails (e.g. FTS index does not exist) so the - * caller can distinguish "zero matches" from "index missing". + * Returns a benign failure when the query fails because the index doesn't + * exist (the normal, expected case), and a non-benign failure with the + * captured message for any other error, so the caller can distinguish "zero + * matches", "index missing", and "a real error occurred" instead of + * collapsing the latter two into the same silent `null`. */ async function queryFTSViaExecutor( executor: (cypher: string, params: Record) => Promise, @@ -37,7 +71,7 @@ async function queryFTSViaExecutor( indexName: string, query: string, limit: number, -): Promise | null> { +): Promise { const cypher = ` CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := false) RETURN node, score @@ -46,17 +80,20 @@ async function queryFTSViaExecutor( `; try { const rows = await executor(cypher, { query }); - return rows.map((row: any) => { - const node = row.node || row[0] || {}; - const score = row.score ?? row[1] ?? 0; - return { - filePath: node.filePath || '', - score: typeof score === 'number' ? score : parseFloat(score) || 0, - nodeId: node.nodeId || node.id || '', - }; - }); - } catch { - return null; + return { + rows: rows.map((row: any) => { + const node = row.node || row[0] || {}; + const score = row.score ?? row[1] ?? 0; + return { + filePath: node.filePath || '', + score: typeof score === 'number' ? score : parseFloat(score) || 0, + nodeId: node.nodeId || node.id || '', + }; + }), + }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { benign: classifyFtsQueryError(message) === 'missing-index', message }; } } @@ -95,8 +132,23 @@ export const searchFTSFromLbug = async ( ); const resultsByIndex: any[][] = []; let queriesSucceeded = 0; + const nonBenignErrors: string[] = []; - if (repoId) { + const ftsExtension = getExtensionCapabilities().find((c) => c.name === 'fts'); + if (ftsExtension && !ftsExtension.loaded) { + // tri-review NEW-4 (applies to BOTH the MCP pool path and the CLI/pipeline + // path — a /simplify altitude pass caught the original repoId-only guard + // letting the CLI branch surface spurious "non-benign" errors for this + // exact expected state, which the pool branch correctly stayed silent on): + // extension-unavailable is an expected, already-diagnosed degraded-capability + // state (#2374/#2658), not a per-table query error — every configured table + // would throw the identical "function not defined" shape, which + // classifyFtsQueryError correctly refuses to call benign "missing-index" + // (it's a different, more serious condition). Skip the N redundant + // QUERY_FTS_INDEX round-trips and N nonBenignErrors entries; ftsAvailable + // stays false and ftsDegradedWarning() already reports this state + // accurately from the same extension-capabilities registry. + } else if (repoId) { // Use MCP connection pool via dynamic import // IMPORTANT: FTS queries run sequentially to avoid connection contention. // The MCP pool supports multiple connections, but FTS is best run serially. @@ -106,21 +158,28 @@ export const searchFTSFromLbug = async ( executeParameterized(repoId, cypher, params); for (const { table, indexName } of FTS_INDEXES) { - const result = await queryFTSViaExecutor(executor, table, indexName, searchQuery, limit); - if (result !== null) { + const outcome = await queryFTSViaExecutor(executor, table, indexName, searchQuery, limit); + if (outcome.rows) { queriesSucceeded++; - resultsByIndex.push(result); + resultsByIndex.push(outcome.rows); + } else if (!outcome.benign) { + nonBenignErrors.push(redactPaths(outcome.message ?? 'Unknown FTS query error')); } } } else { // Use core lbug adapter (CLI / pipeline context) — also sequential for safety. + // tri-review Residual-1: `queryFTS` itself only swallows a genuinely-missing + // index (via the SAME classifyFtsQueryError this module re-exports); a + // missing-table or real query error rethrows here — track it the same way + // the MCP pool path does instead of a bare `catch {}` that dropped it. for (const { table, indexName } of FTS_INDEXES) { try { const result = await queryFTS(table, indexName, searchQuery, limit, false); queriesSucceeded++; resultsByIndex.push(result); - } catch { - // FTS index may not exist — count as failed + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + nonBenignErrors.push(redactPaths(message)); } } } @@ -165,5 +224,6 @@ export const searchFTSFromLbug = async ( nodeIds: r.nodeIds, })), ftsAvailable, + ...(nonBenignErrors.length > 0 && { nonBenignErrors }), }; }; diff --git a/gitnexus/src/core/search/fts-indexes.ts b/gitnexus/src/core/search/fts-indexes.ts index ab098a68c..dfc55286c 100644 --- a/gitnexus/src/core/search/fts-indexes.ts +++ b/gitnexus/src/core/search/fts-indexes.ts @@ -11,17 +11,62 @@ import { FTS_INDEXES } from './fts-schema.js'; * ELF header", "has not been installed") have no leading path separator and * survive. CLI/doctor/log surfaces keep the full path (they read the reason * directly, not through this function). + * + * tri-review Residual-3: every real message shape observed from LadybugDB + * wraps the path in single quotes (`Failed to load library '': ...`), + * so a QUOTED path is redacted first, consuming through its closing quote — + * spaces included (e.g. a Windows username like `alice smith`). The original + * unquoted-stop-at-first-whitespace pattern still runs afterward as a + * fallback for the rare case of a path appearing without quotes; that path's + * own known limitation (partial redaction if it itself contains a space) is + * unchanged, but is no longer the ONLY path this function knows how to redact. */ -const redactPaths = (reason: string): string => - reason.replace(/(?:[A-Za-z]:\\|\/)[^\s'"]+/g, ''); +export const redactPaths = (reason: string): string => + reason + .replace(/'((?:[A-Za-z]:\\|\/)[^']*)'/g, "''") + .replace(/(?:[A-Za-z]:\\|\/)[^\s'"]+/g, ''); + +/** + * Resolved-repo/index identity a caller can attach to a degraded-FTS warning + * (#2767) so a reader can tell whether *this* session even resolved the index + * they expect, instead of guessing between a stale connection, a different + * repo/branch, or a genuine build failure. MCP-`query`-only today — never + * forwarded into the HTTP `/api/search` response (see that call site). + */ +export interface FtsWarningContext { + repoName: string; + branch?: string; + indexedAt?: string; + /** Already redacted by the caller (e.g. via {@link redactPaths} on a captured query error). */ + lastErrorRedacted?: string; +} + +/** The repo/branch/indexed-at portion shared by both warning-context formatters below. */ +const formatResolvedSuffix = (context: FtsWarningContext): string => { + const branchSuffix = context.branch ? `/branch:${context.branch}` : ''; + const indexedSuffix = context.indexedAt ? `, indexed ${context.indexedAt}` : ''; + return `${context.repoName}${branchSuffix}${indexedSuffix}`; +}; + +const formatWarningContext = (context: FtsWarningContext): string => { + const errorSuffix = context.lastErrorRedacted ? `; last error: ${context.lastErrorRedacted}` : ''; + return ` (resolved: ${formatResolvedSuffix(context)}${errorSuffix})`; +}; /** * Warning attached to search responses when BM25/FTS is degraded. Prefers the * live extension-load failure (with LadybugDB's real reason, #2374) over the * generic indexes-missing message, so "indexes exist but the extension broke" * is not misreported as missing indexes. + * + * `context`, when supplied, appends the resolved repo/branch/indexed-at (and + * redacted query-error detail, if captured) so a CLI/MCP mismatch — or a real + * query error masquerading as "indexes missing" — is visible in the warning + * text itself (#2767). Optional and additive: omitting it reproduces today's + * exact message. */ -export const ftsDegradedWarning = (): string => { +export const ftsDegradedWarning = (context?: FtsWarningContext): string => { + const suffix = context ? formatWarningContext(context) : ''; const fts = getExtensionCapabilities().find((c) => c.name === 'fts'); if (fts && !fts.loaded) { const reason = fts.reason ? redactPaths(fts.reason).replace(/\.$/, '') : undefined; @@ -38,12 +83,33 @@ export const ftsDegradedWarning = (): string => { return ( 'FTS extension failed to load — keyword search degraded' + (reason ? ` (${reason})` : '') + - tail + tail + + suffix ); } - return 'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.'; + return ( + 'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts ' + + '(or gitnexus analyze --force) to rebuild indexes.' + + suffix + ); }; +/** + * Warning for when the FTS extension is loaded and indexes exist, but every + * configured table's query failed for a real, non-benign reason (timeout, + * connection reset, native fault) — as opposed to `ftsDegradedWarning`'s + * missing-index case. `--repair-fts` will not fix a query/connection error, + * so this deliberately does NOT suggest it: reusing the missing-index + * message here would reproduce, for this cause, the exact misleading + * "run --repair-fts" guidance #2767 itself was about (tri-review NEW-1). + */ +export const ftsQueryFailedWarning = (context: FtsWarningContext): string => + 'FTS keyword search failed — every configured index query returned an error' + + (context.lastErrorRedacted ? ` (${context.lastErrorRedacted})` : '') + + '; results do not include keyword matches. This is not a missing-index ' + + 'condition — see server logs for details.' + + ` (resolved: ${formatResolvedSuffix(context)})`; + // Stemmers shipped by the LadybugDB FTS extension. Mirrors the lowercase token // set in the extension bundled with @ladybugdb/core 0.18.x (see package.json). // Keep in sync on a LadybugDB minor bump — a value here that the installed diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 694483a6d..c4e9d12a9 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -63,7 +63,7 @@ import { import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME } from '../../core/lbug/schema.js'; import { getExactScanLimit } from '../../core/platform/capabilities.js'; import { PhaseTimer } from '../../core/search/phase-timer.js'; -import { ftsDegradedWarning } from '../../core/search/fts-indexes.js'; +import { ftsDegradedWarning, ftsQueryFailedWarning } from '../../core/search/fts-indexes.js'; import { cjkSegmentationModeMismatch, containsSegmentableCjkRun, @@ -779,6 +779,13 @@ export function attachToolStaleness( }; } +/** tri-review Residual-2: see `LocalBackend.lastObservedPoolState`'s doc comment. */ +interface PoolObservedState { + indexedAt?: string; + dbIdentity: Awaited>; + ftsStatus?: string; +} + export class LocalBackend { private static readonly TOOL_STALENESS_TTL_MS = 5000; private repos: Map = new Map(); @@ -796,18 +803,30 @@ export class LocalBackend { // the other; lbugPath is unique per flat/branch index. private toolStalenessCache: Map }> = new Map(); - // Last meta.indexedAt observed for an open pool, keyed by lbugPath. Keyed by - // pool (not stored on the handle) because branch handles are produced fresh - // by applyBranchScope on every resolveRepo call, so mutating the handle would - // not persist across calls and the staleness check would reinit forever - // (#2106). - private lastObservedIndexedAt: Map = new Map(); - // #2614 F1: file identity of the lbug the pool last opened. An atomic swap or - // an in-place incremental changes the inode; reiniting on that reinit-covers - // the window where meta.indexedAt hasn't caught up (and the incremental case), - // so a rebuilt index is never served stale even when the stamp looks current. - private lastObservedDbIdentity: Map>> = - new Map(); + // tri-review Residual-2: consolidates what were three parallel per-poolKey + // Maps (lastObservedIndexedAt / lastObservedDbIdentity / lastObservedFtsStatus) + // touched in lockstep at every call site below — one Map, one delete, one + // shape. Keyed by lbugPath (not stored on the repo handle) because branch + // handles are produced fresh by applyBranchScope on every resolveRepo call, + // so mutating the handle would not persist across calls and the staleness + // check would reinit forever (#2106). + // - `indexedAt`: last meta.indexedAt observed for an open pool. + // - `dbIdentity`: file identity of the lbug the pool last opened (#2614 F1) + // — an atomic swap or in-place incremental changes the inode; reiniting + // on that covers the window where meta.indexedAt hasn't caught up (and + // the incremental case), so a rebuilt index is never served stale even + // when the stamp looks current. + // - `ftsStatus`: last meta.capabilities.fts.status observed (#2767). + // `--repair-fts` intentionally never restamps `indexedAt` (it doesn't + // regenerate the graph), so this is the dedicated signal a warm session + // uses to notice a repair — independent of the file-identity heuristic, + // which the repair path also triggers but only incidentally. + private lastObservedPoolState: Map = new Map(); + /** Merge-patch one poolKey's observed state, preserving fields not passed. */ + private setObservedState(poolKey: string, patch: Partial): void { + const current = this.lastObservedPoolState.get(poolKey) ?? { dbIdentity: null }; + this.lastObservedPoolState.set(poolKey, { ...current, ...patch }); + } private groupToolSvc: GroupService | null = null; /** * One-shot stderr warnings for sibling-clone drift, keyed by @@ -1143,8 +1162,7 @@ export class LocalBackend { this.initializedRepos.delete(key); this.lastStalenessCheck.delete(key); this.toolStalenessCache.delete(key); - this.lastObservedIndexedAt.delete(key); - this.lastObservedDbIdentity.delete(key); + this.lastObservedPoolState.delete(key); this.reinitPromises.delete(key); closeLbug(key).catch(() => {}); } @@ -1548,10 +1566,11 @@ export class LocalBackend { // Reading the flat meta for a branch handle would compare the branch // index's indexedAt against the primary's and thrash the pool (#2106). const meta = await loadMeta(path.dirname(repo.lbugPath)); + const observedState = this.lastObservedPoolState.get(poolKey); // Compare against the last indexedAt OBSERVED for this pool (keyed by // lbugPath), not the handle's — branch handles are fresh spreads so a // handle mutation would not persist and would reinit on every check. - const observed = this.lastObservedIndexedAt.get(poolKey) ?? repo.indexedAt; + const observed = observedState?.indexedAt ?? repo.indexedAt; const stampChanged = !!meta?.indexedAt && meta.indexedAt !== observed; // #2614 F1: also reinit on a file-identity change. An atomic swap (or an // in-place incremental) changes the lbug inode; keying only on @@ -1559,10 +1578,19 @@ export class LocalBackend { // latch on the old inode forever (its stamp already == meta.indexedAt). const currentIdentity = await statDbIdentity(repo.lbugPath); const identityChanged = dbIdentityChanged( - this.lastObservedDbIdentity.get(poolKey) ?? null, + observedState?.dbIdentity ?? null, currentIdentity, ); - if (stampChanged || identityChanged) { + // #2767: `--repair-fts` intentionally never restamps `indexedAt` (it + // doesn't regenerate the graph), so `stampChanged` alone can't notice + // a repair. `capabilities.fts.status` is the field repair-fts DOES + // write, so a change there is a third, independent reinit trigger — + // sibling to stampChanged/identityChanged, not a replacement for them + // (identityChanged still catches an in-place mutation even if the + // caps stamp were somehow missed). + const ftsStatus = meta?.capabilities?.fts?.status; + const ftsCapsChanged = observedState?.ftsStatus !== ftsStatus; + if (stampChanged || identityChanged || ftsCapsChanged) { // Index was rebuilt/swapped — DELEGATE the close/reopen to the pool's // initLbug, which refuses to evict (and close the shared Database) // while a query is in flight (its checkedOut>0 guard). Calling @@ -1571,17 +1599,22 @@ export class LocalBackend { // reinitPromises to serialize concurrent detectors. const reinit = (async () => { try { - // Advance the observed stamp regardless: a stamp change with an - // unchanged file must not re-trigger on every check. - if (meta?.indexedAt) this.lastObservedIndexedAt.set(poolKey, meta.indexedAt); const reopened = await initLbug(poolKey, repo.lbugPath); + // tri-review NEW-7: advance the observed stamp/caps watermarks + // only AFTER initLbug completes, not before calling it — still + // regardless of `reopened` true/false (a stamp/caps change with + // an unchanged file must not re-trigger on every check), but if + // initLbug THROWS the watermark must stay at its old value so + // the next staleness check retries, instead of a failed reinit + // silently latching as "already applied" and never trying again. + const patch: Partial = { ftsStatus }; + if (meta?.indexedAt) patch.indexedAt = meta.indexedAt; // Advance the observed IDENTITY only when the pool actually rolled // over. If a query was in flight, initLbug served the current // handle and returned false; leaving the identity divergent // re-triggers the reopen on a later idle check instead of latching. - if (reopened) { - this.lastObservedDbIdentity.set(poolKey, await statDbIdentity(repo.lbugPath)); - } + if (reopened) patch.dbIdentity = await statDbIdentity(repo.lbugPath); + this.setObservedState(poolKey, patch); } finally { this.reinitPromises.delete(poolKey); } @@ -1599,8 +1632,18 @@ export class LocalBackend { try { await initLbug(poolKey, repo.lbugPath); this.initializedRepos.add(poolKey); - this.lastObservedIndexedAt.set(poolKey, repo.indexedAt); - this.lastObservedDbIdentity.set(poolKey, await statDbIdentity(repo.lbugPath)); + // #2767: ftsStatus is deliberately left unset (undefined) here rather + // than issuing an extra loadMeta read — every tool call already routes + // through ensureInitialized, so an extra per-cold-init read adds up, + // and the cost of skipping it is negligible: at most one redundant + // initLbug call on the first warm check (initLbug itself no-ops + // cheaply via a single fs.stat when the file identity is actually + // unchanged, per pool-adapter.ts's own "unchanged → reuse" guard), not + // a real reopen. + this.setObservedState(poolKey, { + indexedAt: repo.indexedAt, + dbIdentity: await statDbIdentity(repo.lbugPath), + }); } catch (err: any) { // If lock error, mark as not initialized so next call retries this.initializedRepos.delete(poolKey); @@ -2047,6 +2090,21 @@ export class LocalBackend { // unavailable the search helper may return an unexpected shape. const bm25Results = bm25SearchResult?.results ?? []; const ftsUsed = bm25SearchResult?.ftsUsed ?? false; + // #2767: log every non-benign per-table FTS query error server-side, + // regardless of whether OTHER tables succeeded — previously a real error + // on N-1 of N tables while one succeeded left zero diagnostic trail. + const ftsQueryErrors = bm25SearchResult?.nonBenignErrors; + if (ftsQueryErrors) { + // tri-review NEW-5: these strings are already classified non-benign by + // classifyFtsQueryError — do NOT route them through logQueryError, + // whose own broader, unanchored isBenignMissingTableError regex (any + // "does not exist" substring, anywhere) could disagree and silently + // demote an already-flagged real error to debug, undercutting the + // severity signal this classification exists to preserve. + for (const err of ftsQueryErrors) { + logger.warn({ context: 'query:fts-search', err }, 'GitNexus query failed (degraded)'); + } + } // Merge via reciprocal rank fusion timer.start('merge'); @@ -2326,7 +2384,37 @@ export class LocalBackend { // path, leaving the success-path response shape byte-identical. const warnings: string[] = []; if (!ftsUsed) { - warnings.push(ftsDegradedWarning()); + // #2767: attach what THIS session resolved (repo/branch/indexed-at) so a + // CLI/MCP mismatch is visible in the warning itself rather than requiring + // a separate debugging round-trip. tri-review NEW-3: `indexedAt` reads + // from `lastObservedPoolState` (kept current by ensureInitialized's + // staleness check, including a same-call reinit) rather than the `repo` + // handle resolved before that check ran — a warm backend that just + // reopened against a newer on-disk index must not warn with stale + // metadata. No extra I/O: the map is already maintained per-request. + const warningContext = { + repoName: repo.name, + branch: repo.branch, + indexedAt: this.lastObservedPoolState.get(repo.lbugPath)?.indexedAt ?? repo.indexedAt, + }; + // tri-review NEW-1: every table failing for a REAL error (timeout, + // connection reset) is not a missing-index condition — `ftsDegradedWarning`'s + // "run --repair-fts" headline won't fix it. Route to a dedicated message + // instead of burying the real cause as a trailing suffix on bad advice. + warnings.push( + ftsQueryErrors + ? ftsQueryFailedWarning({ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] }) + : ftsDegradedWarning(warningContext), + ); + } else if (ftsQueryErrors) { + // #2767: at least one FTS table succeeded (ftsUsed=true) but another + // hit a real, non-benign error — results may be silently missing + // matches from that table with no signal, the same "partial success" + // shape the enrichmentDegraded branch below already surfaces. Mirror + // that convention instead of only logging server-side. + warnings.push( + `FTS keyword search partially failed — ${ftsQueryErrors.length} of the configured indexes hit a query error and were skipped; results may be missing matches from those node types (see server logs).`, + ); } // #2331: a CJK query against a server process resolving // GITNEXUS_FTS_CJK_SEGMENTATION to 'none' silently misses sub-phrase @@ -2403,6 +2491,10 @@ export class LocalBackend { 'Symbol enrichment partially failed — some process/cohesion/content data may be missing from these results (see server logs).', ); } + // #2767: a partial FTS failure (some tables ok, one or more real errors) + // is as much a "results may be incomplete" signal as enrichmentDegraded — + // flag it the same way rather than only via the warning string. + const ftsPartial = ftsUsed && !!ftsQueryErrors; return { processes, @@ -2410,7 +2502,7 @@ export class LocalBackend { definitions: definitions.slice(0, 20), // cap standalone definitions timing, ...(warnings.length > 0 && { warning: warnings.join(' ') }), - ...(enrichmentDegraded && { partial: true }), + ...((enrichmentDegraded || ftsPartial) && { partial: true }), }; } @@ -2421,7 +2513,7 @@ export class LocalBackend { repo: RepoHandle, query: string, limit: number, - ): Promise<{ results: any[]; ftsUsed: boolean }> { + ): Promise<{ results: any[]; ftsUsed: boolean; nonBenignErrors?: string[] }> { let searchFTSFromLbug; try { ({ searchFTSFromLbug } = await import('../../core/search/bm25-index.js')); @@ -2453,6 +2545,7 @@ export class LocalBackend { // could be undefined when the FTS extension is unavailable in the MCP process. const bm25Results = ftsResponse?.results ?? []; const ftsUsed = ftsResponse?.ftsAvailable ?? false; + const nonBenignErrors = ftsResponse?.nonBenignErrors; const results: any[] = []; @@ -2524,7 +2617,7 @@ export class LocalBackend { } } - return { results, ftsUsed }; + return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) }; } /** diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 8d3d0aeda..031f148c4 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -1833,8 +1833,16 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => }, pendingNodeIds: string[], ): Promise => { + // tri-review NEW-2: re-read immediately before writing (mirrors + // the pattern in run-analyze.ts's --repair-fts stamp) instead of + // spreading the stale `embeddingMeta` snapshot captured once at + // job start. This job can run up to EMBED_TIMEOUT_MS (30 min); + // without a fresh read, a concurrent writer's update (e.g. a + // --repair-fts capability stamp) would be silently reverted on + // every checkpoint save for the job's whole lifetime. + const latestMeta = (await loadMeta(entry.storagePath)) ?? embeddingMeta; embeddingMeta = { - ...embeddingMeta, + ...latestMeta, embeddingCheckpoint: { at: new Date().toISOString(), ...checkpoint, @@ -1896,7 +1904,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // handles this during process exit, but the server keeps the // connection open for other routes — a CHECKPOINT is enough. await flushWAL(); - embeddingMeta = { ...embeddingMeta, embeddingCheckpoint: undefined }; + // Same re-read-before-write reasoning as saveEmbeddingCheckpoint above. + const finalMeta = (await loadMeta(entry.storagePath)) ?? embeddingMeta; + embeddingMeta = { ...finalMeta, embeddingCheckpoint: undefined }; await saveMeta(entry.storagePath, embeddingMeta); }); diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 6b8b6f19b..7ddc84bb1 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -187,9 +187,12 @@ export interface RepoMeta { * the meta literal in run-analyze.ts — typed here so the stamp site is * compile-checked; tri-review 4669518496 P1/U3: `vectorSearch.status` * must never claim 'vector-index' unless the run verified or recreated - * the HNSW index). Forensic today — no programmatic readers (`doctor` - * prints platform-derived capabilities, query routing never consults - * meta). The status unions mirror `CapabilityStatus` / + * the HNSW index). `fts.status` gained its first programmatic reader in + * #2767: `LocalBackend.ensureInitialized()` compares it against the + * warm connection pool's last-observed value as the dedicated signal + * that `--repair-fts` changed FTS availability (`doctor` still prints + * platform-derived capabilities separately; `graph`/`vectorSearch` remain + * forensic-only). The status unions mirror `CapabilityStatus` / * `SemanticSearchMode` in core/platform/capabilities.ts; inlined to keep * storage/ free of a core/ type dependency. */ diff --git a/gitnexus/test/integration/fts-repair-warm-session.test.ts b/gitnexus/test/integration/fts-repair-warm-session.test.ts new file mode 100644 index 000000000..2550ba874 --- /dev/null +++ b/gitnexus/test/integration/fts-repair-warm-session.test.ts @@ -0,0 +1,169 @@ +/** + * Integration test for issue #2767: the MCP `query` tool reported "FTS + * indexes missing" against an index the CLI could search successfully, + * because a long-lived MCP session's pooled read-only connection had no + * reliable signal that `gitnexus analyze --repair-fts` changed FTS + * availability (repair-fts intentionally never restamps `indexedAt`). + * + * Everything real: a real writable LadybugDB session builds the initial + * index WITHOUT FTS (the exact shape implied by the original report — FTS + * built later), a real `LocalBackend` resolves it via the real registry and + * issues a real `query` tool call through the real connection pool, then a + * SEPARATE real writable session performs the repair (real + * `createSearchFTSIndexes`, real `saveMeta` capability stamp — the same + * production functions `--repair-fts` calls), and the SAME still-warm + * `LocalBackend` instance re-queries without any restart. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import { createTempDir } from '../helpers/test-db.js'; +import { resolveAnalyzeInstallPolicy } from '../../src/core/lbug/extension-loader.js'; +import { + getStoragePaths, + registerRepo, + saveMeta, + type RepoMeta, +} from '../../src/storage/repo-manager.js'; +import { closeLbug as poolClose } from '../../src/core/lbug/pool-adapter.js'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; + +const REQUIRE_FTS = process.env.GITNEXUS_REQUIRE_FTS === '1'; + +type QueryResult = { + error?: unknown; + warning?: string; + definitions?: Array<{ id: string }>; + process_symbols?: Array<{ id: string }>; +}; + +const matchedIds = (r: QueryResult): string[] => + [...(r.process_symbols ?? []), ...(r.definitions ?? [])].map((s) => s.id); + +const ftsMissing = (r: QueryResult): boolean => + typeof r.warning === 'string' && /FTS indexes missing/i.test(r.warning); + +/** + * Poll the SAME warm `LocalBackend` until it stops reporting FTS-missing, or + * the deadline passes. Exercises the real 5s staleness-check throttle + * (`ensureInitialized`) rather than sleeping-and-hoping or reaching into + * backend internals to bypass it — proves the fix holds within the actual + * production timing window. + */ +async function waitForFtsRecognized( + backend: LocalBackend, + query: string, + // Production throttle is 5s (`lastStalenessCheck`); this deadline leaves a + // generous margin beyond it for a loaded CI runner, per review feedback + // that the original 7s deadline left only ~2s of slack (#2767). + timeoutMs = 15000, + intervalMs = 300, +): Promise { + const deadline = Date.now() + timeoutMs; + let last: QueryResult; + do { + last = await backend.callTool('query', { query }); + if (!ftsMissing(last)) return last; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } while (Date.now() < deadline); + return last!; +} + +describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', () => { + let tmpHandle: Awaited>; + let repoPath: string; + let storagePath: string; + let lbugPath: string; + let savedHome: string | undefined; + + beforeEach(async () => { + tmpHandle = await createTempDir('gnx-fts-repair-warm-'); + repoPath = tmpHandle.dbPath; + savedHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = path.join(repoPath, '.gitnexus-home'); + ({ storagePath, lbugPath } = getStoragePaths(repoPath)); + }); + + afterEach(async () => { + await poolClose(lbugPath).catch(() => {}); + if (savedHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedHome; + await tmpHandle.cleanup(); + }); + + it( + 'a warm session transitions from FTS-unavailable to FTS-available without restarting, after an out-of-band --repair-fts', + { timeout: 60_000 }, + async (ctx) => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const { createSearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js'); + + // ── Step 1: build the index WITHOUT FTS (analyzed before repair) ──── + await adapter.initLbug(lbugPath); + + const ftsAvailable = await adapter.loadFTSExtension(undefined, { + policy: resolveAnalyzeInstallPolicy(), + }); + if (!ftsAvailable) { + if (REQUIRE_FTS) { + throw new Error( + 'FTS extension is required (GITNEXUS_REQUIRE_FTS=1) but could not be loaded — ' + + 'this FTS-dependent integration test must not be silently skipped in CI.', + ); + } + await adapter.closeLbug(); + ctx.skip(); + return; + } + + await adapter.executeQuery( + `CREATE (n:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 3, content: 'function login() { return true; }'})`, + ); + await adapter.flushWAL(); + await adapter.closeLbug(); + + const indexedAt = new Date().toISOString(); + const baseMeta: RepoMeta = { + repoPath, + lastCommit: 'c1', + indexedAt, + stats: { files: 1, nodes: 1 }, + capabilities: { + graph: { provider: 'ladybugdb', status: 'available' }, + fts: { provider: 'ladybugdb-fts', status: 'unavailable' }, + vectorSearch: { provider: 'exact-scan', status: 'unavailable', exactScanLimit: 0 }, + }, + }; + await saveMeta(storagePath, baseMeta); + await registerRepo(repoPath, baseMeta, { name: 'test-repo' }); + + // ── Step 2: a real warm LocalBackend observes "FTS unavailable" ───── + const backend = new LocalBackend(); + await backend.init(); + const before = await backend.callTool('query', { query: 'login' }); + expect(before.error).toBeUndefined(); + expect(ftsMissing(before)).toBe(true); + + // ── Step 3: out-of-band --repair-fts (separate writable session) ──── + // Same production functions the repair-fts branch of runFullAnalysis + // calls — real FTS build, then the #2767 capability-only meta stamp + // (indexedAt/lastCommit deliberately unchanged, R4). + await adapter.initLbug(lbugPath); + await createSearchFTSIndexes(); + await adapter.flushWAL(); + await adapter.closeLbug(); + await saveMeta(storagePath, { + ...baseMeta, + capabilities: { + ...baseMeta.capabilities!, + fts: { provider: 'ladybugdb-fts', status: 'available' }, + }, + }); + + // ── Step 4: the SAME still-warm backend re-queries — no restart ───── + const after = await waitForFtsRecognized(backend, 'login'); + expect(after.error).toBeUndefined(); + expect(ftsMissing(after)).toBe(false); + expect(matchedIds(after)).toContain('func:login'); + }, + ); +}); diff --git a/gitnexus/test/unit/bm25-search.test.ts b/gitnexus/test/unit/bm25-search.test.ts index f6105918b..793cf7aae 100644 --- a/gitnexus/test/unit/bm25-search.test.ts +++ b/gitnexus/test/unit/bm25-search.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { searchFTSFromLbug, type BM25SearchResult } from '../../src/core/search/bm25-index.js'; +import { classifyFtsQueryError } from '../../src/core/lbug/lbug-adapter.js'; +import { extensionManager, resetExtensionState } from '../../src/core/lbug/extension-loader.js'; import { FTS_INDEXES } from '../../src/core/search/fts-schema.js'; vi.mock('../../src/core/lbug/lbug-adapter.js', async (importOriginal) => { @@ -315,6 +317,183 @@ describe('BM25 search', () => { }); }); + describe('classifyFtsQueryError (#2767)', () => { + it('classifies the real "doesn\'t have an index" message (confirmed against a live QUERY_FTS_INDEX call) as missing-index', () => { + expect( + classifyFtsQueryError( + "Prepare failed: Binder exception: Table File doesn't have an index with name file_fts.", + ), + ).toBe('missing-index'); + }); + + it('classifies the real "table does not exist" message (confirmed against a live QUERY_FTS_INDEX call on a nonexistent table) as missing-table, distinct from missing-index (tri-review NEW-6)', () => { + // Empirically confirmed: the table-missing message uses "does not + // exist", NOT "doesn't have an index with name" — a genuinely different + // phrasing from missing-index, not the same condition under two names. + // Conflating them was the exact bug: a corrupted/partial DB (table + // itself gone) would have been silently treated as the ordinary + // "index not built yet" case. + expect( + classifyFtsQueryError( + 'Prepare failed: Binder exception: Table TotallyNonexistentTable does not exist.', + ), + ).toBe('missing-table'); + }); + + it('classifies a Catalog-exception "does not exist" message as missing-table too (both exception classes covered)', () => { + expect(classifyFtsQueryError('Catalog exception: Table SomeTable does not exist.')).toBe( + 'missing-table', + ); + }); + + it('classifies the extension-unavailable Catalog exception as other, not benign (mirrors the confirmed DROP_FTS_INDEX shape for QUERY_FTS_INDEX)', () => { + // Message shape confirmed for DROP_FTS_INDEX in + // drop-fts-index-error-classification.test.ts; QUERY_FTS_INDEX would + // fail identically when the extension isn't loaded (same catalog). + expect( + classifyFtsQueryError( + "Catalog exception: function QUERY_FTS_INDEX is not defined. This function exists in the FTS extension. You can install and load the extension by running 'INSTALL FTS; LOAD EXTENSION FTS;'.", + ), + ).toBe('other'); + }); + + it('does not misclassify a real, differently-classed error that echoes the benign phrase in its body', () => { + // Adversarial case: a Runtime exception (not Binder/Catalog) that + // happens to echo the user's own search text — which could itself + // contain "does not exist" — must not be anchored away as benign. + expect( + classifyFtsQueryError( + 'Runtime exception: FTS query syntax error near "the config file does not exist here"', + ), + ).toBe('other'); + }); + + it('does not misclassify a real Binder-class error unrelated to a missing FTS index', () => { + expect(classifyFtsQueryError('Binder exception: column X does not match expected type')).toBe( + 'other', + ); + }); + + it('classifies any other message as other', () => { + expect(classifyFtsQueryError('Query execution timed out after 30000ms')).toBe('other'); + expect(classifyFtsQueryError('Connection pool exhausted')).toBe('other'); + }); + }); + + describe('MCP pool path — real vs benign FTS query errors (#2767)', () => { + const REPO = 'test-repo-error-classification'; + + beforeEach(() => { + mockExecuteParameterized.mockReset(); + }); + + it('a benign missing-index error on every table leaves nonBenignErrors unset (unchanged behavior)', async () => { + mockExecuteParameterized.mockRejectedValue( + new Error("Binder exception: Table Function doesn't have an index with name function_fts."), + ); + + const response = await searchFTSFromLbug('login', 5, REPO); + + expect(response.ftsAvailable).toBe(false); + expect(response.nonBenignErrors).toBeUndefined(); + }); + + it('a missing-table error (table itself gone, not just its FTS index) surfaces as non-benign — schema drift is not the ordinary degraded state (tri-review NEW-6)', async () => { + mockExecuteParameterized.mockRejectedValue( + new Error('Binder exception: Table Function does not exist.'), + ); + + const response = await searchFTSFromLbug('login', 5, REPO); + + expect(response.ftsAvailable).toBe(false); + expect(response.nonBenignErrors!.length).toBeGreaterThan(0); + }); + + it('a real error on every table surfaces it in nonBenignErrors, redacted', async () => { + mockExecuteParameterized.mockRejectedValue( + new Error( + 'Query execution failed: connection reset at /home/alice/.gitnexus/lbug/main.lbug', + ), + ); + + const response = await searchFTSFromLbug('login', 5, REPO); + + expect(response.ftsAvailable).toBe(false); + expect(response.nonBenignErrors).toBeDefined(); + expect(response.nonBenignErrors!.length).toBeGreaterThan(0); + expect(response.nonBenignErrors![0]).toContain('connection reset'); + expect(response.nonBenignErrors![0]).not.toMatch(/\/home\/alice/); + }); + + it('a real error on one table while another succeeds is still reported (partial-failure gap closed)', async () => { + let call = 0; + mockExecuteParameterized.mockImplementation(async (_repo: string, cypher: string) => { + call++; + if (cypher.includes("QUERY_FTS_INDEX('Function'")) { + throw new Error('Query execution timed out after 30000ms'); + } + if (cypher.includes("QUERY_FTS_INDEX('File'")) { + return [{ node: { filePath: 'src/index.ts', id: 'file:index' }, score: 3 }]; + } + return []; + }); + + const response = await searchFTSFromLbug('login', 5, REPO); + + // At least one table succeeded, so the client-visible availability + // signal and result set are unaffected (regression guard). + expect(response.ftsAvailable).toBe(true); + expect(response.results.length).toBeGreaterThan(0); + // But the real error on the OTHER table is not silently dropped. + expect(response.nonBenignErrors).toBeDefined(); + expect(response.nonBenignErrors![0]).toContain('timed out'); + expect(call).toBe(FTS_INDEXES.length); + }); + }); + + describe('short-circuits when the FTS extension is unavailable (tri-review NEW-4)', () => { + const REPO = 'test-repo-extension-unavailable'; + + afterEach(() => { + resetExtensionState(); + }); + + it('MCP pool path: skips per-table QUERY_FTS_INDEX calls and reports no nonBenignErrors when the extension failed to load', async () => { + await extensionManager.ensure( + vi.fn().mockRejectedValue(new Error('invalid ELF header.')), + 'fts', + 'FTS', + { policy: 'load-only' }, + ); + mockExecuteParameterized.mockReset(); + + const response = await searchFTSFromLbug('login', 5, REPO); + + // The expected degraded-capability state — not per-table query errors. + expect(response.ftsAvailable).toBe(false); + expect(response.nonBenignErrors).toBeUndefined(); + // No redundant round-trips to a pool that can't have FTS loaded. + expect(mockExecuteParameterized).not.toHaveBeenCalled(); + }); + + it('CLI/pipeline path (no repoId): also skips per-table calls and reports no nonBenignErrors — same expected state, same silence (fixes the pool-only guard a /simplify altitude pass caught)', async () => { + const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js'); + await extensionManager.ensure( + vi.fn().mockRejectedValue(new Error('invalid ELF header.')), + 'fts', + 'FTS', + { policy: 'load-only' }, + ); + vi.mocked(queryFTS).mockClear(); + + const response = await searchFTSFromLbug('login', 5); // no repoId → CLI/pipeline branch + + expect(response.ftsAvailable).toBe(false); + expect(response.nonBenignErrors).toBeUndefined(); + expect(vi.mocked(queryFTS)).not.toHaveBeenCalled(); + }); + }); + describe('GITNEXUS_FTS_CJK_SEGMENTATION query-side transform (#2331)', () => { const CJK_REPO = 'test-repo-cjk-query'; diff --git a/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts b/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts new file mode 100644 index 000000000..5dc089e56 --- /dev/null +++ b/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// tri-review NEW-7: `lastObservedIndexedAt`/`lastObservedFtsStatus` used to be +// advanced BEFORE `initLbug` was awaited, unlike `lastObservedDbIdentity` +// (which is only advanced once `initLbug` confirms the pool rolled over). If +// `initLbug` threw, the watermark had already been latched to the new value — +// permanently hiding a failed reinit from every later staleness check, since +// a subsequent comparison against that same watermark would see no change. +// This isolates the fix: a poolKey with `identityChanged` always false (a +// nonexistent lbugPath — no backstop from the file-identity signal) must +// still retry after a transient `initLbug` failure. + +const initLbugMock = vi.fn(); +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + initLbug: (...args: any[]) => initLbugMock(...args), + isLbugReady: vi.fn().mockReturnValue(true), + }; +}); + +const loadMetaMock = vi.fn(); +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadMeta: (...args: any[]) => loadMetaMock(...args), + }; +}); + +import { LocalBackend } from '../../src/mcp/local/local-backend'; + +describe('ensureInitialized reinit watermark (tri-review NEW-7)', () => { + const poolKey = '/tmp/nonexistent-repo/.gitnexus/lbug'; + const repoHandle = { id: 'r1', name: 'r1', lbugPath: poolKey, indexedAt: 'v0' } as any; + let backend: any; + + beforeEach(() => { + vi.clearAllMocks(); + backend = new LocalBackend() as any; + // Seed the "warm, already initialized" precondition ensureInitialized + // requires to reach the staleness-check branch at all. + backend.initializedRepos.add(poolKey); + backend.lastStalenessCheck.set(poolKey, 0); // force past the 5s throttle + }); + + it('does not latch the fts-status watermark when initLbug throws, so the next staleness check retries', async () => { + loadMetaMock.mockResolvedValue({ + indexedAt: 'v1', + capabilities: { fts: { status: 'available' } }, + }); + initLbugMock.mockRejectedValueOnce(new Error('lock timeout')); + + await expect(backend.ensureInitialized(repoHandle)).rejects.toThrow('lock timeout'); + + // The failed reinit must NOT have advanced either watermark — a nonexistent + // lbugPath means dbIdentity never changes, so there is no other signal to + // fall back on for a retry. + expect(backend.lastObservedPoolState.get(poolKey)?.ftsStatus).toBeUndefined(); + expect(backend.lastObservedPoolState.get(poolKey)?.indexedAt).toBeUndefined(); + + // Second staleness check (throttle reset again) with initLbug now succeeding. + backend.lastStalenessCheck.set(poolKey, 0); + initLbugMock.mockResolvedValueOnce(false); // "no real reopen needed" — still a completed call + + await expect(backend.ensureInitialized(repoHandle)).resolves.toBeUndefined(); + + // The retry succeeded and the watermark is now current — proving the + // failed first attempt did not permanently suppress detection. + expect(backend.lastObservedPoolState.get(poolKey)?.ftsStatus).toBe('available'); + expect(backend.lastObservedPoolState.get(poolKey)?.indexedAt).toBe('v1'); + expect(initLbugMock).toHaveBeenCalledTimes(2); + }); +}); + +describe('ensureInitialized ftsCapsChanged trigger, isolated from identityChanged (tri-review Residual-4)', () => { + // The only existing coverage for this reinit trigger is the integration + // test in fts-repair-warm-session.test.ts, where a REAL --repair-fts run + // also mutates the lbug file — so identityChanged is confounded with + // ftsCapsChanged there, and it's impossible to tell from that test alone + // whether ftsCapsChanged is actually load-bearing. This isolates it: a + // nonexistent lbugPath means statDbIdentity always resolves null, so + // identityChanged is provably false on every check — the ONLY way a reinit + // can fire here is via ftsCapsChanged (indexedAt is held constant too, so + // stampChanged is also false). + const poolKey = '/tmp/nonexistent-repo-caps-only/.gitnexus/lbug'; + const repoHandle = { id: 'r2', name: 'r2', lbugPath: poolKey, indexedAt: 'same' } as any; + let backend: any; + + beforeEach(() => { + vi.clearAllMocks(); + backend = new LocalBackend() as any; + backend.initializedRepos.add(poolKey); + backend.lastStalenessCheck.set(poolKey, 0); + }); + + it('fires a reinit on a capabilities.fts.status change alone, with indexedAt and dbIdentity both unchanged', async () => { + // Seed a baseline observed state: same indexedAt the next loadMeta will + // report, a DIFFERENT ftsStatus, and dbIdentity null (matches what + // statDbIdentity will keep returning for this nonexistent path). + backend.lastObservedPoolState.set(poolKey, { + indexedAt: 'same', + ftsStatus: 'unavailable', + dbIdentity: null, + }); + + loadMetaMock.mockResolvedValue({ + indexedAt: 'same', // unchanged — stampChanged must be false + capabilities: { fts: { status: 'available' } }, // changed — the only live signal + }); + initLbugMock.mockResolvedValueOnce(true); + + await backend.ensureInitialized(repoHandle); + + // A reinit only happens inside the `if (stampChanged || identityChanged + // || ftsCapsChanged)` branch — initLbug being called at all here proves + // ftsCapsChanged fired, since the other two provably could not have. + expect(initLbugMock).toHaveBeenCalledTimes(1); + expect(backend.lastObservedPoolState.get(poolKey)?.ftsStatus).toBe('available'); + }); + + it('does NOT fire a reinit when nothing observable changed (negative control)', async () => { + backend.lastObservedPoolState.set(poolKey, { + indexedAt: 'same', + ftsStatus: 'available', + dbIdentity: null, + }); + + loadMetaMock.mockResolvedValue({ + indexedAt: 'same', + capabilities: { fts: { status: 'available' } }, // same as observed + }); + + await backend.ensureInitialized(repoHandle); + + expect(initLbugMock).not.toHaveBeenCalled(); + }); +}); diff --git a/gitnexus/test/unit/fts-degraded-warning.test.ts b/gitnexus/test/unit/fts-degraded-warning.test.ts index fe5cc0554..42425be99 100644 --- a/gitnexus/test/unit/fts-degraded-warning.test.ts +++ b/gitnexus/test/unit/fts-degraded-warning.test.ts @@ -79,6 +79,28 @@ describe('ftsDegradedWarning (#2374)', () => { expect(warning).toContain('not a valid Win32 application'); }); + it('fully redacts a Windows path containing a space in the username (tri-review Residual-3, was only partially redacted)', async () => { + await extensionManager.ensure( + vi + .fn() + .mockRejectedValue( + new Error( + "Failed to load library 'C:\\Users\\alice smith\\.lbdb\\extension\\0.18.0\\win_amd64\\fts\\libfts.lbug_extension': not a valid Win32 application", + ), + ), + 'fts', + 'FTS', + { policy: 'load-only' }, + ); + + const warning = ftsDegradedWarning(); + // Neither the drive-letter prefix NOR the tail after the space may leak. + expect(warning).not.toMatch(/C:\\Users\\/); + expect(warning).not.toContain('smith'); + expect(warning).not.toContain('alice'); + expect(warning).toContain('not a valid Win32 application'); + }); + it('surfaces the runtime-install remedy, not reinstall, for a Windows missing-dependency error', async () => { await extensionManager.ensure( vi @@ -139,3 +161,68 @@ describe('ftsDegradedWarning (#2374)', () => { expect(ftsDegradedWarning()).toMatch(/Visual C\+\+/); }); }); + +describe('ftsDegradedWarning resolved-repo context (#2767)', () => { + it('omits the context suffix entirely when no context is passed (unchanged message)', async () => { + await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', { + policy: 'load-only', + }); + + expect(ftsDegradedWarning()).toBe( + 'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.', + ); + }); + + it('appends the resolved repo name and indexed-at on the indexes-missing branch', async () => { + await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', { + policy: 'load-only', + }); + + const warning = ftsDegradedWarning({ + repoName: 'myrepo', + indexedAt: '2026-07-30T12:00:00.000Z', + }); + expect(warning).toContain('FTS indexes missing'); + expect(warning).toContain('resolved: myrepo'); + expect(warning).toContain('indexed 2026-07-30T12:00:00.000Z'); + }); + + it('includes the branch label when the resolved handle is branch-scoped', async () => { + await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', { + policy: 'load-only', + }); + + const warning = ftsDegradedWarning({ repoName: 'myrepo', branch: 'feature/x' }); + expect(warning).toContain('branch:feature/x'); + }); + + it('never leaks an absolute path via the context suffix', async () => { + await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', { + policy: 'load-only', + }); + + const warning = ftsDegradedWarning({ + repoName: 'myrepo', + lastErrorRedacted: 'connection reset', + }); + expect(warning).not.toMatch(/\/home\/|\/Users\/|C:\\Users\\/); + expect(warning).toContain('last error: connection reset'); + }); + + it('also renders the context suffix on the extension-failed-to-load branch', async () => { + await extensionManager.ensure( + vi.fn().mockRejectedValue(new Error('invalid ELF header.')), + 'fts', + 'FTS', + { policy: 'load-only' }, + ); + + const warning = ftsDegradedWarning({ + repoName: 'myrepo', + indexedAt: '2026-07-30T12:00:00.000Z', + }); + expect(warning).toContain('FTS extension failed to load'); + expect(warning).toContain('resolved: myrepo'); + expect(warning).toContain('indexed 2026-07-30T12:00:00.000Z'); + }); +}); diff --git a/gitnexus/test/unit/query-degraded-signal.test.ts b/gitnexus/test/unit/query-degraded-signal.test.ts index e72b9a7ce..a7ed807e8 100644 --- a/gitnexus/test/unit/query-degraded-signal.test.ts +++ b/gitnexus/test/unit/query-degraded-signal.test.ts @@ -46,7 +46,7 @@ import { LocalBackend } from '../../src/mcp/local/local-backend'; // A backend whose hybrid search yields exactly one matched symbol, so the // enrichment chunk loop runs and can be made to fail. `ftsUsed` is parameterized // so we can exercise the FTS-missing + enrichment-degraded composition. -function makeBackend(ftsUsed = true): LocalBackend { +function makeBackend(ftsUsed = true, nonBenignErrors?: string[]): LocalBackend { const backend = new LocalBackend(); const repoHandle = { id: 'repo1', @@ -68,7 +68,9 @@ function makeBackend(ftsUsed = true): LocalBackend { startLine: 1, endLine: 2, }; - (backend as any).bm25Search = vi.fn().mockResolvedValue({ results: [sym], ftsUsed }); + (backend as any).bm25Search = vi + .fn() + .mockResolvedValue({ results: [sym], ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) }); (backend as any).semanticSearch = vi.fn().mockResolvedValue([]); return { backend, repoHandle } as any; } @@ -133,6 +135,102 @@ describe('query: degraded-enrichment signal', () => { expect(result.warning.toLowerCase()).toContain('enrichment'); }); + it('a non-benign FTS query error is logged AND surfaced as a partial-result warning even when FTS overall succeeded (#2767)', async () => { + const cap: LoggerCapture = _captureLogger(); + try { + const b = makeBackend(true, ['Query execution timed out after 30000ms']); + executeParameterizedMock.mockResolvedValue([]); + + const result = await runQuery(b); + + // Real error must reach the server log (previously silent)... + expect(result).not.toHaveProperty('error'); + const record = cap.records().find((r) => r.context === 'query:fts-search'); + expect(record).toBeDefined(); + // ...AND the client sees it: mirrors the enrichmentDegraded convention + // for "some succeeded, one genuinely failed" (#2767). + expect(result.partial).toBe(true); + expect(result.warning).toMatch(/FTS keyword search partially failed/); + } finally { + cap.restore(); + } + }); + + it('logs an already-classified non-benign FTS error at warn even when it echoes "does not exist" (tri-review NEW-5)', async () => { + const cap: LoggerCapture = _captureLogger(); // default 'info' level — a debug-level record would be invisible here + try { + // Adversarial shape from classifyFtsQueryError's own doc comment: a real + // error whose body happens to contain the benign phrase. It's already in + // nonBenignErrors (classifyFtsQueryError correctly refused to call it + // benign), so it must not be silently re-demoted to debug by a second, + // broader classifier on the logging path. + const b = makeBackend(true, [ + 'Runtime exception: FTS query syntax error near "the config file does not exist here"', + ]); + executeParameterizedMock.mockResolvedValue([]); + + await runQuery(b); + + const record = cap.records().find((r) => r.context === 'query:fts-search'); + expect(record).toBeDefined(); + expect(record!.level).toBeGreaterThanOrEqual(40); // pino 'warn', not 'debug' (20) + } finally { + cap.restore(); + } + }); + + it('does not flag partial when FTS fully succeeds with no query errors', async () => { + const b = makeBackend(true); + executeParameterizedMock.mockResolvedValue([]); + + const result = await runQuery(b); + + expect(result.partial).toBeUndefined(); + expect(result.warning).toBeUndefined(); + }); + + it('surfaces a dedicated query-failed warning (not missing-index advice) when every table failed for a real error (tri-review NEW-1)', async () => { + const b = makeBackend(false, ['connection reset']); + executeParameterizedMock.mockResolvedValue([]); + + const result = await runQuery(b); + + // The indexes are NOT missing here — --repair-fts would not help, so the + // misleading missing-index advice must not be the headline. + expect(result.warning).not.toContain('FTS indexes missing'); + expect(result.warning).not.toContain('repair-fts'); + expect(result.warning).toContain('FTS keyword search failed'); + expect(result.warning).toContain('connection reset'); + }); + + it('the FTS-missing warning uses the freshly-observed indexedAt, not a stale cached repo handle (tri-review NEW-3)', async () => { + const b = makeBackend(false); // FTS unavailable + executeParameterizedMock.mockResolvedValue([]); + // Simulate ensureInitialized having just reinit'd against a newer index — + // it keeps lastObservedPoolState current but never mutates the caller's + // `repo` handle (a fresh spread per call), which still reads the old value. + (b.backend as any).lastObservedPoolState.set('/tmp/repo/.gitnexus/lbug', { + indexedAt: 'fresher-than-repo-handle', + dbIdentity: null, + }); + + const result = await runQuery(b); + + expect(result.warning).toContain('indexed fresher-than-repo-handle'); + expect(result.warning).not.toContain('indexed now'); // the stale repo.indexedAt value + }); + + it('the FTS-missing warning includes the resolved repo name and indexed-at (#2767)', async () => { + const b = makeBackend(false); // FTS unavailable + executeParameterizedMock.mockResolvedValue([]); + + const result = await runQuery(b); + + expect(result.warning).toContain('FTS indexes missing'); + expect(result.warning).toContain('resolved: repo1'); + expect(result.warning).toContain('indexed now'); + }); + it('warns when a CJK query hits a server resolving segmentation to none (#2331)', async () => { const b = makeBackend(true); executeParameterizedMock.mockResolvedValue([]); diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts index 3dc0be797..67b35fcbc 100644 --- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts @@ -259,6 +259,246 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => { } }); + const mockRepairSuccessLbugAdapter = (overrides: Record = {}) => ({ + initLbug: vi.fn(async () => undefined), + loadGraphToLbug: vi.fn(async () => undefined), + getLbugStats: vi.fn(async () => ({})), + executeQuery: vi.fn(async () => []), + executeWithReusedStatement: vi.fn(async () => []), + closeLbug: vi.fn(async () => undefined), + wipeLbugDbFiles: vi.fn(async () => undefined), + loadCachedEmbeddings: vi.fn(async () => ({ embeddingNodeIds: new Set(), embeddings: [] })), + deleteNodesForFile: vi.fn(async () => undefined), + deleteNodesForFiles: vi.fn(async () => undefined), + deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined), + queryImporters: vi.fn(async () => []), + queryImportersBatch: vi.fn(async () => []), + loadFTSExtension: vi.fn(async () => true), + ...overrides, + }); + + it('--repair-fts stamps capabilities.fts.status while leaving indexedAt/lastCommit/runnerIdentity/stats byte-identical (#2767)', async () => { + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter()); + vi.doMock('../../src/core/search/fts-indexes.js', () => ({ + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + createSearchFTSIndexes: vi.fn(async () => undefined), + verifySearchFTSIndexes: vi.fn(async () => []), + })); + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ + ...(await importActual()), + ensureGitNexusIgnored: vi.fn(async () => undefined), + })); + + const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-stamp-'); + try { + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + const seededIndexedAt = new Date('2026-01-01T00:00:00.000Z').toISOString(); + const seeded: RepoMeta = { + repoPath: tmpRepo.dbPath, + lastCommit: 'abc123', + indexedAt: seededIndexedAt, + stats: { files: 7, nodes: 42, edges: 10 }, + runnerIdentity: { + source: { kind: 'source' as const, digest: 'src-digest' }, + build: { + kind: 'source' as const, + rootPath: '/x', + canonicalization: 'gitnexus-analyzer-build-v2', + digest: 'build-digest', + }, + dependencyRuntime: { + manifestPath: '/x/package.json', + lockfilePath: null, + canonicalization: 'gitnexus-analyzer-dependency-runtime-v4', + packageCount: 1, + artifactCount: 1, + digest: 'dep-digest', + }, + }, + capabilities: { + graph: { provider: 'ladybugdb', status: 'available' }, + fts: { provider: 'ladybugdb-fts', status: 'degraded' }, + vectorSearch: { provider: 'exact-scan', status: 'unavailable', exactScanLimit: 500 }, + }, + }; + await saveMeta(storagePath, seeded); + await createPlaceholderGraphStore(lbugPath); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const result = await runFullAnalysis( + tmpRepo.dbPath, + { repairFts: true }, + { onProgress: () => {} }, + ); + expect(result.ftsRepairedOnly).toBe(true); + + const meta = JSON.parse(await fs.readFile(`${storagePath}/gitnexus.json`, 'utf-8')); + expect(meta.capabilities.fts.status).toBe('available'); + // Everything repair-fts must NOT touch stays byte-identical (R4). + expect(meta.indexedAt).toBe(seededIndexedAt); + expect(meta.lastCommit).toBe('abc123'); + expect(meta.runnerIdentity).toEqual(seeded.runnerIdentity); + expect(meta.stats).toEqual(seeded.stats); + // graph/vectorSearch, which repair-fts also never touches, pass through. + expect(meta.capabilities.graph).toEqual(seeded.capabilities!.graph); + expect(meta.capabilities.vectorSearch).toEqual(seeded.capabilities!.vectorSearch); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('--repair-fts backfills a full capabilities object when the existing meta predates the field entirely (#2767)', async () => { + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter()); + vi.doMock('../../src/core/search/fts-indexes.js', () => ({ + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + createSearchFTSIndexes: vi.fn(async () => undefined), + verifySearchFTSIndexes: vi.fn(async () => []), + })); + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ + ...(await importActual()), + ensureGitNexusIgnored: vi.fn(async () => undefined), + })); + + const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-legacy-meta-'); + try { + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + // Legacy shape: no `capabilities` key at all (pre-#2658 meta.json). + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: '', + indexedAt: new Date().toISOString(), + stats: {}, + }); + await createPlaceholderGraphStore(lbugPath); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + // Must not throw — a partial-capabilities spread over `undefined` would + // otherwise violate RepoMeta.capabilities' required sub-fields. + const result = await runFullAnalysis( + tmpRepo.dbPath, + { repairFts: true }, + { onProgress: () => {} }, + ); + expect(result.ftsRepairedOnly).toBe(true); + + const meta = JSON.parse(await fs.readFile(`${storagePath}/gitnexus.json`, 'utf-8')); + expect(meta.capabilities.fts.status).toBe('available'); + expect(meta.capabilities.graph).toBeDefined(); + expect(meta.capabilities.graph.status).toBe('available'); + expect(meta.capabilities.vectorSearch).toBeDefined(); + expect(meta.capabilities.vectorSearch.status).toBe('unavailable'); + expect(typeof meta.capabilities.vectorSearch.exactScanLimit).toBe('number'); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('--repair-fts still reports success when the capability-stamp write fails (#2767)', async () => { + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter()); + vi.doMock('../../src/core/search/fts-indexes.js', () => ({ + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + createSearchFTSIndexes: vi.fn(async () => undefined), + verifySearchFTSIndexes: vi.fn(async () => []), + })); + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ + ...(await importActual()), + ensureGitNexusIgnored: vi.fn(async () => undefined), + // Repair itself (createSearchFTSIndexes/verify) already succeeded by the + // time this fires — a write failure here must degrade, not fail the run. + saveMeta: vi.fn(async () => { + throw new Error('EACCES: permission denied'); + }), + })); + + const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-stamp-write-fail-'); + try { + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: '', + indexedAt: new Date().toISOString(), + stats: {}, + }); + await createPlaceholderGraphStore(lbugPath); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + { repairFts: true }, + { onProgress: () => {}, onLog: (msg: string) => logs.push(msg) }, + ); + + expect(result.ftsRepairedOnly).toBe(true); + expect(logs.join('\n')).toMatch(/capability stamp write failed/i); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('--repair-fts stamps onto the LATEST on-disk meta, not a snapshot from before the rebuild ran (#2767)', async () => { + // A concurrent writer (e.g. the HTTP server's background embedding + // checkpoint job) lands its own saveMeta while the FTS rebuild is in + // flight. The repair-fts stamp must not silently revert that write by + // basing itself on the `existingMeta` captured before the rebuild started. + const CONCURRENT_LAST_COMMIT = 'concurrent-writer-commit'; + let storagePathForConcurrentWrite = ''; + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter()); + vi.doMock('../../src/core/search/fts-indexes.js', () => ({ + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + createSearchFTSIndexes: vi.fn(async () => { + // Simulate the concurrent writer landing mid-repair, via the test + // file's own top-level `saveMeta` import (bound before any + // vi.doMock call in this file, so it is always the real function). + await saveMeta(storagePathForConcurrentWrite, { + repoPath: '', + lastCommit: CONCURRENT_LAST_COMMIT, + indexedAt: new Date().toISOString(), + stats: { files: 999 }, + }); + }), + verifySearchFTSIndexes: vi.fn(async () => []), + })); + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ + ...(await importActual()), + ensureGitNexusIgnored: vi.fn(async () => undefined), + })); + + const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-race-'); + try { + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + storagePathForConcurrentWrite = storagePath; + await fs.mkdir(storagePath, { recursive: true }); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: 'original-commit', + indexedAt: new Date().toISOString(), + stats: { files: 1 }, + }); + await createPlaceholderGraphStore(lbugPath); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const result = await runFullAnalysis( + tmpRepo.dbPath, + { repairFts: true }, + { onProgress: () => {} }, + ); + expect(result.ftsRepairedOnly).toBe(true); + + const meta = JSON.parse(await fs.readFile(`${storagePath}/gitnexus.json`, 'utf-8')); + // The concurrent writer's update survives — the stamp did not revert it. + expect(meta.lastCommit).toBe(CONCURRENT_LAST_COMMIT); + expect(meta.stats).toEqual({ files: 999 }); + // The FTS stamp still landed on top of that latest state. + expect(meta.capabilities.fts.status).toBe('available'); + } finally { + await tmpRepo.cleanup(); + } + }); + it('surfaces extension-unavailable errors from FTS index creation in repair mode', async () => { vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ initLbug: vi.fn(async () => undefined), From 7ee0df9e554625dcb63a9bae147b71ee322eee4a Mon Sep 17 00:00:00 2001 From: Void Freud <246163318+voidfreud@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:47:14 +0300 Subject: [PATCH 12/17] fix: serialize global registry transactions across processes (#2716) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: serialize global registry mutations * fix: serialize global registry mutations * fix: keep registry reads lock-free * test(registry): document cross-platform lock coverage * fix(registry): isolate the registry lock's namespace, timeout and diagnostics Review follow-ups on the global registry lock (#2716): - The lock took `getGlobalDir()` itself, which is byte-identical to a repo's index slot when that repo is rooted at the user's home directory (a real dotfiles layout). `runFullAnalysis` holds the per-repo lock across its whole pipeline and `acquireIndexLock` is not reentrant, so `registerRepo` / `adoptFlatBranchLabel` self-deadlocked until the wait ceiling and then failed the analyze. The registry now locks a private `/registry-lock` namespace no index slot can ever resolve to. - The lock inherited the index lock's 10-minute default timeout, sized for multi-minute analyze runs. `gitnexus augment` — documented to cold-start in under 500ms and shelled out from editor tool-use hooks — reaches it through `listRegisteredRepos({ validate: true })`. Registry transactions are sub-second, so they now get their own 5s ceiling. - Contention was silent: no `log`/`onWaitStart` was wired, and the primitive's own texts attribute a wait to "another gitnexus analyze", which misnames a registry holder. A registry-specific line is emitted on wait start instead. - On timeout the transaction now proceeds unlocked with a warning rather than throwing. The lost-update race it guards was unguarded before this branch, so degrading to the old best-effort behaviour beats failing `analyze`/`list`/ `index` outright — none of which wrap these calls in a handler — on a wedged lock. - `adoptFlatBranchLabel`'s recursive `fs.rm` no longer runs inside the lock; only the closing re-read/mutate/write does, mirroring `clean.ts`, which deletes the branch directory before calling the locked `removeBranchIndex`. A slow delete no longer blocks every registry operation on the machine. * test(registry): cover the remaining locked mutators and the colliding layout Three of the five functions the registry lock wraps had no overlap coverage, so a future narrowing of the lock would go unnoticed. Adds: - overlapping `removeBranchIndex` calls on two branches of one entry, - an overlapping `unregisterRepo` / `registerRepo` pair on distinct repos, - a registration issued while an index lock is held on the global directory, which reproduces the home-rooted self-contention the lock namespace fix addresses. Each fails without the corresponding fix: the two overlap tests lose an update when `withRegistryLock` is bypassed, and the collision test sees the wait announcement and the degraded-write warning once the lock namespace is reverted to `getGlobalDir()`. The collision test asserts on those log records rather than on elapsed time, so it stays deterministic on a slow runner. * perf(registry): keep the validation walk out of the registry lock `listRegisteredRepos({ validate: true })` held the global lock across its read-only validation walk — an `fs.access` per entry, slow on a network mount or a large registry — even though the common case prunes nothing and writes nothing. That is the same lock `gitnexus augment` takes on every editor tool call, so unrelated registry work serialized behind a walk that never touched the file. The walk now runs unlocked; the lock is taken only when an entry is provably gone, and the prune is applied to a snapshot re-read inside it, so a registration that lands during the walk is no longer clobbered by a stale write. Same shape as the `adoptFlatBranchLabel` split. --------- Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar --- gitnexus/scripts/cross-platform-tests.ts | 3 + gitnexus/src/storage/repo-manager.ts | 138 +++++++++++++++++++---- gitnexus/test/unit/repo-manager.test.ts | 88 +++++++++++++++ 3 files changed, 208 insertions(+), 21 deletions(-) diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index e301e5de8..76bb3891e 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -63,6 +63,9 @@ const PLATFORM_LOGIC = [ 'test/unit/lbug-config-pagesize.test.ts', 'test/unit/worker-pool-windows-quarantine.test.ts', 'test/unit/lbug-pool-fts-load.test.ts', + // Global registry writes use the platform-specific index-lock backend + // (Windows named pipe, Linux socket, or macOS file lock). This includes the + // overlapping-registration regression from #2716 on every OS matrix. 'test/unit/repo-manager.test.ts', 'test/unit/repo-manager-finalize-invariant.test.ts', 'test/unit/git-utils.test.ts', diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 7ddc84bb1..805b28ce5 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -23,6 +23,7 @@ import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js'; import { stripWindowsLongPathPrefix } from '../lib/utils.js'; import { retryRename } from './fs-atomic.js'; import { logger } from '../core/logger.js'; +import { acquireIndexLock, IndexLockTimeoutError, type IndexLockHandle } from './index-lock.js'; import { branchSlug, BRANCHES_DIR, @@ -1139,6 +1140,69 @@ export const getGlobalRegistryPath = (): string => { return path.join(getGlobalDir(), 'registry.json'); }; +/** + * Lock namespace for the global registry. + * + * Deliberately a dedicated sub-directory rather than {@link getGlobalDir} + * itself: an index slot's lock dir is always `/.gitnexus` (or + * `/.gitnexus/branches/`), so for a repository rooted at the + * user's home directory — dotfiles-at-`$HOME` is a real layout — the per-repo + * analyze lock and the global-dir lock would resolve to the SAME directory. + * `acquireIndexLock` is not reentrant, so `runFullAnalysis` (which holds the + * per-repo lock across its whole pipeline) would then self-deadlock the moment + * it reached `registerRepo`/`adoptFlatBranchLabel`. No repo's index slot can + * ever be named `registry-lock`, so this namespace cannot collide. + */ +const getRegistryLockDir = (): string => path.join(getGlobalDir(), 'registry-lock'); + +/** + * Wait ceiling for the registry lock. A registry transaction is a sub-second + * JSON read/merge/write, so it must NOT inherit the index lock's 10-minute + * default (sized for multi-minute analyze runs): `gitnexus augment` runs on + * every editor/agent tool call with a documented sub-500ms cold-start budget + * and reaches this lock via `listRegisteredRepos({ validate: true })`. + */ +const REGISTRY_LOCK_TIMEOUT_MS = 5_000; + +/** + * Serialize global registry read/merge/write transactions across processes. + * + * The registry is shared by every indexed repository, so per-index locks do + * not protect this file. Reuse the cross-platform index lock primitive with a + * registry-private lock namespace; the handle is kernel-owned on supported + * platforms and crash-reclaimable by the existing fallback. + * + * On timeout the transaction proceeds UNLOCKED rather than throwing: the lock + * closes a lost-update race that existed unguarded before #2716, so degrading + * to the old best-effort behaviour is strictly better than failing an + * `analyze`/`list`/`augment` outright on a wedged lock (a stale pid-reuse + * ghost on platforms without start-time verification can look live forever). + */ +const withRegistryLock = async (operation: () => Promise): Promise => { + let lock: IndexLockHandle | null = null; + try { + lock = await acquireIndexLock(getRegistryLockDir(), { + timeoutMs: REGISTRY_LOCK_TIMEOUT_MS, + // Registry contention was previously invisible: `acquireIndexLock`'s own + // `log` texts name an "analyze" holder, which misattributes a registry + // wait, so surface a registry-specific line instead (#2716 review). + onWaitStart: () => + logger.info('Waiting for another GitNexus process to finish a registry update…'), + }); + } catch (err) { + if (!(err instanceof IndexLockTimeoutError)) throw err; + logger.warn( + { timeoutMs: REGISTRY_LOCK_TIMEOUT_MS }, + 'Timed out waiting for the global registry lock; proceeding without it. A concurrent registry write may be lost.', + ); + } + try { + return await operation(); + } finally { + lock?.release(); + } +}; + /** * Read the global registry. Returns empty array if not found. */ @@ -1285,7 +1349,7 @@ const hasCustomAlias = (entry: RegistryEntry, inferredName: string | null): bool * caller can re-use it to keep AGENTS.md / skill files aligned with the * MCP-visible repo name (#979). */ -export const registerRepo = async ( +const registerRepoUnlocked = async ( repoPath: string, meta: RepoMeta, opts?: RegisterRepoOptions, @@ -1452,11 +1516,17 @@ export const registerRepo = async ( return name; }; +export const registerRepo = async ( + repoPath: string, + meta: RepoMeta, + opts?: RegisterRepoOptions, +): Promise => withRegistryLock(() => registerRepoUnlocked(repoPath, meta, opts)); + /** * Remove a repo from the global registry. * Called after `gitnexus clean`. */ -export const unregisterRepo = async (repoPath: string): Promise => { +const unregisterRepoUnlocked = async (repoPath: string): Promise => { // Canonicalise BOTH sides so an unregister call issued with the // symlink form (`/var/folders/.../repo`) still matches an entry // written with the realpath form (`/private/var/folders/.../repo`), @@ -1468,6 +1538,9 @@ export const unregisterRepo = async (repoPath: string): Promise => { await writeRegistry(filtered); }; +export const unregisterRepo = async (repoPath: string): Promise => + withRegistryLock(() => unregisterRepoUnlocked(repoPath)); + /** * Remove a single non-primary branch's summary from a repo's registry entry * (#2106 R7). Called by `gitnexus clean --branch`. Returns `true` when a @@ -1476,7 +1549,7 @@ export const unregisterRepo = async (repoPath: string): Promise => { * primary entry is left intact; an empty `branches[]` is dropped to keep the * registry shape legacy-clean. */ -export const removeBranchIndex = async (repoPath: string, branch: string): Promise => { +const removeBranchIndexUnlocked = async (repoPath: string, branch: string): Promise => { const resolved = canonicalizePath(repoPath); const entries = await readRegistry(); const idx = entries.findIndex((e) => registryPathEquals(canonicalizePath(e.path), resolved)); @@ -1493,6 +1566,9 @@ export const removeBranchIndex = async (repoPath: string, branch: string): Promi return true; }; +export const removeBranchIndex = async (repoPath: string, branch: string): Promise => + withRegistryLock(() => removeBranchIndexUnlocked(repoPath, branch)); + /** * Record that the flat workspace slot now serves `branch` (#2354). * @@ -1509,6 +1585,12 @@ export const removeBranchIndex = async (repoPath: string, branch: string): Promi * a no-op — including the sub-index deletion, which only runs for registered * repos (never self-heals an unregistered repo, per #2264/#1169; the registry * check precedes the rm per #2364 review F2) — and no subprocess is spawned. + * + * Only the closing re-read/mutate/write runs under the registry lock. The + * recursive `rm` stays outside it — mirroring `clean.ts`, which deletes the + * branch directory before calling the (locked) `removeBranchIndex` — so a slow + * delete (large sub-index, AV scan, network mount) never blocks every other + * registry operation on the machine. */ export const adoptFlatBranchLabel = async (repoPath: string, branch: string): Promise => { const canonicalInput = canonicalizePath(repoPath); @@ -1559,22 +1641,24 @@ export const adoptFlatBranchLabel = async (repoPath: string, branch: string): Pr } } - // Re-read AFTER the potentially slow recursive rm: the registry is a - // multi-writer whole-file overwrite, and writing a pre-rm snapshot would - // silently clobber concurrent registerRepo/removeBranchIndex writers — - // the #2106 R9 re-read-before-write discipline registerRepo follows. - const entries = await readRegistry(); - const idx = isRegistered(entries); - if (idx < 0) return; // unregistered concurrently → still a no-op - const entry = entries[idx]; - const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches; - const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0); - if (entry.branch === branch && !droppedSummary) return; // already coherent - entry.branch = branch; - if (remaining && remaining.length > 0) entry.branches = remaining; - else delete entry.branches; - entries[idx] = entry; - await writeRegistry(entries); + // Re-read AFTER the potentially slow recursive rm, and under the lock: the + // registry is a multi-writer whole-file overwrite, and writing a pre-rm + // snapshot would silently clobber concurrent registerRepo/removeBranchIndex + // writers — the #2106 R9 re-read-before-write discipline registerRepo follows. + await withRegistryLock(async () => { + const entries = await readRegistry(); + const idx = isRegistered(entries); + if (idx < 0) return; // unregistered concurrently → still a no-op + const entry = entries[idx]; + const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches; + const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0); + if (entry.branch === branch && !droppedSummary) return; // already coherent + entry.branch = branch; + if (remaining && remaining.length > 0) entry.branches = remaining; + else delete entry.branches; + entries[idx] = entry; + await writeRegistry(entries); + }); }; /** @@ -1908,9 +1992,21 @@ export const listRegisteredRepos = async (opts?: { } } - // If we pruned any entries, save the cleaned registry + // If we pruned any entries, save the cleaned registry — under the lock, and + // only then. The validation walk above is read-only (an fs.access per entry, + // slow on a network mount or a large registry) and the common case prunes + // nothing, so holding the global lock across it would serialize every + // `gitnexus augment` behind unrelated registry work for no benefit. Re-read + // inside the lock and drop the provably-absent paths from that fresh + // snapshot, so a concurrent registration in the validation window survives. if (valid.length !== entries.length) { - await writeRegistry(valid); + const pruned = new Set( + entries.filter((entry) => !valid.includes(entry)).map((entry) => entry.path), + ); + await withRegistryLock(async () => { + const fresh = await readRegistry(); + await writeRegistry(fresh.filter((entry) => !pruned.has(entry.path))); + }); } return valid; diff --git a/gitnexus/test/unit/repo-manager.test.ts b/gitnexus/test/unit/repo-manager.test.ts index ed95fb35c..a0a48d52e 100644 --- a/gitnexus/test/unit/repo-manager.test.ts +++ b/gitnexus/test/unit/repo-manager.test.ts @@ -23,6 +23,7 @@ import { readRegistry, loadCLIConfig, registerRepo, + unregisterRepo, removeBranchIndex, adoptFlatBranchLabel, listRegisteredRepos, @@ -38,6 +39,7 @@ import { type RegistryEntry, type RepoMeta, } from '../../src/storage/repo-manager.js'; +import { acquireIndexLock } from '../../src/storage/index-lock.js'; import { parseRepoNameFromUrl, getInferredRepoName } from '../../src/storage/git.js'; import { execSync } from 'child_process'; import { createTempDir } from '../helpers/test-db.js'; @@ -901,6 +903,74 @@ describe('registerRepo name override + collision guard (#829)', () => { await parentB.cleanup(); } }); + it('preserves all entries when distinct registrations overlap', async () => { + const repos = await Promise.all( + Array.from({ length: 6 }, (_, index) => createTempDir(`gitnexus-concurrent-repo-${index}-`)), + ); + try { + await Promise.all( + repos.map((repo, index) => + registerRepo(repo.dbPath, meta, { name: `concurrent-${index}` }), + ), + ); + + const entries = await listRegisteredRepos(); + expect(entries).toHaveLength(repos.length); + expect(new Set(entries.map((entry) => entry.name))).toEqual( + new Set(repos.map((_, index) => `concurrent-${index}`)), + ); + } finally { + await Promise.all(repos.map((repo) => repo.cleanup())); + } + }); + + it('keeps an overlapping unregisterRepo and registerRepo from clobbering each other', async () => { + await registerRepo(tmpRepoA.dbPath, meta, { name: 'stays' }); + await registerRepo(tmpRepoB.dbPath, meta, { name: 'goes' }); + const added = await createTempDir('gitnexus-concurrent-added-'); + + try { + await Promise.all([ + unregisterRepo(tmpRepoB.dbPath), + registerRepo(added.dbPath, meta, { name: 'added' }), + ]); + + const entries = await listRegisteredRepos(); + expect(new Set(entries.map((entry) => entry.name))).toEqual(new Set(['stays', 'added'])); + } finally { + await added.cleanup(); + } + }); + + it('registers while an index lock is held on the global directory (#2716)', async () => { + // A repo rooted at the user's home directory makes the per-repo analyze + // lock target `~/.gitnexus` — the very directory the registry lock would + // take if it shared that namespace. `runFullAnalysis` holds the per-repo + // lock across its call to `registerRepo` and `acquireIndexLock` is not + // reentrant, so a shared namespace self-deadlocks until the wait ceiling + // and then degrades. The registry lock lives in its own sub-directory, so + // the registration must contend with nothing: no wait announcement, no + // degraded-write warning. Asserted on the log rather than elapsed time — + // the outcome is what matters, and it stays deterministic on a slow runner. + const capture = _captureLogger(); + const held = await acquireIndexLock(tmpHome.dbPath); + try { + await registerRepo(tmpRepoA.dbPath, meta, { name: 'home-rooted' }); + } finally { + held.release(); + capture.restore(); + } + + const logged = capture.records().map((record) => record.msg); + expect(logged).not.toContain( + 'Waiting for another GitNexus process to finish a registry update…', + ); + expect(logged).not.toContain( + 'Timed out waiting for the global registry lock; proceeding without it. A concurrent registry write may be lost.', + ); + const entries = await listRegisteredRepos(); + expect(entries.map((entry) => entry.name)).toEqual(['home-rooted']); + }); }); // ─── registerRepo branch nesting (#2106) ───────────────────────────── @@ -1015,6 +1085,24 @@ describe('registerRepo branch nesting (#2106)', () => { expect(entry.branches?.map((b) => b.branch)).toEqual(['feature/y']); }); + it('overlapping removeBranchIndex calls drop both summaries (#2716)', async () => { + await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111')); + await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' }); + await registerRepo(tmpRepo.dbPath, metaFor('feature/y', 'ccc3333'), { branch: 'feature/y' }); + + // Unserialized, both writers read the same two-branch snapshot and the + // last rename wins — one summary survives as a lost update. + const removed = await Promise.all([ + removeBranchIndex(tmpRepo.dbPath, 'feature/x'), + removeBranchIndex(tmpRepo.dbPath, 'feature/y'), + ]); + + expect(removed).toEqual([true, true]); + const [entry] = await listRegisteredRepos(); + expect(entry.branch).toBe('main'); // primary intact + expect(entry.branches).toBeUndefined(); + }); + // ─── adoptFlatBranchLabel (#2354) ─────────────────────────────────── it('adoptFlatBranchLabel relabels the entry and removes a shadowed sub-index', async () => { From 0f78179e7fa9100e447c5aef7a02a3a3d5001526 Mon Sep 17 00:00:00 2001 From: Void Freud <246163318+voidfreud@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:38:10 +0300 Subject: [PATCH 13/17] fix(jvm): enforce proximity-bounded sibling injection (#2732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jvm): bound same-package sibling injection * docs(jvm): document sibling injection cap * fix(jvm): mark truncated sibling sets incomplete and bound the merge Review follow-ups on the sibling injection cap (#2732): - The cap silently produced a third visibility state. Before it, a file was either fully visible (package under 500 files) or fully incomplete; once `injectedIds.size` hit the cap, real siblings were dropped while `isVisibilityIncomplete` still returned `false`. That flag gates wildcard attribution in seven Spring passes (`bean-candidates.ts:199` and the java/ kotlin bean-metadata, conditionals, config-bindings and DI resolvers), so 201-500-file packages — exactly this cap's population — resolved wildcard annotations against a truncated sibling set with no log signal. Truncation now marks the file incomplete and analyze warns once with the affected file count. - The cap only bounded `bindingAugmentations`; the two `typeBindings` merges below it still absorbed every sibling, so a class excluded from the binding set could still steer receiver/variable type inference through `scope.typeBindings`. Both halves now use the same bounded sibling set, and the merge iterates that set directly rather than filtering a full rescan, so the cap bounds the work as well as the result. - Path segments are split once per bucket instead of on every pairwise proximity comparison — that comparison runs O(files²) per package. - `JvmPackageFact` was re-declared locally instead of imported from `package-facts.js`, where the canonical declaration still serves both languages' facades and capture side-channels. Nothing kept the copies in sync. Restored the import. - README/.env.example: `GITNEXUS_MAX_INJECTED_SIBLINGS` does not lift the fixed 500-file package skip (including at `0`), and truncation disables wildcard attribution for the affected files. Both are now stated. * test(jvm): restore the language-facade coverage and pin the cap's behaviour The cap rewrite replaced the per-language harness with generic fixtures, dropping the Java/Kotlin capture-side-channel and facade coverage (package fact extraction, the 500-file skip, fail-closed on a file that produced no ParsedFile) and leaving a proximity fixture whose candidates were already in order — so it could not tell a working sort from plain truncation of the input. Restores that harness and adds cap-specific cases on top, driven through the shared JVM factory. The fixture interleaves near and distant siblings, so the retained set is only reachable by a working proximity sort. Covers: the exact capped set, truncation marking the file visibility-incomplete, type bindings bounded by the same sibling set, the unbounded `0` override staying complete, and the documented default of 200 applying when the variable is unset. Each new case fails against the pre-fix implementation. --------- Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar --- gitnexus/.env.example | 7 + gitnexus/README.md | 25 +++ .../languages/jvm/package-siblings.ts | 90 +++++++- .../test/unit/jvm-package-siblings.test.ts | 197 ++++++++++++++++-- 4 files changed, 298 insertions(+), 21 deletions(-) diff --git a/gitnexus/.env.example b/gitnexus/.env.example index 16baa3a75..36c1ce99b 100644 --- a/gitnexus/.env.example +++ b/gitnexus/.env.example @@ -15,6 +15,13 @@ # Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI. # See README for details. +# JVM / Kotlin same-package sibling injection +# Limits implicit sibling class bindings per module scope, nearest first by path. +# Set to 0 for no limit. Files whose sibling set is truncated are marked +# visibility-incomplete (wildcard attribution disabled for them). Packages over +# 500 files are skipped entirely regardless of this value. +# GITNEXUS_MAX_INJECTED_SIBLINGS=200 + # Azure DevOps Server (Self-Hosted) Integration # Base URL of your Azure DevOps Server instance. Prefer https:// — the PAT is # sent in an Authorization header, so cleartext http:// exposes it on the wire diff --git a/gitnexus/README.md b/gitnexus/README.md index db24b2693..25372af03 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -311,6 +311,31 @@ validates the returned vector's length): Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI. Retry and pacing settings are provider-neutral; provider-specific limits should be supplied through configuration. When unset, local embeddings are used unchanged. +## JVM Package Sibling Injection + +Java and Kotlin files in the same package receive implicit sibling class bindings +to resolve same-package references. By default, GitNexus injects at most 200 +siblings per module scope, nearest first by path. Set +`GITNEXUS_MAX_INJECTED_SIBLINGS=0` to remove that per-file limit; this can +substantially increase indexing work for large packages. + +```bash +export GITNEXUS_MAX_INJECTED_SIBLINGS=200 +gitnexus analyze . +``` + +When the limit truncates a file's sibling set, that file is marked +visibility-incomplete: same-package references still resolve through the +injected siblings, but wildcard-import attribution (used by the Spring +bean/DI/config passes) is disabled for it rather than resolved against a +partial view. Analyze logs a `sibling injection truncated` warning naming how +many files were affected. + +Packages with more than 500 files are a separate, fixed limit: they are skipped +entirely (logged as `skipping package with N files`) and every file in them is +marked visibility-incomplete. `GITNEXUS_MAX_INJECTED_SIBLINGS` does not lift +that skip — including at `0`. + ## Multi-Repo Support GitNexus supports indexing multiple repositories. Each `gitnexus analyze` registers the repo in a global registry (`~/.gitnexus/registry.json`). The MCP server serves all indexed repos automatically. diff --git a/gitnexus/src/core/ingestion/languages/jvm/package-siblings.ts b/gitnexus/src/core/ingestion/languages/jvm/package-siblings.ts index 7e43bc71c..e524dc333 100644 --- a/gitnexus/src/core/ingestion/languages/jvm/package-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/jvm/package-siblings.ts @@ -4,8 +4,20 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe import { isClassLike } from '../../scope-resolution/scope/walkers.js'; import type { JvmPackageFact } from './package-facts.js'; +/** Packages larger than this get no implicit sibling visibility at all — every + * file in them is marked incomplete. `GITNEXUS_MAX_INJECTED_SIBLINGS` bounds + * injection *within* a package and does not lift this skip. */ const MAX_PACKAGE_FILES = 500; +const DEFAULT_MAX_INJECTED_SIBLINGS = 200; + +function getMaxInjectedSiblings(): number { + const raw = process.env.GITNEXUS_MAX_INJECTED_SIBLINGS; + if (raw === undefined || raw === '') return DEFAULT_MAX_INJECTED_SIBLINGS; + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : DEFAULT_MAX_INJECTED_SIBLINGS; +} + export interface JvmPackageSiblingOptions { readonly languageLabel: string; readonly getPackageFact: (filePath: string) => JvmPackageFact | undefined; @@ -79,6 +91,8 @@ export function createJvmPackageSiblingVisibility( } const augmentations = indexes.bindingAugmentations as Map>; + const maxInjectedSiblings = getMaxInjectedSiblings(); + let truncatedFiles = 0; for (const bucket of buckets.values()) { if (bucket.moduleScopes.length < 2) continue; @@ -100,6 +114,21 @@ export function createJvmPackageSiblingVisibility( } } + // Per-bucket lookups: the per-file loop below is O(files²) over these, + // so split each path into segments once here instead of re-splitting it + // on every pairwise proximity comparison, and address siblings by path + // so a truncated scope can merge its bounded set without rescanning the + // whole package. + const segmentsByPath = new Map(); + const moduleScopeByPath = new Map(); + const parsedByPath = new Map(); + for (const parsed of bucket.parsed) { + segmentsByPath.set(parsed.filePath, pathSegments(parsed.filePath)); + parsedByPath.set(parsed.filePath, parsed); + } + for (const sibling of bucket.moduleScopes) moduleScopeByPath.set(sibling.filePath, sibling); + const allSiblingPaths = [...parsedByPath.keys()]; + for (const { filePath, scope } of bucket.moduleScopes) { let scopeAug = augmentations.get(scope.id); if (scopeAug === undefined) { @@ -107,24 +136,37 @@ export function createJvmPackageSiblingVisibility( augmentations.set(scope.id, scopeAug); } + const ownSegments = segmentsByPath.get(filePath) ?? pathSegments(filePath); const proximityCache = new Map(); const candidates = classDefs.filter((candidate) => candidate.filePath !== filePath); for (const candidate of candidates) { if (!proximityCache.has(candidate.filePath)) { proximityCache.set( candidate.filePath, - sharedSegmentCount(candidate.filePath, filePath), + sharedSegmentCount(segmentsByPath.get(candidate.filePath) ?? [], ownSegments), ); } } + // Nearest-first by shared path prefix. Ties (the common case in a flat + // package, where every sibling shares the same directory) keep the + // walker's traversal order, so the retained set is deterministic but + // arbitrary among equally-near candidates — which is why truncation + // marks the file incomplete below rather than being treated as exact. candidates.sort( (a, b) => (proximityCache.get(b.filePath) ?? 0) - (proximityCache.get(a.filePath) ?? 0), ); const injectedIds = new Set(); - for (const { def } of candidates) { + const injectedPaths = new Set(); + let truncated = false; + for (const { def, filePath: defPath } of candidates) { if (injectedIds.has(def.nodeId) || def.qualifiedName === undefined) continue; + if (maxInjectedSiblings > 0 && injectedIds.size >= maxInjectedSiblings) { + truncated = true; + break; + } injectedIds.add(def.nodeId); + injectedPaths.add(defPath); const simpleName = def.qualifiedName.includes('.') ? def.qualifiedName.slice(def.qualifiedName.lastIndexOf('.') + 1) : def.qualifiedName; @@ -133,15 +175,37 @@ export function createJvmPackageSiblingVisibility( bindings.push({ def, origin: 'namespace' }); } + if (truncated) { + // Sibling visibility for this file is now partial, and downstream + // Spring attribution treats a complete flag as "this package's names + // are fully known". Marking it incomplete keeps wildcard attribution + // conservative instead of silently resolving against a truncated set. + incompleteFiles.add(filePath); + truncatedFiles++; + } + + // Keep the two halves of "what this file can see" in agreement: when + // the cap truncated the binding set, merge type bindings from the same + // bounded sibling set only — and iterate that set directly, so the cap + // bounds the merge work too instead of just filtering a full scan. An + // untruncated scope merges every sibling as before, including ones that + // contribute no class-like def. const typeBindings = scope.typeBindings as Map; - for (const sibling of bucket.moduleScopes) { - if (sibling.filePath === filePath) continue; + const mergePaths = truncated ? injectedPaths : allSiblingPaths; + // Module scopes first, then class scopes: module-level bindings win on + // a name collision, which the two-pass order (not file order) encodes. + for (const siblingPath of mergePaths) { + if (siblingPath === filePath) continue; + const sibling = moduleScopeByPath.get(siblingPath); + if (sibling === undefined) continue; for (const [name, ref] of sibling.scope.typeBindings) { if (!typeBindings.has(name)) typeBindings.set(name, ref); } } - for (const sibling of bucket.parsed) { - if (sibling.filePath === filePath) continue; + for (const siblingPath of mergePaths) { + if (siblingPath === filePath) continue; + const sibling = parsedByPath.get(siblingPath); + if (sibling === undefined) continue; for (const siblingScope of sibling.scopes) { if (siblingScope.kind !== 'Class') continue; for (const [name, ref] of siblingScope.typeBindings) { @@ -151,6 +215,12 @@ export function createJvmPackageSiblingVisibility( } } } + + if (truncatedFiles > 0) { + logger.warn( + `[${options.languageLabel}-package-siblings] sibling injection truncated at ${maxInjectedSiblings} per file for ${truncatedFiles} file(s); wildcard attribution disabled for those files (raise or unset GITNEXUS_MAX_INJECTED_SIBLINGS to widen, 0 for unbounded)`, + ); + } } return { @@ -159,9 +229,11 @@ export function createJvmPackageSiblingVisibility( }; } -function sharedSegmentCount(a: string, b: string): number { - const aSegments = a.replace(/\\/g, '/').split('/'); - const bSegments = b.replace(/\\/g, '/').split('/'); +function pathSegments(filePath: string): string[] { + return filePath.replace(/\\/g, '/').split('/'); +} + +function sharedSegmentCount(aSegments: readonly string[], bSegments: readonly string[]): number { let index = 0; while ( index < aSegments.length && diff --git a/gitnexus/test/unit/jvm-package-siblings.test.ts b/gitnexus/test/unit/jvm-package-siblings.test.ts index 7083e53e8..efa912408 100644 --- a/gitnexus/test/unit/jvm-package-siblings.test.ts +++ b/gitnexus/test/unit/jvm-package-siblings.test.ts @@ -1,5 +1,6 @@ -import type { ParsedFile, ScopeResolutionIndexes } from 'gitnexus-shared'; +import type { ParsedFile, ScopeResolutionIndexes, SymbolDefinition } from 'gitnexus-shared'; import { describe, expect, it } from 'vitest'; +import { createJvmPackageSiblingVisibility } from '../../src/core/ingestion/languages/jvm/package-siblings.js'; import { collectJavaCaptureSideChannel } from '../../src/core/ingestion/languages/java/capture-side-channel.js'; import { emitJavaScopeCaptures } from '../../src/core/ingestion/languages/java/captures.js'; import { @@ -63,18 +64,9 @@ const harnesses: readonly LanguagePackageHarness[] = [ }, ]; +/** A file with only a module scope — `moduleWithClass` without the class half. */ function parsedFile(filePath: string, index: number): ParsedFile { - return { - filePath, - scopes: [ - { - id: `module:${index}`, - kind: 'Module', - typeBindings: new Map(), - ownedDefs: [], - }, - ], - } as unknown as ParsedFile; + return moduleWithClass(filePath, index); } function emptyIndexes(): ScopeResolutionIndexes { @@ -144,3 +136,184 @@ for (const harness of harnesses) { }); }); } + +// ─── sibling injection cap (#2732) ─────────────────────────────────── +// +// Driven through the shared JVM factory rather than a language facade: the +// cap is language-agnostic, and a synthetic fixture can place candidates at +// chosen path distances without hand-writing hundreds of real sources. + +function classDef(nodeId: string, filePath: string, qualifiedName: string): SymbolDefinition { + return { nodeId, filePath, type: 'Class', qualifiedName } as unknown as SymbolDefinition; +} + +function moduleWithClass( + filePath: string, + index: number, + def?: SymbolDefinition, + moduleTypeBindings?: ReadonlyMap, +): ParsedFile { + const moduleId = `module:${index}`; + const scopes: Record[] = [ + { + id: moduleId, + kind: 'Module', + typeBindings: new Map(moduleTypeBindings ?? []), + ownedDefs: [], + }, + ]; + if (def !== undefined) { + scopes.push({ + id: `class:${index}`, + kind: 'Class', + parent: moduleId, + typeBindings: new Map(), + ownedDefs: [def], + }); + } + return { filePath, scopes } as unknown as ParsedFile; +} + +function jvmVisibility(facts: ReadonlyMap) { + return createJvmPackageSiblingVisibility({ + languageLabel: 'JVM', + getPackageFact: (filePath) => facts.get(filePath), + }); +} + +/** + * `count` siblings of `com.example`, interleaved near/far so the retained set + * cannot be produced by simply truncating the input order: every odd index is + * a distant `vendor/` file, every even index sits beside the target. A working + * proximity sort keeps the near half; plain truncation would keep half of each. + */ +function interleavedPackage( + targetPath: string, + count: number, + moduleBinding?: (siblingName: string) => ReadonlyMap, +): { parsedFiles: ParsedFile[]; facts: Map } { + const facts = new Map(); + facts.set(targetPath, { status: 'known', packageName: 'com.example' }); + const parsedFiles = [moduleWithClass(targetPath, 0)]; + for (let index = 0; index < count; index++) { + const isFar = index % 2 === 1; + const name = isFar ? `FarType${index}` : `NearType${index}`; + const filePath = isFar ? `vendor/generated/${name}.java` : `src/com/example/near/${name}.java`; + facts.set(filePath, { status: 'known', packageName: 'com.example' }); + parsedFiles.push( + moduleWithClass( + filePath, + index + 1, + classDef(`class:${index}`, filePath, `com.example.${name}`), + moduleBinding?.(name), + ), + ); + } + return { parsedFiles, facts }; +} + +function withMaxInjectedSiblings(value: string | undefined, run: () => T): T { + const previous = process.env.GITNEXUS_MAX_INJECTED_SIBLINGS; + if (value === undefined) delete process.env.GITNEXUS_MAX_INJECTED_SIBLINGS; + else process.env.GITNEXUS_MAX_INJECTED_SIBLINGS = value; + try { + return run(); + } finally { + if (previous === undefined) delete process.env.GITNEXUS_MAX_INJECTED_SIBLINGS; + else process.env.GITNEXUS_MAX_INJECTED_SIBLINGS = previous; + } +} + +describe('JVM sibling injection cap (#2732)', () => { + const targetPath = 'src/com/example/Target.java'; + + it('keeps the nearest siblings, drops the rest, and injects exactly the cap', () => { + const { parsedFiles, facts } = interleavedPackage(targetPath, 10); + const visibility = jvmVisibility(facts); + const indexes = emptyIndexes(); + + withMaxInjectedSiblings('4', () => + visibility.populateNamespaceSiblings(parsedFiles, indexes, { + fileContents: new Map(parsedFiles.map((parsed) => [parsed.filePath, 'class Type {}'])), + }), + ); + + const injected = indexes.bindingAugmentations.get('module:0'); + expect([...(injected?.keys() ?? [])].sort()).toEqual([ + 'NearType0', + 'NearType2', + 'NearType4', + 'NearType6', + ]); + }); + + it('marks a file whose sibling set was truncated as visibility-incomplete', () => { + const { parsedFiles, facts } = interleavedPackage(targetPath, 10); + const visibility = jvmVisibility(facts); + + withMaxInjectedSiblings('4', () => + visibility.populateNamespaceSiblings(parsedFiles, emptyIndexes(), { + fileContents: new Map(parsedFiles.map((parsed) => [parsed.filePath, 'class Type {}'])), + }), + ); + + // Spring bean/DI/conditional attribution keys off this flag; a truncated + // sibling set must never read as exact package visibility. + expect(visibility.isVisibilityIncomplete(targetPath)).toBe(true); + }); + + it('bounds type bindings by the same sibling set it bounded bindings by', () => { + // Each sibling module scope carries a type binding named after itself, so + // the merged set names exactly which siblings were treated as visible. + const { parsedFiles, facts } = interleavedPackage( + targetPath, + 10, + (name) => new Map([[`Binding_${name}`, { source: 'import' }]]), + ); + const visibility = jvmVisibility(facts); + + withMaxInjectedSiblings('4', () => + visibility.populateNamespaceSiblings(parsedFiles, emptyIndexes(), { + fileContents: new Map(parsedFiles.map((parsed) => [parsed.filePath, 'class Type {}'])), + }), + ); + + const targetModule = parsedFiles[0].scopes.find((scope) => scope.kind === 'Module'); + expect([...(targetModule?.typeBindings.keys() ?? [])].sort()).toEqual([ + 'Binding_NearType0', + 'Binding_NearType2', + 'Binding_NearType4', + 'Binding_NearType6', + ]); + }); + + it('injects every sibling and stays complete when the cap is disabled', () => { + const { parsedFiles, facts } = interleavedPackage(targetPath, 10); + const visibility = jvmVisibility(facts); + const indexes = emptyIndexes(); + + withMaxInjectedSiblings('0', () => + visibility.populateNamespaceSiblings(parsedFiles, indexes, { + fileContents: new Map(parsedFiles.map((parsed) => [parsed.filePath, 'class Type {}'])), + }), + ); + + expect(indexes.bindingAugmentations.get('module:0')?.size).toBe(10); + expect(visibility.isVisibilityIncomplete(targetPath)).toBe(false); + }); + + it('applies the documented default of 200 when the env var is unset', () => { + const { parsedFiles, facts } = interleavedPackage(targetPath, 402); + const visibility = jvmVisibility(facts); + const indexes = emptyIndexes(); + + withMaxInjectedSiblings(undefined, () => + visibility.populateNamespaceSiblings(parsedFiles, indexes, { + fileContents: new Map(parsedFiles.map((parsed) => [parsed.filePath, 'class Type {}'])), + }), + ); + + expect(indexes.bindingAugmentations.get('module:0')?.size).toBe(200); + expect(visibility.isVisibilityIncomplete(targetPath)).toBe(true); + }); +}); From c238085676ef2bb4d8c7baf8f812ad29b0b84339 Mon Sep 17 00:00:00 2001 From: ChunxueLi <54129170+ChunxueLi@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:18:07 +0800 Subject: [PATCH 14/17] feat: make MAX_PROPERTY_DISPATCH_FANOUT configurable via env (#2726) * feat(scope-resolution): make MAX_PROPERTY_DISPATCH_FANOUT configurable via env * test(property-dispatch): add env override tests * docs(scope-resolution): document GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT in README and .env.example Add a dedicated troubleshooting subsection and .env.example entry for the GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT environment variable, matching the format used by the sibling GITNEXUS_MAX_CALLABLE_VALUE_TARGETS knob. Closes maintainer request: "Could you please document this in the readme plus the .env.example?" --------- Co-authored-by: Ubuntu --- gitnexus/.env.example | 7 +++ gitnexus/README.md | 23 +++++++++ .../passes/property-dispatch.ts | 9 +++- .../property-dispatch-fanout-env.test.ts | 48 +++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 gitnexus/test/unit/scope-resolution/property-dispatch-fanout-env.test.ts diff --git a/gitnexus/.env.example b/gitnexus/.env.example index 36c1ce99b..38fabe218 100644 --- a/gitnexus/.env.example +++ b/gitnexus/.env.example @@ -31,3 +31,10 @@ # Personal Access Token with Code (Read) scope for cloning private repos. # Used for both self-hosted and cloud (dev.azure.com) Azure DevOps. # AZURE_DEVOPS_PAT=your-pat-here + +# Scope-resolution property-key dispatch cap (default 32). Per-property-key +# registration cap in the property-dispatch scope-resolution pass. Raise for +# repos whose provider/hook tables lose CALLS coverage on a legitimate key. +# Positive integer only — non-integer or < 1 values fall back to 32. +# See README § "Scope-resolution property-key dispatch cap". +# GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT=32 diff --git a/gitnexus/README.md b/gitnexus/README.md index 25372af03..ad7ca4033 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -688,6 +688,29 @@ After scope resolution, analyze prunes inert block-local value symbols (a functi Programmatic callers can pass `keepLocalValueSymbols: true` in `PipelineOptions` instead of setting the env var. +### Scope-resolution property-key dispatch cap + +During scope resolution GitNexus synthesizes CALLS edges through *property-key +dispatch* — call sites like `hooks.emitScopeCaptures()` where a property key is +registered by multiple definitions across the codebase. To keep this fan-in +bounded, each property key is capped at **32 registrations**: a key registered +by more than 32 distinct functions is skipped entirely (no CALLS are synthesized +through it), and the dropped key names are surfaced in the analyze log for +operator visibility. The cap is calibrated at 2× this repo's own provider table +(16 legitimate registrations, one per language provider). + +| Variable | Default | Effect | +| --------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT` | `32` | Per-property-key registration cap in the property-dispatch scope-resolution pass. Set to a positive integer to raise it for repositories whose provider/hook tables exceed the default and lose CALLS coverage on a legitimate key; non-integer or `< 1` values fall back to `32`. Lowering it tightens the overflow budget. | + +```bash +# A property key registered by 40 functions overflows the default 32 and drops +# all CALLS through it — raise the cap for that repo and rebuild so scope +# resolution reruns. +export GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT=64 +npx gitnexus analyze --force +``` + ### Hook augmentation and skip diagnostics The Claude Code / Antigravity hooks keep their **stderr** silent on normal skip diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts index b22799776..fef9ed29d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts @@ -52,8 +52,15 @@ import { findCallableBindingInScope } from '../scope/walkers.js'; * 2× that — dropping the motivating key was the failure the first value (8) * had. ponytail: flat cap; revisit with per-receiver narrowing if real * repos show useful keys being dropped (§12 of the #2437 plan). + * + * Override via `GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT` env var for repos with + * legitimate high-fanout property keys (e.g. large Vue codebases where + * `validator` exceeds the default). */ -export const MAX_PROPERTY_DISPATCH_FANOUT = 32; +export const MAX_PROPERTY_DISPATCH_FANOUT = (() => { + const env = Number(process.env.GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT); + return Number.isInteger(env) && env >= 1 ? env : 32; +})(); /** Below the 0.85 resolved baseline; same discount idea as interface-dispatch. */ export const PROPERTY_DISPATCH_CONFIDENCE = 0.7; diff --git a/gitnexus/test/unit/scope-resolution/property-dispatch-fanout-env.test.ts b/gitnexus/test/unit/scope-resolution/property-dispatch-fanout-env.test.ts new file mode 100644 index 000000000..fad474648 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/property-dispatch-fanout-env.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, afterEach } from 'vitest'; + +/** + * Verifies that MAX_PROPERTY_DISPATCH_FANOUT reads the + * GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT environment variable at module load time + * and falls back to the default (32) when unset. + */ + +const MODULE_PATH = '../../../src/core/ingestion/scope-resolution/passes/property-dispatch.js'; + +async function loadWithEnv(envValue: string | undefined): Promise { + const { vi } = await import('vitest'); + vi.resetModules(); + + if (envValue === undefined) { + delete process.env.GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT; + } else { + process.env.GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT = envValue; + } + + const mod = await import(MODULE_PATH); + return mod.MAX_PROPERTY_DISPATCH_FANOUT; +} + +describe('MAX_PROPERTY_DISPATCH_FANOUT env override', () => { + afterEach(() => { + delete process.env.GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT; + }); + + it('defaults to 32 when the env var is not set', async () => { + const cap = await loadWithEnv(undefined); + expect(cap).toBe(32); + }); + + it('respects a valid positive integer override', async () => { + const cap = await loadWithEnv('48'); + expect(cap).toBe(48); + }); + + it('falls back to default for non-integer values', async () => { + expect(await loadWithEnv('not-a-number')).toBe(32); + }); + + it('falls back to default for values below 1', async () => { + expect(await loadWithEnv('0')).toBe(32); + expect(await loadWithEnv('-1')).toBe(32); + }); +}); From bebb1d23677eafd8bd5bc7d9cc7bfff2128f1491 Mon Sep 17 00:00:00 2001 From: Karl Lehenbauer Date: Sat, 1 Aug 2026 06:35:37 -0500 Subject: [PATCH 15/17] fix(schema): declare Swift member-containment pairs in CONTAINS DDL (#2769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(schema): declare Swift member-containment pairs in CONTAINS DDL * fix(schema): declare remaining Rust impl/trait and JS/TS object-literal HAS_METHOD pairs; guard streamed emit sinks against undeclared pairs (PR #2769 review) * refactor(schema): share one declared-pairs constant across router and sinks DECLARED_REL_PAIRS was being computed independently in three places (csv-generator.ts, graph-emit-sink.ts, pdg-emit-sink.ts) from the same static RELATION_SCHEMA parse. Export the existing constant from csv-generator.ts (already imported by both sinks) instead. assertDeclaredPair now takes the pre-built pairKey rather than the two labels, since every caller (RelPairRouter.route, both sinks' addRelationship) needs that same key immediately after for its own Map/stream lookup on the per-streamed-edge hot path — avoids rebuilding the template string twice per edge. Also drops two schema.test.ts assertions that duplicated coverage already in the more narrowly-named regression tests below them, and trims the v32 ladder comment to point at assertDeclaredPair's docstring instead of re-explaining the same failure mechanism. * fix(schema): use replaceAll for the pair-arrow error message (CodeQL) .replace(str, ...) only touches the first match; CodeQL flags that as incomplete string escaping regardless of the caller's invariant that pairKey contains exactly one '|'. replaceAll is equivalent here and silences the alert. --------- Co-authored-by: Gergo Magyar --- gitnexus/src/core/lbug/csv-generator.ts | 5 +- gitnexus/src/core/lbug/graph-emit-sink.ts | 5 +- gitnexus/src/core/lbug/pdg-emit-sink.ts | 4 +- gitnexus/src/core/lbug/rel-pair-routing.ts | 29 ++++++-- gitnexus/src/core/lbug/schema.ts | 16 +++++ gitnexus/src/storage/repo-manager.ts | 25 ++++++- .../unit/call-summary-schema-version.test.ts | 11 ++- .../test/unit/lbug/graph-emit-sink.test.ts | 23 +++++++ gitnexus/test/unit/lbug/pdg-emit-sink.test.ts | 21 ++++++ gitnexus/test/unit/schema.test.ts | 69 +++++++++++++++---- 10 files changed, 180 insertions(+), 28 deletions(-) diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 18f91e723..68a66aa0e 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -23,7 +23,10 @@ import { parseTruthyEnv } from '../ingestion/utils/env.js'; import { SYMBOL_NODE_LABELS } from '../ingestion/utils/symbol-labels.js'; import { applyCjkSegmentationIfEnabled } from '../search/cjk-segmentation.js'; -const DECLARED_RELATION_PAIRS = parseRelationSchemaPairs(RELATION_SCHEMA); +/** Computed once — `RELATION_SCHEMA` is a static template literal. Exported so + * the streamed sinks (`GraphEmitSink`, `PdgEmitSink`) share this parse + * instead of each re-deriving it from the same DDL string. */ +export const DECLARED_RELATION_PAIRS = parseRelationSchemaPairs(RELATION_SCHEMA); /** * Deterministic output ordering — optional (out-of-core / windowed-resolve diff --git a/gitnexus/src/core/lbug/graph-emit-sink.ts b/gitnexus/src/core/lbug/graph-emit-sink.ts index 4e1845d71..c03bacf01 100644 --- a/gitnexus/src/core/lbug/graph-emit-sink.ts +++ b/gitnexus/src/core/lbug/graph-emit-sink.ts @@ -95,8 +95,8 @@ import fs from 'fs'; import path from 'path'; import type { GraphNode, GraphRelationship, RelationshipType } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../graph/types.js'; -import { REL_CSV_HEADER, buildRelRow } from './csv-generator.js'; -import { getNodeLabel } from './rel-pair-routing.js'; +import { DECLARED_RELATION_PAIRS, REL_CSV_HEADER, buildRelRow } from './csv-generator.js'; +import { assertDeclaredPair, getNodeLabel } from './rel-pair-routing.js'; import { NODE_TABLES } from './schema.js'; import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js'; @@ -412,6 +412,7 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { if (!this.validTables.has(fromLabel) || !this.validTables.has(toLabel)) return; const pairKey = `${fromLabel}|${toLabel}`; + assertDeclaredPair(pairKey, DECLARED_RELATION_PAIRS); let writer = this.relWriters.get(pairKey); if (writer === undefined) { try { diff --git a/gitnexus/src/core/lbug/pdg-emit-sink.ts b/gitnexus/src/core/lbug/pdg-emit-sink.ts index 79f8b899f..cc5560d2a 100644 --- a/gitnexus/src/core/lbug/pdg-emit-sink.ts +++ b/gitnexus/src/core/lbug/pdg-emit-sink.ts @@ -49,11 +49,12 @@ import type { GraphNode, GraphRelationship, RelationshipType } from 'gitnexus-sh import type { KnowledgeGraph } from '../graph/types.js'; import { BASICBLOCK_CSV_HEADER, + DECLARED_RELATION_PAIRS, REL_CSV_HEADER, buildBasicBlockRow, buildRelRow, } from './csv-generator.js'; -import { getNodeLabel } from './rel-pair-routing.js'; +import { assertDeclaredPair, getNodeLabel } from './rel-pair-routing.js'; import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js'; import { NODE_TABLES, type NodeTableName } from './schema.js'; @@ -165,6 +166,7 @@ export class PdgEmitSink implements KnowledgeGraph { // `RelPairRouter` exactly so the streamed set matches the whole-graph set. if (!this.validTables.has(fromLabel) || !this.validTables.has(toLabel)) return; const pairKey = `${fromLabel}|${toLabel}`; + assertDeclaredPair(pairKey, DECLARED_RELATION_PAIRS); let writer = this.relWriters.get(pairKey); if (writer === undefined) { try { diff --git a/gitnexus/src/core/lbug/rel-pair-routing.ts b/gitnexus/src/core/lbug/rel-pair-routing.ts index 9729b5bec..5a77cec0b 100644 --- a/gitnexus/src/core/lbug/rel-pair-routing.ts +++ b/gitnexus/src/core/lbug/rel-pair-routing.ts @@ -69,6 +69,29 @@ export interface RelPairMeta { rows: number; } +/** + * Fail fast on an endpoint-label pair absent from the relationship DDL, the + * same guard `RelPairRouter.route` applies to the whole-graph emit. Exported + * so the streamed sinks (`GraphEmitSink`, `PdgEmitSink`) can apply it too — + * without this, an undeclared pair on a streaming run reaches `COPY`, fails + * the bulk insert, and is silently dropped by the per-edge fallback instead + * of failing loudly like the non-streaming path does. + * + * Takes the already-built `From|To` pairKey rather than the two labels — every + * caller needs that same key immediately after for its own Map/stream lookup, + * and this is on the per-edge hot path, so building it twice would be a + * needless allocation per edge. `|` cannot appear inside a label (node labels + * are `NODE_TABLES` identifiers), so splitting it back apart for the error + * message is safe. + */ +export const assertDeclaredPair = (pairKey: string, declaredPairs: ReadonlySet): void => { + if (!declaredPairs.has(pairKey)) { + throw new Error( + `Relationship label pair ${pairKey.replaceAll('|', '→')} is not declared in the LadybugDB relation schema`, + ); + } +}; + /** * Routes already-escaped relationship CSV rows to per-FROM→TO-label-pair * files. Filters edges whose endpoint labels are not valid node tables @@ -124,11 +147,7 @@ export class RelPairRouter { } const pairKey = `${fromLabel}|${toLabel}`; - if (!this.declaredPairs.has(pairKey)) { - throw new Error( - `Relationship label pair ${fromLabel}→${toLabel} is not declared in the LadybugDB relation schema`, - ); - } + assertDeclaredPair(pairKey, this.declaredPairs); const ws = this.streams.get(pairKey); if (ws === undefined) { // First edge for this pair: open the stream, write header + row. diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 5803776fa..92aceae1f 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -332,6 +332,8 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM Method TO Interface, FROM Method TO \`Constructor\`, FROM Method TO \`Property\`, + FROM Method TO \`Variable\`, + FROM Method TO \`Const\`, FROM Method TO CodeElement, FROM \`Template\` TO \`Template\`, FROM \`Template\` TO Function, @@ -376,6 +378,12 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`Enum\` TO Community, FROM \`Enum\` TO Class, FROM \`Enum\` TO Interface, + FROM \`Enum\` TO Function, + FROM \`Enum\` TO Method, + FROM \`Enum\` TO \`Struct\`, + FROM \`Enum\` TO \`Constructor\`, + FROM \`Enum\` TO \`Property\`, + FROM \`Enum\` TO \`TypeAlias\`, FROM \`Macro\` TO Community, FROM \`Macro\` TO Function, FROM \`Macro\` TO Method, @@ -386,10 +394,12 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`Namespace\` TO Community, FROM \`Namespace\` TO \`Struct\`, FROM \`Trait\` TO Method, + FROM \`Trait\` TO Function, FROM \`Trait\` TO \`Constructor\`, FROM \`Trait\` TO \`Property\`, FROM \`Trait\` TO Community, FROM \`Impl\` TO Method, + FROM \`Impl\` TO Function, FROM \`Impl\` TO \`Constructor\`, FROM \`Impl\` TO \`Property\`, FROM \`Impl\` TO Community, @@ -400,10 +410,16 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`TypeAlias\` TO \`Trait\`, FROM \`TypeAlias\` TO Class, FROM \`Const\` TO Community, + FROM \`Const\` TO Method, FROM \`Static\` TO Community, FROM \`Variable\` TO Community, + FROM \`Variable\` TO Method, FROM \`Property\` TO Community, FROM \`Property\` TO \`Property\`, + FROM \`Property\` TO Class, + FROM \`Property\` TO \`Enum\`, + FROM \`Property\` TO Function, + FROM \`Property\` TO \`Struct\`, FROM \`Record\` TO Method, FROM \`Record\` TO \`Constructor\`, FROM \`Record\` TO \`Property\`, diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 805b28ce5..afe51a1fb 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -645,8 +645,31 @@ export interface RepoMeta { * as namespace edges (#2746), enabling qualified constructor and method CALLS * edges. Pre-v31 indexes retain the old package-target/missing-edge graph for * unchanged files through the reuse gate. Force a full re-analyze. + * + * v32: the relation DDL (the single shared `CodeRelation` REL TABLE) gains + * sixteen FROM/TO pairs carried by `HAS_METHOD`/`HAS_PROPERTY` and + * scope-resolution edges: Enum→{Function, Method, Struct, Constructor, + * Property, TypeAlias}, Property→{Class, Enum, Function, Struct}, + * Method→{Variable, Const}, Trait→Function, Impl→Function, Const→Method and + * Variable→Method. The Enum/Property set was observed on Swift (enums carry + * computed properties, methods, initializers and nested types) and is also + * reached by Java/PHP enum members; Trait/Impl→Function covers a Rust + * `impl`/`trait` method, which is minted as a `Function` node, not `Method`; + * Const/Variable→Method and its sibling Method→Const cover a JS/TS object + * literal's shorthand methods, whose owner is labelled `Const`/`Variable`. A + * pre-v32 database physically lacks these from-to pairs — see + * `assertDeclaredPair` (rel-pair-routing.ts) for why an incremental top-up + * fails loudly on one path and silently on the other. Force a full re-analyze. + * + * (This shipped as v31 on its own branch; `main` took 31 for #2746 first, so + * it is renumbered here. Re-check both constants against origin/main + * immediately before merging — this is the sixth time that collision has + * bitten. If this change is ever reverted, do not free 32 for reuse — the + * reuse gate is exact equality, so an index already stamped 32 would satisfy + * it against a differently-shaped reverted DB. Start the next allocation at + * 33 instead.) */ -export const INCREMENTAL_SCHEMA_VERSION = 31; +export const INCREMENTAL_SCHEMA_VERSION = 32; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index 1adbb2a7e..bd35a8032 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 31 (Python module-import resolution, #2746)', () => { + it('INCREMENTAL_SCHEMA_VERSION is bumped to 32 (Rust/Swift/JS-TS member-containment DDL, #2769)', () => { // Moves with every bump BY DESIGN — that is the point of pinning it. A // change that alters emitted ids or edges without bumping would otherwise // ship silently, and an existing index would keep serving the old graph // through the reuse gate below. - expect(INCREMENTAL_SCHEMA_VERSION).toBe(31); + expect(INCREMENTAL_SCHEMA_VERSION).toBe(32); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { @@ -208,7 +208,12 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // A pre-v31 (v30) index treats `from pkg import models` as a named package // import, so unchanged files retain the old missing qualified CALLS edges. expect(passesReuseGate(30)).toBe(false); + // A pre-v32 (v31) index predates the Rust impl/trait, JS/TS object-literal + // and Swift member-containment relation pairs (#2769), so an incremental + // top-up emitting one of those edges would fail the bulk COPY (or silently + // drop it on the streamed path) → must NOT reuse. + expect(passesReuseGate(31)).toBe(false); // The current stamp passes the gate (incremental top-up eligible). - expect(passesReuseGate(31)).toBe(true); + expect(passesReuseGate(32)).toBe(true); }); }); diff --git a/gitnexus/test/unit/lbug/graph-emit-sink.test.ts b/gitnexus/test/unit/lbug/graph-emit-sink.test.ts index 7f051ce53..a315f7f86 100644 --- a/gitnexus/test/unit/lbug/graph-emit-sink.test.ts +++ b/gitnexus/test/unit/lbug/graph-emit-sink.test.ts @@ -127,6 +127,29 @@ describe('GraphEmitSink routing', () => { expect(manifest).toMatchObject({ totalRows: 0 }); expect(real.relationshipCount).toBe(0); }); + + it('throws rather than silently dropping an undeclared endpoint-label pair (#2769)', () => { + // Before #2769's fix, this pair reached COPY, failed the bulk insert, and + // the per-edge fallback swallowed the failure into `catch {}` — the run + // still exited 0 with the edge silently missing. Both endpoints are valid + // node tables (so the validTables gate above does not catch it); the pair + // itself is simply absent from RELATION_SCHEMA. + const real = createKnowledgeGraph(); + const sink = new GraphEmitSink(real, csvDir); + sink.beginStreaming(); + + const undeclared: GraphRelationship = { + id: 'CALLS:Static:a->Static:b', + sourceId: 'Static:src/a.ts:A', + targetId: 'Static:src/a.ts:B', + type: 'CALLS', + confidence: 1, + reason: 'direct', + }; + expect(() => sink.addRelationship(undeclared)).toThrow( + /Relationship label pair Static→Static is not declared/, + ); + }); }); describe('GraphEmitSink arming', () => { diff --git a/gitnexus/test/unit/lbug/pdg-emit-sink.test.ts b/gitnexus/test/unit/lbug/pdg-emit-sink.test.ts index 7d4699529..1a63e165c 100644 --- a/gitnexus/test/unit/lbug/pdg-emit-sink.test.ts +++ b/gitnexus/test/unit/lbug/pdg-emit-sink.test.ts @@ -78,6 +78,27 @@ describe('PdgEmitSink — routing', () => { sink.finalize(); }); + it('throws rather than silently dropping an undeclared endpoint-label pair (#2769)', () => { + // PDG edges are always BasicBlock|BasicBlock in production, but the sink + // applies no declared-pair gate of its own — this pins that the shared + // `assertDeclaredPair` guard (also wired into GraphEmitSink) still fires + // here rather than reaching COPY and silently dropping the edge. + const real = createKnowledgeGraph(); + const sink = new PdgEmitSink(real, path.join(tmpRoot, 'pdg-csv')); + + const undeclared: GraphRelationship = { + id: 'CFG:Static:a->Static:b', + sourceId: 'Static:src/a.ts:A', + targetId: 'Static:src/a.ts:B', + type: 'CFG', + confidence: 1, + reason: 'seq', + }; + expect(() => sink.addRelationship(undeclared)).toThrow( + /Relationship label pair Static→Static is not declared/, + ); + }); + it('delegates structural nodes, CALLS, and the whole-program TAINT_PATH edge to the real graph', () => { const real = createKnowledgeGraph(); const sink = new PdgEmitSink(real, path.join(tmpRoot, 'pdg-csv')); diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index 43e6cb7ba..b4c2cd04c 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -22,6 +22,7 @@ import { EMBEDDING_SCHEMA, CREATE_VECTOR_INDEX_QUERY, } from '../../src/core/lbug/schema.js'; +import { parseRelationSchemaPairs } from '../../src/core/lbug/rel-pair-routing.js'; describe('LadybugDB Schema', () => { describe('NODE_TABLES', () => { @@ -212,32 +213,70 @@ describe('LadybugDB Schema', () => { expect(RELATION_SCHEMA).toContain('FROM Class TO CodeElement'); }); + it('declares the Swift enum/property member-containment pairs (#2769)', () => { + // Asserted through the runtime's own parser, not raw strings, so a + // cosmetic DDL formatting change cannot fail this without a semantic + // change (see PR #2769 review Finding 7). The Rust impl/trait and JS/TS + // object-literal pairs get their own tests below — narrower and named + // for the specific abort each one fixes. + const declaredPairs = parseRelationSchemaPairs(RELATION_SCHEMA); + const memberPairs = [ + // Swift enum members (also reached by Java/PHP enum methods) + 'Enum|Function', + 'Enum|Method', + 'Enum|Struct', + 'Enum|Constructor', + 'Enum|Property', + 'Enum|TypeAlias', + 'Property|Class', + 'Property|Enum', + 'Property|Function', + 'Property|Struct', + 'Method|Variable', + 'Method|Const', + ]; + + for (const pair of memberPairs) { + expect(declaredPairs.has(pair)).toBe(true); + } + }); + it('has all FROM/TO pairs needed for HAS_METHOD edges', () => { // HAS_METHOD sources: Class, Interface, Struct, Trait, Impl, Record // HAS_METHOD targets: Method, Constructor (Property is now HAS_PROPERTY) + const declaredPairs = parseRelationSchemaPairs(RELATION_SCHEMA); const sources = ['Class', 'Interface']; const backtickSources = ['Struct', 'Trait', 'Impl', 'Record']; const targets = ['Method']; const backtickTargets = ['Constructor']; - // Non-backtick source → non-backtick target - for (const src of sources) { - for (const tgt of targets) { - expect(RELATION_SCHEMA).toContain(`FROM ${src} TO ${tgt}`); - } - for (const tgt of backtickTargets) { - expect(RELATION_SCHEMA).toContain(`FROM ${src} TO \`${tgt}\``); + for (const src of [...sources, ...backtickSources]) { + for (const tgt of [...targets, ...backtickTargets]) { + expect(declaredPairs.has(`${src}|${tgt}`)).toBe(true); } } + }); - // Backtick source → all targets - for (const src of backtickSources) { - for (const tgt of targets) { - expect(RELATION_SCHEMA).toContain(`FROM \`${src}\` TO ${tgt}`); - } - for (const tgt of backtickTargets) { - expect(RELATION_SCHEMA).toContain(`FROM \`${src}\` TO \`${tgt}\``); - } + it('has FROM/TO pairs for HAS_METHOD edges whose member is minted as Function, not Method (#2769)', () => { + // A Rust `impl`/`trait` method is minted as a `Function` node, not + // `Method` (tree-sitter-queries.ts), so `Impl|Method`/`Trait|Method` + // never fire for it — this was the reproduced abort on any Rust repo + // with an `impl` or `trait` block (PR #2769 review Finding 1). + const declaredPairs = parseRelationSchemaPairs(RELATION_SCHEMA); + for (const pair of ['Trait|Function', 'Impl|Function']) { + expect(declaredPairs.has(pair)).toBe(true); + } + }); + + it('has FROM/TO pairs for HAS_METHOD edges owned by a JS/TS object-literal binding (#2769)', () => { + // A `const x = { m() {} }` / `var x = { m() {} }` owner is labelled + // verbatim `Const`/`Variable` (ast-helpers.ts), and its shorthand + // method is a `Method` node — this was the second reproduced abort, + // hit on ordinary TS/JS code including GitNexus's own source tree + // (PR #2769 review Finding 1). + const declaredPairs = parseRelationSchemaPairs(RELATION_SCHEMA); + for (const pair of ['Const|Method', 'Variable|Method']) { + expect(declaredPairs.has(pair)).toBe(true); } }); }); From 99291891b7448326c7eb51129e1be35f88b888ba Mon Sep 17 00:00:00 2001 From: ChunxueLi <54129170+ChunxueLi@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:42:36 +0800 Subject: [PATCH 16/17] feat: make MAX_CALLABLE_VALUE_TARGETS configurable via env (#2725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(scope-resolution): make MAX_CALLABLE_VALUE_TARGETS configurable via env The branch's original commit was a whole-file snapshot taken at a stale base and never touched the constant, so the env read was missing and the branch's own test failed. Implemented here, matching the sibling GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT knob. Co-Authored-By: Claude Opus 5 (1M context) * test(callable-value-flow): add env override tests * docs(callable-value-flow): document GITNEXUS_MAX_CALLABLE_VALUE_TARGETS env Adds a Troubleshooting subsection to README.md and a commented entry to gitnexus/.env.example for the new per-callable-site dispatch-target cap (default 32), following the maintainer's review request to document the knob alongside its implementation. --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Ubuntu Co-authored-by: Gergő Magyar --- gitnexus/.env.example | 7 +++ gitnexus/README.md | 27 ++++++++++ .../passes/callable-value-flow.ts | 12 ++++- .../callable-value-flow-env.test.ts | 52 +++++++++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 gitnexus/test/unit/scope-resolution/callable-value-flow-env.test.ts diff --git a/gitnexus/.env.example b/gitnexus/.env.example index 38fabe218..3d4d73bad 100644 --- a/gitnexus/.env.example +++ b/gitnexus/.env.example @@ -38,3 +38,10 @@ # Positive integer only — non-integer or < 1 values fall back to 32. # See README § "Scope-resolution property-key dispatch cap". # GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT=32 + +# Per-callable-site dispatch-target cap (default 32). Raise this for repos whose +# wide dispatch tables overflow the default and lose a whole call chain; analyze +# then logs "callable-value-flow: candidate set exceeded the cap". Positive +# integer only — non-integer or < 1 values fall back to 32. +# See README § "Scope-resolution dispatch-target cap". +# GITNEXUS_MAX_CALLABLE_VALUE_TARGETS=64 diff --git a/gitnexus/README.md b/gitnexus/README.md index ad7ca4033..e9b16ac73 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -711,6 +711,33 @@ export GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT=64 npx gitnexus analyze --force ``` +### Scope-resolution dispatch-target cap + +During scope resolution GitNexus resolves calls that flow through *callable +values* — function/method references bound to variables, passed as arguments, +or stored in maps/tables. To keep that inclusion-based resolution finite, each +callable site is capped at **32 dispatch targets**. When a site gathers more +candidates than the cap it is treated as **overflowed** and *all* of its call +edges are dropped — a cliff, not a tail, so a repository with a legitimately +wide dispatch table (a single callable site resolving to 33+ targets) loses +that site's whole call chain. In that case `analyze` logs +`callable-value-flow: candidate set exceeded the cap; no partial CALLS emitted` +alongside a warning carrying the language, the overflowing context, the +candidate count, and the cap (32). + +Raise the cap for such repositories: + +| Variable | Default | Effect | +| ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GITNEXUS_MAX_CALLABLE_VALUE_TARGETS` | `32` | Per-callable-site dispatch-target cap in the callable-value-flow scope-resolution pass. Set to a positive integer to raise it for repositories whose wide dispatch tables overflow the default and lose a whole call chain; non-integer or `< 1` values fall back to `32`. Lowering it tightens the overflow budget. | + +```bash +# A callable site resolving to 48 targets overflows the default 32 and drops +# the chain — raise the cap for that repo and rebuild so scope resolution reruns. +export GITNEXUS_MAX_CALLABLE_VALUE_TARGETS=64 +npx gitnexus analyze --force +``` + ### Hook augmentation and skip diagnostics The Claude Code / Antigravity hooks keep their **stderr** silent on normal skip diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts index 1f39bf304..6a8e1ba83 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts @@ -29,7 +29,17 @@ import { resolveInheritanceBaseInScope } from '../scope/walkers.js'; import { definitionIdPosition } from '../utils/definition-id.js'; import { narrowOverloadCandidates } from './overload-narrowing.js'; -export const MAX_CALLABLE_VALUE_TARGETS = 32; +/** + * Per-site dispatch-target cap. Above it the site is treated as overflowed and + * its edges are dropped — a cliff, so a repo with a legitimately wide dispatch + * table (33+ candidates on one callable site) loses the whole call chain. + * + * Override via `GITNEXUS_MAX_CALLABLE_VALUE_TARGETS` for such repos. + */ +export const MAX_CALLABLE_VALUE_TARGETS = (() => { + const env = Number(process.env.GITNEXUS_MAX_CALLABLE_VALUE_TARGETS); + return Number.isInteger(env) && env >= 1 ? env : 32; +})(); interface Target { readonly id: string; diff --git a/gitnexus/test/unit/scope-resolution/callable-value-flow-env.test.ts b/gitnexus/test/unit/scope-resolution/callable-value-flow-env.test.ts new file mode 100644 index 000000000..33ca4591c --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/callable-value-flow-env.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, afterEach } from 'vitest'; + +/** + * Verifies that MAX_CALLABLE_VALUE_TARGETS reads the + * GITNEXUS_MAX_CALLABLE_VALUE_TARGETS environment variable at module load time + * and falls back to the default (32) when unset. + * + * Because the constant is evaluated once at import time, we use a dynamic + * import per test to get a fresh module instance under each env configuration. + */ + +const MODULE_PATH = '../../../src/core/ingestion/scope-resolution/passes/callable-value-flow.js'; + +async function loadWithEnv(envValue: string | undefined): Promise { + // vitest module cache is keyed by path; vi.resetModules is needed for re-import + const { vi } = await import('vitest'); + vi.resetModules(); + + if (envValue === undefined) { + delete process.env.GITNEXUS_MAX_CALLABLE_VALUE_TARGETS; + } else { + process.env.GITNEXUS_MAX_CALLABLE_VALUE_TARGETS = envValue; + } + + const mod = await import(MODULE_PATH); + return mod.MAX_CALLABLE_VALUE_TARGETS; +} + +describe('MAX_CALLABLE_VALUE_TARGETS env override', () => { + afterEach(() => { + delete process.env.GITNEXUS_MAX_CALLABLE_VALUE_TARGETS; + }); + + it('defaults to 32 when the env var is not set', async () => { + const cap = await loadWithEnv(undefined); + expect(cap).toBe(32); + }); + + it('respects a valid positive integer override', async () => { + const cap = await loadWithEnv('48'); + expect(cap).toBe(48); + }); + + it('falls back to default for non-integer values', async () => { + expect(await loadWithEnv('abc')).toBe(32); + }); + + it('falls back to default for values below 1', async () => { + expect(await loadWithEnv('0')).toBe(32); + expect(await loadWithEnv('-5')).toBe(32); + }); +}); From 11476465180b47a53f263a158092e9095a776cc5 Mon Sep 17 00:00:00 2001 From: MyShining <249674729@qq.com> Date: Sun, 2 Aug 2026 00:22:12 +0800 Subject: [PATCH 17/17] feat(spring): model AOP transactions, caching, and security (#2783) * feat(spring): model AOP advice and proxy behavior * fix(spring): address AOP review findings --------- Co-authored-by: Shining --- ARCHITECTURE.md | 276 ++--- gitnexus-shared/src/graph/types.ts | 6 + gitnexus-shared/src/lbug/schema-constants.ts | 1 + .../src/core/incremental/subgraph-extract.ts | 18 +- .../frameworks/spring/analysis-features.ts | 16 + .../frameworks/spring/annotation-arguments.ts | 34 +- .../frameworks/spring/aop-candidates.ts | 118 +++ .../core/ingestion/frameworks/spring/aop.ts | 707 +++++++++++++ .../languages/java/capture-side-channel.ts | 21 + .../core/ingestion/languages/java/captures.ts | 14 + .../languages/java/scope-resolver.ts | 2 + .../ingestion/languages/java/spring-aop.ts | 61 ++ .../languages/kotlin/capture-side-channel.ts | 25 + .../ingestion/languages/kotlin/captures.ts | 17 + .../languages/kotlin/scope-resolver.ts | 2 + .../ingestion/languages/kotlin/spring-aop.ts | 77 ++ .../core/ingestion/pipeline-phases/index.ts | 6 + .../ingestion/pipeline-phases/spring-aop.ts | 618 +++++++++++ gitnexus/src/core/ingestion/pipeline.ts | 16 +- gitnexus/src/core/lbug/graph-emit-sink.ts | 6 +- gitnexus/src/core/lbug/lbug-adapter.ts | 48 + gitnexus/src/core/lbug/schema.ts | 1 + gitnexus/src/core/run-analyze.ts | 17 +- gitnexus/src/mcp/local/aop-metadata.ts | 352 +++++++ gitnexus/src/mcp/local/bean-metadata.ts | 1 + gitnexus/src/mcp/local/local-backend.ts | 72 +- gitnexus/src/mcp/tools.ts | 2 +- gitnexus/src/storage/parse-cache.ts | 5 +- gitnexus/src/storage/repo-manager.ts | 7 +- .../integration/lbug-core-adapter.test.ts | 64 ++ .../test/integration/local-backend.test.ts | 1 + .../integration/spring-aop-benchmark.test.ts | 507 +++++++++ .../test/integration/spring-aop-mcp.test.ts | 457 ++++++++ .../integration/spring-aop-pipeline.test.ts | 997 ++++++++++++++++++ gitnexus/test/unit/analysis-features.test.ts | 4 + .../unit/call-summary-schema-version.test.ts | 9 +- .../unit/incremental-orchestration.test.ts | 207 ++++ .../test/unit/incremental-parse-cache.test.ts | 8 +- .../unit/incremental-subgraph-extract.test.ts | 30 + .../ingestion/pipeline-phase-registry.test.ts | 9 +- gitnexus/test/unit/schema.test.ts | 4 +- gitnexus/test/unit/security.test.ts | 2 + .../unit/spring-annotation-arguments.test.ts | 26 + gitnexus/test/unit/spring-aop.test.ts | 372 +++++++ gitnexus/test/unit/spring-bean-schema.test.ts | 2 + gitnexus/vitest.config.ts | 2 + 46 files changed, 5075 insertions(+), 172 deletions(-) create mode 100644 gitnexus/src/core/ingestion/frameworks/spring/aop-candidates.ts create mode 100644 gitnexus/src/core/ingestion/frameworks/spring/aop.ts create mode 100644 gitnexus/src/core/ingestion/languages/java/spring-aop.ts create mode 100644 gitnexus/src/core/ingestion/languages/kotlin/spring-aop.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/spring-aop.ts create mode 100644 gitnexus/src/mcp/local/aop-metadata.ts create mode 100644 gitnexus/test/integration/spring-aop-benchmark.test.ts create mode 100644 gitnexus/test/integration/spring-aop-mcp.test.ts create mode 100644 gitnexus/test/integration/spring-aop-pipeline.test.ts create mode 100644 gitnexus/test/unit/spring-aop.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 86ad6c426..c8063f848 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,18 +4,18 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`). ## Repository layout -| Path | Role | -|------|------| -| `gitnexus/` | npm package `gitnexus`: CLI, MCP server (stdio), HTTP API, ingestion pipeline, LadybugDB graph, embeddings. | -| `gitnexus-web/` | Vite + React thin client: graph explorer + AI chat. All queries via `gitnexus serve` HTTP API. | -| `gitnexus-shared/` | Shared TypeScript types and constants (consumed by CLI and Web). | -| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Agent skills and plugin metadata. | -| `eval/` | Evaluation harnesses for benchmarking tool usage. | -| `.github/` | CI workflows + composite actions (`setup-gitnexus/`, `setup-gitnexus-web/`). | +| Path | Role | +| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `gitnexus/` | npm package `gitnexus`: CLI, MCP server (stdio), HTTP API, ingestion pipeline, LadybugDB graph, embeddings. | +| `gitnexus-web/` | Vite + React thin client: graph explorer + AI chat. All queries via `gitnexus serve` HTTP API. | +| `gitnexus-shared/` | Shared TypeScript types and constants (consumed by CLI and Web). | +| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Agent skills and plugin metadata. | +| `eval/` | Evaluation harnesses for benchmarking tool usage. | +| `.github/` | CI workflows + composite actions (`setup-gitnexus/`, `setup-gitnexus-web/`). | ## End-to-end flow: index → graph → tools -1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 15 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery. +1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). The default DAG of 19 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery. 2. **Persistence** — `repo-manager.ts` (paths, registry, LadybugDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches). @@ -28,53 +28,53 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`). ## MCP tools -| Tool | Purpose | -|------|---------| -| `list_repos` | Discover indexed repos | -| `query` | Hybrid BM25 + vector search over the graph | -| `cypher` | Ad hoc Cypher against the schema | -| `context` | Callers, callees, processes for one symbol | -| `impact` | Blast radius (upstream/downstream) with risk summary | -| `detect_changes` | Map git diffs to affected symbols and processes | -| `rename` | Graph-assisted multi-file rename with `dry_run` preview | -| `api_impact` | Pre-change impact report for an API route handler | -| `trace` | Shortest directed path between two symbols (call + class-member edges); group-aware (`repo: "@"`) for cross-repo traces | -| `route_map` | API route → handler → consumer mappings | -| `tool_map` | MCP/RPC tool definitions and handlers | -| `shape_check` | Response shape vs consumer property access mismatches | -| `explain` | Persisted taint findings (source→sink data flows) — needs `analyze --pdg` | -| `pdg_query` | Control/data dependence — CDG (`mode: controls`) / REACHING_DEF (`mode: flows`) — needs `analyze --pdg` | -| `group_list` | List repo groups or details for one group | -| `group_sync` | Rebuild group Contract Registry (`contracts.json`) and bridge graph | +| Tool | Purpose | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `list_repos` | Discover indexed repos | +| `query` | Hybrid BM25 + vector search over the graph | +| `cypher` | Ad hoc Cypher against the schema | +| `context` | Callers, callees, processes for one symbol | +| `impact` | Blast radius (upstream/downstream) with risk summary | +| `detect_changes` | Map git diffs to affected symbols and processes | +| `rename` | Graph-assisted multi-file rename with `dry_run` preview | +| `api_impact` | Pre-change impact report for an API route handler | +| `trace` | Shortest directed path between two symbols (call + class-member edges); group-aware (`repo: "@"`) for cross-repo traces | +| `route_map` | API route → handler → consumer mappings | +| `tool_map` | MCP/RPC tool definitions and handlers | +| `shape_check` | Response shape vs consumer property access mismatches | +| `explain` | Persisted taint findings (source→sink data flows) — needs `analyze --pdg` | +| `pdg_query` | Control/data dependence — CDG (`mode: controls`) / REACHING_DEF (`mode: flows`) — needs `analyze --pdg` | +| `group_list` | List repo groups or details for one group | +| `group_sync` | Rebuild group Contract Registry (`contracts.json`) and bridge graph | `query`, `context`, and `impact` are group-aware: pass `repo: "@"` (or `"@/"` to scope to one member) plus optional `service: ""`. Group-mode `query` merges per-repo results via Reciprocal Rank Fusion; group-mode `impact` runs the local walk in the chosen member and fans out across boundaries via the Contract Bridge (`gitnexus/src/core/group/cross-impact.ts`). `trace` is also group-aware via `repo: "@"` — but, unlike the others, it resolves `from`/`to` across **all** members (a `@/` suffix is advisory for trace, not a scope); pass `from_uid`/`to_uid` to disambiguate a symbol name that occurs in more than one member. Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path that crosses repositories: it resolves `from`/`to` across all members, and when they live in different repos it joins the home-repo segment to the target-repo segment over a single `ContractLink` boundary (an HTTP consumer→provider link, joined on `Contract.symbolUid`), reported as a `CONTRACT_LINK` hop in `crossings[]`. The crossing is clamped to one boundary (`MAX_SUPPORTED_CROSS_DEPTH`, shared with cross-impact); deeper `crossDepth` is reported via `notes[]`. With `pdg: true` (experimental, opt-in), each boundary-adjacent segment is enriched with its intra-procedural REACHING_DEF data-flow when that repo was indexed with `--pdg` (reusing the same anchored `flows` query as `pdg_query`); data flow never crosses the repo boundary, and a missing PDG layer degrades to call-level hops with a note. Two stores meet only at the `symbolUid` grain — the per-repo PDG/call graph and the group bridge — so this is the documented join; full cross-program (SDG-like) data flow across the boundary remains deferred (see `docs/plans/2026-06-18-002-feat-unified-pdg-impact-evaluation-plan.md`). The previously-planned `group_query`, `group_context`, `group_impact`, `group_contracts`, `group_status` MCP tools are intentionally not introduced — group-level state is exposed via resources instead: -| Resource URI | Purpose | -|--------------|---------| +| Resource URI | Purpose | +| ----------------------------------- | -------------------------------------------------------- | | `gitnexus://group/{name}/contracts` | Contract Registry (provider/consumer rows + cross-links) | -| `gitnexus://group/{name}/status` | Per-member index + Contract Registry staleness | +| `gitnexus://group/{name}/status` | Per-member index + Contract Registry staleness | ## Where to change what -| Concern | Start in | -|---------|----------| -| CLI commands/flags | `src/cli/` (`index.ts`, per-command modules) | -| Parsing/graph construction | `src/core/ingestion/pipeline-phases/` + `pipeline.ts` | -| Graph schema/DB | `src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`) | -| MCP tools/resources | `src/mcp/server.ts`, `tools.ts`, `resources.ts` | -| Cross-repo groups (sync, contracts, `@` routing) | `src/core/group/` (`service.ts`, `cross-impact.ts`, `sync.ts`, `bridge-db.ts`) | -| Search ranking | `src/core/search/` (BM25, hybrid fusion) | -| Embeddings | `src/core/embeddings/` + `src/core/run-analyze.ts` | -| Wiki generation | `src/core/wiki/` | -| Language support | `src/core/ingestion/languages/` + `tree-sitter-queries.ts` + `gitnexus-shared/src/languages.ts` | -| Import resolution | `src/core/ingestion/import-processor.ts` + `import-resolvers/configs/` + `model/resolution-context.ts` | -| Call resolution/inheritance/MRO | `src/core/ingestion/scope-resolution/` (pipeline, passes, graph-bridge) | -| Type extraction | `src/core/ingestion/type-extractors/` | -| Worker pool | `src/core/ingestion/workers/` | -| Web UI | `gitnexus-web/src/` | -| CI | `.github/workflows/*.yml`, `.github/actions/` | +| Concern | Start in | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| CLI commands/flags | `src/cli/` (`index.ts`, per-command modules) | +| Parsing/graph construction | `src/core/ingestion/pipeline-phases/` + `pipeline.ts` | +| Graph schema/DB | `src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`) | +| MCP tools/resources | `src/mcp/server.ts`, `tools.ts`, `resources.ts` | +| Cross-repo groups (sync, contracts, `@` routing) | `src/core/group/` (`service.ts`, `cross-impact.ts`, `sync.ts`, `bridge-db.ts`) | +| Search ranking | `src/core/search/` (BM25, hybrid fusion) | +| Embeddings | `src/core/embeddings/` + `src/core/run-analyze.ts` | +| Wiki generation | `src/core/wiki/` | +| Language support | `src/core/ingestion/languages/` + `tree-sitter-queries.ts` + `gitnexus-shared/src/languages.ts` | +| Import resolution | `src/core/ingestion/import-processor.ts` + `import-resolvers/configs/` + `model/resolution-context.ts` | +| Call resolution/inheritance/MRO | `src/core/ingestion/scope-resolution/` (pipeline, passes, graph-bridge) | +| Type extraction | `src/core/ingestion/type-extractors/` | +| Worker pool | `src/core/ingestion/workers/` | +| Web UI | `gitnexus-web/src/` | +| CI | `.github/workflows/*.yml`, `.github/actions/` | > Paths above are relative to `gitnexus/` unless they start with `gitnexus-web/` or `.github/`. @@ -82,30 +82,35 @@ Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path th ## Pipeline Phase DAG -15 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output. +19 default phases are defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output. `--pdg` adds `taintSummaries` and `callSummaries` (21 total). ``` -scan → structure → [markdown, cobol] → parse → [routes, tools, orm] - → crossFile → scopeResolution → pruneLocalSymbols → mro → di → communities → processes +scan → structure → [springConfig, markdown, cobol] → parse → [routes, tools, orm] + → crossFile → scopeResolution → [springAutoConfiguration, springAop] + → pruneLocalSymbols → mro → springAopInheritance → di → communities → processes ``` -| Phase | File | Deps | Output | -|-------|------|------|--------| -| `scan` | `scan.ts` | (root) | File paths + sizes | -| `structure` | `structure.ts` | `scan` | File/Folder nodes, CONTAINS edges, `allPathSet` | -| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx | -| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) | -| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries | -| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators) | -| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges | -| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) | -| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order | -| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator | -| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution | -| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges | -| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes or factory Methods to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) | -| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) | -| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges | +| Phase | File | Deps | Output | +| ------------------------- | -------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scan` | `scan.ts` | (root) | File paths + sizes | +| `structure` | `structure.ts` | `scan` | File/Folder nodes, CONTAINS edges, `allPathSet` | +| `springConfig` | `spring-config.ts` | `structure` | Spring configuration-property nodes and metadata | +| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx | +| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) | +| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries | +| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators) | +| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges | +| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) | +| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order | +| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator | +| `springAutoConfiguration` | `spring-auto-configuration.ts` | `structure`, `scopeResolution` | DECLARES and CONDITIONAL_ON metadata for Spring configuration candidates | +| `springAop` | `spring-aop.ts` | `scopeResolution` | Direct declarative/advice ADVISED_BY edges and pointcut evidence | +| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution | +| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges | +| `springAopInheritance` | `spring-aop.ts` | `springAop`, `mro` | Propagates declarative behavior through class/interface inheritance decisions | +| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes or factory Methods to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) | +| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) | +| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges | **Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `types.ts`, `runner.ts`, `index.ts`. @@ -124,6 +129,7 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm] 4. **Timing** — per-phase `durationMs` in `PhaseResult`, dev-mode console logging. **Design patterns:** + - **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output. - **Typed phase access** — `getPhaseOutput(deps, 'name')` for type-safe upstream results. - **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership. @@ -141,7 +147,9 @@ import type { PipelinePhase, PhaseResult } from './types.js'; import { getPhaseOutput } from './types.js'; import type { ParseOutput } from './parse.js'; -export interface MyPhaseOutput { /* ... */ } +export interface MyPhaseOutput { + /* ... */ +} export const myPhase: PipelinePhase = { name: 'myPhase', @@ -149,7 +157,9 @@ export const myPhase: PipelinePhase = { async execute(ctx, deps) { const { allPaths } = getPhaseOutput(deps, 'parse'); // ... write to ctx.graph ... - return { /* typed output */ }; + return { + /* typed output */ + }; }, }; ``` @@ -228,7 +238,7 @@ Standalone (regex-based) providers such as COBOL participate via `ScopeResolver. On a `--pdg` run the parse worker builds a per-function control-flow graph from the tree-sitter AST (`LanguageProvider.cfgVisitor`; TypeScript/JavaScript today) and serializes it onto `ParsedFile.cfgSideChannel` as plain data. Scope-resolution then emits the program-dependence layers from that side-channel **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live** — the only window where the worker-built CFGs are loaded (the store is cleared right after the phase returns). A standalone post-`mro` phase would read an empty store, so the emit deliberately lives in-phase, mirroring the `applyCaptureSideChannel` pattern. The opt-in is off by default (graph byte-identical), folded into the parse-cache key (a pdg-off warm cache is never reused on a `--pdg` run), and each layer is bounded by a per-function edge cap that logs any dropped edges. All layers are `BasicBlock → BasicBlock` edges in the single `CodeRelation` table, keyed by `type`; there is **no** `Function → BasicBlock` edge — the symbol↔block join is reconstructed from the BasicBlock id prefix + line span. The layers build on each other: -- **M1 — CFG** (#2081): `BasicBlock` nodes + `CFG` edges. Edge *kind* (`seq`/`cond-true`/`loop-back`/…) rides the `reason` column (CFG is one `CodeRelation` type, not one per kind). +- **M1 — CFG** (#2081): `BasicBlock` nodes + `CFG` edges. Edge _kind_ (`seq`/`cond-true`/`loop-back`/…) rides the `reason` column (CFG is one `CodeRelation` type, not one per kind). - **M2 — REACHING_DEF** (#2082): GEN/KILL def→use data dependence from a pure fixpoint solver; the variable name rides `reason`. - **M3/M4 — TAINTED / SANITIZES / TAINT_PATH** (#2083–#2084): intra- and inter-procedural taint (source→sink) — the `explain` tool's data. - **M5 — CDG** (#2085): Ferrante control dependence over a Cooper–Harvey–Kennedy post-dominator tree (the EXIT-rooted reverse CFG); branch sense (`'T'`/`'F'`) rides `reason`. A CFG whose EXIT is unreachable from some block is skipped for CDG (post-dominance would be unsound) while its CFG/REACHING_DEF layers are kept. @@ -241,25 +251,25 @@ See `core/ingestion/cfg/` (emit + the pure CFG / post-dominator / control-depend Single interface a language implements to plug into the pipeline. Contract fully documented in `scope-resolution/contract/scope-resolver.ts`. -| Hook | Purpose | -|------|---------| -| `languageProvider` | Base `LanguageProvider` (tree-sitter query, `emitScopeCaptures`, import/binding interpreters, hooks) | -| `populateOwners(parsed)` | Fill deferred `ownerId` fields on method defs (captures can't always know the owning class at parse time) | -| `buildMro(graph, parsed, nodeLookup)` | Produce `mroByClassDefId: Map` — C3, Ruby-mixin, or first-wins per language | -| `resolveImportTarget(target, fromFile, allFiles)` | `(rawImportPath, sourceFile) → targetFilePath` (PEP-328 for Python, etc.) | -| `isNamespaceImport(parsedImport, targetFile, fromFile)` | Optionally reclassify a resolved named import as a namespace handle when the imported symbol is itself a module | -| `mergeBindings(existing, incoming, scopeId)` | Shadowing / LEGB precedence | -| `arityCompatibility` | Provider consumed by registry during `MethodRegistry.lookup` Step 2 | -| `importEdgeReason` | Confidence-tier string for IMPORTS edge reason field | -| `propagatesReturnTypesAcrossImports?` | Opt out of cross-file return-type propagation (default on) | -| `fieldFallbackOnMethodLookup?` | Statically-typed languages turn this OFF — the heuristic over-connects (default on) | -| `unwrapCollectionAccessor?` | Property-style collection views (`data.Values` on Dictionary-like receivers) — default off | -| `collapseMemberCallsByCallerTarget?` | One CALLS edge per (caller, target) instead of per-site — default off | -| `populateNamespaceSiblings?` | Cross-file implicit visibility (compiler-implicit namespace sharing) — default off; ctx carries `treeCache` | -| `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level | -| `hasFileLocalCallableLinkage?` | Precise internal-linkage predicate used only when joining callable declarations/prototypes to cross-file definitions; C/C++ use it for `static` free functions | -| `constructorCallTargetsClass?` | A constructor-form call `Type(...)` links to the Class def rather than its explicit Constructor def — default off; Swift and Dart opt in | -| `constructionSyntax?` | How the language spells construction, so an INLINE constructor receiver (`Service(db).m()`, `new Service(db).m()`, `Service.new.m()`) can be typed — `bare` / `keyword` / `selector`; default off, opt in per language only where measured to be needed (#2708) | +| Hook | Purpose | +| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `languageProvider` | Base `LanguageProvider` (tree-sitter query, `emitScopeCaptures`, import/binding interpreters, hooks) | +| `populateOwners(parsed)` | Fill deferred `ownerId` fields on method defs (captures can't always know the owning class at parse time) | +| `buildMro(graph, parsed, nodeLookup)` | Produce `mroByClassDefId: Map` — C3, Ruby-mixin, or first-wins per language | +| `resolveImportTarget(target, fromFile, allFiles)` | `(rawImportPath, sourceFile) → targetFilePath` (PEP-328 for Python, etc.) | +| `isNamespaceImport(parsedImport, targetFile, fromFile)` | Optionally reclassify a resolved named import as a namespace handle when the imported symbol is itself a module | +| `mergeBindings(existing, incoming, scopeId)` | Shadowing / LEGB precedence | +| `arityCompatibility` | Provider consumed by registry during `MethodRegistry.lookup` Step 2 | +| `importEdgeReason` | Confidence-tier string for IMPORTS edge reason field | +| `propagatesReturnTypesAcrossImports?` | Opt out of cross-file return-type propagation (default on) | +| `fieldFallbackOnMethodLookup?` | Statically-typed languages turn this OFF — the heuristic over-connects (default on) | +| `unwrapCollectionAccessor?` | Property-style collection views (`data.Values` on Dictionary-like receivers) — default off | +| `collapseMemberCallsByCallerTarget?` | One CALLS edge per (caller, target) instead of per-site — default off | +| `populateNamespaceSiblings?` | Cross-file implicit visibility (compiler-implicit namespace sharing) — default off; ctx carries `treeCache` | +| `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level | +| `hasFileLocalCallableLinkage?` | Precise internal-linkage predicate used only when joining callable declarations/prototypes to cross-file definitions; C/C++ use it for `static` free functions | +| `constructorCallTargetsClass?` | A constructor-form call `Type(...)` links to the Class def rather than its explicit Constructor def — default off; Swift and Dart opt in | +| `constructionSyntax?` | How the language spells construction, so an INLINE constructor receiver (`Service(db).m()`, `new Service(db).m()`, `Service.new.m()`) can be typed — `bare` / `keyword` / `selector`; default off, opt in per language only where measured to be needed (#2708) | ### Per-language registration @@ -270,21 +280,21 @@ CI auto-discovers the set via `tsx`. No workflow edit required. ### Code references -| Module | Purpose | -|--------|---------| -| `scope-resolution/contract/scope-resolver.ts` | `ScopeResolver` interface + shared types | -| `scope-resolution/pipeline/run.ts` | Generic orchestrator | -| `scope-resolution/pipeline/phase.ts` | Pipeline-phase wrapper (deps: `parse`, `structure`) | -| `scope-resolution/pipeline/registry.ts` | `SCOPE_RESOLVERS` map | -| `scope-resolution/passes/*.ts` | Reference-resolution passes (receiver-bound, free-call fallback, compound-receiver, MRO, cross-file return-type propagation) | -| `scope-resolution/graph-bridge/*.ts` | CLI-local translation from resolved references → `KnowledgeGraph` edges | -| `scope-resolution/scope/*.ts` | Generic scope-chain walkers + namespace targets | -| `scope-resolution/workspace-index.ts` | Build-once O(1) lookup index | -| `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs | -| `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) | -| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs | -| `languages/csharp/captures.ts` | `emitCsharpScopeCaptures` (honors cross-phase Tree cache) | -| `languages/csharp/namespace-siblings.ts` | Cross-file implicit-namespace visibility hook (reads `treeCache`) | +| Module | Purpose | +| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `scope-resolution/contract/scope-resolver.ts` | `ScopeResolver` interface + shared types | +| `scope-resolution/pipeline/run.ts` | Generic orchestrator | +| `scope-resolution/pipeline/phase.ts` | Pipeline-phase wrapper (deps: `parse`, `structure`) | +| `scope-resolution/pipeline/registry.ts` | `SCOPE_RESOLVERS` map | +| `scope-resolution/passes/*.ts` | Reference-resolution passes (receiver-bound, free-call fallback, compound-receiver, MRO, cross-file return-type propagation) | +| `scope-resolution/graph-bridge/*.ts` | CLI-local translation from resolved references → `KnowledgeGraph` edges | +| `scope-resolution/scope/*.ts` | Generic scope-chain walkers + namespace targets | +| `scope-resolution/workspace-index.ts` | Build-once O(1) lookup index | +| `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs | +| `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) | +| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs | +| `languages/csharp/captures.ts` | `emitCsharpScopeCaptures` (honors cross-phase Tree cache) | +| `languages/csharp/namespace-siblings.ts` | Cross-file implicit-namespace visibility hook (reads `treeCache`) | ### Performance notes @@ -314,15 +324,15 @@ CI auto-discovers the set via `tsx`. No workflow edit required. Each language implements `LanguageProvider` (`language-provider.ts`). Key fields: -| Field | Purpose | -|-------|---------| -| `id`, `extensions` | Language identity and file matching | -| `treeSitterQueries` | S-expression queries for AST extraction | -| `importSemantics` | `named` / `wildcard-leaf` / `wildcard-transitive` / `namespace` | -| `importResolver` | Language-specific path → file resolution | -| `exportChecker` | Public/exported symbol detection | -| `typeConfig` | Type annotation extraction rules | -| `mroStrategy` | `first-wins` / `c3` / `none` | +| Field | Purpose | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id`, `extensions` | Language identity and file matching | +| `treeSitterQueries` | S-expression queries for AST extraction | +| `importSemantics` | `named` / `wildcard-leaf` / `wildcard-transitive` / `namespace` | +| `importResolver` | Language-specific path → file resolution | +| `exportChecker` | Public/exported symbol detection | +| `typeConfig` | Type annotation extraction rules | +| `mroStrategy` | `first-wins` / `c3` / `none` | | `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) | 16 providers in `languages/index.ts` via `satisfies Record` — missing a language is a compile error. @@ -337,22 +347,23 @@ Per-language import resolution uses the **configs + factory** pattern (like call Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSemantics` controls which tier activates: -| Tier | Confidence | Mechanism | -|------|-----------|-----------| -| 1 — same-file | 0.95 | Symbol table for caller's file | -| 2 — import-scoped | 0.9 | `NamedImportMap` chains (named) or all files in `importMap` (wildcard) | -| 3 — global | 0.5 | O(1) index lookups: class, impl, callable. Fallback only | +| Tier | Confidence | Mechanism | +| ----------------- | ---------- | ---------------------------------------------------------------------- | +| 1 — same-file | 0.95 | Symbol table for caller's file | +| 2 — import-scoped | 0.9 | `NamedImportMap` chains (named) or all files in `importMap` (wildcard) | +| 3 — global | 0.5 | O(1) index lookups: class, impl, callable. Fallback only | -| Import strategy | Languages | Behavior | -|----------------|-----------|----------| -| `named` | TS, JS, Java, C#, Rust, PHP, Kotlin | Only explicitly imported names visible | -| `wildcard-leaf` | Go, Ruby, Swift, Dart | Whole-package import, no transitive re-exports | -| `wildcard-transitive` | C, C++ | `#include` closure chains through re-exports | -| `namespace` | Python | Module aliases resolved at call site | +| Import strategy | Languages | Behavior | +| --------------------- | ----------------------------------- | ---------------------------------------------- | +| `named` | TS, JS, Java, C#, Rust, PHP, Kotlin | Only explicitly imported names visible | +| `wildcard-leaf` | Go, Ruby, Swift, Dart | Whole-package import, no transitive re-exports | +| `wildcard-transitive` | C, C++ | `#include` closure chains through re-exports | +| `namespace` | Python | Module aliases resolved at call site | ### Chunked parse-and-resolve `parse` processes files in ~20 MB byte-budget chunks to bound memory. Per chunk: + 1. Worker pool dispatches files (the sole parse path — there is no sequential fallback; `skipWorkers`, `--workers 0`, and `GITNEXUS_WORKER_POOL_SIZE=0` are rejected with an actionable error) 2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult` 3. Synthesize wildcard bindings (`wildcard-synthesis.ts`) @@ -363,11 +374,12 @@ Inheritance edges are emitted later, by the scope-resolution phase (`preEmitInhe Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`. -**Worker-serialized ParsedFiles (#2038).** To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the *sole* parse path and workers serialize each file's `ParsedFile` (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence `usedWorkerPool` can be false even when the repo has parseable files). +**Worker-serialized ParsedFiles (#2038).** To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the _sole_ parse path and workers serialize each file's `ParsedFile` (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence `usedWorkerPool` can be false even when the repo has parseable files). ### Inheritance and MRO Inheritance is captured by the `@reference.inherits` tag and emitted by the scope-resolution phase: `preEmitInheritanceEdges` resolves each base in scope, then `emitHeritageEdges` writes the `EXTENDS`/`IMPLEMENTS` edges. The phase then computes method resolution order via each `ScopeResolver`'s `buildMro` hook, feeding a `MethodDispatchIndex` used for owner-scoped lookups. Per-language strategy: + - **`first-wins`** — Java, C#, C++, TS, Ruby, Go - **`c3`** — Python (C3 linearization) - **`ruby-mixin`** — Ruby (mixin-aware linearization) @@ -419,7 +431,7 @@ Defined in `lbug/schema.ts`. Separate node tables per type, single `CodeRelation **Node tables:** File, Folder, Function, Class, Interface, Method, Constructor, CodeElement, Struct, Enum, Macro, Typedef, Union, Namespace, Trait, Impl, TypeAlias, Const, Static, Property, Record, Delegate, Annotation, Template, Module, Community, Process, Route, Tool, Section, Embedding. -**Relation types** (`CodeRelation.type`): CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF. +**Relation types** (`CodeRelation.type`): CONTAINS, DEFINES, CALLS, IMPORTS, INHERITS, EXTENDS, IMPLEMENTS, USES, DECORATES, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF, WRAPS, QUERIES, INJECTS, CONDITIONAL_ON, DECLARES, ADVISED_BY, BINDS_EVENT_HANDLER, EMITS_EVENT. **Optional `--pdg` additions** (off by default, opt-in via `gitnexus analyze --pdg`; see _Optional CFG/PDG emission_ above): a `BasicBlock` node table, plus the PDG relation types `CFG`, `REACHING_DEF`, `CDG`, `TAINTED`, `SANITIZES`, and `TAINT_PATH` on the same `CodeRelation` table. These are deliberately kept out of the default `VALID_RELATION_TYPES` / web graph schema — query them via `cypher`, `explain`, or `pdg_query`. @@ -447,12 +459,12 @@ Node IDs use arity suffix (`#`): `Method:file:Class.method#1` vs `#2 **METHOD_IMPLEMENTS confidence tiering:** -| Match quality | Confidence | -|---|---| -| Exact parameter types match | 1.0 | -| Arity match, types unavailable | 1.0 | -| Variadic vs fixed | 0.7 | -| Insufficient info | 0.7 | +| Match quality | Confidence | +| ------------------------------ | ---------- | +| Exact parameter types match | 1.0 | +| Arity match, types unavailable | 1.0 | +| Variadic vs fixed | 0.7 | +| Insufficient info | 0.7 | ## Related docs diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index cf6d7252b..d9d916e0d 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -153,6 +153,12 @@ export type RelationshipType = * semantics belong in `reason` so the relationship can be reused by other * metadata-driven systems. */ | 'DECLARES' + /** Framework advice relationship. Source = the class-like/Method whose behavior + * is intercepted; target = either the concrete advice Method or a synthetic + * CodeElement describing a declarative interceptor (transaction, cache, or + * method security). Runtime activation remains explicitly unknown in the + * relationship reason; this edge records statically visible advice only. */ + | 'ADVISED_BY' /** Vue component event system: a handler function in a parent component is * bound to an event emitted by a child component (`@event="handlerFn"`). * Source = handler Function/Method node in the parent. diff --git a/gitnexus-shared/src/lbug/schema-constants.ts b/gitnexus-shared/src/lbug/schema-constants.ts index ff3f4535b..350aa273d 100644 --- a/gitnexus-shared/src/lbug/schema-constants.ts +++ b/gitnexus-shared/src/lbug/schema-constants.ts @@ -72,6 +72,7 @@ export const REL_TYPES = [ 'INJECTS', 'CONDITIONAL_ON', 'DECLARES', + 'ADVISED_BY', // Taint/PDG substrate (issue #2080) — reserved edge types, emitted by no // phase yet (CFG → M1, REACHING_DEF → M2, TAINTED/SANITIZES/TAINT_PATH → // M3/M4). REACHING_DEF's variable name rides the relation's `reason` column. diff --git a/gitnexus/src/core/incremental/subgraph-extract.ts b/gitnexus/src/core/incremental/subgraph-extract.ts index 9f2e49a56..e0f0e41eb 100644 --- a/gitnexus/src/core/incremental/subgraph-extract.ts +++ b/gitnexus/src/core/incremental/subgraph-extract.ts @@ -55,10 +55,12 @@ import { isSpringAutoConfigurationDeclaration, isSpringAutoConfigurationSyntheticClass, } from '../ingestion/frameworks/spring/auto-configuration.js'; +import { isSpringAopEvidenceNode } from '../ingestion/frameworks/spring/aop.js'; const isGraphWideNode = (node: GraphNode): boolean => node.label === 'Community' || node.label === 'Process' || + isSpringAopEvidenceNode(node) || isSpringAutoConfigurationSyntheticClass(node); /** @@ -98,6 +100,10 @@ const isGraphWideRelationship = (relationship: GraphRelationship): boolean => relationship.type === 'TAINT_PATH' || relationship.type === 'CALL_SUMMARY' || relationship.type === 'INJECTS' || + // Spring pointcut matching (#2416) is repository-wide. A third-file change + // can alter annotation-name visibility or the set matched by a wildcard, + // even when neither endpoint file changed. + relationship.type === 'ADVISED_BY' || isSpringAutoConfigurationDeclaration(relationship); /** @@ -144,10 +150,13 @@ export const extractChangedSubgraph = ( /** * Public — derive the EFFECTIVE write-set: `toWriteSet` expanded by one - * hop along every edge in the new graph that crosses the writable - * boundary (one endpoint in a writable file, the other in an unchanged - * file). The unchanged-side file is pulled in so its stale rows are - * deleted + rewritten in lockstep with the changed side. + * hop along every file-owned edge in the new graph that crosses the + * writable boundary (one endpoint in a writable file, the other in an + * unchanged file). Graph-wide relationships are excluded: their owner + * phase delete-alls and re-extracts them independently, so following them + * here would turn high-fan-out metadata into a near-full-repository write. + * For ordinary edges, the unchanged-side file is pulled in so its stale + * rows are deleted + rewritten in lockstep with the changed side. * * Single pass over the edge list. Does NOT mutate `toWriteSet`. The * orchestrator MUST feed the returned set to both `deleteNodesForFiles` @@ -161,6 +170,7 @@ export const computeEffectiveWriteSet = ( const nodeFilePaths = indexNodeFilePaths(fullGraph); const expanded = new Set(toWriteSet); fullGraph.forEachRelationship((r: GraphRelationship) => { + if (isGraphWideRelationship(r)) return; const sourcePath = nodeFilePaths.get(r.sourceId); const targetPath = nodeFilePaths.get(r.targetId); if (!sourcePath || !targetPath) return; // skip edges to graph-wide nodes diff --git a/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts b/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts index 58edeca7d..37b5f2b14 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts @@ -27,3 +27,19 @@ export const SPRING_CONDITIONALS_FEATURE: AnalysisFeatureDescriptor = { version: 1, appliesTo: (filePaths) => filePaths.some(isSpringConditionOrAutoConfigurationFile), }; + +/** + * Candidate-language approximation, not a claim that the file contains AOP. + * Kotlin scripts are included because `.kts` is a supported Kotlin input. + */ +function isJvmSourceFile(filePath: string): boolean { + const normalized = filePath.replaceAll('\\', '/').toLowerCase(); + return normalized.endsWith('.java') || normalized.endsWith('.kt') || normalized.endsWith('.kts'); +} + +/** Durable completeness contract for Spring proxy/advice evidence (#2416). */ +export const SPRING_AOP_FEATURE: AnalysisFeatureDescriptor = { + id: 'spring.aop-advice', + version: 1, + appliesTo: (filePaths) => filePaths.some(isJvmSourceFile), +}; diff --git a/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts b/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts index eb05e44f2..928f3e805 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts @@ -6,7 +6,7 @@ export interface SpringAnnotationArgument { function splitTopLevel(value: string, separator: string): string[] | null { const parts: string[] = []; const stack: string[] = []; - let quote: '"' | "'" | null = null; + let quote: '"' | "'" | '"""' | null = null; let escaped = false; let start = 0; @@ -22,12 +22,24 @@ function splitTopLevel(value: string, separator: string): string[] | null { for (let index = 0; index < value.length; index++) { const char = value[index]; + if (quote === '"""') { + if (value.startsWith('"""', index)) { + quote = null; + index += 2; + } + continue; + } if (quote !== null) { if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === quote) quote = null; continue; } + if (value.startsWith('"""', index)) { + quote = '"""'; + index += 2; + continue; + } if (char === '"' || char === "'") { quote = char; continue; @@ -53,7 +65,7 @@ function splitTopLevel(value: string, separator: string): string[] | null { function topLevelAssignment(argument: string): number { const stack: string[] = []; - let quote: '"' | "'" | null = null; + let quote: '"' | "'" | '"""' | null = null; let escaped = false; // Keep the same deliberate generic-delimiter policy as splitTopLevel. const closing = new Map([ @@ -65,12 +77,24 @@ function topLevelAssignment(argument: string): number { for (let index = 0; index < argument.length; index++) { const char = argument[index]; + if (quote === '"""') { + if (argument.startsWith('"""', index)) { + quote = null; + index += 2; + } + continue; + } if (quote !== null) { if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === quote) quote = null; continue; } + if (argument.startsWith('"""', index)) { + quote = '"""'; + index += 2; + continue; + } if (char === '"' || char === "'") { quote = char; continue; @@ -119,6 +143,12 @@ export function parseSpringAnnotationArguments( export function parseStaticStringLiteral(value: string): string | null { const trimmed = value.trim(); + if (trimmed.startsWith('"""')) { + if (trimmed.length < 6 || !trimmed.endsWith('"""')) return null; + const raw = trimmed.slice(3, -3); + if (raw.includes('"""') || /\$(?:\{|[A-Za-z_])/.test(raw)) return null; + return raw; + } const match = /^"((?:\\.|[^"\\])*)"$/s.exec(trimmed); if (match === null) return null; if (/(^|[^\\])\$(?:\{|[A-Za-z_])/.test(match[1])) return null; diff --git a/gitnexus/src/core/ingestion/frameworks/spring/aop-candidates.ts b/gitnexus/src/core/ingestion/frameworks/spring/aop-candidates.ts new file mode 100644 index 000000000..3e1e8b2c4 --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/aop-candidates.ts @@ -0,0 +1,118 @@ +import type { GraphNode } from 'gitnexus-shared'; +import type { SpringAopStaticPointcut } from './aop.js'; + +export interface SpringAopOwnedMethod { + readonly method: GraphNode; + readonly owner: GraphNode; +} + +export interface SpringAopCandidateIndex { + readonly totalCandidates: number; + candidatesFor(pointcut: SpringAopStaticPointcut): readonly SpringAopOwnedMethod[]; +} + +interface OwnerBucket { + readonly id: string; + readonly qualifiedName: string; + readonly candidates: SpringAopOwnedMethod[]; +} + +function ownerPatternLiteralPrefix(pattern: string): string { + const wildcardIndex = pattern.indexOf('*'); + const descendantIndex = pattern.indexOf('..'); + const firstDynamicIndex = [wildcardIndex, descendantIndex] + .filter((index) => index >= 0) + .reduce((first, index) => Math.min(first, index), pattern.length); + return pattern.slice(0, firstDynamicIndex); +} + +function lowerBoundByQualifiedName(buckets: readonly OwnerBucket[], prefix: string): number { + let low = 0; + let high = buckets.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if ((buckets[middle]?.qualifiedName ?? '') < prefix) low = middle + 1; + else high = middle; + } + return low; +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +/** + * Build immutable candidate lists once per pipeline run. The pointcut matcher + * remains the final authority; this index only returns safe supersets. + */ +export function createSpringAopCandidateIndex( + candidates: readonly SpringAopOwnedMethod[], + methodAnnotations: ReadonlyMap>, +): SpringAopCandidateIndex { + const allCandidates = [...candidates]; + const candidatesByAnnotation = new Map(); + const candidatesByExactOwner = new Map(); + const ownerBucketsById = new Map(); + + for (const candidate of allCandidates) { + for (const annotation of methodAnnotations.get(candidate.method.id) ?? []) { + const annotated = candidatesByAnnotation.get(annotation) ?? []; + annotated.push(candidate); + candidatesByAnnotation.set(annotation, annotated); + } + + const qualifiedName = candidate.owner.properties.qualifiedName; + if (typeof qualifiedName !== 'string' || qualifiedName.length === 0) continue; + const exactOwnerCandidates = candidatesByExactOwner.get(qualifiedName) ?? []; + exactOwnerCandidates.push(candidate); + candidatesByExactOwner.set(qualifiedName, exactOwnerCandidates); + + let bucket = ownerBucketsById.get(candidate.owner.id); + if (bucket === undefined) { + bucket = { id: candidate.owner.id, qualifiedName, candidates: [] }; + ownerBucketsById.set(candidate.owner.id, bucket); + } + bucket.candidates.push(candidate); + } + + const ownerBuckets = [...ownerBucketsById.values()].sort( + (left, right) => + compareStrings(left.qualifiedName, right.qualifiedName) || compareStrings(left.id, right.id), + ); + const candidatesByOwnerPattern = new Map(); + + return { + totalCandidates: allCandidates.length, + candidatesFor(pointcut) { + if (pointcut.kind === 'annotation') { + return candidatesByAnnotation.get(pointcut.annotation) ?? []; + } + if (!pointcut.ownerPattern.includes('*') && !pointcut.ownerPattern.includes('..')) { + return candidatesByExactOwner.get(pointcut.ownerPattern) ?? []; + } + + // Unqualified wildcard patterns (for example `*Service` or `Order*`) + // match the owner simple name. The qualified-name prefix index cannot + // safely narrow those, so preserve the full candidate superset. + if (!pointcut.ownerPattern.includes('.')) return allCandidates; + + const prefix = ownerPatternLiteralPrefix(pointcut.ownerPattern); + if (prefix.length === 0) return allCandidates; + const cached = candidatesByOwnerPattern.get(pointcut.ownerPattern); + if (cached !== undefined) return cached; + + const selected: SpringAopOwnedMethod[] = []; + for ( + let index = lowerBoundByQualifiedName(ownerBuckets, prefix); + index < ownerBuckets.length; + index += 1 + ) { + const bucket = ownerBuckets[index]; + if (bucket === undefined || !bucket.qualifiedName.startsWith(prefix)) break; + selected.push(...bucket.candidates); + } + candidatesByOwnerPattern.set(pointcut.ownerPattern, selected); + return selected; + }, + }; +} diff --git a/gitnexus/src/core/ingestion/frameworks/spring/aop.ts b/gitnexus/src/core/ingestion/frameworks/spring/aop.ts new file mode 100644 index 000000000..fe82eaed6 --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/aop.ts @@ -0,0 +1,707 @@ +import type { GraphNode, ParsedFile, Range, ScopeId } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { + resolveCallerGraphId, + resolveDefGraphId, +} from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { stripBidiAndZeroWidth } from '../../utils/ast-helpers.js'; +import { + parseSpringAnnotationArguments, + parseStaticStringLiteral, +} from './annotation-arguments.js'; +import { createSpringAnnotationNameResolver } from './bean-candidates.js'; + +export const SPRING_AOP_REASON_PREFIX = 'spring-aop:v1:'; +export const SPRING_AOP_EVIDENCE_DESCRIPTION_PREFIX = 'Spring AOP: '; +export const SPRING_AOP_EVIDENCE_ID_PREFIX = 'CodeElement:spring-aop:'; +const MAX_POINTCUT_LENGTH = 1_000; + +export type SpringAopBehavior = + | 'transactional' + | 'caching' + | 'cacheable' + | 'cache-evict' + | 'cache-put' + | 'authorization'; + +export type SpringAopAdviceKind = + | 'around' + | 'before' + | 'after' + | 'after-returning' + | 'after-throwing' + | 'pointcut'; + +export interface SpringAopAnnotationFact { + readonly name: string; + readonly text: string; + readonly line: number; + /** Kotlin use-site targets do not describe a callable annotation here. */ + readonly useSiteTarget?: string; +} + +export interface SpringAopOwnerFact< + Annotation extends SpringAopAnnotationFact = SpringAopAnnotationFact, +> { + readonly ownerScopeId: ScopeId; + readonly ownerKind: 'class' | 'callable'; + readonly ownerFilePath?: string; + /** Exact syntax range used only as a fail-closed bridge for collapsed language scopes. */ + readonly ownerRange?: Range; + /** The language models this owner/member as static, but it belongs to a singleton instance. */ + readonly singletonInstance?: true; + readonly annotations: readonly Annotation[]; +} + +export interface SpringAopMetadataAdapter { + getFacts(filePath: string): readonly SpringAopOwnerFact[]; + isPackageVisibilityIncomplete(filePath: string): boolean; +} + +export interface SpringAopBehaviorReason { + readonly kind: 'behavior'; + readonly annotation: string; + readonly behavior: SpringAopBehavior; + readonly declaredOn: 'class' | 'method'; + readonly activation: 'unknown'; + readonly proxy: 'possible'; +} + +export interface SpringAopAdviceReason { + readonly kind: 'advice'; + readonly annotation: string; + readonly advice: Exclude; + readonly pointcut: string; + readonly match: 'static'; + readonly activation: 'unknown'; + readonly proxy: 'possible'; +} + +export interface SpringAopPointcutReason { + readonly kind: 'pointcut'; + readonly annotation: string; + readonly pointcut: string | null; + readonly match: 'static' | 'unresolved'; + readonly resolution: 'resolved' | 'unknown'; +} + +export interface SpringAopAspectReason { + readonly kind: 'aspect'; + readonly annotation: string; + readonly activation: 'unknown'; + readonly registration: 'unknown'; +} + +export type SpringAopReason = + | SpringAopBehaviorReason + | SpringAopAdviceReason + | SpringAopPointcutReason + | SpringAopAspectReason; + +export interface SpringAopAspectRecord { + readonly ownerId: string; + readonly annotation: string; + readonly line: number; +} + +export interface SpringAopBehaviorRecord { + readonly ownerId: string; + readonly ownerKind: 'class' | 'callable'; + readonly annotation: string; + readonly behavior: SpringAopBehavior; + readonly line: number; +} + +export interface SpringAopAdviceRecord { + readonly ownerId: string; + readonly annotation: string; + readonly advice: SpringAopAdviceKind; + readonly pointcut: string | null; + readonly line: number; +} + +export interface SpringAopGraphMetadata { + readonly candidateFilePaths: ReadonlySet; + readonly aspectClassIds: ReadonlySet; + readonly singletonInstanceClassIds: ReadonlySet; + readonly aspects: readonly SpringAopAspectRecord[]; + readonly behaviors: readonly SpringAopBehaviorRecord[]; + readonly advices: readonly SpringAopAdviceRecord[]; +} + +interface MutableSpringAopGraphMetadata { + readonly candidateFilePaths: Set; + readonly aspectClassIds: Set; + readonly singletonInstanceClassIds: Set; + readonly aspects: SpringAopAspectRecord[]; + readonly behaviors: SpringAopBehaviorRecord[]; + readonly advices: SpringAopAdviceRecord[]; +} + +const metadataByGraph = new WeakMap(); + +function graphMetadata(graph: KnowledgeGraph): MutableSpringAopGraphMetadata { + let metadata = metadataByGraph.get(graph); + if (metadata === undefined) { + metadata = { + candidateFilePaths: new Set(), + aspectClassIds: new Set(), + singletonInstanceClassIds: new Set(), + aspects: [], + behaviors: [], + advices: [], + }; + metadataByGraph.set(graph, metadata); + } + return metadata; +} + +export function getSpringAopGraphMetadata(graph: KnowledgeGraph): SpringAopGraphMetadata { + return graphMetadata(graph); +} + +const ASPECT_ANNOTATION = 'org.aspectj.lang.annotation.Aspect'; + +const BEHAVIOR_ANNOTATIONS = new Map([ + ['org.springframework.transaction.annotation.Transactional', 'transactional'], + ['jakarta.transaction.Transactional', 'transactional'], + ['javax.transaction.Transactional', 'transactional'], + ['org.springframework.cache.annotation.Cacheable', 'cacheable'], + ['org.springframework.cache.annotation.CacheEvict', 'cache-evict'], + ['org.springframework.cache.annotation.CachePut', 'cache-put'], + ['org.springframework.cache.annotation.Caching', 'caching'], + ['org.springframework.security.access.prepost.PreAuthorize', 'authorization'], + ['org.springframework.security.access.prepost.PostAuthorize', 'authorization'], + ['org.springframework.security.access.prepost.PreFilter', 'authorization'], + ['org.springframework.security.access.prepost.PostFilter', 'authorization'], + ['org.springframework.security.access.annotation.Secured', 'authorization'], + ['jakarta.annotation.security.RolesAllowed', 'authorization'], + ['javax.annotation.security.RolesAllowed', 'authorization'], +]); + +const ADVICE_ANNOTATIONS = new Map([ + ['org.aspectj.lang.annotation.Around', 'around'], + ['org.aspectj.lang.annotation.Before', 'before'], + ['org.aspectj.lang.annotation.After', 'after'], + ['org.aspectj.lang.annotation.AfterReturning', 'after-returning'], + ['org.aspectj.lang.annotation.AfterThrowing', 'after-throwing'], + ['org.aspectj.lang.annotation.Pointcut', 'pointcut'], +]); + +const RECOGNIZED_AOP_ANNOTATIONS = new Set([ + ASPECT_ANNOTATION, + ...BEHAVIOR_ANNOTATIONS.keys(), + ...ADVICE_ANNOTATIONS.keys(), +]); + +const CAPTURE_RELEVANT_SIMPLE_NAMES = new Set( + [...RECOGNIZED_AOP_ANNOTATIONS].map((name) => simpleName(name)), +); + +function simpleName(name: string): string { + const separator = name.lastIndexOf('.'); + return separator === -1 ? name : name.slice(separator + 1); +} + +export function hasSpringAopRelevantAnnotation( + annotations: readonly Pick[], +): boolean { + return annotations.some((annotation) => + CAPTURE_RELEVANT_SIMPLE_NAMES.has(simpleName(annotation.name)), + ); +} + +function ownerGraphNode( + fact: SpringAopOwnerFact, + indexes: ScopeResolutionIndexes, + nodeLookup: GraphNodeLookup, + graph: KnowledgeGraph, + exactOwnerByRange: ReadonlyMap, +): GraphNode | undefined { + const ownerScope = indexes.scopeTree.getScope(fact.ownerScopeId); + + let ownerId: string | undefined; + if (fact.ownerKind === 'class' && ownerScope !== undefined) { + const classDef = ownerScope.ownedDefs.find( + (definition) => definition.type === 'Class' || definition.type === 'Interface', + ); + if (classDef !== undefined) + ownerId = resolveDefGraphId(classDef.filePath, classDef, nodeLookup); + } else if (ownerScope !== undefined) { + ownerId = resolveCallerGraphId(fact.ownerScopeId, indexes, nodeLookup); + } + + if (ownerId === undefined && fact.ownerFilePath !== undefined && fact.ownerRange !== undefined) { + const kind = fact.ownerKind === 'class' ? 'class' : 'callable'; + const fallback = exactOwnerByRange.get( + `${kind}\0${fact.ownerFilePath}\0${fact.ownerRange.startLine - 1}\0${fact.ownerRange.endLine - 1}`, + ); + if (fallback !== null && fallback !== undefined) ownerId = fallback.id; + } + if (ownerId === undefined) return undefined; + const owner = graph.getNode(ownerId); + return owner === undefined || owner.label === 'File' ? undefined : owner; +} + +function staticPointcutExpression(annotationText: string): string | null { + const args = parseSpringAnnotationArguments(annotationText); + if (args === null) return null; + const pointcutArguments = args.filter( + (argument) => + argument.name === undefined || argument.name === 'value' || argument.name === 'pointcut', + ); + if (pointcutArguments.length !== 1) return null; + const allowedCompanionArguments = new Set(['returning', 'throwing', 'argNames']); + if ( + args.some( + (argument) => + argument !== pointcutArguments[0] && + (argument.name === undefined || !allowedCompanionArguments.has(argument.name)), + ) + ) { + return null; + } + const argument = pointcutArguments[0]; + if (argument === undefined) return null; + const parsed = parseStaticStringLiteral(argument.value); + return parsed === null ? null : sanitizePointcut(parsed); +} + +function sanitizePointcut(value: string): string | null { + const normalized = stripBidiAndZeroWidth(value).replace(/\s+/g, ' ').trim(); + return normalized.length > 0 && normalized.length <= MAX_POINTCUT_LENGTH ? normalized : null; +} + +/** + * Resolve syntax facts after imports and package visibility are complete. + * Language adapters own AST shape only; this shared layer owns framework FQNs. + */ +export function createSpringAopMetadataAttacher( + adapter: SpringAopMetadataAdapter, +) { + return ( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + indexes: ScopeResolutionIndexes, + ): void => { + const resolveAnnotation = createSpringAnnotationNameResolver(indexes); + const metadata = graphMetadata(graph); + const exactOwnerByRange = new Map(); + for (const node of graph.iterNodes()) { + const kind = + node.label === 'Method' + ? 'callable' + : node.label === 'Class' || node.label === 'Interface' + ? 'class' + : undefined; + if (kind === undefined || typeof node.properties.filePath !== 'string') continue; + const key = `${kind}\0${node.properties.filePath}\0${node.properties.startLine}\0${node.properties.endLine}`; + exactOwnerByRange.set(key, exactOwnerByRange.has(key) ? null : node); + } + let classIdByMethod: ReadonlyMap | undefined; + + const singletonOwnerId = (owner: GraphNode): string | undefined => { + if (owner.label === 'Class' || owner.label === 'Interface') return owner.id; + if (owner.label !== 'Method') return undefined; + if (classIdByMethod === undefined) { + const owners = new Map(); + for (const relationship of graph.iterRelationshipsByType('HAS_METHOD')) { + owners.set(relationship.targetId, relationship.sourceId); + } + classIdByMethod = owners; + } + return classIdByMethod.get(owner.id); + }; + + for (const parsed of parsedFiles) { + // The set is populated only by registered language adapters. The shared + // matcher can therefore reject same-qualified-name symbols from other + // languages without naming Java/Kotlin in framework-generic code. + metadata.candidateFilePaths.add(parsed.filePath); + const incomplete = adapter.isPackageVisibilityIncomplete(parsed.filePath); + const resolvedAnnotations = new Map(); + for (const fact of adapter.getFacts(parsed.filePath)) { + const owner = ownerGraphNode(fact, indexes, nodeLookup, graph, exactOwnerByRange); + const ownerScope = indexes.scopeTree.getScope(fact.ownerScopeId); + if (owner === undefined) continue; + if (fact.singletonInstance === true) { + const singletonId = singletonOwnerId(owner); + if (singletonId !== undefined) metadata.singletonInstanceClassIds.add(singletonId); + } + + for (const annotation of fact.annotations) { + // `@get:`, `@field:`, etc. target generated/property elements rather + // than the callable represented by this fact. Guessing would overstate + // proxy behavior, so Kotlin use-site targets fail closed. + if (annotation.useSiteTarget !== undefined) continue; + const enclosingScope = ownerScope?.parent ?? null; + const cacheKey = `${enclosingScope ?? ''}\0${annotation.name}`; + let resolved = resolvedAnnotations.get(cacheKey); + if (!resolvedAnnotations.has(cacheKey)) { + resolved = resolveAnnotation( + annotation.name, + parsed, + enclosingScope, + RECOGNIZED_AOP_ANNOTATIONS, + incomplete, + ); + resolvedAnnotations.set(cacheKey, resolved); + } + if (resolved === undefined) continue; + + if (resolved === ASPECT_ANNOTATION && fact.ownerKind === 'class') { + metadata.aspectClassIds.add(owner.id); + metadata.aspects.push({ + ownerId: owner.id, + annotation: resolved, + line: annotation.line, + }); + continue; + } + + const behavior = BEHAVIOR_ANNOTATIONS.get(resolved); + if (behavior !== undefined) { + metadata.behaviors.push({ + ownerId: owner.id, + ownerKind: fact.ownerKind, + annotation: resolved, + behavior, + line: annotation.line, + }); + continue; + } + + const advice = ADVICE_ANNOTATIONS.get(resolved); + if (advice !== undefined && fact.ownerKind === 'callable' && owner.label === 'Method') { + metadata.advices.push({ + ownerId: owner.id, + annotation: resolved, + advice, + pointcut: staticPointcutExpression(annotation.text), + line: annotation.line, + }); + } + } + } + } + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isBoundedString(value: unknown, maxLength = MAX_POINTCUT_LENGTH): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= maxLength; +} + +const BEHAVIORS = new Set([ + 'transactional', + 'caching', + 'cacheable', + 'cache-evict', + 'cache-put', + 'authorization', +]); +const ADVICES = new Set>([ + 'around', + 'before', + 'after', + 'after-returning', + 'after-throwing', +]); + +export function encodeSpringAopReason(reason: SpringAopReason): string { + return `${SPRING_AOP_REASON_PREFIX}${JSON.stringify(reason)}`; +} + +/** Decode only the current, validated reason contract; malformed/foreign rows fail closed. */ +export function decodeSpringAopReason(value: unknown): SpringAopReason | undefined { + if (typeof value !== 'string' || !value.startsWith(SPRING_AOP_REASON_PREFIX)) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(value.slice(SPRING_AOP_REASON_PREFIX.length)); + } catch { + return undefined; + } + if (!isRecord(parsed) || !isBoundedString(parsed.annotation)) return undefined; + + if ( + parsed.kind === 'behavior' && + typeof parsed.behavior === 'string' && + BEHAVIORS.has(parsed.behavior as SpringAopBehavior) && + BEHAVIOR_ANNOTATIONS.get(parsed.annotation) === parsed.behavior && + (parsed.declaredOn === 'class' || parsed.declaredOn === 'method') && + parsed.activation === 'unknown' && + parsed.proxy === 'possible' + ) { + return parsed as unknown as SpringAopBehaviorReason; + } + if ( + parsed.kind === 'advice' && + typeof parsed.advice === 'string' && + ADVICES.has(parsed.advice as Exclude) && + ADVICE_ANNOTATIONS.get(parsed.annotation) === parsed.advice && + isBoundedString(parsed.pointcut) && + parsed.match === 'static' && + parsed.activation === 'unknown' && + parsed.proxy === 'possible' + ) { + return parsed as unknown as SpringAopAdviceReason; + } + if ( + parsed.kind === 'pointcut' && + ADVICE_ANNOTATIONS.has(parsed.annotation) && + (parsed.pointcut === null || isBoundedString(parsed.pointcut)) && + ((parsed.match === 'static' && + parsed.resolution === 'resolved' && + typeof parsed.pointcut === 'string') || + (parsed.match === 'unresolved' && parsed.resolution === 'unknown')) + ) { + return parsed as unknown as SpringAopPointcutReason; + } + if ( + parsed.kind === 'aspect' && + parsed.annotation === ASPECT_ANNOTATION && + parsed.activation === 'unknown' && + parsed.registration === 'unknown' + ) { + return parsed as unknown as SpringAopAspectReason; + } + return undefined; +} + +export function isSpringAopEvidenceNode(node: GraphNode): boolean { + return node.label === 'CodeElement' && node.id.startsWith(SPRING_AOP_EVIDENCE_ID_PREFIX); +} + +export interface SpringAopExecutionPointcut { + readonly kind: 'execution'; + readonly ownerPattern: string; + readonly methodPattern: string; + readonly visibility?: 'public'; + readonly parameterCount?: number; +} + +export interface SpringAopWithinPointcut { + readonly kind: 'within'; + readonly ownerPattern: string; +} + +export interface SpringAopAnnotationPointcut { + readonly kind: 'annotation'; + readonly annotation: string; +} + +export type SpringAopStaticPointcut = + | SpringAopExecutionPointcut + | SpringAopWithinPointcut + | SpringAopAnnotationPointcut; + +const TYPE_PATTERN = /^[A-Za-z_$*][A-Za-z0-9_$.*]*$/; +const METHOD_PATTERN = /^[A-Za-z_$*][A-Za-z0-9_$*]*$/; + +/** Parse the deliberately narrow, fully static pointcut subset supported in v1. */ +export function parseSpringAopPointcut(expression: string): SpringAopStaticPointcut | null { + const normalized = sanitizePointcut(expression); + if (normalized === null) return null; + const annotation = /^@annotation\s*\(\s*([A-Za-z_$][A-Za-z0-9_$.]*)\s*\)$/.exec(normalized); + if (annotation !== null) { + const annotationName = annotation[1]; + return annotationName !== undefined && BEHAVIOR_ANNOTATIONS.has(annotationName) + ? { kind: 'annotation', annotation: annotationName } + : null; + } + const within = /^within\s*\(\s*([^()]+?)\s*\)$/.exec(normalized); + if (within !== null) { + const ownerPattern = within[1]?.trim(); + return ownerPattern !== undefined && validTypePattern(ownerPattern) + ? { kind: 'within', ownerPattern } + : null; + } + + const execution = /^execution\s*\(\s*([^()]*)\(([^()]*)\)\s*\)$/.exec(normalized); + if (execution === null) return null; + const head = execution[1]?.trim(); + const parameters = execution[2]?.trim(); + if (head === undefined || parameters === undefined) return null; + const tokens = head.split(/\s+/); + let visibility: 'public' | undefined; + let returnPattern: string; + let qualifiedMethod: string; + if (tokens.length === 2) { + [returnPattern, qualifiedMethod] = tokens as [string, string]; + } else if (tokens.length === 3 && tokens[0] === 'public') { + const publicTokens = tokens as [string, string, string]; + visibility = 'public'; + returnPattern = publicTokens[1]; + qualifiedMethod = publicTokens[2]; + } else { + return null; + } + if (returnPattern !== '*') return null; + + const separator = qualifiedMethod.lastIndexOf('.'); + const ownerPattern = separator === -1 ? '*' : qualifiedMethod.slice(0, separator); + const methodPattern = separator === -1 ? qualifiedMethod : qualifiedMethod.slice(separator + 1); + if (!validTypePattern(ownerPattern) || !METHOD_PATTERN.test(methodPattern)) return null; + + let parameterCount: number | undefined; + if (parameters === '..') parameterCount = undefined; + else if (parameters === '') parameterCount = 0; + else { + const parameterPatterns = parameters.split(',').map((part) => part.trim()); + if (parameterPatterns.some((part) => part !== '*')) return null; + parameterCount = parameterPatterns.length; + } + + return { + kind: 'execution', + ownerPattern, + methodPattern, + ...(visibility === undefined ? {} : { visibility }), + ...(parameterCount === undefined ? {} : { parameterCount }), + }; +} + +function validTypePattern(pattern: string): boolean { + return ( + TYPE_PATTERN.test(pattern) && + // A simple exact type name needs the aspect's package/import scope to + // resolve correctly. That context is not retained in the v1 record, so + // fail closed instead of claiming a static match. Unqualified wildcard + // patterns are self-contained and match the declaring type's simple name. + (pattern.includes('.') || pattern.includes('*')) && + !pattern.includes('...') && + pattern.split('..').length <= 2 && + !pattern.endsWith('.') + ); +} + +function findLiteral( + value: string, + literal: string, + startIndex: number, + endExclusive: number, +): number { + const prefixLengths = new Array(literal.length).fill(0); + for (let index = 1, prefixLength = 0; index < literal.length; index += 1) { + while (prefixLength > 0 && literal[index] !== literal[prefixLength]) { + prefixLength = prefixLengths[prefixLength - 1] ?? 0; + } + if (literal[index] === literal[prefixLength]) prefixLength += 1; + prefixLengths[index] = prefixLength; + } + + for (let index = startIndex, prefixLength = 0; index < endExclusive; index += 1) { + while (prefixLength > 0 && value[index] !== literal[prefixLength]) { + prefixLength = prefixLengths[prefixLength - 1] ?? 0; + } + if (value[index] === literal[prefixLength]) prefixLength += 1; + if (prefixLength === literal.length) return index - literal.length + 1; + } + return -1; +} + +/** Match a single-segment glob in O(pattern + value) without regex backtracking. */ +function segmentGlobMatches(pattern: string, value: string): boolean { + if (!pattern.includes('*')) return pattern === value; + const literalChunks = pattern.split('*').filter((chunk) => chunk.length > 0); + if (literalChunks.length === 0) return true; + + let firstMiddleChunk = 0; + let lastMiddleChunkExclusive = literalChunks.length; + let cursor = 0; + let middleEndExclusive = value.length; + + if (!pattern.startsWith('*')) { + const prefix = literalChunks[0] ?? ''; + if (!value.startsWith(prefix)) return false; + cursor = prefix.length; + firstMiddleChunk = 1; + } + + if (!pattern.endsWith('*')) { + const suffix = literalChunks[literalChunks.length - 1] ?? ''; + const suffixStart = value.length - suffix.length; + if (suffixStart < cursor || !value.endsWith(suffix)) return false; + middleEndExclusive = suffixStart; + lastMiddleChunkExclusive -= 1; + } + + for (let index = firstMiddleChunk; index < lastMiddleChunkExclusive; index += 1) { + const chunk = literalChunks[index] ?? ''; + const matchIndex = findLiteral(value, chunk, cursor, middleEndExclusive); + if (matchIndex === -1) return false; + cursor = matchIndex + chunk.length; + } + return cursor <= middleEndExclusive; +} + +function patternSegmentsMatch( + patternSegments: readonly string[], + valueSegments: readonly string[], +) { + return ( + patternSegments.length === valueSegments.length && + patternSegments.every((segment, index) => + segmentGlobMatches(segment, valueSegments[index] ?? ''), + ) + ); +} + +/** Match Spring's narrow type-pattern subset without compiling repository input as regex. */ +function typePatternMatches(pattern: string, value: string): boolean { + if (!validTypePattern(pattern) || value.length === 0) return false; + if (pattern === '*') return true; + + const valueSegments = value.split('.'); + if (valueSegments.some((segment) => segment.length === 0)) return false; + if (!pattern.includes('.')) { + return segmentGlobMatches(pattern, valueSegments[valueSegments.length - 1] ?? ''); + } + const pieces = pattern.split('..'); + if (pieces.length === 1) return patternSegmentsMatch(pattern.split('.'), valueSegments); + + const leftSegments = (pieces[0] ?? '').split('.'); + const rightSegments = (pieces[1] ?? '').split('.'); + if (valueSegments.length < leftSegments.length + rightSegments.length) return false; + return ( + patternSegmentsMatch(leftSegments, valueSegments.slice(0, leftSegments.length)) && + patternSegmentsMatch(rightSegments, valueSegments.slice(-rightSegments.length)) + ); +} + +export function springAopPointcutMatches( + pointcut: SpringAopStaticPointcut, + owner: GraphNode, + method: GraphNode, + methodAnnotations: ReadonlySet = new Set(), +): boolean { + if (method.label !== 'Method') return false; + if (pointcut.kind === 'annotation') return methodAnnotations.has(pointcut.annotation); + const qualifiedName = owner.properties.qualifiedName; + if (typeof qualifiedName !== 'string') return false; + if (!typePatternMatches(pointcut.ownerPattern, qualifiedName)) return false; + if (pointcut.kind === 'within') return true; + if ( + pointcut.visibility === 'public' && + method.properties.visibility !== 'public' && + // Java and Kotlin interface methods are public when no visibility modifier + // is present. Their extractors retain that absence as `package`; explicit + // private members remain private and therefore fail this exception. + !(owner.label === 'Interface' && method.properties.visibility === 'package') + ) { + return false; + } + if ( + pointcut.parameterCount !== undefined && + method.properties.parameterCount !== pointcut.parameterCount + ) { + return false; + } + return segmentGlobMatches(pointcut.methodPattern, method.properties.name); +} diff --git a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts index 3fd9db774..7a7f82685 100644 --- a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts @@ -10,6 +10,7 @@ import { } from '../jvm/package-facts.js'; import { getJavaPackageFact, setJavaPackageFact } from './package-facts.js'; import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js'; +import type { JavaSpringAopFact } from './spring-aop.js'; import type { JavaSpringConditionalFact } from './spring-conditionals.js'; import type { JavaSpringDiClassFact } from './spring-di.js'; @@ -19,12 +20,14 @@ export interface JavaCaptureSideChannel { readonly kind: 'java'; readonly packageFact: JvmPackageFact; readonly classAnnotations: readonly JavaClassAnnotationFact[]; + readonly springAopFacts?: readonly JavaSpringAopFact[]; readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[]; readonly springConditionalFacts?: readonly JavaSpringConditionalFact[]; readonly springDiFacts?: readonly JavaSpringDiClassFact[]; } const classAnnotations = createClassAnnotationFactStore(); +const springAopFacts = new Map(); const springConfigConsumers = new Map(); const springConditionalFacts = new Map(); const springDiFacts = new Map(); @@ -32,11 +35,21 @@ const springDiFacts = new Map(); /** Clear facts retained by a prior workspace pass in a long-lived process. */ export function clearJavaClassAnnotationFacts(): void { classAnnotations.clear(); + springAopFacts.clear(); springConfigConsumers.clear(); springConditionalFacts.clear(); springDiFacts.clear(); } +export function setJavaSpringAopFacts(filePath: string, facts: readonly JavaSpringAopFact[]): void { + if (facts.length === 0) springAopFacts.delete(filePath); + else springAopFacts.set(filePath, facts); +} + +export function getJavaSpringAopFacts(filePath: string): readonly JavaSpringAopFact[] { + return springAopFacts.get(filePath) ?? []; +} + /** Store the annotation syntax collected by Java's existing scope-query traversal. */ export function setJavaClassAnnotationFacts( filePath: string, @@ -90,12 +103,14 @@ export function collectJavaCaptureSideChannel( filePath: string, ): JavaCaptureSideChannel | undefined { const facts = classAnnotations.get(filePath); + const aopFacts = springAopFacts.get(filePath) ?? []; const configConsumers = springConfigConsumers.get(filePath) ?? []; const conditionFacts = springConditionalFacts.get(filePath) ?? []; const diFacts = springDiFacts.get(filePath) ?? []; const packageFact = getJavaPackageFact(filePath); if ( facts.length === 0 && + aopFacts.length === 0 && configConsumers.length === 0 && conditionFacts.length === 0 && diFacts.length === 0 && @@ -107,6 +122,7 @@ export function collectJavaCaptureSideChannel( kind: 'java', packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT, classAnnotations: facts, + ...(aopFacts.length > 0 ? { springAopFacts: aopFacts } : {}), ...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}), ...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}), ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), @@ -128,6 +144,7 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { !Array.isArray(data.classAnnotations) ) { setJavaClassAnnotationFacts(parsed.filePath, []); + setJavaSpringAopFacts(parsed.filePath, []); setJavaSpringConfigConsumerFacts(parsed.filePath, []); setJavaSpringConditionalFacts(parsed.filePath, []); setJavaSpringDiFacts(parsed.filePath, []); @@ -135,6 +152,10 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { return; } setJavaClassAnnotationFacts(parsed.filePath, data.classAnnotations); + setJavaSpringAopFacts( + parsed.filePath, + Array.isArray(data.springAopFacts) ? data.springAopFacts : [], + ); setJavaSpringConfigConsumerFacts( parsed.filePath, Array.isArray(data.springConfigConsumers) ? data.springConfigConsumers : [], diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index dd463c83e..60d315df5 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -35,6 +35,7 @@ import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { setJavaClassAnnotationFacts, + setJavaSpringAopFacts, setJavaSpringConfigConsumerFacts, setJavaSpringConditionalFacts, setJavaSpringDiFacts, @@ -44,6 +45,7 @@ import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captur import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js'; import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spring-di.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; +import { captureJavaSpringAopFacts, type JavaSpringAopFact } from './spring-aop.js'; import { captureJavaSpringConditionalFacts, type JavaSpringConditionalFact, @@ -132,6 +134,8 @@ export function emitJavaScopeCaptures( const rawMatches = getJavaScopeQuery().matches(tree.rootNode); const out: CaptureMatch[] = []; const classAnnotations = new Map>(); + const springAopFacts: JavaSpringAopFact[] = []; + const springAopTypeNodeIds = new Set(); const springConditionalFacts: JavaSpringConditionalFact[] = []; const springDiFacts: JavaSpringDiClassFact[] = []; const springDiClassNodeIds = new Set(); @@ -154,6 +158,15 @@ export function emitJavaScopeCaptures( } if (Object.keys(grouped).length === 0) continue; + const springAopTypeNode = [ + nodeIfType(nodeMap['@scope.class'], 'class_declaration'), + nodeIfType(nodeMap['@scope.class'], 'interface_declaration'), + ].find((node): node is SyntaxNode => node !== null); + if (springAopTypeNode !== undefined && !springAopTypeNodeIds.has(springAopTypeNode.id)) { + springAopTypeNodeIds.add(springAopTypeNode.id); + springAopFacts.push(...captureJavaSpringAopFacts(springAopTypeNode, filePath)); + } + const springDiClassNode = nodeIfType(nodeMap['@scope.class'], 'class_declaration'); if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) { springDiClassNodeIds.add(springDiClassNode.id); @@ -375,6 +388,7 @@ export function emitJavaScopeCaptures( filePath, captureJavaSpringConfigConsumerFacts(tree.rootNode, filePath), ); + setJavaSpringAopFacts(filePath, springAopFacts); setJavaSpringConditionalFacts(filePath, springConditionalFacts); setJavaSpringDiFacts(filePath, springDiFacts); diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts index 3b27c5d41..86c94bccb 100644 --- a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts @@ -30,6 +30,7 @@ import { } from './index.js'; import { populateJavaPackageSiblings } from './package-siblings.js'; import { attachSpringBeanCandidateMetadata } from './spring-bean-metadata.js'; +import { attachJavaSpringAopMetadata } from './spring-aop.js'; import { attachJavaSpringConfigBindings } from './spring-config-bindings.js'; import { attachJavaSpringConditionalMetadata } from './spring-conditionals.js'; import { attachJavaSpringDiMetadata } from './spring-di.js'; @@ -88,6 +89,7 @@ const javaScopeResolver: ScopeResolver = { populateRangeBindings: populateJavaCrossFileReturnTypes, emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes, ctx) => { attachSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes); + attachJavaSpringAopMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx); diff --git a/gitnexus/src/core/ingestion/languages/java/spring-aop.ts b/gitnexus/src/core/ingestion/languages/java/spring-aop.ts new file mode 100644 index 000000000..724e30691 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/spring-aop.ts @@ -0,0 +1,61 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + createSpringAopMetadataAttacher, + hasSpringAopRelevantAnnotation, + type SpringAopOwnerFact, +} from '../../frameworks/spring/aop.js'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { getJavaSpringAopFacts } from './capture-side-channel.js'; +import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js'; +import { javaSpringAnnotationFacts, type JavaAnnotationSyntaxFact } from './spring-di.js'; + +export type JavaSpringAopAnnotationFact = JavaAnnotationSyntaxFact; + +export type JavaSpringAopFact = SpringAopOwnerFact; + +function scopeId(filePath: string, node: SyntaxNode, kind: 'Class' | 'Function') { + return makeScopeId({ + filePath, + range: nodeToCapture('@spring-aop.owner', node).range, + kind, + }); +} + +/** + * Capture Spring AOP syntax while Java's existing class traversal already has + * the AST node in hand. Import/FQN resolution and pointcut matching remain in + * the shared post-resolution layer. + */ +export function captureJavaSpringAopFacts( + classNode: SyntaxNode, + filePath: string, +): JavaSpringAopFact[] { + const facts: JavaSpringAopFact[] = []; + const classAnnotations = javaSpringAnnotationFacts(classNode); + if (hasSpringAopRelevantAnnotation(classAnnotations)) { + facts.push({ + ownerScopeId: scopeId(filePath, classNode, 'Class'), + ownerKind: 'class', + annotations: classAnnotations, + }); + } + + const body = classNode.childForFieldName('body'); + if (body === null) return facts; + for (const member of body.namedChildren) { + if (member.type !== 'method_declaration') continue; + const annotations = javaSpringAnnotationFacts(member); + if (!hasSpringAopRelevantAnnotation(annotations)) continue; + facts.push({ + ownerScopeId: scopeId(filePath, member, 'Function'), + ownerKind: 'callable', + annotations, + }); + } + return facts; +} + +export const attachJavaSpringAopMetadata = createSpringAopMetadataAttacher({ + getFacts: getJavaSpringAopFacts, + isPackageVisibilityIncomplete: isJavaPackageSiblingVisibilityIncomplete, +}); diff --git a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts index c1e8ce4f8..3c211bcc0 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts @@ -49,10 +49,12 @@ import { } from '../jvm/package-facts.js'; import { getCompanionScopesForFile, markCompanionScope } from './companion-scopes.js'; import { getKotlinPackageFact, setKotlinPackageFact } from './package-facts.js'; +import type { KotlinSpringAopFact } from './spring-aop.js'; import type { KotlinSpringConditionalFact } from './spring-conditionals.js'; import type { KotlinSpringDiClassFact } from './spring-di.js'; const classAnnotations = createClassAnnotationFactStore(); +const springAopFacts = new Map(); const springConditionalFacts = new Map(); const springDiFacts = new Map(); @@ -70,6 +72,8 @@ export interface KotlinCaptureSideChannel { readonly packageFact: JvmPackageFact; /** Class annotation syntax collected by the existing scope traversal. */ readonly classAnnotations: readonly ClassAnnotationFact[]; + /** Spring proxy/advice syntax captured per class or callable owner. */ + readonly springAopFacts?: readonly KotlinSpringAopFact[]; /** Profile, conditional, and auto-configuration syntax captured per owner. */ readonly springConditionalFacts?: readonly KotlinSpringConditionalFact[]; /** Constructor, property, and method injection syntax captured per class. */ @@ -78,10 +82,23 @@ export interface KotlinCaptureSideChannel { export function clearKotlinClassAnnotationFacts(): void { classAnnotations.clear(); + springAopFacts.clear(); springConditionalFacts.clear(); springDiFacts.clear(); } +export function setKotlinSpringAopFacts( + filePath: string, + facts: readonly KotlinSpringAopFact[], +): void { + if (facts.length === 0) springAopFacts.delete(filePath); + else springAopFacts.set(filePath, facts); +} + +export function getKotlinSpringAopFacts(filePath: string): readonly KotlinSpringAopFact[] { + return springAopFacts.get(filePath) ?? []; +} + export function setKotlinClassAnnotationFacts( filePath: string, facts: readonly ClassAnnotationFact[], @@ -129,12 +146,14 @@ export function collectKotlinCaptureSideChannel( ): KotlinCaptureSideChannel | undefined { const companionScopes = getCompanionScopesForFile(filePath); const annotationFacts = classAnnotations.get(filePath); + const aopFacts = springAopFacts.get(filePath) ?? []; const conditionFacts = springConditionalFacts.get(filePath) ?? []; const diFacts = springDiFacts.get(filePath) ?? []; const packageFact = getKotlinPackageFact(filePath); if ( companionScopes.length === 0 && annotationFacts.length === 0 && + aopFacts.length === 0 && conditionFacts.length === 0 && diFacts.length === 0 && packageFact === undefined @@ -146,6 +165,7 @@ export function collectKotlinCaptureSideChannel( companionScopes, packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT, classAnnotations: annotationFacts, + ...(aopFacts.length > 0 ? { springAopFacts: aopFacts } : {}), ...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}), ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), }; @@ -170,6 +190,7 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void { !Array.isArray(data.classAnnotations) ) { classAnnotations.set(parsed.filePath, []); + setKotlinSpringAopFacts(parsed.filePath, []); setKotlinSpringConditionalFacts(parsed.filePath, []); setKotlinSpringDiFacts(parsed.filePath, []); setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); @@ -179,6 +200,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void { markCompanionScope(parsed.filePath, scopeId); } classAnnotations.set(parsed.filePath, data.classAnnotations); + setKotlinSpringAopFacts( + parsed.filePath, + Array.isArray(data.springAopFacts) ? data.springAopFacts : [], + ); setKotlinSpringConditionalFacts( parsed.filePath, Array.isArray(data.springConditionalFacts) ? data.springConditionalFacts : [], diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index 00868c174..4e063c3cf 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -20,6 +20,7 @@ import { getKotlinParser, getKotlinScopeQuery } from './query.js'; import { markCompanionScope } from './companion-scopes.js'; import { setKotlinClassAnnotationFacts, + setKotlinSpringAopFacts, setKotlinSpringConditionalFacts, setKotlinSpringDiFacts, } from './capture-side-channel.js'; @@ -27,6 +28,7 @@ import { captureKotlinPackageFact } from './package-facts.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './spring-di.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; +import { captureKotlinSpringAopFacts, type KotlinSpringAopFact } from './spring-aop.js'; import { captureKotlinSpringConditionalFacts, type KotlinSpringConditionalFact, @@ -93,6 +95,8 @@ export function emitKotlinScopeCaptures( const out: CaptureMatch[] = []; const classAnnotations = new Map>(); + const springAopFacts: KotlinSpringAopFact[] = []; + const springAopTypeNodeIds = new Set(); const springConditionalFacts: KotlinSpringConditionalFact[] = []; const springDiFacts: KotlinSpringDiClassFact[] = []; const springDiClassNodeIds = new Set(); @@ -119,6 +123,18 @@ export function emitKotlinScopeCaptures( } if (Object.keys(grouped).length === 0) continue; + // tree-sitter-kotlin represents both classes and interfaces with + // `class_declaration`; `object_declaration` is the separate object form. + const springAopTypeNode = [ + nodeIfType(groupedNodes['@scope.class'], 'class_declaration'), + nodeIfType(groupedNodes['@scope.class'], 'object_declaration'), + nodeIfType(groupedNodes['@scope.class'], 'companion_object'), + ].find((node): node is SyntaxNode => node !== null); + if (springAopTypeNode !== undefined && !springAopTypeNodeIds.has(springAopTypeNode.id)) { + springAopTypeNodeIds.add(springAopTypeNode.id); + springAopFacts.push(...captureKotlinSpringAopFacts(springAopTypeNode, filePath)); + } + const springDiClassNode = nodeIfType(groupedNodes['@scope.class'], 'class_declaration'); if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) { springDiClassNodeIds.add(springDiClassNode.id); @@ -323,6 +339,7 @@ export function emitKotlinScopeCaptures( } setKotlinClassAnnotationFacts(filePath, materializeClassAnnotationFacts(classAnnotations)); + setKotlinSpringAopFacts(filePath, springAopFacts); setKotlinSpringConditionalFacts(filePath, springConditionalFacts); setKotlinSpringDiFacts(filePath, springDiFacts); out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS)); diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index bad2b85d0..f2a7c9d21 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -21,6 +21,7 @@ import { import { isKotlinStaticOnly } from './owners.js'; import { populateKotlinPackageSiblings } from './package-siblings.js'; import { attachKotlinSpringBeanCandidateMetadata } from './spring-bean-metadata.js'; +import { attachKotlinSpringAopMetadata } from './spring-aop.js'; import { clearKotlinPackageFacts } from './package-facts.js'; import { attachKotlinSpringDiMetadata } from './spring-di.js'; import { attachKotlinSpringConditionalMetadata } from './spring-conditionals.js'; @@ -127,6 +128,7 @@ export const kotlinScopeResolver: ScopeResolver = { populateNamespaceSiblings: populateKotlinPackageSiblings, emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes) => { attachKotlinSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes); + attachKotlinSpringAopMetadata(graph, parsedFiles, nodeLookup, indexes); attachKotlinSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes); attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes); }, diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-aop.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-aop.ts new file mode 100644 index 000000000..797651619 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-aop.ts @@ -0,0 +1,77 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + createSpringAopMetadataAttacher, + type SpringAopOwnerFact, +} from '../../frameworks/spring/aop.js'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { getKotlinSpringAopFacts } from './capture-side-channel.js'; +import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js'; +import { kotlinSpringAnnotationFacts, type KotlinAnnotationSyntaxFact } from './spring-di.js'; + +export type KotlinSpringAopAnnotationFact = KotlinAnnotationSyntaxFact; + +export type KotlinSpringAopFact = SpringAopOwnerFact; + +function scopeId(filePath: string, node: SyntaxNode, kind: 'Class' | 'Function') { + return makeScopeId({ + filePath, + range: nodeToCapture('@spring-aop.owner', node).range, + kind, + }); +} + +function ownerRange(node: SyntaxNode) { + return nodeToCapture('@spring-aop.owner', node).range; +} + +/** + * Capture Spring AOP syntax from the class node already surfaced by Kotlin's + * scope query. The shared layer resolves annotations and rejects non-default + * use-site targets after imports and package visibility have finalized. + */ +export function captureKotlinSpringAopFacts( + classNode: SyntaxNode, + filePath: string, +): KotlinSpringAopFact[] { + const facts: KotlinSpringAopFact[] = []; + const classAnnotations = kotlinSpringAnnotationFacts(classNode); + const singletonInstance = + classNode.type === 'object_declaration' || classNode.type === 'companion_object'; + // Kotlin import aliases can give a Spring annotation any local simple name. + // Capture annotated owners conservatively, then let the post-import shared + // resolver keep only recognized Spring AOP annotations. Objects also retain + // an empty owner fact so the shared phase can distinguish their singleton + // instance members from true static methods without naming Kotlin. + if (classAnnotations.length > 0 || singletonInstance) { + facts.push({ + ownerScopeId: scopeId(filePath, classNode, 'Class'), + ownerKind: 'class', + ownerFilePath: filePath, + ownerRange: ownerRange(classNode), + ...(singletonInstance ? { singletonInstance: true } : {}), + annotations: classAnnotations, + }); + } + + const body = classNode.namedChildren.find((child) => child.type === 'class_body'); + if (body === undefined) return facts; + for (const member of body.namedChildren) { + if (member.type !== 'function_declaration') continue; + const annotations = kotlinSpringAnnotationFacts(member); + if (annotations.length === 0) continue; + facts.push({ + ownerScopeId: scopeId(filePath, member, 'Function'), + ownerKind: 'callable', + ownerFilePath: filePath, + ownerRange: ownerRange(member), + ...(singletonInstance ? { singletonInstance: true } : {}), + annotations, + }); + } + return facts; +} + +export const attachKotlinSpringAopMetadata = createSpringAopMetadataAttacher({ + getFacts: getKotlinSpringAopFacts, + isPackageVisibilityIncomplete: isKotlinPackageSiblingVisibilityIncomplete, +}); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/index.ts b/gitnexus/src/core/ingestion/pipeline-phases/index.ts index 0095c6bef..359d80a65 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/index.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/index.ts @@ -25,6 +25,12 @@ export { springAutoConfigurationPhase, type SpringAutoConfigurationOutput, } from './spring-auto-configuration.js'; +export { + springAopPhase, + springAopInheritancePhase, + type SpringAopOutput, + type SpringAopInheritanceOutput, +} from './spring-aop.js'; export { pruneLocalSymbolsPhase, type PruneLocalSymbolsOutput } from './prune-local-symbols.js'; export { taintSummariesPhase, type TaintSummariesOutput } from './taint-summaries.js'; export { callSummariesPhase, type CallSummariesOutput } from './call-summaries.js'; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/spring-aop.ts b/gitnexus/src/core/ingestion/pipeline-phases/spring-aop.ts new file mode 100644 index 000000000..8e4ac3cc5 --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/spring-aop.ts @@ -0,0 +1,618 @@ +/** + * Phase: springAop + * + * Materializes statically visible Spring proxy/advice behavior after every + * language resolver has attached normalized metadata to the shared graph. + * The post-MRO export in this file propagates declarative behavior through + * METHOD_OVERRIDES/METHOD_IMPLEMENTS without changing @annotation semantics. + * + * @deps scopeResolution + * @reads Class/Method nodes, HAS_METHOD edges, shared Spring AOP metadata + * @writes synthetic CodeElement nodes, DEFINES/DECLARES/ADVISED_BY edges + */ + +import type { GraphNode } from 'gitnexus-shared'; +import { generateId } from '../../../lib/utils.js'; +import { + decodeSpringAopReason, + encodeSpringAopReason, + getSpringAopGraphMetadata, + parseSpringAopPointcut, + SPRING_AOP_EVIDENCE_DESCRIPTION_PREFIX, + springAopPointcutMatches, + type SpringAopAdviceRecord, + type SpringAopAspectRecord, + type SpringAopBehaviorRecord, + type SpringAopPointcutReason, +} from '../frameworks/spring/aop.js'; +import { + createSpringAopCandidateIndex, + type SpringAopCandidateIndex, + type SpringAopOwnedMethod, +} from '../frameworks/spring/aop-candidates.js'; +import { toZeroBasedLine } from '../utils/line-base.js'; +import type { PipelineContext, PipelinePhase } from './types.js'; +import { logger } from '../../logger.js'; + +export const DEFAULT_SPRING_AOP_MAX_CANDIDATE_INSPECTIONS_PER_ADVICE = 100_000; +export const DEFAULT_SPRING_AOP_MAX_CANDIDATE_INSPECTIONS = 2_000_000; +export const DEFAULT_SPRING_AOP_MAX_ADVISED_EDGES_PER_ADVICE = 25_000; +export const DEFAULT_SPRING_AOP_MAX_ADVISED_EDGES = 100_000; + +export interface SpringAopOutput { + readonly advisedByEdges: number; + readonly evidenceNodes: number; + readonly unresolvedPointcuts: number; + readonly candidateInspections: number; + readonly truncatedAdvices: number; +} + +export interface SpringAopInheritanceOutput { + readonly inheritedBehaviorEdges: number; +} + +function simpleName(name: string): string { + const separator = name.lastIndexOf('.'); + return separator === -1 ? name : name.slice(separator + 1); +} + +function eligibleMethod( + node: GraphNode, + owner: GraphNode | undefined, + singletonInstanceClassIds: ReadonlySet, +): boolean { + return ( + node.label === 'Method' && + (node.properties.isStatic !== true || + (owner !== undefined && singletonInstanceClassIds.has(owner.id))) && + node.properties.visibility !== 'private' + ); +} + +function eligibleOwner(node: GraphNode): boolean { + return node.label === 'Class' || node.label === 'Interface'; +} + +function addEvidenceNode( + ctx: PipelineContext, + owner: GraphNode, + annotation: string, + line: number, + discriminator: string, + description: string, +): GraphNode { + const evidenceId = generateId( + 'CodeElement', + `spring-aop:${owner.id}:${line}:${annotation}:${discriminator}`, + ); + const evidence: GraphNode = { + id: evidenceId, + label: 'CodeElement', + properties: { + name: `@${simpleName(annotation)}`, + filePath: owner.properties.filePath, + startLine: toZeroBasedLine(line), + endLine: toZeroBasedLine(line), + isExported: false, + description: `${SPRING_AOP_EVIDENCE_DESCRIPTION_PREFIX}${description}`, + }, + }; + ctx.graph.addNode(evidence); + + const fileId = generateId('File', owner.properties.filePath); + if (ctx.graph.getNode(fileId) !== undefined) { + ctx.graph.addRelationship({ + id: generateId('DEFINES', `${fileId}->${evidenceId}`), + sourceId: fileId, + targetId: evidenceId, + type: 'DEFINES', + confidence: 1, + reason: 'spring-aop:evidence', + }); + } + return evidence; +} + +function emitBehavior( + ctx: PipelineContext, + record: SpringAopBehaviorRecord, + classMethods: ReadonlyMap, + ownerByMethod: ReadonlyMap, + singletonInstanceClassIds: ReadonlySet, +): { edges: number; evidence: number } { + const owner = ctx.graph.getNode(record.ownerId); + if (owner === undefined || (!eligibleOwner(owner) && owner.label !== 'Method')) { + return { edges: 0, evidence: 0 }; + } + if ( + owner.label === 'Method' && + !eligibleMethod(owner, ownerByMethod.get(owner.id), singletonInstanceClassIds) + ) { + return { edges: 0, evidence: 0 }; + } + const evidence = addEvidenceNode( + ctx, + owner, + record.annotation, + record.line, + `behavior:${record.behavior}`, + `${record.behavior} interceptor; activation unknown; proxy possible`, + ); + const reason = encodeSpringAopReason({ + kind: 'behavior', + annotation: record.annotation, + behavior: record.behavior, + declaredOn: record.ownerKind === 'class' ? 'class' : 'method', + activation: 'unknown', + proxy: 'possible', + }); + + const sources = + record.ownerKind === 'class' ? [owner, ...(classMethods.get(owner.id) ?? [])] : [owner]; + let edges = 0; + for (const source of sources) { + if ( + !eligibleOwner(source) && + !eligibleMethod(source, ownerByMethod.get(source.id), singletonInstanceClassIds) + ) { + continue; + } + ctx.graph.addRelationship({ + id: generateId( + 'ADVISED_BY', + `${source.id}->${evidence.id}:${record.line}:${record.behavior}`, + ), + sourceId: source.id, + targetId: evidence.id, + type: 'ADVISED_BY', + confidence: 1, + reason, + }); + edges++; + } + return { edges, evidence: 1 }; +} + +function emitAspect(ctx: PipelineContext, record: SpringAopAspectRecord): number { + const owner = ctx.graph.getNode(record.ownerId); + if (owner === undefined || !eligibleOwner(owner)) return 0; + const evidence = addEvidenceNode( + ctx, + owner, + record.annotation, + record.line, + 'aspect', + 'AspectJ aspect marker; registration unknown; activation unknown', + ); + ctx.graph.addRelationship({ + id: generateId('DECLARES', `${owner.id}->${evidence.id}`), + sourceId: owner.id, + targetId: evidence.id, + type: 'DECLARES', + confidence: 1, + reason: encodeSpringAopReason({ + kind: 'aspect', + annotation: record.annotation, + activation: 'unknown', + registration: 'unknown', + }), + }); + return 1; +} + +function pointcutReason(record: SpringAopAdviceRecord, resolved: boolean): SpringAopPointcutReason { + return { + kind: 'pointcut', + annotation: record.annotation, + pointcut: record.pointcut, + match: resolved ? 'static' : 'unresolved', + resolution: resolved ? 'resolved' : 'unknown', + }; +} + +interface SpringAopAdviceBudget { + readonly maxInspectionsPerAdvice: number; + readonly maxInspections: number; + readonly maxEdgesPerAdvice: number; + readonly maxEdges: number; + inspections: number; + edges: number; +} + +function resolveBudget(value: number | undefined, fallback: number): number { + return Number.isSafeInteger(value) && value !== undefined && value >= 0 ? value : fallback; +} + +function budgetReached(limit: number, used: number): boolean { + return limit !== 0 && used >= limit; +} + +function emitAdvice( + ctx: PipelineContext, + record: SpringAopAdviceRecord, + aspectClassIds: ReadonlySet, + ownerByMethod: ReadonlyMap, + candidateIndex: SpringAopCandidateIndex, + methodAnnotations: ReadonlyMap>, + budget: SpringAopAdviceBudget, +): { + edges: number; + evidence: number; + unresolved: number; + inspections: number; + truncated: boolean; +} { + const adviceNode = ctx.graph.getNode(record.ownerId); + if (adviceNode === undefined || adviceNode.label !== 'Method') { + return { edges: 0, evidence: 0, unresolved: 0, inspections: 0, truncated: false }; + } + const staticPointcut = record.pointcut; + const parsed = staticPointcut === null ? null : parseSpringAopPointcut(staticPointcut); + const isAdviceMethod = record.advice !== 'pointcut'; + const adviceOwner = ownerByMethod.get(adviceNode.id); + const activeAspect = adviceOwner !== undefined && aspectClassIds.has(adviceOwner.id); + const resolved = parsed !== null && (!isAdviceMethod || activeAspect); + const evidence = addEvidenceNode( + ctx, + adviceNode, + record.annotation, + record.line, + `pointcut:${record.advice}:${record.pointcut ?? ''}`, + `${record.advice} pointcut ${record.pointcut ?? ''}; resolution ${ + resolved ? 'resolved' : 'unknown' + }`, + ); + ctx.graph.addRelationship({ + id: generateId('DECLARES', `${adviceNode.id}->${evidence.id}`), + sourceId: adviceNode.id, + targetId: evidence.id, + type: 'DECLARES', + confidence: 1, + reason: encodeSpringAopReason(pointcutReason(record, resolved)), + }); + + if (!isAdviceMethod || !resolved || parsed === null || staticPointcut === null) { + return { + edges: 0, + evidence: 1, + unresolved: resolved ? 0 : 1, + inspections: 0, + truncated: false, + }; + } + + let edges = 0; + let inspections = 0; + let truncated = false; + for (const candidate of candidateIndex.candidatesFor(parsed)) { + if ( + budgetReached(budget.maxInspectionsPerAdvice, inspections) || + budgetReached(budget.maxInspections, budget.inspections) + ) { + truncated = true; + break; + } + inspections += 1; + budget.inspections += 1; + if (candidate.method.id === adviceNode.id) continue; + if (aspectClassIds.has(candidate.owner.id)) continue; + if ( + !springAopPointcutMatches( + parsed, + candidate.owner, + candidate.method, + methodAnnotations.get(candidate.method.id), + ) + ) { + continue; + } + if ( + budgetReached(budget.maxEdgesPerAdvice, edges) || + budgetReached(budget.maxEdges, budget.edges) + ) { + truncated = true; + break; + } + ctx.graph.addRelationship({ + id: generateId( + 'ADVISED_BY', + `${candidate.method.id}->${adviceNode.id}:${record.line}:${record.advice}`, + ), + sourceId: candidate.method.id, + targetId: adviceNode.id, + type: 'ADVISED_BY', + confidence: 0.95, + reason: encodeSpringAopReason({ + kind: 'advice', + annotation: record.annotation, + advice: record.advice, + pointcut: staticPointcut, + match: 'static', + activation: 'unknown', + proxy: 'possible', + }), + }); + edges++; + budget.edges += 1; + } + return { edges, evidence: 1, unresolved: 0, inspections, truncated }; +} + +export const springAopPhase: PipelinePhase = { + name: 'springAop', + deps: ['scopeResolution'], + + async execute(ctx: PipelineContext): Promise { + const metadata = getSpringAopGraphMetadata(ctx.graph); + if ( + metadata.aspects.length === 0 && + metadata.behaviors.length === 0 && + metadata.advices.length === 0 + ) { + return { + advisedByEdges: 0, + evidenceNodes: 0, + unresolvedPointcuts: 0, + candidateInspections: 0, + truncatedAdvices: 0, + }; + } + + ctx.onProgress({ + phase: 'enriching', + percent: 97, + message: 'Resolving Spring proxy and advice edges...', + stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: ctx.graph.nodeCount }, + }); + + const classMethods = new Map(); + const ownerByMethod = new Map(); + const methodAnnotations = new Map>(); + const candidates: SpringAopOwnedMethod[] = []; + for (const relationship of ctx.graph.iterRelationshipsByType('HAS_METHOD')) { + const owner = ctx.graph.getNode(relationship.sourceId); + const method = ctx.graph.getNode(relationship.targetId); + if (owner === undefined || !eligibleOwner(owner) || method?.label !== 'Method') continue; + const ownerFilePath = owner.properties.filePath; + if (typeof ownerFilePath !== 'string' || !metadata.candidateFilePaths.has(ownerFilePath)) { + continue; + } + ownerByMethod.set(method.id, owner); + if (!eligibleMethod(method, owner, metadata.singletonInstanceClassIds)) continue; + const methods = classMethods.get(owner.id) ?? []; + methods.push(method); + classMethods.set(owner.id, methods); + candidates.push({ method, owner }); + } + + let advisedByEdges = 0; + let evidenceNodes = 0; + let unresolvedPointcuts = 0; + let candidateInspections = 0; + let truncatedAdvices = 0; + const budget: SpringAopAdviceBudget = { + maxInspectionsPerAdvice: resolveBudget( + ctx.options?.springAopMaxCandidateInspectionsPerAdvice, + DEFAULT_SPRING_AOP_MAX_CANDIDATE_INSPECTIONS_PER_ADVICE, + ), + maxInspections: resolveBudget( + ctx.options?.springAopMaxCandidateInspections, + DEFAULT_SPRING_AOP_MAX_CANDIDATE_INSPECTIONS, + ), + maxEdgesPerAdvice: resolveBudget( + ctx.options?.springAopMaxAdvisedEdgesPerAdvice, + DEFAULT_SPRING_AOP_MAX_ADVISED_EDGES_PER_ADVICE, + ), + maxEdges: resolveBudget( + ctx.options?.springAopMaxAdvisedEdges, + DEFAULT_SPRING_AOP_MAX_ADVISED_EDGES, + ), + inspections: 0, + edges: 0, + }; + for (const aspect of metadata.aspects) evidenceNodes += emitAspect(ctx, aspect); + for (const behavior of metadata.behaviors) { + // `@annotation` matches annotations declared directly on the method. + // Class-level behavior is fanned out by emitBehavior, but must not be + // copied here (that would model @within/@target semantics instead). + if (behavior.ownerKind === 'callable') { + const annotations = methodAnnotations.get(behavior.ownerId) ?? new Set(); + annotations.add(behavior.annotation); + methodAnnotations.set(behavior.ownerId, annotations); + } + const emitted = emitBehavior( + ctx, + behavior, + classMethods, + ownerByMethod, + metadata.singletonInstanceClassIds, + ); + advisedByEdges += emitted.edges; + evidenceNodes += emitted.evidence; + } + const candidateIndex = createSpringAopCandidateIndex(candidates, methodAnnotations); + for (const advice of metadata.advices) { + const emitted = emitAdvice( + ctx, + advice, + metadata.aspectClassIds, + ownerByMethod, + candidateIndex, + methodAnnotations, + budget, + ); + advisedByEdges += emitted.edges; + evidenceNodes += emitted.evidence; + unresolvedPointcuts += emitted.unresolved; + candidateInspections += emitted.inspections; + if (emitted.truncated) { + truncatedAdvices += 1; + logger.warn( + `[spring-aop] truncated advice ${advice.ownerId}: ` + + `${emitted.inspections} candidate inspections, ${emitted.edges} edges emitted; ` + + `run totals ${budget.inspections} inspections/${budget.edges} edges`, + ); + } + } + + if (truncatedAdvices > 0) { + ctx.onProgress({ + phase: 'enriching', + percent: 97, + message: 'Spring AOP advice resolution truncated by configured budgets', + detail: `${truncatedAdvices} advice method(s); ${candidateInspections} inspections; ${budget.edges} advice edges`, + stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: ctx.graph.nodeCount }, + }); + } + + return { + advisedByEdges, + evidenceNodes, + unresolvedPointcuts, + candidateInspections, + truncatedAdvices, + }; + }, +}; + +interface InheritedBehaviorWorkItem { + readonly sourceId: string; + readonly evidenceId: string; + readonly reason: string; +} + +function sameMethodSignature(left: GraphNode, right: GraphNode): boolean { + if (left.properties.name !== right.properties.name) return false; + const leftCount = left.properties.parameterCount; + const rightCount = right.properties.parameterCount; + if (typeof leftCount === 'number' && typeof rightCount === 'number' && leftCount !== rightCount) { + return false; + } + const leftTypes = left.properties.parameterTypes; + const rightTypes = right.properties.parameterTypes; + return !( + Array.isArray(leftTypes) && + Array.isArray(rightTypes) && + leftTypes.length > 0 && + rightTypes.length > 0 && + (leftTypes.length !== rightTypes.length || + leftTypes.some((type, index) => type !== rightTypes[index])) + ); +} + +/** + * Propagate behavior evidence across the inheritance decisions materialized by + * MRO. This is deliberately separate from pointcut matching: `@annotation` + * continues to mean an annotation declared directly on the callable. + */ +export const springAopInheritancePhase: PipelinePhase = { + name: 'springAopInheritance', + deps: ['springAop', 'mro'], + + async execute(ctx: PipelineContext): Promise { + const metadata = getSpringAopGraphMetadata(ctx.graph); + const ownerByMethod = new Map(); + const classMethods = new Map(); + for (const relationship of ctx.graph.iterRelationshipsByType('HAS_METHOD')) { + const owner = ctx.graph.getNode(relationship.sourceId); + const method = ctx.graph.getNode(relationship.targetId); + if (owner === undefined || !eligibleOwner(owner) || method?.label !== 'Method') continue; + ownerByMethod.set(method.id, owner); + const methods = classMethods.get(owner.id) ?? []; + methods.push(method); + classMethods.set(owner.id, methods); + } + + const childrenByParent = new Map>(); + for (const type of ['EXTENDS', 'IMPLEMENTS'] as const) { + for (const relationship of ctx.graph.iterRelationshipsByType(type)) { + const children = childrenByParent.get(relationship.targetId) ?? new Set(); + children.add(relationship.sourceId); + childrenByParent.set(relationship.targetId, children); + } + } + + const implementingMethods = new Map>(); + for (const relationship of ctx.graph.iterRelationshipsByType('METHOD_IMPLEMENTS')) { + const methods = implementingMethods.get(relationship.targetId) ?? new Set(); + methods.add(relationship.sourceId); + implementingMethods.set(relationship.targetId, methods); + } + + const overridingClasses = new Map>(); + for (const relationship of ctx.graph.iterRelationshipsByType('METHOD_OVERRIDES')) { + const classes = overridingClasses.get(relationship.targetId) ?? new Set(); + classes.add(relationship.sourceId); + overridingClasses.set(relationship.targetId, classes); + } + + const queue: InheritedBehaviorWorkItem[] = []; + const emittedKeys = new Set(); + for (const relationship of ctx.graph.iterRelationshipsByType('ADVISED_BY')) { + if (decodeSpringAopReason(relationship.reason)?.kind !== 'behavior') continue; + const key = `${relationship.sourceId}\0${relationship.targetId}\0${relationship.reason}`; + emittedKeys.add(key); + queue.push({ + sourceId: relationship.sourceId, + evidenceId: relationship.targetId, + reason: relationship.reason, + }); + } + + let inheritedBehaviorEdges = 0; + const enqueue = (source: GraphNode, item: InheritedBehaviorWorkItem): void => { + const owner = ownerByMethod.get(source.id); + if ( + !eligibleOwner(source) && + !eligibleMethod(source, owner, metadata.singletonInstanceClassIds) + ) { + return; + } + const key = `${source.id}\0${item.evidenceId}\0${item.reason}`; + if (emittedKeys.has(key)) return; + emittedKeys.add(key); + ctx.graph.addRelationship({ + id: generateId('ADVISED_BY', `${source.id}->${item.evidenceId}:inherited:${item.reason}`), + sourceId: source.id, + targetId: item.evidenceId, + type: 'ADVISED_BY', + confidence: 1, + reason: item.reason, + }); + inheritedBehaviorEdges += 1; + queue.push({ sourceId: source.id, evidenceId: item.evidenceId, reason: item.reason }); + }; + + for (let cursor = 0; cursor < queue.length; cursor += 1) { + const item = queue[cursor]; + if (item === undefined) continue; + const source = ctx.graph.getNode(item.sourceId); + if (source === undefined) continue; + + if (eligibleOwner(source)) { + for (const childId of childrenByParent.get(source.id) ?? []) { + const child = ctx.graph.getNode(childId); + if (child === undefined || !eligibleOwner(child)) continue; + enqueue(child, item); + for (const method of classMethods.get(child.id) ?? []) enqueue(method, item); + } + continue; + } + if (source.label !== 'Method') continue; + + for (const methodId of implementingMethods.get(source.id) ?? []) { + const method = ctx.graph.getNode(methodId); + if (method !== undefined) enqueue(method, item); + } + for (const classId of overridingClasses.get(source.id) ?? []) { + const matches = (classMethods.get(classId) ?? []).filter((method) => + sameMethodSignature(method, source), + ); + const [match] = matches; + if (matches.length === 1 && match !== undefined) enqueue(match, item); + } + } + + return { inheritedBehaviorEdges }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index e99515468..4e858bd3f 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -34,6 +34,8 @@ import { scopeResolutionPhase, springConfigPhase, springAutoConfigurationPhase, + springAopPhase, + springAopInheritancePhase, pruneLocalSymbolsPhase, taintSummariesPhase, callSummariesPhase, @@ -56,6 +58,14 @@ export interface PipelineOptions { * to retain those nodes under `skipGraphPhases`. */ skipGraphPhases?: boolean; + /** Per-advice Spring AOP candidate inspection cap. `0` disables this cap. */ + springAopMaxCandidateInspectionsPerAdvice?: number; + /** Aggregate Spring AOP candidate inspection cap for one analysis. `0` disables this cap. */ + springAopMaxCandidateInspections?: number; + /** Per-advice Spring AOP `ADVISED_BY` edge cap. `0` disables this cap. */ + springAopMaxAdvisedEdgesPerAdvice?: number; + /** Aggregate Spring AOP advice-edge cap for one analysis. `0` disables this cap. */ + springAopMaxAdvisedEdges?: number; /** * Build the control-flow-graph / PDG substrate (#2081 M1, opt-in via `--pdg`). * Off by default: workers skip all CFG work and emit no `cfgSideChannel`, and @@ -262,8 +272,8 @@ export interface PipelineOptions { * Phase dependency graph: * * scan → structure → [springConfig, markdown, cobol] → parse → [routes, tools, orm] - * → crossFile → scopeResolution → springAutoConfiguration → pruneLocalSymbols - * → mro → di → communities → processes + * → crossFile → scopeResolution → [springAutoConfiguration, springAop] → pruneLocalSymbols + * → mro → springAopInheritance → di → communities → processes * * To add a new phase: create a file in pipeline-phases/, export the phase * object, and `.register()` it at the appropriate position below. Opt-in @@ -290,6 +300,7 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] { .register(crossFilePhase) .register(scopeResolutionPhase) .register(springAutoConfigurationPhase) + .register(springAopPhase) .register(pruneLocalSymbolsPhase) // M4 (#2084): interprocedural taint fixpoint — the first real opt-in // pdg-gated phase. Off ⇒ absent ⇒ byte-identical graph. No always-on @@ -297,6 +308,7 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] { .register(taintSummariesPhase, { enabledWhen: (o) => o.pdg === true }) .register(callSummariesPhase, { enabledWhen: (o) => o.pdg === true }) .register(mroPhase, { enabledWhen: (o) => !o.skipGraphPhases }) + .register(springAopInheritancePhase, { enabledWhen: (o) => !o.skipGraphPhases }) .register(diPhase, { enabledWhen: (o) => !o.skipGraphPhases }) .register(communitiesPhase, { enabledWhen: (o) => !o.skipGraphPhases }) .register(processesPhase, { enabledWhen: (o) => !o.skipGraphPhases }) diff --git a/gitnexus/src/core/lbug/graph-emit-sink.ts b/gitnexus/src/core/lbug/graph-emit-sink.ts index c03bacf01..a0491ab14 100644 --- a/gitnexus/src/core/lbug/graph-emit-sink.ts +++ b/gitnexus/src/core/lbug/graph-emit-sink.ts @@ -127,8 +127,8 @@ import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js'; * `routes`/`tools`, never read back mid-pipeline). * * Adding a relationship type that a phase reads back WITHOUT adding it here is - * a silent-wrong-graph bug, not a crash — and NOTHING automated catches it. - * The differential round-trip test cannot: `addRelationship` partitions edges + * a silent-wrong-graph bug, not a crash. The differential round-trip test cannot: + * `addRelationship` partitions edges * between the graph and the CSVs, and the union of a partition is invariant * under where the partition line falls, so that test stays green no matter how * this set is drawn. Only the read-site audit protects this invariant; re-run it @@ -144,6 +144,8 @@ export const RETAINED_REL_TYPES: ReadonlySet = new Set => deleteAllRelationshipsOfType('INJECTS', 'di', 'duplicate INJECTS edges'); +/** + * Drop every Spring AOP `ADVISED_BY` relationship before incremental + * writeback. Pointcut/annotation resolution is whole-program: adding a type in + * a third file can shadow a wildcard annotation import or change a wildcard + * execution match between two otherwise unchanged endpoint files. + */ +export const deleteAllAdvisedBy = async (): Promise<{ edgesDeleted: number }> => + deleteAllRelationshipsOfType('ADVISED_BY', 'spring-aop', 'duplicate ADVISED_BY edges'); + +/** Drop all synthetic Spring AOP evidence nodes before incremental writeback. */ +export const deleteSpringAopEvidenceNodes = async (): Promise<{ nodesDeleted: number }> => { + const c = conn; + if (!c) { + throw new Error('LadybugDB not initialized. Call initLbug first.'); + } + return withConnLock(async () => { + let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined; + const idPrefix = escapeCypherString(SPRING_AOP_EVIDENCE_ID_PREFIX); + const predicate = `n.id STARTS WITH '${idPrefix}'`; + try { + countResult = await c.query( + `MATCH (n:CodeElement) WHERE ${predicate} RETURN count(n) AS cnt`, + ); + const result = Array.isArray(countResult) ? countResult[0] : countResult; + const rows = await result.getAll(); + const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0); + if (count > 0) { + await closeQueryResults( + await c.query(`MATCH (n:CodeElement) WHERE ${predicate} DETACH DELETE n`), + ); + } + if (countResult) await closeQueryResults(countResult); + return { nodesDeleted: count }; + } catch (err) { + if (countResult) await closeQueryResults(countResult); + if (classifyDeleteAllError(err) === 'benign-missing-table') { + return { nodesDeleted: 0 }; + } + const message = err instanceof Error ? err.message : String(err); + throw new Error( + '[spring-aop] failed to clear synthetic evidence before incremental re-write ' + + `(${message}) — aborting to avoid stale advice metadata; the next run will full-rebuild`, + ); + } + }); +}; + /** * Drop Spring-owned auto-configuration `DECLARES` relationships before * incremental writeback. `DECLARES` is generic, so exact reason filtering is diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 92aceae1f..7af15c962 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -360,6 +360,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM Interface TO Method, FROM Interface TO Class, FROM Interface TO Interface, + FROM Interface TO CodeElement, FROM Interface TO \`TypeAlias\`, FROM Interface TO \`Struct\`, FROM Interface TO \`Constructor\`, diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 754c1e6b7..ef57b4977 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -33,6 +33,8 @@ import { deleteAllInterprocTaintPaths, deleteAllCallSummaries, deleteAllInjects, + deleteAllAdvisedBy, + deleteSpringAopEvidenceNodes, deleteSpringAutoConfigurationDeclarations, deleteSpringAutoConfigurationSyntheticClasses, queryImportersBatch, @@ -141,6 +143,7 @@ import { STALE_HASH_SENTINEL } from './lbug/schema.js'; import { isSpringBeanCandidateSourceFile } from './ingestion/frameworks/spring/bean-catalog.js'; import { isSpringBeanFactoryDeclaration } from './ingestion/frameworks/spring/bean-factories.js'; import { + SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, } from './ingestion/frameworks/spring/analysis-features.js'; @@ -158,6 +161,7 @@ import { const ANALYSIS_FEATURES = [ CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, + SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, @@ -2121,16 +2125,21 @@ async function runFullAnalysisInner( // deleting on every non-pdg incremental run (N runs = N copies of // every INJECTS row; CodeRelation has no PK and no read-side dedup). await deleteAllInjects(); - // 2b. Drop Spring-owned DECLARES edges (#2415). The + // 2b. Spring AOP pointcuts are matched against the full resolved graph; + // a third-file change can invalidate an edge between unchanged files. + // Rebuild the complete ADVISED_BY set on every incremental writeback. + await deleteAllAdvisedBy(); + await deleteSpringAopEvidenceNodes(); + // 2c. Drop Spring-owned DECLARES edges (#2415). The // auto-configuration phase scans every metadata file and recomputes // the full set each run; exact reason filtering leaves declarations // owned by other metadata systems untouched. await deleteSpringAutoConfigurationDeclarations(); - // 2c. Drop source-unavailable auto-configuration placeholders. Fresh + // 2d. Drop source-unavailable auto-configuration placeholders. Fresh // synthetic nodes are graph-wide in extractChangedSubgraph, so this // also removes an orphan when a newly-added real class takes over. await deleteSpringAutoConfigurationSyntheticClasses(); - // 2d. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on + // 2e. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on // — their validity is a whole-program property (an A→C flow can be // invalidated by a change to an intermediate function on a third // file), so endpoint-writability extraction can't refresh them. @@ -2138,7 +2147,7 @@ async function runFullAnalysisInner( // graph (isGraphWideRelType), mirroring Community/Process. if (options.pdg === true) { await deleteAllInterprocTaintPaths(); - // 2e. Drop CALL_SUMMARY edges (PDG FU-C) on an incremental `--pdg` + // 2f. Drop CALL_SUMMARY edges (PDG FU-C) on an incremental `--pdg` // writeback. They are re-included from the FULL fresh graph // (isGraphWideRelType) and the callSummaries phase recomputes every // summary each run, so delete-all-then-rebuild keeps an unchanged diff --git a/gitnexus/src/mcp/local/aop-metadata.ts b/gitnexus/src/mcp/local/aop-metadata.ts new file mode 100644 index 000000000..a3506d9c5 --- /dev/null +++ b/gitnexus/src/mcp/local/aop-metadata.ts @@ -0,0 +1,352 @@ +import { executeParameterized } from '../../core/lbug/pool-adapter.js'; +import { + decodeSpringAopReason, + type SpringAopReason, +} from '../../core/ingestion/frameworks/spring/aop.js'; + +type SpringAopBehaviorReason = Extract; +type SpringAopAdviceReason = Extract; +type SpringAopAspectReason = Extract; +type SpringAopPointcutReason = Extract; + +export interface SpringAopBehaviorMetadata { + readonly annotation: string; + readonly behavior: SpringAopBehaviorReason['behavior']; + readonly declaredOn: SpringAopBehaviorReason['declaredOn']; + readonly activation: SpringAopBehaviorReason['activation']; + readonly evidenceId: string; +} + +export interface SpringAopAdviceMetadata { + readonly annotation: string; + readonly advice: SpringAopAdviceReason['advice']; + readonly pointcut: string; + readonly match: SpringAopAdviceReason['match']; + readonly activation: SpringAopAdviceReason['activation']; + readonly adviceId: string; + readonly adviceName?: string; + readonly adviceFilePath?: string; + readonly advisedId: string; + readonly advisedName?: string; + readonly advisedFilePath?: string; +} + +export interface SpringAopUnresolvedPointcutMetadata { + readonly annotation: string; + readonly pointcut: string | null; + readonly adviceId: string; + readonly adviceName?: string; + readonly adviceFilePath?: string; + readonly evidenceId: string; +} + +export interface SpringAopResolvedPointcutMetadata { + readonly annotation: string; + readonly pointcut: string; + readonly match: Extract; + readonly resolution: Extract; + readonly adviceId: string; + readonly adviceName?: string; + readonly adviceFilePath?: string; + readonly evidenceId: string; +} + +export interface SpringAopAspectMetadata { + readonly annotation: string; + readonly activation: SpringAopAspectReason['activation']; + readonly registration: SpringAopAspectReason['registration']; + readonly evidenceId: string; +} + +export interface SpringAopMetadata { + readonly framework: 'spring'; + readonly proxied?: 'possible'; + readonly truncated?: true; + readonly aspect?: SpringAopAspectMetadata; + readonly behaviors: readonly SpringAopBehaviorMetadata[]; + readonly advices: readonly SpringAopAdviceMetadata[]; + readonly resolvedPointcuts: readonly SpringAopResolvedPointcutMetadata[]; + readonly unresolvedPointcuts: readonly SpringAopUnresolvedPointcutMetadata[]; +} + +interface RelationshipRow { + readonly sourceId: string; + readonly sourceName?: string; + readonly sourceFilePath?: string; + readonly targetId: string; + readonly targetName?: string; + readonly targetFilePath?: string; + readonly reason: unknown; +} + +const SUPPORTED_SYMBOL_TYPES = new Set(['Class', 'Interface', 'Method', 'CodeElement']); +const QUERY_RESULT_LIMIT = 1_000; +const QUERY_FETCH_LIMIT = QUERY_RESULT_LIMIT + 1; + +function readRowValue(row: unknown, name: string, index: number): unknown { + if (typeof row === 'object' && row !== null && name in row) { + return (row as Record)[name]; + } + return Array.isArray(row) ? row[index] : undefined; +} + +function readRequiredString(row: unknown, name: string, index: number): string | undefined { + const value = readRowValue(row, name, index); + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function readOptionalString(row: unknown, name: string, index: number): string | undefined { + const value = readRowValue(row, name, index); + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function normalizeRelationshipRow(row: unknown): RelationshipRow | undefined { + const sourceId = readRequiredString(row, 'sourceId', 0); + const targetId = readRequiredString(row, 'targetId', 3); + if (sourceId === undefined || targetId === undefined) return undefined; + + const sourceName = readOptionalString(row, 'sourceName', 1); + const sourceFilePath = readOptionalString(row, 'sourceFilePath', 2); + const targetName = readOptionalString(row, 'targetName', 4); + const targetFilePath = readOptionalString(row, 'targetFilePath', 5); + + return { + sourceId, + ...optionalField('sourceName', sourceName), + ...optionalField('sourceFilePath', sourceFilePath), + targetId, + ...optionalField('targetName', targetName), + ...optionalField('targetFilePath', targetFilePath), + reason: readRowValue(row, 'reason', 6), + }; +} + +function optionalField( + key: Key, + value: string | undefined, +): { readonly [K in Key]?: string } { + return value === undefined ? {} : ({ [key]: value } as { readonly [K in Key]?: string }); +} + +function stableDedupe(values: readonly T[], keyOf: (value: T) => string): T[] { + const unique = new Map(); + for (const value of values) unique.set(keyOf(value), value); + return [...unique.entries()] + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([, value]) => value); +} + +const RELATIONSHIP_PROJECTION = ` + RETURN source.id AS sourceId, source.name AS sourceName, source.filePath AS sourceFilePath, + target.id AS targetId, target.name AS targetName, target.filePath AS targetFilePath, + r.reason AS reason, r.step AS step +`; + +const DETERMINISTIC_RELATIONSHIP_ORDER = 'ORDER BY sourceId, targetId, reason, step'; + +/** + * Read additive Spring proxy/advice metadata for context and impact results. + * + * The helper intentionally trusts only versioned reasons accepted by the + * shared decoder. Other DECLARES edges (for example Spring Bean factories) + * and malformed/forward-version evidence are ignored. Query failures are + * fail-soft because older or partially upgraded indexes must remain readable. + */ +export async function querySpringAopMetadata( + lbugPath: string, + symbolId: string, + symbolType: string, +): Promise { + if (!SUPPORTED_SYMBOL_TYPES.has(symbolType)) return undefined; + + try { + const [outgoingAdviceRows, incomingAdviceRows, outgoingPointcutRows, incomingPointcutRows] = + await Promise.all([ + executeParameterized( + lbugPath, + `MATCH (source {id: $symbolId})-[r:CodeRelation]->(target) + WHERE r.type = 'ADVISED_BY' + AND r.reason STARTS WITH 'spring-aop:v1:' + ${RELATIONSHIP_PROJECTION} + ${DETERMINISTIC_RELATIONSHIP_ORDER} + LIMIT 1001`, + { symbolId }, + ), + executeParameterized( + lbugPath, + `MATCH (source)-[r:CodeRelation]->(target {id: $symbolId}) + WHERE r.type = 'ADVISED_BY' + AND r.reason STARTS WITH 'spring-aop:v1:' + ${RELATIONSHIP_PROJECTION} + ${DETERMINISTIC_RELATIONSHIP_ORDER} + LIMIT 1001`, + { symbolId }, + ), + executeParameterized( + lbugPath, + `MATCH (source {id: $symbolId})-[r:CodeRelation]->(target:CodeElement) + WHERE r.type = 'DECLARES' + AND r.reason STARTS WITH 'spring-aop:v1:' + ${RELATIONSHIP_PROJECTION} + ${DETERMINISTIC_RELATIONSHIP_ORDER} + LIMIT 1001`, + { symbolId }, + ), + executeParameterized( + lbugPath, + `MATCH (source)-[r:CodeRelation]->(target:CodeElement {id: $symbolId}) + WHERE r.type = 'DECLARES' + AND r.reason STARTS WITH 'spring-aop:v1:' + ${RELATIONSHIP_PROJECTION} + ${DETERMINISTIC_RELATIONSHIP_ORDER} + LIMIT 1001`, + { symbolId }, + ), + ]); + + const behaviors: SpringAopBehaviorMetadata[] = []; + const advices: SpringAopAdviceMetadata[] = []; + const resolvedPointcuts: SpringAopResolvedPointcutMetadata[] = []; + const unresolvedPointcuts: SpringAopUnresolvedPointcutMetadata[] = []; + const aspects: SpringAopAspectMetadata[] = []; + const truncated = [ + outgoingAdviceRows, + incomingAdviceRows, + outgoingPointcutRows, + incomingPointcutRows, + ].some((rows) => rows.length >= QUERY_FETCH_LIMIT); + let queriedSymbolIsAdvisedSource = false; + + for (const [rows, isOutgoing] of [ + [outgoingAdviceRows, true], + [incomingAdviceRows, false], + ] as const) { + for (const rawRow of rows.slice(0, QUERY_RESULT_LIMIT)) { + const row = normalizeRelationshipRow(rawRow); + if (row === undefined) continue; + const reason = decodeSpringAopReason(row.reason); + if (reason?.kind === 'behavior') { + if (isOutgoing) queriedSymbolIsAdvisedSource = true; + behaviors.push({ + annotation: reason.annotation, + behavior: reason.behavior, + declaredOn: reason.declaredOn, + activation: reason.activation, + evidenceId: row.targetId, + }); + } else if (reason?.kind === 'advice') { + if (isOutgoing) queriedSymbolIsAdvisedSource = true; + advices.push({ + annotation: reason.annotation, + advice: reason.advice, + pointcut: reason.pointcut, + match: reason.match, + activation: reason.activation, + adviceId: row.targetId, + ...optionalField('adviceName', row.targetName), + ...optionalField('adviceFilePath', row.targetFilePath), + advisedId: row.sourceId, + ...optionalField('advisedName', row.sourceName), + ...optionalField('advisedFilePath', row.sourceFilePath), + }); + } + } + } + + for (const rows of [outgoingPointcutRows, incomingPointcutRows]) { + for (const rawRow of rows.slice(0, QUERY_RESULT_LIMIT)) { + const row = normalizeRelationshipRow(rawRow); + if (row === undefined) continue; + const reason = decodeSpringAopReason(row.reason); + if (reason?.kind === 'aspect') { + aspects.push({ + annotation: reason.annotation, + activation: reason.activation, + registration: reason.registration, + evidenceId: row.targetId, + }); + } else if ( + reason?.kind === 'pointcut' && + reason.match === 'static' && + reason.resolution === 'resolved' && + typeof reason.pointcut === 'string' + ) { + resolvedPointcuts.push({ + annotation: reason.annotation, + pointcut: reason.pointcut, + match: reason.match, + resolution: reason.resolution, + adviceId: row.sourceId, + ...optionalField('adviceName', row.sourceName), + ...optionalField('adviceFilePath', row.sourceFilePath), + evidenceId: row.targetId, + }); + } else if (reason?.kind === 'pointcut' && reason.match === 'unresolved') { + unresolvedPointcuts.push({ + annotation: reason.annotation, + pointcut: reason.pointcut, + adviceId: row.sourceId, + ...optionalField('adviceName', row.sourceName), + ...optionalField('adviceFilePath', row.sourceFilePath), + evidenceId: row.targetId, + }); + } + } + } + + const dedupedAspects = stableDedupe(aspects, (aspect) => + JSON.stringify([aspect.annotation, aspect.evidenceId]), + ); + + const dedupedBehaviors = stableDedupe(behaviors, (behavior) => + JSON.stringify([ + behavior.behavior, + behavior.annotation, + behavior.declaredOn, + behavior.evidenceId, + ]), + ); + const dedupedAdvices = stableDedupe(advices, (advice) => + JSON.stringify([advice.advisedId, advice.adviceId, advice.advice, advice.pointcut]), + ); + const dedupedPointcuts = stableDedupe(unresolvedPointcuts, (pointcut) => + JSON.stringify([ + pointcut.adviceId, + pointcut.evidenceId, + pointcut.annotation, + pointcut.pointcut, + ]), + ); + const dedupedResolvedPointcuts = stableDedupe(resolvedPointcuts, (pointcut) => + JSON.stringify([ + pointcut.adviceId, + pointcut.evidenceId, + pointcut.annotation, + pointcut.pointcut, + ]), + ); + + if ( + dedupedAspects.length === 0 && + dedupedBehaviors.length === 0 && + dedupedAdvices.length === 0 && + dedupedResolvedPointcuts.length === 0 && + dedupedPointcuts.length === 0 + ) { + return undefined; + } + + return { + framework: 'spring', + ...(queriedSymbolIsAdvisedSource ? { proxied: 'possible' as const } : {}), + ...(truncated ? { truncated: true as const } : {}), + ...(dedupedAspects[0] === undefined ? {} : { aspect: dedupedAspects[0] }), + behaviors: dedupedBehaviors, + advices: dedupedAdvices, + resolvedPointcuts: dedupedResolvedPointcuts, + unresolvedPointcuts: dedupedPointcuts, + }; + } catch { + return undefined; + } +} diff --git a/gitnexus/src/mcp/local/bean-metadata.ts b/gitnexus/src/mcp/local/bean-metadata.ts index cd08ad327..c99a24e20 100644 --- a/gitnexus/src/mcp/local/bean-metadata.ts +++ b/gitnexus/src/mcp/local/bean-metadata.ts @@ -27,6 +27,7 @@ export async function queryClassBeanMetadata( lbugPath, `${pattern} WHERE r.type = 'DECLARES' + AND r.reason STARTS WITH 'spring-bean-factory:' RETURN r.reason AS reason LIMIT 1`, { symbolId }, diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index c4e9d12a9..690c48c8f 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -19,6 +19,7 @@ import { dbIdentityChanged, } from '../../core/lbug/pool-adapter.js'; import { queryClassBeanMetadata } from './bean-metadata.js'; +import { querySpringAopMetadata } from './aop-metadata.js'; import { isValidQueryParams } from '../../core/lbug/query-params.js'; import { toDisplayLine } from './line-display.js'; import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/lbug-config.js'; @@ -310,6 +311,10 @@ export const VALID_RELATION_TYPES = new Set([ // surface. 'CONDITIONAL_ON', 'DECLARES', + // Spring proxy/advice evidence (#2416). Opt-in for traversal so existing + // impact defaults do not silently widen; target enrichment still surfaces + // advised/proxied state on ordinary impact calls. + 'ADVISED_BY', ]); /** @@ -3365,16 +3370,31 @@ export class LocalBackend { const symId = sym.id; // Categorized incoming refs - const incomingRows = await executeParameterized( - repo.lbugPath, - ` + const [incomingRows, incomingAdvisedRows] = await Promise.all([ + executeParameterized( + repo.lbugPath, + ` MATCH (caller)-[r:CodeRelation]->(n {id: $symId}) WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES'] RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind LIMIT 30 `, - { symId }, - ); + { symId }, + ), + // Keep high-fan-in advice edges out of the legacy 30-row context window. + // A broad pointcut can advise hundreds of methods; sharing that LIMIT + // would make CALLS/HAS_METHOD/etc. disappear nondeterministically. + executeParameterized( + repo.lbugPath, + ` + MATCH (caller)-[r:CodeRelation {type: 'ADVISED_BY'}]->(n {id: $symId}) + RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind + LIMIT 30 + `, + { symId }, + ), + ]); + incomingRows.push(...incomingAdvisedRows); let typedPropertyRows: any[] = []; // Fix #480: Class/Interface nodes have no direct CALLS/IMPORTS edges — @@ -3493,16 +3513,28 @@ export class LocalBackend { } // Categorized outgoing refs - const outgoingRows = await executeParameterized( - repo.lbugPath, - ` + const [outgoingRows, outgoingAdvisedRows] = await Promise.all([ + executeParameterized( + repo.lbugPath, + ` MATCH (n {id: $symId})-[r:CodeRelation]->(target) WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES'] RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind LIMIT 30 `, - { symId }, - ); + { symId }, + ), + executeParameterized( + repo.lbugPath, + ` + MATCH (n {id: $symId})-[r:CodeRelation {type: 'ADVISED_BY'}]->(target) + RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind + LIMIT 30 + `, + { symId }, + ), + ]); + outgoingRows.push(...outgoingAdvisedRows); // Process participation let processRows: any[] = []; @@ -3564,6 +3596,7 @@ export class LocalBackend { (sym.name || sym[1]) as string, ); const beanMetadataPromise = queryClassBeanMetadata(repo.lbugPath, symId, epistemicSymType); + const aopMetadataPromise = querySpringAopMetadata(repo.lbugPath, symId, epistemicSymType); let methodMetadata: Record | undefined; if (isMethodLike) { @@ -3602,7 +3635,11 @@ export class LocalBackend { // dynamic dispatch are not reflected in `incoming`, so the view is a lower // bound. Additive; never suppresses a field. Resolved from the probe started // above (concurrent with methodMetadata). - const [epistemic, beanMetadata] = await Promise.all([epistemicPromise, beanMetadataPromise]); + const [epistemic, beanMetadata, aopMetadata] = await Promise.all([ + epistemicPromise, + beanMetadataPromise, + aopMetadataPromise, + ]); return { status: 'found', @@ -3616,6 +3653,7 @@ export class LocalBackend { ...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}), ...(methodMetadata ? { methodMetadata } : {}), ...(beanMetadata ? { bean: beanMetadata } : {}), + ...(aopMetadata ? { aop: aopMetadata } : {}), }, ...epistemic, incoming: categorize(incomingRows), @@ -5906,7 +5944,10 @@ export class LocalBackend { opts.skipEpistemic || summaryOnly ? Promise.resolve(undefined) : queryClassBeanMetadata(repo.lbugPath, symId, symType); - + const aopMetadataPromise = + opts.skipEpistemic || summaryOnly + ? Promise.resolve(undefined) + : querySpringAopMetadata(repo.lbugPath, symId, symType); const impacted: any[] = []; const visited = new Set([symId]); const pdgBridgeEvidenceById = new Map(); @@ -6388,7 +6429,11 @@ export class LocalBackend { // #1858 — await the epistemic boundary probe kicked off alongside the BFS // above. Additive: leaves impactedCount and every existing field untouched. - const [epistemic, beanMetadata] = await Promise.all([epistemicPromise, beanMetadataPromise]); + const [epistemic, beanMetadata, aopMetadata] = await Promise.all([ + epistemicPromise, + beanMetadataPromise, + aopMetadataPromise, + ]); const base = { target: { @@ -6397,6 +6442,7 @@ export class LocalBackend { type: symType, filePath: sym.filePath || sym[2], ...(beanMetadata ? { bean: beanMetadata } : {}), + ...(aopMetadata ? { aop: aopMetadata } : {}), }, direction, impactedCount: impacted.length, diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 98687a7d8..690464cbf 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -534,7 +534,7 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep type: 'array', items: { type: 'string' }, description: - 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES (default: usage-based, ACCESSES excluded by default). DI edges (consumer Class or factory Method → provider Class or declaration CodeElement) require explicitly including INJECTS.', + 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES (default: usage-based, ACCESSES excluded by default). DI edges require INJECTS; Spring proxy/advice edges require ADVISED_BY.', }, includeTests: { type: 'boolean', description: 'Include test files (default: false)' }, minConfidence: { diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 40a89f0f7..571ce7fa1 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -146,7 +146,10 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // v36: bound-callable graph `startLine` follows the initializer so multi-line // closure bindings join the scope channel (#2735). Warm cache would otherwise // keep serving wrapper-line startLines and drop the CALLS edge. -const SCHEMA_BUMP = 36; +// v37: Java/Kotlin capture side-channels include Spring AOP owner/advice facts +// (#2416). Warm cache entries at v36 do not carry those facts and would silently +// omit ADVISED_BY evidence. +const SCHEMA_BUMP = 37; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index afe51a1fb..896fe863f 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -668,8 +668,13 @@ export interface RepoMeta { * reuse gate is exact equality, so an index already stamped 32 would satisfy * it against a differently-shaped reverted DB. Start the next allocation at * 33 instead.) + * + * v33: Spring AOP evidence adds the Interface→CodeElement relation pair + * (#2416). LadybugDB fixes allowed endpoint pairs when the relation table is + * created, so an older index cannot persist these edges through incremental + * writeback. Force a full re-analyze. */ -export const INCREMENTAL_SCHEMA_VERSION = 32; +export const INCREMENTAL_SCHEMA_VERSION = 33; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/integration/lbug-core-adapter.test.ts b/gitnexus/test/integration/lbug-core-adapter.test.ts index 2de9538d5..7af37a108 100644 --- a/gitnexus/test/integration/lbug-core-adapter.test.ts +++ b/gitnexus/test/integration/lbug-core-adapter.test.ts @@ -174,6 +174,70 @@ withTestLbugDB( expect(Number((queriesLeft[0] as { cnt: number }).cnt)).toBe(1); }); + it('deleteAllAdvisedBy: removes only ADVISED_BY edges and is benign when none exist (#2416)', async () => { + const { executeQuery: coreExecuteQuery, deleteAllAdvisedBy } = + await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(deleteAllAdvisedBy()).resolves.toEqual({ edgesDeleted: 0 }); + const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as Array<{ + id: string; + }>; + expect(fns.length).toBe(2); + await coreExecuteQuery( + `MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` + + `CREATE (a)-[:CodeRelation {type: 'ADVISED_BY', confidence: 0.95, reason: 'spring-aop:v1:{}', step: 0}]->(b)`, + ); + + await expect(deleteAllAdvisedBy()).resolves.toEqual({ edgesDeleted: 1 }); + const advisedLeft = await coreExecuteQuery( + `MATCH ()-[r:CodeRelation]->() WHERE r.type = 'ADVISED_BY' RETURN count(r) AS cnt`, + ); + expect(Number((advisedLeft[0] as { cnt: number }).cnt)).toBe(0); + }); + + it('deleteSpringAopEvidenceNodes: keys deletion to the owned ID namespace (#2416)', async () => { + const { executeQuery: coreExecuteQuery, deleteSpringAopEvidenceNodes } = + await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(deleteSpringAopEvidenceNodes()).resolves.toEqual({ nodesDeleted: 0 }); + await coreExecuteQuery( + `CREATE (:CodeElement {id: 'CodeElement:spring-aop:test-evidence', name: 'Aop', filePath: 'A.java', startLine: 1, endLine: 1, isExported: false, content: '', description: 'ordinary text'})`, + ); + await coreExecuteQuery( + `CREATE (:CodeElement {id: 'CodeElement:ordinary-lookalike', name: 'Other', filePath: 'B.java', startLine: 1, endLine: 1, isExported: false, content: '', description: 'Spring AOP: lookalike'})`, + ); + + await expect(deleteSpringAopEvidenceNodes()).resolves.toEqual({ nodesDeleted: 1 }); + const rows = await coreExecuteQuery( + `MATCH (n:CodeElement) WHERE n.id IN ['CodeElement:spring-aop:test-evidence', 'CodeElement:ordinary-lookalike'] RETURN n.id AS id`, + ); + expect(rows).toEqual([{ id: 'CodeElement:ordinary-lookalike' }]); + await coreExecuteQuery( + `MATCH (n:CodeElement {id: 'CodeElement:ordinary-lookalike'}) DETACH DELETE n`, + ); + }); + + it('persists the Interface Spring AOP evidence relation pair (#2416)', async () => { + const { executeQuery: coreExecuteQuery } = + await import('../../src/core/lbug/lbug-adapter.js'); + await coreExecuteQuery( + `CREATE (:Interface {id: 'Interface:aop-test', name: 'AdvisedInterface', filePath: 'I.java', startLine: 1, endLine: 2, isExported: true, content: '', description: ''})`, + ); + await coreExecuteQuery( + `CREATE (:CodeElement {id: 'CodeElement:aop-interface-evidence', name: 'Transactional', filePath: 'I.java', startLine: 1, endLine: 1, isExported: false, content: '', description: 'Spring AOP evidence'})`, + ); + await coreExecuteQuery( + `MATCH (source:Interface {id: 'Interface:aop-test'}), (target:CodeElement {id: 'CodeElement:aop-interface-evidence'}) CREATE (source)-[:CodeRelation {type: 'ADVISED_BY', confidence: 1.0, reason: 'spring-aop:v1:{}', step: 0}]->(target)`, + ); + const rows = await coreExecuteQuery( + `MATCH (source)-[r:CodeRelation]->() WHERE source.id = 'Interface:aop-test' AND r.type = 'ADVISED_BY' RETURN source.id AS id`, + ); + expect(rows).toEqual([{ id: 'Interface:aop-test' }]); + await coreExecuteQuery( + `MATCH (n) WHERE n.id IN ['Interface:aop-test', 'CodeElement:aop-interface-evidence'] DETACH DELETE n`, + ); + }); + it('deleteSpringAutoConfigurationDeclarations: removes only Spring DECLARES edges (#2415)', async () => { const { executeQuery: coreExecuteQuery, deleteSpringAutoConfigurationDeclarations } = await import('../../src/core/lbug/lbug-adapter.js'); diff --git a/gitnexus/test/integration/local-backend.test.ts b/gitnexus/test/integration/local-backend.test.ts index 318490d82..ff39b3cb9 100644 --- a/gitnexus/test/integration/local-backend.test.ts +++ b/gitnexus/test/integration/local-backend.test.ts @@ -71,6 +71,7 @@ withTestLbugDB( 'HAS_METHOD', 'METHOD_OVERRIDES', 'ACCESSES', + 'ADVISED_BY', ]; const invalidTypes = ['CONTAINS', 'STEP_IN_PROCESS', 'MEMBER_OF', 'DROP_TABLE']; diff --git a/gitnexus/test/integration/spring-aop-benchmark.test.ts b/gitnexus/test/integration/spring-aop-benchmark.test.ts new file mode 100644 index 000000000..bb88d5780 --- /dev/null +++ b/gitnexus/test/integration/spring-aop-benchmark.test.ts @@ -0,0 +1,507 @@ +/** + * Spring AOP candidate-selection and Kotlin capture benchmarks (#2416). + * + * Normal CI runs deterministic work-count tripwires. Wall-clock scaling and + * mixed-language pipeline measurements stay behind the benchmark flag: + * + * GITNEXUS_BENCH=1 npx vitest run test/integration/spring-aop-benchmark.test.ts + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { GraphNode } from 'gitnexus-shared'; +import { describe, expect, it } from 'vitest'; +import { + createSpringAopCandidateIndex, + type SpringAopOwnedMethod, +} from '../../src/core/ingestion/frameworks/spring/aop-candidates.js'; +import { + decodeSpringAopReason, + parseSpringAopPointcut, + springAopPointcutMatches, + type SpringAopStaticPointcut, +} from '../../src/core/ingestion/frameworks/spring/aop.js'; +import { collectKotlinCaptureSideChannel } from '../../src/core/ingestion/languages/kotlin/capture-side-channel.js'; +import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/captures.js'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; + +const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1'; +const TRANSACTIONAL = 'org.springframework.transaction.annotation.Transactional'; +const CACHEABLE = 'org.springframework.cache.annotation.Cacheable'; + +interface CandidateFixture { + readonly candidates: readonly SpringAopOwnedMethod[]; + readonly methodAnnotations: ReadonlyMap>; +} + +function candidateFixture(methodCount: number): CandidateFixture { + const methodsPerOwner = 10; + if (methodCount % methodsPerOwner !== 0) { + throw new Error(`methodCount must be divisible by ${methodsPerOwner}`); + } + + const candidates: SpringAopOwnedMethod[] = []; + const methodAnnotations = new Map>(); + for (let ownerIndex = 0; ownerIndex < methodCount / methodsPerOwner; ownerIndex += 1) { + const language = ownerIndex % 2 === 0 ? 'java' : 'kotlin'; + const extension = language === 'java' ? 'java' : 'kt'; + const qualifiedName = `com.example.partition${ownerIndex % 100}.Service${ownerIndex}`; + const filePath = `src/${language}/Service${ownerIndex}.${extension}`; + const owner: GraphNode = { + id: `Class:${qualifiedName}:${extension}`, + label: 'Class', + properties: { + name: `Service${ownerIndex}`, + qualifiedName, + filePath, + language, + startLine: 1, + endLine: 40, + isExported: true, + }, + }; + + for (let methodIndex = 0; methodIndex < methodsPerOwner; methodIndex += 1) { + const name = `${methodIndex % 2 === 0 ? 'read' : 'write'}${methodIndex}`; + const method: GraphNode = { + id: `Method:${qualifiedName}.${name}:${extension}`, + label: 'Method', + properties: { + name, + qualifiedName: `${qualifiedName}.${name}`, + filePath, + language, + startLine: methodIndex + 2, + endLine: methodIndex + 2, + isExported: true, + visibility: methodIndex % 3 === 0 ? 'protected' : 'public', + parameterCount: methodIndex % 3, + }, + }; + candidates.push({ method, owner }); + + const annotations = new Set(); + if (ownerIndex % 100 === 0 && methodIndex === 0) annotations.add(TRANSACTIONAL); + if (ownerIndex % 125 === 1 && methodIndex === 1) annotations.add(CACHEABLE); + if (annotations.size > 0) methodAnnotations.set(method.id, annotations); + } + } + return { candidates, methodAnnotations }; +} + +function parsePointcut(expression: string): SpringAopStaticPointcut { + const pointcut = parseSpringAopPointcut(expression); + if (pointcut === null) throw new Error(`Expected a static pointcut: ${expression}`); + return pointcut; +} + +function matchingIds( + pointcut: SpringAopStaticPointcut, + candidates: readonly SpringAopOwnedMethod[], + methodAnnotations: ReadonlyMap>, +): string[] { + return candidates + .filter((candidate) => + springAopPointcutMatches( + pointcut, + candidate.owner, + candidate.method, + methodAnnotations.get(candidate.method.id), + ), + ) + .map((candidate) => candidate.method.id) + .sort(); +} + +function selectivePointcuts(): SpringAopStaticPointcut[] { + const partitions = [0, 7, 19, 42, 88]; + return [ + ...partitions.map((partition) => parsePointcut(`within(com.example.partition${partition}..*)`)), + ...partitions.map((partition) => + parsePointcut(`execution(public * com.example.partition${partition}..*.read*(*))`), + ), + ...partitions.map((partition) => + parsePointcut(`within(com.example.partition${partition}.Service${partition})`), + ), + parsePointcut(`@annotation(${TRANSACTIONAL})`), + parsePointcut(`@annotation(${CACHEABLE})`), + ]; +} + +describe('Spring AOP candidate-index regression tripwire (#2416)', () => { + it('preserves brute-force matches while reducing selective advice inspections by 10x', () => { + const fixture = candidateFixture(50_000); + const index = createSpringAopCandidateIndex(fixture.candidates, fixture.methodAnnotations); + const pointcuts = selectivePointcuts(); + let indexedInspections = 0; + + for (const pointcut of pointcuts) { + const selected = index.candidatesFor(pointcut); + indexedInspections += selected.length; + expect(matchingIds(pointcut, selected, fixture.methodAnnotations)).toEqual( + matchingIds(pointcut, fixture.candidates, fixture.methodAnnotations), + ); + } + + const bruteForceInspections = pointcuts.length * fixture.candidates.length; + expect(index.totalCandidates).toBe(50_000); + expect(indexedInspections).toBeLessThanOrEqual(bruteForceInspections / 10); + + const leadingWildcard = parsePointcut('within(*..Service*)'); + const broadCandidates = index.candidatesFor(leadingWildcard); + expect(broadCandidates).toHaveLength(fixture.candidates.length); + expect(matchingIds(leadingWildcard, broadCandidates, fixture.methodAnnotations)).toEqual( + matchingIds(leadingWildcard, fixture.candidates, fixture.methodAnnotations), + ); + }, 30_000); +}); + +describe('Spring AOP broad-advice budget regression tripwire (#2416)', () => { + it('bounds aggregate edge work across multiple broad advices and reports truncation', async () => { + const root = writeMixedSpringAopRepo(20); + const progressMessages: string[] = []; + try { + const result = await runPipelineFromRepo( + root, + (progress) => progressMessages.push(progress.message), + { + skipGraphPhases: true, + workerPoolSize: 1, + springAopMaxCandidateInspectionsPerAdvice: 0, + springAopMaxCandidateInspections: 0, + springAopMaxAdvisedEdgesPerAdvice: 5, + springAopMaxAdvisedEdges: 9, + }, + ); + const adviceEdges = [...result.graph.iterRelationshipsByType('ADVISED_BY')].filter( + (relationship) => decodeSpringAopReason(relationship.reason)?.kind === 'advice', + ); + + expect(adviceEdges).toHaveLength(9); + expect(progressMessages).toContain( + 'Spring AOP advice resolution truncated by configured budgets', + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 30_000); + + it('bounds aggregate candidate inspections across multiple broad advices', async () => { + const root = writeMixedSpringAopRepo(20); + const progressMessages: string[] = []; + try { + const result = await runPipelineFromRepo( + root, + (progress) => progressMessages.push(progress.message), + { + skipGraphPhases: true, + workerPoolSize: 1, + springAopMaxCandidateInspectionsPerAdvice: 4, + springAopMaxCandidateInspections: 7, + springAopMaxAdvisedEdgesPerAdvice: 0, + springAopMaxAdvisedEdges: 0, + }, + ); + const adviceEdges = [...result.graph.iterRelationshipsByType('ADVISED_BY')].filter( + (relationship) => decodeSpringAopReason(relationship.reason)?.kind === 'advice', + ); + + expect(adviceEdges).toHaveLength(7); + expect(progressMessages).toContain( + 'Spring AOP advice resolution truncated by configured budgets', + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 30_000); +}); + +function denseKotlinAopSource(classCount: number): string { + const classes = Array.from({ length: classCount }, (_, index) => { + const transactional = + index % 50 === 0 + ? ` + @Tx + fun transactional${index}() {}` + : ''; + return ` +@Noise +class Subject${index} { + @Noise + fun ordinary${index}() {} + + @OtherNoise + fun secondary${index}() {} +${transactional} +} +`; + }).join('\n'); + + return `package com.example + +import org.aspectj.lang.annotation.Aspect as AopAspect +import org.aspectj.lang.annotation.Before as AdviceBefore +import org.springframework.transaction.annotation.Transactional as Tx + +@AopAspect +class DenseAspect { + @AdviceBefore("@annotation(org.springframework.transaction.annotation.Transactional)") + fun beforeTransaction() {} +} + +${classes} +`; +} + +interface KotlinCaptureResult { + readonly classCount: number; + readonly elapsedMs: number; + readonly captureCount: number; + readonly factCount: number; + readonly annotationNames: readonly string[]; +} + +function runKotlinAopCapture(classCount: number, run: number): KotlinCaptureResult { + const filePath = `src/SpringAopBench${classCount}_${run}.kt`; + const startedAt = performance.now(); + const captures = emitKotlinScopeCaptures(denseKotlinAopSource(classCount), filePath); + const elapsedMs = performance.now() - startedAt; + const facts = collectKotlinCaptureSideChannel(filePath)?.springAopFacts ?? []; + return { + classCount, + elapsedMs, + captureCount: captures.length, + factCount: facts.length, + annotationNames: facts.flatMap((fact) => fact.annotations.map((annotation) => annotation.name)), + }; +} + +describe('Kotlin Spring AOP capture regression tripwire (#2416)', () => { + it('captures dense unrelated annotations and every Spring alias within a coarse budget', () => { + const classCount = 400; + const aliasedTransactionalCount = classCount / 50; + const budgetMs = 10_000; + + runKotlinAopCapture(4, 0); + const smaller = runKotlinAopCapture(classCount / 2, 1); + const result = runKotlinAopCapture(classCount, 1); + + expect(smaller.factCount).toBe((classCount / 2) * 3 + aliasedTransactionalCount / 2 + 2); + expect(result.factCount).toBe(classCount * 3 + aliasedTransactionalCount + 2); + expect(result.annotationNames.filter((name) => name === 'Noise')).toHaveLength(classCount * 2); + expect(result.annotationNames.filter((name) => name === 'OtherNoise')).toHaveLength(classCount); + expect(result.annotationNames.filter((name) => name === 'Tx')).toHaveLength( + aliasedTransactionalCount, + ); + expect(result.annotationNames.filter((name) => name === 'AopAspect')).toHaveLength(1); + expect(result.annotationNames.filter((name) => name === 'AdviceBefore')).toHaveLength(1); + expect(result.captureCount).toBeGreaterThan(classCount * 8); + expect(result.captureCount / smaller.captureCount).toBeGreaterThan(1.9); + expect(result.captureCount / smaller.captureCount).toBeLessThan(2.05); + expect(result.elapsedMs).toBeLessThan(budgetMs); + }, 30_000); +}); + +interface SelectorBenchResult { + readonly methods: number; + readonly buildMs: number; + readonly queryMs: number; + readonly examined: number; + readonly matches: number; +} + +function runSelectorBenchmark(methods: number): SelectorBenchResult { + const fixture = candidateFixture(methods); + const buildStartedAt = performance.now(); + const index = createSpringAopCandidateIndex(fixture.candidates, fixture.methodAnnotations); + const buildMs = performance.now() - buildStartedAt; + const pointcuts = selectivePointcuts(); + let examined = 0; + let matches = 0; + const queryStartedAt = performance.now(); + for (const pointcut of pointcuts) { + const selected = index.candidatesFor(pointcut); + examined += selected.length; + matches += matchingIds(pointcut, selected, fixture.methodAnnotations).length; + } + const queryMs = performance.now() - queryStartedAt; + return { methods, buildMs, queryMs, examined, matches }; +} + +describe.skipIf(!BENCH_ENABLED)('Spring AOP candidate-index scaling benchmark (#2416)', () => { + it('reports build/query scaling while keeping selective work proportional to candidates', () => { + const scales = [10_000, 50_000, 100_000]; + const results = scales.map((methods) => runSelectorBenchmark(methods)); + + for (const result of results) { + const bruteForceInspections = selectivePointcuts().length * result.methods; + console.log( + ` selector methods=${result.methods}: build=${result.buildMs.toFixed(1)}ms ` + + `query=${result.queryMs.toFixed(1)}ms (${result.examined} examined, ` + + `${result.matches} matches)`, + ); + expect(result.examined).toBeLessThanOrEqual(bruteForceInspections / 10); + expect(result.matches).toBeGreaterThan(0); + expect(result.buildMs + result.queryMs).toBeLessThan(10_000); + } + + const first = results[0]!; + const last = results[results.length - 1]!; + const workRatio = last.examined / first.examined; + const sizeRatio = last.methods / first.methods; + expect(workRatio).toBeGreaterThan(sizeRatio * 0.9); + expect(workRatio).toBeLessThan(sizeRatio * 1.1); + }, 60_000); +}); + +function writeFixture(root: string, relativePath: string, content: string): void { + const target = path.join(root, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +function javaServices(count: number): string { + return Array.from( + { length: count }, + (_, index) => ` +class JavaService${index} { + @Transactional public void transaction${index}() {} + public void read${index}() {} + public void write${index}() {} +} +`, + ).join('\n'); +} + +function kotlinServices(count: number): string { + return Array.from( + { length: count }, + (_, index) => ` +class KotlinService${index} { + @Tx fun transaction${index}() {} + fun read${index}() {} + fun write${index}() {} +} +`, + ).join('\n'); +} + +function writeMixedSpringAopRepo(serviceCount: number): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `spring-aop-bench-${serviceCount}-`)); + const javaCount = serviceCount / 2; + const kotlinCount = serviceCount - javaCount; + writeFixture( + root, + 'src/main/java/com/example/service/Services.java', + `package com.example.service; +import org.springframework.transaction.annotation.Transactional; +${javaServices(javaCount)} +`, + ); + writeFixture( + root, + 'src/main/kotlin/com/example/service/Services.kt', + `package com.example.service +import org.springframework.transaction.annotation.Transactional as Tx +${kotlinServices(kotlinCount)} +`, + ); + writeFixture( + root, + 'src/main/java/com/example/aspect/BenchmarkAspect.java', + `package com.example.aspect; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; + +@Aspect +public class BenchmarkAspect { + @Before("@annotation(org.springframework.transaction.annotation.Transactional)") + public void transactionalAdvice() {} + + @Before("within(com.example.service.KotlinService*)") + public void kotlinServiceAdvice() {} + + @Before("execution(public * com.example.service.JavaService*.read*(..))") + public void javaReadAdvice() {} +} +`, + ); + return root; +} + +interface PipelineBenchResult { + readonly services: number; + readonly elapsedMs: number; + readonly advisedBy: number; + readonly behaviorEdges: number; + readonly adviceEdges: number; + readonly transactionalAdviceEdges: number; + readonly kotlinAdviceEdges: number; + readonly javaAdviceEdges: number; +} + +async function runMixedPipelineBenchmark(serviceCount: number): Promise { + const root = writeMixedSpringAopRepo(serviceCount); + try { + const startedAt = performance.now(); + const result = await runPipelineFromRepo(root, () => {}, { + skipGraphPhases: true, + workerPoolSize: 1, + }); + const elapsedMs = performance.now() - startedAt; + const advisedBy = [...result.graph.iterRelationshipsByType('ADVISED_BY')]; + let behaviorEdges = 0; + let adviceEdges = 0; + for (const relationship of advisedBy) { + const kind = decodeSpringAopReason(relationship.reason)?.kind; + if (kind === 'behavior') behaviorEdges += 1; + if (kind === 'advice') adviceEdges += 1; + } + const adviceEdgeCount = (name: string): number => + advisedBy.filter( + (relationship) => + decodeSpringAopReason(relationship.reason)?.kind === 'advice' && + result.graph.getNode(relationship.targetId)?.properties.name === name, + ).length; + + return { + services: serviceCount, + elapsedMs, + advisedBy: advisedBy.length, + behaviorEdges, + adviceEdges, + transactionalAdviceEdges: adviceEdgeCount('transactionalAdvice'), + kotlinAdviceEdges: adviceEdgeCount('kotlinServiceAdvice'), + javaAdviceEdges: adviceEdgeCount('javaReadAdvice'), + }; + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +describe.skipIf(!BENCH_ENABLED)('mixed Java/Kotlin Spring AOP pipeline benchmark (#2416)', () => { + it('scales real behavior and advice materialization with exact ADVISED_BY counts', async () => { + const scales = [20, 40, 80]; + const results: PipelineBenchResult[] = []; + for (const services of scales) { + const result = await runMixedPipelineBenchmark(services); + results.push(result); + console.log( + ` pipeline services=${services}: ${result.elapsedMs.toFixed(1)}ms ` + + `(${result.behaviorEdges} behavior, ${result.adviceEdges} advice edges)`, + ); + } + + for (const result of results) { + const javaServiceCount = result.services / 2; + const kotlinServiceCount = result.services - javaServiceCount; + expect(result.behaviorEdges).toBe(result.services); + expect(result.transactionalAdviceEdges).toBe(result.services); + expect(result.kotlinAdviceEdges).toBe(kotlinServiceCount * 3); + expect(result.javaAdviceEdges).toBe(javaServiceCount); + expect(result.adviceEdges).toBe(result.services + kotlinServiceCount * 3 + javaServiceCount); + expect(result.advisedBy).toBe(result.behaviorEdges + result.adviceEdges); + expect(result.elapsedMs).toBeLessThan(120_000); + } + }, 300_000); +}); diff --git a/gitnexus/test/integration/spring-aop-mcp.test.ts b/gitnexus/test/integration/spring-aop-mcp.test.ts new file mode 100644 index 000000000..d93107099 --- /dev/null +++ b/gitnexus/test/integration/spring-aop-mcp.test.ts @@ -0,0 +1,457 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { querySpringAopMetadata } from '../../src/mcp/local/aop-metadata.js'; +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; +import { withTestLbugDB } from '../helpers/test-indexed-db.js'; + +vi.mock('../../src/storage/repo-manager.js', () => ({ + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), +})); + +const SERVICE_ID = 'Class:src/PaymentService.java:PaymentService'; +const ASPECT_CLASS_ID = 'Class:src/AuditAspect.java:AuditAspect'; +const NOISY_ASPECT_CLASS_ID = 'Class:src/NoisyAspect.java:NoisyAspect'; +const PAY_ID = 'Method:src/PaymentService.java:PaymentService.pay#0'; +const CLASS_LEVEL_METHOD_ID = 'Method:src/PaymentService.java:PaymentService.list#0'; +const AUDIT_ID = 'Method:src/AuditAspect.java:AuditAspect.audit#0'; +const UNKNOWN_ADVICE_ID = 'Method:src/AuditAspect.java:AuditAspect.authorize#0'; +const RESOLVED_ADVICE_ID = 'Method:src/AuditAspect.java:AuditAspect.resolvedPointcut#0'; +const HIGH_FAN_IN_ADVICE_ID = 'Method:src/AuditAspect.java:AuditAspect.hotAdvice#0'; +const NOISY_ADVICE_ID = 'Method:src/NoisyAspect.java:NoisyAspect.noisyAdvice#0'; +const NOISY_ADVISED_SOURCE_ID = 'Method:src/NoisySource.java:NoisySource.run#0'; +const NOISY_ADVICE_TARGET_ID = 'Method:src/NoisyTarget.java:NoisyTarget.advise#0'; +const FOREIGN_ADVISED_SOURCE_ID = 'Method:src/ForeignSource.java:ForeignSource.run#0'; +const FOREIGN_ADVICE_TARGET_ID = 'Method:src/ForeignTarget.java:ForeignTarget.advise#0'; +const TRUNCATED_SOURCE_ID = 'Method:src/TruncatedSource.java:TruncatedSource.run#0'; +const TRUNCATED_TARGET_ID = 'Method:src/TruncatedTarget.java:TruncatedTarget.advise#0'; +const PLAIN_ID = 'Method:src/Plain.java:Plain.run#0'; +const CLASS_BEHAVIOR_ID = `CodeElement:spring-aop:${SERVICE_ID}:transactional`; +const METHOD_BEHAVIOR_ID = `CodeElement:spring-aop:${PAY_ID}:cacheable`; +const UNKNOWN_POINTCUT_ID = `CodeElement:spring-aop:${UNKNOWN_ADVICE_ID}:pointcut`; +const RESOLVED_POINTCUT_ID = `CodeElement:spring-aop:${RESOLVED_ADVICE_ID}:pointcut`; +const UNRELATED_DECLARATION_ID = `CodeElement:spring-bean:${PLAIN_ID}`; +const ASPECT_EVIDENCE_ID = `CodeElement:spring-aop:${ASPECT_CLASS_ID}:aspect`; +const NOISY_ASPECT_EVIDENCE_ID = `CodeElement:spring-aop:${NOISY_ASPECT_CLASS_ID}:aspect`; +const NOISY_POINTCUT_ID = `CodeElement:spring-aop:${NOISY_ADVICE_ID}:pointcut`; + +const springReason = (value: object): string => `spring-aop:v1:${JSON.stringify(value)}`; + +const CLASS_BEHAVIOR_REASON = springReason({ + kind: 'behavior', + annotation: 'org.springframework.transaction.annotation.Transactional', + behavior: 'transactional', + declaredOn: 'class', + activation: 'unknown', + proxy: 'possible', +}); +const METHOD_BEHAVIOR_REASON = springReason({ + kind: 'behavior', + annotation: 'org.springframework.cache.annotation.Cacheable', + behavior: 'cacheable', + declaredOn: 'method', + activation: 'unknown', + proxy: 'possible', +}); +const ADVICE_REASON = springReason({ + kind: 'advice', + annotation: 'org.aspectj.lang.annotation.Around', + advice: 'around', + pointcut: 'execution(* com.example.PaymentService.pay(..))', + match: 'static', + activation: 'unknown', + proxy: 'possible', +}); +const UNKNOWN_POINTCUT_REASON = springReason({ + kind: 'pointcut', + annotation: 'org.aspectj.lang.annotation.Before', + pointcut: 'securedOperation()', + match: 'unresolved', + resolution: 'unknown', +}); +const RESOLVED_POINTCUT_REASON = springReason({ + kind: 'pointcut', + annotation: 'org.aspectj.lang.annotation.Pointcut', + pointcut: 'execution(* com.example.PaymentService.pay(..))', + match: 'static', + resolution: 'resolved', +}); +const ASPECT_REASON = springReason({ + kind: 'aspect', + annotation: 'org.aspectj.lang.annotation.Aspect', + activation: 'unknown', + registration: 'unknown', +}); + +const HIGH_FAN_IN_ADVISED_IDS = Array.from( + { length: 31 }, + (_, index) => `Method:src/FanInService.java:FanInService.advised${index}#0`, +); +const HIGH_FAN_IN_CALLER_IDS = Array.from( + { length: 31 }, + (_, index) => `Method:src/LegacyCaller.java:LegacyCaller.call${index}#0`, +); + +const SEED = [ + `CREATE (c:Class {id:'${SERVICE_ID}', name:'PaymentService', filePath:'src/PaymentService.java', startLine:0, endLine:20, isExported:false, content:'class PaymentService {}', description:'', frameworkAnnotations:[]})`, + `CREATE (c:Class {id:'${ASPECT_CLASS_ID}', name:'AuditAspect', filePath:'src/AuditAspect.java', startLine:0, endLine:20, isExported:false, content:'class AuditAspect {}', description:'', frameworkAnnotations:[]})`, + `CREATE (c:Class {id:'${NOISY_ASPECT_CLASS_ID}', name:'NoisyAspect', filePath:'src/NoisyAspect.java', startLine:0, endLine:20, isExported:false, content:'class NoisyAspect {}', description:'', frameworkAnnotations:[]})`, + `CREATE (m:Method {id:'${PAY_ID}', name:'pay', filePath:'src/PaymentService.java', startLine:4, endLine:8, isExported:false, content:'void pay() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${CLASS_LEVEL_METHOD_ID}', name:'list', filePath:'src/PaymentService.java', startLine:10, endLine:12, isExported:false, content:'void list() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${AUDIT_ID}', name:'audit', filePath:'src/AuditAspect.java', startLine:4, endLine:8, isExported:false, content:'Object audit() {}', description:'', parameterCount:0, returnType:'Object'})`, + `CREATE (m:Method {id:'${UNKNOWN_ADVICE_ID}', name:'authorize', filePath:'src/AuditAspect.java', startLine:10, endLine:12, isExported:false, content:'void authorize() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${RESOLVED_ADVICE_ID}', name:'resolvedPointcut', filePath:'src/AuditAspect.java', startLine:12, endLine:13, isExported:false, content:'void resolvedPointcut() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${HIGH_FAN_IN_ADVICE_ID}', name:'hotAdvice', filePath:'src/AuditAspect.java', startLine:14, endLine:16, isExported:false, content:'void hotAdvice() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${NOISY_ADVICE_ID}', name:'noisyAdvice', filePath:'src/NoisyAspect.java', startLine:4, endLine:8, isExported:false, content:'void noisyAdvice() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${NOISY_ADVISED_SOURCE_ID}', name:'run', filePath:'src/NoisySource.java', startLine:1, endLine:2, isExported:false, content:'void run() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${NOISY_ADVICE_TARGET_ID}', name:'advise', filePath:'src/NoisyTarget.java', startLine:1, endLine:2, isExported:false, content:'void advise() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${FOREIGN_ADVISED_SOURCE_ID}', name:'run', filePath:'src/ForeignSource.java', startLine:1, endLine:2, isExported:false, content:'void run() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${FOREIGN_ADVICE_TARGET_ID}', name:'advise', filePath:'src/ForeignTarget.java', startLine:1, endLine:2, isExported:false, content:'void advise() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${TRUNCATED_SOURCE_ID}', name:'run', filePath:'src/TruncatedSource.java', startLine:1, endLine:2, isExported:false, content:'void run() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (m:Method {id:'${TRUNCATED_TARGET_ID}', name:'advise', filePath:'src/TruncatedTarget.java', startLine:1, endLine:2, isExported:false, content:'void advise() {}', description:'', parameterCount:0, returnType:'void'})`, + ...HIGH_FAN_IN_ADVISED_IDS.map( + (id, index) => + `CREATE (m:Method {id:'${id}', name:'advised${index}', filePath:'src/FanInService.java', startLine:${index}, endLine:${index}, isExported:false, content:'void advised${index}() {}', description:'', parameterCount:0, returnType:'void'})`, + ), + ...HIGH_FAN_IN_CALLER_IDS.map( + (id, index) => + `CREATE (m:Method {id:'${id}', name:'call${index}', filePath:'src/LegacyCaller.java', startLine:${index}, endLine:${index}, isExported:false, content:'void call${index}() {}', description:'', parameterCount:0, returnType:'void'})`, + ), + `CREATE (m:Method {id:'${PLAIN_ID}', name:'run', filePath:'src/Plain.java', startLine:1, endLine:2, isExported:false, content:'void run() {}', description:'', parameterCount:0, returnType:'void'})`, + `CREATE (e:CodeElement {id:'${CLASS_BEHAVIOR_ID}', name:'Transactional', filePath:'src/PaymentService.java', startLine:0, endLine:0, isExported:false, content:'', description:'Spring AOP behavior evidence'})`, + `CREATE (e:CodeElement {id:'${METHOD_BEHAVIOR_ID}', name:'Cacheable', filePath:'src/PaymentService.java', startLine:4, endLine:4, isExported:false, content:'', description:'Spring AOP behavior evidence'})`, + `CREATE (e:CodeElement {id:'${UNKNOWN_POINTCUT_ID}', name:'securedOperation()', filePath:'src/AuditAspect.java', startLine:10, endLine:10, isExported:false, content:'', description:'Spring AOP unresolved pointcut evidence'})`, + `CREATE (e:CodeElement {id:'${RESOLVED_POINTCUT_ID}', name:'pay()', filePath:'src/AuditAspect.java', startLine:12, endLine:12, isExported:false, content:'', description:'Spring AOP resolved pointcut evidence'})`, + `CREATE (e:CodeElement {id:'${UNRELATED_DECLARATION_ID}', name:'plain', filePath:'src/Plain.java', startLine:1, endLine:1, isExported:false, content:'', description:'Spring Bean factory declaration'})`, + `CREATE (e:CodeElement {id:'${ASPECT_EVIDENCE_ID}', name:'Aspect', filePath:'src/AuditAspect.java', startLine:0, endLine:0, isExported:false, content:'', description:'Spring AOP aspect evidence'})`, + `CREATE (e:CodeElement {id:'${NOISY_ASPECT_EVIDENCE_ID}', name:'Aspect', filePath:'src/NoisyAspect.java', startLine:0, endLine:0, isExported:false, content:'', description:'Spring AOP aspect evidence'})`, + `CREATE (e:CodeElement {id:'${NOISY_POINTCUT_ID}', name:'securedOperation()', filePath:'src/NoisyAspect.java', startLine:4, endLine:4, isExported:false, content:'', description:'Spring AOP unresolved pointcut evidence'})`, + `MATCH (c:Class {id:'${SERVICE_ID}'}), (e:CodeElement {id:'${CLASS_BEHAVIOR_ID}'}) CREATE (c)-[:CodeRelation {type:'ADVISED_BY', confidence:1.0, reason:'${CLASS_BEHAVIOR_REASON}', step:0}]->(e)`, + `MATCH (m:Method {id:'${CLASS_LEVEL_METHOD_ID}'}), (e:CodeElement {id:'${CLASS_BEHAVIOR_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:1.0, reason:'${CLASS_BEHAVIOR_REASON}', step:0}]->(e)`, + `MATCH (m:Method {id:'${PAY_ID}'}), (e:CodeElement {id:'${METHOD_BEHAVIOR_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:1.0, reason:'${METHOD_BEHAVIOR_REASON}', step:0}]->(e)`, + // Duplicate evidence exercises read-side deduplication for indexes produced + // by an interrupted/retried incremental write. + `MATCH (m:Method {id:'${PAY_ID}'}), (e:CodeElement {id:'${METHOD_BEHAVIOR_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:1.0, reason:'${METHOD_BEHAVIOR_REASON}', step:0}]->(e)`, + `MATCH (m:Method {id:'${PAY_ID}'}), (a:Method {id:'${AUDIT_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.9, reason:'${ADVICE_REASON}', step:0}]->(a)`, + `MATCH (a:Method {id:'${UNKNOWN_ADVICE_ID}'}), (e:CodeElement {id:'${UNKNOWN_POINTCUT_ID}'}) CREATE (a)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${UNKNOWN_POINTCUT_REASON}', step:0}]->(e)`, + `MATCH (a:Method {id:'${RESOLVED_ADVICE_ID}'}), (e:CodeElement {id:'${RESOLVED_POINTCUT_ID}'}) CREATE (a)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${RESOLVED_POINTCUT_REASON}', step:0}]->(e)`, + `MATCH (c:Class {id:'${ASPECT_CLASS_ID}'}), (e:CodeElement {id:'${ASPECT_EVIDENCE_ID}'}) CREATE (c)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${ASPECT_REASON}', step:0}]->(e)`, + `MATCH (m:Method {id:'${PLAIN_ID}'}), (e:CodeElement {id:'${UNRELATED_DECLARATION_ID}'}) CREATE (m)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'spring-bean-factory:{"names":["plain"],"namesKnown":true}', step:0}]->(e)`, + `MATCH (c:Class {id:'${NOISY_ASPECT_CLASS_ID}'}), (e:CodeElement {id:'${UNRELATED_DECLARATION_ID}'}) UNWIND range(1, 1001) AS ignored CREATE (c)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'spring-bean-factory:{"names":["noise"],"namesKnown":true}', step:ignored}]->(e)`, + `MATCH (c:Class {id:'${NOISY_ASPECT_CLASS_ID}'}), (e:CodeElement {id:'${NOISY_ASPECT_EVIDENCE_ID}'}) CREATE (c)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${ASPECT_REASON}', step:0}]->(e)`, + `MATCH (m:Method {id:'${PLAIN_ID}'}), (e:CodeElement {id:'${NOISY_POINTCUT_ID}'}) UNWIND range(1, 1001) AS ignored CREATE (m)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'spring-bean-factory:{"names":["noise"],"namesKnown":true}', step:ignored}]->(e)`, + `MATCH (m:Method {id:'${NOISY_ADVICE_ID}'}), (e:CodeElement {id:'${NOISY_POINTCUT_ID}'}) CREATE (m)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${UNKNOWN_POINTCUT_REASON}', step:0}]->(e)`, + `MATCH (m:Method {id:'${NOISY_ADVISED_SOURCE_ID}'}), (a:Method {id:'${NOISY_ADVICE_TARGET_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.9, reason:'${ADVICE_REASON}', step:0}]->(a)`, + `MATCH (m:Method {id:'${NOISY_ADVISED_SOURCE_ID}'}), (a:Method {id:'${FOREIGN_ADVICE_TARGET_ID}'}) UNWIND range(1, 1001) AS ignored CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.1, reason:'foreign-advice', step:ignored}]->(a)`, + `MATCH (m:Method {id:'${FOREIGN_ADVISED_SOURCE_ID}'}), (a:Method {id:'${NOISY_ADVICE_TARGET_ID}'}) UNWIND range(1, 1001) AS ignored CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.1, reason:'foreign-advice', step:ignored}]->(a)`, + `MATCH (m:Method {id:'${TRUNCATED_SOURCE_ID}'}), (a:Method {id:'${TRUNCATED_TARGET_ID}'}) UNWIND range(1, 1001) AS item CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.9, reason:'${ADVICE_REASON}', step:item}]->(a)`, + ...HIGH_FAN_IN_ADVISED_IDS.map( + (id) => + `MATCH (m:Method {id:'${id}'}), (a:Method {id:'${HIGH_FAN_IN_ADVICE_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.9, reason:'${ADVICE_REASON}', step:0}]->(a)`, + ), + ...HIGH_FAN_IN_CALLER_IDS.map( + (id) => + `MATCH (m:Method {id:'${id}'}), (a:Method {id:'${HIGH_FAN_IN_ADVICE_ID}'}) CREATE (m)-[:CodeRelation {type:'CALLS', confidence:1.0, reason:'test fixture', step:0}]->(a)`, + ), +]; + +withTestLbugDB( + 'spring-aop-mcp', + (handle) => { + let backend: LocalBackend; + + beforeAll(() => { + backend = (handle as typeof handle & { _backend: LocalBackend })._backend; + }); + + describe('Spring AOP metadata enrichment', () => { + it('normalizes declarative behavior and explicit advice for an advised method', async () => { + const metadata = await querySpringAopMetadata(handle.repoId, PAY_ID, 'Method'); + + expect(metadata).toEqual({ + framework: 'spring', + proxied: 'possible', + behaviors: [ + { + annotation: 'org.springframework.cache.annotation.Cacheable', + behavior: 'cacheable', + declaredOn: 'method', + activation: 'unknown', + evidenceId: METHOD_BEHAVIOR_ID, + }, + ], + advices: [ + { + annotation: 'org.aspectj.lang.annotation.Around', + advice: 'around', + pointcut: 'execution(* com.example.PaymentService.pay(..))', + match: 'static', + activation: 'unknown', + adviceId: AUDIT_ID, + adviceName: 'audit', + adviceFilePath: 'src/AuditAspect.java', + advisedId: PAY_ID, + advisedName: 'pay', + advisedFilePath: 'src/PaymentService.java', + }, + ], + resolvedPointcuts: [], + unresolvedPointcuts: [], + }); + }); + + it('returns the same canonical advice direction from the advice method', async () => { + const metadata = await querySpringAopMetadata(handle.repoId, AUDIT_ID, 'Method'); + + expect(metadata?.advices).toEqual([ + expect.objectContaining({ + adviceId: AUDIT_ID, + advisedId: PAY_ID, + advice: 'around', + }), + ]); + expect(metadata).not.toHaveProperty('proxied'); + }); + + it('exposes the same AOP metadata through context and impact', async () => { + const [context, adviceContext, impact, adviceImpact] = await Promise.all([ + backend.callTool('context', { uid: PAY_ID }), + backend.callTool('context', { uid: AUDIT_ID }), + backend.callTool('impact', { + target: 'pay', + direction: 'upstream', + }), + backend.callTool('impact', { + target: 'audit', + direction: 'upstream', + relationTypes: ['ADVISED_BY'], + includeTests: true, + }), + ]); + + expect(context.symbol.aop).toEqual( + expect.objectContaining({ + framework: 'spring', + proxied: 'possible', + behaviors: [expect.objectContaining({ behavior: 'cacheable' })], + advices: [expect.objectContaining({ advice: 'around', adviceId: AUDIT_ID })], + }), + ); + expect(context.outgoing.advised_by).toEqual( + expect.arrayContaining([expect.objectContaining({ uid: AUDIT_ID, name: 'audit' })]), + ); + expect(adviceContext.incoming.advised_by).toEqual( + expect.arrayContaining([expect.objectContaining({ uid: PAY_ID, name: 'pay' })]), + ); + expect(adviceContext.symbol.aop).not.toHaveProperty('proxied'); + expect(impact.target.aop).toEqual(context.symbol.aop); + expect(adviceImpact.target.aop).toEqual(adviceContext.symbol.aop); + expect(adviceImpact.byDepth[1]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: PAY_ID, + name: 'pay', + relationType: 'ADVISED_BY', + }), + ]), + ); + }); + + it('keeps legacy context relations when advice fan-in exceeds the context window', async () => { + const context = await backend.callTool('context', { uid: HIGH_FAN_IN_ADVICE_ID }); + + expect(context.incoming.calls).toHaveLength(30); + expect(context.incoming.advised_by).toHaveLength(30); + expect(context.symbol.aop.advices).toHaveLength(31); + }); + + it('supports Class and CodeElement behavior evidence', async () => { + const classMetadata = await querySpringAopMetadata(handle.repoId, SERVICE_ID, 'Class'); + const classLevelMethodMetadata = await querySpringAopMetadata( + handle.repoId, + CLASS_LEVEL_METHOD_ID, + 'Method', + ); + const evidenceMetadata = await querySpringAopMetadata( + handle.repoId, + METHOD_BEHAVIOR_ID, + 'CodeElement', + ); + + expect(classMetadata?.behaviors).toEqual([ + expect.objectContaining({ behavior: 'transactional', declaredOn: 'class' }), + ]); + expect(classMetadata?.proxied).toBe('possible'); + expect(classLevelMethodMetadata?.behaviors).toEqual([ + expect.objectContaining({ behavior: 'transactional', declaredOn: 'class' }), + ]); + expect(classLevelMethodMetadata?.proxied).toBe('possible'); + expect(evidenceMetadata?.behaviors).toEqual([ + expect.objectContaining({ behavior: 'cacheable', evidenceId: METHOD_BEHAVIOR_ID }), + ]); + expect(evidenceMetadata).not.toHaveProperty('proxied'); + }); + + it('surfaces standalone Aspect declarations without claiming proxy activation', async () => { + const [classMetadata, evidenceMetadata, context] = await Promise.all([ + querySpringAopMetadata(handle.repoId, ASPECT_CLASS_ID, 'Class'), + querySpringAopMetadata(handle.repoId, ASPECT_EVIDENCE_ID, 'CodeElement'), + backend.callTool('context', { uid: ASPECT_CLASS_ID }), + ]); + const expectedAspect = { + annotation: 'org.aspectj.lang.annotation.Aspect', + activation: 'unknown', + registration: 'unknown', + evidenceId: ASPECT_EVIDENCE_ID, + }; + + expect(classMetadata?.aspect).toEqual(expectedAspect); + expect(classMetadata).not.toHaveProperty('proxied'); + expect(evidenceMetadata?.aspect).toEqual(expectedAspect); + expect(evidenceMetadata).not.toHaveProperty('proxied'); + expect(context.symbol.aop.aspect).toEqual(expectedAspect); + expect(context.symbol.aop).not.toHaveProperty('proxied'); + }); + + it('surfaces unresolved pointcuts without guessing an advised target', async () => { + const adviceMetadata = await querySpringAopMetadata( + handle.repoId, + UNKNOWN_ADVICE_ID, + 'Method', + ); + const evidenceMetadata = await querySpringAopMetadata( + handle.repoId, + UNKNOWN_POINTCUT_ID, + 'CodeElement', + ); + + const expected = [ + { + annotation: 'org.aspectj.lang.annotation.Before', + pointcut: 'securedOperation()', + adviceId: UNKNOWN_ADVICE_ID, + adviceName: 'authorize', + adviceFilePath: 'src/AuditAspect.java', + evidenceId: UNKNOWN_POINTCUT_ID, + }, + ]; + expect(adviceMetadata).toEqual({ + framework: 'spring', + behaviors: [], + advices: [], + resolvedPointcuts: [], + unresolvedPointcuts: expected, + }); + expect(evidenceMetadata?.unresolvedPointcuts).toEqual(expected); + }); + + it('surfaces resolved standalone pointcut declarations from both endpoints', async () => { + const [adviceMetadata, evidenceMetadata] = await Promise.all([ + querySpringAopMetadata(handle.repoId, RESOLVED_ADVICE_ID, 'Method'), + querySpringAopMetadata(handle.repoId, RESOLVED_POINTCUT_ID, 'CodeElement'), + ]); + const expected = [ + { + annotation: 'org.aspectj.lang.annotation.Pointcut', + pointcut: 'execution(* com.example.PaymentService.pay(..))', + match: 'static', + resolution: 'resolved', + adviceId: RESOLVED_ADVICE_ID, + adviceName: 'resolvedPointcut', + adviceFilePath: 'src/AuditAspect.java', + evidenceId: RESOLVED_POINTCUT_ID, + }, + ]; + + expect(adviceMetadata?.resolvedPointcuts).toEqual(expected); + expect(evidenceMetadata?.resolvedPointcuts).toEqual(expected); + }); + + it('orders capped rows deterministically and reports positive truncation', async () => { + const first = await querySpringAopMetadata(handle.repoId, TRUNCATED_SOURCE_ID, 'Method'); + const second = await querySpringAopMetadata(handle.repoId, TRUNCATED_SOURCE_ID, 'Method'); + + expect(first?.truncated).toBe(true); + expect(first?.advices).toEqual([ + expect.objectContaining({ + advisedId: TRUNCATED_SOURCE_ID, + adviceId: TRUNCATED_TARGET_ID, + }), + ]); + expect(second).toEqual(first); + }); + + it('does not let unrelated DECLARES exhaust the Spring AOP query budget', async () => { + const [aspectMetadata, pointcutMetadata] = await Promise.all([ + querySpringAopMetadata(handle.repoId, NOISY_ASPECT_CLASS_ID, 'Class'), + querySpringAopMetadata(handle.repoId, NOISY_POINTCUT_ID, 'CodeElement'), + ]); + + expect(aspectMetadata?.aspect).toEqual({ + annotation: 'org.aspectj.lang.annotation.Aspect', + activation: 'unknown', + registration: 'unknown', + evidenceId: NOISY_ASPECT_EVIDENCE_ID, + }); + expect(aspectMetadata).not.toHaveProperty('truncated'); + expect(pointcutMetadata?.unresolvedPointcuts).toEqual([ + { + annotation: 'org.aspectj.lang.annotation.Before', + pointcut: 'securedOperation()', + adviceId: NOISY_ADVICE_ID, + adviceName: 'noisyAdvice', + adviceFilePath: 'src/NoisyAspect.java', + evidenceId: NOISY_POINTCUT_ID, + }, + ]); + expect(pointcutMetadata).not.toHaveProperty('truncated'); + }); + + it('does not let unrelated ADVISED_BY exhaust either AOP query direction', async () => { + const [sourceMetadata, targetMetadata] = await Promise.all([ + querySpringAopMetadata(handle.repoId, NOISY_ADVISED_SOURCE_ID, 'Method'), + querySpringAopMetadata(handle.repoId, NOISY_ADVICE_TARGET_ID, 'Method'), + ]); + + expect(sourceMetadata?.advices).toEqual([ + expect.objectContaining({ + advisedId: NOISY_ADVISED_SOURCE_ID, + adviceId: NOISY_ADVICE_TARGET_ID, + }), + ]); + expect(targetMetadata?.advices).toEqual(sourceMetadata?.advices); + expect(sourceMetadata).not.toHaveProperty('truncated'); + expect(targetMetadata).not.toHaveProperty('truncated'); + }); + + it('ignores unrelated DECLARES evidence and unsupported symbol kinds', async () => { + await expect( + querySpringAopMetadata(handle.repoId, PLAIN_ID, 'Method'), + ).resolves.toBeUndefined(); + await expect( + querySpringAopMetadata(handle.repoId, 'Function:src/plain.ts:run', 'Function'), + ).resolves.toBeUndefined(); + }); + }); + }, + { + seed: SEED, + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'test-repo', + path: '/test/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 3, nodes: 12, communities: 0, processes: 0 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as typeof handle & { _backend?: LocalBackend })._backend = backend; + }, + }, +); diff --git a/gitnexus/test/integration/spring-aop-pipeline.test.ts b/gitnexus/test/integration/spring-aop-pipeline.test.ts new file mode 100644 index 000000000..439451d44 --- /dev/null +++ b/gitnexus/test/integration/spring-aop-pipeline.test.ts @@ -0,0 +1,997 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { decodeSpringAopReason } from '../../src/core/ingestion/frameworks/spring/aop.js'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { + loadParseCache, + PARSE_CACHE_VERSION, + pruneCache, + saveParseCache, + type ParseCache, +} from '../../src/storage/parse-cache.js'; +import { + getDurableParsedFileDir, + pruneAndSaveDurableParsedFileStore, +} from '../../src/storage/parsedfile-store.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; + +function writeFixture(root: string, relativePath: string, content: string): void { + const target = path.join(root, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +describe('Spring AOP, transaction, cache, and method-security pipeline (#2416)', () => { + let dir: string; + let result: PipelineResult; + let nodes: GraphNode[]; + let advisedBy: GraphRelationship[]; + let declarations: GraphRelationship[]; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-aop-')); + writeFixture( + dir, + 'src/main/java/com/example/service/OrderService.java', + `package com.example.service; + +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.security.access.annotation.Secured; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Transactional; + +public class OrderService { + @Transactional + public void transactionalOperation() {} + + @Cacheable("orders") + public String cachedOperation() { return "cached"; } + + @CacheEvict(cacheNames = "orders", allEntries = true) + public void evictOperation() {} + + @PreAuthorize("hasRole('ADMIN')") + public void securedOperation() {} + + @Secured("ROLE_AUDITOR") + public void legacySecuredOperation() {} + + public void plainOperation() {} +} +`, + ); + writeFixture( + dir, + 'src/main/java/com/example/service/SecuredOperations.java', + `package com.example.service; + +import org.springframework.security.access.prepost.PreAuthorize; + +public interface SecuredOperations { + @PreAuthorize("hasRole('OPERATOR')") + void interfaceSecuredOperation(); +} +`, + ); + writeFixture( + dir, + 'src/main/java/com/example/service/SecuredOperationsImpl.java', + `package com.example.service; + +public class SecuredOperationsImpl implements SecuredOperations { + @Override + public void interfaceSecuredOperation() {} +} +`, + ); + writeFixture( + dir, + 'src/main/java/com/example/service/ClassLevelService.java', + `package com.example.service; + +import org.springframework.transaction.annotation.Transactional; + +@Transactional +public class ClassLevelService { + public void inheritedTransaction() {} + private void privateHelper() {} + public static void staticHelper() {} +} +`, + ); + writeFixture( + dir, + 'src/main/java/com/example/service/TransactionalOperations.java', + `package com.example.service; + +import org.springframework.transaction.annotation.Transactional; + +@Transactional +public interface TransactionalOperations { + void interfaceInheritedTransaction(); +} +`, + ); + writeFixture( + dir, + 'src/main/java/com/example/service/TransactionalOperationsImpl.java', + `package com.example.service; + +public class TransactionalOperationsImpl implements TransactionalOperations { + @Override + public void interfaceInheritedTransaction() {} +} +`, + ); + writeFixture( + dir, + 'src/main/java/com/example/service/InheritedBehaviorService.java', + `package com.example.service; + +import org.springframework.transaction.annotation.Transactional; + +class InheritedBehaviorBase { + @Transactional + public void overriddenTransaction() {} +} + +public class InheritedBehaviorService extends InheritedBehaviorBase { + @Override + public void overriddenTransaction() {} +} +`, + ); + writeFixture( + dir, + 'src/main/java/com/example/aop/OrderAspect.java', + `package com.example.aop; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.After; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; + +@Aspect +public class OrderAspect { + @Around("execution(* com.example..OrderService.*(..))") + public Object traceOrderOperations(ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } + + @Before("@annotation(org.springframework.transaction.annotation.Transactional)") + public void transactionalAnnotationAdvice() {} + + @Before("within(*Service)") + public void simpleNameWithinAdvice() {} + + @Before("execution(* *Service.kotlinCachedOperation(..))") + public void simpleNameExecutionAdvice() {} + + @Before("execution(public * com.example.service.SecuredOperations.interfaceSecuredOperation(..))") + public void publicInterfaceAdvice() {} + + @Before("within(OrderService)") + public void unresolvedSimpleTypeAdvice() {} + + @AfterReturning( + pointcut = "execution(* com.example..OrderService.cachedOperation(..))", + returning = "result") + public void cachedReturnAdvice(Object result) {} + + @Before("namedOrderOperations()") + public void unresolvedNamedAdvice() {} + + @Before("") + public void emptyPointcutAdvice() {} + + @After("execution(* com.example..OrderService.*(..)) && args(orderId)") + public void unresolvedCompoundAdvice(String orderId) {} +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/aop/KotlinOrderAspect.kt', + `package com.example.aop + +import org.aspectj.lang.annotation.Aspect +import org.aspectj.lang.annotation.Before + +@Aspect +object KotlinOrderAspect { + @Before("within(com.example.service.KotlinOrderService)") + fun traceKotlinOperations() {} + + @Before("within(com.example.service.KotlinObjectService)") + fun traceKotlinObjectOperations() {} + + @Before("""execution(* com.example..KotlinOrderService.kotlinCachedOperation(..))""") + fun rawStringPointcutAdvice() {} +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/service/KotlinOrderService.kt', + `package com.example.service + +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.Cacheable +import org.springframework.cache.annotation.CachePut +import org.springframework.cache.annotation.Caching +import org.springframework.security.access.annotation.Secured +import org.springframework.security.access.prepost.PostAuthorize +import org.springframework.security.access.prepost.PostFilter +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.access.prepost.PreFilter +import org.springframework.transaction.annotation.Transactional + +class KotlinOrderService { + @Transactional + fun kotlinTransactionalOperation() {} + + @Cacheable("orders") + fun kotlinCachedOperation(): String = "cached" + + @CacheEvict(cacheNames = ["orders"], allEntries = true) + fun kotlinEvictOperation() {} + + @PreAuthorize("hasRole('ADMIN')") + fun kotlinSecuredOperation() {} + + @Secured("ROLE_AUDITOR") + fun kotlinLegacySecuredOperation() {} + + @CachePut("orders") + fun kotlinCachePutOperation() {} + + @Caching(cacheable = [Cacheable("orders")]) + fun kotlinCachingOperation() {} + + @PostAuthorize("returnObject != null") + fun kotlinPostAuthorizeOperation(): String = "ok" + + @PreFilter("filterObject != null") + fun kotlinPreFilterOperation(values: List) {} + + @PostFilter("filterObject != null") + fun kotlinPostFilterOperation(): List = emptyList() + + @jakarta.annotation.security.RolesAllowed("ADMIN") + fun kotlinJakartaRolesAllowedOperation() {} + + @javax.annotation.security.RolesAllowed("AUDITOR") + fun kotlinJavaxRolesAllowedOperation() {} + + @jakarta.transaction.Transactional + fun kotlinJakartaTransaction() {} + + @javax.transaction.Transactional + fun kotlinJavaxTransaction() {} + + @Transactional + suspend fun kotlinSuspendTransaction() {} + + @Transactional + fun String.kotlinExtensionTransaction() {} + + @org.springframework.transaction.annotation.Transactional + fun kotlinFullyQualifiedTransaction() {} + + @Transactional + private fun kotlinPrivateTransaction() {} +} + +@Transactional +fun kotlinTopLevelTransaction() {} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/service/KotlinObjectService.kt', + `package com.example.service + +import org.springframework.transaction.annotation.Transactional + +interface KotlinObjectContract { + fun kotlinObjectInheritedTransaction() + fun kotlinObjectExplicitTransaction() +} + +@Transactional +object KotlinObjectService : KotlinObjectContract { + override fun kotlinObjectInheritedTransaction() {} + + @Transactional + override fun kotlinObjectExplicitTransaction() {} + + private fun kotlinObjectPrivateHelper() {} +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/service/KotlinTransactionalOperations.kt', + `package com.example.service + +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.transaction.annotation.Transactional + +@Transactional +interface KotlinTransactionalOperations { + fun kotlinInterfaceInheritedTransaction() + + @PreAuthorize("hasRole('KOTLIN_OPERATOR')") + fun kotlinInterfaceSecuredOperation() +} + +interface KotlinMethodAnnotatedOperations { + @Transactional + fun kotlinInterfaceExplicitTransaction() +} + +class KotlinTransactionalOperationsImpl : KotlinTransactionalOperations { + override fun kotlinInterfaceInheritedTransaction() {} + override fun kotlinInterfaceSecuredOperation() {} +} + +class KotlinMethodAnnotatedOperationsImpl : KotlinMethodAnnotatedOperations { + override fun kotlinInterfaceExplicitTransaction() {} +} + +open class KotlinBehaviorBase { + @Transactional + open fun kotlinOverriddenTransaction() {} +} + +class KotlinBehaviorService : KotlinBehaviorBase() { + override fun kotlinOverriddenTransaction() {} +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/service/KotlinCompanionService.kt', + `package com.example.service + +import org.springframework.transaction.annotation.Transactional + +class KotlinCompanionService { + companion object { + @Transactional + fun kotlinCompanionTransaction() {} + + @receiver:Transactional + fun String.kotlinReceiverTargetTransaction() {} + } +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/service/KotlinAliasedService.kt', + `package com.example.service + +import org.springframework.transaction.annotation.Transactional as Tx + +class KotlinAliasedService { + @Tx + fun kotlinAliasedTransactionalOperation() {} + + fun kotlinAliasedPlainOperation() {} +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/aop/KotlinAliasedAspect.kt', + `package com.example.aop + +import org.aspectj.lang.annotation.Aspect as AopAspect +import org.aspectj.lang.annotation.Before as AdviceBefore + +@AopAspect +object KotlinAliasedAspect { + @AdviceBefore("@annotation(org.springframework.transaction.annotation.Transactional)") + fun aliasedTransactionalAdvice() {} +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/service/KotlinWildcardService.kt', + `package com.example.service + +import org.springframework.transaction.annotation.* + +class KotlinWildcardService { + @Transactional + fun kotlinWildcardTransaction() {} +} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/service/KotlinScriptService.kts', + `package com.example.service + +import org.springframework.transaction.annotation.Transactional + +class KotlinScriptService { + @Transactional + fun kotlinScriptTransaction() {} +} +`, + ); + writeFixture( + dir, + 'src/main/csharp/com/example/service/OrderService.cs', + `namespace com.example.service { + public class OrderService { + public void foreignOperation() {} + } +} +`, + ); + + result = await runPipelineFromRepo(dir, () => {}, { skipGraphPhases: false }); + nodes = [...result.graph.iterNodes()]; + advisedBy = [...result.graph.iterRelationshipsByType('ADVISED_BY')]; + declarations = [...result.graph.iterRelationshipsByType('DECLARES')]; + }, 60_000); + + afterAll(() => { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + + const nodeNamed = (name: string): GraphNode | undefined => + nodes.find((node) => node.properties.name === name); + + const relationshipTarget = (relationship: GraphRelationship): GraphNode | undefined => + result.graph.getNode(relationship.targetId); + + const methodNamedOn = (ownerName: string, methodName: string): GraphNode | undefined => { + const owner = nodeNamed(ownerName); + const ownership = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find( + (relationship) => + relationship.sourceId === owner?.id && + result.graph.getNode(relationship.targetId)?.properties.name === methodName, + ); + return ownership === undefined ? undefined : result.graph.getNode(ownership.targetId); + }; + + const declarativeAdviceForNode = (source: GraphNode | undefined): GraphRelationship[] => { + if (source === undefined) return []; + return advisedBy.filter( + (relationship) => + relationship.sourceId === source.id && + relationshipTarget(relationship)?.label === 'CodeElement', + ); + }; + + const declarativeAdviceFor = (name: string): GraphRelationship[] => + declarativeAdviceForNode(nodeNamed(name)); + + const behaviorSignaturesForNode = (node: GraphNode | undefined): string[] => + declarativeAdviceForNode(node) + .flatMap((relationship) => { + const reason = decodeSpringAopReason(relationship.reason); + return reason?.kind === 'behavior' ? [`${reason.annotation}:${reason.declaredOn}`] : []; + }) + .sort(); + + const behaviorSignaturesFor = (name: string): string[] => + behaviorSignaturesForNode(nodeNamed(name)); + + it('attaches Java and Kotlin declarative behavior as explicit ADVISED_BY evidence', () => { + const methodNames = [ + 'transactionalOperation', + 'cachedOperation', + 'evictOperation', + 'securedOperation', + 'legacySecuredOperation', + 'interfaceSecuredOperation', + 'kotlinTransactionalOperation', + 'kotlinCachedOperation', + 'kotlinEvictOperation', + 'kotlinSecuredOperation', + 'kotlinLegacySecuredOperation', + 'kotlinCachePutOperation', + 'kotlinCachingOperation', + 'kotlinPostAuthorizeOperation', + 'kotlinPreFilterOperation', + 'kotlinPostFilterOperation', + 'kotlinJakartaRolesAllowedOperation', + 'kotlinJavaxRolesAllowedOperation', + 'kotlinJakartaTransaction', + 'kotlinJavaxTransaction', + 'kotlinSuspendTransaction', + 'kotlinExtensionTransaction', + 'kotlinFullyQualifiedTransaction', + ]; + + for (const methodName of methodNames) { + const edges = declarativeAdviceFor(methodName); + expect(edges, `${methodName} should retain its declarative Spring behavior`).toHaveLength(1); + expect(decodeSpringAopReason(edges[0]?.reason)?.kind).toBe('behavior'); + expect(edges.map((edge) => relationshipTarget(edge)?.label)).toEqual(['CodeElement']); + } + }); + + it('stores synthetic evidence locations in the graph zero-based line convention', () => { + const evidence = relationshipTarget(declarativeAdviceFor('transactionalOperation')[0]!); + + // @Transactional is on source line 10 in OrderService.java. + expect(evidence?.properties.startLine).toBe(9); + expect(evidence?.properties.endLine).toBe(9); + }); + + it('fans class-level behavior out only to proxy-eligible methods', () => { + expect(declarativeAdviceFor('ClassLevelService')).toHaveLength(1); + expect(declarativeAdviceFor('inheritedTransaction')).toHaveLength(1); + expect(declarativeAdviceFor('privateHelper')).toHaveLength(0); + expect(declarativeAdviceFor('staticHelper')).toHaveLength(0); + expect(declarativeAdviceFor('TransactionalOperations')).toHaveLength(1); + expect(declarativeAdviceFor('interfaceInheritedTransaction')).toHaveLength(1); + }); + + it('captures Kotlin interface class fan-out and method-declared behaviors', () => { + expect(nodeNamed('KotlinTransactionalOperations')?.label).toBe('Interface'); + expect(nodeNamed('KotlinMethodAnnotatedOperations')?.label).toBe('Interface'); + + expect(behaviorSignaturesFor('KotlinTransactionalOperations')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:class', + ]); + expect(behaviorSignaturesFor('kotlinInterfaceInheritedTransaction')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:class', + ]); + expect(behaviorSignaturesFor('kotlinInterfaceSecuredOperation')).toEqual([ + 'org.springframework.security.access.prepost.PreAuthorize:method', + 'org.springframework.transaction.annotation.Transactional:class', + ]); + expect(behaviorSignaturesFor('kotlinInterfaceExplicitTransaction')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:method', + ]); + expect(behaviorSignaturesFor('kotlinPrivateTransaction')).toEqual([]); + expect(nodeNamed('kotlinTopLevelTransaction')?.label).toBe('Function'); + expect(behaviorSignaturesFor('kotlinTopLevelTransaction')).toEqual([]); + expect(behaviorSignaturesFor('kotlinWildcardTransaction')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:method', + ]); + expect(behaviorSignaturesFor('kotlinScriptTransaction')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:method', + ]); + }); + + it('covers the complete Kotlin cache, security, and transaction behavior matrix', () => { + const behaviorFor = (name: string): string | undefined => { + const [edge] = declarativeAdviceFor(name); + const reason = decodeSpringAopReason(edge?.reason); + return reason?.kind === 'behavior' ? reason.behavior : undefined; + }; + + expect({ + cachePut: behaviorFor('kotlinCachePutOperation'), + caching: behaviorFor('kotlinCachingOperation'), + postAuthorize: behaviorFor('kotlinPostAuthorizeOperation'), + preFilter: behaviorFor('kotlinPreFilterOperation'), + postFilter: behaviorFor('kotlinPostFilterOperation'), + jakartaRoles: behaviorFor('kotlinJakartaRolesAllowedOperation'), + javaxRoles: behaviorFor('kotlinJavaxRolesAllowedOperation'), + jakartaTransaction: behaviorFor('kotlinJakartaTransaction'), + javaxTransaction: behaviorFor('kotlinJavaxTransaction'), + }).toEqual({ + cachePut: 'cache-put', + caching: 'caching', + postAuthorize: 'authorization', + preFilter: 'authorization', + postFilter: 'authorization', + jakartaRoles: 'authorization', + javaxRoles: 'authorization', + jakartaTransaction: 'transactional', + javaxTransaction: 'transactional', + }); + }); + + it('propagates Java and Kotlin behavior through implementations and overrides', () => { + expect( + behaviorSignaturesForNode( + methodNamedOn('SecuredOperationsImpl', 'interfaceSecuredOperation'), + ), + ).toEqual(['org.springframework.security.access.prepost.PreAuthorize:method']); + expect( + behaviorSignaturesForNode( + methodNamedOn('TransactionalOperationsImpl', 'interfaceInheritedTransaction'), + ), + ).toEqual(['org.springframework.transaction.annotation.Transactional:class']); + expect( + behaviorSignaturesForNode(methodNamedOn('InheritedBehaviorService', 'overriddenTransaction')), + ).toEqual(['org.springframework.transaction.annotation.Transactional:method']); + expect( + behaviorSignaturesForNode( + methodNamedOn('KotlinTransactionalOperationsImpl', 'kotlinInterfaceSecuredOperation'), + ), + ).toEqual([ + 'org.springframework.security.access.prepost.PreAuthorize:method', + 'org.springframework.transaction.annotation.Transactional:class', + ]); + expect( + behaviorSignaturesForNode( + methodNamedOn('KotlinMethodAnnotatedOperationsImpl', 'kotlinInterfaceExplicitTransaction'), + ), + ).toEqual(['org.springframework.transaction.annotation.Transactional:method']); + expect( + behaviorSignaturesForNode( + methodNamedOn('KotlinBehaviorService', 'kotlinOverriddenTransaction'), + ), + ).toEqual(['org.springframework.transaction.annotation.Transactional:method']); + }); + + it('captures Kotlin companion methods as singleton behavior and fails closed on use-site targets', () => { + expect(behaviorSignaturesFor('kotlinCompanionTransaction')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:method', + ]); + expect(behaviorSignaturesFor('kotlinReceiverTargetTransaction')).toEqual([]); + expect(behaviorSignaturesFor('kotlinAliasedPlainOperation')).toEqual([]); + }); + + it('matches execution(public ...) against an implicit-public Java interface method', () => { + const advice = nodeNamed('publicInterfaceAdvice'); + const targets = advisedBy + .filter((relationship) => relationship.targetId === advice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name); + expect(targets).toEqual(['interfaceSecuredOperation']); + }); + + it('treats Kotlin object members as singleton instance methods for AOP', () => { + expect(behaviorSignaturesFor('KotlinObjectService')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:class', + ]); + expect( + behaviorSignaturesForNode( + methodNamedOn('KotlinObjectService', 'kotlinObjectInheritedTransaction'), + ), + ).toEqual(['org.springframework.transaction.annotation.Transactional:class']); + expect( + behaviorSignaturesForNode( + methodNamedOn('KotlinObjectService', 'kotlinObjectExplicitTransaction'), + ), + ).toEqual([ + 'org.springframework.transaction.annotation.Transactional:class', + 'org.springframework.transaction.annotation.Transactional:method', + ]); + expect(behaviorSignaturesFor('kotlinObjectPrivateHelper')).toEqual([]); + + const objectAdvice = nodeNamed('traceKotlinObjectOperations'); + const advisedMethods = advisedBy + .filter((relationship) => relationship.targetId === objectAdvice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name) + .sort(); + expect(advisedMethods).toEqual([ + 'kotlinObjectExplicitTransaction', + 'kotlinObjectInheritedTransaction', + ]); + }); + + it('resolves Kotlin aliases for behaviors, aspects, and advice annotations', () => { + expect(behaviorSignaturesFor('kotlinAliasedTransactionalOperation')).toEqual([ + 'org.springframework.transaction.annotation.Transactional:method', + ]); + + const aliasedAspect = nodeNamed('KotlinAliasedAspect'); + const aspectMarker = declarations.find((relationship) => { + const reason = decodeSpringAopReason(relationship.reason); + return relationship.sourceId === aliasedAspect?.id && reason?.kind === 'aspect'; + }); + expect(aspectMarker).toBeDefined(); + + const advice = nodeNamed('aliasedTransactionalAdvice'); + const advisedMethods = advisedBy + .filter((relationship) => relationship.targetId === advice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name) + .sort(); + expect(advisedMethods).toEqual([ + 'kotlinAliasedTransactionalOperation', + 'kotlinCompanionTransaction', + 'kotlinExtensionTransaction', + 'kotlinFullyQualifiedTransaction', + 'kotlinInterfaceExplicitTransaction', + 'kotlinObjectExplicitTransaction', + 'kotlinOverriddenTransaction', + 'kotlinScriptTransaction', + 'kotlinSuspendTransaction', + 'kotlinTransactionalOperation', + 'kotlinWildcardTransaction', + 'overriddenTransaction', + 'transactionalOperation', + ]); + }); + + it('connects a statically understandable execution pointcut to every matching method', () => { + const advice = nodeNamed('traceOrderOperations'); + expect(advice?.label).toBe('Method'); + + const advisedMethods = advisedBy + .filter((relationship) => relationship.targetId === advice?.id) + .map((relationship) => String(result.graph.getNode(relationship.sourceId)?.properties.name)) + .sort(); + + expect(advisedMethods).toEqual([ + 'cachedOperation', + 'evictOperation', + 'legacySecuredOperation', + 'plainOperation', + 'securedOperation', + 'transactionalOperation', + ]); + + const foreignMethod = nodeNamed('foreignOperation'); + const foreignOwnership = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find( + (relationship) => relationship.targetId === foreignMethod?.id, + ); + expect(foreignMethod?.label).toBe('Method'); + expect(result.graph.getNode(foreignOwnership?.sourceId ?? '')?.properties.qualifiedName).toBe( + 'com.example.service.OrderService', + ); + expect(advisedBy.some((relationship) => relationship.sourceId === foreignMethod?.id)).toBe( + false, + ); + + const kotlinAdvice = nodeNamed('traceKotlinOperations'); + const kotlinAdvisedMethods = advisedBy + .filter((relationship) => relationship.targetId === kotlinAdvice?.id) + .map((relationship) => String(result.graph.getNode(relationship.sourceId)?.properties.name)) + .sort(); + expect(kotlinAdvisedMethods).toEqual([ + 'kotlinCachePutOperation', + 'kotlinCachedOperation', + 'kotlinCachingOperation', + 'kotlinEvictOperation', + 'kotlinExtensionTransaction', + 'kotlinFullyQualifiedTransaction', + 'kotlinJakartaRolesAllowedOperation', + 'kotlinJakartaTransaction', + 'kotlinJavaxRolesAllowedOperation', + 'kotlinJavaxTransaction', + 'kotlinLegacySecuredOperation', + 'kotlinPostAuthorizeOperation', + 'kotlinPostFilterOperation', + 'kotlinPreFilterOperation', + 'kotlinSecuredOperation', + 'kotlinSuspendTransaction', + 'kotlinTransactionalOperation', + ]); + }); + + it('matches unqualified wildcard type patterns by owner simple name', () => { + const withinAdvice = nodeNamed('simpleNameWithinAdvice'); + const withinTargets = advisedBy + .filter((relationship) => relationship.targetId === withinAdvice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name); + expect(withinTargets).toContain('plainOperation'); + expect(withinTargets).toContain('kotlinCachedOperation'); + expect(withinTargets).not.toContain('kotlinInterfaceInheritedTransaction'); + + const executionAdvice = nodeNamed('simpleNameExecutionAdvice'); + const executionTargets = advisedBy + .filter((relationship) => relationship.targetId === executionAdvice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name); + expect(executionTargets).toEqual(['kotlinCachedOperation']); + }); + + it('@annotation matches only directly declared method annotations across Java and Kotlin', () => { + const transactionalAdvice = nodeNamed('transactionalAnnotationAdvice'); + + const advisedByTransactionalAnnotation = advisedBy + .filter((relationship) => relationship.targetId === transactionalAdvice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name) + .sort(); + expect(advisedByTransactionalAnnotation).toEqual([ + 'kotlinAliasedTransactionalOperation', + 'kotlinCompanionTransaction', + 'kotlinExtensionTransaction', + 'kotlinFullyQualifiedTransaction', + 'kotlinInterfaceExplicitTransaction', + 'kotlinObjectExplicitTransaction', + 'kotlinOverriddenTransaction', + 'kotlinScriptTransaction', + 'kotlinSuspendTransaction', + 'kotlinTransactionalOperation', + 'kotlinWildcardTransaction', + 'overriddenTransaction', + 'transactionalOperation', + ]); + expect(advisedByTransactionalAnnotation).not.toContain('kotlinInterfaceInheritedTransaction'); + }); + + it('supports pointcuts with companion annotation attributes', () => { + const cachedReturnAdvice = nodeNamed('cachedReturnAdvice'); + + const advisedByCachedReturn = advisedBy + .filter((relationship) => relationship.targetId === cachedReturnAdvice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name); + expect(advisedByCachedReturn).toEqual(['cachedOperation']); + }); + + it('resolves Kotlin raw-string advice pointcuts', () => { + const rawStringAdvice = nodeNamed('rawStringPointcutAdvice'); + const advisedMethods = advisedBy + .filter((relationship) => relationship.targetId === rawStringAdvice?.id) + .map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name); + + expect(advisedMethods).toEqual(['kotlinCachedOperation']); + }); + + it('retains the Aspect declaration without assuming bean registration', () => { + for (const aspectName of ['OrderAspect', 'KotlinOrderAspect']) { + const aspect = nodeNamed(aspectName); + const marker = declarations.find((relationship) => { + const reason = decodeSpringAopReason(relationship.reason); + return relationship.sourceId === aspect?.id && reason?.kind === 'aspect'; + }); + + expect(marker, `${aspectName} should retain its Aspect marker`).toBeDefined(); + expect(decodeSpringAopReason(marker?.reason)).toMatchObject({ + kind: 'aspect', + activation: 'unknown', + registration: 'unknown', + }); + } + }); + + it('preserves unknown pointcuts as evidence without guessing advised targets', () => { + for (const adviceName of [ + 'unresolvedNamedAdvice', + 'emptyPointcutAdvice', + 'unresolvedCompoundAdvice', + 'unresolvedSimpleTypeAdvice', + ]) { + const advice = nodeNamed(adviceName); + expect(advice?.label).toBe('Method'); + expect(advisedBy.some((relationship) => relationship.targetId === advice?.id)).toBe(false); + + const evidence = declarations.filter((relationship) => { + const reason = decodeSpringAopReason(relationship.reason); + return ( + relationship.sourceId === advice?.id && + relationshipTarget(relationship)?.label === 'CodeElement' && + reason?.kind === 'pointcut' && + reason.match === 'unresolved' && + reason.resolution === 'unknown' + ); + }); + expect(evidence, `${adviceName} should retain conservative pointcut evidence`).toHaveLength( + 1, + ); + } + }); +}); + +describe('Spring AOP durable warm parse cache (#2416)', () => { + it('replays identical Java/Kotlin ADVISED_BY edges without spawning workers', async () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-aop-warm-')); + const repo = path.join(temp, 'repo'); + const storage = path.join(temp, 'storage'); + try { + writeFixture( + repo, + 'src/main/java/com/example/JavaService.java', + `package com.example; + +import org.springframework.transaction.annotation.Transactional; + +public class JavaService { + @Transactional + public void javaTransaction() {} +} +`, + ); + writeFixture( + repo, + 'src/main/kotlin/com/example/KotlinAop.kt', + `package com.example + +import org.aspectj.lang.annotation.Aspect as AopAspect +import org.aspectj.lang.annotation.Before as AdviceBefore +import org.springframework.transaction.annotation.Transactional as Tx + +class KotlinService { + @Tx + fun kotlinTransaction() {} +} + +class KotlinCompanionHolder { + companion object { + @Tx + fun companionTransaction() {} + } +} + +@AopAspect +object KotlinAspect { + @AdviceBefore("""@annotation(org.springframework.transaction.annotation.Transactional)""") + fun beforeTransaction() {} +} +`, + ); + + const coldCache: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map(), + usedKeys: new Set(), + storagePath: storage, + onDiskKeys: new Set(), + }; + const cold = await runPipelineFromRepo(repo, () => {}, { + skipGraphPhases: true, + workerPoolSize: 1, + parseCache: coldCache, + }); + expect(cold.usedWorkerPool).toBe(true); + + pruneCache(coldCache, coldCache.usedKeys); + const savedKeys = await saveParseCache(storage, coldCache); + expect(savedKeys.length).toBeGreaterThan(0); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(storage), + PARSE_CACHE_VERSION, + new Set(savedKeys), + ); + + const warmCache = await loadParseCache(storage); + expect(warmCache.onDiskKeys).toEqual(new Set(savedKeys)); + const warm = await runPipelineFromRepo(repo, () => {}, { + skipGraphPhases: true, + workerPoolSize: 1, + parseCache: warmCache, + }); + expect(warm.usedWorkerPool).toBe(false); + + const project = (pipeline: PipelineResult) => + [...pipeline.graph.iterRelationshipsByType('ADVISED_BY')] + .flatMap((edge) => { + const reason = decodeSpringAopReason(edge.reason); + if (reason?.kind !== 'behavior' && reason?.kind !== 'advice') return []; + const source = pipeline.graph.getNode(edge.sourceId); + const target = pipeline.graph.getNode(edge.targetId); + return [ + { + id: edge.id, + sourceId: edge.sourceId, + targetId: edge.targetId, + confidence: edge.confidence, + sourceName: source?.properties.name, + sourceFilePath: source?.properties.filePath, + targetName: target?.properties.name, + targetFilePath: target?.properties.filePath, + reason, + }, + ]; + }) + .sort((left, right) => left.id.localeCompare(right.id)); + + const coldEdges = project(cold); + expect(project(warm)).toEqual(coldEdges); + expect( + coldEdges + .map((edge) => `${edge.reason.kind}:${edge.sourceName}->${edge.targetName}`) + .sort(), + ).toEqual([ + 'advice:companionTransaction->beforeTransaction', + 'advice:javaTransaction->beforeTransaction', + 'advice:kotlinTransaction->beforeTransaction', + 'behavior:companionTransaction->@Transactional', + 'behavior:javaTransaction->@Transactional', + 'behavior:kotlinTransaction->@Transactional', + ]); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/gitnexus/test/unit/analysis-features.test.ts b/gitnexus/test/unit/analysis-features.test.ts index 2f537867d..5e77834f4 100644 --- a/gitnexus/test/unit/analysis-features.test.ts +++ b/gitnexus/test/unit/analysis-features.test.ts @@ -6,6 +6,7 @@ import { type AnalysisFeatureDescriptor, } from '../../src/core/analysis-features.js'; import { + SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; @@ -13,6 +14,7 @@ import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languag const FEATURES = [ CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, + SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, @@ -25,12 +27,14 @@ describe('analysis feature versions', () => { }); expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({ 'graph.class-framework-annotations': 1, + 'spring.aop-advice': 1, 'spring.bean-inventory': 2, 'spring.conditionals-auto-configuration': 1, 'spring.config-bindings': 1, }); expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({ 'graph.class-framework-annotations': 1, + 'spring.aop-advice': 1, 'spring.bean-inventory': 2, 'spring.conditionals-auto-configuration': 1, }); diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index bd35a8032..95bc624d6 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 32 (Rust/Swift/JS-TS member-containment DDL, #2769)', () => { + it('INCREMENTAL_SCHEMA_VERSION is bumped to 33 (Spring AOP relation pairs, #2416)', () => { // Moves with every bump BY DESIGN — that is the point of pinning it. A // change that alters emitted ids or edges without bumping would otherwise // ship silently, and an existing index would keep serving the old graph // through the reuse gate below. - expect(INCREMENTAL_SCHEMA_VERSION).toBe(32); + expect(INCREMENTAL_SCHEMA_VERSION).toBe(33); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { @@ -213,7 +213,10 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // top-up emitting one of those edges would fail the bulk COPY (or silently // drop it on the streamed path) → must NOT reuse. expect(passesReuseGate(31)).toBe(false); + // A pre-v33 (v32) index predates the Spring AOP Interface→CodeElement + // relation pair (#2416), so it cannot persist all evidence edges. + expect(passesReuseGate(32)).toBe(false); // The current stamp passes the gate (incremental top-up eligible). - expect(passesReuseGate(32)).toBe(true); + expect(passesReuseGate(33)).toBe(true); }); }); diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index d3773e51f..36088d387 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -44,9 +44,14 @@ import { } from '../helpers/embedding-seed.js'; import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js'; import { + SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; +import { + decodeSpringAopReason, + SPRING_AOP_EVIDENCE_ID_PREFIX, +} from '../../src/core/ingestion/frameworks/spring/aop.js'; import { SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX } from '../../src/core/ingestion/frameworks/spring/auto-configuration.js'; import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languages/java/analysis-features.js'; @@ -107,6 +112,55 @@ async function setupKotlinSpringBeanIncrementalRepo() { return repo; } +const springAopAspectSource = (pointcut: string): string => + `package com.example;\n` + + `import org.aspectj.lang.annotation.Aspect;\n` + + `import org.aspectj.lang.annotation.Before;\n\n` + + `@Aspect public class TraceAspect {\n` + + ` @Before("${pointcut}") public void trace() {}\n` + + `}\n`; + +async function setupSpringAopIncrementalRepo() { + const repo = await createTempDir('gitnexus-incr-spring-aop-'); + const javaSrc = path.join(repo.dbPath, 'src', 'main', 'java', 'com', 'example'); + const kotlinSrc = path.join(repo.dbPath, 'src', 'main', 'kotlin', 'com', 'example'); + const resources = path.join(repo.dbPath, 'src', 'main', 'resources'); + await Promise.all([ + mkdir(javaSrc, { recursive: true }), + mkdir(kotlinSrc, { recursive: true }), + mkdir(resources, { recursive: true }), + ]); + await Promise.all([ + writeFile(path.join(repo.dbPath, '.gitignore'), '.gitnexus/\n', 'utf-8'), + writeFile( + path.join(javaSrc, 'FirstService.java'), + 'package com.example;\n\n' + + 'public class FirstService {\n' + + ' public void first() {}\n' + + '}\n', + 'utf-8', + ), + writeFile( + path.join(javaSrc, 'TraceAspect.java'), + springAopAspectSource('within(com.example.FirstService)'), + 'utf-8', + ), + writeFile( + path.join(kotlinSrc, 'KotlinService.kt'), + 'package com.example\n\n' + + 'import org.springframework.transaction.annotation.Transactional as Tx\n\n' + + 'class KotlinService {\n' + + ' @Tx fun kotlinTx() {}\n' + + '}\n', + 'utf-8', + ), + writeFile(path.join(resources, 'application.properties'), 'feature.enabled=true\n', 'utf-8'), + ]); + execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' }); + gitCommitAll(repo.dbPath, 'initial spring aop fixture'); + return repo; +} + async function setupSpringBeanFactoryIncrementalRepo() { const repo = await createTempDir('gitnexus-incr-spring-bean-factory-'); const src = path.join(repo.dbPath, 'src', 'com', 'other'); @@ -236,6 +290,81 @@ async function countSpringAutoConfigurationSyntheticClasses(repoPath: string): P } } +interface SpringAopPersistedRelationship { + readonly relType: string; + readonly sourceId: string; + readonly sourceName: string; + readonly targetId: string; + readonly targetName: string; + readonly reason: string; +} + +interface SpringAopPersistedEvidence { + readonly id: string; + readonly description: string; +} + +interface SpringAopPersistedSnapshot { + readonly relationships: readonly SpringAopPersistedRelationship[]; + readonly evidence: readonly SpringAopPersistedEvidence[]; +} + +async function readSpringAopSnapshot(repoPath: string): Promise { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const { lbugPath } = getStoragePaths(repoPath); + await adapter.initLbug(lbugPath); + try { + const relationships = (await adapter.executeQuery( + `MATCH (s)-[r:CodeRelation]->(t) ` + + `WHERE (r.type = 'ADVISED_BY' OR r.type = 'DECLARES') ` + + `AND r.reason STARTS WITH 'spring-aop:v1:' ` + + `RETURN r.type AS relType, s.id AS sourceId, s.name AS sourceName, ` + + `t.id AS targetId, t.name AS targetName, r.reason AS reason ` + + `ORDER BY relType, sourceId, targetId, reason`, + )) as SpringAopPersistedRelationship[]; + const evidence = (await adapter.executeQuery( + `MATCH (n:CodeElement) WHERE n.id STARTS WITH '${SPRING_AOP_EVIDENCE_ID_PREFIX}' ` + + `RETURN n.id AS id, n.description AS description ORDER BY id`, + )) as SpringAopPersistedEvidence[]; + return { relationships, evidence }; + } finally { + await adapter.closeLbug(); + } +} + +function assertSpringAopSnapshotShape( + snapshot: SpringAopPersistedSnapshot, + expectedAdviceSource: string, +): void { + expect(snapshot.relationships).toHaveLength(4); + expect(snapshot.evidence).toHaveLength(3); + const decoded = snapshot.relationships.map((relationship) => ({ + relationship, + reason: decodeSpringAopReason(relationship.reason), + })); + expect(decoded.map(({ reason }) => reason?.kind).sort()).toEqual([ + 'advice', + 'aspect', + 'behavior', + 'pointcut', + ]); + expect(decoded.find(({ reason }) => reason?.kind === 'behavior')?.relationship.sourceName).toBe( + 'kotlinTx', + ); + const advice = decoded.find(({ reason }) => reason?.kind === 'advice')?.relationship; + expect(advice?.sourceName).toBe(expectedAdviceSource); + expect(advice?.targetName).toBe('trace'); + expect( + new Set( + snapshot.relationships.map( + (relationship) => + `${relationship.relType}\0${relationship.sourceId}\0${relationship.targetId}\0${relationship.reason}`, + ), + ).size, + ).toBe(snapshot.relationships.length); + expect(new Set(snapshot.evidence.map(({ id }) => id)).size).toBe(snapshot.evidence.length); +} + /** Java DI fixture (#2200): `@Autowired List` + 2 implementers ⇒ exactly * 2 INJECTS edges (Consumer→FooA, Consumer→FooB). Same shapes as the * spring-di-pipeline integration fixture. */ @@ -345,6 +474,7 @@ describe('runFullAnalysis — incremental orchestration', () => { const meta = await loadMeta(storagePath); expect(meta!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, + [SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, }); @@ -362,6 +492,7 @@ describe('runFullAnalysis — incremental orchestration', () => { expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([SPRING_SERVICE]); expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, + [SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, }); @@ -430,6 +561,7 @@ describe('runFullAnalysis — incremental orchestration', () => { expect(logs.join('\n')).not.toContain('Incremental:'); expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, + [SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, }); @@ -438,6 +570,81 @@ describe('runFullAnalysis — incremental orchestration', () => { } }, 300_000); + it('replaces Spring AOP evidence across real incremental runs without duplicates', async () => { + const repo = await setupSpringAopIncrementalRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + const firstSnapshot = await readSpringAopSnapshot(repo.dbPath); + assertSpringAopSnapshotShape(firstSnapshot, 'first'); + const firstPointcutEvidenceId = firstSnapshot.relationships.find( + ({ reason }) => decodeSpringAopReason(reason)?.kind === 'pointcut', + )?.targetId; + expect(firstPointcutEvidenceId).toBeDefined(); + + const aspectPath = path.join( + repo.dbPath, + 'src', + 'main', + 'java', + 'com', + 'example', + 'TraceAspect.java', + ); + await writeFile( + aspectPath, + springAopAspectSource( + '@annotation(org.springframework.transaction.annotation.Transactional)', + ), + 'utf-8', + ); + gitCommitAll(repo.dbPath, 'retarget spring advice'); + const retargetLogs: string[] = []; + const retargeted = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {}, onLog: (message) => retargetLogs.push(message) }, + ); + expect(retargeted.alreadyUpToDate).toBeUndefined(); + expect(retargetLogs.join('\n')).toContain('Incremental: changed=1'); + expect(retargetLogs.join('\n')).not.toContain('switching to a full DB write'); + + const secondSnapshot = await readSpringAopSnapshot(repo.dbPath); + assertSpringAopSnapshotShape(secondSnapshot, 'kotlinTx'); + const secondPointcutEvidenceId = secondSnapshot.relationships.find( + ({ reason }) => decodeSpringAopReason(reason)?.kind === 'pointcut', + )?.targetId; + expect(secondPointcutEvidenceId).toBeDefined(); + expect(secondPointcutEvidenceId).not.toBe(firstPointcutEvidenceId); + expect(secondSnapshot.evidence.map(({ id }) => id)).not.toContain(firstPointcutEvidenceId); + + const propertyPath = path.join( + repo.dbPath, + 'src', + 'main', + 'resources', + 'application.properties', + ); + await writeFile(propertyPath, 'feature.enabled=false\n', 'utf-8'); + gitCommitAll(repo.dbPath, 'change unrelated resource'); + const replayLogs: string[] = []; + const replayed = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { + onLog: (message) => replayLogs.push(message), + onProgress: () => {}, + }, + ); + expect(replayed.alreadyUpToDate).toBeUndefined(); + expect(replayLogs.join('\n')).toContain('Incremental: changed=1'); + expect(replayLogs.join('\n')).not.toContain('switching to a full DB write'); + expect(await readSpringAopSnapshot(repo.dbPath)).toEqual(secondSnapshot); + } finally { + await repo.cleanup(); + } + }, 600_000); + it('second run after a comment-only edit takes the incremental path, clears the dirty flag, and preserves graph stats exactly', async () => { const repo = await setupMiniRepo(); try { diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index c9560f43d..6d70a26fb 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -101,11 +101,9 @@ describe('fileContentHash', () => { }); describe('PARSE_CACHE_VERSION', () => { - // 35 -> 36 for the bound-callable start-line join (#2735). Updated - // deliberately after main independently took 35 for Spring side-channel - // captures (#2413), so the pin continues to catch concurrent bump collisions. - it('pins SCHEMA_BUMP to 36 so concurrent bumps cannot silently collide (#2736)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(36); + // 36 -> 37 for Java/Kotlin Spring AOP capture side-channels (#2416). + it('pins SCHEMA_BUMP to 37 so concurrent bumps cannot silently collide (#2416)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(37); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { diff --git a/gitnexus/test/unit/incremental-subgraph-extract.test.ts b/gitnexus/test/unit/incremental-subgraph-extract.test.ts index b46e86ba4..4841bba3a 100644 --- a/gitnexus/test/unit/incremental-subgraph-extract.test.ts +++ b/gitnexus/test/unit/incremental-subgraph-extract.test.ts @@ -156,6 +156,20 @@ describe('extractChangedSubgraph', () => { expect(sub.relationships.map((r) => r.id)).toEqual(['inj1']); }); + it('always includes ADVISED_BY edges even between two unchanged files (#2416)', () => { + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('service:Method', '/repo/Service.java', 'Method')); + g.addNode(makeFileNode('aspect:Method', '/repo/Aspect.java', 'Method')); + g.addRelationship( + makeRel('advised1', 'service:Method', 'aspect:Method', 'ADVISED_BY', 'spring-aop:v1:{}'), + ); + g.addRelationship(makeRel('call1', 'service:Method', 'aspect:Method', 'CALLS')); + + const sub = extractChangedSubgraph(g, new Set(['/repo/AnnotationShadow.java'])); + + expect(sub.relationships.map((relationship) => relationship.id)).toEqual(['advised1']); + }); + it('always includes Spring DECLARES edges between unchanged metadata and classes (#2415)', () => { const g = createKnowledgeGraph(); g.addNode(makeFileNode('metadata:File', '/repo/META-INF/spring.factories', 'File')); @@ -191,6 +205,22 @@ describe('extractChangedSubgraph', () => { }); describe('computeEffectiveWriteSet (Finding 1)', () => { + it('does not expand through graph-wide ADVISED_BY edges rebuilt by their owner phase', () => { + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('aspect:advice', '/repo/Aspect.java', 'Method')); + for (let index = 0; index < 100; index += 1) { + const serviceId = `service:${index}`; + g.addNode(makeFileNode(serviceId, `/repo/Service${index}.java`, 'Method')); + g.addRelationship( + makeRel(`advised:${index}`, serviceId, 'aspect:advice', 'ADVISED_BY', 'spring-aop:v1:{}'), + ); + } + + const effective = computeEffectiveWriteSet(g, new Set(['/repo/Aspect.java'])); + + expect([...effective]).toEqual(['/repo/Aspect.java']); + }); + it('barrel re-export — expands the writable set to the consumer file', () => { // Scenario: file C (a barrel) used to re-export from B; now re-exports // from D. File A is unchanged byte-wise but its CALLS to foo() now diff --git a/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts b/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts index 9e204631f..5b8c446f9 100644 --- a/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts +++ b/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts @@ -76,15 +76,22 @@ const FULL_ORDER = [ 'crossFile', 'scopeResolution', 'springAutoConfiguration', + 'springAop', 'pruneLocalSymbols', 'mro', + 'springAopInheritance', 'di', 'communities', 'processes', ]; const WITHOUT_GRAPH_PHASES = FULL_ORDER.filter( - (n) => n !== 'mro' && n !== 'di' && n !== 'communities' && n !== 'processes', + (n) => + n !== 'mro' && + n !== 'springAopInheritance' && + n !== 'di' && + n !== 'communities' && + n !== 'processes', ); describe('buildPhaseList parity (registry refactor, #2080)', () => { diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index b4c2cd04c..ae02ce2c1 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -116,6 +116,7 @@ describe('LadybugDB Schema', () => { it('includes Spring condition and auto-configuration edge types (#2415)', () => { expect(REL_TYPES).toContain('CONDITIONAL_ON'); expect(REL_TYPES).toContain('DECLARES'); + expect(REL_TYPES).toContain('ADVISED_BY'); expect(REL_TYPES).not.toContain('AUTO_REGISTERS'); }); }); @@ -209,8 +210,9 @@ describe('LadybugDB Schema', () => { expect(RELATION_SCHEMA).toContain('FROM BasicBlock TO BasicBlock'); }); - it('persists consumer Class injection edges to synthetic provider declarations', () => { + it('persists class-like framework edges to synthetic declarations (#2416)', () => { expect(RELATION_SCHEMA).toContain('FROM Class TO CodeElement'); + expect(RELATION_SCHEMA).toContain('FROM Interface TO CodeElement'); }); it('declares the Swift enum/property member-containment pairs (#2769)', () => { diff --git a/gitnexus/test/unit/security.test.ts b/gitnexus/test/unit/security.test.ts index 581f1b18b..bd79ae010 100644 --- a/gitnexus/test/unit/security.test.ts +++ b/gitnexus/test/unit/security.test.ts @@ -42,6 +42,8 @@ describe('VALID_RELATION_TYPES', () => { // Conditional activation and metadata declaration/discovery (#2415) 'CONDITIONAL_ON', 'DECLARES', + // Spring proxy/advice evidence (#2416) + 'ADVISED_BY', ] as const; it('contains all expected relation types', () => { diff --git a/gitnexus/test/unit/spring-annotation-arguments.test.ts b/gitnexus/test/unit/spring-annotation-arguments.test.ts index 9304aa4d3..7e6107e59 100644 --- a/gitnexus/test/unit/spring-annotation-arguments.test.ts +++ b/gitnexus/test/unit/spring-annotation-arguments.test.ts @@ -3,6 +3,7 @@ import { normalizeSpringBeanType, parseSpringAnnotationArguments, parseStaticClassLiteral, + parseStaticStringLiteral, parseStaticStringValues, } from '../../src/core/ingestion/frameworks/spring/annotation-arguments.js'; import { @@ -45,6 +46,31 @@ describe('Spring annotation static arguments', () => { expect(parseStaticClassLiteral('Object.class')).toBe(''); }); + it('parses static Kotlin raw strings and rejects raw string templates', () => { + expect(parseStaticStringLiteral('"""within(com.example.service.KotlinOrderService)"""')).toBe( + 'within(com.example.service.KotlinOrderService)', + ); + expect(parseStaticStringValues('["one", """two, three"""]')).toEqual(['one', 'two, three']); + + expect(parseStaticStringLiteral('"""bean-$name"""')).toBeNull(); + expect(parseStaticStringLiteral('"""bean-${expression}"""')).toBeNull(); + expect(parseStaticStringValues('["""static""", """$name"""]')).toBeNull(); + }); + + it('keeps commas, assignments, and ordinary quotes inside Kotlin raw arguments', () => { + expect( + parseSpringAnnotationArguments( + '@AfterReturning(pointcut = """execution(* com.example.Service.run(*,*)) && args("quoted=value")""", returning = "result")', + ), + ).toEqual([ + { + name: 'pointcut', + value: '"""execution(* com.example.Service.run(*,*)) && args("quoted=value")"""', + }, + { name: 'returning', value: '"result"' }, + ]); + }); + it('normalizes Kotlin nullability, mutable collections, projections, and bean generics', () => { expect(normalizeSpringBeanType('MutableList?')).toBe('List'); expect(normalizeSpringBeanType('com.example.Gateway')).toBe('com.example.Gateway'); diff --git a/gitnexus/test/unit/spring-aop.test.ts b/gitnexus/test/unit/spring-aop.test.ts new file mode 100644 index 000000000..93e42436c --- /dev/null +++ b/gitnexus/test/unit/spring-aop.test.ts @@ -0,0 +1,372 @@ +import type { GraphNode } from 'gitnexus-shared'; +import { describe, expect, it } from 'vitest'; +import { + decodeSpringAopReason, + encodeSpringAopReason, + isSpringAopEvidenceNode, + parseSpringAopPointcut, + springAopPointcutMatches, + type SpringAopReason, +} from '../../src/core/ingestion/frameworks/spring/aop.js'; +import { + createSpringAopCandidateIndex, + type SpringAopOwnedMethod, +} from '../../src/core/ingestion/frameworks/spring/aop-candidates.js'; + +const owner: GraphNode = { + id: 'Class:com.example.service.OrderService', + label: 'Class', + properties: { + name: 'OrderService', + qualifiedName: 'com.example.service.OrderService', + filePath: 'src/OrderService.java', + startLine: 1, + endLine: 20, + isExported: true, + }, +}; + +const method: GraphNode = { + id: 'Method:com.example.service.OrderService.run', + label: 'Method', + properties: { + name: 'run', + qualifiedName: 'com.example.service.OrderService.run', + filePath: 'src/OrderService.java', + startLine: 3, + endLine: 5, + isExported: true, + visibility: 'public', + parameterCount: 1, + }, +}; + +describe('Spring AOP static pointcuts (#2416)', () => { + it('parses the deliberately narrow execution, within, and known @annotation subset', () => { + expect(parseSpringAopPointcut('execution(public * com.example..OrderService.r*( * ))')).toEqual( + { + kind: 'execution', + ownerPattern: 'com.example..OrderService', + methodPattern: 'r*', + visibility: 'public', + parameterCount: 1, + }, + ); + expect(parseSpringAopPointcut('within(com.example..service.*)')).toEqual({ + kind: 'within', + ownerPattern: 'com.example..service.*', + }); + expect( + parseSpringAopPointcut( + '@annotation(org.springframework.transaction.annotation.Transactional)', + ), + ).toEqual({ + kind: 'annotation', + annotation: 'org.springframework.transaction.annotation.Transactional', + }); + }); + + it('matches owner, method, visibility, arity, and resolved method annotations', () => { + const execution = parseSpringAopPointcut('execution(public * com.example..OrderService.r*(*))'); + const within = parseSpringAopPointcut('within(com.example..OrderService)'); + const annotation = parseSpringAopPointcut( + '@annotation(org.springframework.transaction.annotation.Transactional)', + ); + + expect(execution && springAopPointcutMatches(execution, owner, method)).toBe(true); + expect(within && springAopPointcutMatches(within, owner, method)).toBe(true); + expect( + annotation && + springAopPointcutMatches( + annotation, + owner, + method, + new Set(['org.springframework.transaction.annotation.Transactional']), + ), + ).toBe(true); + }); + + it('treats an unmodified interface method as public for execution(public ...)', () => { + const interfaceOwner: GraphNode = { ...owner, label: 'Interface' }; + const implicitPublicMethod: GraphNode = { + ...method, + properties: { ...method.properties, visibility: 'package' }, + }; + const execution = parseSpringAopPointcut( + 'execution(public * com.example..OrderService.run(*))', + ); + + expect( + execution && springAopPointcutMatches(execution, interfaceOwner, implicitPublicMethod), + ).toBe(true); + expect(execution && springAopPointcutMatches(execution, owner, implicitPublicMethod)).toBe( + false, + ); + }); + + it('matches unqualified wildcard type patterns against the owner simple name', () => { + const within = parseSpringAopPointcut('within(*Service)'); + const execution = parseSpringAopPointcut('execution(* *Service.run(..))'); + + expect(within && springAopPointcutMatches(within, owner, method)).toBe(true); + expect(execution && springAopPointcutMatches(execution, owner, method)).toBe(true); + }); + + it.each(['within(OrderService)', 'execution(* OrderService.run(..))'])( + 'fails closed when a simple exact type needs unavailable import context: %s', + (expression) => { + expect(parseSpringAopPointcut(expression)).toBeNull(); + }, + ); + + it.each([ + 'namedPointcut()', + 'execution(* com.example..OrderService.*(..)) && args(orderId)', + 'execution(String com.example.OrderService.run(..))', + '@annotation(com.example.DynamicMarker)', + 'bean(orderService)', + ])('fails closed for unsupported or dynamic expression %s', (expression) => { + expect(parseSpringAopPointcut(expression)).toBeNull(); + }); + + it('rejects overlong input instead of truncating away a dynamic suffix', () => { + const start = 'execution(* '; + const end = '.run(..))'; + const validPrefix = `${start}${'a'.repeat(1_000 - start.length - end.length)}${end}`; + expect(validPrefix).toHaveLength(1_000); + expect(parseSpringAopPointcut(`${validPrefix} && args(value)`)).toBeNull(); + }); + + it('matches adversarial wildcard input without regex backtracking', () => { + const wildcardChain = '*a'.repeat(10); + const execution = parseSpringAopPointcut( + `execution(* com.example..OrderService.${wildcardChain}z(..))`, + ); + const within = parseSpringAopPointcut(`within(com.example.${wildcardChain}z)`); + const adversarialMethod = { + ...method, + properties: { ...method.properties, name: 'a'.repeat(24) }, + }; + const adversarialOwner = { + ...owner, + properties: { + ...owner.properties, + qualifiedName: `com.example.${'a'.repeat(24)}`, + }, + }; + + const startedAt = performance.now(); + expect(execution && springAopPointcutMatches(execution, owner, adversarialMethod)).toBe(false); + expect(within && springAopPointcutMatches(within, adversarialOwner, method)).toBe(false); + expect(performance.now() - startedAt).toBeLessThan(100); + }); +}); + +describe('Spring AOP pointcut candidate index (#2416)', () => { + it('selects only methods that directly declare an @annotation behavior', () => { + const annotated = { method, owner } satisfies SpringAopOwnedMethod; + const plain = { + owner, + method: { + ...method, + id: 'Method:com.example.service.OrderService.plain', + properties: { ...method.properties, name: 'plain' }, + }, + } satisfies SpringAopOwnedMethod; + const pointcut = parseSpringAopPointcut( + '@annotation(org.springframework.transaction.annotation.Transactional)', + ); + expect(pointcut).not.toBeNull(); + + const index = createSpringAopCandidateIndex( + [annotated, plain], + new Map([[method.id, new Set(['org.springframework.transaction.annotation.Transactional'])]]), + ); + + expect(index.candidatesFor(pointcut!)).toEqual([annotated]); + }); + + it('narrows exact and literal-prefix owner pointcuts without excluding matches', () => { + const ownedMethod = (qualifiedName: string): SpringAopOwnedMethod => { + const ownerName = qualifiedName.slice(qualifiedName.lastIndexOf('.') + 1); + return { + owner: { + ...owner, + id: `Class:${qualifiedName}`, + properties: { ...owner.properties, name: ownerName, qualifiedName }, + }, + method: { + ...method, + id: `Method:${qualifiedName}.run`, + properties: { ...method.properties, qualifiedName: `${qualifiedName}.run` }, + }, + }; + }; + const order = ownedMethod('com.example.service.OrderService'); + const payment = ownedMethod('com.example.billing.PaymentService'); + const legacy = ownedMethod('org.legacy.LegacyService'); + const candidates = [order, payment, legacy]; + const index = createSpringAopCandidateIndex(candidates, new Map()); + const exact = parseSpringAopPointcut('within(com.example.service.OrderService)'); + const prefixed = parseSpringAopPointcut('within(com.example..*Service)'); + const leadingWildcard = parseSpringAopPointcut('within(*..*Service)'); + expect(exact && index.candidatesFor(exact)).toEqual([order]); + expect(prefixed && index.candidatesFor(prefixed)).toEqual([payment, order]); + expect(leadingWildcard && index.candidatesFor(leadingWildcard)).toEqual(candidates); + }); + + it('preserves brute-force results across exact, wildcard, descendant, and annotation pointcuts', () => { + const ownedMethod = ( + qualifiedName: string, + methodName: string, + filePath: string, + ): SpringAopOwnedMethod => ({ + owner: { + ...owner, + id: `Class:${filePath}:${qualifiedName}`, + properties: { ...owner.properties, qualifiedName, filePath }, + }, + method: { + ...method, + id: `Method:${filePath}:${qualifiedName}.${methodName}`, + properties: { + ...method.properties, + name: methodName, + qualifiedName: `${qualifiedName}.${methodName}`, + filePath, + }, + }, + }); + const candidates = [ + ownedMethod('com.example.service.OrderService', 'run', 'OrderService.java'), + ownedMethod('com.example.service.OrderService', 'read', 'OrderService.kt'), + ownedMethod('com.example.billing.InvoiceService', 'run', 'InvoiceService.kt'), + ownedMethod('org.legacy.LegacyService', 'run', 'LegacyService.java'), + ]; + const transactional = 'org.springframework.transaction.annotation.Transactional'; + const methodAnnotations = new Map([[candidates[1]!.method.id, new Set([transactional])]]); + const index = createSpringAopCandidateIndex(candidates, methodAnnotations); + const expressions = [ + 'within(com.example.service.OrderService)', + 'within(com.example..*Service)', + 'within(*..*Service)', + 'execution(public * com.example..*Service.r*( * ))', + `@annotation(${transactional})`, + ]; + + for (const expression of expressions) { + const pointcut = parseSpringAopPointcut(expression); + expect(pointcut, expression).not.toBeNull(); + const matchingIds = (pool: readonly SpringAopOwnedMethod[]) => + pool + .filter((candidate) => + springAopPointcutMatches( + pointcut!, + candidate.owner, + candidate.method, + methodAnnotations.get(candidate.method.id), + ), + ) + .map((candidate) => candidate.method.id) + .sort(); + + expect(matchingIds(index.candidatesFor(pointcut!)), expression).toEqual( + matchingIds(candidates), + ); + } + }); +}); + +describe('Spring AOP persisted reason contract (#2416)', () => { + const reasons: readonly SpringAopReason[] = [ + { + kind: 'behavior', + annotation: 'org.springframework.transaction.annotation.Transactional', + behavior: 'transactional', + declaredOn: 'method', + activation: 'unknown', + proxy: 'possible', + }, + { + kind: 'behavior', + annotation: 'org.springframework.cache.annotation.Caching', + behavior: 'caching', + declaredOn: 'method', + activation: 'unknown', + proxy: 'possible', + }, + { + kind: 'advice', + annotation: 'org.aspectj.lang.annotation.Around', + advice: 'around', + pointcut: 'execution(* com.example..OrderService.*(..))', + match: 'static', + activation: 'unknown', + proxy: 'possible', + }, + { + kind: 'pointcut', + annotation: 'org.aspectj.lang.annotation.Before', + pointcut: 'namedPointcut()', + match: 'unresolved', + resolution: 'unknown', + }, + { + kind: 'pointcut', + annotation: 'org.aspectj.lang.annotation.After', + pointcut: null, + match: 'unresolved', + resolution: 'unknown', + }, + { + kind: 'aspect', + annotation: 'org.aspectj.lang.annotation.Aspect', + activation: 'unknown', + registration: 'unknown', + }, + ]; + + it.each(reasons)('round-trips the $kind reason', (reason) => { + expect(decodeSpringAopReason(encodeSpringAopReason(reason))).toEqual(reason); + }); + + it.each([ + 'spring-aop:v2:{}', + 'spring-aop:v1:not-json', + `spring-aop:v1:${JSON.stringify({ + kind: 'behavior', + annotation: 'com.example.FakeTransactional', + behavior: 'transactional', + declaredOn: 'method', + activation: 'unknown', + proxy: 'possible', + })}`, + `spring-aop:v1:${JSON.stringify({ + kind: 'pointcut', + annotation: 'org.aspectj.lang.annotation.Before', + pointcut: 'execution(* com.example.Service.run(..))', + match: 'static', + resolution: 'unknown', + })}`, + ])('rejects malformed, foreign, or internally inconsistent reason %s', (reason) => { + expect(decodeSpringAopReason(reason)).toBeUndefined(); + }); + + it('identifies owned evidence by its stable ID namespace, not mutable prose', () => { + expect( + isSpringAopEvidenceNode({ + ...method, + id: 'CodeElement:spring-aop:evidence', + label: 'CodeElement', + }), + ).toBe(true); + expect( + isSpringAopEvidenceNode({ + ...method, + id: 'CodeElement:ordinary', + label: 'CodeElement', + properties: { ...method.properties, description: 'Spring AOP: lookalike' }, + }), + ).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/spring-bean-schema.test.ts b/gitnexus/test/unit/spring-bean-schema.test.ts index 07882b2eb..22ca2cd99 100644 --- a/gitnexus/test/unit/spring-bean-schema.test.ts +++ b/gitnexus/test/unit/spring-bean-schema.test.ts @@ -5,6 +5,7 @@ import { PARSE_CACHE_VERSION } from '../../src/storage/parse-cache.js'; import { INCREMENTAL_SCHEMA_VERSION } from '../../src/storage/repo-manager.js'; import { isSpringBeanCandidateSourceFile } from '../../src/core/ingestion/frameworks/spring/bean-catalog.js'; import { + SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; @@ -24,6 +25,7 @@ describe('Spring Bean Class persistence schema', () => { expect(parseSchemaVersion).toBeGreaterThanOrEqual(31); expect(INCREMENTAL_SCHEMA_VERSION).toBeGreaterThanOrEqual(23); expect(CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version).toBe(1); + expect(SPRING_AOP_FEATURE.version).toBe(1); expect(SPRING_BEAN_INVENTORY_FEATURE.version).toBe(2); expect(SPRING_CONDITIONALS_FEATURE.version).toBe(1); }); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index d5326509e..d1dad6541 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -70,6 +70,7 @@ export default defineConfig({ 'test/integration/lbug-pool-stability.test.ts', 'test/integration/local-backend.test.ts', 'test/integration/local-backend-calltool.test.ts', + 'test/integration/spring-aop-mcp.test.ts', 'test/integration/search-core.test.ts', 'test/integration/search-pool.test.ts', 'test/integration/fts-description-search.test.ts', @@ -116,6 +117,7 @@ export default defineConfig({ 'test/integration/lbug-pool-stability.test.ts', 'test/integration/local-backend.test.ts', 'test/integration/local-backend-calltool.test.ts', + 'test/integration/spring-aop-mcp.test.ts', 'test/integration/search-core.test.ts', 'test/integration/search-pool.test.ts', 'test/integration/fts-description-search.test.ts',