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