mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-27 06:12:20 +00:00
merge: sync inheritance fixes with main
This commit is contained in:
commit
7174db4f87
38 changed files with 1511 additions and 129 deletions
|
|
@ -29,6 +29,30 @@ more sites than the original standalone branch, which cleared 14. The `b12`
|
|||
alias control resolves. Zig call drops fall from 13 to 10; `no-chain` call
|
||||
drops move from 34 to 31 and `in-program` calls from 60 to 57.
|
||||
|
||||
## JS/TS literal-key writes and class-level field receivers (#3498)
|
||||
|
||||
Measured on top of #3654 (`a3db31923`, tree-identical to its squash merge
|
||||
`2b6542737`). Call drops move from 138 to 136, all-kind drops from 200 to
|
||||
178, and writes from 20 to zero. Read drops remain 42 and every shape-arm
|
||||
state is unchanged.
|
||||
|
||||
The 20 cleared writes are 19 object-literal keys answered by later resolution
|
||||
passes and the CommonJS `module.exports.arrowRequest` declaration. Answered
|
||||
sites withdraw only a matching kind/name/site drop; unanswered sites retain
|
||||
theirs. A return-shape key's own write requires its exact declaration position
|
||||
and file, so unrelated input reads and writes retain ordinary candidate ranking.
|
||||
|
||||
The two call-drop changes are `Config.DEFAULT.validate()` resolving through
|
||||
its `new Config()` initializer and the owned `this.route.go()` site in
|
||||
`ts-dynamic-this-no-seed` being classified as `receiver-owned-but-unbound`,
|
||||
which is outside this gate. The latter does not gain a call edge, and its call
|
||||
remains in the persisted unresolved-receiver summary used by impact/context.
|
||||
Static field inference stops at an untyped override and resolves constructor names through
|
||||
the value namespace; annotations retain type-namespace lookup.
|
||||
|
||||
Answered-site identity includes the full range, so an answered inner read in
|
||||
`res.data.data` cannot withdraw the unresolved outer read with the same name.
|
||||
|
||||
## Go module visibility and qualified receiver refusal
|
||||
|
||||
Local imports now select the importer's own module or a module selected by its
|
||||
|
|
@ -925,3 +949,11 @@ Two incidental corrections from that check, both to KTD6:
|
|||
### Same-package inheritance review corrections
|
||||
|
||||
The fallback excludes function-local and nested JVM candidates, while local and nested deriving classes can still use package-level bases. Synthetic deriving classes do not enter this fallback; their source captures do not retain the qualifier evidence needed here. C# filters by the written generic arity before source-root proximity. Five native assertions fail before the corrections; all 14 focused checks and all 755 Java/Kotlin/C# resolver and helper tests pass afterward. Build and both TypeScript configurations pass. After merging main `d57f570d`, receiver counts and all shape states match main (135 calls, 182 total, 27 reads and 20 writes).
|
||||
|
||||
|
||||
### Merged main `f6e42092`
|
||||
|
||||
Re-measured both benchmark arms after merging the Zig receiver fix from main.
|
||||
Shape states remain unchanged; counts are 133 call drops and
|
||||
160 drops of all kinds (27 reads,
|
||||
0 writes). The counts include both main and this branch's fixes.
|
||||
|
|
|
|||
|
|
@ -199,17 +199,16 @@
|
|||
}
|
||||
},
|
||||
"countArm": {
|
||||
"callDrops": 135,
|
||||
"totalDropsAllKinds": 182,
|
||||
"callDrops": 133,
|
||||
"totalDropsAllKinds": 160,
|
||||
"bySiteKind": {
|
||||
"call": 135,
|
||||
"read": 27,
|
||||
"write": 20
|
||||
"call": 133,
|
||||
"read": 27
|
||||
},
|
||||
"callDropsByExtension": {
|
||||
".java": 49,
|
||||
".py": 24,
|
||||
".ts": 17,
|
||||
".ts": 16,
|
||||
".zig": 10,
|
||||
".cs": 8,
|
||||
".tsx": 6,
|
||||
|
|
@ -217,21 +216,21 @@
|
|||
".php": 4,
|
||||
".rs": 4,
|
||||
".kt": 4,
|
||||
".js": 2,
|
||||
".dart": 2,
|
||||
".js": 1,
|
||||
".swift": 1
|
||||
},
|
||||
"callDropsByShape": {
|
||||
"chain-field": 59,
|
||||
"chain-field": 57,
|
||||
"no-chain": 31,
|
||||
"chain-call": 31,
|
||||
"<<unclassified>>": 13,
|
||||
"chain-unwrap": 1
|
||||
},
|
||||
"callDropsByOrigin": {
|
||||
"in-program": 57,
|
||||
"in-program": 56,
|
||||
"external": 44,
|
||||
"unknown": 32,
|
||||
"unknown": 31,
|
||||
"<<unclassified>>": 2
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,36 @@
|
|||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { FieldExtractionConfig } from '../generic.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { hasKeyword, findVisibility, typeFromAnnotation } from './helpers.js';
|
||||
import type { FieldVisibility } from '../../field-types.js';
|
||||
|
||||
const VISIBILITY_KEYWORDS = new Set<FieldVisibility>(['public', 'private', 'protected']);
|
||||
|
||||
/**
|
||||
* The class a field's initializer constructs: `static DEFAULT = new Config()`
|
||||
* -> `Config`, `repo = new models.Repo()` -> `models.Repo`. `undefined` for any
|
||||
* other initializer — a literal, a call, a missing one.
|
||||
*
|
||||
* The same two constructor shapes (identifier, member expression) the JS and TS
|
||||
* scope queries bind a class field from (`@type-binding.constructor`). Those
|
||||
* bindings live in the CLASS scope, which has one map for static and instance
|
||||
* members, so a static field's binding is dropped there to keep it from
|
||||
* retyping an instance field of the same name (`isStaticClassFieldBinding`).
|
||||
* Recorded on the field instead, the static field keeps its type on its own
|
||||
* Property node, where nothing else can collide with it.
|
||||
*/
|
||||
export function constructedTypeOfFieldInitializer(node: SyntaxNode): string | undefined {
|
||||
const value = node.childForFieldName('value');
|
||||
if (value === null || value.type !== 'new_expression') return undefined;
|
||||
const constructorNode = value.childForFieldName('constructor');
|
||||
if (constructorNode === null) return undefined;
|
||||
if (constructorNode.type !== 'identifier' && constructorNode.type !== 'member_expression') {
|
||||
return undefined;
|
||||
}
|
||||
return constructorNode.text;
|
||||
}
|
||||
|
||||
const shared: Omit<FieldExtractionConfig, 'language'> = {
|
||||
typeDeclarationNodes: [
|
||||
'class_declaration',
|
||||
|
|
@ -35,6 +60,8 @@ const shared: Omit<FieldExtractionConfig, 'language'> = {
|
|||
return typeFromAnnotation(node);
|
||||
},
|
||||
|
||||
extractInitializerType: constructedTypeOfFieldInitializer,
|
||||
|
||||
extractVisibility(node) {
|
||||
// TypeScript accessibility_modifier
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,13 @@ export interface FieldExtractionConfig {
|
|||
* `normalizeType`/`resolveType` entirely — it is the untouched source text.
|
||||
*/
|
||||
extractRawType?: (node: SyntaxNode) => string | undefined;
|
||||
/**
|
||||
* Extract the type a field's initializer constructs (`= new Config()` ->
|
||||
* `Config`). Consulted only when `extractType` found no declared type, so it
|
||||
* can never stand in for one. Optional — only languages whose fields can be
|
||||
* typed by their initializer alone implement it.
|
||||
*/
|
||||
extractInitializerType?: (node: SyntaxNode) => string | undefined;
|
||||
/**
|
||||
* Extract `'@Name'`-prefixed annotation names from a field declaration
|
||||
* node (e.g. `['@Autowired']`). Optional — only languages with
|
||||
|
|
@ -232,16 +239,27 @@ export function createFieldExtractor(config: FieldExtractionConfig): FieldExtrac
|
|||
annotations = undefined;
|
||||
}
|
||||
|
||||
let initializerType: string | undefined;
|
||||
try {
|
||||
initializerType = type === null ? config.extractInitializerType?.(node) : undefined;
|
||||
} catch {
|
||||
// Same group-drop rationale as the extractRawType guard above —
|
||||
// degrade to a field without an initializer type.
|
||||
initializerType = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
...(rawDeclaredType !== undefined ? { rawDeclaredType } : {}),
|
||||
...(initializerType !== undefined ? { initializerType } : {}),
|
||||
...(annotations !== undefined && annotations.length > 0 ? { annotations } : {}),
|
||||
visibility: config.extractVisibilityForName?.(node, name) ?? config.extractVisibility(node),
|
||||
isStatic: config.isStatic(node),
|
||||
isReadonly: config.isReadonly(node),
|
||||
sourceFile: context.filePath,
|
||||
line: node.startPosition.row + 1,
|
||||
column: node.startPosition.column,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { BaseFieldExtractor } from '../field-extractor.js';
|
||||
import { constructedTypeOfFieldInitializer } from './configs/typescript-javascript.js';
|
||||
import type {
|
||||
FieldExtractorContext,
|
||||
ExtractedFields,
|
||||
|
|
@ -177,14 +178,18 @@ export class TypeScriptFieldExtractor extends BaseFieldExtractor {
|
|||
type = resolvedType ?? type;
|
||||
}
|
||||
|
||||
const initializerType = type === null ? constructedTypeOfFieldInitializer(node) : undefined;
|
||||
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
...(initializerType !== undefined ? { initializerType } : {}),
|
||||
visibility: this.extractVisibility(node),
|
||||
isStatic: this.isStatic(node),
|
||||
isReadonly: this.isReadonly(node),
|
||||
sourceFile: context.filePath,
|
||||
line: node.startPosition.row + 1,
|
||||
column: node.startPosition.column,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ export interface FieldInfo {
|
|||
* Never passes through simple-name extraction or type resolution.
|
||||
*/
|
||||
rawDeclaredType?: string;
|
||||
/**
|
||||
* The type the field's INITIALIZER constructs, recorded only for a field that
|
||||
* declares no type (`static DEFAULT = new Config()` -> `Config`). Omitted when
|
||||
* `type` is set, when the initializer constructs nothing nameable, and for
|
||||
* every language whose config does not read initializers.
|
||||
*/
|
||||
initializerType?: string;
|
||||
/**
|
||||
* Annotation names found on the field declaration, `'@Name'`-prefixed
|
||||
* (e.g. `['@Autowired']`), matching the method-extractor convention.
|
||||
|
|
@ -56,6 +63,8 @@ export interface FieldInfo {
|
|||
sourceFile: string;
|
||||
/** Line number */
|
||||
line: number;
|
||||
/** Zero-based declaration column, when the extractor has an AST position. */
|
||||
column?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -26,9 +26,11 @@
|
|||
* instanceof narrowing passes are duplicated from `typescript/captures.ts`
|
||||
* (they are pure AST operations with no grammar-specific logic).
|
||||
*
|
||||
* Pure given the input source text. No I/O, no globals consulted.
|
||||
* Derives captures from its inputs and records CommonJS classification for
|
||||
* immediate side-channel collection. Performs no I/O.
|
||||
*/
|
||||
|
||||
import { recordCommonJsModuleKind } from '../typescript/module-kind.js';
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import {
|
||||
findNodeAtRange,
|
||||
|
|
@ -890,6 +892,8 @@ export function emitJsScopeCaptures(
|
|||
});
|
||||
}
|
||||
|
||||
recordCommonJsModuleKind(tree.rootNode, filePath);
|
||||
|
||||
const rawMatches = getJsScopeQuery(grammarPath).matches(tree.rootNode);
|
||||
// Export evidence, read once per file (see `ts-js-export-marker.ts`).
|
||||
const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { jsArityCompatibility } from './arity.js';
|
|||
import { makeJsResolveImportTarget } from './import-target.js';
|
||||
import { loadTsconfigIndex } from '../typescript/tsconfig.js';
|
||||
import { typescriptModuleExports } from '../typescript/module-exports.js';
|
||||
import { isCommonJsExportAssignment } from '../typescript/cjs-export-receiver.js';
|
||||
import { loadNodeWorkspacePackages } from '../typescript/workspace-packages.js';
|
||||
import { isJsSelfBoundCallableFallbackPlausible } from './callable-visibility.js';
|
||||
|
||||
|
|
@ -93,6 +94,8 @@ const javascriptScopeResolver: ScopeResolver = {
|
|||
// JavaScript `super` keyword: same pattern as TypeScript.
|
||||
isSuperReceiver: (text) => /^super(\s*\(|\s*\.|\s*\[|\s*$)/.test(text.trim()),
|
||||
|
||||
isModuleExportAssignment: isCommonJsExportAssignment,
|
||||
|
||||
// JavaScript is dynamically typed — enable the field-fallback heuristic
|
||||
// so member-call receivers without type annotations can still resolve
|
||||
// through declared class fields (e.g. JSDoc-typed fields).
|
||||
|
|
|
|||
|
|
@ -24,8 +24,7 @@ import { typeConfig as rubyConfig } from '../type-extractors/ruby.js';
|
|||
import { routeRubyCall } from '../call-routing.js';
|
||||
import { rubyExportChecker } from '../export-detection.js';
|
||||
import { RUBY_QUERIES } from '../tree-sitter-queries.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { rubyConfig as rubyFieldConfig } from '../field-extractors/configs/ruby.js';
|
||||
import { rubyFieldExtractor } from './ruby/field-extractor.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
import { rubyMethodConfig } from '../method-extractors/configs/ruby.js';
|
||||
import { createVariableExtractor } from '../variable-extractors/generic.js';
|
||||
|
|
@ -216,7 +215,7 @@ export const rubyProvider = defineLanguage({
|
|||
callExtractor: createCallExtractor(rubyCallConfig),
|
||||
resolveEnclosingOwner: rubyResolveEnclosingOwner,
|
||||
resolveContainerTypeOwner: rubyResolveContainerTypeOwner,
|
||||
fieldExtractor: createFieldExtractor(rubyFieldConfig),
|
||||
fieldExtractor: rubyFieldExtractor,
|
||||
methodExtractor: createMethodExtractor({
|
||||
...rubyMethodConfig,
|
||||
extractFunctionName: rubyExtractFunctionName,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import type { FieldExtractor } from '../../field-extractor.js';
|
||||
import { createFieldExtractor } from '../../field-extractors/generic.js';
|
||||
import { rubyConfig } from '../../field-extractors/configs/ruby.js';
|
||||
|
||||
const declarationExtractor = createFieldExtractor(rubyConfig);
|
||||
|
||||
export const rubyFieldExtractor: FieldExtractor = {
|
||||
language: declarationExtractor.language,
|
||||
isTypeDeclaration: (node) => declarationExtractor.isTypeDeclaration(node),
|
||||
extract(node, context) {
|
||||
const extracted = declarationExtractor.extract(node, context);
|
||||
if (extracted === null) return null;
|
||||
|
||||
// Accessors describe one property: any setter makes that property writable.
|
||||
const writable = new Set<string>();
|
||||
for (const field of extracted.fields) {
|
||||
if (!field.isReadonly) writable.add(field.name);
|
||||
}
|
||||
for (const field of extracted.fields) {
|
||||
if (writable.has(field.name)) field.isReadonly = false;
|
||||
}
|
||||
return extracted;
|
||||
},
|
||||
};
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
isModuleLevelThisExport,
|
||||
isShadowedCjsExportAssignmentNode,
|
||||
} from './typescript/cjs-export-assignment.js';
|
||||
import { collectCommonJsModuleKind, withCommonJsModuleKind } from './typescript/module-kind.js';
|
||||
|
||||
const rootOf = (node: SyntaxNode): SyntaxNode | undefined =>
|
||||
(node as { tree?: { rootNode?: SyntaxNode } }).tree?.rootNode;
|
||||
|
|
@ -522,7 +523,8 @@ export const typescriptProvider = defineLanguage({
|
|||
// ./typescript/index.ts for the full per-hook rationale and the
|
||||
// canonical capture vocabulary in ./typescript/query.ts
|
||||
// (TYPESCRIPT_SCOPE_QUERY constant).
|
||||
emitScopeCaptures: emitTsScopeCaptures,
|
||||
emitScopeCaptures: withCommonJsModuleKind(emitTsScopeCaptures),
|
||||
collectCaptureSideChannel: collectCommonJsModuleKind,
|
||||
// CFG/PDG substrate (#2081 M1) — runs in the worker on a --pdg run.
|
||||
cfgVisitor: createTypeScriptCfgVisitor(),
|
||||
interpretImport: interpretTsImport,
|
||||
|
|
@ -616,7 +618,8 @@ export const javascriptProvider = defineLanguage({
|
|||
// semantics are identical; JS-specific additions (CJS require(),
|
||||
// JSDoc type bindings) live in ./javascript/captures.ts.
|
||||
// See ./javascript/index.ts for the full per-module rationale.
|
||||
emitScopeCaptures: emitJsScopeCaptures,
|
||||
emitScopeCaptures: withCommonJsModuleKind(emitJsScopeCaptures),
|
||||
collectCaptureSideChannel: collectCommonJsModuleKind,
|
||||
// CFG/PDG substrate (#2081 M1) — TS and JS share the same grammar family.
|
||||
cfgVisitor: createTypeScriptCfgVisitor(),
|
||||
interpretImport: interpretJsImport,
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@
|
|||
* 5. **Receiver-binding synthesis** (Unit 3) — `this` type anchors on
|
||||
* instance methods, with arrow-function lexical-this walk-up.
|
||||
*
|
||||
* Pure given the input source text. No I/O, no globals consulted.
|
||||
* Derives captures from its inputs and records CommonJS classification for
|
||||
* immediate side-channel collection. Performs no I/O.
|
||||
*/
|
||||
|
||||
import { recordCommonJsModuleKind } from './module-kind.js';
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import {
|
||||
findNodeAtRange,
|
||||
|
|
@ -284,7 +286,12 @@ export const TS_CLASS_FIELD_DEFINITION_TYPES: ReadonlySet<string> = new Set([
|
|||
* For a class declaring BOTH twins the wrong edge does not disappear, it MOVES:
|
||||
* `Host.p` now reads the INSTANCE twin's binding, because that is what is left
|
||||
* in the map under that name. Only the no-twin case — the common static shape —
|
||||
* is a true missed edge.
|
||||
* is a true missed edge, and its first hop no longer is: the field extractor
|
||||
* records the type the initializer constructs on the static Property NODE
|
||||
* (`initializerType`), and the class-level receiver route in
|
||||
* `receiver-bound-calls.ts` reads it there, so `Host.q.m()` resolves `Wrong.m`
|
||||
* without the binding ever entering the shared map. A longer chain off it
|
||||
* (`Host.q.m().n()`) still goes through the fold and is still missed.
|
||||
*
|
||||
* The trade is still the right one, since `this.p` is overwhelmingly the more
|
||||
* common access and a `Host.p` static chain is the cheaper place to be wrong.
|
||||
|
|
@ -297,7 +304,8 @@ export const TS_CLASS_FIELD_DEFINITION_TYPES: ReadonlySet<string> = new Set([
|
|||
* with automatic parser-cache invalidation. Both shapes are pinned by rows in
|
||||
* `test/integration/resolvers/inferred-field-receiver-matrix.test.ts`
|
||||
* (`static-read-of-a-same-name-twin-picks-up-the-instance-type`,
|
||||
* `static-read-without-a-twin-loses-its-type`), so the cost moves visibly.
|
||||
* `static-read-without-a-twin-types-from-its-initializer`), so the cost moves
|
||||
* visibly.
|
||||
*
|
||||
* Sibling of {@link isStaticMethodThis}, which drops the ASSIGNMENT form
|
||||
* (`this.x = new Y()` inside a static method). Together they cover both ways a
|
||||
|
|
@ -393,6 +401,8 @@ export function emitTsScopeCaptures(
|
|||
recordCacheHit();
|
||||
}
|
||||
|
||||
recordCommonJsModuleKind(tree.rootNode, filePath);
|
||||
|
||||
const rawMatches = getTsScopeQuery(grammarPath).matches(tree.rootNode);
|
||||
// Export evidence, read once per file (see `ts-js-export-marker.ts`).
|
||||
const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath);
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ const THIS_BINDING_NODE_TYPES = new Set([
|
|||
* `exports`/`module` reference marks it CommonJS. A file with neither signal
|
||||
* is left alone — silence is not evidence of CommonJS.
|
||||
*/
|
||||
function isCommonJsModule(root: SyntaxNode, filePath?: string): boolean {
|
||||
export function isCommonJsModule(root: SyntaxNode, filePath?: string): boolean {
|
||||
// The extension settles it outright where Node itself does: `.cjs`/`.cts` are
|
||||
// CommonJS whatever the body contains, `.mjs`/`.mts` are ESM. Without this a
|
||||
// `.cjs` file whose only content is `this.x = fn` carries no AST signal and
|
||||
|
|
@ -206,19 +206,29 @@ function isCommonJsModule(root: SyntaxNode, filePath?: string): boolean {
|
|||
if (cached !== undefined) return cached;
|
||||
|
||||
let sawCjsSignal = false;
|
||||
const visit = (node: SyntaxNode): boolean => {
|
||||
let sawEsmSignal = false;
|
||||
const pending = [root];
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop();
|
||||
if (node === undefined) break;
|
||||
// An ESM statement settles it immediately: top-level `this` is undefined.
|
||||
if (node.type === 'import_statement' || node.type === 'export_statement') return false;
|
||||
if (node.type === 'import_statement' || node.type === 'export_statement') {
|
||||
sawEsmSignal = true;
|
||||
break;
|
||||
}
|
||||
if (node.type === 'identifier' && (node.text === 'exports' || node.text === 'module'))
|
||||
sawCjsSignal = true;
|
||||
if (node.type === 'call_expression' && node.childForFieldName('function')?.text === 'require')
|
||||
sawCjsSignal = true;
|
||||
|
||||
for (const child of node.namedChildren) if (!visit(child)) return false;
|
||||
return true;
|
||||
};
|
||||
const children = node.namedChildren;
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
const child = children[i];
|
||||
if (child !== undefined) pending.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
const result = visit(root) && sawCjsSignal;
|
||||
const result = !sawEsmSignal && sawCjsSignal;
|
||||
isCommonJsByRoot.set(root, result);
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import type { ParsedFile, ReferenceSite } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { lookupNameClaim } from '../../scope-resolution/scope/walkers.js';
|
||||
import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js';
|
||||
|
||||
/**
|
||||
* True when `site` writes a member of `module.exports` and `module` is the
|
||||
* CommonJS module object.
|
||||
*
|
||||
* `module` must resolve to NOTHING in scope. The canonical UMD wrapper and any
|
||||
* `function wrap(module) { … }` take it as a parameter, and a write through that
|
||||
* local is a write to a value the resolver cannot type — a real drop, not a
|
||||
* declaration. Same rule `cjs-export-assignment.ts` applies to `exports` on the
|
||||
* capture side, asked here of the resolved scope chain instead of the AST.
|
||||
*/
|
||||
export function isCommonJsExportAssignment(
|
||||
site: ReferenceSite,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsed: ParsedFile,
|
||||
): boolean {
|
||||
if (parsed.captureSideChannel !== true) return false;
|
||||
if (site.kind !== 'write') return false;
|
||||
if (!isModuleExportsReceiver(site)) return false;
|
||||
const claim = lookupNameClaim(site.inScope, 'module', scopes, {
|
||||
position: site.atRange,
|
||||
purpose: 'value',
|
||||
});
|
||||
return claim.status === 'absent';
|
||||
}
|
||||
|
||||
function isModuleExportsReceiver(site: ReferenceSite): boolean {
|
||||
const chain = decodeReceiverChain(site.receiverChain);
|
||||
if (chain === undefined) return site.explicitReceiver?.name === 'module.exports';
|
||||
const [step] = chain.steps;
|
||||
return (
|
||||
chain.baseReceiverName === 'module' &&
|
||||
!chain.truncated &&
|
||||
chain.steps.length === 1 &&
|
||||
step?.kind === 'field' &&
|
||||
step.name === 'exports'
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import type { LanguageProvider } from '../../language-provider.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { assertCloneable } from '../../workers/clone-safety.js';
|
||||
import { isCommonJsModule } from './cjs-export-assignment.js';
|
||||
|
||||
type CaptureEmitter = NonNullable<LanguageProvider['emitScopeCaptures']>;
|
||||
|
||||
// Collected immediately after extraction. Keep only the file's scalar fact;
|
||||
// standalone capture calls cannot retain ASTs or maps.
|
||||
let pending: { readonly filePath: string; readonly commonJs: boolean } | undefined;
|
||||
|
||||
export function withCommonJsModuleKind(emit: CaptureEmitter): CaptureEmitter {
|
||||
return (...args) => {
|
||||
pending = undefined;
|
||||
return emit(...args);
|
||||
};
|
||||
}
|
||||
|
||||
export function recordCommonJsModuleKind(root: SyntaxNode, filePath: string): void {
|
||||
pending = { filePath, commonJs: isCommonJsModule(root, filePath) };
|
||||
}
|
||||
|
||||
export function collectCommonJsModuleKind(filePath: string): boolean | undefined {
|
||||
const captured = pending;
|
||||
pending = undefined;
|
||||
return captured?.filePath === filePath ? assertCloneable(captured.commonJs) : undefined;
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import { simpleKey } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
|||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { typescriptProvider } from '../typescript.js';
|
||||
import { typescriptModuleExports } from './module-exports.js';
|
||||
import { isCommonJsExportAssignment } from './cjs-export-receiver.js';
|
||||
import { loadTsconfigIndex, type TsconfigIndex } from './tsconfig.js';
|
||||
import { loadNodeWorkspacePackages, type NodeWorkspacePackages } from './workspace-packages.js';
|
||||
import { indexOnlyElementType } from '../../type-extractors/shared.js';
|
||||
|
|
@ -134,6 +135,8 @@ const typescriptScopeResolver: ScopeResolver = {
|
|||
// contain the substring `super` (e.g. `superman`).
|
||||
isSuperReceiver: (text) => /^super(\s*\(|\s*\.|\s*\[|\s*$)/.test(text.trim()),
|
||||
|
||||
isModuleExportAssignment: isCommonJsExportAssignment,
|
||||
|
||||
// TypeScript is statically typed — field-fallback heuristic off
|
||||
// (the type-binding layer produces precise owner types). Return-
|
||||
// type propagation across imports on (matches the legacy DAG's
|
||||
|
|
|
|||
|
|
@ -1557,6 +1557,24 @@ export interface ScopeResolver {
|
|||
* caller records a receiver-unresolved outcome. */
|
||||
readonly suppressReceiverLookup?: (typeRef: TypeRef) => boolean;
|
||||
|
||||
/**
|
||||
* True when a WRITE site is the language's module-export DECLARATION written
|
||||
* through a qualified receiver — CommonJS `module.exports.name = …` — rather
|
||||
* than a write to a member of some value.
|
||||
*
|
||||
* Instrumentation only. Consulted after the receiver-bound cases have all
|
||||
* failed to type the receiver, to keep the declaration out of the
|
||||
* `receiver-unresolved` drops: the receiver is the module's own export
|
||||
* object, not a value whose type was lost. It neither emits an edge nor marks
|
||||
* the site handled, so whatever the later passes do with the site is
|
||||
* unchanged. Omit to record every such site as a drop.
|
||||
*/
|
||||
readonly isModuleExportAssignment?: (
|
||||
site: ReferenceSite,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsed: ParsedFile,
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* Enable the receiver-bound Case 0.5 fallback for explicit `this`
|
||||
* receivers (`this->m()` / `this.m()`) that resolves against the
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ type ReceiverBoundProviderSubset = Pick<
|
|||
| 'createReceiverNamespaceResolver'
|
||||
| 'resolveReceiverMember'
|
||||
| 'suppressReceiverLookup'
|
||||
| 'isModuleExportAssignment'
|
||||
| 'resolveThisViaEnclosingClass'
|
||||
| 'resolveMissingReceiverMembersFromSubtypes'
|
||||
| 'missingReceiverSubtypeCandidateCompatibility'
|
||||
|
|
@ -1081,40 +1082,29 @@ export function emitReceiverBoundCalls(
|
|||
return n;
|
||||
};
|
||||
|
||||
/**
|
||||
* Declared type of the CLASS-LEVEL field named `fieldName` on `ownerId`, or
|
||||
* `undefined` when the owner declares no such field, declares only an
|
||||
* instance one, or declares one whose type was never captured.
|
||||
*
|
||||
* Both facts live on the graph NODE rather than on `SymbolDefinition` —
|
||||
* `isStatic` is set by the structure phase and `declaredType` by the field
|
||||
* extractor — which is the same place {@link isUnreachableByInstanceDispatch}
|
||||
* reads `isStatic` from, so this introduces no new dependency.
|
||||
*
|
||||
* `isStatic === true` is required, not merely preferred. The receiver that
|
||||
* asks this question resolved its head to the CLASS, so an instance field of
|
||||
* that name is not reachable through it and answering with the instance
|
||||
* field's type would type the receiver as something the source cannot
|
||||
* denote. A def that resolves to no node, or a node with no captured type,
|
||||
* answers `undefined` — the declining direction, matching how the rest of
|
||||
* this pass treats an unresolvable lookup.
|
||||
*/
|
||||
// Undefined means absent; null means an untyped static declaration that
|
||||
// shadows ancestors. An annotation names a type; a constructor names a value.
|
||||
const declaredTypeOfClassLevelField = (
|
||||
ownerId: string,
|
||||
fieldName: string,
|
||||
): string | undefined => {
|
||||
): { readonly name: string; readonly purpose: 'type' | 'value' } | null | undefined => {
|
||||
let declaresStatic = false;
|
||||
for (const candidate of model.fields.lookupAllByOwner(ownerId, fieldName)) {
|
||||
const graphId = resolveDefGraphId(candidate.filePath, candidate, nodeLookup);
|
||||
if (graphId === undefined) continue;
|
||||
const properties = graph.getNode(graphId)?.properties;
|
||||
if (properties?.isStatic !== true) continue;
|
||||
const declaredType = properties.declaredType;
|
||||
if (typeof declaredType !== 'string') continue;
|
||||
const trimmed = declaredType.trim();
|
||||
if (trimmed.length === 0) continue;
|
||||
return trimmed;
|
||||
declaresStatic = true;
|
||||
for (const [fieldType, purpose] of [
|
||||
[properties.declaredType, 'type'],
|
||||
[properties.initializerType, 'value'],
|
||||
] as const) {
|
||||
if (typeof fieldType !== 'string') continue;
|
||||
const trimmed = fieldType.trim();
|
||||
if (trimmed.length > 0) return { name: trimmed, purpose };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return declaresStatic ? null : undefined;
|
||||
};
|
||||
|
||||
const resolveReceiverNamespace = provider.createReceiverNamespaceResolver?.(scopes, index);
|
||||
|
|
@ -1224,10 +1214,11 @@ export function emitReceiverBoundCalls(
|
|||
`${receiverName}.${memberName}`,
|
||||
);
|
||||
|
||||
const baseName =
|
||||
decodeReceiverChain(site.receiverChain)?.baseReceiverName ??
|
||||
receiverName.split(/[.([\s]/, 1)[0];
|
||||
|
||||
if (provider.suppressReceiverLookup !== undefined) {
|
||||
const baseName =
|
||||
decodeReceiverChain(site.receiverChain)?.baseReceiverName ??
|
||||
receiverName.split(/[.([\s]/, 1)[0];
|
||||
const baseTypeRef =
|
||||
baseName === undefined
|
||||
? undefined
|
||||
|
|
@ -1256,7 +1247,7 @@ export function emitReceiverBoundCalls(
|
|||
// instead of letting the receiver-blind lexical fallback in
|
||||
// `lookupCore` match the enclosing class's member by name.
|
||||
// No-op for every language that leaves `Scope.ownsReceivers` unset.
|
||||
if (isReceiverOwnedButUnbound(site.inScope, receiverName, scopes)) {
|
||||
if (isReceiverOwnedButUnbound(site.inScope, baseName, scopes)) {
|
||||
options.recordSourceBinding?.(site, {
|
||||
status: 'blocked',
|
||||
targetDefIds: [],
|
||||
|
|
@ -1270,6 +1261,7 @@ export function emitReceiverBoundCalls(
|
|||
range: site.atRange,
|
||||
reason: 'receiver-owned-but-unbound',
|
||||
candidateIds: [],
|
||||
siteKind: site.kind,
|
||||
});
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
|
|
@ -3059,7 +3051,7 @@ export function emitReceiverBoundCalls(
|
|||
// First owner that declares one wins, matching every other walk in
|
||||
// this pass.
|
||||
let fieldOwnerId: string | undefined;
|
||||
let fieldDeclaredType: string | undefined;
|
||||
let fieldDeclaredType: ReturnType<typeof declaredTypeOfClassLevelField>;
|
||||
for (const ownerId of [
|
||||
headClass.nodeId,
|
||||
...scopes.methodDispatch.mroFor(headClass.nodeId),
|
||||
|
|
@ -3069,8 +3061,10 @@ export function emitReceiverBoundCalls(
|
|||
staticMemberReceiver.memberName,
|
||||
);
|
||||
if (declared === undefined) continue;
|
||||
fieldOwnerId = ownerId;
|
||||
fieldDeclaredType = declared;
|
||||
if (declared !== null) {
|
||||
fieldOwnerId = ownerId;
|
||||
fieldDeclaredType = declared;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Resolve the declared type from where it was WRITTEN — the
|
||||
|
|
@ -3081,14 +3075,14 @@ export function emitReceiverBoundCalls(
|
|||
const declaringScope =
|
||||
fieldOwnerId === undefined ? undefined : index.classScopeByDefId.get(fieldOwnerId)?.id;
|
||||
const receiverClass =
|
||||
declaringScope === undefined || fieldDeclaredType === undefined
|
||||
declaringScope === undefined || fieldDeclaredType == null
|
||||
? undefined
|
||||
: resolveClassBindingForName(
|
||||
declaringScope,
|
||||
fieldDeclaredType,
|
||||
fieldDeclaredType.name,
|
||||
scopes,
|
||||
provider.stripTypePreservingDecoration,
|
||||
{ onNameFallback },
|
||||
{ onNameFallback, purpose: fieldDeclaredType.purpose },
|
||||
);
|
||||
if (receiverClass !== undefined) {
|
||||
const chain = [
|
||||
|
|
@ -3172,7 +3166,7 @@ export function emitReceiverBoundCalls(
|
|||
site,
|
||||
confidence,
|
||||
calleeCapture,
|
||||
fieldDeclaredType,
|
||||
fieldDeclaredType?.name,
|
||||
);
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
|
|
@ -3199,7 +3193,8 @@ export function emitReceiverBoundCalls(
|
|||
// could not type (`x = ns.untyped()`) is a drop too, not an exact miss.
|
||||
(typeRef !== undefined &&
|
||||
BARE_TYPE_NAME_RE.test(receiverName) &&
|
||||
bindsNamespaceCallable(typeRef, namespaceTargets)))
|
||||
bindsNamespaceCallable(typeRef, namespaceTargets))) &&
|
||||
provider.isModuleExportAssignment?.(site, scopes, parsed) !== true
|
||||
) {
|
||||
// Decoded once: both the shape census and the origin classifier read the
|
||||
// same chain, and this is inside the drop guard so a resolved site pays
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
|
||||
import type { CallableFlowOperand, ParsedFile, Scope, ScopeId } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { answeredSiteKey } from '../resolution-outcome.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
import { resolveCallerGraphId } from '../graph-bridge/ids.js';
|
||||
|
|
@ -328,6 +329,13 @@ export function emitReturnShapeMemberAccesses(
|
|||
propertyNameIndex: PropertyNameIndex,
|
||||
/** Sites this pass resolves, so the name fallback leaves them alone. */
|
||||
handledSink: Set<string>,
|
||||
/**
|
||||
* Receives the {@link answeredSiteKey} of every site this pass emitted an edge
|
||||
* for. Narrower than `handledSink`, which also claims a site the producer's
|
||||
* shape DISPROVED — that site is settled, but it has no edge, so a drop
|
||||
* recorded for it must stand.
|
||||
*/
|
||||
answeredSink?: Set<string>,
|
||||
): ReturnShapeMemberStats {
|
||||
let emitted = 0;
|
||||
let memberNotOnShape = 0;
|
||||
|
|
@ -509,6 +517,7 @@ export function emitReturnShapeMemberAccesses(
|
|||
});
|
||||
// Claim the site so the name fallback cannot re-answer it differently.
|
||||
handledSink.add(siteKey);
|
||||
answeredSink?.add(answeredSiteKey(parsed.filePath, site.atRange, site.kind, site.name));
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,13 +63,14 @@
|
|||
* rather than through `tryEmitEdge`, whose target side takes a def.
|
||||
*/
|
||||
|
||||
import type { ImportEdge, ParsedFile, ScopeId } from 'gitnexus-shared';
|
||||
import type { ImportEdge, ParsedFile, ReferenceSite, ScopeId } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
import { resolveCallerGraphId } from '../graph-bridge/ids.js';
|
||||
import { callableFlowSiteKey } from './callable-value-flow.js';
|
||||
import { isTestFile } from '../../entry-point-scoring.js';
|
||||
import { answeredSiteKey } from '../resolution-outcome.js';
|
||||
import { getLanguageFromFilename } from 'gitnexus-shared';
|
||||
|
||||
/** Language a definition lives in, for reporting which anchor a reader cannot reach. */
|
||||
|
|
@ -299,21 +300,6 @@ function buildDirectImportMap(
|
|||
return byFile;
|
||||
}
|
||||
|
||||
/** Is this Property id qualified by `<owner>.`, allowing a position suffix? */
|
||||
function idOwnedBy(id: string, owner: string): boolean {
|
||||
const at = id.indexOf(`:${owner}.`);
|
||||
if (at === -1) return false;
|
||||
const rest = id.slice(at + owner.length + 2);
|
||||
return !rest.includes('.') || rest.indexOf('@') < rest.indexOf('.');
|
||||
}
|
||||
|
||||
/** Trailing symbol name of a graph id (`Function:a/b.js:buildB` -> `buildB`). */
|
||||
function simpleNameOfGraphId(graphId: string): string {
|
||||
const tail = graphId.slice(graphId.lastIndexOf(':') + 1);
|
||||
const at = tail.indexOf('@');
|
||||
return at === -1 ? tail : tail.slice(0, at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the single candidate a read in `readingFile` can plausibly mean.
|
||||
*
|
||||
|
|
@ -327,8 +313,7 @@ function narrowToSingleCandidate(
|
|||
candidatesIn: readonly PropertyCandidate[],
|
||||
readingFile: string,
|
||||
importedFiles: ReadonlySet<string> | undefined,
|
||||
/** Simple name of the callable the site sits in, when it could be resolved. */
|
||||
ownerName?: string,
|
||||
site: ReferenceSite,
|
||||
): { readonly id: string; readonly tier: string } | null {
|
||||
// DECLARED ANCHORS FIRST. A return shape is a real definition but a weaker
|
||||
// one: it says "some function builds an object with this key", where a named
|
||||
|
|
@ -338,20 +323,13 @@ function narrowToSingleCandidate(
|
|||
// return shapes were indexed at all.
|
||||
let candidates = candidatesIn;
|
||||
|
||||
// A SITE INSIDE ITS OWN RETURN SHAPE BINDS TO ITS OWN KEY.
|
||||
//
|
||||
// `export function buildB(row) { return { tickIntervalMs: row.b } }` writes
|
||||
// the key that IS `buildB.tickIntervalMs`. Ranking declared anchors above
|
||||
// return shapes is right for a READ through a receiver, but applied here it
|
||||
// handed that write to a same-named module const (`fnSettings.tickIntervalMs`)
|
||||
// that `buildB` never touches — a wrong edge, reported at the confident tier,
|
||||
// and the node the key actually defines was left with no writer at all.
|
||||
//
|
||||
// Checked before every other rule because it is evidence rather than ranking:
|
||||
// the owner qualifier on the candidate id and the enclosing callable are the
|
||||
// same symbol. Nothing else can outrank that.
|
||||
if (ownerName !== undefined && ownerName.length > 0) {
|
||||
const own = candidates.filter((c) => c.fromReturnShape && idOwnedBy(c.id, ownerName));
|
||||
// Only the key's declaration WRITE proves ownership. Other accesses inside
|
||||
// the producer still use ordinary ranking, even when they share its key name.
|
||||
if (site.kind === 'write') {
|
||||
const position = `@${site.atRange.startLine - 1}:${site.atRange.startCol}`;
|
||||
const own = candidates.filter(
|
||||
(c) => c.fromReturnShape && c.filePath === readingFile && c.id.endsWith(position),
|
||||
);
|
||||
if (own.length === 1) return { id: own[0]!.id, tier: 'own-return-shape' };
|
||||
}
|
||||
|
||||
|
|
@ -427,6 +405,13 @@ export function emitUniqueNamePropertyAccesses(
|
|||
* asserts nothing about what it means.
|
||||
*/
|
||||
reportOnly = false,
|
||||
/**
|
||||
* Receives the {@link answeredSiteKey} of every site this pass emitted an edge
|
||||
* for, so the caller can withdraw a `receiver-unresolved` drop the
|
||||
* receiver-bound pass recorded for it earlier. Never written in `reportOnly`
|
||||
* mode: a site that was only counted still has no edge.
|
||||
*/
|
||||
answeredSink?: Set<string>,
|
||||
): UniqueNamePropertyStats {
|
||||
const byName = prebuiltPropertyNameIndex ?? buildPropertyNameIndex(graph);
|
||||
const ownFilePaths = new Set(parsedFiles.map((p) => p.filePath));
|
||||
|
|
@ -489,8 +474,6 @@ export function emitUniqueNamePropertyAccesses(
|
|||
continue;
|
||||
}
|
||||
|
||||
// Resolved BEFORE narrowing: the enclosing callable is evidence the
|
||||
// ranking needs, not just the edge's source.
|
||||
const callerGraphId = resolveCallerGraphId(
|
||||
site.callerScope ?? site.inScope,
|
||||
indexes,
|
||||
|
|
@ -503,7 +486,7 @@ export function emitUniqueNamePropertyAccesses(
|
|||
candidates,
|
||||
parsed.filePath,
|
||||
directImports.get(parsed.filePath),
|
||||
simpleNameOfGraphId(callerGraphId),
|
||||
site,
|
||||
);
|
||||
if (choice === null) {
|
||||
ambiguous++;
|
||||
|
|
@ -532,6 +515,7 @@ export function emitUniqueNamePropertyAccesses(
|
|||
reason: `${EDGE_REASON} (${choice.tier}): ${site.kind}`,
|
||||
evidence: [],
|
||||
});
|
||||
answeredSink?.add(answeredSiteKey(parsed.filePath, site.atRange, site.kind, site.name));
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ import {
|
|||
} from '../utils/generic-instantiation.js';
|
||||
import { buildWorkspaceResolutionIndex } from '../workspace-index.js';
|
||||
import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js';
|
||||
import { withdrawAnsweredReceiverDrops } from '../resolution-outcome.js';
|
||||
import { logHeapProbe } from '../../utils/heap-probe.js';
|
||||
import { parseTruthyEnv } from '../../utils/env.js';
|
||||
|
||||
|
|
@ -764,6 +765,8 @@ export function runScopeResolution(
|
|||
if (extractionWarned) scopeExtractionFailedPaths.push(file.path);
|
||||
continue;
|
||||
}
|
||||
const captureSideChannel = provider.languageProvider.collectCaptureSideChannel?.(file.path);
|
||||
if (captureSideChannel !== undefined) parsed = { ...parsed, captureSideChannel };
|
||||
if (input.pdg === true && provider.languageProvider.collectSourceCfgs) {
|
||||
const warnCfg = (message: string): void => {
|
||||
logger.warn(message);
|
||||
|
|
@ -1509,6 +1512,7 @@ export function runScopeResolution(
|
|||
// several functions returning the same field name. Sites it resolves are
|
||||
// added to the skip set, so the fallback below never second-guesses them.
|
||||
const sharedPropertyIndex = input.prebuiltPropertyNameIndex ?? buildPropertyNameIndex(graph);
|
||||
const answeredAfterReceiverPass = new Set<string>();
|
||||
const returnShapeMembers = callableFlowOnly
|
||||
? { emitted: 0, memberNotOnShape: 0 }
|
||||
: emitReturnShapeMemberAccesses(
|
||||
|
|
@ -1519,6 +1523,7 @@ export function runScopeResolution(
|
|||
uniqueNameSkipSites,
|
||||
sharedPropertyIndex,
|
||||
uniqueNameSkipSites,
|
||||
answeredAfterReceiverPass,
|
||||
);
|
||||
|
||||
const nameFallbackDisabled = provider.fieldFallbackOnMethodLookup === false;
|
||||
|
|
@ -1540,7 +1545,9 @@ export function runScopeResolution(
|
|||
finalized,
|
||||
sharedPropertyIndex,
|
||||
nameFallbackDisabled,
|
||||
answeredAfterReceiverPass,
|
||||
);
|
||||
withdrawAnsweredReceiverDrops(resolutionOutcomes, answeredAfterReceiverPass);
|
||||
|
||||
// value-ref registrations (#2437): USES edges at the registration sites
|
||||
// plus field-based dispatch — synthesized CALLS from member-call sites to
|
||||
|
|
|
|||
|
|
@ -233,3 +233,58 @@ export function classifyReceiverShape(decoded: DecodedReceiverChain | undefined)
|
|||
}
|
||||
|
||||
export type ResolutionOutcomeRecorder = (outcome: ResolutionOutcome) => void;
|
||||
|
||||
/**
|
||||
* Identity of ONE reference site, for withdrawing that site's drop and no
|
||||
* other's.
|
||||
*
|
||||
* Position alone is not enough: `cfg.timeouts` (a read of `timeouts`) and
|
||||
* `cfg.timeouts.retry()` (a call of `retry`) both start at `cfg`, so a key of
|
||||
* file + line + column names two sites. The kind, member name, and full range
|
||||
* separate them, including repeated names such as `res.data.data`.
|
||||
*/
|
||||
export function answeredSiteKey(
|
||||
filePath: string,
|
||||
range: Range,
|
||||
siteKind: ReferenceKind,
|
||||
name: string,
|
||||
): string {
|
||||
return `${filePath}:${range.startLine}:${range.startCol}:${range.endLine}:${range.endCol}:${siteKind}:${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw every `receiver-unresolved` drop whose site a LATER pass answered
|
||||
* with an edge, keyed by {@link answeredSiteKey}. Returns how many it removed.
|
||||
*
|
||||
* The receiver-bound pass records a drop as soon as its own cases give up, but
|
||||
* it is not the last pass to see a read or write: the return-shape and
|
||||
* unique-name passes run after it and can still emit an `ACCESSES` edge for the
|
||||
* same site. A drop says the site was lost with no candidate, and a site with an
|
||||
* edge was not lost. A site those passes also left alone keeps its drop.
|
||||
*
|
||||
* A drop without a `siteKind` is kept: the key needs the kind, and guessing it
|
||||
* would withdraw on a weaker identity than the one the passes recorded.
|
||||
* Compacts in place, keeping the survivors in their recorded order.
|
||||
*/
|
||||
export function withdrawAnsweredReceiverDrops(
|
||||
outcomes: ResolutionOutcome[],
|
||||
answered: ReadonlySet<string>,
|
||||
): number {
|
||||
if (answered.size === 0) return 0;
|
||||
let kept = 0;
|
||||
for (const outcome of outcomes) {
|
||||
const isAnsweredDrop =
|
||||
outcome.kind === 'suppressed' &&
|
||||
outcome.reason === 'receiver-unresolved' &&
|
||||
outcome.siteKind !== undefined &&
|
||||
answered.has(
|
||||
answeredSiteKey(outcome.filePath, outcome.range, outcome.siteKind, outcome.name),
|
||||
);
|
||||
if (isAnsweredDrop) continue;
|
||||
outcomes[kept] = outcome;
|
||||
kept++;
|
||||
}
|
||||
const withdrawn = outcomes.length - kept;
|
||||
outcomes.length = kept;
|
||||
return withdrawn;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Aggregate `receiver-unresolved` resolution outcomes into the small,
|
||||
* Aggregate unresolved and owned-but-unbound receiver calls into the small,
|
||||
* index-persisted summary that `impact()` / `context()` read to decide whether
|
||||
* their result is exact or a lower bound (#2744, the second half of #2708).
|
||||
*
|
||||
|
|
@ -74,7 +74,7 @@ export interface UnresolvedReceiverSummary {
|
|||
* identical or the artifact churns on one map and not the other is exactly the
|
||||
*/
|
||||
/**
|
||||
* A `receiver-unresolved` drop at a CALL site.
|
||||
* An unresolved or owned-but-unbound receiver at a CALL site.
|
||||
*
|
||||
* ONE predicate, shared by the persisted summary and the per-file diagnostic —
|
||||
* the same contract `rankAndCap` states for its comparator. If the two ever
|
||||
|
|
@ -86,8 +86,9 @@ export interface UnresolvedReceiverSummary {
|
|||
* (`x.argtypes = [...]`) arrive alongside lost method calls — measured at 25 of
|
||||
* 124 drops on the fixture corpus. Counting them made the consumer's "N call
|
||||
* sites invoking X were dropped" literally false. A missing `siteKind` counts as
|
||||
* a call: the only emitter always sets it, and erring toward `lower-bound` is
|
||||
* the safe direction for an epistemic signal.
|
||||
* a call for `receiver-unresolved`, preserving the older recorder contract.
|
||||
* Owned-but-unbound outcomes require an explicit call kind because that
|
||||
* recorder also reports property accesses.
|
||||
*
|
||||
* Receiver ORIGIN is deliberately NOT decided here — the summary routes external
|
||||
* drops into their own bucket while the diagnostic excludes them, and collapsing
|
||||
|
|
@ -98,8 +99,9 @@ function isUnresolvedReceiverCall(
|
|||
): outcome is Extract<ResolutionOutcome, { kind: 'suppressed' }> {
|
||||
return (
|
||||
outcome.kind === 'suppressed' &&
|
||||
outcome.reason === 'receiver-unresolved' &&
|
||||
(outcome.siteKind === undefined || outcome.siteKind === 'call')
|
||||
((outcome.reason === 'receiver-unresolved' &&
|
||||
(outcome.siteKind === undefined || outcome.siteKind === 'call')) ||
|
||||
(outcome.reason === 'receiver-owned-but-unbound' && outcome.siteKind === 'call'))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -667,7 +667,7 @@ const clearCaches = (): void => {
|
|||
// Keyed by class node startIndex (unique per AST node within a file).
|
||||
// ============================================================================
|
||||
|
||||
const fieldInfoCache = new Map<number, Map<string, FieldInfo>>();
|
||||
const fieldInfoCache = new Map<number, Map<string, FieldInfo[]>>();
|
||||
|
||||
/**
|
||||
* Walk up from a definition node to find the nearest enclosing class/struct/interface
|
||||
|
|
@ -796,14 +796,14 @@ const NOOP_SYMBOL_TABLE: SymbolTableReader = {
|
|||
|
||||
/**
|
||||
* Get (or extract and cache) field info for a class node.
|
||||
* Returns a name→FieldInfo map, or undefined if the provider has no field extractor
|
||||
* Returns a name→declarations map, or undefined if the provider has no field extractor
|
||||
* or the class yielded no fields.
|
||||
*/
|
||||
function getFieldInfo(
|
||||
classNode: SyntaxNode,
|
||||
provider: LanguageProvider,
|
||||
context: FieldExtractorContext,
|
||||
): Map<string, FieldInfo> | undefined {
|
||||
): Map<string, FieldInfo[]> | undefined {
|
||||
if (!provider.fieldExtractor) return undefined;
|
||||
|
||||
const cacheKey = classNode.startIndex;
|
||||
|
|
@ -813,14 +813,52 @@ function getFieldInfo(
|
|||
const result = provider.fieldExtractor.extract(classNode, context);
|
||||
if (!result?.fields?.length) return undefined;
|
||||
|
||||
cached = new Map<string, FieldInfo>();
|
||||
cached = new Map<string, FieldInfo[]>();
|
||||
for (const field of result.fields) {
|
||||
cached.set(field.name, field);
|
||||
const declarations = cached.get(field.name);
|
||||
if (declarations !== undefined) declarations.push(field);
|
||||
else cached.set(field.name, [field]);
|
||||
}
|
||||
for (const declarations of cached.values()) {
|
||||
if (declarations.length > 1) {
|
||||
declarations.sort((a, b) => a.line - b.line || (a.column ?? -1) - (b.column ?? -1));
|
||||
}
|
||||
}
|
||||
fieldInfoCache.set(cacheKey, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
function fieldInfoLowerBound(
|
||||
declarations: readonly FieldInfo[],
|
||||
line: number,
|
||||
column: number,
|
||||
): number {
|
||||
let lo = 0;
|
||||
let hi = declarations.length;
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
const field = declarations[mid]!;
|
||||
if (field.line < line || (field.line === line && (field.column ?? -1) < column)) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
function selectFieldInfo(
|
||||
declarations: readonly FieldInfo[] | undefined,
|
||||
line: number,
|
||||
column?: number,
|
||||
): FieldInfo | undefined {
|
||||
if (declarations === undefined || declarations.length === 0) return undefined;
|
||||
if (declarations.length === 1) return declarations[0];
|
||||
const start = fieldInfoLowerBound(declarations, line, -1);
|
||||
const end = fieldInfoLowerBound(declarations, line + 1, -1);
|
||||
if (end - start === 1) return declarations[start];
|
||||
if (column === undefined) return undefined;
|
||||
const index = fieldInfoLowerBound(declarations, line, column);
|
||||
return index < end && declarations[index]?.column === column ? declarations[index] : undefined;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MethodExtractor cache — extract method metadata once per class, reuse for each method.
|
||||
// Keyed by class node startIndex (unique per AST node within a file).
|
||||
|
|
@ -2099,7 +2137,7 @@ const processFileGroup = (
|
|||
const propEnclosingClassId =
|
||||
propEnclosingInfo?.qualifiedClassId ?? propEnclosingInfo?.classId ?? null;
|
||||
// Enrich routed properties with FieldExtractor metadata
|
||||
let routedFieldMap: Map<string, FieldInfo> | undefined;
|
||||
let routedFieldMap: Map<string, FieldInfo[]> | undefined;
|
||||
if (provider.fieldExtractor && typeEnv) {
|
||||
const classNode = findEnclosingClassNode(captureMap['call']);
|
||||
if (classNode) {
|
||||
|
|
@ -2112,7 +2150,11 @@ const processFileGroup = (
|
|||
}
|
||||
}
|
||||
for (const item of routed.items) {
|
||||
const routedFieldInfo = routedFieldMap?.get(item.propName);
|
||||
const routedFieldInfo = selectFieldInfo(
|
||||
routedFieldMap?.get(item.propName),
|
||||
captureMap['call'].startPosition.row + 1,
|
||||
captureMap['call'].startPosition.column,
|
||||
);
|
||||
const propQualifiedName = propEnclosingInfo
|
||||
? `${propEnclosingInfo.className}.${item.propName}`
|
||||
: item.propName;
|
||||
|
|
@ -2956,7 +2998,11 @@ const processFileGroup = (
|
|||
filePath: file.path,
|
||||
language,
|
||||
});
|
||||
const info = fieldMap?.get(nodeName);
|
||||
const info = selectFieldInfo(
|
||||
fieldMap?.get(nodeName),
|
||||
definitionNode.startPosition.row + 1,
|
||||
definitionNode.startPosition.column,
|
||||
);
|
||||
if (info) {
|
||||
declaredType = info.type ?? undefined;
|
||||
// Mutate methodProps BEFORE the `{...methodProps}` spread below —
|
||||
|
|
@ -2964,6 +3010,9 @@ const processFileGroup = (
|
|||
if (info.rawDeclaredType !== undefined) {
|
||||
methodProps.rawDeclaredType = info.rawDeclaredType;
|
||||
}
|
||||
if (info.initializerType !== undefined) {
|
||||
methodProps.initializerType = info.initializerType;
|
||||
}
|
||||
// Field annotations ('@Name' strings, U2 PR #2200) — omit when empty.
|
||||
if (info.annotations !== undefined && info.annotations.length > 0) {
|
||||
methodProps.annotations = info.annotations;
|
||||
|
|
|
|||
92
gitnexus/test/integration/commonjs-extraction-parity.test.ts
Normal file
92
gitnexus/test/integration/commonjs-extraction-parity.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js';
|
||||
import { javascriptScopeResolver } from '../../src/core/ingestion/languages/javascript/scope-resolver.js';
|
||||
import { typescriptScopeResolver } from '../../src/core/ingestion/languages/typescript/scope-resolver.js';
|
||||
import { isCommonJsExportAssignment } from '../../src/core/ingestion/languages/typescript/cjs-export-receiver.js';
|
||||
import { runScopeResolution } from '../../src/core/ingestion/scope-resolution/pipeline/run.js';
|
||||
import { createWorkerPool } from '../../src/core/ingestion/workers/worker-pool.js';
|
||||
import type { ParseWorkerResult } from '../../src/core/ingestion/workers/parse-worker.js';
|
||||
|
||||
const cases = [
|
||||
{
|
||||
path: 'common.js',
|
||||
content: 'module.exports.value = 1;',
|
||||
resolver: javascriptScopeResolver,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
path: 'esm.js',
|
||||
content: 'export {}; module.exports.value = 1;',
|
||||
resolver: javascriptScopeResolver,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
path: 'common.ts',
|
||||
content: 'module.exports.value = 1;',
|
||||
resolver: typescriptScopeResolver,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
path: 'esm.ts',
|
||||
content: 'export {}; module.exports.value = 1;',
|
||||
resolver: typescriptScopeResolver,
|
||||
expected: false,
|
||||
},
|
||||
];
|
||||
|
||||
describe('CommonJS metadata across extraction paths', () => {
|
||||
let fromWorkers: Map<string, ParsedFile>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const pool = createWorkerPool(
|
||||
new URL('../../dist/core/ingestion/workers/parse-worker.js', import.meta.url),
|
||||
1,
|
||||
);
|
||||
try {
|
||||
const results = await pool.dispatch<{ path: string; content: string }, ParseWorkerResult>(
|
||||
cases.map(({ path, content }) => ({ path, content })),
|
||||
);
|
||||
const parsed = results.flatMap((result) => result.parsedFiles);
|
||||
expect(parsed).toHaveLength(cases.length);
|
||||
fromWorkers = new Map(parsed.map((file) => [file.filePath, file]));
|
||||
} finally {
|
||||
await pool.terminate();
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it.each(cases)(
|
||||
'$path agrees for uncached main-thread and worker extraction',
|
||||
({ path, content, resolver, expected }) => {
|
||||
for (const mode of ['uncached', 'worker'] as const) {
|
||||
let observed = false;
|
||||
let channel: unknown;
|
||||
let exportAssignment: boolean | undefined;
|
||||
runScopeResolution(
|
||||
{
|
||||
graph: createKnowledgeGraph(),
|
||||
model: createSemanticModel(),
|
||||
files: [{ path, content }],
|
||||
...(mode === 'worker' ? { preExtractedParsedFiles: fromWorkers } : {}),
|
||||
onResolved: ({ parsedFiles, indexes }) => {
|
||||
expect(parsedFiles).toHaveLength(1);
|
||||
const parsed = parsedFiles[0]!;
|
||||
channel = parsed.captureSideChannel;
|
||||
const writes = parsed.referenceSites.filter(
|
||||
(site) => site.kind === 'write' && site.name === 'value',
|
||||
);
|
||||
expect(writes).toHaveLength(1);
|
||||
exportAssignment = isCommonJsExportAssignment(writes[0]!, indexes, parsed);
|
||||
observed = true;
|
||||
},
|
||||
},
|
||||
resolver,
|
||||
);
|
||||
expect(observed, mode).toBe(true);
|
||||
expect(channel, mode).toBe(expected);
|
||||
expect(exportAssignment, mode).toBe(expected);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
116
gitnexus/test/integration/field-info-worker-metadata.test.ts
Normal file
116
gitnexus/test/integration/field-info-worker-metadata.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import { createWorkerPool } from '../../src/core/ingestion/workers/worker-pool.js';
|
||||
import type { ParseWorkerResult } from '../../src/core/ingestion/workers/parse-worker.js';
|
||||
|
||||
describe('field declaration metadata through the parse worker', () => {
|
||||
it.each([
|
||||
['static first', 'static p = new StaticTarget();\n p = new InstanceTarget();'],
|
||||
['instance first', 'p = new InstanceTarget();\n static p = new StaticTarget();'],
|
||||
['same line', 'static p = new StaticTarget(); p = new InstanceTarget();'],
|
||||
])(
|
||||
'keeps static and instance initializer metadata in separate worker records: %s',
|
||||
async (_, fields) => {
|
||||
const pool = createWorkerPool(
|
||||
new URL('../../dist/core/ingestion/workers/parse-worker.js', import.meta.url),
|
||||
1,
|
||||
);
|
||||
try {
|
||||
const results = await pool.dispatch<{ path: string; content: string }, ParseWorkerResult>([
|
||||
{ path: 'fields.ts', content: `class Holder {\n ${fields}\n}` },
|
||||
]);
|
||||
const properties = results
|
||||
.flatMap((result) => result.nodes)
|
||||
.filter((node) => node.label === 'Property');
|
||||
expect(properties).toHaveLength(2);
|
||||
expect(properties.filter((node) => node.properties.isStatic === true)).toHaveLength(1);
|
||||
expect(
|
||||
properties.find((node) => node.properties.isStatic === true)?.properties.initializerType,
|
||||
).toBe('StaticTarget');
|
||||
expect(
|
||||
properties.find((node) => node.properties.isStatic === false)?.properties.initializerType,
|
||||
).toBe('InstanceTarget');
|
||||
} finally {
|
||||
await pool.terminate();
|
||||
}
|
||||
},
|
||||
120000,
|
||||
);
|
||||
it('keeps readonly metadata when an accessor argument is on a later line', async () => {
|
||||
const pool = createWorkerPool(
|
||||
new URL('../../dist/core/ingestion/workers/parse-worker.js', import.meta.url),
|
||||
1,
|
||||
);
|
||||
try {
|
||||
const results = await pool.dispatch<{ path: string; content: string }, ParseWorkerResult>([
|
||||
{
|
||||
path: 'reader.rb',
|
||||
content: 'class Reader\n attr_reader(\n :foo\n )\n attr_reader :foo\nend\n',
|
||||
},
|
||||
]);
|
||||
const properties = results
|
||||
.flatMap((result) => result.nodes)
|
||||
.filter((node) => node.label === 'Property');
|
||||
expect(
|
||||
properties.map((node) => ({
|
||||
name: node.properties.name,
|
||||
line: node.properties.startLine,
|
||||
readonly: node.properties.isReadonly,
|
||||
})),
|
||||
).toEqual([
|
||||
{ name: 'foo', line: 2, readonly: true },
|
||||
{ name: 'foo', line: 4, readonly: true },
|
||||
]);
|
||||
} finally {
|
||||
await pool.terminate();
|
||||
}
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
describe('Ruby accessor mutability on the logical property', () => {
|
||||
it('combines readers and writers independently of declaration order', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ruby-accessor-mutability-'));
|
||||
try {
|
||||
await fs.writeFile(
|
||||
path.join(root, 'accessors.rb'),
|
||||
[
|
||||
'class ReaderThenWriter',
|
||||
' attr_reader :foo',
|
||||
' attr_writer :foo',
|
||||
'end',
|
||||
'class WriterThenReader',
|
||||
' attr_writer :foo',
|
||||
' attr_reader :foo',
|
||||
'end',
|
||||
'class ReadersOnly',
|
||||
' attr_reader :foo',
|
||||
' attr_reader :foo',
|
||||
'end',
|
||||
'class MultiName',
|
||||
' attr_reader :foo, :bar',
|
||||
' attr_writer :bar',
|
||||
'end',
|
||||
].join('\n'),
|
||||
);
|
||||
const result = await runPipelineFromRepo(root, () => {}, {});
|
||||
const properties = [...result.graph.iterNodes()].filter((node) => node.label === 'Property');
|
||||
const readonly = (owner: string, name = 'foo') => {
|
||||
const property = properties.find(
|
||||
(node) => node.id === `Property:accessors.rb:${owner}.${name}`,
|
||||
);
|
||||
expect(property).toBeDefined();
|
||||
return property?.properties.isReadonly;
|
||||
};
|
||||
expect(readonly('ReaderThenWriter')).toBe(false);
|
||||
expect(readonly('WriterThenReader')).toBe(false);
|
||||
expect(readonly('ReadersOnly')).toBe(true);
|
||||
expect(readonly('MultiName')).toBe(true);
|
||||
expect(readonly('MultiName', 'bar')).toBe(false);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 120000);
|
||||
});
|
||||
|
|
@ -6,7 +6,7 @@ import path from 'path';
|
|||
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
|
||||
import type { PipelineOptions } from '../../../src/core/ingestion/pipeline.js';
|
||||
import type { PipelineResult } from '../../../src/types/pipeline.js';
|
||||
import type { GraphRelationship } from 'gitnexus-shared';
|
||||
import type { GraphRelationship, ReferenceKind } from 'gitnexus-shared';
|
||||
|
||||
export const FIXTURES = path.resolve(__dirname, '..', '..', 'fixtures', 'lang-resolution');
|
||||
export const CROSS_FILE_FIXTURES = path.resolve(
|
||||
|
|
@ -68,6 +68,59 @@ export function getResolutionOutcomes(result: PipelineResult) {
|
|||
return result.resolutionOutcomes ?? [];
|
||||
}
|
||||
|
||||
/** File, 1-based start line, column, member name, and reference kind. */
|
||||
export type OutcomeSite = readonly [
|
||||
filePath: string,
|
||||
line: number,
|
||||
col: number,
|
||||
name: string,
|
||||
kind: ReferenceKind,
|
||||
];
|
||||
|
||||
/**
|
||||
* Reasons recorded for the requested kind and member at this start position.
|
||||
*
|
||||
* A call and a read can share both their member name and start position.
|
||||
*/
|
||||
export function suppressionReasonsAt(
|
||||
result: PipelineResult,
|
||||
[filePath, line, col, name, siteKind]: OutcomeSite,
|
||||
): string[] {
|
||||
return getResolutionOutcomes(result)
|
||||
.flatMap((outcome) =>
|
||||
outcome.kind === 'suppressed' &&
|
||||
outcome.filePath === filePath &&
|
||||
outcome.range.startLine === line &&
|
||||
outcome.range.startCol === col &&
|
||||
outcome.name === name &&
|
||||
outcome.siteKind === siteKind
|
||||
? [outcome.reason]
|
||||
: [],
|
||||
)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an `ACCESSES` edge `sourceId -> targetId` was emitted for the site
|
||||
* at `line:col`. The name-inference and return-shape passes key each edge id by
|
||||
* the site position, which is what ties the edge to one site rather than to any
|
||||
* access between the same two nodes.
|
||||
*/
|
||||
export function hasAccessesAtSite(
|
||||
result: PipelineResult,
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
line: number,
|
||||
col: number,
|
||||
): boolean {
|
||||
return getRelationships(result, 'ACCESSES').some(
|
||||
(edge) =>
|
||||
edge.rel.sourceId === sourceId &&
|
||||
edge.rel.targetId === targetId &&
|
||||
edge.rel.id.endsWith(`:${line}:${col}`),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationships whose source or target id does not resolve to a live graph node.
|
||||
* A non-empty result means the graph has dangling edges (an endpoint that was
|
||||
|
|
|
|||
|
|
@ -213,6 +213,12 @@ export class StaticCtorThis {
|
|||
static constructor() { this.p = new Alien(); }
|
||||
run(x) { return this.p.inner().compute(x); }
|
||||
}
|
||||
export class StaticReadOnly {
|
||||
static q = new Alien();
|
||||
}
|
||||
export class StaticReader {
|
||||
readOnly(x) { return StaticReadOnly.q.inner().compute(x); }
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Python ───────────────────────────────────────────────────────────────────
|
||||
|
|
@ -1197,6 +1203,11 @@ const CASES: readonly LanguageCase[] = [
|
|||
// `Host.p` (static twin) Outer ✗ WRONG Alien ✓ correct
|
||||
// `Host.q` (static, no twin) — none, missed Alien ✓ correct
|
||||
//
|
||||
// The `Host.q` cell has moved since, without touching the drop: the
|
||||
// class-level receiver route types ONE hop from the static Property node's
|
||||
// initializer type, so `Host.q.m()` now resolves `Alien.m` (the second row
|
||||
// below). Only the twin row still pays the trade in full.
|
||||
//
|
||||
// So the trade is NOT the "missed edge beats a wrong one" the comment on
|
||||
// `isStaticClassFieldBinding` claims, and this row is why that comment now
|
||||
// says otherwise. For a class with BOTH twins the wrong edge did not go
|
||||
|
|
@ -1216,17 +1227,22 @@ const CASES: readonly LanguageCase[] = [
|
|||
status: 'resolves',
|
||||
},
|
||||
// The static-only shape — no instance twin, so nothing at all is left in
|
||||
// the map and the chain simply loses its type. This is the genuine MISSED
|
||||
// edge, and the common static shape; the row above is the rarer one. A
|
||||
// `known-gap` row rather than a positive assertion because there is no
|
||||
// surviving target to name — the whole point is that the edge is gone. It
|
||||
// is protected from vacuity by the `every row has a live caller node`
|
||||
// guard, the same way this file's other known-gap rows are.
|
||||
// the map. This was the genuine MISSED edge, and the common static shape.
|
||||
// The class-level receiver route does not read the map: it types
|
||||
// `StaticReadOnly.q` from the static Property NODE, which now carries the
|
||||
// type its initializer constructs when the field declares none. So the
|
||||
// first hop resolves to `Alien.inner` — and not to `Outer.inner`, which
|
||||
// `Alien` shares the name with, so this cannot pass on a wrong type.
|
||||
//
|
||||
// `Inner.compute` is still absent, and that is the remaining gap: the
|
||||
// second hop's receiver `StaticReadOnly.q.inner()` goes through the
|
||||
// compound fold, which types a field step only from the class-scope map
|
||||
// the static binding is kept out of.
|
||||
{
|
||||
name: 'static-read-without-a-twin-loses-its-type',
|
||||
name: 'static-read-without-a-twin-types-from-its-initializer',
|
||||
callerId: `Method:${TS_FILE}:StaticReader.readOnly#1`,
|
||||
targets: [],
|
||||
status: 'known-gap',
|
||||
targets: [`Method:${TS_FILE}:Alien.inner#0`],
|
||||
status: 'resolves',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -1289,6 +1305,16 @@ const CASES: readonly LanguageCase[] = [
|
|||
targets: [`Method:${JS_FILE}:Outer.inner#0`],
|
||||
status: 'resolves',
|
||||
},
|
||||
// JavaScript's half of the TypeScript static-only row: a field typed by
|
||||
// nothing but its initializer, read through the class. JavaScript has no
|
||||
// annotation to fall back on, so the initializer is the only evidence
|
||||
// there is — the `Config.DEFAULT.validate()` shape.
|
||||
{
|
||||
name: 'static-read-without-a-twin-types-from-its-initializer',
|
||||
callerId: `Method:${JS_FILE}:StaticReader.readOnly#1`,
|
||||
targets: [`Method:${JS_FILE}:Alien.inner#0`],
|
||||
status: 'resolves',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -31,7 +31,15 @@
|
|||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
|
||||
import {
|
||||
FIXTURES,
|
||||
getRelationships,
|
||||
hasAccessesAtSite,
|
||||
runPipelineFromRepo,
|
||||
suppressionReasonsAt,
|
||||
type OutcomeSite,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
describe('JavaScript plain-object property access (A1/A5)', () => {
|
||||
let result: PipelineResult;
|
||||
|
|
@ -166,6 +174,76 @@ describe('JavaScript plain-object property access (A1/A5)', () => {
|
|||
expect(propertyNames()).not.toContain('notAConstructedField');
|
||||
expect(writersOf('notAConstructedField')).toEqual([]);
|
||||
});
|
||||
|
||||
// The capture makes the ENCLOSING LITERAL the receiver, and the
|
||||
// receiver-bound pass treats any receiver text holding a `.` or `(` as a
|
||||
// compound expression to fold — which `{ x: row.a }`, `{ r: 0.5 }` and
|
||||
// `{ ...seed, extra }` all do. The fold cannot type a literal, so each of
|
||||
// these keys was recorded as a `receiver-unresolved` drop, and the drop
|
||||
// stayed after the unique-name pass answered the same site with an edge.
|
||||
// Each row pins both halves: the edge exists at THIS site, and no drop does.
|
||||
it.each<readonly [OutcomeSite, string, string]>([
|
||||
[
|
||||
['destructured.js', 26, 4, 'exitContract', 'write'],
|
||||
'Function:destructured.js:buildPlan',
|
||||
'Property:destructured.js:buildPlan.exitContract@25:4',
|
||||
],
|
||||
[
|
||||
['destructured.js', 27, 6, 'destructuredOnlyField', 'write'],
|
||||
'Function:destructured.js:buildPlan',
|
||||
'Property:destructured.js:destructuredDefaults.destructuredOnlyField',
|
||||
],
|
||||
[
|
||||
['destructured.js', 34, 4, 'destructuredOnlyField', 'write'],
|
||||
'Function:destructured.js:buildFlat',
|
||||
'Property:destructured.js:buildFlat.destructuredOnlyField@33:4',
|
||||
],
|
||||
[
|
||||
['frozen.js', 26, 20, 'extra', 'write'],
|
||||
'Function:frozen.js:buildRules',
|
||||
'Property:frozen.js:buildRules.extra@25:20',
|
||||
],
|
||||
[
|
||||
['return-shape-typed.js', 6, 4, 'ambiguousProducedField', 'write'],
|
||||
'Function:return-shape-typed.js:producerAlpha',
|
||||
'Property:return-shape-typed.js:producerAlpha.ambiguousProducedField@5:4',
|
||||
],
|
||||
[
|
||||
['return-shape-typed.js', 12, 4, 'ambiguousProducedField', 'write'],
|
||||
'Function:return-shape-typed.js:producerBeta',
|
||||
'Property:return-shape-typed.js:producerBeta.ambiguousProducedField@11:4',
|
||||
],
|
||||
[
|
||||
['return-shape.js', 8, 4, 'returnShapeOnlyField', 'write'],
|
||||
'Function:return-shape.js:formatAlert',
|
||||
'Property:return-shape.js:formatAlert.returnShapeOnlyField@7:4',
|
||||
],
|
||||
[
|
||||
['return-shape.js', 9, 4, 'sharedWithDeclared', 'write'],
|
||||
'Function:return-shape.js:formatAlert',
|
||||
'Property:return-shape.js:formatAlert.sharedWithDeclared@8:4',
|
||||
],
|
||||
[
|
||||
['return-shape.js', 20, 4, 'summaryOnlyField', 'write'],
|
||||
'Function:return-shape.js:formatSummary',
|
||||
'Property:return-shape.js:formatSummary.summaryOnlyField@19:4',
|
||||
],
|
||||
[
|
||||
['return-shape.js', 45, 13, 'anonReturnKey', 'write'],
|
||||
'File:return-shape.js',
|
||||
'Property:return-shape.js:fn@43:2.anonReturnKey@44:13',
|
||||
],
|
||||
[
|
||||
['return-shape.js', 53, 4, 'productionAndTestField', 'write'],
|
||||
'Function:return-shape.js:buildProductionShape',
|
||||
'Property:return-shape.js:buildProductionShape.productionAndTestField@52:4',
|
||||
],
|
||||
])('answers %j with an edge and records no drop for it', (site, sourceId, targetId) => {
|
||||
expect({
|
||||
edge: hasAccessesAtSite(result, sourceId, targetId, site[1], site[2]),
|
||||
suppressions: suppressionReasonsAt(result, site),
|
||||
}).toEqual({ edge: true, suppressions: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// R3-4. The dominant shape in idiomatic JS and the one with no anchor at all:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
/**
|
||||
* JavaScript: self/this resolution, parent resolution, super resolution
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { summarizeUnresolvedReceivers } from '../../../src/core/ingestion/scope-resolution/unresolved-receivers.js';
|
||||
import {
|
||||
FIXTURES,
|
||||
CROSS_FILE_FIXTURES,
|
||||
|
|
@ -11,6 +14,9 @@ import {
|
|||
getNodesByLabelFull,
|
||||
edgeSet,
|
||||
runPipelineFromRepo,
|
||||
suppressionReasonsAt,
|
||||
writeFixtureRepo,
|
||||
type OutcomeSite,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
|
|
@ -321,6 +327,23 @@ describe('Field type resolution (JavaScript)', () => {
|
|||
expect(def!.properties.isStatic).toBe(true);
|
||||
expect(def!.properties.visibility).toBe('public');
|
||||
});
|
||||
|
||||
// `static DEFAULT = new Config()` declares no type, and a static field's
|
||||
// initializer binding is kept out of the class scope on purpose (it would
|
||||
// retype an instance field of the same name). So `Config.DEFAULT` had no
|
||||
// type anywhere and the call was dropped. The Property node now records the
|
||||
// type its initializer constructs, and the class-level receiver route reads it.
|
||||
it('records the type a static field initializer constructs on its Property node', () => {
|
||||
const def = getNodesByLabelFull(result, 'Property').find((p) => p.name === 'DEFAULT');
|
||||
expect(def?.properties.initializerType).toBe('Config');
|
||||
});
|
||||
|
||||
it('resolves Config.DEFAULT.validate() through the static field initializer type', () => {
|
||||
const targets = getRelationships(result, 'CALLS')
|
||||
.filter((c) => c.source === 'validateConfig')
|
||||
.map((c) => c.rel.targetId);
|
||||
expect(targets).toEqual(['Method:models.js:Config.validate#0']);
|
||||
});
|
||||
});
|
||||
|
||||
// ACCESSES write edges from assignment expressions
|
||||
|
|
@ -691,3 +714,263 @@ describe('JavaScript inline constructor receiver resolution', () => {
|
|||
expect(call!.rel.targetId).toContain('Legacy');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CommonJS export assignments are declarations, not dropped member writes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CommonJS export assignment receivers (JavaScript)', () => {
|
||||
let repoDir: string | undefined;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-js-cjs-export-receiver-'));
|
||||
writeFixtureRepo(repoDir, {
|
||||
'lib.js': `exports.memberRequest = function (url) {
|
||||
return url;
|
||||
};
|
||||
module.exports.arrowRequest = (url) => url;
|
||||
function wrap(module) {
|
||||
module.exports.wrapped = 1;
|
||||
}
|
||||
module.exports.api.nested = 1;
|
||||
`,
|
||||
'esm-export.js': 'export {};\nmodule.exports.esmExport = 1;\n',
|
||||
'esm-import.js': 'import "./side-effect.js";\nmodule.exports.esmImport = 1;\n',
|
||||
'side-effect.js': 'const initialized = true;\n',
|
||||
'explicit.mjs': 'module.exports.explicitEsm = 1;\n',
|
||||
});
|
||||
result = await runPipelineFromRepo(repoDir, () => {}, {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// `module.exports.arrowRequest = …` DECLARES this module's `arrowRequest`
|
||||
// export, exactly as `exports.memberRequest = …` on line 1 does. Its receiver
|
||||
// merely has a dot in it, so the receiver-bound pass tried to fold
|
||||
// `module.exports` as a value, failed, and filed the declaration as a dropped
|
||||
// member write. The bare `exports` spelling never reached that path at all.
|
||||
//
|
||||
// Inside `wrap`, `module` is a PARAMETER: that write goes through a local
|
||||
// value the resolver cannot type, so it is a real drop and must stay one. So
|
||||
// is the last line, which writes INTO a value hanging off the export object
|
||||
// rather than declaring an export.
|
||||
it.each<readonly [OutcomeSite, readonly string[]]>([
|
||||
[['lib.js', 1, 0, 'memberRequest', 'write'], []],
|
||||
[['lib.js', 4, 0, 'arrowRequest', 'write'], []],
|
||||
[['lib.js', 6, 2, 'wrapped', 'write'], ['receiver-unresolved']],
|
||||
[['lib.js', 8, 0, 'nested', 'write'], ['receiver-unresolved']],
|
||||
])('records %j as %j', (site, reasons) => {
|
||||
expect(suppressionReasonsAt(result, site)).toEqual(reasons);
|
||||
});
|
||||
|
||||
// Instrumentation only: the declaration keeps the write edge it already had,
|
||||
// and gains nothing.
|
||||
it('keeps the module-level write edge to each exported function', () => {
|
||||
const writes = getRelationships(result, 'ACCESSES')
|
||||
.filter((e) => e.sourceLabel === 'File' && e.rel.reason === 'scope-resolution: write')
|
||||
.map((e) => e.target)
|
||||
.sort();
|
||||
expect(writes).toEqual(['arrowRequest', 'memberRequest']);
|
||||
});
|
||||
|
||||
it.each(['esm-export.js', 'esm-import.js', 'explicit.mjs'])(
|
||||
'keeps a receiver drop for an unbound module in %s',
|
||||
(filePath) => {
|
||||
expect(
|
||||
result.resolutionOutcomes?.some(
|
||||
(outcome) =>
|
||||
outcome.kind === 'suppressed' &&
|
||||
outcome.filePath === filePath &&
|
||||
outcome.reason === 'receiver-unresolved' &&
|
||||
outcome.siteKind === 'write',
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Literal writes and owned receiver bases preserve bindings', () => {
|
||||
let result: PipelineResult;
|
||||
let fixtureRoot: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-literal-bindings-'));
|
||||
writeFixtureRepo(fixtureRoot, {
|
||||
'private.js': `const config = { privateFlag: true };
|
||||
class PrivateProducer {
|
||||
#make(row) { return { privateFlag: row.value }; }
|
||||
}
|
||||
`,
|
||||
'owned.dart': `class DartWorker { void go() {} }
|
||||
class DartBundle { DartWorker worker = DartWorker(); }
|
||||
class DartOwner {
|
||||
DartBundle task = DartBundle();
|
||||
void loop(xs) {
|
||||
for (final task in xs) { task.worker.go(); }
|
||||
}
|
||||
}
|
||||
`,
|
||||
'input.js': `const config = { secretFlag: true };
|
||||
export class SignalService {
|
||||
make(options) {
|
||||
options.secretFlag = false;
|
||||
return { secretFlag: options.secretFlag };
|
||||
}
|
||||
}
|
||||
new SignalService().make(config);
|
||||
`,
|
||||
'foreign-reader.js': `const local = { foreignFlag: 1 };
|
||||
export class Service { make(obj) { return obj.foreignFlag; } }
|
||||
`,
|
||||
'foreign-producer.js': `export class Service { make() { return { foreignFlag: 0.5 }; } }
|
||||
`,
|
||||
'repeated.js': `function build() { return { data: { data: 1 } }; }
|
||||
function use() { const res = build(); return res.data.data; }
|
||||
`,
|
||||
'static.js': `class StaticService { go() {} }
|
||||
class StaticOther { go() {} }
|
||||
class StaticBase { static svc = new StaticService(); }
|
||||
class NullChild extends StaticBase { static svc = null; }
|
||||
class UnknownChild extends StaticBase { static svc = unknownFactory(); }
|
||||
class InheritedChild extends StaticBase {}
|
||||
class TypedChild extends StaticBase { static svc = new StaticOther(); }
|
||||
function nullProbe() { return NullChild.svc.go(); }
|
||||
function unknownProbe() { return UnknownChild.svc.go(); }
|
||||
function inheritedProbe() { return InheritedChild.svc.go(); }
|
||||
function typedProbe() { return TypedChild.svc.go(); }
|
||||
`,
|
||||
'constructor.js': `class JsRepo { save() {} }
|
||||
class JsActual { save() {} }
|
||||
function jsShadow(JsRepo) {
|
||||
class JsHolder { static item = new JsRepo(); }
|
||||
JsHolder.item.save();
|
||||
}
|
||||
jsShadow(JsActual);
|
||||
`,
|
||||
'constructor.ts': `class TsRepo { save() {} }
|
||||
class TsActual { save() {} }
|
||||
function tsShadow(TsRepo: any) {
|
||||
class TsHolder { static item = new TsRepo(); }
|
||||
TsHolder.item.save();
|
||||
}
|
||||
function tsAnnotated(TsRepo: any) {
|
||||
class AnnotatedHolder { static item: TsRepo; }
|
||||
AnnotatedHolder.item.save();
|
||||
}
|
||||
tsShadow(TsActual);
|
||||
tsAnnotated(TsActual);
|
||||
`,
|
||||
});
|
||||
result = await runPipelineFromRepo(fixtureRoot, () => {}, { skipGraphPhases: true });
|
||||
}, 120000);
|
||||
|
||||
afterAll(() => {
|
||||
if (fixtureRoot !== undefined) fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each(['read', 'write'])('keeps an input property %s on the declared input', (kind) => {
|
||||
const edges = getRelationships(result, 'ACCESSES').filter(
|
||||
(e) =>
|
||||
e.rel.sourceId === 'Method:input.js:SignalService.make#1' &&
|
||||
e.rel.reason?.endsWith(`: ${kind}`),
|
||||
);
|
||||
expect(edges.some((e) => e.rel.targetId === 'Property:input.js:config.secretFlag')).toBe(true);
|
||||
if (kind === 'read') {
|
||||
expect(edges.some((e) => e.rel.targetId.includes('SignalService.make.secretFlag@'))).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the returned literal declaration write on its own key', () => {
|
||||
const writes = getRelationships(result, 'ACCESSES').filter(
|
||||
(e) =>
|
||||
e.rel.sourceId === 'Method:input.js:SignalService.make#1' &&
|
||||
e.rel.targetId === 'Property:input.js:SignalService.make.secretFlag@4:13' &&
|
||||
e.rel.reason?.endsWith(': write'),
|
||||
);
|
||||
expect(writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('binds a private method literal write to its own returned key', () => {
|
||||
const writes = getRelationships(result, 'ACCESSES').filter(
|
||||
(e) =>
|
||||
e.sourceFilePath === 'private.js' &&
|
||||
e.sourceLabel !== 'File' &&
|
||||
e.rel.reason?.endsWith(': write'),
|
||||
);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0]?.rel.targetId).toContain(
|
||||
'Property:private.js:PrivateProducer.#make.privateFlag@',
|
||||
);
|
||||
});
|
||||
|
||||
it('records a Dart chain on a masked field base as owned but unbound', () => {
|
||||
expect(getRelationships(result, 'CALLS').filter((e) => e.source === 'loop')).toEqual([]);
|
||||
expect(
|
||||
result.resolutionOutcomes?.some(
|
||||
(o) =>
|
||||
o.kind === 'suppressed' &&
|
||||
o.reason === 'receiver-owned-but-unbound' &&
|
||||
o.filePath === 'owned.dart' &&
|
||||
o.name === 'go',
|
||||
),
|
||||
).toBe(true);
|
||||
const owned =
|
||||
result.resolutionOutcomes?.filter((outcome) => outcome.filePath === 'owned.dart') ?? [];
|
||||
expect(summarizeUnresolvedReceivers(owned)?.counts.go).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the unresolved outer read when a repeated-name inner read was answered', () => {
|
||||
const drops = result.resolutionOutcomes?.filter(
|
||||
(outcome) =>
|
||||
outcome.kind === 'suppressed' &&
|
||||
outcome.reason === 'receiver-unresolved' &&
|
||||
outcome.filePath === 'repeated.js' &&
|
||||
outcome.name === 'data',
|
||||
);
|
||||
expect(drops).toHaveLength(1);
|
||||
expect(drops?.[0]).toMatchObject({
|
||||
range: { startLine: 2, startCol: 45, endCol: 58 },
|
||||
});
|
||||
expect(
|
||||
getRelationships(result, 'ACCESSES').some(
|
||||
(edge) =>
|
||||
edge.sourceFilePath === 'repeated.js' &&
|
||||
edge.rel.reason === 'scope-resolution: return-shape member: read',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not borrow a same-named method return shape from another file', () => {
|
||||
const reads = getRelationships(result, 'ACCESSES').filter(
|
||||
(e) => e.rel.sourceId === 'Method:foreign-reader.js:Service.make#1',
|
||||
);
|
||||
expect(reads.map((e) => e.rel.targetId)).toEqual([
|
||||
'Property:foreign-reader.js:local.foreignFlag',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['nullProbe', 'unknownProbe'])('stops at an untyped static override in %s', (source) => {
|
||||
expect(getRelationships(result, 'CALLS').filter((e) => e.source === source)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['inheritedProbe', 'Method:static.js:StaticService.go#0'],
|
||||
['typedProbe', 'Method:static.js:StaticOther.go#0'],
|
||||
['tsAnnotated', 'Method:constructor.ts:TsRepo.save#0'],
|
||||
])('resolves the available field type in %s', (source, target) => {
|
||||
expect(
|
||||
getRelationships(result, 'CALLS')
|
||||
.filter((e) => e.source === source)
|
||||
.map((e) => e.rel.targetId),
|
||||
).toContain(target);
|
||||
});
|
||||
|
||||
it.each(['jsShadow', 'tsShadow'])('respects the constructor value parameter in %s', (source) => {
|
||||
expect(getRelationships(result, 'CALLS').filter((e) => e.source === source)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,7 +20,15 @@
|
|||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
|
||||
import {
|
||||
FIXTURES,
|
||||
getRelationships,
|
||||
hasAccessesAtSite,
|
||||
runPipelineFromRepo,
|
||||
suppressionReasonsAt,
|
||||
type OutcomeSite,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
describe('member-call producers (W2-1)', () => {
|
||||
let result: PipelineResult;
|
||||
|
|
@ -75,4 +83,44 @@ describe('member-call producers (W2-1)', () => {
|
|||
// 0.5 name tier and get answered by an unrelated same-named key.
|
||||
expect(shapeTargetsOf('readAbsent')).toEqual([]);
|
||||
});
|
||||
|
||||
// The WRITE side of the same shapes: each producer writes the keys of the
|
||||
// literal it returns. A METHOD's graph id carries its overload suffix
|
||||
// (`SignalService.make#0`) while its keys are qualified `SignalService.make.`,
|
||||
// so the own-return-shape match missed every method, three same-named
|
||||
// candidates were left, and the write got no edge at all. Every row is also
|
||||
// free of a `receiver-unresolved` drop: the literal's decimals made the
|
||||
// receiver-bound pass file the site as one before the edge existed.
|
||||
it.each<readonly [OutcomeSite, string, string]>([
|
||||
[
|
||||
['src/producer.js', 5, 13, 'secretFlag', 'write'],
|
||||
'Method:src/producer.js:SignalService.make#0',
|
||||
'Property:src/producer.js:SignalService.make.secretFlag@4:13',
|
||||
],
|
||||
[
|
||||
['src/producer.js', 5, 38, 'wickRatio', 'write'],
|
||||
'Method:src/producer.js:SignalService.make#0',
|
||||
'Property:src/producer.js:SignalService.make.wickRatio@4:38',
|
||||
],
|
||||
[
|
||||
['src/producer.js', 9, 13, 'secretFlag', 'write'],
|
||||
'Method:src/producer.js:SignalService.other#0',
|
||||
'Property:src/producer.js:SignalService.other.secretFlag@8:13',
|
||||
],
|
||||
[
|
||||
['src/producer.js', 15, 11, 'secretFlag', 'write'],
|
||||
'Function:src/producer.js:makeSignal',
|
||||
'Property:src/producer.js:makeSignal.secretFlag@14:11',
|
||||
],
|
||||
[
|
||||
['src/producer.js', 15, 36, 'wickRatio', 'write'],
|
||||
'Function:src/producer.js:makeSignal',
|
||||
'Property:src/producer.js:makeSignal.wickRatio@14:36',
|
||||
],
|
||||
])('binds the producer write %j to its own key with no drop', (site, sourceId, targetId) => {
|
||||
expect({
|
||||
edge: hasAccessesAtSite(result, sourceId, targetId, site[1], site[2]),
|
||||
suppressions: suppressionReasonsAt(result, site),
|
||||
}).toEqual({ edge: true, suppressions: [] });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,7 +17,15 @@
|
|||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
|
||||
import {
|
||||
FIXTURES,
|
||||
getRelationships,
|
||||
hasAccessesAtSite,
|
||||
runPipelineFromRepo,
|
||||
suppressionReasonsAt,
|
||||
type OutcomeSite,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
describe('caller-derived parameter types (W2-2)', () => {
|
||||
let result: PipelineResult;
|
||||
|
|
@ -142,4 +150,36 @@ describe('caller-derived parameter types (W2-2)', () => {
|
|||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]).toContain('makeSpike.wickRatio');
|
||||
});
|
||||
|
||||
// Each producer WRITES the keys of its own returned literal, and the
|
||||
// unique-name pass answers that write with the key it defines. The literal
|
||||
// holds a decimal, so the receiver-bound pass had already filed the same site
|
||||
// as a `receiver-unresolved` drop — and kept it after the edge was emitted.
|
||||
it.each<readonly [OutcomeSite, string, string]>([
|
||||
[
|
||||
['src/producer.js', 4, 11, 'wickRatio', 'write'],
|
||||
'Function:src/producer.js:makeSpike',
|
||||
'Property:src/producer.js:makeSpike.wickRatio@3:11',
|
||||
],
|
||||
[
|
||||
['src/producer.js', 4, 27, 'source', 'write'],
|
||||
'Function:src/producer.js:makeSpike',
|
||||
'Property:src/producer.js:makeSpike.source@3:27',
|
||||
],
|
||||
[
|
||||
['src/producer.js', 8, 11, 'wickRatio', 'write'],
|
||||
'Function:src/producer.js:makeCandle',
|
||||
'Property:src/producer.js:makeCandle.wickRatio@7:11',
|
||||
],
|
||||
[
|
||||
['src/producer.js', 8, 27, 'source', 'write'],
|
||||
'Function:src/producer.js:makeCandle',
|
||||
'Property:src/producer.js:makeCandle.source@7:27',
|
||||
],
|
||||
])('answers the producer write %j with its own key and no drop', (site, sourceId, targetId) => {
|
||||
expect({
|
||||
edge: hasAccessesAtSite(result, sourceId, targetId, site[1], site[2]),
|
||||
suppressions: suppressionReasonsAt(result, site),
|
||||
}).toEqual({ edge: true, suppressions: [] });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ import {
|
|||
edgeSet,
|
||||
getResolutionOutcomes,
|
||||
runPipelineFromRepo,
|
||||
suppressionReasonsAt,
|
||||
writeFixtureRepo,
|
||||
type OutcomeSite,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
|
|
@ -3213,6 +3215,20 @@ describe('TS dynamic-this receiver seeding guard', () => {
|
|||
const calls = getRelationships(result, 'CALLS');
|
||||
expect(calls.some((c) => c.target === 'go' && c.source === 'onClick')).toBe(false);
|
||||
});
|
||||
|
||||
// Both sites on `this.route.go()` are lost for ONE reason: the object-literal
|
||||
// method owns `this` and gives it no type. The call's receiver is the chain
|
||||
// `this.route`, and the owned-receiver check was asked about that whole text
|
||||
// — which no scope owns — instead of its base `this`. So the call went on to
|
||||
// the compound fold and was filed as a generic `receiver-unresolved` drop,
|
||||
// or, when the read beside it (same start column) was handled first, as
|
||||
// nothing at all.
|
||||
it.each<readonly [OutcomeSite, readonly string[]]>([
|
||||
[['app.ts', 17, 6, 'route', 'read'], ['receiver-owned-but-unbound']],
|
||||
[['app.ts', 17, 6, 'go', 'call'], ['receiver-owned-but-unbound']],
|
||||
])('records %j under its real reason', (site, reasons) => {
|
||||
expect(suppressionReasonsAt(result, site)).toEqual(reasons);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
51
gitnexus/test/unit/commonjs-module-kind.test.ts
Normal file
51
gitnexus/test/unit/commonjs-module-kind.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
import JavaScript from 'tree-sitter-javascript';
|
||||
import {
|
||||
javascriptProvider,
|
||||
typescriptProvider,
|
||||
} from '../../src/core/ingestion/languages/typescript.js';
|
||||
import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js';
|
||||
import { isCommonJsModule } from '../../src/core/ingestion/languages/typescript/cjs-export-assignment.js';
|
||||
import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js';
|
||||
|
||||
describe('deep CommonJS classification', () => {
|
||||
it.each([false, true])('handles a deep expression with trailing ESM=%s', (esm) => {
|
||||
const node = (type: string, namedChildren: SyntaxNode[], text = '') =>
|
||||
({ type, namedChildren, text, childForFieldName: () => null }) as unknown as SyntaxNode;
|
||||
let expression = node('identifier', [], 'module');
|
||||
for (let i = 0; i < 20000; i++) expression = node('binary_expression', [expression]);
|
||||
const root = node('program', esm ? [expression, node('export_statement', [])] : [expression]);
|
||||
expect(isCommonJsModule(root, 'deep.ts')).toBe(!esm);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
['JavaScript', javascriptProvider, JavaScript, 'js', 'cjs', 'mjs'],
|
||||
['TypeScript', typescriptProvider, TypeScript.typescript, 'ts', 'cts', 'mts'],
|
||||
] as const)(
|
||||
'%s module-kind capture',
|
||||
(_, provider, grammar, extension, commonJsExtension, esmExtension) => {
|
||||
it.each([
|
||||
['module.exports.value = 1;', extension, true],
|
||||
['export {}; module.exports.value = 1;', extension, false],
|
||||
['import "./dep"; module.exports.value = 1;', extension, false],
|
||||
['export {}; module.exports.value = 1;', commonJsExtension, true],
|
||||
['module.exports.value = 1;', esmExtension, false],
|
||||
] as const)('carries %j in .%s as %s through serialization', (source, suffix, expected) => {
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(grammar);
|
||||
const tree = parser.parse(source);
|
||||
const filePath = `module.${suffix}`;
|
||||
const parsed = extractParsedFile(provider, source, filePath, undefined, tree);
|
||||
expect(parsed).toBeDefined();
|
||||
const channel = provider.collectCaptureSideChannel?.(filePath);
|
||||
expect(channel).toBe(expected);
|
||||
expect(
|
||||
JSON.parse(JSON.stringify({ ...parsed, captureSideChannel: channel })).captureSideChannel,
|
||||
).toBe(expected);
|
||||
expect(provider.collectCaptureSideChannel?.(filePath)).toBeUndefined();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { TypeScriptFieldExtractor } from '../../src/core/ingestion/field-extractors/typescript.js';
|
||||
import { createFieldExtractor } from '../../src/core/ingestion/field-extractors/generic.js';
|
||||
import { typescriptConfig } from '../../src/core/ingestion/field-extractors/configs/typescript-javascript.js';
|
||||
import {
|
||||
javascriptConfig,
|
||||
typescriptConfig,
|
||||
} from '../../src/core/ingestion/field-extractors/configs/typescript-javascript.js';
|
||||
import { pythonConfig } from '../../src/core/ingestion/field-extractors/configs/python.js';
|
||||
import { goConfig } from '../../src/core/ingestion/field-extractors/configs/go.js';
|
||||
import { cppConfig } from '../../src/core/ingestion/field-extractors/configs/c-cpp.js';
|
||||
|
|
@ -9,11 +12,15 @@ import { rubyConfig } from '../../src/core/ingestion/field-extractors/configs/ru
|
|||
import { dartConfig } from '../../src/core/ingestion/field-extractors/configs/dart.js';
|
||||
import { javaConfig, kotlinConfig } from '../../src/core/ingestion/field-extractors/configs/jvm.js';
|
||||
import { swiftConfig } from '../../src/core/ingestion/field-extractors/configs/swift.js';
|
||||
import type { FieldExtractorContext } from '../../src/core/ingestion/field-types.js';
|
||||
import type {
|
||||
ExtractedFields,
|
||||
FieldExtractorContext,
|
||||
} from '../../src/core/ingestion/field-types.js';
|
||||
import type { TypeEnvironment } from '../../src/core/ingestion/type-env.js';
|
||||
import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js';
|
||||
import Parser from 'tree-sitter';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
import JavaScript from 'tree-sitter-javascript';
|
||||
import Python from 'tree-sitter-python';
|
||||
import Go from 'tree-sitter-go';
|
||||
import Cpp from 'tree-sitter-cpp';
|
||||
|
|
@ -1501,3 +1508,63 @@ describeSwift('GenericFieldExtractor — Swift (F75 protocol property requiremen
|
|||
expect(matches[0].type).toBe('String');
|
||||
});
|
||||
});
|
||||
|
||||
// A JS/TS field with no declared type is typed only by what its initializer
|
||||
// constructs. The scope query binds that type for an INSTANCE field, but a
|
||||
// static field's binding is kept out of the class scope (it would retype an
|
||||
// instance field of the same name), so the field extractor records it on the
|
||||
// field instead, for the Property node. A declared type, when present, is the
|
||||
// type, and nothing is recorded beside it.
|
||||
describe('field initializer type (JS/TS)', () => {
|
||||
const javascriptExtractor = createFieldExtractor(javascriptConfig);
|
||||
const typescriptExtractor = new TypeScriptFieldExtractor();
|
||||
const context = createMockContext();
|
||||
const initializerTypeOf = (
|
||||
language: Parser.Language,
|
||||
extract: (node: Parser.SyntaxNode) => ExtractedFields | null,
|
||||
source: string,
|
||||
field: string,
|
||||
): string | undefined => {
|
||||
const classParser = new Parser();
|
||||
classParser.setLanguage(language);
|
||||
const [classNode] = classParser.parse(source).rootNode.namedChildren;
|
||||
return extract(classNode)?.fields.find((f) => f.name === field)?.initializerType;
|
||||
};
|
||||
|
||||
it.each<readonly [string, string, string | undefined]>([
|
||||
['static DEFAULT = new Config();', 'DEFAULT', 'Config'],
|
||||
['static repo = new models.Repo();', 'repo', 'models.Repo'],
|
||||
['p = new Outer();', 'p', 'Outer'],
|
||||
['#q = new Outer();', '#q', 'Outer'],
|
||||
['static n = 5;', 'n', undefined],
|
||||
['static made = make();', 'made', undefined],
|
||||
['static bare;', 'bare', undefined],
|
||||
])('JavaScript `%s` -> %s: %s', (member, field, expected) => {
|
||||
expect(
|
||||
initializerTypeOf(
|
||||
JavaScript,
|
||||
(node) => javascriptExtractor.extract(node, context),
|
||||
`class C { ${member} }`,
|
||||
field,
|
||||
),
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it.each<readonly [string, string, string | undefined]>([
|
||||
['static DEFAULT = new Config();', 'DEFAULT', 'Config'],
|
||||
['static repo = new models.Repo();', 'repo', 'models.Repo'],
|
||||
['private p = new Outer();', 'p', 'Outer'],
|
||||
['static r: Alien = new Alien();', 'r', undefined],
|
||||
['static n = 5;', 'n', undefined],
|
||||
['static made = make();', 'made', undefined],
|
||||
])('TypeScript `%s` -> %s: %s', (member, field, expected) => {
|
||||
expect(
|
||||
initializerTypeOf(
|
||||
TypeScript.typescript,
|
||||
(node) => typescriptExtractor.extract(node, context),
|
||||
`class C { ${member} }`,
|
||||
field,
|
||||
),
|
||||
).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
36
gitnexus/test/unit/resolver-outcome-helpers.test.ts
Normal file
36
gitnexus/test/unit/resolver-outcome-helpers.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { suppressionReasonsAt } from '../integration/resolvers/helpers.js';
|
||||
import type { PipelineResult } from '../../src/types/pipeline.js';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
|
||||
describe('suppressionReasonsAt', () => {
|
||||
it('separates a call and a read of the same member at the same position', () => {
|
||||
const common = {
|
||||
kind: 'suppressed' as const,
|
||||
phase: 'receiver-bound-calls',
|
||||
filePath: 'app.go',
|
||||
name: 'save',
|
||||
range: { startLine: 1, startCol: 0, endLine: 1, endCol: 10 },
|
||||
candidateIds: [],
|
||||
};
|
||||
const result: PipelineResult = {
|
||||
graph: createKnowledgeGraph(),
|
||||
repoPath: '/repo',
|
||||
totalFileCount: 1,
|
||||
usedWorkerPool: false,
|
||||
reparsedFileCount: 0,
|
||||
scopeExtractionFailures: [],
|
||||
unavailableScopeLanguageFiles: 0,
|
||||
resolutionOutcomes: [
|
||||
{ ...common, reason: 'receiver-unresolved', siteKind: 'call' },
|
||||
{ ...common, reason: 'receiver-owned-but-unbound', siteKind: 'read' },
|
||||
],
|
||||
};
|
||||
expect(suppressionReasonsAt(result, ['app.go', 1, 0, 'save', 'call'])).toEqual([
|
||||
'receiver-unresolved',
|
||||
]);
|
||||
expect(suppressionReasonsAt(result, ['app.go', 1, 0, 'save', 'read'])).toEqual([
|
||||
'receiver-owned-but-unbound',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* A `receiver-unresolved` drop is a claim that the site was lost with no
|
||||
* candidate. The receiver-bound pass records it as soon as its own cases give
|
||||
* up, but the read/write passes that run after it (return-shape members,
|
||||
* unique-name properties) can still answer the same site with an `ACCESSES`
|
||||
* edge. Such a site is not lost, so its drop is withdrawn — and ONLY its drop:
|
||||
* every other outcome, and every drop at a site nothing answered, stays.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
answeredSiteKey,
|
||||
withdrawAnsweredReceiverDrops,
|
||||
} from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js';
|
||||
import type { ResolutionOutcome } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js';
|
||||
|
||||
type SiteKind = 'call' | 'read' | 'write';
|
||||
|
||||
const at = (startLine: number, startCol: number) => ({
|
||||
startLine,
|
||||
startCol,
|
||||
endLine: startLine,
|
||||
endCol: startCol + 1,
|
||||
});
|
||||
|
||||
function drop(
|
||||
name: string,
|
||||
siteKind: SiteKind,
|
||||
line = 4,
|
||||
col = 2,
|
||||
): Extract<ResolutionOutcome, { kind: 'suppressed' }> {
|
||||
return {
|
||||
kind: 'suppressed',
|
||||
reason: 'receiver-unresolved',
|
||||
candidateIds: [],
|
||||
phase: 'receiver-bound-calls',
|
||||
filePath: 'src/producer.js',
|
||||
name,
|
||||
range: at(line, col),
|
||||
siteKind,
|
||||
};
|
||||
}
|
||||
|
||||
/** The one site the later pass answered: the WRITE of `wickRatio` at 4:2. */
|
||||
const ANSWERED = new Set([answeredSiteKey('src/producer.js', at(4, 2), 'write', 'wickRatio')]);
|
||||
|
||||
describe('withdrawAnsweredReceiverDrops', () => {
|
||||
it('keeps an unanswered repeated-name chain that ends after the answered inner read', () => {
|
||||
const inner = drop('data', 'read');
|
||||
const outer: ResolutionOutcome = {
|
||||
...drop('data', 'read'),
|
||||
range: { ...at(4, 2), endCol: 12 },
|
||||
};
|
||||
const outcomes = [inner, outer];
|
||||
const answered = new Set([answeredSiteKey(inner.filePath, inner.range, 'read', 'data')]);
|
||||
expect(withdrawAnsweredReceiverDrops(outcomes, answered)).toBe(1);
|
||||
expect(outcomes).toEqual([outer]);
|
||||
});
|
||||
|
||||
it.each<{ readonly label: string; readonly outcome: ResolutionOutcome; readonly kept: boolean }>([
|
||||
{ label: 'the answered write itself', outcome: drop('wickRatio', 'write'), kept: false },
|
||||
// `a.b` and `a.b.c()` start at the same column, so position alone cannot
|
||||
// tell the read the later pass answered from the call nothing answered.
|
||||
{
|
||||
label: 'a call at the same position with another name',
|
||||
outcome: drop('save', 'call'),
|
||||
kept: true,
|
||||
},
|
||||
{
|
||||
label: 'a read of the same name at the same position',
|
||||
outcome: drop('wickRatio', 'read'),
|
||||
kept: true,
|
||||
},
|
||||
{
|
||||
label: 'the same write one column over',
|
||||
outcome: drop('wickRatio', 'write', 4, 3),
|
||||
kept: true,
|
||||
},
|
||||
{
|
||||
label: 'a different suppression at the answered site',
|
||||
outcome: {
|
||||
kind: 'suppressed',
|
||||
reason: 'member-lookup-ambiguous',
|
||||
candidateIds: ['a', 'b'],
|
||||
phase: 'receiver-bound-calls',
|
||||
filePath: 'src/producer.js',
|
||||
name: 'wickRatio',
|
||||
range: at(4, 2),
|
||||
siteKind: 'write',
|
||||
},
|
||||
kept: true,
|
||||
},
|
||||
{
|
||||
label: 'a drop that carries no site kind',
|
||||
outcome: {
|
||||
kind: 'suppressed',
|
||||
reason: 'receiver-unresolved',
|
||||
candidateIds: [],
|
||||
phase: 'receiver-bound-calls',
|
||||
filePath: 'src/producer.js',
|
||||
name: 'wickRatio',
|
||||
range: at(4, 2),
|
||||
},
|
||||
kept: true,
|
||||
},
|
||||
])('$label kept=$kept', ({ outcome, kept }) => {
|
||||
const outcomes = [outcome];
|
||||
const withdrawn = withdrawAnsweredReceiverDrops(outcomes, ANSWERED);
|
||||
expect({ outcomes, withdrawn }).toEqual({
|
||||
outcomes: kept ? [outcome] : [],
|
||||
withdrawn: kept ? 0 : 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the survivors in their recorded order', () => {
|
||||
const first = drop('save', 'call', 1, 0);
|
||||
const answered = drop('wickRatio', 'write');
|
||||
const last = drop('secretFlag', 'write', 9, 9);
|
||||
const outcomes = [first, answered, last];
|
||||
withdrawAnsweredReceiverDrops(outcomes, ANSWERED);
|
||||
expect(outcomes).toEqual([first, last]);
|
||||
});
|
||||
|
||||
it('withdraws nothing when nothing was answered', () => {
|
||||
const outcomes = [drop('wickRatio', 'write')];
|
||||
expect(withdrawAnsweredReceiverDrops(outcomes, new Set())).toBe(0);
|
||||
expect(outcomes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -33,7 +33,7 @@ function dropped(
|
|||
name: string,
|
||||
siteKind: 'call' | 'read' | 'write' = 'call',
|
||||
receiverOrigin?: ReceiverOrigin,
|
||||
): ResolutionOutcome {
|
||||
): Extract<ResolutionOutcome, { kind: 'suppressed' }> {
|
||||
return {
|
||||
kind: 'suppressed',
|
||||
reason: 'receiver-unresolved',
|
||||
|
|
@ -48,6 +48,19 @@ function dropped(
|
|||
}
|
||||
|
||||
describe('summarizeUnresolvedReceivers', () => {
|
||||
it.each(['call', 'read', 'write'] as const)(
|
||||
'counts owned-but-unbound %s sites only when they are calls',
|
||||
(siteKind) => {
|
||||
const outcome: ResolutionOutcome = {
|
||||
...dropped('go', siteKind),
|
||||
reason: 'receiver-owned-but-unbound',
|
||||
};
|
||||
expect(summarizeUnresolvedReceivers([outcome])).toEqual(
|
||||
siteKind === 'call' ? { counts: { go: 1 }, totalSites: 1 } : undefined,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns undefined when nothing was dropped, so a clean repo stores no key', () => {
|
||||
expect(summarizeUnresolvedReceivers([])).toBeUndefined();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue