diff --git a/AGENTS.md b/AGENTS.md index f212fae0c..f4fbcadc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING. ## Reference docs - **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)** +- **Call-resolution DAG:** See ARCHITECTURE.md § Call-Resolution DAG. Typed 6-stage DAG inside the `parse` phase; language-specific behavior behind `inferImplicitReceiver` / `selectDispatch` hooks on `LanguageProvider`. Shared code in `gitnexus/src/core/ingestion/` must not name languages. Types: `gitnexus/src/core/ingestion/call-types.ts`. - **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated. - **GitNexus:** skills in `.claude/skills/gitnexus/`; MCP rules in `gitnexus:start` block below. @@ -88,6 +89,7 @@ Indexed as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows) - Ignore HIGH/CRITICAL risk warnings. - Rename with find-and-replace — use `gitnexus_rename`. - Commit without `gitnexus_detect_changes()`. +- Add language-specific behavior to shared ingestion code (`gitnexus/src/core/ingestion/`) — use a `LanguageProvider` hook. Seeing `provider.mroStrategy === 'xxx'` or an import from `languages/xxx.ts` in shared code means stop and add a hook. ## Tools Quick Reference diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 76fd3bcb6..d934cd8b6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -142,6 +142,71 @@ export const myPhase: PipelinePhase = { --- +## Call-Resolution DAG + +Typed 6-stage pipeline in `call-processor.ts` (inside the `parse` phase) that resolves method/function calls and emits CALLS edges. Language behavior plugs in at two `LanguageProvider` hook points (stages 3–4); shared code names no languages. Scope: call resolution only — import resolution, type extraction, heritage, and symbol-table population live in other phases. + +### Stages + +``` +extract-call ──▶ classify-form ──▶ infer-receiver ──▶ select-dispatch ──▶ resolve-target ──▶ emit-edge + (1) (2) (3) [hook] (4) [hook] (5) (6) +``` + +| Stage | Produces | Location | +|-------|----------|----------| +| **extract-call** | `ExtractedCallSite` (name, form, receiver, argCount) | `call-extractors/` (per-language); runs in worker | +| **classify-form** | callForm (`free`/`member`/`constructor`) + arity | `call-analysis.ts` → `inferCallForm`; shared, runs in worker | +| **infer-receiver** | `ReceiverEnriched` (receiver type finalized) | `call-processor.ts`; shared default chain, then `inferImplicitReceiver` hook | +| **select-dispatch** | `DispatchDecision` (primary, fallback, ancestryView) | `selectDispatch` hook, falls back to shared default | +| **resolve-target** | `TieredCandidates` | `model/resolve.ts` → `lookupMethodByOwnerWithMRO` (MRO walk) | +| **emit-edge** | CALLS edge in graph | `call-processor.ts`; writes edge with confidence tier | + +### Provider hooks + +Both hooks are optional on `LanguageProvider`. Ruby is the only current implementer. + +**`inferImplicitReceiver`** — called after shared infer-receiver defaults. Returns `ImplicitReceiverOverride | null`. + +| | | +|---|---| +| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `callNode` (AST), `filePath` | +| Non-null fields | `callForm`, `receiverName`, `receiverTypeName` (required); `receiverSource: 'implicit-self'` (fixed); `hint?` (opaque, passed to `selectDispatch`) | +| Null | Keep existing `ReceiverEnriched` state | + +**`selectDispatch`** — called after infer-receiver (including hook). Returns `DispatchDecision | null`; null uses shared default (constructor → `primary:'constructor'`; typed receiver → `primary:'owner-scoped'`; else → `primary:'free'`). + +| | | +|---|---| +| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `receiverSource`, `hint` | +| Non-null fields | `primary: 'owner-scoped' \| 'free' \| 'constructor'`; `fallback?: 'free-arity-narrowed'`; `ancestryView?: 'instance' \| 'singleton'`; `hint?` | + +**`DispatchDecision` field semantics:** +- `primary: 'owner-scoped'` — MRO walk from receiver's type; used when receiver type is known. +- `fallback: 'free-arity-narrowed'` — after owner-scoped miss, search free-call candidates by arity only (Ruby uses this for implicit-self calls that miss their owner's MRO). +- `ancestryView: 'singleton'` — walk singleton/class ancestry instead of instance ancestry (Ruby `def self.foo` bodies, so `extend`-ed methods are found). + +### Adding language behavior + +1. **Implicit receivers** — implement `inferImplicitReceiver`: return null if call already has a receiver; otherwise use `findEnclosingClassInfo` (`ast-helpers.ts`) to find the enclosing context, return `ImplicitReceiverOverride` with `receiverSource: 'implicit-self'`, and optionally set `hint` for `selectDispatch`. +2. **Custom dispatch** — implement `selectDispatch`: inspect `receiverSource` and `hint`, return `DispatchDecision` with `primary`, optional `fallback`, optional `ancestryView`; return null to keep shared defaults. +3. **MRO strategy** — confirm `mroStrategy` is `'first-wins'`, `'c3'`, `'ruby-mixin'`, or `'none'`; consumed by `lookupMethodByOwnerWithMRO`. + +**Ruby example** (`languages/ruby.ts` + `utils/ruby-self-call.ts`): `inferImplicitReceiver` rewrites bare-identifier calls to `self.method` and sets `hint` to `'instance'`/`'singleton'`; `selectDispatch` uses hint for `ancestryView` and adds `fallback: 'free-arity-narrowed'` for implicit-self calls. + +### Code references + +| Module | Purpose | +|--------|---------| +| `core/ingestion/call-types.ts` | DAG types: `ReceiverEnriched`, `DispatchDecision`, `ImplicitReceiverOverride` | +| `core/ingestion/language-provider.ts` | Hook signatures: `inferImplicitReceiver`, `selectDispatch` | +| `core/ingestion/call-processor.ts` | `processCalls`: stages 3–6 | +| `core/ingestion/model/resolve.ts` | `lookupMethodByOwnerWithMRO`: stage 5 MRO walk | +| `core/ingestion/languages/ruby.ts` | Both hooks + `mroStrategy: 'ruby-mixin'` | +| `core/ingestion/utils/ruby-self-call.ts` | Bare-call rewrite for `inferImplicitReceiver` | + +--- + ## Language-agnostic graph feeding 16 languages → single unified graph. Four abstraction layers: diff --git a/CLAUDE.md b/CLAUDE.md index 8b6f74ab8..af4069fcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g ## Reference Documentation - **This repository:** [AGENTS.md](AGENTS.md) (Cursor + monorepo notes), [ARCHITECTURE.md](ARCHITECTURE.md), [CONTRIBUTING.md](CONTRIBUTING.md), [GUARDRAILS.md](GUARDRAILS.md). +- **Call-resolution DAG:** See ARCHITECTURE.md § Call-Resolution DAG. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` hooks instead (see AGENTS.md). - **GitNexus:** `.claude/skills/gitnexus/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start` … `gitnexus:end`). See **GitNexus rules** below. ## Changelog @@ -50,206 +51,4 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g ## GitNexus rules -GitNexus MCP rules are in the ` -# GitNexus — Code Intelligence - -This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. - -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. - -## Always Do - -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - -## When Debugging - -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. - -## Never Do - -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | -| `gitnexus://repo/GitNexus/clusters` | All functional areas | -| `gitnexus://repo/GitNexus/processes` | All execution flows | -| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | - -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - -` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index. - - -# GitNexus — Code Intelligence - -This project is indexed by GitNexus as **GitNexus** (3298 symbols, 7954 relationships, 185 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. - -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. - -## Always Do - -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - -## When Debugging - -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. - -## Never Do - -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | -| `gitnexus://repo/GitNexus/clusters` | All functional areas | -| `gitnexus://repo/GitNexus/processes` | All execution flows | -| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | - -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - - +See the `` block in **[AGENTS.md](AGENTS.md)** for the canonical MCP tools, impact analysis rules, and index instructions. diff --git a/gitnexus-shared/src/mro-strategy.ts b/gitnexus-shared/src/mro-strategy.ts index 6168c67c0..7ace9306f 100644 --- a/gitnexus-shared/src/mro-strategy.ts +++ b/gitnexus-shared/src/mro-strategy.ts @@ -1,23 +1,46 @@ /** - * MRO (Method Resolution Order) strategy — shared between CLI and any - * future consumer that reasons about multiple-inheritance semantics. + * MRO (Method Resolution Order) strategy — shared canonical definition. * - * Lives in `gitnexus-shared` so the low-level resolution module - * (`core/ingestion/model/resolve.ts`) does not need to import from - * `languages/` — keeping the `model/` layer free of language-registry - * coupling. + * Lives in `gitnexus-shared` so `model/resolve.ts` and `mro-processor.ts` share + * the type without importing the language registry (avoids circular coupling). * - * Strategy semantics: - * - `first-wins`: BFS ancestor walk, first match wins (default). - * - `leftmost-base`: BFS ancestor walk, leftmost base wins (C++). - * - `c3`: C3-linearized ancestor order, first match wins (Python). - * - `implements-split`: BFS walk, first match wins (Java/C#/Kotlin) — full - * interface-default ambiguity is handled at graph level. - * - `qualified-syntax`: No auto-resolution (Rust — requires `::m`). + * `first-wins` (default, Java/C#/Kotlin/Go/Swift/Dart): + * BFS ancestor walk in declaration order; first match wins. + * + * `leftmost-base` (C++): + * BFS walk; HeritageMap preserves source insertion order, so BFS naturally + * picks the leftmost base in diamond inheritance. + * + * `c3` (Python): + * C3-linearization; falls back to BFS on cyclic/inconsistent hierarchy. + * See model/resolve.ts § c3Linearize. + * + * `implements-split` (Java/C#/Kotlin): + * Low-level lookup is BFS; graph-level mro-processor detects and warns on + * interface-default method ambiguity. + * + * `qualified-syntax` (Rust): + * No auto-resolution — `lookupMethodByOwnerWithMRO` returns undefined immediately. + * Rust requires explicit `::method` syntax. + * + * `ruby-mixin` (Ruby): + * Kind-aware walk that does NOT short-circuit on direct owner first (`prepend` + * must beat the class's own method). Walk order: + * 1. Prepend providers (reverse declaration — last-prepended wins) + * 2. Direct owner's own methods + * 3. Include providers (reverse declaration) + * 4. Transitive ancestors (BFS fallback) + * Singleton dispatch: caller passes `ancestryOverride` (extend providers only); + * becomes a simple left-to-right scan. Miss NEVER falls through to file-scoped + * lookup — null-routes or honors `fallback`. + * + * @see model/resolve.ts § lookupMethodByOwnerWithMRO + * @see languages/ruby.ts § selectDispatch */ export type MroStrategy = | 'first-wins' | 'c3' | 'leftmost-base' | 'implements-split' - | 'qualified-syntax'; + | 'qualified-syntax' + | 'ruby-mixin'; diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 6c9191970..c30d83848 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -7,6 +7,32 @@ import type { ExtractedHeritage, } from './model/index.js'; import { CLASS_TYPES, CALL_TARGET_TYPES, lookupMethodByOwnerWithMRO } from './model/index.js'; +import type { DispatchDecision, ReceiverEnriched } from './call-types.js'; + +/** Shorthand for the receiver-source discriminant shared across the DAG. */ +type ReceiverSource = ReceiverEnriched['receiverSource']; + +/** + * DAG stage 4 fallback: used when `selectDispatch` is absent or returns null. + * Preserves pre-DAG dispatch semantics: + * - 'constructor' → constructor branch + * - 'free' → free branch (admits Swift/Kotlin class-target fast path) + * - 'member' or undefined → owner-scoped branch + * + * `undefined` callForm MUST route through owner-scoped (not free) so bare + * identifiers without a classified shape do NOT trigger `resolveFreeCall`'s + * class-target fast path. Without a `receiverTypeName`, the owner-scoped + * branch falls through to `resolveModuleAliasedCall` + `singleCandidate`, + * matching legacy behavior where non-callable symbols (Class, Interface) + * null-route instead of producing spurious Constructor edges. + */ +const defaultDispatchDecision = ( + callForm: 'free' | 'member' | 'constructor' | undefined, +): DispatchDecision => { + if (callForm === 'constructor') return { primary: 'constructor' }; + if (callForm === 'free') return { primary: 'free' }; + return { primary: 'owner-scoped' }; +}; import Parser from 'tree-sitter'; import type { ResolutionContext } from './model/resolution-context.js'; import { TIER_CONFIDENCE, type ResolutionTier } from './model/resolution-context.js'; @@ -766,22 +792,26 @@ export const processCalls = async ( // Extract heritage from query matches to build parentMap for buildTypeEnv. // Heritage-processor runs in PARALLEL, so graph edges don't exist when buildTypeEnv runs. const fileParentMap = new Map(); - for (const match of matches) { - const captureMap: Record = {}; - match.captures.forEach((c) => (captureMap[c.name] = c.node)); - if (captureMap['heritage.class'] && captureMap['heritage.extends']) { - const className: string = captureMap['heritage.class'].text; - const parentName: string = captureMap['heritage.extends'].text; - const extendsNode = captureMap['heritage.extends']; - const fieldDecl = extendsNode.parent; - if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) - continue; - let parents = fileParentMap.get(className); - if (!parents) { - parents = []; - fileParentMap.set(className, parents); + if (provider.heritageExtractor) { + for (const match of matches) { + const captureMap: Record = {}; + match.captures.forEach((c) => (captureMap[c.name] = c.node)); + if (captureMap['heritage.class']) { + const heritageItems = provider.heritageExtractor.extract(captureMap, { + filePath: file.path, + language, + }); + for (const item of heritageItems) { + if (item.kind === 'extends') { + let parents = fileParentMap.get(item.className); + if (!parents) { + parents = []; + fileParentMap.set(item.className, parents); + } + if (!parents.includes(item.parentName)) parents.push(item.parentName); + } + } } - if (!parents.includes(parentName)) parents.push(parentName); } } const parentMap: ReadonlyMap = fileParentMap; @@ -991,6 +1021,28 @@ export const processCalls = async ( const calledName = nameNode.text; + // Check heritage extractor for call-based heritage (e.g., Ruby include/extend/prepend) + if (provider.heritageExtractor?.extractFromCall) { + const heritageItems = provider.heritageExtractor.extractFromCall( + calledName, + captureMap['call'], + { filePath: file.path, language }, + ); + if (heritageItems !== null) { + for (const item of heritageItems) { + collectedHeritage.push({ + filePath: file.path, + className: item.className, + parentName: item.parentName, + kind: item.kind, + }); + } + return; + } + } + + // Dispatch: route language-specific calls (properties, imports) + // Heritage routing is handled by heritageExtractor.extractFromCall above. const routed = callRouter?.(calledName, captureMap['call']); if (routed) { switch (routed.kind) { @@ -998,17 +1050,6 @@ export const processCalls = async ( case 'import': return; - case 'heritage': - for (const item of routed.items) { - collectedHeritage.push({ - filePath: file.path, - className: item.enclosingClass, - parentName: item.mixinName, - kind: item.heritageKind, - }); - } - return; - case 'properties': { const fileId = generateId('File', file.path); const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path); @@ -1061,10 +1102,17 @@ export const processCalls = async ( if (provider.isBuiltInName(calledName)) return; - const callForm = inferCallForm(callNode, nameNode); - const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined; + // --- DAG stage 2-3: classify-form + infer-receiver (shared defaults) --- + // These stages run the shared inference chain. Language providers can + // customize infer-receiver (stage 3) via the inferImplicitReceiver hook + // which runs AFTER this default chain (typed-binding → constructor-map → + // module-alias → class-as-receiver → mixed-chain), and selectDispatch + // (stage 4) which picks the resolver branch. + let callForm = inferCallForm(callNode, nameNode); + let receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined; let receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined; + let receiverSource: ReceiverSource = receiverTypeName ? 'typed-binding' : 'none'; // Phase P: virtual dispatch override — when the declared type is a base class but // the constructor created a known subclass, prefer the more specific type. // Checks per-file parentMap first, then falls back to globalParentMap for @@ -1103,6 +1151,7 @@ export const processCalls = async ( ctx.model.types.lookupClassByName(receiverTypeName).length > 0) ) { receiverTypeName = ctorType; + receiverSource = 'constructor-map'; } } } @@ -1111,10 +1160,14 @@ export const processCalls = async ( const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx, provider); const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : ''; receiverTypeName = lookupReceiverType(receiverIndex, funcName, receiverName); + if (receiverTypeName) receiverSource = 'constructor-map'; } - // Fall back to class-as-receiver for static method calls (e.g. UserService.find_user()). - // When the receiver name is not a variable in TypeEnv but resolves to a Class/Struct/Interface - // through the standard tiered resolution, use it directly as the receiver type. + // Fall back to class-as-receiver for static method calls (e.g. UserService.find_user(), + // Greetable.format()). When the receiver name is not a variable in TypeEnv but + // resolves to a class-like symbol (Class / Interface / Struct / Enum / Trait) via + // tiered resolution, use it directly as the receiver type. `Trait` is included so + // Ruby module class-method calls flow through the class-as-receiver path and reach + // the `selectDispatch` hook's singleton branch. if (!receiverTypeName && receiverName && callForm === 'member') { const typeResolved = ctx.resolve(receiverName, file.path); if ( @@ -1124,10 +1177,12 @@ export const processCalls = async ( d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || - d.type === 'Enum', + d.type === 'Enum' || + d.type === 'Trait', ) ) { receiverTypeName = receiverName; + receiverSource = 'class-as-receiver'; } } // Hoist sourceId so it's available for ACCESSES edge emission during chain walk. @@ -1173,11 +1228,51 @@ export const processCalls = async ( makeAccessEmitter(graph, sourceId), heritageMap, ); + if (receiverTypeName) receiverSource = 'mixed-chain'; } } } } + // --- DAG stage 3: infer-receiver (provider hook) --- + // Synthesize implicit receivers for languages that omit them (e.g., Ruby bare-call). + // This hook runs AFTER the shared inference chain so explicit receivers / + // typed bindings always take precedence. Output (if non-null) overlays onto + // the ReceiverEnriched for the next stage. + let dispatchHint: string | undefined; + if (provider.inferImplicitReceiver) { + const override = provider.inferImplicitReceiver({ + calledName, + callForm, + receiverName, + receiverTypeName, + callNode, + filePath: file.path, + }); + if (override) { + callForm = override.callForm; + receiverName = override.receiverName; + receiverTypeName = override.receiverTypeName; + receiverSource = override.receiverSource; + dispatchHint = override.hint; + } + } + + // --- DAG stage 4: select-dispatch (provider hook + default fallback) --- + // Decide which resolver path to try first (primary) and fallback strategy. + // Language providers can customize dispatch via selectDispatch hook; all + // others use the shared defaultDispatchDecision. Always non-null after this + // block so downstream resolvers are table-driven. + const dispatchDecision: DispatchDecision = + provider.selectDispatch?.({ + calledName, + callForm, + receiverName, + receiverTypeName, + receiverSource, + hint: dispatchHint, + }) ?? defaultDispatchDecision(callForm); + // Build overload hints for languages with inferLiteralType (Java/Kotlin/C#/C++). // Only used when multiple candidates survive arity filtering — ~1-3% of calls. const langConfig = provider.typeConfig; @@ -1199,6 +1294,7 @@ export const processCalls = async ( widenCache, undefined, heritageMap, + dispatchDecision, ); if (!resolved) return; @@ -1737,11 +1833,20 @@ const resolveCallTarget = ( widenCache?: WidenCache, preComputedArgTypes?: (string | undefined)[], heritageMap?: HeritageMap, + dispatchDecision?: DispatchDecision, ): ResolveResult | null => { const tiered = ctx.resolve(call.calledName, currentFile); if (!tiered) return null; - if (call.callForm === 'free') { + // DAG dispatch: use decision.primary to pick the resolver branch. + // Callers that own the DAG (processCalls + crossFile deferred paths) + // pass a decision; other callers use the shared default ladder. + // Language-specific primary / fallback / ancestryView overrides come from + // the provider's `selectDispatch` hook. + const decision = dispatchDecision ?? defaultDispatchDecision(call.callForm); + const primary = decision.primary; + + if (primary === 'free') { return resolveFreeCall( call.calledName, currentFile, @@ -1752,7 +1857,7 @@ const resolveCallTarget = ( preComputedArgTypes, ); } - if (call.callForm === 'constructor') { + if (primary === 'constructor') { return ( resolveStaticCall( call.calledName, @@ -1765,6 +1870,7 @@ const resolveCallTarget = ( ) ?? singleCandidate(tiered, call.argCount, 'constructor') ); } + // primary === 'owner-scoped' if (call.receiverTypeName) { // Skip the owner-scoped MRO path when the tiered pool has genuine // overload ambiguity that needs D1-D4+E handling, not D0. @@ -1772,6 +1878,15 @@ const resolveCallTarget = ( (!!overloadHints || !!preComputedArgTypes) && countCallableCandidates(tiered.candidates, call.argCount, call.callForm) > 1; // Try owner-scoped (resolveMemberCall) then file-scoped (resolveMemberCallByFile). + // DAG: dispatchDecision.ancestryView selects instance vs singleton ancestry + // for kind-aware MRO strategies. Ruby `Account.log` flows via 'singleton'. + // + // Singleton-ancestry miss MUST NOT degrade to the file-scoped fallback: + // resolveMemberCallByFile matches by ownerId and would happily pick an + // instance method defined on the same class, leaking instance dispatch + // onto what was declared a class-method call. For singleton dispatch, + // a miss either null-routes or falls through to `decision.fallback`. + const singletonDispatch = decision.ancestryView === 'singleton'; const memberResult = (!skipMember ? resolveMemberCall( @@ -1781,18 +1896,21 @@ const resolveCallTarget = ( ctx, heritageMap, call.argCount, + decision.ancestryView, ) : null) ?? - resolveMemberCallByFile( - call.calledName, - call.receiverTypeName, - currentFile, - ctx, - call.argCount, - call.callForm, - overloadHints, - preComputedArgTypes, - ); + (singletonDispatch + ? null + : resolveMemberCallByFile( + call.calledName, + call.receiverTypeName, + currentFile, + ctx, + call.argCount, + call.callForm, + overloadHints, + preComputedArgTypes, + )); if (memberResult) return memberResult; // Module-alias narrowing runs as a FALLBACK, after owner/file-scoped @@ -1828,7 +1946,26 @@ const resolveCallTarget = ( // hierarchy. When the type is NOT in the index (PHP `mixed`, dynamic // types, unresolvable aliases), the scoped resolvers had nothing to // work with and singleCandidate is the correct last resort. + // + // DAG fallback override: when `select-dispatch` returned + // `fallback: 'free-arity-narrowed'` (today: Ruby implicit-self bare + // calls whose enclosing class doesn't define the method), fall through + // to free-call resolution instead of null-routing. This preserves + // existing free-call arity-narrowing heuristics for bare calls that + // happen to target methods on unrelated classes. if (typeResolves && typeResolves.candidates.length > 0) { + if (decision.fallback === 'free-arity-narrowed') { + const free = resolveFreeCall( + call.calledName, + currentFile, + ctx, + call.argCount, + tiered, + overloadHints, + preComputedArgTypes, + ); + if (free) return free; + } return null; // null-route: type resolved, no candidate matched } return singleCandidate(tiered, call.argCount, call.callForm); @@ -2024,6 +2161,13 @@ const resolveMethodByOwner = ( ctx: ResolutionContext, heritageMap?: HeritageMap, argCount?: number, + /** + * DAG-sourced ancestry selector. `'singleton'` routes through + * `heritageMap.getSingletonAncestry(owner)` for class-method dispatch + * (Ruby `Account.log` via `extend LoggerMixin`). Default / undefined + * uses the walker's instance-dispatch behavior. + */ + ancestryView?: 'instance' | 'singleton', ): { def: SymbolDefinition; tier: ResolutionTier } | undefined => { const typeResolved = ctx.resolve(receiverTypeName, filePath); if (!typeResolved) return undefined; @@ -2052,6 +2196,14 @@ const resolveMethodByOwner = ( let ambiguous = false; for (const candidate of typeResolved.candidates) { if (!CLASS_LIKE_TYPES.has(candidate.type)) continue; + // Singleton dispatch: when the DAG decision requested the singleton + // ancestry view, pass `heritageMap.getSingletonAncestry` as the walker's + // ancestry override. Kind-aware strategies (e.g. MroStrategy 'ruby-mixin') + // honor the override by scanning it linearly in place of their default walk. + const singletonOverride = + ancestryView === 'singleton' && canWalkMRO && heritageMap + ? heritageMap.getSingletonAncestry(candidate.nodeId).map((e) => e.parentId) + : undefined; const def = canWalkMRO ? lookupMethodByOwnerWithMRO( candidate.nodeId, @@ -2060,6 +2212,7 @@ const resolveMethodByOwner = ( ctx.model, mroStrategy, argCount, + singletonOverride, ) : ctx.model.methods.lookupMethodByOwner(candidate.nodeId, methodName, argCount); if (!def) continue; @@ -2114,6 +2267,7 @@ export const resolveMemberCall = ( ctx: ResolutionContext, heritageMap?: HeritageMap, argCount?: number, + ancestryView?: 'instance' | 'singleton', ): ResolveResult | null => { const resolved = resolveMethodByOwner( ownerType, @@ -2122,6 +2276,7 @@ export const resolveMemberCall = ( ctx, heritageMap, argCount, + ancestryView, ); if (!resolved) return null; return toResolveResult(resolved.def, resolved.tier); diff --git a/gitnexus/src/core/ingestion/call-routing.ts b/gitnexus/src/core/ingestion/call-routing.ts index 3922c0c89..f9e60df10 100644 --- a/gitnexus/src/core/ingestion/call-routing.ts +++ b/gitnexus/src/core/ingestion/call-routing.ts @@ -1,10 +1,14 @@ /** * Shared Ruby call routing logic. * - * Ruby expresses imports, heritage (mixins), and property definitions as - * method calls rather than syntax-level constructs. This module provides a - * routing function used by the CLI call-processor, CLI parse-worker, and - * the web call-processor so that the classification logic lives in one place. + * Ruby expresses imports and property definitions as method calls rather + * than syntax-level constructs. This module provides a routing function + * used by the CLI call-processor, CLI parse-worker, and the web + * call-processor so that the classification logic lives in one place. + * + * Heritage (mixins: include/extend/prepend) was previously routed here + * but is now handled by heritageExtractor.extractFromCall before the + * call router runs. The router still returns 'skip' for these calls. * * NOTE: This file is intentionally duplicated in gitnexus-web/ because the * two packages have separate build targets (Node native vs WASM/browser). @@ -30,17 +34,10 @@ export type CallRouter = (calledName: string, callNode: SyntaxNode) => CallRouti export type RubyCallRouting = | { kind: 'import'; importPath: string; isRelative: boolean } - | { kind: 'heritage'; items: RubyHeritageItem[] } | { kind: 'properties'; items: RubyPropertyItem[] } | { kind: 'call' } | { kind: 'skip' }; -export interface RubyHeritageItem { - enclosingClass: string; - mixinName: string; - heritageKind: 'include' | 'extend' | 'prepend'; -} - export type RubyAccessorType = 'attr_accessor' | 'attr_reader' | 'attr_writer'; export interface RubyPropertyItem { @@ -56,9 +53,6 @@ export interface RubyPropertyItem { const CALL_RESULT: RubyCallRouting = { kind: 'call' }; const SKIP_RESULT: RubyCallRouting = { kind: 'skip' }; -/** Max depth for parent-walking loops to prevent pathological AST traversals */ -const MAX_PARENT_DEPTH = 50; - // ── Routing function ──────────────────────────────────────────────────────── /** @@ -88,35 +82,12 @@ export function routeRubyCall(calledName: string, callNode: SyntaxNode): RubyCal return { kind: 'import', importPath, isRelative }; } - // ── include / extend / prepend → heritage (mixin) ────────────────────── + // ── include / extend / prepend — heritage (now handled by heritageExtractor) ─ + // Call-based heritage is intercepted by heritageExtractor.extractFromCall + // before the call router runs. Return SKIP_RESULT so these calls don't + // fall through to normal call processing. if (calledName === 'include' || calledName === 'extend' || calledName === 'prepend') { - let enclosingClass: string | null = null; - let current = callNode.parent; - let depth = 0; - while (current && ++depth <= MAX_PARENT_DEPTH) { - if (current.type === 'class' || current.type === 'module') { - const nameNode = current.childForFieldName?.('name'); - if (nameNode) { - enclosingClass = nameNode.text; - break; - } - } - current = current.parent; - } - if (!enclosingClass) return SKIP_RESULT; - - const items: RubyHeritageItem[] = []; - const argList = callNode.childForFieldName?.('arguments'); - for (const arg of argList?.children ?? []) { - if (arg.type === 'constant' || arg.type === 'scope_resolution') { - items.push({ - enclosingClass, - mixinName: arg.text, - heritageKind: calledName as 'include' | 'extend' | 'prepend', - }); - } - } - return items.length > 0 ? { kind: 'heritage', items } : SKIP_RESULT; + return SKIP_RESULT; } // ── attr_accessor / attr_reader / attr_writer → property definitions ─── diff --git a/gitnexus/src/core/ingestion/call-types.ts b/gitnexus/src/core/ingestion/call-types.ts index da175720d..b42ecbf16 100644 --- a/gitnexus/src/core/ingestion/call-types.ts +++ b/gitnexus/src/core/ingestion/call-types.ts @@ -78,3 +78,100 @@ export interface CallExtractionConfig { */ typeAsReceiverHeuristic?: boolean; } + +// --------------------------------------------------------------------------- +// Call-resolution DAG types +// --------------------------------------------------------------------------- +// +// The call-resolution pipeline is a typed DAG: +// +// extract-call ──▶ classify-form ──▶ infer-receiver ──▶ select-dispatch ──▶ resolve-target ──▶ emit-edge +// +// Provider hooks plug in at infer-receiver and select-dispatch; shared stages +// stay language-agnostic. Stages 1-2 run in the parse worker; stages 3-6 run +// on the main thread. DAG-internal types below are main-thread-only and never +// serialize to the graph. + +/** + * DAG stage 3 output: call record with receiver type and source discriminant. + * + * `receiverTypeName` is resolved via TypeEnv → constructor-map → class-as-receiver → + * mixed-chain, or synthesized by `inferImplicitReceiver`. `receiverSource` tags + * which path won and drives MRO strategy selection in stage 4. + * + * Invariants: + * - `receiverSource` MUST match how `receiverTypeName` was resolved; every + * discriminant must have a live reader and writer. + * - `hint` is opaque to shared stages; only the same provider's `selectDispatch` reads it. + * + * @see language-provider.ts § inferImplicitReceiver, selectDispatch + */ +export interface ReceiverEnriched { + readonly calledName: string; + readonly callForm: 'free' | 'member' | 'constructor' | undefined; + readonly receiverName: string | undefined; + readonly receiverTypeName: string | undefined; + readonly receiverSource: + | 'none' + | 'typed-binding' + | 'constructor-map' + | 'class-as-receiver' + | 'mixed-chain' + | 'implicit-self'; + /** Free-form hint from the provider hook; opaque to shared stages. */ + readonly hint?: string; +} + +/** + * Provider hook output for `LanguageProvider.inferImplicitReceiver` (DAG stage 3). + * + * Overlay applied to `ReceiverEnriched` when an implicit receiver is synthesized. + * Ruby example: bare `serialize` inside `Account#call_serialize` → + * `{ callForm: 'member', receiverName: 'self', receiverTypeName: 'Account', + * receiverSource: 'implicit-self', hint: 'instance' }` + * + * Invariants: + * - `receiverSource` is always `'implicit-self'` — the only variant this type produces. + * - `callForm` is always `'member'` — the rewrite converts bare-call to method invocation. + * - `hint` is opaque to shared stages; consumed by the same language's `selectDispatch`. + */ +export interface ImplicitReceiverOverride { + readonly callForm: 'free' | 'member' | 'constructor'; + readonly receiverName: string; + readonly receiverTypeName: string; + readonly receiverSource: Extract; + /** Free-form language tag (e.g. Ruby sets 'singleton' for `def self.foo` + * method bodies). Consumed by the same language's `selectDispatch` hook. */ + readonly hint?: string; +} + +/** + * DAG stage 4 output: dispatch strategy for resolving the target method. + * + * Encodes which resolver branch to try first and an optional fallback. + * Stage 5 delegates to `resolveMemberCall`, `resolveFreeCall`, or + * `resolveStaticCall` based on `primary`. + * + * - `primary`: `'owner-scoped'` = MRO walk, `'free'` = arity-tiered global lookup, + * `'constructor'` = type instantiation. + * - `fallback`: Only `'free-arity-narrowed'` exists; used by Ruby implicit-self + * to degrade to arity-tiered free lookup when the MRO walk misses. + * - `ancestryView`: Ruby `'ruby-mixin'` only. `'singleton'` walks extend providers + * only; a miss NEVER falls through to file-scoped lookup (enforced in + * resolveCallTarget). `'instance'` is the default. + * + * Common patterns: + * - `{primary: 'constructor'}` — constructor call + * - `{primary: 'owner-scoped'}` — member call with known type + * - `{primary: 'owner-scoped', fallback: 'free-arity-narrowed', ancestryView: 'instance'}` — Ruby implicit-self + * - `{primary: 'owner-scoped', ancestryView: 'singleton'}` — Ruby class-method call + * + * @see language-provider.ts § selectDispatch + * @see call-processor.ts § defaultDispatchDecision, resolveCallTarget + */ +export interface DispatchDecision { + readonly primary: 'owner-scoped' | 'free' | 'constructor'; + readonly fallback?: 'free-arity-narrowed'; + readonly ancestryView?: 'instance' | 'singleton'; + readonly hint?: string; +} diff --git a/gitnexus/src/core/ingestion/heritage-extractors/configs/go.ts b/gitnexus/src/core/ingestion/heritage-extractors/configs/go.ts new file mode 100644 index 000000000..63768172d --- /dev/null +++ b/gitnexus/src/core/ingestion/heritage-extractors/configs/go.ts @@ -0,0 +1,24 @@ +// gitnexus/src/core/ingestion/heritage-extractors/configs/go.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { HeritageExtractionConfig } from '../../heritage-types.js'; + +/** + * Go heritage extraction config. + * + * Go struct embedding: the tree-sitter query matches ALL field_declarations + * with type_identifier, but only anonymous fields (no name) are embedded. + * Named fields like `Breed string` also match — skip them. + * + * The shouldSkipExtends hook checks if the extends node's parent is a + * field_declaration with a named field child, indicating a regular + * (non-embedded) field that should not produce a heritage record. + */ +export const goHeritageConfig: HeritageExtractionConfig = { + language: SupportedLanguages.Go, + + shouldSkipExtends(extendsNode) { + const fieldDecl = extendsNode.parent; + return fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName?.('name') != null; + }, +}; diff --git a/gitnexus/src/core/ingestion/heritage-extractors/configs/ruby.ts b/gitnexus/src/core/ingestion/heritage-extractors/configs/ruby.ts new file mode 100644 index 000000000..41bca1ff2 --- /dev/null +++ b/gitnexus/src/core/ingestion/heritage-extractors/configs/ruby.ts @@ -0,0 +1,73 @@ +// gitnexus/src/core/ingestion/heritage-extractors/configs/ruby.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { HeritageExtractionConfig, HeritageInfo } from '../../heritage-types.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Maximum parent depth for enclosing class/module walk. + * Prevents runaway walks on malformed/deeply-nested ASTs. + */ +const MAX_PARENT_DEPTH = 50; + +/** + * Walk up the AST from a call node to find the enclosing class or module name. + * Ruby include/extend/prepend calls must be inside a class or module body. + */ +function findEnclosingClassName(callNode: SyntaxNode): string | null { + let current = callNode.parent; + let depth = 0; + while (current && ++depth <= MAX_PARENT_DEPTH) { + if (current.type === 'class' || current.type === 'module') { + const nameNode = current.childForFieldName?.('name'); + if (nameNode) return nameNode.text; + } + current = current.parent; + } + return null; +} + +/** Ruby heritage call names that express mixin inclusion. */ +const RUBY_HERITAGE_CALL_NAMES: ReadonlySet = new Set(['include', 'extend', 'prepend']); + +/** + * Ruby heritage extraction config. + * + * Ruby expresses inheritance in two ways, and only one of them has + * dedicated tree-sitter heritage captures: + * + * 1. Class inheritance (`class A < B`) produces standard + * `@heritage.extends` captures and flows through the generic + * capture-based `extract` hook (not defined here — the factory + * handles it). + * 2. Mixin calls (`include`/`extend`/`prepend`) have no dedicated + * heritage captures; they surface as ordinary call sites. The + * `callBasedHeritage` hook below intercepts them before the call + * router, absorbing the mixin routing logic that previously lived + * in call-routing.ts (routeRubyCall). + */ +export const rubyHeritageConfig: HeritageExtractionConfig = { + language: SupportedLanguages.Ruby, + + callBasedHeritage: { + callNames: RUBY_HERITAGE_CALL_NAMES, + + extract(calledName, callNode, _filePath): HeritageInfo[] { + const enclosingClass = findEnclosingClassName(callNode); + if (!enclosingClass) return []; + + const results: HeritageInfo[] = []; + const argList = callNode.childForFieldName?.('arguments'); + for (const arg of argList?.children ?? []) { + if (arg.type === 'constant' || arg.type === 'scope_resolution') { + results.push({ + className: enclosingClass, + parentName: arg.text, + kind: calledName, // 'include' | 'extend' | 'prepend' + }); + } + } + return results; + }, + }, +}; diff --git a/gitnexus/src/core/ingestion/heritage-extractors/generic.ts b/gitnexus/src/core/ingestion/heritage-extractors/generic.ts new file mode 100644 index 000000000..39c3d34c8 --- /dev/null +++ b/gitnexus/src/core/ingestion/heritage-extractors/generic.ts @@ -0,0 +1,84 @@ +// gitnexus/src/core/ingestion/heritage-extractors/generic.ts + +/** + * Generic table-driven heritage extractor factory. + * + * Follows the same config+factory pattern as method-extractors/generic.ts, + * field-extractors/generic.ts, call-extractors/generic.ts, and + * variable-extractors/generic.ts. + * + * Languages with custom extraction hooks (Go: shouldSkipExtends, Ruby: + * callBasedHeritage) pass a full HeritageExtractionConfig. Languages + * that use the default capture-based extraction can pass just the + * SupportedLanguages enum value — no per-language config file needed. + */ + +import type { SupportedLanguages } from 'gitnexus-shared'; +import type { CaptureMap } from '../language-provider.js'; +import type { + HeritageExtractionConfig, + HeritageExtractor, + HeritageExtractorContext, + HeritageInfo, +} from '../heritage-types.js'; +import type { SyntaxNode } from '../utils/ast-helpers.js'; + +/** + * Create a HeritageExtractor from a declarative config or a language enum. + * + * When a full HeritageExtractionConfig is provided, custom hooks + * (shouldSkipExtends, callBasedHeritage) drive the extraction. + * When only a SupportedLanguages value is provided, the factory produces + * a default extractor that handles the standard @heritage.* captures. + */ +export function createHeritageExtractor( + config: HeritageExtractionConfig | SupportedLanguages, +): HeritageExtractor { + const actualConfig: HeritageExtractionConfig = + typeof config === 'string' ? { language: config } : config; + const callNameSet = actualConfig.callBasedHeritage?.callNames; + + return { + language: actualConfig.language, + + extract(captureMap: CaptureMap, context: HeritageExtractorContext): HeritageInfo[] { + const classNode = captureMap['heritage.class']; + if (!classNode) return []; + + const className = classNode.text; + const results: HeritageInfo[] = []; + + const extendsNode = captureMap['heritage.extends']; + if (extendsNode) { + if (!actualConfig.shouldSkipExtends?.(extendsNode)) { + results.push({ className, parentName: extendsNode.text, kind: 'extends' }); + } + } + + const implementsNode = captureMap['heritage.implements']; + if (implementsNode) { + results.push({ className, parentName: implementsNode.text, kind: 'implements' }); + } + + const traitNode = captureMap['heritage.trait']; + if (traitNode) { + results.push({ className, parentName: traitNode.text, kind: 'trait-impl' }); + } + + return results; + }, + + ...(callNameSet + ? { + extractFromCall( + calledName: string, + callNode: SyntaxNode, + context: HeritageExtractorContext, + ): HeritageInfo[] | null { + if (!callNameSet.has(calledName)) return null; + return actualConfig.callBasedHeritage!.extract(calledName, callNode, context.filePath); + }, + } + : {}), + }; +} diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index bc8628fa5..6692d1c95 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -19,7 +19,7 @@ import { ASTCache } from './ast-cache.js'; import Parser from 'tree-sitter'; import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js'; import { generateId } from '../../lib/utils.js'; -import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared'; +import { getLanguageFromFilename, type NodeLabel, type SupportedLanguages } from 'gitnexus-shared'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { yieldToEventLoop } from './utils/event-loop.js'; import { getProvider } from './languages/index.js'; @@ -32,6 +32,7 @@ import type { import { resolveExtendsType } from './model/heritage-map.js'; import type { ResolutionContext } from './model/resolution-context.js'; import { TIER_CONFIDENCE } from './model/resolution-context.js'; +import type { HeritageInfo } from './heritage-types.js'; /** * Derive the heritage-resolution strategy for a language from its @@ -83,6 +84,103 @@ const resolveHeritageId = ( }; }; +/** + * Resolve a single HeritageInfo to a graph edge, using the same resolution + * logic as processHeritageFromExtracted. This bridges the heritage extractor + * output format to the graph-resolution side. + */ +const resolveAndAddHeritageEdge = ( + graph: KnowledgeGraph, + item: HeritageInfo, + filePath: string, + language: SupportedLanguages, + ctx: ResolutionContext, +): void => { + if (item.kind === 'extends') { + const { type: relType, idPrefix } = resolveExtendsType( + item.parentName, + filePath, + ctx, + getHeritageStrategyForLanguage(language), + ); + + const child = resolveHeritageId( + item.className, + filePath, + ctx, + 'Class', + `${filePath}:${item.className}`, + ); + const parent = resolveHeritageId(item.parentName, filePath, ctx, idPrefix); + + if (child.id && parent.id && child.id !== parent.id) { + graph.addRelationship({ + id: generateId(relType, `${child.id}->${parent.id}`), + sourceId: child.id, + targetId: parent.id, + type: relType, + confidence: Math.sqrt(child.confidence * parent.confidence), + reason: '', + }); + } + } else if (item.kind === 'implements') { + const cls = resolveHeritageId( + item.className, + filePath, + ctx, + 'Class', + `${filePath}:${item.className}`, + ); + const iface = resolveHeritageId(item.parentName, filePath, ctx, 'Interface'); + + if (cls.id && iface.id) { + graph.addRelationship({ + id: generateId('IMPLEMENTS', `${cls.id}->${iface.id}`), + sourceId: cls.id, + targetId: iface.id, + type: 'IMPLEMENTS', + confidence: Math.sqrt(cls.confidence * iface.confidence), + reason: '', + }); + } + } else if ( + item.kind === 'trait-impl' || + item.kind === 'include' || + item.kind === 'extend' || + item.kind === 'prepend' + ) { + // Fallback label for an unresolved child name. Rust `trait-impl` children + // are structs; Ruby mixin children are classes or modules (Trait). For + // Ruby mixin kinds the common case resolves through the type registry + // post-plan-001, so the fallback only fires for true-unresolved references + // (e.g. mixin inside a singleton_class). `Class` is strictly better than + // `Struct` there because it matches the label the structure phase would + // emit for a Ruby `class` — the dominant shape. Ruby modules that fail + // to resolve still lose their `Trait` label in the synthesized id, but + // they fail to resolve rarely and the tradeoff is documented. + const childFallbackLabel: NodeLabel = item.kind === 'trait-impl' ? 'Struct' : 'Class'; + const strct = resolveHeritageId( + item.className, + filePath, + ctx, + childFallbackLabel, + `${filePath}:${item.className}`, + ); + const trait = resolveHeritageId(item.parentName, filePath, ctx, 'Trait'); + + if (strct.id && trait.id) { + graph.addRelationship({ + id: generateId('IMPLEMENTS', `${strct.id}->${trait.id}:${item.kind}`), + sourceId: strct.id, + targetId: trait.id, + type: 'IMPLEMENTS', + confidence: Math.sqrt(strct.confidence * trait.confidence), + reason: item.kind, + }); + } + } +}; + export const processHeritage = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], @@ -135,112 +233,32 @@ export const processHeritage = async ( let query; let matches; try { - const language = parser.getLanguage(); - query = new Parser.Query(language, queryStr); + const treeSitterLang = parser.getLanguage(); + query = new Parser.Query(treeSitterLang, queryStr); matches = query.matches(tree.rootNode); } catch (queryError) { console.warn(`Heritage query error for ${file.path}:`, queryError); continue; } - // 4. Process heritage matches + // 4. Process heritage matches via provider heritage extractor + const heritageExtractor = provider.heritageExtractor; matches.forEach((match) => { const captureMap: Record = {}; match.captures.forEach((c) => { captureMap[c.name] = c.node; }); - // EXTENDS or IMPLEMENTS: resolve via symbol table for languages where - // the tree-sitter query can't distinguish classes from interfaces (C#, Java) - if (captureMap['heritage.class'] && captureMap['heritage.extends']) { - // Go struct embedding: skip named fields (only anonymous fields are embedded) - const extendsNode = captureMap['heritage.extends']; - const fieldDecl = extendsNode.parent; - if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) { - return; // Named field, not struct embedding - } + if (!captureMap['heritage.class']) return; + if (!heritageExtractor) return; - const className = captureMap['heritage.class'].text; - const parentClassName = captureMap['heritage.extends'].text; + const heritageItems = heritageExtractor.extract(captureMap, { + filePath: file.path, + language, + }); - const { type: relType, idPrefix } = resolveExtendsType( - parentClassName, - file.path, - ctx, - getHeritageStrategyForLanguage(language), - ); - - const child = resolveHeritageId( - className, - file.path, - ctx, - 'Class', - `${file.path}:${className}`, - ); - const parent = resolveHeritageId(parentClassName, file.path, ctx, idPrefix); - - if (child.id && parent.id && child.id !== parent.id) { - graph.addRelationship({ - id: generateId(relType, `${child.id}->${parent.id}`), - sourceId: child.id, - targetId: parent.id, - type: relType, - confidence: Math.sqrt(child.confidence * parent.confidence), - reason: '', - }); - } - } - - // IMPLEMENTS: Class implements Interface (TypeScript only) - if (captureMap['heritage.class'] && captureMap['heritage.implements']) { - const className = captureMap['heritage.class'].text; - const interfaceName = captureMap['heritage.implements'].text; - - const cls = resolveHeritageId( - className, - file.path, - ctx, - 'Class', - `${file.path}:${className}`, - ); - const iface = resolveHeritageId(interfaceName, file.path, ctx, 'Interface'); - - if (cls.id && iface.id) { - graph.addRelationship({ - id: generateId('IMPLEMENTS', `${cls.id}->${iface.id}`), - sourceId: cls.id, - targetId: iface.id, - type: 'IMPLEMENTS', - confidence: Math.sqrt(cls.confidence * iface.confidence), - reason: '', - }); - } - } - - // IMPLEMENTS (Rust): impl Trait for Struct - if (captureMap['heritage.trait'] && captureMap['heritage.class']) { - const structName = captureMap['heritage.class'].text; - const traitName = captureMap['heritage.trait'].text; - - const strct = resolveHeritageId( - structName, - file.path, - ctx, - 'Struct', - `${file.path}:${structName}`, - ); - const trait = resolveHeritageId(traitName, file.path, ctx, 'Trait'); - - if (strct.id && trait.id) { - graph.addRelationship({ - id: generateId('IMPLEMENTS', `${strct.id}->${trait.id}`), - sourceId: strct.id, - targetId: trait.id, - type: 'IMPLEMENTS', - confidence: Math.sqrt(strct.confidence * trait.confidence), - reason: 'trait-impl', - }); - } + for (const item of heritageItems) { + resolveAndAddHeritageEdge(graph, item, file.path, language, ctx); } }); @@ -331,11 +349,15 @@ export const processHeritageFromExtracted = async ( h.kind === 'extend' || h.kind === 'prepend' ) { + // See the per-item call above (processHeritageFromExtractedItem) for + // rationale: `Class` is the correct fallback for Ruby mixin kinds, + // `Struct` stays the Rust `trait-impl` default. + const childFallbackLabel: NodeLabel = h.kind === 'trait-impl' ? 'Struct' : 'Class'; const strct = resolveHeritageId( h.className, h.filePath, ctx, - 'Struct', + childFallbackLabel, `${h.filePath}:${h.className}`, ); const trait = resolveHeritageId(h.parentName, h.filePath, ctx, 'Trait'); @@ -361,6 +383,15 @@ export const processHeritageFromExtracted = async ( * {@link ExtractedHeritage} rows without mutating the graph. Used on the * sequential pipeline path so `buildHeritageMap(..., ctx)` can run before * `processCalls` (worker path defers calls until heritage from all chunks exists). + * + * This prepass extracts BOTH capture-based heritage (`@heritage.*` — extends / + * implements / trait-impl) AND call-based heritage (`@call.name` routed through + * `heritageExtractor.extractFromCall` — Ruby `include` / `extend` / `prepend`). + * Without the second pass, sequential-mode `sequentialHeritageMap` would not + * know about Ruby mixin ancestry before `processCalls` resolves calls against + * it, silently dropping mixed-in methods from the graph. This function stays + * read-only — `processCalls` still owns emission of heritage graph edges via + * its `rubyHeritage` return path. */ export async function extractExtractedHeritageFromFiles( files: { path: string; content: string }[], @@ -400,6 +431,8 @@ export async function extractExtractedHeritageFromFiles( continue; } + const callBasedEnabled = !!provider.heritageExtractor?.extractFromCall; + for (const match of matches) { const captureMap: Record = {}; match.captures.forEach((c) => { @@ -407,35 +440,44 @@ export async function extractExtractedHeritageFromFiles( }); if (captureMap['heritage.class']) { - if (captureMap['heritage.extends']) { - const extendsNode = captureMap['heritage.extends']; - const fieldDecl = extendsNode.parent; - const isNamedField = - fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name'); - if (!isNamedField) { + if (provider.heritageExtractor) { + const heritageItems = provider.heritageExtractor.extract(captureMap, { + filePath: file.path, + language, + }); + for (const item of heritageItems) { out.push({ filePath: file.path, - className: captureMap['heritage.class'].text, - parentName: captureMap['heritage.extends'].text, - kind: 'extends', + className: item.className, + parentName: item.parentName, + kind: item.kind, }); } } - if (captureMap['heritage.implements']) { - out.push({ - filePath: file.path, - className: captureMap['heritage.class'].text, - parentName: captureMap['heritage.implements'].text, - kind: 'implements', - }); - } - if (captureMap['heritage.trait']) { - out.push({ - filePath: file.path, - className: captureMap['heritage.class'].text, - parentName: captureMap['heritage.trait'].text, - kind: 'trait-impl', - }); + continue; + } + + // Call-based heritage (e.g. Ruby include/extend/prepend). Matches the + // routing the worker path performs inline in parse-worker.ts — see the + // `provider.heritageExtractor?.extractFromCall` branch there. We only + // need call-based records here; other @call captures are consumed by + // processCalls later in the sequential loop. + if (callBasedEnabled && captureMap['call'] && captureMap['call.name']) { + const calledName: string = captureMap['call.name'].text; + const heritageItems = provider.heritageExtractor!.extractFromCall!( + calledName, + captureMap['call'], + { filePath: file.path, language }, + ); + if (heritageItems) { + for (const item of heritageItems) { + out.push({ + filePath: file.path, + className: item.className, + parentName: item.parentName, + kind: item.kind, + }); + } } } } diff --git a/gitnexus/src/core/ingestion/heritage-types.ts b/gitnexus/src/core/ingestion/heritage-types.ts new file mode 100644 index 000000000..82b6bbfad --- /dev/null +++ b/gitnexus/src/core/ingestion/heritage-types.ts @@ -0,0 +1,104 @@ +// gitnexus/src/core/ingestion/heritage-types.ts + +/** + * Types for the language-agnostic heritage extraction pipeline. + * + * Follows the same pattern as call-types.ts, variable-types.ts, and + * method-types.ts: defines the domain interfaces consumed by + * createHeritageExtractor() and the per-language configs. + * + * Heritage extraction handles extends/implements/trait-impl captures from + * tree-sitter queries, plus call-based heritage for languages like Ruby + * (include/extend/prepend expressed as method calls). + */ + +import type { SupportedLanguages } from 'gitnexus-shared'; +import type { SyntaxNode } from './utils/ast-helpers.js'; +import type { CaptureMap } from './language-provider.js'; + +// --------------------------------------------------------------------------- +// Extracted result +// --------------------------------------------------------------------------- + +/** + * Per-match heritage extraction result. The parse worker adds filePath to + * produce the final {@link ExtractedHeritage} that enters the resolution + * pipeline (heritage-processor.ts / heritage-map.ts). + */ +export interface HeritageInfo { + className: string; + parentName: string; + /** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */ + kind: string; +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +export interface HeritageExtractorContext { + filePath: string; + language: SupportedLanguages; +} + +// --------------------------------------------------------------------------- +// Extractor interface (produced by createHeritageExtractor) +// --------------------------------------------------------------------------- + +export interface HeritageExtractor { + readonly language: SupportedLanguages; + + /** + * Extract heritage records from tree-sitter @heritage.* captures. + * + * @param captureMap The capture map from a single tree-sitter match + * @param context File path and language context + * @returns Array of heritage records (may be empty if captures don't match) + */ + extract(captureMap: CaptureMap, context: HeritageExtractorContext): HeritageInfo[]; + + /** + * Extract heritage from a call node (for languages where heritage is + * expressed as method calls, e.g., Ruby include/extend/prepend). + * + * @param calledName The method name (e.g. 'include', 'extend', 'prepend') + * @param callNode The tree-sitter call AST node + * @param context File path and language context + * @returns Heritage records if the call is heritage-related, or null to + * fall through to the call router / normal call handling. + */ + extractFromCall?( + calledName: string, + callNode: SyntaxNode, + context: HeritageExtractorContext, + ): HeritageInfo[] | null; +} + +// --------------------------------------------------------------------------- +// Config interface (one per language / language group) +// --------------------------------------------------------------------------- + +export interface HeritageExtractionConfig { + language: SupportedLanguages; + + /** + * Called for heritage.extends captures. Return true to skip this extends + * capture. Used by Go to skip named struct fields that match the + * field_declaration pattern but are not anonymous embeddings. + * + * Default: never skip (all extends captures are valid). + */ + shouldSkipExtends?: (extendsNode: SyntaxNode) => boolean; + + /** + * Call-based heritage extraction for languages where heritage is expressed + * as method calls (e.g., Ruby include/extend/prepend). + * + * callNames: set of method names that trigger heritage extraction. + * extract: extract heritage items from the call node + method name. + */ + callBasedHeritage?: { + readonly callNames: ReadonlySet; + extract(calledName: string, callNode: SyntaxNode, filePath: string): HeritageInfo[]; + }; +} diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 298c7999f..3960c6a8a 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -12,10 +12,16 @@ import type { SupportedLanguages, MroStrategy } from 'gitnexus-shared'; import type { LanguageTypeConfig } from './type-extractors/types.js'; import type { CallRouter } from './call-routing.js'; -import type { CallExtractor } from './call-types.js'; +import type { + CallExtractor, + DispatchDecision, + ImplicitReceiverOverride, + ReceiverEnriched, +} from './call-types.js'; import type { ClassExtractor } from './class-types.js'; import type { ExportChecker } from './export-detection.js'; import type { FieldExtractor } from './field-extractor.js'; +import type { HeritageExtractor } from './heritage-types.js'; import type { MethodExtractor } from './method-types.js'; import type { VariableExtractor } from './variable-types.js'; import type { ImportResolverFn } from './import-resolvers/types.js'; @@ -179,6 +185,13 @@ interface LanguageProviderConfig { * Uses the same provider-driven strategy pattern as method/field extraction so * namespace/package/module rules stay language-specific. */ readonly classExtractor?: ClassExtractor; + /** Heritage extractor for extracting extends/implements/trait-impl relationships + * from tree-sitter @heritage.* captures and call-based heritage (e.g., Ruby + * include/extend/prepend). Produced by createHeritageExtractor() — pass a + * SupportedLanguages value for default behaviour or a full + * HeritageExtractionConfig for languages with custom hooks (Go, Ruby). + * All tree-sitter providers MUST supply this. */ + readonly heritageExtractor?: HeritageExtractor; /** Extract a semantic description for a definition node (e.g., PHP Eloquent * property arrays, relation method descriptions). * Default: undefined (no description extraction). */ @@ -192,6 +205,69 @@ interface LanguageProviderConfig { * Default: undefined (no route files). */ readonly isRouteFile?: (filePath: string) => boolean; + // ── Call-resolution DAG hooks ───────────────────────────────────── + /** + * DAG stage 3 hook: synthesize an implicit receiver when the call site omits one. + * + * Runs after shared inference (TypeEnv → constructor-map → class-as-receiver → + * mixed-chain). Return an `ImplicitReceiverOverride` to overlay all fields onto + * `ReceiverEnriched`; return null to keep current state and proceed to stage 4. + * + * Constraints: MUST return null when an explicit receiver is already set, at + * top-level scope, or for built-in methods. Do not mutate input params. + * `hint` is opaque to shared stages; consumed by this language's `selectDispatch`. + * + * Ruby example: bare `serialize` in `Account#call_serialize` → + * `{ callForm: 'member', receiverName: 'self', receiverTypeName: 'Account', + * receiverSource: 'implicit-self', hint: 'instance' }` + * + * @see call-types.ts § ImplicitReceiverOverride + * @see selectDispatch (stage 4, reads the hint) + * + * Default: undefined (no implicit-receiver inference). + */ + readonly inferImplicitReceiver?: (params: { + readonly calledName: string; + readonly callForm: 'free' | 'member' | 'constructor' | undefined; + readonly receiverName: string | undefined; + readonly receiverTypeName: string | undefined; + readonly callNode: SyntaxNode; + readonly filePath: string; + }) => ImplicitReceiverOverride | null; + + /** + * DAG stage 4 hook: decide dispatch strategy (primary path, fallback, MRO view). + * + * Runs after stage 3. Return a `DispatchDecision` to override shared defaults; + * return null to use `defaultDispatchDecision` (constructor→`'constructor'`, + * member→`'owner-scoped'`, free→`'free'`). Most languages return null. + * + * The hook is responsible for its own gating. `ancestryView` only affects + * `'ruby-mixin'` strategy. Singleton-ancestry miss NEVER falls through to + * file-scoped fallback in stage 5 (enforced in resolveCallTarget). + * + * Ruby examples: + * - `receiverSource='implicit-self', hint='instance'` → + * `{primary: 'owner-scoped', fallback: 'free-arity-narrowed', ancestryView: 'instance'}` + * - `receiverSource='class-as-receiver'` → + * `{primary: 'owner-scoped', ancestryView: 'singleton'}` (miss null-routes) + * - `receiverSource='implicit-self', hint='singleton'` → + * `{primary: 'owner-scoped', fallback: 'free-arity-narrowed', ancestryView: 'singleton'}` + * + * @see call-types.ts § DispatchDecision + * @see call-processor.ts § defaultDispatchDecision, resolveCallTarget + * + * Default: undefined (use `defaultDispatchDecision`). + */ + readonly selectDispatch?: (params: { + readonly calledName: string; + readonly callForm: 'free' | 'member' | 'constructor' | undefined; + readonly receiverName: string | undefined; + readonly receiverTypeName: string | undefined; + readonly receiverSource: ReceiverEnriched['receiverSource']; + readonly hint: string | undefined; + }) => DispatchDecision | null; + // ── Noise filtering ──────────────────────────────────────────────── /** Built-in/stdlib names that should be filtered from the call graph for this language. * Default: undefined (no language-specific filtering). */ diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 5a60959a0..37e6da732 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -43,6 +43,7 @@ import { createVariableExtractor } from '../variable-extractors/generic.js'; import { cVariableConfig, cppVariableConfig } from '../variable-extractors/configs/c-cpp.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js'; +import { createHeritageExtractor } from '../heritage-extractors/generic.js'; const C_BUILT_INS: ReadonlySet = new Set([ 'printf', @@ -329,6 +330,7 @@ export const cProvider = defineLanguage({ }), variableExtractor: createVariableExtractor(cVariableConfig), classExtractor: cClassExtractor, + heritageExtractor: createHeritageExtractor(SupportedLanguages.C), labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, }); @@ -350,6 +352,7 @@ export const cppProvider = defineLanguage({ }), variableExtractor: createVariableExtractor(cppVariableConfig), classExtractor: cppClassExtractor, + heritageExtractor: createHeritageExtractor(SupportedLanguages.CPlusPlus), labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/csharp.ts b/gitnexus/src/core/ingestion/languages/csharp.ts index 9a447fced..a491f339a 100644 --- a/gitnexus/src/core/ingestion/languages/csharp.ts +++ b/gitnexus/src/core/ingestion/languages/csharp.ts @@ -24,6 +24,7 @@ import { createMethodExtractor } from '../method-extractors/generic.js'; import { csharpMethodConfig } from '../method-extractors/configs/csharp.js'; import { createVariableExtractor } from '../variable-extractors/generic.js'; import { csharpVariableConfig } from '../variable-extractors/configs/csharp.js'; +import { createHeritageExtractor } from '../heritage-extractors/generic.js'; const BUILT_INS: ReadonlySet = new Set([ 'Console', @@ -135,5 +136,6 @@ export const csharpProvider = defineLanguage({ methodExtractor: createMethodExtractor(csharpMethodConfig), variableExtractor: createVariableExtractor(csharpVariableConfig), classExtractor: createClassExtractor(csharpClassConfig), + heritageExtractor: createHeritageExtractor(SupportedLanguages.CSharp), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index a596d35ae..130d15deb 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -30,6 +30,7 @@ import { createVariableExtractor } from '../variable-extractors/generic.js'; import { dartVariableConfig } from '../variable-extractors/configs/dart.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { dartCallConfig } from '../call-extractors/configs/dart.js'; +import { createHeritageExtractor } from '../heritage-extractors/generic.js'; /** * Resolve the enclosing function from a `function_body` node by looking at its @@ -102,6 +103,7 @@ export const dartProvider = defineLanguage({ methodExtractor: createMethodExtractor(dartMethodConfig), variableExtractor: createVariableExtractor(dartVariableConfig), classExtractor: createClassExtractor(dartClassConfig), + heritageExtractor: createHeritageExtractor(SupportedLanguages.Dart), enclosingFunctionFinder: dartEnclosingFunctionFinder, builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index dcb0ec3d0..245dc197b 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -26,6 +26,8 @@ import { createVariableExtractor } from '../variable-extractors/generic.js'; import { goVariableConfig } from '../variable-extractors/configs/go.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { goCallConfig } from '../call-extractors/configs/go.js'; +import { createHeritageExtractor } from '../heritage-extractors/generic.js'; +import { goHeritageConfig } from '../heritage-extractors/configs/go.js'; export const goProvider = defineLanguage({ id: SupportedLanguages.Go, @@ -40,4 +42,5 @@ export const goProvider = defineLanguage({ methodExtractor: createMethodExtractor(goMethodConfig), variableExtractor: createVariableExtractor(goVariableConfig), classExtractor: createClassExtractor(goClassConfig), + heritageExtractor: createHeritageExtractor(goHeritageConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index f905213c8..4781219d3 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -25,6 +25,7 @@ import { createMethodExtractor } from '../method-extractors/generic.js'; import { javaMethodConfig } from '../method-extractors/configs/jvm.js'; import { createVariableExtractor } from '../variable-extractors/generic.js'; import { javaVariableConfig } from '../variable-extractors/configs/jvm.js'; +import { createHeritageExtractor } from '../heritage-extractors/generic.js'; export const javaProvider = defineLanguage({ id: SupportedLanguages.Java, @@ -41,4 +42,5 @@ export const javaProvider = defineLanguage({ methodExtractor: createMethodExtractor(javaMethodConfig), variableExtractor: createVariableExtractor(javaVariableConfig), classExtractor: createClassExtractor(javaClassConfig), + heritageExtractor: createHeritageExtractor(SupportedLanguages.Java), }); diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 0f8a73425..dd65e5d42 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -27,6 +27,7 @@ import { createMethodExtractor } from '../method-extractors/generic.js'; import { kotlinMethodConfig } from '../method-extractors/configs/jvm.js'; import { createVariableExtractor } from '../variable-extractors/generic.js'; import { kotlinVariableConfig } from '../variable-extractors/configs/jvm.js'; +import { createHeritageExtractor } from '../heritage-extractors/generic.js'; /** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method). * Kotlin grammar uses function_declaration for both top-level functions and class methods. @@ -116,6 +117,7 @@ export const kotlinProvider = defineLanguage({ methodExtractor: createMethodExtractor(kotlinMethodConfig), variableExtractor: createVariableExtractor(kotlinVariableConfig), classExtractor: createClassExtractor(kotlinClassConfig), + heritageExtractor: createHeritageExtractor(SupportedLanguages.Kotlin), builtInNames: BUILT_INS, labelOverride: (functionNode, defaultLabel) => { if (defaultLabel !== 'Function') return defaultLabel; diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index af999bb4f..736a63468 100644 --- a/gitnexus/src/core/ingestion/languages/php.ts +++ b/gitnexus/src/core/ingestion/languages/php.ts @@ -26,6 +26,7 @@ import { createVariableExtractor } from '../variable-extractors/generic.js'; import { phpVariableConfig } from '../variable-extractors/configs/php.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { phpCallConfig } from '../call-extractors/configs/php.js'; +import { createHeritageExtractor } from '../heritage-extractors/generic.js'; const BUILT_INS: ReadonlySet = new Set([ 'echo', @@ -248,6 +249,7 @@ export const phpProvider = defineLanguage({ methodExtractor: createMethodExtractor(phpMethodConfig), variableExtractor: createVariableExtractor(phpVariableConfig), classExtractor: createClassExtractor(phpClassConfig), + heritageExtractor: createHeritageExtractor(SupportedLanguages.PHP), descriptionExtractor: phpDescriptionExtractor, isRouteFile: isPhpRouteFile, builtInNames: BUILT_INS, diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index 74ac72396..5773cd3dd 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -28,6 +28,7 @@ import { createVariableExtractor } from '../variable-extractors/generic.js'; import { pythonVariableConfig } from '../variable-extractors/configs/python.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { pythonCallConfig } from '../call-extractors/configs/python.js'; +import { createHeritageExtractor } from '../heritage-extractors/generic.js'; const BUILT_INS: ReadonlySet = new Set([ 'print', @@ -74,5 +75,6 @@ export const pythonProvider = defineLanguage({ methodExtractor: createMethodExtractor(pythonMethodConfig), variableExtractor: createVariableExtractor(pythonVariableConfig), classExtractor: createClassExtractor(pythonClassConfig), + heritageExtractor: createHeritageExtractor(SupportedLanguages.Python), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index 081740885..e538f3fd2 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -27,6 +27,28 @@ import { createVariableExtractor } from '../variable-extractors/generic.js'; import { rubyVariableConfig } from '../variable-extractors/configs/ruby.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { rubyCallConfig } from '../call-extractors/configs/ruby.js'; +import { createHeritageExtractor } from '../heritage-extractors/generic.js'; +import { rubyHeritageConfig } from '../heritage-extractors/configs/ruby.js'; +import { maybeRewriteRubyBareCallToSelf } from '../utils/ruby-self-call.js'; +import { findEnclosingClassInfo } from '../utils/ast-helpers.js'; +import type { DispatchDecision, ImplicitReceiverOverride } from '../call-types.js'; + +/** + * Ruby label override. Applied to: + * - `definition.module` captures in the structure phase — remaps to `Trait` + * so Ruby modules are registered in the class-like type registry and are + * therefore resolvable by `lookupClassByName` during mixin heritage + * resolution (`include`/`extend`/`prepend`). + * - `definition.function` captures — Ruby has no bare "function" construct + * (top-level `def` is a method on `main`); return the default so generic + * logic continues to apply. + * + * Returning `null` means "skip this definition"; we never do that here. + */ +const rubyLabelOverride = (_node: SyntaxNode, defaultLabel: NodeLabel): NodeLabel | null => { + if (defaultLabel === 'Module') return 'Trait'; + return defaultLabel; +}; /** Ruby method/singleton_method: extract name from 'name' field, label as Method. */ const rubyExtractFunctionName = ( @@ -105,6 +127,27 @@ const BUILT_INS: ReadonlySet = new Set([ 'uniq', ]); +/** + * Remaps `class << self` (singleton_class) to its enclosing class/module for + * receiver inference. A `singleton_class` node is not itself a type — walking + * up to the real owner lets `inferImplicitReceiver` set `hint='singleton'`. + * Returns null for orphaned singleton_class (no enclosing class/module found). + * All other container types are returned as-is. + */ +const rubyResolveEnclosingOwner = (node: SyntaxNode): SyntaxNode | null => { + if (node.type === 'singleton_class') { + let ancestor = node.parent; + while (ancestor) { + if (ancestor.type === 'class' || ancestor.type === 'module') { + return ancestor; + } + ancestor = ancestor.parent; + } + return null; // no enclosing class/module — skip + } + return node; // use as-is for all other container types +}; + export const rubyProvider = defineLanguage({ id: SupportedLanguages.Ruby, extensions: ['.rb', '.rake', '.gemspec'], @@ -115,21 +158,7 @@ export const rubyProvider = defineLanguage({ callRouter: routeRubyCall, importSemantics: 'wildcard-leaf', callExtractor: createCallExtractor(rubyCallConfig), - resolveEnclosingOwner(node) { - // Ruby singleton_class (class << self) should resolve to the enclosing - // class or module for owner/container resolution (HAS_METHOD edges, class IDs). - if (node.type === 'singleton_class') { - let ancestor = node.parent; - while (ancestor) { - if (ancestor.type === 'class' || ancestor.type === 'module') { - return ancestor; - } - ancestor = ancestor.parent; - } - return null; // no enclosing class/module — skip - } - return node; // use as-is for all other container types - }, + resolveEnclosingOwner: rubyResolveEnclosingOwner, fieldExtractor: createFieldExtractor(rubyFieldConfig), methodExtractor: createMethodExtractor({ ...rubyMethodConfig, @@ -137,5 +166,70 @@ export const rubyProvider = defineLanguage({ }), variableExtractor: createVariableExtractor(rubyVariableConfig), classExtractor: createClassExtractor(rubyClassConfig), + heritageExtractor: createHeritageExtractor(rubyHeritageConfig), + labelOverride: rubyLabelOverride, + // Ruby MRO is kind-aware: prepend providers beat the class's own method, + // which in turn beats include providers. See `lookupMethodByOwnerWithMRO` + // in `model/resolve.ts` for the walk order. + mroStrategy: 'ruby-mixin', + + // ── DAG hooks ──────────────────────────────────────────────────── + // + // DAG stage 3: rewrite bare calls (e.g. `serialize` in Account#call_serialize) + // as `self.serialize` so they route through owner-scoped MRO instead of + // global free-call lookup. `dispatchKind` goes into `hint` for stage 4. + inferImplicitReceiver: ({ + calledName, + callForm, + receiverName, + receiverTypeName, + callNode, + filePath, + }): ImplicitReceiverOverride | null => { + // Only fire when no receiver has been resolved already. + if (receiverName || receiverTypeName) return null; + const enclosing = findEnclosingClassInfo(callNode, filePath, rubyResolveEnclosingOwner); + const rewrite = maybeRewriteRubyBareCallToSelf( + calledName, + callForm, + callNode, + enclosing?.className ?? null, + { isBuiltInName: (n) => BUILT_INS.has(n), mroStrategy: 'ruby-mixin' }, + ); + if (!rewrite) return null; + return { + callForm: rewrite.callForm, + receiverName: rewrite.receiverName, + receiverTypeName: rewrite.receiverTypeName, + receiverSource: 'implicit-self', + hint: rewrite.dispatchKind, // 'instance' | 'singleton' + }; + }, + + // DAG stage 4: two Ruby dispatch overrides — + // implicit-self: MRO walk first, fallback to free-arity-narrowed on miss. + // class-as-receiver: singleton ancestry (extend providers only); miss null-routes. + selectDispatch: ({ receiverSource, hint }): DispatchDecision | null => { + if (receiverSource === 'implicit-self') { + // hint='instance' → instance ancestry (prepend→direct→include, see mro-strategy.ts § 'ruby-mixin') + // hint='singleton' → singleton ancestry (extend providers only; miss null-routes) + const ancestryView: 'instance' | 'singleton' = + hint === 'singleton' ? 'singleton' : 'instance'; + return { + primary: 'owner-scoped', + fallback: 'free-arity-narrowed', + ancestryView, + }; + } + if (receiverSource === 'class-as-receiver') { + // Class constant receiver (e.g. Account.log): singleton ancestry only; miss null-routes. + return { + primary: 'owner-scoped', + ancestryView: 'singleton', + }; + } + return null; + }, + builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index 8086db753..ac0cd5788 100644 --- a/gitnexus/src/core/ingestion/languages/rust.ts +++ b/gitnexus/src/core/ingestion/languages/rust.ts @@ -30,6 +30,7 @@ import { createVariableExtractor } from '../variable-extractors/generic.js'; import { rustVariableConfig } from '../variable-extractors/configs/rust.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { rustCallConfig } from '../call-extractors/configs/rust.js'; +import { createHeritageExtractor } from '../heritage-extractors/generic.js'; /** Rust impl_item: find the function_item child and extract its name as a Method. */ const rustExtractFunctionName = ( @@ -134,5 +135,6 @@ export const rustProvider = defineLanguage({ }), variableExtractor: createVariableExtractor(rustVariableConfig), classExtractor: createClassExtractor(rustClassConfig), + heritageExtractor: createHeritageExtractor(SupportedLanguages.Rust), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index 450b49e6b..38f5d8147 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -30,6 +30,7 @@ import { createVariableExtractor } from '../variable-extractors/generic.js'; import { swiftVariableConfig } from '../variable-extractors/configs/swift.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { swiftCallConfig } from '../call-extractors/configs/swift.js'; +import { createHeritageExtractor } from '../heritage-extractors/generic.js'; /** * Group Swift files by SPM target for implicit module visibility. @@ -254,6 +255,7 @@ export const swiftProvider = defineLanguage({ }), variableExtractor: createVariableExtractor(swiftVariableConfig), classExtractor: createClassExtractor(swiftClassConfig), + heritageExtractor: createHeritageExtractor(SupportedLanguages.Swift), implicitImportWirer: wireSwiftImplicitImports, builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 71f1b0315..0a1c3f28f 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -43,6 +43,7 @@ import { typescriptCallConfig, javascriptCallConfig, } from '../call-extractors/configs/typescript-javascript.js'; +import { createHeritageExtractor } from '../heritage-extractors/generic.js'; /** * TypeScript/JavaScript: arrow_function and function_expression get their name @@ -182,6 +183,7 @@ export const typescriptProvider = defineLanguage({ }), variableExtractor: createVariableExtractor(typescriptVariableConfig), classExtractor: createClassExtractor(typescriptClassConfig), + heritageExtractor: createHeritageExtractor(SupportedLanguages.TypeScript), builtInNames: BUILT_INS, }); @@ -201,5 +203,6 @@ export const javascriptProvider = defineLanguage({ }), variableExtractor: createVariableExtractor(javascriptVariableConfig), classExtractor: createClassExtractor(javascriptClassConfig), + heritageExtractor: createHeritageExtractor(SupportedLanguages.JavaScript), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/vue.ts b/gitnexus/src/core/ingestion/languages/vue.ts index 243d941cf..51ab37882 100644 --- a/gitnexus/src/core/ingestion/languages/vue.ts +++ b/gitnexus/src/core/ingestion/languages/vue.ts @@ -27,6 +27,7 @@ import { createVariableExtractor } from '../variable-extractors/generic.js'; import { typescriptVariableConfig } from '../variable-extractors/configs/typescript-javascript.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { typescriptCallConfig } from '../call-extractors/configs/typescript-javascript.js'; +import { createHeritageExtractor } from '../heritage-extractors/generic.js'; const VUE_SPECIFIC_BUILT_INS = [ 'ref', @@ -76,5 +77,6 @@ export const vueProvider = defineLanguage({ fieldExtractor: typescriptFieldExtractor, variableExtractor: createVariableExtractor(typescriptVariableConfig), classExtractor: vueClassExtractor, + heritageExtractor: createHeritageExtractor(SupportedLanguages.TypeScript), builtInNames: VUE_BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/model/heritage-map.ts b/gitnexus/src/core/ingestion/model/heritage-map.ts index 16ecf3305..ae89022b4 100644 --- a/gitnexus/src/core/ingestion/model/heritage-map.ts +++ b/gitnexus/src/core/ingestion/model/heritage-map.ts @@ -87,11 +87,47 @@ export const resolveExtendsType = ( /** Maximum ancestor chain depth to prevent runaway traversal. */ const MAX_ANCESTOR_DEPTH = 32; +/** + * Direct parent entry with the heritage kind that produced it. Preserved + * so kind-aware consumers (Ruby MRO, see `lookupMethodByOwnerWithMRO`) can + * walk prepend/include providers in the correct order. Flat-string consumers + * use `getParents` / `getAncestors` and see only the parent nodeIds. + */ +export interface ParentEntry { + readonly parentId: string; + /** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */ + readonly kind: string; +} + export interface HeritageMap { /** Direct parents of `childNodeId` (extends + implements + trait-impl). */ getParents(childNodeId: string): string[]; /** Full ancestor chain (BFS, bounded depth, cycle-safe). */ getAncestors(childNodeId: string): string[]; + /** + * Direct parents with heritage kind preserved, insertion-ordered. Used by + * kind-aware consumers (Ruby MRO) that need to distinguish prepend / + * include / extend / extends for walk-order decisions. + * + * Insertion order mirrors the order `ExtractedHeritage` records were fed + * into `buildHeritageMap`, which in turn mirrors tree-sitter match order. + * For Ruby, this matches source declaration order for `prepend` / `include` + * statements — the MRO walk reverses this (last-declared-first) at the + * consumer side. + */ + getParentEntries(childNodeId: string): readonly ParentEntry[]; + /** + * Ordered ancestry for instance method dispatch (Ruby-aware): includes + * `extends`, `implements`, `trait-impl`, `include`, `prepend` kinds. + * Excludes `extend` (singleton-only). Order is caller-determined in Unit 3. + * For non-Ruby callers (first-wins, c3, etc.), this matches `getAncestors`. + */ + getInstanceAncestry(childNodeId: string): readonly ParentEntry[]; + /** + * Ordered ancestry for singleton / class-method dispatch (Ruby-aware): + * only `extend` kind parents. For non-Ruby languages this is always empty. + */ + getSingletonAncestry(childNodeId: string): readonly ParentEntry[]; /** * File paths of classes that directly implement or extend-as-interface the * given interface/abstract-class **name**. Replaces the standalone @@ -130,8 +166,12 @@ export const buildHeritageMap = ( ctx: ResolutionContext, getHeritageStrategy?: HeritageStrategyLookup, ): HeritageMap => { - // childNodeId → Set (Set to deduplicate cross-chunk duplicates) - const directParents = new Map>(); + // childNodeId → insertion-ordered array of { parentId, kind }. + // Ordered array (not Set) because Ruby MRO walk depends on declaration + // order. A parallel `seen` map dedupes `(parentId, kind)` pairs without + // losing order. + const directParents = new Map(); + const seenParents = new Map>(); // interfaceName → Set (implementor lookup for interface dispatch) const implementorFiles = new Map>(); @@ -149,10 +189,23 @@ export const buildHeritageMap = ( let parents = directParents.get(child.nodeId); if (!parents) { - parents = new Set(); + parents = []; directParents.set(child.nodeId, parents); } - parents.add(parent.nodeId); + let seen = seenParents.get(child.nodeId); + if (!seen) { + seen = new Set(); + seenParents.set(child.nodeId, seen); + } + // Dedup by `parentId + kind` so the same parent under two different + // kinds (e.g. a module that is both included and prepended — legal + // Ruby though unusual) is recorded twice; the consumer needs both + // kinds in the walk. A single (parent, kind) pair is deduped. + const key = `${parent.nodeId}|${h.kind}`; + if (!seen.has(key)) { + seen.add(key); + parents.push({ parentId: parent.nodeId, kind: h.kind }); + } } } } @@ -191,9 +244,30 @@ export const buildHeritageMap = ( // --- Public API --------------------------------------------------- + /** Internal helper: return the entries array (may be undefined). */ + const entriesFor = (nodeId: string): readonly ParentEntry[] | undefined => + directParents.get(nodeId); + + const getParentEntries = (childNodeId: string): readonly ParentEntry[] => { + const entries = entriesFor(childNodeId); + return entries ?? []; + }; + const getParents = (childNodeId: string): string[] => { - const parents = directParents.get(childNodeId); - return parents ? [...parents] : []; + const entries = entriesFor(childNodeId); + if (!entries) return []; + // Deduplicate parent ids across kinds so the flat-string contract + // (used by non-Ruby MRO strategies and by the C3 linearizer) stays + // identical to its pre-kind-awareness behavior. + const out: string[] = []; + const seen = new Set(); + for (const e of entries) { + if (!seen.has(e.parentId)) { + seen.add(e.parentId); + out.push(e.parentId); + } + } + return out; }; const getAncestors = (childNodeId: string): string[] => { @@ -212,10 +286,13 @@ export const buildHeritageMap = ( visited.add(parentId); result.push(parentId); // Expand parent's own parents for next level - const grandparents = directParents.get(parentId); + const grandparents = entriesFor(parentId); if (grandparents) { + const gpSeen = new Set(); for (const gp of grandparents) { - if (!visited.has(gp)) nextFrontier.push(gp); + if (gpSeen.has(gp.parentId)) continue; + gpSeen.add(gp.parentId); + if (!visited.has(gp.parentId)) nextFrontier.push(gp.parentId); } } } @@ -226,9 +303,77 @@ export const buildHeritageMap = ( return result; }; + /** + * Lazy-computed per-owner split of direct parents into instance-dispatch + * (non-`extend`) and singleton-dispatch (`extend`-only) views. Memoized on + * first request so the `.filter()` pass happens at most once per owner per + * HeritageMap lifetime, not per call-site dispatch. + * + * Shared empty-array sentinels for owners with no entries in a given view + * avoid per-call allocation when the split is asymmetric (common Ruby case: + * a class has `include` but no `extend`, so its singleton view is empty). + */ + const EMPTY_PARENT_ENTRIES: readonly ParentEntry[] = []; + const splitCache = new Map< + string, + { instance: readonly ParentEntry[]; singleton: readonly ParentEntry[] } + >(); + + const splitForOwner = ( + childNodeId: string, + ): { instance: readonly ParentEntry[]; singleton: readonly ParentEntry[] } => { + let cached = splitCache.get(childNodeId); + if (cached) return cached; + const entries = entriesFor(childNodeId); + if (!entries || entries.length === 0) { + cached = { instance: EMPTY_PARENT_ENTRIES, singleton: EMPTY_PARENT_ENTRIES }; + } else { + const instance: ParentEntry[] = []; + const singleton: ParentEntry[] = []; + for (const e of entries) { + if (e.kind === 'extend') singleton.push(e); + else instance.push(e); + } + cached = { + instance: instance.length === 0 ? EMPTY_PARENT_ENTRIES : instance, + singleton: singleton.length === 0 ? EMPTY_PARENT_ENTRIES : singleton, + }; + } + splitCache.set(childNodeId, cached); + return cached; + }; + + /** + * Instance-dispatch ancestry walk. Excludes `extend` (singleton-only). + * For kind-aware consumers (Ruby MRO): walks parents in source-insertion + * order. The consumer is responsible for interleaving self / reversing + * prepend order / etc. This method preserves raw declaration order. + * + * Result is cached per owner; repeat calls return the same array. + */ + const getInstanceAncestry = (childNodeId: string): readonly ParentEntry[] => + splitForOwner(childNodeId).instance; + + /** + * Singleton-dispatch ancestry walk. Only `extend` parents. For non-Ruby + * languages this is always empty (no language currently produces `extend` + * heritage records outside Ruby). + * + * Result is cached per owner; repeat calls return the same array. + */ + const getSingletonAncestry = (childNodeId: string): readonly ParentEntry[] => + splitForOwner(childNodeId).singleton; + const getImplementorFiles = (interfaceName: string): ReadonlySet => { return implementorFiles.get(interfaceName) ?? EMPTY_SET; }; - return { getParents, getAncestors, getImplementorFiles }; + return { + getParents, + getAncestors, + getParentEntries, + getInstanceAncestry, + getSingletonAncestry, + getImplementorFiles, + }; }; diff --git a/gitnexus/src/core/ingestion/model/resolve.ts b/gitnexus/src/core/ingestion/model/resolve.ts index 746a2b2de..c05945b2d 100644 --- a/gitnexus/src/core/ingestion/model/resolve.ts +++ b/gitnexus/src/core/ingestion/model/resolve.ts @@ -278,33 +278,24 @@ const buildParentMapFromHeritage = ( // --------------------------------------------------------------------------- /** - * Look up a method on an owner class, walking the parent chain via HeritageMap - * when the method isn't found on the direct owner. + * DAG stage 5 helper: look up a method on an owner class via MRO walk. * - * Respects the 5 per-language MRO strategies: - * - `first-wins`: BFS ancestor walk, first match wins (default) - * - `leftmost-base`: BFS ancestor walk, leftmost base in declaration order wins (C++); - * HeritageMap preserves insertion order matching source declaration, - * so BFS order is equivalent to leftmost-base semantics - * - `c3`: C3-linearized ancestor order, first match wins (Python) - * - `implements-split`: BFS ancestor walk, first match wins (Java/C#) — - * full ambiguity detection for multiple interface defaults - * is handled by computeMRO at graph level - * - `qualified-syntax`: No auto-resolution (Rust) — returns undefined + * Low-level resolver; no dependency on SymbolTable, language registry, or + * resolution-context (keeps model/ layer free of cross-layer imports). + * All strategies respect `argCount` for overload narrowing. + * `ancestryOverride` replaces the default walk; caller must compute it correctly. * - * Uses the `c3Linearize` defined in this file (also consumed by - * mro-processor.ts for graph-level MRO emission) for the `c3` strategy. + * Strategy summary (full docs in gitnexus-shared/mro-strategy.ts): + * - `first-wins` / `leftmost-base` / `implements-split`: BFS, first match wins. + * - `c3`: C3-linearized order; falls back to BFS on cycle/inconsistency. + * - `qualified-syntax`: returns undefined immediately (Rust requires explicit syntax). + * - `ruby-mixin`: kind-aware walk — see inline comments below. * - * Depends only on {@link SemanticModel} + {@link HeritageMap} + an - * {@link MroStrategy} literal — NO dependency on SymbolTable, the language - * registry, or resolution-context, which keeps the `model/` module free of - * cross-layer imports. Callers derive the strategy from their language - * provider before invoking this function. + * Internal API: exported for call-processor resolvers and tests. + * External callers should use resolveMemberCall instead. * - * @internal This is the low-level MRO walker. Exported so call-processor's - * higher-level resolvers (and unit tests) can invoke it directly. Callers - * outside `core/ingestion/` should use the higher-level resolvers in - * call-processor.ts instead of depending on this function. + * @see gitnexus-shared/mro-strategy.ts § 'ruby-mixin' + * @see call-processor.ts § resolveMemberCall */ export const lookupMethodByOwnerWithMRO = ( ownerNodeId: string, @@ -313,7 +304,91 @@ export const lookupMethodByOwnerWithMRO = ( model: SemanticModel, strategy: MroStrategy, argCount?: number, + /** + * Optional pre-computed ancestry list. When provided, overrides the default + * per-strategy ancestry source. Primarily used by Ruby singleton dispatch: + * the caller supplies `heritageMap.getSingletonAncestry(ownerNodeId)` as + * node-id array so this walker resolves against `extend` providers only. + * + * For `ruby-mixin` strategy, passing an override switches the walker into + * a no-prepend-no-direct linear scan (the caller has already decided the + * order), which is the correct semantics for singleton dispatch. + */ + ancestryOverride?: readonly string[], ): SymbolDefinition | undefined => { + // ── Ruby mixin strategy ─────────────────────────────────────────── + // Kind-aware walk — does NOT short-circuit on direct owner first (prepend beats direct). + // Instance dispatch: prepend (reverse) → direct → include (reverse) → transitive BFS. + // Singleton dispatch: caller supplies ancestryOverride (extend providers only); + // simple left-to-right scan. Miss NEVER falls through to file-scoped fallback. + // See gitnexus-shared/mro-strategy.ts § 'ruby-mixin' for full strategy docs. + if (strategy === 'ruby-mixin') { + if (ancestryOverride) { + // Singleton dispatch: scan pre-computed ancestry only. Miss null-routes. + for (const ancestorId of ancestryOverride) { + const method = model.methods.lookupMethodByOwner(ancestorId, methodName, argCount); + if (method) return method; + } + return undefined; + } + + // Instance dispatch — kind-aware walk per the pseudocode above. + const instanceEntries = heritageMap.getInstanceAncestry(ownerNodeId); + // Partition into prepend parents vs other parents (extends / include / + // implements / trait-impl), preserving declaration order within each. + const prependParents: string[] = []; + const otherParents: string[] = []; + for (const e of instanceEntries) { + if (e.kind === 'prepend') prependParents.push(e.parentId); + else otherParents.push(e.parentId); + } + + // Step 1: Walk prepend parents in REVERSE declaration order (last-prepended wins). + for (let i = prependParents.length - 1; i >= 0; i--) { + const method = model.methods.lookupMethodByOwner(prependParents[i], methodName, argCount); + if (method) return method; + } + + // Step 2: Direct owner lookup (the class's own method). + // This is the only difference from other strategies — prepend beats direct. + const direct = model.methods.lookupMethodByOwner(ownerNodeId, methodName, argCount); + if (direct) return direct; + + // Step 3: Walk extends + include parents in REVERSE declaration order. + // (Ruby `include A; include B` puts B ahead of A in MRO.) + for (let i = otherParents.length - 1; i >= 0; i--) { + const method = model.methods.lookupMethodByOwner(otherParents[i], methodName, argCount); + if (method) return method; + } + + // Step 4: Transitive ancestors (a mixin that itself mixes in another module). + // Fall back to the BFS ancestor walk for depth > 1. Order is best-effort; + // Ruby's actual MRO for transitive mixins is rare and under-specified + // (documented in architecture docs as deferred work). + // + // O(1) skip-check via Sets: + // - `walkedDirect` covers parents already visited in steps 1-3. + // - `singletonOnly` covers direct `extend` providers: they belong to + // the singleton MRO and must NEVER appear in instance dispatch. + // Building Sets once before the BFS loop avoids O(n²) `Array.includes` + // on large mixin hierarchies. + const walkedDirect = new Set(prependParents); + for (const id of otherParents) walkedDirect.add(id); + const singletonOnly = new Set( + heritageMap.getSingletonAncestry(ownerNodeId).map((e) => e.parentId), + ); + for (const ancestorId of heritageMap.getAncestors(ownerNodeId)) { + if (ancestorId === ownerNodeId) continue; + if (walkedDirect.has(ancestorId)) continue; + if (singletonOnly.has(ancestorId)) continue; + const method = model.methods.lookupMethodByOwner(ancestorId, methodName, argCount); + if (method) return method; + } + return undefined; + } + + // ── Non-Ruby strategies: direct-owner-first short-circuit ───────── + // Direct lookup first (child override — no walk needed). // argCount is threaded through so arity-differing overloads on the direct // owner can be disambiguated before the MRO walk starts. @@ -326,7 +401,9 @@ export const lookupMethodByOwnerWithMRO = ( // Determine ancestor walk order based on MRO strategy. // readonly to accept the cached (frozen) c3 linearization without copying. let ancestors: readonly string[]; - if (strategy === 'c3') { + if (ancestryOverride) { + ancestors = ancestryOverride; + } else if (strategy === 'c3') { // C3 linearization (memoized per HeritageMap // so repeated calls for the same owner within an ingestion run reuse the // linearization instead of rebuilding the parent map and re-running C3). diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 52c7a0d47..f8e0d3b53 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -108,6 +108,7 @@ export async function runChunkedParseAndResolve( allORMQueries: ExtractedORMQuery[]; bindingAccumulator: BindingAccumulator; resolutionContext: ReturnType; + usedWorkerPool: boolean; }> { const ctx = createResolutionContext(); const symbolTable = ctx.model.symbols; @@ -173,9 +174,11 @@ export async function runChunkedParseAndResolve( stats: { filesProcessed: 0, totalFiles: totalParseable, nodesCreated: graph.nodeCount }, }); - // Don't spawn workers for tiny repos — overhead exceeds benefit - const MIN_FILES_FOR_WORKERS = 15; - const MIN_BYTES_FOR_WORKERS = 512 * 1024; + // Don't spawn workers for tiny repos — overhead exceeds benefit. + // Test suites may lower the thresholds via `options.workerThresholdsForTest` + // to exercise the worker-pool path with small fixtures; see PipelineOptions. + const MIN_FILES_FOR_WORKERS = options?.workerThresholdsForTest?.minFiles ?? 15; + const MIN_BYTES_FOR_WORKERS = options?.workerThresholdsForTest?.minBytes ?? 512 * 1024; const totalBytes = parseableScanned.reduce((s, f) => s + f.size, 0); // Create worker pool once, reuse across chunks @@ -588,5 +591,9 @@ export async function runChunkedParseAndResolve( allORMQueries, bindingAccumulator, resolutionContext: ctx, + // Whether a worker pool was actually live for this run. False means the + // sequential fallback handled every chunk (either due to `skipWorkers`, + // the file-count/byte thresholds, or a pool-creation failure). + usedWorkerPool: workerPool !== undefined, }; } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts index 19ccec409..6415cb6e5 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts @@ -56,6 +56,13 @@ export interface ParseOutput { readonly allPathSet: ReadonlySet; /** Pass-through: total file count for progress reporting. */ totalFiles: number; + /** + * True if the parse phase spawned a live worker pool for this run. + * False means every chunk ran through the sequential fallback (skipWorkers, + * thresholds not met, or pool-creation failure). Primarily a test affordance: + * see `PipelineOptions.workerThresholdsForTest`. + */ + readonly usedWorkerPool: boolean; } export const parsePhase: PipelinePhase = { diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index c0cfa2992..e1f226289 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -43,6 +43,17 @@ export interface PipelineOptions { skipGraphPhases?: boolean; /** Force sequential parsing (no worker pool). Useful for testing the sequential path. */ skipWorkers?: boolean; + /** + * @internal Test-only override for worker-pool gating thresholds. + * When unset, production defaults apply (15 files OR 512 KB total bytes). + * Setting either field lowers the corresponding threshold so small test + * fixtures can still exercise the worker-pool path. Do not use from + * production call sites. + */ + workerThresholdsForTest?: { + minFiles?: number; + minBytes?: number; + }; } // ── Phase registry ───────────────────────────────────────────────────────── @@ -99,7 +110,10 @@ export const runPipelineFromRepo = async ( }); // Extract final results for the PipelineResult contract - const { totalFiles } = getPhaseOutput<{ totalFiles: number }>(results, 'parse'); + const { totalFiles, usedWorkerPool } = getPhaseOutput<{ + totalFiles: number; + usedWorkerPool: boolean; + }>(results, 'parse'); let communityResult: CommunitiesOutput['communityResult'] | undefined; let processResult: ProcessesOutput['processResult'] | undefined; @@ -123,5 +137,12 @@ export const runPipelineFromRepo = async ( }, }); - return { graph, repoPath, totalFileCount: totalFiles, communityResult, processResult }; + return { + graph, + repoPath, + totalFileCount: totalFiles, + communityResult, + processResult, + usedWorkerPool, + }; }; diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index a36ed103d..f482fbec6 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -153,7 +153,14 @@ export const CONTAINER_TYPE_TO_LABEL: Record = { mixin_declaration: 'Mixin', extension_declaration: 'Extension', class: 'Class', - module: 'Module', + // Ruby `module` declarations map to `Trait` so they participate in the + // class-like type registry used by `lookupClassByName` / `buildHeritageMap`. + // This lets `include` / `extend` / `prepend` mixin heritage resolve to + // the providing module. Safe for non-Ruby languages: the only supported + // grammar that uses the bare `module` AST node type as a container is + // Ruby (Rust uses `mod_item`). Any new language adding a `module` node + // type must explicitly reclassify here. + module: 'Trait', singleton_class: 'Class', // Ruby: class << self inherits enclosing class name object_declaration: 'Class', companion_object: 'Class', @@ -185,7 +192,17 @@ export function getLabelFromCaptures( if (captureMap['definition.struct']) return 'Struct'; if (captureMap['definition.enum']) return 'Enum'; if (captureMap['definition.namespace']) return 'Namespace'; - if (captureMap['definition.module']) return 'Module'; + if (captureMap['definition.module']) { + // Let providers reclassify module captures (e.g. Ruby remaps `Module`→`Trait` + // so mixin heritage resolves through `lookupClassByName`). Returning null + // from labelOverride means "skip this symbol"; treat it as a no-op here so + // we keep the default label rather than dropping a real definition. + if (provider.labelOverride) { + const override = provider.labelOverride(captureMap['definition.module'], 'Module'); + if (override && override !== 'Module') return override; + } + return 'Module'; + } if (captureMap['definition.trait']) return 'Trait'; if (captureMap['definition.impl']) return 'Impl'; if (captureMap['definition.type']) return 'TypeAlias'; diff --git a/gitnexus/src/core/ingestion/utils/ruby-self-call.ts b/gitnexus/src/core/ingestion/utils/ruby-self-call.ts new file mode 100644 index 000000000..16fc7e7ab --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/ruby-self-call.ts @@ -0,0 +1,101 @@ +// gitnexus/src/core/ingestion/utils/ruby-self-call.ts + +/** + * Ruby bare-call self-inference helper. + * + * Ruby makes `self` implicit for method calls inside instance and class bodies: + * `serialize` inside `Account#call_serialize` means `self.serialize`. Other + * supported languages make the receiver explicit in source (`this.x`, `self.x`), + * so tree-sitter produces a member call directly. Ruby's bare identifier + * produces either `callForm === 'free'` or `callForm === undefined` (body_statement + * identifier captures where the @call node IS the @call.name node), and + * `resolveFreeCall` does a global tiered name lookup — no MRO walk. + * + * This helper is a pure decision function consumed by the Ruby language + * provider's `inferImplicitReceiver` hook. Shared pipeline code never imports + * it directly — only `languages/ruby.ts` does. + */ + +import type { SyntaxNode } from './ast-helpers.js'; +import type { LanguageProvider } from '../language-provider.js'; + +/** + * Rewrite suggestion returned by `maybeRewriteRubyBareCallToSelf`. + * + * `callForm` is always `'member'`; `receiverName` is always `'self'`. + * `dispatchKind` controls the stage-4 ancestry view: + * - `'instance'` → prepend → direct → include (normal MRO) + * - `'singleton'` → extend providers only, no file-scoped fallback + * + * Consumed by `languages/ruby.ts § inferImplicitReceiver` (wraps into + * `ImplicitReceiverOverride`; `dispatchKind` becomes the `hint` field). + */ +export interface SelfCallRewrite { + readonly callForm: 'member'; + readonly receiverName: 'self'; + readonly receiverTypeName: string; + /** `'singleton'` when the enclosing method is `def self.foo` / inside a + * `singleton_class` body; `'instance'` otherwise. Controls MRO ancestry + * view selection in stage-4 dispatch. */ + readonly dispatchKind: 'instance' | 'singleton'; +} + +/** Maximum parent-walk depth to prevent runaway traversal. */ +const MAX_PARENT_DEPTH = 50; + +/** + * Returns true if `callNode` is inside a `singleton_method` or `singleton_class`. + * Stops at `class`/`module` boundary or MAX_PARENT_DEPTH (50) to bound traversal. + */ +function isInsideSingletonMethod(callNode: SyntaxNode): boolean { + let current: SyntaxNode | null = callNode.parent; + let depth = 0; + while (current && depth++ < MAX_PARENT_DEPTH) { + if (current.type === 'singleton_method') return true; + if (current.type === 'singleton_class') return true; + if (current.type === 'class' || current.type === 'module') return false; + current = current.parent; + } + return false; +} + +/** + * Pure decision function: should a bare Ruby call be rewritten as `self.method`? + * + * Returns a `SelfCallRewrite` when all gates pass; null otherwise. + * Gates (all required): `callForm` is `'free'` or `undefined`, strategy is + * `'ruby-mixin'`, `enclosingClassName` is non-null, name is not `'super'`, + * name is not a built-in. + * + * Note: Ruby body-statement identifiers produce `callForm === undefined` because + * the @call node IS the @call.name node in tree-sitter-ruby. + * + * Example: `calledName='serialize'` in `Account` instance method → + * `{callForm:'member', receiverName:'self', receiverTypeName:'Account', dispatchKind:'instance'}` + */ +export function maybeRewriteRubyBareCallToSelf( + calledName: string, + callForm: 'free' | 'member' | 'constructor' | undefined, + callNode: SyntaxNode, + enclosingClassName: string | null, + provider: Pick, +): SelfCallRewrite | null { + // Body-statement bare identifiers produce `callForm === undefined` because + // the @call node IS the @call.name node in tree-sitter-ruby. Treat both + // undefined and 'free' as qualifying. + if (callForm !== 'free' && callForm !== undefined) return null; + if (provider.mroStrategy !== 'ruby-mixin') return null; + if (!enclosingClassName) return null; + if (calledName === 'super') return null; + if (provider.isBuiltInName(calledName)) return null; + + const dispatchKind: SelfCallRewrite['dispatchKind'] = isInsideSingletonMethod(callNode) + ? 'singleton' + : 'instance'; + return { + callForm: 'member', + receiverName: 'self', + receiverTypeName: enclosingClassName, + dispatchKind, + }; +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 130f489ac..203e027fa 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -1400,33 +1400,36 @@ const processFileGroup = ( // Heritage edges (EXTENDS/IMPLEMENTS) are created by heritage-processor which runs // in PARALLEL with call-processor, so the graph edges don't exist when buildTypeEnv // runs. This pre-pass makes parent class information available for type resolution. + const provider = getProvider(language); const fileParentMap = new Map(); - for (const match of matches) { - const captureMap: Record = {}; - for (const c of match.captures) { - captureMap[c.name] = c.node; - } - if (captureMap['heritage.class'] && captureMap['heritage.extends']) { - const className: string = captureMap['heritage.class'].text; - const parentName: string = captureMap['heritage.extends'].text; - // Skip Go named fields (only anonymous fields are struct embedding) - const extendsNode = captureMap['heritage.extends']; - const fieldDecl = extendsNode.parent; - if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) - continue; - let parents = fileParentMap.get(className); - if (!parents) { - parents = []; - fileParentMap.set(className, parents); + if (provider.heritageExtractor) { + for (const match of matches) { + const captureMap: Record = {}; + for (const c of match.captures) { + captureMap[c.name] = c.node; + } + if (captureMap['heritage.class']) { + const heritageItems = provider.heritageExtractor.extract(captureMap, { + filePath: file.path, + language, + }); + for (const item of heritageItems) { + if (item.kind === 'extends') { + let parents = fileParentMap.get(item.className); + if (!parents) { + parents = []; + fileParentMap.set(item.className, parents); + } + if (!parents.includes(item.parentName)) parents.push(item.parentName); + } + } } - if (!parents.includes(parentName)) parents.push(parentName); } } // Build per-file type environment + constructor bindings in a single AST walk. // Constructor bindings are verified against the SymbolTable in processCallsFromExtracted. const parentMap: ReadonlyMap = fileParentMap; - const provider = getProvider(language); const typeEnv = buildTypeEnv(tree, language, { parentMap, enclosingFunctionFinder: provider?.enclosingFunctionFinder, @@ -1699,7 +1702,28 @@ const processFileGroup = ( if (callNameNode) { const calledName = callNameNode.text; - // Dispatch: route language-specific calls (heritage, properties, imports) + // Check heritage extractor for call-based heritage (e.g., Ruby include/extend/prepend) + if (provider.heritageExtractor?.extractFromCall) { + const heritageItems = provider.heritageExtractor.extractFromCall( + calledName, + callNode, + { filePath: file.path, language }, + ); + if (heritageItems !== null) { + for (const item of heritageItems) { + result.heritage.push({ + filePath: file.path, + className: item.className, + parentName: item.parentName, + kind: item.kind, + }); + } + continue; + } + } + + // Dispatch: route language-specific calls (properties, imports) + // Heritage routing is handled by heritageExtractor.extractFromCall above. const routed = callRouter?.(calledName, captureMap['call']); if (routed) { if (routed.kind === 'skip') continue; @@ -1713,18 +1737,6 @@ const processFileGroup = ( continue; } - if (routed.kind === 'heritage') { - for (const item of routed.items) { - result.heritage.push({ - filePath: file.path, - className: item.enclosingClass, - parentName: item.mixinName, - kind: item.heritageKind, - }); - } - continue; - } - if (routed.kind === 'properties') { const propEnclosingInfo = cachedFindEnclosingClassInfo( captureMap['call'], @@ -1878,41 +1890,29 @@ const processFileGroup = ( continue; } - // Extract heritage (extends/implements) + // Extract heritage (extends/implements) via provider heritage extractor if (captureMap['heritage.class']) { - if (captureMap['heritage.extends']) { - // Go struct embedding: the query matches ALL field_declarations with - // type_identifier, but only anonymous fields (no name) are embedded. - // Named fields like `Breed string` also match — skip them. - const extendsNode = captureMap['heritage.extends']; - const fieldDecl = extendsNode.parent; - const isNamedField = - fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name'); - if (!isNamedField) { + if (provider.heritageExtractor) { + const heritageItems = provider.heritageExtractor.extract(captureMap, { + filePath: file.path, + language, + }); + for (const item of heritageItems) { result.heritage.push({ filePath: file.path, - className: captureMap['heritage.class'].text, - parentName: captureMap['heritage.extends'].text, - kind: 'extends', + className: item.className, + parentName: item.parentName, + kind: item.kind, }); } + // When the extractor consumes the match, skip symbol processing below. + if (heritageItems.length > 0) { + continue; + } } - if (captureMap['heritage.implements']) { - result.heritage.push({ - filePath: file.path, - className: captureMap['heritage.class'].text, - parentName: captureMap['heritage.implements'].text, - kind: 'implements', - }); - } - if (captureMap['heritage.trait']) { - result.heritage.push({ - filePath: file.path, - className: captureMap['heritage.class'].text, - parentName: captureMap['heritage.trait'].text, - kind: 'trait-impl', - }); - } + // Fallback: the extractor returned [] (or is absent), but the match still + // carries a heritage-specific capture. The match belongs to a heritage + // clause and must not fall through to generic symbol processing. if ( captureMap['heritage.extends'] || captureMap['heritage.implements'] || diff --git a/gitnexus/src/types/pipeline.ts b/gitnexus/src/types/pipeline.ts index d8b4cbd69..331bdd6fc 100644 --- a/gitnexus/src/types/pipeline.ts +++ b/gitnexus/src/types/pipeline.ts @@ -11,4 +11,10 @@ export interface PipelineResult { totalFileCount: number; communityResult?: CommunityDetectionResult; processResult?: ProcessDetectionResult; + /** + * True if the parse phase spawned a worker pool for this run. False means + * the sequential fallback handled every chunk. Primarily a test affordance + * so regression suites can prove which path executed. + */ + usedWorkerPool: boolean; } diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-sequential-mixin/lib/account.rb b/gitnexus/test/fixtures/lang-resolution/ruby-sequential-mixin/lib/account.rb new file mode 100644 index 000000000..a0193f83c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-sequential-mixin/lib/account.rb @@ -0,0 +1,25 @@ +require_relative 'greetable' +require_relative 'logger_mixin' +require_relative 'prepended_override' + +class Account + include Greetable + extend LoggerMixin + prepend PrependedOverride + + def serialize + "account" + end + + def call_greet + greet + end + + def call_serialize + serialize + end + + def call_prepended_marker + prepended_marker + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-sequential-mixin/lib/greetable.rb b/gitnexus/test/fixtures/lang-resolution/ruby-sequential-mixin/lib/greetable.rb new file mode 100644 index 000000000..84b8b81cc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-sequential-mixin/lib/greetable.rb @@ -0,0 +1,5 @@ +module Greetable + def greet + "hello from greetable" + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-sequential-mixin/lib/logger_mixin.rb b/gitnexus/test/fixtures/lang-resolution/ruby-sequential-mixin/lib/logger_mixin.rb new file mode 100644 index 000000000..48ea441f1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-sequential-mixin/lib/logger_mixin.rb @@ -0,0 +1,5 @@ +module LoggerMixin + def log(msg) + puts msg + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-sequential-mixin/lib/prepended_override.rb b/gitnexus/test/fixtures/lang-resolution/ruby-sequential-mixin/lib/prepended_override.rb new file mode 100644 index 000000000..c2ab67107 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-sequential-mixin/lib/prepended_override.rb @@ -0,0 +1,13 @@ +module PrependedOverride + def serialize + "prepended" + end + + # Unique method name not defined elsewhere in the fixture. Calling this + # from Account proves the prepend heritage edge adds PrependedOverride to + # the MRO at all. Shadowed-name resolution (prepend > self) is deferred — + # see plan 001's "Deferred to Separate Tasks: Ruby MRO kind-ordering". + def prepended_marker + "prepended-only" + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-sequential-mixin/lib/usage.rb b/gitnexus/test/fixtures/lang-resolution/ruby-sequential-mixin/lib/usage.rb new file mode 100644 index 000000000..9eb37dd8b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-sequential-mixin/lib/usage.rb @@ -0,0 +1,10 @@ +require_relative 'account' + +class Usage + def run + a = Account.new + a.call_greet + a.call_serialize + Account.log("from Usage") + end +end diff --git a/gitnexus/test/integration/heritage-extractor-wiring.test.ts b/gitnexus/test/integration/heritage-extractor-wiring.test.ts new file mode 100644 index 000000000..47e62fda3 --- /dev/null +++ b/gitnexus/test/integration/heritage-extractor-wiring.test.ts @@ -0,0 +1,186 @@ +/** + * Integration tests for heritage extractor wiring via real tree-sitter output. + * + * Complements test/unit/heritage-extraction.test.ts, which exercises the + * configs and factory against mocked AST nodes. These tests drive the same + * extractors against **real** tree-sitter parses so that a drift between + * the per-language tree-sitter queries and the extractor configs would be + * caught here even if mocked unit tests keep passing. + * + * Context: PR #890 review follow-up. See + * docs/plans/2026-04-16-005-refactor-pr890-review-followups-plan.md Unit 3a. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import Parser from 'tree-sitter'; +import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { getProvider } from '../../src/core/ingestion/languages/index.js'; +import type { CaptureMap } from '../../src/core/ingestion/language-provider.js'; +import { rubyHeritageConfig } from '../../src/core/ingestion/heritage-extractors/configs/ruby.js'; + +let parser: Parser; + +beforeAll(async () => { + parser = await loadParser(); +}); + +/** Run the provider's tree-sitter queries over `code` and yield per-match capture maps. */ +function runQueries(code: string, lang: SupportedLanguages): CaptureMap[] { + const tree = parser.parse(code); + const provider = getProvider(lang); + const query = new Parser.Query(parser.getLanguage(), provider.treeSitterQueries); + const matches = query.matches(tree.rootNode); + + return matches.map((match) => { + const captureMap: Record = {}; + for (const capture of match.captures) { + captureMap[capture.name] = capture.node; + } + return captureMap as unknown as CaptureMap; + }); +} + +/** Parse `code` and return the first AST node whose type matches `nodeType`. */ +function findFirstNode(code: string, nodeType: string): any | null { + const tree = parser.parse(code); + const stack: any[] = [tree.rootNode]; + while (stack.length > 0) { + const node = stack.pop(); + if (node.type === nodeType) return node; + for (let i = node.childCount - 1; i >= 0; i--) { + stack.push(node.child(i)); + } + } + return null; +} + +// ─── Ruby extractFromCall — real AST ───────────────────────────────────────── + +describe('Ruby heritage extractFromCall (real tree-sitter AST)', () => { + beforeAll(async () => { + await loadLanguage(SupportedLanguages.Ruby); + }); + + const extract = rubyHeritageConfig.callBasedHeritage!.extract; + + it('3a-1: class Foo; include Bar; end → single include entry', () => { + const code = `class Foo\n include Bar\nend\n`; + const callNode = findFirstNode(code, 'call'); + expect(callNode).not.toBeNull(); + + const result = extract('include', callNode, 'foo.rb'); + expect(result).toEqual([{ className: 'Foo', parentName: 'Bar', kind: 'include' }]); + }); + + it('3a-2: include A, B, C produces three entries, one per constant arg', () => { + const code = `class Multi\n include A, B, C\nend\n`; + const callNode = findFirstNode(code, 'call'); + expect(callNode).not.toBeNull(); + + const result = extract('include', callNode, 'multi.rb'); + expect(result).toEqual([ + { className: 'Multi', parentName: 'A', kind: 'include' }, + { className: 'Multi', parentName: 'B', kind: 'include' }, + { className: 'Multi', parentName: 'C', kind: 'include' }, + ]); + }); + + it('3a-3: extend ActiveSupport::Concern (scope_resolution arg)', () => { + const code = `class Post\n extend ActiveSupport::Concern\nend\n`; + const callNode = findFirstNode(code, 'call'); + expect(callNode).not.toBeNull(); + + const result = extract('extend', callNode, 'post.rb'); + expect(result).toEqual([ + { className: 'Post', parentName: 'ActiveSupport::Concern', kind: 'extend' }, + ]); + }); + + it('3a-4: nested module/class resolves to the nearest class, not the module', () => { + const code = `module Outer\n class Inner\n include X\n end\nend\n`; + const callNode = findFirstNode(code, 'call'); + expect(callNode).not.toBeNull(); + + const result = extract('include', callNode, 'nested.rb'); + expect(result).toEqual([{ className: 'Inner', parentName: 'X', kind: 'include' }]); + }); + + it('3a-5: top-level include with no enclosing class returns []', () => { + const code = `include Foo\n`; + // NOTE: `include Foo` at top level may not produce a `call` node in tree-sitter-ruby; + // it often lowers to an `identifier` body_statement. Construct a realistic top-level + // call (`Kernel.include Foo`) to exercise the no-enclosing-class branch. + const fallback = `Kernel.include(Foo)\n`; + const callNode = findFirstNode(fallback, 'call'); + expect(callNode).not.toBeNull(); + + const result = extract('include', callNode, 'top.rb'); + expect(result).toEqual([]); + }); + + it('prepend inside a module uses the module as enclosingClass', () => { + const code = `module AppHelper\n prepend Logged\nend\n`; + const callNode = findFirstNode(code, 'call'); + expect(callNode).not.toBeNull(); + + const result = extract('prepend', callNode, 'helper.rb'); + expect(result).toEqual([{ className: 'AppHelper', parentName: 'Logged', kind: 'prepend' }]); + }); +}); + +// ─── TypeScript heritage.extract — real AST + real query captures ──────────── + +describe('TypeScript heritage extract (real tree-sitter captures)', () => { + beforeAll(async () => { + await loadLanguage(SupportedLanguages.TypeScript); + }); + + it('3a-6: class Child extends Parent {} produces one extends entry', () => { + const code = `class Child extends Parent {}\n`; + const captureMaps = runQueries(code, SupportedLanguages.TypeScript); + const heritageMatches = captureMaps.filter((m) => (m as any)['heritage.class']); + expect(heritageMatches.length).toBeGreaterThan(0); + + const provider = getProvider(SupportedLanguages.TypeScript); + const extractor = provider.heritageExtractor!; + const items = extractor.extract(heritageMatches[0], { + filePath: 'child.ts', + language: SupportedLanguages.TypeScript, + }); + + expect(items).toEqual([{ className: 'Child', parentName: 'Parent', kind: 'extends' }]); + }); + + it('3a-7: class Child extends Parent implements IFoo {} yields extends + implements', () => { + const code = `interface IFoo {}\nclass Parent {}\nclass Child extends Parent implements IFoo {}\n`; + const captureMaps = runQueries(code, SupportedLanguages.TypeScript); + const heritageMatches = captureMaps.filter( + (m) => + (m as any)['heritage.class'] && + ((m as any)['heritage.extends'] || (m as any)['heritage.implements']), + ); + expect(heritageMatches.length).toBeGreaterThan(0); + + const provider = getProvider(SupportedLanguages.TypeScript); + const extractor = provider.heritageExtractor!; + + const kinds = new Set(); + const parents = new Set(); + for (const cm of heritageMatches) { + const items = extractor.extract(cm, { + filePath: 'child.ts', + language: SupportedLanguages.TypeScript, + }); + for (const item of items) { + expect(item.className).toBe('Child'); + kinds.add(item.kind); + parents.add(item.parentName); + } + } + + expect(kinds).toContain('extends'); + expect(kinds).toContain('implements'); + expect(parents).toContain('Parent'); + expect(parents).toContain('IFoo'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts b/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts new file mode 100644 index 000000000..c3e105a15 --- /dev/null +++ b/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts @@ -0,0 +1,232 @@ +/** + * Regression: Ruby mixin heritage resolution must work on the sequential + * ingestion fallback AND the worker-pool path, with identical output. + * + * Guards the two Codex adversarial review findings addressed by plan + * `docs/plans/2026-04-17-001-fix-codex-adversarial-ruby-mixin-heritage-plan.md`: + * + * 1. Sequential-mode `sequentialHeritageMap` must include Ruby `include` / + * `extend` / `prepend` mixin ancestry before `processCalls` resolves calls + * against it. `extractExtractedHeritageFromFiles` now also runs + * `heritageExtractor.extractFromCall` during its prepass. + * + * 2. Ruby `module` declarations are relabeled to `Trait` so they participate + * in `lookupClassByName` / `buildHeritageMap`. + * + * The follow-up plan `docs/plans/2026-04-17-002-fix-ce-review-ruby-mixin-followups-plan.md` + * Units 1 and 2 harden this suite: + * - Worker mode actually spawns a worker pool (verified via + * `PipelineResult.usedWorkerPool`) instead of silently falling back. + * - The prepend-only `prepended_marker` assertion checks the resolved + * method's OWNER, so reverting the Module→Trait relabel (Unit 2 of + * plan 001) makes the test fail with a clear owner-mismatch instead + * of passing trivially on `Account`'s own method. + * + * Plan 003 adds the `'ruby-mixin'` MroStrategy and kind-aware ancestry + * (prepend / include / extend split). Plan 005 (the call-resolution DAG) + * installs `inferImplicitReceiver` and `selectDispatch` provider hooks that + * let Ruby self-rewrite bare-identifier calls to `self.method` so they take + * the owner-scoped MRO path. The shadow-name assertion below exercises the + * full chain: Module→Trait relabel + kind-aware MRO + self-inference. + * + * Known guard limitation (documented residual): reverting plan 001 Unit 1 + * alone (the sequential prepass extractFromCall) does NOT make these tests + * fail, because `processCalls` independently extracts call-based heritage + * into `rubyHeritage`, feeds it to `processHeritageFromExtracted` for graph + * edges, and the call resolver's global-name fallback can still locate + * mixin-provided methods without MRO ancestry. A stronger guard would need + * an ambiguous method name that only MRO can disambiguate; that requires + * cross-chunk or multi-class shadowing scenarios not covered by this + * fixture. Tracked as residual work in plan 002's Unit 3 (cross-chunk). + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import type { GraphRelationship } from '../../../src/core/graph/types.js'; +import { + FIXTURES, + getRelationships, + getNodesByLabel, + runPipelineFromRepo, + type PipelineOptions, + type PipelineResult, +} from './helpers.js'; + +const FIXTURE = path.join(FIXTURES, 'ruby-sequential-mixin'); + +async function runMode(opts: PipelineOptions): Promise { + return runPipelineFromRepo(FIXTURE, () => {}, opts); +} + +/** CALLS edges from `sourceName` whose target is a Method node. */ +function methodCallEdges(result: PipelineResult, sourceName: string): Set { + const edges = getRelationships(result, 'CALLS').filter( + (e) => e.source === sourceName && e.targetLabel === 'Method', + ); + return new Set(edges.map((e) => `${e.source} → ${e.target}`)); +} + +/** + * Find the name of the node that `HAS_METHOD`s this target node, if any. + * Returns `undefined` when no owner edge exists (e.g., top-level function). + */ +function findMethodOwner(result: PipelineResult, methodNodeId: string): string | undefined { + for (const rel of result.graph.iterRelationships() as IterableIterator) { + if (rel.type === 'HAS_METHOD' && rel.targetId === methodNodeId) { + return result.graph.getNode(rel.sourceId)?.properties.name; + } + } + return undefined; +} + +/** + * Return the owner names of every `Method` target reached by a CALLS edge + * starting at `sourceName` whose target's name matches `targetMethodName`. + * Used to assert WHICH provider resolved a shadowed method name like + * `serialize` (provided by both Account and PrependedOverride). + */ +function resolvedMethodOwners( + result: PipelineResult, + sourceName: string, + targetMethodName: string, +): string[] { + const owners: string[] = []; + for (const e of getRelationships(result, 'CALLS')) { + if (e.source === sourceName && e.targetLabel === 'Method' && e.target === targetMethodName) { + const owner = findMethodOwner(result, e.rel.targetId); + if (owner) owners.push(owner); + } + } + return owners.sort(); +} + +describe('Ruby mixin heritage: sequential vs worker parity', () => { + let sequential: PipelineResult; + let workers: PipelineResult; + + beforeAll(async () => { + sequential = await runMode({ skipWorkers: true }); + // Force the worker pool to spawn even though the fixture is tiny. + // Without this override, the pipeline's MIN_FILES_FOR_WORKERS / MIN_BYTES_FOR_WORKERS + // gate would fall back to sequential and the "worker vs sequential" parity + // assertion below would degenerate into sequential-vs-sequential. + workers = await runMode({ + skipWorkers: false, + workerThresholdsForTest: { minFiles: 1, minBytes: 0 }, + }); + }, 120000); + + it('exercises both pipeline paths (sequential and worker)', () => { + // If either of these assertions fails, every downstream parity check + // below is meaningless — both modes would be running the same path. + expect(sequential.usedWorkerPool).toBe(false); + expect(workers.usedWorkerPool).toBe(true); + }); + + it('labels Ruby modules as Trait in both modes', () => { + const expected = ['Greetable', 'LoggerMixin', 'PrependedOverride']; + expect(getNodesByLabel(sequential, 'Trait').sort()).toEqual(expected); + expect(getNodesByLabel(workers, 'Trait').sort()).toEqual(expected); + // No Ruby modules leak through as the inert `Module` label. + // The 'lib' module node is the fixture's top-level directory node, which + // the ingestion pipeline emits for every fixture root — unrelated to + // Ruby `module` declarations. Filtering it keeps the assertion specific + // to Ruby-module relabeling without being coupled to how directory nodes + // are emitted. + expect(getNodesByLabel(sequential, 'Module').filter((n) => n !== 'lib')).toEqual([]); + expect(getNodesByLabel(workers, 'Module').filter((n) => n !== 'lib')).toEqual([]); + }); + + it('sequential mode resolves include-provided method: call_greet → greet', () => { + const edges = methodCallEdges(sequential, 'call_greet'); + expect([...edges]).toContain('call_greet → greet'); + + // Stronger: the resolved `greet` must be owned by the `Greetable` module + // (relabeled to Trait). A regression in Unit 2 of plan 001 would either + // fail to resolve (owners = []) or resolve to some other owner. + const owners = resolvedMethodOwners(sequential, 'call_greet', 'greet'); + expect(owners).toContain('Greetable'); + }); + + it('sequential mode resolves prepend-only method: call_prepended_marker → PrependedOverride#prepended_marker', () => { + // `prepended_marker` is defined ONLY on PrependedOverride — not on + // Account, Greetable, or LoggerMixin. Narrow guard for the prepend + // provider entering the MRO (plan 001 / plan 003). + const owners = resolvedMethodOwners(sequential, 'call_prepended_marker', 'prepended_marker'); + expect(owners).toContain('PrependedOverride'); + }); + + it('sequential mode resolves prepend shadow: call_serialize → PrependedOverride#serialize (not Account#serialize)', () => { + // `Account` defines `def serialize` AND `prepend PrependedOverride` which + // also defines `serialize`. Ruby MRO says the prepended module wins. + // This assertion exercises the full chain: + // - Module→Trait relabel (plan 001 Unit 2) — PrependedOverride resolvable via lookupClassByName + // - `'ruby-mixin'` MroStrategy (plan 003 Unit 3) — prepend walks before direct owner + // - `inferImplicitReceiver` hook (plan 005 / DAG) — bare `serialize` call rewritten + // as `self.serialize` so it takes the owner-scoped MRO path + // Reverting ANY of the three makes this assertion fail. + const owners = resolvedMethodOwners(sequential, 'call_serialize', 'serialize'); + expect(owners).toContain('PrependedOverride'); + }); + + it('sequential mode resolves extend-provided class method: Usage#run → LoggerMixin#log', () => { + // `Account extend LoggerMixin` means `LoggerMixin#log` is a CLASS method + // on `Account`. The fixture's `Usage#run` calls `Account.log("from Usage")` + // — a class-constant receiver, not an instance call. This exercises the + // `selectDispatch` hook's `receiverSource === 'class-as-receiver'` branch, + // which returns `ancestryView: 'singleton'` so the walker uses + // `getSingletonAncestry(Account)` = [LoggerMixin] instead of the instance + // MRO (which would have resolved nothing, since `log` is not an instance + // method on Account). + // + // Reverting the singleton branch in Ruby's selectDispatch (or dropping + // 'Trait'/'Class' from the class-as-receiver filter in call-processor) + // makes this assertion fail. + const owners = resolvedMethodOwners(sequential, 'run', 'log'); + expect(owners).toContain('LoggerMixin'); + }); + + // TODO(plan-003-followup): assert that prepend shadows self for + // `call_serialize → PrependedOverride#serialize`. Blocked on Ruby bare-call + // self-inference: bare identifier calls like `serialize` inside `Account#call_serialize` + // currently flow through `resolveFreeCall` (global name lookup), not + // `resolveMemberCall` (owner-scoped + MRO walk). The `'ruby-mixin'` MroStrategy + // added by plan 003 is correctly wired and will apply as soon as Ruby bare calls + // are threaded as `self.method` with receiverTypeName = enclosing class. Until then, + // shadow-name resolution lands on `Account#serialize` regardless of prepend MRO. + // + // The `prepended_marker` test above is the narrower guard that works today + // (non-shadowed method only reachable via the prepend provider). + + it('sequential mode emits IMPLEMENTS edges for all three mixin kinds', () => { + // Ruby mixins (include / extend / prepend) flow through the IMPLEMENTS + // branch of processHeritageFromExtracted with the mixin kind recorded in + // rel.reason. See heritage-processor.ts L146-168. + const kinds = getRelationships(sequential, 'IMPLEMENTS') + .filter((e) => e.source === 'Account') + .map((e) => e.rel.reason ?? '') + .sort(); + expect(kinds).toEqual(['extend', 'include', 'prepend']); + }); + + it('worker mode resolves the same include and prepend-only targets', () => { + // Cross-mode ownership parity for the mixin providers. If Unit 1 of + // plan 001 regressed on the sequential side only, the `greet` / + // `prepended_marker` owners would diverge between modes here — the + // sequential side would lose the mixin-provided edges while worker + // mode kept them (or vice versa). + expect(resolvedMethodOwners(workers, 'call_greet', 'greet')).toContain('Greetable'); + expect(resolvedMethodOwners(workers, 'call_prepended_marker', 'prepended_marker')).toContain( + 'PrependedOverride', + ); + }); + + it('sequential and worker modes produce the same mixin-method CALLS edges', () => { + const seqEdges = methodCallEdges(sequential, 'call_greet'); + const workerEdges = methodCallEdges(workers, 'call_greet'); + expect([...seqEdges].sort()).toEqual([...workerEdges].sort()); + + const seqMarker = methodCallEdges(sequential, 'call_prepended_marker'); + const workerMarker = methodCallEdges(workers, 'call_prepended_marker'); + expect([...seqMarker].sort()).toEqual([...workerMarker].sort()); + }); +}); diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 8cb7a29e3..82806aac5 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -33,8 +33,12 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User', 'UserService']); }); - it('detects 3 modules', () => { - expect(getNodesByLabel(result, 'Module')).toEqual(['Cacheable', 'Loggable', 'Serializable']); + it('detects 3 modules (labeled as Trait for class-like registry lookup)', () => { + // Ruby `module` declarations are relabeled to `Trait` during ingestion so + // they participate in `lookupClassByName` and `buildHeritageMap`. This is + // the single source of truth for Ruby module detection in the graph. + expect(getNodesByLabel(result, 'Trait')).toEqual(['Cacheable', 'Loggable', 'Serializable']); + expect(getNodesByLabel(result, 'Module')).toEqual([]); }); it('detects methods on classes and modules', () => { @@ -431,9 +435,10 @@ describe('Ruby parent resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-parent-resolution'), () => {}); }, 60000); - it('detects BaseModel and User classes plus Serializable module', () => { + it('detects BaseModel and User classes plus Serializable module (Trait)', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']); - expect(getNodesByLabel(result, 'Module')).toEqual(['Serializable']); + // Ruby modules are labeled Trait — see the "detects 3 modules" test above. + expect(getNodesByLabel(result, 'Trait')).toEqual(['Serializable']); }); it('emits EXTENDS edge: User < BaseModel', () => { diff --git a/gitnexus/test/unit/call-routing/ruby.test.ts b/gitnexus/test/unit/call-routing/ruby.test.ts index e5e6858b3..1c602f3aa 100644 --- a/gitnexus/test/unit/call-routing/ruby.test.ts +++ b/gitnexus/test/unit/call-routing/ruby.test.ts @@ -251,197 +251,29 @@ describe('routeRubyCall — require / require_relative', () => { }); // ── include / extend / prepend ─────────────────────────────────────────────── +// Heritage routing (include/extend/prepend) is now handled by +// heritageExtractor.extractFromCall before the call router runs. +// routeRubyCall returns 'skip' so these calls don't fall through +// to normal call processing. -describe('routeRubyCall — include / extend / prepend', () => { - it('include with a single constant arg inside a class returns heritage', () => { +describe('routeRubyCall — include / extend / prepend (now delegated to heritageExtractor)', () => { + it('include returns skip (heritage handled by heritageExtractor)', () => { const node = makeHeritageCallNode([makeConstantArg('Serializable')], 'class', 'User'); - const result = routeRubyCall('include', node); - - expect(result).toEqual({ - kind: 'heritage', - items: [{ enclosingClass: 'User', mixinName: 'Serializable', heritageKind: 'include' }], - }); + expect(routeRubyCall('include', node)).toEqual({ kind: 'skip' }); }); - it('extend with a scope_resolution arg (Foo::Bar) returns heritage', () => { + it('extend returns skip (heritage handled by heritageExtractor)', () => { const node = makeHeritageCallNode( [makeScopeResolutionArg('ActiveSupport::Concern')], 'class', 'Post', ); - const result = routeRubyCall('extend', node); - - expect(result).toEqual({ - kind: 'heritage', - items: [ - { enclosingClass: 'Post', mixinName: 'ActiveSupport::Concern', heritageKind: 'extend' }, - ], - }); + expect(routeRubyCall('extend', node)).toEqual({ kind: 'skip' }); }); - it('prepend records heritageKind as "prepend"', () => { + it('prepend returns skip (heritage handled by heritageExtractor)', () => { const node = makeHeritageCallNode([makeConstantArg('Instrumented')], 'class', 'Service'); - const result = routeRubyCall('prepend', node); - - expect(result).toEqual({ - kind: 'heritage', - items: [{ enclosingClass: 'Service', mixinName: 'Instrumented', heritageKind: 'prepend' }], - }); - }); - - it('include inside a module (not a class) still resolves enclosing name', () => { - const node = makeHeritageCallNode([makeConstantArg('Helpers')], 'module', 'ApplicationHelper'); - const result = routeRubyCall('include', node); - - expect(result).toEqual({ - kind: 'heritage', - items: [ - { enclosingClass: 'ApplicationHelper', mixinName: 'Helpers', heritageKind: 'include' }, - ], - }); - }); - - it('include with multiple constant args produces one item per arg', () => { - const args = [makeConstantArg('Mod1'), makeConstantArg('Mod2'), makeConstantArg('Mod3')]; - const node = makeHeritageCallNode(args, 'class', 'MyClass'); - const result = routeRubyCall('include', node); - - expect(result).toEqual({ - kind: 'heritage', - items: [ - { enclosingClass: 'MyClass', mixinName: 'Mod1', heritageKind: 'include' }, - { enclosingClass: 'MyClass', mixinName: 'Mod2', heritageKind: 'include' }, - { enclosingClass: 'MyClass', mixinName: 'Mod3', heritageKind: 'include' }, - ], - }); - }); - - it('returns skip when no enclosing class or module is found in parent chain', () => { - const node = makeHeritageCallNode([makeConstantArg('Mod')], null, null); - expect(routeRubyCall('include', node)).toEqual({ kind: 'skip' }); - }); - - it('returns skip when enclosing class node has no name child', () => { - // nameNode is undefined — childForFieldName('name') returns undefined - const argList: MockNode = { - type: 'argument_list', - text: '', - children: [makeConstantArg('Mod')], - }; - const classNode: MockNode = { - type: 'class', - text: '', - parent: null, - childForFieldName: (_name: string) => undefined, - }; - const bodyNode: MockNode = { type: 'body', text: '', parent: classNode }; - const callNode: MockNode = { - type: 'call', - text: '', - parent: bodyNode, - childForFieldName: (name: string) => (name === 'arguments' ? argList : undefined), - }; - expect(routeRubyCall('include', callNode)).toEqual({ kind: 'skip' }); - }); - - it('returns skip when arg list contains only non-constant/non-scope_resolution args', () => { - const node = makeHeritageCallNode([makeIdentifierArg('some_var')], 'class', 'Foo'); - expect(routeRubyCall('include', node)).toEqual({ kind: 'skip' }); - }); - - it('returns skip when arg list is empty', () => { - const node = makeHeritageCallNode([], 'class', 'Foo'); - expect(routeRubyCall('include', node)).toEqual({ kind: 'skip' }); - }); - - it('walks nested scopes to find the nearest enclosing class', () => { - // callNode is 5 levels deep inside a class body - const node = makeHeritageCallNode([makeConstantArg('DeepMixin')], 'class', 'DeepClass', 5); - const result = routeRubyCall('include', node); - - expect(result).toEqual({ - kind: 'heritage', - items: [{ enclosingClass: 'DeepClass', mixinName: 'DeepMixin', heritageKind: 'include' }], - }); - }); - - it('returns skip when parent depth exceeds MAX_PARENT_DEPTH (50)', () => { - // Build a chain of 51 intermediate nodes with no class/module in it - const argList: MockNode = { - type: 'argument_list', - text: '', - children: [makeConstantArg('Mod')], - }; - const callNode: MockNode = { - type: 'call', - text: '', - parent: null, - childForFieldName: (name: string) => (name === 'arguments' ? argList : undefined), - }; - - let current: MockNode = callNode; - // Create 51 parents — all plain body nodes, never a class/module - for (let i = 0; i < 51; i++) { - const parent: MockNode = { type: 'body_statement', text: '', parent: null }; - current.parent = parent; - current = parent; - } - - expect(routeRubyCall('include', callNode)).toEqual({ kind: 'skip' }); - }); - - it('finds class at exactly depth 50 (boundary — must succeed)', () => { - // 49 plain wrappers, then the class at depth 50 - const argList: MockNode = { - type: 'argument_list', - text: '', - children: [makeConstantArg('BoundaryMixin')], - }; - const callNode: MockNode = { - type: 'call', - text: '', - parent: null, - childForFieldName: (name: string) => (name === 'arguments' ? argList : undefined), - }; - - let leaf: MockNode = callNode; - for (let i = 0; i < 49; i++) { - const wrapper: MockNode = { type: 'body_statement', text: '', parent: null }; - leaf.parent = wrapper; - leaf = wrapper; - } - - const nameNode: MockNode = { type: 'constant', text: 'BoundaryClass' }; - const classNode: MockNode = { - type: 'class', - text: '', - parent: null, - childForFieldName: (name: string) => (name === 'name' ? nameNode : undefined), - }; - leaf.parent = classNode; - - const result = routeRubyCall('include', callNode); - expect(result).toEqual({ - kind: 'heritage', - items: [ - { enclosingClass: 'BoundaryClass', mixinName: 'BoundaryMixin', heritageKind: 'include' }, - ], - }); - }); - - it('skips non-constant args mixed with constant args, collecting only constants', () => { - const args = [ - makeIdentifierArg('local_var'), - makeConstantArg('ValidMixin'), - makeIdentifierArg('another_var'), - ]; - const node = makeHeritageCallNode(args, 'class', 'Foo'); - const result = routeRubyCall('include', node); - - expect(result).toEqual({ - kind: 'heritage', - items: [{ enclosingClass: 'Foo', mixinName: 'ValidMixin', heritageKind: 'include' }], - }); + expect(routeRubyCall('prepend', node)).toEqual({ kind: 'skip' }); }); }); diff --git a/gitnexus/test/unit/heritage-extraction.test.ts b/gitnexus/test/unit/heritage-extraction.test.ts new file mode 100644 index 000000000..93010fa78 --- /dev/null +++ b/gitnexus/test/unit/heritage-extraction.test.ts @@ -0,0 +1,428 @@ +import { describe, it, expect } from 'vitest'; +import { createHeritageExtractor } from '../../src/core/ingestion/heritage-extractors/generic.js'; +import { rubyHeritageConfig } from '../../src/core/ingestion/heritage-extractors/configs/ruby.js'; +import { goHeritageConfig } from '../../src/core/ingestion/heritage-extractors/configs/go.js'; +import type { + HeritageExtractionConfig, + HeritageExtractorContext, +} from '../../src/core/ingestion/heritage-types.js'; +import type { CaptureMap } from '../../src/core/ingestion/language-provider.js'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { getProvider } from '../../src/core/ingestion/languages/index.js'; + +// --------------------------------------------------------------------------- +// Mock AST node helpers +// --------------------------------------------------------------------------- + +interface MockNode { + type: string; + text: string; + parent?: MockNode | null; + children?: MockNode[]; + childForFieldName?: (name: string) => MockNode | undefined; +} + +/** Create a minimal mock SyntaxNode for capture map entries. */ +function makeNode(text: string, type = 'identifier', parent?: MockNode): MockNode { + return { type, text, parent: parent ?? null }; +} + +/** Create a mock field_declaration node (Go struct fields). */ +function makeGoFieldDecl(opts: { hasName: boolean; typeName: string }): MockNode { + const nameNode = opts.hasName ? makeNode('MyField', 'field_identifier') : undefined; + const fieldDecl: MockNode = { + type: 'field_declaration', + text: '', + childForFieldName: (name: string) => (name === 'name' ? nameNode : undefined), + }; + const typeNode = makeNode(opts.typeName, 'type_identifier', fieldDecl); + return typeNode; +} + +/** Build a CaptureMap from partial entries. */ +function buildCaptureMap(entries: Record): CaptureMap { + return entries as unknown as CaptureMap; +} + +/** Default context for testing. */ +function ctx(filePath = 'Test.java', language = SupportedLanguages.Java): HeritageExtractorContext { + return { filePath, language }; +} + +// --------------------------------------------------------------------------- +// Factory construction +// --------------------------------------------------------------------------- + +describe('createHeritageExtractor', () => { + it('creates an extractor from a minimal config', () => { + const extractor = createHeritageExtractor(SupportedLanguages.Java); + expect(extractor).toBeDefined(); + expect(extractor.language).toBe(SupportedLanguages.Java); + expect(typeof extractor.extract).toBe('function'); + }); + + it('creates an extractor from a language enum (default config)', () => { + const languages: SupportedLanguages[] = [ + SupportedLanguages.Java, + SupportedLanguages.Kotlin, + SupportedLanguages.CSharp, + SupportedLanguages.TypeScript, + SupportedLanguages.JavaScript, + SupportedLanguages.CPlusPlus, + SupportedLanguages.C, + SupportedLanguages.Python, + SupportedLanguages.Rust, + SupportedLanguages.Dart, + SupportedLanguages.PHP, + SupportedLanguages.Swift, + ]; + for (const lang of languages) { + const extractor = createHeritageExtractor(lang); + expect(extractor.language, `${lang} extractor should have correct language`).toBe(lang); + expect(typeof extractor.extract).toBe('function'); + expect(extractor.extractFromCall).toBeUndefined(); + } + }); + + it('creates an extractor from full config with custom hooks', () => { + const configs: HeritageExtractionConfig[] = [goHeritageConfig, rubyHeritageConfig]; + for (const cfg of configs) { + expect( + () => createHeritageExtractor(cfg), + `config for ${cfg.language} must construct cleanly`, + ).not.toThrow(); + } + }); + + it('sets extractFromCall when callBasedHeritage is configured', () => { + const extractor = createHeritageExtractor(rubyHeritageConfig); + expect(extractor.extractFromCall).toBeDefined(); + expect(typeof extractor.extractFromCall).toBe('function'); + }); + + it('does not set extractFromCall for default language extractors', () => { + const extractor = createHeritageExtractor(SupportedLanguages.Java); + expect(extractor.extractFromCall).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Generic extraction from captures +// --------------------------------------------------------------------------- + +describe('HeritageExtractor.extract', () => { + const extractor = createHeritageExtractor(SupportedLanguages.Java); + + it('returns empty array when heritage.class is not present', () => { + const captures = buildCaptureMap({}); + expect(extractor.extract(captures, ctx())).toEqual([]); + }); + + it('returns empty array when heritage.class is present but no extends/implements/trait', () => { + const captures = buildCaptureMap({ + 'heritage.class': makeNode('MyClass'), + }); + expect(extractor.extract(captures, ctx())).toEqual([]); + }); + + it('extracts extends heritage', () => { + const captures = buildCaptureMap({ + 'heritage.class': makeNode('Child'), + 'heritage.extends': makeNode('Parent'), + }); + const result = extractor.extract(captures, ctx()); + expect(result).toEqual([{ className: 'Child', parentName: 'Parent', kind: 'extends' }]); + }); + + it('extracts implements heritage', () => { + const captures = buildCaptureMap({ + 'heritage.class': makeNode('MyClass'), + 'heritage.implements': makeNode('MyInterface'), + }); + const result = extractor.extract(captures, ctx()); + expect(result).toEqual([ + { className: 'MyClass', parentName: 'MyInterface', kind: 'implements' }, + ]); + }); + + it('extracts trait-impl heritage', () => { + const rustExtractor = createHeritageExtractor(SupportedLanguages.Rust); + const captures = buildCaptureMap({ + 'heritage.class': makeNode('MyStruct'), + 'heritage.trait': makeNode('Display'), + }); + const result = rustExtractor.extract(captures, ctx('main.rs', SupportedLanguages.Rust)); + expect(result).toEqual([{ className: 'MyStruct', parentName: 'Display', kind: 'trait-impl' }]); + }); + + it('extracts both extends and implements from same match', () => { + const captures = buildCaptureMap({ + 'heritage.class': makeNode('MyClass'), + 'heritage.extends': makeNode('BaseClass'), + 'heritage.implements': makeNode('ISerializable'), + }); + const result = extractor.extract(captures, ctx()); + expect(result).toEqual([ + { className: 'MyClass', parentName: 'BaseClass', kind: 'extends' }, + { className: 'MyClass', parentName: 'ISerializable', kind: 'implements' }, + ]); + }); + + it('extracts all three heritage kinds from same match', () => { + const captures = buildCaptureMap({ + 'heritage.class': makeNode('MyStruct'), + 'heritage.extends': makeNode('Base'), + 'heritage.implements': makeNode('IFace'), + 'heritage.trait': makeNode('Trait'), + }); + const result = extractor.extract(captures, ctx()); + expect(result).toEqual([ + { className: 'MyStruct', parentName: 'Base', kind: 'extends' }, + { className: 'MyStruct', parentName: 'IFace', kind: 'implements' }, + { className: 'MyStruct', parentName: 'Trait', kind: 'trait-impl' }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Go: shouldSkipExtends (named field detection) +// --------------------------------------------------------------------------- + +describe('Go HeritageExtractor — shouldSkipExtends', () => { + const extractor = createHeritageExtractor(goHeritageConfig); + + it('extracts anonymous struct embedding (no field name)', () => { + const typeNode = makeGoFieldDecl({ hasName: false, typeName: 'Animal' }); + const captures = buildCaptureMap({ + 'heritage.class': makeNode('Dog'), + 'heritage.extends': typeNode as unknown as CaptureMap[string], + }); + const result = extractor.extract(captures, ctx('main.go', SupportedLanguages.Go)); + expect(result).toEqual([{ className: 'Dog', parentName: 'Animal', kind: 'extends' }]); + }); + + it('skips named struct fields (Breed string)', () => { + const typeNode = makeGoFieldDecl({ hasName: true, typeName: 'string' }); + const captures = buildCaptureMap({ + 'heritage.class': makeNode('Dog'), + 'heritage.extends': typeNode as unknown as CaptureMap[string], + }); + const result = extractor.extract(captures, ctx('main.go', SupportedLanguages.Go)); + expect(result).toEqual([]); + }); + + it('extracts heritage when extends node has no parent', () => { + const orphanNode = makeNode('Embedded', 'type_identifier'); + orphanNode.parent = null; + const captures = buildCaptureMap({ + 'heritage.class': makeNode('Foo'), + 'heritage.extends': orphanNode as unknown as CaptureMap[string], + }); + const result = extractor.extract(captures, ctx('main.go', SupportedLanguages.Go)); + expect(result).toEqual([{ className: 'Foo', parentName: 'Embedded', kind: 'extends' }]); + }); +}); + +// --------------------------------------------------------------------------- +// Ruby: call-based heritage (include/extend/prepend) +// --------------------------------------------------------------------------- + +describe('Ruby HeritageExtractor — call-based heritage', () => { + const extractor = createHeritageExtractor(rubyHeritageConfig); + + /** Build a mock call node for include/extend/prepend. */ + function makeCallNode( + argNodes: MockNode[], + enclosingType: 'class' | 'module' | null, + enclosingName: string | null, + ): MockNode { + const argList: MockNode = { + type: 'argument_list', + text: '', + children: argNodes, + }; + const classNameNode = enclosingName ? makeNode(enclosingName, 'constant') : undefined; + const enclosingNode: MockNode | null = + enclosingType && enclosingName + ? { + type: enclosingType, + text: '', + parent: null, + childForFieldName: (name: string) => (name === 'name' ? classNameNode : undefined), + } + : null; + + const bodyNode: MockNode = { + type: 'body_statement', + text: '', + parent: enclosingNode, + }; + if (enclosingNode) enclosingNode.children = [bodyNode]; + + const callNode: MockNode = { + type: 'call', + text: '', + parent: bodyNode, + childForFieldName: (name: string) => (name === 'arguments' ? argList : undefined), + }; + return callNode; + } + + function makeConstantArg(name: string): MockNode { + return makeNode(name, 'constant'); + } + + function makeScopeResolutionArg(name: string): MockNode { + return makeNode(name, 'scope_resolution'); + } + + const rubyCtx = ctx('app.rb', SupportedLanguages.Ruby); + + it('returns null for non-heritage call names', () => { + const callNode = makeCallNode([makeConstantArg('Foo')], 'class', 'Bar'); + expect(extractor.extractFromCall!('puts', callNode as any, rubyCtx)).toBeNull(); + }); + + it('extracts include heritage with single constant arg', () => { + const callNode = makeCallNode([makeConstantArg('Serializable')], 'class', 'User'); + const result = extractor.extractFromCall!('include', callNode as any, rubyCtx); + expect(result).toEqual([{ className: 'User', parentName: 'Serializable', kind: 'include' }]); + }); + + it('extracts extend heritage with scope_resolution arg', () => { + const callNode = makeCallNode( + [makeScopeResolutionArg('ActiveSupport::Concern')], + 'class', + 'Post', + ); + const result = extractor.extractFromCall!('extend', callNode as any, rubyCtx); + expect(result).toEqual([ + { className: 'Post', parentName: 'ActiveSupport::Concern', kind: 'extend' }, + ]); + }); + + it('extracts prepend heritage', () => { + const callNode = makeCallNode([makeConstantArg('Instrumented')], 'class', 'Service'); + const result = extractor.extractFromCall!('prepend', callNode as any, rubyCtx); + expect(result).toEqual([{ className: 'Service', parentName: 'Instrumented', kind: 'prepend' }]); + }); + + it('extracts include inside a module', () => { + const callNode = makeCallNode([makeConstantArg('Helpers')], 'module', 'AppHelper'); + const result = extractor.extractFromCall!('include', callNode as any, rubyCtx); + expect(result).toEqual([{ className: 'AppHelper', parentName: 'Helpers', kind: 'include' }]); + }); + + it('extracts multiple constant args as separate heritage items', () => { + const args = [makeConstantArg('Mod1'), makeConstantArg('Mod2'), makeConstantArg('Mod3')]; + const callNode = makeCallNode(args, 'class', 'MyClass'); + const result = extractor.extractFromCall!('include', callNode as any, rubyCtx); + expect(result).toEqual([ + { className: 'MyClass', parentName: 'Mod1', kind: 'include' }, + { className: 'MyClass', parentName: 'Mod2', kind: 'include' }, + { className: 'MyClass', parentName: 'Mod3', kind: 'include' }, + ]); + }); + + it('returns empty array when no enclosing class/module', () => { + const argList: MockNode = { + type: 'argument_list', + text: '', + children: [makeConstantArg('Foo')], + }; + const callNode: MockNode = { + type: 'call', + text: '', + parent: null, + childForFieldName: (name: string) => (name === 'arguments' ? argList : undefined), + }; + const result = extractor.extractFromCall!('include', callNode as any, rubyCtx); + expect(result).toEqual([]); + }); + + it('skips non-constant/non-scope_resolution args', () => { + const args = [ + makeConstantArg('Mod1'), + makeNode('some_var', 'identifier'), // not constant or scope_resolution + makeConstantArg('Mod2'), + ]; + const callNode = makeCallNode(args, 'class', 'MyClass'); + const result = extractor.extractFromCall!('include', callNode as any, rubyCtx); + expect(result).toEqual([ + { className: 'MyClass', parentName: 'Mod1', kind: 'include' }, + { className: 'MyClass', parentName: 'Mod2', kind: 'include' }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Language-specific config validation +// --------------------------------------------------------------------------- + +describe('HeritageExtraction language configs', () => { + it('Go config has shouldSkipExtends hook', () => { + expect(goHeritageConfig.language).toBe(SupportedLanguages.Go); + expect(goHeritageConfig.shouldSkipExtends).toBeDefined(); + expect(typeof goHeritageConfig.shouldSkipExtends).toBe('function'); + }); + + it('Ruby config has callBasedHeritage', () => { + expect(rubyHeritageConfig.language).toBe(SupportedLanguages.Ruby); + expect(rubyHeritageConfig.callBasedHeritage).toBeDefined(); + expect(rubyHeritageConfig.callBasedHeritage!.callNames).toEqual( + new Set(['include', 'extend', 'prepend']), + ); + }); + + it('default language extractors have no custom hooks', () => { + const defaultLanguages: SupportedLanguages[] = [ + SupportedLanguages.Java, + SupportedLanguages.Kotlin, + SupportedLanguages.CSharp, + SupportedLanguages.TypeScript, + SupportedLanguages.JavaScript, + SupportedLanguages.CPlusPlus, + SupportedLanguages.C, + SupportedLanguages.Python, + SupportedLanguages.Rust, + SupportedLanguages.Dart, + SupportedLanguages.PHP, + SupportedLanguages.Swift, + ]; + for (const lang of defaultLanguages) { + const extractor = createHeritageExtractor(lang); + expect(extractor.language).toBe(lang); + expect(extractor.extractFromCall, `${lang} should not have extractFromCall`).toBeUndefined(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Provider wiring — every tree-sitter provider MUST have heritageExtractor +// --------------------------------------------------------------------------- + +describe('heritageExtractor on LanguageProvider', () => { + it('all tree-sitter providers have heritageExtractor defined', () => { + const languages: SupportedLanguages[] = [ + SupportedLanguages.TypeScript, + SupportedLanguages.JavaScript, + SupportedLanguages.Python, + SupportedLanguages.Java, + SupportedLanguages.Kotlin, + SupportedLanguages.Go, + SupportedLanguages.Rust, + SupportedLanguages.CSharp, + SupportedLanguages.C, + SupportedLanguages.CPlusPlus, + SupportedLanguages.PHP, + SupportedLanguages.Ruby, + SupportedLanguages.Swift, + SupportedLanguages.Dart, + SupportedLanguages.Vue, + ]; + for (const lang of languages) { + const provider = getProvider(lang); + expect(provider.heritageExtractor, `${lang} should have a heritageExtractor`).toBeDefined(); + } + }); +}); diff --git a/gitnexus/test/unit/resolve-enclosing-owner.test.ts b/gitnexus/test/unit/resolve-enclosing-owner.test.ts index b00da7d8c..6139d69af 100644 --- a/gitnexus/test/unit/resolve-enclosing-owner.test.ts +++ b/gitnexus/test/unit/resolve-enclosing-owner.test.ts @@ -87,7 +87,10 @@ end expect(info).not.toBeNull(); expect(info!.className).toBe('Helpers'); - expect(info!.classId).toContain('Module'); + // Ruby modules are labeled `Trait` so mixin heritage resolves through + // the class-like type registry; the enclosing class id switches labels + // in lockstep with the structure-phase label. + expect(info!.classId).toContain('Trait'); }); it('returns null for file-level singleton_class without enclosing class', () => { diff --git a/gitnexus/test/unit/ruby-self-call.test.ts b/gitnexus/test/unit/ruby-self-call.test.ts new file mode 100644 index 000000000..1ac439a3b --- /dev/null +++ b/gitnexus/test/unit/ruby-self-call.test.ts @@ -0,0 +1,316 @@ +/** + * Unit tests for `maybeRewriteRubyBareCallToSelf` — the self-inference helper + * that rewrites Ruby bare-identifier calls inside class/module bodies into + * `self`-receiver member calls (plan 005 DAG / Ruby `inferImplicitReceiver`). + * + * The helper is pure: given the call name, callForm, AST node, enclosing + * class, and a minimal provider shape, it returns a rewrite suggestion or + * null. These tests pin each gate + branch so regressions surface at unit + * level rather than through the Ruby integration fixtures. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import Parser from 'tree-sitter'; +import Ruby from 'tree-sitter-ruby'; +import { maybeRewriteRubyBareCallToSelf } from '../../src/core/ingestion/utils/ruby-self-call.js'; +import type { LanguageProvider } from '../../src/core/ingestion/language-provider.js'; +import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; + +const parser = new Parser(); + +beforeAll(() => { + parser.setLanguage(Ruby as unknown as Parser.Language); +}); + +function parseRuby(src: string): SyntaxNode { + return parser.parse(src).rootNode; +} + +/** Find the first identifier node whose `text` matches `name` (DFS, pre-order). */ +function findIdentifier(root: SyntaxNode, name: string): SyntaxNode | null { + const stack: SyntaxNode[] = [root]; + while (stack.length) { + const node = stack.pop()!; + if (node.type === 'identifier' && node.text === name) return node; + for (let i = node.childCount - 1; i >= 0; i--) { + const child = node.child(i); + if (child) stack.push(child); + } + } + return null; +} + +/** Minimal provider stub matching the helper's structural expectations. */ +const rubyProviderStub: Pick = { + isBuiltInName: (name: string) => + new Set(['puts', 'p', 'raise', 'require', 'include', 'extend', 'prepend', 'attr_accessor']).has( + name, + ), + mroStrategy: 'ruby-mixin', +}; + +const nonRubyProviderStub: Pick = { + isBuiltInName: () => false, + mroStrategy: 'first-wins', +}; + +describe('maybeRewriteRubyBareCallToSelf', () => { + it('rewrites bare call inside instance method → self-receiver member call', () => { + const root = parseRuby(` +class Account + def call_greet + greet + end +end +`); + const call = findIdentifier(root, 'greet')!; + expect(call).not.toBeNull(); + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'greet', + 'free', + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite).toEqual({ + callForm: 'member', + receiverName: 'self', + receiverTypeName: 'Account', + dispatchKind: 'instance', + }); + }); + + it('accepts `callForm === undefined` (body_statement bare identifier captures)', () => { + // Ruby body-statement captures produce callForm === undefined because the + // @call node IS the @call.name node. The helper must accept both undefined + // and 'free'. + const root = parseRuby(` +class Account + def work + helper + end +end +`); + const call = findIdentifier(root, 'helper')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'helper', + undefined, + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite?.callForm).toBe('member'); + expect(rewrite?.receiverTypeName).toBe('Account'); + }); + + it('flags singleton dispatch for calls inside `def self.foo` bodies', () => { + const root = parseRuby(` +class Account + def self.factory + log("building") + end +end +`); + const call = findIdentifier(root, 'log')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'log', + 'free', + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite?.dispatchKind).toBe('singleton'); + expect(rewrite?.receiverTypeName).toBe('Account'); + }); + + it('flags singleton dispatch for calls inside `class << self` body', () => { + const root = parseRuby(` +class Account + class << self + def factory + log("msg") + end + end +end +`); + const call = findIdentifier(root, 'log')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'log', + 'free', + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite?.dispatchKind).toBe('singleton'); + }); + + it('returns null for Kernel built-in methods (puts)', () => { + const root = parseRuby(` +class Account + def greet + puts "hi" + end +end +`); + const call = findIdentifier(root, 'puts')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'puts', + 'free', + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite).toBeNull(); + }); + + it('returns null for `super` keyword', () => { + const root = parseRuby(` +class Account + def save + super + end +end +`); + const call = findIdentifier(root, 'super') ?? root; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'super', + 'free', + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite).toBeNull(); + }); + + it('returns null at file-level (no enclosing class)', () => { + const root = parseRuby(` +some_top_level_call +`); + const call = findIdentifier(root, 'some_top_level_call')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'some_top_level_call', + 'free', + call, + null, + rubyProviderStub, + ); + expect(rewrite).toBeNull(); + }); + + it('returns null when callForm is already member (explicit receiver present)', () => { + const root = parseRuby(` +class Account + def greet + self.say_hello + end +end +`); + const call = findIdentifier(root, 'say_hello')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'say_hello', + 'member', + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite).toBeNull(); + }); + + it('returns null when callForm is constructor', () => { + const root = parseRuby(` +class Account + def build + Account.new + end +end +`); + const call = findIdentifier(root, 'new')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'new', + 'constructor', + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite).toBeNull(); + }); + + it('returns null for non-Ruby providers (mroStrategy !== ruby-mixin)', () => { + const root = parseRuby(` +class Account + def greet + some_call + end +end +`); + const call = findIdentifier(root, 'some_call')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'some_call', + 'free', + call, + 'Account', + nonRubyProviderStub, + ); + expect(rewrite).toBeNull(); + }); + + it('uses module name as receiverTypeName for calls inside module body', () => { + const root = parseRuby(` +module Helpers + def format + capitalize + end +end +`); + const call = findIdentifier(root, 'capitalize')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'capitalize', + 'free', + call, + 'Helpers', + rubyProviderStub, + ); + expect(rewrite).toEqual({ + callForm: 'member', + receiverName: 'self', + receiverTypeName: 'Helpers', + dispatchKind: 'instance', + }); + }); + + it('terminates walk at enclosing class/module (does not walk past into outer scopes)', () => { + // `outer_call` is inside `Inner#work`; Inner is nested inside Outer. + // Enclosing class is Inner — the helper should return dispatchKind='instance' + // without escaping to Outer's singleton methods (if any existed). + const root = parseRuby(` +module Outer + class Inner + def work + outer_call + end + end +end +`); + const call = findIdentifier(root, 'outer_call')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'outer_call', + 'free', + call, + 'Inner', + rubyProviderStub, + ); + expect(rewrite?.dispatchKind).toBe('instance'); + expect(rewrite?.receiverTypeName).toBe('Inner'); + }); +}); diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index 7bf0d7b4b..28fc8e08b 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -1678,6 +1678,230 @@ describe('lookupMethodByOwnerWithMRO', () => { expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:User:getName'); }); + + // ── ruby-mixin: kind-aware MRO walk (prepend > self > include) ──── + // + // Ruby's `'ruby-mixin'` strategy is the only one that does NOT short-circuit + // on direct-owner lookup first — prepend providers must beat the class's + // own method of the same name. These tests exercise the walk order directly + // through lookupMethodByOwnerWithMRO rather than through the full pipeline. + + it("ruby-mixin: prepend provider beats class's own method (shadow)", () => { + ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class'); + ctx.model.symbols.add('lib/prep.rb', 'PrependedOverride', 'trait:PrependedOverride', 'Trait'); + ctx.model.symbols.add('lib/account.rb', 'serialize', 'method:Account:serialize', 'Method', { + returnType: 'String', + ownerId: 'class:Account', + }); + ctx.model.symbols.add( + 'lib/prep.rb', + 'serialize', + 'method:PrependedOverride:serialize', + 'Method', + { returnType: 'String', ownerId: 'trait:PrependedOverride' }, + ); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'lib/account.rb', + className: 'Account', + parentName: 'PrependedOverride', + kind: 'prepend', + }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:Account', + 'serialize', + map, + ctx.model, + 'ruby-mixin', + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:PrependedOverride:serialize'); + }); + + it("ruby-mixin: class's own method wins over include provider (shadow)", () => { + ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class'); + ctx.model.symbols.add('lib/mixin.rb', 'Greetable', 'trait:Greetable', 'Trait'); + ctx.model.symbols.add('lib/account.rb', 'greet', 'method:Account:greet', 'Method', { + returnType: 'String', + ownerId: 'class:Account', + }); + ctx.model.symbols.add('lib/mixin.rb', 'greet', 'method:Greetable:greet', 'Method', { + returnType: 'String', + ownerId: 'trait:Greetable', + }); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'lib/account.rb', + className: 'Account', + parentName: 'Greetable', + kind: 'include', + }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:Account', + 'greet', + map, + ctx.model, + 'ruby-mixin', + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:Account:greet'); + }); + + it('ruby-mixin: include provider used when class lacks the method', () => { + ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class'); + ctx.model.symbols.add('lib/mixin.rb', 'Greetable', 'trait:Greetable', 'Trait'); + ctx.model.symbols.add('lib/mixin.rb', 'greet', 'method:Greetable:greet', 'Method', { + returnType: 'String', + ownerId: 'trait:Greetable', + }); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'lib/account.rb', + className: 'Account', + parentName: 'Greetable', + kind: 'include', + }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:Account', + 'greet', + map, + ctx.model, + 'ruby-mixin', + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:Greetable:greet'); + }); + + it('ruby-mixin: extend providers excluded from instance-dispatch walk', () => { + ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class'); + ctx.model.symbols.add('lib/logger.rb', 'LoggerMixin', 'trait:LoggerMixin', 'Trait'); + ctx.model.symbols.add('lib/logger.rb', 'log', 'method:LoggerMixin:log', 'Method', { + returnType: 'void', + ownerId: 'trait:LoggerMixin', + }); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'lib/account.rb', + className: 'Account', + parentName: 'LoggerMixin', + kind: 'extend', + }, + ]; + const map = buildHeritageMap(heritage, ctx); + + // Instance dispatch: `extend` providers MUST NOT appear in the walk. + // Result is undefined — Account has no instance `log`. + const result = lookupMethodByOwnerWithMRO('class:Account', 'log', map, ctx.model, 'ruby-mixin'); + expect(result).toBeUndefined(); + }); + + it('ruby-mixin: singleton ancestryOverride routes to extend provider', () => { + ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class'); + ctx.model.symbols.add('lib/logger.rb', 'LoggerMixin', 'trait:LoggerMixin', 'Trait'); + ctx.model.symbols.add('lib/logger.rb', 'log', 'method:LoggerMixin:log', 'Method', { + returnType: 'void', + ownerId: 'trait:LoggerMixin', + }); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'lib/account.rb', + className: 'Account', + parentName: 'LoggerMixin', + kind: 'extend', + }, + ]; + const map = buildHeritageMap(heritage, ctx); + + // Singleton dispatch: caller pre-computes the singleton ancestry and + // passes it as ancestryOverride. The walker scans it linearly without + // the prepend/direct/include partition. + const singletonAncestry = map.getSingletonAncestry('class:Account').map((e) => e.parentId); + const result = lookupMethodByOwnerWithMRO( + 'class:Account', + 'log', + map, + ctx.model, + 'ruby-mixin', + undefined, + singletonAncestry, + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:LoggerMixin:log'); + }); + + it('ruby-mixin: transitive mixin — module provides method via an included module', () => { + // class Account; include Outer; end + // module Outer; include Inner; end + // module Inner; def helper; end; end + ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class'); + ctx.model.symbols.add('lib/outer.rb', 'Outer', 'trait:Outer', 'Trait'); + ctx.model.symbols.add('lib/inner.rb', 'Inner', 'trait:Inner', 'Trait'); + ctx.model.symbols.add('lib/inner.rb', 'helper', 'method:Inner:helper', 'Method', { + returnType: 'void', + ownerId: 'trait:Inner', + }); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'lib/account.rb', + className: 'Account', + parentName: 'Outer', + kind: 'include', + }, + { filePath: 'lib/outer.rb', className: 'Outer', parentName: 'Inner', kind: 'include' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:Account', + 'helper', + map, + ctx.model, + 'ruby-mixin', + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:Inner:helper'); + }); + + it('ruby-mixin: stacked prepends — last-prepended wins', () => { + // class A; prepend P1; prepend P2; end + // Ruby MRO places P2 ahead of P1; last-prepended is closest to self. + ctx.model.symbols.add('lib/a.rb', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('lib/p1.rb', 'P1', 'trait:P1', 'Trait'); + ctx.model.symbols.add('lib/p2.rb', 'P2', 'trait:P2', 'Trait'); + ctx.model.symbols.add('lib/p1.rb', 'foo', 'method:P1:foo', 'Method', { + returnType: 'String', + ownerId: 'trait:P1', + }); + ctx.model.symbols.add('lib/p2.rb', 'foo', 'method:P2:foo', 'Method', { + returnType: 'String', + ownerId: 'trait:P2', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'lib/a.rb', className: 'A', parentName: 'P1', kind: 'prepend' }, + { filePath: 'lib/a.rb', className: 'A', parentName: 'P2', kind: 'prepend' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO('class:A', 'foo', map, ctx.model, 'ruby-mixin'); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:P2:foo'); + }); }); // ---------------------------------------------------------------------------