fix(cli)!: report the named export the config default export already declares, instead of dropping it silently (#18419) - #18647
Conversation
… declares, instead of dropping it silently `loadConfig()` merges every named export of `objectstack.config.ts` onto the default-exported stack as a top-level key. A name the default already carried was skipped by `if (key === 'default' || key in merged) continue` — the build exited 0, the artifact carried the default's value, and nothing was written at any level, so authored content vanished on the success path. The drop itself is correct and stays: one key, one value. What changes is that it is now REPORTED — on stderr, from the loader, so all twelve commands that load a config carry it and a `--json` run's stdout stays a single parseable document — and recorded structurally on `LoadedConfig.shadowedNamedExports`. Advisory, never fatal: the stack that comes out is valid, it is merely missing what the shadowed export carried. That disposition is this package's own for the class (#3786's undeclared authoring keys, #4095's orphaned runtime members), not a fresh judgement. Sweeping the arm for other silent shapes found a second one in the same expression: `key in merged` walks the PROTOTYPE chain, so `Object.prototype`'s members answered true for a default export carrying no such key. An `export const toString = …` was skipped by the collision arm and therefore never reached the strict parse that refuses an undeclared stack key by name — the loud refusal, silently turned off by the spelling of the key. The test is now `Object.prototype.hasOwnProperty.call`, which both closes that hole and is what makes the new diagnostic truthful: without it the loader would report `toString` as shadowed by a default export that declares nothing of the sort. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 24 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 283248dacd7bd32e575fa269d07b71ecb3dc9d46 && git checkout 283248dacd7bd32e575fa269d07b71ecb3dc9d46
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 84ad2e1394f77e7f117b3422590b791a068c2bc3 b04bfbefd51c44f1bde7b3d36712bfd1081c0c72 && git checkout -B drift-repro 84ad2e1394f77e7f117b3422590b791a068c2bc3 && git merge --no-ff b04bfbefd51c44f1bde7b3d36712bfd1081c0c72
node scripts/docs-audit/affected-docs.mjs --json 84ad2e1394f77e7f117b3422590b791a068c2bc3
|
… not silent `your-first-project.mdx` stated the shape by its INPUTS and never named `loadConfig`, so the docs drift check could not see it: its callout said a named export whose key the default export already carries "is dropped **silently**, and the build still succeeds". The first half is still true and the second half is unchanged — what is no longer true is the word the sentence turns on. Found by sweeping for the input shape (a config pairing a default export with a colliding named export) rather than for the symbol, which is the only arm that reaches a page written this way. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
…silent-drop Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18419
What was wrong
objectstack.config.tsis loaded as a module:loadConfig()takes the default export as the base and merges every named export onto it as a top-level stack key. A named export whose name the default export already carries was skipped byso the authored value reached no artifact, no parse and no log at any level, while
os buildexited 0.Triage's two binding constraints, carried verbatim:
The loop is untouched. What changed is the one arm, and what it does when it fires.
The card's three rows, re-measured on
origin/main32be735Row A was taken as given as the card's positive control — it is not rebuilt here, it is used: it rides in the same harness as rows B and C so a repaired loader is distinguishable from one that simply warns about everything. All three reproduced, plus two shapes the sweep added.
export const ProbeNamedExport = [1,2,3]export const objects = [], default carries noobjectsexport const objects = [row], default carriesobjects: []shadowedNamedExports: ['objects']functionsfunctionsexport const toString = [1,2,3]Premise falsified:
key in mergedis not one silent arm, it is twoSweeping
loadConfig()for other silent shapes (the dispatch asked for this, carrying a control that can fail) turned up row D inside the very same expression.key in mergedwalks the prototype chain, so everyObject.prototypemember —toString,valueOf,constructor,hasOwnProperty,propertyIsEnumerable,toLocaleString,isPrototypeOf— answered true for a default export that carries no such key at all. Such an export was skipped by the collision arm and therefore never reached the strict parse that refuses an undeclared stack key by name: row A's loud refusal, silently turned off by the spelling of the key.This is not an optional extra. Without it the new diagnostic would lie — it would report
toStringas "shadowed by the default export" when the default declares nothing of the sort.Object.prototype.hasOwnProperty.call(merged, key)is what makes the report truthful and closes the hole in one move.Everything else in
loadConfig()was swept and is loud:mod.default || modwith a falsy default hands the whole namespace to the strict parse, which refuses thedefaultkey by name.The decision: advisory, not refusal — read off the tree
The choice was made from this package's own repairs of this class, not from taste:
os build --jsondrops the undeclared-authoring-key warnings thatos validate --jsoncarries #11643 — "Undeclared authoring keys — dropped at load". The same shape (a build that exits 0 while quietly dropping an authored value).compile.tsstates the disposition in its own comment: "Advisory, never fatal." Surfaced on the text face and in the--jsonwarningspayload, never by failing the run.onEnablehook — every script action handler goes unregistered and 404s at dispatch (examples/app-todo: all 8) #4095 —graftRuntimeMembers'orphaned. An authoredonEnablethat finds no bundle to land on is "reported rather than dropped, which is the failure mode that made this invisible for so long";os serveprints a⚠line and keeps serving.objectstack.config.tsmay carry no named export — the build parses the whole config module against the strict stack schema, and nothing documents that constraint #18171 — the sibling half of this very loop. It added the explanation for a merged-then-refused key and spent nothing on the accept set.A refusal would also have to live in
loadConfig()to reach every face, and two of those faces exist precisely to read a config the current schema is unhappy with:os doctordiagnoses broken projects, andos migrate metais entitled to read past a rejection viaauthoredSourcePlugin— a loader-level throw is not something itsauthoredSourceoption can shim away. Refusing there would close the upgrade path against exactly the legacy configs a collision is most likely to sit in.Where it renders: stderr, from the loader.
loadConfig()is handed no--jsonflag (this file'sresolveConfigPathheader states the same fact for the same reason) and twelve commands call it, so printing from the loader is what puts the finding on all twelve faces instead of the two that handle named exports today. stderr is what keeps a--jsonrun's stdout a single parseable document — the shaperefuseConfigalready established in this file, minus the throw.printWarningToStderris the warning-severity counterpart of the existingprintErrorToStderr, added for that one reason.Accept-set measurement (the dispatch asked for the number and the pathspec)
Pathspec swept:
git ls-files '*objectstack.config.ts' '*objectstack.config.js' '*objectstack.config.mjs'— 10 tracked config files.examples/app-crm,examples/app-showcase,examples/app-todo), each exportingonEnable. That non-zero count is the sweep's own positive control: the detector finds named exports where they exist, so the collision count below is a reading and not a dead search.onEnableinside itsdefineStack({ … })call, so none collides; and none exports anObject.prototypename.Rows C and C2 move no accept-set member — the build still exits 0 with the same artifact, it merely also says what it dropped. Row D is the one narrowing, and it narrows toward the documented rule rather than away from it.
Clause-②: no (narrowing)
Re-declared from the measured diff, not inherited from the claim comment. The #16349 ruling made this clause directional: widening the accept set or the public surface triggers the contract-review tier; pulling code back to the declared contract does not.
Three readings:
shadowedNamedExportWarningandprintWarningToStderrare added, plus theLoadedConfig.shadowedNamedExportsfield. None is on a published entry point.packages/cli'sexportsmap is exactly.,./console,./hook-body,./package.json; none of the three entry sources carries anexport *or re-exportsutils/config.js/utils/format.js(grepped — the only matches are prose in comments). The module ships inside the tarball underdist/, but no published entry point names these symbols, so the public surface does not grow.ERROR_CODE_LEDGER/StandardErrorCode— 0 hits across the diff, against a positive control on the same instrument that returns 4. No code is minted: the advisory is text on stderr and carries neithercodenorhttpStatus, soerrorCodeFields()still contributes nothing here — the standing prohibition in theConfigRefusalErrorheader is respected.Object.prototypename used to build green and now gets the same named refusal every other undeclared helper export has always got. That is the rule this file's own header states, so the movement is toward the declared contract, not away from it. Rows C and C2 — the card's actual subject — move no accept-set member at all.The mechanical limb is NOT MEASURED, and that is reported rather than read as clean.
check-widening-tells.mjs --declaration noover this diff exits 0 while saying so in its own words: 5 changed files, 0 judged, 5NOT MEASURED— "no declared surface covers it", because the tells are defined overpackages/specschema sources, closed sets, theapi-surfacelistings and the registries, and this diff touches none of those. So its exit 0 is evidence about no surface at all, and the reading above is the semantic one.--pairexit code is recorded in the report comment on #18419.⇒ The measured reading is
no, soneeds:contract-reviewis not hung — that label stays the seat's. The fix was not softened to reach that answer: the one accept-set movement in the diff is kept, declared, and carried in the changeset under a**BREAKING**banner with an ADR-0087 disposition.Documentation falsification sweep
Predicate written down before reading anything. A page is falsified iff (a) it shows a config this change now REFUSES — a default export plus a named export named for an
Object.prototypemember; or (b) it states, present tense, that a named export whose key the default already carries is merged/wins/honoured; or (c) it documents the silent drop as intended. NOT falsified by merely namingloadConfig, by non-colliding named exports (onEnable/functions— that loop is deliberate and untouched), or by a default export alone.Swept by the INPUT shape rather than the symbol, over
content/docs,skills/,examples/,apps/docs/and every tracked config.config-shadowed-named-export.test.ts, the row that exercises the refusal). Zero documented examples, zero shipped configs.content/docs/getting-started/your-first-project.mdx, fixed in this PR. Its callout said such an export "is dropped silently, and the build still succeeds". It never saysloadConfig, so the docs drift check could not see it: it states the rule by its INPUTS while the diff changed the EMITTER — exactly the blind spot that check confesses to.content/docs/deployment/cli.mdxcarried the same claim in table form and is fixed too.content/docs/automation/hook-bodies.mdxmerely namesloadConfigin passing, andcontent/docs/releases/v17/17-2.mdxis RELEASE-OWNED (read-only, untouched) and its "silently dropped" lines are about unrelated A compare-and-setwhereon a by-idupdateis silently inert — the extra predicate keys never reach the driver, andSqlHttpOutbox.redeliver's status guard is one of them #11009/A scalardata.idbeside a DIFFERENT scalarwhere.idsilently drops thewhere.idpredicate — the one unhonoured-predicate shape #11009's refusal deliberately left standing #11142 write-path guardrails.os initscaffold comments already said "a helper exported from here … the build refuses it". That sentence was false for prototype-chain names before this change and is true for every spelling after it — strengthened, not falsified, so left alone.Controls, both directions: a token known present returned 5 files (
export const onEnable); a nonsense token returned zero. What the sweep could not reach: the siblingobjectuirepo (out of this repository) and any page describing the shape in wording that shares no token with either arm — though arm (a) is a source regex over every tracked file, so it is wording-independent.Verification
Everything below is measured on the merged head
b04bfbefd(this branch withorigin/mainmerged in, rebuilt per AGENTS.md §9).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon this tree and reconciled with--rancarryingCOMMAND :: exit CODEfor every one.pnpm --filter @objectstack/cli typecheck— exit 0.pnpm --filter @objectstack/cli exec vitest run --project unit— exit 0, 212 files / 3026 tests. The integration tier is declared to CI: the diff touches no spawn entry, nobin/, notest/helpers/serve-process.tsand no driver/kernel boot path. The new pin's tier is measured, not asserted —vitest list --project unitfinds its 7 tests, and a known spawn e2e is absent from that list as the control.pnpm lint— exit 0, repo-wide, not narrowed. (dispatch-gatesdoes not name this family; it was run anyway.)One gate was red before the merge and is green after, and the difference is not mine.
pnpm check:cross-package-test-inputsfailed onpackages/cli/test/init-created-files-summary.e2e.test.ts— a file this diff never touches — for descendingpackages/spec/dist/. A control on pristineorigin/mainplus an unrelated one-linepackages/cliedit reproduced it identically (the naive control was vacuous: that worktree had nopackages/spec/distto walk, so the row could not fire).origin/mainthen landed#18641, which judges a walk root against the index rather than the working tree — exactly this case. Merged in, the gate is exit 0. No finding to file: it was a real defect, in the gate, already fixed upstream.Acceptance notes
Noted, not filed — observations from the sweep, no card:
loadConfig()'sif (!baseConfig) throw new Error('No default export found in …')is unreachable.baseConfigismod.default || mod, andmodis always a non-null object frombundleRequire, so the guard can only fire on a falsymod. The real "no default export" path falls through to the namespace-as-stack branch and is refused by the strict parse instead. Dead code, not a defect; left alone. Successor who will meet it: whoever next touches this unwrap — the same reader the#18419comment right above it now addresses.not a declared stack key→ "the build fails, naming the key") was false for prototype-chain names before this change and is true for every spelling after it. Corrected as part of the same row rewrite rather than filed.Authored by Claude Code, session
session_01DvvamiacK328idtBYJBxV3.Generated by Claude Code