diff --git a/CHANGELOG.md b/CHANGELOG.md index 15de951..b7915ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ Pre-1.0 releases followed it in spirit; their breaking changes are marked **Brea ### Added +- **`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 diff --git a/README.md b/README.md index b78bf0f..5842edc 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ has stopped being obvious._ ## Features -- **Declare, Then Run**: Commands, services, settings, storage, secrets, watchers and views are data; compiling them produces an immutable plan +- **Declare, Then Run**: Commands, services, settings, storage, secrets, watchers and views are data; compiling them produces an immutable plan — `describePlan` hands you that plan as JSON, to diff in a review or feed to a tool - **Preflight Before VS Code**: Duplicate ids, a missing service, a dependency cycle, a captive dependency — all rejected at import time, before a single API call - **One Cleanup Owner**: `deactivate` is the only teardown path; `context.subscriptions` gets one synchronous failsafe and nothing else - **Every Unit of Work Is an Operation**: A command invocation or a watcher batch arrives with an id, a logger, a combined `AbortSignal`, a progress session and a resource scope diff --git a/docs/guide.md b/docs/guide.md index 339ed05..8f6f011 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -710,6 +710,59 @@ every extension's deactivation against a few seconds and then exits; the framework's own budget sits inside that, and past it pending work is abandoned. Knowing _which_ work is the difference between a mystery and a fix. +### The plan, as data + +Diagnostics say what is happening; `describePlan` says what was declared. It +turns a compiled plan into JSON — module ids, service tokens and the edges +between them, command ids and titles, settings keys and defaults, watcher +globs, view ids — with nothing callable in it. + + + +```ts +import { describePlan } from '@kkdev92/vscode-ext-kit'; + +import { app } from './extension.js'; + +/** + * What this extension registers, as JSON. + * + * Commit the output and a pull request shows the topology change beside the + * code change: a new command, a service that gained a dependency, a watcher + * whose glob moved. Deterministic, so a diff means a declaration changed. + */ +export function planAsJson(): string { + return JSON.stringify(describePlan(app.plan), null, 2); +} + +/** Every command in the plan, with the module that declared it. */ +export function commandOwners(): readonly string[] { + return describePlan(app.plan).commands.map((command) => `${command.id} (${command.moduleId})`); +} + +/** + * The service graph as edges, which is most of what a dependency diagram is. + * + * Token ids, not token objects: the description carries nothing callable, so + * there is nothing here to resolve or mutate. + */ +export function serviceEdges(): readonly string[] { + return describePlan(app.plan).services.flatMap((service) => + Object.values(service.dependencies).map((dependency) => `${service.token} -> ${dependency}`) + ); +} +``` + +It is deterministic and in declaration order, so the output is worth +committing: a diff in the file means a declaration changed, which is the +review question `git diff` on a large module rarely answers directly. The same +document is what a manifest cross-check and a dependency diagram want, and it +is the honest answer to "what does this extension actually register?" for +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. + ## Keeping package.json honest VS Code reads the manifest before any extension code runs, so `src` and diff --git a/docs/samples/describe-plan.ts b/docs/samples/describe-plan.ts new file mode 100644 index 0000000..e061e88 --- /dev/null +++ b/docs/samples/describe-plan.ts @@ -0,0 +1,31 @@ +import { describePlan } from '@kkdev92/vscode-ext-kit'; + +import { app } from './extension.js'; + +/** + * What this extension registers, as JSON. + * + * Commit the output and a pull request shows the topology change beside the + * code change: a new command, a service that gained a dependency, a watcher + * whose glob moved. Deterministic, so a diff means a declaration changed. + */ +export function planAsJson(): string { + return JSON.stringify(describePlan(app.plan), null, 2); +} + +/** Every command in the plan, with the module that declared it. */ +export function commandOwners(): readonly string[] { + return describePlan(app.plan).commands.map((command) => `${command.id} (${command.moduleId})`); +} + +/** + * The service graph as edges, which is most of what a dependency diagram is. + * + * Token ids, not token objects: the description carries nothing callable, so + * there is nothing here to resolve or mutate. + */ +export function serviceEdges(): readonly string[] { + return describePlan(app.plan).services.flatMap((service) => + Object.values(service.dependencies).map((dependency) => `${service.token} -> ${dependency}`) + ); +} diff --git a/src/foundation/application/describe.ts b/src/foundation/application/describe.ts new file mode 100644 index 0000000..6c3ce14 --- /dev/null +++ b/src/foundation/application/describe.ts @@ -0,0 +1,414 @@ +/** + * @packageDocumentation + * A compiled plan, as plain data. + * + * The framework knows exactly what an extension registers — that is the point + * of compiling declarations before running them — but an `ApplicationPlan` + * holds factories, handlers and token objects, so it cannot be printed, + * diffed or serialized. This turns it into JSON: ids, names, lifetimes and + * dependency edges, and nothing that carries behaviour or user data. + * + * What it is for: a "what does this extension actually register?" answer in a + * review, a diff in a pull request, a graph, a manifest cross-check, and + * machine-readable context for a tool reading a codebase it did not write. + * + * What it is not: a way to reach into the application. Nothing here can be + * called, resolved or mutated, and the projection is deliberately one-way. + */ +import { defineOwn } from '../internal/record.js'; +import type { ApplicationPlan } from './plan.js'; +import { FRAMEWORK_SERVICES } from './plan.js'; + +/** Element types read straight off the plan, so this file names no capability. */ +type Module = ApplicationPlan['modules'][number]; +type Dependencies = ApplicationPlan['services'][number]['dependencies']; +type SettingsRegistration = ApplicationPlan['settings'][number]; +type SettingSpec = SettingsRegistration['values'][string]; +type WatcherPattern = ApplicationPlan['fileWatchers'][number]['patterns']; + +/** One module, and what it says it needs from the host. */ +export interface ModuleDescription { + readonly id: string; + /** Declared host compatibility, or `'unspecified'`. */ + readonly compatibility: string; + /** Hard requirements checked at activation. */ + readonly requires: { + readonly workspace: boolean; + readonly trust: boolean; + readonly localFileSystem: boolean; + }; + /** The optional diagnostic label the module was declared with. */ + readonly source: string | undefined; +} + +/** One service registration and the edges out of it. */ +export interface ServiceDescription { + /** The token's debug id. Container identity is the token object, not this. */ + readonly token: string; + readonly lifetime: 'singleton' | 'transient'; + /** Injected token ids, keyed by the name the factory receives. */ + readonly dependencies: Readonly>; + readonly moduleId: string; +} + +/** One command handler. */ +export interface CommandDescription { + readonly id: string; + readonly title: string | undefined; + readonly category: string | undefined; + /** Whether it was declared with `handleTextEditor`. */ + readonly textEditor: boolean; + /** Whether the contract carries a runtime argument validator. */ + readonly validated: boolean; + readonly dependencies: Readonly>; + readonly moduleId: string; +} + +/** One hosted service, and which phases it implements. */ +export interface HostedServiceDescription { + readonly id: string; + readonly start: boolean; + readonly run: boolean; + readonly stop: boolean; + readonly dependencies: Readonly>; + readonly moduleId: string; +} + +/** One setting, keyed as the manifest keys it. */ +export interface SettingDescription { + /** Fully-qualified key: the section and the name, joined. */ + readonly key: string; + /** JSON Schema type names, always as a list. */ + readonly type: readonly string[]; + /** The declared default. Whatever the declaration put there. */ + readonly default: unknown; + /** Contribution scope, matching `contributes.configuration`. */ + readonly scope: string; + /** Allowed values, in declaration order, when the setting is an enum. */ + readonly enum: readonly unknown[] | undefined; +} + +/** One settings group. */ +export interface SettingsSectionDescription { + readonly section: string; + /** How an invalid configured value is treated: `'strict'` or `'lenient'`. */ + readonly policy: string; + readonly values: readonly SettingDescription[]; + readonly moduleId: string; +} + +/** One typed storage key. */ +export interface StorageDescription { + readonly key: string; + readonly scope: 'global' | 'workspace'; + /** Whether the key participates in Settings Sync. */ + readonly syncable: boolean; + /** The schema version values are written at. */ + readonly version: number; + /** Whether a schema validates what is read and written. */ + readonly validated: boolean; + readonly ttlMs: number | undefined; + readonly legacyKeys: readonly string[]; + /** Versions a migration step is registered for, ascending. */ + readonly migratesFrom: readonly number[]; + readonly moduleId: string; +} + +/** One declared secret. Names only — a value never exists at plan time. */ +export interface SecretDescription { + readonly key: string; + readonly validated: boolean; + readonly moduleId: string; +} + +/** One declared file watcher. */ +export interface FileWatcherDescription { + readonly id: string; + /** Globs, with a relative pattern rendered as `::`. */ + readonly patterns: readonly string[]; + readonly ignorePatterns: readonly string[]; + /** Event kinds watched, or the default three when unspecified. */ + readonly events: readonly string[]; + readonly debounceDelayMs: number | undefined; + readonly maxWaitMs: number | undefined; + readonly maxBatchSize: number | undefined; + readonly dependencies: Readonly>; + readonly moduleId: string; +} + +/** One declared status bar item. */ +export interface StatusBarItemDescription { + readonly id: string; + readonly alignment: string | undefined; + readonly priority: number | undefined; + readonly moduleId: string; +} + +/** One declared language status item. */ +export interface LanguageStatusItemDescription { + readonly id: string; + /** Short name shown in the Language Status hover. */ + readonly name: string; + readonly moduleId: string; +} + +/** + * A declaration identified by an id and resolved from the container: a tree + * view, a webview view, a panel restorer, a managed raw registration. + */ +export interface RegistrationDescription { + readonly id: string; + readonly dependencies: Readonly>; + readonly moduleId: string; +} + +/** + * Everything a compiled plan registers, as JSON. + * + * Every list is in declaration order, so two runs over the same modules + * produce the same document and a diff means something changed. + */ +export interface ApplicationPlanDescription { + readonly name: string; + readonly shutdown: { readonly timeoutMs: number }; + readonly modules: readonly ModuleDescription[]; + readonly services: readonly ServiceDescription[]; + /** + * Token ids the Application registers itself, which a module may inject + * without declaring: `Notifications`, `Editors`, `Log` and the rest. + */ + readonly frameworkServices: readonly string[]; + readonly commands: readonly CommandDescription[]; + readonly hostedServices: readonly HostedServiceDescription[]; + readonly settings: readonly SettingsSectionDescription[]; + readonly storage: readonly StorageDescription[]; + readonly secrets: readonly SecretDescription[]; + readonly fileWatchers: readonly FileWatcherDescription[]; + readonly statusBarItems: readonly StatusBarItemDescription[]; + readonly languageStatusItems: readonly LanguageStatusItemDescription[]; + readonly treeViews: readonly RegistrationDescription[]; + readonly webviewViews: readonly RegistrationDescription[]; + /** Panel restorers, keyed by the `viewType` they restore. */ + readonly webviewSerializers: readonly RegistrationDescription[]; + readonly rawRegistrations: readonly RegistrationDescription[]; +} + +/** + * Which module declared each entry. + * + * Some declarations carry a `moduleId` and some do not, so rather than + * depending on which is which, ownership is recovered the same way for all of + * them: the plan's flat lists hold the very objects the modules hold, so + * identity answers it. + */ +function owners( + modules: readonly Module[], + pick: (module: Module) => readonly T[] +): ReadonlyMap { + const owner = new Map(); + for (const module of modules) { + for (const entry of pick(module)) { + owner.set(entry, module.id); + } + } + return owner; +} + +/** + * Turns a declared dependency map into token ids. + * + * `defineOwn` rather than assignment: the names come from a declaration this + * package did not write, and one of them would reach `Object.prototype`'s + * `__proto__` setter instead of creating a property. + */ +function dependencyIds(dependencies: Dependencies): Readonly> { + const ids: Record = {}; + for (const [name, token] of Object.entries(dependencies)) { + defineOwn(ids, name, token.id); + } + return ids; +} + +/** Renders one glob. A relative pattern keeps its base, which is what makes it relative. */ +function patternText(pattern: string | { readonly baseUri: { toString(): string } }): string { + if (typeof pattern === 'string') { + return pattern; + } + const relative = pattern as { + readonly baseUri: { toString(): string }; + readonly pattern: string; + }; + return `${relative.baseUri.toString()}::${relative.pattern}`; +} + +/** Both spellings of `patterns` — one glob or several — as a list. */ +function patternList(patterns: WatcherPattern): readonly string[] { + return Array.isArray(patterns) + ? patterns.map((pattern: string | { readonly baseUri: { toString(): string } }) => + patternText(pattern) + ) + : [patternText(patterns as string | { readonly baseUri: { toString(): string } })]; +} + +/** A JSON Schema `type` is a name or a list of them; a list is easier to read. */ +function typeNames(spec: SettingSpec): readonly string[] { + return Array.isArray(spec.type) ? [...(spec.type as readonly string[])] : [spec.type as string]; +} + +/** + * Describes a compiled plan. + * + * Deterministic: the same modules produce the same document, in declaration + * order. Nothing callable and nothing opaque crosses the boundary — no + * factories, handlers, schemas, providers or token objects — so the result is + * safe to `JSON.stringify`, commit, diff and hand to a tool. + * + * Secret *keys* appear, because a declared key is metadata the extension's own + * source states in the clear. Secret values do not exist at plan time and + * never could. + * + * @example + * ```ts + * const description = describePlan(app.plan); + * console.log(description.commands.map((command) => command.id)); + * ``` + */ +export function describePlan(plan: ApplicationPlan): ApplicationPlanDescription { + const modules = plan.modules; + const settingsOwner = owners(modules, (module) => module.settings); + const storageOwner = owners(modules, (module) => module.storage); + const secretOwner = owners(modules, (module) => module.secrets); + const statusBarOwner = owners(modules, (module) => module.statusBarItems); + const languageStatusOwner = owners(modules, (module) => module.languageStatusItems); + + const unknownModule = ''; + + return { + name: plan.name, + shutdown: { timeoutMs: plan.shutdown.timeoutMs }, + + modules: modules.map((module) => ({ + id: module.id, + compatibility: module.compatibility, + requires: { + workspace: module.requires.workspace === true, + trust: module.requires.trust === true, + localFileSystem: module.requires.localFileSystem === true, + }, + source: module.source, + })), + + services: plan.services.map((service) => ({ + token: service.token.id, + lifetime: service.lifetime, + dependencies: dependencyIds(service.dependencies), + moduleId: service.moduleId, + })), + + frameworkServices: FRAMEWORK_SERVICES.map((token) => token.id), + + commands: [ + ...plan.commands.map((command) => ({ command, textEditor: false })), + ...plan.textEditorCommands.map((command) => ({ command, textEditor: true })), + ].map(({ command, textEditor }) => ({ + id: command.contract.descriptor.id, + title: command.contract.descriptor.title, + category: command.contract.descriptor.category, + textEditor, + validated: command.contract.args !== undefined, + dependencies: dependencyIds(command.dependencies), + moduleId: command.moduleId, + })), + + hostedServices: plan.hostedServices.map((service) => ({ + id: service.id, + start: service.start !== undefined, + run: service.run !== undefined, + stop: service.stop !== undefined, + dependencies: dependencyIds(service.dependencies), + moduleId: service.moduleId, + })), + + settings: plan.settings.map((registration) => ({ + section: registration.section, + policy: registration.policy, + values: Object.entries(registration.values).map(([name, spec]) => ({ + key: `${registration.section}.${name}`, + type: typeNames(spec), + default: spec.default, + scope: spec.scope, + enum: spec.enum === undefined ? undefined : [...spec.enum], + })), + moduleId: settingsOwner.get(registration) ?? unknownModule, + })), + + storage: plan.storage.map((registration) => ({ + key: registration.key, + scope: registration.scope, + syncable: registration.syncable === true, + version: registration.options.version ?? 1, + validated: registration.options.schema !== undefined, + ttlMs: registration.options.ttlMs, + legacyKeys: [...(registration.options.legacyKeys ?? [])], + migratesFrom: Object.keys(registration.options.migrations ?? {}) + .map((version) => Number(version)) + .sort((left, right) => left - right), + moduleId: storageOwner.get(registration) ?? unknownModule, + })), + + secrets: plan.secrets.map((registration) => ({ + key: registration.key, + validated: registration.schema !== undefined, + moduleId: secretOwner.get(registration) ?? unknownModule, + })), + + fileWatchers: plan.fileWatchers.map((watcher) => ({ + id: watcher.id, + patterns: patternList(watcher.patterns), + ignorePatterns: [...(watcher.ignorePatterns ?? [])], + events: [...(watcher.events ?? ['create', 'change', 'delete'])], + debounceDelayMs: watcher.debounceDelay, + maxWaitMs: watcher.maxWait, + maxBatchSize: watcher.maxBatchSize, + dependencies: dependencyIds(watcher.dependencies), + moduleId: watcher.moduleId, + })), + + statusBarItems: plan.statusBarItems.map((item) => ({ + id: item.id, + alignment: item.alignment, + priority: item.priority, + moduleId: statusBarOwner.get(item) ?? unknownModule, + })), + + languageStatusItems: plan.languageStatusItems.map((item) => ({ + id: item.id, + name: item.name, + moduleId: languageStatusOwner.get(item) ?? unknownModule, + })), + + treeViews: plan.treeViews.map((view) => ({ + id: view.id, + dependencies: dependencyIds(view.dependencies), + moduleId: view.moduleId, + })), + + webviewViews: plan.webviewViews.map((view) => ({ + id: view.id, + dependencies: dependencyIds(view.dependencies), + moduleId: view.moduleId, + })), + + webviewSerializers: plan.webviewSerializers.map((serializer) => ({ + id: serializer.viewType, + dependencies: dependencyIds(serializer.dependencies), + moduleId: serializer.moduleId, + })), + + rawRegistrations: plan.rawRegistrations.map((registration) => ({ + id: registration.id, + dependencies: dependencyIds(registration.dependencies), + moduleId: registration.moduleId, + })), + }; +} diff --git a/src/foundation/application/plan.ts b/src/foundation/application/plan.ts index 2016747..a35127a 100644 --- a/src/foundation/application/plan.ts +++ b/src/foundation/application/plan.ts @@ -98,6 +98,28 @@ export interface CompileApplicationOptions { const DEFAULT_SHUTDOWN_TIMEOUT_MS = 3_000; +/** + * Tokens the Application registers itself rather than a module. + * + * Named once because two things need the same list: preflight, which must not + * report a module injecting one of these as depending on nothing, and + * `describePlan`, which reports what is injectable without being declared. + * Adding a framework service means adding it here. + */ +export const FRAMEWORK_SERVICES: readonly ServiceToken[] = Object.freeze([ + Notifications, + QuickInput, + Localization, + Editors, + Webviews, + Secrets, + StatusBar, + Commands, + FileWatchers, + Operations, + Log, +]); + /** * Compiles modules into an immutable plan, reporting every definition-time * problem it can find before a single platform registration happens. @@ -300,17 +322,7 @@ export function compileApplication(options: CompileApplicationOptions): Applicat ...secrets.map((registration) => registration.token), ...statusBarItems.map((item) => item.token), ...languageStatusItems.map((item) => item.token), - Notifications, - QuickInput, - Localization, - Editors, - Webviews, - Secrets, - StatusBar, - Commands, - FileWatchers, - Operations, - Log, + ...FRAMEWORK_SERVICES, ]); // The graph is checked against the same set. A service that injects a diff --git a/src/index.ts b/src/index.ts index 6771635..ef80e4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,6 +68,24 @@ export type { export { ModuleCompatibility } from './foundation/modules/compatibility.js'; export type { ModuleRequirements } from './foundation/modules/compatibility.js'; export type { ApplicationPlan } from './foundation/application/plan.js'; +// A compiled plan as JSON: what the extension registers, for a review, a diff +// or a tool. Nothing callable crosses the boundary. +export { describePlan } from './foundation/application/describe.js'; +export type { + ApplicationPlanDescription, + CommandDescription, + FileWatcherDescription, + HostedServiceDescription, + LanguageStatusItemDescription, + ModuleDescription, + RegistrationDescription, + SecretDescription, + ServiceDescription, + SettingDescription, + SettingsSectionDescription, + StatusBarItemDescription, + StorageDescription, +} from './foundation/application/describe.js'; export type { HostDiagnostic } from './foundation/hosting/application-host.js'; // --- Explicit dependency injection ---------------------------------------- diff --git a/tests/foundation/application/describe.test.ts b/tests/foundation/application/describe.test.ts new file mode 100644 index 0000000..fa49daf --- /dev/null +++ b/tests/foundation/application/describe.test.ts @@ -0,0 +1,353 @@ +/** + * The plan, projected to JSON. + * + * What this suite is really protecting is the boundary: nothing callable and + * nothing opaque may cross it, and the same modules must always produce the + * same document. A tool that diffs this output is only useful if a diff means + * a declaration changed, rather than a map having been iterated in a different + * order. + */ +import { describe, expect, it } from 'vitest'; + +import { compileApplication } from '../../../src/foundation/application/plan.js'; +import { describePlan } from '../../../src/foundation/application/describe.js'; +import { defineCommandContract } from '../../../src/foundation/commands/contract.js'; +import { ModuleCompatibility } from '../../../src/foundation/modules/compatibility.js'; +import { defineModule } from '../../../src/foundation/modules/definition.js'; +import { serviceToken } from '../../../src/foundation/services/token.js'; +import { defineSettings, setting } from '../../../src/foundation/settings/definition.js'; +import { Log } from '../../../src/foundation/logging/token.js'; +import { Notifications } from '../../../src/capabilities/ui/notifications.js'; +import { defineSecret, defineStorage } from '../../../src/capabilities/storage/definition.js'; +import { defineStatusBarItem } from '../../../src/capabilities/ui/definition.js'; +import { s } from '../../../src/capabilities/core/schema.js'; + +interface Repository { + count(): number; +} +const Repository = serviceToken('projects.repository'); +const Clock = serviceToken<{ now(): number }>('projects.clock'); +// Transient, and nothing long-lived depends on it: a singleton that did would +// capture it for the application's lifetime, and preflight rejects that. +const Session = serviceToken<{ id: string }>('projects.session'); + +const Refresh = defineCommandContract( + { id: 'sample.refresh', title: 'Refresh', category: 'Sample' }, + { args: { validate: (value) => ({ ok: true, value: value as readonly [boolean] }) } } +); +const Reformat = defineCommandContract({ + id: 'sample.reformat', + title: 'Reformat', +}); + +const Settings = defineSettings({ + section: 'sample.projects', + values: { + enabled: setting.boolean({ default: true, scope: 'resource' }), + mode: setting.enum({ values: ['fast', 'thorough'], default: 'fast' }), + }, +}); + +const Recent = defineStorage({ + key: 'sample.recent', + scope: 'global', + syncable: true, + defaultValue: [], + version: 3, + migrations: { 2: (old) => old, 1: (old) => old }, + ttlMs: 60_000, + legacyKeys: ['sample.history'], +}); + +const Token = defineSecret<{ value: string }>({ + key: 'sample.token', + schema: s.object({ value: s.string() }), +}); + +const Status = defineStatusBarItem({ + id: 'sample.status', + text: 'Sample', + alignment: 'right', + priority: 10, +}); + +/** One module of every declaration kind, so the projection is exercised whole. */ +const everything = defineModule( + 'projects', + { uses: { log: Log }, compatibility: ModuleCompatibility.WebSafe, requires: { trust: true } }, + (module): undefined => { + module.services.singleton(Repository, { + inject: { clock: Clock }, + create: () => ({ count: () => 0 }), + }); + module.services.singleton(Clock, () => ({ now: () => 0 })); + module.services.transient(Session, () => ({ id: 'one' })); + + module.commands.handle(Refresh, { + inject: { repository: Repository }, + execute: () => 1, + }); + module.commands.handleTextEditor(Reformat, () => undefined); + + module.settings.add(Settings); + module.storage.add(Recent); + module.secrets.add(Token); + module.statusBar.add(Status); + + module.hostedServices.add({ id: 'projects.index', start: () => undefined }); + module.hostedServices.background({ id: 'projects.poll', run: () => undefined }); + + module.fileWatchers.add({ + id: 'projects.manifests', + patterns: [ + '**/package.json', + { baseUri: { scheme: 'file', path: '/w', toString: () => 'file:///w' }, pattern: '*.md' }, + ], + ignorePatterns: ['**/node_modules/**'], + events: ['change'], + debounceDelay: 250, + inject: { notify: Notifications }, + handle: () => undefined, + }); + + module.treeViews.add({ id: 'sample.tree', resolveProvider: () => ({}) }); + module.webviews.addView({ id: 'sample.panel', resolve: () => undefined }); + module.webviews.restorePanel({ viewType: 'sample.preview', restore: () => undefined }); + module.raw.register({ id: 'sample.raw', bind: () => undefined }); + + return undefined; + } +); + +const plan = compileApplication({ name: 'sample', modules: [everything] }); + +describe('describePlan', () => { + it('is deterministic and JSON-safe', () => { + const first = describePlan(plan); + const second = describePlan(plan); + + expect(first).toEqual(second); + // The whole point: this can be written to a file, committed and diffed. + expect(JSON.parse(JSON.stringify(first))).toEqual(first); + }); + + it('carries no function, token object or provider across the boundary', () => { + const seen = new Set(); + const walk = (value: unknown, path: string): void => { + if (typeof value === 'function') { + throw new Error(`a function reached the description at ${path}`); + } + if (typeof value !== 'object' || value === null || seen.has(value)) { + return; + } + seen.add(value); + for (const [key, child] of Object.entries(value)) { + walk(child, `${path}.${key}`); + } + }; + + expect(() => walk(describePlan(plan), 'description')).not.toThrow(); + }); + + it('describes modules with their compatibility and requirements', () => { + expect(describePlan(plan).modules).toEqual([ + { + id: 'projects', + compatibility: 'web-safe', + requires: { workspace: false, trust: true, localFileSystem: false }, + source: undefined, + }, + ]); + }); + + it('names service tokens, lifetimes and the edges between them', () => { + expect(describePlan(plan).services).toEqual([ + { + token: 'projects.repository', + lifetime: 'singleton', + dependencies: { clock: 'projects.clock' }, + moduleId: 'projects', + }, + { + token: 'projects.clock', + lifetime: 'singleton', + dependencies: {}, + moduleId: 'projects', + }, + { + token: 'projects.session', + lifetime: 'transient', + dependencies: {}, + moduleId: 'projects', + }, + ]); + }); + + it('lists what is injectable without being declared', () => { + // A reader of the graph would otherwise see a command depending on a token + // that nothing in the plan registers. + expect(describePlan(plan).frameworkServices).toContain('framework.notifications'); + expect(describePlan(plan).frameworkServices).toContain('framework.log'); + }); + + it('separates plain commands from text editor ones and says which validate', () => { + expect(describePlan(plan).commands).toEqual([ + { + id: 'sample.refresh', + title: 'Refresh', + category: 'Sample', + textEditor: false, + validated: true, + // The module's ambient `uses` is merged in, exactly as preflight sees it. + dependencies: { log: 'framework.log', repository: 'projects.repository' }, + moduleId: 'projects', + }, + { + id: 'sample.reformat', + title: 'Reformat', + category: undefined, + textEditor: true, + validated: false, + dependencies: { log: 'framework.log' }, + moduleId: 'projects', + }, + ]); + }); + + it('says which lifecycle phases a hosted service implements', () => { + expect(describePlan(plan).hostedServices).toEqual([ + { + id: 'projects.index', + start: true, + run: false, + stop: false, + dependencies: { log: 'framework.log' }, + moduleId: 'projects', + }, + { + id: 'projects.poll', + start: false, + run: true, + stop: false, + dependencies: { log: 'framework.log' }, + moduleId: 'projects', + }, + ]); + }); + + it('describes settings the way the manifest keys them', () => { + expect(describePlan(plan).settings).toEqual([ + { + section: 'sample.projects', + policy: 'lenient', + values: [ + { + key: 'sample.projects.enabled', + type: ['boolean'], + default: true, + scope: 'resource', + enum: undefined, + }, + { + key: 'sample.projects.mode', + type: ['string'], + default: 'fast', + scope: 'window', + enum: ['fast', 'thorough'], + }, + ], + moduleId: 'projects', + }, + ]); + }); + + it('describes storage without carrying the stored shape', () => { + expect(describePlan(plan).storage).toEqual([ + { + key: 'sample.recent', + scope: 'global', + syncable: true, + version: 3, + validated: false, + ttlMs: 60_000, + legacyKeys: ['sample.history'], + // Ascending regardless of the order the migrations were declared in, + // because a diff of this document must not move when a literal does. + migratesFrom: [1, 2], + moduleId: 'projects', + }, + ]); + }); + + it('names secret keys and never anything else about them', () => { + // A declared key is metadata the extension's own source states in the + // clear; a value does not exist at plan time and never could. + expect(describePlan(plan).secrets).toEqual([ + { key: 'sample.token', validated: true, moduleId: 'projects' }, + ]); + }); + + it('renders both glob spellings, keeping a relative pattern relative', () => { + expect(describePlan(plan).fileWatchers).toEqual([ + { + id: 'projects.manifests', + patterns: ['**/package.json', 'file:///w::*.md'], + ignorePatterns: ['**/node_modules/**'], + events: ['change'], + debounceDelayMs: 250, + maxWaitMs: undefined, + maxBatchSize: undefined, + dependencies: { log: 'framework.log', notify: 'framework.notifications' }, + moduleId: 'projects', + }, + ]); + }); + + it('describes the UI and view declarations, each keyed by its own id', () => { + const description = describePlan(plan); + + expect(description.statusBarItems).toEqual([ + { id: 'sample.status', alignment: 'right', priority: 10, moduleId: 'projects' }, + ]); + expect(description.treeViews).toEqual([ + { id: 'sample.tree', dependencies: { log: 'framework.log' }, moduleId: 'projects' }, + ]); + expect(description.webviewViews.map((view) => view.id)).toEqual(['sample.panel']); + // A restorer is keyed by the view type it restores, which is its id here. + expect(description.webviewSerializers.map((entry) => entry.id)).toEqual(['sample.preview']); + expect(description.rawRegistrations.map((entry) => entry.id)).toEqual(['sample.raw']); + }); + + it('attributes a declaration to its module even when the declaration does not say', () => { + // Settings, storage, secrets and UI items carry no moduleId of their own; + // ownership is recovered from the module that holds them. + const two = compileApplication({ + name: 'sample', + modules: [ + defineModule('a', (module): undefined => { + module.settings.add(Settings); + return undefined; + }), + defineModule('b', (module): undefined => { + module.storage.add(Recent); + return undefined; + }), + ], + }); + const description = describePlan(two); + + expect(description.settings[0]?.moduleId).toBe('a'); + expect(description.storage[0]?.moduleId).toBe('b'); + }); + + it('reports an empty application without inventing anything', () => { + const empty = describePlan(compileApplication({ name: 'empty', modules: [] })); + + expect(empty.name).toBe('empty'); + expect(empty.shutdown).toEqual({ timeoutMs: 3_000 }); + expect(empty.modules).toEqual([]); + expect(empty.commands).toEqual([]); + // Framework services exist whether or not anything declares a module. + expect(empty.frameworkServices.length).toBeGreaterThan(0); + }); +});