test(scope-resolution): guard closure identity invariants (#2748)

This commit is contained in:
azizur100389 2026-07-30 05:34:21 +01:00 committed by GitHub
parent bc76ba2f25
commit bee3e82ab2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 122 additions and 0 deletions

View file

@ -596,6 +596,21 @@ describeIfWorkerBuilt('a closure binding as a call SOURCE (#2699 part B)', () =>
expect(targets).toEqual(['rel:CALLS:Function:A.kt:handler->Function:A.kt:target']);
});
it('Kotlin: a call at block level is not attributed to a nested named function (#2736)', async () => {
// A Block-kind scope may own a nested function without being that
// function's body. The start-position alignment check is what keeps the
// block-level call attributed to `outer`, rather than swallowing it into
// the uncalled `nested`.
const targets = await callEdgeIdsFor(
'Block.kt',
'fun target(): Int = 1\n\nfun outer(): Int {\n' +
' if (true) {\n fun nested(): Int = 0\n return target()\n }\n' +
' return 0\n}\n',
);
expect(targets).toEqual(['rel:CALLS:Function:Block.kt:outer->Function:Block.kt:target']);
});
it('PHP: a call inside the closure IS attributed to the binding (#2699 S1)', async () => {
// FLIPPED by #2699 S1. php/query.ts now carries the closure-binding
// declaration rule with javascript/query.ts's anchor discipline

View file

@ -25,11 +25,23 @@
* pass. Every assertion here is therefore on the EDGE, whose source and target
* are produced by the two different phases: it can only pass if both agree.
*/
import {
buildDefIndex,
buildScopeTree,
type Reference,
type Scope,
type ScopeId,
type SymbolDefinition,
} from 'gitnexus-shared';
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { ScopeResolutionIndexes } from '../../src/core/ingestion/model/scope-resolution-indexes.js';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { buildGraphNodeLookup } from '../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js';
import { emitReferencesViaLookup } from '../../src/core/ingestion/scope-resolution/graph-bridge/references-to-edges.js';
import { DIST_WORKER_URL, distWorkerExists } from '../helpers/worker-parse.js';
vi.setConfig({ testTimeout: 90_000 });
@ -352,3 +364,94 @@ describeIfWorkerBuilt('function-local VALUES carry their own identity (#2699 A1)
expect(ids).toEqual(['Const:v.ts:handler', 'Const:v.ts:run.handler@3:2']);
});
});
describe('function-local value identity resolves through to emitted edges (#2736)', () => {
it('targets the position-qualified local rather than its file-level twin', () => {
const filePath = 'v.ts';
const caller: SymbolDefinition = {
nodeId: 'def:v.ts#3:0:Function:run',
filePath,
type: 'Function',
qualifiedName: 'run',
};
const topLevel: SymbolDefinition = {
nodeId: 'def:v.ts#1:0:Const:handler',
filePath,
type: 'Const',
qualifiedName: 'handler',
};
const local: SymbolDefinition = {
nodeId: 'def:v.ts#4:2:Const:run.handler',
filePath,
type: 'Const',
qualifiedName: 'run.handler',
};
const moduleScope: Scope = {
id: 'scope:v.ts#1:0-100:0:Module',
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 },
filePath,
bindings: new Map(),
ownedDefs: [caller, topLevel],
imports: [],
typeBindings: new Map(),
};
const functionScope: Scope = {
id: 'scope:v.ts#3:0-6:1:Function',
parent: moduleScope.id,
kind: 'Function',
range: { startLine: 3, startCol: 0, endLine: 6, endCol: 1 },
filePath,
bindings: new Map(),
ownedDefs: [local],
imports: [],
typeBindings: new Map(),
};
const indexes = {
scopeTree: buildScopeTree([moduleScope, functionScope]),
defs: buildDefIndex([caller, topLevel, local]),
} as unknown as ScopeResolutionIndexes;
const graph = createKnowledgeGraph();
graph.addNode({
id: 'Function:v.ts:run',
label: 'Function',
properties: { name: 'run', qualifiedName: 'run', filePath, startLine: 2 },
});
graph.addNode({
id: 'Const:v.ts:handler',
label: 'Const',
properties: { name: 'handler', qualifiedName: 'handler', filePath, startLine: 0 },
});
graph.addNode({
id: 'Const:v.ts:run.handler@3:2',
label: 'Const',
properties: { name: 'handler', qualifiedName: 'run.handler', filePath, startLine: 3 },
});
const reference: Reference = {
fromScope: functionScope.id,
toDef: local.nodeId,
atRange: { startLine: 5, startCol: 9, endLine: 5, endCol: 16 },
kind: 'read',
confidence: 1,
evidence: [],
};
const emitted = emitReferencesViaLookup(
graph,
indexes,
{ bySourceScope: new Map<ScopeId, readonly Reference[]>([[functionScope.id, [reference]]]) },
buildGraphNodeLookup(graph),
);
expect(emitted).toEqual({ emitted: 1, skipped: 0 });
expect(graph.relationships).toEqual([
expect.objectContaining({
sourceId: 'Function:v.ts:run',
targetId: 'Const:v.ts:run.handler@3:2',
type: 'ACCESSES',
}),
]);
});
});

View file

@ -101,6 +101,10 @@ describe('fileContentHash', () => {
});
describe('PARSE_CACHE_VERSION', () => {
it('pins SCHEMA_BUMP to 31 so concurrent bumps cannot silently collide (#2736)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(31);
});
it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {
// Looks like "1+1.6.4" — schema bump prefix + actual gitnexus version
expect(PARSE_CACHE_VERSION).toMatch(/^\d+\+\d+\.\d+\.\d+/);