From 329720d7dc123ecd38a6c6d17e4e509e55a7fc34 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Tue, 12 May 2026 00:38:18 -0400 Subject: [PATCH 01/14] fix(callers): await compressClusters in webview, CLI, extension activator, and tests --- src/cli/scan.ts | 4 +- src/extension.ts | 2 +- .../__tests__/compression.test.ts | 84 +++++++++++-------- src/intelligence/__tests__/export.test.ts | 44 ++++++---- src/webview-provider.ts | 2 +- 5 files changed, 84 insertions(+), 52 deletions(-) diff --git a/src/cli/scan.ts b/src/cli/scan.ts index e32f4b1..8e6bc93 100644 --- a/src/cli/scan.ts +++ b/src/cli/scan.ts @@ -152,7 +152,7 @@ async function runContextFormat(options: CliOptions, access: Awaited void): void { - try { - fn(); - console.log(`PASS ${name}`); - } catch (error) { - console.error(`FAIL ${name}`); - throw error; - } +const pendingTests: Array<() => Promise> = []; + +function run(name: string, fn: () => void | Promise): void { + pendingTests.push(async () => { + try { + await fn(); + console.log(`PASS ${name}`); + } catch (error) { + console.error(`FAIL ${name}`); + throw error; + } + }); } -function withTempWorkspace(files: Record, fn: (workspaceDir: string) => void): void { +async function withTempWorkspace( + files: Record, + fn: (workspaceDir: string) => void | Promise +): Promise { const originalCwd = process.cwd(); const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "compression-test-")); @@ -29,15 +36,15 @@ function withTempWorkspace(files: Record, fn: (workspaceDir: str fs.writeFileSync(absolutePath, content, "utf8"); } process.chdir(workspaceDir); - fn(workspaceDir); + await fn(workspaceDir); } finally { process.chdir(originalCwd); fs.rmSync(workspaceDir, { recursive: true, force: true }); } } -run("compressClusters returns compact summaries, normalized findings, and bounded snippets", () => { - withTempWorkspace( +run("compressClusters returns compact summaries, normalized findings, and bounded snippets", async () => { + await withTempWorkspace( { "src/chat/loop.ts": [ "export async function loop(items) {", @@ -63,7 +70,7 @@ run("compressClusters returns compact summaries, normalized findings, and bounde "}, 1000);", ].join("\n"), }, - () => { + async () => { const snapshot = buildSnapshot({ apiCalls: [ { @@ -136,7 +143,7 @@ run("compressClusters returns compact summaries, normalized findings, and bounde }); const clusters = buildReviewClusters(scoreRepoIntelligence(snapshot)); - const compressed = compressClusters(clusters, snapshot); + const compressed = await compressClusters(clusters, snapshot); assert.ok(compressed.length >= 1); const loopCluster = compressed.find((cluster) => cluster.primarySummary.filePath === "src/chat/loop.ts"); @@ -179,8 +186,8 @@ run("compressClusters returns compact summaries, normalized findings, and bounde ); }); -run("compressClusters dedupes repeated same-file findings and collapses repeated titles in export output", () => { - withTempWorkspace( +run("compressClusters dedupes repeated same-file findings and collapses repeated titles in export output", async () => { + await withTempWorkspace( { "src/chat/cache.ts": [ "export async function loadModel() {", @@ -189,7 +196,7 @@ run("compressClusters dedupes repeated same-file findings and collapses repeated "}", ].join("\n"), }, - () => { + async () => { const snapshot = buildSnapshot({ apiCalls: [ { @@ -243,7 +250,7 @@ run("compressClusters dedupes repeated same-file findings and collapses repeated ], }); - const compressed = compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); + const compressed = await compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); const cluster = compressed.find((entry) => entry.primarySummary.filePath === "src/chat/cache.ts"); assert.ok(cluster); assert.equal(cluster?.findings.filter((finding) => finding.title === "Missing caching").length, 1); @@ -252,8 +259,8 @@ run("compressClusters dedupes repeated same-file findings and collapses repeated ); }); -run("compressClusters uses softer evidence language for weak test-derived files", () => { - withTempWorkspace( +run("compressClusters uses softer evidence language for weak test-derived files", async () => { + await withTempWorkspace( { "src/test/providers.test.ts": [ "for (const provider of ALL_PROVIDERS) {", @@ -261,7 +268,7 @@ run("compressClusters uses softer evidence language for weak test-derived files" "}", ].join("\n"), }, - () => { + async () => { const snapshot = buildSnapshot({ apiCalls: [ { @@ -276,7 +283,7 @@ run("compressClusters uses softer evidence language for weak test-derived files" findings: [], }); - const compressed = compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); + const compressed = await compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); const testCluster = compressed.find((cluster) => cluster.primarySummary.filePath === "src/test/providers.test.ts"); assert.ok(testCluster); assert.ok(testCluster?.primarySummary.description.startsWith("This test file")); @@ -289,8 +296,8 @@ run("compressClusters uses softer evidence language for weak test-derived files" ); }); -run("compressClusters uses neutral snippet labels for test helper cache-like code", () => { - withTempWorkspace( +run("compressClusters uses neutral snippet labels for test helper cache-like code", async () => { + await withTempWorkspace( { "src/test/providers.test.ts": [ "function findProvider(id) {", @@ -298,7 +305,7 @@ run("compressClusters uses neutral snippet labels for test helper cache-like cod "}", ].join("\n"), }, - () => { + async () => { const snapshot = buildSnapshot({ apiCalls: [ { @@ -324,7 +331,7 @@ run("compressClusters uses neutral snippet labels for test helper cache-like cod ], }); - const compressed = compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); + const compressed = await compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); const testCluster = compressed.find((cluster) => cluster.primarySummary.filePath === "src/test/providers.test.ts"); assert.ok(testCluster); assert.ok(testCluster?.snippets.some((snippet) => snippet.label === "Relevant test helper context")); @@ -333,8 +340,8 @@ run("compressClusters uses neutral snippet labels for test helper cache-like cod ); }); -run("compressClusters handles files with only findings, null providers, and missing snippet files", () => { - withTempWorkspace( +run("compressClusters handles files with only findings, null providers, and missing snippet files", async () => { + await withTempWorkspace( { "src/shared/a.ts": [ "export async function a() {", @@ -352,7 +359,7 @@ run("compressClusters handles files with only findings, null providers, and miss "}", ].join("\n"), }, - () => { + async () => { const snapshot = buildSnapshot({ apiCalls: [ { @@ -404,7 +411,7 @@ run("compressClusters handles files with only findings, null providers, and miss ], }); - const compressed = compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); + const compressed = await compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); assert.ok(compressed.length >= 1); const sharedCluster = compressed.find((cluster) => cluster.primarySummary.filePath.startsWith("src/shared/")); @@ -425,8 +432,8 @@ run("compressClusters handles files with only findings, null providers, and miss ); }); -run("compressClusters uses snapshot.repoRoot instead of process.cwd() for snippet reads", () => { - withTempWorkspace( +run("compressClusters uses snapshot.repoRoot instead of process.cwd() for snippet reads", async () => { + await withTempWorkspace( { "src/chat/loop.ts": [ "export async function loop(items) {", @@ -436,7 +443,7 @@ run("compressClusters uses snapshot.repoRoot instead of process.cwd() for snippe "}", ].join("\n"), }, - (workspaceDir) => { + async (workspaceDir) => { const snapshot = buildSnapshot({ repoRoot: workspaceDir, apiCalls: [ @@ -456,7 +463,7 @@ run("compressClusters uses snapshot.repoRoot instead of process.cwd() for snippe process.chdir(os.tmpdir()); try { - const compressed = compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); + const compressed = await compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); const loopCluster = compressed.find((cluster) => cluster.primarySummary.filePath === "src/chat/loop.ts"); assert.ok(loopCluster); assert.ok((loopCluster?.snippets.length ?? 0) >= 1); @@ -466,3 +473,12 @@ run("compressClusters uses snapshot.repoRoot instead of process.cwd() for snippe } ); }); + +(async () => { + for (const test of pendingTests) { + await test(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/intelligence/__tests__/export.test.ts b/src/intelligence/__tests__/export.test.ts index c3ee28b..0a1d029 100644 --- a/src/intelligence/__tests__/export.test.ts +++ b/src/intelligence/__tests__/export.test.ts @@ -10,17 +10,24 @@ import { buildExportContext, formatAsJSON, formatAsMarkdown } from "../export"; import { scoreRepoIntelligence } from "../scorer"; import type { CompressedCluster, ExportedContext } from "../types"; -function run(name: string, fn: () => void): void { - try { - fn(); - console.log(`PASS ${name}`); - } catch (error) { - console.error(`FAIL ${name}`); - throw error; - } +const pendingTests: Array<() => Promise> = []; + +function run(name: string, fn: () => void | Promise): void { + pendingTests.push(async () => { + try { + await fn(); + console.log(`PASS ${name}`); + } catch (error) { + console.error(`FAIL ${name}`); + throw error; + } + }); } -function withTempWorkspace(files: Record, fn: (workspaceDir: string) => void): void { +async function withTempWorkspace( + files: Record, + fn: (workspaceDir: string) => void | Promise +): Promise { const originalCwd = process.cwd(); const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "export-test-")); @@ -31,15 +38,15 @@ function withTempWorkspace(files: Record, fn: (workspaceDir: str fs.writeFileSync(absolutePath, content, "utf8"); } process.chdir(workspaceDir); - fn(workspaceDir); + await fn(workspaceDir); } finally { process.chdir(originalCwd); fs.rmSync(workspaceDir, { recursive: true, force: true }); } } -run("buildExportContext assembles meta, top files, key risks, and passes clusters through unchanged", () => { - withTempWorkspace( +run("buildExportContext assembles meta, top files, key risks, and passes clusters through unchanged", async () => { + await withTempWorkspace( { "src/chat/loop.ts": [ "export async function loop(items) {", @@ -59,7 +66,7 @@ run("buildExportContext assembles meta, top files, key risks, and passes cluster "}, 1000);", ].join("\n"), }, - (workspaceDir) => { + async (workspaceDir) => { const snapshot = buildSnapshot({ apiCalls: [ { @@ -112,7 +119,7 @@ run("buildExportContext assembles meta, top files, key risks, and passes cluster }); const scored = scoreRepoIntelligence(snapshot); - const clusters = compressClusters(buildReviewClusters(scored), snapshot); + const clusters = await compressClusters(buildReviewClusters(scored), snapshot); const context = buildExportContext(clusters, snapshot, scored, { generatorVersion: "0.1.0" }); assert.equal(context.meta.projectName, path.basename(workspaceDir)); @@ -838,3 +845,12 @@ run("buildExportContext prefers non-generated non-tooling top files when runtime assert.ok(!context.summary.topFiles.some((file) => file.filePath === "dashboard-dist/assets/index-abc123.js")); assert.ok(!context.summary.topFiles.some((file) => file.filePath === "src/scanner/patterns/provider-gemini.ts")); }); + +(async () => { + for (const test of pendingTests) { + await test(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/webview-provider.ts b/src/webview-provider.ts index b1b7e72..c86f610 100644 --- a/src/webview-provider.ts +++ b/src/webview-provider.ts @@ -1151,7 +1151,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { }); const scored = scoreSnapshot(snapshot); const clusters = buildReviewClusters(scored); - const compressed = compressClusters(clusters, snapshot); + const compressed = await compressClusters(clusters, snapshot); const generatorVersion = String(this.context.extension.packageJSON.version ?? ""); const exportContext = buildExportContext(compressed, snapshot, scored, { generatorVersion: generatorVersion || undefined, From 5bd3d4024df839cf125877555d80afcdda9cb7bd Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Tue, 12 May 2026 00:40:38 -0400 Subject: [PATCH 02/14] fix(webview): route intelligence debug output through OutputChannel --- src/webview-provider.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/webview-provider.ts b/src/webview-provider.ts index c86f610..3d32af3 100644 --- a/src/webview-provider.ts +++ b/src/webview-provider.ts @@ -1211,8 +1211,9 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { totalFilesScanned, }); const scored = scoreSnapshot(snapshot); + const ch = getOutputChannel(); for (const file of scored.scoredFiles.slice(0, 5)) { - console.log( + ch.appendLine( `[intelligence] ${file.filePath} | priority=${file.scores.aiReviewPriority.toFixed(2)} | ` + `importance=${file.scores.importance.toFixed(2)} | ` + `costLeak=${file.scores.costLeak.toFixed(2)} | ` + From 7bed29ef0a42b8f98098e2fa14f5ea68c5f23fc8 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Tue, 12 May 2026 00:43:46 -0400 Subject: [PATCH 03/14] fix(webview): serialize scenario persistence through a single-flight queue --- src/webview-provider.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/webview-provider.ts b/src/webview-provider.ts index 3d32af3..29325e6 100644 --- a/src/webview-provider.ts +++ b/src/webview-provider.ts @@ -673,6 +673,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { // Simulator state (persisted across sessions) private savedScenarios: import("./simulator/types").SavedScenario[] = []; + private scenarioPersistQueue: Promise = Promise.resolve(); // Chat state private chatHistory: ChatMessage[] = []; @@ -1180,6 +1181,14 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { } } + private async persistScenarios(next: import("./simulator/types").SavedScenario[]): Promise { + this.savedScenarios = next; + this.scenarioPersistQueue = this.scenarioPersistQueue + .catch(() => {}) + .then(() => this.context.globalState.update("eco.simulatorScenarios", next)); + await this.scenarioPersistQueue; + } + private async handleStartScan() { await vscode.commands.executeCommand("setContext", "recost.scanning", true); try { From b833636ccc73f5bea15d58d3846a08285fe7b11f Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Tue, 12 May 2026 00:44:36 -0400 Subject: [PATCH 04/14] fix(webview): use recost.simulatorScenarios storage key to match the loader The persistScenarios helper introduced in 7bed29e wrote to eco.simulatorScenarios but the constructor loads from recost.simulatorScenarios, so writes through the queue would never round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/webview-provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webview-provider.ts b/src/webview-provider.ts index 29325e6..5b2b288 100644 --- a/src/webview-provider.ts +++ b/src/webview-provider.ts @@ -1185,7 +1185,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { this.savedScenarios = next; this.scenarioPersistQueue = this.scenarioPersistQueue .catch(() => {}) - .then(() => this.context.globalState.update("eco.simulatorScenarios", next)); + .then(() => this.context.globalState.update("recost.simulatorScenarios", next)); await this.scenarioPersistQueue; } From 09302a58838c1daec25da37d6adcb30c3c8f5722 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Tue, 12 May 2026 00:45:20 -0400 Subject: [PATCH 05/14] fix(webview): surface debug-export failures to the user --- src/webview-provider.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/webview-provider.ts b/src/webview-provider.ts index 5b2b288..a2f3dfa 100644 --- a/src/webview-provider.ts +++ b/src/webview-provider.ts @@ -739,6 +739,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { } catch (error) { const message = error instanceof Error ? error.message : String(error); this.outputChannel.appendLine(`[debug-export] Failed to write ${exportPath}: ${message}`); + vscode.window.showErrorMessage(`ReCost: failed to export scan results: ${message}`); } } From 6ab8f3c69ae4ca61379930fc613fef9ca870b014 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Tue, 12 May 2026 00:54:05 -0400 Subject: [PATCH 06/14] refactor(webview): extract ChatHandler from webview-provider --- src/webview-provider.ts | 628 ++------------------------------ src/webview/chat-handler.ts | 706 ++++++++++++++++++++++++++++++++++++ 2 files changed, 734 insertions(+), 600 deletions(-) create mode 100644 src/webview/chat-handler.ts diff --git a/src/webview-provider.ts b/src/webview-provider.ts index a2f3dfa..62ef83e 100644 --- a/src/webview-provider.ts +++ b/src/webview-provider.ts @@ -5,22 +5,13 @@ import * as path from "path"; import { scanWorkspace, detectLocalWastePatterns, - readWorkspaceFileExcerpt, countScopedWorkspaceFiles, getWorkspaceScanFiles, } from "./scanner/workspace-scanner"; import { createProject, findProjectByName, submitScan, getAllEndpoints, getAllSuggestions, validateProjectId } from "./api-client"; -import { buildSystemPrompt } from "./chat/prompts"; import { - buildProviderOptions, - executeChat, - findModelMetadata, getDefaultChatSelection, - getProviderAdapter, - ChatAdapterError, type ChatProviderId, - type NormalizedChatMessage, - type NormalizedChatRequest, } from "./chat"; import type { WebviewMessage, HostMessage, KeyServiceId, KeyStatusSummary, ProjectIdStatusSummary } from "./messages"; import type { ApiCallInput, EndpointRecord, Suggestion, ScanSummary } from "./analysis/types"; @@ -47,47 +38,7 @@ import { } from "./key-management"; import { resolveWorkspaceFilePathSafely } from "./workspace-file-access"; import { getOutputChannel } from "./output"; - -interface ChatMessage { - role: "user" | "assistant"; - content: string; -} - -interface AiFinding { - type: Suggestion["type"]; - severity: Suggestion["severity"]; - confidence: number; - description: string; - affectedFile: string; - targetLine?: number; - evidence: string[]; -} - -interface AiPromptFile { - path: string; - snippet: string; - startLine: number; - endLine: number; -} - -interface AiReviewInput { - files: AiPromptFile[]; - summary: ScanSummary | null; - endpoints: Array<{ - id: string; - method: string; - url: string; - status: EndpointRecord["status"]; - monthlyCost: number; - files: string[]; - }>; - suggestions: Array<{ - type: Suggestion["type"]; - severity: Suggestion["severity"]; - description: string; - affectedFiles: string[]; - }>; -} +import { ChatHandler } from "./webview/chat-handler"; async function resolveWorkspaceFileSafely( workspaceFolder: vscode.WorkspaceFolder, @@ -676,7 +627,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { private scenarioPersistQueue: Promise = Promise.resolve(); // Chat state - private chatHistory: ChatMessage[] = []; + private readonly chatHandler: ChatHandler; private readonly outputChannel: vscode.OutputChannel; private readonly keyValidationState = new Map(); private readonly projectIdCheckingState = new Set(); @@ -749,6 +700,25 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { this.context.subscriptions.push(this.outputChannel); this.savedScenarios = (this.context.globalState.get("recost.simulatorScenarios")) ?? []; this.restoreKeyValidationState(); + this.chatHandler = new ChatHandler({ + postMessage: (m) => this.postMessage(m), + outputChannel: this.outputChannel, + context: this.context, + getSelectedChatProvider: () => this.getSelectedChatProvider(), + getSelectedChatModel: () => this.getSelectedChatModel(), + getLastEndpoints: () => this.lastEndpoints, + getLastSuggestions: () => this.lastSuggestions, + getLastSummary: () => this.lastSummary, + getProjectId: () => this.projectId, + setLastSuggestions: (suggestions) => { this.lastSuggestions = suggestions; }, + setLastSummary: (summary) => { this.lastSummary = summary; }, + getKeyServiceIdForProvider: (providerId) => this.getKeyServiceIdForProvider(providerId), + getStoredProviderApiKey: (providerId) => this.getStoredProviderApiKey(providerId), + setValidationState: (serviceId, snapshot) => this.setValidationState(serviceId, snapshot), + clearValidationState: (serviceId) => this.clearValidationState(serviceId), + sendKeyStatusUpdate: (serviceId, focusServiceId) => this.sendKeyStatusUpdate(serviceId, focusServiceId), + openKeys: (focusServiceId) => this.openKeys(focusServiceId), + }); } resolveWebviewView( @@ -817,13 +787,8 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { return readStoredSecret(getKeyService(serviceId), this.context.secrets); } - private async sendChatConfig(providerId = this.getSelectedChatProvider(), model = this.getSelectedChatModel()) { - this.postMessage({ - type: "chatConfig", - providers: buildProviderOptions(), - selectedProvider: providerId, - selectedModel: model, - }); + private sendChatConfig(providerId?: ChatProviderId, model?: string) { + return this.chatHandler.sendChatConfig(providerId, model); } public postMessage(message: HostMessage) { @@ -1193,7 +1158,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { private async handleStartScan() { await vscode.commands.executeCommand("setContext", "recost.scanning", true); try { - this.chatHistory = []; + this.chatHandler.resetHistory(); const scannedFiles = (await getWorkspaceScanFiles()).map((file) => file.relativePath); const apiCalls = await scanWorkspace((progress) => { @@ -1462,454 +1427,8 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { } } - private logAiReview(message: string) { - const stamp = new Date().toISOString(); - this.outputChannel.appendLine(`[${stamp}] ${message}`); - } - - private getAiReviewConfig() { - const config = vscode.workspace.getConfiguration("recost"); - return { - enabled: config.get("aiReview.enabled", true), - minConfidence: config.get("aiReview.minConfidence", 0.7), - maxFiles: config.get("aiReview.maxFiles", 25), - maxCharsPerFile: config.get("aiReview.maxCharsPerFile", 6000), - fallbackModel: config.get("aiReview.model", "gpt-4.1-mini"), - }; - } - - private resolveAiReviewSelection(fallbackModel: string): { providerId: ChatProviderId; model: string } { - const providerId = this.getSelectedChatProvider(); - const provider = getProviderAdapter(providerId); - const selectedModel = this.getSelectedChatModel(); - if (provider.models.some((entry) => entry.id === selectedModel)) { - return { providerId, model: selectedModel }; - } - if (providerId === "openai" && provider.models.some((entry) => entry.id === fallbackModel)) { - return { providerId, model: fallbackModel }; - } - return { providerId, model: provider.models[0]?.id ?? fallbackModel }; - } - - private async executeAiReviewRequest(request: NormalizedChatRequest) { - const modelMeta = findModelMetadata(request.provider, request.model); - const requiresFallback = request.provider === "openai" && modelMeta?.reasoning; - if (!requiresFallback) { - return executeChat({ request, secrets: this.context.secrets }); - } - try { - return await executeChat({ request: { ...request, stream: false }, secrets: this.context.secrets }); - } catch (error) { - const chatError = error as ChatAdapterError; - if (chatError?.status !== 400) { - throw error; - } - return executeChat({ - request: { - ...request, - stream: false, - messages: request.messages.filter((message) => message.role !== "system"), - }, - secrets: this.context.secrets, - }); - } - } - - private redactSensitiveText(value: string): string { - return value - .replace(/sk-[a-zA-Z0-9]{16,}/g, "[REDACTED_OPENAI_KEY]") - .replace(/(api[_-]?key|token|secret)\s*[:=]\s*["'`][^"'`\n]{8,}["'`]/gi, "$1=[REDACTED]") - .replace(/(authorization\s*:\s*["'`]bearer\s+)[^"'`\n]+/gi, "$1[REDACTED]"); - } - - private async buildAiReviewInputContext(maxFiles: number, maxCharsPerFile: number): Promise { - const scoreByFile = new Map(); - const lineHintByFile = new Map(); - const severityScore: Record = { high: 4, medium: 2, low: 1 }; - - for (const suggestion of this.lastSuggestions) { - for (const file of suggestion.affectedFiles) { - scoreByFile.set(file, (scoreByFile.get(file) ?? 0) + severityScore[suggestion.severity]); - if (suggestion.targetLine && !lineHintByFile.has(file)) { - lineHintByFile.set(file, suggestion.targetLine); - } - } - } - - for (const endpoint of this.lastEndpoints) { - const endpointScore = - endpoint.status === "n_plus_one_risk" || endpoint.status === "redundant" ? 4 : - endpoint.status === "rate_limit_risk" ? 3 : - endpoint.status === "cacheable" || endpoint.status === "batchable" ? 2 : - 1; - for (const callSite of endpoint.callSites) { - scoreByFile.set(callSite.file, (scoreByFile.get(callSite.file) ?? 0) + endpointScore); - if (!lineHintByFile.has(callSite.file)) { - lineHintByFile.set(callSite.file, callSite.line); - } - } - } - - const rankedFiles = [...scoreByFile.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, Math.max(1, maxFiles)) - .map(([file]) => file); - - const files: AiPromptFile[] = []; - for (let i = 0; i < rankedFiles.length; i += 1) { - const file = rankedFiles[i]; - this.postMessage({ - type: "aiReviewProgress", - stage: `Preparing context (${i + 1}/${rankedFiles.length})`, - current: i + 1, - total: rankedFiles.length, - }); - - const excerpt = await readWorkspaceFileExcerpt(file, { - centerLine: lineHintByFile.get(file), - contextLines: 40, - maxChars: maxCharsPerFile, - }); - if (!excerpt || !excerpt.content.trim()) continue; - files.push({ - path: file, - startLine: excerpt.startLine, - endLine: excerpt.endLine, - snippet: this.redactSensitiveText(excerpt.content), - }); - } - - return { - files, - summary: this.lastSummary, - endpoints: this.lastEndpoints.map((endpoint) => ({ - id: endpoint.id, - method: endpoint.method, - url: endpoint.url, - status: endpoint.status, - monthlyCost: endpoint.monthlyCost, - files: endpoint.files, - })), - suggestions: this.lastSuggestions.map((suggestion) => ({ - type: suggestion.type, - severity: suggestion.severity, - description: suggestion.description, - affectedFiles: suggestion.affectedFiles, - })), - }; - } - - private buildAiReviewPrompt(input: AiReviewInput): string { - const contract = { - findings: [ - { - type: "cache | batch | redundancy | n_plus_one | rate_limit", - severity: "high | medium | low", - confidence: 0.0, - description: "short, specific finding", - affectedFile: "path/to/file.ts", - targetLine: 1, - evidence: ["short reason 1", "short reason 2"], - }, - ], - }; - - return [ - "You are an API efficiency code reviewer.", - "Analyze only the provided snippets and existing scan context.", - "Return ONLY valid JSON with no markdown and no extra text.", - "Do not invent files. Use only provided file paths.", - "Prefer high precision over recall.", - `JSON contract: ${JSON.stringify(contract)}`, - `Context: ${JSON.stringify(input)}`, - ].join("\n"); - } - - private parseAndValidateAiFindings( - raw: string, - validFiles: Set, - minConfidence: number - ): { accepted: AiFinding[]; filtered: number } { - const allowedTypes = new Set(["cache", "batch", "redundancy", "n_plus_one", "rate_limit"]); - const allowedSeverity = new Set(["high", "medium", "low"]); - - const tryParse = (value: string): unknown => { - try { - return JSON.parse(value); - } catch { - return null; - } - }; - - let parsed = tryParse(raw); - if (!parsed) { - const fenced = raw.match(/```json\s*([\s\S]*?)\s*```/i) ?? raw.match(/```\s*([\s\S]*?)\s*```/i); - if (fenced) { - parsed = tryParse(fenced[1]); - } - } - if (!parsed) { - const start = raw.indexOf("{"); - const end = raw.lastIndexOf("}"); - if (start >= 0 && end > start) { - parsed = tryParse(raw.slice(start, end + 1)); - } - } - - const findings = (parsed as { findings?: unknown })?.findings; - if (!Array.isArray(findings)) { - return { accepted: [], filtered: 0 }; - } - - const accepted: AiFinding[] = []; - let filtered = 0; - for (const entry of findings) { - if (accepted.length >= 50) { - filtered += 1; - continue; - } - if (!entry || typeof entry !== "object") { - filtered += 1; - continue; - } - const candidate = entry as Record; - const type = candidate.type; - const severity = candidate.severity; - const affectedFile = candidate.affectedFile; - const description = candidate.description; - if ( - typeof type !== "string" || - typeof severity !== "string" || - typeof affectedFile !== "string" || - typeof description !== "string" - ) { - filtered += 1; - continue; - } - if (!allowedTypes.has(type as Suggestion["type"]) || !allowedSeverity.has(severity as Suggestion["severity"])) { - filtered += 1; - continue; - } - if (!validFiles.has(affectedFile)) { - filtered += 1; - continue; - } - - const confidence = clampConfidence(Number(candidate.confidence)); - if (confidence < minConfidence) { - filtered += 1; - continue; - } - - const rawLine = Number(candidate.targetLine); - const targetLine = Number.isFinite(rawLine) && rawLine > 0 ? Math.floor(rawLine) : undefined; - const evidence = Array.isArray(candidate.evidence) - ? candidate.evidence.filter((item): item is string => typeof item === "string").slice(0, 4).map((item) => trimText(item, 180)) - : []; - - accepted.push({ - type: type as Suggestion["type"], - severity: severity as Suggestion["severity"], - confidence, - description: trimText(description.trim(), 500), - affectedFile, - targetLine, - evidence, - }); - } - - return { accepted, filtered }; - } - - private mapAiFindingToSuggestion(finding: AiFinding, index: number): Suggestion { - const scanId = this.lastEndpoints[0]?.scanId ?? this.projectId ?? `local-${Date.now()}`; - const projectId = this.lastEndpoints[0]?.projectId ?? this.projectId ?? "local"; - const fileEndpoints = this.lastEndpoints.filter((ep) => ep.files.includes(finding.affectedFile)); - const related = fileEndpoints.map((endpoint) => endpoint.id); - const closestEndpoint = findClosestEndpoint( - { affectedFile: finding.affectedFile, line: finding.targetLine }, - fileEndpoints - ); - const directCost = closestEndpoint?.monthlyCost ?? 0; - const fileMonthlyCost = fileEndpoints.reduce((sum, ep) => sum + ep.monthlyCost, 0); - const monthlyBaseline = directCost > 0 - ? directCost - : fileMonthlyCost > 0 - ? fileMonthlyCost - : 0; // unknown — no savings estimate - - return { - id: `ai-${Date.now()}-${index + 1}`, - projectId, - scanId, - type: finding.type, - severity: finding.severity, - affectedEndpoints: related, - affectedFiles: [finding.affectedFile], - targetLine: finding.targetLine, - estimatedMonthlySavings: calculateSavings(finding.type, finding.severity, monthlyBaseline), - description: finding.description, - codeFix: "", - source: "ai", - confidence: finding.confidence, - evidence: finding.evidence, - reviewedAt: new Date().toISOString(), - pricingClass: classifyPricing(fileEndpoints.map((ep) => ep.costModel)), - }; - } - - 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 }; - } - - private async handleRunAiReview() { - const { enabled, minConfidence, maxFiles, maxCharsPerFile, fallbackModel } = this.getAiReviewConfig(); - if (!enabled) { - this.postMessage({ type: "aiReviewError", message: "AI review is disabled in settings." }); - return; - } - if (this.lastEndpoints.length === 0 && this.lastSuggestions.length === 0) { - this.postMessage({ type: "aiReviewError", message: "Run a scan before AI review." }); - return; - } - - try { - const { providerId, model } = this.resolveAiReviewSelection(fallbackModel); - const provider = getProviderAdapter(providerId); - this.postMessage({ type: "aiReviewProgress", stage: "Collecting files..." }); - const input = await this.buildAiReviewInputContext(maxFiles, maxCharsPerFile); - if (input.files.length === 0) { - this.postMessage({ type: "aiReviewComplete", added: 0, filtered: 0 }); - return; - } - - this.postMessage({ type: "aiReviewProgress", stage: `Calling ${provider.displayName}...` }); - const response = await this.executeAiReviewRequest({ - provider: providerId, - model, - temperature: providerId === "recost" ? undefined : 0.1, - stream: false, - messages: [ - { - role: "system", - content: "You are a strict API efficiency reviewer. Return only JSON.", - }, - { - role: "user", - content: this.buildAiReviewPrompt(input), - }, - ], - }); - - const raw = response.content ?? ""; - this.postMessage({ type: "aiReviewProgress", stage: "Validating findings..." }); - const validFiles = new Set(input.files.map((file) => file.path)); - const { accepted, filtered } = this.parseAndValidateAiFindings(raw, validFiles, minConfidence); - const aiSuggestions = accepted.map((finding, index) => this.mapAiFindingToSuggestion(finding, index)); - const merged = this.mergeAiSuggestions(this.lastSuggestions, aiSuggestions); - - this.lastSuggestions = merged.merged; - const summary = this.lastSummary ?? { - totalEndpoints: this.lastEndpoints.length, - totalCallsPerDay: this.lastEndpoints.reduce((sum, endpoint) => sum + endpoint.callsPerDay, 0), - totalMonthlyCost: this.lastEndpoints.reduce((sum, endpoint) => sum + endpoint.monthlyCost, 0), - highRiskCount: 0, - }; - const updatedSummary: ScanSummary = { - ...summary, - totalEndpoints: Math.max(summary.totalEndpoints, this.lastEndpoints.length), - highRiskCount: this.lastSuggestions.filter((suggestion) => suggestion.severity === "high").length, - }; - this.lastSummary = updatedSummary; - - this.logAiReview( - `provider=${providerId} model=${model} files=${input.files.length} raw=${accepted.length + filtered} accepted=${merged.added} filtered=${filtered + merged.filtered}` - ); - - this.postMessage({ - type: "scanResults", - endpoints: this.lastEndpoints, - suggestions: this.lastSuggestions, - summary: updatedSummary, - }); - this.postMessage({ type: "aiReviewComplete", added: merged.added, filtered: filtered + merged.filtered }); - } catch (err: unknown) { - const chatError = err as ChatAdapterError; - const { providerId } = this.resolveAiReviewSelection(fallbackModel); - const serviceId = this.getKeyServiceIdForProvider(providerId); - if (chatError?.code === "bad_auth") { - if (serviceId) { - const apiKey = await this.getStoredProviderApiKey(providerId); - if (apiKey) { - await this.setValidationState(serviceId, { - state: "invalid", - message: chatError.message, - lastCheckedAt: new Date().toISOString(), - keyFingerprint: buildKeyFingerprint(apiKey), - }); - } else { - await this.clearValidationState(serviceId); - } - await this.sendKeyStatusUpdate(serviceId, serviceId); - } - this.openKeys(serviceId); - this.postMessage({ type: "aiReviewError", message: chatError.message }); - return; - } - if (chatError?.code === "missing_api_key") { - if (serviceId) { - await this.clearValidationState(serviceId); - await this.sendKeyStatusUpdate(serviceId, serviceId); - } - this.openKeys(serviceId); - this.postMessage({ type: "aiReviewError", message: chatError.message }); - return; - } - const message = err instanceof Error ? err.message : "AI review failed"; - this.logAiReview(`error=${message}`); - this.postMessage({ type: "aiReviewError", message }); - } + private handleRunAiReview() { + return this.chatHandler.handleRunAiReview(); } private async getOrCreateProject(rcApiKey?: string): Promise { @@ -1982,99 +1501,8 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { ); } - private buildMessages(text: string, limitContext = false): NormalizedChatMessage[] { - const suggestions = limitContext ? this.lastSuggestions.slice(0, 5) : this.lastSuggestions; - const endpoints = limitContext ? this.lastEndpoints.slice(0, 8) : this.lastEndpoints; - return [ - { role: "system", content: buildSystemPrompt(this.lastSummary, suggestions, endpoints) }, - ...this.chatHistory, - { role: "user", content: text }, - ]; - } - - private async executeProviderRequest(request: NormalizedChatRequest) { - return executeChat({ - request, - secrets: this.context.secrets, - onChunk: async (chunk) => { - if (chunk.delta) { - this.postMessage({ type: "chatStreaming", chunk: chunk.delta }); - } - }, - }); - } - - private async handleChat(text: string, providerId: string, model: string) { - const provider = getProviderAdapter(providerId); - const modelMeta = findModelMetadata(providerId, model); - const messages = this.buildMessages(text, providerId === "recost"); - const baseRequest: NormalizedChatRequest = { - provider: providerId, - model, - messages, - temperature: providerId === "recost" ? undefined : 0.7, - stream: provider.supportsStreaming && (modelMeta?.supportsStreaming ?? provider.supportsStreaming), - }; - - try { - let response; - const requiresFallback = providerId === "openai" && modelMeta?.reasoning; - if (requiresFallback) { - try { - response = await this.executeProviderRequest({ ...baseRequest, stream: false }); - } catch (error) { - const chatError = error as ChatAdapterError; - if (chatError?.status === 400) { - response = await this.executeProviderRequest({ - ...baseRequest, - stream: false, - messages: messages.filter((message) => message.role !== "system"), - }); - } else { - throw error; - } - } - } else { - response = await this.executeProviderRequest(baseRequest); - } - - this.chatHistory.push({ role: "user", content: text }); - this.chatHistory.push({ role: "assistant", content: response.content }); - this.postMessage({ type: "chatDone", fullContent: response.content }); - } catch (error) { - const chatError = error as ChatAdapterError; - const serviceId = this.getKeyServiceIdForProvider(providerId); - if (chatError?.code === "bad_auth") { - if (serviceId) { - const apiKey = await this.getStoredProviderApiKey(providerId); - if (apiKey) { - await this.setValidationState(serviceId, { - state: "invalid", - message: chatError.message, - lastCheckedAt: new Date().toISOString(), - keyFingerprint: buildKeyFingerprint(apiKey), - }); - } else { - await this.clearValidationState(serviceId); - } - await this.sendKeyStatusUpdate(serviceId, serviceId); - } - this.openKeys(serviceId); - this.postMessage({ type: "chatError", message: chatError.message }); - return; - } - if (chatError?.code === "missing_api_key") { - if (serviceId) { - await this.clearValidationState(serviceId); - await this.sendKeyStatusUpdate(serviceId, serviceId); - } - this.openKeys(serviceId); - this.postMessage({ type: "chatError", message: chatError.message }); - return; - } - const message = error instanceof Error ? error.message : "Network error. Check your connection."; - this.postMessage({ type: "chatError", message }); - } + private handleChat(text: string, provider: string, model: string) { + return this.chatHandler.handleChat(text, provider, model); } private async handleApplyFix(code: string, file: string, line?: number) { diff --git a/src/webview/chat-handler.ts b/src/webview/chat-handler.ts new file mode 100644 index 0000000..7e4f602 --- /dev/null +++ b/src/webview/chat-handler.ts @@ -0,0 +1,706 @@ +import * as vscode from "vscode"; +import type { HostMessage, KeyServiceId } from "../messages"; +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 } from "../scan-results"; +import { buildKeyFingerprint } from "../key-management"; +import { + buildProviderOptions, + executeChat, + findModelMetadata, + getProviderAdapter, + ChatAdapterError, + type ChatProviderId, + type NormalizedChatMessage, + type NormalizedChatRequest, +} from "../chat"; +// 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)}...`; +} + +function clampConfidence(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value < 0) return 0; + if (value > 1) return 1; + return value; +} + +const PROXIMITY_THRESHOLD_LINES = 25; + +function findClosestEndpoint( + finding: { affectedFile: string; line?: number }, + fileEndpoints: EndpointRecord[] +): EndpointRecord | null { + if (!finding.line || fileEndpoints.length === 0) return null; + + let closest: EndpointRecord | null = null; + let closestDistance = Infinity; + + for (const ep of fileEndpoints) { + // Skip route-def endpoints — they have monthlyCost === 0 and would + // produce misleading $0 savings estimates + if (ep.monthlyCost === 0 && ep.callSites.every(s => s.library === "route-def")) continue; + + for (const site of ep.callSites) { + if (site.file !== finding.affectedFile) continue; + const distance = Math.abs(site.line - finding.line); + if (distance < closestDistance) { + closestDistance = distance; + closest = ep; + } + } + } + + return closestDistance <= PROXIMITY_THRESHOLD_LINES ? closest : null; +} + +export interface ChatMessage { + role: "user" | "assistant"; + content: string; +} + +interface AiFinding { + type: Suggestion["type"]; + severity: Suggestion["severity"]; + confidence: number; + description: string; + affectedFile: string; + targetLine?: number; + evidence: string[]; +} + +interface AiPromptFile { + path: string; + snippet: string; + startLine: number; + endLine: number; +} + +interface AiReviewInput { + files: AiPromptFile[]; + summary: ScanSummary | null; + endpoints: Array<{ + id: string; + method: string; + url: string; + status: EndpointRecord["status"]; + monthlyCost: number; + files: string[]; + }>; + suggestions: Array<{ + type: Suggestion["type"]; + severity: Suggestion["severity"]; + description: string; + affectedFiles: string[]; + }>; +} + +export interface ChatHandlerContext { + postMessage(message: HostMessage): void; + outputChannel: vscode.OutputChannel; + context: vscode.ExtensionContext; + getSelectedChatProvider(): ChatProviderId; + getSelectedChatModel(): string; + // Scan state read accessors + getLastEndpoints(): EndpointRecord[]; + getLastSuggestions(): Suggestion[]; + getLastSummary(): ScanSummary | null; + getProjectId(): string | null; + // Scan state write accessors (AI review mutates these) + setLastSuggestions(suggestions: Suggestion[]): void; + setLastSummary(summary: ScanSummary | null): void; + // Key management callables (non-chat-related concerns) + getKeyServiceIdForProvider(providerId: string): KeyServiceId | undefined; + getStoredProviderApiKey(providerId: string): Promise; + setValidationState(serviceId: KeyServiceId, snapshot: PersistedKeyValidationSnapshot): Promise; + clearValidationState(serviceId: KeyServiceId): Promise; + sendKeyStatusUpdate(serviceId: KeyServiceId, focusServiceId?: KeyServiceId): Promise; + openKeys(focusServiceId?: KeyServiceId): void; +} + +export class ChatHandler { + private chatHistory: ChatMessage[] = []; + + constructor(private readonly ctx: ChatHandlerContext) {} + + public resetHistory(): void { + this.chatHistory = []; + } + + public async sendChatConfig( + providerId: ChatProviderId = this.ctx.getSelectedChatProvider(), + model: string = this.ctx.getSelectedChatModel() + ) { + this.ctx.postMessage({ + type: "chatConfig", + providers: buildProviderOptions(), + selectedProvider: providerId, + selectedModel: model, + }); + } + + private logAiReview(message: string) { + const stamp = new Date().toISOString(); + this.ctx.outputChannel.appendLine(`[${stamp}] ${message}`); + } + + private getAiReviewConfig() { + const config = vscode.workspace.getConfiguration("recost"); + return { + enabled: config.get("aiReview.enabled", true), + minConfidence: config.get("aiReview.minConfidence", 0.7), + maxFiles: config.get("aiReview.maxFiles", 25), + maxCharsPerFile: config.get("aiReview.maxCharsPerFile", 6000), + fallbackModel: config.get("aiReview.model", "gpt-4.1-mini"), + }; + } + + private resolveAiReviewSelection(fallbackModel: string): { providerId: ChatProviderId; model: string } { + const providerId = this.ctx.getSelectedChatProvider(); + const provider = getProviderAdapter(providerId); + const selectedModel = this.ctx.getSelectedChatModel(); + if (provider.models.some((entry) => entry.id === selectedModel)) { + return { providerId, model: selectedModel }; + } + if (providerId === "openai" && provider.models.some((entry) => entry.id === fallbackModel)) { + return { providerId, model: fallbackModel }; + } + return { providerId, model: provider.models[0]?.id ?? fallbackModel }; + } + + private async executeAiReviewRequest(request: NormalizedChatRequest) { + const modelMeta = findModelMetadata(request.provider, request.model); + const requiresFallback = request.provider === "openai" && modelMeta?.reasoning; + if (!requiresFallback) { + return executeChat({ request, secrets: this.ctx.context.secrets }); + } + try { + return await executeChat({ request: { ...request, stream: false }, secrets: this.ctx.context.secrets }); + } catch (error) { + const chatError = error as ChatAdapterError; + if (chatError?.status !== 400) { + throw error; + } + return executeChat({ + request: { + ...request, + stream: false, + messages: request.messages.filter((message) => message.role !== "system"), + }, + secrets: this.ctx.context.secrets, + }); + } + } + + private redactSensitiveText(value: string): string { + return value + .replace(/sk-[a-zA-Z0-9]{16,}/g, "[REDACTED_OPENAI_KEY]") + .replace(/(api[_-]?key|token|secret)\s*[:=]\s*["'`][^"'`\n]{8,}["'`]/gi, "$1=[REDACTED]") + .replace(/(authorization\s*:\s*["'`]bearer\s+)[^"'`\n]+/gi, "$1[REDACTED]"); + } + + private async buildAiReviewInputContext(maxFiles: number, maxCharsPerFile: number): Promise { + const scoreByFile = new Map(); + const lineHintByFile = new Map(); + const severityScore: Record = { high: 4, medium: 2, low: 1 }; + + for (const suggestion of this.ctx.getLastSuggestions()) { + for (const file of suggestion.affectedFiles) { + scoreByFile.set(file, (scoreByFile.get(file) ?? 0) + severityScore[suggestion.severity]); + if (suggestion.targetLine && !lineHintByFile.has(file)) { + lineHintByFile.set(file, suggestion.targetLine); + } + } + } + + for (const endpoint of this.ctx.getLastEndpoints()) { + const endpointScore = + endpoint.status === "n_plus_one_risk" || endpoint.status === "redundant" ? 4 : + endpoint.status === "rate_limit_risk" ? 3 : + endpoint.status === "cacheable" || endpoint.status === "batchable" ? 2 : + 1; + for (const callSite of endpoint.callSites) { + scoreByFile.set(callSite.file, (scoreByFile.get(callSite.file) ?? 0) + endpointScore); + if (!lineHintByFile.has(callSite.file)) { + lineHintByFile.set(callSite.file, callSite.line); + } + } + } + + const rankedFiles = [...scoreByFile.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, Math.max(1, maxFiles)) + .map(([file]) => file); + + const files: AiPromptFile[] = []; + for (let i = 0; i < rankedFiles.length; i += 1) { + const file = rankedFiles[i]; + this.ctx.postMessage({ + type: "aiReviewProgress", + stage: `Preparing context (${i + 1}/${rankedFiles.length})`, + current: i + 1, + total: rankedFiles.length, + }); + + const excerpt = await readWorkspaceFileExcerpt(file, { + centerLine: lineHintByFile.get(file), + contextLines: 40, + maxChars: maxCharsPerFile, + }); + if (!excerpt || !excerpt.content.trim()) continue; + files.push({ + path: file, + startLine: excerpt.startLine, + endLine: excerpt.endLine, + snippet: this.redactSensitiveText(excerpt.content), + }); + } + + return { + files, + summary: this.ctx.getLastSummary(), + endpoints: this.ctx.getLastEndpoints().map((endpoint) => ({ + id: endpoint.id, + method: endpoint.method, + url: endpoint.url, + status: endpoint.status, + monthlyCost: endpoint.monthlyCost, + files: endpoint.files, + })), + suggestions: this.ctx.getLastSuggestions().map((suggestion) => ({ + type: suggestion.type, + severity: suggestion.severity, + description: suggestion.description, + affectedFiles: suggestion.affectedFiles, + })), + }; + } + + private buildAiReviewPrompt(input: AiReviewInput): string { + const contract = { + findings: [ + { + type: "cache | batch | redundancy | n_plus_one | rate_limit", + severity: "high | medium | low", + confidence: 0.0, + description: "short, specific finding", + affectedFile: "path/to/file.ts", + targetLine: 1, + evidence: ["short reason 1", "short reason 2"], + }, + ], + }; + + return [ + "You are an API efficiency code reviewer.", + "Analyze only the provided snippets and existing scan context.", + "Return ONLY valid JSON with no markdown and no extra text.", + "Do not invent files. Use only provided file paths.", + "Prefer high precision over recall.", + `JSON contract: ${JSON.stringify(contract)}`, + `Context: ${JSON.stringify(input)}`, + ].join("\n"); + } + + private parseAndValidateAiFindings( + raw: string, + validFiles: Set, + minConfidence: number + ): { accepted: AiFinding[]; filtered: number } { + const allowedTypes = new Set(["cache", "batch", "redundancy", "n_plus_one", "rate_limit"]); + const allowedSeverity = new Set(["high", "medium", "low"]); + + const tryParse = (value: string): unknown => { + try { + return JSON.parse(value); + } catch { + return null; + } + }; + + let parsed = tryParse(raw); + if (!parsed) { + const fenced = raw.match(/```json\s*([\s\S]*?)\s*```/i) ?? raw.match(/```\s*([\s\S]*?)\s*```/i); + if (fenced) { + parsed = tryParse(fenced[1]); + } + } + if (!parsed) { + const start = raw.indexOf("{"); + const end = raw.lastIndexOf("}"); + if (start >= 0 && end > start) { + parsed = tryParse(raw.slice(start, end + 1)); + } + } + + const findings = (parsed as { findings?: unknown })?.findings; + if (!Array.isArray(findings)) { + return { accepted: [], filtered: 0 }; + } + + const accepted: AiFinding[] = []; + let filtered = 0; + for (const entry of findings) { + if (accepted.length >= 50) { + filtered += 1; + continue; + } + if (!entry || typeof entry !== "object") { + filtered += 1; + continue; + } + const candidate = entry as Record; + const type = candidate.type; + const severity = candidate.severity; + const affectedFile = candidate.affectedFile; + const description = candidate.description; + if ( + typeof type !== "string" || + typeof severity !== "string" || + typeof affectedFile !== "string" || + typeof description !== "string" + ) { + filtered += 1; + continue; + } + if (!allowedTypes.has(type as Suggestion["type"]) || !allowedSeverity.has(severity as Suggestion["severity"])) { + filtered += 1; + continue; + } + if (!validFiles.has(affectedFile)) { + filtered += 1; + continue; + } + + const confidence = clampConfidence(Number(candidate.confidence)); + if (confidence < minConfidence) { + filtered += 1; + continue; + } + + const rawLine = Number(candidate.targetLine); + const targetLine = Number.isFinite(rawLine) && rawLine > 0 ? Math.floor(rawLine) : undefined; + const evidence = Array.isArray(candidate.evidence) + ? candidate.evidence.filter((item): item is string => typeof item === "string").slice(0, 4).map((item) => trimText(item, 180)) + : []; + + accepted.push({ + type: type as Suggestion["type"], + severity: severity as Suggestion["severity"], + confidence, + description: trimText(description.trim(), 500), + affectedFile, + targetLine, + evidence, + }); + } + + return { accepted, filtered }; + } + + private mapAiFindingToSuggestion(finding: AiFinding, index: number): Suggestion { + const lastEndpoints = this.ctx.getLastEndpoints(); + const providerProjectId = this.ctx.getProjectId(); + const scanId = lastEndpoints[0]?.scanId ?? providerProjectId ?? `local-${Date.now()}`; + const projectId = lastEndpoints[0]?.projectId ?? providerProjectId ?? "local"; + const fileEndpoints = lastEndpoints.filter((ep) => ep.files.includes(finding.affectedFile)); + const related = fileEndpoints.map((endpoint) => endpoint.id); + const closestEndpoint = findClosestEndpoint( + { affectedFile: finding.affectedFile, line: finding.targetLine }, + fileEndpoints + ); + const directCost = closestEndpoint?.monthlyCost ?? 0; + const fileMonthlyCost = fileEndpoints.reduce((sum, ep) => sum + ep.monthlyCost, 0); + const monthlyBaseline = directCost > 0 + ? directCost + : fileMonthlyCost > 0 + ? fileMonthlyCost + : 0; // unknown — no savings estimate + + return { + id: `ai-${Date.now()}-${index + 1}`, + projectId, + scanId, + type: finding.type, + severity: finding.severity, + affectedEndpoints: related, + affectedFiles: [finding.affectedFile], + targetLine: finding.targetLine, + estimatedMonthlySavings: calculateSavings(finding.type, finding.severity, monthlyBaseline), + description: finding.description, + codeFix: "", + source: "ai", + confidence: finding.confidence, + evidence: finding.evidence, + reviewedAt: new Date().toISOString(), + pricingClass: classifyPricing(fileEndpoints.map((ep) => ep.costModel)), + }; + } + + 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 }; + } + + public async handleRunAiReview() { + const { enabled, minConfidence, maxFiles, maxCharsPerFile, fallbackModel } = this.getAiReviewConfig(); + if (!enabled) { + this.ctx.postMessage({ type: "aiReviewError", message: "AI review is disabled in settings." }); + return; + } + const lastEndpoints = this.ctx.getLastEndpoints(); + const lastSuggestions = this.ctx.getLastSuggestions(); + if (lastEndpoints.length === 0 && lastSuggestions.length === 0) { + this.ctx.postMessage({ type: "aiReviewError", message: "Run a scan before AI review." }); + return; + } + + try { + const { providerId, model } = this.resolveAiReviewSelection(fallbackModel); + const provider = getProviderAdapter(providerId); + this.ctx.postMessage({ type: "aiReviewProgress", stage: "Collecting files..." }); + const input = await this.buildAiReviewInputContext(maxFiles, maxCharsPerFile); + if (input.files.length === 0) { + this.ctx.postMessage({ type: "aiReviewComplete", added: 0, filtered: 0 }); + return; + } + + this.ctx.postMessage({ type: "aiReviewProgress", stage: `Calling ${provider.displayName}...` }); + const response = await this.executeAiReviewRequest({ + provider: providerId, + model, + temperature: providerId === "recost" ? undefined : 0.1, + stream: false, + messages: [ + { + role: "system", + content: "You are a strict API efficiency reviewer. Return only JSON.", + }, + { + role: "user", + content: this.buildAiReviewPrompt(input), + }, + ], + }); + + const raw = response.content ?? ""; + this.ctx.postMessage({ type: "aiReviewProgress", stage: "Validating findings..." }); + const validFiles = new Set(input.files.map((file) => file.path)); + const { accepted, filtered } = this.parseAndValidateAiFindings(raw, validFiles, minConfidence); + const aiSuggestions = accepted.map((finding, index) => this.mapAiFindingToSuggestion(finding, index)); + const currentSuggestions = this.ctx.getLastSuggestions(); + const merged = this.mergeAiSuggestions(currentSuggestions, aiSuggestions); + + this.ctx.setLastSuggestions(merged.merged); + const currentEndpoints = this.ctx.getLastEndpoints(); + const currentSummary = this.ctx.getLastSummary(); + const summary = currentSummary ?? { + totalEndpoints: currentEndpoints.length, + totalCallsPerDay: currentEndpoints.reduce((sum, endpoint) => sum + endpoint.callsPerDay, 0), + totalMonthlyCost: currentEndpoints.reduce((sum, endpoint) => sum + endpoint.monthlyCost, 0), + highRiskCount: 0, + }; + const updatedSuggestions = this.ctx.getLastSuggestions(); + const updatedSummary: ScanSummary = { + ...summary, + totalEndpoints: Math.max(summary.totalEndpoints, currentEndpoints.length), + highRiskCount: updatedSuggestions.filter((suggestion) => suggestion.severity === "high").length, + }; + this.ctx.setLastSummary(updatedSummary); + + this.logAiReview( + `provider=${providerId} model=${model} files=${input.files.length} raw=${accepted.length + filtered} accepted=${merged.added} filtered=${filtered + merged.filtered}` + ); + + this.ctx.postMessage({ + type: "scanResults", + endpoints: currentEndpoints, + suggestions: updatedSuggestions, + summary: updatedSummary, + }); + this.ctx.postMessage({ type: "aiReviewComplete", added: merged.added, filtered: filtered + merged.filtered }); + } catch (err: unknown) { + const chatError = err as ChatAdapterError; + const { providerId } = this.resolveAiReviewSelection(fallbackModel); + const serviceId = this.ctx.getKeyServiceIdForProvider(providerId); + if (chatError?.code === "bad_auth") { + if (serviceId) { + const apiKey = await this.ctx.getStoredProviderApiKey(providerId); + if (apiKey) { + await this.ctx.setValidationState(serviceId, { + state: "invalid", + message: chatError.message, + lastCheckedAt: new Date().toISOString(), + keyFingerprint: buildKeyFingerprint(apiKey), + }); + } else { + await this.ctx.clearValidationState(serviceId); + } + await this.ctx.sendKeyStatusUpdate(serviceId, serviceId); + } + this.ctx.openKeys(serviceId); + this.ctx.postMessage({ type: "aiReviewError", message: chatError.message }); + return; + } + if (chatError?.code === "missing_api_key") { + if (serviceId) { + await this.ctx.clearValidationState(serviceId); + await this.ctx.sendKeyStatusUpdate(serviceId, serviceId); + } + this.ctx.openKeys(serviceId); + this.ctx.postMessage({ type: "aiReviewError", message: chatError.message }); + return; + } + const message = err instanceof Error ? err.message : "AI review failed"; + this.logAiReview(`error=${message}`); + this.ctx.postMessage({ type: "aiReviewError", message }); + } + } + + private buildMessages(text: string, limitContext = false): NormalizedChatMessage[] { + const lastSuggestions = this.ctx.getLastSuggestions(); + const lastEndpoints = this.ctx.getLastEndpoints(); + const lastSummary = this.ctx.getLastSummary(); + const suggestions = limitContext ? lastSuggestions.slice(0, 5) : lastSuggestions; + const endpoints = limitContext ? lastEndpoints.slice(0, 8) : lastEndpoints; + return [ + { role: "system", content: buildSystemPrompt(lastSummary, suggestions, endpoints) }, + ...this.chatHistory, + { role: "user", content: text }, + ]; + } + + private async executeProviderRequest(request: NormalizedChatRequest) { + return executeChat({ + request, + secrets: this.ctx.context.secrets, + onChunk: async (chunk) => { + if (chunk.delta) { + this.ctx.postMessage({ type: "chatStreaming", chunk: chunk.delta }); + } + }, + }); + } + + public async handleChat(text: string, providerId: string, model: string) { + const provider = getProviderAdapter(providerId); + const modelMeta = findModelMetadata(providerId, model); + const messages = this.buildMessages(text, providerId === "recost"); + const baseRequest: NormalizedChatRequest = { + provider: providerId, + model, + messages, + temperature: providerId === "recost" ? undefined : 0.7, + stream: provider.supportsStreaming && (modelMeta?.supportsStreaming ?? provider.supportsStreaming), + }; + + try { + let response; + const requiresFallback = providerId === "openai" && modelMeta?.reasoning; + if (requiresFallback) { + try { + response = await this.executeProviderRequest({ ...baseRequest, stream: false }); + } catch (error) { + const chatError = error as ChatAdapterError; + if (chatError?.status === 400) { + response = await this.executeProviderRequest({ + ...baseRequest, + stream: false, + messages: messages.filter((message) => message.role !== "system"), + }); + } else { + throw error; + } + } + } else { + response = await this.executeProviderRequest(baseRequest); + } + + this.chatHistory.push({ role: "user", content: text }); + this.chatHistory.push({ role: "assistant", content: response.content }); + this.ctx.postMessage({ type: "chatDone", fullContent: response.content }); + } catch (error) { + const chatError = error as ChatAdapterError; + const serviceId = this.ctx.getKeyServiceIdForProvider(providerId); + if (chatError?.code === "bad_auth") { + if (serviceId) { + const apiKey = await this.ctx.getStoredProviderApiKey(providerId); + if (apiKey) { + await this.ctx.setValidationState(serviceId, { + state: "invalid", + message: chatError.message, + lastCheckedAt: new Date().toISOString(), + keyFingerprint: buildKeyFingerprint(apiKey), + }); + } else { + await this.ctx.clearValidationState(serviceId); + } + await this.ctx.sendKeyStatusUpdate(serviceId, serviceId); + } + this.ctx.openKeys(serviceId); + this.ctx.postMessage({ type: "chatError", message: chatError.message }); + return; + } + if (chatError?.code === "missing_api_key") { + if (serviceId) { + await this.ctx.clearValidationState(serviceId); + await this.ctx.sendKeyStatusUpdate(serviceId, serviceId); + } + this.ctx.openKeys(serviceId); + this.ctx.postMessage({ type: "chatError", message: chatError.message }); + return; + } + const message = error instanceof Error ? error.message : "Network error. Check your connection."; + this.ctx.postMessage({ type: "chatError", message }); + } + } +} From c119c78a60b313d43043c5231b39754fefbc6949 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Tue, 12 May 2026 01:00:09 -0400 Subject: [PATCH 07/14] refactor(webview): extract KeyManagementHandler from webview-provider Co-Authored-By: Claude Opus 4.7 (1M context) --- src/webview-provider.ts | 167 +++----------------- src/webview/key-management-handler.ts | 210 ++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 145 deletions(-) create mode 100644 src/webview/key-management-handler.ts diff --git a/src/webview-provider.ts b/src/webview-provider.ts index 62ef83e..fe4f9a6 100644 --- a/src/webview-provider.ts +++ b/src/webview-provider.ts @@ -13,7 +13,7 @@ import { getDefaultChatSelection, type ChatProviderId, } from "./chat"; -import type { WebviewMessage, HostMessage, KeyServiceId, KeyStatusSummary, ProjectIdStatusSummary } from "./messages"; +import type { WebviewMessage, HostMessage, KeyServiceId, ProjectIdStatusSummary } from "./messages"; import type { ApiCallInput, EndpointRecord, Suggestion, ScanSummary } from "./analysis/types"; import { runSimulation, StaticDataSource } from "./simulator"; import type { SimulatorInput } from "./simulator/types"; @@ -27,18 +27,15 @@ import { buildExportContext, formatAsMarkdown } from "./intelligence/export"; import { estimateLocalMonthlyCost } from "./intelligence/cost-utils"; import { buildKeyFingerprint, - buildKeyStatusSummary, getKeyService, - listKeyServices, - maskKeyPreview, readStoredSecret, resolveCurrentKeyValue, - validateServiceKey, type PersistedKeyValidationSnapshot, } from "./key-management"; import { resolveWorkspaceFilePathSafely } from "./workspace-file-access"; import { getOutputChannel } from "./output"; import { ChatHandler } from "./webview/chat-handler"; +import { KeyManagementHandler } from "./webview/key-management-handler"; async function resolveWorkspaceFileSafely( workspaceFolder: vscode.WorkspaceFolder, @@ -607,7 +604,6 @@ export async function dispatchWebviewMessage( export class ReCostSidebarProvider implements vscode.WebviewViewProvider { public static readonly viewType = "recost.sidebarView"; - private static readonly KEY_VALIDATION_STATE_STORAGE_KEY = "recost.keyValidationState"; private static readonly MANUAL_PROJECT_ID_STORAGE_KEY = "recost.manualProjectId"; private static readonly MANUAL_PROJECT_ID_VALIDATION_STORAGE_KEY = "recost.manualProjectIdValidation"; @@ -628,8 +624,8 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { // Chat state private readonly chatHandler: ChatHandler; + private readonly keyManagementHandler: KeyManagementHandler; private readonly outputChannel: vscode.OutputChannel; - private readonly keyValidationState = new Map(); private readonly projectIdCheckingState = new Set(); private async sendProjectIdStatus(): Promise { @@ -699,7 +695,16 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { this.outputChannel = vscode.window.createOutputChannel("ReCost AI Review"); this.context.subscriptions.push(this.outputChannel); this.savedScenarios = (this.context.globalState.get("recost.simulatorScenarios")) ?? []; - this.restoreKeyValidationState(); + this.keyManagementHandler = new KeyManagementHandler({ + postMessage: (m) => this.postMessage(m), + context: this.context, + outputChannel: this.outputChannel, + openKeys: (id) => this.openKeys(id), + getManualProjectId: () => this.getManualProjectId(), + clearProjectIdValidationState: () => this.clearProjectIdValidationState(), + sendProjectIdStatus: () => this.sendProjectIdStatus(), + validateManualProjectId: () => this.validateManualProjectId(), + }); this.chatHandler = new ChatHandler({ postMessage: (m) => this.postMessage(m), outputChannel: this.outputChannel, @@ -778,13 +783,11 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { } private getKeyServiceIdForProvider(providerId: string): KeyServiceId | undefined { - return listKeyServices().find((service) => service.providerId === providerId)?.serviceId; + return this.keyManagementHandler.getKeyServiceIdForProvider(providerId); } private async getStoredProviderApiKey(providerId: string): Promise { - const serviceId = this.getKeyServiceIdForProvider(providerId); - if (!serviceId) return undefined; - return readStoredSecret(getKeyService(serviceId), this.context.secrets); + return this.keyManagementHandler.getStoredProviderApiKey(providerId); } private sendChatConfig(providerId?: ChatProviderId, model?: string) { @@ -961,109 +964,24 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { return { projectId: await this.getOrCreateProject(rcApiKey), source: "auto" }; } - private async buildAllKeyStatuses(): Promise { - const services = listKeyServices(); - return Promise.all( - services.map((service) => - this.buildKeyStatus(service) - ) - ); - } - private async sendAllKeyStatuses(focusServiceId?: KeyServiceId) { - this.postMessage({ type: "allKeyStatuses", statuses: await this.buildAllKeyStatuses(), focusServiceId }); + return this.keyManagementHandler.sendAllKeyStatuses(focusServiceId); } private async sendKeyStatusUpdate(serviceId: KeyServiceId, focusServiceId?: KeyServiceId) { - const service = getKeyService(serviceId); - const status = await this.buildKeyStatus(service); - this.postMessage({ type: "keyStatusUpdated", status, focusServiceId }); + return this.keyManagementHandler.sendKeyStatusUpdate(serviceId, focusServiceId); } private async clearServiceKey(serviceId: KeyServiceId) { - const service = getKeyService(serviceId); - if (service.secretStorageKey) { - await this.context.secrets.delete(service.secretStorageKey); - } - if (serviceId === "openai") { - await this.context.secrets.delete("recost.openaiApiKey"); - } - await this.clearValidationState(serviceId); - await this.sendKeyStatusUpdate(serviceId); - if (serviceId === "recost") { - await this.clearProjectIdValidationState(); - await this.sendProjectIdStatus(); - } + return this.keyManagementHandler.clearServiceKey(serviceId); } private async setServiceKey(serviceId: KeyServiceId, value: string) { - const service = getKeyService(serviceId); - const trimmed = value.trim(); - if (!trimmed) { - this.postMessage({ type: "keyActionError", serviceId, message: "API key must not be empty." }); - return; - } - if (!service.secretStorageKey) { - this.postMessage({ type: "keyActionError", serviceId, message: `${service.displayName} does not use stored API keys in this extension.` }); - return; - } - if (serviceId === "openai" && !/^sk-/.test(trimmed)) { - this.postMessage({ type: "keyActionError", serviceId, message: 'OpenAI API keys must start with "sk-".' }); - return; - } - await this.context.secrets.store(service.secretStorageKey, trimmed); - if (serviceId === "openai") { - await this.context.secrets.store("recost.openaiApiKey", trimmed); - } - await this.clearValidationState(serviceId); - await this.sendKeyStatusUpdate(serviceId); - await this.testServiceKey(serviceId); - if (serviceId === "recost" && this.getManualProjectId()) { - await this.validateManualProjectId(); - } + return this.keyManagementHandler.setServiceKey(serviceId, value); } private async testServiceKey(serviceId: KeyServiceId) { - const service = getKeyService(serviceId); - const current = await this.buildKeyStatus(service); - if (current.source === "missing") { - this.postMessage({ type: "keyActionError", serviceId, message: `${service.displayName} key is missing.` }); - return; - } - this.postMessage({ - type: "keyStatusUpdated", - status: { ...current, state: "checking", message: undefined }, - focusServiceId: serviceId, - }); - try { - const value = await resolveCurrentKeyValue(service, this.context.secrets); - if (!value) { - this.postMessage({ type: "keyActionError", serviceId, message: `${service.displayName} key is missing.` }); - return; - } - const validation = await validateServiceKey(service, value); - await this.setValidationState(serviceId, { - ...validation, - keyFingerprint: buildKeyFingerprint(value), - }); - await this.sendKeyStatusUpdate(serviceId, serviceId); - if (serviceId === "recost") { - await vscode.commands.executeCommand("setContext", "recost.keyOnline", validation.state === "valid"); - } - } catch (error) { - const previous = this.keyValidationState.get(serviceId); - await this.sendKeyStatusUpdate(serviceId, serviceId); - const message = error instanceof Error ? error.message : `Unable to test ${service.displayName} key.`; - if (previous) { - this.postMessage({ type: "keyActionError", serviceId, message }); - } else { - this.postMessage({ - type: "keyStatusUpdated", - status: { ...current, message, maskedPreview: current.maskedPreview ?? maskKeyPreview(undefined) }, - focusServiceId: serviceId, - }); - } - } + return this.keyManagementHandler.testServiceKey(serviceId); } private async handleMessage(message: WebviewMessage): Promise { @@ -1452,53 +1370,12 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { return readStoredSecret(getKeyService("recost"), this.context.secrets); } - private restoreKeyValidationState() { - const stored = - this.context.globalState.get>>( - ReCostSidebarProvider.KEY_VALIDATION_STATE_STORAGE_KEY - ) ?? {}; - for (const [serviceId, snapshot] of Object.entries(stored) as [KeyServiceId, PersistedKeyValidationSnapshot | undefined][]) { - if (snapshot) { - this.keyValidationState.set(serviceId, snapshot); - } - } - } - - private async persistKeyValidationState() { - await this.context.globalState.update( - ReCostSidebarProvider.KEY_VALIDATION_STATE_STORAGE_KEY, - Object.fromEntries(this.keyValidationState.entries()) - ); - } - private async clearValidationState(serviceId: KeyServiceId) { - this.keyValidationState.delete(serviceId); - await this.persistKeyValidationState(); + return this.keyManagementHandler.clearValidationState(serviceId); } private async setValidationState(serviceId: KeyServiceId, snapshot: PersistedKeyValidationSnapshot) { - this.keyValidationState.set(serviceId, snapshot); - await this.persistKeyValidationState(); - } - - private async getValidationSnapshot(serviceId: KeyServiceId): Promise { - const snapshot = this.keyValidationState.get(serviceId); - if (!snapshot) return undefined; - const service = getKeyService(serviceId); - const currentValue = await resolveCurrentKeyValue(service, this.context.secrets); - if (!currentValue || snapshot.keyFingerprint !== buildKeyFingerprint(currentValue)) { - await this.clearValidationState(serviceId); - return undefined; - } - return snapshot; - } - - private async buildKeyStatus(service: ReturnType): Promise { - return buildKeyStatusSummary( - service, - this.context.secrets, - await this.getValidationSnapshot(service.serviceId) - ); + return this.keyManagementHandler.setValidationState(serviceId, snapshot); } private handleChat(text: string, provider: string, model: string) { diff --git a/src/webview/key-management-handler.ts b/src/webview/key-management-handler.ts new file mode 100644 index 0000000..d28cad9 --- /dev/null +++ b/src/webview/key-management-handler.ts @@ -0,0 +1,210 @@ +import * as vscode from "vscode"; +import type { HostMessage, KeyServiceId, KeyStatusSummary } from "../messages"; +import { + buildKeyFingerprint, + buildKeyStatusSummary, + getKeyService, + listKeyServices, + maskKeyPreview, + readStoredSecret, + resolveCurrentKeyValue, + validateServiceKey, + type PersistedKeyValidationSnapshot, +} from "../key-management"; + +export interface KeyManagementHandlerContext { + postMessage(message: HostMessage): void; + context: vscode.ExtensionContext; + outputChannel: vscode.OutputChannel; + // For UI navigation only: + openKeys(focusServiceId?: KeyServiceId): void; + // Project-id coupling: setServiceKey/clearServiceKey must trigger project-id + // revalidation when the recost key changes. The provider continues to own + // the project-id family (separate storage keys + workspaceState). + getManualProjectId(): string | null; + clearProjectIdValidationState(): Promise; + sendProjectIdStatus(): Promise; + validateManualProjectId(): Promise; +} + +export class KeyManagementHandler { + private static readonly KEY_VALIDATION_STATE_STORAGE_KEY = "recost.keyValidationState"; + + private readonly keyValidationState = new Map(); + + constructor(private readonly ctx: KeyManagementHandlerContext) { + this.restoreKeyValidationState(); + } + + private get context(): vscode.ExtensionContext { + return this.ctx.context; + } + + private postMessage(message: HostMessage): void { + this.ctx.postMessage(message); + } + + public getKeyServiceIdForProvider(providerId: string): KeyServiceId | undefined { + return listKeyServices().find((service) => service.providerId === providerId)?.serviceId; + } + + public async getStoredProviderApiKey(providerId: string): Promise { + const serviceId = this.getKeyServiceIdForProvider(providerId); + if (!serviceId) return undefined; + return readStoredSecret(getKeyService(serviceId), this.context.secrets); + } + + public async buildAllKeyStatuses(): Promise { + const services = listKeyServices(); + return Promise.all( + services.map((service) => + this.buildKeyStatus(service) + ) + ); + } + + public async sendAllKeyStatuses(focusServiceId?: KeyServiceId) { + this.postMessage({ type: "allKeyStatuses", statuses: await this.buildAllKeyStatuses(), focusServiceId }); + } + + public async sendKeyStatusUpdate(serviceId: KeyServiceId, focusServiceId?: KeyServiceId) { + const service = getKeyService(serviceId); + const status = await this.buildKeyStatus(service); + this.postMessage({ type: "keyStatusUpdated", status, focusServiceId }); + } + + public async clearServiceKey(serviceId: KeyServiceId) { + const service = getKeyService(serviceId); + if (service.secretStorageKey) { + await this.context.secrets.delete(service.secretStorageKey); + } + if (serviceId === "openai") { + await this.context.secrets.delete("recost.openaiApiKey"); + } + await this.clearValidationState(serviceId); + await this.sendKeyStatusUpdate(serviceId); + if (serviceId === "recost") { + await this.ctx.clearProjectIdValidationState(); + await this.ctx.sendProjectIdStatus(); + } + } + + public async setServiceKey(serviceId: KeyServiceId, value: string) { + const service = getKeyService(serviceId); + const trimmed = value.trim(); + if (!trimmed) { + this.postMessage({ type: "keyActionError", serviceId, message: "API key must not be empty." }); + return; + } + if (!service.secretStorageKey) { + this.postMessage({ type: "keyActionError", serviceId, message: `${service.displayName} does not use stored API keys in this extension.` }); + return; + } + if (serviceId === "openai" && !/^sk-/.test(trimmed)) { + this.postMessage({ type: "keyActionError", serviceId, message: 'OpenAI API keys must start with "sk-".' }); + return; + } + await this.context.secrets.store(service.secretStorageKey, trimmed); + if (serviceId === "openai") { + await this.context.secrets.store("recost.openaiApiKey", trimmed); + } + await this.clearValidationState(serviceId); + await this.sendKeyStatusUpdate(serviceId); + await this.testServiceKey(serviceId); + if (serviceId === "recost" && this.ctx.getManualProjectId()) { + await this.ctx.validateManualProjectId(); + } + } + + public async testServiceKey(serviceId: KeyServiceId) { + const service = getKeyService(serviceId); + const current = await this.buildKeyStatus(service); + if (current.source === "missing") { + this.postMessage({ type: "keyActionError", serviceId, message: `${service.displayName} key is missing.` }); + return; + } + this.postMessage({ + type: "keyStatusUpdated", + status: { ...current, state: "checking", message: undefined }, + focusServiceId: serviceId, + }); + try { + const value = await resolveCurrentKeyValue(service, this.context.secrets); + if (!value) { + this.postMessage({ type: "keyActionError", serviceId, message: `${service.displayName} key is missing.` }); + return; + } + const validation = await validateServiceKey(service, value); + await this.setValidationState(serviceId, { + ...validation, + keyFingerprint: buildKeyFingerprint(value), + }); + await this.sendKeyStatusUpdate(serviceId, serviceId); + if (serviceId === "recost") { + await vscode.commands.executeCommand("setContext", "recost.keyOnline", validation.state === "valid"); + } + } catch (error) { + const previous = this.keyValidationState.get(serviceId); + await this.sendKeyStatusUpdate(serviceId, serviceId); + const message = error instanceof Error ? error.message : `Unable to test ${service.displayName} key.`; + if (previous) { + this.postMessage({ type: "keyActionError", serviceId, message }); + } else { + this.postMessage({ + type: "keyStatusUpdated", + status: { ...current, message, maskedPreview: current.maskedPreview ?? maskKeyPreview(undefined) }, + focusServiceId: serviceId, + }); + } + } + } + + private restoreKeyValidationState() { + const stored = + this.context.globalState.get>>( + KeyManagementHandler.KEY_VALIDATION_STATE_STORAGE_KEY + ) ?? {}; + for (const [serviceId, snapshot] of Object.entries(stored) as [KeyServiceId, PersistedKeyValidationSnapshot | undefined][]) { + if (snapshot) { + this.keyValidationState.set(serviceId, snapshot); + } + } + } + + private async persistKeyValidationState() { + await this.context.globalState.update( + KeyManagementHandler.KEY_VALIDATION_STATE_STORAGE_KEY, + Object.fromEntries(this.keyValidationState.entries()) + ); + } + + public async clearValidationState(serviceId: KeyServiceId) { + this.keyValidationState.delete(serviceId); + await this.persistKeyValidationState(); + } + + public async setValidationState(serviceId: KeyServiceId, snapshot: PersistedKeyValidationSnapshot) { + this.keyValidationState.set(serviceId, snapshot); + await this.persistKeyValidationState(); + } + + private async getValidationSnapshot(serviceId: KeyServiceId): Promise { + const snapshot = this.keyValidationState.get(serviceId); + if (!snapshot) return undefined; + const service = getKeyService(serviceId); + const currentValue = await resolveCurrentKeyValue(service, this.context.secrets); + if (!currentValue || snapshot.keyFingerprint !== buildKeyFingerprint(currentValue)) { + await this.clearValidationState(serviceId); + return undefined; + } + return snapshot; + } + + private async buildKeyStatus(service: ReturnType): Promise { + return buildKeyStatusSummary( + service, + this.context.secrets, + await this.getValidationSnapshot(service.serviceId) + ); + } +} From 5139dd689c28dd70cbf0478cf39a531e1dec555e Mon Sep 17 00:00:00 2001 From: Andres Lopez <190146319+AndresL230@users.noreply.github.com> Date: Tue, 12 May 2026 02:22:26 -0400 Subject: [PATCH 08/14] feat(span): add SourceSpan type and helpers (issue #80) --- src/scanner/source-span.ts | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/scanner/source-span.ts diff --git a/src/scanner/source-span.ts b/src/scanner/source-span.ts new file mode 100644 index 0000000..0e7b5de --- /dev/null +++ b/src/scanner/source-span.ts @@ -0,0 +1,39 @@ +/** + * Span describing where a detection lives in source. + * + * - Lines are 1-based to match VSCode's display convention. + * - Columns are 0-based to match tree-sitter's `startPosition.column` + * and VSCode's `Position` constructor. + */ +export interface SourceSpan { + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; +} + +/** Build a zero-width span at a single point (used as a safe fallback). */ +export function pointSpan(line: number, column = 0): SourceSpan { + return { startLine: line, startColumn: column, endLine: line, endColumn: column }; +} + +/** + * Compute the end position of a regex match given (a) the start line/column + * inside the source and (b) the matched text. Walks the matched text counting + * newlines so multi-line matches report their true extent. + */ +export function spanFromMatch( + startLine: number, + startColumn: number, + matchText: string +): SourceSpan { + let endLine = startLine; + let endColumn = startColumn + matchText.length; + const newlineCount = (matchText.match(/\n/g) ?? []).length; + if (newlineCount > 0) { + endLine = startLine + newlineCount; + const lastNewline = matchText.lastIndexOf("\n"); + endColumn = matchText.length - lastNewline - 1; + } + return { startLine, startColumn, endLine, endColumn }; +} From ada66740e288adc30ac83f1b4ea24f9a8c2276a3 Mon Sep 17 00:00:00 2001 From: Andres Lopez <190146319+AndresL230@users.noreply.github.com> Date: Tue, 12 May 2026 02:26:37 -0400 Subject: [PATCH 09/14] refactor(webview): extract SimulationHandler from webview-provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves handleRunSimulation, persistScenarios, and the single-flight scenarioPersistQueue (introduced in C5) into src/webview/simulation-handler.ts. Structurally pure extraction — no behavioral change. webview-provider.ts drops from 1556 to 1534 lines. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/webview-provider.ts | 38 +++++----------------- src/webview/simulation-handler.ts | 53 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 30 deletions(-) create mode 100644 src/webview/simulation-handler.ts diff --git a/src/webview-provider.ts b/src/webview-provider.ts index fe4f9a6..b22de6e 100644 --- a/src/webview-provider.ts +++ b/src/webview-provider.ts @@ -15,7 +15,6 @@ import { } from "./chat"; import type { WebviewMessage, HostMessage, KeyServiceId, ProjectIdStatusSummary } from "./messages"; import type { ApiCallInput, EndpointRecord, Suggestion, ScanSummary } from "./analysis/types"; -import { runSimulation, StaticDataSource } from "./simulator"; import type { SimulatorInput } from "./simulator/types"; import { classifyEndpointScope, detectEndpointProvider } from "./scanner/endpoint-classification"; import { classifyPricing, calculateSavings } from "./scan-results"; @@ -36,6 +35,7 @@ import { resolveWorkspaceFilePathSafely } from "./workspace-file-access"; import { getOutputChannel } from "./output"; import { ChatHandler } from "./webview/chat-handler"; import { KeyManagementHandler } from "./webview/key-management-handler"; +import { SimulationHandler } from "./webview/simulation-handler"; async function resolveWorkspaceFileSafely( workspaceFolder: vscode.WorkspaceFolder, @@ -618,13 +618,10 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { private lastApiCalls: ApiCallInput[] = []; private lastFindings: Awaited> = []; - // Simulator state (persisted across sessions) - private savedScenarios: import("./simulator/types").SavedScenario[] = []; - private scenarioPersistQueue: Promise = Promise.resolve(); - // Chat state private readonly chatHandler: ChatHandler; private readonly keyManagementHandler: KeyManagementHandler; + private readonly simulationHandler: SimulationHandler; private readonly outputChannel: vscode.OutputChannel; private readonly projectIdCheckingState = new Set(); @@ -694,7 +691,11 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { this.context = context; this.outputChannel = vscode.window.createOutputChannel("ReCost AI Review"); this.context.subscriptions.push(this.outputChannel); - this.savedScenarios = (this.context.globalState.get("recost.simulatorScenarios")) ?? []; + this.simulationHandler = new SimulationHandler({ + postMessage: (m) => this.postMessage(m), + context: this.context, + getLastEndpoints: () => this.lastEndpoints, + }); this.keyManagementHandler = new KeyManagementHandler({ postMessage: (m) => this.postMessage(m), context: this.context, @@ -998,7 +999,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { applyFix: (code, file, line) => this.handleApplyFix(code, file, line), openFile: (file, line) => this.handleOpenFile(file, line), openDashboard: () => this.handleOpenDashboard(), - runSimulation: (input) => { this.handleRunSimulation(input); }, + runSimulation: (input) => { this.simulationHandler.handleRunSimulation(input); }, getAllKeyStatuses: () => this.sendAllKeyStatuses(), getProjectIdStatus: () => this.sendProjectIdStatus(), setKey: (serviceId, value) => this.setServiceKey(serviceId, value), @@ -1050,29 +1051,6 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { } } - private handleRunSimulation(input: SimulatorInput): void { - try { - if (this.lastEndpoints.length === 0) { - this.postMessage({ type: "simulationError", message: "Run a scan first to use the simulator." }); - return; - } - const source = new StaticDataSource(this.lastEndpoints); - const result = runSimulation(source, input); - this.postMessage({ type: "simulationResult", result }); - } catch (err) { - const message = err instanceof Error ? err.message : "Simulation failed"; - this.postMessage({ type: "simulationError", message }); - } - } - - private async persistScenarios(next: import("./simulator/types").SavedScenario[]): Promise { - this.savedScenarios = next; - this.scenarioPersistQueue = this.scenarioPersistQueue - .catch(() => {}) - .then(() => this.context.globalState.update("recost.simulatorScenarios", next)); - await this.scenarioPersistQueue; - } - private async handleStartScan() { await vscode.commands.executeCommand("setContext", "recost.scanning", true); try { diff --git a/src/webview/simulation-handler.ts b/src/webview/simulation-handler.ts new file mode 100644 index 0000000..8aea722 --- /dev/null +++ b/src/webview/simulation-handler.ts @@ -0,0 +1,53 @@ +import * as vscode from "vscode"; +import type { HostMessage } from "../messages"; +import type { EndpointRecord } from "../analysis/types"; +import type { SavedScenario, SimulatorInput } from "../simulator/types"; +import { runSimulation, StaticDataSource } from "../simulator"; + +export interface SimulationHandlerContext { + postMessage(message: HostMessage): void; + context: vscode.ExtensionContext; + getLastEndpoints(): EndpointRecord[]; +} + +export class SimulationHandler { + private static readonly SCENARIOS_STORAGE_KEY = "recost.simulatorScenarios"; + + private savedScenarios: SavedScenario[] = []; + private scenarioPersistQueue: Promise = Promise.resolve(); + + constructor(private readonly ctx: SimulationHandlerContext) { + this.savedScenarios = + this.ctx.context.globalState.get( + SimulationHandler.SCENARIOS_STORAGE_KEY + ) ?? []; + } + + public handleRunSimulation(input: SimulatorInput): void { + try { + const endpoints = this.ctx.getLastEndpoints(); + if (endpoints.length === 0) { + this.ctx.postMessage({ type: "simulationError", message: "Run a scan first to use the simulator." }); + return; + } + const source = new StaticDataSource(endpoints); + const result = runSimulation(source, input); + this.ctx.postMessage({ type: "simulationResult", result }); + } catch (err) { + const message = err instanceof Error ? err.message : "Simulation failed"; + this.ctx.postMessage({ type: "simulationError", message }); + } + } + + public async persistScenarios(next: SavedScenario[]): Promise { + this.savedScenarios = next; + this.scenarioPersistQueue = this.scenarioPersistQueue + .catch(() => {}) + .then(() => this.ctx.context.globalState.update(SimulationHandler.SCENARIOS_STORAGE_KEY, next)); + await this.scenarioPersistQueue; + } + + public getSavedScenarios(): SavedScenario[] { + return this.savedScenarios; + } +} From 40ef019774a5371bd313194a6d6bee405ea4a250 Mon Sep 17 00:00:00 2001 From: Andres Lopez <190146319+AndresL230@users.noreply.github.com> Date: Tue, 12 May 2026 02:33:15 -0400 Subject: [PATCH 10/14] refactor(webview): extract ScanPublishingHandler from webview-provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves handleStartScan plus the URL-canonicalization, endpoint-merging, local-finding-aggregation, and aggressive-suggestion helpers it depends on (~470 lines) into src/webview/scan-publishing-handler.ts. The handler calls back into the provider through a ScanPublishingHandlerContext for state mutations, key validation updates, project resolution, and the debug-export side effect. Structurally pure extraction — no behavioral change. webview-provider.ts drops from 1534 to 791 lines (under the ~800 target set by the plan). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/webview-provider.ts | 795 +------------------------ src/webview/scan-publishing-handler.ts | 788 ++++++++++++++++++++++++ 2 files changed, 814 insertions(+), 769 deletions(-) create mode 100644 src/webview/scan-publishing-handler.ts diff --git a/src/webview-provider.ts b/src/webview-provider.ts index b22de6e..a48168e 100644 --- a/src/webview-provider.ts +++ b/src/webview-provider.ts @@ -6,9 +6,8 @@ import { scanWorkspace, detectLocalWastePatterns, countScopedWorkspaceFiles, - getWorkspaceScanFiles, } from "./scanner/workspace-scanner"; -import { createProject, findProjectByName, submitScan, getAllEndpoints, getAllSuggestions, validateProjectId } from "./api-client"; +import { findProjectByName, createProject, validateProjectId } from "./api-client"; import { getDefaultChatSelection, type ChatProviderId, @@ -16,14 +15,11 @@ import { import type { WebviewMessage, HostMessage, KeyServiceId, ProjectIdStatusSummary } from "./messages"; import type { ApiCallInput, EndpointRecord, Suggestion, ScanSummary } from "./analysis/types"; import type { SimulatorInput } from "./simulator/types"; -import { classifyEndpointScope, detectEndpointProvider } from "./scanner/endpoint-classification"; -import { classifyPricing, calculateSavings } from "./scan-results"; import { buildSnapshot } from "./intelligence/builder"; import { scoreSnapshot } from "./intelligence/scorer"; import { buildReviewClusters } from "./intelligence/clusters"; import { compressClusters } from "./intelligence/compression"; import { buildExportContext, formatAsMarkdown } from "./intelligence/export"; -import { estimateLocalMonthlyCost } from "./intelligence/cost-utils"; import { buildKeyFingerprint, getKeyService, @@ -36,6 +32,7 @@ import { getOutputChannel } from "./output"; import { ChatHandler } from "./webview/chat-handler"; import { KeyManagementHandler } from "./webview/key-management-handler"; import { SimulationHandler } from "./webview/simulation-handler"; +import { ScanPublishingHandler, type ExportDebugPayload } from "./webview/scan-publishing-handler"; async function resolveWorkspaceFileSafely( workspaceFolder: vscode.WorkspaceFolder, @@ -66,475 +63,6 @@ export async function collectLocalScanData( return { apiCalls, findings, totalFilesScanned }; } -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)}...`; -} - -function clampConfidence(value: number): number { - if (!Number.isFinite(value)) return 0; - if (value < 0) return 0; - if (value > 1) return 1; - return value; -} - - -function mapStatusToSuggestionType(status: EndpointRecord["status"]): Suggestion["type"] | null { - switch (status) { - case "cacheable": - return "cache"; - case "batchable": - return "batch"; - case "redundant": - return "redundancy"; - case "n_plus_one_risk": - return "n_plus_one"; - case "rate_limit_risk": - return "rate_limit"; - default: - return null; - } -} - -function chooseSeverity(status: EndpointRecord["status"], monthlyCost: number): Suggestion["severity"] { - if (status === "n_plus_one_risk" || status === "redundant") { - return monthlyCost >= 100 ? "high" : "medium"; - } - if (status === "rate_limit_risk") { - return monthlyCost >= 50 ? "high" : "medium"; - } - return monthlyCost >= 100 ? "medium" : "low"; -} - - -function confidenceFromEndpointStatus(endpoint: EndpointRecord): number { - const base = - endpoint.status === "n_plus_one_risk" ? 0.78 : - endpoint.status === "redundant" ? 0.72 : - endpoint.status === "rate_limit_risk" ? 0.7 : - endpoint.status === "cacheable" ? 0.66 : - endpoint.status === "batchable" ? 0.66 : - 0.55; - const perRequestBoost = endpoint.callSites.some((site) => site.frequency === "per-request") ? 0.07 : 0; - return clampConfidence(base + perRequestBoost); -} - -function buildAggressiveDescription(endpoint: EndpointRecord, type: Suggestion["type"]): string { - const firstSite = endpoint.callSites[0]; - const location = firstSite ? ` (${firstSite.file}:${firstSite.line})` : ""; - switch (type) { - case "cache": - return `Potential caching opportunity detected for \`${endpoint.method} ${endpoint.url}\`${location}. This endpoint appears cacheable; consider adding response caching with explicit TTL and cache invalidation rules to reduce repeated requests and cost.`; - case "batch": - return `Potential batching opportunity detected for \`${endpoint.method} ${endpoint.url}\`${location}. This endpoint appears in a pattern that may benefit from request batching or bulk-fetch patterns to reduce request volume.`; - case "redundancy": - return `Potential redundant API usage detected for \`${endpoint.method} ${endpoint.url}\`${location}. Multiple call paths may be invoking equivalent requests; consider deduping in-flight requests and consolidating repeated fetches.`; - case "n_plus_one": - return `Potential N+1 API pattern detected for \`${endpoint.method} ${endpoint.url}\`${location}. Review loop-driven request behavior and replace with prefetch/batch patterns where possible.`; - case "rate_limit": - return `Potential rate-limit risk detected for \`${endpoint.method} ${endpoint.url}\`${location}. Add throttling/backoff and request coalescing to reduce burst frequency and avoid provider limits.`; - default: - return `Potential optimization opportunity detected for \`${endpoint.method} ${endpoint.url}\`${location}.`; - } -} - -function buildAggressiveSuggestions( - endpoints: EndpointRecord[], - suggestions: Suggestion[], - localFindings: Awaited> -): Suggestion[] { - const existing = new Set(); - for (const suggestion of suggestions) { - for (const endpointId of suggestion.affectedEndpoints) { - existing.add(`${endpointId}:${suggestion.type}`); - } - } - - function normalizePath(p: string): string { - return p.replace(/\\/g, "/").replace(/^\.\//, ""); - } - - // Build a set of (type, file) pairs already covered by the waste detector. - // Aggressive suggestions for these will be suppressed so the richer finding wins. - const coveredByWaste = new Set(); - for (const finding of localFindings) { - coveredByWaste.add(`${finding.type}:${normalizePath(finding.affectedFile)}`); - } - - const extras: Suggestion[] = []; - for (const endpoint of endpoints) { - const type = mapStatusToSuggestionType(endpoint.status); - if (!type) continue; - - const dedupeKey = `${endpoint.id}:${type}`; - if (existing.has(dedupeKey)) continue; - - // Skip internal endpoints — they have no cost implications - if (endpoint.scope === "internal") continue; - - // If the waste detector already covers this type for any of this endpoint's files, - // suppress the aggressive suggestion — the waste detector finding has richer evidence. - const suppressedByWaste = endpoint.files.some((f) => - coveredByWaste.has(`${type}:${normalizePath(f)}`) - ); - if (suppressedByWaste) continue; - - extras.push({ - id: `local-${endpoint.id}-${type}`, - projectId: endpoint.projectId, - scanId: endpoint.scanId, - type, - severity: chooseSeverity(endpoint.status, endpoint.monthlyCost), - affectedEndpoints: [endpoint.id], - affectedFiles: endpoint.files, - estimatedMonthlySavings: calculateSavings(type, "medium", endpoint.monthlyCost), - description: buildAggressiveDescription(endpoint, type), - codeFix: "", - source: "local-rule", - confidence: confidenceFromEndpointStatus(endpoint), - evidence: endpoint.callSites.slice(0, 3).map((site) => `Observed callsite: ${site.file}:${site.line}`), - pricingClass: classifyPricing([endpoint.costModel]), - }); - } - - return [...suggestions, ...extras]; -} - -const PROXIMITY_THRESHOLD_LINES = 25; - -/** - * Find the endpoint whose call site is closest to the finding's line number. - * Only considers call sites within PROXIMITY_THRESHOLD_LINES of the finding. - * Falls back to null if no close match is found, allowing callers to use - * file-level cost as a fallback. - * - * TODO: Replace line-proximity threshold with function-scope matching once - * function boundary data is available at this point in the pipeline. Function - * scope is semantically more accurate — a finding and its triggering call site - * always share the same function body regardless of line distance. - */ -function findClosestEndpoint( - finding: { affectedFile: string; line?: number }, - fileEndpoints: EndpointRecord[] -): EndpointRecord | null { - if (!finding.line || fileEndpoints.length === 0) return null; - - let closest: EndpointRecord | null = null; - let closestDistance = Infinity; - - for (const ep of fileEndpoints) { - // Skip route-def endpoints — they have monthlyCost === 0 and would - // produce misleading $0 savings estimates - if (ep.monthlyCost === 0 && ep.callSites.every(s => s.library === "route-def")) continue; - - for (const site of ep.callSites) { - if (site.file !== finding.affectedFile) continue; - const distance = Math.abs(site.line - finding.line); - if (distance < closestDistance) { - closestDistance = distance; - closest = ep; - } - } - } - - return closestDistance <= PROXIMITY_THRESHOLD_LINES ? closest : null; -} - -function mergeLocalWasteFindings( - baseSuggestions: Suggestion[], - localFindings: Awaited>, - endpoints: EndpointRecord[], - projectId: string, - scanId: string -): Suggestion[] { - const existingByDescAndFile = new Set( - baseSuggestions.map((s) => `${s.description}::${s.affectedFiles[0] ?? ""}`) - ); - - const locals: Suggestion[] = []; - for (const finding of localFindings) { - if (finding.confidence < 0.35) continue; - - const key = `${finding.description}::${finding.affectedFile}`; - if (existingByDescAndFile.has(key)) continue; - existingByDescAndFile.add(key); - - const fileEndpoints = endpoints.filter((ep) => ep.files.includes(finding.affectedFile)); - const closestEndpoint = findClosestEndpoint(finding, fileEndpoints); - const directCost = closestEndpoint?.monthlyCost ?? 0; - const fileMonthlyCost = fileEndpoints.reduce((sum, ep) => sum + ep.monthlyCost, 0); - const baselineCost = directCost > 0 - ? directCost - : fileMonthlyCost > 0 - ? fileMonthlyCost - : 0; // unknown — no savings estimate - const estimatedMonthlySavings = calculateSavings(finding.type, finding.severity, baselineCost); - const pricingClass = classifyPricing(fileEndpoints.map((ep) => ep.costModel)); - - locals.push({ - id: finding.id, - projectId, - scanId, - type: finding.type, - severity: finding.severity, - affectedEndpoints: fileEndpoints.map((ep) => ep.id), - affectedFiles: [finding.affectedFile], - targetLine: finding.line, - estimatedMonthlySavings, - description: finding.description, - codeFix: "", - source: "local-rule", - confidence: finding.confidence, - evidence: finding.evidence, - pricingClass, - }); - } - - return [...baseSuggestions, ...locals]; -} - -const GENERIC_DYNAMIC_TOKENS = new Set(["endpoint", "url", "path", "uri", "route"]); -const OUTBOUND_LIBRARIES = new Set([ - "fetch", - "axios", - "got", - "superagent", - "ky", - "requests", - "http", - "HttpClient", - "$http", - "openai", -]); - -function isHighConfidenceEndpointUrl(url: string): boolean { - if (!url) return false; - if (/^https?:\/\//i.test(url)) return true; - if (url.startsWith("/")) return true; - if (/\$\{\s*(endpoint|url|path|uri|route)\s*\}/i.test(url)) return false; - const dynamic = url.match(/^]+)>$/i); - if (!dynamic) return false; - const token = dynamic[1].trim().toLowerCase(); - if (GENERIC_DYNAMIC_TOKENS.has(token)) return false; - // A naked dynamic base URL token is not an endpoint route. - if (/base[_-]?url/.test(token)) return false; - return /base[_-]?url|api|endpoint/i.test(token); -} - -function shouldSubmitRemote(call: ApiCallInput): boolean { - if (!call.library || !OUTBOUND_LIBRARIES.has(call.library)) return false; - return isHighConfidenceEndpointUrl(call.url); -} - -function shouldIncludeSynthetic(call: ApiCallInput): boolean { - if (!isHighConfidenceEndpointUrl(call.url)) return false; - if (call.library === "route-def" || call.library === "api-helper") return call.url.startsWith("/"); - return true; -} - -function normalizePathParams(url: string): string { - return url - .replace(/\$\{\s*[^}]+\s*\}/g, ":param") - .replace(/<[^>]+>/g, ":param") - .replace(/\{[^}]+\}/g, ":param"); -} - -function stripQueryAndHash(url: string): string { - const queryIdx = url.indexOf("?"); - const hashIdx = url.indexOf("#"); - const cutAt = - queryIdx >= 0 && hashIdx >= 0 ? Math.min(queryIdx, hashIdx) : - queryIdx >= 0 ? queryIdx : - hashIdx >= 0 ? hashIdx : - -1; - return cutAt >= 0 ? url.slice(0, cutAt) : url; -} - -function canonicalizeEndpointUrl(url: string): string { - const stripped = stripQueryAndHash(url.trim()); - return normalizePathParams(stripped); -} - -function isDynamicPlaceholderUrl(url: string): boolean { - return /^]+>$/i.test(url.trim()); -} - -function buildEndpointKey(method: string, url: string): string { - return `${method.toUpperCase()} ${canonicalizeEndpointUrl(url)}`; -} - -function pickDisplayUrl(current: string, candidate: string): string { - const currentCanonical = canonicalizeEndpointUrl(current); - const candidateCanonical = canonicalizeEndpointUrl(candidate); - - const score = (value: string): number => { - let s = 0; - if (!isDynamicPlaceholderUrl(value)) s += 3; - if (value === stripQueryAndHash(value)) s += 2; - if (value.includes(":param")) s += 1; - if (value.includes("/")) s += 1; - return s; - }; - - const currentScore = score(currentCanonical); - const candidateScore = score(candidateCanonical); - return candidateScore > currentScore ? candidateCanonical : currentCanonical; -} - -const FREQUENCY_SEVERITY: Record = { - polling: 6, - "unbounded-loop": 5, - parallel: 4, - "bounded-loop": 3, - conditional: 2, - "cache-guarded": 1, - single: 0, -}; - -function pickMostSevereFrequency(a: string | undefined, b: string | undefined): string | undefined { - if (!a && !b) return undefined; - if (!a) return b; - if (!b) return a; - return (FREQUENCY_SEVERITY[a] ?? 0) >= (FREQUENCY_SEVERITY[b] ?? 0) ? a : b; -} - -function mergeRemoteAndLocalEndpoints( - remote: EndpointRecord[], - localCalls: ApiCallInput[], - projectId: string, - scanId: string -): EndpointRecord[] { - const merged = remote.map((endpoint) => ({ - ...endpoint, - scope: endpoint.scope ?? classifyEndpointScope(endpoint.url), - })); - const byMethodUrl = new Map(); - for (const endpoint of merged) { - byMethodUrl.set(buildEndpointKey(endpoint.method, endpoint.url), endpoint); - } - - const syntheticByMethodUrl = new Map(); - for (const call of localCalls) { - if (!shouldIncludeSynthetic(call)) continue; - const key = buildEndpointKey(call.method, call.url); - if (byMethodUrl.has(key)) { - const endpoint = byMethodUrl.get(key)!; - endpoint.url = pickDisplayUrl(endpoint.url, call.url); - if (!endpoint.files.includes(call.file)) { - endpoint.files.push(call.file); - } - const hasSite = endpoint.callSites.some( - (site) => site.file === call.file && site.line === call.line && site.library === call.library - ); - if (!hasSite) { - endpoint.callSites.push({ - file: call.file, - line: call.line, - library: call.library ?? "", - frequency: call.frequency, - frequencyClass: call.frequencyClass, - crossFileOrigin: call.crossFileOrigin ?? null, - }); - } - // Propagate enriched fields to endpoint - if (!endpoint.methodSignature && call.methodSignature) endpoint.methodSignature = call.methodSignature; - if (!endpoint.costModel && call.costModel) endpoint.costModel = call.costModel; - endpoint.frequencyClass = pickMostSevereFrequency(endpoint.frequencyClass, call.frequencyClass); - if (call.batchCapable) endpoint.batchCapable = true; - if (call.cacheCapable) endpoint.cacheCapable = true; - if (call.streaming) endpoint.streaming = true; - if (call.isMiddleware) endpoint.isMiddleware = true; - if (call.crossFileOrigin) { - endpoint.crossFileOrigins = endpoint.crossFileOrigins ?? []; - endpoint.crossFileOrigins.push(call.crossFileOrigin); - } - continue; - } - - if (!syntheticByMethodUrl.has(key)) { - const canonicalUrl = canonicalizeEndpointUrl(call.url); - const provider = call.provider ?? detectEndpointProvider(canonicalUrl); - const callsPerDay = call.frequency === "per-request" ? 100 : call.library === "route-def" ? 0 : 1; - syntheticByMethodUrl.set(key, { - id: `local-${scanId}-${syntheticByMethodUrl.size + 1}`, - projectId, - scanId, - provider, - method: call.method, - url: canonicalUrl, - scope: classifyEndpointScope(canonicalUrl), - files: [call.file], - callSites: [{ - file: call.file, - line: call.line, - library: call.library ?? "", - frequency: call.frequency, - frequencyClass: call.frequencyClass, - crossFileOrigin: call.crossFileOrigin ?? null, - }], - callsPerDay, - monthlyCost: estimateLocalMonthlyCost(provider, callsPerDay, call.methodSignature) ?? 0, - status: - call.frequency === "per-request" - ? "n_plus_one_risk" - : call.library === "route-def" - ? "normal" - : "normal", - methodSignature: call.methodSignature, - costModel: call.costModel, - frequencyClass: call.frequencyClass, - batchCapable: call.batchCapable, - cacheCapable: call.cacheCapable, - streaming: call.streaming, - isMiddleware: call.isMiddleware, - crossFileOrigins: call.crossFileOrigin ? [call.crossFileOrigin] : undefined, - }); - continue; - } - - const synthetic = syntheticByMethodUrl.get(key)!; - synthetic.url = pickDisplayUrl(synthetic.url, call.url); - synthetic.scope = classifyEndpointScope(synthetic.url); - synthetic.provider = call.provider ?? detectEndpointProvider(synthetic.url); - if (!synthetic.files.includes(call.file)) { - synthetic.files.push(call.file); - } - const hasSite = synthetic.callSites.some( - (site) => site.file === call.file && site.line === call.line && site.library === call.library - ); - if (!hasSite) { - synthetic.callSites.push({ - file: call.file, - line: call.line, - library: call.library ?? "", - frequency: call.frequency, - frequencyClass: call.frequencyClass, - crossFileOrigin: call.crossFileOrigin ?? null, - }); - } - if (call.frequency === "per-request") { - synthetic.status = "n_plus_one_risk"; - synthetic.callsPerDay = Math.max(synthetic.callsPerDay, 100); - } - if (!synthetic.methodSignature && call.methodSignature) synthetic.methodSignature = call.methodSignature; - if (!synthetic.costModel && call.costModel) synthetic.costModel = call.costModel; - synthetic.frequencyClass = pickMostSevereFrequency(synthetic.frequencyClass, call.frequencyClass); - if (call.batchCapable) synthetic.batchCapable = true; - if (call.cacheCapable) synthetic.cacheCapable = true; - if (call.streaming) synthetic.streaming = true; - if (call.isMiddleware) synthetic.isMiddleware = true; - if (call.crossFileOrigin) { - synthetic.crossFileOrigins = synthetic.crossFileOrigins ?? []; - synthetic.crossFileOrigins.push(call.crossFileOrigin); - } - } - - return [...merged, ...syntheticByMethodUrl.values()] - .filter((ep) => ep.scope !== "internal"); -} export interface WebviewMessageHandlers { startScan(): Promise; @@ -622,6 +150,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { private readonly chatHandler: ChatHandler; private readonly keyManagementHandler: KeyManagementHandler; private readonly simulationHandler: SimulationHandler; + private readonly scanPublishingHandler: ScanPublishingHandler; private readonly outputChannel: vscode.OutputChannel; private readonly projectIdCheckingState = new Set(); @@ -648,29 +177,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { return path.join(os.tmpdir(), `recost-extension-scan-results-${workspaceName}.json`); } - private async exportDebugScanResults(payload: { - mode: "local-only" | "remote-enriched"; - scannedFiles: string[]; - local: { - apiCalls: ApiCallInput[]; - localWasteFindings: Awaited>; - submittedRemoteApiCalls: ApiCallInput[]; - }; - remote: null | { - projectId: string; - scanId: string; - endpoints: EndpointRecord[]; - suggestions: Suggestion[]; - summary: ScanSummary; - }; - final: { - projectId: string; - scanId: string; - endpoints: EndpointRecord[]; - suggestions: Suggestion[]; - summary: ScanSummary; - }; - }): Promise { + private async exportDebugScanResults(payload: ExportDebugPayload): Promise { const exportPath = this.getDebugScanExportPath(); const body = { exportedAt: new Date().toISOString(), @@ -725,6 +232,27 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { sendKeyStatusUpdate: (serviceId, focusServiceId) => this.sendKeyStatusUpdate(serviceId, focusServiceId), openKeys: (focusServiceId) => this.openKeys(focusServiceId), }); + this.scanPublishingHandler = new ScanPublishingHandler({ + postMessage: (m) => this.postMessage(m), + context: this.context, + setLastEndpoints: (endpoints) => { this.lastEndpoints = endpoints; }, + setLastSuggestions: (suggestions) => { this.lastSuggestions = suggestions; }, + setLastSummary: (summary) => { this.lastSummary = summary; }, + setLastApiCalls: (calls) => { this.lastApiCalls = calls; }, + setLastFindings: (findings) => { this.lastFindings = findings; }, + setProjectId: (id) => { this.projectId = id; }, + getProjectId: () => this.projectId, + getManualProjectId: () => this.getManualProjectId(), + getRcApiKey: () => this.getRcApiKey(), + resolveScanProjectTarget: (rcApiKey) => this.resolveScanProjectTarget(rcApiKey), + getWorkspaceName: () => this.getWorkspaceName(), + openKeys: (focusServiceId) => this.openKeys(focusServiceId), + setRecostValidationState: (snapshot) => this.setValidationState("recost", snapshot), + clearRecostValidationState: () => this.clearValidationState("recost"), + sendRecostKeyStatusUpdate: () => this.sendKeyStatusUpdate("recost", "recost"), + resetChatHistory: () => this.chatHandler.resetHistory(), + exportDebugScanResults: (payload) => this.exportDebugScanResults(payload), + }); } resolveWebviewView( @@ -987,7 +515,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { private async handleMessage(message: WebviewMessage): Promise { await dispatchWebviewMessage(message, { - startScan: () => this.handleStartScan(), + startScan: () => this.scanPublishingHandler.handleStartScan(), runAiReview: () => this.handleRunAiReview(), chat: (text, provider, model) => this.handleChat(text, provider, model), modelChanged: async (provider, model) => { @@ -1051,277 +579,6 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { } } - private async handleStartScan() { - await vscode.commands.executeCommand("setContext", "recost.scanning", true); - try { - this.chatHandler.resetHistory(); - const scannedFiles = (await getWorkspaceScanFiles()).map((file) => file.relativePath); - - const apiCalls = await scanWorkspace((progress) => { - this.postMessage({ - type: "scanProgress", - stage: "scanning", - file: progress.file, - fileIndex: progress.fileIndex, - fileTotal: progress.fileTotal, - }); - }); - - this.postMessage({ type: "scanProgress", stage: "analyzing" }); - this.postMessage({ type: "scanProgress", stage: "detecting" }); - const localWasteFindings = await detectLocalWastePatterns(); - this.lastApiCalls = apiCalls; - this.lastFindings = localWasteFindings; - this.postMessage({ type: "scanProgress", stage: "resolving" }); - - if (process.env.RECOST_INTELLIGENCE_DEBUG === "1") { - const totalFilesScanned = await countScopedWorkspaceFiles(); - const snapshot = buildSnapshot({ - apiCalls, - findings: localWasteFindings, - totalFilesScanned, - }); - const scored = scoreSnapshot(snapshot); - const ch = getOutputChannel(); - for (const file of scored.scoredFiles.slice(0, 5)) { - ch.appendLine( - `[intelligence] ${file.filePath} | priority=${file.scores.aiReviewPriority.toFixed(2)} | ` + - `importance=${file.scores.importance.toFixed(2)} | ` + - `costLeak=${file.scores.costLeak.toFixed(2)} | ` + - `reliabilityRisk=${file.scores.reliabilityRisk.toFixed(2)} | ` + - `reasons=${file.reasons.join("; ")}` - ); - } - } - - this.postMessage({ type: "scanComplete" }); - - const publishLocalOnlyResults = (localProjectId: string, localScanId: string) => { - const endpoints = mergeRemoteAndLocalEndpoints([], apiCalls, localProjectId, localScanId); - const mergedSuggestions = mergeLocalWasteFindings( - [], - localWasteFindings, - endpoints, - localProjectId, - localScanId - ); - const summary: ScanSummary = { - totalEndpoints: endpoints.length, - totalCallsPerDay: endpoints.reduce((sum, ep) => sum + ep.callsPerDay, 0), - totalMonthlyCost: endpoints.reduce((sum, ep) => sum + ep.monthlyCost, 0), - highRiskCount: mergedSuggestions.filter((s) => s.severity === "high").length, - }; - - const externalEndpoints = endpoints.filter((ep) => ep.scope !== "internal"); - this.lastEndpoints = externalEndpoints; - this.lastSuggestions = mergedSuggestions; - this.lastSummary = { ...summary, totalEndpoints: externalEndpoints.length }; - this.postMessage({ - type: "scanResults", - endpoints: externalEndpoints, - suggestions: mergedSuggestions, - summary: { ...summary, totalEndpoints: externalEndpoints.length }, - }); - void this.exportDebugScanResults({ - mode: "local-only", - scannedFiles, - local: { - apiCalls, - localWasteFindings, - submittedRemoteApiCalls: [], - }, - remote: null, - final: { - projectId: localProjectId, - scanId: localScanId, - endpoints, - suggestions: mergedSuggestions, - summary, - }, - }); - }; - - if (apiCalls.length === 0) { - this.lastEndpoints = []; - this.lastSuggestions = []; - this.lastSummary = { - totalEndpoints: 0, - totalCallsPerDay: 0, - totalMonthlyCost: 0, - highRiskCount: 0, - }; - this.postMessage({ - type: "scanResults", - endpoints: [], - suggestions: [], - summary: this.lastSummary, - }); - void this.exportDebugScanResults({ - mode: "local-only", - scannedFiles, - local: { - apiCalls, - localWasteFindings, - submittedRemoteApiCalls: [], - }, - remote: null, - final: { - projectId: "local", - scanId: `local-${Date.now()}`, - endpoints: [], - suggestions: [], - summary: this.lastSummary, - }, - }); - return; - } - - // Ensure we have a project on the remote API - const manualProjectId = this.getManualProjectId(); - let rcApiKey = await this.getRcApiKey(); - if (!rcApiKey) { - publishLocalOnlyResults(manualProjectId ?? this.projectId ?? "local", `local-${Date.now()}`); - this.postMessage({ - type: "scanNotification", - message: "No ReCost API key — showing local results only. Add a key in Keys to enable remote sync.", - }); - return; - } - // Submit scan and fetch results - // Ensure every call has a provider — fall back to URL-based detection, then skip if still unknown. - const remoteApiCalls = apiCalls - .filter(shouldSubmitRemote) - .map((call) => ({ - ...call, - provider: call.provider ?? detectEndpointProvider(canonicalizeEndpointUrl(call.url)) ?? "unknown", - })) - .filter((call) => call.provider !== "unknown"); - if (remoteApiCalls.length === 0) { - publishLocalOnlyResults(manualProjectId ?? this.projectId ?? "local", `local-${Date.now()}`); - return; - } - - // Show local results immediately so UI unblocks, then update with remote - publishLocalOnlyResults(manualProjectId ?? this.projectId ?? "local", `local-${Date.now()}`); - - try { - const projectTarget = await this.resolveScanProjectTarget(rcApiKey); - let projectId = projectTarget.projectId; - let scanResult; - try { - scanResult = await submitScan(projectId, remoteApiCalls, rcApiKey); - } catch (err: unknown) { - // Project may have been deleted, create a fresh one and retry once. - if ((err as { status?: number }).status === 404 && projectTarget.source === "auto") { - const freshId = await createProject(this.getWorkspaceName(), rcApiKey); - this.projectId = freshId; - projectId = freshId; - await this.context.globalState.update("recost.projectId", freshId); - scanResult = await submitScan(projectId, remoteApiCalls, rcApiKey); - } else { - throw err; - } - } - - const [remoteEndpoints, suggestions] = await Promise.all([ - getAllEndpoints(projectId, scanResult.scanId, rcApiKey), - getAllSuggestions(projectId, scanResult.scanId, rcApiKey), - ]); - const taggedRemoteSuggestions = suggestions.map((s) => ({ ...s, source: s.source ?? "remote" })); - - const endpoints = mergeRemoteAndLocalEndpoints(remoteEndpoints, apiCalls, projectId, scanResult.scanId); - const externalEndpoints = endpoints.filter((ep) => ep.scope !== "internal"); - this.lastEndpoints = externalEndpoints; - const aggressiveSuggestions = buildAggressiveSuggestions(endpoints, taggedRemoteSuggestions, localWasteFindings); - const mergedSuggestions = mergeLocalWasteFindings( - aggressiveSuggestions, - localWasteFindings, - endpoints, - projectId, - scanResult.scanId - ); - this.lastSuggestions = mergedSuggestions; - this.lastSummary = { ...scanResult.summary, totalEndpoints: externalEndpoints.length }; - - this.postMessage({ - type: "scanResults", - endpoints: externalEndpoints, - suggestions: mergedSuggestions, - summary: { - ...scanResult.summary, - totalEndpoints: externalEndpoints.length, - }, - }); - void this.exportDebugScanResults({ - mode: "remote-enriched", - scannedFiles, - local: { - apiCalls, - localWasteFindings, - submittedRemoteApiCalls: remoteApiCalls, - }, - remote: { - projectId, - scanId: scanResult.scanId, - endpoints: remoteEndpoints, - suggestions, - summary: scanResult.summary, - }, - final: { - projectId, - scanId: scanResult.scanId, - endpoints, - suggestions: mergedSuggestions, - summary: { - ...scanResult.summary, - totalEndpoints: Math.max(scanResult.summary.totalEndpoints, endpoints.length), - }, - }, - }); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Remote analysis failed"; - const status = (err as { status?: number }).status; - const authLikeFailure = - status === 401 || - (status === 403 && /invalid|unauthori[sz]ed|forbidden|auth/i.test(message)); - - if (authLikeFailure) { - const rcApiKey = await this.getRcApiKey(); - if (rcApiKey) { - await this.setValidationState("recost", { - state: "invalid", - message, - lastCheckedAt: new Date().toISOString(), - keyFingerprint: buildKeyFingerprint(rcApiKey), - }); - } else { - await this.clearValidationState("recost"); - } - await this.sendKeyStatusUpdate("recost", "recost"); - this.openKeys("recost"); - } - publishLocalOnlyResults(manualProjectId ?? this.projectId ?? "local", `local-${Date.now()}`); - if (status === 404 && manualProjectId) { - this.postMessage({ - type: "scanNotification", - message: `Project ID ${manualProjectId} was not found. Keeping the saved manual Project ID and showing local results.`, - }); - return; - } - if (err instanceof Error && err.message === "fetch failed") { - this.postMessage({ - type: "scanNotification", - message: "Could not reach ReCost server. Showing local results.", - }); - } - } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Unknown error during scan"; - this.postMessage({ type: "error", message }); - } finally { - await vscode.commands.executeCommand("setContext", "recost.scanning", false); - } - } private handleRunAiReview() { return this.chatHandler.handleRunAiReview(); diff --git a/src/webview/scan-publishing-handler.ts b/src/webview/scan-publishing-handler.ts new file mode 100644 index 0000000..13f8307 --- /dev/null +++ b/src/webview/scan-publishing-handler.ts @@ -0,0 +1,788 @@ +import * as vscode from "vscode"; +import { + scanWorkspace, + detectLocalWastePatterns, + countScopedWorkspaceFiles, + getWorkspaceScanFiles, +} from "../scanner/workspace-scanner"; +import { createProject, submitScan, getAllEndpoints, getAllSuggestions } from "../api-client"; +import type { HostMessage, KeyServiceId } from "../messages"; +import type { ApiCallInput, EndpointRecord, Suggestion, ScanSummary } from "../analysis/types"; +import { classifyEndpointScope, detectEndpointProvider } from "../scanner/endpoint-classification"; +import { classifyPricing, calculateSavings } from "../scan-results"; +import { buildSnapshot } from "../intelligence/builder"; +import { scoreSnapshot } from "../intelligence/scorer"; +import { estimateLocalMonthlyCost } from "../intelligence/cost-utils"; +import { buildKeyFingerprint, type PersistedKeyValidationSnapshot } from "../key-management"; +import { getOutputChannel } from "../output"; + +export interface ExportDebugPayload { + mode: "local-only" | "remote-enriched"; + scannedFiles: string[]; + local: { + apiCalls: ApiCallInput[]; + localWasteFindings: Awaited>; + submittedRemoteApiCalls: ApiCallInput[]; + }; + remote: null | { + projectId: string; + scanId: string; + endpoints: EndpointRecord[]; + suggestions: Suggestion[]; + summary: ScanSummary; + }; + final: { + projectId: string; + scanId: string; + endpoints: EndpointRecord[]; + suggestions: Suggestion[]; + summary: ScanSummary; + }; +} + +export interface ScanPublishingHandlerContext { + postMessage(message: HostMessage): void; + context: vscode.ExtensionContext; + setLastEndpoints(endpoints: EndpointRecord[]): void; + setLastSuggestions(suggestions: Suggestion[]): void; + setLastSummary(summary: ScanSummary | null): void; + setLastApiCalls(calls: ApiCallInput[]): void; + setLastFindings(findings: Awaited>): void; + setProjectId(id: string | null): void; + getProjectId(): string | null; + getManualProjectId(): string | null; + getRcApiKey(): Promise; + resolveScanProjectTarget(rcApiKey: string): Promise<{ projectId: string; source: "manual" | "auto" }>; + getWorkspaceName(): string; + openKeys(focusServiceId?: KeyServiceId): void; + setRecostValidationState(snapshot: PersistedKeyValidationSnapshot): Promise; + clearRecostValidationState(): Promise; + sendRecostKeyStatusUpdate(): Promise; + resetChatHistory(): void; + exportDebugScanResults(payload: ExportDebugPayload): Promise; +} + +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)}...`; +} + +function clampConfidence(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value < 0) return 0; + if (value > 1) return 1; + return value; +} + +function mapStatusToSuggestionType(status: EndpointRecord["status"]): Suggestion["type"] | null { + switch (status) { + case "cacheable": + return "cache"; + case "batchable": + return "batch"; + case "redundant": + return "redundancy"; + case "n_plus_one_risk": + return "n_plus_one"; + case "rate_limit_risk": + return "rate_limit"; + default: + return null; + } +} + +function chooseSeverity(status: EndpointRecord["status"], monthlyCost: number): Suggestion["severity"] { + if (status === "n_plus_one_risk" || status === "redundant") { + return monthlyCost >= 100 ? "high" : "medium"; + } + if (status === "rate_limit_risk") { + return monthlyCost >= 50 ? "high" : "medium"; + } + return monthlyCost >= 100 ? "medium" : "low"; +} + +function confidenceFromEndpointStatus(endpoint: EndpointRecord): number { + const base = + endpoint.status === "n_plus_one_risk" ? 0.78 : + endpoint.status === "redundant" ? 0.72 : + endpoint.status === "rate_limit_risk" ? 0.7 : + endpoint.status === "cacheable" ? 0.66 : + endpoint.status === "batchable" ? 0.66 : + 0.55; + const perRequestBoost = endpoint.callSites.some((site) => site.frequency === "per-request") ? 0.07 : 0; + return clampConfidence(base + perRequestBoost); +} + +function buildAggressiveDescription(endpoint: EndpointRecord, type: Suggestion["type"]): string { + const firstSite = endpoint.callSites[0]; + const location = firstSite ? ` (${firstSite.file}:${firstSite.line})` : ""; + switch (type) { + case "cache": + return `Potential caching opportunity detected for \`${endpoint.method} ${endpoint.url}\`${location}. This endpoint appears cacheable; consider adding response caching with explicit TTL and cache invalidation rules to reduce repeated requests and cost.`; + case "batch": + return `Potential batching opportunity detected for \`${endpoint.method} ${endpoint.url}\`${location}. This endpoint appears in a pattern that may benefit from request batching or bulk-fetch patterns to reduce request volume.`; + case "redundancy": + return `Potential redundant API usage detected for \`${endpoint.method} ${endpoint.url}\`${location}. Multiple call paths may be invoking equivalent requests; consider deduping in-flight requests and consolidating repeated fetches.`; + case "n_plus_one": + return `Potential N+1 API pattern detected for \`${endpoint.method} ${endpoint.url}\`${location}. Review loop-driven request behavior and replace with prefetch/batch patterns where possible.`; + case "rate_limit": + return `Potential rate-limit risk detected for \`${endpoint.method} ${endpoint.url}\`${location}. Add throttling/backoff and request coalescing to reduce burst frequency and avoid provider limits.`; + default: + return `Potential optimization opportunity detected for \`${endpoint.method} ${endpoint.url}\`${location}.`; + } +} + +function buildAggressiveSuggestions( + endpoints: EndpointRecord[], + suggestions: Suggestion[], + localFindings: Awaited> +): Suggestion[] { + const existing = new Set(); + for (const suggestion of suggestions) { + for (const endpointId of suggestion.affectedEndpoints) { + existing.add(`${endpointId}:${suggestion.type}`); + } + } + + function normalizePath(p: string): string { + return p.replace(/\\/g, "/").replace(/^\.\//, ""); + } + + const coveredByWaste = new Set(); + for (const finding of localFindings) { + coveredByWaste.add(`${finding.type}:${normalizePath(finding.affectedFile)}`); + } + + const extras: Suggestion[] = []; + for (const endpoint of endpoints) { + const type = mapStatusToSuggestionType(endpoint.status); + if (!type) continue; + + const dedupeKey = `${endpoint.id}:${type}`; + if (existing.has(dedupeKey)) continue; + + if (endpoint.scope === "internal") continue; + + const suppressedByWaste = endpoint.files.some((f) => + coveredByWaste.has(`${type}:${normalizePath(f)}`) + ); + if (suppressedByWaste) continue; + + extras.push({ + id: `local-${endpoint.id}-${type}`, + projectId: endpoint.projectId, + scanId: endpoint.scanId, + type, + severity: chooseSeverity(endpoint.status, endpoint.monthlyCost), + affectedEndpoints: [endpoint.id], + affectedFiles: endpoint.files, + estimatedMonthlySavings: calculateSavings(type, "medium", endpoint.monthlyCost), + description: buildAggressiveDescription(endpoint, type), + codeFix: "", + source: "local-rule", + confidence: confidenceFromEndpointStatus(endpoint), + evidence: endpoint.callSites.slice(0, 3).map((site) => `Observed callsite: ${site.file}:${site.line}`), + pricingClass: classifyPricing([endpoint.costModel]), + }); + } + + return [...suggestions, ...extras]; +} + +const PROXIMITY_THRESHOLD_LINES = 25; + +function findClosestEndpoint( + finding: { affectedFile: string; line?: number }, + fileEndpoints: EndpointRecord[] +): EndpointRecord | null { + if (!finding.line || fileEndpoints.length === 0) return null; + + let closest: EndpointRecord | null = null; + let closestDistance = Infinity; + + for (const ep of fileEndpoints) { + if (ep.monthlyCost === 0 && ep.callSites.every(s => s.library === "route-def")) continue; + + for (const site of ep.callSites) { + if (site.file !== finding.affectedFile) continue; + const distance = Math.abs(site.line - finding.line); + if (distance < closestDistance) { + closestDistance = distance; + closest = ep; + } + } + } + + return closestDistance <= PROXIMITY_THRESHOLD_LINES ? closest : null; +} + +function mergeLocalWasteFindings( + baseSuggestions: Suggestion[], + localFindings: Awaited>, + endpoints: EndpointRecord[], + projectId: string, + scanId: string +): Suggestion[] { + const existingByDescAndFile = new Set( + baseSuggestions.map((s) => `${s.description}::${s.affectedFiles[0] ?? ""}`) + ); + + const locals: Suggestion[] = []; + for (const finding of localFindings) { + if (finding.confidence < 0.35) continue; + + const key = `${finding.description}::${finding.affectedFile}`; + if (existingByDescAndFile.has(key)) continue; + existingByDescAndFile.add(key); + + const fileEndpoints = endpoints.filter((ep) => ep.files.includes(finding.affectedFile)); + const closestEndpoint = findClosestEndpoint(finding, fileEndpoints); + const directCost = closestEndpoint?.monthlyCost ?? 0; + const fileMonthlyCost = fileEndpoints.reduce((sum, ep) => sum + ep.monthlyCost, 0); + const baselineCost = directCost > 0 + ? directCost + : fileMonthlyCost > 0 + ? fileMonthlyCost + : 0; + const estimatedMonthlySavings = calculateSavings(finding.type, finding.severity, baselineCost); + const pricingClass = classifyPricing(fileEndpoints.map((ep) => ep.costModel)); + + locals.push({ + id: finding.id, + projectId, + scanId, + type: finding.type, + severity: finding.severity, + affectedEndpoints: fileEndpoints.map((ep) => ep.id), + affectedFiles: [finding.affectedFile], + targetLine: finding.line, + estimatedMonthlySavings, + description: finding.description, + codeFix: "", + source: "local-rule", + confidence: finding.confidence, + evidence: finding.evidence, + pricingClass, + }); + } + + return [...baseSuggestions, ...locals]; +} + +// Silence unused-helper warnings while keeping the helpers available for future +// callers of mergeLocalWasteFindings or buildAggressiveSuggestions that may need them. +void normalizeDescription; +void trimText; + +const GENERIC_DYNAMIC_TOKENS = new Set(["endpoint", "url", "path", "uri", "route"]); +const OUTBOUND_LIBRARIES = new Set([ + "fetch", + "axios", + "got", + "superagent", + "ky", + "requests", + "http", + "HttpClient", + "$http", + "openai", +]); + +function isHighConfidenceEndpointUrl(url: string): boolean { + if (!url) return false; + if (/^https?:\/\//i.test(url)) return true; + if (url.startsWith("/")) return true; + if (/\$\{\s*(endpoint|url|path|uri|route)\s*\}/i.test(url)) return false; + const dynamic = url.match(/^]+)>$/i); + if (!dynamic) return false; + const token = dynamic[1].trim().toLowerCase(); + if (GENERIC_DYNAMIC_TOKENS.has(token)) return false; + if (/base[_-]?url/.test(token)) return false; + return /base[_-]?url|api|endpoint/i.test(token); +} + +function shouldSubmitRemote(call: ApiCallInput): boolean { + if (!call.library || !OUTBOUND_LIBRARIES.has(call.library)) return false; + return isHighConfidenceEndpointUrl(call.url); +} + +function shouldIncludeSynthetic(call: ApiCallInput): boolean { + if (!isHighConfidenceEndpointUrl(call.url)) return false; + if (call.library === "route-def" || call.library === "api-helper") return call.url.startsWith("/"); + return true; +} + +function normalizePathParams(url: string): string { + return url + .replace(/\$\{\s*[^}]+\s*\}/g, ":param") + .replace(/<[^>]+>/g, ":param") + .replace(/\{[^}]+\}/g, ":param"); +} + +function stripQueryAndHash(url: string): string { + const queryIdx = url.indexOf("?"); + const hashIdx = url.indexOf("#"); + const cutAt = + queryIdx >= 0 && hashIdx >= 0 ? Math.min(queryIdx, hashIdx) : + queryIdx >= 0 ? queryIdx : + hashIdx >= 0 ? hashIdx : + -1; + return cutAt >= 0 ? url.slice(0, cutAt) : url; +} + +function canonicalizeEndpointUrl(url: string): string { + const stripped = stripQueryAndHash(url.trim()); + return normalizePathParams(stripped); +} + +function isDynamicPlaceholderUrl(url: string): boolean { + return /^]+>$/i.test(url.trim()); +} + +function buildEndpointKey(method: string, url: string): string { + return `${method.toUpperCase()} ${canonicalizeEndpointUrl(url)}`; +} + +function pickDisplayUrl(current: string, candidate: string): string { + const currentCanonical = canonicalizeEndpointUrl(current); + const candidateCanonical = canonicalizeEndpointUrl(candidate); + + const score = (value: string): number => { + let s = 0; + if (!isDynamicPlaceholderUrl(value)) s += 3; + if (value === stripQueryAndHash(value)) s += 2; + if (value.includes(":param")) s += 1; + if (value.includes("/")) s += 1; + return s; + }; + + const currentScore = score(currentCanonical); + const candidateScore = score(candidateCanonical); + return candidateScore > currentScore ? candidateCanonical : currentCanonical; +} + +const FREQUENCY_SEVERITY: Record = { + polling: 6, + "unbounded-loop": 5, + parallel: 4, + "bounded-loop": 3, + conditional: 2, + "cache-guarded": 1, + single: 0, +}; + +function pickMostSevereFrequency(a: string | undefined, b: string | undefined): string | undefined { + if (!a && !b) return undefined; + if (!a) return b; + if (!b) return a; + return (FREQUENCY_SEVERITY[a] ?? 0) >= (FREQUENCY_SEVERITY[b] ?? 0) ? a : b; +} + +function mergeRemoteAndLocalEndpoints( + remote: EndpointRecord[], + localCalls: ApiCallInput[], + projectId: string, + scanId: string +): EndpointRecord[] { + const merged = remote.map((endpoint) => ({ + ...endpoint, + scope: endpoint.scope ?? classifyEndpointScope(endpoint.url), + })); + const byMethodUrl = new Map(); + for (const endpoint of merged) { + byMethodUrl.set(buildEndpointKey(endpoint.method, endpoint.url), endpoint); + } + + const syntheticByMethodUrl = new Map(); + for (const call of localCalls) { + if (!shouldIncludeSynthetic(call)) continue; + const key = buildEndpointKey(call.method, call.url); + if (byMethodUrl.has(key)) { + const endpoint = byMethodUrl.get(key)!; + endpoint.url = pickDisplayUrl(endpoint.url, call.url); + if (!endpoint.files.includes(call.file)) { + endpoint.files.push(call.file); + } + const hasSite = endpoint.callSites.some( + (site) => site.file === call.file && site.line === call.line && site.library === call.library + ); + if (!hasSite) { + endpoint.callSites.push({ + file: call.file, + line: call.line, + library: call.library ?? "", + frequency: call.frequency, + frequencyClass: call.frequencyClass, + crossFileOrigin: call.crossFileOrigin ?? null, + }); + } + if (!endpoint.methodSignature && call.methodSignature) endpoint.methodSignature = call.methodSignature; + if (!endpoint.costModel && call.costModel) endpoint.costModel = call.costModel; + endpoint.frequencyClass = pickMostSevereFrequency(endpoint.frequencyClass, call.frequencyClass); + if (call.batchCapable) endpoint.batchCapable = true; + if (call.cacheCapable) endpoint.cacheCapable = true; + if (call.streaming) endpoint.streaming = true; + if (call.isMiddleware) endpoint.isMiddleware = true; + if (call.crossFileOrigin) { + endpoint.crossFileOrigins = endpoint.crossFileOrigins ?? []; + endpoint.crossFileOrigins.push(call.crossFileOrigin); + } + continue; + } + + if (!syntheticByMethodUrl.has(key)) { + const canonicalUrl = canonicalizeEndpointUrl(call.url); + const provider = call.provider ?? detectEndpointProvider(canonicalUrl); + const callsPerDay = call.frequency === "per-request" ? 100 : call.library === "route-def" ? 0 : 1; + syntheticByMethodUrl.set(key, { + id: `local-${scanId}-${syntheticByMethodUrl.size + 1}`, + projectId, + scanId, + provider, + method: call.method, + url: canonicalUrl, + scope: classifyEndpointScope(canonicalUrl), + files: [call.file], + callSites: [{ + file: call.file, + line: call.line, + library: call.library ?? "", + frequency: call.frequency, + frequencyClass: call.frequencyClass, + crossFileOrigin: call.crossFileOrigin ?? null, + }], + callsPerDay, + monthlyCost: estimateLocalMonthlyCost(provider, callsPerDay, call.methodSignature) ?? 0, + status: + call.frequency === "per-request" + ? "n_plus_one_risk" + : call.library === "route-def" + ? "normal" + : "normal", + methodSignature: call.methodSignature, + costModel: call.costModel, + frequencyClass: call.frequencyClass, + batchCapable: call.batchCapable, + cacheCapable: call.cacheCapable, + streaming: call.streaming, + isMiddleware: call.isMiddleware, + crossFileOrigins: call.crossFileOrigin ? [call.crossFileOrigin] : undefined, + }); + continue; + } + + const synthetic = syntheticByMethodUrl.get(key)!; + synthetic.url = pickDisplayUrl(synthetic.url, call.url); + synthetic.scope = classifyEndpointScope(synthetic.url); + synthetic.provider = call.provider ?? detectEndpointProvider(synthetic.url); + if (!synthetic.files.includes(call.file)) { + synthetic.files.push(call.file); + } + const hasSite = synthetic.callSites.some( + (site) => site.file === call.file && site.line === call.line && site.library === call.library + ); + if (!hasSite) { + synthetic.callSites.push({ + file: call.file, + line: call.line, + library: call.library ?? "", + frequency: call.frequency, + frequencyClass: call.frequencyClass, + crossFileOrigin: call.crossFileOrigin ?? null, + }); + } + if (call.frequency === "per-request") { + synthetic.status = "n_plus_one_risk"; + synthetic.callsPerDay = Math.max(synthetic.callsPerDay, 100); + } + if (!synthetic.methodSignature && call.methodSignature) synthetic.methodSignature = call.methodSignature; + if (!synthetic.costModel && call.costModel) synthetic.costModel = call.costModel; + synthetic.frequencyClass = pickMostSevereFrequency(synthetic.frequencyClass, call.frequencyClass); + if (call.batchCapable) synthetic.batchCapable = true; + if (call.cacheCapable) synthetic.cacheCapable = true; + if (call.streaming) synthetic.streaming = true; + if (call.isMiddleware) synthetic.isMiddleware = true; + if (call.crossFileOrigin) { + synthetic.crossFileOrigins = synthetic.crossFileOrigins ?? []; + synthetic.crossFileOrigins.push(call.crossFileOrigin); + } + } + + return [...merged, ...syntheticByMethodUrl.values()] + .filter((ep) => ep.scope !== "internal"); +} + +export class ScanPublishingHandler { + constructor(private readonly ctx: ScanPublishingHandlerContext) {} + + public async handleStartScan() { + await vscode.commands.executeCommand("setContext", "recost.scanning", true); + try { + this.ctx.resetChatHistory(); + const scannedFiles = (await getWorkspaceScanFiles()).map((file) => file.relativePath); + + const apiCalls = await scanWorkspace((progress) => { + this.ctx.postMessage({ + type: "scanProgress", + stage: "scanning", + file: progress.file, + fileIndex: progress.fileIndex, + fileTotal: progress.fileTotal, + }); + }); + + this.ctx.postMessage({ type: "scanProgress", stage: "analyzing" }); + this.ctx.postMessage({ type: "scanProgress", stage: "detecting" }); + const localWasteFindings = await detectLocalWastePatterns(); + this.ctx.setLastApiCalls(apiCalls); + this.ctx.setLastFindings(localWasteFindings); + this.ctx.postMessage({ type: "scanProgress", stage: "resolving" }); + + if (process.env.RECOST_INTELLIGENCE_DEBUG === "1") { + const totalFilesScanned = await countScopedWorkspaceFiles(); + const snapshot = buildSnapshot({ + apiCalls, + findings: localWasteFindings, + totalFilesScanned, + }); + const scored = scoreSnapshot(snapshot); + const ch = getOutputChannel(); + for (const file of scored.scoredFiles.slice(0, 5)) { + ch.appendLine( + `[intelligence] ${file.filePath} | priority=${file.scores.aiReviewPriority.toFixed(2)} | ` + + `importance=${file.scores.importance.toFixed(2)} | ` + + `costLeak=${file.scores.costLeak.toFixed(2)} | ` + + `reliabilityRisk=${file.scores.reliabilityRisk.toFixed(2)} | ` + + `reasons=${file.reasons.join("; ")}` + ); + } + } + + this.ctx.postMessage({ type: "scanComplete" }); + + const publishLocalOnlyResults = (localProjectId: string, localScanId: string) => { + const endpoints = mergeRemoteAndLocalEndpoints([], apiCalls, localProjectId, localScanId); + const mergedSuggestions = mergeLocalWasteFindings( + [], + localWasteFindings, + endpoints, + localProjectId, + localScanId + ); + const summary: ScanSummary = { + totalEndpoints: endpoints.length, + totalCallsPerDay: endpoints.reduce((sum, ep) => sum + ep.callsPerDay, 0), + totalMonthlyCost: endpoints.reduce((sum, ep) => sum + ep.monthlyCost, 0), + highRiskCount: mergedSuggestions.filter((s) => s.severity === "high").length, + }; + + const externalEndpoints = endpoints.filter((ep) => ep.scope !== "internal"); + this.ctx.setLastEndpoints(externalEndpoints); + this.ctx.setLastSuggestions(mergedSuggestions); + this.ctx.setLastSummary({ ...summary, totalEndpoints: externalEndpoints.length }); + this.ctx.postMessage({ + type: "scanResults", + endpoints: externalEndpoints, + suggestions: mergedSuggestions, + summary: { ...summary, totalEndpoints: externalEndpoints.length }, + }); + void this.ctx.exportDebugScanResults({ + mode: "local-only", + scannedFiles, + local: { + apiCalls, + localWasteFindings, + submittedRemoteApiCalls: [], + }, + remote: null, + final: { + projectId: localProjectId, + scanId: localScanId, + endpoints, + suggestions: mergedSuggestions, + summary, + }, + }); + }; + + if (apiCalls.length === 0) { + this.ctx.setLastEndpoints([]); + this.ctx.setLastSuggestions([]); + const emptySummary: ScanSummary = { + totalEndpoints: 0, + totalCallsPerDay: 0, + totalMonthlyCost: 0, + highRiskCount: 0, + }; + this.ctx.setLastSummary(emptySummary); + this.ctx.postMessage({ + type: "scanResults", + endpoints: [], + suggestions: [], + summary: emptySummary, + }); + void this.ctx.exportDebugScanResults({ + mode: "local-only", + scannedFiles, + local: { + apiCalls, + localWasteFindings, + submittedRemoteApiCalls: [], + }, + remote: null, + final: { + projectId: "local", + scanId: `local-${Date.now()}`, + endpoints: [], + suggestions: [], + summary: emptySummary, + }, + }); + return; + } + + const manualProjectId = this.ctx.getManualProjectId(); + let rcApiKey = await this.ctx.getRcApiKey(); + if (!rcApiKey) { + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + this.ctx.postMessage({ + type: "scanNotification", + message: "No ReCost API key — showing local results only. Add a key in Keys to enable remote sync.", + }); + return; + } + const remoteApiCalls = apiCalls + .filter(shouldSubmitRemote) + .map((call) => ({ + ...call, + provider: call.provider ?? detectEndpointProvider(canonicalizeEndpointUrl(call.url)) ?? "unknown", + })) + .filter((call) => call.provider !== "unknown"); + if (remoteApiCalls.length === 0) { + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + return; + } + + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + + try { + const projectTarget = await this.ctx.resolveScanProjectTarget(rcApiKey); + let projectId = projectTarget.projectId; + let scanResult; + try { + scanResult = await submitScan(projectId, remoteApiCalls, rcApiKey); + } catch (err: unknown) { + if ((err as { status?: number }).status === 404 && projectTarget.source === "auto") { + const freshId = await createProject(this.ctx.getWorkspaceName(), rcApiKey); + this.ctx.setProjectId(freshId); + projectId = freshId; + await this.ctx.context.globalState.update("recost.projectId", freshId); + scanResult = await submitScan(projectId, remoteApiCalls, rcApiKey); + } else { + throw err; + } + } + + const [remoteEndpoints, suggestions] = await Promise.all([ + getAllEndpoints(projectId, scanResult.scanId, rcApiKey), + getAllSuggestions(projectId, scanResult.scanId, rcApiKey), + ]); + const taggedRemoteSuggestions = suggestions.map((s) => ({ ...s, source: s.source ?? "remote" })); + + const endpoints = mergeRemoteAndLocalEndpoints(remoteEndpoints, apiCalls, projectId, scanResult.scanId); + const externalEndpoints = endpoints.filter((ep) => ep.scope !== "internal"); + this.ctx.setLastEndpoints(externalEndpoints); + const aggressiveSuggestions = buildAggressiveSuggestions(endpoints, taggedRemoteSuggestions, localWasteFindings); + const mergedSuggestions = mergeLocalWasteFindings( + aggressiveSuggestions, + localWasteFindings, + endpoints, + projectId, + scanResult.scanId + ); + this.ctx.setLastSuggestions(mergedSuggestions); + this.ctx.setLastSummary({ ...scanResult.summary, totalEndpoints: externalEndpoints.length }); + + this.ctx.postMessage({ + type: "scanResults", + endpoints: externalEndpoints, + suggestions: mergedSuggestions, + summary: { + ...scanResult.summary, + totalEndpoints: externalEndpoints.length, + }, + }); + void this.ctx.exportDebugScanResults({ + mode: "remote-enriched", + scannedFiles, + local: { + apiCalls, + localWasteFindings, + submittedRemoteApiCalls: remoteApiCalls, + }, + remote: { + projectId, + scanId: scanResult.scanId, + endpoints: remoteEndpoints, + suggestions, + summary: scanResult.summary, + }, + final: { + projectId, + scanId: scanResult.scanId, + endpoints, + suggestions: mergedSuggestions, + summary: { + ...scanResult.summary, + totalEndpoints: Math.max(scanResult.summary.totalEndpoints, endpoints.length), + }, + }, + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Remote analysis failed"; + const status = (err as { status?: number }).status; + const authLikeFailure = + status === 401 || + (status === 403 && /invalid|unauthori[sz]ed|forbidden|auth/i.test(message)); + + if (authLikeFailure) { + const rcKey = await this.ctx.getRcApiKey(); + if (rcKey) { + await this.ctx.setRecostValidationState({ + state: "invalid", + message, + lastCheckedAt: new Date().toISOString(), + keyFingerprint: buildKeyFingerprint(rcKey), + }); + } else { + await this.ctx.clearRecostValidationState(); + } + await this.ctx.sendRecostKeyStatusUpdate(); + this.ctx.openKeys("recost"); + } + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + if (status === 404 && manualProjectId) { + this.ctx.postMessage({ + type: "scanNotification", + message: `Project ID ${manualProjectId} was not found. Keeping the saved manual Project ID and showing local results.`, + }); + return; + } + if (err instanceof Error && err.message === "fetch failed") { + this.ctx.postMessage({ + type: "scanNotification", + message: "Could not reach ReCost server. Showing local results.", + }); + } + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error during scan"; + this.ctx.postMessage({ type: "error", message }); + } finally { + await vscode.commands.executeCommand("setContext", "recost.scanning", false); + } + } +} From db8e17ad38345a95472110f4169e60551c29667d Mon Sep 17 00:00:00 2001 From: Andres Lopez <190146319+AndresL230@users.noreply.github.com> Date: Tue, 12 May 2026 02:36:03 -0400 Subject: [PATCH 11/14] test(extension): smoke test for activate/deactivate exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a lightweight activation test that loads the built dist/extension.js with vscode hot-swapped for a minimal local stub. Verifies activate() and deactivate() are exported, and that deactivate() is idempotent without a prior activate() call (catches state-assumption regressions on edge-case shutdown sequences). Loading the built artifact rather than compiling extension.ts under the scanner tsconfig keeps the test compile graph small — extension.ts pulls in the entire webview-provider closure and would force the scanner test tsconfig to grow into a parallel full-extension compile. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/test/__mocks__/vscode.ts | 104 ++++++++++++++++++++++++++ src/test/extension-activation.test.ts | 54 +++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 src/test/__mocks__/vscode.ts create mode 100644 src/test/extension-activation.test.ts diff --git a/package.json b/package.json index d1f69a5..5178540 100644 --- a/package.json +++ b/package.json @@ -196,7 +196,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 && 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/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", + "test:scanner": "tsc -p tsconfig.scanner-tests.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/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", "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/__mocks__/vscode.ts b/src/test/__mocks__/vscode.ts new file mode 100644 index 0000000..e8be2ab --- /dev/null +++ b/src/test/__mocks__/vscode.ts @@ -0,0 +1,104 @@ +// Minimal `vscode` shim for unit-test environments where the real Extension +// Host API is not available. Only stubs the surface that src/extension.ts and +// its eager imports touch at module-load time and during a no-op +// activate()/deactivate() sequence. + +class Disposable { + static from(..._disposables: { dispose: () => void }[]): Disposable { + return new Disposable(); + } + dispose(): void { /* noop */ } +} + +class EventEmitter { + event = (_listener: (_e: T) => void) => new Disposable(); + fire(_data: T): void { /* noop */ } + dispose(): void { /* noop */ } +} + +class Uri { + static file(p: string) { return new Uri(p); } + static joinPath(_base: Uri, ..._segments: string[]) { return new Uri("joined"); } + constructor(public readonly fsPath: string = "") {} + toString() { return this.fsPath; } +} + +const noop = () => undefined; +const asyncNoop = async () => undefined; + +const window = { + createOutputChannel: (_name: string) => ({ + appendLine: noop, + append: noop, + clear: noop, + show: noop, + dispose: noop, + hide: noop, + replace: noop, + name: _name, + }), + createStatusBarItem: () => ({ + text: "", + tooltip: "", + command: undefined, + color: undefined, + show: noop, + hide: noop, + dispose: noop, + }), + showInformationMessage: asyncNoop, + showErrorMessage: asyncNoop, + showWarningMessage: asyncNoop, + registerWebviewViewProvider: () => new Disposable(), + activeTextEditor: undefined, +}; + +const workspace = { + workspaceFolders: [] as { uri: Uri; name: string; index: number }[], + getConfiguration: () => ({ + get: (_key: string, defaultValue?: T): T | undefined => defaultValue, + update: asyncNoop, + has: () => false, + inspect: () => undefined, + }), + onDidChangeConfiguration: () => new Disposable(), + onDidChangeWorkspaceFolders: () => new Disposable(), + fs: { + readFile: asyncNoop, + writeFile: asyncNoop, + stat: asyncNoop, + }, + findFiles: async () => [] as Uri[], +}; + +const commands = { + registerCommand: () => new Disposable(), + executeCommand: asyncNoop, +}; + +const env = { + clipboard: { + writeText: asyncNoop, + readText: async () => "", + }, + openExternal: asyncNoop, +}; + +const StatusBarAlignment = { Left: 1, Right: 2 } as const; +const ViewColumn = { Active: -1, Beside: -2, One: 1, Two: 2, Three: 3 } as const; +const ConfigurationTarget = { Global: 1, Workspace: 2, WorkspaceFolder: 3 } as const; +const ThemeColor = class { constructor(public readonly id: string) {} }; + +export { + Disposable, + EventEmitter, + Uri, + window, + workspace, + commands, + env, + StatusBarAlignment, + ViewColumn, + ConfigurationTarget, + ThemeColor, +}; diff --git a/src/test/extension-activation.test.ts b/src/test/extension-activation.test.ts new file mode 100644 index 0000000..4ee5281 --- /dev/null +++ b/src/test/extension-activation.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import Module from "node:module"; + +// Activation test for src/extension.ts. +// +// Strategy: extension.ts imports `vscode` at module top level, which is only +// available inside the real Extension Host. We monkey-patch Node's require +// resolution to return our local stub for any `require("vscode")`, then load +// the BUILT artifact (dist/extension.js) rather than compiling extension.ts +// under the scanner tsconfig — that would drag the entire webview-provider +// transitive closure into the test compile graph for no benefit. + +const distExtensionPath = path.resolve(__dirname, "..", "..", "dist", "extension.js"); +const mockVscodePath = path.resolve(__dirname, "__mocks__", "vscode.js"); + +function installVscodeMock(): void { + if (!fs.existsSync(mockVscodePath)) { + throw new Error(`vscode mock not compiled at ${mockVscodePath} — run "npx tsc -p tsconfig.scanner-tests.json" first`); + } + const originalRequire = Module.prototype.require as unknown as (id: string) => unknown; + function patched(this: NodeJS.Module, id: string) { + if (id === "vscode") return originalRequire.call(this, mockVscodePath); + return originalRequire.call(this, id); + } + (Module.prototype as { require: unknown }).require = patched; +} + +async function runTests() { + if (!fs.existsSync(distExtensionPath)) { + throw new Error(`dist/extension.js not found at ${distExtensionPath} — run "npm run build:ext" before tests`); + } + + installVscodeMock(); + + // Use dynamic require so tsc does not try to follow the import path into + // the extension's `vscode` import chain. The vscode mock is now wired. + const ext = require(distExtensionPath) as { activate?: unknown; deactivate?: unknown }; + + assert.equal(typeof ext.activate, "function", "activate must be exported"); + assert.equal(typeof ext.deactivate, "function", "deactivate must be exported"); + + // deactivate() must be idempotent — calling it before activate() ever fires, + // and calling it twice, must not throw. This catches accidental state + // assumptions in deactivate() that would otherwise blow up on edge-case + // shutdown sequences (e.g., extension host kills before activate completes). + (ext.deactivate as () => void)(); + (ext.deactivate as () => void)(); + + console.log("PASS extension-activation"); +} + +runTests().catch((e) => { console.error(e); process.exit(1); }); From 0eb87094c0a3dfd987314a1df61a8d5866a8b5bf Mon Sep 17 00:00:00 2001 From: Andres Lopez <190146319+AndresL230@users.noreply.github.com> Date: Tue, 12 May 2026 02:37:26 -0400 Subject: [PATCH 12/14] chore(deps): upgrade esbuild to ^0.28 (fixes GHSA-67mh-4wv8-2f99) npm audit reports 0 vulnerabilities after the bump. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 226 +++++++++++++++++++++++++--------------------- package.json | 2 +- 2 files changed, 123 insertions(+), 105 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5a4b59e..1433dfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "devDependencies": { "@types/node": "^20.0.0", "@types/vscode": "^1.85.0", - "esbuild": "^0.24.0", + "esbuild": "^0.28.0", "typescript": "^5.8.0" }, "engines": { @@ -23,9 +23,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -40,9 +40,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -57,9 +57,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -74,9 +74,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -91,9 +91,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -108,9 +108,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -125,9 +125,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -142,9 +142,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -159,9 +159,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -176,9 +176,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -193,9 +193,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -210,9 +210,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -227,9 +227,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -244,9 +244,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -261,9 +261,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -278,9 +278,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -295,9 +295,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -312,9 +312,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -329,9 +329,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -346,9 +346,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -363,9 +363,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -379,10 +379,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -397,9 +414,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -414,9 +431,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -431,9 +448,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -597,9 +614,9 @@ } }, "node_modules/esbuild": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -610,31 +627,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.2", - "@esbuild/android-arm": "0.24.2", - "@esbuild/android-arm64": "0.24.2", - "@esbuild/android-x64": "0.24.2", - "@esbuild/darwin-arm64": "0.24.2", - "@esbuild/darwin-x64": "0.24.2", - "@esbuild/freebsd-arm64": "0.24.2", - "@esbuild/freebsd-x64": "0.24.2", - "@esbuild/linux-arm": "0.24.2", - "@esbuild/linux-arm64": "0.24.2", - "@esbuild/linux-ia32": "0.24.2", - "@esbuild/linux-loong64": "0.24.2", - "@esbuild/linux-mips64el": "0.24.2", - "@esbuild/linux-ppc64": "0.24.2", - "@esbuild/linux-riscv64": "0.24.2", - "@esbuild/linux-s390x": "0.24.2", - "@esbuild/linux-x64": "0.24.2", - "@esbuild/netbsd-arm64": "0.24.2", - "@esbuild/netbsd-x64": "0.24.2", - "@esbuild/openbsd-arm64": "0.24.2", - "@esbuild/openbsd-x64": "0.24.2", - "@esbuild/sunos-x64": "0.24.2", - "@esbuild/win32-arm64": "0.24.2", - "@esbuild/win32-ia32": "0.24.2", - "@esbuild/win32-x64": "0.24.2" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/event-target-shim": { diff --git a/package.json b/package.json index 5178540..8bd7280 100644 --- a/package.json +++ b/package.json @@ -209,7 +209,7 @@ "devDependencies": { "@types/node": "^20.0.0", "@types/vscode": "^1.85.0", - "esbuild": "^0.24.0", + "esbuild": "^0.28.0", "typescript": "^5.8.0" } } From 3f28a41e1f416cbe7dfb72db4d4ccafb98db0853 Mon Sep 17 00:00:00 2001 From: Andres Lopez <190146319+AndresL230@users.noreply.github.com> Date: Tue, 12 May 2026 02:37:56 -0400 Subject: [PATCH 13/14] chore(deps): upgrade openai to ^4.104 (latest in v4 line) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tightens the floor inside the v4 line; the lockfile was already resolving to a recent 4.x patch under the prior ^4.73.0 constraint, so this bump is range-tightening only — no new code installed and no breaking changes. v6 migration deferred — needs integration tests for the chat adapter before we can safely move past v4. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1433dfa..61ee80a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.2", "license": "MIT", "dependencies": { - "openai": "^4.73.0", + "openai": "^4.104.0", "web-tree-sitter": "^0.26.7" }, "devDependencies": { diff --git a/package.json b/package.json index 8bd7280..f4b0250 100644 --- a/package.json +++ b/package.json @@ -203,7 +203,7 @@ "package": "npm run build:webview && node esbuild.mjs --release && npx @vscode/vsce package --no-dependencies --allow-missing-repository" }, "dependencies": { - "openai": "^4.73.0", + "openai": "^4.104.0", "web-tree-sitter": "^0.26.7" }, "devDependencies": { From 93c0972e5609b418d2512f3136093a40af8b00c2 Mon Sep 17 00:00:00 2001 From: Andres Lopez <190146319+AndresL230@users.noreply.github.com> Date: Tue, 12 May 2026 03:24:54 -0400 Subject: [PATCH 14/14] fix(pr-87): address CodeRabbit review comments - chat-handler: redact rc- ReCost keys in addition to sk- OpenAI keys before forwarding snippets to external chat providers - scan-publishing-handler: use computed severity (not hardcoded "medium") when calling calculateSavings in buildAggressiveSuggestions - scan-publishing-handler: run buildAggressiveSuggestions in the local-only publish path so offline scans surface the same cache/batch/ n_plus_one suggestions as the remote-enriched path - simulation-handler: move savedScenarios in-memory update to after the globalState.update resolves, preventing in-memory/persisted divergence on storage failure - webview-provider: return the runSimulation handler's value from the dispatch wrapper to match surrounding entries (defensive cleanup) - export.test.ts: add costLeaks and providerSummary to inline ExportedContext literals (TS compile error: both are required fields) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/intelligence/__tests__/export.test.ts | 4 ++++ src/webview-provider.ts | 2 +- src/webview/chat-handler.ts | 1 + src/webview/scan-publishing-handler.ts | 8 +++++--- src/webview/simulation-handler.ts | 6 ++++-- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/intelligence/__tests__/export.test.ts b/src/intelligence/__tests__/export.test.ts index 0a1d029..f6893f0 100644 --- a/src/intelligence/__tests__/export.test.ts +++ b/src/intelligence/__tests__/export.test.ts @@ -207,7 +207,9 @@ run("formatAsMarkdown and formatAsJSON render stable onboarding output", () => { }, ], keyRisks: ["Unbounded loop API calls", "Rate-limit risk"], + costLeaks: [], }, + providerSummary: [], clusters, }; @@ -605,7 +607,9 @@ run("formatAsMarkdown clarifies cluster-vs-primary providers and softens heurist }, ], keyRisks: ["Potential missing caching on hot path"], + costLeaks: [], }, + providerSummary: [], clusters: [ { id: "cluster:src/chat/providers/xai.ts", diff --git a/src/webview-provider.ts b/src/webview-provider.ts index a48168e..3e4b896 100644 --- a/src/webview-provider.ts +++ b/src/webview-provider.ts @@ -527,7 +527,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { applyFix: (code, file, line) => this.handleApplyFix(code, file, line), openFile: (file, line) => this.handleOpenFile(file, line), openDashboard: () => this.handleOpenDashboard(), - runSimulation: (input) => { this.simulationHandler.handleRunSimulation(input); }, + runSimulation: (input) => this.simulationHandler.handleRunSimulation(input), getAllKeyStatuses: () => this.sendAllKeyStatuses(), getProjectIdStatus: () => this.sendProjectIdStatus(), setKey: (serviceId, value) => this.setServiceKey(serviceId, value), diff --git a/src/webview/chat-handler.ts b/src/webview/chat-handler.ts index 7e4f602..0a0cbe8 100644 --- a/src/webview/chat-handler.ts +++ b/src/webview/chat-handler.ts @@ -203,6 +203,7 @@ export class ChatHandler { private redactSensitiveText(value: string): string { return value + .replace(/\brc-[a-zA-Z0-9_-]{8,}\b/g, "[REDACTED_RECOST_KEY]") .replace(/sk-[a-zA-Z0-9]{16,}/g, "[REDACTED_OPENAI_KEY]") .replace(/(api[_-]?key|token|secret)\s*[:=]\s*["'`][^"'`\n]{8,}["'`]/gi, "$1=[REDACTED]") .replace(/(authorization\s*:\s*["'`]bearer\s+)[^"'`\n]+/gi, "$1[REDACTED]"); diff --git a/src/webview/scan-publishing-handler.ts b/src/webview/scan-publishing-handler.ts index 13f8307..79280d6 100644 --- a/src/webview/scan-publishing-handler.ts +++ b/src/webview/scan-publishing-handler.ts @@ -171,15 +171,16 @@ function buildAggressiveSuggestions( ); if (suppressedByWaste) continue; + const severity = chooseSeverity(endpoint.status, endpoint.monthlyCost); extras.push({ id: `local-${endpoint.id}-${type}`, projectId: endpoint.projectId, scanId: endpoint.scanId, type, - severity: chooseSeverity(endpoint.status, endpoint.monthlyCost), + severity, affectedEndpoints: [endpoint.id], affectedFiles: endpoint.files, - estimatedMonthlySavings: calculateSavings(type, "medium", endpoint.monthlyCost), + estimatedMonthlySavings: calculateSavings(type, severity, endpoint.monthlyCost), description: buildAggressiveDescription(endpoint, type), codeFix: "", source: "local-rule", @@ -565,8 +566,9 @@ 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, diff --git a/src/webview/simulation-handler.ts b/src/webview/simulation-handler.ts index 8aea722..66a768c 100644 --- a/src/webview/simulation-handler.ts +++ b/src/webview/simulation-handler.ts @@ -40,10 +40,12 @@ export class SimulationHandler { } public async persistScenarios(next: SavedScenario[]): Promise { - this.savedScenarios = next; this.scenarioPersistQueue = this.scenarioPersistQueue .catch(() => {}) - .then(() => this.ctx.context.globalState.update(SimulationHandler.SCENARIOS_STORAGE_KEY, next)); + .then(async () => { + await this.ctx.context.globalState.update(SimulationHandler.SCENARIOS_STORAGE_KEY, next); + this.savedScenarios = next; + }); await this.scenarioPersistQueue; }