fix(resolution): address review findings on #3182

Bot findings (13) and human findings (magyargergo, 6) on the scope-resolution
precision PR, each classified and fixed minimally in the PR's spirit — never
guess: refuse where impossible, label where undecidable.

Bot findings:
- finalize-algorithm.ts:1026 (Variable excluded from wildcard collisions) → TP.
  Introduced tri-state SymbolDefinition.isExported (export evidence supplied by
  the TS/JS capture emitters via @declaration.is-exported). With evidence an
  exported Variable collides; without it the prior exclusion stands.
- typescript/scope-resolver.ts:138 (private `function foo` counted as a
  wildcard provider) → TP. A def marked isExported:false is neither a collision
  candidate nor published by the `export *` closure (indexTopLevelExportsByName).
- node-workspace-packages.ts:474 (rejection evicts newer memo entry) → TP.
  Delete only when the memo still holds this load's own promise.
- dart/name-fallback-visibility.ts:68 (cross-directory `part`) → TP. The Dart
  query captures no `part` directive, so cross-file `_` names are undecidable:
  the refusal is downgraded to a labeled guess (never refused) and documented.
- go/name-fallback-visibility.ts:116 (root-package exception before _test.go)
  → TP. A candidate in any other package's `_test.go` is refused first, before
  the module-root and import checks.
- go/package-siblings.ts:76 (real package named `foo_test`) → TP. `_test` is
  stripped only when a non-test sibling in the directory declares the stripped
  name (or no non-test sibling exists); a declared `foo_test` package keeps it.
- rust/name-fallback-visibility.ts:91 (`use crate:🅰️:other` authorizes every
  item of `a`) → TP. The parent-path match now requires the imported item to be
  the candidate; a module path match or glob still authorizes the module.
- ruby/name-fallback-visibility.ts:155 (`includes(constant)` substring) → TP.
  Qualified mentions are matched segment-wise on `::` / `.`.
- name-fallback-summary.ts:104 (callsByLanguage never supplied) → TP. The
  pipeline now builds resolvedCalleeNamesByCaller through the emit sink
  (complete under streaming) and run-analyze feeds countCallsByLanguage.
- free-call-fallback.ts:710 (dedup keeps first site's label) → TP, see below.
- barrel-wildcard-arrow-const.test.ts:22 (fixture cleanup) → nit, try/finally.
- utils/name-fallback-visibility.ts:24 (`crate::` comment) → nit, corrected.
- community-processor.ts:318 ("only these four") → nit, five.

Human findings (magyargergo):
- finalize-algorithm.ts:1374 (nested function admitted as top-level export) →
  TP. The export marker stops at nesting boundaries (function bodies, class
  bodies, object literals), so the nested def is isExported:false and skipped;
  additionally resolveDefGraphId fails closed before the label-agnostic simple
  key when a function-local callable of that name exists in the file, so a
  non-callable def can no longer alias onto `wrapper.selected`.
- javascript/scope-resolver.ts:105 (CJS `module.exports = { alpha() {} }` +
  destructured require) → TP. Members of the module.exports object literal are
  marked isExported:true, and the top-level export index admits a marked
  member regardless of its Method label. Regression covers the duplicate-name case.
- finalize-algorithm.ts:1049 (class member shadows a star-vs-star collision) →
  TP. The shadow set is limited to module-level bindings (canShadowWildcard).
- node-workspace-packages.ts:730 (vite lib.entry matched inside a comment) →
  TP. Comments are stripped (stripJsComments) and every live lib.entry is a
  candidate, so two disagreeing entries are refused as ambiguous.
- ruby/name-fallback-visibility.ts:160 (instance_eval rebinds self) → TP. A
  caller file using instance_eval/class_eval/instance_exec/class_exec/
  module_eval/module_exec keeps class-owned guesses labeled instead of refused
  (file-level, source-text based — the site cannot tell its enclosing block).
- free-call-fallback.ts:710 (ordering; overlaps the bot finding) → TP. The
  deduplicated edge's label is decided at flush from every collapsed site: one
  site resolved through a real binding proves the edge (0.85 import-resolved);
  it is a guess only when all sites were guesses. Order never decides.

Validation note: integration tests run the parse workers from gitnexus/dist,
so `npm run build` in gitnexus/ is required before the suite reflects capture
changes (a stale dist masked the marker in the first run). Full suite: 20660
passed, 15 failed, all in the known environmental set (analyzer-identity*,
evidence-provenance-helper, hooks linked-worktree, pinned-install retry,
git-utils ENOTEMPTY).

Overlaps between bot and human: free-call-fallback.ts:710 ordering (both), and
the finalize-algorithm collision set (bot: Variable candidates; human: member
shadowing and nested functions) — all resolved by the same export-evidence
mechanism.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dSxjDEzuYDENrR314EiFc
This commit is contained in:
Abhinav Pandey 2026-09-05 14:22:08 +05:30
parent 6d3ac0d8ee
commit c5a4c2a63b
No known key found for this signature in database
29 changed files with 1661 additions and 98 deletions

View file

@ -1000,19 +1000,28 @@ function namedReexportCandidates(
* property. The wildcard closure loop tolerates the wider set because nobody * property. The wildcard closure loop tolerates the wider set because nobody
* imports a property by name; a refusal cannot afford the same tolerance. * imports a property by name; a refusal cannot afford the same tolerance.
* *
* `Variable` is excluded too. `SymbolDefinition` carries no export marker, and * Export evidence, when the language supplies it (`SymbolDefinition.isExported`,
* the typical top-level `const` in a barrel's sources is module-private * tri-state), settles the rest: a def marked `false` is module-private and is
* (`const category = ['Axis']` in fourteen option-builder files) the closure * neither a provider here nor published by the closure
* would never be asked for it, so refusing it protects nothing and would refuse * (`indexTopLevelExportsByName`), so a private `function foo` beside an exported
* a real exported constant of the same name. Residual risk, accepted: a * one no longer refuses the export and, the half that matters more, cannot be
* non-exported `function`/`class` sharing its name with an exported one behind * the first-listed winner the closure binds either. A def marked `true` counts
* the same barrel is counted as a collision and the export is refused a * whatever its label, `Variable` included: the closure publishes a `Variable`,
* missing edge, never a wrong one. `ownerId` is only set for class members, so * 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 * a callable nested in an object literal (`showIf: (cfg) => …` across fourteen
* option-builder files) still counts as a provider; `SymbolDefinition` carries * option-builder files) still counts as a provider when unmarked. Measured
* neither a scope nor an export marker to do better with. Measured after both * before the export marker existed: grafana@871af0720 refuses 52 names (from
* exclusions: grafana@871af0720 refuses 52 names (from 2,640 before them), * 2,640 before the member exclusion), discourse@3f71fa15c 5.
* discourse@3f71fa15c 5.
*/ */
/** Labels that are never a module export, whatever their owner. */ /** Labels that are never a module export, whatever their owner. */
@ -1023,7 +1032,8 @@ const NON_EXPORTABLE_MEMBER_LABELS: readonly string[] = [
'Parameter', 'Parameter',
'Field', 'Field',
]; ];
const NON_EXPORTABLE_LABELS: ReadonlySet<string> = new Set([ /** Labels excluded from the collision set when no export evidence is present. */
const UNMARKED_NON_COLLIDING_LABELS: ReadonlySet<string> = new Set([
...NON_EXPORTABLE_MEMBER_LABELS, ...NON_EXPORTABLE_MEMBER_LABELS,
'Variable', 'Variable',
]); ]);
@ -1034,9 +1044,34 @@ const NON_EXPORTABLE_LABELS: ReadonlySet<string> = new Set([
*/ */
const MEMBER_LABELS: ReadonlySet<string> = new Set(NON_EXPORTABLE_MEMBER_LABELS); const MEMBER_LABELS: ReadonlySet<string> = 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 { 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( function collectAmbiguousWildcards(
file: FinalizeFile, file: FinalizeFile,
@ -1045,6 +1080,7 @@ function collectAmbiguousWildcards(
): ReadonlyMap<string, readonly string[]> { ): ReadonlyMap<string, readonly string[]> {
const shadowed = new Set<string>(); const shadowed = new Set<string>();
for (const def of file.localDefs) { for (const def of file.localDefs) {
if (!canShadowWildcard(def)) continue;
const name = deriveSimpleName(def); const name = deriveSimpleName(def);
if (name !== null) shadowed.add(name); 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 * 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 * reliable while the closure is built) are skipped unless the language marked
* barrel legitimately republishes a `const`. Same memoization contract. * 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< const TOP_LEVEL_EXPORTS_BY_NAME = new WeakMap<
readonly SymbolDefinition[], readonly SymbolDefinition[],
@ -1371,7 +1414,11 @@ function indexTopLevelExportsByName(
if (cached !== undefined) return cached; if (cached !== undefined) return cached;
const index = new Map<string, SymbolDefinition>(); const index = new Map<string, SymbolDefinition>();
for (const d of defs) { 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); const name = deriveSimpleName(d);
if (name === null) continue; if (name === null) continue;
const existing = index.get(name); const existing = index.get(name);

View file

@ -111,6 +111,18 @@ export interface SymbolDefinition {
* source (for example an anonymous class). Consumers may use this only as a * source (for example an anonymous class). Consumers may use this only as a
* conservative priority hint; it does not change graph-node identity. */ * conservative priority hint; it does not change graph-node identity. */
isSynthetic?: boolean; 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 */ /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string; ownerId?: string;
/** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`) /** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`)

View file

@ -313,7 +313,7 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun
const connectedNodes = new Set<string>(); const connectedNodes = new Set<string>();
const nodeDegree = new Map<string, number>(); const nodeDegree = new Map<string, number>();
// 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. // so taking objects would allocate one per edge for nothing.
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence, reason) => { knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence, reason) => {
if (!isClusteringRelationship(type) || sourceId === targetId) return; if (!isClusteringRelationship(type) || sourceId === targetId) return;

View file

@ -471,8 +471,14 @@ export async function loadNodeWorkspacePackages(
const key = path.resolve(repoRoot); const key = path.resolve(repoRoot);
const cached = workspacePackagesMemo.get(key); const cached = workspacePackagesMemo.get(key);
if (cached !== undefined) return cached; if (cached !== undefined) return cached;
const pending = loadNodeWorkspacePackagesUncached(repoRoot).catch((err: unknown) => { const pending: Promise<NodeWorkspacePackages | null> = loadNodeWorkspacePackagesUncached(
workspacePackagesMemo.delete(key); 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; throw err;
}); });
workspacePackagesMemo.set(key, pending); 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']) { for (const cfg of ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']) {
try { try {
const text = await fs.readFile(path.join(dir, cfg), 'utf-8'); const text = stripJsComments(await fs.readFile(path.join(dir, cfg), 'utf-8'));
const match = /lib\s*:\s*\{[^}]*?entry\s*:\s*['"]([^'"]+)['"]/s.exec(text); // EVERY `lib: { entry: '…' }` in the live text is a candidate, not the
if (match !== null) candidates.push(rebase(match[1]!)); // 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 { } catch {
/* no such config */ /* no such config */
} }
@ -747,6 +760,41 @@ async function discoverSourceEntries(
return { entries: existing, ambiguous: [] }; 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). */ /** 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 // The root is threaded explicitly: a module-level "current root" clobbered
// under two concurrent scans and turned an ambiguity refusal into a confident // under two concurrent scans and turned an ambiguity refusal into a confident

View file

@ -7,18 +7,23 @@
* imported by any spelling. So a `_`-prefixed candidate in another file is an * imported by any spelling. So a `_`-prefixed candidate in another file is an
* impossible call, not an unlikely one. * impossible call, not an unlikely one.
* *
* "Library" is approximated by "the same DIRECTORY". The exact boundary is * The exact boundary is `part` / `part of` one library spanning several
* `part` / `part of` one library spanning several files, with `_` names * files, with `_` names shared between them and the extractor does not
* shared between them and the extractor does not surface `part` directives * surface `part` directives yet (the Dart query captures only `library_import`;
* yet (nothing in `languages/dart/` reads them). Refusing on "same file" would * nothing in `languages/dart/` reads `part`). Without them a cross-file `_`
* therefore delete real edges on Flutter's dominant generated-code idiom: * candidate is UNDECIDABLE, not impossible: refusing on "different file" would
* `factory Foo.fromJson(j) => _$FooFromJson(j)` calls into `foo.g.dart`, a * delete real edges on Flutter's dominant generated-code idiom (`factory
* `part` of the same library that sits beside it. Parts are, in practice, * Foo.fromJson(j) => _$FooFromJson(j)` calls into `foo.g.dart`, a `part` beside
* always siblings of their library file, so a same-directory `_` candidate is * it), and refusing on "different directory" is wrong too a `part` URI is a
* treated as plausible (and stays a LABELED edge); only a `_` candidate in * relative URI and legally traverses directories (`part '../shared/gen.dart';`).
* another directory is refused, which no `part` layout can make legal. A caller * An earlier version refused the cross-directory case as "no `part` layout can
* that names the candidate's file in a directive is also accepted, for the day * make this legal"; that claim was false, so the hook now REFUSES NOTHING and
* the extractor surfaces `part` as an import target. * 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 * 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 * 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 type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import { import {
directoryOf,
modulePathReaches, modulePathReaches,
stripExtension, stripExtension,
} from '../../scope-resolution/utils/name-fallback-visibility.js'; } from '../../scope-resolution/utils/name-fallback-visibility.js';
@ -63,8 +67,11 @@ export function dartIsGlobalNameFallbackPlausible(ctx: {
}): boolean { }): boolean {
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true; if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
if (!isPrivateDartName(ctx.candidate)) return true; if (!isPrivateDartName(ctx.candidate)) return true;
// Sibling files may be `part`s of one library (see the header) — undecidable // A directive naming the candidate's file is positive evidence of one library.
// without `part` extraction, so allowed rather than refused. if (sharesLibrary(ctx.callerParsed, ctx.candidate.filePath)) return true;
if (directoryOf(ctx.candidate.filePath) === directoryOf(ctx.callerParsed.filePath)) return true; // Any other file may be a `part` of the caller's library — a sibling or, via
return sharesLibrary(ctx.callerParsed, ctx.candidate.filePath); // 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;
} }

View file

@ -33,7 +33,13 @@ import {
} from '../../scope-resolution/utils/name-fallback-visibility.js'; } from '../../scope-resolution/utils/name-fallback-visibility.js';
import { inferGoPackageName } from './package-clause.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 { function internalPackageOf(pkgName: string): string {
return pkgName.endsWith('_test') && pkgName.length > '_test'.length return pkgName.endsWith('_test') && pkgName.length > '_test'.length
? pkgName.slice(0, -'_test'.length) ? pkgName.slice(0, -'_test'.length)
@ -101,6 +107,13 @@ export function goIsGlobalNameFallbackPlausible(ctx: {
return true; 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); const simpleName = goSimpleName(ctx.candidate);
// No identifier to read the case of — an unanswered question, not a refusal. // No identifier to read the case of — an unanswered question, not a refusal.
if (simpleName === '') return true; if (simpleName === '') return true;

View file

@ -29,15 +29,34 @@ function isGoTestFile(filePath: string): boolean {
} }
/** /**
* `foo_test` `foo`; a package name that is not an external-test name itself. * The package a `_test.go` file's clause belongs to, given the package names
* Known miss (never a wrong edge): a package genuinely NAMED `foo_test` has its * the directory's NON-test files declare.
* internal `_test.go` files keyed as external tests of `foo`, so they see no *
* unexported siblings. Disambiguating needs the directory's non-test clause. * `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 { function testFilePackageOf(
return pkgName.endsWith('_test') && pkgName.length > '_test'.length declared: string,
? pkgName.slice(0, -'_test'.length) nonTestPackagesInDir: ReadonlySet<string> | undefined,
: pkgName; ): { 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( export function populateGoPackageSiblings(
@ -64,16 +83,31 @@ export function populateGoPackageSiblings(
readonly external: boolean; readonly external: boolean;
} }
const filesByPackage = new Map<string, SiblingFile[]>(); const filesByPackage = new Map<string, SiblingFile[]>();
// 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<string, string>();
const nonTestPackagesByDir = new Map<string, Set<string>>();
for (const parsed of parsedFiles) { 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) ?? ''); const declared = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
if (declared === null) continue; 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<string>();
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 isTest = isGoTestFile(parsed.filePath);
const external = isTest && declared !== internalPackageOf(declared); const dir = goPackageDir(parsed.filePath);
const key = `${goPackageDir(parsed.filePath)}\0${isTest ? internalPackageOf(declared) : declared}`; const { pkg, external } = isTest
? testFilePackageOf(declared, nonTestPackagesByDir.get(dir))
: { pkg: declared, external: false };
const key = `${dir}\0${pkg}`;
const list = filesByPackage.get(key) ?? []; const list = filesByPackage.get(key) ?? [];
list.push({ filePath: parsed.filePath, defs: [...parsed.localDefs], isTest, external }); list.push({ filePath: parsed.filePath, defs: [...parsed.localDefs], isTest, external });
filesByPackage.set(key, list); filesByPackage.set(key, list);

View file

@ -36,6 +36,7 @@ import {
syntheticCapture, syntheticCapture,
type SyntaxNode, type SyntaxNode,
} from '../../utils/ast-helpers.js'; } from '../../utils/ast-helpers.js';
import { collectEsmExportEvidence, esmExportVerdict } from '../../ts-js-export-marker.js';
import { splitImportStatement } from '../typescript/import-decomposer.js'; import { splitImportStatement } from '../typescript/import-decomposer.js';
import { getJsParser, getJsScopeQuery, jsCachedTreeMatchesGrammar } from './query.js'; import { getJsParser, getJsScopeQuery, jsCachedTreeMatchesGrammar } from './query.js';
import { computeTsArityMetadata } from '../typescript/arity-metadata.js'; import { computeTsArityMetadata } from '../typescript/arity-metadata.js';
@ -983,6 +984,8 @@ export function emitJsScopeCaptures(
} }
const rawMatches = getJsScopeQuery(filePath).matches(tree.rootNode); 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[] = []; const out: CaptureMatch[] = [];
for (const m of rawMatches) { 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 // non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched. // all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']); 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); out.push(grouped);
// Synthesize `this` receiver type-bindings on class member functions. // Synthesize `this` receiver type-bindings on class member functions.

View file

@ -107,14 +107,35 @@ function ownerLabelOf(
return owner?.type; 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: { export function rubyIsGlobalNameFallbackPlausible(ctx: {
readonly callerParsed: ParsedFile; readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition; readonly candidate: SymbolDefinition;
readonly parsedFileOf?: (filePath: string) => ParsedFile | undefined; readonly parsedFileOf?: (filePath: string) => ParsedFile | undefined;
readonly sourceTextOf?: (filePath: string) => string | undefined;
}): boolean { }): boolean {
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true; if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
// Top-level method — the autoload shape the fallback exists for. // Top-level method — the autoload shape the fallback exists for.
if (ctx.candidate.ownerId === undefined) return true; 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 // 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 // it (see the header). A module owner, or an owner we cannot type, is not a
// refusal. // refusal.
@ -148,11 +169,16 @@ export function rubyIsGlobalNameFallbackPlausible(ctx: {
} }
} }
// A bare mention of the constant anywhere in the caller (`Billing::Invoice`, // 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 site of ctx.callerParsed.referenceSites) {
for (const constant of constants) { for (const constant of constants) {
if (site.name === constant) return true; 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; return true;
} }
} }

View file

@ -82,14 +82,41 @@ export function rustIsGlobalNameFallbackPlausible(ctx: {
// than refuse on an unanswered question. // than refuse on an unanswered question.
if (candidateModule === '') return true; if (candidateModule === '') return true;
const candidateName = rustSimpleNameOf(ctx.candidate);
for (const imp of ctx.callerParsed.parsedImports) { for (const imp of ctx.callerParsed.parsedImports) {
const usePath = rustUsePathOf(imp.targetRaw); 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; if (modulePathReaches(usePath, candidateModule)) return true;
// A `use` names an ITEM as often as a module (`use crate::user::User`), and if (imp.kind === 'wildcard') continue;
// whether `targetRaw` includes that final name varies by import form. Try // Otherwise the path names ONE item inside a module (`use crate::a::other`).
// the parent path too, or a module-only match would be missed. // 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('::'))); const parent = usePath.slice(0, Math.max(0, usePath.lastIndexOf('::')));
if (parent !== '' && modulePathReaches(parent, candidateModule)) return true; if (parent !== '' && modulePathReaches(parent, candidateModule)) return true;
} }
return false; 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] ?? '';
}

View file

@ -32,6 +32,7 @@ import {
syntheticCapture, syntheticCapture,
type SyntaxNode, type SyntaxNode,
} from '../../utils/ast-helpers.js'; } from '../../utils/ast-helpers.js';
import { collectEsmExportEvidence, esmExportVerdict } from '../../ts-js-export-marker.js';
import { splitImportStatement } from './import-decomposer.js'; import { splitImportStatement } from './import-decomposer.js';
import { getTsParser, getTsScopeQuery, tsCachedTreeMatchesGrammar } from './query.js'; import { getTsParser, getTsScopeQuery, tsCachedTreeMatchesGrammar } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
@ -388,6 +389,8 @@ export function emitTsScopeCaptures(
} }
const rawMatches = getTsScopeQuery(filePath).matches(tree.rootNode); 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[] = []; const out: CaptureMatch[] = [];
for (const m of rawMatches) { for (const m of rawMatches) {
@ -660,6 +663,20 @@ export function emitTsScopeCaptures(
// instead of re-parsing the receiver's source text. Self-gating: a // 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 // non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched. // 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']); synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']);
out.push(grouped); out.push(grouped);

View file

@ -16,6 +16,7 @@
*/ */
import { createKnowledgeGraph } from '../graph/graph.js'; import { createKnowledgeGraph } from '../graph/graph.js';
import type { KnowledgeGraph } from '../graph/types.js';
import { GraphEmitSink, type GraphEmitManifest } from '../lbug/graph-emit-sink.js'; import { GraphEmitSink, type GraphEmitManifest } from '../lbug/graph-emit-sink.js';
import { type PipelineProgress } from 'gitnexus-shared'; import { type PipelineProgress } from 'gitnexus-shared';
import { PipelineResult } from '../../types/pipeline.js'; import { PipelineResult } from '../../types/pipeline.js';
@ -410,6 +411,11 @@ export const runPipelineFromRepo = async (
graphEmitSink?.close(); 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 // Extract final results for the PipelineResult contract
const { const {
totalFiles, totalFiles,
@ -473,6 +479,7 @@ export const runPipelineFromRepo = async (
communityResult, communityResult,
processResult, processResult,
resolutionOutcomes, resolutionOutcomes,
resolvedCalleeNamesByCaller,
undecidedSatisfaction, undecidedSatisfaction,
usedWorkerPool, usedWorkerPool,
reparsedFileCount, reparsedFileCount,
@ -518,3 +525,29 @@ export const runPipelineFromRepo = async (
return result; return result;
}; };
/**
* Caller node id the simple names of every callee it has a CALLS edge to.
*
* `edges` may be the streaming sink or the raw graph; `nodes` is always the raw
* graph, which holds every node in both modes (only relationships stream). One
* O(E) field-wise pass, allocation-free per edge except for the per-caller set.
*/
export function collectResolvedCalleeNames(
edges: Pick<KnowledgeGraph, 'forEachRelationshipFields'>,
nodes: Pick<KnowledgeGraph, 'getNode'>,
): ReadonlyMap<string, ReadonlySet<string>> {
const out = new Map<string, Set<string>>();
edges.forEachRelationshipFields((sourceId, targetId, type) => {
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<string>();
out.set(sourceId, names);
}
names.add(name);
});
return out;
}

View file

@ -707,6 +707,10 @@ function buildDefFromDeclarationMatch(
const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']); const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']);
const isDeleted = parseBooleanCapture(match['@declaration.is-deleted']); const isDeleted = parseBooleanCapture(match['@declaration.is-deleted']);
const isSynthetic = parseBooleanCapture(match['@declaration.is-synthetic']); 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 { return {
nodeId: makeDefId(filePath, anchor.range, type, nameCap.text), nodeId: makeDefId(filePath, anchor.range, type, nameCap.text),
@ -725,6 +729,7 @@ function buildDefFromDeclarationMatch(
...(isExplicit === true ? { isExplicit: true } : {}), ...(isExplicit === true ? { isExplicit: true } : {}),
...(isDeleted === true ? { isDeleted: true } : {}), ...(isDeleted === true ? { isDeleted: true } : {}),
...(isSynthetic === true ? { isSynthetic: true } : {}), ...(isSynthetic === true ? { isSynthetic: true } : {}),
...(isExported !== undefined ? { isExported } : {}),
}; };
} }

View file

@ -503,9 +503,25 @@ function resolveDefGraphIdUncached(
if (qualifiedHit !== undefined) return qualifiedHit; if (qualifiedHit !== undefined) return qualifiedHit;
} }
const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1); 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)); 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`. */ /** Derive the simple (unqualified) name of a def from its `qualifiedName`. */
export function simpleQualifiedName(def: SymbolDefinition): string | undefined { export function simpleQualifiedName(def: SymbolDefinition): string | undefined {
const q = def.qualifiedName; const q = def.qualifiedName;

View file

@ -201,7 +201,17 @@ export function emitFreeCallFallback(
}; };
for (const parsed of parsedFiles) { for (const parsed of parsedFiles) {
type PendingRel = { rel: Parameters<KnowledgeGraph['addRelationship']>[0]; gatedAll: boolean }; type PendingRel = {
rel: Parameters<KnowledgeGraph['addRelationship']>[0];
gatedAll: boolean;
/**
* The confidence/reason a PRECISELY resolved site (a real binding, not a
* unique-name guess) proved for this edge; `undefined` while every site
* collapsed into it so far was a guess. Decided at flush, not by the
* first site the walk met.
*/
precise: { confidence: number; reason: string } | undefined;
};
const pending = new Map<string, PendingRel>(); const pending = new Map<string, PendingRel>();
const bindingCandidatesByScope = const bindingCandidatesByScope =
options.freeCallsRequireInstanceOwnership === true options.freeCallsRequireInstanceOwnership === true
@ -686,44 +696,62 @@ export function emitFreeCallFallback(
// that collapses into it, so a callee reached from one live site and one // 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 // dead site stays live whichever site the walk meets first. Emission is
// deferred to the end of this file's sites for that reason. // 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); const pendingRel = pending.get(relId);
if (pendingRel !== undefined) { if (pendingRel !== undefined) {
if (site.staticGated !== true) pendingRel.gatedAll = false; 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; continue;
} }
if (seen.has(relId)) continue; if (seen.has(relId)) continue;
seen.add(relId); seen.add(relId);
pending.set(relId, { pending.set(relId, {
gatedAll: site.staticGated === true, gatedAll: site.staticGated === true,
precise: preciseHere,
rel: { rel: {
id: relId, id: relId,
sourceId: callerGraphId, sourceId: callerGraphId,
targetId: tgtGraphId, targetId: tgtGraphId,
type: 'CALLS', type: 'CALLS',
// A name guess is not an import resolution and must not be spelled like // Guess values as placeholders; decided at flush from `precise`.
// one. It used to be emitted at 0.85 / `'import-resolved'`, which made confidence: 0.5,
// it indistinguishable from an edge a real import produced — so every reason: GLOBAL_NAME_FALLBACK_REASON,
// 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,
),
}, },
}); });
emitted++; emitted++;
} }
for (const { rel, gatedAll } of pending.values()) { for (const { rel, gatedAll, precise } of pending.values()) {
graph.addRelationship(gatedAll ? { ...rel, staticGated: true } : rel); // 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; return emitted;

View file

@ -21,8 +21,10 @@ export function directoryOf(filePath: string): string {
/** /**
* Split a module path into segments, accepting the three separators languages * Split a module path into segments, accepting the three separators languages
* spell module nesting with: `/` (Go, Node), `::` (Rust, C++) and `.` (JVM, * spell module nesting with: `/` (Go, Node), `::` (Rust, C++) and `.` (JVM,
* Python). Empty segments are dropped, so a leading `./` or `crate::` prefix * Python). Empty segments and `.` are dropped, so a leading `./` contributes
* contributes nothing. * 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 * `.` 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. * specifier like `./util/parse.js` must not split on the extension dot.

View file

@ -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 <id>`, `export = <id>`. */
readonly namedLocals: ReadonlySet<string>;
/** The file exports through a CommonJS assignment: a plain "not under
* `export`" is no verdict there. */
readonly commonJs: boolean;
}
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<string> = new Set([
'class_body',
'interface_body',
'enum_body',
'object_type',
'statement_block',
'arrow_function',
'function_expression',
'function_declaration',
'generator_function',
'generator_function_declaration',
'method_definition',
'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<string>();
// Any CommonJS export surface anywhere in the file — a direct `module.exports
// = …`, an alias (`const m = module.exports; m.x = …`), an `exports.x` — means
// "not under `export`" says nothing. 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);
}

View file

@ -21,6 +21,7 @@ import { invalidateNodeWorkspacePackages } from './ingestion/import-resolvers/no
import { import {
logNameFallbackSummary, logNameFallbackSummary,
summarizeNameFallback, summarizeNameFallback,
countCallsByLanguage,
} from './ingestion/scope-resolution/name-fallback-summary.js'; } from './ingestion/scope-resolution/name-fallback-summary.js';
import { runPipelineFromRepo } from './ingestion/pipeline.js'; import { runPipelineFromRepo } from './ingestion/pipeline.js';
import { import {
@ -3872,7 +3873,10 @@ async function runFullAnalysisInner(
// Census of name-guessed CALLS edges (labeled `global-name-fallback`), refused // Census of name-guessed CALLS edges (labeled `global-name-fallback`), refused
// impossibles and ambiguous `export *` names — the honesty readout for this // impossibles and ambiguous `export *` names — the honesty readout for this
// run's resolution. Logged, and persisted below as `nameFallbackEdges`. // run's resolution. Logged, and persisted below as `nameFallbackEdges`.
const nameFallbackSummary = summarizeNameFallback(resolutionOutcomes); const nameFallbackSummary = summarizeNameFallback(
resolutionOutcomes,
countCallsByLanguage(pipelineResult.resolvedCalleeNamesByCaller, pipelineResult.graph),
);
logNameFallbackSummary(nameFallbackSummary); logNameFallbackSummary(nameFallbackSummary);
// Annotated so the capabilities stamp below is compile-checked against // Annotated so the capabilities stamp below is compile-checked against

View file

@ -33,6 +33,14 @@ export interface PipelineResult {
* produced; graph edge semantics are unchanged. * produced; graph edge semantics are unchanged.
*/ */
resolutionOutcomes: readonly ResolutionOutcome[]; resolutionOutcomes: readonly ResolutionOutcome[];
/**
* Caller node id simple names of every callee it has a CALLS edge to, read
* through the streaming sink when one was active (the raw graph holds no
* streamed edge). Denominator for the name-fallback census
* (`countCallsByLanguage`), so a guess count can be read as a share of the
* call graph. Absent only when scope resolution did not run.
*/
resolvedCalleeNamesByCaller?: ReadonlyMap<string, ReadonlySet<string>>;
/** /**
* Interfaces whose structural-satisfaction check could not be completed * Interfaces whose structural-satisfaction check could not be completed
* (#2873). Empty for languages with no structural detection. * (#2873). Empty for languages with no structural detection.

View file

@ -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<string, string>) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-3182-${name}-`));
try {
writeFixtureRepo(dir, files);
const result = await runPipelineFromRepo(dir, () => {});
const calls = getRelationships(result, 'CALLS')
.filter(
(e) => e.sourceFilePath.endsWith('caller.ts') || e.sourceFilePath.endsWith('caller.js'),
)
.map((e) => ({
target: e.target,
targetId: e.rel.targetId,
targetFile: path.basename(e.targetFilePath),
confidence: e.rel.confidence,
reason: e.rel.reason,
}))
.sort((a, b) => a.target.localeCompare(b.target) || a.targetFile.localeCompare(b.targetFile));
return { calls, outcomes: getResolutionOutcomes(result) };
} finally {
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
}
describe('#3182 review reproductions', () => {
it('1. a nested function behind `export *` does not displace the exported value of the same name', async () => {
const { calls } = await run('nested', {
'package.json': '{ "name": "r", "private": true }\n',
'lib.ts': `export function factory() { return () => 1; }\nexport const selected = factory();\nfunction wrapper() { function selected() {} return selected; }\nexport { wrapper };\n`,
'index.ts': `export * from './lib';\n`,
'caller.ts': `import { selected } from './index';\nexport function go() { return selected(); }\n`,
});
// `selected` is a `const` value (arrow returned by a call) — the graph
// has no Function node for it, so the honest outcome is NO edge to a
// callable named `selected`; above all, none to `wrapper`'s private one.
expect(calls.filter((c) => c.target === 'selected')).toEqual([]);
}, 120000);
it('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);
});

View file

@ -18,14 +18,16 @@ async function run(name: string, extra: Record<string, string>, remove: string[]
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-c2b-${name}-`)); const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-c2b-${name}-`));
const files: Record<string, string> = { ...base, ...extra }; const files: Record<string, string> = { ...base, ...extra };
for (const r of remove) delete files[r]; for (const r of remove) delete files[r];
writeFixtureRepo(dir, files); try {
const result = await runPipelineFromRepo(dir, () => {}); writeFixtureRepo(dir, files);
const targets = getRelationships(result, 'CALLS') const result = await runPipelineFromRepo(dir, () => {});
.filter((e) => e.sourceFilePath.includes('packages/app/src/main')) return getRelationships(result, 'CALLS')
.map((e) => e.target) .filter((e) => e.sourceFilePath.includes('packages/app/src/main'))
.sort(); .map((e) => e.target)
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); .sort();
return targets; } finally {
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
} }
describe('C2 probe 2', () => { describe('C2 probe 2', () => {
it('E: impl is a .tsx file with plain function exports', async () => { it('E: impl is a .tsx file with plain function exports', async () => {

View file

@ -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<void> | null,
releaseGate: null as (() => void) | null,
gate: null as Promise<void> | null,
failNextIgnoreCheck: false,
rootReaddirCalls: 0,
watchedRoot: null as string | null,
}));
ctx.reached = new Promise<void>((resolve) => {
ctx.reachedResolve = resolve;
});
ctx.gate = new Promise<void>((resolve) => {
ctx.releaseGate = resolve;
});
vi.mock('fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs/promises')>();
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<unknown>)(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<typeof import('../../src/config/ignore-service.js')>();
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);
});
});

View file

@ -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');
});
});

View file

@ -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<string, string | undefined> {
const out: Record<string, string | undefined> = {};
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);
});
});

View file

@ -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<string>(),
createSemanticModel(),
buildWorkspaceResolutionIndex([callerParsed, targetParsed]),
{ allowGlobalFallback: true, recordResolutionOutcome: (o) => outcomes.push(o) },
);
const calls = graph.relationships.filter((r) => r.type === 'CALLS');
return { calls, outcomes };
}
describe('free-call dedup: the label is decided from every collapsed site, never by order', () => {
it('control — a lone precise site is import-resolved at 0.85', () => {
const { calls } = run([preciseSite(3)]);
expect(calls).toHaveLength(1);
expect(calls[0]!.confidence).toBe(0.85);
expect(calls[0]!.reason).toBe('import-resolved');
});
it('control — a lone guessed site is labeled at 0.5', () => {
const { calls, outcomes } = run([guessedSite(3)]);
expect(calls).toHaveLength(1);
expect(calls[0]!.confidence).toBe(0.5);
expect(calls[0]!.reason).toBe(GLOBAL_NAME_FALLBACK_REASON);
expect(outcomes.map((o) => o.kind)).toEqual(['fallback-guessed']);
});
it('guess FIRST, precise second: the precise site proves the edge — 0.85 import-resolved', () => {
const { calls } = run([guessedSite(3), preciseSite(4)]);
expect(calls).toHaveLength(1);
expect(calls[0]!.confidence).toBe(0.85);
expect(calls[0]!.reason).toBe('import-resolved');
});
it('precise FIRST, guess second: identical — a redundant guess does not taint a proven edge', () => {
const { calls } = run([preciseSite(3), guessedSite(4)]);
expect(calls).toHaveLength(1);
expect(calls[0]!.confidence).toBe(0.85);
expect(calls[0]!.reason).toBe('import-resolved');
});
it('two guessed sites stay a guess', () => {
const { calls } = run([guessedSite(3), guessedSite(4)]);
expect(calls).toHaveLength(1);
expect(calls[0]!.confidence).toBe(0.5);
expect(calls[0]!.reason).toBe(GLOBAL_NAME_FALLBACK_REASON);
});
});

View file

@ -144,4 +144,30 @@ describe('Go _test.go package siblings', () => {
expect(see('m:foo', 'setup')).toEqual([]); expect(see('m:foo', 'setup')).toEqual([]);
expect(see('m:bar', '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([]);
});
}); });

View file

@ -168,6 +168,35 @@ describe('Go: isGlobalNameFallbackPlausible', () => {
).toBe(true); ).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', () => { it('does not refuse an exported identifier in the module ROOT package', () => {
// The root package is imported by the module path alone, which the // The root package is imported by the module path alone, which the
// repo-relative layout cannot align against — undecidable, so allowed. // repo-relative layout cannot align against — undecidable, so allowed.
@ -187,15 +216,16 @@ describe('Go: isGlobalNameFallbackPlausible', () => {
}); });
describe('Dart: isGlobalNameFallbackPlausible', () => { describe('Dart: isGlobalNameFallbackPlausible', () => {
it('REFUSES a library-private name from another directory', () => { it('does NOT refuse a library-private name from another directory — a `part` URI may cross it', () => {
// No `part` layout can span directories in practice, so a `_` name across // `part '../shared/gen.dart';` is legal Dart, and `part` directives are not
// one is impossible, not merely unproven. // extracted yet, so "different directory" is undecidable, not impossible.
// The edge stays a labeled guess rather than being deleted.
expect( expect(
dartIsGlobalNameFallbackPlausible({ dartIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('lib/widgets/b.dart'), callerParsed: mkCaller('lib/widgets/b.dart'),
candidate: mkCandidate('lib/models/a.dart', '_privateHelper'), candidate: mkCandidate('lib/models/a.dart', '_privateHelper'),
}), }),
).toBe(false); ).toBe(true);
}); });
it('allows a library-private name in a SIBLING file (possible `part`)', () => { it('allows a library-private name in a SIBLING file (possible `part`)', () => {
@ -322,6 +352,40 @@ describe('Rust: isGlobalNameFallbackPlausible', () => {
).toBe(true); ).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', () => { it('does not judge a PATH-QUALIFIED call site', () => {
// `User::new(...)` names its path in source. Refusing it for lacking a // `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 // `use` of the module would delete an edge the code spells out — the
@ -527,6 +591,68 @@ describe('Ruby: isGlobalNameFallbackPlausible', () => {
).toBe(true); ).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', () => { it('does not refuse an owned method with no nameable namespace', () => {
expect( expect(
rubyIsGlobalNameFallbackPlausible({ rubyIsGlobalNameFallbackPlausible({

View file

@ -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 });
});
});

View file

@ -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> = {}): 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');
});
});