From f64f6cb94ab35c5daf24001b1d589ab0a629028b Mon Sep 17 00:00:00 2001 From: Segun Olumbe Date: Sun, 14 Jun 2026 10:24:38 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(vscode):=20super=20extension=20?= =?UTF-8?q?=E2=80=94=20AI-native=20gating,=20cockpit,=20interactive=20find?= =?UTF-8?q?ings,=20multi-root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grow the gate extension from a read-only verdict mirror into a full editor surface, and lift the engine cap that starved it. Engine (0.2.0 -> 0.3.0) - Findings are no longer capped at 5 per domain in the JSON (MAX_FINDINGS runaway guard instead). The editor gets every located finding, not a sample. - server.json + lockfiles synced to 0.3.0. Extension (clients/vscode 0.3.0) - AI-native gating: @gate chat participant, a gate_check Language Model tool agent mode can call before it says "done", and an MCP server provider — so the assistant writing the code is checked by the same gate as CI. - Verdict cockpit: an interactive webview in gate's own Activity Bar container. - Interactive findings: Quick Fixes (mute / open docs), hovers, CodeLens. - Feedback loop: debounced run-on-save, in-flight runs superseded (child killed). - Multi-root workspaces; gate.strict / gate.codeLens / gate.debounceMs settings; getting-started walkthrough; install-the-engine flow. Tests: engine 60, extension 27. .vsix packages clean. --- CHANGELOG.md | 31 +++ README.md | 19 +- clients/vscode/README.md | 75 ++++-- clients/vscode/media/gate.svg | 4 + clients/vscode/media/walkthrough/ai.md | 14 ++ clients/vscode/media/walkthrough/install.md | 19 ++ clients/vscode/media/walkthrough/run.md | 18 ++ clients/vscode/package-lock.json | 8 +- clients/vscode/package.json | 186 +++++++++++++- clients/vscode/src/ai.ts | 209 ++++++++++++++++ clients/vscode/src/cockpit.ts | 255 ++++++++++++++++++++ clients/vscode/src/extension.ts | 216 ++++++++++++++--- clients/vscode/src/gate.ts | 177 +++++++++++++- clients/vscode/src/providers.ts | 96 ++++++++ clients/vscode/test/multiroot.test.js | 154 ++++++++++++ package-lock.json | 4 +- package.json | 2 +- server.json | 4 +- src/adapters/aiglare.js | 4 +- src/adapters/bouncer.js | 4 +- src/adapters/repoctx.js | 4 +- src/adapters/tieline.js | 4 +- src/verdict.js | 8 + 23 files changed, 1422 insertions(+), 93 deletions(-) create mode 100644 clients/vscode/media/gate.svg create mode 100644 clients/vscode/media/walkthrough/ai.md create mode 100644 clients/vscode/media/walkthrough/install.md create mode 100644 clients/vscode/media/walkthrough/run.md create mode 100644 clients/vscode/src/ai.ts create mode 100644 clients/vscode/src/cockpit.ts create mode 100644 clients/vscode/src/providers.ts create mode 100644 clients/vscode/test/multiroot.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd6ebb..2bc90e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to `@nugehs/gate` are documented here. +## [0.3.0] - 2026-06-14 + +### Engine + +- Findings are no longer capped at 5 per domain in the JSON. That cap was a + terminal-display nicety that leaked into the data model and starved editor + clients of squiggles on any repo with more than a handful of findings. The + `findings` array is now bounded only by a high runaway guard (`MAX_FINDINGS`), + and the summary counts already carry the true totals. +- Editor clients can pass `--strict` per run (e.g. from a `gate.strict` setting). + +### VS Code / Cursor extension (`clients/vscode` 0.3.0) + +The extension grows from a read-only verdict mirror into a full editor surface: + +- **AI-native gating** — an `@gate` chat participant (`@gate can this ship?`, + `@gate /why`), a `gate_check` Language Model tool agent mode can call before it + declares a change done, and an MCP server provider that registers gate's own + MCP server with the editor. The assistant writing the code is checked by the + same gate CI uses. +- **Verdict cockpit** — a webview in gate's own Activity Bar container: the + unified verdict as an interactive board (jump to a finding, mute it, re-check). +- **Interactive findings** — Quick Fixes (mute a finding, open the tool's docs), + hovers with full detail, and a CodeLens above any line that carries a finding. +- **Faster, safer feedback loop** — saves are debounced and an in-flight run is + superseded (its child killed) instead of piling up overlapping full-repo gates. +- **Multi-root workspaces** — every folder is checked; the status bar shows the + worst verdict, the tree gains a per-folder layer. +- **More settings** — `gate.strict`, `gate.codeLens`, `gate.debounceMs`; a + getting-started walkthrough; and an "install the engine" flow. + ## [0.2.0] - 2026-06-14 - Findings now carry `file` and `line` where the underlying tool provides a diff --git a/README.md b/README.md index 766fe7f..5d2c623 100644 --- a/README.md +++ b/README.md @@ -145,13 +145,26 @@ Tools: Registry manifest: [`server.json`](server.json) (`io.github.nugehs/gate`). +## Editor extension + +The same normalized verdict drives a **VS Code / Cursor extension** +([`clients/vscode`](clients/vscode)) — the gates, shifted left from CI into the +editor: + +- a **verdict cockpit** and a checks tree in gate's own Activity Bar container; +- **inline diagnostics**, hovers, Quick Fixes (mute / open docs) and CodeLens on + located findings; +- **AI-native gating** — an `@gate` chat participant, a `gate_check` tool agent + mode can call before it says "done", and an MCP server provider — so the + assistant writing the code is checked by the same gate as CI; +- debounced run-on-save with in-flight cancellation, and multi-root support. + ## Roadmap -gate is the shared spine. The same normalized verdict already drives the CLI, -the `--ci` gate, and the MCP server above. Next clients on the same JSON: +gate is the shared spine. The same JSON already drives the CLI, the `--ci` gate, +the MCP server, and the editor extension above. Next client on the same JSON: - **Web cockpit** — a repo/PR verdict board over the JSON, unifying the four `*-web` sites. -- **Editor extension** — shift the gates left from CI into the editor as inline findings. ## License diff --git a/clients/vscode/README.md b/clients/vscode/README.md index 0888f53..81f6693 100644 --- a/clients/vscode/README.md +++ b/clients/vscode/README.md @@ -1,26 +1,59 @@ # gate — VS Code & Cursor extension -One ship/no-ship verdict in your editor. The extension runs -[`@nugehs/gate`](https://github.com/nugehs/gate) against your workspace and shows -the unified verdict from **aiglare**, **bouncer**, **tieline** and **repoctx**. +**One ship/no-ship verdict in your editor — and in your AI assistant.** + +The extension runs [`@nugehs/gate`](https://github.com/nugehs/gate) against your +workspace and turns the unified verdict from **aiglare** (AI governance), +**bouncer** (compliance), **tieline** (contract drift) and **repoctx** (merge +readiness) into a live editor surface. > Cursor, VSCodium and Windsurf are VS Code forks — this is the same extension -> for all of them. Install it from [Open VSX](https://open-vsx.org) or from a -> packaged `.vsix`. +> for all of them. Install it from [Open VSX](https://open-vsx.org) or a packaged +> `.vsix`. ## What you get -- **Status bar** — `✓ / ⚠ / ✗ gate: VERDICT`. Click to re-check. -- **`gate` panel** (Explorer) — the four checks with their status and summary, - expandable to findings (click a finding to jump to its line). +### The verdict, everywhere + +- **Status bar** — `✓ / ⚠ / ✗ gate: VERDICT`. Click to open the cockpit. +- **Verdict cockpit** — a board in gate's own Activity Bar container: the overall + verdict, a card per domain, the blocking reasons, and every located finding. + Click a finding to jump to it, mute it, or re-check — all without leaving it. +- **Checks tree** — the four domains, expandable to findings (multi-root + workspaces get a folder layer on top). - **Inline diagnostics** — squiggles on the exact line for findings that carry a - location: **aiglare** red surfaces and **tieline** drift. bouncer (a missing - control is an *absence*, no line) and repoctx (repo-level) stay in the panel. -- **Command** — `gate: Check Workspace`. -- **Run on save** — re-checks when you save (toggle with `gate.runOnSave`). + location (aiglare red surfaces, tieline drift), with a clickable rule link. + +### Interactive findings + +- **Quick Fixes** on any finding — **Mute** it (per-workspace, reversible) or + **open the tool's docs**. +- **Hovers** with the full finding detail and a one-click mute. +- **CodeLens** above any line that carries a finding (toggle with `gate.codeLens`). + +### AI-native gating + +The assistant writing your code is checked by the same gate your CI uses: -> Squiggles need the engine to emit `file:line` (gate ≥ 0.2.0). Against an older -> published gate the panel still works; the diagnostics simply stay empty. +- **`@gate` chat participant** — ask `@gate can this ship?`, or `@gate /why` for + the blocking reasons in plain language. +- **Agent tool** — in agent mode the model can call the **`gate_check`** tool + itself before it claims a change is ready; a `fail` verdict tells it not to ship. +- **MCP server** — gate registers its own MCP server with the editor, so any + MCP-aware agent gets the unified `gate_check` tool automatically. + +### A feedback loop that keeps up + +- **Run on save**, debounced — a save-storm collapses into one run. +- **In-flight runs are superseded** (the child process is killed) instead of + piling up overlapping full-repo gates. +- **Multi-root** — every workspace folder is checked; the status bar reports the + worst verdict across them. + +> Squiggles, hovers and CodeLens need the engine to emit `file:line` and the full +> finding set (gate ≥ 0.3.0). Against an older engine the verdict still works; the +> located surfaces simply thin out. bouncer (a missing control is an *absence*, +> no line) and repoctx (repo-level) stay in the cockpit and tree by nature. ## Requirements @@ -31,15 +64,27 @@ The `gate` engine must be resolvable. The extension looks, in order, for: 3. `gate` on `PATH` 4. `npx @nugehs/gate` +No engine installed? Run **gate: Install the gate engine** (or the button in the +cockpit / walkthrough). + ## Settings | Setting | Default | Meaning | | --- | --- | --- | | `gate.path` | `""` | Explicit path to gate (bin or `src/cli.js`). | -| `gate.runOnSave` | `true` | Re-run gate on file save. | +| `gate.runOnSave` | `true` | Re-run gate on file save (debounced). | +| `gate.strict` | `false` | Treat WARN/UNKNOWN as blocking too (`--strict`). | +| `gate.codeLens` | `true` | Show a CodeLens above lines with a finding. | +| `gate.debounceMs` | `500` | Delay after the last save before re-running. | | `gate.only` | `[]` | Run only these checks. | | `gate.skip` | `[]` | Skip these checks. | +## Commands + +`gate: Check Workspace`, `gate: Refresh`, `gate: Open Verdict Cockpit`, +`gate: Show Output Log`, `gate: Clear Muted Findings`, +`gate: Install the gate engine`. + ## Develop ``` diff --git a/clients/vscode/media/gate.svg b/clients/vscode/media/gate.svg new file mode 100644 index 0000000..263533a --- /dev/null +++ b/clients/vscode/media/gate.svg @@ -0,0 +1,4 @@ + + + + diff --git a/clients/vscode/media/walkthrough/ai.md b/clients/vscode/media/walkthrough/ai.md new file mode 100644 index 0000000..89f8435 --- /dev/null +++ b/clients/vscode/media/walkthrough/ai.md @@ -0,0 +1,14 @@ +## Ask the AI + +gate is wired into the editor's AI surfaces, so the assistant writing your code is +checked by the same gate your CI uses. + +- **`@gate` chat participant** — in Copilot Chat, type `@gate can this ship?` or + `@gate /why` to get the verdict and the blocking reasons in plain language. +- **Agent tool** — in agent mode, the model can call the **`#gate`** tool itself + before it claims a change is ready. A `fail` verdict tells it not to ship. +- **MCP server** — gate registers its MCP server with the editor, so any + MCP-aware agent gets the unified `gate_check` tool automatically. + +This is the whole point of the nugehs toolchain: governance that travels with the +code, from the editor to CI. diff --git a/clients/vscode/media/walkthrough/install.md b/clients/vscode/media/walkthrough/install.md new file mode 100644 index 0000000..b1ad33e --- /dev/null +++ b/clients/vscode/media/walkthrough/install.md @@ -0,0 +1,19 @@ +## Install the engine + +The extension is a thin client over the **`@nugehs/gate`** CLI. Install it once: + +``` +npm i -g @nugehs/gate # global +# or, per project: +npm i -D @nugehs/gate +``` + +The extension resolves the engine in this order: + +1. the `gate.path` setting +2. `node_modules/.bin/gate` in your workspace +3. `gate` on your `PATH` +4. `npx @nugehs/gate` (no install needed, just slower on first run) + +You don't need all four underlying tools (aiglare, bouncer, tieline, repoctx) — +any that aren't configured are reported as **skipped**, never a failure. diff --git a/clients/vscode/media/walkthrough/run.md b/clients/vscode/media/walkthrough/run.md new file mode 100644 index 0000000..e41f107 --- /dev/null +++ b/clients/vscode/media/walkthrough/run.md @@ -0,0 +1,18 @@ +## Run your first check + +Open the **gate** icon in the Activity Bar, or run **gate: Check Workspace** from +the Command Palette. + +You get one normalized verdict: + +| Status | Meaning | +| --- | --- | +| ✓ pass | every check that ran is clean | +| ⚠ warn | something worth a look — not blocking | +| ✗ fail | a blocking problem; do not ship | + +Findings that carry a line (aiglare red surfaces, tieline drift) show up as inline +squiggles you can click to jump to. The **Verdict** cockpit summarizes all four +domains; the **Checks** tree lets you drill into individual findings. + +Re-checks run automatically on save (toggle with `gate.runOnSave`). diff --git a/clients/vscode/package-lock.json b/clients/vscode/package-lock.json index d6374e4..6373cd7 100644 --- a/clients/vscode/package-lock.json +++ b/clients/vscode/package-lock.json @@ -1,20 +1,20 @@ { "name": "gate", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gate", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "devDependencies": { "@types/node": "^20", - "@types/vscode": "^1.85.0", + "@types/vscode": "^1.101.0", "typescript": "^5.4.0" }, "engines": { - "vscode": "^1.85.0" + "vscode": "^1.101.0" } }, "node_modules/@types/node": { diff --git a/clients/vscode/package.json b/clients/vscode/package.json index 0545e6a..68851b0 100644 --- a/clients/vscode/package.json +++ b/clients/vscode/package.json @@ -1,30 +1,46 @@ { "name": "gate", - "displayName": "gate — unified verdict", - "description": "One ship/no-ship verdict in your editor: aiglare, bouncer, tieline & repoctx via @nugehs/gate. Works in VS Code and Cursor.", - "version": "0.2.0", + "displayName": "gate — unified ship/no-ship verdict", + "description": "One ship/no-ship verdict in your editor: AI governance, compliance, contract drift & merge readiness via @nugehs/gate. Inline diagnostics, a verdict cockpit, an @gate chat participant, and a Copilot agent tool. VS Code & Cursor.", + "version": "0.3.0", "publisher": "nugehs", "license": "MIT", "icon": "icon.png", + "galleryBanner": { + "color": "#0B1020", + "theme": "dark" + }, "engines": { - "vscode": "^1.85.0" + "vscode": "^1.101.0" }, + "extensionKind": [ + "workspace" + ], "categories": [ "Linters", + "Testing", + "AI", "Other" ], "keywords": [ "gate", "governance", "compliance", + "ai", + "mcp", "ci", "cursor" ], + "qna": "marketplace", "repository": { "type": "git", "url": "git+https://github.com/nugehs/gate.git", "directory": "clients/vscode" }, + "bugs": { + "url": "https://github.com/nugehs/gate/issues" + }, + "homepage": "https://github.com/nugehs/gate#readme", "main": "./out/extension.js", "activationEvents": [ "onStartupFinished" @@ -40,13 +56,48 @@ "command": "gate.refresh", "title": "gate: Refresh", "icon": "$(refresh)" + }, + { + "command": "gate.showOutput", + "title": "gate: Show Output Log", + "icon": "$(output)" + }, + { + "command": "gate.focusCockpit", + "title": "gate: Open Verdict Cockpit" + }, + { + "command": "gate.muteFinding", + "title": "gate: Mute This Finding" + }, + { + "command": "gate.clearMutes", + "title": "gate: Clear Muted Findings" + }, + { + "command": "gate.installEngine", + "title": "gate: Install the gate engine (npm i -g @nugehs/gate)" } ], + "viewsContainers": { + "activitybar": [ + { + "id": "gate", + "title": "gate", + "icon": "media/gate.svg" + } + ] + }, "views": { - "explorer": [ + "gate": [ + { + "id": "gate.cockpit", + "name": "Verdict", + "type": "webview" + }, { "id": "gate.verdict", - "name": "gate" + "name": "Checks" } ] }, @@ -56,6 +107,17 @@ "command": "gate.refresh", "when": "view == gate.verdict", "group": "navigation" + }, + { + "command": "gate.showOutput", + "when": "view == gate.verdict", + "group": "navigation" + } + ], + "commandPalette": [ + { + "command": "gate.muteFinding", + "when": "false" } ] }, @@ -70,7 +132,23 @@ "gate.runOnSave": { "type": "boolean", "default": true, - "description": "Re-run gate when a file is saved." + "description": "Re-run gate when a file is saved (debounced)." + }, + "gate.strict": { + "type": "boolean", + "default": false, + "description": "Treat WARN/UNKNOWN as blocking too (passes --strict to the engine)." + }, + "gate.codeLens": { + "type": "boolean", + "default": true, + "description": "Show a CodeLens above lines that carry a gate finding." + }, + "gate.debounceMs": { + "type": "number", + "default": 500, + "minimum": 0, + "description": "How long to wait after the last save before re-running gate." }, "gate.only": { "type": "array", @@ -91,7 +169,97 @@ "description": "Skip these checks." } } - } + }, + "walkthroughs": [ + { + "id": "gate.gettingStarted", + "title": "Get started with gate", + "description": "One ship/no-ship verdict from aiglare, bouncer, tieline & repoctx — right in your editor.", + "steps": [ + { + "id": "gate.install", + "title": "Install the engine", + "description": "gate runs the @nugehs/gate CLI under the hood. Install it once, globally or in your project.\n[Install gate](command:gate.installEngine)", + "media": { + "markdown": "media/walkthrough/install.md" + } + }, + { + "id": "gate.run", + "title": "Run your first check", + "description": "Run the unified gate over your workspace and read the verdict in the cockpit.\n[Check workspace](command:gate.check)", + "media": { + "markdown": "media/walkthrough/run.md" + } + }, + { + "id": "gate.ai", + "title": "Ask the AI", + "description": "Type @gate in Copilot Chat to ask \"can this ship?\" — or let agent mode call the gate tool before it says a change is done.", + "media": { + "markdown": "media/walkthrough/ai.md" + } + } + ] + } + ], + "languageModelTools": [ + { + "name": "gate_check", + "tags": ["governance", "ci", "nugehs", "ship"], + "toolReferenceName": "gate", + "displayName": "Gate: can this ship?", + "modelDescription": "Run the nugehs gate against the current workspace and return one normalized ship/no-ship verdict. It merges four deterministic checks — aiglare (AI/LLM governance guardrails), bouncer (compliance controls), tieline (frontend/backend contract drift) and repoctx (merge readiness) — into a single verdict (pass | warn | fail) with the blocking reasons. Call this before telling the user a change is ready to merge, ship, or deploy; a 'fail' verdict means do not ship.", + "canBeReferencedInPrompt": true, + "icon": "$(shield)", + "inputSchema": { + "type": "object", + "properties": { + "only": { + "type": "array", + "items": { + "type": "string", + "enum": ["aiglare", "bouncer", "tieline", "repoctx"] + }, + "description": "Run only these checks." + }, + "skip": { + "type": "array", + "items": { + "type": "string", + "enum": ["aiglare", "bouncer", "tieline", "repoctx"] + }, + "description": "Skip these checks." + }, + "strict": { + "type": "boolean", + "description": "Treat warnings and unknowns as blocking too." + } + } + } + } + ], + "chatParticipants": [ + { + "id": "nugehs.gate", + "name": "gate", + "fullName": "gate", + "description": "Can this ship? The unified nugehs verdict.", + "isSticky": true, + "commands": [ + { + "name": "why", + "description": "Explain the current blocking reasons in plain language." + } + ] + } + ], + "mcpServerDefinitionProviders": [ + { + "id": "nugehs-gate", + "label": "gate (nugehs)" + } + ] }, "scripts": { "vscode:prepublish": "npm run compile", @@ -104,7 +272,7 @@ }, "devDependencies": { "@types/node": "^20", - "@types/vscode": "^1.85.0", + "@types/vscode": "^1.101.0", "typescript": "^5.4.0" } } diff --git a/clients/vscode/src/ai.ts b/clients/vscode/src/ai.ts new file mode 100644 index 0000000..1606ae6 --- /dev/null +++ b/clients/vscode/src/ai.ts @@ -0,0 +1,209 @@ +// Tier 0 — make the AI assistant itself gated. Three editor AI surfaces, all +// backed by the same unified verdict: +// • Language Model Tool — agent mode can call `gate_check` before it says "done" +// • Chat participant — `@gate can this ship?` / `@gate /why` +// • MCP server provider — registers gate's own MCP server with the editor +// +// Every entry point is feature-detected, so the extension still loads on an +// editor/fork that doesn't implement a given API (e.g. older Cursor builds). + +import * as vscode from 'vscode'; +import * as path from 'node:path'; +import { existsSync } from 'node:fs'; +import { overallStatus, type GateRunner, type FolderResult } from './gate'; + +interface GateToolInput { + only?: string[]; + skip?: string[]; + strict?: boolean; +} + +export function registerAi( + context: vscode.ExtensionContext, + runner: GateRunner, + workspaceRoot: () => string | undefined, + configuredPath: () => string | undefined +): void { + registerTool(context, runner); + registerParticipant(context, runner); + registerMcp(context, workspaceRoot, configuredPath); +} + +// ---- Agent tool ---- + +function registerTool(context: vscode.ExtensionContext, runner: GateRunner): void { + if (typeof vscode.lm?.registerTool !== 'function') return; + + const tool: vscode.LanguageModelTool = { + async invoke(options, token) { + const input = options.input ?? {}; + const ac = new AbortController(); + token.onCancellationRequested(() => ac.abort()); + const results = await runner.run({ only: input.only, skip: input.skip, strict: input.strict, signal: ac.signal }); + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(formatForModel(results))]); + }, + prepareInvocation() { + return { invocationMessage: 'Running gate…' }; + }, + }; + + context.subscriptions.push(vscode.lm.registerTool('gate_check', tool)); +} + +// ---- Chat participant ---- + +function registerParticipant(context: vscode.ExtensionContext, runner: GateRunner): void { + if (typeof vscode.chat?.createChatParticipant !== 'function') return; + + const handler: vscode.ChatRequestHandler = async (request, _ctxt, stream, token) => { + stream.progress('Running gate…'); + const ac = new AbortController(); + token.onCancellationRequested(() => ac.abort()); + + let results: FolderResult[]; + try { + results = await runner.run({ signal: ac.signal }); + } catch (err) { + stream.markdown(`Could not run gate: ${err instanceof Error ? err.message : String(err)}`); + return {}; + } + + renderChat(stream, results, request.command); + return {}; + }; + + const participant = vscode.chat.createChatParticipant('nugehs.gate', handler); + participant.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'gate.svg'); + context.subscriptions.push(participant); +} + +function renderChat(stream: vscode.ChatResponseStream, results: FolderResult[], command?: string): void { + const overall = overallStatus(results); + stream.markdown(`### ${mark(overall)} gate: **${overall.toUpperCase()}**\n\n`); + + for (const r of results) { + if (results.length > 1) stream.markdown(`#### ${r.name}\n\n`); + if (r.error) { + stream.markdown(`- could not run: ${r.error}\n`); + continue; + } + const v = r.verdict; + if (!v) continue; + for (const d of v.domains) { + stream.markdown(`- ${mark(d.status)} **${d.label}** — ${d.status}${d.summary ? ` · ${d.summary}` : ''}\n`); + } + const reasons = v.gate?.reasons ?? []; + if (reasons.length) { + stream.markdown(`\n**Blocking reasons:**\n`); + for (const x of reasons) stream.markdown(`- ${x}\n`); + } + } + + stream.markdown(`\n${command === 'why' ? whyGuidance(overall) : verdictLine(overall)}\n`); + stream.button({ command: 'gate.focusCockpit', title: 'Open verdict cockpit' }); +} + +// ---- MCP server provider ---- + +function registerMcp( + context: vscode.ExtensionContext, + workspaceRoot: () => string | undefined, + configuredPath: () => string | undefined +): void { + if (typeof vscode.lm?.registerMcpServerDefinitionProvider !== 'function') return; + + const provider: vscode.McpServerDefinitionProvider = { + provideMcpServerDefinitions() { + const { command, args } = mcpCommand(workspaceRoot(), configuredPath()); + return [new vscode.McpStdioServerDefinition('gate', command, args)]; + }, + }; + + try { + context.subscriptions.push(vscode.lm.registerMcpServerDefinitionProvider('nugehs-gate', provider)); + } catch { + // API present in types but not implemented on this host — skip silently. + } +} + +function mcpCommand(root: string | undefined, configured: string | undefined): { command: string; args: string[] } { + if (configured) { + return configured.endsWith('.js') + ? { command: process.execPath, args: [configured, 'mcp'] } + : { command: configured, args: ['mcp'] }; + } + if (root) { + const local = path.join(root, 'node_modules', '.bin', 'gate'); + if (existsSync(local)) return { command: local, args: ['mcp'] }; + } + return { command: 'npx', args: ['--yes', '@nugehs/gate', 'mcp'] }; +} + +// ---- Shared formatting ---- + +function mark(status: string): string { + if (status === 'fail' || status === 'error') return '✗'; + if (status === 'warn' || status === 'unknown') return '⚠'; + if (status === 'pass') return '✓'; + return '·'; +} + +function verdictLine(overall: string): string { + switch (overall) { + case 'fail': + return 'A blocking problem was found — resolve the reasons above before shipping.'; + case 'warn': + return 'Shippable, but with warnings worth a look.'; + case 'pass': + return 'Clean — every check that ran passed.'; + default: + return 'No checks ran for this workspace.'; + } +} + +function whyGuidance(overall: string): string { + switch (overall) { + case 'fail': + return 'Each blocking reason names the tool and the failing check. Fix those (an aiglare red surface on a side-effectful sink, a tieline drift, a missing bouncer control, or a repoctx merge gate) and re-run gate.'; + case 'warn': + return 'Warnings do not block by default. Review them, or enable `gate.strict` to treat them as blocking.'; + case 'pass': + return 'Nothing is blocking. Every check that applied to this repo passed.'; + default: + return 'No domain actually ran — every check was skipped or deselected. Configure at least one tool (e.g. `bouncer init`, `tieline init`).'; + } +} + +/** Plain-text verdict the model can act on — explicit "do not ship" on a fail. */ +function formatForModel(results: FolderResult[]): string { + const overall = overallStatus(results); + const lines: string[] = [`gate verdict: ${overall.toUpperCase()}`]; + + for (const r of results) { + const head = results.length > 1 ? `[${r.name}] ` : ''; + if (r.error) { + lines.push(`${head}error — ${r.error}`); + continue; + } + const v = r.verdict; + if (!v) { + lines.push(`${head}not run`); + continue; + } + if (v.gate?.nothingChecked) lines.push(`${head}NO CHECKS RAN — ${(v.gate.reasons ?? []).join('; ')}`); + for (const d of v.domains) lines.push(`${head}${d.label}: ${d.status}${d.summary ? ' — ' + d.summary : ''}`); + for (const reason of v.gate?.reasons ?? []) lines.push(`${head}BLOCKING: ${reason}`); + } + + lines.push(''); + lines.push( + overall === 'fail' + ? 'Do not ship: at least one blocking problem was found. Resolve the BLOCKING items above before merging.' + : overall === 'warn' + ? 'Shippable, but with warnings worth a look.' + : overall === 'pass' + ? 'Clean: every check that ran passed.' + : 'No checks ran for this workspace.' + ); + return lines.join('\n'); +} diff --git a/clients/vscode/src/cockpit.ts b/clients/vscode/src/cockpit.ts new file mode 100644 index 0000000..e8620d1 --- /dev/null +++ b/clients/vscode/src/cockpit.ts @@ -0,0 +1,255 @@ +// Tier 3 — the verdict "cockpit": a webview view in gate's own Activity Bar +// container. It renders the same unified verdict the CLI prints, but as an +// interactive board — click a finding to jump to it, mute it, or re-check. +// +// It reads the latest run through GateView and never runs the engine directly; +// every action posts a message that the extension turns into a command. + +import * as vscode from 'vscode'; +import * as path from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { overallStatus, findingKey, type GateView, type FolderResult, type DomainResult, type Finding } from './gate'; + +type Msg = + | { type: 'check' } + | { type: 'install' } + | { type: 'clearMutes' } + | { type: 'mute'; key: string } + | { type: 'open'; file: string; line?: number }; + +export class CockpitProvider implements vscode.WebviewViewProvider { + static readonly viewId = 'gate.cockpit'; + private webviewView?: vscode.WebviewView; + private busy = false; + + constructor(private readonly view: GateView, private readonly extensionUri: vscode.Uri) {} + + resolveWebviewView(webviewView: vscode.WebviewView): void { + this.webviewView = webviewView; + webviewView.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] }; + webviewView.webview.onDidReceiveMessage((m: Msg) => this.onMessage(m)); + this.render(); + } + + setBusy(busy: boolean): void { + this.busy = busy; + this.render(); + } + + refresh(): void { + this.render(); + } + + private onMessage(m: Msg): void { + switch (m?.type) { + case 'check': + vscode.commands.executeCommand('gate.check'); + break; + case 'install': + vscode.commands.executeCommand('gate.installEngine'); + break; + case 'clearMutes': + vscode.commands.executeCommand('gate.clearMutes'); + break; + case 'mute': + if (m.key) vscode.commands.executeCommand('gate.muteFinding', m.key); + break; + case 'open': + if (typeof m.file === 'string') { + const l = typeof m.line === 'number' ? Math.max(0, m.line - 1) : 0; + vscode.commands.executeCommand('vscode.open', vscode.Uri.file(m.file), { + selection: new vscode.Range(l, 0, l, 0), + }); + } + break; + } + } + + private render(): void { + if (!this.webviewView) return; + const w = this.webviewView.webview; + w.html = renderHtml(w, this.view, this.busy); + } +} + +// ---- HTML rendering (pure-ish string building) ---- + +const STATUS_CLASS: Record = { + pass: 'ok', + warn: 'warn', + unknown: 'warn', + fail: 'bad', + error: 'bad', + skipped: 'mute', +}; + +function esc(s: unknown): string { + return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string)); +} + +function resolveAbs(file: string, root: string): string { + return path.isAbsolute(file) ? file : path.join(root, file); +} + +function nonceStr(): string { + return randomBytes(16).toString('base64').replace(/[^a-zA-Z0-9]/g, '').slice(0, 24); +} + +function findingRow(d: DomainResult, f: Finding, root: string, muted: ReadonlySet): string { + const title = String(f.title ?? f.id ?? 'finding'); + const hasLoc = typeof f.file === 'string' && f.file; + const abs = hasLoc ? resolveAbs(String(f.file), root) : ''; + const line = typeof f.line === 'number' && f.line > 0 ? f.line : 1; + const key = hasLoc ? findingKey(d.tool, abs, line, title) : ''; + if (key && muted.has(key)) return ''; + + const loc = hasLoc ? `${path.basename(abs)}:${line}` : ''; + const sev = f.severity ? `${esc(f.severity)}` : ''; + const openAttr = hasLoc ? ` data-open="${esc(abs)}" data-line="${line}"` : ''; + const muteBtn = key ? `` : ''; + return `
  • + ${esc(title)}${sev} + ${loc ? `${esc(loc)}` : ''} + ${muteBtn} +
  • `; +} + +function domainCard(d: DomainResult, root: string, muted: ReadonlySet): string { + const cls = STATUS_CLASS[d.status] ?? 'mute'; + const rows = (d.findings ?? []).map((f) => findingRow(d, f, root, muted)).join(''); + return `
    +
    + + ${esc(d.label)} + ${esc(d.status)} +
    +
    ${esc(d.summary)}
    + ${rows ? `
      ${rows}
    ` : ''} +
    `; +} + +function folderBlock(r: FolderResult, showName: boolean, muted: ReadonlySet): string { + if (r.error) { + return `
    + ${showName ? `

    ${esc(r.name)}

    ` : ''} +
    could not run
    +
    ${esc(r.error)}
    +
    `; + } + const v = r.verdict; + if (!v) return ''; + const reasons = v.gate?.reasons ?? []; + const cards = v.domains.map((d) => domainCard(d, v.repo.root, muted)).join(''); + const reasonList = reasons.length + ? `
    Blocking reasons
      ${reasons.map((x) => `
    • ${esc(x)}
    • `).join('')}
    ` + : ''; + return `
    + ${showName ? `

    ${esc(r.name)} — ${esc(v.verdict.toUpperCase())}

    ` : ''} + ${cards} + ${reasonList} +
    `; +} + +function renderHtml(webview: vscode.Webview, view: GateView, busy: boolean): string { + const results = view.results(); + const muted = view.muted(); + const overall = overallStatus(results); + const nonce = nonceStr(); + const csp = `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';`; + + const engineMissing = results.length > 0 && results.every((r) => r.error && /could not run gate|enoent|not found/i.test(r.error)); + + let body: string; + if (busy) { + body = `
    Running gate…
    `; + } else if (results.length === 0) { + body = `
    No workspace folder open.
    `; + } else if (engineMissing) { + body = `
    +

    The gate engine isn't installed.

    + +

    Or set gate.path in settings.

    +
    `; + } else { + const showNames = results.length > 1; + body = results.map((r) => folderBlock(r, showNames, muted)).join(''); + } + + const overallLabel = overall === 'none' ? '—' : overall.toUpperCase(); + const mutedCount = muted.size; + + return ` + + + + + + + +
    + ${esc(overallLabel)} + + ${mutedCount ? `` : ''} + +
    + ${body} + + +`; +} diff --git a/clients/vscode/src/extension.ts b/clients/vscode/src/extension.ts index e1a6cc4..6f35684 100644 --- a/clients/vscode/src/extension.ts +++ b/clients/vscode/src/extension.ts @@ -1,93 +1,205 @@ import * as vscode from 'vscode'; import * as path from 'node:path'; import { existsSync } from 'node:fs'; -import { runGate, statusBarText, toTree, toDiagnostics, type TreeNode, type Verdict, type Status } from './gate'; +import { + runGate, + statusBarTextMulti, + toTreeMulti, + toDiagnosticsMulti, + toolDocsUrl, + overallStatus, + type TreeNode, + type FolderResult, + type Status, + type GateView, + type GateRunner, + type RunRequest, +} from './gate'; +import { registerProviders, GateCodeLensProvider } from './providers'; +import { CockpitProvider } from './cockpit'; +import { registerAi } from './ai'; let statusBar: vscode.StatusBarItem; let output: vscode.OutputChannel; -let provider: VerdictProvider; +let treeProvider: VerdictProvider; let diagnostics: vscode.DiagnosticCollection; -let lastVerdict: Verdict | null = null; +let cockpit: CockpitProvider; +let codeLens: GateCodeLensProvider; +let ctx: vscode.ExtensionContext; + +let results: FolderResult[] = []; +let muted: Set = new Set(); let running = false; +let runTimer: ReturnType | undefined; +let currentRun: AbortController | undefined; let devCliPath: string | undefined; +const MUTED_KEY = 'gate.muted'; + export function activate(context: vscode.ExtensionContext): void { + ctx = context; output = vscode.window.createOutputChannel('gate'); + muted = new Set(context.workspaceState.get(MUTED_KEY, [])); + statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0); - statusBar.command = 'gate.check'; + statusBar.command = 'gate.focusCockpit'; context.subscriptions.push(statusBar, output); refreshStatusBar(); statusBar.show(); - provider = new VerdictProvider(); - context.subscriptions.push(vscode.window.registerTreeDataProvider('gate.verdict', provider)); + const view: GateView = { + results: () => results, + muted: () => muted, + overall: () => overallStatus(results), + }; + + treeProvider = new VerdictProvider(); + context.subscriptions.push(vscode.window.registerTreeDataProvider('gate.verdict', treeProvider)); + + cockpit = new CockpitProvider(view, context.extensionUri); + context.subscriptions.push(vscode.window.registerWebviewViewProvider(CockpitProvider.viewId, cockpit)); diagnostics = vscode.languages.createDiagnosticCollection('gate'); context.subscriptions.push(diagnostics); - // When running from source (clients/vscode inside the gate repo), the engine - // lives two levels up. Installed as a .vsix this won't exist, so it's ignored. + ({ codeLens } = registerProviders(context, view)); + + // Running from source (clients/vscode inside the gate repo), the engine lives + // two levels up. Installed as a .vsix this won't exist, so it's ignored. const candidate = path.join(context.extensionPath, '..', '..', 'src', 'cli.js'); devCliPath = existsSync(candidate) ? candidate : undefined; + const runner: GateRunner = { run: (req) => runWorkspace(req) }; + registerAi(context, runner, workspaceRoot, configuredPath); + context.subscriptions.push( vscode.commands.registerCommand('gate.check', () => check()), - vscode.commands.registerCommand('gate.refresh', () => check()) + vscode.commands.registerCommand('gate.refresh', () => check()), + vscode.commands.registerCommand('gate.showOutput', () => output.show()), + vscode.commands.registerCommand('gate.focusCockpit', () => vscode.commands.executeCommand('gate.cockpit.focus')), + vscode.commands.registerCommand('gate.muteFinding', (key?: string) => muteFinding(key)), + vscode.commands.registerCommand('gate.clearMutes', () => clearMutes()), + vscode.commands.registerCommand('gate.installEngine', () => installEngine()) ); context.subscriptions.push( vscode.workspace.onDidSaveTextDocument(() => { if (vscode.workspace.getConfiguration('gate').get('runOnSave', true)) { - check(); + scheduleCheck(debounceMs()); } - }) + }), + vscode.workspace.onDidChangeWorkspaceFolders(() => check()) ); check(); } +function debounceMs(): number { + return Math.max(0, vscode.workspace.getConfiguration('gate').get('debounceMs', 500)); +} + +function configuredPath(): string | undefined { + return vscode.workspace.getConfiguration('gate').get('path') || devCliPath; +} + function workspaceRoot(): string | undefined { return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; } -async function check(): Promise { - const root = workspaceRoot(); - if (!root) return; +/** Debounced re-check — the last request within the window wins. */ +function scheduleCheck(delayMs: number): void { + if (runTimer) clearTimeout(runTimer); + runTimer = setTimeout(() => { + runTimer = undefined; + void check(); + }, delayMs); +} +/** + * Run gate across every workspace folder, concurrently. Per-folder failures are + * captured as a FolderResult.error (not thrown) so one bad folder can't sink the + * whole run; a cancellation (signal aborted) does propagate so check() can bail. + */ +async function runWorkspace(req: RunRequest): Promise { + const folders = vscode.workspace.workspaceFolders ?? []; const cfg = vscode.workspace.getConfiguration('gate'); + const cp = configuredPath(); + const only = req.only ?? cfg.get('only') ?? []; + const skip = req.skip ?? cfg.get('skip') ?? []; + const strict = req.strict ?? cfg.get('strict') ?? false; + + return Promise.all( + folders.map(async (folder): Promise => { + const root = folder.uri.fsPath; + const name = folder.name || path.basename(root); + try { + const verdict = await runGate({ workspaceRoot: root, configuredPath: cp, only, skip, strict, signal: req.signal }); + return { folder: root, name, verdict }; + } catch (err) { + if (req.signal?.aborted) throw err; + return { folder: root, name, verdict: null, error: err instanceof Error ? err.message : String(err) }; + } + }) + ); +} + +async function check(): Promise { + if (!vscode.workspace.workspaceFolders?.length) return; + + // Supersede any in-flight run: abort it (kills its child) so a save-storm can't + // pile up overlapping full-workspace gates racing to write the UI. + currentRun?.abort(); + const run = new AbortController(); + currentRun = run; + running = true; refreshStatusBar(); + cockpit.setBusy(true); try { - lastVerdict = await runGate({ - workspaceRoot: root, - configuredPath: cfg.get('path') || devCliPath, - only: cfg.get('only') ?? [], - skip: cfg.get('skip') ?? [], - }); - const v = lastVerdict; - output.appendLine(`[${v.generatedAt}] ${v.repo.name}: ${v.verdict.toUpperCase()} (${v.gate.reasons.length} reason(s))`); + const r = await runWorkspace({ signal: run.signal }); + if (run.signal.aborted) return; // a newer run took over while we awaited + results = r; + logResults(r); } catch (err) { - lastVerdict = null; - const message = err instanceof Error ? err.message : String(err); - output.appendLine(`[error] ${message}`); - vscode.window.showWarningMessage(`gate: ${message}`); + if (run.signal.aborted) return; // cancelled by a newer run — not a failure + output.appendLine(`[error] ${err instanceof Error ? err.message : String(err)}`); } finally { - running = false; - refreshStatusBar(); - provider.refresh(lastVerdict); - applyDiagnostics(lastVerdict); + // Only the still-current run owns the UI; a superseded run must not clobber + // the newer run's spinner, verdict, or diagnostics. + if (currentRun === run) { + currentRun = undefined; + running = false; + refreshStatusBar(); + treeProvider.refresh(); + applyDiagnostics(); + cockpit.setBusy(false); + codeLens.refresh(); + } + } +} + +function logResults(rs: FolderResult[]): void { + for (const r of rs) { + if (r.error) { + output.appendLine(`[${r.name}] error: ${r.error}`); + continue; + } + const v = r.verdict; + if (!v) continue; + output.appendLine(`[${v.generatedAt}] ${r.name}: ${v.verdict.toUpperCase()} (${v.gate.reasons.length} reason(s))`); } } -function applyDiagnostics(v: Verdict | null): void { +function applyDiagnostics(): void { diagnostics.clear(); - if (!v) return; const byFile = new Map(); - for (const d of toDiagnostics(v)) { + for (const d of toDiagnosticsMulti(results, muted)) { const line = Math.max(0, d.line - 1); const range = new vscode.Range(line, 0, line, Number.MAX_SAFE_INTEGER); const diag = new vscode.Diagnostic(range, d.message, severityFor(d.severity)); diag.source = d.source; + // A clickable rule link in the Problems panel. + diag.code = { value: d.tool, target: vscode.Uri.parse(toolDocsUrl(d.tool)) }; const list = byFile.get(d.file) ?? []; list.push(diag); byFile.set(d.file, list); @@ -97,6 +209,33 @@ function applyDiagnostics(v: Verdict | null): void { } } +async function muteFinding(key?: string): Promise { + if (!key || muted.has(key)) return; + muted.add(key); + await ctx.workspaceState.update(MUTED_KEY, [...muted]); + refreshAfterMuteChange(); +} + +async function clearMutes(): Promise { + if (!muted.size) return; + muted.clear(); + await ctx.workspaceState.update(MUTED_KEY, []); + refreshAfterMuteChange(); +} + +function refreshAfterMuteChange(): void { + treeProvider.refresh(); + applyDiagnostics(); + cockpit.refresh(); + codeLens.refresh(); +} + +function installEngine(): void { + const term = vscode.window.createTerminal('gate'); + term.show(); + term.sendText('npm install -g @nugehs/gate'); +} + function severityFor(status: Status): vscode.DiagnosticSeverity { switch (status) { case 'fail': @@ -111,7 +250,7 @@ function severityFor(status: Status): vscode.DiagnosticSeverity { } function refreshStatusBar(): void { - const { text, tooltip } = statusBarText(lastVerdict, running); + const { text, tooltip } = statusBarTextMulti(results, running); statusBar.text = text; statusBar.tooltip = tooltip; } @@ -119,10 +258,10 @@ function refreshStatusBar(): void { class VerdictProvider implements vscode.TreeDataProvider { private readonly _onDidChange = new vscode.EventEmitter(); readonly onDidChangeTreeData = this._onDidChange.event; - private roots: TreeNode[] = toTree(null); + private roots: TreeNode[] = toTreeMulti([]); - refresh(v: Verdict | null): void { - this.roots = toTree(v); + refresh(): void { + this.roots = toTreeMulti(results); this._onDidChange.fire(); } @@ -169,5 +308,6 @@ function iconFor(status: Status): vscode.ThemeIcon { } export function deactivate(): void { - /* nothing to clean up beyond context.subscriptions */ + if (runTimer) clearTimeout(runTimer); + currentRun?.abort(); } diff --git a/clients/vscode/src/gate.ts b/clients/vscode/src/gate.ts index f09b7d1..5fae1dd 100644 --- a/clients/vscode/src/gate.ts +++ b/clients/vscode/src/gate.ts @@ -71,15 +71,20 @@ export interface RunOptions { configuredPath?: string; only?: string[]; skip?: string[]; + /** Treat WARN/UNKNOWN as blocking too (passes --strict to the engine). */ + strict?: boolean; + /** Abort the run (kills the child) when a newer check supersedes this one. */ + signal?: AbortSignal; } function exec( command: string, args: string[], - cwd: string + cwd: string, + signal?: AbortSignal ): Promise<{ stdout: string; stderr: string; error?: NodeJS.ErrnoException }> { return new Promise((resolve) => { - execFile(command, args, { cwd, maxBuffer: 32 * 1024 * 1024, timeout: 180_000 }, (error, stdout, stderr) => { + execFile(command, args, { cwd, maxBuffer: 32 * 1024 * 1024, timeout: 180_000, signal }, (error, stdout, stderr) => { resolve({ stdout, stderr, error: (error as NodeJS.ErrnoException) || undefined }); }); }); @@ -95,13 +100,21 @@ function selectionArgs(only?: string[], skip?: string[]): string[] { /** Run gate against a workspace and parse its unified verdict. */ export async function runGate(opts: RunOptions): Promise { const inv = resolveGate(opts); - const tail = [opts.workspaceRoot, '--json', ...selectionArgs(opts.only, opts.skip)]; + const tail = [ + opts.workspaceRoot, + '--json', + ...selectionArgs(opts.only, opts.skip), + ...(opts.strict ? ['--strict'] : []), + ]; - let res = await exec(inv.command, [...inv.args, ...tail], opts.workspaceRoot); + let res = await exec(inv.command, [...inv.args, ...tail], opts.workspaceRoot, opts.signal); if (res.error?.code === 'ENOENT' && inv.command === 'gate') { // No local or global gate — fall back to the published package via npx. - res = await exec('npx', ['--yes', '@nugehs/gate', ...tail], opts.workspaceRoot); + res = await exec('npx', ['--yes', '@nugehs/gate', ...tail], opts.workspaceRoot, opts.signal); } + // A superseded run shows up here as an AbortError; surface it as a cancellation + // the caller can distinguish from a real failure rather than "no output". + if (opts.signal?.aborted) throw new Error('gate run cancelled'); if (res.error?.code === 'ENOENT') { throw new Error('Could not run gate. Install it (`npm i -g @nugehs/gate`) or set "gate.path".'); } @@ -190,24 +203,166 @@ export interface DiagDescriptor { line: number; // 1-based severity: Status; message: string; - source: string; + source: string; // `gate/` + tool: string; // the originating tool (aiglare, tieline, …) + title: string; // the finding's human label + key: string; // stable identity, used to mute a specific finding } -/** Flatten a verdict's located findings into diagnostic descriptors (vscode-free). */ -export function toDiagnostics(v: Verdict | null): DiagDescriptor[] { +const EMPTY_SET: ReadonlySet = new Set(); + +/** Stable identity for a single located finding — used to mute it in the editor. */ +export function findingKey(tool: string, file: string, line: number, title: string): string { + return `${tool}:${file}:${line}:${title}`; +} + +/** + * Flatten a verdict's located findings into diagnostic descriptors (vscode-free). + * `muted` keys (see {@link findingKey}) are dropped so a user can silence a + * specific finding without touching the engine. + */ +export function toDiagnostics(v: Verdict | null, muted: ReadonlySet = EMPTY_SET): DiagDescriptor[] { if (!v) return []; const out: DiagDescriptor[] = []; for (const d of v.domains) { for (const f of d.findings ?? []) { if (typeof f.file !== 'string' || !f.file) continue; + const file = resolveFile(f.file, v.repo.root); + const line = typeof f.line === 'number' && f.line > 0 ? f.line : 1; + const title = String(f.title ?? f.id ?? 'finding'); + const key = findingKey(d.tool, file, line, title); + if (muted.has(key)) continue; out.push({ - file: resolveFile(f.file, v.repo.root), - line: typeof f.line === 'number' && f.line > 0 ? f.line : 1, + file, + line, severity: d.status, - message: `${d.label}: ${f.title ?? f.id ?? 'finding'}${f.severity ? ` (${f.severity})` : ''}`, + message: `${d.label}: ${title}${f.severity ? ` (${f.severity})` : ''}`, source: `gate/${d.tool}`, + tool: d.tool, + title, + key, }); } } return out; } + +// ---- Multi-root workspace aggregation ---- + +export interface FolderResult { + folder: string; // absolute fsPath of the workspace folder + name: string; // display name (folder basename) + verdict: Verdict | null; // null when the run failed for this folder + error?: string; // populated when the run failed +} + +const STATUS_RANK: Record = { + pass: 0, + skipped: 0, + warn: 2, + unknown: 2, + error: 2, + fail: 3, +}; + +export type Overall = 'pass' | 'warn' | 'fail' | 'none'; + +const OVERALL_ICON: Record = { + pass: '$(pass)', + warn: '$(warning)', + fail: '$(error)', + none: '$(circle-outline)', +}; + +function folderSummary(r: FolderResult): string { + if (r.error) return `error — ${r.error}`; + const v = r.verdict; + if (!v) return 'not run'; + if (v.gate?.nothingChecked) return 'nothing checked'; + return v.verdict.toUpperCase(); +} + +/** Worst verdict across every folder. A failed/empty/unchecked run counts as warn, never pass. */ +export function overallStatus(results: FolderResult[]): Overall { + if (results.length === 0) return 'none'; + let rank = 0; + for (const r of results) { + if (r.error || !r.verdict || r.verdict.gate?.nothingChecked) { + rank = Math.max(rank, 2); + continue; + } + rank = Math.max(rank, STATUS_RANK[r.verdict.verdict] ?? 2); + } + return rank >= 3 ? 'fail' : rank >= 2 ? 'warn' : 'pass'; +} + +/** Status-bar text across N folders. One folder = the existing single-verdict view. */ +export function statusBarTextMulti(results: FolderResult[], running: boolean): { text: string; tooltip: string } { + if (running) return { text: '$(sync~spin) gate', tooltip: 'gate: running…' }; + if (results.length === 0) return { text: '$(circle-outline) gate', tooltip: 'gate: not run yet — click to check' }; + if (results.length === 1) return statusBarText(results[0].verdict, false); + const overall = overallStatus(results); + const text = `${OVERALL_ICON[overall]} gate: ${overall.toUpperCase()} · ${results.length} folders`; + const lines = results.map((r) => `${r.name}: ${folderSummary(r)}`); + return { text, tooltip: [`gate · ${results.length} folders`, '', ...lines].join('\n') }; +} + +/** Tree across N folders. One folder = domains at the root; many = a folder layer on top. */ +export function toTreeMulti(results: FolderResult[]): TreeNode[] { + if (results.length === 0) return [{ label: 'Run gate to see the verdict' }]; + if (results.length === 1) return toTree(results[0].verdict); + return results.map((r) => ({ + label: r.name, + description: folderSummary(r).toLowerCase(), + status: r.error ? ('error' as Status) : (r.verdict?.verdict as Status | undefined), + tooltip: r.folder, + children: r.error ? [{ label: r.error }] : toTree(r.verdict), + })); +} + +/** All located diagnostics across every folder, minus muted ones. */ +export function toDiagnosticsMulti(results: FolderResult[], muted: ReadonlySet = EMPTY_SET): DiagDescriptor[] { + return results.flatMap((r) => toDiagnostics(r.verdict, muted)); +} + +/** Located findings for a single file (powers hovers, code actions and CodeLens). */ +export function findingsForFile( + results: FolderResult[], + fsPath: string, + muted: ReadonlySet = EMPTY_SET +): DiagDescriptor[] { + return toDiagnosticsMulti(results, muted).filter((d) => d.file === fsPath); +} + +const TOOL_DOCS: Record = { + aiglare: 'https://www.npmjs.com/package/@nugehs/aiglare', + bouncer: 'https://www.npmjs.com/package/@nugehs/bouncer', + tieline: 'https://www.npmjs.com/package/@nugehs/tieline', + repoctx: 'https://www.npmjs.com/package/@nugehs/repoctx', +}; + +/** The docs URL for a tool, for the "open rule" code action. */ +export function toolDocsUrl(tool: string): string { + return TOOL_DOCS[tool] ?? 'https://github.com/nugehs/gate#readme'; +} + +// ---- Editor-facing accessors (implemented by the extension host) ---- + +/** Read-only view of the latest run — shared with the providers and the cockpit. */ +export interface GateView { + results(): FolderResult[]; + muted(): ReadonlySet; + overall(): Overall; +} + +export interface RunRequest { + only?: string[]; + skip?: string[]; + strict?: boolean; + signal?: AbortSignal; +} + +/** Runs gate across the whole workspace (every folder) and returns per-folder results. */ +export interface GateRunner { + run(req: RunRequest): Promise; +} diff --git a/clients/vscode/src/providers.ts b/clients/vscode/src/providers.ts new file mode 100644 index 0000000..37f878c --- /dev/null +++ b/clients/vscode/src/providers.ts @@ -0,0 +1,96 @@ +// Tier 2 — turn read-only squiggles into interactive findings: +// • Quick Fixes (mute a finding, open the tool's docs) +// • Hovers (full finding detail on the exact line) +// • CodeLens (an inline marker above any line that carries a finding) +// +// All three read the latest run through the shared GateView and locate findings +// per file with findingsForFile() — they never re-run the engine themselves. + +import * as vscode from 'vscode'; +import { findingsForFile, toolDocsUrl, type GateView } from './gate'; + +const SELECTOR: vscode.DocumentSelector = { scheme: 'file' }; + +export class GateCodeActionProvider implements vscode.CodeActionProvider { + constructor(private readonly view: GateView) {} + + provideCodeActions( + document: vscode.TextDocument, + range: vscode.Range + ): vscode.CodeAction[] { + const hits = findingsForFile(this.view.results(), document.uri.fsPath, this.view.muted()).filter( + (f) => f.line - 1 >= range.start.line && f.line - 1 <= range.end.line + ); + + const actions: vscode.CodeAction[] = []; + for (const f of hits) { + const mute = new vscode.CodeAction(`gate: Mute “${f.title}” (${f.tool})`, vscode.CodeActionKind.QuickFix); + mute.command = { command: 'gate.muteFinding', title: 'Mute finding', arguments: [f.key] }; + actions.push(mute); + + const docs = new vscode.CodeAction(`gate: Open ${f.tool} docs`, vscode.CodeActionKind.QuickFix); + docs.command = { command: 'vscode.open', title: 'Open docs', arguments: [vscode.Uri.parse(toolDocsUrl(f.tool))] }; + actions.push(docs); + } + return actions; + } +} + +export class GateHoverProvider implements vscode.HoverProvider { + constructor(private readonly view: GateView) {} + + provideHover(document: vscode.TextDocument, position: vscode.Position): vscode.Hover | undefined { + const hits = findingsForFile(this.view.results(), document.uri.fsPath, this.view.muted()).filter( + (f) => f.line - 1 === position.line + ); + if (!hits.length) return undefined; + + const md = new vscode.MarkdownString(undefined, true); + md.isTrusted = { enabledCommands: ['gate.muteFinding'] }; + for (const f of hits) { + const muteArg = encodeURIComponent(JSON.stringify([f.key])); + md.appendMarkdown(`$(shield) **gate · ${f.tool}** — \`${f.severity}\`\n\n`); + md.appendMarkdown(`${f.message}\n\n`); + md.appendMarkdown(`[Mute](command:gate.muteFinding?${muteArg}) · [${f.tool} docs](${toolDocsUrl(f.tool)})\n\n`); + } + return new vscode.Hover(md); + } +} + +export class GateCodeLensProvider implements vscode.CodeLensProvider { + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChangeCodeLenses = this._onDidChange.event; + + constructor(private readonly view: GateView) {} + + /** Re-emit lenses after a new run or a mute toggle. */ + refresh(): void { + this._onDidChange.fire(); + } + + provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { + if (!vscode.workspace.getConfiguration('gate').get('codeLens', true)) return []; + return findingsForFile(this.view.results(), document.uri.fsPath, this.view.muted()).map((f) => { + const line = Math.max(0, f.line - 1); + const lens = new vscode.CodeLens(new vscode.Range(line, 0, line, 0)); + lens.command = { command: 'gate.focusCockpit', title: `$(shield) gate · ${f.tool}: ${f.title}` }; + return lens; + }); + } +} + +/** Register all three providers for on-disk files. */ +export function registerProviders( + context: vscode.ExtensionContext, + view: GateView +): { codeLens: GateCodeLensProvider } { + const codeLens = new GateCodeLensProvider(view); + context.subscriptions.push( + vscode.languages.registerCodeActionsProvider(SELECTOR, new GateCodeActionProvider(view), { + providedCodeActionKinds: [vscode.CodeActionKind.QuickFix], + }), + vscode.languages.registerHoverProvider(SELECTOR, new GateHoverProvider(view)), + vscode.languages.registerCodeLensProvider(SELECTOR, codeLens) + ); + return { codeLens }; +} diff --git a/clients/vscode/test/multiroot.test.js b/clients/vscode/test/multiroot.test.js new file mode 100644 index 0000000..da7e15a --- /dev/null +++ b/clients/vscode/test/multiroot.test.js @@ -0,0 +1,154 @@ +// Tests for the multi-root aggregation and finding-mute logic in src/gate.ts, +// run against the compiled output. (CommonJS — this package is not type:module.) +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { + overallStatus, + statusBarTextMulti, + toTreeMulti, + toDiagnosticsMulti, + findingsForFile, + findingKey, + toDiagnostics, +} = require('../out/gate.js'); + +function verdict(over = {}) { + return { + schemaVersion: 1, + tool: 'gate', + generatedAt: '2026-06-14T00:00:00.000Z', + repo: { root: '/work/app', name: 'app' }, + verdict: 'fail', + ok: false, + summary: {}, + gate: { ci: false, strict: false, failed: false, nothingChecked: false, reasons: [] }, + domains: [ + { + tool: 'aiglare', + domain: 'ai-governance', + label: 'AI governance', + status: 'fail', + summary: '1 blocking', + findings: [{ title: 'pay.ts', severity: 'red', file: 'src/pay.ts', line: 42 }], + }, + ], + ...over, + }; +} + +function folder(name, root, v, error) { + return { folder: '/work/' + name, name, verdict: v ?? null, error }; +} + +// ---- overallStatus ---- + +test('overallStatus: empty → none', () => { + assert.equal(overallStatus([]), 'none'); +}); + +test('overallStatus: worst wins (fail beats warn beats pass)', () => { + const pass = folder('a', '/work/a', verdict({ verdict: 'pass' })); + const warn = folder('b', '/work/b', verdict({ verdict: 'warn' })); + const fail = folder('c', '/work/c', verdict({ verdict: 'fail' })); + assert.equal(overallStatus([pass]), 'pass'); + assert.equal(overallStatus([pass, warn]), 'warn'); + assert.equal(overallStatus([pass, warn, fail]), 'fail'); +}); + +test('overallStatus: error / null / nothingChecked count as warn, never pass', () => { + assert.equal(overallStatus([folder('a', '/a', null, 'Could not run gate')]), 'warn'); + assert.equal(overallStatus([folder('a', '/a', null)]), 'warn'); + const nothing = folder('b', '/b', verdict({ verdict: 'pass', gate: { nothingChecked: true, reasons: ['no checks ran'] } })); + assert.equal(overallStatus([nothing]), 'warn'); +}); + +// ---- statusBarTextMulti ---- + +test('statusBarTextMulti: no folders → not-run, running → spinner', () => { + assert.match(statusBarTextMulti([], false).text, /circle-outline/); + assert.match(statusBarTextMulti([], true).text, /sync~spin/); +}); + +test('statusBarTextMulti: single folder delegates to the single-verdict view', () => { + const one = folder('app', '/work/app', verdict({ verdict: 'fail' })); + assert.match(statusBarTextMulti([one], false).text, /\$\(error\) gate: FAIL/); +}); + +test('statusBarTextMulti: many folders show worst verdict + count', () => { + const a = folder('a', '/work/a', verdict({ verdict: 'pass' })); + const b = folder('b', '/work/b', verdict({ verdict: 'fail' })); + const { text, tooltip } = statusBarTextMulti([a, b], false); + assert.match(text, /gate: FAIL · 2 folders/); + assert.match(tooltip, /a: PASS/); + assert.match(tooltip, /b: FAIL/); +}); + +// ---- toTreeMulti ---- + +test('toTreeMulti: empty → placeholder; single → domains at root', () => { + assert.match(toTreeMulti([])[0].label, /Run gate/); + const roots = toTreeMulti([folder('app', '/work/app', verdict())]); + assert.equal(roots[0].label, 'AI governance'); + assert.equal(roots[0].children[0].label, 'pay.ts'); +}); + +test('toTreeMulti: many folders get a folder layer on top', () => { + const a = folder('a', '/work/a', verdict({ repo: { root: '/work/a', name: 'a' } })); + const b = folder('b', '/work/b', verdict({ repo: { root: '/work/b', name: 'b' }, verdict: 'warn' })); + const roots = toTreeMulti([a, b]); + assert.equal(roots.length, 2); + assert.equal(roots[0].label, 'a'); + assert.equal(roots[0].children[0].label, 'AI governance'); // domain nested under folder +}); + +// ---- diagnostics + muting ---- + +const fa = folder( + 'a', + '/work/a', + verdict({ + repo: { root: '/work/a', name: 'a' }, + domains: [{ tool: 'aiglare', label: 'AI governance', status: 'fail', summary: 'x', findings: [{ title: 'pay.ts', severity: 'red', file: 'src/pay.ts', line: 42 }] }], + }) +); +const fb = folder( + 'b', + '/work/b', + verdict({ + repo: { root: '/work/b', name: 'b' }, + verdict: 'warn', + domains: [{ tool: 'tieline', label: 'Contract drift', status: 'warn', summary: 'y', findings: [{ title: 'GET /x', severity: 'drift', file: '/abs/api.ts', line: 7 }] }], + }) +); + +test('toDiagnosticsMulti: located findings across folders are all surfaced', () => { + const ds = toDiagnosticsMulti([fa, fb]); + assert.equal(ds.length, 2); + assert.equal(ds[0].file, path.join('/work/a', 'src/pay.ts')); + assert.equal(ds[1].file, '/abs/api.ts'); +}); + +test('findingsForFile: filters to a single file', () => { + const hits = findingsForFile([fa, fb], path.join('/work/a', 'src/pay.ts')); + assert.equal(hits.length, 1); + assert.equal(hits[0].tool, 'aiglare'); +}); + +test('muting: a muted key is dropped from diagnostics', () => { + const key = findingKey('aiglare', path.join('/work/a', 'src/pay.ts'), 42, 'pay.ts'); + const ds = toDiagnosticsMulti([fa, fb], new Set([key])); + assert.equal(ds.length, 1); + assert.equal(ds[0].tool, 'tieline'); // only the un-muted finding remains +}); + +test('findingKey: stable identity string', () => { + assert.equal(findingKey('aiglare', '/a/b.ts', 3, 'x'), 'aiglare:/a/b.ts:3:x'); +}); + +test('toDiagnostics: muted set drops the matching finding', () => { + const v = verdict({ repo: { root: '/r', name: 'r' } }); + const key = findingKey('aiglare', path.join('/r', 'src/pay.ts'), 42, 'pay.ts'); + assert.equal(toDiagnostics(v).length, 1); + assert.equal(toDiagnostics(v, new Set([key])).length, 0); +}); diff --git a/package-lock.json b/package-lock.json index 3014283..a9d514d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nugehs/gate", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nugehs/gate", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "bin": { "gate": "src/cli.js", diff --git a/package.json b/package.json index 64465dd..04d7fe6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nugehs/gate", - "version": "0.2.0", + "version": "0.3.0", "mcpName": "io.github.nugehs/gate", "description": "gate — one ship/no-ship verdict from aiglare, bouncer, tieline & repoctx. The unified merge gate for the nugehs toolchain: run four deterministic checks, get one normalized verdict.", "type": "module", diff --git a/server.json b/server.json index ba5259a..6596f40 100644 --- a/server.json +++ b/server.json @@ -6,13 +6,13 @@ "url": "https://github.com/nugehs/gate", "source": "github" }, - "version": "0.2.0", + "version": "0.3.0", "packages": [ { "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "@nugehs/gate", - "version": "0.2.0", + "version": "0.3.0", "runtimeHint": "npx", "transport": { "type": "stdio" diff --git a/src/adapters/aiglare.js b/src/adapters/aiglare.js index 2fe785a..9a4aefa 100644 --- a/src/adapters/aiglare.js +++ b/src/adapters/aiglare.js @@ -7,7 +7,7 @@ // derive the blocking verdict ourselves — a red surface on a side-effectful // sink is the "AI auto-triggers an irreversible action" case. -import { STATUS } from '../verdict.js'; +import { STATUS, MAX_FINDINGS } from '../verdict.js'; const isBlocking = (s) => s.severity === 'red' && s.sink === 'side-effectful'; @@ -64,7 +64,7 @@ export default { status, summary: parts.join(' · '), counts: { surfaces: count, red: s.red, amber: s.amber, green: s.green, blocking: blocking.length }, - findings: ordered.slice(0, 5).map((x) => ({ + findings: ordered.slice(0, MAX_FINDINGS).map((x) => ({ id: x.file, severity: 'red', title: x.file, diff --git a/src/adapters/bouncer.js b/src/adapters/bouncer.js index e552082..978b48c 100644 --- a/src/adapters/bouncer.js +++ b/src/adapters/bouncer.js @@ -5,7 +5,7 @@ // unknown — surface not locatable; explicitly NOT a pass // No findings at all means no packs are configured for this repo → skipped. -import { STATUS } from '../verdict.js'; +import { STATUS, MAX_FINDINGS } from '../verdict.js'; export default { tool: 'bouncer', @@ -54,7 +54,7 @@ export default { status, summary: parts.join(', '), counts: { rules: findings.length, pass: pass.length, fail: fail.length, unknown: unknown.length }, - findings: [...fail, ...unknown].slice(0, 5).map((f) => ({ + findings: [...fail, ...unknown].slice(0, MAX_FINDINGS).map((f) => ({ id: f.ruleId, severity: f.severity, title: f.standard, diff --git a/src/adapters/repoctx.js b/src/adapters/repoctx.js index 456b041..42038cb 100644 --- a/src/adapters/repoctx.js +++ b/src/adapters/repoctx.js @@ -2,7 +2,7 @@ // Dialect: { verdict: 'PASS'|'WARN'|'FAIL', checks: [ { name, status, summary } ] } // This is the merge spine the other three feed; its verdict maps directly. -import { STATUS } from '../verdict.js'; +import { STATUS, MAX_FINDINGS } from '../verdict.js'; export default { tool: 'repoctx', @@ -43,7 +43,7 @@ export default { status, summary, counts: { checks: checks.length, failing: failing.length }, - findings: failing.slice(0, 5).map((c) => ({ + findings: failing.slice(0, MAX_FINDINGS).map((c) => ({ id: c.name, severity: String(c.status).toLowerCase(), title: c.summary, diff --git a/src/adapters/tieline.js b/src/adapters/tieline.js index 081f510..4487d46 100644 --- a/src/adapters/tieline.js +++ b/src/adapters/tieline.js @@ -4,7 +4,7 @@ // unverifiable — call shape couldn't be resolved on one side // All-zero totals means no contract map was built (not configured) → skipped. -import { STATUS } from '../verdict.js'; +import { STATUS, MAX_FINDINGS } from '../verdict.js'; export default { tool: 'tieline', @@ -53,7 +53,7 @@ export default { status, summary: parts.join(' · '), counts: t, - findings: (json.drift ?? []).slice(0, 5).map((d) => ({ + findings: (json.drift ?? []).slice(0, MAX_FINDINGS).map((d) => ({ id: `${d.method ?? ''} ${d.path ?? d.url ?? ''}`.trim(), severity: 'drift', title: d.path ?? d.url ?? d.endpoint, diff --git a/src/verdict.js b/src/verdict.js index 14efedb..ed972ca 100644 --- a/src/verdict.js +++ b/src/verdict.js @@ -15,6 +15,14 @@ export const STATUS = Object.freeze({ ERROR: 'error', // the tool could not be run or returned garbage }); +// Per-domain bound on the `findings` array each adapter carries in the JSON. +// The summary counts always hold the true totals; this only bounds the +// per-finding drill-down an editor turns into squiggles and tree rows. Set high +// so no real repo is truncated — it exists purely as a runaway guard. (Was an +// arbitrary 5, which starved editor clients of diagnostics on any repo with +// more than a handful of findings.) +export const MAX_FINDINGS = 200; + // How bad each status is when rolling up. SKIPPED is invisible to the verdict; // UNKNOWN and ERROR sit at WARN level so they never silently pass. const RANK = Object.freeze({ From ae78720dc2ede8b2c54af2faf4466bed3b53eb19 Mon Sep 17 00:00:00 2001 From: Segun Olumbe Date: Sun, 14 Jun 2026 10:31:28 +0100 Subject: [PATCH 2/2] =?UTF-8?q?test(vscode):=20headless=20activation=20smo?= =?UTF-8?q?ke=20=E2=80=94=20run=20real=20activate()=20against=20a=20mock?= =?UTF-8?q?=20vscode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inject a fake `vscode`, run the compiled activate(), and exercise the cockpit render, chat participant, LM tool and MCP provider end-to-end. Catches runtime wiring bugs the type-checker can't, without a live editor. +9 tests (36 total). --- clients/vscode/test/activation.test.js | 119 ++++++++++++++ clients/vscode/test/vscode-mock.js | 216 +++++++++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 clients/vscode/test/activation.test.js create mode 100644 clients/vscode/test/vscode-mock.js diff --git a/clients/vscode/test/activation.test.js b/clients/vscode/test/activation.test.js new file mode 100644 index 0000000..61292db --- /dev/null +++ b/clients/vscode/test/activation.test.js @@ -0,0 +1,119 @@ +// Headless activation smoke test: inject a fake `vscode`, run the real compiled +// activate(), and exercise the AI surfaces + cockpit end-to-end. This catches +// runtime wiring bugs (bad command ids, missing registrations, render throws) +// that the type-checker can't, without needing a live editor. +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Module = require('node:module'); + +const vscodeMock = require('./vscode-mock.js'); +const rec = vscodeMock.__; + +// Intercept `require('vscode')` for the extension and its modules. +const origLoad = Module._load; +Module._load = function (request, parent, isMain) { + if (request === 'vscode') return vscodeMock; + return origLoad.call(this, request, parent, isMain); +}; + +const extension = require('../out/extension.js'); + +const ctxState = new Map(); +const context = { + subscriptions: [], + extensionPath: '/fake/ext', + extensionUri: vscodeMock.Uri.file('/fake/ext'), + workspaceState: { + get: (k, d) => (ctxState.has(k) ? ctxState.get(k) : d), + update: (k, v) => { + ctxState.set(k, v); + return Promise.resolve(); + }, + }, +}; + +// Activate once (no workspace folders → check() early-returns, nothing spawns). +let activateError = null; +try { + extension.activate(context); +} catch (e) { + activateError = e; +} + +test('activate() runs without throwing and registers disposables', () => { + assert.equal(activateError, null); + assert.ok(context.subscriptions.length > 5); +}); + +test('all commands are registered', () => { + for (const id of [ + 'gate.check', + 'gate.refresh', + 'gate.showOutput', + 'gate.focusCockpit', + 'gate.muteFinding', + 'gate.clearMutes', + 'gate.installEngine', + ]) { + assert.equal(typeof rec.commands[id], 'function', `missing command: ${id}`); + } +}); + +test('views, diagnostics and the three providers are wired', () => { + assert.ok(rec.trees.some((t) => t.id === 'gate.verdict')); + assert.ok(rec.webviews.some((w) => w.id === 'gate.cockpit')); + assert.equal(rec.codeActionProviders.length, 1); + assert.equal(rec.hoverProviders.length, 1); + assert.equal(rec.codeLensProviders.length, 1); + assert.ok(rec.saveHandlers.length >= 1); +}); + +test('AI surfaces are registered: tool, participant, mcp provider', () => { + assert.ok(rec.tools['gate_check'], 'gate_check tool not registered'); + assert.ok(rec.participants['nugehs.gate'], 'chat participant not registered'); + assert.ok(rec.mcpProviders['nugehs-gate'], 'mcp provider not registered'); +}); + +test('cockpit renders HTML when resolved', () => { + const cockpit = rec.webviews.find((w) => w.id === 'gate.cockpit').provider; + const view = { webview: { options: {}, html: '', onDidReceiveMessage: () => ({ dispose() {} }) } }; + cockpit.resolveWebviewView(view); + assert.match(view.webview.html, //); + assert.match(view.webview.html, /Re-check/); + assert.match(view.webview.html, /No workspace folder open/); // no folders in this harness +}); + +test('chat participant streams a verdict', async () => { + const handler = rec.participants['nugehs.gate']; + const streamed = []; + const stream = { + markdown: (s) => streamed.push(typeof s === 'string' ? s : s.value), + progress: () => {}, + button: () => {}, + }; + const token = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }; + await handler({ command: undefined, prompt: '' }, {}, stream, token); + const text = streamed.join(''); + assert.match(text, /gate:/i); + assert.match(text, /NONE/); // no folders → nothing to check +}); + +test('LM tool returns an actionable text verdict', async () => { + const tool = rec.tools['gate_check']; + const token = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }; + const result = await tool.invoke({ input: {} }, token); + assert.ok(result && Array.isArray(result.parts)); + assert.match(result.parts[0].value, /gate verdict:/); +}); + +test('MCP provider yields a stdio definition that runs `gate mcp`', () => { + const provider = rec.mcpProviders['nugehs-gate']; + const defs = provider.provideMcpServerDefinitions(); + assert.equal(defs.length, 1); + assert.ok(defs[0].args.includes('mcp')); +}); + +test('deactivate() is clean', () => { + assert.doesNotThrow(() => extension.deactivate()); + Module._load = origLoad; // restore +}); diff --git a/clients/vscode/test/vscode-mock.js b/clients/vscode/test/vscode-mock.js new file mode 100644 index 0000000..7712fb6 --- /dev/null +++ b/clients/vscode/test/vscode-mock.js @@ -0,0 +1,216 @@ +// A minimal fake of the `vscode` module — just enough surface to run the real +// activate() and exercise the cockpit, chat participant, LM tool and MCP provider +// in plain node:test, with no editor. Every registration is recorded in `__` so +// the activation test can assert on it and invoke captured handlers. + +const rec = { + commands: {}, + executed: [], + trees: [], + webviews: [], + terminals: [], + codeActionProviders: [], + hoverProviders: [], + codeLensProviders: [], + saveHandlers: [], + folderHandlers: [], + tools: {}, + mcpProviders: {}, + participants: {}, +}; + +class EventEmitter { + constructor() { + this.listeners = []; + } + get event() { + return (fn) => { + this.listeners.push(fn); + return { dispose() {} }; + }; + } + fire(e) { + for (const l of this.listeners) l(e); + } + dispose() {} +} + +class Range { + constructor(...a) { + this.args = a; + } +} +class Diagnostic { + constructor(range, message, severity) { + Object.assign(this, { range, message, severity }); + } +} +class ThemeIcon { + constructor(id, color) { + this.id = id; + this.color = color; + } +} +class ThemeColor { + constructor(id) { + this.id = id; + } +} +class TreeItem { + constructor(label, collapsibleState) { + this.label = label; + this.collapsibleState = collapsibleState; + } +} +class CodeAction { + constructor(title, kind) { + this.title = title; + this.kind = kind; + } +} +class MarkdownString { + constructor(value, supportThemeIcons) { + this.value = value || ''; + this.supportThemeIcons = supportThemeIcons; + this.isTrusted = false; + } + appendMarkdown(s) { + this.value += s; + return this; + } +} +class Hover { + constructor(contents) { + this.contents = contents; + } +} +class CodeLens { + constructor(range, command) { + this.range = range; + this.command = command; + } +} +class LanguageModelToolResult { + constructor(parts) { + this.parts = parts; + } +} +class LanguageModelTextPart { + constructor(value) { + this.value = value; + } +} +class McpStdioServerDefinition { + constructor(label, command, args, env, version) { + Object.assign(this, { label, command, args, env, version }); + } +} + +const Uri = { + file: (p) => ({ scheme: 'file', fsPath: p, path: p, toString: () => 'file://' + p }), + parse: (s) => ({ scheme: 'parsed', fsPath: s, toString: () => s }), + joinPath: (base, ...segs) => ({ fsPath: [base.fsPath, ...segs].join('/'), toString: () => [base.fsPath, ...segs].join('/') }), +}; + +const window = { + createOutputChannel: (name) => ({ name, appendLine() {}, show() {}, dispose() {} }), + createStatusBarItem: () => ({ text: '', tooltip: '', command: '', show() {}, hide() {}, dispose() {} }), + registerTreeDataProvider: (id, provider) => { + rec.trees.push({ id, provider }); + return { dispose() {} }; + }, + registerWebviewViewProvider: (id, provider) => { + rec.webviews.push({ id, provider }); + return { dispose() {} }; + }, + createTerminal: (name) => { + rec.terminals.push(name); + return { show() {}, sendText() {} }; + }, +}; + +const languages = { + createDiagnosticCollection: (name) => ({ name, clear() {}, set() {}, delete() {}, dispose() {} }), + registerCodeActionsProvider: (_sel, provider) => { + rec.codeActionProviders.push(provider); + return { dispose() {} }; + }, + registerHoverProvider: (_sel, provider) => { + rec.hoverProviders.push(provider); + return { dispose() {} }; + }, + registerCodeLensProvider: (_sel, provider) => { + rec.codeLensProviders.push(provider); + return { dispose() {} }; + }, +}; + +const commands = { + registerCommand: (id, fn) => { + rec.commands[id] = fn; + return { dispose() {} }; + }, + executeCommand: (id, ...args) => { + rec.executed.push({ id, args }); + return Promise.resolve(); + }, +}; + +const workspace = { + workspaceFolders: undefined, + getConfiguration: () => ({ get: (_key, def) => def }), + onDidSaveTextDocument: (fn) => { + rec.saveHandlers.push(fn); + return { dispose() {} }; + }, + onDidChangeWorkspaceFolders: (fn) => { + rec.folderHandlers.push(fn); + return { dispose() {} }; + }, +}; + +const lm = { + registerTool: (name, tool) => { + rec.tools[name] = tool; + return { dispose() {} }; + }, + registerMcpServerDefinitionProvider: (id, provider) => { + rec.mcpProviders[id] = provider; + return { dispose() {} }; + }, +}; + +const chat = { + createChatParticipant: (id, handler) => { + rec.participants[id] = handler; + return { id, iconPath: undefined, dispose() {} }; + }, +}; + +module.exports = { + __: rec, + EventEmitter, + Range, + Diagnostic, + ThemeIcon, + ThemeColor, + TreeItem, + CodeAction, + MarkdownString, + Hover, + CodeLens, + LanguageModelToolResult, + LanguageModelTextPart, + McpStdioServerDefinition, + Uri, + window, + languages, + commands, + workspace, + lm, + chat, + StatusBarAlignment: { Left: 1, Right: 2 }, + DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 }, + TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 }, + CodeActionKind: { QuickFix: { value: 'quickfix' } }, +};