Repository navigation
bug(create-plugin): buildLicenseFile's MIT fallback lets the emitted manifest and LICENSE text disagree, against its own docblock #8892
Description
Found while implementing objectui#8786 (director-seat follow-up card, item 3 of its body). ⛔ Deliberately NOT carried in that card's PR — the dispatching seat ruled it in scope only if that diff falsified the docblock, and it does not touch either function.
The declared contract
packages/create-plugin/src/templates.ts, the docblock over buildLicenseFile, says the MIT fallback exists so that one state is unreachable — quoted verbatim:
Falls back to
DEFAULT_LICENSE_ID's text rather than throwing or emitting nothing if an unoffered id ever reaches here.resolveLicenseIdalready makes that unreachable from the CLI; the point of the fallback is that the ONE state this card exists to remove — a manifest claiming a licence with no text beside it — must not be reachable by any route, including a future caller that buildsPluginTemplateVarsby hand. It is not a lenient alias for bad input: the manifest is written from the same resolved id, so the two still agree.
Why the last sentence is false
buildLicenseFile totalises its input (findLicense(vars.license) ?? findLicense(DEFAULT_LICENSE_ID)), but buildPackageJson writes vars.license verbatim into the emitted manifest. Nothing resolves it a second time. So the two do NOT come from the same resolved id, and the named caller — one that builds PluginTemplateVars by hand — reaches exactly the state the docblock says is unreachable:
const files = buildPluginFiles({ /* … */ license: 'NOT-A-LICENCE' });
// files['package.json'] declares "license": "NOT-A-LICENCE"
// files['LICENSE'] carries the MIT textA manifest claiming a licence beside text for a different one is the same defect class objectui#8041 exists to remove, arrived at by the route the docblock names.
Boundary — this is not user-visible today
@object-ui/create-plugin is bin-only (no exports, no main), and the CLI is the only caller: it passes resolveLicenseId(answers.license), which is total onto the four offered ids. So there is no route from a real scaffold to a disagreeing pair. What is wrong is the contract: a docblock that states an invariant the code does not hold, in the one file whose whole subject is that the manifest and the text are one decision read twice.
Directions (⛔ not ruled — the maintainer's or the director seat's call)
- Make
buildPackageJsonwrite the same resolved id (resolveLicenseId(vars.license)), so the docblock becomes true and the fallback is genuinely total. - Make
buildLicenseFilethrow on an unoffered id instead of falling back, so the disagreement is impossible rather than silent — the docblock then needs rewriting, since it argues for the fallback. - Correct the docblock to what the code does: the fallback keeps a LICENSE from being missing, not from disagreeing.
Direction 1 is the smallest and keeps both the fallback and the sentence. Direction 2 is the contract-first shape (reject bad input at the boundary rather than coercing it) but changes a published function's failure mode.
⛔ Out of scope here: the licence set itself (four licences, no free text — the maintainer's call), and objectui#8778 (copyright holder in the four emitted source headers), which stays separate.
Filed by the domain:ui dev seat while working objectui#8786; not labelled, for PM triage.
Generated by Claude Code
Activity
os-litant commentedon Sep 10, 2026
Triage:
findingbehaviour admitted as class (b) — it violates a contract the code states about itself. Lands inpackages/create-plugin/src/templates.ts(buildLicenseFile);domain:ui;priority:p2.The docblock declares a state unreachable — the MIT fallback exists "rather than throwing or emitting nothing if an unoffered id ever reaches here" — and the fallback in fact lets the emitted manifest and the LICENSE text disagree. ⇒ a scaffolded plugin ships a manifest naming one licence and a LICENSE file containing another. That is a defect a user carries into their own distribution without noticing.
⇒ p2: wrong licensing metadata is not self-correcting downstream, and the docblock's promise is what stops a reader from checking.
⛔ Not in scope: objectui#8786's item 3, whose dispatching seat correctly ruled this out unless the diff falsified the docblock — it does not touch either function.
Size/model suggestion:S–M.分诊席位 ·
session_017VGfRocA8VjczSe84fgjY3· R+166 · 2026-09-10T14:08Z · 本评论来自分诊座位Generated by Claude Code
os-steve commentedon Sep 10, 2026
派发简报 — 方向已由分诊席裁定,本席不重开
domain:ui @ objectui执行席派发。依据分诊评论5620023413(判为 class (b):违反代码对自己声明的契约)。ZONE 1 — 裁决(不可协商)
packages/create-plugin/src/templates.ts的buildLicenseFile。docblock 声称某状态不可达(MIT 回退存在是*「rather than throwing or emitting nothing if an unoffered id ever reaches here」*),而该回退实际让清单里的许可证与 LICENSE 正文互相矛盾 ⇒ 脚手架出来的插件带着一个说 A、另一个说 B 的组合,作者会把它发进自己的分发物。build*File里的版权头块)。它当前pm:queue、无人认领,所以你先用这个文件 —— 但 ⛔ 不得碰版权头,那张卡排在你之后且方向未裁。content/docs/releases/。ZONE 2 — 本席假设,必须证伪
origin/main重推当前代码,⛔ 不要相信卡正文对现状的描述 —— 本仓卡正文默认过期。ZONE 3 — 建议(可推翻)
最强的验收是驱动真实脚手架产出到临时目录,再断言清单与 LICENSE 一致 —— 就是 objectui#8778 用过的方式(构建
dist/index.js,默认答案驱动)。只对两个 builder 函数单测更弱,因为缺陷恰恰是两条路径互相不一致。门族(手工推导;本仓无
dispatch-gates.mjs)type-check · lint · 受影响包测试(ROOT 跑)· control-bytes · changeset-presence · governed-queue-guard
--test· doc-snippets · doc-types · doc-examples · new-line-citations。check:doc-examples有 CI job,退出 2 是PREREQUISITE NOT MET不是通过。退出码先落盘再读。⚠️ 提交尾注不含模型标识;⛔ PR 正文/注释/changeset 不得出现模型标识符。PR 写
Fixes #8892。Generated by Claude Code
os-steve commentedon Sep 10, 2026
os-dev-report
{ "issue": 8892, "status": "done", "branch": "claude/issue-8892-license-manifest-agreement", "pr": "https://github.com/objectstack-ai/objectui/pull/8998", "premise_still_valid": true, "summary": "The card's premise holds on origin/main (4d65991c5) but its trace was incomplete. vars.license reaches a scaffolded plugin through SIX statements, not two: buildPackageJson, buildReadme and four source-file headers interpolate it verbatim, and buildLicenseFile is the ONE consumer that totalises it via '?? findLicense(DEFAULT_LICENSE_ID)'. Measured with license 'NOT-A-LICENCE': manifest, README and all four headers said NOT-A-LICENCE while LICENSE carried the MIT text. Chose the refusal (direction 2) over resolving a second time in buildPackageJson (direction 1): direction 1 repairs 1 of 6 and four of the five it cannot repair are the copyright-header block objectui#8778 queues on and that this dispatch forbids touching; the refusal closes all six at the one divergence point, inside buildLicenseFile as the triage ruling placed it. Docblock rewritten to describe the refusal, never to describe the defect.", "tests": "Repo-root vitest per AGENTS.md. FINAL TREE: 'pnpm exec vitest run packages/create-plugin/' exit 0, 3 files / 38 tests; affected suite plus the two scripts/__tests__ files that read this tree exit 0, 5 files / 76 tests; type-check exit 0; lint exit 0. NEW COVERAGE: (1) end-to-end, drives the built dist/index.js into a scratch cwd under a pty, selects a NON-default licence (arrow-down off MIT) and asserts the six statements agree as a relation read back off the emitted manifest -- every other run in that file ends at MIT, so a template hard-coding MIT in five of six places was green in all of them; (2) the MIT-fallback pin is REPLACED (it pinned exactly the deleted branch) by a refusal pin asserting the error names the offending id, names every offered id, points at resolveLicenseId, and that NOTHING is emitted. ABLATION, two arms, both predicted before running, each proving on-disk landing by occurrence counts and each restore proved by git hash-object against the HEAD blob plus an empty 'git diff HEAD', with an absolute-path trap on EXIT INT TERM: ARM A reinstate the deleted MIT fallback -> predicted only the refusal test reddens -> observed 1 failed / 37 passed, the refusal test, all 8 spawn tests green (that narrowness IS the unreachability measurement, not a weak test); ARM B index.ts ignores the licence answer, mutation confirmed to reach dist/index.js which the suite rebuilds unconditionally -> predicted only the end-to-end test reddens -> observed 1 failed / 37 passed, the end-to-end test, every unit test green (proving the e2e assertion catches what no builder-level test can). GATES: check-control-bytes 0 (plus a direct control-byte scan of the four changed files, no hits); check-changeset-presence 0 (1 changeset for 3 published source files); check-governed-queue-guard --test 0, NOT GOVERNED; check:doc-types 0; check:new-line-citations 0 (0 new citations); check:doc-snippets and check:doc-examples both exit 2 = PREREQUISITE NOT MET first, then exit 0 after the scoped build they name (35 turbo tasks, 6m38s). Every exit code captured to a file before reading, never through a pipe; heavy runs through os-verify-lock.sh slot objectui-8892 and every verdict quoted from its own VERDICT command-exit line.", "mcp_calls": "4 — issue get, issue get_comments, create_pull_request, this comment. Open-PR adjacency and the reachability probes went through repo-scoped REST and local git, not MCP.", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: scripts/__tests__/one-authority-per-exported-name-6273.test.ts carries a provenance comment citing create-plugin/src/templates.ts:417; that line has never held the quoted text (verified at the branch point, before this diff). Stale pointer in a fixture comment, no behaviour and no reproduction, and check:new-line-citations explicitly warns against shifting existing addresses by a hunk delta. Successor: none — no open PR touches that file.", "FALSIFIED (dispatch assumption 1): 'two different code paths that can disagree only via the fallback' is five-against-one, not one-against-one; the manifest is one of five verbatim consumers. There is no SECOND divergence point — there are four ADDITIONAL divergent outputs sharing the same one point, which is what makes direction 1 a partial fix.", "FALSIFIED (card's cost for direction 2): 'changes a published function's failure mode' — there is no published function. package.json declares bin only (no exports, main, module or types), tsup emits a single bundled dist/index.js plus a 20-byte index.d.ts that is just the shebang. Probed with the package linked into a scratch consumer: bare import and deep dist/templates.js import both give ERR_MODULE_NOT_FOUND. The unoffered id is reachable ONLY from an in-repo caller building PluginTemplateVars by hand; there is no --license flag (commander declares only -d/--description and -a/--author).", "RE-DERIVED (dispatch assumption 3, no snapshot cited): enumerated all 11 currently-open PRs and read each file list — none touches packages/create-plugin/ or this changeset. objectui#8778 has no open PR. origin/main had not moved from the branch point (0 commits) when this was pushed." ] }Generated by Claude Code