fix(r): read contains= case-sensitively, and pin the VIRTUAL heritage edge

R matches argument names case-sensitively, so setClass("A", CONTAINS = "B") names no
base; the scope query linked it anyway. Drop the CONTAINS spelling from both predicates.

Pin the VIRTUAL placeholder: a class naming both a real base and VIRTUAL gets both edges
(the dedup set must be seeded only from edges that target the placeholder), the edge keeps
its confidence and reason, and running the heritage hook twice mints one node and one edge.
This commit is contained in:
Mick Cooney 2026-10-17 18:42:18 +01:00
parent 7581ae0697
commit 649ed87a6d
3 changed files with 80 additions and 3 deletions

View file

@ -303,7 +303,7 @@ export const R_SCOPE_QUERY = `
arguments: (arguments
(argument
name: (identifier) @_contains1
(#match? @_contains1 "^(contains|CONTAINS)$")
(#match? @_contains1 "^(contains)$")
value: (string
content: (string_content) @reference.inherits))))
@ -315,7 +315,7 @@ export const R_SCOPE_QUERY = `
arguments: (arguments
(argument
name: (identifier) @_contains2
(#match? @_contains2 "^(contains|CONTAINS)$")
(#match? @_contains2 "^(contains)$")
value: (call
function: (identifier) @_cfn2
(#match? @_cfn2 "^c$")

View file

@ -0,0 +1,10 @@
# `contains` is matched case-sensitively, as R matches argument names: `CONTAINS =` links nothing.
setClass("Base", representation(x = "numeric"))
# Both a real base and the VIRTUAL sentinel: each gets its own EXTENDS edge.
setClass("Both", contains = c("Base", "VIRTUAL"))
# The sentinel written first, then a real base.
setClass("SentinelFirst", contains = c("VIRTUAL", "Base"))
setClass("Shouted", CONTAINS = "Base")

View file

@ -7,7 +7,8 @@
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import path from 'path';
import { FIXTURES, getRelationships, runPipelineFromRepo } from './helpers.js';
import { FIXTURES, getNodesByLabel, getRelationships, runPipelineFromRepo } from './helpers.js';
import { rScopeResolver } from '../../../src/core/ingestion/languages/r/scope-resolver.js';
import type { PipelineResult } from './helpers.js';
beforeAll(() => vi.stubEnv('GITNEXUS_WORKER_READY_TIMEOUT_MS', '60000'));
@ -52,3 +53,69 @@ describe('R: the S4 sentinel and an R6 base of the same name in one repository',
expect(extendsEdges(result, 'Child')).toEqual([]);
});
});
describe('R: the VIRTUAL placeholder edge next to other heritage edges', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'r-s4-contains-forms'), () => {});
}, 60000);
it.each(['Both', 'SentinelFirst'])(
'%s gets an edge to its real base and one to the placeholder',
(source) => {
expect(
extendsEdges(result, source)
.map((e) => e.rel.targetId)
.sort(),
).toEqual([PLACEHOLDER, 'Class:R/classes.R:Base'].sort());
},
);
it('gives the placeholder edge the confidence and reason of a resolved virtual heritage edge', () => {
const edge = extendsEdges(result, 'Both').find((e) => e.rel.targetId === PLACEHOLDER);
expect(edge?.rel.confidence).toBe(0.85);
expect(edge?.rel.reason).toBe('scope-resolution: inherits (virtual)');
});
it('mints exactly one placeholder node', () => {
expect(result.graph.getNode(PLACEHOLDER)?.label).toBe('Class');
expect(getNodesByLabel(result, 'Class').filter((n) => n === 'VIRTUAL')).toHaveLength(1);
});
it('reads `contains` case-sensitively: CONTAINS = "Base" links nothing', () => {
expect(extendsEdges(result, 'Shouted')).toEqual([]);
});
});
describe('R: emitting the VIRTUAL heritage edges twice changes nothing', () => {
let result: PipelineResult;
let calls = 0;
beforeAll(async () => {
const original = rScopeResolver.emitHeritageEdges;
if (original === undefined) throw new Error('the R scope resolver emits heritage edges');
const spy = vi
.spyOn(rScopeResolver, 'emitHeritageEdges')
.mockImplementation((...args: Parameters<typeof original>) => {
calls++;
original(...args);
original(...args);
});
try {
result = await runPipelineFromRepo(path.join(FIXTURES, 'r-s4-contains-forms'), () => {});
} finally {
spy.mockRestore();
}
}, 60000);
it('ran the doubled hook (otherwise the checks below prove nothing)', () => {
expect(calls).toBeGreaterThan(0);
});
it('keeps one placeholder node and one edge per class that names it', () => {
expect(getNodesByLabel(result, 'Class').filter((n) => n === 'VIRTUAL')).toHaveLength(1);
const toPlaceholder = getRelationships(result, 'EXTENDS').filter(
(e) => e.rel.targetId === PLACEHOLDER,
);
expect(toPlaceholder.map((e) => e.source).sort()).toEqual(['Both', 'SentinelFirst']);
});
});