diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index fb0d5022c..b3acab128 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -1000,19 +1000,28 @@ function namedReexportCandidates( * property. The wildcard closure loop tolerates the wider set because nobody * imports a property by name; a refusal cannot afford the same tolerance. * - * `Variable` is excluded too. `SymbolDefinition` carries no export marker, and - * the typical top-level `const` in a barrel's sources is module-private - * (`const category = ['Axis']` in fourteen option-builder files) — the closure - * would never be asked for it, so refusing it protects nothing and would refuse - * a real exported constant of the same name. Residual risk, accepted: 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 + * 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; `SymbolDefinition` carries - * neither a scope nor an export marker to do better with. Measured after both - * exclusions: grafana@871af0720 refuses 52 names (from 2,640 before them), - * discourse@3f71fa15c 5. + * 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. */ @@ -1023,7 +1032,8 @@ const NON_EXPORTABLE_MEMBER_LABELS: readonly string[] = [ 'Parameter', 'Field', ]; -const NON_EXPORTABLE_LABELS: ReadonlySet = new Set([ +/** Labels excluded from the collision set when no export evidence is present. */ +const UNMARKED_NON_COLLIDING_LABELS: ReadonlySet = new Set([ ...NON_EXPORTABLE_MEMBER_LABELS, 'Variable', ]); @@ -1034,9 +1044,34 @@ const NON_EXPORTABLE_LABELS: ReadonlySet = new Set([ */ const MEMBER_LABELS: ReadonlySet = new Set(NON_EXPORTABLE_MEMBER_LABELS); -/** A declaration `export *` could publish: top-level and of an exportable kind. */ +/** + * 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 { - return def.ownerId === undefined && !NON_EXPORTABLE_LABELS.has(def.type); + // 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, @@ -1045,6 +1080,7 @@ function collectAmbiguousWildcards( ): ReadonlyMap { const shadowed = new Set(); for (const def of file.localDefs) { + if (!canShadowWildcard(def)) continue; const name = deriveSimpleName(def); if (name !== null) shadowed.add(name); } @@ -1354,10 +1390,17 @@ function indexExportsByName( } /** - * `indexExportsByName` restricted to declarations `export *` can publish: + * `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; `Variable` stays, since a - * barrel legitimately republishes a `const`. Same memoization contract. + * 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[], @@ -1371,7 +1414,11 @@ function indexTopLevelExportsByName( if (cached !== undefined) return cached; const index = new Map(); for (const d of defs) { - if (MEMBER_LABELS.has(d.type)) continue; + // 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); diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index e90b0be85..4334d1534 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -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`) diff --git a/gitnexus/src/core/ingestion/community-processor.ts b/gitnexus/src/core/ingestion/community-processor.ts index dd32b1a23..77b66d73b 100644 --- a/gitnexus/src/core/ingestion/community-processor.ts +++ b/gitnexus/src/core/ingestion/community-processor.ts @@ -313,7 +313,7 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun const connectedNodes = new Set(); const nodeDegree = new Map(); - // Field-wise scan (#2680): this walks every edge and reads only these four, + // Field-wise scan (#2680): this walks every edge and reads only these five, // so taking objects would allocate one per edge for nothing. knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence, reason) => { if (!isClusteringRelationship(type) || sourceId === targetId) return; diff --git a/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts b/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts index 6411b4ed4..8d91e8a37 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts @@ -471,8 +471,14 @@ export async function loadNodeWorkspacePackages( const key = path.resolve(repoRoot); const cached = workspacePackagesMemo.get(key); if (cached !== undefined) return cached; - const pending = loadNodeWorkspacePackagesUncached(repoRoot).catch((err: unknown) => { - workspacePackagesMemo.delete(key); + const pending: Promise = loadNodeWorkspacePackagesUncached( + repoRoot, + ).catch((err: unknown) => { + // Evict only OUR entry. If this load was invalidated while in flight and a + // newer load has since been installed under the same key, deleting by key + // alone would evict that one, and every later caller would start another + // full scan instead of joining it. + if (workspacePackagesMemo.get(key) === pending) workspacePackagesMemo.delete(key); throw err; }); workspacePackagesMemo.set(key, pending); @@ -726,9 +732,16 @@ async function discoverSourceEntries( } for (const cfg of ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']) { try { - const text = await fs.readFile(path.join(dir, cfg), 'utf-8'); - const match = /lib\s*:\s*\{[^}]*?entry\s*:\s*['"]([^'"]+)['"]/s.exec(text); - if (match !== null) candidates.push(rebase(match[1]!)); + const text = stripJsComments(await fs.readFile(path.join(dir, cfg), 'utf-8')); + // EVERY `lib: { entry: '…' }` in the live text is a candidate, not the + // first: a stale `lib` object left in the file (or a second one under a + // conditional) is a competing claim, and two claims are an ambiguity the + // `existing.length > 1` rule below refuses. Comments are stripped first — + // a commented-out `// old lib: { entry: 'src/wrong.ts' }` used to be the + // first match and became the package's entry. + for (const match of text.matchAll(/lib\s*:\s*\{[^}]*?entry\s*:\s*['"]([^'"]+)['"]/gs)) { + push(candidates, rebase(match[1]!)); + } } catch { /* no such config */ } @@ -747,6 +760,41 @@ async function discoverSourceEntries( return { entries: existing, ambiguous: [] }; } +/** + * Remove line (`//`) and block comments from JS/TS config text before a regex + * reads it. String contents are preserved (a `//` inside quotes is not a + * comment), so `entry: 'src/index.ts'` survives, as does a comment opener + * written inside a string. + */ +export function stripJsComments(text: string): string { + let out = ''; + let i = 0; + while (i < text.length) { + const ch = text[i]!; + const next = text[i + 1]; + if (ch === '"' || ch === "'" || ch === '`') { + const quote = ch; + let j = i + 1; + while (j < text.length && text[j] !== quote) { + if (text[j] === '\\') j++; + j++; + } + out += text.slice(i, j + 1); + i = j + 1; + } else if (ch === '/' && next === '/') { + const end = text.indexOf('\n', i); + i = end === -1 ? text.length : end; + } else if (ch === '/' && next === '*') { + const end = text.indexOf('*/', i + 2); + i = end === -1 ? text.length : end + 2; + } else { + out += ch; + i++; + } + } + return out; +} + /** A repo-relative stem exists as a source file (with any TS/JS extension). */ // The root is threaded explicitly: a module-level "current root" clobbered // under two concurrent scans and turned an ambiguity refusal into a confident diff --git a/gitnexus/src/core/ingestion/languages/dart/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/dart/name-fallback-visibility.ts index 59ee00f33..562311bb8 100644 --- a/gitnexus/src/core/ingestion/languages/dart/name-fallback-visibility.ts +++ b/gitnexus/src/core/ingestion/languages/dart/name-fallback-visibility.ts @@ -7,18 +7,23 @@ * imported by any spelling. So a `_`-prefixed candidate in another file is an * impossible call, not an unlikely one. * - * "Library" is approximated by "the same DIRECTORY". 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 (nothing in `languages/dart/` reads them). Refusing on "same file" would - * therefore delete real edges on Flutter's dominant generated-code idiom: - * `factory Foo.fromJson(j) => _$FooFromJson(j)` calls into `foo.g.dart`, a - * `part` of the same library that sits beside it. Parts are, in practice, - * always siblings of their library file, so a same-directory `_` candidate is - * treated as plausible (and stays a LABELED edge); only a `_` candidate in - * another directory is refused, which no `part` layout can make legal. A caller - * that names the candidate's file in a directive is also accepted, for the day - * the extractor surfaces `part` as an import target. + * 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 @@ -29,7 +34,6 @@ import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; import { - directoryOf, modulePathReaches, stripExtension, } from '../../scope-resolution/utils/name-fallback-visibility.js'; @@ -63,8 +67,11 @@ export function dartIsGlobalNameFallbackPlausible(ctx: { }): boolean { if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true; if (!isPrivateDartName(ctx.candidate)) return true; - // Sibling files may be `part`s of one library (see the header) — undecidable - // without `part` extraction, so allowed rather than refused. - if (directoryOf(ctx.candidate.filePath) === directoryOf(ctx.callerParsed.filePath)) return true; - return sharesLibrary(ctx.callerParsed, ctx.candidate.filePath); + // 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; } diff --git a/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts index bc49ae6c0..c6f93cb24 100644 --- a/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts +++ b/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts @@ -33,7 +33,13 @@ import { } from '../../scope-resolution/utils/name-fallback-visibility.js'; import { inferGoPackageName } from './package-clause.js'; -/** `foo_test` → `foo`; mirrors `package-siblings.ts`. */ +/** + * `foo_test` → `foo`. `package-siblings.ts` refines this with the directory's + * non-test package clauses (a package genuinely NAMED `foo_test` keeps its + * name there); this hook sees one file at a time and cannot, so such a + * package's internal tests are classified external here. Refuse-only tier, so + * the cost is a missed same-package guess, never a wrong edge. + */ function internalPackageOf(pkgName: string): string { return pkgName.endsWith('_test') && pkgName.length > '_test'.length ? pkgName.slice(0, -'_test'.length) @@ -101,6 +107,13 @@ export function goIsGlobalNameFallbackPlausible(ctx: { return true; } + // Different directory, so a 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; diff --git a/gitnexus/src/core/ingestion/languages/go/package-siblings.ts b/gitnexus/src/core/ingestion/languages/go/package-siblings.ts index fb45613d5..79f628ef2 100644 --- a/gitnexus/src/core/ingestion/languages/go/package-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/go/package-siblings.ts @@ -29,15 +29,34 @@ function isGoTestFile(filePath: string): boolean { } /** - * `foo_test` → `foo`; a package name that is not an external-test name → itself. - * Known miss (never a wrong edge): a package genuinely NAMED `foo_test` has its - * internal `_test.go` files keyed as external tests of `foo`, so they see no - * unexported siblings. Disambiguating needs the directory's non-test clause. + * The package a `_test.go` file's clause belongs to, given the package names + * the directory's NON-test files declare. + * + * `package foo_test` is the external-test convention ONLY when the directory's + * real package is `foo`; the `_test` suffix is otherwise a legal identifier + * (`package foo_test` in a directory whose non-test files also say `foo_test`). + * Stripping it unconditionally keyed such a package's own internal tests as + * external tests of a non-existent `foo`, so they saw no sibling at all — a + * resolution miss on every same-package call. Strip only when the stripped + * name is what the non-test siblings declare; with no non-test sibling to ask + * (a test-only directory) the convention is assumed, as before. */ -function internalPackageOf(pkgName: string): string { - return pkgName.endsWith('_test') && pkgName.length > '_test'.length - ? pkgName.slice(0, -'_test'.length) - : pkgName; +function testFilePackageOf( + declared: string, + nonTestPackagesInDir: ReadonlySet | undefined, +): { readonly pkg: string; readonly external: boolean } { + if (!declared.endsWith('_test') || declared.length <= '_test'.length) { + return { pkg: declared, external: false }; + } + const stripped = declared.slice(0, -'_test'.length); + if (nonTestPackagesInDir !== undefined && nonTestPackagesInDir.has(declared)) { + return { pkg: declared, external: false }; + } + if (nonTestPackagesInDir === undefined || nonTestPackagesInDir.has(stripped)) { + return { pkg: stripped, external: true }; + } + // Neither name is declared by a non-test sibling: keep the clause as written. + return { pkg: declared, external: false }; } export function populateGoPackageSiblings( @@ -64,16 +83,31 @@ export function populateGoPackageSiblings( readonly external: boolean; } const filesByPackage = new Map(); + // Same derivation as `populateGoWorkspaceOwners` — one shared resolver, so + // the two passes cannot disagree about a file's package (#2837). The + // no-clause case is reported there; warning twice for one fact would be + // noise. + const declaredByFile = new Map(); + const nonTestPackagesByDir = new Map>(); for (const parsed of parsedFiles) { - // Same derivation as `populateGoWorkspaceOwners` — one shared resolver, so - // the two passes cannot disagree about a file's package (#2837). The - // no-clause case is reported there; warning twice for one fact would be - // noise. const declared = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? ''); if (declared === null) continue; + declaredByFile.set(parsed.filePath, declared); + if (isGoTestFile(parsed.filePath)) continue; + const dir = goPackageDir(parsed.filePath); + const names = nonTestPackagesByDir.get(dir) ?? new Set(); + names.add(declared); + nonTestPackagesByDir.set(dir, names); + } + for (const parsed of parsedFiles) { + const declared = declaredByFile.get(parsed.filePath); + if (declared === undefined) continue; const isTest = isGoTestFile(parsed.filePath); - const external = isTest && declared !== internalPackageOf(declared); - const key = `${goPackageDir(parsed.filePath)}\0${isTest ? internalPackageOf(declared) : declared}`; + const dir = goPackageDir(parsed.filePath); + const { pkg, external } = isTest + ? testFilePackageOf(declared, nonTestPackagesByDir.get(dir)) + : { pkg: declared, external: false }; + const key = `${dir}\0${pkg}`; const list = filesByPackage.get(key) ?? []; list.push({ filePath: parsed.filePath, defs: [...parsed.localDefs], isTest, external }); filesByPackage.set(key, list); diff --git a/gitnexus/src/core/ingestion/languages/javascript/captures.ts b/gitnexus/src/core/ingestion/languages/javascript/captures.ts index d3ba94791..2b68a3662 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/captures.ts @@ -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) { @@ -1207,6 +1210,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. diff --git a/gitnexus/src/core/ingestion/languages/ruby/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/ruby/name-fallback-visibility.ts index 98f1f5ee8..cff251e2d 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/name-fallback-visibility.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/name-fallback-visibility.ts @@ -107,14 +107,35 @@ function ownerLabelOf( 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. + if (ctx.sourceTextOf !== undefined) { + 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. @@ -148,11 +169,16 @@ export function rubyIsGlobalNameFallbackPlausible(ctx: { } } // A bare mention of the constant anywhere in the caller (`Billing::Invoice`, - // `Invoice.new`) is enough to make the namespace present in this file. + // `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.includes(constant)) { + if ( + site.rawQualifiedName !== undefined && + site.rawQualifiedName.split(/::|\./).some((segment) => segment === constant) + ) { return true; } } diff --git a/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts index 0835f745a..58c165d61 100644 --- a/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts +++ b/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts @@ -82,14 +82,41 @@ export function rustIsGlobalNameFallbackPlausible(ctx: { // than refuse on an unanswered question. if (candidateModule === '') return true; + const candidateName = rustSimpleNameOf(ctx.candidate); for (const imp of ctx.callerParsed.parsedImports) { const usePath = rustUsePathOf(imp.targetRaw); + // The `use` path itself names the candidate's module (`use crate::a;`, a + // glob `use crate::a::*`, or a decomposed form whose source is the module): + // the module was brought into scope. Tolerant on purpose — see the header + // of `modulePathReaches` on which direction is the safe one. if (modulePathReaches(usePath, candidateModule)) return true; - // A `use` names an ITEM as often as a module (`use crate::user::User`), and - // whether `targetRaw` includes that final name varies by import form. Try - // the parent path too, or a module-only match would be missed. + if (imp.kind === 'wildcard') 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. + // Matched on the ORIGINAL name (`importedName`): an alias renames the local + // handle, so the edge it authorizes is the one written under the alias. + if (importedNameOf(imp) !== candidateName) continue; 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] ?? ''; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index 2e3a76290..b0cc8fb9a 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -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) { @@ -660,6 +663,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); diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 8ee94e265..79bcd08b3 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -16,6 +16,7 @@ */ import { createKnowledgeGraph } from '../graph/graph.js'; +import type { KnowledgeGraph } from '../graph/types.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 +411,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 +479,7 @@ export const runPipelineFromRepo = async ( communityResult, processResult, resolutionOutcomes, + resolvedCalleeNamesByCaller, undecidedSatisfaction, usedWorkerPool, reparsedFileCount, @@ -518,3 +525,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, + nodes: Pick, +): ReadonlyMap> { + const out = new Map>(); + edges.forEachRelationshipFields((sourceId, targetId, type) => { + if (type !== 'CALLS') 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(); + out.set(sourceId, names); + } + names.add(name); + }); + return out; +} diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 31f66b896..ebc8e72b2 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -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 } : {}), }; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index 55becdcb6..1107dcad0 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -503,9 +503,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; diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 5d021d8eb..77d1e1d34 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -201,7 +201,17 @@ export function emitFreeCallFallback( }; for (const parsed of parsedFiles) { - type PendingRel = { rel: Parameters[0]; gatedAll: boolean }; + type PendingRel = { + rel: Parameters[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(); const bindingCandidatesByScope = options.freeCallsRequireInstanceOwnership === true @@ -686,44 +696,62 @@ export function emitFreeCallFallback( // 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', - // 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). - confidence: fnDefFromGlobalNameFallback ? 0.5 : 0.85, - reason: fnDefFromGlobalNameFallback - ? GLOBAL_NAME_FALLBACK_REASON - : // 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. - 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; diff --git a/gitnexus/src/core/ingestion/scope-resolution/utils/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/scope-resolution/utils/name-fallback-visibility.ts index 7219f37ad..2c66f1c05 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/utils/name-fallback-visibility.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/utils/name-fallback-visibility.ts @@ -21,8 +21,10 @@ export function directoryOf(filePath: string): string { /** * Split a module path into segments, accepting the three separators languages * spell module nesting with: `/` (Go, Node), `::` (Rust, C++) and `.` (JVM, - * Python). Empty segments are dropped, so a leading `./` or `crate::` prefix - * contributes nothing. + * 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. diff --git a/gitnexus/src/core/ingestion/ts-js-export-marker.ts b/gitnexus/src/core/ingestion/ts-js-export-marker.ts new file mode 100644 index 000000000..b3c48c42a --- /dev/null +++ b/gitnexus/src/core/ingestion/ts-js-export-marker.ts @@ -0,0 +1,146 @@ +/** + * 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 `, `export = `. */ + readonly namedLocals: ReadonlySet; + /** The file exports through a CommonJS assignment: a plain "not under + * `export`" is no verdict there. */ + readonly commonJs: boolean; +} + +const CJS_EXPORT_ASSIGNMENT = /^\s*(module\.exports\b|exports\s*[.[]|this\.[A-Za-z_$][\w$]*\s*=)/; +const CJS_SURFACE = /\bmodule\.exports\b|\bexports\s*[.[=]/; + +/** Node types below which a declaration is nested, not module-level. */ +const NESTING_BOUNDARIES: ReadonlySet = 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', + 'internal_module', +]); + +/** + * 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(); + // 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. Text-level on purpose: the alias forms + // are open-ended and a missed one would mark a real export private. + let commonJs = CJS_SURFACE.test(root.text); + for (const stmt of root.namedChildren) { + if (stmt.type === 'export_statement') { + 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 (stmt.type === 'expression_statement' && CJS_EXPORT_ASSIGNMENT.test(stmt.text)) { + 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 || left.type !== 'member_expression') return false; + if (left.childForFieldName('object')?.text !== 'module') return false; + if (left.childForFieldName('property')?.text !== 'exports') 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; + while (current !== null && current.type !== 'program') { + if (current.type === 'export_statement') return true; + 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) { + return evidence.commonJs ? undefined : false; + } + current = current.parent; + } + if (evidence.commonJs) return undefined; + return evidence.namedLocals.has(nameNode.text); +} diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index ac26c15f6..add86ccd8 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -21,6 +21,7 @@ import { invalidateNodeWorkspacePackages } from './ingestion/import-resolvers/no import { logNameFallbackSummary, summarizeNameFallback, + countCallsByLanguage, } from './ingestion/scope-resolution/name-fallback-summary.js'; import { runPipelineFromRepo } from './ingestion/pipeline.js'; import { @@ -3872,7 +3873,10 @@ async function runFullAnalysisInner( // Census of name-guessed CALLS edges (labeled `global-name-fallback`), refused // impossibles and ambiguous `export *` names — the honesty readout for this // run's resolution. Logged, and persisted below as `nameFallbackEdges`. - const nameFallbackSummary = summarizeNameFallback(resolutionOutcomes); + const nameFallbackSummary = summarizeNameFallback( + resolutionOutcomes, + countCallsByLanguage(pipelineResult.resolvedCalleeNamesByCaller, pipelineResult.graph), + ); logNameFallbackSummary(nameFallbackSummary); // Annotated so the capabilities stamp below is compile-checked against diff --git a/gitnexus/src/types/pipeline.ts b/gitnexus/src/types/pipeline.ts index d665a7acf..4136cca42 100644 --- a/gitnexus/src/types/pipeline.ts +++ b/gitnexus/src/types/pipeline.ts @@ -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>; /** * Interfaces whose structural-satisfaction check could not be completed * (#2873). Empty for languages with no structural detection. diff --git a/gitnexus/test/integration/resolvers/barrel-review-3182-repros.test.ts b/gitnexus/test/integration/resolvers/barrel-review-3182-repros.test.ts new file mode 100644 index 000000000..f2cfa840e --- /dev/null +++ b/gitnexus/test/integration/resolvers/barrel-review-3182-repros.test.ts @@ -0,0 +1,137 @@ +/** + * 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) { + 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('2. a CommonJS `module.exports = { alpha() {} }` member binds an exact destructured require', async () => { + const files = { + 'package.json': '{ "name": "r", "private": true }\n', + 'lib.js': `module.exports = { 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); +}); diff --git a/gitnexus/test/integration/resolvers/barrel-wildcard-arrow-const.test.ts b/gitnexus/test/integration/resolvers/barrel-wildcard-arrow-const.test.ts index 17d1fb989..9f9906bdd 100644 --- a/gitnexus/test/integration/resolvers/barrel-wildcard-arrow-const.test.ts +++ b/gitnexus/test/integration/resolvers/barrel-wildcard-arrow-const.test.ts @@ -18,14 +18,16 @@ async function run(name: string, extra: Record, remove: string[] const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-c2b-${name}-`)); const files: Record = { ...base, ...extra }; for (const r of remove) delete files[r]; - writeFixtureRepo(dir, files); - const result = await runPipelineFromRepo(dir, () => {}); - const targets = getRelationships(result, 'CALLS') - .filter((e) => e.sourceFilePath.includes('packages/app/src/main')) - .map((e) => e.target) - .sort(); - fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); - return targets; + 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 () => { diff --git a/gitnexus/test/unit/node-workspace-memo-rejection.test.ts b/gitnexus/test/unit/node-workspace-memo-rejection.test.ts new file mode 100644 index 000000000..90dfac825 --- /dev/null +++ b/gitnexus/test/unit/node-workspace-memo-rejection.test.ts @@ -0,0 +1,115 @@ +/** + * Regression (review finding on #3182, node-workspace-packages.ts:474) — a + * rejected, already-INVALIDATED load must not evict the newer load memoized + * under the same key. Sequence: load A in flight → `invalidate(key)` → load B + * installed → A rejects. A's handler used to `delete(key)` unconditionally, + * throwing B away so every later caller started another full scan. + */ +import { describe, it, expect, vi, afterAll } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; + +const ctx = vi.hoisted(() => ({ + gateRoot: null as string | null, + reachedResolve: null as (() => void) | null, + reached: null as Promise | null, + releaseGate: null as (() => void) | null, + gate: null as Promise | null, + failNextIgnoreCheck: false, + rootReaddirCalls: 0, + watchedRoot: null as string | null, +})); +ctx.reached = new Promise((resolve) => { + ctx.reachedResolve = resolve; +}); +ctx.gate = new Promise((resolve) => { + ctx.releaseGate = resolve; +}); + +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + const d = (actual as unknown as { default: typeof actual }).default ?? actual; + return { + default: new Proxy(d, { + get(target, prop) { + if (prop === 'readdir') { + return async (p: string, opts: unknown) => { + // Park load A on its FIRST readdir of the repo root; everything + // else — including load B's entire scan — proceeds unmodified. + if (String(p) === ctx.watchedRoot) ctx.rootReaddirCalls++; + if (ctx.gateRoot !== null && String(p) === ctx.gateRoot) { + ctx.gateRoot = null; + ctx.reachedResolve!(); + await ctx.gate; + } + return (target.readdir as (p: string, o: unknown) => Promise)(p, opts); + }; + } + const v = Reflect.get(target, prop, target) as unknown; + return typeof v === 'function' ? (v as (...args: unknown[]) => unknown).bind(target) : v; + }, + }), + }; +}); + +vi.mock('../../src/config/ignore-service.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // Called from the scan loop OUTSIDE any try/catch — the one place a + // throw turns into a rejected load promise. + isHardcodedIgnoredDirectoryAtPath: (repoRoot: string, dir: string) => { + if (ctx.failNextIgnoreCheck) { + ctx.failNextIgnoreCheck = false; + throw new Error('injected scan failure'); + } + return actual.isHardcodedIgnoredDirectoryAtPath(repoRoot, dir); + }, + }; +}); + +describe('node-workspace-packages memo: a rejected invalidated load keeps the newer entry', () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-memo-reject-')); + const w = (p: string, s: string) => { + fs.mkdirSync(path.dirname(path.join(repo, p)), { recursive: true }); + fs.writeFileSync(path.join(repo, p), s); + }; + w('package.json', JSON.stringify({ name: 'root', private: true, workspaces: ['packages/*'] })); + w('packages/lib/package.json', JSON.stringify({ name: '@m/lib', main: 'src/index.ts' })); + w('packages/lib/src/index.ts', 'export const x = 1;\n'); + + afterAll(() => { + fs.rmSync(repo, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + it('load B survives load A rejecting after invalidation', async () => { + const { loadNodeWorkspacePackages, invalidateNodeWorkspacePackages } = + await import('../../src/core/ingestion/import-resolvers/node-workspace-packages.js'); + const key = path.resolve(repo); + + ctx.gateRoot = key; + ctx.watchedRoot = key; + const loadA = loadNodeWorkspacePackages(repo); + await ctx.reached; // A is parked mid-scan + + invalidateNodeWorkspacePackages(repo); + const loadB = loadNodeWorkspacePackages(repo); + expect(loadB).not.toBe(loadA); + const packagesB = await loadB; // B completes and is memoized + expect(packagesB?.byName.has('@m/lib') ?? false).toBe(true); + + // Now let A resume and blow up. + ctx.failNextIgnoreCheck = true; + ctx.releaseGate!(); + await expect(loadA).rejects.toThrow('injected scan failure'); + + // The memo must still serve B, not start a fresh scan. (`async` re-wraps + // the cached promise, so identity cannot be compared — count scans instead.) + const scansBefore = ctx.rootReaddirCalls; + expect(scansBefore).toBeGreaterThan(0); + const third = await loadNodeWorkspacePackages(repo); + expect(third).toBe(packagesB); + expect(ctx.rootReaddirCalls).toBe(scansBefore); + }); +}); diff --git a/gitnexus/test/unit/node-workspace-vite-entry-comments.test.ts b/gitnexus/test/unit/node-workspace-vite-entry-comments.test.ts new file mode 100644 index 000000000..ad6adfe57 --- /dev/null +++ b/gitnexus/test/unit/node-workspace-vite-entry-comments.test.ts @@ -0,0 +1,76 @@ +/** + * Review finding on #3182 (magyargergo, node-workspace-packages.ts:730): the + * vite `lib.entry` regex took its FIRST match, which could sit inside a comment + * (`// old lib: { entry: 'src/wrong.ts' }`) ahead of the live config. Comments + * are stripped first, and every live `lib.entry` is a candidate so two + * disagreeing ones are refused as ambiguous rather than first-wins. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + loadNodeWorkspacePackages, + stripJsComments, +} from '../../src/core/ingestion/import-resolvers/node-workspace-packages.js'; + +describe('stripJsComments', () => { + it('drops line and block comments and keeps string contents intact', () => { + expect(stripJsComments("a; // lib: { entry: 'x' }\nb /* lib: {\n entry: 'y' } */ c")).toBe( + 'a; \nb c', + ); + expect(stripJsComments("const u = 'http://x/*y'; // c")).toBe("const u = 'http://x/*y'; "); + expect(stripJsComments('const s = "a\\"//b"; x')).toBe('const s = "a\\"//b"; x'); + }); +}); + +describe('vite lib.entry discovery ignores comments and refuses disagreeing entries', () => { + let dir: string; + const w = (p: string, s: string) => { + fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true }); + fs.writeFileSync(path.join(dir, p), s); + }; + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-vite-comment-')); + w('package.json', JSON.stringify({ name: 'root', private: true, workspaces: ['packages/*'] })); + // A commented-out stale entry BEFORE the live one; both files exist. + w( + 'packages/commented/package.json', + JSON.stringify({ name: '@acme/commented', exports: { '.': './dist/bundle.js' } }), + ); + w( + 'packages/commented/vite.config.ts', + `// old lib: { entry: "src/wrong.ts" }\n/* also once: lib: { entry: 'src/wrong.ts' } */\nexport default defineConfig({ build: { lib: { entry: "src/right.ts" } } });\n`, + ); + w('packages/commented/src/wrong.ts', 'export const wrong = 1;\n'); + w('packages/commented/src/right.ts', 'export const right = 1;\n'); + // Two LIVE lib objects that disagree: ambiguous, refuse. + w( + 'packages/twolive/package.json', + JSON.stringify({ name: '@acme/twolive', main: 'dist/index.js' }), + ); + w( + 'packages/twolive/vite.config.ts', + `const a = { lib: { entry: 'src/a.ts' } };\nexport default process.env.X ? a : { build: { lib: { entry: 'src/b.ts' } } };\n`, + ); + w('packages/twolive/src/a.ts', 'export const a = 1;\n'); + w('packages/twolive/src/b.ts', 'export const b = 1;\n'); + }); + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + it('picks the live entry, never the commented one', async () => { + const pkgs = await loadNodeWorkspacePackages(dir); + const entries = pkgs!.byName.get('@acme/commented')!.entries; + expect(entries).toContain('packages/commented/src/right'); + expect(entries).not.toContain('packages/commented/src/wrong'); + }); + + it('refuses when two live lib entries name different existing files', async () => { + const pkgs = await loadNodeWorkspacePackages(dir); + const entries = pkgs!.byName.get('@acme/twolive')!.entries; + expect(entries).not.toContain('packages/twolive/src/a'); + expect(entries).not.toContain('packages/twolive/src/b'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts b/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts new file mode 100644 index 000000000..58d65c97a --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts @@ -0,0 +1,140 @@ +/** + * `@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 { + const out: Record = {}; + for (const m of emit(src, filePath)) { + const name = m['@declaration.name']?.text; + if (name === undefined) continue; + if (name in out && 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('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 (JavaScript emitter)', () => { + 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); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/free-call-fallback-guess-taint.test.ts b/gitnexus/test/unit/scope-resolution/free-call-fallback-guess-taint.test.ts new file mode 100644 index 000000000..348ea002b --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/free-call-fallback-guess-taint.test.ts @@ -0,0 +1,217 @@ +/** + * 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'; + +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: { kind: string }[] = []; + emitFreeCallFallback( + graph, + indexes, + [callerParsed, targetParsed], + buildGraphNodeLookup(graph), + { bySourceScope: new Map() }, + new Set(), + 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); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-test-file-siblings.test.ts b/gitnexus/test/unit/scope-resolution/go/go-test-file-siblings.test.ts index d11ea7d9b..6fb1ad8ef 100644 --- a/gitnexus/test/unit/scope-resolution/go/go-test-file-siblings.test.ts +++ b/gitnexus/test/unit/scope-resolution/go/go-test-file-siblings.test.ts @@ -144,4 +144,30 @@ describe('Go _test.go package siblings', () => { expect(see('m:foo', 'setup')).toEqual([]); expect(see('m:bar', 'setup')).toEqual([]); }); + + it('a package genuinely NAMED `foo_test` keeps its internal tests in their declared package', () => { + // `package foo_test` is the external-test convention only when the + // directory's real package is `foo`. Here the non-test files themselves say + // `foo_test`, so its `_test.go` files are INTERNAL tests of that package + // and must see unexported siblings; stripping `_test` blindly keyed them + // as external tests of a non-existent `foo` and published nothing. + const impl = def('impl', 'pkg/foo_test/impl.go', 'unexportedHelper'); + const fixture = def('fixture', 'pkg/foo_test/impl_test.go', 'newFixture'); + const { see } = setup([ + { path: 'pkg/foo_test/impl.go', scope: 'm:impl', pkg: 'foo_test', defs: [impl] }, + { path: 'pkg/foo_test/impl_test.go', scope: 'm:impl-test', pkg: 'foo_test', defs: [fixture] }, + ]); + expect(see('m:impl-test', 'unexportedHelper')).toEqual(['impl']); + // Non-test files still never see test-only declarations. + expect(see('m:impl', 'newFixture')).toEqual([]); + }); + + it('`package foo_test` beside `package foo` is still the external-test convention', () => { + const { see } = setup([ + { path: 'pkg/a/a.go', scope: 'm:a', pkg: 'a', defs: [helper, exported] }, + { path: 'pkg/a/a_test.go', scope: 'm:a-ext', pkg: 'a_test', defs: [testOnly] }, + ]); + expect(see('m:a-ext', 'setUpHelper')).toEqual([]); + expect(see('m:a-ext', 'NewThing')).toEqual([]); + }); }); diff --git a/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts b/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts index 8cfa559d0..3487a4d1d 100644 --- a/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts +++ b/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts @@ -168,6 +168,35 @@ describe('Go: isGlobalNameFallbackPlausible', () => { ).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('does not refuse an exported identifier in 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. @@ -187,15 +216,16 @@ describe('Go: isGlobalNameFallbackPlausible', () => { }); describe('Dart: isGlobalNameFallbackPlausible', () => { - it('REFUSES a library-private name from another directory', () => { - // No `part` layout can span directories in practice, so a `_` name across - // one is impossible, not merely unproven. + 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(false); + ).toBe(true); }); it('allows a library-private name in a SIBLING file (possible `part`)', () => { @@ -322,6 +352,40 @@ describe('Rust: isGlobalNameFallbackPlausible', () => { ).toBe(true); }); + 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('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('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 @@ -527,6 +591,68 @@ describe('Ruby: isGlobalNameFallbackPlausible', () => { ).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'), + }), + ).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('does not refuse an owned method with no nameable namespace', () => { expect( rubyIsGlobalNameFallbackPlausible({ diff --git a/gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts b/gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts new file mode 100644 index 000000000..c3ff05dde --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts @@ -0,0 +1,43 @@ +/** + * 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 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'); + 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 }); + g.addRelationship({ id: 'r2', sourceId: 'a', targetId: 'c', type: 'CALLS', confidence: 0.5 }); + g.addRelationship({ id: 'r3', sourceId: 'c', targetId: 'b', type: 'CALLS', confidence: 0.85 }); + g.addRelationship({ + id: 'r4', + sourceId: 'file', + targetId: 'a', + type: 'DEFINES', + confidence: 1, + }); + g.addRelationship({ id: 'r5', sourceId: 'a', targetId: 'file', type: 'CALLS', confidence: 1 }); + + 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 }); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/wildcard-collision-export-evidence.test.ts b/gitnexus/test/unit/scope-resolution/wildcard-collision-export-evidence.test.ts new file mode 100644 index 000000000..91d74b2bc --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/wildcard-collision-export-evidence.test.ts @@ -0,0 +1,191 @@ +/** + * `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 => ({ + 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('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'); + }); +});