feat(ingestion): language-agnostic heritage extractor with config+factory pattern (#890)

This commit is contained in:
Copilot 2026-04-17 17:51:17 +01:00 committed by GitHub
parent daca8360bf
commit dfa449ef41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 3023 additions and 722 deletions

View file

@ -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

View file

@ -142,6 +142,71 @@ export const myPhase: PipelinePhase<MyPhaseOutput> = {
---
## 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 34); 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 36 |
| `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:

205
CLAUDE.md
View file

@ -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:start -->
# 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: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — 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` |
<!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index.
<!-- gitnexus:start -->
# 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: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — 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` |
<!-- gitnexus:end -->
See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** for the canonical MCP tools, impact analysis rules, and index instructions.

View file

@ -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 `<T as Trait>::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 `<Type as Trait>::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';

View file

@ -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<string, string[]>();
for (const match of matches) {
const captureMap: Record<string, any> = {};
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<string, any> = {};
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<string, readonly string[]> = 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);

View file

@ -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 ───

View file

@ -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<ReceiverEnriched['receiverSource'], 'implicit-self'>;
/** 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;
}

View file

@ -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;
},
};

View file

@ -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<string> = 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;
},
},
};

View file

@ -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);
},
}
: {}),
};
}

View file

@ -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<string, any> = {};
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<string, any> = {};
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,
});
}
}
}
}

View file

@ -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<string>;
extract(calledName: string, callNode: SyntaxNode, filePath: string): HeritageInfo[];
};
}

View file

@ -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). */

View file

@ -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<string> = 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,
});

View file

@ -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<string> = 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,
});

View file

@ -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,
});

View file

@ -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),
});

View file

@ -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),
});

View file

@ -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;

View file

@ -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<string> = 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,

View file

@ -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<string> = 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,
});

View file

@ -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<string> = 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,
});

View file

@ -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,
});

View file

@ -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,
});

View file

@ -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,
});

View file

@ -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,
});

View file

@ -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<parentNodeId> (Set to deduplicate cross-chunk duplicates)
const directParents = new Map<string, Set<string>>();
// 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<string, ParentEntry[]>();
const seenParents = new Map<string, Set<string>>();
// interfaceName → Set<filePath> (implementor lookup for interface dispatch)
const implementorFiles = new Map<string, Set<string>>();
@ -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<string>();
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<string>();
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<string> => {
return implementorFiles.get(interfaceName) ?? EMPTY_SET;
};
return { getParents, getAncestors, getImplementorFiles };
return {
getParents,
getAncestors,
getParentEntries,
getInstanceAncestry,
getSingletonAncestry,
getImplementorFiles,
};
};

View file

@ -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<string>(prependParents);
for (const id of otherParents) walkedDirect.add(id);
const singletonOnly = new Set<string>(
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).

View file

@ -108,6 +108,7 @@ export async function runChunkedParseAndResolve(
allORMQueries: ExtractedORMQuery[];
bindingAccumulator: BindingAccumulator;
resolutionContext: ReturnType<typeof createResolutionContext>;
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,
};
}

View file

@ -56,6 +56,13 @@ export interface ParseOutput {
readonly allPathSet: ReadonlySet<string>;
/** 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<ParseOutput> = {

View file

@ -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,
};
};

View file

@ -153,7 +153,14 @@ export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
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';

View file

@ -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<LanguageProvider, 'isBuiltInName' | 'mroStrategy'>,
): 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,
};
}

View file

@ -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<string, string[]>();
for (const match of matches) {
const captureMap: Record<string, SyntaxNode> = {};
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<string, SyntaxNode> = {};
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<string, readonly string[]> = 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'] ||

View file

@ -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;
}

View file

@ -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

View file

@ -0,0 +1,5 @@
module Greetable
def greet
"hello from greetable"
end
end

View file

@ -0,0 +1,5 @@
module LoggerMixin
def log(msg)
puts msg
end
end

View file

@ -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

View file

@ -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

View file

@ -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<string, any> = {};
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<string>();
const parents = new Set<string>();
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');
});
});

View file

@ -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 ModuleTrait 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: ModuleTrait 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<PipelineResult> {
return runPipelineFromRepo(FIXTURE, () => {}, opts);
}
/** CALLS edges from `sourceName` whose target is a Method node. */
function methodCallEdges(result: PipelineResult, sourceName: string): Set<string> {
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<GraphRelationship>) {
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());
});
});

View file

@ -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', () => {

View file

@ -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' });
});
});

View file

@ -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<string, MockNode | undefined>): 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();
}
});
});

View file

@ -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', () => {

View file

@ -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<LanguageProvider, 'isBuiltInName' | 'mroStrategy'> = {
isBuiltInName: (name: string) =>
new Set(['puts', 'p', 'raise', 'require', 'include', 'extend', 'prepend', 'attr_accessor']).has(
name,
),
mroStrategy: 'ruby-mixin',
};
const nonRubyProviderStub: Pick<LanguageProvider, 'isBuiltInName' | 'mroStrategy'> = {
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');
});
});

View file

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