GitNexus/gitnexus/test/unit/scope-resolution/name-fallback-summary.test.ts
Abhinav Pandey 2220f4d851
fix(resolution): label fallback guesses and preserve export visibility (#3190)
* fix(resolution): distinguish name guesses and preserve export visibility

* test(go): keep method enrichment fixture in one package

* fix(resolution): address split review edge cases and evidence reporting

* fix(exports): recognize imported and expression-local receivers

* fix(resolution): align export and target evidence with language scope

* fix(ingestion): preserve lexical import provenance through resolution

* test(ci): rebalance Windows shards from measured slow suites

* Address PR review feedback (#3190)

- Label constructor unique-name guesses as global-name-fallback and run language vetoes
- Tighten Go qualified, Rust crate::, and Ruby class-reopen fallback guards
- Ignore for-loop shadowed CommonJS receivers and exclude guesses from the resolved-call census
- Refresh FinalizeOutput and hook docs for lexical binding scopes

Co-authored-by: Cursor <cursoragent@cursor.com>

* Tighten review-feedback leftovers on fallback visibility.

Qualified Go calls still respect export and test-package rules, nested Rust src/ stays a module segment, and top-level conditional this.x is treated as CommonJS.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3190)

- Distinguish Swift package prefixes when comparing target modules
- Document lexical import binding and handledSites refusal marking
- Drop the stale ci-scope-parity workflow claim and prototype-safe export verdicts

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3190)

Supply caller source on Ruby visibility cases so they exercise the named allow branches instead of the missing-text bypass.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(resolution): label unique constructor types as name guesses

A workspace-unique class hit in findClassBindingInScope was treated as
an in-scope bind, so Go/JS constructor-form sites skipped the guess
label and the Go unexported veto.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(resolution): keep qualified constructors precise after unique-name split

Bare constructor unique-name hits stay guesses so Go can veto an
unexported type. A written qualifier is now carried as rawQualifiedName
so `new pkg.Foo()` and `models.Box[T]{}` can still recover the unique
class without that veto.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(bench): rebaseline Go/Java scope-capture fingerprints for constructor qualifiers

Generic Go composite literals and qualified Java `new pkg.Foo()` now
carry @reference.qualified-name on existing constructor matches.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(resolution): treat import-reached unique constructors as precise

C++ #include and Rust re-exports do not mint a lexical class binding.
A unique type in an imported file (or imported directory) is therefore
a real bind, not a name guess, so those CALLS edges stay import-resolved.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(resolution): require named or resolved imports for constructor precision

Bare Go Box[T]{} is not package-qualified, and a sibling-file import of a different name is not constructor visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-10 11:38:19 +01:00

238 lines
8.9 KiB
TypeScript

/**
* The per-language guessed/refused census persisted as
* `RepoMeta.nameFallbackEdges` and printed in the analyze summary.
*
* The pair of counts is the point. A guess count alone cannot distinguish a
* language with few impossible candidates from one whose visibility hook is
* missing, and a refusal count alone cannot distinguish a working guard from
* one that rejects everything — so both are asserted to survive per language
* rather than being folded into a repo-wide total.
*/
import { describe, it, expect } from 'vitest';
import type { ResolutionOutcome } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js';
import {
countCallsByLanguage,
formatNameFallbackSummary,
MAX_AMBIGUOUS_NAMES,
summarizeNameFallback,
} from '../../../src/core/ingestion/scope-resolution/name-fallback-summary.js';
const range = { startLine: 1, startCol: 0, endLine: 1, endCol: 5 };
const guessed = (language: string | undefined, name = 'helper'): ResolutionOutcome => ({
kind: 'fallback-guessed',
targetId: `def:${name}`,
language,
phase: 'free-call-fallback',
filePath: 'a',
name,
range,
});
const refused = (language: string | undefined, name = 'helper'): ResolutionOutcome => ({
kind: 'fallback-refused',
candidateId: `def:${name}`,
language,
phase: 'free-call-fallback',
filePath: 'a',
name,
range,
});
describe('summarizeNameFallback', () => {
it('returns undefined when a run neither guessed nor refused', () => {
// A repository with no opt-in language must store no key at all, rather
// than a row of zeroes that reads as a measured result.
expect(summarizeNameFallback([])).toBeUndefined();
});
it('ignores unrelated outcomes', () => {
const unrelated: ResolutionOutcome = {
kind: 'resolved',
targetId: 'def:x',
phase: 'free-call-fallback',
filePath: 'a',
name: 'x',
range,
};
expect(summarizeNameFallback([unrelated])).toBeUndefined();
});
it('keeps guesses and refusals separated per language', () => {
const summary = summarizeNameFallback([
guessed('go'),
guessed('go', 'other'),
refused('go'),
refused('rust'),
refused('rust'),
refused('rust'),
]);
expect(summary).toEqual({
byLanguage: {
go: { guessed: 2, guessedPairs: 2, refused: 1 },
rust: { guessed: 0, guessedPairs: 0, refused: 3 },
},
totalGuessed: 2,
distinctGuessedPairs: 2,
totalRefused: 4,
totalAmbiguousReexports: 0,
});
});
it('keeps call SITES in `guessed` and distinct (caller file, callee name) pairs in `guessedPairs`', () => {
// Three guessed `helper()` sites plus one `other()` in one file: 4 sites,
// 2 distinct pairs. `callsByLanguage` counts distinct callee names, so the
// guessy RATIO is pairs/calls and stays bounded by 1 (M5); the site count
// keeps its historical unit so persisted summaries stay comparable (M13).
const summary = summarizeNameFallback(
[guessed('go'), guessed('go'), guessed('go'), guessed('go', 'other')],
{ go: 2 },
);
expect(summary?.byLanguage.go).toEqual({ guessed: 4, guessedPairs: 2, refused: 0 });
expect(summary?.totalGuessed).toBe(4);
expect(summary?.distinctGuessedPairs).toBe(2);
expect(
summary!.byLanguage.go!.guessedPairs! / summary!.callsByLanguage!.go!,
).toBeLessThanOrEqual(1);
});
it('counts refused `export *` collisions in the same census, outside the language table', () => {
const summary = summarizeNameFallback([
{
kind: 'reexport-ambiguous',
candidateIds: ['def:a', 'def:b'],
phase: 'finalize',
filePath: 'packages/ui/src/index.ts',
name: 'collide',
},
]);
expect(summary).toEqual({
byLanguage: {},
totalGuessed: 0,
distinctGuessedPairs: 0,
totalRefused: 0,
totalAmbiguousReexports: 1,
ambiguousReexportNames: ['packages/ui/src/index.ts:collide'],
});
expect(formatNameFallbackSummary(summary)).toContain('1 barrel name(s) refused as ambiguous');
});
it('buckets an unattributed pass rather than dropping it', () => {
const summary = summarizeNameFallback([guessed(undefined)]);
expect(summary?.byLanguage).toEqual({ unknown: { guessed: 1, guessedPairs: 1, refused: 0 } });
expect(summary?.totalGuessed).toBe(1);
});
it('caps the persisted ambiguous-name list at MAX_AMBIGUOUS_NAMES while keeping the total exact', () => {
const ambiguous = (name: string): ResolutionOutcome => ({
kind: 'reexport-ambiguous',
candidateIds: ['def:a', 'def:b'],
phase: 'finalize',
filePath: 'x.ts',
name,
});
// 250 distinct fixed-width names, well over the 200 cap, generated in
// DESCENDING order so a bug that capped BEFORE sorting (first 200 seen,
// not first 200 alphabetically) would be caught.
const names = Array.from({ length: 250 }, (_, i) => `n${String(249 - i).padStart(3, '0')}`);
const summary = summarizeNameFallback(names.map(ambiguous));
expect(summary?.totalAmbiguousReexports).toBe(250);
expect(summary?.ambiguousReexportNames).toHaveLength(MAX_AMBIGUOUS_NAMES);
const expectedSorted = [...new Set(names.map((n) => `x.ts:${n}`))].sort().slice(0, 200);
expect(summary?.ambiguousReexportNames).toEqual(expectedSorted);
});
it('does not cap when the list is at or under the bound', () => {
const ambiguous = (name: string): ResolutionOutcome => ({
kind: 'reexport-ambiguous',
candidateIds: ['def:a', 'def:b'],
phase: 'finalize',
filePath: 'x.ts',
name,
});
const names = Array.from({ length: MAX_AMBIGUOUS_NAMES }, (_, i) => `n${i}`);
const summary = summarizeNameFallback(names.map(ambiguous));
expect(summary?.ambiguousReexportNames).toHaveLength(MAX_AMBIGUOUS_NAMES);
});
});
describe('countCallsByLanguage', () => {
const nodesOf = (byId: Record<string, string>) => ({
getNode: (id: string) =>
byId[id] === undefined ? undefined : { properties: { filePath: byId[id] } },
});
it('buckets CALLS totals by the CALLER file language, summed across callers', () => {
const index = new Map<string, ReadonlySet<string>>([
['caller-go-1', new Set(['A', 'B'])],
['caller-go-2', new Set(['C'])],
['caller-ts-1', new Set(['D', 'E', 'F'])],
]);
const nodes = nodesOf({
'caller-go-1': 'pkg/a.go',
'caller-go-2': 'pkg/b.go',
'caller-ts-1': 'src/x.ts',
});
expect(countCallsByLanguage(index, nodes)).toEqual({ go: 3, typescript: 3 });
});
it('counts each file/name pair once across functions in the same file', () => {
const index = new Map<string, ReadonlySet<string>>([
['first', new Set(['helper', 'other'])],
['second', new Set(['helper'])],
['third', new Set(['helper'])],
]);
const nodes = nodesOf({ first: 'a.go', second: 'a.go', third: 'b.go' });
expect(countCallsByLanguage(index, nodes)).toEqual({ go: 3 });
});
it('falls back to "unknown" for a caller whose language cannot be detected', () => {
const index = new Map<string, ReadonlySet<string>>([['caller-1', new Set(['A'])]]);
const nodes = nodesOf({ 'caller-1': 'README' });
expect(countCallsByLanguage(index, nodes)).toEqual({ unknown: 1 });
});
it('skips a caller id absent from the node table rather than throwing', () => {
const index = new Map<string, ReadonlySet<string>>([
['missing', new Set(['A'])],
['present', new Set(['B', 'C'])],
]);
const nodes = nodesOf({ present: 'a.py' });
expect(countCallsByLanguage(index, nodes)).toEqual({ python: 2 });
});
it('returns undefined when either input is missing (no denominator available)', () => {
const nodes = nodesOf({ a: 'a.go' });
expect(countCallsByLanguage(undefined, nodes)).toBeUndefined();
expect(countCallsByLanguage(new Map(), undefined)).toBeUndefined();
});
it('returns undefined rather than an empty object when the index has entries but nothing attributes', () => {
const index = new Map<string, ReadonlySet<string>>([['caller-1', new Set(['A'])]]);
const nodes = nodesOf({}); // caller-1 not in the node table
expect(countCallsByLanguage(index, nodes)).toBeUndefined();
});
});
describe('formatNameFallbackSummary', () => {
it('prints nothing when there is nothing to report', () => {
expect(formatNameFallbackSummary(undefined)).toBeUndefined();
});
it('reports both totals and the per-language split, busiest first', () => {
const line = formatNameFallbackSummary(
summarizeNameFallback([guessed('ruby'), refused('go'), refused('go'), refused('go')]),
);
expect(line).toContain('1 call site (1 distinct caller-file/name pairs)');
expect(line).toContain('3 refused as impossible');
// `go` has more total activity, so it leads.
expect(line).toMatch(/go 0\/3.*ruby 1\/0/);
});
it('uses plural call sites for multiple guesses', () => {
expect(
formatNameFallbackSummary(summarizeNameFallback([guessed('go'), guessed('go')])),
).toContain('2 call sites (');
});
});