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