merge: sync inheritance fixes with main

This commit is contained in:
Gergo Magyar 2026-10-22 05:18:06 +00:00
commit 7174db4f87
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
@ -925,3 +949,11 @@ Two incidental corrections from that check, both to KTD6:
### Same-package inheritance review corrections
The fallback excludes function-local and nested JVM candidates, while local and nested deriving classes can still use package-level bases. Synthetic deriving classes do not enter this fallback; their source captures do not retain the qualifier evidence needed here. C# filters by the written generic arity before source-root proximity. Five native assertions fail before the corrections; all 14 focused checks and all 755 Java/Kotlin/C# resolver and helper tests pass afterward. Build and both TypeScript configurations pass. After merging main `d57f570d`, receiver counts and all shape states match main (135 calls, 182 total, 27 reads and 20 writes).
### Merged main `f6e42092`
Re-measured both benchmark arms after merging the Zig receiver fix from main.
Shape states remain unchanged; counts are 133 call drops and
160 drops of all kinds (27 reads,
0 writes). The counts include both main and this branch's fixes.

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

@ -122,6 +122,7 @@ import {
} from '../utils/generic-instantiation.js';
import { buildWorkspaceResolutionIndex } from '../workspace-index.js';
import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js';
import { withdrawAnsweredReceiverDrops } from '../resolution-outcome.js';
import { logHeapProbe } from '../../utils/heap-probe.js';
import { parseTruthyEnv } from '../../utils/env.js';
@ -764,6 +765,8 @@ export function runScopeResolution(
if (extractionWarned) scopeExtractionFailedPaths.push(file.path);
continue;
}
const captureSideChannel = provider.languageProvider.collectCaptureSideChannel?.(file.path);
if (captureSideChannel !== undefined) parsed = { ...parsed, captureSideChannel };
if (input.pdg === true && provider.languageProvider.collectSourceCfgs) {
const warnCfg = (message: string): void => {
logger.warn(message);
@ -1509,6 +1512,7 @@ export function runScopeResolution(
// several functions returning the same field name. Sites it resolves are
// added to the skip set, so the fallback below never second-guesses them.
const sharedPropertyIndex = input.prebuiltPropertyNameIndex ?? buildPropertyNameIndex(graph);
const answeredAfterReceiverPass = new Set<string>();
const returnShapeMembers = callableFlowOnly
? { emitted: 0, memberNotOnShape: 0 }
: emitReturnShapeMemberAccesses(
@ -1519,6 +1523,7 @@ export function runScopeResolution(
uniqueNameSkipSites,
sharedPropertyIndex,
uniqueNameSkipSites,
answeredAfterReceiverPass,
);
const nameFallbackDisabled = provider.fieldFallbackOnMethodLookup === false;
@ -1540,7 +1545,9 @@ export function runScopeResolution(
finalized,
sharedPropertyIndex,
nameFallbackDisabled,
answeredAfterReceiverPass,
);
withdrawAnsweredReceiverDrops(resolutionOutcomes, answeredAfterReceiverPass);
// value-ref registrations (#2437): USES edges at the registration sites
// plus field-based dispatch — synthesized CALLS from member-call sites to

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