feat(spring): model AOP transactions, caching, and security (#2783)

* feat(spring): model AOP advice and proxy behavior

* fix(spring): address AOP review findings

---------

Co-authored-by: Shining <xuenning@qiyi.com>
This commit is contained in:
MyShining
2026-08-01 17:22:12 +01:00
committed by GitHub
co-authored by Shining
parent 99291891b7
commit 1147646518
46 changed files with 5075 additions and 172 deletions
+144 -132
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 from consumer Classes or factory Methods to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) |
| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges |
| Phase | File | Deps | Output |
| ------------------------- | -------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scan` | `scan.ts` | (root) | File paths + sizes |
| `structure` | `structure.ts` | `scan` | File/Folder nodes, CONTAINS edges, `allPathSet` |
| `springConfig` | `spring-config.ts` | `structure` | Spring configuration-property nodes and metadata |
| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx |
| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) |
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries |
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators) |
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order |
| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator |
| `springAutoConfiguration` | `spring-auto-configuration.ts` | `structure`, `scopeResolution` | DECLARES and CONDITIONAL_ON metadata for Spring configuration candidates |
| `springAop` | `spring-aop.ts` | `scopeResolution` | Direct declarative/advice ADVISED_BY edges and pointcut evidence |
| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution |
| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
| `springAopInheritance` | `spring-aop.ts` | `springAop`, `mro` | Propagates declarative behavior through class/interface inheritance decisions |
| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes or factory Methods to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) |
| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges |
**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `types.ts`, `runner.ts`, `index.ts`.
@@ -124,6 +129,7 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
4. **Timing** — per-phase `durationMs` in `PhaseResult`, dev-mode console logging.
**Design patterns:**
- **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output.
- **Typed phase access** — `getPhaseOutput<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 Cooper–Harvey–Kennedy post-dominator tree (the EXIT-rooted reverse CFG); branch sense (`'T'`/`'F'`) rides `reason`. A CFG whose EXIT is unreachable from some block is skipped for CDG (post-dominance would be unsound) while its CFG/REACHING_DEF layers are kept.
@@ -241,25 +251,25 @@ See `core/ingestion/cfg/` (emit + the pure CFG / post-dominator / control-depend
Single interface a language implements to plug into the pipeline. Contract fully documented in `scope-resolution/contract/scope-resolver.ts`.
| Hook | Purpose |
|------|---------|
| `languageProvider` | Base `LanguageProvider` (tree-sitter query, `emitScopeCaptures`, import/binding interpreters, hooks) |
| `populateOwners(parsed)` | Fill deferred `ownerId` fields on method defs (captures can't always know the owning class at parse time) |
| `buildMro(graph, parsed, nodeLookup)` | Produce `mroByClassDefId: Map<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) |
| 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
@@ -270,21 +280,21 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
### Code references
| Module | Purpose |
|--------|---------|
| `scope-resolution/contract/scope-resolver.ts` | `ScopeResolver` interface + shared types |
| `scope-resolution/pipeline/run.ts` | Generic orchestrator |
| `scope-resolution/pipeline/phase.ts` | Pipeline-phase wrapper (deps: `parse`, `structure`) |
| `scope-resolution/pipeline/registry.ts` | `SCOPE_RESOLVERS` map |
| `scope-resolution/passes/*.ts` | Reference-resolution passes (receiver-bound, free-call fallback, compound-receiver, MRO, cross-file return-type propagation) |
| `scope-resolution/graph-bridge/*.ts` | CLI-local translation from resolved references → `KnowledgeGraph` edges |
| `scope-resolution/scope/*.ts` | Generic scope-chain walkers + namespace targets |
| `scope-resolution/workspace-index.ts` | Build-once O(1) lookup index |
| `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs |
| `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) |
| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs |
| `languages/csharp/captures.ts` | `emitCsharpScopeCaptures` (honors cross-phase Tree cache) |
| `languages/csharp/namespace-siblings.ts` | Cross-file implicit-namespace visibility hook (reads `treeCache`) |
| Module | Purpose |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `scope-resolution/contract/scope-resolver.ts` | `ScopeResolver` interface + shared types |
| `scope-resolution/pipeline/run.ts` | Generic orchestrator |
| `scope-resolution/pipeline/phase.ts` | Pipeline-phase wrapper (deps: `parse`, `structure`) |
| `scope-resolution/pipeline/registry.ts` | `SCOPE_RESOLVERS` map |
| `scope-resolution/passes/*.ts` | Reference-resolution passes (receiver-bound, free-call fallback, compound-receiver, MRO, cross-file return-type propagation) |
| `scope-resolution/graph-bridge/*.ts` | CLI-local translation from resolved references → `KnowledgeGraph` edges |
| `scope-resolution/scope/*.ts` | Generic scope-chain walkers + namespace targets |
| `scope-resolution/workspace-index.ts` | Build-once O(1) lookup index |
| `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs |
| `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) |
| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs |
| `languages/csharp/captures.ts` | `emitCsharpScopeCaptures` (honors cross-phase Tree cache) |
| `languages/csharp/namespace-siblings.ts` | Cross-file implicit-namespace visibility hook (reads `treeCache`) |
### Performance notes
@@ -314,15 +324,15 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
Each language implements `LanguageProvider` (`language-provider.ts`). Key fields:
| Field | Purpose |
|-------|---------|
| `id`, `extensions` | Language identity and file matching |
| `treeSitterQueries` | S-expression queries for AST extraction |
| `importSemantics` | `named` / `wildcard-leaf` / `wildcard-transitive` / `namespace` |
| `importResolver` | Language-specific path → file resolution |
| `exportChecker` | Public/exported symbol detection |
| `typeConfig` | Type annotation extraction rules |
| `mroStrategy` | `first-wins` / `c3` / `none` |
| Field | Purpose |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`, `extensions` | Language identity and file matching |
| `treeSitterQueries` | S-expression queries for AST extraction |
| `importSemantics` | `named` / `wildcard-leaf` / `wildcard-transitive` / `namespace` |
| `importResolver` | Language-specific path → file resolution |
| `exportChecker` | Public/exported symbol detection |
| `typeConfig` | Type annotation extraction rules |
| `mroStrategy` | `first-wins` / `c3` / `none` |
| `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) |
16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
@@ -337,22 +347,23 @@ Per-language import resolution uses the **configs + factory** pattern (like call
Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSemantics` controls which tier activates:
| Tier | Confidence | Mechanism |
|------|-----------|-----------|
| 1 — same-file | 0.95 | Symbol table for caller's file |
| 2 — import-scoped | 0.9 | `NamedImportMap` chains (named) or all files in `importMap` (wildcard) |
| 3 — global | 0.5 | O(1) index lookups: class, impl, callable. Fallback only |
| Tier | Confidence | Mechanism |
| ----------------- | ---------- | ---------------------------------------------------------------------- |
| 1 — same-file | 0.95 | Symbol table for caller's file |
| 2 — import-scoped | 0.9 | `NamedImportMap` chains (named) or all files in `importMap` (wildcard) |
| 3 — global | 0.5 | O(1) index lookups: class, impl, callable. Fallback only |
| Import strategy | Languages | Behavior |
|----------------|-----------|----------|
| `named` | TS, JS, Java, C#, Rust, PHP, Kotlin | Only explicitly imported names visible |
| `wildcard-leaf` | Go, Ruby, Swift, Dart | Whole-package import, no transitive re-exports |
| `wildcard-transitive` | C, C++ | `#include` closure chains through re-exports |
| `namespace` | Python | Module aliases resolved at call site |
| Import strategy | Languages | Behavior |
| --------------------- | ----------------------------------- | ---------------------------------------------- |
| `named` | TS, JS, Java, C#, Rust, PHP, Kotlin | Only explicitly imported names visible |
| `wildcard-leaf` | Go, Ruby, Swift, Dart | Whole-package import, no transitive re-exports |
| `wildcard-transitive` | C, C++ | `#include` closure chains through re-exports |
| `namespace` | Python | Module aliases resolved at call site |
### Chunked parse-and-resolve
`parse` processes files in ~20 MB byte-budget chunks to bound memory. Per chunk:
1. Worker pool dispatches files (the sole parse path — there is no sequential fallback; `skipWorkers`, `--workers 0`, and `GITNEXUS_WORKER_POOL_SIZE=0` are rejected with an actionable error)
2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult`
3. Synthesize wildcard bindings (`wildcard-synthesis.ts`)
@@ -363,11 +374,12 @@ Inheritance edges are emitted later, by the scope-resolution phase (`preEmitInhe
Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`.
**Worker-serialized ParsedFiles (#2038).** To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the *sole* parse path and workers serialize each file's `ParsedFile` (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence `usedWorkerPool` can be false even when the repo has parseable files).
**Worker-serialized ParsedFiles (#2038).** To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the _sole_ parse path and workers serialize each file's `ParsedFile` (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence `usedWorkerPool` can be false even when the repo has parseable files).
### Inheritance and MRO
Inheritance is captured by the `@reference.inherits` tag and emitted by the scope-resolution phase: `preEmitInheritanceEdges` resolves each base in scope, then `emitHeritageEdges` writes the `EXTENDS`/`IMPLEMENTS` edges. The phase then computes method resolution order via each `ScopeResolver`'s `buildMro` hook, feeding a `MethodDispatchIndex` used for owner-scoped lookups. Per-language strategy:
- **`first-wins`** — Java, C#, C++, TS, Ruby, Go
- **`c3`** — Python (C3 linearization)
- **`ruby-mixin`** — Ruby (mixin-aware linearization)
@@ -419,7 +431,7 @@ Defined in `lbug/schema.ts`. Separate node tables per type, single `CodeRelation
**Node tables:** File, Folder, Function, Class, Interface, Method, Constructor, CodeElement, Struct, Enum, Macro, Typedef, Union, Namespace, Trait, Impl, TypeAlias, Const, Static, Property, Record, Delegate, Annotation, Template, Module, Community, Process, Route, Tool, Section, Embedding.
**Relation types** (`CodeRelation.type`): CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF.
**Relation types** (`CodeRelation.type`): CONTAINS, DEFINES, CALLS, IMPORTS, INHERITS, EXTENDS, IMPLEMENTS, USES, DECORATES, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF, WRAPS, QUERIES, INJECTS, CONDITIONAL_ON, DECLARES, ADVISED_BY, BINDS_EVENT_HANDLER, EMITS_EVENT.
**Optional `--pdg` additions** (off by default, opt-in via `gitnexus analyze --pdg`; see _Optional CFG/PDG emission_ above): a `BasicBlock` node table, plus the PDG relation types `CFG`, `REACHING_DEF`, `CDG`, `TAINTED`, `SANITIZES`, and `TAINT_PATH` on the same `CodeRelation` table. These are deliberately kept out of the default `VALID_RELATION_TYPES` / web graph schema — query them via `cypher`, `explain`, or `pdg_query`.
@@ -447,12 +459,12 @@ Node IDs use arity suffix (`#<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
+6
View File
@@ -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.
@@ -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.
@@ -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
@@ -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),
};
@@ -6,7 +6,7 @@ export interface SpringAnnotationArgument {
function splitTopLevel(value: string, separator: string): string[] | null {
const parts: string[] = [];
const stack: string[] = [];
let quote: '"' | "'" | null = null;
let quote: '"' | "'" | '"""' | null = null;
let escaped = false;
let start = 0;
@@ -22,12 +22,24 @@ function splitTopLevel(value: string, separator: string): string[] | null {
for (let index = 0; index < value.length; index++) {
const char = value[index];
if (quote === '"""') {
if (value.startsWith('"""', index)) {
quote = null;
index += 2;
}
continue;
}
if (quote !== null) {
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (char === quote) quote = null;
continue;
}
if (value.startsWith('"""', index)) {
quote = '"""';
index += 2;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
@@ -53,7 +65,7 @@ function splitTopLevel(value: string, separator: string): string[] | null {
function topLevelAssignment(argument: string): number {
const stack: string[] = [];
let quote: '"' | "'" | null = null;
let quote: '"' | "'" | '"""' | null = null;
let escaped = false;
// Keep the same deliberate generic-delimiter policy as splitTopLevel.
const closing = new Map([
@@ -65,12 +77,24 @@ function topLevelAssignment(argument: string): number {
for (let index = 0; index < argument.length; index++) {
const char = argument[index];
if (quote === '"""') {
if (argument.startsWith('"""', index)) {
quote = null;
index += 2;
}
continue;
}
if (quote !== null) {
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (char === quote) quote = null;
continue;
}
if (argument.startsWith('"""', index)) {
quote = '"""';
index += 2;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
@@ -119,6 +143,12 @@ export function parseSpringAnnotationArguments(
export function parseStaticStringLiteral(value: string): string | null {
const trimmed = value.trim();
if (trimmed.startsWith('"""')) {
if (trimmed.length < 6 || !trimmed.endsWith('"""')) return null;
const raw = trimmed.slice(3, -3);
if (raw.includes('"""') || /\$(?:\{|[A-Za-z_])/.test(raw)) return null;
return raw;
}
const match = /^"((?:\\.|[^"\\])*)"$/s.exec(trimmed);
if (match === null) return null;
if (/(^|[^\\])\$(?:\{|[A-Za-z_])/.test(match[1])) return null;
@@ -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;
},
};
}
@@ -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);
}
@@ -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 : [],
@@ -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);
@@ -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);
@@ -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,
});
@@ -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 : [],
@@ -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));
@@ -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);
},
@@ -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,
});
@@ -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';
@@ -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 };
},
};
+14 -2
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 })
+4 -2
View File
@@ -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',
]);
/**
+48
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
+1
View File
@@ -360,6 +360,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM Interface TO Method,
FROM Interface TO Class,
FROM Interface TO Interface,
FROM Interface TO CodeElement,
FROM Interface TO \`TypeAlias\`,
FROM Interface TO \`Struct\`,
FROM Interface TO \`Constructor\`,
+13 -4
View File
@@ -33,6 +33,8 @@ import {
deleteAllInterprocTaintPaths,
deleteAllCallSummaries,
deleteAllInjects,
deleteAllAdvisedBy,
deleteSpringAopEvidenceNodes,
deleteSpringAutoConfigurationDeclarations,
deleteSpringAutoConfigurationSyntheticClasses,
queryImportersBatch,
@@ -141,6 +143,7 @@ import { STALE_HASH_SENTINEL } from './lbug/schema.js';
import { isSpringBeanCandidateSourceFile } from './ingestion/frameworks/spring/bean-catalog.js';
import { isSpringBeanFactoryDeclaration } from './ingestion/frameworks/spring/bean-factories.js';
import {
SPRING_AOP_FEATURE,
SPRING_BEAN_INVENTORY_FEATURE,
SPRING_CONDITIONALS_FEATURE,
} from './ingestion/frameworks/spring/analysis-features.js';
@@ -158,6 +161,7 @@ import {
const ANALYSIS_FEATURES = [
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
SPRING_AOP_FEATURE,
SPRING_BEAN_INVENTORY_FEATURE,
SPRING_CONDITIONALS_FEATURE,
SPRING_CONFIG_BINDINGS_FEATURE,
@@ -2121,16 +2125,21 @@ async function runFullAnalysisInner(
// deleting on every non-pdg incremental run (N runs = N copies of
// every INJECTS row; CodeRelation has no PK and no read-side dedup).
await deleteAllInjects();
// 2b. Drop Spring-owned DECLARES edges (#2415). The
// 2b. Spring AOP pointcuts are matched against the full resolved graph;
// a third-file change can invalidate an edge between unchanged files.
// Rebuild the complete ADVISED_BY set on every incremental writeback.
await deleteAllAdvisedBy();
await deleteSpringAopEvidenceNodes();
// 2c. Drop Spring-owned DECLARES edges (#2415). The
// auto-configuration phase scans every metadata file and recomputes
// the full set each run; exact reason filtering leaves declarations
// owned by other metadata systems untouched.
await deleteSpringAutoConfigurationDeclarations();
// 2c. Drop source-unavailable auto-configuration placeholders. Fresh
// 2d. Drop source-unavailable auto-configuration placeholders. Fresh
// synthetic nodes are graph-wide in extractChangedSubgraph, so this
// also removes an orphan when a newly-added real class takes over.
await deleteSpringAutoConfigurationSyntheticClasses();
// 2d. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on
// 2e. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on
// — their validity is a whole-program property (an A→C flow can be
// invalidated by a change to an intermediate function on a third
// file), so endpoint-writability extraction can't refresh them.
@@ -2138,7 +2147,7 @@ async function runFullAnalysisInner(
// graph (isGraphWideRelType), mirroring Community/Process.
if (options.pdg === true) {
await deleteAllInterprocTaintPaths();
// 2e. Drop CALL_SUMMARY edges (PDG FU-C) on an incremental `--pdg`
// 2f. Drop CALL_SUMMARY edges (PDG FU-C) on an incremental `--pdg`
// writeback. They are re-included from the FULL fresh graph
// (isGraphWideRelType) and the callSummaries phase recomputes every
// summary each run, so delete-all-then-rebuild keeps an unchanged
+352
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;
}
}
+1
View File
@@ -27,6 +27,7 @@ export async function queryClassBeanMetadata(
lbugPath,
`${pattern}
WHERE r.type = 'DECLARES'
AND r.reason STARTS WITH 'spring-bean-factory:'
RETURN r.reason AS reason
LIMIT 1`,
{ symbolId },
+59 -13
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';
@@ -310,6 +311,10 @@ export const VALID_RELATION_TYPES = new Set([
// surface.
'CONDITIONAL_ON',
'DECLARES',
// Spring proxy/advice evidence (#2416). Opt-in for traversal so existing
// impact defaults do not silently widen; target enrichment still surfaces
// advised/proxied state on ordinary impact calls.
'ADVISED_BY',
]);
/**
@@ -3365,16 +3370,31 @@ export class LocalBackend {
const symId = sym.id;
// Categorized incoming refs
const incomingRows = await executeParameterized(
repo.lbugPath,
`
const [incomingRows, incomingAdvisedRows] = await Promise.all([
executeParameterized(
repo.lbugPath,
`
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
LIMIT 30
`,
{ symId },
);
{ symId },
),
// Keep high-fan-in advice edges out of the legacy 30-row context window.
// A broad pointcut can advise hundreds of methods; sharing that LIMIT
// would make CALLS/HAS_METHOD/etc. disappear nondeterministically.
executeParameterized(
repo.lbugPath,
`
MATCH (caller)-[r:CodeRelation {type: 'ADVISED_BY'}]->(n {id: $symId})
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
LIMIT 30
`,
{ symId },
),
]);
incomingRows.push(...incomingAdvisedRows);
let typedPropertyRows: any[] = [];
// Fix #480: Class/Interface nodes have no direct CALLS/IMPORTS edges —
@@ -3493,16 +3513,28 @@ export class LocalBackend {
}
// Categorized outgoing refs
const outgoingRows = await executeParameterized(
repo.lbugPath,
`
const [outgoingRows, outgoingAdvisedRows] = await Promise.all([
executeParameterized(
repo.lbugPath,
`
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
LIMIT 30
`,
{ symId },
);
{ symId },
),
executeParameterized(
repo.lbugPath,
`
MATCH (n {id: $symId})-[r:CodeRelation {type: 'ADVISED_BY'}]->(target)
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
LIMIT 30
`,
{ symId },
),
]);
outgoingRows.push(...outgoingAdvisedRows);
// Process participation
let processRows: any[] = [];
@@ -3564,6 +3596,7 @@ export class LocalBackend {
(sym.name || sym[1]) as string,
);
const beanMetadataPromise = queryClassBeanMetadata(repo.lbugPath, symId, epistemicSymType);
const aopMetadataPromise = querySpringAopMetadata(repo.lbugPath, symId, epistemicSymType);
let methodMetadata: Record<string, unknown> | undefined;
if (isMethodLike) {
@@ -3602,7 +3635,11 @@ export class LocalBackend {
// dynamic dispatch are not reflected in `incoming`, so the view is a lower
// bound. Additive; never suppresses a field. Resolved from the probe started
// above (concurrent with methodMetadata).
const [epistemic, beanMetadata] = await Promise.all([epistemicPromise, beanMetadataPromise]);
const [epistemic, beanMetadata, aopMetadata] = await Promise.all([
epistemicPromise,
beanMetadataPromise,
aopMetadataPromise,
]);
return {
status: 'found',
@@ -3616,6 +3653,7 @@ export class LocalBackend {
...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
...(methodMetadata ? { methodMetadata } : {}),
...(beanMetadata ? { bean: beanMetadata } : {}),
...(aopMetadata ? { aop: aopMetadata } : {}),
},
...epistemic,
incoming: categorize(incomingRows),
@@ -5906,7 +5944,10 @@ export class LocalBackend {
opts.skipEpistemic || summaryOnly
? Promise.resolve(undefined)
: queryClassBeanMetadata(repo.lbugPath, symId, symType);
const aopMetadataPromise =
opts.skipEpistemic || summaryOnly
? Promise.resolve(undefined)
: querySpringAopMetadata(repo.lbugPath, symId, symType);
const impacted: any[] = [];
const visited = new Set<string>([symId]);
const pdgBridgeEvidenceById = new Map<string, PdgBridgeEvidenceInfo>();
@@ -6388,7 +6429,11 @@ export class LocalBackend {
// #1858 — await the epistemic boundary probe kicked off alongside the BFS
// above. Additive: leaves impactedCount and every existing field untouched.
const [epistemic, beanMetadata] = await Promise.all([epistemicPromise, beanMetadataPromise]);
const [epistemic, beanMetadata, aopMetadata] = await Promise.all([
epistemicPromise,
beanMetadataPromise,
aopMetadataPromise,
]);
const base = {
target: {
@@ -6397,6 +6442,7 @@ export class LocalBackend {
type: symType,
filePath: sym.filePath || sym[2],
...(beanMetadata ? { bean: beanMetadata } : {}),
...(aopMetadata ? { aop: aopMetadata } : {}),
},
direction,
impactedCount: impacted.length,
+1 -1
View File
@@ -534,7 +534,7 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
type: 'array',
items: { type: 'string' },
description:
'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES (default: usage-based, ACCESSES excluded by default). DI edges (consumer Class or factory Method → provider Class or declaration CodeElement) require explicitly including INJECTS.',
'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES (default: usage-based, ACCESSES excluded by default). DI edges require INJECTS; Spring proxy/advice edges require ADVISED_BY.',
},
includeTests: { type: 'boolean', description: 'Include test files (default: false)' },
minConfidence: {
+4 -1
View File
@@ -146,7 +146,10 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// v36: bound-callable graph `startLine` follows the initializer so multi-line
// closure bindings join the scope channel (#2735). Warm cache would otherwise
// keep serving wrapper-line startLines and drop the CALLS edge.
const SCHEMA_BUMP = 36;
// v37: Java/Kotlin capture side-channels include Spring AOP owner/advice facts
// (#2416). Warm cache entries at v36 do not carry those facts and would silently
// omit ADVISED_BY evidence.
const SCHEMA_BUMP = 37;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from
+6 -1
View File
@@ -668,8 +668,13 @@ export interface RepoMeta {
* reuse gate is exact equality, so an index already stamped 32 would satisfy
* it against a differently-shaped reverted DB. Start the next allocation at
* 33 instead.)
*
* v33: Spring AOP evidence adds the Interface→CodeElement relation pair
* (#2416). LadybugDB fixes allowed endpoint pairs when the relation table is
* created, so an older index cannot persist these edges through incremental
* writeback. Force a full re-analyze.
*/
export const INCREMENTAL_SCHEMA_VERSION = 32;
export const INCREMENTAL_SCHEMA_VERSION = 33;
export interface IndexedRepo {
repoPath: string;
@@ -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');
@@ -71,6 +71,7 @@ withTestLbugDB(
'HAS_METHOD',
'METHOD_OVERRIDES',
'ACCESSES',
'ADVISED_BY',
];
const invalidTypes = ['CONTAINS', 'STEP_IN_PROCESS', 'MEMBER_OF', 'DROP_TABLE'];
@@ -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);
});
@@ -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;
},
},
);
@@ -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);
});
@@ -6,6 +6,7 @@ import {
type AnalysisFeatureDescriptor,
} from '../../src/core/analysis-features.js';
import {
SPRING_AOP_FEATURE,
SPRING_BEAN_INVENTORY_FEATURE,
SPRING_CONDITIONALS_FEATURE,
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
@@ -13,6 +14,7 @@ import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languag
const FEATURES = [
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
SPRING_AOP_FEATURE,
SPRING_BEAN_INVENTORY_FEATURE,
SPRING_CONDITIONALS_FEATURE,
SPRING_CONFIG_BINDINGS_FEATURE,
@@ -25,12 +27,14 @@ describe('analysis feature versions', () => {
});
expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({
'graph.class-framework-annotations': 1,
'spring.aop-advice': 1,
'spring.bean-inventory': 2,
'spring.conditionals-auto-configuration': 1,
'spring.config-bindings': 1,
});
expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({
'graph.class-framework-annotations': 1,
'spring.aop-advice': 1,
'spring.bean-inventory': 2,
'spring.conditionals-auto-configuration': 1,
});
@@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => {
});
describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
it('INCREMENTAL_SCHEMA_VERSION is bumped to 32 (Rust/Swift/JS-TS member-containment DDL, #2769)', () => {
it('INCREMENTAL_SCHEMA_VERSION is bumped to 33 (Spring AOP relation pairs, #2416)', () => {
// Moves with every bump BY DESIGN — that is the point of pinning it. A
// change that alters emitted ids or edges without bumping would otherwise
// ship silently, and an existing index would keep serving the old graph
// through the reuse gate below.
expect(INCREMENTAL_SCHEMA_VERSION).toBe(32);
expect(INCREMENTAL_SCHEMA_VERSION).toBe(33);
});
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
@@ -213,7 +213,10 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
// top-up emitting one of those edges would fail the bulk COPY (or silently
// drop it on the streamed path) → must NOT reuse.
expect(passesReuseGate(31)).toBe(false);
// A pre-v33 (v32) index predates the Spring AOP Interface→CodeElement
// relation pair (#2416), so it cannot persist all evidence edges.
expect(passesReuseGate(32)).toBe(false);
// The current stamp passes the gate (incremental top-up eligible).
expect(passesReuseGate(32)).toBe(true);
expect(passesReuseGate(33)).toBe(true);
});
});
@@ -44,9 +44,14 @@ import {
} from '../helpers/embedding-seed.js';
import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js';
import {
SPRING_AOP_FEATURE,
SPRING_BEAN_INVENTORY_FEATURE,
SPRING_CONDITIONALS_FEATURE,
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
import {
decodeSpringAopReason,
SPRING_AOP_EVIDENCE_ID_PREFIX,
} from '../../src/core/ingestion/frameworks/spring/aop.js';
import { SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX } from '../../src/core/ingestion/frameworks/spring/auto-configuration.js';
import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languages/java/analysis-features.js';
@@ -107,6 +112,55 @@ async function setupKotlinSpringBeanIncrementalRepo() {
return repo;
}
const springAopAspectSource = (pointcut: string): string =>
`package com.example;\n` +
`import org.aspectj.lang.annotation.Aspect;\n` +
`import org.aspectj.lang.annotation.Before;\n\n` +
`@Aspect public class TraceAspect {\n` +
` @Before("${pointcut}") public void trace() {}\n` +
`}\n`;
async function setupSpringAopIncrementalRepo() {
const repo = await createTempDir('gitnexus-incr-spring-aop-');
const javaSrc = path.join(repo.dbPath, 'src', 'main', 'java', 'com', 'example');
const kotlinSrc = path.join(repo.dbPath, 'src', 'main', 'kotlin', 'com', 'example');
const resources = path.join(repo.dbPath, 'src', 'main', 'resources');
await Promise.all([
mkdir(javaSrc, { recursive: true }),
mkdir(kotlinSrc, { recursive: true }),
mkdir(resources, { recursive: true }),
]);
await Promise.all([
writeFile(path.join(repo.dbPath, '.gitignore'), '.gitnexus/\n', 'utf-8'),
writeFile(
path.join(javaSrc, 'FirstService.java'),
'package com.example;\n\n' +
'public class FirstService {\n' +
' public void first() {}\n' +
'}\n',
'utf-8',
),
writeFile(
path.join(javaSrc, 'TraceAspect.java'),
springAopAspectSource('within(com.example.FirstService)'),
'utf-8',
),
writeFile(
path.join(kotlinSrc, 'KotlinService.kt'),
'package com.example\n\n' +
'import org.springframework.transaction.annotation.Transactional as Tx\n\n' +
'class KotlinService {\n' +
' @Tx fun kotlinTx() {}\n' +
'}\n',
'utf-8',
),
writeFile(path.join(resources, 'application.properties'), 'feature.enabled=true\n', 'utf-8'),
]);
execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' });
gitCommitAll(repo.dbPath, 'initial spring aop fixture');
return repo;
}
async function setupSpringBeanFactoryIncrementalRepo() {
const repo = await createTempDir('gitnexus-incr-spring-bean-factory-');
const src = path.join(repo.dbPath, 'src', 'com', 'other');
@@ -236,6 +290,81 @@ async function countSpringAutoConfigurationSyntheticClasses(repoPath: string): P
}
}
interface SpringAopPersistedRelationship {
readonly relType: string;
readonly sourceId: string;
readonly sourceName: string;
readonly targetId: string;
readonly targetName: string;
readonly reason: string;
}
interface SpringAopPersistedEvidence {
readonly id: string;
readonly description: string;
}
interface SpringAopPersistedSnapshot {
readonly relationships: readonly SpringAopPersistedRelationship[];
readonly evidence: readonly SpringAopPersistedEvidence[];
}
async function readSpringAopSnapshot(repoPath: string): Promise<SpringAopPersistedSnapshot> {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const { lbugPath } = getStoragePaths(repoPath);
await adapter.initLbug(lbugPath);
try {
const relationships = (await adapter.executeQuery(
`MATCH (s)-[r:CodeRelation]->(t) ` +
`WHERE (r.type = 'ADVISED_BY' OR r.type = 'DECLARES') ` +
`AND r.reason STARTS WITH 'spring-aop:v1:' ` +
`RETURN r.type AS relType, s.id AS sourceId, s.name AS sourceName, ` +
`t.id AS targetId, t.name AS targetName, r.reason AS reason ` +
`ORDER BY relType, sourceId, targetId, reason`,
)) as SpringAopPersistedRelationship[];
const evidence = (await adapter.executeQuery(
`MATCH (n:CodeElement) WHERE n.id STARTS WITH '${SPRING_AOP_EVIDENCE_ID_PREFIX}' ` +
`RETURN n.id AS id, n.description AS description ORDER BY id`,
)) as SpringAopPersistedEvidence[];
return { relationships, evidence };
} finally {
await adapter.closeLbug();
}
}
function assertSpringAopSnapshotShape(
snapshot: SpringAopPersistedSnapshot,
expectedAdviceSource: string,
): void {
expect(snapshot.relationships).toHaveLength(4);
expect(snapshot.evidence).toHaveLength(3);
const decoded = snapshot.relationships.map((relationship) => ({
relationship,
reason: decodeSpringAopReason(relationship.reason),
}));
expect(decoded.map(({ reason }) => reason?.kind).sort()).toEqual([
'advice',
'aspect',
'behavior',
'pointcut',
]);
expect(decoded.find(({ reason }) => reason?.kind === 'behavior')?.relationship.sourceName).toBe(
'kotlinTx',
);
const advice = decoded.find(({ reason }) => reason?.kind === 'advice')?.relationship;
expect(advice?.sourceName).toBe(expectedAdviceSource);
expect(advice?.targetName).toBe('trace');
expect(
new Set(
snapshot.relationships.map(
(relationship) =>
`${relationship.relType}\0${relationship.sourceId}\0${relationship.targetId}\0${relationship.reason}`,
),
).size,
).toBe(snapshot.relationships.length);
expect(new Set(snapshot.evidence.map(({ id }) => id)).size).toBe(snapshot.evidence.length);
}
/** Java DI fixture (#2200): `@Autowired List<IFoo>` + 2 implementers ⇒ exactly
* 2 INJECTS edges (Consumer→FooA, Consumer→FooB). Same shapes as the
* spring-di-pipeline integration fixture. */
@@ -345,6 +474,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
const meta = await loadMeta(storagePath);
expect(meta!.analysisFeatures).toEqual({
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
[SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version,
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
});
@@ -362,6 +492,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([SPRING_SERVICE]);
expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
[SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version,
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
});
@@ -430,6 +561,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
expect(logs.join('\n')).not.toContain('Incremental:');
expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
[SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version,
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
});
@@ -438,6 +570,81 @@ describe('runFullAnalysis — incremental orchestration', () => {
}
}, 300_000);
it('replaces Spring AOP evidence across real incremental runs without duplicates', async () => {
const repo = await setupSpringAopIncrementalRepo();
try {
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
const firstSnapshot = await readSpringAopSnapshot(repo.dbPath);
assertSpringAopSnapshotShape(firstSnapshot, 'first');
const firstPointcutEvidenceId = firstSnapshot.relationships.find(
({ reason }) => decodeSpringAopReason(reason)?.kind === 'pointcut',
)?.targetId;
expect(firstPointcutEvidenceId).toBeDefined();
const aspectPath = path.join(
repo.dbPath,
'src',
'main',
'java',
'com',
'example',
'TraceAspect.java',
);
await writeFile(
aspectPath,
springAopAspectSource(
'@annotation(org.springframework.transaction.annotation.Transactional)',
),
'utf-8',
);
gitCommitAll(repo.dbPath, 'retarget spring advice');
const retargetLogs: string[] = [];
const retargeted = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true },
{ onProgress: () => {}, onLog: (message) => retargetLogs.push(message) },
);
expect(retargeted.alreadyUpToDate).toBeUndefined();
expect(retargetLogs.join('\n')).toContain('Incremental: changed=1');
expect(retargetLogs.join('\n')).not.toContain('switching to a full DB write');
const secondSnapshot = await readSpringAopSnapshot(repo.dbPath);
assertSpringAopSnapshotShape(secondSnapshot, 'kotlinTx');
const secondPointcutEvidenceId = secondSnapshot.relationships.find(
({ reason }) => decodeSpringAopReason(reason)?.kind === 'pointcut',
)?.targetId;
expect(secondPointcutEvidenceId).toBeDefined();
expect(secondPointcutEvidenceId).not.toBe(firstPointcutEvidenceId);
expect(secondSnapshot.evidence.map(({ id }) => id)).not.toContain(firstPointcutEvidenceId);
const propertyPath = path.join(
repo.dbPath,
'src',
'main',
'resources',
'application.properties',
);
await writeFile(propertyPath, 'feature.enabled=false\n', 'utf-8');
gitCommitAll(repo.dbPath, 'change unrelated resource');
const replayLogs: string[] = [];
const replayed = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true },
{
onLog: (message) => replayLogs.push(message),
onProgress: () => {},
},
);
expect(replayed.alreadyUpToDate).toBeUndefined();
expect(replayLogs.join('\n')).toContain('Incremental: changed=1');
expect(replayLogs.join('\n')).not.toContain('switching to a full DB write');
expect(await readSpringAopSnapshot(repo.dbPath)).toEqual(secondSnapshot);
} finally {
await repo.cleanup();
}
}, 600_000);
it('second run after a comment-only edit takes the incremental path, clears the dirty flag, and preserves graph stats exactly', async () => {
const repo = await setupMiniRepo();
try {
@@ -101,11 +101,9 @@ describe('fileContentHash', () => {
});
describe('PARSE_CACHE_VERSION', () => {
// 35 -> 36 for the bound-callable start-line join (#2735). Updated
// deliberately after main independently took 35 for Spring side-channel
// captures (#2413), so the pin continues to catch concurrent bump collisions.
it('pins SCHEMA_BUMP to 36 so concurrent bumps cannot silently collide (#2736)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(36);
// 36 -> 37 for Java/Kotlin Spring AOP capture side-channels (#2416).
it('pins SCHEMA_BUMP to 37 so concurrent bumps cannot silently collide (#2416)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(37);
});
it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {
@@ -156,6 +156,20 @@ describe('extractChangedSubgraph', () => {
expect(sub.relationships.map((r) => r.id)).toEqual(['inj1']);
});
it('always includes ADVISED_BY edges even between two unchanged files (#2416)', () => {
const g = createKnowledgeGraph();
g.addNode(makeFileNode('service:Method', '/repo/Service.java', 'Method'));
g.addNode(makeFileNode('aspect:Method', '/repo/Aspect.java', 'Method'));
g.addRelationship(
makeRel('advised1', 'service:Method', 'aspect:Method', 'ADVISED_BY', 'spring-aop:v1:{}'),
);
g.addRelationship(makeRel('call1', 'service:Method', 'aspect:Method', 'CALLS'));
const sub = extractChangedSubgraph(g, new Set(['/repo/AnnotationShadow.java']));
expect(sub.relationships.map((relationship) => relationship.id)).toEqual(['advised1']);
});
it('always includes Spring DECLARES edges between unchanged metadata and classes (#2415)', () => {
const g = createKnowledgeGraph();
g.addNode(makeFileNode('metadata:File', '/repo/META-INF/spring.factories', 'File'));
@@ -191,6 +205,22 @@ describe('extractChangedSubgraph', () => {
});
describe('computeEffectiveWriteSet (Finding 1)', () => {
it('does not expand through graph-wide ADVISED_BY edges rebuilt by their owner phase', () => {
const g = createKnowledgeGraph();
g.addNode(makeFileNode('aspect:advice', '/repo/Aspect.java', 'Method'));
for (let index = 0; index < 100; index += 1) {
const serviceId = `service:${index}`;
g.addNode(makeFileNode(serviceId, `/repo/Service${index}.java`, 'Method'));
g.addRelationship(
makeRel(`advised:${index}`, serviceId, 'aspect:advice', 'ADVISED_BY', 'spring-aop:v1:{}'),
);
}
const effective = computeEffectiveWriteSet(g, new Set(['/repo/Aspect.java']));
expect([...effective]).toEqual(['/repo/Aspect.java']);
});
it('barrel re-export — expands the writable set to the consumer file', () => {
// Scenario: file C (a barrel) used to re-export from B; now re-exports
// from D. File A is unchanged byte-wise but its CALLS to foo() now
@@ -76,15 +76,22 @@ const FULL_ORDER = [
'crossFile',
'scopeResolution',
'springAutoConfiguration',
'springAop',
'pruneLocalSymbols',
'mro',
'springAopInheritance',
'di',
'communities',
'processes',
];
const WITHOUT_GRAPH_PHASES = FULL_ORDER.filter(
(n) => n !== 'mro' && n !== 'di' && n !== 'communities' && n !== 'processes',
(n) =>
n !== 'mro' &&
n !== 'springAopInheritance' &&
n !== 'di' &&
n !== 'communities' &&
n !== 'processes',
);
describe('buildPhaseList parity (registry refactor, #2080)', () => {
+3 -1
View File
@@ -116,6 +116,7 @@ describe('LadybugDB Schema', () => {
it('includes Spring condition and auto-configuration edge types (#2415)', () => {
expect(REL_TYPES).toContain('CONDITIONAL_ON');
expect(REL_TYPES).toContain('DECLARES');
expect(REL_TYPES).toContain('ADVISED_BY');
expect(REL_TYPES).not.toContain('AUTO_REGISTERS');
});
});
@@ -209,8 +210,9 @@ describe('LadybugDB Schema', () => {
expect(RELATION_SCHEMA).toContain('FROM BasicBlock TO BasicBlock');
});
it('persists consumer Class injection edges to synthetic provider declarations', () => {
it('persists class-like framework edges to synthetic declarations (#2416)', () => {
expect(RELATION_SCHEMA).toContain('FROM Class TO CodeElement');
expect(RELATION_SCHEMA).toContain('FROM Interface TO CodeElement');
});
it('declares the Swift enum/property member-containment pairs (#2769)', () => {
+2
View File
@@ -42,6 +42,8 @@ describe('VALID_RELATION_TYPES', () => {
// Conditional activation and metadata declaration/discovery (#2415)
'CONDITIONAL_ON',
'DECLARES',
// Spring proxy/advice evidence (#2416)
'ADVISED_BY',
] as const;
it('contains all expected relation types', () => {
@@ -3,6 +3,7 @@ import {
normalizeSpringBeanType,
parseSpringAnnotationArguments,
parseStaticClassLiteral,
parseStaticStringLiteral,
parseStaticStringValues,
} from '../../src/core/ingestion/frameworks/spring/annotation-arguments.js';
import {
@@ -45,6 +46,31 @@ describe('Spring annotation static arguments', () => {
expect(parseStaticClassLiteral('Object.class')).toBe('');
});
it('parses static Kotlin raw strings and rejects raw string templates', () => {
expect(parseStaticStringLiteral('"""within(com.example.service.KotlinOrderService)"""')).toBe(
'within(com.example.service.KotlinOrderService)',
);
expect(parseStaticStringValues('["one", """two, three"""]')).toEqual(['one', 'two, three']);
expect(parseStaticStringLiteral('"""bean-$name"""')).toBeNull();
expect(parseStaticStringLiteral('"""bean-${expression}"""')).toBeNull();
expect(parseStaticStringValues('["""static""", """$name"""]')).toBeNull();
});
it('keeps commas, assignments, and ordinary quotes inside Kotlin raw arguments', () => {
expect(
parseSpringAnnotationArguments(
'@AfterReturning(pointcut = """execution(* com.example.Service.run(*,*)) && args("quoted=value")""", returning = "result")',
),
).toEqual([
{
name: 'pointcut',
value: '"""execution(* com.example.Service.run(*,*)) && args("quoted=value")"""',
},
{ name: 'returning', value: '"result"' },
]);
});
it('normalizes Kotlin nullability, mutable collections, projections, and bean generics', () => {
expect(normalizeSpringBeanType('MutableList<out Gateway?>?')).toBe('List');
expect(normalizeSpringBeanType('com.example.Gateway')).toBe('com.example.Gateway');
+372
View File
@@ -0,0 +1,372 @@
import type { GraphNode } from 'gitnexus-shared';
import { describe, expect, it } from 'vitest';
import {
decodeSpringAopReason,
encodeSpringAopReason,
isSpringAopEvidenceNode,
parseSpringAopPointcut,
springAopPointcutMatches,
type SpringAopReason,
} from '../../src/core/ingestion/frameworks/spring/aop.js';
import {
createSpringAopCandidateIndex,
type SpringAopOwnedMethod,
} from '../../src/core/ingestion/frameworks/spring/aop-candidates.js';
const owner: GraphNode = {
id: 'Class:com.example.service.OrderService',
label: 'Class',
properties: {
name: 'OrderService',
qualifiedName: 'com.example.service.OrderService',
filePath: 'src/OrderService.java',
startLine: 1,
endLine: 20,
isExported: true,
},
};
const method: GraphNode = {
id: 'Method:com.example.service.OrderService.run',
label: 'Method',
properties: {
name: 'run',
qualifiedName: 'com.example.service.OrderService.run',
filePath: 'src/OrderService.java',
startLine: 3,
endLine: 5,
isExported: true,
visibility: 'public',
parameterCount: 1,
},
};
describe('Spring AOP static pointcuts (#2416)', () => {
it('parses the deliberately narrow execution, within, and known @annotation subset', () => {
expect(parseSpringAopPointcut('execution(public * com.example..OrderService.r*( * ))')).toEqual(
{
kind: 'execution',
ownerPattern: 'com.example..OrderService',
methodPattern: 'r*',
visibility: 'public',
parameterCount: 1,
},
);
expect(parseSpringAopPointcut('within(com.example..service.*)')).toEqual({
kind: 'within',
ownerPattern: 'com.example..service.*',
});
expect(
parseSpringAopPointcut(
'@annotation(org.springframework.transaction.annotation.Transactional)',
),
).toEqual({
kind: 'annotation',
annotation: 'org.springframework.transaction.annotation.Transactional',
});
});
it('matches owner, method, visibility, arity, and resolved method annotations', () => {
const execution = parseSpringAopPointcut('execution(public * com.example..OrderService.r*(*))');
const within = parseSpringAopPointcut('within(com.example..OrderService)');
const annotation = parseSpringAopPointcut(
'@annotation(org.springframework.transaction.annotation.Transactional)',
);
expect(execution && springAopPointcutMatches(execution, owner, method)).toBe(true);
expect(within && springAopPointcutMatches(within, owner, method)).toBe(true);
expect(
annotation &&
springAopPointcutMatches(
annotation,
owner,
method,
new Set(['org.springframework.transaction.annotation.Transactional']),
),
).toBe(true);
});
it('treats an unmodified interface method as public for execution(public ...)', () => {
const interfaceOwner: GraphNode = { ...owner, label: 'Interface' };
const implicitPublicMethod: GraphNode = {
...method,
properties: { ...method.properties, visibility: 'package' },
};
const execution = parseSpringAopPointcut(
'execution(public * com.example..OrderService.run(*))',
);
expect(
execution && springAopPointcutMatches(execution, interfaceOwner, implicitPublicMethod),
).toBe(true);
expect(execution && springAopPointcutMatches(execution, owner, implicitPublicMethod)).toBe(
false,
);
});
it('matches unqualified wildcard type patterns against the owner simple name', () => {
const within = parseSpringAopPointcut('within(*Service)');
const execution = parseSpringAopPointcut('execution(* *Service.run(..))');
expect(within && springAopPointcutMatches(within, owner, method)).toBe(true);
expect(execution && springAopPointcutMatches(execution, owner, method)).toBe(true);
});
it.each(['within(OrderService)', 'execution(* OrderService.run(..))'])(
'fails closed when a simple exact type needs unavailable import context: %s',
(expression) => {
expect(parseSpringAopPointcut(expression)).toBeNull();
},
);
it.each([
'namedPointcut()',
'execution(* com.example..OrderService.*(..)) && args(orderId)',
'execution(String com.example.OrderService.run(..))',
'@annotation(com.example.DynamicMarker)',
'bean(orderService)',
])('fails closed for unsupported or dynamic expression %s', (expression) => {
expect(parseSpringAopPointcut(expression)).toBeNull();
});
it('rejects overlong input instead of truncating away a dynamic suffix', () => {
const start = 'execution(* ';
const end = '.run(..))';
const validPrefix = `${start}${'a'.repeat(1_000 - start.length - end.length)}${end}`;
expect(validPrefix).toHaveLength(1_000);
expect(parseSpringAopPointcut(`${validPrefix} && args(value)`)).toBeNull();
});
it('matches adversarial wildcard input without regex backtracking', () => {
const wildcardChain = '*a'.repeat(10);
const execution = parseSpringAopPointcut(
`execution(* com.example..OrderService.${wildcardChain}z(..))`,
);
const within = parseSpringAopPointcut(`within(com.example.${wildcardChain}z)`);
const adversarialMethod = {
...method,
properties: { ...method.properties, name: 'a'.repeat(24) },
};
const adversarialOwner = {
...owner,
properties: {
...owner.properties,
qualifiedName: `com.example.${'a'.repeat(24)}`,
},
};
const startedAt = performance.now();
expect(execution && springAopPointcutMatches(execution, owner, adversarialMethod)).toBe(false);
expect(within && springAopPointcutMatches(within, adversarialOwner, method)).toBe(false);
expect(performance.now() - startedAt).toBeLessThan(100);
});
});
describe('Spring AOP pointcut candidate index (#2416)', () => {
it('selects only methods that directly declare an @annotation behavior', () => {
const annotated = { method, owner } satisfies SpringAopOwnedMethod;
const plain = {
owner,
method: {
...method,
id: 'Method:com.example.service.OrderService.plain',
properties: { ...method.properties, name: 'plain' },
},
} satisfies SpringAopOwnedMethod;
const pointcut = parseSpringAopPointcut(
'@annotation(org.springframework.transaction.annotation.Transactional)',
);
expect(pointcut).not.toBeNull();
const index = createSpringAopCandidateIndex(
[annotated, plain],
new Map([[method.id, new Set(['org.springframework.transaction.annotation.Transactional'])]]),
);
expect(index.candidatesFor(pointcut!)).toEqual([annotated]);
});
it('narrows exact and literal-prefix owner pointcuts without excluding matches', () => {
const ownedMethod = (qualifiedName: string): SpringAopOwnedMethod => {
const ownerName = qualifiedName.slice(qualifiedName.lastIndexOf('.') + 1);
return {
owner: {
...owner,
id: `Class:${qualifiedName}`,
properties: { ...owner.properties, name: ownerName, qualifiedName },
},
method: {
...method,
id: `Method:${qualifiedName}.run`,
properties: { ...method.properties, qualifiedName: `${qualifiedName}.run` },
},
};
};
const order = ownedMethod('com.example.service.OrderService');
const payment = ownedMethod('com.example.billing.PaymentService');
const legacy = ownedMethod('org.legacy.LegacyService');
const candidates = [order, payment, legacy];
const index = createSpringAopCandidateIndex(candidates, new Map());
const exact = parseSpringAopPointcut('within(com.example.service.OrderService)');
const prefixed = parseSpringAopPointcut('within(com.example..*Service)');
const leadingWildcard = parseSpringAopPointcut('within(*..*Service)');
expect(exact && index.candidatesFor(exact)).toEqual([order]);
expect(prefixed && index.candidatesFor(prefixed)).toEqual([payment, order]);
expect(leadingWildcard && index.candidatesFor(leadingWildcard)).toEqual(candidates);
});
it('preserves brute-force results across exact, wildcard, descendant, and annotation pointcuts', () => {
const ownedMethod = (
qualifiedName: string,
methodName: string,
filePath: string,
): SpringAopOwnedMethod => ({
owner: {
...owner,
id: `Class:${filePath}:${qualifiedName}`,
properties: { ...owner.properties, qualifiedName, filePath },
},
method: {
...method,
id: `Method:${filePath}:${qualifiedName}.${methodName}`,
properties: {
...method.properties,
name: methodName,
qualifiedName: `${qualifiedName}.${methodName}`,
filePath,
},
},
});
const candidates = [
ownedMethod('com.example.service.OrderService', 'run', 'OrderService.java'),
ownedMethod('com.example.service.OrderService', 'read', 'OrderService.kt'),
ownedMethod('com.example.billing.InvoiceService', 'run', 'InvoiceService.kt'),
ownedMethod('org.legacy.LegacyService', 'run', 'LegacyService.java'),
];
const transactional = 'org.springframework.transaction.annotation.Transactional';
const methodAnnotations = new Map([[candidates[1]!.method.id, new Set([transactional])]]);
const index = createSpringAopCandidateIndex(candidates, methodAnnotations);
const expressions = [
'within(com.example.service.OrderService)',
'within(com.example..*Service)',
'within(*..*Service)',
'execution(public * com.example..*Service.r*( * ))',
`@annotation(${transactional})`,
];
for (const expression of expressions) {
const pointcut = parseSpringAopPointcut(expression);
expect(pointcut, expression).not.toBeNull();
const matchingIds = (pool: readonly SpringAopOwnedMethod[]) =>
pool
.filter((candidate) =>
springAopPointcutMatches(
pointcut!,
candidate.owner,
candidate.method,
methodAnnotations.get(candidate.method.id),
),
)
.map((candidate) => candidate.method.id)
.sort();
expect(matchingIds(index.candidatesFor(pointcut!)), expression).toEqual(
matchingIds(candidates),
);
}
});
});
describe('Spring AOP persisted reason contract (#2416)', () => {
const reasons: readonly SpringAopReason[] = [
{
kind: 'behavior',
annotation: 'org.springframework.transaction.annotation.Transactional',
behavior: 'transactional',
declaredOn: 'method',
activation: 'unknown',
proxy: 'possible',
},
{
kind: 'behavior',
annotation: 'org.springframework.cache.annotation.Caching',
behavior: 'caching',
declaredOn: 'method',
activation: 'unknown',
proxy: 'possible',
},
{
kind: 'advice',
annotation: 'org.aspectj.lang.annotation.Around',
advice: 'around',
pointcut: 'execution(* com.example..OrderService.*(..))',
match: 'static',
activation: 'unknown',
proxy: 'possible',
},
{
kind: 'pointcut',
annotation: 'org.aspectj.lang.annotation.Before',
pointcut: 'namedPointcut()',
match: 'unresolved',
resolution: 'unknown',
},
{
kind: 'pointcut',
annotation: 'org.aspectj.lang.annotation.After',
pointcut: null,
match: 'unresolved',
resolution: 'unknown',
},
{
kind: 'aspect',
annotation: 'org.aspectj.lang.annotation.Aspect',
activation: 'unknown',
registration: 'unknown',
},
];
it.each(reasons)('round-trips the $kind reason', (reason) => {
expect(decodeSpringAopReason(encodeSpringAopReason(reason))).toEqual(reason);
});
it.each([
'spring-aop:v2:{}',
'spring-aop:v1:not-json',
`spring-aop:v1:${JSON.stringify({
kind: 'behavior',
annotation: 'com.example.FakeTransactional',
behavior: 'transactional',
declaredOn: 'method',
activation: 'unknown',
proxy: 'possible',
})}`,
`spring-aop:v1:${JSON.stringify({
kind: 'pointcut',
annotation: 'org.aspectj.lang.annotation.Before',
pointcut: 'execution(* com.example.Service.run(..))',
match: 'static',
resolution: 'unknown',
})}`,
])('rejects malformed, foreign, or internally inconsistent reason %s', (reason) => {
expect(decodeSpringAopReason(reason)).toBeUndefined();
});
it('identifies owned evidence by its stable ID namespace, not mutable prose', () => {
expect(
isSpringAopEvidenceNode({
...method,
id: 'CodeElement:spring-aop:evidence',
label: 'CodeElement',
}),
).toBe(true);
expect(
isSpringAopEvidenceNode({
...method,
id: 'CodeElement:ordinary',
label: 'CodeElement',
properties: { ...method.properties, description: 'Spring AOP: lookalike' },
}),
).toBe(false);
});
});
@@ -5,6 +5,7 @@ import { PARSE_CACHE_VERSION } from '../../src/storage/parse-cache.js';
import { INCREMENTAL_SCHEMA_VERSION } from '../../src/storage/repo-manager.js';
import { isSpringBeanCandidateSourceFile } from '../../src/core/ingestion/frameworks/spring/bean-catalog.js';
import {
SPRING_AOP_FEATURE,
SPRING_BEAN_INVENTORY_FEATURE,
SPRING_CONDITIONALS_FEATURE,
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
@@ -24,6 +25,7 @@ describe('Spring Bean Class persistence schema', () => {
expect(parseSchemaVersion).toBeGreaterThanOrEqual(31);
expect(INCREMENTAL_SCHEMA_VERSION).toBeGreaterThanOrEqual(23);
expect(CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version).toBe(1);
expect(SPRING_AOP_FEATURE.version).toBe(1);
expect(SPRING_BEAN_INVENTORY_FEATURE.version).toBe(2);
expect(SPRING_CONDITIONALS_FEATURE.version).toBe(1);
});
+2
View File
@@ -70,6 +70,7 @@ export default defineConfig({
'test/integration/lbug-pool-stability.test.ts',
'test/integration/local-backend.test.ts',
'test/integration/local-backend-calltool.test.ts',
'test/integration/spring-aop-mcp.test.ts',
'test/integration/search-core.test.ts',
'test/integration/search-pool.test.ts',
'test/integration/fts-description-search.test.ts',
@@ -116,6 +117,7 @@ export default defineConfig({
'test/integration/lbug-pool-stability.test.ts',
'test/integration/local-backend.test.ts',
'test/integration/local-backend-calltool.test.ts',
'test/integration/spring-aop-mcp.test.ts',
'test/integration/search-core.test.ts',
'test/integration/search-pool.test.ts',
'test/integration/fts-description-search.test.ts',