diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index 461e63a20..27c953ec7 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -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. diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index 8d48c513a..8a4d378bd 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -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, "<>": 13, "chain-unwrap": 1 }, "callDropsByOrigin": { - "in-program": 57, + "in-program": 56, "external": 44, - "unknown": 32, + "unknown": 31, "<>": 2 } } diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/typescript-javascript.ts b/gitnexus/src/core/ingestion/field-extractors/configs/typescript-javascript.ts index 1f7786973..1ae3e40cb 100644 --- a/gitnexus/src/core/ingestion/field-extractors/configs/typescript-javascript.ts +++ b/gitnexus/src/core/ingestion/field-extractors/configs/typescript-javascript.ts @@ -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(['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 = { typeDeclarationNodes: [ 'class_declaration', @@ -35,6 +60,8 @@ const shared: Omit = { return typeFromAnnotation(node); }, + extractInitializerType: constructedTypeOfFieldInitializer, + extractVisibility(node) { // TypeScript accessibility_modifier for (let i = 0; i < node.namedChildCount; i++) { diff --git a/gitnexus/src/core/ingestion/field-extractors/generic.ts b/gitnexus/src/core/ingestion/field-extractors/generic.ts index 7e22053ee..960566c7d 100644 --- a/gitnexus/src/core/ingestion/field-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/field-extractors/generic.ts @@ -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, }; } } diff --git a/gitnexus/src/core/ingestion/field-extractors/typescript.ts b/gitnexus/src/core/ingestion/field-extractors/typescript.ts index 974148a8d..6a3d7c307 100644 --- a/gitnexus/src/core/ingestion/field-extractors/typescript.ts +++ b/gitnexus/src/core/ingestion/field-extractors/typescript.ts @@ -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, }; } diff --git a/gitnexus/src/core/ingestion/field-types.ts b/gitnexus/src/core/ingestion/field-types.ts index 6c243f8ba..d334460a6 100644 --- a/gitnexus/src/core/ingestion/field-types.ts +++ b/gitnexus/src/core/ingestion/field-types.ts @@ -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; } /** diff --git a/gitnexus/src/core/ingestion/languages/javascript/captures.ts b/gitnexus/src/core/ingestion/languages/javascript/captures.ts index a7764f554..e2890c50b 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/captures.ts @@ -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); diff --git a/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts index 9d9639b3a..7927222d6 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts @@ -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). diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index 3f6c0aab4..ac3ae9d4c 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/languages/ruby/field-extractor.ts b/gitnexus/src/core/ingestion/languages/ruby/field-extractor.ts new file mode 100644 index 000000000..87f30f0ad --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/field-extractor.ts @@ -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(); + 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; + }, +}; diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 9b5b573de..32f2e95cb 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index 59879a6f1..3ba0f2a94 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -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 = 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 = 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); diff --git a/gitnexus/src/core/ingestion/languages/typescript/cjs-export-assignment.ts b/gitnexus/src/core/ingestion/languages/typescript/cjs-export-assignment.ts index a82557ac0..4647c3c6c 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/cjs-export-assignment.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/cjs-export-assignment.ts @@ -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; } diff --git a/gitnexus/src/core/ingestion/languages/typescript/cjs-export-receiver.ts b/gitnexus/src/core/ingestion/languages/typescript/cjs-export-receiver.ts new file mode 100644 index 000000000..c7fe642f0 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/cjs-export-receiver.ts @@ -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' + ); +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/module-kind.ts b/gitnexus/src/core/ingestion/languages/typescript/module-kind.ts new file mode 100644 index 000000000..5f945108b --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/module-kind.ts @@ -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; + +// 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; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts index be5f7a8cf..0d150511a 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index dad42ce6e..039e7bcc3 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index d44910f41..4eee46061 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -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; 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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/return-shape-members.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/return-shape-members.ts index b5b1c24b2..36728d687 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/return-shape-members.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/return-shape-members.ts @@ -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, + /** + * 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, ): 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++; } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts index 24fa8faad..ec3e4abb3 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts @@ -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 `.`, 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 | 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, ): 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++; } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 26078af45..6d10d8b24 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -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(); 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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts index 8a14460a6..9ccbcdf53 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts @@ -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, +): 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; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts b/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts index 4c9449819..40c0d1351 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts @@ -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 { 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')) ); } diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 65c8c71d1..71aec66ae 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -667,7 +667,7 @@ const clearCaches = (): void => { // Keyed by class node startIndex (unique per AST node within a file). // ============================================================================ -const fieldInfoCache = new Map>(); +const fieldInfoCache = new Map>(); /** * 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 | undefined { +): Map | 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(); + cached = new Map(); 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 | undefined; + let routedFieldMap: Map | 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; diff --git a/gitnexus/test/integration/commonjs-extraction-parity.test.ts b/gitnexus/test/integration/commonjs-extraction-parity.test.ts new file mode 100644 index 000000000..90bea66de --- /dev/null +++ b/gitnexus/test/integration/commonjs-extraction-parity.test.ts @@ -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; + + 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); + } + }, + ); +}); diff --git a/gitnexus/test/integration/field-info-worker-metadata.test.ts b/gitnexus/test/integration/field-info-worker-metadata.test.ts new file mode 100644 index 000000000..79c86b7a5 --- /dev/null +++ b/gitnexus/test/integration/field-info-worker-metadata.test.ts @@ -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); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index ee4193923..ac87317ab 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -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 diff --git a/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts b/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts index 9ad4a6b62..a932b571a 100644 --- a/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts +++ b/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts @@ -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', + }, ], }, { diff --git a/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts b/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts index 6df379155..efc4dc882 100644 --- a/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts +++ b/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts @@ -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([ + [ + ['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: diff --git a/gitnexus/test/integration/resolvers/javascript.test.ts b/gitnexus/test/integration/resolvers/javascript.test.ts index deadef3af..879d04de0 100644 --- a/gitnexus/test/integration/resolvers/javascript.test.ts +++ b/gitnexus/test/integration/resolvers/javascript.test.ts @@ -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([ + [['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([]); + }); +}); diff --git a/gitnexus/test/integration/resolvers/member-call-producer.test.ts b/gitnexus/test/integration/resolvers/member-call-producer.test.ts index a023d96b0..b55bd3aa4 100644 --- a/gitnexus/test/integration/resolvers/member-call-producer.test.ts +++ b/gitnexus/test/integration/resolvers/member-call-producer.test.ts @@ -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([ + [ + ['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: [] }); + }); }); diff --git a/gitnexus/test/integration/resolvers/parameter-producer.test.ts b/gitnexus/test/integration/resolvers/parameter-producer.test.ts index ccac6e9ff..662a65d01 100644 --- a/gitnexus/test/integration/resolvers/parameter-producer.test.ts +++ b/gitnexus/test/integration/resolvers/parameter-producer.test.ts @@ -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([ + [ + ['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: [] }); + }); }); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 4b3cadcda..ea1becd00 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -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([ + [['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); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/commonjs-module-kind.test.ts b/gitnexus/test/unit/commonjs-module-kind.test.ts new file mode 100644 index 000000000..dbf1a66ad --- /dev/null +++ b/gitnexus/test/unit/commonjs-module-kind.test.ts @@ -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(); + }); + }, +); diff --git a/gitnexus/test/unit/field-extraction.test.ts b/gitnexus/test/unit/field-extraction.test.ts index 12d1d481e..07a64d7fd 100644 --- a/gitnexus/test/unit/field-extraction.test.ts +++ b/gitnexus/test/unit/field-extraction.test.ts @@ -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([ + ['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([ + ['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); + }); +}); diff --git a/gitnexus/test/unit/resolver-outcome-helpers.test.ts b/gitnexus/test/unit/resolver-outcome-helpers.test.ts new file mode 100644 index 000000000..b201d2df5 --- /dev/null +++ b/gitnexus/test/unit/resolver-outcome-helpers.test.ts @@ -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', + ]); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/answered-receiver-drops.test.ts b/gitnexus/test/unit/scope-resolution/answered-receiver-drops.test.ts new file mode 100644 index 000000000..6dddf5114 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/answered-receiver-drops.test.ts @@ -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 { + 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); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts b/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts index 641d96f39..26e5a1219 100644 --- a/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts +++ b/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts @@ -33,7 +33,7 @@ function dropped( name: string, siteKind: 'call' | 'read' | 'write' = 'call', receiverOrigin?: ReceiverOrigin, -): ResolutionOutcome { +): Extract { 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(); });