mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-12 23:02:45 +00:00
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:
parent
21744bdbfa
commit
0c4a8ebf11
12 changed files with 988 additions and 314 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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:
|
||||
|
|
|
|||
205
CLAUDE.md
205
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: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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
101
gitnexus/src/core/ingestion/utils/ruby-self-call.ts
Normal file
101
gitnexus/src/core/ingestion/utils/ruby-self-call.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
316
gitnexus/test/unit/ruby-self-call.test.ts
Normal file
316
gitnexus/test/unit/ruby-self-call.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue