GitNexus/gitnexus/test/unit/scope-resolution/value-ref-dispatchability.test.ts
Navid EMAD 1c7c05ffc4
fix(review): let a file's own @import outrank the workspace-wide class fallback
Found by running the local preflight harness before pushing rather than after.
One of its five findings is a real hole in R2-2; the rest are documentation that
overclaimed.

R4-1 (valid, REPRODUCED) — the container channel preempted the file's own
`@import`. R2-2 added the module channel as a FALLBACK after
`findClassBindingInScope`, and that order is wrong: `findClassBindingInScope`
does not stop at the scope chain. When its `isClassLike` walk misses — and a
namespace handle binds a Module, so it always misses — it falls back to
`scopes.qualifiedNames`, a workspace-wide index, and answers with the unique def
of that name anywhere in the repo. A container named `dom_utils` in a file
`Element.zig` never imports therefore captured
`bridge.accessor(dom_utils.compare, …)`, binding
`Method:src/webapi/decoy.zig:dom_utils.compare#2` while the module channel that
would have answered correctly was never reached. R3-1's shadow guard cannot
catch it: the import binds at MODULE scope, which that guard treats as the floor.

Fixed by trying the module channel FIRST. An `@import` written in this file is
the strongest available statement about what the name means here and outranks a
global uniqueness guess; when the handle is not an import of this file the
channel answers nothing and the container path runs exactly as before. Pinned by
`decoy.zig` and a strengthened assertion on the existing module-owner test,
which fails on the old order.

R4-2 — the cause documentation named shapes nothing captures. `tools.ts`
illustrated `callableValueReferences` with "a callback argument", "a stored
function pointer" and `qsort(xs, n, sz, compareItems)`. Only Zig captures a call
argument or a const initialiser; JS/TS capture only object-literal property
values, and C has no value-ref rule, so the `qsort` example is counted in no
language. Both cause blocks now name the captured shapes and say that a bare
JS/TS callback argument is not among them, so a 0 does not rule it out.

R4-3 — the same block exempted itself from the re-index caveat this PR proves it
needs. "Read from the graph, so it needs no index-time metadata" is true of the
probe and false of the edges: an index built before a language emitted these
captures has none and reports 0 — the warm-cache failure R2-1 bumped
SCHEMA_BUMP for. It now says to re-analyze before reading a 0 as measured.

R4-4 — the dispatchability canary was narrower than its own promise. Its header
claimed it fails on "a fourth language emitting value-ref at all"; it reads
`languages/<dir>/query.ts`, so Vue — which owns no query and borrows
`emitTsScopeCaptures` / `emitJsScopeCaptures` — emits value-refs while the
assertion lists three languages, and a capture synthesized in code is invisible
to it. The case now asserts on query OWNERS, which is what it checks and is
sound because a delegating language inherits the rules it borrows; the header
states the synthesized-capture gap instead of letting a green tick imply it away.

R4-5 — the BARE docstring described a lexical walk that is not one.
`findCallableBindingInScope` applies the callable predicate WHILE walking, so a
nearer parameter or local is stepped over: the defect R3-1 fixed on the container
channel, unguarded here, pre-existing since #2437 and reachable in JS/TS. Out of
scope for #3399, so behaviour is unchanged and the sentence now says what the
walk does rather than implying a guarantee it does not give.

Gates: tsc --noEmit clean, npm run build clean, prettier clean.
`test/integration/resolvers` 3,632 passed / 3 skipped (70 files);
`test/unit/scope-resolution` 2,015 passed (120 files);
`impact-callable-value-references` 7 passed under `lbug-db`. Bench --check:
receiver-resolution, zig-cross-file-resolution, scope-capture (15 languages),
scope-emission, callable-value-flow all PASS with no baseline edited.
2026-09-08 18:48:08 +02:00

167 lines
6.9 KiB
TypeScript

/**
* Canary for the invariant that `callableValueReferenceBoundaries`' dispatch
* exclusion silently depends on (#3219 review round 3).
*
* The exclusion, in `mcp/local/local-backend.ts`: a target with an inbound
* `property-dispatch` CALLS edge is NOT hedged, because the analyzer followed
* the registration and nothing was missed. It is symbol-level, not edge-level —
* the graph does not record which registration produced which synthesized call.
*
* That is only sound while no single symbol can carry BOTH kinds of
* registration, and today none can, for a reason that lives nowhere near the
* exclusion:
*
* - `emitPropertyDispatchCalls` synthesizes a CALLS edge only for a
* registration whose site carries a `propertyKey` (sweep 1 skips the
* registration index when it is undefined; sweep 2 reads only that index).
* - Every JS/TS `@reference.value-ref` rule also captures
* `@reference.property-key` — both are object-literal shapes.
* - No Zig `@reference.value-ref` rule captures one: Zig has no
* object-literal key to dispatch through.
*
* So a dispatchable registration is always a JS/TS one and an undispatchable
* registration is always a Zig one, and the two cannot meet on one symbol.
*
* The day that stops being true — a JS/TS rule for a bare callback argument
* (`register(handler)`), a Zig rule that grows a key — a symbol CAN have both,
* and the exclusion starts publishing `exact` over a registration the analyzer
* provably did not follow. That is the #3399 defect returning through a side
* door, and it would not fail a single existing test.
*
* This test fails instead. If it fails, do not relax it: go and decide what
* `callableValueReferenceBoundaries` should do about a mixed symbol (the
* options are recorded at the exclusion site), then update this file.
*
* WHAT IT DOES NOT COVER, stated so the green tick is not read as more than it
* is. It reads query SOURCES, so a capture synthesized in code rather than
* matched by a rule — the mechanism `@reference.static-gated` uses — can break
* the partition with this test green. A provider adding one has to come here by
* hand. Languages that own no query and delegate to another's captures (Vue →
* `emitTsScopeCaptures` / `emitJsScopeCaptures`) are covered transitively, by
* the rules they borrow, which is why the last case asserts on query OWNERS
* rather than on the set of languages that can emit a value-ref.
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { TYPESCRIPT_SCOPE_QUERY } from '../../../src/core/ingestion/languages/typescript/query.js';
import { JAVASCRIPT_SCOPE_QUERY } from '../../../src/core/ingestion/languages/javascript/query.js';
import { ZIG_SCOPE_QUERY } from '../../../src/core/ingestion/languages/zig/query.js';
const VALUE_REF = '@reference.value-ref';
const PROPERTY_KEY = '@reference.property-key';
/**
* Split a tree-sitter scope query into its top-level s-expression rules.
*
* `;;` comments are dropped first — they discuss the very tags this test
* matches on (Zig's rules carry a paragraph explaining why they attach no
* property key), so leaving them in would make every Zig rule look keyed.
* Double-quoted anonymous nodes (`"const"`, `"("`) are skipped while counting
* depth: a query that matches a literal paren would otherwise unbalance it.
*/
function topLevelRules(query: string): string[] {
const src = query
.split('\n')
.map((line) => {
const comment = line.indexOf(';;');
return comment === -1 ? line : line.slice(0, comment);
})
.join('\n');
const rules: string[] = [];
let depth = 0;
let start = -1;
let inString = false;
for (let i = 0; i < src.length; i++) {
const ch = src[i];
if (inString) {
if (ch === '\\') i++;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === '(') {
if (depth === 0) start = i;
depth++;
} else if (ch === ')') {
depth--;
if (depth === 0 && start !== -1) {
rules.push(src.slice(start, i + 1));
start = -1;
}
if (depth < 0) depth = 0;
}
}
return rules;
}
function valueRefRules(query: string): string[] {
return topLevelRules(query).filter((rule) => rule.includes(VALUE_REF));
}
describe('value-ref dispatchability partition', () => {
it('splits a query into rules without being confused by comments or literal parens', () => {
// Guards the guard: a splitter that silently returned [] would make every
// assertion below vacuously true.
const rules = topLevelRules(`
;; a comment mentioning (parens) and ${PROPERTY_KEY}
(call_expression
function: (_)
(identifier) @reference.name)
(variable_declaration
"const" . (identifier) @a .)
`);
expect(rules).toHaveLength(2);
expect(rules[0]).toContain('call_expression');
expect(rules[1]).toContain('variable_declaration');
expect(rules.join('\n')).not.toContain(PROPERTY_KEY);
});
it('every TypeScript value-ref rule is DISPATCHABLE (carries a property key)', () => {
const rules = valueRefRules(TYPESCRIPT_SCOPE_QUERY);
expect(rules.length).toBeGreaterThan(0);
expect(rules.filter((r) => !r.includes(PROPERTY_KEY))).toEqual([]);
});
it('every JavaScript value-ref rule is DISPATCHABLE (carries a property key)', () => {
const rules = valueRefRules(JAVASCRIPT_SCOPE_QUERY);
expect(rules.length).toBeGreaterThan(0);
expect(rules.filter((r) => !r.includes(PROPERTY_KEY))).toEqual([]);
});
it('every Zig value-ref rule is UNDISPATCHABLE (carries no property key)', () => {
const rules = valueRefRules(ZIG_SCOPE_QUERY);
expect(rules.length).toBeGreaterThan(0);
expect(rules.filter((r) => r.includes(PROPERTY_KEY))).toEqual([]);
});
it('no OTHER language OWNS a value-ref rule', () => {
// The three above are hand-classified. A fourth query declaring
// `value-ref` has not been classified by anyone, so the exclusion's premise
// is unverified for it — classify it here and in the exclusion's comment.
// "Owns", not "emits": Vue has no query of its own and borrows TypeScript's
// and JavaScript's captures, so it inherits their classification rather than
// needing one. A capture synthesized in code owns no rule either and is
// invisible here — see the header.
const languagesDir = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'../../../src/core/ingestion/languages',
);
const emitting = fs
.readdirSync(languagesDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.filter((e) => {
const query = path.join(languagesDir, e.name, 'query.ts');
return fs.existsSync(query) && fs.readFileSync(query, 'utf8').includes(VALUE_REF);
})
.map((e) => e.name)
.sort();
expect(emitting).toEqual(['javascript', 'typescript', 'zig']);
});
});