GitNexus/gitnexus/test/unit/ingestion/di.test.ts
ChunxueLi 1029a8ddd7
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 (#2200)
* 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>
2026-07-02 17:49:46 +01:00

921 lines
34 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>',
});
});
});