mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-09 22:33:39 +00:00
fix(resolution): address review findings on #3182
Bot findings (13) and human findings (magyargergo, 6) on the scope-resolution precision PR, each classified and fixed minimally in the PR's spirit — never guess: refuse where impossible, label where undecidable. Bot findings: - finalize-algorithm.ts:1026 (Variable excluded from wildcard collisions) → TP. Introduced tri-state SymbolDefinition.isExported (export evidence supplied by the TS/JS capture emitters via @declaration.is-exported). With evidence an exported Variable collides; without it the prior exclusion stands. - typescript/scope-resolver.ts:138 (private `function foo` counted as a wildcard provider) → TP. A def marked isExported:false is neither a collision candidate nor published by the `export *` closure (indexTopLevelExportsByName). - node-workspace-packages.ts:474 (rejection evicts newer memo entry) → TP. Delete only when the memo still holds this load's own promise. - dart/name-fallback-visibility.ts:68 (cross-directory `part`) → TP. The Dart query captures no `part` directive, so cross-file `_` names are undecidable: the refusal is downgraded to a labeled guess (never refused) and documented. - go/name-fallback-visibility.ts:116 (root-package exception before _test.go) → TP. A candidate in any other package's `_test.go` is refused first, before the module-root and import checks. - go/package-siblings.ts:76 (real package named `foo_test`) → TP. `_test` is stripped only when a non-test sibling in the directory declares the stripped name (or no non-test sibling exists); a declared `foo_test` package keeps it. - rust/name-fallback-visibility.ts:91 (`use crate:🅰️:other` authorizes every item of `a`) → TP. The parent-path match now requires the imported item to be the candidate; a module path match or glob still authorizes the module. - ruby/name-fallback-visibility.ts:155 (`includes(constant)` substring) → TP. Qualified mentions are matched segment-wise on `::` / `.`. - name-fallback-summary.ts:104 (callsByLanguage never supplied) → TP. The pipeline now builds resolvedCalleeNamesByCaller through the emit sink (complete under streaming) and run-analyze feeds countCallsByLanguage. - free-call-fallback.ts:710 (dedup keeps first site's label) → TP, see below. - barrel-wildcard-arrow-const.test.ts:22 (fixture cleanup) → nit, try/finally. - utils/name-fallback-visibility.ts:24 (`crate::` comment) → nit, corrected. - community-processor.ts:318 ("only these four") → nit, five. Human findings (magyargergo): - finalize-algorithm.ts:1374 (nested function admitted as top-level export) → TP. The export marker stops at nesting boundaries (function bodies, class bodies, object literals), so the nested def is isExported:false and skipped; additionally resolveDefGraphId fails closed before the label-agnostic simple key when a function-local callable of that name exists in the file, so a non-callable def can no longer alias onto `wrapper.selected`. - javascript/scope-resolver.ts:105 (CJS `module.exports = { alpha() {} }` + destructured require) → TP. Members of the module.exports object literal are marked isExported:true, and the top-level export index admits a marked member regardless of its Method label. Regression covers the duplicate-name case. - finalize-algorithm.ts:1049 (class member shadows a star-vs-star collision) → TP. The shadow set is limited to module-level bindings (canShadowWildcard). - node-workspace-packages.ts:730 (vite lib.entry matched inside a comment) → TP. Comments are stripped (stripJsComments) and every live lib.entry is a candidate, so two disagreeing entries are refused as ambiguous. - ruby/name-fallback-visibility.ts:160 (instance_eval rebinds self) → TP. A caller file using instance_eval/class_eval/instance_exec/class_exec/ module_eval/module_exec keeps class-owned guesses labeled instead of refused (file-level, source-text based — the site cannot tell its enclosing block). - free-call-fallback.ts:710 (ordering; overlaps the bot finding) → TP. The deduplicated edge's label is decided at flush from every collapsed site: one site resolved through a real binding proves the edge (0.85 import-resolved); it is a guess only when all sites were guesses. Order never decides. Validation note: integration tests run the parse workers from gitnexus/dist, so `npm run build` in gitnexus/ is required before the suite reflects capture changes (a stale dist masked the marker in the first run). Full suite: 20660 passed, 15 failed, all in the known environmental set (analyzer-identity*, evidence-provenance-helper, hooks linked-worktree, pinned-install retry, git-utils ENOTEMPTY). Overlaps between bot and human: free-call-fallback.ts:710 ordering (both), and the finalize-algorithm collision set (bot: Variable candidates; human: member shadowing and nested functions) — all resolved by the same export-evidence mechanism. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dSxjDEzuYDENrR314EiFc
This commit is contained in:
parent
6d3ac0d8ee
commit
c5a4c2a63b
29 changed files with 1661 additions and 98 deletions
|
|
@ -1000,19 +1000,28 @@ function namedReexportCandidates(
|
||||||
* property. The wildcard closure loop tolerates the wider set because nobody
|
* property. The wildcard closure loop tolerates the wider set because nobody
|
||||||
* imports a property by name; a refusal cannot afford the same tolerance.
|
* imports a property by name; a refusal cannot afford the same tolerance.
|
||||||
*
|
*
|
||||||
* `Variable` is excluded too. `SymbolDefinition` carries no export marker, and
|
* Export evidence, when the language supplies it (`SymbolDefinition.isExported`,
|
||||||
* the typical top-level `const` in a barrel's sources is module-private
|
* tri-state), settles the rest: a def marked `false` is module-private and is
|
||||||
* (`const category = ['Axis']` in fourteen option-builder files) — the closure
|
* neither a provider here nor published by the closure
|
||||||
* would never be asked for it, so refusing it protects nothing and would refuse
|
* (`indexTopLevelExportsByName`), so a private `function foo` beside an exported
|
||||||
* a real exported constant of the same name. Residual risk, accepted: a
|
* one no longer refuses the export — and, the half that matters more, cannot be
|
||||||
* non-exported `function`/`class` sharing its name with an exported one behind
|
* the first-listed winner the closure binds either. A def marked `true` counts
|
||||||
* the same barrel is counted as a collision and the export is refused — a
|
* whatever its label, `Variable` included: the closure publishes a `Variable`,
|
||||||
* missing edge, never a wrong one. `ownerId` is only set for class members, so
|
* so two sources each exporting `const alpha` are a real collision and must be
|
||||||
|
* refused rather than first-wins.
|
||||||
|
*
|
||||||
|
* Without evidence (`isExported` undefined — most languages) `Variable` is
|
||||||
|
* excluded from the COLLISION set only: the typical top-level `const` in a
|
||||||
|
* barrel's sources is module-private (`const category = ['Axis']` in fourteen
|
||||||
|
* option-builder files), so counting it would refuse a real exported constant
|
||||||
|
* of the same name for nothing. Residual risk, accepted, for that evidence-free
|
||||||
|
* case: a non-exported `function`/`class` sharing its name with an exported one
|
||||||
|
* behind the same barrel is counted as a collision and the export is refused —
|
||||||
|
* a missing edge, never a wrong one. `ownerId` is only set for class members, so
|
||||||
* a callable nested in an object literal (`showIf: (cfg) => …` across fourteen
|
* a callable nested in an object literal (`showIf: (cfg) => …` across fourteen
|
||||||
* option-builder files) still counts as a provider; `SymbolDefinition` carries
|
* option-builder files) still counts as a provider when unmarked. Measured
|
||||||
* neither a scope nor an export marker to do better with. Measured after both
|
* before the export marker existed: grafana@871af0720 refuses 52 names (from
|
||||||
* exclusions: grafana@871af0720 refuses 52 names (from 2,640 before them),
|
* 2,640 before the member exclusion), discourse@3f71fa15c 5.
|
||||||
* discourse@3f71fa15c 5.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Labels that are never a module export, whatever their owner. */
|
/** Labels that are never a module export, whatever their owner. */
|
||||||
|
|
@ -1023,7 +1032,8 @@ const NON_EXPORTABLE_MEMBER_LABELS: readonly string[] = [
|
||||||
'Parameter',
|
'Parameter',
|
||||||
'Field',
|
'Field',
|
||||||
];
|
];
|
||||||
const NON_EXPORTABLE_LABELS: ReadonlySet<string> = new Set([
|
/** Labels excluded from the collision set when no export evidence is present. */
|
||||||
|
const UNMARKED_NON_COLLIDING_LABELS: ReadonlySet<string> = new Set([
|
||||||
...NON_EXPORTABLE_MEMBER_LABELS,
|
...NON_EXPORTABLE_MEMBER_LABELS,
|
||||||
'Variable',
|
'Variable',
|
||||||
]);
|
]);
|
||||||
|
|
@ -1034,9 +1044,34 @@ const NON_EXPORTABLE_LABELS: ReadonlySet<string> = new Set([
|
||||||
*/
|
*/
|
||||||
const MEMBER_LABELS: ReadonlySet<string> = new Set(NON_EXPORTABLE_MEMBER_LABELS);
|
const MEMBER_LABELS: ReadonlySet<string> = new Set(NON_EXPORTABLE_MEMBER_LABELS);
|
||||||
|
|
||||||
/** A declaration `export *` could publish: top-level and of an exportable kind. */
|
/**
|
||||||
|
* A declaration `export *` could publish, for COLLISION purposes: top-level, of
|
||||||
|
* an exportable kind, and not marked module-private. With export evidence the
|
||||||
|
* label rule yields to the marker (an exported `Variable` collides; a private
|
||||||
|
* `function` does not); without it `Variable` is left out — see the header.
|
||||||
|
*/
|
||||||
function isWildcardPublishable(def: SymbolDefinition): boolean {
|
function isWildcardPublishable(def: SymbolDefinition): boolean {
|
||||||
return def.ownerId === undefined && !NON_EXPORTABLE_LABELS.has(def.type);
|
// Explicit evidence wins over the label: a CommonJS `module.exports = {
|
||||||
|
// alpha() {} }` member is labeled Method and IS the module's export.
|
||||||
|
if (def.isExported === true) return true;
|
||||||
|
if (def.isExported === false) return false;
|
||||||
|
if (def.ownerId !== undefined) return false;
|
||||||
|
return !UNMARKED_NON_COLLIDING_LABELS.has(def.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can a declaration of the barrel's OWN shadow a name its `export *` sources
|
||||||
|
* collide on? Only a module-level binding can — ECMAScript's explicit-export
|
||||||
|
* precedence is about the module's own exports. A class MEMBER named `clash`
|
||||||
|
* (`export class Unrelated { clash() {} }`) is not such a binding and must not
|
||||||
|
* switch the collision check off; it did, and a confident edge to one source's
|
||||||
|
* `clash` was emitted where the import should have been refused.
|
||||||
|
*/
|
||||||
|
function canShadowWildcard(def: SymbolDefinition): boolean {
|
||||||
|
if (def.isExported === true) return true;
|
||||||
|
if (def.isExported === false) return false;
|
||||||
|
if (def.ownerId !== undefined) return false;
|
||||||
|
return !MEMBER_LABELS.has(def.type);
|
||||||
}
|
}
|
||||||
function collectAmbiguousWildcards(
|
function collectAmbiguousWildcards(
|
||||||
file: FinalizeFile,
|
file: FinalizeFile,
|
||||||
|
|
@ -1045,6 +1080,7 @@ function collectAmbiguousWildcards(
|
||||||
): ReadonlyMap<string, readonly string[]> {
|
): ReadonlyMap<string, readonly string[]> {
|
||||||
const shadowed = new Set<string>();
|
const shadowed = new Set<string>();
|
||||||
for (const def of file.localDefs) {
|
for (const def of file.localDefs) {
|
||||||
|
if (!canShadowWildcard(def)) continue;
|
||||||
const name = deriveSimpleName(def);
|
const name = deriveSimpleName(def);
|
||||||
if (name !== null) shadowed.add(name);
|
if (name !== null) shadowed.add(name);
|
||||||
}
|
}
|
||||||
|
|
@ -1354,10 +1390,17 @@ function indexExportsByName(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `indexExportsByName` restricted to declarations `export *` can publish:
|
* `indexExportsByName` restricted to declarations a module publishes by name:
|
||||||
* members (by LABEL — `ownerId` is stamped by a later reconcile pass and is not
|
* members (by LABEL — `ownerId` is stamped by a later reconcile pass and is not
|
||||||
* reliable while the closure is built) are skipped; `Variable` stays, since a
|
* reliable while the closure is built) are skipped unless the language marked
|
||||||
* barrel legitimately republishes a `const`. Same memoization contract.
|
* them exported (a CommonJS `module.exports = { alpha() {} }` member), and so
|
||||||
|
* is any def the language marked module-private (`isExported === false`) — a
|
||||||
|
* function nested inside another function carries the Function label and used
|
||||||
|
* to displace the real exported value of the same name here; a barrel cannot
|
||||||
|
* republish what its source never exported, and binding it would put a private
|
||||||
|
* `function foo` in front of the exported one another source provides.
|
||||||
|
* `Variable` stays, since a barrel legitimately republishes a `const`. Same
|
||||||
|
* memoization contract.
|
||||||
*/
|
*/
|
||||||
const TOP_LEVEL_EXPORTS_BY_NAME = new WeakMap<
|
const TOP_LEVEL_EXPORTS_BY_NAME = new WeakMap<
|
||||||
readonly SymbolDefinition[],
|
readonly SymbolDefinition[],
|
||||||
|
|
@ -1371,7 +1414,11 @@ function indexTopLevelExportsByName(
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
const index = new Map<string, SymbolDefinition>();
|
const index = new Map<string, SymbolDefinition>();
|
||||||
for (const d of defs) {
|
for (const d of defs) {
|
||||||
if (MEMBER_LABELS.has(d.type)) continue;
|
// Evidence over label, both ways: a marked-private def (a function nested
|
||||||
|
// in another function carries the Function label too) is skipped, and a
|
||||||
|
// marked-exported member (`module.exports = { alpha() {} }`) is admitted.
|
||||||
|
if (d.isExported === false) continue;
|
||||||
|
if (d.isExported !== true && MEMBER_LABELS.has(d.type)) continue;
|
||||||
const name = deriveSimpleName(d);
|
const name = deriveSimpleName(d);
|
||||||
if (name === null) continue;
|
if (name === null) continue;
|
||||||
const existing = index.get(name);
|
const existing = index.get(name);
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,18 @@ export interface SymbolDefinition {
|
||||||
* source (for example an anonymous class). Consumers may use this only as a
|
* source (for example an anonymous class). Consumers may use this only as a
|
||||||
* conservative priority hint; it does not change graph-node identity. */
|
* conservative priority hint; it does not change graph-node identity. */
|
||||||
isSynthetic?: boolean;
|
isSynthetic?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the producing language saw EXPORT EVIDENCE on this declaration —
|
||||||
|
* an `export` modifier, a later `export { name }` specifier, an `export
|
||||||
|
* default name`. TRI-STATE, and the absence is load-bearing: `undefined`
|
||||||
|
* means the language emitted no verdict (most languages, and any ECMAScript
|
||||||
|
* file whose export surface is a CommonJS assignment the marker cannot read),
|
||||||
|
* which readers MUST treat as "unknown" and fall back to their prior
|
||||||
|
* behavior. Only `false` says "this module does not publish the name": a
|
||||||
|
* `false` keeps a module-private `function foo` from being counted as a
|
||||||
|
* wildcard provider or bound through a barrel's `export *` closure.
|
||||||
|
*/
|
||||||
|
isExported?: boolean;
|
||||||
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
|
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
/** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`)
|
/** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`)
|
||||||
|
|
|
||||||
|
|
@ -313,7 +313,7 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun
|
||||||
const connectedNodes = new Set<string>();
|
const connectedNodes = new Set<string>();
|
||||||
const nodeDegree = new Map<string, number>();
|
const nodeDegree = new Map<string, number>();
|
||||||
|
|
||||||
// Field-wise scan (#2680): this walks every edge and reads only these four,
|
// Field-wise scan (#2680): this walks every edge and reads only these five,
|
||||||
// so taking objects would allocate one per edge for nothing.
|
// so taking objects would allocate one per edge for nothing.
|
||||||
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence, reason) => {
|
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence, reason) => {
|
||||||
if (!isClusteringRelationship(type) || sourceId === targetId) return;
|
if (!isClusteringRelationship(type) || sourceId === targetId) return;
|
||||||
|
|
|
||||||
|
|
@ -471,8 +471,14 @@ export async function loadNodeWorkspacePackages(
|
||||||
const key = path.resolve(repoRoot);
|
const key = path.resolve(repoRoot);
|
||||||
const cached = workspacePackagesMemo.get(key);
|
const cached = workspacePackagesMemo.get(key);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
const pending = loadNodeWorkspacePackagesUncached(repoRoot).catch((err: unknown) => {
|
const pending: Promise<NodeWorkspacePackages | null> = loadNodeWorkspacePackagesUncached(
|
||||||
workspacePackagesMemo.delete(key);
|
repoRoot,
|
||||||
|
).catch((err: unknown) => {
|
||||||
|
// Evict only OUR entry. If this load was invalidated while in flight and a
|
||||||
|
// newer load has since been installed under the same key, deleting by key
|
||||||
|
// alone would evict that one, and every later caller would start another
|
||||||
|
// full scan instead of joining it.
|
||||||
|
if (workspacePackagesMemo.get(key) === pending) workspacePackagesMemo.delete(key);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
workspacePackagesMemo.set(key, pending);
|
workspacePackagesMemo.set(key, pending);
|
||||||
|
|
@ -726,9 +732,16 @@ async function discoverSourceEntries(
|
||||||
}
|
}
|
||||||
for (const cfg of ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']) {
|
for (const cfg of ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']) {
|
||||||
try {
|
try {
|
||||||
const text = await fs.readFile(path.join(dir, cfg), 'utf-8');
|
const text = stripJsComments(await fs.readFile(path.join(dir, cfg), 'utf-8'));
|
||||||
const match = /lib\s*:\s*\{[^}]*?entry\s*:\s*['"]([^'"]+)['"]/s.exec(text);
|
// EVERY `lib: { entry: '…' }` in the live text is a candidate, not the
|
||||||
if (match !== null) candidates.push(rebase(match[1]!));
|
// first: a stale `lib` object left in the file (or a second one under a
|
||||||
|
// conditional) is a competing claim, and two claims are an ambiguity the
|
||||||
|
// `existing.length > 1` rule below refuses. Comments are stripped first —
|
||||||
|
// a commented-out `// old lib: { entry: 'src/wrong.ts' }` used to be the
|
||||||
|
// first match and became the package's entry.
|
||||||
|
for (const match of text.matchAll(/lib\s*:\s*\{[^}]*?entry\s*:\s*['"]([^'"]+)['"]/gs)) {
|
||||||
|
push(candidates, rebase(match[1]!));
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* no such config */
|
/* no such config */
|
||||||
}
|
}
|
||||||
|
|
@ -747,6 +760,41 @@ async function discoverSourceEntries(
|
||||||
return { entries: existing, ambiguous: [] };
|
return { entries: existing, ambiguous: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove line (`//`) and block comments from JS/TS config text before a regex
|
||||||
|
* reads it. String contents are preserved (a `//` inside quotes is not a
|
||||||
|
* comment), so `entry: 'src/index.ts'` survives, as does a comment opener
|
||||||
|
* written inside a string.
|
||||||
|
*/
|
||||||
|
export function stripJsComments(text: string): string {
|
||||||
|
let out = '';
|
||||||
|
let i = 0;
|
||||||
|
while (i < text.length) {
|
||||||
|
const ch = text[i]!;
|
||||||
|
const next = text[i + 1];
|
||||||
|
if (ch === '"' || ch === "'" || ch === '`') {
|
||||||
|
const quote = ch;
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < text.length && text[j] !== quote) {
|
||||||
|
if (text[j] === '\\') j++;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
out += text.slice(i, j + 1);
|
||||||
|
i = j + 1;
|
||||||
|
} else if (ch === '/' && next === '/') {
|
||||||
|
const end = text.indexOf('\n', i);
|
||||||
|
i = end === -1 ? text.length : end;
|
||||||
|
} else if (ch === '/' && next === '*') {
|
||||||
|
const end = text.indexOf('*/', i + 2);
|
||||||
|
i = end === -1 ? text.length : end + 2;
|
||||||
|
} else {
|
||||||
|
out += ch;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** A repo-relative stem exists as a source file (with any TS/JS extension). */
|
/** A repo-relative stem exists as a source file (with any TS/JS extension). */
|
||||||
// The root is threaded explicitly: a module-level "current root" clobbered
|
// The root is threaded explicitly: a module-level "current root" clobbered
|
||||||
// under two concurrent scans and turned an ambiguity refusal into a confident
|
// under two concurrent scans and turned an ambiguity refusal into a confident
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,23 @@
|
||||||
* imported by any spelling. So a `_`-prefixed candidate in another file is an
|
* imported by any spelling. So a `_`-prefixed candidate in another file is an
|
||||||
* impossible call, not an unlikely one.
|
* impossible call, not an unlikely one.
|
||||||
*
|
*
|
||||||
* "Library" is approximated by "the same DIRECTORY". The exact boundary is
|
* The exact boundary is `part` / `part of` — one library spanning several
|
||||||
* `part` / `part of` — one library spanning several files, with `_` names
|
* files, with `_` names shared between them — and the extractor does not
|
||||||
* shared between them — and the extractor does not surface `part` directives
|
* surface `part` directives yet (the Dart query captures only `library_import`;
|
||||||
* yet (nothing in `languages/dart/` reads them). Refusing on "same file" would
|
* nothing in `languages/dart/` reads `part`). Without them a cross-file `_`
|
||||||
* therefore delete real edges on Flutter's dominant generated-code idiom:
|
* candidate is UNDECIDABLE, not impossible: refusing on "different file" would
|
||||||
* `factory Foo.fromJson(j) => _$FooFromJson(j)` calls into `foo.g.dart`, a
|
* delete real edges on Flutter's dominant generated-code idiom (`factory
|
||||||
* `part` of the same library that sits beside it. Parts are, in practice,
|
* Foo.fromJson(j) => _$FooFromJson(j)` calls into `foo.g.dart`, a `part` beside
|
||||||
* always siblings of their library file, so a same-directory `_` candidate is
|
* it), and refusing on "different directory" is wrong too — a `part` URI is a
|
||||||
* treated as plausible (and stays a LABELED edge); only a `_` candidate in
|
* relative URI and legally traverses directories (`part '../shared/gen.dart';`).
|
||||||
* another directory is refused, which no `part` layout can make legal. A caller
|
* An earlier version refused the cross-directory case as "no `part` layout can
|
||||||
* that names the candidate's file in a directive is also accepted, for the day
|
* make this legal"; that claim was false, so the hook now REFUSES NOTHING and
|
||||||
* the extractor surfaces `part` as an import target.
|
* every cross-file `_` candidate stays a LABELED edge (0.5 /
|
||||||
|
* `global-name-fallback`), which is the honest answer until `part` is
|
||||||
|
* extracted. A caller that names the candidate's file in a directive is
|
||||||
|
* recognized already, for the day the extractor surfaces `part` as an import
|
||||||
|
* target; at that point "not the same library" becomes decidable and the
|
||||||
|
* refusal can return.
|
||||||
*
|
*
|
||||||
* Public names are left to the labeled-edge path. Dart does require an import
|
* Public names are left to the labeled-edge path. Dart does require an import
|
||||||
* for a cross-library public name, but the fallback exists partly to recover
|
* for a cross-library public name, but the fallback exists partly to recover
|
||||||
|
|
@ -29,7 +34,6 @@
|
||||||
|
|
||||||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||||
import {
|
import {
|
||||||
directoryOf,
|
|
||||||
modulePathReaches,
|
modulePathReaches,
|
||||||
stripExtension,
|
stripExtension,
|
||||||
} from '../../scope-resolution/utils/name-fallback-visibility.js';
|
} from '../../scope-resolution/utils/name-fallback-visibility.js';
|
||||||
|
|
@ -63,8 +67,11 @@ export function dartIsGlobalNameFallbackPlausible(ctx: {
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
|
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
|
||||||
if (!isPrivateDartName(ctx.candidate)) return true;
|
if (!isPrivateDartName(ctx.candidate)) return true;
|
||||||
// Sibling files may be `part`s of one library (see the header) — undecidable
|
// A directive naming the candidate's file is positive evidence of one library.
|
||||||
// without `part` extraction, so allowed rather than refused.
|
if (sharesLibrary(ctx.callerParsed, ctx.candidate.filePath)) return true;
|
||||||
if (directoryOf(ctx.candidate.filePath) === directoryOf(ctx.callerParsed.filePath)) return true;
|
// Any other file may be a `part` of the caller's library — a sibling or, via
|
||||||
return sharesLibrary(ctx.callerParsed, ctx.candidate.filePath);
|
// a relative `part` URI, a file in another directory. Undecidable without
|
||||||
|
// `part` extraction (see the header), so allowed as a labeled guess, never
|
||||||
|
// refused.
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,13 @@ import {
|
||||||
} from '../../scope-resolution/utils/name-fallback-visibility.js';
|
} from '../../scope-resolution/utils/name-fallback-visibility.js';
|
||||||
import { inferGoPackageName } from './package-clause.js';
|
import { inferGoPackageName } from './package-clause.js';
|
||||||
|
|
||||||
/** `foo_test` → `foo`; mirrors `package-siblings.ts`. */
|
/**
|
||||||
|
* `foo_test` → `foo`. `package-siblings.ts` refines this with the directory's
|
||||||
|
* non-test package clauses (a package genuinely NAMED `foo_test` keeps its
|
||||||
|
* name there); this hook sees one file at a time and cannot, so such a
|
||||||
|
* package's internal tests are classified external here. Refuse-only tier, so
|
||||||
|
* the cost is a missed same-package guess, never a wrong edge.
|
||||||
|
*/
|
||||||
function internalPackageOf(pkgName: string): string {
|
function internalPackageOf(pkgName: string): string {
|
||||||
return pkgName.endsWith('_test') && pkgName.length > '_test'.length
|
return pkgName.endsWith('_test') && pkgName.length > '_test'.length
|
||||||
? pkgName.slice(0, -'_test'.length)
|
? pkgName.slice(0, -'_test'.length)
|
||||||
|
|
@ -101,6 +107,13 @@ export function goIsGlobalNameFallbackPlausible(ctx: {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Different directory, so a different package — and a `_test.go` file's
|
||||||
|
// declarations are compiled only into ITS OWN package's test binary. No other
|
||||||
|
// package, test or not, can see them, exported or not. Decidable from the
|
||||||
|
// path alone, so it comes before every exception below (the module-root
|
||||||
|
// exception in particular used to accept a root `helper_test.go` export).
|
||||||
|
if (classifyGoFile(ctx.candidate.filePath, ctx.sourceTextOf).isTest) return false;
|
||||||
|
|
||||||
const simpleName = goSimpleName(ctx.candidate);
|
const simpleName = goSimpleName(ctx.candidate);
|
||||||
// No identifier to read the case of — an unanswered question, not a refusal.
|
// No identifier to read the case of — an unanswered question, not a refusal.
|
||||||
if (simpleName === '') return true;
|
if (simpleName === '') return true;
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,34 @@ function isGoTestFile(filePath: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `foo_test` → `foo`; a package name that is not an external-test name → itself.
|
* The package a `_test.go` file's clause belongs to, given the package names
|
||||||
* Known miss (never a wrong edge): a package genuinely NAMED `foo_test` has its
|
* the directory's NON-test files declare.
|
||||||
* internal `_test.go` files keyed as external tests of `foo`, so they see no
|
*
|
||||||
* unexported siblings. Disambiguating needs the directory's non-test clause.
|
* `package foo_test` is the external-test convention ONLY when the directory's
|
||||||
|
* real package is `foo`; the `_test` suffix is otherwise a legal identifier
|
||||||
|
* (`package foo_test` in a directory whose non-test files also say `foo_test`).
|
||||||
|
* Stripping it unconditionally keyed such a package's own internal tests as
|
||||||
|
* external tests of a non-existent `foo`, so they saw no sibling at all — a
|
||||||
|
* resolution miss on every same-package call. Strip only when the stripped
|
||||||
|
* name is what the non-test siblings declare; with no non-test sibling to ask
|
||||||
|
* (a test-only directory) the convention is assumed, as before.
|
||||||
*/
|
*/
|
||||||
function internalPackageOf(pkgName: string): string {
|
function testFilePackageOf(
|
||||||
return pkgName.endsWith('_test') && pkgName.length > '_test'.length
|
declared: string,
|
||||||
? pkgName.slice(0, -'_test'.length)
|
nonTestPackagesInDir: ReadonlySet<string> | undefined,
|
||||||
: pkgName;
|
): { readonly pkg: string; readonly external: boolean } {
|
||||||
|
if (!declared.endsWith('_test') || declared.length <= '_test'.length) {
|
||||||
|
return { pkg: declared, external: false };
|
||||||
|
}
|
||||||
|
const stripped = declared.slice(0, -'_test'.length);
|
||||||
|
if (nonTestPackagesInDir !== undefined && nonTestPackagesInDir.has(declared)) {
|
||||||
|
return { pkg: declared, external: false };
|
||||||
|
}
|
||||||
|
if (nonTestPackagesInDir === undefined || nonTestPackagesInDir.has(stripped)) {
|
||||||
|
return { pkg: stripped, external: true };
|
||||||
|
}
|
||||||
|
// Neither name is declared by a non-test sibling: keep the clause as written.
|
||||||
|
return { pkg: declared, external: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function populateGoPackageSiblings(
|
export function populateGoPackageSiblings(
|
||||||
|
|
@ -64,16 +83,31 @@ export function populateGoPackageSiblings(
|
||||||
readonly external: boolean;
|
readonly external: boolean;
|
||||||
}
|
}
|
||||||
const filesByPackage = new Map<string, SiblingFile[]>();
|
const filesByPackage = new Map<string, SiblingFile[]>();
|
||||||
|
// Same derivation as `populateGoWorkspaceOwners` — one shared resolver, so
|
||||||
|
// the two passes cannot disagree about a file's package (#2837). The
|
||||||
|
// no-clause case is reported there; warning twice for one fact would be
|
||||||
|
// noise.
|
||||||
|
const declaredByFile = new Map<string, string>();
|
||||||
|
const nonTestPackagesByDir = new Map<string, Set<string>>();
|
||||||
for (const parsed of parsedFiles) {
|
for (const parsed of parsedFiles) {
|
||||||
// Same derivation as `populateGoWorkspaceOwners` — one shared resolver, so
|
|
||||||
// the two passes cannot disagree about a file's package (#2837). The
|
|
||||||
// no-clause case is reported there; warning twice for one fact would be
|
|
||||||
// noise.
|
|
||||||
const declared = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
|
const declared = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
|
||||||
if (declared === null) continue;
|
if (declared === null) continue;
|
||||||
|
declaredByFile.set(parsed.filePath, declared);
|
||||||
|
if (isGoTestFile(parsed.filePath)) continue;
|
||||||
|
const dir = goPackageDir(parsed.filePath);
|
||||||
|
const names = nonTestPackagesByDir.get(dir) ?? new Set<string>();
|
||||||
|
names.add(declared);
|
||||||
|
nonTestPackagesByDir.set(dir, names);
|
||||||
|
}
|
||||||
|
for (const parsed of parsedFiles) {
|
||||||
|
const declared = declaredByFile.get(parsed.filePath);
|
||||||
|
if (declared === undefined) continue;
|
||||||
const isTest = isGoTestFile(parsed.filePath);
|
const isTest = isGoTestFile(parsed.filePath);
|
||||||
const external = isTest && declared !== internalPackageOf(declared);
|
const dir = goPackageDir(parsed.filePath);
|
||||||
const key = `${goPackageDir(parsed.filePath)}\0${isTest ? internalPackageOf(declared) : declared}`;
|
const { pkg, external } = isTest
|
||||||
|
? testFilePackageOf(declared, nonTestPackagesByDir.get(dir))
|
||||||
|
: { pkg: declared, external: false };
|
||||||
|
const key = `${dir}\0${pkg}`;
|
||||||
const list = filesByPackage.get(key) ?? [];
|
const list = filesByPackage.get(key) ?? [];
|
||||||
list.push({ filePath: parsed.filePath, defs: [...parsed.localDefs], isTest, external });
|
list.push({ filePath: parsed.filePath, defs: [...parsed.localDefs], isTest, external });
|
||||||
filesByPackage.set(key, list);
|
filesByPackage.set(key, list);
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
syntheticCapture,
|
syntheticCapture,
|
||||||
type SyntaxNode,
|
type SyntaxNode,
|
||||||
} from '../../utils/ast-helpers.js';
|
} from '../../utils/ast-helpers.js';
|
||||||
|
import { collectEsmExportEvidence, esmExportVerdict } from '../../ts-js-export-marker.js';
|
||||||
import { splitImportStatement } from '../typescript/import-decomposer.js';
|
import { splitImportStatement } from '../typescript/import-decomposer.js';
|
||||||
import { getJsParser, getJsScopeQuery, jsCachedTreeMatchesGrammar } from './query.js';
|
import { getJsParser, getJsScopeQuery, jsCachedTreeMatchesGrammar } from './query.js';
|
||||||
import { computeTsArityMetadata } from '../typescript/arity-metadata.js';
|
import { computeTsArityMetadata } from '../typescript/arity-metadata.js';
|
||||||
|
|
@ -983,6 +984,8 @@ export function emitJsScopeCaptures(
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawMatches = getJsScopeQuery(filePath).matches(tree.rootNode);
|
const rawMatches = getJsScopeQuery(filePath).matches(tree.rootNode);
|
||||||
|
// Export evidence, read once per file (see `ts-js-export-marker.ts`).
|
||||||
|
const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath);
|
||||||
const out: CaptureMatch[] = [];
|
const out: CaptureMatch[] = [];
|
||||||
|
|
||||||
for (const m of rawMatches) {
|
for (const m of rawMatches) {
|
||||||
|
|
@ -1207,6 +1210,20 @@ export function emitJsScopeCaptures(
|
||||||
// non-call match, an absent receiver, or a chain with no nameable base
|
// non-call match, an absent receiver, or a chain with no nameable base
|
||||||
// all leave `grouped` untouched.
|
// all leave `grouped` untouched.
|
||||||
synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']);
|
synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']);
|
||||||
|
// `@declaration.is-exported`: a verdict for every declaration the file's
|
||||||
|
// export surface can decide (see `ts-js-export-marker.ts`); nothing where
|
||||||
|
// it cannot, because absence is the honest answer there.
|
||||||
|
const declNameNode = groupedNodes['@declaration.name'];
|
||||||
|
if (exportEvidence !== undefined && declNameNode !== undefined) {
|
||||||
|
const verdict = esmExportVerdict(declNameNode, exportEvidence);
|
||||||
|
if (verdict !== undefined) {
|
||||||
|
grouped['@declaration.is-exported'] = syntheticCapture(
|
||||||
|
'@declaration.is-exported',
|
||||||
|
declNameNode,
|
||||||
|
verdict ? 'true' : 'false',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
out.push(grouped);
|
out.push(grouped);
|
||||||
|
|
||||||
// Synthesize `this` receiver type-bindings on class member functions.
|
// Synthesize `this` receiver type-bindings on class member functions.
|
||||||
|
|
|
||||||
|
|
@ -107,14 +107,35 @@ function ownerLabelOf(
|
||||||
return owner?.type;
|
return owner?.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls that rebind `self` for the duration of a block: inside
|
||||||
|
* `service.instance_eval do … end` a bare `helper()` is dispatched on
|
||||||
|
* `service`, so it legitimately reaches a class-owned method the caller file
|
||||||
|
* never names. Detected on the caller's SOURCE TEXT, not the call site — the
|
||||||
|
* site does not know which block encloses it — so any file that uses one of
|
||||||
|
* these forms keeps its class-owned guesses LABELED rather than refused. Coarse
|
||||||
|
* in the safe direction: it loses refusals in that file, never an edge.
|
||||||
|
*/
|
||||||
|
const SELF_REBINDING_CALL =
|
||||||
|
/\b(?:instance_eval|instance_exec|class_eval|class_exec|module_eval|module_exec)\b/;
|
||||||
|
|
||||||
export function rubyIsGlobalNameFallbackPlausible(ctx: {
|
export function rubyIsGlobalNameFallbackPlausible(ctx: {
|
||||||
readonly callerParsed: ParsedFile;
|
readonly callerParsed: ParsedFile;
|
||||||
readonly candidate: SymbolDefinition;
|
readonly candidate: SymbolDefinition;
|
||||||
readonly parsedFileOf?: (filePath: string) => ParsedFile | undefined;
|
readonly parsedFileOf?: (filePath: string) => ParsedFile | undefined;
|
||||||
|
readonly sourceTextOf?: (filePath: string) => string | undefined;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
|
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
|
||||||
// Top-level method — the autoload shape the fallback exists for.
|
// Top-level method — the autoload shape the fallback exists for.
|
||||||
if (ctx.candidate.ownerId === undefined) return true;
|
if (ctx.candidate.ownerId === undefined) return true;
|
||||||
|
// A self-rebinding block anywhere in the caller makes "the class is never
|
||||||
|
// named here" no proof of impossibility (see `SELF_REBINDING_CALL`). The
|
||||||
|
// pipeline always supplies the source; a missing text is an unanswered
|
||||||
|
// question and keeps the labeled edge as well.
|
||||||
|
if (ctx.sourceTextOf !== undefined) {
|
||||||
|
const text = ctx.sourceTextOf(ctx.callerParsed.filePath);
|
||||||
|
if (text === undefined || SELF_REBINDING_CALL.test(text)) return true;
|
||||||
|
}
|
||||||
// Only a CLASS body makes a bare cross-file call impossible without naming
|
// Only a CLASS body makes a bare cross-file call impossible without naming
|
||||||
// it (see the header). A module owner, or an owner we cannot type, is not a
|
// it (see the header). A module owner, or an owner we cannot type, is not a
|
||||||
// refusal.
|
// refusal.
|
||||||
|
|
@ -148,11 +169,16 @@ export function rubyIsGlobalNameFallbackPlausible(ctx: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A bare mention of the constant anywhere in the caller (`Billing::Invoice`,
|
// A bare mention of the constant anywhere in the caller (`Billing::Invoice`,
|
||||||
// `Invoice.new`) is enough to make the namespace present in this file.
|
// `Invoice.new`) is enough to make the namespace present in this file. The
|
||||||
|
// qualified spelling is matched SEGMENT-wise on `::` / `.`: `InvoiceService`
|
||||||
|
// is not a mention of `Invoice`, and a substring test made it one.
|
||||||
for (const site of ctx.callerParsed.referenceSites) {
|
for (const site of ctx.callerParsed.referenceSites) {
|
||||||
for (const constant of constants) {
|
for (const constant of constants) {
|
||||||
if (site.name === constant) return true;
|
if (site.name === constant) return true;
|
||||||
if (site.rawQualifiedName !== undefined && site.rawQualifiedName.includes(constant)) {
|
if (
|
||||||
|
site.rawQualifiedName !== undefined &&
|
||||||
|
site.rawQualifiedName.split(/::|\./).some((segment) => segment === constant)
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,14 +82,41 @@ export function rustIsGlobalNameFallbackPlausible(ctx: {
|
||||||
// than refuse on an unanswered question.
|
// than refuse on an unanswered question.
|
||||||
if (candidateModule === '') return true;
|
if (candidateModule === '') return true;
|
||||||
|
|
||||||
|
const candidateName = rustSimpleNameOf(ctx.candidate);
|
||||||
for (const imp of ctx.callerParsed.parsedImports) {
|
for (const imp of ctx.callerParsed.parsedImports) {
|
||||||
const usePath = rustUsePathOf(imp.targetRaw);
|
const usePath = rustUsePathOf(imp.targetRaw);
|
||||||
|
// The `use` path itself names the candidate's module (`use crate::a;`, a
|
||||||
|
// glob `use crate::a::*`, or a decomposed form whose source is the module):
|
||||||
|
// the module was brought into scope. Tolerant on purpose — see the header
|
||||||
|
// of `modulePathReaches` on which direction is the safe one.
|
||||||
if (modulePathReaches(usePath, candidateModule)) return true;
|
if (modulePathReaches(usePath, candidateModule)) return true;
|
||||||
// A `use` names an ITEM as often as a module (`use crate::user::User`), and
|
if (imp.kind === 'wildcard') continue;
|
||||||
// whether `targetRaw` includes that final name varies by import form. Try
|
// Otherwise the path names ONE item inside a module (`use crate::a::other`).
|
||||||
// the parent path too, or a module-only match would be missed.
|
// Its PARENT is the candidate's module only if that item IS the candidate:
|
||||||
|
// importing `other` says nothing about a `helper` in `a`, and the bare
|
||||||
|
// parent-path match used to accept every item of `a` on its strength.
|
||||||
|
// Matched on the ORIGINAL name (`importedName`): an alias renames the local
|
||||||
|
// handle, so the edge it authorizes is the one written under the alias.
|
||||||
|
if (importedNameOf(imp) !== candidateName) continue;
|
||||||
const parent = usePath.slice(0, Math.max(0, usePath.lastIndexOf('::')));
|
const parent = usePath.slice(0, Math.max(0, usePath.lastIndexOf('::')));
|
||||||
if (parent !== '' && modulePathReaches(parent, candidateModule)) return true;
|
if (parent !== '' && modulePathReaches(parent, candidateModule)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The identifier a `use` binds, as written at its source (`importedName`). */
|
||||||
|
function importedNameOf(imp: ParsedFile['parsedImports'][number]): string | undefined {
|
||||||
|
return 'importedName' in imp ? imp.importedName : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identifier a Rust declaration contributes to its module: the FIRST
|
||||||
|
* segment of `qualifiedName` after any module prefix — `User` for `User.new`
|
||||||
|
* (an associated function is reached through its type, so it is the type the
|
||||||
|
* `use` must name), the bare name for a free function.
|
||||||
|
*/
|
||||||
|
function rustSimpleNameOf(candidate: SymbolDefinition): string {
|
||||||
|
const qualified = candidate.qualifiedName ?? '';
|
||||||
|
const segments = qualified.split(/::|\./).filter((s) => s !== '');
|
||||||
|
return segments[0] ?? '';
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
syntheticCapture,
|
syntheticCapture,
|
||||||
type SyntaxNode,
|
type SyntaxNode,
|
||||||
} from '../../utils/ast-helpers.js';
|
} from '../../utils/ast-helpers.js';
|
||||||
|
import { collectEsmExportEvidence, esmExportVerdict } from '../../ts-js-export-marker.js';
|
||||||
import { splitImportStatement } from './import-decomposer.js';
|
import { splitImportStatement } from './import-decomposer.js';
|
||||||
import { getTsParser, getTsScopeQuery, tsCachedTreeMatchesGrammar } from './query.js';
|
import { getTsParser, getTsScopeQuery, tsCachedTreeMatchesGrammar } from './query.js';
|
||||||
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
|
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
|
||||||
|
|
@ -388,6 +389,8 @@ export function emitTsScopeCaptures(
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawMatches = getTsScopeQuery(filePath).matches(tree.rootNode);
|
const rawMatches = getTsScopeQuery(filePath).matches(tree.rootNode);
|
||||||
|
// Export evidence, read once per file (see `ts-js-export-marker.ts`).
|
||||||
|
const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath);
|
||||||
const out: CaptureMatch[] = [];
|
const out: CaptureMatch[] = [];
|
||||||
|
|
||||||
for (const m of rawMatches) {
|
for (const m of rawMatches) {
|
||||||
|
|
@ -660,6 +663,20 @@ export function emitTsScopeCaptures(
|
||||||
// instead of re-parsing the receiver's source text. Self-gating: a
|
// instead of re-parsing the receiver's source text. Self-gating: a
|
||||||
// non-call match, an absent receiver, or a chain with no nameable base
|
// non-call match, an absent receiver, or a chain with no nameable base
|
||||||
// all leave `grouped` untouched.
|
// all leave `grouped` untouched.
|
||||||
|
// `@declaration.is-exported`: a verdict for every declaration the file's
|
||||||
|
// export surface can decide (see `ts-js-export-marker.ts`); nothing where
|
||||||
|
// it cannot, because absence is the honest answer there.
|
||||||
|
const declNameNode = groupedNodes['@declaration.name'];
|
||||||
|
if (exportEvidence !== undefined && declNameNode !== undefined) {
|
||||||
|
const verdict = esmExportVerdict(declNameNode, exportEvidence);
|
||||||
|
if (verdict !== undefined) {
|
||||||
|
grouped['@declaration.is-exported'] = syntheticCapture(
|
||||||
|
'@declaration.is-exported',
|
||||||
|
declNameNode,
|
||||||
|
verdict ? 'true' : 'false',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']);
|
synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']);
|
||||||
out.push(grouped);
|
out.push(grouped);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createKnowledgeGraph } from '../graph/graph.js';
|
import { createKnowledgeGraph } from '../graph/graph.js';
|
||||||
|
import type { KnowledgeGraph } from '../graph/types.js';
|
||||||
import { GraphEmitSink, type GraphEmitManifest } from '../lbug/graph-emit-sink.js';
|
import { GraphEmitSink, type GraphEmitManifest } from '../lbug/graph-emit-sink.js';
|
||||||
import { type PipelineProgress } from 'gitnexus-shared';
|
import { type PipelineProgress } from 'gitnexus-shared';
|
||||||
import { PipelineResult } from '../../types/pipeline.js';
|
import { PipelineResult } from '../../types/pipeline.js';
|
||||||
|
|
@ -410,6 +411,11 @@ export const runPipelineFromRepo = async (
|
||||||
graphEmitSink?.close();
|
graphEmitSink?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolved-call index for the name-fallback census: read through the SINK,
|
||||||
|
// whose field-wise scan includes every streamed edge, not through `graph`,
|
||||||
|
// which under streaming holds none of them.
|
||||||
|
const resolvedCalleeNamesByCaller = collectResolvedCalleeNames(graphEmitSink ?? graph, graph);
|
||||||
|
|
||||||
// Extract final results for the PipelineResult contract
|
// Extract final results for the PipelineResult contract
|
||||||
const {
|
const {
|
||||||
totalFiles,
|
totalFiles,
|
||||||
|
|
@ -473,6 +479,7 @@ export const runPipelineFromRepo = async (
|
||||||
communityResult,
|
communityResult,
|
||||||
processResult,
|
processResult,
|
||||||
resolutionOutcomes,
|
resolutionOutcomes,
|
||||||
|
resolvedCalleeNamesByCaller,
|
||||||
undecidedSatisfaction,
|
undecidedSatisfaction,
|
||||||
usedWorkerPool,
|
usedWorkerPool,
|
||||||
reparsedFileCount,
|
reparsedFileCount,
|
||||||
|
|
@ -518,3 +525,29 @@ export const runPipelineFromRepo = async (
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caller node id → the simple names of every callee it has a CALLS edge to.
|
||||||
|
*
|
||||||
|
* `edges` may be the streaming sink or the raw graph; `nodes` is always the raw
|
||||||
|
* graph, which holds every node in both modes (only relationships stream). One
|
||||||
|
* O(E) field-wise pass, allocation-free per edge except for the per-caller set.
|
||||||
|
*/
|
||||||
|
export function collectResolvedCalleeNames(
|
||||||
|
edges: Pick<KnowledgeGraph, 'forEachRelationshipFields'>,
|
||||||
|
nodes: Pick<KnowledgeGraph, 'getNode'>,
|
||||||
|
): ReadonlyMap<string, ReadonlySet<string>> {
|
||||||
|
const out = new Map<string, Set<string>>();
|
||||||
|
edges.forEachRelationshipFields((sourceId, targetId, type) => {
|
||||||
|
if (type !== 'CALLS') return;
|
||||||
|
const name = nodes.getNode(targetId)?.properties.name;
|
||||||
|
if (typeof name !== 'string' || name === '') return;
|
||||||
|
let names = out.get(sourceId);
|
||||||
|
if (names === undefined) {
|
||||||
|
names = new Set<string>();
|
||||||
|
out.set(sourceId, names);
|
||||||
|
}
|
||||||
|
names.add(name);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -707,6 +707,10 @@ function buildDefFromDeclarationMatch(
|
||||||
const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']);
|
const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']);
|
||||||
const isDeleted = parseBooleanCapture(match['@declaration.is-deleted']);
|
const isDeleted = parseBooleanCapture(match['@declaration.is-deleted']);
|
||||||
const isSynthetic = parseBooleanCapture(match['@declaration.is-synthetic']);
|
const isSynthetic = parseBooleanCapture(match['@declaration.is-synthetic']);
|
||||||
|
// Tri-state on purpose: only a producer that saw the file's export surface
|
||||||
|
// emits the marker, and both `true` and `false` are verdicts (see
|
||||||
|
// `SymbolDefinition.isExported`). Absent stays absent.
|
||||||
|
const isExported = parseBooleanCapture(match['@declaration.is-exported']);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodeId: makeDefId(filePath, anchor.range, type, nameCap.text),
|
nodeId: makeDefId(filePath, anchor.range, type, nameCap.text),
|
||||||
|
|
@ -725,6 +729,7 @@ function buildDefFromDeclarationMatch(
|
||||||
...(isExplicit === true ? { isExplicit: true } : {}),
|
...(isExplicit === true ? { isExplicit: true } : {}),
|
||||||
...(isDeleted === true ? { isDeleted: true } : {}),
|
...(isDeleted === true ? { isDeleted: true } : {}),
|
||||||
...(isSynthetic === true ? { isSynthetic: true } : {}),
|
...(isSynthetic === true ? { isSynthetic: true } : {}),
|
||||||
|
...(isExported !== undefined ? { isExported } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -503,9 +503,25 @@ function resolveDefGraphIdUncached(
|
||||||
if (qualifiedHit !== undefined) return qualifiedHit;
|
if (qualifiedHit !== undefined) return qualifiedHit;
|
||||||
}
|
}
|
||||||
const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1);
|
const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1);
|
||||||
|
// FAIL CLOSED before the label-agnostic simple key when a FUNCTION-LOCAL
|
||||||
|
// callable of this name exists in the file. The guards above cover a def
|
||||||
|
// that is itself a callable; a NON-callable def (`export const selected =
|
||||||
|
// factory()`, a `Variable` with no graph node of its own) used to fall
|
||||||
|
// through here and alias onto `wrapper.selected`, the function-local one
|
||||||
|
// (#3182 review). Whatever the def's label, a bare name matching a local
|
||||||
|
// callable is the aliasing this key cannot tell apart, and a missing edge
|
||||||
|
// is the correct failure direction.
|
||||||
|
for (const localLabel of LOCAL_CALLABLE_LABELS) {
|
||||||
|
if (nodeLookup.get(localNameKey(filePath, localLabel, simpleName)) !== undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
return nodeLookup.get(simpleKey(filePath, simpleName));
|
return nodeLookup.get(simpleKey(filePath, simpleName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Labels the structure phase registers function-local declarations under. */
|
||||||
|
const LOCAL_CALLABLE_LABELS: readonly NodeLabel[] = ['Function', 'Method'];
|
||||||
|
|
||||||
/** Derive the simple (unqualified) name of a def from its `qualifiedName`. */
|
/** Derive the simple (unqualified) name of a def from its `qualifiedName`. */
|
||||||
export function simpleQualifiedName(def: SymbolDefinition): string | undefined {
|
export function simpleQualifiedName(def: SymbolDefinition): string | undefined {
|
||||||
const q = def.qualifiedName;
|
const q = def.qualifiedName;
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,17 @@ export function emitFreeCallFallback(
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const parsed of parsedFiles) {
|
for (const parsed of parsedFiles) {
|
||||||
type PendingRel = { rel: Parameters<KnowledgeGraph['addRelationship']>[0]; gatedAll: boolean };
|
type PendingRel = {
|
||||||
|
rel: Parameters<KnowledgeGraph['addRelationship']>[0];
|
||||||
|
gatedAll: boolean;
|
||||||
|
/**
|
||||||
|
* The confidence/reason a PRECISELY resolved site (a real binding, not a
|
||||||
|
* unique-name guess) proved for this edge; `undefined` while every site
|
||||||
|
* collapsed into it so far was a guess. Decided at flush, not by the
|
||||||
|
* first site the walk met.
|
||||||
|
*/
|
||||||
|
precise: { confidence: number; reason: string } | undefined;
|
||||||
|
};
|
||||||
const pending = new Map<string, PendingRel>();
|
const pending = new Map<string, PendingRel>();
|
||||||
const bindingCandidatesByScope =
|
const bindingCandidatesByScope =
|
||||||
options.freeCallsRequireInstanceOwnership === true
|
options.freeCallsRequireInstanceOwnership === true
|
||||||
|
|
@ -686,44 +696,62 @@ export function emitFreeCallFallback(
|
||||||
// that collapses into it, so a callee reached from one live site and one
|
// that collapses into it, so a callee reached from one live site and one
|
||||||
// dead site stays live whichever site the walk meets first. Emission is
|
// dead site stays live whichever site the walk meets first. Emission is
|
||||||
// deferred to the end of this file's sites for that reason.
|
// deferred to the end of this file's sites for that reason.
|
||||||
|
const preciseHere = fnDefFromGlobalNameFallback
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
confidence: 0.85,
|
||||||
|
// Match legacy DAG's reason convention so consumers that
|
||||||
|
// assert `reason === 'import-resolved'` keep working. The
|
||||||
|
// construction-site marker is opt-in for the same reason.
|
||||||
|
reason: constructionSiteReason(
|
||||||
|
fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
|
||||||
|
site,
|
||||||
|
options.markConstructionSites,
|
||||||
|
),
|
||||||
|
};
|
||||||
const pendingRel = pending.get(relId);
|
const pendingRel = pending.get(relId);
|
||||||
if (pendingRel !== undefined) {
|
if (pendingRel !== undefined) {
|
||||||
if (site.staticGated !== true) pendingRel.gatedAll = false;
|
if (site.staticGated !== true) pendingRel.gatedAll = false;
|
||||||
|
// The edge's label is decided at flush time from EVERY site that
|
||||||
|
// collapsed into it, not from whichever the walk met first. One site
|
||||||
|
// resolved through a real binding PROVES the dependency; a guessed
|
||||||
|
// site for the same pair is then redundant evidence, not a taint.
|
||||||
|
if (pendingRel.precise === undefined) pendingRel.precise = preciseHere;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (seen.has(relId)) continue;
|
if (seen.has(relId)) continue;
|
||||||
seen.add(relId);
|
seen.add(relId);
|
||||||
pending.set(relId, {
|
pending.set(relId, {
|
||||||
gatedAll: site.staticGated === true,
|
gatedAll: site.staticGated === true,
|
||||||
|
precise: preciseHere,
|
||||||
rel: {
|
rel: {
|
||||||
id: relId,
|
id: relId,
|
||||||
sourceId: callerGraphId,
|
sourceId: callerGraphId,
|
||||||
targetId: tgtGraphId,
|
targetId: tgtGraphId,
|
||||||
type: 'CALLS',
|
type: 'CALLS',
|
||||||
// A name guess is not an import resolution and must not be spelled like
|
// Guess values as placeholders; decided at flush from `precise`.
|
||||||
// one. It used to be emitted at 0.85 / `'import-resolved'`, which made
|
confidence: 0.5,
|
||||||
// it indistinguishable from an edge a real import produced — so every
|
reason: GLOBAL_NAME_FALLBACK_REASON,
|
||||||
// consumer that wanted to discount guesses had no field to do it with.
|
|
||||||
// 0.5 is the deliberate "coin flip" value, and the reason is what the
|
|
||||||
// process/community walks and the MCP tools actually key on, because
|
|
||||||
// 0.5 sits exactly ON their thresholds (see graph/edge-reasons.ts).
|
|
||||||
confidence: fnDefFromGlobalNameFallback ? 0.5 : 0.85,
|
|
||||||
reason: fnDefFromGlobalNameFallback
|
|
||||||
? GLOBAL_NAME_FALLBACK_REASON
|
|
||||||
: // Match legacy DAG's reason convention so consumers that
|
|
||||||
// assert `reason === 'import-resolved'` keep working. The
|
|
||||||
// construction-site marker is opt-in for the same reason.
|
|
||||||
constructionSiteReason(
|
|
||||||
fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
|
|
||||||
site,
|
|
||||||
options.markConstructionSites,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
emitted++;
|
emitted++;
|
||||||
}
|
}
|
||||||
for (const { rel, gatedAll } of pending.values()) {
|
for (const { rel, gatedAll, precise } of pending.values()) {
|
||||||
graph.addRelationship(gatedAll ? { ...rel, staticGated: true } : rel);
|
// A name guess is not an import resolution and must not be spelled like
|
||||||
|
// one. It used to be emitted at 0.85 / `'import-resolved'`, which made
|
||||||
|
// it indistinguishable from an edge a real import produced — so every
|
||||||
|
// consumer that wanted to discount guesses had no field to do it with.
|
||||||
|
// 0.5 is the deliberate "coin flip" value, and the reason is what the
|
||||||
|
// process/community walks and the MCP tools actually key on, because
|
||||||
|
// 0.5 sits exactly ON their thresholds (see graph/edge-reasons.ts).
|
||||||
|
// An edge is a guess only when EVERY site that collapsed into it was one;
|
||||||
|
// a single precisely resolved site proves it, whatever order the walk
|
||||||
|
// met the sites in. Independent of `gatedAll`.
|
||||||
|
const labeled =
|
||||||
|
precise !== undefined
|
||||||
|
? { ...rel, confidence: precise.confidence, reason: precise.reason }
|
||||||
|
: rel;
|
||||||
|
graph.addRelationship(gatedAll ? { ...labeled, staticGated: true } : labeled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return emitted;
|
return emitted;
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,10 @@ export function directoryOf(filePath: string): string {
|
||||||
/**
|
/**
|
||||||
* Split a module path into segments, accepting the three separators languages
|
* Split a module path into segments, accepting the three separators languages
|
||||||
* spell module nesting with: `/` (Go, Node), `::` (Rust, C++) and `.` (JVM,
|
* spell module nesting with: `/` (Go, Node), `::` (Rust, C++) and `.` (JVM,
|
||||||
* Python). Empty segments are dropped, so a leading `./` or `crate::` prefix
|
* Python). Empty segments and `.` are dropped, so a leading `./` contributes
|
||||||
* contributes nothing.
|
* nothing. Named root prefixes are NOT stripped here — `crate::a` yields
|
||||||
|
* `['crate', 'a']`; a language whose paths carry one (Rust's `crate::` /
|
||||||
|
* `super::`) removes it before calling, see `rustUsePathOf`.
|
||||||
*
|
*
|
||||||
* `.` is only treated as a separator when the path contains no `/`: a Node
|
* `.` is only treated as a separator when the path contains no `/`: a Node
|
||||||
* specifier like `./util/parse.js` must not split on the extension dot.
|
* specifier like `./util/parse.js` must not split on the extension dot.
|
||||||
|
|
|
||||||
146
gitnexus/src/core/ingestion/ts-js-export-marker.ts
Normal file
146
gitnexus/src/core/ingestion/ts-js-export-marker.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
/**
|
||||||
|
* Export evidence for ECMAScript declarations — the `@declaration.is-exported`
|
||||||
|
* marker both the TypeScript and the JavaScript capture emitters synthesize.
|
||||||
|
*
|
||||||
|
* `SymbolDefinition.isExported` is tri-state, and this is where the three
|
||||||
|
* states are decided for TS/JS:
|
||||||
|
*
|
||||||
|
* - `true` — the declaration sits under an `export_statement` (`export
|
||||||
|
* function f`, `export const x`, `export default class`), or the
|
||||||
|
* file names it in an `export { f }` / `export { f as g }` clause
|
||||||
|
* or an `export default f` / `export = f` statement.
|
||||||
|
* - `false` — an ESM-shaped file (no CommonJS export assignment) that does
|
||||||
|
* neither. The declaration is module-private: `export *` cannot
|
||||||
|
* republish it and it must not be counted as a wildcard provider.
|
||||||
|
* - no verdict — the file exports through CommonJS (`module.exports = …`,
|
||||||
|
* `exports.x = …`, top-level `this.x = …`) or is an ambient
|
||||||
|
* `.d.ts`, where "not under `export`" says nothing about what the
|
||||||
|
* module publishes. Nothing is emitted, and the reader keeps its
|
||||||
|
* prior behavior. One CommonJS shape IS decidable and gets `true`:
|
||||||
|
* a method or property declared directly in the object literal
|
||||||
|
* assigned to `module.exports` (`module.exports = { alpha() {} }`)
|
||||||
|
* is that module's export of `alpha`.
|
||||||
|
*
|
||||||
|
* Only a declaration reached from the top level through DECLARATION nodes can
|
||||||
|
* be a module export. The walk therefore stops with `false` at the first
|
||||||
|
* nesting boundary — a class body, an interface/enum body, a function body, an
|
||||||
|
* object literal that is not the `module.exports` value: `export class C {
|
||||||
|
* m() {} }` exports `C`, not `m`; `function w() { function s() {} }` exports
|
||||||
|
* nothing even when the file has `export { s }` for a different `s`.
|
||||||
|
*
|
||||||
|
* The ancestor walk here is deliberately NOT `tsExportChecker`
|
||||||
|
* (`export-detection.ts`): that checker's text fallback (`text.startsWith('export ')`)
|
||||||
|
* fires on the `program` node of any file whose first token is `export`, which
|
||||||
|
* would mark every declaration in such a file exported.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SyntaxNode } from './utils/ast-helpers.js';
|
||||||
|
|
||||||
|
export interface EsmExportEvidence {
|
||||||
|
/** Local names published by `export { … }`, `export default <id>`, `export = <id>`. */
|
||||||
|
readonly namedLocals: ReadonlySet<string>;
|
||||||
|
/** The file exports through a CommonJS assignment: a plain "not under
|
||||||
|
* `export`" is no verdict there. */
|
||||||
|
readonly commonJs: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CJS_EXPORT_ASSIGNMENT = /^\s*(module\.exports\b|exports\s*[.[]|this\.[A-Za-z_$][\w$]*\s*=)/;
|
||||||
|
const CJS_SURFACE = /\bmodule\.exports\b|\bexports\s*[.[=]/;
|
||||||
|
|
||||||
|
/** Node types below which a declaration is nested, not module-level. */
|
||||||
|
const NESTING_BOUNDARIES: ReadonlySet<string> = new Set([
|
||||||
|
'class_body',
|
||||||
|
'interface_body',
|
||||||
|
'enum_body',
|
||||||
|
'object_type',
|
||||||
|
'statement_block',
|
||||||
|
'arrow_function',
|
||||||
|
'function_expression',
|
||||||
|
'function_declaration',
|
||||||
|
'generator_function',
|
||||||
|
'generator_function_declaration',
|
||||||
|
'method_definition',
|
||||||
|
'internal_module',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a file's top level once. `undefined` means the file's export surface
|
||||||
|
* cannot be read at all (ambient `.d.ts`), so no marker should be emitted.
|
||||||
|
*/
|
||||||
|
export function collectEsmExportEvidence(
|
||||||
|
root: SyntaxNode,
|
||||||
|
filePath: string,
|
||||||
|
): EsmExportEvidence | undefined {
|
||||||
|
if (filePath.endsWith('.d.ts')) return undefined;
|
||||||
|
const namedLocals = new Set<string>();
|
||||||
|
// Any CommonJS export surface anywhere in the file — a direct `module.exports
|
||||||
|
// = …`, an alias (`const m = module.exports; m.x = …`), an `exports.x` — means
|
||||||
|
// "not under `export`" says nothing. Text-level on purpose: the alias forms
|
||||||
|
// are open-ended and a missed one would mark a real export private.
|
||||||
|
let commonJs = CJS_SURFACE.test(root.text);
|
||||||
|
for (const stmt of root.namedChildren) {
|
||||||
|
if (stmt.type === 'export_statement') {
|
||||||
|
for (const child of stmt.namedChildren) {
|
||||||
|
if (child.type === 'export_clause') {
|
||||||
|
for (const spec of child.namedChildren) {
|
||||||
|
if (spec.type !== 'export_specifier') continue;
|
||||||
|
const name = spec.childForFieldName('name')?.text;
|
||||||
|
if (name !== undefined && name !== '') namedLocals.add(name);
|
||||||
|
}
|
||||||
|
} else if (child.type === 'identifier') {
|
||||||
|
// `export default f;` and TS `export = f;`.
|
||||||
|
namedLocals.add(child.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (stmt.type === 'expression_statement' && CJS_EXPORT_ASSIGNMENT.test(stmt.text)) {
|
||||||
|
commonJs = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { namedLocals, commonJs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is `object` the value of a top-level `module.exports = { … }` assignment? */
|
||||||
|
function isModuleExportsObject(object: SyntaxNode): boolean {
|
||||||
|
const assignment = object.parent;
|
||||||
|
if (assignment === null || assignment.type !== 'assignment_expression') return false;
|
||||||
|
if (assignment.childForFieldName('right')?.id !== object.id) return false;
|
||||||
|
const left = assignment.childForFieldName('left');
|
||||||
|
if (left === null || left.type !== 'member_expression') return false;
|
||||||
|
if (left.childForFieldName('object')?.text !== 'module') return false;
|
||||||
|
if (left.childForFieldName('property')?.text !== 'exports') return false;
|
||||||
|
return (
|
||||||
|
assignment.parent?.type === 'expression_statement' &&
|
||||||
|
assignment.parent.parent?.type === 'program'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The export verdict for a declaration whose NAME node is `nameNode`:
|
||||||
|
* `true` / `false` as documented in the header, `undefined` when this file's
|
||||||
|
* export surface cannot decide it (CommonJS, other than the `module.exports`
|
||||||
|
* object literal itself).
|
||||||
|
*/
|
||||||
|
export function esmExportVerdict(
|
||||||
|
nameNode: SyntaxNode,
|
||||||
|
evidence: EsmExportEvidence,
|
||||||
|
): boolean | undefined {
|
||||||
|
// The name node's own declaration node is where the walk starts; the
|
||||||
|
// declaration itself (a `method_definition`, a `function_declaration`) must
|
||||||
|
// not count as its own nesting boundary.
|
||||||
|
let current: SyntaxNode | null = nameNode.parent;
|
||||||
|
while (current !== null && current.type !== 'program') {
|
||||||
|
if (current.type === 'export_statement') return true;
|
||||||
|
if (current.type === 'object') {
|
||||||
|
// `module.exports = { alpha() {} }`: the literal's own members are the
|
||||||
|
// module's exports. Any other object literal is a nesting boundary.
|
||||||
|
if (isModuleExportsObject(current) && nameNode.parent?.parent?.id === current.id) return true;
|
||||||
|
return evidence.commonJs ? undefined : false;
|
||||||
|
}
|
||||||
|
if (NESTING_BOUNDARIES.has(current.type) && current.id !== nameNode.parent?.id) {
|
||||||
|
return evidence.commonJs ? undefined : false;
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
if (evidence.commonJs) return undefined;
|
||||||
|
return evidence.namedLocals.has(nameNode.text);
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import { invalidateNodeWorkspacePackages } from './ingestion/import-resolvers/no
|
||||||
import {
|
import {
|
||||||
logNameFallbackSummary,
|
logNameFallbackSummary,
|
||||||
summarizeNameFallback,
|
summarizeNameFallback,
|
||||||
|
countCallsByLanguage,
|
||||||
} from './ingestion/scope-resolution/name-fallback-summary.js';
|
} from './ingestion/scope-resolution/name-fallback-summary.js';
|
||||||
import { runPipelineFromRepo } from './ingestion/pipeline.js';
|
import { runPipelineFromRepo } from './ingestion/pipeline.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -3872,7 +3873,10 @@ async function runFullAnalysisInner(
|
||||||
// Census of name-guessed CALLS edges (labeled `global-name-fallback`), refused
|
// Census of name-guessed CALLS edges (labeled `global-name-fallback`), refused
|
||||||
// impossibles and ambiguous `export *` names — the honesty readout for this
|
// impossibles and ambiguous `export *` names — the honesty readout for this
|
||||||
// run's resolution. Logged, and persisted below as `nameFallbackEdges`.
|
// run's resolution. Logged, and persisted below as `nameFallbackEdges`.
|
||||||
const nameFallbackSummary = summarizeNameFallback(resolutionOutcomes);
|
const nameFallbackSummary = summarizeNameFallback(
|
||||||
|
resolutionOutcomes,
|
||||||
|
countCallsByLanguage(pipelineResult.resolvedCalleeNamesByCaller, pipelineResult.graph),
|
||||||
|
);
|
||||||
logNameFallbackSummary(nameFallbackSummary);
|
logNameFallbackSummary(nameFallbackSummary);
|
||||||
|
|
||||||
// Annotated so the capabilities stamp below is compile-checked against
|
// Annotated so the capabilities stamp below is compile-checked against
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@ export interface PipelineResult {
|
||||||
* produced; graph edge semantics are unchanged.
|
* produced; graph edge semantics are unchanged.
|
||||||
*/
|
*/
|
||||||
resolutionOutcomes: readonly ResolutionOutcome[];
|
resolutionOutcomes: readonly ResolutionOutcome[];
|
||||||
|
/**
|
||||||
|
* Caller node id → simple names of every callee it has a CALLS edge to, read
|
||||||
|
* through the streaming sink when one was active (the raw graph holds no
|
||||||
|
* streamed edge). Denominator for the name-fallback census
|
||||||
|
* (`countCallsByLanguage`), so a guess count can be read as a share of the
|
||||||
|
* call graph. Absent only when scope resolution did not run.
|
||||||
|
*/
|
||||||
|
resolvedCalleeNamesByCaller?: ReadonlyMap<string, ReadonlySet<string>>;
|
||||||
/**
|
/**
|
||||||
* Interfaces whose structural-satisfaction check could not be completed
|
* Interfaces whose structural-satisfaction check could not be completed
|
||||||
* (#2873). Empty for languages with no structural detection.
|
* (#2873). Empty for languages with no structural detection.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -18,14 +18,16 @@ async function run(name: string, extra: Record<string, string>, remove: string[]
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-c2b-${name}-`));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-c2b-${name}-`));
|
||||||
const files: Record<string, string> = { ...base, ...extra };
|
const files: Record<string, string> = { ...base, ...extra };
|
||||||
for (const r of remove) delete files[r];
|
for (const r of remove) delete files[r];
|
||||||
writeFixtureRepo(dir, files);
|
try {
|
||||||
const result = await runPipelineFromRepo(dir, () => {});
|
writeFixtureRepo(dir, files);
|
||||||
const targets = getRelationships(result, 'CALLS')
|
const result = await runPipelineFromRepo(dir, () => {});
|
||||||
.filter((e) => e.sourceFilePath.includes('packages/app/src/main'))
|
return getRelationships(result, 'CALLS')
|
||||||
.map((e) => e.target)
|
.filter((e) => e.sourceFilePath.includes('packages/app/src/main'))
|
||||||
.sort();
|
.map((e) => e.target)
|
||||||
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
.sort();
|
||||||
return targets;
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
describe('C2 probe 2', () => {
|
describe('C2 probe 2', () => {
|
||||||
it('E: impl is a .tsx file with plain function exports', async () => {
|
it('E: impl is a .tsx file with plain function exports', async () => {
|
||||||
|
|
|
||||||
115
gitnexus/test/unit/node-workspace-memo-rejection.test.ts
Normal file
115
gitnexus/test/unit/node-workspace-memo-rejection.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
140
gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts
Normal file
140
gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
/**
|
||||||
|
* `@declaration.is-exported` — the export-evidence marker the TypeScript and
|
||||||
|
* JavaScript capture emitters synthesize (`ts-js-export-marker.ts`), and its
|
||||||
|
* landing on `SymbolDefinition.isExported` through the central extractor.
|
||||||
|
* Review findings on #3182 (typescript/scope-resolver.ts:138).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { emitTsScopeCaptures } from '../../../src/core/ingestion/languages/typescript/captures.js';
|
||||||
|
import { emitJsScopeCaptures } from '../../../src/core/ingestion/languages/javascript/captures.js';
|
||||||
|
import { extract } from '../../../src/core/ingestion/scope-extractor.js';
|
||||||
|
import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js';
|
||||||
|
|
||||||
|
type Emit = typeof emitTsScopeCaptures;
|
||||||
|
|
||||||
|
function verdicts(emit: Emit, src: string, filePath: string): Record<string, string | undefined> {
|
||||||
|
const out: Record<string, string | undefined> = {};
|
||||||
|
for (const m of emit(src, filePath)) {
|
||||||
|
const name = m['@declaration.name']?.text;
|
||||||
|
if (name === undefined) continue;
|
||||||
|
if (name in out && out[name] !== undefined) continue;
|
||||||
|
out[name] = m['@declaration.is-exported']?.text;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ESM = `
|
||||||
|
export function a() {}
|
||||||
|
function b() {}
|
||||||
|
const c = () => 1;
|
||||||
|
export const d = 2;
|
||||||
|
function e() {}
|
||||||
|
export { c, e as renamed };
|
||||||
|
function f() {}
|
||||||
|
export default f;
|
||||||
|
function g() { function inner() {} }
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe('@declaration.is-exported (TypeScript emitter)', () => {
|
||||||
|
it('marks direct, clause and default exports true and everything else false in an ESM file', () => {
|
||||||
|
const v = verdicts(emitTsScopeCaptures, ESM, 'test.ts');
|
||||||
|
expect(v.a).toBe('true');
|
||||||
|
expect(v.b).toBe('false');
|
||||||
|
expect(v.c).toBe('true');
|
||||||
|
expect(v.d).toBe('true');
|
||||||
|
expect(v.e).toBe('true');
|
||||||
|
expect(v.f).toBe('true');
|
||||||
|
expect(v.g).toBe('false');
|
||||||
|
expect(v.inner).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a member of an exported class is NOT itself exported; nested functions never are (magyargergo)', () => {
|
||||||
|
const v = verdicts(
|
||||||
|
emitTsScopeCaptures,
|
||||||
|
'export class Unrelated { clash() {} }\nfunction wrapper() { function selected() {} }\nexport { selected };\nconst selected = 1;\n',
|
||||||
|
'test.ts',
|
||||||
|
);
|
||||||
|
expect(v.Unrelated).toBe('true');
|
||||||
|
expect(v.clash).toBe('false');
|
||||||
|
expect(v.wrapper).toBe('false');
|
||||||
|
// Two `selected`s: the module-level one is exported by the clause, the
|
||||||
|
// nested one is not — `verdicts` keeps the first non-undefined per name, so
|
||||||
|
// look them up individually.
|
||||||
|
const all = emitTsScopeCaptures(
|
||||||
|
'function wrapper() { function selected() {} }\nexport { selected };\nconst selected = 1;\n',
|
||||||
|
'test.ts',
|
||||||
|
).filter((m) => m['@declaration.name']?.text === 'selected');
|
||||||
|
expect(all.map((m) => m['@declaration.is-exported']?.text).sort()).toEqual(['false', 'true']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a method of the `module.exports = { … }` object literal IS that module's export (magyargergo)", () => {
|
||||||
|
const v = verdicts(
|
||||||
|
emitJsScopeCaptures,
|
||||||
|
'function helper() {}\nmodule.exports = { alpha() { return 1; }, beta: () => 2 };\n',
|
||||||
|
'lib.js',
|
||||||
|
);
|
||||||
|
expect(v.alpha).toBe('true');
|
||||||
|
expect(v.beta).toBe('true');
|
||||||
|
expect(v.helper).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits NO verdict for a CommonJS file — `module.exports` is an export surface it cannot read', () => {
|
||||||
|
const v = verdicts(
|
||||||
|
emitTsScopeCaptures,
|
||||||
|
'function a() {}\nfunction b() {}\nmodule.exports = { a };\n',
|
||||||
|
'test.ts',
|
||||||
|
);
|
||||||
|
expect(v.a).toBeUndefined();
|
||||||
|
expect(v.b).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits NO verdict for an ambient .d.ts', () => {
|
||||||
|
const v = verdicts(emitTsScopeCaptures, 'declare function a(): void;\n', 'lib.d.ts');
|
||||||
|
expect(v.a).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a file that STARTS with `export` mark everything exported (the text-prefix trap)', () => {
|
||||||
|
const v = verdicts(
|
||||||
|
emitTsScopeCaptures,
|
||||||
|
'export const x = 1;\nfunction hidden() {}\n',
|
||||||
|
'test.ts',
|
||||||
|
);
|
||||||
|
expect(v.x).toBe('true');
|
||||||
|
expect(v.hidden).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@declaration.is-exported (JavaScript emitter)', () => {
|
||||||
|
it('marks ESM declarations', () => {
|
||||||
|
const v = verdicts(emitJsScopeCaptures, ESM, 'test.js');
|
||||||
|
expect(v.a).toBe('true');
|
||||||
|
expect(v.b).toBe('false');
|
||||||
|
expect(v.e).toBe('true');
|
||||||
|
expect(v.f).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays silent for `exports.x =` files', () => {
|
||||||
|
const v = verdicts(emitJsScopeCaptures, 'function a() {}\nexports.a = a;\n', 'test.js');
|
||||||
|
expect(v.a).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SymbolDefinition.isExported through the extractor', () => {
|
||||||
|
it('lands as a tri-state field: true / false / absent', () => {
|
||||||
|
const esm = extract(
|
||||||
|
emitTsScopeCaptures('export function a() {}\nfunction b() {}\n', 'x.ts'),
|
||||||
|
'x.ts',
|
||||||
|
typescriptScopeResolver,
|
||||||
|
);
|
||||||
|
const byName = new Map(esm.localDefs.map((d) => [d.qualifiedName, d.isExported]));
|
||||||
|
expect(byName.get('a')).toBe(true);
|
||||||
|
expect(byName.get('b')).toBe(false);
|
||||||
|
const cjs = extract(
|
||||||
|
emitTsScopeCaptures('function a() {}\nmodule.exports = a;\n', 'y.ts'),
|
||||||
|
'y.ts',
|
||||||
|
typescriptScopeResolver,
|
||||||
|
);
|
||||||
|
expect(cjs.localDefs.find((d) => d.qualifiedName === 'a')?.isExported).toBeUndefined();
|
||||||
|
expect('isExported' in cjs.localDefs.find((d) => d.qualifiedName === 'a')!).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -144,4 +144,30 @@ describe('Go _test.go package siblings', () => {
|
||||||
expect(see('m:foo', 'setup')).toEqual([]);
|
expect(see('m:foo', 'setup')).toEqual([]);
|
||||||
expect(see('m:bar', 'setup')).toEqual([]);
|
expect(see('m:bar', 'setup')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a package genuinely NAMED `foo_test` keeps its internal tests in their declared package', () => {
|
||||||
|
// `package foo_test` is the external-test convention only when the
|
||||||
|
// directory's real package is `foo`. Here the non-test files themselves say
|
||||||
|
// `foo_test`, so its `_test.go` files are INTERNAL tests of that package
|
||||||
|
// and must see unexported siblings; stripping `_test` blindly keyed them
|
||||||
|
// as external tests of a non-existent `foo` and published nothing.
|
||||||
|
const impl = def('impl', 'pkg/foo_test/impl.go', 'unexportedHelper');
|
||||||
|
const fixture = def('fixture', 'pkg/foo_test/impl_test.go', 'newFixture');
|
||||||
|
const { see } = setup([
|
||||||
|
{ path: 'pkg/foo_test/impl.go', scope: 'm:impl', pkg: 'foo_test', defs: [impl] },
|
||||||
|
{ path: 'pkg/foo_test/impl_test.go', scope: 'm:impl-test', pkg: 'foo_test', defs: [fixture] },
|
||||||
|
]);
|
||||||
|
expect(see('m:impl-test', 'unexportedHelper')).toEqual(['impl']);
|
||||||
|
// Non-test files still never see test-only declarations.
|
||||||
|
expect(see('m:impl', 'newFixture')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('`package foo_test` beside `package foo` is still the external-test convention', () => {
|
||||||
|
const { see } = setup([
|
||||||
|
{ path: 'pkg/a/a.go', scope: 'm:a', pkg: 'a', defs: [helper, exported] },
|
||||||
|
{ path: 'pkg/a/a_test.go', scope: 'm:a-ext', pkg: 'a_test', defs: [testOnly] },
|
||||||
|
]);
|
||||||
|
expect(see('m:a-ext', 'setUpHelper')).toEqual([]);
|
||||||
|
expect(see('m:a-ext', 'NewThing')).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,35 @@ describe('Go: isGlobalNameFallbackPlausible', () => {
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("REFUSES an exported helper declared in another package's `_test.go`, module root included", () => {
|
||||||
|
// A `_test.go` file is compiled only into its own package's test binary;
|
||||||
|
// no other package can see it. The module-root exception used to run first
|
||||||
|
// and accept `root_helper_test.go`'s exports for every subdirectory caller.
|
||||||
|
expect(
|
||||||
|
goIsGlobalNameFallbackPlausible({
|
||||||
|
callerParsed: mkCaller('internal/svc/caller.go', [namedImport('github.com/org/mod')]),
|
||||||
|
candidate: mkCandidate('helpers_test.go', 'ExportedTestHelper'),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
goIsGlobalNameFallbackPlausible({
|
||||||
|
callerParsed: mkCaller('internal/svc/caller.go', [
|
||||||
|
namedImport('github.com/org/mod/internal/models'),
|
||||||
|
]),
|
||||||
|
candidate: mkCandidate('internal/models/fixtures_test.go', 'NewFixture'),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
// ...even from another package's own test file.
|
||||||
|
expect(
|
||||||
|
goIsGlobalNameFallbackPlausible({
|
||||||
|
callerParsed: mkCaller('internal/svc/caller_test.go', [
|
||||||
|
namedImport('github.com/org/mod/internal/models'),
|
||||||
|
]),
|
||||||
|
candidate: mkCandidate('internal/models/fixtures_test.go', 'NewFixture'),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not refuse an exported identifier in the module ROOT package', () => {
|
it('does not refuse an exported identifier in the module ROOT package', () => {
|
||||||
// The root package is imported by the module path alone, which the
|
// The root package is imported by the module path alone, which the
|
||||||
// repo-relative layout cannot align against — undecidable, so allowed.
|
// repo-relative layout cannot align against — undecidable, so allowed.
|
||||||
|
|
@ -187,15 +216,16 @@ describe('Go: isGlobalNameFallbackPlausible', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Dart: isGlobalNameFallbackPlausible', () => {
|
describe('Dart: isGlobalNameFallbackPlausible', () => {
|
||||||
it('REFUSES a library-private name from another directory', () => {
|
it('does NOT refuse a library-private name from another directory — a `part` URI may cross it', () => {
|
||||||
// No `part` layout can span directories in practice, so a `_` name across
|
// `part '../shared/gen.dart';` is legal Dart, and `part` directives are not
|
||||||
// one is impossible, not merely unproven.
|
// extracted yet, so "different directory" is undecidable, not impossible.
|
||||||
|
// The edge stays a labeled guess rather than being deleted.
|
||||||
expect(
|
expect(
|
||||||
dartIsGlobalNameFallbackPlausible({
|
dartIsGlobalNameFallbackPlausible({
|
||||||
callerParsed: mkCaller('lib/widgets/b.dart'),
|
callerParsed: mkCaller('lib/widgets/b.dart'),
|
||||||
candidate: mkCandidate('lib/models/a.dart', '_privateHelper'),
|
candidate: mkCandidate('lib/models/a.dart', '_privateHelper'),
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows a library-private name in a SIBLING file (possible `part`)', () => {
|
it('allows a library-private name in a SIBLING file (possible `part`)', () => {
|
||||||
|
|
@ -322,6 +352,40 @@ describe('Rust: isGlobalNameFallbackPlausible', () => {
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('REFUSES when the only `use` of the module names a DIFFERENT item', () => {
|
||||||
|
// `use crate::a::other;` brings `other` into scope, not `helper`. The
|
||||||
|
// parent-path match used to accept every item of `a` on its strength.
|
||||||
|
expect(
|
||||||
|
rustIsGlobalNameFallbackPlausible({
|
||||||
|
site: BARE_SITE,
|
||||||
|
callerParsed: mkCaller('src/b.rs', [namedImport('crate::a::other', 'other')]),
|
||||||
|
candidate: mkCandidate('src/a.rs', 'unique_helper_xyz'),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a glob `use` of the module — every item is in scope', () => {
|
||||||
|
expect(
|
||||||
|
rustIsGlobalNameFallbackPlausible({
|
||||||
|
site: BARE_SITE,
|
||||||
|
callerParsed: mkCaller('src/b.rs', [{ kind: 'wildcard', targetRaw: 'crate::a' }]),
|
||||||
|
candidate: mkCandidate('src/a.rs', 'unique_helper_xyz'),
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a `use` that names the candidate itself, with the item on the path', () => {
|
||||||
|
expect(
|
||||||
|
rustIsGlobalNameFallbackPlausible({
|
||||||
|
site: BARE_SITE,
|
||||||
|
callerParsed: mkCaller('src/b.rs', [
|
||||||
|
namedImport('crate::a::unique_helper_xyz', 'unique_helper_xyz'),
|
||||||
|
]),
|
||||||
|
candidate: mkCandidate('src/a.rs', 'unique_helper_xyz'),
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not judge a PATH-QUALIFIED call site', () => {
|
it('does not judge a PATH-QUALIFIED call site', () => {
|
||||||
// `User::new(...)` names its path in source. Refusing it for lacking a
|
// `User::new(...)` names its path in source. Refusing it for lacking a
|
||||||
// `use` of the module would delete an edge the code spells out — the
|
// `use` of the module would delete an edge the code spells out — the
|
||||||
|
|
@ -527,6 +591,68 @@ describe('Ruby: isGlobalNameFallbackPlausible', () => {
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('REFUSES when the caller only mentions a LONGER constant containing the name as a substring', () => {
|
||||||
|
// `BillingService.build` is not a mention of `Billing`; `includes()` said it was.
|
||||||
|
const site = {
|
||||||
|
name: 'build',
|
||||||
|
rawQualifiedName: 'BillingService.build',
|
||||||
|
} as unknown as ParsedFile['referenceSites'][number];
|
||||||
|
expect(
|
||||||
|
rubyIsGlobalNameFallbackPlausible({
|
||||||
|
callerParsed: mkCaller('app/b.rb', [], [site]),
|
||||||
|
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||||
|
parsedFileOf: ownerFile('def:Billing', 'Class'),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a qualified mention whose SEGMENT is the constant (`Acme::Billing.new`)', () => {
|
||||||
|
const site = {
|
||||||
|
name: 'new',
|
||||||
|
rawQualifiedName: 'Acme::Billing.new',
|
||||||
|
} as unknown as ParsedFile['referenceSites'][number];
|
||||||
|
expect(
|
||||||
|
rubyIsGlobalNameFallbackPlausible({
|
||||||
|
callerParsed: mkCaller('app/b.rb', [], [site]),
|
||||||
|
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||||
|
parsedFileOf: ownerFile('def:Billing', 'Class'),
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the LABELED guess when the caller file rebinds `self` (`instance_eval` DSL blocks) (magyargergo)', () => {
|
||||||
|
// `service.instance_eval do unique_helper_xyz() end` dispatches the bare
|
||||||
|
// call on `service`, so the class never being named here proves nothing.
|
||||||
|
const src =
|
||||||
|
'def caller(service)\n service.instance_eval do\n unique_helper_xyz()\n end\nend\n';
|
||||||
|
expect(
|
||||||
|
rubyIsGlobalNameFallbackPlausible({
|
||||||
|
callerParsed: mkCaller('app/b.rb'),
|
||||||
|
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||||
|
parsedFileOf: ownerFile('def:Billing', 'Class'),
|
||||||
|
sourceTextOf: () => src,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
// ...and still REFUSES when the source has no such block.
|
||||||
|
expect(
|
||||||
|
rubyIsGlobalNameFallbackPlausible({
|
||||||
|
callerParsed: mkCaller('app/b.rb'),
|
||||||
|
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||||
|
parsedFileOf: ownerFile('def:Billing', 'Class'),
|
||||||
|
sourceTextOf: () => 'def caller\n unique_helper_xyz()\nend\n',
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
// A missing source text is an unanswered question, not a refusal.
|
||||||
|
expect(
|
||||||
|
rubyIsGlobalNameFallbackPlausible({
|
||||||
|
callerParsed: mkCaller('app/b.rb'),
|
||||||
|
candidate: mkCandidate('app/a.rb', 'Billing.unique_helper_xyz', 'def:Billing'),
|
||||||
|
parsedFileOf: ownerFile('def:Billing', 'Class'),
|
||||||
|
sourceTextOf: () => undefined,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not refuse an owned method with no nameable namespace', () => {
|
it('does not refuse an owned method with no nameable namespace', () => {
|
||||||
expect(
|
expect(
|
||||||
rubyIsGlobalNameFallbackPlausible({
|
rubyIsGlobalNameFallbackPlausible({
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue