mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-16 23:43:12 +00:00
Merge branch 'main' into fix/skill-evolution-gate
This commit is contained in:
commit
e1df209367
129 changed files with 10287 additions and 478 deletions
|
|
@ -358,7 +358,7 @@ jobs:
|
|||
|
||||
- name: Ensure Python (arm64 Windows only)
|
||||
if: matrix.platform_arch == 'win32-arm64'
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
|
|
|||
1
.github/workflows/ci-tests.yml
vendored
1
.github/workflows/ci-tests.yml
vendored
|
|
@ -563,6 +563,7 @@ jobs:
|
|||
test/integration/cobol-pipeline-benchmark.test.ts
|
||||
test/integration/csharp-pipeline-benchmark.test.ts
|
||||
test/integration/instance-ownership-pipeline-benchmark.test.ts
|
||||
test/integration/spring-bean-resource-benchmark.test.ts
|
||||
test/integration/rust-pipeline-benchmark.test.ts
|
||||
test/integration/php-pipeline-benchmark.test.ts
|
||||
test/integration/ruby-pipeline-benchmark.test.ts
|
||||
|
|
|
|||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
|
@ -73,6 +73,6 @@ jobs:
|
|||
- '**/test/**/fixtures/**'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
|
|
|||
2
.github/workflows/pr-labeler.yml
vendored
2
.github/workflows/pr-labeler.yml
vendored
|
|
@ -108,7 +108,7 @@ jobs:
|
|||
# Pinned to v7.2.0. Verify SHA via:
|
||||
# gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0
|
||||
# v7 removed `disable-releaser`; use `dry-run: true` to only autolabel.
|
||||
- uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
||||
- uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
dry-run: true
|
||||
|
|
|
|||
2
.github/workflows/scorecard.yml
vendored
2
.github/workflows/scorecard.yml
vendored
|
|
@ -53,6 +53,6 @@ jobs:
|
|||
retention-days: 5
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
|
|
|||
2
.github/workflows/triage-sweep.yml
vendored
2
.github/workflows/triage-sweep.yml
vendored
|
|
@ -66,7 +66,7 @@ jobs:
|
|||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
|
|
|
|||
2
.github/workflows/trivy.yml
vendored
2
.github/workflows/trivy.yml
vendored
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
exit-code: '0'
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
sarif_file: trivy-${{ matrix.image.name }}.sarif
|
||||
category: trivy-${{ matrix.image.name }}
|
||||
|
|
|
|||
4
.github/workflows/workflow-lint.yml
vendored
4
.github/workflows/workflow-lint.yml
vendored
|
|
@ -58,7 +58,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
continue-on-error: true
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
|
|
|
|||
275
ARCHITECTURE.md
275
ARCHITECTURE.md
|
|
@ -4,18 +4,18 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
|||
|
||||
## Repository layout
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `gitnexus/` | npm package `gitnexus`: CLI, MCP server (stdio), HTTP API, ingestion pipeline, LadybugDB graph, embeddings. |
|
||||
| `gitnexus-web/` | Vite + React thin client: graph explorer + AI chat. All queries via `gitnexus serve` HTTP API. |
|
||||
| `gitnexus-shared/` | Shared TypeScript types and constants (consumed by CLI and Web). |
|
||||
| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Agent skills and plugin metadata. |
|
||||
| `eval/` | Evaluation harnesses for benchmarking tool usage. |
|
||||
| `.github/` | CI workflows + composite actions (`setup-gitnexus/`, `setup-gitnexus-web/`). |
|
||||
| Path | Role |
|
||||
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `gitnexus/` | npm package `gitnexus`: CLI, MCP server (stdio), HTTP API, ingestion pipeline, LadybugDB graph, embeddings. |
|
||||
| `gitnexus-web/` | Vite + React thin client: graph explorer + AI chat. All queries via `gitnexus serve` HTTP API. |
|
||||
| `gitnexus-shared/` | Shared TypeScript types and constants (consumed by CLI and Web). |
|
||||
| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Agent skills and plugin metadata. |
|
||||
| `eval/` | Evaluation harnesses for benchmarking tool usage. |
|
||||
| `.github/` | CI workflows + composite actions (`setup-gitnexus/`, `setup-gitnexus-web/`). |
|
||||
|
||||
## End-to-end flow: index → graph → tools
|
||||
|
||||
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 15 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
|
||||
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). The default DAG of 19 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
|
||||
|
||||
2. **Persistence** — `repo-manager.ts` (paths, registry, LadybugDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches).
|
||||
|
||||
|
|
@ -28,53 +28,53 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
|||
|
||||
## MCP tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `list_repos` | Discover indexed repos |
|
||||
| `query` | Hybrid BM25 + vector search over the graph |
|
||||
| `cypher` | Ad hoc Cypher against the schema |
|
||||
| `context` | Callers, callees, processes for one symbol |
|
||||
| `impact` | Blast radius (upstream/downstream) with risk summary |
|
||||
| `detect_changes` | Map git diffs to affected symbols and processes |
|
||||
| `rename` | Graph-assisted multi-file rename with `dry_run` preview |
|
||||
| `api_impact` | Pre-change impact report for an API route handler |
|
||||
| `trace` | Shortest directed path between two symbols (call + class-member edges); group-aware (`repo: "@<group>"`) for cross-repo traces |
|
||||
| `route_map` | API route → handler → consumer mappings |
|
||||
| `tool_map` | MCP/RPC tool definitions and handlers |
|
||||
| `shape_check` | Response shape vs consumer property access mismatches |
|
||||
| `explain` | Persisted taint findings (source→sink data flows) — needs `analyze --pdg` |
|
||||
| `pdg_query` | Control/data dependence — CDG (`mode: controls`) / REACHING_DEF (`mode: flows`) — needs `analyze --pdg` |
|
||||
| `group_list` | List repo groups or details for one group |
|
||||
| `group_sync` | Rebuild group Contract Registry (`contracts.json`) and bridge graph |
|
||||
| Tool | Purpose |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `list_repos` | Discover indexed repos |
|
||||
| `query` | Hybrid BM25 + vector search over the graph |
|
||||
| `cypher` | Ad hoc Cypher against the schema |
|
||||
| `context` | Callers, callees, processes for one symbol |
|
||||
| `impact` | Blast radius (upstream/downstream) with risk summary |
|
||||
| `detect_changes` | Map git diffs to affected symbols and processes |
|
||||
| `rename` | Graph-assisted multi-file rename with `dry_run` preview |
|
||||
| `api_impact` | Pre-change impact report for an API route handler |
|
||||
| `trace` | Shortest directed path between two symbols (call + class-member edges); group-aware (`repo: "@<group>"`) for cross-repo traces |
|
||||
| `route_map` | API route → handler → consumer mappings |
|
||||
| `tool_map` | MCP/RPC tool definitions and handlers |
|
||||
| `shape_check` | Response shape vs consumer property access mismatches |
|
||||
| `explain` | Persisted taint findings (source→sink data flows) — needs `analyze --pdg` |
|
||||
| `pdg_query` | Control/data dependence — CDG (`mode: controls`) / REACHING_DEF (`mode: flows`) — needs `analyze --pdg` |
|
||||
| `group_list` | List repo groups or details for one group |
|
||||
| `group_sync` | Rebuild group Contract Registry (`contracts.json`) and bridge graph |
|
||||
|
||||
`query`, `context`, and `impact` are group-aware: pass `repo: "@<groupName>"` (or `"@<groupName>/<memberPath>"` to scope to one member) plus optional `service: "<monorepo/path>"`. Group-mode `query` merges per-repo results via Reciprocal Rank Fusion; group-mode `impact` runs the local walk in the chosen member and fans out across boundaries via the Contract Bridge (`gitnexus/src/core/group/cross-impact.ts`). `trace` is also group-aware via `repo: "@<groupName>"` — but, unlike the others, it resolves `from`/`to` across **all** members (a `@<groupName>/<memberPath>` suffix is advisory for trace, not a scope); pass `from_uid`/`to_uid` to disambiguate a symbol name that occurs in more than one member.
|
||||
|
||||
Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path that crosses repositories: it resolves `from`/`to` across all members, and when they live in different repos it joins the home-repo segment to the target-repo segment over a single `ContractLink` boundary (an HTTP consumer→provider link, joined on `Contract.symbolUid`), reported as a `CONTRACT_LINK` hop in `crossings[]`. The crossing is clamped to one boundary (`MAX_SUPPORTED_CROSS_DEPTH`, shared with cross-impact); deeper `crossDepth` is reported via `notes[]`. With `pdg: true` (experimental, opt-in), each boundary-adjacent segment is enriched with its intra-procedural REACHING_DEF data-flow when that repo was indexed with `--pdg` (reusing the same anchored `flows` query as `pdg_query`); data flow never crosses the repo boundary, and a missing PDG layer degrades to call-level hops with a note. Two stores meet only at the `symbolUid` grain — the per-repo PDG/call graph and the group bridge — so this is the documented join; full cross-program (SDG-like) data flow across the boundary remains deferred (see `docs/plans/2026-06-18-002-feat-unified-pdg-impact-evaluation-plan.md`). The previously-planned `group_query`, `group_context`, `group_impact`, `group_contracts`, `group_status` MCP tools are intentionally not introduced — group-level state is exposed via resources instead:
|
||||
|
||||
| Resource URI | Purpose |
|
||||
|--------------|---------|
|
||||
| Resource URI | Purpose |
|
||||
| ----------------------------------- | -------------------------------------------------------- |
|
||||
| `gitnexus://group/{name}/contracts` | Contract Registry (provider/consumer rows + cross-links) |
|
||||
| `gitnexus://group/{name}/status` | Per-member index + Contract Registry staleness |
|
||||
| `gitnexus://group/{name}/status` | Per-member index + Contract Registry staleness |
|
||||
|
||||
## Where to change what
|
||||
|
||||
| Concern | Start in |
|
||||
|---------|----------|
|
||||
| CLI commands/flags | `src/cli/` (`index.ts`, per-command modules) |
|
||||
| Parsing/graph construction | `src/core/ingestion/pipeline-phases/` + `pipeline.ts` |
|
||||
| Graph schema/DB | `src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`) |
|
||||
| MCP tools/resources | `src/mcp/server.ts`, `tools.ts`, `resources.ts` |
|
||||
| Cross-repo groups (sync, contracts, `@<group>` routing) | `src/core/group/` (`service.ts`, `cross-impact.ts`, `sync.ts`, `bridge-db.ts`) |
|
||||
| Search ranking | `src/core/search/` (BM25, hybrid fusion) |
|
||||
| Embeddings | `src/core/embeddings/` + `src/core/run-analyze.ts` |
|
||||
| Wiki generation | `src/core/wiki/` |
|
||||
| Language support | `src/core/ingestion/languages/` + `tree-sitter-queries.ts` + `gitnexus-shared/src/languages.ts` |
|
||||
| Import resolution | `src/core/ingestion/import-processor.ts` + `import-resolvers/configs/` + `model/resolution-context.ts` |
|
||||
| Call resolution/inheritance/MRO | `src/core/ingestion/scope-resolution/` (pipeline, passes, graph-bridge) |
|
||||
| Type extraction | `src/core/ingestion/type-extractors/` |
|
||||
| Worker pool | `src/core/ingestion/workers/` |
|
||||
| Web UI | `gitnexus-web/src/` |
|
||||
| CI | `.github/workflows/*.yml`, `.github/actions/` |
|
||||
| Concern | Start in |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| CLI commands/flags | `src/cli/` (`index.ts`, per-command modules) |
|
||||
| Parsing/graph construction | `src/core/ingestion/pipeline-phases/` + `pipeline.ts` |
|
||||
| Graph schema/DB | `src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`) |
|
||||
| MCP tools/resources | `src/mcp/server.ts`, `tools.ts`, `resources.ts` |
|
||||
| Cross-repo groups (sync, contracts, `@<group>` routing) | `src/core/group/` (`service.ts`, `cross-impact.ts`, `sync.ts`, `bridge-db.ts`) |
|
||||
| Search ranking | `src/core/search/` (BM25, hybrid fusion) |
|
||||
| Embeddings | `src/core/embeddings/` + `src/core/run-analyze.ts` |
|
||||
| Wiki generation | `src/core/wiki/` |
|
||||
| Language support | `src/core/ingestion/languages/` + `tree-sitter-queries.ts` + `gitnexus-shared/src/languages.ts` |
|
||||
| Import resolution | `src/core/ingestion/import-processor.ts` + `import-resolvers/configs/` + `model/resolution-context.ts` |
|
||||
| Call resolution/inheritance/MRO | `src/core/ingestion/scope-resolution/` (pipeline, passes, graph-bridge) |
|
||||
| Type extraction | `src/core/ingestion/type-extractors/` |
|
||||
| Worker pool | `src/core/ingestion/workers/` |
|
||||
| Web UI | `gitnexus-web/src/` |
|
||||
| CI | `.github/workflows/*.yml`, `.github/actions/` |
|
||||
|
||||
> Paths above are relative to `gitnexus/` unless they start with `gitnexus-web/` or `.github/`.
|
||||
|
||||
|
|
@ -82,30 +82,35 @@ Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path th
|
|||
|
||||
## Pipeline Phase DAG
|
||||
|
||||
15 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
|
||||
19 default phases are defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output. `--pdg` adds `taintSummaries` and `callSummaries` (21 total).
|
||||
|
||||
```
|
||||
scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
||||
→ crossFile → scopeResolution → pruneLocalSymbols → mro → di → communities → processes
|
||||
scan → structure → [springConfig, markdown, cobol] → parse → [routes, tools, orm]
|
||||
→ crossFile → scopeResolution → [springAutoConfiguration, springAop]
|
||||
→ pruneLocalSymbols → mro → springAopInheritance → di → communities → processes
|
||||
```
|
||||
|
||||
| Phase | File | Deps | Output |
|
||||
|-------|------|------|--------|
|
||||
| `scan` | `scan.ts` | (root) | File paths + sizes |
|
||||
| `structure` | `structure.ts` | `scan` | File/Folder nodes, CONTAINS edges, `allPathSet` |
|
||||
| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx |
|
||||
| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) |
|
||||
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries |
|
||||
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators) |
|
||||
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
|
||||
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
|
||||
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order |
|
||||
| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator |
|
||||
| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution |
|
||||
| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
|
||||
| `di` | `di.ts` | `mro` | INJECTS edges (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) |
|
||||
| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
|
||||
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges |
|
||||
| Phase | File | Deps | Output |
|
||||
| ------------------------- | -------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `scan` | `scan.ts` | (root) | File paths + sizes |
|
||||
| `structure` | `structure.ts` | `scan` | File/Folder nodes, CONTAINS edges, `allPathSet` |
|
||||
| `springConfig` | `spring-config.ts` | `structure` | Spring configuration-property nodes and metadata |
|
||||
| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx |
|
||||
| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) |
|
||||
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries |
|
||||
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators) |
|
||||
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
|
||||
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
|
||||
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order |
|
||||
| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator |
|
||||
| `springAutoConfiguration` | `spring-auto-configuration.ts` | `structure`, `scopeResolution` | DECLARES and CONDITIONAL_ON metadata for Spring configuration candidates |
|
||||
| `springAop` | `spring-aop.ts` | `scopeResolution` | Direct declarative/advice ADVISED_BY edges and pointcut evidence |
|
||||
| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution |
|
||||
| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
|
||||
| `springAopInheritance` | `spring-aop.ts` | `springAop`, `mro` | Propagates declarative behavior through class/interface inheritance decisions |
|
||||
| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes or factory Methods to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) |
|
||||
| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
|
||||
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges |
|
||||
|
||||
**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `types.ts`, `runner.ts`, `index.ts`.
|
||||
|
||||
|
|
@ -124,6 +129,7 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
|||
4. **Timing** — per-phase `durationMs` in `PhaseResult`, dev-mode console logging.
|
||||
|
||||
**Design patterns:**
|
||||
|
||||
- **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output.
|
||||
- **Typed phase access** — `getPhaseOutput<T>(deps, 'name')` for type-safe upstream results.
|
||||
- **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership.
|
||||
|
|
@ -141,7 +147,9 @@ import type { PipelinePhase, PhaseResult } from './types.js';
|
|||
import { getPhaseOutput } from './types.js';
|
||||
import type { ParseOutput } from './parse.js';
|
||||
|
||||
export interface MyPhaseOutput { /* ... */ }
|
||||
export interface MyPhaseOutput {
|
||||
/* ... */
|
||||
}
|
||||
|
||||
export const myPhase: PipelinePhase<MyPhaseOutput> = {
|
||||
name: 'myPhase',
|
||||
|
|
@ -149,7 +157,9 @@ export const myPhase: PipelinePhase<MyPhaseOutput> = {
|
|||
async execute(ctx, deps) {
|
||||
const { allPaths } = getPhaseOutput<ParseOutput>(deps, 'parse');
|
||||
// ... write to ctx.graph ...
|
||||
return { /* typed output */ };
|
||||
return {
|
||||
/* typed output */
|
||||
};
|
||||
},
|
||||
};
|
||||
```
|
||||
|
|
@ -228,7 +238,7 @@ Standalone (regex-based) providers such as COBOL participate via `ScopeResolver.
|
|||
|
||||
On a `--pdg` run the parse worker builds a per-function control-flow graph from the tree-sitter AST (`LanguageProvider.cfgVisitor`; TypeScript/JavaScript today) and serializes it onto `ParsedFile.cfgSideChannel` as plain data. Scope-resolution then emits the program-dependence layers from that side-channel **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live** — the only window where the worker-built CFGs are loaded (the store is cleared right after the phase returns). A standalone post-`mro` phase would read an empty store, so the emit deliberately lives in-phase, mirroring the `applyCaptureSideChannel` pattern. The opt-in is off by default (graph byte-identical), folded into the parse-cache key (a pdg-off warm cache is never reused on a `--pdg` run), and each layer is bounded by a per-function edge cap that logs any dropped edges. All layers are `BasicBlock → BasicBlock` edges in the single `CodeRelation` table, keyed by `type`; there is **no** `Function → BasicBlock` edge — the symbol↔block join is reconstructed from the BasicBlock id prefix + line span. The layers build on each other:
|
||||
|
||||
- **M1 — CFG** (#2081): `BasicBlock` nodes + `CFG` edges. Edge *kind* (`seq`/`cond-true`/`loop-back`/…) rides the `reason` column (CFG is one `CodeRelation` type, not one per kind).
|
||||
- **M1 — CFG** (#2081): `BasicBlock` nodes + `CFG` edges. Edge _kind_ (`seq`/`cond-true`/`loop-back`/…) rides the `reason` column (CFG is one `CodeRelation` type, not one per kind).
|
||||
- **M2 — REACHING_DEF** (#2082): GEN/KILL def→use data dependence from a pure fixpoint solver; the variable name rides `reason`.
|
||||
- **M3/M4 — TAINTED / SANITIZES / TAINT_PATH** (#2083–#2084): intra- and inter-procedural taint (source→sink) — the `explain` tool's data.
|
||||
- **M5 — CDG** (#2085): Ferrante control dependence over a 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,24 +251,25 @@ See `core/ingestion/cfg/` (emit + the pure CFG / post-dominator / control-depend
|
|||
|
||||
Single interface a language implements to plug into the pipeline. Contract fully documented in `scope-resolution/contract/scope-resolver.ts`.
|
||||
|
||||
| Hook | Purpose |
|
||||
|------|---------|
|
||||
| `languageProvider` | Base `LanguageProvider` (tree-sitter query, `emitScopeCaptures`, import/binding interpreters, hooks) |
|
||||
| `populateOwners(parsed)` | Fill deferred `ownerId` fields on method defs (captures can't always know the owning class at parse time) |
|
||||
| `buildMro(graph, parsed, nodeLookup)` | Produce `mroByClassDefId: Map<DefId, DefId[]>` — C3, Ruby-mixin, or first-wins per language |
|
||||
| `resolveImportTarget(target, fromFile, allFiles)` | `(rawImportPath, sourceFile) → targetFilePath` (PEP-328 for Python, etc.) |
|
||||
| `mergeBindings(existing, incoming, scopeId)` | Shadowing / LEGB precedence |
|
||||
| `arityCompatibility` | Provider consumed by registry during `MethodRegistry.lookup` Step 2 |
|
||||
| `importEdgeReason` | Confidence-tier string for IMPORTS edge reason field |
|
||||
| `propagatesReturnTypesAcrossImports?` | Opt out of cross-file return-type propagation (default on) |
|
||||
| `fieldFallbackOnMethodLookup?` | Statically-typed languages turn this OFF — the heuristic over-connects (default on) |
|
||||
| `unwrapCollectionAccessor?` | Property-style collection views (`data.Values` on Dictionary-like receivers) — default off |
|
||||
| `collapseMemberCallsByCallerTarget?` | One CALLS edge per (caller, target) instead of per-site — default off |
|
||||
| `populateNamespaceSiblings?` | Cross-file implicit visibility (compiler-implicit namespace sharing) — default off; ctx carries `treeCache` |
|
||||
| `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level |
|
||||
| `hasFileLocalCallableLinkage?` | Precise internal-linkage predicate used only when joining callable declarations/prototypes to cross-file definitions; C/C++ use it for `static` free functions |
|
||||
| `constructorCallTargetsClass?` | A constructor-form call `Type(...)` links to the Class def rather than its explicit Constructor def — default off; Swift and Dart opt in |
|
||||
| `constructionSyntax?` | How the language spells construction, so an INLINE constructor receiver (`Service(db).m()`, `new Service(db).m()`, `Service.new.m()`) can be typed — `bare` / `keyword` / `selector`; default off, opt in per language only where measured to be needed (#2708) |
|
||||
| Hook | Purpose |
|
||||
| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `languageProvider` | Base `LanguageProvider` (tree-sitter query, `emitScopeCaptures`, import/binding interpreters, hooks) |
|
||||
| `populateOwners(parsed)` | Fill deferred `ownerId` fields on method defs (captures can't always know the owning class at parse time) |
|
||||
| `buildMro(graph, parsed, nodeLookup)` | Produce `mroByClassDefId: Map<DefId, DefId[]>` — C3, Ruby-mixin, or first-wins per language |
|
||||
| `resolveImportTarget(target, fromFile, allFiles)` | `(rawImportPath, sourceFile) → targetFilePath` (PEP-328 for Python, etc.) |
|
||||
| `isNamespaceImport(parsedImport, targetFile, fromFile)` | Optionally reclassify a resolved named import as a namespace handle when the imported symbol is itself a module |
|
||||
| `mergeBindings(existing, incoming, scopeId)` | Shadowing / LEGB precedence |
|
||||
| `arityCompatibility` | Provider consumed by registry during `MethodRegistry.lookup` Step 2 |
|
||||
| `importEdgeReason` | Confidence-tier string for IMPORTS edge reason field |
|
||||
| `propagatesReturnTypesAcrossImports?` | Opt out of cross-file return-type propagation (default on) |
|
||||
| `fieldFallbackOnMethodLookup?` | Statically-typed languages turn this OFF — the heuristic over-connects (default on) |
|
||||
| `unwrapCollectionAccessor?` | Property-style collection views (`data.Values` on Dictionary-like receivers) — default off |
|
||||
| `collapseMemberCallsByCallerTarget?` | One CALLS edge per (caller, target) instead of per-site — default off |
|
||||
| `populateNamespaceSiblings?` | Cross-file implicit visibility (compiler-implicit namespace sharing) — default off; ctx carries `treeCache` |
|
||||
| `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level |
|
||||
| `hasFileLocalCallableLinkage?` | Precise internal-linkage predicate used only when joining callable declarations/prototypes to cross-file definitions; C/C++ use it for `static` free functions |
|
||||
| `constructorCallTargetsClass?` | A constructor-form call `Type(...)` links to the Class def rather than its explicit Constructor def — default off; Swift and Dart opt in |
|
||||
| `constructionSyntax?` | How the language spells construction, so an INLINE constructor receiver (`Service(db).m()`, `new Service(db).m()`, `Service.new.m()`) can be typed — `bare` / `keyword` / `selector`; default off, opt in per language only where measured to be needed (#2708) |
|
||||
|
||||
### Per-language registration
|
||||
|
||||
|
|
@ -269,21 +280,21 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
|||
|
||||
### Code references
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `scope-resolution/contract/scope-resolver.ts` | `ScopeResolver` interface + shared types |
|
||||
| `scope-resolution/pipeline/run.ts` | Generic orchestrator |
|
||||
| `scope-resolution/pipeline/phase.ts` | Pipeline-phase wrapper (deps: `parse`, `structure`) |
|
||||
| `scope-resolution/pipeline/registry.ts` | `SCOPE_RESOLVERS` map |
|
||||
| `scope-resolution/passes/*.ts` | Reference-resolution passes (receiver-bound, free-call fallback, compound-receiver, MRO, cross-file return-type propagation) |
|
||||
| `scope-resolution/graph-bridge/*.ts` | CLI-local translation from resolved references → `KnowledgeGraph` edges |
|
||||
| `scope-resolution/scope/*.ts` | Generic scope-chain walkers + namespace targets |
|
||||
| `scope-resolution/workspace-index.ts` | Build-once O(1) lookup index |
|
||||
| `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs |
|
||||
| `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) |
|
||||
| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs |
|
||||
| `languages/csharp/captures.ts` | `emitCsharpScopeCaptures` (honors cross-phase Tree cache) |
|
||||
| `languages/csharp/namespace-siblings.ts` | Cross-file implicit-namespace visibility hook (reads `treeCache`) |
|
||||
| Module | Purpose |
|
||||
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `scope-resolution/contract/scope-resolver.ts` | `ScopeResolver` interface + shared types |
|
||||
| `scope-resolution/pipeline/run.ts` | Generic orchestrator |
|
||||
| `scope-resolution/pipeline/phase.ts` | Pipeline-phase wrapper (deps: `parse`, `structure`) |
|
||||
| `scope-resolution/pipeline/registry.ts` | `SCOPE_RESOLVERS` map |
|
||||
| `scope-resolution/passes/*.ts` | Reference-resolution passes (receiver-bound, free-call fallback, compound-receiver, MRO, cross-file return-type propagation) |
|
||||
| `scope-resolution/graph-bridge/*.ts` | CLI-local translation from resolved references → `KnowledgeGraph` edges |
|
||||
| `scope-resolution/scope/*.ts` | Generic scope-chain walkers + namespace targets |
|
||||
| `scope-resolution/workspace-index.ts` | Build-once O(1) lookup index |
|
||||
| `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs |
|
||||
| `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) |
|
||||
| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs |
|
||||
| `languages/csharp/captures.ts` | `emitCsharpScopeCaptures` (honors cross-phase Tree cache) |
|
||||
| `languages/csharp/namespace-siblings.ts` | Cross-file implicit-namespace visibility hook (reads `treeCache`) |
|
||||
|
||||
### Performance notes
|
||||
|
||||
|
|
@ -313,15 +324,15 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
|||
|
||||
Each language implements `LanguageProvider` (`language-provider.ts`). Key fields:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `id`, `extensions` | Language identity and file matching |
|
||||
| `treeSitterQueries` | S-expression queries for AST extraction |
|
||||
| `importSemantics` | `named` / `wildcard-leaf` / `wildcard-transitive` / `namespace` |
|
||||
| `importResolver` | Language-specific path → file resolution |
|
||||
| `exportChecker` | Public/exported symbol detection |
|
||||
| `typeConfig` | Type annotation extraction rules |
|
||||
| `mroStrategy` | `first-wins` / `c3` / `none` |
|
||||
| Field | Purpose |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id`, `extensions` | Language identity and file matching |
|
||||
| `treeSitterQueries` | S-expression queries for AST extraction |
|
||||
| `importSemantics` | `named` / `wildcard-leaf` / `wildcard-transitive` / `namespace` |
|
||||
| `importResolver` | Language-specific path → file resolution |
|
||||
| `exportChecker` | Public/exported symbol detection |
|
||||
| `typeConfig` | Type annotation extraction rules |
|
||||
| `mroStrategy` | `first-wins` / `c3` / `none` |
|
||||
| `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) |
|
||||
|
||||
16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
|
||||
|
|
@ -336,22 +347,23 @@ Per-language import resolution uses the **configs + factory** pattern (like call
|
|||
|
||||
Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSemantics` controls which tier activates:
|
||||
|
||||
| Tier | Confidence | Mechanism |
|
||||
|------|-----------|-----------|
|
||||
| 1 — same-file | 0.95 | Symbol table for caller's file |
|
||||
| 2 — import-scoped | 0.9 | `NamedImportMap` chains (named) or all files in `importMap` (wildcard) |
|
||||
| 3 — global | 0.5 | O(1) index lookups: class, impl, callable. Fallback only |
|
||||
| Tier | Confidence | Mechanism |
|
||||
| ----------------- | ---------- | ---------------------------------------------------------------------- |
|
||||
| 1 — same-file | 0.95 | Symbol table for caller's file |
|
||||
| 2 — import-scoped | 0.9 | `NamedImportMap` chains (named) or all files in `importMap` (wildcard) |
|
||||
| 3 — global | 0.5 | O(1) index lookups: class, impl, callable. Fallback only |
|
||||
|
||||
| Import strategy | Languages | Behavior |
|
||||
|----------------|-----------|----------|
|
||||
| `named` | TS, JS, Java, C#, Rust, PHP, Kotlin | Only explicitly imported names visible |
|
||||
| `wildcard-leaf` | Go, Ruby, Swift, Dart | Whole-package import, no transitive re-exports |
|
||||
| `wildcard-transitive` | C, C++ | `#include` closure chains through re-exports |
|
||||
| `namespace` | Python | Module aliases resolved at call site |
|
||||
| Import strategy | Languages | Behavior |
|
||||
| --------------------- | ----------------------------------- | ---------------------------------------------- |
|
||||
| `named` | TS, JS, Java, C#, Rust, PHP, Kotlin | Only explicitly imported names visible |
|
||||
| `wildcard-leaf` | Go, Ruby, Swift, Dart | Whole-package import, no transitive re-exports |
|
||||
| `wildcard-transitive` | C, C++ | `#include` closure chains through re-exports |
|
||||
| `namespace` | Python | Module aliases resolved at call site |
|
||||
|
||||
### Chunked parse-and-resolve
|
||||
|
||||
`parse` processes files in ~20 MB byte-budget chunks to bound memory. Per chunk:
|
||||
|
||||
1. Worker pool dispatches files (the sole parse path — there is no sequential fallback; `skipWorkers`, `--workers 0`, and `GITNEXUS_WORKER_POOL_SIZE=0` are rejected with an actionable error)
|
||||
2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult`
|
||||
3. Synthesize wildcard bindings (`wildcard-synthesis.ts`)
|
||||
|
|
@ -362,11 +374,12 @@ Inheritance edges are emitted later, by the scope-resolution phase (`preEmitInhe
|
|||
|
||||
Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`.
|
||||
|
||||
**Worker-serialized ParsedFiles (#2038).** To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the *sole* parse path and workers serialize each file's `ParsedFile` (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence `usedWorkerPool` can be false even when the repo has parseable files).
|
||||
**Worker-serialized ParsedFiles (#2038).** To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the _sole_ parse path and workers serialize each file's `ParsedFile` (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence `usedWorkerPool` can be false even when the repo has parseable files).
|
||||
|
||||
### Inheritance and MRO
|
||||
|
||||
Inheritance is captured by the `@reference.inherits` tag and emitted by the scope-resolution phase: `preEmitInheritanceEdges` resolves each base in scope, then `emitHeritageEdges` writes the `EXTENDS`/`IMPLEMENTS` edges. The phase then computes method resolution order via each `ScopeResolver`'s `buildMro` hook, feeding a `MethodDispatchIndex` used for owner-scoped lookups. Per-language strategy:
|
||||
|
||||
- **`first-wins`** — Java, C#, C++, TS, Ruby, Go
|
||||
- **`c3`** — Python (C3 linearization)
|
||||
- **`ruby-mixin`** — Ruby (mixin-aware linearization)
|
||||
|
|
@ -418,7 +431,7 @@ Defined in `lbug/schema.ts`. Separate node tables per type, single `CodeRelation
|
|||
|
||||
**Node tables:** File, Folder, Function, Class, Interface, Method, Constructor, CodeElement, Struct, Enum, Macro, Typedef, Union, Namespace, Trait, Impl, TypeAlias, Const, Static, Property, Record, Delegate, Annotation, Template, Module, Community, Process, Route, Tool, Section, Embedding.
|
||||
|
||||
**Relation types** (`CodeRelation.type`): CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF.
|
||||
**Relation types** (`CodeRelation.type`): CONTAINS, DEFINES, CALLS, IMPORTS, INHERITS, EXTENDS, IMPLEMENTS, USES, DECORATES, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF, WRAPS, QUERIES, INJECTS, CONDITIONAL_ON, DECLARES, ADVISED_BY, BINDS_EVENT_HANDLER, EMITS_EVENT.
|
||||
|
||||
**Optional `--pdg` additions** (off by default, opt-in via `gitnexus analyze --pdg`; see _Optional CFG/PDG emission_ above): a `BasicBlock` node table, plus the PDG relation types `CFG`, `REACHING_DEF`, `CDG`, `TAINTED`, `SANITIZES`, and `TAINT_PATH` on the same `CodeRelation` table. These are deliberately kept out of the default `VALID_RELATION_TYPES` / web graph schema — query them via `cypher`, `explain`, or `pdg_query`.
|
||||
|
||||
|
|
@ -446,12 +459,12 @@ Node IDs use arity suffix (`#<paramCount>`): `Method:file:Class.method#1` vs `#2
|
|||
|
||||
**METHOD_IMPLEMENTS confidence tiering:**
|
||||
|
||||
| Match quality | Confidence |
|
||||
|---|---|
|
||||
| Exact parameter types match | 1.0 |
|
||||
| Arity match, types unavailable | 1.0 |
|
||||
| Variadic vs fixed | 0.7 |
|
||||
| Insufficient info | 0.7 |
|
||||
| Match quality | Confidence |
|
||||
| ------------------------------ | ---------- |
|
||||
| Exact parameter types match | 1.0 |
|
||||
| Arity match, types unavailable | 1.0 |
|
||||
| Variadic vs fixed | 0.7 |
|
||||
| Insufficient info | 0.7 |
|
||||
|
||||
## Related docs
|
||||
|
||||
|
|
|
|||
|
|
@ -133,8 +133,8 @@ export type RelationshipType =
|
|||
* shared DI phase uses type heritage, qualifier names, and preferred
|
||||
* provider markers to resolve it. Ambiguous single injection is represented
|
||||
* by multiple lower-confidence edges instead of a fabricated exact target.
|
||||
* Source = the consumer Class node (the one owning the injection site).
|
||||
* Target = a concrete provider Class node.
|
||||
* Source = the consumer Class, or a factory Method for its parameters.
|
||||
* Target = a concrete provider Class or synthetic provider CodeElement.
|
||||
* Framework specifics live in the `reason` payload (e.g.
|
||||
* `Spring DI: @Autowired List<T>`), not in this type contract.
|
||||
* Lets Cypher queries trace which beans the container injects into a given
|
||||
|
|
@ -153,6 +153,12 @@ export type RelationshipType =
|
|||
* semantics belong in `reason` so the relationship can be reused by other
|
||||
* metadata-driven systems. */
|
||||
| 'DECLARES'
|
||||
/** Framework advice relationship. Source = the class-like/Method whose behavior
|
||||
* is intercepted; target = either the concrete advice Method or a synthetic
|
||||
* CodeElement describing a declarative interceptor (transaction, cache, or
|
||||
* method security). Runtime activation remains explicitly unknown in the
|
||||
* relationship reason; this edge records statically visible advice only. */
|
||||
| 'ADVISED_BY'
|
||||
/** Vue component event system: a handler function in a parent component is
|
||||
* bound to an event emitted by a child component (`@event="handlerFn"`).
|
||||
* Source = handler Function/Method node in the parent.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -96,6 +96,16 @@ export interface FinalizeHooks {
|
|||
parsedImport?: ParsedImport,
|
||||
): string | readonly string[] | null;
|
||||
|
||||
/**
|
||||
* Reclassify syntax that names an imported symbol as a namespace import
|
||||
* after target resolution proves the symbol is itself a module.
|
||||
*/
|
||||
readonly isNamespaceImport?: (
|
||||
parsedImport: ParsedImport,
|
||||
targetFile: string,
|
||||
fromFile: string,
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* For a wildcard `import * from M`, return the names visible in the
|
||||
* exporting module scope `M`. The finalize pass looks each name up in
|
||||
|
|
@ -389,7 +399,10 @@ function makeEdgeDrafts(
|
|||
localName: extractLocalName(parsed),
|
||||
targetFile: tf,
|
||||
targetExportedName: extractExportedName(parsed),
|
||||
kind: edgeKindFor(parsed),
|
||||
kind:
|
||||
hooks.isNamespaceImport?.(parsed, tf, file.filePath) === true
|
||||
? 'namespace'
|
||||
: edgeKindFor(parsed),
|
||||
};
|
||||
return {
|
||||
source: parsed,
|
||||
|
|
@ -461,7 +474,7 @@ function tryFinalize(
|
|||
// languages emit a synthetic module-def), pick it up as the `targetDefId`
|
||||
// so consumers can reach the module as a symbol — but its absence is not
|
||||
// a failure.
|
||||
if (draft.source.kind === 'namespace') {
|
||||
if (draft.base.kind === 'namespace') {
|
||||
const moduleDef = findExportByName(targetModule.localDefs, extractExportedName(draft.source));
|
||||
return {
|
||||
...draft.base,
|
||||
|
|
|
|||
|
|
@ -415,6 +415,11 @@ export interface Scope {
|
|||
/** Local type facts visible from this scope (parameter annotations, `self` binding, etc.). */
|
||||
readonly typeBindings: ReadonlyMap<string, TypeRef>;
|
||||
|
||||
/** Lexically bound names that may have no definition or type fact of their
|
||||
* own (for example, an untyped function parameter). Consumers use this only
|
||||
* as a shadowing barrier; it never resolves a symbol by itself. */
|
||||
readonly lexicalNames?: ReadonlySet<string>;
|
||||
|
||||
/** Receiver names this scope BINDS rather than inherits — `this`, `self`, … (#2701).
|
||||
*
|
||||
* A receiver walk (`findReceiverTypeBinding`) that reaches such a scope
|
||||
|
|
|
|||
28
gitnexus-web/package-lock.json
generated
28
gitnexus-web/package-lock.json
generated
|
|
@ -9,7 +9,7 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.5.1",
|
||||
"@langchain/core": "^1.2.2",
|
||||
"@langchain/core": "^1.2.3",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.8",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.4.6",
|
||||
"lru-cache": "^11.5.2",
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^8.0.0",
|
||||
"@babel/types": "^8.0.4",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
|
@ -255,14 +255,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0.tgz",
|
||||
"integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==",
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz",
|
||||
"integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^8.0.0",
|
||||
"@babel/helper-validator-identifier": "^8.0.0"
|
||||
"@babel/helper-validator-identifier": "^8.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
|
|
@ -1139,9 +1139,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.2.tgz",
|
||||
"integrity": "sha512-KfjEOT6sCg0vvItagfEtGpmrGoLMGfma4Affb5BGEqPmS2YR3AxW54pABSkhQlzCehTB+0BnLquAe1lGF4J9zQ==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.3.tgz",
|
||||
"integrity": "sha512-F+L5SsciykwDl7eDxacnhDTcWe1IF6jetzfkvI5PPfq6ogWHO7xcjU90SGh/3lqbbS0tgun+qF01KIqxawrCsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.0.2",
|
||||
|
|
@ -4932,9 +4932,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
|
||||
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
|
@ -4951,7 +4951,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.5.1",
|
||||
"@langchain/core": "^1.2.2",
|
||||
"@langchain/core": "^1.2.3",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.8",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.4.6",
|
||||
"lru-cache": "^11.5.2",
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^8.0.0",
|
||||
"@babel/types": "^8.0.4",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@
|
|||
# Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI.
|
||||
# See README for details.
|
||||
|
||||
# JVM / Kotlin same-package sibling injection
|
||||
# Limits implicit sibling class bindings per module scope, nearest first by path.
|
||||
# Set to 0 for no limit. Files whose sibling set is truncated are marked
|
||||
# visibility-incomplete (wildcard attribution disabled for them). Packages over
|
||||
# 500 files are skipped entirely regardless of this value.
|
||||
# GITNEXUS_MAX_INJECTED_SIBLINGS=200
|
||||
|
||||
# Azure DevOps Server (Self-Hosted) Integration
|
||||
# Base URL of your Azure DevOps Server instance. Prefer https:// — the PAT is
|
||||
# sent in an Authorization header, so cleartext http:// exposes it on the wire
|
||||
|
|
@ -24,3 +31,17 @@
|
|||
# Personal Access Token with Code (Read) scope for cloning private repos.
|
||||
# Used for both self-hosted and cloud (dev.azure.com) Azure DevOps.
|
||||
# AZURE_DEVOPS_PAT=your-pat-here
|
||||
|
||||
# Scope-resolution property-key dispatch cap (default 32). Per-property-key
|
||||
# registration cap in the property-dispatch scope-resolution pass. Raise for
|
||||
# repos whose provider/hook tables lose CALLS coverage on a legitimate key.
|
||||
# Positive integer only — non-integer or < 1 values fall back to 32.
|
||||
# See README § "Scope-resolution property-key dispatch cap".
|
||||
# GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT=32
|
||||
|
||||
# Per-callable-site dispatch-target cap (default 32). Raise this for repos whose
|
||||
# wide dispatch tables overflow the default and lose a whole call chain; analyze
|
||||
# then logs "callable-value-flow: candidate set exceeded the cap". Positive
|
||||
# integer only — non-integer or < 1 values fall back to 32.
|
||||
# See README § "Scope-resolution dispatch-target cap".
|
||||
# GITNEXUS_MAX_CALLABLE_VALUE_TARGETS=64
|
||||
|
|
|
|||
|
|
@ -311,6 +311,31 @@ validates the returned vector's length):
|
|||
|
||||
Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI. Retry and pacing settings are provider-neutral; provider-specific limits should be supplied through configuration. When unset, local embeddings are used unchanged.
|
||||
|
||||
## JVM Package Sibling Injection
|
||||
|
||||
Java and Kotlin files in the same package receive implicit sibling class bindings
|
||||
to resolve same-package references. By default, GitNexus injects at most 200
|
||||
siblings per module scope, nearest first by path. Set
|
||||
`GITNEXUS_MAX_INJECTED_SIBLINGS=0` to remove that per-file limit; this can
|
||||
substantially increase indexing work for large packages.
|
||||
|
||||
```bash
|
||||
export GITNEXUS_MAX_INJECTED_SIBLINGS=200
|
||||
gitnexus analyze .
|
||||
```
|
||||
|
||||
When the limit truncates a file's sibling set, that file is marked
|
||||
visibility-incomplete: same-package references still resolve through the
|
||||
injected siblings, but wildcard-import attribution (used by the Spring
|
||||
bean/DI/config passes) is disabled for it rather than resolved against a
|
||||
partial view. Analyze logs a `sibling injection truncated` warning naming how
|
||||
many files were affected.
|
||||
|
||||
Packages with more than 500 files are a separate, fixed limit: they are skipped
|
||||
entirely (logged as `skipping package with N files`) and every file in them is
|
||||
marked visibility-incomplete. `GITNEXUS_MAX_INJECTED_SIBLINGS` does not lift
|
||||
that skip — including at `0`.
|
||||
|
||||
## Multi-Repo Support
|
||||
|
||||
GitNexus supports indexing multiple repositories. Each `gitnexus analyze` registers the repo in a global registry (`~/.gitnexus/registry.json`). The MCP server serves all indexed repos automatically.
|
||||
|
|
@ -663,6 +688,56 @@ After scope resolution, analyze prunes inert block-local value symbols (a functi
|
|||
|
||||
Programmatic callers can pass `keepLocalValueSymbols: true` in `PipelineOptions` instead of setting the env var.
|
||||
|
||||
### Scope-resolution property-key dispatch cap
|
||||
|
||||
During scope resolution GitNexus synthesizes CALLS edges through *property-key
|
||||
dispatch* — call sites like `hooks.emitScopeCaptures()` where a property key is
|
||||
registered by multiple definitions across the codebase. To keep this fan-in
|
||||
bounded, each property key is capped at **32 registrations**: a key registered
|
||||
by more than 32 distinct functions is skipped entirely (no CALLS are synthesized
|
||||
through it), and the dropped key names are surfaced in the analyze log for
|
||||
operator visibility. The cap is calibrated at 2× this repo's own provider table
|
||||
(16 legitimate registrations, one per language provider).
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| --------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT` | `32` | Per-property-key registration cap in the property-dispatch scope-resolution pass. Set to a positive integer to raise it for repositories whose provider/hook tables exceed the default and lose CALLS coverage on a legitimate key; non-integer or `< 1` values fall back to `32`. Lowering it tightens the overflow budget. |
|
||||
|
||||
```bash
|
||||
# A property key registered by 40 functions overflows the default 32 and drops
|
||||
# all CALLS through it — raise the cap for that repo and rebuild so scope
|
||||
# resolution reruns.
|
||||
export GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT=64
|
||||
npx gitnexus analyze --force
|
||||
```
|
||||
|
||||
### Scope-resolution dispatch-target cap
|
||||
|
||||
During scope resolution GitNexus resolves calls that flow through *callable
|
||||
values* — function/method references bound to variables, passed as arguments,
|
||||
or stored in maps/tables. To keep that inclusion-based resolution finite, each
|
||||
callable site is capped at **32 dispatch targets**. When a site gathers more
|
||||
candidates than the cap it is treated as **overflowed** and *all* of its call
|
||||
edges are dropped — a cliff, not a tail, so a repository with a legitimately
|
||||
wide dispatch table (a single callable site resolving to 33+ targets) loses
|
||||
that site's whole call chain. In that case `analyze` logs
|
||||
`callable-value-flow: candidate set exceeded the cap; no partial CALLS emitted`
|
||||
alongside a warning carrying the language, the overflowing context, the
|
||||
candidate count, and the cap (32).
|
||||
|
||||
Raise the cap for such repositories:
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GITNEXUS_MAX_CALLABLE_VALUE_TARGETS` | `32` | Per-callable-site dispatch-target cap in the callable-value-flow scope-resolution pass. Set to a positive integer to raise it for repositories whose wide dispatch tables overflow the default and lose a whole call chain; non-integer or `< 1` values fall back to `32`. Lowering it tightens the overflow budget. |
|
||||
|
||||
```bash
|
||||
# A callable site resolving to 48 targets overflows the default 32 and drops
|
||||
# the chain — raise the cap for that repo and rebuild so scope resolution reruns.
|
||||
export GITNEXUS_MAX_CALLABLE_VALUE_TARGETS=64
|
||||
npx gitnexus analyze --force
|
||||
```
|
||||
|
||||
### Hook augmentation and skip diagnostics
|
||||
|
||||
The Claude Code / Antigravity hooks keep their **stderr** silent on normal skip
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
ec879302c3257418edb52c3bc2c1ac0e43d40e742319c493218b7d0ff3466483
|
||||
b57c5479f158c41a6328fa8d61c234aaec88fc41780252d2fbd7baca21491aa2
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'la
|
|||
function canonicalizeMatch(match) {
|
||||
const parts = [];
|
||||
for (const tag of Object.keys(match)) {
|
||||
// Scope-only lexical shadow metadata is correctness-tested separately and
|
||||
// does not alter capture matching or the benchmark's scaling contract.
|
||||
if (tag === '@scope.lexical-names') continue;
|
||||
const cap = match[tag];
|
||||
const r = cap.range;
|
||||
parts.push(`${tag}|${cap.text}|${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`);
|
||||
|
|
|
|||
6
gitnexus/package-lock.json
generated
6
gitnexus/package-lock.json
generated
|
|
@ -3006,9 +3006,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz",
|
||||
"integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==",
|
||||
"version": "8.6.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz",
|
||||
"integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ const PLATFORM_LOGIC = [
|
|||
'test/unit/lbug-config-pagesize.test.ts',
|
||||
'test/unit/worker-pool-windows-quarantine.test.ts',
|
||||
'test/unit/lbug-pool-fts-load.test.ts',
|
||||
// Global registry writes use the platform-specific index-lock backend
|
||||
// (Windows named pipe, Linux socket, or macOS file lock). This includes the
|
||||
// overlapping-registration regression from #2716 on every OS matrix.
|
||||
'test/unit/repo-manager.test.ts',
|
||||
'test/unit/repo-manager-finalize-invariant.test.ts',
|
||||
'test/unit/git-utils.test.ts',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -30,7 +30,13 @@ export interface DiInjectionMatch {
|
|||
namedSelection?: {
|
||||
name: string;
|
||||
reason: string;
|
||||
/** Name-first frameworks may fall back to type only for implicit/default
|
||||
* names. Explicit names remain strict. */
|
||||
fallbackToType?: boolean;
|
||||
};
|
||||
/** Most injection edges originate at the owning Class. Factory-method
|
||||
* parameters preserve the Method as the semantic source. */
|
||||
edgeSource?: 'owner-class' | 'site';
|
||||
/** Human-readable edge reason. Framework specifics (names, idioms,
|
||||
* collection wrapper, gating annotation) live in this payload so the
|
||||
* shared `di` phase stays framework-neutral. */
|
||||
|
|
@ -41,6 +47,13 @@ export interface DiInjectionMatch {
|
|||
export interface DiProviderMatch {
|
||||
/** Provider names and aliases that can satisfy a named injection. */
|
||||
names: readonly string[];
|
||||
/** Optional type directly provided by a declaration node, such as a
|
||||
* framework factory method whose node is not itself a Class. */
|
||||
providedTypeName?: string;
|
||||
/** Graph node that declares this provider. The shared phase excludes a
|
||||
* provider from injection into its own declaration site without knowing the
|
||||
* framework-specific declaration model. */
|
||||
declaredByNodeId?: string;
|
||||
/** Present when the framework marks this as its preferred candidate. The
|
||||
* value is appended to the emitted edge reason when it disambiguates. */
|
||||
preferenceReason?: string;
|
||||
|
|
|
|||
|
|
@ -262,11 +262,16 @@ function isInjectionMatch(value: unknown): value is DiInjectionMatch {
|
|||
typeof match.targetTypeName === 'string' &&
|
||||
(match.cardinality === 'single' || match.cardinality === 'collection') &&
|
||||
typeof match.reason === 'string' &&
|
||||
(match.edgeSource === undefined ||
|
||||
match.edgeSource === 'owner-class' ||
|
||||
match.edgeSource === 'site') &&
|
||||
(namedSelection === undefined ||
|
||||
(typeof namedSelection === 'object' &&
|
||||
namedSelection !== null &&
|
||||
typeof namedSelection.name === 'string' &&
|
||||
typeof namedSelection.reason === 'string'))
|
||||
typeof namedSelection.reason === 'string' &&
|
||||
(namedSelection.fallbackToType === undefined ||
|
||||
typeof namedSelection.fallbackToType === 'boolean')))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -276,6 +281,8 @@ function isProviderMatch(value: unknown): value is DiProviderMatch {
|
|||
return (
|
||||
Array.isArray(provider.names) &&
|
||||
provider.names.every((name) => typeof name === 'string') &&
|
||||
(provider.providedTypeName === undefined || typeof provider.providedTypeName === 'string') &&
|
||||
(provider.declaredByNodeId === undefined || typeof provider.declaredByNodeId === 'string') &&
|
||||
(provider.preferenceReason === undefined || typeof provider.preferenceReason === 'string')
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ function collectReferenceSites(parsedFiles: readonly ParsedFile[]) {
|
|||
function withDefaultHooks(partial: Partial<FinalizeHooks>): FinalizeHooks {
|
||||
return {
|
||||
resolveImportTarget: partial.resolveImportTarget ?? (() => null),
|
||||
isNamespaceImport: partial.isNamespaceImport,
|
||||
expandsWildcardTo: partial.expandsWildcardTo ?? (() => []),
|
||||
mergeBindings:
|
||||
partial.mergeBindings ??
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { isSpringBeanCandidateSourceFile } from './bean-catalog.js';
|
|||
/** Durable completeness contract for Java/Kotlin Spring Bean evidence. */
|
||||
export const SPRING_BEAN_INVENTORY_FEATURE: AnalysisFeatureDescriptor = {
|
||||
id: 'spring.bean-inventory',
|
||||
version: 1,
|
||||
version: 2,
|
||||
appliesTo: (filePaths) => filePaths.some(isSpringBeanCandidateSourceFile),
|
||||
};
|
||||
|
||||
|
|
@ -27,3 +27,19 @@ export const SPRING_CONDITIONALS_FEATURE: AnalysisFeatureDescriptor = {
|
|||
version: 1,
|
||||
appliesTo: (filePaths) => filePaths.some(isSpringConditionOrAutoConfigurationFile),
|
||||
};
|
||||
|
||||
/**
|
||||
* Candidate-language approximation, not a claim that the file contains AOP.
|
||||
* Kotlin scripts are included because `.kts` is a supported Kotlin input.
|
||||
*/
|
||||
function isJvmSourceFile(filePath: string): boolean {
|
||||
const normalized = filePath.replaceAll('\\', '/').toLowerCase();
|
||||
return normalized.endsWith('.java') || normalized.endsWith('.kt') || normalized.endsWith('.kts');
|
||||
}
|
||||
|
||||
/** Durable completeness contract for Spring proxy/advice evidence (#2416). */
|
||||
export const SPRING_AOP_FEATURE: AnalysisFeatureDescriptor = {
|
||||
id: 'spring.aop-advice',
|
||||
version: 1,
|
||||
appliesTo: (filePaths) => filePaths.some(isJvmSourceFile),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
export interface SpringAnnotationArgument {
|
||||
readonly name?: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
function splitTopLevel(value: string, separator: string): string[] | null {
|
||||
const parts: string[] = [];
|
||||
const stack: string[] = [];
|
||||
let quote: '"' | "'" | '"""' | null = null;
|
||||
let escaped = false;
|
||||
let start = 0;
|
||||
|
||||
// Angle brackets are deliberate: Java/Kotlin generic expressions may contain
|
||||
// commas that are not annotation-argument separators. Ambiguous comparison
|
||||
// expressions remain unsupported and fail closed instead of being mis-split.
|
||||
const closing = new Map([
|
||||
['(', ')'],
|
||||
['[', ']'],
|
||||
['{', '}'],
|
||||
['<', '>'],
|
||||
]);
|
||||
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const char = value[index];
|
||||
if (quote === '"""') {
|
||||
if (value.startsWith('"""', index)) {
|
||||
quote = null;
|
||||
index += 2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (quote !== null) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === '\\') escaped = true;
|
||||
else if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (value.startsWith('"""', index)) {
|
||||
quote = '"""';
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
const closingChar = closing.get(char);
|
||||
if (closingChar !== undefined) {
|
||||
stack.push(closingChar);
|
||||
continue;
|
||||
}
|
||||
if (stack[stack.length - 1] === char) {
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
if (char === separator && stack.length === 0) {
|
||||
parts.push(value.slice(start, index).trim());
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
if (quote !== null || stack.length > 0) return null;
|
||||
parts.push(value.slice(start).trim());
|
||||
return parts;
|
||||
}
|
||||
|
||||
function topLevelAssignment(argument: string): number {
|
||||
const stack: string[] = [];
|
||||
let quote: '"' | "'" | '"""' | null = null;
|
||||
let escaped = false;
|
||||
// Keep the same deliberate generic-delimiter policy as splitTopLevel.
|
||||
const closing = new Map([
|
||||
['(', ')'],
|
||||
['[', ']'],
|
||||
['{', '}'],
|
||||
['<', '>'],
|
||||
]);
|
||||
|
||||
for (let index = 0; index < argument.length; index++) {
|
||||
const char = argument[index];
|
||||
if (quote === '"""') {
|
||||
if (argument.startsWith('"""', index)) {
|
||||
quote = null;
|
||||
index += 2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (quote !== null) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === '\\') escaped = true;
|
||||
else if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (argument.startsWith('"""', index)) {
|
||||
quote = '"""';
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
const closingChar = closing.get(char);
|
||||
if (closingChar !== undefined) {
|
||||
stack.push(closingChar);
|
||||
continue;
|
||||
}
|
||||
if (stack[stack.length - 1] === char) {
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
if (char === '=' && stack.length === 0) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Parse Java/Kotlin annotation arguments without evaluating constants. */
|
||||
export function parseSpringAnnotationArguments(
|
||||
annotationText: string,
|
||||
): readonly SpringAnnotationArgument[] | null {
|
||||
const open = annotationText.indexOf('(');
|
||||
if (open === -1) return [];
|
||||
const close = annotationText.lastIndexOf(')');
|
||||
if (close < open || annotationText.slice(close + 1).trim().length > 0) return null;
|
||||
const body = annotationText.slice(open + 1, close).trim();
|
||||
if (body.length === 0) return [];
|
||||
const rawArguments = splitTopLevel(body, ',');
|
||||
if (rawArguments === null || rawArguments.some((argument) => argument.length === 0)) return null;
|
||||
|
||||
const parsed: SpringAnnotationArgument[] = [];
|
||||
for (const raw of rawArguments) {
|
||||
const assignment = topLevelAssignment(raw);
|
||||
if (assignment === -1) {
|
||||
parsed.push({ value: raw });
|
||||
continue;
|
||||
}
|
||||
const name = raw.slice(0, assignment).trim();
|
||||
const value = raw.slice(assignment + 1).trim();
|
||||
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) || value.length === 0) return null;
|
||||
parsed.push({ name, value });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseStaticStringLiteral(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('"""')) {
|
||||
if (trimmed.length < 6 || !trimmed.endsWith('"""')) return null;
|
||||
const raw = trimmed.slice(3, -3);
|
||||
if (raw.includes('"""') || /\$(?:\{|[A-Za-z_])/.test(raw)) return null;
|
||||
return raw;
|
||||
}
|
||||
const match = /^"((?:\\.|[^"\\])*)"$/s.exec(trimmed);
|
||||
if (match === null) return null;
|
||||
if (/(^|[^\\])\$(?:\{|[A-Za-z_])/.test(match[1])) return null;
|
||||
try {
|
||||
return JSON.parse(`"${match[1]}"`) as string;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse one string literal or a Java/Kotlin annotation string array. */
|
||||
export function parseStaticStringValues(value: string): readonly string[] | null {
|
||||
const trimmed = value.trim();
|
||||
const pair =
|
||||
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'));
|
||||
const body = pair ? trimmed.slice(1, -1).trim() : trimmed;
|
||||
if (body.length === 0) return [];
|
||||
const parts = pair ? splitTopLevel(body, ',') : [body];
|
||||
if (parts === null) return null;
|
||||
const strings: string[] = [];
|
||||
for (const part of parts) {
|
||||
const parsed = parseStaticStringLiteral(part);
|
||||
if (parsed === null) return null;
|
||||
strings.push(parsed);
|
||||
}
|
||||
return strings;
|
||||
}
|
||||
|
||||
/** Parse `Foo.class` or `Foo::class`; an Object/Any default returns an empty string. */
|
||||
export function parseStaticClassLiteral(value: string): string | null {
|
||||
const compact = value.replace(/\s+/g, '');
|
||||
const match = /^([A-Za-z_$][A-Za-z0-9_$.]*)(?:\.class|::class)$/.exec(compact);
|
||||
if (match === null) return null;
|
||||
const typeName = match[1];
|
||||
if (
|
||||
typeName === 'Object' ||
|
||||
typeName === 'java.lang.Object' ||
|
||||
typeName === 'Any' ||
|
||||
typeName === 'kotlin.Any'
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
return typeName;
|
||||
}
|
||||
|
||||
/** Normalize a declared bean type to the graph's simple/qualified raw type key. */
|
||||
export function normalizeSpringBeanType(rawType: string): string | null {
|
||||
// Projection keywords are tokens only when separated from the following
|
||||
// type. Strip them before whitespace compaction so valid lowercase type
|
||||
// aliases such as `outputStream` and `inside.Type` remain intact.
|
||||
let normalized = rawType
|
||||
.replace(/^(?:out|in)\s+/, '')
|
||||
.replace(/([<,])\s*(?:out|in)\s+/g, '$1')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(
|
||||
/^kotlin\.collections\.Mutable(List|Set|Collection|Map)(?=<|$)/,
|
||||
'kotlin.collections.$1',
|
||||
)
|
||||
.replace(/^Mutable(List|Set|Collection|Map)(?=<|$)/, '$1')
|
||||
.replace(/\?$/, '');
|
||||
const generic = normalized.indexOf('<');
|
||||
if (generic !== -1) {
|
||||
if (!normalized.endsWith('>')) return null;
|
||||
normalized = normalized.slice(0, generic);
|
||||
}
|
||||
if (
|
||||
normalized === 'void' ||
|
||||
normalized === 'Void' ||
|
||||
normalized === 'Unit' ||
|
||||
normalized === 'kotlin.Unit'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(normalized)
|
||||
? normalized
|
||||
: null;
|
||||
}
|
||||
118
gitnexus/src/core/ingestion/frameworks/spring/aop-candidates.ts
Normal file
118
gitnexus/src/core/ingestion/frameworks/spring/aop-candidates.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import type { SpringAopStaticPointcut } from './aop.js';
|
||||
|
||||
export interface SpringAopOwnedMethod {
|
||||
readonly method: GraphNode;
|
||||
readonly owner: GraphNode;
|
||||
}
|
||||
|
||||
export interface SpringAopCandidateIndex {
|
||||
readonly totalCandidates: number;
|
||||
candidatesFor(pointcut: SpringAopStaticPointcut): readonly SpringAopOwnedMethod[];
|
||||
}
|
||||
|
||||
interface OwnerBucket {
|
||||
readonly id: string;
|
||||
readonly qualifiedName: string;
|
||||
readonly candidates: SpringAopOwnedMethod[];
|
||||
}
|
||||
|
||||
function ownerPatternLiteralPrefix(pattern: string): string {
|
||||
const wildcardIndex = pattern.indexOf('*');
|
||||
const descendantIndex = pattern.indexOf('..');
|
||||
const firstDynamicIndex = [wildcardIndex, descendantIndex]
|
||||
.filter((index) => index >= 0)
|
||||
.reduce((first, index) => Math.min(first, index), pattern.length);
|
||||
return pattern.slice(0, firstDynamicIndex);
|
||||
}
|
||||
|
||||
function lowerBoundByQualifiedName(buckets: readonly OwnerBucket[], prefix: string): number {
|
||||
let low = 0;
|
||||
let high = buckets.length;
|
||||
while (low < high) {
|
||||
const middle = low + Math.floor((high - low) / 2);
|
||||
if ((buckets[middle]?.qualifiedName ?? '') < prefix) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
function compareStrings(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build immutable candidate lists once per pipeline run. The pointcut matcher
|
||||
* remains the final authority; this index only returns safe supersets.
|
||||
*/
|
||||
export function createSpringAopCandidateIndex(
|
||||
candidates: readonly SpringAopOwnedMethod[],
|
||||
methodAnnotations: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
): SpringAopCandidateIndex {
|
||||
const allCandidates = [...candidates];
|
||||
const candidatesByAnnotation = new Map<string, SpringAopOwnedMethod[]>();
|
||||
const candidatesByExactOwner = new Map<string, SpringAopOwnedMethod[]>();
|
||||
const ownerBucketsById = new Map<string, OwnerBucket>();
|
||||
|
||||
for (const candidate of allCandidates) {
|
||||
for (const annotation of methodAnnotations.get(candidate.method.id) ?? []) {
|
||||
const annotated = candidatesByAnnotation.get(annotation) ?? [];
|
||||
annotated.push(candidate);
|
||||
candidatesByAnnotation.set(annotation, annotated);
|
||||
}
|
||||
|
||||
const qualifiedName = candidate.owner.properties.qualifiedName;
|
||||
if (typeof qualifiedName !== 'string' || qualifiedName.length === 0) continue;
|
||||
const exactOwnerCandidates = candidatesByExactOwner.get(qualifiedName) ?? [];
|
||||
exactOwnerCandidates.push(candidate);
|
||||
candidatesByExactOwner.set(qualifiedName, exactOwnerCandidates);
|
||||
|
||||
let bucket = ownerBucketsById.get(candidate.owner.id);
|
||||
if (bucket === undefined) {
|
||||
bucket = { id: candidate.owner.id, qualifiedName, candidates: [] };
|
||||
ownerBucketsById.set(candidate.owner.id, bucket);
|
||||
}
|
||||
bucket.candidates.push(candidate);
|
||||
}
|
||||
|
||||
const ownerBuckets = [...ownerBucketsById.values()].sort(
|
||||
(left, right) =>
|
||||
compareStrings(left.qualifiedName, right.qualifiedName) || compareStrings(left.id, right.id),
|
||||
);
|
||||
const candidatesByOwnerPattern = new Map<string, readonly SpringAopOwnedMethod[]>();
|
||||
|
||||
return {
|
||||
totalCandidates: allCandidates.length,
|
||||
candidatesFor(pointcut) {
|
||||
if (pointcut.kind === 'annotation') {
|
||||
return candidatesByAnnotation.get(pointcut.annotation) ?? [];
|
||||
}
|
||||
if (!pointcut.ownerPattern.includes('*') && !pointcut.ownerPattern.includes('..')) {
|
||||
return candidatesByExactOwner.get(pointcut.ownerPattern) ?? [];
|
||||
}
|
||||
|
||||
// Unqualified wildcard patterns (for example `*Service` or `Order*`)
|
||||
// match the owner simple name. The qualified-name prefix index cannot
|
||||
// safely narrow those, so preserve the full candidate superset.
|
||||
if (!pointcut.ownerPattern.includes('.')) return allCandidates;
|
||||
|
||||
const prefix = ownerPatternLiteralPrefix(pointcut.ownerPattern);
|
||||
if (prefix.length === 0) return allCandidates;
|
||||
const cached = candidatesByOwnerPattern.get(pointcut.ownerPattern);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const selected: SpringAopOwnedMethod[] = [];
|
||||
for (
|
||||
let index = lowerBoundByQualifiedName(ownerBuckets, prefix);
|
||||
index < ownerBuckets.length;
|
||||
index += 1
|
||||
) {
|
||||
const bucket = ownerBuckets[index];
|
||||
if (bucket === undefined || !bucket.qualifiedName.startsWith(prefix)) break;
|
||||
selected.push(...bucket.candidates);
|
||||
}
|
||||
candidatesByOwnerPattern.set(pointcut.ownerPattern, selected);
|
||||
return selected;
|
||||
},
|
||||
};
|
||||
}
|
||||
707
gitnexus/src/core/ingestion/frameworks/spring/aop.ts
Normal file
707
gitnexus/src/core/ingestion/frameworks/spring/aop.ts
Normal file
|
|
@ -0,0 +1,707 @@
|
|||
import type { GraphNode, ParsedFile, Range, ScopeId } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import {
|
||||
resolveCallerGraphId,
|
||||
resolveDefGraphId,
|
||||
} from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import { stripBidiAndZeroWidth } from '../../utils/ast-helpers.js';
|
||||
import {
|
||||
parseSpringAnnotationArguments,
|
||||
parseStaticStringLiteral,
|
||||
} from './annotation-arguments.js';
|
||||
import { createSpringAnnotationNameResolver } from './bean-candidates.js';
|
||||
|
||||
export const SPRING_AOP_REASON_PREFIX = 'spring-aop:v1:';
|
||||
export const SPRING_AOP_EVIDENCE_DESCRIPTION_PREFIX = 'Spring AOP: ';
|
||||
export const SPRING_AOP_EVIDENCE_ID_PREFIX = 'CodeElement:spring-aop:';
|
||||
const MAX_POINTCUT_LENGTH = 1_000;
|
||||
|
||||
export type SpringAopBehavior =
|
||||
| 'transactional'
|
||||
| 'caching'
|
||||
| 'cacheable'
|
||||
| 'cache-evict'
|
||||
| 'cache-put'
|
||||
| 'authorization';
|
||||
|
||||
export type SpringAopAdviceKind =
|
||||
| 'around'
|
||||
| 'before'
|
||||
| 'after'
|
||||
| 'after-returning'
|
||||
| 'after-throwing'
|
||||
| 'pointcut';
|
||||
|
||||
export interface SpringAopAnnotationFact {
|
||||
readonly name: string;
|
||||
readonly text: string;
|
||||
readonly line: number;
|
||||
/** Kotlin use-site targets do not describe a callable annotation here. */
|
||||
readonly useSiteTarget?: string;
|
||||
}
|
||||
|
||||
export interface SpringAopOwnerFact<
|
||||
Annotation extends SpringAopAnnotationFact = SpringAopAnnotationFact,
|
||||
> {
|
||||
readonly ownerScopeId: ScopeId;
|
||||
readonly ownerKind: 'class' | 'callable';
|
||||
readonly ownerFilePath?: string;
|
||||
/** Exact syntax range used only as a fail-closed bridge for collapsed language scopes. */
|
||||
readonly ownerRange?: Range;
|
||||
/** The language models this owner/member as static, but it belongs to a singleton instance. */
|
||||
readonly singletonInstance?: true;
|
||||
readonly annotations: readonly Annotation[];
|
||||
}
|
||||
|
||||
export interface SpringAopMetadataAdapter<Annotation extends SpringAopAnnotationFact> {
|
||||
getFacts(filePath: string): readonly SpringAopOwnerFact<Annotation>[];
|
||||
isPackageVisibilityIncomplete(filePath: string): boolean;
|
||||
}
|
||||
|
||||
export interface SpringAopBehaviorReason {
|
||||
readonly kind: 'behavior';
|
||||
readonly annotation: string;
|
||||
readonly behavior: SpringAopBehavior;
|
||||
readonly declaredOn: 'class' | 'method';
|
||||
readonly activation: 'unknown';
|
||||
readonly proxy: 'possible';
|
||||
}
|
||||
|
||||
export interface SpringAopAdviceReason {
|
||||
readonly kind: 'advice';
|
||||
readonly annotation: string;
|
||||
readonly advice: Exclude<SpringAopAdviceKind, 'pointcut'>;
|
||||
readonly pointcut: string;
|
||||
readonly match: 'static';
|
||||
readonly activation: 'unknown';
|
||||
readonly proxy: 'possible';
|
||||
}
|
||||
|
||||
export interface SpringAopPointcutReason {
|
||||
readonly kind: 'pointcut';
|
||||
readonly annotation: string;
|
||||
readonly pointcut: string | null;
|
||||
readonly match: 'static' | 'unresolved';
|
||||
readonly resolution: 'resolved' | 'unknown';
|
||||
}
|
||||
|
||||
export interface SpringAopAspectReason {
|
||||
readonly kind: 'aspect';
|
||||
readonly annotation: string;
|
||||
readonly activation: 'unknown';
|
||||
readonly registration: 'unknown';
|
||||
}
|
||||
|
||||
export type SpringAopReason =
|
||||
| SpringAopBehaviorReason
|
||||
| SpringAopAdviceReason
|
||||
| SpringAopPointcutReason
|
||||
| SpringAopAspectReason;
|
||||
|
||||
export interface SpringAopAspectRecord {
|
||||
readonly ownerId: string;
|
||||
readonly annotation: string;
|
||||
readonly line: number;
|
||||
}
|
||||
|
||||
export interface SpringAopBehaviorRecord {
|
||||
readonly ownerId: string;
|
||||
readonly ownerKind: 'class' | 'callable';
|
||||
readonly annotation: string;
|
||||
readonly behavior: SpringAopBehavior;
|
||||
readonly line: number;
|
||||
}
|
||||
|
||||
export interface SpringAopAdviceRecord {
|
||||
readonly ownerId: string;
|
||||
readonly annotation: string;
|
||||
readonly advice: SpringAopAdviceKind;
|
||||
readonly pointcut: string | null;
|
||||
readonly line: number;
|
||||
}
|
||||
|
||||
export interface SpringAopGraphMetadata {
|
||||
readonly candidateFilePaths: ReadonlySet<string>;
|
||||
readonly aspectClassIds: ReadonlySet<string>;
|
||||
readonly singletonInstanceClassIds: ReadonlySet<string>;
|
||||
readonly aspects: readonly SpringAopAspectRecord[];
|
||||
readonly behaviors: readonly SpringAopBehaviorRecord[];
|
||||
readonly advices: readonly SpringAopAdviceRecord[];
|
||||
}
|
||||
|
||||
interface MutableSpringAopGraphMetadata {
|
||||
readonly candidateFilePaths: Set<string>;
|
||||
readonly aspectClassIds: Set<string>;
|
||||
readonly singletonInstanceClassIds: Set<string>;
|
||||
readonly aspects: SpringAopAspectRecord[];
|
||||
readonly behaviors: SpringAopBehaviorRecord[];
|
||||
readonly advices: SpringAopAdviceRecord[];
|
||||
}
|
||||
|
||||
const metadataByGraph = new WeakMap<KnowledgeGraph, MutableSpringAopGraphMetadata>();
|
||||
|
||||
function graphMetadata(graph: KnowledgeGraph): MutableSpringAopGraphMetadata {
|
||||
let metadata = metadataByGraph.get(graph);
|
||||
if (metadata === undefined) {
|
||||
metadata = {
|
||||
candidateFilePaths: new Set(),
|
||||
aspectClassIds: new Set(),
|
||||
singletonInstanceClassIds: new Set(),
|
||||
aspects: [],
|
||||
behaviors: [],
|
||||
advices: [],
|
||||
};
|
||||
metadataByGraph.set(graph, metadata);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function getSpringAopGraphMetadata(graph: KnowledgeGraph): SpringAopGraphMetadata {
|
||||
return graphMetadata(graph);
|
||||
}
|
||||
|
||||
const ASPECT_ANNOTATION = 'org.aspectj.lang.annotation.Aspect';
|
||||
|
||||
const BEHAVIOR_ANNOTATIONS = new Map<string, SpringAopBehavior>([
|
||||
['org.springframework.transaction.annotation.Transactional', 'transactional'],
|
||||
['jakarta.transaction.Transactional', 'transactional'],
|
||||
['javax.transaction.Transactional', 'transactional'],
|
||||
['org.springframework.cache.annotation.Cacheable', 'cacheable'],
|
||||
['org.springframework.cache.annotation.CacheEvict', 'cache-evict'],
|
||||
['org.springframework.cache.annotation.CachePut', 'cache-put'],
|
||||
['org.springframework.cache.annotation.Caching', 'caching'],
|
||||
['org.springframework.security.access.prepost.PreAuthorize', 'authorization'],
|
||||
['org.springframework.security.access.prepost.PostAuthorize', 'authorization'],
|
||||
['org.springframework.security.access.prepost.PreFilter', 'authorization'],
|
||||
['org.springframework.security.access.prepost.PostFilter', 'authorization'],
|
||||
['org.springframework.security.access.annotation.Secured', 'authorization'],
|
||||
['jakarta.annotation.security.RolesAllowed', 'authorization'],
|
||||
['javax.annotation.security.RolesAllowed', 'authorization'],
|
||||
]);
|
||||
|
||||
const ADVICE_ANNOTATIONS = new Map<string, SpringAopAdviceKind>([
|
||||
['org.aspectj.lang.annotation.Around', 'around'],
|
||||
['org.aspectj.lang.annotation.Before', 'before'],
|
||||
['org.aspectj.lang.annotation.After', 'after'],
|
||||
['org.aspectj.lang.annotation.AfterReturning', 'after-returning'],
|
||||
['org.aspectj.lang.annotation.AfterThrowing', 'after-throwing'],
|
||||
['org.aspectj.lang.annotation.Pointcut', 'pointcut'],
|
||||
]);
|
||||
|
||||
const RECOGNIZED_AOP_ANNOTATIONS = new Set<string>([
|
||||
ASPECT_ANNOTATION,
|
||||
...BEHAVIOR_ANNOTATIONS.keys(),
|
||||
...ADVICE_ANNOTATIONS.keys(),
|
||||
]);
|
||||
|
||||
const CAPTURE_RELEVANT_SIMPLE_NAMES = new Set(
|
||||
[...RECOGNIZED_AOP_ANNOTATIONS].map((name) => simpleName(name)),
|
||||
);
|
||||
|
||||
function simpleName(name: string): string {
|
||||
const separator = name.lastIndexOf('.');
|
||||
return separator === -1 ? name : name.slice(separator + 1);
|
||||
}
|
||||
|
||||
export function hasSpringAopRelevantAnnotation(
|
||||
annotations: readonly Pick<SpringAopAnnotationFact, 'name'>[],
|
||||
): boolean {
|
||||
return annotations.some((annotation) =>
|
||||
CAPTURE_RELEVANT_SIMPLE_NAMES.has(simpleName(annotation.name)),
|
||||
);
|
||||
}
|
||||
|
||||
function ownerGraphNode(
|
||||
fact: SpringAopOwnerFact,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
nodeLookup: GraphNodeLookup,
|
||||
graph: KnowledgeGraph,
|
||||
exactOwnerByRange: ReadonlyMap<string, GraphNode | null>,
|
||||
): GraphNode | undefined {
|
||||
const ownerScope = indexes.scopeTree.getScope(fact.ownerScopeId);
|
||||
|
||||
let ownerId: string | undefined;
|
||||
if (fact.ownerKind === 'class' && ownerScope !== undefined) {
|
||||
const classDef = ownerScope.ownedDefs.find(
|
||||
(definition) => definition.type === 'Class' || definition.type === 'Interface',
|
||||
);
|
||||
if (classDef !== undefined)
|
||||
ownerId = resolveDefGraphId(classDef.filePath, classDef, nodeLookup);
|
||||
} else if (ownerScope !== undefined) {
|
||||
ownerId = resolveCallerGraphId(fact.ownerScopeId, indexes, nodeLookup);
|
||||
}
|
||||
|
||||
if (ownerId === undefined && fact.ownerFilePath !== undefined && fact.ownerRange !== undefined) {
|
||||
const kind = fact.ownerKind === 'class' ? 'class' : 'callable';
|
||||
const fallback = exactOwnerByRange.get(
|
||||
`${kind}\0${fact.ownerFilePath}\0${fact.ownerRange.startLine - 1}\0${fact.ownerRange.endLine - 1}`,
|
||||
);
|
||||
if (fallback !== null && fallback !== undefined) ownerId = fallback.id;
|
||||
}
|
||||
if (ownerId === undefined) return undefined;
|
||||
const owner = graph.getNode(ownerId);
|
||||
return owner === undefined || owner.label === 'File' ? undefined : owner;
|
||||
}
|
||||
|
||||
function staticPointcutExpression(annotationText: string): string | null {
|
||||
const args = parseSpringAnnotationArguments(annotationText);
|
||||
if (args === null) return null;
|
||||
const pointcutArguments = args.filter(
|
||||
(argument) =>
|
||||
argument.name === undefined || argument.name === 'value' || argument.name === 'pointcut',
|
||||
);
|
||||
if (pointcutArguments.length !== 1) return null;
|
||||
const allowedCompanionArguments = new Set(['returning', 'throwing', 'argNames']);
|
||||
if (
|
||||
args.some(
|
||||
(argument) =>
|
||||
argument !== pointcutArguments[0] &&
|
||||
(argument.name === undefined || !allowedCompanionArguments.has(argument.name)),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const argument = pointcutArguments[0];
|
||||
if (argument === undefined) return null;
|
||||
const parsed = parseStaticStringLiteral(argument.value);
|
||||
return parsed === null ? null : sanitizePointcut(parsed);
|
||||
}
|
||||
|
||||
function sanitizePointcut(value: string): string | null {
|
||||
const normalized = stripBidiAndZeroWidth(value).replace(/\s+/g, ' ').trim();
|
||||
return normalized.length > 0 && normalized.length <= MAX_POINTCUT_LENGTH ? normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve syntax facts after imports and package visibility are complete.
|
||||
* Language adapters own AST shape only; this shared layer owns framework FQNs.
|
||||
*/
|
||||
export function createSpringAopMetadataAttacher<Annotation extends SpringAopAnnotationFact>(
|
||||
adapter: SpringAopMetadataAdapter<Annotation>,
|
||||
) {
|
||||
return (
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): void => {
|
||||
const resolveAnnotation = createSpringAnnotationNameResolver(indexes);
|
||||
const metadata = graphMetadata(graph);
|
||||
const exactOwnerByRange = new Map<string, GraphNode | null>();
|
||||
for (const node of graph.iterNodes()) {
|
||||
const kind =
|
||||
node.label === 'Method'
|
||||
? 'callable'
|
||||
: node.label === 'Class' || node.label === 'Interface'
|
||||
? 'class'
|
||||
: undefined;
|
||||
if (kind === undefined || typeof node.properties.filePath !== 'string') continue;
|
||||
const key = `${kind}\0${node.properties.filePath}\0${node.properties.startLine}\0${node.properties.endLine}`;
|
||||
exactOwnerByRange.set(key, exactOwnerByRange.has(key) ? null : node);
|
||||
}
|
||||
let classIdByMethod: ReadonlyMap<string, string> | undefined;
|
||||
|
||||
const singletonOwnerId = (owner: GraphNode): string | undefined => {
|
||||
if (owner.label === 'Class' || owner.label === 'Interface') return owner.id;
|
||||
if (owner.label !== 'Method') return undefined;
|
||||
if (classIdByMethod === undefined) {
|
||||
const owners = new Map<string, string>();
|
||||
for (const relationship of graph.iterRelationshipsByType('HAS_METHOD')) {
|
||||
owners.set(relationship.targetId, relationship.sourceId);
|
||||
}
|
||||
classIdByMethod = owners;
|
||||
}
|
||||
return classIdByMethod.get(owner.id);
|
||||
};
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
// The set is populated only by registered language adapters. The shared
|
||||
// matcher can therefore reject same-qualified-name symbols from other
|
||||
// languages without naming Java/Kotlin in framework-generic code.
|
||||
metadata.candidateFilePaths.add(parsed.filePath);
|
||||
const incomplete = adapter.isPackageVisibilityIncomplete(parsed.filePath);
|
||||
const resolvedAnnotations = new Map<string, string | undefined>();
|
||||
for (const fact of adapter.getFacts(parsed.filePath)) {
|
||||
const owner = ownerGraphNode(fact, indexes, nodeLookup, graph, exactOwnerByRange);
|
||||
const ownerScope = indexes.scopeTree.getScope(fact.ownerScopeId);
|
||||
if (owner === undefined) continue;
|
||||
if (fact.singletonInstance === true) {
|
||||
const singletonId = singletonOwnerId(owner);
|
||||
if (singletonId !== undefined) metadata.singletonInstanceClassIds.add(singletonId);
|
||||
}
|
||||
|
||||
for (const annotation of fact.annotations) {
|
||||
// `@get:`, `@field:`, etc. target generated/property elements rather
|
||||
// than the callable represented by this fact. Guessing would overstate
|
||||
// proxy behavior, so Kotlin use-site targets fail closed.
|
||||
if (annotation.useSiteTarget !== undefined) continue;
|
||||
const enclosingScope = ownerScope?.parent ?? null;
|
||||
const cacheKey = `${enclosingScope ?? '<root>'}\0${annotation.name}`;
|
||||
let resolved = resolvedAnnotations.get(cacheKey);
|
||||
if (!resolvedAnnotations.has(cacheKey)) {
|
||||
resolved = resolveAnnotation(
|
||||
annotation.name,
|
||||
parsed,
|
||||
enclosingScope,
|
||||
RECOGNIZED_AOP_ANNOTATIONS,
|
||||
incomplete,
|
||||
);
|
||||
resolvedAnnotations.set(cacheKey, resolved);
|
||||
}
|
||||
if (resolved === undefined) continue;
|
||||
|
||||
if (resolved === ASPECT_ANNOTATION && fact.ownerKind === 'class') {
|
||||
metadata.aspectClassIds.add(owner.id);
|
||||
metadata.aspects.push({
|
||||
ownerId: owner.id,
|
||||
annotation: resolved,
|
||||
line: annotation.line,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const behavior = BEHAVIOR_ANNOTATIONS.get(resolved);
|
||||
if (behavior !== undefined) {
|
||||
metadata.behaviors.push({
|
||||
ownerId: owner.id,
|
||||
ownerKind: fact.ownerKind,
|
||||
annotation: resolved,
|
||||
behavior,
|
||||
line: annotation.line,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const advice = ADVICE_ANNOTATIONS.get(resolved);
|
||||
if (advice !== undefined && fact.ownerKind === 'callable' && owner.label === 'Method') {
|
||||
metadata.advices.push({
|
||||
ownerId: owner.id,
|
||||
annotation: resolved,
|
||||
advice,
|
||||
pointcut: staticPointcutExpression(annotation.text),
|
||||
line: annotation.line,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isBoundedString(value: unknown, maxLength = MAX_POINTCUT_LENGTH): value is string {
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= maxLength;
|
||||
}
|
||||
|
||||
const BEHAVIORS = new Set<SpringAopBehavior>([
|
||||
'transactional',
|
||||
'caching',
|
||||
'cacheable',
|
||||
'cache-evict',
|
||||
'cache-put',
|
||||
'authorization',
|
||||
]);
|
||||
const ADVICES = new Set<Exclude<SpringAopAdviceKind, 'pointcut'>>([
|
||||
'around',
|
||||
'before',
|
||||
'after',
|
||||
'after-returning',
|
||||
'after-throwing',
|
||||
]);
|
||||
|
||||
export function encodeSpringAopReason(reason: SpringAopReason): string {
|
||||
return `${SPRING_AOP_REASON_PREFIX}${JSON.stringify(reason)}`;
|
||||
}
|
||||
|
||||
/** Decode only the current, validated reason contract; malformed/foreign rows fail closed. */
|
||||
export function decodeSpringAopReason(value: unknown): SpringAopReason | undefined {
|
||||
if (typeof value !== 'string' || !value.startsWith(SPRING_AOP_REASON_PREFIX)) return undefined;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value.slice(SPRING_AOP_REASON_PREFIX.length));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(parsed) || !isBoundedString(parsed.annotation)) return undefined;
|
||||
|
||||
if (
|
||||
parsed.kind === 'behavior' &&
|
||||
typeof parsed.behavior === 'string' &&
|
||||
BEHAVIORS.has(parsed.behavior as SpringAopBehavior) &&
|
||||
BEHAVIOR_ANNOTATIONS.get(parsed.annotation) === parsed.behavior &&
|
||||
(parsed.declaredOn === 'class' || parsed.declaredOn === 'method') &&
|
||||
parsed.activation === 'unknown' &&
|
||||
parsed.proxy === 'possible'
|
||||
) {
|
||||
return parsed as unknown as SpringAopBehaviorReason;
|
||||
}
|
||||
if (
|
||||
parsed.kind === 'advice' &&
|
||||
typeof parsed.advice === 'string' &&
|
||||
ADVICES.has(parsed.advice as Exclude<SpringAopAdviceKind, 'pointcut'>) &&
|
||||
ADVICE_ANNOTATIONS.get(parsed.annotation) === parsed.advice &&
|
||||
isBoundedString(parsed.pointcut) &&
|
||||
parsed.match === 'static' &&
|
||||
parsed.activation === 'unknown' &&
|
||||
parsed.proxy === 'possible'
|
||||
) {
|
||||
return parsed as unknown as SpringAopAdviceReason;
|
||||
}
|
||||
if (
|
||||
parsed.kind === 'pointcut' &&
|
||||
ADVICE_ANNOTATIONS.has(parsed.annotation) &&
|
||||
(parsed.pointcut === null || isBoundedString(parsed.pointcut)) &&
|
||||
((parsed.match === 'static' &&
|
||||
parsed.resolution === 'resolved' &&
|
||||
typeof parsed.pointcut === 'string') ||
|
||||
(parsed.match === 'unresolved' && parsed.resolution === 'unknown'))
|
||||
) {
|
||||
return parsed as unknown as SpringAopPointcutReason;
|
||||
}
|
||||
if (
|
||||
parsed.kind === 'aspect' &&
|
||||
parsed.annotation === ASPECT_ANNOTATION &&
|
||||
parsed.activation === 'unknown' &&
|
||||
parsed.registration === 'unknown'
|
||||
) {
|
||||
return parsed as unknown as SpringAopAspectReason;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isSpringAopEvidenceNode(node: GraphNode): boolean {
|
||||
return node.label === 'CodeElement' && node.id.startsWith(SPRING_AOP_EVIDENCE_ID_PREFIX);
|
||||
}
|
||||
|
||||
export interface SpringAopExecutionPointcut {
|
||||
readonly kind: 'execution';
|
||||
readonly ownerPattern: string;
|
||||
readonly methodPattern: string;
|
||||
readonly visibility?: 'public';
|
||||
readonly parameterCount?: number;
|
||||
}
|
||||
|
||||
export interface SpringAopWithinPointcut {
|
||||
readonly kind: 'within';
|
||||
readonly ownerPattern: string;
|
||||
}
|
||||
|
||||
export interface SpringAopAnnotationPointcut {
|
||||
readonly kind: 'annotation';
|
||||
readonly annotation: string;
|
||||
}
|
||||
|
||||
export type SpringAopStaticPointcut =
|
||||
| SpringAopExecutionPointcut
|
||||
| SpringAopWithinPointcut
|
||||
| SpringAopAnnotationPointcut;
|
||||
|
||||
const TYPE_PATTERN = /^[A-Za-z_$*][A-Za-z0-9_$.*]*$/;
|
||||
const METHOD_PATTERN = /^[A-Za-z_$*][A-Za-z0-9_$*]*$/;
|
||||
|
||||
/** Parse the deliberately narrow, fully static pointcut subset supported in v1. */
|
||||
export function parseSpringAopPointcut(expression: string): SpringAopStaticPointcut | null {
|
||||
const normalized = sanitizePointcut(expression);
|
||||
if (normalized === null) return null;
|
||||
const annotation = /^@annotation\s*\(\s*([A-Za-z_$][A-Za-z0-9_$.]*)\s*\)$/.exec(normalized);
|
||||
if (annotation !== null) {
|
||||
const annotationName = annotation[1];
|
||||
return annotationName !== undefined && BEHAVIOR_ANNOTATIONS.has(annotationName)
|
||||
? { kind: 'annotation', annotation: annotationName }
|
||||
: null;
|
||||
}
|
||||
const within = /^within\s*\(\s*([^()]+?)\s*\)$/.exec(normalized);
|
||||
if (within !== null) {
|
||||
const ownerPattern = within[1]?.trim();
|
||||
return ownerPattern !== undefined && validTypePattern(ownerPattern)
|
||||
? { kind: 'within', ownerPattern }
|
||||
: null;
|
||||
}
|
||||
|
||||
const execution = /^execution\s*\(\s*([^()]*)\(([^()]*)\)\s*\)$/.exec(normalized);
|
||||
if (execution === null) return null;
|
||||
const head = execution[1]?.trim();
|
||||
const parameters = execution[2]?.trim();
|
||||
if (head === undefined || parameters === undefined) return null;
|
||||
const tokens = head.split(/\s+/);
|
||||
let visibility: 'public' | undefined;
|
||||
let returnPattern: string;
|
||||
let qualifiedMethod: string;
|
||||
if (tokens.length === 2) {
|
||||
[returnPattern, qualifiedMethod] = tokens as [string, string];
|
||||
} else if (tokens.length === 3 && tokens[0] === 'public') {
|
||||
const publicTokens = tokens as [string, string, string];
|
||||
visibility = 'public';
|
||||
returnPattern = publicTokens[1];
|
||||
qualifiedMethod = publicTokens[2];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (returnPattern !== '*') return null;
|
||||
|
||||
const separator = qualifiedMethod.lastIndexOf('.');
|
||||
const ownerPattern = separator === -1 ? '*' : qualifiedMethod.slice(0, separator);
|
||||
const methodPattern = separator === -1 ? qualifiedMethod : qualifiedMethod.slice(separator + 1);
|
||||
if (!validTypePattern(ownerPattern) || !METHOD_PATTERN.test(methodPattern)) return null;
|
||||
|
||||
let parameterCount: number | undefined;
|
||||
if (parameters === '..') parameterCount = undefined;
|
||||
else if (parameters === '') parameterCount = 0;
|
||||
else {
|
||||
const parameterPatterns = parameters.split(',').map((part) => part.trim());
|
||||
if (parameterPatterns.some((part) => part !== '*')) return null;
|
||||
parameterCount = parameterPatterns.length;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'execution',
|
||||
ownerPattern,
|
||||
methodPattern,
|
||||
...(visibility === undefined ? {} : { visibility }),
|
||||
...(parameterCount === undefined ? {} : { parameterCount }),
|
||||
};
|
||||
}
|
||||
|
||||
function validTypePattern(pattern: string): boolean {
|
||||
return (
|
||||
TYPE_PATTERN.test(pattern) &&
|
||||
// A simple exact type name needs the aspect's package/import scope to
|
||||
// resolve correctly. That context is not retained in the v1 record, so
|
||||
// fail closed instead of claiming a static match. Unqualified wildcard
|
||||
// patterns are self-contained and match the declaring type's simple name.
|
||||
(pattern.includes('.') || pattern.includes('*')) &&
|
||||
!pattern.includes('...') &&
|
||||
pattern.split('..').length <= 2 &&
|
||||
!pattern.endsWith('.')
|
||||
);
|
||||
}
|
||||
|
||||
function findLiteral(
|
||||
value: string,
|
||||
literal: string,
|
||||
startIndex: number,
|
||||
endExclusive: number,
|
||||
): number {
|
||||
const prefixLengths = new Array<number>(literal.length).fill(0);
|
||||
for (let index = 1, prefixLength = 0; index < literal.length; index += 1) {
|
||||
while (prefixLength > 0 && literal[index] !== literal[prefixLength]) {
|
||||
prefixLength = prefixLengths[prefixLength - 1] ?? 0;
|
||||
}
|
||||
if (literal[index] === literal[prefixLength]) prefixLength += 1;
|
||||
prefixLengths[index] = prefixLength;
|
||||
}
|
||||
|
||||
for (let index = startIndex, prefixLength = 0; index < endExclusive; index += 1) {
|
||||
while (prefixLength > 0 && value[index] !== literal[prefixLength]) {
|
||||
prefixLength = prefixLengths[prefixLength - 1] ?? 0;
|
||||
}
|
||||
if (value[index] === literal[prefixLength]) prefixLength += 1;
|
||||
if (prefixLength === literal.length) return index - literal.length + 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Match a single-segment glob in O(pattern + value) without regex backtracking. */
|
||||
function segmentGlobMatches(pattern: string, value: string): boolean {
|
||||
if (!pattern.includes('*')) return pattern === value;
|
||||
const literalChunks = pattern.split('*').filter((chunk) => chunk.length > 0);
|
||||
if (literalChunks.length === 0) return true;
|
||||
|
||||
let firstMiddleChunk = 0;
|
||||
let lastMiddleChunkExclusive = literalChunks.length;
|
||||
let cursor = 0;
|
||||
let middleEndExclusive = value.length;
|
||||
|
||||
if (!pattern.startsWith('*')) {
|
||||
const prefix = literalChunks[0] ?? '';
|
||||
if (!value.startsWith(prefix)) return false;
|
||||
cursor = prefix.length;
|
||||
firstMiddleChunk = 1;
|
||||
}
|
||||
|
||||
if (!pattern.endsWith('*')) {
|
||||
const suffix = literalChunks[literalChunks.length - 1] ?? '';
|
||||
const suffixStart = value.length - suffix.length;
|
||||
if (suffixStart < cursor || !value.endsWith(suffix)) return false;
|
||||
middleEndExclusive = suffixStart;
|
||||
lastMiddleChunkExclusive -= 1;
|
||||
}
|
||||
|
||||
for (let index = firstMiddleChunk; index < lastMiddleChunkExclusive; index += 1) {
|
||||
const chunk = literalChunks[index] ?? '';
|
||||
const matchIndex = findLiteral(value, chunk, cursor, middleEndExclusive);
|
||||
if (matchIndex === -1) return false;
|
||||
cursor = matchIndex + chunk.length;
|
||||
}
|
||||
return cursor <= middleEndExclusive;
|
||||
}
|
||||
|
||||
function patternSegmentsMatch(
|
||||
patternSegments: readonly string[],
|
||||
valueSegments: readonly string[],
|
||||
) {
|
||||
return (
|
||||
patternSegments.length === valueSegments.length &&
|
||||
patternSegments.every((segment, index) =>
|
||||
segmentGlobMatches(segment, valueSegments[index] ?? ''),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Match Spring's narrow type-pattern subset without compiling repository input as regex. */
|
||||
function typePatternMatches(pattern: string, value: string): boolean {
|
||||
if (!validTypePattern(pattern) || value.length === 0) return false;
|
||||
if (pattern === '*') return true;
|
||||
|
||||
const valueSegments = value.split('.');
|
||||
if (valueSegments.some((segment) => segment.length === 0)) return false;
|
||||
if (!pattern.includes('.')) {
|
||||
return segmentGlobMatches(pattern, valueSegments[valueSegments.length - 1] ?? '');
|
||||
}
|
||||
const pieces = pattern.split('..');
|
||||
if (pieces.length === 1) return patternSegmentsMatch(pattern.split('.'), valueSegments);
|
||||
|
||||
const leftSegments = (pieces[0] ?? '').split('.');
|
||||
const rightSegments = (pieces[1] ?? '').split('.');
|
||||
if (valueSegments.length < leftSegments.length + rightSegments.length) return false;
|
||||
return (
|
||||
patternSegmentsMatch(leftSegments, valueSegments.slice(0, leftSegments.length)) &&
|
||||
patternSegmentsMatch(rightSegments, valueSegments.slice(-rightSegments.length))
|
||||
);
|
||||
}
|
||||
|
||||
export function springAopPointcutMatches(
|
||||
pointcut: SpringAopStaticPointcut,
|
||||
owner: GraphNode,
|
||||
method: GraphNode,
|
||||
methodAnnotations: ReadonlySet<string> = new Set(),
|
||||
): boolean {
|
||||
if (method.label !== 'Method') return false;
|
||||
if (pointcut.kind === 'annotation') return methodAnnotations.has(pointcut.annotation);
|
||||
const qualifiedName = owner.properties.qualifiedName;
|
||||
if (typeof qualifiedName !== 'string') return false;
|
||||
if (!typePatternMatches(pointcut.ownerPattern, qualifiedName)) return false;
|
||||
if (pointcut.kind === 'within') return true;
|
||||
if (
|
||||
pointcut.visibility === 'public' &&
|
||||
method.properties.visibility !== 'public' &&
|
||||
// Java and Kotlin interface methods are public when no visibility modifier
|
||||
// is present. Their extractors retain that absence as `package`; explicit
|
||||
// private members remain private and therefore fail this exception.
|
||||
!(owner.label === 'Interface' && method.properties.visibility === 'package')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
pointcut.parameterCount !== undefined &&
|
||||
method.properties.parameterCount !== pointcut.parameterCount
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return segmentGlobMatches(pointcut.methodPattern, method.properties.name);
|
||||
}
|
||||
112
gitnexus/src/core/ingestion/frameworks/spring/bean-factories.ts
Normal file
112
gitnexus/src/core/ingestion/frameworks/spring/bean-factories.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import type { GraphRelationship, ScopeId } from 'gitnexus-shared';
|
||||
import { parseSpringAnnotationArguments, parseStaticStringValues } from './annotation-arguments.js';
|
||||
import type { SpringDiAnnotationFact, SpringDiDependencyFact } from './di-metadata.js';
|
||||
|
||||
export const SPRING_BEAN_ANNOTATION = 'org.springframework.context.annotation.Bean';
|
||||
export const SPRING_BEAN_DECLARATION_ID_PREFIX = 'CodeElement:spring-bean:';
|
||||
export const SPRING_BEAN_FACTORY_REASON_PREFIX = 'spring-bean-factory:';
|
||||
|
||||
export interface SpringBeanFactoryMethodFact<
|
||||
Annotation extends SpringDiAnnotationFact = SpringDiAnnotationFact,
|
||||
> {
|
||||
readonly callableScopeId: ScopeId;
|
||||
readonly methodName: string;
|
||||
readonly returnType?: string;
|
||||
readonly annotations: readonly Annotation[];
|
||||
readonly dependencies: readonly SpringDiDependencyFact<Annotation>[];
|
||||
}
|
||||
|
||||
export interface SpringBeanFactoryDeclaration {
|
||||
readonly names: readonly string[];
|
||||
readonly namesKnown: boolean;
|
||||
readonly providedType?: string;
|
||||
}
|
||||
|
||||
export interface SpringBeanFactoryMetadata {
|
||||
readonly framework: 'spring';
|
||||
readonly role: 'factory-method';
|
||||
readonly annotation: typeof SPRING_BEAN_ANNOTATION;
|
||||
readonly names: readonly string[];
|
||||
readonly providedType?: string;
|
||||
}
|
||||
|
||||
function simpleName(name: string): string {
|
||||
const separator = name.lastIndexOf('.');
|
||||
return separator === -1 ? name : name.slice(separator + 1);
|
||||
}
|
||||
|
||||
export function hasSpringBeanFactorySyntax(
|
||||
annotations: readonly Pick<SpringDiAnnotationFact, 'name'>[],
|
||||
): boolean {
|
||||
return annotations.some((annotation) => simpleName(annotation.name) === 'Bean');
|
||||
}
|
||||
|
||||
/** Resolve statically readable Bean names; dynamic constants remain explicitly unknown. */
|
||||
export function springBeanNames(
|
||||
annotationText: string,
|
||||
defaultMethodName: string,
|
||||
): { readonly names: readonly string[]; readonly namesKnown: boolean } {
|
||||
const argumentsList = parseSpringAnnotationArguments(annotationText);
|
||||
if (argumentsList === null) return { names: [], namesKnown: false };
|
||||
const nameArguments = argumentsList.filter(
|
||||
(argument) =>
|
||||
argument.name === undefined || argument.name === 'name' || argument.name === 'value',
|
||||
);
|
||||
if (nameArguments.length === 0) return { names: [defaultMethodName], namesKnown: true };
|
||||
|
||||
const names = new Set<string>();
|
||||
for (const argument of nameArguments) {
|
||||
const values = parseStaticStringValues(argument.value);
|
||||
if (values === null) return { names: [], namesKnown: false };
|
||||
for (const value of values) {
|
||||
if (value.length > 0) names.add(value);
|
||||
}
|
||||
}
|
||||
return {
|
||||
names: names.size === 0 ? [defaultMethodName] : [...names],
|
||||
namesKnown: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeSpringBeanFactoryReason(declaration: SpringBeanFactoryDeclaration): string {
|
||||
return `${SPRING_BEAN_FACTORY_REASON_PREFIX}${JSON.stringify(declaration)}`;
|
||||
}
|
||||
|
||||
export function decodeSpringBeanFactoryReason(
|
||||
reason: unknown,
|
||||
): SpringBeanFactoryMetadata | undefined {
|
||||
if (typeof reason !== 'string' || !reason.startsWith(SPRING_BEAN_FACTORY_REASON_PREFIX)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(
|
||||
reason.slice(SPRING_BEAN_FACTORY_REASON_PREFIX.length),
|
||||
) as Partial<SpringBeanFactoryDeclaration>;
|
||||
if (
|
||||
!Array.isArray(value.names) ||
|
||||
!value.names.every((name) => typeof name === 'string') ||
|
||||
typeof value.namesKnown !== 'boolean' ||
|
||||
(value.providedType !== undefined && typeof value.providedType !== 'string')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
framework: 'spring',
|
||||
role: 'factory-method',
|
||||
annotation: SPRING_BEAN_ANNOTATION,
|
||||
names: value.names,
|
||||
...(value.providedType === undefined ? {} : { providedType: value.providedType }),
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSpringBeanFactoryDeclaration(
|
||||
relationship: Pick<GraphRelationship, 'type' | 'reason'>,
|
||||
): boolean {
|
||||
return (
|
||||
relationship.type === 'DECLARES' &&
|
||||
relationship.reason.startsWith(SPRING_BEAN_FACTORY_REASON_PREFIX)
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ParsedFile, ScopeId } from 'gitnexus-shared';
|
||||
import type { GraphNode, ParsedFile, ScopeId } from 'gitnexus-shared';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import type { DiInjectionMatch, DiProviderMatch } from '../../di-extractors/index.js';
|
||||
import {
|
||||
|
|
@ -8,10 +9,30 @@ import {
|
|||
SPRING_DI_PROVIDER_PROPERTY,
|
||||
} from '../../di-extractors/spring.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import {
|
||||
resolveCallerGraphId,
|
||||
resolveDefGraphId,
|
||||
} from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import { createSpringAnnotationNameResolver } from './bean-candidates.js';
|
||||
import { SPRING_BEAN_STEREOTYPES } from './bean-catalog.js';
|
||||
import {
|
||||
encodeSpringBeanFactoryReason,
|
||||
SPRING_BEAN_ANNOTATION,
|
||||
SPRING_BEAN_DECLARATION_ID_PREFIX,
|
||||
springBeanNames,
|
||||
type SpringBeanFactoryMethodFact,
|
||||
} from './bean-factories.js';
|
||||
import {
|
||||
normalizeSpringBeanType,
|
||||
parseSpringAnnotationArguments,
|
||||
parseStaticStringValues,
|
||||
} from './annotation-arguments.js';
|
||||
import {
|
||||
SPRING_RESOURCE_ANNOTATIONS,
|
||||
springResourceDefaultName,
|
||||
springResourceInjectionMatch,
|
||||
} from './resource-injection.js';
|
||||
|
||||
export interface SpringDiAnnotationFact {
|
||||
readonly name: string;
|
||||
|
|
@ -42,6 +63,7 @@ export interface SpringDiClassFact<
|
|||
readonly classScopeId: ScopeId;
|
||||
readonly classAnnotations: readonly Annotation[];
|
||||
readonly injectionSites: readonly SpringDiInjectionSiteFact<Annotation, SiteKind>[];
|
||||
readonly beanFactoryMethods?: readonly SpringBeanFactoryMethodFact<Annotation>[];
|
||||
}
|
||||
|
||||
const INJECTION_ANNOTATIONS = new Set([
|
||||
|
|
@ -63,6 +85,8 @@ const RESOLVABLE_DI_ANNOTATIONS = new Set([
|
|||
...INJECTION_ANNOTATIONS,
|
||||
...QUALIFIER_ANNOTATIONS,
|
||||
...PRIMARY_ANNOTATIONS,
|
||||
...SPRING_RESOURCE_ANNOTATIONS,
|
||||
SPRING_BEAN_ANNOTATION,
|
||||
]);
|
||||
|
||||
const CAPTURE_RELEVANT_ANNOTATIONS = new Set([
|
||||
|
|
@ -71,6 +95,7 @@ const CAPTURE_RELEVANT_ANNOTATIONS = new Set([
|
|||
'Qualifier',
|
||||
'Named',
|
||||
'Primary',
|
||||
'Resource',
|
||||
'Component',
|
||||
'Service',
|
||||
'Repository',
|
||||
|
|
@ -104,16 +129,12 @@ export function hasSpringStereotypeSyntax(annotations: readonly SpringDiAnnotati
|
|||
}
|
||||
|
||||
function staticStringArgument(annotationText: string): string | undefined {
|
||||
const args = annotationText.match(/\((.*)\)$/s)?.[1]?.trim();
|
||||
if (args === undefined) return undefined;
|
||||
const value = args.replace(/^value\s*=\s*/, '').trim();
|
||||
const literal = value.match(/^"((?:\\.|[^"\\])*)"$/s);
|
||||
if (literal === null) return undefined;
|
||||
try {
|
||||
return JSON.parse(`"${literal[1]}"`) as string;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const argumentsList = parseSpringAnnotationArguments(annotationText);
|
||||
if (argumentsList === null || argumentsList.length !== 1) return undefined;
|
||||
const argument = argumentsList[0];
|
||||
if (argument.name !== undefined && argument.name !== 'value') return undefined;
|
||||
const values = parseStaticStringValues(argument.value);
|
||||
return values !== null && values.length === 1 ? values[0] : undefined;
|
||||
}
|
||||
|
||||
function defaultBeanName(className: string): string {
|
||||
|
|
@ -146,6 +167,7 @@ export interface SpringDiMetadataAdapter<
|
|||
annotation: Annotation,
|
||||
site: SpringDiInjectionSiteFact<Annotation, SiteKind>,
|
||||
): boolean;
|
||||
isFactoryQualifierAnnotationApplicable?(annotation: Annotation): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -231,18 +253,174 @@ export function createSpringDiMetadataAttacher<
|
|||
classNode.properties[SPRING_DI_PROVIDER_PROPERTY] = provider;
|
||||
}
|
||||
|
||||
// Factory methods have no legacy member-collection fallback to suppress,
|
||||
// so they do not participate in semanticallyOwnedMemberNames below.
|
||||
for (const factory of fact.beanFactoryMethods ?? []) {
|
||||
const factoryScope = indexes.scopeTree.getScope(factory.callableScopeId);
|
||||
if (factoryScope === undefined) continue;
|
||||
const beanAnnotation = factory.annotations.find(
|
||||
(annotation) => resolveFact(annotation, factoryScope.parent) === SPRING_BEAN_ANNOTATION,
|
||||
);
|
||||
if (beanAnnotation === undefined) continue;
|
||||
const methodId = resolveCallerGraphId(factory.callableScopeId, indexes, nodeLookup, {
|
||||
startLine: factoryScope.range.startLine,
|
||||
startCol: factoryScope.range.startCol,
|
||||
});
|
||||
if (methodId === undefined) continue;
|
||||
const methodNode = graph.getNode(methodId);
|
||||
if (methodNode === undefined || methodNode.label !== 'Method') continue;
|
||||
|
||||
const names = springBeanNames(beanAnnotation.text, factory.methodName);
|
||||
const providedType =
|
||||
factory.returnType === undefined
|
||||
? undefined
|
||||
: normalizeSpringBeanType(factory.returnType);
|
||||
const declaration = {
|
||||
names: names.names,
|
||||
namesKnown: names.namesKnown,
|
||||
...(providedType === null || providedType === undefined ? {} : { providedType }),
|
||||
};
|
||||
const reason = encodeSpringBeanFactoryReason(declaration);
|
||||
const beanId = `${SPRING_BEAN_DECLARATION_ID_PREFIX}${methodNode.id}`;
|
||||
const beanNode: GraphNode = {
|
||||
id: beanId,
|
||||
label: 'CodeElement',
|
||||
properties: {
|
||||
name: names.names[0] ?? factory.methodName,
|
||||
filePath: methodNode.properties.filePath,
|
||||
startLine: methodNode.properties.startLine,
|
||||
endLine: methodNode.properties.endLine,
|
||||
language: methodNode.properties.language,
|
||||
description:
|
||||
`Spring @Bean factory declaration from ${factory.methodName}` +
|
||||
(providedType === null || providedType === undefined
|
||||
? ''
|
||||
: ` providing ${providedType}`),
|
||||
},
|
||||
};
|
||||
beanNode.properties[SPRING_DI_PROVIDER_PROPERTY] = {
|
||||
names: names.names,
|
||||
declaredByNodeId: methodNode.id,
|
||||
...(providedType === null || providedType === undefined
|
||||
? {}
|
||||
: { providedTypeName: providedType }),
|
||||
} satisfies DiProviderMatch;
|
||||
graph.addNode(beanNode);
|
||||
const fileId = generateId('File', parsed.filePath);
|
||||
if (graph.getNode(fileId) !== undefined) {
|
||||
graph.addRelationship({
|
||||
id: generateId('DEFINES', `${fileId}->${beanId}`),
|
||||
sourceId: fileId,
|
||||
targetId: beanId,
|
||||
type: 'DEFINES',
|
||||
confidence: 1,
|
||||
reason: 'spring-bean:declaration',
|
||||
});
|
||||
}
|
||||
graph.addRelationship({
|
||||
id: generateId('DECLARES', `${methodNode.id}->${beanId}`),
|
||||
sourceId: methodNode.id,
|
||||
targetId: beanId,
|
||||
type: 'DECLARES',
|
||||
confidence: names.namesKnown ? 1 : 0.8,
|
||||
reason,
|
||||
});
|
||||
|
||||
const factoryMatches: DiInjectionMatch[] = [];
|
||||
for (const dependency of factory.dependencies) {
|
||||
const parsedType = adapter.parseInjectionType(dependency.rawType);
|
||||
if (parsedType === null) continue;
|
||||
let qualifierAnnotation: Annotation | undefined;
|
||||
for (const annotation of dependency.annotations) {
|
||||
if (adapter.isFactoryQualifierAnnotationApplicable?.(annotation) === false) continue;
|
||||
const resolved = resolveFact(annotation, factoryScope.id);
|
||||
if (resolved !== undefined && QUALIFIER_ANNOTATIONS.has(resolved)) {
|
||||
qualifierAnnotation = annotation;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const qualifier =
|
||||
qualifierAnnotation === undefined
|
||||
? undefined
|
||||
: staticStringArgument(qualifierAnnotation.text);
|
||||
if (qualifierAnnotation !== undefined && qualifier === undefined) continue;
|
||||
factoryMatches.push({
|
||||
targetTypeName: parsedType.targetTypeName,
|
||||
cardinality: parsedType.cardinality,
|
||||
edgeSource: 'site',
|
||||
...(qualifier === undefined
|
||||
? {}
|
||||
: {
|
||||
namedSelection: {
|
||||
name: qualifier,
|
||||
reason: `qualifier "${qualifier}"`,
|
||||
},
|
||||
}),
|
||||
reason:
|
||||
`Spring DI: @Bean method ${factory.methodName} parameter ${dependency.name}: ` +
|
||||
parsedType.displayType,
|
||||
});
|
||||
}
|
||||
if (factoryMatches.length > 0) {
|
||||
const existing = methodNode.properties[SPRING_DI_INJECTION_SITES_PROPERTY];
|
||||
methodNode.properties[SPRING_DI_INJECTION_SITES_PROPERTY] = [
|
||||
...(Array.isArray(existing) ? existing : []),
|
||||
...factoryMatches,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const matches: DiInjectionMatch[] = [];
|
||||
const semanticallyOwnedMemberNames = new Set<string>();
|
||||
for (const site of fact.injectionSites) {
|
||||
let injectionAnnotation: Annotation | undefined;
|
||||
let resourceAnnotation: Annotation | undefined;
|
||||
for (const annotation of site.annotations) {
|
||||
if (adapter.isInjectionAnnotationApplicable?.(annotation, site) === false) continue;
|
||||
const resolved = resolveFact(annotation, classScope.id);
|
||||
if (resolved !== undefined && INJECTION_ANNOTATIONS.has(resolved)) {
|
||||
injectionAnnotation = annotation;
|
||||
break;
|
||||
} else if (resolved !== undefined && SPRING_RESOURCE_ANNOTATIONS.has(resolved)) {
|
||||
resourceAnnotation = annotation;
|
||||
}
|
||||
}
|
||||
if (injectionAnnotation !== undefined && resourceAnnotation !== undefined) {
|
||||
// The annotation pair is semantically ambiguous, so emit no edge.
|
||||
// It still owns a captured member: otherwise the legacy collection
|
||||
// matcher sees the same Property and fans out behind this fail-closed
|
||||
// decision.
|
||||
if (site.kind === adapter.capturedMemberKind) {
|
||||
semanticallyOwnedMemberNames.add(site.memberName);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resourceAnnotation !== undefined) {
|
||||
if (site.kind === adapter.capturedMemberKind) {
|
||||
semanticallyOwnedMemberNames.add(site.memberName);
|
||||
}
|
||||
if (site.dependencies.length !== 1 || site.kind === 'constructor') continue;
|
||||
const defaultName = springResourceDefaultName(
|
||||
site.kind,
|
||||
site.memberName,
|
||||
site.dependencies.length,
|
||||
);
|
||||
if (defaultName === null) continue;
|
||||
const dependency = site.dependencies[0];
|
||||
const location =
|
||||
site.kind === adapter.capturedMemberKind
|
||||
? site.memberName
|
||||
: `${site.memberName} parameter ${dependency.name}`;
|
||||
const resourceMatch = springResourceInjectionMatch(
|
||||
resourceAnnotation.text,
|
||||
defaultName,
|
||||
dependency.rawType,
|
||||
location,
|
||||
);
|
||||
if (resourceMatch !== null) matches.push(resourceMatch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (injectionAnnotation === undefined) {
|
||||
if (!site.implicitConstructor || frameworkAnnotations.length === 0) continue;
|
||||
} else if (site.kind === adapter.capturedMemberKind) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import type { DiInjectionMatch } from '../../di-extractors/index.js';
|
||||
import {
|
||||
normalizeSpringBeanType,
|
||||
parseSpringAnnotationArguments,
|
||||
parseStaticClassLiteral,
|
||||
parseStaticStringValues,
|
||||
} from './annotation-arguments.js';
|
||||
|
||||
export const SPRING_RESOURCE_ANNOTATIONS = new Set([
|
||||
'jakarta.annotation.Resource',
|
||||
'javax.annotation.Resource',
|
||||
]);
|
||||
|
||||
function singleNamedArgument(
|
||||
annotationText: string,
|
||||
name: string,
|
||||
): { readonly present: boolean; readonly value?: string } | null {
|
||||
const argumentsList = parseSpringAnnotationArguments(annotationText);
|
||||
if (argumentsList === null || argumentsList.some((argument) => argument.name === undefined)) {
|
||||
return null;
|
||||
}
|
||||
const matches = argumentsList.filter((argument) => argument.name === name);
|
||||
if (matches.length > 1) return null;
|
||||
return matches.length === 0 ? { present: false } : { present: true, value: matches[0].value };
|
||||
}
|
||||
|
||||
function staticSingleString(value: string | undefined): string | null {
|
||||
if (value === undefined) return null;
|
||||
const values = parseStaticStringValues(value);
|
||||
return values !== null && values.length === 1 ? values[0] : null;
|
||||
}
|
||||
|
||||
function javaBeansDecapitalize(value: string): string {
|
||||
if (value.length === 0) return value;
|
||||
if (
|
||||
value.length > 1 &&
|
||||
value[0] !== value[0].toLowerCase() &&
|
||||
value[1] !== value[1].toLowerCase()
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return value[0].toLowerCase() + value.slice(1);
|
||||
}
|
||||
|
||||
export function springResourceDefaultName(
|
||||
siteKind: string,
|
||||
memberName: string,
|
||||
dependencyCount: number,
|
||||
): string | null {
|
||||
if (siteKind !== 'method') return memberName;
|
||||
if (dependencyCount !== 1 || !/^set[A-Z_$]/.test(memberName) || memberName.length <= 3) {
|
||||
return null;
|
||||
}
|
||||
return javaBeansDecapitalize(memberName.slice(3));
|
||||
}
|
||||
|
||||
/** Build the conservative name-first Resource match shared by Java and Kotlin. */
|
||||
export function springResourceInjectionMatch(
|
||||
annotationText: string,
|
||||
defaultName: string,
|
||||
rawDeclaredType: string,
|
||||
location: string,
|
||||
): DiInjectionMatch | null {
|
||||
const nameArgument = singleNamedArgument(annotationText, 'name');
|
||||
const typeArgument = singleNamedArgument(annotationText, 'type');
|
||||
const lookupArgument = singleNamedArgument(annotationText, 'lookup');
|
||||
const mappedNameArgument = singleNamedArgument(annotationText, 'mappedName');
|
||||
if (
|
||||
nameArgument === null ||
|
||||
typeArgument === null ||
|
||||
lookupArgument === null ||
|
||||
mappedNameArgument === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const runtimeArgument of [lookupArgument, mappedNameArgument]) {
|
||||
if (!runtimeArgument.present) continue;
|
||||
const value = staticSingleString(runtimeArgument.value);
|
||||
if (value === null || value.length > 0) return null;
|
||||
}
|
||||
|
||||
const declaredType = normalizeSpringBeanType(rawDeclaredType);
|
||||
if (declaredType === null) return null;
|
||||
let targetTypeName = declaredType;
|
||||
if (typeArgument.present) {
|
||||
const override = parseStaticClassLiteral(typeArgument.value ?? '');
|
||||
if (override === null) return null;
|
||||
if (override.length > 0) targetTypeName = override;
|
||||
}
|
||||
|
||||
let selectedName = defaultName;
|
||||
let explicitName = false;
|
||||
if (nameArgument.present) {
|
||||
const parsedName = staticSingleString(nameArgument.value);
|
||||
if (parsedName === null) return null;
|
||||
if (parsedName.length > 0) {
|
||||
selectedName = parsedName;
|
||||
explicitName = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
targetTypeName,
|
||||
cardinality: 'single',
|
||||
namedSelection: {
|
||||
name: selectedName,
|
||||
reason: `${explicitName ? 'resource name' : 'default resource name'} "${selectedName}"`,
|
||||
...(!explicitName && !rawDeclaredType.includes('<') ? { fallbackToType: true } : {}),
|
||||
},
|
||||
reason: `Spring DI: @Resource ${location}: ${targetTypeName}`,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
61
gitnexus/src/core/ingestion/languages/java/spring-aop.ts
Normal file
61
gitnexus/src/core/ingestion/languages/java/spring-aop.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { makeScopeId } from 'gitnexus-shared';
|
||||
import {
|
||||
createSpringAopMetadataAttacher,
|
||||
hasSpringAopRelevantAnnotation,
|
||||
type SpringAopOwnerFact,
|
||||
} from '../../frameworks/spring/aop.js';
|
||||
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { getJavaSpringAopFacts } from './capture-side-channel.js';
|
||||
import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js';
|
||||
import { javaSpringAnnotationFacts, type JavaAnnotationSyntaxFact } from './spring-di.js';
|
||||
|
||||
export type JavaSpringAopAnnotationFact = JavaAnnotationSyntaxFact;
|
||||
|
||||
export type JavaSpringAopFact = SpringAopOwnerFact<JavaSpringAopAnnotationFact>;
|
||||
|
||||
function scopeId(filePath: string, node: SyntaxNode, kind: 'Class' | 'Function') {
|
||||
return makeScopeId({
|
||||
filePath,
|
||||
range: nodeToCapture('@spring-aop.owner', node).range,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture Spring AOP syntax while Java's existing class traversal already has
|
||||
* the AST node in hand. Import/FQN resolution and pointcut matching remain in
|
||||
* the shared post-resolution layer.
|
||||
*/
|
||||
export function captureJavaSpringAopFacts(
|
||||
classNode: SyntaxNode,
|
||||
filePath: string,
|
||||
): JavaSpringAopFact[] {
|
||||
const facts: JavaSpringAopFact[] = [];
|
||||
const classAnnotations = javaSpringAnnotationFacts(classNode);
|
||||
if (hasSpringAopRelevantAnnotation(classAnnotations)) {
|
||||
facts.push({
|
||||
ownerScopeId: scopeId(filePath, classNode, 'Class'),
|
||||
ownerKind: 'class',
|
||||
annotations: classAnnotations,
|
||||
});
|
||||
}
|
||||
|
||||
const body = classNode.childForFieldName('body');
|
||||
if (body === null) return facts;
|
||||
for (const member of body.namedChildren) {
|
||||
if (member.type !== 'method_declaration') continue;
|
||||
const annotations = javaSpringAnnotationFacts(member);
|
||||
if (!hasSpringAopRelevantAnnotation(annotations)) continue;
|
||||
facts.push({
|
||||
ownerScopeId: scopeId(filePath, member, 'Function'),
|
||||
ownerKind: 'callable',
|
||||
annotations,
|
||||
});
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
export const attachJavaSpringAopMetadata = createSpringAopMetadataAttacher({
|
||||
getFacts: getJavaSpringAopFacts,
|
||||
isPackageVisibilityIncomplete: isJavaPackageSiblingVisibilityIncomplete,
|
||||
});
|
||||
|
|
@ -8,6 +8,10 @@ import {
|
|||
type SpringDiDependencyFact,
|
||||
type SpringDiInjectionSiteFact,
|
||||
} from '../../frameworks/spring/di-metadata.js';
|
||||
import {
|
||||
hasSpringBeanFactorySyntax,
|
||||
type SpringBeanFactoryMethodFact,
|
||||
} from '../../frameworks/spring/bean-factories.js';
|
||||
import { parseSpringInjectionType } from '../../di-extractors/spring.js';
|
||||
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js';
|
||||
|
|
@ -30,6 +34,7 @@ export type JavaSpringDiClassFact = SpringDiClassFact<
|
|||
JavaAnnotationSyntaxFact,
|
||||
JavaSpringInjectionSiteKind
|
||||
>;
|
||||
type JavaSpringBeanFactoryMethodFact = SpringBeanFactoryMethodFact<JavaAnnotationSyntaxFact>;
|
||||
|
||||
export function javaSpringAnnotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] {
|
||||
const facts: JavaAnnotationSyntaxFact[] = [];
|
||||
|
|
@ -81,6 +86,7 @@ export function captureJavaSpringDiClassFact(
|
|||
if (body === null) return null;
|
||||
const classAnnotations = javaSpringAnnotationFacts(classNode);
|
||||
const injectionSites: JavaSpringInjectionSiteFact[] = [];
|
||||
const beanFactoryMethods: JavaSpringBeanFactoryMethodFact[] = [];
|
||||
|
||||
const constructors = body.namedChildren.filter(
|
||||
(child) => child.type === 'constructor_declaration',
|
||||
|
|
@ -127,10 +133,31 @@ export function captureJavaSpringDiClassFact(
|
|||
}
|
||||
} else if (member.type === 'method_declaration') {
|
||||
const annotations = javaSpringAnnotationFacts(member);
|
||||
const memberName = member.childForFieldName('name')?.text.trim() ?? '<method>';
|
||||
const beanFactory = hasSpringBeanFactorySyntax(annotations);
|
||||
if (beanFactory) {
|
||||
const callableCapture = nodeToCapture('@spring-bean.factory', member);
|
||||
const returnType = member.childForFieldName('type')?.text.trim();
|
||||
beanFactoryMethods.push({
|
||||
callableScopeId: makeScopeId({
|
||||
filePath,
|
||||
range: callableCapture.range,
|
||||
kind: 'Function',
|
||||
}),
|
||||
methodName: memberName,
|
||||
...(returnType === undefined ? {} : { returnType }),
|
||||
annotations,
|
||||
dependencies: dependenciesOf(member),
|
||||
});
|
||||
}
|
||||
// @Bean parameters are already represented on the factory Method. Do not
|
||||
// also attach them to the owning configuration Class when the method has
|
||||
// an otherwise relevant annotation such as @Autowired or @Qualifier.
|
||||
if (beanFactory) continue;
|
||||
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
injectionSites.push({
|
||||
kind: 'method',
|
||||
memberName: member.childForFieldName('name')?.text.trim() ?? '<method>',
|
||||
memberName,
|
||||
implicitConstructor: false,
|
||||
annotations,
|
||||
dependencies: dependenciesOf(member),
|
||||
|
|
@ -138,12 +165,19 @@ export function captureJavaSpringDiClassFact(
|
|||
}
|
||||
}
|
||||
|
||||
if (injectionSites.length === 0 && !hasSpringDiRelevantAnnotation(classAnnotations)) return null;
|
||||
if (
|
||||
injectionSites.length === 0 &&
|
||||
beanFactoryMethods.length === 0 &&
|
||||
!hasSpringDiRelevantAnnotation(classAnnotations)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const classCapture = nodeToCapture('@spring-di.class', classNode);
|
||||
return {
|
||||
classScopeId: makeScopeId({ filePath, range: classCapture.range, kind: 'Class' }),
|
||||
classAnnotations,
|
||||
injectionSites,
|
||||
...(beanFactoryMethods.length === 0 ? {} : { beanFactoryMethods }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,20 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
|
|||
import { isClassLike } from '../../scope-resolution/scope/walkers.js';
|
||||
import type { JvmPackageFact } from './package-facts.js';
|
||||
|
||||
/** Packages larger than this get no implicit sibling visibility at all — every
|
||||
* file in them is marked incomplete. `GITNEXUS_MAX_INJECTED_SIBLINGS` bounds
|
||||
* injection *within* a package and does not lift this skip. */
|
||||
const MAX_PACKAGE_FILES = 500;
|
||||
|
||||
const DEFAULT_MAX_INJECTED_SIBLINGS = 200;
|
||||
|
||||
function getMaxInjectedSiblings(): number {
|
||||
const raw = process.env.GITNEXUS_MAX_INJECTED_SIBLINGS;
|
||||
if (raw === undefined || raw === '') return DEFAULT_MAX_INJECTED_SIBLINGS;
|
||||
const parsed = Number(raw);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : DEFAULT_MAX_INJECTED_SIBLINGS;
|
||||
}
|
||||
|
||||
export interface JvmPackageSiblingOptions {
|
||||
readonly languageLabel: string;
|
||||
readonly getPackageFact: (filePath: string) => JvmPackageFact | undefined;
|
||||
|
|
@ -79,6 +91,8 @@ export function createJvmPackageSiblingVisibility(
|
|||
}
|
||||
|
||||
const augmentations = indexes.bindingAugmentations as Map<ScopeId, Map<string, BindingRef[]>>;
|
||||
const maxInjectedSiblings = getMaxInjectedSiblings();
|
||||
let truncatedFiles = 0;
|
||||
|
||||
for (const bucket of buckets.values()) {
|
||||
if (bucket.moduleScopes.length < 2) continue;
|
||||
|
|
@ -100,6 +114,21 @@ export function createJvmPackageSiblingVisibility(
|
|||
}
|
||||
}
|
||||
|
||||
// Per-bucket lookups: the per-file loop below is O(files²) over these,
|
||||
// so split each path into segments once here instead of re-splitting it
|
||||
// on every pairwise proximity comparison, and address siblings by path
|
||||
// so a truncated scope can merge its bounded set without rescanning the
|
||||
// whole package.
|
||||
const segmentsByPath = new Map<string, string[]>();
|
||||
const moduleScopeByPath = new Map<string, PackageBucket['moduleScopes'][number]>();
|
||||
const parsedByPath = new Map<string, ParsedFile>();
|
||||
for (const parsed of bucket.parsed) {
|
||||
segmentsByPath.set(parsed.filePath, pathSegments(parsed.filePath));
|
||||
parsedByPath.set(parsed.filePath, parsed);
|
||||
}
|
||||
for (const sibling of bucket.moduleScopes) moduleScopeByPath.set(sibling.filePath, sibling);
|
||||
const allSiblingPaths = [...parsedByPath.keys()];
|
||||
|
||||
for (const { filePath, scope } of bucket.moduleScopes) {
|
||||
let scopeAug = augmentations.get(scope.id);
|
||||
if (scopeAug === undefined) {
|
||||
|
|
@ -107,24 +136,37 @@ export function createJvmPackageSiblingVisibility(
|
|||
augmentations.set(scope.id, scopeAug);
|
||||
}
|
||||
|
||||
const ownSegments = segmentsByPath.get(filePath) ?? pathSegments(filePath);
|
||||
const proximityCache = new Map<string, number>();
|
||||
const candidates = classDefs.filter((candidate) => candidate.filePath !== filePath);
|
||||
for (const candidate of candidates) {
|
||||
if (!proximityCache.has(candidate.filePath)) {
|
||||
proximityCache.set(
|
||||
candidate.filePath,
|
||||
sharedSegmentCount(candidate.filePath, filePath),
|
||||
sharedSegmentCount(segmentsByPath.get(candidate.filePath) ?? [], ownSegments),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Nearest-first by shared path prefix. Ties (the common case in a flat
|
||||
// package, where every sibling shares the same directory) keep the
|
||||
// walker's traversal order, so the retained set is deterministic but
|
||||
// arbitrary among equally-near candidates — which is why truncation
|
||||
// marks the file incomplete below rather than being treated as exact.
|
||||
candidates.sort(
|
||||
(a, b) => (proximityCache.get(b.filePath) ?? 0) - (proximityCache.get(a.filePath) ?? 0),
|
||||
);
|
||||
|
||||
const injectedIds = new Set<string>();
|
||||
for (const { def } of candidates) {
|
||||
const injectedPaths = new Set<string>();
|
||||
let truncated = false;
|
||||
for (const { def, filePath: defPath } of candidates) {
|
||||
if (injectedIds.has(def.nodeId) || def.qualifiedName === undefined) continue;
|
||||
if (maxInjectedSiblings > 0 && injectedIds.size >= maxInjectedSiblings) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
injectedIds.add(def.nodeId);
|
||||
injectedPaths.add(defPath);
|
||||
const simpleName = def.qualifiedName.includes('.')
|
||||
? def.qualifiedName.slice(def.qualifiedName.lastIndexOf('.') + 1)
|
||||
: def.qualifiedName;
|
||||
|
|
@ -133,15 +175,37 @@ export function createJvmPackageSiblingVisibility(
|
|||
bindings.push({ def, origin: 'namespace' });
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
// Sibling visibility for this file is now partial, and downstream
|
||||
// Spring attribution treats a complete flag as "this package's names
|
||||
// are fully known". Marking it incomplete keeps wildcard attribution
|
||||
// conservative instead of silently resolving against a truncated set.
|
||||
incompleteFiles.add(filePath);
|
||||
truncatedFiles++;
|
||||
}
|
||||
|
||||
// Keep the two halves of "what this file can see" in agreement: when
|
||||
// the cap truncated the binding set, merge type bindings from the same
|
||||
// bounded sibling set only — and iterate that set directly, so the cap
|
||||
// bounds the merge work too instead of just filtering a full scan. An
|
||||
// untruncated scope merges every sibling as before, including ones that
|
||||
// contribute no class-like def.
|
||||
const typeBindings = scope.typeBindings as Map<string, TypeRef>;
|
||||
for (const sibling of bucket.moduleScopes) {
|
||||
if (sibling.filePath === filePath) continue;
|
||||
const mergePaths = truncated ? injectedPaths : allSiblingPaths;
|
||||
// Module scopes first, then class scopes: module-level bindings win on
|
||||
// a name collision, which the two-pass order (not file order) encodes.
|
||||
for (const siblingPath of mergePaths) {
|
||||
if (siblingPath === filePath) continue;
|
||||
const sibling = moduleScopeByPath.get(siblingPath);
|
||||
if (sibling === undefined) continue;
|
||||
for (const [name, ref] of sibling.scope.typeBindings) {
|
||||
if (!typeBindings.has(name)) typeBindings.set(name, ref);
|
||||
}
|
||||
}
|
||||
for (const sibling of bucket.parsed) {
|
||||
if (sibling.filePath === filePath) continue;
|
||||
for (const siblingPath of mergePaths) {
|
||||
if (siblingPath === filePath) continue;
|
||||
const sibling = parsedByPath.get(siblingPath);
|
||||
if (sibling === undefined) continue;
|
||||
for (const siblingScope of sibling.scopes) {
|
||||
if (siblingScope.kind !== 'Class') continue;
|
||||
for (const [name, ref] of siblingScope.typeBindings) {
|
||||
|
|
@ -151,6 +215,12 @@ export function createJvmPackageSiblingVisibility(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (truncatedFiles > 0) {
|
||||
logger.warn(
|
||||
`[${options.languageLabel}-package-siblings] sibling injection truncated at ${maxInjectedSiblings} per file for ${truncatedFiles} file(s); wildcard attribution disabled for those files (raise or unset GITNEXUS_MAX_INJECTED_SIBLINGS to widen, 0 for unbounded)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -159,9 +229,11 @@ export function createJvmPackageSiblingVisibility(
|
|||
};
|
||||
}
|
||||
|
||||
function sharedSegmentCount(a: string, b: string): number {
|
||||
const aSegments = a.replace(/\\/g, '/').split('/');
|
||||
const bSegments = b.replace(/\\/g, '/').split('/');
|
||||
function pathSegments(filePath: string): string[] {
|
||||
return filePath.replace(/\\/g, '/').split('/');
|
||||
}
|
||||
|
||||
function sharedSegmentCount(aSegments: readonly string[], bSegments: readonly string[]): number {
|
||||
let index = 0;
|
||||
while (
|
||||
index < aSegments.length &&
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
77
gitnexus/src/core/ingestion/languages/kotlin/spring-aop.ts
Normal file
77
gitnexus/src/core/ingestion/languages/kotlin/spring-aop.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { makeScopeId } from 'gitnexus-shared';
|
||||
import {
|
||||
createSpringAopMetadataAttacher,
|
||||
type SpringAopOwnerFact,
|
||||
} from '../../frameworks/spring/aop.js';
|
||||
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { getKotlinSpringAopFacts } from './capture-side-channel.js';
|
||||
import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js';
|
||||
import { kotlinSpringAnnotationFacts, type KotlinAnnotationSyntaxFact } from './spring-di.js';
|
||||
|
||||
export type KotlinSpringAopAnnotationFact = KotlinAnnotationSyntaxFact;
|
||||
|
||||
export type KotlinSpringAopFact = SpringAopOwnerFact<KotlinSpringAopAnnotationFact>;
|
||||
|
||||
function scopeId(filePath: string, node: SyntaxNode, kind: 'Class' | 'Function') {
|
||||
return makeScopeId({
|
||||
filePath,
|
||||
range: nodeToCapture('@spring-aop.owner', node).range,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
|
||||
function ownerRange(node: SyntaxNode) {
|
||||
return nodeToCapture('@spring-aop.owner', node).range;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture Spring AOP syntax from the class node already surfaced by Kotlin's
|
||||
* scope query. The shared layer resolves annotations and rejects non-default
|
||||
* use-site targets after imports and package visibility have finalized.
|
||||
*/
|
||||
export function captureKotlinSpringAopFacts(
|
||||
classNode: SyntaxNode,
|
||||
filePath: string,
|
||||
): KotlinSpringAopFact[] {
|
||||
const facts: KotlinSpringAopFact[] = [];
|
||||
const classAnnotations = kotlinSpringAnnotationFacts(classNode);
|
||||
const singletonInstance =
|
||||
classNode.type === 'object_declaration' || classNode.type === 'companion_object';
|
||||
// Kotlin import aliases can give a Spring annotation any local simple name.
|
||||
// Capture annotated owners conservatively, then let the post-import shared
|
||||
// resolver keep only recognized Spring AOP annotations. Objects also retain
|
||||
// an empty owner fact so the shared phase can distinguish their singleton
|
||||
// instance members from true static methods without naming Kotlin.
|
||||
if (classAnnotations.length > 0 || singletonInstance) {
|
||||
facts.push({
|
||||
ownerScopeId: scopeId(filePath, classNode, 'Class'),
|
||||
ownerKind: 'class',
|
||||
ownerFilePath: filePath,
|
||||
ownerRange: ownerRange(classNode),
|
||||
...(singletonInstance ? { singletonInstance: true } : {}),
|
||||
annotations: classAnnotations,
|
||||
});
|
||||
}
|
||||
|
||||
const body = classNode.namedChildren.find((child) => child.type === 'class_body');
|
||||
if (body === undefined) return facts;
|
||||
for (const member of body.namedChildren) {
|
||||
if (member.type !== 'function_declaration') continue;
|
||||
const annotations = kotlinSpringAnnotationFacts(member);
|
||||
if (annotations.length === 0) continue;
|
||||
facts.push({
|
||||
ownerScopeId: scopeId(filePath, member, 'Function'),
|
||||
ownerKind: 'callable',
|
||||
ownerFilePath: filePath,
|
||||
ownerRange: ownerRange(member),
|
||||
...(singletonInstance ? { singletonInstance: true } : {}),
|
||||
annotations,
|
||||
});
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
export const attachKotlinSpringAopMetadata = createSpringAopMetadataAttacher({
|
||||
getFacts: getKotlinSpringAopFacts,
|
||||
isPackageVisibilityIncomplete: isKotlinPackageSiblingVisibilityIncomplete,
|
||||
});
|
||||
|
|
@ -9,6 +9,10 @@ import {
|
|||
type SpringDiDependencyFact,
|
||||
type SpringDiInjectionSiteFact,
|
||||
} from '../../frameworks/spring/di-metadata.js';
|
||||
import {
|
||||
hasSpringBeanFactorySyntax,
|
||||
type SpringBeanFactoryMethodFact,
|
||||
} from '../../frameworks/spring/bean-factories.js';
|
||||
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { getKotlinSpringDiFacts } from './capture-side-channel.js';
|
||||
import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js';
|
||||
|
|
@ -31,6 +35,7 @@ export type KotlinSpringDiClassFact = SpringDiClassFact<
|
|||
KotlinAnnotationSyntaxFact,
|
||||
KotlinSpringInjectionSiteKind
|
||||
>;
|
||||
type KotlinSpringBeanFactoryMethodFact = SpringBeanFactoryMethodFact<KotlinAnnotationSyntaxFact>;
|
||||
|
||||
const KOTLIN_TYPE_NODES = new Set(['user_type', 'nullable_type', 'function_type']);
|
||||
|
||||
|
|
@ -86,6 +91,24 @@ function directTypeNode(node: SyntaxNode): SyntaxNode | undefined {
|
|||
return node.namedChildren.find((child) => KOTLIN_TYPE_NODES.has(child.type));
|
||||
}
|
||||
|
||||
function kotlinBeanFactoryReturnType(functionNode: SyntaxNode): string | undefined {
|
||||
const parametersIndex = functionNode.namedChildren.findIndex(
|
||||
(child) => child.type === 'function_value_parameters',
|
||||
);
|
||||
if (parametersIndex === -1) return undefined;
|
||||
const afterParameters = functionNode.namedChildren.slice(parametersIndex + 1);
|
||||
const explicit = afterParameters.find((child) => KOTLIN_TYPE_NODES.has(child.type));
|
||||
if (explicit !== undefined) return explicit.text.trim();
|
||||
|
||||
const body = afterParameters.find((child) => !KOTLIN_TYPE_NODES.has(child.type));
|
||||
if (body === undefined) return undefined;
|
||||
const call =
|
||||
body.type === 'call_expression' ? body : firstDescendantOfType(body, 'call_expression');
|
||||
const callee = call?.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
const inferred = callee?.text.trim();
|
||||
return inferred !== undefined && /^[A-Z_$]/.test(inferred) ? inferred : undefined;
|
||||
}
|
||||
|
||||
function parameterDependency(
|
||||
parameter: SyntaxNode,
|
||||
precedingAnnotations: readonly KotlinAnnotationSyntaxFact[] = [],
|
||||
|
|
@ -166,6 +189,7 @@ export function captureKotlinSpringDiClassFact(
|
|||
if (!isKotlinBeanCandidateClass(classNode)) return null;
|
||||
const classAnnotations = kotlinSpringAnnotationFacts(classNode);
|
||||
const injectionSites: KotlinSpringInjectionSiteFact[] = [];
|
||||
const beanFactoryMethods: KotlinSpringBeanFactoryMethodFact[] = [];
|
||||
const body = classNode.namedChildren.find((child) => child.type === 'class_body');
|
||||
const primaryConstructor = classNode.namedChildren.find(
|
||||
(child) => child.type === 'primary_constructor',
|
||||
|
|
@ -224,10 +248,33 @@ export function captureKotlinSpringDiClassFact(
|
|||
});
|
||||
} else if (member.type === 'function_declaration') {
|
||||
const annotations = kotlinSpringAnnotationFacts(member);
|
||||
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
const name =
|
||||
member.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim() ??
|
||||
'<method>';
|
||||
const factoryAnnotations = annotations.filter(
|
||||
(annotation) => annotation.useSiteTarget === undefined,
|
||||
);
|
||||
const beanFactory = hasSpringBeanFactorySyntax(factoryAnnotations);
|
||||
if (beanFactory) {
|
||||
const callableCapture = nodeToCapture('@spring-bean.factory', member);
|
||||
const returnType = kotlinBeanFactoryReturnType(member);
|
||||
beanFactoryMethods.push({
|
||||
callableScopeId: makeScopeId({
|
||||
filePath,
|
||||
range: callableCapture.range,
|
||||
kind: 'Function',
|
||||
}),
|
||||
methodName: name,
|
||||
...(returnType === undefined ? {} : { returnType }),
|
||||
annotations: factoryAnnotations,
|
||||
dependencies: functionDependencies(member),
|
||||
});
|
||||
}
|
||||
// @Bean parameters are already represented on the factory Method. Do not
|
||||
// also attach them to the owning configuration Class when the method has
|
||||
// an otherwise relevant annotation such as @Autowired or @Qualifier.
|
||||
if (beanFactory) continue;
|
||||
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
injectionSites.push({
|
||||
kind: 'method',
|
||||
memberName: name,
|
||||
|
|
@ -239,12 +286,19 @@ export function captureKotlinSpringDiClassFact(
|
|||
}
|
||||
}
|
||||
|
||||
if (injectionSites.length === 0 && !hasSpringDiRelevantAnnotation(classAnnotations)) return null;
|
||||
if (
|
||||
injectionSites.length === 0 &&
|
||||
beanFactoryMethods.length === 0 &&
|
||||
!hasSpringDiRelevantAnnotation(classAnnotations)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const classCapture = nodeToCapture('@spring-di.class', classNode);
|
||||
return {
|
||||
classScopeId: makeScopeId({ filePath, range: classCapture.range, kind: 'Class' }),
|
||||
classAnnotations,
|
||||
injectionSites,
|
||||
...(beanFactoryMethods.length === 0 ? {} : { beanFactoryMethods }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -275,6 +329,10 @@ function isApplicableQualifierAnnotation(
|
|||
return annotation.useSiteTarget === 'param';
|
||||
}
|
||||
|
||||
function isApplicableFactoryQualifierAnnotation(annotation: KotlinAnnotationSyntaxFact): boolean {
|
||||
return annotation.useSiteTarget === undefined || annotation.useSiteTarget === 'param';
|
||||
}
|
||||
|
||||
function parseKotlinSpringInjectionType(rawType: string) {
|
||||
// Kotlin nullable suffixes, type projections, and mutable collection aliases
|
||||
// do not change the JVM bean type selected by Spring. Normalize only those
|
||||
|
|
@ -298,4 +356,5 @@ export const attachKotlinSpringDiMetadata = createSpringDiMetadataAttacher<
|
|||
capturedMemberKind: 'property',
|
||||
isInjectionAnnotationApplicable: isApplicableInjectionAnnotation,
|
||||
isQualifierAnnotationApplicable: isApplicableQualifierAnnotation,
|
||||
isFactoryQualifierAnnotationApplicable: isApplicableFactoryQualifierAnnotation,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ interface PythonArityMetadata {
|
|||
readonly parameterCount: number | undefined;
|
||||
readonly requiredParameterCount: number | undefined;
|
||||
readonly parameterTypes: readonly string[] | undefined;
|
||||
readonly parameterNames: readonly string[];
|
||||
}
|
||||
|
||||
export function computePythonArityMetadata(fnNode: SyntaxNode): PythonArityMetadata {
|
||||
|
|
@ -50,5 +51,6 @@ export function computePythonArityMetadata(fnNode: SyntaxNode): PythonArityMetad
|
|||
parameterCount,
|
||||
requiredParameterCount,
|
||||
parameterTypes: types.length > 0 ? types : undefined,
|
||||
parameterNames: params.map((parameter) => parameter.name),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,6 +146,14 @@ export function emitPythonScopeCaptures(
|
|||
const scopeNode = nodeMap['@scope.function']!;
|
||||
const fnNode = scopeNode.type === 'function_definition' ? scopeNode : null;
|
||||
if (fnNode !== null) {
|
||||
const parameterNames = computePythonArityMetadata(fnNode).parameterNames;
|
||||
if (parameterNames.length > 0) {
|
||||
grouped['@scope.lexical-names'] = syntheticCapture(
|
||||
'@scope.lexical-names',
|
||||
fnNode,
|
||||
JSON.stringify(parameterNames),
|
||||
);
|
||||
}
|
||||
const synth = synthesizeReceiverTypeBinding(fnNode);
|
||||
if (synth !== null) out.push(synth);
|
||||
out.push(...synthesizeConstructorFieldTypeBindings(fnNode));
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
* `linkStatus: 'unresolved'`.
|
||||
*/
|
||||
|
||||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
import { resolvePythonImportInternal } from '../../import-resolvers/python.js';
|
||||
import { recordPythonFileIndexBuild } from './index-stats.js';
|
||||
|
||||
|
|
@ -20,6 +20,9 @@ export interface PythonResolveContext {
|
|||
* through to `getPythonFileIndex`'s `WeakMap` key (built once per run, not
|
||||
* copied per import). The whole resolver chain only reads the set. */
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
/** Optional parsed workspace used to preserve a package's explicit export
|
||||
* when it collides with a same-named concrete submodule. */
|
||||
readonly parsedFiles?: readonly ParsedFile[];
|
||||
}
|
||||
|
||||
export function resolvePythonImportTarget(
|
||||
|
|
@ -48,6 +51,40 @@ export function resolvePythonImportTarget(
|
|||
if (parsedImport.kind === 'dynamic-unresolved') return null;
|
||||
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
|
||||
|
||||
const submoduleTarget = pythonImportedSubmoduleTarget(parsedImport);
|
||||
if (
|
||||
submoduleTarget !== null &&
|
||||
(parsedImport.kind === 'named' || parsedImport.kind === 'alias')
|
||||
) {
|
||||
// Python's IMPORT_FROM first reads an attribute already exported by the
|
||||
// package and only loads a same-named submodule when that attribute is
|
||||
// absent. Preserve that precedence when parsed workspace facts are
|
||||
// available; the flag suppresses this submodule probe in the recursive
|
||||
// base-package lookup.
|
||||
const packageTarget = resolvePythonImportTarget(
|
||||
{ ...parsedImport, targetIncludesImportedName: true },
|
||||
workspaceIndex,
|
||||
);
|
||||
if (
|
||||
packageTarget !== null &&
|
||||
pythonFileExportsName(packageTarget, parsedImport.importedName, ctx.parsedFiles)
|
||||
) {
|
||||
return packageTarget;
|
||||
}
|
||||
|
||||
const submodule = resolvePythonImportTarget(
|
||||
{
|
||||
kind: 'namespace',
|
||||
localName: parsedImport.localName,
|
||||
importedName: parsedImport.importedName,
|
||||
targetRaw: submoduleTarget,
|
||||
},
|
||||
workspaceIndex,
|
||||
);
|
||||
if (submodule !== null) return submodule;
|
||||
if (packageTarget !== null) return packageTarget;
|
||||
}
|
||||
|
||||
// PEP-328 relative + single-segment proximity bare imports.
|
||||
const internal = resolvePythonImportInternal(
|
||||
ctx.fromFile,
|
||||
|
|
@ -85,6 +122,22 @@ export function resolvePythonImportTarget(
|
|||
return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths, ctx.fromFile);
|
||||
}
|
||||
|
||||
function pythonFileExportsName(
|
||||
targetFile: string,
|
||||
importedName: string,
|
||||
parsedFiles: readonly ParsedFile[] | undefined,
|
||||
): boolean {
|
||||
if (parsedFiles === undefined) return false;
|
||||
const parsed = parsedFiles.find((file) => file.filePath === targetFile);
|
||||
if (parsed === undefined) return false;
|
||||
return parsed.localDefs.some((def) => {
|
||||
const qualifiedName = def.qualifiedName;
|
||||
if (qualifiedName === undefined || qualifiedName.length === 0) return false;
|
||||
const dot = qualifiedName.lastIndexOf('.');
|
||||
return (dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1)) === importedName;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `package/sub/module` style paths (already dot-flattened) to a
|
||||
* concrete file in `allFilePaths`. Tries the exact path first, then walks
|
||||
|
|
@ -341,3 +394,48 @@ function getPythonFileIndex(allFilePaths: ReadonlySet<string>): PythonFileIndex
|
|||
PYTHON_FILE_INDEX_CACHE.set(allFilePaths, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
function pythonImportedSubmoduleTarget(parsedImport: ParsedImport): string | null {
|
||||
if (parsedImport.kind !== 'named' && parsedImport.kind !== 'alias') return null;
|
||||
if (parsedImport.targetIncludesImportedName === true) return null;
|
||||
const separator = parsedImport.targetRaw.endsWith('.') ? '' : '.';
|
||||
return parsedImport.targetRaw + separator + parsedImport.importedName;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named Python import is a namespace handle only when its resolved file is
|
||||
* the concrete submodule formed by appending the imported name. This keeps
|
||||
* ordinary symbol imports on the named-binding path.
|
||||
*/
|
||||
export function isPythonImportedModule(
|
||||
parsedImport: ParsedImport,
|
||||
targetFile: string,
|
||||
fromFile: string,
|
||||
): boolean {
|
||||
const submoduleTarget = pythonImportedSubmoduleTarget(parsedImport);
|
||||
if (submoduleTarget === null) return false;
|
||||
|
||||
const normalizedTarget = targetFile.replace(/\\/g, '/');
|
||||
let pathLike: string;
|
||||
|
||||
if (submoduleTarget.startsWith('.')) {
|
||||
const match = submoduleTarget.match(/^(\.+)(.*)$/);
|
||||
if (match === null) return false;
|
||||
const ascend = match[1].length - 1;
|
||||
const base = fromFile.replace(/\\/g, '/').split('/').slice(0, -1);
|
||||
if (ascend > base.length) return false;
|
||||
const relativeParts = match[2].split('.').filter(Boolean);
|
||||
pathLike = [...base.slice(0, base.length - ascend), ...relativeParts].join('/');
|
||||
} else {
|
||||
pathLike = submoduleTarget.replace(/\./g, '/');
|
||||
}
|
||||
|
||||
const moduleFile = pathLike + '.py';
|
||||
const packageFile = pathLike + '/__init__.py';
|
||||
return (
|
||||
normalizedTarget === moduleFile ||
|
||||
normalizedTarget === packageFile ||
|
||||
normalizedTarget.endsWith('/' + moduleFile) ||
|
||||
normalizedTarget.endsWith('/' + packageFile)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,11 @@ export { getPythonCaptureCacheStats, resetPythonCaptureCacheStats } from './cach
|
|||
export { interpretPythonImport, interpretPythonTypeBinding } from './interpret.js';
|
||||
export { pythonMergeBindings } from './merge-bindings.js';
|
||||
export { pythonArityCompatibility } from './arity.js';
|
||||
export { resolvePythonImportTarget, type PythonResolveContext } from './import-target.js';
|
||||
export {
|
||||
isPythonImportedModule,
|
||||
resolvePythonImportTarget,
|
||||
type PythonResolveContext,
|
||||
} from './import-target.js';
|
||||
export {
|
||||
pythonBindingScopeFor,
|
||||
pythonFunctionDefinitionLabel,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.
|
|||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { pythonProvider } from '../python.js';
|
||||
import {
|
||||
isPythonImportedModule,
|
||||
pythonArityCompatibility,
|
||||
pythonMergeBindings,
|
||||
resolvePythonImportTarget,
|
||||
|
|
@ -32,22 +33,29 @@ const pythonScopeResolver: ScopeResolver = {
|
|||
languageProvider: pythonProvider,
|
||||
importEdgeReason: 'python-scope: import',
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, _resolutionConfig, context) => {
|
||||
// Pass the orchestrator's stable run-level `ReadonlySet` straight through
|
||||
// (no per-import copy). The Python resolver chain only reads the set, and
|
||||
// `getPythonFileIndex` memoizes its index on the set's identity via a
|
||||
// WeakMap — so the index is built once per run and reused across every
|
||||
// import. Copying here (the previous `new Set(allFilePaths)`) handed a
|
||||
// fresh identity to every import, defeating that cache (PR #1918 review P1).
|
||||
const ws: PythonResolveContext = { fromFile, allFilePaths };
|
||||
const ws: PythonResolveContext = {
|
||||
fromFile,
|
||||
allFilePaths,
|
||||
parsedFiles: context?.parsedFiles,
|
||||
};
|
||||
// `WorkspaceIndex` is an opaque `unknown` placeholder in the
|
||||
// shared contract, so `ws` passes structurally without a cast.
|
||||
return resolvePythonImportTarget(
|
||||
{ kind: 'named', localName: '_', importedName: '_', targetRaw },
|
||||
context?.parsedImport ?? { kind: 'namespace', localName: '_', importedName: '_', targetRaw },
|
||||
ws,
|
||||
);
|
||||
},
|
||||
|
||||
isNamespaceImport: (parsedImport, targetFile, fromFile) =>
|
||||
isPythonImportedModule(parsedImport, targetFile, fromFile),
|
||||
|
||||
// Python LEGB precedence: local > import/namespace/reexport > wildcard.
|
||||
// The per-scope id is unused by pythonMergeBindings (tier ordering
|
||||
// is computed purely from BindingRef.origin), so we don't need to
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* Framework-neutral dependency-injection resolution. Per-language resolvers
|
||||
* identify injection sites and provider metadata; this phase performs only
|
||||
* graph-level type/heritage resolution and emits Class -> Class INJECTS edges.
|
||||
* graph-level type/heritage resolution and emits owner/site -> provider INJECTS edges.
|
||||
*
|
||||
* @deps mro
|
||||
* @reads graph (Class/Interface/member nodes and heritage/ownership edges)
|
||||
|
|
@ -50,6 +50,24 @@ interface PendingEdge {
|
|||
reason: string;
|
||||
}
|
||||
|
||||
type ProvidedTypesByLanguage = Map<string, Map<string, Set<string>>>;
|
||||
|
||||
function addProvidedType(
|
||||
index: ProvidedTypesByLanguage,
|
||||
language: string,
|
||||
typeName: string,
|
||||
providerId: string,
|
||||
): void {
|
||||
const byName = index.get(language) ?? new Map<string, Set<string>>();
|
||||
const names = new Set([typeName, typeName.slice(typeName.lastIndexOf('.') + 1)]);
|
||||
for (const name of names) {
|
||||
const providers = byName.get(name) ?? new Set<string>();
|
||||
providers.add(providerId);
|
||||
byName.set(name, providers);
|
||||
}
|
||||
index.set(language, byName);
|
||||
}
|
||||
|
||||
function emptyNameIndex(): NameIndex {
|
||||
return { byQualifiedName: new Map(), bySimpleName: new Map() };
|
||||
}
|
||||
|
|
@ -98,6 +116,10 @@ export const diPhase: PipelinePhase<DIOutput> = {
|
|||
|
||||
const candidates: CandidateSite[] = [];
|
||||
const providers = new Map<string, DiProviderMatch>();
|
||||
const providedTypes = new Map<string, Map<string, Set<string>>>();
|
||||
const providerNames = new Map<string, Map<string, Set<string>>>();
|
||||
const providerNodes = new Map<string, GraphNode>();
|
||||
const providersByDeclarer = new Map<string, Set<string>>();
|
||||
ctx.graph.forEachNode((node) => {
|
||||
const language = node.properties.language;
|
||||
if (language === undefined || !isSupportedLanguage(language)) return;
|
||||
|
|
@ -105,7 +127,25 @@ export const diPhase: PipelinePhase<DIOutput> = {
|
|||
if (resolver === undefined) return;
|
||||
|
||||
const provider = resolver.matchProvider(node);
|
||||
if (provider !== null) providers.set(node.id, provider);
|
||||
if (provider !== null) {
|
||||
providers.set(node.id, provider);
|
||||
providerNodes.set(node.id, node);
|
||||
const namesByLanguage = providerNames.get(language) ?? new Map<string, Set<string>>();
|
||||
for (const name of provider.names) {
|
||||
const namedProviders = namesByLanguage.get(name) ?? new Set<string>();
|
||||
namedProviders.add(node.id);
|
||||
namesByLanguage.set(name, namedProviders);
|
||||
}
|
||||
providerNames.set(language, namesByLanguage);
|
||||
if (provider.providedTypeName !== undefined) {
|
||||
addProvidedType(providedTypes, language, provider.providedTypeName, node.id);
|
||||
}
|
||||
if (provider.declaredByNodeId !== undefined) {
|
||||
const declared = providersByDeclarer.get(provider.declaredByNodeId) ?? new Set<string>();
|
||||
declared.add(node.id);
|
||||
providersByDeclarer.set(provider.declaredByNodeId, declared);
|
||||
}
|
||||
}
|
||||
for (const match of resolver.matchInjectionSites(node)) {
|
||||
candidates.push({ ...match, siteNodeId: node.id, language });
|
||||
}
|
||||
|
|
@ -121,10 +161,19 @@ export const diPhase: PipelinePhase<DIOutput> = {
|
|||
}
|
||||
|
||||
const interfaceToImplementers = new Map<string, Set<string>>();
|
||||
const directSupertypes = new Map<string, Set<string>>();
|
||||
for (const rel of ctx.graph.iterRelationshipsByType('IMPLEMENTS')) {
|
||||
const set = interfaceToImplementers.get(rel.targetId) ?? new Set<string>();
|
||||
set.add(rel.sourceId);
|
||||
interfaceToImplementers.set(rel.targetId, set);
|
||||
const supertypes = directSupertypes.get(rel.sourceId) ?? new Set<string>();
|
||||
supertypes.add(rel.targetId);
|
||||
directSupertypes.set(rel.sourceId, supertypes);
|
||||
}
|
||||
for (const rel of ctx.graph.iterRelationshipsByType('EXTENDS')) {
|
||||
const supertypes = directSupertypes.get(rel.sourceId) ?? new Set<string>();
|
||||
supertypes.add(rel.targetId);
|
||||
directSupertypes.set(rel.sourceId, supertypes);
|
||||
}
|
||||
|
||||
const memberToClass = new Map<string, string>();
|
||||
|
|
@ -137,7 +186,6 @@ export const diPhase: PipelinePhase<DIOutput> = {
|
|||
const candidateLanguages = new Set<string>(candidates.map((candidate) => candidate.language));
|
||||
const interfacesByLanguage = new Map<string, NameIndex>();
|
||||
const classesByLanguage = new Map<string, NameIndex>();
|
||||
const classNodes = new Map<string, GraphNode>();
|
||||
ctx.graph.forEachNode((node) => {
|
||||
if (node.label !== 'Class' && node.label !== 'Interface') return;
|
||||
const language = node.properties.language;
|
||||
|
|
@ -146,9 +194,68 @@ export const diPhase: PipelinePhase<DIOutput> = {
|
|||
const index = indexes.get(language) ?? emptyNameIndex();
|
||||
addIndexedName(index, node);
|
||||
indexes.set(language, index);
|
||||
if (node.label === 'Class') classNodes.set(node.id, node);
|
||||
if (node.label === 'Class') providerNodes.set(node.id, node);
|
||||
});
|
||||
|
||||
// A declaration returning a concrete class is assignable to every class or
|
||||
// interface that type extends/implements. Expand once per language+type and
|
||||
// register the declaration under those ancestor names. This keeps named
|
||||
// selection fast (set intersection below) while allowing, for example, a
|
||||
// `DefaultGateway` Bean to satisfy a named `Gateway` Resource site.
|
||||
const assignableNamesByProvidedType = new Map<string, readonly string[]>();
|
||||
for (const [providerId, provider] of providers) {
|
||||
if (provider.providedTypeName === undefined) continue;
|
||||
const providerNode = providerNodes.get(providerId);
|
||||
const language = providerNode?.properties.language;
|
||||
if (typeof language !== 'string') continue;
|
||||
const cacheKey = `${language}\0${provider.providedTypeName}`;
|
||||
let assignableTypeNames = assignableNamesByProvidedType.get(cacheKey);
|
||||
if (assignableTypeNames === undefined) {
|
||||
const classEntry = resolveIndexedName(
|
||||
classesByLanguage.get(language),
|
||||
provider.providedTypeName,
|
||||
);
|
||||
const interfaceEntry = resolveIndexedName(
|
||||
interfacesByLanguage.get(language),
|
||||
provider.providedTypeName,
|
||||
);
|
||||
const rootTypeId =
|
||||
typeof classEntry === 'string' && interfaceEntry === undefined
|
||||
? classEntry
|
||||
: typeof interfaceEntry === 'string' && classEntry === undefined
|
||||
? interfaceEntry
|
||||
: undefined;
|
||||
const names = new Set<string>();
|
||||
if (rootTypeId !== undefined) {
|
||||
const queue = [rootTypeId];
|
||||
const visited = new Set<string>();
|
||||
while (queue.length > 0) {
|
||||
const typeId = queue.pop();
|
||||
if (typeId === undefined) continue;
|
||||
if (visited.has(typeId)) continue;
|
||||
visited.add(typeId);
|
||||
const typeNode = ctx.graph.getNode(typeId);
|
||||
if (
|
||||
(typeNode?.label === 'Class' || typeNode?.label === 'Interface') &&
|
||||
typeNode.properties.language === language
|
||||
) {
|
||||
names.add(typeNode.properties.name);
|
||||
const qualifiedName = typeNode.properties.qualifiedName;
|
||||
if (typeof qualifiedName === 'string') names.add(qualifiedName);
|
||||
}
|
||||
for (const supertypeId of directSupertypes.get(typeId) ?? []) {
|
||||
queue.push(supertypeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
assignableTypeNames = [...names];
|
||||
assignableNamesByProvidedType.set(cacheKey, assignableTypeNames);
|
||||
}
|
||||
for (const typeName of assignableTypeNames) {
|
||||
addProvidedType(providedTypes, language, typeName, providerId);
|
||||
}
|
||||
}
|
||||
|
||||
let ambiguousSkipped = 0;
|
||||
let ambiguousInjections = 0;
|
||||
const ambiguousTypeNames = new Set<string>();
|
||||
|
|
@ -166,6 +273,8 @@ export const diPhase: PipelinePhase<DIOutput> = {
|
|||
const consumerClassId =
|
||||
siteNode?.label === 'Class' ? siteNode.id : memberToClass.get(candidate.siteNodeId);
|
||||
if (consumerClassId === undefined) continue;
|
||||
const edgeSourceId =
|
||||
candidate.edgeSource === 'site' && siteNode !== undefined ? siteNode.id : consumerClassId;
|
||||
|
||||
const classEntry = resolveIndexedName(
|
||||
classesByLanguage.get(candidate.language),
|
||||
|
|
@ -196,39 +305,55 @@ export const diPhase: PipelinePhase<DIOutput> = {
|
|||
if (typeof interfaceEntry === 'string') {
|
||||
for (const id of interfaceToImplementers.get(interfaceEntry) ?? []) structural.add(id);
|
||||
}
|
||||
for (const id of providedTypes.get(candidate.language)?.get(candidate.targetTypeName) ?? []) {
|
||||
structural.add(id);
|
||||
}
|
||||
for (const id of providersByDeclarer.get(edgeSourceId) ?? []) structural.delete(id);
|
||||
structural.delete(consumerClassId);
|
||||
structural.delete(edgeSourceId);
|
||||
if (structural.size === 0) continue;
|
||||
|
||||
let viable = providerCandidates(structural, providers);
|
||||
const namedSelection = candidate.namedSelection;
|
||||
let usedNamedSelection = false;
|
||||
let selectionSuffix = '';
|
||||
let viable: string[];
|
||||
if (namedSelection !== undefined) {
|
||||
viable = viable.filter(
|
||||
(id) => providers.get(id)?.names.includes(namedSelection.name) === true,
|
||||
);
|
||||
if (viable.length === 0) continue;
|
||||
const named = [
|
||||
...(providerNames.get(candidate.language)?.get(namedSelection.name) ?? []),
|
||||
].filter((id) => structural.has(id));
|
||||
if (named.length > 0) {
|
||||
viable = named;
|
||||
usedNamedSelection = true;
|
||||
selectionSuffix = `; ${namedSelection.reason}`;
|
||||
} else if (namedSelection.fallbackToType === true) {
|
||||
viable = providerCandidates(structural, providers);
|
||||
selectionSuffix = `; ${namedSelection.reason} unmatched; type fallback`;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
viable = providerCandidates(structural, providers);
|
||||
}
|
||||
|
||||
if (candidate.cardinality === 'collection') {
|
||||
const confidence = namedSelection === undefined ? 0.8 : 0.9;
|
||||
const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`;
|
||||
const confidence = usedNamedSelection ? 0.9 : 0.8;
|
||||
for (const targetId of viable) {
|
||||
queueEdge({
|
||||
sourceId: consumerClassId,
|
||||
sourceId: edgeSourceId,
|
||||
targetId,
|
||||
confidence,
|
||||
reason: candidate.reason + suffix,
|
||||
reason: candidate.reason + selectionSuffix,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (viable.length === 1) {
|
||||
const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`;
|
||||
queueEdge({
|
||||
sourceId: consumerClassId,
|
||||
sourceId: edgeSourceId,
|
||||
targetId: viable[0],
|
||||
confidence: namedSelection === undefined ? 0.9 : 0.95,
|
||||
reason: candidate.reason + suffix,
|
||||
confidence: usedNamedSelection ? 0.95 : namedSelection === undefined ? 0.9 : 0.85,
|
||||
reason: candidate.reason + selectionSuffix,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -237,28 +362,28 @@ export const diPhase: PipelinePhase<DIOutput> = {
|
|||
const reason = providers.get(id)?.preferenceReason;
|
||||
return reason === undefined ? [] : [{ id, reason }];
|
||||
});
|
||||
if (namedSelection === undefined && preferred.length === 1) {
|
||||
if (!usedNamedSelection && preferred.length === 1) {
|
||||
const selected = preferred[0];
|
||||
queueEdge({
|
||||
sourceId: consumerClassId,
|
||||
sourceId: edgeSourceId,
|
||||
targetId: selected.id,
|
||||
confidence: 0.95,
|
||||
reason: `${candidate.reason}; ${selected.reason}`,
|
||||
reason: `${candidate.reason}${selectionSuffix}; ${selected.reason}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
ambiguousInjections++;
|
||||
const candidateNames = viable
|
||||
.map((id) => classNodes.get(id)?.properties.name ?? id)
|
||||
.map((id) => providerNodes.get(id)?.properties.name ?? id)
|
||||
.sort()
|
||||
.join(', ');
|
||||
for (const targetId of viable) {
|
||||
queueEdge({
|
||||
sourceId: consumerClassId,
|
||||
sourceId: edgeSourceId,
|
||||
targetId,
|
||||
confidence: 0.5,
|
||||
reason: `${candidate.reason}; ambiguous candidates: ${candidateNames}`,
|
||||
reason: `${candidate.reason}${selectionSuffix}; ambiguous candidates: ${candidateNames}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
618
gitnexus/src/core/ingestion/pipeline-phases/spring-aop.ts
Normal file
618
gitnexus/src/core/ingestion/pipeline-phases/spring-aop.ts
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
/**
|
||||
* Phase: springAop
|
||||
*
|
||||
* Materializes statically visible Spring proxy/advice behavior after every
|
||||
* language resolver has attached normalized metadata to the shared graph.
|
||||
* The post-MRO export in this file propagates declarative behavior through
|
||||
* METHOD_OVERRIDES/METHOD_IMPLEMENTS without changing @annotation semantics.
|
||||
*
|
||||
* @deps scopeResolution
|
||||
* @reads Class/Method nodes, HAS_METHOD edges, shared Spring AOP metadata
|
||||
* @writes synthetic CodeElement nodes, DEFINES/DECLARES/ADVISED_BY edges
|
||||
*/
|
||||
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import { generateId } from '../../../lib/utils.js';
|
||||
import {
|
||||
decodeSpringAopReason,
|
||||
encodeSpringAopReason,
|
||||
getSpringAopGraphMetadata,
|
||||
parseSpringAopPointcut,
|
||||
SPRING_AOP_EVIDENCE_DESCRIPTION_PREFIX,
|
||||
springAopPointcutMatches,
|
||||
type SpringAopAdviceRecord,
|
||||
type SpringAopAspectRecord,
|
||||
type SpringAopBehaviorRecord,
|
||||
type SpringAopPointcutReason,
|
||||
} from '../frameworks/spring/aop.js';
|
||||
import {
|
||||
createSpringAopCandidateIndex,
|
||||
type SpringAopCandidateIndex,
|
||||
type SpringAopOwnedMethod,
|
||||
} from '../frameworks/spring/aop-candidates.js';
|
||||
import { toZeroBasedLine } from '../utils/line-base.js';
|
||||
import type { PipelineContext, PipelinePhase } from './types.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
export const DEFAULT_SPRING_AOP_MAX_CANDIDATE_INSPECTIONS_PER_ADVICE = 100_000;
|
||||
export const DEFAULT_SPRING_AOP_MAX_CANDIDATE_INSPECTIONS = 2_000_000;
|
||||
export const DEFAULT_SPRING_AOP_MAX_ADVISED_EDGES_PER_ADVICE = 25_000;
|
||||
export const DEFAULT_SPRING_AOP_MAX_ADVISED_EDGES = 100_000;
|
||||
|
||||
export interface SpringAopOutput {
|
||||
readonly advisedByEdges: number;
|
||||
readonly evidenceNodes: number;
|
||||
readonly unresolvedPointcuts: number;
|
||||
readonly candidateInspections: number;
|
||||
readonly truncatedAdvices: number;
|
||||
}
|
||||
|
||||
export interface SpringAopInheritanceOutput {
|
||||
readonly inheritedBehaviorEdges: number;
|
||||
}
|
||||
|
||||
function simpleName(name: string): string {
|
||||
const separator = name.lastIndexOf('.');
|
||||
return separator === -1 ? name : name.slice(separator + 1);
|
||||
}
|
||||
|
||||
function eligibleMethod(
|
||||
node: GraphNode,
|
||||
owner: GraphNode | undefined,
|
||||
singletonInstanceClassIds: ReadonlySet<string>,
|
||||
): boolean {
|
||||
return (
|
||||
node.label === 'Method' &&
|
||||
(node.properties.isStatic !== true ||
|
||||
(owner !== undefined && singletonInstanceClassIds.has(owner.id))) &&
|
||||
node.properties.visibility !== 'private'
|
||||
);
|
||||
}
|
||||
|
||||
function eligibleOwner(node: GraphNode): boolean {
|
||||
return node.label === 'Class' || node.label === 'Interface';
|
||||
}
|
||||
|
||||
function addEvidenceNode(
|
||||
ctx: PipelineContext,
|
||||
owner: GraphNode,
|
||||
annotation: string,
|
||||
line: number,
|
||||
discriminator: string,
|
||||
description: string,
|
||||
): GraphNode {
|
||||
const evidenceId = generateId(
|
||||
'CodeElement',
|
||||
`spring-aop:${owner.id}:${line}:${annotation}:${discriminator}`,
|
||||
);
|
||||
const evidence: GraphNode = {
|
||||
id: evidenceId,
|
||||
label: 'CodeElement',
|
||||
properties: {
|
||||
name: `@${simpleName(annotation)}`,
|
||||
filePath: owner.properties.filePath,
|
||||
startLine: toZeroBasedLine(line),
|
||||
endLine: toZeroBasedLine(line),
|
||||
isExported: false,
|
||||
description: `${SPRING_AOP_EVIDENCE_DESCRIPTION_PREFIX}${description}`,
|
||||
},
|
||||
};
|
||||
ctx.graph.addNode(evidence);
|
||||
|
||||
const fileId = generateId('File', owner.properties.filePath);
|
||||
if (ctx.graph.getNode(fileId) !== undefined) {
|
||||
ctx.graph.addRelationship({
|
||||
id: generateId('DEFINES', `${fileId}->${evidenceId}`),
|
||||
sourceId: fileId,
|
||||
targetId: evidenceId,
|
||||
type: 'DEFINES',
|
||||
confidence: 1,
|
||||
reason: 'spring-aop:evidence',
|
||||
});
|
||||
}
|
||||
return evidence;
|
||||
}
|
||||
|
||||
function emitBehavior(
|
||||
ctx: PipelineContext,
|
||||
record: SpringAopBehaviorRecord,
|
||||
classMethods: ReadonlyMap<string, readonly GraphNode[]>,
|
||||
ownerByMethod: ReadonlyMap<string, GraphNode>,
|
||||
singletonInstanceClassIds: ReadonlySet<string>,
|
||||
): { edges: number; evidence: number } {
|
||||
const owner = ctx.graph.getNode(record.ownerId);
|
||||
if (owner === undefined || (!eligibleOwner(owner) && owner.label !== 'Method')) {
|
||||
return { edges: 0, evidence: 0 };
|
||||
}
|
||||
if (
|
||||
owner.label === 'Method' &&
|
||||
!eligibleMethod(owner, ownerByMethod.get(owner.id), singletonInstanceClassIds)
|
||||
) {
|
||||
return { edges: 0, evidence: 0 };
|
||||
}
|
||||
const evidence = addEvidenceNode(
|
||||
ctx,
|
||||
owner,
|
||||
record.annotation,
|
||||
record.line,
|
||||
`behavior:${record.behavior}`,
|
||||
`${record.behavior} interceptor; activation unknown; proxy possible`,
|
||||
);
|
||||
const reason = encodeSpringAopReason({
|
||||
kind: 'behavior',
|
||||
annotation: record.annotation,
|
||||
behavior: record.behavior,
|
||||
declaredOn: record.ownerKind === 'class' ? 'class' : 'method',
|
||||
activation: 'unknown',
|
||||
proxy: 'possible',
|
||||
});
|
||||
|
||||
const sources =
|
||||
record.ownerKind === 'class' ? [owner, ...(classMethods.get(owner.id) ?? [])] : [owner];
|
||||
let edges = 0;
|
||||
for (const source of sources) {
|
||||
if (
|
||||
!eligibleOwner(source) &&
|
||||
!eligibleMethod(source, ownerByMethod.get(source.id), singletonInstanceClassIds)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
ctx.graph.addRelationship({
|
||||
id: generateId(
|
||||
'ADVISED_BY',
|
||||
`${source.id}->${evidence.id}:${record.line}:${record.behavior}`,
|
||||
),
|
||||
sourceId: source.id,
|
||||
targetId: evidence.id,
|
||||
type: 'ADVISED_BY',
|
||||
confidence: 1,
|
||||
reason,
|
||||
});
|
||||
edges++;
|
||||
}
|
||||
return { edges, evidence: 1 };
|
||||
}
|
||||
|
||||
function emitAspect(ctx: PipelineContext, record: SpringAopAspectRecord): number {
|
||||
const owner = ctx.graph.getNode(record.ownerId);
|
||||
if (owner === undefined || !eligibleOwner(owner)) return 0;
|
||||
const evidence = addEvidenceNode(
|
||||
ctx,
|
||||
owner,
|
||||
record.annotation,
|
||||
record.line,
|
||||
'aspect',
|
||||
'AspectJ aspect marker; registration unknown; activation unknown',
|
||||
);
|
||||
ctx.graph.addRelationship({
|
||||
id: generateId('DECLARES', `${owner.id}->${evidence.id}`),
|
||||
sourceId: owner.id,
|
||||
targetId: evidence.id,
|
||||
type: 'DECLARES',
|
||||
confidence: 1,
|
||||
reason: encodeSpringAopReason({
|
||||
kind: 'aspect',
|
||||
annotation: record.annotation,
|
||||
activation: 'unknown',
|
||||
registration: 'unknown',
|
||||
}),
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
|
||||
function pointcutReason(record: SpringAopAdviceRecord, resolved: boolean): SpringAopPointcutReason {
|
||||
return {
|
||||
kind: 'pointcut',
|
||||
annotation: record.annotation,
|
||||
pointcut: record.pointcut,
|
||||
match: resolved ? 'static' : 'unresolved',
|
||||
resolution: resolved ? 'resolved' : 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
interface SpringAopAdviceBudget {
|
||||
readonly maxInspectionsPerAdvice: number;
|
||||
readonly maxInspections: number;
|
||||
readonly maxEdgesPerAdvice: number;
|
||||
readonly maxEdges: number;
|
||||
inspections: number;
|
||||
edges: number;
|
||||
}
|
||||
|
||||
function resolveBudget(value: number | undefined, fallback: number): number {
|
||||
return Number.isSafeInteger(value) && value !== undefined && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function budgetReached(limit: number, used: number): boolean {
|
||||
return limit !== 0 && used >= limit;
|
||||
}
|
||||
|
||||
function emitAdvice(
|
||||
ctx: PipelineContext,
|
||||
record: SpringAopAdviceRecord,
|
||||
aspectClassIds: ReadonlySet<string>,
|
||||
ownerByMethod: ReadonlyMap<string, GraphNode>,
|
||||
candidateIndex: SpringAopCandidateIndex,
|
||||
methodAnnotations: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
budget: SpringAopAdviceBudget,
|
||||
): {
|
||||
edges: number;
|
||||
evidence: number;
|
||||
unresolved: number;
|
||||
inspections: number;
|
||||
truncated: boolean;
|
||||
} {
|
||||
const adviceNode = ctx.graph.getNode(record.ownerId);
|
||||
if (adviceNode === undefined || adviceNode.label !== 'Method') {
|
||||
return { edges: 0, evidence: 0, unresolved: 0, inspections: 0, truncated: false };
|
||||
}
|
||||
const staticPointcut = record.pointcut;
|
||||
const parsed = staticPointcut === null ? null : parseSpringAopPointcut(staticPointcut);
|
||||
const isAdviceMethod = record.advice !== 'pointcut';
|
||||
const adviceOwner = ownerByMethod.get(adviceNode.id);
|
||||
const activeAspect = adviceOwner !== undefined && aspectClassIds.has(adviceOwner.id);
|
||||
const resolved = parsed !== null && (!isAdviceMethod || activeAspect);
|
||||
const evidence = addEvidenceNode(
|
||||
ctx,
|
||||
adviceNode,
|
||||
record.annotation,
|
||||
record.line,
|
||||
`pointcut:${record.advice}:${record.pointcut ?? '<dynamic>'}`,
|
||||
`${record.advice} pointcut ${record.pointcut ?? '<non-static>'}; resolution ${
|
||||
resolved ? 'resolved' : 'unknown'
|
||||
}`,
|
||||
);
|
||||
ctx.graph.addRelationship({
|
||||
id: generateId('DECLARES', `${adviceNode.id}->${evidence.id}`),
|
||||
sourceId: adviceNode.id,
|
||||
targetId: evidence.id,
|
||||
type: 'DECLARES',
|
||||
confidence: 1,
|
||||
reason: encodeSpringAopReason(pointcutReason(record, resolved)),
|
||||
});
|
||||
|
||||
if (!isAdviceMethod || !resolved || parsed === null || staticPointcut === null) {
|
||||
return {
|
||||
edges: 0,
|
||||
evidence: 1,
|
||||
unresolved: resolved ? 0 : 1,
|
||||
inspections: 0,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
let edges = 0;
|
||||
let inspections = 0;
|
||||
let truncated = false;
|
||||
for (const candidate of candidateIndex.candidatesFor(parsed)) {
|
||||
if (
|
||||
budgetReached(budget.maxInspectionsPerAdvice, inspections) ||
|
||||
budgetReached(budget.maxInspections, budget.inspections)
|
||||
) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
inspections += 1;
|
||||
budget.inspections += 1;
|
||||
if (candidate.method.id === adviceNode.id) continue;
|
||||
if (aspectClassIds.has(candidate.owner.id)) continue;
|
||||
if (
|
||||
!springAopPointcutMatches(
|
||||
parsed,
|
||||
candidate.owner,
|
||||
candidate.method,
|
||||
methodAnnotations.get(candidate.method.id),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
budgetReached(budget.maxEdgesPerAdvice, edges) ||
|
||||
budgetReached(budget.maxEdges, budget.edges)
|
||||
) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
ctx.graph.addRelationship({
|
||||
id: generateId(
|
||||
'ADVISED_BY',
|
||||
`${candidate.method.id}->${adviceNode.id}:${record.line}:${record.advice}`,
|
||||
),
|
||||
sourceId: candidate.method.id,
|
||||
targetId: adviceNode.id,
|
||||
type: 'ADVISED_BY',
|
||||
confidence: 0.95,
|
||||
reason: encodeSpringAopReason({
|
||||
kind: 'advice',
|
||||
annotation: record.annotation,
|
||||
advice: record.advice,
|
||||
pointcut: staticPointcut,
|
||||
match: 'static',
|
||||
activation: 'unknown',
|
||||
proxy: 'possible',
|
||||
}),
|
||||
});
|
||||
edges++;
|
||||
budget.edges += 1;
|
||||
}
|
||||
return { edges, evidence: 1, unresolved: 0, inspections, truncated };
|
||||
}
|
||||
|
||||
export const springAopPhase: PipelinePhase<SpringAopOutput> = {
|
||||
name: 'springAop',
|
||||
deps: ['scopeResolution'],
|
||||
|
||||
async execute(ctx: PipelineContext): Promise<SpringAopOutput> {
|
||||
const metadata = getSpringAopGraphMetadata(ctx.graph);
|
||||
if (
|
||||
metadata.aspects.length === 0 &&
|
||||
metadata.behaviors.length === 0 &&
|
||||
metadata.advices.length === 0
|
||||
) {
|
||||
return {
|
||||
advisedByEdges: 0,
|
||||
evidenceNodes: 0,
|
||||
unresolvedPointcuts: 0,
|
||||
candidateInspections: 0,
|
||||
truncatedAdvices: 0,
|
||||
};
|
||||
}
|
||||
|
||||
ctx.onProgress({
|
||||
phase: 'enriching',
|
||||
percent: 97,
|
||||
message: 'Resolving Spring proxy and advice edges...',
|
||||
stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: ctx.graph.nodeCount },
|
||||
});
|
||||
|
||||
const classMethods = new Map<string, GraphNode[]>();
|
||||
const ownerByMethod = new Map<string, GraphNode>();
|
||||
const methodAnnotations = new Map<string, Set<string>>();
|
||||
const candidates: SpringAopOwnedMethod[] = [];
|
||||
for (const relationship of ctx.graph.iterRelationshipsByType('HAS_METHOD')) {
|
||||
const owner = ctx.graph.getNode(relationship.sourceId);
|
||||
const method = ctx.graph.getNode(relationship.targetId);
|
||||
if (owner === undefined || !eligibleOwner(owner) || method?.label !== 'Method') continue;
|
||||
const ownerFilePath = owner.properties.filePath;
|
||||
if (typeof ownerFilePath !== 'string' || !metadata.candidateFilePaths.has(ownerFilePath)) {
|
||||
continue;
|
||||
}
|
||||
ownerByMethod.set(method.id, owner);
|
||||
if (!eligibleMethod(method, owner, metadata.singletonInstanceClassIds)) continue;
|
||||
const methods = classMethods.get(owner.id) ?? [];
|
||||
methods.push(method);
|
||||
classMethods.set(owner.id, methods);
|
||||
candidates.push({ method, owner });
|
||||
}
|
||||
|
||||
let advisedByEdges = 0;
|
||||
let evidenceNodes = 0;
|
||||
let unresolvedPointcuts = 0;
|
||||
let candidateInspections = 0;
|
||||
let truncatedAdvices = 0;
|
||||
const budget: SpringAopAdviceBudget = {
|
||||
maxInspectionsPerAdvice: resolveBudget(
|
||||
ctx.options?.springAopMaxCandidateInspectionsPerAdvice,
|
||||
DEFAULT_SPRING_AOP_MAX_CANDIDATE_INSPECTIONS_PER_ADVICE,
|
||||
),
|
||||
maxInspections: resolveBudget(
|
||||
ctx.options?.springAopMaxCandidateInspections,
|
||||
DEFAULT_SPRING_AOP_MAX_CANDIDATE_INSPECTIONS,
|
||||
),
|
||||
maxEdgesPerAdvice: resolveBudget(
|
||||
ctx.options?.springAopMaxAdvisedEdgesPerAdvice,
|
||||
DEFAULT_SPRING_AOP_MAX_ADVISED_EDGES_PER_ADVICE,
|
||||
),
|
||||
maxEdges: resolveBudget(
|
||||
ctx.options?.springAopMaxAdvisedEdges,
|
||||
DEFAULT_SPRING_AOP_MAX_ADVISED_EDGES,
|
||||
),
|
||||
inspections: 0,
|
||||
edges: 0,
|
||||
};
|
||||
for (const aspect of metadata.aspects) evidenceNodes += emitAspect(ctx, aspect);
|
||||
for (const behavior of metadata.behaviors) {
|
||||
// `@annotation` matches annotations declared directly on the method.
|
||||
// Class-level behavior is fanned out by emitBehavior, but must not be
|
||||
// copied here (that would model @within/@target semantics instead).
|
||||
if (behavior.ownerKind === 'callable') {
|
||||
const annotations = methodAnnotations.get(behavior.ownerId) ?? new Set<string>();
|
||||
annotations.add(behavior.annotation);
|
||||
methodAnnotations.set(behavior.ownerId, annotations);
|
||||
}
|
||||
const emitted = emitBehavior(
|
||||
ctx,
|
||||
behavior,
|
||||
classMethods,
|
||||
ownerByMethod,
|
||||
metadata.singletonInstanceClassIds,
|
||||
);
|
||||
advisedByEdges += emitted.edges;
|
||||
evidenceNodes += emitted.evidence;
|
||||
}
|
||||
const candidateIndex = createSpringAopCandidateIndex(candidates, methodAnnotations);
|
||||
for (const advice of metadata.advices) {
|
||||
const emitted = emitAdvice(
|
||||
ctx,
|
||||
advice,
|
||||
metadata.aspectClassIds,
|
||||
ownerByMethod,
|
||||
candidateIndex,
|
||||
methodAnnotations,
|
||||
budget,
|
||||
);
|
||||
advisedByEdges += emitted.edges;
|
||||
evidenceNodes += emitted.evidence;
|
||||
unresolvedPointcuts += emitted.unresolved;
|
||||
candidateInspections += emitted.inspections;
|
||||
if (emitted.truncated) {
|
||||
truncatedAdvices += 1;
|
||||
logger.warn(
|
||||
`[spring-aop] truncated advice ${advice.ownerId}: ` +
|
||||
`${emitted.inspections} candidate inspections, ${emitted.edges} edges emitted; ` +
|
||||
`run totals ${budget.inspections} inspections/${budget.edges} edges`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (truncatedAdvices > 0) {
|
||||
ctx.onProgress({
|
||||
phase: 'enriching',
|
||||
percent: 97,
|
||||
message: 'Spring AOP advice resolution truncated by configured budgets',
|
||||
detail: `${truncatedAdvices} advice method(s); ${candidateInspections} inspections; ${budget.edges} advice edges`,
|
||||
stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: ctx.graph.nodeCount },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
advisedByEdges,
|
||||
evidenceNodes,
|
||||
unresolvedPointcuts,
|
||||
candidateInspections,
|
||||
truncatedAdvices,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
interface InheritedBehaviorWorkItem {
|
||||
readonly sourceId: string;
|
||||
readonly evidenceId: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
function sameMethodSignature(left: GraphNode, right: GraphNode): boolean {
|
||||
if (left.properties.name !== right.properties.name) return false;
|
||||
const leftCount = left.properties.parameterCount;
|
||||
const rightCount = right.properties.parameterCount;
|
||||
if (typeof leftCount === 'number' && typeof rightCount === 'number' && leftCount !== rightCount) {
|
||||
return false;
|
||||
}
|
||||
const leftTypes = left.properties.parameterTypes;
|
||||
const rightTypes = right.properties.parameterTypes;
|
||||
return !(
|
||||
Array.isArray(leftTypes) &&
|
||||
Array.isArray(rightTypes) &&
|
||||
leftTypes.length > 0 &&
|
||||
rightTypes.length > 0 &&
|
||||
(leftTypes.length !== rightTypes.length ||
|
||||
leftTypes.some((type, index) => type !== rightTypes[index]))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagate behavior evidence across the inheritance decisions materialized by
|
||||
* MRO. This is deliberately separate from pointcut matching: `@annotation`
|
||||
* continues to mean an annotation declared directly on the callable.
|
||||
*/
|
||||
export const springAopInheritancePhase: PipelinePhase<SpringAopInheritanceOutput> = {
|
||||
name: 'springAopInheritance',
|
||||
deps: ['springAop', 'mro'],
|
||||
|
||||
async execute(ctx: PipelineContext): Promise<SpringAopInheritanceOutput> {
|
||||
const metadata = getSpringAopGraphMetadata(ctx.graph);
|
||||
const ownerByMethod = new Map<string, GraphNode>();
|
||||
const classMethods = new Map<string, GraphNode[]>();
|
||||
for (const relationship of ctx.graph.iterRelationshipsByType('HAS_METHOD')) {
|
||||
const owner = ctx.graph.getNode(relationship.sourceId);
|
||||
const method = ctx.graph.getNode(relationship.targetId);
|
||||
if (owner === undefined || !eligibleOwner(owner) || method?.label !== 'Method') continue;
|
||||
ownerByMethod.set(method.id, owner);
|
||||
const methods = classMethods.get(owner.id) ?? [];
|
||||
methods.push(method);
|
||||
classMethods.set(owner.id, methods);
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<string, Set<string>>();
|
||||
for (const type of ['EXTENDS', 'IMPLEMENTS'] as const) {
|
||||
for (const relationship of ctx.graph.iterRelationshipsByType(type)) {
|
||||
const children = childrenByParent.get(relationship.targetId) ?? new Set<string>();
|
||||
children.add(relationship.sourceId);
|
||||
childrenByParent.set(relationship.targetId, children);
|
||||
}
|
||||
}
|
||||
|
||||
const implementingMethods = new Map<string, Set<string>>();
|
||||
for (const relationship of ctx.graph.iterRelationshipsByType('METHOD_IMPLEMENTS')) {
|
||||
const methods = implementingMethods.get(relationship.targetId) ?? new Set<string>();
|
||||
methods.add(relationship.sourceId);
|
||||
implementingMethods.set(relationship.targetId, methods);
|
||||
}
|
||||
|
||||
const overridingClasses = new Map<string, Set<string>>();
|
||||
for (const relationship of ctx.graph.iterRelationshipsByType('METHOD_OVERRIDES')) {
|
||||
const classes = overridingClasses.get(relationship.targetId) ?? new Set<string>();
|
||||
classes.add(relationship.sourceId);
|
||||
overridingClasses.set(relationship.targetId, classes);
|
||||
}
|
||||
|
||||
const queue: InheritedBehaviorWorkItem[] = [];
|
||||
const emittedKeys = new Set<string>();
|
||||
for (const relationship of ctx.graph.iterRelationshipsByType('ADVISED_BY')) {
|
||||
if (decodeSpringAopReason(relationship.reason)?.kind !== 'behavior') continue;
|
||||
const key = `${relationship.sourceId}\0${relationship.targetId}\0${relationship.reason}`;
|
||||
emittedKeys.add(key);
|
||||
queue.push({
|
||||
sourceId: relationship.sourceId,
|
||||
evidenceId: relationship.targetId,
|
||||
reason: relationship.reason,
|
||||
});
|
||||
}
|
||||
|
||||
let inheritedBehaviorEdges = 0;
|
||||
const enqueue = (source: GraphNode, item: InheritedBehaviorWorkItem): void => {
|
||||
const owner = ownerByMethod.get(source.id);
|
||||
if (
|
||||
!eligibleOwner(source) &&
|
||||
!eligibleMethod(source, owner, metadata.singletonInstanceClassIds)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const key = `${source.id}\0${item.evidenceId}\0${item.reason}`;
|
||||
if (emittedKeys.has(key)) return;
|
||||
emittedKeys.add(key);
|
||||
ctx.graph.addRelationship({
|
||||
id: generateId('ADVISED_BY', `${source.id}->${item.evidenceId}:inherited:${item.reason}`),
|
||||
sourceId: source.id,
|
||||
targetId: item.evidenceId,
|
||||
type: 'ADVISED_BY',
|
||||
confidence: 1,
|
||||
reason: item.reason,
|
||||
});
|
||||
inheritedBehaviorEdges += 1;
|
||||
queue.push({ sourceId: source.id, evidenceId: item.evidenceId, reason: item.reason });
|
||||
};
|
||||
|
||||
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
||||
const item = queue[cursor];
|
||||
if (item === undefined) continue;
|
||||
const source = ctx.graph.getNode(item.sourceId);
|
||||
if (source === undefined) continue;
|
||||
|
||||
if (eligibleOwner(source)) {
|
||||
for (const childId of childrenByParent.get(source.id) ?? []) {
|
||||
const child = ctx.graph.getNode(childId);
|
||||
if (child === undefined || !eligibleOwner(child)) continue;
|
||||
enqueue(child, item);
|
||||
for (const method of classMethods.get(child.id) ?? []) enqueue(method, item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (source.label !== 'Method') continue;
|
||||
|
||||
for (const methodId of implementingMethods.get(source.id) ?? []) {
|
||||
const method = ctx.graph.getNode(methodId);
|
||||
if (method !== undefined) enqueue(method, item);
|
||||
}
|
||||
for (const classId of overridingClasses.get(source.id) ?? []) {
|
||||
const matches = (classMethods.get(classId) ?? []).filter((method) =>
|
||||
sameMethodSignature(method, source),
|
||||
);
|
||||
const [match] = matches;
|
||||
if (matches.length === 1 && match !== undefined) enqueue(match, item);
|
||||
}
|
||||
}
|
||||
|
||||
return { inheritedBehaviorEdges };
|
||||
},
|
||||
};
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ export function extract(
|
|||
d.range,
|
||||
d.filePath,
|
||||
d.ownsReceivers,
|
||||
d.lexicalNames,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -314,6 +315,7 @@ interface ScopeDraft {
|
|||
readonly ownedDefs: SymbolDefinition[];
|
||||
readonly imports: ImportEdge[];
|
||||
readonly typeBindings: Map<string, TypeRef>;
|
||||
readonly lexicalNames?: ReadonlySet<string>;
|
||||
/** See `Scope.ownsReceivers` — set once at pass 1, never mutated. */
|
||||
readonly ownsReceivers?: ReadonlySet<string>;
|
||||
}
|
||||
|
|
@ -371,6 +373,7 @@ function draftToScope(draft: ScopeDraft): Scope {
|
|||
ownedDefs: Object.freeze(draft.ownedDefs.slice()),
|
||||
imports: Object.freeze(draft.imports.slice()),
|
||||
typeBindings: new Map(draft.typeBindings),
|
||||
lexicalNames: draft.lexicalNames,
|
||||
ownsReceivers: draft.ownsReceivers,
|
||||
};
|
||||
}
|
||||
|
|
@ -448,6 +451,7 @@ function pass1BuildScopes(
|
|||
cand.range,
|
||||
filePath,
|
||||
provider.scopeOwnsReceivers?.(cand.match),
|
||||
parseScopeLexicalNames(cand.match),
|
||||
),
|
||||
);
|
||||
stack.push(cand);
|
||||
|
|
@ -494,6 +498,7 @@ function makeDraft(
|
|||
range: Range,
|
||||
filePath: string,
|
||||
ownsReceivers?: ReadonlySet<string>,
|
||||
lexicalNames?: ReadonlySet<string>,
|
||||
): ScopeDraft {
|
||||
return {
|
||||
id,
|
||||
|
|
@ -505,10 +510,26 @@ function makeDraft(
|
|||
ownedDefs: [],
|
||||
imports: [],
|
||||
typeBindings: new Map(),
|
||||
lexicalNames,
|
||||
ownsReceivers,
|
||||
};
|
||||
}
|
||||
|
||||
function parseScopeLexicalNames(match: CaptureMatch): ReadonlySet<string> | undefined {
|
||||
const raw = match['@scope.lexical-names']?.text;
|
||||
if (raw === undefined) return undefined;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return undefined;
|
||||
const names = parsed.filter(
|
||||
(name): name is string => typeof name === 'string' && name.length > 0,
|
||||
);
|
||||
return names.length > 0 ? new Set(names) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pass 2: attach declarations + local bindings ──────────────────────────
|
||||
|
||||
function pass2AttachDeclarations(
|
||||
|
|
@ -1490,6 +1511,7 @@ function rangesEqual(a: Range, b: Range): boolean {
|
|||
* change.
|
||||
*/
|
||||
const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
|
||||
'@scope.lexical-names',
|
||||
'@declaration.name',
|
||||
'@declaration.qualified_name',
|
||||
'@import.name',
|
||||
|
|
|
|||
|
|
@ -361,6 +361,17 @@ export interface ScopeResolver {
|
|||
context?: ImportResolutionContext,
|
||||
): string | readonly string[] | null;
|
||||
|
||||
/**
|
||||
* Optionally reclassify an import as a namespace handle after target
|
||||
* resolution proves the imported name is itself a module. Returning false
|
||||
* retains ordinary named-binding behavior.
|
||||
*/
|
||||
readonly isNamespaceImport?: (
|
||||
parsedImport: ParsedImport,
|
||||
targetFile: string,
|
||||
fromFile: string,
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* Enumerate names visible through a wildcard import after the target
|
||||
* module scope has been linked. Languages that do not support
|
||||
|
|
|
|||
|
|
@ -218,6 +218,11 @@ export function resolveDefGraphId(
|
|||
// without either side having to model the scope chain. Node ids are
|
||||
// 0-based, def ids 1-based. An `AMBIGUOUS_POSITION` tombstone (two
|
||||
// callables on one line) falls through to the name-based keys below.
|
||||
//
|
||||
// For a closure binding the two query channels still anchor on different
|
||||
// AST nodes (outer wrapper vs inner callable), but the graph node's
|
||||
// `startLine` follows the initializer (#2735) so this join matches even
|
||||
// when the binding is split across lines.
|
||||
const line = defStartLine(def.nodeId, filePath);
|
||||
if (line !== undefined && isPositionQualifiedLocalLabel(def.type)) {
|
||||
const simple = simpleNameOf(qn);
|
||||
|
|
@ -226,13 +231,15 @@ export function resolveDefGraphId(
|
|||
// FAIL CLOSED when a function-local of this name exists in the file (#2699
|
||||
// follow-up). Falling through to the name keys would end at the label-agnostic,
|
||||
// first-write-wins `simpleKey` below and alias this def onto whichever same-named
|
||||
// callable was registered first — reproducibly minting a FALSE edge for a
|
||||
// multiline `const pick =` (the declaration and its initializer land on different
|
||||
// lines, so the position join misses). A missing edge is the correct failure
|
||||
// direction for a graph whose consumers include `impact`; a fabricated caller is
|
||||
// not. Gated on `localNameKey` so this ONLY fires where the collision is real —
|
||||
// a file with no such local keeps its previous fallback behaviour, which is what
|
||||
// preserves legitimate anchor differences such as a Vue SFC's `lineOffset`.
|
||||
// callable was registered first — reproducibly minting a FALSE edge. A missing
|
||||
// edge is the correct failure direction for a graph whose consumers include
|
||||
// `impact`; a fabricated caller is not. Gated on `localNameKey` so this ONLY
|
||||
// fires where the collision is real — a file with no such local keeps its
|
||||
// previous fallback behaviour, which is what preserves legitimate anchor
|
||||
// differences such as a Vue SFC's `lineOffset`.
|
||||
//
|
||||
// Multi-line closure bindings are NOT this case anymore (#2735): their graph
|
||||
// `startLine` follows the initializer, so the position key above hits.
|
||||
if (nodeLookup.get(localNameKey(filePath, def.type, simple)) !== undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,17 @@ import { resolveInheritanceBaseInScope } from '../scope/walkers.js';
|
|||
import { definitionIdPosition } from '../utils/definition-id.js';
|
||||
import { narrowOverloadCandidates } from './overload-narrowing.js';
|
||||
|
||||
export const MAX_CALLABLE_VALUE_TARGETS = 32;
|
||||
/**
|
||||
* Per-site dispatch-target cap. Above it the site is treated as overflowed and
|
||||
* its edges are dropped — a cliff, so a repo with a legitimately wide dispatch
|
||||
* table (33+ candidates on one callable site) loses the whole call chain.
|
||||
*
|
||||
* Override via `GITNEXUS_MAX_CALLABLE_VALUE_TARGETS` for such repos.
|
||||
*/
|
||||
export const MAX_CALLABLE_VALUE_TARGETS = (() => {
|
||||
const env = Number(process.env.GITNEXUS_MAX_CALLABLE_VALUE_TARGETS);
|
||||
return Number.isInteger(env) && env >= 1 ? env : 32;
|
||||
})();
|
||||
|
||||
interface Target {
|
||||
readonly id: string;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js';
|
|||
import {
|
||||
findClassBindingInScope,
|
||||
findEnclosingClassDef,
|
||||
findExportedDef,
|
||||
findExportedDefByName,
|
||||
findReceiverTypeBinding,
|
||||
isClassLike,
|
||||
|
|
@ -100,6 +101,8 @@ interface ResolveCompoundReceiverOptions {
|
|||
* rather than re-declared, so a future sub-field cannot be added there
|
||||
* and silently ignored here (#2708). */
|
||||
readonly constructionSyntax?: ScopeResolver['constructionSyntax'];
|
||||
/** Verified namespace handles visible in the current file. */
|
||||
readonly namespaceTargets?: ReadonlyMap<string, readonly string[]>;
|
||||
/** Compact receiver chain for THIS site (`ReferenceSite.receiverChain`), when
|
||||
* the language's capture emitter produced one. Present ⇒ the structural fold
|
||||
* is tried before the text cascade; absent ⇒ behaviour is exactly as before.
|
||||
|
|
@ -146,6 +149,42 @@ function escapeForRegExp(literal: string): string {
|
|||
return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** True when a local declaration between the call site and its module scope
|
||||
* shadows a file-level namespace import with the same name. Namespace targets
|
||||
* are collected per file, so callers must apply this lexical guard before
|
||||
* trusting them at an inner scope. */
|
||||
function isNamespaceNameShadowed(
|
||||
namespaceName: string,
|
||||
inScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): boolean {
|
||||
let currentId: ScopeId | null = inScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return true;
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) return true;
|
||||
if (
|
||||
scope.kind !== 'Object' &&
|
||||
(scope.bindings.has(namespaceName) ||
|
||||
scope.typeBindings.has(namespaceName) ||
|
||||
scope.lexicalNames?.has(namespaceName) === true ||
|
||||
scope.ownedDefs.some((def) => {
|
||||
const qualifiedName = def.qualifiedName;
|
||||
if (qualifiedName === undefined) return false;
|
||||
const dot = qualifiedName.lastIndexOf('.');
|
||||
return (dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1)) === namespaceName;
|
||||
}))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (scope.kind === 'Module') return false;
|
||||
currentId = scope.parent;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type of a construction expression's callee — the class it constructs.
|
||||
*
|
||||
|
|
@ -164,6 +203,7 @@ function resolveConstructionExpressionClass(
|
|||
fnExpr: string,
|
||||
inScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
index: WorkspaceResolutionIndex,
|
||||
options: ResolveCompoundReceiverOptions,
|
||||
): SymbolDefinition | undefined {
|
||||
const syntax = options.constructionSyntax;
|
||||
|
|
@ -190,14 +230,30 @@ function resolveConstructionExpressionClass(
|
|||
// name).
|
||||
if (calleeName === undefined || calleeName.length === 0) return undefined;
|
||||
|
||||
const direct = findClassBindingInScope(inScope, calleeName, scopes);
|
||||
if (direct !== undefined && isClassLike(direct.type)) return direct;
|
||||
|
||||
// Generic construction — `new Box<string>()` arrives here as `Box<string>`,
|
||||
// which names no class binding. Retry on the base name, the same
|
||||
// normalization `resolveClassBindingForName` in `receiver-bound-calls`
|
||||
// already applies for typed receivers (#2708).
|
||||
const baseName = stripTemplateArguments(calleeName).trim();
|
||||
const lastDot = baseName.lastIndexOf('.');
|
||||
if (lastDot !== -1) {
|
||||
const namespaceName = baseName.slice(0, lastDot);
|
||||
const exportedName = baseName.slice(lastDot + 1);
|
||||
const namespaceFiles = options.namespaceTargets?.get(namespaceName) ?? [];
|
||||
// A verified namespace is authoritative: do not fall through to the
|
||||
// workspace-wide simple-name heuristics on either a miss or ambiguity.
|
||||
if (namespaceFiles.length > 0) {
|
||||
if (isNamespaceNameShadowed(namespaceName, inScope, scopes)) return undefined;
|
||||
const namespaceMatches = namespaceFiles
|
||||
.map((targetFile) => findExportedDef(targetFile, exportedName, index))
|
||||
.filter((def): def is SymbolDefinition => def !== undefined && isClassLike(def.type));
|
||||
return namespaceMatches.length === 1 ? namespaceMatches[0] : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const direct = findClassBindingInScope(inScope, calleeName, scopes);
|
||||
if (direct !== undefined && isClassLike(direct.type)) return direct;
|
||||
|
||||
if (baseName.length > 0 && baseName !== calleeName) {
|
||||
const viaBaseName = findClassBindingInScope(inScope, baseName, scopes);
|
||||
if (viaBaseName !== undefined && isClassLike(viaBaseName.type)) return viaBaseName;
|
||||
|
|
@ -206,8 +262,8 @@ function resolveConstructionExpressionClass(
|
|||
// Qualified callee — `new ns.Service()` / `new Outer.Inner()`. Prefer an
|
||||
// unambiguous qualified-name match, then fall back to the trailing simple
|
||||
// name the way receiver resolution does elsewhere (#2708).
|
||||
const lastDot = baseName.lastIndexOf('.');
|
||||
if (lastDot === -1) return undefined;
|
||||
|
||||
const qualifiedIds = scopes.qualifiedNames.get(baseName);
|
||||
if (qualifiedIds.length === 1) {
|
||||
const qualified = scopes.defs.get(qualifiedIds[0]!);
|
||||
|
|
@ -503,7 +559,7 @@ export function resolveCompoundReceiverClass(
|
|||
// the dot-split below routes it into member resolution (#2708).
|
||||
const keyword = options.constructionSyntax?.keyword;
|
||||
if (keyword !== undefined && new RegExp(`^${escapeForRegExp(keyword)}\\s`).test(fnExpr)) {
|
||||
return resolveConstructionExpressionClass(fnExpr, inScope, scopes, options);
|
||||
return resolveConstructionExpressionClass(fnExpr, inScope, scopes, index, options);
|
||||
}
|
||||
|
||||
const lastDot = fnExpr.lastIndexOf('.');
|
||||
|
|
@ -525,7 +581,7 @@ export function resolveCompoundReceiverClass(
|
|||
// read a type off; the return-type path above cannot help either,
|
||||
// because a class has no return-type binding. Type it from the
|
||||
// class the callee names (#2708).
|
||||
return resolveConstructionExpressionClass(fnExpr, inScope, scopes, options);
|
||||
return resolveConstructionExpressionClass(fnExpr, inScope, scopes, index, options);
|
||||
}
|
||||
|
||||
// `obj.method()` — resolve obj's class, look up method's return
|
||||
|
|
@ -540,7 +596,15 @@ export function resolveCompoundReceiverClass(
|
|||
options,
|
||||
depth + 1,
|
||||
);
|
||||
if (objClass === undefined) return undefined;
|
||||
if (objClass === undefined) {
|
||||
// A verified namespace-qualified bare constructor is syntactically
|
||||
// indistinguishable from an untyped member call here. Only the namespace
|
||||
// map makes the constructor interpretation safe.
|
||||
if (options.namespaceTargets?.has(objExpr) === true) {
|
||||
return resolveConstructionExpressionClass(fnExpr, inScope, scopes, index, options);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Does `objExpr` name the CLASS ITSELF (`Factory.new`) rather than a
|
||||
// value whose type is that class (`factory.new`)? Only the former is
|
||||
|
|
@ -743,7 +807,13 @@ export function resolveCompoundReceiverClass(
|
|||
// seeded and the whole chain resolved to nothing. A constructed value is an
|
||||
// instance, so `currentIsClassConstant` correctly stays false here.
|
||||
if (currentClass === undefined) {
|
||||
currentClass = resolveConstructionExpressionClass(headMemberName, inScope, scopes, options);
|
||||
currentClass = resolveConstructionExpressionClass(
|
||||
headMemberName,
|
||||
inScope,
|
||||
scopes,
|
||||
index,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 1; i < parts.length && currentClass !== undefined; i++) {
|
||||
|
|
|
|||
|
|
@ -52,8 +52,15 @@ import { findCallableBindingInScope } from '../scope/walkers.js';
|
|||
* 2× that — dropping the motivating key was the failure the first value (8)
|
||||
* had. ponytail: flat cap; revisit with per-receiver narrowing if real
|
||||
* repos show useful keys being dropped (§12 of the #2437 plan).
|
||||
*
|
||||
* Override via `GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT` env var for repos with
|
||||
* legitimate high-fanout property keys (e.g. large Vue codebases where
|
||||
* `validator` exceeds the default).
|
||||
*/
|
||||
export const MAX_PROPERTY_DISPATCH_FANOUT = 32;
|
||||
export const MAX_PROPERTY_DISPATCH_FANOUT = (() => {
|
||||
const env = Number(process.env.GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT);
|
||||
return Number.isInteger(env) && env >= 1 ? env : 32;
|
||||
})();
|
||||
|
||||
/** Below the 0.85 resolved baseline; same discount idea as interface-dispatch. */
|
||||
export const PROPERTY_DISPATCH_CONFIDENCE = 0.7;
|
||||
|
|
|
|||
|
|
@ -257,6 +257,7 @@ export function emitReceiverBoundCalls(
|
|||
|
||||
for (const parsed of parsedFiles) {
|
||||
const namespaceTargets = collectNamespaceTargets(parsed, scopes);
|
||||
const fileCompoundOpts = { ...compoundOpts, namespaceTargets };
|
||||
// Per-file resolved-callee-id capture context (#2227 U2). Built once per
|
||||
// file; `undefined` when the sink is absent (pdg off) so the `tryEmitEdge`
|
||||
// capture is a no-op and emission stays byte-identical (R4).
|
||||
|
|
@ -409,7 +410,7 @@ export function emitReceiverBoundCalls(
|
|||
index,
|
||||
// Group A: the receiver IS this site's expression, so the site's
|
||||
// captured chain describes it and the structural fold applies.
|
||||
{ ...compoundOpts, receiverChain: site.receiverChain },
|
||||
{ ...fileCompoundOpts, receiverChain: site.receiverChain },
|
||||
);
|
||||
compoundReceiverUnresolved = currentClass === undefined;
|
||||
if (currentClass !== undefined) {
|
||||
|
|
@ -938,7 +939,7 @@ export function emitReceiverBoundCalls(
|
|||
typeRef.declaredAtScope,
|
||||
scopes,
|
||||
index,
|
||||
compoundOpts,
|
||||
fileCompoundOpts,
|
||||
);
|
||||
if (ownerDef === undefined && !typeRef.rawName.includes('(')) {
|
||||
ownerDef = resolveCompoundReceiverClass(
|
||||
|
|
@ -946,7 +947,7 @@ export function emitReceiverBoundCalls(
|
|||
typeRef.declaredAtScope,
|
||||
scopes,
|
||||
index,
|
||||
compoundOpts,
|
||||
fileCompoundOpts,
|
||||
);
|
||||
}
|
||||
if (ownerDef !== undefined) {
|
||||
|
|
@ -1049,7 +1050,7 @@ export function emitReceiverBoundCalls(
|
|||
scopes,
|
||||
index,
|
||||
// Group A, same reasoning as Case 0 above.
|
||||
{ ...compoundOpts, receiverChain: site.receiverChain },
|
||||
{ ...fileCompoundOpts, receiverChain: site.receiverChain },
|
||||
);
|
||||
}
|
||||
if (ownerDef !== undefined) {
|
||||
|
|
|
|||
|
|
@ -605,6 +605,8 @@ export function runScopeResolution(
|
|||
parsedFiles,
|
||||
parsedImport,
|
||||
}),
|
||||
isNamespaceImport: (parsedImport, targetFile, fromFile) =>
|
||||
provider.isNamespaceImport?.(parsedImport, targetFile, fromFile) ?? false,
|
||||
expandsWildcardTo: (targetModuleScope) =>
|
||||
provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [],
|
||||
mergeBindings: (existing, incoming, scopeId) =>
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@
|
|||
*
|
||||
* Next-consumer contract: any language with namespace-style imports
|
||||
* (TypeScript `import * as X`, Java static import, Ruby `require`)
|
||||
* uses this directly. `ParsedImport.kind === 'namespace'` is the
|
||||
* cross-language hook.
|
||||
* uses this directly. The finalized `ImportEdge.kind === 'namespace'`
|
||||
* classification is authoritative; providers may produce it directly from
|
||||
* syntax or reclassify a named import after target resolution proves it names
|
||||
* a module.
|
||||
*
|
||||
* Scope-chain concern (verified 2026-04-21): `pythonImportOwningScope`
|
||||
* documents that function-local and class-body imports bind to the
|
||||
|
|
@ -43,14 +45,8 @@ export function collectNamespaceTargets(
|
|||
const moduleEdges = scopes.imports.get(parsed.moduleScope);
|
||||
if (moduleEdges === undefined) return out;
|
||||
|
||||
const namespaceLocals = new Set<string>();
|
||||
for (const imp of parsed.parsedImports) {
|
||||
if (imp.kind === 'namespace') namespaceLocals.add(imp.localName);
|
||||
}
|
||||
|
||||
for (const edge of moduleEdges) {
|
||||
if (edge.targetFile === null) continue;
|
||||
if (!namespaceLocals.has(edge.localName)) continue;
|
||||
if (edge.targetFile === null || edge.kind !== 'namespace') continue;
|
||||
let targets = out.get(edge.localName);
|
||||
if (targets === undefined) {
|
||||
targets = [];
|
||||
|
|
|
|||
|
|
@ -19,8 +19,78 @@
|
|||
* in here is derived, and why only genuinely nested callables get one.
|
||||
*/
|
||||
|
||||
import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { definitionIdPosition } from '../scope-resolution/utils/definition-id.js';
|
||||
|
||||
const LOCAL_IDENTITY_SUFFIX = /@\d+:\d+$/;
|
||||
|
||||
function simpleDefinitionName(def: SymbolDefinition): string | undefined {
|
||||
const qualifiedName = def.qualifiedName;
|
||||
if (qualifiedName === undefined) return undefined;
|
||||
const dot = qualifiedName.lastIndexOf('.');
|
||||
const tail = dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1);
|
||||
return tail.replace(LOCAL_IDENTITY_SUFFIX, '');
|
||||
}
|
||||
|
||||
function containsPosition(node: SyntaxNode, row: number, column: number): boolean {
|
||||
const start = node.startPosition;
|
||||
const end = node.endPosition;
|
||||
if (row < start.row || row > end.row) return false;
|
||||
if (row === start.row && column < start.column) return false;
|
||||
if (row === end.row && column > end.column) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-based start row that keys the graph-to-scope position join for a bound
|
||||
* callable (#2735).
|
||||
*
|
||||
* Graph-node queries may anchor on an outer binding wrapper while the scope
|
||||
* channel anchors on the inner callable. The join is line-only, so a multi-line
|
||||
* binding needs the graph node's `startLine` to follow the semantic definition.
|
||||
*
|
||||
* `ParsedFile.localDefs` is the language-agnostic source of that position.
|
||||
* Matching uses only the canonical label, name, and source range; shared worker
|
||||
* code does not need to know grammar node types or initializer field names.
|
||||
*
|
||||
* Node ids stay on the binding wrapper via `localIdentity(definitionNode)`.
|
||||
* Missing or ambiguous semantic matches retain the wrapper row, preserving the
|
||||
* existing fail-closed behavior.
|
||||
*/
|
||||
export function boundCallableStartRow(
|
||||
definitionNode: SyntaxNode,
|
||||
nodeName: string,
|
||||
nodeLabel: NodeLabel,
|
||||
localDefs: readonly SymbolDefinition[] | undefined,
|
||||
nameNode?: SyntaxNode | null,
|
||||
): number {
|
||||
if (localDefs === undefined) return definitionNode.startPosition.row;
|
||||
|
||||
const origin = nameNode?.startPosition ?? definitionNode.startPosition;
|
||||
let best: { row: number; distance: number } | undefined;
|
||||
let tied = false;
|
||||
|
||||
for (const def of localDefs) {
|
||||
if (def.type !== nodeLabel || simpleDefinitionName(def) !== nodeName) continue;
|
||||
const position = definitionIdPosition(def.nodeId, def.filePath);
|
||||
if (position === undefined) continue;
|
||||
|
||||
const row = position.line - 1;
|
||||
if (!containsPosition(definitionNode, row, position.column)) continue;
|
||||
|
||||
const distance =
|
||||
Math.abs(row - origin.row) * 1_000_000 + Math.abs(position.column - origin.column);
|
||||
if (best === undefined || distance < best.distance) {
|
||||
best = { row, distance };
|
||||
tied = false;
|
||||
} else if (distance === best.distance && row !== best.row) {
|
||||
tied = true;
|
||||
}
|
||||
}
|
||||
|
||||
return best !== undefined && !tied ? best.row : definitionNode.startPosition.row;
|
||||
}
|
||||
/**
|
||||
* A function-local callable's own name segment: its name plus its declaration
|
||||
* position.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { parentPort, threadId, workerData } from 'node:worker_threads';
|
||||
import { localIdentity, nestedCallableQualifiedName } from './callable-id.js';
|
||||
import {
|
||||
boundCallableStartRow,
|
||||
localIdentity,
|
||||
nestedCallableQualifiedName,
|
||||
} from './callable-id.js';
|
||||
import Parser from 'tree-sitter';
|
||||
import JavaScript from 'tree-sitter-javascript';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
|
|
@ -2240,11 +2244,27 @@ const processFileGroup = (
|
|||
}
|
||||
}
|
||||
|
||||
const startLine = definitionNode
|
||||
? definitionNode.startPosition.row + lineOffset
|
||||
: nameNode
|
||||
? nameNode.startPosition.row + lineOffset
|
||||
: lineOffset;
|
||||
// #2735: for a bound callable the graph-node capture sits on the OUTER
|
||||
// wrapper while scope-resolution anchors on the INNER expression. The
|
||||
// position join is line-only, so `startLine` must follow the initializer
|
||||
// (ids still use `definitionNode` via `localIdentity`).
|
||||
const startRow =
|
||||
definitionNode &&
|
||||
(nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor')
|
||||
? boundCallableStartRow(
|
||||
definitionNode,
|
||||
nodeName,
|
||||
nodeLabel,
|
||||
parsedFile?.localDefs,
|
||||
nameNode,
|
||||
)
|
||||
: definitionNode?.startPosition.row;
|
||||
const startLine =
|
||||
startRow !== undefined
|
||||
? startRow + lineOffset
|
||||
: nameNode
|
||||
? nameNode.startPosition.row + lineOffset
|
||||
: lineOffset;
|
||||
|
||||
// Compute enclosing class BEFORE node ID — needed to qualify method IDs
|
||||
const needsOwner =
|
||||
|
|
@ -2750,7 +2770,7 @@ const processFileGroup = (
|
|||
properties: {
|
||||
name: nodeName,
|
||||
filePath: file.path,
|
||||
startLine: definitionNode ? definitionNode.startPosition.row + lineOffset : startLine,
|
||||
startLine,
|
||||
endLine: definitionNode ? definitionNode.endPosition.row + lineOffset : startLine,
|
||||
language: language,
|
||||
isExported:
|
||||
|
|
|
|||
|
|
@ -17,12 +17,17 @@ import { createWriteStream, WriteStream } from 'fs';
|
|||
import path from 'path';
|
||||
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import { NodeTableName, NODE_TABLES } from './schema.js';
|
||||
import { RelPairRouter } from './rel-pair-routing.js';
|
||||
import { NodeTableName, NODE_TABLES, RELATION_SCHEMA } from './schema.js';
|
||||
import { parseRelationSchemaPairs, RelPairRouter } from './rel-pair-routing.js';
|
||||
import { parseTruthyEnv } from '../ingestion/utils/env.js';
|
||||
import { SYMBOL_NODE_LABELS } from '../ingestion/utils/symbol-labels.js';
|
||||
import { applyCjkSegmentationIfEnabled } from '../search/cjk-segmentation.js';
|
||||
|
||||
/** Computed once — `RELATION_SCHEMA` is a static template literal. Exported so
|
||||
* the streamed sinks (`GraphEmitSink`, `PdgEmitSink`) share this parse
|
||||
* instead of each re-deriving it from the same DDL string. */
|
||||
export const DECLARED_RELATION_PAIRS = parseRelationSchemaPairs(RELATION_SCHEMA);
|
||||
|
||||
/**
|
||||
* Deterministic output ordering — optional (out-of-core / windowed-resolve
|
||||
* enabler). When `GITNEXUS_SORT_GRAPH_OUTPUT` is set, nodes and relationships
|
||||
|
|
@ -785,7 +790,12 @@ export const streamAllCSVsToDisk = async (
|
|||
// read once instead of twice. The router applies the SAME label-derivation +
|
||||
// validTables filter as the legacy splitRelCsvByLabelPair, so the per-pair
|
||||
// files are byte-identical (asserted by the differential test).
|
||||
const relRouter = new RelPairRouter(csvDir, REL_CSV_HEADER, new Set<string>(NODE_TABLES));
|
||||
const relRouter = new RelPairRouter(
|
||||
csvDir,
|
||||
REL_CSV_HEADER,
|
||||
new Set<string>(NODE_TABLES),
|
||||
DECLARED_RELATION_PAIRS,
|
||||
);
|
||||
try {
|
||||
let emitted = 0;
|
||||
for (const rel of orderedRelationships(graph, sortOutput)) {
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
import type { GraphNode, GraphRelationship, RelationshipType } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import { REL_CSV_HEADER, buildRelRow } from './csv-generator.js';
|
||||
import { getNodeLabel } from './rel-pair-routing.js';
|
||||
import { DECLARED_RELATION_PAIRS, REL_CSV_HEADER, buildRelRow } from './csv-generator.js';
|
||||
import { assertDeclaredPair, getNodeLabel } from './rel-pair-routing.js';
|
||||
import { NODE_TABLES } from './schema.js';
|
||||
import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js';
|
||||
|
||||
|
|
@ -127,8 +127,8 @@ import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js';
|
|||
* `routes`/`tools`, never read back mid-pipeline).
|
||||
*
|
||||
* Adding a relationship type that a phase reads back WITHOUT adding it here is
|
||||
* a silent-wrong-graph bug, not a crash — and NOTHING automated catches it.
|
||||
* The differential round-trip test cannot: `addRelationship` partitions edges
|
||||
* a silent-wrong-graph bug, not a crash. The differential round-trip test cannot:
|
||||
* `addRelationship` partitions edges
|
||||
* between the graph and the CSVs, and the union of a partition is invariant
|
||||
* under where the partition line falls, so that test stays green no matter how
|
||||
* this set is drawn. Only the read-site audit protects this invariant; re-run it
|
||||
|
|
@ -144,6 +144,8 @@ export const RETAINED_REL_TYPES: ReadonlySet<RelationshipType> = new Set<Relatio
|
|||
'METHOD_IMPLEMENTS',
|
||||
'DEFINES',
|
||||
'INJECTS',
|
||||
// springAopInheritance reads direct behavior evidence after MRO.
|
||||
'ADVISED_BY',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
@ -412,6 +414,7 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl {
|
|||
if (!this.validTables.has(fromLabel) || !this.validTables.has(toLabel)) return;
|
||||
|
||||
const pairKey = `${fromLabel}|${toLabel}`;
|
||||
assertDeclaredPair(pairKey, DECLARED_RELATION_PAIRS);
|
||||
let writer = this.relWriters.get(pairKey);
|
||||
if (writer === undefined) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ import {
|
|||
SPRING_AUTO_CONFIGURATION_REASONS,
|
||||
SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX,
|
||||
} from '../ingestion/frameworks/spring/auto-configuration.js';
|
||||
import { SPRING_AOP_EVIDENCE_ID_PREFIX } from '../ingestion/frameworks/spring/aop.js';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Relationship CSV splitting — extracted for testability (PR #818)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2745,6 +2746,53 @@ export const deleteAllCallSummaries = async (): Promise<{ edgesDeleted: number }
|
|||
export const deleteAllInjects = async (): Promise<{ edgesDeleted: number }> =>
|
||||
deleteAllRelationshipsOfType('INJECTS', 'di', 'duplicate INJECTS edges');
|
||||
|
||||
/**
|
||||
* Drop every Spring AOP `ADVISED_BY` relationship before incremental
|
||||
* writeback. Pointcut/annotation resolution is whole-program: adding a type in
|
||||
* a third file can shadow a wildcard annotation import or change a wildcard
|
||||
* execution match between two otherwise unchanged endpoint files.
|
||||
*/
|
||||
export const deleteAllAdvisedBy = async (): Promise<{ edgesDeleted: number }> =>
|
||||
deleteAllRelationshipsOfType('ADVISED_BY', 'spring-aop', 'duplicate ADVISED_BY edges');
|
||||
|
||||
/** Drop all synthetic Spring AOP evidence nodes before incremental writeback. */
|
||||
export const deleteSpringAopEvidenceNodes = async (): Promise<{ nodesDeleted: number }> => {
|
||||
const c = conn;
|
||||
if (!c) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
}
|
||||
return withConnLock(async () => {
|
||||
let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined;
|
||||
const idPrefix = escapeCypherString(SPRING_AOP_EVIDENCE_ID_PREFIX);
|
||||
const predicate = `n.id STARTS WITH '${idPrefix}'`;
|
||||
try {
|
||||
countResult = await c.query(
|
||||
`MATCH (n:CodeElement) WHERE ${predicate} RETURN count(n) AS cnt`,
|
||||
);
|
||||
const result = Array.isArray(countResult) ? countResult[0] : countResult;
|
||||
const rows = await result.getAll();
|
||||
const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
|
||||
if (count > 0) {
|
||||
await closeQueryResults(
|
||||
await c.query(`MATCH (n:CodeElement) WHERE ${predicate} DETACH DELETE n`),
|
||||
);
|
||||
}
|
||||
if (countResult) await closeQueryResults(countResult);
|
||||
return { nodesDeleted: count };
|
||||
} catch (err) {
|
||||
if (countResult) await closeQueryResults(countResult);
|
||||
if (classifyDeleteAllError(err) === 'benign-missing-table') {
|
||||
return { nodesDeleted: 0 };
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
'[spring-aop] failed to clear synthetic evidence before incremental re-write ' +
|
||||
`(${message}) — aborting to avoid stale advice metadata; the next run will full-rebuild`,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop Spring-owned auto-configuration `DECLARES` relationships before
|
||||
* incremental writeback. `DECLARES` is generic, so exact reason filtering is
|
||||
|
|
@ -3077,6 +3125,55 @@ export const ensureFTSIndex = async (
|
|||
}
|
||||
};
|
||||
|
||||
export type FtsQueryFailureClass = 'missing-index' | 'missing-table' | 'other';
|
||||
|
||||
/**
|
||||
* Classify a `QUERY_FTS_INDEX` failure so a genuinely-missing index (normal —
|
||||
* this table's FTS index hasn't been built yet) is distinguished from a real
|
||||
* query-time error that would otherwise look identical (#2767), and from the
|
||||
* table itself being missing (schema drift / a corrupted or partial DB — a
|
||||
* much more serious condition than an unbuilt index).
|
||||
*
|
||||
* tri-review Residual-1: this used to be a second, independently-maintained
|
||||
* classifier living in `core/search/bm25-index.ts` (re-exported from there
|
||||
* for backward compatibility), duplicating this function's job for the
|
||||
* IDENTICAL `QUERY_FTS_INDEX` cypher call. `queryFTS` below now uses this
|
||||
* same classifier for its own catch instead of a bare, unanchored
|
||||
* `.includes('does not exist')` check that could not tell "index missing"
|
||||
* from "table missing" apart, and silently swallowed both alike.
|
||||
*
|
||||
* Three real message shapes were confirmed empirically against a live
|
||||
* `CALL QUERY_FTS_INDEX(...)`:
|
||||
* `"Prepare failed: Binder exception: Table <T> doesn't have an index with
|
||||
* name <name>."` — the table exists, only its FTS index is missing (normal,
|
||||
* benign — `missing-index`) — `"Prepare failed: Binder exception: Table <T>
|
||||
* does not exist."` — the TABLE ITSELF is missing (`missing-table`) — and a
|
||||
* `Catalog exception: function QUERY_FTS_INDEX is not defined...` when the
|
||||
* FTS extension isn't loaded at all (`other`; mirrors the confirmed
|
||||
* `DROP_FTS_INDEX` shape in {@link isBenignDropFtsIndexError}'s doc comment).
|
||||
*
|
||||
* Anchored to the exception class (after stripping the optional "Prepare
|
||||
* failed: " wrapper LadybugDB adds for statement-preparation failures),
|
||||
* mirroring `isBenignDropFtsIndexError`'s START-of-message anchor: a bare
|
||||
* substring search would misclassify a genuine, differently-classed error
|
||||
* (e.g. a `Runtime exception` from the FTS parser that echoes the user's
|
||||
* own search text back into its message) as benign whenever that echoed
|
||||
* text happened to contain "does not exist" — silently dropping a real
|
||||
* error, the exact #2767 failure mode this function exists to prevent.
|
||||
*/
|
||||
export const classifyFtsQueryError = (message: string): FtsQueryFailureClass => {
|
||||
const PREPARE_FAILED_PREFIX = 'Prepare failed: ';
|
||||
const body = message.startsWith(PREPARE_FAILED_PREFIX)
|
||||
? message.slice(PREPARE_FAILED_PREFIX.length)
|
||||
: message;
|
||||
if (!body.startsWith('Binder exception:') && !body.startsWith('Catalog exception:')) {
|
||||
return 'other';
|
||||
}
|
||||
if (body.includes("doesn't have an index")) return 'missing-index';
|
||||
if (body.includes('does not exist')) return 'missing-table';
|
||||
return 'other';
|
||||
};
|
||||
|
||||
/**
|
||||
* Query a full-text search index
|
||||
* @param tableName - The node table name
|
||||
|
|
@ -3121,8 +3218,13 @@ export const queryFTS = async (
|
|||
};
|
||||
});
|
||||
} catch (e: any) {
|
||||
// Return empty if index doesn't exist yet
|
||||
if (e.message?.includes('does not exist')) {
|
||||
// Return empty only for a genuinely-missing index — the ordinary,
|
||||
// expected case. A missing TABLE (schema drift) or any other real error
|
||||
// rethrows instead of being silently swallowed (tri-review Residual-1 /
|
||||
// NEW-6 — this used to be a bare `.includes('does not exist')` check
|
||||
// that could not tell the two apart).
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
if (classifyFtsQueryError(message) === 'missing-index') {
|
||||
return [];
|
||||
}
|
||||
throw e;
|
||||
|
|
|
|||
|
|
@ -49,11 +49,12 @@ import type { GraphNode, GraphRelationship, RelationshipType } from 'gitnexus-sh
|
|||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import {
|
||||
BASICBLOCK_CSV_HEADER,
|
||||
DECLARED_RELATION_PAIRS,
|
||||
REL_CSV_HEADER,
|
||||
buildBasicBlockRow,
|
||||
buildRelRow,
|
||||
} from './csv-generator.js';
|
||||
import { getNodeLabel } from './rel-pair-routing.js';
|
||||
import { assertDeclaredPair, getNodeLabel } from './rel-pair-routing.js';
|
||||
import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js';
|
||||
import { NODE_TABLES, type NodeTableName } from './schema.js';
|
||||
|
||||
|
|
@ -165,6 +166,7 @@ export class PdgEmitSink implements KnowledgeGraph {
|
|||
// `RelPairRouter` exactly so the streamed set matches the whole-graph set.
|
||||
if (!this.validTables.has(fromLabel) || !this.validTables.has(toLabel)) return;
|
||||
const pairKey = `${fromLabel}|${toLabel}`;
|
||||
assertDeclaredPair(pairKey, DECLARED_RELATION_PAIRS);
|
||||
let writer = this.relWriters.get(pairKey);
|
||||
if (writer === undefined) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -16,8 +16,11 @@
|
|||
* router derives the label from the RAW id, while the oracle re-derives it via a
|
||||
* regex over the ESCAPED row — so for an id containing a `"` the router is the
|
||||
* more-correct path (it routes the edge to the right pair; the oracle's regex
|
||||
* mis-buckets or drops it). `splitRelCsvByLabelPair` is retained as the
|
||||
* differential oracle (the quote-in-id divergence is asserted explicitly).
|
||||
* mis-buckets or drops it). Unlike the legacy oracle, the router also rejects a
|
||||
* valid node-label pair absent from the relationship DDL. That distinction is
|
||||
* load-bearing: silently writing such a CSV lets COPY fail late and can drop an
|
||||
* otherwise valid edge during fallback. `splitRelCsvByLabelPair` is retained as
|
||||
* the differential oracle (the quote-in-id divergence is asserted explicitly).
|
||||
*
|
||||
* Backpressure: at most one stream is awaited at a time (the caller routes
|
||||
* edges sequentially and awaits the returned drain promise before the next),
|
||||
|
|
@ -45,11 +48,50 @@ export const getNodeLabel = (nodeId: string): string => {
|
|||
return nodeId.split(':')[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the FROM→TO pairs accepted by a relationship DDL.
|
||||
*
|
||||
* This belongs at the routing boundary: schema.ts owns the DDL, while the CSV
|
||||
* router owns the fail-fast check that prevents writing a pair LadybugDB cannot
|
||||
* COPY. Backticks quote schema labels and are not part of the graph label.
|
||||
*/
|
||||
export const parseRelationSchemaPairs = (relationSchema: string): ReadonlySet<string> =>
|
||||
new Set(
|
||||
[
|
||||
...relationSchema.matchAll(
|
||||
/\bFROM\s+`?([A-Za-z][A-Za-z0-9_]*)`?\s+TO\s+`?([A-Za-z][A-Za-z0-9_]*)`?/g,
|
||||
),
|
||||
].map((match) => `${match[1]}|${match[2]}`),
|
||||
);
|
||||
|
||||
export interface RelPairMeta {
|
||||
csvPath: string;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail fast on an endpoint-label pair absent from the relationship DDL, the
|
||||
* same guard `RelPairRouter.route` applies to the whole-graph emit. Exported
|
||||
* so the streamed sinks (`GraphEmitSink`, `PdgEmitSink`) can apply it too —
|
||||
* without this, an undeclared pair on a streaming run reaches `COPY`, fails
|
||||
* the bulk insert, and is silently dropped by the per-edge fallback instead
|
||||
* of failing loudly like the non-streaming path does.
|
||||
*
|
||||
* Takes the already-built `From|To` pairKey rather than the two labels — every
|
||||
* caller needs that same key immediately after for its own Map/stream lookup,
|
||||
* and this is on the per-edge hot path, so building it twice would be a
|
||||
* needless allocation per edge. `|` cannot appear inside a label (node labels
|
||||
* are `NODE_TABLES` identifiers), so splitting it back apart for the error
|
||||
* message is safe.
|
||||
*/
|
||||
export const assertDeclaredPair = (pairKey: string, declaredPairs: ReadonlySet<string>): void => {
|
||||
if (!declaredPairs.has(pairKey)) {
|
||||
throw new Error(
|
||||
`Relationship label pair ${pairKey.replaceAll('|', '→')} is not declared in the LadybugDB relation schema`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Routes already-escaped relationship CSV rows to per-FROM→TO-label-pair
|
||||
* files. Filters edges whose endpoint labels are not valid node tables
|
||||
|
|
@ -69,6 +111,7 @@ export class RelPairRouter {
|
|||
private readonly csvDir: string,
|
||||
private readonly header: string,
|
||||
private readonly validTables: Set<string>,
|
||||
private readonly declaredPairs: ReadonlySet<string>,
|
||||
private readonly wsFactory: WriteStreamFactory = (p) => createWriteStream(p, 'utf-8'),
|
||||
) {}
|
||||
|
||||
|
|
@ -104,6 +147,7 @@ export class RelPairRouter {
|
|||
}
|
||||
|
||||
const pairKey = `${fromLabel}|${toLabel}`;
|
||||
assertDeclaredPair(pairKey, this.declaredPairs);
|
||||
const ws = this.streams.get(pairKey);
|
||||
if (ws === undefined) {
|
||||
// First edge for this pair: open the stream, write header + row.
|
||||
|
|
|
|||
|
|
@ -316,6 +316,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
|||
FROM Class TO \`Namespace\`,
|
||||
FROM Class TO \`Typedef\`,
|
||||
FROM Class TO \`Property\`,
|
||||
FROM Class TO CodeElement,
|
||||
FROM Method TO Function,
|
||||
FROM Method TO Method,
|
||||
FROM Method TO Class,
|
||||
|
|
@ -331,6 +332,8 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
|||
FROM Method TO Interface,
|
||||
FROM Method TO \`Constructor\`,
|
||||
FROM Method TO \`Property\`,
|
||||
FROM Method TO \`Variable\`,
|
||||
FROM Method TO \`Const\`,
|
||||
FROM Method TO CodeElement,
|
||||
FROM \`Template\` TO \`Template\`,
|
||||
FROM \`Template\` TO Function,
|
||||
|
|
@ -357,6 +360,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
|||
FROM Interface TO Method,
|
||||
FROM Interface TO Class,
|
||||
FROM Interface TO Interface,
|
||||
FROM Interface TO CodeElement,
|
||||
FROM Interface TO \`TypeAlias\`,
|
||||
FROM Interface TO \`Struct\`,
|
||||
FROM Interface TO \`Constructor\`,
|
||||
|
|
@ -375,6 +379,12 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
|||
FROM \`Enum\` TO Community,
|
||||
FROM \`Enum\` TO Class,
|
||||
FROM \`Enum\` TO Interface,
|
||||
FROM \`Enum\` TO Function,
|
||||
FROM \`Enum\` TO Method,
|
||||
FROM \`Enum\` TO \`Struct\`,
|
||||
FROM \`Enum\` TO \`Constructor\`,
|
||||
FROM \`Enum\` TO \`Property\`,
|
||||
FROM \`Enum\` TO \`TypeAlias\`,
|
||||
FROM \`Macro\` TO Community,
|
||||
FROM \`Macro\` TO Function,
|
||||
FROM \`Macro\` TO Method,
|
||||
|
|
@ -385,10 +395,12 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
|||
FROM \`Namespace\` TO Community,
|
||||
FROM \`Namespace\` TO \`Struct\`,
|
||||
FROM \`Trait\` TO Method,
|
||||
FROM \`Trait\` TO Function,
|
||||
FROM \`Trait\` TO \`Constructor\`,
|
||||
FROM \`Trait\` TO \`Property\`,
|
||||
FROM \`Trait\` TO Community,
|
||||
FROM \`Impl\` TO Method,
|
||||
FROM \`Impl\` TO Function,
|
||||
FROM \`Impl\` TO \`Constructor\`,
|
||||
FROM \`Impl\` TO \`Property\`,
|
||||
FROM \`Impl\` TO Community,
|
||||
|
|
@ -399,10 +411,16 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
|||
FROM \`TypeAlias\` TO \`Trait\`,
|
||||
FROM \`TypeAlias\` TO Class,
|
||||
FROM \`Const\` TO Community,
|
||||
FROM \`Const\` TO Method,
|
||||
FROM \`Static\` TO Community,
|
||||
FROM \`Variable\` TO Community,
|
||||
FROM \`Variable\` TO Method,
|
||||
FROM \`Property\` TO Community,
|
||||
FROM \`Property\` TO \`Property\`,
|
||||
FROM \`Property\` TO Class,
|
||||
FROM \`Property\` TO \`Enum\`,
|
||||
FROM \`Property\` TO Function,
|
||||
FROM \`Property\` TO \`Struct\`,
|
||||
FROM \`Record\` TO Method,
|
||||
FROM \`Record\` TO \`Constructor\`,
|
||||
FROM \`Record\` TO \`Property\`,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import {
|
|||
deleteAllInterprocTaintPaths,
|
||||
deleteAllCallSummaries,
|
||||
deleteAllInjects,
|
||||
deleteAllAdvisedBy,
|
||||
deleteSpringAopEvidenceNodes,
|
||||
deleteSpringAutoConfigurationDeclarations,
|
||||
deleteSpringAutoConfigurationSyntheticClasses,
|
||||
queryImportersBatch,
|
||||
|
|
@ -139,7 +141,9 @@ import { sanitizeDetectedBranch } from '../cli/analyze-config.js';
|
|||
import { EMBEDDING_TABLE_NAME } from './lbug/schema.js';
|
||||
import { STALE_HASH_SENTINEL } from './lbug/schema.js';
|
||||
import { isSpringBeanCandidateSourceFile } from './ingestion/frameworks/spring/bean-catalog.js';
|
||||
import { isSpringBeanFactoryDeclaration } from './ingestion/frameworks/spring/bean-factories.js';
|
||||
import {
|
||||
SPRING_AOP_FEATURE,
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
} from './ingestion/frameworks/spring/analysis-features.js';
|
||||
|
|
@ -157,6 +161,7 @@ import {
|
|||
|
||||
const ANALYSIS_FEATURES = [
|
||||
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
|
||||
SPRING_AOP_FEATURE,
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||
|
|
@ -167,6 +172,12 @@ interface PersistedFrameworkAnnotationRow {
|
|||
readonly frameworkAnnotations?: unknown;
|
||||
}
|
||||
|
||||
interface PersistedSpringBeanDeclarationRow {
|
||||
readonly id?: unknown;
|
||||
readonly filePath?: unknown;
|
||||
readonly reason?: unknown;
|
||||
}
|
||||
|
||||
function stringList(value: unknown): readonly string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
|
|
@ -200,6 +211,45 @@ function collectFrameworkAnnotationDriftFiles(
|
|||
return driftFiles;
|
||||
}
|
||||
|
||||
function collectSpringBeanDeclarationDriftFiles(
|
||||
graph: KnowledgeGraph,
|
||||
persistedRows: readonly PersistedSpringBeanDeclarationRow[],
|
||||
): Set<string> {
|
||||
const persisted = new Map<string, { readonly filePath: string; readonly reason: string }>();
|
||||
for (const row of persistedRows) {
|
||||
if (
|
||||
typeof row.id === 'string' &&
|
||||
typeof row.filePath === 'string' &&
|
||||
typeof row.reason === 'string' &&
|
||||
isSpringBeanFactoryDeclaration({ type: 'DECLARES', reason: row.reason })
|
||||
) {
|
||||
persisted.set(row.id, { filePath: row.filePath, reason: row.reason });
|
||||
}
|
||||
}
|
||||
|
||||
const current = new Map<string, { readonly filePath: string; readonly reason: string }>();
|
||||
for (const relationship of graph.relationships) {
|
||||
if (relationship.type !== 'DECLARES') continue;
|
||||
if (!isSpringBeanFactoryDeclaration(relationship)) continue;
|
||||
const declaration = graph.getNode(relationship.targetId);
|
||||
if (declaration === undefined || typeof declaration.properties.filePath !== 'string') continue;
|
||||
current.set(declaration.id, {
|
||||
filePath: declaration.properties.filePath,
|
||||
reason: relationship.reason,
|
||||
});
|
||||
}
|
||||
|
||||
const driftFiles = new Set<string>();
|
||||
for (const [id, value] of current) {
|
||||
const prior = persisted.get(id);
|
||||
if (prior === undefined || prior.reason !== value.reason) driftFiles.add(value.filePath);
|
||||
}
|
||||
for (const [id, value] of persisted) {
|
||||
if (!current.has(id)) driftFiles.add(value.filePath);
|
||||
}
|
||||
return driftFiles;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -985,6 +1035,47 @@ async function runFullAnalysisInner(
|
|||
);
|
||||
}
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
// #2767: stamp ONLY capabilities.fts so a long-lived MCP session's
|
||||
// ensureInitialized() has an explicit, correctly-scoped signal that FTS
|
||||
// changed — indexedAt/lastCommit/runnerIdentity/stats are copied through
|
||||
// untouched (see the "must not claim a new analyzer identity" comment
|
||||
// below). capabilities is forensic/no-programmatic-readers-until-now, so
|
||||
// graph/vectorSearch are backfilled with conservative, honest defaults
|
||||
// when a legacy meta.json predates this field entirely — repair-fts
|
||||
// never touched them and cannot claim a capability it did not verify.
|
||||
// Best-effort: a write failure must not turn an already-successful FTS
|
||||
// rebuild into a reported repair failure.
|
||||
try {
|
||||
// Re-read the on-disk meta immediately before writing, rather than
|
||||
// reusing `existingMeta` (captured before the FTS rebuild ran, which
|
||||
// can span real wall-clock time). Another writer to this same
|
||||
// gitnexus.json in the interim — e.g. the HTTP server's background
|
||||
// embedding-checkpoint job — must not have its update silently
|
||||
// reverted by this stamp overwriting a stale snapshot. Falls back to
|
||||
// `existingMeta` only if the file became unreadable in that window.
|
||||
const latestMeta = (await loadMeta(metaDir)) ?? existingMeta;
|
||||
await saveMeta(metaDir, {
|
||||
...latestMeta,
|
||||
capabilities: {
|
||||
graph: latestMeta.capabilities?.graph ?? {
|
||||
provider: 'ladybugdb',
|
||||
status: 'available',
|
||||
},
|
||||
fts: { provider: 'ladybugdb-fts', status: 'available' },
|
||||
vectorSearch: latestMeta.capabilities?.vectorSearch ?? {
|
||||
provider: 'exact-scan',
|
||||
status: 'unavailable',
|
||||
exactScanLimit: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
log(
|
||||
`FTS capability stamp write failed (non-critical, repair itself succeeded${
|
||||
err instanceof Error ? `: ${err.message}` : ''
|
||||
}); continuing.`,
|
||||
);
|
||||
}
|
||||
progress('fts', 90, 'Search indexes ready');
|
||||
progress('done', 100, 'Done');
|
||||
return {
|
||||
|
|
@ -1855,6 +1946,23 @@ async function runFullAnalysisInner(
|
|||
'framework annotation property drift',
|
||||
);
|
||||
}
|
||||
|
||||
const persistedSpringBeanDeclarations = (await executeQuery(
|
||||
'MATCH (m:Method)-[r:CodeRelation]->(b:CodeElement) ' +
|
||||
"WHERE r.type = 'DECLARES' AND r.reason STARTS WITH 'spring-bean-factory:' " +
|
||||
'RETURN b.id AS id, b.filePath AS filePath, r.reason AS reason',
|
||||
)) as PersistedSpringBeanDeclarationRow[];
|
||||
const springBeanDeclarationDriftFiles = collectSpringBeanDeclarationDriftFiles(
|
||||
pipelineResult.graph,
|
||||
persistedSpringBeanDeclarations,
|
||||
);
|
||||
for (const filePath of springBeanDeclarationDriftFiles) effectiveWriteSet.add(filePath);
|
||||
if (springBeanDeclarationDriftFiles.size > 0) {
|
||||
log(
|
||||
`Incremental: +${springBeanDeclarationDriftFiles.size} file(s) added for ` +
|
||||
'Spring Bean factory declaration drift',
|
||||
);
|
||||
}
|
||||
}
|
||||
// Deduped: deleted entries may already appear via importer-BFS
|
||||
// expansion (the importer BFS can return a now-deleted path), which
|
||||
|
|
@ -2017,16 +2125,21 @@ async function runFullAnalysisInner(
|
|||
// deleting on every non-pdg incremental run (N runs = N copies of
|
||||
// every INJECTS row; CodeRelation has no PK and no read-side dedup).
|
||||
await deleteAllInjects();
|
||||
// 2b. Drop Spring-owned DECLARES edges (#2415). The
|
||||
// 2b. Spring AOP pointcuts are matched against the full resolved graph;
|
||||
// a third-file change can invalidate an edge between unchanged files.
|
||||
// Rebuild the complete ADVISED_BY set on every incremental writeback.
|
||||
await deleteAllAdvisedBy();
|
||||
await deleteSpringAopEvidenceNodes();
|
||||
// 2c. Drop Spring-owned DECLARES edges (#2415). The
|
||||
// auto-configuration phase scans every metadata file and recomputes
|
||||
// the full set each run; exact reason filtering leaves declarations
|
||||
// owned by other metadata systems untouched.
|
||||
await deleteSpringAutoConfigurationDeclarations();
|
||||
// 2c. Drop source-unavailable auto-configuration placeholders. Fresh
|
||||
// 2d. Drop source-unavailable auto-configuration placeholders. Fresh
|
||||
// synthetic nodes are graph-wide in extractChangedSubgraph, so this
|
||||
// also removes an orphan when a newly-added real class takes over.
|
||||
await deleteSpringAutoConfigurationSyntheticClasses();
|
||||
// 2d. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on
|
||||
// 2e. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on
|
||||
// — their validity is a whole-program property (an A→C flow can be
|
||||
// invalidated by a change to an intermediate function on a third
|
||||
// file), so endpoint-writability extraction can't refresh them.
|
||||
|
|
@ -2034,7 +2147,7 @@ async function runFullAnalysisInner(
|
|||
// graph (isGraphWideRelType), mirroring Community/Process.
|
||||
if (options.pdg === true) {
|
||||
await deleteAllInterprocTaintPaths();
|
||||
// 2e. Drop CALL_SUMMARY edges (PDG FU-C) on an incremental `--pdg`
|
||||
// 2f. Drop CALL_SUMMARY edges (PDG FU-C) on an incremental `--pdg`
|
||||
// writeback. They are re-included from the FULL fresh graph
|
||||
// (isGraphWideRelType) and the callSummaries phase recomputes every
|
||||
// summary each run, so delete-all-then-rebuild keeps an unchanged
|
||||
|
|
|
|||
|
|
@ -5,8 +5,14 @@
|
|||
* Always reads from the database (no cached state to drift).
|
||||
*/
|
||||
|
||||
import { queryFTS } from '../lbug/lbug-adapter.js';
|
||||
// tri-review Residual-1: `classifyFtsQueryError` now lives in lbug-adapter.ts
|
||||
// (see its doc comment) so `queryFTS`'s own catch can share the SAME
|
||||
// classifier instead of maintaining a second, independently-drifting copy
|
||||
// for the identical `QUERY_FTS_INDEX` cypher call.
|
||||
import { queryFTS, classifyFtsQueryError } from '../lbug/lbug-adapter.js';
|
||||
import { normalizeFtsText } from '../lbug/csv-generator.js';
|
||||
import { getExtensionCapabilities } from '../lbug/extension-loader.js';
|
||||
import { redactPaths } from './fts-indexes.js';
|
||||
import { FTS_INDEXES } from './fts-schema.js';
|
||||
import {
|
||||
applyCjkSegmentationIfEnabled,
|
||||
|
|
@ -24,12 +30,40 @@ export interface FTSSearchResponse {
|
|||
results: BM25SearchResult[];
|
||||
/** True when at least one FTS index query succeeded (index exists). */
|
||||
ftsAvailable: boolean;
|
||||
/**
|
||||
* Redacted (via {@link redactPaths}) message(s) from per-table
|
||||
* `QUERY_FTS_INDEX` calls that failed for a reason OTHER than "index
|
||||
* doesn't exist" (#2767) — a real query/connection error was previously
|
||||
* indistinguishable from a genuinely-missing index. Populated whenever ANY
|
||||
* table hit a non-benign error, regardless of whether other tables
|
||||
* succeeded, so a caller can always log it; whether to also surface it in
|
||||
* a client-facing warning is a caller decision (see `LocalBackend.query()`,
|
||||
* which only does so when every table failed).
|
||||
*/
|
||||
nonBenignErrors?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional-field shape rather than a discriminated union: this project builds
|
||||
* with `strict: false` (no `strictNullChecks`), under which TypeScript's
|
||||
* control-flow narrowing across an `if/else` on a boolean discriminant is
|
||||
* unreliable (verified empirically — narrows correctly under `strict: true`,
|
||||
* fails under `strict: false`). `rows` present means success; `rows` absent
|
||||
* means failure, with `benign`/`message` describing why.
|
||||
*/
|
||||
interface FTSQueryOutcome {
|
||||
rows?: Array<{ filePath: string; score: number; nodeId: string }>;
|
||||
benign?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single FTS query via a custom executor (for MCP connection pool).
|
||||
* Returns `null` when the query fails (e.g. FTS index does not exist) so the
|
||||
* caller can distinguish "zero matches" from "index missing".
|
||||
* Returns a benign failure when the query fails because the index doesn't
|
||||
* exist (the normal, expected case), and a non-benign failure with the
|
||||
* captured message for any other error, so the caller can distinguish "zero
|
||||
* matches", "index missing", and "a real error occurred" instead of
|
||||
* collapsing the latter two into the same silent `null`.
|
||||
*/
|
||||
async function queryFTSViaExecutor(
|
||||
executor: (cypher: string, params: Record<string, any>) => Promise<any[]>,
|
||||
|
|
@ -37,7 +71,7 @@ async function queryFTSViaExecutor(
|
|||
indexName: string,
|
||||
query: string,
|
||||
limit: number,
|
||||
): Promise<Array<{ filePath: string; score: number; nodeId: string }> | null> {
|
||||
): Promise<FTSQueryOutcome> {
|
||||
const cypher = `
|
||||
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := false)
|
||||
RETURN node, score
|
||||
|
|
@ -46,17 +80,20 @@ async function queryFTSViaExecutor(
|
|||
`;
|
||||
try {
|
||||
const rows = await executor(cypher, { query });
|
||||
return rows.map((row: any) => {
|
||||
const node = row.node || row[0] || {};
|
||||
const score = row.score ?? row[1] ?? 0;
|
||||
return {
|
||||
filePath: node.filePath || '',
|
||||
score: typeof score === 'number' ? score : parseFloat(score) || 0,
|
||||
nodeId: node.nodeId || node.id || '',
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
return {
|
||||
rows: rows.map((row: any) => {
|
||||
const node = row.node || row[0] || {};
|
||||
const score = row.score ?? row[1] ?? 0;
|
||||
return {
|
||||
filePath: node.filePath || '',
|
||||
score: typeof score === 'number' ? score : parseFloat(score) || 0,
|
||||
nodeId: node.nodeId || node.id || '',
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return { benign: classifyFtsQueryError(message) === 'missing-index', message };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,8 +132,23 @@ export const searchFTSFromLbug = async (
|
|||
);
|
||||
const resultsByIndex: any[][] = [];
|
||||
let queriesSucceeded = 0;
|
||||
const nonBenignErrors: string[] = [];
|
||||
|
||||
if (repoId) {
|
||||
const ftsExtension = getExtensionCapabilities().find((c) => c.name === 'fts');
|
||||
if (ftsExtension && !ftsExtension.loaded) {
|
||||
// tri-review NEW-4 (applies to BOTH the MCP pool path and the CLI/pipeline
|
||||
// path — a /simplify altitude pass caught the original repoId-only guard
|
||||
// letting the CLI branch surface spurious "non-benign" errors for this
|
||||
// exact expected state, which the pool branch correctly stayed silent on):
|
||||
// extension-unavailable is an expected, already-diagnosed degraded-capability
|
||||
// state (#2374/#2658), not a per-table query error — every configured table
|
||||
// would throw the identical "function not defined" shape, which
|
||||
// classifyFtsQueryError correctly refuses to call benign "missing-index"
|
||||
// (it's a different, more serious condition). Skip the N redundant
|
||||
// QUERY_FTS_INDEX round-trips and N nonBenignErrors entries; ftsAvailable
|
||||
// stays false and ftsDegradedWarning() already reports this state
|
||||
// accurately from the same extension-capabilities registry.
|
||||
} else if (repoId) {
|
||||
// Use MCP connection pool via dynamic import
|
||||
// IMPORTANT: FTS queries run sequentially to avoid connection contention.
|
||||
// The MCP pool supports multiple connections, but FTS is best run serially.
|
||||
|
|
@ -106,21 +158,28 @@ export const searchFTSFromLbug = async (
|
|||
executeParameterized(repoId, cypher, params);
|
||||
|
||||
for (const { table, indexName } of FTS_INDEXES) {
|
||||
const result = await queryFTSViaExecutor(executor, table, indexName, searchQuery, limit);
|
||||
if (result !== null) {
|
||||
const outcome = await queryFTSViaExecutor(executor, table, indexName, searchQuery, limit);
|
||||
if (outcome.rows) {
|
||||
queriesSucceeded++;
|
||||
resultsByIndex.push(result);
|
||||
resultsByIndex.push(outcome.rows);
|
||||
} else if (!outcome.benign) {
|
||||
nonBenignErrors.push(redactPaths(outcome.message ?? 'Unknown FTS query error'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use core lbug adapter (CLI / pipeline context) — also sequential for safety.
|
||||
// tri-review Residual-1: `queryFTS` itself only swallows a genuinely-missing
|
||||
// index (via the SAME classifyFtsQueryError this module re-exports); a
|
||||
// missing-table or real query error rethrows here — track it the same way
|
||||
// the MCP pool path does instead of a bare `catch {}` that dropped it.
|
||||
for (const { table, indexName } of FTS_INDEXES) {
|
||||
try {
|
||||
const result = await queryFTS(table, indexName, searchQuery, limit, false);
|
||||
queriesSucceeded++;
|
||||
resultsByIndex.push(result);
|
||||
} catch {
|
||||
// FTS index may not exist — count as failed
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
nonBenignErrors.push(redactPaths(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -165,5 +224,6 @@ export const searchFTSFromLbug = async (
|
|||
nodeIds: r.nodeIds,
|
||||
})),
|
||||
ftsAvailable,
|
||||
...(nonBenignErrors.length > 0 && { nonBenignErrors }),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,17 +11,62 @@ import { FTS_INDEXES } from './fts-schema.js';
|
|||
* ELF header", "has not been installed") have no leading path separator and
|
||||
* survive. CLI/doctor/log surfaces keep the full path (they read the reason
|
||||
* directly, not through this function).
|
||||
*
|
||||
* tri-review Residual-3: every real message shape observed from LadybugDB
|
||||
* wraps the path in single quotes (`Failed to load library '<path>': ...`),
|
||||
* so a QUOTED path is redacted first, consuming through its closing quote —
|
||||
* spaces included (e.g. a Windows username like `alice smith`). The original
|
||||
* unquoted-stop-at-first-whitespace pattern still runs afterward as a
|
||||
* fallback for the rare case of a path appearing without quotes; that path's
|
||||
* own known limitation (partial redaction if it itself contains a space) is
|
||||
* unchanged, but is no longer the ONLY path this function knows how to redact.
|
||||
*/
|
||||
const redactPaths = (reason: string): string =>
|
||||
reason.replace(/(?:[A-Za-z]:\\|\/)[^\s'"]+/g, '<path>');
|
||||
export const redactPaths = (reason: string): string =>
|
||||
reason
|
||||
.replace(/'((?:[A-Za-z]:\\|\/)[^']*)'/g, "'<path>'")
|
||||
.replace(/(?:[A-Za-z]:\\|\/)[^\s'"]+/g, '<path>');
|
||||
|
||||
/**
|
||||
* Resolved-repo/index identity a caller can attach to a degraded-FTS warning
|
||||
* (#2767) so a reader can tell whether *this* session even resolved the index
|
||||
* they expect, instead of guessing between a stale connection, a different
|
||||
* repo/branch, or a genuine build failure. MCP-`query`-only today — never
|
||||
* forwarded into the HTTP `/api/search` response (see that call site).
|
||||
*/
|
||||
export interface FtsWarningContext {
|
||||
repoName: string;
|
||||
branch?: string;
|
||||
indexedAt?: string;
|
||||
/** Already redacted by the caller (e.g. via {@link redactPaths} on a captured query error). */
|
||||
lastErrorRedacted?: string;
|
||||
}
|
||||
|
||||
/** The repo/branch/indexed-at portion shared by both warning-context formatters below. */
|
||||
const formatResolvedSuffix = (context: FtsWarningContext): string => {
|
||||
const branchSuffix = context.branch ? `/branch:${context.branch}` : '';
|
||||
const indexedSuffix = context.indexedAt ? `, indexed ${context.indexedAt}` : '';
|
||||
return `${context.repoName}${branchSuffix}${indexedSuffix}`;
|
||||
};
|
||||
|
||||
const formatWarningContext = (context: FtsWarningContext): string => {
|
||||
const errorSuffix = context.lastErrorRedacted ? `; last error: ${context.lastErrorRedacted}` : '';
|
||||
return ` (resolved: ${formatResolvedSuffix(context)}${errorSuffix})`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Warning attached to search responses when BM25/FTS is degraded. Prefers the
|
||||
* live extension-load failure (with LadybugDB's real reason, #2374) over the
|
||||
* generic indexes-missing message, so "indexes exist but the extension broke"
|
||||
* is not misreported as missing indexes.
|
||||
*
|
||||
* `context`, when supplied, appends the resolved repo/branch/indexed-at (and
|
||||
* redacted query-error detail, if captured) so a CLI/MCP mismatch — or a real
|
||||
* query error masquerading as "indexes missing" — is visible in the warning
|
||||
* text itself (#2767). Optional and additive: omitting it reproduces today's
|
||||
* exact message.
|
||||
*/
|
||||
export const ftsDegradedWarning = (): string => {
|
||||
export const ftsDegradedWarning = (context?: FtsWarningContext): string => {
|
||||
const suffix = context ? formatWarningContext(context) : '';
|
||||
const fts = getExtensionCapabilities().find((c) => c.name === 'fts');
|
||||
if (fts && !fts.loaded) {
|
||||
const reason = fts.reason ? redactPaths(fts.reason).replace(/\.$/, '') : undefined;
|
||||
|
|
@ -38,12 +83,33 @@ export const ftsDegradedWarning = (): string => {
|
|||
return (
|
||||
'FTS extension failed to load — keyword search degraded' +
|
||||
(reason ? ` (${reason})` : '') +
|
||||
tail
|
||||
tail +
|
||||
suffix
|
||||
);
|
||||
}
|
||||
return 'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.';
|
||||
return (
|
||||
'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts ' +
|
||||
'(or gitnexus analyze --force) to rebuild indexes.' +
|
||||
suffix
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Warning for when the FTS extension is loaded and indexes exist, but every
|
||||
* configured table's query failed for a real, non-benign reason (timeout,
|
||||
* connection reset, native fault) — as opposed to `ftsDegradedWarning`'s
|
||||
* missing-index case. `--repair-fts` will not fix a query/connection error,
|
||||
* so this deliberately does NOT suggest it: reusing the missing-index
|
||||
* message here would reproduce, for this cause, the exact misleading
|
||||
* "run --repair-fts" guidance #2767 itself was about (tri-review NEW-1).
|
||||
*/
|
||||
export const ftsQueryFailedWarning = (context: FtsWarningContext): string =>
|
||||
'FTS keyword search failed — every configured index query returned an error' +
|
||||
(context.lastErrorRedacted ? ` (${context.lastErrorRedacted})` : '') +
|
||||
'; results do not include keyword matches. This is not a missing-index ' +
|
||||
'condition — see server logs for details.' +
|
||||
` (resolved: ${formatResolvedSuffix(context)})`;
|
||||
|
||||
// Stemmers shipped by the LadybugDB FTS extension. Mirrors the lowercase token
|
||||
// set in the extension bundled with @ladybugdb/core 0.18.x (see package.json).
|
||||
// Keep in sync on a LadybugDB minor bump — a value here that the installed
|
||||
|
|
|
|||
352
gitnexus/src/mcp/local/aop-metadata.ts
Normal file
352
gitnexus/src/mcp/local/aop-metadata.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
import { executeParameterized } from '../../core/lbug/pool-adapter.js';
|
||||
import {
|
||||
decodeSpringAopReason,
|
||||
type SpringAopReason,
|
||||
} from '../../core/ingestion/frameworks/spring/aop.js';
|
||||
|
||||
type SpringAopBehaviorReason = Extract<SpringAopReason, { kind: 'behavior' }>;
|
||||
type SpringAopAdviceReason = Extract<SpringAopReason, { kind: 'advice' }>;
|
||||
type SpringAopAspectReason = Extract<SpringAopReason, { kind: 'aspect' }>;
|
||||
type SpringAopPointcutReason = Extract<SpringAopReason, { kind: 'pointcut' }>;
|
||||
|
||||
export interface SpringAopBehaviorMetadata {
|
||||
readonly annotation: string;
|
||||
readonly behavior: SpringAopBehaviorReason['behavior'];
|
||||
readonly declaredOn: SpringAopBehaviorReason['declaredOn'];
|
||||
readonly activation: SpringAopBehaviorReason['activation'];
|
||||
readonly evidenceId: string;
|
||||
}
|
||||
|
||||
export interface SpringAopAdviceMetadata {
|
||||
readonly annotation: string;
|
||||
readonly advice: SpringAopAdviceReason['advice'];
|
||||
readonly pointcut: string;
|
||||
readonly match: SpringAopAdviceReason['match'];
|
||||
readonly activation: SpringAopAdviceReason['activation'];
|
||||
readonly adviceId: string;
|
||||
readonly adviceName?: string;
|
||||
readonly adviceFilePath?: string;
|
||||
readonly advisedId: string;
|
||||
readonly advisedName?: string;
|
||||
readonly advisedFilePath?: string;
|
||||
}
|
||||
|
||||
export interface SpringAopUnresolvedPointcutMetadata {
|
||||
readonly annotation: string;
|
||||
readonly pointcut: string | null;
|
||||
readonly adviceId: string;
|
||||
readonly adviceName?: string;
|
||||
readonly adviceFilePath?: string;
|
||||
readonly evidenceId: string;
|
||||
}
|
||||
|
||||
export interface SpringAopResolvedPointcutMetadata {
|
||||
readonly annotation: string;
|
||||
readonly pointcut: string;
|
||||
readonly match: Extract<SpringAopPointcutReason['match'], 'static'>;
|
||||
readonly resolution: Extract<SpringAopPointcutReason['resolution'], 'resolved'>;
|
||||
readonly adviceId: string;
|
||||
readonly adviceName?: string;
|
||||
readonly adviceFilePath?: string;
|
||||
readonly evidenceId: string;
|
||||
}
|
||||
|
||||
export interface SpringAopAspectMetadata {
|
||||
readonly annotation: string;
|
||||
readonly activation: SpringAopAspectReason['activation'];
|
||||
readonly registration: SpringAopAspectReason['registration'];
|
||||
readonly evidenceId: string;
|
||||
}
|
||||
|
||||
export interface SpringAopMetadata {
|
||||
readonly framework: 'spring';
|
||||
readonly proxied?: 'possible';
|
||||
readonly truncated?: true;
|
||||
readonly aspect?: SpringAopAspectMetadata;
|
||||
readonly behaviors: readonly SpringAopBehaviorMetadata[];
|
||||
readonly advices: readonly SpringAopAdviceMetadata[];
|
||||
readonly resolvedPointcuts: readonly SpringAopResolvedPointcutMetadata[];
|
||||
readonly unresolvedPointcuts: readonly SpringAopUnresolvedPointcutMetadata[];
|
||||
}
|
||||
|
||||
interface RelationshipRow {
|
||||
readonly sourceId: string;
|
||||
readonly sourceName?: string;
|
||||
readonly sourceFilePath?: string;
|
||||
readonly targetId: string;
|
||||
readonly targetName?: string;
|
||||
readonly targetFilePath?: string;
|
||||
readonly reason: unknown;
|
||||
}
|
||||
|
||||
const SUPPORTED_SYMBOL_TYPES = new Set(['Class', 'Interface', 'Method', 'CodeElement']);
|
||||
const QUERY_RESULT_LIMIT = 1_000;
|
||||
const QUERY_FETCH_LIMIT = QUERY_RESULT_LIMIT + 1;
|
||||
|
||||
function readRowValue(row: unknown, name: string, index: number): unknown {
|
||||
if (typeof row === 'object' && row !== null && name in row) {
|
||||
return (row as Record<string, unknown>)[name];
|
||||
}
|
||||
return Array.isArray(row) ? row[index] : undefined;
|
||||
}
|
||||
|
||||
function readRequiredString(row: unknown, name: string, index: number): string | undefined {
|
||||
const value = readRowValue(row, name, index);
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function readOptionalString(row: unknown, name: string, index: number): string | undefined {
|
||||
const value = readRowValue(row, name, index);
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeRelationshipRow(row: unknown): RelationshipRow | undefined {
|
||||
const sourceId = readRequiredString(row, 'sourceId', 0);
|
||||
const targetId = readRequiredString(row, 'targetId', 3);
|
||||
if (sourceId === undefined || targetId === undefined) return undefined;
|
||||
|
||||
const sourceName = readOptionalString(row, 'sourceName', 1);
|
||||
const sourceFilePath = readOptionalString(row, 'sourceFilePath', 2);
|
||||
const targetName = readOptionalString(row, 'targetName', 4);
|
||||
const targetFilePath = readOptionalString(row, 'targetFilePath', 5);
|
||||
|
||||
return {
|
||||
sourceId,
|
||||
...optionalField('sourceName', sourceName),
|
||||
...optionalField('sourceFilePath', sourceFilePath),
|
||||
targetId,
|
||||
...optionalField('targetName', targetName),
|
||||
...optionalField('targetFilePath', targetFilePath),
|
||||
reason: readRowValue(row, 'reason', 6),
|
||||
};
|
||||
}
|
||||
|
||||
function optionalField<const Key extends string>(
|
||||
key: Key,
|
||||
value: string | undefined,
|
||||
): { readonly [K in Key]?: string } {
|
||||
return value === undefined ? {} : ({ [key]: value } as { readonly [K in Key]?: string });
|
||||
}
|
||||
|
||||
function stableDedupe<T>(values: readonly T[], keyOf: (value: T) => string): T[] {
|
||||
const unique = new Map<string, T>();
|
||||
for (const value of values) unique.set(keyOf(value), value);
|
||||
return [...unique.entries()]
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([, value]) => value);
|
||||
}
|
||||
|
||||
const RELATIONSHIP_PROJECTION = `
|
||||
RETURN source.id AS sourceId, source.name AS sourceName, source.filePath AS sourceFilePath,
|
||||
target.id AS targetId, target.name AS targetName, target.filePath AS targetFilePath,
|
||||
r.reason AS reason, r.step AS step
|
||||
`;
|
||||
|
||||
const DETERMINISTIC_RELATIONSHIP_ORDER = 'ORDER BY sourceId, targetId, reason, step';
|
||||
|
||||
/**
|
||||
* Read additive Spring proxy/advice metadata for context and impact results.
|
||||
*
|
||||
* The helper intentionally trusts only versioned reasons accepted by the
|
||||
* shared decoder. Other DECLARES edges (for example Spring Bean factories)
|
||||
* and malformed/forward-version evidence are ignored. Query failures are
|
||||
* fail-soft because older or partially upgraded indexes must remain readable.
|
||||
*/
|
||||
export async function querySpringAopMetadata(
|
||||
lbugPath: string,
|
||||
symbolId: string,
|
||||
symbolType: string,
|
||||
): Promise<SpringAopMetadata | undefined> {
|
||||
if (!SUPPORTED_SYMBOL_TYPES.has(symbolType)) return undefined;
|
||||
|
||||
try {
|
||||
const [outgoingAdviceRows, incomingAdviceRows, outgoingPointcutRows, incomingPointcutRows] =
|
||||
await Promise.all([
|
||||
executeParameterized(
|
||||
lbugPath,
|
||||
`MATCH (source {id: $symbolId})-[r:CodeRelation]->(target)
|
||||
WHERE r.type = 'ADVISED_BY'
|
||||
AND r.reason STARTS WITH 'spring-aop:v1:'
|
||||
${RELATIONSHIP_PROJECTION}
|
||||
${DETERMINISTIC_RELATIONSHIP_ORDER}
|
||||
LIMIT 1001`,
|
||||
{ symbolId },
|
||||
),
|
||||
executeParameterized(
|
||||
lbugPath,
|
||||
`MATCH (source)-[r:CodeRelation]->(target {id: $symbolId})
|
||||
WHERE r.type = 'ADVISED_BY'
|
||||
AND r.reason STARTS WITH 'spring-aop:v1:'
|
||||
${RELATIONSHIP_PROJECTION}
|
||||
${DETERMINISTIC_RELATIONSHIP_ORDER}
|
||||
LIMIT 1001`,
|
||||
{ symbolId },
|
||||
),
|
||||
executeParameterized(
|
||||
lbugPath,
|
||||
`MATCH (source {id: $symbolId})-[r:CodeRelation]->(target:CodeElement)
|
||||
WHERE r.type = 'DECLARES'
|
||||
AND r.reason STARTS WITH 'spring-aop:v1:'
|
||||
${RELATIONSHIP_PROJECTION}
|
||||
${DETERMINISTIC_RELATIONSHIP_ORDER}
|
||||
LIMIT 1001`,
|
||||
{ symbolId },
|
||||
),
|
||||
executeParameterized(
|
||||
lbugPath,
|
||||
`MATCH (source)-[r:CodeRelation]->(target:CodeElement {id: $symbolId})
|
||||
WHERE r.type = 'DECLARES'
|
||||
AND r.reason STARTS WITH 'spring-aop:v1:'
|
||||
${RELATIONSHIP_PROJECTION}
|
||||
${DETERMINISTIC_RELATIONSHIP_ORDER}
|
||||
LIMIT 1001`,
|
||||
{ symbolId },
|
||||
),
|
||||
]);
|
||||
|
||||
const behaviors: SpringAopBehaviorMetadata[] = [];
|
||||
const advices: SpringAopAdviceMetadata[] = [];
|
||||
const resolvedPointcuts: SpringAopResolvedPointcutMetadata[] = [];
|
||||
const unresolvedPointcuts: SpringAopUnresolvedPointcutMetadata[] = [];
|
||||
const aspects: SpringAopAspectMetadata[] = [];
|
||||
const truncated = [
|
||||
outgoingAdviceRows,
|
||||
incomingAdviceRows,
|
||||
outgoingPointcutRows,
|
||||
incomingPointcutRows,
|
||||
].some((rows) => rows.length >= QUERY_FETCH_LIMIT);
|
||||
let queriedSymbolIsAdvisedSource = false;
|
||||
|
||||
for (const [rows, isOutgoing] of [
|
||||
[outgoingAdviceRows, true],
|
||||
[incomingAdviceRows, false],
|
||||
] as const) {
|
||||
for (const rawRow of rows.slice(0, QUERY_RESULT_LIMIT)) {
|
||||
const row = normalizeRelationshipRow(rawRow);
|
||||
if (row === undefined) continue;
|
||||
const reason = decodeSpringAopReason(row.reason);
|
||||
if (reason?.kind === 'behavior') {
|
||||
if (isOutgoing) queriedSymbolIsAdvisedSource = true;
|
||||
behaviors.push({
|
||||
annotation: reason.annotation,
|
||||
behavior: reason.behavior,
|
||||
declaredOn: reason.declaredOn,
|
||||
activation: reason.activation,
|
||||
evidenceId: row.targetId,
|
||||
});
|
||||
} else if (reason?.kind === 'advice') {
|
||||
if (isOutgoing) queriedSymbolIsAdvisedSource = true;
|
||||
advices.push({
|
||||
annotation: reason.annotation,
|
||||
advice: reason.advice,
|
||||
pointcut: reason.pointcut,
|
||||
match: reason.match,
|
||||
activation: reason.activation,
|
||||
adviceId: row.targetId,
|
||||
...optionalField('adviceName', row.targetName),
|
||||
...optionalField('adviceFilePath', row.targetFilePath),
|
||||
advisedId: row.sourceId,
|
||||
...optionalField('advisedName', row.sourceName),
|
||||
...optionalField('advisedFilePath', row.sourceFilePath),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const rows of [outgoingPointcutRows, incomingPointcutRows]) {
|
||||
for (const rawRow of rows.slice(0, QUERY_RESULT_LIMIT)) {
|
||||
const row = normalizeRelationshipRow(rawRow);
|
||||
if (row === undefined) continue;
|
||||
const reason = decodeSpringAopReason(row.reason);
|
||||
if (reason?.kind === 'aspect') {
|
||||
aspects.push({
|
||||
annotation: reason.annotation,
|
||||
activation: reason.activation,
|
||||
registration: reason.registration,
|
||||
evidenceId: row.targetId,
|
||||
});
|
||||
} else if (
|
||||
reason?.kind === 'pointcut' &&
|
||||
reason.match === 'static' &&
|
||||
reason.resolution === 'resolved' &&
|
||||
typeof reason.pointcut === 'string'
|
||||
) {
|
||||
resolvedPointcuts.push({
|
||||
annotation: reason.annotation,
|
||||
pointcut: reason.pointcut,
|
||||
match: reason.match,
|
||||
resolution: reason.resolution,
|
||||
adviceId: row.sourceId,
|
||||
...optionalField('adviceName', row.sourceName),
|
||||
...optionalField('adviceFilePath', row.sourceFilePath),
|
||||
evidenceId: row.targetId,
|
||||
});
|
||||
} else if (reason?.kind === 'pointcut' && reason.match === 'unresolved') {
|
||||
unresolvedPointcuts.push({
|
||||
annotation: reason.annotation,
|
||||
pointcut: reason.pointcut,
|
||||
adviceId: row.sourceId,
|
||||
...optionalField('adviceName', row.sourceName),
|
||||
...optionalField('adviceFilePath', row.sourceFilePath),
|
||||
evidenceId: row.targetId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dedupedAspects = stableDedupe(aspects, (aspect) =>
|
||||
JSON.stringify([aspect.annotation, aspect.evidenceId]),
|
||||
);
|
||||
|
||||
const dedupedBehaviors = stableDedupe(behaviors, (behavior) =>
|
||||
JSON.stringify([
|
||||
behavior.behavior,
|
||||
behavior.annotation,
|
||||
behavior.declaredOn,
|
||||
behavior.evidenceId,
|
||||
]),
|
||||
);
|
||||
const dedupedAdvices = stableDedupe(advices, (advice) =>
|
||||
JSON.stringify([advice.advisedId, advice.adviceId, advice.advice, advice.pointcut]),
|
||||
);
|
||||
const dedupedPointcuts = stableDedupe(unresolvedPointcuts, (pointcut) =>
|
||||
JSON.stringify([
|
||||
pointcut.adviceId,
|
||||
pointcut.evidenceId,
|
||||
pointcut.annotation,
|
||||
pointcut.pointcut,
|
||||
]),
|
||||
);
|
||||
const dedupedResolvedPointcuts = stableDedupe(resolvedPointcuts, (pointcut) =>
|
||||
JSON.stringify([
|
||||
pointcut.adviceId,
|
||||
pointcut.evidenceId,
|
||||
pointcut.annotation,
|
||||
pointcut.pointcut,
|
||||
]),
|
||||
);
|
||||
|
||||
if (
|
||||
dedupedAspects.length === 0 &&
|
||||
dedupedBehaviors.length === 0 &&
|
||||
dedupedAdvices.length === 0 &&
|
||||
dedupedResolvedPointcuts.length === 0 &&
|
||||
dedupedPointcuts.length === 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
framework: 'spring',
|
||||
...(queriedSymbolIsAdvisedSource ? { proxied: 'possible' as const } : {}),
|
||||
...(truncated ? { truncated: true as const } : {}),
|
||||
...(dedupedAspects[0] === undefined ? {} : { aspect: dedupedAspects[0] }),
|
||||
behaviors: dedupedBehaviors,
|
||||
advices: dedupedAdvices,
|
||||
resolvedPointcuts: dedupedResolvedPointcuts,
|
||||
unresolvedPointcuts: dedupedPointcuts,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,15 +3,39 @@ import {
|
|||
deriveSpringBeanMetadata,
|
||||
type SpringBeanMetadata,
|
||||
} from '../../core/ingestion/frameworks/spring/bean-catalog.js';
|
||||
import {
|
||||
decodeSpringBeanFactoryReason,
|
||||
type SpringBeanFactoryMetadata,
|
||||
} from '../../core/ingestion/frameworks/spring/bean-factories.js';
|
||||
|
||||
export async function queryClassBeanMetadata(
|
||||
lbugPath: string,
|
||||
symbolId: string,
|
||||
symbolType: string,
|
||||
): Promise<SpringBeanMetadata | undefined> {
|
||||
if (symbolType !== 'Class') return undefined;
|
||||
): Promise<SpringBeanMetadata | SpringBeanFactoryMetadata | undefined> {
|
||||
if (symbolType !== 'Class' && symbolType !== 'Method' && symbolType !== 'CodeElement') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
if (symbolType !== 'Class') {
|
||||
const pattern =
|
||||
symbolType === 'Method'
|
||||
? 'MATCH (m:Method {id: $symbolId})-[r:CodeRelation]->(b:CodeElement)'
|
||||
: 'MATCH (m:Method)-[r:CodeRelation]->(b:CodeElement {id: $symbolId})';
|
||||
const rows = await executeParameterized(
|
||||
lbugPath,
|
||||
`${pattern}
|
||||
WHERE r.type = 'DECLARES'
|
||||
AND r.reason STARTS WITH 'spring-bean-factory:'
|
||||
RETURN r.reason AS reason
|
||||
LIMIT 1`,
|
||||
{ symbolId },
|
||||
);
|
||||
const row = rows[0];
|
||||
return row === undefined ? undefined : decodeSpringBeanFactoryReason(row.reason ?? row[0]);
|
||||
}
|
||||
|
||||
const rows = await executeParameterized(
|
||||
lbugPath,
|
||||
`
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
dbIdentityChanged,
|
||||
} from '../../core/lbug/pool-adapter.js';
|
||||
import { queryClassBeanMetadata } from './bean-metadata.js';
|
||||
import { querySpringAopMetadata } from './aop-metadata.js';
|
||||
import { isValidQueryParams } from '../../core/lbug/query-params.js';
|
||||
import { toDisplayLine } from './line-display.js';
|
||||
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/lbug-config.js';
|
||||
|
|
@ -63,7 +64,7 @@ import {
|
|||
import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME } from '../../core/lbug/schema.js';
|
||||
import { getExactScanLimit } from '../../core/platform/capabilities.js';
|
||||
import { PhaseTimer } from '../../core/search/phase-timer.js';
|
||||
import { ftsDegradedWarning } from '../../core/search/fts-indexes.js';
|
||||
import { ftsDegradedWarning, ftsQueryFailedWarning } from '../../core/search/fts-indexes.js';
|
||||
import {
|
||||
cjkSegmentationModeMismatch,
|
||||
containsSegmentableCjkRun,
|
||||
|
|
@ -310,6 +311,10 @@ export const VALID_RELATION_TYPES = new Set([
|
|||
// surface.
|
||||
'CONDITIONAL_ON',
|
||||
'DECLARES',
|
||||
// Spring proxy/advice evidence (#2416). Opt-in for traversal so existing
|
||||
// impact defaults do not silently widen; target enrichment still surfaces
|
||||
// advised/proxied state on ordinary impact calls.
|
||||
'ADVISED_BY',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
@ -779,6 +784,13 @@ export function attachToolStaleness(
|
|||
};
|
||||
}
|
||||
|
||||
/** tri-review Residual-2: see `LocalBackend.lastObservedPoolState`'s doc comment. */
|
||||
interface PoolObservedState {
|
||||
indexedAt?: string;
|
||||
dbIdentity: Awaited<ReturnType<typeof statDbIdentity>>;
|
||||
ftsStatus?: string;
|
||||
}
|
||||
|
||||
export class LocalBackend {
|
||||
private static readonly TOOL_STALENESS_TTL_MS = 5000;
|
||||
private repos: Map<string, RepoHandle> = new Map();
|
||||
|
|
@ -796,18 +808,30 @@ export class LocalBackend {
|
|||
// the other; lbugPath is unique per flat/branch index.
|
||||
private toolStalenessCache: Map<string, { at: number; value: Promise<StalenessInfo> }> =
|
||||
new Map();
|
||||
// Last meta.indexedAt observed for an open pool, keyed by lbugPath. Keyed by
|
||||
// pool (not stored on the handle) because branch handles are produced fresh
|
||||
// by applyBranchScope on every resolveRepo call, so mutating the handle would
|
||||
// not persist across calls and the staleness check would reinit forever
|
||||
// (#2106).
|
||||
private lastObservedIndexedAt: Map<string, string> = new Map();
|
||||
// #2614 F1: file identity of the lbug the pool last opened. An atomic swap or
|
||||
// an in-place incremental changes the inode; reiniting on that reinit-covers
|
||||
// the window where meta.indexedAt hasn't caught up (and the incremental case),
|
||||
// so a rebuilt index is never served stale even when the stamp looks current.
|
||||
private lastObservedDbIdentity: Map<string, Awaited<ReturnType<typeof statDbIdentity>>> =
|
||||
new Map();
|
||||
// tri-review Residual-2: consolidates what were three parallel per-poolKey
|
||||
// Maps (lastObservedIndexedAt / lastObservedDbIdentity / lastObservedFtsStatus)
|
||||
// touched in lockstep at every call site below — one Map, one delete, one
|
||||
// shape. Keyed by lbugPath (not stored on the repo handle) because branch
|
||||
// handles are produced fresh by applyBranchScope on every resolveRepo call,
|
||||
// so mutating the handle would not persist across calls and the staleness
|
||||
// check would reinit forever (#2106).
|
||||
// - `indexedAt`: last meta.indexedAt observed for an open pool.
|
||||
// - `dbIdentity`: file identity of the lbug the pool last opened (#2614 F1)
|
||||
// — an atomic swap or in-place incremental changes the inode; reiniting
|
||||
// on that covers the window where meta.indexedAt hasn't caught up (and
|
||||
// the incremental case), so a rebuilt index is never served stale even
|
||||
// when the stamp looks current.
|
||||
// - `ftsStatus`: last meta.capabilities.fts.status observed (#2767).
|
||||
// `--repair-fts` intentionally never restamps `indexedAt` (it doesn't
|
||||
// regenerate the graph), so this is the dedicated signal a warm session
|
||||
// uses to notice a repair — independent of the file-identity heuristic,
|
||||
// which the repair path also triggers but only incidentally.
|
||||
private lastObservedPoolState: Map<string, PoolObservedState> = new Map();
|
||||
/** Merge-patch one poolKey's observed state, preserving fields not passed. */
|
||||
private setObservedState(poolKey: string, patch: Partial<PoolObservedState>): void {
|
||||
const current = this.lastObservedPoolState.get(poolKey) ?? { dbIdentity: null };
|
||||
this.lastObservedPoolState.set(poolKey, { ...current, ...patch });
|
||||
}
|
||||
private groupToolSvc: GroupService | null = null;
|
||||
/**
|
||||
* One-shot stderr warnings for sibling-clone drift, keyed by
|
||||
|
|
@ -1143,8 +1167,7 @@ export class LocalBackend {
|
|||
this.initializedRepos.delete(key);
|
||||
this.lastStalenessCheck.delete(key);
|
||||
this.toolStalenessCache.delete(key);
|
||||
this.lastObservedIndexedAt.delete(key);
|
||||
this.lastObservedDbIdentity.delete(key);
|
||||
this.lastObservedPoolState.delete(key);
|
||||
this.reinitPromises.delete(key);
|
||||
closeLbug(key).catch(() => {});
|
||||
}
|
||||
|
|
@ -1548,10 +1571,11 @@ export class LocalBackend {
|
|||
// Reading the flat meta for a branch handle would compare the branch
|
||||
// index's indexedAt against the primary's and thrash the pool (#2106).
|
||||
const meta = await loadMeta(path.dirname(repo.lbugPath));
|
||||
const observedState = this.lastObservedPoolState.get(poolKey);
|
||||
// Compare against the last indexedAt OBSERVED for this pool (keyed by
|
||||
// lbugPath), not the handle's — branch handles are fresh spreads so a
|
||||
// handle mutation would not persist and would reinit on every check.
|
||||
const observed = this.lastObservedIndexedAt.get(poolKey) ?? repo.indexedAt;
|
||||
const observed = observedState?.indexedAt ?? repo.indexedAt;
|
||||
const stampChanged = !!meta?.indexedAt && meta.indexedAt !== observed;
|
||||
// #2614 F1: also reinit on a file-identity change. An atomic swap (or an
|
||||
// in-place incremental) changes the lbug inode; keying only on
|
||||
|
|
@ -1559,10 +1583,19 @@ export class LocalBackend {
|
|||
// latch on the old inode forever (its stamp already == meta.indexedAt).
|
||||
const currentIdentity = await statDbIdentity(repo.lbugPath);
|
||||
const identityChanged = dbIdentityChanged(
|
||||
this.lastObservedDbIdentity.get(poolKey) ?? null,
|
||||
observedState?.dbIdentity ?? null,
|
||||
currentIdentity,
|
||||
);
|
||||
if (stampChanged || identityChanged) {
|
||||
// #2767: `--repair-fts` intentionally never restamps `indexedAt` (it
|
||||
// doesn't regenerate the graph), so `stampChanged` alone can't notice
|
||||
// a repair. `capabilities.fts.status` is the field repair-fts DOES
|
||||
// write, so a change there is a third, independent reinit trigger —
|
||||
// sibling to stampChanged/identityChanged, not a replacement for them
|
||||
// (identityChanged still catches an in-place mutation even if the
|
||||
// caps stamp were somehow missed).
|
||||
const ftsStatus = meta?.capabilities?.fts?.status;
|
||||
const ftsCapsChanged = observedState?.ftsStatus !== ftsStatus;
|
||||
if (stampChanged || identityChanged || ftsCapsChanged) {
|
||||
// Index was rebuilt/swapped — DELEGATE the close/reopen to the pool's
|
||||
// initLbug, which refuses to evict (and close the shared Database)
|
||||
// while a query is in flight (its checkedOut>0 guard). Calling
|
||||
|
|
@ -1571,17 +1604,22 @@ export class LocalBackend {
|
|||
// reinitPromises to serialize concurrent detectors.
|
||||
const reinit = (async () => {
|
||||
try {
|
||||
// Advance the observed stamp regardless: a stamp change with an
|
||||
// unchanged file must not re-trigger on every check.
|
||||
if (meta?.indexedAt) this.lastObservedIndexedAt.set(poolKey, meta.indexedAt);
|
||||
const reopened = await initLbug(poolKey, repo.lbugPath);
|
||||
// tri-review NEW-7: advance the observed stamp/caps watermarks
|
||||
// only AFTER initLbug completes, not before calling it — still
|
||||
// regardless of `reopened` true/false (a stamp/caps change with
|
||||
// an unchanged file must not re-trigger on every check), but if
|
||||
// initLbug THROWS the watermark must stay at its old value so
|
||||
// the next staleness check retries, instead of a failed reinit
|
||||
// silently latching as "already applied" and never trying again.
|
||||
const patch: Partial<PoolObservedState> = { ftsStatus };
|
||||
if (meta?.indexedAt) patch.indexedAt = meta.indexedAt;
|
||||
// Advance the observed IDENTITY only when the pool actually rolled
|
||||
// over. If a query was in flight, initLbug served the current
|
||||
// handle and returned false; leaving the identity divergent
|
||||
// re-triggers the reopen on a later idle check instead of latching.
|
||||
if (reopened) {
|
||||
this.lastObservedDbIdentity.set(poolKey, await statDbIdentity(repo.lbugPath));
|
||||
}
|
||||
if (reopened) patch.dbIdentity = await statDbIdentity(repo.lbugPath);
|
||||
this.setObservedState(poolKey, patch);
|
||||
} finally {
|
||||
this.reinitPromises.delete(poolKey);
|
||||
}
|
||||
|
|
@ -1599,8 +1637,18 @@ export class LocalBackend {
|
|||
try {
|
||||
await initLbug(poolKey, repo.lbugPath);
|
||||
this.initializedRepos.add(poolKey);
|
||||
this.lastObservedIndexedAt.set(poolKey, repo.indexedAt);
|
||||
this.lastObservedDbIdentity.set(poolKey, await statDbIdentity(repo.lbugPath));
|
||||
// #2767: ftsStatus is deliberately left unset (undefined) here rather
|
||||
// than issuing an extra loadMeta read — every tool call already routes
|
||||
// through ensureInitialized, so an extra per-cold-init read adds up,
|
||||
// and the cost of skipping it is negligible: at most one redundant
|
||||
// initLbug call on the first warm check (initLbug itself no-ops
|
||||
// cheaply via a single fs.stat when the file identity is actually
|
||||
// unchanged, per pool-adapter.ts's own "unchanged → reuse" guard), not
|
||||
// a real reopen.
|
||||
this.setObservedState(poolKey, {
|
||||
indexedAt: repo.indexedAt,
|
||||
dbIdentity: await statDbIdentity(repo.lbugPath),
|
||||
});
|
||||
} catch (err: any) {
|
||||
// If lock error, mark as not initialized so next call retries
|
||||
this.initializedRepos.delete(poolKey);
|
||||
|
|
@ -2047,6 +2095,21 @@ export class LocalBackend {
|
|||
// unavailable the search helper may return an unexpected shape.
|
||||
const bm25Results = bm25SearchResult?.results ?? [];
|
||||
const ftsUsed = bm25SearchResult?.ftsUsed ?? false;
|
||||
// #2767: log every non-benign per-table FTS query error server-side,
|
||||
// regardless of whether OTHER tables succeeded — previously a real error
|
||||
// on N-1 of N tables while one succeeded left zero diagnostic trail.
|
||||
const ftsQueryErrors = bm25SearchResult?.nonBenignErrors;
|
||||
if (ftsQueryErrors) {
|
||||
// tri-review NEW-5: these strings are already classified non-benign by
|
||||
// classifyFtsQueryError — do NOT route them through logQueryError,
|
||||
// whose own broader, unanchored isBenignMissingTableError regex (any
|
||||
// "does not exist" substring, anywhere) could disagree and silently
|
||||
// demote an already-flagged real error to debug, undercutting the
|
||||
// severity signal this classification exists to preserve.
|
||||
for (const err of ftsQueryErrors) {
|
||||
logger.warn({ context: 'query:fts-search', err }, 'GitNexus query failed (degraded)');
|
||||
}
|
||||
}
|
||||
|
||||
// Merge via reciprocal rank fusion
|
||||
timer.start('merge');
|
||||
|
|
@ -2326,7 +2389,37 @@ export class LocalBackend {
|
|||
// path, leaving the success-path response shape byte-identical.
|
||||
const warnings: string[] = [];
|
||||
if (!ftsUsed) {
|
||||
warnings.push(ftsDegradedWarning());
|
||||
// #2767: attach what THIS session resolved (repo/branch/indexed-at) so a
|
||||
// CLI/MCP mismatch is visible in the warning itself rather than requiring
|
||||
// a separate debugging round-trip. tri-review NEW-3: `indexedAt` reads
|
||||
// from `lastObservedPoolState` (kept current by ensureInitialized's
|
||||
// staleness check, including a same-call reinit) rather than the `repo`
|
||||
// handle resolved before that check ran — a warm backend that just
|
||||
// reopened against a newer on-disk index must not warn with stale
|
||||
// metadata. No extra I/O: the map is already maintained per-request.
|
||||
const warningContext = {
|
||||
repoName: repo.name,
|
||||
branch: repo.branch,
|
||||
indexedAt: this.lastObservedPoolState.get(repo.lbugPath)?.indexedAt ?? repo.indexedAt,
|
||||
};
|
||||
// tri-review NEW-1: every table failing for a REAL error (timeout,
|
||||
// connection reset) is not a missing-index condition — `ftsDegradedWarning`'s
|
||||
// "run --repair-fts" headline won't fix it. Route to a dedicated message
|
||||
// instead of burying the real cause as a trailing suffix on bad advice.
|
||||
warnings.push(
|
||||
ftsQueryErrors
|
||||
? ftsQueryFailedWarning({ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] })
|
||||
: ftsDegradedWarning(warningContext),
|
||||
);
|
||||
} else if (ftsQueryErrors) {
|
||||
// #2767: at least one FTS table succeeded (ftsUsed=true) but another
|
||||
// hit a real, non-benign error — results may be silently missing
|
||||
// matches from that table with no signal, the same "partial success"
|
||||
// shape the enrichmentDegraded branch below already surfaces. Mirror
|
||||
// that convention instead of only logging server-side.
|
||||
warnings.push(
|
||||
`FTS keyword search partially failed — ${ftsQueryErrors.length} of the configured indexes hit a query error and were skipped; results may be missing matches from those node types (see server logs).`,
|
||||
);
|
||||
}
|
||||
// #2331: a CJK query against a server process resolving
|
||||
// GITNEXUS_FTS_CJK_SEGMENTATION to 'none' silently misses sub-phrase
|
||||
|
|
@ -2403,6 +2496,10 @@ export class LocalBackend {
|
|||
'Symbol enrichment partially failed — some process/cohesion/content data may be missing from these results (see server logs).',
|
||||
);
|
||||
}
|
||||
// #2767: a partial FTS failure (some tables ok, one or more real errors)
|
||||
// is as much a "results may be incomplete" signal as enrichmentDegraded —
|
||||
// flag it the same way rather than only via the warning string.
|
||||
const ftsPartial = ftsUsed && !!ftsQueryErrors;
|
||||
|
||||
return {
|
||||
processes,
|
||||
|
|
@ -2410,7 +2507,7 @@ export class LocalBackend {
|
|||
definitions: definitions.slice(0, 20), // cap standalone definitions
|
||||
timing,
|
||||
...(warnings.length > 0 && { warning: warnings.join(' ') }),
|
||||
...(enrichmentDegraded && { partial: true }),
|
||||
...((enrichmentDegraded || ftsPartial) && { partial: true }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2421,7 +2518,7 @@ export class LocalBackend {
|
|||
repo: RepoHandle,
|
||||
query: string,
|
||||
limit: number,
|
||||
): Promise<{ results: any[]; ftsUsed: boolean }> {
|
||||
): Promise<{ results: any[]; ftsUsed: boolean; nonBenignErrors?: string[] }> {
|
||||
let searchFTSFromLbug;
|
||||
try {
|
||||
({ searchFTSFromLbug } = await import('../../core/search/bm25-index.js'));
|
||||
|
|
@ -2453,6 +2550,7 @@ export class LocalBackend {
|
|||
// could be undefined when the FTS extension is unavailable in the MCP process.
|
||||
const bm25Results = ftsResponse?.results ?? [];
|
||||
const ftsUsed = ftsResponse?.ftsAvailable ?? false;
|
||||
const nonBenignErrors = ftsResponse?.nonBenignErrors;
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
|
|
@ -2524,7 +2622,7 @@ export class LocalBackend {
|
|||
}
|
||||
}
|
||||
|
||||
return { results, ftsUsed };
|
||||
return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2924,6 +3022,8 @@ export class LocalBackend {
|
|||
UNION ALL
|
||||
MATCH (n:\`Constructor\`) WHERE n.id IN $ids RETURN n.id AS id, 'Constructor' AS label
|
||||
UNION ALL
|
||||
MATCH (n:\`CodeElement\`) WHERE n.id IN $ids RETURN n.id AS id, 'CodeElement' AS label
|
||||
UNION ALL
|
||||
MATCH (n:\`Const\`) WHERE n.id IN $ids RETURN n.id AS id, 'Const' AS label
|
||||
UNION ALL
|
||||
MATCH (n:\`Variable\`) WHERE n.id IN $ids RETURN n.id AS id, 'Variable' AS label
|
||||
|
|
@ -3270,16 +3370,31 @@ export class LocalBackend {
|
|||
const symId = sym.id;
|
||||
|
||||
// Categorized incoming refs
|
||||
const incomingRows = await executeParameterized(
|
||||
repo.lbugPath,
|
||||
`
|
||||
const [incomingRows, incomingAdvisedRows] = await Promise.all([
|
||||
executeParameterized(
|
||||
repo.lbugPath,
|
||||
`
|
||||
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
|
||||
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
||||
LIMIT 30
|
||||
`,
|
||||
{ symId },
|
||||
);
|
||||
{ symId },
|
||||
),
|
||||
// Keep high-fan-in advice edges out of the legacy 30-row context window.
|
||||
// A broad pointcut can advise hundreds of methods; sharing that LIMIT
|
||||
// would make CALLS/HAS_METHOD/etc. disappear nondeterministically.
|
||||
executeParameterized(
|
||||
repo.lbugPath,
|
||||
`
|
||||
MATCH (caller)-[r:CodeRelation {type: 'ADVISED_BY'}]->(n {id: $symId})
|
||||
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
||||
LIMIT 30
|
||||
`,
|
||||
{ symId },
|
||||
),
|
||||
]);
|
||||
incomingRows.push(...incomingAdvisedRows);
|
||||
let typedPropertyRows: any[] = [];
|
||||
|
||||
// Fix #480: Class/Interface nodes have no direct CALLS/IMPORTS edges —
|
||||
|
|
@ -3398,16 +3513,28 @@ export class LocalBackend {
|
|||
}
|
||||
|
||||
// Categorized outgoing refs
|
||||
const outgoingRows = await executeParameterized(
|
||||
repo.lbugPath,
|
||||
`
|
||||
const [outgoingRows, outgoingAdvisedRows] = await Promise.all([
|
||||
executeParameterized(
|
||||
repo.lbugPath,
|
||||
`
|
||||
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
|
||||
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
|
||||
LIMIT 30
|
||||
`,
|
||||
{ symId },
|
||||
);
|
||||
{ symId },
|
||||
),
|
||||
executeParameterized(
|
||||
repo.lbugPath,
|
||||
`
|
||||
MATCH (n {id: $symId})-[r:CodeRelation {type: 'ADVISED_BY'}]->(target)
|
||||
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
|
||||
LIMIT 30
|
||||
`,
|
||||
{ symId },
|
||||
),
|
||||
]);
|
||||
outgoingRows.push(...outgoingAdvisedRows);
|
||||
|
||||
// Process participation
|
||||
let processRows: any[] = [];
|
||||
|
|
@ -3469,6 +3596,7 @@ export class LocalBackend {
|
|||
(sym.name || sym[1]) as string,
|
||||
);
|
||||
const beanMetadataPromise = queryClassBeanMetadata(repo.lbugPath, symId, epistemicSymType);
|
||||
const aopMetadataPromise = querySpringAopMetadata(repo.lbugPath, symId, epistemicSymType);
|
||||
|
||||
let methodMetadata: Record<string, unknown> | undefined;
|
||||
if (isMethodLike) {
|
||||
|
|
@ -3507,7 +3635,11 @@ export class LocalBackend {
|
|||
// dynamic dispatch are not reflected in `incoming`, so the view is a lower
|
||||
// bound. Additive; never suppresses a field. Resolved from the probe started
|
||||
// above (concurrent with methodMetadata).
|
||||
const [epistemic, beanMetadata] = await Promise.all([epistemicPromise, beanMetadataPromise]);
|
||||
const [epistemic, beanMetadata, aopMetadata] = await Promise.all([
|
||||
epistemicPromise,
|
||||
beanMetadataPromise,
|
||||
aopMetadataPromise,
|
||||
]);
|
||||
|
||||
return {
|
||||
status: 'found',
|
||||
|
|
@ -3521,6 +3653,7 @@ export class LocalBackend {
|
|||
...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
|
||||
...(methodMetadata ? { methodMetadata } : {}),
|
||||
...(beanMetadata ? { bean: beanMetadata } : {}),
|
||||
...(aopMetadata ? { aop: aopMetadata } : {}),
|
||||
},
|
||||
...epistemic,
|
||||
incoming: categorize(incomingRows),
|
||||
|
|
@ -5811,7 +5944,10 @@ export class LocalBackend {
|
|||
opts.skipEpistemic || summaryOnly
|
||||
? Promise.resolve(undefined)
|
||||
: queryClassBeanMetadata(repo.lbugPath, symId, symType);
|
||||
|
||||
const aopMetadataPromise =
|
||||
opts.skipEpistemic || summaryOnly
|
||||
? Promise.resolve(undefined)
|
||||
: querySpringAopMetadata(repo.lbugPath, symId, symType);
|
||||
const impacted: any[] = [];
|
||||
const visited = new Set<string>([symId]);
|
||||
const pdgBridgeEvidenceById = new Map<string, PdgBridgeEvidenceInfo>();
|
||||
|
|
@ -6293,7 +6429,11 @@ export class LocalBackend {
|
|||
|
||||
// #1858 — await the epistemic boundary probe kicked off alongside the BFS
|
||||
// above. Additive: leaves impactedCount and every existing field untouched.
|
||||
const [epistemic, beanMetadata] = await Promise.all([epistemicPromise, beanMetadataPromise]);
|
||||
const [epistemic, beanMetadata, aopMetadata] = await Promise.all([
|
||||
epistemicPromise,
|
||||
beanMetadataPromise,
|
||||
aopMetadataPromise,
|
||||
]);
|
||||
|
||||
const base = {
|
||||
target: {
|
||||
|
|
@ -6302,6 +6442,7 @@ export class LocalBackend {
|
|||
type: symType,
|
||||
filePath: sym.filePath || sym[2],
|
||||
...(beanMetadata ? { bean: beanMetadata } : {}),
|
||||
...(aopMetadata ? { aop: aopMetadata } : {}),
|
||||
},
|
||||
direction,
|
||||
impactedCount: impacted.length,
|
||||
|
|
|
|||
|
|
@ -233,8 +233,8 @@ EXAMPLES:
|
|||
• Find method overrides (MRO resolution):
|
||||
MATCH (winner:Method)-[r:CodeRelation {type: 'METHOD_OVERRIDES'}]->(loser:Method) RETURN winner.name, winner.filePath, loser.filePath, r.reason
|
||||
|
||||
• Find DI-injected implementations (beans injected into a consumer class):
|
||||
MATCH (c:Class {name: 'OrderService'})-[r:CodeRelation]->(impl:Class) WHERE r.type = 'INJECTS' RETURN impl.name, r.reason
|
||||
• Find DI-injected providers (provider Classes or synthetic factory declarations):
|
||||
MATCH (c:Class {name: 'OrderService'})-[r:CodeRelation]->(provider) WHERE r.type = 'INJECTS' RETURN provider.name, r.reason
|
||||
|
||||
• Detect diamond inheritance:
|
||||
MATCH (d:Class)-[:CodeRelation {type: 'EXTENDS'}]->(b1), (d)-[:CodeRelation {type: 'EXTENDS'}]->(b2), (b1)-[:CodeRelation {type: 'EXTENDS'}]->(a), (b2)-[:CodeRelation {type: 'EXTENDS'}]->(a) WHERE b1 <> b2 RETURN d.name, b1.name, b2.name, a.name
|
||||
|
|
@ -534,7 +534,7 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
|
|||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description:
|
||||
'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES (default: usage-based, ACCESSES excluded by default). DI fan-out (consumer→implementer) requires explicitly including INJECTS.',
|
||||
'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES (default: usage-based, ACCESSES excluded by default). DI edges require INJECTS; Spring proxy/advice edges require ADVISED_BY.',
|
||||
},
|
||||
includeTests: { type: 'boolean', description: 'Include test files (default: false)' },
|
||||
minConfidence: {
|
||||
|
|
|
|||
|
|
@ -1833,8 +1833,16 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
},
|
||||
pendingNodeIds: string[],
|
||||
): Promise<void> => {
|
||||
// tri-review NEW-2: re-read immediately before writing (mirrors
|
||||
// the pattern in run-analyze.ts's --repair-fts stamp) instead of
|
||||
// spreading the stale `embeddingMeta` snapshot captured once at
|
||||
// job start. This job can run up to EMBED_TIMEOUT_MS (30 min);
|
||||
// without a fresh read, a concurrent writer's update (e.g. a
|
||||
// --repair-fts capability stamp) would be silently reverted on
|
||||
// every checkpoint save for the job's whole lifetime.
|
||||
const latestMeta = (await loadMeta(entry.storagePath)) ?? embeddingMeta;
|
||||
embeddingMeta = {
|
||||
...embeddingMeta,
|
||||
...latestMeta,
|
||||
embeddingCheckpoint: {
|
||||
at: new Date().toISOString(),
|
||||
...checkpoint,
|
||||
|
|
@ -1896,7 +1904,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
// handles this during process exit, but the server keeps the
|
||||
// connection open for other routes — a CHECKPOINT is enough.
|
||||
await flushWAL();
|
||||
embeddingMeta = { ...embeddingMeta, embeddingCheckpoint: undefined };
|
||||
// Same re-read-before-write reasoning as saveEmbeddingCheckpoint above.
|
||||
const finalMeta = (await loadMeta(entry.storagePath)) ?? embeddingMeta;
|
||||
embeddingMeta = { ...finalMeta, embeddingCheckpoint: undefined };
|
||||
await saveMeta(entry.storagePath, embeddingMeta);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,21 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
|
|||
// the main thread (the #1983 OOM). Because the two stores share this version,
|
||||
// any future change to the `ParsedFile` serialization shape MUST bump
|
||||
// SCHEMA_BUMP so both invalidate in lockstep.
|
||||
// v35: Java/Kotlin Spring @Bean factory and @Resource side-channel facts
|
||||
// (#2413). ParsedFile results are content-addressed and replayed verbatim, so
|
||||
// the feature/schema inventory alone cannot invalidate pre-#2413 captures.
|
||||
// (Cut as v32 on this branch; `main` took 32 for #2742 and 33/34 for #2747
|
||||
// first, so this series is renumbered at merge time — see the v21 note.)
|
||||
// v34: the receiver-chain capture is emitted by ALL 14 language emitters, not
|
||||
// just TypeScript. v33 landed with the TypeScript-only emission; the rollout to
|
||||
// the other 13 languages changed the capture set AGAIN, so a cache stamped 33 by
|
||||
// an intermediate build of that series is not equivalent to one stamped at this
|
||||
// commit — it would be treated as current while every non-TypeScript file
|
||||
// replayed pre-rollout captures, leaving the feature silently inert for 13 of 14
|
||||
// languages. Bumped there so the version tracks the FINAL capture set rather
|
||||
// than the first divergence.
|
||||
// v33: TypeScript call matches carry `@reference.receiver-chain`, a compact
|
||||
// encoding of a receiver that is itself an expression.
|
||||
// v32: Rust items are qualified by their enclosing `mod` chain (#2742). The
|
||||
// qualified name is computed in the parse worker, so a warm cache replays the
|
||||
// old unqualified ids verbatim and the collapse persists.
|
||||
|
|
@ -128,19 +143,13 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
|
|||
// JLS 13.1 immediate-host chains (#2555).
|
||||
// v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity.
|
||||
// v16: direct callee identity.
|
||||
// v34: the receiver-chain capture is emitted by ALL 14 language emitters, not
|
||||
// just TypeScript. v33 landed with the TypeScript-only emission; the rollout to
|
||||
// the other 13 languages changed the capture set AGAIN, so a cache stamped 33 by
|
||||
// an intermediate build of this series is not equivalent to one stamped at this
|
||||
// commit — it would be treated as current while every non-TypeScript file
|
||||
// replayed pre-rollout captures, leaving the feature silently inert for 13 of 14
|
||||
// languages. Bumped here so the version tracks the FINAL capture set rather than
|
||||
// the first divergence.
|
||||
// v33: TypeScript call matches carry `@reference.receiver-chain`, a compact
|
||||
// encoding of a receiver that is itself an expression. (Shipped as v32 on its own
|
||||
// branch; `main` took 32 for #2742 first, so this series is renumbered — see the
|
||||
// v21 note on re-checking against origin/main at MERGE time, not branch time.)
|
||||
const SCHEMA_BUMP = 34;
|
||||
// v36: bound-callable graph `startLine` follows the initializer so multi-line
|
||||
// closure bindings join the scope channel (#2735). Warm cache would otherwise
|
||||
// keep serving wrapper-line startLines and drop the CALLS edge.
|
||||
// v37: Java/Kotlin capture side-channels include Spring AOP owner/advice facts
|
||||
// (#2416). Warm cache entries at v36 do not carry those facts and would silently
|
||||
// omit ADVISED_BY evidence.
|
||||
const SCHEMA_BUMP = 37;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js';
|
|||
import { stripWindowsLongPathPrefix } from '../lib/utils.js';
|
||||
import { retryRename } from './fs-atomic.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { acquireIndexLock, IndexLockTimeoutError, type IndexLockHandle } from './index-lock.js';
|
||||
import {
|
||||
branchSlug,
|
||||
BRANCHES_DIR,
|
||||
|
|
@ -187,9 +188,12 @@ export interface RepoMeta {
|
|||
* the meta literal in run-analyze.ts — typed here so the stamp site is
|
||||
* compile-checked; tri-review 4669518496 P1/U3: `vectorSearch.status`
|
||||
* must never claim 'vector-index' unless the run verified or recreated
|
||||
* the HNSW index). Forensic today — no programmatic readers (`doctor`
|
||||
* prints platform-derived capabilities, query routing never consults
|
||||
* meta). The status unions mirror `CapabilityStatus` /
|
||||
* the HNSW index). `fts.status` gained its first programmatic reader in
|
||||
* #2767: `LocalBackend.ensureInitialized()` compares it against the
|
||||
* warm connection pool's last-observed value as the dedicated signal
|
||||
* that `--repair-fts` changed FTS availability (`doctor` still prints
|
||||
* platform-derived capabilities separately; `graph`/`vectorSearch` remain
|
||||
* forensic-only). The status unions mirror `CapabilityStatus` /
|
||||
* `SemanticSearchMode` in core/platform/capabilities.ts; inlined to keep
|
||||
* storage/ free of a core/ type dependency.
|
||||
*/
|
||||
|
|
@ -603,6 +607,15 @@ export interface RepoMeta {
|
|||
* graph for every unchanged file, which is exactly the missing-caller symptom
|
||||
* #2708 reported. Force a full re-analyze.
|
||||
*
|
||||
* v29: Spring @Bean declarations are CodeElement providers and INJECTS may run
|
||||
* from a consumer Class or factory Method to that CodeElement (#2413). The
|
||||
* relation DDL gained Class→CodeElement; a pre-v29 database cannot persist that
|
||||
* label pair, so force a one-time rebuild against the expanded schema.
|
||||
*
|
||||
* (This shipped as v25 on its own branch; `main` took 25 through 28 first, so it
|
||||
* is renumbered at merge time. Re-check both constants against origin/main
|
||||
* immediately before merging — this is the fifth time that collision has bitten.)
|
||||
*
|
||||
* v26: unresolved-receiver member names are persisted
|
||||
* (`unresolvedReceiverMembers`) so `impact()`/`context()` can report
|
||||
* `epistemic: 'lower-bound'` instead of a confident `'exact'` when a call site
|
||||
|
|
@ -622,8 +635,46 @@ export interface RepoMeta {
|
|||
* `#[cfg(test)] mod tests` makes that close to every Rust repo — so a pre-v25
|
||||
* index holds ids an incremental top-up cannot reconcile and would simply
|
||||
* strand. Force a full re-analyze.
|
||||
*
|
||||
* v30: bound-callable graph `startLine` follows the initializer (#2735), so a
|
||||
* multi-line closure binding joins the scope channel and emits its CALLS edge.
|
||||
* Pre-v30 indexes keep the wrapper line on unchanged files and would keep
|
||||
* failing closed (no edge) through the reuse gate. Force a full re-analyze.
|
||||
*
|
||||
* v31: Python named imports that resolve to concrete submodules are finalized
|
||||
* as namespace edges (#2746), enabling qualified constructor and method CALLS
|
||||
* edges. Pre-v31 indexes retain the old package-target/missing-edge graph for
|
||||
* unchanged files through the reuse gate. Force a full re-analyze.
|
||||
*
|
||||
* v32: the relation DDL (the single shared `CodeRelation` REL TABLE) gains
|
||||
* sixteen FROM/TO pairs carried by `HAS_METHOD`/`HAS_PROPERTY` and
|
||||
* scope-resolution edges: Enum→{Function, Method, Struct, Constructor,
|
||||
* Property, TypeAlias}, Property→{Class, Enum, Function, Struct},
|
||||
* Method→{Variable, Const}, Trait→Function, Impl→Function, Const→Method and
|
||||
* Variable→Method. The Enum/Property set was observed on Swift (enums carry
|
||||
* computed properties, methods, initializers and nested types) and is also
|
||||
* reached by Java/PHP enum members; Trait/Impl→Function covers a Rust
|
||||
* `impl`/`trait` method, which is minted as a `Function` node, not `Method`;
|
||||
* Const/Variable→Method and its sibling Method→Const cover a JS/TS object
|
||||
* literal's shorthand methods, whose owner is labelled `Const`/`Variable`. A
|
||||
* pre-v32 database physically lacks these from-to pairs — see
|
||||
* `assertDeclaredPair` (rel-pair-routing.ts) for why an incremental top-up
|
||||
* fails loudly on one path and silently on the other. Force a full re-analyze.
|
||||
*
|
||||
* (This shipped as v31 on its own branch; `main` took 31 for #2746 first, so
|
||||
* it is renumbered here. Re-check both constants against origin/main
|
||||
* immediately before merging — this is the sixth time that collision has
|
||||
* bitten. If this change is ever reverted, do not free 32 for reuse — the
|
||||
* reuse gate is exact equality, so an index already stamped 32 would satisfy
|
||||
* it against a differently-shaped reverted DB. Start the next allocation at
|
||||
* 33 instead.)
|
||||
*
|
||||
* 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 = 28;
|
||||
export const INCREMENTAL_SCHEMA_VERSION = 33;
|
||||
|
||||
export interface IndexedRepo {
|
||||
repoPath: string;
|
||||
|
|
@ -1117,6 +1168,69 @@ export const getGlobalRegistryPath = (): string => {
|
|||
return path.join(getGlobalDir(), 'registry.json');
|
||||
};
|
||||
|
||||
/**
|
||||
* Lock namespace for the global registry.
|
||||
*
|
||||
* Deliberately a dedicated sub-directory rather than {@link getGlobalDir}
|
||||
* itself: an index slot's lock dir is always `<repo>/.gitnexus` (or
|
||||
* `<repo>/.gitnexus/branches/<slug>`), so for a repository rooted at the
|
||||
* user's home directory — dotfiles-at-`$HOME` is a real layout — the per-repo
|
||||
* analyze lock and the global-dir lock would resolve to the SAME directory.
|
||||
* `acquireIndexLock` is not reentrant, so `runFullAnalysis` (which holds the
|
||||
* per-repo lock across its whole pipeline) would then self-deadlock the moment
|
||||
* it reached `registerRepo`/`adoptFlatBranchLabel`. No repo's index slot can
|
||||
* ever be named `registry-lock`, so this namespace cannot collide.
|
||||
*/
|
||||
const getRegistryLockDir = (): string => path.join(getGlobalDir(), 'registry-lock');
|
||||
|
||||
/**
|
||||
* Wait ceiling for the registry lock. A registry transaction is a sub-second
|
||||
* JSON read/merge/write, so it must NOT inherit the index lock's 10-minute
|
||||
* default (sized for multi-minute analyze runs): `gitnexus augment` runs on
|
||||
* every editor/agent tool call with a documented sub-500ms cold-start budget
|
||||
* and reaches this lock via `listRegisteredRepos({ validate: true })`.
|
||||
*/
|
||||
const REGISTRY_LOCK_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Serialize global registry read/merge/write transactions across processes.
|
||||
*
|
||||
* The registry is shared by every indexed repository, so per-index locks do
|
||||
* not protect this file. Reuse the cross-platform index lock primitive with a
|
||||
* registry-private lock namespace; the handle is kernel-owned on supported
|
||||
* platforms and crash-reclaimable by the existing fallback.
|
||||
*
|
||||
* On timeout the transaction proceeds UNLOCKED rather than throwing: the lock
|
||||
* closes a lost-update race that existed unguarded before #2716, so degrading
|
||||
* to the old best-effort behaviour is strictly better than failing an
|
||||
* `analyze`/`list`/`augment` outright on a wedged lock (a stale pid-reuse
|
||||
* ghost on platforms without start-time verification can look live forever).
|
||||
*/
|
||||
const withRegistryLock = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
let lock: IndexLockHandle | null = null;
|
||||
try {
|
||||
lock = await acquireIndexLock(getRegistryLockDir(), {
|
||||
timeoutMs: REGISTRY_LOCK_TIMEOUT_MS,
|
||||
// Registry contention was previously invisible: `acquireIndexLock`'s own
|
||||
// `log` texts name an "analyze" holder, which misattributes a registry
|
||||
// wait, so surface a registry-specific line instead (#2716 review).
|
||||
onWaitStart: () =>
|
||||
logger.info('Waiting for another GitNexus process to finish a registry update…'),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof IndexLockTimeoutError)) throw err;
|
||||
logger.warn(
|
||||
{ timeoutMs: REGISTRY_LOCK_TIMEOUT_MS },
|
||||
'Timed out waiting for the global registry lock; proceeding without it. A concurrent registry write may be lost.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
lock?.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the global registry. Returns empty array if not found.
|
||||
*/
|
||||
|
|
@ -1263,7 +1377,7 @@ const hasCustomAlias = (entry: RegistryEntry, inferredName: string | null): bool
|
|||
* caller can re-use it to keep AGENTS.md / skill files aligned with the
|
||||
* MCP-visible repo name (#979).
|
||||
*/
|
||||
export const registerRepo = async (
|
||||
const registerRepoUnlocked = async (
|
||||
repoPath: string,
|
||||
meta: RepoMeta,
|
||||
opts?: RegisterRepoOptions,
|
||||
|
|
@ -1430,11 +1544,17 @@ export const registerRepo = async (
|
|||
return name;
|
||||
};
|
||||
|
||||
export const registerRepo = async (
|
||||
repoPath: string,
|
||||
meta: RepoMeta,
|
||||
opts?: RegisterRepoOptions,
|
||||
): Promise<string> => withRegistryLock(() => registerRepoUnlocked(repoPath, meta, opts));
|
||||
|
||||
/**
|
||||
* Remove a repo from the global registry.
|
||||
* Called after `gitnexus clean`.
|
||||
*/
|
||||
export const unregisterRepo = async (repoPath: string): Promise<void> => {
|
||||
const unregisterRepoUnlocked = async (repoPath: string): Promise<void> => {
|
||||
// Canonicalise BOTH sides so an unregister call issued with the
|
||||
// symlink form (`/var/folders/.../repo`) still matches an entry
|
||||
// written with the realpath form (`/private/var/folders/.../repo`),
|
||||
|
|
@ -1446,6 +1566,9 @@ export const unregisterRepo = async (repoPath: string): Promise<void> => {
|
|||
await writeRegistry(filtered);
|
||||
};
|
||||
|
||||
export const unregisterRepo = async (repoPath: string): Promise<void> =>
|
||||
withRegistryLock(() => unregisterRepoUnlocked(repoPath));
|
||||
|
||||
/**
|
||||
* Remove a single non-primary branch's summary from a repo's registry entry
|
||||
* (#2106 R7). Called by `gitnexus clean --branch`. Returns `true` when a
|
||||
|
|
@ -1454,7 +1577,7 @@ export const unregisterRepo = async (repoPath: string): Promise<void> => {
|
|||
* primary entry is left intact; an empty `branches[]` is dropped to keep the
|
||||
* registry shape legacy-clean.
|
||||
*/
|
||||
export const removeBranchIndex = async (repoPath: string, branch: string): Promise<boolean> => {
|
||||
const removeBranchIndexUnlocked = async (repoPath: string, branch: string): Promise<boolean> => {
|
||||
const resolved = canonicalizePath(repoPath);
|
||||
const entries = await readRegistry();
|
||||
const idx = entries.findIndex((e) => registryPathEquals(canonicalizePath(e.path), resolved));
|
||||
|
|
@ -1471,6 +1594,9 @@ export const removeBranchIndex = async (repoPath: string, branch: string): Promi
|
|||
return true;
|
||||
};
|
||||
|
||||
export const removeBranchIndex = async (repoPath: string, branch: string): Promise<boolean> =>
|
||||
withRegistryLock(() => removeBranchIndexUnlocked(repoPath, branch));
|
||||
|
||||
/**
|
||||
* Record that the flat workspace slot now serves `branch` (#2354).
|
||||
*
|
||||
|
|
@ -1487,6 +1613,12 @@ export const removeBranchIndex = async (repoPath: string, branch: string): Promi
|
|||
* a no-op — including the sub-index deletion, which only runs for registered
|
||||
* repos (never self-heals an unregistered repo, per #2264/#1169; the registry
|
||||
* check precedes the rm per #2364 review F2) — and no subprocess is spawned.
|
||||
*
|
||||
* Only the closing re-read/mutate/write runs under the registry lock. The
|
||||
* recursive `rm` stays outside it — mirroring `clean.ts`, which deletes the
|
||||
* branch directory before calling the (locked) `removeBranchIndex` — so a slow
|
||||
* delete (large sub-index, AV scan, network mount) never blocks every other
|
||||
* registry operation on the machine.
|
||||
*/
|
||||
export const adoptFlatBranchLabel = async (repoPath: string, branch: string): Promise<void> => {
|
||||
const canonicalInput = canonicalizePath(repoPath);
|
||||
|
|
@ -1537,22 +1669,24 @@ export const adoptFlatBranchLabel = async (repoPath: string, branch: string): Pr
|
|||
}
|
||||
}
|
||||
|
||||
// Re-read AFTER the potentially slow recursive rm: the registry is a
|
||||
// multi-writer whole-file overwrite, and writing a pre-rm snapshot would
|
||||
// silently clobber concurrent registerRepo/removeBranchIndex writers —
|
||||
// the #2106 R9 re-read-before-write discipline registerRepo follows.
|
||||
const entries = await readRegistry();
|
||||
const idx = isRegistered(entries);
|
||||
if (idx < 0) return; // unregistered concurrently → still a no-op
|
||||
const entry = entries[idx];
|
||||
const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches;
|
||||
const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0);
|
||||
if (entry.branch === branch && !droppedSummary) return; // already coherent
|
||||
entry.branch = branch;
|
||||
if (remaining && remaining.length > 0) entry.branches = remaining;
|
||||
else delete entry.branches;
|
||||
entries[idx] = entry;
|
||||
await writeRegistry(entries);
|
||||
// Re-read AFTER the potentially slow recursive rm, and under the lock: the
|
||||
// registry is a multi-writer whole-file overwrite, and writing a pre-rm
|
||||
// snapshot would silently clobber concurrent registerRepo/removeBranchIndex
|
||||
// writers — the #2106 R9 re-read-before-write discipline registerRepo follows.
|
||||
await withRegistryLock(async () => {
|
||||
const entries = await readRegistry();
|
||||
const idx = isRegistered(entries);
|
||||
if (idx < 0) return; // unregistered concurrently → still a no-op
|
||||
const entry = entries[idx];
|
||||
const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches;
|
||||
const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0);
|
||||
if (entry.branch === branch && !droppedSummary) return; // already coherent
|
||||
entry.branch = branch;
|
||||
if (remaining && remaining.length > 0) entry.branches = remaining;
|
||||
else delete entry.branches;
|
||||
entries[idx] = entry;
|
||||
await writeRegistry(entries);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1886,9 +2020,21 @@ export const listRegisteredRepos = async (opts?: {
|
|||
}
|
||||
}
|
||||
|
||||
// If we pruned any entries, save the cleaned registry
|
||||
// If we pruned any entries, save the cleaned registry — under the lock, and
|
||||
// only then. The validation walk above is read-only (an fs.access per entry,
|
||||
// slow on a network mount or a large registry) and the common case prunes
|
||||
// nothing, so holding the global lock across it would serialize every
|
||||
// `gitnexus augment` behind unrelated registry work for no benefit. Re-read
|
||||
// inside the lock and drop the provably-absent paths from that fresh
|
||||
// snapshot, so a concurrent registration in the validation window survives.
|
||||
if (valid.length !== entries.length) {
|
||||
await writeRegistry(valid);
|
||||
const pruned = new Set(
|
||||
entries.filter((entry) => !valid.includes(entry)).map((entry) => entry.path),
|
||||
);
|
||||
await withRegistryLock(async () => {
|
||||
const fresh = await readRegistry();
|
||||
await writeRegistry(fresh.filter((entry) => !pruned.has(entry.path)));
|
||||
});
|
||||
}
|
||||
|
||||
return valid;
|
||||
|
|
|
|||
3
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/decoy/models.py
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/decoy/models.py
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
class User:
|
||||
def save(self):
|
||||
return "wrong"
|
||||
0
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/__init__.py
vendored
Normal file
0
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/__init__.py
vendored
Normal file
15
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/app.py
vendored
Normal file
15
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/app.py
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from pkg import models
|
||||
from pkg.models import User
|
||||
|
||||
|
||||
def inline_save(db):
|
||||
return models.User(db).save()
|
||||
|
||||
|
||||
def assigned_save(db):
|
||||
user = models.User(db)
|
||||
return user.save()
|
||||
|
||||
|
||||
def direct_save(db):
|
||||
return User(db).save()
|
||||
6
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/models.py
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/models.py
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class User:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
def save(self):
|
||||
return self.db
|
||||
|
|
@ -331,6 +331,22 @@
|
|||
"captureGroups": 41,
|
||||
"digest": "e8807a9969732197feb04204d200d5810b895c006f1424a7a6f5f0d5762ef49a"
|
||||
},
|
||||
"python-from-module-alias/decoy/models.py": {
|
||||
"captureGroups": 7,
|
||||
"digest": "bc8f2332e9a4a683f3fdae966a0d8a8f534a310ddc438a7a4ebc040e97d642b8"
|
||||
},
|
||||
"python-from-module-alias/pkg/__init__.py": {
|
||||
"captureGroups": 0,
|
||||
"digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"python-from-module-alias/pkg/app.py": {
|
||||
"captureGroups": 23,
|
||||
"digest": "afb50d3e98def472f9215e986dd4f964eedd197a9acfc8048d92b882e8fe868a"
|
||||
},
|
||||
"python-from-module-alias/pkg/models.py": {
|
||||
"captureGroups": 14,
|
||||
"digest": "fe52542a27d98c57d7acd48212dc301bdb448ad5868afb68c98cede9299dd1e5"
|
||||
},
|
||||
"python-function-local-import-chain/app.py": {
|
||||
"captureGroups": 9,
|
||||
"digest": "ad4d45976ca10c3fc3b7bf498ee23397797368a5d2817311d0e95e943af89199"
|
||||
|
|
|
|||
|
|
@ -64,19 +64,14 @@ const nodeIdsContaining = async (
|
|||
};
|
||||
|
||||
describeIfWorkerBuilt(
|
||||
'#2699 review P1-1 — a closure that cannot be named never credits its parent',
|
||||
'#2735 — a multi-line closure binding is a call SOURCE (not merely fail-closed)',
|
||||
() => {
|
||||
it('a MULTI-LINE closure binding does not fabricate a call from the enclosing function', async () => {
|
||||
// The two channels anchor on DIFFERENT nodes by design — graph-node on the
|
||||
// outer wrapper, scope-resolution on the inner closure. On one line they
|
||||
// share a row and the position join matches. Split across lines it misses,
|
||||
// and before the fix `resolveCallerGraphId` CLIMBED to the enclosing scope,
|
||||
// emitting `outer -> target` although `outer` calls nothing. That is a CALLS
|
||||
// edge present nowhere in the source — the exact defect class #2699 exists
|
||||
// to remove — so the bridge now fails closed at the owning callable.
|
||||
//
|
||||
// The single-line binding in the same fixture proves the fail-closed path
|
||||
// did not simply delete the feature.
|
||||
it('PHP: both single-line and multi-line bindings emit CALLS to target', async () => {
|
||||
// Graph-node queries anchor `@definition.function` on the OUTER assignment;
|
||||
// scope-resolution anchors `@declaration.function` on the INNER closure.
|
||||
// #2699 made a miss fail closed (no fabricated `outer -> target`). #2735
|
||||
// makes the join hit by putting the graph node's `startLine` on the
|
||||
// initializer, so the real `outer.$multi -> target` edge appears.
|
||||
const edges = await callEdges(
|
||||
'ml.php',
|
||||
'<?php\nfunction target($x) { return $x; }\nfunction outer() {\n' +
|
||||
|
|
@ -84,7 +79,105 @@ describeIfWorkerBuilt(
|
|||
' $multi =\n function ($x) { return target($x); };\n return 1;\n}\n',
|
||||
);
|
||||
|
||||
expect(edges).toEqual(['Function:ml.php:outer.$single@3:2 -> Function:ml.php:target']);
|
||||
expect(edges).toEqual([
|
||||
'Function:ml.php:outer.$multi@4:2 -> Function:ml.php:target',
|
||||
'Function:ml.php:outer.$single@3:2 -> Function:ml.php:target',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Rust: a wrapped closure binding emits CALLS to target', async () => {
|
||||
const edges = await callEdges(
|
||||
'ml.rs',
|
||||
'fn target(x: i32) -> i32 { x }\nfn outer() -> i32 {\n' +
|
||||
' let handler =\n || target(1);\n handler()\n}\n',
|
||||
);
|
||||
|
||||
expect(edges).toEqual([
|
||||
'Function:ml.rs:outer -> Function:ml.rs:outer.handler@2:4',
|
||||
'Function:ml.rs:outer.handler@2:4 -> Function:ml.rs:target',
|
||||
]);
|
||||
});
|
||||
|
||||
it('TypeScript: a multi-line const arrow binding emits CALLS to target', async () => {
|
||||
const edges = await callEdges(
|
||||
'ml.ts',
|
||||
'function target(x: number): number { return x; }\nfunction outer(): number {\n' +
|
||||
' const single = (x: number) => target(x);\n' +
|
||||
' const multi =\n (x: number) => target(x);\n return single(1) + multi(2);\n}\n',
|
||||
);
|
||||
|
||||
expect(edges).toEqual([
|
||||
'Function:ml.ts:outer -> Function:ml.ts:outer.multi@3:2',
|
||||
'Function:ml.ts:outer -> Function:ml.ts:outer.single@2:2',
|
||||
'Function:ml.ts:outer.multi@3:2 -> Function:ml.ts:target',
|
||||
'Function:ml.ts:outer.single@2:2 -> Function:ml.ts:target',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Kotlin: a multi-line val lambda binding emits CALLS to target', async () => {
|
||||
const edges = await callEdges(
|
||||
'ml.kt',
|
||||
'fun target(x: Int): Int = x\nfun outer(): Int {\n' +
|
||||
' val single = { x: Int -> target(x) }\n' +
|
||||
' val multi =\n { x: Int -> target(x) }\n return 1\n}\n',
|
||||
);
|
||||
|
||||
expect(edges.some((e) => e.includes('multi') && e.endsWith('-> Function:ml.kt:target'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
edges.some((e) => e.startsWith('Function:ml.kt:outer ->') && e.endsWith('target')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('Ruby: a multi-line lambda do-end binding emits CALLS to target', async () => {
|
||||
const edges = await callEdges(
|
||||
'ml.rb',
|
||||
'def target(x)\n x\nend\ndef outer\n' +
|
||||
' a = ->(x) { target(x) }\n' +
|
||||
' b =\n lambda do |y|\n target(y)\n end\nend\n',
|
||||
);
|
||||
|
||||
expect(edges.some((e) => e.includes('.b@') && e.endsWith('-> Method:ml.rb:target#1'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(edges.some((e) => e.startsWith('Method:ml.rb:outer#0 ->'))).toBe(false);
|
||||
});
|
||||
|
||||
it('Dart: a multi-line var closure binding emits CALLS to target', async () => {
|
||||
const edges = await callEdges(
|
||||
'ml.dart',
|
||||
'int target(int x) => x;\nint outer() {\n' +
|
||||
' var single = (int x) => target(x);\n' +
|
||||
' var multi =\n (int x) => target(x);\n return 1;\n}\n',
|
||||
);
|
||||
|
||||
expect(
|
||||
edges.some((e) => e.includes('multi') && e.endsWith('-> Function:ml.dart:target')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
edges.some((e) => e.startsWith('Function:ml.dart:outer ->') && e.endsWith('target')),
|
||||
).toBe(false);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describeIfWorkerBuilt(
|
||||
'#2699 review P1-1 — a closure that cannot be named never credits its parent',
|
||||
() => {
|
||||
it('a MULTI-LINE closure binding does not fabricate a call from the enclosing function', async () => {
|
||||
// Retained as the fail-closed half of #2735: even when the join works,
|
||||
// `outer` itself must not grow a CALLS edge to `target` — only the
|
||||
// binding nodes do.
|
||||
const edges = await callEdges(
|
||||
'ml.php',
|
||||
'<?php\nfunction target($x) { return $x; }\nfunction outer() {\n' +
|
||||
' $single = function ($x) { return target($x); };\n' +
|
||||
' $multi =\n function ($x) { return target($x); };\n return 1;\n}\n',
|
||||
);
|
||||
|
||||
expect(edges.some((e) => e.startsWith('Function:ml.php:outer ->'))).toBe(false);
|
||||
expect(edges).toContain('Function:ml.php:outer.$multi@4:2 -> Function:ml.php:target');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -641,17 +641,18 @@ describe('streamAllCSVsToDisk — direct per-pair emit matches the split oracle'
|
|||
{ id: 'Function:a.ts:f:1', label: 'Function', name: 'f', filePath: 'a.ts' },
|
||||
{ id: 'Function:a.ts:g:5', label: 'Function', name: 'g', filePath: 'a.ts' },
|
||||
{ id: 'comm_1', label: 'Community' as never, name: 'c1', filePath: '' },
|
||||
{ id: 'comm_2', label: 'Community' as never, name: 'c2', filePath: '' },
|
||||
{ id: 'proc_1', label: 'Process' as never, name: 'p1', filePath: '' },
|
||||
{ id: 'proc_2', label: 'Process' as never, name: 'p2', filePath: '' },
|
||||
],
|
||||
[
|
||||
{ sourceId: 'File:a.ts', targetId: 'Function:a.ts:f:1', type: 'CONTAINS' },
|
||||
{ sourceId: 'File:a.ts', targetId: 'Function:a.ts:g:5', type: 'CONTAINS' },
|
||||
{ sourceId: 'Function:a.ts:f:1', targetId: 'Function:a.ts:g:5', type: 'CALLS' },
|
||||
{ sourceId: 'comm_1', targetId: 'comm_2', type: 'CONTAINS' },
|
||||
// proc_ prefix → Process label (getNodeLabel special case).
|
||||
{ sourceId: 'proc_1', targetId: 'proc_2', type: 'CONTAINS' },
|
||||
// comm_ target prefix → Community label (getNodeLabel special case);
|
||||
// Function→Community is a real schema pair.
|
||||
{ sourceId: 'Function:a.ts:f:1', targetId: 'comm_1', type: 'MEMBER_OF' },
|
||||
// proc_ target prefix → Process label; Function→Process is likewise
|
||||
// declared in the production relation schema.
|
||||
{ sourceId: 'Function:a.ts:g:5', targetId: 'proc_1', type: 'STEP_IN_PROCESS' },
|
||||
// Invalid FROM label ('Bogus' ∉ NODE_TABLES) — skipped by both paths.
|
||||
{ sourceId: 'Bogus:x', targetId: 'File:a.ts', type: 'CONTAINS' },
|
||||
// Invalid TO label — exercises the OTHER branch of the skip condition.
|
||||
|
|
|
|||
169
gitnexus/test/integration/fts-repair-warm-session.test.ts
Normal file
169
gitnexus/test/integration/fts-repair-warm-session.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* Integration test for issue #2767: the MCP `query` tool reported "FTS
|
||||
* indexes missing" against an index the CLI could search successfully,
|
||||
* because a long-lived MCP session's pooled read-only connection had no
|
||||
* reliable signal that `gitnexus analyze --repair-fts` changed FTS
|
||||
* availability (repair-fts intentionally never restamps `indexedAt`).
|
||||
*
|
||||
* Everything real: a real writable LadybugDB session builds the initial
|
||||
* index WITHOUT FTS (the exact shape implied by the original report — FTS
|
||||
* built later), a real `LocalBackend` resolves it via the real registry and
|
||||
* issues a real `query` tool call through the real connection pool, then a
|
||||
* SEPARATE real writable session performs the repair (real
|
||||
* `createSearchFTSIndexes`, real `saveMeta` capability stamp — the same
|
||||
* production functions `--repair-fts` calls), and the SAME still-warm
|
||||
* `LocalBackend` instance re-queries without any restart.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
import { resolveAnalyzeInstallPolicy } from '../../src/core/lbug/extension-loader.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
registerRepo,
|
||||
saveMeta,
|
||||
type RepoMeta,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import { closeLbug as poolClose } from '../../src/core/lbug/pool-adapter.js';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
|
||||
const REQUIRE_FTS = process.env.GITNEXUS_REQUIRE_FTS === '1';
|
||||
|
||||
type QueryResult = {
|
||||
error?: unknown;
|
||||
warning?: string;
|
||||
definitions?: Array<{ id: string }>;
|
||||
process_symbols?: Array<{ id: string }>;
|
||||
};
|
||||
|
||||
const matchedIds = (r: QueryResult): string[] =>
|
||||
[...(r.process_symbols ?? []), ...(r.definitions ?? [])].map((s) => s.id);
|
||||
|
||||
const ftsMissing = (r: QueryResult): boolean =>
|
||||
typeof r.warning === 'string' && /FTS indexes missing/i.test(r.warning);
|
||||
|
||||
/**
|
||||
* Poll the SAME warm `LocalBackend` until it stops reporting FTS-missing, or
|
||||
* the deadline passes. Exercises the real 5s staleness-check throttle
|
||||
* (`ensureInitialized`) rather than sleeping-and-hoping or reaching into
|
||||
* backend internals to bypass it — proves the fix holds within the actual
|
||||
* production timing window.
|
||||
*/
|
||||
async function waitForFtsRecognized(
|
||||
backend: LocalBackend,
|
||||
query: string,
|
||||
// Production throttle is 5s (`lastStalenessCheck`); this deadline leaves a
|
||||
// generous margin beyond it for a loaded CI runner, per review feedback
|
||||
// that the original 7s deadline left only ~2s of slack (#2767).
|
||||
timeoutMs = 15000,
|
||||
intervalMs = 300,
|
||||
): Promise<QueryResult> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last: QueryResult;
|
||||
do {
|
||||
last = await backend.callTool('query', { query });
|
||||
if (!ftsMissing(last)) return last;
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
} while (Date.now() < deadline);
|
||||
return last!;
|
||||
}
|
||||
|
||||
describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', () => {
|
||||
let tmpHandle: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let repoPath: string;
|
||||
let storagePath: string;
|
||||
let lbugPath: string;
|
||||
let savedHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHandle = await createTempDir('gnx-fts-repair-warm-');
|
||||
repoPath = tmpHandle.dbPath;
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = path.join(repoPath, '.gitnexus-home');
|
||||
({ storagePath, lbugPath } = getStoragePaths(repoPath));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await poolClose(lbugPath).catch(() => {});
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpHandle.cleanup();
|
||||
});
|
||||
|
||||
it(
|
||||
'a warm session transitions from FTS-unavailable to FTS-available without restarting, after an out-of-band --repair-fts',
|
||||
{ timeout: 60_000 },
|
||||
async (ctx) => {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const { createSearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js');
|
||||
|
||||
// ── Step 1: build the index WITHOUT FTS (analyzed before repair) ────
|
||||
await adapter.initLbug(lbugPath);
|
||||
|
||||
const ftsAvailable = await adapter.loadFTSExtension(undefined, {
|
||||
policy: resolveAnalyzeInstallPolicy(),
|
||||
});
|
||||
if (!ftsAvailable) {
|
||||
if (REQUIRE_FTS) {
|
||||
throw new Error(
|
||||
'FTS extension is required (GITNEXUS_REQUIRE_FTS=1) but could not be loaded — ' +
|
||||
'this FTS-dependent integration test must not be silently skipped in CI.',
|
||||
);
|
||||
}
|
||||
await adapter.closeLbug();
|
||||
ctx.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
await adapter.executeQuery(
|
||||
`CREATE (n:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 3, content: 'function login() { return true; }'})`,
|
||||
);
|
||||
await adapter.flushWAL();
|
||||
await adapter.closeLbug();
|
||||
|
||||
const indexedAt = new Date().toISOString();
|
||||
const baseMeta: RepoMeta = {
|
||||
repoPath,
|
||||
lastCommit: 'c1',
|
||||
indexedAt,
|
||||
stats: { files: 1, nodes: 1 },
|
||||
capabilities: {
|
||||
graph: { provider: 'ladybugdb', status: 'available' },
|
||||
fts: { provider: 'ladybugdb-fts', status: 'unavailable' },
|
||||
vectorSearch: { provider: 'exact-scan', status: 'unavailable', exactScanLimit: 0 },
|
||||
},
|
||||
};
|
||||
await saveMeta(storagePath, baseMeta);
|
||||
await registerRepo(repoPath, baseMeta, { name: 'test-repo' });
|
||||
|
||||
// ── Step 2: a real warm LocalBackend observes "FTS unavailable" ─────
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
const before = await backend.callTool('query', { query: 'login' });
|
||||
expect(before.error).toBeUndefined();
|
||||
expect(ftsMissing(before)).toBe(true);
|
||||
|
||||
// ── Step 3: out-of-band --repair-fts (separate writable session) ────
|
||||
// Same production functions the repair-fts branch of runFullAnalysis
|
||||
// calls — real FTS build, then the #2767 capability-only meta stamp
|
||||
// (indexedAt/lastCommit deliberately unchanged, R4).
|
||||
await adapter.initLbug(lbugPath);
|
||||
await createSearchFTSIndexes();
|
||||
await adapter.flushWAL();
|
||||
await adapter.closeLbug();
|
||||
await saveMeta(storagePath, {
|
||||
...baseMeta,
|
||||
capabilities: {
|
||||
...baseMeta.capabilities!,
|
||||
fts: { provider: 'ladybugdb-fts', status: 'available' },
|
||||
},
|
||||
});
|
||||
|
||||
// ── Step 4: the SAME still-warm backend re-queries — no restart ─────
|
||||
const after = await waitForFtsRecognized(backend, 'login');
|
||||
expect(after.error).toBeUndefined();
|
||||
expect(ftsMissing(after)).toBe(false);
|
||||
expect(matchedIds(after)).toContain('func:login');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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'];
|
||||
|
||||
|
|
|
|||
|
|
@ -2336,6 +2336,44 @@ describe('Python module import CALLS resolution (Issue #337)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module reached through `from pkg import models` (#2746)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Python from-import module alias resolution', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-from-module-alias'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('links the imported module rather than the package initializer', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const appImports = imports.filter((edge) => edge.sourceFilePath === 'pkg/app.py');
|
||||
|
||||
expect(appImports.length).toBeGreaterThan(0);
|
||||
expect(appImports.every((edge) => edge.targetFilePath === 'pkg/models.py')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves inline and assigned calls through the module alias', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(edge) => edge.sourceFilePath === 'pkg/app.py',
|
||||
);
|
||||
|
||||
expect(calls.filter((edge) => edge.target === 'User')).toHaveLength(2);
|
||||
expect(calls.filter((edge) => edge.target === 'save')).toHaveLength(3);
|
||||
expect(calls.every((edge) => edge.targetFilePath === 'pkg/models.py')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not bind the same-named class from an unrelated module', () => {
|
||||
const wrongCalls = getRelationships(result, 'CALLS').filter(
|
||||
(edge) => edge.sourceFilePath === 'pkg/app.py' && edge.targetFilePath === 'decoy/models.py',
|
||||
);
|
||||
|
||||
expect(wrongCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External dotted imports: framework modules like django.apps must not resolve
|
||||
// to unrelated local basename matches such as accounts/apps.py or config/urls.py.
|
||||
|
|
|
|||
507
gitnexus/test/integration/spring-aop-benchmark.test.ts
Normal file
507
gitnexus/test/integration/spring-aop-benchmark.test.ts
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
/**
|
||||
* Spring AOP candidate-selection and Kotlin capture benchmarks (#2416).
|
||||
*
|
||||
* Normal CI runs deterministic work-count tripwires. Wall-clock scaling and
|
||||
* mixed-language pipeline measurements stay behind the benchmark flag:
|
||||
*
|
||||
* GITNEXUS_BENCH=1 npx vitest run test/integration/spring-aop-benchmark.test.ts
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createSpringAopCandidateIndex,
|
||||
type SpringAopOwnedMethod,
|
||||
} from '../../src/core/ingestion/frameworks/spring/aop-candidates.js';
|
||||
import {
|
||||
decodeSpringAopReason,
|
||||
parseSpringAopPointcut,
|
||||
springAopPointcutMatches,
|
||||
type SpringAopStaticPointcut,
|
||||
} from '../../src/core/ingestion/frameworks/spring/aop.js';
|
||||
import { collectKotlinCaptureSideChannel } from '../../src/core/ingestion/languages/kotlin/capture-side-channel.js';
|
||||
import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/captures.js';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
|
||||
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
|
||||
const TRANSACTIONAL = 'org.springframework.transaction.annotation.Transactional';
|
||||
const CACHEABLE = 'org.springframework.cache.annotation.Cacheable';
|
||||
|
||||
interface CandidateFixture {
|
||||
readonly candidates: readonly SpringAopOwnedMethod[];
|
||||
readonly methodAnnotations: ReadonlyMap<string, ReadonlySet<string>>;
|
||||
}
|
||||
|
||||
function candidateFixture(methodCount: number): CandidateFixture {
|
||||
const methodsPerOwner = 10;
|
||||
if (methodCount % methodsPerOwner !== 0) {
|
||||
throw new Error(`methodCount must be divisible by ${methodsPerOwner}`);
|
||||
}
|
||||
|
||||
const candidates: SpringAopOwnedMethod[] = [];
|
||||
const methodAnnotations = new Map<string, Set<string>>();
|
||||
for (let ownerIndex = 0; ownerIndex < methodCount / methodsPerOwner; ownerIndex += 1) {
|
||||
const language = ownerIndex % 2 === 0 ? 'java' : 'kotlin';
|
||||
const extension = language === 'java' ? 'java' : 'kt';
|
||||
const qualifiedName = `com.example.partition${ownerIndex % 100}.Service${ownerIndex}`;
|
||||
const filePath = `src/${language}/Service${ownerIndex}.${extension}`;
|
||||
const owner: GraphNode = {
|
||||
id: `Class:${qualifiedName}:${extension}`,
|
||||
label: 'Class',
|
||||
properties: {
|
||||
name: `Service${ownerIndex}`,
|
||||
qualifiedName,
|
||||
filePath,
|
||||
language,
|
||||
startLine: 1,
|
||||
endLine: 40,
|
||||
isExported: true,
|
||||
},
|
||||
};
|
||||
|
||||
for (let methodIndex = 0; methodIndex < methodsPerOwner; methodIndex += 1) {
|
||||
const name = `${methodIndex % 2 === 0 ? 'read' : 'write'}${methodIndex}`;
|
||||
const method: GraphNode = {
|
||||
id: `Method:${qualifiedName}.${name}:${extension}`,
|
||||
label: 'Method',
|
||||
properties: {
|
||||
name,
|
||||
qualifiedName: `${qualifiedName}.${name}`,
|
||||
filePath,
|
||||
language,
|
||||
startLine: methodIndex + 2,
|
||||
endLine: methodIndex + 2,
|
||||
isExported: true,
|
||||
visibility: methodIndex % 3 === 0 ? 'protected' : 'public',
|
||||
parameterCount: methodIndex % 3,
|
||||
},
|
||||
};
|
||||
candidates.push({ method, owner });
|
||||
|
||||
const annotations = new Set<string>();
|
||||
if (ownerIndex % 100 === 0 && methodIndex === 0) annotations.add(TRANSACTIONAL);
|
||||
if (ownerIndex % 125 === 1 && methodIndex === 1) annotations.add(CACHEABLE);
|
||||
if (annotations.size > 0) methodAnnotations.set(method.id, annotations);
|
||||
}
|
||||
}
|
||||
return { candidates, methodAnnotations };
|
||||
}
|
||||
|
||||
function parsePointcut(expression: string): SpringAopStaticPointcut {
|
||||
const pointcut = parseSpringAopPointcut(expression);
|
||||
if (pointcut === null) throw new Error(`Expected a static pointcut: ${expression}`);
|
||||
return pointcut;
|
||||
}
|
||||
|
||||
function matchingIds(
|
||||
pointcut: SpringAopStaticPointcut,
|
||||
candidates: readonly SpringAopOwnedMethod[],
|
||||
methodAnnotations: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
): string[] {
|
||||
return candidates
|
||||
.filter((candidate) =>
|
||||
springAopPointcutMatches(
|
||||
pointcut,
|
||||
candidate.owner,
|
||||
candidate.method,
|
||||
methodAnnotations.get(candidate.method.id),
|
||||
),
|
||||
)
|
||||
.map((candidate) => candidate.method.id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function selectivePointcuts(): SpringAopStaticPointcut[] {
|
||||
const partitions = [0, 7, 19, 42, 88];
|
||||
return [
|
||||
...partitions.map((partition) => parsePointcut(`within(com.example.partition${partition}..*)`)),
|
||||
...partitions.map((partition) =>
|
||||
parsePointcut(`execution(public * com.example.partition${partition}..*.read*(*))`),
|
||||
),
|
||||
...partitions.map((partition) =>
|
||||
parsePointcut(`within(com.example.partition${partition}.Service${partition})`),
|
||||
),
|
||||
parsePointcut(`@annotation(${TRANSACTIONAL})`),
|
||||
parsePointcut(`@annotation(${CACHEABLE})`),
|
||||
];
|
||||
}
|
||||
|
||||
describe('Spring AOP candidate-index regression tripwire (#2416)', () => {
|
||||
it('preserves brute-force matches while reducing selective advice inspections by 10x', () => {
|
||||
const fixture = candidateFixture(50_000);
|
||||
const index = createSpringAopCandidateIndex(fixture.candidates, fixture.methodAnnotations);
|
||||
const pointcuts = selectivePointcuts();
|
||||
let indexedInspections = 0;
|
||||
|
||||
for (const pointcut of pointcuts) {
|
||||
const selected = index.candidatesFor(pointcut);
|
||||
indexedInspections += selected.length;
|
||||
expect(matchingIds(pointcut, selected, fixture.methodAnnotations)).toEqual(
|
||||
matchingIds(pointcut, fixture.candidates, fixture.methodAnnotations),
|
||||
);
|
||||
}
|
||||
|
||||
const bruteForceInspections = pointcuts.length * fixture.candidates.length;
|
||||
expect(index.totalCandidates).toBe(50_000);
|
||||
expect(indexedInspections).toBeLessThanOrEqual(bruteForceInspections / 10);
|
||||
|
||||
const leadingWildcard = parsePointcut('within(*..Service*)');
|
||||
const broadCandidates = index.candidatesFor(leadingWildcard);
|
||||
expect(broadCandidates).toHaveLength(fixture.candidates.length);
|
||||
expect(matchingIds(leadingWildcard, broadCandidates, fixture.methodAnnotations)).toEqual(
|
||||
matchingIds(leadingWildcard, fixture.candidates, fixture.methodAnnotations),
|
||||
);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('Spring AOP broad-advice budget regression tripwire (#2416)', () => {
|
||||
it('bounds aggregate edge work across multiple broad advices and reports truncation', async () => {
|
||||
const root = writeMixedSpringAopRepo(20);
|
||||
const progressMessages: string[] = [];
|
||||
try {
|
||||
const result = await runPipelineFromRepo(
|
||||
root,
|
||||
(progress) => progressMessages.push(progress.message),
|
||||
{
|
||||
skipGraphPhases: true,
|
||||
workerPoolSize: 1,
|
||||
springAopMaxCandidateInspectionsPerAdvice: 0,
|
||||
springAopMaxCandidateInspections: 0,
|
||||
springAopMaxAdvisedEdgesPerAdvice: 5,
|
||||
springAopMaxAdvisedEdges: 9,
|
||||
},
|
||||
);
|
||||
const adviceEdges = [...result.graph.iterRelationshipsByType('ADVISED_BY')].filter(
|
||||
(relationship) => decodeSpringAopReason(relationship.reason)?.kind === 'advice',
|
||||
);
|
||||
|
||||
expect(adviceEdges).toHaveLength(9);
|
||||
expect(progressMessages).toContain(
|
||||
'Spring AOP advice resolution truncated by configured budgets',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('bounds aggregate candidate inspections across multiple broad advices', async () => {
|
||||
const root = writeMixedSpringAopRepo(20);
|
||||
const progressMessages: string[] = [];
|
||||
try {
|
||||
const result = await runPipelineFromRepo(
|
||||
root,
|
||||
(progress) => progressMessages.push(progress.message),
|
||||
{
|
||||
skipGraphPhases: true,
|
||||
workerPoolSize: 1,
|
||||
springAopMaxCandidateInspectionsPerAdvice: 4,
|
||||
springAopMaxCandidateInspections: 7,
|
||||
springAopMaxAdvisedEdgesPerAdvice: 0,
|
||||
springAopMaxAdvisedEdges: 0,
|
||||
},
|
||||
);
|
||||
const adviceEdges = [...result.graph.iterRelationshipsByType('ADVISED_BY')].filter(
|
||||
(relationship) => decodeSpringAopReason(relationship.reason)?.kind === 'advice',
|
||||
);
|
||||
|
||||
expect(adviceEdges).toHaveLength(7);
|
||||
expect(progressMessages).toContain(
|
||||
'Spring AOP advice resolution truncated by configured budgets',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
function denseKotlinAopSource(classCount: number): string {
|
||||
const classes = Array.from({ length: classCount }, (_, index) => {
|
||||
const transactional =
|
||||
index % 50 === 0
|
||||
? `
|
||||
@Tx
|
||||
fun transactional${index}() {}`
|
||||
: '';
|
||||
return `
|
||||
@Noise
|
||||
class Subject${index} {
|
||||
@Noise
|
||||
fun ordinary${index}() {}
|
||||
|
||||
@OtherNoise
|
||||
fun secondary${index}() {}
|
||||
${transactional}
|
||||
}
|
||||
`;
|
||||
}).join('\n');
|
||||
|
||||
return `package com.example
|
||||
|
||||
import org.aspectj.lang.annotation.Aspect as AopAspect
|
||||
import org.aspectj.lang.annotation.Before as AdviceBefore
|
||||
import org.springframework.transaction.annotation.Transactional as Tx
|
||||
|
||||
@AopAspect
|
||||
class DenseAspect {
|
||||
@AdviceBefore("@annotation(org.springframework.transaction.annotation.Transactional)")
|
||||
fun beforeTransaction() {}
|
||||
}
|
||||
|
||||
${classes}
|
||||
`;
|
||||
}
|
||||
|
||||
interface KotlinCaptureResult {
|
||||
readonly classCount: number;
|
||||
readonly elapsedMs: number;
|
||||
readonly captureCount: number;
|
||||
readonly factCount: number;
|
||||
readonly annotationNames: readonly string[];
|
||||
}
|
||||
|
||||
function runKotlinAopCapture(classCount: number, run: number): KotlinCaptureResult {
|
||||
const filePath = `src/SpringAopBench${classCount}_${run}.kt`;
|
||||
const startedAt = performance.now();
|
||||
const captures = emitKotlinScopeCaptures(denseKotlinAopSource(classCount), filePath);
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
const facts = collectKotlinCaptureSideChannel(filePath)?.springAopFacts ?? [];
|
||||
return {
|
||||
classCount,
|
||||
elapsedMs,
|
||||
captureCount: captures.length,
|
||||
factCount: facts.length,
|
||||
annotationNames: facts.flatMap((fact) => fact.annotations.map((annotation) => annotation.name)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Kotlin Spring AOP capture regression tripwire (#2416)', () => {
|
||||
it('captures dense unrelated annotations and every Spring alias within a coarse budget', () => {
|
||||
const classCount = 400;
|
||||
const aliasedTransactionalCount = classCount / 50;
|
||||
const budgetMs = 10_000;
|
||||
|
||||
runKotlinAopCapture(4, 0);
|
||||
const smaller = runKotlinAopCapture(classCount / 2, 1);
|
||||
const result = runKotlinAopCapture(classCount, 1);
|
||||
|
||||
expect(smaller.factCount).toBe((classCount / 2) * 3 + aliasedTransactionalCount / 2 + 2);
|
||||
expect(result.factCount).toBe(classCount * 3 + aliasedTransactionalCount + 2);
|
||||
expect(result.annotationNames.filter((name) => name === 'Noise')).toHaveLength(classCount * 2);
|
||||
expect(result.annotationNames.filter((name) => name === 'OtherNoise')).toHaveLength(classCount);
|
||||
expect(result.annotationNames.filter((name) => name === 'Tx')).toHaveLength(
|
||||
aliasedTransactionalCount,
|
||||
);
|
||||
expect(result.annotationNames.filter((name) => name === 'AopAspect')).toHaveLength(1);
|
||||
expect(result.annotationNames.filter((name) => name === 'AdviceBefore')).toHaveLength(1);
|
||||
expect(result.captureCount).toBeGreaterThan(classCount * 8);
|
||||
expect(result.captureCount / smaller.captureCount).toBeGreaterThan(1.9);
|
||||
expect(result.captureCount / smaller.captureCount).toBeLessThan(2.05);
|
||||
expect(result.elapsedMs).toBeLessThan(budgetMs);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
interface SelectorBenchResult {
|
||||
readonly methods: number;
|
||||
readonly buildMs: number;
|
||||
readonly queryMs: number;
|
||||
readonly examined: number;
|
||||
readonly matches: number;
|
||||
}
|
||||
|
||||
function runSelectorBenchmark(methods: number): SelectorBenchResult {
|
||||
const fixture = candidateFixture(methods);
|
||||
const buildStartedAt = performance.now();
|
||||
const index = createSpringAopCandidateIndex(fixture.candidates, fixture.methodAnnotations);
|
||||
const buildMs = performance.now() - buildStartedAt;
|
||||
const pointcuts = selectivePointcuts();
|
||||
let examined = 0;
|
||||
let matches = 0;
|
||||
const queryStartedAt = performance.now();
|
||||
for (const pointcut of pointcuts) {
|
||||
const selected = index.candidatesFor(pointcut);
|
||||
examined += selected.length;
|
||||
matches += matchingIds(pointcut, selected, fixture.methodAnnotations).length;
|
||||
}
|
||||
const queryMs = performance.now() - queryStartedAt;
|
||||
return { methods, buildMs, queryMs, examined, matches };
|
||||
}
|
||||
|
||||
describe.skipIf(!BENCH_ENABLED)('Spring AOP candidate-index scaling benchmark (#2416)', () => {
|
||||
it('reports build/query scaling while keeping selective work proportional to candidates', () => {
|
||||
const scales = [10_000, 50_000, 100_000];
|
||||
const results = scales.map((methods) => runSelectorBenchmark(methods));
|
||||
|
||||
for (const result of results) {
|
||||
const bruteForceInspections = selectivePointcuts().length * result.methods;
|
||||
console.log(
|
||||
` selector methods=${result.methods}: build=${result.buildMs.toFixed(1)}ms ` +
|
||||
`query=${result.queryMs.toFixed(1)}ms (${result.examined} examined, ` +
|
||||
`${result.matches} matches)`,
|
||||
);
|
||||
expect(result.examined).toBeLessThanOrEqual(bruteForceInspections / 10);
|
||||
expect(result.matches).toBeGreaterThan(0);
|
||||
expect(result.buildMs + result.queryMs).toBeLessThan(10_000);
|
||||
}
|
||||
|
||||
const first = results[0]!;
|
||||
const last = results[results.length - 1]!;
|
||||
const workRatio = last.examined / first.examined;
|
||||
const sizeRatio = last.methods / first.methods;
|
||||
expect(workRatio).toBeGreaterThan(sizeRatio * 0.9);
|
||||
expect(workRatio).toBeLessThan(sizeRatio * 1.1);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
function writeFixture(root: string, relativePath: string, content: string): void {
|
||||
const target = path.join(root, relativePath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content);
|
||||
}
|
||||
|
||||
function javaServices(count: number): string {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) => `
|
||||
class JavaService${index} {
|
||||
@Transactional public void transaction${index}() {}
|
||||
public void read${index}() {}
|
||||
public void write${index}() {}
|
||||
}
|
||||
`,
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
function kotlinServices(count: number): string {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) => `
|
||||
class KotlinService${index} {
|
||||
@Tx fun transaction${index}() {}
|
||||
fun read${index}() {}
|
||||
fun write${index}() {}
|
||||
}
|
||||
`,
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
function writeMixedSpringAopRepo(serviceCount: number): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `spring-aop-bench-${serviceCount}-`));
|
||||
const javaCount = serviceCount / 2;
|
||||
const kotlinCount = serviceCount - javaCount;
|
||||
writeFixture(
|
||||
root,
|
||||
'src/main/java/com/example/service/Services.java',
|
||||
`package com.example.service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
${javaServices(javaCount)}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
root,
|
||||
'src/main/kotlin/com/example/service/Services.kt',
|
||||
`package com.example.service
|
||||
import org.springframework.transaction.annotation.Transactional as Tx
|
||||
${kotlinServices(kotlinCount)}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
root,
|
||||
'src/main/java/com/example/aspect/BenchmarkAspect.java',
|
||||
`package com.example.aspect;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Before;
|
||||
|
||||
@Aspect
|
||||
public class BenchmarkAspect {
|
||||
@Before("@annotation(org.springframework.transaction.annotation.Transactional)")
|
||||
public void transactionalAdvice() {}
|
||||
|
||||
@Before("within(com.example.service.KotlinService*)")
|
||||
public void kotlinServiceAdvice() {}
|
||||
|
||||
@Before("execution(public * com.example.service.JavaService*.read*(..))")
|
||||
public void javaReadAdvice() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
interface PipelineBenchResult {
|
||||
readonly services: number;
|
||||
readonly elapsedMs: number;
|
||||
readonly advisedBy: number;
|
||||
readonly behaviorEdges: number;
|
||||
readonly adviceEdges: number;
|
||||
readonly transactionalAdviceEdges: number;
|
||||
readonly kotlinAdviceEdges: number;
|
||||
readonly javaAdviceEdges: number;
|
||||
}
|
||||
|
||||
async function runMixedPipelineBenchmark(serviceCount: number): Promise<PipelineBenchResult> {
|
||||
const root = writeMixedSpringAopRepo(serviceCount);
|
||||
try {
|
||||
const startedAt = performance.now();
|
||||
const result = await runPipelineFromRepo(root, () => {}, {
|
||||
skipGraphPhases: true,
|
||||
workerPoolSize: 1,
|
||||
});
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
const advisedBy = [...result.graph.iterRelationshipsByType('ADVISED_BY')];
|
||||
let behaviorEdges = 0;
|
||||
let adviceEdges = 0;
|
||||
for (const relationship of advisedBy) {
|
||||
const kind = decodeSpringAopReason(relationship.reason)?.kind;
|
||||
if (kind === 'behavior') behaviorEdges += 1;
|
||||
if (kind === 'advice') adviceEdges += 1;
|
||||
}
|
||||
const adviceEdgeCount = (name: string): number =>
|
||||
advisedBy.filter(
|
||||
(relationship) =>
|
||||
decodeSpringAopReason(relationship.reason)?.kind === 'advice' &&
|
||||
result.graph.getNode(relationship.targetId)?.properties.name === name,
|
||||
).length;
|
||||
|
||||
return {
|
||||
services: serviceCount,
|
||||
elapsedMs,
|
||||
advisedBy: advisedBy.length,
|
||||
behaviorEdges,
|
||||
adviceEdges,
|
||||
transactionalAdviceEdges: adviceEdgeCount('transactionalAdvice'),
|
||||
kotlinAdviceEdges: adviceEdgeCount('kotlinServiceAdvice'),
|
||||
javaAdviceEdges: adviceEdgeCount('javaReadAdvice'),
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!BENCH_ENABLED)('mixed Java/Kotlin Spring AOP pipeline benchmark (#2416)', () => {
|
||||
it('scales real behavior and advice materialization with exact ADVISED_BY counts', async () => {
|
||||
const scales = [20, 40, 80];
|
||||
const results: PipelineBenchResult[] = [];
|
||||
for (const services of scales) {
|
||||
const result = await runMixedPipelineBenchmark(services);
|
||||
results.push(result);
|
||||
console.log(
|
||||
` pipeline services=${services}: ${result.elapsedMs.toFixed(1)}ms ` +
|
||||
`(${result.behaviorEdges} behavior, ${result.adviceEdges} advice edges)`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
const javaServiceCount = result.services / 2;
|
||||
const kotlinServiceCount = result.services - javaServiceCount;
|
||||
expect(result.behaviorEdges).toBe(result.services);
|
||||
expect(result.transactionalAdviceEdges).toBe(result.services);
|
||||
expect(result.kotlinAdviceEdges).toBe(kotlinServiceCount * 3);
|
||||
expect(result.javaAdviceEdges).toBe(javaServiceCount);
|
||||
expect(result.adviceEdges).toBe(result.services + kotlinServiceCount * 3 + javaServiceCount);
|
||||
expect(result.advisedBy).toBe(result.behaviorEdges + result.adviceEdges);
|
||||
expect(result.elapsedMs).toBeLessThan(120_000);
|
||||
}
|
||||
}, 300_000);
|
||||
});
|
||||
457
gitnexus/test/integration/spring-aop-mcp.test.ts
Normal file
457
gitnexus/test/integration/spring-aop-mcp.test.ts
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { querySpringAopMetadata } from '../../src/mcp/local/aop-metadata.js';
|
||||
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
||||
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
||||
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
||||
findSiblingClones: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const SERVICE_ID = 'Class:src/PaymentService.java:PaymentService';
|
||||
const ASPECT_CLASS_ID = 'Class:src/AuditAspect.java:AuditAspect';
|
||||
const NOISY_ASPECT_CLASS_ID = 'Class:src/NoisyAspect.java:NoisyAspect';
|
||||
const PAY_ID = 'Method:src/PaymentService.java:PaymentService.pay#0';
|
||||
const CLASS_LEVEL_METHOD_ID = 'Method:src/PaymentService.java:PaymentService.list#0';
|
||||
const AUDIT_ID = 'Method:src/AuditAspect.java:AuditAspect.audit#0';
|
||||
const UNKNOWN_ADVICE_ID = 'Method:src/AuditAspect.java:AuditAspect.authorize#0';
|
||||
const RESOLVED_ADVICE_ID = 'Method:src/AuditAspect.java:AuditAspect.resolvedPointcut#0';
|
||||
const HIGH_FAN_IN_ADVICE_ID = 'Method:src/AuditAspect.java:AuditAspect.hotAdvice#0';
|
||||
const NOISY_ADVICE_ID = 'Method:src/NoisyAspect.java:NoisyAspect.noisyAdvice#0';
|
||||
const NOISY_ADVISED_SOURCE_ID = 'Method:src/NoisySource.java:NoisySource.run#0';
|
||||
const NOISY_ADVICE_TARGET_ID = 'Method:src/NoisyTarget.java:NoisyTarget.advise#0';
|
||||
const FOREIGN_ADVISED_SOURCE_ID = 'Method:src/ForeignSource.java:ForeignSource.run#0';
|
||||
const FOREIGN_ADVICE_TARGET_ID = 'Method:src/ForeignTarget.java:ForeignTarget.advise#0';
|
||||
const TRUNCATED_SOURCE_ID = 'Method:src/TruncatedSource.java:TruncatedSource.run#0';
|
||||
const TRUNCATED_TARGET_ID = 'Method:src/TruncatedTarget.java:TruncatedTarget.advise#0';
|
||||
const PLAIN_ID = 'Method:src/Plain.java:Plain.run#0';
|
||||
const CLASS_BEHAVIOR_ID = `CodeElement:spring-aop:${SERVICE_ID}:transactional`;
|
||||
const METHOD_BEHAVIOR_ID = `CodeElement:spring-aop:${PAY_ID}:cacheable`;
|
||||
const UNKNOWN_POINTCUT_ID = `CodeElement:spring-aop:${UNKNOWN_ADVICE_ID}:pointcut`;
|
||||
const RESOLVED_POINTCUT_ID = `CodeElement:spring-aop:${RESOLVED_ADVICE_ID}:pointcut`;
|
||||
const UNRELATED_DECLARATION_ID = `CodeElement:spring-bean:${PLAIN_ID}`;
|
||||
const ASPECT_EVIDENCE_ID = `CodeElement:spring-aop:${ASPECT_CLASS_ID}:aspect`;
|
||||
const NOISY_ASPECT_EVIDENCE_ID = `CodeElement:spring-aop:${NOISY_ASPECT_CLASS_ID}:aspect`;
|
||||
const NOISY_POINTCUT_ID = `CodeElement:spring-aop:${NOISY_ADVICE_ID}:pointcut`;
|
||||
|
||||
const springReason = (value: object): string => `spring-aop:v1:${JSON.stringify(value)}`;
|
||||
|
||||
const CLASS_BEHAVIOR_REASON = springReason({
|
||||
kind: 'behavior',
|
||||
annotation: 'org.springframework.transaction.annotation.Transactional',
|
||||
behavior: 'transactional',
|
||||
declaredOn: 'class',
|
||||
activation: 'unknown',
|
||||
proxy: 'possible',
|
||||
});
|
||||
const METHOD_BEHAVIOR_REASON = springReason({
|
||||
kind: 'behavior',
|
||||
annotation: 'org.springframework.cache.annotation.Cacheable',
|
||||
behavior: 'cacheable',
|
||||
declaredOn: 'method',
|
||||
activation: 'unknown',
|
||||
proxy: 'possible',
|
||||
});
|
||||
const ADVICE_REASON = springReason({
|
||||
kind: 'advice',
|
||||
annotation: 'org.aspectj.lang.annotation.Around',
|
||||
advice: 'around',
|
||||
pointcut: 'execution(* com.example.PaymentService.pay(..))',
|
||||
match: 'static',
|
||||
activation: 'unknown',
|
||||
proxy: 'possible',
|
||||
});
|
||||
const UNKNOWN_POINTCUT_REASON = springReason({
|
||||
kind: 'pointcut',
|
||||
annotation: 'org.aspectj.lang.annotation.Before',
|
||||
pointcut: 'securedOperation()',
|
||||
match: 'unresolved',
|
||||
resolution: 'unknown',
|
||||
});
|
||||
const RESOLVED_POINTCUT_REASON = springReason({
|
||||
kind: 'pointcut',
|
||||
annotation: 'org.aspectj.lang.annotation.Pointcut',
|
||||
pointcut: 'execution(* com.example.PaymentService.pay(..))',
|
||||
match: 'static',
|
||||
resolution: 'resolved',
|
||||
});
|
||||
const ASPECT_REASON = springReason({
|
||||
kind: 'aspect',
|
||||
annotation: 'org.aspectj.lang.annotation.Aspect',
|
||||
activation: 'unknown',
|
||||
registration: 'unknown',
|
||||
});
|
||||
|
||||
const HIGH_FAN_IN_ADVISED_IDS = Array.from(
|
||||
{ length: 31 },
|
||||
(_, index) => `Method:src/FanInService.java:FanInService.advised${index}#0`,
|
||||
);
|
||||
const HIGH_FAN_IN_CALLER_IDS = Array.from(
|
||||
{ length: 31 },
|
||||
(_, index) => `Method:src/LegacyCaller.java:LegacyCaller.call${index}#0`,
|
||||
);
|
||||
|
||||
const SEED = [
|
||||
`CREATE (c:Class {id:'${SERVICE_ID}', name:'PaymentService', filePath:'src/PaymentService.java', startLine:0, endLine:20, isExported:false, content:'class PaymentService {}', description:'', frameworkAnnotations:[]})`,
|
||||
`CREATE (c:Class {id:'${ASPECT_CLASS_ID}', name:'AuditAspect', filePath:'src/AuditAspect.java', startLine:0, endLine:20, isExported:false, content:'class AuditAspect {}', description:'', frameworkAnnotations:[]})`,
|
||||
`CREATE (c:Class {id:'${NOISY_ASPECT_CLASS_ID}', name:'NoisyAspect', filePath:'src/NoisyAspect.java', startLine:0, endLine:20, isExported:false, content:'class NoisyAspect {}', description:'', frameworkAnnotations:[]})`,
|
||||
`CREATE (m:Method {id:'${PAY_ID}', name:'pay', filePath:'src/PaymentService.java', startLine:4, endLine:8, isExported:false, content:'void pay() {}', description:'', parameterCount:0, returnType:'void'})`,
|
||||
`CREATE (m:Method {id:'${CLASS_LEVEL_METHOD_ID}', name:'list', filePath:'src/PaymentService.java', startLine:10, endLine:12, isExported:false, content:'void list() {}', description:'', parameterCount:0, returnType:'void'})`,
|
||||
`CREATE (m:Method {id:'${AUDIT_ID}', name:'audit', filePath:'src/AuditAspect.java', startLine:4, endLine:8, isExported:false, content:'Object audit() {}', description:'', parameterCount:0, returnType:'Object'})`,
|
||||
`CREATE (m:Method {id:'${UNKNOWN_ADVICE_ID}', name:'authorize', filePath:'src/AuditAspect.java', startLine:10, endLine:12, isExported:false, content:'void authorize() {}', description:'', parameterCount:0, returnType:'void'})`,
|
||||
`CREATE (m:Method {id:'${RESOLVED_ADVICE_ID}', name:'resolvedPointcut', filePath:'src/AuditAspect.java', startLine:12, endLine:13, isExported:false, content:'void resolvedPointcut() {}', description:'', parameterCount:0, returnType:'void'})`,
|
||||
`CREATE (m:Method {id:'${HIGH_FAN_IN_ADVICE_ID}', name:'hotAdvice', filePath:'src/AuditAspect.java', startLine:14, endLine:16, isExported:false, content:'void hotAdvice() {}', description:'', parameterCount:0, returnType:'void'})`,
|
||||
`CREATE (m:Method {id:'${NOISY_ADVICE_ID}', name:'noisyAdvice', filePath:'src/NoisyAspect.java', startLine:4, endLine:8, isExported:false, content:'void noisyAdvice() {}', description:'', parameterCount:0, returnType:'void'})`,
|
||||
`CREATE (m:Method {id:'${NOISY_ADVISED_SOURCE_ID}', name:'run', filePath:'src/NoisySource.java', startLine:1, endLine:2, isExported:false, content:'void run() {}', description:'', parameterCount:0, returnType:'void'})`,
|
||||
`CREATE (m:Method {id:'${NOISY_ADVICE_TARGET_ID}', name:'advise', filePath:'src/NoisyTarget.java', startLine:1, endLine:2, isExported:false, content:'void advise() {}', description:'', parameterCount:0, returnType:'void'})`,
|
||||
`CREATE (m:Method {id:'${FOREIGN_ADVISED_SOURCE_ID}', name:'run', filePath:'src/ForeignSource.java', startLine:1, endLine:2, isExported:false, content:'void run() {}', description:'', parameterCount:0, returnType:'void'})`,
|
||||
`CREATE (m:Method {id:'${FOREIGN_ADVICE_TARGET_ID}', name:'advise', filePath:'src/ForeignTarget.java', startLine:1, endLine:2, isExported:false, content:'void advise() {}', description:'', parameterCount:0, returnType:'void'})`,
|
||||
`CREATE (m:Method {id:'${TRUNCATED_SOURCE_ID}', name:'run', filePath:'src/TruncatedSource.java', startLine:1, endLine:2, isExported:false, content:'void run() {}', description:'', parameterCount:0, returnType:'void'})`,
|
||||
`CREATE (m:Method {id:'${TRUNCATED_TARGET_ID}', name:'advise', filePath:'src/TruncatedTarget.java', startLine:1, endLine:2, isExported:false, content:'void advise() {}', description:'', parameterCount:0, returnType:'void'})`,
|
||||
...HIGH_FAN_IN_ADVISED_IDS.map(
|
||||
(id, index) =>
|
||||
`CREATE (m:Method {id:'${id}', name:'advised${index}', filePath:'src/FanInService.java', startLine:${index}, endLine:${index}, isExported:false, content:'void advised${index}() {}', description:'', parameterCount:0, returnType:'void'})`,
|
||||
),
|
||||
...HIGH_FAN_IN_CALLER_IDS.map(
|
||||
(id, index) =>
|
||||
`CREATE (m:Method {id:'${id}', name:'call${index}', filePath:'src/LegacyCaller.java', startLine:${index}, endLine:${index}, isExported:false, content:'void call${index}() {}', description:'', parameterCount:0, returnType:'void'})`,
|
||||
),
|
||||
`CREATE (m:Method {id:'${PLAIN_ID}', name:'run', filePath:'src/Plain.java', startLine:1, endLine:2, isExported:false, content:'void run() {}', description:'', parameterCount:0, returnType:'void'})`,
|
||||
`CREATE (e:CodeElement {id:'${CLASS_BEHAVIOR_ID}', name:'Transactional', filePath:'src/PaymentService.java', startLine:0, endLine:0, isExported:false, content:'', description:'Spring AOP behavior evidence'})`,
|
||||
`CREATE (e:CodeElement {id:'${METHOD_BEHAVIOR_ID}', name:'Cacheable', filePath:'src/PaymentService.java', startLine:4, endLine:4, isExported:false, content:'', description:'Spring AOP behavior evidence'})`,
|
||||
`CREATE (e:CodeElement {id:'${UNKNOWN_POINTCUT_ID}', name:'securedOperation()', filePath:'src/AuditAspect.java', startLine:10, endLine:10, isExported:false, content:'', description:'Spring AOP unresolved pointcut evidence'})`,
|
||||
`CREATE (e:CodeElement {id:'${RESOLVED_POINTCUT_ID}', name:'pay()', filePath:'src/AuditAspect.java', startLine:12, endLine:12, isExported:false, content:'', description:'Spring AOP resolved pointcut evidence'})`,
|
||||
`CREATE (e:CodeElement {id:'${UNRELATED_DECLARATION_ID}', name:'plain', filePath:'src/Plain.java', startLine:1, endLine:1, isExported:false, content:'', description:'Spring Bean factory declaration'})`,
|
||||
`CREATE (e:CodeElement {id:'${ASPECT_EVIDENCE_ID}', name:'Aspect', filePath:'src/AuditAspect.java', startLine:0, endLine:0, isExported:false, content:'', description:'Spring AOP aspect evidence'})`,
|
||||
`CREATE (e:CodeElement {id:'${NOISY_ASPECT_EVIDENCE_ID}', name:'Aspect', filePath:'src/NoisyAspect.java', startLine:0, endLine:0, isExported:false, content:'', description:'Spring AOP aspect evidence'})`,
|
||||
`CREATE (e:CodeElement {id:'${NOISY_POINTCUT_ID}', name:'securedOperation()', filePath:'src/NoisyAspect.java', startLine:4, endLine:4, isExported:false, content:'', description:'Spring AOP unresolved pointcut evidence'})`,
|
||||
`MATCH (c:Class {id:'${SERVICE_ID}'}), (e:CodeElement {id:'${CLASS_BEHAVIOR_ID}'}) CREATE (c)-[:CodeRelation {type:'ADVISED_BY', confidence:1.0, reason:'${CLASS_BEHAVIOR_REASON}', step:0}]->(e)`,
|
||||
`MATCH (m:Method {id:'${CLASS_LEVEL_METHOD_ID}'}), (e:CodeElement {id:'${CLASS_BEHAVIOR_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:1.0, reason:'${CLASS_BEHAVIOR_REASON}', step:0}]->(e)`,
|
||||
`MATCH (m:Method {id:'${PAY_ID}'}), (e:CodeElement {id:'${METHOD_BEHAVIOR_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:1.0, reason:'${METHOD_BEHAVIOR_REASON}', step:0}]->(e)`,
|
||||
// Duplicate evidence exercises read-side deduplication for indexes produced
|
||||
// by an interrupted/retried incremental write.
|
||||
`MATCH (m:Method {id:'${PAY_ID}'}), (e:CodeElement {id:'${METHOD_BEHAVIOR_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:1.0, reason:'${METHOD_BEHAVIOR_REASON}', step:0}]->(e)`,
|
||||
`MATCH (m:Method {id:'${PAY_ID}'}), (a:Method {id:'${AUDIT_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.9, reason:'${ADVICE_REASON}', step:0}]->(a)`,
|
||||
`MATCH (a:Method {id:'${UNKNOWN_ADVICE_ID}'}), (e:CodeElement {id:'${UNKNOWN_POINTCUT_ID}'}) CREATE (a)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${UNKNOWN_POINTCUT_REASON}', step:0}]->(e)`,
|
||||
`MATCH (a:Method {id:'${RESOLVED_ADVICE_ID}'}), (e:CodeElement {id:'${RESOLVED_POINTCUT_ID}'}) CREATE (a)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${RESOLVED_POINTCUT_REASON}', step:0}]->(e)`,
|
||||
`MATCH (c:Class {id:'${ASPECT_CLASS_ID}'}), (e:CodeElement {id:'${ASPECT_EVIDENCE_ID}'}) CREATE (c)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${ASPECT_REASON}', step:0}]->(e)`,
|
||||
`MATCH (m:Method {id:'${PLAIN_ID}'}), (e:CodeElement {id:'${UNRELATED_DECLARATION_ID}'}) CREATE (m)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'spring-bean-factory:{"names":["plain"],"namesKnown":true}', step:0}]->(e)`,
|
||||
`MATCH (c:Class {id:'${NOISY_ASPECT_CLASS_ID}'}), (e:CodeElement {id:'${UNRELATED_DECLARATION_ID}'}) UNWIND range(1, 1001) AS ignored CREATE (c)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'spring-bean-factory:{"names":["noise"],"namesKnown":true}', step:ignored}]->(e)`,
|
||||
`MATCH (c:Class {id:'${NOISY_ASPECT_CLASS_ID}'}), (e:CodeElement {id:'${NOISY_ASPECT_EVIDENCE_ID}'}) CREATE (c)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${ASPECT_REASON}', step:0}]->(e)`,
|
||||
`MATCH (m:Method {id:'${PLAIN_ID}'}), (e:CodeElement {id:'${NOISY_POINTCUT_ID}'}) UNWIND range(1, 1001) AS ignored CREATE (m)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'spring-bean-factory:{"names":["noise"],"namesKnown":true}', step:ignored}]->(e)`,
|
||||
`MATCH (m:Method {id:'${NOISY_ADVICE_ID}'}), (e:CodeElement {id:'${NOISY_POINTCUT_ID}'}) CREATE (m)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${UNKNOWN_POINTCUT_REASON}', step:0}]->(e)`,
|
||||
`MATCH (m:Method {id:'${NOISY_ADVISED_SOURCE_ID}'}), (a:Method {id:'${NOISY_ADVICE_TARGET_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.9, reason:'${ADVICE_REASON}', step:0}]->(a)`,
|
||||
`MATCH (m:Method {id:'${NOISY_ADVISED_SOURCE_ID}'}), (a:Method {id:'${FOREIGN_ADVICE_TARGET_ID}'}) UNWIND range(1, 1001) AS ignored CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.1, reason:'foreign-advice', step:ignored}]->(a)`,
|
||||
`MATCH (m:Method {id:'${FOREIGN_ADVISED_SOURCE_ID}'}), (a:Method {id:'${NOISY_ADVICE_TARGET_ID}'}) UNWIND range(1, 1001) AS ignored CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.1, reason:'foreign-advice', step:ignored}]->(a)`,
|
||||
`MATCH (m:Method {id:'${TRUNCATED_SOURCE_ID}'}), (a:Method {id:'${TRUNCATED_TARGET_ID}'}) UNWIND range(1, 1001) AS item CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.9, reason:'${ADVICE_REASON}', step:item}]->(a)`,
|
||||
...HIGH_FAN_IN_ADVISED_IDS.map(
|
||||
(id) =>
|
||||
`MATCH (m:Method {id:'${id}'}), (a:Method {id:'${HIGH_FAN_IN_ADVICE_ID}'}) CREATE (m)-[:CodeRelation {type:'ADVISED_BY', confidence:0.9, reason:'${ADVICE_REASON}', step:0}]->(a)`,
|
||||
),
|
||||
...HIGH_FAN_IN_CALLER_IDS.map(
|
||||
(id) =>
|
||||
`MATCH (m:Method {id:'${id}'}), (a:Method {id:'${HIGH_FAN_IN_ADVICE_ID}'}) CREATE (m)-[:CodeRelation {type:'CALLS', confidence:1.0, reason:'test fixture', step:0}]->(a)`,
|
||||
),
|
||||
];
|
||||
|
||||
withTestLbugDB(
|
||||
'spring-aop-mcp',
|
||||
(handle) => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeAll(() => {
|
||||
backend = (handle as typeof handle & { _backend: LocalBackend })._backend;
|
||||
});
|
||||
|
||||
describe('Spring AOP metadata enrichment', () => {
|
||||
it('normalizes declarative behavior and explicit advice for an advised method', async () => {
|
||||
const metadata = await querySpringAopMetadata(handle.repoId, PAY_ID, 'Method');
|
||||
|
||||
expect(metadata).toEqual({
|
||||
framework: 'spring',
|
||||
proxied: 'possible',
|
||||
behaviors: [
|
||||
{
|
||||
annotation: 'org.springframework.cache.annotation.Cacheable',
|
||||
behavior: 'cacheable',
|
||||
declaredOn: 'method',
|
||||
activation: 'unknown',
|
||||
evidenceId: METHOD_BEHAVIOR_ID,
|
||||
},
|
||||
],
|
||||
advices: [
|
||||
{
|
||||
annotation: 'org.aspectj.lang.annotation.Around',
|
||||
advice: 'around',
|
||||
pointcut: 'execution(* com.example.PaymentService.pay(..))',
|
||||
match: 'static',
|
||||
activation: 'unknown',
|
||||
adviceId: AUDIT_ID,
|
||||
adviceName: 'audit',
|
||||
adviceFilePath: 'src/AuditAspect.java',
|
||||
advisedId: PAY_ID,
|
||||
advisedName: 'pay',
|
||||
advisedFilePath: 'src/PaymentService.java',
|
||||
},
|
||||
],
|
||||
resolvedPointcuts: [],
|
||||
unresolvedPointcuts: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the same canonical advice direction from the advice method', async () => {
|
||||
const metadata = await querySpringAopMetadata(handle.repoId, AUDIT_ID, 'Method');
|
||||
|
||||
expect(metadata?.advices).toEqual([
|
||||
expect.objectContaining({
|
||||
adviceId: AUDIT_ID,
|
||||
advisedId: PAY_ID,
|
||||
advice: 'around',
|
||||
}),
|
||||
]);
|
||||
expect(metadata).not.toHaveProperty('proxied');
|
||||
});
|
||||
|
||||
it('exposes the same AOP metadata through context and impact', async () => {
|
||||
const [context, adviceContext, impact, adviceImpact] = await Promise.all([
|
||||
backend.callTool('context', { uid: PAY_ID }),
|
||||
backend.callTool('context', { uid: AUDIT_ID }),
|
||||
backend.callTool('impact', {
|
||||
target: 'pay',
|
||||
direction: 'upstream',
|
||||
}),
|
||||
backend.callTool('impact', {
|
||||
target: 'audit',
|
||||
direction: 'upstream',
|
||||
relationTypes: ['ADVISED_BY'],
|
||||
includeTests: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(context.symbol.aop).toEqual(
|
||||
expect.objectContaining({
|
||||
framework: 'spring',
|
||||
proxied: 'possible',
|
||||
behaviors: [expect.objectContaining({ behavior: 'cacheable' })],
|
||||
advices: [expect.objectContaining({ advice: 'around', adviceId: AUDIT_ID })],
|
||||
}),
|
||||
);
|
||||
expect(context.outgoing.advised_by).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ uid: AUDIT_ID, name: 'audit' })]),
|
||||
);
|
||||
expect(adviceContext.incoming.advised_by).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ uid: PAY_ID, name: 'pay' })]),
|
||||
);
|
||||
expect(adviceContext.symbol.aop).not.toHaveProperty('proxied');
|
||||
expect(impact.target.aop).toEqual(context.symbol.aop);
|
||||
expect(adviceImpact.target.aop).toEqual(adviceContext.symbol.aop);
|
||||
expect(adviceImpact.byDepth[1]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: PAY_ID,
|
||||
name: 'pay',
|
||||
relationType: 'ADVISED_BY',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps legacy context relations when advice fan-in exceeds the context window', async () => {
|
||||
const context = await backend.callTool('context', { uid: HIGH_FAN_IN_ADVICE_ID });
|
||||
|
||||
expect(context.incoming.calls).toHaveLength(30);
|
||||
expect(context.incoming.advised_by).toHaveLength(30);
|
||||
expect(context.symbol.aop.advices).toHaveLength(31);
|
||||
});
|
||||
|
||||
it('supports Class and CodeElement behavior evidence', async () => {
|
||||
const classMetadata = await querySpringAopMetadata(handle.repoId, SERVICE_ID, 'Class');
|
||||
const classLevelMethodMetadata = await querySpringAopMetadata(
|
||||
handle.repoId,
|
||||
CLASS_LEVEL_METHOD_ID,
|
||||
'Method',
|
||||
);
|
||||
const evidenceMetadata = await querySpringAopMetadata(
|
||||
handle.repoId,
|
||||
METHOD_BEHAVIOR_ID,
|
||||
'CodeElement',
|
||||
);
|
||||
|
||||
expect(classMetadata?.behaviors).toEqual([
|
||||
expect.objectContaining({ behavior: 'transactional', declaredOn: 'class' }),
|
||||
]);
|
||||
expect(classMetadata?.proxied).toBe('possible');
|
||||
expect(classLevelMethodMetadata?.behaviors).toEqual([
|
||||
expect.objectContaining({ behavior: 'transactional', declaredOn: 'class' }),
|
||||
]);
|
||||
expect(classLevelMethodMetadata?.proxied).toBe('possible');
|
||||
expect(evidenceMetadata?.behaviors).toEqual([
|
||||
expect.objectContaining({ behavior: 'cacheable', evidenceId: METHOD_BEHAVIOR_ID }),
|
||||
]);
|
||||
expect(evidenceMetadata).not.toHaveProperty('proxied');
|
||||
});
|
||||
|
||||
it('surfaces standalone Aspect declarations without claiming proxy activation', async () => {
|
||||
const [classMetadata, evidenceMetadata, context] = await Promise.all([
|
||||
querySpringAopMetadata(handle.repoId, ASPECT_CLASS_ID, 'Class'),
|
||||
querySpringAopMetadata(handle.repoId, ASPECT_EVIDENCE_ID, 'CodeElement'),
|
||||
backend.callTool('context', { uid: ASPECT_CLASS_ID }),
|
||||
]);
|
||||
const expectedAspect = {
|
||||
annotation: 'org.aspectj.lang.annotation.Aspect',
|
||||
activation: 'unknown',
|
||||
registration: 'unknown',
|
||||
evidenceId: ASPECT_EVIDENCE_ID,
|
||||
};
|
||||
|
||||
expect(classMetadata?.aspect).toEqual(expectedAspect);
|
||||
expect(classMetadata).not.toHaveProperty('proxied');
|
||||
expect(evidenceMetadata?.aspect).toEqual(expectedAspect);
|
||||
expect(evidenceMetadata).not.toHaveProperty('proxied');
|
||||
expect(context.symbol.aop.aspect).toEqual(expectedAspect);
|
||||
expect(context.symbol.aop).not.toHaveProperty('proxied');
|
||||
});
|
||||
|
||||
it('surfaces unresolved pointcuts without guessing an advised target', async () => {
|
||||
const adviceMetadata = await querySpringAopMetadata(
|
||||
handle.repoId,
|
||||
UNKNOWN_ADVICE_ID,
|
||||
'Method',
|
||||
);
|
||||
const evidenceMetadata = await querySpringAopMetadata(
|
||||
handle.repoId,
|
||||
UNKNOWN_POINTCUT_ID,
|
||||
'CodeElement',
|
||||
);
|
||||
|
||||
const expected = [
|
||||
{
|
||||
annotation: 'org.aspectj.lang.annotation.Before',
|
||||
pointcut: 'securedOperation()',
|
||||
adviceId: UNKNOWN_ADVICE_ID,
|
||||
adviceName: 'authorize',
|
||||
adviceFilePath: 'src/AuditAspect.java',
|
||||
evidenceId: UNKNOWN_POINTCUT_ID,
|
||||
},
|
||||
];
|
||||
expect(adviceMetadata).toEqual({
|
||||
framework: 'spring',
|
||||
behaviors: [],
|
||||
advices: [],
|
||||
resolvedPointcuts: [],
|
||||
unresolvedPointcuts: expected,
|
||||
});
|
||||
expect(evidenceMetadata?.unresolvedPointcuts).toEqual(expected);
|
||||
});
|
||||
|
||||
it('surfaces resolved standalone pointcut declarations from both endpoints', async () => {
|
||||
const [adviceMetadata, evidenceMetadata] = await Promise.all([
|
||||
querySpringAopMetadata(handle.repoId, RESOLVED_ADVICE_ID, 'Method'),
|
||||
querySpringAopMetadata(handle.repoId, RESOLVED_POINTCUT_ID, 'CodeElement'),
|
||||
]);
|
||||
const expected = [
|
||||
{
|
||||
annotation: 'org.aspectj.lang.annotation.Pointcut',
|
||||
pointcut: 'execution(* com.example.PaymentService.pay(..))',
|
||||
match: 'static',
|
||||
resolution: 'resolved',
|
||||
adviceId: RESOLVED_ADVICE_ID,
|
||||
adviceName: 'resolvedPointcut',
|
||||
adviceFilePath: 'src/AuditAspect.java',
|
||||
evidenceId: RESOLVED_POINTCUT_ID,
|
||||
},
|
||||
];
|
||||
|
||||
expect(adviceMetadata?.resolvedPointcuts).toEqual(expected);
|
||||
expect(evidenceMetadata?.resolvedPointcuts).toEqual(expected);
|
||||
});
|
||||
|
||||
it('orders capped rows deterministically and reports positive truncation', async () => {
|
||||
const first = await querySpringAopMetadata(handle.repoId, TRUNCATED_SOURCE_ID, 'Method');
|
||||
const second = await querySpringAopMetadata(handle.repoId, TRUNCATED_SOURCE_ID, 'Method');
|
||||
|
||||
expect(first?.truncated).toBe(true);
|
||||
expect(first?.advices).toEqual([
|
||||
expect.objectContaining({
|
||||
advisedId: TRUNCATED_SOURCE_ID,
|
||||
adviceId: TRUNCATED_TARGET_ID,
|
||||
}),
|
||||
]);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
it('does not let unrelated DECLARES exhaust the Spring AOP query budget', async () => {
|
||||
const [aspectMetadata, pointcutMetadata] = await Promise.all([
|
||||
querySpringAopMetadata(handle.repoId, NOISY_ASPECT_CLASS_ID, 'Class'),
|
||||
querySpringAopMetadata(handle.repoId, NOISY_POINTCUT_ID, 'CodeElement'),
|
||||
]);
|
||||
|
||||
expect(aspectMetadata?.aspect).toEqual({
|
||||
annotation: 'org.aspectj.lang.annotation.Aspect',
|
||||
activation: 'unknown',
|
||||
registration: 'unknown',
|
||||
evidenceId: NOISY_ASPECT_EVIDENCE_ID,
|
||||
});
|
||||
expect(aspectMetadata).not.toHaveProperty('truncated');
|
||||
expect(pointcutMetadata?.unresolvedPointcuts).toEqual([
|
||||
{
|
||||
annotation: 'org.aspectj.lang.annotation.Before',
|
||||
pointcut: 'securedOperation()',
|
||||
adviceId: NOISY_ADVICE_ID,
|
||||
adviceName: 'noisyAdvice',
|
||||
adviceFilePath: 'src/NoisyAspect.java',
|
||||
evidenceId: NOISY_POINTCUT_ID,
|
||||
},
|
||||
]);
|
||||
expect(pointcutMetadata).not.toHaveProperty('truncated');
|
||||
});
|
||||
|
||||
it('does not let unrelated ADVISED_BY exhaust either AOP query direction', async () => {
|
||||
const [sourceMetadata, targetMetadata] = await Promise.all([
|
||||
querySpringAopMetadata(handle.repoId, NOISY_ADVISED_SOURCE_ID, 'Method'),
|
||||
querySpringAopMetadata(handle.repoId, NOISY_ADVICE_TARGET_ID, 'Method'),
|
||||
]);
|
||||
|
||||
expect(sourceMetadata?.advices).toEqual([
|
||||
expect.objectContaining({
|
||||
advisedId: NOISY_ADVISED_SOURCE_ID,
|
||||
adviceId: NOISY_ADVICE_TARGET_ID,
|
||||
}),
|
||||
]);
|
||||
expect(targetMetadata?.advices).toEqual(sourceMetadata?.advices);
|
||||
expect(sourceMetadata).not.toHaveProperty('truncated');
|
||||
expect(targetMetadata).not.toHaveProperty('truncated');
|
||||
});
|
||||
|
||||
it('ignores unrelated DECLARES evidence and unsupported symbol kinds', async () => {
|
||||
await expect(
|
||||
querySpringAopMetadata(handle.repoId, PLAIN_ID, 'Method'),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
querySpringAopMetadata(handle.repoId, 'Function:src/plain.ts:run', 'Function'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
seed: SEED,
|
||||
poolAdapter: true,
|
||||
afterSetup: async (handle) => {
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
||||
{
|
||||
name: 'test-repo',
|
||||
path: '/test/repo',
|
||||
storagePath: handle.tmpHandle.dbPath,
|
||||
indexedAt: new Date().toISOString(),
|
||||
lastCommit: 'abc123',
|
||||
stats: { files: 3, nodes: 12, communities: 0, processes: 0 },
|
||||
},
|
||||
]);
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
(handle as typeof handle & { _backend?: LocalBackend })._backend = backend;
|
||||
},
|
||||
},
|
||||
);
|
||||
997
gitnexus/test/integration/spring-aop-pipeline.test.ts
Normal file
997
gitnexus/test/integration/spring-aop-pipeline.test.ts
Normal file
|
|
@ -0,0 +1,997 @@
|
|||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { decodeSpringAopReason } from '../../src/core/ingestion/frameworks/spring/aop.js';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import {
|
||||
loadParseCache,
|
||||
PARSE_CACHE_VERSION,
|
||||
pruneCache,
|
||||
saveParseCache,
|
||||
type ParseCache,
|
||||
} from '../../src/storage/parse-cache.js';
|
||||
import {
|
||||
getDurableParsedFileDir,
|
||||
pruneAndSaveDurableParsedFileStore,
|
||||
} from '../../src/storage/parsedfile-store.js';
|
||||
import type { PipelineResult } from '../../src/types/pipeline.js';
|
||||
|
||||
function writeFixture(root: string, relativePath: string, content: string): void {
|
||||
const target = path.join(root, relativePath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content);
|
||||
}
|
||||
|
||||
describe('Spring AOP, transaction, cache, and method-security pipeline (#2416)', () => {
|
||||
let dir: string;
|
||||
let result: PipelineResult;
|
||||
let nodes: GraphNode[];
|
||||
let advisedBy: GraphRelationship[];
|
||||
let declarations: GraphRelationship[];
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-aop-'));
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/java/com/example/service/OrderService.java',
|
||||
`package com.example.service;
|
||||
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
public class OrderService {
|
||||
@Transactional
|
||||
public void transactionalOperation() {}
|
||||
|
||||
@Cacheable("orders")
|
||||
public String cachedOperation() { return "cached"; }
|
||||
|
||||
@CacheEvict(cacheNames = "orders", allEntries = true)
|
||||
public void evictOperation() {}
|
||||
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public void securedOperation() {}
|
||||
|
||||
@Secured("ROLE_AUDITOR")
|
||||
public void legacySecuredOperation() {}
|
||||
|
||||
public void plainOperation() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/java/com/example/service/SecuredOperations.java',
|
||||
`package com.example.service;
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
public interface SecuredOperations {
|
||||
@PreAuthorize("hasRole('OPERATOR')")
|
||||
void interfaceSecuredOperation();
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/java/com/example/service/SecuredOperationsImpl.java',
|
||||
`package com.example.service;
|
||||
|
||||
public class SecuredOperationsImpl implements SecuredOperations {
|
||||
@Override
|
||||
public void interfaceSecuredOperation() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/java/com/example/service/ClassLevelService.java',
|
||||
`package com.example.service;
|
||||
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Transactional
|
||||
public class ClassLevelService {
|
||||
public void inheritedTransaction() {}
|
||||
private void privateHelper() {}
|
||||
public static void staticHelper() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/java/com/example/service/TransactionalOperations.java',
|
||||
`package com.example.service;
|
||||
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Transactional
|
||||
public interface TransactionalOperations {
|
||||
void interfaceInheritedTransaction();
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/java/com/example/service/TransactionalOperationsImpl.java',
|
||||
`package com.example.service;
|
||||
|
||||
public class TransactionalOperationsImpl implements TransactionalOperations {
|
||||
@Override
|
||||
public void interfaceInheritedTransaction() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/java/com/example/service/InheritedBehaviorService.java',
|
||||
`package com.example.service;
|
||||
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
class InheritedBehaviorBase {
|
||||
@Transactional
|
||||
public void overriddenTransaction() {}
|
||||
}
|
||||
|
||||
public class InheritedBehaviorService extends InheritedBehaviorBase {
|
||||
@Override
|
||||
public void overriddenTransaction() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/java/com/example/aop/OrderAspect.java',
|
||||
`package com.example.aop;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.After;
|
||||
import org.aspectj.lang.annotation.AfterReturning;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Before;
|
||||
|
||||
@Aspect
|
||||
public class OrderAspect {
|
||||
@Around("execution(* com.example..OrderService.*(..))")
|
||||
public Object traceOrderOperations(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
@Before("@annotation(org.springframework.transaction.annotation.Transactional)")
|
||||
public void transactionalAnnotationAdvice() {}
|
||||
|
||||
@Before("within(*Service)")
|
||||
public void simpleNameWithinAdvice() {}
|
||||
|
||||
@Before("execution(* *Service.kotlinCachedOperation(..))")
|
||||
public void simpleNameExecutionAdvice() {}
|
||||
|
||||
@Before("execution(public * com.example.service.SecuredOperations.interfaceSecuredOperation(..))")
|
||||
public void publicInterfaceAdvice() {}
|
||||
|
||||
@Before("within(OrderService)")
|
||||
public void unresolvedSimpleTypeAdvice() {}
|
||||
|
||||
@AfterReturning(
|
||||
pointcut = "execution(* com.example..OrderService.cachedOperation(..))",
|
||||
returning = "result")
|
||||
public void cachedReturnAdvice(Object result) {}
|
||||
|
||||
@Before("namedOrderOperations()")
|
||||
public void unresolvedNamedAdvice() {}
|
||||
|
||||
@Before("")
|
||||
public void emptyPointcutAdvice() {}
|
||||
|
||||
@After("execution(* com.example..OrderService.*(..)) && args(orderId)")
|
||||
public void unresolvedCompoundAdvice(String orderId) {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/kotlin/com/example/aop/KotlinOrderAspect.kt',
|
||||
`package com.example.aop
|
||||
|
||||
import org.aspectj.lang.annotation.Aspect
|
||||
import org.aspectj.lang.annotation.Before
|
||||
|
||||
@Aspect
|
||||
object KotlinOrderAspect {
|
||||
@Before("within(com.example.service.KotlinOrderService)")
|
||||
fun traceKotlinOperations() {}
|
||||
|
||||
@Before("within(com.example.service.KotlinObjectService)")
|
||||
fun traceKotlinObjectOperations() {}
|
||||
|
||||
@Before("""execution(* com.example..KotlinOrderService.kotlinCachedOperation(..))""")
|
||||
fun rawStringPointcutAdvice() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/kotlin/com/example/service/KotlinOrderService.kt',
|
||||
`package com.example.service
|
||||
|
||||
import org.springframework.cache.annotation.CacheEvict
|
||||
import org.springframework.cache.annotation.Cacheable
|
||||
import org.springframework.cache.annotation.CachePut
|
||||
import org.springframework.cache.annotation.Caching
|
||||
import org.springframework.security.access.annotation.Secured
|
||||
import org.springframework.security.access.prepost.PostAuthorize
|
||||
import org.springframework.security.access.prepost.PostFilter
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.access.prepost.PreFilter
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
class KotlinOrderService {
|
||||
@Transactional
|
||||
fun kotlinTransactionalOperation() {}
|
||||
|
||||
@Cacheable("orders")
|
||||
fun kotlinCachedOperation(): String = "cached"
|
||||
|
||||
@CacheEvict(cacheNames = ["orders"], allEntries = true)
|
||||
fun kotlinEvictOperation() {}
|
||||
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
fun kotlinSecuredOperation() {}
|
||||
|
||||
@Secured("ROLE_AUDITOR")
|
||||
fun kotlinLegacySecuredOperation() {}
|
||||
|
||||
@CachePut("orders")
|
||||
fun kotlinCachePutOperation() {}
|
||||
|
||||
@Caching(cacheable = [Cacheable("orders")])
|
||||
fun kotlinCachingOperation() {}
|
||||
|
||||
@PostAuthorize("returnObject != null")
|
||||
fun kotlinPostAuthorizeOperation(): String = "ok"
|
||||
|
||||
@PreFilter("filterObject != null")
|
||||
fun kotlinPreFilterOperation(values: List<String>) {}
|
||||
|
||||
@PostFilter("filterObject != null")
|
||||
fun kotlinPostFilterOperation(): List<String> = emptyList()
|
||||
|
||||
@jakarta.annotation.security.RolesAllowed("ADMIN")
|
||||
fun kotlinJakartaRolesAllowedOperation() {}
|
||||
|
||||
@javax.annotation.security.RolesAllowed("AUDITOR")
|
||||
fun kotlinJavaxRolesAllowedOperation() {}
|
||||
|
||||
@jakarta.transaction.Transactional
|
||||
fun kotlinJakartaTransaction() {}
|
||||
|
||||
@javax.transaction.Transactional
|
||||
fun kotlinJavaxTransaction() {}
|
||||
|
||||
@Transactional
|
||||
suspend fun kotlinSuspendTransaction() {}
|
||||
|
||||
@Transactional
|
||||
fun String.kotlinExtensionTransaction() {}
|
||||
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
fun kotlinFullyQualifiedTransaction() {}
|
||||
|
||||
@Transactional
|
||||
private fun kotlinPrivateTransaction() {}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun kotlinTopLevelTransaction() {}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/kotlin/com/example/service/KotlinObjectService.kt',
|
||||
`package com.example.service
|
||||
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
interface KotlinObjectContract {
|
||||
fun kotlinObjectInheritedTransaction()
|
||||
fun kotlinObjectExplicitTransaction()
|
||||
}
|
||||
|
||||
@Transactional
|
||||
object KotlinObjectService : KotlinObjectContract {
|
||||
override fun kotlinObjectInheritedTransaction() {}
|
||||
|
||||
@Transactional
|
||||
override fun kotlinObjectExplicitTransaction() {}
|
||||
|
||||
private fun kotlinObjectPrivateHelper() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/kotlin/com/example/service/KotlinTransactionalOperations.kt',
|
||||
`package com.example.service
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Transactional
|
||||
interface KotlinTransactionalOperations {
|
||||
fun kotlinInterfaceInheritedTransaction()
|
||||
|
||||
@PreAuthorize("hasRole('KOTLIN_OPERATOR')")
|
||||
fun kotlinInterfaceSecuredOperation()
|
||||
}
|
||||
|
||||
interface KotlinMethodAnnotatedOperations {
|
||||
@Transactional
|
||||
fun kotlinInterfaceExplicitTransaction()
|
||||
}
|
||||
|
||||
class KotlinTransactionalOperationsImpl : KotlinTransactionalOperations {
|
||||
override fun kotlinInterfaceInheritedTransaction() {}
|
||||
override fun kotlinInterfaceSecuredOperation() {}
|
||||
}
|
||||
|
||||
class KotlinMethodAnnotatedOperationsImpl : KotlinMethodAnnotatedOperations {
|
||||
override fun kotlinInterfaceExplicitTransaction() {}
|
||||
}
|
||||
|
||||
open class KotlinBehaviorBase {
|
||||
@Transactional
|
||||
open fun kotlinOverriddenTransaction() {}
|
||||
}
|
||||
|
||||
class KotlinBehaviorService : KotlinBehaviorBase() {
|
||||
override fun kotlinOverriddenTransaction() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/kotlin/com/example/service/KotlinCompanionService.kt',
|
||||
`package com.example.service
|
||||
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
class KotlinCompanionService {
|
||||
companion object {
|
||||
@Transactional
|
||||
fun kotlinCompanionTransaction() {}
|
||||
|
||||
@receiver:Transactional
|
||||
fun String.kotlinReceiverTargetTransaction() {}
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/kotlin/com/example/service/KotlinAliasedService.kt',
|
||||
`package com.example.service
|
||||
|
||||
import org.springframework.transaction.annotation.Transactional as Tx
|
||||
|
||||
class KotlinAliasedService {
|
||||
@Tx
|
||||
fun kotlinAliasedTransactionalOperation() {}
|
||||
|
||||
fun kotlinAliasedPlainOperation() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/kotlin/com/example/aop/KotlinAliasedAspect.kt',
|
||||
`package com.example.aop
|
||||
|
||||
import org.aspectj.lang.annotation.Aspect as AopAspect
|
||||
import org.aspectj.lang.annotation.Before as AdviceBefore
|
||||
|
||||
@AopAspect
|
||||
object KotlinAliasedAspect {
|
||||
@AdviceBefore("@annotation(org.springframework.transaction.annotation.Transactional)")
|
||||
fun aliasedTransactionalAdvice() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/kotlin/com/example/service/KotlinWildcardService.kt',
|
||||
`package com.example.service
|
||||
|
||||
import org.springframework.transaction.annotation.*
|
||||
|
||||
class KotlinWildcardService {
|
||||
@Transactional
|
||||
fun kotlinWildcardTransaction() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/kotlin/com/example/service/KotlinScriptService.kts',
|
||||
`package com.example.service
|
||||
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
class KotlinScriptService {
|
||||
@Transactional
|
||||
fun kotlinScriptTransaction() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
dir,
|
||||
'src/main/csharp/com/example/service/OrderService.cs',
|
||||
`namespace com.example.service {
|
||||
public class OrderService {
|
||||
public void foreignOperation() {}
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
result = await runPipelineFromRepo(dir, () => {}, { skipGraphPhases: false });
|
||||
nodes = [...result.graph.iterNodes()];
|
||||
advisedBy = [...result.graph.iterRelationshipsByType('ADVISED_BY')];
|
||||
declarations = [...result.graph.iterRelationshipsByType('DECLARES')];
|
||||
}, 60_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const nodeNamed = (name: string): GraphNode | undefined =>
|
||||
nodes.find((node) => node.properties.name === name);
|
||||
|
||||
const relationshipTarget = (relationship: GraphRelationship): GraphNode | undefined =>
|
||||
result.graph.getNode(relationship.targetId);
|
||||
|
||||
const methodNamedOn = (ownerName: string, methodName: string): GraphNode | undefined => {
|
||||
const owner = nodeNamed(ownerName);
|
||||
const ownership = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find(
|
||||
(relationship) =>
|
||||
relationship.sourceId === owner?.id &&
|
||||
result.graph.getNode(relationship.targetId)?.properties.name === methodName,
|
||||
);
|
||||
return ownership === undefined ? undefined : result.graph.getNode(ownership.targetId);
|
||||
};
|
||||
|
||||
const declarativeAdviceForNode = (source: GraphNode | undefined): GraphRelationship[] => {
|
||||
if (source === undefined) return [];
|
||||
return advisedBy.filter(
|
||||
(relationship) =>
|
||||
relationship.sourceId === source.id &&
|
||||
relationshipTarget(relationship)?.label === 'CodeElement',
|
||||
);
|
||||
};
|
||||
|
||||
const declarativeAdviceFor = (name: string): GraphRelationship[] =>
|
||||
declarativeAdviceForNode(nodeNamed(name));
|
||||
|
||||
const behaviorSignaturesForNode = (node: GraphNode | undefined): string[] =>
|
||||
declarativeAdviceForNode(node)
|
||||
.flatMap((relationship) => {
|
||||
const reason = decodeSpringAopReason(relationship.reason);
|
||||
return reason?.kind === 'behavior' ? [`${reason.annotation}:${reason.declaredOn}`] : [];
|
||||
})
|
||||
.sort();
|
||||
|
||||
const behaviorSignaturesFor = (name: string): string[] =>
|
||||
behaviorSignaturesForNode(nodeNamed(name));
|
||||
|
||||
it('attaches Java and Kotlin declarative behavior as explicit ADVISED_BY evidence', () => {
|
||||
const methodNames = [
|
||||
'transactionalOperation',
|
||||
'cachedOperation',
|
||||
'evictOperation',
|
||||
'securedOperation',
|
||||
'legacySecuredOperation',
|
||||
'interfaceSecuredOperation',
|
||||
'kotlinTransactionalOperation',
|
||||
'kotlinCachedOperation',
|
||||
'kotlinEvictOperation',
|
||||
'kotlinSecuredOperation',
|
||||
'kotlinLegacySecuredOperation',
|
||||
'kotlinCachePutOperation',
|
||||
'kotlinCachingOperation',
|
||||
'kotlinPostAuthorizeOperation',
|
||||
'kotlinPreFilterOperation',
|
||||
'kotlinPostFilterOperation',
|
||||
'kotlinJakartaRolesAllowedOperation',
|
||||
'kotlinJavaxRolesAllowedOperation',
|
||||
'kotlinJakartaTransaction',
|
||||
'kotlinJavaxTransaction',
|
||||
'kotlinSuspendTransaction',
|
||||
'kotlinExtensionTransaction',
|
||||
'kotlinFullyQualifiedTransaction',
|
||||
];
|
||||
|
||||
for (const methodName of methodNames) {
|
||||
const edges = declarativeAdviceFor(methodName);
|
||||
expect(edges, `${methodName} should retain its declarative Spring behavior`).toHaveLength(1);
|
||||
expect(decodeSpringAopReason(edges[0]?.reason)?.kind).toBe('behavior');
|
||||
expect(edges.map((edge) => relationshipTarget(edge)?.label)).toEqual(['CodeElement']);
|
||||
}
|
||||
});
|
||||
|
||||
it('stores synthetic evidence locations in the graph zero-based line convention', () => {
|
||||
const evidence = relationshipTarget(declarativeAdviceFor('transactionalOperation')[0]!);
|
||||
|
||||
// @Transactional is on source line 10 in OrderService.java.
|
||||
expect(evidence?.properties.startLine).toBe(9);
|
||||
expect(evidence?.properties.endLine).toBe(9);
|
||||
});
|
||||
|
||||
it('fans class-level behavior out only to proxy-eligible methods', () => {
|
||||
expect(declarativeAdviceFor('ClassLevelService')).toHaveLength(1);
|
||||
expect(declarativeAdviceFor('inheritedTransaction')).toHaveLength(1);
|
||||
expect(declarativeAdviceFor('privateHelper')).toHaveLength(0);
|
||||
expect(declarativeAdviceFor('staticHelper')).toHaveLength(0);
|
||||
expect(declarativeAdviceFor('TransactionalOperations')).toHaveLength(1);
|
||||
expect(declarativeAdviceFor('interfaceInheritedTransaction')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('captures Kotlin interface class fan-out and method-declared behaviors', () => {
|
||||
expect(nodeNamed('KotlinTransactionalOperations')?.label).toBe('Interface');
|
||||
expect(nodeNamed('KotlinMethodAnnotatedOperations')?.label).toBe('Interface');
|
||||
|
||||
expect(behaviorSignaturesFor('KotlinTransactionalOperations')).toEqual([
|
||||
'org.springframework.transaction.annotation.Transactional:class',
|
||||
]);
|
||||
expect(behaviorSignaturesFor('kotlinInterfaceInheritedTransaction')).toEqual([
|
||||
'org.springframework.transaction.annotation.Transactional:class',
|
||||
]);
|
||||
expect(behaviorSignaturesFor('kotlinInterfaceSecuredOperation')).toEqual([
|
||||
'org.springframework.security.access.prepost.PreAuthorize:method',
|
||||
'org.springframework.transaction.annotation.Transactional:class',
|
||||
]);
|
||||
expect(behaviorSignaturesFor('kotlinInterfaceExplicitTransaction')).toEqual([
|
||||
'org.springframework.transaction.annotation.Transactional:method',
|
||||
]);
|
||||
expect(behaviorSignaturesFor('kotlinPrivateTransaction')).toEqual([]);
|
||||
expect(nodeNamed('kotlinTopLevelTransaction')?.label).toBe('Function');
|
||||
expect(behaviorSignaturesFor('kotlinTopLevelTransaction')).toEqual([]);
|
||||
expect(behaviorSignaturesFor('kotlinWildcardTransaction')).toEqual([
|
||||
'org.springframework.transaction.annotation.Transactional:method',
|
||||
]);
|
||||
expect(behaviorSignaturesFor('kotlinScriptTransaction')).toEqual([
|
||||
'org.springframework.transaction.annotation.Transactional:method',
|
||||
]);
|
||||
});
|
||||
|
||||
it('covers the complete Kotlin cache, security, and transaction behavior matrix', () => {
|
||||
const behaviorFor = (name: string): string | undefined => {
|
||||
const [edge] = declarativeAdviceFor(name);
|
||||
const reason = decodeSpringAopReason(edge?.reason);
|
||||
return reason?.kind === 'behavior' ? reason.behavior : undefined;
|
||||
};
|
||||
|
||||
expect({
|
||||
cachePut: behaviorFor('kotlinCachePutOperation'),
|
||||
caching: behaviorFor('kotlinCachingOperation'),
|
||||
postAuthorize: behaviorFor('kotlinPostAuthorizeOperation'),
|
||||
preFilter: behaviorFor('kotlinPreFilterOperation'),
|
||||
postFilter: behaviorFor('kotlinPostFilterOperation'),
|
||||
jakartaRoles: behaviorFor('kotlinJakartaRolesAllowedOperation'),
|
||||
javaxRoles: behaviorFor('kotlinJavaxRolesAllowedOperation'),
|
||||
jakartaTransaction: behaviorFor('kotlinJakartaTransaction'),
|
||||
javaxTransaction: behaviorFor('kotlinJavaxTransaction'),
|
||||
}).toEqual({
|
||||
cachePut: 'cache-put',
|
||||
caching: 'caching',
|
||||
postAuthorize: 'authorization',
|
||||
preFilter: 'authorization',
|
||||
postFilter: 'authorization',
|
||||
jakartaRoles: 'authorization',
|
||||
javaxRoles: 'authorization',
|
||||
jakartaTransaction: 'transactional',
|
||||
javaxTransaction: 'transactional',
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates Java and Kotlin behavior through implementations and overrides', () => {
|
||||
expect(
|
||||
behaviorSignaturesForNode(
|
||||
methodNamedOn('SecuredOperationsImpl', 'interfaceSecuredOperation'),
|
||||
),
|
||||
).toEqual(['org.springframework.security.access.prepost.PreAuthorize:method']);
|
||||
expect(
|
||||
behaviorSignaturesForNode(
|
||||
methodNamedOn('TransactionalOperationsImpl', 'interfaceInheritedTransaction'),
|
||||
),
|
||||
).toEqual(['org.springframework.transaction.annotation.Transactional:class']);
|
||||
expect(
|
||||
behaviorSignaturesForNode(methodNamedOn('InheritedBehaviorService', 'overriddenTransaction')),
|
||||
).toEqual(['org.springframework.transaction.annotation.Transactional:method']);
|
||||
expect(
|
||||
behaviorSignaturesForNode(
|
||||
methodNamedOn('KotlinTransactionalOperationsImpl', 'kotlinInterfaceSecuredOperation'),
|
||||
),
|
||||
).toEqual([
|
||||
'org.springframework.security.access.prepost.PreAuthorize:method',
|
||||
'org.springframework.transaction.annotation.Transactional:class',
|
||||
]);
|
||||
expect(
|
||||
behaviorSignaturesForNode(
|
||||
methodNamedOn('KotlinMethodAnnotatedOperationsImpl', 'kotlinInterfaceExplicitTransaction'),
|
||||
),
|
||||
).toEqual(['org.springframework.transaction.annotation.Transactional:method']);
|
||||
expect(
|
||||
behaviorSignaturesForNode(
|
||||
methodNamedOn('KotlinBehaviorService', 'kotlinOverriddenTransaction'),
|
||||
),
|
||||
).toEqual(['org.springframework.transaction.annotation.Transactional:method']);
|
||||
});
|
||||
|
||||
it('captures Kotlin companion methods as singleton behavior and fails closed on use-site targets', () => {
|
||||
expect(behaviorSignaturesFor('kotlinCompanionTransaction')).toEqual([
|
||||
'org.springframework.transaction.annotation.Transactional:method',
|
||||
]);
|
||||
expect(behaviorSignaturesFor('kotlinReceiverTargetTransaction')).toEqual([]);
|
||||
expect(behaviorSignaturesFor('kotlinAliasedPlainOperation')).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches execution(public ...) against an implicit-public Java interface method', () => {
|
||||
const advice = nodeNamed('publicInterfaceAdvice');
|
||||
const targets = advisedBy
|
||||
.filter((relationship) => relationship.targetId === advice?.id)
|
||||
.map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name);
|
||||
expect(targets).toEqual(['interfaceSecuredOperation']);
|
||||
});
|
||||
|
||||
it('treats Kotlin object members as singleton instance methods for AOP', () => {
|
||||
expect(behaviorSignaturesFor('KotlinObjectService')).toEqual([
|
||||
'org.springframework.transaction.annotation.Transactional:class',
|
||||
]);
|
||||
expect(
|
||||
behaviorSignaturesForNode(
|
||||
methodNamedOn('KotlinObjectService', 'kotlinObjectInheritedTransaction'),
|
||||
),
|
||||
).toEqual(['org.springframework.transaction.annotation.Transactional:class']);
|
||||
expect(
|
||||
behaviorSignaturesForNode(
|
||||
methodNamedOn('KotlinObjectService', 'kotlinObjectExplicitTransaction'),
|
||||
),
|
||||
).toEqual([
|
||||
'org.springframework.transaction.annotation.Transactional:class',
|
||||
'org.springframework.transaction.annotation.Transactional:method',
|
||||
]);
|
||||
expect(behaviorSignaturesFor('kotlinObjectPrivateHelper')).toEqual([]);
|
||||
|
||||
const objectAdvice = nodeNamed('traceKotlinObjectOperations');
|
||||
const advisedMethods = advisedBy
|
||||
.filter((relationship) => relationship.targetId === objectAdvice?.id)
|
||||
.map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name)
|
||||
.sort();
|
||||
expect(advisedMethods).toEqual([
|
||||
'kotlinObjectExplicitTransaction',
|
||||
'kotlinObjectInheritedTransaction',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves Kotlin aliases for behaviors, aspects, and advice annotations', () => {
|
||||
expect(behaviorSignaturesFor('kotlinAliasedTransactionalOperation')).toEqual([
|
||||
'org.springframework.transaction.annotation.Transactional:method',
|
||||
]);
|
||||
|
||||
const aliasedAspect = nodeNamed('KotlinAliasedAspect');
|
||||
const aspectMarker = declarations.find((relationship) => {
|
||||
const reason = decodeSpringAopReason(relationship.reason);
|
||||
return relationship.sourceId === aliasedAspect?.id && reason?.kind === 'aspect';
|
||||
});
|
||||
expect(aspectMarker).toBeDefined();
|
||||
|
||||
const advice = nodeNamed('aliasedTransactionalAdvice');
|
||||
const advisedMethods = advisedBy
|
||||
.filter((relationship) => relationship.targetId === advice?.id)
|
||||
.map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name)
|
||||
.sort();
|
||||
expect(advisedMethods).toEqual([
|
||||
'kotlinAliasedTransactionalOperation',
|
||||
'kotlinCompanionTransaction',
|
||||
'kotlinExtensionTransaction',
|
||||
'kotlinFullyQualifiedTransaction',
|
||||
'kotlinInterfaceExplicitTransaction',
|
||||
'kotlinObjectExplicitTransaction',
|
||||
'kotlinOverriddenTransaction',
|
||||
'kotlinScriptTransaction',
|
||||
'kotlinSuspendTransaction',
|
||||
'kotlinTransactionalOperation',
|
||||
'kotlinWildcardTransaction',
|
||||
'overriddenTransaction',
|
||||
'transactionalOperation',
|
||||
]);
|
||||
});
|
||||
|
||||
it('connects a statically understandable execution pointcut to every matching method', () => {
|
||||
const advice = nodeNamed('traceOrderOperations');
|
||||
expect(advice?.label).toBe('Method');
|
||||
|
||||
const advisedMethods = advisedBy
|
||||
.filter((relationship) => relationship.targetId === advice?.id)
|
||||
.map((relationship) => String(result.graph.getNode(relationship.sourceId)?.properties.name))
|
||||
.sort();
|
||||
|
||||
expect(advisedMethods).toEqual([
|
||||
'cachedOperation',
|
||||
'evictOperation',
|
||||
'legacySecuredOperation',
|
||||
'plainOperation',
|
||||
'securedOperation',
|
||||
'transactionalOperation',
|
||||
]);
|
||||
|
||||
const foreignMethod = nodeNamed('foreignOperation');
|
||||
const foreignOwnership = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find(
|
||||
(relationship) => relationship.targetId === foreignMethod?.id,
|
||||
);
|
||||
expect(foreignMethod?.label).toBe('Method');
|
||||
expect(result.graph.getNode(foreignOwnership?.sourceId ?? '')?.properties.qualifiedName).toBe(
|
||||
'com.example.service.OrderService',
|
||||
);
|
||||
expect(advisedBy.some((relationship) => relationship.sourceId === foreignMethod?.id)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const kotlinAdvice = nodeNamed('traceKotlinOperations');
|
||||
const kotlinAdvisedMethods = advisedBy
|
||||
.filter((relationship) => relationship.targetId === kotlinAdvice?.id)
|
||||
.map((relationship) => String(result.graph.getNode(relationship.sourceId)?.properties.name))
|
||||
.sort();
|
||||
expect(kotlinAdvisedMethods).toEqual([
|
||||
'kotlinCachePutOperation',
|
||||
'kotlinCachedOperation',
|
||||
'kotlinCachingOperation',
|
||||
'kotlinEvictOperation',
|
||||
'kotlinExtensionTransaction',
|
||||
'kotlinFullyQualifiedTransaction',
|
||||
'kotlinJakartaRolesAllowedOperation',
|
||||
'kotlinJakartaTransaction',
|
||||
'kotlinJavaxRolesAllowedOperation',
|
||||
'kotlinJavaxTransaction',
|
||||
'kotlinLegacySecuredOperation',
|
||||
'kotlinPostAuthorizeOperation',
|
||||
'kotlinPostFilterOperation',
|
||||
'kotlinPreFilterOperation',
|
||||
'kotlinSecuredOperation',
|
||||
'kotlinSuspendTransaction',
|
||||
'kotlinTransactionalOperation',
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches unqualified wildcard type patterns by owner simple name', () => {
|
||||
const withinAdvice = nodeNamed('simpleNameWithinAdvice');
|
||||
const withinTargets = advisedBy
|
||||
.filter((relationship) => relationship.targetId === withinAdvice?.id)
|
||||
.map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name);
|
||||
expect(withinTargets).toContain('plainOperation');
|
||||
expect(withinTargets).toContain('kotlinCachedOperation');
|
||||
expect(withinTargets).not.toContain('kotlinInterfaceInheritedTransaction');
|
||||
|
||||
const executionAdvice = nodeNamed('simpleNameExecutionAdvice');
|
||||
const executionTargets = advisedBy
|
||||
.filter((relationship) => relationship.targetId === executionAdvice?.id)
|
||||
.map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name);
|
||||
expect(executionTargets).toEqual(['kotlinCachedOperation']);
|
||||
});
|
||||
|
||||
it('@annotation matches only directly declared method annotations across Java and Kotlin', () => {
|
||||
const transactionalAdvice = nodeNamed('transactionalAnnotationAdvice');
|
||||
|
||||
const advisedByTransactionalAnnotation = advisedBy
|
||||
.filter((relationship) => relationship.targetId === transactionalAdvice?.id)
|
||||
.map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name)
|
||||
.sort();
|
||||
expect(advisedByTransactionalAnnotation).toEqual([
|
||||
'kotlinAliasedTransactionalOperation',
|
||||
'kotlinCompanionTransaction',
|
||||
'kotlinExtensionTransaction',
|
||||
'kotlinFullyQualifiedTransaction',
|
||||
'kotlinInterfaceExplicitTransaction',
|
||||
'kotlinObjectExplicitTransaction',
|
||||
'kotlinOverriddenTransaction',
|
||||
'kotlinScriptTransaction',
|
||||
'kotlinSuspendTransaction',
|
||||
'kotlinTransactionalOperation',
|
||||
'kotlinWildcardTransaction',
|
||||
'overriddenTransaction',
|
||||
'transactionalOperation',
|
||||
]);
|
||||
expect(advisedByTransactionalAnnotation).not.toContain('kotlinInterfaceInheritedTransaction');
|
||||
});
|
||||
|
||||
it('supports pointcuts with companion annotation attributes', () => {
|
||||
const cachedReturnAdvice = nodeNamed('cachedReturnAdvice');
|
||||
|
||||
const advisedByCachedReturn = advisedBy
|
||||
.filter((relationship) => relationship.targetId === cachedReturnAdvice?.id)
|
||||
.map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name);
|
||||
expect(advisedByCachedReturn).toEqual(['cachedOperation']);
|
||||
});
|
||||
|
||||
it('resolves Kotlin raw-string advice pointcuts', () => {
|
||||
const rawStringAdvice = nodeNamed('rawStringPointcutAdvice');
|
||||
const advisedMethods = advisedBy
|
||||
.filter((relationship) => relationship.targetId === rawStringAdvice?.id)
|
||||
.map((relationship) => result.graph.getNode(relationship.sourceId)?.properties.name);
|
||||
|
||||
expect(advisedMethods).toEqual(['kotlinCachedOperation']);
|
||||
});
|
||||
|
||||
it('retains the Aspect declaration without assuming bean registration', () => {
|
||||
for (const aspectName of ['OrderAspect', 'KotlinOrderAspect']) {
|
||||
const aspect = nodeNamed(aspectName);
|
||||
const marker = declarations.find((relationship) => {
|
||||
const reason = decodeSpringAopReason(relationship.reason);
|
||||
return relationship.sourceId === aspect?.id && reason?.kind === 'aspect';
|
||||
});
|
||||
|
||||
expect(marker, `${aspectName} should retain its Aspect marker`).toBeDefined();
|
||||
expect(decodeSpringAopReason(marker?.reason)).toMatchObject({
|
||||
kind: 'aspect',
|
||||
activation: 'unknown',
|
||||
registration: 'unknown',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves unknown pointcuts as evidence without guessing advised targets', () => {
|
||||
for (const adviceName of [
|
||||
'unresolvedNamedAdvice',
|
||||
'emptyPointcutAdvice',
|
||||
'unresolvedCompoundAdvice',
|
||||
'unresolvedSimpleTypeAdvice',
|
||||
]) {
|
||||
const advice = nodeNamed(adviceName);
|
||||
expect(advice?.label).toBe('Method');
|
||||
expect(advisedBy.some((relationship) => relationship.targetId === advice?.id)).toBe(false);
|
||||
|
||||
const evidence = declarations.filter((relationship) => {
|
||||
const reason = decodeSpringAopReason(relationship.reason);
|
||||
return (
|
||||
relationship.sourceId === advice?.id &&
|
||||
relationshipTarget(relationship)?.label === 'CodeElement' &&
|
||||
reason?.kind === 'pointcut' &&
|
||||
reason.match === 'unresolved' &&
|
||||
reason.resolution === 'unknown'
|
||||
);
|
||||
});
|
||||
expect(evidence, `${adviceName} should retain conservative pointcut evidence`).toHaveLength(
|
||||
1,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spring AOP durable warm parse cache (#2416)', () => {
|
||||
it('replays identical Java/Kotlin ADVISED_BY edges without spawning workers', async () => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-aop-warm-'));
|
||||
const repo = path.join(temp, 'repo');
|
||||
const storage = path.join(temp, 'storage');
|
||||
try {
|
||||
writeFixture(
|
||||
repo,
|
||||
'src/main/java/com/example/JavaService.java',
|
||||
`package com.example;
|
||||
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
public class JavaService {
|
||||
@Transactional
|
||||
public void javaTransaction() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
repo,
|
||||
'src/main/kotlin/com/example/KotlinAop.kt',
|
||||
`package com.example
|
||||
|
||||
import org.aspectj.lang.annotation.Aspect as AopAspect
|
||||
import org.aspectj.lang.annotation.Before as AdviceBefore
|
||||
import org.springframework.transaction.annotation.Transactional as Tx
|
||||
|
||||
class KotlinService {
|
||||
@Tx
|
||||
fun kotlinTransaction() {}
|
||||
}
|
||||
|
||||
class KotlinCompanionHolder {
|
||||
companion object {
|
||||
@Tx
|
||||
fun companionTransaction() {}
|
||||
}
|
||||
}
|
||||
|
||||
@AopAspect
|
||||
object KotlinAspect {
|
||||
@AdviceBefore("""@annotation(org.springframework.transaction.annotation.Transactional)""")
|
||||
fun beforeTransaction() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const coldCache: ParseCache = {
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: new Map(),
|
||||
usedKeys: new Set(),
|
||||
storagePath: storage,
|
||||
onDiskKeys: new Set(),
|
||||
};
|
||||
const cold = await runPipelineFromRepo(repo, () => {}, {
|
||||
skipGraphPhases: true,
|
||||
workerPoolSize: 1,
|
||||
parseCache: coldCache,
|
||||
});
|
||||
expect(cold.usedWorkerPool).toBe(true);
|
||||
|
||||
pruneCache(coldCache, coldCache.usedKeys);
|
||||
const savedKeys = await saveParseCache(storage, coldCache);
|
||||
expect(savedKeys.length).toBeGreaterThan(0);
|
||||
await pruneAndSaveDurableParsedFileStore(
|
||||
getDurableParsedFileDir(storage),
|
||||
PARSE_CACHE_VERSION,
|
||||
new Set(savedKeys),
|
||||
);
|
||||
|
||||
const warmCache = await loadParseCache(storage);
|
||||
expect(warmCache.onDiskKeys).toEqual(new Set(savedKeys));
|
||||
const warm = await runPipelineFromRepo(repo, () => {}, {
|
||||
skipGraphPhases: true,
|
||||
workerPoolSize: 1,
|
||||
parseCache: warmCache,
|
||||
});
|
||||
expect(warm.usedWorkerPool).toBe(false);
|
||||
|
||||
const project = (pipeline: PipelineResult) =>
|
||||
[...pipeline.graph.iterRelationshipsByType('ADVISED_BY')]
|
||||
.flatMap((edge) => {
|
||||
const reason = decodeSpringAopReason(edge.reason);
|
||||
if (reason?.kind !== 'behavior' && reason?.kind !== 'advice') return [];
|
||||
const source = pipeline.graph.getNode(edge.sourceId);
|
||||
const target = pipeline.graph.getNode(edge.targetId);
|
||||
return [
|
||||
{
|
||||
id: edge.id,
|
||||
sourceId: edge.sourceId,
|
||||
targetId: edge.targetId,
|
||||
confidence: edge.confidence,
|
||||
sourceName: source?.properties.name,
|
||||
sourceFilePath: source?.properties.filePath,
|
||||
targetName: target?.properties.name,
|
||||
targetFilePath: target?.properties.filePath,
|
||||
reason,
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
|
||||
const coldEdges = project(cold);
|
||||
expect(project(warm)).toEqual(coldEdges);
|
||||
expect(
|
||||
coldEdges
|
||||
.map((edge) => `${edge.reason.kind}:${edge.sourceName}->${edge.targetName}`)
|
||||
.sort(),
|
||||
).toEqual([
|
||||
'advice:companionTransaction->beforeTransaction',
|
||||
'advice:javaTransaction->beforeTransaction',
|
||||
'advice:kotlinTransaction->beforeTransaction',
|
||||
'behavior:companionTransaction->@Transactional',
|
||||
'behavior:javaTransaction->@Transactional',
|
||||
'behavior:kotlinTransaction->@Transactional',
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
|
@ -14,12 +14,19 @@ const KOTLIN_BEAN_ID = 'Class:src/KotlinBillingService.kt:KotlinBillingService';
|
|||
const PLAIN_ID = 'Class:src/PlainUtility.java:PlainUtility';
|
||||
const NON_JAVA_ID = 'Class:src/AppProvider.ts:AppProvider';
|
||||
const CONFLICT_ID = 'Class:src/ConflictingBean.java:ConflictingBean';
|
||||
const FACTORY_METHOD_ID = 'Method:src/AppConfiguration.java:AppConfiguration.billingService#0';
|
||||
const FACTORY_BEAN_ID = `CodeElement:spring-bean:${FACTORY_METHOD_ID}`;
|
||||
const FACTORY_REASON =
|
||||
'spring-bean-factory:{"names":["billingService","billingAlias"],"namesKnown":true,"providedType":"BillingService"}';
|
||||
const SEED = [
|
||||
`CREATE (c:Class {id:'${BEAN_ID}', name:'BillingService', filePath:'src/BillingService.java', startLine:0, endLine:3, isExported:false, content:'class BillingService {}', description:'', frameworkAnnotations:['org.springframework.stereotype.Service']})`,
|
||||
`CREATE (c:Class {id:'${KOTLIN_BEAN_ID}', name:'KotlinBillingService', filePath:'src/KotlinBillingService.kt', startLine:0, endLine:3, isExported:false, content:'class KotlinBillingService', description:'', frameworkAnnotations:['org.springframework.stereotype.Service']})`,
|
||||
`CREATE (c:Class {id:'${PLAIN_ID}', name:'PlainUtility', filePath:'src/PlainUtility.java', startLine:0, endLine:1, isExported:false, content:'class PlainUtility {}', description:'', frameworkAnnotations:[]})`,
|
||||
`CREATE (c:Class {id:'${NON_JAVA_ID}', name:'AppProvider', filePath:'src/AppProvider.ts', startLine:0, endLine:1, isExported:true, content:'class AppProvider {}', description:'', frameworkAnnotations:['@nestjs/common.Injectable']})`,
|
||||
`CREATE (c:Class {id:'${CONFLICT_ID}', name:'ConflictingBean', filePath:'src/ConflictingBean.java', startLine:0, endLine:1, isExported:false, content:'class ConflictingBean {}', description:'', frameworkAnnotations:['org.springframework.stereotype.Service', 'org.springframework.stereotype.Component']})`,
|
||||
`CREATE (m:Method {id:'${FACTORY_METHOD_ID}', name:'billingService', filePath:'src/AppConfiguration.java', startLine:4, endLine:6, isExported:false, content:'@Bean BillingService billingService()', description:'', parameterCount:0, returnType:'BillingService'})`,
|
||||
`CREATE (b:CodeElement {id:'${FACTORY_BEAN_ID}', name:'billingService', filePath:'src/AppConfiguration.java', startLine:4, endLine:6, isExported:false, content:'', description:'Spring Bean factory declaration'})`,
|
||||
`MATCH (m:Method {id:'${FACTORY_METHOD_ID}'}), (b:CodeElement {id:'${FACTORY_BEAN_ID}'}) CREATE (m)-[:CodeRelation {type:'DECLARES', confidence:1.0, reason:'${FACTORY_REASON}', step:0}]->(b)`,
|
||||
];
|
||||
|
||||
withTestLbugDB(
|
||||
|
|
@ -76,6 +83,21 @@ withTestLbugDB(
|
|||
expect(nonJava.symbol).not.toHaveProperty('bean');
|
||||
expect(conflict.target).not.toHaveProperty('bean');
|
||||
});
|
||||
|
||||
it('enriches both Bean factory methods and their synthetic declarations', async () => {
|
||||
const methodContext = await backend.callTool('context', { uid: FACTORY_METHOD_ID });
|
||||
const declarationContext = await backend.callTool('context', { uid: FACTORY_BEAN_ID });
|
||||
const expectedFactoryBean = {
|
||||
framework: 'spring',
|
||||
role: 'factory-method',
|
||||
annotation: 'org.springframework.context.annotation.Bean',
|
||||
names: ['billingService', 'billingAlias'],
|
||||
providedType: 'BillingService',
|
||||
};
|
||||
|
||||
expect(methodContext.symbol.bean).toEqual(expectedFactoryBean);
|
||||
expect(declarationContext.symbol.bean).toEqual(expectedFactoryBean);
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { expect, it } from 'vitest';
|
|||
import { buildTestGraph } from '../helpers/test-graph.js';
|
||||
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
||||
import { streamAllCSVsToDisk } from '../../src/core/lbug/csv-generator.js';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
|
||||
const CLASS_ID = 'Class:src/BillingService.java:BillingService';
|
||||
const FRAMEWORK_MARKER = 'com.acme.FrameworkMarker';
|
||||
|
|
@ -122,4 +123,87 @@ withTestLbugDB('spring-bean-metadata-roundtrip', (handle) => {
|
|||
'Cannot safely encode CSV string-list item',
|
||||
);
|
||||
});
|
||||
|
||||
it('persists pipeline-produced Class and Method INJECTS edges to Bean declarations', async () => {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const root = path.join(handle.tmpHandle.dbPath, 'spring-bean-injects-roundtrip');
|
||||
const repoDir = path.join(root, 'repo');
|
||||
const storageDir = path.join(root, 'storage');
|
||||
await fs.mkdir(repoDir, { recursive: true });
|
||||
await fs.mkdir(storageDir, { recursive: true });
|
||||
await Promise.all([
|
||||
fs.writeFile(
|
||||
path.join(repoDir, 'Gateway.java'),
|
||||
`package com.persisted;
|
||||
public interface Gateway {}
|
||||
`,
|
||||
),
|
||||
fs.writeFile(
|
||||
path.join(repoDir, 'DefaultGateway.java'),
|
||||
`package com.persisted;
|
||||
public class DefaultGateway implements Gateway {}
|
||||
`,
|
||||
),
|
||||
fs.writeFile(
|
||||
path.join(repoDir, 'PersistedConfig.java'),
|
||||
`package com.persisted;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@Configuration
|
||||
public class PersistedConfig {
|
||||
@Bean Gateway persistedGateway() { return new DefaultGateway(); }
|
||||
@Bean Object persistedAggregate(Gateway gateway) { return new Object(); }
|
||||
}
|
||||
`,
|
||||
),
|
||||
fs.writeFile(
|
||||
path.join(repoDir, 'PersistedConsumer.java'),
|
||||
`package com.persisted;
|
||||
import jakarta.annotation.Resource;
|
||||
public class PersistedConsumer {
|
||||
@Resource(name = "persistedGateway") Gateway selected;
|
||||
}
|
||||
`,
|
||||
),
|
||||
]);
|
||||
|
||||
const { graph } = await runPipelineFromRepo(repoDir, () => {}, {});
|
||||
expect(
|
||||
graph.relationships.some(
|
||||
(relationship) =>
|
||||
relationship.type === 'INJECTS' &&
|
||||
graph.getNode(relationship.sourceId)?.properties.name === 'PersistedConsumer' &&
|
||||
graph.getNode(relationship.targetId)?.properties.name === 'persistedGateway',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
graph.relationships.some(
|
||||
(relationship) =>
|
||||
relationship.type === 'INJECTS' &&
|
||||
graph.getNode(relationship.sourceId)?.properties.name === 'persistedAggregate' &&
|
||||
graph.getNode(relationship.targetId)?.properties.name === 'persistedGateway',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await adapter.loadGraphToLbug(graph, repoDir, storageDir);
|
||||
|
||||
expect(
|
||||
await adapter.executeQuery(
|
||||
`MATCH (source:Class)-[r:CodeRelation]->(target:CodeElement)
|
||||
WHERE r.type = 'INJECTS'
|
||||
AND source.name = 'PersistedConsumer'
|
||||
AND target.name = 'persistedGateway'
|
||||
RETURN source.name AS source, target.name AS target`,
|
||||
),
|
||||
).toEqual([{ source: 'PersistedConsumer', target: 'persistedGateway' }]);
|
||||
expect(
|
||||
await adapter.executeQuery(
|
||||
`MATCH (source:Method)-[r:CodeRelation]->(target:CodeElement)
|
||||
WHERE r.type = 'INJECTS'
|
||||
AND source.name = 'persistedAggregate'
|
||||
AND target.name = 'persistedGateway'
|
||||
RETURN source.name AS source, target.name AS target`,
|
||||
),
|
||||
).toEqual([{ source: 'persistedAggregate', target: 'persistedGateway' }]);
|
||||
}, 90_000);
|
||||
});
|
||||
|
|
|
|||
174
gitnexus/test/integration/spring-bean-resource-benchmark.test.ts
Normal file
174
gitnexus/test/integration/spring-bean-resource-benchmark.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* Spring @Bean / @Resource scaling benchmark (#2413, #2633).
|
||||
*
|
||||
* The normal-CI tripwires protect the single-pass Java and Kotlin capture
|
||||
* paths. The gated benchmark exercises the complete graph pipeline, including
|
||||
* Bean provider indexing and Resource name-first lookup:
|
||||
*
|
||||
* GITNEXUS_BENCH=1 npx vitest run test/integration/spring-bean-resource-benchmark.test.ts
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { emitJavaScopeCaptures } from '../../src/core/ingestion/languages/java/captures.js';
|
||||
import { collectJavaCaptureSideChannel } from '../../src/core/ingestion/languages/java/capture-side-channel.js';
|
||||
import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/captures.js';
|
||||
import { collectKotlinCaptureSideChannel } from '../../src/core/ingestion/languages/kotlin/capture-side-channel.js';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
|
||||
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
|
||||
|
||||
interface CaptureResult {
|
||||
elapsedMs: number;
|
||||
captureCount: number;
|
||||
factoryCount: number;
|
||||
resourceCount: number;
|
||||
}
|
||||
|
||||
function denseJavaSource(size: number): string {
|
||||
const factories = Array.from(
|
||||
{ length: size },
|
||||
(_, index) => ` @Bean Gateway bean${index}() { return new GatewayImpl(); }`,
|
||||
).join('\n');
|
||||
const consumers = Array.from(
|
||||
{ length: size },
|
||||
(_, index) => `
|
||||
class Consumer${index} {
|
||||
@Resource(name = "bean${index}") Gateway dependency;
|
||||
}`,
|
||||
).join('\n');
|
||||
return `package com.example;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
interface Gateway {}
|
||||
class GatewayImpl implements Gateway {}
|
||||
@Configuration
|
||||
class BenchConfiguration {
|
||||
${factories}
|
||||
}
|
||||
${consumers}
|
||||
`;
|
||||
}
|
||||
|
||||
function denseKotlinSource(size: number): string {
|
||||
const factories = Array.from(
|
||||
{ length: size },
|
||||
(_, index) => ` @Bean fun bean${index}(): Gateway = GatewayImpl()`,
|
||||
).join('\n');
|
||||
const consumers = Array.from(
|
||||
{ length: size },
|
||||
(_, index) => `
|
||||
class Consumer${index} {
|
||||
@field:Resource(name = "bean${index}")
|
||||
lateinit var dependency: Gateway
|
||||
}`,
|
||||
).join('\n');
|
||||
return `package com.example
|
||||
import jakarta.annotation.Resource
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
interface Gateway
|
||||
class GatewayImpl : Gateway
|
||||
@Configuration
|
||||
class BenchConfiguration {
|
||||
${factories}
|
||||
}
|
||||
${consumers}
|
||||
`;
|
||||
}
|
||||
|
||||
function runJavaCapture(size: number, run: number): CaptureResult {
|
||||
const filePath = `src/SpringBeanResource${size}_${run}.java`;
|
||||
const start = performance.now();
|
||||
const captures = emitJavaScopeCaptures(denseJavaSource(size), filePath);
|
||||
const elapsedMs = performance.now() - start;
|
||||
const facts = collectJavaCaptureSideChannel(filePath)?.springDiFacts ?? [];
|
||||
return {
|
||||
elapsedMs,
|
||||
captureCount: captures.length,
|
||||
factoryCount: facts.reduce((count, fact) => count + (fact.beanFactoryMethods?.length ?? 0), 0),
|
||||
resourceCount: facts.reduce((count, fact) => count + fact.injectionSites.length, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function runKotlinCapture(size: number, run: number): CaptureResult {
|
||||
const filePath = `src/SpringBeanResource${size}_${run}.kt`;
|
||||
const start = performance.now();
|
||||
const captures = emitKotlinScopeCaptures(denseKotlinSource(size), filePath);
|
||||
const elapsedMs = performance.now() - start;
|
||||
const facts = collectKotlinCaptureSideChannel(filePath)?.springDiFacts ?? [];
|
||||
return {
|
||||
elapsedMs,
|
||||
captureCount: captures.length,
|
||||
factoryCount: facts.reduce((count, fact) => count + (fact.beanFactoryMethods?.length ?? 0), 0),
|
||||
resourceCount: facts.reduce((count, fact) => count + fact.injectionSites.length, 0),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Spring Bean/Resource capture O(n²) regression tripwire (#2413, #2633)', () => {
|
||||
it('captures 400 Java factories and Resource sites within a coarse linear-time budget', () => {
|
||||
runJavaCapture(4, 0);
|
||||
const result = runJavaCapture(400, 1);
|
||||
|
||||
expect(result.factoryCount).toBe(400);
|
||||
expect(result.resourceCount).toBe(400);
|
||||
expect(result.captureCount).toBeGreaterThan(3_200);
|
||||
expect(result.elapsedMs).toBeLessThan(10_000);
|
||||
}, 30_000);
|
||||
|
||||
it('captures 400 Kotlin factories and Resource sites within a coarse linear-time budget', () => {
|
||||
runKotlinCapture(4, 0);
|
||||
const result = runKotlinCapture(400, 1);
|
||||
|
||||
expect(result.factoryCount).toBe(400);
|
||||
expect(result.resourceCount).toBe(400);
|
||||
expect(result.captureCount).toBeGreaterThan(2_400);
|
||||
expect(result.elapsedMs).toBeLessThan(10_000);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe.skipIf(!BENCH_ENABLED)(
|
||||
'Spring Bean/Resource end-to-end scaling benchmark (#2413, #2633)',
|
||||
() => {
|
||||
it('keeps named Resource resolution sub-quadratic as providers and sites grow together', async () => {
|
||||
const scales = [25, 50, 100, 200];
|
||||
const results: Array<{
|
||||
size: number;
|
||||
elapsedMs: number;
|
||||
declarations: number;
|
||||
injections: number;
|
||||
}> = [];
|
||||
|
||||
for (const size of scales) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `spring-bean-resource-${size}-`));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'Application.java'), denseJavaSource(size));
|
||||
const start = performance.now();
|
||||
const result = await runPipelineFromRepo(dir, () => {}, {});
|
||||
const elapsedMs = performance.now() - start;
|
||||
const declarations = [...result.graph.iterRelationshipsByType('DECLARES')].length;
|
||||
const injections = [...result.graph.iterRelationshipsByType('INJECTS')].length;
|
||||
results.push({ size, elapsedMs, declarations, injections });
|
||||
console.log(
|
||||
` pipeline n=${size}: ${elapsedMs.toFixed(1)}ms ` +
|
||||
`(${declarations} declarations, ${injections} injections)`,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
expect(result.declarations).toBe(result.size);
|
||||
expect(result.injections).toBe(result.size);
|
||||
}
|
||||
const first = results[0];
|
||||
const last = results[results.length - 1];
|
||||
const sizeRatio = last.size / first.size;
|
||||
const wallRatio = last.elapsedMs / first.elapsedMs;
|
||||
expect(wallRatio).toBeLessThan(Math.pow(sizeRatio, 1.5));
|
||||
}, 300_000);
|
||||
},
|
||||
);
|
||||
396
gitnexus/test/integration/spring-bean-resource-pipeline.test.ts
Normal file
396
gitnexus/test/integration/spring-bean-resource-pipeline.test.ts
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { decodeSpringBeanFactoryReason } from '../../src/core/ingestion/frameworks/spring/bean-factories.js';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import type { PipelineResult } from '../../src/types/pipeline.js';
|
||||
|
||||
function nodeNames(result: PipelineResult): Map<string, string> {
|
||||
const names = new Map<string, string>();
|
||||
result.graph.forEachNode((node) => names.set(node.id, String(node.properties.name)));
|
||||
return names;
|
||||
}
|
||||
|
||||
function injectionDetails(result: PipelineResult) {
|
||||
const names = nodeNames(result);
|
||||
return result.graph.relationships
|
||||
.filter((relationship) => relationship.type === 'INJECTS')
|
||||
.map((relationship) => ({
|
||||
pair: `${names.get(relationship.sourceId)}->${names.get(relationship.targetId)}`,
|
||||
confidence: relationship.confidence,
|
||||
reason: relationship.reason,
|
||||
}))
|
||||
.sort((left, right) => left.pair.localeCompare(right.pair));
|
||||
}
|
||||
|
||||
function beanDeclarations(result: PipelineResult) {
|
||||
const names = nodeNames(result);
|
||||
return result.graph.relationships
|
||||
.filter((relationship) => relationship.type === 'DECLARES')
|
||||
.flatMap((relationship) => {
|
||||
const metadata = decodeSpringBeanFactoryReason(relationship.reason);
|
||||
return metadata === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
factory: names.get(relationship.sourceId),
|
||||
bean: names.get(relationship.targetId),
|
||||
metadata,
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort((left, right) => String(left.factory).localeCompare(String(right.factory)));
|
||||
}
|
||||
|
||||
describe('Spring Bean factories and Resource injection pipeline (#2413, #2633)', () => {
|
||||
let dir: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
const sources: Record<string, string> = {
|
||||
'Gateway.java': 'package com.example; public interface Gateway {}\n',
|
||||
'DefaultGateway.java':
|
||||
'package com.example; public class DefaultGateway implements Gateway {}\n',
|
||||
'ConcreteRepo.java': 'package com.example; public class ConcreteRepo {}\n',
|
||||
'ClassGateway.java': `package com.example;
|
||||
import org.springframework.stereotype.Service;
|
||||
@Service("classGateway")
|
||||
public class ClassGateway implements Gateway {}
|
||||
`,
|
||||
'AppConfiguration.java': `package com.example;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@Configuration
|
||||
public class AppConfiguration {
|
||||
@Bean Gateway gateway() { return new DefaultGateway(); }
|
||||
@Bean(name = {"slowGateway", "gatewayAlias"})
|
||||
Gateway namedGateway() { return new DefaultGateway(); }
|
||||
@Bean Gateway setterGateway() { return new DefaultGateway(); }
|
||||
@Bean DefaultGateway concreteGateway() { return new DefaultGateway(); }
|
||||
@Bean Gateway selfAwareGateway(Gateway dependency) { return new DefaultGateway(); }
|
||||
@Bean List<Gateway> gatewayList() { return List.of(); }
|
||||
@Bean ConcreteRepo repo() { return new ConcreteRepo(); }
|
||||
@Bean @Autowired
|
||||
ConcreteRepo service(@Qualifier("gatewayAlias") Gateway gateway) {
|
||||
return new ConcreteRepo();
|
||||
}
|
||||
@Bean ConcreteRepo aggregate(List<Gateway> gateways) {
|
||||
return new ConcreteRepo();
|
||||
}
|
||||
}
|
||||
`,
|
||||
'ExplicitResourceConsumer.java': `package com.example;
|
||||
import jakarta.annotation.Resource;
|
||||
public class ExplicitResourceConsumer {
|
||||
@Resource(name = "slowGateway") Gateway selected;
|
||||
}
|
||||
`,
|
||||
'DefaultResourceConsumer.java': `package com.example;
|
||||
import javax.annotation.Resource;
|
||||
public class DefaultResourceConsumer {
|
||||
@Resource Gateway gateway;
|
||||
}
|
||||
`,
|
||||
'SetterResourceConsumer.java': `package com.example;
|
||||
import jakarta.annotation.Resource;
|
||||
public class SetterResourceConsumer {
|
||||
@Resource void setSetterGateway(Gateway value) {}
|
||||
}
|
||||
`,
|
||||
'CollectionResourceConsumer.java': `package com.example;
|
||||
import java.util.List;
|
||||
import javax.annotation.Resource;
|
||||
public class CollectionResourceConsumer {
|
||||
@Resource(name = "gatewayList") List<Gateway> gateways;
|
||||
}
|
||||
`,
|
||||
'ConcreteFactoryResourceConsumer.java': `package com.example;
|
||||
import jakarta.annotation.Resource;
|
||||
public class ConcreteFactoryResourceConsumer {
|
||||
@Resource(name = "concreteGateway") Gateway gateway;
|
||||
}
|
||||
`,
|
||||
'GenericResourceConsumer.java': `package com.example;
|
||||
import java.util.List;
|
||||
import jakarta.annotation.Resource;
|
||||
public class GenericResourceConsumer {
|
||||
@Resource List<Gateway> missingGateways;
|
||||
}
|
||||
`,
|
||||
'TypeOverrideResourceConsumer.java': `package com.example;
|
||||
import jakarta.annotation.Resource;
|
||||
public class TypeOverrideResourceConsumer {
|
||||
@Resource(type = ConcreteRepo.class) Object repo;
|
||||
}
|
||||
`,
|
||||
'FallbackResourceConsumer.java': `package com.example;
|
||||
import jakarta.annotation.Resource;
|
||||
public class FallbackResourceConsumer {
|
||||
@Resource Gateway unknownGateway;
|
||||
}
|
||||
`,
|
||||
'MissingResourceConsumer.java': `package com.example;
|
||||
import jakarta.annotation.Resource;
|
||||
public class MissingResourceConsumer {
|
||||
@Resource(name = "missing") Gateway gateway;
|
||||
}
|
||||
`,
|
||||
'RuntimeResourceConsumer.java': `package com.example;
|
||||
import jakarta.annotation.Resource;
|
||||
public class RuntimeResourceConsumer {
|
||||
@Resource(lookup = "java:global/gateway") Gateway gateway;
|
||||
}
|
||||
`,
|
||||
'QualifierConsumer.java': `package com.example;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
public class QualifierConsumer {
|
||||
@Autowired @Qualifier("gatewayAlias") Gateway gateway;
|
||||
}
|
||||
`,
|
||||
'ConflictingResourceConsumer.java': `package com.example;
|
||||
import java.util.List;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
public class ConflictingResourceConsumer {
|
||||
@Autowired @Resource List<Gateway> gateways;
|
||||
}
|
||||
`,
|
||||
'ClassResourceConsumer.java': `package com.example;
|
||||
import jakarta.annotation.Resource;
|
||||
public class ClassResourceConsumer {
|
||||
@Resource(name = "classGateway") Gateway gateway;
|
||||
}
|
||||
`,
|
||||
'LocalSpringNames.java': `package com.local;
|
||||
@interface Bean {}
|
||||
@interface Resource {}
|
||||
interface LocalGateway {}
|
||||
class LocalConfiguration {
|
||||
@Bean LocalGateway localGateway() { return null; }
|
||||
}
|
||||
class LocalResourceConsumer {
|
||||
@Resource LocalGateway localGateway;
|
||||
}
|
||||
`,
|
||||
'KGateway.kt': `package com.kotlin
|
||||
interface KGateway
|
||||
class KGatewayImpl : KGateway
|
||||
class KRepo
|
||||
`,
|
||||
'KClassGateway.kt': `package com.kotlin
|
||||
import org.springframework.stereotype.Service
|
||||
@Service("kotlinClassGateway")
|
||||
class KClassGateway : KGateway
|
||||
`,
|
||||
'KotlinConfiguration.kt': `package com.kotlin
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
@Configuration
|
||||
class KotlinConfiguration {
|
||||
@Bean fun kotlinGateway(): KGateway = KGatewayImpl()
|
||||
@Bean(name = ["kotlinNamed", "kotlinAlias"])
|
||||
fun namedGateway(): KGateway = KGatewayImpl()
|
||||
@Bean fun inferredGateway() = KGatewayImpl()
|
||||
@Bean fun kotlinConcreteGateway() = KGatewayImpl()
|
||||
@Bean fun kotlinSelfAwareGateway(dependency: KGateway): KGateway = KGatewayImpl()
|
||||
private fun buildGateway(): KGateway = KGatewayImpl()
|
||||
@Bean fun indirectGateway() = buildGateway()
|
||||
@Bean fun kotlinList(): List<KGateway> = emptyList()
|
||||
@Bean @Autowired
|
||||
fun kotlinService(@param:Qualifier("kotlinAlias") gateway: KGateway): KRepo = KRepo()
|
||||
}
|
||||
`,
|
||||
'KotlinExplicitResourceConsumer.kt': `package com.kotlin
|
||||
import jakarta.annotation.Resource
|
||||
class KotlinExplicitResourceConsumer {
|
||||
@field:Resource(name = "kotlinNamed")
|
||||
lateinit var selected: KGateway
|
||||
}
|
||||
`,
|
||||
'KotlinDefaultResourceConsumer.kt': `package com.kotlin
|
||||
import javax.annotation.Resource
|
||||
class KotlinDefaultResourceConsumer {
|
||||
@field:Resource
|
||||
lateinit var kotlinGateway: KGateway
|
||||
}
|
||||
`,
|
||||
'KotlinSetterResourceConsumer.kt': `package com.kotlin
|
||||
import jakarta.annotation.Resource
|
||||
class KotlinSetterResourceConsumer {
|
||||
@Resource fun setInferredGateway(value: KGatewayImpl) {}
|
||||
}
|
||||
`,
|
||||
'KotlinCollectionResourceConsumer.kt': `package com.kotlin
|
||||
import jakarta.annotation.Resource
|
||||
class KotlinCollectionResourceConsumer {
|
||||
@set:Resource
|
||||
var kotlinList: List<KGateway>? = null
|
||||
}
|
||||
`,
|
||||
'KotlinConcreteFactoryResourceConsumer.kt': `package com.kotlin
|
||||
import jakarta.annotation.Resource
|
||||
class KotlinConcreteFactoryResourceConsumer {
|
||||
@field:Resource(name = "kotlinConcreteGateway")
|
||||
lateinit var gateway: KGateway
|
||||
}
|
||||
`,
|
||||
'KotlinGenericResourceConsumer.kt': `package com.kotlin
|
||||
import jakarta.annotation.Resource
|
||||
class KotlinGenericResourceConsumer {
|
||||
@field:Resource
|
||||
lateinit var missingGateways: List<KGateway>
|
||||
}
|
||||
`,
|
||||
'KotlinConflictingResourceConsumer.kt': `package com.kotlin
|
||||
import jakarta.annotation.Resource
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
class KotlinConflictingResourceConsumer {
|
||||
@field:Autowired
|
||||
@field:Resource
|
||||
lateinit var gateways: List<KGateway>
|
||||
}
|
||||
`,
|
||||
'KotlinGetterResourceConsumer.kt': `package com.kotlin
|
||||
import jakarta.annotation.Resource
|
||||
class KotlinGetterResourceConsumer {
|
||||
@get:Resource
|
||||
var kotlinGateway: KGateway? = null
|
||||
}
|
||||
`,
|
||||
'KotlinClassResourceConsumer.kt': `package com.kotlin
|
||||
import jakarta.annotation.Resource
|
||||
class KotlinClassResourceConsumer {
|
||||
@field:Resource(name = "kotlinClassGateway")
|
||||
lateinit var gateway: KGateway
|
||||
}
|
||||
`,
|
||||
'KotlinLocalSpringNames.kt': `package com.kotlinlocal
|
||||
annotation class Bean
|
||||
annotation class Resource
|
||||
interface LocalGateway
|
||||
class LocalConfiguration {
|
||||
@Bean fun localGateway(): LocalGateway = TODO()
|
||||
}
|
||||
class LocalResourceConsumer {
|
||||
@field:Resource lateinit var localGateway: LocalGateway
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-bean-resource-'));
|
||||
for (const [fileName, source] of Object.entries(sources)) {
|
||||
fs.writeFileSync(path.join(dir, fileName), source);
|
||||
}
|
||||
result = await runPipelineFromRepo(dir, () => {}, {});
|
||||
}, 90_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates Java and Kotlin Bean declarations with names, aliases, and returned types', () => {
|
||||
const declarations = beanDeclarations(result);
|
||||
expect(
|
||||
declarations.find((declaration) => declaration.factory === 'gateway')?.metadata,
|
||||
).toMatchObject({ names: ['gateway'], providedType: 'Gateway' });
|
||||
expect(
|
||||
declarations.find((declaration) => declaration.factory === 'namedGateway')?.metadata,
|
||||
).toMatchObject({ names: ['slowGateway', 'gatewayAlias'], providedType: 'Gateway' });
|
||||
expect(
|
||||
declarations.find((declaration) => declaration.factory === 'kotlinGateway')?.metadata,
|
||||
).toMatchObject({ names: ['kotlinGateway'], providedType: 'KGateway' });
|
||||
expect(
|
||||
declarations.find((declaration) => declaration.factory === 'inferredGateway')?.metadata,
|
||||
).toMatchObject({ names: ['inferredGateway'], providedType: 'KGatewayImpl' });
|
||||
const indirectGateway = declarations.find(
|
||||
(declaration) => declaration.factory === 'indirectGateway',
|
||||
);
|
||||
expect(indirectGateway?.metadata).toMatchObject({ names: ['indirectGateway'] });
|
||||
expect(indirectGateway?.metadata).not.toHaveProperty('providedType');
|
||||
expect(
|
||||
declarations.some((declaration) => declaration.metadata.names.includes('localGateway')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('uses Bean factory names and return types for Java Resource and Qualifier resolution', () => {
|
||||
const details = injectionDetails(result);
|
||||
const pairs = details.map((detail) => detail.pair);
|
||||
expect(pairs).toContain('ExplicitResourceConsumer->slowGateway');
|
||||
expect(pairs).toContain('DefaultResourceConsumer->gateway');
|
||||
expect(pairs).toContain('SetterResourceConsumer->setterGateway');
|
||||
expect(pairs).toContain('CollectionResourceConsumer->gatewayList');
|
||||
expect(pairs).toContain('ConcreteFactoryResourceConsumer->concreteGateway');
|
||||
expect(pairs).toContain('TypeOverrideResourceConsumer->repo');
|
||||
expect(pairs).toContain('ClassResourceConsumer->ClassGateway');
|
||||
expect(pairs).toContain('QualifierConsumer->slowGateway');
|
||||
expect(pairs).toContain('service->slowGateway');
|
||||
expect(pairs.some((pair) => pair.startsWith('AppConfiguration->'))).toBe(false);
|
||||
expect(pairs.filter((pair) => pair.startsWith('aggregate->'))).toEqual([
|
||||
'aggregate->ClassGateway',
|
||||
'aggregate->concreteGateway',
|
||||
'aggregate->gateway',
|
||||
'aggregate->selfAwareGateway',
|
||||
'aggregate->setterGateway',
|
||||
'aggregate->slowGateway',
|
||||
]);
|
||||
expect(pairs.some((pair) => pair === 'selfAwareGateway->selfAwareGateway')).toBe(false);
|
||||
expect(pairs.some((pair) => pair.startsWith('selfAwareGateway->'))).toBe(true);
|
||||
expect(pairs.filter((pair) => pair.startsWith('CollectionResourceConsumer->'))).toEqual([
|
||||
'CollectionResourceConsumer->gatewayList',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses default-name type fallback conservatively and keeps explicit/runtime misses unresolved', () => {
|
||||
const details = injectionDetails(result);
|
||||
const fallback = details.filter((detail) =>
|
||||
detail.pair.startsWith('FallbackResourceConsumer->'),
|
||||
);
|
||||
expect(fallback.length).toBeGreaterThan(1);
|
||||
expect(fallback.every((detail) => detail.confidence === 0.5)).toBe(true);
|
||||
expect(fallback.every((detail) => detail.reason.includes('type fallback'))).toBe(true);
|
||||
expect(details.some((detail) => detail.pair.startsWith('MissingResourceConsumer->'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(details.some((detail) => detail.pair.startsWith('RuntimeResourceConsumer->'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(details.some((detail) => detail.pair.startsWith('ConflictingResourceConsumer->'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(details.some((detail) => detail.pair.startsWith('GenericResourceConsumer->'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(details.some((detail) => detail.pair.startsWith('LocalResourceConsumer->'))).toBe(false);
|
||||
});
|
||||
|
||||
it('provides Kotlin parity for explicit/default/setter/collection Resource sites', () => {
|
||||
const pairs = injectionDetails(result).map((detail) => detail.pair);
|
||||
expect(pairs).toContain('KotlinExplicitResourceConsumer->kotlinNamed');
|
||||
expect(pairs).toContain('KotlinDefaultResourceConsumer->kotlinGateway');
|
||||
expect(pairs).toContain('KotlinSetterResourceConsumer->inferredGateway');
|
||||
expect(pairs).toContain('KotlinCollectionResourceConsumer->kotlinList');
|
||||
expect(pairs).toContain('KotlinConcreteFactoryResourceConsumer->kotlinConcreteGateway');
|
||||
expect(pairs).toContain('KotlinClassResourceConsumer->KClassGateway');
|
||||
expect(pairs).toContain('kotlinService->kotlinNamed');
|
||||
expect(pairs.some((pair) => pair.startsWith('KotlinConfiguration->'))).toBe(false);
|
||||
expect(pairs.some((pair) => pair.startsWith('KotlinGetterResourceConsumer->'))).toBe(false);
|
||||
expect(pairs.some((pair) => pair.startsWith('KotlinGenericResourceConsumer->'))).toBe(false);
|
||||
expect(pairs.some((pair) => pair.startsWith('KotlinConflictingResourceConsumer->'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(pairs.some((pair) => pair === 'kotlinSelfAwareGateway->kotlinSelfAwareGateway')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(pairs.some((pair) => pair.startsWith('kotlinSelfAwareGateway->'))).toBe(true);
|
||||
expect(pairs.filter((pair) => pair.startsWith('KotlinCollectionResourceConsumer->'))).toEqual([
|
||||
'KotlinCollectionResourceConsumer->kotlinList',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -242,7 +242,12 @@ com.duplicate.DuplicateAutoConfiguration
|
|||
});
|
||||
|
||||
it('uses metadata DECLARES evidence without claiming annotation-based registration', () => {
|
||||
const targetNames = declarations
|
||||
const metadataDeclarations = declarations.filter(
|
||||
(edge) =>
|
||||
edge.reason === 'spring-auto-configuration-import' ||
|
||||
edge.reason === 'spring-auto-configuration-factory',
|
||||
);
|
||||
const targetNames = metadataDeclarations
|
||||
.map((edge) => String(result.graph.getNode(edge.targetId)?.properties.name))
|
||||
.sort();
|
||||
expect(targetNames).toEqual([
|
||||
|
|
@ -254,13 +259,13 @@ com.duplicate.DuplicateAutoConfiguration
|
|||
'StarterAutoConfiguration',
|
||||
]);
|
||||
expect(
|
||||
declarations.some(
|
||||
metadataDeclarations.some(
|
||||
(edge) =>
|
||||
result.graph.getNode(edge.targetId)?.properties.name === 'OrdinaryApplicationConfig',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
declarations.every((edge) =>
|
||||
metadataDeclarations.every((edge) =>
|
||||
String(result.graph.getNode(edge.sourceId)?.properties.filePath).includes('META-INF'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type AnalysisFeatureDescriptor,
|
||||
} from '../../src/core/analysis-features.js';
|
||||
import {
|
||||
SPRING_AOP_FEATURE,
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
|
||||
|
|
@ -13,6 +14,7 @@ import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languag
|
|||
|
||||
const FEATURES = [
|
||||
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
|
||||
SPRING_AOP_FEATURE,
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||
|
|
@ -25,13 +27,15 @@ describe('analysis feature versions', () => {
|
|||
});
|
||||
expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({
|
||||
'graph.class-framework-annotations': 1,
|
||||
'spring.bean-inventory': 1,
|
||||
'spring.aop-advice': 1,
|
||||
'spring.bean-inventory': 2,
|
||||
'spring.conditionals-auto-configuration': 1,
|
||||
'spring.config-bindings': 1,
|
||||
});
|
||||
expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({
|
||||
'graph.class-framework-annotations': 1,
|
||||
'spring.bean-inventory': 1,
|
||||
'spring.aop-advice': 1,
|
||||
'spring.bean-inventory': 2,
|
||||
'spring.conditionals-auto-configuration': 1,
|
||||
});
|
||||
expect(
|
||||
|
|
@ -56,7 +60,7 @@ describe('analysis feature versions', () => {
|
|||
it('requires an exact, well-formed feature set', () => {
|
||||
const expected = {
|
||||
'graph.class-framework-annotations': 1,
|
||||
'spring.bean-inventory': 1,
|
||||
'spring.bean-inventory': 2,
|
||||
};
|
||||
|
||||
expect(findAnalysisFeatureMismatches(expected, expected)).toEqual([]);
|
||||
|
|
@ -66,7 +70,7 @@ describe('analysis feature versions', () => {
|
|||
]);
|
||||
expect(
|
||||
findAnalysisFeatureMismatches(
|
||||
{ 'graph.class-framework-annotations': 1, 'spring.bean-inventory': 2 },
|
||||
{ 'graph.class-framework-annotations': 1, 'spring.bean-inventory': 1 },
|
||||
expected,
|
||||
),
|
||||
).toEqual(['version:spring.bean-inventory']);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { searchFTSFromLbug, type BM25SearchResult } from '../../src/core/search/bm25-index.js';
|
||||
import { classifyFtsQueryError } from '../../src/core/lbug/lbug-adapter.js';
|
||||
import { extensionManager, resetExtensionState } from '../../src/core/lbug/extension-loader.js';
|
||||
import { FTS_INDEXES } from '../../src/core/search/fts-schema.js';
|
||||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', async (importOriginal) => {
|
||||
|
|
@ -315,6 +317,183 @@ describe('BM25 search', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('classifyFtsQueryError (#2767)', () => {
|
||||
it('classifies the real "doesn\'t have an index" message (confirmed against a live QUERY_FTS_INDEX call) as missing-index', () => {
|
||||
expect(
|
||||
classifyFtsQueryError(
|
||||
"Prepare failed: Binder exception: Table File doesn't have an index with name file_fts.",
|
||||
),
|
||||
).toBe('missing-index');
|
||||
});
|
||||
|
||||
it('classifies the real "table does not exist" message (confirmed against a live QUERY_FTS_INDEX call on a nonexistent table) as missing-table, distinct from missing-index (tri-review NEW-6)', () => {
|
||||
// Empirically confirmed: the table-missing message uses "does not
|
||||
// exist", NOT "doesn't have an index with name" — a genuinely different
|
||||
// phrasing from missing-index, not the same condition under two names.
|
||||
// Conflating them was the exact bug: a corrupted/partial DB (table
|
||||
// itself gone) would have been silently treated as the ordinary
|
||||
// "index not built yet" case.
|
||||
expect(
|
||||
classifyFtsQueryError(
|
||||
'Prepare failed: Binder exception: Table TotallyNonexistentTable does not exist.',
|
||||
),
|
||||
).toBe('missing-table');
|
||||
});
|
||||
|
||||
it('classifies a Catalog-exception "does not exist" message as missing-table too (both exception classes covered)', () => {
|
||||
expect(classifyFtsQueryError('Catalog exception: Table SomeTable does not exist.')).toBe(
|
||||
'missing-table',
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies the extension-unavailable Catalog exception as other, not benign (mirrors the confirmed DROP_FTS_INDEX shape for QUERY_FTS_INDEX)', () => {
|
||||
// Message shape confirmed for DROP_FTS_INDEX in
|
||||
// drop-fts-index-error-classification.test.ts; QUERY_FTS_INDEX would
|
||||
// fail identically when the extension isn't loaded (same catalog).
|
||||
expect(
|
||||
classifyFtsQueryError(
|
||||
"Catalog exception: function QUERY_FTS_INDEX is not defined. This function exists in the FTS extension. You can install and load the extension by running 'INSTALL FTS; LOAD EXTENSION FTS;'.",
|
||||
),
|
||||
).toBe('other');
|
||||
});
|
||||
|
||||
it('does not misclassify a real, differently-classed error that echoes the benign phrase in its body', () => {
|
||||
// Adversarial case: a Runtime exception (not Binder/Catalog) that
|
||||
// happens to echo the user's own search text — which could itself
|
||||
// contain "does not exist" — must not be anchored away as benign.
|
||||
expect(
|
||||
classifyFtsQueryError(
|
||||
'Runtime exception: FTS query syntax error near "the config file does not exist here"',
|
||||
),
|
||||
).toBe('other');
|
||||
});
|
||||
|
||||
it('does not misclassify a real Binder-class error unrelated to a missing FTS index', () => {
|
||||
expect(classifyFtsQueryError('Binder exception: column X does not match expected type')).toBe(
|
||||
'other',
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies any other message as other', () => {
|
||||
expect(classifyFtsQueryError('Query execution timed out after 30000ms')).toBe('other');
|
||||
expect(classifyFtsQueryError('Connection pool exhausted')).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP pool path — real vs benign FTS query errors (#2767)', () => {
|
||||
const REPO = 'test-repo-error-classification';
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteParameterized.mockReset();
|
||||
});
|
||||
|
||||
it('a benign missing-index error on every table leaves nonBenignErrors unset (unchanged behavior)', async () => {
|
||||
mockExecuteParameterized.mockRejectedValue(
|
||||
new Error("Binder exception: Table Function doesn't have an index with name function_fts."),
|
||||
);
|
||||
|
||||
const response = await searchFTSFromLbug('login', 5, REPO);
|
||||
|
||||
expect(response.ftsAvailable).toBe(false);
|
||||
expect(response.nonBenignErrors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a missing-table error (table itself gone, not just its FTS index) surfaces as non-benign — schema drift is not the ordinary degraded state (tri-review NEW-6)', async () => {
|
||||
mockExecuteParameterized.mockRejectedValue(
|
||||
new Error('Binder exception: Table Function does not exist.'),
|
||||
);
|
||||
|
||||
const response = await searchFTSFromLbug('login', 5, REPO);
|
||||
|
||||
expect(response.ftsAvailable).toBe(false);
|
||||
expect(response.nonBenignErrors!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('a real error on every table surfaces it in nonBenignErrors, redacted', async () => {
|
||||
mockExecuteParameterized.mockRejectedValue(
|
||||
new Error(
|
||||
'Query execution failed: connection reset at /home/alice/.gitnexus/lbug/main.lbug',
|
||||
),
|
||||
);
|
||||
|
||||
const response = await searchFTSFromLbug('login', 5, REPO);
|
||||
|
||||
expect(response.ftsAvailable).toBe(false);
|
||||
expect(response.nonBenignErrors).toBeDefined();
|
||||
expect(response.nonBenignErrors!.length).toBeGreaterThan(0);
|
||||
expect(response.nonBenignErrors![0]).toContain('connection reset');
|
||||
expect(response.nonBenignErrors![0]).not.toMatch(/\/home\/alice/);
|
||||
});
|
||||
|
||||
it('a real error on one table while another succeeds is still reported (partial-failure gap closed)', async () => {
|
||||
let call = 0;
|
||||
mockExecuteParameterized.mockImplementation(async (_repo: string, cypher: string) => {
|
||||
call++;
|
||||
if (cypher.includes("QUERY_FTS_INDEX('Function'")) {
|
||||
throw new Error('Query execution timed out after 30000ms');
|
||||
}
|
||||
if (cypher.includes("QUERY_FTS_INDEX('File'")) {
|
||||
return [{ node: { filePath: 'src/index.ts', id: 'file:index' }, score: 3 }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const response = await searchFTSFromLbug('login', 5, REPO);
|
||||
|
||||
// At least one table succeeded, so the client-visible availability
|
||||
// signal and result set are unaffected (regression guard).
|
||||
expect(response.ftsAvailable).toBe(true);
|
||||
expect(response.results.length).toBeGreaterThan(0);
|
||||
// But the real error on the OTHER table is not silently dropped.
|
||||
expect(response.nonBenignErrors).toBeDefined();
|
||||
expect(response.nonBenignErrors![0]).toContain('timed out');
|
||||
expect(call).toBe(FTS_INDEXES.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('short-circuits when the FTS extension is unavailable (tri-review NEW-4)', () => {
|
||||
const REPO = 'test-repo-extension-unavailable';
|
||||
|
||||
afterEach(() => {
|
||||
resetExtensionState();
|
||||
});
|
||||
|
||||
it('MCP pool path: skips per-table QUERY_FTS_INDEX calls and reports no nonBenignErrors when the extension failed to load', async () => {
|
||||
await extensionManager.ensure(
|
||||
vi.fn().mockRejectedValue(new Error('invalid ELF header.')),
|
||||
'fts',
|
||||
'FTS',
|
||||
{ policy: 'load-only' },
|
||||
);
|
||||
mockExecuteParameterized.mockReset();
|
||||
|
||||
const response = await searchFTSFromLbug('login', 5, REPO);
|
||||
|
||||
// The expected degraded-capability state — not per-table query errors.
|
||||
expect(response.ftsAvailable).toBe(false);
|
||||
expect(response.nonBenignErrors).toBeUndefined();
|
||||
// No redundant round-trips to a pool that can't have FTS loaded.
|
||||
expect(mockExecuteParameterized).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('CLI/pipeline path (no repoId): also skips per-table calls and reports no nonBenignErrors — same expected state, same silence (fixes the pool-only guard a /simplify altitude pass caught)', async () => {
|
||||
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await extensionManager.ensure(
|
||||
vi.fn().mockRejectedValue(new Error('invalid ELF header.')),
|
||||
'fts',
|
||||
'FTS',
|
||||
{ policy: 'load-only' },
|
||||
);
|
||||
vi.mocked(queryFTS).mockClear();
|
||||
|
||||
const response = await searchFTSFromLbug('login', 5); // no repoId → CLI/pipeline branch
|
||||
|
||||
expect(response.ftsAvailable).toBe(false);
|
||||
expect(response.nonBenignErrors).toBeUndefined();
|
||||
expect(vi.mocked(queryFTS)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GITNEXUS_FTS_CJK_SEGMENTATION query-side transform (#2331)', () => {
|
||||
const CJK_REPO = 'test-repo-cjk-query';
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue