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.
This commit is contained in:
Mick Cooney 2026-09-28 18:06:23 +01:00
parent 658e5ce840
commit d2be5fd3d5

View file

@ -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<string>();
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,