From d2b9a38dfbe5d39eb5912efe7d88ab6523f1f671 Mon Sep 17 00:00:00 2001 From: kkdev92 <112151103+kkdev92@users.noreply.github.com> Date: Sun, 30 Aug 2026 01:01:39 +0900 Subject: [PATCH] docs: a step-by-step migration guide from 2.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/migration-from-2x.md` gives the order to do the work in. Inventory the existing `activate`; classify what it registers; move all of it into one module as raw registrations first, so the host owns activation and cleanup from the first commit; then turn each entry into the declaration it is — hosted services for initialisation and loops, owned scopes for subscriptions, settings and storage declarations, the escape hatch for what has no model. It ends with the Test Host, the manifest check and an Extension Host lane. The two code samples — the starting `activate`, and the same extension declared — compile under the consumer's compiler options and are checked against the page by the same test that covers the README and the guide. Also fixes the changelog's link to the 3.0.0-alpha.1 section, which pointed at the wrong date. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 12 +- README.md | 8 +- docs/migration-from-2x.md | 343 +++++++++++++++++++++++++++++++ docs/samples/migration-after.ts | 99 +++++++++ docs/samples/migration-before.ts | 52 +++++ tests/readme-samples.test.ts | 2 +- 6 files changed, 512 insertions(+), 4 deletions(-) create mode 100644 docs/migration-from-2x.md create mode 100644 docs/samples/migration-after.ts create mode 100644 docs/samples/migration-before.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a7420c4..e0e9f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,16 @@ Pre-1.0 releases followed it in spirit; their breaking changes are marked **Brea its readable form. The reference is not hosted yet; the output is under `docs/api/`, ignored by git. +- **A migration guide from 2.x.** `docs/migration-from-2x.md` gives the order to + do the work in: inventory the existing `activate`, classify what it registers, + move all of it into one module as raw registrations first — so the host owns + activation and cleanup from the first commit — then turn each entry into the + declaration it is: hosted services for initialisation and loops, owned scopes + for subscriptions, settings and storage declarations, the escape hatch for + what has no model. It finishes with the Test Host, the manifest check and an + Extension Host lane, and its two code samples compile and are checked against + the page like every other sample. + ### Changed - **`defineExtension` is single-use, like the extension host it serves.** A @@ -230,7 +240,7 @@ install changes. ### What arrived across the alphas -Read [3.0.0-alpha.1](#300-alpha1---2026-08-07) for the shape of the thing — an +Read [3.0.0-alpha.1](#300-alpha1---2026-08-08) for the shape of the thing — an immutable plan, compiled and validated before VS Code is touched, run by a host that owns one cleanup path. The two alphas after it were consumer-driven, and what they fixed says something about how this was built: diff --git a/README.md b/README.md index ad5c085..4809d65 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ trusted for things it cannot do. - **The Test Host does not reproduce VS Code**: it renders no UI, interprets no contribution point, and does not substitute a direct `import "vscode"` - **No editor events yet**: `Editors` hands you the active editor and cross-file edits, but there is no `onDidChangeActive` / `onDidChangeSelection` / `onDidChangeDocument`; subscribing means reaching for `vscode` directly and disposing by hand, which is the one place the single-cleanup-owner rule leaks - **No log-level filtering, deliberately**: the framework writes to a `LogOutputChannel` and VS Code owns the level — per channel, persisted, in the Output panel. An extension cannot raise its own channel's level, so a `logLevel` setting of your own can only ever make the log quieter -- **No hosted API reference yet** — it is generated from the JSDoc (`npm run docs:api`) but not published — and no step-by-step migration guide from 2.x +- **No hosted API reference yet** — it is generated from the JSDoc (`npm run docs:api`) but not published --- @@ -371,7 +371,11 @@ Three consequences worth knowing before you port: Typed storage keeps 2.x's envelope format, so values written by a 2.x build are read by a 3.x build, and the mock kit is unchanged. -The [CHANGELOG](CHANGELOG.md) carries the full old-to-new mapping. +The [CHANGELOG](CHANGELOG.md) carries the full old-to-new mapping, and +[Migrating from 2.x](docs/migration-from-2x.md) the order to do the work in: +inventory, classification, one module holding everything as raw registrations +first, then each entry turned into the declaration it is, and finally the Test +Host, the manifest check and an Extension Host lane. --- diff --git a/docs/migration-from-2x.md b/docs/migration-from-2x.md new file mode 100644 index 0000000..9332225 --- /dev/null +++ b/docs/migration-from-2x.md @@ -0,0 +1,343 @@ +# Migrating from 2.x + +2.x is a utility library: you call its helpers from your own `activate()`. 3.x +and 4.x are a framework: it owns activation and deactivation, and you hand it +modules. Everything 2.x could do, 4.x can do — the shapes changed, because there +is now one way in per ability rather than a standalone function _and_ a +declaration. This page is the order to do the work in. The old-to-new table for +every removed helper is in the [3.0.0-alpha.1 changelog](../CHANGELOG.md#300-alpha1---2026-08-08) +and is not repeated here. + +Migrate straight to 4.x. The API is the one 3.0.0 exposed; 4.0.0 raised the +VS Code floor and changed nothing else. Before you start, your extension needs +what the [platform requirements](../README.md#platform-requirements) list: +`engines.vscode` of `^1.134.0` or later, ESM, and `ESNext.Disposable` plus an +`AbortSignal` lib in `tsconfig`. Typed storage keeps the 2.x envelope, so there +is no data migration; values a 2.x build wrote are read as they are. + +## The starting point + +Every step below works through an inventory like this one: an `activate` that +builds state, registers things, pushes each disposable onto +`context.subscriptions`, and reads configuration where it happens to need it. +Nothing about it is wrong. The point of the migration is that after it, none of +this file is yours to maintain. + + + +```ts +import * as vscode from 'vscode'; + +// The shape most extensions start from, and the one 2.x helpers were called +// from: an `activate` that registers everything, pushes each disposable onto +// `context.subscriptions`, and reads configuration wherever it happens to be +// needed. Nothing here is wrong. It is the inventory the steps work through. + +let index: Map | undefined; +let timer: ReturnType | undefined; + +export async function activate(context: vscode.ExtensionContext): Promise { + // Async initialisation, awaited before anything is registered. + index = await buildIndex(); + + // A command that reads a setting at call time. + context.subscriptions.push( + vscode.commands.registerCommand('sample.countProjects', () => { + const limit = vscode.workspace.getConfiguration('sample').get('limit', 10); + return Math.min(index?.size ?? 0, limit); + }) + ); + + // A watcher that keeps the index fresh, three callbacks at a time. + const watcher = vscode.workspace.createFileSystemWatcher('**/*.project.json'); + watcher.onDidCreate((uri) => index?.set(uri.fsPath, Date.now())); + watcher.onDidChange((uri) => index?.set(uri.fsPath, Date.now())); + watcher.onDidDelete((uri) => index?.delete(uri.fsPath)); + context.subscriptions.push(watcher); + + // A periodic job on a timer, disposed by hand. + timer = setInterval(() => { + void buildIndex().then((fresh) => (index = fresh)); + }, 30_000); + context.subscriptions.push({ dispose: () => clearInterval(timer) }); + + // A provider this package has no model for. + context.subscriptions.push( + vscode.languages.registerHoverProvider( + { scheme: 'file' }, + { provideHover: () => new vscode.Hover(`${String(index?.size ?? 0)} projects`) } + ) + ); +} + +export function deactivate(): void { + index = undefined; +} + +async function buildIndex(): Promise> { + const files = await vscode.workspace.findFiles('**/*.project.json', '**/node_modules/**'); + return new Map(files.map((file) => [file.fsPath, Date.now()])); +} +``` + +## 1. Inventory `activate` + +List what the extension does at activation, one line per item. The grep that +finds most of it: + +```bash +grep -rn "subscriptions.push\|getConfiguration\|globalState\|workspaceState\|secrets\.\|setInterval\|setTimeout\|onDid" src +``` + +Add the module-level `let`s, everything `activate` awaits, and every `vscode.*` +call that runs outside a handler. If 2.x helpers are in use, each is one line too +— `createExtensionKit`, `safeExecute`, `withProgress`, `createTypedStorage`, +`createFileWatcher`, the picker functions. The list is the migration's scope, +and the unit of every later commit. + +## 2. Classify each item + +Each line is one of these, and the classification decides which declaration it +becomes: + +| It is… | It becomes… | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| a command | `defineCommandContract` + `module.commands.handle` | +| state built at activation | a service: `serviceToken` + `module.services.singleton` | +| something awaited before the extension is usable | a hosted service `start` | +| a loop or timer | `module.hostedServices.background`, with `context.delay` | +| a file system watcher | `module.fileWatchers.add` | +| a configuration read | `defineSettings` + `module.settings.add`, injected under `Settings.token` | +| a memento or secret | `defineStorage` / `defineSecret`, injected under their tokens | +| a notification, picker, progress or status message | the `Notifications`, `QuickInput`, `StatusBar` tokens, or `context.notify` / `.ask` / `.progress` | +| a tree view, webview view or panel restorer | `module.treeViews.add`, `module.webviews.addView`, `module.webviews.restorePanel` | +| a status bar or language status item | `defineStatusBarItem` / `defineLanguageStatusItem` | +| an editor operation | the `Editors` token (`editors.active`) | +| anything else the platform registers — a provider, a URI handler | `module.raw.register`, with the disposable under `registrations.own` | + +Two things stop being items: `context.subscriptions.push` (the host owns what +it registered) and whatever `deactivate` cleaned up by hand (the host unwinds +it, in reverse order, inside a shutdown budget). + +## 3. Move it into a module — as it is, first + +The framework owns `activate`, so there is no half-way state where some +registrations are declared and the rest still live in your `activate`. The +fastest safe path is to move _everything_ first and improve it afterwards: + +1. Create one module. Wrap each existing registration in `module.raw.register`, + with the disposable it produced under `registrations.own(...)` and the state + it needs behind a service. The [escape hatch](guide.md#the-escape-hatch) + shows the shape. +2. Replace `activate` and `deactivate` with the framework's: + `export const app = defineExtension({ name, modules: [...] })`, then + `export const activate = app.activate` and + `export const deactivate = app.deactivate`. +3. Run the extension. Activation and cleanup are now the host's, every + registration rolls back if a later one fails, and `deactivate` unwinds all of + it — before a single declaration has been written. + +From here each step is a small commit: take one raw registration, turn it into +the declaration step 2 named, run the tests, move on. The extension works at +every commit. + +## 4. Async initialisation becomes a hosted service + +Anything `activate` awaited before registering — building an index, opening a +connection — goes into a hosted service's `start`. Activation awaits it, and a +throw rolls the whole activation back rather than leaving a half-activated +extension behind. Service factories stay synchronous, so resolution cannot +deadlock; the async part is the hosted service's. + +A timer or loop becomes `module.hostedServices.background`: the host tracks it, +`context.signal` aborts it on shutdown, and `context.delay` returns early so a +sleeping loop cannot hold the shutdown budget. The `setInterval` handle and the +disposable that cleared it both disappear. + +## 5. Direct subscriptions become owned scopes + +A `createFileSystemWatcher` with three callbacks becomes one +`module.fileWatchers.add`, delivering debounced, deduplicated batches that each +run as an operation — with a logger, a cancellation signal and a resource scope +of their own. A configuration read becomes a declaration whose accessor is +injected, so the key, type, default and contribution scope are stated once and +`package.json` is checked against them. A memento becomes `defineStorage`, and +its existing values are read as they are. + +Everything that still needs the raw API subscribes inside `raw.register`, under +`registrations.own(...)`. Nothing is pushed onto `context.subscriptions` any +more, by you or by anything you declared. + +This is the inventory above after steps 3 to 5: + + + +```ts +import { + defineCommandContract, + defineExtension, + defineModule, + defineSettings, + serviceToken, + setting, + type OperationContext, +} from '@kkdev92/vscode-ext-kit'; + +// The inventory, declared. Each registration says what it is and what it needs; +// when it is disposed is no longer its concern. Nothing in this file imports +// `vscode`, which is what lets a test run this exact plan on fakes. + +interface ProjectIndex { + count(): number; + touch(path: string): void; + forget(path: string): void; + prune(olderThanMs: number): void; +} +const ProjectIndex = serviceToken('sample.projectIndex'); + +// The setting the command read ad hoc, declared once: key, type, default and +// contribution scope, with package.json checked against it. +export const Settings = defineSettings({ + section: 'sample', + values: { limit: setting.number({ default: 10, minimum: 1 }) }, +}); + +// The command's id, arguments and result, fixed for every caller. +export const CountProjects = defineCommandContract({ + id: 'sample.countProjects', +}); + +export const projectsModule = defineModule('projects', (module): undefined => { + module.settings.add(Settings); + + // The module-level `let index` becomes a service: built by the container, + // owned by it, and replaceable in a test. + module.services.singleton(ProjectIndex, () => { + const seen = new Map(); + return { + count: () => seen.size, + touch: (path) => void seen.set(path, Date.now()), + forget: (path) => void seen.delete(path), + prune: (olderThanMs) => { + const cutoff = Date.now() - olderThanMs; + for (const [path, at] of seen) { + if (at < cutoff) seen.delete(path); + } + }, + }; + }); + + module.commands.handle(CountProjects, { + inject: { index: ProjectIndex, settings: Settings.token }, + execute: (_context: OperationContext, _args, { index, settings }) => + Math.min(index.count(), settings.read().values.limit), + }); + + // The watcher, its three callbacks collapsed into one debounced batch that + // runs as an operation. + module.fileWatchers.add({ + id: 'projects.files', + patterns: ['**/*.project.json'], + ignorePatterns: ['**/node_modules/**'], + inject: { index: ProjectIndex }, + handle: (_context: OperationContext, events, { index }) => { + for (const event of events) { + if (event.type === 'delete') index.forget(event.uri.fsPath); + else index.touch(event.uri.fsPath); + } + }, + }); + + // The `setInterval` becomes a background hosted service. The host tracks it, + // `context.delay` returns early on shutdown, and there is no handle to clear. + module.hostedServices.background({ + id: 'projects.prune', + inject: { index: ProjectIndex }, + run: async (context, { index }) => { + while (!context.signal.aborted) { + await context.delay(30_000); + if (context.signal.aborted) return; + index.prune(24 * 60 * 60 * 1000); + } + }, + }); + + return undefined; +}); + +// Preflight runs here, at import time. `activate` and `deactivate` are the +// framework's: `deactivate` is the one cleanup path, and `activate` puts a +// single synchronous failsafe on `context.subscriptions`. Nothing else is +// pushed there, by you or by anything you declared. +export const app = defineExtension({ name: 'Sample', modules: [projectsModule] }); +export const activate = app.activate; +export const deactivate = app.deactivate; +``` + +The hover provider is missing from it on purpose. It is the one thing the +framework has no model for, so it stays a raw registration — and the next step +is about where that lives. + +## 6. Isolate the escape hatch + +Whatever is left in `raw.register` belongs in its own module file. The reason is +the `import * as vscode` at the top of that file: a module that carries one +cannot be imported where no VS Code exists, and a unit test is exactly that +place. Keep the modules that need the platform apart from the ones that do not, +and the plan a test loads is the plan that ships, minus the file it cannot load. + +A raw registration still shows up in the compiled plan, rolls back with the +module, and unwinds through `deactivate`. What it does not get is a fake: the +[escape hatch](guide.md#the-escape-hatch) section says what that costs. + +## 7. Run the production plan in the Test Host + +`createTestHost({ plan: app.plan })` starts the plan you export — not a +rebuild of it for testing — against one fake per capability port. Execute a +command through `host.application.commands.execute`, override one singleton to +isolate a feature, and assert `host.leaks()` after `host.stop()`: a registration +or resource left behind is the bug that used to surface as a stale listener two +releases later. [Testing](guide.md#testing) walks through it. + +If a module you load imports `vscode` (step 6 is why you might not), point +Vitest's `resolve.alias` at `@kkdev92/vscode-ext-kit/testing/vitest`, or merge +`vscodeExtKitVitestConfig` from `@kkdev92/vscode-ext-kit/testing/vitest-config`. + +## 8. Check the manifest against the plan + +`package.json` cannot be generated from the source — VS Code reads it before any +code runs, and it carries titles and descriptions only a person can write — but +the overlap is mechanical: command ids, setting keys, types, defaults, enum +values, scopes, view ids. `assertManifestMatches`, from +`@kkdev92/vscode-ext-kit/testing`, compares the two and names every disagreement +at once, with the JSON to paste. [Keeping package.json +honest](guide.md#keeping-packagejson-honest) has the test. Run it now: a +migration that renamed a section or fixed a default is exactly when the two +drift. + +`npx vscode-ext-kit plan ./dist/extension.js --check` is the same idea one level +up — preflight, from the command line, against the built entry. + +## 9. Add an Extension Host lane + +Unit tests on fakes answer most questions. The ones they cannot — does the +extension activate in a real host, does `deactivate` run inside VS Code's +deadline, does a text editor command behave as the platform actually calls it — +need the real thing. This repository's [fixtures](../fixtures/README.md) show +the two lanes, desktop and web, and how a case asserts an observable fact rather +than what the API is believed to promise. A single case that activates the +migrated extension and runs one command is enough to start with; the real +extensions built on this package each found something there that no unit test +saw. + +## What you can delete afterwards + +- Every `context.subscriptions.push`, and the `deactivate` body that mirrored + it. +- Module-level `let`s that held state between `activate` and the handlers. + They are services now. +- Error presentation around command handlers. A result and a rejection both + reach the caller; the Command Palette already shows the dialog. +- `CancellationToken` plumbing. `context.signal` combines the operation's own + cancellation with the user's, and everything that took a token takes a + signal. diff --git a/docs/samples/migration-after.ts b/docs/samples/migration-after.ts new file mode 100644 index 0000000..4790dd8 --- /dev/null +++ b/docs/samples/migration-after.ts @@ -0,0 +1,99 @@ +import { + defineCommandContract, + defineExtension, + defineModule, + defineSettings, + serviceToken, + setting, + type OperationContext, +} from '@kkdev92/vscode-ext-kit'; + +// The inventory, declared. Each registration says what it is and what it needs; +// when it is disposed is no longer its concern. Nothing in this file imports +// `vscode`, which is what lets a test run this exact plan on fakes. + +interface ProjectIndex { + count(): number; + touch(path: string): void; + forget(path: string): void; + prune(olderThanMs: number): void; +} +const ProjectIndex = serviceToken('sample.projectIndex'); + +// The setting the command read ad hoc, declared once: key, type, default and +// contribution scope, with package.json checked against it. +export const Settings = defineSettings({ + section: 'sample', + values: { limit: setting.number({ default: 10, minimum: 1 }) }, +}); + +// The command's id, arguments and result, fixed for every caller. +export const CountProjects = defineCommandContract({ + id: 'sample.countProjects', +}); + +export const projectsModule = defineModule('projects', (module): undefined => { + module.settings.add(Settings); + + // The module-level `let index` becomes a service: built by the container, + // owned by it, and replaceable in a test. + module.services.singleton(ProjectIndex, () => { + const seen = new Map(); + return { + count: () => seen.size, + touch: (path) => void seen.set(path, Date.now()), + forget: (path) => void seen.delete(path), + prune: (olderThanMs) => { + const cutoff = Date.now() - olderThanMs; + for (const [path, at] of seen) { + if (at < cutoff) seen.delete(path); + } + }, + }; + }); + + module.commands.handle(CountProjects, { + inject: { index: ProjectIndex, settings: Settings.token }, + execute: (_context: OperationContext, _args, { index, settings }) => + Math.min(index.count(), settings.read().values.limit), + }); + + // The watcher, its three callbacks collapsed into one debounced batch that + // runs as an operation. + module.fileWatchers.add({ + id: 'projects.files', + patterns: ['**/*.project.json'], + ignorePatterns: ['**/node_modules/**'], + inject: { index: ProjectIndex }, + handle: (_context: OperationContext, events, { index }) => { + for (const event of events) { + if (event.type === 'delete') index.forget(event.uri.fsPath); + else index.touch(event.uri.fsPath); + } + }, + }); + + // The `setInterval` becomes a background hosted service. The host tracks it, + // `context.delay` returns early on shutdown, and there is no handle to clear. + module.hostedServices.background({ + id: 'projects.prune', + inject: { index: ProjectIndex }, + run: async (context, { index }) => { + while (!context.signal.aborted) { + await context.delay(30_000); + if (context.signal.aborted) return; + index.prune(24 * 60 * 60 * 1000); + } + }, + }); + + return undefined; +}); + +// Preflight runs here, at import time. `activate` and `deactivate` are the +// framework's: `deactivate` is the one cleanup path, and `activate` puts a +// single synchronous failsafe on `context.subscriptions`. Nothing else is +// pushed there, by you or by anything you declared. +export const app = defineExtension({ name: 'Sample', modules: [projectsModule] }); +export const activate = app.activate; +export const deactivate = app.deactivate; diff --git a/docs/samples/migration-before.ts b/docs/samples/migration-before.ts new file mode 100644 index 0000000..30cc29d --- /dev/null +++ b/docs/samples/migration-before.ts @@ -0,0 +1,52 @@ +import * as vscode from 'vscode'; + +// The shape most extensions start from, and the one 2.x helpers were called +// from: an `activate` that registers everything, pushes each disposable onto +// `context.subscriptions`, and reads configuration wherever it happens to be +// needed. Nothing here is wrong. It is the inventory the steps work through. + +let index: Map | undefined; +let timer: ReturnType | undefined; + +export async function activate(context: vscode.ExtensionContext): Promise { + // Async initialisation, awaited before anything is registered. + index = await buildIndex(); + + // A command that reads a setting at call time. + context.subscriptions.push( + vscode.commands.registerCommand('sample.countProjects', () => { + const limit = vscode.workspace.getConfiguration('sample').get('limit', 10); + return Math.min(index?.size ?? 0, limit); + }) + ); + + // A watcher that keeps the index fresh, three callbacks at a time. + const watcher = vscode.workspace.createFileSystemWatcher('**/*.project.json'); + watcher.onDidCreate((uri) => index?.set(uri.fsPath, Date.now())); + watcher.onDidChange((uri) => index?.set(uri.fsPath, Date.now())); + watcher.onDidDelete((uri) => index?.delete(uri.fsPath)); + context.subscriptions.push(watcher); + + // A periodic job on a timer, disposed by hand. + timer = setInterval(() => { + void buildIndex().then((fresh) => (index = fresh)); + }, 30_000); + context.subscriptions.push({ dispose: () => clearInterval(timer) }); + + // A provider this package has no model for. + context.subscriptions.push( + vscode.languages.registerHoverProvider( + { scheme: 'file' }, + { provideHover: () => new vscode.Hover(`${String(index?.size ?? 0)} projects`) } + ) + ); +} + +export function deactivate(): void { + index = undefined; +} + +async function buildIndex(): Promise> { + const files = await vscode.workspace.findFiles('**/*.project.json', '**/node_modules/**'); + return new Map(files.map((file) => [file.fsPath, Date.now()])); +} diff --git a/tests/readme-samples.test.ts b/tests/readme-samples.test.ts index 6a04e54..8f3b601 100644 --- a/tests/readme-samples.test.ts +++ b/tests/readme-samples.test.ts @@ -22,7 +22,7 @@ import { describe, expect, it } from 'vitest'; * compiled should not use this marker, or readers will reasonably assume the * exact block passed `tsc`. */ -const PAGES = ['README.md', join('docs', 'guide.md')]; +const PAGES = ['README.md', join('docs', 'guide.md'), join('docs', 'migration-from-2x.md')]; const SAMPLES = join('docs', 'samples'); // `\n+` because Prettier puts a blank line between an HTML comment and the // fence after it. Requiring exactly one newline made `npm run format` break