This commit is contained in:
Abhinav Pandey 2026-09-05 10:35:55 +01:00 • committed by GitHub
commit f1b07c2d18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 6631 additions and 99 deletions

View file

@ -12,6 +12,10 @@ All notable changes to GitNexus will be documented in this file.
### Fixed
- Scope resolution: the global unique-name call fallback now emits its edges as `global-name-fallback` at confidence 0.5 (they were published as `import-resolved` @ 0.85, indistinguishable from real resolution), refuses edges the language makes impossible (Go unexported names across packages, non-`pub` Rust items, Swift `private`/`fileprivate`, Dart `_private` across libraries, Ruby class-owned methods without any namespace evidence), and excludes the class from process and community detection. `export *` fan-out and TS/JS/Vue named imports bind module-level declarations only (a class method can no longer win a barrel name); two `export *` sources publishing the same name are refused and recorded as `reexport-ambiguous`. Each run logs and persists a census (`nameFallbackEdges`).
- Go: `_test.go` files join their package's sibling table, so same-package calls from tests resolve directly instead of falling to the global fallback (on grafana: 4,857 guesses → 7).
- Node workspaces: nested workspace roots are discovered (gated by the outer scope), package entries are found from `source`/`publishConfig`/vite `lib.entry`/`src/main`/`src/index` when `main`/`exports` point at build output, a package whose `exports` map has no `"."` no longer receives a fabricated root entry, and the workspace scan is deterministic (sorted) and memoised per run.
- **Azure OpenAI compatibility** — use `max_completion_tokens` instead of deprecated `max_tokens` (newer models reject `max_tokens`); skip `temperature` for Azure provider (some models reject non-default values) (#618)
- **Simplified Azure interactive setup** — 3 prompts (endpoint, deployment, key) instead of 7 (#618)
- **Wiki HTML viewer script injection** — escape `</script>` in embedded JSON so LLM-generated markdown no longer breaks the viewer (#618)

View file

@ -137,6 +137,7 @@ export type {
FinalizeOutput,
FinalizedScc,
FinalizeStats,
AmbiguousWildcardExport,
} from './scope-resolution/finalize-algorithm.js';
// Scope-aware registries + 7-step lookup (RFC §4; Ring 2 SHARED #917)

View file

@ -114,6 +114,34 @@ export interface FinalizeHooks {
*/
expandsWildcardTo(targetModuleScope: ScopeId, workspaceIndex: WorkspaceIndex): readonly string[];
/**
* Does this language make two `wildcard` re-exports that both DECLARE the
* same name AMBIGUOUS (no winner), rather than overloads or redeclarations
* of one entity?
*
* True for ECMAScript modules: `export * from './a'; export * from './b'`
* with `collide` declared in both excludes the name from the module's
* exports, so binding either source is a guess. False (the default) for
* languages whose wildcard import is `#include`, `require`, or a package
* fan-out, where the same name declared in two files is an overload set
* (C++ `write_audit(int)` / `write_audit(int, int)` across two headers), a
* redeclaration of one function, or a per-file `init` — legal, and resolved
* downstream by arity or by definition. Only a language that opts in has
* its collisions refused and reported via `ambiguousWildcardExports`.
*/
readonly wildcardCollisionIsAmbiguous?: boolean;
/**
* A named import / named re-export binds only to MODULE-LEVEL declarations
* of the target file. Opt-in for languages whose `import { x }` can never
* reach a class member: without it, a class method sharing a name with a
* top-level value — or standing alone — wins the callable preference in
* `findExportByName` and the import binds to a symbol the module cannot
* export (a confident wrong edge). Languages that bind module-level members
* by bare name (static members, module functions) leave it off.
*/
readonly namedImportsBindTopLevelOnly?: boolean;
/**
* Merge `incoming` bindings into `existing` for a given name. Called
* once per name at each scope. Typical rules:
@ -164,6 +192,24 @@ export interface FinalizeStats {
readonly unresolvedEdges: number;
readonly sccCount: number;
readonly largestSccSize: number;
/**
* Names a file re-exported through two or more `export *` sources that each
* DECLARE the name, so the language names no winner. The finalize pass
* refuses to bind them (they are absent from the file's re-export closure
* AND from its wildcard-expanded module-scope bindings) instead of taking the
* first-listed source and publishing the guess as `import-resolved`.
* Reported so the caller can record the refusal — an importer of that name
* stays unresolved, and the reason must be auditable rather than silent.
*/
readonly ambiguousWildcardExports: readonly AmbiguousWildcardExport[];
}
/** One refused `export *` collision — see `FinalizeStats.ambiguousWildcardExports`. */
export interface AmbiguousWildcardExport {
readonly filePath: string;
readonly name: string;
/** `nodeId`s of the colliding declarations, in `export *` declaration order. */
readonly candidateDefIds: readonly string[];
}
export interface FinalizeOutput {
@ -223,7 +269,25 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
// SCC-condensed). Eliminates the recursive crawl that the per-edge
// `tryFinalize` call site used to do; lookups are O(1) afterwards.
// See `buildReexportClosures` for the algorithm.
const reexportClosures = buildReexportClosures(input.files, byFilePath, edgeIndex);
const ambiguityByFile = collectAmbiguityByFile(
input.files,
byFilePath,
edgeIndex,
hooks.wildcardCollisionIsAmbiguous === true,
hooks.namedImportsBindTopLevelOnly === true,
);
const ambiguousByFile = new Map<string, ReadonlySet<string>>();
for (const [filePath, byName] of ambiguityByFile) {
ambiguousByFile.set(filePath, new Set(byName.keys()));
}
const topLevelOnly = hooks.namedImportsBindTopLevelOnly === true;
const reexportClosures = buildReexportClosures(
input.files,
byFilePath,
edgeIndex,
ambiguousByFile,
topLevelOnly,
);
// ── Phase 3: process SCCs in reverse-topological order (leaves first).
// Within each SCC, run a bounded fixpoint that resolves intra-SCC edges.
@ -231,6 +295,17 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
// already finalized); edges inside the SCC may need multiple passes.
const linkedByScope = new Map<ScopeId, readonly ImportEdge[]>();
let linkedEdges = 0;
// Every refused name, reported from the ambiguity map itself rather than from
// the edges phase 4 happens to drop: a language whose `expandsWildcardTo`
// returns nothing (TypeScript — `export *` never binds names locally) drops
// no expanded edge, yet its importers were refused through the closure just
// the same, and that refusal must still be visible.
const ambiguousWildcardExports: AmbiguousWildcardExport[] = [];
for (const [filePath, byName] of ambiguityByFile) {
for (const [name, candidateDefIds] of byName) {
ambiguousWildcardExports.push({ filePath, name, candidateDefIds });
}
}
for (const scc of sccs) {
const sccFiles = new Set(scc.files);
@ -249,7 +324,7 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
if (drafts === undefined) continue;
for (const draft of drafts) {
if (draft.finalized !== null) continue;
const finalized = tryFinalize(draft, byFilePath, reexportClosures);
const finalized = tryFinalize(draft, byFilePath, reexportClosures, topLevelOnly);
if (finalized !== null) {
draft.finalized = finalized;
progressed = true;
@ -278,6 +353,13 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
const drafts = edgeIndex.get(file.filePath);
if (drafts === undefined) continue;
const finalized: ImportEdge[] = [];
// Names this file's `export *` sources collide on (see
// `collectAmbiguousWildcards`). Their expanded edges are dropped here, so
// the file's own module scope does not bind an arbitrary winner either —
// suppressing them only in the closure would leave this binding standing,
// and it was this binding, not the closure, that produced the published
// `import-resolved` guess.
const ambiguousHere = ambiguousByFile.get(file.filePath) ?? EMPTY_NAME_SET;
for (const d of drafts) {
const edge = d.finalized;
if (edge === null) {
@ -286,7 +368,10 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') {
// Produce one `wildcard-expanded` ImportEdge per exported name.
const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex);
for (const e of expanded) finalized.push(e);
for (const e of expanded) {
if (e.kind === 'wildcard-expanded' && ambiguousHere.has(e.localName)) continue;
finalized.push(e);
}
} else {
finalized.push(edge);
}
@ -312,6 +397,7 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
unresolvedEdges: totalEdges - linkedEdges,
sccCount,
largestSccSize,
ambiguousWildcardExports: Object.freeze(ambiguousWildcardExports),
};
return Object.freeze({
@ -529,6 +615,7 @@ function tryFinalize(
draft: ImportEdgeDraft,
byFilePath: Map<string, FinalizeFile>,
reexportClosures: ReadonlyMap<string, FileReexportClosure>,
topLevelOnly: boolean,
): ImportEdge | null {
const targetFile = draft.targetFile;
if (targetFile === null) return draft.base; // already terminal
@ -552,7 +639,11 @@ function tryFinalize(
// so consumers can reach the module as a symbol — but its absence is not
// a failure.
if (draft.base.kind === 'namespace') {
const moduleDef = findExportByName(targetModule.localDefs, extractExportedName(draft.source));
const moduleDef = findExportByName(
targetModule.localDefs,
extractExportedName(draft.source),
topLevelOnly,
);
return {
...draft.base,
targetModuleScope: targetModule.moduleScope,
@ -564,7 +655,7 @@ function tryFinalize(
// local defs. Multi-hop re-export chains settle iteratively — each hop
// resolves once its prior hop is finalized.
const importedName = extractExportedName(draft.source);
const exported = findExportByName(targetModule.localDefs, importedName);
const exported = findExportByName(targetModule.localDefs, importedName, topLevelOnly);
if (exported !== undefined) {
const transitiveVia =
@ -691,15 +782,18 @@ function buildReexportClosures(
files: readonly FinalizeFile[],
byFilePath: ReadonlyMap<string, FinalizeFile>,
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
ambiguous: ReadonlyMap<string, ReadonlySet<string>>,
topLevelOnly: boolean,
): ReadonlyMap<string, FileReexportClosure> {
const closures = new Map<string, Map<string, ReexportClosureEntry>>();
for (const file of files) closures.set(file.filePath, new Map());
// ── Step 1: build the re-export sub-graph (only resolvable wildcard /
// reexport / flagged-named targets contribute edges), and collect the
// per-file ambiguous names in the same walk.
// reexport / flagged-named targets contribute edges). The per-file
// ambiguous-name sets arrive precomputed (`collectAmbiguityByFile`) because
// phase 4 consults the same sets when it expands wildcards into module
// scope — one source of truth for "this name has no winner".
const subGraph = new Map<string, Set<string>>();
const ambiguous = new Map<string, ReadonlySet<string>>();
for (const file of files) {
const targets = new Set<string>();
const drafts = edgeIndex.get(file.filePath);
@ -710,7 +804,6 @@ function buildReexportClosures(
if (!byFilePath.has(d.targetFile)) continue;
targets.add(d.targetFile);
}
ambiguous.set(file.filePath, collectAmbiguousReexports(drafts, byFilePath));
}
subGraph.set(file.filePath, targets);
}
@ -726,7 +819,7 @@ function buildReexportClosures(
if (!scc.isCycle) {
const filePath = scc.files[0];
if (filePath !== undefined) {
populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous);
populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous, topLevelOnly);
}
continue;
}
@ -740,7 +833,9 @@ function buildReexportClosures(
progressed = false;
iter++;
for (const filePath of scc.files) {
if (populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous)) {
if (
populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous, topLevelOnly)
) {
progressed = true;
}
}
@ -820,6 +915,212 @@ function isNamedReexport(draft: ImportEdgeDraft): draft is ImportEdgeDraft & {
* are still filling in, so detecting them needs a set that grows during the
* fixpoint — the thing this pre-pass exists to avoid.
*/
/**
* Per-file set of re-exported names that have NO decidable winner, from both
* detectors: `collectAmbiguousReexports` (flagged-named vs flagged-named) and
* `collectAmbiguousWildcards` (`export *` vs `export *`, direct declarations).
* Fixed for the whole run; consulted by the closure fixpoint AND by phase 4's
* wildcard expansion, so a refused name is absent from BOTH the exports an
* importer can reach and the module-scope bindings the file itself sees.
*/
function collectAmbiguityByFile(
files: readonly FinalizeFile[],
byFilePath: ReadonlyMap<string, FinalizeFile>,
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
wildcardCollisionIsAmbiguous: boolean,
topLevelOnly: boolean,
): ReadonlyMap<string, ReadonlyMap<string, readonly string[]>> {
const out = new Map<string, ReadonlyMap<string, readonly string[]>>();
for (const file of files) {
const drafts = edgeIndex.get(file.filePath);
if (drafts === undefined) continue;
const byName = new Map<string, readonly string[]>();
for (const name of collectAmbiguousReexports(drafts, byFilePath)) {
byName.set(name, namedReexportCandidates(drafts, byFilePath, name, topLevelOnly));
}
// Wildcard-vs-wildcard is a language rule (`FinalizeHooks.
// wildcardCollisionIsAmbiguous`): ECMAScript excludes the name, C++
// overloads it. Without the opt-in this half stays first-wins.
if (wildcardCollisionIsAmbiguous) {
for (const [name, ids] of collectAmbiguousWildcards(file, drafts, byFilePath)) {
if (!byName.has(name)) byName.set(name, ids);
}
}
if (byName.size === 0) continue;
out.set(file.filePath, byName);
}
return out;
}
/** The declarations a flagged-named collision on `localName` points at. */
function namedReexportCandidates(
drafts: readonly ImportEdgeDraft[],
byFilePath: ReadonlyMap<string, FinalizeFile>,
localName: string,
topLevelOnly: boolean,
): readonly string[] {
const ids: string[] = [];
for (const draft of drafts) {
if (!isNamedReexport(draft) || draft.source.localName !== localName) continue;
const targetFile = draft.targetFile;
if (targetFile === null) continue;
const target = byFilePath.get(targetFile);
if (target === undefined) continue;
const def = findExportByName(target.localDefs, draft.source.importedName, topLevelOnly);
if (def !== undefined && !ids.includes(def.nodeId)) ids.push(def.nodeId);
}
return Object.freeze(ids);
}
/**
* `export * from './a'; export * from './b'` where BOTH `a` and `b` declare
* `collide`: the language names no winner (ECMAScript excludes the name from
* the module's exports entirely; a direct `import { collide }` of it is a
* SyntaxError-class ambiguity). First-wins here published the `a` binding as
* `import-resolved` at full confidence — a definite target for a call that has
* none, which is the incorrect-context-over-missing-context failure in its
* purest form. The name is refused instead and reported.
*
* Decidable in this pre-pass because it reads only the targets' own
* `localDefs` — nothing that fills in during the closure fixpoint. Collisions
* that arrive TRANSITIVELY (two wildcards whose targets each re-export the
* name from somewhere else) are still first-wins; detecting them needs a set
* that grows mid-fixpoint, the thing this pre-pass exists to avoid.
*
* A name the file DECLARES itself, or re-exports by NAME, is excluded: an
* explicit export shadows every `export *`, so those collisions are legal and
* resolved by precedence, not ambiguous.
*
* Only MODULE-LEVEL, EXPORT-SHAPED declarations can collide. `localDefs` also
* carries class members, properties and parameters (a `Property:value` on two
* unrelated classes, an interface field named `move`), which no `export *`
* publishes. Counting those produced thousands of phantom collisions on a real
* monorepo (2,640 on grafana) and — the dangerous half — would have refused a
* genuinely exported `move()` because some class elsewhere had a `move`
* property. The wildcard closure loop tolerates the wider set because nobody
* imports a property by name; a refusal cannot afford the same tolerance.
*
* Export evidence, when the language supplies it (`SymbolDefinition.isExported`,
* tri-state), settles the rest: a def marked `false` is module-private and is
* neither a provider here nor published by the closure
* (`indexTopLevelExportsByName`), so a private `function foo` beside an exported
* one no longer refuses the export — and, the half that matters more, cannot be
* the first-listed winner the closure binds either. A def marked `true` counts
* whatever its label, `Variable` included: the closure publishes a `Variable`,
* so two sources each exporting `const alpha` are a real collision and must be
* refused rather than first-wins.
*
* Without evidence (`isExported` undefined — most languages) `Variable` is
* excluded from the COLLISION set only: the typical top-level `const` in a
* barrel's sources is module-private (`const category = ['Axis']` in fourteen
* option-builder files), so counting it would refuse a real exported constant
* of the same name for nothing. Residual risk, accepted, for that evidence-free
* case: a non-exported `function`/`class` sharing its name with an exported one
* behind the same barrel is counted as a collision and the export is refused —
* a missing edge, never a wrong one. `ownerId` is only set for class members, so
* a callable nested in an object literal (`showIf: (cfg) => …` across fourteen
* option-builder files) still counts as a provider when unmarked. Measured
* before the export marker existed: grafana@871af0720 refuses 52 names (from
* 2,640 before the member exclusion), discourse@3f71fa15c 5.
*/
/** Labels that are never a module export, whatever their owner. */
const NON_EXPORTABLE_MEMBER_LABELS: readonly string[] = [
'Property',
'Method',
'Constructor',
'Parameter',
'Field',
];
/** Labels excluded from the collision set when no export evidence is present. */
const UNMARKED_NON_COLLIDING_LABELS: ReadonlySet<string> = new Set([
...NON_EXPORTABLE_MEMBER_LABELS,
'Variable',
]);
/**
* Labels a module can never export by name: class/interface members and
* parameters. Filtered by LABEL, not `ownerId` — `ownerId` is populated in a
* later pass and is not reliable while the closure is built.
*/
const MEMBER_LABELS: ReadonlySet<string> = new Set(NON_EXPORTABLE_MEMBER_LABELS);
/**
* A declaration `export *` could publish, for COLLISION purposes: top-level, of
* an exportable kind, and not marked module-private. With export evidence the
* label rule yields to the marker (an exported `Variable` collides; a private
* `function` does not); without it `Variable` is left out — see the header.
*/
function isWildcardPublishable(def: SymbolDefinition): boolean {
// Explicit evidence wins over the label: a CommonJS `module.exports = {
// alpha() {} }` member is labeled Method and IS the module's export.
if (def.isExported === true) return true;
if (def.isExported === false) return false;
if (def.ownerId !== undefined) return false;
return !UNMARKED_NON_COLLIDING_LABELS.has(def.type);
}
/**
* Can a declaration of the barrel's OWN shadow a name its `export *` sources
* collide on? Only a module-level binding can — ECMAScript's explicit-export
* precedence is about the module's own exports. A class MEMBER named `clash`
* (`export class Unrelated { clash() {} }`) is not such a binding and must not
* switch the collision check off; it did, and a confident edge to one source's
* `clash` was emitted where the import should have been refused.
*/
function canShadowWildcard(def: SymbolDefinition): boolean {
if (def.isExported === true) return true;
if (def.isExported === false) return false;
if (def.ownerId !== undefined) return false;
return !MEMBER_LABELS.has(def.type);
}
function collectAmbiguousWildcards(
file: FinalizeFile,
drafts: readonly ImportEdgeDraft[],
byFilePath: ReadonlyMap<string, FinalizeFile>,
): ReadonlyMap<string, readonly string[]> {
const shadowed = new Set<string>();
for (const def of file.localDefs) {
if (!canShadowWildcard(def)) continue;
const name = deriveSimpleName(def);
if (name !== null) shadowed.add(name);
}
for (const draft of drafts) {
if (isNamedReexport(draft)) shadowed.add(draft.source.localName);
}
// name → (target file → declaring def ids), in declaration order.
const providers = new Map<string, Map<string, string[]>>();
for (const draft of drafts) {
if (draft.source.kind !== 'wildcard') continue;
const targetFile = draft.targetFile;
if (targetFile === null) continue;
const target = byFilePath.get(targetFile);
if (target === undefined) continue;
for (const def of target.localDefs) {
if (!isWildcardPublishable(def)) continue;
const name = deriveSimpleName(def);
if (name === null || shadowed.has(name)) continue;
let byTarget = providers.get(name);
if (byTarget === undefined) {
byTarget = new Map<string, string[]>();
providers.set(name, byTarget);
}
const ids = byTarget.get(targetFile);
if (ids === undefined) byTarget.set(targetFile, [def.nodeId]);
else ids.push(def.nodeId);
}
}
const conflicting = new Map<string, readonly string[]>();
for (const [name, byTarget] of providers) {
// Two DIFFERENT source files declaring the name. The same file declaring
// it twice (overloads, a declaration merged with its namespace) is one
// provider and not a collision.
if (byTarget.size < 2) continue;
conflicting.set(name, Object.freeze([...byTarget.values()].flat()));
}
return conflicting;
}
function collectAmbiguousReexports(
drafts: readonly ImportEdgeDraft[],
byFilePath: ReadonlyMap<string, FinalizeFile>,
@ -859,6 +1160,7 @@ function populateFileClosure(
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
closures: Map<string, Map<string, ReexportClosureEntry>>,
ambiguousByFile: ReadonlyMap<string, ReadonlySet<string>>,
topLevelOnly: boolean,
): boolean {
const myClosure = closures.get(filePath);
if (myClosure === undefined) return false;
@ -883,7 +1185,7 @@ function populateFileClosure(
if (ambiguous.has(localName) || myClosure.has(localName)) continue;
const importedName = draft.source.importedName;
const direct = findExportByName(targetModule.localDefs, importedName);
const direct = findExportByName(targetModule.localDefs, importedName, topLevelOnly);
if (direct !== undefined) {
myClosure.set(localName, { def: direct, via: Object.freeze([targetFile]) });
continue;
@ -909,9 +1211,27 @@ function populateFileClosure(
const targetModule = byFilePath.get(targetFile);
if (targetModule === undefined) continue;
for (const def of targetModule.localDefs) {
const name = deriveSimpleName(def);
if (name === null || ambiguous.has(name) || myClosure.has(name)) continue;
// Fan out the WINNER per name, not every def. `export const alpha = () =>
// {}` emits both a `Variable` (the lexical declaration) and a `Function`
// (the arrow) under the same simple name; iterating `localDefs` raw let
// whichever came first — the `Variable` — claim the closure slot, and a
// call bound to a value shadow emits no CALLS edge. Named re-exports
// already go through `findExportByName`'s callable-preferred index; the
// wildcard hop is the same lookup and must apply the same preference.
// Measured: grafana `Button`/`clearButtonStyles` (arrow consts behind
// `export *`) resolved 8 of 475 ledger entries before this.
// Over TOP-LEVEL declarations only. `localDefs` also carries class members;
// `Foo.render` (label `Method`, callable) outranked the file's real
// `const render` in the callable-preferred index and `import { render }`
// bound to a symbol `export *` can never publish — a confident wrong edge
// where the value shadow used to yield none. Gated by the same hook as the
// named-import path: only a language that opted in (ECMAScript, where
// `export *` cannot publish a class member) narrows; every other language's
// wildcard keeps the wide index, whose members are legitimately reachable.
for (const [name, def] of (topLevelOnly ? indexTopLevelExportsByName : indexExportsByName)(
targetModule.localDefs,
)) {
if (ambiguous.has(name) || myClosure.has(name)) continue;
myClosure.set(name, { def, via: Object.freeze([targetFile]) });
}
const targetClosure = closures.get(targetFile);
@ -993,6 +1313,13 @@ function deriveSimpleName(def: SymbolDefinition): string | null {
function findExportByName(
defs: readonly SymbolDefinition[],
name: string,
/**
* `true` (a `namedImportsBindTopLevelOnly` language): consult only
* module-level declarations, so a class member can neither outrank a
* top-level value nor bind on its own. Phase-4 wildcard expansion keeps
* the wide index — that is the path languages use to bind members.
*/
topLevelOnly: boolean = false,
): SymbolDefinition | undefined {
// GENERIC RULE (applies to every language using this finalize
// algorithm): when MULTIPLE `SymbolDefinition`s share the same simple
@ -1018,7 +1345,7 @@ function findExportByName(
//
// See `gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts`
// for the cross-file regression this rule prevents.
return indexExportsByName(defs).get(name);
return (topLevelOnly ? indexTopLevelExportsByName(defs) : indexExportsByName(defs)).get(name);
}
/**
@ -1062,6 +1389,47 @@ function indexExportsByName(
return index;
}
/**
* `indexExportsByName` restricted to declarations a module publishes by name:
* members (by LABEL — `ownerId` is stamped by a later reconcile pass and is not
* reliable while the closure is built) are skipped unless the language marked
* them exported (a CommonJS `module.exports = { alpha() {} }` member), and so
* is any def the language marked module-private (`isExported === false`) — a
* function nested inside another function carries the Function label and used
* to displace the real exported value of the same name here; a barrel cannot
* republish what its source never exported, and binding it would put a private
* `function foo` in front of the exported one another source provides.
* `Variable` stays, since a barrel legitimately republishes a `const`. Same
* memoization contract.
*/
const TOP_LEVEL_EXPORTS_BY_NAME = new WeakMap<
readonly SymbolDefinition[],
ReadonlyMap<string, SymbolDefinition>
>();
function indexTopLevelExportsByName(
defs: readonly SymbolDefinition[],
): ReadonlyMap<string, SymbolDefinition> {
const cached = TOP_LEVEL_EXPORTS_BY_NAME.get(defs);
if (cached !== undefined) return cached;
const index = new Map<string, SymbolDefinition>();
for (const d of defs) {
// Evidence over label, both ways: a marked-private def (a function nested
// in another function carries the Function label too) is skipped, and a
// marked-exported member (`module.exports = { alpha() {} }`) is admitted.
if (d.isExported === false) continue;
if (d.isExported !== true && MEMBER_LABELS.has(d.type)) continue;
const name = deriveSimpleName(d);
if (name === null) continue;
const existing = index.get(name);
if (existing === undefined) index.set(name, d);
else if (!isCallableOrTypeLike(existing.type) && isCallableOrTypeLike(d.type))
index.set(name, d);
}
TOP_LEVEL_EXPORTS_BY_NAME.set(defs, index);
return index;
}
const EMPTY_NAME_SET: ReadonlySet<string> = new Set();
const CALLABLE_OR_TYPE_LIKE: ReadonlySet<string> = new Set([

View file

@ -111,6 +111,18 @@ export interface SymbolDefinition {
* source (for example an anonymous class). Consumers may use this only as a
* conservative priority hint; it does not change graph-node identity. */
isSynthetic?: boolean;
/**
* Whether the producing language saw EXPORT EVIDENCE on this declaration —
* an `export` modifier, a later `export { name }` specifier, an `export
* default name`. TRI-STATE, and the absence is load-bearing: `undefined`
* means the language emitted no verdict (most languages, and any ECMAScript
* file whose export surface is a CommonJS assignment the marker cannot read),
* which readers MUST treat as "unknown" and fall back to their prior
* behavior. Only `false` says "this module does not publish the name": a
* `false` keeps a module-private `function foo` from being counted as a
* wildcard provider or bound through a barrel's `export *` closure.
*/
isExported?: boolean;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
/** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`)

View file

@ -74,6 +74,10 @@ All notable changes to GitNexus will be documented in this file.
### Fixed
- Scope resolution: the global unique-name call fallback now emits its edges as `global-name-fallback` at confidence 0.5 (they were published as `import-resolved` @ 0.85, indistinguishable from real resolution), refuses edges the language makes impossible (Go unexported names across packages, non-`pub` Rust items, Swift `private`/`fileprivate`, Dart `_private` across libraries, Ruby class-owned methods without any namespace evidence), and excludes the class from process and community detection. `export *` fan-out and TS/JS/Vue named imports bind module-level declarations only (a class method can no longer win a barrel name); two `export *` sources publishing the same name are refused and recorded as `reexport-ambiguous`. Each run logs and persists a census (`nameFallbackEdges`).
- Go: `_test.go` files join their package's sibling table, so same-package calls from tests resolve directly instead of falling to the global fallback (on grafana: 4,857 guesses → 7).
- Node workspaces: nested workspace roots are discovered (gated by the outer scope), package entries are found from `source`/`publishConfig`/vite `lib.entry`/`src/main`/`src/index` when `main`/`exports` point at build output, a package whose `exports` map has no `"."` no longer receives a fabricated root entry, and the workspace scan is deterministic (sorted) and memoised per run.
- **`group sync` stops claiming matching it never did** — the advertised BM25/embedding cascade was config, help text and MCP schema with no matcher behind it; the unread `matching.bm25_threshold`, `matching.embedding_threshold`, `detect.embedding_fallback` and `--skip-embeddings` surfaces are removed (#3020)
- **Emitted Next.js build output is ignored during ingestion**, and the inert `public/build` entry is deleted (#3018)
- **NestJS decorator routes are indexed** so `api_impact` and `route_map` stop reporting live endpoints as non-existent (#3017)

View file

@ -0,0 +1,37 @@
/**
* Edge `reason` values that mark a CALLS edge as a HEURISTIC GUESS rather than
* a resolution.
*
* The distinction exists because an edge's `confidence` number cannot carry it.
* `GLOBAL_NAME_FALLBACK_REASON` edges are emitted at exactly 0.5 — the same
* number as `process-processor`'s `MIN_TRACE_CONFIDENCE` and
* `community-processor`'s `MIN_CONFIDENCE_LARGE` — so a `confidence < 0.5`
* gate does NOT exclude them. Anything that must exclude guesses has to read
* the reason, which is why `KnowledgeGraph.forEachRelationshipFields` passes it
* and why `GraphEmitSink` retains a reason column.
*/
/**
* The target was chosen because its SIMPLE NAME is unique in the workspace —
* not because any import, scope chain, or type binding led to it.
*
* Emitted only by the `pickUniqueGlobalCallable` tier of the free-call
* fallback, and only for the languages that opt into
* `allowGlobalFreeCallFallback`. It is a name collision away from being wrong
* and must never be presented as an import-resolved edge: a reader who cannot
* tell the two apart has no way to discount the guess.
*/
export const GLOBAL_NAME_FALLBACK_REASON = 'global-name-fallback';
/**
* Reasons excluded from process tracing and large-graph community detection.
*
* Both walks exist to describe how the program actually flows. Seeding a flow
* from a unique-name guess produces a confident-looking trace through code that
* may never call each other, which is worse than a shorter honest trace.
*/
const HEURISTIC_EDGE_REASONS: ReadonlySet<string> = new Set([GLOBAL_NAME_FALLBACK_REASON]);
/** True when this edge's target was guessed by name rather than resolved. */
export const isHeuristicEdgeReason = (reason: string): boolean =>
HEURISTIC_EDGE_REASONS.has(reason);

View file

@ -163,9 +163,17 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
relationshipMap.forEach(fn);
},
forEachRelationshipFields(
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
fn: (
sourceId: string,
targetId: string,
type: RelationshipType,
confidence: number,
reason: string,
) => void,
) {
relationshipMap.forEach((rel) => fn(rel.sourceId, rel.targetId, rel.type, rel.confidence));
relationshipMap.forEach((rel) =>
fn(rel.sourceId, rel.targetId, rel.type, rel.confidence, rel.reason),
);
},
getNode: (id: string) => nodeMap.get(id),

View file

@ -31,14 +31,26 @@ export interface KnowledgeGraph {
* Zero-allocation relationship scan: fields, not objects (#2680).
*
* The whole-graph scans (the local-symbol pruner, community detection,
* process extraction) read only these four fields, and materializing a
* process extraction) read only these five fields, and materializing a
* `GraphRelationship` per edge just to read them dominates iteration cost once
* relationships are held columnar — measured at ~90 ms per analyze on a
* million-edge graph. Prefer this over `forEachRelationship` in any pass that
* walks every edge and needs no other field.
*
* `reason` is passed because confidence alone cannot separate a heuristic
* name guess from a resolved edge that happens to sit at the same number:
* the global-name fallback emits at exactly the process/community threshold
* (0.5), so the walks that must exclude it have to read the reason. See
* `GraphEmitSink`'s reason column, added for this consumer.
*/
forEachRelationshipFields: (
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
fn: (
sourceId: string,
targetId: string,
type: RelationshipType,
confidence: number,
reason: string,
) => void,
) => void;
getNode: (id: string) => GraphNode | undefined;
nodeCount: number;

View file

@ -19,6 +19,7 @@ import { dirname, resolve } from 'node:path';
import { Worker } from 'node:worker_threads';
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
import { KnowledgeGraph } from '../graph/types.js';
import { isHeuristicEdgeReason } from '../graph/edge-reasons.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@ -131,6 +132,18 @@ const LEIDEN_TIMEOUT_MS = 60_000;
const ICEBUG_TIMEOUT_MS = 60_000;
const MIN_CONFIDENCE_LARGE = 0.5;
/**
* Whether a large graph's projection may include this edge.
*
* The confidence floor alone lets every global-name-fallback edge through — it
* is emitted at exactly `MIN_CONFIDENCE_LARGE`, so `confidence <` never
* excludes it. Clustering on unique-name guesses fuses unrelated areas into one
* community, which is precisely the noise the large-graph floor exists to
* remove, so the reason is checked too — see `graph/edge-reasons.ts`.
*/
const isLargeGraphEligible = (confidence: number, reason: string): boolean =>
confidence >= MIN_CONFIDENCE_LARGE && !isHeuristicEdgeReason(reason);
export const resolveCommunityDetectionEngine = (
raw = process.env[COMMUNITY_ENGINE_ENV],
): CommunityDetectionEngine => {
@ -300,11 +313,16 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun
const connectedNodes = new Set<string>();
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.
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence) => {
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence, reason) => {
if (!isClusteringRelationship(type) || sourceId === targetId) return;
if (isLarge && confidence < MIN_CONFIDENCE_LARGE) return;
// A name-guessed edge must not join two nodes into a community at ANY graph
// size — the rationale in `graph/edge-reasons.ts` is about what the edge
// claims, not about how many symbols surround it. Process tracing applies
// the same reason gate unconditionally (`process-processor.ts`).
if (isHeuristicEdgeReason(reason)) return;
if (isLarge && !isLargeGraphEligible(confidence, reason)) return;
connectedNodes.add(sourceId);
connectedNodes.add(targetId);
@ -340,9 +358,10 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun
const seenEdges = new Set<string>();
const edges: Array<readonly [number, number]> = [];
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence) => {
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence, reason) => {
if (!isClusteringRelationship(type) || sourceId === targetId) return;
if (isLarge && confidence < MIN_CONFIDENCE_LARGE) return;
if (isHeuristicEdgeReason(reason)) return;
if (isLarge && !isLargeGraphEligible(confidence, reason)) return;
const sourceIndex = nodeIndexById.get(sourceId);
const targetIndex = nodeIndexById.get(targetId);

View file

@ -202,6 +202,8 @@ function withDefaultHooks(partial: Partial<FinalizeHooks>): FinalizeHooks {
return {
resolveImportTarget: partial.resolveImportTarget ?? (() => null),
isNamespaceImport: partial.isNamespaceImport,
wildcardCollisionIsAmbiguous: partial.wildcardCollisionIsAmbiguous === true,
namedImportsBindTopLevelOnly: partial.namedImportsBindTopLevelOnly === true,
expandsWildcardTo: partial.expandsWildcardTo ?? (() => []),
mergeBindings:
partial.mergeBindings ??

View file

@ -221,10 +221,24 @@ function admits(scope: WorkspaceScope | null, dir: string): boolean {
// The root package is always itself, workspace or not.
if (dir === '') return true;
if (scope === null) return false;
if (scope.exclude.some((pattern) => globToRegExp(pattern).test(dir))) return false;
// An exclusion covers the directory AND everything under it: `!packages/legacy`
// must keep `packages/legacy/foo` out even when a nested workspace root inside
// the excluded subtree re-declares `packages/*`.
if (scope.exclude.some((pattern) => matchesDirOrAncestor(globToRegExp(pattern), dir)))
return false;
return scope.include.some((pattern) => globToRegExp(pattern).test(dir));
}
function matchesDirOrAncestor(re: RegExp, dir: string): boolean {
let current = dir;
while (current !== '') {
if (re.test(current)) return true;
const slash = current.lastIndexOf('/');
current = slash === -1 ? '' : current.slice(0, slash);
}
return false;
}
/**
* Match one workspace glob.
*
@ -272,9 +286,50 @@ function globToRegExp(pattern: string): RegExp {
* tooling that does not read pnpm's file).
*/
async function loadWorkspaceScope(repoRoot: string): Promise<WorkspaceScope | null> {
const patterns: string[] = [];
// Every workspace ROOT in the repo, not just the top level. keycloak keeps its
// JavaScript workspace at `js/pnpm-workspace.yaml`; reading only the repo root
// found no workspace, admitted no package, and `@keycloak/keycloak-ui-shared`
// (780–845 raw calls per SHA) resolved 7 times. Patterns from a nested root
// are rebased onto that root so `packages/*` under `js/` admits `js/packages/x`.
//
// Gated, though: a nested root counts only when the repo root declares NO
// workspace (keycloak), or when the nested root's directory is itself admitted
// by the root's scope. Unioning every nested root unconditionally let an
// `examples/*/package.json` starter (turborepo/vite/nuxt templates carry
// `workspaces`) admit its example packages — and, being shallow, outrank the
// real package of the same name — and let a root inside an excluded subtree
// re-admit what the outer `!exclusion` had removed.
const rootPatterns = await readWorkspacePatternsAt(repoRoot);
const rootScope = rootPatterns.length === 0 ? null : toScope(rootPatterns);
const patterns: string[] = [...rootPatterns];
for (const root of await findWorkspaceRoots(repoRoot)) {
if (root === repoRoot) continue;
const prefix = repoRelativeDir(repoRoot, root);
if (rootScope !== null && !admits(rootScope, prefix)) continue;
const rebase = (p: string): string => {
const negated = p.startsWith('!');
const body = negated ? p.slice(1) : p;
const joined = prefix === '' ? body : `${prefix}/${body.replace(/^\.\//, '')}`;
return negated ? `!${joined}` : joined;
};
for (const pattern of await readWorkspacePatternsAt(root)) patterns.push(rebase(pattern));
}
const rootManifest = await readJsonFile(path.join(repoRoot, 'package.json'));
if (patterns.length === 0) return null;
return toScope(patterns);
}
function toScope(patterns: readonly string[]): WorkspaceScope {
return {
include: patterns.filter((p) => !p.startsWith('!')),
exclude: patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1)),
};
}
/** The workspace patterns declared at ONE directory, all three spellings merged. */
async function readWorkspacePatternsAt(root: string): Promise<string[]> {
const patterns: string[] = [];
const rootManifest = await readJsonFile(path.join(root, 'package.json'));
const workspaces = rootManifest?.workspaces;
if (Array.isArray(workspaces)) {
patterns.push(...workspaces.filter((w): w is string => typeof w === 'string'));
@ -285,20 +340,82 @@ async function loadWorkspaceScope(repoRoot: string): Promise<WorkspaceScope | nu
patterns.push(...nested.filter((w): w is string => typeof w === 'string'));
}
}
patterns.push(...(await readYamlPackages(path.join(repoRoot, 'pnpm-workspace.yaml'))));
patterns.push(...(await readYamlPackages(path.join(repoRoot, 'pnpm-workspace.yml'))));
const lerna = await readJsonFile(path.join(repoRoot, 'lerna.json'));
patterns.push(...(await readYamlPackages(path.join(root, 'pnpm-workspace.yaml'))));
patterns.push(...(await readYamlPackages(path.join(root, 'pnpm-workspace.yml'))));
const lerna = await readJsonFile(path.join(root, 'lerna.json'));
if (Array.isArray(lerna?.packages)) {
patterns.push(...lerna.packages.filter((w): w is string => typeof w === 'string'));
}
return patterns;
}
if (patterns.length === 0) return null;
return {
include: patterns.filter((p) => !p.startsWith('!')),
exclude: patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1)),
};
/**
* Directories that declare a workspace: the repo root plus any directory (to a
* shallow depth — workspace roots sit near the top) holding a
* `pnpm-workspace.yaml`, a `lerna.json`, or a `package.json` with `workspaces`.
*/
const WORKSPACE_ROOT_MAX_DEPTH = 4;
/**
* Bound on the depth-≤4 directory walk. Generous on purpose: the previous 2,000
* cap tripped silently in readdir order, so WHICH packages existed — and which
* imports resolved — varied between two checkouts of the same commit. Tripping
* it now warns, so a truncated scan is a logged fact rather than a quiet one.
*/
const WORKSPACE_ROOT_SCAN_MAX_DIRS = 50_000;
/** Directory names whose nested `workspaces` are starters/fixtures, never members. */
const NON_MEMBER_ROOT_DIRS = new Set([
'example',
'examples',
'fixture',
'fixtures',
'template',
'templates',
'sample',
'samples',
]);
async function findWorkspaceRoots(repoRoot: string): Promise<string[]> {
const roots: string[] = [repoRoot];
const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
let scanned = 0;
while (queue.length > 0) {
if (scanned >= WORKSPACE_ROOT_SCAN_MAX_DIRS) {
logger.warn(
`[node] workspace-root scan of ${repoRoot} hit the ${WORKSPACE_ROOT_SCAN_MAX_DIRS}-directory cap; nested workspace roots below it were not considered`,
);
break;
}
const { dir, depth } = queue.shift()!;
scanned++;
let entries: import('fs').Dirent[];
try {
entries = (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
} catch {
continue;
}
if (dir !== repoRoot) {
const names = new Set(entries.filter((e) => e.isFile()).map((e) => e.name));
let declares =
names.has('pnpm-workspace.yaml') ||
names.has('pnpm-workspace.yml') ||
names.has('lerna.json');
if (!declares && names.has('package.json')) {
const manifest = await readJsonFile(path.join(dir, 'package.json'));
declares = manifest?.workspaces !== undefined && manifest.workspaces !== null;
}
if (declares) roots.push(dir);
}
if (depth >= WORKSPACE_ROOT_MAX_DEPTH) continue;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (NON_MEMBER_ROOT_DIRS.has(entry.name.toLowerCase())) continue;
const child = path.join(dir, entry.name);
if (isHardcodedIgnoredDirectoryAtPath(repoRoot, child)) continue;
queue.push({ dir: child, depth: depth + 1 });
}
}
return roots;
}
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | null> {
@ -334,8 +451,42 @@ async function readYamlPackages(filePath: string): Promise<string[]> {
* declaration, so this is far cheaper than the C# namespace scan next door,
* which reads every `.cs` file.
*/
/**
* Per-repo memo. The TS/JS/Vue scope resolvers and the unresolved-call ledger
* classifier each ask for the same map during one analyze; without this the
* 20k-directory walk ran once per asker (four times on a full run, one of them
* inside the index lock). Invalidated at the start of every `runFullAnalysis`
* so a long-lived server never serves a stale package map across runs.
*/
const workspacePackagesMemo = new Map<string, Promise<NodeWorkspacePackages | null>>();
export function invalidateNodeWorkspacePackages(repoRoot?: string): void {
if (repoRoot === undefined) workspacePackagesMemo.clear();
else workspacePackagesMemo.delete(path.resolve(repoRoot));
}
export async function loadNodeWorkspacePackages(
repoRoot: string,
): Promise<NodeWorkspacePackages | null> {
const key = path.resolve(repoRoot);
const cached = workspacePackagesMemo.get(key);
if (cached !== undefined) return cached;
const pending: Promise<NodeWorkspacePackages | null> = loadNodeWorkspacePackagesUncached(
repoRoot,
).catch((err: unknown) => {
// Evict only OUR entry. If this load was invalidated while in flight and a
// newer load has since been installed under the same key, deleting by key
// alone would evict that one, and every later caller would start another
// full scan instead of joining it.
if (workspacePackagesMemo.get(key) === pending) workspacePackagesMemo.delete(key);
throw err;
});
workspacePackagesMemo.set(key, pending);
return pending;
}
async function loadNodeWorkspacePackagesUncached(
repoRoot: string,
): Promise<NodeWorkspacePackages | null> {
const scope = await loadWorkspaceScope(repoRoot);
const byName = new Map<string, NodeWorkspacePackage>();
@ -354,7 +505,11 @@ export async function loadNodeWorkspacePackages(
let entries: import('fs').Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
// Sorted like findWorkspaceRoots: same-depth name collisions resolve first-wins,
// and readdir order is filesystem-dependent.
entries = (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
} catch {
continue;
}
@ -424,6 +579,28 @@ async function readManifest(
push(entries, joinRepoPath(packageDir, conventional));
}
}
// Entry points that name BUILD OUTPUT (`main: dist/x.js`, `exports: ./dist/…`)
// never match an indexed source file — the package's real source entry has to
// be discovered. keycloak's `@keycloak/keycloak-ui-shared` publishes
// `dist/keycloak-ui-shared.js` and keeps its entry in `vite.config.ts`
// (`build.lib.entry: 'src/main.ts'`); with only the declared fields it had 7
// resolved calls against ~845 raw. Tried in a fixed order, and ONLY appended
// (declared entries keep precedence): `source` / `publishConfig.source`, the
// vite `lib.entry`, then `src/main` / `src/index`. More than one candidate
// that exists on disk is recorded as ambiguous rather than picked — a wrong
// entry binds every import of the package to the wrong file.
// A package whose `exports` map has no `"."` (only subpaths) refuses the bare
// specifier outright; discovery must not manufacture a `src/index` root for it.
const rootlessExports = declaresExports && rootExports.length === 0;
const discovered = rootlessExports
? { entries: [], ambiguous: [] }
: await discoverSourceEntries(parsed, dir, rebase, packageDir, repoRoot);
for (const entry of discovered.entries) push(entries, entry);
if (discovered.ambiguous.length > 0) {
logger.warn(
`[node] package ${name}: ${discovered.ambiguous.length} candidate source entries (${discovered.ambiguous.join(', ')}) — none adopted; declare \`source\` or a single lib entry`,
);
}
const subpathImports = new Map<string, readonly string[]>();
collectImports(parsed.imports, subpathImports, rebase);
@ -507,6 +684,134 @@ function collectImports(
}
}
/**
* Does a declared entry point at build output rather than source? Output
* directories and minified bundles. A plain `.js` is NOT build output on its
* own — `main: "src/index.js"` / `index.js` is a JavaScript package's source,
* and treating every `.js` as output ran discovery for essentially every CJS
* package and could adopt a `src/main` beside the real entry.
*/
function looksLikeBuildOutput(entry: string): boolean {
return /(^|\/)(dist|build|lib|out|esm|cjs|umd)\//.test(entry) || /\.min\.[cm]?js$/.test(entry);
}
async function discoverSourceEntries(
parsed: Record<string, unknown>,
dir: string,
rebase: (raw: string) => string,
packageDir: string,
repoRoot: string,
): Promise<{ entries: string[]; ambiguous: string[] }> {
const declared: string[] = [];
const exportsRoot = parsed.exports;
if (typeof exportsRoot === 'string') declared.push(exportsRoot);
else if (exportsRoot !== null && typeof exportsRoot === 'object') {
const dot = (exportsRoot as Record<string, unknown>)['.'];
if (typeof dot === 'string') declared.push(dot);
else if (dot !== null && typeof dot === 'object') {
for (const v of Object.values(dot as Record<string, unknown>))
if (typeof v === 'string') declared.push(v);
}
}
for (const field of ['module', 'main']) {
const value = parsed[field];
if (typeof value === 'string') declared.push(value);
}
// Only when every declared entry is build output (or nothing is declared and
// the conventional stems are absent) does discovery run at all.
if (declared.length > 0 && !declared.every(looksLikeBuildOutput))
return { entries: [], ambiguous: [] };
const candidates: string[] = [];
const source = parsed.source;
if (typeof source === 'string') candidates.push(rebase(source));
const publishConfig = parsed.publishConfig;
if (publishConfig !== null && typeof publishConfig === 'object') {
const ps = (publishConfig as Record<string, unknown>).source;
if (typeof ps === 'string') candidates.push(rebase(ps));
}
for (const cfg of ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']) {
try {
const text = stripJsComments(await fs.readFile(path.join(dir, cfg), 'utf-8'));
// EVERY `lib: { entry: '…' }` in the live text is a candidate, not the
// first: a stale `lib` object left in the file (or a second one under a
// conditional) is a competing claim, and two claims are an ambiguity the
// `existing.length > 1` rule below refuses. Comments are stripped first —
// a commented-out `// old lib: { entry: 'src/wrong.ts' }` used to be the
// first match and became the package's entry.
for (const match of text.matchAll(/lib\s*:\s*\{[^}]*?entry\s*:\s*['"]([^'"]+)['"]/gs)) {
push(candidates, rebase(match[1]!));
}
} catch {
/* no such config */
}
}
const existing: string[] = [];
for (const candidate of candidates) {
if (await stemExists(repoRoot, candidate)) push(existing, candidate);
}
if (existing.length === 0) {
for (const conventional of ['src/main', 'src/index']) {
const stem = joinRepoPath(packageDir, conventional);
if (await stemExists(repoRoot, stem)) push(existing, stem);
}
}
if (existing.length > 1) return { entries: [], ambiguous: existing };
return { entries: existing, ambiguous: [] };
}
/**
* Remove line (`//`) and block comments from JS/TS config text before a regex
* reads it. String contents are preserved (a `//` inside quotes is not a
* comment), so `entry: 'src/index.ts'` survives, as does a comment opener
* written inside a string.
*/
export function stripJsComments(text: string): string {
let out = '';
let i = 0;
while (i < text.length) {
const ch = text[i]!;
const next = text[i + 1];
if (ch === '"' || ch === "'" || ch === '`') {
const quote = ch;
let j = i + 1;
while (j < text.length && text[j] !== quote) {
if (text[j] === '\\') j++;
j++;
}
out += text.slice(i, j + 1);
i = j + 1;
} else if (ch === '/' && next === '/') {
const end = text.indexOf('\n', i);
i = end === -1 ? text.length : end;
} else if (ch === '/' && next === '*') {
const end = text.indexOf('*/', i + 2);
i = end === -1 ? text.length : end + 2;
} else {
out += ch;
i++;
}
}
return out;
}
/** A repo-relative stem exists as a source file (with any TS/JS extension). */
// The root is threaded explicitly: a module-level "current root" clobbered
// under two concurrent scans and turned an ambiguity refusal into a confident
// wrong entry (the second repo's root made one candidate "not exist").
async function stemExists(repoRoot: string, stem: string): Promise<boolean> {
const abs = path.join(repoRoot, stem);
for (const ext of ['', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']) {
try {
const st = await fs.stat(abs + ext);
if (st.isFile()) return true;
} catch {
/* try next */
}
}
return false;
}
/** `"./src/index.ts"` -> `"src/index"`; leaves an extension-less path alone. */
function stripEntryPrefixes(entry: string): string {
const withoutDot = entry.replace(/^\.\//, '').replace(/^\//, '');

View file

@ -0,0 +1,77 @@
/**
* Dart's veto on the global-name fallback — see
* `ScopeResolver.isGlobalNameFallbackPlausible`.
*
* Dart's privacy is LIBRARY-scoped and marked in the identifier itself: a name
* beginning with `_` is visible only inside its own library and cannot be
* imported by any spelling. So a `_`-prefixed candidate in another file is an
* impossible call, not an unlikely one.
*
* The exact boundary is `part` / `part of` — one library spanning several
* files, with `_` names shared between them — and the extractor does not
* surface `part` directives yet (the Dart query captures only `library_import`;
* nothing in `languages/dart/` reads `part`). Without them a cross-file `_`
* candidate is UNDECIDABLE, not impossible: refusing on "different file" would
* delete real edges on Flutter's dominant generated-code idiom (`factory
* Foo.fromJson(j) => _$FooFromJson(j)` calls into `foo.g.dart`, a `part` beside
* it), and refusing on "different directory" is wrong too — a `part` URI is a
* relative URI and legally traverses directories (`part '../shared/gen.dart';`).
* An earlier version refused the cross-directory case as "no `part` layout can
* make this legal"; that claim was false, so the hook now REFUSES NOTHING and
* every cross-file `_` candidate stays a LABELED edge (0.5 /
* `global-name-fallback`), which is the honest answer until `part` is
* extracted. A caller that names the candidate's file in a directive is
* recognized already, for the day the extractor surfaces `part` as an import
* target; at that point "not the same library" becomes decidable and the
* refusal can return.
*
* Public names are left to the labeled-edge path. Dart does require an import
* for a cross-library public name, but the fallback exists partly to recover
* edges where the import chain was not reconstructed, and refusing every
* cross-file public call would delete real edges to buy a rule the `_` marker
* already gives for free.
*/
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import {
modulePathReaches,
stripExtension,
} from '../../scope-resolution/utils/name-fallback-visibility.js';
/** Dart privacy marker: a leading underscore on the declared identifier. Read
* from the last `qualifiedName` segment, so `_Foo.bar` is public `bar` on a
* private class and `Foo._bar` is the private member. */
function isPrivateDartName(candidate: SymbolDefinition): boolean {
const qualified = candidate.qualifiedName ?? '';
const dot = qualified.lastIndexOf('.');
const simple = dot === -1 ? qualified : qualified.slice(dot + 1);
return simple.startsWith('_');
}
/**
* Are these two files parts of one library? True when the caller names the
* candidate's file in a `part` / `part of` directive, which the extractor
* surfaces as an ordinary import target.
*/
function sharesLibrary(callerParsed: ParsedFile, candidateFilePath: string): boolean {
const candidateModule = stripExtension(candidateFilePath);
for (const imp of callerParsed.parsedImports) {
if (modulePathReaches(stripExtension(imp.targetRaw), candidateModule)) return true;
}
return false;
}
export function dartIsGlobalNameFallbackPlausible(ctx: {
readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition;
}): boolean {
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
if (!isPrivateDartName(ctx.candidate)) return true;
// A directive naming the candidate's file is positive evidence of one library.
if (sharesLibrary(ctx.callerParsed, ctx.candidate.filePath)) return true;
// Any other file may be a `part` of the caller's library — a sibling or, via
// a relative `part` URI, a file in another directory. Undecidable without
// `part` extraction (see the header), so allowed as a labeled guess, never
// refused.
return true;
}

View file

@ -41,6 +41,7 @@ import { decodeMarker } from '../../utils/heritage-marker.js';
import { typeApplicationArguments } from '../../utils/template-arguments.js';
import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js';
import { expandDartWildcardNames } from './expand-wildcards.js';
import { dartIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
interface ClassDefRef {
readonly graphId: string;
@ -233,5 +234,6 @@ export const dartScopeResolver: ScopeResolver = {
// No `new`: bare `Foo()` resolves to the type; with cross-file imports the
// callee is reachable workspace-wide.
allowGlobalFreeCallFallback: true,
isGlobalNameFallbackPlausible: dartIsGlobalNameFallbackPlausible,
constructorCallTargetsClass: true,
};

View file

@ -0,0 +1,131 @@
/**
* Go's veto on the global-name fallback — see
* `ScopeResolver.isGlobalNameFallbackPlausible`.
*
* Go's visibility rules are unusually decidable from a file path and an
* identifier, which is why this is the language the guard is most complete for:
*
* 1. A package IS a directory. Two files in the same directory see each
* other's identifiers with no import and no qualification, so a same-
* directory candidate is always plausible.
* 2. An identifier is exported iff it begins with an upper-case letter. An
* UNEXPORTED identifier is invisible outside its own package — no import
* makes it reachable, so a cross-directory candidate with a lower-case
* initial is not unlikely, it is IMPOSSIBLE.
* 3. An exported identifier still requires the caller's file to import the
* package. Go has no ambient namespace, so an exported name from a package
* this file never imported cannot be called here either.
*
* Rule 2 is the one that matters most in practice: `uniqueHelperXyz` defined
* once in package `a` used to acquire a caller in package `b` purely because
* the name was unique in the repo, and the resulting edge was published as
* `import-resolved` — a caller Go itself would reject.
*
* Note this checks the CALL's legality, not the callee's identity, so it is
* safe against the aliasing and dot-import forms: both change the local handle,
* neither changes the imported package PATH, which is what rule 3 reads.
*/
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import {
anyImportReaches,
directoryOf,
} from '../../scope-resolution/utils/name-fallback-visibility.js';
import { inferGoPackageName } from './package-clause.js';
/**
* `foo_test` → `foo`. `package-siblings.ts` refines this with the directory's
* non-test package clauses (a package genuinely NAMED `foo_test` keeps its
* name there); this hook sees one file at a time and cannot, so such a
* package's internal tests are classified external here. Refuse-only tier, so
* the cost is a missed same-package guess, never a wrong edge.
*/
function internalPackageOf(pkgName: string): string {
return pkgName.endsWith('_test') && pkgName.length > '_test'.length
? pkgName.slice(0, -'_test'.length)
: pkgName;
}
/**
* The same three-way split `package-siblings.ts` derives for the confident
* tier: is this a `_test.go` file, and if so does it declare the EXTERNAL test
* package (`foo_test`)? `external` is `undefined` when the package clause is
* not available — an unanswered question the caller must treat as "cannot
* decide", never as "internal".
*/
function classifyGoFile(
filePath: string,
sourceTextOf: ((p: string) => string | undefined) | undefined,
): { isTest: boolean; external: boolean | undefined } {
const isTest = filePath.endsWith('_test.go');
if (!isTest) return { isTest, external: false };
const text = sourceTextOf?.(filePath);
if (text === undefined) return { isTest, external: undefined };
const declared = inferGoPackageName(text);
if (declared === null) return { isTest, external: undefined };
return { isTest, external: declared !== internalPackageOf(declared) };
}
/** Go export rule: an upper-case initial, by Unicode letter case. */
function isExportedGoName(name: string): boolean {
return /^\p{Lu}/u.test(name);
}
/**
* The simple identifier a Go declaration contributes to its package scope: the
* last segment of `qualifiedName`, which is `Type.method` for a method and the
* bare identifier for a function. Either way the LAST segment is the identifier
* whose case decides export.
*/
function goSimpleName(candidate: SymbolDefinition): string {
const qualified = candidate.qualifiedName ?? '';
const dot = qualified.lastIndexOf('.');
return dot === -1 ? qualified : qualified.slice(dot + 1);
}
export function goIsGlobalNameFallbackPlausible(ctx: {
readonly sourceTextOf?: (filePath: string) => string | undefined;
readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition;
}): boolean {
const callerDir = directoryOf(ctx.callerParsed.filePath);
const candidateDir = directoryOf(ctx.candidate.filePath);
// Same directory is NOT the same package (rule 1, refined): a `_test.go`
// file may declare `foo_test`, and test-only declarations are invisible to
// non-test files. Apply the split `package-siblings.ts` uses for the
// confident tier, so the heuristic tier cannot reopen what it closed.
if (callerDir === candidateDir) {
const caller = classifyGoFile(ctx.callerParsed.filePath, ctx.sourceTextOf);
const cand = classifyGoFile(ctx.candidate.filePath, ctx.sourceTextOf);
// Non-test files never see test-only declarations.
if (cand.isTest && !caller.isTest) return false;
// An external test package and its tested package are different packages:
// a BARE name cannot cross that boundary in either direction. Undecidable
// (no package clause available) → allow.
if (caller.external === true && cand.external === false) return false;
if (cand.external === true && caller.external === false) return false;
return true;
}
// Different directory, so a different package — and a `_test.go` file's
// declarations are compiled only into ITS OWN package's test binary. No other
// package, test or not, can see them, exported or not. Decidable from the
// path alone, so it comes before every exception below (the module-root
// exception in particular used to accept a root `helper_test.go` export).
if (classifyGoFile(ctx.candidate.filePath, ctx.sourceTextOf).isTest) return false;
const simpleName = goSimpleName(ctx.candidate);
// No identifier to read the case of — an unanswered question, not a refusal.
if (simpleName === '') return true;
// Unexported across a package boundary (rule 2): no import can reach it.
if (!isExportedGoName(simpleName)) return false;
// Exported, so legal to reference — but only from a file that imports the
// package (rule 3). A candidate in the module ROOT package has an empty
// directory, which `modulePathReaches` cannot align against any import path
// (the root package is imported by the module path alone, which the repo-
// relative layout does not carry). That is an unanswered question, not a
// refusal — treat it as plausible.
if (candidateDir === '') return true;
return anyImportReaches(ctx.callerParsed, candidateDir);
}

View file

@ -10,44 +10,110 @@ import { goPackageDir, inferGoPackageName } from './package-clause.js';
* Future optimization: build a name→def inverted index per package to reduce
* to O(n×d).
*/
/**
* Go test files. `_test.go` files are compiled into the package's test binary:
* an INTERNAL test (`package foo`) sees every name its non-test siblings and
* the other `_test.go` files of the package declare; an EXTERNAL test
* (`package foo_test`) is a separate package that imports `foo` and therefore
* sees only its EXPORTED names. Non-test files never see test-only helpers —
* `go build` does not compile them.
*
* Before this, `_test.go` files were dropped from sibling augmentation
* entirely, so every same-package free call from a test fell through to the
* global unique-name fallback: 4,857 labeled guesses on grafana@871af0720,
* 25/25 sampled being test → same-directory helper — the right target reached
* through the wrong path, at guess confidence.
*/
function isGoTestFile(filePath: string): boolean {
return filePath.endsWith('_test.go');
}
/**
* The package a `_test.go` file's clause belongs to, given the package names
* the directory's NON-test files declare.
*
* `package foo_test` is the external-test convention ONLY when the directory's
* real package is `foo`; the `_test` suffix is otherwise a legal identifier
* (`package foo_test` in a directory whose non-test files also say `foo_test`).
* Stripping it unconditionally keyed such a package's own internal tests as
* external tests of a non-existent `foo`, so they saw no sibling at all — a
* resolution miss on every same-package call. Strip only when the stripped
* name is what the non-test siblings declare; with no non-test sibling to ask
* (a test-only directory) the convention is assumed, as before.
*/
function testFilePackageOf(
declared: string,
nonTestPackagesInDir: ReadonlySet<string> | undefined,
): { readonly pkg: string; readonly external: boolean } {
if (!declared.endsWith('_test') || declared.length <= '_test'.length) {
return { pkg: declared, external: false };
}
const stripped = declared.slice(0, -'_test'.length);
if (nonTestPackagesInDir !== undefined && nonTestPackagesInDir.has(declared)) {
return { pkg: declared, external: false };
}
if (nonTestPackagesInDir === undefined || nonTestPackagesInDir.has(stripped)) {
return { pkg: stripped, external: true };
}
// Neither name is declared by a non-test sibling: keep the clause as written.
return { pkg: declared, external: false };
}
export function populateGoPackageSiblings(
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
ctx: { readonly fileContents: ReadonlyMap<string, string> },
): void {
// 0. Filter out test files — Go _test.go files should not contribute
// same-package sibling bindings to non-test files.
const nonTestFiles = parsedFiles.filter((f) => !f.filePath.endsWith('_test.go'));
// 1. Expand dot imports first so subsequent same-package sibling
// augmentation can also see dot-imported names.
expandGoDotImports(nonTestFiles, indexes);
// augmentation can also see dot-imported names. Test files dot-import too.
expandGoDotImports(parsedFiles, indexes);
// 2. Group files by package directory plus package name. Go package
// identity is directory-scoped; repeated `package main` directories
// must not see each other's unqualified names.
const packageByFile = new Map<string, string>();
for (const parsed of nonTestFiles) {
// 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 pkgName = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
if (pkgName !== null) {
packageByFile.set(parsed.filePath, `${goPackageDir(parsed.filePath)}\0${pkgName}`);
}
//
// `_test.go` files join the INTERNAL package's bucket (a `foo_test`
// external test is keyed by `foo`, its `external` flag recording the
// exported-only rule), so one bucket holds everything the test binary
// compiles together, and the visibility rules below decide who sees whom.
interface SiblingFile {
readonly filePath: string;
readonly defs: readonly SymbolDefinition[];
readonly isTest: boolean;
readonly external: boolean;
}
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) {
const declared = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
if (declared === null) continue;
declaredByFile.set(parsed.filePath, declared);
if (isGoTestFile(parsed.filePath)) continue;
const dir = goPackageDir(parsed.filePath);
const names = nonTestPackagesByDir.get(dir) ?? new Set<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 dir = goPackageDir(parsed.filePath);
const { pkg, external } = isTest
? testFilePackageOf(declared, nonTestPackagesByDir.get(dir))
: { pkg: declared, external: false };
const key = `${dir}\0${pkg}`;
const list = filesByPackage.get(key) ?? [];
list.push({ filePath: parsed.filePath, defs: [...parsed.localDefs], isTest, external });
filesByPackage.set(key, list);
}
const filesByPackage = new Map<string, { filePath: string; defs: SymbolDefinition[] }[]>();
for (const parsed of nonTestFiles) {
const pkgName = packageByFile.get(parsed.filePath);
if (pkgName === undefined) continue;
const list = filesByPackage.get(pkgName) ?? [];
list.push({ filePath: parsed.filePath, defs: [...parsed.localDefs] });
filesByPackage.set(pkgName, list);
}
// 2. Use bindingAugmentations channel per I8
// 3. Use bindingAugmentations channel per I8
const augmentations = indexes.bindingAugmentations as Map<ScopeId, Map<string, BindingRef[]>>;
for (const [, siblings] of filesByPackage) {
@ -57,6 +123,17 @@ export function populateGoPackageSiblings(
for (const receiver of siblings) {
if (receiver.filePath === target.filePath) continue; // no self-reference
// Non-test files never see test-only declarations.
if (target.isTest && !receiver.isTest) continue;
// A `foo` internal test does not see a `foo_test` file's declarations
// at all (different packages) — and an external test package sees `foo`
// ONLY qualified (`foo.NewThing`), never as a bare name: that binding
// comes from its explicit import of the package path, through the
// ordinary import resolver. Publishing bare exported names here bound
// `NewThing()` in `foo_test` to a call Go itself would reject.
if (target.external && !receiver.external) continue;
if (receiver.external && !target.external) continue;
const receiverModule = indexes.moduleScopes.byFilePath.get(receiver.filePath);
if (receiverModule === undefined) continue;

View file

@ -16,6 +16,7 @@ import { detectGoInterfaceImplementations } from './interface-impls.js';
import { populateGoRangeBindings } from './range-binding.js';
import { expandGoWildcardNames } from './expand-wildcards.js';
import { goMapValueType } from './interpret.js';
import { goIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
/** Slice `[]T` and array `[N]T` / `[...]T` → the element spelling. Hoisted —
* a literal inside the hook would mint a fresh RegExp per folded subscript. */
@ -85,6 +86,7 @@ export const goScopeResolver: ScopeResolver = {
hoistTypeBindingsToModule: true,
propagatesReturnTypesAcrossImports: true,
allowGlobalFreeCallFallback: true,
isGlobalNameFallbackPlausible: goIsGlobalNameFallbackPlausible,
populateNamespaceSiblings: populateGoPackageSiblings,
mirrorNamespaceTypeBindings: mirrorGoNamespaceTypeBindings,

View file

@ -36,6 +36,7 @@ import {
syntheticCapture,
type SyntaxNode,
} from '../../utils/ast-helpers.js';
import { collectEsmExportEvidence, esmExportVerdict } from '../../ts-js-export-marker.js';
import { splitImportStatement } from '../typescript/import-decomposer.js';
import { getJsParser, getJsScopeQuery, jsCachedTreeMatchesGrammar } from './query.js';
import { computeTsArityMetadata } from '../typescript/arity-metadata.js';
@ -983,6 +984,8 @@ export function emitJsScopeCaptures(
}
const rawMatches = getJsScopeQuery(filePath).matches(tree.rootNode);
// Export evidence, read once per file (see `ts-js-export-marker.ts`).
const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath);
const out: CaptureMatch[] = [];
for (const m of rawMatches) {
@ -1207,6 +1210,20 @@ export function emitJsScopeCaptures(
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']);
// `@declaration.is-exported`: a verdict for every declaration the file's
// export surface can decide (see `ts-js-export-marker.ts`); nothing where
// it cannot, because absence is the honest answer there.
const declNameNode = groupedNodes['@declaration.name'];
if (exportEvidence !== undefined && declNameNode !== undefined) {
const verdict = esmExportVerdict(declNameNode, exportEvidence);
if (verdict !== undefined) {
grouped['@declaration.is-exported'] = syntheticCapture(
'@declaration.is-exported',
declNameNode,
verdict ? 'true' : 'false',
);
}
}
out.push(grouped);
// Synthesize `this` receiver type-bindings on class member functions.

View file

@ -97,6 +97,12 @@ const javascriptScopeResolver: ScopeResolver = {
// explicit imports at the call site. Workspace-wide unique-name fallback
// recovers these edges.
allowGlobalFreeCallFallback: true,
// Same ECMAScript `export *` exclusivity as TypeScript: a name declared by
// two wildcard sources is refused, not guessed.
exclusiveWildcardReexports: true,
// Same ECMAScript rule as TypeScript: named imports bind module-level declarations only.
namedImportsBindTopLevelOnly: true,
};
export { javascriptScopeResolver };

View file

@ -0,0 +1,187 @@
/**
* Ruby's veto on the global-name fallback — see
* `ScopeResolver.isGlobalNameFallbackPlausible`.
*
* Ruby keeps the labeled fallback deliberately: with autoload (Rails' zeitwerk,
* `ActiveSupport::Dependencies`) a file genuinely can call a method whose
* defining file it never requires, so "no require" is NOT evidence of
* impossibility the way it is in Go or Rust.
*
* What IS decidable is namespacing — but only for CLASS bodies. A method
* defined inside a `class` is not callable as a bare `helper()` from another
* file unless that file names the class somehow (a receiver `Ns.helper`, a
* subclass declaration, an `include`, a `require`). A method defined inside a
* `module` body is different in kind: modules exist to be mixed in, and Rails
* mixes them in for you — every `*Helper` module is included into views and
* controllers by the framework, and concerns arrive through `included do`
* hooks — so a bare `format_money()` in a view legitimately reaches
* `module ApplicationHelper` with no `include`, `require`, or constant
* anywhere in the caller. Refusing that would delete real edges on exactly the
* codebase shape Ruby's fallback exists to serve. So module-owned methods stay
* a LABELED guess, and the refusal targets CLASS-owned methods whose class the
* caller never names. A top-level method (`ownerId === undefined`) is left
* alone, since that is the shape autoload actually delivers. When the owner
* cannot be found or typed (no `parsedFileOf`, owner outside the file set),
* the question is unanswered and the edge is allowed.
*
* "Never names" is read from the caller's own text-visible signals — its
* `require`/`include`/`extend` targets, and any reference site spelling the
* namespace's constant. If any of them mentions the namespace, the call is
* plausible and the labeled edge stands. `require` paths are snake_case
* (`billing/invoice_service`) while constants are CamelCase
* (`Billing::InvoiceService`), so the comparison normalizes both sides —
* without that the `require` branch never matched anything.
*
* One more thing a caller file can do without naming the class: INHERIT its
* way to it. `class UsersController < AdminController` reaches every method
* `ApplicationController` defines while naming only `AdminController`, and an
* `include Concern` reaches whatever that concern includes in turn. Neither
* chain is decidable from one file, so a caller with ANY inheritance or mixin
* surface (an `inherits` site, an `include`/`extend`/`prepend` marker) is
* treated as plausible. So is a caller file that DEFINES a module: a module's
* methods run against whatever class includes the module, and call that class's
* methods bare — `module PostGuardian; def can_see?; is_staff? ...` reaches
* `class Guardian#is_staff?` because Guardian includes PostGuardian, a fact the
* module file never states. What remains refused is the shape Ruby itself
* rejects: a bare call to a class's method from a file that inherits nothing,
* mixes in nothing, defines no module, and never spells the class. Measured on
* discourse@3f71fa15c that is ~1000 refusals, and the sample is what the rule
* promises: RSpec `before`/`after`/`subject` guessed to serializer methods of
* the same name, `Gemfile`'s `gem` to `Plugin::Instance#gem`, `routes.rb`'s
* `get` to `Draft#get` — fabricated callers, every one.
*/
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import { moduleSegments } from '../../scope-resolution/utils/name-fallback-visibility.js';
import { HERITAGE_MARKER_PREFIX } from '../../utils/heritage-marker.js';
/**
* The namespace segments a candidate's qualified name declares, minus the
* method itself. `Billing::Invoice#total` / `Billing.Invoice.total` → the
* `Billing`, `Invoice` constants a caller would have to name.
*/
function namespaceConstantsOf(candidate: SymbolDefinition): readonly string[] {
const qualified = candidate.qualifiedName;
if (qualified === undefined || qualified === '') return [];
const segments = qualified
.split(/::|\.|#/)
.map((s) => s.trim())
.filter((s) => s !== '');
// Drop the trailing method name; what remains is the namespace chain.
const namespace = segments.slice(0, -1);
// Only CONSTANTS name a Ruby namespace (upper-case initial). A lower-case
// segment is a receiver expression, not a namespace a caller can mention.
return namespace.filter((s) => /^[A-Z]/.test(s));
}
/**
* `billing/invoice_service` vs `InvoiceService`: a path segment names a
* constant when, with underscores removed, the two are equal case-insensitively.
* Zeitwerk's own inflection rule, minus acronym overrides — over-matching here
* only loses a refusal (the edge stays, labeled), which is the safe direction.
*/
function pathSegmentNamesConstant(segment: string, constant: string): boolean {
return segment.replace(/_/g, '').toLowerCase() === constant.toLowerCase();
}
function requireReachesConstant(targetRaw: string, constant: string): boolean {
for (const segment of moduleSegments(targetRaw)) {
if (pathSegmentNamesConstant(segment, constant)) return true;
}
return false;
}
/**
* The declared kind of the candidate's owner, read from the candidate's own
* file. Ruby labels `class` bodies `Class` and `module` bodies `Trait`
* (`query.ts`: "module (labeled Trait for class-like registry lookup)").
* `undefined` when the owner cannot be found — an unanswered question.
*/
function ownerLabelOf(
candidate: SymbolDefinition,
parsedFileOf: ((filePath: string) => ParsedFile | undefined) | undefined,
): string | undefined {
const ownerId = candidate.ownerId;
if (ownerId === undefined || parsedFileOf === undefined) return undefined;
const owner = parsedFileOf(candidate.filePath)?.localDefs.find((d) => d.nodeId === ownerId);
return owner?.type;
}
/**
* Calls that rebind `self` for the duration of a block: inside
* `service.instance_eval do … end` a bare `helper()` is dispatched on
* `service`, so it legitimately reaches a class-owned method the caller file
* never names. Detected on the caller's SOURCE TEXT, not the call site — the
* site does not know which block encloses it — so any file that uses one of
* these forms keeps its class-owned guesses LABELED rather than refused. Coarse
* in the safe direction: it loses refusals in that file, never an edge.
*/
const SELF_REBINDING_CALL =
/\b(?:instance_eval|instance_exec|class_eval|class_exec|module_eval|module_exec)\b/;
export function rubyIsGlobalNameFallbackPlausible(ctx: {
readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition;
readonly parsedFileOf?: (filePath: string) => ParsedFile | undefined;
readonly sourceTextOf?: (filePath: string) => string | undefined;
}): boolean {
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
// Top-level method — the autoload shape the fallback exists for.
if (ctx.candidate.ownerId === undefined) return true;
// A self-rebinding block anywhere in the caller makes "the class is never
// named here" no proof of impossibility (see `SELF_REBINDING_CALL`). The
// pipeline always supplies the source; a missing text is an unanswered
// question and keeps the labeled edge as well.
if (ctx.sourceTextOf !== undefined) {
const text = ctx.sourceTextOf(ctx.callerParsed.filePath);
if (text === undefined || SELF_REBINDING_CALL.test(text)) return true;
}
// Only a CLASS body makes a bare cross-file call impossible without naming
// it (see the header). A module owner, or an owner we cannot type, is not a
// refusal.
if (ownerLabelOf(ctx.candidate, ctx.parsedFileOf) !== 'Class') return true;
const constants = namespaceConstantsOf(ctx.candidate);
// Owned but with no nameable namespace (an anonymous or lower-cased owner):
// nothing to check, so do not refuse on an unanswered question.
if (constants.length === 0) return true;
// Any inheritance or mixin surface in the caller can reach the class
// transitively (see the header) — not decidable here, so not refused.
for (const site of ctx.callerParsed.referenceSites) {
if (site.kind === 'inherits') return true;
}
for (const imp of ctx.callerParsed.parsedImports) {
if (imp.targetRaw.startsWith(HERITAGE_MARKER_PREFIX)) return true;
}
// A file that defines a module is a mixin whose methods run inside some
// including class (see the header). Ruby labels `module` bodies `Trait`.
for (const def of ctx.callerParsed.localDefs) {
if (def.type === 'Trait') return true;
}
for (const imp of ctx.callerParsed.parsedImports) {
for (const constant of constants) {
if (requireReachesConstant(imp.targetRaw, constant)) return true;
// `include Billing::Invoice` arrives as an import whose LOCAL name is the
// constant rather than a path. Not every variant carries one.
if ('localName' in imp && imp.localName === constant) return true;
}
}
// A bare mention of the constant anywhere in the caller (`Billing::Invoice`,
// `Invoice.new`) is enough to make the namespace present in this file. The
// qualified spelling is matched SEGMENT-wise on `::` / `.`: `InvoiceService`
// is not a mention of `Invoice`, and a substring test made it one.
for (const site of ctx.callerParsed.referenceSites) {
for (const constant of constants) {
if (site.name === constant) return true;
if (
site.rawQualifiedName !== undefined &&
site.rawQualifiedName.split(/::|\./).some((segment) => segment === constant)
) {
return true;
}
}
}
return false;
}

View file

@ -10,6 +10,7 @@ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-l
import type { KnowledgeGraph } from '../../../graph/types.js';
import { generateId } from '../../../../lib/utils.js';
import { decodeMarker } from '../../utils/heritage-marker.js';
import { rubyIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
/**
* #1991: resolve a BARE mixin reference (`include Loggable`) to a nested module by
@ -287,4 +288,5 @@ export const rubyScopeResolver: ScopeResolver = {
fieldFallbackOnMethodLookup: true,
propagatesReturnTypesAcrossImports: true,
allowGlobalFreeCallFallback: true,
isGlobalNameFallbackPlausible: rubyIsGlobalNameFallbackPlausible,
};

View file

@ -0,0 +1,122 @@
/**
* Rust's veto on the global-name fallback — see
* `ScopeResolver.isGlobalNameFallbackPlausible`.
*
* Rust has NO ambient namespace. A bare `helper()` resolves only against names
* in scope, and for an item declared in another module the only way in is a
* `use` path (or a fully-qualified `crate::a::b::helper()` call, which is not a
* bare free call and never reaches this tier — it carries a qualified name and
* is resolved earlier by `resolveQualifiedFreeCall`).
*
* One rule therefore covers both halves the visibility question splits into:
*
* - A non-`pub` item cannot be `use`d from outside its module at all, so the
* absence of a covering `use` correctly refuses it.
* - A `pub` item is reachable, but only from a file that actually wrote the
* `use`, which is the same check.
*
* That is why this does not need to read the `pub` marker, which
* `SymbolDefinition` does not carry. It asks the decidable question — "did this
* file bring the name's module into scope?" — instead of the undecidable one.
*
* Module paths are matched against the candidate's FILE path (extension
* stripped, and `mod`/`lib`/`main` stem dropped, since `a/b/mod.rs` IS module
* `a::b`). `use` targets are `::`-separated and `crate::`/`super::` prefixes
* contribute no segments, so suffix matching lines the two up.
*/
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import {
modulePathReaches,
stripExtension,
} from '../../scope-resolution/utils/name-fallback-visibility.js';
/** File-stems that name their PARENT directory as the module, not themselves. */
const RUST_DIRECTORY_MODULE_STEMS: ReadonlySet<string> = new Set(['mod', 'lib', 'main']);
/** Directories that hold a crate's root and contribute no module segment, so
* `src/net/http.rs` is module `net::http` and not `src::net::http`. */
const RUST_CRATE_ROOT_DIRS: ReadonlySet<string> = new Set(['src', 'tests', 'benches', 'examples']);
/** Path prefixes of a `use` that name a root rather than a module segment. */
const RUST_USE_ROOT_PREFIXES: ReadonlySet<string> = new Set(['crate', 'self', 'super', '$crate']);
/**
* The module path a Rust file provides, as a `/`-joined path.
*
* Two normalizations, both needed for a file path and a `use` path to line up
* on their trailing segments: the `mod`/`lib`/`main` stem names its parent
* directory, and a leading crate-root directory (`src/`) is not a module.
*/
function rustModulePathOf(filePath: string): string {
const withoutExtension = stripExtension(filePath);
const segments = withoutExtension.split('/').filter((s) => s !== '');
const stem = segments[segments.length - 1];
if (stem !== undefined && RUST_DIRECTORY_MODULE_STEMS.has(stem)) segments.pop();
while (segments.length > 0 && RUST_CRATE_ROOT_DIRS.has(segments[0]!)) segments.shift();
return segments.join('/');
}
/** A `use` target with its root prefix dropped: `crate::a::b` → `a::b`. */
function rustUsePathOf(targetRaw: string): string {
const segments = targetRaw.split('::').filter((s) => s !== '');
while (segments.length > 0 && RUST_USE_ROOT_PREFIXES.has(segments[0]!)) segments.shift();
return segments.join('::');
}
export function rustIsGlobalNameFallbackPlausible(ctx: {
readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition;
readonly site: { readonly rawQualifiedName?: string };
}): boolean {
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
// A PATH-QUALIFIED call (`User::new(...)`, `crate::a::helper()`) reaches this
// tier when the qualifier could not be followed, carrying only its tail name.
// It is not a bare-name guess: the source named the path, so the module rule
// below would refuse an edge the code spells out.
if (ctx.site.rawQualifiedName !== undefined) return true;
const candidateModule = rustModulePathOf(ctx.candidate.filePath);
// A candidate whose file maps to no module path (a crate root reduced to '')
// is not something this rule can speak about; allow the labeled edge rather
// than refuse on an unanswered question.
if (candidateModule === '') return true;
const candidateName = rustSimpleNameOf(ctx.candidate);
for (const imp of ctx.callerParsed.parsedImports) {
const usePath = rustUsePathOf(imp.targetRaw);
// The `use` path itself names the candidate's module (`use crate::a;`, a
// glob `use crate::a::*`, or a decomposed form whose source is the module):
// the module was brought into scope. Tolerant on purpose — see the header
// of `modulePathReaches` on which direction is the safe one.
if (modulePathReaches(usePath, candidateModule)) return true;
if (imp.kind === 'wildcard') continue;
// Otherwise the path names ONE item inside a module (`use crate::a::other`).
// Its PARENT is the candidate's module only if that item IS the candidate:
// importing `other` says nothing about a `helper` in `a`, and the bare
// parent-path match used to accept every item of `a` on its strength.
// Matched on the ORIGINAL name (`importedName`): an alias renames the local
// handle, so the edge it authorizes is the one written under the alias.
if (importedNameOf(imp) !== candidateName) continue;
const parent = usePath.slice(0, Math.max(0, usePath.lastIndexOf('::')));
if (parent !== '' && modulePathReaches(parent, candidateModule)) return true;
}
return false;
}
/** The identifier a `use` binds, as written at its source (`importedName`). */
function importedNameOf(imp: ParsedFile['parsedImports'][number]): string | undefined {
return 'importedName' in imp ? imp.importedName : undefined;
}
/**
* The identifier a Rust declaration contributes to its module: the FIRST
* segment of `qualifiedName` after any module prefix — `User` for `User.new`
* (an associated function is reached through its type, so it is the type the
* `use` must name), the bare name for a free function.
*/
function rustSimpleNameOf(candidate: SymbolDefinition): string {
const qualified = candidate.qualifiedName ?? '';
const segments = qualified.split(/::|\./).filter((s) => s !== '');
return segments[0] ?? '';
}

View file

@ -19,6 +19,7 @@ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-l
import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js';
import type { KnowledgeGraph } from '../../../graph/types.js';
import { generateId } from '../../../../lib/utils.js';
import { rustIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
/**
* Emit Rust `S IMPLEMENTS T` edges from `impl T for S` trait implementations.
@ -192,4 +193,5 @@ export const rustScopeResolver: ScopeResolver = {
hoistTypeBindingsToModule: true,
propagatesReturnTypesAcrossImports: true,
allowGlobalFreeCallFallback: true,
isGlobalNameFallbackPlausible: rustIsGlobalNameFallbackPlausible,
};

View file

@ -0,0 +1,62 @@
/**
* Swift's veto on the global-name fallback — see
* `ScopeResolver.isGlobalNameFallbackPlausible`.
*
* Swift's default access level is `internal`: visible throughout the MODULE and
* nowhere else. Swift needs no per-file import inside a module, which is why
* the global fallback is enabled for it at all — but that whole-module
* visibility stops hard at the module boundary. A candidate in a DIFFERENT
* module is reachable only if the caller wrote `import <ThatModule>`, and even
* then only if the declaration is `public`.
*
* A module is approximated by its source directory, the layout every Swift
* package manifest produces: `Sources/<Target>/…` and `Tests/<Target>/…`. Files
* outside that layout fall back to their top-level directory.
*
* The `private` / `fileprivate` half of the rule is NOT implemented, because
* neither marker is recoverable from the parse model this hook sees —
* `SymbolDefinition` carries no access level and `ParsedFile` no modifiers.
* Those candidates keep the labeled low-confidence edge, which is the
* "cannot decide, so do not refuse" direction the hook contract asks for.
*/
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import { modulePathReaches } from '../../scope-resolution/utils/name-fallback-visibility.js';
/** Directory names that hold one subdirectory PER TARGET rather than sources. */
const SWIFT_TARGET_ROOTS: ReadonlySet<string> = new Set(['Sources', 'Tests', 'sources', 'tests']);
/**
* The module (target) a Swift file belongs to.
*
* `Sources/Core/User.swift` → `Core`. A path with no target root returns its
* first segment, so a flat repository still groups its files together instead
* of putting every file in its own module.
*/
function swiftModuleOf(filePath: string): string {
const segments = filePath.split('/').filter((s) => s !== '');
for (let i = 0; i < segments.length - 1; i++) {
if (SWIFT_TARGET_ROOTS.has(segments[i]!)) return segments[i + 1]!;
}
return segments.length > 1 ? segments[0]! : '';
}
export function swiftIsGlobalNameFallbackPlausible(ctx: {
readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition;
}): boolean {
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
const callerModule = swiftModuleOf(ctx.callerParsed.filePath);
const candidateModule = swiftModuleOf(ctx.candidate.filePath);
// Same module: whole-module `internal` visibility, no import needed.
if (callerModule === candidateModule) return true;
// A file the layout heuristic cannot place is not something this rule can
// speak about — allow rather than refuse on an unanswered question.
if (callerModule === '' || candidateModule === '') return true;
for (const imp of ctx.callerParsed.parsedImports) {
if (modulePathReaches(imp.targetRaw, candidateModule)) return true;
}
return false;
}

View file

@ -66,6 +66,7 @@ import {
mirrorSwiftSiblingTypeBindings,
type SwiftResolveContext,
} from './index.js';
import { swiftIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const;
@ -136,6 +137,7 @@ const swiftScopeResolver: ScopeResolver = {
// global free-call fallback (as Python/Go/Ruby/COBOL do for the same
// no-`new` constructor + cross-file free-call shape).
allowGlobalFreeCallFallback: true,
isGlobalNameFallbackPlausible: swiftIsGlobalNameFallbackPlausible,
// Swift's call graph models `Type(...)` as a reference to the type
// itself, not its `init` — both the legacy DAG and this test suite link

View file

@ -32,6 +32,7 @@ import {
syntheticCapture,
type SyntaxNode,
} from '../../utils/ast-helpers.js';
import { collectEsmExportEvidence, esmExportVerdict } from '../../ts-js-export-marker.js';
import { splitImportStatement } from './import-decomposer.js';
import { getTsParser, getTsScopeQuery, tsCachedTreeMatchesGrammar } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
@ -388,6 +389,8 @@ export function emitTsScopeCaptures(
}
const rawMatches = getTsScopeQuery(filePath).matches(tree.rootNode);
// Export evidence, read once per file (see `ts-js-export-marker.ts`).
const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath);
const out: CaptureMatch[] = [];
for (const m of rawMatches) {
@ -660,6 +663,20 @@ export function emitTsScopeCaptures(
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
// `@declaration.is-exported`: a verdict for every declaration the file's
// export surface can decide (see `ts-js-export-marker.ts`); nothing where
// it cannot, because absence is the honest answer there.
const declNameNode = groupedNodes['@declaration.name'];
if (exportEvidence !== undefined && declNameNode !== undefined) {
const verdict = esmExportVerdict(declNameNode, exportEvidence);
if (verdict !== undefined) {
grouped['@declaration.is-exported'] = syntheticCapture(
'@declaration.is-exported',
declNameNode,
verdict ? 'true' : 'false',
);
}
}
synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']);
out.push(grouped);

View file

@ -132,6 +132,15 @@ const typescriptScopeResolver: ScopeResolver = {
fieldFallbackOnMethodLookup: false,
propagatesReturnTypesAcrossImports: true,
// ECMAScript: `export * from './a'; export * from './b'` with a name declared
// in both exports NEITHER — the finalize pass refuses the binding instead of
// taking the first-listed source (see `exclusiveWildcardReexports`).
exclusiveWildcardReexports: true,
// `import { x }` never reaches a class member: named imports and named
// re-exports bind to module-level declarations only (a class method sharing
// a name with a top-level value must not win the callable preference).
namedImportsBindTopLevelOnly: true,
// TypeScript uses `.values()` / `.keys()` method-call syntax for collection
// views -- no property-style accessors like C#'s `Dictionary<K,V>.Values` --
// so `elementTypeOf` answers only the `index` route and lets the regular

View file

@ -117,6 +117,9 @@ const vueScopeResolver: ScopeResolver = {
// Vue uses explicit imports for all external symbols; no global free-
// call fallback needed (would produce spurious edges for built-ins).
allowGlobalFreeCallFallback: false,
// Vue SFC scripts are TypeScript/JavaScript: a named import binds a module-level
// declaration, never a class member (see the TS resolver).
namedImportsBindTopLevelOnly: true,
/**
* Expand the scope-resolution file universe for Vue by performing a

View file

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

@ -10,8 +10,9 @@
* Processes help agents understand how features work through the codebase.
*/
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
import type { GraphNode, NodeLabel, RelationshipType } from 'gitnexus-shared';
import { KnowledgeGraph } from '../graph/types.js';
import { isHeuristicEdgeReason } from '../graph/edge-reasons.js';
import { CommunityMembership } from './community-processor.js';
import { calculateEntryPointScore, isTestFile } from './entry-point-scoring.js';
import { SupportedLanguages } from 'gitnexus-shared';
@ -436,12 +437,28 @@ type AdjacencyList = Map<string, string[]>;
*/
const MIN_TRACE_CONFIDENCE = 0.5;
/**
* True when an edge may seed or extend a traced flow.
*
* The confidence floor alone is not sufficient: the global-name fallback emits
* at exactly `MIN_TRACE_CONFIDENCE`, so a `<` comparison admits every one of
* its guesses. A flow assembled from name guesses reads as a real execution
* path through code that may never call each other, so the reason is checked
* too — see `graph/edge-reasons.ts`.
*/
const isTraceableCallsEdge = (
type: RelationshipType,
confidence: number,
reason: string,
): boolean =>
type === 'CALLS' && confidence >= MIN_TRACE_CONFIDENCE && !isHeuristicEdgeReason(reason);
const buildCallsGraph = (graph: KnowledgeGraph): AdjacencyList => {
const adj = new Map<string, string[]>();
// Field-wise scan (#2680) — whole-graph walk, four fields, no object needed.
graph.forEachRelationshipFields((sourceId, targetId, type, confidence) => {
if (type !== 'CALLS' || confidence < MIN_TRACE_CONFIDENCE) return;
// Field-wise scan (#2680) — whole-graph walk, five fields, no object needed.
graph.forEachRelationshipFields((sourceId, targetId, type, confidence, reason) => {
if (!isTraceableCallsEdge(type, confidence, reason)) return;
const existing = adj.get(sourceId);
if (existing === undefined) adj.set(sourceId, [targetId]);
else existing.push(targetId);
@ -453,8 +470,8 @@ const buildCallsGraph = (graph: KnowledgeGraph): AdjacencyList => {
const buildReverseCallsGraph = (graph: KnowledgeGraph): AdjacencyList => {
const adj = new Map<string, string[]>();
graph.forEachRelationshipFields((sourceId, targetId, type, confidence) => {
if (type !== 'CALLS' || confidence < MIN_TRACE_CONFIDENCE) return;
graph.forEachRelationshipFields((sourceId, targetId, type, confidence, reason) => {
if (!isTraceableCallsEdge(type, confidence, reason)) return;
const existing = adj.get(targetId);
if (existing === undefined) adj.set(targetId, [sourceId]);
else existing.push(sourceId);

View file

@ -707,6 +707,10 @@ function buildDefFromDeclarationMatch(
const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']);
const isDeleted = parseBooleanCapture(match['@declaration.is-deleted']);
const isSynthetic = parseBooleanCapture(match['@declaration.is-synthetic']);
// Tri-state on purpose: only a producer that saw the file's export surface
// emits the marker, and both `true` and `false` are verdicts (see
// `SymbolDefinition.isExported`). Absent stays absent.
const isExported = parseBooleanCapture(match['@declaration.is-exported']);
return {
nodeId: makeDefId(filePath, anchor.range, type, nameCap.text),
@ -725,6 +729,7 @@ function buildDefFromDeclarationMatch(
...(isExplicit === true ? { isExplicit: true } : {}),
...(isDeleted === true ? { isDeleted: true } : {}),
...(isSynthetic === true ? { isSynthetic: true } : {}),
...(isExported !== undefined ? { isExported } : {}),
};
}

View file

@ -262,7 +262,8 @@
* - Node identity: same `generateId(...)` helper, same qualified-name
* keyspace, same File/Folder/Method/Class node labels.
* - Edge vocabulary: `'import-resolved' | 'global' | 'local-call' |
* 'same-file' | 'interface-dispatch' | 'read' | 'write'` — both
* 'same-file' | 'interface-dispatch' | 'read' | 'write' |
* 'global-name-fallback'` — both
* paths emit the same reasons (see
* `gitnexus/src/core/ingestion/call-processor.ts` for the legacy
* emitter and `passes/receiver-bound-calls.ts` /
@ -850,6 +851,80 @@ export interface ScopeResolver {
*/
readonly allowGlobalFreeCallFallback?: boolean;
/**
* Two `wildcard` re-exports that both DECLARE a name make it AMBIGUOUS in
* this language — ECMAScript `export *` semantics, where the module simply
* does not export the name and any binding is a guess. Opt-in: for a
* language whose wildcard import is `#include`, `require` or a package
* fan-out, the same name in two files is an overload set or a redeclaration,
* and refusing it would delete real edges (C++ arity-narrowed overloads
* across two headers). Forwarded to `FinalizeHooks.wildcardCollisionIsAmbiguous`.
*/
readonly exclusiveWildcardReexports?: boolean;
/**
* A named import or named re-export can only bind to a MODULE-LEVEL
* declaration of the target file — ECMAScript semantics, where
* `import { x }` never reaches a class member. Opt-in: languages that bind
* module-level members by bare name (static members, module functions)
* leave it off. Forwarded to `FinalizeHooks.namedImportsBindTopLevelOnly`.
*/
readonly namedImportsBindTopLevelOnly?: boolean;
/**
* Veto for a single `allowGlobalFreeCallFallback` guess.
*
* The fallback picks a callable because its SIMPLE NAME is unique in the
* workspace — it consults no import and no scope chain. For most languages a
* large share of those guesses are not merely unlikely but IMPOSSIBLE: Go
* cannot call an unexported identifier from another package, ESM cannot see a
* name it did not import, Rust cannot reach an item with no `use` path. This
* hook is where a language states those rules, so the shared pass can drop
* the edge instead of publishing a guess that the language forbids.
*
* Return `false` to REFUSE (no edge, recorded as `fallback-refused`). Return
* `true`, or leave the hook undefined, to emit the labeled
* `global-name-fallback` edge. **Only answer `false` when the call is
* impossible, not when it is merely unproven** — a wrongly-refused candidate
* is a lost real edge, whereas a wrongly-allowed one is at least labeled and
* excluded from flows.
*
* Deliberately NOT folded into `isCallableVisibleFromCaller`: that hook also
* gates precise dispatch paths (implicit-this, member calls), so a rule
* written for the name-guess tier would silently suppress resolved edges too.
*
* `parsedFileOf` reaches the CANDIDATE's parse result — a language whose rule
* depends on the declaration side (an `export` marker, a `pub` marker) needs
* it, because `SymbolDefinition` carries no visibility field. It returns
* `undefined` for a path outside this pass's file set; treat that as
* "cannot decide" and allow.
*/
readonly isGlobalNameFallbackPlausible?: (ctx: {
readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition;
readonly parsedFileOf: (filePath: string) => ParsedFile | undefined;
/**
* Raw source of any parsed file, for languages whose visibility rule needs
* a declaration the parse does not carry (Go's package clause: a test file's
* `package foo_test` is a different package from its directory's `foo`).
* Absent when the pipeline has no contents at hand; hooks must then answer
* from paths alone and, when undecidable, allow.
*/
readonly sourceTextOf?: (filePath: string) => string | undefined;
/**
* The call site, so a language can tell a BARE name from a PATH-QUALIFIED
* one. Both reach this tier — a qualified call whose qualifier the resolver
* could not follow falls through to the bare-name search with only its tail
* name — but they are not the same claim. Rust's `User::new(...)` named its
* type in source, so refusing it for lacking a `use` of the module would
* delete an edge the source spells out.
*/
readonly site: {
readonly name: string;
readonly rawQualifiedName?: string;
};
}) => boolean;
/**
* In this language every `Method` belongs to a class instance, so a
* FREE (receiver-less) call may resolve to a `Method` only when the

View file

@ -503,9 +503,25 @@ function resolveDefGraphIdUncached(
if (qualifiedHit !== undefined) return qualifiedHit;
}
const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1);
// FAIL CLOSED before the label-agnostic simple key when a FUNCTION-LOCAL
// callable of this name exists in the file. The guards above cover a def
// that is itself a callable; a NON-callable def (`export const selected =
// factory()`, a `Variable` with no graph node of its own) used to fall
// through here and alias onto `wrapper.selected`, the function-local one
// (#3182 review). Whatever the def's label, a bare name matching a local
// callable is the aliasing this key cannot tell apart, and a missing edge
// is the correct failure direction.
for (const localLabel of LOCAL_CALLABLE_LABELS) {
if (nodeLookup.get(localNameKey(filePath, localLabel, simpleName)) !== undefined) {
return undefined;
}
}
return nodeLookup.get(simpleKey(filePath, simpleName));
}
/** Labels the structure phase registers function-local declarations under. */
const LOCAL_CALLABLE_LABELS: readonly NodeLabel[] = ['Function', 'Method'];
/** Derive the simple (unqualified) name of a def from its `qualifiedName`. */
export function simpleQualifiedName(def: SymbolDefinition): string | undefined {
const q = def.qualifiedName;

View file

@ -0,0 +1,201 @@
/**
* Per-language census of the global-name fallback: how many CALLS edges rest on
* a unique-name guess, and how many guesses each language's visibility rules
* refused.
*
* Both halves are needed and neither is meaningful alone. A guess count with no
* refusal count cannot distinguish a language with genuinely few impossible
* candidates from one whose hook is missing; a refusal count with no guess count
* cannot distinguish a working guard from one that rejects everything. The pair
* is what makes the guard auditable on a real repository, which is the whole
* point of recording it — before this, guessed edges were emitted with the same
* reason and confidence as import-resolved ones and the number was unknowable.
*
* Structural sibling of `unresolved-receivers.ts`'s summary, and persisted the
* same way (`RepoMeta.nameFallbackEdges`).
*/
import { getLanguageFromFilename } from 'gitnexus-shared';
import { logger } from '../../logger.js';
import type { ResolutionOutcome } from './resolution-outcome.js';
/**
* CALLS edges per language, from the caller→callee-name index the pipeline
* builds while its streaming sink is live (so it is complete under `--force`),
* bucketed by the CALLER's file language. Gives `NameFallbackSummary.byLanguage`
* its denominator: a guess count is only readable as a share of the calls.
*/
export function countCallsByLanguage(
index: ReadonlyMap<string, ReadonlySet<string>> | undefined,
nodes: { getNode(id: string): { properties: Record<string, unknown> } | undefined } | undefined,
): Readonly<Record<string, number>> | undefined {
if (index === undefined || nodes === undefined) return undefined;
const counts = new Map<string, number>();
for (const [callerId, callees] of index) {
const filePath = nodes.getNode(callerId)?.properties?.filePath;
if (typeof filePath !== 'string') continue;
const language = getLanguageFromFilename(filePath) ?? 'unknown';
counts.set(language, (counts.get(language) ?? 0) + callees.size);
}
if (counts.size === 0) return undefined;
const out: Record<string, number> = {};
for (const [language, count] of [...counts.entries()].sort(([a], [b]) =>
a < b ? -1 : a > b ? 1 : 0,
)) {
out[language] = count;
}
return out;
}
/** Unattributed bucket for a pass that recorded no language. */
const UNKNOWN_LANGUAGE = 'unknown';
export interface NameFallbackLanguageCounts {
/** Labeled `global-name-fallback` edges emitted for this language — CALL SITES. */
readonly guessed: number;
/**
* Distinct (caller file, callee name) pairs among those sites — the unit
* `callsByLanguage` counts in, so `guessedPairs / callsByLanguage[lang]` is a
* ratio bounded by 1. Absent on a summary persisted before this field existed.
*/
readonly guessedPairs?: number;
/** Candidates this language's `isGlobalNameFallbackPlausible` hook refused. */
readonly refused: number;
}
export interface NameFallbackSummary {
/** Language → guessed/refused counts. Languages with neither are absent. */
readonly byLanguage: Readonly<Record<string, NameFallbackLanguageCounts>>;
/** Guessed call sites, repo-wide. */
readonly totalGuessed: number;
/** Distinct (caller file, callee name) pairs among the guessed sites. */
readonly distinctGuessedPairs?: number;
readonly totalRefused: number;
/**
* Barrel names refused because two `export *` sources both declared them
* (`reexport-ambiguous`). Not a guess and not per-language — a name the
* finalize pass declined to bind at all — but it belongs in the same census:
* it is the other place the resolver used to publish an arbitrary winner as
* `import-resolved`.
*/
readonly totalAmbiguousReexports: number;
/**
* The refused barrel names themselves (`file:name`), sorted, capped at
* `MAX_AMBIGUOUS_NAMES`. A count alone cannot say whether a refusal landed on
* a name anyone calls; the list can be joined against the ledger's `byName`.
*/
readonly ambiguousReexportNames?: readonly string[];
/**
* CALLS edges per language (resolved through any path), so `guessed` can be
* read as a SHARE of a language's call graph rather than a bare count. Absent
* when the caller did not supply the totals.
*/
readonly callsByLanguage?: Readonly<Record<string, number>>;
}
/** Bound on the persisted ambiguous-name list; the total beside it stays exact. */
export const MAX_AMBIGUOUS_NAMES = 200;
/**
* Build the summary, or `undefined` when the run neither guessed nor refused —
* a repository with no opt-in language stores no key at all, so the artifact
* stays absent rather than recording a row of zeroes.
*/
export function summarizeNameFallback(
outcomes: readonly ResolutionOutcome[],
callsByLanguage?: Readonly<Record<string, number>>,
): NameFallbackSummary | undefined {
const guessed = new Map<string, number>();
const guessedPairsByLanguage = new Map<string, number>();
const refused = new Map<string, number>();
const ambiguousNames = new Set<string>();
// Two units, both kept. `guessed` counts call SITES — the number of emitted
// guessed edges, which is what the log line has always reported and what
// earlier persisted summaries hold. `guessedPairs` dedupes by (caller file,
// callee name), the unit `callsByLanguage` is counted in: ten guessed `foo()`
// calls in one file are one pair against a denominator that counts `foo`
// once, so the guessy RATIO uses pairs and is bounded by 1. Changing the unit
// of `guessed` itself silently read as a large improvement across engines.
const guessedPairs = new Set<string>();
let totalGuessed = 0;
let totalRefused = 0;
let totalAmbiguousReexports = 0;
for (const outcome of outcomes) {
if (outcome.kind === 'fallback-guessed') {
const language = outcome.language ?? UNKNOWN_LANGUAGE;
guessed.set(language, (guessed.get(language) ?? 0) + 1);
totalGuessed++;
const pair = `${outcome.filePath}\u0000${outcome.name}`;
if (!guessedPairs.has(pair)) {
guessedPairs.add(pair);
guessedPairsByLanguage.set(language, (guessedPairsByLanguage.get(language) ?? 0) + 1);
}
} else if (outcome.kind === 'fallback-refused') {
const language = outcome.language ?? UNKNOWN_LANGUAGE;
refused.set(language, (refused.get(language) ?? 0) + 1);
totalRefused++;
} else if (outcome.kind === 'reexport-ambiguous') {
totalAmbiguousReexports++;
ambiguousNames.add(`${outcome.filePath}:${outcome.name}`);
}
}
if (totalGuessed === 0 && totalRefused === 0 && totalAmbiguousReexports === 0) return undefined;
const byLanguage: Record<string, NameFallbackLanguageCounts> = {};
for (const language of new Set([...guessed.keys(), ...refused.keys()])) {
byLanguage[language] = {
guessed: guessed.get(language) ?? 0,
guessedPairs: guessedPairsByLanguage.get(language) ?? 0,
refused: refused.get(language) ?? 0,
};
}
const sortedNames = [...ambiguousNames].sort();
return {
byLanguage,
totalGuessed,
distinctGuessedPairs: guessedPairs.size,
totalRefused,
totalAmbiguousReexports,
...(sortedNames.length > 0
? { ambiguousReexportNames: sortedNames.slice(0, MAX_AMBIGUOUS_NAMES) }
: {}),
...(callsByLanguage !== undefined ? { callsByLanguage } : {}),
};
}
/**
* One-line readout for the analyze summary. `undefined` when there is nothing to
* report, so a run on a repository with no opt-in language prints no line.
*/
export function formatNameFallbackSummary(
summary: NameFallbackSummary | undefined,
): string | undefined {
if (summary === undefined) return undefined;
const perLanguage = Object.entries(summary.byLanguage)
.sort(([, a], [, b]) => b.guessed + b.refused - (a.guessed + a.refused))
.map(([language, counts]) => `${language} ${counts.guessed}/${counts.refused}`)
.join(', ');
const ambiguous =
summary.totalAmbiguousReexports > 0
? `; ${summary.totalAmbiguousReexports} barrel name(s) refused as ambiguous \`export *\``
: '';
const languages = perLanguage === '' ? 'none' : perLanguage;
const pairs =
summary.distinctGuessedPairs !== undefined
? ` (${summary.distinctGuessedPairs} distinct caller-file/name pairs)`
: '';
return `name-guessed CALLS edges: ${summary.totalGuessed} call sites${pairs}, ${summary.totalRefused} refused as impossible (guessed/refused by language: ${languages})${ambiguous}`;
}
/**
* Print the readout as part of the analyze summary. Unconditional (not behind a
* debug env var, unlike the receiver-drop diagnostic): a reader deciding how far
* to trust this index's call graph needs to know how much of it is guessed, and
* a number nobody sees is the state this work exists to end.
*/
export function logNameFallbackSummary(summary: NameFallbackSummary | undefined): void {
const line = formatNameFallbackSummary(summary);
if (line === undefined) return;
logger.info(line);
}

View file

@ -36,6 +36,7 @@ import type {
ResolutionOutcomeRecorder,
ResolutionSuppressionReason,
} from '../resolution-outcome.js';
import { GLOBAL_NAME_FALLBACK_REASON } from '../../../graph/edge-reasons.js';
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js';
import {
@ -64,6 +65,15 @@ export function emitFreeCallFallback(
workspaceIndex: WorkspaceResolutionIndex,
options: {
readonly allowGlobalFallback?: boolean;
/** Language whose pass this is, carried onto the fallback outcome records
* so the analyze summary can report guesses/refusals PER LANGUAGE. A
* repo-wide total hides which language's rules are the loose ones. */
readonly language?: string;
/** Per-language veto on a name guess — see
* `ScopeResolver.isGlobalNameFallbackPlausible`. */
readonly isGlobalNameFallbackPlausible?: ScopeResolver['isGlobalNameFallbackPlausible'];
/** Raw source lookup handed to `isGlobalNameFallbackPlausible` (optional). */
readonly sourceTextOf?: (filePath: string) => string | undefined;
/** When true, `Type(...)` constructor calls link to the Class def
* itself rather than its explicit Constructor. Swift opts in. */
readonly constructorCallTargetsClass?: boolean;
@ -138,6 +148,14 @@ export function emitFreeCallFallback(
let allFilePathsMemo: ReadonlySet<string> | undefined;
const allFilePaths = (): ReadonlySet<string> =>
(allFilePathsMemo ??= new Set(parsedFiles.map((p) => p.filePath)));
// Candidate-side parse lookup for `isGlobalNameFallbackPlausible`. Built
// lazily and once, on the same terms as `allFilePaths` above: a language
// without the hook never pays for the index.
let parsedByPathMemo: ReadonlyMap<string, ParsedFile> | undefined;
const parsedFileByPath = (): ((filePath: string) => ParsedFile | undefined) => {
parsedByPathMemo ??= new Map(parsedFiles.map((p) => [p.filePath, p]));
return (filePath) => parsedByPathMemo!.get(filePath);
};
// Per-pass memo of pickUniqueGlobalCallable's post-filter candidate list,
// keyed (simpleName, callerFilePath). Only created when no per-caller
// visibility filter applies (the list is then a pure function of name+file —
@ -183,7 +201,17 @@ export function emitFreeCallFallback(
};
for (const parsed of parsedFiles) {
type PendingRel = { rel: Parameters<KnowledgeGraph['addRelationship']>[0]; gatedAll: boolean };
type PendingRel = {
rel: Parameters<KnowledgeGraph['addRelationship']>[0];
gatedAll: boolean;
/**
* The confidence/reason a PRECISELY resolved site (a real binding, not a
* unique-name guess) proved for this edge; `undefined` while every site
* collapsed into it so far was a guess. Decided at flush, not by the
* first site the walk met.
*/
precise: { confidence: number; reason: string } | undefined;
};
const pending = new Map<string, PendingRel>();
const bindingCandidatesByScope =
options.freeCallsRequireInstanceOwnership === true
@ -543,10 +571,18 @@ export function emitFreeCallFallback(
}
}
}
// V1: pickUniqueGlobalCallable ignores import context — resolves to any
// globally-unique callable. False cross-package edges are possible when
// the caller does not import the target package. Same-package calls are
// usually caught by nearest-scope lookup before reaching here.
// Name-guess tier: pickUniqueGlobalCallable consults no import context —
// it resolves to any globally-unique callable. Same-package calls are
// usually caught by nearest-scope lookup before reaching here, so what
// lands in this tier is disproportionately cross-module, and a
// cross-module name match is a guess.
//
// Two things make that honest rather than a lie. The language's
// `isGlobalNameFallbackPlausible` hook refuses candidates its own
// visibility rules forbid (below), and every edge that survives is
// emitted with `GLOBAL_NAME_FALLBACK_REASON` at 0.5 rather than
// masquerading as `import-resolved` at 0.85 (see the emit site).
let fnDefFromGlobalNameFallback = false;
if (fnDef === undefined && options.allowGlobalFallback === true) {
fnDef = pickUniqueGlobalCallable(
site.name,
@ -570,6 +606,33 @@ export function emitFreeCallFallback(
scopeDefsCache,
options.conversionOnlyArgTypePrefixes,
);
fnDefFromGlobalNameFallback = fnDef !== undefined;
}
if (fnDefFromGlobalNameFallback && fnDef !== undefined) {
if (
options.isGlobalNameFallbackPlausible?.({
callerParsed: parsed,
candidate: fnDef,
parsedFileOf: parsedFileByPath(),
sourceTextOf: options.sourceTextOf,
site: { name: site.name, rawQualifiedName: site.rawQualifiedName },
}) === false
) {
// The language proved this call impossible. Mark the site handled so
// `emit-references` does not substitute its own looser guess for the
// edge we just refused — the point is no edge, not a different one.
options.recordResolutionOutcome?.({
kind: 'fallback-refused',
candidateId: fnDef.nodeId,
language: options.language,
phase: 'free-call-fallback',
filePath: parsed.filePath,
name: site.name,
range: site.atRange,
});
handledSites.add(siteKey(parsed.filePath, site));
continue;
}
}
if (fnDef === undefined) continue;
if (fnDef.isDeleted === true) {
@ -617,40 +680,78 @@ export function emitFreeCallFallback(
site.atRange.startCol,
tgtGraphId,
);
if (fnDefFromGlobalNameFallback) {
options.recordResolutionOutcome?.({
kind: 'fallback-guessed',
targetId: fnDef.nodeId,
language: options.language,
phase: 'free-call-fallback',
filePath: parsed.filePath,
name: site.name,
range: site.atRange,
});
}
const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`;
// One edge per (caller, callee): `staticGated` is the AND over every site
// that collapses into it, so a callee reached from one live site and one
// dead site stays live whichever site the walk meets first. Emission is
// deferred to the end of this file's sites for that reason.
const preciseHere = fnDefFromGlobalNameFallback
? undefined
: {
confidence: 0.85,
// Match legacy DAG's reason convention so consumers that
// assert `reason === 'import-resolved'` keep working. The
// construction-site marker is opt-in for the same reason.
reason: constructionSiteReason(
fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
site,
options.markConstructionSites,
),
};
const pendingRel = pending.get(relId);
if (pendingRel !== undefined) {
if (site.staticGated !== true) pendingRel.gatedAll = false;
// The edge's label is decided at flush time from EVERY site that
// collapsed into it, not from whichever the walk met first. One site
// resolved through a real binding PROVES the dependency; a guessed
// site for the same pair is then redundant evidence, not a taint.
if (pendingRel.precise === undefined) pendingRel.precise = preciseHere;
continue;
}
if (seen.has(relId)) continue;
seen.add(relId);
pending.set(relId, {
gatedAll: site.staticGated === true,
precise: preciseHere,
rel: {
id: relId,
sourceId: callerGraphId,
targetId: tgtGraphId,
type: 'CALLS',
confidence: 0.85,
// Match legacy DAG's reason convention so consumers that
// assert `reason === 'import-resolved'` keep working. The
// construction-site marker is opt-in for the same reason.
reason: constructionSiteReason(
fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
site,
options.markConstructionSites,
),
// Guess values as placeholders; decided at flush from `precise`.
confidence: 0.5,
reason: GLOBAL_NAME_FALLBACK_REASON,
},
});
emitted++;
}
for (const { rel, gatedAll } of pending.values()) {
graph.addRelationship(gatedAll ? { ...rel, staticGated: true } : rel);
for (const { rel, gatedAll, precise } of pending.values()) {
// A name guess is not an import resolution and must not be spelled like
// one. It used to be emitted at 0.85 / `'import-resolved'`, which made
// it indistinguishable from an edge a real import produced — so every
// consumer that wanted to discount guesses had no field to do it with.
// 0.5 is the deliberate "coin flip" value, and the reason is what the
// process/community walks and the MCP tools actually key on, because
// 0.5 sits exactly ON their thresholds (see graph/edge-reasons.ts).
// An edge is a guess only when EVERY site that collapsed into it was one;
// a single precisely resolved site proves it, whatever order the walk
// met the sites in. Independent of `gatedAll`.
const labeled =
precise !== undefined
? { ...rel, confidence: precise.confidence, reason: precise.reason }
: rel;
graph.addRelationship(gatedAll ? { ...labeled, staticGated: true } : labeled);
}
}
return emitted;

View file

@ -732,9 +732,23 @@ export function runScopeResolution(
provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [],
mergeBindings: (existing, incoming, scopeId) =>
provider.mergeBindings(existing, incoming, scopeId),
wildcardCollisionIsAmbiguous: provider.exclusiveWildcardReexports === true,
namedImportsBindTopLevelOnly: provider.namedImportsBindTopLevelOnly === true,
},
});
logHeapProbe('sr-post-finalize', `lang=${provider.language}`);
// `export *` collisions the shared finalize refused to bind (WS1 C2). Recorded
// as outcomes so the refusal is auditable next to the name-fallback census —
// a silently unresolved importer is indistinguishable from a resolver gap.
for (const refused of finalized.stats.ambiguousWildcardExports) {
recordResolutionOutcome({
kind: 'reexport-ambiguous',
candidateIds: refused.candidateDefIds,
phase: 'finalize',
filePath: refused.filePath,
name: refused.name,
});
}
// One store and ONE writer rule for heritage instantiations (#2912), shared by
// the pre-pass below and by the language hook further down — a heritage shape
// the pre-pass cannot express (Rust `impl T for S`, Dart `implements`) records
@ -1080,6 +1094,12 @@ export function runScopeResolution(
workspaceIndex,
{
allowGlobalFallback: provider.allowGlobalFreeCallFallback === true,
language: provider.language,
isGlobalNameFallbackPlausible: provider.isGlobalNameFallbackPlausible,
sourceTextOf:
provider.isGlobalNameFallbackPlausible !== undefined
? (filePath: string) => getFileContents().get(filePath)
: undefined,
constructorCallTargetsClass: provider.constructorCallTargetsClass === true,
markConstructionSites: provider.markConstructionSites === true,
isFileLocalDef: provider.isFileLocalDef,

View file

@ -108,6 +108,60 @@ export type ResolutionOutcome =
* every real codebase and taught readers to ignore it.
*/
readonly receiverOrigin?: ReceiverOrigin;
}
/**
* The global-name fallback fired: a callable was chosen because its SIMPLE
* NAME is unique in the workspace, with no import or scope chain leading to
* it. A labeled low-confidence edge WAS emitted.
*
* Separate from `resolved` because it is not a resolution, and separate from
* `suppressed` because an edge exists. Counting it is the only way a reader
* can tell how much of a language's call graph rests on name uniqueness — the
* number that was previously invisible because these edges were emitted with
* the same reason and confidence as import-resolved ones.
*/
| {
readonly kind: 'fallback-guessed';
readonly targetId: string;
readonly language?: string;
readonly phase: string;
readonly filePath: string;
readonly name: string;
readonly range: Range;
}
/**
* A global-name-fallback candidate was REFUSED by the language's plausibility
* hook: the language's own visibility rules make that call impossible, so no
* edge was emitted.
*
* The counterpart of `fallback-guessed`, and the pair is what makes the
* refusal auditable — a refusal count with no guess count cannot distinguish
* "the guard works" from "the guard rejects everything".
*/
| {
readonly kind: 'fallback-refused';
readonly candidateId: string;
readonly language?: string;
readonly phase: string;
readonly filePath: string;
readonly name: string;
readonly range: Range;
}
/**
* A barrel re-exported `name` through two or more `export *` sources that
* each declare it, so the language names no winner. The shared finalize pass
* REFUSED the binding (see `FinalizeStats.ambiguousWildcardExports`) instead
* of publishing the first-listed source as `import-resolved`; every importer
* of `name` through `filePath` stays unresolved. No `range`: the collision
* belongs to the file's export surface, not to one statement.
*/
| {
readonly kind: 'reexport-ambiguous';
readonly candidateIds: readonly string[];
readonly phase: string;
/** The barrel file whose `export *` sources collide. */
readonly filePath: string;
readonly name: string;
};
/**

View file

@ -0,0 +1,97 @@
/**
* Language-agnostic primitives for `ScopeResolver.isGlobalNameFallbackPlausible`
* implementations.
*
* The hook itself is per-language — the RULES here are not. What every
* implementation needs is the same small set of path arithmetic: which
* directory a file sits in, and whether a module path a caller wrote can name
* a given file or directory. Those questions are about paths, not about any
* language, so they live in shared code (see AGENTS.md: shared ingestion must
* not name languages) and the language files supply only the semantics.
*/
import type { ParsedFile } from 'gitnexus-shared';
/** POSIX-style parent directory. `''` for a file at the repo root. */
export function directoryOf(filePath: string): string {
const slash = filePath.lastIndexOf('/');
return slash === -1 ? '' : filePath.slice(0, slash);
}
/**
* Split a module path into segments, accepting the three separators languages
* spell module nesting with: `/` (Go, Node), `::` (Rust, C++) and `.` (JVM,
* Python). Empty segments and `.` are dropped, so a leading `./` contributes
* nothing. Named root prefixes are NOT stripped here — `crate::a` yields
* `['crate', 'a']`; a language whose paths carry one (Rust's `crate::` /
* `super::`) removes it before calling, see `rustUsePathOf`.
*
* `.` is only treated as a separator when the path contains no `/`: a Node
* specifier like `./util/parse.js` must not split on the extension dot.
*/
export function moduleSegments(modulePath: string): readonly string[] {
const bySlashOrColon = modulePath.split(/\/|::/).filter((s) => s !== '' && s !== '.');
if (modulePath.includes('/')) return bySlashOrColon;
return bySlashOrColon.flatMap((s) => s.split('.').filter((p) => p !== ''));
}
/**
* Does a module path a caller wrote reach `targetPath`?
*
* True when either side's segments are a SUFFIX of the other's. Both directions
* are needed and neither alone is sufficient:
*
* - The written path is usually longer than the repo-relative one, because it
* carries a module/package prefix that is not a directory
* (`github.com/org/svc/internal/models` → `internal/models`).
* - The repo-relative path is longer when the manifest that defines the module
* root sits in a subdirectory of the analyzed tree (`svc/go.mod`, so
* `svc/internal/models` is written `mod/internal/models`).
*
* So the match is on ALIGNED TRAILING SEGMENTS, and it succeeds when either the
* shorter side is fully contained in the longer, or at least two segments align.
* Both conditions are needed: full containment covers a one-segment package
* (`mod/models` reaching `models`), while the two-segment floor covers the case
* where neither side contains the other because each carries a different root
* (`mod/internal/models` vs `svc/internal/models`). A SINGLE aligned segment
* with neither side contained is not enough — `handlers` appearing at the end of
* two unrelated trees says nothing.
*
* Tolerance is the SAFE direction here: this predicate is consulted to decide
* whether to REFUSE an edge, so over-matching loses a refusal (the edge stays,
* labeled and excluded from flows) while under-matching loses a real edge.
*/
export function modulePathReaches(writtenPath: string, targetPath: string): boolean {
const written = moduleSegments(writtenPath);
const target = moduleSegments(targetPath);
if (written.length === 0 || target.length === 0) return false;
const shorter = Math.min(written.length, target.length);
let aligned = 0;
while (
aligned < shorter &&
written[written.length - 1 - aligned] === target[target.length - 1 - aligned]
) {
aligned++;
}
return aligned === shorter || aligned >= 2;
}
/** Every module path this file's import statements named, in source order. */
function importedModulePaths(parsed: ParsedFile): readonly string[] {
return parsed.parsedImports.map((imp) => imp.targetRaw);
}
/** True when any of the caller's imports reaches `targetPath`. */
export function anyImportReaches(parsed: ParsedFile, targetPath: string): boolean {
for (const written of importedModulePaths(parsed)) {
if (modulePathReaches(written, targetPath)) return true;
}
return false;
}
/** Strip a trailing file extension. `a/b.rs` → `a/b`; `a/b` → `a/b`. */
export function stripExtension(filePath: string): string {
const slash = filePath.lastIndexOf('/');
const dot = filePath.lastIndexOf('.');
return dot > slash ? filePath.slice(0, dot) : filePath;
}

View file

@ -0,0 +1,188 @@
/**
* 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*(this\.[A-Za-z_$][\w$]*\s*=)/;
/**
* Does the file touch a CommonJS export object anywhere — `module.exports` or
* `exports.x` / `exports[x]` — as an actual expression? Read from AST nodes,
* not source text, so a comment or string mentioning `module.exports` does not
* disable the file's verdicts.
*/
function hasCommonJsExportSurface(root: SyntaxNode): boolean {
for (const member of root.descendantsOfType('member_expression')) {
const object = member.childForFieldName('object');
if (object === null) continue;
if (object.type === 'identifier' && object.text === 'exports') return true;
if (
object.type === 'identifier' &&
object.text === 'module' &&
member.childForFieldName('property')?.text === 'exports'
) {
return true;
}
}
for (const sub of root.descendantsOfType('subscript_expression')) {
const object = sub.childForFieldName('object');
if (object?.type === 'identifier' && object.text === 'exports') return true;
}
return false;
}
/** Node types below which a declaration is nested, not module-level. */
const NESTING_BOUNDARIES: ReadonlySet<string> = new Set([
'class_body',
'interface_body',
'enum_body',
'object_type',
'statement_block',
'arrow_function',
'function_expression',
'function_declaration',
'generator_function',
'generator_function_declaration',
'method_definition',
// `export namespace NS { export function f() {} }` / `declare module 'x' {
// export function q(): void }`: an `export` inside these bodies is an export
// of the namespace/ambient module, not of the file.
'internal_module',
'module',
'ambient_declaration',
]);
/**
* Scan a file's top level once. `undefined` means the file's export surface
* cannot be read at all (ambient `.d.ts`), so no marker should be emitted.
*/
export function collectEsmExportEvidence(
root: SyntaxNode,
filePath: string,
): EsmExportEvidence | undefined {
if (filePath.endsWith('.d.ts')) return undefined;
const namedLocals = new Set<string>();
// Any CommonJS export surface anywhere in the file — a direct `module.exports
// = …`, an alias (`const m = module.exports; m.x = …`), an `exports.x` — means
// "not under `export`" says nothing.
let commonJs = hasCommonJsExportSurface(root);
for (const stmt of root.namedChildren) {
if (stmt.type === 'export_statement') {
// `export { a } from './x'` / `export type { T } from './t'` re-export
// ANOTHER module's names: they say nothing about a local `a`, and adding
// them here marked a private local of the same name exported.
if (stmt.childForFieldName('source') !== null) continue;
for (const child of stmt.namedChildren) {
if (child.type === 'export_clause') {
for (const spec of child.namedChildren) {
if (spec.type !== 'export_specifier') continue;
const name = spec.childForFieldName('name')?.text;
if (name !== undefined && name !== '') namedLocals.add(name);
}
} else if (child.type === 'identifier') {
// `export default f;` and TS `export = f;`.
namedLocals.add(child.text);
}
}
} else if (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;
// An `export` keyword is only a FILE-level export when the walk reaches the
// program without crossing a nesting boundary — one inside a namespace or
// ambient-module body is that container's export (see NESTING_BOUNDARIES).
let underExport = false;
while (current !== null && current.type !== 'program') {
if (current.type === 'export_statement') {
underExport = true;
current = current.parent;
continue;
}
if (current.type === 'object') {
// `module.exports = { alpha() {} }`: the literal's own members are the
// module's exports. Any other object literal is a nesting boundary.
if (isModuleExportsObject(current) && nameNode.parent?.parent?.id === current.id) return true;
return evidence.commonJs ? undefined : false;
}
if (NESTING_BOUNDARIES.has(current.type) && current.id !== nameNode.parent?.id) {
return evidence.commonJs ? undefined : false;
}
current = current.parent;
}
if (underExport) return true;
if (evidence.commonJs) return undefined;
return evidence.namedLocals.has(nameNode.text);
}

View file

@ -70,7 +70,7 @@
* replaced, so the obvious rewrite is not the one that shipped.
* 3. The remaining ~90 ms was object allocation itself, irreducible while the
* read API returns objects — so the five whole-graph scans moved to
* `forEachRelationshipFields`, which passes the four fields they actually
* `forEachRelationshipFields`, which passes the five fields they actually
* read as primitives and allocates nothing. See
* {@link GraphEmitSink.forEachRelationshipFields}.
*
@ -282,14 +282,19 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl {
* safe because `buildRelRow` never persists `rel.id` and no consumer keys on
* it (audited).
*
* The dropped `reason`/`step` are safe too, but for a different reason worth
* stating: the PERSISTED row keeps their true values, because `buildRelRow` is
* handed the original relationship on the way through. Only in-memory reads
* see the `'streamed'` placeholder, and the in-pipeline consumers of streamed
* edges read neither field. So e.g. the `ACCESSES reason: 'read'|'write'`
* distinction that MCP queries rely on survives in the database. A future
* in-pipeline consumer needing `reason` or `step` on a streamed edge must add
* the column, not trust the placeholder.
* `reason` IS now retained, as an interned index — the in-pipeline consumer
* this JSDoc anticipated arrived. Process tracing and large-graph community
* detection must exclude global-name-fallback edges, which are emitted at
* exactly their confidence threshold (0.5) and so cannot be separated by
* confidence alone. Interning keeps the cost at one small integer per edge
* (the reason vocabulary is a fixed set of literals), not one string.
*
* `id` and `step` remain dropped. The PERSISTED row keeps `step`'s true value,
* because `buildRelRow` is handed the original relationship on the way
* through; only in-memory OBJECT reads see the `'streamed'`-era placeholder,
* and no in-pipeline consumer of streamed edges reads `step`. A future
* in-pipeline consumer needing `step` must add the column, not trust the
* placeholder.
*
* Node ids are interned; the strings are shared by reference with the node
* map's, so interning adds bookkeeping, not new text.
@ -300,6 +305,12 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl {
private readonly tgtIx: number[] = [];
private readonly relTypes: RelationshipType[] = [];
private readonly confidences: number[] = [];
/** Interned reason strings, and the per-edge index into them. The vocabulary
* is a fixed set of emitter literals, so this is O(vocabulary) text plus one
* small integer per edge. */
private readonly reasonIds = new Map<string, number>();
private readonly reasonByIx: string[] = [];
private readonly reasonIx: number[] = [];
private finalized = false;
/**
* Streaming is OFF until {@link beginStreaming} is called by `parse`.
@ -472,6 +483,16 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl {
this.tgtIx.push(tgtIx);
this.relTypes.push(relationship.type);
this.confidences.push(relationship.confidence);
this.reasonIx.push(this.internReason(relationship.reason));
}
private internReason(reason: string): number {
const existing = this.reasonIds.get(reason);
if (existing !== undefined) return existing;
const ix = this.reasonByIx.length;
this.reasonByIx.push(reason);
this.reasonIds.set(reason, ix);
return ix;
}
/** Flush + close every writer and return the COPY manifest. Every fd is
@ -599,7 +620,13 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl {
* with the object-based graph despite holding relationships columnar.
*/
forEachRelationshipFields(
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
fn: (
sourceId: string,
targetId: string,
type: RelationshipType,
confidence: number,
reason: string,
) => void,
): void {
this.real.forEachRelationshipFields(fn);
for (let ix = 0; ix < this.srcIx.length; ix++) {
@ -608,6 +635,7 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl {
this.nodeIdByIx[this.tgtIx[ix]],
this.relTypes[ix],
this.confidences[ix],
this.reasonByIx[this.reasonIx[ix]],
);
}
}

View file

@ -299,7 +299,13 @@ export class PdgEmitSink implements KnowledgeGraph {
this.real.forEachRelationship(fn);
}
forEachRelationshipFields(
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
fn: (
sourceId: string,
targetId: string,
type: RelationshipType,
confidence: number,
reason: string,
) => void,
): void {
this.real.forEachRelationshipFields(fn);
}

View file

@ -17,6 +17,12 @@ import { constants as fsConstants } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { retryRename } from '../storage/fs-atomic.js';
import { acquireIndexLock } from '../storage/index-lock.js';
import { invalidateNodeWorkspacePackages } from './ingestion/import-resolvers/node-workspace-packages.js';
import {
logNameFallbackSummary,
summarizeNameFallback,
countCallsByLanguage,
} from './ingestion/scope-resolution/name-fallback-summary.js';
import { runPipelineFromRepo } from './ingestion/pipeline.js';
import {
logUnresolvedReceiverFiles,
@ -1076,6 +1082,9 @@ export async function runFullAnalysis(
// Scope the degraded-parse log throttle to this run (module-level counter
// would otherwise stay saturated on a reused process).
resetDegradedParseCounter();
// The workspace-package memo is per process: this run must see the tree as it
// is now, not as the previous run in a long-lived watch/server process saw it.
invalidateNodeWorkspacePackages(repoPath);
const log = (msg: string) => callbacks.onLog?.(stripControlCharacters(msg));
const acquireOpts = {
@ -3861,6 +3870,14 @@ async function runFullAnalysisInner(
const resolutionOutcomes = pipelineResult.resolutionOutcomes ?? [];
logUnresolvedReceiverFiles(resolutionOutcomes);
// Census of name-guessed CALLS edges (labeled `global-name-fallback`), refused
// impossibles and ambiguous `export *` names — the honesty readout for this
// run's resolution. Logged, and persisted below as `nameFallbackEdges`.
const nameFallbackSummary = summarizeNameFallback(
resolutionOutcomes,
countCallsByLanguage(pipelineResult.resolvedCalleeNamesByCaller, pipelineResult.graph),
);
logNameFallbackSummary(nameFallbackSummary);
// Annotated so the capabilities stamp below is compile-checked against
// RepoMeta's status unions (tri-review 4669518496 P1/U3) — an unannotated
@ -3977,6 +3994,7 @@ async function runFullAnalysisInner(
// Git-only: non-git repos never take the incremental path.
schemaFingerprint: hasGitDir(repoPath) ? SCHEMA_FINGERPRINT : undefined,
unresolvedReceiverMembers: summarizeUnresolvedReceivers(resolutionOutcomes),
nameFallbackEdges: nameFallbackSummary,
scopeExtractionFailures: summarizeScopeExtractionFailures(
pipelineResult.scopeExtractionFailures,
),

View file

@ -28,6 +28,7 @@
import fs from 'fs/promises';
import path from 'path';
import type { UnresolvedReceiverSummary } from '../core/ingestion/scope-resolution/unresolved-receivers.js';
import type { NameFallbackSummary } from '../core/ingestion/scope-resolution/name-fallback-summary.js';
import type { UndecidedSatisfactionSummary } from '../core/ingestion/scope-resolution/undecided-satisfaction.js';
import type { ScopeExtractionFailureSummary } from '../core/ingestion/scope-resolution/scope-extraction-failures.js';
@ -311,6 +312,13 @@ export interface RepoMeta {
* reads as absent, and both correctly mean "no hedge available from here".
*/
undecidedInterfaceSatisfaction?: UndecidedSatisfactionSummary;
/**
* Census of the name-guessed CALLS edges the run emitted (labeled
* `global-name-fallback`), the impossible ones it refused, and the ambiguous
* `export *` names it declined to publish. Absent on indexes built before the
* census existed. See `scope-resolution/name-fallback-summary.ts`.
*/
nameFallbackEdges?: NameFallbackSummary;
/**
* SHA-256 of every file's content at the time of the last successful
* indexing run. The next run computes current hashes and diffs against

View file

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

View file

@ -0,0 +1,135 @@
/**
* C2 — the grafana `Button` shape: a workspace barrel `export *`s a components
* index, which re-exports NAMED bindings (with inline `type` modifiers) from a
* DIRECTORY index, which `export *`s the real file, whose `Button` is a
* `React.forwardRef` const. Measured on grafana@871af0720: `Button` resolved 8
* of 475 ledger entries while siblings through plain hops resolved at scale.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import {
getRelationships,
getResolutionOutcomes,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './helpers.js';
describe('named re-export through a directory index that wildcards (grafana Button shape)', () => {
let result: PipelineResult;
let repoDir: string | undefined;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-c2-dir-index-'));
writeFixtureRepo(repoDir, {
'package.json': '{ "name": "root", "private": true, "workspaces": ["packages/*"] }\n',
'packages/ui/package.json':
'{ "name": "@x/ui", "version": "1.0.0", "main": "src/index.ts" }\n',
'packages/ui/src/index.ts': `export * from './components';\nexport * from './themes';\n`,
// Inline `type` modifiers on the same statement as value re-exports.
'packages/ui/src/components/index.ts': `export { Stack } from './Layout/Stack';
export { Button, LinkButton, type ButtonVariant, ButtonGroup, type ButtonProps, clearButtonStyles } from './Button';
`,
// Directory index: wildcard + one named re-export.
'packages/ui/src/components/Button/index.ts': `export * from './Button';\nexport { ButtonGroup } from './ButtonGroup';\n`,
'packages/ui/src/components/Button/Button.tsx': `import React from 'react';
export type ButtonVariant = 'primary' | 'secondary';
export interface ButtonProps { variant?: ButtonVariant; label: string }
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
return null;
});
export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonProps>((props, ref) => {
return null;
});
export const clearButtonStyles = (theme: string) => {
return theme;
};
`,
'packages/ui/src/components/Button/ButtonGroup.tsx': `export function ButtonGroup(children: string) {
return children;
}
`,
'packages/ui/src/components/Layout/Stack.tsx': `export function Stack(children: string) {
return children;
}
`,
'packages/ui/src/themes/index.ts': `export * from './hooks';\n`,
'packages/ui/src/themes/hooks/index.ts': `export * from './useStyles2';\n`,
'packages/ui/src/themes/hooks/useStyles2.ts': `export function useStyles2(fn: (t: string) => string) {
return fn('theme');
}
`,
'packages/app/package.json':
'{ "name": "@x/app", "version": "1.0.0", "main": "src/main.tsx", "dependencies": { "@x/ui": "1.0.0" } }\n',
'packages/app/tsconfig.json': `{ "compilerOptions": { "jsx": "react-jsx" } }\n`,
'packages/app/src/main.tsx': `import { Button, LinkButton, ButtonGroup, clearButtonStyles, Stack, useStyles2 } from '@x/ui';
export function render() {
const styles = useStyles2((t) => t);
clearButtonStyles(styles);
ButtonGroup('x');
Stack('y');
const a = <Button label="a" />;
const b = <LinkButton label="b" />;
return [a, b];
}
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 120_000);
afterAll(() => {
if (repoDir !== undefined)
fs.rmSync(repoDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
const callsFromMain = () =>
getRelationships(result, 'CALLS').filter((e) =>
e.sourceFilePath.includes('packages/app/src/main.tsx'),
);
it('resolves the plain named hop (Stack) and the deep wildcard chain (useStyles2)', () => {
const targets = callsFromMain().map((e) => e.target);
expect(targets.some((t) => t.includes('Stack'))).toBe(true);
expect(targets.some((t) => t.includes('useStyles2'))).toBe(true);
});
it('resolves value names on a statement that also carries inline `type` modifiers', () => {
const targets = callsFromMain().map((e) => e.target);
expect(targets.some((t) => t.includes('clearButtonStyles'))).toBe(true);
expect(targets.some((t) => t.includes('ButtonGroup'))).toBe(true);
});
it('resolves a forwardRef const component used as JSX through the dir-index wildcard', () => {
const targets = callsFromMain().map((e) => e.target);
// Exact name — `/Button$/` also matched `LinkButton`, which made the
// assertion below imply this one and would have let zero `Button` edges pass.
expect(targets).toContain('Button');
expect(targets.some((t) => t.includes('LinkButton'))).toBe(true);
});
it('refuses nothing on this shape (no ambiguity, no fallback)', () => {
const outcomes = getResolutionOutcomes(result);
expect(outcomes.filter((o) => o.kind === 'reexport-ambiguous')).toEqual([]);
expect(outcomes.filter((o) => o.kind === 'fallback-guessed')).toEqual([]);
});
// The bug this fixture regresses against wasn't "no edge" — it was an edge
// to the WRONG node. `export const Button = React.forwardRef(...)` emits a
// `Variable` def for the lexical declaration alongside the `Function` def
// for the arrow; before the wildcard fan-out used the same
// callable-preferred index the named-reexport path already used, whichever
// def `localDefs` happened to iterate first could win the closure slot. An
// edge landing on `Variable` would still show up in a `target` name match
// (both defs share the simple name) while pointing at the wrong graph node
// — so the label, not just the name, is the assertion that actually catches
// a regression here.
it('every arrow-const winner through the wildcard chain is the Function def, not the Variable shadow', () => {
const byTarget = new Map(callsFromMain().map((e) => [e.target, e.targetLabel]));
expect(byTarget.get('Button')).toBe('Function');
expect(byTarget.get('LinkButton')).toBe('Function');
expect(byTarget.get('clearButtonStyles')).toBe('Function');
});
});

View file

@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './helpers.js';
/**
* `namedImportsBindTopLevelOnly` (ECMAScript): `import { beta }` can never reach a
* class member. Before the hook, `findExportByName`'s callable preference let
* `Foo.beta()` outrank the top-level `const beta = 42` — or bind on its own when no
* top-level `beta` existed — and the import produced a confident CALLS edge to a
* symbol the module cannot export. Incorrect context is worse than missing: the
* Variable shadow must win and emit no edge; the member must never bind.
*/
const impl = `export const beta = 42;\nexport function alpha(s: string) { return s; }\nexport class Foo { beta() { return 1; } }\n`;
const memberOnly = `export function alpha(s: string) { return s; }\nexport class Foo { beta() { return 1; } }\n`;
async function run(name: string, files: Record<string, string>) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-named-member-${name}-`));
writeFixtureRepo(dir, files);
const result = await runPipelineFromRepo(dir, () => {});
const targets = getRelationships(result, 'CALLS')
.filter((e) => e.sourceFilePath.includes('src/main'))
.map((e) => e.target)
.sort();
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
return targets;
}
describe('named imports bind module-level declarations only (TS/JS)', () => {
it('direct named import: a class method sharing a top-level value name emits no edge', async () => {
const targets = await run('direct', {
'package.json': '{ "name": "root", "private": true }\n',
'src/Impl.ts': impl,
'src/main.ts': `import { alpha, beta } from './Impl';\nexport function render() { alpha('a'); beta(); }\n`,
});
expect(targets).toEqual(['alpha']);
}, 60000);
it('direct named import: a class method with NO top-level declaration does not bind', async () => {
const targets = await run('member-only', {
'package.json': '{ "name": "root", "private": true }\n',
'src/Impl.ts': memberOnly,
'src/main.ts': `import { alpha, beta } from './Impl';\nexport function render() { alpha('a'); beta(); }\n`,
});
expect(targets).toEqual(['alpha']);
}, 60000);
it('named re-export through a barrel: same rule', async () => {
const targets = await run('barrel', {
'package.json': '{ "name": "root", "private": true }\n',
'src/Impl.ts': impl,
'src/index.ts': `export { alpha, beta } from './Impl';\n`,
'src/main.ts': `import { alpha, beta } from './index';\nexport function render() { alpha('a'); beta(); }\n`,
});
expect(targets).toEqual(['alpha']);
}, 60000);
it('control: a top-level arrow-const behind the same barrel still binds', async () => {
const targets = await run('control', {
'package.json': '{ "name": "root", "private": true }\n',
'src/Impl.ts': `export const beta = () => 1;\nexport function alpha(s: string) { return s; }\n`,
'src/index.ts': `export { alpha, beta } from './Impl';\n`,
'src/main.ts': `import { alpha, beta } from './index';\nexport function render() { alpha('a'); beta(); }\n`,
});
expect(targets).toEqual(['alpha', 'beta']);
}, 60000);
});

View file

@ -0,0 +1,56 @@
/**
* C6 — ECMAScript precedence: an explicit named export shadows a star
* collision. Only star-vs-star is ambiguous; `export { collide } from './a'`
* next to `export * from './a'; export * from './b'` binds `a`'s `collide`
* and must NOT be refused.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import {
getRelationships,
getResolutionOutcomes,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './helpers.js';
describe('named export shadows a star collision', () => {
let result: PipelineResult;
let dir: string;
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-c6-prec-'));
writeFixtureRepo(dir, {
'package.json': '{ "name": "root", "private": true, "workspaces": ["packages/*"] }\n',
'packages/ui/package.json':
'{ "name": "@x/ui", "version": "1.0.0", "main": "src/index.ts" }\n',
'packages/ui/src/index.ts': `export { collide } from './a';\nexport * from './a';\nexport * from './b';\n`,
'packages/ui/src/a.ts': `export function collide() { return 'a'; }\nexport function onlyA() { return 1; }\n`,
'packages/ui/src/b.ts': `export function collide() { return 'b'; }\nexport function onlyB() { return 2; }\n`,
'packages/app/package.json':
'{ "name": "@x/app", "version": "1.0.0", "main": "src/main.ts", "dependencies": { "@x/ui": "1.0.0" } }\n',
'packages/app/src/main.ts': `import { collide, onlyA, onlyB } from '@x/ui';\nexport function run() { collide(); onlyA(); onlyB(); }\n`,
});
result = await runPipelineFromRepo(dir, () => {});
}, 120_000);
afterAll(() => fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }));
it('binds the named export (a.ts) and does not refuse it', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.sourceFilePath.includes('main.ts') && e.target === 'collide',
);
expect(edges.length).toBe(1);
expect(edges[0]!.targetFilePath).toContain('packages/ui/src/a.ts');
expect(getResolutionOutcomes(result).filter((o) => o.kind === 'reexport-ambiguous')).toEqual(
[],
);
});
it('still resolves the non-colliding star names', () => {
const targets = getRelationships(result, 'CALLS')
.filter((e) => e.sourceFilePath.includes('main.ts'))
.map((e) => e.target)
.sort();
expect(targets).toEqual(['collide', 'onlyA', 'onlyB']);
});
});

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

@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './helpers.js';
const base = {
'package.json': '{ "name": "root", "private": true, "workspaces": ["packages/*"] }\n',
'packages/ui/package.json': '{ "name": "@x/ui", "version": "1.0.0", "main": "src/index.ts" }\n',
'packages/ui/src/index.ts': `export * from './components';\n`,
'packages/ui/src/components/index.ts': `export { alpha, beta } from './Impl';\n`,
'packages/ui/src/components/Impl/index.ts': `export * from './Impl';\n`,
'packages/app/package.json':
'{ "name": "@x/app", "version": "1.0.0", "main": "src/main.ts", "dependencies": { "@x/ui": "1.0.0" } }\n',
'packages/app/src/main.ts': `import { alpha, beta } from '@x/ui';\nexport function render() { alpha('a'); beta('b'); }\n`,
};
async function run(name: string, extra: Record<string, string>, remove: string[] = []) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-c2b-${name}-`));
const files: Record<string, string> = { ...base, ...extra };
for (const r of remove) delete files[r];
try {
writeFixtureRepo(dir, files);
const result = await runPipelineFromRepo(dir, () => {});
return getRelationships(result, 'CALLS')
.filter((e) => e.sourceFilePath.includes('packages/app/src/main'))
.map((e) => e.target)
.sort();
} finally {
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
}
describe('C2 probe 2', () => {
it('E: impl is a .tsx file with plain function exports', async () => {
expect(
await run('e', {
'packages/ui/src/components/Impl/Impl.tsx': `export function alpha(s: string) { return s; }\nexport function beta(s: string) { return s; }\n`,
}),
).toEqual(['alpha', 'beta']);
}, 60000);
it('F: impl is .ts with ARROW CONST exports', async () => {
expect(
await run('f', {
'packages/ui/src/components/Impl/Impl.ts': `export const alpha = (s: string) => { return s; };\nexport const beta = (s: string) => { return s; };\n`,
}),
).toEqual(['alpha', 'beta']);
}, 60000);
it('G: impl is .ts with a React import + forwardRef generic const alongside plain fns', async () => {
expect(
await run('g', {
'packages/ui/src/components/Impl/Impl.ts': `import React from 'react';\nexport const Widget = React.forwardRef<HTMLButtonElement, { label: string }>((props, ref) => { return null; });\nexport function alpha(s: string) { return s; }\nexport function beta(s: string) { return s; }\n`,
}),
).toEqual(['alpha', 'beta']);
}, 60000);
it('H: main is .tsx and ALSO uses a JSX element', async () => {
expect(
await run(
'h',
{
'packages/ui/src/components/Impl/Impl.ts': `export function alpha(s: string) { return s; }\nexport function beta(s: string) { return s; }\nexport function Widget(p: { label: string }) { return null; }\n`,
'packages/ui/src/components/index.ts': `export { alpha, beta, Widget } from './Impl';\n`,
'packages/app/src/main.tsx': `import { alpha, beta, Widget } from '@x/ui';\nexport function render() { alpha('a'); beta('b'); return <Widget label="x" />; }\n`,
},
['packages/app/src/main.ts'],
),
).toEqual(['Widget', 'alpha', 'beta']);
}, 60000);
it('B1: a class METHOD sharing a top-level const name never wins the wildcard fan-out', async () => {
// `export *` can only publish module-level declarations. `Foo.render` is
// callable and outranked the value shadow in the callable-preferred index,
// binding `import { render }` to a symbol it can never reach. The safe
// outcome is the pre-existing one: a value shadow yields NO CALLS edge.
expect(
await run('b1', {
'packages/ui/src/components/Impl/Impl.ts': `export const alpha = (s: string) => { return s; };\nexport const beta = 42;\nexport class Foo { beta() { return 1; } }\n`,
}),
).toEqual(['alpha']);
}, 60000);
it('B1 control: the arrow const still wins over its own Variable shadow', async () => {
expect(
await run('b1c', {
'packages/ui/src/components/Impl/Impl.ts': `export const alpha = (s: string) => { return s; };\nexport const beta = (s: string) => { return s; };\nexport class Foo { alpha() { return 1; } }\n`,
}),
).toEqual(['alpha', 'beta']);
}, 60000);
it('I: dir index has wildcard AND a named re-export from a sibling', async () => {
expect(
await run('i', {
'packages/ui/src/components/Impl/index.ts': `export * from './Impl';\nexport { beta } from './Beta';\n`,
'packages/ui/src/components/Impl/Impl.ts': `export function alpha(s: string) { return s; }\n`,
'packages/ui/src/components/Impl/Beta.ts': `export function beta(s: string) { return s; }\n`,
}),
).toEqual(['alpha', 'beta']);
}, 60000);
});

View file

@ -0,0 +1,203 @@
/**
* The workspace-package barrel hop.
*
* A call imported from a workspace package (`@x/ui`) resolves through the
* package's `main`, through three re-export forms, and through a barrel chain
* three levels deep; a call imported through a tsconfig `paths` alias resolves
* too. Those resolutions are pinned here against regression, together with the
* one shape the resolver must REFUSE: two `export *` sources publishing the
* same name (a star-vs-star collision) is recorded as `reexport-ambiguous`
* naming both candidates, and the name appears in the run's census.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import {
getRelationships,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './helpers.js';
import { summarizeNameFallback } from '../../../src/core/ingestion/scope-resolution/name-fallback-summary.js';
describe('workspace-package barrel hop', () => {
let result: PipelineResult;
let repoDir: string | undefined;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ws1c-barrel-'));
writeFixtureRepo(repoDir, {
'package.json': '{ "name": "root", "private": true, "workspaces": ["packages/*"] }\n',
'packages/ui/package.json':
'{ "name": "@x/ui", "version": "1.0.0", "main": "src/index.ts" }\n',
// Three re-export forms in one barrel, plus a nested barrel chain.
'packages/ui/src/index.ts': `export { Button } from './components/Button/Button';
export * from './components/Stack';
export type { ButtonProps } from './components/Button/Button';
export * from './themes';
`,
'packages/ui/src/components/Button/Button.tsx': `export interface ButtonProps { label: string }
export function Button(props: ButtonProps) {
return props.label;
}
`,
'packages/ui/src/components/Stack/index.ts': `export * from './Stack';\n`,
'packages/ui/src/components/Stack/Stack.tsx': `export function Stack(children: string) {
return children;
}
`,
// themes -> hooks -> useStyles2: three barrels deep.
'packages/ui/src/themes/index.ts': `export * from './hooks';\n`,
'packages/ui/src/themes/hooks/index.ts': `export * from './useStyles2';\n`,
'packages/ui/src/themes/hooks/useStyles2.ts': `export function useStyles2(fn: (t: string) => string) {
return fn('theme');
}
`,
'packages/app/package.json':
'{ "name": "@x/app", "version": "1.0.0", "main": "src/main.tsx", "dependencies": { "@x/ui": "1.0.0" } }\n',
'packages/app/tsconfig.json': `{
"compilerOptions": {
"jsx": "react-jsx",
"baseUrl": "src",
"paths": { "app/core/*": ["core/*"] }
}
}
`,
'packages/app/src/core/utils/format.ts': `export function formatTitle(raw: string) {
return raw.trim();
}
`,
// Calls AND JSX through the same imported names.
'packages/app/src/features/Panel.tsx': `import { Button, Stack, useStyles2 } from '@x/ui';
import { formatTitle } from 'app/core/utils/format';
export function Panel() {
const styles = useStyles2((t) => t);
const title = formatTitle(' hi ');
const label = Button({ label: title });
const stacked = Stack(label);
return <Stack><Button label={styles + stacked} /></Stack>;
}
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 180000);
afterAll(() => {
if (repoDir !== undefined) {
fs.rmSync(repoDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
});
it('resolves a call through the package barrel to its real definition file', () => {
const edge = getRelationships(result, 'CALLS').find(
(c) => c.source === 'Panel' && c.target === 'Button',
);
expect(edge?.targetFilePath).toBe('packages/ui/src/components/Button/Button.tsx');
});
it('resolves through an `export *` re-export', () => {
const edge = getRelationships(result, 'CALLS').find(
(c) => c.source === 'Panel' && c.target === 'Stack',
);
expect(edge?.targetFilePath).toBe('packages/ui/src/components/Stack/Stack.tsx');
});
it('resolves through a barrel chain three levels deep', () => {
const edge = getRelationships(result, 'CALLS').find(
(c) => c.source === 'Panel' && c.target === 'useStyles2',
);
expect(edge?.targetFilePath).toBe('packages/ui/src/themes/hooks/useStyles2.ts');
});
it('resolves through a tsconfig `paths` alias', () => {
const edge = getRelationships(result, 'CALLS').find(
(c) => c.source === 'Panel' && c.target === 'formatTitle',
);
expect(edge?.targetFilePath).toBe('packages/app/src/core/utils/format.ts');
});
});
/**
* `export * from './a'; export * from './b'` where both files declare `collide`
* is ambiguous: the language names no winner (ECMAScript excludes the name from
* the module's exports). The resolver used to pick the first-listed source and
* publish the edge as `import-resolved` at 0.85 — a definite target for a call
* that has none, the "incorrect context is worse than missing context" failure
* in its purest form.
*
* Fixed in the shared finalize pass (`collectAmbiguousWildcards`): the name is
* refused in BOTH places it used to win — the barrel's re-export closure and
* the barrel's own wildcard-expanded module scope — and reported through
* `FinalizeStats.ambiguousWildcardExports`, which the pipeline records as a
* `reexport-ambiguous` resolution outcome. The importer stays unresolved —
* a missing edge, never a wrong one.
*/
describe('ambiguous `export *` collision is refused, not guessed', () => {
let result: PipelineResult;
let repoDir: string | undefined;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ws1c-ambig-'));
writeFixtureRepo(repoDir, {
'package.json': '{ "name": "root", "private": true, "workspaces": ["packages/*"] }\n',
'packages/ui/package.json':
'{ "name": "@x/ui", "version": "1.0.0", "main": "src/index.ts" }\n',
'packages/ui/src/index.ts': `export * from './a';\nexport * from './b';\nexport * from './c';\n`,
'packages/ui/src/a.ts': `export function collide() { return 'a'; }\nexport function onlyA() { return 1; }\n`,
'packages/ui/src/b.ts': `export function collide() { return 'b'; }\n`,
'packages/ui/src/c.ts': `export function onlyC() { return 3; }\n`,
'packages/app/package.json':
'{ "name": "@x/app", "version": "1.0.0", "main": "src/m.ts", "dependencies": { "@x/ui": "1.0.0" } }\n',
'packages/app/src/m.ts': `import { collide, onlyA, onlyC } from '@x/ui';
export function useIt() { onlyA(); onlyC(); return collide(); }
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 180000);
afterAll(() => {
if (repoDir !== undefined) {
fs.rmSync(repoDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
});
it('emits NO CALLS edge for the colliding name', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'useIt' && c.target === 'collide',
);
expect(edges).toEqual([]);
});
it('still resolves the names that do NOT collide through the same barrel', () => {
const targets = getRelationships(result, 'CALLS')
.filter((c) => c.source === 'useIt')
.map((c) => c.target)
.sort();
expect(targets).toEqual(['onlyA', 'onlyC']);
});
it('records the refusal as a `reexport-ambiguous` outcome naming both candidates', () => {
const refused = result.resolutionOutcomes.filter(
(o) => o.kind === 'reexport-ambiguous' && o.name === 'collide',
);
expect(refused).toHaveLength(1);
const [outcome] = refused;
expect(outcome!.kind === 'reexport-ambiguous' && outcome!.filePath).toBe(
'packages/ui/src/index.ts',
);
expect(outcome!.kind === 'reexport-ambiguous' ? outcome!.candidateIds.length : 0).toBe(2);
});
// The census persists the refused barrel name itself, not just a count. This
// is the real star-vs-star collision the pipeline produced (not a hand-built
// `ResolutionOutcome`), closing the loop from the `reexport-ambiguous`
// outcome through to the persisted name list.
it('the barrel census names the refused collision in `ambiguousReexportNames`', () => {
const summary = summarizeNameFallback(result.resolutionOutcomes);
expect(summary?.totalAmbiguousReexports).toBe(1);
expect(summary?.ambiguousReexportNames).toEqual(['packages/ui/src/index.ts:collide']);
});
});

View file

@ -0,0 +1,145 @@
/**
* Go — external test packages (`package foo_test`) through the REAL pipeline
* (tree-sitter extraction + import resolution + `populateGoPackageSiblings`),
* not just the isolated `populateGoPackageSiblings` unit in
* `test/unit/scope-resolution/go/go-test-file-siblings.test.ts`.
*
* The fix (`gitnexus/src/core/ingestion/languages/go/package-siblings.ts`):
* an external test package (`package foo_test`, e.g. `a_ext_test.go`) no
* longer gets BARE-name sibling bindings from `foo` at all — Go itself
* requires `foo.NewThing`, not `NewThing()`, inside `package foo_test`. The
* QUALIFIED form still resolves — it was never routed through
* `populateGoPackageSiblings` in the first place, it goes through the
* ordinary import resolver (`import "…/pkg"` + a member call), which this
* fix does not touch.
*
* Also pins: an INTERNAL test file (`package foo`, e.g. `a_test.go`) keeps
* its bare-name sibling bindings (unchanged).
*
* The same boundary is enforced on the heuristic channel too: the Go
* name-fallback hook classifies files by package (internal test / external
* `foo_test` / non-test), not by directory, so neither binding gets even a
* 0.5-confidence `global-name-fallback` edge (asserted at the bottom).
* `populateGoPackageSiblings` (touched by this diff) correctly refuses it on
* its own channel, but Go's separate `global-name-fallback` heuristic
* (`goIsGlobalNameFallbackPlausible`, untouched by either commit under test)
* treats "same directory" as always plausible, independent of the Go
* package/test boundary, and reopens both this case and the external-test
* bare-call case at 0.5 confidence.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { getRelationships, writeFixtureRepo, type PipelineResult } from './helpers.js';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
describe('Go external vs internal test packages — qualified vs bare NewThing (real pipeline)', () => {
let result: PipelineResult;
let dir: string;
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-go-exttest-'));
writeFixtureRepo(dir, {
'go.mod': 'module example.com/extpkg\n\ngo 1.21\n',
'pkg/a.go': [
'package a',
'',
'func NewThing() int {',
'\treturn 1',
'}',
'',
'func UsesTestHelper() int {',
'\treturn onlyInInternalTest()',
'}',
'',
].join('\n'),
// Internal test: same package (`a`). Bare `NewThing()` and a
// test-only declaration other internal-test files can see.
'pkg/a_test.go': [
'package a',
'',
'func onlyInInternalTest() int {',
'\treturn 2',
'}',
'',
'func CallBareFromInternalTest() int {',
'\treturn NewThing()',
'}',
'',
].join('\n'),
// External test: `package a_test`, a DIFFERENT package that must
// import `pkg` explicitly to reach it — exactly like any other
// consumer of the package.
'pkg/a_ext_test.go': [
'package a_test',
'',
'import "example.com/extpkg/pkg"',
'',
'func CallQualifiedFromExternalTest() int {',
'\treturn pkg.NewThing()',
'}',
'',
'func CallBareFromExternalTest() int {',
'\treturn NewThing()',
'}',
'',
].join('\n'),
});
result = await runPipelineFromRepo(dir, () => {});
}, 60000);
it('an internal test file (still package `a`) resolves the bare call — unchanged behavior', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.source === 'CallBareFromInternalTest',
);
expect(edges.map((e) => e.target)).toEqual(['NewThing']);
// Confident — no heuristic-fallback reason on this edge.
expect(edges[0]!.rel.reason).not.toBe('global-name-fallback');
});
it('an external test package resolves the QUALIFIED call (pkg.NewThing) through the ordinary import resolver', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.source === 'CallQualifiedFromExternalTest',
);
expect(edges.map((e) => e.target)).toEqual(['NewThing']);
expect(edges[0]!.rel.reason).not.toBe('global-name-fallback');
});
it("an external test package does NOT get a CONFIDENT bare-name edge from `foo`'s package-sibling channel", () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.source === 'CallBareFromExternalTest',
);
const toNewThing = edges.filter((e) => e.target === 'NewThing');
// No binding at all: the confident package-sibling channel refuses the
// cross-package bare name, and the name-fallback hook refuses it too.
expect(toNewThing).toEqual([]);
});
it('a non-test file gets NO edge to a test-only declaration — confident or heuristic', () => {
const edges = getRelationships(result, 'CALLS').filter((e) => e.source === 'UsesTestHelper');
const toHelper = edges.filter((e) => e.target === 'onlyInInternalTest');
expect(toHelper).toEqual([]);
});
/**
* Regression guard for the name-fallback channel. `goIsGlobalNameFallbackPlausible`
* once treated "same directory" as "same package"; a directory can hold three Go
* packages at once (`foo`, external `foo_test`, and `foo`'s own `_test.go` files),
* so both bindings below used to come back as 0.5-confidence `global-name-fallback`
* edges. The hook now classifies caller and candidate by package (via the package
* clause when sources are available) and refuses non-test → test-only and bare
* cross-package names outright. The two tests below pin "no edge at all".
*/
it('a non-test file calling a test-only helper by bare name should get NO edge at all, not even a heuristic one', () => {
const edges = getRelationships(result, 'CALLS').filter((e) => e.source === 'UsesTestHelper');
expect(edges.map((e) => e.target)).not.toContain('onlyInInternalTest');
});
it("an external test package's bare NewThing() should get NO edge at all — Go rejects the call outright, so no confidence tier should bind it", () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.source === 'CallBareFromExternalTest',
);
expect(edges.map((e) => e.target)).not.toContain('NewThing');
});
});

View file

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

View file

@ -0,0 +1,84 @@
/**
* `runFullAnalysis` must invalidate the per-process workspace-package memo
* BEFORE it takes the index lock, so a long-lived watch/server process never
* resolves the second analyze's imports against the first analyze's package
* map (a changed `package.json`, a new workspace member, or a moved entry point
* would otherwise bind to the old file — a confident wrong edge).
*
* Delegating mocks: the real implementations run; the spies only record order.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { execSync } from 'child_process';
import { promises as fs } from 'node:fs';
import path from 'node:path';
type Workspace =
typeof import('../../src/core/ingestion/import-resolvers/node-workspace-packages.js');
type Lock = typeof import('../../src/storage/index-lock.js');
const ctx = vi.hoisted(() => ({
invalidate: vi.fn(),
acquire: vi.fn(),
}));
vi.mock(
'../../src/core/ingestion/import-resolvers/node-workspace-packages.js',
async (importOriginal) => {
const actual = await importOriginal<Workspace>();
ctx.invalidate.mockImplementation(actual.invalidateNodeWorkspacePackages);
return { ...actual, invalidateNodeWorkspacePackages: ctx.invalidate };
},
);
vi.mock('../../src/storage/index-lock.js', async (importOriginal) => {
const actual = await importOriginal<Lock>();
ctx.acquire.mockImplementation(actual.acquireIndexLock);
return { ...actual, acquireIndexLock: ctx.acquire };
});
import { runFullAnalysis } from '../../src/core/run-analyze.js';
import { createTempDir } from '../helpers/test-db.js';
describe('runFullAnalysis invalidates the workspace-package memo before the lock', () => {
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
let savedHome: string | undefined;
beforeEach(async () => {
tmpHome = await createTempDir('gn-ws-memo-home-');
savedHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
ctx.invalidate.mockClear();
ctx.acquire.mockClear();
});
afterEach(async () => {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await tmpHome.cleanup();
});
it('calls invalidateNodeWorkspacePackages(repoPath) and does so before acquireIndexLock', async () => {
const tmp = await createTempDir('gn-ws-memo-repo-');
const repo = tmp.dbPath;
try {
execSync('git init', { cwd: repo, stdio: 'pipe' });
await fs.writeFile(
path.join(repo, 'a.ts'),
'export function greet(n: string) { return `hi ${n}`; }\nexport function caller() { return greet("x"); }\n',
);
execSync('git add -A && git -c user.name=t -c user.email=t@t commit -m init', {
cwd: repo,
stdio: 'pipe',
});
await runFullAnalysis(repo, {}, { onProgress: () => {} });
expect(ctx.invalidate).toHaveBeenCalled();
const invalidateArgs = ctx.invalidate.mock.calls[0]!;
expect(invalidateArgs[0]).toBe(repo);
expect(ctx.acquire).toHaveBeenCalled();
const firstInvalidate = ctx.invalidate.mock.invocationCallOrder[0]!;
const firstAcquire = ctx.acquire.mock.invocationCallOrder[0]!;
expect(firstInvalidate).toBeLessThan(firstAcquire);
} finally {
await tmp.cleanup();
}
}, 120_000);
});

View file

@ -0,0 +1,157 @@
/**
* A CALLS edge whose target was GUESSED from a unique name must never seed or
* extend a process trace, and must never join two nodes into a community on a
* large graph.
*
* The reason this needs its own test rather than resting on the confidence
* floor: guessed edges are emitted at exactly 0.5, which is the value of both
* `process-processor`'s `MIN_TRACE_CONFIDENCE` and `community-processor`'s
* `MIN_CONFIDENCE_LARGE`. Those gates were written as `confidence < THRESHOLD`,
* so 0.5 passes them. Anything relying on "low confidence is filtered out"
* would silently admit every guess, which is how a name collision turns into a
* confident-looking execution flow through code that never calls itself.
*
* The control arm is what makes each assertion mean something: the SAME topology
* with a resolved reason must still produce the flow, so a passing test cannot
* be explained by the graph being unusable.
*/
import { describe, it, expect } from 'vitest';
import { processProcesses } from '../../src/core/ingestion/process-processor.js';
import { buildCommunityProjection } from '../../src/core/ingestion/community-processor.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { KnowledgeGraph } from '../../src/core/graph/types.js';
import { GLOBAL_NAME_FALLBACK_REASON } from '../../src/core/graph/edge-reasons.js';
const addFunction = (graph: KnowledgeGraph, name: string, filePath: string): string => {
const id = `func:${name}`;
graph.addNode({
id,
label: 'Function',
properties: { name, filePath, startLine: 1, endLine: 10, isExported: true },
});
return id;
};
const addCall = (
graph: KnowledgeGraph,
sourceId: string,
targetId: string,
reason: string,
): void => {
graph.addRelationship({
id: `rel:CALLS:${sourceId}->${targetId}`,
sourceId,
targetId,
type: 'CALLS',
// The exact value guessed edges are emitted at, on purpose: a test using
// 0.3 would pass against a pure confidence gate and prove nothing.
confidence: 0.5,
reason,
});
};
/** A 3-node chain whose every CALLS edge carries `reason`. */
const chainGraph = (reason: string): KnowledgeGraph => {
const graph = createKnowledgeGraph();
const handle = addFunction(graph, 'handleRequest', 'src/handler.ts');
const validate = addFunction(graph, 'validateInput', 'src/validate.ts');
const persist = addFunction(graph, 'persistRecord', 'src/store.ts');
addCall(graph, handle, validate, reason);
addCall(graph, validate, persist, reason);
return graph;
};
describe('process tracing excludes name-guessed CALLS edges', () => {
it('traces a flow when the chain is resolved (control)', async () => {
const result = await processProcesses(chainGraph('import-resolved'), []);
expect(result.processes.length).toBeGreaterThan(0);
});
it('traces NO flow when the identical chain is a unique-name guess', async () => {
const result = await processProcesses(chainGraph(GLOBAL_NAME_FALLBACK_REASON), []);
expect(result.processes).toHaveLength(0);
expect(result.steps).toHaveLength(0);
});
it('keeps a resolved chain intact when a guessed edge branches off it', async () => {
// The guess must be dropped without taking the real flow with it.
const graph = chainGraph('import-resolved');
const stray = addFunction(graph, 'unrelatedHelper', 'vendor/other.ts');
addCall(graph, 'func:validateInput', stray, GLOBAL_NAME_FALLBACK_REASON);
const result = await processProcesses(graph, []);
expect(result.processes.length).toBeGreaterThan(0);
const tracedNames = new Set(result.steps.map((step) => step.toName));
expect(tracedNames.has('unrelatedHelper')).toBe(false);
});
});
describe('large-graph community projection excludes name-guessed CALLS edges', () => {
/**
* Build a graph over the 10,000-symbol line that makes a projection "large",
* with one CALLS edge of `reason` joining `filler0` to `filler1`.
*
* Both endpoints are anchored to two shared hubs by RESOLVED edges. That is
* load-bearing rather than scaffolding: a large projection drops degree-1
* nodes, so anchoring only once would make the guessed edge's removal prune
* its endpoints too and the edge count would collapse for a second reason.
* With the anchors, the eligible node set is identical in both arms and the
* only difference in the projection is the edge under test.
*/
const largeGraphWithOneCall = (reason: string): KnowledgeGraph => {
const graph = createKnowledgeGraph();
for (let i = 0; i < 10_001; i++) addFunction(graph, `filler${i}`, `src/f${i}.ts`);
const hubA = addFunction(graph, 'hubA', 'src/hubA.ts');
const hubB = addFunction(graph, 'hubB', 'src/hubB.ts');
for (const endpoint of ['func:filler0', 'func:filler1']) {
addCall(graph, endpoint, hubA, 'import-resolved');
addCall(graph, endpoint, hubB, 'import-resolved');
}
addCall(graph, 'func:filler0', 'func:filler1', reason);
return graph;
};
it('projects the joining edge when it is resolved (control)', () => {
const projection = buildCommunityProjection(largeGraphWithOneCall('import-resolved'));
expect(projection.edges).toHaveLength(5);
});
it('omits the joining edge when it is a unique-name guess', () => {
const projection = buildCommunityProjection(largeGraphWithOneCall(GLOBAL_NAME_FALLBACK_REASON));
expect(projection.edges).toHaveLength(4);
// Same node set in both arms — the difference really is the one edge.
expect(projection.nodes).toHaveLength(4);
});
});
describe('SMALL-graph community projection excludes name-guessed CALLS edges too', () => {
// The exclusion is about what the edge claims, not about graph size: a
// 3-symbol repository must not cluster two functions on a name guess either.
const smallGraphWithOneCall = (reason: string): KnowledgeGraph => {
const graph = createKnowledgeGraph();
const a = addFunction(graph, 'alpha', 'src/a.ts');
const b = addFunction(graph, 'beta', 'src/b.ts');
const c = addFunction(graph, 'gamma', 'src/c.ts');
addCall(graph, a, b, 'import-resolved');
addCall(graph, b, c, reason);
return graph;
};
it('projects the edge when it is resolved (control)', () => {
const projection = buildCommunityProjection(smallGraphWithOneCall('import-resolved'));
expect(projection.isLarge).toBe(false);
expect(projection.edges).toHaveLength(2);
expect(projection.nodes).toHaveLength(3);
});
it('omits the edge when it is a unique-name guess', () => {
const projection = buildCommunityProjection(smallGraphWithOneCall(GLOBAL_NAME_FALLBACK_REASON));
expect(projection.isLarge).toBe(false);
expect(projection.edges).toHaveLength(1);
// The guessed edge's far endpoint no longer touches any clustering edge, so
// it is not projected — a small graph keeps degree-1 nodes, but not
// degree-0 ones.
expect(projection.nodes).toHaveLength(2);
});
});

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,258 @@
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,
resolveNodeWorkspaceImport,
} from '../../src/core/ingestion/import-resolvers/node-workspace-packages.js';
/**
* C3 — a workspace declared BELOW the repo root (keycloak: `js/pnpm-workspace.yaml`).
* C4 — a package whose `main`/`exports` name build output; the source entry is
* discovered from `source`, `publishConfig.source`, or vite `lib.entry`.
*/
describe('nested workspace roots and non-dist entry discovery', () => {
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-c3c4-'));
// Root has NO workspace declaration (a Java repo with a JS subtree).
w('pom.xml', '<project/>');
w('js/pnpm-workspace.yaml', 'packages:\n - "libs/*"\n - "apps/*"\n');
// C4: dist main + vite lib entry → discover src/main.ts
w(
'js/libs/ui-shared/package.json',
JSON.stringify({
name: '@keycloak/keycloak-ui-shared',
main: './dist/keycloak-ui-shared.js',
module: './dist/keycloak-ui-shared.js',
types: './dist/keycloak-ui-shared.d.ts',
}),
);
w(
'js/libs/ui-shared/vite.config.ts',
`export default defineConfig({ build: { lib: { entry: 'src/main.ts', formats: ['es'] } } });\n`,
);
w('js/libs/ui-shared/src/main.ts', 'export const x = 1;\n');
// `source` field wins when present.
w(
'js/libs/with-source/package.json',
JSON.stringify({ name: '@acme/with-source', main: 'dist/index.js', source: 'src/entry.ts' }),
);
w('js/libs/with-source/src/entry.ts', 'export const y = 1;\n');
// Ambiguous: `source` and vite entry name DIFFERENT existing files → refuse both.
w(
'js/libs/ambiguous/package.json',
JSON.stringify({ name: '@acme/ambiguous', main: 'dist/index.js', source: 'src/a.ts' }),
);
w(
'js/libs/ambiguous/vite.config.ts',
`export default { build: { lib: { entry: 'src/b.ts' } } };\n`,
);
w('js/libs/ambiguous/src/a.ts', 'export const a = 1;\n');
w('js/libs/ambiguous/src/b.ts', 'export const b = 1;\n');
// A source `main` is left alone — discovery never runs.
w(
'js/apps/admin/package.json',
JSON.stringify({ name: '@keycloak/admin', main: 'src/index.tsx' }),
);
w('js/apps/admin/src/index.tsx', 'export const z = 1;\n');
// A manifest OUTSIDE the declared workspace is not admitted.
w(
'js/examples/demo/package.json',
JSON.stringify({ name: '@keycloak/demo', main: 'src/index.ts' }),
);
// `source` declared but the file does NOT exist on disk; the vite lib
// entry does. Discovery must fall through to it rather than treating the
// dangling `source` as a second, disagreeing candidate.
w(
'js/libs/source-missing/package.json',
JSON.stringify({
name: '@acme/source-missing',
main: 'dist/index.js',
source: 'src/does-not-exist.ts',
}),
);
w(
'js/libs/source-missing/vite.config.ts',
`export default { build: { lib: { entry: 'src/real.ts' } } };\n`,
);
w('js/libs/source-missing/src/real.ts', 'export const real = 1;\n');
// `source` and `publishConfig.source` name the SAME existing file — two
// candidate strings, one real target. Must NOT read as ambiguous.
w(
'js/libs/dup-source/package.json',
JSON.stringify({
name: '@acme/dup-source',
main: 'dist/index.js',
source: 'src/shared.ts',
publishConfig: { source: 'src/shared.ts' },
}),
);
w('js/libs/dup-source/src/shared.ts', 'export const shared = 1;\n');
// Nothing declared, nothing conventional but BOTH `src/main` and
// `src/index` exist — the fallback tier has no priority order of its
// own, so two existing conventional candidates are ambiguous too.
w(
'js/libs/both-conventional/package.json',
JSON.stringify({ name: '@acme/both-conventional' }),
);
w('js/libs/both-conventional/src/main.ts', 'export const m = 1;\n');
w('js/libs/both-conventional/src/index.ts', 'export const i = 1;\n');
});
afterAll(() => fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }));
it('admits packages declared by a nested pnpm-workspace.yaml', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs).not.toBeNull();
expect([...pkgs!.byName.keys()].sort()).toEqual([
'@acme/ambiguous',
'@acme/both-conventional',
'@acme/dup-source',
'@acme/source-missing',
'@acme/with-source',
'@keycloak/admin',
'@keycloak/keycloak-ui-shared',
]);
expect(pkgs!.byName.has('@keycloak/demo')).toBe(false);
});
it('discovers the vite lib entry when main points at dist', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('@keycloak/keycloak-ui-shared')!.entries).toContain(
'js/libs/ui-shared/src/main',
);
});
it('honours `source` when main points at dist', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('@acme/with-source')!.entries).toContain(
'js/libs/with-source/src/entry',
);
});
it('refuses when two discovered candidates disagree', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
const entries = pkgs!.byName.get('@acme/ambiguous')!.entries;
expect(entries).not.toContain('js/libs/ambiguous/src/a');
expect(entries).not.toContain('js/libs/ambiguous/src/b');
});
it('leaves a source `main` untouched', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('@keycloak/admin')!.entries[0]).toBe('js/apps/admin/src/index');
});
it('falls through to the vite entry when `source` names a file that does not exist on disk', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
const entries = pkgs!.byName.get('@acme/source-missing')!.entries;
expect(entries).toContain('js/libs/source-missing/src/real');
expect(entries).not.toContain('js/libs/source-missing/src/does-not-exist');
});
it('does not treat `source` and `publishConfig.source` naming the SAME file as ambiguous', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
const entries = pkgs!.byName.get('@acme/dup-source')!.entries;
expect(entries).toContain('js/libs/dup-source/src/shared');
});
// Declared entries keep precedence over a discovered one (per the code
// comment: discovered entries are ONLY appended). This is the resolution-
// time consequence of that ordering, not just an entries-array shape check:
// if the declared `dist/*.js` happens to exist among the indexed files
// (e.g. a repo that does not gitignore build output), it is still what a
// bare `import '@keycloak/keycloak-ui-shared'` resolves to — the
// discovered `src/main.ts` entry is only reached when the dist file is
// NOT among the indexed files, which is the common case.
it('a declared dist entry that exists among the indexed files is still used over the discovered source entry', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
const allFilesWithDist = new Set([
'js/libs/ui-shared/dist/keycloak-ui-shared.js',
'js/libs/ui-shared/src/main.ts',
]);
expect(resolveNodeWorkspaceImport('@keycloak/keycloak-ui-shared', pkgs, allFilesWithDist)).toBe(
'js/libs/ui-shared/dist/keycloak-ui-shared.js',
);
// Without the dist file indexed (the common case — build output is not
// checked in), resolution falls through to the discovered source entry.
const allFilesSourceOnly = new Set(['js/libs/ui-shared/src/main.ts']);
expect(
resolveNodeWorkspaceImport('@keycloak/keycloak-ui-shared', pkgs, allFilesSourceOnly),
).toBe('js/libs/ui-shared/src/main.ts');
});
// `discoverSourceEntries`'s ambiguity refusal only governs ITS OWN
// candidates (`source` / `publishConfig.source` / vite / its own
// `src/main`-then-`src/index` fallback). It does NOT reach the older,
// separate unconditional `src/index` fallback `readManifest` already adds
// for every package with no `exports` map — so when nothing is declared
// and BOTH `src/main.ts` and `src/index.ts` exist, discovery contributes
// NOTHING (refused as ambiguous, `src/main` never appears), but
// `src/index` still ends up in `entries` anyway, through the unrelated
// unconditional path. Documented here because it means the "no binding on
// ambiguity" guarantee is a discovery-local property, not a package-wide one.
it('an ambiguous discovery still leaves the unconditional src/index fallback standing', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
const entries = pkgs!.byName.get('@acme/both-conventional')!.entries;
expect(entries).toContain('js/libs/both-conventional/src/index');
expect(entries).not.toContain('js/libs/both-conventional/src/main');
});
});
describe('workspace root discovery depth cap (WORKSPACE_ROOT_MAX_DEPTH = 4)', () => {
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);
};
afterAll(() => fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }));
it('finds a workspace root exactly at depth 4, but not one at depth 5', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-c3-depth-'));
// No root workspace declaration.
w('README.md', '# root\n');
// depth 4: a/b/c/d/pnpm-workspace.yaml (d is the 4th directory level).
w('a/b/c/d/pnpm-workspace.yaml', 'packages:\n - "pkgs/*"\n');
w('a/b/c/d/pkgs/at-depth-4/package.json', JSON.stringify({ name: '@depth/four' }));
w('a/b/c/d/pkgs/at-depth-4/index.ts', 'export const x = 1;\n');
// depth 5: a/b/c/d/e/pnpm-workspace.yaml — one level too deep to be found.
w('a/b/c/d/e/pnpm-workspace.yaml', 'packages:\n - "pkgs/*"\n');
w('a/b/c/d/e/pkgs/at-depth-5/package.json', JSON.stringify({ name: '@depth/five' }));
w('a/b/c/d/e/pkgs/at-depth-5/index.ts', 'export const y = 1;\n');
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs).not.toBeNull();
expect(pkgs!.byName.has('@depth/four')).toBe(true);
expect(pkgs!.byName.has('@depth/five')).toBe(false);
});
});
describe('a nested `package.json` "workspaces" field (not pnpm-workspace.yaml) is also found as a root', () => {
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);
};
afterAll(() => fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }));
it('admits packages declared by a nested package.json "workspaces" array', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-c3-pkgjson-root-'));
w('go.mod', 'module example.com/root\n');
// Nested JS workspace root declared via `package.json`'s `workspaces`
// field rather than a pnpm-workspace.yaml — the OTHER of the three
// spellings `readWorkspacePatternsAt` merges, exercised here at a non-root directory.
w(
'frontend/package.json',
JSON.stringify({ name: 'frontend-root', private: true, workspaces: ['packages/*'] }),
);
w('frontend/packages/ui/package.json', JSON.stringify({ name: '@fe/ui' }));
w('frontend/packages/ui/index.ts', 'export const x = 1;\n');
// Outside the nested workspace's own pattern scope — must not be admitted.
w('frontend/other/package.json', JSON.stringify({ name: '@fe/other' }));
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs).not.toBeNull();
expect(pkgs!.byName.has('@fe/ui')).toBe(true);
expect(pkgs!.byName.has('@fe/other')).toBe(false);
});
});

View file

@ -0,0 +1,134 @@
/**
* Regression — entry discovery must not share mutable state across concurrent
* `loadNodeWorkspacePackages` calls. A module-level repo-root singleton once let
* a second repo's scan interleave with the first's `stemExists` checks, so a
* package with two real candidate source entries (which must be REFUSED as
* ambiguous) was adopted with a single confident, wrong winner. The repo root is
* now threaded explicitly; both candidates must be refused under interleaving.
*/
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
const raceCtx = vi.hoisted(() => ({
reachedResolve: null as (() => void) | null,
reached: null as Promise<void> | null,
releaseGate: null as (() => void) | null,
gate: null as Promise<void> | null,
}));
raceCtx.reached = new Promise<void>((resolve) => {
raceCtx.reachedResolve = resolve;
});
raceCtx.gate = new Promise<void>((resolve) => {
raceCtx.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 === 'stat') {
return async (p: string) => {
// Gate ONLY the stat check for repo A's SECOND package's real
// source file — everything else (including repo B's entire
// scan, and repo A's first package) proceeds unmodified.
if (String(p).includes('pkg2-sentinel')) {
raceCtx.reachedResolve!();
await raceCtx.gate;
}
return (target as typeof import('fs/promises')).stat(p as unknown as never);
};
}
const v = Reflect.get(target, prop, target) as unknown;
return typeof v === 'function' ? (v as (...args: unknown[]) => unknown).bind(target) : v;
},
}),
};
});
describe('node-workspace-packages: entry discovery is safe under concurrent scans (regression for a former shared-global (race)', () => {
let repoA: string;
let repoB: string;
const w = (root: string, p: string, s: string) => {
fs.mkdirSync(path.dirname(path.join(root, p)), { recursive: true });
fs.writeFileSync(path.join(root, p), s);
};
beforeAll(() => {
repoA = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-race-a-'));
repoB = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-race-b-'));
// Repo A: two packages needing entry discovery, package1 SHALLOWER than
// package2 so the BFS visits package1 first (queue is layer-ordered).
w(repoA, 'package.json', JSON.stringify({ name: 'root-a', private: true, workspaces: ['**'] }));
w(
repoA,
'pkg1/package.json',
JSON.stringify({ name: '@race/pkg1', main: 'dist/index.js', source: 'src/real1.ts' }),
);
w(repoA, 'pkg1/src/real1.ts', 'export const one = 1;\n');
// pkg2 declares TWO candidate source fields, both real files. Candidate 1
// (`source`) is checked first — its `stemExists` call is the one whose
// STAT gets gated, but its `abs` path was already computed (correctly,
// against repo A) before the gate blocks the underlying `fs.stat`, so it
// still resolves correctly once released. Candidate 2 (`publishConfig.
// source`) is checked SECOND, in a separate `stemExists` call whose
// `abs` is computed fresh AFTER repo B's scan has already clobbered the
// shared global — that is the call the race corrupts.
w(
repoA,
'pkg1/pkg2-sentinel/package.json',
JSON.stringify({
name: '@race/pkg2',
main: 'dist/index.js',
source: 'src/real2a.ts',
publishConfig: { source: 'src/real2b.ts' },
}),
);
w(repoA, 'pkg1/pkg2-sentinel/src/real2a.ts', 'export const twoA = 1;\n');
w(repoA, 'pkg1/pkg2-sentinel/src/real2b.ts', 'export const twoB = 2;\n');
// Repo B: a single trivial package needing NO discovery at all — its
// scan completes purely on the strength of a declared source `main`,
// (the former shared repo-root global would have been clobbered here).
w(repoB, 'package.json', JSON.stringify({ name: 'root-b', private: true, workspaces: ['*'] }));
w(repoB, 'lib/package.json', JSON.stringify({ name: '@other/lib', main: 'src/index.ts' }));
w(repoB, 'lib/src/index.ts', 'export const b = 1;\n');
});
afterAll(() => {
fs.rmSync(repoA, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
fs.rmSync(repoB, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
it("repo B finishing mid-scan does not corrupt repo A's in-flight source-entry discovery", async () => {
const { loadNodeWorkspacePackages } =
await import('../../src/core/ingestion/import-resolvers/node-workspace-packages.js');
const pA = loadNodeWorkspacePackages(repoA);
// Wait until A is blocked right before checking pkg2's real source file.
await raceCtx.reached;
// Run B to completion WHILE A is gated — this is the clobber.
await loadNodeWorkspacePackages(repoB);
raceCtx.releaseGate!();
const pkgsA = await pA;
// pkg1 (checked before the clobber) is unaffected.
expect(pkgsA!.byName.get('@race/pkg1')!.entries).toContain('pkg1/src/real1');
// pkg2 declares TWO real, DIFFERENT source candidates — the correct
// behavior is REFUSAL (both exist, genuinely ambiguous, per the same
// "ambiguous when >1 exists" rule the C4 fixture in
// node-workspace-nested-roots.test.ts pins), so NEITHER should be
// adopted.
//
// With the root threaded per call, candidate 2's existence check uses ITS repo root,
// both candidates are found, and the package is refused as ambiguous.
const pkg2Entries = pkgsA!.byName.get('@race/pkg2')!.entries;
expect(pkg2Entries).not.toContain('pkg1/pkg2-sentinel/src/real2a');
expect(pkg2Entries).not.toContain('pkg1/pkg2-sentinel/src/real2b');
});
});

View file

@ -0,0 +1,330 @@
/**
* Final-review fixes on workspace-package discovery:
* - B2: an `exports` map with no `"."` (rootless) refuses the bare specifier —
* discovery must not manufacture a `src/index` root entry for it.
* - M6: nested workspace roots are gated by the outer scope; an outer
* `!exclusion` keeps binding under a nested root; starter/fixture roots
* (`examples/`, `fixtures/`, `templates/`, `samples/`) are never roots.
* - M9: the package map is memoised per repo root within a process and can be
* invalidated explicitly.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
loadNodeWorkspacePackages,
invalidateNodeWorkspacePackages,
resolveNodeWorkspaceImport,
} from '../../src/core/ingestion/import-resolvers/node-workspace-packages.js';
import { _captureLogger } from '../../src/core/logger.js';
function mkRepo(prefix: string) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const w = (p: string, s: string) => {
fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true });
fs.writeFileSync(path.join(dir, p), s);
};
return { dir, w };
}
const rm = (dir: string) =>
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
describe('B2 — rootless `exports` refuses the bare specifier', () => {
let dir: string;
beforeAll(() => {
const r = mkRepo('gn-b2-');
dir = r.dir;
r.w('package.json', JSON.stringify({ name: 'root', workspaces: ['packages/*'] }));
// exports with only a subpath, no main: bare `@repo/subonly` does not resolve in Node.
r.w(
'packages/subonly/package.json',
JSON.stringify({ name: '@repo/subonly', exports: { './feature': './src/feature.ts' } }),
);
r.w('packages/subonly/src/feature.ts', 'export const f = 1;\n');
r.w('packages/subonly/src/index.ts', 'export const trap = 1;\n');
// exports subpath-only PLUS a build-output main: Node ignores `main` when
// `exports` exists, so the root is still refused.
r.w(
'packages/submain/package.json',
JSON.stringify({
name: '@repo/submain',
main: './dist/index.js',
exports: { './feature': './src/feature.ts' },
}),
);
r.w('packages/submain/src/feature.ts', 'export const f = 1;\n');
r.w('packages/submain/src/index.ts', 'export const trap = 1;\n');
// exports WITH a root: resolves as before.
r.w(
'packages/rooted/package.json',
JSON.stringify({ name: '@repo/rooted', exports: { '.': './src/index.ts' } }),
);
r.w('packages/rooted/src/index.ts', 'export const ok = 1;\n');
// exports as a bare STRING — Node's shorthand for `{".": "<string>"}`. The
// walker's `currentSubpath === ''` default treats a top-level string as the
// root export directly.
r.w(
'packages/stringform/package.json',
JSON.stringify({ name: '@repo/stringform', exports: './src/index.ts' }),
);
r.w('packages/stringform/src/index.ts', 'export const ok = 1;\n');
// exports declaring ONLY a subpath PATTERN (`"./*"`), no `"."` at all. Same
// rootless rule as `subonly` — the pattern populates `subpathExports`, the
// bare specifier still refuses.
r.w(
'packages/patternonly/package.json',
JSON.stringify({ name: '@repo/patternonly', exports: { './*': './src/*.ts' } }),
);
r.w('packages/patternonly/src/anything.ts', 'export const a = 1;\n');
r.w('packages/patternonly/src/index.ts', 'export const trap = 1;\n');
});
afterAll(() => rm(dir));
it('a subpath-only exports map yields no root entry (no src/index edge)', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs).not.toBeNull();
expect(pkgs!.byName.get('@repo/subonly')?.entries).toEqual([]);
expect(pkgs!.byName.get('@repo/submain')?.entries).toEqual([]);
// The subpath the map DOES declare still resolves.
// Entries are extension-less stems.
expect(pkgs!.byName.get('@repo/subonly')?.subpathExports.get('feature')).toEqual([
'packages/subonly/src/feature',
]);
});
it('a `"."` export still resolves the root', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('@repo/rooted')?.entries).toEqual(['packages/rooted/src/index']);
});
it('a string-form `exports` (Node shorthand for `{".": "..."}`) resolves the root', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('@repo/stringform')?.entries).toEqual([
'packages/stringform/src/index',
]);
});
it('a pattern-only `exports` (`"./*"`, no `"."`) does NOT fabricate a root — bare specifier still refuses', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
const pkg = pkgs!.byName.get('@repo/patternonly');
expect(pkg).toBeDefined();
// No root entry manufactured — this is the same rootless refusal as `subonly`.
expect(pkg!.entries).toEqual([]);
// The pattern itself IS recorded (so `@repo/patternonly/anything` still
// resolves) — the refusal is scoped to the bare specifier only.
expect(pkg!.subpathExports.get('*')).toEqual(['packages/patternonly/src/*']);
expect(
resolveNodeWorkspaceImport(
'@repo/patternonly/anything',
pkgs,
new Set(['packages/patternonly/src/anything.ts']),
),
).toBe('packages/patternonly/src/anything.ts');
});
it('none of the rootless refusals are recorded as ambiguous — a refusal is silent, not a warning', async () => {
const cap = _captureLogger();
try {
invalidateNodeWorkspacePackages(dir);
await loadNodeWorkspacePackages(dir);
} finally {
cap.restore();
}
const text = cap.text();
// The ONLY ambiguity warning this module ever emits names "candidate
// source entries" (discoverSourceEntries) — must never fire for a
// rootlessExports package, since discovery is skipped entirely for them.
expect(text.includes('candidate source entries')).toBe(false);
});
});
describe('M6 — nested workspace roots are gated by the outer scope', () => {
let dir: string;
beforeAll(() => {
const r = mkRepo('gn-m6-');
dir = r.dir;
r.w(
'package.json',
JSON.stringify({ name: 'root', workspaces: ['packages/*', '!packages/legacy'] }),
);
r.w('packages/real/package.json', JSON.stringify({ name: '@repo/real', main: 'src/index.ts' }));
r.w('packages/real/src/index.ts', 'export const real = 1;\n');
// Excluded subtree that re-declares a workspace of its own: must stay out.
r.w(
'packages/legacy/package.json',
JSON.stringify({ name: '@repo/legacy', workspaces: ['libs/*'] }),
);
r.w(
'packages/legacy/libs/old/package.json',
JSON.stringify({ name: '@repo/old', main: 'src/index.ts' }),
);
r.w('packages/legacy/libs/old/src/index.ts', 'export const old = 1;\n');
// A starter under examples/ carrying `workspaces`: never a root.
r.w(
'examples/starter/package.json',
JSON.stringify({ name: 'starter', workspaces: ['apps/*'] }),
);
r.w(
'examples/starter/apps/web/package.json',
JSON.stringify({ name: '@repo/real', main: 'src/index.ts' }),
);
r.w('examples/starter/apps/web/src/index.ts', 'export const fake = 1;\n');
// A nested root that IS admitted by the outer scope (packages/*): its members count.
r.w(
'packages/nested/package.json',
JSON.stringify({ name: '@repo/nested', workspaces: ['inner/*'] }),
);
r.w(
'packages/nested/inner/leaf/package.json',
JSON.stringify({ name: '@repo/leaf', main: 'src/index.ts' }),
);
r.w('packages/nested/inner/leaf/src/index.ts', 'export const leaf = 1;\n');
});
afterAll(() => rm(dir));
it('an outer `!exclusion` keeps binding under a nested root inside the excluded subtree', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.has('@repo/legacy')).toBe(false);
expect(pkgs!.byName.has('@repo/old')).toBe(false);
});
it('an examples/ starter never becomes a root, so its name collision cannot outrank the real package', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('@repo/real')?.dir).toBe('packages/real');
expect(pkgs!.byName.has('starter')).toBe(false);
for (const pkg of pkgs!.byName.values()) expect(pkg.dir.startsWith('examples/')).toBe(false);
});
it('a nested root admitted by the outer scope contributes its members', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('@repo/leaf')?.dir).toBe('packages/nested/inner/leaf');
expect(pkgs!.byName.get('@repo/leaf')?.entries).toContain(
'packages/nested/inner/leaf/src/index',
);
});
});
describe('M9 — per-repo memo', () => {
let dir: string;
beforeAll(() => {
const r = mkRepo('gn-m9-');
dir = r.dir;
r.w('package.json', JSON.stringify({ name: 'root', workspaces: ['packages/*'] }));
r.w('packages/a/package.json', JSON.stringify({ name: '@repo/a', main: 'src/index.ts' }));
r.w('packages/a/src/index.ts', 'export const a = 1;\n');
});
afterAll(() => rm(dir));
it('returns the same map for the same root until invalidated', async () => {
invalidateNodeWorkspacePackages(dir);
const first = await loadNodeWorkspacePackages(dir);
const second = await loadNodeWorkspacePackages(dir);
expect(second).toBe(first);
// A package added after the first scan is invisible until invalidation…
fs.mkdirSync(path.join(dir, 'packages/b/src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'packages/b/package.json'),
JSON.stringify({ name: '@repo/b', main: 'src/index.ts' }),
);
fs.writeFileSync(path.join(dir, 'packages/b/src/index.ts'), 'export const b = 1;\n');
expect((await loadNodeWorkspacePackages(dir))!.byName.has('@repo/b')).toBe(false);
// …and visible after it.
invalidateNodeWorkspacePackages(dir);
expect((await loadNodeWorkspacePackages(dir))!.byName.has('@repo/b')).toBe(true);
expect(typeof resolveNodeWorkspaceImport).toBe('function');
});
});
describe('M6 — the outer exclusion covers a nested root declared via pnpm-workspace.yaml too', () => {
// Same shape as the "M6" describe block above (`packages/legacy` re-declares
// its own workspace and must stay excluded), but the nested declaration is
// the OTHER of the two spellings `readWorkspacePatternsAt` merges —
// `pnpm-workspace.yaml` rather than `package.json`'s `workspaces` field —
// exercising the gate against the spelling `findWorkspaceRoots` treats
// identically for "declares a workspace" but differently for `admits()`.
let dir: string;
beforeAll(() => {
const r = mkRepo('gn-m6-yaml-');
dir = r.dir;
r.w(
'package.json',
JSON.stringify({ name: 'root', workspaces: ['packages/*', '!packages/legacy'] }),
);
r.w(
'packages/real/package.json',
JSON.stringify({ name: '@repo/real2', main: 'src/index.ts' }),
);
r.w('packages/real/src/index.ts', 'export const real = 1;\n');
// Excluded subtree whose OWN workspace is declared via pnpm-workspace.yaml.
r.w('packages/legacy/package.json', JSON.stringify({ name: '@repo/legacy2' }));
r.w('packages/legacy/pnpm-workspace.yaml', 'packages:\n - "libs/*"\n');
r.w(
'packages/legacy/libs/old/package.json',
JSON.stringify({ name: '@repo/old2', main: 'src/index.ts' }),
);
r.w('packages/legacy/libs/old/src/index.ts', 'export const old = 1;\n');
});
afterAll(() => rm(dir));
it('the pnpm-workspace.yaml-declared nested root inside the excluded subtree is NOT admitted', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs).not.toBeNull();
// The excluded package.json itself (packages/legacy) is also outside the
// outer scope, independent of its own nested declaration.
expect(pkgs!.byName.has('@repo/legacy2')).toBe(false);
expect(pkgs!.byName.has('@repo/old2')).toBe(false);
expect(pkgs!.byName.get('@repo/real2')?.dir).toBe('packages/real');
});
});
describe('M6 — name collision: the root package always wins over an admitted nested duplicate', () => {
// `admits(scope, '')` is unconditionally true (`if (dir === '') return true`)
// — the repo root is always itself a package, workspace or not. The BFS in
// `loadNodeWorkspacePackagesUncached` visits shallower directories first
// (queue is depth-ordered), so when the ROOT package and a nested workspace
// member declare the SAME name, "first declaration wins" must mean the root,
// not the shallowest scanned nested match. Documented behavior, not a
// "correct" resolution in any package-manager sense — pnpm/npm would refuse
// to install two packages with the same name at all. What matters here is
// that GitNexus's own winner is deterministic and repeatable.
let dir: string;
beforeAll(() => {
const r = mkRepo('gn-m6-collide-');
dir = r.dir;
r.w(
'package.json',
JSON.stringify({
name: '@repo/dup',
private: true,
workspaces: ['packages/*'],
// `exports: {"."}` keeps `entries` to exactly this one declared stem —
// `main` alone would also pull in the always-appended conventional
// fallbacks (`src/index`, `index`, `lib/index`), which would make the
// "exactly one entry, the root's" assertion below false positive-prone.
exports: { '.': './root-src/index.ts' },
}),
);
r.w('root-src/index.ts', 'export const rootWins = 1;\n');
// A nested, admitted package reusing the SAME name as the root.
r.w(
'packages/dupnested/package.json',
JSON.stringify({ name: '@repo/dup', main: 'src/index.ts' }),
);
r.w('packages/dupnested/src/index.ts', 'export const nestedLoses = 1;\n');
});
afterAll(() => rm(dir));
it('the root package (shallowest, dir === "") wins the name collision, deterministically', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs).not.toBeNull();
const winner = pkgs!.byName.get('@repo/dup');
expect(winner).toBeDefined();
expect(winner!.dir).toBe('');
expect(winner!.entries).toEqual(['root-src/index']);
// Repeated scans (memo invalidated each time) keep picking the same winner.
invalidateNodeWorkspacePackages(dir);
const second = await loadNodeWorkspacePackages(dir);
expect(second!.byName.get('@repo/dup')?.dir).toBe('');
});
});

View file

@ -0,0 +1,171 @@
/**
* M7 — `findWorkspaceRoots`'s workspace-ROOT scan (nested `pnpm-workspace.yaml`
* / `lerna.json` / `package.json#workspaces` discovery inside
* `node-workspace-packages.ts`): sorted `readdir` (deterministic) and a
* 50,000-directory cap that warns instead of silently truncating.
*
* Real disk I/O can't cheaply exercise a 50,000-directory tree, so `readdir` /
* `readFile` are intercepted for one virtual root and everything else falls
* through to the real `fs/promises` (same Proxy-over-`importOriginal` shape as
* `node-workspace-repo-root-race.test.ts`), so unrelated code (the logger's
* own init, etc.) is unaffected.
*
* Two things are proven, deliberately kept SEPARATE because they can't both be
* observed through the same signal: a package admitted through a NESTED
* root's declaration sits one directory level BELOW the declaring directory,
* and the wide level needed to trip the 50k root-scan cap already exceeds the
* SEPARATE 20k-directory package-scan cap — so a package nested under the
* wide, capped subtree is not a reachable signal for either scan.
*
* 1. The cap trips and warns (`logger.warn`, captured via `_captureLogger`)
* rather than hanging or throwing, however the synthetic `readdir` order
* is shuffled.
* 2. A package admitted through a path that does NOT depend on the wide,
* capped subtree (`members/only`, admitted directly by the repo root's
* OWN declared `workspaces: ['members/*']`) still resolves correctly and
* IDENTICALLY no matter how the wide subtree's `readdir` order is
* shuffled — the capped/warned branch does not corrupt or drop unrelated,
* already-resolvable results.
*/
import { describe, it, expect, vi } from 'vitest';
import { _captureLogger } from '../../src/core/logger.js';
const ROOT = '/virtual-gn-m7-repo';
const FILLER_COUNT = 50_010; // > WORKSPACE_ROOT_SCAN_MAX_DIRS (50_000)
// The wide subtree is named to sort AFTER `members`: both scans now read directories in
// sorted order (deterministic), so a capped scan drops whatever sorts last — the
// fixture must not rely on unsorted readdir order to reach `members/only` first.
interface FakeDirent {
readonly name: string;
isDirectory(): boolean;
isFile(): boolean;
}
const dirEnt = (name: string): FakeDirent => ({
name,
isDirectory: () => true,
isFile: () => false,
});
const fileEnt = (name: string): FakeDirent => ({
name,
isDirectory: () => false,
isFile: () => true,
});
/** Fisher-Yates, seeded by a simple LCG so each "shuffle" is reproducible. */
function shuffled<T>(arr: readonly T[], seed: number): T[] {
const out = [...arr];
let s = seed;
const rand = (): number => {
s = (s * 1103515245 + 12345) & 0x7fffffff;
return s / 0x7fffffff;
};
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[out[i], out[j]] = [out[j]!, out[i]!];
}
return out;
}
const fillerNames = Array.from(
{ length: FILLER_COUNT },
(_, i) => `d${String(i).padStart(6, '0')}`,
);
// A mutable box the mock factory closes over — flipped per shuffle from
// inside the test, so `vi.mock` (hoisted, registered once) can still serve a
// different `filler` order on each call without `vi.resetModules()`.
const box = vi.hoisted(() => ({ fillerOrder: [] as string[] }));
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;
const relOf = (p: string): string | null =>
p === ROOT ? '' : p.startsWith(`${ROOT}/`) ? p.slice(ROOT.length + 1) : null;
const fakeReaddir = async (dir: string) => {
const rel = relOf(dir);
if (rel === null)
return (d as typeof import('fs/promises')).readdir(
dir as never,
{
withFileTypes: true,
} as never,
);
if (rel === '') return [dirEnt('members'), dirEnt('zzz-filler')];
if (rel === 'members') return [dirEnt('only')];
if (rel === 'members/only') return [fileEnt('package.json')];
if (rel === 'zzz-filler') return box.fillerOrder.map(dirEnt);
// Every filler child (and anything deeper, unreached in practice) is empty.
return [];
};
const fakeReadFile = async (file: string) => {
const rel = relOf(file);
if (rel === null) return (d as typeof import('fs/promises')).readFile(file as never, 'utf-8');
if (rel === 'package.json') {
return JSON.stringify({ name: 'root', private: true, workspaces: ['members/*'] });
}
if (rel === 'members/only/package.json') {
return JSON.stringify({ name: '@repo/only', exports: { '.': './index.ts' } });
}
const err = Object.assign(new Error(`ENOENT: ${file}`), { code: 'ENOENT' });
throw err;
};
return {
default: new Proxy(d, {
get(target, prop) {
if (prop === 'readdir') return fakeReaddir;
if (prop === 'readFile') return fakeReadFile;
const v = Reflect.get(target, prop, target) as unknown;
return typeof v === 'function' ? (v as (...args: unknown[]) => unknown).bind(target) : v;
},
}),
};
});
describe('M7 — workspace-root scan: sorted readdir + a cap that warns (50,010-directory synthetic tree)', () => {
it('trips the cap and warns, and the run still completes deterministically for a result outside the capped subtree', async () => {
const { loadNodeWorkspacePackages, invalidateNodeWorkspacePackages } =
await import('../../src/core/ingestion/import-resolvers/node-workspace-packages.js');
const results: {
warned: boolean;
onlyDir: string | undefined;
onlyEntries: readonly string[] | undefined;
}[] = [];
for (const seed of [1, 2]) {
box.fillerOrder = shuffled(fillerNames, seed);
invalidateNodeWorkspacePackages(ROOT);
const cap = _captureLogger();
let pkgs;
try {
pkgs = await loadNodeWorkspacePackages(ROOT);
} finally {
cap.restore();
}
const text = cap.text();
const warned =
text.includes('workspace-root scan') &&
text.includes('50000-directory cap') &&
text.includes(ROOT);
const only = pkgs?.byName.get('@repo/only');
results.push({ warned, onlyDir: only?.dir, onlyEntries: only?.entries });
}
// Both shuffles hit the cap and warned about it.
expect(results[0]!.warned).toBe(true);
expect(results[1]!.warned).toBe(true);
// Both shuffles still admit the package reachable independently of the
// wide/capped `filler` subtree, identically.
expect(results[0]!.onlyDir).toBe('members/only');
expect(results[1]!.onlyDir).toBe('members/only');
expect(results[0]!.onlyEntries).toEqual(['members/only/index']);
expect(results[1]!.onlyEntries).toEqual(results[0]!.onlyEntries);
}, 60_000);
});

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,185 @@
/**
* `@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 — Opus review follow-ups', () => {
it('a re-export FROM another module never marks a same-named local exported', () => {
const v = verdicts(
emitTsScopeCaptures,
"export { alpha } from './other';\nexport { beta as gamma } from './o';\nexport type { T } from './t';\nfunction alpha() {}\nfunction beta() {}\nfunction gamma() {}\ntype T = number;\nexport const keep = 1;\n",
'test.ts',
);
expect(v.alpha).not.toBe('true');
expect(v.beta).not.toBe('true');
expect(v.gamma).not.toBe('true');
expect(v.T).not.toBe('true');
expect(v.alpha).toBe('false');
expect(v.keep).toBe('true');
});
it('exports inside a namespace or ambient module body are not file-level exports', () => {
const v = verdicts(
emitTsScopeCaptures,
"export namespace NS { export function f() {} }\ndeclare module 'x' { export function q(): void; }\nexport function top() {}\n",
'test.ts',
);
expect(v.NS).toBe('true');
expect(v.f).not.toBe('true');
expect(v.q).not.toBe('true');
expect(v.top).toBe('true');
});
it('a comment or string mentioning module.exports does not silence the ESM verdicts', () => {
const v = verdicts(
emitTsScopeCaptures,
"// legacy: module.exports = api\nconst note = 'exports.x = 1';\nexport function a() {}\nfunction b() {}\n",
'test.ts',
);
expect(v.a).toBe('true');
expect(v.b).toBe('false');
// ...while a real alias of the export object still does.
const cjs = verdicts(
emitJsScopeCaptures,
'const m = module.exports;\nfunction b() {}\nm.b = b;\n',
'x.js',
);
expect(cjs.b).toBeUndefined();
});
});
describe('@declaration.is-exported (JavaScript emitter)', () => {
it('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

@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest';
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js';
import { populateGoPackageSiblings } from '../../../../src/core/ingestion/languages/go/index.js';
/**
* C1 — `_test.go` files get package-sibling bindings (they used to be dropped,
* sending every same-package call from a test to the global fallback).
*/
function def(nodeId: string, filePath: string, name: string): SymbolDefinition {
return { nodeId, filePath, type: 'Function', qualifiedName: name };
}
function parsed(
filePath: string,
moduleScope: string,
...localDefs: SymbolDefinition[]
): ParsedFile {
return { filePath, moduleScope, scopes: [], parsedImports: [], localDefs, referenceSites: [] };
}
function setup(files: { path: string; scope: string; pkg: string; defs: SymbolDefinition[] }[]) {
const parsedFiles = files.map((f) => parsed(f.path, f.scope, ...f.defs));
const indexes = {
moduleScopes: { byFilePath: new Map(files.map((f) => [f.path, f.scope])) },
imports: new Map(),
bindings: new Map(),
bindingAugmentations: new Map(),
} as unknown as ScopeResolutionIndexes;
const fileContents = new Map(files.map((f) => [f.path, `package ${f.pkg}\n`]));
populateGoPackageSiblings(parsedFiles, indexes, { fileContents });
const see = (scope: string, name: string) =>
indexes.bindingAugmentations
.get(scope)
?.get(name)
?.map((b) => b.def.nodeId) ?? [];
return { see };
}
describe('Go _test.go package siblings', () => {
const helper = def('helper', 'pkg/a/a.go', 'setUpHelper');
const exported = def('exported', 'pkg/a/a.go', 'NewThing');
const testOnly = def('test-only', 'pkg/a/a_test.go', 'fakeStore');
const otherTest = def('other-test', 'pkg/a/b_test.go', 'scenario');
it('an internal test file sees non-test siblings, exported and unexported', () => {
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-test', pkg: 'a', defs: [testOnly] },
]);
expect(see('m:a-test', 'setUpHelper')).toEqual(['helper']);
expect(see('m:a-test', 'NewThing')).toEqual(['exported']);
});
it('internal test files see each other', () => {
const { see } = setup([
{ path: 'pkg/a/a_test.go', scope: 'm:a-test', pkg: 'a', defs: [testOnly] },
{ path: 'pkg/a/b_test.go', scope: 'm:b-test', pkg: 'a', defs: [otherTest] },
]);
expect(see('m:a-test', 'scenario')).toEqual(['other-test']);
expect(see('m:b-test', 'fakeStore')).toEqual(['test-only']);
});
it('a non-test file does NOT see a test-only helper', () => {
const { see } = setup([
{ path: 'pkg/a/a.go', scope: 'm:a', pkg: 'a', defs: [helper] },
{ path: 'pkg/a/a_test.go', scope: 'm:a-test', pkg: 'a', defs: [testOnly] },
]);
expect(see('m:a', 'fakeStore')).toEqual([]);
});
it('an external test package (`foo_test`) gets NO bare-name bindings from `foo` — it must qualify `foo.X`', () => {
// Go requires `a.NewThing` inside `package a_test`; a bare `NewThing()`
// there is a compile error, so publishing it bound a call Go rejects.
// The qualified form resolves through the test's explicit import of the
// package path, not through sibling augmentation.
const { see } = setup([
{ path: 'pkg/a/a.go', scope: 'm:a', pkg: 'a', defs: [helper, exported] },
{ path: 'pkg/a/a_ext_test.go', scope: 'm:ext', pkg: 'a_test', defs: [testOnly] },
]);
expect(see('m:ext', 'NewThing')).toEqual([]);
expect(see('m:ext', 'setUpHelper')).toEqual([]);
// and `a` does not see the external test's declarations
expect(see('m:a', 'fakeStore')).toEqual([]);
});
it('tests in a different directory with the same package name stay isolated', () => {
const far = def('far', 'pkg/b/x_test.go', 'farHelper');
const { see } = setup([
{ path: 'pkg/a/a_test.go', scope: 'm:a-test', pkg: 'a', defs: [testOnly] },
{ path: 'pkg/b/x_test.go', scope: 'm:far', pkg: 'a', defs: [far] },
]);
expect(see('m:a-test', 'farHelper')).toEqual([]);
});
// Gap: the existing external-test test only checked visibility FROM the
// external test's own scope (`m:ext`) and confirmed the internal package's
// NON-test file (`m:a`) doesn't see it. It never checked the internal
// TEST's scope — `package foo`'s `a_test.go` is still package `foo`, not
// `foo_test`, and must be just as blind to `foo_test`'s declarations,
// exported or not, as the non-test file is. `target.external &&
// !receiver.external` is the line this exercises; a receiver-side bug
// there (e.g. checking `target.isTest` instead) would leak names across
// the `foo` / `foo_test` package boundary through the internal test only.
it('an internal test file (still package `foo`) does not see the external test package at all, exported or not', () => {
const extExported = def('ext-exported', 'pkg/a/a_ext_test.go', 'ExtHelper');
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-test', pkg: 'a', defs: [testOnly] },
{ path: 'pkg/a/b_test.go', scope: 'm:b-test', pkg: 'a', defs: [otherTest] },
{ path: 'pkg/a/a_ext_test.go', scope: 'm:ext', pkg: 'a_test', defs: [extExported] },
]);
expect(see('m:a-test', 'ExtHelper')).toEqual([]);
// still sees its own package's OTHER internal-test sibling (a different
// file than itself, so the self-reference guard does not apply)
expect(see('m:a-test', 'scenario')).toEqual(['other-test']);
});
// Two `_test.go` files that are BOTH external (`package foo_test`) are, to
// each other, the same package — full visibility, unexported names
// included. Distinct from "internal tests see each other" above (that case
// never touches the `exportedOnly` branch at all).
it('two external test files in the same directory see each other fully, unexported included', () => {
const extA = def('ext-a', 'pkg/a/a_ext_test.go', 'scaffold');
const extB = def('ext-b', 'pkg/a/b_ext_test.go', 'teardown');
const { see } = setup([
{ path: 'pkg/a/a_ext_test.go', scope: 'm:ext-a', pkg: 'a_test', defs: [extA] },
{ path: 'pkg/a/b_ext_test.go', scope: 'm:ext-b', pkg: 'a_test', defs: [extB] },
]);
expect(see('m:ext-a', 'teardown')).toEqual(['ext-b']);
expect(see('m:ext-b', 'scaffold')).toEqual(['ext-a']);
});
// Requirement: "two packages in one directory ... do not cross-bind
// non-exported names". Two genuinely distinct NON-test packages sharing a
// directory (e.g. a `main` package next to a `//go:build ignore` tool)
// must stay in separate sibling groups — same directory, different
// `dir\0pkgName` key.
it('two distinct non-test packages in the same directory do not cross-bind', () => {
const fooHelper = def('foo-helper', 'pkg/a/main.go', 'setup');
const barHelper = def('bar-helper', 'pkg/a/tool.go', 'setup');
const { see } = setup([
{ path: 'pkg/a/main.go', scope: 'm:foo', pkg: 'foo', defs: [fooHelper] },
{ path: 'pkg/a/tool.go', scope: 'm:bar', pkg: 'bar', defs: [barHelper] },
]);
expect(see('m:foo', 'setup')).toEqual([]);
expect(see('m:bar', 'setup')).toEqual([]);
});
it('a package genuinely NAMED `foo_test` keeps its internal tests in their declared package', () => {
// `package foo_test` is the external-test convention only when the
// directory's real package is `foo`. Here the non-test files themselves say
// `foo_test`, so its `_test.go` files are INTERNAL tests of that package
// and must see unexported siblings; stripping `_test` blindly keyed them
// as external tests of a non-existent `foo` and published nothing.
const impl = def('impl', 'pkg/foo_test/impl.go', 'unexportedHelper');
const fixture = def('fixture', 'pkg/foo_test/impl_test.go', 'newFixture');
const { see } = setup([
{ path: 'pkg/foo_test/impl.go', scope: 'm:impl', pkg: 'foo_test', defs: [impl] },
{ path: 'pkg/foo_test/impl_test.go', scope: 'm:impl-test', pkg: 'foo_test', defs: [fixture] },
]);
expect(see('m:impl-test', 'unexportedHelper')).toEqual(['impl']);
// Non-test files still never see test-only declarations.
expect(see('m:impl', 'newFixture')).toEqual([]);
});
it('`package foo_test` beside `package foo` is still the external-test convention', () => {
const { see } = setup([
{ path: 'pkg/a/a.go', scope: 'm:a', pkg: 'a', defs: [helper, exported] },
{ path: 'pkg/a/a_test.go', scope: 'm:a-ext', pkg: 'a_test', defs: [testOnly] },
]);
expect(see('m:a-ext', 'setUpHelper')).toEqual([]);
expect(see('m:a-ext', 'NewThing')).toEqual([]);
});
});

View file

@ -0,0 +1,222 @@
/**
* The per-language guessed/refused census persisted as
* `RepoMeta.nameFallbackEdges` and printed in the analyze summary.
*
* The pair of counts is the point. A guess count alone cannot distinguish a
* language with few impossible candidates from one whose visibility hook is
* missing, and a refusal count alone cannot distinguish a working guard from
* one that rejects everything — so both are asserted to survive per language
* rather than being folded into a repo-wide total.
*/
import { describe, it, expect } from 'vitest';
import type { ResolutionOutcome } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js';
import {
countCallsByLanguage,
formatNameFallbackSummary,
MAX_AMBIGUOUS_NAMES,
summarizeNameFallback,
} from '../../../src/core/ingestion/scope-resolution/name-fallback-summary.js';
const range = { startLine: 1, startCol: 0, endLine: 1, endCol: 5 };
const guessed = (language: string | undefined, name = 'helper'): ResolutionOutcome => ({
kind: 'fallback-guessed',
targetId: `def:${name}`,
language,
phase: 'free-call-fallback',
filePath: 'a',
name,
range,
});
const refused = (language: string | undefined, name = 'helper'): ResolutionOutcome => ({
kind: 'fallback-refused',
candidateId: `def:${name}`,
language,
phase: 'free-call-fallback',
filePath: 'a',
name,
range,
});
describe('summarizeNameFallback', () => {
it('returns undefined when a run neither guessed nor refused', () => {
// A repository with no opt-in language must store no key at all, rather
// than a row of zeroes that reads as a measured result.
expect(summarizeNameFallback([])).toBeUndefined();
});
it('ignores unrelated outcomes', () => {
const unrelated: ResolutionOutcome = {
kind: 'resolved',
targetId: 'def:x',
phase: 'free-call-fallback',
filePath: 'a',
name: 'x',
range,
};
expect(summarizeNameFallback([unrelated])).toBeUndefined();
});
it('keeps guesses and refusals separated per language', () => {
const summary = summarizeNameFallback([
guessed('go'),
guessed('go', 'other'),
refused('go'),
refused('rust'),
refused('rust'),
refused('rust'),
]);
expect(summary).toEqual({
byLanguage: {
go: { guessed: 2, guessedPairs: 2, refused: 1 },
rust: { guessed: 0, guessedPairs: 0, refused: 3 },
},
totalGuessed: 2,
distinctGuessedPairs: 2,
totalRefused: 4,
totalAmbiguousReexports: 0,
});
});
it('keeps call SITES in `guessed` and distinct (caller file, callee name) pairs in `guessedPairs`', () => {
// Three guessed `helper()` sites plus one `other()` in one file: 4 sites,
// 2 distinct pairs. `callsByLanguage` counts distinct callee names, so the
// guessy RATIO is pairs/calls and stays bounded by 1 (M5); the site count
// keeps its historical unit so persisted summaries stay comparable (M13).
const summary = summarizeNameFallback(
[guessed('go'), guessed('go'), guessed('go'), guessed('go', 'other')],
{ go: 2 },
);
expect(summary?.byLanguage.go).toEqual({ guessed: 4, guessedPairs: 2, refused: 0 });
expect(summary?.totalGuessed).toBe(4);
expect(summary?.distinctGuessedPairs).toBe(2);
expect(
summary!.byLanguage.go!.guessedPairs! / summary!.callsByLanguage!.go!,
).toBeLessThanOrEqual(1);
});
it('counts refused `export *` collisions in the same census, outside the language table', () => {
const summary = summarizeNameFallback([
{
kind: 'reexport-ambiguous',
candidateIds: ['def:a', 'def:b'],
phase: 'finalize',
filePath: 'packages/ui/src/index.ts',
name: 'collide',
},
]);
expect(summary).toEqual({
byLanguage: {},
totalGuessed: 0,
distinctGuessedPairs: 0,
totalRefused: 0,
totalAmbiguousReexports: 1,
ambiguousReexportNames: ['packages/ui/src/index.ts:collide'],
});
expect(formatNameFallbackSummary(summary)).toContain('1 barrel name(s) refused as ambiguous');
});
it('buckets an unattributed pass rather than dropping it', () => {
const summary = summarizeNameFallback([guessed(undefined)]);
expect(summary?.byLanguage).toEqual({ unknown: { guessed: 1, guessedPairs: 1, refused: 0 } });
expect(summary?.totalGuessed).toBe(1);
});
it('caps the persisted ambiguous-name list at MAX_AMBIGUOUS_NAMES while keeping the total exact', () => {
const ambiguous = (name: string): ResolutionOutcome => ({
kind: 'reexport-ambiguous',
candidateIds: ['def:a', 'def:b'],
phase: 'finalize',
filePath: 'x.ts',
name,
});
// 250 distinct fixed-width names, well over the 200 cap, generated in
// DESCENDING order so a bug that capped BEFORE sorting (first 200 seen,
// not first 200 alphabetically) would be caught.
const names = Array.from({ length: 250 }, (_, i) => `n${String(249 - i).padStart(3, '0')}`);
const summary = summarizeNameFallback(names.map(ambiguous));
expect(summary?.totalAmbiguousReexports).toBe(250);
expect(summary?.ambiguousReexportNames).toHaveLength(MAX_AMBIGUOUS_NAMES);
const expectedSorted = [...new Set(names.map((n) => `x.ts:${n}`))].sort().slice(0, 200);
expect(summary?.ambiguousReexportNames).toEqual(expectedSorted);
});
it('does not cap when the list is at or under the bound', () => {
const ambiguous = (name: string): ResolutionOutcome => ({
kind: 'reexport-ambiguous',
candidateIds: ['def:a', 'def:b'],
phase: 'finalize',
filePath: 'x.ts',
name,
});
const names = Array.from({ length: MAX_AMBIGUOUS_NAMES }, (_, i) => `n${i}`);
const summary = summarizeNameFallback(names.map(ambiguous));
expect(summary?.ambiguousReexportNames).toHaveLength(MAX_AMBIGUOUS_NAMES);
});
});
describe('countCallsByLanguage', () => {
const nodesOf = (byId: Record<string, string>) => ({
getNode: (id: string) =>
byId[id] === undefined ? undefined : { properties: { filePath: byId[id] } },
});
it('buckets CALLS totals by the CALLER file language, summed across callers', () => {
const index = new Map<string, ReadonlySet<string>>([
['caller-go-1', new Set(['A', 'B'])],
['caller-go-2', new Set(['C'])],
['caller-ts-1', new Set(['D', 'E', 'F'])],
]);
const nodes = nodesOf({
'caller-go-1': 'pkg/a.go',
'caller-go-2': 'pkg/b.go',
'caller-ts-1': 'src/x.ts',
});
expect(countCallsByLanguage(index, nodes)).toEqual({ go: 3, typescript: 3 });
});
it('falls back to "unknown" for a caller whose language cannot be detected', () => {
const index = new Map<string, ReadonlySet<string>>([['caller-1', new Set(['A'])]]);
const nodes = nodesOf({ 'caller-1': 'README' });
expect(countCallsByLanguage(index, nodes)).toEqual({ unknown: 1 });
});
it('skips a caller id absent from the node table rather than throwing', () => {
const index = new Map<string, ReadonlySet<string>>([
['missing', new Set(['A'])],
['present', new Set(['B', 'C'])],
]);
const nodes = nodesOf({ present: 'a.py' });
expect(countCallsByLanguage(index, nodes)).toEqual({ python: 2 });
});
it('returns undefined when either input is missing (no denominator available)', () => {
const nodes = nodesOf({ a: 'a.go' });
expect(countCallsByLanguage(undefined, nodes)).toBeUndefined();
expect(countCallsByLanguage(new Map(), undefined)).toBeUndefined();
});
it('returns undefined rather than an empty object when the index has entries but nothing attributes', () => {
const index = new Map<string, ReadonlySet<string>>([['caller-1', new Set(['A'])]]);
const nodes = nodesOf({}); // caller-1 not in the node table
expect(countCallsByLanguage(index, nodes)).toBeUndefined();
});
});
describe('formatNameFallbackSummary', () => {
it('prints nothing when there is nothing to report', () => {
expect(formatNameFallbackSummary(undefined)).toBeUndefined();
});
it('reports both totals and the per-language split, busiest first', () => {
const line = formatNameFallbackSummary(
summarizeNameFallback([guessed('ruby'), refused('go'), refused('go'), refused('go')]),
);
expect(line).toContain('1 call sites (1 distinct caller-file/name pairs)');
expect(line).toContain('3 refused as impossible');
// `go` has more total activity, so it leads.
expect(line).toMatch(/go 0\/3.*ruby 1\/0/);
});
});

View file

@ -0,0 +1,665 @@
/**
* Unit tests for the per-language `isGlobalNameFallbackPlausible` hooks and the
* shared path arithmetic they are built on.
*
* These hooks decide whether a UNIQUE-NAME GUESS is allowed to become a labeled
* CALLS edge or must be dropped as impossible. The asymmetry matters for how
* these tests are written: a wrong `false` deletes a real edge, so every case
* that the language cannot decide is asserted to return `true`. "Refuses when
* impossible" and "does not refuse when merely unproven" are therefore BOTH
* requirements, and both are tested per language.
*
* Pure functions over synthetic stubs — no pipeline, no fixtures.
*/
import { describe, it, expect } from 'vitest';
import type { ParsedFile, ParsedImport, SymbolDefinition } from 'gitnexus-shared';
import { goIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/languages/go/name-fallback-visibility.js';
import { dartIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/languages/dart/name-fallback-visibility.js';
import { rustIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/languages/rust/name-fallback-visibility.js';
import { swiftIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/languages/swift/name-fallback-visibility.js';
import { rubyIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/languages/ruby/name-fallback-visibility.js';
import {
directoryOf,
modulePathReaches,
moduleSegments,
stripExtension,
} from '../../../src/core/ingestion/scope-resolution/utils/name-fallback-visibility.js';
const namedImport = (targetRaw: string, localName = 'x'): ParsedImport => ({
kind: 'named',
localName,
importedName: localName,
targetRaw,
});
const mkCaller = (
filePath: string,
imports: readonly ParsedImport[] = [],
referenceSites: ParsedFile['referenceSites'] = [],
localDefs: readonly SymbolDefinition[] = [],
): ParsedFile =>
({
filePath,
parsedImports: imports,
referenceSites,
localDefs,
}) as unknown as ParsedFile;
/** A bare (unqualified) call site — the shape the name-guess tier exists for. */
const BARE_SITE = { name: 'unique_helper_xyz' } as const;
const mkCandidate = (filePath: string, qualifiedName: string, ownerId?: string): SymbolDefinition =>
({
nodeId: `def:${filePath}:${qualifiedName}`,
filePath,
type: 'Function',
qualifiedName,
ownerId,
}) as unknown as SymbolDefinition;
describe('shared path arithmetic', () => {
it('splits module paths on /, :: and .', () => {
expect(moduleSegments('a/b/c')).toEqual(['a', 'b', 'c']);
expect(moduleSegments('crate::a::b')).toEqual(['crate', 'a', 'b']);
expect(moduleSegments('com.example.Thing')).toEqual(['com', 'example', 'Thing']);
});
it('does not split a path-bearing specifier on its extension dot', () => {
expect(moduleSegments('./util/parse.js')).toEqual(['util', 'parse.js']);
});
it('matches a written module prefix against a repo-relative directory', () => {
// The written import carries a module prefix that is not a directory.
expect(modulePathReaches('github.com/org/svc/internal/models', 'internal/models')).toBe(true);
// ...and the reverse, when the module manifest sits in a subdirectory.
expect(modulePathReaches('mod/internal/models', 'svc/internal/models')).toBe(true);
});
it('does not match unrelated paths that merely share a middle segment', () => {
expect(modulePathReaches('github.com/org/svc/internal/models', 'internal/handlers')).toBe(
false,
);
expect(modulePathReaches('a/b', 'c/d')).toBe(false);
});
it('reports no match for an empty path on either side', () => {
expect(modulePathReaches('', 'a/b')).toBe(false);
expect(modulePathReaches('a/b', '')).toBe(false);
});
it('derives directories and strips extensions', () => {
expect(directoryOf('a/b/c.go')).toBe('a/b');
expect(directoryOf('main.go')).toBe('');
expect(stripExtension('a/b.rs')).toBe('a/b');
expect(stripExtension('a/b')).toBe('a/b');
});
});
describe('Go: isGlobalNameFallbackPlausible', () => {
it('REFUSES an unexported identifier from another package', () => {
// The headline case: `a.uniqueHelperXyz` is invisible to package `b`, and no
// import can make it visible, so the guess is impossible rather than weak.
expect(
goIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('b/caller.go'),
candidate: mkCandidate('a/helper.go', 'uniqueHelperXyz'),
}),
).toBe(false);
});
it('REFUSES an unexported identifier even when the package IS imported', () => {
expect(
goIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('b/caller.go', [namedImport('example.com/mod/a')]),
candidate: mkCandidate('a/helper.go', 'uniqueHelperXyz'),
}),
).toBe(false);
});
it('allows an unexported identifier inside the SAME package directory', () => {
expect(
goIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('a/caller.go'),
candidate: mkCandidate('a/helper.go', 'uniqueHelperXyz'),
}),
).toBe(true);
});
it('REFUSES an exported identifier when the caller never imports its package', () => {
expect(
goIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('b/caller.go', [namedImport('example.com/mod/unrelated')]),
candidate: mkCandidate('a/helper.go', 'UniqueHelperXyz'),
}),
).toBe(false);
});
it('allows an exported identifier whose package the caller imports', () => {
expect(
goIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('b/caller.go', [namedImport('example.com/mod/a')]),
candidate: mkCandidate('a/helper.go', 'UniqueHelperXyz'),
}),
).toBe(true);
});
it('reads export case from the last segment of a method qualified name', () => {
expect(
goIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('b/caller.go'),
candidate: mkCandidate('a/helper.go', 'Host.doThing'),
}),
).toBe(false);
expect(
goIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('b/caller.go', [namedImport('mod/a')]),
candidate: mkCandidate('a/helper.go', 'Host.DoThing'),
}),
).toBe(true);
});
it('does not refuse when there is no identifier to judge', () => {
expect(
goIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('b/caller.go'),
candidate: mkCandidate('a/helper.go', ''),
}),
).toBe(true);
});
it("REFUSES an exported helper declared in another package's `_test.go`, module root included", () => {
// A `_test.go` file is compiled only into its own package's test binary;
// no other package can see it. The module-root exception used to run first
// and accept `root_helper_test.go`'s exports for every subdirectory caller.
expect(
goIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('internal/svc/caller.go', [namedImport('github.com/org/mod')]),
candidate: mkCandidate('helpers_test.go', 'ExportedTestHelper'),
}),
).toBe(false);
expect(
goIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('internal/svc/caller.go', [
namedImport('github.com/org/mod/internal/models'),
]),
candidate: mkCandidate('internal/models/fixtures_test.go', 'NewFixture'),
}),
).toBe(false);
// ...even from another package's own test file.
expect(
goIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('internal/svc/caller_test.go', [
namedImport('github.com/org/mod/internal/models'),
]),
candidate: mkCandidate('internal/models/fixtures_test.go', 'NewFixture'),
}),
).toBe(false);
});
it('does not refuse an exported identifier in the module ROOT package', () => {
// The root package is imported by the module path alone, which the
// repo-relative layout cannot align against — undecidable, so allowed.
expect(
goIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('internal/svc/caller.go', [namedImport('github.com/org/mod')]),
candidate: mkCandidate('root.go', 'ExportedHelper'),
}),
).toBe(true);
expect(
goIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('internal/svc/caller.go'),
candidate: mkCandidate('root.go', 'ExportedHelper'),
}),
).toBe(true);
});
});
describe('Dart: isGlobalNameFallbackPlausible', () => {
it('does NOT refuse a library-private name from another directory — a `part` URI may cross it', () => {
// `part '../shared/gen.dart';` is legal Dart, and `part` directives are not
// extracted yet, so "different directory" is undecidable, not impossible.
// The edge stays a labeled guess rather than being deleted.
expect(
dartIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('lib/widgets/b.dart'),
candidate: mkCandidate('lib/models/a.dart', '_privateHelper'),
}),
).toBe(true);
});
it('allows a library-private name in a SIBLING file (possible `part`)', () => {
// `part` directives are not extracted yet, and parts are siblings of their
// library file — refusing here would delete the Flutter `foo.g.dart` edge.
expect(
dartIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('lib/b.dart'),
candidate: mkCandidate('lib/a.dart', '_privateHelper'),
}),
).toBe(true);
});
it('allows the generated-part idiom: `_$FooFromJson` in `foo.g.dart` beside `foo.dart`', () => {
expect(
dartIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('lib/models/foo.dart'),
candidate: mkCandidate('lib/models/foo.g.dart', '_$FooFromJson'),
}),
).toBe(true);
});
it('allows a library-private name in the same file', () => {
expect(
dartIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('lib/a.dart'),
candidate: mkCandidate('lib/a.dart', '_privateHelper'),
}),
).toBe(true);
});
it('allows a library-private name across directories when the caller names the file', () => {
// The `part` / `part of` direction, once the extractor surfaces it as an
// import target: an explicit directive against the candidate's file wins.
expect(
dartIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('lib/widgets/b.dart', [namedImport('lib/models/a.dart')]),
candidate: mkCandidate('lib/models/a.dart', '_privateHelper'),
}),
).toBe(true);
});
it('allows a public name across files', () => {
expect(
dartIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('lib/b.dart'),
candidate: mkCandidate('lib/a.dart', 'publicHelper'),
}),
).toBe(true);
});
it('judges privacy on the member, not its owner', () => {
// `_Foo.bar` is a public member of a private class — the member name is what
// a bare call would name, so it is not refused on the owner's underscore.
expect(
dartIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('lib/b.dart'),
candidate: mkCandidate('lib/a.dart', '_Foo.bar'),
}),
).toBe(true);
});
});
describe('Rust: isGlobalNameFallbackPlausible', () => {
it('REFUSES a cross-module item with no covering `use`', () => {
expect(
rustIsGlobalNameFallbackPlausible({
site: BARE_SITE,
callerParsed: mkCaller('src/b.rs', [namedImport('crate::unrelated')]),
candidate: mkCandidate('src/a.rs', 'unique_helper_xyz'),
}),
).toBe(false);
});
it('allows a same-file item', () => {
expect(
rustIsGlobalNameFallbackPlausible({
site: BARE_SITE,
callerParsed: mkCaller('src/a.rs'),
candidate: mkCandidate('src/a.rs', 'unique_helper_xyz'),
}),
).toBe(true);
});
it('allows an item whose module the caller brought into scope', () => {
expect(
rustIsGlobalNameFallbackPlausible({
site: BARE_SITE,
callerParsed: mkCaller('src/b.rs', [namedImport('crate::a')]),
candidate: mkCandidate('src/a.rs', 'unique_helper_xyz'),
}),
).toBe(true);
});
it('treats `mod.rs` as its parent directory module', () => {
expect(
rustIsGlobalNameFallbackPlausible({
site: BARE_SITE,
callerParsed: mkCaller('src/b.rs', [namedImport('crate::net::http')]),
candidate: mkCandidate('src/net/http/mod.rs', 'unique_helper_xyz'),
}),
).toBe(true);
});
it('does not refuse when the candidate file maps to no module path', () => {
expect(
rustIsGlobalNameFallbackPlausible({
site: BARE_SITE,
callerParsed: mkCaller('src/b.rs'),
candidate: mkCandidate('lib.rs', 'unique_helper_xyz'),
}),
).toBe(true);
});
it('allows an item whose `use` names the ITEM rather than only its module', () => {
// `use crate::user::User` may arrive with the item name still on the path.
// Matching only the full path missed the module and refused `User::new`.
expect(
rustIsGlobalNameFallbackPlausible({
site: BARE_SITE,
callerParsed: mkCaller('src/main.rs', [namedImport('crate::user::User', 'User')]),
candidate: mkCandidate('src/user.rs', 'User.new'),
}),
).toBe(true);
});
it('REFUSES when the only `use` of the module names a DIFFERENT item', () => {
// `use crate::a::other;` brings `other` into scope, not `helper`. The
// parent-path match used to accept every item of `a` on its strength.
expect(
rustIsGlobalNameFallbackPlausible({
site: BARE_SITE,
callerParsed: mkCaller('src/b.rs', [namedImport('crate::a::other', 'other')]),
candidate: mkCandidate('src/a.rs', 'unique_helper_xyz'),
}),
).toBe(false);
});
it('allows a glob `use` of the module — every item is in scope', () => {
expect(
rustIsGlobalNameFallbackPlausible({
site: BARE_SITE,
callerParsed: mkCaller('src/b.rs', [{ kind: 'wildcard', targetRaw: 'crate::a' }]),
candidate: mkCandidate('src/a.rs', 'unique_helper_xyz'),
}),
).toBe(true);
});
it('allows a `use` that names the candidate itself, with the item on the path', () => {
expect(
rustIsGlobalNameFallbackPlausible({
site: BARE_SITE,
callerParsed: mkCaller('src/b.rs', [
namedImport('crate::a::unique_helper_xyz', 'unique_helper_xyz'),
]),
candidate: mkCandidate('src/a.rs', 'unique_helper_xyz'),
}),
).toBe(true);
});
it('does not judge a PATH-QUALIFIED call site', () => {
// `User::new(...)` names its path in source. Refusing it for lacking a
// `use` of the module would delete an edge the code spells out — the
// regression this carve-out exists for (rust-scope.test.ts).
expect(
rustIsGlobalNameFallbackPlausible({
site: { name: 'new', rawQualifiedName: 'User::new' },
callerParsed: mkCaller('src/main.rs'),
candidate: mkCandidate('src/user.rs', 'User.new'),
}),
).toBe(true);
});
});
describe('Swift: isGlobalNameFallbackPlausible', () => {
it('allows a cross-file candidate in the same target (whole-module internal)', () => {
expect(
swiftIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('Sources/Core/Caller.swift'),
candidate: mkCandidate('Sources/Core/Helper.swift', 'uniqueHelperXyz'),
}),
).toBe(true);
});
it('REFUSES a candidate in another target the caller never imports', () => {
expect(
swiftIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('Sources/App/Caller.swift'),
candidate: mkCandidate('Sources/Core/Helper.swift', 'uniqueHelperXyz'),
}),
).toBe(false);
});
it('allows a candidate in another target the caller imports', () => {
expect(
swiftIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('Sources/App/Caller.swift', [namedImport('Core')]),
candidate: mkCandidate('Sources/Core/Helper.swift', 'uniqueHelperXyz'),
}),
).toBe(true);
});
it('does not refuse when the layout heuristic cannot place a file', () => {
expect(
swiftIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('Caller.swift'),
candidate: mkCandidate('Sources/Core/Helper.swift', 'uniqueHelperXyz'),
}),
).toBe(true);
});
});
describe('Ruby: isGlobalNameFallbackPlausible', () => {
/** A candidate file whose `localDefs` carry the owner with the given label. */
const ownerFile =
(ownerId: string, label: 'Class' | 'Trait') =>
(filePath: string): ParsedFile | undefined =>
({
filePath,
parsedImports: [],
referenceSites: [],
localDefs: [{ nodeId: ownerId, filePath, type: label, qualifiedName: 'Billing' }],
}) as unknown as ParsedFile;
it('allows a TOP-LEVEL method across files (the autoload shape)', () => {
// Ruby keeps this guess on purpose: with zeitwerk a file really can call a
// method whose defining file it never requires.
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/b.rb'),
candidate: mkCandidate('app/a.rb', 'unique_helper_xyz'),
}),
).toBe(true);
});
it('REFUSES a CLASS-owned method whose class the caller never names', () => {
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/b.rb', [namedImport('app/unrelated')]),
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
parsedFileOf: ownerFile('def:Billing', 'Class'),
}),
).toBe(false);
});
it('allows a MODULE-owned method with no mention — Rails mixes modules in for you', () => {
// `module ApplicationHelper` is included into every view by the framework;
// a bare `format_money()` there is legal with no include/require/constant.
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/views/show.html.erb'),
candidate: mkCandidate('app/helpers/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
parsedFileOf: ownerFile('def:Billing', 'Trait'),
}),
).toBe(true);
});
it('allows a class-owned method when the caller INHERITS from anything (transitive chains)', () => {
// `class UsersController < AdminController` reaches ApplicationController's
// methods while naming only AdminController — undecidable from one file.
const inherits = {
kind: 'inherits',
name: 'AdminController',
} as unknown as ParsedFile['referenceSites'][number];
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/controllers/users_controller.rb', [], [inherits]),
candidate: mkCandidate(
'app/controllers/application_controller.rb',
'ApplicationController#unique_helper_xyz',
'def:ApplicationController',
),
parsedFileOf: ownerFile('def:ApplicationController', 'Class'),
}),
).toBe(true);
});
it('allows a class-owned method when the caller mixes anything in (include/extend marker)', () => {
const mixin = {
kind: 'namespace',
localName: 'Auditable',
importedName: 'Auditable',
targetRaw: '__heritage__:include:Auditable:Report',
} as unknown as ParsedImport;
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/models/report.rb', [mixin]),
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
parsedFileOf: ownerFile('def:Billing', 'Class'),
}),
).toBe(true);
});
it('allows a class-owned method when the caller file DEFINES a module (a mixin body)', () => {
// `module PostGuardian; def can_see?(post); is_staff? ...` — the module's
// methods run inside `class Guardian`, which includes it; the module file
// never names Guardian.
const moduleDef = {
nodeId: 'def:PostGuardian',
filePath: 'lib/guardian/post_guardian.rb',
type: 'Trait',
qualifiedName: 'PostGuardian',
} as unknown as SymbolDefinition;
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('lib/guardian/post_guardian.rb', [], [], [moduleDef]),
candidate: mkCandidate('lib/guardian.rb', 'Guardian#is_staff?', 'def:Guardian'),
parsedFileOf: ownerFile('def:Guardian', 'Class'),
}),
).toBe(true);
});
it('does not refuse when the owner cannot be typed (no file lookup)', () => {
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/b.rb'),
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
}),
).toBe(true);
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/b.rb'),
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
parsedFileOf: () => undefined,
}),
).toBe(true);
});
it('allows a class-owned method when the caller requires its namespace (snake_case path)', () => {
// `require 'billing/invoice_service'` names `Billing::InvoiceService` — the
// path is snake_case and the constant CamelCase, so the match normalizes.
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/b.rb', [namedImport('billing/invoice_service')]),
candidate: mkCandidate(
'app/a.rb',
'Billing::InvoiceService#unique_helper_xyz',
'def:InvoiceService',
),
parsedFileOf: ownerFile('def:InvoiceService', 'Class'),
}),
).toBe(true);
});
it('allows a class-owned method when the caller includes the constant by name', () => {
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/b.rb', [namedImport('Billing', 'Billing')]),
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
parsedFileOf: ownerFile('def:Billing', 'Class'),
}),
).toBe(true);
});
it('allows a class-owned method when the caller mentions the constant', () => {
const site = { name: 'Billing' } as unknown as ParsedFile['referenceSites'][number];
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/b.rb', [], [site]),
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
parsedFileOf: ownerFile('def:Billing', 'Class'),
}),
).toBe(true);
});
it('REFUSES when the caller only mentions a LONGER constant containing the name as a substring', () => {
// `BillingService.build` is not a mention of `Billing`; `includes()` said it was.
const site = {
name: 'build',
rawQualifiedName: 'BillingService.build',
} as unknown as ParsedFile['referenceSites'][number];
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/b.rb', [], [site]),
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
parsedFileOf: ownerFile('def:Billing', 'Class'),
}),
).toBe(false);
});
it('allows a qualified mention whose SEGMENT is the constant (`Acme::Billing.new`)', () => {
const site = {
name: 'new',
rawQualifiedName: 'Acme::Billing.new',
} as unknown as ParsedFile['referenceSites'][number];
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/b.rb', [], [site]),
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
parsedFileOf: ownerFile('def:Billing', 'Class'),
}),
).toBe(true);
});
it('keeps the LABELED guess when the caller file rebinds `self` (`instance_eval` DSL blocks) (magyargergo)', () => {
// `service.instance_eval do unique_helper_xyz() end` dispatches the bare
// call on `service`, so the class never being named here proves nothing.
const src =
'def caller(service)\n service.instance_eval do\n unique_helper_xyz()\n end\nend\n';
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/b.rb'),
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
parsedFileOf: ownerFile('def:Billing', 'Class'),
sourceTextOf: () => src,
}),
).toBe(true);
// ...and still REFUSES when the source has no such block.
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/b.rb'),
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
parsedFileOf: ownerFile('def:Billing', 'Class'),
sourceTextOf: () => 'def caller\n unique_helper_xyz()\nend\n',
}),
).toBe(false);
// A missing source text is an unanswered question, not a refusal.
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/b.rb'),
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
parsedFileOf: ownerFile('def:Billing', 'Class'),
sourceTextOf: () => undefined,
}),
).toBe(true);
});
it('does not refuse an owned method with no nameable namespace', () => {
expect(
rubyIsGlobalNameFallbackPlausible({
callerParsed: mkCaller('app/b.rb'),
candidate: mkCandidate('app/a.rb', 'unique_helper_xyz', 'def:anon'),
parsedFileOf: ownerFile('def:anon', 'Class'),
}),
).toBe(true);
});
});

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

View file

@ -0,0 +1,152 @@
/**
* M17 — the `export *` wildcard fan-out in `populateFileClosure`
* (gitnexus-shared/src/scope-resolution/finalize-algorithm.ts) is gated by the
* SAME `namedImportsBindTopLevelOnly` hook as the named-import path:
*
* for (const [name, def] of (topLevelOnly ? indexTopLevelExportsByName : indexExportsByName)(...))
*
* Before this fix, the wildcard fan-out ALWAYS used the narrow (top-level-only)
* index, regardless of the hook — silently adopting ECMAScript's `export *`
* semantics (a class member can never be published by a bare wildcard
* re-export) for every language, including ones (Python, Java, ...) whose
* wildcard/star import legitimately republishes class members by name.
*
* This is below the extraction layer (RFC #909 Ring 2 PKG #921) — synthetic
* `ParsedFile` input against `finalizeScopeModel` with a FAKE resolver
* (`namedImportsBindTopLevelOnly` toggled directly), same technique as
* `finalize-orchestrator.test.ts`. No real language parser involved; the
* fixture below is deliberately language-agnostic (Vue is the one migrated
* resolver that opts in for real — see `languages/vue/scope-resolver.ts`).
*
* Fixture shape, held constant across both hook settings:
* B.ts: class Foo with method `beta` — NO top-level `beta` declaration.
* A.ts: `export * from './B'` (wildcard re-export; populates A's closure).
* C.ts: `import { beta } from './A'` — resolves through A's closure, which
* the direct check on A's own (empty) localDefs never satisfies.
*/
import { describe, it, expect } from 'vitest';
import type { ParsedFile, ParsedImport, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import { finalizeScopeModel } from '../../../src/core/ingestion/finalize-orchestrator.js';
import { vueScopeResolver } from '../../../src/core/ingestion/languages/vue/scope-resolver.js';
import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js';
import { javascriptScopeResolver } from '../../../src/core/ingestion/languages/javascript/scope-resolver.js';
const mkScope = (id: ScopeId, filePath: string): Scope => ({
id,
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 },
filePath,
bindings: new Map(),
ownedDefs: [],
imports: [],
typeBindings: new Map(),
});
const mkFile = (filePath: string, overrides: Partial<ParsedFile> = {}): ParsedFile => ({
filePath,
moduleScope: `scope:${filePath}#module`,
scopes: overrides.scopes ?? [mkScope(`scope:${filePath}#module`, filePath)],
parsedImports: overrides.parsedImports ?? [],
localDefs: overrides.localDefs ?? [],
referenceSites: overrides.referenceSites ?? [],
});
function buildFixture(topLevelOnly: boolean) {
// B.ts: a class with a method `beta`, and NO top-level `beta` of any kind.
const fooClass: SymbolDefinition = {
nodeId: 'def:Foo',
filePath: 'B.ts',
type: 'Class',
qualifiedName: 'B.Foo',
};
const fooBetaMethod: SymbolDefinition = {
nodeId: 'def:Foo.beta',
filePath: 'B.ts',
type: 'Method',
ownerId: 'def:Foo',
qualifiedName: 'B.Foo.beta',
};
const fileB = mkFile('B.ts', { localDefs: [fooClass, fooBetaMethod] });
// A.ts: `export * from './B'` — a wildcard re-export, no local defs of its own.
const wildcardImport: ParsedImport = { kind: 'wildcard', targetRaw: 'B.ts' };
const fileA = mkFile('A.ts', { parsedImports: [wildcardImport] });
// C.ts: `import { beta } from './A'`.
const namedImport: ParsedImport = {
kind: 'named',
localName: 'beta',
importedName: 'beta',
targetRaw: 'A.ts',
};
const fileC = mkFile('C.ts', { parsedImports: [namedImport] });
const out = finalizeScopeModel([fileB, fileA, fileC], {
hooks: {
resolveImportTarget: (targetRaw) => targetRaw,
namedImportsBindTopLevelOnly: topLevelOnly,
},
});
const cImports = out.imports.get(fileC.moduleScope) ?? [];
return { out, fileC, fooBetaMethod, betaImport: cImports[0] };
}
describe('M17 — export * wildcard fan-out gated by namedImportsBindTopLevelOnly', () => {
it('a language that does NOT opt in (Python/Java-shaped: hook false) publishes the class method through the wildcard — wide index preserved', () => {
const { betaImport, fooBetaMethod } = buildFixture(false);
expect(betaImport).toBeDefined();
expect(betaImport!.linkStatus).toBeUndefined();
expect(betaImport!.targetFile).toBe('A.ts');
expect(betaImport!.targetDefId).toBe(fooBetaMethod.nodeId);
});
it('a language that DOES opt in (ECMAScript-shaped: hook true) refuses — the wildcard fan-out narrows to module-level declarations only', () => {
const { betaImport } = buildFixture(true);
expect(betaImport).toBeDefined();
// Neither A's own (empty) localDefs nor A's wildcard-populated closure
// (narrowed to MEMBER_LABELS-excluded defs) ever publish `beta` — the
// import stays unresolved rather than binding a class member no
// top-level name legitimizes.
expect(betaImport!.linkStatus).toBe('unresolved');
expect(betaImport!.targetDefId).toBeUndefined();
});
it('mutation check: a top-level (non-member) def behind the same wildcard still binds under EITHER setting', () => {
// Control — proves the gate narrows MEMBER labels specifically, not
// wildcard re-exports wholesale.
for (const topLevelOnly of [false, true]) {
const alphaVar: SymbolDefinition = {
nodeId: 'def:alpha',
filePath: 'B.ts',
type: 'Variable',
qualifiedName: 'B.alpha',
};
const fileB = mkFile('B.ts', { localDefs: [alphaVar] });
const fileA = mkFile('A.ts', {
parsedImports: [{ kind: 'wildcard', targetRaw: 'B.ts' }],
});
const fileC = mkFile('C.ts', {
parsedImports: [
{ kind: 'named', localName: 'alpha', importedName: 'alpha', targetRaw: 'A.ts' },
],
});
const out = finalizeScopeModel([fileB, fileA, fileC], {
hooks: {
resolveImportTarget: (targetRaw) => targetRaw,
namedImportsBindTopLevelOnly: topLevelOnly,
},
});
const edge = out.imports.get(fileC.moduleScope)?.[0];
expect(edge?.linkStatus, `topLevelOnly=${topLevelOnly}`).toBeUndefined();
expect(edge?.targetDefId, `topLevelOnly=${topLevelOnly}`).toBe('def:alpha');
}
});
it('Vue opts in (TS semantics); JS/TS themselves already do — every migrated resolver that sets the hook does so as `true`', () => {
expect(vueScopeResolver.namedImportsBindTopLevelOnly).toBe(true);
expect(typescriptScopeResolver.namedImportsBindTopLevelOnly).toBe(true);
expect(javascriptScopeResolver.namedImportsBindTopLevelOnly).toBe(true);
});
});