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:
+144
-132
@@ -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
|
||||
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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 })
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user