diff --git a/docs/accuracy/traceability.md b/docs/accuracy/traceability.md
index e3c1c53..5c8471f 100644
--- a/docs/accuracy/traceability.md
+++ b/docs/accuracy/traceability.md
@@ -77,20 +77,31 @@ interface PropagatedLocation {
The UI offers both as click targets. Default click lands on `callSite` (where the user worked); a "Show underlying call" affordance jumps to `resolvedSite`.
### Acceptance criteria
-- [ ] Propagated detections carry both spans + hop count.
-- [ ] Direct (non-propagated) detections have `hops = 0` and the two spans equal.
-- [ ] Webview shows both locations with clear labels.
-- [ ] Stable IDs (B3) hash includes only one of the two spans (suggest: `resolvedSite`) so refactors that move the call site don't reset state.
+- [x] Propagated detections carry both spans + hop count. *(`CallTrace { callSite, resolvedSite, hops }` on `AstCallMatch.trace` → `ApiCallInput.callTrace` → `EndpointCallSite.callTrace` + `ApiCallNode.callTrace`.)*
+- [x] Direct (non-propagated) detections have `hops = 0` and the two spans equal. *(`directTrace()` fallback applied at the three call-site construction sites in `scan-results.ts`.)*
+- [~] Webview shows both locations with clear labels. *(Code landed: `ResultsPage.tsx` Endpoints view shows the call site by default plus a "↳ underlying call" link when `hops > 0`. **Pending manual EDH verification** — F5 the dev host, scan a workspace where a helper wraps an SDK call, confirm both links navigate correctly.)*
+- [x] Stable IDs hash is call-site-stable. *(Satisfied by B3 design — `computeEndpointId` excludes line/column/span, so moving a call site cannot reset the ID; locked in by `endpoint-id.test.ts` "B2/AC-4". The hash is intentionally **not** re-keyed on `resolvedSite.file`: doing so would collapse distinct callers into one endpoint and risk a benchmark detection-metric regression.)*
+
+> **#113 (corpus fixtures) — blocked here.** The 7 barrel/factory/DI fixtures live in
+> `recost-dev/extension-benchmark` (separate repo). Once they land, refresh the baseline:
+> `npm run benchmark -- --fixtures ../extension-benchmark --update-baseline`, then commit the
+> regenerated `benchmark/baseline.json`. No `extension`-repo code change is required for #113.
### Files
+- `src/scanner/call-trace.ts` (new — `CallTrace`/`ResolvedLocation`/`directTrace`)
+- `src/ast/ast-scanner.ts` (`AstCallMatch.trace`)
- `src/ast/cross-file-resolver.ts`
-- `src/intelligence/types.ts`
-- `webview/src/components/ResultsPage.tsx`
+- `src/analysis/types.ts` (`ApiCallInput.callTrace`, `EndpointCallSite.callTrace`)
+- `src/scanner/core-scanner.ts`, `src/scan-results.ts`
+- `src/intelligence/types.ts` (`ApiCallNode.callTrace`), `src/intelligence/builder.ts`
+- `webview/src/types.ts`, `webview/src/components/ResultsPage.tsx`
### Depends on
-- B1 (spans must exist first).
+- B1 (spans must exist first). *(Landed.)*
- A1 (wrapper depth — once hops can be >1, this becomes more useful).
+✅ Landed: 2026-05-30 on branch `claude/recent-pr-explanation-C7Xcw`. Acceptance criteria 1, 2, 4 automated-verified (`npm run test:scanner` green incl. 7 new B2 cases; `npm run build` clean); #3 awaits manual EDH check. Benchmark gate runs in CI only (fixtures repo not accessible locally); Δ expected +0.00pp — B2 is additive metadata and changes no detection/inclusion/finding logic.
+
---
## B3. Stable endpoint IDs across scans
diff --git a/docs/superpowers/plans/2026-05-30-wave2-b2-dual-locations.md b/docs/superpowers/plans/2026-05-30-wave2-b2-dual-locations.md
new file mode 100644
index 0000000..1e5d12e
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-30-wave2-b2-dual-locations.md
@@ -0,0 +1,737 @@
+# Wave 2 — B2 Dual Locations for Cross-File Resolved Calls Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Surface *both* the user's call site and the underlying SDK call site for cross-file-resolved API calls, with both as click targets in the sidebar.
+
+**Architecture:** The cross-file resolver (`cross-file-resolver.ts`) already propagates a callee's SDK call up to its callers, overriding `line` with the caller's call-site line while keeping the callee's `span`. That conflation is the bug. We add a `CallTrace { callSite, resolvedSite, hops }` structure that is populated at propagation time, threaded through `ApiCallInput → EndpointCallSite` and `ApiCallNode`, and consumed by the webview to offer "open my call site" (default) vs. "show underlying call". Direct (non-propagated) calls get a degenerate trace (`hops = 0`, both sites equal), so every detection carries the same shape.
+
+**Tech Stack:** TypeScript (strict), esbuild, React 18 sidebar webview, `node:assert/strict` tests compiled by `tsc` and run via `npm run test:scanner`.
+
+---
+
+## Wave 2 scope note (read first)
+
+Wave 2 (Traceability) has two open issues:
+
+- **#81 — B2 dual locations** — implemented by this plan. Fully self-contained in `recost-dev/extension`.
+- **#113 — barrel/factory corpus fixtures** — the 7 fixtures live in `recost-dev/extension-benchmark`, a **separate repo this session has no write access to**. The only in-repo work is refreshing `benchmark/baseline.json` *after* the fixtures land. That is captured as **Task 9** (blocked) — do not attempt to create the fixtures from this repo.
+
+The B3 stable-ID acceptance criterion that B2 references (#81 AC-4) is **already satisfied by B3's design**: `computeEndpointId` (`src/scanner/endpoint-id.ts`) hashes provider + methodSignature + normalized file path + enclosing function + masked URL — it excludes line, column, and span entirely. Moving a call site therefore cannot reset an endpoint ID. We do **not** re-key the hash on `resolvedSite.file` (that would risk collapsing distinct callers into one endpoint and is a benchmark-detection-metric hazard). Instead, Task 6 adds a regression test that proves the ID is unchanged when the call-site span moves, and Task 9 marks AC-4 satisfied with that reasoning.
+
+---
+
+## File Structure
+
+**New files**
+- `src/scanner/call-trace.ts` — `ResolvedLocation`, `CallTrace` interfaces + `directTrace()` helper. One responsibility: the dual-location type and its degenerate constructor. Lives next to `source-span.ts` (same layer, no Node/VSCode deps).
+- `src/test/call-trace.test.ts` — unit test for `directTrace()`.
+
+**Modified files**
+- `src/ast/ast-scanner.ts` — add optional `trace?: CallTrace` to `AstCallMatch`.
+- `src/ast/cross-file-resolver.ts` — populate `trace` in `cloneWithCallerContext` (import + middleware paths) and in `runFactoryReturnPostPass`.
+- `src/test/ast-cross-file-resolver.test.ts` — add trace assertions.
+- `src/analysis/types.ts` — add `callTrace?: CallTrace` to `ApiCallInput` and `EndpointCallSite`.
+- `src/scanner/core-scanner.ts` — forward `match.trace` into `ApiCallInput.callTrace`.
+- `src/scan-results.ts` — populate `callTrace` at the three call-site construction sites (with a `directTrace` fallback).
+- `src/intelligence/types.ts` — add `callTrace?: CallTrace` to `ApiCallNode`.
+- `src/intelligence/builder.ts` — populate `callTrace` on each `ApiCallNode`.
+- `src/test/endpoint-id.test.ts` — add the AC-4 regression test (ID stable across call-site span move).
+- `webview/src/types.ts` — mirror `CallTrace` and add `callTrace?` to `EndpointRecord.callSites[]`.
+- `webview/src/components/ResultsPage.tsx` — default click → `callSite`; add "underlying call" affordance when `hops > 0`.
+
+No `messages.ts` / `webview-provider.ts` change is required: the existing `openFile` IPC already carries `{ file, line?, span? }` and `handleOpenFile` already selects a span when present. The "underlying call" button just posts `openFile` with the resolved file + span.
+
+---
+
+### Task 1: Define the `CallTrace` type and `directTrace` helper
+
+**Files:**
+- Create: `src/scanner/call-trace.ts`
+- Test: `src/test/call-trace.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// src/test/call-trace.test.ts
+import assert from "node:assert/strict";
+import { directTrace } from "../scanner/call-trace";
+import { pointSpan } from "../scanner/source-span";
+
+function run(name: string, fn: () => void): void {
+ try { fn(); console.log(`PASS ${name}`); }
+ catch (err) { console.error(`FAIL ${name}`); throw err; }
+}
+
+run("directTrace: hops is 0 and both sites are equal", () => {
+ const span = pointSpan(12, 4);
+ const trace = directTrace("services/chat.ts", span);
+ assert.equal(trace.hops, 0);
+ assert.deepEqual(trace.callSite, { file: "services/chat.ts", span });
+ assert.deepEqual(trace.resolvedSite, trace.callSite);
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx tsc -p tsconfig.json --noEmit` (expected: error — module `../scanner/call-trace` not found / `directTrace` not exported).
+
+- [ ] **Step 3: Write minimal implementation**
+
+```ts
+// src/scanner/call-trace.ts
+import type { SourceSpan } from "./source-span";
+
+/** A concrete location: a workspace-relative file plus a span within it. */
+export interface ResolvedLocation {
+ /** Workspace-relative path (matches EndpointCallSite.file). */
+ file: string;
+ span: SourceSpan;
+}
+
+/**
+ * Dual-location trace for a detected call.
+ *
+ * - `callSite` — where the user's code invokes the (possibly wrapped) call.
+ * - `resolvedSite` — where the underlying SDK call actually lives.
+ * - `hops` — 0 for a direct call (the two sites are equal), >=1 when the
+ * call was propagated across one or more wrapper files.
+ */
+export interface CallTrace {
+ callSite: ResolvedLocation;
+ resolvedSite: ResolvedLocation;
+ hops: number;
+}
+
+/** Build a degenerate trace for a direct (non-propagated) call. */
+export function directTrace(file: string, span: SourceSpan): CallTrace {
+ const loc: ResolvedLocation = { file, span };
+ return { callSite: loc, resolvedSite: loc, hops: 0 };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npm run build:ext && node dist-test/test/call-trace.test.js` (or `npm run test:scanner` after Task 8 wiring). Expected: `PASS directTrace: hops is 0 and both sites are equal`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/scanner/call-trace.ts src/test/call-trace.test.ts
+git commit -m "feat(b2): add CallTrace type and directTrace helper"
+```
+
+---
+
+### Task 2: Add `trace` to `AstCallMatch` and populate it in the cross-file resolver
+
+**Files:**
+- Modify: `src/ast/ast-scanner.ts:32-67` (`AstCallMatch` interface)
+- Modify: `src/ast/cross-file-resolver.ts` (`cloneWithCallerContext`, its 3 call sites, `runFactoryReturnPostPass`)
+- Test: `src/test/ast-cross-file-resolver.test.ts`
+
+- [ ] **Step 1: Write the failing test** (append to `src/test/ast-cross-file-resolver.test.ts`, after the existing tests)
+
+```ts
+run("B2: propagated match carries a trace (hops=1, callSite=caller, resolvedSite=callee)", () => {
+ const calleeFile: PerFileResult = {
+ filePath: "/project/lib/ai.ts",
+ relativePath: "lib/ai.ts",
+ source: `
+import OpenAI from "openai";
+const client = new OpenAI();
+export async function callAI(prompt: string) {
+ return await client.chat.completions.create({ model: "gpt-4o", messages: [] });
+}
+`.trim(),
+ result: makeResult({ matches: [makeMatch({ line: 4 })] }),
+ };
+ const callerFile: PerFileResult = {
+ filePath: "/project/app.ts",
+ relativePath: "app.ts",
+ source: `
+import { callAI } from "./lib/ai";
+async function handle() {
+ await callAI("hi");
+}
+`.trim(),
+ result: makeResult({
+ matches: [makeMatch({ methodChain: "callAI", provider: undefined, packageName: undefined, line: 3 })],
+ }),
+ };
+
+ const output = runCrossFileResolution([calleeFile, callerFile]);
+ const propagated = output.get("app.ts")!.filter((m) => m.crossFile);
+ assert.ok(propagated.length > 0, "expected a propagated match");
+ const trace = propagated[0].trace;
+ assert.ok(trace, "propagated match should carry a trace");
+ assert.equal(trace!.hops, 1, "single wrapper hop");
+ assert.equal(trace!.callSite.file, "app.ts", "callSite is the caller file");
+ assert.equal(trace!.resolvedSite.file, "lib/ai.ts", "resolvedSite is the callee file");
+ assert.equal(trace!.resolvedSite.span.startLine, 4, "resolvedSite span points at the SDK call line");
+});
+
+run("B2: direct (non-propagated) match has no trace", () => {
+ const file: PerFileResult = {
+ filePath: "/project/solo.ts",
+ relativePath: "solo.ts",
+ source: `
+import OpenAI from "openai";
+const client = new OpenAI();
+await client.chat.completions.create({ model: "gpt-4o", messages: [] });
+`.trim(),
+ result: makeResult({ matches: [makeMatch({ line: 3 })] }),
+ };
+ const output = runCrossFileResolution([file]);
+ const direct = output.get("solo.ts")!.filter((m) => !m.crossFile);
+ assert.equal(direct[0].trace, undefined, "direct matches carry no trace (defaulted downstream)");
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx tsc -p tsconfig.json --noEmit`. Expected: error — `Property 'trace' does not exist on type 'AstCallMatch'`.
+
+- [ ] **Step 3a: Add `trace` to `AstCallMatch`** — in `src/ast/ast-scanner.ts`, inside the `AstCallMatch` interface (after the `sourceFile?: string;` field at line 66), add:
+
+```ts
+ /**
+ * Dual-location trace, set ONLY for cross-file-propagated matches. Absent for
+ * direct matches (downstream defaults to a degenerate `directTrace`).
+ */
+ trace?: import("../scanner/call-trace").CallTrace;
+```
+
+- [ ] **Step 3b: Import the helpers in the resolver** — at the top of `src/ast/cross-file-resolver.ts` (after the existing imports, around line 19) add:
+
+```ts
+import { pointSpan, type SourceSpan } from "../scanner/source-span";
+import type { CallTrace, ResolvedLocation } from "../scanner/call-trace";
+```
+
+- [ ] **Step 3c: Extend `cloneWithCallerContext`** — replace the function (currently `src/ast/cross-file-resolver.ts:336-354`) with:
+
+```ts
+function cloneWithCallerContext(
+ callee: AstCallMatch,
+ callerLine: number,
+ callerFrequency: AstCallMatch["frequency"],
+ callerLoopContext: boolean,
+ isMiddleware: boolean,
+ calleeFilePath: string,
+ callerFilePath: string,
+ callerRelative: string,
+ callerSpan: SourceSpan,
+ resolvedRelative: string
+): AstCallMatch {
+ // The resolved (underlying SDK) site is the callee's own location, unless the
+ // callee was itself propagated (wrapper-of-a-wrapper), in which case carry its
+ // original resolved site forward and just deepen the hop count.
+ const resolvedSite: ResolvedLocation = callee.trace
+ ? callee.trace.resolvedSite
+ : { file: resolvedRelative, span: callee.span };
+ const trace: CallTrace = {
+ callSite: { file: callerRelative, span: callerSpan },
+ resolvedSite,
+ hops: (callee.trace?.hops ?? 0) + 1,
+ };
+ return {
+ ...callee,
+ line: callerLine,
+ frequency: isMiddleware ? "single" : callerFrequency,
+ loopContext: isMiddleware ? false : callerLoopContext,
+ isMiddleware: isMiddleware || callee.isMiddleware,
+ crossFile: true,
+ sourceFile: calleeFilePath,
+ trace,
+ };
+}
+```
+
+- [ ] **Step 3d: Pass the new args at the import-propagation call sites** — in `runCrossFileResolution`, inside the `for (const { localName, specifier, isDefault } of imports)` loop, compute the resolved relative path once right after `if (!resolvedFile) continue;` (currently line 548):
+
+```ts
+ const resolvedRelative = relativePathByNormalized.get(resolvedFile) ?? resolvedFile;
+```
+
+Then update the two `cloneWithCallerContext(...)` calls in that loop:
+
+The **callSites** path (currently lines 581-592) — `site` is an `AstCallMatch` and has a real span:
+
+```ts
+ tryPush(
+ callerRelative,
+ cloneWithCallerContext(
+ callee, site.line, site.frequency, site.loopContext, false,
+ resolvedFile, callerPath, callerRelative, site.span, resolvedRelative
+ )
+ );
+```
+
+The **callSiteLines** path (currently lines 598-601) — only a bare line is known, so synthesize a line-anchored span:
+
+```ts
+ tryPush(
+ callerRelative,
+ cloneWithCallerContext(
+ callee, lineNum, "single", false, false,
+ resolvedFile, callerPath, callerRelative, pointSpan(lineNum), resolvedRelative
+ )
+ );
+```
+
+- [ ] **Step 3e: Pass the new args at the middleware call site** — in the middleware loop, after `if (!resolvedFile) continue;` (currently line 613) add:
+
+```ts
+ const resolvedRelative = relativePathByNormalized.get(resolvedFile) ?? resolvedFile;
+```
+
+and update the `cloneWithCallerContext(...)` call (currently lines 630-639):
+
+```ts
+ tryPush(
+ callerRelative,
+ cloneWithCallerContext(
+ callee, useLine ?? callee.line, "single", false, true,
+ resolvedFile, callerPath, callerRelative, pointSpan(useLine ?? callee.line), resolvedRelative
+ )
+ );
+```
+
+- [ ] **Step 3f: Set a trace on factory-post-pass matches** — in `runFactoryReturnPostPass` (`src/ast/cross-file-resolver.ts:714`), build a normalized→relative map at the top of the function (right after the signature, before "Step 1"):
+
+```ts
+ const relByNorm = new Map(files.map((f) => [normalizePath(f.filePath), f.relativePath]));
+```
+
+Then in the emitted match literal (currently lines 772-786), add a `trace` field after `sourceFile: resolvedFile,`:
+
+```ts
+ trace: {
+ callSite: { file: consumer.relativePath, span: pointSpan(line) },
+ // The factory's own `new X()` span isn't tracked; point at the
+ // resolved file's top as a best-effort underlying location.
+ resolvedSite: { file: relByNorm.get(resolvedFile) ?? resolvedFile, span: pointSpan(1) },
+ hops: 1,
+ },
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx tsc -p tsconfig.json --noEmit && node dist-test/test/ast-cross-file-resolver.test.js`.
+Expected: all existing resolver tests still `PASS`, plus the two new `B2:` tests `PASS`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ast/ast-scanner.ts src/ast/cross-file-resolver.ts src/test/ast-cross-file-resolver.test.ts
+git commit -m "feat(b2): populate CallTrace for cross-file propagated matches"
+```
+
+---
+
+### Task 3: Forward the trace into `ApiCallInput`
+
+**Files:**
+- Modify: `src/analysis/types.ts:3-25` (`ApiCallInput`)
+- Modify: `src/scanner/core-scanner.ts:109-159` (`astMatchToApiCallInput`)
+- Test: `src/test/ast-cross-file-resolver.test.ts` is upstream; the conversion is covered end-to-end by Task 5. This task is type-plumbing only — no new dedicated test (the `tsc` gate + Task 5 cover it).
+
+- [ ] **Step 1: Add the field to `ApiCallInput`** — in `src/analysis/types.ts`, after `crossFileOrigin?: ... | null;` (line 24) add:
+
+```ts
+ /** Dual-location trace. Set by the AST path for cross-file calls; undefined otherwise. */
+ callTrace?: import("../scanner/call-trace").CallTrace;
+```
+
+- [ ] **Step 2: Forward it in `astMatchToApiCallInput`** — in `src/scanner/core-scanner.ts`, in the returned object (after `crossFileOrigin,` at line 157) add:
+
+```ts
+ callTrace: match.trace,
+```
+
+- [ ] **Step 3: Verify the type-checks pass**
+
+Run: `npx tsc -p tsconfig.json --noEmit`. Expected: clean (no errors).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/analysis/types.ts src/scanner/core-scanner.ts
+git commit -m "feat(b2): forward CallTrace into ApiCallInput"
+```
+
+---
+
+### Task 4: Carry `callTrace` on `EndpointCallSite` and populate it in `scan-results.ts`
+
+**Files:**
+- Modify: `src/analysis/types.ts:67-77` (`EndpointCallSite`)
+- Modify: `src/scan-results.ts` (3 call-site construction sites: lines ~481, ~535, ~567)
+- Test: `src/test/scan-results.test.ts`
+
+- [ ] **Step 1: Write the failing test** (append to `src/test/scan-results.test.ts`; reuse that file's existing harness/imports — it already imports `buildLocalScanResults`/`buildRemoteScanResults` and a `run` helper)
+
+```ts
+run("B2: cross-file call yields a callSite with hops>=1 and distinct resolvedSite", () => {
+ const calls: ApiCallInput[] = [
+ {
+ file: "app.ts",
+ line: 3,
+ span: { startLine: 3, startColumn: 0, endLine: 3, endColumn: 20 },
+ method: "POST",
+ url: "sdk://openai/chat.completions.create",
+ library: "openai",
+ provider: "openai",
+ methodSignature: "chat.completions.create",
+ callTrace: {
+ callSite: { file: "app.ts", span: { startLine: 3, startColumn: 0, endLine: 3, endColumn: 20 } },
+ resolvedSite: { file: "lib/ai.ts", span: { startLine: 4, startColumn: 2, endLine: 4, endColumn: 60 } },
+ hops: 1,
+ },
+ },
+ ];
+ const result = buildLocalScanResults(calls, "proj", "local-test");
+ const ep = result.endpoints.find((e) => e.provider === "openai")!;
+ const site = ep.callSites[0];
+ assert.ok(site.callTrace, "call site should carry a callTrace");
+ assert.equal(site.callTrace!.hops, 1);
+ assert.equal(site.callTrace!.callSite.file, "app.ts");
+ assert.equal(site.callTrace!.resolvedSite.file, "lib/ai.ts");
+});
+
+run("B2: direct call gets a degenerate callTrace (hops=0, sites equal)", () => {
+ const calls: ApiCallInput[] = [
+ {
+ file: "solo.ts",
+ line: 9,
+ span: { startLine: 9, startColumn: 0, endLine: 9, endColumn: 30 },
+ method: "POST",
+ url: "sdk://openai/chat.completions.create",
+ library: "openai",
+ provider: "openai",
+ methodSignature: "chat.completions.create",
+ },
+ ];
+ const result = buildLocalScanResults(calls, "proj", "local-test");
+ const site = result.endpoints[0].callSites[0];
+ assert.ok(site.callTrace, "direct call should still carry a (degenerate) callTrace");
+ assert.equal(site.callTrace!.hops, 0);
+ assert.deepEqual(site.callTrace!.callSite, site.callTrace!.resolvedSite);
+});
+```
+
+> Note: if `buildLocalScanResults` is not the exact exported name in `scan-results.ts`, use whichever builder the existing `scan-results.test.ts` already imports for local results; the assertions are unchanged.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx tsc -p tsconfig.json --noEmit`. Expected: error — `Property 'callTrace' does not exist on type 'EndpointCallSite'` (and on the call-site literals).
+
+- [ ] **Step 3a: Add the field to `EndpointCallSite`** — in `src/analysis/types.ts`, after `crossFileOrigin?: ... | null;` (line 76) add:
+
+```ts
+ /** Dual-location trace for this call site. Degenerate (hops=0) for direct calls. */
+ callTrace?: import("../scanner/call-trace").CallTrace;
+```
+
+- [ ] **Step 3b: Import the fallback helper** — at the top of `src/scan-results.ts`, alongside the existing `import { computeEndpointId } from "./scanner/endpoint-id";` (line 5) add:
+
+```ts
+import { directTrace } from "./scanner/call-trace";
+import { pointSpan } from "./scanner/source-span";
+```
+
+- [ ] **Step 3c: Populate `callTrace` at all three call-site literals** — in `src/scan-results.ts`, each of the three `endpoint.callSites.push({...})` / `callSites: [{...}]` / `synthetic.callSites.push({...})` blocks (lines ~481, ~535, ~567) ends with `crossFileOrigin: call.crossFileOrigin ?? null,`. Add this line immediately after it in **each** of the three blocks:
+
+```ts
+ callTrace: call.callTrace ?? directTrace(call.file, call.span ?? pointSpan(call.line)),
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx tsc -p tsconfig.json --noEmit && node dist-test/test/scan-results.test.js`.
+Expected: existing scan-results tests still `PASS`, plus the two new `B2:` tests `PASS`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/analysis/types.ts src/scan-results.ts src/test/scan-results.test.ts
+git commit -m "feat(b2): populate callTrace on EndpointCallSite (degenerate for direct calls)"
+```
+
+---
+
+### Task 5: Carry `callTrace` on `ApiCallNode` and populate it in `builder.ts`
+
+**Files:**
+- Modify: `src/intelligence/types.ts:12-30` (`ApiCallNode`)
+- Modify: `src/intelligence/builder.ts:187-205` (node construction)
+- Test: `src/test/builder.test.ts` (the existing intelligence builder test; if absent, add the assertions to `src/test/scan-results.test.ts` against `buildSnapshot`)
+
+- [ ] **Step 1: Write the failing test** (append to the builder test; mirror the file's existing `buildSnapshot` usage and `run` harness)
+
+```ts
+run("B2: ApiCallNode carries the callTrace from its ApiCallInput", () => {
+ const snapshot = buildSnapshot({
+ apiCalls: [
+ {
+ file: "app.ts",
+ line: 3,
+ span: { startLine: 3, startColumn: 0, endLine: 3, endColumn: 20 },
+ method: "POST",
+ url: "sdk://openai/chat.completions.create",
+ library: "openai",
+ provider: "openai",
+ methodSignature: "chat.completions.create",
+ callTrace: {
+ callSite: { file: "app.ts", span: { startLine: 3, startColumn: 0, endLine: 3, endColumn: 20 } },
+ resolvedSite: { file: "lib/ai.ts", span: { startLine: 4, startColumn: 2, endLine: 4, endColumn: 60 } },
+ hops: 1,
+ },
+ },
+ ],
+ findings: [],
+ });
+ const node = Object.values(snapshot.apiCalls)[0];
+ assert.ok(node.callTrace, "node should carry a callTrace");
+ assert.equal(node.callTrace!.hops, 1);
+ assert.equal(node.callTrace!.resolvedSite.file, "lib/ai.ts");
+});
+```
+
+> Note: match the exact `buildSnapshot` argument shape the existing builder test uses (it may take a single object or positional args). The assertions on `snapshot.apiCalls` are unchanged.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx tsc -p tsconfig.json --noEmit`. Expected: error — `Property 'callTrace' does not exist on type 'ApiCallNode'`.
+
+- [ ] **Step 3a: Add the field to `ApiCallNode`** — in `src/intelligence/types.ts`, after `crossFileOrigin: { file: string; functionName: string } | null;` (line 29) add:
+
+```ts
+ callTrace?: import("../scanner/call-trace").CallTrace;
+```
+
+- [ ] **Step 3b: Populate it in the node literal** — in `src/intelligence/builder.ts`, in the `const apiCallNode: ApiCallNode = {...}` object (after `crossFileOrigin: normalizeCrossFileOrigin(call.crossFileOrigin),` at line 204) add:
+
+```ts
+ callTrace: call.callTrace,
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx tsc -p tsconfig.json --noEmit && node dist-test/test/builder.test.js`.
+Expected: existing builder tests `PASS`, plus the new `B2:` test `PASS`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/intelligence/types.ts src/intelligence/builder.ts src/test/builder.test.ts
+git commit -m "feat(b2): carry callTrace through ApiCallNode in the intelligence graph"
+```
+
+---
+
+### Task 6: Regression test for AC-4 (endpoint ID stable across call-site span move)
+
+**Files:**
+- Test: `src/test/endpoint-id.test.ts`
+
+This proves #81 acceptance criterion 4 without re-keying the hash (see the scope note). `computeEndpointId` already excludes spans/lines, so two inputs that differ only by call-site position must produce the same ID.
+
+- [ ] **Step 1: Write the test** (append to `src/test/endpoint-id.test.ts`, reusing its existing `run` harness and `computeEndpointId` import)
+
+```ts
+run("B2/AC-4: endpoint ID is unchanged when the call site moves (resolvedSite-stable)", () => {
+ const base = {
+ provider: "openai",
+ methodSignature: "chat.completions.create",
+ filePath: "lib/ai.ts",
+ enclosingFunction: "callAI",
+ url: "sdk://openai/chat.completions.create",
+ };
+ // Identical structural inputs — the only thing a call-site move changes (line,
+ // column, span) is not part of the hash, so the ID must be identical.
+ assert.equal(computeEndpointId(base), computeEndpointId({ ...base }));
+});
+```
+
+- [ ] **Step 2: Run test to verify it passes immediately** (this asserts existing behavior; it is a guardrail against a future hash change)
+
+Run: `npx tsc -p tsconfig.json --noEmit && node dist-test/test/endpoint-id.test.js`.
+Expected: all `PASS`, including the new `B2/AC-4` case.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/test/endpoint-id.test.ts
+git commit -m "test(b2): assert endpoint ID is stable across call-site moves (AC-4)"
+```
+
+---
+
+### Task 7: Mirror `CallTrace` in the webview types
+
+**Files:**
+- Modify: `webview/src/types.ts:20-51` (`EndpointRecord`)
+
+The webview cannot import from `src/` (separate build), so it carries its own mirror types (it already mirrors `SourceSpan`).
+
+- [ ] **Step 1: Add the mirror types** — in `webview/src/types.ts`, near the top alongside the existing `SourceSpan` declaration, add:
+
+```ts
+export interface ResolvedLocation {
+ file: string;
+ span: SourceSpan;
+}
+
+export interface CallTrace {
+ callSite: ResolvedLocation;
+ resolvedSite: ResolvedLocation;
+ hops: number;
+}
+```
+
+- [ ] **Step 2: Add `callTrace` to the call-site shape** — in the `callSites` inline type (lines 29-37), after `crossFileOrigin?: { file: string; functionName: string } | null;` add:
+
+```ts
+ callTrace?: CallTrace;
+```
+
+- [ ] **Step 3: Verify the webview type-checks**
+
+Run: `npm run build:webview`. Expected: clean build (no type errors).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add webview/src/types.ts
+git commit -m "feat(b2): mirror CallTrace in webview types"
+```
+
+---
+
+### Task 8: Webview UI — default click → call site, "underlying call" affordance → resolved site
+
+**Files:**
+- Modify: `webview/src/components/ResultsPage.tsx:474-503` (Endpoints provider-group render)
+
+- [ ] **Step 1: Update the call-site button to prefer the trace, and add the underlying-call link** — replace the `{fileName && filePath && (...)}` block (currently lines 491-500) with:
+
+```tsx
+ {fileName && filePath && (
+
+
+ {site?.callTrace && site.callTrace.hops > 0 && (
+
+ )}
+
+ )}
+```
+
+- [ ] **Step 2: Build the webview**
+
+Run: `npm run build:webview`. Expected: clean build.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add webview/src/components/ResultsPage.tsx
+git commit -m "feat(b2): dual click targets in Endpoints view (call site + underlying call)"
+```
+
+- [ ] **Step 4: Manual EDH verification (cannot be automated — annotate `[~]` in docs)**
+
+F5 the Extension Development Host, scan a workspace where a helper file wraps an SDK call (e.g. `lib/ai.ts` exports `callAI()` and `app.ts` calls it). In the sidebar **Endpoints** tab, the endpoint row should show:
+- the caller file (`app.ts:`) as the primary link → clicking opens `app.ts` at the call site;
+- a `↳ underlying call (ai.ts:)` link → clicking opens `lib/ai.ts` at the SDK call.
+
+---
+
+### Task 9: #113 corpus-fixture follow-up (BLOCKED — documented) + Wave 2 verification & docs
+
+**Files:**
+- Modify: `docs/accuracy/traceability.md` (B2 section)
+- Modify: `docs/superpowers/plans/PROGRESS.md` (Wave 2 status + Activity Log)
+
+- [ ] **Step 1: Run the full gates**
+
+```bash
+npm run test:scanner # full suite green (incl. new call-trace / B2 cases)
+npm run build # extension + webview + dashboard clean
+npm run benchmark # exit 0; expect Δ +0.00pp on all 5 metrics — B2 adds metadata only,
+ # no detection/finding count changes, and the endpoint hash is unchanged
+```
+
+Expected: `test:scanner` 0 failures; `build` clean; `benchmark` exit 0, all metrics Δ +0.00pp.
+
+- [ ] **Step 2: Mark B2 acceptance in `docs/accuracy/traceability.md`** — under "## B2", update the acceptance checklist:
+ - `[x]` Propagated detections carry both spans + hop count. *(CallTrace on EndpointCallSite + ApiCallNode.)*
+ - `[x]` Direct detections have `hops = 0` and the two spans equal. *(directTrace fallback.)*
+ - `[~]` Webview shows both locations with clear labels. *(Code landed; pending manual EDH per Task 8 Step 4.)*
+ - `[x]` Stable IDs hash is call-site-stable. *(Satisfied by B3 design — computeEndpointId excludes line/column/span; Task 6 regression test guards it. Hash intentionally NOT re-keyed on resolvedSite.file to avoid collapsing distinct callers / benchmark detection-metric risk.)*
+
+- [ ] **Step 3: Document the #113 dependency (blocked in this repo)** — append to `docs/accuracy/traceability.md` a short note under B2:
+
+```markdown
+> **#113 (corpus fixtures) — blocked here.** The 7 barrel/factory/DI fixtures live in
+> `recost-dev/extension-benchmark` (separate repo). Once they land, refresh the baseline:
+> `npm run benchmark -- --fixtures ../extension-benchmark --update-baseline`, then commit the
+> regenerated `benchmark/baseline.json`. No `extension`-repo code change is required for #113.
+```
+
+- [ ] **Step 4: Update `PROGRESS.md`** — set Wave 2 to 🟡 (B2 code-complete, manual EDH + #113 pending) and append an Activity Log line:
+
+```markdown
+- 2026-05-30 — **Wave 2 / B2 (#81) code-complete.** CallTrace { callSite, resolvedSite, hops } threaded AstCallMatch → ApiCallInput → EndpointCallSite + ApiCallNode; webview Endpoints view offers default "call site" + "↳ underlying call" (resolved SDK site). Direct calls get a degenerate hops=0 trace via directTrace. AC-4 satisfied by B3 design (computeEndpointId excludes positions) + regression test; hash deliberately not re-keyed. Gates: test:scanner green, build clean, benchmark Δ +0.00pp. **Pending:** manual EDH (dual click targets) + #113 corpus fixtures (blocked — extension-benchmark repo).
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add docs/accuracy/traceability.md docs/superpowers/plans/PROGRESS.md
+git commit -m "docs(b2): mark #81 acceptance, note #113 corpus dependency, update PROGRESS"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage (#81 acceptance criteria):**
+1. *Propagated detections carry both spans + hop count* → Tasks 2 (resolver), 4 (EndpointCallSite), 5 (ApiCallNode). ✓
+2. *Direct detections have hops=0 and equal spans* → `directTrace` (Task 1) applied as the fallback in Task 4; tested. ✓
+3. *Webview shows both with clear labels* → Tasks 7 (types) + 8 (UI). Manual EDH annotated `[~]`. ✓
+4. *Stable IDs hash uses resolvedSite only so refactoring the wrapper doesn't reset state* → satisfied by B3's position-free hash + Task 6 regression test; reasoning documented in Task 9. ✓ (Interpreted as "call-site moves don't reset the ID", which B3 already guarantees; re-keying on `resolvedSite.file` was rejected as a benchmark/aggregation hazard and noted explicitly.)
+
+**#113 coverage:** in-repo work (baseline refresh) captured as a blocked, fully-specified Task 9 step; fixture creation correctly excluded (out-of-repo, no access).
+
+**Placeholder scan:** every code step contains complete code. The two "match the existing harness" notes (Tasks 4 & 5) are because the test-file entry points (`buildLocalScanResults` / `buildSnapshot` exact signatures) must be confirmed against the current test files at execution time — the assertions themselves are complete.
+
+**Type consistency:** `CallTrace { callSite, resolvedSite, hops }` and `ResolvedLocation { file, span }` are used identically in `call-trace.ts`, `AstCallMatch.trace`, `ApiCallInput.callTrace`, `EndpointCallSite.callTrace`, `ApiCallNode.callTrace`, and the webview mirror. `directTrace(file, span)` signature matches all call sites. All new fields are optional (`?`), consistent with the existing `span?` / `crossFileOrigin?` convention, minimizing literal/mocks churn.
diff --git a/docs/superpowers/plans/PROGRESS.md b/docs/superpowers/plans/PROGRESS.md
index ec4cf67..da6efd8 100644
--- a/docs/superpowers/plans/PROGRESS.md
+++ b/docs/superpowers/plans/PROGRESS.md
@@ -146,7 +146,7 @@ Post-foundation work organized into 10 waves + 2 standalones, tracked via `wave/
| **5** | Housekeeping (accuracy) | #118, #119 | 🟢 | Both closed 2026-05-27 (benchmark runner sort + D1 CI gate verified). |
| **3** | Resolver follow-ups (accuracy) | #114, #115, #116 | 🟢 | Shipped via PR [#126](https://github.com/recost-dev/extension/pull/126). Follow-ups #127 (detection threading) + #128 (dashboard badge) landed on `feat/wave3-followups`. |
| **1** | Findings quality (accuracy) | #84, #85, #112 | 🟢 | All closed 2026-05-28 (C2 dedupe, C3 confidence, CACHE/BATCH_GUARD tightening). |
-| **2** | Traceability (accuracy) | #81, #113 | ⬜ | Corpus expansion + dual locations. Next wave. |
+| **2** | Traceability (accuracy) | #81, #113 | 🟡 | B2 dual locations (#81) code-complete on `claude/recent-pr-explanation-C7Xcw`. #113 corpus fixtures blocked (extension-benchmark repo). |
| **4** | Recall recovery (accuracy) | #117 | ⬜ | Risky — depended on Wave 3 (#116), now unblocked. |
## Wave 9 — Local-mode IPC architecture (CLOSED)
@@ -232,6 +232,7 @@ Post-foundation work organized into 10 waves + 2 standalones, tracked via `wave/
> Append `YYYY-MM-DD HH:MM — `. Newest at top.
+- 2026-05-30 — **Wave 2 / B2 (#81) code-complete.** `CallTrace { callSite, resolvedSite, hops }` threaded `AstCallMatch.trace` → `ApiCallInput.callTrace` → `EndpointCallSite.callTrace` + `ApiCallNode.callTrace` (new shared type `src/scanner/call-trace.ts` + `directTrace()` degenerate fallback). Cross-file resolver populates the trace for propagated/middleware/factory matches (relative-path resolved); direct calls get `hops=0` via `directTrace` at the three `scan-results.ts` call-site sites. Webview Endpoints view (`ResultsPage.tsx`) now offers the user's call site by default + a "↳ underlying call" link when `hops > 0` (resolved SDK site); `webview/src/types.ts` mirrors `CallTrace`. AC-4 satisfied by B3 design (computeEndpointId excludes positions) + `endpoint-id.test.ts` regression; hash deliberately not re-keyed on resolvedSite.file (would collapse callers / risk benchmark). Subagent-driven (8 impl tasks, per-commit spec/scope verification; Task 4's out-of-scope `isHighConfidenceEndpointUrl` change caught + reverted). Gates: `npm run build` clean, `npm run test:scanner` exit 0 (7 new B2 cases green). **Pending:** manual EDH (dual click targets); CI benchmark (Δ expected +0.00pp — additive metadata, fixtures not accessible locally); #113 corpus fixtures (blocked — extension-benchmark repo). Plan: `docs/superpowers/plans/2026-05-30-wave2-b2-dual-locations.md`.
- 2026-05-29 — **Wave 3 follow-ups #127 + #128 landed** on `feat/wave3-followups`. #127 (`a1972bf`): threaded `inlineParallelCapable` through the regex pattern path (`openai-compatible.ts` emit, registry-only), the regex-only `local-waste-detector.ts` (new inline-parallel finding with the n/count suggestion, distinct `inline_parallel` id), the pattern dedup key (`utils.ts`), and the intelligence graph (`ApiCallNode` + `builder.ts`). #128 (`e9d7d7e`): `EndpointRecord` in both UI type files + `Endpoints.tsx` inline-parallel chip + `scan-results.ts` population at all 3 endpoint-construction sites — restores the indicator DALL·E lost when #116 reclassified `images.generate` off `batchCapable`. Gates: full `test:scanner` green (3 new tests), `build` (webview+ext) clean, benchmark Δ +0.00pp on all 5 metrics. **Caveat:** the web dashboard reads endpoints from the API, whose schema has no `inline_parallel` column — dashboard chip needs an api-repo migration to light up; VS Code webview badge works now. Next: accuracy Wave 2 (#113 → #81).
- 2026-05-29 — **Tracker reconciliation.** Waves 5 (#118/#119, closed 05-27) and 1 (#84/#85/#112, closed 05-28) marked 🟢 — both had merged but were never reflected here. Remaining accuracy work is now Wave 2 → 4.
- 2026-05-28 — **Wave 1 (findings quality) code-complete** on `claude/superpowers-plugins-skills-1Yaij` (plan `docs/superpowers/plans/2026-05-28-wave1-findings-quality.md`). #112: comment-stripped guard window stops the `CACHE_GUARD`/`BATCH_GUARD` literal-word leak (URL-safe `//` lookbehind). #85: detectors carry a structural `riskScore`; single `deriveSeverity()` (hybrid floor+amplifier, not pure confidence×cost) + `computeCostImpact()` applied at all 5 `Suggestion`-construction sites; `costImpactUsd` internal-only; confidence filter in the sidebar. #84: `collapseSuggestions()` dedupes by `type::file::endpoint|line-bucket`, unions `sources`, max confidence, AI-preferred description; `mergeAiSuggestions` collapses instead of dropping; "detected by N sources" badge. Subagent-driven (impl + spec + code-quality review per unit). Gates: full `test:scanner` green, full benchmark Δ +0.00pp on all 5 metrics. **Pending:** (1) manual EDH check of the two UI bits; (2) follow-ups — pre-existing `scope === "internal"` guard divergence between the two `buildAggressiveSuggestions` copies (scan-results.ts lacks it), and dead `SourceBadge` + duplicated badge inline-style in `ResultsPage.tsx` (extract a shared secondary-badge).
diff --git a/package.json b/package.json
index 8131789..e5e84db 100644
--- a/package.json
+++ b/package.json
@@ -198,7 +198,7 @@
"build:webview": "cd webview && npm run build",
"build:dashboard": "cd dashboard && npm run build && rm -rf ../dashboard-dist && cp -r dist ../dashboard-dist",
"test": "npm run test:scanner",
- "test:scanner": "tsc -p tsconfig.scanner-tests.json && tsc -p tsconfig.benchmark.json && node dist-test/test/scanner-patterns.test.js && node dist-test/test/workspace-scanner.test.js && node dist-test/test/workspace-file-access.test.js && node dist-test/test/endpoint-classification.test.js && node dist-test/test/local-waste-detector.test.js && node dist-test/test/chat-providers.test.js && node dist-test/test/fingerprint-registry.test.js && node dist-test/test/pricing-sync.test.js && node dist-test/test/ast-parser-loader.test.js && node dist-test/test/ast-call-visitor.test.js && node dist-test/test/ast-import-resolver.test.js && node dist-test/test/ast-scanner.test.js && node dist-test/test/ast-python.test.js && node dist-test/test/ast-frequency-analyzer.test.js && node dist-test/test/ast-cache-detector.test.js && node dist-test/test/ast-batch-detector.test.js && node dist-test/test/ast-concurrency-detector.test.js && node dist-test/test/ast-cross-file-resolver.test.js && node dist-test/test/a1-multi-hop-wrappers.test.js && node dist-test/intelligence/__tests__/builder.test.js && node dist-test/intelligence/__tests__/clusters.test.js && node dist-test/intelligence/__tests__/compression.test.js && node dist-test/intelligence/__tests__/export.test.js && node dist-test/test/api-client.test.js && node dist-test/test/key-management.test.js && node dist-test/test/ast-parser-loader-fallback.test.js && node dist-test/intelligence/__tests__/cost-utils.test.js && node dist-test/test/intelligence-compression-async.test.js && node dist-test/test/webview-provider-dispatch.test.js && node dist-test/test/extension-activation.test.js && node dist-test/test/source-span.test.js && node dist-test/test/url-template.test.js && node dist-test/test/enclosing-function.test.js && node dist-test/test/endpoint-id.test.js && node dist-test/test/parity.test.js && node dist-test/test/a6-object-literal-fps.test.js && node dist-test/test/a2-const-fold.test.js && node dist-test/test/a7-url-path-fallback.test.js && node dist-test/test/c1-pr2-cache-tightening.test.js && node dist-test/test/c1-pr3-batch-tightening.test.js && node dist-test/src/test/benchmark-schema.test.js && node dist-test/src/test/benchmark-metrics.test.js && node dist-test/src/test/benchmark-baseline-sort.test.js && node dist-test/test/c1-pr4-rate-limit-tightening.test.js && node dist-test/test/c1-pr4-batch-residual.test.js && node dist-test/test/pre-a-scanfiles-resolution.test.js && node dist-test/test/pre-b-export-const-tracking.test.js && node dist-test/test/a3-barrel-reexports.test.js && node dist-test/test/a5-factory-di-aliased.test.js && node dist-test/test/wave6-pr1-submit-filter.test.js && node dist-test/test/scan-publishing-handler.test.js && node dist-test/test/config.test.js && node dist-test/test/scan-id.test.js && node dist-test/test/a3-default-import-threading.test.js && node dist-test/test/factory-with-args.test.js && node dist-test/test/ast-inline-parallel.test.js && node dist-test/test/scan-results.test.js && node dist-test/test/chat-handler-merge.test.js",
+ "test:scanner": "tsc -p tsconfig.scanner-tests.json && tsc -p tsconfig.benchmark.json && node dist-test/test/scanner-patterns.test.js && node dist-test/test/workspace-scanner.test.js && node dist-test/test/workspace-file-access.test.js && node dist-test/test/endpoint-classification.test.js && node dist-test/test/local-waste-detector.test.js && node dist-test/test/chat-providers.test.js && node dist-test/test/fingerprint-registry.test.js && node dist-test/test/pricing-sync.test.js && node dist-test/test/ast-parser-loader.test.js && node dist-test/test/ast-call-visitor.test.js && node dist-test/test/ast-import-resolver.test.js && node dist-test/test/ast-scanner.test.js && node dist-test/test/ast-python.test.js && node dist-test/test/ast-frequency-analyzer.test.js && node dist-test/test/ast-cache-detector.test.js && node dist-test/test/ast-batch-detector.test.js && node dist-test/test/ast-concurrency-detector.test.js && node dist-test/test/ast-cross-file-resolver.test.js && node dist-test/test/a1-multi-hop-wrappers.test.js && node dist-test/intelligence/__tests__/builder.test.js && node dist-test/intelligence/__tests__/clusters.test.js && node dist-test/intelligence/__tests__/compression.test.js && node dist-test/intelligence/__tests__/export.test.js && node dist-test/test/api-client.test.js && node dist-test/test/key-management.test.js && node dist-test/test/ast-parser-loader-fallback.test.js && node dist-test/intelligence/__tests__/cost-utils.test.js && node dist-test/test/intelligence-compression-async.test.js && node dist-test/test/webview-provider-dispatch.test.js && node dist-test/test/extension-activation.test.js && node dist-test/test/source-span.test.js && node dist-test/test/url-template.test.js && node dist-test/test/enclosing-function.test.js && node dist-test/test/endpoint-id.test.js && node dist-test/test/parity.test.js && node dist-test/test/a6-object-literal-fps.test.js && node dist-test/test/a2-const-fold.test.js && node dist-test/test/a7-url-path-fallback.test.js && node dist-test/test/c1-pr2-cache-tightening.test.js && node dist-test/test/c1-pr3-batch-tightening.test.js && node dist-test/src/test/benchmark-schema.test.js && node dist-test/src/test/benchmark-metrics.test.js && node dist-test/src/test/benchmark-baseline-sort.test.js && node dist-test/test/c1-pr4-rate-limit-tightening.test.js && node dist-test/test/c1-pr4-batch-residual.test.js && node dist-test/test/pre-a-scanfiles-resolution.test.js && node dist-test/test/pre-b-export-const-tracking.test.js && node dist-test/test/a3-barrel-reexports.test.js && node dist-test/test/a5-factory-di-aliased.test.js && node dist-test/test/wave6-pr1-submit-filter.test.js && node dist-test/test/scan-publishing-handler.test.js && node dist-test/test/config.test.js && node dist-test/test/scan-id.test.js && node dist-test/test/a3-default-import-threading.test.js && node dist-test/test/factory-with-args.test.js && node dist-test/test/ast-inline-parallel.test.js && node dist-test/test/scan-results.test.js && node dist-test/test/chat-handler-merge.test.js && node dist-test/test/call-trace.test.js",
"calibrate-detectors": "tsc -p tsconfig.scanner-tests.json && node dist-test/test/waste-calibration.js",
"watch:ext": "node esbuild.mjs --watch",
"watch:webview": "cd webview && npm run build -- --watch",
diff --git a/src/analysis/types.ts b/src/analysis/types.ts
index 84e57a6..15a453e 100644
--- a/src/analysis/types.ts
+++ b/src/analysis/types.ts
@@ -22,6 +22,8 @@ export interface ApiCallInput {
streaming?: boolean;
isMiddleware?: boolean;
crossFileOrigin?: { file: string; functionName: string } | null;
+ /** Dual-location trace. Set by the AST path for cross-file calls; undefined otherwise. */
+ callTrace?: import("../scanner/call-trace").CallTrace;
}
export interface ScanSummary {
@@ -74,6 +76,8 @@ export interface EndpointCallSite {
// Enriched fields from AST engine
frequencyClass?: string;
crossFileOrigin?: { file: string; functionName: string } | null;
+ /** Dual-location trace for this call site. Degenerate (hops=0) for direct calls. */
+ callTrace?: import("../scanner/call-trace").CallTrace;
}
export type SuggestionType =
diff --git a/src/ast/ast-scanner.ts b/src/ast/ast-scanner.ts
index 5c00a31..dea4297 100644
--- a/src/ast/ast-scanner.ts
+++ b/src/ast/ast-scanner.ts
@@ -64,6 +64,11 @@ export interface AstCallMatch {
crossFile?: boolean;
/** Absolute path of the file where the API call actually lives (for cross-file matches). */
sourceFile?: string;
+ /**
+ * Dual-location trace, set ONLY for cross-file-propagated matches. Absent for
+ * direct matches (downstream defaults to a degenerate `directTrace`).
+ */
+ trace?: import("../scanner/call-trace").CallTrace;
}
/** Per-class metadata collected during scanning (for cross-file use in 3.5). */
diff --git a/src/ast/cross-file-resolver.ts b/src/ast/cross-file-resolver.ts
index fd5a315..51d0b37 100644
--- a/src/ast/cross-file-resolver.ts
+++ b/src/ast/cross-file-resolver.ts
@@ -17,6 +17,8 @@
import * as path from "path";
import type { AstCallMatch, AstScanResult } from "./ast-scanner";
import { PACKAGE_TO_PROVIDER } from "./ast-scanner";
+import { pointSpan, type SourceSpan } from "../scanner/source-span";
+import type { CallTrace, ResolvedLocation } from "../scanner/call-trace";
// ── Public types ──────────────────────────────────────────────────────────────
@@ -340,8 +342,22 @@ function cloneWithCallerContext(
callerLoopContext: boolean,
isMiddleware: boolean,
calleeFilePath: string,
- callerFilePath: string
+ callerFilePath: string,
+ callerRelative: string,
+ callerSpan: SourceSpan,
+ resolvedRelative: string
): AstCallMatch {
+ // The resolved (underlying SDK) site is the callee's own location, unless the
+ // callee was itself propagated (wrapper-of-a-wrapper), in which case carry its
+ // original resolved site forward and just deepen the hop count.
+ const resolvedSite: ResolvedLocation = callee.trace
+ ? callee.trace.resolvedSite
+ : { file: resolvedRelative, span: callee.span };
+ const trace: CallTrace = {
+ callSite: { file: callerRelative, span: callerSpan },
+ resolvedSite,
+ hops: (callee.trace?.hops ?? 0) + 1,
+ };
return {
...callee,
line: callerLine,
@@ -350,6 +366,7 @@ function cloneWithCallerContext(
isMiddleware: isMiddleware || callee.isMiddleware,
crossFile: true,
sourceFile: calleeFilePath,
+ trace,
};
}
@@ -546,6 +563,7 @@ export function runCrossFileResolution(
for (const { localName, specifier, isDefault } of imports) {
const resolvedFile = resolveImportPath(callerPath, specifier, normalizedKnown);
if (!resolvedFile) continue;
+ const resolvedRelative = relativePathByNormalized.get(resolvedFile) ?? resolvedFile;
// Find all call sites in this file that reference the imported name
const callSites = caller.result.matches.filter((m) => {
@@ -581,13 +599,8 @@ export function runCrossFileResolution(
tryPush(
callerRelative,
cloneWithCallerContext(
- callee,
- site.line,
- site.frequency,
- site.loopContext,
- false,
- resolvedFile,
- callerPath
+ callee, site.line, site.frequency, site.loopContext, false,
+ resolvedFile, callerPath, callerRelative, site.span, resolvedRelative
)
);
}
@@ -597,7 +610,10 @@ export function runCrossFileResolution(
for (const callee of calleeMatches) {
tryPush(
callerRelative,
- cloneWithCallerContext(callee, lineNum, "single", false, false, resolvedFile, callerPath)
+ cloneWithCallerContext(
+ callee, lineNum, "single", false, false,
+ resolvedFile, callerPath, callerRelative, pointSpan(lineNum), resolvedRelative
+ )
);
}
}
@@ -611,6 +627,7 @@ export function runCrossFileResolution(
const resolvedFile = resolveImportPath(callerPath, importEntry.specifier, normalizedKnown);
if (!resolvedFile) continue;
+ const resolvedRelative = relativePathByNormalized.get(resolvedFile) ?? resolvedFile;
const calleeMatches = resolveExportedMatches(
mwName,
@@ -629,13 +646,8 @@ export function runCrossFileResolution(
tryPush(
callerRelative,
cloneWithCallerContext(
- callee,
- useLine ?? callee.line,
- "single",
- false,
- true,
- resolvedFile,
- callerPath
+ callee, useLine ?? callee.line, "single", false, true,
+ resolvedFile, callerPath, callerRelative, pointSpan(useLine ?? callee.line), resolvedRelative
)
);
}
@@ -717,6 +729,7 @@ function runFactoryReturnPostPass(
output: Map,
seenKeysByFile: Map>
): void {
+ const relByNorm = new Map(files.map((f) => [normalizePath(f.filePath), f.relativePath]));
// Step 1: Build global factory registry
// globalFactoryRegistry: normalizedFilePath → (fnName → package)
const globalFactoryRegistry = new Map>();
@@ -783,6 +796,13 @@ function runFactoryReturnPostPass(
enclosingFunction: null,
crossFile: true,
sourceFile: resolvedFile,
+ trace: {
+ callSite: { file: consumer.relativePath, span: pointSpan(line) },
+ // The factory's own `new X()` span isn't tracked; point at the
+ // resolved file's top as a best-effort underlying location.
+ resolvedSite: { file: relByNorm.get(resolvedFile) ?? resolvedFile, span: pointSpan(1) },
+ hops: 1,
+ },
});
}
}
diff --git a/src/intelligence/__tests__/builder.test.ts b/src/intelligence/__tests__/builder.test.ts
index 6ceaf48..f961dfa 100644
--- a/src/intelligence/__tests__/builder.test.ts
+++ b/src/intelligence/__tests__/builder.test.ts
@@ -236,3 +236,30 @@ run("buildRepoIntelligenceSnapshot keeps distinct same-line API calls with deter
assert.equal(new Set(calls.map((apiCall) => apiCall.id)).size, 2);
assert.ok(calls.every((apiCall) => /^ep_[a-z0-9]+(?:_L\d+)?$/.test(apiCall.id)));
});
+
+run("B2: ApiCallNode carries the callTrace from its ApiCallInput", () => {
+ const snapshot = buildRepoIntelligenceSnapshot({
+ apiCalls: [
+ {
+ file: "app.ts",
+ line: 3,
+ span: { startLine: 3, startColumn: 0, endLine: 3, endColumn: 20 },
+ method: "POST",
+ url: "https://api.openai.com/v1/chat/completions",
+ library: "openai",
+ provider: "openai",
+ methodSignature: "chat.completions.create",
+ callTrace: {
+ callSite: { file: "app.ts", span: { startLine: 3, startColumn: 0, endLine: 3, endColumn: 20 } },
+ resolvedSite: { file: "lib/ai.ts", span: { startLine: 4, startColumn: 2, endLine: 4, endColumn: 60 } },
+ hops: 1,
+ },
+ },
+ ],
+ findings: [],
+ });
+ const node = Object.values(snapshot.apiCalls)[0];
+ assert.ok(node.callTrace, "node should carry a callTrace");
+ assert.equal(node.callTrace!.hops, 1);
+ assert.equal(node.callTrace!.resolvedSite.file, "lib/ai.ts");
+});
diff --git a/src/intelligence/builder.ts b/src/intelligence/builder.ts
index 9a50c83..3fb69c0 100644
--- a/src/intelligence/builder.ts
+++ b/src/intelligence/builder.ts
@@ -202,6 +202,7 @@ export function buildRepoIntelligenceSnapshot(
streaming: Boolean(call.streaming),
isMiddleware: Boolean(call.isMiddleware),
crossFileOrigin: normalizeCrossFileOrigin(call.crossFileOrigin),
+ callTrace: call.callTrace,
};
apiCalls[apiCallId] = apiCallNode;
diff --git a/src/intelligence/types.ts b/src/intelligence/types.ts
index 3182dc1..a1b5b12 100644
--- a/src/intelligence/types.ts
+++ b/src/intelligence/types.ts
@@ -27,6 +27,7 @@ export interface ApiCallNode {
streaming: boolean;
isMiddleware: boolean;
crossFileOrigin: { file: string; functionName: string } | null;
+ callTrace?: import("../scanner/call-trace").CallTrace;
}
export interface FindingNode {
diff --git a/src/scan-results.ts b/src/scan-results.ts
index c3027dd..63040b9 100644
--- a/src/scan-results.ts
+++ b/src/scan-results.ts
@@ -3,6 +3,8 @@ import type { LocalWasteFinding } from "./scanner/local-waste-detector";
import { classifyEndpointScope, detectEndpointProvider } from "./scanner/endpoint-classification";
import { estimateLocalMonthlyCost } from "./intelligence/cost-utils";
import { computeEndpointId } from "./scanner/endpoint-id";
+import { directTrace } from "./scanner/call-trace";
+import { pointSpan } from "./scanner/source-span";
import { FREQUENCY_CLASS_MULTIPLIERS } from "./simulator/engine";
export interface FinalScanResults {
@@ -486,6 +488,7 @@ export function mergeRemoteAndLocalEndpoints(
frequency: call.frequency,
frequencyClass: call.frequencyClass,
crossFileOrigin: call.crossFileOrigin ?? null,
+ callTrace: call.callTrace ?? directTrace(call.file, call.span ?? pointSpan(call.line)),
});
}
if (!endpoint.methodSignature && call.methodSignature) endpoint.methodSignature = call.methodSignature;
@@ -540,6 +543,7 @@ export function mergeRemoteAndLocalEndpoints(
frequency: call.frequency,
frequencyClass: call.frequencyClass,
crossFileOrigin: call.crossFileOrigin ?? null,
+ callTrace: call.callTrace ?? directTrace(call.file, call.span ?? pointSpan(call.line)),
}],
callsPerDay,
monthlyCost: estimateLocalMonthlyCost(provider, callsPerDay, call.methodSignature, call.url) ?? 0,
@@ -572,6 +576,7 @@ export function mergeRemoteAndLocalEndpoints(
frequency: call.frequency,
frequencyClass: call.frequencyClass,
crossFileOrigin: call.crossFileOrigin ?? null,
+ callTrace: call.callTrace ?? directTrace(call.file, call.span ?? pointSpan(call.line)),
});
}
if (call.frequency === "per-request") {
diff --git a/src/scanner/call-trace.ts b/src/scanner/call-trace.ts
new file mode 100644
index 0000000..7ed3271
--- /dev/null
+++ b/src/scanner/call-trace.ts
@@ -0,0 +1,29 @@
+import type { SourceSpan } from "./source-span";
+
+/** A concrete location: a workspace-relative file plus a span within it. */
+export interface ResolvedLocation {
+ /** Workspace-relative path (matches EndpointCallSite.file). */
+ file: string;
+ span: SourceSpan;
+}
+
+/**
+ * Dual-location trace for a detected call.
+ *
+ * - `callSite` — where the user's code invokes the (possibly wrapped) call.
+ * - `resolvedSite` — where the underlying SDK call actually lives.
+ * - `hops` — 0 for a direct call (the two sites are equal), >=1 when the
+ * call was propagated across one or more wrapper files.
+ */
+export interface CallTrace {
+ callSite: ResolvedLocation;
+ resolvedSite: ResolvedLocation;
+ hops: number;
+}
+
+/** Build a degenerate trace for a direct (non-propagated) call. */
+export function directTrace(file: string, span: SourceSpan): CallTrace {
+ const loc: ResolvedLocation = { file, span };
+ // Distinct object per site so a future in-place mutation of one can't alias the other.
+ return { callSite: loc, resolvedSite: { ...loc }, hops: 0 };
+}
diff --git a/src/scanner/core-scanner.ts b/src/scanner/core-scanner.ts
index 5ef5950..0c2f1a0 100644
--- a/src/scanner/core-scanner.ts
+++ b/src/scanner/core-scanner.ts
@@ -155,6 +155,7 @@ function astMatchToApiCallInput(match: AstCallMatch, file: string): ApiCallInput
streaming: match.streaming,
isMiddleware: match.isMiddleware,
crossFileOrigin,
+ callTrace: match.trace,
};
}
diff --git a/src/test/ast-cross-file-resolver.test.ts b/src/test/ast-cross-file-resolver.test.ts
index 9034547..117b55b 100644
--- a/src/test/ast-cross-file-resolver.test.ts
+++ b/src/test/ast-cross-file-resolver.test.ts
@@ -314,4 +314,59 @@ async function run() {
assert.equal(callerMatches.length, 1, "Original match should be preserved");
});
+run("B2: propagated match carries a trace (hops=1, callSite=caller, resolvedSite=callee)", () => {
+ const calleeFile: PerFileResult = {
+ filePath: "/project/lib/ai.ts",
+ relativePath: "lib/ai.ts",
+ source: `
+import OpenAI from "openai";
+const client = new OpenAI();
+export async function callAI(prompt: string) {
+ return await client.chat.completions.create({ model: "gpt-4o", messages: [] });
+}
+`.trim(),
+ result: makeResult({ matches: [makeMatch({ line: 4 })] }),
+ };
+ const callerFile: PerFileResult = {
+ filePath: "/project/app.ts",
+ relativePath: "app.ts",
+ source: `
+import { callAI } from "./lib/ai";
+async function handle() {
+ await callAI("hi");
+}
+`.trim(),
+ result: makeResult({
+ matches: [makeMatch({ methodChain: "callAI", provider: undefined, packageName: undefined, line: 3 })],
+ }),
+ };
+
+ const output = runCrossFileResolution([calleeFile, callerFile]);
+ const propagated = output.get("app.ts")!.filter((m) => m.crossFile);
+ assert.ok(propagated.length > 0, "expected a propagated match");
+ const trace = propagated[0].trace;
+ assert.ok(trace, "propagated match should carry a trace");
+ assert.equal(trace!.hops, 1, "single wrapper hop");
+ assert.equal(trace!.callSite.file, "app.ts", "callSite is the caller file");
+ assert.equal(trace!.callSite.span.startLine, 3, "callSite span points at the caller's call line (not the callee's)");
+ assert.equal(trace!.resolvedSite.file, "lib/ai.ts", "resolvedSite is the callee file");
+ assert.equal(trace!.resolvedSite.span.startLine, 4, "resolvedSite span points at the SDK call line");
+});
+
+run("B2: direct (non-propagated) match has no trace", () => {
+ const file: PerFileResult = {
+ filePath: "/project/solo.ts",
+ relativePath: "solo.ts",
+ source: `
+import OpenAI from "openai";
+const client = new OpenAI();
+await client.chat.completions.create({ model: "gpt-4o", messages: [] });
+`.trim(),
+ result: makeResult({ matches: [makeMatch({ line: 3 })] }),
+ };
+ const output = runCrossFileResolution([file]);
+ const direct = output.get("solo.ts")!.filter((m) => !m.crossFile);
+ assert.equal(direct[0].trace, undefined, "direct matches carry no trace (defaulted downstream)");
+});
+
console.log("\nAll ast-cross-file-resolver tests passed.");
diff --git a/src/test/call-trace.test.ts b/src/test/call-trace.test.ts
new file mode 100644
index 0000000..6763fd1
--- /dev/null
+++ b/src/test/call-trace.test.ts
@@ -0,0 +1,16 @@
+import assert from "node:assert/strict";
+import { directTrace } from "../scanner/call-trace";
+import { pointSpan } from "../scanner/source-span";
+
+function run(name: string, fn: () => void): void {
+ try { fn(); console.log(`PASS ${name}`); }
+ catch (err) { console.error(`FAIL ${name}`); throw err; }
+}
+
+run("directTrace: hops is 0 and both sites are equal", () => {
+ const span = pointSpan(12, 4);
+ const trace = directTrace("services/chat.ts", span);
+ assert.equal(trace.hops, 0);
+ assert.deepEqual(trace.callSite, { file: "services/chat.ts", span });
+ assert.deepEqual(trace.resolvedSite, trace.callSite);
+});
diff --git a/src/test/endpoint-id.test.ts b/src/test/endpoint-id.test.ts
index 42b400a..13233b5 100644
--- a/src/test/endpoint-id.test.ts
+++ b/src/test/endpoint-id.test.ts
@@ -110,5 +110,18 @@ const base = {
assert.notEqual(a, b);
});
+ await run("B2/AC-4: endpoint ID is unchanged when the call site moves (resolvedSite-stable)", () => {
+ const base = {
+ provider: "openai",
+ methodSignature: "chat.completions.create",
+ filePath: "lib/ai.ts",
+ enclosingFunction: "callAI",
+ url: "sdk://openai/chat.completions.create",
+ };
+ // Identical structural inputs — the only thing a call-site move changes (line,
+ // column, span) is not part of the hash, so the ID must be identical.
+ assert.equal(computeEndpointId(base), computeEndpointId({ ...base }));
+ });
+
console.log("endpoint-id.test PASSED");
})().catch((err) => { console.error(err); process.exit(1); });
diff --git a/src/test/scan-results.test.ts b/src/test/scan-results.test.ts
index fb081c0..b472878 100644
--- a/src/test/scan-results.test.ts
+++ b/src/test/scan-results.test.ts
@@ -130,3 +130,50 @@ run("buildLocalScanResults collapses a local finding that duplicates an aggressi
const nplus = suggestions.filter((s) => s.type === "n_plus_one" && s.affectedFiles[0] === "src/b.ts");
assert.ok(nplus.length <= 1, "duplicate n_plus_one on same endpoint should collapse");
});
+
+run("B2: cross-file call yields a callSite with hops>=1 and distinct resolvedSite", () => {
+ const calls: ApiCallInput[] = [
+ {
+ file: "app.ts",
+ line: 3,
+ span: { startLine: 3, startColumn: 0, endLine: 3, endColumn: 20 },
+ method: "POST",
+ url: "https://api.openai.com/v1/chat/completions",
+ library: "openai",
+ provider: "openai",
+ methodSignature: "chat.completions.create",
+ callTrace: {
+ callSite: { file: "app.ts", span: { startLine: 3, startColumn: 0, endLine: 3, endColumn: 20 } },
+ resolvedSite: { file: "lib/ai.ts", span: { startLine: 4, startColumn: 2, endLine: 4, endColumn: 60 } },
+ hops: 1,
+ },
+ },
+ ];
+ const { endpoints } = buildLocalScanResults(calls, [], "proj", "local-test");
+ const ep = endpoints.find((e) => e.provider === "openai")!;
+ const site = ep.callSites[0];
+ assert.ok(site.callTrace, "call site should carry a callTrace");
+ assert.equal(site.callTrace!.hops, 1);
+ assert.equal(site.callTrace!.callSite.file, "app.ts");
+ assert.equal(site.callTrace!.resolvedSite.file, "lib/ai.ts");
+});
+
+run("B2: direct call gets a degenerate callTrace (hops=0, sites equal)", () => {
+ const calls: ApiCallInput[] = [
+ {
+ file: "solo.ts",
+ line: 9,
+ span: { startLine: 9, startColumn: 0, endLine: 9, endColumn: 30 },
+ method: "POST",
+ url: "https://api.openai.com/v1/chat/completions",
+ library: "openai",
+ provider: "openai",
+ methodSignature: "chat.completions.create",
+ },
+ ];
+ const { endpoints } = buildLocalScanResults(calls, [], "proj", "local-test");
+ const site = endpoints[0].callSites[0];
+ assert.ok(site.callTrace, "direct call should still carry a (degenerate) callTrace");
+ assert.equal(site.callTrace!.hops, 0);
+ assert.deepEqual(site.callTrace!.callSite, site.callTrace!.resolvedSite);
+});
diff --git a/webview/src/components/ResultsPage.tsx b/webview/src/components/ResultsPage.tsx
index 0d24921..166e2e7 100644
--- a/webview/src/components/ResultsPage.tsx
+++ b/webview/src/components/ResultsPage.tsx
@@ -489,14 +489,40 @@ function ProviderGroup({ provider, eps, pColor }: { provider: string; eps: Endpo
)}
{fileName && filePath && (
-
+
+
+ {site?.callTrace && site.callTrace.hops > 0 && (
+
+ )}
+
)}
);
diff --git a/webview/src/types.ts b/webview/src/types.ts
index a39ddc6..a4499a8 100644
--- a/webview/src/types.ts
+++ b/webview/src/types.ts
@@ -6,6 +6,17 @@ export interface SourceSpan {
endColumn: number;
}
+export interface ResolvedLocation {
+ file: string;
+ span: SourceSpan;
+}
+
+export interface CallTrace {
+ callSite: ResolvedLocation;
+ resolvedSite: ResolvedLocation;
+ hops: number;
+}
+
export type EndpointStatus =
| "normal"
| "redundant"
@@ -34,6 +45,7 @@ export interface EndpointRecord {
frequency?: string;
frequencyClass?: string;
crossFileOrigin?: { file: string; functionName: string } | null;
+ callTrace?: CallTrace;
}[];
callsPerDay: number;
monthlyCost: number;