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;