GitNexus/gitnexus/test/unit/scope-resolution/value-ref-namespace-precedence.test.ts

254 lines
10 KiB
TypeScript

/**
* `findNamespaceValueRefTarget` precedence: a name the target module DECLARES
* beats a name it merely re-publishes, and it beats it whether or not the
* declaration happens to be callable (#3219 review round 8).
*
* The channel resolves `bridge.accessor(hub.scale, …)` by reading the target
* module's own module-scope bindings first and its re-published ones second.
* The order is only meaningful if the FIRST lookup decides on the NAME: it
* applies `CALL_TARGET_TYPES` while it selects, so a target module declaring a
* non-callable `scale` answers nothing there and — before this guard — fell
* through to the published channel and bound an imported callable under a name
* the module's own declaration owns. `findExportedDef` does not behave that
* way: it returns any local def and lets the caller's type gate reject it, so
* `findExportedDefIncludingImportedNames` never reaches the imported names for
* a name the file declares. `hub.scale` and `hub.scale()` must not disagree
* about which module owns `scale`.
*
* WHY THE INDEXES ARE HAND-BUILT, stated so this is not read as a fixture that
* "just happens" to be synthetic. Python and Zig both set
* `namespaceExportsIncludeImportedNames`; Zig forbids declaring a name twice
* in one container, so its source cannot express the overlapping bindings
* tested here. Building the indexes directly isolates the shared precedence
* contract from provider extraction and finalization. The middle case below
* fails without the local-name guard.
*/
import { describe, it, expect } from 'vitest';
import { resolveValueRefTarget } from '../../../src/core/ingestion/scope-resolution/passes/property-dispatch.js';
import type {
BindingRef,
ImportEdge,
ReferenceSite,
Scope,
ScopeId,
SymbolDefinition,
} from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js';
import type { SemanticModel } from '../../../src/core/ingestion/model/semantic-model.js';
const CONSUMER_FILE = 'src/Element.zig';
const HUB_FILE = 'src/hub.zig';
const CONSUMER = 'scope:consumer:module' as ScopeId;
const HUB = 'scope:hub:module' as ScopeId;
const def = (nodeId: string, type: string, filePath: string): SymbolDefinition =>
({ nodeId, name: 'scale', type, filePath }) as unknown as SymbolDefinition;
const ref = (d: SymbolDefinition, origin: string): BindingRef =>
({ def: d, origin }) as unknown as BindingRef;
/** The callable `hub.zig` re-publishes from `dom_utils.zig`. */
const PUBLISHED = def('Function:src/dom_utils.zig:scale', 'Function', 'src/dom_utils.zig');
/** A `scale` the hub declares ITSELF, in the two kinds that matter. */
const LOCAL_CONST = def('Const:src/hub.zig:scale', 'Const', HUB_FILE);
const LOCAL_FN = def('Function:src/hub.zig:scale', 'Function', HUB_FILE);
function moduleScope(id: ScopeId, filePath: string): Scope {
return {
id,
kind: 'Module',
parent: null,
filePath,
range: { startLine: 1, startCol: 0, endLine: 99, endCol: 0 },
bindings: new Map(),
typeBindings: new Map(),
imports: [],
ownedDefs: [],
} as unknown as Scope;
}
/**
* Everything `resolveValueRefTarget` reads for a qualified site whose receiver
* is a namespace handle: the consumer's namespace import edge, and the target
* module's local (`scopes.bindings`) and published (`bindingAugmentations`)
* channels. `qualifiedNames` answers nothing so the CONTAINER channel — which
* runs only if this one declines — cannot supply the resolution instead and
* make a declining assertion pass for the wrong reason.
*/
function indexes(opts: {
local?: readonly BindingRef[];
published?: readonly BindingRef[];
}): ScopeResolutionIndexes {
const scopesById = new Map<ScopeId, Scope>([
[CONSUMER, moduleScope(CONSUMER, CONSUMER_FILE)],
[HUB, moduleScope(HUB, HUB_FILE)],
]);
const bindings = new Map<ScopeId, Map<string, readonly BindingRef[]>>();
if (opts.local !== undefined) bindings.set(HUB, new Map([['scale', opts.local]]));
const augmentations = new Map<ScopeId, Map<string, readonly BindingRef[]>>();
if (opts.published !== undefined) augmentations.set(HUB, new Map([['scale', opts.published]]));
return {
scopeTree: { getScope: (id: ScopeId) => scopesById.get(id) },
moduleScopes: new Map<string, ScopeId>([
[CONSUMER_FILE, CONSUMER],
[HUB_FILE, HUB],
]),
imports: new Map([[CONSUMER, [{ kind: 'namespace', localName: 'hub', targetFile: HUB_FILE }]]]),
bindings,
bindingAugmentations: augmentations,
workspaceFqnBindings: new Map(),
namespaceFqnBindings: new Map(),
accessibleNamespacesByScope: new Map(),
defs: new Map(),
qualifiedNames: { get: () => [], has: () => false, size: 0 },
} as unknown as ScopeResolutionIndexes;
}
/** `bridge.accessor(hub.scale, …)` written at the consumer's module scope. */
const SITE = {
name: 'scale',
kind: 'value-ref',
inScope: CONSUMER,
explicitReceiver: { name: 'hub' },
atRange: { startLine: 10, startCol: 4, endLine: 10, endCol: 20 },
} as unknown as ReferenceSite;
const MODEL = {} as unknown as SemanticModel;
const resolve = (scopes: ScopeResolutionIndexes): SymbolDefinition | undefined =>
resolveValueRefTarget(SITE, CONSUMER_FILE, scopes, MODEL, true);
describe('findNamespaceValueRefTarget — local declarations outrank re-published ones', () => {
it('resolves a re-published callable when the hub declares nothing under the name', () => {
// The control: this is the HUB case the channel exists for, and every
// assertion below is only meaningful while it holds. If the guard were
// written as "decline whenever anything is bound in the target module",
// this is what would break.
expect(resolve(indexes({ published: [ref(PUBLISHED, 'reexport')] }))?.nodeId).toBe(
PUBLISHED.nodeId,
);
});
it('resolves a callable published through a wildcard import', () => {
expect(resolve(indexes({ published: [ref(PUBLISHED, 'wildcard')] }))?.nodeId).toBe(
PUBLISHED.nodeId,
);
});
it('declines when the hub declares a NON-callable under the name', () => {
// The regression. The local lookup type-gates before precedence is settled,
// so `scale` answered nothing locally and the published channel bound
// `dom_utils.scale` — a USES edge to a member the hub does not expose under
// that name, and the opposite of what `hub.scale()` resolves to.
expect(
resolve(
indexes({
local: [ref(LOCAL_CONST, 'local')],
published: [ref(PUBLISHED, 'reexport')],
}),
),
).toBeUndefined();
});
it('still prefers a local CALLABLE over a re-published one', () => {
// The guard suppresses the published channel; it must not suppress the
// local answer that made the precedence rule worth stating.
expect(
resolve(
indexes({
local: [ref(LOCAL_FN, 'local')],
published: [ref(PUBLISHED, 'reexport')],
}),
)?.nodeId,
).toBe(LOCAL_FN.nodeId);
});
it('declines a re-published callable for a provider that does not publish its imports', () => {
// `namespaceExportsIncludeImportedNames` is opt-in: in a language where a
// module's imports are NOT its exports, the hub channel stays closed and
// the guard above is never consulted.
expect(
resolveValueRefTarget(
SITE,
CONSUMER_FILE,
indexes({ published: [ref(PUBLISHED, 'reexport')] }),
MODEL,
false,
),
).toBeUndefined();
});
});
describe('namespace value references follow lexical visibility', () => {
const INNER = 'scope:inner';
const SIBLING = 'scope:sibling';
const CLASS = 'scope:class';
const METHOD = 'scope:method';
const OTHER = 'scope:other:module';
const OTHER_FILE = 'src/other.zig';
const OTHER_FN = def('Function:src/other.zig:scale', 'Function', OTHER_FILE);
function fixture(edge: Partial<ImportEdge> = {}, importScope = INNER) {
const base = indexes({ local: [ref(LOCAL_FN, 'local')] });
const extraScopes = new Map<ScopeId, Scope>([
[INNER, { ...moduleScope(INNER, CONSUMER_FILE), kind: 'Function', parent: CONSUMER }],
[SIBLING, { ...moduleScope(SIBLING, CONSUMER_FILE), kind: 'Function', parent: CONSUMER }],
[CLASS, { ...moduleScope(CLASS, CONSUMER_FILE), kind: 'Class', parent: INNER }],
[METHOD, { ...moduleScope(METHOD, CONSUMER_FILE), kind: 'Function', parent: CLASS }],
[OTHER, moduleScope(OTHER, OTHER_FILE)],
]);
return {
...base,
scopeTree: { getScope: (id: ScopeId) => extraScopes.get(id) ?? base.scopeTree.getScope(id) },
moduleScopes: {
get: (file: string) => (file === OTHER_FILE ? OTHER : base.moduleScopes.get(file)),
},
imports: new Map([
...base.imports,
[
importScope,
[
{
localName: 'hub',
targetExportedName: 'other',
targetFile: OTHER_FILE,
kind: 'namespace',
...edge,
} as ImportEdge,
],
],
]),
bindings: new Map([
...base.bindings,
[OTHER, new Map([['scale', [ref(OTHER_FN, 'local')]]])],
]),
} as unknown as ScopeResolutionIndexes;
}
function at(scope: ScopeId, scopes: ScopeResolutionIndexes) {
return resolveValueRefTarget({ ...SITE, inScope: scope }, CONSUMER_FILE, scopes, MODEL, true, {
skipEnclosingClasses: true,
});
}
it('selects the nearest import for a registration without leaking it to a sibling', () => {
const scopes = fixture();
expect(at(INNER, scopes)?.nodeId).toBe(OTHER_FN.nodeId);
expect(at(METHOD, scopes)?.nodeId).toBe(OTHER_FN.nodeId);
expect(at(SIBLING, scopes)?.nodeId).toBe(LOCAL_FN.nodeId);
});
it.each([{ kind: 'named' as const }, { targetFile: null, linkStatus: 'unresolved' as const }])(
'does not revive the outer namespace under a shadowing import: %j',
(edge) => {
expect(at(INNER, fixture(edge))).toBeUndefined();
},
);
it('skips an enclosing class import but retains it for class-body registrations', () => {
const scopes = fixture({}, CLASS);
expect(at(CLASS, scopes)?.nodeId).toBe(OTHER_FN.nodeId);
expect(at(METHOD, scopes)?.nodeId).toBe(LOCAL_FN.nodeId);
});
});