Skip to content

Commit f32f480

Browse files
fix(cli)!: report the named export the config default export already declares, instead of dropping it silently (#18419) (#18647)
Fixes #18419 ## What was wrong `objectstack.config.ts` is 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 by ```ts if (key === 'default' || key in merged) continue; // the silent skip ``` so the authored value reached no artifact, no parse and no log at any level, while `os build` exited 0. Triage's two binding constraints, carried verbatim: > ⭐ 那个循环**是故意的**(注释写明要保住 `onEnable` / `functions` 这类具名导出);缺陷只在 **`key in merged` 那一臂**:撞名解析成默认,⛔ 无声。⇒ **⛔ 修法不是删循环。** > ✅ 填报席给了三行对照,其中 A 行是**发火对照**(`export const ProbeNamedExport` ⇒ 响亮失败、parse 拒、点名该键)⇒ 证明同一条通道**有能力响亮**,静默是这一臂特有的。⛔ 本席不重跑。 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/main` 32be735 Row 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. | row | authored | before | after | |:---|:---|:---|:---| | A (control) | `export const ProbeNamedExport = [1,2,3]` | **loud** — parse refuses, names the key | unchanged | | B | `export const objects = []`, default carries no `objects` | merged, accepted | unchanged | | C | `export const objects = [row]`, default carries `objects: []` | **silent**, exit 0, row gone | reported on stderr, `shadowedNamedExports: ['objects']` | | C2 | same collision on `functions` | **silent**, the handler gone | reported, names `functions` | | D | `export const toString = [1,2,3]` | **silent**, exit 0 | refused by name — rejoins row A | ## Premise falsified: `key in merged` is not one silent arm, it is two Sweeping `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 merged` walks the **prototype chain**, so every `Object.prototype` member — `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 `toString` as "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 || mod` with a falsy default hands the whole namespace to the strict parse, which refuses the `default` key 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: - **#3786 / #11643 — "Undeclared authoring keys — dropped at load".** The same shape (a build that exits 0 while quietly dropping an authored value). `compile.ts` states the disposition in its own comment: *"Advisory, never fatal."* Surfaced on the text face and in the `--json` `warnings` payload, never by failing the run. - **#4095 — `graftRuntimeMembers`' `orphaned`.** An authored `onEnable` that finds no bundle to land on is "reported rather than dropped, which is the failure mode that made this invisible for so long"; `os serve` prints a `⚠` line and keeps serving. - **#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 doctor` diagnoses broken projects, and `os migrate meta` is entitled to read **past** a rejection via `authoredSourcePlugin` — a loader-level throw is not something its `authoredSource` option 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 `--json` flag (this file's `resolveConfigPath` header 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 `--json` run's stdout a single parseable document — the shape `refuseConfig` already established in this file, minus the throw. `printWarningToStderr` is the warning-severity counterpart of the existing `printErrorToStderr`, 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. - **3** carry a named export at all (`examples/app-crm`, `examples/app-showcase`, `examples/app-todo`), each exporting `onEnable`. 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. - **0** of the 10 would newly fail. None of the three writes `onEnable` inside its `defineStack({ … })` call, so none collides; and none exports an `Object.prototype` name. 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: 1. **New exports** — `shadowedNamedExportWarning` and `printWarningToStderr` are added, plus the `LoadedConfig.shadowedNamedExports` field. None is on a published entry point. `packages/cli`'s `exports` map is exactly `.`, `./console`, `./hook-body`, `./package.json`; none of the three entry sources carries an `export *` or re-exports `utils/config.js` / `utils/format.js` (grepped — the only matches are prose in comments). The module ships inside the tarball under `dist/`, but no published entry point names these symbols, so the public surface does not grow. 2. **`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 neither `code` nor `httpStatus`, so `errorCodeFields()` still contributes nothing here — the standing prohibition in the `ConfigRefusalError` header is respected. 3. **Accept-set changes** — one, and it is a **narrowing**: row D. A config exporting an `Object.prototype` name 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 no` over this diff exits 0 while saying so in its own words: 5 changed files, 0 judged, 5 `NOT MEASURED` — "no declared surface covers it", because the tells are defined over `packages/spec` schema sources, closed sets, the `api-surface` listings 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. `--pair` exit code is recorded in the report comment on #18419. ⇒ The measured reading is `no`, so `needs:contract-review` is **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.prototype` member; 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 naming `loadConfig`, 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. - **Arm (a): 1 hit repo-wide**, and it is this PR's own new pin fixture (`config-shadowed-named-export.test.ts`, the row that exercises the refusal). Zero documented examples, zero shipped configs. - **Arm (b)/(c): 1 page falsified — `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 says `loadConfig`, 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.mdx` carried the same claim in table form and is fixed too. - The two rows the drift check did name were re-derived here and are **not** falsified: `content/docs/automation/hook-bodies.mdx` merely names `loadConfig` in passing, and `content/docs/releases/v17/17-2.mdx` is RELEASE-OWNED (read-only, untouched) and its "silently dropped" lines are about unrelated #11009/#11142 write-path guardrails. - The `os init` scaffold 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 sibling `objectui` repo (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 with `origin/main` merged in, rebuilt per AGENTS.md §9). - **Derived gates: 90 families, 90 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0.** Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this tree and reconciled with `--ran` carrying `COMMAND :: exit CODE` for 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, no `bin/`, no `test/helpers/serve-process.ts` and no driver/kernel boot path. The new pin's tier is measured, not asserted — `vitest list --project unit` finds 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-gates` does 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-inputs` failed on `packages/cli/test/init-created-files-summary.e2e.test.ts` — a file this diff never touches — for descending `packages/spec/dist/`. A control on pristine `origin/main` plus an unrelated one-line `packages/cli` edit reproduced it **identically** (the naive control was vacuous: that worktree had no `packages/spec/dist` to walk, so the row could not fire). `origin/main` then 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()`'s `if (!baseConfig) throw new Error('No default export found in …')` is unreachable. `baseConfig` is `mod.default || mod`, and `mod` is always a non-null object from `bundleRequire`, so the guard can only fire on a falsy `mod`. 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 `#18419` comment right above it now addresses. - The docs table's first row (`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](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c17ff70 commit f32f480

6 files changed

Lines changed: 410 additions & 14 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
fix(cli)!: a named export the config's default export already declares is reported instead of silently dropped (#18419)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) no metadata key is retired, renamed or given a new meaning here, no stored `sys_metadata` document changes, and the only authored construct whose treatment moves is a NAMED EXPORT of `objectstack.config.ts` that was already inert — it was skipped by the merge and reached no artifact. `os migrate meta` has nothing it could rewrite, so there is no ledger entry for this to be missing. -->
8+
9+
`objectstack.config.ts` is 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** loses — the default's value wins — and until now it lost in complete silence. `os build` exited 0, the artifact carried the default's value, and nothing was written at any level:
10+
11+
```ts
12+
export default defineStack({ manifest, objects: [Task] });
13+
export const objects = [Task, Invoice]; // Invoice never reached the artifact
14+
```
15+
16+
The loader now says so on stderr, names every shadowed key, and states the rule and the remedy. It is an **advisory, not a refusal** — the stack that comes out is valid, it is merely missing what the shadowed export carried — which is the disposition this package already gives the same failure class (`#3786`'s undeclared authoring keys are "advisory, never fatal"; `#4095`'s orphaned runtime members are "reported rather than dropped"). It goes to stderr rather than stdout because `loadConfig()` is handed no `--json` flag and twelve commands call it, so a `--json` run's stdout stays a single parseable document. `LoadedConfig.shadowedNamedExports` carries the same names structurally.
17+
18+
**BREAKING** in the accept-set sense, landing in the launch window as `minor` (the lockstep convention: `major` is refused by `check-changeset-no-major`, and breaking-ness is carried by this banner plus the ADR-0087 disposition above): the collision test now reads **own keys only**. `key in merged` walked the prototype chain, so every `Object.prototype` member — `toString`, `valueOf`, `constructor`, `hasOwnProperty`, `propertyIsEnumerable`, `toLocaleString`, `isPrototypeOf` — was treated as a key the default export "already carries" when the default carries no such key at all. Such an export was skipped by the merge and therefore never reached the strict parse that refuses an undeclared stack key by name, so `export const toString = …` beside a valid stack built green while `export const collectPackageDirs = …` was refused. That hole is closed: those names now merge like any other and are refused by name, the same sentence every other undeclared helper export has always got.
19+
20+
Nobody's metadata or stored data changes. A config affected by the narrowing was already shipping that export's value nowhere; what changes is that the build now says so instead of exiting 0. Move the helper into a sibling module and import it, which is what the config-authoring docs have always prescribed for a helper exported beside the stack.

‎content/docs/deployment/cli.mdx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2063,10 +2063,21 @@ recognisable rather than as patterns to use:
20632063
|:---|:---|
20642064
| not a declared stack key | the build **fails**, naming the key (above) |
20652065
| a declared stack key the default export does **not** carry | merged in and **accepted** — the `onEnable` / `functions` path |
2066-
| a key the default export **already** carries | dropped **silently**; the build exits 0 and the exported value is never read |
2066+
| a key the default export **already** carries | the default's value wins and the exported one is dropped — the build still exits 0, and the drop is **reported on stderr** |
20672067
20682068
The last row is why every stack key belongs inside `defineStack()`: a second
2069-
copy beside it is not a second declaration.
2069+
copy beside it is not a second declaration, it is a value nothing reads.
2070+
2071+
```console
2072+
$ printf '\nexport const objects = [myExtraObject];\n' >> objectstack.config.ts
2073+
$ os build
2074+
⚠ `objects` is a named export that was DROPPED — the default-exported stack already declares that key
2075+
```
2076+
2077+
The advisory goes to **stderr**, so it reaches a `--json` run's operator without
2078+
putting anything but the envelope on stdout. It does not fail the build: the
2079+
stack it produces is valid, it is simply missing what the shadowed export
2080+
carried.
20702081
20712082
### Config File Auto-Detection
20722083

‎content/docs/getting-started/your-first-project.mdx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,12 @@ them a pattern to rely on:
154154
- a named export whose name **is** a declared stack key that the default export
155155
does not carry is merged in and accepted — that is the `onEnable` /
156156
`functions` path;
157-
- a named export whose name the default export **already carries** is dropped
158-
**silently**, and the build still succeeds. A second
159-
`export const objects = [...]` beside `defineStack({ objects })` is therefore
160-
a value nothing reads. Write every stack key inside `defineStack()`.
157+
- a named export whose name the default export **already carries** loses to the
158+
default, and the build still succeeds — but the loader now **says so on
159+
stderr**, naming the key. A second `export const objects = [...]` beside
160+
`defineStack({ objects })` is still a value nothing reads; it is no longer a
161+
value nothing reads *and nothing mentions*. Write every stack key inside
162+
`defineStack()`.
161163

162164
</Callout>
163165

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A named export the default-exported stack already declares is DROPPED — and
5+
* the drop is now reported instead of silent (#18419).
6+
*
7+
* ── The finding ──────────────────────────────────────────────────────────
8+
*
9+
* `loadConfig()` merges every named export of `objectstack.config.ts` onto the
10+
* default export as a top-level stack key. A name the default already carries
11+
* was skipped by `if (key === 'default' || key in merged) continue`, so:
12+
*
13+
* export default defineStack({ manifest, objects: [] });
14+
* export const objects = [oneRow]; // <- never reaches anything
15+
*
16+
* built to exit 0, wrote an artifact whose `objects` is the default's `[]`, and
17+
* logged NOTHING at any level. Authored content vanished on the success path.
18+
*
19+
* ── The positive control this file carries ───────────────────────────────
20+
*
21+
* The same channel is LOUD for a named export the stack schema does not
22+
* declare: `export const ProbeNamedExport = [1,2,3]` is merged in, refused by
23+
* the strict parse and named in the message (#18171, pinned next door in
24+
* `config-named-export-rule.test.ts`). So silence was specific to this one arm
25+
* rather than a property of the loader — which is why the first two pins below
26+
* assert the UNCHANGED rows. A pin that only proved "a warning appears" could
27+
* not tell a repaired loader from one that warns about everything.
28+
*
29+
* ── The second arm, measured while sweeping for the first ────────────────
30+
*
31+
* `key in merged` walks the PROTOTYPE chain, so `Object.prototype`'s members
32+
* answered true for a default export that carries no such key at all. An
33+
* `export const toString = …` was therefore skipped by the collision arm and
34+
* never reached the strict parse that would have refused it by name — the loud
35+
* refusal above, silently turned off by the spelling of the key. `loadConfig`
36+
* now tests own keys only, so that row rejoins the control.
37+
*
38+
* ── Disposition: advisory, not refusal ───────────────────────────────────
39+
*
40+
* Read off this package's own repairs of this class — #3786's undeclared
41+
* authoring keys ("Advisory, never fatal") and #4095's orphaned runtime members
42+
* ("reported rather than dropped") — and recorded in full on
43+
* {@link shadowedNamedExportWarning}. The stack that comes out is valid; it is
44+
* merely missing what the shadowed export carried, so the run continues and the
45+
* author is told. The accept set therefore moves for the prototype-chain row
46+
* only, and in the narrowing direction.
47+
*/
48+
49+
import { describe, it, expect, afterAll, vi } from 'vitest';
50+
import fs from 'node:fs';
51+
import path from 'node:path';
52+
import { fileURLToPath } from 'node:url';
53+
import { ObjectStackDefinitionSchema } from '@objectstack/spec';
54+
55+
import { loadConfig, shadowedNamedExportWarning } from './config.js';
56+
57+
const HERE = path.dirname(fileURLToPath(import.meta.url));
58+
/** `packages/cli/tmp` — throwaway projects, the placement sibling suites use. */
59+
const TMP_ROOT = path.resolve(HERE, '..', '..', 'tmp');
60+
61+
/** The card's own probe export — a name the stack schema does not declare. */
62+
const PROBE = 'ProbeNamedExport';
63+
64+
const MANIFEST = `{
65+
id: 'com.example.probe',
66+
namespace: 'probe',
67+
version: '0.1.0',
68+
type: 'app',
69+
name: 'Probe',
70+
engines: { protocol: '^17' },
71+
}`;
72+
73+
const roots: string[] = [];
74+
afterAll(() => {
75+
for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true });
76+
});
77+
78+
function writeConfig(tag: string, body: string): string {
79+
fs.mkdirSync(TMP_ROOT, { recursive: true });
80+
const dir = fs.mkdtempSync(path.join(TMP_ROOT, `shadowed-${tag}-`));
81+
roots.push(dir);
82+
const file = path.join(dir, 'objectstack.config.ts');
83+
fs.writeFileSync(file, body);
84+
return file;
85+
}
86+
87+
/** Load `body`, capturing whatever the loader wrote to each stream. */
88+
async function loadCapturing(tag: string, body: string) {
89+
const err: string[] = [];
90+
const out: string[] = [];
91+
const errSpy = vi.spyOn(console, 'error').mockImplementation((...a) => { err.push(a.join(' ')); });
92+
const outSpy = vi.spyOn(console, 'log').mockImplementation((...a) => { out.push(a.join(' ')); });
93+
try {
94+
const loaded = await loadConfig(writeConfig(tag, body));
95+
return { ...loaded, stderr: err.join('\n'), stdout: out.join('\n') };
96+
} finally {
97+
errSpy.mockRestore();
98+
outSpy.mockRestore();
99+
}
100+
}
101+
102+
describe('#18419 — a named export the default already declares is dropped, and said so', () => {
103+
it('CONTROL: an undeclared named export is still refused by name, and is not a "drop"', async () => {
104+
const loaded = await loadCapturing('control', `import { defineStack } from '@objectstack/spec';
105+
106+
export default defineStack({
107+
manifest: ${MANIFEST},
108+
});
109+
110+
export const ${PROBE} = [1, 2, 3];
111+
`);
112+
113+
// Merged, therefore reaches the parse, therefore refused — the property
114+
// #18171 pinned and this change must not spend.
115+
expect(loaded.namedExports).toEqual([PROBE]);
116+
expect(loaded.shadowedNamedExports).toEqual([]);
117+
const result = ObjectStackDefinitionSchema.safeParse(loaded.config);
118+
expect(result.success).toBe(false);
119+
if (result.success) return;
120+
const unrecognized = result.error.issues.filter((i) => i.code === 'unrecognized_keys');
121+
expect((unrecognized[0] as unknown as { keys: string[] }).keys).toEqual([PROBE]);
122+
123+
// Nothing was dropped, so nothing is announced. "Reported" has to be
124+
// distinguishable from "always reported".
125+
expect(loaded.stderr).not.toContain('DROPPED');
126+
}, 60_000);
127+
128+
it('CONTROL: a declared key the default does NOT carry is still merged, silently and legally', async () => {
129+
const loaded = await loadCapturing('accepted', `import { defineStack } from '@objectstack/spec';
130+
131+
export default defineStack({
132+
manifest: ${MANIFEST},
133+
});
134+
135+
export const objects = [];
136+
`);
137+
138+
expect(loaded.namedExports).toEqual(['objects']);
139+
expect(loaded.shadowedNamedExports).toEqual([]);
140+
expect(ObjectStackDefinitionSchema.safeParse(loaded.config).success).toBe(true);
141+
expect(loaded.stderr).toBe('');
142+
}, 60_000);
143+
144+
it('the collision is RECORDED and ANNOUNCED — the silence this card is about', async () => {
145+
const loaded = await loadCapturing('collision', `import { defineStack } from '@objectstack/spec';
146+
147+
export default defineStack({
148+
manifest: ${MANIFEST},
149+
objects: [],
150+
});
151+
152+
export const objects = [{ name: 'probe_row', label: 'Probe Row', fields: { name: { type: 'text', label: 'Name' } } }];
153+
`);
154+
155+
// The drop itself is unchanged — the default's value still wins…
156+
expect(loaded.config.objects).toEqual([]);
157+
expect(loaded.namedExports).toEqual([]);
158+
// …and it is no longer invisible.
159+
expect(loaded.shadowedNamedExports).toEqual(['objects']);
160+
expect(loaded.stderr).toContain('objects');
161+
expect(loaded.stderr).toContain('DROPPED');
162+
// The rule and the remedy, not just the fact.
163+
expect(loaded.stderr).toContain('loaded as a MODULE');
164+
expect(loaded.stderr).toContain('defineStack');
165+
166+
// Advisory, never fatal: the stack that comes out is still valid.
167+
expect(ObjectStackDefinitionSchema.safeParse(loaded.config).success).toBe(true);
168+
}, 60_000);
169+
170+
it('…including on `functions` — the runtime member an app really does author', async () => {
171+
const loaded = await loadCapturing('functions', `import { defineStack } from '@objectstack/spec';
172+
173+
export default defineStack({
174+
manifest: ${MANIFEST},
175+
functions: { fromDefault: () => 'default' },
176+
});
177+
178+
export const functions = { fromNamedExport: () => 'named' };
179+
`);
180+
181+
expect(loaded.shadowedNamedExports).toEqual(['functions']);
182+
expect(Object.keys(loaded.config.functions)).toEqual(['fromDefault']);
183+
// The handler that vanished is named, because that is the one the author
184+
// has to go looking for.
185+
expect(loaded.stderr).toContain('functions');
186+
}, 60_000);
187+
188+
it('a name that is only on Object.prototype is NOT a collision — it rejoins the control', async () => {
189+
const loaded = await loadCapturing('proto', `import { defineStack } from '@objectstack/spec';
190+
191+
export default defineStack({
192+
manifest: ${MANIFEST},
193+
});
194+
195+
export const toString = [1, 2, 3];
196+
`);
197+
198+
// The default export carries no `toString` of its own, so nothing shadows
199+
// this — it is an undeclared stack key like any other, and goes the loud way.
200+
expect(loaded.shadowedNamedExports).toEqual([]);
201+
expect(loaded.namedExports).toEqual(['toString']);
202+
const result = ObjectStackDefinitionSchema.safeParse(loaded.config);
203+
expect(result.success).toBe(false);
204+
if (result.success) return;
205+
const unrecognized = result.error.issues.filter((i) => i.code === 'unrecognized_keys');
206+
expect((unrecognized[0] as unknown as { keys: string[] }).keys).toContain('toString');
207+
}, 60_000);
208+
209+
it('the advisory goes to stderr only — a --json run keeps stdout parseable', async () => {
210+
const loaded = await loadCapturing('streams', `import { defineStack } from '@objectstack/spec';
211+
212+
export default defineStack({
213+
manifest: ${MANIFEST},
214+
objects: [],
215+
});
216+
217+
export const objects = [{ name: 'probe_row', label: 'Probe Row', fields: { name: { type: 'text', label: 'Name' } } }];
218+
`);
219+
220+
expect(loaded.stderr).toContain('DROPPED');
221+
// `loadConfig` is handed no `--json` flag, so the one channel it must never
222+
// write to is the one the machine reads.
223+
expect(loaded.stdout).toBe('');
224+
}, 60_000);
225+
226+
it('the warning names every key, and says what to do instead', () => {
227+
const one = shadowedNamedExportWarning(['objects']).join('\n');
228+
expect(one).toContain('`objects`');
229+
expect(one).toContain('is a named export that was DROPPED');
230+
expect(one).toContain('move the value inside defineStack');
231+
232+
const many = shadowedNamedExportWarning(['objects', 'functions']).join('\n');
233+
expect(many).toContain('`objects`, `functions`');
234+
expect(many).toContain('are named exports that were DROPPED');
235+
});
236+
});

0 commit comments

Comments
 (0)