Uh oh!
There was an error while loading. Please reload this page.
fix(cli): refuse a relative plugins: [...] entry, naming the two spellings that work - #11154
Conversation
…ellings that work (#10944) A string entry that is not a bare package name was handed straight to `import()`, which ESM resolves against `serve.ts` itself — the installed CLI's directory. The served app's root never entered the resolution, so a relative path could never address anything the app owns; measured, `'..'` even loaded this package's own command barrel. The boot loop caught the failure, printed one red line naming a path inside the CLI, and served the app without the plugin. Ruled at triage on #10944: refuse the spelling at load, naming the two that do resolve from the app (a declared bare package name, an absolute/`file://` path). No accepted set widens — the spelling has never loaded an app's file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
📓 Docs Drift CheckThis PR changes 1 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 23 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 8815a071a31a91291b7b972497a77b6489539d8e && git checkout 8815a071a31a91291b7b972497a77b6489539d8e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 17bad125cca89674cfd2dca59025b0d31da68968 2ff2d33130206717b9634fda7c241e3edff02486 && git checkout -B drift-repro 17bad125cca89674cfd2dca59025b0d31da68968 && git merge --no-ff 2ff2d33130206717b9634fda7c241e3edff02486
node scripts/docs-audit/affected-docs.mjs --json 17bad125cca89674cfd2dca59025b0d31da68968
|
os-elon
commented
Aug 22, 2026
复核记录 —— open question 答 A(即本 PR 现状),另确认一处我简报里的前提被你纠正对了⛔ 尚未入队: open question:A,就放在 config-plugin load,⛔ 不扩到 |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32601926582 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10944
Ruled at triage: option B — refuse a relative
plugins: [...]entry at load,naming the two spellings that do work. Option A (resolving relative entries
against the served app's root) is deliberately not implemented: it is a
capability addition with zero measured pull, it stays a maintainer decision, and
this refusal is the collection point for such a request. The message does not
hint that it is coming.
(Note on this body: GitHub's sanitizer silently deletes short angle-bracket
fragments, so placeholders below are written
[like-this]rather than withangle brackets — the first revision of this body lost three of them.)
The defect, measured before the fix
packages/cli/src/commands/serve.ts→Serve.importConfigPlugin. A string entrythat is not a bare package name was handed straight to
import(), which ESMresolves against the file containing the call — the installed CLI's
@objectstack/cli/dist/commands/. The served app's root never entered theresolution.
Measured on
acb4dbc09(the merge base), throughServe.importConfigPlugin,against a fixture app that really did carry
local-plugin.jsbeside itspackage.json([cli]= the CLI package root):The app's own file was never seen by any relative spelling, and the
'..'row isthe same fact as a positive: a relative entry can load a module — it can only
ever load one belonging to the CLI.
On "silently loads nothing", precisely. It is not literally silent at
stderr: the boot loop catches the throw and prints one red✗ Failed to load plugin: …line (console.erroris not among the streamsservemutes during boot — onlyprocess.stdout.write,console.logandconsole.debugare). What was silent is everything that matters afterwards: theboot continues, the app is served without the plugin, and the only
diagnostic names a path inside the CLI's install directory — which tells an
author nothing about the spelling being the problem. A config could therefore
carry a dead
plugins:entry indefinitely while the deployment looked healthy.The change
Refused before the
try, and deliberately not wrapped in the existingFailed to import plugin '[specifier]': …text — nothing was imported, andcalling a refusal an import failure sends the author hunting for a missing file.
The message is owned by one function (
Serve.relativePluginSpecifierRefusal) sothe string a user reads is chosen and pinned rather than assembled at a call
site. Actual text:
Bullet 2 echoes the author's own specifier, so the suggested line is
copy-pasteable, and
new URL(spec, import.meta.url).hrefis the app-local pluginfile written the way an author actually wants it — the config computes the
absolute URL from its own location, using only resolution that already works.
The quoting switches to JSON escaping when a specifier would break single quotes
(the
'.\x.js'spelling), so the suggestion never silently drops an escape.Every specifier shape I tested, and the conclusion for each
Each was run through
Serve.importConfigPluginon the merge base first; thematrix is pinned in
serve-config-plugin-relative-refusal.test.ts../local-plugin.js,./nested/plugin.js../local-plugin.jscommands/dir.commandsdirectory.../.\local-plugin.js,..\local-plugin.js\to/, so these are the same relative specifier (measured: identical resolved path to./local-plugin.js)/abs/local-plugin.jsfile:///abs/local-plugin.jsC:\app\plugin.js,C:/app/plugin.jspackageNameFromSpecifieralready returnsundefinedfor it via its protocol regex, so it reaches the sameimport()and is unaffected)node:pathdata:text/javascript,…@mycompany/crm,chalklocal-plugin.js.hidden-not-relative.not followed by a separator; not a relative specifierSo the predicate is
/^\.\.?(?:[\\/]|$)/— deliberately narrower thanpackageNameFromSpecifier(s) === undefined, which also answers "not a package"for every base-independent spelling above.
Reverse verification — prediction before each run, then the observation
All legs run against
packages/cli/src/commands/serve-config-plugin-relative-refusal.test.tsand
serve-config-plugin-host-resolution.test.ts(15 tests). The fix wascommitted first, so each leg was restored with
git checkout HEAD -- [path](never
git stash).importConfigPluginisRelativePluginSpecifier→return true(the "refuse everything" bad implementation)file://loads, and every load pin in the #10908 file fails with it/^\.\//..,.,.\,..\,../)names spelling (b))git diff HEADemptyLeg 2 is the preservation leg the third acceptance criterion asks for: a
suite that only asserted "the relative entry is refused" would have passed on
that implementation. It does not, because the headline test asserts the refusal
and both working spellings in one body.
No rebuild is involved in these legs and none is claimed: the subject
(
Serve) is imported relatively inside its own package (./serve.js), whichvitest resolves to
packages/cli/src/commands/serve.ts— source, notdist/.Leg 1 going red is the inline proof that the mutation reached the running code,
and leg 5 restores it (
git diff HEADempty, byte-identical).Fixture triage — the pin this supersedes
serve-config-plugin-host-resolution.test.tscarriedkeeps a RELATIVE specifier anchored to serve.ts, not to @objectstack/types,which pinned the exact branch this card removes: its assertions would have kept
passing only because the resolution it described no longer runs. It is
replaced, not reworded — the slot now asserts that the refusal fires and
that an absolute path still loads, which is what makes it a narrowing rather than
a ban. The full refusal coverage lives in the new file.
I scanned by the rule's consumer radius, not by the edited package:
Serve.importConfigPluginhas exactly one non-test call site (the boot loop inserve.ts), and no fixture, doc, example app or test anywhere in the repo writesa relative
plugins:spelling — which is also the "zero measured pull" thetriage rests on.
What this deliberately does not do
the loop's existing
catch, printed as one red line, and the boot continues —the same as every other failed plugin load. Making a bad
plugins:entry failthe boot is a separate policy question about the whole loop, not about this
spelling.
loadConfig()is untouched.os validate/os doctor/os lintdo notread
plugins:entries today; adding a config-time scan there is a widersurface than this card and was not authorised.
Verification
Union derived with
node scripts/pm/dispatch-gates.mjs(no path arguments —the script takes its own changeset from the merge base). All at
2ff2d33130, the head commit of this PR, on a clean tree.Merge base
acb4dbc09703d5c6145efb376c50ea12dfe9f41c; three-dot changeset(
git diff --name-only $(git merge-base origin/main HEAD) HEAD):Every gate the derivation named, plus the convention-triggered family for a new
test file, run locally and green (each quoted from the gate's own verdict line):
check:nul-bytes—OK (scanned 6457 text file(s) … no raw ASCII control bytes)check:changeset-gate-self-tests— 118 + 212 + 116 assertions, all✓check:cross-package-test-inputs—OK: 13 package(s) read outside themselves, all declaredcheck:objectui-changeset—✓ objectui-range --self-test: all checks passedcheck:route-envelope— exit 0, ratchet holds (its two⚠ ratchet #9559rows are pre-existingpackages/restentries, untouched here)check:slot-lookup—✓ slot-lookup ratchet holds: 107 unswept site(s) … none newcheck:test-source-alias—OK — 72 packages with tests scannedcheck:type-source-resolution—OK — 77 packages with a tsconfig.json scannedcheck-adr-0087-registration—✓ this PR adds no declared-breaking changesetcheck-changeset-no-major—✓ This diff introduces no 'major' bump.check-ci-filter-parity—OK: all 86 declared cross-package glob(s) … coveredcheck-empty-changeset—✓ No empty-frontmatter changeset introduced by this diffcheck-plugin-teardown-shape—✓ 63 Plugin implementation(s) across 4479 source(s)docs-audit/check-affected-docs— exit 0check:query-options-erasure—✓ ratchet holds: 67 unswept non-test site(s) … none newcheck:engine-double-contract—OK — 381 pinned, 133 in the DEBT ledger, 2 exemptcheck:where-matcher—✓ 280 matcher(s) discovered … 0 silently-wrong … none newcheck:type-check-coverage—OK — 65/78 workspace packages type-checkedcheck:type-check-debt --re-measure— first run refused (@objectstack/service-knowledgehad no built type entry point). Treated as NOT MEASURED, not as a pass: built the closure exactly aslint.ymldoes (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and re-ran →OK — 33 ledger entr(ies) re-measured in 217.5s, 1895 raw tsc error(s) total, none above its recorded numberPackage suites:
pnpm --filter @objectstack/cli test—Test Files 157 passed (157) / Tests 1730 passed (1730)(full suite, not just the changed files)pnpm --filter @objectstack/cli typecheck—tsc --noEmit, exit 0All heavy runs went through
scripts/pm/os-verify-lock.sh. CI is still in flight:the lint farm, the cross-package
testshards and the packages outside@objectstack/clihave not been run locally and are the half CI owns.Changeset
.changeset/refuse-relative-config-plugin-specifier.md,@objectstack/cli: minor.minorrather thanpatchbecause one shape genuinely changes what it loads:plugins: ['.']/plugins: ['..']resolved into the CLI's own package and couldregister its command barrel as a plugin; those are now refused. The changeset
states plainly that the spelling never worked but failed quietly enough to be
missed, so an author who has been carrying a dead entry now gets an answer
instead of a path into the CLI's install directory. Not declared breaking and not
major, so no ADR-0087 disposition marker is required (the gate confirms:this PR adds no declared-breaking changeset).Out-of-scope findings
None. No unrelated defect was encountered.
Generated by Claude Code