Skip to content

Commit d8024f0

Browse files
os-muskclaude
andauthored
feat(core): narrow Plugin.type to the closed PluginType set the spec declares (#13925) (#14608)
* feat(core): narrow Plugin.type to the closed PluginType set the spec declares `Plugin.type` was `string` on a published surface while the authority — `PluginSchema.type` in @objectstack/spec, `z.enum(['standard', ...CORE_PLUGIN_TYPES])` — was a closed set enforced only at parse; the TSDoc beside it carried the enumeration as prose, and prose drifted. Maintainer ruling 2026-09-01: core aligns to the declared contract. - `PluginType = 'standard' | (typeof CORE_PLUGIN_TYPES)[number]`, derived from the spec's constant over the published `@objectstack/spec/kernel` subpath (already a dependency; zero new edges), exported from core. - `Plugin.type?: PluginType`; the docblock points at the type instead of re-listing members. - Runtime parity pin in core: the Zod enum's options equal the union's members in declared order; members parse, non-members are refused with `invalid_value` at `type`. - Compile-time pin in packages/rest (its test-typecheck program reads core's built .d.ts; core is a type-check DEBT package, so a `@ts-expect-error` there would be a phantom): non-member literal, `string`-typed value and PluginMetadata each refused; every member and type-level equality with the spec-derived shape as positive controls. - Changeset: @objectstack/core minor with the BREAKING banner and the ADR-0087 no-migration-prescription disposition. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * fix(census): pin the plugin-class `type` initializers to their literal so they satisfy PluginType The workspace census under the narrowing (tsc per package, TS2416 "Property 'type' in type 'X' is not assignable to the same property in base type 'Plugin'") names seventeen `implements Plugin` classes whose `type = '<member>'` initializer widened to `string`. Each becomes `type = '<member>' as const` — the spelling three sibling services already use — and the downstream TS2345s at their `kernel.use()` sites clear with it. The docs copy of the interface in content/docs/plugins/anatomy.mdx follows the type. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * fix(census): pin the remaining plugin-class `type` initializers named by the build and test-typecheck programs Second census pass (turbo build --continue=always over ./packages/*, then tsc per package on the src and tsconfig.test.json programs): twenty-three more `implements Plugin` classes — plugins, triggers, services, the http-conformance node adapter, and four test fixtures compiled by their package's `check:test-typecheck` ratchet — each TS2416 at its `type = '<member>'` initializer. Same correction: `as const`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * fix(census): spell the objectql integration mocks' plugin type as the member they are objectql's test-typecheck ratchet (EXACT, per-file) named src/plugin.integration.test.ts: TS2322 "Type '"metadata"' is not assignable to type 'PluginType | undefined'" (x2) and the same for '"test"'. The three kernel.use() mocks register a `metadata` service and nothing reads their `type`; neither spelling was ever a plugin type (the Zod gate refuses both), so the member they are is `standard`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 44813ba commit d8024f0

46 files changed

Lines changed: 272 additions & 54 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
feat(core): `Plugin.type` is the closed set the spec declares — a `PluginType` derived from `CORE_PLUGIN_TYPES` (#13925)
6+
7+
**BREAKING** accept-set narrowing on a published type, shipped as `minor`
8+
under the repo's launch-window convention for breaking changes. `Plugin.type`
9+
(and, through it, `PluginMetadata.type`) was declared `string`, so nothing
10+
type-checked a plugin author against the eight values the platform accepts —
11+
the TSDoc beside it carried the whole enumeration as prose, and prose drifted.
12+
Maintainer ruling 2026-09-01: the Zod enum in `@objectstack/spec`
13+
(`PluginSchema.type`, declared `z.enum(['standard', ...CORE_PLUGIN_TYPES])`)
14+
is the authority and the contract was always a closed set; the `string` in
15+
core was the mismatch, and narrowing it is core aligning to the declared
16+
contract rather than a new restriction. Paid in one stroke — no warning window.
17+
18+
What changes:
19+
20+
- `@objectstack/core` now exports `PluginType`, derived from the spec's own
21+
constant: `'standard' | (typeof CORE_PLUGIN_TYPES)[number]` — today
22+
`standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`.
23+
It is not re-spelled in core, so the compiler's accept set and the Zod gate's
24+
cannot drift apart; a runtime parity test pins the two against each other.
25+
- `Plugin.type` is typed `PluginType`. A literal outside the set, or a value
26+
typed `string`, no longer compiles. Runtime behaviour is unchanged: the Zod
27+
gate refused such a value before and still does (`invalid_value` at `type`).
28+
29+
**Migration.** A plugin that declares one of the eight members needs no change.
30+
A plugin that assigned a computed or `string`-typed value narrows it at the
31+
producer — declare the literal, or type the variable `PluginType` — rather than
32+
casting at the assignment; a value that was never one of the eight was never a
33+
valid plugin type and was already refused at parse time.
34+
35+
<!-- adr-0087: not-required (no-migration-prescription) A TypeScript narrowing on a published runtime interface, aligning `packages/core` to the accept set `packages/spec` already declared. No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed — `CORE_PLUGIN_TYPES` and `PluginSchema.type` are read, not changed — so `objectstack migrate meta` has nothing to rewrite and there is no tombstone to mint. The channel that reaches an affected author is the compiler, at the assignment, which is more precise than a ledger line; which member a formerly `string`-typed value should become is authoring intent no migration entry can decide. The in-repo census under the workspace typecheck is recorded on the PR. -->

content/docs/plugins/anatomy.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,12 @@ export interface Plugin {
118118
version?: string;
119119

120120
/**
121-
* Plugin Type (Optional)
122-
* One of: standard, ui, driver, server, app, theme, agent, objectql.
121+
* Plugin Type (Optional) — a `PluginType`, the closed set the spec declares
122+
* (`CORE_PLUGIN_TYPES` plus `standard`): standard, ui, driver, server, app,
123+
* theme, agent, objectql. A value outside it does not compile.
123124
* @default 'standard'
124125
*/
125-
type?: string;
126+
type?: PluginType;
126127

127128
/**
128129
* Dependencies (Optional)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// RUNTIME parity pin for the closed `Plugin.type` set (#13925).
4+
//
5+
// `Plugin.type` in `./types.ts` is a `PluginType` DERIVED from the spec's
6+
// `CORE_PLUGIN_TYPES` constant (`'standard' | (typeof CORE_PLUGIN_TYPES)[number]`),
7+
// and `PluginSchema.type` in `@objectstack/spec` is declared as
8+
// `z.enum(['standard', ...CORE_PLUGIN_TYPES])`. Both sides read the same
9+
// constant, so the one way they can still drift is the Zod enum's literal
10+
// prefix changing shape (a member added to the enum but not to the constant,
11+
// or `'standard'` renamed) — which is exactly what the first case below reads
12+
// off the schema at runtime, member by member and in declared order.
13+
//
14+
// The COMPILE-TIME half — a non-member literal or a `string`-typed value no
15+
// longer type-checks against the PUBLISHED `Plugin.type` — lives in
16+
// `packages/rest/src/plugin-type-closed-set.pin.test.ts`, deliberately NOT
17+
// here: `@objectstack/core` has no `typecheck` script (type-check DEBT ledger
18+
// entry), so a `@ts-expect-error` in this package is a phantom pin no tsc
19+
// program a `typecheck` script runs would ever evaluate —
20+
// `check:type-check-coverage` refuses exactly that. The rest package's
21+
// `tsconfig.test.json` program is compiled by its `typecheck` script and reads
22+
// core's BUILT `.d.ts`, so the pin over there guards the published contract.
23+
24+
import { describe, it, expect } from 'vitest';
25+
import { CORE_PLUGIN_TYPES, PluginSchema } from '@objectstack/spec/kernel';
26+
import type { PluginType } from './types.js';
27+
28+
/**
29+
* The TypeScript union's members, spelled by the same derivation `PluginType`
30+
* uses. `satisfies` makes each entry a member of the union; the schema
31+
* comparison below makes the list COMPLETE against the Zod enum.
32+
*/
33+
const UNION_MEMBERS = ['standard', ...CORE_PLUGIN_TYPES] as const satisfies readonly PluginType[];
34+
35+
/**
36+
* Walks the wrapper chain `PluginSchema.shape.type` carries
37+
* (`optional` → `default` → `enum`, measured at 9c7d9d4b3) down to the enum's
38+
* declared options. Throws rather than returning `[]` when no enum is found,
39+
* so a re-shaped key cannot read as "zero members, all equal".
40+
*/
41+
function zodEnumOptions(schema: unknown): readonly string[] {
42+
let node = schema as { options?: readonly string[]; def?: { innerType?: unknown } } | undefined;
43+
while (node) {
44+
if (Array.isArray(node.options)) return node.options;
45+
node = node.def?.innerType as typeof node;
46+
}
47+
throw new Error('PluginSchema.shape.type carries no z.enum in its wrapper chain');
48+
}
49+
50+
describe('Plugin.type closed set — runtime parity with the spec enum (#13925)', () => {
51+
it('the Zod enum enumerates exactly the TypeScript union, in declared order', () => {
52+
const options = zodEnumOptions(PluginSchema.shape.type);
53+
expect(options).toEqual([...UNION_MEMBERS]);
54+
// Positive control on the instrument: the list is populated and the
55+
// spec constant is the seven-member set the union is derived from.
56+
expect(options).toHaveLength(8);
57+
expect(CORE_PLUGIN_TYPES).toHaveLength(7);
58+
});
59+
60+
it('every union member parses through PluginSchema', () => {
61+
for (const type of UNION_MEMBERS) {
62+
const result = PluginSchema.safeParse({ type });
63+
expect(result.success, `PluginSchema refused union member '${type}'`).toBe(true);
64+
}
65+
});
66+
67+
it('a non-member is refused by PluginSchema with invalid_value at ["type"]', () => {
68+
// `'plugin'` / `'module'` are PACKAGE manifest types (ManifestSchema.type),
69+
// never plugin types; `'ui-plugin'` is the spelling a stale describe()
70+
// string still uses; the casing variant guards against a lax comparator.
71+
for (const type of ['bogus', 'ui-plugin', 'plugin', 'module', 'Standard']) {
72+
const result = PluginSchema.safeParse({ type });
73+
expect(result.success, `PluginSchema accepted non-member '${type}'`).toBe(false);
74+
if (!result.success) {
75+
expect(result.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([
76+
['invalid_value', 'type'],
77+
]);
78+
}
79+
}
80+
});
81+
});

packages/core/src/types.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { ObjectKernel } from './kernel.js';
44
import type { Logger, LifecycleEventName } from '@objectstack/spec/contracts';
5+
import type { CORE_PLUGIN_TYPES } from '@objectstack/spec/kernel';
56

67
/**
78
* PluginContext - Runtime context available to plugins
@@ -91,6 +92,18 @@ export interface PluginContext {
9192
getKernel(): ObjectKernel;
9293
}
9394

95+
/**
96+
* The closed set of plugin types (#13925): `'standard'` plus the seven
97+
* `CORE_PLUGIN_TYPES` members, in exactly the shape `PluginSchema.type`
98+
* declares in `@objectstack/spec` (`kernel/plugin.zod.ts`:
99+
* `z.enum(['standard', ...CORE_PLUGIN_TYPES])`). Derived from the spec's own
100+
* constant rather than re-spelled here, so the compiler's accept set and the
101+
* Zod gate's cannot drift apart: `plugin-type-closed-set.test.ts` pins the
102+
* parity at runtime, and `packages/rest`'s `plugin-type-closed-set.pin.test.ts`
103+
* pins the published `.d.ts` at compile time.
104+
*/
105+
export type PluginType = 'standard' | (typeof CORE_PLUGIN_TYPES)[number];
106+
94107
/**
95108
* Plugin Interface
96109
*
@@ -108,16 +121,13 @@ export interface Plugin {
108121
version?: string;
109122

110123
/**
111-
* Plugin type (standard, ui, driver, server, app, theme, agent, objectql)
112-
*
113-
* Authoritative set: `CORE_PLUGIN_TYPES` in `@objectstack/spec`
114-
* (`kernel/plugin.zod.ts`), which `PluginSchema.type` enumerates as
115-
* `z.enum(['standard', ...CORE_PLUGIN_TYPES])`. This field is typed
116-
* `string`, so nothing type-checks an author against the list above —
117-
* keep the two in step when the declared set changes.
124+
* Plugin type categorisation for runtime behaviour — a {@link PluginType},
125+
* the closed set the spec declares. The enumeration lives on that type
126+
* (derived from `CORE_PLUGIN_TYPES`), not in this comment: a value outside
127+
* it no longer type-checks, and `PluginSchema.type` refuses it at parse.
118128
* @default 'standard'
119129
*/
120-
type?: string;
130+
type?: PluginType;
121131

122132
/**
123133
* List of other plugin names that this plugin depends on.

packages/metadata/src/plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ export interface MetadataPluginOptions {
262262

263263
export class MetadataPlugin implements Plugin {
264264
name = 'com.objectstack.metadata';
265-
type = 'standard';
265+
type = 'standard' as const;
266266
version = '1.0.0';
267267
/**
268268
* Services init() UNCONDITIONALLY registers (ADR-0116, #4131) — lets the

packages/objectql/src/plugin.integration.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
134134

135135
await kernel.use({
136136
name: 'mock-metadata',
137-
type: 'test',
137+
type: 'standard',
138138
version: '1.0.0',
139139
init: async (ctx) => {
140140
ctx.registerService('metadata', mockMetadataService);
@@ -320,7 +320,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
320320
// Register mock metadata service BEFORE ObjectQL
321321
await kernel.use({
322322
name: 'mock-metadata',
323-
type: 'metadata',
323+
type: 'standard',
324324
version: '1.0.0',
325325
init: async (ctx) => {
326326
ctx.registerService('metadata', mockMetadataService);
@@ -368,7 +368,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
368368

369369
await kernel.use({
370370
name: 'mock-metadata',
371-
type: 'metadata',
371+
type: 'standard',
372372
version: '1.0.0',
373373
init: async (ctx) => {
374374
ctx.registerService('metadata', mockMetadataService);

packages/objectql/src/plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ export interface ObjectQLPluginOptions {
203203

204204
export class ObjectQLPlugin implements Plugin {
205205
name = 'com.objectstack.engine.objectql';
206-
type = 'objectql';
206+
type = 'objectql' as const;
207207
version = '1.0.0';
208208
/**
209209
* Services init() UNCONDITIONALLY registers (ADR-0116, #4131) — lets the

packages/plugins/knowledge-memory/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ export interface KnowledgeMemoryPluginOptions {
207207
export class KnowledgeMemoryPlugin implements Plugin {
208208
name = 'com.objectstack.plugin.knowledge-memory';
209209
version = '0.1.0';
210-
type = 'standard';
210+
type = 'standard' as const;
211211

212212
private readonly adapter: KnowledgeMemoryAdapter;
213213

packages/plugins/knowledge-ragflow/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ export interface KnowledgeRagflowPluginOptions extends KnowledgeRagflowAdapterOp
277277
export class KnowledgeRagflowPlugin implements Plugin {
278278
name = 'com.objectstack.plugin.knowledge-ragflow';
279279
version = '0.1.0';
280-
type = 'standard';
280+
type = 'standard' as const;
281281

282282
private readonly adapter: KnowledgeRagflowAdapter;
283283

packages/plugins/plugin-approvals/src/approvals-plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export interface ApprovalsPluginOptions {
9292
export class ApprovalsServicePlugin implements Plugin {
9393
name = 'com.objectstack.service.approvals';
9494
version = '1.0.0';
95-
type = 'standard';
95+
type = 'standard' as const;
9696
dependencies = ['com.objectstack.engine.objectql'];
9797

9898
private readonly options: ApprovalsPluginOptions;

0 commit comments

Comments
 (0)