From d6eba623d7de5b057ea95ef5b9c65b4cfee09264 Mon Sep 17 00:00:00 2001 From: kkdev92 <112151103+kkdev92@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:58:04 +0900 Subject: [PATCH] feat(cli): print, draw and check the plan an extension compiles `describePlan` made the plan readable as data; this makes it reachable without writing code. `vscode-ext-kit plan ` evaluates an extension's entry module and prints what it registers -- as JSON, as a Mermaid or Graphviz graph of modules, services and the edges between them, or, with `--check`, as an exit code and the list of problems preflight found. The entry module is evaluated with a stand-in for `vscode`, because the real module only exists inside an extension host. That works because nothing in this package touches VS Code before `activate`; an extension's own module-scope code is held to the same rule, which the framework already asks of it. ESM imports of `vscode` are redirected by a resolution hook, CommonJS `require('vscode')` -- what a bundled extension does -- by Node's CommonJS resolver, so both an unbundled entry and a production bundle read the same way. The tool lives in `bin/`, outside the runtime core, which may not touch Node. `verify:package` runs it from the installed tarball against a plan the throwaway consumer wrote, so the one place the stand-in meets a real install is checked on every run. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 + README.md | 22 ++ bin/vscode-ext-kit.mjs | 401 +++++++++++++++++++++++++++++ bin/vscode-stub-hooks.mjs | 21 ++ bin/vscode-stub.cjs | 30 +++ docs/guide.md | 5 + package.json | 14 +- scripts/verify-package.mjs | 36 +++ tests/cli/fixtures/broken-plan.mjs | 16 ++ tests/cli/fixtures/sample-plan.mjs | 25 ++ tests/cli/plan.test.ts | 122 +++++++++ tests/node-shims.d.ts | 11 + 12 files changed, 712 insertions(+), 2 deletions(-) create mode 100644 bin/vscode-ext-kit.mjs create mode 100644 bin/vscode-stub-hooks.mjs create mode 100644 bin/vscode-stub.cjs create mode 100644 tests/cli/fixtures/broken-plan.mjs create mode 100644 tests/cli/fixtures/sample-plan.mjs create mode 100644 tests/cli/plan.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 63b1763..8c100be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,17 @@ Pre-1.0 releases followed it in spirit; their breaking changes are marked **Brea `PreflightError` is exported from the root, so the error can be recognised with `instanceof` rather than by its name. +- **A command-line tool: `vscode-ext-kit plan`.** It reads the plan an + extension compiles at import time and prints it — as the JSON `describePlan` + returns, as a Mermaid or Graphviz graph of modules, services and the edges + between them, or, with `--check`, as an exit code and the list of problems + preflight found. The entry module is evaluated with a stand-in for `vscode`, + which only exists inside an extension host; that works because nothing in + this package touches VS Code before `activate`, and it holds an extension's + module-scope code to the rule the framework already asks of it. The tool + lives in `bin/` and is exercised by `verify:package` against the installed + tarball, not just the repository's own layout. + ### Changed - **`defineExtension` is single-use, like the extension host it serves.** A diff --git a/README.md b/README.md index 5351fd5..60aa57a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ has stopped being obvious._ - [Quick Start](#quick-start) - [Why vscode-ext-kit](#why-vscode-ext-kit) - [Usage](#usage) +- [Command Line](#command-line) - [What Is Guaranteed](#what-is-guaranteed) - [Known Limitations](#known-limitations) - [How It Works](#how-it-works) @@ -261,6 +262,27 @@ generated API reference is not built yet. --- +## Command Line + +The package ships one command. It reads the plan an extension compiles at +import time and prints it, so what the extension registers can be reviewed, +diffed and drawn without starting VS Code. + +```bash +npx vscode-ext-kit plan ./out/extension.js # the plan as JSON +npx vscode-ext-kit plan ./out/extension.js --format mermaid # modules, services and their edges +npx vscode-ext-kit plan ./out/extension.js --check # exit 1 with every problem preflight found +``` + +The entry module is evaluated with a stand-in for `vscode`, which only exists +inside an extension host. That works because nothing in this package touches +VS Code before `activate` — and it means module-scope code in the extension +must not either, which the framework already asks for. Export the +`defineExtension` result as `app` (or name the export with `--export`); the +JSON is what `describePlan` returns. + +--- + ## What Is Guaranteed - `stop()` runs exactly once, and only after `start()` completed or failed diff --git a/bin/vscode-ext-kit.mjs b/bin/vscode-ext-kit.mjs new file mode 100644 index 0000000..508af1e --- /dev/null +++ b/bin/vscode-ext-kit.mjs @@ -0,0 +1,401 @@ +#!/usr/bin/env node +// The command-line tool. +// +// vscode-ext-kit plan [--export ] [--format json|mermaid|dot] [--check] [--kit ] +// +// Reads the plan an extension compiles at import time and prints it: as the +// JSON `describePlan` produces, as a Mermaid or Graphviz graph of modules, +// services and the edges between them, or — with `--check` — as nothing but +// an exit code and the list of problems preflight found. +// +// The entry module is evaluated with a stand-in for `vscode` (see +// vscode-stub.cjs), because the real module only exists inside an extension +// host. Nothing in this package touches VS Code before `activate`, so a +// well-formed entry evaluates to its plan without noticing. Module-scope code +// that reads a VS Code value would get a proxy instead; keep such reads inside +// `activate` or a handler, which the framework asks for anyway. + +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire, register } from 'node:module'; +import { resolve as resolvePath } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const EXIT_OK = 0; +const EXIT_PREFLIGHT = 1; +const EXIT_USAGE = 2; + +const USAGE = `usage: vscode-ext-kit plan [options] + + Prints the plan an extension compiles at import time. + + the extension's entry module (ESM or a CommonJS bundle) + --export the export holding the defineExtension result or the plan + (default: tries "app", then the default export) + --format json (default) | mermaid | dot + --check print nothing on success; exit 1 listing every problem + preflight found + --kit the @kkdev92/vscode-ext-kit to describe the plan with + (default: the copy this tool ships in) + --help this text + +exit codes: 0 ok, 1 preflight rejected the plan, 2 usage or load error +`; + +// --- `vscode` stand-in ---------------------------------------------------- +// Registered before anything else is imported. ESM imports go through the +// hooks module; CommonJS `require('vscode')` — what a bundled extension does — +// goes through Node's CommonJS resolver, which is patched here to agree. +const STUB_PATH = fileURLToPath(new URL('./vscode-stub.cjs', import.meta.url)); +const requireFromHere = createRequire(import.meta.url); +const NodeModule = requireFromHere('node:module'); +const resolveFilename = NodeModule._resolveFilename; +NodeModule._resolveFilename = function (request, ...rest) { + return request === 'vscode' ? STUB_PATH : resolveFilename.call(this, request, ...rest); +}; +register('./vscode-stub-hooks.mjs', import.meta.url); + +// --- arguments ------------------------------------------------------------ +/** @param {string[]} argv */ +function parse(argv) { + const options = { + command: undefined, + entry: undefined, + exportName: undefined, + format: 'json', + check: false, + kit: undefined, + help: false, + }; + const positional = []; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const value = () => { + const next = argv[index + 1]; + if (next === undefined || next.startsWith('--')) { + throw new UsageError(`${arg} needs a value`); + } + index += 1; + return next; + }; + switch (arg) { + case '--help': + case '-h': + options.help = true; + break; + case '--export': + options.exportName = value(); + break; + case '--format': + options.format = value(); + break; + case '--check': + options.check = true; + break; + case '--kit': + options.kit = value(); + break; + default: + if (arg.startsWith('--')) { + throw new UsageError(`unknown option ${arg}`); + } + positional.push(arg); + } + } + [options.command, options.entry] = positional; + if (!['json', 'mermaid', 'dot'].includes(options.format)) { + throw new UsageError(`--format must be json, mermaid or dot, not ${options.format}`); + } + return options; +} + +class UsageError extends Error {} + +// --- loading -------------------------------------------------------------- +/** Whether a value has the shape `compileApplication` produces. */ +function isPlan(value) { + return ( + typeof value === 'object' && + value !== null && + typeof value.name === 'string' && + Array.isArray(value.modules) && + Array.isArray(value.services) + ); +} + +/** + * Finds the plan in what the entry module exported: either a + * `defineExtension` result (which carries `.plan`) or a plan itself. + */ +async function loadPlan(entryPath, exportName) { + const url = pathToFileURL(resolvePath(entryPath)).href; + const exported = await import(url); + const candidates = + exportName === undefined + ? [exported.app, exported.default?.app, exported.default, exported.plan] + : [exported[exportName], exported.default?.[exportName]]; + for (const candidate of candidates) { + if (isPlan(candidate)) { + return candidate; + } + if (typeof candidate === 'object' && candidate !== null && isPlan(candidate.plan)) { + return candidate.plan; + } + } + throw new UsageError( + exportName === undefined + ? `${entryPath} exports no plan. Export the defineExtension result as "app", or name the export with --export.` + : `${entryPath} has no export "${exportName}" holding a defineExtension result or a plan.` + ); +} + +/** + * The package to describe the plan with: the copy this tool ships in, unless + * `--kit` names another. Accepts a package directory or a module file. + */ +async function loadKit(kitPath) { + let target; + if (kitPath === undefined) { + target = new URL('../dist/index.js', import.meta.url); + } else { + const absolute = resolvePath(kitPath); + const manifest = resolvePath(absolute, 'package.json'); + if (existsSync(manifest)) { + const entry = + JSON.parse(readFileSync(manifest, 'utf8')).exports?.['.']?.import ?? './dist/index.js'; + target = pathToFileURL(resolvePath(absolute, entry)); + } else { + target = pathToFileURL(absolute); + } + } + const kit = await import(target.href); + if (typeof kit.describePlan !== 'function') { + throw new UsageError( + `${fileURLToPath(target)} does not export describePlan; is it @kkdev92/vscode-ext-kit 4.1 or later?` + ); + } + return kit; +} + +// --- output --------------------------------------------------------------- +/** A Mermaid/DOT-safe node id. */ +const nodeId = (prefix, text) => `${prefix}_${text.replace(/[^A-Za-z0-9_]/g, '_')}`; +const quote = (text) => text.replace(/"/g, '#quot;'); + +/** + * Modules as subgraphs; services, commands, hosted services, watchers and + * views inside them; dependency edges between them. Framework services that + * something depends on appear in their own subgraph, so an edge never points + * at nothing. + */ +function toMermaid(description) { + const lines = ['flowchart LR']; + const edges = []; + const frameworkUsed = new Set(); + const framework = new Set(description.frameworkServices); + const service = (token) => nodeId('svc', token); + const dependsOn = (from, dependencies, style) => { + for (const token of Object.values(dependencies)) { + if (framework.has(token)) { + frameworkUsed.add(token); + } + edges.push(` ${from} ${style} ${service(token)}`); + } + }; + + for (const module of description.modules) { + lines.push(` subgraph ${nodeId('module', module.id)}["${quote(module.id)}"]`); + for (const entry of description.services.filter((s) => s.moduleId === module.id)) { + lines.push( + ` ${service(entry.token)}["${quote(entry.token)}
${entry.lifetime}"]` + ); + dependsOn(service(entry.token), entry.dependencies, '-->'); + } + for (const entry of description.commands.filter((c) => c.moduleId === module.id)) { + const id = nodeId('cmd', entry.id); + lines.push( + ` ${id}(["${entry.textEditor ? 'editor command' : 'command'}
${quote(entry.id)}"])` + ); + dependsOn(id, entry.dependencies, '-.->'); + } + for (const entry of description.hostedServices.filter((h) => h.moduleId === module.id)) { + const id = nodeId('hosted', entry.id); + lines.push(` ${id}[["hosted service
${quote(entry.id)}"]]`); + dependsOn(id, entry.dependencies, '-.->'); + } + for (const entry of description.fileWatchers.filter((w) => w.moduleId === module.id)) { + const id = nodeId('watch', entry.id); + lines.push(` ${id}>"watcher
${quote(entry.id)}"]`); + dependsOn(id, entry.dependencies, '-.->'); + } + for (const [kind, list] of [ + ['tree view', description.treeViews], + ['webview', description.webviewViews], + ['panel restorer', description.webviewSerializers], + ['raw', description.rawRegistrations], + ]) { + for (const entry of list.filter((v) => v.moduleId === module.id)) { + const id = nodeId(kind.replace(/\s/g, ''), entry.id); + lines.push(` ${id}[/"${kind}
${quote(entry.id)}"/]`); + dependsOn(id, entry.dependencies, '-.->'); + } + } + lines.push(' end'); + } + + if (frameworkUsed.size > 0) { + lines.push(' subgraph framework["framework services"]'); + for (const token of frameworkUsed) { + lines.push(` ${service(token)}["${quote(token)}"]`); + } + lines.push(' end'); + } + + return [...lines, ...edges].join('\n') + '\n'; +} + +/** The same graph for Graphviz. */ +function toDot(description) { + const lines = ['digraph plan {', ' rankdir=LR;', ' node [shape=box, fontname="Helvetica"];']; + const edges = []; + const frameworkUsed = new Set(); + const framework = new Set(description.frameworkServices); + const dependsOn = (from, dependencies, style) => { + for (const token of Object.values(dependencies)) { + if (framework.has(token)) { + frameworkUsed.add(token); + } + edges.push(` "${from}" -> "${token}"${style};`); + } + }; + + for (const module of description.modules) { + lines.push(` subgraph "cluster_${module.id}" {`, ` label="${module.id}";`); + for (const entry of description.services.filter((s) => s.moduleId === module.id)) { + lines.push(` "${entry.token}" [label="${entry.token}\\n${entry.lifetime}"];`); + dependsOn(entry.token, entry.dependencies, ''); + } + const declared = [ + ...description.commands + .filter((c) => c.moduleId === module.id) + .map((c) => [c.id, c.textEditor ? 'editor command' : 'command', c.dependencies]), + ...description.hostedServices + .filter((h) => h.moduleId === module.id) + .map((h) => [h.id, 'hosted service', h.dependencies]), + ...description.fileWatchers + .filter((w) => w.moduleId === module.id) + .map((w) => [w.id, 'watcher', w.dependencies]), + ...description.treeViews + .filter((v) => v.moduleId === module.id) + .map((v) => [v.id, 'tree view', v.dependencies]), + ...description.webviewViews + .filter((v) => v.moduleId === module.id) + .map((v) => [v.id, 'webview', v.dependencies]), + ...description.webviewSerializers + .filter((v) => v.moduleId === module.id) + .map((v) => [v.id, 'panel restorer', v.dependencies]), + ...description.rawRegistrations + .filter((v) => v.moduleId === module.id) + .map((v) => [v.id, 'raw', v.dependencies]), + ]; + for (const [id, kind, dependencies] of declared) { + lines.push(` "${id}" [shape=ellipse, label="${kind}\\n${id}"];`); + dependsOn(id, dependencies, ' [style=dashed]'); + } + lines.push(' }'); + } + + if (frameworkUsed.size > 0) { + lines.push( + ' subgraph "cluster_framework" {', + ' label="framework services";', + ' style=dashed;' + ); + for (const token of frameworkUsed) { + lines.push(` "${token}";`); + } + lines.push(' }'); + } + + return [...lines, ...edges, '}'].join('\n') + '\n'; +} + +/** One line per problem, the way a compiler reports. */ +function formatProblems(problems) { + return problems + .map((problem) => { + const where = problem.moduleId === undefined ? '' : ` (module ${problem.moduleId})`; + const what = problem.subject === undefined ? '' : ` ${problem.subject}`; + return ` ${problem.code}${what}${where}\n ${problem.message}`; + }) + .join('\n'); +} + +// --- main ----------------------------------------------------------------- +async function main(argv) { + const options = parse(argv); + if (options.help || options.command === undefined) { + process.stdout.write(USAGE); + return options.help ? EXIT_OK : EXIT_USAGE; + } + if (options.command !== 'plan') { + throw new UsageError(`unknown command "${options.command}"; only "plan" exists`); + } + if (options.entry === undefined) { + throw new UsageError('plan needs an module'); + } + + let plan; + try { + plan = await loadPlan(options.entry, options.exportName); + } catch (error) { + // Preflight rejects a plan by throwing while the entry module evaluates. + // Recognised by name rather than by class: the error comes from whichever + // copy of the package the entry imported, which need not be this one. + if ( + error instanceof Error && + error.name === 'PreflightError' && + Array.isArray(error.problems) + ) { + process.stderr.write( + `preflight rejected the plan with ${error.problems.length} problem(s):\n${formatProblems(error.problems)}\n` + ); + return EXIT_PREFLIGHT; + } + throw error; + } + + const kit = await loadKit(options.kit); + const description = kit.describePlan(plan); + + if (options.check) { + process.stdout.write( + `plan ok: ${description.modules.length} module(s), ${description.services.length} service(s), ` + + `${description.commands.length} command(s), ${description.hostedServices.length} hosted service(s)\n` + ); + return EXIT_OK; + } + + const output = + options.format === 'mermaid' + ? toMermaid(description) + : options.format === 'dot' + ? toDot(description) + : `${JSON.stringify(description, null, 2)}\n`; + process.stdout.write(output); + return EXIT_OK; +} + +try { + process.exitCode = await main(process.argv.slice(2)); +} catch (error) { + if (error instanceof UsageError) { + process.stderr.write(`vscode-ext-kit: ${error.message}\n\n${USAGE}`); + process.exitCode = EXIT_USAGE; + } else { + process.stderr.write( + `vscode-ext-kit: could not load the plan.\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n` + ); + process.exitCode = EXIT_USAGE; + } +} diff --git a/bin/vscode-stub-hooks.mjs b/bin/vscode-stub-hooks.mjs new file mode 100644 index 0000000..b69b0d4 --- /dev/null +++ b/bin/vscode-stub-hooks.mjs @@ -0,0 +1,21 @@ +// Module resolution hooks for `import 'vscode'`, registered by the CLI before +// it loads an extension's entry module. +// +// Runs on Node's hooks thread, so it can share nothing with the CLI except +// files; the stand-in it points at lives beside it. CommonJS `require('vscode')` +// — what a bundled extension does — is not seen by these hooks and is handled +// on the main thread by the CLI, which patches CommonJS resolution the same way. + +const STUB = new URL('./vscode-stub.cjs', import.meta.url).href; + +/** + * @param {string} specifier + * @param {object} context + * @param {(specifier: string, context: object) => Promise} nextResolve + */ +export async function resolve(specifier, context, nextResolve) { + if (specifier === 'vscode') { + return { url: STUB, format: 'commonjs', shortCircuit: true }; + } + return nextResolve(specifier, context); +} diff --git a/bin/vscode-stub.cjs b/bin/vscode-stub.cjs new file mode 100644 index 0000000..bda2088 --- /dev/null +++ b/bin/vscode-stub.cjs @@ -0,0 +1,30 @@ +// A `vscode` that answers every property with another of itself and does +// nothing, loaded in place of the real module when a plan is read outside the +// extension host — where `vscode` does not exist. +// +// This is enough because nothing in the package touches VS Code before +// `activate`: `defineExtension` compiles the plan and stops. An extension's own +// module-scope code is held to the same rule by the framework's design, so a +// well-formed entry module evaluates to a plan without ever reaching a real +// VS Code value. One that does gets a proxy back rather than a crash, and the +// failure lands where it belongs — in `activate`, in a real host. +'use strict'; + +const make = () => + new Proxy(function vscodeStub() {}, { + get: (_target, key) => { + // Not thenable, so `await` on a proxied value resolves to it instead of + // waiting on a `then` that would never settle. + if (key === 'then') { + return undefined; + } + if (key === Symbol.toPrimitive) { + return () => '[vscode stub]'; + } + return make(); + }, + apply: () => make(), + construct: () => make(), + }); + +module.exports = make(); diff --git a/docs/guide.md b/docs/guide.md index 09da69f..dfa1507 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -816,6 +816,11 @@ anyone — or anything — reading the codebase for the first time. Secret _keys_ appear, because a declared key is metadata the source already states in the clear. Secret values do not exist at plan time. +The same document is available without writing code: `npx vscode-ext-kit plan +./out/extension.js` prints it, `--format mermaid` or `--format dot` draws the +modules, services and edges, and `--check` turns a preflight failure into an +exit code and a list of problems — the shape a CI step wants. + ## Keeping package.json honest VS Code reads the manifest before any extension code runs, so `src` and diff --git a/package.json b/package.json index e95c85d..521464f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "vscode-ext-kit": "./bin/vscode-ext-kit.mjs" + }, "sideEffects": false, "exports": { ".": { @@ -59,6 +62,7 @@ "./package.json": "./package.json" }, "files": [ + "bin", "dist", "src", "README.md", @@ -107,8 +111,8 @@ "test:coverage": "vitest run --coverage", "lint": "eslint src tests fixtures --max-warnings 0", "lint:fix": "eslint src tests fixtures --fix", - "format": "prettier --write \"{src,tests,fixtures,docs}/**/*.ts\" \"{scripts,fixtures}/**/*.mjs\"", - "format:check": "prettier --check \"{src,tests,fixtures,docs}/**/*.ts\" \"{scripts,fixtures}/**/*.mjs\"", + "format": "prettier --write \"{src,tests,fixtures,docs}/**/*.ts\" \"{bin,scripts,fixtures,tests}/**/*.{mjs,cjs}\"", + "format:check": "prettier --check \"{src,tests,fixtures,docs}/**/*.ts\" \"{bin,scripts,fixtures,tests}/**/*.{mjs,cjs}\"", "knip": "knip", "verify:package": "node scripts/verify-package.mjs", "fixture:build": "node fixtures/extension-host/build.mjs", @@ -136,6 +140,9 @@ ], "docs/**/*.ts": [ "prettier --write" + ], + "{bin,scripts}/**/*.{mjs,cjs}": [ + "prettier --write" ] }, "prettier": { @@ -149,12 +156,15 @@ "knip": { "project": [ "src/**/*.ts", + "bin/**/*.{mjs,cjs}", "fixtures/**/*.ts", "fixtures/**/*.mjs", "scripts/**/*.mjs" ], "entry": [ "src/index.ts", + "bin/vscode-stub-hooks.mjs", + "bin/vscode-stub.cjs", "src/testing/index.ts", "src/testing/mock/vitest.ts", "src/testing/mock/vitest-config.ts", diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index dc77c28..6d86318 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -270,6 +270,42 @@ console.log(JSON.stringify(results)); } catch (error) { note(false, `consumer.ts typecheck failed:\n${error.stdout ?? error.message}`); } + + // 5. The command-line tool, run from the installed package against a plan + // the consumer wrote. The only place the `vscode` stand-in meets a real + // install rather than this repository's own layout. + writeFileSync( + join(consumer, 'consumer-plan.mjs'), + `import { defineCommandContract, defineExtension, defineModule } from '${packageName}'; +const Hello = defineCommandContract({ id: 'consumer.hello', title: 'Hello' }); +const greeting = defineModule('greeting', (module) => { + module.commands.handle(Hello, () => undefined); + return undefined; +}); +export const app = defineExtension({ name: 'consumer', modules: [greeting] }); +` + ); + try { + const described = JSON.parse( + run( + process.execPath, + [ + join(consumer, 'node_modules', ...packageName.split('/'), 'bin', 'vscode-ext-kit.mjs'), + 'plan', + join(consumer, 'consumer-plan.mjs'), + '--format', + 'json', + ], + consumer + ) + ); + note( + described.name === 'consumer' && described.commands[0]?.id === 'consumer.hello', + 'the CLI describes a consumer plan from the installed package' + ); + } catch (error) { + note(false, `the CLI failed against the installed package:\n${error.stderr ?? error.message}`); + } } finally { rmSync(work, { recursive: true, force: true }); } diff --git a/tests/cli/fixtures/broken-plan.mjs b/tests/cli/fixtures/broken-plan.mjs new file mode 100644 index 0000000..d39c702 --- /dev/null +++ b/tests/cli/fixtures/broken-plan.mjs @@ -0,0 +1,16 @@ +// Two modules handling one command id: preflight rejects this while the module +// evaluates, which is the failure the CLI's `--check` exists to report. +import { defineCommandContract, defineExtension, defineModule } from '../../../dist/index.js'; + +const Refresh = defineCommandContract({ id: 'sample.refresh', title: 'Refresh' }); + +const first = defineModule('first', (module) => { + module.commands.handle(Refresh, () => undefined); + return undefined; +}); +const second = defineModule('second', (module) => { + module.commands.handle(Refresh, () => undefined); + return undefined; +}); + +export const app = defineExtension({ name: 'broken', modules: [first, second] }); diff --git a/tests/cli/fixtures/sample-plan.mjs b/tests/cli/fixtures/sample-plan.mjs new file mode 100644 index 0000000..ecc7117 --- /dev/null +++ b/tests/cli/fixtures/sample-plan.mjs @@ -0,0 +1,25 @@ +// A small extension, the way a consumer writes one, for the CLI to read. +// Imports the built output because that is what the CLI resolves `vscode` +// against; `npm run typecheck` builds it, and the test skips when it is absent. +import { + Log, + defineCommandContract, + defineExtension, + defineModule, + serviceToken, +} from '../../../dist/index.js'; + +const Clock = serviceToken('sample.clock'); +const Refresh = defineCommandContract({ id: 'sample.refresh', title: 'Refresh' }); + +const projects = defineModule('projects', (module) => { + module.services.singleton(Clock, () => ({ now: () => 0 })); + module.commands.handle(Refresh, { + inject: { clock: Clock, log: Log }, + execute: () => undefined, + }); + module.hostedServices.add({ id: 'projects.index', start: () => undefined }); + return undefined; +}); + +export const app = defineExtension({ name: 'sample', modules: [projects] }); diff --git a/tests/cli/plan.test.ts b/tests/cli/plan.test.ts new file mode 100644 index 0000000..0ba520e --- /dev/null +++ b/tests/cli/plan.test.ts @@ -0,0 +1,122 @@ +/** + * The command-line tool, run as a consumer runs it: a child process, an entry + * module, and whatever comes out on stdout, stderr and the exit code. + * + * The fixtures import the built output, because the tool resolves `vscode` to + * a stand-in and that only matters for code that actually imports `vscode` — + * the real adapters in `dist/`. `npm run typecheck` builds it; without it this + * suite skips rather than failing on a missing file, and says so. + */ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +// Tests execute on Node, but the repo's tsconfig deliberately omits Node types +// (the runtime core must not reach them). Declare the one global this file needs. +declare const process: { readonly execPath: string }; + +const cli = resolve('bin', 'vscode-ext-kit.mjs'); +const fixture = (name: string): string => resolve('tests', 'cli', 'fixtures', name); +const kit = resolve('dist', 'index.js'); +const built = existsSync(kit); + +interface Run { + readonly code: number; + readonly stdout: string; + readonly stderr: string; +} + +/** Runs the CLI and captures everything, whichever way it exits. */ +function run(...args: readonly string[]): Run { + try { + const stdout = execFileSync(process.execPath, [cli, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, stdout, stderr: '' }; + } catch (error) { + const failed = error as { status?: number; stdout?: string; stderr?: string }; + return { code: failed.status ?? -1, stdout: failed.stdout ?? '', stderr: failed.stderr ?? '' }; + } +} + +describe.skipIf(!built)('vscode-ext-kit plan', () => { + it('describes a plan as JSON, attributing each entry to its module', () => { + const result = run('plan', fixture('sample-plan.mjs'), '--kit', kit); + + expect(result.code).toBe(0); + const description = JSON.parse(result.stdout) as { + name: string; + services: { token: string; lifetime: string; moduleId: string }[]; + commands: { id: string; dependencies: Record; moduleId: string }[]; + hostedServices: { id: string }[]; + }; + expect(description.name).toBe('sample'); + expect(description.services).toEqual([ + { token: 'sample.clock', lifetime: 'singleton', dependencies: {}, moduleId: 'projects' }, + ]); + expect(description.commands[0]).toMatchObject({ + id: 'sample.refresh', + dependencies: { clock: 'sample.clock', log: 'framework.log' }, + moduleId: 'projects', + }); + expect(description.hostedServices.map((service) => service.id)).toEqual(['projects.index']); + }); + + it('draws the plan as a Mermaid graph with dependency edges', () => { + const result = run('plan', fixture('sample-plan.mjs'), '--kit', kit, '--format', 'mermaid'); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('flowchart LR'); + expect(result.stdout).toContain('subgraph module_projects["projects"]'); + // The command depends on the module's own service and on a framework one; + // both edges point at a node that exists. + expect(result.stdout).toContain('cmd_sample_refresh -.-> svc_sample_clock'); + expect(result.stdout).toContain('cmd_sample_refresh -.-> svc_framework_log'); + expect(result.stdout).toContain('subgraph framework["framework services"]'); + }); + + it('draws the same graph for Graphviz', () => { + const result = run('plan', fixture('sample-plan.mjs'), '--kit', kit, '--format', 'dot'); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('digraph plan {'); + expect(result.stdout).toContain('subgraph "cluster_projects"'); + expect(result.stdout).toContain('"sample.refresh" -> "sample.clock" [style=dashed];'); + }); + + it('exits 1 on --check when preflight rejects the plan, naming every problem', () => { + const result = run('plan', fixture('broken-plan.mjs'), '--kit', kit, '--check'); + + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('COMMAND_HANDLER_CONFLICT sample.refresh (module second)'); + expect(result.stderr).toContain('only one handler per command id'); + }); + + it('exits 0 on --check with a one-line summary when the plan is sound', () => { + const result = run('plan', fixture('sample-plan.mjs'), '--kit', kit, '--check'); + + expect(result.code).toBe(0); + expect(result.stdout.trim()).toBe( + 'plan ok: 1 module(s), 1 service(s), 1 command(s), 1 hosted service(s)' + ); + }); + + it('explains what to export when the entry holds no plan', () => { + const result = run('plan', fixture('../plan.test.ts'), '--kit', kit); + + expect(result.code).toBe(2); + expect(result.stderr).toContain('could not load the plan'); + }); + + it('treats an unknown option as a usage error', () => { + const result = run('plan', fixture('sample-plan.mjs'), '--colour'); + + expect(result.code).toBe(2); + expect(result.stderr).toContain('unknown option --colour'); + expect(result.stderr).toContain('usage: vscode-ext-kit plan'); + }); +}); diff --git a/tests/node-shims.d.ts b/tests/node-shims.d.ts index dec31e5..dd8356b 100644 --- a/tests/node-shims.d.ts +++ b/tests/node-shims.d.ts @@ -21,11 +21,22 @@ declare module 'node:fs' { } export function readdirSync(path: string, options: { withFileTypes: true }): Dirent[]; export function readFileSync(path: string, encoding: 'utf8'): string; + export function existsSync(path: string): boolean; } declare module 'node:path' { export function join(...segments: string[]): string; + export function resolve(...segments: string[]): string; export function dirname(path: string): string; export function relative(from: string, to: string): string; export const posix: { normalize(path: string): string; join(...segments: string[]): string }; } + +declare module 'node:child_process' { + /** The one call the CLI test makes: run, capture, and throw on a non-zero exit. */ + export function execFileSync( + file: string, + args: readonly string[], + options: { encoding: 'utf8'; stdio: readonly ['ignore', 'pipe', 'pipe'] } + ): string; +}