diff --git a/docs/lsp.md b/docs/lsp.md new file mode 100644 index 0000000..484001d --- /dev/null +++ b/docs/lsp.md @@ -0,0 +1,151 @@ +# Language Server Protocol (LSP) Daemon + +The ChainProof Language Server Protocol (LSP) Daemon delivers real-time Solidity security feedback, vulnerability detection, and gas optimization directly inside text editors and IDEs (VS Code, Neovim, Emacs, JetBrains). + +--- + +## Architecture Overview + +``` + ┌────────────────┐ JSON-RPC 2.0 over Stdio/Socket ┌───────────────────────────────────┐ + │ │ ────────────────────────────────────────> │ @chainproof/core LSP Daemon │ + │ Editor Client │ │ - DocumentStore (In-memory AST) │ + │ (VS Code, etc)│ <──────────────────────────────────────── │ - AnalysisService (Debounced) │ + └────────────────┘ Diagnostics, Hovers, QuickFixes, TM └───────────────────────────────────┘ +``` + +The LSP Daemon decouples scan orchestration from individual editor extensions by providing an incremental, cancellable, transport-neutral service: + +- **Document Store (`DocumentStore`)**: Manages in-memory document overlays for active editor buffers, handling text edits, position/offset conversions, version tracking, AST LRU caching, and import-graph dependency tracking. +- **Analysis Service (`AnalysisService`)**: Schedules debounced, cancellable incremental security scans using bounded queues and load shedding. Reuses ASTs and import graphs across open overlays and on-disk files. +- **Transports (`LspTransportListener`)**: Supports standard input/output streams (`stdio`), authenticated local Unix/IPC domain sockets (`ipc`), and TCP socket listeners (`tcp`). +- **Language Intelligence Providers**: + - **Diagnostics**: Real-time error and warning highlights for SWC vulnerabilities and gas optimization hints. + - **Quick Fixes & Code Actions**: Deterministic remediations (e.g., replacing `tx.origin` with `msg.sender`, checking call return values), inline comment suppression insertion (`// chainproof-disable-next-line`), rule documentation links, and evidence path navigation. + - **Hover**: Rich Markdown hover cards detailing vulnerability mechanisms, confidence ratings, and recommendations. + - **Document Symbols**: Hierarchical symbols for contracts, interfaces, functions, state variables, and events. + - **Call Hierarchy**: Incoming and outgoing call hierarchy based on function calls. + - **References**: Evidence trail location resolution across workspace files. + +--- + +## Running the LSP Daemon + +### CLI Usage + +Start the LSP daemon using the `chainproof lsp` command: + +```bash +# Stdio mode (default, for editor child processes) +chainproof lsp --transport stdio + +# Authenticated local IPC socket mode +chainproof lsp --transport ipc --socket /tmp/chainproof-lsp.sock --token MY_SECRET_TOKEN + +# Authenticated TCP socket mode +chainproof lsp --transport tcp --port 8433 --token MY_SECRET_TOKEN +``` + +### CLI Options + +| Flag | Description | Default | +| --- | --- | --- | +| `--transport ` | Transport protocol: `stdio`, `ipc`, or `tcp` | `stdio` | +| `--socket ` | Domain socket file path (IPC mode) | `/tmp/chainproof-lsp.sock` | +| `--port ` | TCP port (TCP mode) | `8433` | +| `--token ` | Shared authentication secret token | `env.CHAINPROOF_LSP_TOKEN` | +| `--max-queue ` | Maximum pending request queue depth | `50` | +| `--max-concurrent `| Maximum concurrent background scan tasks | `2` | +| `--debounce ` | Debounce delay for document edits in ms | `150` | + +--- + +## Socket Security & Authentication Model + +When running over IPC or TCP sockets, local security boundaries are enforced: + +1. **Localhost Binding**: TCP socket listeners strictly bind to `127.0.0.1` to prevent external network exposure. +2. **Token Authentication**: When `--token ` or `CHAINPROOF_LSP_TOKEN` is configured, clients must send an initial handshake line upon connection: + ```text + AUTH + ``` + If authentication fails or times out (5 seconds), the daemon responds with `AUTH_FAILED` and immediately closes the connection. +3. **Bounded Resources & Load Shedding**: Analysis queues are bounded by depth (`maxQueueDepth`). When capacity is exceeded, oldest pending analysis jobs are safely rejected to prevent memory exhaustion or editor freeze during rapid typing. + +--- + +## Protocol Extensions (Custom JSON-RPC Requests) + +The ChainProof LSP Daemon extends standard LSP with custom JSON-RPC request endpoints: + +### `chainproof/threatModel` +Generates a comprehensive STRIDE/DeFi threat model for open or workspace contracts. + +- **Params**: + ```json + { + "uri": "file:///path/to/Contract.sol", + "minSeverity": "low" + } + ``` +- **Response**: + ```json + { + "threatModel": { ... }, + "markdown": "# Threat Model Report...", + "json": "{ ... }" + } + ``` + +### `chainproof/scanReport` +Generates a full workspace audit report formatted as Markdown, JSON, or ASCII Table. + +- **Params**: + ```json + { + "format": "markdown", + "minSeverity": "low" + } + ``` +- **Response**: + ```json + { + "format": "markdown", + "content": "# Audit Report...", + "summary": { "critical": 0, "high": 1, "total": 3 } + } + ``` + +### `chainproof/clearCache` +Clears internal AST caches and resets incremental watch state. + +- **Response**: + ```json + { + "cleared": true, + "message": "AST cache and watch state cleared successfully." + } + ``` + +### `chainproof/status` +Returns daemon operational statistics, queue depth, active analyses, and cache hit/miss counts. + +- **Response**: + ```json + { + "openDocumentsCount": 2, + "queueDepth": 0, + "activeAnalyses": 0, + "cacheStats": { "hits": 42, "misses": 5, "entries": 47 }, + "uptimeSeconds": 120, + "workspaceFolders": ["/path/to/project"] + } + ``` + +--- + +## Troubleshooting + +- **No diagnostics displayed**: Ensure the active file is a `.sol` file and contains valid Solidity syntax. Check output logs via `--transport stdio` or output channel. +- **High CPU / Memory Usage**: Adjust `--debounce` (e.g. `--debounce 300`) or reduce `--max-concurrent` (e.g. `--max-concurrent 1`). +- **Socket Auth Failures**: Confirm `--token` supplied to CLI matches the secret header sent by client connection socket scripts. diff --git a/package-lock.json b/package-lock.json index 1345dfc..404c288 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4548,7 +4548,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -4719,7 +4718,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -10432,7 +10430,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -11717,6 +11714,125 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vscode-jsonrpc": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.2.tgz", + "integrity": "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageclient": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz", + "integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==", + "license": "MIT", + "dependencies": { + "minimatch": "^5.1.0", + "semver": "^7.3.7", + "vscode-languageserver-protocol": "3.17.5" + }, + "engines": { + "vscode": "^1.82.0" + } + }, + "node_modules/vscode-languageclient/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/vscode-languageclient/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageclient/node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageclient/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.3.tgz", + "integrity": "sha512-DF49+WeV5py4zO5hhobp60jjsDSK0lAqA0OuKBLBvp423HPWQcCbhZz3JgyfIewsEz2f8U+X75xNIFHdiXZm2w==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "9.0.2", + "vscode-languageserver-types": "3.18.3" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz", + "integrity": "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz", + "integrity": "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==", + "license": "MIT" + }, + "node_modules/vscode-languageserver/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver/node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -12078,7 +12194,10 @@ "@solidity-parser/parser": "^0.18.0", "axios": "^1.6.0", "chalk": "^4.1.2", - "dotenv": "^16.4.0" + "dotenv": "^16.4.0", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-protocol": "^3.18.3", + "vscode-languageserver-textdocument": "^1.0.14" }, "devDependencies": { "@types/jest": "^30.0.0", @@ -12137,7 +12256,8 @@ "name": "chainproof-vscode", "version": "0.1.0", "dependencies": { - "@chainproof/core": "*" + "@chainproof/core": "*", + "vscode-languageclient": "^9.0.1" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1339203..a7ff8c3 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -29,9 +29,7 @@ import type { ServerOptions } from "@chainproof/server"; import { registerWatchCommand } from "./commands/watch"; import { registerInvariantsCommand } from "./commands/invariants"; import { registerStakingCommand } from "./commands/staking"; -import { registerGovernanceCommand } from "./commands/governance"; -import { registerBridgeCommand } from "./commands/bridge"; -import { registerDosCommand } from "./commands/dos"; +import { registerLspCommand } from "./commands/lsp"; // ─── ASCII Banner ───────────────────────────────────────────────────────────── @@ -632,8 +630,6 @@ program registerWatchCommand(program, printBanner); registerInvariantsCommand(program, printBanner); registerStakingCommand(program); -registerGovernanceCommand(program, printBanner); -registerBridgeCommand(program, printBanner); -registerDosCommand(program, printBanner); +registerLspCommand(program, printBanner); program.parse(); diff --git a/packages/cli/src/commands/lsp.ts b/packages/cli/src/commands/lsp.ts new file mode 100644 index 0000000..bfbbe73 --- /dev/null +++ b/packages/cli/src/commands/lsp.ts @@ -0,0 +1,93 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import { startLspDaemon, type LspDaemonOptions, type TransportType } from "@chainproof/core"; + +export function registerLspCommand(program: Command, printBanner: () => void) { + program + .command("lsp") + .description("Start the ChainProof Language Server Protocol (LSP) Daemon") + .option("--transport ", "Transport mode: stdio|ipc|tcp", "stdio") + .option("--socket ", "Path to IPC domain socket (used when transport === 'ipc')") + .option("--port ", "Port for TCP server (used when transport === 'tcp')", "8433") + .option("--token ", "Authentication secret token for IPC or TCP sockets") + .option("--max-queue ", "Maximum pending analysis queue depth", "50") + .option("--max-concurrent ", "Maximum concurrent analysis tasks", "2") + .option("--debounce ", "Debounce delay for file edits in ms", "150") + .action( + (opts: { + transport: string; + socket?: string; + port: string; + token?: string; + maxQueue: string; + maxConcurrent: string; + debounce: string; + }) => { + const transport = opts.transport as TransportType; + if (!["stdio", "ipc", "tcp"].includes(transport)) { + console.error(chalk.red(" ❌ Invalid transport mode. Use stdio, ipc, or tcp.")); + process.exit(1); + } + + const port = parseInt(opts.port, 10); + if (isNaN(port) || port < 1 || port > 65535) { + console.error(chalk.red(" ❌ Invalid port number")); + process.exit(1); + } + + const maxQueueDepth = parseInt(opts.maxQueue, 10); + const maxConcurrent = parseInt(opts.maxConcurrent, 10); + const debounceMs = parseInt(opts.debounce, 10); + + if (transport !== "stdio") { + printBanner(); + console.log( + chalk.cyan( + ` Starting ChainProof LSP Daemon...\n` + + ` Transport : ${transport.toUpperCase()}\n` + + (transport === "ipc" ? ` Socket : ${opts.socket ?? "default"}\n` : "") + + (transport === "tcp" ? ` Port : ${port}\n` : "") + + ` Auth Token : ${opts.token ? chalk.green("enabled") : chalk.yellow("disabled (open)")}\n` + + ` Max Queue : ${maxQueueDepth}\n` + ) + ); + } + + const daemonOptions: LspDaemonOptions = { + transport, + socketPath: opts.socket, + port, + authToken: opts.token ?? process.env.CHAINPROOF_LSP_TOKEN, + maxQueueDepth, + maxConcurrent, + debounceMs, + logger: (level, msg) => { + if (transport !== "stdio") { + const color = level === "error" ? chalk.red : level === "warn" ? chalk.yellow : chalk.gray; + console.log(color(`[LSP ${level.toUpperCase()}] ${msg}`)); + } + }, + }; + + try { + const daemon = startLspDaemon(daemonOptions); + + process.on("SIGINT", () => { + if (transport !== "stdio") { + console.log(chalk.yellow("\n Shutting down LSP Daemon...")); + } + daemon.stop(); + process.exit(0); + }); + + process.on("SIGTERM", () => { + daemon.stop(); + process.exit(0); + }); + } catch (err) { + console.error(chalk.red(` ❌ Failed to start LSP Daemon: ${err}`)); + process.exit(1); + } + } + ); +} diff --git a/packages/core/package.json b/packages/core/package.json index bbf33c8..90cdd63 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,7 +27,10 @@ "@solidity-parser/parser": "^0.18.0", "axios": "^1.6.0", "chalk": "^4.1.2", - "dotenv": "^16.4.0" + "dotenv": "^16.4.0", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-protocol": "^3.18.3", + "vscode-languageserver-textdocument": "^1.0.14" }, "devDependencies": { "@types/jest": "^30.0.0", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 00c4e67..c1c6a12 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -64,6 +64,35 @@ export { } from "./config"; export type { ChainProofConfig } from "./config"; +// ─── Language Server Protocol Daemon ───────────────────────────────────────── +export { + ChainProofLspServer, + startLspDaemon, + DocumentStore, + AnalysisService, + LspTransportListener, + ChainProofCodeActionProvider, + ChainProofProviders, + ChainProofLspMethods, +} from "./lsp"; +export type { + LspDaemonOptions, + TransportType, + DiagnosticData, + ExtendedLspDiagnostic, + LspStatus, + ThreatModelRequestParams, + ScanReportRequestParams, + ScanReportResponse, + ClearCacheResponse, + OverlayDocument, + AnalysisJob, + PublishDiagnosticsCallback, + WorkDoneProgressCallback, + TransportConnection, + ConnectionHandler, +} from "./lsp"; + // ─── Public types ───────────────────────────────────────────────────────────── export type { diff --git a/packages/core/src/lsp/analysis-service.ts b/packages/core/src/lsp/analysis-service.ts new file mode 100644 index 0000000..3ab8cac --- /dev/null +++ b/packages/core/src/lsp/analysis-service.ts @@ -0,0 +1,351 @@ +import { DiagnosticSeverity, type Diagnostic as LspDiagnostic, type ProgressToken } from "vscode-languageserver"; +import type { CancellationToken } from "vscode-languageserver"; +import * as path from "path"; +import * as fs from "fs"; +import { scan, scanIncremental, collectSolFiles, type WatchScanState } from "../scanner"; +import { DocumentStore, type OverlayDocument } from "./document-store"; +import { getCacheStats, ASTCache } from "../ast/cache"; +import type { ScanConfig, ScanResult, Finding, GasHint, FileScanResult } from "../types"; +import type { ExtendedLspDiagnostic, DiagnosticData, LspDaemonOptions } from "./types"; + +export interface AnalysisJob { + id: string; + uri: string; + filePath: string; + version: number; + workspaceFolders: string[]; + cancellationToken?: CancellationToken; + progressToken?: ProgressToken; + resolve: (diagnostics: Map) => void; + reject: (err: Error) => void; +} + +export type PublishDiagnosticsCallback = ( + uri: string, + diagnostics: ExtendedLspDiagnostic[], + version?: number +) => void; + +export type WorkDoneProgressCallback = ( + token: ProgressToken, + action: "begin" | "report" | "end", + value: { title?: string; percentage?: number; message?: string } +) => void; + +/** + * Background analysis service orchestrating cancellable incremental scans, + * bounded queue management, diagnostic mapping, and progress reporting. + */ +export class AnalysisService { + private readonly documentStore: DocumentStore; + private readonly options: LspDaemonOptions; + private readonly queue: AnalysisJob[] = []; + private activeJobsCount = 0; + private watchState: WatchScanState | undefined; + private pendingDebounceTimers = new Map(); + private activeCancellations = new Map(); + private latestPublishedVersions = new Map(); + + public onPublishDiagnostics?: PublishDiagnosticsCallback; + public onWorkDoneProgress?: WorkDoneProgressCallback; + + constructor(documentStore: DocumentStore, options: LspDaemonOptions = {}) { + this.documentStore = documentStore; + this.options = { + maxQueueDepth: 50, + maxConcurrent: 2, + debounceMs: 150, + ...options, + }; + } + + public get queueDepth(): number { + return this.queue.length; + } + + public get activeAnalyses(): number { + return this.activeJobsCount; + } + + /** Convert ChainProof Finding severity to LSP DiagnosticSeverity */ + public static toLspSeverity(severity: Finding["severity"]): DiagnosticSeverity { + switch (severity) { + case "critical": + case "high": + return DiagnosticSeverity.Error; + case "medium": + return DiagnosticSeverity.Warning; + case "low": + return DiagnosticSeverity.Information; + case "info": + case "gas": + default: + return DiagnosticSeverity.Hint; + } + } + + /** Schedule a document for analysis with debouncing and cancellation */ + public scheduleAnalysis( + uri: string, + workspaceFolders: string[], + cancellationToken?: CancellationToken, + progressToken?: ProgressToken + ): Promise> { + // Cancel any existing debounce timer for this URI + const existingTimer = this.pendingDebounceTimers.get(uri); + if (existingTimer) { + clearTimeout(existingTimer); + this.pendingDebounceTimers.delete(uri); + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingDebounceTimers.delete(uri); + + const overlay = this.documentStore.get(uri); + const version = overlay ? overlay.version : 0; + const filePath = DocumentStore.uriToFilePath(uri); + + // Shed oldest request if queue depth exceeded + if (this.queue.length >= (this.options.maxQueueDepth ?? 50)) { + const dropped = this.queue.shift(); + if (dropped) { + dropped.reject(new Error("Analysis queue capacity exceeded (load shedding)")); + } + } + + const job: AnalysisJob = { + id: `${uri}:${version}:${Date.now()}`, + uri, + filePath, + version, + workspaceFolders, + cancellationToken, + progressToken, + resolve, + reject, + }; + + this.queue.push(job); + this.processQueue(); + }, this.options.debounceMs ?? 150); + + this.pendingDebounceTimers.set(uri, timer); + }); + } + + private async processQueue(): Promise { + const maxConcurrent = this.options.maxConcurrent ?? 2; + if (this.activeJobsCount >= maxConcurrent || this.queue.length === 0) { + return; + } + + const job = this.queue.shift(); + if (!job) return; + + this.activeJobsCount++; + + try { + if (job.cancellationToken) { + this.activeCancellations.set(job.uri, job.cancellationToken); + } + + if (job.progressToken && this.onWorkDoneProgress) { + this.onWorkDoneProgress(job.progressToken, "begin", { + title: "ChainProof Security Scan", + percentage: 0, + message: `Analyzing ${path.basename(job.filePath)}...`, + }); + } + + const diagnosticsMap = await this.runAnalysisJob(job); + job.resolve(diagnosticsMap); + + if (job.progressToken && this.onWorkDoneProgress) { + this.onWorkDoneProgress(job.progressToken, "end", { + message: "Scan complete", + }); + } + } catch (err) { + if (job.progressToken && this.onWorkDoneProgress) { + this.onWorkDoneProgress(job.progressToken, "end", { + message: "Scan cancelled or failed", + }); + } + job.reject(err instanceof Error ? err : new Error(String(err))); + } finally { + this.activeCancellations.delete(job.uri); + this.activeJobsCount--; + this.processQueue(); + } + } + + private async runAnalysisJob( + job: AnalysisJob + ): Promise> { + const targets = this.determineScanTargets(job); + const config: ScanConfig = { + targets, + useSlither: this.options.scanConfig?.useSlither ?? false, + useLLM: this.options.scanConfig?.useLLM ?? false, + useMetrics: this.options.scanConfig?.useMetrics ?? true, + minSeverity: this.options.scanConfig?.minSeverity ?? "info", + apiKey: this.options.scanConfig?.apiKey, + plugins: this.options.scanConfig?.plugins, + }; + + let scanResult: ScanResult; + + if (!this.watchState || this.watchState.allFiles.length === 0) { + if (job.cancellationToken?.isCancellationRequested) { + throw new Error("Analysis cancelled"); + } + scanResult = await scan(config); + this.watchState = { + allFiles: collectSolFiles(targets), + result: scanResult, + }; + } else { + if (job.cancellationToken?.isCancellationRequested) { + throw new Error("Analysis cancelled"); + } + const outcome = await scanIncremental(config, this.watchState, [job.filePath]); + this.watchState = outcome.state; + scanResult = outcome.state.result; + } + + if (job.cancellationToken?.isCancellationRequested) { + throw new Error("Analysis cancelled"); + } + + const diagnosticsMap = this.mapScanResultToDiagnostics(scanResult); + + // Publish diagnostics to listeners + if (this.onPublishDiagnostics) { + for (const [uri, diagnostics] of diagnosticsMap.entries()) { + const overlay = this.documentStore.get(uri); + const version = overlay ? overlay.version : undefined; + + // Guard against publishing stale diagnostics if document was updated since scan started + const latestPublished = this.latestPublishedVersions.get(uri); + if (version !== undefined && latestPublished !== undefined && version < latestPublished) { + continue; + } + + if (version !== undefined) { + this.latestPublishedVersions.set(uri, version); + } + + this.onPublishDiagnostics(uri, diagnostics, version); + } + } + + return diagnosticsMap; + } + + private determineScanTargets(job: AnalysisJob): string[] { + if (job.workspaceFolders.length > 0) { + return job.workspaceFolders; + } + const dir = path.dirname(job.filePath); + return fs.existsSync(dir) ? [dir] : [job.filePath]; + } + + private mapScanResultToDiagnostics( + scanResult: ScanResult + ): Map { + const map = new Map(); + + for (const fileResult of scanResult.files) { + const uri = DocumentStore.filePathToUri(fileResult.file); + const overlay = this.documentStore.get(uri); + const diagnostics: ExtendedLspDiagnostic[] = []; + + // Vulnerability findings + for (const finding of fileResult.findings) { + const range = overlay + ? DocumentStore.lineToRange(overlay.textDocument, finding.line) + : { + start: { line: Math.max(0, finding.line - 1), character: 0 }, + end: { line: Math.max(0, finding.line - 1), character: 999 }, + }; + + const evidenceItems = finding.evidence + ? finding.evidence.map((e) => ({ + file: finding.file, + line: e.line ?? finding.line, + description: e.description, + })) + : undefined; + + const data: DiagnosticData = { + findingId: finding.id, + swcId: finding.swcId, + recommendation: finding.recommendation, + evidencePath: evidenceItems, + confidence: finding.confidence, + assumptions: finding.assumptions, + isGasHint: false, + }; + + const diag: ExtendedLspDiagnostic = { + range, + severity: AnalysisService.toLspSeverity(finding.severity), + code: finding.swcId ?? finding.id, + source: "ChainProof", + message: `[${finding.id}] ${finding.title}\n${finding.description}\n\nFix: ${finding.recommendation}`, + data, + }; + + if (finding.swcId) { + diag.relatedInformation = [ + { + location: { + uri, + range, + }, + message: `SWC Registry Entry: https://swcregistry.io/docs/${finding.swcId}`, + }, + ]; + } + + diagnostics.push(diag); + } + + // Gas hints + for (const hint of fileResult.gasHints) { + const range = overlay + ? DocumentStore.lineToRange(overlay.textDocument, hint.line) + : { + start: { line: Math.max(0, hint.line - 1), character: 0 }, + end: { line: Math.max(0, hint.line - 1), character: 999 }, + }; + + const data: DiagnosticData = { + ruleId: "GAS", + recommendation: hint.description, + isGasHint: true, + }; + + diagnostics.push({ + range, + severity: DiagnosticSeverity.Hint, + code: "GAS", + source: "ChainProof", + message: `⛽ Gas Optimization: ${hint.description} (${hint.estimatedSaving})`, + data, + }); + } + + map.set(uri, diagnostics); + } + + return map; + } + + /** Reset internal watch state cache */ + public resetWatchState(): void { + this.watchState = undefined; + this.latestPublishedVersions.clear(); + } +} diff --git a/packages/core/src/lsp/code-actions.ts b/packages/core/src/lsp/code-actions.ts new file mode 100644 index 0000000..2e98c9a --- /dev/null +++ b/packages/core/src/lsp/code-actions.ts @@ -0,0 +1,197 @@ +import { + CodeAction, + CodeActionKind, + Command, + TextEdit, + WorkspaceEdit, + type CodeActionParams, +} from "vscode-languageserver"; +import { DocumentStore } from "./document-store"; +import type { ExtendedLspDiagnostic } from "./types"; + +/** + * Provider for LSP CodeActions: deterministic remediations (QuickFixes), + * inline suppression comment insertion, SWC documentation links, and evidence path navigation. + */ +export class ChainProofCodeActionProvider { + private readonly documentStore: DocumentStore; + + constructor(documentStore: DocumentStore) { + this.documentStore = documentStore; + } + + public provideCodeActions(params: CodeActionParams): CodeAction[] { + const actions: CodeAction[] = []; + const overlay = this.documentStore.get(params.textDocument.uri); + if (!overlay) return actions; + + const chainProofDiagnostics = params.context.diagnostics.filter( + (d) => d.source === "ChainProof" + ) as ExtendedLspDiagnostic[]; + + for (const diag of chainProofDiagnostics) { + const data = diag.data; + + // 1. Deterministic QuickFixes + if (data?.findingId === "CP-115" || diag.code === "SWC-115" || diag.code === "CP-115") { + const fixAction = this.createTxOriginQuickFix(overlay.uri, diag); + if (fixAction) actions.push(fixAction); + } else if (data?.findingId === "CP-104" || diag.code === "CP-104") { + const fixAction = this.createUncheckedReturnQuickFix(overlay.uri, diag); + if (fixAction) actions.push(fixAction); + } + + // 2. Suppression CodeAction + const findingOrRuleId = data?.findingId ?? (typeof diag.code === "string" ? diag.code : undefined); + if (findingOrRuleId) { + const suppressAction = this.createSuppressionAction(overlay.uri, diag, findingOrRuleId); + if (suppressAction) actions.push(suppressAction); + } + + // 3. Rule Documentation Link CodeAction + if (data?.swcId) { + actions.push({ + title: `Open SWC-${data.swcId} Documentation`, + kind: CodeActionKind.Empty, + command: Command.create( + "Open Documentation", + "vscode.open", + `https://swcregistry.io/docs/${data.swcId}` + ), + diagnostics: [diag], + }); + } + + // 4. Evidence Path Navigation CodeAction + if (data?.evidencePath && data.evidencePath.length > 0) { + actions.push({ + title: `Show Vulnerability Evidence Trail (${data.evidencePath.length} steps)`, + kind: CodeActionKind.Empty, + command: Command.create( + "Show Evidence Trail", + "chainproof.showEvidenceTrail", + overlay.uri, + data.evidencePath + ), + diagnostics: [diag], + }); + } + } + + return actions; + } + + private createTxOriginQuickFix(uri: string, diag: ExtendedLspDiagnostic): CodeAction | null { + const overlay = this.documentStore.get(uri); + if (!overlay) return null; + + const line = diag.range.start.line; + const lineText = overlay.textDocument.getText({ + start: { line, character: 0 }, + end: { line: line + 1, character: 0 }, + }); + + if (!lineText.includes("tx.origin")) return null; + + const newText = lineText.replace(/\btx\.origin\b/g, "msg.sender"); + const edit: WorkspaceEdit = { + changes: { + [uri]: [ + TextEdit.replace( + { + start: { line, character: 0 }, + end: { line, character: lineText.length }, + }, + newText + ), + ], + }, + }; + + return { + title: "Replace tx.origin with msg.sender (ChainProof QuickFix)", + kind: CodeActionKind.QuickFix, + isPreferred: true, + edit, + diagnostics: [diag], + }; + } + + private createUncheckedReturnQuickFix(uri: string, diag: ExtendedLspDiagnostic): CodeAction | null { + const overlay = this.documentStore.get(uri); + if (!overlay) return null; + + const line = diag.range.start.line; + const lineText = overlay.textDocument.getText({ + start: { line, character: 0 }, + end: { line: line + 1, character: 0 }, + }); + + const trimmed = lineText.trim(); + const indent = lineText.substring(0, lineText.indexOf(trimmed)); + let newText: string; + + if (trimmed.endsWith(";")) { + const stmt = trimmed.slice(0, -1).trim(); + newText = `${indent}(bool success, ) = ${stmt};\n${indent}require(success, "External call failed");\n`; + } else { + newText = `${indent}(bool success, ) = ${trimmed};\n${indent}require(success, "External call failed");\n`; + } + + const edit: WorkspaceEdit = { + changes: { + [uri]: [ + TextEdit.replace( + { + start: { line, character: 0 }, + end: { line, character: lineText.length }, + }, + newText + ), + ], + }, + }; + + return { + title: "Check call return value with require(success) (ChainProof QuickFix)", + kind: CodeActionKind.QuickFix, + isPreferred: true, + edit, + diagnostics: [diag], + }; + } + + private createSuppressionAction( + uri: string, + diag: ExtendedLspDiagnostic, + ruleId: string + ): CodeAction | null { + const overlay = this.documentStore.get(uri); + if (!overlay) return null; + + const line = diag.range.start.line; + const lineText = overlay.textDocument.getText({ + start: { line, character: 0 }, + end: { line: line + 1, character: 0 }, + }); + + const trimmed = lineText.trim(); + const indent = lineText.substring(0, lineText.indexOf(trimmed)); + const suppressionComment = `${indent}// chainproof-disable-next-line ${ruleId}\n`; + + const edit: WorkspaceEdit = { + changes: { + [uri]: [ + TextEdit.insert({ line, character: 0 }, suppressionComment), + ], + }, + }; + + return { + title: `Suppress finding ${ruleId} with inline comment (ChainProof)`, + kind: CodeActionKind.QuickFix, + edit, + diagnostics: [diag], + }; + } +} diff --git a/packages/core/src/lsp/document-store.ts b/packages/core/src/lsp/document-store.ts new file mode 100644 index 0000000..89f71a5 --- /dev/null +++ b/packages/core/src/lsp/document-store.ts @@ -0,0 +1,215 @@ +import { TextDocument } from "vscode-languageserver-textdocument"; +import type { Position, Range } from "vscode-languageserver"; +import * as path from "path"; +import * as fs from "fs"; +import { parseSolidity } from "../ast/parser"; +import { ASTCache } from "../ast/cache"; +import { buildImportGraph, type ImportGraph, type ParsedSolidityFile } from "../ast/import-graph"; +import type { ASTNode } from "../types"; + +export interface OverlayDocument { + uri: string; + filePath: string; + version: number; + textDocument: TextDocument; + ast?: ASTNode; + parseError?: string; + contentHash: string; + lastUpdated: number; +} + +/** + * Manages open documents in memory (editor overlays), tracking buffer edits, + * line/character offsets, AST caching, and dependency relationships with on-disk files. + */ +export class DocumentStore { + private readonly documents = new Map(); + private readonly astCache: ASTCache; + + constructor(astCache?: ASTCache) { + this.astCache = astCache ?? new ASTCache(); + } + + /** Convert file URI or file path into normalized absolute file path */ + public static uriToFilePath(uri: string): string { + if (uri.startsWith("file://")) { + const decoded = decodeURIComponent(uri.replace(/^file:\/\//, "")); + // Handle Windows drive letter formatting (e.g. /c:/ -> c:/) + if (process.platform === "win32" && /^\/[a-zA-Z]:/.test(decoded)) { + return path.normalize(decoded.slice(1)); + } + return path.normalize(decoded); + } + return path.resolve(uri); + } + + /** Convert file path to file:// URI */ + public static filePathToUri(filePath: string): string { + const absPath = path.resolve(filePath).replace(/\\/g, "/"); + if (!absPath.startsWith("/")) { + return `file:///${absPath}`; + } + return `file://${absPath}`; + } + + /** Open or update a document buffer from an LSP didOpen or didChange event */ + public openOrUpdate( + uri: string, + version: number, + text: string, + languageId = "solidity" + ): OverlayDocument { + const filePath = DocumentStore.uriToFilePath(uri); + const existing = this.documents.get(uri); + + let doc: TextDocument; + if (existing) { + doc = TextDocument.create(uri, languageId, version, text); + } else { + doc = TextDocument.create(uri, languageId, version, text); + } + + const contentHash = ASTCache.hashContent(text); + let ast: ASTNode | undefined; + let parseError: string | undefined; + + const cached = this.astCache.get(contentHash); + if (cached) { + ast = cached.ast; + } else { + const parsed = parseSolidity(text, filePath); + ast = parsed.ast ?? undefined; + parseError = parsed.error; + if (ast) { + this.astCache.set(contentHash, { + contentHash, + ast, + parsedAt: Date.now(), + filePath, + }); + } + } + + const overlay: OverlayDocument = { + uri, + filePath, + version, + textDocument: doc, + ast, + parseError, + contentHash, + lastUpdated: Date.now(), + }; + + this.documents.set(uri, overlay); + return overlay; + } + + /** Close a document buffer */ + public close(uri: string): boolean { + return this.documents.delete(uri); + } + + /** Retrieve overlay document by URI */ + public get(uri: string): OverlayDocument | undefined { + return this.documents.get(uri); + } + + /** Retrieve overlay document by physical file path */ + public getByFilePath(filePath: string): OverlayDocument | undefined { + const resolved = path.resolve(filePath); + for (const doc of this.documents.values()) { + if (path.resolve(doc.filePath) === resolved) { + return doc; + } + } + return undefined; + } + + /** List all open documents */ + public getAll(): OverlayDocument[] { + return Array.from(this.documents.values()); + } + + /** Check if document is open */ + public has(uri: string): boolean { + return this.documents.has(uri); + } + + /** Count of open documents */ + public get size(): number { + return this.documents.size; + } + + /** Convert line (1-indexed) and column (1-indexed) to LSP Position (0-indexed line/char) */ + public static toPosition(line: number, column = 1): Position { + return { + line: Math.max(0, line - 1), + character: Math.max(0, column - 1), + }; + } + + /** Convert LSP Position (0-indexed) to 1-indexed line number */ + public static positionToLine(position: Position): number { + return position.line + 1; + } + + /** Create an LSP Range for a 1-indexed line number */ + public static lineToRange(document: TextDocument, line1Indexed: number): Range { + const lineIndex = Math.max(0, line1Indexed - 1); + const lineCount = document.lineCount; + + if (lineIndex >= lineCount) { + const lastLine = Math.max(0, lineCount - 1); + const text = document.getText({ + start: { line: lastLine, character: 0 }, + end: { line: lastLine, character: Number.MAX_SAFE_INTEGER }, + }); + return { + start: { line: lastLine, character: 0 }, + end: { line: lastLine, character: text.length }, + }; + } + + const lineText = document.getText({ + start: { line: lineIndex, character: 0 }, + end: { line: lineIndex + 1, character: 0 }, + }); + // Remove newline char if included + const length = lineText.replace(/[\r\n]+$/, "").length; + + return { + start: { line: lineIndex, character: 0 }, + end: { line: lineIndex, character: Math.max(0, length) }, + }; + } + + /** + * Build a unified ImportGraph combining in-memory overlay documents and on-disk files. + * Overlay documents override disk content for any open file. + */ + public buildOverlayImportGraph(knownFiles: string[]): ImportGraph { + const allFilePaths = new Set(); + for (const f of knownFiles) { + allFilePaths.add(path.resolve(f)); + } + for (const doc of this.documents.values()) { + allFilePaths.add(path.resolve(doc.filePath)); + } + + const graph = buildImportGraph(Array.from(allFilePaths)); + + // Override with open overlay files content and ast where applicable + for (const [absPath, parsed] of graph.files.entries()) { + const overlay = this.getByFilePath(absPath); + if (overlay) { + parsed.source = overlay.textDocument.getText(); + if (overlay.ast) { + parsed.ast = overlay.ast; + } + } + } + + return graph; + } +} diff --git a/packages/core/src/lsp/index.ts b/packages/core/src/lsp/index.ts new file mode 100644 index 0000000..e45bd30 --- /dev/null +++ b/packages/core/src/lsp/index.ts @@ -0,0 +1,26 @@ +export { DocumentStore } from "./document-store"; +export type { OverlayDocument } from "./document-store"; + +export { AnalysisService } from "./analysis-service"; +export type { AnalysisJob, PublishDiagnosticsCallback, WorkDoneProgressCallback } from "./analysis-service"; + +export { LspTransportListener } from "./transports"; +export type { TransportConnection, ConnectionHandler } from "./transports"; + +export { ChainProofCodeActionProvider } from "./code-actions"; +export { ChainProofProviders } from "./providers"; + +export { ChainProofLspServer, startLspDaemon } from "./server"; + +export { ChainProofLspMethods } from "./types"; +export type { + LspDaemonOptions, + TransportType, + DiagnosticData, + ExtendedLspDiagnostic, + LspStatus, + ThreatModelRequestParams, + ScanReportRequestParams, + ScanReportResponse, + ClearCacheResponse, +} from "./types"; diff --git a/packages/core/src/lsp/providers.ts b/packages/core/src/lsp/providers.ts new file mode 100644 index 0000000..9d12ce0 --- /dev/null +++ b/packages/core/src/lsp/providers.ts @@ -0,0 +1,332 @@ +import { + Hover, + DocumentSymbol, + SymbolKind, + CallHierarchyItem, + CallHierarchyIncomingCall, + CallHierarchyOutgoingCall, + Location, + MarkupKind, + type HoverParams, + type DocumentSymbolParams, + type CallHierarchyPrepareParams, + type CallHierarchyIncomingCallsParams, + type CallHierarchyOutgoingCallsParams, + type ReferenceParams, +} from "vscode-languageserver"; +import { DocumentStore } from "./document-store"; +import { visit } from "../ast/parser"; +import type { ASTNode } from "../types"; +import type { ExtendedLspDiagnostic } from "./types"; + +/** + * Providers for language intelligence features: + * Hover cards, Document Symbols, Call Hierarchy, and References. + */ +export class ChainProofProviders { + private readonly documentStore: DocumentStore; + private readonly currentDiagnostics = new Map(); + + constructor(documentStore: DocumentStore) { + this.documentStore = documentStore; + } + + public setDiagnostics(uri: string, diagnostics: ExtendedLspDiagnostic[]): void { + this.currentDiagnostics.set(uri, diagnostics); + } + + // ── 1. Hover Provider ──────────────────────────────────────────────────────── + + public provideHover(params: HoverParams): Hover | null { + const overlay = this.documentStore.get(params.textDocument.uri); + if (!overlay) return null; + + const line = params.position.line; + const diagnostics = this.currentDiagnostics.get(params.textDocument.uri) ?? []; + const lineDiagnostics = diagnostics.filter( + (d) => d.range.start.line <= line && line <= d.range.end.line + ); + + if (lineDiagnostics.length > 0) { + const markdownContents = lineDiagnostics.map((d) => { + const data = d.data; + const confidenceBadge = data?.confidence ? ` **[Confidence: ${data.confidence}]**` : ""; + const swcLink = data?.swcId ? `\n\n[SWC-${data.swcId} Reference](https://swcregistry.io/docs/${data.swcId})` : ""; + const rec = data?.recommendation ? `\n\n**Recommendation:**\n${data.recommendation}` : ""; + + return `### 🛡️ ChainProof Security Finding (${d.code || "CP"}) ${confidenceBadge}\n\n${d.message}${rec}${swcLink}`; + }); + + return { + contents: { + kind: MarkupKind.Markdown, + value: markdownContents.join("\n\n---\n\n"), + }, + }; + } + + return null; + } + + // ── 2. Document Symbols Provider ───────────────────────────────────────────── + + public provideDocumentSymbols(params: DocumentSymbolParams): DocumentSymbol[] { + const overlay = this.documentStore.get(params.textDocument.uri); + if (!overlay || !overlay.ast) return []; + + const symbols: DocumentSymbol[] = []; + + visit(overlay.ast, { + ContractDefinition(node: ASTNode) { + const contract = node as { + name?: string; + kind?: string; + loc?: { start: { line: number; column: number }; end: { line: number; column: number } }; + }; + if (!contract.name || !contract.loc) return; + + const range = { + start: DocumentStore.toPosition(contract.loc.start.line, contract.loc.start.column), + end: DocumentStore.toPosition(contract.loc.end.line, contract.loc.end.column), + }; + + const children: DocumentSymbol[] = []; + visit(node, { + FunctionDefinition(fnNode: ASTNode) { + const fn = fnNode as { + name?: string; + visibility?: string; + isConstructor?: boolean; + loc?: { start: { line: number; column: number }; end: { line: number; column: number } }; + }; + if (!fn.loc) return; + const fnName = fn.isConstructor ? "constructor" : fn.name || "fallback"; + + children.push({ + name: fnName, + detail: fn.visibility || "public", + kind: SymbolKind.Function, + range: { + start: DocumentStore.toPosition(fn.loc.start.line, fn.loc.start.column), + end: DocumentStore.toPosition(fn.loc.end.line, fn.loc.end.column), + }, + selectionRange: { + start: DocumentStore.toPosition(fn.loc.start.line, fn.loc.start.column), + end: DocumentStore.toPosition(fn.loc.start.line, fn.loc.start.column + fnName.length), + }, + }); + }, + StateVariableDeclaration(varNode: ASTNode) { + const decl = varNode as { + variables?: Array<{ + name?: string; + typeName?: { name?: string }; + loc?: { start: { line: number; column: number }; end: { line: number; column: number } }; + }>; + }; + for (const v of decl.variables ?? []) { + if (!v.name || !v.loc) continue; + children.push({ + name: v.name, + detail: v.typeName?.name || "var", + kind: SymbolKind.Variable, + range: { + start: DocumentStore.toPosition(v.loc.start.line, v.loc.start.column), + end: DocumentStore.toPosition(v.loc.end.line, v.loc.end.column), + }, + selectionRange: { + start: DocumentStore.toPosition(v.loc.start.line, v.loc.start.column), + end: DocumentStore.toPosition(v.loc.start.line, v.loc.start.column + v.name.length), + }, + }); + } + }, + EventDefinition(evtNode: ASTNode) { + const evt = evtNode as { + name?: string; + loc?: { start: { line: number; column: number }; end: { line: number; column: number } }; + }; + if (!evt.name || !evt.loc) return; + children.push({ + name: evt.name, + kind: SymbolKind.Event, + range: { + start: DocumentStore.toPosition(evt.loc.start.line, evt.loc.start.column), + end: DocumentStore.toPosition(evt.loc.end.line, evt.loc.end.column), + }, + selectionRange: { + start: DocumentStore.toPosition(evt.loc.start.line, evt.loc.start.column), + end: DocumentStore.toPosition(evt.loc.start.line, evt.loc.start.column + evt.name.length), + }, + }); + }, + }); + + symbols.push({ + name: contract.name, + detail: contract.kind || "contract", + kind: contract.kind === "interface" ? SymbolKind.Interface : SymbolKind.Class, + range, + selectionRange: { + start: range.start, + end: { line: range.start.line, character: range.start.character + contract.name.length }, + }, + children, + }); + }, + }); + + return symbols; + } + + // ── 3. Call Hierarchy Provider ─────────────────────────────────────────────── + + public prepareCallHierarchy(params: CallHierarchyPrepareParams): CallHierarchyItem[] | null { + const overlay = this.documentStore.get(params.textDocument.uri); + if (!overlay || !overlay.ast) return null; + + const targetLine = params.position.line + 1; + let targetFnName: string | null = null; + let targetFnLoc: { start: { line: number; column: number }; end: { line: number; column: number } } | null = null; + + visit(overlay.ast, { + FunctionDefinition(node: ASTNode) { + const fn = node as { + name?: string; + loc?: { start: { line: number; column: number }; end: { line: number; column: number } }; + }; + if (fn.loc && fn.loc.start.line <= targetLine && targetLine <= fn.loc.end.line) { + targetFnName = fn.name || "anonymous"; + targetFnLoc = fn.loc; + } + }, + }); + + if (!targetFnName || !targetFnLoc) return null; + + const loc = targetFnLoc as { start: { line: number; column: number }; end: { line: number; column: number } }; + const fnRange = { + start: DocumentStore.toPosition(loc.start.line, loc.start.column), + end: DocumentStore.toPosition(loc.end.line, loc.end.column), + }; + + return [ + { + name: targetFnName, + kind: SymbolKind.Function, + uri: params.textDocument.uri, + range: fnRange, + selectionRange: fnRange, + }, + ]; + } + + public provideIncomingCalls(params: CallHierarchyIncomingCallsParams): CallHierarchyIncomingCall[] { + const incoming: CallHierarchyIncomingCall[] = []; + const targetItem = params.item; + const allDocs = this.documentStore.getAll(); + + for (const doc of allDocs) { + if (!doc.ast) continue; + + visit(doc.ast, { + FunctionCall(node: ASTNode) { + const call = node as { + expression?: { name?: string; memberName?: string }; + loc?: { start: { line: number; column: number }; end: { line: number; column: number } }; + }; + const calledName = call.expression?.name || call.expression?.memberName; + + if (calledName === targetItem.name && call.loc) { + const callRange = { + start: DocumentStore.toPosition(call.loc.start.line, call.loc.start.column), + end: DocumentStore.toPosition(call.loc.end.line, call.loc.end.column), + }; + + incoming.push({ + from: { + name: "caller", + kind: SymbolKind.Function, + uri: doc.uri, + range: callRange, + selectionRange: callRange, + }, + fromRanges: [callRange], + }); + } + }, + }); + } + + return incoming; + } + + public provideOutgoingCalls(params: CallHierarchyOutgoingCallsParams): CallHierarchyOutgoingCall[] { + const outgoing: CallHierarchyOutgoingCall[] = []; + const targetItem = params.item; + const overlay = this.documentStore.get(targetItem.uri); + if (!overlay || !overlay.ast) return outgoing; + + visit(overlay.ast, { + FunctionCall(node: ASTNode) { + const call = node as { + expression?: { name?: string; memberName?: string }; + loc?: { start: { line: number; column: number }; end: { line: number; column: number } }; + }; + const calledName = call.expression?.name || call.expression?.memberName; + + if (calledName && call.loc) { + const callRange = { + start: DocumentStore.toPosition(call.loc.start.line, call.loc.start.column), + end: DocumentStore.toPosition(call.loc.end.line, call.loc.end.column), + }; + + outgoing.push({ + to: { + name: calledName, + kind: SymbolKind.Function, + uri: targetItem.uri, + range: callRange, + selectionRange: callRange, + }, + fromRanges: [callRange], + }); + } + }, + }); + + return outgoing; + } + + // ── 4. References Provider ─────────────────────────────────────────────────── + + public provideReferences(params: ReferenceParams): Location[] { + const locations: Location[] = []; + const overlay = this.documentStore.get(params.textDocument.uri); + if (!overlay) return locations; + + const line = params.position.line; + const diagnostics = this.currentDiagnostics.get(params.textDocument.uri) ?? []; + const lineDiagnostics = diagnostics.filter( + (d) => d.range.start.line <= line && line <= d.range.end.line + ); + + for (const d of lineDiagnostics) { + if (d.data?.evidencePath) { + for (const item of d.data.evidencePath) { + const itemUri = DocumentStore.filePathToUri(item.file); + const targetOverlay = this.documentStore.get(itemUri); + const range = targetOverlay + ? DocumentStore.lineToRange(targetOverlay.textDocument, item.line) + : { + start: { line: Math.max(0, item.line - 1), character: 0 }, + end: { line: Math.max(0, item.line - 1), character: 999 }, + }; + locations.push({ uri: itemUri, range }); + } + } + } + + return locations; + } +} diff --git a/packages/core/src/lsp/server.ts b/packages/core/src/lsp/server.ts new file mode 100644 index 0000000..913e769 --- /dev/null +++ b/packages/core/src/lsp/server.ts @@ -0,0 +1,303 @@ +import { + createConnection, + ProposedFeatures, + TextDocumentSyncKind, + type Connection, + type InitializeParams, + type InitializeResult, + type ServerCapabilities, + type DidOpenTextDocumentParams, + type DidChangeTextDocumentParams, + type DidCloseTextDocumentParams, + type DidSaveTextDocumentParams, +} from "vscode-languageserver/node"; +import type { CancellationToken } from "vscode-languageserver"; + +import { DocumentStore } from "./document-store"; +import { AnalysisService } from "./analysis-service"; +import { LspTransportListener } from "./transports"; +import { ChainProofCodeActionProvider } from "./code-actions"; +import { ChainProofProviders } from "./providers"; +import { clearCache, getCacheStats } from "../ast/cache"; +import { generateThreatModel, generateMarkdownThreatModel, generateJSONThreatModel } from "../threat-model"; +import { generateMarkdownReport, generateJSONReport, generateTableReport } from "../report/generator"; +import { scan as runScan } from "../scanner"; +import type { ScanConfig } from "../types"; + +import { + ChainProofLspMethods, + type LspDaemonOptions, + type LspStatus, + type ThreatModelRequestParams, + type ScanReportRequestParams, + type ScanReportResponse, + type ClearCacheResponse, + type ExtendedLspDiagnostic, +} from "./types"; + +export class ChainProofLspServer { + private readonly documentStore: DocumentStore; + private readonly analysisService: AnalysisService; + private readonly codeActionProvider: ChainProofCodeActionProvider; + private readonly providers: ChainProofProviders; + private readonly transportListener: LspTransportListener; + private readonly options: LspDaemonOptions; + + private connection: Connection | undefined; + private workspaceFolders: string[] = []; + private startTime = Date.now(); + + constructor(options: LspDaemonOptions = {}) { + this.options = options; + this.documentStore = new DocumentStore(); + this.analysisService = new AnalysisService(this.documentStore, options); + this.codeActionProvider = new ChainProofCodeActionProvider(this.documentStore); + this.providers = new ChainProofProviders(this.documentStore); + this.transportListener = new LspTransportListener(options); + + // Wire up diagnostic publishing callback + this.analysisService.onPublishDiagnostics = (uri, diagnostics) => { + this.providers.setDiagnostics(uri, diagnostics); + if (this.connection) { + this.connection.sendDiagnostics({ uri, diagnostics }); + } + }; + } + + /** + * Start the LSP daemon server listening on configured transport. + */ + public start(): void { + this.transportListener.listen((transportConn) => { + const conn = createConnection(ProposedFeatures.all, transportConn.reader, transportConn.writer); + this.connection = conn; + this.bindConnectionHandlers(conn); + conn.listen(); + }); + } + + /** Stop the server and release resources */ + public stop(): void { + this.transportListener.close(); + if (this.connection) { + this.connection.dispose(); + this.connection = undefined; + } + } + + private bindConnectionHandlers(connection: Connection): void { + // ── 1. Lifecycle Handlers ────────────────────────────────────────────────── + connection.onInitialize((params: InitializeParams): InitializeResult => { + this.workspaceFolders = []; + if (params.workspaceFolders) { + this.workspaceFolders = params.workspaceFolders.map((wf) => DocumentStore.uriToFilePath(wf.uri)); + } else if (params.rootUri) { + this.workspaceFolders = [DocumentStore.uriToFilePath(params.rootUri)]; + } else if (params.rootPath) { + this.workspaceFolders = [params.rootPath]; + } + + const capabilities: ServerCapabilities = { + textDocumentSync: TextDocumentSyncKind.Full, + codeActionProvider: { + codeActionKinds: ["quickfix", "refactor"], + }, + hoverProvider: true, + documentSymbolProvider: true, + callHierarchyProvider: true, + referencesProvider: true, + workspace: { + workspaceFolders: { + supported: true, + changeNotifications: true, + }, + }, + }; + + return { capabilities }; + }); + + connection.onInitialized(() => { + this.options.logger?.("info", "ChainProof LSP Daemon initialized."); + }); + + connection.onShutdown(() => { + this.options.logger?.("info", "ChainProof LSP Daemon shutting down."); + this.analysisService.resetWatchState(); + }); + + connection.onExit(() => { + this.stop(); + }); + + // Workspace folder updates + connection.workspace.onDidChangeWorkspaceFolders((event) => { + for (const removed of event.removed) { + const pathRemoved = DocumentStore.uriToFilePath(removed.uri); + this.workspaceFolders = this.workspaceFolders.filter((f) => f !== pathRemoved); + } + for (const added of event.added) { + const pathAdded = DocumentStore.uriToFilePath(added.uri); + if (!this.workspaceFolders.includes(pathAdded)) { + this.workspaceFolders.push(pathAdded); + } + } + this.analysisService.resetWatchState(); + }); + + // ── 2. Document Synchronization Handlers ──────────────────────────────── + connection.onDidOpenTextDocument((params: DidOpenTextDocumentParams) => { + const { uri, version, text, languageId } = params.textDocument; + this.documentStore.openOrUpdate(uri, version, text, languageId); + this.analysisService.scheduleAnalysis(uri, this.workspaceFolders).catch(() => {}); + }); + + connection.onDidChangeTextDocument((params: DidChangeTextDocumentParams) => { + const { uri, version } = params.textDocument; + const change = params.contentChanges[0]; + if (change && "text" in change) { + this.documentStore.openOrUpdate(uri, version, change.text); + this.analysisService.scheduleAnalysis(uri, this.workspaceFolders).catch(() => {}); + } + }); + + connection.onDidSaveTextDocument((params: DidSaveTextDocumentParams) => { + const { uri } = params.textDocument; + if (params.text) { + const overlay = this.documentStore.get(uri); + const version = overlay ? overlay.version : 0; + this.documentStore.openOrUpdate(uri, version, params.text); + } + this.analysisService.scheduleAnalysis(uri, this.workspaceFolders).catch(() => {}); + }); + + connection.onDidCloseTextDocument((params: DidCloseTextDocumentParams) => { + this.documentStore.close(params.textDocument.uri); + }); + + // ── 3. Language Intelligence Handlers ───────────────────────────────────── + connection.onCodeAction((params) => this.codeActionProvider.provideCodeActions(params)); + connection.onHover((params) => this.providers.provideHover(params)); + connection.onDocumentSymbol((params) => this.providers.provideDocumentSymbols(params)); + connection.languages.callHierarchy.onPrepare((params) => this.providers.prepareCallHierarchy(params)); + connection.languages.callHierarchy.onIncomingCalls((params) => this.providers.provideIncomingCalls(params)); + connection.languages.callHierarchy.onOutgoingCalls((params) => this.providers.provideOutgoingCalls(params)); + connection.onReferences((params) => this.providers.provideReferences(params)); + + // ── 4. Custom Request Handlers ─────────────────────────────────────────── + + // chainproof/threatModel + connection.onRequest( + ChainProofLspMethods.ThreatModel, + async (params: ThreatModelRequestParams, cancelToken: CancellationToken) => { + let targets: string[] = []; + if (params.uri) { + targets = [DocumentStore.uriToFilePath(params.uri)]; + } else if (params.workspacePath) { + targets = [params.workspacePath]; + } else if (this.workspaceFolders.length > 0) { + targets = this.workspaceFolders; + } else { + throw new Error("No target URI or workspace specified for threat model generation"); + } + + const model = await generateThreatModel({ + targets, + assumptionsPath: params.assumptionsPath, + minSeverity: params.minSeverity ?? "low", + }); + + if (cancelToken.isCancellationRequested) { + throw new Error("Request cancelled"); + } + + return { + threatModel: model, + markdown: generateMarkdownThreatModel(model), + json: generateJSONThreatModel(model), + }; + } + ); + + // chainproof/scanReport + connection.onRequest( + ChainProofLspMethods.ScanReport, + async (params: ScanReportRequestParams, cancelToken: CancellationToken): Promise => { + let targets: string[] = []; + if (params.uri) { + targets = [DocumentStore.uriToFilePath(params.uri)]; + } else if (params.workspacePath) { + targets = [params.workspacePath]; + } else if (this.workspaceFolders.length > 0) { + targets = this.workspaceFolders; + } else { + throw new Error("No target URI or workspace specified for scan report generation"); + } + + const config: ScanConfig = { + targets, + useSlither: this.options.scanConfig?.useSlither ?? false, + useLLM: this.options.scanConfig?.useLLM ?? false, + useMetrics: this.options.scanConfig?.useMetrics ?? true, + minSeverity: params.minSeverity ?? "low", + }; + + const scanResult = await runScan(config); + + if (cancelToken.isCancellationRequested) { + throw new Error("Request cancelled"); + } + + const format = params.format ?? "markdown"; + let content: string; + if (format === "json") { + content = generateJSONReport(scanResult); + } else if (format === "table") { + content = generateTableReport(scanResult); + } else { + content = generateMarkdownReport(scanResult); + } + + return { + format, + content, + summary: scanResult.summary, + }; + } + ); + + // chainproof/clearCache + connection.onRequest(ChainProofLspMethods.ClearCache, (): ClearCacheResponse => { + clearCache(); + this.analysisService.resetWatchState(); + return { + cleared: true, + message: "AST cache and watch state cleared successfully.", + }; + }); + + // chainproof/status + connection.onRequest(ChainProofLspMethods.Status, (): LspStatus => { + const stats = getCacheStats(); + return { + openDocumentsCount: this.documentStore.size, + queueDepth: this.analysisService.queueDepth, + activeAnalyses: this.analysisService.activeAnalyses, + cacheStats: { + hits: stats.hits, + misses: stats.misses, + entries: stats.hits + stats.misses, + }, + uptimeSeconds: Math.floor((Date.now() - this.startTime) / 1000), + workspaceFolders: this.workspaceFolders, + }; + }); + } +} + +/** Utility function to start LSP Daemon server instance */ +export function startLspDaemon(options: LspDaemonOptions = {}): ChainProofLspServer { + const server = new ChainProofLspServer(options); + server.start(); + return server; +} diff --git a/packages/core/src/lsp/transports.ts b/packages/core/src/lsp/transports.ts new file mode 100644 index 0000000..15bf2a3 --- /dev/null +++ b/packages/core/src/lsp/transports.ts @@ -0,0 +1,156 @@ +import * as net from "net"; +import * as fs from "fs"; +import * as path from "path"; +import type { Stream } from "stream"; +import { + StreamMessageReader, + StreamMessageWriter, + SocketMessageReader, + SocketMessageWriter, + type MessageReader, + type MessageWriter, +} from "vscode-languageserver/node"; +import type { LspDaemonOptions } from "./types"; + +export interface TransportConnection { + reader: MessageReader; + writer: MessageWriter; + close: () => void; +} + +export type ConnectionHandler = (connection: TransportConnection) => void; + +/** + * Transport abstraction supporting stdio streams and authenticated local IPC/TCP sockets. + */ +export class LspTransportListener { + private readonly options: LspDaemonOptions; + private netServer: net.Server | undefined; + private isListening = false; + + constructor(options: LspDaemonOptions = {}) { + this.options = options; + } + + /** + * Listen for incoming LSP client connections based on daemon options (stdio, IPC socket, or TCP port). + */ + public listen(onConnection: ConnectionHandler): void { + const transport = this.options.transport ?? "stdio"; + + if (transport === "stdio") { + const reader = new StreamMessageReader(process.stdin); + const writer = new StreamMessageWriter(process.stdout); + onConnection({ + reader, + writer, + close: () => { + reader.dispose(); + writer.dispose(); + }, + }); + this.isListening = true; + return; + } + + this.netServer = net.createServer((socket: net.Socket) => { + this.handleSocketConnection(socket, onConnection); + }); + + if (transport === "ipc") { + const socketPath = this.options.socketPath ?? this.defaultIpcPath(); + // Remove stale socket file if it exists + if (fs.existsSync(socketPath)) { + try { + fs.unlinkSync(socketPath); + } catch { + // Ignore + } + } + + // Ensure directory exists + const dir = path.dirname(socketPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + this.netServer.listen(socketPath, () => { + this.isListening = true; + this.options.logger?.("info", `LSP IPC daemon listening on socket ${socketPath}`); + }); + } else if (transport === "tcp") { + const port = this.options.port ?? 8433; + this.netServer.listen(port, "127.0.0.1", () => { + this.isListening = true; + this.options.logger?.("info", `LSP TCP daemon listening on 127.0.0.1:${port}`); + }); + } + } + + private handleSocketConnection(socket: net.Socket, onConnection: ConnectionHandler): void { + // Socket authentication check if authToken is configured + if (this.options.authToken) { + let authenticated = false; + const authTimeout = setTimeout(() => { + if (!authenticated) { + this.options.logger?.("warn", "Socket connection authentication timeout. Closing socket."); + socket.destroy(); + } + }, 5000); + + const onData = (data: Buffer) => { + const line = data.toString("utf-8").trim(); + const authHeader = line.startsWith("AUTH ") ? line.slice(5) : line; + + if (authHeader === this.options.authToken) { + authenticated = true; + clearTimeout(authTimeout); + socket.removeListener("data", onData); + this.options.logger?.("info", "Socket client authenticated successfully."); + this.bindSocketReaderWriter(socket, onConnection); + } else { + clearTimeout(authTimeout); + this.options.logger?.("warn", "Socket authentication failed: invalid token."); + socket.write("AUTH_FAILED\n"); + socket.destroy(); + } + }; + + socket.on("data", onData); + } else { + this.bindSocketReaderWriter(socket, onConnection); + } + } + + private bindSocketReaderWriter(socket: net.Socket, onConnection: ConnectionHandler): void { + const reader = new SocketMessageReader(socket); + const writer = new SocketMessageWriter(socket); + + const connection: TransportConnection = { + reader, + writer, + close: () => { + reader.dispose(); + writer.dispose(); + socket.destroy(); + }, + }; + + onConnection(connection); + } + + private defaultIpcPath(): string { + if (process.platform === "win32") { + return "\\\\.\\pipe\\chainproof-lsp"; + } + return path.join(process.env.TMPDIR || "/tmp", "chainproof-lsp.sock"); + } + + /** Close transport listener server */ + public close(): void { + if (this.netServer && this.isListening) { + this.netServer.close(); + this.isListening = false; + } + } +} diff --git a/packages/core/src/lsp/types.ts b/packages/core/src/lsp/types.ts new file mode 100644 index 0000000..cef493e --- /dev/null +++ b/packages/core/src/lsp/types.ts @@ -0,0 +1,95 @@ +import type { Finding, GasHint, ScanConfig, ASTNode } from "../types"; +import type { ThreatModel } from "../threat-model"; +import type { Range, Diagnostic as LspDiagnostic, DiagnosticSeverity as LspDiagnosticSeverity } from "vscode-languageserver"; + +export type TransportType = "stdio" | "ipc" | "tcp"; + +export interface LspDaemonOptions { + /** Transport protocol: stdio, IPC socket, or TCP socket */ + transport?: TransportType; + /** Path for IPC domain socket (used when transport === 'ipc') */ + socketPath?: string; + /** Port for TCP server (used when transport === 'tcp') */ + port?: number; + /** Secret bearer token required for socket authentication */ + authToken?: string; + /** Maximum pending requests in the queue before shedding load */ + maxQueueDepth?: number; + /** Maximum concurrent analysis tasks */ + maxConcurrent?: number; + /** Debounce delay for document changes in milliseconds */ + debounceMs?: number; + /** Base scan configuration overrides */ + scanConfig?: Partial; + /** Optional logger function */ + logger?: (level: "info" | "warn" | "error" | "debug", message: string) => void; +} + +export interface DiagnosticData { + findingId?: string; + swcId?: string; + ruleId?: string; + recommendation?: string; + evidencePath?: Array<{ file: string; line: number; description: string }>; + confidence?: "high" | "medium" | "low"; + assumptions?: string[]; + isGasHint?: boolean; +} + +export interface ExtendedLspDiagnostic extends LspDiagnostic { + data?: DiagnosticData; +} + +export interface LspStatus { + openDocumentsCount: number; + queueDepth: number; + activeAnalyses: number; + cacheStats: { + hits: number; + misses: number; + entries: number; + }; + uptimeSeconds: number; + workspaceFolders: string[]; +} + +export interface ThreatModelRequestParams { + uri?: string; + workspacePath?: string; + assumptionsPath?: string; + minSeverity?: "critical" | "high" | "medium" | "low"; +} + +export interface ScanReportRequestParams { + uri?: string; + workspacePath?: string; + format?: "markdown" | "json" | "table"; + minSeverity?: "critical" | "high" | "medium" | "low" | "info"; +} + +export interface ScanReportResponse { + format: "markdown" | "json" | "table"; + content: string; + summary: { + critical: number; + high: number; + medium: number; + low: number; + info: number; + gas: number; + total: number; + }; +} + +export interface ClearCacheResponse { + cleared: boolean; + message: string; +} + +/** Custom JSON-RPC method strings for ChainProof protocol extensions */ +export const ChainProofLspMethods = { + ThreatModel: "chainproof/threatModel", + ScanReport: "chainproof/scanReport", + ClearCache: "chainproof/clearCache", + Status: "chainproof/status", +} as const; diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index b049e52..5189836 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -7,8 +7,18 @@ "engines": { "vscode": "^1.85.0" }, - "categories": ["Linters", "Other"], - "keywords": ["solidity", "ethereum", "security", "audit", "web3", "smart-contracts"], + "categories": [ + "Linters", + "Other" + ], + "keywords": [ + "solidity", + "ethereum", + "security", + "audit", + "web3", + "smart-contracts" + ], "icon": "icon.png", "activationEvents": [ "onLanguage:solidity" @@ -79,7 +89,13 @@ }, "chainproof.minSeverity": { "type": "string", - "enum": ["critical", "high", "medium", "low", "info"], + "enum": [ + "critical", + "high", + "medium", + "low", + "info" + ], "default": "low", "description": "Minimum severity level to display" } @@ -101,11 +117,12 @@ "package": "vsce package" }, "dependencies": { - "@chainproof/core": "*" + "@chainproof/core": "*", + "vscode-languageclient": "^9.0.1" }, "devDependencies": { - "@types/vscode": "^1.85.0", "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", "@vscode/vsce": "^2.24.0" } }