From e6fb55ee74fe1d17739c2055a4f98f3ac469387b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 00:42:32 +0000 Subject: [PATCH 01/19] docs(plan): Wave 1 findings-quality implementation plan (#84/#85/#112) https://claude.ai/code/session_01DPQ8sonp1j85mKqDQefc8T --- .../2026-05-28-wave1-findings-quality.md | 1008 +++++++++++++++++ 1 file changed, 1008 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-28-wave1-findings-quality.md diff --git a/docs/superpowers/plans/2026-05-28-wave1-findings-quality.md b/docs/superpowers/plans/2026-05-28-wave1-findings-quality.md new file mode 100644 index 0000000..cda0a31 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-wave1-findings-quality.md @@ -0,0 +1,1008 @@ +# Wave 1 — Findings Quality Implementation Plan (#84, #85, #112) + +> **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:** Make surfaced findings honest and prioritized — derive severity from cost + structural signals (#85), collapse duplicate AI/local findings while recording every source that detected them (#84), and stop the `CACHE_GUARD`/`BATCH_GUARD` literal-word leak that suppresses real cache/batch findings (#112). + +**Architecture:** Three coupled changes shipped in one PR on `claude/superpowers-plugins-skills-1Yaij`. They share one seam: the `Suggestion` pipeline (the user-facing finding type), not the `FindingNode` intelligence graph the spec idealizes. Severity becomes *derived* at one place — `deriveSeverity()` in `scan-results.ts` — combining the detector's existing structural `riskScore` (a **floor**, preserving C1's calibrated precision) with a confidence-weighted `costImpactUsd` **amplifier** (escalates expensive endpoints, satisfies "different cost → different severity"). Because severity reassignment never adds or removes findings, the per-type precision benchmark gate is unaffected; because cost can only escalate above the structural floor, C1's severity calibration can't regress. Dedupe collapses findings by a structural key (`type | file | endpoint/line-bucket`), unioning `sources` and taking `max()` confidence. `costImpactUsd` stays internal (drives severity + ordering); it is never rendered. + +**Tech Stack:** TypeScript (strict), esbuild, React 18 webview. Tests are plain `node:assert/strict` files compiled by `tsc -p tsconfig.scanner-tests.json` into `dist-test/` and run via the `test:scanner` npm script chain. Benchmark gate: `npm run benchmark` (per-type finding precision/recall against `benchmark/baseline.json`). + +**Spec mapping (important — read before starting):** +- Spec `docs/accuracy/findings.md` C2/C3 references `FindingNode`, `finding-dedupe.ts`, `endpointId`, `lineRange`. The findings users see are `Suggestion[]` (built in `scan-results.ts` + `chat-handler.ts`), and the real AI-vs-local merge point is `chat-handler.ts:mergeAiSuggestions`. **This plan targets the `Suggestion` pipeline.** We mirror the new fields onto `FindingNode` for graph/export parity, but the authoritative behavior (UI filter, "detected by N sources" badge, severity grouping) lives on `Suggestion`. +- Severity model decision (locked with the user): **Hybrid** — structural floor + cost amplifier, `Math.max` of the two tiers. Not the spec's pure `confidence × cost` formula (which would zero-out free-endpoint risk and re-baseline the benchmark). +- `costImpactUsd` source decision (locked): **reuse the existing heuristic** — endpoint `monthlyCost` (from `estimateLocalMonthlyCost` / `LOCAL_PRICING`) × the shared `FREQUENCY_CLASS_MULTIPLIERS`. No new pricing model. + +--- + +## File Structure + +| File | Responsibility | Change | +|---|---|---| +| `src/analysis/types.ts` | Shared `Suggestion` / `Severity` / `SuggestionType` | Add `sources?: string[]`, `costImpactUsd?: number \| null` to `Suggestion` | +| `src/scanner/local-waste-detector.ts` | Regex waste detectors → `LocalWasteFinding` | Add `riskScore` to `LocalWasteFinding`; emit it (the `score` already computed); tighten `CACHE_GUARD`/`BATCH_GUARD` via comment-stripped guard window (#112) | +| `src/scanner/python-waste-detector.ts` | Python waste detector | Emit `riskScore` (1 site) | +| `src/ast/waste/batch-detector.ts` / `cache-detector.ts` / `concurrency-detector.ts` | AST waste detectors | Emit `riskScore` (10 sites) | +| `src/simulator/engine.ts` | Simulator; owns `FREQUENCY_CLASS_MULTIPLIERS` | `export` the multiplier map (single source of truth) | +| `src/scan-results.ts` | Suggestion construction (local + remote merge) | Add `deriveSeverity()`, `computeCostImpact()`, `collapseSuggestions()`; apply at every construction site; set `sources` | +| `src/webview/chat-handler.ts` | AI review → Suggestion merge | Rewrite `mergeAiSuggestions` to collapse-not-drop; derive severity for AI findings; set `sources: ["ai"]` | +| `src/webview/scan-publishing-handler.ts` | Remote-path Suggestion construction | Apply `deriveSeverity`/`computeCostImpact`/`sources` at its 2 construction sites | +| `src/intelligence/types.ts` | `FindingNode` (graph) | Add `sources: string[]`, `costImpactUsd: number \| null` | +| `src/intelligence/builder.ts` | Builds `FindingNode` from `LocalWasteFinding` | Populate new fields | +| `webview/src/types.ts` | Webview mirror of `Suggestion` | Mirror `sources` (already has `confidence`) | +| `webview/src/components/ResultsPage.tsx` | Sidebar results UI | Confidence filter control (#85); "detected by N sources" badge (#84) | +| `src/test/local-waste-detector.test.ts` | existing | Add #112 guard-leak regression cases | +| `src/test/scan-results.test.ts` | **new** | `deriveSeverity`, `computeCostImpact`, `collapseSuggestions` unit tests | +| `src/test/chat-handler-merge.test.ts` | **new** | `mergeAiSuggestions` collapse + sources tests | +| `package.json` | `test:scanner` chain | Register the 2 new test files | + +**Task groups** (logically separable; can be reviewed/committed as units within the one PR): +- **Group A — #112** guard tightening (independent; smallest; do first). +- **Group B — #85** confidence + derived severity + cost impact + confidence filter UI. +- **Group C — #84** dedupe + sources field + sources badge UI (depends on B's `deriveSeverity`/`collapseSuggestions`). +- **Group V** — verification (full suite + benchmark + manual EDH). + +--- + +## Group A — #112: Tighten CACHE_GUARD / BATCH_GUARD + +**Root cause:** `extractCallSites` builds an ±8-line `windowText` (`getWindow`, `local-waste-detector.ts:105`) and the cache/batch detectors suppress findings when `CACHE_GUARD`/`BATCH_GUARD` match anywhere in it (`local-waste-detector.ts:151-152`). The regexes include bare `\bcache\b` and `\bbatch\b`, so a comment like `// TODO: batch these later` or `# we cache elsewhere` silently kills a real finding (the "literal-word leak" noted in `docs/superpowers/plans/2026-05-15-c1-pr4-rate-limit-batch-tightening.md:128`). + +**Fix:** Test guards against a *comment-stripped* copy of the window so prose mentions don't count, while real code mechanisms (`cacheClient.get`, `staleTime:`, `messageBatches`, `.bulk(`) still guard. Keep all other signals on the raw window. + +### Task A1: Comment-stripping helper + apply to guard detection + +**Files:** +- Modify: `src/scanner/local-waste-detector.ts` (add helper near `getWindow` ~line 105; use it in the `cacheGuard`/`batchGuard` assignments ~line 151-152) +- Test: `src/test/local-waste-detector.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/test/local-waste-detector.test.ts`: + +```ts +import { detectLocalWasteFindingsInText } from "../scanner/local-waste-detector"; + +run("#112: bare 'cache' in a comment does not suppress a cache finding", () => { + const text = [ + "// we should cache this someday but do not yet", + "export async function loadUsers(ids) {", + " const out = [];", + " for (const id of ids) {", + " out.push(await fetch(`https://api.example.com/users/${id}`));", + " }", + " return out;", + "}", + ].join("\n"); + const findings = detectLocalWasteFindingsInText("src/users.ts", text); + assert.ok(findings.some((f) => f.type === "cache"), "expected a cache finding despite the comment word"); +}); + +run("#112: bare 'batch' in a comment does not suppress a batch finding", () => { + const text = [ + "// batch these calls in a follow-up PR", + "export async function embedAll(items) {", + " for (const it of items) {", + " await openai.embeddings.create({ input: it });", + " }", + "}", + ].join("\n"); + const findings = detectLocalWasteFindingsInText("src/embed.ts", text); + assert.ok(findings.some((f) => f.type === "batch"), "expected a batch finding despite the comment word"); +}); + +run("#112: a real cache mechanism in code still suppresses the cache finding", () => { + const text = [ + "import { queryClient } from './qc';", + "export async function loadUsers(ids) {", + " const out = [];", + " for (const id of ids) {", + " out.push(await queryClient.fetchQuery(['u', id], () => fetch(`/users/${id}`), { staleTime: 60000 }));", + " }", + " return out;", + "}", + ].join("\n"); + const findings = detectLocalWasteFindingsInText("src/users2.ts", text); + assert.ok(!findings.some((f) => f.type === "cache"), "real staleTime/queryClient guard should still suppress"); +}); +``` + +- [ ] **Step 2: Run to verify the first two fail (leak), third passes** + +Run: `npm run test:scanner 2>&1 | grep -A2 "#112"` +Expected: the two comment-word tests FAIL (finding suppressed by leak); the real-mechanism test PASSES. + +- [ ] **Step 3: Add `stripComments` and a comment-free guard window** + +In `src/scanner/local-waste-detector.ts`, add after `getWindow` (~line 109): + +```ts +// #112: guard regexes (CACHE_GUARD/BATCH_GUARD/...) must not be tripped by the +// bare words "cache"/"batch" appearing in comments. Strip line + block comments +// before testing guards; all other signals keep using the raw window text. +function stripComments(windowText: string): string { + return windowText + .replace(/\/\*[\s\S]*?\*\//g, " ") // block comments + .replace(/\/\/[^\n]*/g, " ") // // line comments + .replace(/(^|\s)#[^\n]*/g, "$1 "); // # line comments (python) +} +``` + +- [ ] **Step 4: Use the stripped window for the guard signals** + +In `extractCallSites` (~line 137-165), the `windowText` is computed once. Add a sibling and switch the five guard signals to it. Replace: + +```ts + const windowText = getWindow(lines, line - 1); + const callKind = classifyCallKind(match); + return { + line, + match, + windowText, +``` +with: +```ts + const windowText = getWindow(lines, line - 1); + const guardWindowText = stripComments(windowText); + const callKind = classifyCallKind(match); + return { + line, + match, + windowText, +``` +and change the guard assignments (same object literal) from testing `windowText` to `guardWindowText`: +```ts + cacheGuard: CACHE_GUARD.test(guardWindowText), + batchGuard: BATCH_GUARD.test(guardWindowText), + concurrencyGuard: CONCURRENCY_GUARD.test(guardWindowText), + retryGuard: RETRY_GUARD.test(guardWindowText), + idempotencyGuard: IDEMPOTENCY_GUARD.test(guardWindowText), + explicitGuard: EXPLICIT_GUARD.test(guardWindowText), +``` +Leave `promiseAll`, `mapFanout`, `arrayFanout`, `hotPath`, `polling`, `retryNearby`, `authLookup`, `configLookup`, `smallBounded` on the raw `windowText`. + +- [ ] **Step 5: Run to verify all three pass** + +Run: `npm run test:scanner 2>&1 | grep -A2 "#112"` +Expected: all three PASS. + +- [ ] **Step 6: Run the benchmark to confirm no precision/recall regression** + +Run: `npm run benchmark` +Expected: `cache` and `batch` finding precision do not drop > 1pp on types with sample ≥ 3; recall does not drop. (Comment-only suppressions were false negatives; recall should hold or improve.) Record the Δ. + +- [ ] **Step 7: Commit** + +```bash +git add src/scanner/local-waste-detector.ts src/test/local-waste-detector.test.ts +git commit -m "fix(scanner): stop CACHE_GUARD/BATCH_GUARD comment-word leak (#112)" +``` + +--- + +## Group B — #85: Confidence everywhere + severity derived from signals + +### Task B1: Export `FREQUENCY_CLASS_MULTIPLIERS` from the simulator (single source of truth) + +**Files:** +- Modify: `src/simulator/engine.ts:16` + +- [ ] **Step 1: Read the current map** + +Run: `sed -n '16,24p' src/simulator/engine.ts` +Expected: `const FREQUENCY_CLASS_MULTIPLIERS: Record = { "unbounded-loop": 10, "polling": 8, "parallel": 3, "bounded-loop": 3, "conditional": 0.5, "cache-guarded": 0.1 };` + +- [ ] **Step 2: Add `export`** + +Change `const FREQUENCY_CLASS_MULTIPLIERS` to `export const FREQUENCY_CLASS_MULTIPLIERS`. No other change. (CLAUDE.md mandates these multipliers stay single-sourced; we reuse, never copy.) + +- [ ] **Step 3: Verify build** + +Run: `npm run build:ext` +Expected: clean (no new errors). + +- [ ] **Step 4: Commit** + +```bash +git add src/simulator/engine.ts +git commit -m "refactor(simulator): export FREQUENCY_CLASS_MULTIPLIERS for reuse (#85)" +``` + +### Task B2: `riskScore` on `LocalWasteFinding` + emit from every detector + +**Files:** +- Modify: `src/scanner/local-waste-detector.ts` (`LocalWasteFinding` interface ~line 34; `makeFinding` ~line 221) +- Modify: `src/scanner/python-waste-detector.ts:170` +- Modify: `src/ast/waste/batch-detector.ts:136,189,250,305` +- Modify: `src/ast/waste/cache-detector.ts:246` +- Modify: `src/ast/waste/concurrency-detector.ts:132,177,224,268` +- Test: covered transitively by B4 (`deriveSeverity` consumes `riskScore`) + +- [ ] **Step 1: Add the field to the interface** + +In `src/scanner/local-waste-detector.ts`, in `interface LocalWasteFinding` (~line 34), add after `confidence: number;`: + +```ts + /** Structural risk score from the detector (pre-cost). Feeds deriveSeverity(); the + * `severity` field is now provisional/structural and overridden at Suggestion build. */ + riskScore: number; +``` + +- [ ] **Step 2: Emit it from the central `makeFinding`** + +In `src/scanner/local-waste-detector.ts`, `makeFinding` (~line 221) takes `score`. Add `riskScore: score,` to the returned object (next to `severity: scoreToSeverity(score)`): + +```ts + return { + id: `local-${type}-${relativePath}:${line}`, + type, + severity: scoreToSeverity(score), + riskScore: score, + confidence, + description, + affectedFile: relativePath, + line, + evidence, + }; +``` + +- [ ] **Step 3: Emit it from python + AST detectors** + +Each site already computes a `score` immediately before `severity: scoreToSeverity(score)`. Add `riskScore: score,` directly beneath each `severity: scoreToSeverity(score),` line at these exact locations: +- `src/scanner/python-waste-detector.ts:170` +- `src/ast/waste/batch-detector.ts:136`, `:189`, `:250`, `:305` +- `src/ast/waste/cache-detector.ts:246` +- `src/ast/waste/concurrency-detector.ts:132`, `:177`, `:224`, `:268` + +For each, the edit is identical in shape: +```ts + severity: scoreToSeverity(score), + riskScore: score, +``` +(If a site's local variable is not named `score`, use whatever value is passed to `scoreToSeverity(...)` on that line.) + +- [ ] **Step 4: Verify build** + +Run: `npm run build:ext` +Expected: clean. If any AST-detector object is typed as `LocalWasteFinding` and a site is missed, tsc flags the missing required `riskScore` — fix that site. + +- [ ] **Step 5: Run scanner tests (no behavior change yet)** + +Run: `npm run test:scanner 2>&1 | tail -5` +Expected: all PASS (these tests assert on `type`/`severity`/`confidence`, which are unchanged here). + +- [ ] **Step 6: Commit** + +```bash +git add src/scanner/local-waste-detector.ts src/scanner/python-waste-detector.ts src/ast/waste/*.ts +git commit -m "feat(scanner): carry structural riskScore on findings (#85)" +``` + +### Task B3: `deriveSeverity` + `computeCostImpact` in scan-results.ts + +**Files:** +- Modify: `src/scan-results.ts` (add near `calculateSavings` ~line 62) +- Test: `src/test/scan-results.test.ts` (**new**) + +- [ ] **Step 1: Write the failing tests** + +Create `src/test/scan-results.test.ts`: + +```ts +import assert from "node:assert/strict"; +import { deriveSeverity, computeCostImpact } from "../scan-results"; + +function run(name: string, fn: () => void): void { + try { + fn(); + console.log(`PASS ${name}`); + } catch (error) { + console.error(`FAIL ${name}`); + throw error; + } +} + +run("computeCostImpact amplifies by frequency class and rounds", () => { + assert.equal(computeCostImpact(10, "polling"), 80); // 10 * 8 + assert.equal(computeCostImpact(10, "unbounded-loop"), 100); // 10 * 10 + assert.equal(computeCostImpact(10, undefined), 10); // no class → 1x + assert.equal(computeCostImpact(0, "polling"), null); // no baseline → null + assert.equal(computeCostImpact(10, "cache-guarded"), 1); // 10 * 0.1 +}); + +run("deriveSeverity keeps the structural floor when cost is ~0 (benchmark-safe)", () => { + // high structural score on a free endpoint stays high (not dropped to low) + assert.equal(deriveSeverity({ riskScore: 6, confidence: 0.9, costImpactUsd: 0 }), "high"); + assert.equal(deriveSeverity({ riskScore: 4, confidence: 0.9, costImpactUsd: null }), "medium"); + assert.equal(deriveSeverity({ riskScore: 1, confidence: 0.9, costImpactUsd: 0 }), "low"); +}); + +run("deriveSeverity escalates a cheap-structure finding on an expensive endpoint", () => { + // low structural score, but big confidence-weighted cost → high + assert.equal(deriveSeverity({ riskScore: 1, confidence: 0.9, costImpactUsd: 200 }), "high"); // 0.9*200=180 + assert.equal(deriveSeverity({ riskScore: 1, confidence: 0.9, costImpactUsd: 20 }), "medium"); // 0.9*20=18 +}); + +run("deriveSeverity: same type, different cost → different severity (#85 acceptance)", () => { + const cheap = deriveSeverity({ riskScore: 2, confidence: 0.8, costImpactUsd: 0 }); + const pricey = deriveSeverity({ riskScore: 2, confidence: 0.8, costImpactUsd: 500 }); + assert.notEqual(cheap, pricey); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npm run test:scanner 2>&1 | grep -A2 "scan-results"` (after Step 4 wiring) — for now: +Run: `tsc -p tsconfig.scanner-tests.json 2>&1 | grep scan-results.test || echo "compiles-but-functions-missing"` +Expected: FAIL — `deriveSeverity`/`computeCostImpact` not exported. + +- [ ] **Step 3: Implement both functions** + +In `src/scan-results.ts`, add the import at the top (next to the other imports): + +```ts +import { FREQUENCY_CLASS_MULTIPLIERS } from "./simulator/engine"; +``` + +Add after `calculateSavings` (~line 62): + +```ts +/** + * #85: monthly $ exposure of a finding (internal severity signal — never shown). + * Heuristic only: endpoint monthlyCost (LOCAL_PRICING/fingerprints) amplified by the + * shared frequency-class multiplier. Returns null when no baseline cost is known. + */ +export function computeCostImpact( + baselineMonthlyCost: number, + frequencyClass: string | undefined +): number | null { + if (!baselineMonthlyCost || baselineMonthlyCost <= 0) return null; + const multiplier = frequencyClass ? (FREQUENCY_CLASS_MULTIPLIERS[frequencyClass] ?? 1) : 1; + return Number((baselineMonthlyCost * multiplier).toFixed(2)); +} + +export interface SeveritySignals { + riskScore: number; // structural score (0..~7) from the detector + confidence: number; // 0..1 + costImpactUsd: number | null; // from computeCostImpact() +} + +/** + * #85: the single place severity is derived. Hybrid model — + * - structural FLOOR (riskScore thresholds 5/3, matching the calibrated scoreToSeverity) + * preserves C1 precision and keeps free-endpoint risks visible; + * - cost AMPLIFIER (confidence × costImpactUsd, thresholds 100/10) can only escalate. + * severity = max(structuralTier, costTier). Never drops below structural → benchmark-safe. + */ +export function deriveSeverity(signals: SeveritySignals): Severity { + const structuralTier = signals.riskScore >= 5 ? 2 : signals.riskScore >= 3 ? 1 : 0; + const costScore = signals.confidence * (signals.costImpactUsd ?? 0); + const costTier = costScore >= 100 ? 2 : costScore >= 10 ? 1 : 0; + const tier = Math.max(structuralTier, costTier); + return tier === 2 ? "high" : tier === 1 ? "medium" : "low"; +} + +/** Map a finding's own/structural severity back to an approximate riskScore. + * Used for AI findings (no structural score) so deriveSeverity works uniformly. */ +export const SEVERITY_TO_RISK_SCORE: Record = { high: 5, medium: 3, low: 1 }; +``` + +Confirm `Severity` is imported in this file — `src/scan-results.ts:1` imports from `./analysis/types`; add `Severity` to that import if not present. + +- [ ] **Step 4: Register the new test file** + +In `package.json` `test:scanner`, append ` && node dist-test/test/scan-results.test.js` to the chain (after `local-waste-detector.test.js` is fine). + +- [ ] **Step 5: Run to verify it passes** + +Run: `npm run test:scanner 2>&1 | grep -A2 "scan-results\|deriveSeverity\|computeCostImpact"` +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/scan-results.ts src/test/scan-results.test.ts package.json +git commit -m "feat(findings): central deriveSeverity + computeCostImpact (#85)" +``` + +### Task B4: Apply derived severity + costImpact at every Suggestion-construction site + +**Files:** +- Modify: `src/scan-results.ts` — `buildAggressiveSuggestions` (~line 150), `mergeLocalWasteFindings` (~line 235) +- Modify: `src/webview/scan-publishing-handler.ts` — 2 construction sites (~line 188, ~line 268) +- Modify: `src/webview/chat-handler.ts` — `mapAiFindingToSuggestion` (~line 431) +- Test: extend `src/test/scan-results.test.ts` + +- [ ] **Step 1: Write the failing integration test** + +Append to `src/test/scan-results.test.ts`: + +```ts +import { buildLocalScanResults } from "../scan-results"; +import type { ApiCallInput } from "../analysis/types"; +import type { LocalWasteFinding } from "../scanner/local-waste-detector"; + +run("buildLocalScanResults: every suggestion has costImpactUsd defined and sources set", () => { + const calls: ApiCallInput[] = [{ + file: "src/a.ts", line: 5, method: "POST", url: "https://api.openai.com/v1/chat/completions", + library: "openai", provider: "openai", frequency: "per-request", frequencyClass: "unbounded-loop", + methodSignature: "chat.completions.create", costModel: "per_token", + }]; + const findings: LocalWasteFinding[] = [{ + id: "f1", type: "n_plus_one", severity: "low", riskScore: 6, confidence: 0.9, + description: "loop-driven openai call", affectedFile: "src/a.ts", line: 5, + evidence: ["Outbound call occurs inside a loop."], + }]; + const { suggestions } = buildLocalScanResults(calls, findings, "proj", "scan"); + assert.ok(suggestions.length >= 1); + for (const s of suggestions) { + assert.ok(s.costImpactUsd !== undefined, "costImpactUsd must be populated (null allowed)"); + assert.ok(Array.isArray(s.sources) && s.sources.length >= 1, "sources must be set"); + assert.ok(["high", "medium", "low"].includes(s.severity)); + } +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npm run test:scanner 2>&1 | grep -A2 "every suggestion"` +Expected: FAIL — `costImpactUsd`/`sources` undefined. + +- [ ] **Step 3: Add fields to the `Suggestion` type** + +In `src/analysis/types.ts`, `interface Suggestion` (~line 89), add: +```ts + sources?: string[]; + costImpactUsd?: number | null; +``` + +- [ ] **Step 4: Wire `buildAggressiveSuggestions` (scan-results.ts ~line 150)** + +Replace the pushed object's `severity`/`estimatedMonthlySavings` lines and add the new fields. The block currently is: +```ts + severity: chooseSeverity(endpoint.status, endpoint.monthlyCost), + affectedEndpoints: [endpoint.id], + affectedFiles: endpoint.files, + estimatedMonthlySavings: calculateSavings(type, "medium", endpoint.monthlyCost), +``` +Replace with: +```ts + ...(() => { + const confidence = confidenceFromEndpointStatus(endpoint); + const costImpactUsd = computeCostImpact(endpoint.monthlyCost, endpoint.frequencyClass); + const severity = deriveSeverity({ + riskScore: SEVERITY_TO_RISK_SCORE[chooseSeverity(endpoint.status, endpoint.monthlyCost)], + confidence, + costImpactUsd, + }); + return { + severity, + affectedEndpoints: [endpoint.id], + affectedFiles: endpoint.files, + estimatedMonthlySavings: calculateSavings(type, severity, endpoint.monthlyCost), + costImpactUsd, + }; + })(), +``` +And remove the later standalone `confidence: confidenceFromEndpointStatus(endpoint),` line if it now duplicates — instead keep a single `confidence` and add `sources: ["remote"]` (these are aggressive suggestions derived from endpoint status on the remote path). Net: the object must end up with exactly one `severity`, `confidence`, `estimatedMonthlySavings`, `costImpactUsd`, and `sources: ["remote"]`. + +> Note for implementer: the IIFE is to keep `confidence`/`costImpactUsd`/`severity` consistent in one spot. If you prefer, hoist three `const`s above the `extras.push({...})` call instead — same result, less nesting. Pick the clearer form. + +- [ ] **Step 5: Wire `mergeLocalWasteFindings` (scan-results.ts ~line 235)** + +The pushed object currently sets `severity: finding.severity` and `estimatedMonthlySavings: calculateSavings(finding.type, finding.severity, baselineCost)`. Just above the `locals.push({...})`, add: +```ts + const frequencyClass = closestEndpoint?.frequencyClass + ?? fileEndpoints.find((ep) => ep.frequencyClass)?.frequencyClass; + const costImpactUsd = computeCostImpact(baselineCost, frequencyClass); + const severity = deriveSeverity({ + riskScore: finding.riskScore, + confidence: finding.confidence, + costImpactUsd, + }); +``` +Then in the pushed object replace `severity: finding.severity,` with `severity,`, replace the `estimatedMonthlySavings` value's severity arg with the derived `severity`, and add `costImpactUsd,` and `sources: ["local-rule"],`. + +- [ ] **Step 6: Wire `scan-publishing-handler.ts` (2 sites ~line 188, ~line 268)** + +These mirror `buildAggressiveSuggestions` (endpoint-status path, ~188) and `mergeLocalWasteFindings` (local-finding path, ~268) — apply the same pattern: import `deriveSeverity, computeCostImpact, SEVERITY_TO_RISK_SCORE` from `../scan-results` (it already imports `classifyPricing, calculateSavings` from there per `scan-publishing-handler.ts:13`), compute `costImpactUsd` + derived `severity`, pass derived severity to `calculateSavings`, and set `sources` (`["remote"]` for the status-derived site, `["local-rule"]` for the local-finding site). `LocalWasteFinding.riskScore` is available on the finding-path site. + +- [ ] **Step 7: Wire `chat-handler.ts:mapAiFindingToSuggestion` (~line 431)** + +Import `deriveSeverity, computeCostImpact, SEVERITY_TO_RISK_SCORE` from `../scan-results` (it already imports `classifyPricing, calculateSavings` per `chat-handler.ts:7`). Just before the `return {`, add: +```ts + const aiFrequencyClass = closestEndpoint?.frequencyClass + ?? fileEndpoints.find((ep) => ep.frequencyClass)?.frequencyClass; + const costImpactUsd = computeCostImpact(monthlyBaseline, aiFrequencyClass); + const severity = deriveSeverity({ + riskScore: SEVERITY_TO_RISK_SCORE[finding.severity], + confidence: finding.confidence, + costImpactUsd, + }); +``` +In the returned object: replace `severity: finding.severity,` with `severity,`; change `estimatedMonthlySavings: calculateSavings(finding.type, finding.severity, monthlyBaseline)` to use `severity`; add `costImpactUsd,` and `sources: ["ai"],`. + +- [ ] **Step 8: Run tests + build** + +Run: `npm run build:ext && npm run test:scanner 2>&1 | grep -A2 "every suggestion"` +Expected: PASS. Full chain green. + +- [ ] **Step 9: Run the benchmark (severity changes must not move per-type precision)** + +Run: `npm run benchmark` +Expected: per-type finding precision/recall Δ = 0 (severity reassignment adds/removes no findings). Record Δ. + +- [ ] **Step 10: Commit** + +```bash +git add src/analysis/types.ts src/scan-results.ts src/webview/scan-publishing-handler.ts src/webview/chat-handler.ts src/test/scan-results.test.ts +git commit -m "feat(findings): derive severity + cost impact at all suggestion sites (#85)" +``` + +### Task B5: FindingNode graph parity + +**Files:** +- Modify: `src/intelligence/types.ts` (`FindingNode` ~line 31) +- Modify: `src/intelligence/builder.ts` (~line 246) +- Modify: `src/scanner/local-waste-detector.ts` (so `LocalWasteFinding` can carry `costImpactUsd` into the graph) — **only if** the builder needs it; otherwise default to `null`. +- Test: `src/intelligence/__tests__/builder.test.ts` (existing) + +- [ ] **Step 1: Add fields to FindingNode** + +In `src/intelligence/types.ts` `interface FindingNode`, add: +```ts + sources: string[]; + costImpactUsd: number | null; +``` + +- [ ] **Step 2: Populate in builder** + +In `src/intelligence/builder.ts` (~line 246), the `findingNode` literal — add: +```ts + sources: ["local-rule"], + costImpactUsd: null, +``` +(The graph is built from `LocalWasteFinding[]` only — all local-rule — so `["local-rule"]` is correct here; `costImpactUsd` is `null` at graph-build time because endpoint cost is resolved later in the Suggestion layer. This keeps the graph honest rather than fabricating a number.) + +- [ ] **Step 3: Build + run intelligence tests** + +Run: `npm run build:ext && node dist-test/intelligence/__tests__/builder.test.js` +(After a `tsc -p tsconfig.scanner-tests.json`.) Expected: PASS — if `builder.test.ts` asserts exact `FindingNode` shape, update its fixtures to include the two new fields. + +- [ ] **Step 4: Commit** + +```bash +git add src/intelligence/types.ts src/intelligence/builder.ts src/intelligence/__tests__/builder.test.ts +git commit -m "feat(intelligence): mirror sources + costImpactUsd on FindingNode (#85/#84)" +``` + +### Task B6: Confidence filter in the sidebar UI + +**Files:** +- Modify: `webview/src/components/ResultsPage.tsx` (Findings/Issues subtab, near `visibleSuggestions` ~line 597 and the type-filter dropdown ~line 687) + +- [ ] **Step 1: Add `minConfidence` state** + +Near the other `useState` hooks in the `ResultsPage` component, add: +```tsx +const [minConfidence, setMinConfidence] = useState(0); // 0 = show all +``` + +- [ ] **Step 2: Apply to `visibleSuggestions`** + +`visibleSuggestions` (~line 597) currently filters by `typeFilter`. Chain the confidence filter (findings without a confidence value are treated as fully confident so they are never hidden): +```tsx +const visibleSuggestions = (typeFilter === "all" ? suggestions : suggestions.filter((s) => s.type === typeFilter)) + .filter((s) => (typeof s.confidence === "number" ? s.confidence : 1) >= minConfidence); +``` + +- [ ] **Step 3: Add the control next to the type-filter dropdown (~line 687)** + +Beside the existing ` setMinConfidence(Number(e.target.value))} + className="eco-select" +> + + + + + +``` +(Reuse whatever className/styling the adjacent type ` + )} {(() => { From d0f5bcbafe3bb3da386ad8239e209e3ad4e8ed5f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 01:55:38 +0000 Subject: [PATCH 12/19] fix(webview): always show confidence filter, even for single-type scans (#85) --- webview/src/components/ResultsPage.tsx | 58 +++++++++++++------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/webview/src/components/ResultsPage.tsx b/webview/src/components/ResultsPage.tsx index 0fb6684..c7b9414 100644 --- a/webview/src/components/ResultsPage.tsx +++ b/webview/src/components/ResultsPage.tsx @@ -675,34 +675,36 @@ export function ResultsPage({ ) : ( <> - {presentTypes.length > 1 && ( -
- Type - - -
- )} +
+ {presentTypes.length > 1 && ( + <> + Type + + + )} + +
{(() => { const paidIssues = visibleSuggestions.filter((s) => s.pricingClass === "paid"); const freeIssues = visibleSuggestions.filter((s) => s.pricingClass === "free"); From 920d76b58ffd4560490c360f3e75110e30068bbc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 02:01:19 +0000 Subject: [PATCH 13/19] feat(findings): collapseSuggestions merges sources + max confidence (#84) https://claude.ai/code/session_01DPQ8sonp1j85mKqDQefc8T --- src/scan-results.ts | 60 +++++++++++++++++++++++++++++++++++ src/test/scan-results.test.ts | 54 ++++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/scan-results.ts b/src/scan-results.ts index 8c43c7e..5fb24e4 100644 --- a/src/scan-results.ts +++ b/src/scan-results.ts @@ -102,6 +102,66 @@ export function deriveSeverity(signals: SeveritySignals): Severity { * deriveSeverity thresholds: high >= 5, medium >= 3, low < 3. */ export const SEVERITY_TO_RISK_SCORE: Record = { high: 5, medium: 3, low: 1 }; +function suggestionMergeKey(s: Suggestion): string { + const file = s.affectedFiles[0] ?? ""; + const endpoint = s.affectedEndpoints[0]; + const locationBucket = endpoint ?? `L${Math.floor((s.targetLine ?? 0) / 5)}`; + return `${s.type}::${file}::${locationBucket}`; +} + +const SOURCE_DESCRIPTION_RANK: Record = { ai: 3, remote: 2, "local-rule": 1 }; + +function sourcesOf(s: Suggestion): string[] { + if (s.sources && s.sources.length > 0) return s.sources; + return s.source ? [s.source] : []; +} + +/** + * #84: collapse findings that describe the same issue at the same location into one. + * - dedupe key: type | file | endpointId (or 5-line bucket when no endpoint) + * - sources: union of both findings' sources + * - confidence: max() + * - description/evidence: from the highest-ranked source (ai > remote > local-rule) + * - severity: recomputed from merged signals (max severity floor, max confidence, max cost) + */ +export function collapseSuggestions(suggestions: Suggestion[]): Suggestion[] { + const byKey = new Map(); + for (const incoming of suggestions) { + const key = suggestionMergeKey(incoming); + const existing = byKey.get(key); + if (!existing) { + byKey.set(key, { ...incoming, sources: [...new Set(sourcesOf(incoming))] }); + continue; + } + + const mergedSources = [...new Set([...sourcesOf(existing), ...sourcesOf(incoming)])]; + const confidence = Math.max(existing.confidence ?? 0, incoming.confidence ?? 0); + const costImpactUsd = Math.max(existing.costImpactUsd ?? 0, incoming.costImpactUsd ?? 0) || null; + const rank = (s: Suggestion) => SOURCE_DESCRIPTION_RANK[s.source ?? ""] ?? 0; + const descSource = rank(incoming) > rank(existing) ? incoming : existing; + const riskScore = SEVERITY_TO_RISK_SCORE[ + ([existing.severity, incoming.severity].includes("high") + ? "high" + : [existing.severity, incoming.severity].includes("medium") + ? "medium" + : "low") as Severity + ]; + const severity = deriveSeverity({ riskScore, confidence, costImpactUsd }); + + byKey.set(key, { + ...existing, + sources: mergedSources, + confidence, + costImpactUsd, + description: descSource.description, + evidence: descSource.evidence ?? existing.evidence, + severity, + estimatedMonthlySavings: Math.max(existing.estimatedMonthlySavings, incoming.estimatedMonthlySavings), + }); + } + return [...byKey.values()]; +} + const FREQUENCY_SEVERITY: Record = { polling: 6, "unbounded-loop": 5, diff --git a/src/test/scan-results.test.ts b/src/test/scan-results.test.ts index 5f52f4b..fb081c0 100644 --- a/src/test/scan-results.test.ts +++ b/src/test/scan-results.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; -import { deriveSeverity, computeCostImpact, buildRemoteScanResults } from "../scan-results"; +import { deriveSeverity, computeCostImpact, buildRemoteScanResults, collapseSuggestions } from "../scan-results"; import { buildLocalScanResults } from "../scan-results"; import type { ApiCallInput, EndpointRecord, ScanSummary } from "../analysis/types"; +import type { Suggestion } from "../analysis/types"; import type { LocalWasteFinding } from "../scanner/local-waste-detector"; function run(name: string, fn: () => void): void { @@ -78,3 +79,54 @@ run("buildRemoteScanResults: aggressive suggestion from endpoint status is label assert.ok(aggressive!.costImpactUsd !== undefined); assert.ok(["high", "medium", "low"].includes(aggressive!.severity)); }); + +function sug(p: Partial): Suggestion { + return { + id: p.id ?? "x", projectId: "p", scanId: "s", + type: p.type ?? "n_plus_one", severity: p.severity ?? "low", + affectedEndpoints: p.affectedEndpoints ?? [], affectedFiles: p.affectedFiles ?? ["src/a.ts"], + targetLine: p.targetLine, estimatedMonthlySavings: p.estimatedMonthlySavings ?? 0, + description: p.description ?? "d", codeFix: "", source: p.source, confidence: p.confidence, + evidence: p.evidence ?? [], sources: p.sources, costImpactUsd: p.costImpactUsd ?? null, + }; +} + +run("collapseSuggestions: same type+endpoint collapses, unions sources, max confidence, prefers AI desc", () => { + const local = sug({ id: "l", type: "n_plus_one", affectedEndpoints: ["ep1"], confidence: 0.7, + description: "loop call", source: "local-rule", sources: ["local-rule"], severity: "medium" }); + const ai = sug({ id: "a", type: "n_plus_one", affectedEndpoints: ["ep1"], confidence: 0.9, + description: "N+1: this fetch runs once per user in the loop", source: "ai", sources: ["ai"], severity: "low" }); + const out = collapseSuggestions([local, ai]); + assert.equal(out.length, 1); + assert.deepEqual([...out[0].sources!].sort(), ["ai", "local-rule"]); + assert.equal(out[0].confidence, 0.9); + assert.match(out[0].description, /N\+1/); +}); + +run("collapseSuggestions: different endpoints do not collapse", () => { + const a = sug({ id: "a", affectedEndpoints: ["ep1"] }); + const b = sug({ id: "b", affectedEndpoints: ["ep2"] }); + assert.equal(collapseSuggestions([a, b]).length, 2); +}); + +run("collapseSuggestions: no endpoint -> 5-line bucket collapses nearby same-type findings", () => { + const a = sug({ id: "a", affectedEndpoints: [], targetLine: 12, sources: ["local-rule"] }); + const b = sug({ id: "b", affectedEndpoints: [], targetLine: 14, sources: ["ai"], description: "richer", source: "ai" }); + const out = collapseSuggestions([a, b]); + assert.equal(out.length, 1); + assert.deepEqual([...out[0].sources!].sort(), ["ai", "local-rule"]); +}); + +run("buildLocalScanResults collapses a local finding that duplicates an aggressive one", () => { + const calls: ApiCallInput[] = [{ + file: "src/b.ts", line: 8, method: "GET", url: "https://api.openai.com/v1/models", + library: "openai", provider: "openai", frequency: "per-request", frequencyClass: "polling", + }]; + const findings: LocalWasteFinding[] = [{ + id: "f1", type: "n_plus_one", severity: "medium", riskScore: 4, confidence: 0.8, + description: "dup", affectedFile: "src/b.ts", line: 8, evidence: [], + }]; + const { suggestions } = buildLocalScanResults(calls, findings, "p", "s"); + 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"); +}); From af5c9784ca4e678ee0991277b68b4ac4432f8fa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 02:03:16 +0000 Subject: [PATCH 14/19] feat(ai-review): collapse AI+local findings, record sources (#84) https://claude.ai/code/session_01DPQ8sonp1j85mKqDQefc8T --- package.json | 2 +- src/test/chat-handler-merge.test.ts | 28 +++++++++++++++ src/webview/chat-handler.ts | 54 ++++------------------------- 3 files changed, 36 insertions(+), 48 deletions(-) create mode 100644 src/test/chat-handler-merge.test.ts diff --git a/package.json b/package.json index 1e1c27d..8131789 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", + "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", "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/test/chat-handler-merge.test.ts b/src/test/chat-handler-merge.test.ts new file mode 100644 index 0000000..44e551d --- /dev/null +++ b/src/test/chat-handler-merge.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { collapseSuggestions } from "../scan-results"; +import type { Suggestion } from "../analysis/types"; + +function run(name: string, fn: () => void): void { + try { fn(); console.log(`PASS ${name}`); } + catch (e) { console.error(`FAIL ${name}`); throw e; } +} + +function sug(p: Partial): Suggestion { + return { + id: p.id ?? "x", projectId: "p", scanId: "s", type: p.type ?? "n_plus_one", + severity: p.severity ?? "low", affectedEndpoints: p.affectedEndpoints ?? ["ep1"], + affectedFiles: p.affectedFiles ?? ["src/a.ts"], targetLine: p.targetLine ?? 10, + estimatedMonthlySavings: 0, description: p.description ?? "d", codeFix: "", + source: p.source, confidence: p.confidence ?? 0.5, evidence: [], sources: p.sources, + costImpactUsd: p.costImpactUsd ?? null, + }; +} + +run("#84: AI + local on the same endpoint collapse to one with both sources", () => { + const local = sug({ id: "l", source: "local-rule", sources: ["local-rule"], confidence: 0.7 }); + const ai = sug({ id: "a", source: "ai", sources: ["ai"], confidence: 0.9, description: "richer ai desc" }); + const out = collapseSuggestions([local, ai]); + assert.equal(out.length, 1); + assert.deepEqual([...out[0].sources!].sort(), ["ai", "local-rule"]); + assert.equal(out[0].confidence, 0.9); +}); diff --git a/src/webview/chat-handler.ts b/src/webview/chat-handler.ts index ffd12df..16a3163 100644 --- a/src/webview/chat-handler.ts +++ b/src/webview/chat-handler.ts @@ -4,7 +4,7 @@ import type { PersistedKeyValidationSnapshot } from "../key-management"; import type { EndpointRecord, Suggestion, ScanSummary } from "../analysis/types"; import { buildSystemPrompt } from "../chat/prompts"; import { readWorkspaceFileExcerpt } from "../scanner/workspace-scanner"; -import { classifyPricing, calculateSavings, deriveSeverity, computeCostImpact, SEVERITY_TO_RISK_SCORE } from "../scan-results"; +import { classifyPricing, calculateSavings, deriveSeverity, computeCostImpact, SEVERITY_TO_RISK_SCORE, collapseSuggestions } from "../scan-results"; import { buildKeyFingerprint } from "../key-management"; import { buildProviderOptions, @@ -20,10 +20,6 @@ import { newLocalScanId } from "../scan-id"; // Local copies of small pure helpers used here. Avoid importing from // webview-provider.ts to prevent a circular import. Originals remain in // webview-provider.ts where non-chat code also uses them. -function normalizeDescription(value: string): string { - return value.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim(); -} - function trimText(value: string, max: number): string { return value.length <= max ? value : `${value.slice(0, max)}...`; } @@ -459,48 +455,12 @@ export class ChatHandler { } private mergeAiSuggestions(existing: Suggestion[], incoming: Suggestion[]): { merged: Suggestion[]; added: number; filtered: number } { - const existingByKey = new Set(); - const deterministicOverlap = new Map(); - - for (const suggestion of existing) { - const file = suggestion.affectedFiles[0] ?? ""; - const line = suggestion.targetLine ?? 0; - const key = `${suggestion.type}|${file}|${line}|${normalizeDescription(suggestion.description)}`; - existingByKey.add(key); - if (file && suggestion.source !== "ai") { - const overlapKey = `${suggestion.type}|${file}`; - const lines = deterministicOverlap.get(overlapKey) ?? []; - lines.push(line); - deterministicOverlap.set(overlapKey, lines); - } - } - - const aiByKey = new Set(); - const accepted: Suggestion[] = []; - let filtered = 0; - - for (const suggestion of incoming) { - const file = suggestion.affectedFiles[0] ?? ""; - const line = suggestion.targetLine ?? 0; - const key = `${suggestion.type}|${file}|${line}|${normalizeDescription(suggestion.description)}`; - if (existingByKey.has(key) || aiByKey.has(key)) { - filtered += 1; - continue; - } - - const overlapKey = `${suggestion.type}|${file}`; - const overlapLines = deterministicOverlap.get(overlapKey) ?? []; - const nearDeterministic = overlapLines.some((knownLine) => Math.abs(knownLine - line) <= 5); - if (nearDeterministic) { - filtered += 1; - continue; - } - - aiByKey.add(key); - accepted.push(suggestion); - } - - return { merged: [...existing, ...accepted], added: accepted.length, filtered }; + const collapsed = collapseSuggestions([...existing, ...incoming]); + // "added" = net new distinct findings the AI pass contributed; + // "filtered" = AI findings that collapsed into an existing one. + const added = collapsed.length - existing.length; + const filtered = incoming.length - added; + return { merged: collapsed, added: Math.max(0, added), filtered: Math.max(0, filtered) }; } public async handleRunAiReview() { From c4fb4203b4cddcf19ea70f8a513b5acbfd1f344b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 02:04:46 +0000 Subject: [PATCH 15/19] feat(findings): collapse duplicate suggestions on scan paths (#84) https://claude.ai/code/session_01DPQ8sonp1j85mKqDQefc8T --- src/scan-results.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/scan-results.ts b/src/scan-results.ts index 5fb24e4..51aeafa 100644 --- a/src/scan-results.ts +++ b/src/scan-results.ts @@ -598,7 +598,9 @@ export function buildLocalScanResults( scanId: string ): FinalScanResults { const endpoints = mergeRemoteAndLocalEndpoints([], apiCalls, projectId, scanId); - const suggestions = mergeLocalWasteFindings([], localWasteFindings, endpoints, 0, projectId, scanId); + const suggestions = collapseSuggestions( + mergeLocalWasteFindings([], localWasteFindings, endpoints, 0, projectId, scanId) + ); return { endpoints, suggestions, @@ -621,13 +623,15 @@ export function buildRemoteScanResults( scanId: string ): FinalScanResults { const endpoints = mergeRemoteAndLocalEndpoints(remoteEndpoints, apiCalls, projectId, scanId); - const suggestions = mergeLocalWasteFindings( - buildAggressiveSuggestions(endpoints, tagRemoteSuggestions(remoteSuggestions)), - localWasteFindings, - endpoints, - remoteSummary.totalMonthlyCost, - projectId, - scanId + const suggestions = collapseSuggestions( + mergeLocalWasteFindings( + buildAggressiveSuggestions(endpoints, tagRemoteSuggestions(remoteSuggestions)), + localWasteFindings, + endpoints, + remoteSummary.totalMonthlyCost, + projectId, + scanId + ) ); return { endpoints, From d6dd2df4e8d1aa2e13a32c4e50515df2e6f17494 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 02:18:00 +0000 Subject: [PATCH 16/19] docs(findings): document collapseSuggestions endpoint/line-bucket semantics (#84) --- src/scan-results.ts | 7 +++++++ src/webview/chat-handler.ts | 3 +++ 2 files changed, 10 insertions(+) diff --git a/src/scan-results.ts b/src/scan-results.ts index 51aeafa..8cf2478 100644 --- a/src/scan-results.ts +++ b/src/scan-results.ts @@ -105,6 +105,9 @@ export const SEVERITY_TO_RISK_SCORE: Record = { high: 5, mediu function suggestionMergeKey(s: Suggestion): string { const file = s.affectedFiles[0] ?? ""; const endpoint = s.affectedEndpoints[0]; + // When no endpoint or line is known, all same-type findings in the file share + // bucket L0 and collapse into one — acceptable for this file-level fallback; + // real call sites always carry a line number. const locationBucket = endpoint ?? `L${Math.floor((s.targetLine ?? 0) / 5)}`; return `${s.type}::${file}::${locationBucket}`; } @@ -123,6 +126,10 @@ function sourcesOf(s: Suggestion): string[] { * - confidence: max() * - description/evidence: from the highest-ranked source (ai > remote > local-rule) * - severity: recomputed from merged signals (max severity floor, max confidence, max cost) + * + * NOTE: two findings on the same endpoint always collapse regardless of line distance — + * the endpoint ID is the canonical dedup anchor. This is intentional: local + remote/AI + * detectors describing the same endpoint should produce one merged finding. */ export function collapseSuggestions(suggestions: Suggestion[]): Suggestion[] { const byKey = new Map(); diff --git a/src/webview/chat-handler.ts b/src/webview/chat-handler.ts index 16a3163..3ef560a 100644 --- a/src/webview/chat-handler.ts +++ b/src/webview/chat-handler.ts @@ -458,6 +458,9 @@ export class ChatHandler { const collapsed = collapseSuggestions([...existing, ...incoming]); // "added" = net new distinct findings the AI pass contributed; // "filtered" = AI findings that collapsed into an existing one. + // These counts are cosmetic (logged + sent in aiReviewComplete). If `existing` + // somehow held duplicates (shouldn't post-scan-collapse), `added` could go + // negative — the Math.max(0, ...) clamps below keep the reported numbers sane. const added = collapsed.length - existing.length; const filtered = incoming.length - added; return { merged: collapsed, added: Math.max(0, added), filtered: Math.max(0, filtered) }; From fdb28fc285cdf992f6b104daf5950b6662f70228 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 02:19:02 +0000 Subject: [PATCH 17/19] feat(webview): 'detected by N sources' badge (#84) https://claude.ai/code/session_01DPQ8sonp1j85mKqDQefc8T --- webview/src/components/ResultsPage.tsx | 18 ++++++++++++++++++ webview/src/types.ts | 2 ++ 2 files changed, 20 insertions(+) diff --git a/webview/src/components/ResultsPage.tsx b/webview/src/components/ResultsPage.tsx index c7b9414..0d24921 100644 --- a/webview/src/components/ResultsPage.tsx +++ b/webview/src/components/ResultsPage.tsx @@ -248,6 +248,24 @@ function SuggestionCard({ {provider} )} + {suggestion.sources && suggestion.sources.length > 1 && ( + + detected by {suggestion.sources.length} sources + + )} {suggestion.estimatedMonthlySavings > 0 && ( diff --git a/webview/src/types.ts b/webview/src/types.ts index 581dd00..614184c 100644 --- a/webview/src/types.ts +++ b/webview/src/types.ts @@ -66,6 +66,8 @@ export interface Suggestion { evidence?: string[]; reviewedAt?: string; pricingClass?: "paid" | "free" | "unknown"; + sources?: string[]; + costImpactUsd?: number | null; } export interface ScanSummary { From 4adbc8cd965fb7fb2e57bd33c67c9721e6957371 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 02:24:20 +0000 Subject: [PATCH 18/19] docs(accuracy): mark Wave 1 findings-quality complete (#84/#85/#112) --- docs/accuracy/findings.md | 39 ++++++++++++++++++------------ docs/superpowers/plans/PROGRESS.md | 3 ++- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/docs/accuracy/findings.md b/docs/accuracy/findings.md index 88769f0..f45c5f6 100644 --- a/docs/accuracy/findings.md +++ b/docs/accuracy/findings.md @@ -102,15 +102,19 @@ The `lineRange` bucket means "lines 12–18" matches "lines 14–16" (one is a s 4. When duplicates collapse, preserve the higher-confidence finding and append the other's source as metadata so the UI can show "detected by 2 sources." ### Acceptance criteria -- [ ] Two findings of the same `type` on the same `endpointId` collapse to one. -- [ ] The collapsed finding lists both sources (e.g., `sources: ["local-rule", "remote"]`). -- [ ] Confidence of the collapsed finding is `max()` of inputs, not averaged. -- [ ] Description picked: prefer the AI's (richer wording), fall back to local-rule's if no AI version exists. +- [x] Two findings of the same `type` on the same `endpointId` collapse to one. +- [x] The collapsed finding lists both sources (e.g., `sources: ["local-rule", "remote"]`). +- [x] Confidence of the collapsed finding is `max()` of inputs, not averaged. +- [x] Description picked: prefer the AI's (richer wording), fall back to local-rule's if no AI version exists. + +### Landed (2026-05-28) +Implemented on the `Suggestion` pipeline, not the idealized `FindingNode` graph: the real AI-vs-local merge point is `chat-handler.ts:mergeAiSuggestions`, and the user-facing findings are `Suggestion[]`. A single `collapseSuggestions()` in `scan-results.ts` dedupes by `type :: file :: (endpointId | floor(line/5) bucket)`, unions `sources`, takes `max()` confidence/cost/savings, and prefers the highest-ranked source's description (ai > remote > local-rule). `mergeAiSuggestions` is now a thin wrapper over it (collapse, not drop); both scan builders apply it once. `sources` is mirrored onto `FindingNode` for graph parity. UI: a "detected by N sources" badge renders when `sources.length > 1` (interactive EDH check pending). Full benchmark Δ +0.00pp on all metrics. ### Files -- `src/intelligence/finding-dedupe.ts` -- `src/webview-provider.ts` (merge point) -- `src/intelligence/types.ts` (add `sources: string[]` to FindingNode) +- `src/scan-results.ts` (`collapseSuggestions`, applied at both builders) +- `src/webview/chat-handler.ts` (`mergeAiSuggestions` merge point) +- `src/analysis/types.ts` (`sources?`/`costImpactUsd?` on `Suggestion`) + `src/intelligence/types.ts` (`sources`/`costImpactUsd` on `FindingNode`) +- `webview/src/components/ResultsPage.tsx` + `webview/src/types.ts` (sources badge) ### Depends on - B3 (stable endpoint IDs for the merge key). @@ -162,17 +166,20 @@ This means an unbounded loop on a free endpoint (cost impact ≈ $0) automatical 4. Add UI controls: filter by confidence, sort by cost impact. ### Acceptance criteria -- [ ] Every `FindingNode` has `confidence` and `costImpactUsd` populated. -- [ ] Severity is computed at one place from those signals. -- [ ] Filtering by confidence in the webview hides low-confidence findings. -- [ ] Two findings of the same type on different-cost endpoints get different severities. -- [ ] Existing severity-based UI grouping still works (groups derived from new computation). +- [x] Every finding carries `confidence` and `costImpactUsd` (populated; `FindingNode` mirrors both, `costImpactUsd` null at graph-build time and resolved in the `Suggestion` layer where endpoint cost is known). +- [x] Severity is computed at one place from those signals (`deriveSeverity()` in `scan-results.ts`). +- [~] Filtering by confidence in the webview hides low-confidence findings (code-complete + builds/typechecks; interactive EDH check pending). +- [x] Two findings of the same type on different-cost endpoints get different severities (unit-tested). +- [~] Existing severity-based UI grouping still works (groups derive from the filtered set; interactive EDH check pending). + +### Landed (2026-05-28) — Hybrid severity model +Severity is **not** the spec's literal `confidence × costImpactUsd`. That pure formula would zero-out free-endpoint risk and re-baseline the calibrated detectors. Instead `deriveSeverity({riskScore, confidence, costImpactUsd})` is a **hybrid floor + amplifier**: the C1-calibrated structural `riskScore` sets a floor (thresholds 5/3, matching `scoreToSeverity`) and a confidence-weighted cost term (thresholds 100/10) can only escalate via `Math.max`. This keeps free-endpoint structural risks visible, lets expensive endpoints rise, and is benchmark-safe — severity reassignment never adds or drops a finding, so per-type precision/recall is untouched (full benchmark Δ +0.00pp). `costImpactUsd` reuses the existing heuristic (`monthlyCost × FREQUENCY_CLASS_MULTIPLIERS`) and stays internal — it drives severity/ordering but is never rendered. Detectors carry a `riskScore`; severity is overridden at every `Suggestion`-construction site (incl. AI findings, whose self-reported severity maps to a riskScore floor). ### Files -- `src/scanner/local-waste-detector.ts` (and python variant + ast/waste/*) -- `src/intelligence/types.ts` (FindingNode shape) -- `webview/src/components/ResultsPage.tsx` (filter UI) -- `src/webview-provider.ts` (severity computation) +- `src/scan-results.ts` (`deriveSeverity`, `computeCostImpact`, `SEVERITY_TO_RISK_SCORE`; applied at all construction sites) +- `src/scanner/local-waste-detector.ts` (+ python variant + `ast/waste/*`): emit `riskScore` +- `src/analysis/types.ts` / `src/intelligence/types.ts` (`costImpactUsd`) +- `webview/src/components/ResultsPage.tsx` + `webview/src/types.ts` (confidence filter) ### Depends on - C1 (per-detector confidence values come from the calibration). diff --git a/docs/superpowers/plans/PROGRESS.md b/docs/superpowers/plans/PROGRESS.md index 4ee44ac..a52c614 100644 --- a/docs/superpowers/plans/PROGRESS.md +++ b/docs/superpowers/plans/PROGRESS.md @@ -145,7 +145,7 @@ Post-foundation work organized into 10 waves + 2 standalones, tracked via `wave/ | — | Standalone #52 | #52 | ⬜ | Dashboard theming — can defer indefinitely. | | **5** | Housekeeping (accuracy) | #118, #119 | ⬜ | Cheapest accuracy work. Both in-repo (benchmark runner + `benchmark.yml`). | | **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). | -| **1** | Findings quality (accuracy) | #84, #85, #112 | ⬜ | User-facing. | +| **1** | Findings quality (accuracy) | #84, #85, #112 | 🟡 | Code complete + reviewed; full benchmark Δ +0.00pp. Awaits manual EDH check of the two UI bits (confidence filter, sources badge). | | **2** | Traceability (accuracy) | #81, #113 | ⬜ | Corpus expansion + dual locations. | | **4** | Recall recovery (accuracy) | #117 | ⬜ | Risky — depended on Wave 3 (#116), now unblocked. | @@ -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-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). - 2026-05-27 — **Wave 3 shipped (PR #126)** — #114/#115/#116 closed. Subagent-driven across 2 parallel worktree tracks; gates green (test:scanner, build:ext, benchmark Δ +0.00pp). Follow-ups filed: #127 (detection threading) + #128 (dashboard badge). **Tracker reconciliation:** also corrected stale statuses — Waves 7 (PR #122), 8 (PR #123), and 10 (PR #124) had merged earlier but were never marked 🟢 here. All platform waves (6–10) now confirmed shipped; remaining work is accuracy waves 5→1→2→4 + standalones. - 2026-05-21 — **Wave 7 PR opened (#122).** Closes #92 + #93 as design-resolved via labeling, not reconciliation. 7 commits on `wave7/cost-simulator-labeling` (worktree `../extension-wave7`): shared `EstimateDisclaimer` component, render on Simulate tab, code comments on `LOCAL_PRICING` + `FREQUENCY_CLASS_MULTIPLIERS`, CLAUDE.md "Cost numbers: heuristic vs authoritative" section. All gates green (build, 4/4 tests, D1 Δ +0.00pp). Spec + plan committed to main. Wave 8 (#46/#94/#100) is next per severity order. - 2026-05-15 — **Wave 9 closed docs-only via PR #120**, **Wave 6 shipped** (api#40 + extension#121). Both detailed in `memory/wave9_local_mode_resolution.md` and `memory/wave6_status.md`. From 23821fe2e266844f11b24c984bed3747c7ec958f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 02:33:36 +0000 Subject: [PATCH 19/19] fix(sidebar): apply collapseSuggestions on the VSCode scan path (#84) --- src/webview/scan-publishing-handler.ts | 30 +++++++++++++++----------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/webview/scan-publishing-handler.ts b/src/webview/scan-publishing-handler.ts index d0b62f4..2b6b4a8 100644 --- a/src/webview/scan-publishing-handler.ts +++ b/src/webview/scan-publishing-handler.ts @@ -10,7 +10,7 @@ import type { HostMessage, KeyServiceId } from "../messages"; import type { ApiCallInput, EndpointRecord, Suggestion, ScanSummary } from "../analysis/types"; import { classifyEndpointScope, detectEndpointProvider } from "../scanner/endpoint-classification"; import { computeEndpointId } from "../scanner/endpoint-id"; -import { classifyPricing, calculateSavings, deriveSeverity, computeCostImpact, SEVERITY_TO_RISK_SCORE } from "../scan-results"; +import { classifyPricing, calculateSavings, deriveSeverity, computeCostImpact, SEVERITY_TO_RISK_SCORE, collapseSuggestions } from "../scan-results"; import { buildSnapshot } from "../intelligence/builder"; import { scoreSnapshot } from "../intelligence/scorer"; import { estimateLocalMonthlyCost } from "../intelligence/cost-utils"; @@ -610,12 +610,14 @@ export class ScanPublishingHandler { const publishLocalOnlyResults = (localProjectId: string, localScanId: string) => { const endpoints = mergeRemoteAndLocalEndpoints([], apiCalls, localProjectId, localScanId); const aggressiveSuggestions = buildAggressiveSuggestions(endpoints, [], localWasteFindings); - const mergedSuggestions = mergeLocalWasteFindings( - aggressiveSuggestions, - localWasteFindings, - endpoints, - localProjectId, - localScanId + const mergedSuggestions = collapseSuggestions( + mergeLocalWasteFindings( + aggressiveSuggestions, + localWasteFindings, + endpoints, + localProjectId, + localScanId + ) ); const summary: ScanSummary = { totalEndpoints: endpoints.length, @@ -753,12 +755,14 @@ export class ScanPublishingHandler { this.ctx.setLastEndpoints(externalEndpoints); void this.ctx.pruneSavedScenariosAgainst(externalEndpoints); const aggressiveSuggestions = buildAggressiveSuggestions(endpoints, taggedRemoteSuggestions, localWasteFindings); - const mergedSuggestions = mergeLocalWasteFindings( - aggressiveSuggestions, - localWasteFindings, - endpoints, - projectId, - scanResult.scanId + const mergedSuggestions = collapseSuggestions( + mergeLocalWasteFindings( + aggressiveSuggestions, + localWasteFindings, + endpoints, + projectId, + scanResult.scanId + ) ); this.ctx.setLastSuggestions(mergedSuggestions); this.ctx.setLastSummary({ ...scanResult.summary, totalEndpoints: externalEndpoints.length });