From 8908557b2b8234884af9eb6dd9496f6a20a5b8b6 Mon Sep 17 00:00:00 2001 From: kkdev92 <112151103+kkdev92@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:28:30 +0900 Subject: [PATCH] feat(plan): describe a compiled plan as 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. `describePlan` projects it: module ids, service tokens and the edges between them, command ids and titles, settings keys and defaults, storage versions, watcher globs, view ids. Nothing callable and nothing opaque crosses the boundary, so the result is safe to stringify, commit and diff. It is deterministic and in declaration order, which is what makes a diff mean something: a change in the file is a change in what the extension registers. 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 needs. Ownership is recovered by identity rather than by field, because settings, storage, secrets and UI items carry no module id of their own: the plan's flat lists hold the very objects the modules hold. The tokens the Application registers itself are now named once, in `FRAMEWORK_SERVICES`, because preflight and this projection need the same list -- preflight so a module injecting `Notifications` is not reported as depending on nothing, and the description so a reader of the graph can tell which tokens need no declaration. Secret keys appear; a declared key is metadata the extension's own source states in the clear, and a value does not exist at plan time. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 + README.md | 2 +- docs/guide.md | 53 +++ docs/samples/describe-plan.ts | 31 ++ src/foundation/application/describe.ts | 414 ++++++++++++++++++ src/foundation/application/plan.ts | 34 +- src/index.ts | 18 + tests/foundation/application/describe.test.ts | 353 +++++++++++++++ 8 files changed, 906 insertions(+), 12 deletions(-) create mode 100644 docs/samples/describe-plan.ts create mode 100644 src/foundation/application/describe.ts create mode 100644 tests/foundation/application/describe.test.ts 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); + }); +});