GitNexus/gitnexus/test/integration/resolvers/name-fallback-edges.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

207 lines
7.6 KiB
TypeScript

/**
* End-to-end behaviour of the global-name fallback after WS1-A.
*
* Two things used to be true at once and both were wrong:
*
* 1. A call to a name that happens to be unique in the repository acquired a
* CALLS edge even when the language forbids the call outright — Go's
* unexported identifiers being the clearest case.
* 2. Every such edge was emitted with `confidence: 0.85` and
* `reason: 'import-resolved'`, i.e. spelled exactly like an edge a real
* import produced, so no consumer could discount it.
*
* These tests pin both. The Go arm proves the impossible edge is now REFUSED,
* with a same-file call confirming ordinary local resolution is preserved.
* That control does not exercise the global-name tier. The Ruby arm proves a
* surviving guess is LABELED, since Ruby deliberately keeps the tier for
* autoload. The last test is the regression that matters most: no edge from
* this tier may ever again carry `import-resolved`.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import {
getRelationships,
getResolutionOutcomes,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './helpers.js';
import { GLOBAL_NAME_FALLBACK_REASON } from '../../../src/core/graph/edge-reasons.js';
const rmRepo = (dir: string | undefined): void => {
if (dir !== undefined) {
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
};
describe('Go: an unexported identifier is not callable from another package', () => {
let result: PipelineResult;
let repoDir: string | undefined;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ws1-go-fallback-'));
writeFixtureRepo(repoDir, {
'go.mod': 'module example.com/mod\n\ngo 1.22\n',
// Package `a` owns `uniqueHelperXyz`. The name is unique repo-wide, which
// is the ONLY reason the old fallback matched it from package `b`.
'a/helper.go': `package a
func uniqueHelperXyz() int {
return 41
}
func UseItLocally() int {
return uniqueHelperXyz() + 1
}
`,
// Package `b` cannot see `uniqueHelperXyz` under any spelling: Go's
// lower-case initial makes it package-private, so no import helps.
'b/caller.go': `package b
func CallItRemotely() int {
return uniqueHelperXyz() + 1
}
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 120000);
afterAll(() => rmRepo(repoDir));
it('keeps ordinary same-file resolution as a control', () => {
const calls = getRelationships(result, 'CALLS');
const local = calls.find((c) => c.source === 'UseItLocally' && c.target === 'uniqueHelperXyz');
expect(local).toBeDefined();
expect(local!.rel.reason).not.toBe(GLOBAL_NAME_FALLBACK_REASON);
});
it('emits NO caller edge from the other package', () => {
const calls = getRelationships(result, 'CALLS');
const crossPackage = calls.filter(
(c) => c.source === 'CallItRemotely' && c.target === 'uniqueHelperXyz',
);
expect(crossPackage).toEqual([]);
});
it('records the drop as a refusal rather than losing it silently', () => {
const refusals = getResolutionOutcomes(result).filter(
(o) => o.kind === 'fallback-refused' && o.name === 'uniqueHelperXyz',
);
expect(refusals.length).toBeGreaterThan(0);
expect(refusals.every((o) => o.kind === 'fallback-refused' && o.language === 'go')).toBe(true);
});
it('lists no cross-package caller for the unexported helper at all', () => {
// The shape an `impact --direction upstream` answer is built from: every
// CALLS edge whose target is the helper. Package `b` must not appear.
const callers = getRelationships(result, 'CALLS')
.filter((c) => c.target === 'uniqueHelperXyz')
.map((c) => c.sourceFilePath);
expect(callers.some((filePath) => filePath.includes('b/caller.go'))).toBe(false);
expect(callers.some((filePath) => filePath.includes('a/helper.go'))).toBe(true);
});
});
describe('Go/JS: constructor-form unique-name guesses are labeled, not import-resolved', () => {
it('refuses an unexported Go composite literal from another package', async () => {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ws1-go-ctor-'));
try {
writeFixtureRepo(repoDir, {
'go.mod': 'module example.com/mod\n\ngo 1.22\n',
'a/t.go': `package a
type uniqueWidget struct{}
`,
'b/c.go': `package b
func F() { _ = uniqueWidget{} }
`,
});
const result = await runPipelineFromRepo(repoDir, () => {});
const cross = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'F' && c.target === 'uniqueWidget',
);
expect(cross).toEqual([]);
const refusals = getResolutionOutcomes(result).filter(
(o) => o.kind === 'fallback-refused' && o.name === 'uniqueWidget',
);
expect(refusals.length).toBeGreaterThan(0);
} finally {
rmRepo(repoDir);
}
}, 120000);
it('labels a JS `new UniqueWidget()` guess instead of import-resolved', async () => {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ws1-js-ctor-'));
try {
writeFixtureRepo(repoDir, {
'package.json': '{ "name": "ctor-fallback", "private": true }\n',
'b/widget.js': `export class UniqueWidget {}\n`,
'a/consumer.js': `export function make() { return new UniqueWidget(); }\n`,
});
const result = await runPipelineFromRepo(repoDir, () => {});
const edge = getRelationships(result, 'CALLS').find(
(c) => c.source === 'make' && c.target === 'UniqueWidget',
);
expect(edge).toBeDefined();
expect(edge!.rel.reason).toBe(GLOBAL_NAME_FALLBACK_REASON);
expect(edge!.rel.confidence).toBe(0.5);
} finally {
rmRepo(repoDir);
}
}, 120000);
});
describe('Ruby: a surviving name guess is labeled as a guess', () => {
let result: PipelineResult;
let repoDir: string | undefined;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ws1-ruby-fallback-'));
writeFixtureRepo(repoDir, {
// A top-level method in one file, called from another with no `require` —
// the autoload shape Ruby keeps the fallback for. The edge is a guess and
// is allowed to exist, but it must say so.
'app/a.rb': `def unique_helper_xyz
41
end
`,
'app/b.rb': `def call_it
unique_helper_xyz()
end
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 120000);
afterAll(() => rmRepo(repoDir));
it('emits the edge with the guess reason and 0.5 confidence', () => {
const edge = getRelationships(result, 'CALLS').find(
(c) => c.source === 'call_it' && c.target === 'unique_helper_xyz',
);
expect(edge).toBeDefined();
expect(edge!.rel.reason).toBe(GLOBAL_NAME_FALLBACK_REASON);
expect(edge!.rel.confidence).toBe(0.5);
});
it('counts the guess so a reader can see how much of the graph is guessed', () => {
const guesses = getResolutionOutcomes(result).filter(
(o) => o.kind === 'fallback-guessed' && o.name === 'unique_helper_xyz',
);
expect(guesses.length).toBeGreaterThan(0);
expect(guesses.every((o) => o.kind === 'fallback-guessed' && o.language === 'ruby')).toBe(true);
});
it('REGRESSION: no guessed edge is spelled like an import-resolved one', () => {
// The specific lie this work removed. Asserted over the whole graph, not
// just the one edge, so a future emitter cannot reintroduce it elsewhere.
const mislabeled = getRelationships(result, 'CALLS').filter(
(c) => c.rel.reason === 'import-resolved',
);
expect(mislabeled).toEqual([]);
});
});