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