Merge branch 'main' into fix/skill-evolution-gate

This commit is contained in:
Gergő Magyar 2026-08-01 17:54:38 +01:00 committed by GitHub
commit e1df209367
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
129 changed files with 10287 additions and 478 deletions

View file

@ -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'

View file

@ -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

View file

@ -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 }}'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 }}

View file

@ -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'
@ -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

View file

@ -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: "@<group>"`) 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: "@<group>"`) 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: "@<groupName>"` (or `"@<groupName>/<memberPath>"` to scope to one member) plus optional `service: "<monorepo/path>"`. 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: "@<groupName>"` — but, unlike the others, it resolves `from`/`to` across **all** members (a `@<groupName>/<memberPath>` 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, `@<group>` 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, `@<group>` 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 (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<T>(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<MyPhaseOutput> = {
name: 'myPhase',
@ -149,7 +157,9 @@ export const myPhase: PipelinePhase<MyPhaseOutput> = {
async execute(ctx, deps) {
const { allPaths } = getPhaseOutput<ParseOutput>(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 CooperHarveyKennedy 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,24 +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<DefId, DefId[]>` — C3, Ruby-mixin, or first-wins per language |
| `resolveImportTarget(target, fromFile, allFiles)` | `(rawImportPath, sourceFile) → targetFilePath` (PEP-328 for Python, etc.) |
| `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<DefId, DefId[]>` — 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
@ -269,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
@ -313,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<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
@ -336,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`)
@ -362,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)
@ -418,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`.
@ -446,12 +459,12 @@ Node IDs use arity suffix (`#<paramCount>`): `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

View file

@ -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<T>`), not in this type contract.
* Lets Cypher queries trace which beans the container injects into a given
@ -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.

View file

@ -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.

View file

@ -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,

View file

@ -415,6 +415,11 @@ export interface Scope {
/** Local type facts visible from this scope (parameter annotations, `self` binding, etc.). */
readonly typeBindings: ReadonlyMap<string, TypeRef>;
/** 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<string>;
/** Receiver names this scope BINDS rather than inherits `this`, `self`, (#2701).
*
* A receiver walk (`findReceiverTypeBinding`) that reaches such a scope

View file

@ -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",
@ -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",
@ -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"
@ -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",
@ -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": {

View file

@ -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",
@ -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",
@ -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",

View file

@ -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
@ -24,3 +31,17 @@
# 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
# 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

View file

@ -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.
@ -663,6 +688,56 @@ 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
```
### 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

View file

@ -1 +1 @@
ec879302c3257418edb52c3bc2c1ac0e43d40e742319c493218b7d0ff3466483
b57c5479f158c41a6328fa8d61c234aaec88fc41780252d2fbd7baca21491aa2

View file

@ -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}`);

View file

@ -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",

View file

@ -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',

View file

@ -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<string>(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

View file

@ -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;

View file

@ -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')
);
}

View file

@ -201,6 +201,7 @@ function collectReferenceSites(parsedFiles: readonly ParsedFile[]) {
function withDefaultHooks(partial: Partial<FinalizeHooks>): FinalizeHooks {
return {
resolveImportTarget: partial.resolveImportTarget ?? (() => null),
isNamespaceImport: partial.isNamespaceImport,
expandsWildcardTo: partial.expandsWildcardTo ?? (() => []),
mergeBindings:
partial.mergeBindings ??

View file

@ -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),
};
@ -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),
};

View file

@ -0,0 +1,229 @@
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 === '"""') {
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;
}
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 === '"""') {
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;
}
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();
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;
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;
}

View file

@ -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<string, ReadonlySet<string>>,
): SpringAopCandidateIndex {
const allCandidates = [...candidates];
const candidatesByAnnotation = new Map<string, SpringAopOwnedMethod[]>();
const candidatesByExactOwner = new Map<string, SpringAopOwnedMethod[]>();
const ownerBucketsById = new Map<string, OwnerBucket>();
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<string, readonly SpringAopOwnedMethod[]>();
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;
},
};
}

View file

@ -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<Annotation extends SpringAopAnnotationFact> {
getFacts(filePath: string): readonly SpringAopOwnerFact<Annotation>[];
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<SpringAopAdviceKind, 'pointcut'>;
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<string>;
readonly aspectClassIds: ReadonlySet<string>;
readonly singletonInstanceClassIds: ReadonlySet<string>;
readonly aspects: readonly SpringAopAspectRecord[];
readonly behaviors: readonly SpringAopBehaviorRecord[];
readonly advices: readonly SpringAopAdviceRecord[];
}
interface MutableSpringAopGraphMetadata {
readonly candidateFilePaths: Set<string>;
readonly aspectClassIds: Set<string>;
readonly singletonInstanceClassIds: Set<string>;
readonly aspects: SpringAopAspectRecord[];
readonly behaviors: SpringAopBehaviorRecord[];
readonly advices: SpringAopAdviceRecord[];
}
const metadataByGraph = new WeakMap<KnowledgeGraph, MutableSpringAopGraphMetadata>();
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<string, SpringAopBehavior>([
['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<string, SpringAopAdviceKind>([
['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<string>([
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<SpringAopAnnotationFact, 'name'>[],
): 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<string, GraphNode | null>,
): 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<Annotation extends SpringAopAnnotationFact>(
adapter: SpringAopMetadataAdapter<Annotation>,
) {
return (
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
indexes: ScopeResolutionIndexes,
): void => {
const resolveAnnotation = createSpringAnnotationNameResolver(indexes);
const metadata = graphMetadata(graph);
const exactOwnerByRange = new Map<string, GraphNode | null>();
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<string, string> | 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<string, string>();
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<string, string | undefined>();
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 ?? '<root>'}\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<string, unknown> {
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<SpringAopBehavior>([
'transactional',
'caching',
'cacheable',
'cache-evict',
'cache-put',
'authorization',
]);
const ADVICES = new Set<Exclude<SpringAopAdviceKind, 'pointcut'>>([
'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<SpringAopAdviceKind, 'pointcut'>) &&
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<number>(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<string> = 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);
}

View file

@ -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<Annotation>[];
}
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<SpringDiAnnotationFact, 'name'>[],
): 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<string>();
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<SpringBeanFactoryDeclaration>;
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<GraphRelationship, 'type' | 'reason'>,
): boolean {
return (
relationship.type === 'DECLARES' &&
relationship.reason.startsWith(SPRING_BEAN_FACTORY_REASON_PREFIX)
);
}

View file

@ -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<Annotation, SiteKind>[];
readonly beanFactoryMethods?: readonly SpringBeanFactoryMethodFact<Annotation>[];
}
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<Annotation, SiteKind>,
): 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<string>();
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) {

View file

@ -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}`,
};
}

View file

@ -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<string, readonly JavaSpringAopFact[]>();
const springConfigConsumers = new Map<string, readonly JavaSpringConfigConsumerFact[]>();
const springConditionalFacts = new Map<string, readonly JavaSpringConditionalFact[]>();
const springDiFacts = new Map<string, readonly JavaSpringDiClassFact[]>();
@ -32,11 +35,21 @@ const springDiFacts = new Map<string, readonly JavaSpringDiClassFact[]>();
/** 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 : [],

View file

@ -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<ScopeId, Set<string>>();
const springAopFacts: JavaSpringAopFact[] = [];
const springAopTypeNodeIds = new Set<number>();
const springConditionalFacts: JavaSpringConditionalFact[] = [];
const springDiFacts: JavaSpringDiClassFact[] = [];
const springDiClassNodeIds = new Set<number>();
@ -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);

View file

@ -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);

View file

@ -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<JavaSpringAopAnnotationFact>;
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,
});

View file

@ -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<JavaAnnotationSyntaxFact>;
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() ?? '<method>';
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() ?? '<method>',
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 }),
};
}

View file

@ -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<ScopeId, Map<string, BindingRef[]>>;
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<string, string[]>();
const moduleScopeByPath = new Map<string, PackageBucket['moduleScopes'][number]>();
const parsedByPath = new Map<string, ParsedFile>();
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<string, number>();
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<string>();
for (const { def } of candidates) {
const injectedPaths = new Set<string>();
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<string, TypeRef>;
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 &&

View file

@ -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<string, readonly KotlinSpringAopFact[]>();
const springConditionalFacts = new Map<string, readonly KotlinSpringConditionalFact[]>();
const springDiFacts = new Map<string, readonly KotlinSpringDiClassFact[]>();
@ -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 : [],

View file

@ -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<ScopeId, Set<string>>();
const springAopFacts: KotlinSpringAopFact[] = [];
const springAopTypeNodeIds = new Set<number>();
const springConditionalFacts: KotlinSpringConditionalFact[] = [];
const springDiFacts: KotlinSpringDiClassFact[] = [];
const springDiClassNodeIds = new Set<number>();
@ -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));

View file

@ -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);
},

View file

@ -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<KotlinSpringAopAnnotationFact>;
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,
});

View file

@ -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<KotlinAnnotationSyntaxFact>;
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() ??
'<method>';
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,
});

View file

@ -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),
};
}

View file

@ -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));

View file

@ -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<string>;
/** 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<string>): 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)
);
}

View file

@ -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,

View file

@ -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

View file

@ -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<string, Map<string, Set<string>>>;
function addProvidedType(
index: ProvidedTypesByLanguage,
language: string,
typeName: string,
providerId: string,
): void {
const byName = index.get(language) ?? new Map<string, Set<string>>();
const names = new Set([typeName, typeName.slice(typeName.lastIndexOf('.') + 1)]);
for (const name of names) {
const providers = byName.get(name) ?? new Set<string>();
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<DIOutput> = {
const candidates: CandidateSite[] = [];
const providers = new Map<string, DiProviderMatch>();
const providedTypes = new Map<string, Map<string, Set<string>>>();
const providerNames = new Map<string, Map<string, Set<string>>>();
const providerNodes = new Map<string, GraphNode>();
const providersByDeclarer = new Map<string, Set<string>>();
ctx.graph.forEachNode((node) => {
const language = node.properties.language;
if (language === undefined || !isSupportedLanguage(language)) return;
@ -105,7 +127,25 @@ export const diPhase: PipelinePhase<DIOutput> = {
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<string, Set<string>>();
for (const name of provider.names) {
const namedProviders = namesByLanguage.get(name) ?? new Set<string>();
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<string>();
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<DIOutput> = {
}
const interfaceToImplementers = new Map<string, Set<string>>();
const directSupertypes = new Map<string, Set<string>>();
for (const rel of ctx.graph.iterRelationshipsByType('IMPLEMENTS')) {
const set = interfaceToImplementers.get(rel.targetId) ?? new Set<string>();
set.add(rel.sourceId);
interfaceToImplementers.set(rel.targetId, set);
const supertypes = directSupertypes.get(rel.sourceId) ?? new Set<string>();
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<string>();
supertypes.add(rel.targetId);
directSupertypes.set(rel.sourceId, supertypes);
}
const memberToClass = new Map<string, string>();
@ -137,7 +186,6 @@ export const diPhase: PipelinePhase<DIOutput> = {
const candidateLanguages = new Set<string>(candidates.map((candidate) => candidate.language));
const interfacesByLanguage = new Map<string, NameIndex>();
const classesByLanguage = new Map<string, NameIndex>();
const classNodes = new Map<string, GraphNode>();
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<DIOutput> = {
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<string, readonly string[]>();
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<string>();
if (rootTypeId !== undefined) {
const queue = [rootTypeId];
const visited = new Set<string>();
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<string>();
@ -166,6 +273,8 @@ export const diPhase: PipelinePhase<DIOutput> = {
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<DIOutput> = {
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<DIOutput> = {
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}`,
});
}
}

View file

@ -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';

View file

@ -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<string>,
): 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<string, readonly GraphNode[]>,
ownerByMethod: ReadonlyMap<string, GraphNode>,
singletonInstanceClassIds: ReadonlySet<string>,
): { 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<string>,
ownerByMethod: ReadonlyMap<string, GraphNode>,
candidateIndex: SpringAopCandidateIndex,
methodAnnotations: ReadonlyMap<string, ReadonlySet<string>>,
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 ?? '<dynamic>'}`,
`${record.advice} pointcut ${record.pointcut ?? '<non-static>'}; 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<SpringAopOutput> = {
name: 'springAop',
deps: ['scopeResolution'],
async execute(ctx: PipelineContext): Promise<SpringAopOutput> {
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<string, GraphNode[]>();
const ownerByMethod = new Map<string, GraphNode>();
const methodAnnotations = new Map<string, Set<string>>();
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<string>();
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<SpringAopInheritanceOutput> = {
name: 'springAopInheritance',
deps: ['springAop', 'mro'],
async execute(ctx: PipelineContext): Promise<SpringAopInheritanceOutput> {
const metadata = getSpringAopGraphMetadata(ctx.graph);
const ownerByMethod = new Map<string, GraphNode>();
const classMethods = new Map<string, GraphNode[]>();
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<string, Set<string>>();
for (const type of ['EXTENDS', 'IMPLEMENTS'] as const) {
for (const relationship of ctx.graph.iterRelationshipsByType(type)) {
const children = childrenByParent.get(relationship.targetId) ?? new Set<string>();
children.add(relationship.sourceId);
childrenByParent.set(relationship.targetId, children);
}
}
const implementingMethods = new Map<string, Set<string>>();
for (const relationship of ctx.graph.iterRelationshipsByType('METHOD_IMPLEMENTS')) {
const methods = implementingMethods.get(relationship.targetId) ?? new Set<string>();
methods.add(relationship.sourceId);
implementingMethods.set(relationship.targetId, methods);
}
const overridingClasses = new Map<string, Set<string>>();
for (const relationship of ctx.graph.iterRelationshipsByType('METHOD_OVERRIDES')) {
const classes = overridingClasses.get(relationship.targetId) ?? new Set<string>();
classes.add(relationship.sourceId);
overridingClasses.set(relationship.targetId, classes);
}
const queue: InheritedBehaviorWorkItem[] = [];
const emittedKeys = new Set<string>();
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 };
},
};

View file

@ -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 })

View file

@ -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<string, TypeRef>;
readonly lexicalNames?: ReadonlySet<string>;
/** See `Scope.ownsReceivers` — set once at pass 1, never mutated. */
readonly ownsReceivers?: ReadonlySet<string>;
}
@ -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<string>,
lexicalNames?: ReadonlySet<string>,
): ScopeDraft {
return {
id,
@ -505,10 +510,26 @@ function makeDraft(
ownedDefs: [],
imports: [],
typeBindings: new Map(),
lexicalNames,
ownsReceivers,
};
}
function parseScopeLexicalNames(match: CaptureMatch): ReadonlySet<string> | 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<string> = new Set<string>([
'@scope.lexical-names',
'@declaration.name',
'@declaration.qualified_name',
'@import.name',

View file

@ -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

View file

@ -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;
}

View file

@ -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;

View file

@ -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<string, readonly string[]>;
/** 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<ScopeId>();
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<string>()` arrives here as `Box<string>`,
// 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++) {

View file

@ -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;

View file

@ -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) {

View file

@ -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) =>

View file

@ -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<string>();
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 = [];

View file

@ -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.

View file

@ -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:

View file

@ -17,12 +17,17 @@ 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';
/** 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
* enabler). When `GITNEXUS_SORT_GRAPH_OUTPUT` is set, nodes and relationships
@ -785,7 +790,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<string>(NODE_TABLES));
const relRouter = new RelPairRouter(
csvDir,
REL_CSV_HEADER,
new Set<string>(NODE_TABLES),
DECLARED_RELATION_PAIRS,
);
try {
let emitted = 0;
for (const rel of orderedRelationships(graph, sortOutput)) {

View file

@ -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';
@ -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<RelationshipType> = new Set<Relatio
'METHOD_IMPLEMENTS',
'DEFINES',
'INJECTS',
// springAopInheritance reads direct behavior evidence after MRO.
'ADVISED_BY',
]);
/**
@ -412,6 +414,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 {

View file

@ -65,6 +65,7 @@ import {
SPRING_AUTO_CONFIGURATION_REASONS,
SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX,
} from '../ingestion/frameworks/spring/auto-configuration.js';
import { SPRING_AOP_EVIDENCE_ID_PREFIX } from '../ingestion/frameworks/spring/aop.js';
// ---------------------------------------------------------------------------
// Relationship CSV splitting — extracted for testability (PR #818)
// ---------------------------------------------------------------------------
@ -2745,6 +2746,53 @@ export const deleteAllCallSummaries = async (): Promise<{ edgesDeleted: number }
export const deleteAllInjects = async (): Promise<{ edgesDeleted: number }> =>
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
@ -3077,6 +3125,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 <T> doesn't have an index with
* name <name>."` — the table exists, only its FTS index is missing (normal,
* benign `missing-index`) `"Prepare failed: Binder exception: Table <T>
* 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 +3218,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;

View file

@ -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 {

View file

@ -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,11 +48,50 @@ export const getNodeLabel = (nodeId: string): string => {
return nodeId.split(':')[0];
};
/**
* Extract the FROMTO 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<string> =>
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;
}
/**
* 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<string>): 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-FROMTO-label-pair
* files. Filters edges whose endpoint labels are not valid node tables
@ -69,6 +111,7 @@ export class RelPairRouter {
private readonly csvDir: string,
private readonly header: string,
private readonly validTables: Set<string>,
private readonly declaredPairs: ReadonlySet<string>,
private readonly wsFactory: WriteStreamFactory = (p) => createWriteStream(p, 'utf-8'),
) {}
@ -104,6 +147,7 @@ export class RelPairRouter {
}
const pairKey = `${fromLabel}|${toLabel}`;
assertDeclaredPair(pairKey, this.declaredPairs);
const ws = this.streams.get(pairKey);
if (ws === undefined) {
// First edge for this pair: open the stream, write header + row.

View file

@ -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,
@ -331,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,
@ -357,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\`,
@ -375,6 +379,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,
@ -385,10 +395,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,
@ -399,10 +411,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\`,

View file

@ -33,6 +33,8 @@ import {
deleteAllInterprocTaintPaths,
deleteAllCallSummaries,
deleteAllInjects,
deleteAllAdvisedBy,
deleteSpringAopEvidenceNodes,
deleteSpringAutoConfigurationDeclarations,
deleteSpringAutoConfigurationSyntheticClasses,
queryImportersBatch,
@ -139,7 +141,9 @@ 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_AOP_FEATURE,
SPRING_BEAN_INVENTORY_FEATURE,
SPRING_CONDITIONALS_FEATURE,
} from './ingestion/frameworks/spring/analysis-features.js';
@ -157,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,
@ -167,6 +172,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 +211,45 @@ function collectFrameworkAnnotationDriftFiles(
return driftFiles;
}
function collectSpringBeanDeclarationDriftFiles(
graph: KnowledgeGraph,
persistedRows: readonly PersistedSpringBeanDeclarationRow[],
): Set<string> {
const persisted = new Map<string, { readonly filePath: string; readonly reason: string }>();
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<string, { readonly filePath: string; readonly reason: string }>();
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<string>();
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
// ---------------------------------------------------------------------------
@ -985,6 +1035,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 {
@ -1855,6 +1946,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
@ -2017,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.
@ -2034,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

View file

@ -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<string, any>) => Promise<any[]>,
@ -37,7 +71,7 @@ async function queryFTSViaExecutor(
indexName: string,
query: string,
limit: number,
): Promise<Array<{ filePath: string; score: number; nodeId: string }> | null> {
): Promise<FTSQueryOutcome> {
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 }),
};
};

View file

@ -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 '<path>': ...`),
* 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, '<path>');
export const redactPaths = (reason: string): string =>
reason
.replace(/'((?:[A-Za-z]:\\|\/)[^']*)'/g, "'<path>'")
.replace(/(?:[A-Za-z]:\\|\/)[^\s'"]+/g, '<path>');
/**
* 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

View file

@ -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<SpringAopReason, { kind: 'behavior' }>;
type SpringAopAdviceReason = Extract<SpringAopReason, { kind: 'advice' }>;
type SpringAopAspectReason = Extract<SpringAopReason, { kind: 'aspect' }>;
type SpringAopPointcutReason = Extract<SpringAopReason, { kind: 'pointcut' }>;
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<SpringAopPointcutReason['match'], 'static'>;
readonly resolution: Extract<SpringAopPointcutReason['resolution'], 'resolved'>;
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<string, unknown>)[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<const Key extends string>(
key: Key,
value: string | undefined,
): { readonly [K in Key]?: string } {
return value === undefined ? {} : ({ [key]: value } as { readonly [K in Key]?: string });
}
function stableDedupe<T>(values: readonly T[], keyOf: (value: T) => string): T[] {
const unique = new Map<string, T>();
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<SpringAopMetadata | undefined> {
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;
}
}

View file

@ -3,15 +3,39 @@ 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<SpringBeanMetadata | undefined> {
if (symbolType !== 'Class') return undefined;
): Promise<SpringBeanMetadata | SpringBeanFactoryMetadata | undefined> {
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'
AND r.reason STARTS WITH 'spring-bean-factory:'
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,
`

View file

@ -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';
@ -63,7 +64,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,
@ -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',
]);
/**
@ -779,6 +784,13 @@ export function attachToolStaleness(
};
}
/** tri-review Residual-2: see `LocalBackend.lastObservedPoolState`'s doc comment. */
interface PoolObservedState {
indexedAt?: string;
dbIdentity: Awaited<ReturnType<typeof statDbIdentity>>;
ftsStatus?: string;
}
export class LocalBackend {
private static readonly TOOL_STALENESS_TTL_MS = 5000;
private repos: Map<string, RepoHandle> = new Map();
@ -796,18 +808,30 @@ export class LocalBackend {
// the other; lbugPath is unique per flat/branch index.
private toolStalenessCache: Map<string, { at: number; value: Promise<StalenessInfo> }> =
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<string, string> = 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<string, Awaited<ReturnType<typeof statDbIdentity>>> =
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<string, PoolObservedState> = new Map();
/** Merge-patch one poolKey's observed state, preserving fields not passed. */
private setObservedState(poolKey: string, patch: Partial<PoolObservedState>): 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 +1167,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 +1571,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 +1583,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 +1604,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<PoolObservedState> = { 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 +1637,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 +2095,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 +2389,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 +2496,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 +2507,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 +2518,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 +2550,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 +2622,7 @@ export class LocalBackend {
}
}
return { results, ftsUsed };
return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) };
}
/**
@ -2924,6 +3022,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
@ -3270,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 —
@ -3398,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[] = [];
@ -3469,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<string, unknown> | undefined;
if (isMethodLike) {
@ -3507,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',
@ -3521,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),
@ -5811,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<string>([symId]);
const pdgBridgeEvidenceById = new Map<string, PdgBridgeEvidenceInfo>();
@ -6293,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: {
@ -6302,6 +6442,7 @@ export class LocalBackend {
type: symType,
filePath: sym.filePath || sym[2],
...(beanMetadata ? { bean: beanMetadata } : {}),
...(aopMetadata ? { aop: aopMetadata } : {}),
},
direction,
impactedCount: impacted.length,

View file

@ -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 require INJECTS; Spring proxy/advice edges require ADVISED_BY.',
},
includeTests: { type: 'boolean', description: 'Include test files (default: false)' },
minConfidence: {

View file

@ -1833,8 +1833,16 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
},
pendingNodeIds: string[],
): Promise<void> => {
// 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);
});

View file

@ -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,13 @@ 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;
// 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.
// 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

View file

@ -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,
@ -187,9 +188,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.
*/
@ -603,6 +607,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 ClassCodeElement; 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
@ -622,8 +635,46 @@ 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.
*
* 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.
*
* 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}, TraitFunction, ImplFunction, ConstMethod and
* VariableMethod. 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/ImplFunction covers a Rust
* `impl`/`trait` method, which is minted as a `Function` node, not `Method`;
* Const/VariableMethod and its sibling MethodConst 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.)
*
* v33: Spring AOP evidence adds the InterfaceCodeElement 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 = 28;
export const INCREMENTAL_SCHEMA_VERSION = 33;
export interface IndexedRepo {
repoPath: string;
@ -1117,6 +1168,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 `<repo>/.gitnexus` (or
* `<repo>/.gitnexus/branches/<slug>`), 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 <T>(operation: () => Promise<T>): Promise<T> => {
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.
*/
@ -1263,7 +1377,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,
@ -1430,11 +1544,17 @@ export const registerRepo = async (
return name;
};
export const registerRepo = async (
repoPath: string,
meta: RepoMeta,
opts?: RegisterRepoOptions,
): Promise<string> => withRegistryLock(() => registerRepoUnlocked(repoPath, meta, opts));
/**
* Remove a repo from the global registry.
* Called after `gitnexus clean`.
*/
export const unregisterRepo = async (repoPath: string): Promise<void> => {
const unregisterRepoUnlocked = async (repoPath: string): Promise<void> => {
// 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`),
@ -1446,6 +1566,9 @@ export const unregisterRepo = async (repoPath: string): Promise<void> => {
await writeRegistry(filtered);
};
export const unregisterRepo = async (repoPath: string): Promise<void> =>
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
@ -1454,7 +1577,7 @@ export const unregisterRepo = async (repoPath: string): Promise<void> => {
* 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<boolean> => {
const removeBranchIndexUnlocked = async (repoPath: string, branch: string): Promise<boolean> => {
const resolved = canonicalizePath(repoPath);
const entries = await readRegistry();
const idx = entries.findIndex((e) => registryPathEquals(canonicalizePath(e.path), resolved));
@ -1471,6 +1594,9 @@ export const removeBranchIndex = async (repoPath: string, branch: string): Promi
return true;
};
export const removeBranchIndex = async (repoPath: string, branch: string): Promise<boolean> =>
withRegistryLock(() => removeBranchIndexUnlocked(repoPath, branch));
/**
* Record that the flat workspace slot now serves `branch` (#2354).
*
@ -1487,6 +1613,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<void> => {
const canonicalInput = canonicalizePath(repoPath);
@ -1537,22 +1669,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);
});
};
/**
@ -1886,9 +2020,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;

View file

@ -0,0 +1,3 @@
class User:
def save(self):
return "wrong"

View file

@ -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()

View file

@ -0,0 +1,6 @@
class User:
def __init__(self, db):
self.db = db
def save(self):
return self.db

View file

@ -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"

View file

@ -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',
'<?php\nfunction target($x) { return $x; }\nfunction outer() {\n' +
@ -84,7 +79,105 @@ describeIfWorkerBuilt(
' $multi =\n function ($x) { return target($x); };\n return 1;\n}\n',
);
expect(edges).toEqual(['Function:ml.php:outer.$single@3:2 -> 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',
'<?php\nfunction target($x) { return $x; }\nfunction outer() {\n' +
' $single = function ($x) { return target($x); };\n' +
' $multi =\n function ($x) { return target($x); };\n return 1;\n}\n',
);
expect(edges.some((e) => e.startsWith('Function:ml.php:outer ->'))).toBe(false);
expect(edges).toContain('Function:ml.php:outer.$multi@4:2 -> Function:ml.php:target');
});
},
);

View file

@ -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.

View file

@ -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<QueryResult> {
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<ReturnType<typeof createTempDir>>;
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');
},
);
});

View file

@ -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');

View file

@ -71,6 +71,7 @@ withTestLbugDB(
'HAS_METHOD',
'METHOD_OVERRIDES',
'ACCESSES',
'ADVISED_BY',
];
const invalidTypes = ['CONTAINS', 'STEP_IN_PROCESS', 'MEMBER_OF', 'DROP_TABLE'];

View file

@ -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.

View file

@ -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<string, ReadonlySet<string>>;
}
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<string, Set<string>>();
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<string>();
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, ReadonlySet<string>>,
): 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<PipelineBenchResult> {
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);
});

View file

@ -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;
},
},
);

View file

@ -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<String>) {}
@PostFilter("filterObject != null")
fun kotlinPostFilterOperation(): List<String> = 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);
});

View file

@ -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);
});
});
},
{

View file

@ -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);
});

View file

@ -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);
},
);

View file

@ -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<string, string> {
const names = new Map<string, string>();
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<string, string> = {
'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<Gateway> 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<Gateway> 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<Gateway> 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<Gateway> 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<Gateway> 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<KGateway> = 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<KGateway>? = 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<KGateway>
}
`,
'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<KGateway>
}
`,
'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',
]);
});
});

View file

@ -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);

View file

@ -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,13 +27,15 @@ describe('analysis feature versions', () => {
});
expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({
'graph.class-framework-annotations': 1,
'spring.bean-inventory': 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.bean-inventory': 1,
'spring.aop-advice': 1,
'spring.bean-inventory': 2,
'spring.conditionals-auto-configuration': 1,
});
expect(
@ -56,7 +60,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 +70,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']);

View file

@ -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';

Some files were not shown because too many files have changed in this diff Show more