From d2be5fd3d5d9ed52ffc69f7cf8cbb5645d8a28d1 Mon Sep 17 00:00:00 2001 From: Mick Cooney Date: Mon, 28 Sep 2026 18:06:23 +0100 Subject: [PATCH] feat(r): resolve the S4 VIRTUAL heritage sentinel setClass(x, contains = "VIRTUAL", ...) names a base no fixture ever declares, so the generic scope-based heritage lookup (scope-model-only, never consults graph nodes) can't find a binding for it. Add emitRVirtualHeritageEdges, mirroring Ruby's emitRubyMixinEdges shape: it mints the placeholder VIRTUAL Class node once and an EXTENDS edge from every S4 class whose contains= names it, scoped narrowly to that one literal fixture string. All 39/39 r.test.ts scenarios now pass. --- .../ingestion/languages/r/scope-resolver.ts | 85 +++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts index 524071f79..eec212c3f 100644 --- a/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts @@ -6,22 +6,94 @@ * (`import-resolvers/r.ts`), the adapter step 2 of this plan added over the * existing `resolveRImportInternal`, with the repo's local R packages * threaded through `loadRPackageConfig`. Heritage (R6 `inherit=`, S4 - * `contains=`) rides the generic `@reference.inherits` mechanism — R6/S4 - * base names are ordinary scope-bound class names, unlike Ruby's - * `include`/`extend`, which are method calls and need a custom - * `emitHeritageEdges` hook. + * `contains=`) mostly rides the generic `@reference.inherits` mechanism — + * R6/S4 base names are ordinary scope-bound class names, unlike Ruby's + * `include`/`extend`, which are method calls. The one exception is S4's + * `contains = "VIRTUAL"` sentinel (`setClass(x, contains = "VIRTUAL", ...)`, + * R's spelling for an abstract base): no fixture ever declares + * `setClass("VIRTUAL", ...)`, so the generic scope-based lookup + * (`resolveInheritanceBaseInScope` / `findClassBindingInScope`) can never + * find a binding for it — that lookup is scope-model-only and never + * consults graph nodes directly. `emitRVirtualHeritageEdges` below mints + * the placeholder `VIRTUAL` graph node and its EXTENDS edge directly, + * mirroring Ruby's `emitRubyMixinEdges` shape for the same reason: a + * heritage target this plan's fixtures name but never declare. */ import type { ParsedFile, ScopeId } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; -import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import { findEnclosingClassDef, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import { generateId } from '../../../../lib/utils.js'; import { loadRPackageConfig } from '../../language-config.js'; import { resolveRImportTarget } from '../../import-resolvers/r.js'; import { rProvider } from '../r.js'; import { rArityCompatibility, rMergeBindings } from './simple-hooks.js'; +/** Literal string `"VIRTUAL"` graph id — one node shared by every S4 class + * that names it as a base, minted lazily on first use. */ +const VIRTUAL_CLASS_NAME = 'VIRTUAL'; + +/** + * Mint the `VIRTUAL` placeholder Class node (once) and an EXTENDS edge from + * every S4 class whose `contains=` names it, exactly the literal fixture + * string `"VIRTUAL"` (§12 Q1) — not a general policy for arbitrary S4 + * base-type sentinels (`"numeric"`, `"list"`, …), which is a distinct, + * out-of-scope design question (kaybenleroll/GitNexus, Issues To File #2). + * + * Idempotent: re-seeds its dedup set from any `EXTENDS` edge already + * targeting a node named `VIRTUAL` before emitting, and re-uses the graph's + * existing `VIRTUAL` node instead of minting a second one when the + * orchestrator re-runs resolution. + */ +function emitRVirtualHeritageEdges( + graph: KnowledgeGraph, + _parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + scopes?: ScopeResolutionIndexes, +): void { + if (scopes === undefined) return; + + const virtualGraphId = generateId('Class', VIRTUAL_CLASS_NAME); + const emitted = new Set(); + for (const rel of graph.iterRelationshipsByType('EXTENDS')) { + if (rel.targetId === virtualGraphId) emitted.add(rel.sourceId); + } + + let virtualNodeMinted = graph.getNode(virtualGraphId) !== undefined; + + for (const site of scopes.referenceSites) { + if (site.kind !== 'inherits' || site.name !== VIRTUAL_CLASS_NAME) continue; + const callerClass = findEnclosingClassDef(site.inScope, scopes); + if (callerClass === undefined) continue; + const callerGraphId = resolveDefGraphId(callerClass.filePath, callerClass, nodeLookup); + if (callerGraphId === undefined || emitted.has(callerGraphId)) continue; + + if (!virtualNodeMinted) { + graph.addNode({ + id: virtualGraphId, + label: 'Class', + properties: { name: VIRTUAL_CLASS_NAME, filePath: callerClass.filePath }, + }); + virtualNodeMinted = true; + } + emitted.add(callerGraphId); + graph.addRelationship({ + id: generateId('EXTENDS', `${callerGraphId}->${virtualGraphId}`), + sourceId: callerGraphId, + targetId: virtualGraphId, + type: 'EXTENDS', + confidence: 0.85, + reason: 'scope-resolution: inherits (virtual)', + }); + } +} + /** * Enumerate all top-level names exported from a target module scope's file. * `library()`/`require()` are R's wildcard imports — every top-level name of @@ -69,6 +141,9 @@ export const rScopeResolver: ScopeResolver = { populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), + emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes) => + emitRVirtualHeritageEdges(graph, parsedFiles, nodeLookup, scopes), + // R has no `super`. isSuperReceiver: () => false,