mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
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>
This commit is contained in:
parent
1909af4e04
commit
2220f4d851
68 changed files with 6215 additions and 248 deletions
|
|
@ -137,6 +137,7 @@ export type {
|
|||
FinalizeOutput,
|
||||
FinalizedScc,
|
||||
FinalizeStats,
|
||||
AmbiguousWildcardExport,
|
||||
} from './scope-resolution/finalize-algorithm.js';
|
||||
|
||||
// Scope-aware registries + 7-step lookup (RFC §4; Ring 2 SHARED #917)
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@
|
|||
* Pure logic that takes per-file parse output (`ParsedImport[]` +
|
||||
* `SymbolDefinition[]`) and returns:
|
||||
*
|
||||
* - Linked `ImportEdge[]` per module scope, with `targetModuleScope` and
|
||||
* `targetDefId` filled where resolvable; edges that could not be
|
||||
* resolved within the hard fixpoint cap are marked
|
||||
* - Linked `ImportEdge[]` keyed by binding scope (`fromScope`; module
|
||||
* scope unless `importsBindAtLexicalScope` is on), with
|
||||
* `targetModuleScope` and `targetDefId` filled where resolvable; edges
|
||||
* that could not be resolved within the hard fixpoint cap are marked
|
||||
* `linkStatus: 'unresolved'`.
|
||||
* - Materialized `bindings` per module scope — local defs merged with
|
||||
* imported / wildcard-expanded / re-exported names via the provider's
|
||||
* `mergeBindings` precedence.
|
||||
* - Materialized `bindings` keyed by the same scopes — local defs merged
|
||||
* with imported / wildcard-expanded / re-exported names via the
|
||||
* provider's `mergeBindings` precedence.
|
||||
* - The SCC condensation of the import graph, exposed so disjoint SCCs
|
||||
* can be processed in parallel by callers that want that.
|
||||
*
|
||||
|
|
@ -39,7 +40,7 @@ import type { BindingRef, ImportEdge, ParsedImport, ScopeId, WorkspaceIndex } fr
|
|||
/** Per-file input for the finalize pass. */
|
||||
export interface FinalizeFile {
|
||||
readonly filePath: string;
|
||||
/** The module scope id for this file; owns the finalized imports + bindings. */
|
||||
/** Default binding scope for imports without lexical provenance or opt-in. */
|
||||
readonly moduleScope: ScopeId;
|
||||
readonly parsedImports: readonly ParsedImport[];
|
||||
/**
|
||||
|
|
@ -84,6 +85,10 @@ export interface FinalizeInput {
|
|||
* expects pure answers.
|
||||
*/
|
||||
export interface FinalizeHooks {
|
||||
/** Bind imports at their extracted lexical scope. Missing provenance retains
|
||||
* the legacy module-scope behavior. Opt-in: lexical position and language
|
||||
* import-binding semantics are distinct facts. */
|
||||
readonly importsBindAtLexicalScope?: boolean;
|
||||
/**
|
||||
* Resolve a raw import target to the concrete file path that owns it.
|
||||
* Return `null` when no target file is resolvable (e.g., `np.foo` when
|
||||
|
|
@ -114,6 +119,34 @@ export interface FinalizeHooks {
|
|||
*/
|
||||
expandsWildcardTo(targetModuleScope: ScopeId, workspaceIndex: WorkspaceIndex): readonly string[];
|
||||
|
||||
/**
|
||||
* Does this language make two `wildcard` re-exports that both DECLARE the
|
||||
* same name AMBIGUOUS (no winner), rather than overloads or redeclarations
|
||||
* of one entity?
|
||||
*
|
||||
* True for ECMAScript modules: `export * from './a'; export * from './b'`
|
||||
* with `collide` declared in both excludes the name from the module's
|
||||
* exports, so binding either source is a guess. False (the default) for
|
||||
* languages whose wildcard import is `#include`, `require`, or a package
|
||||
* fan-out, where the same name declared in two files is an overload set
|
||||
* (C++ `write_audit(int)` / `write_audit(int, int)` across two headers), a
|
||||
* redeclaration of one function, or a per-file `init` — legal, and resolved
|
||||
* downstream by arity or by definition. Only a language that opts in has
|
||||
* its collisions refused and reported via `ambiguousWildcardExports`.
|
||||
*/
|
||||
readonly wildcardCollisionIsAmbiguous?: boolean;
|
||||
|
||||
/**
|
||||
* A named import / named re-export binds only to MODULE-LEVEL declarations
|
||||
* of the target file. Opt-in for languages whose `import { x }` can never
|
||||
* reach a class member: without it, a class method sharing a name with a
|
||||
* top-level value — or standing alone — wins the callable preference in
|
||||
* `findExportByName` and the import binds to a symbol the module cannot
|
||||
* export (a confident wrong edge). Languages that bind module-level members
|
||||
* by bare name (static members, module functions) leave it off.
|
||||
*/
|
||||
readonly namedImportsBindTopLevelOnly?: boolean;
|
||||
|
||||
/**
|
||||
* Merge `incoming` bindings into `existing` for a given name. Called
|
||||
* once per name at each scope. Typical rules:
|
||||
|
|
@ -164,12 +197,32 @@ export interface FinalizeStats {
|
|||
readonly unresolvedEdges: number;
|
||||
readonly sccCount: number;
|
||||
readonly largestSccSize: number;
|
||||
/**
|
||||
* Names a file re-exported through two or more `export *` sources that each
|
||||
* DECLARE the name, so the language names no winner. The finalize pass
|
||||
* refuses to bind them (they are absent from the file's re-export closure
|
||||
* AND from its wildcard-expanded module-scope bindings) instead of taking the
|
||||
* first-listed source and publishing the guess as `import-resolved`.
|
||||
* Reported so the caller can record the refusal — an importer of that name
|
||||
* stays unresolved, and the reason must be auditable rather than silent.
|
||||
*/
|
||||
readonly ambiguousWildcardExports: readonly AmbiguousWildcardExport[];
|
||||
}
|
||||
|
||||
/** One refused `export *` collision — see `FinalizeStats.ambiguousWildcardExports`. */
|
||||
export interface AmbiguousWildcardExport {
|
||||
readonly filePath: string;
|
||||
readonly name: string;
|
||||
/** `nodeId`s of the colliding declarations, in `export *` declaration order. */
|
||||
readonly candidateDefIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface FinalizeOutput {
|
||||
/** Linked `ImportEdge[]` per module scope, in original input order. */
|
||||
/** Linked `ImportEdge[]` keyed by binding scope (`fromScope`). Module scope
|
||||
* for languages that bind file-wide; nested lexical scopes when
|
||||
* `importsBindAtLexicalScope` is on. */
|
||||
readonly imports: ReadonlyMap<ScopeId, readonly ImportEdge[]>;
|
||||
/** Materialized bindings per module scope. */
|
||||
/** Materialized bindings keyed by the same scopes as `imports`. */
|
||||
readonly bindings: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
|
||||
/** SCCs in reverse-topological order (leaves first). */
|
||||
readonly sccs: readonly FinalizedScc[];
|
||||
|
|
@ -223,7 +276,33 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
// SCC-condensed). Eliminates the recursive crawl that the per-edge
|
||||
// `tryFinalize` call site used to do; lookups are O(1) afterwards.
|
||||
// See `buildReexportClosures` for the algorithm.
|
||||
const reexportClosures = buildReexportClosures(input.files, byFilePath, edgeIndex);
|
||||
// A local import is a dependency of its file, not a re-export of that file.
|
||||
const moduleEdgeIndex = new Map<string, ImportEdgeDraft[]>();
|
||||
for (const file of input.files) {
|
||||
moduleEdgeIndex.set(
|
||||
file.filePath,
|
||||
(edgeIndex.get(file.filePath) ?? []).filter((d) => d.fromScope === file.moduleScope),
|
||||
);
|
||||
}
|
||||
const ambiguityByFile = collectAmbiguityByFile(
|
||||
input.files,
|
||||
byFilePath,
|
||||
moduleEdgeIndex,
|
||||
hooks.wildcardCollisionIsAmbiguous === true,
|
||||
hooks.namedImportsBindTopLevelOnly === true,
|
||||
);
|
||||
const ambiguousByFile = new Map<string, ReadonlySet<string>>();
|
||||
for (const [filePath, byName] of ambiguityByFile) {
|
||||
ambiguousByFile.set(filePath, new Set(byName.keys()));
|
||||
}
|
||||
const topLevelOnly = hooks.namedImportsBindTopLevelOnly === true;
|
||||
const reexportClosures = buildReexportClosures(
|
||||
input.files,
|
||||
byFilePath,
|
||||
moduleEdgeIndex,
|
||||
ambiguousByFile,
|
||||
topLevelOnly,
|
||||
);
|
||||
|
||||
// ── Phase 3: process SCCs in reverse-topological order (leaves first).
|
||||
// Within each SCC, run a bounded fixpoint that resolves intra-SCC edges.
|
||||
|
|
@ -231,6 +310,19 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
// already finalized); edges inside the SCC may need multiple passes.
|
||||
const linkedByScope = new Map<ScopeId, readonly ImportEdge[]>();
|
||||
let linkedEdges = 0;
|
||||
// Every refused wildcard name, reported from the ambiguity map rather than from
|
||||
// the edges phase 4 happens to drop: a language whose `expandsWildcardTo`
|
||||
// returns nothing (TypeScript — `export *` never binds names locally) drops
|
||||
// no expanded edge, yet its importers were refused through the closure just
|
||||
// the same, and that refusal must still be visible. Named-vs-named conflicts
|
||||
// remain refused above but are not export-star collisions in this audit.
|
||||
const ambiguousWildcardExports: AmbiguousWildcardExport[] = [];
|
||||
for (const [filePath, byName] of ambiguityByFile) {
|
||||
for (const [name, ambiguity] of byName) {
|
||||
if (ambiguity.kind !== 'wildcard') continue;
|
||||
ambiguousWildcardExports.push({ filePath, name, candidateDefIds: ambiguity.candidateDefIds });
|
||||
}
|
||||
}
|
||||
|
||||
for (const scc of sccs) {
|
||||
const sccFiles = new Set(scc.files);
|
||||
|
|
@ -249,7 +341,7 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
if (drafts === undefined) continue;
|
||||
for (const draft of drafts) {
|
||||
if (draft.finalized !== null) continue;
|
||||
const finalized = tryFinalize(draft, byFilePath, reexportClosures);
|
||||
const finalized = tryFinalize(draft, byFilePath, reexportClosures, topLevelOnly);
|
||||
if (finalized !== null) {
|
||||
draft.finalized = finalized;
|
||||
progressed = true;
|
||||
|
|
@ -272,13 +364,25 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
}
|
||||
}
|
||||
|
||||
// ── Phase 4: collect finalized `ImportEdge[]` per module scope, preserving
|
||||
// ── Phase 4: collect finalized `ImportEdge[]` per binding scope, preserving
|
||||
// input order within each file, and wildcard-expand where applicable.
|
||||
for (const file of input.files) {
|
||||
const drafts = edgeIndex.get(file.filePath);
|
||||
if (drafts === undefined) continue;
|
||||
const finalized: ImportEdge[] = [];
|
||||
const finalizedByScope = new Map<ScopeId, ImportEdge[]>([[file.moduleScope, []]]);
|
||||
// Names this file's `export *` sources collide on (see
|
||||
// `collectAmbiguousWildcards`). Their expanded edges are dropped here, so
|
||||
// the file's own module scope does not bind an arbitrary winner either —
|
||||
// suppressing them only in the closure would leave this binding standing,
|
||||
// and it was this binding, not the closure, that produced the published
|
||||
// `import-resolved` guess.
|
||||
const ambiguousHere = ambiguousByFile.get(file.filePath) ?? EMPTY_NAME_SET;
|
||||
for (const d of drafts) {
|
||||
let finalized = finalizedByScope.get(d.fromScope);
|
||||
if (finalized === undefined) {
|
||||
finalized = [];
|
||||
finalizedByScope.set(d.fromScope, finalized);
|
||||
}
|
||||
const edge = d.finalized;
|
||||
if (edge === null) {
|
||||
throw new Error(`Invariant violated: import edge was not finalized for ${file.filePath}`);
|
||||
|
|
@ -286,16 +390,25 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') {
|
||||
// Produce one `wildcard-expanded` ImportEdge per exported name.
|
||||
const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex);
|
||||
for (const e of expanded) finalized.push(e);
|
||||
for (const e of expanded) {
|
||||
if (
|
||||
d.fromScope === file.moduleScope &&
|
||||
e.kind === 'wildcard-expanded' &&
|
||||
ambiguousHere.has(e.localName)
|
||||
)
|
||||
continue;
|
||||
finalized.push(e);
|
||||
}
|
||||
} else {
|
||||
finalized.push(edge);
|
||||
}
|
||||
if (edge.linkStatus !== 'unresolved') linkedEdges++;
|
||||
}
|
||||
linkedByScope.set(file.moduleScope, Object.freeze(finalized));
|
||||
for (const [scopeId, edges] of finalizedByScope)
|
||||
linkedByScope.set(scopeId, Object.freeze(edges));
|
||||
}
|
||||
|
||||
// ── Phase 5: materialize module-scope bindings (local + imports + wildcards),
|
||||
// ── Phase 5: materialize bindings (local + scoped imports + wildcards),
|
||||
// delegating precedence to `provider.mergeBindings`.
|
||||
const bindingsByScope = materializeBindings(input.files, linkedByScope, hooks);
|
||||
|
||||
|
|
@ -312,6 +425,7 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
unresolvedEdges: totalEdges - linkedEdges,
|
||||
sccCount,
|
||||
largestSccSize,
|
||||
ambiguousWildcardExports: Object.freeze(ambiguousWildcardExports),
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
|
|
@ -339,6 +453,10 @@ function makeEdgeDrafts(
|
|||
hooks: FinalizeHooks,
|
||||
workspace: WorkspaceIndex,
|
||||
): ImportEdgeDraft[] {
|
||||
const fromScope =
|
||||
hooks.importsBindAtLexicalScope === true
|
||||
? (parsed.declaredAtScope ?? file.moduleScope)
|
||||
: file.moduleScope;
|
||||
// Dynamic-unresolved passes through — no `BindingRef`, no target file.
|
||||
if (parsed.kind === 'dynamic-unresolved') {
|
||||
const base: ImportEdge = {
|
||||
|
|
@ -351,7 +469,7 @@ function makeEdgeDrafts(
|
|||
{
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
fromScope,
|
||||
targetFile: null,
|
||||
base,
|
||||
finalized: base, // already fully finalized
|
||||
|
|
@ -381,7 +499,7 @@ function makeEdgeDrafts(
|
|||
{
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
fromScope,
|
||||
targetFile: null,
|
||||
base,
|
||||
finalized: base,
|
||||
|
|
@ -417,7 +535,7 @@ function makeEdgeDrafts(
|
|||
return {
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
fromScope,
|
||||
targetFile: tf,
|
||||
base,
|
||||
finalized: isFileLevelTerminal ? base : null,
|
||||
|
|
@ -529,6 +647,7 @@ function tryFinalize(
|
|||
draft: ImportEdgeDraft,
|
||||
byFilePath: Map<string, FinalizeFile>,
|
||||
reexportClosures: ReadonlyMap<string, FileReexportClosure>,
|
||||
topLevelOnly: boolean,
|
||||
): ImportEdge | null {
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null) return draft.base; // already terminal
|
||||
|
|
@ -552,7 +671,11 @@ function tryFinalize(
|
|||
// so consumers can reach the module as a symbol — but its absence is not
|
||||
// a failure.
|
||||
if (draft.base.kind === 'namespace') {
|
||||
const moduleDef = findExportByName(targetModule.localDefs, extractExportedName(draft.source));
|
||||
const moduleDef = findExportByName(
|
||||
targetModule.localDefs,
|
||||
extractExportedName(draft.source),
|
||||
topLevelOnly,
|
||||
);
|
||||
return {
|
||||
...draft.base,
|
||||
targetModuleScope: targetModule.moduleScope,
|
||||
|
|
@ -564,7 +687,7 @@ function tryFinalize(
|
|||
// local defs. Multi-hop re-export chains settle iteratively — each hop
|
||||
// resolves once its prior hop is finalized.
|
||||
const importedName = extractExportedName(draft.source);
|
||||
const exported = findExportByName(targetModule.localDefs, importedName);
|
||||
const exported = findExportByName(targetModule.localDefs, importedName, topLevelOnly);
|
||||
|
||||
if (exported !== undefined) {
|
||||
const transitiveVia =
|
||||
|
|
@ -691,15 +814,18 @@ function buildReexportClosures(
|
|||
files: readonly FinalizeFile[],
|
||||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
|
||||
ambiguous: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
topLevelOnly: boolean,
|
||||
): ReadonlyMap<string, FileReexportClosure> {
|
||||
const closures = new Map<string, Map<string, ReexportClosureEntry>>();
|
||||
for (const file of files) closures.set(file.filePath, new Map());
|
||||
|
||||
// ── Step 1: build the re-export sub-graph (only resolvable wildcard /
|
||||
// reexport / flagged-named targets contribute edges), and collect the
|
||||
// per-file ambiguous names in the same walk.
|
||||
// reexport / flagged-named targets contribute edges). The per-file
|
||||
// ambiguous-name sets arrive precomputed (`collectAmbiguityByFile`) because
|
||||
// phase 4 consults the same sets when it expands wildcards into module
|
||||
// scope — one source of truth for "this name has no winner".
|
||||
const subGraph = new Map<string, Set<string>>();
|
||||
const ambiguous = new Map<string, ReadonlySet<string>>();
|
||||
for (const file of files) {
|
||||
const targets = new Set<string>();
|
||||
const drafts = edgeIndex.get(file.filePath);
|
||||
|
|
@ -710,7 +836,6 @@ function buildReexportClosures(
|
|||
if (!byFilePath.has(d.targetFile)) continue;
|
||||
targets.add(d.targetFile);
|
||||
}
|
||||
ambiguous.set(file.filePath, collectAmbiguousReexports(drafts, byFilePath));
|
||||
}
|
||||
subGraph.set(file.filePath, targets);
|
||||
}
|
||||
|
|
@ -726,7 +851,7 @@ function buildReexportClosures(
|
|||
if (!scc.isCycle) {
|
||||
const filePath = scc.files[0];
|
||||
if (filePath !== undefined) {
|
||||
populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous);
|
||||
populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous, topLevelOnly);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -740,7 +865,9 @@ function buildReexportClosures(
|
|||
progressed = false;
|
||||
iter++;
|
||||
for (const filePath of scc.files) {
|
||||
if (populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous)) {
|
||||
if (
|
||||
populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous, topLevelOnly)
|
||||
) {
|
||||
progressed = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -820,6 +947,220 @@ function isNamedReexport(draft: ImportEdgeDraft): draft is ImportEdgeDraft & {
|
|||
* are still filling in, so detecting them needs a set that grows during the
|
||||
* fixpoint — the thing this pre-pass exists to avoid.
|
||||
*/
|
||||
/**
|
||||
* Per-file set of re-exported names that have NO decidable winner, from both
|
||||
* detectors: `collectAmbiguousReexports` (flagged-named vs flagged-named) and
|
||||
* `collectAmbiguousWildcards` (`export *` vs `export *`, direct declarations).
|
||||
* Fixed for the whole run; consulted by the closure fixpoint AND by phase 4's
|
||||
* wildcard expansion, so a refused name is absent from BOTH the exports an
|
||||
* importer can reach and the module-scope bindings the file itself sees.
|
||||
*/
|
||||
interface ReexportAmbiguity {
|
||||
readonly kind: 'named' | 'wildcard';
|
||||
readonly candidateDefIds: readonly string[];
|
||||
}
|
||||
|
||||
function collectAmbiguityByFile(
|
||||
files: readonly FinalizeFile[],
|
||||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
|
||||
wildcardCollisionIsAmbiguous: boolean,
|
||||
topLevelOnly: boolean,
|
||||
): ReadonlyMap<string, ReadonlyMap<string, ReexportAmbiguity>> {
|
||||
const out = new Map<string, ReadonlyMap<string, ReexportAmbiguity>>();
|
||||
for (const file of files) {
|
||||
const drafts = edgeIndex.get(file.filePath);
|
||||
if (drafts === undefined) continue;
|
||||
const byName = new Map<string, ReexportAmbiguity>();
|
||||
for (const name of collectAmbiguousReexports(drafts, byFilePath)) {
|
||||
byName.set(name, {
|
||||
kind: 'named',
|
||||
candidateDefIds: namedReexportCandidates(drafts, byFilePath, name, topLevelOnly),
|
||||
});
|
||||
}
|
||||
// Wildcard-vs-wildcard is a language rule (`FinalizeHooks.
|
||||
// wildcardCollisionIsAmbiguous`): ECMAScript excludes the name, C++
|
||||
// overloads it. Without the opt-in this half stays first-wins.
|
||||
if (wildcardCollisionIsAmbiguous) {
|
||||
for (const [name, ids] of collectAmbiguousWildcards(file, drafts, byFilePath)) {
|
||||
if (!byName.has(name)) byName.set(name, { kind: 'wildcard', candidateDefIds: ids });
|
||||
}
|
||||
}
|
||||
if (byName.size === 0) continue;
|
||||
out.set(file.filePath, byName);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The declarations a flagged-named collision on `localName` points at. */
|
||||
function namedReexportCandidates(
|
||||
drafts: readonly ImportEdgeDraft[],
|
||||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
localName: string,
|
||||
topLevelOnly: boolean,
|
||||
): readonly string[] {
|
||||
const ids: string[] = [];
|
||||
for (const draft of drafts) {
|
||||
if (!isNamedReexport(draft) || draft.source.localName !== localName) continue;
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null) continue;
|
||||
const target = byFilePath.get(targetFile);
|
||||
if (target === undefined) continue;
|
||||
const def = findExportByName(target.localDefs, draft.source.importedName, topLevelOnly);
|
||||
if (def !== undefined && !ids.includes(def.nodeId)) ids.push(def.nodeId);
|
||||
}
|
||||
return Object.freeze(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* `export * from './a'; export * from './b'` where BOTH `a` and `b` declare
|
||||
* `collide`: the language names no winner (ECMAScript excludes the name from
|
||||
* the module's exports entirely; a direct `import { collide }` of it is a
|
||||
* SyntaxError-class ambiguity). First-wins here published the `a` binding as
|
||||
* `import-resolved` at full confidence — a definite target for a call that has
|
||||
* none, which is the incorrect-context-over-missing-context failure in its
|
||||
* purest form. The name is refused instead and reported.
|
||||
*
|
||||
* Decidable in this pre-pass because it reads only the targets' own
|
||||
* `localDefs` — nothing that fills in during the closure fixpoint. Collisions
|
||||
* that arrive TRANSITIVELY (two wildcards whose targets each re-export the
|
||||
* name from somewhere else) are still first-wins; detecting them needs a set
|
||||
* that grows mid-fixpoint, the thing this pre-pass exists to avoid.
|
||||
*
|
||||
* A name the file DECLARES itself, or re-exports by NAME, is excluded: an
|
||||
* explicit export shadows every `export *`, so those collisions are legal and
|
||||
* resolved by precedence, not ambiguous.
|
||||
*
|
||||
* Only MODULE-LEVEL, EXPORT-SHAPED declarations can collide. `localDefs` also
|
||||
* carries class members, properties and parameters (a `Property:value` on two
|
||||
* unrelated classes, an interface field named `move`), which no `export *`
|
||||
* publishes. Counting those produced thousands of phantom collisions on a real
|
||||
* monorepo (2,640 on grafana) and — the dangerous half — would have refused a
|
||||
* genuinely exported `move()` because some class elsewhere had a `move`
|
||||
* property. The wildcard closure loop tolerates the wider set because nobody
|
||||
* imports a property by name; a refusal cannot afford the same tolerance.
|
||||
*
|
||||
* Export evidence, when the language supplies it (`SymbolDefinition.isExported`,
|
||||
* tri-state), settles the rest: a def marked `false` is module-private and is
|
||||
* neither a provider here nor published by the closure
|
||||
* (`indexTopLevelExportsByName`), so a private `function foo` beside an exported
|
||||
* one no longer refuses the export — and, the half that matters more, cannot be
|
||||
* the first-listed winner the closure binds either. A def marked `true` counts
|
||||
* whatever its label, `Variable` included: the closure publishes a `Variable`,
|
||||
* so two sources each exporting `const alpha` are a real collision and must be
|
||||
* refused rather than first-wins.
|
||||
*
|
||||
* Without evidence (`isExported` undefined — most languages) `Variable` is
|
||||
* excluded from the COLLISION set only: the typical top-level `const` in a
|
||||
* barrel's sources is module-private (`const category = ['Axis']` in fourteen
|
||||
* option-builder files), so counting it would refuse a real exported constant
|
||||
* of the same name for nothing. Residual risk, accepted, for that evidence-free
|
||||
* case: a non-exported `function`/`class` sharing its name with an exported one
|
||||
* behind the same barrel is counted as a collision and the export is refused —
|
||||
* a missing edge, never a wrong one. `ownerId` is only set for class members, so
|
||||
* a callable nested in an object literal (`showIf: (cfg) => …` across fourteen
|
||||
* option-builder files) still counts as a provider when unmarked. Measured
|
||||
* before the export marker existed: grafana@871af0720 refuses 52 names (from
|
||||
* 2,640 before the member exclusion), discourse@3f71fa15c 5.
|
||||
*/
|
||||
|
||||
/** Labels that are never a module export, whatever their owner. */
|
||||
const NON_EXPORTABLE_MEMBER_LABELS: readonly string[] = [
|
||||
'Property',
|
||||
'Method',
|
||||
'Constructor',
|
||||
'Parameter',
|
||||
'Field',
|
||||
];
|
||||
/** Labels excluded from the collision set when no export evidence is present. */
|
||||
const UNMARKED_NON_COLLIDING_LABELS: ReadonlySet<string> = new Set([
|
||||
...NON_EXPORTABLE_MEMBER_LABELS,
|
||||
'Variable',
|
||||
]);
|
||||
/**
|
||||
* Labels a module can never export by name: class/interface members and
|
||||
* parameters. Filtered by LABEL, not `ownerId` — `ownerId` is populated in a
|
||||
* later pass and is not reliable while the closure is built.
|
||||
*/
|
||||
const MEMBER_LABELS: ReadonlySet<string> = new Set(NON_EXPORTABLE_MEMBER_LABELS);
|
||||
|
||||
/**
|
||||
* A declaration `export *` could publish, for COLLISION purposes: top-level, of
|
||||
* an exportable kind, and not marked module-private. With export evidence the
|
||||
* label rule yields to the marker (an exported `Variable` collides; a private
|
||||
* `function` does not); without it `Variable` is left out — see the header.
|
||||
*/
|
||||
function isWildcardPublishable(def: SymbolDefinition): boolean {
|
||||
// Explicit evidence wins over the label: a CommonJS `module.exports = {
|
||||
// alpha() {} }` member is labeled Method and IS the module's export.
|
||||
if (def.isExported === true) return true;
|
||||
if (def.isExported === false) return false;
|
||||
if (def.ownerId !== undefined) return false;
|
||||
return !UNMARKED_NON_COLLIDING_LABELS.has(def.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Can a declaration of the barrel's OWN shadow a name its `export *` sources
|
||||
* collide on? Only a module-level binding can — ECMAScript's explicit-export
|
||||
* precedence is about the module's own exports. A class MEMBER named `clash`
|
||||
* (`export class Unrelated { clash() {} }`) is not such a binding and must not
|
||||
* switch the collision check off; it did, and a confident edge to one source's
|
||||
* `clash` was emitted where the import should have been refused.
|
||||
*/
|
||||
function canShadowWildcard(def: SymbolDefinition): boolean {
|
||||
if (def.isExported === true) return true;
|
||||
if (def.isExported === false) return false;
|
||||
if (def.ownerId !== undefined) return false;
|
||||
return !MEMBER_LABELS.has(def.type);
|
||||
}
|
||||
function collectAmbiguousWildcards(
|
||||
file: FinalizeFile,
|
||||
drafts: readonly ImportEdgeDraft[],
|
||||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
): ReadonlyMap<string, readonly string[]> {
|
||||
const shadowed = new Set<string>();
|
||||
for (const def of file.localDefs) {
|
||||
if (!canShadowWildcard(def)) continue;
|
||||
const name = deriveSimpleName(def);
|
||||
if (name !== null) shadowed.add(name);
|
||||
}
|
||||
for (const draft of drafts) {
|
||||
if (isNamedReexport(draft)) shadowed.add(draft.source.localName);
|
||||
}
|
||||
|
||||
// name → (target file → declaring def ids), in declaration order.
|
||||
const providers = new Map<string, Map<string, string[]>>();
|
||||
for (const draft of drafts) {
|
||||
if (draft.source.kind !== 'wildcard') continue;
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null) continue;
|
||||
const target = byFilePath.get(targetFile);
|
||||
if (target === undefined) continue;
|
||||
for (const def of target.localDefs) {
|
||||
if (!isWildcardPublishable(def)) continue;
|
||||
const name = deriveSimpleName(def);
|
||||
if (name === null || shadowed.has(name)) continue;
|
||||
let byTarget = providers.get(name);
|
||||
if (byTarget === undefined) {
|
||||
byTarget = new Map<string, string[]>();
|
||||
providers.set(name, byTarget);
|
||||
}
|
||||
const ids = byTarget.get(targetFile);
|
||||
if (ids === undefined) byTarget.set(targetFile, [def.nodeId]);
|
||||
else ids.push(def.nodeId);
|
||||
}
|
||||
}
|
||||
const conflicting = new Map<string, readonly string[]>();
|
||||
for (const [name, byTarget] of providers) {
|
||||
// Two DIFFERENT source files declaring the name. The same file declaring
|
||||
// it twice (overloads, a declaration merged with its namespace) is one
|
||||
// provider and not a collision.
|
||||
if (byTarget.size < 2) continue;
|
||||
conflicting.set(name, Object.freeze([...byTarget.values()].flat()));
|
||||
}
|
||||
return conflicting;
|
||||
}
|
||||
|
||||
function collectAmbiguousReexports(
|
||||
drafts: readonly ImportEdgeDraft[],
|
||||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
|
|
@ -859,6 +1200,7 @@ function populateFileClosure(
|
|||
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
|
||||
closures: Map<string, Map<string, ReexportClosureEntry>>,
|
||||
ambiguousByFile: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
topLevelOnly: boolean,
|
||||
): boolean {
|
||||
const myClosure = closures.get(filePath);
|
||||
if (myClosure === undefined) return false;
|
||||
|
|
@ -883,7 +1225,7 @@ function populateFileClosure(
|
|||
if (ambiguous.has(localName) || myClosure.has(localName)) continue;
|
||||
|
||||
const importedName = draft.source.importedName;
|
||||
const direct = findExportByName(targetModule.localDefs, importedName);
|
||||
const direct = findExportByName(targetModule.localDefs, importedName, topLevelOnly);
|
||||
if (direct !== undefined) {
|
||||
myClosure.set(localName, { def: direct, via: Object.freeze([targetFile]) });
|
||||
continue;
|
||||
|
|
@ -909,9 +1251,27 @@ function populateFileClosure(
|
|||
const targetModule = byFilePath.get(targetFile);
|
||||
if (targetModule === undefined) continue;
|
||||
|
||||
for (const def of targetModule.localDefs) {
|
||||
const name = deriveSimpleName(def);
|
||||
if (name === null || ambiguous.has(name) || myClosure.has(name)) continue;
|
||||
// Fan out the WINNER per name, not every def. `export const alpha = () =>
|
||||
// {}` emits both a `Variable` (the lexical declaration) and a `Function`
|
||||
// (the arrow) under the same simple name; iterating `localDefs` raw let
|
||||
// whichever came first — the `Variable` — claim the closure slot, and a
|
||||
// call bound to a value shadow emits no CALLS edge. Named re-exports
|
||||
// already go through `findExportByName`'s callable-preferred index; the
|
||||
// wildcard hop is the same lookup and must apply the same preference.
|
||||
// Measured: grafana `Button`/`clearButtonStyles` (arrow consts behind
|
||||
// `export *`) resolved 8 of 475 ledger entries before this.
|
||||
// Over TOP-LEVEL declarations only. `localDefs` also carries class members;
|
||||
// `Foo.render` (label `Method`, callable) outranked the file's real
|
||||
// `const render` in the callable-preferred index and `import { render }`
|
||||
// bound to a symbol `export *` can never publish — a confident wrong edge
|
||||
// where the value shadow used to yield none. Gated by the same hook as the
|
||||
// named-import path: only a language that opted in (ECMAScript, where
|
||||
// `export *` cannot publish a class member) narrows; every other language's
|
||||
// wildcard keeps the wide index, whose members are legitimately reachable.
|
||||
for (const [name, def] of (topLevelOnly ? indexTopLevelExportsByName : indexExportsByName)(
|
||||
targetModule.localDefs,
|
||||
)) {
|
||||
if (ambiguous.has(name) || myClosure.has(name)) continue;
|
||||
myClosure.set(name, { def, via: Object.freeze([targetFile]) });
|
||||
}
|
||||
const targetClosure = closures.get(targetFile);
|
||||
|
|
@ -993,6 +1353,13 @@ function deriveSimpleName(def: SymbolDefinition): string | null {
|
|||
function findExportByName(
|
||||
defs: readonly SymbolDefinition[],
|
||||
name: string,
|
||||
/**
|
||||
* `true` (a `namedImportsBindTopLevelOnly` language): consult only
|
||||
* module-level declarations, so a class member can neither outrank a
|
||||
* top-level value nor bind on its own. Phase-4 wildcard expansion keeps
|
||||
* the wide index — that is the path languages use to bind members.
|
||||
*/
|
||||
topLevelOnly: boolean = false,
|
||||
): SymbolDefinition | undefined {
|
||||
// GENERIC RULE (applies to every language using this finalize
|
||||
// algorithm): when MULTIPLE `SymbolDefinition`s share the same simple
|
||||
|
|
@ -1018,7 +1385,7 @@ function findExportByName(
|
|||
//
|
||||
// See `gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts`
|
||||
// for the cross-file regression this rule prevents.
|
||||
return indexExportsByName(defs).get(name);
|
||||
return (topLevelOnly ? indexTopLevelExportsByName(defs) : indexExportsByName(defs)).get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1062,6 +1429,47 @@ function indexExportsByName(
|
|||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* `indexExportsByName` restricted to declarations a module publishes by name:
|
||||
* members (by LABEL — `ownerId` is stamped by a later reconcile pass and is not
|
||||
* reliable while the closure is built) are skipped unless the language marked
|
||||
* them exported (a CommonJS `module.exports = { alpha() {} }` member), and so
|
||||
* is any def the language marked module-private (`isExported === false`) — a
|
||||
* function nested inside another function carries the Function label and used
|
||||
* to displace the real exported value of the same name here; a barrel cannot
|
||||
* republish what its source never exported, and binding it would put a private
|
||||
* `function foo` in front of the exported one another source provides.
|
||||
* `Variable` stays, since a barrel legitimately republishes a `const`. Same
|
||||
* memoization contract.
|
||||
*/
|
||||
const TOP_LEVEL_EXPORTS_BY_NAME = new WeakMap<
|
||||
readonly SymbolDefinition[],
|
||||
ReadonlyMap<string, SymbolDefinition>
|
||||
>();
|
||||
|
||||
function indexTopLevelExportsByName(
|
||||
defs: readonly SymbolDefinition[],
|
||||
): ReadonlyMap<string, SymbolDefinition> {
|
||||
const cached = TOP_LEVEL_EXPORTS_BY_NAME.get(defs);
|
||||
if (cached !== undefined) return cached;
|
||||
const index = new Map<string, SymbolDefinition>();
|
||||
for (const d of defs) {
|
||||
// Evidence over label, both ways: a marked-private def (a function nested
|
||||
// in another function carries the Function label too) is skipped, and a
|
||||
// marked-exported member (`module.exports = { alpha() {} }`) is admitted.
|
||||
if (d.isExported === false) continue;
|
||||
if (d.isExported !== true && MEMBER_LABELS.has(d.type)) continue;
|
||||
const name = deriveSimpleName(d);
|
||||
if (name === null) continue;
|
||||
const existing = index.get(name);
|
||||
if (existing === undefined) index.set(name, d);
|
||||
else if (!isCallableOrTypeLike(existing.type) && isCallableOrTypeLike(d.type))
|
||||
index.set(name, d);
|
||||
}
|
||||
TOP_LEVEL_EXPORTS_BY_NAME.set(defs, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
const EMPTY_NAME_SET: ReadonlySet<string> = new Set();
|
||||
|
||||
const CALLABLE_OR_TYPE_LIKE: ReadonlySet<string> = new Set([
|
||||
|
|
@ -1179,7 +1587,7 @@ function materializeBindings(
|
|||
linkedByScope: ReadonlyMap<ScopeId, readonly ImportEdge[]>,
|
||||
hooks: FinalizeHooks,
|
||||
): ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>> {
|
||||
const out = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
|
||||
const buckets = new Map<ScopeId, Map<string, readonly BindingRef[]>>();
|
||||
|
||||
// Build a `nodeId → SymbolDefinition` index once across all files
|
||||
// (O(N_files × D_defs)) so the per-edge lookup below is O(1) instead
|
||||
|
|
@ -1204,8 +1612,17 @@ function materializeBindings(
|
|||
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope));
|
||||
}
|
||||
|
||||
// Layer in finalized imports.
|
||||
const imports = linkedByScope.get(file.moduleScope) ?? [];
|
||||
buckets.set(file.moduleScope, scopeBindings);
|
||||
}
|
||||
|
||||
// Layer imports into their binding scope; lexical locals already live in
|
||||
// the scope tree and must not be copied into unrelated scope buckets.
|
||||
for (const [scopeId, imports] of linkedByScope) {
|
||||
let scopeBindings = buckets.get(scopeId);
|
||||
if (scopeBindings === undefined) {
|
||||
scopeBindings = new Map();
|
||||
buckets.set(scopeId, scopeBindings);
|
||||
}
|
||||
for (const edge of imports) {
|
||||
if (edge.targetDefId === undefined || edge.linkStatus === 'unresolved') continue;
|
||||
const def = defById.get(edge.targetDefId);
|
||||
|
|
@ -1224,15 +1641,18 @@ function materializeBindings(
|
|||
if (name === null) continue;
|
||||
const incoming: BindingRef[] = [{ def, origin, via: edge }];
|
||||
const existing = scopeBindings.get(name) ?? [];
|
||||
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope));
|
||||
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, scopeId));
|
||||
}
|
||||
}
|
||||
|
||||
const out = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
|
||||
for (const [scopeId, scopeBindings] of buckets) {
|
||||
// Freeze nested buckets for immutability.
|
||||
const frozen = new Map<string, readonly BindingRef[]>();
|
||||
for (const [name, refs] of scopeBindings) {
|
||||
frozen.set(name, Object.freeze(refs.slice()));
|
||||
}
|
||||
out.set(file.moduleScope, frozen);
|
||||
out.set(scopeId, frozen);
|
||||
}
|
||||
|
||||
return out;
|
||||
|
|
|
|||
|
|
@ -111,6 +111,18 @@ export interface SymbolDefinition {
|
|||
* source (for example an anonymous class). Consumers may use this only as a
|
||||
* conservative priority hint; it does not change graph-node identity. */
|
||||
isSynthetic?: boolean;
|
||||
/**
|
||||
* Whether the producing language saw EXPORT EVIDENCE on this declaration —
|
||||
* an `export` modifier, a later `export { name }` specifier, an `export
|
||||
* default name`. TRI-STATE, and the absence is load-bearing: `undefined`
|
||||
* means the language emitted no verdict (most languages, and any ECMAScript
|
||||
* file whose export surface is a CommonJS assignment the marker cannot read),
|
||||
* which readers MUST treat as "unknown" and fall back to their prior
|
||||
* behavior. Only `false` says "this module does not publish the name": a
|
||||
* `false` keeps a module-private `function foo` from being counted as a
|
||||
* wildcard provider or bound through a barrel's `export *` closure.
|
||||
*/
|
||||
isExported?: boolean;
|
||||
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
|
||||
ownerId?: string;
|
||||
/** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`)
|
||||
|
|
|
|||
|
|
@ -107,7 +107,14 @@ export type CaptureMatch = Readonly<Record<string, Capture>>;
|
|||
* produced when `expandsWildcardTo` materializes a wildcard against target
|
||||
* exports — a provider must never emit it at parse time.
|
||||
*/
|
||||
export type ParsedImport =
|
||||
export type ParsedImport = ParsedImportSyntax & {
|
||||
/** Lexical location retained by extraction, independently of execution timing.
|
||||
* Absent for legacy or synthesized imports. Binding semantics remain opt-in
|
||||
* through FinalizeHooks.importsBindAtLexicalScope. */
|
||||
readonly declaredAtScope?: ScopeId;
|
||||
};
|
||||
|
||||
type ParsedImportSyntax =
|
||||
/**
|
||||
* Per-name import without rename.
|
||||
*
|
||||
|
|
@ -183,14 +190,15 @@ export type ParsedImport =
|
|||
* field exists.** The natural place to decide it looks like the graph
|
||||
* bridge, by walking the scope the finalized edges hang off; that is
|
||||
* exactly what `graph-bridge/imports-to-edges.ts` once attempted, and it
|
||||
* is dead code by construction. `finalize-algorithm.ts:295` publishes
|
||||
* every file's finalized edges as
|
||||
* `linkedByScope.set(file.moduleScope, …)`, so the map the bridge
|
||||
* receives is keyed by the file's **Module** scope and by nothing else:
|
||||
* the walk starts at a `Module` every time and answers `false` for every
|
||||
* import in the tree. Finalize cannot recover the position either —
|
||||
* `FinalizeFile.parsedImports` is a flat per-file `ParsedImport[]` with
|
||||
* no scope attached. The extractor is the last stage that still knows
|
||||
* is dead code by construction. Default finalize still publishes each
|
||||
* file's edges under `file.moduleScope`, so a bridge walk that starts
|
||||
* there answers `false` for every import. Resolvers that opt into
|
||||
* `importsBindAtLexicalScope` instead key the edge by
|
||||
* `parsed.declaredAtScope` when extraction placed the statement (Rust
|
||||
* `use` in a function body is the example). Even then the *execution*
|
||||
* question — does this import run at module init? — is not the same as
|
||||
* the lexical key, and `FinalizeFile.parsedImports` is still a flat
|
||||
* per-file list. The extractor is the last stage that still knows
|
||||
* where the statement sat (`scope-extractor.ts`, Pass 3), so it marks the
|
||||
* fact here and it rides the edge from there — see
|
||||
* {@link ImportEdge.runsOnlyWhenCalled}.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
{
|
||||
"_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/<lang>-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).",
|
||||
"go": {
|
||||
"fingerprint": "9c554a9d698a2b79fb419852daadca87b8aae88180cceabf9c8d82f3e3300f2e",
|
||||
"fingerprint": "2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f",
|
||||
"_rebaselined_3190_ctor_qualified_name": "#3190: generic composite-literal constructors now carry @reference.qualified-name with the written pkg.Box[T] spelling so constructor fallback can tell a qualifier from a bare unique-name guess. DIGEST DRIFT ONLY: the tag is added to existing constructor matches (captureGroups unchanged on go-constructor-type-inference/cmd/main.go). Prior 990e4921a7ef0e6aa4d7da92b671de91fa2621cb27918adb27742740dc6ad902 -> 2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f; capture_groups_fp 2579, fixture_count 122.",
|
||||
"_rebaselined_3190": "Corrected go-method-enrichment/app.go to share package animal with its declarations; its capture count is now 16 rather than 15. No Go emitter change. Scaling budget unchanged.",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.",
|
||||
|
|
@ -137,8 +139,9 @@
|
|||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "2bf47cc19b595a9889ac21ec0154c6ce6786271d68551f21d1bc14c626bcd4ff",
|
||||
"fingerprint": "e22a753b9f59331898a3a27c43072b588c33639937b1f618f6c70ce04f072a9f",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_3190_ctor_qualified_name": "#3190: qualified constructors (`new pkg.Foo()`, `new pkg.Box<T>()`) now copy @reference.call.constructor.qualified onto @reference.qualified-name so constructor fallback treats the written qualifier as precise. DIGEST DRIFT ONLY: the tag is added to existing F35 matches. Prior 2bf47cc19b595a9889ac21ec0154c6ce6786271d68551f21d1bc14c626bcd4ff -> e22a753b9f59331898a3a27c43072b588c33639937b1f618f6c70ce04f072a9f; capture_groups_fp 3586, fixture_count 209.",
|
||||
"_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: synthesized Java anonymous classes and bodied enum constants now carry the presence-only @declaration.is-synthetic sidecar used to preserve source-written dispatch targets at the fanout cap. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE: the tag is attached to existing synthetic declaration matches; capture groups and fixture count remain 5755/18405, 3512, and 206. Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a; CI scaling 0.971 < 1.5.",
|
||||
"_rebaselined_2917_record_component_accessors": "#2917: every implicit Java record-component accessor now emits a component-bounded @scope.function plus @declaration.method/name/zero-arity/return-type metadata. The scope boundary prevents subsequent record-body references from being attributed to the accessor. Java was the only general language fingerprint to move; capture groups scale by exactly two per generated record component (small 5755 -> 6255, large 18405 -> 20005). Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 901a66c7dc0f071eeef9e4864b2519e5b58a1a141a1f9a7817ea42f7ff70eafb; scaling 0.961 < 1.5. Re-measured after merging origin/main, which carries #2935's is-synthetic sidecar on top of the same corpus: 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a -> 79dafc369eaeb7183ee8cc1149b1a6c21ad672c7e5b806fe8b0060e5a952c79a; scaling 1.085 < 1.5, capture groups 6255/20005, capture_groups_fp 3560, fixture_count 206 (unchanged by the merge).",
|
||||
"_rebaselined_2900_record_heritage": "#2900 review follow-up: the Java scale unit now includes a record implementing Marker, so the record-declaration @reference.inherits path is fingerprinted and exercised at scale. Prior b29e263524f55151dcb7cfc4c929d3d1d7bb360355cee4e832158f927857f663 -> 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5; scaling 1.042 < 1.5.",
|
||||
|
|
@ -171,7 +174,8 @@
|
|||
"capture_groups_fp": 680
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff",
|
||||
"fingerprint": "60e75bbe846f7200005f12c4d95c32e82d9d3feae9f2fd594e484670762b82b0",
|
||||
"_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE \u2014 the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = \u2026` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd\u2026 byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b\u2026, 43 fixtures), but it is a WEAK control here \u2014 `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.",
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.",
|
||||
|
|
@ -197,7 +201,8 @@
|
|||
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Drift: `await svc.verify<GuestPayload>(token, ...)` (typescript-generic-calls/src/guest.ts, member call) and `initializer()(() => {...})` (typescript-hof-callbacks/src/store.ts, call-of-call) lose `direct-callee-name`. `await verifyToken<AdminPayload>(token, ...)` (admin.ts/auth.ts) KEEPS `direct-callee-name|verifyToken`: tree-sitter-typescript parses `await f<T>(x)` as call_expression(function: await_expression(f), type_arguments, ...), and wrappedExpression now unwraps `await_expression` so the direct designator survives as it does for the un-awaited spelling. capture_groups_fp 2465 (unchanged). Prior 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633 -> fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff."
|
||||
},
|
||||
"javascript": {
|
||||
"fingerprint": "2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3",
|
||||
"fingerprint": "b916c7072b30d09b4949803810604830ea1aca1cfdda0d9d9312f7cb22f7a10a",
|
||||
"_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior 917a9cd975ba035bdad71fdb70cd72eeddec58c25797e5a1addfa6172808a55c -> b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3; scaling 1.093 < 1.5.",
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@
|
|||
*
|
||||
* Only files heavy enough to matter are listed; everything else is carried by
|
||||
* {@link PER_FILE_OVERHEAD_SEC} alone. These are load-balancing hints, NOT
|
||||
* assertions — no
|
||||
* test asserts a runtime, and drift only makes the split slightly less even, so
|
||||
* a stale entry is harmless and refreshing them is optional. Deliberately not
|
||||
* assertions — no test measures elapsed time against this table. Missing or
|
||||
* stale heavy entries can still overload a shard; refresh them from failed
|
||||
* CI logs and replay that profile in the partition tests. Deliberately not
|
||||
* auto-generated: a committed table is reviewable and works offline, and the
|
||||
* alternative (timing files at CI runtime to decide the split) would make the
|
||||
* partition depend on the very machine load it is trying to protect against.
|
||||
|
|
@ -49,13 +49,15 @@ export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
|
|||
'test/integration/cli-e2e.test.ts': 621,
|
||||
'test/integration/worker-pool.test.ts': 222,
|
||||
'test/unit/incremental-vector-extension-ordering.test.ts': 87,
|
||||
// ESTIMATE, not a measurement (#2841): this suite drives more full
|
||||
// `runFullAnalysis` cycles than the VECTOR sibling above, so the 8 s
|
||||
// PER_FILE_OVERHEAD floor would badly under-charge it and skew the Windows
|
||||
// split — the failure mode that produced the job timeouts this table exists
|
||||
// to prevent. Scaled from the sibling's measured 87 s by analyze-run count.
|
||||
// Replace with a real figure after the first green Windows matrix run.
|
||||
'test/unit/incremental-index-extension-dml-gate.test.ts': 180,
|
||||
// Measured on Windows in run 34014266125 (#3190, 2026-09-06). These DB
|
||||
// suites landed together on shard 3: the old 180s estimate and missing
|
||||
// entries made a ~27-minute recorded load look like an ~12-minute shard.
|
||||
// Upstream speedups may reduce these figures; retaining conservative weights
|
||||
// keeps the expensive suites distributed without changing the watchdog.
|
||||
'test/unit/incremental-index-extension-dml-gate.test.ts': 414,
|
||||
'test/integration/skills-e2e.test.ts': 444,
|
||||
'test/integration/fts-extension-e2e.test.ts': 146,
|
||||
'test/integration/analyze-wal-checkpoint-failure.test.ts': 86,
|
||||
'test/integration/cli-limit-e2e.test.ts': 75,
|
||||
'test/unit/hooks.test.ts': 26,
|
||||
'test/integration/analyze-heap-oom-e2e.test.ts': 23,
|
||||
|
|
|
|||
38
gitnexus/src/core/graph/edge-reasons.ts
Normal file
38
gitnexus/src/core/graph/edge-reasons.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Edge `reason` values that mark a CALLS edge as a HEURISTIC GUESS rather than
|
||||
* a resolution.
|
||||
*
|
||||
* The distinction exists because an edge's `confidence` number cannot carry it.
|
||||
* `GLOBAL_NAME_FALLBACK_REASON` edges are emitted at exactly 0.5 — the same
|
||||
* number as `process-processor`'s `MIN_TRACE_CONFIDENCE` and
|
||||
* `community-processor`'s `MIN_CONFIDENCE_LARGE` — so a `confidence < 0.5`
|
||||
* gate does NOT exclude them. Anything that must exclude guesses has to read
|
||||
* the reason, which is why `KnowledgeGraph.forEachRelationshipFields` passes it
|
||||
* and why `GraphEmitSink` retains a reason column.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The target was chosen because its SIMPLE NAME is unique in the workspace —
|
||||
* not because any import, scope chain, or type binding led to it.
|
||||
*
|
||||
* Emitted by the unique-simple-name tiers of the free-call fallback
|
||||
* (`pickUniqueGlobalCallable` and constructor-form `pickUniqueGlobalClass`),
|
||||
* and only for the languages that opt into `allowGlobalFreeCallFallback`. It
|
||||
* is a name collision away from being wrong and must never be presented as
|
||||
* an import-resolved edge: a reader who cannot tell the two apart has no way
|
||||
* to discount the guess.
|
||||
*/
|
||||
export const GLOBAL_NAME_FALLBACK_REASON = 'global-name-fallback';
|
||||
|
||||
/**
|
||||
* Reasons excluded from process tracing and large-graph community detection.
|
||||
*
|
||||
* Both walks exist to describe how the program actually flows. Seeding a flow
|
||||
* from a unique-name guess produces a confident-looking trace through code that
|
||||
* may never call each other, which is worse than a shorter honest trace.
|
||||
*/
|
||||
const HEURISTIC_EDGE_REASONS: ReadonlySet<string> = new Set([GLOBAL_NAME_FALLBACK_REASON]);
|
||||
|
||||
/** True when this edge's target was guessed by name rather than resolved. */
|
||||
export const isHeuristicEdgeReason = (reason: string): boolean =>
|
||||
HEURISTIC_EDGE_REASONS.has(reason);
|
||||
|
|
@ -163,9 +163,17 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
|
|||
relationshipMap.forEach(fn);
|
||||
},
|
||||
forEachRelationshipFields(
|
||||
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
|
||||
fn: (
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
type: RelationshipType,
|
||||
confidence: number,
|
||||
reason: string,
|
||||
) => void,
|
||||
) {
|
||||
relationshipMap.forEach((rel) => fn(rel.sourceId, rel.targetId, rel.type, rel.confidence));
|
||||
relationshipMap.forEach((rel) =>
|
||||
fn(rel.sourceId, rel.targetId, rel.type, rel.confidence, rel.reason),
|
||||
);
|
||||
},
|
||||
getNode: (id: string) => nodeMap.get(id),
|
||||
|
||||
|
|
|
|||
|
|
@ -31,14 +31,26 @@ export interface KnowledgeGraph {
|
|||
* Zero-allocation relationship scan: fields, not objects (#2680).
|
||||
*
|
||||
* The whole-graph scans (the local-symbol pruner, community detection,
|
||||
* process extraction) read only these four fields, and materializing a
|
||||
* process extraction) read only these five fields, and materializing a
|
||||
* `GraphRelationship` per edge just to read them dominates iteration cost once
|
||||
* relationships are held columnar — measured at ~90 ms per analyze on a
|
||||
* million-edge graph. Prefer this over `forEachRelationship` in any pass that
|
||||
* walks every edge and needs no other field.
|
||||
*
|
||||
* `reason` is passed because confidence alone cannot separate a heuristic
|
||||
* name guess from a resolved edge that happens to sit at the same number:
|
||||
* the global-name fallback emits at exactly the process/community threshold
|
||||
* (0.5), so the walks that must exclude it have to read the reason. See
|
||||
* `GraphEmitSink`'s reason column, added for this consumer.
|
||||
*/
|
||||
forEachRelationshipFields: (
|
||||
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
|
||||
fn: (
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
type: RelationshipType,
|
||||
confidence: number,
|
||||
reason: string,
|
||||
) => void,
|
||||
) => void;
|
||||
getNode: (id: string) => GraphNode | undefined;
|
||||
nodeCount: number;
|
||||
|
|
|
|||
|
|
@ -200,8 +200,11 @@ function collectReferenceSites(parsedFiles: readonly ParsedFile[]) {
|
|||
*/
|
||||
function withDefaultHooks(partial: Partial<FinalizeHooks>): FinalizeHooks {
|
||||
return {
|
||||
importsBindAtLexicalScope: partial.importsBindAtLexicalScope === true,
|
||||
resolveImportTarget: partial.resolveImportTarget ?? (() => null),
|
||||
isNamespaceImport: partial.isNamespaceImport,
|
||||
wildcardCollisionIsAmbiguous: partial.wildcardCollisionIsAmbiguous === true,
|
||||
namedImportsBindTopLevelOnly: partial.namedImportsBindTopLevelOnly === true,
|
||||
expandsWildcardTo: partial.expandsWildcardTo ?? (() => []),
|
||||
mergeBindings:
|
||||
partial.mergeBindings ??
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Dart's veto on the global-name fallback — see
|
||||
* `ScopeResolver.isGlobalNameFallbackPlausible`.
|
||||
*
|
||||
* Dart's privacy is LIBRARY-scoped and marked in the identifier itself: a name
|
||||
* beginning with `_` is visible only inside its own library and cannot be
|
||||
* imported by any spelling. So a `_`-prefixed candidate in another file is an
|
||||
* impossible call, not an unlikely one.
|
||||
*
|
||||
* The exact boundary is `part` / `part of` — one library spanning several
|
||||
* files, with `_` names shared between them — and the extractor does not
|
||||
* surface `part` directives yet (the Dart query captures only `library_import`;
|
||||
* nothing in `languages/dart/` reads `part`). Without them a cross-file `_`
|
||||
* candidate is UNDECIDABLE, not impossible: refusing on "different file" would
|
||||
* delete real edges on Flutter's dominant generated-code idiom (`factory
|
||||
* Foo.fromJson(j) => _$FooFromJson(j)` calls into `foo.g.dart`, a `part` beside
|
||||
* it), and refusing on "different directory" is wrong too — a `part` URI is a
|
||||
* relative URI and legally traverses directories (`part '../shared/gen.dart';`).
|
||||
* An earlier version refused the cross-directory case as "no `part` layout can
|
||||
* make this legal"; that claim was false, so the hook now REFUSES NOTHING and
|
||||
* every cross-file `_` candidate stays a LABELED edge (0.5 /
|
||||
* `global-name-fallback`), which is the honest answer until `part` is
|
||||
* extracted. A caller that names the candidate's file in a directive is
|
||||
* recognized already, for the day the extractor surfaces `part` as an import
|
||||
* target; at that point "not the same library" becomes decidable and the
|
||||
* refusal can return.
|
||||
*
|
||||
* Public names are left to the labeled-edge path. Dart does require an import
|
||||
* for a cross-library public name, but the fallback exists partly to recover
|
||||
* edges where the import chain was not reconstructed, and refusing every
|
||||
* cross-file public call would delete real edges to buy a rule the `_` marker
|
||||
* already gives for free.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
import {
|
||||
modulePathReaches,
|
||||
stripExtension,
|
||||
} from '../../scope-resolution/utils/name-fallback-visibility.js';
|
||||
|
||||
/** Dart privacy marker: a leading underscore on the declared identifier. Read
|
||||
* from the last `qualifiedName` segment, so `_Foo.bar` is public `bar` on a
|
||||
* private class and `Foo._bar` is the private member. */
|
||||
function isPrivateDartName(candidate: SymbolDefinition): boolean {
|
||||
const qualified = candidate.qualifiedName ?? '';
|
||||
const dot = qualified.lastIndexOf('.');
|
||||
const simple = dot === -1 ? qualified : qualified.slice(dot + 1);
|
||||
return simple.startsWith('_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Are these two files parts of one library? True when the caller names the
|
||||
* candidate's file in a `part` / `part of` directive, which the extractor
|
||||
* surfaces as an ordinary import target.
|
||||
*/
|
||||
function sharesLibrary(callerParsed: ParsedFile, candidateFilePath: string): boolean {
|
||||
const candidateModule = stripExtension(candidateFilePath);
|
||||
for (const imp of callerParsed.parsedImports) {
|
||||
if (modulePathReaches(stripExtension(imp.targetRaw), candidateModule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function dartIsGlobalNameFallbackPlausible(ctx: {
|
||||
readonly callerParsed: ParsedFile;
|
||||
readonly candidate: SymbolDefinition;
|
||||
}): boolean {
|
||||
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
|
||||
if (!isPrivateDartName(ctx.candidate)) return true;
|
||||
// A directive naming the candidate's file is positive evidence of one library.
|
||||
if (sharesLibrary(ctx.callerParsed, ctx.candidate.filePath)) return true;
|
||||
// Any other file may be a `part` of the caller's library — a sibling or, via
|
||||
// a relative `part` URI, a file in another directory. Undecidable without
|
||||
// `part` extraction (see the header), so allowed as a labeled guess, never
|
||||
// refused.
|
||||
return true;
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ import { decodeMarker } from '../../utils/heritage-marker.js';
|
|||
import { typeApplicationArguments } from '../../utils/template-arguments.js';
|
||||
import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js';
|
||||
import { expandDartWildcardNames } from './expand-wildcards.js';
|
||||
import { dartIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
|
||||
|
||||
interface ClassDefRef {
|
||||
readonly graphId: string;
|
||||
|
|
@ -233,5 +234,6 @@ export const dartScopeResolver: ScopeResolver = {
|
|||
// No `new`: bare `Foo()` resolves to the type; with cross-file imports the
|
||||
// callee is reachable workspace-wide.
|
||||
allowGlobalFreeCallFallback: true,
|
||||
isGlobalNameFallbackPlausible: dartIsGlobalNameFallbackPlausible,
|
||||
constructorCallTargetsClass: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -508,11 +508,24 @@ function normalizeGenericConstructorCapture(
|
|||
if (referenceNode !== undefined && referenceNode.type === 'generic_type') {
|
||||
const base = referenceNode.childForFieldName('type');
|
||||
if (base !== null) {
|
||||
grouped['@reference.name'] = syntheticCapture(
|
||||
'@reference.name',
|
||||
base,
|
||||
extractSimpleTypeNameText(base),
|
||||
);
|
||||
const simple = extractSimpleTypeNameText(base);
|
||||
// Keep the written `pkg.Box[T]` spelling so constructor resolution
|
||||
// can tell a package-qualified type from a bare unique-name guess.
|
||||
// `Box[T]` is also `text !== simple` (`Box[T]` vs `Box`) — that is
|
||||
// not a qualifier. Only a `qualified_type` base (`pkg.Box`) is.
|
||||
if (
|
||||
grouped['@reference.qualified-name'] === undefined &&
|
||||
base.type === 'qualified_type' &&
|
||||
referenceNode.text.length > 0 &&
|
||||
referenceNode.text !== simple
|
||||
) {
|
||||
grouped['@reference.qualified-name'] = syntheticCapture(
|
||||
'@reference.qualified-name',
|
||||
referenceNode,
|
||||
referenceNode.text,
|
||||
);
|
||||
}
|
||||
grouped['@reference.name'] = syntheticCapture('@reference.name', base, simple);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* Go's veto on the global-name fallback — see
|
||||
* `ScopeResolver.isGlobalNameFallbackPlausible`.
|
||||
*
|
||||
* Go's visibility rules are unusually decidable from a file path and an
|
||||
* identifier, which is why this is the language the guard is most complete for:
|
||||
*
|
||||
* 1. A package IS a directory. Two files in the same directory see each
|
||||
* other's identifiers with no import and no qualification, so a same-
|
||||
* directory candidate is always plausible.
|
||||
* 2. An identifier is exported iff it begins with an upper-case letter. An
|
||||
* UNEXPORTED identifier is invisible outside its own package — no import
|
||||
* makes it reachable, so a cross-directory candidate with a lower-case
|
||||
* initial is not unlikely, it is IMPOSSIBLE.
|
||||
* 3. A bare exported identifier from another package requires a DOT import.
|
||||
* Ordinary, alias, and blank imports never introduce a bare callable.
|
||||
*
|
||||
* Rule 2 is the one that matters most in practice: `uniqueHelperXyz` defined
|
||||
* once in package `a` used to acquire a caller in package `b` purely because
|
||||
* the name was unique in the repo, and the resulting edge was published as
|
||||
* `import-resolved` — a caller Go itself would reject.
|
||||
*
|
||||
* Methods require a receiver and never qualify for this bare-name tier.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
import {
|
||||
modulePathReaches,
|
||||
directoryOf,
|
||||
} from '../../scope-resolution/utils/name-fallback-visibility.js';
|
||||
import { inferGoPackageName } from './package-clause.js';
|
||||
|
||||
/**
|
||||
* Compare actual package clauses, not a guessed `_test` suffix convention:
|
||||
* `foo_test` can itself be a production package name. Missing source remains
|
||||
* undecidable, while the test-file boundary is always available from the path.
|
||||
*/
|
||||
function classifyGoFile(
|
||||
filePath: string,
|
||||
sourceTextOf: ((p: string) => string | undefined) | undefined,
|
||||
): { isTest: boolean; declared: string | undefined } {
|
||||
const isTest = filePath.endsWith('_test.go');
|
||||
const text = sourceTextOf?.(filePath);
|
||||
return {
|
||||
isTest,
|
||||
declared: text === undefined ? undefined : (inferGoPackageName(text) ?? undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/** Go export rule: an upper-case initial, by Unicode letter case. */
|
||||
function isExportedGoName(name: string): boolean {
|
||||
return /^\p{Lu}/u.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The simple identifier a Go declaration contributes to its package scope: the
|
||||
* last segment of `qualifiedName`, which is `Type.method` for a method and the
|
||||
* bare identifier for a function. Either way the LAST segment is the identifier
|
||||
* whose case decides export.
|
||||
*/
|
||||
function goSimpleName(candidate: SymbolDefinition): string {
|
||||
const qualified = candidate.qualifiedName ?? '';
|
||||
const dot = qualified.lastIndexOf('.');
|
||||
return dot === -1 ? qualified : qualified.slice(dot + 1);
|
||||
}
|
||||
|
||||
export function goIsGlobalNameFallbackPlausible(ctx: {
|
||||
readonly sourceTextOf?: (filePath: string) => string | undefined;
|
||||
readonly callerParsed: ParsedFile;
|
||||
readonly candidate: SymbolDefinition;
|
||||
readonly site?: { readonly rawQualifiedName?: string };
|
||||
}): boolean {
|
||||
// A PATH-QUALIFIED call (`pkg.Export()`) reached this tier because the
|
||||
// qualifier could not be followed. Qualification removes the bare-name
|
||||
// "only a dot import introduces this identifier" rule — not export or
|
||||
// test-package visibility. `other.private()` is still impossible.
|
||||
const isPackageQualified = ctx.site?.rawQualifiedName !== undefined;
|
||||
// Methods require a receiver, even within their own package or a dot import.
|
||||
if (ctx.candidate.type === 'Method' || ctx.candidate.qualifiedName?.includes('.')) return false;
|
||||
const callerDir = directoryOf(ctx.callerParsed.filePath);
|
||||
const candidateDir = directoryOf(ctx.candidate.filePath);
|
||||
// Same directory is NOT the same package (rule 1, refined): a `_test.go`
|
||||
// file may declare `foo_test`, and test-only declarations are invisible to
|
||||
// non-test files. Apply the split `package-siblings.ts` uses for the
|
||||
// confident tier, so the heuristic tier cannot reopen what it closed.
|
||||
if (callerDir === candidateDir) {
|
||||
const caller = classifyGoFile(ctx.callerParsed.filePath, ctx.sourceTextOf);
|
||||
const cand = classifyGoFile(ctx.candidate.filePath, ctx.sourceTextOf);
|
||||
// Non-test files never see test-only declarations.
|
||||
if (cand.isTest && !caller.isTest) return false;
|
||||
// Different clauses require an explicit dot import, even in one directory.
|
||||
// Fall through to the exported/import checks for external test packages.
|
||||
// Missing source leaves package identity undecidable.
|
||||
if (
|
||||
caller.declared === undefined ||
|
||||
cand.declared === undefined ||
|
||||
caller.declared === cand.declared
|
||||
)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Different package — and a `_test.go` file's
|
||||
// declarations are compiled only into ITS OWN package's test binary. No other
|
||||
// package, test or not, can see them, exported or not. Decidable from the
|
||||
// path alone, so it comes before every exception below (the module-root
|
||||
// exception in particular used to accept a root `helper_test.go` export).
|
||||
if (classifyGoFile(ctx.candidate.filePath, ctx.sourceTextOf).isTest) return false;
|
||||
|
||||
const simpleName = goSimpleName(ctx.candidate);
|
||||
// No identifier to read the case of — an unanswered question, not a refusal.
|
||||
if (simpleName === '') return true;
|
||||
// Unexported across a package boundary (rule 2): no import can reach it.
|
||||
if (!isExportedGoName(simpleName)) return false;
|
||||
if (isPackageQualified) return true;
|
||||
|
||||
// Only a dot import introduces a bare name. A candidate in the module ROOT package has an empty
|
||||
// directory, which `modulePathReaches` cannot align against any import path
|
||||
// (the root package is imported by the module path alone, which the repo-
|
||||
// relative layout does not carry). That is an unanswered question, not a
|
||||
// refusal — a dot import is plausible even if its path cannot be aligned.
|
||||
return ctx.callerParsed.parsedImports.some(
|
||||
(imp) =>
|
||||
imp.kind === 'wildcard' &&
|
||||
(candidateDir === '' || modulePathReaches(imp.targetRaw, candidateDir)),
|
||||
);
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import { detectGoInterfaceImplementations } from './interface-impls.js';
|
|||
import { populateGoRangeBindings } from './range-binding.js';
|
||||
import { expandGoWildcardNames } from './expand-wildcards.js';
|
||||
import { goMapValueType } from './interpret.js';
|
||||
import { goIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
|
||||
|
||||
/** Slice `[]T` and array `[N]T` / `[...]T` → the element spelling. Hoisted —
|
||||
* a literal inside the hook would mint a fresh RegExp per folded subscript. */
|
||||
|
|
@ -85,6 +86,7 @@ export const goScopeResolver: ScopeResolver = {
|
|||
hoistTypeBindingsToModule: true,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
allowGlobalFreeCallFallback: true,
|
||||
isGlobalNameFallbackPlausible: goIsGlobalNameFallbackPlausible,
|
||||
|
||||
populateNamespaceSiblings: populateGoPackageSiblings,
|
||||
mirrorNamespaceTypeBindings: mirrorGoNamespaceTypeBindings,
|
||||
|
|
|
|||
|
|
@ -357,6 +357,26 @@ export function emitJavaScopeCaptures(
|
|||
}
|
||||
}
|
||||
|
||||
// Qualified constructor `new pkg.Foo()` already binds the simple tail as
|
||||
// `@reference.name` (F35). Copy the scoped spelling onto
|
||||
// `@reference.qualified-name` so constructor resolution can treat it as a
|
||||
// written qualifier instead of a bare unique-name guess (C# does the same).
|
||||
const ctorQualifiedNode = nodeMap['@reference.call.constructor.qualified'];
|
||||
const ctorQualifiedText = grouped['@reference.call.constructor.qualified']?.text;
|
||||
if (
|
||||
ctorQualifiedNode !== undefined &&
|
||||
ctorQualifiedText !== undefined &&
|
||||
ctorQualifiedText.length > 0 &&
|
||||
ctorQualifiedText !== grouped['@reference.name']?.text &&
|
||||
grouped['@reference.qualified-name'] === undefined
|
||||
) {
|
||||
grouped['@reference.qualified-name'] = syntheticCapture(
|
||||
'@reference.qualified-name',
|
||||
ctorQualifiedNode,
|
||||
ctorQualifiedText,
|
||||
);
|
||||
}
|
||||
|
||||
// Synthesize `@reference.arity` on every callsite.
|
||||
const callTag = (
|
||||
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
syntheticCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { collectEsmExportEvidence, esmExportVerdict } from '../../ts-js-export-marker.js';
|
||||
import { splitImportStatement } from '../typescript/import-decomposer.js';
|
||||
import { getJsParser, getJsScopeQuery, jsCachedTreeMatchesGrammar } from './query.js';
|
||||
import { computeTsArityMetadata } from '../typescript/arity-metadata.js';
|
||||
|
|
@ -983,6 +984,8 @@ export function emitJsScopeCaptures(
|
|||
}
|
||||
|
||||
const rawMatches = getJsScopeQuery(filePath).matches(tree.rootNode);
|
||||
// Export evidence, read once per file (see `ts-js-export-marker.ts`).
|
||||
const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath);
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (const m of rawMatches) {
|
||||
|
|
@ -1124,6 +1127,13 @@ export function emitJsScopeCaptures(
|
|||
fnNode,
|
||||
deriveDefaultExportHocName(filePath),
|
||||
);
|
||||
// This declaration's name is synthetic, so the later query-name
|
||||
// marker cannot see it. The HOC predicate already proves the export.
|
||||
grouped['@declaration.is-exported'] = syntheticCapture(
|
||||
'@declaration.is-exported',
|
||||
fnNode,
|
||||
'true',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1207,6 +1217,20 @@ export function emitJsScopeCaptures(
|
|||
// non-call match, an absent receiver, or a chain with no nameable base
|
||||
// all leave `grouped` untouched.
|
||||
synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']);
|
||||
// `@declaration.is-exported`: a verdict for every declaration the file's
|
||||
// export surface can decide (see `ts-js-export-marker.ts`); nothing where
|
||||
// it cannot, because absence is the honest answer there.
|
||||
const declNameNode = groupedNodes['@declaration.name'];
|
||||
if (exportEvidence !== undefined && declNameNode !== undefined) {
|
||||
const verdict = esmExportVerdict(declNameNode, exportEvidence);
|
||||
if (verdict !== undefined) {
|
||||
grouped['@declaration.is-exported'] = syntheticCapture(
|
||||
'@declaration.is-exported',
|
||||
declNameNode,
|
||||
verdict ? 'true' : 'false',
|
||||
);
|
||||
}
|
||||
}
|
||||
out.push(grouped);
|
||||
|
||||
// Synthesize `this` receiver type-bindings on class member functions.
|
||||
|
|
|
|||
|
|
@ -97,6 +97,12 @@ const javascriptScopeResolver: ScopeResolver = {
|
|||
// explicit imports at the call site. Workspace-wide unique-name fallback
|
||||
// recovers these edges.
|
||||
allowGlobalFreeCallFallback: true,
|
||||
|
||||
// Same ECMAScript `export *` exclusivity as TypeScript: a name declared by
|
||||
// two wildcard sources is refused, not guessed.
|
||||
exclusiveWildcardReexports: true,
|
||||
// Same ECMAScript rule as TypeScript: named imports bind module-level declarations only.
|
||||
namedImportsBindTopLevelOnly: true,
|
||||
};
|
||||
|
||||
export { javascriptScopeResolver };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,196 @@
|
|||
/**
|
||||
* Ruby's veto on the global-name fallback — see
|
||||
* `ScopeResolver.isGlobalNameFallbackPlausible`.
|
||||
*
|
||||
* Ruby keeps the labeled fallback deliberately: with autoload (Rails' zeitwerk,
|
||||
* `ActiveSupport::Dependencies`) a file genuinely can call a method whose
|
||||
* defining file it never requires, so "no require" is NOT evidence of
|
||||
* impossibility the way it is in Go or Rust.
|
||||
*
|
||||
* What IS decidable is namespacing — but only for CLASS bodies. A method
|
||||
* defined inside a `class` is not callable as a bare `helper()` from another
|
||||
* file unless that file names the class somehow (a receiver `Ns.helper`, a
|
||||
* subclass declaration, an `include`, a `require`). A method defined inside a
|
||||
* `module` body is different in kind: modules exist to be mixed in, and Rails
|
||||
* mixes them in for you — every `*Helper` module is included into views and
|
||||
* controllers by the framework, and concerns arrive through `included do`
|
||||
* hooks — so a bare `format_money()` in a view legitimately reaches
|
||||
* `module ApplicationHelper` with no `include`, `require`, or constant
|
||||
* anywhere in the caller. Refusing that would delete real edges on exactly the
|
||||
* codebase shape Ruby's fallback exists to serve. So module-owned methods stay
|
||||
* a LABELED guess, and the refusal targets CLASS-owned methods whose class the
|
||||
* caller never names. A top-level method (`ownerId === undefined`) is left
|
||||
* alone, since that is the shape autoload actually delivers. When the owner
|
||||
* cannot be found or typed (no `parsedFileOf`, owner outside the file set),
|
||||
* the question is unanswered and the edge is allowed.
|
||||
*
|
||||
* "Never names" is read from the caller's own text-visible signals — its
|
||||
* `require`/`include`/`extend` targets, and any reference site spelling the
|
||||
* namespace's constant. If any of them mentions the namespace, the call is
|
||||
* plausible and the labeled edge stands. `require` paths are snake_case
|
||||
* (`billing/invoice_service`) while constants are CamelCase
|
||||
* (`Billing::InvoiceService`), so the comparison normalizes both sides —
|
||||
* without that the `require` branch never matched anything.
|
||||
*
|
||||
* One more thing a caller file can do without naming the class: INHERIT its
|
||||
* way to it. `class UsersController < AdminController` reaches every method
|
||||
* `ApplicationController` defines while naming only `AdminController`, and an
|
||||
* `include Concern` reaches whatever that concern includes in turn. Neither
|
||||
* chain is decidable from one file, so a caller with ANY inheritance or mixin
|
||||
* surface (an `inherits` site, an `include`/`extend`/`prepend` marker) is
|
||||
* treated as plausible. So is a caller file that DEFINES a module: a module's
|
||||
* methods run against whatever class includes the module, and call that class's
|
||||
* methods bare — `module PostGuardian; def can_see?; is_staff? ...` reaches
|
||||
* `class Guardian#is_staff?` because Guardian includes PostGuardian, a fact the
|
||||
* module file never states. What remains refused is the shape Ruby itself
|
||||
* rejects: a bare call to a class's method from a file that inherits nothing,
|
||||
* mixes in nothing, defines no module, and never spells the class. Measured on
|
||||
* discourse@3f71fa15c that is ~1000 refusals, and the sample is what the rule
|
||||
* promises: RSpec `before`/`after`/`subject` guessed to serializer methods of
|
||||
* the same name, `Gemfile`'s `gem` to `Plugin::Instance#gem`, `routes.rb`'s
|
||||
* `get` to `Draft#get` — fabricated callers, every one.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { moduleSegments } from '../../scope-resolution/utils/name-fallback-visibility.js';
|
||||
import { HERITAGE_MARKER_PREFIX } from '../../utils/heritage-marker.js';
|
||||
|
||||
/**
|
||||
* The namespace segments a candidate's qualified name declares, minus the
|
||||
* method itself. `Billing::Invoice#total` / `Billing.Invoice.total` → the
|
||||
* `Billing`, `Invoice` constants a caller would have to name.
|
||||
*/
|
||||
function namespaceConstantsOf(candidate: SymbolDefinition): readonly string[] {
|
||||
const qualified = candidate.qualifiedName;
|
||||
if (qualified === undefined || qualified === '') return [];
|
||||
const segments = qualified
|
||||
.split(/::|\.|#/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '');
|
||||
// Drop the trailing method name; what remains is the namespace chain.
|
||||
const namespace = segments.slice(0, -1);
|
||||
// Only CONSTANTS name a Ruby namespace (upper-case initial). A lower-case
|
||||
// segment is a receiver expression, not a namespace a caller can mention.
|
||||
return namespace.filter((s) => /^[A-Z]/.test(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* `billing/invoice_service` vs `InvoiceService`: a path segment names a
|
||||
* constant when, with underscores removed, the two are equal case-insensitively.
|
||||
* Zeitwerk's own inflection rule, minus acronym overrides — over-matching here
|
||||
* only loses a refusal (the edge stays, labeled), which is the safe direction.
|
||||
*/
|
||||
function pathSegmentNamesConstant(segment: string, constant: string): boolean {
|
||||
return segment.replace(/_/g, '').toLowerCase() === constant.toLowerCase();
|
||||
}
|
||||
|
||||
function requireReachesConstant(targetRaw: string, constant: string): boolean {
|
||||
for (const segment of moduleSegments(targetRaw)) {
|
||||
if (pathSegmentNamesConstant(segment, constant)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The declared kind of the candidate's owner, read from the candidate's own
|
||||
* file. Ruby labels `class` bodies `Class` and `module` bodies `Trait`
|
||||
* (`query.ts`: "module (labeled Trait for class-like registry lookup)").
|
||||
* `undefined` when the owner cannot be found — an unanswered question.
|
||||
*/
|
||||
function ownerLabelOf(
|
||||
candidate: SymbolDefinition,
|
||||
parsedFileOf: ((filePath: string) => ParsedFile | undefined) | undefined,
|
||||
): string | undefined {
|
||||
const ownerId = candidate.ownerId;
|
||||
if (ownerId === undefined || parsedFileOf === undefined) return undefined;
|
||||
const owner = parsedFileOf(candidate.filePath)?.localDefs.find((d) => d.nodeId === ownerId);
|
||||
return owner?.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls that rebind `self` for the duration of a block: inside
|
||||
* `service.instance_eval do … end` a bare `helper()` is dispatched on
|
||||
* `service`, so it legitimately reaches a class-owned method the caller file
|
||||
* never names. Detected on the caller's SOURCE TEXT, not the call site — the
|
||||
* site does not know which block encloses it — so any file that uses one of
|
||||
* these forms keeps its class-owned guesses LABELED rather than refused. Coarse
|
||||
* in the safe direction: it loses refusals in that file, never an edge.
|
||||
*/
|
||||
const SELF_REBINDING_CALL =
|
||||
/\b(?:instance_eval|instance_exec|class_eval|class_exec|module_eval|module_exec)\b/;
|
||||
|
||||
export function rubyIsGlobalNameFallbackPlausible(ctx: {
|
||||
readonly callerParsed: ParsedFile;
|
||||
readonly candidate: SymbolDefinition;
|
||||
readonly parsedFileOf?: (filePath: string) => ParsedFile | undefined;
|
||||
readonly sourceTextOf?: (filePath: string) => string | undefined;
|
||||
}): boolean {
|
||||
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
|
||||
// Top-level method — the autoload shape the fallback exists for.
|
||||
if (ctx.candidate.ownerId === undefined) return true;
|
||||
// A self-rebinding block anywhere in the caller makes "the class is never
|
||||
// named here" no proof of impossibility (see `SELF_REBINDING_CALL`). The
|
||||
// pipeline always supplies the source; a missing text is an unanswered
|
||||
// question and keeps the labeled edge as well.
|
||||
const text = ctx.sourceTextOf?.(ctx.callerParsed.filePath);
|
||||
if (text === undefined || SELF_REBINDING_CALL.test(text)) return true;
|
||||
// Only a CLASS body makes a bare cross-file call impossible without naming
|
||||
// it (see the header). A module owner, or an owner we cannot type, is not a
|
||||
// refusal.
|
||||
if (ownerLabelOf(ctx.candidate, ctx.parsedFileOf) !== 'Class') return true;
|
||||
|
||||
const constants = namespaceConstantsOf(ctx.candidate);
|
||||
// Owned but with no nameable namespace (an anonymous or lower-cased owner):
|
||||
// nothing to check, so do not refuse on an unanswered question.
|
||||
if (constants.length === 0) return true;
|
||||
|
||||
// Any inheritance or mixin surface in the caller can reach the class
|
||||
// transitively (see the header) — not decidable here, so not refused.
|
||||
for (const site of ctx.callerParsed.referenceSites) {
|
||||
if (site.kind === 'inherits') return true;
|
||||
}
|
||||
for (const imp of ctx.callerParsed.parsedImports) {
|
||||
if (imp.targetRaw.startsWith(HERITAGE_MARKER_PREFIX)) return true;
|
||||
}
|
||||
// A file that defines a module is a mixin whose methods run inside some
|
||||
// including class (see the header). Ruby labels `module` bodies `Trait`.
|
||||
for (const def of ctx.callerParsed.localDefs) {
|
||||
if (def.type === 'Trait') return true;
|
||||
// A file that REOPENS `class Invoice` names the owner even without an
|
||||
// inherits site or a reference capture — the declaration itself is the
|
||||
// mention.
|
||||
if (def.type === 'Class') {
|
||||
const declared = (def.qualifiedName ?? '')
|
||||
.split(/::|\.|#/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '')
|
||||
.at(-1);
|
||||
if (declared !== undefined && constants.includes(declared)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const imp of ctx.callerParsed.parsedImports) {
|
||||
for (const constant of constants) {
|
||||
if (requireReachesConstant(imp.targetRaw, constant)) return true;
|
||||
// `include Billing::Invoice` arrives as an import whose LOCAL name is the
|
||||
// constant rather than a path. Not every variant carries one.
|
||||
if ('localName' in imp && imp.localName === constant) return true;
|
||||
}
|
||||
}
|
||||
// A bare mention of the constant anywhere in the caller (`Billing::Invoice`,
|
||||
// `Invoice.new`) is enough to make the namespace present in this file. The
|
||||
// qualified spelling is matched SEGMENT-wise on `::` / `.`: `InvoiceService`
|
||||
// is not a mention of `Invoice`, and a substring test made it one.
|
||||
for (const site of ctx.callerParsed.referenceSites) {
|
||||
for (const constant of constants) {
|
||||
if (site.name === constant) return true;
|
||||
if (
|
||||
site.rawQualifiedName !== undefined &&
|
||||
site.rawQualifiedName.split(/::|\./).some((segment) => segment === constant)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-l
|
|||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import { decodeMarker } from '../../utils/heritage-marker.js';
|
||||
import { rubyIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
|
||||
|
||||
/**
|
||||
* #1991: resolve a BARE mixin reference (`include Loggable`) to a nested module by
|
||||
|
|
@ -287,4 +288,5 @@ export const rubyScopeResolver: ScopeResolver = {
|
|||
fieldFallbackOnMethodLookup: true,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
allowGlobalFreeCallFallback: true,
|
||||
isGlobalNameFallbackPlausible: rubyIsGlobalNameFallbackPlausible,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* Rust's veto on the global-name fallback — see
|
||||
* `ScopeResolver.isGlobalNameFallbackPlausible`.
|
||||
*
|
||||
* Rust has NO ambient namespace. A bare `helper()` resolves only against names
|
||||
* in scope, and for an item declared in another module the only way in is a
|
||||
* `use` path (or a fully-qualified `crate::a::b::helper()` call, which is not a
|
||||
* bare free call and never reaches this tier — it carries a qualified name and
|
||||
* is resolved earlier by `resolveQualifiedFreeCall`).
|
||||
*
|
||||
* This hook checks import evidence, not Rust item visibility. A child module
|
||||
* can use private ancestor items, and visibility restrictions such as
|
||||
* `pub(crate)` require more context than `SymbolDefinition` carries. A matching
|
||||
* import therefore keeps a labeled guess rather than proving accessibility.
|
||||
*
|
||||
* Module paths are matched against the candidate's FILE path (extension
|
||||
* stripped, and `mod`/`lib`/`main` stem dropped, since `a/b/mod.rs` IS module
|
||||
* `a::b`). `crate::` names the root; `self::` and `super::` resolve relative
|
||||
* to the caller's module before comparison.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import {
|
||||
modulePathReaches,
|
||||
stripExtension,
|
||||
} from '../../scope-resolution/utils/name-fallback-visibility.js';
|
||||
|
||||
/** File-stems that name their PARENT directory as the module, not themselves. */
|
||||
const RUST_DIRECTORY_MODULE_STEMS: ReadonlySet<string> = new Set(['mod', 'lib', 'main']);
|
||||
|
||||
/** Directories that hold a crate's root and contribute no module segment, so
|
||||
* `src/net/http.rs` is module `net::http` and not `src::net::http`. */
|
||||
const RUST_CRATE_ROOT_DIRS: ReadonlySet<string> = new Set(['src', 'tests', 'benches', 'examples']);
|
||||
|
||||
/** Path prefixes of a `use` that name a root rather than a module segment. */
|
||||
const RUST_USE_ROOT_PREFIXES: ReadonlySet<string> = new Set(['crate', '$crate']);
|
||||
|
||||
// One scope lookup per immutable parsed-file snapshot, not per fallback site.
|
||||
// Weak keys release both the snapshot and its index at the end of ingestion.
|
||||
const scopeLookupByFile = new WeakMap<ParsedFile, ReadonlyMap<ScopeId, Scope>>();
|
||||
|
||||
/**
|
||||
* The module path a Rust file provides, as a `/`-joined path.
|
||||
*
|
||||
* Two normalizations, both needed for a file path and a `use` path to line up
|
||||
* on their trailing segments: the `mod`/`lib`/`main` stem names its parent
|
||||
* directory, and a leading crate-root directory (`src/`) is not a module.
|
||||
*/
|
||||
function rustModulePathOf(filePath: string): string {
|
||||
const withoutExtension = stripExtension(filePath);
|
||||
const segments = withoutExtension.split('/').filter((s) => s !== '');
|
||||
const stem = segments[segments.length - 1];
|
||||
if (stem !== undefined && RUST_DIRECTORY_MODULE_STEMS.has(stem)) segments.pop();
|
||||
// Only the crate-root folder is non-semantic. A nested `src/` is module
|
||||
// `src` (`src/src/helper.rs` → `crate::src::helper`), not another root.
|
||||
if (segments.length > 0 && RUST_CRATE_ROOT_DIRS.has(segments[0]!)) segments.shift();
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory that owns the crate-root folder (`src`/`tests`/…). Empty when the
|
||||
* file sits at the analyzed root (`src/lib.rs`) so a single-crate tree is not
|
||||
* refused on a missing workspace prefix.
|
||||
*/
|
||||
function rustCrateRootOf(filePath: string): string {
|
||||
const segments = stripExtension(filePath)
|
||||
.split('/')
|
||||
.filter((s) => s !== '');
|
||||
const stem = segments[segments.length - 1];
|
||||
if (stem !== undefined && RUST_DIRECTORY_MODULE_STEMS.has(stem)) segments.pop();
|
||||
const rootIdx = segments.findIndex((s) => RUST_CRATE_ROOT_DIRS.has(s));
|
||||
if (rootIdx <= 0) return '';
|
||||
return segments.slice(0, rootIdx).join('/');
|
||||
}
|
||||
|
||||
/** Resolve explicit relative prefixes against the caller's module path. */
|
||||
function rustUsePathOf(targetRaw: string, callerFilePath: string): string {
|
||||
const segments = targetRaw.split('::').filter((s) => s !== '');
|
||||
if (segments[0] === 'self' || segments[0] === 'super') {
|
||||
const base = rustModulePathOf(callerFilePath).split('/').filter(Boolean);
|
||||
if (segments[0] === 'self') segments.shift();
|
||||
while (segments[0] === 'super') {
|
||||
base.pop();
|
||||
segments.shift();
|
||||
}
|
||||
return [...base, ...segments].join('::');
|
||||
}
|
||||
while (segments.length > 0 && RUST_USE_ROOT_PREFIXES.has(segments[0]!)) segments.shift();
|
||||
return segments.join('::');
|
||||
}
|
||||
|
||||
export function rustIsGlobalNameFallbackPlausible(ctx: {
|
||||
readonly callerParsed: ParsedFile;
|
||||
readonly candidate: SymbolDefinition;
|
||||
readonly site: {
|
||||
readonly name: string;
|
||||
readonly rawQualifiedName?: string;
|
||||
readonly inScope?: ScopeId;
|
||||
};
|
||||
}): boolean {
|
||||
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
|
||||
// A PATH-QUALIFIED call (`User::new(...)`, `crate::a::helper()`) reaches this
|
||||
// tier when the qualifier could not be followed, carrying only its tail name.
|
||||
// It is not a bare-name guess: the source named the path, so the module rule
|
||||
// below would refuse an edge the code spells out.
|
||||
if (ctx.site.rawQualifiedName !== undefined) return true;
|
||||
|
||||
const candidateModule = rustModulePathOf(ctx.candidate.filePath);
|
||||
// A candidate whose file maps to no module path (a crate root reduced to '')
|
||||
// is not something this rule can speak about; allow the labeled edge rather
|
||||
// than refuse on an unanswered question.
|
||||
if (candidateModule === '') return true;
|
||||
|
||||
const candidateName = rustSimpleNameOf(ctx.candidate);
|
||||
// Imports are lexical evidence, not a file-wide allowlist. Legacy/synthetic
|
||||
// imports without a scope receipt retain the previous conservative behavior.
|
||||
let visibleScopes: Set<ScopeId> | undefined;
|
||||
if (ctx.site.inScope !== undefined) {
|
||||
let scopes = scopeLookupByFile.get(ctx.callerParsed);
|
||||
if (scopes === undefined) {
|
||||
scopes = new Map(ctx.callerParsed.scopes.map((scope) => [scope.id, scope]));
|
||||
scopeLookupByFile.set(ctx.callerParsed, scopes);
|
||||
}
|
||||
visibleScopes = new Set();
|
||||
let current: ScopeId | null = ctx.site.inScope;
|
||||
while (current !== null && !visibleScopes.has(current)) {
|
||||
visibleScopes.add(current);
|
||||
const scope = scopes.get(current);
|
||||
if (scope === undefined || scope.kind === 'Namespace') break;
|
||||
current = scope.parent;
|
||||
}
|
||||
}
|
||||
for (const imp of ctx.callerParsed.parsedImports) {
|
||||
if (
|
||||
imp.declaredAtScope !== undefined &&
|
||||
visibleScopes !== undefined &&
|
||||
!visibleScopes.has(imp.declaredAtScope)
|
||||
)
|
||||
continue;
|
||||
// `crate::` is the caller's crate. A same trailing module in another
|
||||
// workspace crate is a different item and cannot authorize the guess.
|
||||
if (imp.targetRaw === 'crate' || imp.targetRaw.startsWith('crate::')) {
|
||||
const callerRoot = rustCrateRootOf(ctx.callerParsed.filePath);
|
||||
const candidateRoot = rustCrateRootOf(ctx.candidate.filePath);
|
||||
if (callerRoot !== '' && candidateRoot !== '' && callerRoot !== candidateRoot) continue;
|
||||
}
|
||||
const usePath = rustUsePathOf(imp.targetRaw, ctx.callerParsed.filePath);
|
||||
// Only a glob introduces every bare item of a module. A named import must
|
||||
// match both the candidate's original name and the call's local spelling.
|
||||
if (imp.kind === 'wildcard') {
|
||||
if (modulePathReaches(usePath, candidateModule)) return true;
|
||||
continue;
|
||||
}
|
||||
if (!('localName' in imp) || imp.localName !== ctx.site.name) continue;
|
||||
// Otherwise the path names ONE item inside a module (`use crate::a::other`).
|
||||
// Its PARENT is the candidate's module only if that item IS the candidate:
|
||||
// importing `other` says nothing about a `helper` in `a`, and the bare
|
||||
// parent-path match used to accept every item of `a` on its strength.
|
||||
// An alias authorizes only the local spelling checked above.
|
||||
if (importedNameOf(imp) !== candidateName) continue;
|
||||
if (modulePathReaches(usePath, candidateModule)) return true;
|
||||
const parent = usePath.slice(0, Math.max(0, usePath.lastIndexOf('::')));
|
||||
if (parent !== '' && modulePathReaches(parent, candidateModule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The identifier a `use` binds, as written at its source (`importedName`). */
|
||||
function importedNameOf(imp: ParsedFile['parsedImports'][number]): string | undefined {
|
||||
return 'importedName' in imp ? imp.importedName : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The identifier a Rust declaration contributes to its module: the FIRST
|
||||
* segment of `qualifiedName` after any module prefix — `User` for `User.new`
|
||||
* (an associated function is reached through its type, so it is the type the
|
||||
* `use` must name), the bare name for a free function.
|
||||
*/
|
||||
function rustSimpleNameOf(candidate: SymbolDefinition): string {
|
||||
const qualified = candidate.qualifiedName ?? '';
|
||||
const segments = qualified.split(/::|\./).filter((s) => s !== '');
|
||||
return segments[0] ?? '';
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-l
|
|||
import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import { rustIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
|
||||
|
||||
/**
|
||||
* Emit Rust `S IMPLEMENTS T` edges from `impl T for S` trait implementations.
|
||||
|
|
@ -192,4 +193,6 @@ export const rustScopeResolver: ScopeResolver = {
|
|||
hoistTypeBindingsToModule: true,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
allowGlobalFreeCallFallback: true,
|
||||
importsBindAtLexicalScope: true,
|
||||
isGlobalNameFallbackPlausible: rustIsGlobalNameFallbackPlausible,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Swift's veto on the global-name fallback — see
|
||||
* `ScopeResolver.isGlobalNameFallbackPlausible`.
|
||||
*
|
||||
* Swift's default access level is `internal`: visible throughout the MODULE and
|
||||
* nowhere else. Swift needs no per-file import inside a module, which is why
|
||||
* the global fallback is enabled for it at all — but that whole-module
|
||||
* visibility stops hard at the module boundary. A candidate in a DIFFERENT
|
||||
* module is reachable only if the caller wrote `import <ThatModule>`, and even
|
||||
* then only if the declaration is `public`.
|
||||
*
|
||||
* A module is approximated by its source directory, the layout every Swift
|
||||
* package configuration discovers: `Sources/<Target>/…` (also under `Package/`).
|
||||
* `src/<Target>/…` is also recognized by the package configuration loader.
|
||||
* Files outside these layouts have unknown module identity.
|
||||
*
|
||||
* The `private` / `fileprivate` half of the rule is NOT implemented, because
|
||||
* neither marker is recoverable from the parse model this hook sees —
|
||||
* `SymbolDefinition` carries no access level and `ParsedFile` no modifiers.
|
||||
* Those candidates keep the labeled low-confidence edge, which is the
|
||||
* "cannot decide, so do not refuse" direction the hook contract asks for.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { modulePathReaches } from '../../scope-resolution/utils/name-fallback-visibility.js';
|
||||
|
||||
/** Keep aligned with loadSwiftPackageConfig: other layouts are unconfigured. */
|
||||
const SWIFT_TARGET_ROOTS: ReadonlySet<string> = new Set(['Sources', 'src']);
|
||||
|
||||
/**
|
||||
* The module (target) a Swift file belongs to.
|
||||
*
|
||||
* `Sources/Core/User.swift` → `Core`. Arbitrary Xcode folders are not module
|
||||
* boundaries; without a recognized target layout leave the answer unknown.
|
||||
*/
|
||||
function swiftModuleOf(filePath: string): string {
|
||||
const segments = filePath.split('/').filter((s) => s !== '');
|
||||
for (let i = 0; i < segments.length - 2; i++) {
|
||||
if (SWIFT_TARGET_ROOTS.has(segments[i]!)) {
|
||||
// Keep the path through the target so `Sources/Core` and
|
||||
// `Package/Sources/Core` stay distinct modules. `import Core` still
|
||||
// reaches either via trailing-segment containment.
|
||||
return segments.slice(0, i + 2).join('/');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function swiftIsGlobalNameFallbackPlausible(ctx: {
|
||||
readonly callerParsed: ParsedFile;
|
||||
readonly candidate: SymbolDefinition;
|
||||
}): boolean {
|
||||
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
|
||||
|
||||
const callerModule = swiftModuleOf(ctx.callerParsed.filePath);
|
||||
const candidateModule = swiftModuleOf(ctx.candidate.filePath);
|
||||
// Same module: whole-module `internal` visibility, no import needed.
|
||||
if (callerModule === candidateModule) return true;
|
||||
// A file the layout heuristic cannot place is not something this rule can
|
||||
// speak about — allow rather than refuse on an unanswered question.
|
||||
if (callerModule === '' || candidateModule === '') return true;
|
||||
|
||||
for (const imp of ctx.callerParsed.parsedImports) {
|
||||
if (modulePathReaches(imp.targetRaw, candidateModule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -66,6 +66,7 @@ import {
|
|||
mirrorSwiftSiblingTypeBindings,
|
||||
type SwiftResolveContext,
|
||||
} from './index.js';
|
||||
import { swiftIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
|
||||
|
||||
const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const;
|
||||
|
||||
|
|
@ -136,6 +137,7 @@ const swiftScopeResolver: ScopeResolver = {
|
|||
// global free-call fallback (as Python/Go/Ruby/COBOL do for the same
|
||||
// no-`new` constructor + cross-file free-call shape).
|
||||
allowGlobalFreeCallFallback: true,
|
||||
isGlobalNameFallbackPlausible: swiftIsGlobalNameFallbackPlausible,
|
||||
|
||||
// Swift's call graph models `Type(...)` as a reference to the type
|
||||
// itself, not its `init` — both the legacy DAG and this test suite link
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
syntheticCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { collectEsmExportEvidence, esmExportVerdict } from '../../ts-js-export-marker.js';
|
||||
import { splitImportStatement } from './import-decomposer.js';
|
||||
import { getTsParser, getTsScopeQuery, tsCachedTreeMatchesGrammar } from './query.js';
|
||||
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
|
||||
|
|
@ -388,6 +389,8 @@ export function emitTsScopeCaptures(
|
|||
}
|
||||
|
||||
const rawMatches = getTsScopeQuery(filePath).matches(tree.rootNode);
|
||||
// Export evidence, read once per file (see `ts-js-export-marker.ts`).
|
||||
const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath);
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (const m of rawMatches) {
|
||||
|
|
@ -561,6 +564,13 @@ export function emitTsScopeCaptures(
|
|||
fnNode,
|
||||
deriveDefaultExportHocName(filePath),
|
||||
);
|
||||
// This declaration's name is synthetic, so the later query-name
|
||||
// marker cannot see it. The HOC predicate already proves the export.
|
||||
grouped['@declaration.is-exported'] = syntheticCapture(
|
||||
'@declaration.is-exported',
|
||||
fnNode,
|
||||
'true',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -660,6 +670,20 @@ export function emitTsScopeCaptures(
|
|||
// instead of re-parsing the receiver's source text. Self-gating: a
|
||||
// non-call match, an absent receiver, or a chain with no nameable base
|
||||
// all leave `grouped` untouched.
|
||||
// `@declaration.is-exported`: a verdict for every declaration the file's
|
||||
// export surface can decide (see `ts-js-export-marker.ts`); nothing where
|
||||
// it cannot, because absence is the honest answer there.
|
||||
const declNameNode = groupedNodes['@declaration.name'];
|
||||
if (exportEvidence !== undefined && declNameNode !== undefined) {
|
||||
const verdict = esmExportVerdict(declNameNode, exportEvidence);
|
||||
if (verdict !== undefined) {
|
||||
grouped['@declaration.is-exported'] = syntheticCapture(
|
||||
'@declaration.is-exported',
|
||||
declNameNode,
|
||||
verdict ? 'true' : 'false',
|
||||
);
|
||||
}
|
||||
}
|
||||
synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']);
|
||||
out.push(grouped);
|
||||
|
||||
|
|
|
|||
|
|
@ -132,6 +132,15 @@ const typescriptScopeResolver: ScopeResolver = {
|
|||
fieldFallbackOnMethodLookup: false,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
|
||||
// ECMAScript: `export * from './a'; export * from './b'` with a name declared
|
||||
// in both exports NEITHER — the finalize pass refuses the binding instead of
|
||||
// taking the first-listed source (see `exclusiveWildcardReexports`).
|
||||
exclusiveWildcardReexports: true,
|
||||
// `import { x }` never reaches a class member: named imports and named
|
||||
// re-exports bind to module-level declarations only (a class method sharing
|
||||
// a name with a top-level value must not win the callable preference).
|
||||
namedImportsBindTopLevelOnly: true,
|
||||
|
||||
// TypeScript uses `.values()` / `.keys()` method-call syntax for collection
|
||||
// views -- no property-style accessors like C#'s `Dictionary<K,V>.Values` --
|
||||
// so `elementTypeOf` answers only the `index` route and lets the regular
|
||||
|
|
|
|||
|
|
@ -117,6 +117,9 @@ const vueScopeResolver: ScopeResolver = {
|
|||
// Vue uses explicit imports for all external symbols; no global free-
|
||||
// call fallback needed (would produce spurious edges for built-ins).
|
||||
allowGlobalFreeCallFallback: false,
|
||||
// Vue SFC scripts are TypeScript/JavaScript: a named import binds a module-level
|
||||
// declaration, never a class member (see the TS resolver).
|
||||
namedImportsBindTopLevelOnly: true,
|
||||
|
||||
/**
|
||||
* Expand the scope-resolution file universe for Vue by performing a
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
*/
|
||||
|
||||
import { createKnowledgeGraph } from '../graph/graph.js';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import { GLOBAL_NAME_FALLBACK_REASON } from '../graph/edge-reasons.js';
|
||||
import { GraphEmitSink, type GraphEmitManifest } from '../lbug/graph-emit-sink.js';
|
||||
import { type PipelineProgress } from 'gitnexus-shared';
|
||||
import { PipelineResult } from '../../types/pipeline.js';
|
||||
|
|
@ -410,6 +412,11 @@ export const runPipelineFromRepo = async (
|
|||
graphEmitSink?.close();
|
||||
}
|
||||
|
||||
// Resolved-call index for the name-fallback census: read through the SINK,
|
||||
// whose field-wise scan includes every streamed edge, not through `graph`,
|
||||
// which under streaming holds none of them.
|
||||
const resolvedCalleeNamesByCaller = collectResolvedCalleeNames(graphEmitSink ?? graph, graph);
|
||||
|
||||
// Extract final results for the PipelineResult contract
|
||||
const {
|
||||
totalFiles,
|
||||
|
|
@ -473,6 +480,7 @@ export const runPipelineFromRepo = async (
|
|||
communityResult,
|
||||
processResult,
|
||||
resolutionOutcomes,
|
||||
resolvedCalleeNamesByCaller,
|
||||
undecidedSatisfaction,
|
||||
usedWorkerPool,
|
||||
reparsedFileCount,
|
||||
|
|
@ -518,3 +526,29 @@ export const runPipelineFromRepo = async (
|
|||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Caller node id → the simple names of every callee it has a CALLS edge to.
|
||||
*
|
||||
* `edges` may be the streaming sink or the raw graph; `nodes` is always the raw
|
||||
* graph, which holds every node in both modes (only relationships stream). One
|
||||
* O(E) field-wise pass, allocation-free per edge except for the per-caller set.
|
||||
*/
|
||||
export function collectResolvedCalleeNames(
|
||||
edges: Pick<KnowledgeGraph, 'forEachRelationshipFields'>,
|
||||
nodes: Pick<KnowledgeGraph, 'getNode'>,
|
||||
): ReadonlyMap<string, ReadonlySet<string>> {
|
||||
const out = new Map<string, Set<string>>();
|
||||
edges.forEachRelationshipFields((sourceId, targetId, type, _confidence, reason) => {
|
||||
if (type !== 'CALLS' || reason === GLOBAL_NAME_FALLBACK_REASON) return;
|
||||
const name = nodes.getNode(targetId)?.properties.name;
|
||||
if (typeof name !== 'string' || name === '') return;
|
||||
let names = out.get(sourceId);
|
||||
if (names === undefined) {
|
||||
names = new Set<string>();
|
||||
out.set(sourceId, names);
|
||||
}
|
||||
names.add(name);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -707,6 +707,10 @@ function buildDefFromDeclarationMatch(
|
|||
const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']);
|
||||
const isDeleted = parseBooleanCapture(match['@declaration.is-deleted']);
|
||||
const isSynthetic = parseBooleanCapture(match['@declaration.is-synthetic']);
|
||||
// Tri-state on purpose: only a producer that saw the file's export surface
|
||||
// emits the marker, and both `true` and `false` are verdicts (see
|
||||
// `SymbolDefinition.isExported`). Absent stays absent.
|
||||
const isExported = parseBooleanCapture(match['@declaration.is-exported']);
|
||||
|
||||
return {
|
||||
nodeId: makeDefId(filePath, anchor.range, type, nameCap.text),
|
||||
|
|
@ -725,6 +729,7 @@ function buildDefFromDeclarationMatch(
|
|||
...(isExplicit === true ? { isExplicit: true } : {}),
|
||||
...(isDeleted === true ? { isDeleted: true } : {}),
|
||||
...(isSynthetic === true ? { isSynthetic: true } : {}),
|
||||
...(isExported !== undefined ? { isExported } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1037,8 +1042,7 @@ function pass3CollectImports(
|
|||
if (provider.interpretImport === undefined) return;
|
||||
// Hoisted: the capability is a property of the language, identical for every
|
||||
// match in the file. A provider that declares its imports do not execute
|
||||
// where they are written (C/C++ `#include`, Rust `use`, COBOL `COPY`) skips
|
||||
// the position walk entirely — position cannot defer something that never
|
||||
// where they are written skips the execution-deferral walk — position cannot defer something that never
|
||||
// runs, and marking one deferred would hide a real cycle. Absent reads as
|
||||
// `true`, so an undeclared provider is unchanged. See
|
||||
// `LanguageProvider.importsExecuteWhereWritten`.
|
||||
|
|
@ -1049,14 +1053,22 @@ function pass3CollectImports(
|
|||
const parsed = provider.interpretImport(match);
|
||||
if (parsed === null) continue;
|
||||
// The statement's own position, resolved to the innermost scope holding
|
||||
// it. An unlocatable anchor leaves the import unmarked, which reads as
|
||||
// it. Provenance is retained independently of execution timing. An
|
||||
// unlocatable anchor leaves the import unmarked, which reads as
|
||||
// "runs at initialization" — the fail-safe direction, since it can only
|
||||
// make `check --cycles` over-report.
|
||||
const inScopeId = positionCanDefer
|
||||
? positionIndex.atPosition(filePath, anchor.range.startLine, anchor.range.startCol)
|
||||
: undefined;
|
||||
const deferred = inScopeId !== undefined && runsOnlyWhenCalled(scopeTree, inScopeId);
|
||||
parsedImports.push(deferred ? { ...parsed, runsOnlyWhenCalled: true } : parsed);
|
||||
const inScopeId = positionIndex.atPosition(
|
||||
filePath,
|
||||
anchor.range.startLine,
|
||||
anchor.range.startCol,
|
||||
);
|
||||
const deferred =
|
||||
positionCanDefer && inScopeId !== undefined && runsOnlyWhenCalled(scopeTree, inScopeId);
|
||||
parsedImports.push({
|
||||
...parsed,
|
||||
...(inScopeId !== undefined ? { declaredAtScope: inScopeId } : {}),
|
||||
...(deferred ? { runsOnlyWhenCalled: true } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,13 +82,14 @@
|
|||
* `handledSites` IFF a `tryEmitEdge` call returned `true` for it.
|
||||
* Sites a pass touched but couldn't resolve do NOT get marked —
|
||||
* they still get a chance from the shared resolver. Exception:
|
||||
* the free-call fallback marks the site unconditionally after
|
||||
* attempting emission (even on dedup-collapse), because the
|
||||
* per-(caller, target) collapse semantics require multiple call
|
||||
* sites in the same caller body not produce multiple edges.
|
||||
* `preEmitInheritanceEdges` also pre-marks every `inherits` site so
|
||||
* the generic bridge cannot remap class heritage into method-owned
|
||||
* EXTENDS edges via `resolveCallerGraphId`.
|
||||
* the free-call fallback marks the site after it decides the site,
|
||||
* including when it emits no edge — dedup-collapse, a visibility
|
||||
* veto (`fallback-refused`), or a selected callable that is deleted
|
||||
* / invisible. The later generic pass must not recreate an edge
|
||||
* that pass just refused. `preEmitInheritanceEdges` also pre-marks
|
||||
* every `inherits` site so the generic bridge cannot remap class
|
||||
* heritage into method-owned EXTENDS edges via
|
||||
* `resolveCallerGraphId`.
|
||||
*
|
||||
* - **I3 — `propagateImportedReturnTypes` mutation timing + ordering.**
|
||||
* The pass mutates `Scope.typeBindings` (a plain `new Map(...)` from
|
||||
|
|
@ -248,8 +249,8 @@
|
|||
* ## Semantic-model source of truth
|
||||
*
|
||||
* `ParsedFile` (from `gitnexus-shared/src/scope-resolution/parsed-file.ts`)
|
||||
* is the single semantic model consumed by both the legacy DAG and the
|
||||
* scope-resolution pipeline. Scope-resolution passes MUST NOT build a
|
||||
* is the single semantic model consumed by the scope-resolution pipeline.
|
||||
* Scope-resolution passes MUST NOT build a
|
||||
* parallel parse representation; if a pass needs AST-level facts that
|
||||
* `ParsedFile` doesn't expose, it should reuse the orchestrator's
|
||||
* `treeCache` (see `RunScopeResolutionInput.treeCache`) rather than
|
||||
|
|
@ -257,23 +258,20 @@
|
|||
*
|
||||
* ## Same-graph guarantee
|
||||
*
|
||||
* Edges emitted by `runScopeResolution` and edges emitted by the legacy
|
||||
* DAG are indistinguishable to downstream consumers:
|
||||
* All language resolvers emit edges through `runScopeResolution` using the
|
||||
* shared graph contract:
|
||||
* - Node identity: same `generateId(...)` helper, same qualified-name
|
||||
* keyspace, same File/Folder/Method/Class node labels.
|
||||
* - Edge vocabulary: `'import-resolved' | 'global' | 'local-call' |
|
||||
* 'same-file' | 'interface-dispatch' | 'read' | 'write'` — both
|
||||
* paths emit the same reasons (see
|
||||
* `gitnexus/src/core/ingestion/call-processor.ts` for the legacy
|
||||
* emitter and `passes/receiver-bound-calls.ts` /
|
||||
* 'same-file' | 'interface-dispatch' | 'read' | 'write' |
|
||||
* 'global-name-fallback'` (see `passes/receiver-bound-calls.ts` /
|
||||
* `passes/free-call-fallback.ts` for the scope-resolution emitters).
|
||||
* - Overload disambiguation: both paths use
|
||||
* - Overload disambiguation: resolvers use
|
||||
* `generateId('Method', ...)` suffixed with `parameterTypes` when a
|
||||
* method has overloads — see `graph-bridge/ids.ts`.
|
||||
*
|
||||
* The CI parity workflow (`.github/workflows/ci-scope-parity.yml`)
|
||||
* runs both paths on every migrated language's fixture corpus and
|
||||
* fails if the graph outputs diverge.
|
||||
* Resolver fixture coverage lives in `ci-tests.yml` (the separate
|
||||
* `ci-scope-parity.yml` gate was removed in RING4-1 #942).
|
||||
*
|
||||
* Plan that introduced most of these invariants:
|
||||
* `docs/plans/2026-04-20-001-refactor-emit-pipeline-generalization-plan.md`.
|
||||
|
|
@ -865,6 +863,88 @@ export interface ScopeResolver {
|
|||
*/
|
||||
readonly allowGlobalFreeCallFallback?: boolean;
|
||||
|
||||
/** Opt in to binding imports at their extracted lexical scope, rather than
|
||||
* publishing every file's imports at module scope. */
|
||||
readonly importsBindAtLexicalScope?: boolean;
|
||||
|
||||
/**
|
||||
* Two `wildcard` re-exports that both DECLARE a name make it AMBIGUOUS in
|
||||
* this language — ECMAScript `export *` semantics, where the module simply
|
||||
* does not export the name and any binding is a guess. Opt-in: for a
|
||||
* language whose wildcard import is `#include`, `require` or a package
|
||||
* fan-out, the same name in two files is an overload set or a redeclaration,
|
||||
* and refusing it would delete real edges (C++ arity-narrowed overloads
|
||||
* across two headers). Forwarded to `FinalizeHooks.wildcardCollisionIsAmbiguous`.
|
||||
*/
|
||||
readonly exclusiveWildcardReexports?: boolean;
|
||||
|
||||
/**
|
||||
* A named import or named re-export can only bind to a MODULE-LEVEL
|
||||
* declaration of the target file — ECMAScript semantics, where
|
||||
* `import { x }` never reaches a class member. Opt-in: languages that bind
|
||||
* module-level members by bare name (static members, module functions)
|
||||
* leave it off. Forwarded to `FinalizeHooks.namedImportsBindTopLevelOnly`.
|
||||
*/
|
||||
readonly namedImportsBindTopLevelOnly?: boolean;
|
||||
|
||||
/**
|
||||
* Veto for a single `allowGlobalFreeCallFallback` guess.
|
||||
*
|
||||
* The fallback picks a callable because its SIMPLE NAME is unique in the
|
||||
* workspace — it consults no import and no scope chain. For most languages a
|
||||
* large share of those guesses are not merely unlikely but IMPOSSIBLE: Go
|
||||
* cannot call an unexported identifier from another package, ESM cannot see a
|
||||
* name it did not import, Rust cannot reach an item with no `use` path. This
|
||||
* hook is where a language states those rules, so the shared pass can drop
|
||||
* the edge instead of publishing a guess that the language forbids.
|
||||
*
|
||||
* Return `false` to REFUSE (no edge, recorded as `fallback-refused`). Return
|
||||
* `true`, or leave the hook undefined, to emit the labeled
|
||||
* `global-name-fallback` edge. **Only answer `false` when the call is
|
||||
* impossible, not when it is merely unproven** — a wrongly-refused candidate
|
||||
* is a lost real edge, whereas a wrongly-allowed one is at least labeled and
|
||||
* excluded from flows.
|
||||
*
|
||||
* Deliberately NOT folded into `isCallableVisibleFromCaller`: that hook also
|
||||
* gates precise dispatch paths (implicit-this, member calls), so a rule
|
||||
* written for the name-guess tier would silently suppress resolved edges too.
|
||||
*
|
||||
* `parsedFileOf` reaches the CANDIDATE's parse result — a language whose rule
|
||||
* depends on declaration-side facts not carried by `SymbolDefinition` (for
|
||||
* example Rust's `pub` form) needs it. `isExported` is the tri-state export
|
||||
* marker when the language supplies one; this hook still needs the parse for
|
||||
* visibility facts that marker does not represent. It returns `undefined`
|
||||
* for a path outside this pass's file set; treat that as "cannot decide"
|
||||
* and allow.
|
||||
*/
|
||||
readonly isGlobalNameFallbackPlausible?: (ctx: {
|
||||
readonly callerParsed: ParsedFile;
|
||||
readonly candidate: SymbolDefinition;
|
||||
readonly parsedFileOf: (filePath: string) => ParsedFile | undefined;
|
||||
/**
|
||||
* Raw source of any parsed file, for languages whose visibility rule needs
|
||||
* a declaration the parse does not carry (Go's package clause: a test file's
|
||||
* `package foo_test` is a different package from its directory's `foo`).
|
||||
* Absent when the pipeline has no contents at hand; hooks must then answer
|
||||
* from paths alone and, when undecidable, allow.
|
||||
*/
|
||||
readonly sourceTextOf?: (filePath: string) => string | undefined;
|
||||
/**
|
||||
* The call site, so a language can tell a BARE name from a PATH-QUALIFIED
|
||||
* one. Both reach this tier — a qualified call whose qualifier the resolver
|
||||
* could not follow falls through to the bare-name search with only its tail
|
||||
* name — but they are not the same claim. Rust's `User::new(...)` named its
|
||||
* type in source, so refusing it for lacking a `use` of the module would
|
||||
* delete an edge the source spells out.
|
||||
*/
|
||||
readonly site: {
|
||||
readonly name: string;
|
||||
readonly rawQualifiedName?: string;
|
||||
/** Lexical call-site scope; absent only for legacy/synthetic hook callers. */
|
||||
readonly inScope?: ScopeId;
|
||||
};
|
||||
}) => boolean;
|
||||
|
||||
/**
|
||||
* In this language every `Method` belongs to a class instance, so a
|
||||
* FREE (receiver-less) call may resolve to a `Method` only when the
|
||||
|
|
|
|||
|
|
@ -505,9 +505,25 @@ function resolveDefGraphIdUncached(
|
|||
if (qualifiedHit !== undefined) return qualifiedHit;
|
||||
}
|
||||
const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1);
|
||||
// FAIL CLOSED before the label-agnostic simple key when a FUNCTION-LOCAL
|
||||
// callable of this name exists in the file. The guards above cover a def
|
||||
// that is itself a callable; a NON-callable def (`export const selected =
|
||||
// factory()`, a `Variable` with no graph node of its own) used to fall
|
||||
// through here and alias onto `wrapper.selected`, the function-local one
|
||||
// (#3182 review). Whatever the def's label, a bare name matching a local
|
||||
// callable is the aliasing this key cannot tell apart, and a missing edge
|
||||
// is the correct failure direction.
|
||||
for (const localLabel of LOCAL_CALLABLE_LABELS) {
|
||||
if (nodeLookup.get(localNameKey(filePath, localLabel, simpleName)) !== undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return nodeLookup.get(simpleKey(filePath, simpleName));
|
||||
}
|
||||
|
||||
/** Labels the structure phase registers function-local declarations under. */
|
||||
const LOCAL_CALLABLE_LABELS: readonly NodeLabel[] = ['Function', 'Method'];
|
||||
|
||||
/** Derive the simple (unqualified) name of a def from its `qualifiedName`. */
|
||||
export function simpleQualifiedName(def: SymbolDefinition): string | undefined {
|
||||
const q = def.qualifiedName;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* Per-language census of global-name fallback decisions: how many call sites
|
||||
* used a unique-name guess, and how many candidates visibility rules refused.
|
||||
* Sites are counted before edge coalescing: a precisely bound site can prove
|
||||
* the same caller/target dependency, so this is not a count of heuristic edges.
|
||||
*
|
||||
* Both halves are needed and neither is meaningful alone. A guess count with no
|
||||
* refusal count cannot distinguish a language with genuinely few impossible
|
||||
* candidates from one whose hook is missing; a refusal count with no guess count
|
||||
* cannot distinguish a working guard from one that rejects everything. The pair
|
||||
* is what makes the guard auditable on a real repository, which is the whole
|
||||
* point of recording it — before this, guessed edges were emitted with the same
|
||||
* reason and confidence as import-resolved ones and the number was unknowable.
|
||||
*
|
||||
* Structural sibling of `unresolved-receivers.ts`'s summary, and persisted the
|
||||
* same way (`RepoMeta.nameFallbackEdges`).
|
||||
*/
|
||||
|
||||
import { getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { logger } from '../../logger.js';
|
||||
import type { ResolutionOutcome } from './resolution-outcome.js';
|
||||
|
||||
/**
|
||||
* Distinct caller-file/callee-name pairs per language, from the caller→callee-name index the pipeline
|
||||
* builds while its streaming sink is live (so it is complete under `--force`),
|
||||
* bucketed by the CALLER's file language. Gives `NameFallbackSummary.byLanguage`
|
||||
* its denominator: a guess count is only readable as a share of the calls.
|
||||
*/
|
||||
export function countCallsByLanguage(
|
||||
index: ReadonlyMap<string, ReadonlySet<string>> | undefined,
|
||||
nodes: { getNode(id: string): { properties: Record<string, unknown> } | undefined } | undefined,
|
||||
): Readonly<Record<string, number>> | undefined {
|
||||
if (index === undefined || nodes === undefined) return undefined;
|
||||
const counts = new Map<string, number>();
|
||||
const namesByFile = new Map<string, Set<string>>();
|
||||
for (const [callerId, callees] of index) {
|
||||
const filePath = nodes.getNode(callerId)?.properties?.filePath;
|
||||
if (typeof filePath !== 'string') continue;
|
||||
let names = namesByFile.get(filePath);
|
||||
if (names === undefined) {
|
||||
names = new Set<string>();
|
||||
namesByFile.set(filePath, names);
|
||||
}
|
||||
for (const name of callees) names.add(name);
|
||||
}
|
||||
for (const [filePath, names] of namesByFile) {
|
||||
const language = getLanguageFromFilename(filePath) ?? 'unknown';
|
||||
counts.set(language, (counts.get(language) ?? 0) + names.size);
|
||||
}
|
||||
if (counts.size === 0) return undefined;
|
||||
const out: Record<string, number> = {};
|
||||
for (const [language, count] of [...counts.entries()].sort(([a], [b]) =>
|
||||
a < b ? -1 : a > b ? 1 : 0,
|
||||
)) {
|
||||
out[language] = count;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Unattributed bucket for a pass that recorded no language. */
|
||||
const UNKNOWN_LANGUAGE = 'unknown';
|
||||
|
||||
export interface NameFallbackLanguageCounts {
|
||||
/** Call sites resolved by a unique-name guess, before edge coalescing. */
|
||||
readonly guessed: number;
|
||||
/**
|
||||
* Distinct (caller file, callee name) pairs among those sites — the unit
|
||||
* `callsByLanguage` counts in, so `guessedPairs / callsByLanguage[lang]` is a
|
||||
* ratio bounded by 1. Absent on a summary persisted before this field existed.
|
||||
*/
|
||||
readonly guessedPairs?: number;
|
||||
/** Candidates this language's `isGlobalNameFallbackPlausible` hook refused. */
|
||||
readonly refused: number;
|
||||
}
|
||||
|
||||
export interface NameFallbackSummary {
|
||||
/** Language → guessed/refused counts. Languages with neither are absent. */
|
||||
readonly byLanguage: Readonly<Record<string, NameFallbackLanguageCounts>>;
|
||||
/** Guessed call sites, repo-wide. */
|
||||
readonly totalGuessed: number;
|
||||
/** Distinct (caller file, callee name) pairs among the guessed sites. */
|
||||
readonly distinctGuessedPairs?: number;
|
||||
readonly totalRefused: number;
|
||||
/**
|
||||
* Barrel names refused because two `export *` sources both declared them
|
||||
* (`reexport-ambiguous`). Not a guess and not per-language — a name the
|
||||
* finalize pass declined to bind at all — but it belongs in the same census:
|
||||
* it is the other place the resolver used to publish an arbitrary winner as
|
||||
* `import-resolved`.
|
||||
*/
|
||||
readonly totalAmbiguousReexports: number;
|
||||
/**
|
||||
* The refused barrel names themselves (`file:name`), sorted, capped at
|
||||
* `MAX_AMBIGUOUS_NAMES`. A count alone cannot say whether a refusal landed on
|
||||
* a name anyone calls; the list can be joined against the ledger's `byName`.
|
||||
*/
|
||||
readonly ambiguousReexportNames?: readonly string[];
|
||||
/**
|
||||
* Distinct caller-file/callee-name pairs per language (through any path), so `guessedPairs` can be
|
||||
* read as a SHARE of a language's call graph rather than a bare count. Absent
|
||||
* when the caller did not supply the totals.
|
||||
*/
|
||||
readonly callsByLanguage?: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
/** Bound on the persisted ambiguous-name list; the total beside it stays exact. */
|
||||
export const MAX_AMBIGUOUS_NAMES = 200;
|
||||
|
||||
/**
|
||||
* Build the summary, or `undefined` when the run neither guessed nor refused —
|
||||
* a repository with no opt-in language stores no key at all, so the artifact
|
||||
* stays absent rather than recording a row of zeroes.
|
||||
*/
|
||||
export function summarizeNameFallback(
|
||||
outcomes: readonly ResolutionOutcome[],
|
||||
callsByLanguage?: Readonly<Record<string, number>>,
|
||||
): NameFallbackSummary | undefined {
|
||||
const guessed = new Map<string, number>();
|
||||
const guessedPairsByLanguage = new Map<string, number>();
|
||||
const refused = new Map<string, number>();
|
||||
const ambiguousNames = new Set<string>();
|
||||
// Two units, both kept. `guessed` counts call SITES, not final graph edges:
|
||||
// a precise site may prove the same dependency during edge coalescing.
|
||||
// Preserve the unit earlier summaries hold. `guessedPairs` dedupes by (caller file,
|
||||
// callee name), the unit `callsByLanguage` is counted in: ten guessed `foo()`
|
||||
// calls in one file are one pair against a denominator that counts `foo`
|
||||
// once, so the guessy RATIO uses pairs and is bounded by 1. Changing the unit
|
||||
// of `guessed` itself silently read as a large improvement across engines.
|
||||
const guessedPairs = new Set<string>();
|
||||
let totalGuessed = 0;
|
||||
let totalRefused = 0;
|
||||
let totalAmbiguousReexports = 0;
|
||||
|
||||
for (const outcome of outcomes) {
|
||||
if (outcome.kind === 'fallback-guessed') {
|
||||
const language = outcome.language ?? UNKNOWN_LANGUAGE;
|
||||
guessed.set(language, (guessed.get(language) ?? 0) + 1);
|
||||
totalGuessed++;
|
||||
const pair = `${outcome.filePath}\u0000${outcome.name}`;
|
||||
if (!guessedPairs.has(pair)) {
|
||||
guessedPairs.add(pair);
|
||||
guessedPairsByLanguage.set(language, (guessedPairsByLanguage.get(language) ?? 0) + 1);
|
||||
}
|
||||
} else if (outcome.kind === 'fallback-refused') {
|
||||
const language = outcome.language ?? UNKNOWN_LANGUAGE;
|
||||
refused.set(language, (refused.get(language) ?? 0) + 1);
|
||||
totalRefused++;
|
||||
} else if (outcome.kind === 'reexport-ambiguous') {
|
||||
totalAmbiguousReexports++;
|
||||
ambiguousNames.add(`${outcome.filePath}:${outcome.name}`);
|
||||
}
|
||||
}
|
||||
if (totalGuessed === 0 && totalRefused === 0 && totalAmbiguousReexports === 0) return undefined;
|
||||
|
||||
const byLanguage: Record<string, NameFallbackLanguageCounts> = {};
|
||||
for (const language of new Set([...guessed.keys(), ...refused.keys()])) {
|
||||
byLanguage[language] = {
|
||||
guessed: guessed.get(language) ?? 0,
|
||||
guessedPairs: guessedPairsByLanguage.get(language) ?? 0,
|
||||
refused: refused.get(language) ?? 0,
|
||||
};
|
||||
}
|
||||
const sortedNames = [...ambiguousNames].sort();
|
||||
return {
|
||||
byLanguage,
|
||||
totalGuessed,
|
||||
distinctGuessedPairs: guessedPairs.size,
|
||||
totalRefused,
|
||||
totalAmbiguousReexports,
|
||||
...(sortedNames.length > 0
|
||||
? { ambiguousReexportNames: sortedNames.slice(0, MAX_AMBIGUOUS_NAMES) }
|
||||
: {}),
|
||||
...(callsByLanguage !== undefined ? { callsByLanguage } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line readout for the analyze summary. `undefined` when there is nothing to
|
||||
* report, so a run on a repository with no opt-in language prints no line.
|
||||
*/
|
||||
export function formatNameFallbackSummary(
|
||||
summary: NameFallbackSummary | undefined,
|
||||
): string | undefined {
|
||||
if (summary === undefined) return undefined;
|
||||
const perLanguage = Object.entries(summary.byLanguage)
|
||||
.sort(([, a], [, b]) => b.guessed + b.refused - (a.guessed + a.refused))
|
||||
.map(([language, counts]) => `${language} ${counts.guessed}/${counts.refused}`)
|
||||
.join(', ');
|
||||
const ambiguous =
|
||||
summary.totalAmbiguousReexports > 0
|
||||
? `; ${summary.totalAmbiguousReexports} barrel name(s) refused as ambiguous \`export *\``
|
||||
: '';
|
||||
const languages = perLanguage === '' ? 'none' : perLanguage;
|
||||
const pairs =
|
||||
summary.distinctGuessedPairs !== undefined
|
||||
? ` (${summary.distinctGuessedPairs} distinct caller-file/name pairs)`
|
||||
: '';
|
||||
const siteUnit = summary.totalGuessed === 1 ? 'call site' : 'call sites';
|
||||
return `name-fallback resolution: ${summary.totalGuessed} ${siteUnit}${pairs}, ${summary.totalRefused} refused as impossible (guessed/refused by language: ${languages})${ambiguous}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the readout as part of the analyze summary. Unconditional (not behind a
|
||||
* debug env var, unlike the receiver-drop diagnostic): a reader deciding how far
|
||||
* to trust this index's call graph needs to know how much of it is guessed, and
|
||||
* a number nobody sees is the state this work exists to end.
|
||||
*/
|
||||
export function logNameFallbackSummary(summary: NameFallbackSummary | undefined): void {
|
||||
const line = formatNameFallbackSummary(summary);
|
||||
if (line === undefined) return;
|
||||
logger.info(line);
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ import type {
|
|||
ResolutionOutcomeRecorder,
|
||||
ResolutionSuppressionReason,
|
||||
} from '../resolution-outcome.js';
|
||||
import { GLOBAL_NAME_FALLBACK_REASON } from '../../../graph/edge-reasons.js';
|
||||
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
|
||||
import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js';
|
||||
import {
|
||||
|
|
@ -43,7 +44,9 @@ import {
|
|||
findAllCallableBindingsInScope,
|
||||
findCallableBindingInScope,
|
||||
findCallableBindingsAndAdlBlocker,
|
||||
findClassBindingInScope,
|
||||
findEnclosingClassDef,
|
||||
isClassFileImportGrounded,
|
||||
resolveInheritanceBaseInScope,
|
||||
type CallableBindingCandidate,
|
||||
} from '../scope/walkers.js';
|
||||
|
|
@ -64,6 +67,15 @@ export function emitFreeCallFallback(
|
|||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
options: {
|
||||
readonly allowGlobalFallback?: boolean;
|
||||
/** Language whose pass this is, carried onto the fallback outcome records
|
||||
* so the analyze summary can report guesses/refusals PER LANGUAGE. A
|
||||
* repo-wide total hides which language's rules are the loose ones. */
|
||||
readonly language?: string;
|
||||
/** Per-language veto on a name guess — see
|
||||
* `ScopeResolver.isGlobalNameFallbackPlausible`. */
|
||||
readonly isGlobalNameFallbackPlausible?: ScopeResolver['isGlobalNameFallbackPlausible'];
|
||||
/** Raw source lookup handed to `isGlobalNameFallbackPlausible` (optional). */
|
||||
readonly sourceTextOf?: (filePath: string) => string | undefined;
|
||||
/** When true, `Type(...)` constructor calls link to the Class def
|
||||
* itself rather than its explicit Constructor. Swift opts in. */
|
||||
readonly constructorCallTargetsClass?: boolean;
|
||||
|
|
@ -138,6 +150,14 @@ export function emitFreeCallFallback(
|
|||
let allFilePathsMemo: ReadonlySet<string> | undefined;
|
||||
const allFilePaths = (): ReadonlySet<string> =>
|
||||
(allFilePathsMemo ??= new Set(parsedFiles.map((p) => p.filePath)));
|
||||
// Candidate-side parse lookup for `isGlobalNameFallbackPlausible`. Built
|
||||
// lazily and once, on the same terms as `allFilePaths` above: a language
|
||||
// without the hook never pays for the index.
|
||||
let parsedByPathMemo: ReadonlyMap<string, ParsedFile> | undefined;
|
||||
const parsedFileByPath = (): ((filePath: string) => ParsedFile | undefined) => {
|
||||
parsedByPathMemo ??= new Map(parsedFiles.map((p) => [p.filePath, p]));
|
||||
return (filePath) => parsedByPathMemo!.get(filePath);
|
||||
};
|
||||
// Per-pass memo of pickUniqueGlobalCallable's post-filter candidate list,
|
||||
// keyed (simpleName, callerFilePath). Only created when no per-caller
|
||||
// visibility filter applies (the list is then a pure function of name+file —
|
||||
|
|
@ -183,7 +203,17 @@ export function emitFreeCallFallback(
|
|||
};
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
type PendingRel = { rel: Parameters<KnowledgeGraph['addRelationship']>[0]; gatedAll: boolean };
|
||||
type PendingRel = {
|
||||
rel: Parameters<KnowledgeGraph['addRelationship']>[0];
|
||||
gatedAll: boolean;
|
||||
/**
|
||||
* The confidence/reason a PRECISELY resolved site (a real binding, not a
|
||||
* unique-name guess) proved for this edge; `undefined` while every site
|
||||
* collapsed into it so far was a guess. Decided at flush, not by the
|
||||
* first site the walk met.
|
||||
*/
|
||||
precise: { confidence: number; reason: string } | undefined;
|
||||
};
|
||||
const pending = new Map<string, PendingRel>();
|
||||
const bindingCandidatesByScope =
|
||||
options.freeCallsRequireInstanceOwnership === true
|
||||
|
|
@ -199,13 +229,48 @@ export function emitFreeCallFallback(
|
|||
// to the Class node itself (implicit constructor). Legacy emits
|
||||
// the same two targets; see test expectations.
|
||||
let fnDef: SymbolDefinition | undefined;
|
||||
// Guess flag starts here so the constructor unique-class pick can mark
|
||||
// the same class of guess as `pickUniqueGlobalCallable` below. The
|
||||
// veto/label path keys off this flag; declaring it after that pick left
|
||||
// constructor-form unique-name hits labeled `import-resolved` at 0.85.
|
||||
let fnDefFromGlobalNameFallback = false;
|
||||
// Language visibility vetoes the constructed TYPE (Class/Struct), not a
|
||||
// Constructor child — Go refuses any `qualifiedName` containing `.`.
|
||||
let globalFallbackVetoTarget: SymbolDefinition | undefined;
|
||||
if (site.callForm === 'constructor') {
|
||||
const classDef = resolveInheritanceBaseInScope(
|
||||
// Lexical / written-qualifier first. A bare unique type with no
|
||||
// import/#include evidence is a guess (JS `new UniqueWidget()`,
|
||||
// Go unexported `uniqueWidget{}`). A unique type whose file is
|
||||
// imported, whose import resolved to this def, or whose name
|
||||
// was imported is precise — C++ `#include "user.h"` and Rust
|
||||
// `use`/`pub use` often mint no lexical class binding. An
|
||||
// imported sibling file of a different name is not evidence.
|
||||
let classDef = resolveInheritanceBaseInScope(
|
||||
site.inScope,
|
||||
site.name,
|
||||
scopes,
|
||||
site.rawQualifiedName,
|
||||
undefined,
|
||||
{ uniqueQualifiedNameFallback: site.rawQualifiedName !== undefined },
|
||||
);
|
||||
if (classDef === undefined) {
|
||||
classDef =
|
||||
findClassBindingInScope(site.inScope, site.name, scopes, undefined, {
|
||||
uniqueQualifiedNameFallback: true,
|
||||
}) ??
|
||||
(options.allowGlobalFallback === true
|
||||
? pickUniqueGlobalClass(site.name, globalClassesBySimpleName)
|
||||
: undefined);
|
||||
if (
|
||||
classDef !== undefined &&
|
||||
classDef.type !== 'Interface' &&
|
||||
site.rawQualifiedName === undefined &&
|
||||
!isClassFileImportGrounded(site.inScope, classDef, scopes, site.name)
|
||||
) {
|
||||
fnDefFromGlobalNameFallback = true;
|
||||
globalFallbackVetoTarget = classDef;
|
||||
}
|
||||
}
|
||||
if (classDef !== undefined && classDef.type !== 'Interface') {
|
||||
// Most languages link `Type(...)` to the explicit Constructor def
|
||||
// when one exists (else the Class). Languages that model the call
|
||||
|
|
@ -215,21 +280,6 @@ export function emitFreeCallFallback(
|
|||
options.constructorCallTargetsClass === true
|
||||
? classDef
|
||||
: pickConstructorOrClass(classDef, workspaceIndex, scopes, site.arity);
|
||||
} else if (options.allowGlobalFallback === true) {
|
||||
// The constructed type may live in a sibling/imported file that is
|
||||
// not in the call-site's lexical scope-chain bindings. Fall back to
|
||||
// a unique workspace-wide Class def by simple name (gated on the
|
||||
// same global-fallback opt-in as free calls). Then target the
|
||||
// Class or its Constructor per the language's preference.
|
||||
const globalClass = pickUniqueGlobalClass(site.name, globalClassesBySimpleName);
|
||||
if (globalClass !== undefined) {
|
||||
fnDef =
|
||||
globalClass.type === 'Interface'
|
||||
? undefined
|
||||
: options.constructorCallTargetsClass === true
|
||||
? globalClass
|
||||
: pickConstructorOrClass(globalClass, workspaceIndex, scopes, site.arity);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Module-qualified free call (`mod::fn()`): the source named the module
|
||||
|
|
@ -543,10 +593,17 @@ export function emitFreeCallFallback(
|
|||
}
|
||||
}
|
||||
}
|
||||
// V1: pickUniqueGlobalCallable ignores import context — resolves to any
|
||||
// globally-unique callable. False cross-package edges are possible when
|
||||
// the caller does not import the target package. Same-package calls are
|
||||
// usually caught by nearest-scope lookup before reaching here.
|
||||
// Name-guess tier: pickUniqueGlobalCallable consults no import context —
|
||||
// it resolves to any globally-unique callable. Same-package calls are
|
||||
// usually caught by nearest-scope lookup before reaching here, so what
|
||||
// lands in this tier is disproportionately cross-module, and a
|
||||
// cross-module name match is a guess.
|
||||
//
|
||||
// Two things make that honest rather than a lie. The language's
|
||||
// `isGlobalNameFallbackPlausible` hook refuses candidates its own
|
||||
// visibility rules forbid (below), and every edge that survives is
|
||||
// emitted with `GLOBAL_NAME_FALLBACK_REASON` at 0.5 rather than
|
||||
// masquerading as `import-resolved` at 0.85 (see the emit site).
|
||||
if (fnDef === undefined && options.allowGlobalFallback === true) {
|
||||
fnDef = pickUniqueGlobalCallable(
|
||||
site.name,
|
||||
|
|
@ -570,6 +627,53 @@ export function emitFreeCallFallback(
|
|||
scopeDefsCache,
|
||||
options.conversionOnlyArgTypePrefixes,
|
||||
);
|
||||
fnDefFromGlobalNameFallback = fnDef !== undefined;
|
||||
if (fnDef !== undefined) globalFallbackVetoTarget = fnDef;
|
||||
}
|
||||
if (fnDefFromGlobalNameFallback && fnDef !== undefined) {
|
||||
// An explicit named import cannot bind a declaration proven private
|
||||
// to another module. In particular, a rejected named import must not
|
||||
// reappear as a name guess to a class member or nested function.
|
||||
// Unknown export evidence (e.g. dynamic module exports) keeps the
|
||||
// existing fallback behavior.
|
||||
const vetoCandidate = globalFallbackVetoTarget ?? fnDef;
|
||||
const importsPrivateDeclaration =
|
||||
vetoCandidate.filePath !== parsed.filePath &&
|
||||
vetoCandidate.isExported === false &&
|
||||
parsed.parsedImports.some(
|
||||
(imported) =>
|
||||
(imported.kind === 'named' || imported.kind === 'alias') &&
|
||||
imported.localName === site.name,
|
||||
);
|
||||
if (
|
||||
importsPrivateDeclaration ||
|
||||
options.isGlobalNameFallbackPlausible?.({
|
||||
callerParsed: parsed,
|
||||
candidate: vetoCandidate,
|
||||
parsedFileOf: parsedFileByPath(),
|
||||
sourceTextOf: options.sourceTextOf,
|
||||
site: {
|
||||
name: site.name,
|
||||
rawQualifiedName: site.rawQualifiedName,
|
||||
inScope: site.inScope,
|
||||
},
|
||||
}) === false
|
||||
) {
|
||||
// The language proved this call impossible. Mark the site handled so
|
||||
// `emit-references` does not substitute its own looser guess for the
|
||||
// edge we just refused — the point is no edge, not a different one.
|
||||
options.recordResolutionOutcome?.({
|
||||
kind: 'fallback-refused',
|
||||
candidateId: fnDef.nodeId,
|
||||
language: options.language,
|
||||
phase: 'free-call-fallback',
|
||||
filePath: parsed.filePath,
|
||||
name: site.name,
|
||||
range: site.atRange,
|
||||
});
|
||||
handledSites.add(siteKey(parsed.filePath, site));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (fnDef === undefined) continue;
|
||||
if (fnDef.isDeleted === true) {
|
||||
|
|
@ -617,40 +721,78 @@ export function emitFreeCallFallback(
|
|||
site.atRange.startCol,
|
||||
tgtGraphId,
|
||||
);
|
||||
if (fnDefFromGlobalNameFallback) {
|
||||
options.recordResolutionOutcome?.({
|
||||
kind: 'fallback-guessed',
|
||||
targetId: fnDef.nodeId,
|
||||
language: options.language,
|
||||
phase: 'free-call-fallback',
|
||||
filePath: parsed.filePath,
|
||||
name: site.name,
|
||||
range: site.atRange,
|
||||
});
|
||||
}
|
||||
const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`;
|
||||
// One edge per (caller, callee): `staticGated` is the AND over every site
|
||||
// that collapses into it, so a callee reached from one live site and one
|
||||
// dead site stays live whichever site the walk meets first. Emission is
|
||||
// deferred to the end of this file's sites for that reason.
|
||||
const preciseHere = fnDefFromGlobalNameFallback
|
||||
? undefined
|
||||
: {
|
||||
confidence: 0.85,
|
||||
// Match legacy DAG's reason convention so consumers that
|
||||
// assert `reason === 'import-resolved'` keep working. The
|
||||
// construction-site marker is opt-in for the same reason.
|
||||
reason: constructionSiteReason(
|
||||
fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
|
||||
site,
|
||||
options.markConstructionSites,
|
||||
),
|
||||
};
|
||||
const pendingRel = pending.get(relId);
|
||||
if (pendingRel !== undefined) {
|
||||
if (site.staticGated !== true) pendingRel.gatedAll = false;
|
||||
// The edge's label is decided at flush time from EVERY site that
|
||||
// collapsed into it, not from whichever the walk met first. One site
|
||||
// resolved through a real binding PROVES the dependency; a guessed
|
||||
// site for the same pair is then redundant evidence, not a taint.
|
||||
if (pendingRel.precise === undefined) pendingRel.precise = preciseHere;
|
||||
continue;
|
||||
}
|
||||
if (seen.has(relId)) continue;
|
||||
seen.add(relId);
|
||||
pending.set(relId, {
|
||||
gatedAll: site.staticGated === true,
|
||||
precise: preciseHere,
|
||||
rel: {
|
||||
id: relId,
|
||||
sourceId: callerGraphId,
|
||||
targetId: tgtGraphId,
|
||||
type: 'CALLS',
|
||||
confidence: 0.85,
|
||||
// Match legacy DAG's reason convention so consumers that
|
||||
// assert `reason === 'import-resolved'` keep working. The
|
||||
// construction-site marker is opt-in for the same reason.
|
||||
reason: constructionSiteReason(
|
||||
fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
|
||||
site,
|
||||
options.markConstructionSites,
|
||||
),
|
||||
// Guess values as placeholders; decided at flush from `precise`.
|
||||
confidence: 0.5,
|
||||
reason: GLOBAL_NAME_FALLBACK_REASON,
|
||||
},
|
||||
});
|
||||
emitted++;
|
||||
}
|
||||
for (const { rel, gatedAll } of pending.values()) {
|
||||
graph.addRelationship(gatedAll ? { ...rel, staticGated: true } : rel);
|
||||
for (const { rel, gatedAll, precise } of pending.values()) {
|
||||
// A name guess is not an import resolution and must not be spelled like
|
||||
// one. It used to be emitted at 0.85 / `'import-resolved'`, which made
|
||||
// it indistinguishable from an edge a real import produced — so every
|
||||
// consumer that wanted to discount guesses had no field to do it with.
|
||||
// 0.5 is the deliberate "coin flip" value, and the reason is what the
|
||||
// process/community walks and the MCP tools actually key on, because
|
||||
// 0.5 sits exactly ON their thresholds (see graph/edge-reasons.ts).
|
||||
// An edge is a guess only when EVERY site that collapsed into it was one;
|
||||
// a single precisely resolved site proves it, whatever order the walk
|
||||
// met the sites in. Independent of `gatedAll`.
|
||||
const labeled =
|
||||
precise !== undefined
|
||||
? { ...rel, confidence: precise.confidence, reason: precise.reason }
|
||||
: rel;
|
||||
graph.addRelationship(gatedAll ? { ...labeled, staticGated: true } : labeled);
|
||||
}
|
||||
}
|
||||
return emitted;
|
||||
|
|
|
|||
|
|
@ -120,19 +120,10 @@ export function followChainPostFinalize(
|
|||
* typeBindings — the in-extractor pass-4 ran before propagation and
|
||||
* missed any chain whose terminal lived in a foreign file.
|
||||
*
|
||||
* Scope-chain concern (verified 2026-04-21): `pythonImportOwningScope`
|
||||
* documents that function-local `from x import y` binds `y` to the
|
||||
* inner function scope, which would make a module-only write miss
|
||||
* non-module importers. In practice `finalize-algorithm` hoists those
|
||||
* bindings into `indexes.bindings[moduleScope]` regardless of where
|
||||
* the `import` statement appears — the integration fixture
|
||||
* `python-function-local-import-chain` exercises a chained
|
||||
* receiver-bound call `u = get_user(); u.save()` inside a function
|
||||
* body and emits the expected `do_work → User.save` edge. The
|
||||
* module-scope write is sufficient today. If finalize routing ever
|
||||
* changes to honor the hook's per-scope contract, this pass must
|
||||
* iterate `indexes.bindings` over every scope and mirror into the
|
||||
* binding-owning scope's `typeBindings`, not just the module's.
|
||||
* Finalize can retain lexical import ownership. Mirror into each binding's
|
||||
* owning scope, never hoist local import-derived types into the file scope.
|
||||
* Module ordering remains SCC-based; non-module chains are followed after
|
||||
* all import bindings have been mirrored.
|
||||
*/
|
||||
export function propagateImportedReturnTypes(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
|
|
@ -140,6 +131,7 @@ export function propagateImportedReturnTypes(
|
|||
index: WorkspaceResolutionIndex,
|
||||
): void {
|
||||
const moduleScopeByFile = index.moduleScopeByFile;
|
||||
const scopesByFile = new Map(parsedFiles.map((file) => [file.filePath, file.scopes]));
|
||||
|
||||
// Walk SCCs in reverse-topological order (`indexes.sccs` is leaves-
|
||||
// first per `tarjanSccs`). For each file we mirror import bindings
|
||||
|
|
@ -167,42 +159,44 @@ export function propagateImportedReturnTypes(
|
|||
// import-derived typeBinding mirror. Both helpers fast-path when
|
||||
// no augmentations exist for the scope, so the common case is
|
||||
// allocation-free. See I8.
|
||||
for (const localName of namesAtScope(importerModule.id, indexes)) {
|
||||
// Skip if importer already has a typeBinding for this name —
|
||||
// an explicit local annotation must win over import-derived.
|
||||
if (importerModule.typeBindings.has(localName)) continue;
|
||||
for (const importerScope of scopesByFile.get(filePath) ?? [importerModule]) {
|
||||
for (const localName of namesAtScope(importerScope.id, indexes)) {
|
||||
// Skip if importer already has a typeBinding for this name —
|
||||
// an explicit local annotation must win over import-derived.
|
||||
if (importerScope.typeBindings.has(localName)) continue;
|
||||
|
||||
const refs = lookupBindingsAt(importerModule.id, localName, indexes);
|
||||
for (const ref of refs) {
|
||||
if (ref.origin !== 'import' && ref.origin !== 'reexport' && ref.origin !== 'wildcard')
|
||||
continue;
|
||||
const sourceModule = moduleScopeByFile.get(ref.def.filePath);
|
||||
if (sourceModule === undefined) continue;
|
||||
const refs = lookupBindingsAt(importerScope.id, localName, indexes);
|
||||
for (const ref of refs) {
|
||||
if (ref.origin !== 'import' && ref.origin !== 'reexport' && ref.origin !== 'wildcard')
|
||||
continue;
|
||||
const sourceModule = moduleScopeByFile.get(ref.def.filePath);
|
||||
if (sourceModule === undefined) continue;
|
||||
|
||||
// The source file's typeBinding is keyed by the def's simple
|
||||
// name (e.g. `get_user`), not the importer's local alias.
|
||||
const qn = ref.def.qualifiedName;
|
||||
if (qn === undefined) continue;
|
||||
const dot = qn.lastIndexOf('.');
|
||||
const sourceName = dot === -1 ? qn : qn.slice(dot + 1);
|
||||
// The source file's typeBinding is keyed by the def's simple
|
||||
// name (e.g. `get_user`), not the importer's local alias.
|
||||
const qn = ref.def.qualifiedName;
|
||||
if (qn === undefined) continue;
|
||||
const dot = qn.lastIndexOf('.');
|
||||
const sourceName = dot === -1 ? qn : qn.slice(dot + 1);
|
||||
|
||||
const sourceTypeRef = sourceModule.typeBindings.get(sourceName);
|
||||
if (sourceTypeRef === undefined) continue;
|
||||
const sourceTypeRef = sourceModule.typeBindings.get(sourceName);
|
||||
if (sourceTypeRef === undefined) continue;
|
||||
|
||||
// Chain-follow inside the source module so we mirror the
|
||||
// terminal type, not an intermediate intra-source reference.
|
||||
const terminal = followChainPostFinalize(sourceTypeRef, sourceModule.id, indexes);
|
||||
// Chain-follow inside the source module so we mirror the
|
||||
// terminal type, not an intermediate intra-source reference.
|
||||
const terminal = followChainPostFinalize(sourceTypeRef, sourceModule.id, indexes);
|
||||
|
||||
// Mutating typeBindings is safe because draftToScope
|
||||
// produced a non-frozen Map (Contract Invariant I3/I8).
|
||||
(importerModule.typeBindings as Map<string, TypeRef>).set(localName, terminal);
|
||||
// First-write-wins for the local alias: if the same
|
||||
// `localName` was registered multiple times via
|
||||
// `mergeBindings` (rare; happens with conflicting
|
||||
// re-exports), only the first ref with a usable
|
||||
// typeBinding source is mirrored. Conflict resolution
|
||||
// among multiple sources is the merger's job, not ours.
|
||||
break;
|
||||
// Mutating typeBindings is safe because draftToScope
|
||||
// produced a non-frozen Map (Contract Invariant I3/I8).
|
||||
(importerScope.typeBindings as Map<string, TypeRef>).set(localName, terminal);
|
||||
// First-write-wins for the local alias: if the same
|
||||
// `localName` was registered multiple times via
|
||||
// `mergeBindings` (rare; happens with conflicting
|
||||
// re-exports), only the first ref with a usable
|
||||
// typeBinding source is mirrored. Conflict resolution
|
||||
// among multiple sources is the merger's job, not ours.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -726,6 +726,7 @@ export function runScopeResolution(
|
|||
const resolutionConfig = input.resolutionConfig;
|
||||
const finalized = finalizeScopeModel(parsedFiles, {
|
||||
hooks: {
|
||||
importsBindAtLexicalScope: provider.importsBindAtLexicalScope === true,
|
||||
resolveImportTarget: (targetRaw, fromFile, _workspaceIndex, parsedImport) =>
|
||||
provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig, {
|
||||
parsedFiles,
|
||||
|
|
@ -737,9 +738,23 @@ export function runScopeResolution(
|
|||
provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [],
|
||||
mergeBindings: (existing, incoming, scopeId) =>
|
||||
provider.mergeBindings(existing, incoming, scopeId),
|
||||
wildcardCollisionIsAmbiguous: provider.exclusiveWildcardReexports === true,
|
||||
namedImportsBindTopLevelOnly: provider.namedImportsBindTopLevelOnly === true,
|
||||
},
|
||||
});
|
||||
logHeapProbe('sr-post-finalize', `lang=${provider.language}`);
|
||||
// `export *` collisions the shared finalize refused to bind (WS1 C2). Recorded
|
||||
// as outcomes so the refusal is auditable next to the name-fallback census —
|
||||
// a silently unresolved importer is indistinguishable from a resolver gap.
|
||||
for (const refused of finalized.stats.ambiguousWildcardExports) {
|
||||
recordResolutionOutcome({
|
||||
kind: 'reexport-ambiguous',
|
||||
candidateIds: refused.candidateDefIds,
|
||||
phase: 'finalize',
|
||||
filePath: refused.filePath,
|
||||
name: refused.name,
|
||||
});
|
||||
}
|
||||
// One store and ONE writer rule for heritage instantiations (#2912), shared by
|
||||
// the pre-pass below and by the language hook further down — a heritage shape
|
||||
// the pre-pass cannot express (Rust `impl T for S`, Dart `implements`) records
|
||||
|
|
@ -1085,6 +1100,12 @@ export function runScopeResolution(
|
|||
workspaceIndex,
|
||||
{
|
||||
allowGlobalFallback: provider.allowGlobalFreeCallFallback === true,
|
||||
language: provider.language,
|
||||
isGlobalNameFallbackPlausible: provider.isGlobalNameFallbackPlausible,
|
||||
sourceTextOf:
|
||||
provider.isGlobalNameFallbackPlausible !== undefined
|
||||
? (filePath: string) => getFileContents().get(filePath)
|
||||
: undefined,
|
||||
constructorCallTargetsClass: provider.constructorCallTargetsClass === true,
|
||||
markConstructionSites: provider.markConstructionSites === true,
|
||||
isFileLocalDef: provider.isFileLocalDef,
|
||||
|
|
|
|||
|
|
@ -108,6 +108,60 @@ export type ResolutionOutcome =
|
|||
* every real codebase and taught readers to ignore it.
|
||||
*/
|
||||
readonly receiverOrigin?: ReceiverOrigin;
|
||||
}
|
||||
/**
|
||||
* The global-name fallback fired: a callable was chosen because its SIMPLE
|
||||
* NAME is unique in the workspace, with no import or scope chain leading to
|
||||
* it. A labeled low-confidence edge WAS emitted.
|
||||
*
|
||||
* Separate from `resolved` because it is not a resolution, and separate from
|
||||
* `suppressed` because an edge exists. Counting it is the only way a reader
|
||||
* can tell how much of a language's call graph rests on name uniqueness — the
|
||||
* number that was previously invisible because these edges were emitted with
|
||||
* the same reason and confidence as import-resolved ones.
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'fallback-guessed';
|
||||
readonly targetId: string;
|
||||
readonly language?: string;
|
||||
readonly phase: string;
|
||||
readonly filePath: string;
|
||||
readonly name: string;
|
||||
readonly range: Range;
|
||||
}
|
||||
/**
|
||||
* A global-name-fallback candidate was REFUSED by the language's plausibility
|
||||
* hook: the language's own visibility rules make that call impossible, so no
|
||||
* edge was emitted.
|
||||
*
|
||||
* The counterpart of `fallback-guessed`, and the pair is what makes the
|
||||
* refusal auditable — a refusal count with no guess count cannot distinguish
|
||||
* "the guard works" from "the guard rejects everything".
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'fallback-refused';
|
||||
readonly candidateId: string;
|
||||
readonly language?: string;
|
||||
readonly phase: string;
|
||||
readonly filePath: string;
|
||||
readonly name: string;
|
||||
readonly range: Range;
|
||||
}
|
||||
/**
|
||||
* A barrel re-exported `name` through two or more `export *` sources that
|
||||
* each declare it, so the language names no winner. The shared finalize pass
|
||||
* REFUSED the binding (see `FinalizeStats.ambiguousWildcardExports`) instead
|
||||
* of publishing the first-listed source as `import-resolved`; every importer
|
||||
* of `name` through `filePath` stays unresolved. No `range`: the collision
|
||||
* belongs to the file's export surface, not to one statement.
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'reexport-ambiguous';
|
||||
readonly candidateIds: readonly string[];
|
||||
readonly phase: string;
|
||||
/** The barrel file whose `export *` sources collide. */
|
||||
readonly filePath: string;
|
||||
readonly name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -782,6 +782,20 @@ function soleBoundBaseName(bound: string): string | undefined {
|
|||
return base.length === 0 ? undefined : base;
|
||||
}
|
||||
|
||||
export type ClassBindingLookup = {
|
||||
/**
|
||||
* When false, skip the workspace-unique QualifiedNameIndex hit (and the
|
||||
* dotted-tail variant). Default true — Go inheritance still needs that
|
||||
* hit because namespace-style imports often create no scope binding.
|
||||
*
|
||||
* Constructor-form free calls pass false so a unique type name is not
|
||||
* treated as a precise in-scope binding. Those sites belong to
|
||||
* `pickUniqueGlobalClass`, which labels the edge as a guess and runs
|
||||
* `isGlobalNameFallbackPlausible`.
|
||||
*/
|
||||
readonly uniqueQualifiedNameFallback?: boolean;
|
||||
};
|
||||
|
||||
export function findClassBindingInScope(
|
||||
startScope: ScopeId,
|
||||
receiverName: string,
|
||||
|
|
@ -798,6 +812,7 @@ export function findClassBindingInScope(
|
|||
* selection. Only receiver-chain base and step resolution passes this.
|
||||
*/
|
||||
stripDecoration?: DecorationStripper,
|
||||
lookup?: ClassBindingLookup,
|
||||
): SymbolDefinition | undefined {
|
||||
// A TYPE PARAMETER is not a class, and it is checked before every route below
|
||||
// rather than inside one of them because each route would otherwise reach a
|
||||
|
|
@ -806,7 +821,13 @@ export function findClassBindingInScope(
|
|||
// fallback after stripping. The declaration that introduced the parameter is
|
||||
// the only thing that knows, and it knows for all three.
|
||||
if (bindsTypeParameter(startScope, receiverName, scopes)) {
|
||||
return resolveThroughTypeParameterBound(startScope, receiverName, scopes, stripDecoration);
|
||||
return resolveThroughTypeParameterBound(
|
||||
startScope,
|
||||
receiverName,
|
||||
scopes,
|
||||
stripDecoration,
|
||||
lookup,
|
||||
);
|
||||
}
|
||||
|
||||
const local = walkScopeChain(startScope, receiverName, scopes, (def) => isClassLike(def.type));
|
||||
|
|
@ -815,21 +836,25 @@ export function findClassBindingInScope(
|
|||
// Fallback for languages (Go) where namespace-style imports don't
|
||||
// create scope bindings: resolve via QualifiedNameIndex. Only fires
|
||||
// when the scope-chain walk found nothing; single-match wins.
|
||||
const qnames = scopes.qualifiedNames.get(receiverName);
|
||||
if (qnames.length === 1) {
|
||||
const def = scopes.defs.get(qnames[0]!);
|
||||
if (def !== undefined && isClassLike(def.type)) return def;
|
||||
}
|
||||
// Second fallback: dotted names like "models.User" — try the simple
|
||||
// name (tail after last dot) for languages where defs are indexed by
|
||||
// simple name (Go). Only when the dotted lookup fails.
|
||||
if (receiverName.includes('.')) {
|
||||
const simple = receiverName.slice(receiverName.lastIndexOf('.') + 1);
|
||||
if (simple.length > 0 && simple !== receiverName) {
|
||||
const simpleIds = scopes.qualifiedNames.get(simple);
|
||||
if (simpleIds.length === 1) {
|
||||
const def = scopes.defs.get(simpleIds[0]!);
|
||||
if (def !== undefined && isClassLike(def.type)) return def;
|
||||
// Constructor-form free calls opt out — a unique type name is a guess,
|
||||
// not proof the type is in scope (see `ClassBindingLookup`).
|
||||
if (lookup?.uniqueQualifiedNameFallback !== false) {
|
||||
const qnames = scopes.qualifiedNames.get(receiverName);
|
||||
if (qnames.length === 1) {
|
||||
const def = scopes.defs.get(qnames[0]!);
|
||||
if (def !== undefined && isClassLike(def.type)) return def;
|
||||
}
|
||||
// Second fallback: dotted names like "models.User" — try the simple
|
||||
// name (tail after last dot) for languages where defs are indexed by
|
||||
// simple name (Go). Only when the dotted lookup fails.
|
||||
if (receiverName.includes('.')) {
|
||||
const simple = receiverName.slice(receiverName.lastIndexOf('.') + 1);
|
||||
if (simple.length > 0 && simple !== receiverName) {
|
||||
const simpleIds = scopes.qualifiedNames.get(simple);
|
||||
if (simpleIds.length === 1) {
|
||||
const def = scopes.defs.get(simpleIds[0]!);
|
||||
if (def !== undefined && isClassLike(def.type)) return def;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -875,6 +900,7 @@ function resolveThroughTypeParameterBound(
|
|||
parameterName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
stripDecoration?: DecorationStripper,
|
||||
lookup?: ClassBindingLookup,
|
||||
): SymbolDefinition | undefined {
|
||||
const bound = typeParameterAt(startScope, parameterName, scopes)?.bound;
|
||||
if (bound === undefined) return undefined;
|
||||
|
|
@ -882,7 +908,7 @@ function resolveThroughTypeParameterBound(
|
|||
if (baseName === undefined || baseName === parameterName) return undefined;
|
||||
// A bound naming another parameter terminates here rather than recursing.
|
||||
if (bindsTypeParameter(startScope, baseName, scopes)) return undefined;
|
||||
return findClassBindingInScope(startScope, baseName, scopes, stripDecoration);
|
||||
return findClassBindingInScope(startScope, baseName, scopes, stripDecoration, lookup);
|
||||
}
|
||||
|
||||
function normalizeTemplateArgToken(value: string): string {
|
||||
|
|
@ -1283,6 +1309,7 @@ export function resolveInheritanceBaseInScope(
|
|||
scopes: ScopeResolutionIndexes,
|
||||
rawQualifiedName?: string,
|
||||
enclosingClassDef?: SymbolDefinition,
|
||||
lookup?: ClassBindingLookup,
|
||||
): SymbolDefinition | undefined {
|
||||
// #1982: when the source wrote a qualified base (`Other::Inner`), resolve it
|
||||
// against the full-path QualifiedNameIndex FIRST, so a same-tail nested base
|
||||
|
|
@ -1302,7 +1329,7 @@ export function resolveInheritanceBaseInScope(
|
|||
if (qualified !== undefined) return qualified;
|
||||
}
|
||||
return (
|
||||
findClassBindingInScope(startScope, baseName, scopes) ??
|
||||
findClassBindingInScope(startScope, baseName, scopes, undefined, lookup) ??
|
||||
resolveAmbiguousInheritanceBaseViaImports(startScope, baseName, scopes)
|
||||
);
|
||||
}
|
||||
|
|
@ -1528,6 +1555,43 @@ function dirnameOf(filePath: string): string {
|
|||
return idx === -1 ? '' : filePath.slice(0, idx);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the caller can see `classDef` through import evidence, not
|
||||
* merely because the class name is workspace-unique.
|
||||
*
|
||||
* Exact imported file (`#include "user.h"`) and a finalize-resolved
|
||||
* `targetDefId` (named `use` / `pub use` followed to the def) are
|
||||
* precise. An import that *names* this class (`targetExportedName`)
|
||||
* is also precise — a barrel's `targetFile` is the re-exporting module,
|
||||
* not the defining file. A sibling-file import of a *different* name
|
||||
* is not: `use crate::a::bar` does not make `Foo` in `a/foo.rs` visible.
|
||||
*
|
||||
* Directory-only matching is intentionally omitted. The same-directory
|
||||
* tier on `resolveAmbiguousInheritanceBaseViaImports` is for picking
|
||||
* among *ambiguous* names (C# namespace `using`); it is not proof a
|
||||
* unique constructor type is in scope.
|
||||
*/
|
||||
export function isClassFileImportGrounded(
|
||||
startScope: ScopeId,
|
||||
classDef: Pick<SymbolDefinition, 'filePath' | 'nodeId'>,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
importedName: string,
|
||||
): boolean {
|
||||
const callerModule = moduleScopeIdOf(startScope, scopes);
|
||||
const callerFile = scopes.scopeTree.getScope(startScope)?.filePath;
|
||||
if (callerFile !== undefined && classDef.filePath === callerFile) return true;
|
||||
if (callerModule === null) return false;
|
||||
const importEdges = scopes.imports.get(callerModule);
|
||||
if (importEdges === undefined || importEdges.length === 0) return false;
|
||||
for (const edge of importEdges) {
|
||||
if (edge.targetDefId !== undefined && edge.targetDefId === classDef.nodeId) return true;
|
||||
if (edge.targetFile === null) continue;
|
||||
if (edge.targetFile === classDef.filePath) return true;
|
||||
if (importedName.length > 0 && edge.targetExportedName === importedName) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate for value-receiver bridge: the labels for which
|
||||
* `reconcileOwnership` registers methods/fields under the def's
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Language-agnostic primitives for `ScopeResolver.isGlobalNameFallbackPlausible`
|
||||
* implementations.
|
||||
*
|
||||
* The hook itself is per-language — the RULES here are not. What every
|
||||
* implementation needs is the same small set of path arithmetic: which
|
||||
* directory a file sits in, and whether a module path a caller wrote can name
|
||||
* a given file or directory. Those questions are about paths, not about any
|
||||
* language, so they live in shared code (see AGENTS.md: shared ingestion must
|
||||
* not name languages) and the language files supply only the semantics.
|
||||
*/
|
||||
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
|
||||
/** POSIX-style parent directory. `''` for a file at the repo root. */
|
||||
export function directoryOf(filePath: string): string {
|
||||
const slash = filePath.lastIndexOf('/');
|
||||
return slash === -1 ? '' : filePath.slice(0, slash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a module path into segments, accepting the three separators languages
|
||||
* spell module nesting with: `/` (Go, Node), `::` (Rust, C++) and `.` (JVM,
|
||||
* Python). Empty segments and `.` are dropped, so a leading `./` contributes
|
||||
* nothing. Named root prefixes are NOT stripped here — `crate::a` yields
|
||||
* `['crate', 'a']`; a language whose paths carry one (Rust's `crate::` /
|
||||
* `super::`) removes it before calling, see `rustUsePathOf`.
|
||||
*
|
||||
* `.` is only treated as a separator when the path contains no `/`: a Node
|
||||
* specifier like `./util/parse.js` must not split on the extension dot.
|
||||
*/
|
||||
export function moduleSegments(modulePath: string): readonly string[] {
|
||||
const bySlashOrColon = modulePath.split(/\/|::/).filter((s) => s !== '' && s !== '.');
|
||||
if (modulePath.includes('/')) return bySlashOrColon;
|
||||
return bySlashOrColon.flatMap((s) => s.split('.').filter((p) => p !== ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a module path a caller wrote reach `targetPath`?
|
||||
*
|
||||
* True when either side's segments are a SUFFIX of the other's. Both directions
|
||||
* are needed and neither alone is sufficient:
|
||||
*
|
||||
* - The written path is usually longer than the repo-relative one, because it
|
||||
* carries a module/package prefix that is not a directory
|
||||
* (`github.com/org/svc/internal/models` → `internal/models`).
|
||||
* - The repo-relative path is longer when the manifest that defines the module
|
||||
* root sits in a subdirectory of the analyzed tree (`svc/go.mod`, so
|
||||
* `svc/internal/models` is written `mod/internal/models`).
|
||||
*
|
||||
* So the match is on ALIGNED TRAILING SEGMENTS, and it succeeds when either the
|
||||
* shorter side is fully contained in the longer, or at least two segments align.
|
||||
* Both conditions are needed: full containment covers a one-segment package
|
||||
* (`mod/models` reaching `models`), while the two-segment floor covers the case
|
||||
* where neither side contains the other because each carries a different root
|
||||
* (`mod/internal/models` vs `svc/internal/models`). A SINGLE aligned segment
|
||||
* with neither side contained is not enough — `handlers` appearing at the end of
|
||||
* two unrelated trees says nothing.
|
||||
*
|
||||
* Tolerance is the SAFE direction here: this predicate is consulted to decide
|
||||
* whether to REFUSE an edge, so over-matching loses a refusal (the edge stays,
|
||||
* labeled and excluded from flows) while under-matching loses a real edge.
|
||||
*/
|
||||
export function modulePathReaches(writtenPath: string, targetPath: string): boolean {
|
||||
const written = moduleSegments(writtenPath);
|
||||
const target = moduleSegments(targetPath);
|
||||
if (written.length === 0 || target.length === 0) return false;
|
||||
const shorter = Math.min(written.length, target.length);
|
||||
let aligned = 0;
|
||||
while (
|
||||
aligned < shorter &&
|
||||
written[written.length - 1 - aligned] === target[target.length - 1 - aligned]
|
||||
) {
|
||||
aligned++;
|
||||
}
|
||||
return aligned === shorter || aligned >= 2;
|
||||
}
|
||||
|
||||
/** Every module path this file's import statements named, in source order. */
|
||||
function importedModulePaths(parsed: ParsedFile): readonly string[] {
|
||||
return parsed.parsedImports.map((imp) => imp.targetRaw);
|
||||
}
|
||||
|
||||
/** True when any of the caller's imports reaches `targetPath`. */
|
||||
export function anyImportReaches(parsed: ParsedFile, targetPath: string): boolean {
|
||||
for (const written of importedModulePaths(parsed)) {
|
||||
if (modulePathReaches(written, targetPath)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Strip a trailing file extension. `a/b.rs` → `a/b`; `a/b` → `a/b`. */
|
||||
export function stripExtension(filePath: string): string {
|
||||
const slash = filePath.lastIndexOf('/');
|
||||
const dot = filePath.lastIndexOf('.');
|
||||
return dot > slash ? filePath.slice(0, dot) : filePath;
|
||||
}
|
||||
345
gitnexus/src/core/ingestion/ts-js-export-marker.ts
Normal file
345
gitnexus/src/core/ingestion/ts-js-export-marker.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
/**
|
||||
* Export evidence for ECMAScript declarations — the `@declaration.is-exported`
|
||||
* marker both the TypeScript and the JavaScript capture emitters synthesize.
|
||||
*
|
||||
* `SymbolDefinition.isExported` is tri-state, and this is where the three
|
||||
* states are decided for TS/JS:
|
||||
*
|
||||
* - `true` — the declaration sits under an `export_statement` (`export
|
||||
* function f`, `export const x`, `export default class`), or the
|
||||
* file names it in an `export { f }` / `export { f as g }` clause
|
||||
* or an `export default f` / `export = f` statement.
|
||||
* - `false` — an ESM-shaped file (no CommonJS export assignment) that does
|
||||
* neither. The declaration is module-private: `export *` cannot
|
||||
* republish it and it must not be counted as a wildcard provider.
|
||||
* - no verdict — the file exports through CommonJS (`module.exports = …`,
|
||||
* `exports.x = …`, top-level `this.x = …`) or is an ambient
|
||||
* `.d.ts`, where "not under `export`" says nothing about what the
|
||||
* module publishes. Nothing is emitted, and the reader keeps its
|
||||
* prior behavior. One CommonJS shape IS decidable and gets `true`:
|
||||
* a method or property declared directly in the object literal
|
||||
* assigned to `module.exports` (`module.exports = { alpha() {} }`)
|
||||
* is that module's export of `alpha`.
|
||||
*
|
||||
* Only a declaration reached from the top level through DECLARATION nodes can
|
||||
* be a module export. The walk therefore stops with `false` at the first
|
||||
* nesting boundary — a class body, an interface/enum body, a function body, an
|
||||
* object literal that is not the `module.exports` value: `export class C {
|
||||
* m() {} }` exports `C`, not `m`; `function w() { function s() {} }` exports
|
||||
* nothing even when the file has `export { s }` for a different `s`.
|
||||
*
|
||||
* The ancestor walk here is deliberately NOT `tsExportChecker`
|
||||
* (`export-detection.ts`): that checker's text fallback (`text.startsWith('export ')`)
|
||||
* fires on the `program` node of any file whose first token is `export`, which
|
||||
* would mark every declaration in such a file exported.
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from './utils/ast-helpers.js';
|
||||
|
||||
export interface EsmExportEvidence {
|
||||
/** Local names published by `export { … }`, `export default <id>`, `export = <id>`. */
|
||||
readonly namedLocals: ReadonlySet<string>;
|
||||
/** The file exports through a CommonJS assignment: a plain "not under
|
||||
* `export`" is no verdict there. */
|
||||
readonly commonJs: boolean;
|
||||
}
|
||||
|
||||
/** Read binding patterns, never initializer expressions or property keys. */
|
||||
function bindsReceiver(node: SyntaxNode | null, name: string): boolean {
|
||||
if (node === null) return false;
|
||||
if (node.type === 'identifier' || node.type === 'shorthand_property_identifier_pattern') {
|
||||
return node.text === name;
|
||||
}
|
||||
if (node.type === 'variable_declarator')
|
||||
return bindsReceiver(node.childForFieldName('name'), name);
|
||||
if (node.type === 'assignment_pattern')
|
||||
return bindsReceiver(node.childForFieldName('left'), name);
|
||||
if (node.type === 'pair_pattern') return bindsReceiver(node.childForFieldName('value'), name);
|
||||
if (node.type === 'import_specifier') {
|
||||
return bindsReceiver(node.childForFieldName('alias') ?? node.childForFieldName('name'), name);
|
||||
}
|
||||
if (node.type === 'required_parameter' || node.type === 'optional_parameter') {
|
||||
return bindsReceiver(node.childForFieldName('pattern'), name);
|
||||
}
|
||||
return (
|
||||
[
|
||||
'formal_parameters',
|
||||
'object_pattern',
|
||||
'array_pattern',
|
||||
'rest_pattern',
|
||||
'import_clause',
|
||||
'named_imports',
|
||||
'namespace_import',
|
||||
].includes(node.type) && node.namedChildren.some((child) => bindsReceiver(child, name))
|
||||
);
|
||||
}
|
||||
|
||||
/** A locally bound `module`/`exports` is not Node's export receiver. */
|
||||
function isExportReceiverShadowed(node: SyntaxNode, name: string): boolean {
|
||||
for (let scope = node.parent; scope !== null; scope = scope.parent) {
|
||||
if (
|
||||
['function_expression', 'generator_function', 'class'].includes(scope.type) &&
|
||||
scope.childForFieldName('name')?.text === name
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
bindsReceiver(scope.childForFieldName('parameters'), name) ||
|
||||
bindsReceiver(scope.childForFieldName('parameter'), name)
|
||||
)
|
||||
return true;
|
||||
if (scope.type === 'for_in_statement' || scope.type === 'for_of_statement') {
|
||||
if (bindsReceiver(scope.childForFieldName('left'), name)) return true;
|
||||
}
|
||||
if (scope.type === 'for_statement') {
|
||||
const initializer = scope.childForFieldName('initializer');
|
||||
if (
|
||||
initializer !== null &&
|
||||
(initializer.type === 'lexical_declaration' ||
|
||||
initializer.type === 'variable_declaration') &&
|
||||
initializer.namedChildren.some((child) => bindsReceiver(child, name))
|
||||
)
|
||||
return true;
|
||||
}
|
||||
if (scope.type !== 'program' && scope.type !== 'statement_block') continue;
|
||||
for (const statement of scope.namedChildren) {
|
||||
const declaration =
|
||||
statement.type === 'export_statement'
|
||||
? statement.childForFieldName('declaration')
|
||||
: statement;
|
||||
if (declaration === null) continue;
|
||||
if (
|
||||
declaration.type === 'import_statement' &&
|
||||
declaration.namedChildren.some(
|
||||
(child) => child.type === 'import_clause' && bindsReceiver(child, name),
|
||||
)
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
declaration.type === 'lexical_declaration' ||
|
||||
declaration.type === 'variable_declaration'
|
||||
) {
|
||||
if (declaration.namedChildren.some((child) => bindsReceiver(child, name))) return true;
|
||||
} else if (
|
||||
['function_declaration', 'class_declaration'].includes(declaration.type) &&
|
||||
declaration.childForFieldName('name')?.text === name
|
||||
)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Static dot and bracket spellings of the same CommonJS export object. */
|
||||
function isModuleExportsReference(node: SyntaxNode): boolean {
|
||||
const object = node.childForFieldName('object');
|
||||
if (
|
||||
object?.type !== 'identifier' ||
|
||||
object.text !== 'module' ||
|
||||
isExportReceiverShadowed(node, 'module')
|
||||
)
|
||||
return false;
|
||||
if (node.type === 'member_expression') {
|
||||
return node.childForFieldName('property')?.text === 'exports';
|
||||
}
|
||||
if (node.type !== 'subscript_expression') return false;
|
||||
const index = node.childForFieldName('index');
|
||||
return index?.type === 'string' && (index.text === "'exports'" || index.text === '"exports"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the file touch a CommonJS export object anywhere — `module.exports` or
|
||||
* `exports.x` / `exports[x]` — as an actual expression? Read from AST nodes,
|
||||
* not source text, so a comment or string mentioning `module.exports` does not
|
||||
* disable the file's verdicts.
|
||||
*/
|
||||
function hasCommonJsExportSurface(root: SyntaxNode): boolean {
|
||||
for (const member of root.descendantsOfType('member_expression')) {
|
||||
const object = member.childForFieldName('object');
|
||||
if (object === null) continue;
|
||||
if (
|
||||
object.type === 'identifier' &&
|
||||
object.text === 'exports' &&
|
||||
!isExportReceiverShadowed(member, 'exports')
|
||||
)
|
||||
return true;
|
||||
if (isModuleExportsReference(member)) return true;
|
||||
}
|
||||
for (const sub of root.descendantsOfType('subscript_expression')) {
|
||||
const object = sub.childForFieldName('object');
|
||||
if (
|
||||
object?.type === 'identifier' &&
|
||||
object.text === 'exports' &&
|
||||
!isExportReceiverShadowed(sub, 'exports')
|
||||
)
|
||||
return true;
|
||||
if (isModuleExportsReference(sub)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level control-flow wrappers around `this.x =` / `this['x'] =`.
|
||||
* Recurse through these only — a function or class body is a different `this`.
|
||||
*/
|
||||
const TOP_LEVEL_THIS_WALK: ReadonlySet<string> = new Set([
|
||||
'if_statement',
|
||||
'else_clause',
|
||||
'for_statement',
|
||||
'for_in_statement',
|
||||
'for_of_statement',
|
||||
'while_statement',
|
||||
'do_statement',
|
||||
'switch_statement',
|
||||
'switch_body',
|
||||
'switch_case',
|
||||
'switch_default',
|
||||
'try_statement',
|
||||
'catch_clause',
|
||||
'finally_clause',
|
||||
'labeled_statement',
|
||||
'statement_block',
|
||||
]);
|
||||
|
||||
/** Module-wrapper `this.x =` / `this['x'] =` as a statement. */
|
||||
function isThisExportAssignment(stmt: SyntaxNode): boolean {
|
||||
if (stmt.type !== 'expression_statement') return false;
|
||||
const assignment = stmt.namedChildren[0];
|
||||
const left =
|
||||
assignment?.type === 'assignment_expression' ? assignment.childForFieldName('left') : null;
|
||||
return (
|
||||
left !== null &&
|
||||
(left.type === 'member_expression' || left.type === 'subscript_expression') &&
|
||||
left.childForFieldName('object')?.type === 'this'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A top-level `this` export, including `if (enabled) this.api = api` and the
|
||||
* braced form. Does not walk into function or class bodies.
|
||||
*/
|
||||
function hasTopLevelThisExportAssignment(stmt: SyntaxNode): boolean {
|
||||
if (isThisExportAssignment(stmt)) return true;
|
||||
if (!TOP_LEVEL_THIS_WALK.has(stmt.type)) return false;
|
||||
return stmt.namedChildren.some(hasTopLevelThisExportAssignment);
|
||||
}
|
||||
|
||||
/** Node types below which a declaration is nested, not module-level. */
|
||||
const NESTING_BOUNDARIES: ReadonlySet<string> = new Set([
|
||||
'class_body',
|
||||
'interface_body',
|
||||
'enum_body',
|
||||
'object_type',
|
||||
'statement_block',
|
||||
'arrow_function',
|
||||
'function_expression',
|
||||
'function_declaration',
|
||||
'generator_function',
|
||||
'generator_function_declaration',
|
||||
'method_definition',
|
||||
// `export namespace NS { export function f() {} }` / `declare module 'x' {
|
||||
// export function q(): void }`: an `export` inside these bodies is an export
|
||||
// of the namespace/ambient module, not of the file.
|
||||
'internal_module',
|
||||
'module',
|
||||
'ambient_declaration',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Scan a file's top level once. `undefined` means the file's export surface
|
||||
* cannot be read at all (ambient `.d.ts`), so no marker should be emitted.
|
||||
*/
|
||||
export function collectEsmExportEvidence(
|
||||
root: SyntaxNode,
|
||||
filePath: string,
|
||||
): EsmExportEvidence | undefined {
|
||||
if (filePath.endsWith('.d.ts')) return undefined;
|
||||
const namedLocals = new Set<string>();
|
||||
// Any CommonJS export surface anywhere in the file — a direct `module.exports
|
||||
// = …`, an alias (`const m = module.exports; m.x = …`), an `exports.x` — means
|
||||
// "not under `export`" says nothing.
|
||||
let commonJs = hasCommonJsExportSurface(root);
|
||||
for (const stmt of root.namedChildren) {
|
||||
if (stmt.type === 'export_statement') {
|
||||
// `export { a } from './x'` / `export type { T } from './t'` re-export
|
||||
// ANOTHER module's names: they say nothing about a local `a`, and adding
|
||||
// them here marked a private local of the same name exported.
|
||||
if (stmt.childForFieldName('source') !== null) continue;
|
||||
for (const child of stmt.namedChildren) {
|
||||
if (child.type === 'export_clause') {
|
||||
for (const spec of child.namedChildren) {
|
||||
if (spec.type !== 'export_specifier') continue;
|
||||
const name = spec.childForFieldName('name')?.text;
|
||||
if (name !== undefined && name !== '') namedLocals.add(name);
|
||||
}
|
||||
} else if (child.type === 'identifier') {
|
||||
// `export default f;` and TS `export = f;`.
|
||||
namedLocals.add(child.text);
|
||||
}
|
||||
}
|
||||
} else if (hasTopLevelThisExportAssignment(stmt)) {
|
||||
commonJs = true;
|
||||
}
|
||||
}
|
||||
return { namedLocals, commonJs };
|
||||
}
|
||||
|
||||
/** Is `object` the value of a top-level `module.exports = { … }` assignment? */
|
||||
function isModuleExportsObject(object: SyntaxNode): boolean {
|
||||
const assignment = object.parent;
|
||||
if (assignment === null || assignment.type !== 'assignment_expression') return false;
|
||||
if (assignment.childForFieldName('right')?.id !== object.id) return false;
|
||||
const left = assignment.childForFieldName('left');
|
||||
if (left === null || !isModuleExportsReference(left)) return false;
|
||||
return (
|
||||
assignment.parent?.type === 'expression_statement' &&
|
||||
assignment.parent.parent?.type === 'program'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The export verdict for a declaration whose NAME node is `nameNode`:
|
||||
* `true` / `false` as documented in the header, `undefined` when this file's
|
||||
* export surface cannot decide it (CommonJS, other than the `module.exports`
|
||||
* object literal itself).
|
||||
*/
|
||||
export function esmExportVerdict(
|
||||
nameNode: SyntaxNode,
|
||||
evidence: EsmExportEvidence,
|
||||
): boolean | undefined {
|
||||
// The name node's own declaration node is where the walk starts; the
|
||||
// declaration itself (a `method_definition`, a `function_declaration`) must
|
||||
// not count as its own nesting boundary.
|
||||
let current: SyntaxNode | null = nameNode.parent;
|
||||
// An `export` keyword is only a FILE-level export when the walk reaches the
|
||||
// program without crossing a nesting boundary — one inside a namespace or
|
||||
// ambient-module body is that container's export (see NESTING_BOUNDARIES).
|
||||
let underExport = false;
|
||||
let functionScopedVariable = false;
|
||||
while (current !== null && current.type !== 'program') {
|
||||
if (current.type === 'variable_declaration') functionScopedVariable = true;
|
||||
if (current.type === 'export_statement') {
|
||||
underExport = true;
|
||||
current = current.parent;
|
||||
continue;
|
||||
}
|
||||
if (current.type === 'object') {
|
||||
// `module.exports = { alpha() {} }`: the literal's own members are the
|
||||
// module's exports. Any other object literal is a nesting boundary.
|
||||
if (isModuleExportsObject(current) && nameNode.parent?.parent?.id === current.id) return true;
|
||||
return evidence.commonJs ? undefined : false;
|
||||
}
|
||||
if (NESTING_BOUNDARIES.has(current.type) && current.id !== nameNode.parent?.id) {
|
||||
// `var` crosses blocks but never a function/namespace boundary. A
|
||||
// module-scoped `var` can therefore be exported by a later clause.
|
||||
if (current.type === 'statement_block' && functionScopedVariable) {
|
||||
current = current.parent;
|
||||
continue;
|
||||
}
|
||||
return evidence.commonJs ? undefined : false;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
if (underExport) return true;
|
||||
if (evidence.commonJs) return undefined;
|
||||
return evidence.namedLocals.has(nameNode.text);
|
||||
}
|
||||
|
|
@ -70,7 +70,7 @@
|
|||
* replaced, so the obvious rewrite is not the one that shipped.
|
||||
* 3. The remaining ~90 ms was object allocation itself, irreducible while the
|
||||
* read API returns objects — so the five whole-graph scans moved to
|
||||
* `forEachRelationshipFields`, which passes the four fields they actually
|
||||
* `forEachRelationshipFields`, which passes the five fields they actually
|
||||
* read as primitives and allocates nothing. See
|
||||
* {@link GraphEmitSink.forEachRelationshipFields}.
|
||||
*
|
||||
|
|
@ -282,14 +282,19 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl {
|
|||
* safe because `buildRelRow` never persists `rel.id` and no consumer keys on
|
||||
* it (audited).
|
||||
*
|
||||
* The dropped `reason`/`step` are safe too, but for a different reason worth
|
||||
* stating: the PERSISTED row keeps their true values, because `buildRelRow` is
|
||||
* handed the original relationship on the way through. Only in-memory reads
|
||||
* see the `'streamed'` placeholder, and the in-pipeline consumers of streamed
|
||||
* edges read neither field. So e.g. the `ACCESSES reason: 'read'|'write'`
|
||||
* distinction that MCP queries rely on survives in the database. A future
|
||||
* in-pipeline consumer needing `reason` or `step` on a streamed edge must add
|
||||
* the column, not trust the placeholder.
|
||||
* `reason` IS now retained, as an interned index — the in-pipeline consumer
|
||||
* this JSDoc anticipated arrived. Process tracing and large-graph community
|
||||
* detection must exclude global-name-fallback edges, which are emitted at
|
||||
* exactly their confidence threshold (0.5) and so cannot be separated by
|
||||
* confidence alone. Interning keeps the cost at one small integer per edge
|
||||
* (the reason vocabulary is a fixed set of literals), not one string.
|
||||
*
|
||||
* `id` and `step` remain dropped. The PERSISTED row keeps `step`'s true value,
|
||||
* because `buildRelRow` is handed the original relationship on the way
|
||||
* through; only in-memory OBJECT reads see the `'streamed'`-era placeholder,
|
||||
* and no in-pipeline consumer of streamed edges reads `step`. A future
|
||||
* in-pipeline consumer needing `step` must add the column, not trust the
|
||||
* placeholder.
|
||||
*
|
||||
* Node ids are interned; the strings are shared by reference with the node
|
||||
* map's, so interning adds bookkeeping, not new text.
|
||||
|
|
@ -300,6 +305,12 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl {
|
|||
private readonly tgtIx: number[] = [];
|
||||
private readonly relTypes: RelationshipType[] = [];
|
||||
private readonly confidences: number[] = [];
|
||||
/** Interned reason strings, and the per-edge index into them. The vocabulary
|
||||
* is a fixed set of emitter literals, so this is O(vocabulary) text plus one
|
||||
* small integer per edge. */
|
||||
private readonly reasonIds = new Map<string, number>();
|
||||
private readonly reasonByIx: string[] = [];
|
||||
private readonly reasonIx: number[] = [];
|
||||
private finalized = false;
|
||||
/**
|
||||
* Streaming is OFF until {@link beginStreaming} is called by `parse`.
|
||||
|
|
@ -472,6 +483,16 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl {
|
|||
this.tgtIx.push(tgtIx);
|
||||
this.relTypes.push(relationship.type);
|
||||
this.confidences.push(relationship.confidence);
|
||||
this.reasonIx.push(this.internReason(relationship.reason));
|
||||
}
|
||||
|
||||
private internReason(reason: string): number {
|
||||
const existing = this.reasonIds.get(reason);
|
||||
if (existing !== undefined) return existing;
|
||||
const ix = this.reasonByIx.length;
|
||||
this.reasonByIx.push(reason);
|
||||
this.reasonIds.set(reason, ix);
|
||||
return ix;
|
||||
}
|
||||
|
||||
/** Flush + close every writer and return the COPY manifest. Every fd is
|
||||
|
|
@ -599,7 +620,13 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl {
|
|||
* with the object-based graph despite holding relationships columnar.
|
||||
*/
|
||||
forEachRelationshipFields(
|
||||
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
|
||||
fn: (
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
type: RelationshipType,
|
||||
confidence: number,
|
||||
reason: string,
|
||||
) => void,
|
||||
): void {
|
||||
this.real.forEachRelationshipFields(fn);
|
||||
for (let ix = 0; ix < this.srcIx.length; ix++) {
|
||||
|
|
@ -608,6 +635,7 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl {
|
|||
this.nodeIdByIx[this.tgtIx[ix]],
|
||||
this.relTypes[ix],
|
||||
this.confidences[ix],
|
||||
this.reasonByIx[this.reasonIx[ix]],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -299,7 +299,13 @@ export class PdgEmitSink implements KnowledgeGraph {
|
|||
this.real.forEachRelationship(fn);
|
||||
}
|
||||
forEachRelationshipFields(
|
||||
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
|
||||
fn: (
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
type: RelationshipType,
|
||||
confidence: number,
|
||||
reason: string,
|
||||
) => void,
|
||||
): void {
|
||||
this.real.forEachRelationshipFields(fn);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ import { constants as fsConstants } from 'node:fs';
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { retryRename } from '../storage/fs-atomic.js';
|
||||
import { acquireIndexLock } from '../storage/index-lock.js';
|
||||
import {
|
||||
logNameFallbackSummary,
|
||||
summarizeNameFallback,
|
||||
countCallsByLanguage,
|
||||
} from './ingestion/scope-resolution/name-fallback-summary.js';
|
||||
import { runPipelineFromRepo } from './ingestion/pipeline.js';
|
||||
import {
|
||||
logUnresolvedReceiverFiles,
|
||||
|
|
@ -3861,6 +3866,14 @@ async function runFullAnalysisInner(
|
|||
|
||||
const resolutionOutcomes = pipelineResult.resolutionOutcomes ?? [];
|
||||
logUnresolvedReceiverFiles(resolutionOutcomes);
|
||||
// Census of guessed call sites (before edge coalescing), refused candidates
|
||||
// and ambiguous `export *` names. The legacy `nameFallbackEdges` metadata
|
||||
// key stores site counts, not the final population of heuristic edges.
|
||||
const nameFallbackSummary = summarizeNameFallback(
|
||||
resolutionOutcomes,
|
||||
countCallsByLanguage(pipelineResult.resolvedCalleeNamesByCaller, pipelineResult.graph),
|
||||
);
|
||||
logNameFallbackSummary(nameFallbackSummary);
|
||||
|
||||
// Annotated so the capabilities stamp below is compile-checked against
|
||||
// RepoMeta's status unions (tri-review 4669518496 P1/U3) — an unannotated
|
||||
|
|
@ -3977,6 +3990,7 @@ async function runFullAnalysisInner(
|
|||
// Git-only: non-git repos never take the incremental path.
|
||||
schemaFingerprint: hasGitDir(repoPath) ? SCHEMA_FINGERPRINT : undefined,
|
||||
unresolvedReceiverMembers: summarizeUnresolvedReceivers(resolutionOutcomes),
|
||||
nameFallbackEdges: nameFallbackSummary,
|
||||
scopeExtractionFailures: summarizeScopeExtractionFailures(
|
||||
pipelineResult.scopeExtractionFailures,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -759,14 +759,11 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
|
|||
// USES edge, `callableValueReferenceBoundaries` measures a real zero, and
|
||||
// `impact` on a registered accessor republishes `epistemic: "exact"` — the exact
|
||||
// #3399 defect this change exists to close, silently un-fixed.
|
||||
//
|
||||
// 94-97 went to #3179 (Objective-C), which has since merged; 98 is the next
|
||||
// value above it. Re-checked against origin/main and every open PR touching
|
||||
// this file at merge time, which is the rule the paragraphs above were written
|
||||
// by PRs that each checked only once: main is at 97, and the remaining open
|
||||
// claims (#3190 at 94, #2840 at 71, #1616 at 2) all sit below it and must
|
||||
// re-bump themselves.
|
||||
const SCHEMA_BUMP = 98;
|
||||
// v99: ParsedImport retains declaredAtScope and export evidence changes in
|
||||
// #3190. Old durable ParsedFiles lack the facts needed for scoped binding;
|
||||
// invalidate both stores so warm indexing actually applies the correction.
|
||||
// origin/main took 98 for #3219; 99 is the next free value.
|
||||
const SCHEMA_BUMP = 99;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import type { UnresolvedReceiverSummary } from '../core/ingestion/scope-resolution/unresolved-receivers.js';
|
||||
import type { NameFallbackSummary } from '../core/ingestion/scope-resolution/name-fallback-summary.js';
|
||||
import type { UndecidedSatisfactionSummary } from '../core/ingestion/scope-resolution/undecided-satisfaction.js';
|
||||
import type { ScopeExtractionFailureSummary } from '../core/ingestion/scope-resolution/scope-extraction-failures.js';
|
||||
|
||||
|
|
@ -311,6 +312,15 @@ export interface RepoMeta {
|
|||
* reads as absent, and both correctly mean "no hedge available from here".
|
||||
*/
|
||||
undecidedInterfaceSatisfaction?: UndecidedSatisfactionSummary;
|
||||
/**
|
||||
* Census of name-guessed call sites before edge coalescing, the impossible
|
||||
* candidates the run refused, and the ambiguous
|
||||
* `export *` names it declined to publish. Absent on indexes built before the
|
||||
* census existed. The legacy key does not imply final edge counts: a precise
|
||||
* site may prove a dependency shared with a guessed site.
|
||||
* See `scope-resolution/name-fallback-summary.ts`.
|
||||
*/
|
||||
nameFallbackEdges?: NameFallbackSummary;
|
||||
/**
|
||||
* SHA-256 of every file's content at the time of the last successful
|
||||
* indexing run. The next run computes current hashes and diffs against
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@ export interface PipelineResult {
|
|||
* produced; graph edge semantics are unchanged.
|
||||
*/
|
||||
resolutionOutcomes: readonly ResolutionOutcome[];
|
||||
/**
|
||||
* Caller node id → simple names of every callee it has a CALLS edge to, read
|
||||
* through the streaming sink when one was active (the raw graph holds no
|
||||
* streamed edge). Denominator for the name-fallback census
|
||||
* (`countCallsByLanguage`), so a guess count can be read as a share of the
|
||||
* call graph. Absent only when scope resolution did not run.
|
||||
*/
|
||||
resolvedCalleeNamesByCaller?: ReadonlyMap<string, ReadonlySet<string>>;
|
||||
/**
|
||||
* Interfaces whose structural-satisfaction check could not be completed
|
||||
* (#2873). Empty for languages with no structural detection.
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@
|
|||
},
|
||||
"go-constructor-type-inference/cmd/main.go": {
|
||||
"captureGroups": 19,
|
||||
"digest": "4f2e8bb6dbca59bcb82fc2efd43899b7a92816af28811cfc1858f0ce7ba9f6fe"
|
||||
"digest": "8dff73176d5890d7ede1d8b9cd9f8555518a1dcc11fbf02190beb474c0f9e47e"
|
||||
},
|
||||
"go-constructor-type-inference/models/repo.go": {
|
||||
"captureGroups": 8,
|
||||
|
|
@ -252,8 +252,8 @@
|
|||
"digest": "ad4cfabcd53397ab664ba4bb05df4e1a803455b5c255a19e2ed283c6523a1660"
|
||||
},
|
||||
"go-method-enrichment/app.go": {
|
||||
"captureGroups": 15,
|
||||
"digest": "35929a31f6921feb074f50385aa91879e93f25b88d7f50774fddcfbb7b4e8539"
|
||||
"captureGroups": 16,
|
||||
"digest": "b2ab0936ab28e22d0333119838c929ec6e3d460cc0be8d27aa4d178b9f6452d3"
|
||||
},
|
||||
"go-mixed-chain/cmd/main.go": {
|
||||
"captureGroups": 22,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
package main
|
||||
|
||||
import "animal"
|
||||
// This fixture exercises method enrichment and a valid same-package free call.
|
||||
// A different package with a normal import cannot refer to Dog or Classify bare.
|
||||
package animal
|
||||
|
||||
func main() {
|
||||
dog := Dog{}
|
||||
sound := dog.Speak()
|
||||
category := Classify("dog")
|
||||
_, _ = sound, category
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* C2 — the grafana `Button` shape: a workspace barrel `export *`s a components
|
||||
* index, which re-exports NAMED bindings (with inline `type` modifiers) from a
|
||||
* DIRECTORY index, which `export *`s the real file, whose `Button` is a
|
||||
* `React.forwardRef` const. Measured on grafana@871af0720: `Button` resolved 8
|
||||
* of 475 ledger entries while siblings through plain hops resolved at scale.
|
||||
*/
|
||||
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';
|
||||
|
||||
describe('named re-export through a directory index that wildcards (grafana Button shape)', () => {
|
||||
let result: PipelineResult;
|
||||
let repoDir: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-c2-dir-index-'));
|
||||
writeFixtureRepo(repoDir, {
|
||||
'package.json': '{ "name": "root", "private": true, "workspaces": ["packages/*"] }\n',
|
||||
'packages/ui/package.json':
|
||||
'{ "name": "@x/ui", "version": "1.0.0", "main": "src/index.ts" }\n',
|
||||
'packages/ui/src/index.ts': `export * from './components';\nexport * from './themes';\n`,
|
||||
// Inline `type` modifiers on the same statement as value re-exports.
|
||||
'packages/ui/src/components/index.ts': `export { Stack } from './Layout/Stack';
|
||||
export { Button, LinkButton, type ButtonVariant, ButtonGroup, type ButtonProps, clearButtonStyles } from './Button';
|
||||
`,
|
||||
// Directory index: wildcard + one named re-export.
|
||||
'packages/ui/src/components/Button/index.ts': `export * from './Button';\nexport { ButtonGroup } from './ButtonGroup';\n`,
|
||||
'packages/ui/src/components/Button/Button.tsx': `import React from 'react';
|
||||
export type ButtonVariant = 'primary' | 'secondary';
|
||||
export interface ButtonProps { variant?: ButtonVariant; label: string }
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
||||
return null;
|
||||
});
|
||||
export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonProps>((props, ref) => {
|
||||
return null;
|
||||
});
|
||||
export const clearButtonStyles = (theme: string) => {
|
||||
return theme;
|
||||
};
|
||||
`,
|
||||
'packages/ui/src/components/Button/ButtonGroup.tsx': `export function ButtonGroup(children: string) {
|
||||
return children;
|
||||
}
|
||||
`,
|
||||
'packages/ui/src/components/Layout/Stack.tsx': `export function Stack(children: string) {
|
||||
return children;
|
||||
}
|
||||
`,
|
||||
'packages/ui/src/themes/index.ts': `export * from './hooks';\n`,
|
||||
'packages/ui/src/themes/hooks/index.ts': `export * from './useStyles2';\n`,
|
||||
'packages/ui/src/themes/hooks/useStyles2.ts': `export function useStyles2(fn: (t: string) => string) {
|
||||
return fn('theme');
|
||||
}
|
||||
`,
|
||||
'packages/app/package.json':
|
||||
'{ "name": "@x/app", "version": "1.0.0", "main": "src/main.tsx", "dependencies": { "@x/ui": "1.0.0" } }\n',
|
||||
'packages/app/tsconfig.json': `{ "compilerOptions": { "jsx": "react-jsx" } }\n`,
|
||||
'packages/app/src/main.tsx': `import { Button, LinkButton, ButtonGroup, clearButtonStyles, Stack, useStyles2 } from '@x/ui';
|
||||
|
||||
export function render() {
|
||||
const styles = useStyles2((t) => t);
|
||||
clearButtonStyles(styles);
|
||||
ButtonGroup('x');
|
||||
Stack('y');
|
||||
const a = <Button label="a" />;
|
||||
const b = <LinkButton label="b" />;
|
||||
return [a, b];
|
||||
}
|
||||
`,
|
||||
});
|
||||
result = await runPipelineFromRepo(repoDir, () => {});
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (repoDir !== undefined)
|
||||
fs.rmSync(repoDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
const callsFromMain = () =>
|
||||
getRelationships(result, 'CALLS').filter((e) =>
|
||||
e.sourceFilePath.includes('packages/app/src/main.tsx'),
|
||||
);
|
||||
|
||||
it('resolves the plain named hop (Stack) and the deep wildcard chain (useStyles2)', () => {
|
||||
const targets = callsFromMain().map((e) => e.target);
|
||||
expect(targets.some((t) => t.includes('Stack'))).toBe(true);
|
||||
expect(targets.some((t) => t.includes('useStyles2'))).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves value names on a statement that also carries inline `type` modifiers', () => {
|
||||
const targets = callsFromMain().map((e) => e.target);
|
||||
expect(targets.some((t) => t.includes('clearButtonStyles'))).toBe(true);
|
||||
expect(targets.some((t) => t.includes('ButtonGroup'))).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves a forwardRef const component used as JSX through the dir-index wildcard', () => {
|
||||
const targets = callsFromMain().map((e) => e.target);
|
||||
// Exact name — `/Button$/` also matched `LinkButton`, which made the
|
||||
// assertion below imply this one and would have let zero `Button` edges pass.
|
||||
expect(targets).toContain('Button');
|
||||
expect(targets.some((t) => t.includes('LinkButton'))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses nothing on this shape (no ambiguity, no fallback)', () => {
|
||||
const outcomes = getResolutionOutcomes(result);
|
||||
expect(outcomes.filter((o) => o.kind === 'reexport-ambiguous')).toEqual([]);
|
||||
expect(outcomes.filter((o) => o.kind === 'fallback-guessed')).toEqual([]);
|
||||
});
|
||||
|
||||
// The bug this fixture regresses against wasn't "no edge" — it was an edge
|
||||
// to the WRONG node. `export const Button = React.forwardRef(...)` emits a
|
||||
// `Variable` def for the lexical declaration alongside the `Function` def
|
||||
// for the arrow; before the wildcard fan-out used the same
|
||||
// callable-preferred index the named-reexport path already used, whichever
|
||||
// def `localDefs` happened to iterate first could win the closure slot. An
|
||||
// edge landing on `Variable` would still show up in a `target` name match
|
||||
// (both defs share the simple name) while pointing at the wrong graph node
|
||||
// — so the label, not just the name, is the assertion that actually catches
|
||||
// a regression here.
|
||||
it('every arrow-const winner through the wildcard chain is the Function def, not the Variable shadow', () => {
|
||||
const calls = callsFromMain();
|
||||
for (const name of ['Button', 'LinkButton', 'clearButtonStyles']) {
|
||||
const labels = calls.filter((edge) => edge.target === name).map((edge) => edge.targetLabel);
|
||||
expect(labels, name).toEqual(['Function']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './helpers.js';
|
||||
|
||||
/**
|
||||
* `namedImportsBindTopLevelOnly` (ECMAScript): `import { beta }` can never reach a
|
||||
* class member. Before the hook, `findExportByName`'s callable preference let
|
||||
* `Foo.beta()` outrank the top-level `const beta = 42` — or bind on its own when no
|
||||
* top-level `beta` existed — and the import produced a confident CALLS edge to a
|
||||
* symbol the module cannot export. Incorrect context is worse than missing: the
|
||||
* Variable shadow must win and emit no edge; the member must never bind.
|
||||
*/
|
||||
const impl = `export const beta = 42;\nexport function alpha(s: string) { return s; }\nexport class Foo { beta() { return 1; } }\n`;
|
||||
const memberOnly = `export function alpha(s: string) { return s; }\nexport class Foo { beta() { return 1; } }\n`;
|
||||
|
||||
async function run(name: string, files: Record<string, string>) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-named-member-${name}-`));
|
||||
try {
|
||||
writeFixtureRepo(dir, files);
|
||||
const result = await runPipelineFromRepo(dir, () => {});
|
||||
return getRelationships(result, 'CALLS')
|
||||
.filter((e) => e.sourceFilePath.includes('src/main'))
|
||||
.map((e) => e.target)
|
||||
.sort();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
}
|
||||
}
|
||||
|
||||
describe.each(['ts', 'js'])(
|
||||
'named imports bind module-level declarations only (%s)',
|
||||
(extension) => {
|
||||
// Keep identical scenarios while actually selecting each language's parser
|
||||
// and separately registered resolver.
|
||||
const fixture = (files: Record<string, string>) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(files).map(([file, content]) => [
|
||||
file.replace(/\.ts$/, `.${extension}`),
|
||||
extension === 'js' ? content.replaceAll(': string', '') : content,
|
||||
]),
|
||||
);
|
||||
it('direct named import: a class method sharing a top-level value name emits no edge', async () => {
|
||||
const targets = await run(
|
||||
`direct-${extension}`,
|
||||
fixture({
|
||||
'package.json': '{ "name": "root", "private": true }\n',
|
||||
'src/Impl.ts': impl,
|
||||
'src/main.ts': `import { alpha, beta } from './Impl';\nexport function render() { alpha('a'); beta(); }\n`,
|
||||
}),
|
||||
);
|
||||
expect(targets).toEqual(['alpha']);
|
||||
}, 60000);
|
||||
|
||||
it('direct named import: a class method with NO top-level declaration does not bind', async () => {
|
||||
const targets = await run(
|
||||
`member-only-${extension}`,
|
||||
fixture({
|
||||
'package.json': '{ "name": "root", "private": true }\n',
|
||||
'src/Impl.ts': memberOnly,
|
||||
'src/main.ts': `import { alpha, beta } from './Impl';\nexport function render() { alpha('a'); beta(); }\n`,
|
||||
}),
|
||||
);
|
||||
expect(targets).toEqual(['alpha']);
|
||||
}, 60000);
|
||||
|
||||
it('named re-export through a barrel: same rule', async () => {
|
||||
const targets = await run(
|
||||
`barrel-${extension}`,
|
||||
fixture({
|
||||
'package.json': '{ "name": "root", "private": true }\n',
|
||||
'src/Impl.ts': impl,
|
||||
'src/index.ts': `export { alpha, beta } from './Impl';\n`,
|
||||
'src/main.ts': `import { alpha, beta } from './index';\nexport function render() { alpha('a'); beta(); }\n`,
|
||||
}),
|
||||
);
|
||||
expect(targets).toEqual(['alpha']);
|
||||
}, 60000);
|
||||
|
||||
it('control: a top-level arrow-const behind the same barrel still binds', async () => {
|
||||
const targets = await run(
|
||||
`control-${extension}`,
|
||||
fixture({
|
||||
'package.json': '{ "name": "root", "private": true }\n',
|
||||
'src/Impl.ts': `export const beta = () => 1;\nexport function alpha(s: string) { return s; }\n`,
|
||||
'src/index.ts': `export { alpha, beta } from './Impl';\n`,
|
||||
'src/main.ts': `import { alpha, beta } from './index';\nexport function render() { alpha('a'); beta(); }\n`,
|
||||
}),
|
||||
);
|
||||
expect(targets).toEqual(['alpha', 'beta']);
|
||||
}, 60000);
|
||||
|
||||
it('an aliased private import cannot guess a different module-private declaration', async () => {
|
||||
const targets = await run(
|
||||
`alias-${extension}`,
|
||||
fixture({
|
||||
'package.json': '{ "name": "root", "private": true }\n',
|
||||
'src/Impl.ts': 'export class Foo { beta() {} }\nfunction renamed() {}\n',
|
||||
'src/main.ts':
|
||||
"import { beta as renamed } from './Impl';\nexport function render() { renamed(); }\n",
|
||||
}),
|
||||
);
|
||||
expect(targets).toEqual([]);
|
||||
}, 60000);
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* C6 — ECMAScript precedence: an explicit named export shadows a star
|
||||
* collision. Only star-vs-star is ambiguous; `export { collide } from './a'`
|
||||
* next to `export * from './a'; export * from './b'` binds `a`'s `collide`
|
||||
* and must NOT be refused.
|
||||
*/
|
||||
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';
|
||||
|
||||
describe('named export shadows a star collision', () => {
|
||||
let result: PipelineResult;
|
||||
let dir: string;
|
||||
beforeAll(async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-c6-prec-'));
|
||||
writeFixtureRepo(dir, {
|
||||
'package.json': '{ "name": "root", "private": true, "workspaces": ["packages/*"] }\n',
|
||||
'packages/ui/package.json':
|
||||
'{ "name": "@x/ui", "version": "1.0.0", "main": "src/index.ts" }\n',
|
||||
'packages/ui/src/index.ts': `export { collide } from './a';\nexport * from './a';\nexport * from './b';\n`,
|
||||
'packages/ui/src/a.ts': `export function collide() { return 'a'; }\nexport function onlyA() { return 1; }\n`,
|
||||
'packages/ui/src/b.ts': `export function collide() { return 'b'; }\nexport function onlyB() { return 2; }\n`,
|
||||
'packages/app/package.json':
|
||||
'{ "name": "@x/app", "version": "1.0.0", "main": "src/main.ts", "dependencies": { "@x/ui": "1.0.0" } }\n',
|
||||
'packages/app/src/main.ts': `import { collide, onlyA, onlyB } from '@x/ui';\nexport function run() { collide(); onlyA(); onlyB(); }\n`,
|
||||
});
|
||||
result = await runPipelineFromRepo(dir, () => {});
|
||||
}, 120_000);
|
||||
afterAll(() => fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }));
|
||||
|
||||
it('binds the named export (a.ts) and does not refuse it', () => {
|
||||
const edges = getRelationships(result, 'CALLS').filter(
|
||||
(e) => e.sourceFilePath.includes('main.ts') && e.target === 'collide',
|
||||
);
|
||||
expect(edges.length).toBe(1);
|
||||
expect(edges[0]!.targetFilePath).toContain('packages/ui/src/a.ts');
|
||||
expect(getResolutionOutcomes(result).filter((o) => o.kind === 'reexport-ambiguous')).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
it('still resolves the non-colliding star names', () => {
|
||||
const targets = getRelationships(result, 'CALLS')
|
||||
.filter((e) => e.sourceFilePath.includes('main.ts'))
|
||||
.map((e) => e.target)
|
||||
.sort();
|
||||
expect(targets).toEqual(['collide', 'onlyA', 'onlyB']);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* Pipeline-level reproductions from magyargergo's review of #3182, each of
|
||||
* which produced an incorrect or missing CALLS edge at 6d3ac0d8:
|
||||
*
|
||||
* 1. finalize-algorithm.ts:1374 — a function NESTED in another function behind
|
||||
* an `export *` barrel displaced the real exported value of the same name
|
||||
* (0.85 edge to `wrapper.selected`, which is private to `wrapper`).
|
||||
* 2. javascript/scope-resolver.ts:105 — `module.exports = { alpha() {} }` +
|
||||
* `const { alpha } = require('./lib')`: the Method IS the module's export,
|
||||
* and `namedImportsBindTopLevelOnly` sent the exact import to a name guess
|
||||
* (and to nothing at all once another module declared its own `alpha`).
|
||||
* 3. finalize-algorithm.ts:1049 — `export class Unrelated { clash() {} }` in
|
||||
* the barrel made `clash` a local name and switched the star-vs-star
|
||||
* collision check off.
|
||||
* 4. free-call-fallback.ts:710 — `alpha(); precise();` vs `precise(); alpha();`
|
||||
* after `import { alpha as precise }` gave different edges for one
|
||||
* dependency. A precisely resolved site proves the edge in either order.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
getRelationships,
|
||||
getResolutionOutcomes,
|
||||
runPipelineFromRepo,
|
||||
writeFixtureRepo,
|
||||
} from './helpers.js';
|
||||
|
||||
async function run(name: string, files: Record<string, string>) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-3182-${name}-`));
|
||||
try {
|
||||
writeFixtureRepo(dir, files);
|
||||
const result = await runPipelineFromRepo(dir, () => {});
|
||||
const calls = getRelationships(result, 'CALLS')
|
||||
.filter(
|
||||
(e) => e.sourceFilePath.endsWith('caller.ts') || e.sourceFilePath.endsWith('caller.js'),
|
||||
)
|
||||
.map((e) => ({
|
||||
target: e.target,
|
||||
targetId: e.rel.targetId,
|
||||
targetFile: path.basename(e.targetFilePath),
|
||||
confidence: e.rel.confidence,
|
||||
reason: e.rel.reason,
|
||||
}))
|
||||
.sort((a, b) => a.target.localeCompare(b.target) || a.targetFile.localeCompare(b.targetFile));
|
||||
return { calls, outcomes: getResolutionOutcomes(result) };
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
}
|
||||
}
|
||||
|
||||
describe('#3182 review reproductions', () => {
|
||||
it('1. a nested function behind `export *` does not displace the exported value of the same name', async () => {
|
||||
const { calls } = await run('nested', {
|
||||
'package.json': '{ "name": "r", "private": true }\n',
|
||||
'lib.ts': `export function factory() { return () => 1; }\nexport const selected = factory();\nfunction wrapper() { function selected() {} return selected; }\nexport { wrapper };\n`,
|
||||
'index.ts': `export * from './lib';\n`,
|
||||
'caller.ts': `import { selected } from './index';\nexport function go() { return selected(); }\n`,
|
||||
});
|
||||
// `selected` is a `const` value (arrow returned by a call) — the graph
|
||||
// has no Function node for it, so the honest outcome is NO edge to a
|
||||
// callable named `selected`; above all, none to `wrapper`'s private one.
|
||||
expect(calls.filter((c) => c.target === 'selected')).toEqual([]);
|
||||
}, 120000);
|
||||
|
||||
it.each(['module.exports', "module['exports']", 'module["exports"]'])(
|
||||
'2. a CommonJS `%s = { alpha() {} }` member binds an exact destructured require',
|
||||
async (exportObject) => {
|
||||
const files = {
|
||||
'package.json': '{ "name": "r", "private": true }\n',
|
||||
'lib.js': `${exportObject} = { alpha() { return 1; } };\n`,
|
||||
'caller.js': `const { alpha } = require('./lib');\nfunction run() { return alpha(); }\nmodule.exports = { run };\n`,
|
||||
};
|
||||
const single = await run('cjs1', files);
|
||||
expect(single.calls).toEqual([
|
||||
{
|
||||
target: 'alpha',
|
||||
targetId: 'Method:lib.js:alpha#0',
|
||||
targetFile: 'lib.js',
|
||||
confidence: 0.85,
|
||||
reason: 'import-resolved',
|
||||
},
|
||||
]);
|
||||
// A second module declaring its own `alpha` must not turn the exact import
|
||||
// into an ambiguous guess that disappears.
|
||||
const dup = await run('cjs2', {
|
||||
...files,
|
||||
'other.js': `function alpha() { return 2; }\nmodule.exports = { alpha };\n`,
|
||||
});
|
||||
expect(dup.calls).toEqual([
|
||||
{
|
||||
target: 'alpha',
|
||||
targetId: 'Method:lib.js:alpha#0',
|
||||
targetFile: 'lib.js',
|
||||
confidence: 0.85,
|
||||
reason: 'import-resolved',
|
||||
},
|
||||
]);
|
||||
},
|
||||
120000,
|
||||
);
|
||||
|
||||
it('3. a class member in the barrel does not shadow a star-vs-star collision', async () => {
|
||||
const { calls, outcomes } = await run('shadow', {
|
||||
'package.json': '{ "name": "r", "private": true }\n',
|
||||
'a.ts': `export function clash() { return 'a'; }\n`,
|
||||
'b.ts': `export function clash() { return 'b'; }\n`,
|
||||
'index.ts': `export * from './a';\nexport * from './b';\nexport class Unrelated { clash() { return 0; } }\n`,
|
||||
'caller.ts': `import { clash } from './index';\nexport function go() { return clash(); }\n`,
|
||||
});
|
||||
expect(calls.filter((c) => c.target === 'clash')).toEqual([]);
|
||||
expect(outcomes.some((o) => o.kind === 'reexport-ambiguous' && o.name === 'clash')).toBe(true);
|
||||
}, 120000);
|
||||
|
||||
it('4. `alpha(); precise();` and `precise(); alpha();` yield the same import-resolved edge', async () => {
|
||||
const base = {
|
||||
'package.json': '{ "name": "r", "private": true }\n',
|
||||
'lib.ts': `export function alpha() { return 1; }\n`,
|
||||
};
|
||||
const guessFirst = await run('order1', {
|
||||
...base,
|
||||
'caller.ts': `import { alpha as precise } from './lib';\nexport function go() { alpha(); precise(); }\n`,
|
||||
});
|
||||
const preciseFirst = await run('order2', {
|
||||
...base,
|
||||
'caller.ts': `import { alpha as precise } from './lib';\nexport function go() { precise(); alpha(); }\n`,
|
||||
});
|
||||
const expected = [
|
||||
{
|
||||
target: 'alpha',
|
||||
targetId: 'Function:lib.ts:alpha',
|
||||
targetFile: 'lib.ts',
|
||||
confidence: 0.85,
|
||||
reason: 'import-resolved',
|
||||
},
|
||||
];
|
||||
expect(guessFirst.calls).toEqual(expected);
|
||||
expect(preciseFirst.calls).toEqual(expected);
|
||||
}, 120000);
|
||||
});
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './helpers.js';
|
||||
|
||||
const base = {
|
||||
'package.json': '{ "name": "root", "private": true, "workspaces": ["packages/*"] }\n',
|
||||
'packages/ui/package.json': '{ "name": "@x/ui", "version": "1.0.0", "main": "src/index.ts" }\n',
|
||||
'packages/ui/src/index.ts': `export * from './components';\n`,
|
||||
'packages/ui/src/components/index.ts': `export { alpha, beta } from './Impl';\n`,
|
||||
'packages/ui/src/components/Impl/index.ts': `export * from './Impl';\n`,
|
||||
'packages/app/package.json':
|
||||
'{ "name": "@x/app", "version": "1.0.0", "main": "src/main.ts", "dependencies": { "@x/ui": "1.0.0" } }\n',
|
||||
'packages/app/src/main.ts': `import { alpha, beta } from '@x/ui';\nexport function render() { alpha('a'); beta('b'); }\n`,
|
||||
};
|
||||
async function run(name: string, extra: Record<string, string>, remove: string[] = []) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-c2b-${name}-`));
|
||||
const files: Record<string, string> = { ...base, ...extra };
|
||||
for (const r of remove) delete files[r];
|
||||
try {
|
||||
writeFixtureRepo(dir, files);
|
||||
const result = await runPipelineFromRepo(dir, () => {});
|
||||
return getRelationships(result, 'CALLS')
|
||||
.filter((e) => e.sourceFilePath.includes('packages/app/src/main'))
|
||||
.map((e) => e.target)
|
||||
.sort();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
}
|
||||
}
|
||||
describe('C2 probe 2', () => {
|
||||
it('E: impl is a .tsx file with plain function exports', async () => {
|
||||
expect(
|
||||
await run('e', {
|
||||
'packages/ui/src/components/Impl/Impl.tsx': `export function alpha(s: string) { return s; }\nexport function beta(s: string) { return s; }\n`,
|
||||
}),
|
||||
).toEqual(['alpha', 'beta']);
|
||||
}, 60000);
|
||||
it('F: impl is .ts with ARROW CONST exports', async () => {
|
||||
expect(
|
||||
await run('f', {
|
||||
'packages/ui/src/components/Impl/Impl.ts': `export const alpha = (s: string) => { return s; };\nexport const beta = (s: string) => { return s; };\n`,
|
||||
}),
|
||||
).toEqual(['alpha', 'beta']);
|
||||
}, 60000);
|
||||
it('G: impl is .ts with a React import + forwardRef generic const alongside plain fns', async () => {
|
||||
expect(
|
||||
await run('g', {
|
||||
'packages/ui/src/components/Impl/Impl.ts': `import React from 'react';\nexport const Widget = React.forwardRef<HTMLButtonElement, { label: string }>((props, ref) => { return null; });\nexport function alpha(s: string) { return s; }\nexport function beta(s: string) { return s; }\n`,
|
||||
}),
|
||||
).toEqual(['alpha', 'beta']);
|
||||
}, 60000);
|
||||
it('H: main is .tsx and ALSO uses a JSX element', async () => {
|
||||
expect(
|
||||
await run(
|
||||
'h',
|
||||
{
|
||||
'packages/ui/src/components/Impl/Impl.ts': `export function alpha(s: string) { return s; }\nexport function beta(s: string) { return s; }\nexport function Widget(p: { label: string }) { return null; }\n`,
|
||||
'packages/ui/src/components/index.ts': `export { alpha, beta, Widget } from './Impl';\n`,
|
||||
'packages/app/src/main.tsx': `import { alpha, beta, Widget } from '@x/ui';\nexport function render() { alpha('a'); beta('b'); return <Widget label="x" />; }\n`,
|
||||
},
|
||||
['packages/app/src/main.ts'],
|
||||
),
|
||||
).toEqual(['Widget', 'alpha', 'beta']);
|
||||
}, 60000);
|
||||
it('B1: a class METHOD sharing a top-level const name never wins the wildcard fan-out', async () => {
|
||||
// `export *` can only publish module-level declarations. `Foo.render` is
|
||||
// callable and outranked the value shadow in the callable-preferred index,
|
||||
// binding `import { render }` to a symbol it can never reach. The safe
|
||||
// outcome is the pre-existing one: a value shadow yields NO CALLS edge.
|
||||
expect(
|
||||
await run('b1', {
|
||||
'packages/ui/src/components/Impl/Impl.ts': `export const alpha = (s: string) => { return s; };\nexport const beta = 42;\nexport class Foo { beta() { return 1; } }\n`,
|
||||
}),
|
||||
).toEqual(['alpha']);
|
||||
}, 60000);
|
||||
it('B1 control: the arrow const still wins over its own Variable shadow', async () => {
|
||||
expect(
|
||||
await run('b1c', {
|
||||
'packages/ui/src/components/Impl/Impl.ts': `export const alpha = (s: string) => { return s; };\nexport const beta = (s: string) => { return s; };\nexport class Foo { alpha() { return 1; } }\n`,
|
||||
}),
|
||||
).toEqual(['alpha', 'beta']);
|
||||
}, 60000);
|
||||
it('I: dir index has wildcard AND a named re-export from a sibling', async () => {
|
||||
expect(
|
||||
await run('i', {
|
||||
'packages/ui/src/components/Impl/index.ts': `export * from './Impl';\nexport { beta } from './Beta';\n`,
|
||||
'packages/ui/src/components/Impl/Impl.ts': `export function alpha(s: string) { return s; }\n`,
|
||||
'packages/ui/src/components/Impl/Beta.ts': `export function beta(s: string) { return s; }\n`,
|
||||
}),
|
||||
).toEqual(['alpha', 'beta']);
|
||||
}, 60000);
|
||||
});
|
||||
207
gitnexus/test/integration/resolvers/name-fallback-edges.test.ts
Normal file
207
gitnexus/test/integration/resolvers/name-fallback-edges.test.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './helpers.js';
|
||||
|
||||
async function callsFor(
|
||||
source: string,
|
||||
targetSource = 'pub fn uniqueScopeHelper() {}\n',
|
||||
targetName = 'uniqueScopeHelper',
|
||||
) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-import-scope-'));
|
||||
try {
|
||||
writeFixtureRepo(dir, {
|
||||
'Cargo.toml': '[package]\nname = "scope-test"\nversion = "0.1.0"\nedition = "2021"\n',
|
||||
'src/lib.rs': source,
|
||||
'src/target.rs': targetSource,
|
||||
});
|
||||
const result = await runPipelineFromRepo(dir, () => {});
|
||||
return getRelationships(result, 'CALLS').filter((edge) => edge.target === targetName);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
}
|
||||
}
|
||||
|
||||
describe('Rust import scope through full ingestion', () => {
|
||||
it('preserves method calls on the result of a function-local imported factory', async () => {
|
||||
const calls = await callsFor(
|
||||
`
|
||||
mod target;
|
||||
pub fn allowed() {
|
||||
use crate::target::make_user;
|
||||
let user = make_user();
|
||||
user.save();
|
||||
}
|
||||
`,
|
||||
'pub struct User {}\nimpl User { pub fn save(&self) {} }\npub fn make_user() -> User { User {} }\n',
|
||||
'save',
|
||||
);
|
||||
expect(calls.filter((edge) => edge.source === 'allowed')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('an import in one inline module cannot bind or authorize calls in its sibling', async () => {
|
||||
const calls = await callsFor(`
|
||||
mod target;
|
||||
mod importing {
|
||||
use crate::target::uniqueScopeHelper;
|
||||
pub fn allowed() { uniqueScopeHelper(); }
|
||||
}
|
||||
mod sibling {
|
||||
pub fn denied() { uniqueScopeHelper(); }
|
||||
}
|
||||
`);
|
||||
expect(calls.filter((edge) => edge.source === 'allowed')).toHaveLength(1);
|
||||
expect(calls.filter((edge) => edge.source === 'allowed')[0]!.rel.reason).toBe(
|
||||
'import-resolved',
|
||||
);
|
||||
expect(calls.filter((edge) => edge.source === 'denied')).toEqual([]);
|
||||
});
|
||||
|
||||
it('function-local alias imports remain usable inside that function, not its sibling', async () => {
|
||||
const calls = await callsFor(`
|
||||
mod target;
|
||||
pub fn allowed() {
|
||||
use crate::target::uniqueScopeHelper as localHelper;
|
||||
localHelper();
|
||||
}
|
||||
pub fn denied() { localHelper(); }
|
||||
`);
|
||||
expect(calls.filter((edge) => edge.source === 'allowed')).toHaveLength(1);
|
||||
expect(calls.filter((edge) => edge.source === 'denied')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -29,6 +29,33 @@ const allShards = (files: readonly string[], total: number): readonly (readonly
|
|||
Array.from({ length: total }, (_unused, i) => shardFiles(files, i + 1, total));
|
||||
|
||||
describe('cross-platform shard partition', () => {
|
||||
it('does not recreate the overloaded Windows shard from the #3190 CI run', () => {
|
||||
// Run 34014266125: these serialized DB suites were missing or undercharged
|
||||
// in the scheduling table. Keep the observed profile independent of the
|
||||
// table so deleting a weight cannot make this regression pass again.
|
||||
const observed: Readonly<Record<string, number>> = {
|
||||
'test/integration/skills-e2e.test.ts': 444,
|
||||
'test/unit/incremental-index-extension-dml-gate.test.ts': 414,
|
||||
'test/integration/fts-extension-e2e.test.ts': 146,
|
||||
'test/integration/analyze-wal-checkpoint-failure.test.ts': 86,
|
||||
};
|
||||
const floor = weightOf('test/unmeasured.test.ts');
|
||||
const loads = allShards(ALL_CROSS_PLATFORM, SHARD_TOTAL).map((files) =>
|
||||
files.reduce((sum, file) => sum + Math.max(weightOf(file), (observed[file] ?? 0) + floor), 0),
|
||||
);
|
||||
// This is a deterministic replay of recorded weights, not a timing test.
|
||||
// The runner's existing watchdog remains 20 minutes.
|
||||
expect(Math.max(...loads)).toBeLessThan(20 * 60);
|
||||
const shards = allShards(ALL_CROSS_PLATFORM, SHARD_TOTAL);
|
||||
const heavyweightLocations = [
|
||||
'test/integration/cli-e2e.test.ts',
|
||||
'test/integration/skills-e2e.test.ts',
|
||||
'test/unit/incremental-index-extension-dml-gate.test.ts',
|
||||
].map((file) => shards.findIndex((files) => files.includes(file)));
|
||||
expect(heavyweightLocations).not.toContain(-1);
|
||||
expect(new Set(heavyweightLocations).size).toBe(SHARD_TOTAL);
|
||||
});
|
||||
|
||||
it('covers every file exactly once, with no overlap between shards', () => {
|
||||
const shards = allShards(ALL_CROSS_PLATFORM, SHARD_TOTAL);
|
||||
const seen = shards.flatMap((s) => [...s]);
|
||||
|
|
@ -48,7 +75,7 @@ describe('cross-platform shard partition', () => {
|
|||
expect(Math.max(...weights)).toBeLessThanOrEqual(ideal * 1.34);
|
||||
});
|
||||
|
||||
it('never puts the two heaviest suites on the same shard', () => {
|
||||
it('keeps the CLI and worker-pool suites on different shards', () => {
|
||||
// The exact shape of the outage: cli-e2e and worker-pool are 621 s and
|
||||
// 222 s, so together they are most of a shard's budget before anything else
|
||||
// is scheduled.
|
||||
|
|
|
|||
|
|
@ -274,17 +274,14 @@ describe('PARSE_CACHE_VERSION', () => {
|
|||
// edge is emitted, the boundary probe measures a real zero, and `impact` on a
|
||||
// registered accessor goes back to `epistemic: "exact"`: the #3399 defect,
|
||||
// silently un-fixed on exactly the incremental path most users are on.
|
||||
// 94-97 belong to #3179, which merged first; 98 is the next value above it.
|
||||
// Re-checked against origin/main and every open PR touching parse-cache.ts at
|
||||
// merge time — the rule the paragraphs above were written by PRs that each
|
||||
// checked only once — and the remaining open claims (#3190 at 94, #2840 at
|
||||
// 71, #1616 at 2) all sit below main and must re-bump themselves.
|
||||
it('pins SCHEMA_BUMP to 98 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(98);
|
||||
// Moved 98 -> 99 for #3190: lexical import provenance and corrected export
|
||||
// evidence. origin/main took 98 for #3219; 99 is the next free value.
|
||||
it('pins SCHEMA_BUMP to 99 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(99);
|
||||
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
|
||||
for (const taken of [
|
||||
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
|
||||
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97,
|
||||
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98,
|
||||
]) {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
|
||||
}
|
||||
|
|
|
|||
325
gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts
Normal file
325
gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
/**
|
||||
* `@declaration.is-exported` — the export-evidence marker the TypeScript and
|
||||
* JavaScript capture emitters synthesize (`ts-js-export-marker.ts`), and its
|
||||
* landing on `SymbolDefinition.isExported` through the central extractor.
|
||||
* Review findings on #3182 (typescript/scope-resolver.ts:138).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { emitTsScopeCaptures } from '../../../src/core/ingestion/languages/typescript/captures.js';
|
||||
import { emitJsScopeCaptures } from '../../../src/core/ingestion/languages/javascript/captures.js';
|
||||
import { extract } from '../../../src/core/ingestion/scope-extractor.js';
|
||||
import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js';
|
||||
|
||||
type Emit = typeof emitTsScopeCaptures;
|
||||
|
||||
function verdicts(emit: Emit, src: string, filePath: string): Record<string, string | undefined> {
|
||||
const out = Object.create(null) as Record<string, string | undefined>;
|
||||
for (const m of emit(src, filePath)) {
|
||||
const name = m['@declaration.name']?.text;
|
||||
if (name === undefined) continue;
|
||||
if (Object.hasOwn(out, name) && out[name] !== undefined) continue;
|
||||
out[name] = m['@declaration.is-exported']?.text;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const ESM = `
|
||||
export function a() {}
|
||||
function b() {}
|
||||
const c = () => 1;
|
||||
export const d = 2;
|
||||
function e() {}
|
||||
export { c, e as renamed };
|
||||
function f() {}
|
||||
export default f;
|
||||
function g() { function inner() {} }
|
||||
`;
|
||||
|
||||
describe('@declaration.is-exported (TypeScript emitter)', () => {
|
||||
it('marks direct, clause and default exports true and everything else false in an ESM file', () => {
|
||||
const v = verdicts(emitTsScopeCaptures, ESM, 'test.ts');
|
||||
expect(v.a).toBe('true');
|
||||
expect(v.b).toBe('false');
|
||||
expect(v.c).toBe('true');
|
||||
expect(v.d).toBe('true');
|
||||
expect(v.e).toBe('true');
|
||||
expect(v.f).toBe('true');
|
||||
expect(v.g).toBe('false');
|
||||
expect(v.inner).toBe('false');
|
||||
});
|
||||
|
||||
it('does not skip a declaration named toString as an Object.prototype hit', () => {
|
||||
const v = verdicts(emitTsScopeCaptures, 'export function toString() {}\n', 'test.ts');
|
||||
expect(v.toString).toBe('true');
|
||||
});
|
||||
|
||||
it('a member of an exported class is NOT itself exported; nested functions never are (magyargergo)', () => {
|
||||
const v = verdicts(
|
||||
emitTsScopeCaptures,
|
||||
'export class Unrelated { clash() {} }\nfunction wrapper() { function selected() {} }\nexport { selected };\nconst selected = 1;\n',
|
||||
'test.ts',
|
||||
);
|
||||
expect(v.Unrelated).toBe('true');
|
||||
expect(v.clash).toBe('false');
|
||||
expect(v.wrapper).toBe('false');
|
||||
// Two `selected`s: the module-level one is exported by the clause, the
|
||||
// nested one is not — `verdicts` keeps the first non-undefined per name, so
|
||||
// look them up individually.
|
||||
const all = emitTsScopeCaptures(
|
||||
'function wrapper() { function selected() {} }\nexport { selected };\nconst selected = 1;\n',
|
||||
'test.ts',
|
||||
).filter((m) => m['@declaration.name']?.text === 'selected');
|
||||
expect(all.map((m) => m['@declaration.is-exported']?.text).sort()).toEqual(['false', 'true']);
|
||||
});
|
||||
|
||||
it("a method of the `module.exports = { … }` object literal IS that module's export (magyargergo)", () => {
|
||||
const v = verdicts(
|
||||
emitJsScopeCaptures,
|
||||
'function helper() {}\nmodule.exports = { alpha() { return 1; }, beta: () => 2 };\n',
|
||||
'lib.js',
|
||||
);
|
||||
expect(v.alpha).toBe('true');
|
||||
expect(v.beta).toBe('true');
|
||||
expect(v.helper).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits NO verdict for a CommonJS file — `module.exports` is an export surface it cannot read', () => {
|
||||
const v = verdicts(
|
||||
emitTsScopeCaptures,
|
||||
'function a() {}\nfunction b() {}\nmodule.exports = { a };\n',
|
||||
'test.ts',
|
||||
);
|
||||
expect(v.a).toBeUndefined();
|
||||
expect(v.b).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits NO verdict for an ambient .d.ts', () => {
|
||||
const v = verdicts(emitTsScopeCaptures, 'declare function a(): void;\n', 'lib.d.ts');
|
||||
expect(v.a).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not let a file that STARTS with `export` mark everything exported (the text-prefix trap)', () => {
|
||||
const v = verdicts(
|
||||
emitTsScopeCaptures,
|
||||
'export const x = 1;\nfunction hidden() {}\n',
|
||||
'test.ts',
|
||||
);
|
||||
expect(v.x).toBe('true');
|
||||
expect(v.hidden).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('@declaration.is-exported — Opus review follow-ups', () => {
|
||||
it('a re-export FROM another module never marks a same-named local exported', () => {
|
||||
const v = verdicts(
|
||||
emitTsScopeCaptures,
|
||||
"export { alpha } from './other';\nexport { beta as gamma } from './o';\nexport type { T } from './t';\nfunction alpha() {}\nfunction beta() {}\nfunction gamma() {}\ntype T = number;\nexport const keep = 1;\n",
|
||||
'test.ts',
|
||||
);
|
||||
expect(v.alpha).not.toBe('true');
|
||||
expect(v.beta).not.toBe('true');
|
||||
expect(v.gamma).not.toBe('true');
|
||||
expect(v.T).not.toBe('true');
|
||||
expect(v.alpha).toBe('false');
|
||||
expect(v.keep).toBe('true');
|
||||
});
|
||||
|
||||
it('exports inside a namespace or ambient module body are not file-level exports', () => {
|
||||
const v = verdicts(
|
||||
emitTsScopeCaptures,
|
||||
"export namespace NS { export function f() {} }\ndeclare module 'x' { export function q(): void; }\nexport function top() {}\n",
|
||||
'test.ts',
|
||||
);
|
||||
expect(v.NS).toBe('true');
|
||||
expect(v.f).not.toBe('true');
|
||||
expect(v.q).not.toBe('true');
|
||||
expect(v.top).toBe('true');
|
||||
});
|
||||
|
||||
it('a comment or string mentioning module.exports does not silence the ESM verdicts', () => {
|
||||
const v = verdicts(
|
||||
emitTsScopeCaptures,
|
||||
"// legacy: module.exports = api\nconst note = 'exports.x = 1';\nexport function a() {}\nfunction b() {}\n",
|
||||
'test.ts',
|
||||
);
|
||||
expect(v.a).toBe('true');
|
||||
expect(v.b).toBe('false');
|
||||
// ...while a real alias of the export object still does.
|
||||
const cjs = verdicts(
|
||||
emitJsScopeCaptures,
|
||||
'const m = module.exports;\nfunction b() {}\nm.b = b;\n',
|
||||
'x.js',
|
||||
);
|
||||
expect(cjs.b).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('@declaration.is-exported (JavaScript emitter)', () => {
|
||||
it('keeps bracket-form top-level this exports undecided like dot-form exports', () => {
|
||||
for (const [emit, file] of [
|
||||
[emitJsScopeCaptures, 'x.js'],
|
||||
[emitTsScopeCaptures, 'x.ts'],
|
||||
] as const) {
|
||||
const v = verdicts(emit, "function api() {} this['api'] = api;", file);
|
||||
expect(v.api).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a top-level conditional this assignment as CommonJS', () => {
|
||||
for (const [emit, file] of [
|
||||
[emitJsScopeCaptures, 'x.js'],
|
||||
[emitTsScopeCaptures, 'x.ts'],
|
||||
] as const) {
|
||||
for (const src of [
|
||||
'function api() {}\nif (enabled) this.api = api;\nfunction hidden() {}',
|
||||
'function api() {}\nif (enabled) { this.api = api; }\nfunction hidden() {}',
|
||||
]) {
|
||||
const v = verdicts(emit, src, file);
|
||||
expect(v.api).toBeUndefined();
|
||||
expect(v.hidden).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not treat this assignments inside a function as module exports', () => {
|
||||
const v = verdicts(
|
||||
emitJsScopeCaptures,
|
||||
'function wrapper() { if (enabled) this.api = api; }\nfunction hidden() {}',
|
||||
'x.js',
|
||||
);
|
||||
expect(v.hidden).toBe('false');
|
||||
});
|
||||
|
||||
it('recognizes exported module-scoped var inside a block, but not block let or function-local var', () => {
|
||||
for (const [emit, file] of [
|
||||
[emitJsScopeCaptures, 'x.js'],
|
||||
[emitTsScopeCaptures, 'x.ts'],
|
||||
] as const) {
|
||||
const v = verdicts(
|
||||
emit,
|
||||
'if (ok) { var visible = 1; let hidden = 2; } function wrapper() { var nested = 3; } export { visible, hidden, nested };',
|
||||
file,
|
||||
);
|
||||
expect(v.visible).toBe('true');
|
||||
expect(v.hidden).toBe('false');
|
||||
expect(v.nested).toBe('false');
|
||||
}
|
||||
});
|
||||
|
||||
it('marks synthesized default-export HOC declarations in both emitters', () => {
|
||||
for (const [emit, file] of [
|
||||
[emitJsScopeCaptures, 'Widget.jsx'],
|
||||
[emitTsScopeCaptures, 'Widget.tsx'],
|
||||
] as const) {
|
||||
const captures = emit('export default memo(() => <div />);', file).filter(
|
||||
(capture) => capture['@declaration.function'] !== undefined,
|
||||
);
|
||||
expect(captures.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
captures.every((capture) => capture['@declaration.is-exported']?.text === 'true'),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
'function wrapper(module) { module.exports = {}; }',
|
||||
"function wrapper(module) { module['exports'] = {}; }",
|
||||
'function wrapper(exports) { exports.alpha = 1; }',
|
||||
"const wrapper = exports => { exports['alpha'] = 1; };",
|
||||
'function wrapper() { const module = {}; module.exports = {}; }',
|
||||
'function wrapper() { { module.exports = {}; let module; } }',
|
||||
'function wrapper({ receiver: module }) { module.exports = {}; }',
|
||||
'const module = {}; module.exports = { alpha() {} };',
|
||||
"import module from './other'; module.exports = {};",
|
||||
"import { receiver as exports } from './other'; exports.alpha = 1;",
|
||||
"import * as module from './other'; module.exports = {};",
|
||||
'const wrapper = function module() { module.exports = {}; };',
|
||||
'for (const exports of values) { exports.x = 1; }',
|
||||
'for (const module of values) { module.exports = {}; }',
|
||||
])('ignores a shadowed CommonJS receiver: %s', (source) => {
|
||||
for (const [emit, file] of [
|
||||
[emitJsScopeCaptures, 'x.js'],
|
||||
[emitTsScopeCaptures, 'x.ts'],
|
||||
] as const) {
|
||||
const v = verdicts(
|
||||
emit,
|
||||
`${source}\nexport function publicApi() {}\nfunction hidden() {}`,
|
||||
file,
|
||||
);
|
||||
expect(v.publicApi).toBe('true');
|
||||
expect(v.hidden).toBe('false');
|
||||
}
|
||||
});
|
||||
|
||||
it('retains real CommonJS writes inside an unshadowed wrapper', () => {
|
||||
const v = verdicts(
|
||||
emitJsScopeCaptures,
|
||||
'function wrapper() { module.exports = {}; }\nfunction hidden() {}',
|
||||
'x.js',
|
||||
);
|
||||
expect(v.hidden).toBeUndefined();
|
||||
});
|
||||
|
||||
it('an imported source name does not shadow a different local alias', () => {
|
||||
const v = verdicts(
|
||||
emitJsScopeCaptures,
|
||||
"import { module as other } from './other'; module.exports = {}; function hidden() {}",
|
||||
'x.js',
|
||||
);
|
||||
expect(v.hidden).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["module['exports']", 'module["exports"]'])(
|
||||
'recognizes %s object methods and properties as exports',
|
||||
(target) => {
|
||||
const v = verdicts(
|
||||
emitJsScopeCaptures,
|
||||
`function helper() {}\n${target} = { alpha() { return 1; }, beta: () => 2 };\n`,
|
||||
'lib.js',
|
||||
);
|
||||
expect(v.alpha).toBe('true');
|
||||
expect(v.beta).toBe('true');
|
||||
expect(v.helper).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not confuse dynamic or unrelated module subscripts with exports', () => {
|
||||
for (const target of ['module[key]', "module['other']"]) {
|
||||
const v = verdicts(emitJsScopeCaptures, `${target} = {};\nfunction hidden() {}`, 'lib.js');
|
||||
expect(v.hidden).toBe('false');
|
||||
}
|
||||
});
|
||||
|
||||
it('marks ESM declarations', () => {
|
||||
const v = verdicts(emitJsScopeCaptures, ESM, 'test.js');
|
||||
expect(v.a).toBe('true');
|
||||
expect(v.b).toBe('false');
|
||||
expect(v.e).toBe('true');
|
||||
expect(v.f).toBe('true');
|
||||
});
|
||||
|
||||
it('stays silent for `exports.x =` files', () => {
|
||||
const v = verdicts(emitJsScopeCaptures, 'function a() {}\nexports.a = a;\n', 'test.js');
|
||||
expect(v.a).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SymbolDefinition.isExported through the extractor', () => {
|
||||
it('lands as a tri-state field: true / false / absent', () => {
|
||||
const esm = extract(
|
||||
emitTsScopeCaptures('export function a() {}\nfunction b() {}\n', 'x.ts'),
|
||||
'x.ts',
|
||||
typescriptScopeResolver,
|
||||
);
|
||||
const byName = new Map(esm.localDefs.map((d) => [d.qualifiedName, d.isExported]));
|
||||
expect(byName.get('a')).toBe(true);
|
||||
expect(byName.get('b')).toBe(false);
|
||||
const cjs = extract(
|
||||
emitTsScopeCaptures('function a() {}\nmodule.exports = a;\n', 'y.ts'),
|
||||
'y.ts',
|
||||
typescriptScopeResolver,
|
||||
);
|
||||
expect(cjs.localDefs.find((d) => d.qualifiedName === 'a')?.isExported).toBeUndefined();
|
||||
expect('isExported' in cjs.localDefs.find((d) => d.qualifiedName === 'a')!).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,469 @@
|
|||
/**
|
||||
* Review findings on #3182 (free-call-fallback.ts:710, bot + magyargergo): the
|
||||
* free-call CALLS edge is deduplicated per (caller, callee), and its
|
||||
* confidence/reason used to be whatever the FIRST collapsed site decided, so
|
||||
* `alpha(); precise();` and `precise(); alpha();` produced different edges for
|
||||
* the same dependency. Now the label is decided from every collapsed site: one
|
||||
* site resolved through a real binding PROVES the edge (0.85 /
|
||||
* `import-resolved`); it is a guess (0.5 / `global-name-fallback`) only when
|
||||
* every site was one. Order never decides.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildDefIndex,
|
||||
buildMethodDispatchIndex,
|
||||
buildModuleScopeIndex,
|
||||
buildQualifiedNameIndex,
|
||||
buildScopeTree,
|
||||
type NodeLabel,
|
||||
type ParsedFile,
|
||||
type Range,
|
||||
type ReferenceSite,
|
||||
type Scope,
|
||||
type ScopeId,
|
||||
type SymbolDefinition,
|
||||
} from 'gitnexus-shared';
|
||||
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
|
||||
import type { KnowledgeGraph } from '../../../src/core/graph/types.js';
|
||||
import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js';
|
||||
import { buildGraphNodeLookup } from '../../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js';
|
||||
import { emitFreeCallFallback } from '../../../src/core/ingestion/scope-resolution/passes/free-call-fallback.js';
|
||||
import { buildWorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js';
|
||||
import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js';
|
||||
import { GLOBAL_NAME_FALLBACK_REASON } from '../../../src/core/graph/edge-reasons.js';
|
||||
import {
|
||||
formatNameFallbackSummary,
|
||||
summarizeNameFallback,
|
||||
} from '../../../src/core/ingestion/scope-resolution/name-fallback-summary.js';
|
||||
import type { ResolutionOutcome } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js';
|
||||
|
||||
const CALLER_FILE = 'caller.ts';
|
||||
const TARGET_FILE = 'target.ts';
|
||||
|
||||
const range = (sl: number, sc: number, el = sl, ec = sc + 6): Range => ({
|
||||
startLine: sl,
|
||||
startCol: sc,
|
||||
endLine: el,
|
||||
endCol: ec,
|
||||
});
|
||||
|
||||
const targetDef: SymbolDefinition = {
|
||||
nodeId: 'def:helper',
|
||||
filePath: TARGET_FILE,
|
||||
type: 'Function',
|
||||
qualifiedName: 'helper',
|
||||
};
|
||||
const callerDef: SymbolDefinition = {
|
||||
nodeId: 'def:main',
|
||||
filePath: CALLER_FILE,
|
||||
type: 'Function',
|
||||
qualifiedName: 'main',
|
||||
};
|
||||
|
||||
/** `h()` — resolved PRECISELY through an aliased binding `h → helper`. */
|
||||
const preciseSite = (line: number): ReferenceSite => ({
|
||||
name: 'h',
|
||||
atRange: range(line, 2),
|
||||
inScope: 'scope:caller-mod',
|
||||
kind: 'call',
|
||||
callForm: 'free',
|
||||
arity: 0,
|
||||
});
|
||||
/** `helper()` — no binding in scope; only the global unique-name GUESS reaches it. */
|
||||
const guessedSite = (line: number): ReferenceSite => ({
|
||||
name: 'helper',
|
||||
atRange: range(line, 2),
|
||||
inScope: 'scope:caller-mod',
|
||||
kind: 'call',
|
||||
callForm: 'free',
|
||||
arity: 0,
|
||||
});
|
||||
|
||||
function mkScope(
|
||||
id: ScopeId,
|
||||
filePath: string,
|
||||
ownedDefs: SymbolDefinition[],
|
||||
bindings: Scope['bindings'],
|
||||
): Scope {
|
||||
return {
|
||||
id,
|
||||
parent: null,
|
||||
kind: 'Module',
|
||||
range: range(1, 0, 100, 0),
|
||||
filePath,
|
||||
bindings,
|
||||
ownedDefs,
|
||||
imports: [],
|
||||
typeBindings: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function fnNode(graph: KnowledgeGraph, id: string, name: string, filePath: string): void {
|
||||
graph.addNode({
|
||||
id,
|
||||
label: 'Function' as NodeLabel,
|
||||
properties: { name, filePath, qualifiedName: name },
|
||||
});
|
||||
}
|
||||
|
||||
function run(sites: readonly ReferenceSite[]) {
|
||||
const callerScope = mkScope(
|
||||
'scope:caller-mod',
|
||||
CALLER_FILE,
|
||||
[callerDef],
|
||||
new Map([['h', [{ def: targetDef, origin: 'import' as const }]]]),
|
||||
);
|
||||
const targetScope = mkScope('scope:target-mod', TARGET_FILE, [targetDef], new Map());
|
||||
const callerParsed: ParsedFile = {
|
||||
filePath: CALLER_FILE,
|
||||
moduleScope: 'scope:caller-mod',
|
||||
scopes: [callerScope],
|
||||
parsedImports: [],
|
||||
localDefs: [callerDef],
|
||||
referenceSites: sites,
|
||||
};
|
||||
const targetParsed: ParsedFile = {
|
||||
filePath: TARGET_FILE,
|
||||
moduleScope: 'scope:target-mod',
|
||||
scopes: [targetScope],
|
||||
parsedImports: [],
|
||||
localDefs: [targetDef],
|
||||
referenceSites: [],
|
||||
};
|
||||
const scopes = [callerScope, targetScope];
|
||||
const allDefs = [callerDef, targetDef];
|
||||
const indexes = {
|
||||
scopeTree: buildScopeTree(scopes),
|
||||
defs: buildDefIndex(allDefs),
|
||||
qualifiedNames: buildQualifiedNameIndex(allDefs),
|
||||
moduleScopes: buildModuleScopeIndex(
|
||||
scopes.map((s) => ({ filePath: s.filePath, moduleScopeId: s.id })),
|
||||
),
|
||||
methodDispatch: buildMethodDispatchIndex({
|
||||
owners: [],
|
||||
computeMro: () => [],
|
||||
implementsOf: () => [],
|
||||
}),
|
||||
imports: new Map(),
|
||||
bindings: new Map(),
|
||||
bindingAugmentations: new Map(),
|
||||
workspaceFqnBindings: new Map(),
|
||||
workspaceTypeBindings: new Map(),
|
||||
namespaceFqnBindings: new Map(),
|
||||
namespaceTypeBindings: new Map(),
|
||||
accessibleNamespacesByScope: new Map(),
|
||||
referenceSites: [],
|
||||
sccs: [],
|
||||
stats: {
|
||||
totalFiles: 2,
|
||||
totalEdges: 0,
|
||||
linkedEdges: 0,
|
||||
unresolvedEdges: 0,
|
||||
sccCount: 0,
|
||||
largestSccSize: 0,
|
||||
ambiguousWildcardExports: [],
|
||||
},
|
||||
} as unknown as ScopeResolutionIndexes;
|
||||
const graph = createKnowledgeGraph();
|
||||
fnNode(graph, 'fn:main', 'main', CALLER_FILE);
|
||||
fnNode(graph, 'fn:helper', 'helper', TARGET_FILE);
|
||||
const outcomes: ResolutionOutcome[] = [];
|
||||
emitFreeCallFallback(
|
||||
graph,
|
||||
indexes,
|
||||
[callerParsed, targetParsed],
|
||||
buildGraphNodeLookup(graph),
|
||||
{ bySourceScope: new Map() },
|
||||
new Set<string>(),
|
||||
createSemanticModel(),
|
||||
buildWorkspaceResolutionIndex([callerParsed, targetParsed]),
|
||||
{ allowGlobalFallback: true, recordResolutionOutcome: (o) => outcomes.push(o) },
|
||||
);
|
||||
const calls = graph.relationships.filter((r) => r.type === 'CALLS');
|
||||
return { calls, outcomes };
|
||||
}
|
||||
|
||||
describe('free-call dedup: the label is decided from every collapsed site, never by order', () => {
|
||||
it('control — a lone precise site is import-resolved at 0.85', () => {
|
||||
const { calls } = run([preciseSite(3)]);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.confidence).toBe(0.85);
|
||||
expect(calls[0]!.reason).toBe('import-resolved');
|
||||
});
|
||||
|
||||
it('control — a lone guessed site is labeled at 0.5', () => {
|
||||
const { calls, outcomes } = run([guessedSite(3)]);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.confidence).toBe(0.5);
|
||||
expect(calls[0]!.reason).toBe(GLOBAL_NAME_FALLBACK_REASON);
|
||||
expect(outcomes.map((o) => o.kind)).toEqual(['fallback-guessed']);
|
||||
});
|
||||
|
||||
it('guess FIRST, precise second: the precise site proves the edge — 0.85 import-resolved', () => {
|
||||
const { calls } = run([guessedSite(3), preciseSite(4)]);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.confidence).toBe(0.85);
|
||||
expect(calls[0]!.reason).toBe('import-resolved');
|
||||
});
|
||||
|
||||
it('precise FIRST, guess second: identical — a redundant guess does not taint a proven edge', () => {
|
||||
const { calls } = run([preciseSite(3), guessedSite(4)]);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.confidence).toBe(0.85);
|
||||
expect(calls[0]!.reason).toBe('import-resolved');
|
||||
});
|
||||
|
||||
it('two guessed sites stay a guess', () => {
|
||||
const { calls } = run([guessedSite(3), guessedSite(4)]);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.confidence).toBe(0.5);
|
||||
expect(calls[0]!.reason).toBe(GLOBAL_NAME_FALLBACK_REASON);
|
||||
});
|
||||
|
||||
it('reports guessed sites without claiming they are final guessed edges', () => {
|
||||
for (const sites of [
|
||||
[guessedSite(3), preciseSite(4)],
|
||||
[preciseSite(3), guessedSite(4)],
|
||||
]) {
|
||||
const { calls, outcomes } = run(sites);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.reason).toBe('import-resolved');
|
||||
const summary = summarizeNameFallback(outcomes);
|
||||
expect(summary?.totalGuessed).toBe(1);
|
||||
const line = formatNameFallbackSummary(summary);
|
||||
expect(line).toContain('name-fallback resolution: 1 call site (');
|
||||
expect(line).not.toContain('CALLS edges');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const classTarget: SymbolDefinition = {
|
||||
nodeId: 'def:UniqueWidget',
|
||||
filePath: TARGET_FILE,
|
||||
type: 'Class',
|
||||
qualifiedName: 'UniqueWidget',
|
||||
};
|
||||
|
||||
const ctorGuessedSite = (line: number): ReferenceSite => ({
|
||||
name: 'UniqueWidget',
|
||||
atRange: range(line, 2),
|
||||
inScope: 'scope:caller-mod',
|
||||
kind: 'call',
|
||||
callForm: 'constructor',
|
||||
arity: 0,
|
||||
});
|
||||
|
||||
function classNode(graph: KnowledgeGraph, id: string, name: string, filePath: string): void {
|
||||
graph.addNode({
|
||||
id,
|
||||
label: 'Class' as NodeLabel,
|
||||
properties: { name, filePath, qualifiedName: name },
|
||||
});
|
||||
}
|
||||
|
||||
function runConstructor(
|
||||
sites: readonly ReferenceSite[],
|
||||
options: {
|
||||
readonly bindImportedClass?: boolean;
|
||||
readonly importTargetFile?: string;
|
||||
readonly importExportedName?: string;
|
||||
readonly importTargetDefId?: string;
|
||||
readonly callerFile?: string;
|
||||
readonly targetFile?: string;
|
||||
readonly isGlobalNameFallbackPlausible?: () => boolean;
|
||||
} = {},
|
||||
) {
|
||||
const callerFile = options.callerFile ?? CALLER_FILE;
|
||||
const targetFile = options.targetFile ?? TARGET_FILE;
|
||||
const classDef = { ...classTarget, filePath: targetFile };
|
||||
const caller = { ...callerDef, filePath: callerFile };
|
||||
const callerScope = mkScope(
|
||||
'scope:caller-mod',
|
||||
callerFile,
|
||||
[caller],
|
||||
options.bindImportedClass === true
|
||||
? new Map([['UniqueWidget', [{ def: classDef, origin: 'import' as const }]]])
|
||||
: new Map(),
|
||||
);
|
||||
const targetScope = mkScope('scope:target-mod', targetFile, [classDef], new Map());
|
||||
const callerParsed: ParsedFile = {
|
||||
filePath: callerFile,
|
||||
moduleScope: 'scope:caller-mod',
|
||||
scopes: [callerScope],
|
||||
parsedImports: [],
|
||||
localDefs: [caller],
|
||||
referenceSites: sites,
|
||||
};
|
||||
const targetParsed: ParsedFile = {
|
||||
filePath: targetFile,
|
||||
moduleScope: 'scope:target-mod',
|
||||
scopes: [targetScope],
|
||||
parsedImports: [],
|
||||
localDefs: [classDef],
|
||||
referenceSites: [],
|
||||
};
|
||||
const scopes = [callerScope, targetScope];
|
||||
const allDefs = [caller, classDef];
|
||||
const indexes = {
|
||||
scopeTree: buildScopeTree(scopes),
|
||||
defs: buildDefIndex(allDefs),
|
||||
qualifiedNames: buildQualifiedNameIndex(allDefs),
|
||||
moduleScopes: buildModuleScopeIndex(
|
||||
scopes.map((s) => ({ filePath: s.filePath, moduleScopeId: s.id })),
|
||||
),
|
||||
methodDispatch: buildMethodDispatchIndex({
|
||||
owners: [],
|
||||
computeMro: () => [],
|
||||
implementsOf: () => [],
|
||||
}),
|
||||
imports: new Map(
|
||||
options.importTargetFile !== undefined
|
||||
? [
|
||||
[
|
||||
'scope:caller-mod',
|
||||
[
|
||||
{
|
||||
localName: options.importExportedName ?? 'UniqueWidget',
|
||||
targetFile: options.importTargetFile,
|
||||
targetExportedName: options.importExportedName ?? 'UniqueWidget',
|
||||
...(options.importTargetDefId !== undefined
|
||||
? { targetDefId: options.importTargetDefId }
|
||||
: {}),
|
||||
kind: 'named' as const,
|
||||
},
|
||||
],
|
||||
],
|
||||
]
|
||||
: [],
|
||||
),
|
||||
bindings: new Map(
|
||||
options.bindImportedClass === true
|
||||
? [['scope:caller-mod', new Map([['UniqueWidget', [{ def: classDef, origin: 'import' }]]])]]
|
||||
: [],
|
||||
),
|
||||
bindingAugmentations: new Map(),
|
||||
workspaceFqnBindings: new Map(),
|
||||
workspaceTypeBindings: new Map(),
|
||||
namespaceFqnBindings: new Map(),
|
||||
namespaceTypeBindings: new Map(),
|
||||
accessibleNamespacesByScope: new Map(),
|
||||
referenceSites: [],
|
||||
sccs: [],
|
||||
stats: {
|
||||
totalFiles: 2,
|
||||
totalEdges: 0,
|
||||
linkedEdges: 0,
|
||||
unresolvedEdges: 0,
|
||||
sccCount: 0,
|
||||
largestSccSize: 0,
|
||||
ambiguousWildcardExports: [],
|
||||
},
|
||||
} as unknown as ScopeResolutionIndexes;
|
||||
const graph = createKnowledgeGraph();
|
||||
fnNode(graph, 'fn:main', 'main', callerFile);
|
||||
classNode(graph, 'cls:UniqueWidget', 'UniqueWidget', targetFile);
|
||||
const outcomes: ResolutionOutcome[] = [];
|
||||
emitFreeCallFallback(
|
||||
graph,
|
||||
indexes,
|
||||
[callerParsed, targetParsed],
|
||||
buildGraphNodeLookup(graph),
|
||||
{ bySourceScope: new Map() },
|
||||
new Set<string>(),
|
||||
createSemanticModel(),
|
||||
buildWorkspaceResolutionIndex([callerParsed, targetParsed]),
|
||||
{
|
||||
allowGlobalFallback: true,
|
||||
recordResolutionOutcome: (o) => outcomes.push(o),
|
||||
isGlobalNameFallbackPlausible: options.isGlobalNameFallbackPlausible,
|
||||
},
|
||||
);
|
||||
const calls = graph.relationships.filter((r) => r.type === 'CALLS');
|
||||
return { calls, outcomes };
|
||||
}
|
||||
|
||||
describe('constructor-form unique-name hits are guesses, not import-resolved', () => {
|
||||
it('labels a unique class with no lexical binding as a name guess', () => {
|
||||
const { calls, outcomes } = runConstructor([ctorGuessedSite(3)]);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.confidence).toBe(0.5);
|
||||
expect(calls[0]!.reason).toBe(GLOBAL_NAME_FALLBACK_REASON);
|
||||
expect(outcomes.map((o) => o.kind)).toEqual(['fallback-guessed']);
|
||||
});
|
||||
|
||||
it('refuses the same unique class when the language vetoes the guess', () => {
|
||||
const { calls, outcomes } = runConstructor([ctorGuessedSite(3)], {
|
||||
isGlobalNameFallbackPlausible: () => false,
|
||||
});
|
||||
expect(calls).toEqual([]);
|
||||
expect(outcomes.map((o) => o.kind)).toEqual(['fallback-refused']);
|
||||
});
|
||||
|
||||
it('keeps an imported constructor type as a precise import-resolved edge', () => {
|
||||
const { calls, outcomes } = runConstructor([ctorGuessedSite(3)], { bindImportedClass: true });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.confidence).toBe(0.85);
|
||||
expect(calls[0]!.reason).toBe('import-resolved');
|
||||
expect(outcomes).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats a unique class with a written qualifier as precise, not a bare guess', () => {
|
||||
const { calls, outcomes } = runConstructor([
|
||||
{ ...ctorGuessedSite(3), rawQualifiedName: 'pkg.UniqueWidget' },
|
||||
]);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.confidence).toBe(0.85);
|
||||
expect(calls[0]!.reason).toBe('import-resolved');
|
||||
expect(outcomes).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats a unique class as precise when an import reaches its file', () => {
|
||||
const { calls, outcomes } = runConstructor([ctorGuessedSite(3)], {
|
||||
importTargetFile: TARGET_FILE,
|
||||
});
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.confidence).toBe(0.85);
|
||||
expect(calls[0]!.reason).toBe('import-resolved');
|
||||
expect(outcomes).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not treat a sibling-file import of a different name as constructor visibility', () => {
|
||||
const { calls, outcomes } = runConstructor([ctorGuessedSite(3)], {
|
||||
callerFile: 'src/main.rs',
|
||||
targetFile: 'src/models/handler.rs',
|
||||
importTargetFile: 'src/models/other.rs',
|
||||
importExportedName: 'bar',
|
||||
});
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.confidence).toBe(0.5);
|
||||
expect(calls[0]!.reason).toBe(GLOBAL_NAME_FALLBACK_REASON);
|
||||
expect(outcomes.map((o) => o.kind)).toEqual(['fallback-guessed']);
|
||||
});
|
||||
|
||||
it('treats a unique class as precise when the import names it via a re-export file', () => {
|
||||
const { calls, outcomes } = runConstructor([ctorGuessedSite(3)], {
|
||||
callerFile: 'src/main.rs',
|
||||
targetFile: 'src/models/handler.rs',
|
||||
importTargetFile: 'src/models/mod.rs',
|
||||
importExportedName: 'UniqueWidget',
|
||||
});
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.confidence).toBe(0.85);
|
||||
expect(calls[0]!.reason).toBe('import-resolved');
|
||||
expect(outcomes).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats a unique class as precise when finalize resolved the import to that def', () => {
|
||||
const { calls, outcomes } = runConstructor([ctorGuessedSite(3)], {
|
||||
callerFile: 'src/main.rs',
|
||||
targetFile: 'src/models/handler.rs',
|
||||
importTargetFile: 'src/models/mod.rs',
|
||||
importExportedName: 'Handler',
|
||||
importTargetDefId: classTarget.nodeId,
|
||||
});
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.confidence).toBe(0.85);
|
||||
expect(calls[0]!.reason).toBe('import-resolved');
|
||||
expect(outcomes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -217,6 +217,36 @@ func main() { fmt.Println() }
|
|||
expect(sources).toEqual(['fmt']);
|
||||
});
|
||||
|
||||
it('does not mark a bare generic constructor as package-qualified', () => {
|
||||
const src = `
|
||||
package main
|
||||
|
||||
type Box[T any] struct{}
|
||||
|
||||
func main() { _ = Box[int]{} }
|
||||
`;
|
||||
const refs = emitGoScopeCaptures(src, 'main.go').filter(
|
||||
(m) => m['@reference.call.constructor'] !== undefined,
|
||||
);
|
||||
expect(refs.some((m) => m['@reference.name']?.text === 'Box')).toBe(true);
|
||||
expect(refs.some((m) => m['@reference.qualified-name'] !== undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the written spelling for a package-qualified generic constructor', () => {
|
||||
const src = `
|
||||
package main
|
||||
|
||||
import "example.com/models"
|
||||
|
||||
func main() { _ = models.Box[int]{} }
|
||||
`;
|
||||
const box = emitGoScopeCaptures(src, 'main.go').find(
|
||||
(m) => m['@reference.call.constructor'] !== undefined && m['@reference.name']?.text === 'Box',
|
||||
);
|
||||
expect(box).toBeDefined();
|
||||
expect(box!['@reference.qualified-name']?.text).toBe('models.Box[int]');
|
||||
});
|
||||
|
||||
it('captures a generic function declaration', () => {
|
||||
const src = `
|
||||
package main
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { buildScopeTree, finalize, type FinalizeHooks, type ParsedFile } from 'gitnexus-shared';
|
||||
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
|
||||
import { emitImportEdges } from '../../../src/core/ingestion/scope-resolution/graph-bridge/imports-to-edges.js';
|
||||
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
|
||||
import { rustProvider } from '../../../src/core/ingestion/languages/rust.js';
|
||||
import { rustIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/languages/rust/name-fallback-visibility.js';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
persistParsedFileChunk,
|
||||
loadParsedFilesForPaths,
|
||||
} from '../../../src/storage/parsedfile-store.js';
|
||||
|
||||
function extract(source: string, filePath = 'src/caller.rs'): ParsedFile {
|
||||
const parsed = extractParsedFile(rustProvider, source, filePath);
|
||||
if (parsed === undefined) throw new Error(`Failed extraction: ${filePath}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const source = `
|
||||
mod importing {
|
||||
use crate::target::helper;
|
||||
pub fn allowed() { helper(); }
|
||||
}
|
||||
mod sibling {
|
||||
pub fn denied() { helper(); }
|
||||
}
|
||||
`;
|
||||
|
||||
function link(parsed: ParsedFile, lexical = true) {
|
||||
const target = extract('pub fn helper() {}', 'src/target.rs');
|
||||
const hooks: FinalizeHooks = {
|
||||
resolveImportTarget: (raw: string) =>
|
||||
raw.startsWith('crate::target') ? target.filePath : null,
|
||||
expandsWildcardTo: () => ['helper'],
|
||||
mergeBindings: (existing, incoming) => [...existing, ...incoming],
|
||||
importsBindAtLexicalScope: lexical,
|
||||
};
|
||||
return { target, out: finalize({ files: [parsed, target], workspaceIndex: undefined }, hooks) };
|
||||
}
|
||||
|
||||
describe('import lexical provenance', () => {
|
||||
it('keeps file dependency edges when import binding moves into a local scope', () => {
|
||||
const parsed = extract(source);
|
||||
const { target, out } = link(parsed);
|
||||
const graph = createKnowledgeGraph();
|
||||
emitImportEdges(
|
||||
graph,
|
||||
out.imports,
|
||||
buildScopeTree([...parsed.scopes, ...target.scopes]),
|
||||
'scope import',
|
||||
);
|
||||
const dependencies = graph.relationships.filter((edge) => edge.type === 'IMPORTS');
|
||||
expect(dependencies).toHaveLength(1);
|
||||
expect(dependencies[0]!.sourceId).toBe('File:src/caller.rs');
|
||||
expect(dependencies[0]!.targetId).toBe('File:src/target.rs');
|
||||
});
|
||||
|
||||
it('does not promote a local wildcard into the importing file export closure', () => {
|
||||
const parsed = extract('mod inner { use crate::target::*; }');
|
||||
const target = extract('pub fn helper() {}', 'src/target.rs');
|
||||
const consumer = extract('use crate::caller::helper;', 'src/consumer.rs');
|
||||
const out = finalize(
|
||||
{ files: [parsed, target, consumer], workspaceIndex: undefined },
|
||||
{
|
||||
importsBindAtLexicalScope: true,
|
||||
resolveImportTarget: (raw) =>
|
||||
raw.startsWith('crate::target') ? target.filePath : parsed.filePath,
|
||||
expandsWildcardTo: () => ['helper'],
|
||||
mergeBindings: (existing, incoming) => [...existing, ...incoming],
|
||||
},
|
||||
);
|
||||
expect(out.bindings.get(parsed.parsedImports[0]!.declaredAtScope!)?.has('helper')).toBe(true);
|
||||
expect(out.imports.get(consumer.moduleScope)?.[0]?.linkStatus).toBe('unresolved');
|
||||
expect(out.bindings.get(consumer.moduleScope)?.has('helper')).toBe(false);
|
||||
});
|
||||
|
||||
it('survives the worker/disk ParsedFile round-trip', async () => {
|
||||
const parsed = extract(source);
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'gn-import-provenance-'));
|
||||
try {
|
||||
await persistParsedFileChunk(dir, 'imports', [parsed]);
|
||||
const loaded = await loadParsedFilesForPaths(dir, new Set([parsed.filePath]));
|
||||
const restored = loaded.get(parsed.filePath)!;
|
||||
expect(restored.parsedImports).toEqual(parsed.parsedImports);
|
||||
const scopeId = restored.parsedImports[0]!.declaredAtScope!;
|
||||
expect(link(restored).out.bindings.get(scopeId)?.has('helper')).toBe(true);
|
||||
expect(link(restored).out.bindings.get(restored.moduleScope)?.has('helper')).toBe(false);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it('retains the declaration scope even for non-executing imports', () => {
|
||||
const parsed = extract(source);
|
||||
const imported = parsed.parsedImports[0]!;
|
||||
expect(imported.declaredAtScope).toBeDefined();
|
||||
expect(imported.declaredAtScope).not.toBe(parsed.moduleScope);
|
||||
expect(parsed.scopes.find((scope) => scope.id === imported.declaredAtScope)?.kind).toBe(
|
||||
'Namespace',
|
||||
);
|
||||
expect(imported.runsOnlyWhenCalled).toBeUndefined();
|
||||
expect(JSON.parse(JSON.stringify(imported)).declaredAtScope).toBe(imported.declaredAtScope);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'use crate::target::helper;',
|
||||
'use crate::target::helper as alias;',
|
||||
'use crate::target::*;',
|
||||
])('binds %s only in the importing scope', (declaration) => {
|
||||
const parsed = extract(source.replace('use crate::target::helper;', declaration));
|
||||
const { out } = link(parsed);
|
||||
const scope = parsed.parsedImports[0]!.declaredAtScope!;
|
||||
const name = declaration.includes('alias') ? 'alias' : 'helper';
|
||||
expect(out.bindings.get(scope)?.get(name)?.[0]?.origin).toMatch(/import|wildcard/);
|
||||
expect(out.bindings.get(parsed.moduleScope)?.has(name)).toBe(false);
|
||||
expect(out.imports.get(scope)).toHaveLength(1);
|
||||
expect(out.stats.totalEdges).toBe(1);
|
||||
expect(out.stats.linkedEdges).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves file-level binding for resolvers that have not opted in', () => {
|
||||
const parsed = extract(source);
|
||||
expect(link(parsed, false).out.bindings.get(parsed.moduleScope)?.has('helper')).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves legacy imports without a scope receipt', () => {
|
||||
const parsed = extract(source);
|
||||
const legacy = {
|
||||
...parsed,
|
||||
parsedImports: [
|
||||
{
|
||||
kind: 'named' as const,
|
||||
localName: 'helper',
|
||||
importedName: 'helper',
|
||||
targetRaw: 'crate::target::helper',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(link(legacy).out.bindings.get(parsed.moduleScope)?.has('helper')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not use a sibling inline module import to authorize a guess', () => {
|
||||
const parsed = extract(source);
|
||||
const { target } = link(parsed);
|
||||
const sites = parsed.referenceSites.filter(
|
||||
(site) => site.kind === 'call' && site.name === 'helper',
|
||||
);
|
||||
expect(sites).toHaveLength(2);
|
||||
const candidate = target.localDefs.find((def) => def.qualifiedName === 'helper')!;
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({ callerParsed: parsed, candidate, site: sites[0]! }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({ callerParsed: parsed, candidate, site: sites[1]! }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -18,13 +18,14 @@ function wrapExpr(expr: string): string {
|
|||
return `class C { void m() { ${expr}; } }`;
|
||||
}
|
||||
|
||||
/** All constructor-call matches in `src`, as `{ name, qualified, arity }`. */
|
||||
/** All constructor-call matches in `src`, as `{ name, qualified, qualifiedName, arity }`. */
|
||||
function ctorRefs(src: string) {
|
||||
return emitJavaScopeCaptures(src, 'C.java')
|
||||
.filter((m) => m['@reference.call.constructor'] !== undefined)
|
||||
.map((m) => ({
|
||||
name: m['@reference.name']?.text,
|
||||
qualified: m['@reference.call.constructor.qualified']?.text,
|
||||
qualifiedName: m['@reference.qualified-name']?.text,
|
||||
arity: m['@reference.arity']?.text,
|
||||
}));
|
||||
}
|
||||
|
|
@ -51,7 +52,12 @@ function recordAccessorDeclarations(src: string) {
|
|||
describe('emitJavaScopeCaptures — constructor reference names (F35 #1928)', () => {
|
||||
it('binds the simple name for an unqualified `new User()`', () => {
|
||||
const refs = ctorRefs(wrapExpr('new User()'));
|
||||
expect(refs).toContainEqual({ name: 'User', qualified: undefined, arity: '0' });
|
||||
expect(refs).toContainEqual({
|
||||
name: 'User',
|
||||
qualified: undefined,
|
||||
qualifiedName: undefined,
|
||||
arity: '0',
|
||||
});
|
||||
});
|
||||
|
||||
it('binds the simple-name tail for a qualified `new pkg.Foo()`', () => {
|
||||
|
|
@ -59,6 +65,7 @@ describe('emitJavaScopeCaptures — constructor reference names (F35 #1928)', ()
|
|||
const foo = refs.find((r) => r.name === 'Foo');
|
||||
expect(foo).toBeDefined();
|
||||
expect(foo!.qualified).toBe('pkg.Foo');
|
||||
expect(foo!.qualifiedName).toBe('pkg.Foo');
|
||||
// The name must be the bare tail, never the raw scoped text.
|
||||
expect(refs.some((r) => r.name === 'pkg.Foo')).toBe(false);
|
||||
});
|
||||
|
|
@ -68,6 +75,7 @@ describe('emitJavaScopeCaptures — constructor reference names (F35 #1928)', ()
|
|||
const foo = refs.find((r) => r.name === 'Foo');
|
||||
expect(foo).toBeDefined();
|
||||
expect(foo!.qualified).toBe('a.b.Foo');
|
||||
expect(foo!.qualifiedName).toBe('a.b.Foo');
|
||||
expect(refs.some((r) => r.name === 'a' || r.name === 'b')).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -76,6 +84,7 @@ describe('emitJavaScopeCaptures — constructor reference names (F35 #1928)', ()
|
|||
const box = refs.find((r) => r.name === 'Box');
|
||||
expect(box).toBeDefined();
|
||||
expect(box!.qualified).toBeUndefined();
|
||||
expect(box!.qualifiedName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('binds the simple-name tail for a qualified-generic `new pkg.Box<String>()`', () => {
|
||||
|
|
@ -83,6 +92,7 @@ describe('emitJavaScopeCaptures — constructor reference names (F35 #1928)', ()
|
|||
const box = refs.find((r) => r.name === 'Box');
|
||||
expect(box).toBeDefined();
|
||||
expect(box!.qualified).toBe('pkg.Box');
|
||||
expect(box!.qualifiedName).toBe('pkg.Box');
|
||||
expect(refs.some((r) => r.name === 'pkg.Box' || r.name === 'String')).toBe(false);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* 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 (');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,928 @@
|
|||
/**
|
||||
* Unit tests for the per-language `isGlobalNameFallbackPlausible` hooks and the
|
||||
* shared path arithmetic they are built on.
|
||||
*
|
||||
* These hooks decide whether a UNIQUE-NAME GUESS is allowed to become a labeled
|
||||
* CALLS edge or must be dropped as impossible. The asymmetry matters for how
|
||||
* these tests are written: a wrong `false` deletes a real edge, so every case
|
||||
* that the language cannot decide is asserted to return `true`. "Refuses when
|
||||
* impossible" and "does not refuse when merely unproven" are therefore BOTH
|
||||
* requirements, and both are tested per language.
|
||||
*
|
||||
* Pure functions over synthetic stubs — no pipeline, no fixtures.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ParsedFile, ParsedImport, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { goIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/languages/go/name-fallback-visibility.js';
|
||||
import { dartIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/languages/dart/name-fallback-visibility.js';
|
||||
import { rustIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/languages/rust/name-fallback-visibility.js';
|
||||
import { swiftIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/languages/swift/name-fallback-visibility.js';
|
||||
import { rubyIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/languages/ruby/name-fallback-visibility.js';
|
||||
import {
|
||||
directoryOf,
|
||||
modulePathReaches,
|
||||
moduleSegments,
|
||||
stripExtension,
|
||||
} from '../../../src/core/ingestion/scope-resolution/utils/name-fallback-visibility.js';
|
||||
|
||||
const namedImport = (targetRaw: string, localName = 'x'): ParsedImport => ({
|
||||
kind: 'named',
|
||||
localName,
|
||||
importedName: localName,
|
||||
targetRaw,
|
||||
});
|
||||
|
||||
const mkCaller = (
|
||||
filePath: string,
|
||||
imports: readonly ParsedImport[] = [],
|
||||
referenceSites: ParsedFile['referenceSites'] = [],
|
||||
localDefs: readonly SymbolDefinition[] = [],
|
||||
): ParsedFile =>
|
||||
({
|
||||
filePath,
|
||||
parsedImports: imports,
|
||||
referenceSites,
|
||||
localDefs,
|
||||
}) as unknown as ParsedFile;
|
||||
|
||||
/** A bare (unqualified) call site — the shape the name-guess tier exists for. */
|
||||
const BARE_SITE = { name: 'unique_helper_xyz' } as const;
|
||||
|
||||
const mkCandidate = (filePath: string, qualifiedName: string, ownerId?: string): SymbolDefinition =>
|
||||
({
|
||||
nodeId: `def:${filePath}:${qualifiedName}`,
|
||||
filePath,
|
||||
type: 'Function',
|
||||
qualifiedName,
|
||||
ownerId,
|
||||
}) as unknown as SymbolDefinition;
|
||||
|
||||
describe('shared path arithmetic', () => {
|
||||
it('splits module paths on /, :: and .', () => {
|
||||
expect(moduleSegments('a/b/c')).toEqual(['a', 'b', 'c']);
|
||||
expect(moduleSegments('crate::a::b')).toEqual(['crate', 'a', 'b']);
|
||||
expect(moduleSegments('com.example.Thing')).toEqual(['com', 'example', 'Thing']);
|
||||
});
|
||||
|
||||
it('does not split a path-bearing specifier on its extension dot', () => {
|
||||
expect(moduleSegments('./util/parse.js')).toEqual(['util', 'parse.js']);
|
||||
});
|
||||
|
||||
it('matches a written module prefix against a repo-relative directory', () => {
|
||||
// The written import carries a module prefix that is not a directory.
|
||||
expect(modulePathReaches('github.com/org/svc/internal/models', 'internal/models')).toBe(true);
|
||||
// ...and the reverse, when the module manifest sits in a subdirectory.
|
||||
expect(modulePathReaches('mod/internal/models', 'svc/internal/models')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match unrelated paths that merely share a middle segment', () => {
|
||||
expect(modulePathReaches('github.com/org/svc/internal/models', 'internal/handlers')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(modulePathReaches('a/b', 'c/d')).toBe(false);
|
||||
});
|
||||
|
||||
it('reports no match for an empty path on either side', () => {
|
||||
expect(modulePathReaches('', 'a/b')).toBe(false);
|
||||
expect(modulePathReaches('a/b', '')).toBe(false);
|
||||
});
|
||||
|
||||
it('derives directories and strips extensions', () => {
|
||||
expect(directoryOf('a/b/c.go')).toBe('a/b');
|
||||
expect(directoryOf('main.go')).toBe('');
|
||||
expect(stripExtension('a/b.rs')).toBe('a/b');
|
||||
expect(stripExtension('a/b')).toBe('a/b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go: isGlobalNameFallbackPlausible', () => {
|
||||
it('allows a qualified pkg.Export() that reached the guess tier', () => {
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('b/caller.go', [namedImport('example.com/mod/a')]),
|
||||
candidate: mkCandidate('a/helper.go', 'Export'),
|
||||
site: { rawQualifiedName: 'a.Export' },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('still REFUSES a qualified call to an unexported or test-only declaration', () => {
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('b/caller.go'),
|
||||
candidate: mkCandidate('a/helper.go', 'private'),
|
||||
site: { rawQualifiedName: 'a.private' },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('b/caller.go'),
|
||||
candidate: mkCandidate('a/helper_test.go', 'ExportedTestHelper'),
|
||||
site: { rawQualifiedName: 'a.ExportedTestHelper' },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('allows an external test package to dot-import exported production functions', () => {
|
||||
const callerParsed = mkCaller('foo/caller_test.go', [
|
||||
{ kind: 'wildcard', targetRaw: 'example.com/mod/foo' },
|
||||
]);
|
||||
const sourceTextOf = (file: string) =>
|
||||
file.endsWith('_test.go') ? 'package foo_test' : 'package foo';
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed,
|
||||
sourceTextOf,
|
||||
candidate: mkCandidate('foo/helper.go', 'Helper'),
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed,
|
||||
sourceTextOf,
|
||||
candidate: mkCandidate('foo/helper.go', 'helper'),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
it('REFUSES an unexported identifier from another package', () => {
|
||||
// The headline case: `a.uniqueHelperXyz` is invisible to package `b`, and no
|
||||
// import can make it visible, so the guess is impossible rather than weak.
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('b/caller.go'),
|
||||
candidate: mkCandidate('a/helper.go', 'uniqueHelperXyz'),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('REFUSES an unexported identifier even when the package IS imported', () => {
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('b/caller.go', [namedImport('example.com/mod/a')]),
|
||||
candidate: mkCandidate('a/helper.go', 'uniqueHelperXyz'),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('allows an unexported identifier inside the SAME package directory', () => {
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('a/caller.go'),
|
||||
candidate: mkCandidate('a/helper.go', 'uniqueHelperXyz'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('REFUSES an exported identifier when the caller never imports its package', () => {
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('b/caller.go', [namedImport('example.com/mod/unrelated')]),
|
||||
candidate: mkCandidate('a/helper.go', 'UniqueHelperXyz'),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('allows an exported identifier whose package the caller dot-imports', () => {
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('b/caller.go', [
|
||||
{ kind: 'wildcard', targetRaw: 'example.com/mod/a' },
|
||||
]),
|
||||
candidate: mkCandidate('a/helper.go', 'UniqueHelperXyz'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('REFUSES methods as bare calls even with a dot import', () => {
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('b/caller.go'),
|
||||
candidate: mkCandidate('a/helper.go', 'Host.doThing'),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('b/caller.go', [{ kind: 'wildcard', targetRaw: 'mod/a' }]),
|
||||
candidate: mkCandidate('a/helper.go', 'Host.DoThing'),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['a', 'renamed', '_'])(
|
||||
'REFUSES an ordinary/alias/blank import (%s) for a bare call',
|
||||
(localName) => {
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('b/caller.go', [
|
||||
{ kind: 'namespace', localName, importedName: 'a', targetRaw: 'mod/a' },
|
||||
]),
|
||||
candidate: mkCandidate('a/helper.go', 'UniqueHelperXyz'),
|
||||
}),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps internal tests of a production package named foo_test in the same package', () => {
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('a/caller_test.go'),
|
||||
candidate: mkCandidate('a/helper.go', 'uniqueHelperXyz'),
|
||||
sourceTextOf: () => 'package foo_test\n',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('REFUSES same-directory declarations when the known package clauses differ', () => {
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('a/caller_test.go'),
|
||||
candidate: mkCandidate('a/helper.go', 'UniqueHelperXyz'),
|
||||
sourceTextOf: (path) => (path.endsWith('_test.go') ? 'package a_test\n' : 'package a\n'),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('REFUSES a Method definition even in its own package with no qualified name', () => {
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('a/caller.go'),
|
||||
candidate: { ...mkCandidate('a/helper.go', 'DoThing'), type: 'Method' },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not refuse when there is no identifier to judge', () => {
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('b/caller.go'),
|
||||
candidate: mkCandidate('a/helper.go', ''),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("REFUSES an exported helper declared in another package's `_test.go`, module root included", () => {
|
||||
// A `_test.go` file is compiled only into its own package's test binary;
|
||||
// no other package can see it. The module-root exception used to run first
|
||||
// and accept `root_helper_test.go`'s exports for every subdirectory caller.
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('internal/svc/caller.go', [namedImport('github.com/org/mod')]),
|
||||
candidate: mkCandidate('helpers_test.go', 'ExportedTestHelper'),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('internal/svc/caller.go', [
|
||||
namedImport('github.com/org/mod/internal/models'),
|
||||
]),
|
||||
candidate: mkCandidate('internal/models/fixtures_test.go', 'NewFixture'),
|
||||
}),
|
||||
).toBe(false);
|
||||
// ...even from another package's own test file.
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('internal/svc/caller_test.go', [
|
||||
namedImport('github.com/org/mod/internal/models'),
|
||||
]),
|
||||
candidate: mkCandidate('internal/models/fixtures_test.go', 'NewFixture'),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('requires a dot import even for the module ROOT package', () => {
|
||||
// The root package is imported by the module path alone, which the
|
||||
// repo-relative layout cannot align against — undecidable, so allowed.
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('internal/svc/caller.go', [
|
||||
{ kind: 'wildcard', targetRaw: 'github.com/org/mod' },
|
||||
]),
|
||||
candidate: mkCandidate('root.go', 'ExportedHelper'),
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
goIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('internal/svc/caller.go'),
|
||||
candidate: mkCandidate('root.go', 'ExportedHelper'),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dart: isGlobalNameFallbackPlausible', () => {
|
||||
it('does NOT refuse a library-private name from another directory — a `part` URI may cross it', () => {
|
||||
// `part '../shared/gen.dart';` is legal Dart, and `part` directives are not
|
||||
// extracted yet, so "different directory" is undecidable, not impossible.
|
||||
// The edge stays a labeled guess rather than being deleted.
|
||||
expect(
|
||||
dartIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('lib/widgets/b.dart'),
|
||||
candidate: mkCandidate('lib/models/a.dart', '_privateHelper'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a library-private name in a SIBLING file (possible `part`)', () => {
|
||||
// `part` directives are not extracted yet, and parts are siblings of their
|
||||
// library file — refusing here would delete the Flutter `foo.g.dart` edge.
|
||||
expect(
|
||||
dartIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('lib/b.dart'),
|
||||
candidate: mkCandidate('lib/a.dart', '_privateHelper'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows the generated-part idiom: `_$FooFromJson` in `foo.g.dart` beside `foo.dart`', () => {
|
||||
expect(
|
||||
dartIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('lib/models/foo.dart'),
|
||||
candidate: mkCandidate('lib/models/foo.g.dart', '_$FooFromJson'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a library-private name in the same file', () => {
|
||||
expect(
|
||||
dartIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('lib/a.dart'),
|
||||
candidate: mkCandidate('lib/a.dart', '_privateHelper'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a library-private name across directories when the caller names the file', () => {
|
||||
// The `part` / `part of` direction, once the extractor surfaces it as an
|
||||
// import target: an explicit directive against the candidate's file wins.
|
||||
expect(
|
||||
dartIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('lib/widgets/b.dart', [namedImport('lib/models/a.dart')]),
|
||||
candidate: mkCandidate('lib/models/a.dart', '_privateHelper'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a public name across files', () => {
|
||||
expect(
|
||||
dartIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('lib/b.dart'),
|
||||
candidate: mkCandidate('lib/a.dart', 'publicHelper'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('judges privacy on the member, not its owner', () => {
|
||||
// `_Foo.bar` is a public member of a private class — the member name is what
|
||||
// a bare call would name, so it is not refused on the owner's underscore.
|
||||
expect(
|
||||
dartIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('lib/b.dart'),
|
||||
candidate: mkCandidate('lib/a.dart', '_Foo.bar'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rust: isGlobalNameFallbackPlausible', () => {
|
||||
it.each([
|
||||
['src/a/b.rs', 'super::unique_helper_xyz', 'src/a.rs'],
|
||||
['src/a/b/c.rs', 'super::super::unique_helper_xyz', 'src/a/mod.rs'],
|
||||
['src/a/b.rs', 'self::child::unique_helper_xyz', 'src/a/b/child.rs'],
|
||||
])('resolves relative imports from %s', (caller, target, candidate) => {
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: BARE_SITE,
|
||||
callerParsed: mkCaller(caller, [namedImport(target, BARE_SITE.name)]),
|
||||
candidate: mkCandidate(candidate, BARE_SITE.name),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
it('REFUSES a cross-module item with no covering `use`', () => {
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: BARE_SITE,
|
||||
callerParsed: mkCaller('src/b.rs', [namedImport('crate::unrelated')]),
|
||||
candidate: mkCandidate('src/a.rs', 'unique_helper_xyz'),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('allows a same-file item', () => {
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: BARE_SITE,
|
||||
callerParsed: mkCaller('src/a.rs'),
|
||||
candidate: mkCandidate('src/a.rs', 'unique_helper_xyz'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('REFUSES a bare item when only its module was imported', () => {
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: BARE_SITE,
|
||||
callerParsed: mkCaller('src/b.rs', [namedImport('crate::a')]),
|
||||
candidate: mkCandidate('src/a.rs', 'unique_helper_xyz'),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('treats `mod.rs` as its parent directory module', () => {
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: BARE_SITE,
|
||||
callerParsed: mkCaller('src/b.rs', [{ kind: 'wildcard', targetRaw: 'crate::net::http' }]),
|
||||
candidate: mkCandidate('src/net/http/mod.rs', 'unique_helper_xyz'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not refuse when the candidate file maps to no module path', () => {
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: BARE_SITE,
|
||||
callerParsed: mkCaller('src/b.rs'),
|
||||
candidate: mkCandidate('lib.rs', 'unique_helper_xyz'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('REFUSES a crate:: import that names the same module path in another workspace crate', () => {
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: { name: 'helper' },
|
||||
callerParsed: mkCaller('crates/a/src/caller.rs', [
|
||||
namedImport('crate::tools::helper', 'helper'),
|
||||
]),
|
||||
candidate: mkCandidate('crates/b/src/tools.rs', 'helper'),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: { name: 'helper' },
|
||||
callerParsed: mkCaller('crates/a/src/caller.rs', [
|
||||
namedImport('crate::tools::helper', 'helper'),
|
||||
]),
|
||||
candidate: mkCandidate('crates/a/src/tools.rs', 'helper'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows an item whose `use` names the ITEM rather than only its module', () => {
|
||||
// A bare call exercises the import matcher, not the qualified-site bypass.
|
||||
const candidate = mkCandidate('src/user.rs', 'build_user');
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: { name: 'build_user' },
|
||||
callerParsed: mkCaller('src/main.rs', [
|
||||
namedImport('crate::user::build_user', 'build_user'),
|
||||
]),
|
||||
candidate,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: { name: 'build_user' },
|
||||
callerParsed: mkCaller('src/main.rs'),
|
||||
candidate,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('REFUSES when the only `use` of the module names a DIFFERENT item', () => {
|
||||
// `use crate::a::other;` brings `other` into scope, not `helper`. The
|
||||
// parent-path match used to accept every item of `a` on its strength.
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: BARE_SITE,
|
||||
callerParsed: mkCaller('src/b.rs', [namedImport('crate::a::other', 'other')]),
|
||||
candidate: mkCandidate('src/a.rs', 'unique_helper_xyz'),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts an aliased import only under its local spelling', () => {
|
||||
const callerParsed = mkCaller('src/b.rs', [
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'renamed',
|
||||
importedName: 'unique_helper_xyz',
|
||||
targetRaw: 'crate::a::unique_helper_xyz',
|
||||
},
|
||||
]);
|
||||
const candidate = mkCandidate('src/a.rs', 'unique_helper_xyz');
|
||||
expect(rustIsGlobalNameFallbackPlausible({ callerParsed, candidate, site: BARE_SITE })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({ callerParsed, candidate, site: { name: 'renamed' } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a glob `use` of the module — every item is in scope', () => {
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: BARE_SITE,
|
||||
callerParsed: mkCaller('src/b.rs', [{ kind: 'wildcard', targetRaw: 'crate::a' }]),
|
||||
candidate: mkCandidate('src/a.rs', 'unique_helper_xyz'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a `use` that names the candidate itself, with the item on the path', () => {
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: BARE_SITE,
|
||||
callerParsed: mkCaller('src/b.rs', [
|
||||
namedImport('crate::a::unique_helper_xyz', 'unique_helper_xyz'),
|
||||
]),
|
||||
candidate: mkCandidate('src/a.rs', 'unique_helper_xyz'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a nested src/ directory as a module segment', () => {
|
||||
// `src/src/helper.rs` is crate::src::helper. A glob of an unrelated
|
||||
// `foo::helper` used to match after the crate-root while-strip reduced the
|
||||
// file to just `helper`.
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: BARE_SITE,
|
||||
callerParsed: mkCaller('src/b.rs', [{ kind: 'wildcard', targetRaw: 'crate::foo::helper' }]),
|
||||
candidate: mkCandidate('src/src/helper.rs', 'unique_helper_xyz'),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: BARE_SITE,
|
||||
callerParsed: mkCaller('src/b.rs', [{ kind: 'wildcard', targetRaw: 'crate::src::helper' }]),
|
||||
candidate: mkCandidate('src/src/helper.rs', 'unique_helper_xyz'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not judge a PATH-QUALIFIED call site', () => {
|
||||
// `User::new(...)` names its path in source. Refusing it for lacking a
|
||||
// `use` of the module would delete an edge the code spells out — the
|
||||
// regression this carve-out exists for (rust-scope.test.ts).
|
||||
expect(
|
||||
rustIsGlobalNameFallbackPlausible({
|
||||
site: { name: 'new', rawQualifiedName: 'User::new' },
|
||||
callerParsed: mkCaller('src/main.rs'),
|
||||
candidate: mkCandidate('src/user.rs', 'User.new'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Swift: isGlobalNameFallbackPlausible', () => {
|
||||
it.each(['sources', 'tests', 'Tests'])(
|
||||
'does not invent target modules under unconfigured %s folders',
|
||||
(root) => {
|
||||
expect(
|
||||
swiftIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller(`${root}/App/Caller.swift`),
|
||||
candidate: mkCandidate(`${root}/Core/Helper.swift`, 'helper'),
|
||||
}),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
it('does not invent module boundaries between arbitrary Xcode directories', () => {
|
||||
expect(
|
||||
swiftIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('App/Caller.swift'),
|
||||
candidate: mkCandidate('Shared/Helper.swift', 'helper'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes distinct src targets and requires a matching import', () => {
|
||||
const candidate = mkCandidate('src/Core/Helper.swift', 'helper');
|
||||
expect(
|
||||
swiftIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('src/App/Caller.swift'),
|
||||
candidate,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
swiftIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('src/App/Caller.swift', [namedImport('Core')]),
|
||||
candidate,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
it('allows a cross-file candidate in the same target (whole-module internal)', () => {
|
||||
expect(
|
||||
swiftIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('Sources/Core/Caller.swift'),
|
||||
candidate: mkCandidate('Sources/Core/Helper.swift', 'uniqueHelperXyz'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('REFUSES a candidate in another target the caller never imports', () => {
|
||||
expect(
|
||||
swiftIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('Sources/App/Caller.swift'),
|
||||
candidate: mkCandidate('Sources/Core/Helper.swift', 'uniqueHelperXyz'),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat same-named targets in different packages as one module', () => {
|
||||
expect(
|
||||
swiftIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('Sources/Core/Caller.swift'),
|
||||
candidate: mkCandidate('Package/Sources/Core/Helper.swift', 'uniqueHelperXyz'),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
swiftIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('Sources/Core/Caller.swift', [namedImport('Core')]),
|
||||
candidate: mkCandidate('Package/Sources/Core/Helper.swift', 'uniqueHelperXyz'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a candidate in another target the caller imports', () => {
|
||||
expect(
|
||||
swiftIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('Sources/App/Caller.swift', [namedImport('Core')]),
|
||||
candidate: mkCandidate('Sources/Core/Helper.swift', 'uniqueHelperXyz'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not refuse when the layout heuristic cannot place a file', () => {
|
||||
expect(
|
||||
swiftIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('Caller.swift'),
|
||||
candidate: mkCandidate('Sources/Core/Helper.swift', 'uniqueHelperXyz'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ruby: isGlobalNameFallbackPlausible', () => {
|
||||
/** A candidate file whose `localDefs` carry the owner with the given label. */
|
||||
const ownerFile =
|
||||
(ownerId: string, label: 'Class' | 'Trait') =>
|
||||
(filePath: string): ParsedFile | undefined =>
|
||||
({
|
||||
filePath,
|
||||
parsedImports: [],
|
||||
referenceSites: [],
|
||||
localDefs: [{ nodeId: ownerId, filePath, type: label, qualifiedName: 'Billing' }],
|
||||
}) as unknown as ParsedFile;
|
||||
|
||||
it('allows a TOP-LEVEL method across files (the autoload shape)', () => {
|
||||
// Ruby keeps this guess on purpose: with zeitwerk a file really can call a
|
||||
// method whose defining file it never requires.
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/b.rb'),
|
||||
candidate: mkCandidate('app/a.rb', 'unique_helper_xyz'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('REFUSES a CLASS-owned method whose class the caller never names', () => {
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/b.rb', [namedImport('app/unrelated')]),
|
||||
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||
parsedFileOf: ownerFile('def:Billing', 'Class'),
|
||||
sourceTextOf: () => 'unique_helper_xyz()',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('allows a MODULE-owned method with no mention — Rails mixes modules in for you', () => {
|
||||
// `module ApplicationHelper` is included into every view by the framework;
|
||||
// a bare `format_money()` there is legal with no include/require/constant.
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/views/show.html.erb'),
|
||||
candidate: mkCandidate('app/helpers/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||
parsedFileOf: ownerFile('def:Billing', 'Trait'),
|
||||
sourceTextOf: () => 'unique_helper_xyz()',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a class-owned method when the caller INHERITS from anything (transitive chains)', () => {
|
||||
// `class UsersController < AdminController` reaches ApplicationController's
|
||||
// methods while naming only AdminController — undecidable from one file.
|
||||
const inherits = {
|
||||
kind: 'inherits',
|
||||
name: 'AdminController',
|
||||
} as unknown as ParsedFile['referenceSites'][number];
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/controllers/users_controller.rb', [], [inherits]),
|
||||
candidate: mkCandidate(
|
||||
'app/controllers/application_controller.rb',
|
||||
'ApplicationController#unique_helper_xyz',
|
||||
'def:ApplicationController',
|
||||
),
|
||||
parsedFileOf: ownerFile('def:ApplicationController', 'Class'),
|
||||
sourceTextOf: () => 'class UsersController < AdminController\n unique_helper_xyz()\nend\n',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a class-owned method when the caller mixes anything in (include/extend marker)', () => {
|
||||
const mixin = {
|
||||
kind: 'namespace',
|
||||
localName: 'Auditable',
|
||||
importedName: 'Auditable',
|
||||
targetRaw: '__heritage__:include:Auditable:Report',
|
||||
} as unknown as ParsedImport;
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/models/report.rb', [mixin]),
|
||||
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||
parsedFileOf: ownerFile('def:Billing', 'Class'),
|
||||
sourceTextOf: () => 'include Auditable; unique_helper_xyz()',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a class-owned method when the caller file DEFINES a module (a mixin body)', () => {
|
||||
// `module PostGuardian; def can_see?(post); is_staff? ...` — the module's
|
||||
// methods run inside `class Guardian`, which includes it; the module file
|
||||
// never names Guardian.
|
||||
const moduleDef = {
|
||||
nodeId: 'def:PostGuardian',
|
||||
filePath: 'lib/guardian/post_guardian.rb',
|
||||
type: 'Trait',
|
||||
qualifiedName: 'PostGuardian',
|
||||
} as unknown as SymbolDefinition;
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('lib/guardian/post_guardian.rb', [], [], [moduleDef]),
|
||||
candidate: mkCandidate('lib/guardian.rb', 'Guardian#is_staff?', 'def:Guardian'),
|
||||
parsedFileOf: ownerFile('def:Guardian', 'Class'),
|
||||
sourceTextOf: () => 'module PostGuardian; def can_see?(post); is_staff?; end; end\n',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not refuse when the owner cannot be typed (no file lookup)', () => {
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/b.rb'),
|
||||
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||
sourceTextOf: () => 'unique_helper_xyz()',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/b.rb'),
|
||||
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||
parsedFileOf: () => undefined,
|
||||
sourceTextOf: () => 'unique_helper_xyz()',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a class-owned method when the caller requires its namespace (snake_case path)', () => {
|
||||
// `require 'billing/invoice_service'` names `Billing::InvoiceService` — the
|
||||
// path is snake_case and the constant CamelCase, so the match normalizes.
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/b.rb', [namedImport('billing/invoice_service')]),
|
||||
candidate: mkCandidate(
|
||||
'app/a.rb',
|
||||
'Billing::InvoiceService#unique_helper_xyz',
|
||||
'def:InvoiceService',
|
||||
),
|
||||
parsedFileOf: ownerFile('def:InvoiceService', 'Class'),
|
||||
sourceTextOf: () => "require 'billing/invoice_service'; unique_helper_xyz()",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a class-owned method when the caller includes the constant by name', () => {
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/b.rb', [namedImport('Billing', 'Billing')]),
|
||||
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||
parsedFileOf: ownerFile('def:Billing', 'Class'),
|
||||
sourceTextOf: () => 'include Billing; unique_helper_xyz()',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a class-owned method when the caller REOPENS the class', () => {
|
||||
const klass = {
|
||||
type: 'Class',
|
||||
qualifiedName: 'Invoice',
|
||||
nodeId: 'def:Invoice',
|
||||
} as unknown as SymbolDefinition;
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/models/invoice_reporting.rb', [], [], [klass]),
|
||||
candidate: mkCandidate('app/models/invoice.rb', 'Invoice#total', 'def:Invoice'),
|
||||
parsedFileOf: ownerFile('def:Invoice', 'Class'),
|
||||
sourceTextOf: () => 'class Invoice\n def report; total; end\nend\n',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a class-owned method when the caller mentions the constant', () => {
|
||||
const site = { name: 'Billing' } as unknown as ParsedFile['referenceSites'][number];
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/b.rb', [], [site]),
|
||||
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||
parsedFileOf: ownerFile('def:Billing', 'Class'),
|
||||
sourceTextOf: () => 'Billing; unique_helper_xyz()',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('REFUSES when the caller only mentions a LONGER constant containing the name as a substring', () => {
|
||||
// `BillingService.build` is not a mention of `Billing`; `includes()` said it was.
|
||||
const site = {
|
||||
name: 'build',
|
||||
rawQualifiedName: 'BillingService.build',
|
||||
} as unknown as ParsedFile['referenceSites'][number];
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/b.rb', [], [site]),
|
||||
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||
parsedFileOf: ownerFile('def:Billing', 'Class'),
|
||||
sourceTextOf: () => 'BillingService.build; unique_helper_xyz()',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('allows a qualified mention whose SEGMENT is the constant (`Acme::Billing.new`)', () => {
|
||||
const site = {
|
||||
name: 'new',
|
||||
rawQualifiedName: 'Acme::Billing.new',
|
||||
} as unknown as ParsedFile['referenceSites'][number];
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/b.rb', [], [site]),
|
||||
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||
parsedFileOf: ownerFile('def:Billing', 'Class'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the LABELED guess when the caller file rebinds `self` (`instance_eval` DSL blocks) (magyargergo)', () => {
|
||||
// `service.instance_eval do unique_helper_xyz() end` dispatches the bare
|
||||
// call on `service`, so the class never being named here proves nothing.
|
||||
const src =
|
||||
'def caller(service)\n service.instance_eval do\n unique_helper_xyz()\n end\nend\n';
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/b.rb'),
|
||||
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||
parsedFileOf: ownerFile('def:Billing', 'Class'),
|
||||
sourceTextOf: () => src,
|
||||
}),
|
||||
).toBe(true);
|
||||
// ...and still REFUSES when the source has no such block.
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/b.rb'),
|
||||
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||
parsedFileOf: ownerFile('def:Billing', 'Class'),
|
||||
sourceTextOf: () => 'def caller\n unique_helper_xyz()\nend\n',
|
||||
}),
|
||||
).toBe(false);
|
||||
// A missing source text is an unanswered question, not a refusal.
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/b.rb'),
|
||||
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||
parsedFileOf: ownerFile('def:Billing', 'Class'),
|
||||
sourceTextOf: () => undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the labeled guess when the source lookup itself is absent', () => {
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/b.rb'),
|
||||
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||
parsedFileOf: ownerFile('def:Billing', 'Class'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not refuse an owned method with no nameable namespace', () => {
|
||||
expect(
|
||||
rubyIsGlobalNameFallbackPlausible({
|
||||
callerParsed: mkCaller('app/b.rb'),
|
||||
candidate: mkCandidate('app/a.rb', 'unique_helper_xyz', 'def:anon'),
|
||||
parsedFileOf: ownerFile('def:anon', 'Class'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -187,29 +187,59 @@ describe('Python imports — interpretImport', () => {
|
|||
it('case 10: `import numpy` → namespace import', () => {
|
||||
const f = parse('import numpy\n');
|
||||
expect(f.parsedImports).toEqual([
|
||||
{ kind: 'namespace', localName: 'numpy', importedName: 'numpy', targetRaw: 'numpy' },
|
||||
{
|
||||
kind: 'namespace',
|
||||
localName: 'numpy',
|
||||
importedName: 'numpy',
|
||||
targetRaw: 'numpy',
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('case 11: `import numpy as np` → namespace import with rename', () => {
|
||||
const f = parse('import numpy as np\n');
|
||||
expect(f.parsedImports).toEqual([
|
||||
{ kind: 'namespace', localName: 'np', importedName: 'numpy', targetRaw: 'numpy' },
|
||||
{
|
||||
kind: 'namespace',
|
||||
localName: 'np',
|
||||
importedName: 'numpy',
|
||||
targetRaw: 'numpy',
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('case 12: `import a.b.c` exposes the leading segment as the local name', () => {
|
||||
const f = parse('import a.b.c\n');
|
||||
expect(f.parsedImports).toEqual([
|
||||
{ kind: 'namespace', localName: 'a', importedName: 'a.b.c', targetRaw: 'a.b.c' },
|
||||
{
|
||||
kind: 'namespace',
|
||||
localName: 'a',
|
||||
importedName: 'a.b.c',
|
||||
targetRaw: 'a.b.c',
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('case 13: `import a, b as c` decomposes into one ParsedImport per name', () => {
|
||||
const f = parse('import a, b as c\n');
|
||||
expect(f.parsedImports).toEqual([
|
||||
{ kind: 'namespace', localName: 'a', importedName: 'a', targetRaw: 'a' },
|
||||
{ kind: 'namespace', localName: 'c', importedName: 'b', targetRaw: 'b' },
|
||||
{
|
||||
kind: 'namespace',
|
||||
localName: 'a',
|
||||
importedName: 'a',
|
||||
targetRaw: 'a',
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
{
|
||||
kind: 'namespace',
|
||||
localName: 'c',
|
||||
importedName: 'b',
|
||||
targetRaw: 'b',
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -218,7 +248,14 @@ describe('Python imports — interpretImport', () => {
|
|||
// `reexportsName`: Python republishes the name as `<module>.x`, so it must
|
||||
// enter the re-export closure for `from <module> import x` elsewhere.
|
||||
expect(f.parsedImports).toEqual([
|
||||
{ kind: 'named', localName: 'x', importedName: 'x', targetRaw: 'm', reexportsName: true },
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'x',
|
||||
importedName: 'x',
|
||||
targetRaw: 'm',
|
||||
reexportsName: true,
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -232,6 +269,7 @@ describe('Python imports — interpretImport', () => {
|
|||
alias: 'y',
|
||||
targetRaw: 'm',
|
||||
reexportsName: true,
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -239,21 +277,51 @@ describe('Python imports — interpretImport', () => {
|
|||
it('case 16: `from m import x, y, z` decomposes into three ParsedImports', () => {
|
||||
const f = parse('from m import x, y, z\n');
|
||||
expect(f.parsedImports).toEqual([
|
||||
{ kind: 'named', localName: 'x', importedName: 'x', targetRaw: 'm', reexportsName: true },
|
||||
{ kind: 'named', localName: 'y', importedName: 'y', targetRaw: 'm', reexportsName: true },
|
||||
{ kind: 'named', localName: 'z', importedName: 'z', targetRaw: 'm', reexportsName: true },
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'x',
|
||||
importedName: 'x',
|
||||
targetRaw: 'm',
|
||||
reexportsName: true,
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'y',
|
||||
importedName: 'y',
|
||||
targetRaw: 'm',
|
||||
reexportsName: true,
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'z',
|
||||
importedName: 'z',
|
||||
targetRaw: 'm',
|
||||
reexportsName: true,
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('case 17: `from m import *` → wildcard', () => {
|
||||
const f = parse('from m import *\n');
|
||||
expect(f.parsedImports).toEqual([{ kind: 'wildcard', targetRaw: 'm' }]);
|
||||
expect(f.parsedImports).toEqual([
|
||||
{ kind: 'wildcard', targetRaw: 'm', declaredAtScope: f.moduleScope },
|
||||
]);
|
||||
});
|
||||
|
||||
it('case 18: PEP-328 dotted relative import `from .pkg import x`', () => {
|
||||
const f = parse('from .pkg import x\n');
|
||||
expect(f.parsedImports).toEqual([
|
||||
{ kind: 'named', localName: 'x', importedName: 'x', targetRaw: '.pkg', reexportsName: true },
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'x',
|
||||
importedName: 'x',
|
||||
targetRaw: '.pkg',
|
||||
reexportsName: true,
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -266,6 +334,7 @@ describe('Python imports — interpretImport', () => {
|
|||
importedName: 'x',
|
||||
targetRaw: '..pkg.sub',
|
||||
reexportsName: true,
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -291,6 +360,7 @@ describe('Python imports — function-local', () => {
|
|||
importedName: 'X',
|
||||
targetRaw: 'm',
|
||||
runsOnlyWhenCalled: true,
|
||||
declaredAtScope: scopesByKind(f, 'Function')[0]!.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -305,7 +375,13 @@ describe('Python imports — function-local', () => {
|
|||
// initialization order. The two facts are separate on purpose — this is
|
||||
// the case where suppression and deferral disagree.
|
||||
expect(f.parsedImports).toEqual([
|
||||
{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'm' },
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'X',
|
||||
importedName: 'X',
|
||||
targetRaw: 'm',
|
||||
declaredAtScope: scopesByKind(f, 'Class')[0]!.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -317,9 +393,30 @@ describe('Python imports — function-local', () => {
|
|||
'if TYPE_CHECKING:\n from m import A\ntry:\n from m import B\nexcept ImportError:\n B = None\nfor _ in r:\n from m import C\n',
|
||||
);
|
||||
expect(f.parsedImports).toEqual([
|
||||
{ kind: 'named', localName: 'A', importedName: 'A', targetRaw: 'm', reexportsName: true },
|
||||
{ kind: 'named', localName: 'B', importedName: 'B', targetRaw: 'm', reexportsName: true },
|
||||
{ kind: 'named', localName: 'C', importedName: 'C', targetRaw: 'm', reexportsName: true },
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'A',
|
||||
importedName: 'A',
|
||||
targetRaw: 'm',
|
||||
reexportsName: true,
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'B',
|
||||
importedName: 'B',
|
||||
targetRaw: 'm',
|
||||
reexportsName: true,
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'C',
|
||||
importedName: 'C',
|
||||
targetRaw: 'm',
|
||||
reexportsName: true,
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* Review finding on #3182 (name-fallback-summary.ts:104): the census
|
||||
* denominator `callsByLanguage` was never supplied in production. The pipeline
|
||||
* now builds `resolvedCalleeNamesByCaller` (caller node → callee simple names)
|
||||
* through the edge source that is complete under streaming, and `run-analyze`
|
||||
* feeds it to `countCallsByLanguage`.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
|
||||
import { collectResolvedCalleeNames } from '../../../src/core/ingestion/pipeline.js';
|
||||
import { countCallsByLanguage } from '../../../src/core/ingestion/scope-resolution/name-fallback-summary.js';
|
||||
import { GLOBAL_NAME_FALLBACK_REASON } from '../../../src/core/graph/edge-reasons.js';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
|
||||
describe('collectResolvedCalleeNames', () => {
|
||||
it('groups CALLS targets by caller and ignores other edge types and nameless targets', () => {
|
||||
const g = createKnowledgeGraph();
|
||||
const fn = (id: string, name: string, filePath: string) =>
|
||||
g.addNode({ id, label: 'Function' as NodeLabel, properties: { name, filePath } });
|
||||
fn('a', 'a', 'src/a.go');
|
||||
fn('b', 'b', 'src/b.go');
|
||||
fn('c', 'c', 'src/c.ts');
|
||||
fn('d', 'd', 'src/d.go');
|
||||
g.addNode({ id: 'file', label: 'File' as NodeLabel, properties: { filePath: 'src/a.go' } });
|
||||
g.addRelationship({
|
||||
id: 'r1',
|
||||
sourceId: 'a',
|
||||
targetId: 'b',
|
||||
type: 'CALLS',
|
||||
confidence: 0.85,
|
||||
reason: '',
|
||||
});
|
||||
g.addRelationship({
|
||||
id: 'r2',
|
||||
sourceId: 'a',
|
||||
targetId: 'c',
|
||||
type: 'CALLS',
|
||||
confidence: 0.5,
|
||||
reason: '',
|
||||
});
|
||||
g.addRelationship({
|
||||
id: 'r3',
|
||||
sourceId: 'c',
|
||||
targetId: 'b',
|
||||
type: 'CALLS',
|
||||
confidence: 0.85,
|
||||
reason: '',
|
||||
});
|
||||
g.addRelationship({
|
||||
id: 'r4',
|
||||
sourceId: 'file',
|
||||
targetId: 'a',
|
||||
type: 'DEFINES',
|
||||
confidence: 1,
|
||||
reason: '',
|
||||
});
|
||||
g.addRelationship({
|
||||
id: 'r5',
|
||||
sourceId: 'a',
|
||||
targetId: 'file',
|
||||
type: 'CALLS',
|
||||
confidence: 1,
|
||||
reason: '',
|
||||
});
|
||||
g.addRelationship({
|
||||
id: 'r6',
|
||||
sourceId: 'a',
|
||||
targetId: 'd',
|
||||
type: 'CALLS',
|
||||
confidence: 0.5,
|
||||
reason: GLOBAL_NAME_FALLBACK_REASON,
|
||||
});
|
||||
|
||||
const index = collectResolvedCalleeNames(g, g);
|
||||
expect([...index.keys()].sort()).toEqual(['a', 'c']);
|
||||
expect([...index.get('a')!].sort()).toEqual(['b', 'c']);
|
||||
expect([...index.get('c')!]).toEqual(['b']);
|
||||
|
||||
// ...and it is the shape the census denominator consumes.
|
||||
expect(countCallsByLanguage(index, g)).toEqual({ go: 2, typescript: 1 });
|
||||
});
|
||||
});
|
||||
|
|
@ -329,7 +329,7 @@ describe('Pass 3: raw imports', () => {
|
|||
interpretImport: () => named,
|
||||
}),
|
||||
);
|
||||
expect(result.parsedImports).toEqual([named]);
|
||||
expect(result.parsedImports).toEqual([{ ...named, declaredAtScope: result.moduleScope }]);
|
||||
});
|
||||
|
||||
it('drops imports when `interpretImport` returns null', () => {
|
||||
|
|
@ -443,7 +443,7 @@ describe('Pass 3: runsOnlyWhenCalled', () => {
|
|||
'a.ts',
|
||||
mockProvider({ interpretImport: () => named }),
|
||||
);
|
||||
expect(result.parsedImports).toEqual([named]);
|
||||
expect(result.parsedImports).toEqual([{ ...named, declaredAtScope: result.moduleScope }]);
|
||||
});
|
||||
|
||||
// ─── The provider capability that opts out of the position rule ──────────
|
||||
|
|
@ -474,8 +474,10 @@ describe('Pass 3: runsOnlyWhenCalled', () => {
|
|||
'a.c',
|
||||
mockProvider({ interpretImport: () => named, importsExecuteWhereWritten: false }),
|
||||
);
|
||||
// Byte-identical to the un-deferred shape, not merely `!== true`.
|
||||
expect(result.parsedImports).toEqual([named]);
|
||||
// Scope provenance survives, without adding the execution-deferral flag.
|
||||
expect(result.parsedImports).toEqual([
|
||||
{ ...named, declaredAtScope: 'scope:a.c#2:0-99:0:Function' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('the identical captures ARE marked for a provider that does not declare it', () => {
|
||||
|
|
@ -488,7 +490,9 @@ describe('Pass 3: runsOnlyWhenCalled', () => {
|
|||
];
|
||||
expect(
|
||||
extract(captures, 'a.ts', mockProvider({ interpretImport: () => named })).parsedImports,
|
||||
).toEqual([{ ...named, runsOnlyWhenCalled: true }]);
|
||||
).toEqual([
|
||||
{ ...named, declaredAtScope: 'scope:a.ts#2:0-99:0:Function', runsOnlyWhenCalled: true },
|
||||
]);
|
||||
// Absent must mean `true`, not merely "not false" — the default is the
|
||||
// safe direction (position defers), and only an explicit `false` withholds
|
||||
// deferral. Spelling `true` therefore has to behave exactly like absent.
|
||||
|
|
@ -498,7 +502,9 @@ describe('Pass 3: runsOnlyWhenCalled', () => {
|
|||
'a.ts',
|
||||
mockProvider({ interpretImport: () => named, importsExecuteWhereWritten: true }),
|
||||
).parsedImports,
|
||||
).toEqual([{ ...named, runsOnlyWhenCalled: true }]);
|
||||
).toEqual([
|
||||
{ ...named, declaredAtScope: 'scope:a.ts#2:0-99:0:Function', runsOnlyWhenCalled: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -925,7 +931,9 @@ describe('end-to-end fixture (all 5 passes together)', () => {
|
|||
expect(fn.bindings.get('save')).toBeDefined();
|
||||
|
||||
// Import collected.
|
||||
expect(result.parsedImports).toEqual([parsedImport]);
|
||||
expect(result.parsedImports).toEqual([
|
||||
{ ...parsedImport, declaredAtScope: result.moduleScope },
|
||||
]);
|
||||
|
||||
// Type binding attached to function scope.
|
||||
expect(fn.typeBindings.get('name')?.rawName).toBe('string');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* `export *` collision detection honours EXPORT EVIDENCE (`SymbolDefinition.
|
||||
* isExported`, tri-state) — review findings on #3182 (finalize-algorithm.ts:1026
|
||||
* and typescript/scope-resolver.ts:138).
|
||||
*
|
||||
* Two defects, one mechanism:
|
||||
*
|
||||
* 1. `Variable` was excluded from the collision candidates while the closure
|
||||
* path (`indexTopLevelExportsByName`) retained it, so two sources each
|
||||
* exporting `const alpha` were BOTH published and first-wins silently bound
|
||||
* one of them despite `exclusiveWildcardReexports`.
|
||||
* 2. A module-PRIVATE `function foo` in one source counted as a provider, so a
|
||||
* genuinely exported `foo` in the other source was refused as a collision —
|
||||
* and, without the refusal, the private one could have been the closure's
|
||||
* first-listed winner.
|
||||
*
|
||||
* With evidence: an exported `Variable` collides; a private `function` neither
|
||||
* collides nor binds. Without evidence the prior behaviour is unchanged.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { finalizeScopeModel } from '../../../src/core/ingestion/finalize-orchestrator.js';
|
||||
|
||||
const mkScope = (id: ScopeId, filePath: string): Scope => ({
|
||||
id,
|
||||
parent: null,
|
||||
kind: 'Module',
|
||||
range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 },
|
||||
filePath,
|
||||
bindings: new Map(),
|
||||
ownedDefs: [],
|
||||
imports: [],
|
||||
typeBindings: new Map(),
|
||||
});
|
||||
|
||||
const mkFile = (filePath: string, overrides: Partial<ParsedFile> = {}): ParsedFile => ({
|
||||
filePath,
|
||||
moduleScope: `scope:${filePath}#module`,
|
||||
scopes: [mkScope(`scope:${filePath}#module`, filePath)],
|
||||
parsedImports: overrides.parsedImports ?? [],
|
||||
localDefs: overrides.localDefs ?? [],
|
||||
referenceSites: [],
|
||||
});
|
||||
|
||||
const def = (
|
||||
nodeId: string,
|
||||
filePath: string,
|
||||
type: SymbolDefinition['type'],
|
||||
name: string,
|
||||
isExported?: boolean,
|
||||
): SymbolDefinition => ({
|
||||
nodeId,
|
||||
filePath,
|
||||
type,
|
||||
qualifiedName: name,
|
||||
...(isExported !== undefined ? { isExported } : {}),
|
||||
});
|
||||
|
||||
/** barrel.ts: `export * from './a'; export * from './b'`; c.ts imports `name` from it. */
|
||||
function run(aDefs: SymbolDefinition[], bDefs: SymbolDefinition[], name: string) {
|
||||
const a = mkFile('a.ts', { localDefs: aDefs });
|
||||
const b = mkFile('b.ts', { localDefs: bDefs });
|
||||
const barrel = mkFile('barrel.ts', {
|
||||
parsedImports: [
|
||||
{ kind: 'wildcard', targetRaw: 'a.ts' },
|
||||
{ kind: 'wildcard', targetRaw: 'b.ts' },
|
||||
],
|
||||
});
|
||||
const c = mkFile('c.ts', {
|
||||
parsedImports: [{ kind: 'named', localName: name, importedName: name, targetRaw: 'barrel.ts' }],
|
||||
});
|
||||
const out = finalizeScopeModel([a, b, barrel, c], {
|
||||
hooks: {
|
||||
resolveImportTarget: (targetRaw) => targetRaw,
|
||||
namedImportsBindTopLevelOnly: true,
|
||||
wildcardCollisionIsAmbiguous: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
edge: out.imports.get(c.moduleScope)?.[0],
|
||||
ambiguous: out.stats.ambiguousWildcardExports,
|
||||
};
|
||||
}
|
||||
|
||||
describe('export * collisions with export evidence', () => {
|
||||
it('refuses conflicting named re-exports without reporting an export-star collision', () => {
|
||||
const a = mkFile('a.ts', { localDefs: [def('a:alpha', 'a.ts', 'Function', 'alpha', true)] });
|
||||
const b = mkFile('b.ts', { localDefs: [def('b:alpha', 'b.ts', 'Function', 'alpha', true)] });
|
||||
const barrel = mkFile('barrel.ts', {
|
||||
parsedImports: [
|
||||
{
|
||||
kind: 'named',
|
||||
targetRaw: 'a.ts',
|
||||
localName: 'alpha',
|
||||
importedName: 'alpha',
|
||||
reexportsName: true,
|
||||
},
|
||||
{
|
||||
kind: 'named',
|
||||
targetRaw: 'b.ts',
|
||||
localName: 'alpha',
|
||||
importedName: 'alpha',
|
||||
reexportsName: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const caller = mkFile('caller.ts', {
|
||||
parsedImports: [
|
||||
{ kind: 'named', targetRaw: 'barrel.ts', localName: 'alpha', importedName: 'alpha' },
|
||||
],
|
||||
});
|
||||
const out = finalizeScopeModel([a, b, barrel, caller], {
|
||||
hooks: {
|
||||
resolveImportTarget: (targetRaw) => targetRaw,
|
||||
namedImportsBindTopLevelOnly: true,
|
||||
wildcardCollisionIsAmbiguous: true,
|
||||
},
|
||||
});
|
||||
expect(out.imports.get(caller.moduleScope)?.[0]?.linkStatus).toBe('unresolved');
|
||||
expect(out.stats.ambiguousWildcardExports).toEqual([]);
|
||||
});
|
||||
|
||||
it('two sources each EXPORTING `const alpha` collide — refused, not first-wins', () => {
|
||||
const { edge, ambiguous } = run(
|
||||
[def('def:a.alpha', 'a.ts', 'Variable', 'alpha', true)],
|
||||
[def('def:b.alpha', 'b.ts', 'Variable', 'alpha', true)],
|
||||
'alpha',
|
||||
);
|
||||
expect(edge?.linkStatus).toBe('unresolved');
|
||||
expect(edge?.targetDefId).toBeUndefined();
|
||||
expect(ambiguous.map((x) => x.name)).toEqual(['alpha']);
|
||||
expect([...(ambiguous[0]?.candidateDefIds ?? [])].sort()).toEqual([
|
||||
'def:a.alpha',
|
||||
'def:b.alpha',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a module-PRIVATE `function foo` beside an exported one is not a provider: the export binds', () => {
|
||||
const { edge, ambiguous } = run(
|
||||
[def('def:a.foo', 'a.ts', 'Function', 'foo', true)],
|
||||
[def('def:b.foo', 'b.ts', 'Function', 'foo', false)],
|
||||
'foo',
|
||||
);
|
||||
expect(ambiguous).toEqual([]);
|
||||
expect(edge?.linkStatus).toBeUndefined();
|
||||
expect(edge?.targetDefId).toBe('def:a.foo');
|
||||
});
|
||||
|
||||
it('the private one is never the closure winner either, whichever source is listed first', () => {
|
||||
// b (private) is listed AFTER a here, but a is the one that exports — swap
|
||||
// the roles so the private def sits in the FIRST wildcard source.
|
||||
const { edge } = run(
|
||||
[def('def:a.foo', 'a.ts', 'Function', 'foo', false)],
|
||||
[def('def:b.foo', 'b.ts', 'Function', 'foo', true)],
|
||||
'foo',
|
||||
);
|
||||
expect(edge?.targetDefId).toBe('def:b.foo');
|
||||
});
|
||||
|
||||
it('a private def alone behind the barrel is NOT published through `export *`', () => {
|
||||
const { edge } = run([def('def:a.foo', 'a.ts', 'Function', 'foo', false)], [], 'foo');
|
||||
expect(edge?.linkStatus).toBe('unresolved');
|
||||
});
|
||||
|
||||
it('a class MEMBER of the barrel named like the collision does not shadow it (magyargergo)', () => {
|
||||
// `export class Unrelated { clash() {} }` in the barrel made `clash` a local
|
||||
// name, switched the collision check off, and a confident edge to a.ts went out.
|
||||
const a = mkFile('a.ts', {
|
||||
localDefs: [def('def:a.clash', 'a.ts', 'Function', 'clash', true)],
|
||||
});
|
||||
const b = mkFile('b.ts', {
|
||||
localDefs: [def('def:b.clash', 'b.ts', 'Function', 'clash', true)],
|
||||
});
|
||||
const unrelated = def('def:Unrelated', 'barrel.ts', 'Class', 'Unrelated', true);
|
||||
const member: SymbolDefinition = {
|
||||
nodeId: 'def:Unrelated.clash',
|
||||
filePath: 'barrel.ts',
|
||||
type: 'Method',
|
||||
qualifiedName: 'Unrelated.clash',
|
||||
ownerId: 'def:Unrelated',
|
||||
isExported: false,
|
||||
};
|
||||
const barrel = mkFile('barrel.ts', {
|
||||
localDefs: [unrelated, member],
|
||||
parsedImports: [
|
||||
{ kind: 'wildcard', targetRaw: 'a.ts' },
|
||||
{ kind: 'wildcard', targetRaw: 'b.ts' },
|
||||
],
|
||||
});
|
||||
const c = mkFile('c.ts', {
|
||||
parsedImports: [
|
||||
{ kind: 'named', localName: 'clash', importedName: 'clash', targetRaw: 'barrel.ts' },
|
||||
],
|
||||
});
|
||||
for (const memberEvidence of [member, { ...member, isExported: undefined }]) {
|
||||
const out = finalizeScopeModel(
|
||||
[a, b, { ...barrel, localDefs: [unrelated, memberEvidence] }, c],
|
||||
{
|
||||
hooks: {
|
||||
resolveImportTarget: (targetRaw) => targetRaw,
|
||||
namedImportsBindTopLevelOnly: true,
|
||||
wildcardCollisionIsAmbiguous: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
const edge = out.imports.get(c.moduleScope)?.[0];
|
||||
expect(edge?.linkStatus).toBe('unresolved');
|
||||
expect(out.stats.ambiguousWildcardExports.map((x) => x.name)).toEqual(['clash']);
|
||||
}
|
||||
});
|
||||
|
||||
it('without evidence, behaviour is unchanged: functions collide, Variables do not', () => {
|
||||
const fns = run(
|
||||
[def('def:a.foo', 'a.ts', 'Function', 'foo')],
|
||||
[def('def:b.foo', 'b.ts', 'Function', 'foo')],
|
||||
'foo',
|
||||
);
|
||||
expect(fns.edge?.linkStatus).toBe('unresolved');
|
||||
expect(fns.ambiguous.map((x) => x.name)).toEqual(['foo']);
|
||||
const vars = run(
|
||||
[def('def:a.alpha', 'a.ts', 'Variable', 'alpha')],
|
||||
[def('def:b.alpha', 'b.ts', 'Variable', 'alpha')],
|
||||
'alpha',
|
||||
);
|
||||
expect(vars.ambiguous).toEqual([]);
|
||||
expect(vars.edge?.targetDefId).toBe('def:a.alpha');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* M17 — the `export *` wildcard fan-out in `populateFileClosure`
|
||||
* (gitnexus-shared/src/scope-resolution/finalize-algorithm.ts) is gated by the
|
||||
* SAME `namedImportsBindTopLevelOnly` hook as the named-import path:
|
||||
*
|
||||
* for (const [name, def] of (topLevelOnly ? indexTopLevelExportsByName : indexExportsByName)(...))
|
||||
*
|
||||
* Before this fix, the wildcard fan-out ALWAYS used the narrow (top-level-only)
|
||||
* index, regardless of the hook — silently adopting ECMAScript's `export *`
|
||||
* semantics (a class member can never be published by a bare wildcard
|
||||
* re-export) for every language, including ones (Python, Java, ...) whose
|
||||
* wildcard/star import legitimately republishes class members by name.
|
||||
*
|
||||
* This is below the extraction layer (RFC #909 Ring 2 PKG #921) — synthetic
|
||||
* `ParsedFile` input against `finalizeScopeModel` with a FAKE resolver
|
||||
* (`namedImportsBindTopLevelOnly` toggled directly), same technique as
|
||||
* `finalize-orchestrator.test.ts`. No real language parser involved; the
|
||||
* fixture below is deliberately language-agnostic (Vue, TypeScript, and
|
||||
* JavaScript all opt in through their language-specific scope resolvers).
|
||||
*
|
||||
* Fixture shape, held constant across both hook settings:
|
||||
* B.ts: class Foo with method `beta` — NO top-level `beta` declaration.
|
||||
* A.ts: `export * from './B'` (wildcard re-export; populates A's closure).
|
||||
* C.ts: `import { beta } from './A'` — resolves through A's closure, which
|
||||
* the direct check on A's own (empty) localDefs never satisfies.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ParsedFile, ParsedImport, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { finalizeScopeModel } from '../../../src/core/ingestion/finalize-orchestrator.js';
|
||||
import { vueScopeResolver } from '../../../src/core/ingestion/languages/vue/scope-resolver.js';
|
||||
import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js';
|
||||
import { javascriptScopeResolver } from '../../../src/core/ingestion/languages/javascript/scope-resolver.js';
|
||||
|
||||
const mkScope = (id: ScopeId, filePath: string): Scope => ({
|
||||
id,
|
||||
parent: null,
|
||||
kind: 'Module',
|
||||
range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 },
|
||||
filePath,
|
||||
bindings: new Map(),
|
||||
ownedDefs: [],
|
||||
imports: [],
|
||||
typeBindings: new Map(),
|
||||
});
|
||||
|
||||
const mkFile = (filePath: string, overrides: Partial<ParsedFile> = {}): ParsedFile => ({
|
||||
filePath,
|
||||
moduleScope: `scope:${filePath}#module`,
|
||||
scopes: overrides.scopes ?? [mkScope(`scope:${filePath}#module`, filePath)],
|
||||
parsedImports: overrides.parsedImports ?? [],
|
||||
localDefs: overrides.localDefs ?? [],
|
||||
referenceSites: overrides.referenceSites ?? [],
|
||||
});
|
||||
|
||||
function buildFixture(topLevelOnly: boolean) {
|
||||
// B.ts: a class with a method `beta`, and NO top-level `beta` of any kind.
|
||||
const fooClass: SymbolDefinition = {
|
||||
nodeId: 'def:Foo',
|
||||
filePath: 'B.ts',
|
||||
type: 'Class',
|
||||
qualifiedName: 'B.Foo',
|
||||
};
|
||||
const fooBetaMethod: SymbolDefinition = {
|
||||
nodeId: 'def:Foo.beta',
|
||||
filePath: 'B.ts',
|
||||
type: 'Method',
|
||||
ownerId: 'def:Foo',
|
||||
qualifiedName: 'B.Foo.beta',
|
||||
};
|
||||
const fileB = mkFile('B.ts', { localDefs: [fooClass, fooBetaMethod] });
|
||||
|
||||
// A.ts: `export * from './B'` — a wildcard re-export, no local defs of its own.
|
||||
const wildcardImport: ParsedImport = { kind: 'wildcard', targetRaw: 'B.ts' };
|
||||
const fileA = mkFile('A.ts', { parsedImports: [wildcardImport] });
|
||||
|
||||
// C.ts: `import { beta } from './A'`.
|
||||
const namedImport: ParsedImport = {
|
||||
kind: 'named',
|
||||
localName: 'beta',
|
||||
importedName: 'beta',
|
||||
targetRaw: 'A.ts',
|
||||
};
|
||||
const fileC = mkFile('C.ts', { parsedImports: [namedImport] });
|
||||
|
||||
const out = finalizeScopeModel([fileB, fileA, fileC], {
|
||||
hooks: {
|
||||
resolveImportTarget: (targetRaw) => targetRaw,
|
||||
namedImportsBindTopLevelOnly: topLevelOnly,
|
||||
},
|
||||
});
|
||||
|
||||
const cImports = out.imports.get(fileC.moduleScope) ?? [];
|
||||
return { out, fileC, fooBetaMethod, betaImport: cImports[0] };
|
||||
}
|
||||
|
||||
describe('M17 — export * wildcard fan-out gated by namedImportsBindTopLevelOnly', () => {
|
||||
it('a language that does NOT opt in (Python/Java-shaped: hook false) publishes the class method through the wildcard — wide index preserved', () => {
|
||||
const { betaImport, fooBetaMethod } = buildFixture(false);
|
||||
expect(betaImport).toBeDefined();
|
||||
expect(betaImport!.linkStatus).toBeUndefined();
|
||||
expect(betaImport!.targetFile).toBe('A.ts');
|
||||
expect(betaImport!.targetDefId).toBe(fooBetaMethod.nodeId);
|
||||
});
|
||||
|
||||
it('a language that DOES opt in (ECMAScript-shaped: hook true) refuses — the wildcard fan-out narrows to module-level declarations only', () => {
|
||||
const { betaImport } = buildFixture(true);
|
||||
expect(betaImport).toBeDefined();
|
||||
// Neither A's own (empty) localDefs nor A's wildcard-populated closure
|
||||
// (narrowed to MEMBER_LABELS-excluded defs) ever publish `beta` — the
|
||||
// import stays unresolved rather than binding a class member no
|
||||
// top-level name legitimizes.
|
||||
expect(betaImport!.linkStatus).toBe('unresolved');
|
||||
expect(betaImport!.targetDefId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('mutation check: a top-level (non-member) def behind the same wildcard still binds under EITHER setting', () => {
|
||||
// Control — proves the gate narrows MEMBER labels specifically, not
|
||||
// wildcard re-exports wholesale.
|
||||
for (const topLevelOnly of [false, true]) {
|
||||
const alphaVar: SymbolDefinition = {
|
||||
nodeId: 'def:alpha',
|
||||
filePath: 'B.ts',
|
||||
type: 'Variable',
|
||||
qualifiedName: 'B.alpha',
|
||||
};
|
||||
const fileB = mkFile('B.ts', { localDefs: [alphaVar] });
|
||||
const fileA = mkFile('A.ts', {
|
||||
parsedImports: [{ kind: 'wildcard', targetRaw: 'B.ts' }],
|
||||
});
|
||||
const fileC = mkFile('C.ts', {
|
||||
parsedImports: [
|
||||
{ kind: 'named', localName: 'alpha', importedName: 'alpha', targetRaw: 'A.ts' },
|
||||
],
|
||||
});
|
||||
const out = finalizeScopeModel([fileB, fileA, fileC], {
|
||||
hooks: {
|
||||
resolveImportTarget: (targetRaw) => targetRaw,
|
||||
namedImportsBindTopLevelOnly: topLevelOnly,
|
||||
},
|
||||
});
|
||||
const edge = out.imports.get(fileC.moduleScope)?.[0];
|
||||
expect(edge?.linkStatus, `topLevelOnly=${topLevelOnly}`).toBeUndefined();
|
||||
expect(edge?.targetDefId, `topLevelOnly=${topLevelOnly}`).toBe('def:alpha');
|
||||
}
|
||||
});
|
||||
|
||||
it('Vue opts in (TS semantics); JS/TS themselves already do — every migrated resolver that sets the hook does so as `true`', () => {
|
||||
expect(vueScopeResolver.namedImportsBindTopLevelOnly).toBe(true);
|
||||
expect(typescriptScopeResolver.namedImportsBindTopLevelOnly).toBe(true);
|
||||
expect(javascriptScopeResolver.namedImportsBindTopLevelOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue