mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
test(typescript): pin capture-anchor rewrite invariants (B5 regression)
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 <Foo /> 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.
This commit is contained in:
parent
0f115b1956
commit
31b27bcc04
1 changed files with 152 additions and 0 deletions
|
|
@ -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 <Foo /> 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 <Foo />; }', '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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue