mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
feat: add Spring DI resolver for @Autowired List<T> injection (#2200)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
* feat: add Spring DI resolver for @Autowired List<T> injection Addresses all P0/P1 findings from tri-review (#2200): - P0: Register INJECTS in RelationshipType union (compiles) - P0: Rewrite execute() to emit consumer→implementation edges from graph data only - P1: Register in VALID_RELATION_TYPES, single-pass O(N) indexes - P1: Java-only gate with early exit on non-Java repos - P1: Update FULL_ORDER golden test - 8 unit tests covering all edge cases * test: make VALID_RELATION_TYPES size assertion array-driven (no hardcoded count) The security test hardcoded toBe(16) for the relation type count, but PR #2200 added INJECTS, bumping it to 17. Replace the magic number with an EXPECTED_RELATION_TYPES array whose .length drives the size assertion, so future additions only need to append to the list. Fixes CI failure on PR #2200. * fix(ingestion): thread raw generic field types onto Property nodes so Spring DI matching works (review 4616076037 P0) Production declaredType is generics-stripped by design (extractSimpleTypeName: List<Shape> -> "List"), so the spring-di phase's anchored regexes could never match real extraction output — the phase was a silent no-op on every real Java repository, while its unit tests passed against hand-built node shapes. Add FieldInfo.rawDeclaredType captured verbatim from the field's type node (.text, generics and qualifiers preserved — same precedent as the JVM method extractor), thread it through both parse-worker Property sites, add it to the shared NodeProperties contract, and match on rawDeclaredType ONLY (no declaredType fallback: it can never match real data and would mask future plumbing regressions as quiet no-ops). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ingestion): gate Spring DI on real injection annotations, honest edge reason (review 4616076037 P1) Extract Java field annotations (shared extractAnnotations helper, moved verbatim from the method extractor) onto Property nodes and require @Autowired or @Inject before a collection field becomes an INJECTS candidate. Previously every edge's reason string fabricated "@Autowired" without any annotation ever being checked, and any plain collection field would have fanned out false edges once matching worked. @Resource is deliberately excluded: JSR-250 resolves by bean name first (defaulting to the field name), injecting a single named collection bean — the opposite of the collect-all-implementers fan-out INJECTS models. Pinned by a test. An annotated candidate missing rawDeclaredType now logs an isDev warning (plumbing-contract breach signal) instead of vanishing silently. SCHEMA_BUMP 9 -> 10: Property nodes gained rawDeclaredType + annotations; warm parse caches must invalidate or the DI phase silently no-ops on replayed pre-upgrade nodes (the #2038 trap). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(ingestion): framework-neutral di phase + language-scoped Spring matcher registry (review 4616076037 P1) spring-di was the only pipeline phase naming a language in shared core/ingestion code (DoD.md language rule; the maintainer's direction is a generic DI solution). Split it: - di-extractors/spring.ts: the Spring matcher (annotation gate, collection type parse, @Resource exclusion rationale, framework-specific reason payload) — language-scoped home, mirroring route-extractors/. - di-extractors/index.ts: DI_MATCHERS, a single-valued ReadonlyMap<SupportedLanguages, DiFieldMatcher> mirroring the SCOPE_RESOLVERS registry shape sanctioned by AGENTS.md. Constructor injection deliberately out of scope; widen to arrays only when a second same-language framework lands. - pipeline-phases/di.ts (renamed from spring-di.ts): framework-neutral — routes Property nodes to registered matchers by node language via a typed guard, then runs the unchanged reverse-index fan-out. Zero language or framework names remain (grep-verified). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ingestion): language- and qualified-name-scoped interface resolution for DI fan-out (review 4616076037 P2) The interface index was built from ALL Interface nodes regardless of language, keyed by bare simple name with last-writer-wins overwrite — a polyglot repo with a TS and a Java 'Shape' could fan Java INJECTS edges into TypeScript classes, and two same-named Java interfaces in different packages silently collapsed to whichever parsed last (documented GitNexus bug class: #2054, PR #1956). Resolution is now per-language with qualifiedName as the primary key (Interface nodes already carry package-qualified qualifiedName); dotted element types resolve via qualifiedName, bare names via a per-language simple-name index that records ambiguity and fails CLOSED. Ambiguity skips are observable: DIOutput.ambiguousSkipped + an aggregated isDev debug log, so 'no DI fields' is distinguishable from 'all candidates ambiguous'. Same-package tiebreaking is a pinned, documented follow-up. Order-independence pinned by running collision tests in both insertion orders. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ingestion): depth-aware Spring collection-type parser for idiomatic generics (review 4616076037 P3) The two anchored regexes silently skipped idiomatic Spring shapes: Map<Pair<A,B>, IFoo> (nested-generic key broke the [^,]+ split), List<? extends IFoo> / List<? super IFoo> (bounded wildcards), java.util.List<IFoo> (qualified wrapper), and whitespace/multi-line declarations. Replace them with a small scanner: whitespace normalization, wrapper matched by last dotted segment, depth-aware top-level-comma split, wildcard bound stripping, and a final plain-dotted-type-name gate so anything else (nested-generic elements, arrays, unbounded wildcards, embedded comments, unbalanced brackets) fails closed. Every accept and reject is documented in the module docstring and pinned by 27 table-driven cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(integration): prove Spring DI end-to-end through the real pipeline (review 4616076037 P1) Both no-op incarnations of this feature shipped with a green unit suite because every test hand-built the exact graph shape the phase expected — no test ever ran real Java source through the actual extraction pipeline. Add test/integration/spring-di-pipeline.test.ts: real .java fixtures via runPipelineFromRepo, pinning (a) the extraction contract on the annotated field's Property node (declaredType 'List', rawDeclaredType 'List<IFoo>', annotations ['@Autowired']), (b) set-equality on ALL INJECTS edges (exactly Consumer->FooA and Consumer->FooB; the non-annotated 'plain' field of the same type contributes nothing; no self-edges), and (c) a negative-control fixture with no injection annotations producing zero INJECTS edges. Either historical regression fails at least one of these. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(incremental): register INJECTS across product surfaces + delete-before-writeback (review 4616076037 P2) INJECTS was allowlisted in VALID_RELATION_TYPES but invisible or unhandled everywhere else. Register it deliberately: - REL_TYPES (gitnexus-shared schema-constants): web-side validRelType() otherwise silently rejects INJECTS filters (CLI/web single source of truth). - mcp/tools.ts cypher edge list (agent-facing schema discovery). - isGraphWideRelType: INJECTS validity is a whole-program property — a change to a THIRD file (the interface, or a new/removed implementer) creates/invalidates edges between two untouched files (the TAINT_PATH / #2084 M4 U6 class), so incremental extraction must always re-include the full fresh set. - deleteAllInjects (lbug-adapter): mirrors deleteAllInterprocTaintPaths — COUNT-then-DELETE under withConnLock, benign missing-table carve-out, re-throw otherwise (CodeRelation has no PK and there is no read-side dedup; a fail-soft delete + re-add would silently duplicate rows). - run-analyze.ts: the delete is UNCONDITIONAL, next to the Communities delete — deliberately NOT inside the options.pdg block: the di phase runs on every persisting analyze while the graph-wide re-include is unconditional, so a pdg-gated delete would append without deleting on every non-pdg incremental run (N runs = N copies). - local-backend.ts comment: opt-in traversal by design (not in default impact()/context() lists; no IMPACT_RELATION_CONFIDENCE entry per the WRAPS/FETCHES precedent — edges carry their own 0.8). - ARCHITECTURE.md: 14 -> 15 phases, DAG diagram, phase table, skip-list. Note: the tools.ts edge list also predates WRAPS/QUERIES/USES — that drift is pre-existing and left for a follow-up. Idempotency pinned end-to-end: two successive incremental runs (real runFullAnalysis + real LadybugDB, unrelated-file touches) leave the INJECTS row count stable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: describe INJECTS' actual precondition; drop stale fixed-at-16 comments (review 4616076037 P3) The shared-schema doc for INJECTS claimed an @Autowired precondition the code (pre-fix) never checked, and hardwired Spring semantics into what is now a framework-neutral edge type. Reword: precondition is an injection annotation recognized by a per-language matcher in di-extractors/; framework specifics live in the reason payload, not the type contract. security.test.ts comments still said the allow-list size 'stays fixed at 16' (it is 17 and the assertion derives from EXPECTED_RELATION_TYPES). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: simplify DI surfaces — narrow matcher contract, dedup delete-alls, derive tools edge list Post-implementation simplification pass (4 review angles): - DiFieldMatch/CandidateField carried collectionType + matchedAnnotation that no consumer read (the matcher bakes both into reason) — narrowed to {elementTypeName, reason}. - parseElementTypeName had two guard branches fully subsumed by the final plain-dotted-type-name gate — deleted, rationale folded into the regex comment. - The three byte-identical delete-all-by-rel-type functions in lbug-adapter (TAINT_PATH / CALL_SUMMARY / INJECTS) are now one parameterized helper + thin wrappers with identical names, signatures, and message text (character-diff verified) — the missing-table regex and abort policy now live in exactly one place. - The cypher tool's hand-maintained edge-type list (already missing WRAPS/QUERIES/USES) is now derived from the canonical REL_TYPES — the drift class is gone rather than patched. - di phase: interface indexes are built only for languages that actually have candidates; test builder gained a rawDeclaredType opt-out replacing a hand-rolled node. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: apply Tier-2 review findings — qualified-name fail-closed, honest cypher docs, pinned delete contract, hook isolation - byQualifiedName was last-writer-wins on duplicate qualified names (reproduced: order-dependent INJECTS edges with ambiguousSkipped 0 — same package+interface duplicated across monorepo modules/source roots; Java qualifiedName has no file-path component). Both indexes now share the AMBIGUOUS fail-closed sentinel; order-flip test added. - The REL_TYPES-derived cypher edge list advertised pdg-gated types with no caveat (LLM queries on them silently return zero rows on default indexes) — caveat appended, INJECTS example added, impact relationTypes description now names the DI fan-out opt-in. - The delete-all re-throw contract (only defense against duplicate CodeRelation rows) was untested — error classification extracted to a pure classifyDeleteAllError and pinned exhaustively. - extractRawType/extractAnnotations hooks lacked the per-hook try/catch the pipeline applies elsewhere (#2286 pattern): a throwing hook would silently drop every remaining file in the language group. Hardened, degradation tested. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
fa8ebf672e
commit
1029a8ddd7
31 changed files with 2308 additions and 147 deletions
|
|
@ -15,7 +15,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
|||
|
||||
## End-to-end flow: index → graph → tools
|
||||
|
||||
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 14 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`). DAG of 15 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).
|
||||
|
||||
|
|
@ -82,11 +82,11 @@ Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path th
|
|||
|
||||
## Pipeline Phase DAG
|
||||
|
||||
14 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
|
||||
15 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
|
||||
|
||||
```
|
||||
scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
||||
→ crossFile → scopeResolution → pruneLocalSymbols → mro → communities → processes
|
||||
→ crossFile → scopeResolution → pruneLocalSymbols → mro → di → communities → processes
|
||||
```
|
||||
|
||||
| Phase | File | Deps | Output |
|
||||
|
|
@ -103,6 +103,7 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
|||
| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator |
|
||||
| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution |
|
||||
| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
|
||||
| `di` | `di.ts` | `mro` | INJECTS edges (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) |
|
||||
| `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 |
|
||||
|
||||
|
|
@ -126,7 +127,7 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
|||
- **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.
|
||||
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests); `pruneLocalSymbols` still runs (it is graph cleanup, not analysis). `skipWorkers` is no longer a sequential escape hatch — it (like `--workers 0` / `GITNEXUS_WORKER_POOL_SIZE=0`) is rejected with an actionable error, since the worker pool is the sole parse path (§ Chunked parse-and-resolve).
|
||||
- **Skippable phases** — `skipGraphPhases` omits MRO/di/communities/processes (faster tests); `pruneLocalSymbols` still runs (it is graph cleanup, not analysis). `skipWorkers` is no longer a sequential escape hatch — it (like `--workers 0` / `GITNEXUS_WORKER_POOL_SIZE=0`) is rejected with an actionable error, since the worker pool is the sole parse path (§ Chunked parse-and-resolve).
|
||||
- **Local-symbol pruning** — `pruneLocalSymbols` removes inert block-local value symbols after scope resolution has consumed them. Opt out per-call with `PipelineOptions.keepLocalValueSymbols` or globally with the `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` env var.
|
||||
|
||||
### How to add a new phase
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ export type NodeProperties = {
|
|||
level?: number;
|
||||
returnType?: string;
|
||||
declaredType?: string;
|
||||
/** Verbatim declared-type source text with generics preserved
|
||||
* (e.g. `List<Shape>` where `declaredType` is the stripped `List`). */
|
||||
rawDeclaredType?: string;
|
||||
visibility?: string;
|
||||
isStatic?: boolean;
|
||||
isReadonly?: boolean;
|
||||
|
|
@ -124,6 +127,19 @@ export type RelationshipType =
|
|||
| 'ENTRY_POINT_OF'
|
||||
| 'WRAPS'
|
||||
| 'QUERIES'
|
||||
/** Dependency-injection edge: a consumer class receives every implementer
|
||||
* of interface `T` via a container-injected collection-typed field
|
||||
* (`List<T>`, `Set<T>`, `Collection<T>`, or `Map<K,T>`). Precondition: the
|
||||
* field carries an injection annotation recognized by a per-language
|
||||
* matcher registered in `di-extractors/` (Java/Spring today: `@Autowired`
|
||||
* or `@Inject`; `@Resource` is excluded — by-name-first semantics).
|
||||
* Source = the consumer Class node (the one owning the field).
|
||||
* Target = an implementing Class node.
|
||||
* 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
|
||||
* consumer, complementing the structural `IMPLEMENTS` heritage edges. */
|
||||
| 'INJECTS'
|
||||
/** 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.
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export const REL_TYPES = [
|
|||
'ENTRY_POINT_OF',
|
||||
'WRAPS',
|
||||
'QUERIES',
|
||||
'INJECTS',
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -68,8 +68,21 @@ const isGraphWide = (label: string): boolean => label === 'Community' || label =
|
|||
// re-included from the FULL fresh graph (which the emit phase recomputes every
|
||||
// run) or an unchanged function's summary would be lost. Cheap: one self-loop
|
||||
// edge per return-flowing function.
|
||||
//
|
||||
// `INJECTS` (DI collection injection, #2200) is the same class as TAINT_PATH
|
||||
// (the #2084 M4 U6 pattern above): its validity is a whole-program property —
|
||||
// a change to a THIRD file (the interface itself, or a new/removed
|
||||
// implementer) creates or invalidates edges between two files that were never
|
||||
// touched, so the endpoint-writability rule would strand a stale
|
||||
// consumer→implementer edge (or miss a new one). Always re-extracted from the
|
||||
// fresh graph; the orchestrator unconditionally delete-alls the old rows
|
||||
// first (`deleteAllInjects`). Crash-recovery: delete-then-COPY is not atomic
|
||||
// by design — a crash between them loses INJECTS edges until the next
|
||||
// analyze, and the `incrementalInProgress` dirty flag (saved before any
|
||||
// delete) forces a full rebuild on the next run. Temporary absence is
|
||||
// possible; duplicates are not.
|
||||
const isGraphWideRelType = (type: string): boolean =>
|
||||
type === 'TAINT_PATH' || type === 'CALL_SUMMARY';
|
||||
type === 'TAINT_PATH' || type === 'CALL_SUMMARY' || type === 'INJECTS';
|
||||
|
||||
/**
|
||||
* Build a Map<nodeId, filePath> for every File-bound node in the graph.
|
||||
|
|
|
|||
61
gitnexus/src/core/ingestion/di-extractors/index.ts
Normal file
61
gitnexus/src/core/ingestion/di-extractors/index.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Per-language DI field-matcher registry — the lookup the generic `di`
|
||||
* pipeline phase uses to decide whether a `Property` node is a
|
||||
* dependency-injection fan-out candidate.
|
||||
*
|
||||
* Mirrors `scope-resolution/pipeline/registry.ts` (`SCOPE_RESOLVERS`): a
|
||||
* single-valued `ReadonlyMap<SupportedLanguages, DiFieldMatcher>` consumed by
|
||||
* a framework-neutral phase, so no language or framework names leak into
|
||||
* shared pipeline code. Adding a framework is two lines: implement a
|
||||
* `DiFieldMatcher` in `di-extractors/<framework>.ts` and register it here.
|
||||
*
|
||||
* Scope honesty: matchers are per-language *field-injection* matchers.
|
||||
* Constructor injection (the dominant modern Spring idiom) lives on
|
||||
* Method/parameter nodes and would require widening the phase's routing —
|
||||
* deliberately out of scope (see the plan's Deferred work). The registry is
|
||||
* single-valued per language, matching the `SCOPE_RESOLVERS` shape; widen the
|
||||
* value type to arrays only when a second same-language framework actually
|
||||
* lands (a one-line type change then).
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import { springDiFieldMatcher } from './spring.js';
|
||||
|
||||
/** A successful DI field match, produced by a per-language matcher. */
|
||||
export interface DiFieldMatch {
|
||||
/** The element type name `T` — the injected bean interface. */
|
||||
elementTypeName: string;
|
||||
/** Human-readable edge reason. Framework specifics (names, idioms,
|
||||
* collection wrapper, gating annotation) live in this payload so the
|
||||
* shared `di` phase stays framework-neutral. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-language field-injection matcher: given a `Property` node, return the
|
||||
* parsed DI match or `null` when the field is not container-injected. The
|
||||
* matcher receives the whole node (not pre-plucked fields) so the shared
|
||||
* phase stays ignorant of which properties matter.
|
||||
*/
|
||||
export type DiFieldMatcher = (node: GraphNode) => DiFieldMatch | null;
|
||||
|
||||
/** All `SupportedLanguages` string values, for narrowing raw graph strings. */
|
||||
const SUPPORTED_LANGUAGE_VALUES: ReadonlySet<string> = new Set(Object.values(SupportedLanguages));
|
||||
|
||||
/**
|
||||
* Type guard narrowing an arbitrary graph `language` string to
|
||||
* `SupportedLanguages`, so `DI_MATCHERS.get()` needs no cast.
|
||||
*/
|
||||
export function isSupportedLanguage(value: string): value is SupportedLanguages {
|
||||
return SUPPORTED_LANGUAGE_VALUES.has(value);
|
||||
}
|
||||
|
||||
/** Map of `SupportedLanguages` → `DiFieldMatcher`. The `di` phase routes each
|
||||
* `Property` node here by `node.properties.language`; no entry ⇒ the node is
|
||||
* skipped. This is the single source of truth for which languages (and,
|
||||
* transitively, frameworks) produce INJECTS edges. */
|
||||
export const DI_MATCHERS: ReadonlyMap<SupportedLanguages, DiFieldMatcher> = new Map<
|
||||
SupportedLanguages,
|
||||
DiFieldMatcher
|
||||
>([[SupportedLanguages.Java, springDiFieldMatcher]]);
|
||||
222
gitnexus/src/core/ingestion/di-extractors/spring.ts
Normal file
222
gitnexus/src/core/ingestion/di-extractors/spring.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* Spring dependency-injection field matcher for the generic `di` phase.
|
||||
*
|
||||
* Recognizes the fields Spring's container fills via collect-all-implementers
|
||||
* collection injection: when a Java class declares a field carrying an
|
||||
* injection annotation (`@Autowired` or `@Inject`) typed as `List<T>`,
|
||||
* `Set<T>`, `Collection<T>`, or `Map<K,T>`, the container injects EVERY bean
|
||||
* implementing interface `T`. The matcher reports the element type name `T`
|
||||
* plus a human-readable reason naming the collection wrapper and the
|
||||
* annotation that gated the match; the shared `di` phase turns that into
|
||||
* `INJECTS` edges.
|
||||
*
|
||||
* The injection annotation is a hard precondition: a plain (non-annotated)
|
||||
* collection field is never injected by the container and produces no match.
|
||||
* `@Resource` (JSR-250) is DELIBERATELY excluded: it resolves by bean NAME
|
||||
* first (defaulting to the field name), which injects a single named
|
||||
* collection bean — the opposite of the collect-all-implementers fan-out
|
||||
* INJECTS models. Including it would emit false edges.
|
||||
*
|
||||
* Matching happens on `rawDeclaredType` (the verbatim type text, generics
|
||||
* preserved) — NOT `declaredType`, which is generics-stripped by design
|
||||
* (`List<Shape>` → `List`) and can never match the collection patterns.
|
||||
*
|
||||
* Accepted type shapes (after whitespace normalization — internal runs of
|
||||
* whitespace, including newlines from multi-line declarations, collapse to a
|
||||
* single space):
|
||||
* - `List<T>` / `Set<T>` / `Collection<T>` — element `T`.
|
||||
* - `Map<K, T>` — element is the VALUE type `T`; the key `K` is irrelevant
|
||||
* for DI resolution and may itself be generic (`Map<Pair<A,B>, T>` — the
|
||||
* top-level-comma split is bracket-depth-aware, so nested commas in the
|
||||
* key never bleed into the element).
|
||||
* - Bounded wildcards `List<? extends T>` / `List<? super T>` — element `T`
|
||||
* (both are idiomatic Spring collection injection; the container still
|
||||
* collects every implementer of `T`).
|
||||
* - Package-qualified wrappers `java.util.List<T>` — the wrapper is
|
||||
* recognized by its LAST dotted segment. The ELEMENT keeps its dots
|
||||
* (`List<com.a.Shape>` → `com.a.Shape`): dotted element names resolve via
|
||||
* `qualifiedName` downstream in the `di` phase.
|
||||
*
|
||||
* Documented REJECTIONS (parse returns `null` — no INJECTS edges):
|
||||
* - `Map<String, List<IFoo>>` — the element itself is generic; a nested
|
||||
* generic is not resolvable as a single interface.
|
||||
* - `List<?>` — unbounded wildcard; there is no element type to fan out to.
|
||||
* - Arrays: `IFoo[]`, `List<IFoo>[]`, `List<IFoo[]>` — array injection is
|
||||
* not the collect-all-implementers shape INJECTS models.
|
||||
* - Non-collection types (`IFoo`, `Optional<IFoo>`, …) and wrong generic
|
||||
* arity (`Map<String>`, `List<A, B>`).
|
||||
* - Anything whose element is not a plain (possibly dotted) Java type name —
|
||||
* this makes the parser fail closed on unanticipated syntax. In particular
|
||||
* Java block comments inside the generic arguments (a `/* ... ` comment
|
||||
* between `<` and the element) are NOT stripped and fail closed —
|
||||
* acceptable.
|
||||
*
|
||||
* Registered under `SupportedLanguages.Java` in `./index.ts` (`DI_MATCHERS`);
|
||||
* language routing is the registry's job, so the matcher itself never reads
|
||||
* `node.properties.language`.
|
||||
*/
|
||||
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import type { DiFieldMatch, DiFieldMatcher } from './index.js';
|
||||
import { isDev } from '../utils/env.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
/**
|
||||
* Annotations that trigger Spring's collect-all-implementers collection
|
||||
* injection. `@Resource` is deliberately absent — JSR-250 resolves by bean
|
||||
* NAME first (defaulting to the field name), injecting a single named
|
||||
* collection bean rather than fanning out to every implementer, so an
|
||||
* INJECTS fan-out for it would be a false edge.
|
||||
*/
|
||||
const INJECTION_ANNOTATIONS: ReadonlySet<string> = new Set(['@Autowired', '@Inject']);
|
||||
|
||||
/** Collection wrappers whose generic element Spring fans out to every
|
||||
* implementer. `Map` is special-cased for arity (2 args, element = value). */
|
||||
const COLLECTION_WRAPPERS: ReadonlySet<string> = new Set(['List', 'Set', 'Collection', 'Map']);
|
||||
|
||||
/** Bounded-wildcard prefixes stripped from the element position (single-spaced
|
||||
* — the input is whitespace-normalized before these are checked). */
|
||||
const WILDCARD_EXTENDS_PREFIX = '? extends ';
|
||||
const WILDCARD_SUPER_PREFIX = '? super ';
|
||||
|
||||
/** A plain (possibly dotted) Java type name — the only element shape the
|
||||
* parser accepts. Everything else (wildcards, arrays, comments, stray
|
||||
* punctuation) fails closed. */
|
||||
const JAVA_TYPE_NAME_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/;
|
||||
|
||||
/**
|
||||
* Split a generic-argument list on TOP-LEVEL commas only, tracking `<`/`>`
|
||||
* bracket depth so nested generics (e.g. the `Pair<A,B>` key in
|
||||
* `Map<Pair<A,B>, IFoo>`) never split mid-argument.
|
||||
*
|
||||
* @returns the top-level argument segments (untrimmed), or `null` when the
|
||||
* brackets are unbalanced (fail closed on malformed input).
|
||||
*/
|
||||
function splitTopLevelGenericArgs(inner: string): string[] | null {
|
||||
const args: string[] = [];
|
||||
let depth = 0;
|
||||
let segmentStart = 0;
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
const ch = inner[i];
|
||||
if (ch === '<') {
|
||||
depth++;
|
||||
} else if (ch === '>') {
|
||||
depth--;
|
||||
if (depth < 0) return null;
|
||||
} else if (ch === ',' && depth === 0) {
|
||||
args.push(inner.slice(segmentStart, i));
|
||||
segmentStart = i + 1;
|
||||
}
|
||||
}
|
||||
if (depth !== 0) return null;
|
||||
args.push(inner.slice(segmentStart));
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the injected bean type name from one (whitespace-normalized)
|
||||
* generic-argument segment: strip a bounded-wildcard prefix, then require a
|
||||
* plain dotted Java type name.
|
||||
*
|
||||
* @returns the element type name, or `null` for unbounded wildcards, nested
|
||||
* generics, arrays, and any other non-type-name shape (fail closed).
|
||||
*/
|
||||
function parseElementTypeName(segment: string): string | null {
|
||||
let element = segment.trim();
|
||||
// Bounded wildcards are idiomatic collection injection: the container
|
||||
// still collects every implementer of the bound.
|
||||
if (element.startsWith(WILDCARD_EXTENDS_PREFIX)) {
|
||||
element = element.slice(WILDCARD_EXTENDS_PREFIX.length);
|
||||
} else if (element.startsWith(WILDCARD_SUPER_PREFIX)) {
|
||||
element = element.slice(WILDCARD_SUPER_PREFIX.length);
|
||||
}
|
||||
// Final gate: a plain (possibly dotted) type name. Rejects nested generics
|
||||
// (`Map<String, List<IFoo>>` — not resolvable as a single interface),
|
||||
// arrays (`List<IFoo[]>` — not the fan-out shape INJECTS models), the
|
||||
// unbounded wildcard `?`, un-stripped comments, and any other residue —
|
||||
// all documented rejections; fail closed.
|
||||
if (!JAVA_TYPE_NAME_PATTERN.test(element)) return null;
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Spring DI collection field's raw declared type (verbatim source
|
||||
* text, generics preserved) and return the injected bean type name.
|
||||
*
|
||||
* Whitespace-normalizes first (raw tree-sitter `.text` can span lines), then
|
||||
* recognizes the wrapper by the LAST dotted segment before the first `<`
|
||||
* (so `java.util.List<IFoo>` works), depth-aware-splits the generic argument
|
||||
* list, and validates the element position. See the module docstring for the
|
||||
* full accepted/rejected shape inventory.
|
||||
*
|
||||
* @returns the collection wrapper name + element type name, or `null` when
|
||||
* the raw declared type is not a recognized Spring collection shape.
|
||||
*/
|
||||
export function parseSpringCollectionType(
|
||||
rawDeclaredType: string,
|
||||
): { collectionType: string; elementTypeName: string } | null {
|
||||
// Collapse ALL internal whitespace runs (spaces, tabs, newlines from
|
||||
// multi-line declarations) to single spaces, then trim the ends.
|
||||
const normalized = rawDeclaredType.replace(/\s+/g, ' ').trim();
|
||||
const openIndex = normalized.indexOf('<');
|
||||
// No generic argument list, or trailing residue after the closing `>`
|
||||
// (e.g. the array suffix in `List<IFoo>[]`) — not a collection injection.
|
||||
if (openIndex === -1 || !normalized.endsWith('>')) return null;
|
||||
// Wrapper = last dotted segment of the pre-`<` text: strips a package
|
||||
// qualifier from the WRAPPER only (`java.util.List` → `List`).
|
||||
const wrapperPath = normalized.slice(0, openIndex).trim();
|
||||
const wrapperSegments = wrapperPath.split('.');
|
||||
const wrapper = wrapperSegments[wrapperSegments.length - 1];
|
||||
if (!COLLECTION_WRAPPERS.has(wrapper)) return null;
|
||||
const inner = normalized.slice(openIndex + 1, normalized.length - 1);
|
||||
const args = splitTopLevelGenericArgs(inner);
|
||||
if (args === null) return null;
|
||||
// List/Set/Collection take exactly one type argument; Map exactly two,
|
||||
// and the injected bean type is the VALUE (2nd argument) — the key is
|
||||
// irrelevant for DI resolution.
|
||||
const expectedArity = wrapper === 'Map' ? 2 : 1;
|
||||
if (args.length !== expectedArity) return null;
|
||||
const elementTypeName = parseElementTypeName(args[expectedArity - 1]);
|
||||
if (elementTypeName === null) return null;
|
||||
return { collectionType: wrapper, elementTypeName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a `Property` node against Spring's collection-injection shape.
|
||||
*
|
||||
* Returns the parsed match (with a Spring-specific human-readable `reason`
|
||||
* payload) or `null` when the field is not container-injected.
|
||||
*/
|
||||
export const springDiFieldMatcher: DiFieldMatcher = (node: GraphNode): DiFieldMatch | null => {
|
||||
// Injection-annotation gate: only fields the container actually
|
||||
// injects (@Autowired / @Inject) are candidates. Plain collection
|
||||
// fields are never injected; @Resource is deliberately excluded
|
||||
// (by-name-first semantics — see INJECTION_ANNOTATIONS).
|
||||
const matchedAnnotation = node.properties.annotations?.find((a) => INJECTION_ANNOTATIONS.has(a));
|
||||
if (matchedAnnotation === undefined) return null;
|
||||
// Match on rawDeclaredType ONLY — no `?? declaredType` fallback:
|
||||
// production `declaredType` is generics-stripped by design, so a
|
||||
// fallback can never match real data and would only mask plumbing
|
||||
// regressions as quiet no-ops.
|
||||
const rawDeclaredType = node.properties.rawDeclaredType;
|
||||
if (!rawDeclaredType) {
|
||||
// An injection-annotated field with NO rawDeclaredType means the
|
||||
// extraction plumbing broke its contract (U1 threads the raw type
|
||||
// wherever annotations are threaded) — surface it, don't silently drop.
|
||||
if (isDev) {
|
||||
logger.warn(
|
||||
`Spring DI: annotated field '${node.properties.name}' (${node.properties.filePath}) has no rawDeclaredType — extraction plumbing contract breach; skipping`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const parsed = parseSpringCollectionType(rawDeclaredType);
|
||||
if (!parsed) return null;
|
||||
return {
|
||||
elementTypeName: parsed.elementTypeName,
|
||||
// Honest reason: states the annotation actually found on the field and
|
||||
// the collection wrapper it gated. Framework specifics live HERE, in the
|
||||
// payload — never in the phase.
|
||||
reason: `Spring DI: ${matchedAnnotation} ${parsed.collectionType}<${parsed.elementTypeName}>`,
|
||||
};
|
||||
};
|
||||
|
|
@ -48,6 +48,34 @@ export function hasModifier(node: SyntaxNode, modifierType: string, keyword: str
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect `'@Name'`-prefixed annotation names from a declaration node's
|
||||
* modifier-wrapper children (e.g. Java `modifiers`). Handles both
|
||||
* `marker_annotation` (`@Autowired`) and `annotation`
|
||||
* (`@Autowired(required=false)`) node types. Node-type-agnostic: works for
|
||||
* any declaration (method, field, ...) that groups annotations under a
|
||||
* wrapper child of type `modifierType`.
|
||||
*
|
||||
* Shared by the JVM method- and field-extractor configs (moved verbatim from
|
||||
* `method-extractors/configs/jvm.ts` in PR #2200 U2).
|
||||
*/
|
||||
export function extractAnnotations(node: SyntaxNode, modifierType: string): string[] {
|
||||
const annotations: string[] = [];
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child && child.type === modifierType) {
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const mod = child.namedChild(j);
|
||||
if (mod && (mod.type === 'marker_annotation' || mod.type === 'annotation')) {
|
||||
const nameNode = mod.childForFieldName('name') ?? mod.firstNamedChild;
|
||||
if (nameNode) annotations.push('@' + nameNode.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return annotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first matching visibility keyword found either as a direct keyword
|
||||
* child or inside a modifier wrapper node.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@
|
|||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { FieldExtractionConfig } from '../generic.js';
|
||||
import { findVisibility, hasKeyword, hasModifier, typeFromField } from './helpers.js';
|
||||
import {
|
||||
extractAnnotations,
|
||||
findVisibility,
|
||||
hasKeyword,
|
||||
hasModifier,
|
||||
typeFromField,
|
||||
} from './helpers.js';
|
||||
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
|
||||
import type { FieldVisibility } from '../../field-types.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
|
@ -55,6 +61,20 @@ export const javaConfig: FieldExtractionConfig = {
|
|||
return undefined;
|
||||
},
|
||||
|
||||
extractRawType(node) {
|
||||
// Verbatim type-node text — preserves generic arguments (`List<Shape>`)
|
||||
// and qualifiers (`java.util.List<Shape>`) that extractType strips.
|
||||
// Precedent: the JVM method extractor keeps raw `.text` for the same
|
||||
// reason (method-extractors/configs/jvm.ts).
|
||||
return node.childForFieldName('type')?.text?.trim();
|
||||
},
|
||||
|
||||
extractAnnotations(node) {
|
||||
// Same walk the JVM method extractor uses — field annotations live under
|
||||
// the `modifiers` child of a `field_declaration` (e.g. `@Autowired`).
|
||||
return extractAnnotations(node, 'modifiers');
|
||||
},
|
||||
|
||||
extractVisibility(node) {
|
||||
return findVisibility(node, JAVA_VIS, 'package', 'modifiers');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -51,6 +51,19 @@ export interface FieldExtractionConfig {
|
|||
extractNames?: (node: SyntaxNode) => string[];
|
||||
/** Extract type annotation from a field declaration node */
|
||||
extractType: (node: SyntaxNode) => string | undefined;
|
||||
/**
|
||||
* Extract the verbatim declared-type source text (trimmed) from a field
|
||||
* declaration node, preserving generic arguments (`List<Shape>` stays
|
||||
* `List<Shape>`). Unlike `extractType`, the result bypasses
|
||||
* `normalizeType`/`resolveType` entirely — it is the untouched source text.
|
||||
*/
|
||||
extractRawType?: (node: SyntaxNode) => string | undefined;
|
||||
/**
|
||||
* Extract `'@Name'`-prefixed annotation names from a field declaration
|
||||
* node (e.g. `['@Autowired']`). Optional — only languages with
|
||||
* field-level annotations implement it.
|
||||
*/
|
||||
extractAnnotations?: (node: SyntaxNode) => string[];
|
||||
/** Extract visibility from a field declaration node */
|
||||
extractVisibility: (node: SyntaxNode) => FieldVisibility;
|
||||
/** Extract visibility for one field name from a multi-name declaration. */
|
||||
|
|
@ -183,9 +196,35 @@ export function createFieldExtractor(config: FieldExtractionConfig): FieldExtrac
|
|||
if (resolved) type = resolved;
|
||||
}
|
||||
|
||||
// Raw declared type deliberately bypasses normalizeType/resolveType —
|
||||
// it is the verbatim source text (generics preserved).
|
||||
let rawDeclaredType: string | undefined;
|
||||
try {
|
||||
rawDeclaredType = config.extractRawType?.(node);
|
||||
} catch {
|
||||
// A throw here (an unexpected tree-sitter node shape, a config bug)
|
||||
// must NOT propagate — it would escape processFileGroup to the
|
||||
// language-group catch, which treats any throw as "parser unavailable"
|
||||
// and silently drops every remaining file in the group. Degrade to a
|
||||
// field without the raw type instead. Mirrors the descriptionExtractor
|
||||
// / extractTemplateConstraints guards in parse-worker.ts (#2286 review).
|
||||
rawDeclaredType = undefined;
|
||||
}
|
||||
|
||||
let annotations: string[] | undefined;
|
||||
try {
|
||||
annotations = config.extractAnnotations?.(node);
|
||||
} catch {
|
||||
// Same group-drop rationale as the extractRawType guard above —
|
||||
// degrade to a field without annotations (#2286 review).
|
||||
annotations = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
...(rawDeclaredType !== undefined ? { rawDeclaredType } : {}),
|
||||
...(annotations !== undefined && annotations.length > 0 ? { annotations } : {}),
|
||||
visibility: config.extractVisibilityForName?.(node, name) ?? config.extractVisibility(node),
|
||||
isStatic: config.isStatic(node),
|
||||
isReadonly: config.isReadonly(node),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,19 @@ export interface FieldInfo {
|
|||
name: string;
|
||||
/** Resolved type (may be primitive, FQN, or generic) */
|
||||
type: string | null;
|
||||
/**
|
||||
* Verbatim declared-type source text (trimmed), preserving generic
|
||||
* arguments and qualifiers — e.g. `List<Shape>` where `type` is `List`.
|
||||
* Never passes through simple-name extraction or type resolution.
|
||||
*/
|
||||
rawDeclaredType?: string;
|
||||
/**
|
||||
* Annotation names found on the field declaration, `'@Name'`-prefixed
|
||||
* (e.g. `['@Autowired']`), matching the method-extractor convention.
|
||||
* Omitted when the language config does not extract annotations or the
|
||||
* field has none.
|
||||
*/
|
||||
annotations?: string[];
|
||||
/** Visibility modifier */
|
||||
visibility: FieldVisibility;
|
||||
/** Is this a static member? */
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import type {
|
|||
ParameterInfo,
|
||||
MethodVisibility,
|
||||
} from '../../method-types.js';
|
||||
import { findVisibility, hasModifier } from '../../field-extractors/configs/helpers.js';
|
||||
import {
|
||||
extractAnnotations,
|
||||
findVisibility,
|
||||
hasModifier,
|
||||
} from '../../field-extractors/configs/helpers.js';
|
||||
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
|
|
@ -24,22 +28,8 @@ function extractReturnTypeFromField(node: SyntaxNode): string | undefined {
|
|||
return typeNode.text?.trim();
|
||||
}
|
||||
|
||||
function extractAnnotations(node: SyntaxNode, modifierType: string): string[] {
|
||||
const annotations: string[] = [];
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child && child.type === modifierType) {
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const mod = child.namedChild(j);
|
||||
if (mod && (mod.type === 'marker_annotation' || mod.type === 'annotation')) {
|
||||
const nameNode = mod.childForFieldName('name') ?? mod.firstNamedChild;
|
||||
if (nameNode) annotations.push('@' + nameNode.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return annotations;
|
||||
}
|
||||
// `extractAnnotations` moved to `field-extractors/configs/helpers.js` (PR
|
||||
// #2200 U2) so the field extractor shares the identical walk.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Java
|
||||
|
|
|
|||
269
gitnexus/src/core/ingestion/pipeline-phases/di.ts
Normal file
269
gitnexus/src/core/ingestion/pipeline-phases/di.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* Phase: di
|
||||
*
|
||||
* Framework-neutral dependency-injection resolution. Routes `Property` nodes
|
||||
* by `properties.language` to the per-language field matchers registered in
|
||||
* `di-extractors/` (`DI_MATCHERS` — same registry seam shape as
|
||||
* `SCOPE_RESOLVERS`), then fans each match out to `INJECTS` edges from the
|
||||
* consumer Class node to every Class implementing the matched element
|
||||
* interface.
|
||||
*
|
||||
* This file names NO language or framework: which fields count as
|
||||
* container-injected — and why — is entirely the registered matcher's
|
||||
* business (see `di-extractors/` for the matchers and their semantics,
|
||||
* including deliberate annotation exclusions). The matcher also supplies the
|
||||
* human-readable edge `reason`, so framework specifics stay in the payload,
|
||||
* never in this phase.
|
||||
*
|
||||
* The resolution uses ONLY graph data — Property nodes, `HAS_PROPERTY` edges,
|
||||
* `IMPLEMENTS` edges, and Interface nodes. No filesystem access is performed:
|
||||
* the structural information was already extracted by earlier parse /
|
||||
* structure phases.
|
||||
*
|
||||
* Interface resolution is scoped to the CANDIDATE'S OWN language and prefers
|
||||
* qualified names: a dotted element type resolves via the language's
|
||||
* `qualifiedName` index; a bare simple name resolves only while unique within
|
||||
* that language. Ambiguous names — simple OR qualified (a qualifiedName has
|
||||
* no file-path component, so the same package+name duplicated across monorepo
|
||||
* modules collides too) — fail CLOSED — no edge, never
|
||||
* last-writer-wins — but observably: skips are counted in the phase output's
|
||||
* `ambiguousSkipped` and named in an isDev debug log, so "no DI fields" is
|
||||
* distinguishable from "all candidates ambiguous". Same-package/import-aware
|
||||
* disambiguation is a documented follow-up (see the plan's Deferred work).
|
||||
*
|
||||
* @deps mro
|
||||
* @reads graph (Property nodes, HAS_PROPERTY edges, IMPLEMENTS edges, Interface nodes)
|
||||
* @writes graph (INJECTS edges)
|
||||
*/
|
||||
|
||||
import type { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { PipelinePhase, PipelineContext } from './types.js';
|
||||
import { DI_MATCHERS, isSupportedLanguage } from '../di-extractors/index.js';
|
||||
import { isDev } from '../utils/env.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
export interface DIOutput {
|
||||
injectsEdges: number;
|
||||
fieldsScanned: number;
|
||||
/** Candidates skipped because their element type name — bare simple name
|
||||
* or dotted qualified name — matched more than one Interface within the
|
||||
* candidate's language (fail-closed). */
|
||||
ambiguousSkipped: number;
|
||||
}
|
||||
|
||||
/** Sentinel marking an interface name (simple or qualified) claimed by more
|
||||
* than one Interface node within a language — resolution must fail closed. */
|
||||
const AMBIGUOUS: unique symbol = Symbol('ambiguous');
|
||||
|
||||
/** Per-language interface lookup: qualified names resolve exactly; bare
|
||||
* simple names resolve only while unique within the language. Both indexes
|
||||
* fail closed on their own duplicates. */
|
||||
interface InterfaceIndex {
|
||||
/** `properties.qualifiedName` → Interface node id (when extracted — e.g.
|
||||
* package-qualified for languages with a file-scope package declaration),
|
||||
* or {@link AMBIGUOUS} once a second Interface claims the same qualified
|
||||
* name in the same language — realistic in monorepos, where the same
|
||||
* package+name is duplicated across modules or main/test source roots
|
||||
* (a qualifiedName carries no file-path component). */
|
||||
byQualifiedName: Map<string, string | typeof AMBIGUOUS>;
|
||||
/** `properties.name` → Interface node id, or {@link AMBIGUOUS} once a
|
||||
* second same-name Interface appears in the same language. */
|
||||
bySimpleName: Map<string, string | typeof AMBIGUOUS>;
|
||||
}
|
||||
|
||||
/** A Property node a registered matcher accepted as a DI fan-out candidate. */
|
||||
interface CandidateField {
|
||||
propertyId: string;
|
||||
/** The candidate's language — interface resolution (Pass 3) looks up ONLY
|
||||
* this language's interface index. */
|
||||
language: SupportedLanguages;
|
||||
elementTypeName: string;
|
||||
/** Matcher-supplied edge reason (carries the framework specifics). */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export const diPhase: PipelinePhase<DIOutput> = {
|
||||
name: 'di',
|
||||
// Depends on `mro` for ordering: heritage edges (IMPLEMENTS/EXTENDS) must be
|
||||
// fully populated before we resolve interface→implementer fan-out.
|
||||
deps: ['mro'],
|
||||
|
||||
async execute(ctx: PipelineContext): Promise<DIOutput> {
|
||||
ctx.onProgress({
|
||||
phase: 'enriching',
|
||||
percent: 98,
|
||||
message: 'Resolving dependency-injection edges...',
|
||||
stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: ctx.graph.nodeCount },
|
||||
});
|
||||
|
||||
// ── Pass 1: route Property nodes to registered per-language matchers ───
|
||||
// Early-exit optimization: if no registered matcher accepts any Property
|
||||
// node, skip all index construction. This makes the phase a no-op on
|
||||
// repos with no DI-matched fields (no IMPLEMENTS / HAS_PROPERTY scans).
|
||||
const candidates: CandidateField[] = [];
|
||||
|
||||
ctx.graph.forEachNode((node) => {
|
||||
if (node.label !== 'Property') return;
|
||||
const language = node.properties.language;
|
||||
if (language === undefined || !isSupportedLanguage(language)) return;
|
||||
const matcher = DI_MATCHERS.get(language);
|
||||
if (matcher === undefined) return;
|
||||
const match = matcher(node);
|
||||
if (match === null) return;
|
||||
candidates.push({
|
||||
propertyId: node.id,
|
||||
language,
|
||||
elementTypeName: match.elementTypeName,
|
||||
reason: match.reason,
|
||||
});
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { injectsEdges: 0, fieldsScanned: 0, ambiguousSkipped: 0 };
|
||||
}
|
||||
|
||||
// ── Pass 2: build single-pass reverse indexes ─────────────────────────
|
||||
|
||||
// interfaceNodeId → Set<implementerClassId> (reverse of IMPLEMENTS edge)
|
||||
// IMPLEMENTS edges go Class→Interface, so target is the interface.
|
||||
// Keyed by node id — globally unique — so this index needs no language
|
||||
// scoping; only NAME-based lookups (below) do.
|
||||
const interfaceToImplementers = new Map<string, Set<string>>();
|
||||
for (const rel of ctx.graph.iterRelationshipsByType('IMPLEMENTS')) {
|
||||
const implementerId = rel.sourceId; // Class
|
||||
const interfaceId = rel.targetId; // Interface
|
||||
let set = interfaceToImplementers.get(interfaceId);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
interfaceToImplementers.set(interfaceId, set);
|
||||
}
|
||||
set.add(implementerId);
|
||||
}
|
||||
|
||||
// propertyNodeId → consumerClassId (reverse of HAS_PROPERTY edge)
|
||||
// HAS_PROPERTY edges go Class→Property, so target is the property.
|
||||
const propertyToClass = new Map<string, string>();
|
||||
for (const rel of ctx.graph.iterRelationshipsByType('HAS_PROPERTY')) {
|
||||
propertyToClass.set(rel.targetId, rel.sourceId);
|
||||
}
|
||||
|
||||
// language → InterfaceIndex (from Interface-labeled nodes). Scoped per
|
||||
// language so an Interface in one language can never satisfy a candidate
|
||||
// from another. Within a language, a name resolves only while unique —
|
||||
// a second Interface claiming the same simple OR qualified name flips
|
||||
// that entry to AMBIGUOUS and resolution fails closed (never
|
||||
// last-writer-wins).
|
||||
// Index only languages that can resolve: an Interface in a language with
|
||||
// no candidate can never be looked up in Pass 3.
|
||||
const candidateLanguages = new Set<string>(candidates.map((c) => c.language));
|
||||
const interfacesByLanguage = new Map<string, InterfaceIndex>();
|
||||
ctx.graph.forEachNode((node) => {
|
||||
if (node.label !== 'Interface') return;
|
||||
const language = node.properties.language;
|
||||
if (typeof language !== 'string') return; // no language ⇒ unindexable
|
||||
if (!candidateLanguages.has(language)) return;
|
||||
let index = interfacesByLanguage.get(language);
|
||||
if (index === undefined) {
|
||||
index = { byQualifiedName: new Map(), bySimpleName: new Map() };
|
||||
interfacesByLanguage.set(language, index);
|
||||
}
|
||||
// `qualifiedName` reaches NodeProperties through the extensible index
|
||||
// signature, so narrow it explicitly (no `any`).
|
||||
const qualifiedName = node.properties.qualifiedName;
|
||||
if (typeof qualifiedName === 'string') {
|
||||
index.byQualifiedName.set(
|
||||
qualifiedName,
|
||||
index.byQualifiedName.has(qualifiedName) ? AMBIGUOUS : node.id,
|
||||
);
|
||||
}
|
||||
const simpleName = node.properties.name;
|
||||
index.bySimpleName.set(simpleName, index.bySimpleName.has(simpleName) ? AMBIGUOUS : node.id);
|
||||
});
|
||||
|
||||
// ── Pass 3: emit INJECTS edges ────────────────────────────────────────
|
||||
let injectsEdges = 0;
|
||||
let ambiguousSkipped = 0;
|
||||
const ambiguousElementTypes = new Set<string>();
|
||||
const seenEdges = new Set<string>();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
// Resolve the consumer Class that owns this Property.
|
||||
const consumerClassId = propertyToClass.get(candidate.propertyId);
|
||||
if (!consumerClassId) continue;
|
||||
|
||||
// Resolve the element type name via the CANDIDATE'S OWN language index
|
||||
// only — a same-named Interface in another language never participates.
|
||||
const index = interfacesByLanguage.get(candidate.language);
|
||||
if (index === undefined) continue;
|
||||
|
||||
// A dotted element type is a qualified name (e.g. `com.a.Shape`) —
|
||||
// exact qualifiedName lookup, unaffected by simple-name ambiguity.
|
||||
// A bare name uses the simple-name index. BOTH lookups fail CLOSED
|
||||
// on their own ambiguity (a qualified name too can be claimed twice —
|
||||
// same package+name across monorepo modules): no edge (never
|
||||
// last-writer-wins), but counted and logged so the skip is
|
||||
// observable. Same-package/import-aware disambiguation is a
|
||||
// deliberate follow-up (plan: Deferred work).
|
||||
let interfaceId: string | undefined;
|
||||
if (candidate.elementTypeName.includes('.')) {
|
||||
const entry = index.byQualifiedName.get(candidate.elementTypeName);
|
||||
if (entry === AMBIGUOUS) {
|
||||
ambiguousSkipped++;
|
||||
ambiguousElementTypes.add(candidate.elementTypeName);
|
||||
continue;
|
||||
}
|
||||
interfaceId = entry;
|
||||
} else {
|
||||
const entry = index.bySimpleName.get(candidate.elementTypeName);
|
||||
if (entry === AMBIGUOUS) {
|
||||
ambiguousSkipped++;
|
||||
ambiguousElementTypes.add(candidate.elementTypeName);
|
||||
continue;
|
||||
}
|
||||
interfaceId = entry;
|
||||
}
|
||||
if (interfaceId === undefined) continue;
|
||||
|
||||
// Fan out to every class implementing that interface.
|
||||
const implementers = interfaceToImplementers.get(interfaceId);
|
||||
if (!implementers) continue;
|
||||
|
||||
for (const implId of implementers) {
|
||||
// Skip self-edges: a class never injects its own bean into itself.
|
||||
if (implId === consumerClassId) continue;
|
||||
|
||||
// Dedup-safe edge ID: deterministic from (consumer, implementer).
|
||||
const edgeId = `INJECTS:${consumerClassId}->${implId}`;
|
||||
if (seenEdges.has(edgeId)) continue;
|
||||
seenEdges.add(edgeId);
|
||||
|
||||
ctx.graph.addRelationship({
|
||||
id: edgeId,
|
||||
sourceId: consumerClassId,
|
||||
targetId: implId,
|
||||
type: 'INJECTS',
|
||||
confidence: 0.8,
|
||||
// Matcher-supplied reason — names the framework and the annotation
|
||||
// actually found on the field (see di-extractors/).
|
||||
reason: candidate.reason,
|
||||
});
|
||||
injectsEdges++;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDev && ambiguousSkipped > 0) {
|
||||
// One aggregated debug line (not per-candidate spam): duplicate simple
|
||||
// names are NORMAL in large repos, but the skip must stay observable.
|
||||
logger.debug(
|
||||
`🧩 DI: ${ambiguousSkipped} candidate(s) skipped — ambiguous element interface name(s): ${[...ambiguousElementTypes].sort().join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (isDev && (injectsEdges > 0 || ambiguousSkipped > 0)) {
|
||||
logger.info(
|
||||
`🧩 DI: ${injectsEdges} INJECTS edges from ${candidates.length} injection-annotated collection fields (${ambiguousSkipped} ambiguous skipped)`,
|
||||
);
|
||||
}
|
||||
|
||||
return { injectsEdges, fieldsScanned: candidates.length, ambiguousSkipped };
|
||||
},
|
||||
};
|
||||
|
|
@ -24,6 +24,7 @@ export { pruneLocalSymbolsPhase, type PruneLocalSymbolsOutput } from './prune-lo
|
|||
export { taintSummariesPhase, type TaintSummariesOutput } from './taint-summaries.js';
|
||||
export { callSummariesPhase, type CallSummariesOutput } from './call-summaries.js';
|
||||
export { mroPhase, type MROOutput } from './mro.js';
|
||||
export { diPhase, type DIOutput } from './di.js';
|
||||
export { communitiesPhase, type CommunitiesOutput } from './communities.js';
|
||||
export { processesPhase, type ProcessesOutput } from './processes.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
taintSummariesPhase,
|
||||
callSummariesPhase,
|
||||
mroPhase,
|
||||
diPhase,
|
||||
communitiesPhase,
|
||||
processesPhase,
|
||||
PhaseRegistry,
|
||||
|
|
@ -243,7 +244,7 @@ export interface PipelineOptions {
|
|||
*
|
||||
* scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
||||
* → crossFile → scopeResolution → pruneLocalSymbols
|
||||
* → mro → communities → processes
|
||||
* → mro → 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
|
||||
|
|
@ -275,6 +276,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(diPhase, { enabledWhen: (o) => !o.skipGraphPhases })
|
||||
.register(communitiesPhase, { enabledWhen: (o) => !o.skipGraphPhases })
|
||||
.register(processesPhase, { enabledWhen: (o) => !o.skipGraphPhases })
|
||||
// Normalize a missing options object once here so phase predicates above
|
||||
|
|
|
|||
|
|
@ -1698,6 +1698,13 @@ const processFileGroup = (
|
|||
: routedFieldInfo?.type
|
||||
? { declaredType: routedFieldInfo.type }
|
||||
: {}),
|
||||
...(routedFieldInfo?.rawDeclaredType !== undefined
|
||||
? { rawDeclaredType: routedFieldInfo.rawDeclaredType }
|
||||
: {}),
|
||||
...(routedFieldInfo?.annotations !== undefined &&
|
||||
routedFieldInfo.annotations.length > 0
|
||||
? { annotations: routedFieldInfo.annotations }
|
||||
: {}),
|
||||
...(routedFieldInfo?.visibility !== undefined
|
||||
? { visibility: routedFieldInfo.visibility }
|
||||
: {}),
|
||||
|
|
@ -2249,6 +2256,15 @@ const processFileGroup = (
|
|||
const info = fieldMap?.get(nodeName);
|
||||
if (info) {
|
||||
declaredType = info.type ?? undefined;
|
||||
// Mutate methodProps BEFORE the `{...methodProps}` spread below —
|
||||
// rawDeclaredType is the verbatim generic type text (U1, PR #2200).
|
||||
if (info.rawDeclaredType !== undefined) {
|
||||
methodProps.rawDeclaredType = info.rawDeclaredType;
|
||||
}
|
||||
// Field annotations ('@Name' strings, U2 PR #2200) — omit when empty.
|
||||
if (info.annotations !== undefined && info.annotations.length > 0) {
|
||||
methodProps.annotations = info.annotations;
|
||||
}
|
||||
methodProps.visibility = info.visibility;
|
||||
methodProps.isStatic = info.isStatic;
|
||||
methodProps.isReadonly = info.isReadonly;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { getNodeLabel as deriveNodeLabel, type WriteStreamFactory } from './rel-
|
|||
import type { CachedEmbedding } from '../embeddings/types.js';
|
||||
import { extensionManager, type ExtensionEnsureOptions } from './extension-loader.js';
|
||||
import {
|
||||
classifyDeleteAllError,
|
||||
closeLbugConnection,
|
||||
isDbBusyError,
|
||||
isOpenRetryExhausted,
|
||||
|
|
@ -2147,6 +2148,72 @@ export const deleteAllCommunitiesAndProcesses = async (): Promise<{
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared mechanics for the delete-all-relationships-of-one-type family
|
||||
* ({@link deleteAllInterprocTaintPaths}, {@link deleteAllCallSummaries},
|
||||
* {@link deleteAllInjects}): count the typed CodeRelation rows, then DELETE
|
||||
* them (relationship-level — these are edge types, not node labels, so
|
||||
* endpoints are untouched).
|
||||
*
|
||||
* count + DELETE run as one critical section on the singleton connection so a
|
||||
* concurrent WAL-checkpoint cannot corrupt native state mid-delete (#pdg).
|
||||
*
|
||||
* @param relType the CodeRelation `type` value to delete (e.g. 'INJECTS')
|
||||
* @param logTag the `[tag]` prefix on the abort error message
|
||||
* @param duplicateNoun what the abort message says would be duplicated
|
||||
*/
|
||||
const deleteAllRelationshipsOfType = async (
|
||||
relType: string,
|
||||
logTag: string,
|
||||
duplicateNoun: string,
|
||||
): Promise<{ edgesDeleted: number }> => {
|
||||
const c = conn;
|
||||
if (!c) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
}
|
||||
return withConnLock(async () => {
|
||||
let edgesDeleted = 0;
|
||||
let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined;
|
||||
try {
|
||||
countResult = await c.query(
|
||||
`MATCH ()-[r:CodeRelation]->() WHERE r.type = '${relType}' RETURN count(r) 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 ()-[r:CodeRelation]->() WHERE r.type = '${relType}' DELETE r`),
|
||||
);
|
||||
edgesDeleted = count;
|
||||
}
|
||||
} catch (err) {
|
||||
// A missing table on a freshly-initialized DB is the benign, expected case
|
||||
// (the count query above is what throws) — stay silent. Any OTHER failure
|
||||
// (lock, disk, native error) would leave stale rows that the subsequent
|
||||
// re-extract then DUPLICATES (CodeRelation has no PK), so it must ABORT
|
||||
// the writeback (#2084 review P2-5): re-throw so the caller's crash-
|
||||
// recovery dirty flag forces a clean full rebuild on the next run, rather
|
||||
// than silently writing duplicate rows. The benign-vs-rethrow branch is
|
||||
// pure, extracted, and pinned by unit tests: `classifyDeleteAllError`
|
||||
// (lbug-config.ts, test/unit/lbug-delete-all-error.test.ts).
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (classifyDeleteAllError(err) === 'benign-missing-table') {
|
||||
if (countResult) await closeQueryResults(countResult);
|
||||
return { edgesDeleted };
|
||||
}
|
||||
if (countResult) await closeQueryResults(countResult);
|
||||
throw new Error(
|
||||
`[${logTag}] failed to clear existing ${relType} edges before incremental ` +
|
||||
`re-write (${msg}) — aborting to avoid ${duplicateNoun}; ` +
|
||||
`the next run will full-rebuild`,
|
||||
);
|
||||
}
|
||||
if (countResult) await closeQueryResults(countResult);
|
||||
return { edgesDeleted };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop every interprocedural `TAINT_PATH` relationship (#2084 M4 U6). Used at
|
||||
* the start of an incremental `--pdg` writeback so the `taintSummaries` phase
|
||||
|
|
@ -2162,53 +2229,12 @@ export const deleteAllCommunitiesAndProcesses = async (): Promise<{
|
|||
* run. Relationship-level (TAINT_PATH is an edge type, not a node label), so a
|
||||
* plain DELETE on the typed CodeRelation rows — endpoints are untouched.
|
||||
*/
|
||||
export const deleteAllInterprocTaintPaths = async (): Promise<{ edgesDeleted: number }> => {
|
||||
const c = conn;
|
||||
if (!c) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
}
|
||||
// count + DELETE run as one critical section on the singleton connection so a
|
||||
// concurrent WAL-checkpoint cannot corrupt native state mid-delete (#pdg).
|
||||
return withConnLock(async () => {
|
||||
let edgesDeleted = 0;
|
||||
let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined;
|
||||
try {
|
||||
countResult = await c.query(
|
||||
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'TAINT_PATH' RETURN count(r) 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 ()-[r:CodeRelation]->() WHERE r.type = 'TAINT_PATH' DELETE r`),
|
||||
);
|
||||
edgesDeleted = count;
|
||||
}
|
||||
} catch (err) {
|
||||
// A missing table on a freshly-initialized DB is the benign, expected case
|
||||
// (the count query above is what throws) — stay silent. Any OTHER failure
|
||||
// (lock, disk, native error) would leave stale TAINT_PATH rows that the
|
||||
// subsequent re-extract then DUPLICATES (CodeRelation has no PK), so it
|
||||
// must ABORT the writeback (#2084 review P2-5): re-throw so the caller's
|
||||
// crash-recovery dirty flag forces a clean full rebuild on the next run,
|
||||
// rather than silently writing duplicate cross-function findings.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/no table|not exist|not found|does not exist|Table .* does not exist/i.test(msg)) {
|
||||
if (countResult) await closeQueryResults(countResult);
|
||||
return { edgesDeleted };
|
||||
}
|
||||
if (countResult) await closeQueryResults(countResult);
|
||||
throw new Error(
|
||||
`[taint-interproc] failed to clear existing TAINT_PATH edges before incremental ` +
|
||||
`re-write (${msg}) — aborting to avoid duplicate cross-function findings; ` +
|
||||
`the next run will full-rebuild`,
|
||||
);
|
||||
}
|
||||
if (countResult) await closeQueryResults(countResult);
|
||||
return { edgesDeleted };
|
||||
});
|
||||
};
|
||||
export const deleteAllInterprocTaintPaths = async (): Promise<{ edgesDeleted: number }> =>
|
||||
deleteAllRelationshipsOfType(
|
||||
'TAINT_PATH',
|
||||
'taint-interproc',
|
||||
'duplicate cross-function findings',
|
||||
);
|
||||
|
||||
/**
|
||||
* Drop every `CALL_SUMMARY` relationship (PDG FU-C, U-C3). Used at the start of
|
||||
|
|
@ -2221,51 +2247,27 @@ export const deleteAllInterprocTaintPaths = async (): Promise<{ edgesDeleted: nu
|
|||
* from the fresh graph (`isGraphWideRelType`), so delete-all-then-rebuild keeps
|
||||
* an unchanged function's summary from being lost.
|
||||
*/
|
||||
export const deleteAllCallSummaries = async (): Promise<{ edgesDeleted: number }> => {
|
||||
const c = conn;
|
||||
if (!c) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
}
|
||||
// count + DELETE run as one critical section on the singleton connection so a
|
||||
// concurrent WAL-checkpoint cannot corrupt native state mid-delete (#pdg).
|
||||
return withConnLock(async () => {
|
||||
let edgesDeleted = 0;
|
||||
let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined;
|
||||
try {
|
||||
countResult = await c.query(
|
||||
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'CALL_SUMMARY' RETURN count(r) 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 ()-[r:CodeRelation]->() WHERE r.type = 'CALL_SUMMARY' DELETE r`),
|
||||
);
|
||||
edgesDeleted = count;
|
||||
}
|
||||
} catch (err) {
|
||||
// A missing table on a freshly-initialized DB is the benign, expected case
|
||||
// (the count query is what throws) — stay silent. Any OTHER failure would
|
||||
// leave stale rows that the re-extract then DUPLICATES (CodeRelation has no
|
||||
// PK), so it must ABORT the writeback: re-throw so the caller's crash-
|
||||
// recovery dirty flag forces a clean full rebuild on the next run.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/no table|not exist|not found|does not exist|Table .* does not exist/i.test(msg)) {
|
||||
if (countResult) await closeQueryResults(countResult);
|
||||
return { edgesDeleted };
|
||||
}
|
||||
if (countResult) await closeQueryResults(countResult);
|
||||
throw new Error(
|
||||
`[call-summary] failed to clear existing CALL_SUMMARY edges before incremental ` +
|
||||
`re-write (${msg}) — aborting to avoid duplicate summaries; ` +
|
||||
`the next run will full-rebuild`,
|
||||
);
|
||||
}
|
||||
if (countResult) await closeQueryResults(countResult);
|
||||
return { edgesDeleted };
|
||||
});
|
||||
};
|
||||
export const deleteAllCallSummaries = async (): Promise<{ edgesDeleted: number }> =>
|
||||
deleteAllRelationshipsOfType('CALL_SUMMARY', 'call-summary', 'duplicate summaries');
|
||||
|
||||
/**
|
||||
* Drop every `INJECTS` relationship (DI collection injection, #2200). Used at
|
||||
* the start of an incremental writeback — UNCONDITIONALLY, unlike the
|
||||
* pdg-gated twins above, because the `di` phase runs on every persisting
|
||||
* analyze — so the phase re-materialises them from scratch on the FULL
|
||||
* recomputed graph.
|
||||
*
|
||||
* Mirrors {@link deleteAllInterprocTaintPaths}: INJECTS validity is a
|
||||
* whole-program property (a change to the interface, or a new/removed
|
||||
* implementer, on a THIRD file creates/invalidates edges between two
|
||||
* untouched files), so endpoint-writability extraction can't refresh them.
|
||||
* `extractChangedSubgraph` re-includes ALL of them from the fresh graph
|
||||
* (`isGraphWideRelType`), so delete-all-then-rebuild is the sound move.
|
||||
* Relationship-level (INJECTS is an edge type, not a node label), so a plain
|
||||
* DELETE on the typed CodeRelation rows — endpoints are untouched.
|
||||
*/
|
||||
export const deleteAllInjects = async (): Promise<{ edgesDeleted: number }> =>
|
||||
deleteAllRelationshipsOfType('INJECTS', 'di', 'duplicate INJECTS edges');
|
||||
|
||||
// ============================================================================
|
||||
// Full-Text Search (FTS) Functions
|
||||
|
|
|
|||
|
|
@ -403,6 +403,34 @@ export const isDbBusyError = (err: unknown): boolean => {
|
|||
);
|
||||
};
|
||||
|
||||
/** See {@link classifyDeleteAllError}. */
|
||||
export type DeleteAllErrorClass = 'benign-missing-table' | 'rethrow';
|
||||
|
||||
/**
|
||||
* Classify an error thrown while clearing all relationships of one type
|
||||
* before an incremental re-write (`deleteAllRelationshipsOfType` in
|
||||
* `lbug-adapter.ts` — the `deleteAllInjects` / `deleteAllCallSummaries` /
|
||||
* `deleteAllInterprocTaintPaths` family).
|
||||
*
|
||||
* - `'benign-missing-table'`: the CodeRelation table does not exist yet
|
||||
* (freshly-initialized DB) — the delete-all is a no-op, stay silent.
|
||||
* - `'rethrow'`: ANY other failure (lock, disk, closed connection, native
|
||||
* error) leaves stale rows that the subsequent re-extract then DUPLICATES
|
||||
* (CodeRelation has no PK), so the caller must abort the writeback
|
||||
* (#2084 review P2-5).
|
||||
*
|
||||
* Pure classification, extracted here (next to the other error matchers) so
|
||||
* the load-bearing regex/branch is unit-testable without a native DB —
|
||||
* driving a synthetic failure through the real singleton connection would
|
||||
* break every later test in the shared integration suite (#2200 review).
|
||||
*/
|
||||
export const classifyDeleteAllError = (err: unknown): DeleteAllErrorClass => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return /no table|not exist|not found|does not exist|Table .* does not exist/i.test(msg)
|
||||
? 'benign-missing-table'
|
||||
: 'rethrow';
|
||||
};
|
||||
|
||||
export function createLbugDatabase(
|
||||
lbugModule: LbugModule,
|
||||
databasePath: string,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
deleteAllCommunitiesAndProcesses,
|
||||
deleteAllInterprocTaintPaths,
|
||||
deleteAllCallSummaries,
|
||||
deleteAllInjects,
|
||||
queryImporters,
|
||||
loadFTSExtension,
|
||||
} from './lbug/lbug-adapter.js';
|
||||
|
|
@ -1216,6 +1217,19 @@ export async function runFullAnalysis(
|
|||
// from the fresh pipeline output below. Required for the
|
||||
// "Leiden runs on the FULL graph" correctness invariant.
|
||||
await deleteAllCommunitiesAndProcesses();
|
||||
// 2a. Drop INJECTS edges (DI collection injection, #2200) — their
|
||||
// validity is a whole-program property (a third-file change to the
|
||||
// interface or an implementer creates/invalidates edges between two
|
||||
// untouched files), so endpoint-writability extraction can't refresh
|
||||
// them; extractChangedSubgraph re-includes all of them from the
|
||||
// fresh graph (isGraphWideRelType). UNCONDITIONAL, next to the
|
||||
// Communities delete — NOT inside the `options.pdg` block below: the
|
||||
// di phase runs on every persisting analyze (same !skipGraphPhases
|
||||
// regime as communities/processes) while the graph-wide re-include
|
||||
// is unconditional, so a pdg-gated delete would append without
|
||||
// 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 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
|
||||
|
|
|
|||
|
|
@ -221,6 +221,14 @@ export const VALID_RELATION_TYPES = new Set([
|
|||
'HANDLES_TOOL',
|
||||
'ENTRY_POINT_OF',
|
||||
'WRAPS',
|
||||
// Emitted by the `di` pipeline phase (#2200 — DI collection injection,
|
||||
// consumer Class → implementer Class). Valid here for explicit
|
||||
// `relationTypes` filters, but deliberately NOT in the default impact()
|
||||
// relTypes nor the context() incoming/outgoing lists — traversal is opt-in,
|
||||
// like WRAPS/FETCHES. Also deliberately NO IMPACT_RELATION_CONFIDENCE entry
|
||||
// (WRAPS/FETCHES precedent): the 0.5 unknown-type floor applies there,
|
||||
// and the edges carry their own confidence (0.8) in the graph.
|
||||
'INJECTS',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { REL_TYPES } from 'gitnexus-shared';
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
|
|
@ -207,7 +208,7 @@ SCHEMA:
|
|||
- Nodes: File, Folder, Function, Class, Interface, Method, CodeElement, Community, Process, Route, Tool
|
||||
- Multi-language nodes (use backticks): \`Struct\`, \`Enum\`, \`Trait\`, \`Impl\`, etc.
|
||||
- All edges via single CodeRelation table with 'type' property
|
||||
- Edge types: 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
|
||||
- Edge types: ${REL_TYPES.join(', ')} — CFG, REACHING_DEF, TAINTED, SANITIZES, TAINT_PATH, CDG, POST_DOMINATE are populated ONLY on indexes built with \`gitnexus analyze --pdg\` (zero rows on a default index); OVERRIDES is a legacy alias — rows are written as METHOD_OVERRIDES
|
||||
- Edge properties: type (STRING), confidence (DOUBLE), reason (STRING), step (INT32)
|
||||
|
||||
EXAMPLES:
|
||||
|
|
@ -232,6 +233,9 @@ 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
|
||||
|
||||
• 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
|
||||
|
||||
|
|
@ -512,7 +516,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)',
|
||||
'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.',
|
||||
},
|
||||
includeTests: { type: 'boolean', description: 'Include test files (default: false)' },
|
||||
minConfidence: {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ 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.
|
||||
const SCHEMA_BUMP = 9; // #2312: ParseWorkerResult gained `routerConstructorPrefixes` for FastAPI APIRouter(prefix=...) replay
|
||||
const SCHEMA_BUMP = 10; // PR #2200: Property nodes gained `rawDeclaredType` + `annotations` (Spring DI); warm caches must invalidate or the DI phase silently no-ops on replayed pre-upgrade nodes
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
|
|
@ -128,6 +128,47 @@ withTestLbugDB(
|
|||
expect(Number((left[0] as { cnt: number }).cnt)).toBe(0);
|
||||
});
|
||||
|
||||
it('deleteAllInjects: removes only INJECTS edges and is benign when none exist (#2200)', async () => {
|
||||
// Mirrors the deleteAllInterprocTaintPaths test above (same contract:
|
||||
// COUNT-then-DELETE, missing-table carve-out, re-throw otherwise).
|
||||
// The re-throw path is not simulated here — doing so would require
|
||||
// breaking the shared singleton connection mid-suite. Its benign-vs-
|
||||
// rethrow classification is pinned as a pure function instead:
|
||||
// `classifyDeleteAllError` (lbug-config.ts), exhaustively covered in
|
||||
// test/unit/lbug-delete-all-error.test.ts.
|
||||
const { executeQuery: coreExecuteQuery, deleteAllInjects } =
|
||||
await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// Benign: no INJECTS rows yet → returns 0, does NOT throw.
|
||||
await expect(deleteAllInjects()).resolves.toEqual({ edgesDeleted: 0 });
|
||||
|
||||
// Seed one INJECTS edge plus one edge of ANOTHER type between the two
|
||||
// seeded Function nodes, then delete-all and confirm exactly the
|
||||
// INJECTS row is removed while the other-typed row survives.
|
||||
const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as {
|
||||
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: 'INJECTS', confidence: 0.8, reason: 'di', step: 0}]->(b)`,
|
||||
);
|
||||
await coreExecuteQuery(
|
||||
`MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
|
||||
`CREATE (a)-[:CodeRelation {type: 'QUERIES', confidence: 0.8, reason: 'orm', step: 0}]->(b)`,
|
||||
);
|
||||
const r2 = await deleteAllInjects();
|
||||
expect(r2.edgesDeleted).toBe(1);
|
||||
const injectsLeft = await coreExecuteQuery(
|
||||
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'INJECTS' RETURN count(r) AS cnt`,
|
||||
);
|
||||
expect(Number((injectsLeft[0] as { cnt: number }).cnt)).toBe(0);
|
||||
const queriesLeft = await coreExecuteQuery(
|
||||
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'QUERIES' RETURN count(r) AS cnt`,
|
||||
);
|
||||
expect(Number((queriesLeft[0] as { cnt: number }).cnt)).toBe(1);
|
||||
});
|
||||
|
||||
describe('unhappy path', () => {
|
||||
it('throws on malformed Cypher query', async () => {
|
||||
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
|
|
|||
142
gitnexus/test/integration/spring-di-pipeline.test.ts
Normal file
142
gitnexus/test/integration/spring-di-pipeline.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* End-to-end pipeline coverage for Spring DI collection injection (#2200).
|
||||
* Real Java sources run through the ACTUAL pipeline (parse worker → field
|
||||
* extraction → heritage → `di` phase): an `@Autowired List<IFoo>` field must
|
||||
* yield a Property node carrying the extraction contract
|
||||
* (`declaredType`/`rawDeclaredType`/`annotations`) and exactly one INJECTS
|
||||
* edge per implementer of `IFoo` — while a non-annotated collection field of
|
||||
* the very same type contributes nothing. Both prior no-op incarnations of
|
||||
* this feature (stripped `declaredType` only; no annotation gate) fail here.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import type { PipelineResult } from '../../src/types/pipeline.js';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
|
||||
const IFOO = `package com.example;
|
||||
|
||||
public interface IFoo {}
|
||||
`;
|
||||
|
||||
const FOO_A = `package com.example;
|
||||
|
||||
public class FooA implements IFoo {}
|
||||
`;
|
||||
|
||||
const FOO_B = `package com.example;
|
||||
|
||||
public class FooB implements IFoo {}
|
||||
`;
|
||||
|
||||
const CONSUMER = `package com.example;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
public class Consumer {
|
||||
@Autowired private List<IFoo> foos;
|
||||
private List<IFoo> plain;
|
||||
}
|
||||
`;
|
||||
|
||||
/** A consumer whose collection fields carry NO injection annotation. */
|
||||
const PLAIN_CONSUMER = `package com.example;
|
||||
import java.util.List;
|
||||
|
||||
public class PlainConsumer {
|
||||
private List<IFoo> plain;
|
||||
private List<IFoo> cache;
|
||||
}
|
||||
`;
|
||||
|
||||
function findProperty(result: PipelineResult, name: string): GraphNode | undefined {
|
||||
let found: GraphNode | undefined;
|
||||
result.graph.forEachNode((n) => {
|
||||
if (n.label === 'Property' && n.properties.name === name) found = n;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/** All INJECTS edges as sorted `sourceName->targetName` pairs (set-equality food). */
|
||||
function injectsPairs(result: PipelineResult): string[] {
|
||||
const nameById = new Map<string, string>();
|
||||
result.graph.forEachNode((n) => nameById.set(n.id, String(n.properties.name)));
|
||||
return result.graph.relationships
|
||||
.filter((r) => r.type === 'INJECTS')
|
||||
.map((r) => `${nameById.get(r.sourceId)}->${nameById.get(r.targetId)}`)
|
||||
.sort();
|
||||
}
|
||||
|
||||
describe('Spring DI collection-injection pipeline (#2200)', () => {
|
||||
let dir: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-di-'));
|
||||
fs.writeFileSync(path.join(dir, 'IFoo.java'), IFOO);
|
||||
fs.writeFileSync(path.join(dir, 'FooA.java'), FOO_A);
|
||||
fs.writeFileSync(path.join(dir, 'FooB.java'), FOO_B);
|
||||
fs.writeFileSync(path.join(dir, 'Consumer.java'), CONSUMER);
|
||||
result = await runPipelineFromRepo(dir, () => {}, {});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('extracts the annotated field with the full Property contract (declaredType / rawDeclaredType / annotations)', () => {
|
||||
// THE extraction pin: both no-op incarnations broke exactly here — the
|
||||
// graph never carried a matchable generic type or the gating annotation.
|
||||
const foos = findProperty(result, 'foos');
|
||||
expect(foos, 'Consumer.foos should be a Property node').toBeTruthy();
|
||||
expect(foos!.properties).toMatchObject({
|
||||
declaredType: 'List',
|
||||
rawDeclaredType: 'List<IFoo>',
|
||||
});
|
||||
expect(foos!.properties.annotations).toContain('@Autowired');
|
||||
});
|
||||
|
||||
it('extracts the non-annotated field with the same type contract but NO annotations key', () => {
|
||||
const plain = findProperty(result, 'plain');
|
||||
expect(plain, 'Consumer.plain should be a Property node').toBeTruthy();
|
||||
expect(plain!.properties).toMatchObject({
|
||||
declaredType: 'List',
|
||||
rawDeclaredType: 'List<IFoo>',
|
||||
});
|
||||
// Empty annotation lists are OMITTED (production conditional-spread shape).
|
||||
expect(plain!.properties.annotations).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits exactly the two Consumer→implementer INJECTS edges — nothing from `plain`, no self-edges', () => {
|
||||
// Full set-equality on ALL INJECTS edges in the graph: an extra edge
|
||||
// (e.g. one fanned out from the non-annotated `plain` field, or a
|
||||
// self-edge) fails this, as does a missing implementer.
|
||||
expect(injectsPairs(result)).toEqual(['Consumer->FooA', 'Consumer->FooB']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spring DI pipeline negative control: no injection annotations anywhere (#2200)', () => {
|
||||
let dir: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-di-neg-'));
|
||||
fs.writeFileSync(path.join(dir, 'IFoo.java'), IFOO);
|
||||
fs.writeFileSync(path.join(dir, 'FooA.java'), FOO_A);
|
||||
fs.writeFileSync(path.join(dir, 'FooB.java'), FOO_B);
|
||||
fs.writeFileSync(path.join(dir, 'PlainConsumer.java'), PLAIN_CONSUMER);
|
||||
result = await runPipelineFromRepo(dir, () => {}, {});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('emits zero INJECTS edges when no field carries an injection annotation', () => {
|
||||
// The interface + implementers exist, so fan-out WOULD fire if the
|
||||
// annotation gate regressed — the pre-U2 false-positive class.
|
||||
expect(injectsPairs(result)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,7 +7,7 @@ import { goConfig } from '../../src/core/ingestion/field-extractors/configs/go.j
|
|||
import { cppConfig } from '../../src/core/ingestion/field-extractors/configs/c-cpp.js';
|
||||
import { rubyConfig } from '../../src/core/ingestion/field-extractors/configs/ruby.js';
|
||||
import { dartConfig } from '../../src/core/ingestion/field-extractors/configs/dart.js';
|
||||
import { kotlinConfig } from '../../src/core/ingestion/field-extractors/configs/jvm.js';
|
||||
import { javaConfig, kotlinConfig } from '../../src/core/ingestion/field-extractors/configs/jvm.js';
|
||||
import { swiftConfig } from '../../src/core/ingestion/field-extractors/configs/swift.js';
|
||||
import type { FieldExtractorContext } from '../../src/core/ingestion/field-types.js';
|
||||
import type { TypeEnvironment } from '../../src/core/ingestion/type-env.js';
|
||||
|
|
@ -18,6 +18,7 @@ import Python from 'tree-sitter-python';
|
|||
import Go from 'tree-sitter-go';
|
||||
import Cpp from 'tree-sitter-cpp';
|
||||
import Ruby from 'tree-sitter-ruby';
|
||||
import Java from 'tree-sitter-java';
|
||||
import CSharp from 'tree-sitter-c-sharp';
|
||||
import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.js';
|
||||
|
||||
|
|
@ -1182,6 +1183,135 @@ describe('GenericFieldExtractor — Dart', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Java config — rawDeclaredType: verbatim generic type text (PR #2200 U1)
|
||||
// and annotations: '@Name' strings from the modifiers child (PR #2200 U2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GenericFieldExtractor — Java (rawDeclaredType + annotations)', () => {
|
||||
const parser = new Parser();
|
||||
const extractor = createFieldExtractor(javaConfig);
|
||||
const mockContext = createMockContext();
|
||||
mockContext.language = SupportedLanguages.Java;
|
||||
mockContext.filePath = 'Test.java';
|
||||
|
||||
/** Parse `src` and return the first class_declaration node. */
|
||||
function classNode(src: string) {
|
||||
parser.setLanguage(Java);
|
||||
const tree = parser.parse(src);
|
||||
const node = tree.rootNode.child(0);
|
||||
if (!node) throw new Error('no class node');
|
||||
return node;
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
field: 'private List<Shape> shapes;',
|
||||
name: 'shapes',
|
||||
type: 'List',
|
||||
rawDeclaredType: 'List<Shape>',
|
||||
},
|
||||
{
|
||||
field: 'private Set<T> items;',
|
||||
name: 'items',
|
||||
type: 'Set',
|
||||
rawDeclaredType: 'Set<T>',
|
||||
},
|
||||
{
|
||||
field: 'private Map<String, Foo> byName;',
|
||||
name: 'byName',
|
||||
type: 'Map',
|
||||
rawDeclaredType: 'Map<String, Foo>',
|
||||
},
|
||||
{
|
||||
// Non-generic field: rawDeclaredType is PRESENT and equals the type text.
|
||||
field: 'private String name;',
|
||||
name: 'name',
|
||||
type: 'String',
|
||||
rawDeclaredType: 'String',
|
||||
},
|
||||
{
|
||||
// Qualified generic: raw text preserved verbatim; simple name still last segment.
|
||||
field: 'private java.util.List<Shape> shapes;',
|
||||
name: 'shapes',
|
||||
type: 'List',
|
||||
rawDeclaredType: 'java.util.List<Shape>',
|
||||
},
|
||||
])(
|
||||
'extracts type "$type" and rawDeclaredType "$rawDeclaredType" from `$field`',
|
||||
({ field, name, type, rawDeclaredType }) => {
|
||||
const result = extractor.extract(classNode(`class C { ${field} }`), mockContext);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fields).toHaveLength(1);
|
||||
expect(result!.fields[0]).toMatchObject({ name, type, rawDeclaredType });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
// marker_annotation node type (no arguments).
|
||||
field: '@Autowired private List<Shape> shapes;',
|
||||
annotations: ['@Autowired'],
|
||||
},
|
||||
{
|
||||
// `annotation` node type (with arguments), not `marker_annotation` —
|
||||
// the name comes from the annotation's `name` field.
|
||||
field: '@Autowired(required=false) private List<Shape> shapes;',
|
||||
annotations: ['@Autowired'],
|
||||
},
|
||||
{
|
||||
// Multiple annotations on one field — all are collected, in order.
|
||||
field: '@Nullable @Autowired @Qualifier("shapeBeans") private List<Shape> shapes;',
|
||||
annotations: ['@Nullable', '@Autowired', '@Qualifier'],
|
||||
},
|
||||
])('extracts annotations $annotations from `$field`', ({ field, annotations }) => {
|
||||
const result = extractor.extract(classNode(`class C { ${field} }`), mockContext);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fields).toHaveLength(1);
|
||||
expect(result!.fields[0]).toMatchObject({ name: 'shapes', annotations });
|
||||
});
|
||||
|
||||
it('omits annotations entirely for a non-annotated field', () => {
|
||||
const result = extractor.extract(
|
||||
classNode('class C { private List<Shape> shapes; }'),
|
||||
mockContext,
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fields).toHaveLength(1);
|
||||
expect(result!.fields[0]).not.toHaveProperty('annotations');
|
||||
});
|
||||
|
||||
it('still extracts the field when extractRawType/extractAnnotations throw (per-hook isolation)', () => {
|
||||
// A throwing hook must degrade to a field WITHOUT raw/annotations — never
|
||||
// escape buildField: an escaped throw reaches the language-group catch
|
||||
// upstream (processFileGroup) and silently drops every remaining file in
|
||||
// the group (#2286-review guard pattern).
|
||||
const throwingExtractor = createFieldExtractor({
|
||||
...javaConfig,
|
||||
extractRawType: () => {
|
||||
throw new Error('unexpected node shape');
|
||||
},
|
||||
extractAnnotations: () => {
|
||||
throw new Error('unexpected node shape');
|
||||
},
|
||||
});
|
||||
|
||||
const result = throwingExtractor.extract(
|
||||
classNode('class C { @Autowired private List<Shape> shapes; }'),
|
||||
mockContext,
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fields).toHaveLength(1);
|
||||
expect(result!.fields[0]).toMatchObject({ name: 'shapes', type: 'List' });
|
||||
expect(result!.fields[0]).not.toHaveProperty('rawDeclaredType');
|
||||
expect(result!.fields[0]).not.toHaveProperty('annotations');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kotlin config — F52: companion-object properties indexed as fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
* (Windows LadybugDB handle release can lag; `cleanupTempDir` retries).
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { writeFile, readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
|
|
@ -34,6 +35,56 @@ import { setupMiniRepo as setupSharedMiniRepo } from '../helpers/mini-repo.js';
|
|||
|
||||
const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-orch-');
|
||||
|
||||
/** Stage + commit everything in the temp repo (mirrors mini-repo.ts's git calls). */
|
||||
const gitCommitAll = (cwd: string, message: string): void => {
|
||||
execSync('git -c user.name=test -c user.email=t@t -c commit.gpgsign=false add -A', {
|
||||
cwd,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
execSync(
|
||||
`git -c user.name=test -c user.email=t@t -c commit.gpgsign=false commit -q -m "${message}"`,
|
||||
{ cwd, stdio: 'pipe' },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Direct count over INJECTS CodeRelation rows — mirrors pdg-mode-flip's
|
||||
* countBasicBlocks: reopen the repo DB, count, close (runFullAnalysis closes
|
||||
* the singleton on completion, so each count owns its own open/close).
|
||||
*/
|
||||
async function countInjects(repoPath: string): Promise<number> {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const { lbugPath } = getStoragePaths(repoPath);
|
||||
await adapter.initLbug(lbugPath);
|
||||
try {
|
||||
const rows = (await adapter.executeQuery(
|
||||
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'INJECTS' RETURN count(r) AS c`,
|
||||
)) as Array<{ c: number | bigint }>;
|
||||
return Number(rows[0]?.c ?? 0);
|
||||
} finally {
|
||||
await adapter.closeLbug();
|
||||
}
|
||||
}
|
||||
|
||||
/** Java DI fixture (#2200): `@Autowired List<IFoo>` + 2 implementers ⇒ exactly
|
||||
* 2 INJECTS edges (Consumer→FooA, Consumer→FooB). Same shapes as the
|
||||
* spring-di-pipeline integration fixture. */
|
||||
const JAVA_DI_FIXTURE: ReadonlyArray<readonly [string, string]> = [
|
||||
['IFoo.java', 'package com.example;\n\npublic interface IFoo {}\n'],
|
||||
['FooA.java', 'package com.example;\n\npublic class FooA implements IFoo {}\n'],
|
||||
['FooB.java', 'package com.example;\n\npublic class FooB implements IFoo {}\n'],
|
||||
[
|
||||
'Consumer.java',
|
||||
'package com.example;\n' +
|
||||
'import java.util.List;\n' +
|
||||
'import org.springframework.beans.factory.annotation.Autowired;\n' +
|
||||
'\n' +
|
||||
'public class Consumer {\n' +
|
||||
' @Autowired private List<IFoo> foos;\n' +
|
||||
'}\n',
|
||||
],
|
||||
];
|
||||
|
||||
describe('runFullAnalysis — incremental orchestration', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
|
|
@ -330,4 +381,57 @@ describe('runFullAnalysis — incremental orchestration', () => {
|
|||
await repo.cleanup();
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
// U7 (#2200): the INJECTS delete-before-writeback must be UNCONDITIONAL.
|
||||
// extractChangedSubgraph re-includes ALL INJECTS edges from the fresh graph
|
||||
// on every incremental run (isGraphWideRelType), and CodeRelation has no PK
|
||||
// and no read-side dedup — so a pdg-gated delete (literal TAINT_PATH
|
||||
// mirroring) would append without deleting on every non-pdg incremental
|
||||
// run: N runs = N copies of every INJECTS row. This test is the assertion
|
||||
// that catches exactly that mistake.
|
||||
it('incremental runs neither strand nor duplicate INJECTS edges (delete-all is not pdg-gated) (#2200)', async () => {
|
||||
const repo = await setupMiniRepo();
|
||||
try {
|
||||
const src = path.join(repo.dbPath, 'src');
|
||||
for (const [name, content] of JAVA_DI_FIXTURE) {
|
||||
await writeFile(path.join(src, name), content, 'utf-8');
|
||||
}
|
||||
gitCommitAll(repo.dbPath, 'add java di fixture');
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
|
||||
// Full index: Consumer.foos fans out to the two IFoo implementers.
|
||||
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
||||
expect(await countInjects(repo.dbPath)).toBe(2);
|
||||
|
||||
// Incremental run 1: comment-only touch of an UNRELATED file (none of
|
||||
// the Java DI files change), committed so lastCommit moves.
|
||||
const target = path.join(src, 'logger.ts');
|
||||
const beforeFirstTouch = await readFile(target, 'utf-8');
|
||||
await writeFile(target, beforeFirstTouch + '\n// di idempotency touch 1\n', 'utf-8');
|
||||
gitCommitAll(repo.dbPath, 'unrelated touch 1');
|
||||
const run1 = await runFullAnalysis(
|
||||
repo.dbPath,
|
||||
{ skipAgentsMd: true },
|
||||
{ onProgress: () => {} },
|
||||
);
|
||||
expect(run1.alreadyUpToDate).toBeUndefined();
|
||||
expect(await countInjects(repo.dbPath)).toBe(2);
|
||||
|
||||
// Incremental run 2: second unrelated touch. A gated delete would have
|
||||
// appended two more rows per writeback (4 by now) — must still be 2.
|
||||
const beforeSecondTouch = await readFile(target, 'utf-8');
|
||||
await writeFile(target, beforeSecondTouch + '\n// di idempotency touch 2\n', 'utf-8');
|
||||
gitCommitAll(repo.dbPath, 'unrelated touch 2');
|
||||
const run2 = await runFullAnalysis(
|
||||
repo.dbPath,
|
||||
{ skipAgentsMd: true },
|
||||
{ onProgress: () => {} },
|
||||
);
|
||||
expect(run2.alreadyUpToDate).toBeUndefined();
|
||||
expect(await countInjects(repo.dbPath)).toBe(2);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
}, 600_000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -111,6 +111,25 @@ describe('extractChangedSubgraph', () => {
|
|||
|
||||
expect(sub.relationships.map((r) => r.id)).toEqual(['tp1']);
|
||||
});
|
||||
|
||||
it('always includes INJECTS edges even between two unchanged files (#2200)', () => {
|
||||
// A DI consumer→implementer INJECTS edge whose endpoints (consumer.java,
|
||||
// impl.java) are both unchanged, but the interface (or a sibling
|
||||
// implementer) on the changed third.java altered the fan-out.
|
||||
// Endpoint-writability alone would strand the stale edge; INJECTS is
|
||||
// graph-wide so it is always re-extracted (the orchestrator
|
||||
// unconditionally delete-alls the old rows first). A plain CALLS edge
|
||||
// between the same unchanged files stays excluded.
|
||||
const g = createKnowledgeGraph();
|
||||
g.addNode(makeFileNode('consumer:Class', '/repo/consumer.java'));
|
||||
g.addNode(makeFileNode('impl:Class', '/repo/impl.java'));
|
||||
g.addRelationship(makeRel('inj1', 'consumer:Class', 'impl:Class', 'INJECTS'));
|
||||
g.addRelationship(makeRel('call1', 'consumer:Class', 'impl:Class', 'CALLS'));
|
||||
|
||||
const sub = extractChangedSubgraph(g, new Set(['/repo/third.java']));
|
||||
|
||||
expect(sub.relationships.map((r) => r.id)).toEqual(['inj1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeEffectiveWriteSet (Finding 1)', () => {
|
||||
|
|
|
|||
921
gitnexus/test/unit/ingestion/di.test.ts
Normal file
921
gitnexus/test/unit/ingestion/di.test.ts
Normal file
|
|
@ -0,0 +1,921 @@
|
|||
/**
|
||||
* Unit tests for the framework-neutral `di` pipeline phase and the Spring
|
||||
* DI field matcher registered behind it (`di-extractors/spring.ts`).
|
||||
*
|
||||
* Phase-level: verifies that injection-annotated (@Autowired / @Inject)
|
||||
* collection-typed fields (List<T>, Set<T>, Collection<T>, Map<K,T>) produce
|
||||
* INJECTS edges from the consumer class to every class implementing
|
||||
* interface T — using only graph data, no filesystem access — and that
|
||||
* Property nodes whose language has no registered matcher are skipped.
|
||||
* Non-annotated and @Resource fields produce no edges.
|
||||
*
|
||||
* Matcher-level: pins `springDiFieldMatcher`'s gate + parse behavior
|
||||
* directly, node-shape in / match-or-null out.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
|
||||
import { diPhase } from '../../../src/core/ingestion/pipeline-phases/di.js';
|
||||
import {
|
||||
parseSpringCollectionType,
|
||||
springDiFieldMatcher,
|
||||
} from '../../../src/core/ingestion/di-extractors/spring.js';
|
||||
import { generateId } from '../../../src/lib/utils.js';
|
||||
import type {
|
||||
PhaseResult,
|
||||
PipelineContext,
|
||||
} from '../../../src/core/ingestion/pipeline-phases/types.js';
|
||||
import type { KnowledgeGraph } from '../../../src/core/graph/types.js';
|
||||
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeCtx(graph: KnowledgeGraph, repoPath = '/tmp/repo'): PipelineContext {
|
||||
return { repoPath, graph, onProgress: () => {}, pipelineStart: 0 };
|
||||
}
|
||||
|
||||
function phaseResult<T>(phaseName: string, output: T): PhaseResult<T> {
|
||||
return { phaseName, output, durationMs: 0 };
|
||||
}
|
||||
|
||||
function addClass(
|
||||
graph: KnowledgeGraph,
|
||||
name: string,
|
||||
language: string,
|
||||
label: NodeLabel = 'Class',
|
||||
extra: Record<string, unknown> = {},
|
||||
): string {
|
||||
const id = generateId(label, name);
|
||||
graph.addNode({
|
||||
id,
|
||||
label,
|
||||
properties: { name, filePath: `src/${name}.${language}`, language, ...extra },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an Interface node. `qualifiedName` mirrors the production shape for
|
||||
* languages with a file-scope package declaration (e.g. Java's
|
||||
* `com.a.Shape`); when omitted the node carries only the simple `name`, like
|
||||
* production interfaces without a package qualifier.
|
||||
*
|
||||
* The node id is keyed by `language` + the most qualified identity available
|
||||
* (production ids embed file path + qualified name), so two same-simple-name
|
||||
* interfaces — cross-package or cross-language — are distinct graph nodes,
|
||||
* not a silent `addNode` no-op on a duplicate id.
|
||||
*/
|
||||
function addInterface(
|
||||
graph: KnowledgeGraph,
|
||||
name: string,
|
||||
language = 'java',
|
||||
qualifiedName?: string,
|
||||
): string {
|
||||
const id = generateId('Interface', `${language}:${qualifiedName ?? name}`);
|
||||
graph.addNode({
|
||||
id,
|
||||
label: 'Interface',
|
||||
properties: {
|
||||
name,
|
||||
filePath: `src/${name}.${language}`,
|
||||
language,
|
||||
...(qualifiedName !== undefined ? { qualifiedName } : {}),
|
||||
},
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link `className` IMPLEMENTS the interface added via `addInterface` with the
|
||||
* same (`ifaceName`, `ifaceLanguage`, `ifaceQualifiedName`) identity.
|
||||
*/
|
||||
function addImplements(
|
||||
graph: KnowledgeGraph,
|
||||
className: string,
|
||||
ifaceName: string,
|
||||
ifaceLanguage = 'java',
|
||||
ifaceQualifiedName?: string,
|
||||
): void {
|
||||
const classId = generateId('Class', className);
|
||||
const ifaceId = generateId('Interface', `${ifaceLanguage}:${ifaceQualifiedName ?? ifaceName}`);
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', `${classId}->${ifaceId}`),
|
||||
sourceId: classId,
|
||||
targetId: ifaceId,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Property node (a field) to a class and link it via HAS_PROPERTY.
|
||||
*
|
||||
* Mirrors the production extraction shape: `typeText` is the verbatim type
|
||||
* source text with generics preserved (e.g. `List<IFoo>`), stored as
|
||||
* `rawDeclaredType`, while `declaredType` is the generics-stripped simple
|
||||
* name (e.g. `List`) — derived here from the raw text. `annotations` carries
|
||||
* '@Name' strings and is OMITTED when empty (production conditional-spread
|
||||
* shape); it defaults to `['@Autowired']` so the common annotated case stays
|
||||
* terse. The phase matches on `rawDeclaredType` and gates on `annotations`.
|
||||
*
|
||||
* `rawDeclaredType` defaults to `typeText`; pass `null` to OMIT the property
|
||||
* entirely — the shape a rawDeclaredType-plumbing regression produces, where
|
||||
* only the stripped `declaredType` reaches the graph.
|
||||
*/
|
||||
function addProperty(
|
||||
graph: KnowledgeGraph,
|
||||
ownerClassName: string,
|
||||
fieldName: string,
|
||||
typeText: string,
|
||||
language = 'java',
|
||||
annotations: string[] = ['@Autowired'],
|
||||
rawDeclaredType: string | null = typeText,
|
||||
): string {
|
||||
const ownerId = generateId('Class', ownerClassName);
|
||||
const propId = generateId('Property', `${ownerClassName}.${fieldName}`);
|
||||
// Production `declaredType` is the simple name with generic args stripped.
|
||||
const declaredType = typeText.split('<')[0].trim();
|
||||
graph.addNode({
|
||||
id: propId,
|
||||
label: 'Property',
|
||||
properties: {
|
||||
name: fieldName,
|
||||
filePath: `src/${ownerClassName}.${language}`,
|
||||
language,
|
||||
declaredType,
|
||||
...(rawDeclaredType !== null ? { rawDeclaredType } : {}),
|
||||
...(annotations.length > 0 ? { annotations } : {}),
|
||||
},
|
||||
});
|
||||
graph.addRelationship({
|
||||
id: generateId('HAS_PROPERTY', `${ownerId}->${propId}`),
|
||||
sourceId: ownerId,
|
||||
targetId: propId,
|
||||
type: 'HAS_PROPERTY',
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
return propId;
|
||||
}
|
||||
|
||||
/** Collect all INJECTS relationships currently in the graph. */
|
||||
function injectsEdges(graph: KnowledgeGraph) {
|
||||
return graph.relationships.filter((r) => r.type === 'INJECTS');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('di phase', () => {
|
||||
it('creates INJECTS edges from consumer to every implementer of T', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
// Interface IFoo
|
||||
addInterface(graph, 'IFoo');
|
||||
|
||||
// Two implementers
|
||||
addClass(graph, 'FooImpl1', 'java');
|
||||
addClass(graph, 'FooImpl2', 'java');
|
||||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||||
addImplements(graph, 'FooImpl2', 'IFoo');
|
||||
|
||||
// Consumer with @Autowired List<IFoo>
|
||||
addClass(graph, 'MyService', 'java');
|
||||
addProperty(graph, 'MyService', 'foos', 'List<IFoo>');
|
||||
|
||||
const output = await diPhase.execute(
|
||||
makeCtx(graph),
|
||||
new Map([['mro', phaseResult('mro', { entries: [] })]]),
|
||||
);
|
||||
|
||||
const edges = injectsEdges(graph);
|
||||
const targets = new Set(edges.map((e) => e.targetId));
|
||||
const sources = new Set(edges.map((e) => e.sourceId));
|
||||
|
||||
// Exactly 2 edges, both from MyService
|
||||
expect(edges).toHaveLength(2);
|
||||
expect(sources.size).toBe(1);
|
||||
expect(sources.has(generateId('Class', 'MyService'))).toBe(true);
|
||||
|
||||
// Targets are the two implementers (not IFoo, not MyService)
|
||||
expect(targets.has(generateId('Class', 'FooImpl1'))).toBe(true);
|
||||
expect(targets.has(generateId('Class', 'FooImpl2'))).toBe(true);
|
||||
|
||||
// Edge metadata
|
||||
for (const edge of edges) {
|
||||
expect(edge.type).toBe('INJECTS');
|
||||
expect(edge.confidence).toBe(0.8);
|
||||
expect(edge.reason).toBe('Spring DI: @Autowired List<IFoo>');
|
||||
}
|
||||
|
||||
// Output stats
|
||||
expect(output.injectsEdges).toBe(2);
|
||||
expect(output.fieldsScanned).toBe(1);
|
||||
});
|
||||
|
||||
it('does not create self-edges when the consumer also implements T', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'IFoo');
|
||||
addClass(graph, 'FooImpl1', 'java');
|
||||
addClass(graph, 'FooImpl2', 'java');
|
||||
// MyService ALSO implements IFoo — must not inject into itself
|
||||
addClass(graph, 'MyService', 'java');
|
||||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||||
addImplements(graph, 'FooImpl2', 'IFoo');
|
||||
addImplements(graph, 'MyService', 'IFoo');
|
||||
addProperty(graph, 'MyService', 'foos', 'List<IFoo>');
|
||||
|
||||
await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
const edges = injectsEdges(graph);
|
||||
const myServiceId = generateId('Class', 'MyService');
|
||||
|
||||
// No self-edge
|
||||
expect(edges.some((e) => e.sourceId === myServiceId && e.targetId === myServiceId)).toBe(false);
|
||||
|
||||
// Still injects into the OTHER two implementers
|
||||
expect(edges).toHaveLength(2);
|
||||
const targets = new Set(edges.map((e) => e.targetId));
|
||||
expect(targets.has(generateId('Class', 'FooImpl1'))).toBe(true);
|
||||
expect(targets.has(generateId('Class', 'FooImpl2'))).toBe(true);
|
||||
});
|
||||
|
||||
it('creates no edges when no @Autowired collection fields exist', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'IFoo');
|
||||
addClass(graph, 'FooImpl1', 'java');
|
||||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||||
addClass(graph, 'MyService', 'java');
|
||||
// A non-collection field — should be ignored
|
||||
addProperty(graph, 'MyService', 'foo', 'IFoo');
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
expect(injectsEdges(graph)).toHaveLength(0);
|
||||
expect(output.injectsEdges).toBe(0);
|
||||
expect(output.fieldsScanned).toBe(0);
|
||||
});
|
||||
|
||||
it('creates no edges for a node carrying only the generics-stripped declaredType', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'IFoo');
|
||||
addClass(graph, 'FooImpl1', 'java');
|
||||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||||
addClass(graph, 'MyService', 'java');
|
||||
|
||||
// Production shape when rawDeclaredType plumbing regresses: only the
|
||||
// stripped simple name ("List") reaches the graph (rawDeclaredType: null
|
||||
// opt-out). The field IS injection-annotated (it passes the annotation
|
||||
// gate), so this pins the rawDeclaredType-missing skip path: the phase
|
||||
// must NOT fall back to declaredType — zero edges, zero fields scanned
|
||||
// (and an isDev warning flags the plumbing-contract breach).
|
||||
addProperty(graph, 'MyService', 'foos', 'List<IFoo>', 'java', ['@Autowired'], null);
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
expect(injectsEdges(graph)).toHaveLength(0);
|
||||
expect(output.injectsEdges).toBe(0);
|
||||
expect(output.fieldsScanned).toBe(0);
|
||||
});
|
||||
|
||||
it('skips non-Java Property nodes', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'IFoo');
|
||||
addClass(graph, 'FooImpl1', 'java');
|
||||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||||
|
||||
// TypeScript consumer — even though the declared type looks like a Spring
|
||||
// collection, the language is not Java, so it must be skipped.
|
||||
addClass(graph, 'TsConsumer', 'typescript');
|
||||
addProperty(graph, 'TsConsumer', 'foos', 'List<IFoo>', 'typescript');
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
expect(injectsEdges(graph)).toHaveLength(0);
|
||||
expect(output.injectsEdges).toBe(0);
|
||||
expect(output.fieldsScanned).toBe(0);
|
||||
});
|
||||
|
||||
it('handles Set<T>, Collection<T>, and Map<K,T> collection shapes', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'IPlugin');
|
||||
addClass(graph, 'CorePlugin', 'java');
|
||||
addClass(graph, 'ExtraPlugin', 'java');
|
||||
addImplements(graph, 'CorePlugin', 'IPlugin');
|
||||
addImplements(graph, 'ExtraPlugin', 'IPlugin');
|
||||
|
||||
// Three consumers, one per collection shape
|
||||
addClass(graph, 'SetConsumer', 'java');
|
||||
addProperty(graph, 'SetConsumer', 'plugins', 'Set<IPlugin>');
|
||||
|
||||
addClass(graph, 'CollectionConsumer', 'java');
|
||||
addProperty(graph, 'CollectionConsumer', 'plugins', 'Collection<IPlugin>');
|
||||
|
||||
addClass(graph, 'MapConsumer', 'java');
|
||||
// Map<K,V> — V (IPlugin) is the injected bean type
|
||||
addProperty(graph, 'MapConsumer', 'plugins', 'Map<String,IPlugin>');
|
||||
|
||||
await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
const edges = injectsEdges(graph);
|
||||
|
||||
// 3 consumers × 2 implementers = 6 edges
|
||||
expect(edges).toHaveLength(6);
|
||||
|
||||
const reasons = new Set(edges.map((e) => e.reason));
|
||||
expect(reasons.has('Spring DI: @Autowired Set<IPlugin>')).toBe(true);
|
||||
expect(reasons.has('Spring DI: @Autowired Collection<IPlugin>')).toBe(true);
|
||||
expect(reasons.has('Spring DI: @Autowired Map<IPlugin>')).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op on a graph with no Java Property nodes (early exit)', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'IFoo');
|
||||
addClass(graph, 'FooImpl1', 'java');
|
||||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||||
|
||||
// Non-Java property — should trigger early exit
|
||||
addClass(graph, 'PyConsumer', 'python');
|
||||
addProperty(graph, 'PyConsumer', 'foos', 'List<IFoo>', 'python');
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
expect(output.injectsEdges).toBe(0);
|
||||
expect(output.fieldsScanned).toBe(0);
|
||||
expect(injectsEdges(graph)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('creates no edges when the interface T has no implementers', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'INobody');
|
||||
addClass(graph, 'MyService', 'java');
|
||||
addProperty(graph, 'MyService', 'things', 'List<INobody>');
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
expect(injectsEdges(graph)).toHaveLength(0);
|
||||
expect(output.injectsEdges).toBe(0);
|
||||
// The field was scanned (1), but no implementers exist
|
||||
expect(output.fieldsScanned).toBe(1);
|
||||
});
|
||||
|
||||
it('deduplicates edges when multiple fields inject the same interface', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'IFoo');
|
||||
addClass(graph, 'FooImpl1', 'java');
|
||||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||||
|
||||
// Same consumer, two different fields both typed List<IFoo>
|
||||
addClass(graph, 'MyService', 'java');
|
||||
addProperty(graph, 'MyService', 'foos1', 'List<IFoo>');
|
||||
addProperty(graph, 'MyService', 'foos2', 'List<IFoo>');
|
||||
|
||||
await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
// Only 1 edge MyService → FooImpl1 (deduped by edge ID)
|
||||
const edges = injectsEdges(graph);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0].sourceId).toBe(generateId('Class', 'MyService'));
|
||||
expect(edges[0].targetId).toBe(generateId('Class', 'FooImpl1'));
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Injection-annotation gate (PR #2200 U2)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('creates edges for @Inject fields and states @Inject in the reason', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'IFoo');
|
||||
addClass(graph, 'FooImpl1', 'java');
|
||||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||||
addClass(graph, 'MyService', 'java');
|
||||
addProperty(graph, 'MyService', 'foos', 'List<IFoo>', 'java', ['@Inject']);
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
const edges = injectsEdges(graph);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0]).toMatchObject({
|
||||
sourceId: generateId('Class', 'MyService'),
|
||||
targetId: generateId('Class', 'FooImpl1'),
|
||||
reason: 'Spring DI: @Inject List<IFoo>',
|
||||
});
|
||||
expect(output.fieldsScanned).toBe(1);
|
||||
});
|
||||
|
||||
it('creates no edges for a plain (non-annotated) collection field of a known interface', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'IFoo');
|
||||
addClass(graph, 'FooImpl1', 'java');
|
||||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||||
addClass(graph, 'MyService', 'java');
|
||||
// The false-positive class the review flagged: a collection field with NO
|
||||
// injection annotation is never injected by the container.
|
||||
addProperty(graph, 'MyService', 'cache', 'List<IFoo>', 'java', []);
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
expect(injectsEdges(graph)).toHaveLength(0);
|
||||
expect(output.injectsEdges).toBe(0);
|
||||
expect(output.fieldsScanned).toBe(0);
|
||||
});
|
||||
|
||||
it('creates no edges for @Resource fields (deliberate exclusion)', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'IFoo');
|
||||
addClass(graph, 'FooImpl1', 'java');
|
||||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||||
addClass(graph, 'MyService', 'java');
|
||||
// @Resource (JSR-250) resolves by bean NAME first (defaulting to the
|
||||
// field name), injecting a single named collection bean — the opposite of
|
||||
// the collect-all-implementers fan-out INJECTS models. Its exclusion from
|
||||
// the gate is deliberate; this test pins it.
|
||||
addProperty(graph, 'MyService', 'named', 'List<IFoo>', 'java', ['@Resource']);
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
expect(injectsEdges(graph)).toHaveLength(0);
|
||||
expect(output.injectsEdges).toBe(0);
|
||||
expect(output.fieldsScanned).toBe(0);
|
||||
});
|
||||
|
||||
it('matches any injection annotation when the field carries multiple annotations', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'IFoo');
|
||||
addClass(graph, 'FooImpl1', 'java');
|
||||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||||
addClass(graph, 'MyService', 'java');
|
||||
// Non-injection annotations surround the injection one — the gate must
|
||||
// match @Autowired anywhere in the set, not just first position.
|
||||
addProperty(graph, 'MyService', 'foos', 'List<IFoo>', 'java', [
|
||||
'@Nullable',
|
||||
'@Autowired',
|
||||
'@Qualifier',
|
||||
]);
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
const edges = injectsEdges(graph);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0]).toMatchObject({
|
||||
sourceId: generateId('Class', 'MyService'),
|
||||
targetId: generateId('Class', 'FooImpl1'),
|
||||
reason: 'Spring DI: @Autowired List<IFoo>',
|
||||
});
|
||||
expect(output.fieldsScanned).toBe(1);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Matcher registry routing (PR #2200 U3)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('skips Property nodes whose language has no registered matcher', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'IFoo');
|
||||
addClass(graph, 'FooImpl1', 'java');
|
||||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||||
|
||||
// A supported language with NO DI_MATCHERS entry: the node carries the
|
||||
// full annotated-collection shape, but no matcher is registered for
|
||||
// 'python', so the phase must produce zero candidates.
|
||||
addClass(graph, 'PyConsumer', 'python');
|
||||
addProperty(graph, 'PyConsumer', 'foos', 'List<IFoo>', 'python', ['@Autowired']);
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
expect(injectsEdges(graph)).toHaveLength(0);
|
||||
expect(output.injectsEdges).toBe(0);
|
||||
expect(output.fieldsScanned).toBe(0);
|
||||
});
|
||||
|
||||
it('skips Property nodes whose language string is not a SupportedLanguages value', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'IFoo');
|
||||
addClass(graph, 'FooImpl1', 'java');
|
||||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||||
|
||||
// An arbitrary language string outside the enum exercises the
|
||||
// isSupportedLanguage narrowing guard in the phase's routing.
|
||||
addClass(graph, 'FortranConsumer', 'fortran');
|
||||
addProperty(graph, 'FortranConsumer', 'foos', 'List<IFoo>', 'fortran', ['@Autowired']);
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
expect(injectsEdges(graph)).toHaveLength(0);
|
||||
expect(output.injectsEdges).toBe(0);
|
||||
expect(output.fieldsScanned).toBe(0);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Language- and qualified-name-scoped interface resolution (PR #2200 U4)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it.each([
|
||||
['com.a.Shape inserted first', ['com.a.Shape', 'com.b.Shape'] as const],
|
||||
['com.b.Shape inserted first', ['com.b.Shape', 'com.a.Shape'] as const],
|
||||
])(
|
||||
'fails closed on a two-package same-simple-name collision (%s)',
|
||||
async (_label, [firstQn, secondQn]) => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
// Two Java interfaces named `Shape` in different packages. Insertion
|
||||
// order is the it.each parameter: identical assertions across both
|
||||
// orders pin order-independence (never last-writer-wins).
|
||||
addInterface(graph, 'Shape', 'java', firstQn);
|
||||
addInterface(graph, 'Shape', 'java', secondQn);
|
||||
addClass(graph, 'ShapeAImpl', 'java');
|
||||
addImplements(graph, 'ShapeAImpl', 'Shape', 'java', 'com.a.Shape');
|
||||
addClass(graph, 'ShapeBImpl', 'java');
|
||||
addImplements(graph, 'ShapeBImpl', 'Shape', 'java', 'com.b.Shape');
|
||||
|
||||
addClass(graph, 'MyService', 'java');
|
||||
addProperty(graph, 'MyService', 'shapes', 'List<Shape>');
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
// Bare `Shape` is ambiguous within Java → fail closed, observable skip.
|
||||
expect(injectsEdges(graph)).toHaveLength(0);
|
||||
expect(output).toMatchObject({
|
||||
injectsEdges: 0,
|
||||
fieldsScanned: 1,
|
||||
ambiguousSkipped: 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['typescript interface inserted first', ['typescript', 'java'] as const],
|
||||
['java interface inserted first', ['java', 'typescript'] as const],
|
||||
])(
|
||||
'resolves a bare name only within the candidate language (%s)',
|
||||
async (_label, [firstLang, secondLang]) => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
// A TS `interface Shape` and a Java `interface Shape` (unique WITHIN
|
||||
// Java). The Java consumer's bare `Shape` must resolve to the Java
|
||||
// interface regardless of which language's node was inserted first.
|
||||
addInterface(graph, 'Shape', firstLang);
|
||||
addInterface(graph, 'Shape', secondLang);
|
||||
addClass(graph, 'TsShapeImpl', 'typescript');
|
||||
addImplements(graph, 'TsShapeImpl', 'Shape', 'typescript');
|
||||
addClass(graph, 'JavaShapeImpl', 'java');
|
||||
addImplements(graph, 'JavaShapeImpl', 'Shape', 'java');
|
||||
|
||||
addClass(graph, 'MyService', 'java');
|
||||
addProperty(graph, 'MyService', 'shapes', 'List<Shape>');
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
// Edges ONLY to the Java implementer — the TS implementer never
|
||||
// participates in a Java candidate's resolution.
|
||||
const edges = injectsEdges(graph);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0]).toMatchObject({
|
||||
sourceId: generateId('Class', 'MyService'),
|
||||
targetId: generateId('Class', 'JavaShapeImpl'),
|
||||
});
|
||||
expect(output).toMatchObject({
|
||||
injectsEdges: 1,
|
||||
fieldsScanned: 1,
|
||||
ambiguousSkipped: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('resolves a qualified element type via qualifiedName despite simple-name ambiguity', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'Shape', 'java', 'com.a.Shape');
|
||||
addInterface(graph, 'Shape', 'java', 'com.b.Shape');
|
||||
addClass(graph, 'ShapeAImpl', 'java');
|
||||
addImplements(graph, 'ShapeAImpl', 'Shape', 'java', 'com.a.Shape');
|
||||
addClass(graph, 'ShapeBImpl', 'java');
|
||||
addImplements(graph, 'ShapeBImpl', 'Shape', 'java', 'com.b.Shape');
|
||||
|
||||
// The field spells the element type fully qualified — exact qualifiedName
|
||||
// lookup, unaffected by the bare-name ambiguity.
|
||||
addClass(graph, 'MyService', 'java');
|
||||
addProperty(graph, 'MyService', 'shapes', 'List<com.a.Shape>');
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
const edges = injectsEdges(graph);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0]).toMatchObject({
|
||||
sourceId: generateId('Class', 'MyService'),
|
||||
targetId: generateId('Class', 'ShapeAImpl'),
|
||||
reason: 'Spring DI: @Autowired List<com.a.Shape>',
|
||||
});
|
||||
expect(output).toMatchObject({
|
||||
injectsEdges: 1,
|
||||
fieldsScanned: 1,
|
||||
ambiguousSkipped: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['module A inserted first', ['moduleA', 'moduleB'] as const],
|
||||
['module B inserted first', ['moduleB', 'moduleA'] as const],
|
||||
])(
|
||||
'fails closed on a duplicate-qualifiedName collision (%s)',
|
||||
async (_label, [firstModule, secondModule]) => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
// Two Java interfaces BOTH carrying qualifiedName `com.a.Shape` — the
|
||||
// realistic monorepo shape where the same package+name is duplicated
|
||||
// across modules or main/test source roots (a Java qualifiedName has no
|
||||
// file-path component). Distinct node ids (production ids embed the
|
||||
// file path), identical qualifiedName; insertion order is the it.each
|
||||
// parameter: identical assertions across both orders pin
|
||||
// order-independence (never last-writer-wins).
|
||||
const addModuleShape = (module: string): string => {
|
||||
const id = generateId('Interface', `java:${module}:com.a.Shape`);
|
||||
graph.addNode({
|
||||
id,
|
||||
label: 'Interface',
|
||||
properties: {
|
||||
name: 'Shape',
|
||||
filePath: `${module}/src/Shape.java`,
|
||||
language: 'java',
|
||||
qualifiedName: 'com.a.Shape',
|
||||
},
|
||||
});
|
||||
return id;
|
||||
};
|
||||
const firstIfaceId = addModuleShape(firstModule);
|
||||
const secondIfaceId = addModuleShape(secondModule);
|
||||
|
||||
// One implementer per module's interface, so a wrong (last-writer-wins)
|
||||
// resolution WOULD have implementers to fan out to.
|
||||
const implAId = addClass(graph, 'ShapeAImpl', 'java');
|
||||
const implBId = addClass(graph, 'ShapeBImpl', 'java');
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', `${implAId}->${firstIfaceId}`),
|
||||
sourceId: implAId,
|
||||
targetId: firstIfaceId,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', `${implBId}->${secondIfaceId}`),
|
||||
sourceId: implBId,
|
||||
targetId: secondIfaceId,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
|
||||
// The field spells the element type fully qualified — the dotted branch.
|
||||
addClass(graph, 'MyService', 'java');
|
||||
addProperty(graph, 'MyService', 'shapes', 'List<com.a.Shape>');
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
// Qualified `com.a.Shape` is ambiguous within Java → fail closed,
|
||||
// observable skip — regardless of which module's node indexed first.
|
||||
expect(injectsEdges(graph)).toHaveLength(0);
|
||||
expect(output).toMatchObject({
|
||||
injectsEdges: 0,
|
||||
fieldsScanned: 1,
|
||||
ambiguousSkipped: 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('fails closed even when the consumer shares a package with one collision party (pinned)', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
addInterface(graph, 'Shape', 'java', 'com.a.Shape');
|
||||
addInterface(graph, 'Shape', 'java', 'com.b.Shape');
|
||||
addClass(graph, 'ShapeAImpl', 'java');
|
||||
addImplements(graph, 'ShapeAImpl', 'Shape', 'java', 'com.a.Shape');
|
||||
addClass(graph, 'ShapeBImpl', 'java');
|
||||
addImplements(graph, 'ShapeBImpl', 'Shape', 'java', 'com.b.Shape');
|
||||
|
||||
// The consumer lives in com.a — Java source would resolve its bare
|
||||
// `Shape` to com.a.Shape. Resolution has NO package awareness today, so
|
||||
// this is still an ambiguous fail-closed skip. PINNED as current
|
||||
// behavior: the same-package tiebreaker is a deliberate, documented
|
||||
// follow-up (see the plan's Deferred work); implementing it must flip
|
||||
// this test knowingly.
|
||||
addClass(graph, 'MyService', 'java', 'Class', { qualifiedName: 'com.a.MyService' });
|
||||
addProperty(graph, 'MyService', 'shapes', 'List<Shape>');
|
||||
|
||||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||||
|
||||
expect(injectsEdges(graph)).toHaveLength(0);
|
||||
expect(output).toMatchObject({
|
||||
injectsEdges: 0,
|
||||
fieldsScanned: 1,
|
||||
ambiguousSkipped: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Matcher-level tests (di-extractors/spring.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Hand-build a Property GraphNode for direct matcher calls. */
|
||||
function matcherNode(properties: {
|
||||
name: string;
|
||||
rawDeclaredType?: string;
|
||||
annotations?: string[];
|
||||
language?: string;
|
||||
}): GraphNode {
|
||||
const { name, ...rest } = properties;
|
||||
return {
|
||||
id: generateId('Property', name),
|
||||
label: 'Property',
|
||||
properties: { name, filePath: `src/Owner.java`, language: 'java', ...rest },
|
||||
};
|
||||
}
|
||||
|
||||
describe('springDiFieldMatcher', () => {
|
||||
it('returns the parsed match for an @Autowired collection field', () => {
|
||||
const match = springDiFieldMatcher(
|
||||
matcherNode({ name: 'foos', rawDeclaredType: 'List<IFoo>', annotations: ['@Autowired'] }),
|
||||
);
|
||||
// Wrapper identity and the gating annotation are visible in the reason.
|
||||
expect(match).toEqual({
|
||||
elementTypeName: 'IFoo',
|
||||
reason: 'Spring DI: @Autowired List<IFoo>',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses Map<K,T> to the value type T', () => {
|
||||
const match = springDiFieldMatcher(
|
||||
matcherNode({
|
||||
name: 'plugins',
|
||||
rawDeclaredType: 'Map<String,IPlugin>',
|
||||
annotations: ['@Inject'],
|
||||
}),
|
||||
);
|
||||
// The Map wrapper and the @Inject annotation are visible in the reason.
|
||||
expect(match).toEqual({
|
||||
elementTypeName: 'IPlugin',
|
||||
reason: 'Spring DI: @Inject Map<IPlugin>',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for a non-annotated collection field', () => {
|
||||
expect(
|
||||
springDiFieldMatcher(matcherNode({ name: 'cache', rawDeclaredType: 'List<IFoo>' })),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null for @Resource (deliberate exclusion) and other non-injection annotations', () => {
|
||||
expect(
|
||||
springDiFieldMatcher(
|
||||
matcherNode({ name: 'named', rawDeclaredType: 'List<IFoo>', annotations: ['@Resource'] }),
|
||||
),
|
||||
).toBe(null);
|
||||
expect(
|
||||
springDiFieldMatcher(
|
||||
matcherNode({ name: 'q', rawDeclaredType: 'List<IFoo>', annotations: ['@Qualifier'] }),
|
||||
),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null for an annotated non-collection field', () => {
|
||||
expect(
|
||||
springDiFieldMatcher(
|
||||
matcherNode({ name: 'foo', rawDeclaredType: 'IFoo', annotations: ['@Autowired'] }),
|
||||
),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null for an annotated field with no rawDeclaredType (plumbing breach)', () => {
|
||||
expect(springDiFieldMatcher(matcherNode({ name: 'foos', annotations: ['@Autowired'] }))).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Collection-type parser (PR #2200 U5) — table-driven, exact outputs.
|
||||
// Every ACCEPT/REJECT shape here was executed as a failing (or must-keep-
|
||||
// passing) case during the review; the module docstring documents each
|
||||
// rejection.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it.each<[string, string, { collectionType: string; elementTypeName: string }]>([
|
||||
// Existing happy shapes — must keep parsing identically.
|
||||
['plain List', 'List<IFoo>', { collectionType: 'List', elementTypeName: 'IFoo' }],
|
||||
['plain Set', 'Set<IFoo>', { collectionType: 'Set', elementTypeName: 'IFoo' }],
|
||||
[
|
||||
'plain Collection',
|
||||
'Collection<IFoo>',
|
||||
{ collectionType: 'Collection', elementTypeName: 'IFoo' },
|
||||
],
|
||||
['plain Map', 'Map<String,IPlugin>', { collectionType: 'Map', elementTypeName: 'IPlugin' }],
|
||||
// Generic Map KEY: the old `[^,]+` regex stopped at the nested comma and
|
||||
// captured garbage — the depth-aware split must yield the value type.
|
||||
['generic Map key', 'Map<Pair<A,B>, IFoo>', { collectionType: 'Map', elementTypeName: 'IFoo' }],
|
||||
// Bounded wildcards — idiomatic Spring collection injection.
|
||||
[
|
||||
'upper-bounded wildcard',
|
||||
'List<? extends IFoo>',
|
||||
{ collectionType: 'List', elementTypeName: 'IFoo' },
|
||||
],
|
||||
[
|
||||
'lower-bounded wildcard',
|
||||
'List<? super IFoo>',
|
||||
{ collectionType: 'List', elementTypeName: 'IFoo' },
|
||||
],
|
||||
// Whitespace normalization: padded generics, padded Map comma, and a
|
||||
// multi-line declaration (raw tree-sitter .text can span lines).
|
||||
['padded element', 'List< IFoo >', { collectionType: 'List', elementTypeName: 'IFoo' }],
|
||||
['padded Map comma', 'Map<String , IFoo>', { collectionType: 'Map', elementTypeName: 'IFoo' }],
|
||||
[
|
||||
'multi-line declaration',
|
||||
'Map<\n String,\n IFoo\n>',
|
||||
{ collectionType: 'Map', elementTypeName: 'IFoo' },
|
||||
],
|
||||
// Package-qualified WRAPPER: recognized by its last dotted segment; the
|
||||
// qualifier is stripped from the wrapper only.
|
||||
[
|
||||
'qualified wrapper',
|
||||
'java.util.List<IFoo>',
|
||||
{ collectionType: 'List', elementTypeName: 'IFoo' },
|
||||
],
|
||||
[
|
||||
'qualified Map wrapper',
|
||||
'java.util.Map<String, IFoo>',
|
||||
{ collectionType: 'Map', elementTypeName: 'IFoo' },
|
||||
],
|
||||
// Dotted ELEMENT keeps its dots — resolved via qualifiedName downstream.
|
||||
[
|
||||
'qualified element',
|
||||
'List<com.a.Shape>',
|
||||
{ collectionType: 'List', elementTypeName: 'com.a.Shape' },
|
||||
],
|
||||
[
|
||||
'wildcard + qualified element',
|
||||
'Set<? extends com.a.Shape>',
|
||||
{ collectionType: 'Set', elementTypeName: 'com.a.Shape' },
|
||||
],
|
||||
])('parseSpringCollectionType accepts %s: %j', (_label, raw, expected) => {
|
||||
expect(parseSpringCollectionType(raw)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each<[string, string]>([
|
||||
// Element itself generic — unresolvable as a single interface.
|
||||
['nested-generic element', 'Map<String, List<IFoo>>'],
|
||||
['nested-generic behind wildcard', 'List<? extends List<IFoo>>'],
|
||||
// Unbounded wildcard — no element type to fan out to.
|
||||
['unbounded wildcard', 'List<?>'],
|
||||
// Arrays — not the collect-all-implementers shape INJECTS models.
|
||||
['array type', 'IFoo[]'],
|
||||
['array of collections', 'List<IFoo>[]'],
|
||||
['array element', 'List<IFoo[]>'],
|
||||
// Non-collection types.
|
||||
['bare interface', 'IFoo'],
|
||||
['non-collection wrapper', 'Optional<IFoo>'],
|
||||
// Wrong generic arity.
|
||||
['Map with one argument', 'Map<String>'],
|
||||
['List with two arguments', 'List<A, B>'],
|
||||
['empty argument list', 'List<>'],
|
||||
// Block comments inside generics are not stripped — fail closed.
|
||||
['block comment in generics', 'List</*x*/IFoo>'],
|
||||
// Unbalanced brackets — fail closed.
|
||||
['unbalanced brackets', 'List<IFoo>>'],
|
||||
])('parseSpringCollectionType rejects %s: %j → null', (_label, raw) => {
|
||||
expect(parseSpringCollectionType(raw)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores node language — routing is the DI_MATCHERS registry's job", () => {
|
||||
// The matcher never reads properties.language: a valid Spring shape on a
|
||||
// 'python'-tagged node still matches. The phase-level registry routing
|
||||
// (tested above) is what keeps non-Java nodes away from this matcher.
|
||||
const match = springDiFieldMatcher(
|
||||
matcherNode({
|
||||
name: 'foos',
|
||||
rawDeclaredType: 'List<IFoo>',
|
||||
annotations: ['@Autowired'],
|
||||
language: 'python',
|
||||
}),
|
||||
);
|
||||
expect(match).toMatchObject({
|
||||
elementTypeName: 'IFoo',
|
||||
reason: 'Spring DI: @Autowired List<IFoo>',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -76,12 +76,13 @@ const FULL_ORDER = [
|
|||
'scopeResolution',
|
||||
'pruneLocalSymbols',
|
||||
'mro',
|
||||
'di',
|
||||
'communities',
|
||||
'processes',
|
||||
];
|
||||
|
||||
const WITHOUT_GRAPH_PHASES = FULL_ORDER.filter(
|
||||
(n) => n !== 'mro' && n !== 'communities' && n !== 'processes',
|
||||
(n) => n !== 'mro' && n !== 'di' && n !== 'communities' && n !== 'processes',
|
||||
);
|
||||
|
||||
describe('buildPhaseList parity (registry refactor, #2080)', () => {
|
||||
|
|
@ -94,7 +95,7 @@ describe('buildPhaseList parity (registry refactor, #2080)', () => {
|
|||
expect(buildPhaseList({ skipGraphPhases: false }).map((p) => p.name)).toEqual(FULL_ORDER);
|
||||
});
|
||||
|
||||
it('skipGraphPhases:true → omits exactly mro/communities/processes', () => {
|
||||
it('skipGraphPhases:true → omits exactly mro/di/communities/processes', () => {
|
||||
expect(buildPhaseList({ skipGraphPhases: true }).map((p) => p.name)).toEqual(
|
||||
WITHOUT_GRAPH_PHASES,
|
||||
);
|
||||
|
|
|
|||
43
gitnexus/test/unit/lbug-delete-all-error.test.ts
Normal file
43
gitnexus/test/unit/lbug-delete-all-error.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Unit tests for `classifyDeleteAllError` (lbug-config.ts) — the
|
||||
* benign-vs-rethrow classification behind `deleteAllRelationshipsOfType`
|
||||
* (lbug-adapter.ts), shared by the delete-before-rewrite family
|
||||
* (`deleteAllInjects` / `deleteAllCallSummaries` /
|
||||
* `deleteAllInterprocTaintPaths`).
|
||||
*
|
||||
* The branch is load-bearing: 'benign-missing-table' silently no-ops (a
|
||||
* freshly-initialized DB has no CodeRelation rows to clear), while EVERYTHING
|
||||
* else must be re-thrown by the caller — the only defense against the
|
||||
* subsequent re-extract writing duplicate rows (CodeRelation has no PK,
|
||||
* #2084 review P2-5). It is exercised here as a pure function because driving
|
||||
* a synthetic native failure through the real singleton connection would
|
||||
* break every later test in the shared integration suite (see the note in
|
||||
* test/integration/lbug-core-adapter.test.ts).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classifyDeleteAllError } from '../../src/core/lbug/lbug-config.js';
|
||||
|
||||
describe('classifyDeleteAllError', () => {
|
||||
it.each<[string, string]>([
|
||||
['full missing-table phrasing', 'Binder exception: Table CodeRelation does not exist.'],
|
||||
['bare does-not-exist', 'table does not exist'],
|
||||
['no-table phrasing', 'Catalog exception: no table named CodeRelation'],
|
||||
['not-found phrasing', 'CodeRelation not found in catalog'],
|
||||
['not-exist phrasing (without "does")', 'Error: rel table CodeRelation not exist'],
|
||||
['case-insensitive match', 'TABLE CODERELATION DOES NOT EXIST'],
|
||||
])('classifies %s as benign-missing-table', (_label, message) => {
|
||||
expect(classifyDeleteAllError(new Error(message))).toBe('benign-missing-table');
|
||||
});
|
||||
|
||||
it.each<[string, unknown]>([
|
||||
['a closed connection', new Error('connection closed')],
|
||||
['lock contention', new Error('Could not set lock on file: database is locked')],
|
||||
['disk I/O failure', new Error('IO exception: failed to write WAL entry')],
|
||||
['a generic native error', new Error('Runtime exception: unexpected null pointer')],
|
||||
['a non-Error string throw', 'something went sideways'],
|
||||
['a non-Error object throw (String() → "[object Object]")', { code: 'EIO' }],
|
||||
['undefined (String() → "undefined")', undefined],
|
||||
])('classifies %s as rethrow', (_label, err) => {
|
||||
expect(classifyDeleteAllError(err)).toBe('rethrow');
|
||||
});
|
||||
});
|
||||
|
|
@ -107,6 +107,10 @@ describe('LadybugDB Schema', () => {
|
|||
expect(REL_TYPES).toContain(t);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes the DI collection-injection edge type (#2200)', () => {
|
||||
expect(REL_TYPES).toContain('INJECTS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('node schema DDL', () => {
|
||||
|
|
|
|||
|
|
@ -16,28 +16,34 @@ import {
|
|||
// ─── Relation type allowlist ──────────────────────────────────────────
|
||||
|
||||
describe('VALID_RELATION_TYPES', () => {
|
||||
// The expected types are declared once here; the size assertion derives from
|
||||
// the array length so adding a new type only requires appending to this list.
|
||||
const EXPECTED_RELATION_TYPES = [
|
||||
'CALLS',
|
||||
'IMPORTS',
|
||||
'EXTENDS',
|
||||
'IMPLEMENTS',
|
||||
'HAS_METHOD',
|
||||
'HAS_PROPERTY',
|
||||
'METHOD_OVERRIDES',
|
||||
'OVERRIDES',
|
||||
'METHOD_IMPLEMENTS',
|
||||
'ACCESSES',
|
||||
// USES is an emitted edge type (emit-references.ts) used in the default
|
||||
// impact relTypes + context queries; added to the allowlist in F5.
|
||||
'USES',
|
||||
'HANDLES_ROUTE',
|
||||
'FETCHES',
|
||||
'HANDLES_TOOL',
|
||||
'ENTRY_POINT_OF',
|
||||
'WRAPS',
|
||||
// Spring DI @Autowired collection injection (#2200)
|
||||
'INJECTS',
|
||||
] as const;
|
||||
|
||||
it('contains all expected relation types', () => {
|
||||
expect(VALID_RELATION_TYPES.size).toBe(16);
|
||||
for (const t of [
|
||||
'CALLS',
|
||||
'IMPORTS',
|
||||
'EXTENDS',
|
||||
'IMPLEMENTS',
|
||||
'HAS_METHOD',
|
||||
'HAS_PROPERTY',
|
||||
'METHOD_OVERRIDES',
|
||||
'OVERRIDES',
|
||||
'METHOD_IMPLEMENTS',
|
||||
'ACCESSES',
|
||||
// USES is an emitted edge type (emit-references.ts) used in the default
|
||||
// impact relTypes + context queries; added to the allowlist in F5.
|
||||
'USES',
|
||||
'HANDLES_ROUTE',
|
||||
'FETCHES',
|
||||
'HANDLES_TOOL',
|
||||
'ENTRY_POINT_OF',
|
||||
'WRAPS',
|
||||
]) {
|
||||
expect(VALID_RELATION_TYPES.size).toBe(EXPECTED_RELATION_TYPES.length);
|
||||
for (const t of EXPECTED_RELATION_TYPES) {
|
||||
expect(VALID_RELATION_TYPES.has(t)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
|
@ -61,16 +67,18 @@ describe('VALID_RELATION_TYPES', () => {
|
|||
// Cross-function TAINT_PATH (Function→Function) is the interprocedural
|
||||
// analogue of TAINTED — surfaced ONLY via `explain` (its interprocedural
|
||||
// findings), never impact()'s BFS. Pinned so a future allow-all sweep
|
||||
// can't drag it in, and the set size stays fixed at 16.
|
||||
// can't drag it in — the size assertion tracks EXPECTED_RELATION_TYPES.
|
||||
expect(VALID_RELATION_TYPES.has('TAINT_PATH')).toBe(false);
|
||||
expect(VALID_RELATION_TYPES.size).toBe(16);
|
||||
// Size should match the expected types list — not a hardcoded number.
|
||||
expect(VALID_RELATION_TYPES.size).toBe(EXPECTED_RELATION_TYPES.length);
|
||||
});
|
||||
|
||||
it('CDG control-dependence edge types stay OUT of the impact allow-list (#2085 M5)', () => {
|
||||
// CDG and POST_DOMINATE are BasicBlock→BasicBlock (block space), like the
|
||||
// taint substrate — they must not enter impact()'s symbol-space BFS. Pinned
|
||||
// explicitly (not just via the size==16 guard) so a future "add all emitted
|
||||
// types" sweep can't drag them in, mirroring the TAINTED/TAINT_PATH pins.
|
||||
// explicitly (not just via the EXPECTED_RELATION_TYPES-derived size guard)
|
||||
// so a future "add all emitted types" sweep can't drag them in, mirroring
|
||||
// the TAINTED/TAINT_PATH pins.
|
||||
expect(VALID_RELATION_TYPES.has('CDG')).toBe(false);
|
||||
expect(VALID_RELATION_TYPES.has('POST_DOMINATE')).toBe(false);
|
||||
// REACHING_DEF is the other BasicBlock→BasicBlock PDG edge (#2086 impact
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue