diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 51568d948..109e9bbde 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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(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 diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index 8134ad948..a7d43a918 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -78,6 +78,9 @@ export type NodeProperties = { level?: number; returnType?: string; declaredType?: string; + /** Verbatim declared-type source text with generics preserved + * (e.g. `List` 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`, `Set`, `Collection`, or `Map`). 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`), 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. diff --git a/gitnexus-shared/src/lbug/schema-constants.ts b/gitnexus-shared/src/lbug/schema-constants.ts index 875f74d2e..46b0560fc 100644 --- a/gitnexus-shared/src/lbug/schema-constants.ts +++ b/gitnexus-shared/src/lbug/schema-constants.ts @@ -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. diff --git a/gitnexus/src/core/incremental/subgraph-extract.ts b/gitnexus/src/core/incremental/subgraph-extract.ts index 523d25dd1..bbbb6ac42 100644 --- a/gitnexus/src/core/incremental/subgraph-extract.ts +++ b/gitnexus/src/core/incremental/subgraph-extract.ts @@ -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 for every File-bound node in the graph. diff --git a/gitnexus/src/core/ingestion/di-extractors/index.ts b/gitnexus/src/core/ingestion/di-extractors/index.ts new file mode 100644 index 000000000..0c0869c52 --- /dev/null +++ b/gitnexus/src/core/ingestion/di-extractors/index.ts @@ -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` 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/.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 = 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 = new Map< + SupportedLanguages, + DiFieldMatcher +>([[SupportedLanguages.Java, springDiFieldMatcher]]); diff --git a/gitnexus/src/core/ingestion/di-extractors/spring.ts b/gitnexus/src/core/ingestion/di-extractors/spring.ts new file mode 100644 index 000000000..0a6da59ea --- /dev/null +++ b/gitnexus/src/core/ingestion/di-extractors/spring.ts @@ -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`, + * `Set`, `Collection`, or `Map`, 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` → `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` / `Set` / `Collection` — element `T`. + * - `Map` — element is the VALUE type `T`; the key `K` is irrelevant + * for DI resolution and may itself be generic (`Map, T>` — the + * top-level-comma split is bracket-depth-aware, so nested commas in the + * key never bleed into the element). + * - Bounded wildcards `List` / `List` — element `T` + * (both are idiomatic Spring collection injection; the container still + * collects every implementer of `T`). + * - Package-qualified wrappers `java.util.List` — the wrapper is + * recognized by its LAST dotted segment. The ELEMENT keeps its dots + * (`List` → `com.a.Shape`): dotted element names resolve via + * `qualifiedName` downstream in the `di` phase. + * + * Documented REJECTIONS (parse returns `null` — no INJECTS edges): + * - `Map>` — 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[]`, `List` — array injection is + * not the collect-all-implementers shape INJECTS models. + * - Non-collection types (`IFoo`, `Optional`, …) and wrong generic + * arity (`Map`, `List`). + * - 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 = 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 = 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` key in + * `Map, 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>` — not resolvable as a single interface), + // arrays (`List` — 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` 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[]`) — 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}>`, + }; +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts b/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts index 39d7996f0..ee797de90 100644 --- a/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts +++ b/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts @@ -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. diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/jvm.ts b/gitnexus/src/core/ingestion/field-extractors/configs/jvm.ts index 37015a998..2db9a9aad 100644 --- a/gitnexus/src/core/ingestion/field-extractors/configs/jvm.ts +++ b/gitnexus/src/core/ingestion/field-extractors/configs/jvm.ts @@ -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`) + // and qualifiers (`java.util.List`) 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'); }, diff --git a/gitnexus/src/core/ingestion/field-extractors/generic.ts b/gitnexus/src/core/ingestion/field-extractors/generic.ts index 68f77dc8b..d102ca1a8 100644 --- a/gitnexus/src/core/ingestion/field-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/field-extractors/generic.ts @@ -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` stays + * `List`). 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), diff --git a/gitnexus/src/core/ingestion/field-types.ts b/gitnexus/src/core/ingestion/field-types.ts index 7867ae8a6..6c243f8ba 100644 --- a/gitnexus/src/core/ingestion/field-types.ts +++ b/gitnexus/src/core/ingestion/field-types.ts @@ -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` 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? */ diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/jvm.ts b/gitnexus/src/core/ingestion/method-extractors/configs/jvm.ts index 553f19b75..44d65f4b3 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/jvm.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/jvm.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/pipeline-phases/di.ts b/gitnexus/src/core/ingestion/pipeline-phases/di.ts new file mode 100644 index 000000000..784e65bc4 --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/di.ts @@ -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; + /** `properties.name` → Interface node id, or {@link AMBIGUOUS} once a + * second same-name Interface appears in the same language. */ + bySimpleName: Map; +} + +/** 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 = { + 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 { + 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 (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>(); + 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(); + 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(candidates.map((c) => c.language)); + const interfacesByLanguage = new Map(); + 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(); + const seenEdges = new Set(); + + 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 }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/index.ts b/gitnexus/src/core/ingestion/pipeline-phases/index.ts index 15b5e6777..f4996a8ac 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/index.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/index.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 9c27a5250..58ec391c0 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index adf3a0db7..1eb6352f5 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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; diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index b20f78edd..7c571b7fe 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -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 diff --git a/gitnexus/src/core/lbug/lbug-config.ts b/gitnexus/src/core/lbug/lbug-config.ts index 02c2ba66d..d387f241e 100644 --- a/gitnexus/src/core/lbug/lbug-config.ts +++ b/gitnexus/src/core/lbug/lbug-config.ts @@ -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, diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 5a3091df9..389294579 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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 diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 7c74d39d9..037d0cafe 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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', ]); /** diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index ee4027bba..38444c276 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -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: { diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index a896f2964..ab69cf0c4 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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 diff --git a/gitnexus/test/integration/lbug-core-adapter.test.ts b/gitnexus/test/integration/lbug-core-adapter.test.ts index ebf5d62c7..0d291aa08 100644 --- a/gitnexus/test/integration/lbug-core-adapter.test.ts +++ b/gitnexus/test/integration/lbug-core-adapter.test.ts @@ -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'); diff --git a/gitnexus/test/integration/spring-di-pipeline.test.ts b/gitnexus/test/integration/spring-di-pipeline.test.ts new file mode 100644 index 000000000..1b18efcc8 --- /dev/null +++ b/gitnexus/test/integration/spring-di-pipeline.test.ts @@ -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` 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 foos; + private List 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 plain; + private List 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(); + 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', + }); + 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', + }); + // 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([]); + }); +}); diff --git a/gitnexus/test/unit/field-extraction.test.ts b/gitnexus/test/unit/field-extraction.test.ts index 89b75505d..fd86c3aaa 100644 --- a/gitnexus/test/unit/field-extraction.test.ts +++ b/gitnexus/test/unit/field-extraction.test.ts @@ -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 shapes;', + name: 'shapes', + type: 'List', + rawDeclaredType: 'List', + }, + { + field: 'private Set items;', + name: 'items', + type: 'Set', + rawDeclaredType: 'Set', + }, + { + field: 'private Map byName;', + name: 'byName', + type: 'Map', + rawDeclaredType: 'Map', + }, + { + // 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 shapes;', + name: 'shapes', + type: 'List', + rawDeclaredType: 'java.util.List', + }, + ])( + '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 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 shapes;', + annotations: ['@Autowired'], + }, + { + // Multiple annotations on one field — all are collected, in order. + field: '@Nullable @Autowired @Qualifier("shapeBeans") private List 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 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 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 // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index da4e2c0db..5e8ed6a93 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -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 { + 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` + 2 implementers ⇒ exactly + * 2 INJECTS edges (Consumer→FooA, Consumer→FooB). Same shapes as the + * spring-di-pipeline integration fixture. */ +const JAVA_DI_FIXTURE: ReadonlyArray = [ + ['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 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); }); diff --git a/gitnexus/test/unit/incremental-subgraph-extract.test.ts b/gitnexus/test/unit/incremental-subgraph-extract.test.ts index ed51f45b8..667ef3399 100644 --- a/gitnexus/test/unit/incremental-subgraph-extract.test.ts +++ b/gitnexus/test/unit/incremental-subgraph-extract.test.ts @@ -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)', () => { diff --git a/gitnexus/test/unit/ingestion/di.test.ts b/gitnexus/test/unit/ingestion/di.test.ts new file mode 100644 index 000000000..b5b1990f5 --- /dev/null +++ b/gitnexus/test/unit/ingestion/di.test.ts @@ -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, Set, Collection, Map) 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(phaseName: string, output: T): PhaseResult { + return { phaseName, output, durationMs: 0 }; +} + +function addClass( + graph: KnowledgeGraph, + name: string, + language: string, + label: NodeLabel = 'Class', + extra: Record = {}, +): 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`), 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 + addClass(graph, 'MyService', 'java'); + addProperty(graph, 'MyService', 'foos', 'List'); + + 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'); + } + + // 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'); + + 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', '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', '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, Collection, and Map 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'); + + addClass(graph, 'CollectionConsumer', 'java'); + addProperty(graph, 'CollectionConsumer', 'plugins', 'Collection'); + + addClass(graph, 'MapConsumer', 'java'); + // Map — V (IPlugin) is the injected bean type + addProperty(graph, 'MapConsumer', 'plugins', 'Map'); + + 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')).toBe(true); + expect(reasons.has('Spring DI: @Autowired Collection')).toBe(true); + expect(reasons.has('Spring DI: @Autowired Map')).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', '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'); + + 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 + addClass(graph, 'MyService', 'java'); + addProperty(graph, 'MyService', 'foos1', 'List'); + addProperty(graph, 'MyService', 'foos2', 'List'); + + 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', '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', + }); + 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', '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', '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', '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', + }); + 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', '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', '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'); + + 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'); + + 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'); + + 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', + }); + 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'); + + 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'); + + 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', annotations: ['@Autowired'] }), + ); + // Wrapper identity and the gating annotation are visible in the reason. + expect(match).toEqual({ + elementTypeName: 'IFoo', + reason: 'Spring DI: @Autowired List', + }); + }); + + it('parses Map to the value type T', () => { + const match = springDiFieldMatcher( + matcherNode({ + name: 'plugins', + rawDeclaredType: 'Map', + annotations: ['@Inject'], + }), + ); + // The Map wrapper and the @Inject annotation are visible in the reason. + expect(match).toEqual({ + elementTypeName: 'IPlugin', + reason: 'Spring DI: @Inject Map', + }); + }); + + it('returns null for a non-annotated collection field', () => { + expect( + springDiFieldMatcher(matcherNode({ name: 'cache', rawDeclaredType: 'List' })), + ).toBe(null); + }); + + it('returns null for @Resource (deliberate exclusion) and other non-injection annotations', () => { + expect( + springDiFieldMatcher( + matcherNode({ name: 'named', rawDeclaredType: 'List', annotations: ['@Resource'] }), + ), + ).toBe(null); + expect( + springDiFieldMatcher( + matcherNode({ name: 'q', rawDeclaredType: 'List', 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', { collectionType: 'List', elementTypeName: 'IFoo' }], + ['plain Set', 'Set', { collectionType: 'Set', elementTypeName: 'IFoo' }], + [ + 'plain Collection', + 'Collection', + { collectionType: 'Collection', elementTypeName: 'IFoo' }, + ], + ['plain Map', 'Map', { 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, IFoo>', { collectionType: 'Map', elementTypeName: 'IFoo' }], + // Bounded wildcards — idiomatic Spring collection injection. + [ + 'upper-bounded wildcard', + 'List', + { collectionType: 'List', elementTypeName: 'IFoo' }, + ], + [ + 'lower-bounded wildcard', + 'List', + { 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', { 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', + { collectionType: 'List', elementTypeName: 'IFoo' }, + ], + [ + 'qualified Map wrapper', + 'java.util.Map', + { collectionType: 'Map', elementTypeName: 'IFoo' }, + ], + // Dotted ELEMENT keeps its dots — resolved via qualifiedName downstream. + [ + 'qualified element', + 'List', + { collectionType: 'List', elementTypeName: 'com.a.Shape' }, + ], + [ + 'wildcard + qualified element', + 'Set', + { 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>'], + ['nested-generic behind wildcard', 'List>'], + // 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[]'], + ['array element', 'List'], + // Non-collection types. + ['bare interface', 'IFoo'], + ['non-collection wrapper', 'Optional'], + // Wrong generic arity. + ['Map with one argument', 'Map'], + ['List with two arguments', 'List'], + ['empty argument list', 'List<>'], + // Block comments inside generics are not stripped — fail closed. + ['block comment in generics', 'List'], + // Unbalanced brackets — fail closed. + ['unbalanced brackets', 'List>'], + ])('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', + annotations: ['@Autowired'], + language: 'python', + }), + ); + expect(match).toMatchObject({ + elementTypeName: 'IFoo', + reason: 'Spring DI: @Autowired List', + }); + }); +}); diff --git a/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts b/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts index 679dab52c..51efd6d1f 100644 --- a/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts +++ b/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts @@ -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, ); diff --git a/gitnexus/test/unit/lbug-delete-all-error.test.ts b/gitnexus/test/unit/lbug-delete-all-error.test.ts new file mode 100644 index 000000000..962f33e7a --- /dev/null +++ b/gitnexus/test/unit/lbug-delete-all-error.test.ts @@ -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'); + }); +}); diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index 0dd55c4f2..9d7206ace 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -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', () => { diff --git a/gitnexus/test/unit/security.test.ts b/gitnexus/test/unit/security.test.ts index 906ae305e..b6b1c8ff9 100644 --- a/gitnexus/test/unit/security.test.ts +++ b/gitnexus/test/unit/security.test.ts @@ -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