diff --git a/AGENTS.md b/AGENTS.md index f212fae0c..f4fbcadc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING. ## Reference docs - **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)** +- **Call-resolution DAG:** See ARCHITECTURE.md § Call-Resolution DAG. Typed 6-stage DAG inside the `parse` phase; language-specific behavior behind `inferImplicitReceiver` / `selectDispatch` hooks on `LanguageProvider`. Shared code in `gitnexus/src/core/ingestion/` must not name languages. Types: `gitnexus/src/core/ingestion/call-types.ts`. - **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated. - **GitNexus:** skills in `.claude/skills/gitnexus/`; MCP rules in `gitnexus:start` block below. @@ -88,6 +89,7 @@ Indexed as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows) - Ignore HIGH/CRITICAL risk warnings. - Rename with find-and-replace — use `gitnexus_rename`. - Commit without `gitnexus_detect_changes()`. +- Add language-specific behavior to shared ingestion code (`gitnexus/src/core/ingestion/`) — use a `LanguageProvider` hook. Seeing `provider.mroStrategy === 'xxx'` or an import from `languages/xxx.ts` in shared code means stop and add a hook. ## Tools Quick Reference diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 76fd3bcb6..d934cd8b6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -142,6 +142,71 @@ export const myPhase: PipelinePhase = { --- +## Call-Resolution DAG + +Typed 6-stage pipeline in `call-processor.ts` (inside the `parse` phase) that resolves method/function calls and emits CALLS edges. Language behavior plugs in at two `LanguageProvider` hook points (stages 3–4); shared code names no languages. Scope: call resolution only — import resolution, type extraction, heritage, and symbol-table population live in other phases. + +### Stages + +``` +extract-call ──▶ classify-form ──▶ infer-receiver ──▶ select-dispatch ──▶ resolve-target ──▶ emit-edge + (1) (2) (3) [hook] (4) [hook] (5) (6) +``` + +| Stage | Produces | Location | +|-------|----------|----------| +| **extract-call** | `ExtractedCallSite` (name, form, receiver, argCount) | `call-extractors/` (per-language); runs in worker | +| **classify-form** | callForm (`free`/`member`/`constructor`) + arity | `call-analysis.ts` → `inferCallForm`; shared, runs in worker | +| **infer-receiver** | `ReceiverEnriched` (receiver type finalized) | `call-processor.ts`; shared default chain, then `inferImplicitReceiver` hook | +| **select-dispatch** | `DispatchDecision` (primary, fallback, ancestryView) | `selectDispatch` hook, falls back to shared default | +| **resolve-target** | `TieredCandidates` | `model/resolve.ts` → `lookupMethodByOwnerWithMRO` (MRO walk) | +| **emit-edge** | CALLS edge in graph | `call-processor.ts`; writes edge with confidence tier | + +### Provider hooks + +Both hooks are optional on `LanguageProvider`. Ruby is the only current implementer. + +**`inferImplicitReceiver`** — called after shared infer-receiver defaults. Returns `ImplicitReceiverOverride | null`. + +| | | +|---|---| +| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `callNode` (AST), `filePath` | +| Non-null fields | `callForm`, `receiverName`, `receiverTypeName` (required); `receiverSource: 'implicit-self'` (fixed); `hint?` (opaque, passed to `selectDispatch`) | +| Null | Keep existing `ReceiverEnriched` state | + +**`selectDispatch`** — called after infer-receiver (including hook). Returns `DispatchDecision | null`; null uses shared default (constructor → `primary:'constructor'`; typed receiver → `primary:'owner-scoped'`; else → `primary:'free'`). + +| | | +|---|---| +| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `receiverSource`, `hint` | +| Non-null fields | `primary: 'owner-scoped' \| 'free' \| 'constructor'`; `fallback?: 'free-arity-narrowed'`; `ancestryView?: 'instance' \| 'singleton'`; `hint?` | + +**`DispatchDecision` field semantics:** +- `primary: 'owner-scoped'` — MRO walk from receiver's type; used when receiver type is known. +- `fallback: 'free-arity-narrowed'` — after owner-scoped miss, search free-call candidates by arity only (Ruby uses this for implicit-self calls that miss their owner's MRO). +- `ancestryView: 'singleton'` — walk singleton/class ancestry instead of instance ancestry (Ruby `def self.foo` bodies, so `extend`-ed methods are found). + +### Adding language behavior + +1. **Implicit receivers** — implement `inferImplicitReceiver`: return null if call already has a receiver; otherwise use `findEnclosingClassInfo` (`ast-helpers.ts`) to find the enclosing context, return `ImplicitReceiverOverride` with `receiverSource: 'implicit-self'`, and optionally set `hint` for `selectDispatch`. +2. **Custom dispatch** — implement `selectDispatch`: inspect `receiverSource` and `hint`, return `DispatchDecision` with `primary`, optional `fallback`, optional `ancestryView`; return null to keep shared defaults. +3. **MRO strategy** — confirm `mroStrategy` is `'first-wins'`, `'c3'`, `'ruby-mixin'`, or `'none'`; consumed by `lookupMethodByOwnerWithMRO`. + +**Ruby example** (`languages/ruby.ts` + `utils/ruby-self-call.ts`): `inferImplicitReceiver` rewrites bare-identifier calls to `self.method` and sets `hint` to `'instance'`/`'singleton'`; `selectDispatch` uses hint for `ancestryView` and adds `fallback: 'free-arity-narrowed'` for implicit-self calls. + +### Code references + +| Module | Purpose | +|--------|---------| +| `core/ingestion/call-types.ts` | DAG types: `ReceiverEnriched`, `DispatchDecision`, `ImplicitReceiverOverride` | +| `core/ingestion/language-provider.ts` | Hook signatures: `inferImplicitReceiver`, `selectDispatch` | +| `core/ingestion/call-processor.ts` | `processCalls`: stages 3–6 | +| `core/ingestion/model/resolve.ts` | `lookupMethodByOwnerWithMRO`: stage 5 MRO walk | +| `core/ingestion/languages/ruby.ts` | Both hooks + `mroStrategy: 'ruby-mixin'` | +| `core/ingestion/utils/ruby-self-call.ts` | Bare-call rewrite for `inferImplicitReceiver` | + +--- + ## Language-agnostic graph feeding 16 languages → single unified graph. Four abstraction layers: diff --git a/CLAUDE.md b/CLAUDE.md index 8b6f74ab8..af4069fcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g ## Reference Documentation - **This repository:** [AGENTS.md](AGENTS.md) (Cursor + monorepo notes), [ARCHITECTURE.md](ARCHITECTURE.md), [CONTRIBUTING.md](CONTRIBUTING.md), [GUARDRAILS.md](GUARDRAILS.md). +- **Call-resolution DAG:** See ARCHITECTURE.md § Call-Resolution DAG. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` hooks instead (see AGENTS.md). - **GitNexus:** `.claude/skills/gitnexus/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start` … `gitnexus:end`). See **GitNexus rules** below. ## Changelog @@ -50,206 +51,4 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g ## GitNexus rules -GitNexus MCP rules are in the ` -# GitNexus — Code Intelligence - -This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. - -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. - -## Always Do - -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - -## When Debugging - -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. - -## Never Do - -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | -| `gitnexus://repo/GitNexus/clusters` | All functional areas | -| `gitnexus://repo/GitNexus/processes` | All execution flows | -| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | - -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - -` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index. - - -# GitNexus — Code Intelligence - -This project is indexed by GitNexus as **GitNexus** (3298 symbols, 7954 relationships, 185 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. - -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. - -## Always Do - -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - -## When Debugging - -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. - -## Never Do - -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | -| `gitnexus://repo/GitNexus/clusters` | All functional areas | -| `gitnexus://repo/GitNexus/processes` | All execution flows | -| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | - -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - - +See the `` block in **[AGENTS.md](AGENTS.md)** for the canonical MCP tools, impact analysis rules, and index instructions. diff --git a/gitnexus-shared/src/mro-strategy.ts b/gitnexus-shared/src/mro-strategy.ts index 609246085..7ace9306f 100644 --- a/gitnexus-shared/src/mro-strategy.ts +++ b/gitnexus-shared/src/mro-strategy.ts @@ -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 `::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 `::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' diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 70d9412c2..6c482c0dc 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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); diff --git a/gitnexus/src/core/ingestion/call-types.ts b/gitnexus/src/core/ingestion/call-types.ts index da175720d..b42ecbf16 100644 --- a/gitnexus/src/core/ingestion/call-types.ts +++ b/gitnexus/src/core/ingestion/call-types.ts @@ -78,3 +78,100 @@ export interface CallExtractionConfig { */ typeAsReceiverHeuristic?: boolean; } + +// --------------------------------------------------------------------------- +// Call-resolution DAG types +// --------------------------------------------------------------------------- +// +// The call-resolution pipeline is a typed DAG: +// +// extract-call ──▶ classify-form ──▶ infer-receiver ──▶ select-dispatch ──▶ resolve-target ──▶ emit-edge +// +// Provider hooks plug in at infer-receiver and select-dispatch; shared stages +// stay language-agnostic. Stages 1-2 run in the parse worker; stages 3-6 run +// on the main thread. DAG-internal types below are main-thread-only and never +// serialize to the graph. + +/** + * DAG stage 3 output: call record with receiver type and source discriminant. + * + * `receiverTypeName` is resolved via TypeEnv → constructor-map → class-as-receiver → + * mixed-chain, or synthesized by `inferImplicitReceiver`. `receiverSource` tags + * which path won and drives MRO strategy selection in stage 4. + * + * Invariants: + * - `receiverSource` MUST match how `receiverTypeName` was resolved; every + * discriminant must have a live reader and writer. + * - `hint` is opaque to shared stages; only the same provider's `selectDispatch` reads it. + * + * @see language-provider.ts § inferImplicitReceiver, selectDispatch + */ +export interface ReceiverEnriched { + readonly calledName: string; + readonly callForm: 'free' | 'member' | 'constructor' | undefined; + readonly receiverName: string | undefined; + readonly receiverTypeName: string | undefined; + readonly receiverSource: + | 'none' + | 'typed-binding' + | 'constructor-map' + | 'class-as-receiver' + | 'mixed-chain' + | 'implicit-self'; + /** Free-form hint from the provider hook; opaque to shared stages. */ + readonly hint?: string; +} + +/** + * Provider hook output for `LanguageProvider.inferImplicitReceiver` (DAG stage 3). + * + * Overlay applied to `ReceiverEnriched` when an implicit receiver is synthesized. + * Ruby example: bare `serialize` inside `Account#call_serialize` → + * `{ callForm: 'member', receiverName: 'self', receiverTypeName: 'Account', + * receiverSource: 'implicit-self', hint: 'instance' }` + * + * Invariants: + * - `receiverSource` is always `'implicit-self'` — the only variant this type produces. + * - `callForm` is always `'member'` — the rewrite converts bare-call to method invocation. + * - `hint` is opaque to shared stages; consumed by the same language's `selectDispatch`. + */ +export interface ImplicitReceiverOverride { + readonly callForm: 'free' | 'member' | 'constructor'; + readonly receiverName: string; + readonly receiverTypeName: string; + readonly receiverSource: Extract; + /** Free-form language tag (e.g. Ruby sets 'singleton' for `def self.foo` + * method bodies). Consumed by the same language's `selectDispatch` hook. */ + readonly hint?: string; +} + +/** + * DAG stage 4 output: dispatch strategy for resolving the target method. + * + * Encodes which resolver branch to try first and an optional fallback. + * Stage 5 delegates to `resolveMemberCall`, `resolveFreeCall`, or + * `resolveStaticCall` based on `primary`. + * + * - `primary`: `'owner-scoped'` = MRO walk, `'free'` = arity-tiered global lookup, + * `'constructor'` = type instantiation. + * - `fallback`: Only `'free-arity-narrowed'` exists; used by Ruby implicit-self + * to degrade to arity-tiered free lookup when the MRO walk misses. + * - `ancestryView`: Ruby `'ruby-mixin'` only. `'singleton'` walks extend providers + * only; a miss NEVER falls through to file-scoped lookup (enforced in + * resolveCallTarget). `'instance'` is the default. + * + * Common patterns: + * - `{primary: 'constructor'}` — constructor call + * - `{primary: 'owner-scoped'}` — member call with known type + * - `{primary: 'owner-scoped', fallback: 'free-arity-narrowed', ancestryView: 'instance'}` — Ruby implicit-self + * - `{primary: 'owner-scoped', ancestryView: 'singleton'}` — Ruby class-method call + * + * @see language-provider.ts § selectDispatch + * @see call-processor.ts § defaultDispatchDecision, resolveCallTarget + */ +export interface DispatchDecision { + readonly primary: 'owner-scoped' | 'free' | 'constructor'; + readonly fallback?: 'free-arity-narrowed'; + readonly ancestryView?: 'instance' | 'singleton'; + readonly hint?: string; +} diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 6b14a8f6a..3960c6a8a 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -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). */ diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index 102a66e95..e538f3fd2 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -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 = 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, }); diff --git a/gitnexus/src/core/ingestion/model/resolve.ts b/gitnexus/src/core/ingestion/model/resolve.ts index 659a7f9a8..17b2cddfe 100644 --- a/gitnexus/src/core/ingestion/model/resolve.ts +++ b/gitnexus/src/core/ingestion/model/resolve.ts @@ -278,33 +278,24 @@ const buildParentMapFromHeritage = ( // --------------------------------------------------------------------------- /** - * Look up a method on an owner class, walking the parent chain via HeritageMap - * when the method isn't found on the direct owner. + * DAG stage 5 helper: look up a method on an owner class via MRO walk. * - * Respects the 5 per-language MRO strategies: - * - `first-wins`: BFS ancestor walk, first match wins (default) - * - `leftmost-base`: BFS ancestor walk, leftmost base in declaration order wins (C++); - * HeritageMap preserves insertion order matching source declaration, - * so BFS order is equivalent to leftmost-base semantics - * - `c3`: C3-linearized ancestor order, first match wins (Python) - * - `implements-split`: BFS ancestor walk, first match wins (Java/C#) — - * full ambiguity detection for multiple interface defaults - * is handled by computeMRO at graph level - * - `qualified-syntax`: No auto-resolution (Rust) — returns undefined + * Low-level resolver; no dependency on SymbolTable, language registry, or + * resolution-context (keeps model/ layer free of cross-layer imports). + * All strategies respect `argCount` for overload narrowing. + * `ancestryOverride` replaces the default walk; caller must compute it correctly. * - * Uses the `c3Linearize` defined in this file (also consumed by - * mro-processor.ts for graph-level MRO emission) for the `c3` strategy. + * Strategy summary (full docs in gitnexus-shared/mro-strategy.ts): + * - `first-wins` / `leftmost-base` / `implements-split`: BFS, first match wins. + * - `c3`: C3-linearized order; falls back to BFS on cycle/inconsistency. + * - `qualified-syntax`: returns undefined immediately (Rust requires explicit syntax). + * - `ruby-mixin`: kind-aware walk — see inline comments below. * - * Depends only on {@link SemanticModel} + {@link HeritageMap} + an - * {@link MroStrategy} literal — NO dependency on SymbolTable, the language - * registry, or resolution-context, which keeps the `model/` module free of - * cross-layer imports. Callers derive the strategy from their language - * provider before invoking this function. + * Internal API: exported for call-processor resolvers and tests. + * External callers should use resolveMemberCall instead. * - * @internal This is the low-level MRO walker. Exported so call-processor's - * higher-level resolvers (and unit tests) can invoke it directly. Callers - * outside `core/ingestion/` should use the higher-level resolvers in - * call-processor.ts instead of depending on this function. + * @see gitnexus-shared/mro-strategy.ts § 'ruby-mixin' + * @see call-processor.ts § resolveMemberCall */ export const lookupMethodByOwnerWithMRO = ( ownerNodeId: string, @@ -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; diff --git a/gitnexus/src/core/ingestion/utils/ruby-self-call.ts b/gitnexus/src/core/ingestion/utils/ruby-self-call.ts new file mode 100644 index 000000000..16fc7e7ab --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/ruby-self-call.ts @@ -0,0 +1,101 @@ +// gitnexus/src/core/ingestion/utils/ruby-self-call.ts + +/** + * Ruby bare-call self-inference helper. + * + * Ruby makes `self` implicit for method calls inside instance and class bodies: + * `serialize` inside `Account#call_serialize` means `self.serialize`. Other + * supported languages make the receiver explicit in source (`this.x`, `self.x`), + * so tree-sitter produces a member call directly. Ruby's bare identifier + * produces either `callForm === 'free'` or `callForm === undefined` (body_statement + * identifier captures where the @call node IS the @call.name node), and + * `resolveFreeCall` does a global tiered name lookup — no MRO walk. + * + * This helper is a pure decision function consumed by the Ruby language + * provider's `inferImplicitReceiver` hook. Shared pipeline code never imports + * it directly — only `languages/ruby.ts` does. + */ + +import type { SyntaxNode } from './ast-helpers.js'; +import type { LanguageProvider } from '../language-provider.js'; + +/** + * Rewrite suggestion returned by `maybeRewriteRubyBareCallToSelf`. + * + * `callForm` is always `'member'`; `receiverName` is always `'self'`. + * `dispatchKind` controls the stage-4 ancestry view: + * - `'instance'` → prepend → direct → include (normal MRO) + * - `'singleton'` → extend providers only, no file-scoped fallback + * + * Consumed by `languages/ruby.ts § inferImplicitReceiver` (wraps into + * `ImplicitReceiverOverride`; `dispatchKind` becomes the `hint` field). + */ +export interface SelfCallRewrite { + readonly callForm: 'member'; + readonly receiverName: 'self'; + readonly receiverTypeName: string; + /** `'singleton'` when the enclosing method is `def self.foo` / inside a + * `singleton_class` body; `'instance'` otherwise. Controls MRO ancestry + * view selection in stage-4 dispatch. */ + readonly dispatchKind: 'instance' | 'singleton'; +} + +/** Maximum parent-walk depth to prevent runaway traversal. */ +const MAX_PARENT_DEPTH = 50; + +/** + * Returns true if `callNode` is inside a `singleton_method` or `singleton_class`. + * Stops at `class`/`module` boundary or MAX_PARENT_DEPTH (50) to bound traversal. + */ +function isInsideSingletonMethod(callNode: SyntaxNode): boolean { + let current: SyntaxNode | null = callNode.parent; + let depth = 0; + while (current && depth++ < MAX_PARENT_DEPTH) { + if (current.type === 'singleton_method') return true; + if (current.type === 'singleton_class') return true; + if (current.type === 'class' || current.type === 'module') return false; + current = current.parent; + } + return false; +} + +/** + * Pure decision function: should a bare Ruby call be rewritten as `self.method`? + * + * Returns a `SelfCallRewrite` when all gates pass; null otherwise. + * Gates (all required): `callForm` is `'free'` or `undefined`, strategy is + * `'ruby-mixin'`, `enclosingClassName` is non-null, name is not `'super'`, + * name is not a built-in. + * + * Note: Ruby body-statement identifiers produce `callForm === undefined` because + * the @call node IS the @call.name node in tree-sitter-ruby. + * + * Example: `calledName='serialize'` in `Account` instance method → + * `{callForm:'member', receiverName:'self', receiverTypeName:'Account', dispatchKind:'instance'}` + */ +export function maybeRewriteRubyBareCallToSelf( + calledName: string, + callForm: 'free' | 'member' | 'constructor' | undefined, + callNode: SyntaxNode, + enclosingClassName: string | null, + provider: Pick, +): SelfCallRewrite | null { + // Body-statement bare identifiers produce `callForm === undefined` because + // the @call node IS the @call.name node in tree-sitter-ruby. Treat both + // undefined and 'free' as qualifying. + if (callForm !== 'free' && callForm !== undefined) return null; + if (provider.mroStrategy !== 'ruby-mixin') return null; + if (!enclosingClassName) return null; + if (calledName === 'super') return null; + if (provider.isBuiltInName(calledName)) return null; + + const dispatchKind: SelfCallRewrite['dispatchKind'] = isInsideSingletonMethod(callNode) + ? 'singleton' + : 'instance'; + return { + callForm: 'member', + receiverName: 'self', + receiverTypeName: enclosingClassName, + dispatchKind, + }; +} diff --git a/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts b/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts index 357ef85e9..c3e105a15 100644 --- a/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts +++ b/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts @@ -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: Module→Trait relabel + kind-aware MRO + self-inference. * * Known guard limitation (documented residual): reverting plan 001 Unit 1 * alone (the sequential prepass extractFromCall) does NOT make these tests @@ -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` diff --git a/gitnexus/test/unit/ruby-self-call.test.ts b/gitnexus/test/unit/ruby-self-call.test.ts new file mode 100644 index 000000000..1ac439a3b --- /dev/null +++ b/gitnexus/test/unit/ruby-self-call.test.ts @@ -0,0 +1,316 @@ +/** + * Unit tests for `maybeRewriteRubyBareCallToSelf` — the self-inference helper + * that rewrites Ruby bare-identifier calls inside class/module bodies into + * `self`-receiver member calls (plan 005 DAG / Ruby `inferImplicitReceiver`). + * + * The helper is pure: given the call name, callForm, AST node, enclosing + * class, and a minimal provider shape, it returns a rewrite suggestion or + * null. These tests pin each gate + branch so regressions surface at unit + * level rather than through the Ruby integration fixtures. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import Parser from 'tree-sitter'; +import Ruby from 'tree-sitter-ruby'; +import { maybeRewriteRubyBareCallToSelf } from '../../src/core/ingestion/utils/ruby-self-call.js'; +import type { LanguageProvider } from '../../src/core/ingestion/language-provider.js'; +import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; + +const parser = new Parser(); + +beforeAll(() => { + parser.setLanguage(Ruby as unknown as Parser.Language); +}); + +function parseRuby(src: string): SyntaxNode { + return parser.parse(src).rootNode; +} + +/** Find the first identifier node whose `text` matches `name` (DFS, pre-order). */ +function findIdentifier(root: SyntaxNode, name: string): SyntaxNode | null { + const stack: SyntaxNode[] = [root]; + while (stack.length) { + const node = stack.pop()!; + if (node.type === 'identifier' && node.text === name) return node; + for (let i = node.childCount - 1; i >= 0; i--) { + const child = node.child(i); + if (child) stack.push(child); + } + } + return null; +} + +/** Minimal provider stub matching the helper's structural expectations. */ +const rubyProviderStub: Pick = { + isBuiltInName: (name: string) => + new Set(['puts', 'p', 'raise', 'require', 'include', 'extend', 'prepend', 'attr_accessor']).has( + name, + ), + mroStrategy: 'ruby-mixin', +}; + +const nonRubyProviderStub: Pick = { + isBuiltInName: () => false, + mroStrategy: 'first-wins', +}; + +describe('maybeRewriteRubyBareCallToSelf', () => { + it('rewrites bare call inside instance method → self-receiver member call', () => { + const root = parseRuby(` +class Account + def call_greet + greet + end +end +`); + const call = findIdentifier(root, 'greet')!; + expect(call).not.toBeNull(); + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'greet', + 'free', + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite).toEqual({ + callForm: 'member', + receiverName: 'self', + receiverTypeName: 'Account', + dispatchKind: 'instance', + }); + }); + + it('accepts `callForm === undefined` (body_statement bare identifier captures)', () => { + // Ruby body-statement captures produce callForm === undefined because the + // @call node IS the @call.name node. The helper must accept both undefined + // and 'free'. + const root = parseRuby(` +class Account + def work + helper + end +end +`); + const call = findIdentifier(root, 'helper')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'helper', + undefined, + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite?.callForm).toBe('member'); + expect(rewrite?.receiverTypeName).toBe('Account'); + }); + + it('flags singleton dispatch for calls inside `def self.foo` bodies', () => { + const root = parseRuby(` +class Account + def self.factory + log("building") + end +end +`); + const call = findIdentifier(root, 'log')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'log', + 'free', + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite?.dispatchKind).toBe('singleton'); + expect(rewrite?.receiverTypeName).toBe('Account'); + }); + + it('flags singleton dispatch for calls inside `class << self` body', () => { + const root = parseRuby(` +class Account + class << self + def factory + log("msg") + end + end +end +`); + const call = findIdentifier(root, 'log')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'log', + 'free', + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite?.dispatchKind).toBe('singleton'); + }); + + it('returns null for Kernel built-in methods (puts)', () => { + const root = parseRuby(` +class Account + def greet + puts "hi" + end +end +`); + const call = findIdentifier(root, 'puts')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'puts', + 'free', + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite).toBeNull(); + }); + + it('returns null for `super` keyword', () => { + const root = parseRuby(` +class Account + def save + super + end +end +`); + const call = findIdentifier(root, 'super') ?? root; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'super', + 'free', + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite).toBeNull(); + }); + + it('returns null at file-level (no enclosing class)', () => { + const root = parseRuby(` +some_top_level_call +`); + const call = findIdentifier(root, 'some_top_level_call')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'some_top_level_call', + 'free', + call, + null, + rubyProviderStub, + ); + expect(rewrite).toBeNull(); + }); + + it('returns null when callForm is already member (explicit receiver present)', () => { + const root = parseRuby(` +class Account + def greet + self.say_hello + end +end +`); + const call = findIdentifier(root, 'say_hello')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'say_hello', + 'member', + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite).toBeNull(); + }); + + it('returns null when callForm is constructor', () => { + const root = parseRuby(` +class Account + def build + Account.new + end +end +`); + const call = findIdentifier(root, 'new')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'new', + 'constructor', + call, + 'Account', + rubyProviderStub, + ); + expect(rewrite).toBeNull(); + }); + + it('returns null for non-Ruby providers (mroStrategy !== ruby-mixin)', () => { + const root = parseRuby(` +class Account + def greet + some_call + end +end +`); + const call = findIdentifier(root, 'some_call')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'some_call', + 'free', + call, + 'Account', + nonRubyProviderStub, + ); + expect(rewrite).toBeNull(); + }); + + it('uses module name as receiverTypeName for calls inside module body', () => { + const root = parseRuby(` +module Helpers + def format + capitalize + end +end +`); + const call = findIdentifier(root, 'capitalize')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'capitalize', + 'free', + call, + 'Helpers', + rubyProviderStub, + ); + expect(rewrite).toEqual({ + callForm: 'member', + receiverName: 'self', + receiverTypeName: 'Helpers', + dispatchKind: 'instance', + }); + }); + + it('terminates walk at enclosing class/module (does not walk past into outer scopes)', () => { + // `outer_call` is inside `Inner#work`; Inner is nested inside Outer. + // Enclosing class is Inner — the helper should return dispatchKind='instance' + // without escaping to Outer's singleton methods (if any existed). + const root = parseRuby(` +module Outer + class Inner + def work + outer_call + end + end +end +`); + const call = findIdentifier(root, 'outer_call')!; + + const rewrite = maybeRewriteRubyBareCallToSelf( + 'outer_call', + 'free', + call, + 'Inner', + rubyProviderStub, + ); + expect(rewrite?.dispatchKind).toBe('instance'); + expect(rewrite?.receiverTypeName).toBe('Inner'); + }); +});