fix(resolution): correct receiver drops and property ownership (#3653)
Some checks are pending
Gitleaks / gitleaks (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run

* fix(js): withdraw receiver drops for object-literal key writes a later pass answers

The JS capture makes the enclosing object literal the receiver of each key
it writes (`return { wickRatio: row.a }`). Case 0 of the receiver-bound pass
treats any receiver text holding `.` or `(` as a compound expression, and a
literal holding `row.a`, `0.5` or `...seed` does, so the fold failed and the
site was recorded as a `receiver-unresolved` drop. The return-shape and
unique-name passes run later and answered 17 of these 19 writes with an
ACCESSES edge, but nothing withdrew the earlier drop.

Both passes now report the sites they emitted an edge for, keyed by file,
position, site kind and member name (position alone is shared by `a.b` and
`a.b.c()`), and run.ts withdraws the matching `receiver-unresolved` drops
once they have run. A site they leave unanswered keeps its drop.

The other two writes, in `SignalService.make()`, had no edge at all: the
own-return-shape tier read the caller's owner qualifier as
`SignalService.make#0`, so it never matched the method's keys
(`SignalService.make.secretFlag`) and three same-named candidates were
refused. The qualifier is now the qualified name after `<Label>:<file>:`
without the `#<arity>` suffix. It also stops cutting a positioned id
(`outer.parse@25:2`) down to its column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(js): type a static field from its initializer for class-level receivers

`Config.DEFAULT.validate()` with `static DEFAULT = new Config()` produced no
CALLS edge. JS and TS keep a static field's type binding out of the class
scope on purpose: the scope has one binding map for static and instance
members, so the static binding would retype an instance field of the same
name. The class-level receiver route (`declaredTypeOfClassLevelField`) reads
the static Property node instead, but only its annotation-derived
`declaredType`, and an unannotated field has none. The type had nowhere to
live.

The field extractor now records `initializerType` for a field that declares
no type and is initialized by `new X()` / `new ns.X()` (the same two shapes
the scope queries bind), the parse worker stamps it on the Property node, and
the class-level route falls back to it after `declaredType`. The binding still
never enters the shared class-scope map, so the instance-twin hazard does not
return.

This resolves one hop: `Host.q.m()` now reaches `Alien.m`. A longer chain off
the field still goes through the compound fold, which types a field step only
from the class-scope map, so it stays unresolved. The matrix known-gap row
`static-read-without-a-twin-loses-its-type` flips to a positive row for TS,
with a JS twin added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(js): keep CommonJS module.exports declarations out of receiver drops

`module.exports.arrowRequest = (url) => fetch(url)` declares this module's
`arrowRequest` export; the CJS capture rules already bind it like
`exports.memberRequest = …`. The same line also yields a write reference
whose receiver is `module.exports`, and because that text has a dot the
receiver-bound pass folded it as a compound value, failed, and recorded the
declaration as a `receiver-unresolved` write. The bare `exports.x = …`
spelling never reached that path.

A new ScopeResolver hook, `isModuleExportAssignment`, lets a language say a
write site is a module-export declaration. The receiver-bound pass consults
it only at the drop guard, after every case has failed, so it records no
drop and changes nothing else: the site is not marked handled, no edge is
added, and the existing module-level write edge stays. The JS and TS
resolvers implement it for a receiver of exactly `module.exports` (from the
minted chain, or the text when no chain was minted) when `module` resolves
to nothing in scope. A `module` parameter, as in a UMD wrapper, or a write
into a value hanging off the export object still records its drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): record an owned-`this` chain receiver as owned-but-unbound

`this.route.go()` inside an object-literal method was filed as a generic
`receiver-unresolved` drop (or, depending on site order, as nothing). The
method scope owns `this` and gives it no type, which the pass detects with
`isReceiverOwnedButUnbound`, but that check was handed the whole receiver
text `this.route`. No scope owns a chain, so the call fell through to Case 0,
whose fold cannot type an owned `this` either.

The check now receives the receiver's base (from the minted chain, else the
text before the first `.`/`(`), the same base the `suppressReceiverLookup`
check already used. The call is recorded as `receiver-owned-but-unbound`,
its real reason, and still emits no edge: the dynamic-this negative control
holds. On the fixture corpus this moves only that site; no edge changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(resolution): preserve receiver ownership

Bind a return key's declaration write by exact file and position so
unrelated input accesses keep their ordinary candidate ranking. Stop at
untyped static overrides and resolve constructor initializers as values.

Preserve the combined drop guards after rebasing onto main, cover the
binding regressions, and refresh the receiver baseline after #3654.

Key answered sites by their full range to retain repeated-name outer
reads. Keep owned-but-unbound calls in the persisted completeness signal.

* fix(resolution): address feedback (#3653)

Classify module-export declarations using captured module kind before
suppressing receiver drops. Match same-named field metadata by source
position, including declarations on one line.

Require reference kinds in suppression assertions and guard temporary
fixture cleanup. Cover the reported failures with regression tests.

* perf(parser): index repeated field declarations

Sort private field buckets once and use binary search to avoid
quadratic scans while preserving declaration disambiguation.

* fix(ingestion): preserve capture metadata

Record module kind from the parsed tree on worker and main-thread paths.
Match Ruby field metadata at the accessor call instead of its argument.

* fix(ingestion): harden capture metadata

Keep Ruby reader/writer properties writable. Walk module-kind ASTs iteratively so deep expressions cannot abort extraction.

* test(ingestion): clarify worker metadata scope

* docs(ingestion): state capture metadata effects

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Abhigyan Patwari 2026-10-22 10:29:59 +05:30 • committed by GitHub
parent d57f570dff
commit 2b44565534
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1511 additions and 129 deletions

View file

@ -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
@ -921,3 +945,11 @@ Two incidental corrections from that check, both to KTD6:
drop is recorded. An untyped fixture parameter had been reporting a language
gap that was really a fixture defect — the same error class as the untyped
`$repo` control caught earlier.
### 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.

View file

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

View file

@ -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++) {

View file

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

View file

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

View file

@ -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;
}
/**

View file

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

View file

@ -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).

View file

@ -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,

View file

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

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

@ -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

View file

@ -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

View file

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

View file

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

View file

@ -110,6 +110,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';
@ -699,6 +700,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);
@ -1443,6 +1446,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(
@ -1453,6 +1457,7 @@ export function runScopeResolution(
uniqueNameSkipSites,
sharedPropertyIndex,
uniqueNameSkipSites,
answeredAfterReceiverPass,
);
const nameFallbackDisabled = provider.fieldFallbackOnMethodLookup === false;
@ -1474,7 +1479,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

View file

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

View file

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

View file

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

View 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);
}
},
);
});

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

View file

@ -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

View file

@ -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',
},
],
},
{

View file

@ -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:

View file

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

View file

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

View file

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

View file

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

View 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();
});
},
);

View file

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

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

View file

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

View file

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