fix(spec,core): PluginSchema requires staticPath/slug for type "ui", and Plugin derives its metadata keys from PluginDefinition (#16334) - #16598
Conversation
…derives from PluginDefinition Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
…N_UI_REQUIRED_KEY_MISSING Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
📓 Docs Drift CheckThis PR changes 3 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 140 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cbb111ad171987ae625f61181f5d31647a06ed3b && git checkout cbb111ad171987ae625f61181f5d31647a06ed3b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ac9376a7428996bbfa9807cea823ae52c1879a1e 8dc4a33040aeff2b2e173f318225f284af7a9a77 && git checkout -B drift-repro ac9376a7428996bbfa9807cea823ae52c1879a1e && git merge --no-ff 8dc4a33040aeff2b2e173f318225f284af7a9a77
node scripts/docs-audit/affected-docs.mjs --json ac9376a7428996bbfa9807cea823ae52c1879a1e
|
Contract review at
|
Fixes #16334
Spec half of the plugin-contract enforcement ruling (decision batch #58, 2026-09-06). #16049 (the engine half, landed as #16363) is not addressed here beyond what its boot path already surfaces: this PR makes
PluginSchemasay what its prose already promised, derives core'sPluginmetadata keys from the schema, and touches nothing onkernel.use().Base. Branched from
origin/mainat90e7e6de114af2586da89dd417101b24db716d39(PR #16554, the one-row-per-kind api-surface generator). Verified before generating anything:git merge-base --is-ancestor 90e7e6de114af2586da89dd417101b24db716d39 HEADexit 0 (HEAD was that very commit). The regenerated shards are therefore in the post-#16554 format; export total 5411 before.What changed
1.
packages/spec/src/kernel/plugin.zod.ts—PluginSchema.superRefinestaticPathandslugwere described as(Required for type="ui")and declared.optional(). AsuperRefinenow refuses atype: 'ui'plugin that omits either, or spells itundefined: onecustomissue per missing key,pathnaming the key.PLUGIN_UI_REQUIRED_KEY_MISSING(the only api-surface row this PR adds). Carried in two places — at the HEAD of the issuemessage, the channel the boot path surfaces today (PluginLoader.validatePluginContractre-emits the first issue'spathandmessage, andObjectKernel.use()keeps only the message), and on the issue'sparams.codewithparams.key, zod's structured slot, which no reader consumes yet.type, parses with neither key; a PRESENT value is judged by its own declaration (slugregex unchanged;staticPathstill any string, empty string included — unchanged, not widened)..describe()strings are byte-identical, socheck:docsreads the same prose, which is now true.2.
packages/core/src/types.ts—Plugin extends PluginDefinitionPluginSchemakeys (id,type,staticPath,slug,default,version,description,author,homepage) are inherited fromPluginDefinition(z.inputofPluginSchema), not restated;typeandversionare no longer spelled on the interface. Runtime-only members (name, the ADR-0116 ordering declarations,init/start/destroy) stay on the interface.PluginTypeis unchanged as an exported alias.packages/rest/src/plugin-type-closed-set.pin.test.ts, which compiles against core's built.d.ts: the inheritedPlugin['type'], non-nullable, is type-equal toPluginType— added beside the existing parity pins; all four green.PluginMetadata extends Plugin { version: string }still narrows the inherited optionalversion; core, rest and hono typecheck green (below).3. The
types.tscomment the card cites (:127, "refuses it at parse")packages/core/src/types.tson this base. feat(core): enforce PluginSchema at kernel.use() (#16049) #16363 already rewrote it — the docblock reads "since [finding] PluginSchema has zero runtime callers — the boot path validates name, init and semver only, so the declared plugin contract is never enforced #16049kernel.use()REFUSES it at boot" and carries a paragraph saying the "at parse" wording was measured false. Piece 3 of the card was therefore already satisfied onmain; I did not edit whatever sat at line 127. As part of piece 2 thetypedocblock was folded into the interface docblock (the key is inherited now), its [finding] PluginSchema has zero runtime callers — the boot path validates name, init and semver only, so the declared plugin contract is never enforced #16049 history preserved and the spec:PluginSchemamakesstaticPath/slugreally required fortype: "ui"(superRefine), and core'sPlugininterface derives fromPluginDefinition(spec half of #16049) #16334 requirement added.packages/rest/src/plugin-type-closed-set.pin.test.tsline 7, a historical header sentence, not core's. Left alone; reported as an observation.Fixture triage — the tightened schema now bites at
kernel.use()packages/core/src/plugin-contract-enforcement.test.ts: theuifixtures (modern-ui,bad-slug/good-slug, the legacy twin) now carrystaticPath+slug, so each refusal stays attributable to its own key. New group F pins the boot path SURFACING the spec code (at 'staticPath'/at 'slug'plusPLUGIN_UI_REQUIRED_KEY_MISSINGinside thePLUGIN_CONTRACT_VIOLATIONenvelope), the calibration twin, thestandardscope control, and that the two keys are members ofPlugin. TheFixturewidening alias is now a plainPlugin.packages/core/src/plugin-type-closed-set.test.ts: the{ type }fixture getsstaticPath+slugforuionly (minimalLegal), so the pin keeps asking for a FULL parse per member — it guards the value set — without being re-read as a pin on the two keys.packages/plugins/plugin-hono-server/src/ui-plugin-auto-discovery.pin.test.ts: two pins pinned exactly the branches the contract closes — "derives the slug when none is declared" and "auitype with no staticPath mounts nothing". Both inverted into refusal pins atkernel.use()(the object never reacheskernel.plugins); theNON_UI_TYPEScontrol still proves the harness can produce an empty result.hono-plugin.tsitself is untouched; itsslug || name.split('/').pop()fallback is now unreachable through the kernel — an observation, not fixed here.Restatements kept true — docblock and table only, no behaviour
packages/core/src/plugin-loader.ts, thevalidatePluginContractdocblock: its "what this refuses" enumeration asks to be restated everywhere it is restated; one paragraph added for the conditionaluirequirement. No code change on the boot path.packages/runtime/src/dispatcher-error-vocabulary.ts: one row,PLUGIN_UI_REQUIRED_KEY_MISSING,door: 'none'/boot-refusal, shapeobjlitconst— the scanner sees theparams: { code }site inplugin.zod.tsand requires a verdict.check:dispatcher-error-vocabulary: 74 sites found, 74 classified.The
defaultclause — deliberately not refinedThe card allows a per-object refinement only if cheap; the documented semantics ("only one
uiplugin can be default") are cross-object and stay the kernel's job. The only per-object reading available (default: trueimpliestype: 'ui') is not what the prose promises, so nothing was added. The hono block readsdefaultinside itsuibranch only.Acceptance
uiplugin withoutstaticPathorslugfailsPluginSchema.safeParse—packages/spec/src/kernel/plugin-ui-required-keys.test.tsgroup A (5 cases) plus boot-path group F in core; negative control below.Pluginmetadata keys are derived, not restated —Plugin extends PluginDefinition; thetype/versiondeclarations are gone from the interface; the rest pin that the inheritedtypeequalsPluginTypeis green.pnpm --filter @objectstack/spec check:generated --fixregenerated exactly the two it proved stale (api-surface/kernel.json,export-origins/kernel.json);check:declaration-mapreported current before and after, so there was nothing to regenerate there; clean re-run: "All 15 generated artifacts are up to date.".changeset/plugin-schema-ui-required-keys.md, spec and coreminor(clause-② card: nopatchon a package that grew), with the ADR-0087not-required (no-migration-prescription)disposition;check:adr-0087-registration,check:changeset-no-major,check:empty-changesetgreen.api-surface diff shape
./kernel: +1 row,PLUGIN_UI_REQUIRED_KEY_MISSING (const); 0 removed, 0 modified. Every other entry point: 0 / 0 / 0.export-origins/kernel.json: +1 entry for the same symbol. No existing row removed or modified anywhere.Evidence
Every build and test run went through
scripts/pm/os-verify-lock.sh; each reading below is the wrapper's ownVERDICT command-exitline or the gate's own printed verdict, captured by redirect-then-$?.pnpm --filter '@objectstack/core...' build(spec, its deps, core) — VERDICT command-exit 0. Rest and hono closures viaturbo build --filter='@objectstack/rest^...' --filter='@objectstack/plugin-hono-server^...'— 24 / 24 successful.--maxWorkers=2: spec 4 files / 31 passed (the new pin file: 16 cases); core 3 files / 48 passed (plugin-contract-enforcement,plugin-type-closed-set,plugin-loader); honoui-plugin-auto-discovery.pin14 passed, 1 todo; restplugin-type-closed-set.pin4 passed.pnpm --filter X typecheck, each includingcheck:test-typecheck): spec, core, rest, plugin-hono-server — all exit 0. Direction: core is the producer of the widenedPlugin; rest and hono are DOWNSTREAM consumers whose pins read core's built.d.tsor source. The remaining downstream consumers ofPlugin(turbo lists 76 affected packages off the base) are declared to CI's requiredTypeScript Type Checkjob — the repo-wide sweep is CI's run, not reproduced locally.superRefineguard deleted from the working copy (export kept, so the pin file still links) — mutation proven on disk by.superRefine((plugin, ctx)count 1 → 0,ctx.addIssue1 → 0, blob5e581ef→df4bcbb, not HEAD's — the spec pin file reports exactly5 failed | 11 passed: every group-A refusal case red, every B / C calibration and scope case green. Restore bygit checkout HEAD -- ABSOLUTE_PATHunder an EXIT / INT / TERM trap, proven bygit diff HEAD --quietexit 0, blob hash back to5e581ef, porcelain empty. No build ran in the mutation window: the pin file reads./plugin.zodfrom source, so theauthorable-surface/api.jsonconfound never applied. A first attempt of this leg was VOID by my own anchor (I counted the wordsuperRefine, which the docblock also contains); the script refused to read a result and restored; re-anchored on the code spelling and re-run as above.check:generated(15 / 15 up to date),check:dispatcher-error-vocabulary,check:error-code-casing(5757 files clean),check:nul-bytesplus a control-byte scan of every touched file (no hits),check:test-source-alias,check:cross-package-test-inputs,check:type-check-coverage(76 / 80 OK),check:closing-keyword-parity, speccheck:dual-source-exportsandcheck:exported-any, docs-auditcheck-affected-docsandcheck-drift-comment— all green.check:type-check-debt— exit 3PREREQUISITE NOT MET(seven ledgered packages' deps are unbuilt here; CI builds the full closure first). Not a pass, not a finding.scripts/pm/dispatch-gates.mjs --commands(repo-wide sweeps such ascheck:dts-closure,check:published-files, the census gates) are declared to CI'sLint & Repo Gates.Open question for the reviewer — not blocking
params.codeis the structured channel; the boot path today surfaces the code only through the message head. Whether the loader should readparams.codeand stamp a more specificerr.codethanPLUGIN_CONTRACT_VIOLATIONis a decision on the boot path's side that I could not verify from here; the message-head contract works unchanged either way.Observations, not fixed here — filed and linked from the report comment on #16334
packages/plugins/plugin-hono-server/src/hono-plugin.ts: theslug || name.split('/').pop()derivation and the&& plugin.staticPathguard are unreachable throughkernel.use()after this PR; the legacyui-pluginarm was already unreachable after feat(core): enforce PluginSchema at kernel.use() (#16049) #16363. The pin file'sit.todofor case C still narrates the pre-[finding] PluginSchema has zero runtime callers — the boot path validates name, init and semver only, so the declared plugin contract is never enforced #16049 boot path.packages/core/src/plugin-type-closed-set.test.tsandpackages/rest/src/plugin-type-closed-set.pin.test.tssay core "has notypecheckscript"; it has one now. The rest header also carries the "refused it at parse" wording the card attributes to core.#16554, #16363, #15638 and #16050 are context only; none is addressed here.
🤖 Generated with Claude Code
https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
Generated by Claude Code