From b6afb4bbef75266fb4a1d2636ba8d779bdeb1eb2 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Fri, 17 Apr 2026 11:18:31 +0100 Subject: [PATCH] fix(ingestion): use Class fallback for unresolved Ruby mixin owners, not Struct Ruby `include`/`extend`/`prepend` records share a processHeritageFromExtracted branch with Rust `trait-impl`. When the mixin child name cannot be resolved through the type registry, the fallback synthesized a `Struct:filepath:X` id for both. Rust structs emit under the `Struct` label -- correct. Ruby classes emit under `Class` and Ruby modules emit under `Trait` (since plan 001's module relabel), so the synthesized `Struct:` id would never match any real node and the IMPLEMENTS edge would dangle off a phantom. The resolved case -- the common path now that Ruby modules register normally -- is unaffected; only the fallback for truly unresolved references changes. `Class` is the right default for Ruby mixin kinds because the dominant shape is `class X; include M; end` where X is a Class. Split is kind-aware: Rust `trait-impl` keeps `Struct`, Ruby mixin kinds get `Class`. Applied at both call sites (processHeritageFromExtractedItem + processHeritageFromExtracted). --- .../src/core/ingestion/heritage-processor.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index cc31b3000..6692d1c95 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -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'; @@ -149,11 +149,21 @@ const resolveAndAddHeritageEdge = ( 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, - 'Struct', + childFallbackLabel, `${filePath}:${item.className}`, ); const trait = resolveHeritageId(item.parentName, filePath, ctx, 'Trait'); @@ -339,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');