refactor(ingestion): call-resolution DAG with provider hooks decoupling language logic

Refactors the call-resolution pipeline inside the parse phase into a typed
6-stage DAG where language-specific behavior lives entirely behind provider
hooks. Shared pipeline code names no languages; adding a new language's
implicit-receiver or dispatch semantics is a provider-method change, not a
shared-code edit.

DAG stages:

  extract-call → classify-form → infer-receiver → select-dispatch →
  resolve-target → emit-edge

Stages 1-2 run in the parse worker; stages 3-6 run main-thread in
call-processor. Provider hooks plug in at stages 3 and 4.

New provider hooks on LanguageProvider:

- inferImplicitReceiver: synthesizes receiver when language makes it implicit
  (Ruby bare serialize → self.serialize). Returns ImplicitReceiverOverride or
  null. Runs after the shared receiver-inference chain (TypeEnv →
  constructor-map → class-as-receiver → mixed-chain) so explicit receivers
  always take precedence.

- selectDispatch: returns a DispatchDecision with primary (owner-scoped /
  free / constructor), optional fallback (free-arity-narrowed), optional
  ancestryView (instance / singleton). Defaults come from
  defaultDispatchDecision when the hook returns null.

DAG types in gitnexus/src/core/ingestion/call-types.ts:

- ReceiverEnriched: stage-3 output; carries receiverSource discriminant
  (none / typed-binding / constructor-map / class-as-receiver / mixed-chain
  / implicit-self). Only variants with live writers are declared.
- ImplicitReceiverOverride: hook return shape for implicit-self synthesis.
- DispatchDecision: stage-4 output; language-agnostic dispatch contract.

resolveCallTarget now dispatches on decision.primary (was: callForm ladder
with receiverTypeName gate). Singleton-ancestry miss MUST NOT degrade to
resolveMemberCallByFile — the file-scoped fallback is ownerId-keyed and
would leak instance methods onto class-constant receivers. Singleton miss
null-routes or honors decision.fallback.

lookupMethodByOwnerWithMRO accepts ancestryOverride for singleton dispatch
and the existing 'ruby-mixin' strategy (prepend → direct → include walk).

Ruby implementation (languages/ruby.ts):

- inferImplicitReceiver wraps the new pure helper
  maybeRewriteRubyBareCallToSelf (utils/ruby-self-call.ts) to detect bare
  calls inside class/module bodies and synthesize self.method with the
  enclosing class as receiverTypeName. Tags singleton dispatch via the hint
  field for def self.foo and class << self bodies.
- selectDispatch returns fallback=free-arity-narrowed for implicit-self so
  unresolved self-calls preserve the cross-class arity-narrowing heuristic
  (Service#run_task → OneArg#write_audit still works). Returns
  ancestryView=singleton for class-as-receiver calls (Account.log →
  LoggerMixin#log via extend).
- rubyResolveEnclosingOwner extracted to module-level const, shared by
  provider.resolveEnclosingOwner and inferImplicitReceiver.

Shared code changes that needed to accompany the DAG:

- class-as-receiver inference filter now accepts Trait (Ruby modules as
  receivers reach the singleton branch).
- Local ReceiverSource union in call-processor replaced with an import from
  call-types (single source of truth).
- defaultDispatchDecision helper and DAG-stage labels make the pipeline
  grep-discoverable.

Observable wins:

- Ruby call_serialize → PrependedOverride#serialize: prepend wins over
  class's own method (kind-aware MRO is now reachable from bare calls).
- Usage#run → LoggerMixin#log: class-method dispatch via extend resolves
  through singleton ancestry.
- Service#run_task → OneArg#write_audit: arity-narrowing still works;
  fallback preserves the existing free-call heuristic for implicit-self.

Tests: 1787 integration tests pass across all 12 languages (12 new unit
tests for maybeRewriteRubyBareCallToSelf, 1 new integration assertion for
Usage#run → LoggerMixin#log, tightened shadow-name assertion for
call_serialize → PrependedOverride#serialize).

Docs:

- ARCHITECTURE.md gains a Call-Resolution DAG section documenting stages,
  hook signatures, DispatchDecision semantics, the "how to add language
  behavior" recipe, and non-goals.
- AGENTS.md adds a rule enforcing "shared ingestion pipeline code does not
  name languages — use LanguageProvider hooks."
- CLAUDE.md mirrors the pointer; legacy duplicate MCP block trimmed.
- JSDoc across 7 touched code files documents DAG stages, hook contracts,
  invariants (notably the singleton-miss no-fallback guarantee), and
  canonical vocabulary. Token-optimized via follow-up pass to preserve all
  load-bearing facts while cutting ~47% of doc tokens.
This commit is contained in:
Gergo Magyar 2026-04-17 17:04:05 +01:00
parent 21744bdbfa
commit 0c4a8ebf11
12 changed files with 988 additions and 314 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,27 +1,41 @@
/**
* 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`).
* - `ruby-mixin`: Kind-aware walk (Ruby). Walks `prepend` parents first
* (reverse declaration order last-prepended wins),
* then the direct owner's own methods, then `extends`
* and `include` parents (reverse declaration order).
* This is the only strategy that does NOT do a
* direct-owner-first short-circuit, because Ruby
* `prepend` must beat the class's own method of the
* same name.
* `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'

View file

@ -7,6 +7,23 @@ 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.
* constructor`{primary:'constructor'}`, member`{primary:'owner-scoped'}`,
* free/undefined`{primary:'free'}`.
*/
const defaultDispatchDecision = (
callForm: 'free' | 'member' | 'constructor' | undefined,
): DispatchDecision => {
if (callForm === 'constructor') return { primary: 'constructor' };
if (callForm === 'member') return { primary: 'owner-scoped' };
return { primary: 'free' };
};
import Parser from 'tree-sitter';
import type { ResolutionContext } from './model/resolution-context.js';
import { TIER_CONFIDENCE, type ResolutionTier } from './model/resolution-context.js';
@ -1076,10 +1093,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
@ -1118,6 +1142,7 @@ export const processCalls = async (
ctx.model.types.lookupClassByName(receiverTypeName).length > 0)
) {
receiverTypeName = ctorType;
receiverSource = 'constructor-map';
}
}
}
@ -1126,10 +1151,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 (
@ -1139,10 +1168,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.
@ -1188,11 +1219,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;
@ -1214,6 +1285,7 @@ export const processCalls = async (
widenCache,
undefined,
heritageMap,
dispatchDecision,
);
if (!resolved) return;
@ -1752,11 +1824,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,
@ -1767,7 +1848,7 @@ const resolveCallTarget = (
preComputedArgTypes,
);
}
if (call.callForm === 'constructor') {
if (primary === 'constructor') {
return (
resolveStaticCall(
call.calledName,
@ -1780,6 +1861,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.
@ -1787,6 +1869,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(
@ -1796,18 +1887,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
@ -1843,7 +1937,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);
@ -2039,6 +2152,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;
@ -2067,6 +2187,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,
@ -2075,6 +2203,7 @@ const resolveMethodByOwner = (
ctx.model,
mroStrategy,
argCount,
singletonOverride,
)
: ctx.model.methods.lookupMethodByOwner(candidate.nodeId, methodName, argCount);
if (!def) continue;
@ -2129,6 +2258,7 @@ export const resolveMemberCall = (
ctx: ResolutionContext,
heritageMap?: HeritageMap,
argCount?: number,
ancestryView?: 'instance' | 'singleton',
): ResolveResult | null => {
const resolved = resolveMethodByOwner(
ownerType,
@ -2137,6 +2267,7 @@ export const resolveMemberCall = (
ctx,
heritageMap,
argCount,
ancestryView,
);
if (!resolved) return null;
return toResolveResult(resolved.def, resolved.tier);

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

@ -12,7 +12,12 @@
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';
@ -200,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

@ -29,6 +29,9 @@ 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:
@ -124,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'],
@ -134,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,
@ -162,5 +172,64 @@ export const rubyProvider = defineLanguage({
// 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

@ -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,
@ -320,27 +311,20 @@ export const lookupMethodByOwnerWithMRO = (
* 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-self linear scan (the caller has already decided the
* 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 that does NOT short-circuit on the direct owner first.
// Ruby MRO for instance dispatch:
// 1. Walk `prepend` providers (reverse declaration — last-prepended first)
// 2. Direct owner lookup (the class's own methods)
// 3. Walk `extends` and `include` providers (reverse declaration)
//
// For singleton dispatch (`ClassName.foo`), the caller passes
// `ancestryOverride = heritageMap.getSingletonAncestry(owner).map(...)`.
// The walker then does a simple left-to-right scan of that override and
// skips the prepend/direct/extends-include partition.
// 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 the pre-computed ancestry only.
// argCount still narrows overloaded methods.
// 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;
@ -348,7 +332,7 @@ export const lookupMethodByOwnerWithMRO = (
return undefined;
}
// Instance dispatch — kind-aware walk.
// 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.
@ -359,27 +343,28 @@ export const lookupMethodByOwnerWithMRO = (
else otherParents.push(e.parentId);
}
// 1. Walk prepend parents in REVERSE declaration order (last-prepended wins).
// 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;
}
// 2. Direct owner lookup (the class's own 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;
// 3. Walk extends + include parents in REVERSE declaration order.
// (Ruby `include A; include B` puts B ahead of A in MRO.)
// 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;
}
// 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 this under-spec is
// documented in plan 003's Deferred to Separate Tasks.
// 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).
for (const ancestorId of heritageMap.getAncestors(ownerNodeId)) {
// Skip direct parents we already walked above.
if (prependParents.includes(ancestorId)) continue;

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

@ -23,12 +23,11 @@
* of passing trivially on `Account`'s own method.
*
* Plan 003 adds the `'ruby-mixin'` MroStrategy and kind-aware ancestry
* (prepend / include / extend split). The infrastructure is wired in
* `lookupMethodByOwnerWithMRO` and applies whenever Ruby calls flow through
* the owner-scoped `resolveMemberCall` path. Shadow-name assertion for
* `call_serialize → PrependedOverride#serialize` is TODO-marked below because
* Ruby bare-identifier calls inside methods (self-calls) currently take the
* `resolveFreeCall` path which doesn't do MRO. See the TODO comment for detail.
* (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
@ -150,14 +149,42 @@ describe('Ruby mixin heritage: sequential vs worker parity', () => {
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. A resolver that fails to enter
// the prepend provider into the MRO (regression in plan 001 Unit 1's
// sequential prepass OR Unit 2's module relabel) would not find this
// method at all, and the owner list would be empty.
// 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`

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