From 31b27bcc044eaef2e23d8661b08b4c4b4fe2431b Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 20 May 2026 08:57:41 +0100 Subject: [PATCH] test(typescript): pin capture-anchor rewrite invariants (B5 regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves PR #1693 review B5: the captures.ts ancestor-walk rewrite (findSelfOrAncestorOfType[s] + pickFirstNode replacing the prior findNodeAtRange-from-root path) was semantically equivalent to its predecessor per Lane 4 of the production-readiness review, but the existing typescript-captures.test.ts didn't pin the specific sharp edges where an over-aggressive walk would silently break captures. This file does. Each test exercises a capture class whose anchor type is one the rewrite explicitly handles: - member call obj.foo() -> @reference.call.member (call_expression anchor walks to self) - dynamic import import("./helper") -> raw @import.dynamic gets decomposed by splitImportStatement into @import.statement with @import.kind=dynamic + @import.source stripped of quotes - JSX in .tsx -> @reference.call.free emitted (TSX query pattern, query.ts:899-905) but @declaration.parameter-count is NOT synthesized because findSelfOrAncestorOfType('call_expression') returns null on a jsx_self_closing_element anchor. Pre-rewrite the range lookup also returned null. Pinning this contract catches accidental "walk JSX -> outer call" refactors. - constructor `new Foo(1,2)` -> @reference.call.constructor (new_expression anchor walks to self) - named/namespace import + re-export -> @import.statement (one each) - class method override -> @declaration.method per class, no collapse - member read obj.foo (no call) -> @reference.read.member All assertions use exact .toBe(N) per DoD §2.7. --- .../typescript-captures-anchor.test.ts | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 gitnexus/test/unit/scope-resolution/typescript/typescript-captures-anchor.test.ts diff --git a/gitnexus/test/unit/scope-resolution/typescript/typescript-captures-anchor.test.ts b/gitnexus/test/unit/scope-resolution/typescript/typescript-captures-anchor.test.ts new file mode 100644 index 000000000..cd8adbe3a --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/typescript/typescript-captures-anchor.test.ts @@ -0,0 +1,152 @@ +/** + * U8 (B5 from PR #1693 review) — TS capture ancestor-walk regression coverage. + * + * PR #1693 rewrote `emitTsScopeCaptures` to walk from each captured node's + * own subtree (`findSelfOrAncestorOfType[s]` + `pickFirstNode`) instead of + * re-scanning the whole AST from the root via `findNodeAtRange`. Lane 4 of + * the production-readiness review proved the new path is semantically + * equivalent to the prior range-based lookup for every anchor the TS + * query emits — but the existing `typescript-captures.test.ts` doesn't + * pin the specific sharp edges that an over-aggressive ancestor walk + * would break. This file does. + * + * Each test exercises a capture class whose anchor type is one the + * rewrite explicitly handles: `call_expression`, `new_expression`, + * `import_statement` / `export_statement`, `call_expression` with + * `import` (dynamic), and the JSX-anchored `@reference.call.*` form + * that must NOT synthesize an outer call. Assertions are exact `.toBe(N)` + * per DoD §2.7. + */ +import { describe, it, expect } from 'vitest'; +import { emitTsScopeCaptures } from '../../../../src/core/ingestion/languages/typescript/captures.js'; + +function countMatches(src: string, predicate: (tags: string[]) => boolean): number { + const matches = emitTsScopeCaptures(src, 'test.ts'); + return matches.filter((m) => predicate(Object.keys(m))).length; +} + +function findMatch(src: string, predicate: (tags: string[]) => boolean) { + const matches = emitTsScopeCaptures(src, 'test.ts'); + return matches.find((m) => predicate(Object.keys(m))); +} + +function countMatchesTsx(src: string, predicate: (tags: string[]) => boolean): number { + // TSX-specific query path: file extension drives query selection inside + // emitTsScopeCaptures. Without `.tsx` the JSX call-anchored variants + // never fire, so this test would silently pass on the TypeScript-only + // path instead of exercising the JSX-anchor case the rewrite cares about. + const matches = emitTsScopeCaptures(src, 'test.tsx'); + return matches.filter((m) => predicate(Object.keys(m))).length; +} + +describe('captures.ts ancestor-walk rewrite (U8 / B5)', () => { + it('member call `obj.foo()` emits exactly one @reference.call.member capture', () => { + // call_expression anchor → self in ancestor walk. Baseline case the + // rewrite must preserve: a direct member call captures once via + // @reference.call.member, not zero (would mean ancestor walk lost + // the anchor) and not two (would mean the walk over-emitted). + const count = countMatches('function run(obj: { foo(): void }): void { obj.foo(); }', (t) => + t.includes('@reference.call.member'), + ); + expect(count).toBe(1); + }); + + it('dynamic import gets decomposed to @import.statement with kind=dynamic', () => { + // import(...) is captured by the raw query as @import.dynamic + // (call_expression with `import` function). captures.ts then + // decomposes it via splitImportStatement, which re-emits a normalized + // @import.statement match with @import.kind set to "dynamic" — so the + // central extractor sees ONE uniform import shape regardless of + // static-vs-dynamic. The raw @import.dynamic tag does NOT survive + // into the output stream after decomposition. + const matches = emitTsScopeCaptures( + 'async function load() { const mod = await import("./helper"); return mod; }', + 'test.ts', + ); + const dyn = matches.filter( + (m) => '@import.statement' in m && m['@import.kind']?.text === 'dynamic', + ); + expect(dyn.length).toBe(1); + // The decomposed source-string capture carries the literal with + // surrounding quotes stripped (the decomposer normalizes before + // emitting the synthetic @import.source marker — downstream + // consumers receive the bare module specifier). + expect(dyn[0]['@import.source']?.text).toBe('./helper'); + }); + + it('JSX emits a call.free capture (TSX-only query path) but no arity synthesis', () => { + // Both jsx_self_closing_element and jsx_opening_element with an + // identifier name pattern in the TSX query emit @reference.call.free + // (see query.ts lines 899-905). Lane 4 of the production-readiness + // review documented the design: the capture surfaces so downstream + // consumers know the JSX component is referenced, but arity + // synthesis (findSelfOrAncestorOfType('call_expression')) returns + // null because the anchor is a jsx_*_element, NOT a call_expression + // — so no @declaration.parameter-count is attached. Pre-rewrite, the + // range-based lookup also returned null. This pins both: the capture + // exists AND arity is not synthesized. + const matches = emitTsScopeCaptures('function App() { return ; }', 'test.tsx'); + const jsxCalls = matches.filter((m) => '@reference.call.free' in m); + expect(jsxCalls.length).toBe(1); + // No spurious arity synthesis on the JSX-anchored capture. If a + // future refactor "helpfully" walks JSX → call_expression, this + // assertion fails and the implementer revisits the design. + expect('@declaration.parameter-count' in jsxCalls[0]).toBe(false); + }); + + it('constructor call `new Foo(1, 2)` emits exactly one @reference.call.constructor capture', () => { + // new_expression anchor → self in ancestor walk. + const count = countMatches( + 'class Foo { constructor(_a: number, _b: number) {} }\nconst x = new Foo(1, 2);', + (t) => t.includes('@reference.call.constructor'), + ); + expect(count).toBe(1); + }); + + it('named import `import { foo } from "./a"` emits exactly one @import.statement', () => { + const count = countMatches('import { foo } from "./a";\nconst x = foo();', (t) => + t.includes('@import.statement'), + ); + expect(count).toBe(1); + }); + + it('namespace import `import * as ns from "./a"` emits exactly one @import.statement', () => { + const count = countMatches('import * as ns from "./a";\nconst x = ns.foo();', (t) => + t.includes('@import.statement'), + ); + expect(count).toBe(1); + }); + + it('re-export `export { foo } from "./a"` emits exactly one @import.statement', () => { + // export_statement with a source string IS captured as @import.statement + // (re-exports are pseudo-imports for graph purposes). Ancestor-walk + // targets `['import_statement', 'export_statement']` so the + // export_statement anchor matches itself. + const count = countMatches('export { foo } from "./a";', (t) => + t.includes('@import.statement'), + ); + expect(count).toBe(1); + }); + + it('class method override produces a method capture per class (no collapse, no over-capture)', () => { + // Two run() methods, one per class, both must capture distinctly. + // Pins that the FUNCTION_DECL_TAGS / @declaration.method ancestor-walk + // doesn't accidentally merge override sites onto the parent class. + const count = countMatches( + 'class Base { run(): number { return 1; } }\nclass Child extends Base { run(): number { return 2; } }', + (t) => t.includes('@declaration.method'), + ); + expect(count).toBe(2); + }); + + it('member read `obj.foo` (no call) emits exactly one @reference.read.member capture', () => { + // member_expression anchor → self in ancestor walk. Read-only access + // (not followed by call parens) is the relevant case — a member that + // IS called is captured under @reference.call.member instead. + const count = countMatches( + 'function run(obj: { foo: number }): number { return obj.foo; }', + (t) => t.includes('@reference.read.member'), + ); + expect(count).toBe(1); + }); +});