mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-02 02:11:29 +00:00
* 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>
238 lines
8.9 KiB
TypeScript
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 (');
|
|
});
|
|
});
|