All notable changes to this project are documented in this file.
The format is based on Keep a Changelog. From 1.0.0 onward this project follows Semantic Versioning. Pre-1.0 releases followed it in spirit; their breaking changes are marked Breaking.
A patch about documentation only. The published code is byte-for-byte what
4.1.0 exposed: no source file changed, the API is the same, and the engines
floors are untouched. What changed is that four links in the documents no longer
lead nowhere, and that the documents are now rendered as one set — the guide, the
migration guide, the changelog, the contributing guide and the security policy
alongside the API reference, with the README as the front page — so the links
between them are checked whenever the reference is built.
A minor about seeing what the framework already knows. Everything here is
additive: the API 4.0.1 exposed is unchanged, the VS Code floor stays at
^1.134.0, and the one behaviour change — defineExtension refusing a second
activation after deactivate — is visible only to a test that activated one
result twice. The theme is introspection and tooling: a compiled plan as JSON,
a preflight failure as data, a shutdown timeout that names what held it, a
command-line tool that reads a built extension's plan and checks its manifest,
an API reference generated from the JSDoc, and a step-by-step migration guide
from 2.x. Each piece was run against a real extension before it landed, and two
of the entries below exist because that turned up something the framework could
not yet say.
describePlan(plan) turns a compiled plan into JSON. The framework
already knows exactly what an extension registers — that is what compiling
declarations before running them is for — but an ApplicationPlan holds
factories, handlers and token objects, so the answer was locked inside it.
The description carries module ids, service tokens and the edges between
them, command ids and titles, settings keys and defaults, watcher globs and
view ids, and nothing callable.
It is deterministic and in declaration order, so the output is worth
committing: a diff means a declaration changed. That is the review question
git diff on a large module rarely answers directly, and it is the same
document a manifest cross-check or a dependency diagram wants.
A shutdown that runs out of budget now says what was holding it. The
application.shutdownTimeout diagnostic carried a phase name and nothing
else, which left the only question that matters unanswered: which hosted
service, which operation, which scope. Its details now carry the phase, the
budget, how long it waited, the hosted service inside its own stop, the
services still up, the operations that never settled and the resource scope
tree — ids, names and counts, never an argument or a payload.
createTestHost().inspect() says what a failed leak assertion could not.
leaks() reports three counts; when one is not zero the next question is
which module or operation still holds something, and there was no way to ask.
inspect() answers it: the scope trees, the hosted services still up, the
operations that never settled.
leaks() itself is deliberately unchanged. Adding the fields there was the
obvious move and it broke the first extension it was tried on: the guide
tells you to assert on the whole object, toEqual sees a new field, and the
test fails for a reason that has nothing to do with the extension. A separate
method costs one call and breaks nobody.
RegistrationScope and ResourceScope gained inspect(), returning a
ScopeInspection — name, entry count, attached children. This is what both
of the above are built on, and it is safe to call at any point, including
during disposal.
onDiagnostic is documented. It has been part of defineExtension since
3.0.0 and appeared in no guide; the guide now has a Diagnostics section
listing the events and what they are useful for.
A preflight failure now reports what it found as data. PreflightError
carried its findings as a list of sentences; problems carries them as
{ code, message, subject, moduleId, path } — COMMAND_HANDLER_CONFLICT,
SERVICE_CAPTIVE_DEPENDENCY, TRUST_REQUIRED and the rest — so a test, a
CI step or an editor integration can branch on what went wrong instead of
matching prose. The service-graph validator and runtime preflight had these
codes all along and were dropping them at the throw. The sentence list stays
as issues, unchanged, and the error's message is the same text as before.
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.
diffManifest returns what assertManifestMatches used to only throw.
The assertion compared package.json with the declarations in src and
reported every disagreement as a sentence in one error. The comparison is now
its own function, returning each disagreement as data — which contribution
point, which side is missing it or whether both have it and disagree, the id
it concerns, and the JSON that would settle it when the fix is mechanical.
The assertion is unchanged and built on top of it.
vscode-ext-kit manifest: the manifest check, from the command line.
It compares the plan an extension compiles with its package.json — the
comparison assertManifestMatches makes in a test — and reports every
disagreement, as text or JSON, with exit code 1 when there is one. --apply
adds what the manifest is missing and the source can supply: commands and
settings, complete in everything mechanical and with placeholder titles and
descriptions a person has to replace. What a person has to decide is
reported and left alone: a view needs a container the declaration does not
name, a drifted default has two candidates, and an entry only the manifest
has may be there on purpose.
defineSettings({ contributed: false }), for a section the extension only
reads. editor.tabSize belongs to VS Code. An extension that declares it,
to read it through the same typed accessor as its own settings, was
indistinguishable from one that owns it — so the manifest check asked
package.json for it. The declaration now says which it is: describePlan
carries the answer as contributed, and diffManifest,
assertManifestMatches and the command line leave a section that is not
contributed out of the comparison.
An API reference, generated from the JSDoc. npm run docs:api renders
every public entry point with TypeDoc, and CI runs it with warnings as
errors. Setting that up found what the warnings exist to find: types that
public signatures name — the port a tree provider returns, the surface a fake
hands back, the options setting.boolean takes, the reason on a cancelled
operation — which the package never exported, so a consumer could receive one
and not be able to write its type. They are exported now, from the entry
whose signatures name them. {@link} tags that pointed at internal symbols
became plain code. ApplicationPlan's members are marked internal: the plan
is a handle for createTestHost and describePlan, and describePlan is
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.
defineExtension is single-use, like the extension host it serves. A
second activate while the first is starting or running now joins that
start and resolves to the same value; after deactivate, or after a start
that failed, it rejects with a FrameworkError of kind activation (code
EXTENSION_NOT_RESTARTABLE) instead of quietly building a second application
and a second log channel. VS Code activates an extension once per session,
so nothing changes in the editor; the change shows up only in a test that
activates one defineExtension result twice — build one per test, or run
the plan through createTestHost, which is what it is for.A patch to the tree-view adapter, plus four corrections to what the project
says about itself. The public API is unchanged — the emitted .d.ts files
differ from 4.0.0 in comments only. The one behaviour change is that a
custom TreeDataSource's dispose() now runs once instead of twice; the
shipped providers never noticed the difference.
The tree-view adapter now releases the change-event bridge it builds, and
no longer disposes the provider. To serve onDidChangeTreeData in the
shape VS Code reads, the adapter subscribes to the source and fires a
vscode.EventEmitter; in 4.0.0 it kept neither the subscription nor the
emitter, so disposing the view released the native view and left both
behind. It also called the source's dispose() — while the module scope
already owned the provider, as the port and TreeViewCollection both said —
so every module-declared provider was disposed twice. The shipped
BaseTreeDataProvider clears its listeners and is idempotent, which is why
nothing noticed; a custom source need not be either. The registration now
releases exactly what the adapter created (view, checkbox listener,
subscription, emitter), once, and leaves the source to its owner. The adapter
suite pins each of those, the application suite pins "one provider disposal,
after the view", and the desktop fixture measures the subscription against a
real host: it reported taken 1, released 0 on the 4.0.0 adapter.
createSecretAccessor's JSDoc said writes were not validated. They are,
and the suite has pinned it (failed validation on write); the comment and
the code have disagreed since both arrived in 3.0.0. The interface comments
now say what the code does: validation in both directions, and nothing the
schema produced in any report.
Two comments claimed ResourceScope.own does not await an asynchronous
dispose. It does — that is the whole reason it is separate from
RegistrationScope, which is the one that rejects a thenable.
descriptors.ts and thenable.ts now agree with resource-scope.ts.
The 4.0.0 entry below claims verify:package asserts zero runtime
dependencies. It did not. The package had none — dependencies is simply
absent — but nothing checked, so the sentence was true by luck rather than
by test. The script now asserts it twice: on the manifest about to be packed
and on the copy the throwaway consumer installs.
CONTRIBUTING listed three foundation -> capabilities aggregation points;
the import-graph test allows four, the fourth being a type-only import from
operations/context.ts. The document now matches the test.
A floor raise, essentially. The public API is what 3.0.0 exposed — no
additions, no removals, no signature changes — and the one implementation change
below is behaviour-preserving. The major is here because engines.vscode moved,
which cascades to every extension built on this package, and that is what a
major is for. The previous floor raise shipped the same way, as 2.0.0.
If your extension already declares ^1.134.0 or later, upgrading is a version
number and nothing more. If it does not, raise it in the same commit — vsce
will refuse to package otherwise, which is the whole subject of this release.
^3.x pins keep resolving to 3.0.0, which stays supported.
Breaking: engines.vscode raised from ^1.125.0 to ^1.134.0, in step
with @types/vscode moving to ~1.134.0. Extensions built on this package
inherit the floor and must declare at least ^1.134.0 themselves.
The two versions move together on purpose. vsce refuses to package an
extension whose @types/vscode is newer than its engines.vscode
(validateVSCodeTypesCompatibility, compared on major and minor only), and
the reason behind that check applies here even though this package is a
library and never meets vsce: types above the floor let code compile
against an API the declared minimum does not have. Raising only the types is
what a grouped dependency update proposes by default, and it is why this
entry exists rather than a lockfile-only bump.
DefinitelyTyped had been lagging the stable channel badly — 1.125.0 was the
newest type package for months against a stable line already past 1.131. It
has since caught up to 1.134.0, one release behind the current 1.135.
Dev dependencies, measured across the whole 3.0.0→4.0.0 span rather than
per pull request: vitest and @vitest/coverage-v8 4.1.10 → 4.1.11, eslint
10.8.0 → 10.9.0, typescript-eslint 8.66.0 → 8.67.0, @types/node 26.1.2 →
26.2.0, knip 6.31.0 → 6.32.2. All within the declared ranges, so only the
lockfile moves. TypeScript stays on 6.0 — typescript-eslint declares
typescript >=4.8.4 <6.1.0, so 7.x would break type-aware linting outright.
A development-scope advisory (nanoid, high, transitive through vitest) was
cleared. It never shipped: the published package has zero runtime
dependencies, which verify:package asserts on every run.
The Extension Host and web fixtures declare ^1.134.0 to match, and the
VSCODE_VERSION example in fixtures/README.md moves off 1.131.0, which
would no longer activate them.
A quadratic regex is gone from the fake file watcher. /\/+$/, stripping
trailing separators, made the engine retry the greedy \/+ from every position
before $ rejected it — quadratic in the number of separators, and CodeQL
flags it as js/polynomial-redos. It was not a vulnerability here: the
input is a RelativePattern the test author constructed, and nothing
untrusted reaches a fake. It is fixed because src/testing/ is published code
and the defect is real regardless of who can reach it. Counting backwards is
linear and reads better than the regex did; behaviour is identical.
SECURITY.md listed the wrong supported versions. It still described
3.0.0-alpha.x as "not published to npm yet" and 2.1.x as holding latest
— both untrue since 3.0.0 shipped on 2026-08-08 and took the tag. The table
now names 4.0.x, 3.0.x and 2.1.x.
The 3.x line leaves prerelease. Nothing in the API changed from
3.0.0-alpha.3 — this release moves the latest dist-tag, which is the part
that was still pending.
npm install @kkdev92/vscode-ext-kit now installs the framework. 2.x was a
utility library with a different shape; it continues on v2-maintenance, and
anything pinned to ^2.x resolves exactly as it did. Only a fresh, unpinned
install changes.
Read the 3.0.0-alpha.1 entry below 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:
"integer" while the code had always accepted null and documented
it as "no limit".activate resolves
to, so an extension that publishes an API stops keeping a mutable module
variable and hoping a hosted service filled it.Every one of those came from migrating a real extension, not from reviewing this repository. Three now run on it, each verified in a real Extension Host.
handleDrop
parsed the data transfer and asserted string[]. handleDrag writes that
payload, but the mime type is the extension's own declared string and any
producer that writes it lands in the handler — so a number, null or an array
of objects could reach onDrop, which promises readonly string[]. Found by
a whole-codebase security review; filed as robustness rather than a
vulnerability, since the impact lives in consumer code.Three gaps a third migration found. What is not here is as deliberate: two other candidates were rejected on the line between what the framework owes an extension and what an extension owes itself.
defineExtension({ exports }). Some extensions publish an API — the
built-in Markdown preview reads extendMarkdownIt off one, and anything else
reads extensions.getExtension(id).exports. VS Code takes that value from
whatever activate resolves to, and it is normally built from services, which
do not exist until the application has started. Without a declaration the only
way to produce one was a mutable module variable that a hosted service filled
and activate read back. exports is that declaration: the framework builds
the value after every hosted service has started, from the same instances
everything else got, and resolves activate to it. Not a way to reach the
container — the framework resolves it, and a service stays reachable only
where it was declared.vscode mock accepts setContext. It is a VS Code built-in that no
extension registers, so rejecting it as an unknown command was the mock lying
about the platform — and mirroring a setting into a when clause is common
enough that every such extension would have had to work around it. Narrow on
purpose: other built-ins still reject, because they have effects a mock cannot
stand in for and resolving undefined would let a test claim it did something
it never did.vscode while being built. Every
capability adapter is constructed at activation whether the extension declares
that capability or not, and this was the only one that read a vscode value
doing so (QuickInputButtons.Back) — which made it something every test double
had to supply, including for extensions that never open a quick pick. It is a
getter now.LogOutputChannel is the platform's answer and the level belongs to the user,
per channel, persisted, in the Output panel. Both of those extensions kept a
logLevel setting from before that was true. Making it cheap to keep a setting
that duplicates a platform control is not something the framework should do.isDisposable matching functions. A function carrying dispose is not
disposed by the container, silently. Widening the check would blur a contract
that is currently one shape and clear, and the case that raised it — a
debounced callback — is better owned by a hosted service, which is where it
ended up. No consumer has actually shipped the shape.Both of these came from migrating a second extension onto this. Neither showed up in this repository's own tests, because neither is something the framework uses itself — they are only reachable from the consumer side.
They are worth the release on their own, but the way the first one landed is the
better argument for it: once assertManifestMatches could compare a nullable
setting, the first run against that extension found two settings whose manifest
declared "integer" while the code had always accepted null and documented it
as "no limit". A feature the extension shipped, that its settings editor would
not let anyone use.
setting.nullable. A setting that means "unset" is an ordinary VS Code
pattern and none of the builders could express it. The manifest has to declare
["integer", "null"] before the settings editor will accept a null default —
VS Code reports "type": "string" with "default": null as an error — and a
spec whose validate rejects null makes every lenient read of a cleared
setting fall back to the default. The two halves have to move together, which
is why this is a builder rather than a note in the docs. It wraps any spec:
the default carries over unless you pass one, and an enumerated spec gains
null at the front, where enumDescriptions expects it.workspace.onDidGrantWorkspaceTrust on the vscode mock. With
isTrusted alone, an extension that declines to read something in an
untrusted window has no way to notice when trust is granted. The host is
restarted only if some extension's enablement changes, and an extension that
declared untrustedWorkspaces.supported was already enabled — so it is
re-activated only if some other extension happens to flip, which it cannot
count on. These two are the whole stable trust API; the rest of that surface
is proposed.SettingSpec.type accepts a list, as JSON Schema does, and the new
SettingValueType includes 'null'. Only assertManifestMatches read this
field, and it now compares types as a set — ["string","null"] and
["null","string"] are the same schema, and a reorder is not worth failing a
build over. enum stays order-sensitive, because enumDescriptions pairs
with it positionally.assertManifestMatches could not agree with a nullable setting. It
compared type with !==, so an array never matched whatever the source
said, and the first extension to declare one had to normalise its manifest
before the check would run at all. The pasteable JSON it prints carries the
union too, which matters because that JSON is what it tells the reader to
copy.A different kind of package. 2.x is a utility library you call from your own
activate(). 3.x is an application framework that owns activation and
deactivation, and that you hand modules to. The capability APIs came across; the
application shape did not.
Published on the next dist-tag. latest stays on 2.x, which continues on
v2-maintenance, so nothing installs this by accident:
npm install @kkdev92/vscode-ext-kit@next.
There is no 3.0.0-alpha.0 on npm. Its tag exists and its release workflow
failed before publishing anything — release tags are immutable here, so the fix
took the next number rather than the same one.
Every v2 capability has an equivalent here, and an existing extension has been migrated onto it end to end — commands, views, webviews, settings, storage, secrets and all — and verified in a real Extension Host. That migration is where most of what follows comes from: it found abilities the framework could not express, ceremony it was making the consumer write, and defects none of the framework's own tests could see.
defineExtension / defineModule / compileApplication. A module declares
commands, services, hosted services, settings, storage, secrets, file watchers,
status bar and language status items, tree views, and raw registrations, as
data. compileApplication validates the whole declaration -- duplicate ids,
missing services, dependency cycles, captive dependencies -- and produces a
deeply immutable plan. Preflight runs at import time, before any VS Code API
call.ApplicationHost. A state machine with one cleanup path: stop() runs
exactly once, unwinds framework-owned resources in reverse order, and holds a
shutdown budget (3s by default) inside VS Code's 5s race.AbortSignal, a progress session, and a
resource scope disposed when the work settles.RegistrationScope (synchronous) and ResourceScope
(asynchronous, LIFO, error-aggregating), because VS Code never awaits an async
dispose().src/vscode/.@kkdev92/vscode-ext-kit/testing grew a Test Host. createTestHost runs a
production plan against fakes, with singleton overrides and leak assertions.
Every fake and its real adapter share one contract suite.defineSettings + setting.* fix keys, types, defaults and
contribution scope in one declaration; reads take a scope; watch fires only
when a key's effective value actually changed.defineStorage (versioned, validated,
migrated, optionally syncable) and defineSecret, both injectable under their
own token.defineModule takes a
{ uses } options object, and every handler in the module receives that set
merged under its own inject. A name in both is a definition-time error
rather than a shadowing rule. Services are excluded, because a service's
dependencies are the graph preflight validates. Injected<typeof uses>
names the resulting shape, so the bundle a feature receives is derived rather
than hand-written.notify, ask, l10n, editors, commands and status. Not a second way
in — context.notify resolves the same token an inject would, out of the
same container, exactly as context.logger already did. They are lazy and
non-enumerable, so an application that never notifies builds no notifier and
a spread of the context resolves nothing.Commands, FileWatchers and Operations tokens, for the runtime half
of three abilities that already had a declaration: invoking a command as
opposed to registering one, a glob the user just typed as opposed to a
declared one, and work that did not start in a handler.assertManifestMatches, published so a consumer can keep package.json
in step with what src declares. It names every disagreement at once and
prints the JSON to paste; it found four real drifts the first time it ran
against a migrated extension.Log, for a service that has no operation to take context.logger from.Notifications, QuickInput, Editors, Localization and
Webviews are services; status bar items, language status items, tree views,
webview views, watchers, settings, storage and secrets are declarations. The
root export is 74 values, down from 164.module.fileWatchers.add is for a glob known when the code is written;
FileWatchers.watch is for one the user just typed. Same split as
defineStatusBarItem against StatusBar.flash, and context.progress
against Operations.run. Each pair is one ability with a definition-time and
a runtime entry.ActiveEditor — the same object
Editors.active returns — so a feature works under either declaration.debounce, retry, the
formatters) are on the root barrel as well as their subpaths, because the
subpaths exist for webview bundles that cannot import the root at all;
toPickItem and toPickButton turn out to be vscode-free, since VS Code
recognises a theme icon by its id; and status belongs in the standard
context set, because StatusBar.flash is exactly what a handler body says to
the user.confirm's remembering button says what it does. The default label is
'Yes, Always', not "Don't Ask Again" — pressing it answers yes and
records consent to every later call, and a dismissal-sounding label on a
confirmation dialog is the last place for that to blur. rememberText
overrides it, like yesText and noText.script-src drops cspSource once a nonce is supplied.
Source expressions and nonces are alternatives in CSP Level 3 — a script
matching either one runs — so keeping both let anything under
localResourceRoots execute with no nonce, which is the whole guarantee the
nonce exists for. VS Code's own webviews emit script-src 'nonce-x' alone.Editors, replacing 20 free functions that each took a
vscode.TextEditor. editors.active answers the "is there an editor?"
question once, at the top of a handler.BaseTreeDataProvider and
SimpleTreeDataProvider are vscode-free, a row is plain data, and an icon is
a theme icon id (icon: 'folder'). The adapter builds the platform's
TreeItem.module.webviews.addView for a view,
module.webviews.restorePanel for a panel that survives a window reload, and
the Webviews token for opening one from a handler.AbortSignal everywhere. Anything that took a
CancellationToken — retry attempts, batch resolution, progress steps — takes
a signal, and context.signal already combines the operation's cancellation
with the user's../ui is retired. Its contents were the vscode-bound picker functions,
which the QuickInput service replaces.require() of any subpath fails, and CI proves it
against the packed tarball rather than asserting it in prose.ESNext.Disposable and an AbortSignal lib (DOM,
WebWorker or @types/node) in lib. The public types name both.vitest is declared as an optional peer dependency, for the
./testing/vitest subpath.The application shape v2 implied is gone: createExtensionKit, safeExecute
and the manual context.subscriptions bookkeeping they required.
defineExtension, operations and host.stop() replace them.
The standalone helpers went with it. Every ability survives; the way in changed:
| Removed | Now |
|---|---|
showInfo showWarn showError confirm |
Notifications token |
withProgress withSteps |
context.progress.run / .steps |
pickOne pickMany inputText wizard |
QuickInput token |
createStatusBarItem |
defineStatusBarItem + token |
showStatusMessage createStatusMessage |
StatusBar token: flash |
createSecretStore |
Secrets token |
applyEditsGrouped |
editors.active.editStages |
createLanguageStatusItem |
defineLanguageStatusItem + token |
createFileWatcher watchFile |
module.fileWatchers.add |
createTreeView createDragAndDropController |
module.treeViews.add, with dragAndDrop declared |
createWebviewPanel registerWebviewView registerWebviewPanelSerializer loadHtmlTemplate |
Webviews token / module.webviews.* |
| the 20 editor functions | Editors token |
l10n plural formatDate formatNumber formatRelativeTime getLanguage isLanguage |
Localization token |
createTypedStorage createGlobalStorage createWorkspaceStorage createSecret* |
defineStorage / defineSecret + token |
createNotifier createProgressRunner createManagedStatusBarItem createManagedLanguageStatusItem createManagedFileWatcher createWizard createSettingsAccessor |
built by the host |
createApplication compileApplication createApplicationHost runtimePreflight, the scope constructors, the logger factories, the host state machine, and the nine createVSCode*Capability factories |
defineExtension |
Three more arrived after a trial migration of a real extension found that v3
could not express them: Secrets, for secrets the user names (defineSecret
declares a key the extension knows about at definition time, which is a
different ability); StatusBar.flash, for a message with no item of its own;
and editors.active.editStages, for several edits in order that land as one
undo step.
Two abilities were rebuilt on the v3 side before their v2 form was removed,
rather than dropped: weighted multi-step progress is context.progress.steps,
which still reports cancellation as a value rather than a thrown error, and the
transient status message is status.flash, which is a state the existing item
passes through instead of a second item created and disposed on a timer.
listStorageKeys has no v3 equivalent and is simply gone: a declared storage
knows its own key.
Findings from a full source and test review, each with a regression test:
deactivation ownership and the shutdown deadline, deep plan immutability, webview
RPC envelope validation before any state change, Standard Schema v1 conformance
with a synchronous-only gate, secret redaction in schema failures, atomic
legacy-key storage migration, file watcher construction rollback, per-attempt
retry signals, single-key setting watches, and the fidelity of the fakes and the
mock kit (settings affects semantics, listener removal, watcher pattern
routing).
A second, independent read of the whole tree turned up these, each re-verified against the source before it was changed:
ResourceScope.own discarded a dispose() that returned a promise.
Registration.dispose is typed unknown, and TypeScript accepts a
promise-returning method wherever void is expected, so such a resource
arrived without a complaint and its teardown was never awaited. The cleanup
now hands the promise back to be awaited in place. RegistrationScope refuses
one outright — closing ingress synchronously is its whole purpose.setKeysForSync ran before runtime preflight. It writes to persistent
storage and survives a failed activation, so an application that then refused
to start left the platform holding a claim nothing backed. Preflight now runs
first, before anything in activation touches VS Code.dispose() threw
took the module's resources with it — and replaced the activation error with
the cleanup error. Each phase is attempted independently, and the cause
propagates while the consequence is reported.request() on the webview RPC could throw synchronously — the
postMessage call is evaluated as an argument, so a disposed webview escaped
the method as an exception where the signature promises a promise, stranding
the entry it had already placed in pending.maxWait timer by an overwritten handle
(leaving the old one armed to fire a trailing invocation), and an RPC handler
registration by its method name (so disposing a registration you had just
replaced silently unregistered the replacement — fixed on both endpoints).BigInt and throwing getters all make JSON.stringify
throw; the entry now survives with less in it.UIKind and extensions.
import * as vscode reads named exports, and the environment adapter reads
vscode.UIKind.Web during preflight — so activating through the documented
path threw on every application while every unit test passed. A test now
derives the required set from the adapter sources.languages provider registrations. createTestHost
binds a plan's managed raw registrations like anything else, so an application
registering a hover provider could not start at all.isThenable missed a callable thenable, and the injected dependency
record was built on Object.prototype, where a dependency named __proto__
would have changed the prototype instead of becoming a property.The rest of the first downstream adopter's report: the places where the library
worked as documented but made the caller write the boilerplate, or reach past it
for the raw API. Nothing here is breaking, though withSteps now reports a
cancellation it previously threw.
@kkdev92/vscode-ext-kit/testing/vitest-config exports
vscodeExtKitVitestConfig, a mergeable Vitest config that is the entire
setup:
export default mergeConfig(vscodeExtKitVitestConfig, defineConfig({/* yours */}));
It pairs resolve.alias (pointing vscode at the new
@kkdev92/vscode-ext-kit/testing/vitest, which re-exports the mock as the
named exports import * as vscode reads) with the server.deps.inline entry
that keeps this kit from being externalized. Both halves are required and each
fails confusingly alone, which is why they now ship together. Unlike
vi.mock('vscode', ...), an alias also reaches a prebuilt dist/ bundle, so
activate() can be tested for real. @kkdev92/vscode-ext-kit/testing itself
stays runner-agnostic; only the two new subpaths import vitest (declared as
an optional peer).
@kkdev92/vscode-ext-kit/webview-client ships the webview-side end of
createWebviewRpc — previously a 51-line reference implementation in a JSDoc
comment that every adopter copied into their webview bundle by hand, untyped
and frozen at whatever version they copied. createWebviewRpcClient<S>() is
written against the same WebviewRpcSchema as the host (both sides now build
on a shared, vscode-free protocol.ts), so the contract cannot drift, and it
mirrors the host's semantics: request with timeoutMs/signal whose
cancellation propagates across the wire, onRequest handlers with an
aborting ctx.signal, emit/onEvent, and dispose. Pass
{ vscodeApi: acquireVsCodeApi() } when your webview already acquired the
API — VS Code allows exactly one call — or omit it and the client acquires it.
Wire compatibility is pinned by loopback tests running the published client
against the published host in both directions.
@kkdev92/vscode-ext-kit/format exposes the vscode-free Intl core
(pluralFor, formatNumberFor, formatDateFor, formatRelativeTimeFor,
getOrCreateCached) as a subpath alongside ./timing and ./retry. These
existed but were only reachable through the root barrel, which drags in
vscode — defeating the point for a webview bundle.
./package.json is exported, so require('@kkdev92/vscode-ext-kit/package.json')
works for build scripts that bake the resolved version into a bundle. Node's
ESM resolver rejects any subpath an exports map doesn't list, so this needed
saying explicitly.
PickOptions gained buttons, onTriggerButton, and onTriggerItemButton.
toPickButton shipped in 2.0.0 with nowhere to use it: pickOne/pickMany
resolve with the selection and exposed no trigger event, so a press could not
be handled without abandoning them for a raw createQuickPick. Both handlers
receive the live QuickPick, so a row action can rewrite items, set busy,
or hide(). Passing any of the three routes the picker through
createQuickPick, as prompt already did. PickOptions is now generic in the
item type (PickOptions<T>, defaulting to vscode.QuickPickItem) so
onTriggerItemButton's item is typed; existing bare PickOptions
annotations still compile.
SimpleTreeDataProvider.addItem(item, { parentId?, index? }) inserts at a
position instead of appending. Introducing a group that has to stay on top — a
"Favorites" node — previously meant setItems, which rebuilds the tree and
collapses all of it. index is clamped, so 0 is always first and anything
past the end appends. The addItem(item, parentId) form is unchanged.
withPagination(items, pageSize, { label?, command?, iconPath? }) can put a
command on the "Load more…" row, making it clickable. Without one the row
stays inert and getChildrenOf matches LOAD_MORE_ID itself, as before —
every caller was writing the same .map() to graft a command on. A bare string
third argument still means { label }.
createVSCodeMock covers the APIs an extension reaches for and the kit
itself never calls: version (the top-level one — TextDocument.version is
a document revision and was the only version present), ColorThemeKind,
TextEditorRevealType, window.activeColorTheme,
window.onDidChangeActiveColorTheme, window.showOpenDialog, and
window.showSaveDialog. window._setColorTheme(kind) is the test hook that
switches theme and notifies listeners. Theme detection plus a change
listener is close to universal in extensions that render anything, and none of
it was mockable.
MockFn describes more of vi.fn/jest.fn: mock.results (the only way
to assert on the object a factory mock returned, e.g. the channel from
window.createOutputChannel), plus mockReturnValueOnce,
mockResolvedValueOnce, mockRejectedValue, mockRejectedValueOnce, and
mockImplementationOnce. Both runners already had all of these; the interface
simply didn't mention them, so a test using one didn't typecheck. results
includes the 'incomplete' variant both runners emit for an in-flight call.
resolvePositionsBatch/resolveOffsetsBatch recognize the same line
breaks as VS Code. The one-pass line-start table split on \n only, but
VS Code's text buffer also treats a lone \r as a line terminator — so on a
document containing a bare carriage return, the batch helpers disagreed with
TextDocument.positionAt/offsetAt about every position after it, producing
ranges that edit the wrong text. LF and CRLF documents were always correct
and are unchanged.*.log compiled to an unanchored
regex that also matched inside x.log.txt and foo.logs, silently swallowing
their events. Glob patterns now match a whole path segment at the end of the
path, as glob semantics say they should; **/-style patterns behave as
before.retry's inter-attempt
wait and both webview RPC endpoints (request on the host and the client)
registered an abort listener on the caller's AbortSignal and never
removed it on the success path — an AbortController reused across many
operations accumulated one dead listener per call for as long as it stayed
un-aborted. toAbortSignal had the same shape (one token listener per call)
and now memoizes one bridge signal per token via a WeakMap, so repeated
calls return the same AbortSignal instead of stacking listeners on a token
that may never fire.flushNow iterated the live listener array, so a listener
disposing itself (a one-shot subscription) shifted the array and skipped the
next listener for that batch. Delivery now goes to a snapshot — the same
contract VS Code's own EventEmitter has.vscode semantics
(each verified against the microsoft/vscode implementation): Range
normalizes a reversed start/end pair by swapping (so a reversed Selection
exposes its active position as start, like the host does);
EventEmitter.fire delivers to a listener snapshot; QuickPick/InputBox
subscription dispose() actually unhooks the listener (it was a recorded
no-op); Uri.joinPath resolves ./.. segments — which watchFile()'s
own parent-directory pattern relies on — and Uri.parse extracts the scheme
instead of hardcoding file. Each divergence let a test pass against
behavior the extension host would never produce.undefined. A value
stored before this kit was adopted isn't wrapped in the kit's storage
envelope; get() read .value off it anyway, returning undefined on a
non-nullable T while has() said true — adopting typed storage over
existing extension state silently read back nothing. A non-envelope value now
reads as schema version 0: migrations[0] can convert it, and without one
it flows into validation unchanged. Either way it's re-persisted in envelope
form after the first read.engines.node raised from >=22.0.0 to >=22.12.0 — the honest floor:
consuming this native-ESM package from a CommonJS extension without a bundler
relies on require(esm), which Node stabilized in 22.12. ESM/bundled
consumers were fine on 22.0, but the field describes what every supported
consumption mode needs. README now documents the bundler-free CJS path.WebviewRpcSchema and WebviewRpcRequestOptions moved to a shared,
vscode-free protocol.ts so the new webview client is typed against the
exact same contract as the host. Both are re-exported from their previous
home — every existing import keeps working.withSteps reports mid-step cancellation as cancelled instead of
throwing. Only the gap between steps was checked, so a step handed
toAbortSignal(token) — the usage the JSDoc recommends — rejected with an
AbortError that passed straight through, leaving result.cancelled false and
forcing callers to write both a cancelled branch and a try/catch with
isCancellation(). Cancellation now always comes back as
{ completed: false, cancelled: true } with the results gathered so far,
matching run/tryRun and wizard. A vscode.CancellationError thrown by a
step is treated the same way, and any other error still propagates. Code that
only caught the rejection will now see a completed: false result rather than
an exception.Fixes found by the first downstream adopter to migrate a full extension onto 2.0.0. Two real bugs, and documentation that sent readers the wrong way.
withTimeout no longer strands a caller's promise. Passing an
already-aborted options.signal made it throw synchronously before attaching
any handler to a promise-form operation — and that promise was created back
at the call site, during argument evaluation. If it later rejected (a worker
exiting, say), nothing was there to catch it and the extension host logged an
unhandled rejection. Its rejection is now claimed before the throw.
Function-form operations were never affected and still aren't started when the
signal is already aborted.SimpleTreeDataProvider stops collapsing nodes you asked to be expanded.
A collapsibleState of Expanded now survives setChildren and addItem,
and is honored when passed to the constructor, setItems, or either of those
mutators — previously all three paths overwrote it with Collapsed, so a group
built expanded either never opened or folded shut on every partial update. A
parent that had no children is still promoted to Collapsed when children
arrive, and still drops to None when its last one goes away.WebviewRpcSchema's webviewRequests/hostRequests docs described the
wrong direction. Both fields are named after the side that answers the
request (webviewRequests are sent with rpc.request, hostRequests bound
with rpc.onRequest) — the types always worked this way. The convention, and
why it differs from the send-direction naming used for events, is now spelled
out on the interface and in the README.PickItemDisplay.buttons and toPickButton now say plainly that item buttons
cannot be handled through pickOne/pickMany, which resolve with the
selection and expose no trigger event. Use vscode.window.createQuickPick
directly. (Making them work through pickOne is tracked for a later release.)^1.125.0 VS Code requirement up front, notes that the floor
propagates to dependents, and explains the @types/vscode lag behind the
weekly VS Code release line.createSecretStore's keys() was feature-detected. It
isn't, and doesn't need to be: SecretStorage.keys has been stable since
1.105, well under this library's floor.Follows the current VS Code release line. engines.vscode and the
@types/vscode the library compiles against are now the same version, which is
what keeps the declared floor honest — and raising the floor is what makes the
VS Code APIs added since 1.96 usable without feature detection.
Major rather than minor: raising engines.vscode cascades to every extension
that depends on this library.
toPickButton(icon, opts?) builds a vscode.QuickInputButton, accepting a
codicon name in place of a hand-built ThemeIcon exactly as toPickItem does
for item icons. opts.location places the button in the title bar, inline, or
inside the input box (QuickInputButtonLocation, stable since 1.109);
opts.toggled makes it an on/off toggle (QuickInputButton.toggle, also
1.109). VS Code flips toggle.checked in place on the button you passed it
before firing the trigger event, so read the state back off the same object.
Note that location is ignored for buttons attached to an item via
PickItemDisplay.buttons — VS Code always renders those inline.prompt on pickOne/pickMany (new PickOptions, extending
vscode.QuickPickOptions) shows instructional text below the filter box and
above the items (QuickPick.prompt, stable since 1.108). showQuickPick has
no prompt, so passing one routes the picker through createQuickPick — the
same path async items already take. That path does not honor
onDidSelectItem. A synchronous list is assigned before show() and never
raises busy, so it does not flash empty.createVSCodeMock gained QuickInputButtonLocation and secrets.keys().engines.vscode raised from ^1.96.0 to ^1.125.0. Extensions
depending on this library must raise their own engines.vscode to match.
^1.125.0 is a minimum, so hosts on the current 1.131 line are covered.@types/vscode stays at ~1.125.0 — the newest published type package.
Note that VS Code moved from monthly to weekly releases in March 2026 and
DefinitelyTyped no longer publishes a package per release, so @types/vscode
trails the stable channel (1.125 was published 2026-06-17 against a 1.131
stable). The floor tracks the types, not the stable channel.pickOne/pickMany swallowed the error when an async item list rejected.
Disposing a visible quick pick makes VS Code fire onDidHide
(ExtHostQuickInput.dispose calls _fireDidHide), so the teardown on the
rejection path re-entered the hide handler and resolved the promise with
undefined before reject ran. Callers saw a plain cancellation instead of
the failure. The exit paths now claim the promise before tearing down, the
same way wizard.ts already did. createVSCodeMock's hide()/dispose()
were inert, which is why no test caught this; they now fire onDidHide on a
visible quick input like the real API, so the whole class of bug is testable.^1.96.0 floor had been inaccurate since 1.0.0. The UI/views
redesign added resourceUri to PickItemDisplay/toPickItem, but
QuickPickItem.resourceUri only exists from VS Code 1.108 — building the
library against @types/vscode@1.96.0 fails with TS2353. On hosts older than
1.108 the property was silently dropped, so resourceUri quietly did nothing
rather than failing loudly. Because engines.vscode now matches the types the
library compiles against, the compiler enforces the floor and this class of
drift cannot recur unnoticed.SecretStore.keys() no longer feature-detects SecretStorage.keys() at call
time. That API is stable from VS Code ~1.105, which the new floor guarantees,
so the runtime probe and its requires VS Code 1.105+ rejection are gone —
calls that previously rejected on old hosts now just work. SecretStore.keys()
never resolved to a wrong value, so no behavior change on supported hosts.QuickPickStepConfig.prompt no longer feature-detects
QuickPick.prompt. It was already accepted and silently dropped on hosts
below 1.108; the new floor guarantees it, so it is assigned directly.Fixes and additions from the first real 1.0 adoption, all reported from a downstream extension's migration. Two of them made the published package harder to use than the source tree suggested.
node_modules, so vi.mock('vscode', ...) never reached the
kit's own import * as vscode from 'vscode' and any test touching a kit
function failed with Cannot find package 'vscode'. The README's
vitest.config.ts example now includes the required
server.deps.inline: ['@kkdev92/vscode-ext-kit'], with an explanation of
why it is mandatory rather than a workaround.dist
carries 74 .js.map/.d.ts.map files whose sources resolve to ../src,
but src was not in files. Consumers inlining the package saw a
Sourcemap ... points to missing source files warning per module, and
Go-to-Definition stopped at the .d.ts. src is now published, so both
debugging and Go-to-Definition land on the real TypeScript.createVSCodeMock was missing workspace.applyEdit, which this
library itself calls from applyWorkspaceEdits/applyEditsGrouped — so
testing consumer code that used those helpers failed with
vscode.workspace.applyEdit is not a function.npm run build now cleans dist first. Stale 0.x output (19 flat
*.d.ts files whose sources no longer exist) survived rebuilds and
misrepresented the API when read locally. Published tarballs were never
affected (prepublishOnly already cleaned).s.nullable(inner) — accepts null in addition to the inner schema,
mirroring s.optional. VS Code settings commonly declare
"type": ["string", "null"] with a null default to mean "unset", which
s.optional (which admits undefined) does not cover:
field(s.nullable(s.enum('compact', 'wide')), null).Logger.error accepts unknown — a catch (error) binding can be
passed straight through (catch (error) { logger.error(error, { file }) })
instead of being normalized at every call site. Error instances keep
their stack; anything else is stringified safely.window.activeTextEditor / visibleTextEditors (both directly assignable
for test setup), window.onDidChangeActiveTextEditor /
onDidChangeTextEditorSelection / showTextDocument,
workspace.workspaceFolders / getWorkspaceFolder / asRelativePath /
openTextDocument / onDidChangeTextDocument / onDidSaveTextDocument,
and env.clipboard. Extending a namespace by hand is no longer the price
of testing an ordinary extension.npm run test:smoke, plus a CI job): packs the
tarball, installs it into a throwaway project configured exactly as the
README documents, then typechecks and runs tests against the published
artifact — asserting the vscode mock reaches the kit's internals, all four
entry points resolve, and no sourcemap warnings appear. Both packaging bugs
above were invisible to the existing suite because it only ever tests
src/.vscode.* member this library calls exists
on createVSCodeMock, so a missing mock (like applyEdit) fails here
rather than in a consumer's test run..gitattributes normalizing line endings to LF, so format:check stops
failing on Windows checkouts (core.autocrlf produced CRLF while Prettier
is configured for LF).A ground-up redesign for type safety, performance and testability.
Not backwards compatible with 0.x — see MIGRATION.md for a
complete 0.x → 1.0 mapping. Requirements are unchanged (VS Code ^1.96.0,
zero runtime dependencies).
createExtensionKit — one call in activate() wires a logger, a
disposable scope, and logger-bound run/tryRun/command registration.defineConfigSchema + field + built-in
Standard Schema-compatible s.* validators (or bring zod/valibot).
Validated & cached reads, per-key change events, watchSetting,
checkPackageJsonSync.createWebviewRpc (request/response + events over
postMessage, AbortSignal/timeout, auto-reject on panel close), plus
registerWebviewView (sidebar) and registerWebviewPanelSerializer.wizard().step(...).optionalStep(...) .branch(...).run(...) returning Result with the exact answered shape —
async items (auto busy), async debounced validation, native back button and
step counter.@kkdev92/vscode-ext-kit/testing: the vscode mock suite behind this
library's own test suite, published as a framework-agnostic testing kit
(createVSCodeMock(vi), typed createMockExtensionContext, per-API mock
builders). Works with vitest/jest/bun:test../timing and ./retry — vscode-free modules that
webview bundles can import directly.child(scope) loggers, structured fields, level getter.registerCommands<'a.x' | 'a.y'>, per-command Disposable map returns.syncable), typed onDidChange, tryGet,
multi-key createSecretStore with feature-detected keys().applyWorkspaceEdits, applyEditsGrouped, offset/position batch
utilities. TreeView: checkbox events, drag & drop helper, pagination
helper. UI: createLanguageStatusItem, pick separators, PickItem
value/display separation. std: withTimeout, debounce/throttle
leading/maxWait/flush/pending/signal, retry signal/timeoutMs/
RetryExhaustedError. DisposableCollection supports using
(Symbol.dispose); new createScope.LogOutputChannel by default and takes
structured fields instead of printf varargs; telemetry goes through
vscode.TelemetrySender (custom TelemetryReporter and redactStackPaths
removed). The last Node API import is gone — the library no longer uses
any Node built-in.safeExecute/trySafeExecute → run/tryRun with
cancellation classification: CancellationError/AbortError produce no
error toast, log at debug level, mark cancelled: true, and are never
rethrown.getConfig/getSetting/setSetting/
onConfigChange) replaced by the schema API; notification
showWithActions merged into showInfo/showWarn/showError with
reference-resolved action maps; WebView* renamed to Webview*;
t() → l10n.t(); editor thin wrappers (getLineCount/getDocumentText/
isDirty/getLanguageId) removed; getFilePath returns
{ fsPath, uri } and supports remote schemes.jitter
defaults to 'full'; CSP defaults flipped to safe (inline styles / https
images opt-in).src/ reorganized into domain folders; tests are now type-checked
(tsc -p tsconfig.tests.json runs in npm run typecheck).CommandHandler rejected precisely-typed handlers the raw API accepts.step.title was ignored whenever step numbers were shown.update()/set() destroyed an active spinner;
showStatusMessage reused one hardcoded item id across calls.showWithActions resolved actions by title string and broke on duplicate
titles.SimpleTreeDataProvider.reveal() could never work (no getParent);
removeItem only worked at the root level.migrate-style config listener leaks and DisposableCollection
add-after-dispose throwing instead of disposing.Maintenance release. The library's runtime behavior is unchanged — the work is in platform support, toolchain currency, and release supply chain.
engines.node raised from >=20.0.0 to >=22.0.0. Node.js 20
reached end of life on 2026-04-30. Node 24 (Active LTS) is recommended.engines.vscode intentionally stays at ^1.96.0. Every VS Code API this library
uses was already stable at 1.96, so the floor is not raised for consumers.@types/vscode 1.125, @types/node 26. TypeScript stays on 6.0.x because
typescript-eslint does not yet support TypeScript 7.actions/checkout and actions/setup-node upgraded to v7; release workflow gained
type-check and dead-code gates matching CI.NPM_TOKEN has been retired.brace-expansion denial-of-service advisories
(GHSA-3jxr-9vmj-r5cp, GHSA-mh99-v99m-4gvg) in the transitive dependency tree.Intl formatters in the
localization helpers are cached; error notifications no longer block the caller.Record<string, unknown>.channel.show() is throttled, and home directory paths are redacted from
telemetry stack traces.style-src and img-src.maxDelay and jitter options.WizardQuickPickStep variants are now internal.SimpleTreeDataProvider is indexed for O(1) lookups.showStatusMessage uses the id form of createStatusBarItem.logLevel option is respected on the error path.LogOutputChannel to OutputChannel to gain
full control over log levels.Initial public release.