diff --git a/AGENTS.md b/AGENTS.md index 1b26dc1..e159f78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,4 +8,4 @@ Read `REVIEW.md` at the repository root and apply it in full as the review rules ## Plugins -Follow the [plugin guide](docs/manual/plugins.md). Place bundled plugins under `plugins//` and compose ordered defaults only in root `cli.ts`; keep `src/` feature-neutral and keep plugin module graphs out of private `src/` implementation. Import `@fx/tx/plugin` and every published capability contract (`@fx/tx/theme`, `@fx/tx/theme-override`, `@fx/tx/grid`) type-only — a published specifier is also the only way one bundled plugin may name another's vocabulary — add Bun tests for observable behavior, preserve required coverage, and run `bun run check`. +Follow the [plugin guide](docs/manual/plugins.md). Place bundled plugins under `plugins//` and compose ordered defaults only in root `cli.ts`; keep `src/` feature-neutral and keep plugin module graphs out of private `src/` implementation. Import `@fx/tx/plugin` and every published capability contract (`@fx/tx/config`, `@fx/tx/theme`, `@fx/tx/theme-override`, `@fx/tx/grid`) type-only — a published specifier is also the only way one bundled plugin may name another's vocabulary — add Bun tests for observable behavior, preserve required coverage, and run `bun run check`. diff --git a/REVIEW.md b/REVIEW.md index 2bfbaf4..878642c 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -20,7 +20,7 @@ DNS-rebinding protection stays on instead: `server.allowedHosts` is the `.ts.net Use the [plugin guide](docs/manual/plugins.md) as the practical reference. For plugin changes, verify: -- Core and plugin ownership boundaries remain intact, including type-only use of `@fx/tx/plugin` and of every published capability contract (`@fx/tx/theme`, `@fx/tx/theme-override`, `@fx/tx/grid`). A bundled plugin may name another bundled plugin's vocabulary only through such a published specifier, never through a relative path into its directory; no module under `src/` may import one at all. +- Core and plugin ownership boundaries remain intact, including type-only use of `@fx/tx/plugin` and of every published capability contract (`@fx/tx/config`, `@fx/tx/theme`, `@fx/tx/theme-override`, `@fx/tx/grid`). A bundled plugin may name another bundled plugin's vocabulary only through such a published specifier, never through a relative path into its directory; no module under `src/` may import one at all. - A failed plugin contributes nothing and does not block healthy plugins. - Marketplace plugin names are unique and safe; configured entries are non-empty repository-relative regular files contained after resolution. - React and Ink come from injected dependencies rather than separate runtime imports. diff --git a/docs/changes/0031-publish-the-dialogs-and-config-contracts.md b/docs/changes/0031-publish-the-dialogs-and-config-contracts.md index 8bd086e..e5b9259 100644 --- a/docs/changes/0031-publish-the-dialogs-and-config-contracts.md +++ b/docs/changes/0031-publish-the-dialogs-and-config-contracts.md @@ -93,13 +93,13 @@ The cleanup is the larger half. The sites are found by searching for the declara ## Tasks -- [ ] Publish the config contract - - [ ] Rename the `config` key to `@fx/tx/config` at every provider, consumer, demo, and test naming it, in the same commit that publishes the contract - - [ ] `plugins/config/contract.ts` declaring `Config` and `ConfigValidator` as types alone - - [ ] Have `plugins/config/index.ts` import it type-only and register a value checked against `Config` - - [ ] Reduce `plugins/marketplace/configured.ts` to importing the published contract, keeping `requireConfigCapability` and the marketplace's own key and value types - - [ ] `exports` entry `./config`, its `files` entries, the packed-file assertion, and the consumer-fixture import - - [ ] `test/config-plugin.test.ts` and `test/marketplace-plugin.test.ts` import the published contract; the latter declares its own `ConfigValidator` today +- [x] Publish the config contract + - [x] Rename the `config` key to `@fx/tx/config` at every provider, consumer, demo, and test naming it, in the same commit that publishes the contract + - [x] `plugins/config/contract.ts` declaring `Config` and `ConfigValidator` as types alone + - [x] Have `plugins/config/index.ts` import it type-only and register a value checked against `Config` + - [x] Reduce `plugins/marketplace/configured.ts` to importing the published contract, keeping `requireConfigCapability` and the marketplace's own key and value types + - [x] `exports` entry `./config`, its `files` entries, the packed-file assertion, and the consumer-fixture import + - [x] `test/config-plugin.test.ts` and `test/marketplace-plugin.test.ts` import the published contract; the latter declares its own `ConfigValidator` today - [ ] Publish the dialogs contract - [ ] Rename the `dialogs` key to `@fx/tx/dialogs` at every provider, consumer, demo, and test naming it, in the same commit that publishes the contract - [ ] `plugins/dialogs/contract.ts` declaring the request, option, field, filter, expand, and result types and `Dialogs`, as types alone diff --git a/docs/manual/plugins.md b/docs/manual/plugins.md index 9025855..85132d7 100644 --- a/docs/manual/plugins.md +++ b/docs/manual/plugins.md @@ -320,19 +320,15 @@ This is not a dependency-injection or lifecycle container. There are no schemas, ## Use the bundled config capability -The namespace-free bundled config provider registers one internal capability under the exact opaque key `config`. It persists small JSON values across invocations in one per-user document. Its local structural shape is: +The namespace-free bundled config provider registers one capability under the exact opaque key `@fx/tx/config`. It persists small JSON values across invocations in one per-user document. Import the contract from that same key rather than restating it — the string you read the capability from and the string you import its shape from are one string, so a contract that moves fails your build rather than your command: ```ts -type ConfigValidator = (value: unknown) => value is T - -type Config = { - define(key: string, isValid: ConfigValidator): void - read(key: string): Promise - write(key: string, value: T): Promise -} +import type { Config, ConfigValidator } from "@fx/tx/config"; ``` -A bundled consumer declares that compatible type locally and reads `registrations("config")` inside its command action, after initialization has committed every provider. The shape stays an implementation detail for bundled plugins; it is not a public export from `@fx/tx/plugin`. +`Config` is what the key carries: `define(key, isValid)`, `read(key)` resolving the persisted value or `undefined`, and `write(key, value)`. A `ConfigValidator` is your own `(value: unknown) => value is T`, which is the whole of what makes a read safe — the capability never infers one, because only the consumer knows what its key means. Every member is documented where it is declared, and the contract is types only: it publishes no way to obtain a store, which stays the registry's job, and nothing behind the import is a file, a path, or a document format. + +A consumer reads `registrations("@fx/tx/config")` inside its command action, after initialization has committed every provider. What a read answering none, or more than one, means is the consumer's own decision: the contract types the values under the key and neither supplies one, selects among them, nor rejects one. The marketplace plugin decides it strictly — `requireConfigCapability` treats both counts as errors naming the count, with no fallback — because the config plugin is composed by default, so a `tx` without it is misconfigured rather than degraded. A consumer with a reason to tolerate an absent provider is free to make the opposite choice. Call `define` once for each key before reading or writing it in the current process. Keys are opaque and compared exactly: tx does not trim, normalize, parse, namespace, or reserve them. A second definition of the same key is rejected and leaves the first guard in force. An absent property reads as `undefined`; a present value and every value being written must pass that key's guard. A rejected read affects no other key, and a rejected write changes nothing on disk. Values use JSON encoding, so a guard should accept only values that survive a JSON round trip in the form the consumer expects. @@ -365,7 +361,7 @@ import type { ThemeOverride } from "@fx/tx/theme-override"; `Theming` is what the key carries: `theme(stream, options?)` resolving a `Theme`, which answers `appearance(variable)` with an `Appearance` of optional `dim`, `bold`, `inverse`, and `hue`. A `ThemeVariable` is one of nine semantic roles — `chrome`, `content`, `cursor`, `marker`, `muted`, `strong`, `positive`, `caution`, and `danger` — and a `Hue` is one of nine colours: the eight ANSI ones plus `gray`. Background hues, 256-colour, and truecolour are deliberately absent. Every member is documented where it is declared, and the contract is types only: it publishes no way to obtain a theme, which stays the registry's job. -A consumer reads `registrations("@fx/tx/theme")` inside its command action, after initialization has committed every provider. It must find **exactly one** — as a config consumer already must, through the same rule `requireConfigCapability` applies, and unlike the dialogs capability, where the consumer owns what an absent capability means: none and several are both errors naming the count, and there is deliberately no fallback, because a consumer that fell back would have to carry its own copy of the default theme. The theme plugin is composed by default, so a `tx` without it is misconfigured rather than degraded. +A consumer reads `registrations("@fx/tx/theme")` inside its command action, after initialization has committed every provider. It must find **exactly one** — as the marketplace plugin's `requireConfigCapability` lookup already chooses to, and unlike the dialogs capability, where the consumer owns what an absent capability means: none and several are both errors naming the count, and there is deliberately no fallback, because a consumer that fell back would have to carry its own copy of the default theme. The theme plugin is composed by default, so a `tx` without it is misconfigured rather than degraded. A theme is resolved for the stream a surface draws to rather than handed out ready-made, because whether hues are emitted depends on that stream. Only the stream's TTY-ness is read; the capability never writes to it, retains it, or exposes a terminal or renderer. A resolved theme answers with an appearance alone and never says whether hues were enabled — that decision is already inside every appearance it returns. @@ -616,7 +612,7 @@ Commands, child definitions, generic registry entries, and update participants c Bundled feature plugins live under `plugins//`, conventionally at `plugins//index.ts`. Only the root `cli.ts` composition root selects and orders defaults. Modules under `src/` must remain feature-neutral and must not import or name bundled plugins; a bundled plugin's complete module graph must not import private core implementation under `src/`. -Use type-only imports from `@fx/tx/plugin` and from every capability contract the package publishes — `@fx/tx/theme`, `@fx/tx/theme-override`, and `@fx/tx/grid` today — standard Node.js or Bun APIs, and plugin-owned modules. A published specifier is the one way a bundled plugin may name another bundled plugin's vocabulary: it carries types alone and is erased, so it shares no runtime module graph, while a relative path into another plugin's directory is rejected. None of them may be loaded at run time or imported from under `src/`. Plugin-owned nonliteral dynamic imports of configured entry paths are allowed. +Use type-only imports from `@fx/tx/plugin` and from every capability contract the package publishes — `@fx/tx/config`, `@fx/tx/theme`, `@fx/tx/theme-override`, and `@fx/tx/grid` today — standard Node.js or Bun APIs, and plugin-owned modules. A published specifier is the one way a bundled plugin may name another bundled plugin's vocabulary: it carries types alone and is erased, so it shares no runtime module graph, while a relative path into another plugin's directory is rejected. None of them may be loaded at run time or imported from under `src/`. Plugin-owned nonliteral dynamic imports of configured entry paths are allowed. ## Validate changes diff --git a/docs/specs/config/index.md b/docs/specs/config/index.md index 1867fad..c31dabb 100644 --- a/docs/specs/config/index.md +++ b/docs/specs/config/index.md @@ -6,7 +6,7 @@ [Change 0018](../../changes/0018-add-config-store-and-marketplace-installs.md) specifies the config capability and its first consumer, the marketplace plugin's configured-marketplace list. The bundled provider is implemented under `plugins/config/` and composed only in `cli.ts`; the marketplace plugin consumes it at command time for explicit configured installs and add/remove write-back. The requirements below describe current behavior. -[Change 0031](../../changes/0031-publish-the-dialogs-and-config-contracts.md) publishes this contract at `@fx/tx/config` and renames the capability's registry key to that same specifier, so a consumer imports the contract rather than restating it, under [Plugin System: Published Capability Contracts](../plugin-system/index.md#published-capability-contracts). That change is not yet implemented, so exactly two things below are ahead of the code: the contract is not published today, and the key in use is still the bare `config`. Every other requirement describes current behavior. +[Change 0031](../../changes/0031-publish-the-dialogs-and-config-contracts.md) publishes this contract at `@fx/tx/config` and renames the capability's registry key to that same specifier, so a consumer imports the contract rather than restating it, under [Plugin System: Published Capability Contracts](../plugin-system/index.md#published-capability-contracts). The half of that change covering this capability has landed — publication, the key rename, and the move of the marketplace plugin, the capability tests, and [the plugin guide](../../manual/plugins.md) off their local copies of the shape — so every requirement below describes current behavior. The change stays open for the [Dialogs](../dialogs/) contract. ## Background diff --git a/docs/specs/plugin-system/index.md b/docs/specs/plugin-system/index.md index 30ec212..179ff40 100644 --- a/docs/specs/plugin-system/index.md +++ b/docs/specs/plugin-system/index.md @@ -12,7 +12,7 @@ The approved target architecture is implemented: the core is generic, the market The second registry provider from [Change 0018](../../changes/0018-add-config-store-and-marketplace-installs.md) is implemented outside core under `plugins/config/`: it registers the config capability and follows the separately owned [Config](../config/) contract. The marketplace plugin consumes it for [Configured Marketplaces](#configured-marketplaces), including explicit installation and add/remove write-back. -[Published Capability Contracts](#published-capability-contracts) is implemented for the capabilities [Change 0030](../../changes/0030-publish-bundled-capability-contracts.md) covers. That change established the mechanism and is complete: the [Theming](../theming/) contract is published at `@fx/tx/theme`, its override at `@fx/tx/theme-override`, and the [Grid](../grid/) contract at `@fx/tx/grid`, each registry key renamed to the specifier its contract is published at, and every bundled consumer, the demo, and the capability tests import those contracts rather than restating them. [Change 0031](../../changes/0031-publish-the-dialogs-and-config-contracts.md), which publishes the [Dialogs](../dialogs/) and [Config](../config/) contracts, has not started; both keys remain bare and their consumers still restate those two shapes. +[Published Capability Contracts](#published-capability-contracts) is implemented for the capabilities [Change 0030](../../changes/0030-publish-bundled-capability-contracts.md) covers. That change established the mechanism and is complete: the [Theming](../theming/) contract is published at `@fx/tx/theme`, its override at `@fx/tx/theme-override`, and the [Grid](../grid/) contract at `@fx/tx/grid`, each registry key renamed to the specifier its contract is published at, and every bundled consumer, the demo, and the capability tests import those contracts rather than restating them. [Change 0031](../../changes/0031-publish-the-dialogs-and-config-contracts.md), which publishes the [Dialogs](../dialogs/) and [Config](../config/) contracts, is under way: the [Config](../config/) contract is published at `@fx/tx/config` with its key renamed to it and its consumers importing it, while the dialogs key remains bare and its consumers still restate that shape. [Minimal Clone Footprint](#minimal-clone-footprint) is implemented as specified in [Change 0019](../../changes/0019-reduce-marketplace-clone-footprint.md) for Git-sourced installs and updates, including the explicit complete-tree option, target-commit footprint re-derivation, complete-tree fallback, and transactional restoration. diff --git a/package.json b/package.json index ee60903..a50a235 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "./plugin": { "types": "./src/plugin.ts" }, + "./config": { + "types": "./plugins/config/contract.ts" + }, "./grid": { "types": "./plugins/grid/contract.ts" }, @@ -33,6 +36,7 @@ "dist/tx", "src/plugin.ts", "src/context.ts", + "plugins/config/contract.ts", "plugins/grid/contract.ts", "plugins/theme/contract.ts", "plugins/theme/override-contract.ts", diff --git a/plugins/config/contract.ts b/plugins/config/contract.ts new file mode 100644 index 0000000..05a6af8 --- /dev/null +++ b/plugins/config/contract.ts @@ -0,0 +1,58 @@ +/** + * The published config contract: everything a plugin needs to type the value + * it reads from the `@fx/tx/config` registry key, and nothing else. + * + * It is published at that same specifier, so the string a consumer passes to + * the read and the string it imports this file from are one string rather than + * two that have to be kept agreeing. A consumer that imported this instead of + * restating it learns about a contract that moved when it builds rather than + * when its command reaches for a member that is no longer there. + * + * It declares types alone — no imports, no statements, nothing that survives + * compilation — which is what lets it be published under a `types` condition + * with no runtime condition beside it. That is also what makes it reachable on + * its own: the only exported copy of this shape used to sit in + * `plugins/marketplace/configured.ts`, whose module pulls in the marketplace + * manager and, behind it, `node:child_process`, `node:fs`, and the + * marketplace's source and storage modules. Naming what a config value looks + * like never needed any of that. + * + * Publishing the vocabulary does not publish the store behind it: a consumer + * that can name `define`, `read`, and `write` still cannot persist anything + * without the capability. Nothing here reads a file, resolves a path, or knows + * where the document lives. + */ + +/** + * A consumer's own answer to whether a persisted value is the shape it + * expected. + * + * The capability never infers one. Values arrive from a document a user may + * hand-edit and a previous release may have written, so the guard is the whole + * of what makes a read safe, and it is the consumer's because only the + * consumer knows what its key means. Values are JSON encoded, so a guard + * should accept only what survives a round trip in the form its consumer + * expects. + */ +export type ConfigValidator = (value: unknown) => value is T; + +/** + * The value registered under `@fx/tx/config`: small JSON values persisted + * across invocations, scoped to the user rather than to a working directory. + * + * A key is declared before it is used rather than on first write, so a value + * written by an earlier release, or typed in by hand, is checked by the same + * guard the writer would have been held to. Keys are opaque strings compared + * by exact equality — nothing here trims, normalizes, parses, namespaces, or + * reserves one — and a second definition of the same key is rejected with the + * first guard left in force. + * + * A read of a key with no property answers `undefined`; a read of one whose + * persisted value fails its guard rejects, and affects no other key. A write + * whose value fails its guard rejects and changes nothing on disk. + */ +export type Config = { + define(key: string, isValid: ConfigValidator): void; + read(key: string): Promise; + write(key: string, value: T): Promise; +}; diff --git a/plugins/config/index.ts b/plugins/config/index.ts index a24bf73..2373969 100644 --- a/plugins/config/index.ts +++ b/plugins/config/index.ts @@ -1,13 +1,20 @@ import type { Plugin, PluginDefinition } from "@fx/tx/plugin"; +import type { Config, ConfigValidator } from "./contract.ts"; import { createConfigStorage, resolveConfigPath } from "./storage.ts"; -type ConfigValidator = (value: unknown) => value is T; +/** + * The provider is checked against the contract it publishes rather than + * against a shape declared here: it is imported type-only from beside this + * file, so the value registered under the key and the type a consumer imports + * from that same specifier cannot drift apart. + */ -type Config = { - define(key: string, isValid: ConfigValidator): void; - read(key: string): Promise; - write(key: string, value: T): Promise; -}; +/** The key the capability is registered under, which is also the specifier its + * contract is published at — one string rather than two that have to be kept + * agreeing, and one a package other than this one could not claim. The plugin + * keeps its own bare identity name: that names the plugin, and a capability + * provider claiming no command namespace has nothing to collide over. */ +const configKey = "@fx/tx/config"; function undefinedKey(key: string): Error { return new Error( @@ -67,7 +74,7 @@ const definition: PluginDefinition = Object.freeze({ }, }; - register("config", config); + register(configKey, config); }; }, }); diff --git a/plugins/marketplace/configured.ts b/plugins/marketplace/configured.ts index d3a3e56..3b52437 100644 --- a/plugins/marketplace/configured.ts +++ b/plugins/marketplace/configured.ts @@ -1,12 +1,16 @@ +import type { Config } from "@fx/tx/config"; import type { MarketplaceOperations, ResolvedMarketplace } from "./manager.ts"; -export type ConfigValidator = (value: unknown) => value is T; - -export interface Config { - define(key: string, isValid: ConfigValidator): void; - read(key: string): Promise; - write(key: string, value: T): Promise; -} +/** + * The config capability's shape arrives from the key it is read at rather than + * being restated here. This file used to hold the only exported copy of it, + * which meant anything wanting to name a `Config` took the marketplace manager + * — and `node:child_process`, `node:fs`, and the marketplace's source and + * storage modules behind it — along with the type. + * + * What stays is the marketplace's own: its config key, the value it keeps + * under it, and the lookup policy it applies when it reads the capability. + */ export interface ConfiguredMarketplace { readonly source: string; @@ -60,7 +64,7 @@ export function isConfiguredMarketplaceList( export function requireConfigCapability( registrations: (key: string) => readonly T[], ): Config { - const configs = registrations("config"); + const configs = registrations("@fx/tx/config"); if (configs.length !== 1) { throw new Error( `Expected exactly one config capability, but found ${configs.length}`, diff --git a/test/config-plugin.test.ts b/test/config-plugin.test.ts index 1e36135..e16a39d 100644 --- a/test/config-plugin.test.ts +++ b/test/config-plugin.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import type { Config } from "@fx/tx/config"; import configPlugin from "../plugins/config/index.ts"; import { createConfigStorage, @@ -11,13 +12,12 @@ import { main } from "../src/cli.ts"; import type { PluginDefinition } from "../src/plugin.ts"; import { captureContext, temporaryDirectory } from "./helpers.ts"; -type ConfigValidator = (value: unknown) => value is T; - -type Config = { - define(key: string, isValid: ConfigValidator): void; - read(key: string): Promise; - write(key: string, value: T): Promise; -}; +/** + * The capability is typed by the contract the package publishes at the key it + * is registered under, exactly as an external consumer types it. A local + * structural copy would compile whatever the provider did, so what these cases + * drive through it could drift from the published shape without one noticing. + */ const isNumber = (value: unknown): value is number => typeof value === "number"; const isString = (value: unknown): value is string => typeof value === "string"; @@ -39,10 +39,10 @@ async function obtainConfig(dataHome: string): Promise { load: () => ({ command, registrations }) => { - expect(registrations("config")).toEqual([]); + expect(registrations("@fx/tx/config")).toEqual([]); command((namespace) => namespace.action(() => { - const registered = registrations("config"); + const registered = registrations("@fx/tx/config"); expect(registered).toHaveLength(1); config = registered[0]; }), diff --git a/test/configured-marketplaces.test.ts b/test/configured-marketplaces.test.ts index 179b6f9..afbf538 100644 --- a/test/configured-marketplaces.test.ts +++ b/test/configured-marketplaces.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; +import type { Config } from "@fx/tx/config"; import { - type Config, type ConfiguredMarketplace, ConfiguredMarketplaceNameCollisionError, forgetConfiguredMarketplace, diff --git a/test/marketplace-plugin.test.ts b/test/marketplace-plugin.test.ts index baac30d..ddbbc81 100644 --- a/test/marketplace-plugin.test.ts +++ b/test/marketplace-plugin.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { mkdir, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import type { Config, ConfigValidator } from "@fx/tx/config"; import type { PluginDefinition } from "@fx/tx/plugin"; import { createMarketplacePlugin, @@ -105,9 +106,11 @@ class RecordingManager implements MarketplaceOperations { } } -type ConfigValidator = (value: unknown) => value is T; - -class RecordingConfig { +/** The config capability the marketplace reads, typed by the contract + * published at the key it is registered under rather than by a restatement of + * it: a double this suite drives is only worth as much as its agreement with + * the shape a real provider has to satisfy. */ +class RecordingConfig implements Config { readonly calls: unknown[][] = []; value: unknown = undefined; guard: ConfigValidator | undefined; @@ -143,7 +146,7 @@ function configProvider(config: RecordingConfig): PluginDefinition { load: () => ({ register }) => - register("config", config), + register("@fx/tx/config", config), }; } diff --git a/test/plugin-boundary.test.ts b/test/plugin-boundary.test.ts index 2b9b93b..83f54c8 100644 --- a/test/plugin-boundary.test.ts +++ b/test/plugin-boundary.test.ts @@ -61,6 +61,15 @@ const publishedSpecifiers: readonly string[] = Object.keys( ).map((subpath) => `${packageMetadata.name}${subpath.slice(1)}`); const publishedSpecifierSet = new Set(publishedSpecifiers); +/** Each published specifier against the file its `types` condition points at, + * which is where resolving that specifier starts. */ +const publishedTargets = new Map( + Object.entries(packageMetadata.exports).map(([subpath, condition]) => [ + `${packageMetadata.name}${subpath.slice(1)}`, + join(repositoryRoot, condition.types), + ]), +); + function isWithin(root: string, candidate: string): boolean { const relation = relative(root, candidate); return ( @@ -432,6 +441,46 @@ async function bundledPluginEntries(root = pluginsRoot): Promise { return entries.sort(); } +/** + * Every module a consumer compiles when it imports a published specifier: the + * file the `exports` map points at, and the transitive closure of what that + * file imports, each named relative to the repository root. + * + * Both kinds of specifier a contract may carry are followed — a relative path + * beside it, and another published specifier, which is the one way a contract + * names another's vocabulary — so a contract that grew an import into an + * implementation would widen this rather than escape it. An unresolvable + * specifier fails rather than silently ending the walk short. + */ +async function publishedContractClosure( + program: Program, + checker: Checker, + specifier: string, +): Promise { + const entry = publishedTargets.get(specifier); + if (!entry) throw new Error(`${specifier} is not published`); + const reached = new Set(); + const pending = [await realpath(entry)]; + + for (let path = pending.pop(); path !== undefined; path = pending.pop()) { + if (reached.has(path)) continue; + reached.add(path); + const sourceFile = await requiredSourceFile(program, path); + for (const literal of await moduleSpecifiers(sourceFile, checker)) { + const imported = literal.text.startsWith(".") + ? await resolveRelativeModule(path, literal.text) + : publishedTargets.get(literal.text); + if (!imported) { + throw new Error( + `${relative(repositoryRoot, path)} imports unresolvable ${literal.text}`, + ); + } + pending.push(await realpath(imported)); + } + } + return [...reached].map((path) => relative(repositoryRoot, path)).sort(); +} + async function withProgram( configPath: string, operation: (program: Program, checker: Checker) => Promise, @@ -482,6 +531,7 @@ test("every published subpath is a types condition over a module with no runtime // would leave the whole boundary passing vacuously. expect(publishedSpecifiers).toEqual([ "@fx/tx/plugin", + "@fx/tx/config", "@fx/tx/grid", "@fx/tx/theme", "@fx/tx/theme-override", @@ -510,6 +560,37 @@ test("every published subpath is a types condition over a module with no runtime expect(emitted).toEqual(targets.map((target) => [target, ""] as const)); }); +test("importing the published config contract compiles no implementation", async () => { + // Named rather than left to the equality below, and checked to exist so the + // absence assertions cannot pass by naming files that are not there. The + // config shape used to be exported only from + // `plugins/marketplace/configured.ts`, so naming a config value pulled in + // the marketplace manager and, behind it, `node:child_process`, `node:fs`, + // the source resolver, and the install storage — none of which a consumer + // that only wants to say what a persisted value looks like needs. + const implementation = ["manager.ts", "source.ts", "storage.ts"].map( + (module) => join("plugins", "marketplace", module), + ); + + await withProgram( + join(repositoryRoot, "tsconfig.json"), + async (program, checker) => { + const reached = await publishedContractClosure( + program, + checker, + "@fx/tx/config", + ); + expect(reached).toEqual([join("plugins", "config", "contract.ts")]); + for (const module of implementation) { + expect(await Bun.file(join(repositoryRoot, module)).exists()).toBe( + true, + ); + expect(reached).not.toContain(module); + } + }, + ); +}); + test("bundled plugin entry discovery supports every TypeScript module extension", async () => { const root = await mkdtemp(join(tmpdir(), "tx-plugin-entries-")); const fixtures = [ diff --git a/test/plugin-consumer.test.ts b/test/plugin-consumer.test.ts index 49fee86..70b48f2 100644 --- a/test/plugin-consumer.test.ts +++ b/test/plugin-consumer.test.ts @@ -53,6 +53,7 @@ test("the packed package installs a standalone CLI and every published contract" "README.md", "dist/tx", "package.json", + "plugins/config/contract.ts", "plugins/grid/contract.ts", "plugins/theme/contract.ts", "plugins/theme/override-contract.ts", @@ -88,6 +89,7 @@ test("the packed package installs a standalone CLI and every published contract" writeFile( join(consumerRoot, "plugin.ts"), `import type { Command, Plugin } from "@fx/tx/plugin"; +import type { Config, ConfigValidator } from "@fx/tx/config"; import type { Grid, GridSelectRow, Row } from "@fx/tx/grid"; import type { Appearance, Hue, Theme, ThemeVariable, Theming } from "@fx/tx/theme"; import type { ThemeOverride } from "@fx/tx/theme-override"; @@ -111,6 +113,12 @@ const override: ThemeOverride = { [emphasis]: { bold: true, hue: loud } }; // closure the packed file list above stands for: a cell naming a theme // variable type checks here only if the tarball carries both contracts. const cells: Row = ["greeter", { text: "ready", variable: emphasis }]; + +// A guard typed by the published contract rather than by a restated function +// type. The consumer owns what its own key means; the capability only ever +// asks it this. +const isGreeting: ConfigValidator = (value): value is string => + typeof value === "string"; const row: GridSelectRow = { cells, value: "greeter", @@ -144,6 +152,14 @@ const plugin: Plugin = ({ command, context, register, registrations }) => { const grid: Grid | undefined = registrations("@fx/tx/grid")[0]; grid?.print({ stream: context.stdout, rows: [cells] }); await grid?.select({ message: greeting, rows: [row] }); + // The config capability, read and typed the same way. Its contract is + // reachable on its own: nothing behind this import is a store, a path, + // or the marketplace plugin that used to be the only place the shape + // was exported from. + const config: Config | undefined = + registrations("@fx/tx/config")[0]; + config?.define("greeting", isGreeting); + await config?.write("greeting", greeting); }); }); }; @@ -172,7 +188,7 @@ export default plugin; ), // Exactly the settings [the plugin guide](../docs/manual/plugins.md) // tells a consumer of the published subpaths it needs, over the module - // that imports all four of them and nothing else: a `moduleResolution` + // that imports every one of them and nothing else: a `moduleResolution` // that reads an `exports` map, with no `allowImportingTsExtensions` // beside it. Requiring that option would force `noEmit` or // `emitDeclarationOnly` on every consumer, so a raw-TypeScript `types`