Skip to content

Commit ecf3e3b

Browse files
fix(cli): the strict config refusal now carries the rule it enforces — a named export of objectstack.config.ts IS a top-level stack key (#18416)
Fixes #18171 Clause-②: no — this PR puts no new key on a published payload. It keeps the strict config parse exactly as it is and only documents the existing constraint and improves the diagnostic; no input the build refuses today becomes acceptable. Declared by the dispatching seat in claim `5694749953`. Branch **B** as ruled in claim `5694749953`: the strict parse stays, the constraint becomes discoverable. Branch A (read only the `default` export) is **not** taken, and nothing here drifts toward it — the acceptance set is byte-for-byte what it was. ## The landing point, corrected The dispatch pointed at `packages/cli/src/utils/config.ts` "docblock around line 261". That docblock belongs to `authoredSourcePlugin` (the `os migrate meta` shim) and is not where the module's exports are collected. The real merge is in `loadConfig()` itself: ```ts const merged: any = { ...baseConfig }; for (const key of Object.keys(mod)) { if (key === 'default' || key in merged) continue; merged[key] = (mod as any)[key]; } ``` The config file is therefore loaded as a **module**: the default export is the base, and **every named export is merged onto it as a top-level stack key, under its own name**. That merge is deliberate and load-bearing — `onEnable` and `functions` are declared stack keys an app authors as named exports, and unwrapping `mod.default` alone dropped them. ## The card's finding reproduces here; its wording does not Measured on this tree (not on the `hotcrm` pin the card read), through the real `loadConfig()` and the real `ObjectStackDefinitionSchema`: | the named export | merged config | strict parse | |:---|:---|:---| | `ProbeNamedExport` — not a declared key | `["manifest","ProbeNamedExport"]` | **fails**, `unrecognized_keys`, key named | | `objects`, default carries no `objects` | `["manifest","objects"]` | **passes** — the export is honoured | | `objects`, default already has `objects` | `["manifest","objects"]`, value `[]` | **passes** — the export is dropped silently | So the diagnostic gap is real and the card is right to file it, but "the config file may carry no named export" / "exactly one export, the default stack" is **too strict to document as the rule**: rows 2 and 3 do not fail. What the code enforces is > a named export is legal only when its name is a key `ObjectStackDefinitionSchema` declares and that is what this PR writes down. Documenting the card's sentence verbatim would have described a constraint the build does not have, and would have outlawed `onEnable` and `functions`. Row 3 is a second, separate defect — a metadata-authoring trap, not a documentation gap — and it is **out of scope here**: closing it changes what the build accepts, which is branch-A territory and owes a `pm:retriage`. It is reported to the dispatching seat to file. See Acceptance notes. ## Before / after Both captured end-to-end through `os validate` on the same project, on this branch. The "before" leg is the same binary with `namedExportRejectionHints` ablated to return nothing (restore verified by blob hash). Before — the refusal names the key, and nothing tells the author why a key they never wrote is being judged: ```console ✗ Validation failed _root: ✗ unrecognized_keys: Unrecognized key(s) on this stack definition: `ProbeNamedExport`. Until this surface was closed … 1 validation error(s) total ``` After — same refusal, same exit code (1), same `--json` payload, with the rule and the fix added on the text face only: ```console ✗ Validation failed _root: ✗ unrecognized_keys: Unrecognized key(s) on this stack definition: `ProbeNamedExport`. Until this surface was closed … 1 validation error(s) total `ProbeNamedExport` is a NAMED EXPORT of your config file, it is not a key written inside defineStack(). The config file is loaded as a MODULE: every named export is merged onto the default-exported stack as a top-level key, so a named export is legal only when its name is a key the stack schema declares. A helper exported beside the stack is read as a stack key, and refused above. Fix: move it into a sibling module (e.g. objectstack.composition.ts) and import it here. ``` ## What changed - **`packages/cli/src/utils/config.ts`** — `loadConfig()`'s header now states the rule where the rule is created; `LoadedConfig` gained a `namedExports` reading (the provenance the explanation needs, and nothing else); new `namedExportRejectionHints()` turns a **root** `unrecognized_keys` issue into the rule and the fix, for exactly the keys the merge put there. - **`packages/cli/src/commands/compile.ts`** and **`validate.ts`** — both authoring doors print it, so `os build` and `os validate` do not disagree about what an author is told. ⛔ Text face only: the `--json` branches are untouched, so no field is added to a published envelope. - **`packages/cli/src/commands/init.ts`** — all three `os init` templates scaffold the constraint as a comment at the head of the config they write. No ADR id, no issue number, no repo-relative path — `init-template-comments-self-contained` sweeps that population and is green. - **`content/docs/getting-started/your-first-project.mdx`** and **`content/docs/deployment/cli.mdx`** — the rule on the config-authoring surface and in the CLI configuration reference, with all three rows of the table above named. - **`.changeset/18171-config-module-named-export-rule.md`** — `@objectstack/cli` patch. ## The pin, and that it can fail `packages/cli/src/utils/config-named-export-rule.test.ts`, 5 cases. The **refusal comes first**, because it is the property the ruling protects: the helper export still reaches the strict parse and is still refused by name, with the `unrecognized_keys` code and the key echoed back. Then the hint's rule and fix; then the two things it must NOT claim (a key the author really did write inside `defineStack()`, and a nested unrecognised key); then the two-door parity. **Reverse verification**, from the committed fix, mutation proved on disk, restored with `git checkout HEAD -- THE_PATH`: ``` mutation `if (offenders.length === 0)` -> `if (offenders.length >= 0)` (the hint returns nothing) on disk grep -c 'offenders.length === 0' 1 -> 0 blob d0043a8 -> a6911cd6 result Tests 1 failed | 4 passed (5) — "the hint carries the RULE and the FIX" restore git checkout HEAD -- …; hash back to d0043a8; `git diff HEAD` empty result Tests 5 passed (5) ``` Direction is worth stating plainly: the two negative cases assert `[]` and therefore stay **green** under this mutation. Only the positive case is load-bearing against it — which is exactly why the file does not consist of negative assertions. ## Verification Tree measured: `f6561b2b0` (working tree identical to that commit throughout; `git status` clean). | run | exit | |:---|:---| | 93 gates derived by `scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` | 92 × 0 | | `pnpm check:cross-package-test-inputs` | **1 — pre-existing, not this PR** | | `pnpm lint` (whole repo, re-run at `f6561b2b0`) | 0 | | `pnpm --filter @objectstack/cli typecheck` | 0 | | `pnpm --filter @objectstack/cli exec vitest run --project unit` | 0 — 209 files, 2980 tests | | `pnpm build` (whole repo) | 0 | `check:cross-package-test-inputs` names `packages/cli/test/init-created-files-summary.e2e.test.ts` descending `packages/spec/dist/` — a file this diff does not touch. Filed as #18353 / #18348; all three sibling cards this round hit it independently. `packages/cli`'s `integration` tier is declared to CI: this diff touches no integration-tier file, no `bin/` entry and no driver/kernel boot path, so only the `unit` project was owed locally. Two gates were red **because of this PR** and are now green: `check-reference-carrier-shape` (exit 3) and `check:comment-mask-corpus` (exit 1) both refused to parse `init.ts`, because the first draft of the scaffold comment used backticks inside the template literal that renders it and closed the literal. Fixed in `f6561b2b0`. ## Acceptance notes Filed via the report on #18171 (class c — a metadata-authoring trap; `dev` does not open cards): - **A named export whose name the default export already carries is dropped silently and the build exits 0.** `if (key in merged) continue` in `loadConfig()`. Measured above: `export const objects = [{…}]` beside `defineStack({ manifest, objects: [] })` leaves `config.objects === []`, the authored row never reaches the artifact, and nothing is printed at any level. Dedupe words: config named export shadowed, loadConfig key in merged, silently dropped stack key, objectstack.config module merge. Noted, not filed: - `os create` (`packages/cli/src/commands/create.ts`) and `create-objectstack` also scaffold an `objectstack.config.ts` and did not get the comment. The dispatch's declared file surface names the `os init` template only, so widening it is the dispatching seat's call rather than this PR's. Carrier: whoever takes the follow-up — it lands in the same `init-template-comments-self-contained` population this PR already passes, so it needs no new verification surface. --- _Generated by [Claude Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f3b4283 commit ecf3e3b

8 files changed

Lines changed: 442 additions & 5 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
The strict-parse refusal now says WHY a key the author never wrote inside `defineStack()` is being judged as a stack key.
6+
7+
`objectstack.config.ts` is loaded as a MODULE: `loadConfig()` takes the default export as the base and
8+
then merges every NAMED export onto it as a top-level stack key, under the export's own name. That is
9+
deliberate — `onEnable` and `functions` are declared stack keys an app authors as named exports, and
10+
unwrapping `mod.default` alone dropped them. The consequence nothing stated is that a named export is
11+
legal only when its name is a key `ObjectStackDefinitionSchema` declares, so a helper exported beside
12+
the stack (`export const collectPackageDirs = …`) arrives at the strict parse as a top-level stack key
13+
of that name and is refused there as unrecognised.
14+
15+
The refusal was already loud and named the key. It is unchanged: same key, same `unrecognized_keys`,
16+
same failing parse, same exit code, same `--json` payload. What `os build` and `os validate` now add,
17+
on the text face only, is the rule it enforces and the fix — move the helper into a sibling module and
18+
import it from the config. `LoadedConfig` gained a `namedExports` reading so that explanation has a
19+
provenance to read instead of guessing; nothing about which configs load has changed.
20+
21+
The same rule is now on the config-authoring docs page, in the CLI configuration reference, and in the
22+
comment every `os init` template ships at the top of the config it scaffolds.

‎content/docs/deployment/cli.mdx‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2030,6 +2030,38 @@ export default defineStack({
20302030
});
20312031
```
20322032
2033+
### What the config file may export
2034+
2035+
The config file is loaded as a **module**, and the whole module is the stack:
2036+
the default export is the base, and then **every named export is merged onto it
2037+
as a top-level stack key**, under the export's own name. `onEnable` and
2038+
`functions` are authored that way on purpose.
2039+
2040+
So a named export is legal **only when its name is a key
2041+
`ObjectStackDefinitionSchema` declares**. A helper exported beside the stack —
2042+
`export const collectPackageDirs = …` — arrives at the strict parse as a
2043+
top-level stack key of that name, and is refused:
2044+
2045+
```console
2046+
$ printf '\nexport const ProbeNamedExport = [1, 2, 3];\n' >> objectstack.config.ts
2047+
$ os build
2048+
✗ Validation failed
2049+
unrecognized_keys: Unrecognized key(s) on this stack definition: `ProbeNamedExport`.
2050+
```
2051+
2052+
Move helpers into a sibling module (`objectstack.composition.ts`, for instance)
2053+
and import them from the config. Two further shapes, documented so they are
2054+
recognisable rather than as patterns to use:
2055+
2056+
| The named export's name | What happens |
2057+
|:---|:---|
2058+
| not a declared stack key | the build **fails**, naming the key (above) |
2059+
| a declared stack key the default export does **not** carry | merged in and **accepted** — the `onEnable` / `functions` path |
2060+
| a key the default export **already** carries | dropped **silently**; the build exits 0 and the exported value is never read |
2061+
2062+
The last row is why every stack key belongs inside `defineStack()`: a second
2063+
copy beside it is not a second declaration.
2064+
20332065
### Config File Auto-Detection
20342066
20352067
The CLI searches for configuration files in this order:

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,37 @@ export default defineStack({
130130
});
131131
```
132132

133+
<Callout type="warn">
134+
**The config file is a module, and the whole module is the stack.** What
135+
`defineStack()` returns is the *base*; every **named export** of
136+
`objectstack.config.ts` is then merged onto it as a **top-level stack key**,
137+
under the export's own name. That is how the `onEnable` and `functions` hooks
138+
are authored — and it is why a helper exported beside the stack is not read as a
139+
helper:
140+
141+
```typescript title="objectstack.config.ts"
142+
export default defineStack({ /* … */ });
143+
144+
// ⛔ Read as a top-level stack key called `collectPackageDirs`.
145+
// `os build` refuses it: Unrecognized key(s) on this stack definition.
146+
export const collectPackageDirs = () => [ /* … */ ];
147+
```
148+
149+
A named export is legal **only when its name is a key the stack schema
150+
declares**. Keep helpers in a sibling module — `objectstack.composition.ts`,
151+
say — and import them here. Two further shapes worth recognising, neither of
152+
them a pattern to rely on:
153+
154+
- a named export whose name **is** a declared stack key that the default export
155+
does not carry is merged in and accepted — that is the `onEnable` /
156+
`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()`.
161+
162+
</Callout>
163+
133164
The REST API needs no configuration — it is on by default for every object
134165
with `apiEnabled`. The `plugins:` array ships the three **generic connector
135166
executors**, so you can integrate an external REST / OpenAPI / MCP service as

‎packages/cli/src/commands/compile.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
formatUnknownAuthoringKey,
1414
type ConversionNotice,
1515
} from '@objectstack/spec';
16-
import { loadConfig } from '../utils/config.js';
16+
import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
1717
import { lowerCallables } from '../utils/lower-callables.js';
1818
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
1919
import { artifactPackages, packageBodyAsStack } from '../utils/artifact-packages.js';
@@ -185,7 +185,7 @@ export default class Compile extends Command {
185185
try {
186186
// 1. Load Configuration
187187
if (!flags.json) printStep('Loading configuration...');
188-
const { config, absolutePath, duration } = await loadConfig(args.config);
188+
const { config, absolutePath, duration, namedExports } = await loadConfig(args.config);
189189

190190
if (!flags.json) {
191191
printKV('Config', path.relative(process.cwd(), absolutePath));
@@ -300,6 +300,17 @@ export default class Compile extends Command {
300300
console.log('');
301301
printError('Validation failed');
302302
formatZodErrors(result.error as unknown as ZodError);
303+
// [#18171] …and, when one of those unrecognised top-level keys got
304+
// there by being a NAMED EXPORT of the config module rather than a key
305+
// the author wrote inside `defineStack()`, the rule that makes it one.
306+
// Text face only: the `--json` branch above is untouched, so no field
307+
// is added to a published envelope.
308+
for (const line of namedExportRejectionHints(
309+
(result.error as unknown as ZodError).issues,
310+
namedExports,
311+
)) {
312+
console.log(chalk.dim(line));
313+
}
303314
this.exit(1);
304315
}
305316

‎packages/cli/src/commands/init.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,12 @@ export const TEMPLATES: Record<string, {
589589
configContent: (name: string, namespace: string) => `import { defineStack } from '@objectstack/spec';
590590
import * as objects from './src/objects';
591591
592+
// This file is a MODULE, and the whole module is the stack: the default
593+
// export below is the base, and every NAMED export is merged onto it as a
594+
// top-level stack key under its own name. A helper exported from here is
595+
// therefore read as a stack key and the build refuses it — keep helpers in a
596+
// sibling module and import them. Only names the stack schema declares
597+
// (onEnable, functions, the collections) belong here as named exports.
592598
export default defineStack({
593599
manifest: {
594600
id: 'com.example.${namespace}',
@@ -677,6 +683,12 @@ export default ${toCamelCase(namespace)}Item;
677683
configContent: (name: string, namespace: string) => `import { defineStack } from '@objectstack/spec';
678684
import * as objects from './src/objects';
679685
686+
// This file is a MODULE, and the whole module is the stack: the default
687+
// export below is the base, and every NAMED export is merged onto it as a
688+
// top-level stack key under its own name. A helper exported from here is
689+
// therefore read as a stack key and the build refuses it — keep helpers in a
690+
// sibling module and import them. Only names the stack schema declares
691+
// (onEnable, functions, the collections) belong here as named exports.
680692
export default defineStack({
681693
manifest: {
682694
id: 'com.objectstack.plugin-${name}',
@@ -749,6 +761,12 @@ export default ${toCamelCase(namespace)}Item;
749761
},
750762
configContent: (name: string, namespace: string) => `import { defineStack } from '@objectstack/spec';
751763
764+
// This file is a MODULE, and the whole module is the stack: the default
765+
// export below is the base, and every NAMED export is merged onto it as a
766+
// top-level stack key under its own name. A helper exported from here is
767+
// therefore read as a stack key and the build refuses it — keep helpers in a
768+
// sibling module and import them. Only names the stack schema declares
769+
// (onEnable, functions, the collections) belong here as named exports.
752770
export default defineStack({
753771
manifest: {
754772
id: 'com.example.${namespace}',

‎packages/cli/src/commands/validate.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
formatUnknownAuthoringKey,
1313
type ConversionNotice,
1414
} from '@objectstack/spec';
15-
import { loadConfig } from '../utils/config.js';
15+
import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
1616
import { lowerCallables } from '../utils/lower-callables.js';
1717
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
1818
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
@@ -179,7 +179,7 @@ export default class Validate extends Command {
179179
try {
180180
// 1. Load configuration
181181
if (!flags.json) printStep('Loading configuration...');
182-
const { config, absolutePath, duration } = await loadConfig(args.config);
182+
const { config, absolutePath, duration, namedExports } = await loadConfig(args.config);
183183

184184
if (!flags.json) {
185185
printKV('Config', absolutePath);
@@ -285,6 +285,17 @@ export default class Validate extends Command {
285285
console.log('');
286286
printError('Validation failed');
287287
formatZodErrors(result.error as unknown as ZodError);
288+
// [#18171] …and, when one of those unrecognised top-level keys got
289+
// there by being a NAMED EXPORT of the config module rather than a key
290+
// the author wrote inside `defineStack()`, the rule that makes it one.
291+
// Text face only: the `--json` branch above is untouched, so no field
292+
// is added to a published envelope.
293+
for (const line of namedExportRejectionHints(
294+
(result.error as unknown as ZodError).issues,
295+
namedExports,
296+
)) {
297+
console.log(chalk.dim(line));
298+
}
288299
this.exit(1);
289300
}
290301

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The config module's NAMED EXPORTS are top-level stack keys, and the refusal
5+
* now says so (#18171).
6+
*
7+
* ── The finding ──────────────────────────────────────────────────────────
8+
*
9+
* `objectstack.config.ts` is loaded as a MODULE: `loadConfig()` takes the
10+
* default export as the base and merges every named export onto it as a
11+
* top-level stack key. The strict `ObjectStackDefinitionSchema` then refuses
12+
* any of those names it does not declare. Measured on this tree, appending
13+
* `export const ProbeNamedExport = [1, 2, 3];` to a valid config:
14+
*
15+
* ✗ Validation failed
16+
* unrecognized_keys: Unrecognized key(s) on this stack definition: `ProbeNamedExport`.
17+
*
18+
* The refusal is loud and names the key — that is the property worth keeping,
19+
* and it is why the parse is NOT being relaxed to read `default` alone. What no
20+
* docs page, no scaffold comment and no diagnostic said is the RULE the refusal
21+
* enforces: an author who never wrote `ProbeNamedExport` inside `defineStack()`
22+
* has no way to learn from that sentence that exporting a helper beside the
23+
* stack is what put it there.
24+
*
25+
* ── What is pinned here ──────────────────────────────────────────────────
26+
*
27+
* • the refusal itself is UNCHANGED — same key, same `unrecognized_keys`
28+
* code, same failing parse. This change adds an explanation, ⛔ never an
29+
* acceptance. The pin is first because it is the constraint everything
30+
* else in this file must not break;
31+
* • `loadConfig` reports the names it merged, so the explanation has a
32+
* provenance to read instead of guessing;
33+
* • the hint states the rule and the fix, and names the offending key;
34+
* • it stays SILENT for an unrecognised key the author really did write
35+
* inside `defineStack()` — mis-attributing that one would send them to
36+
* edit a file that is already correct;
37+
* • it stays silent for a NESTED unrecognised key, which belongs to a
38+
* different surface with a different explanation;
39+
* • both authoring doors that print the strict-parse failure — `os build`
40+
* (via `os compile`) and `os validate` — print it, so the two do not
41+
* disagree about what an author is told.
42+
*/
43+
44+
import { describe, it, expect, afterAll } from 'vitest';
45+
import fs from 'node:fs';
46+
import path from 'node:path';
47+
import { fileURLToPath } from 'node:url';
48+
import { ObjectStackDefinitionSchema } from '@objectstack/spec';
49+
50+
import { loadConfig, namedExportRejectionHints } from './config.js';
51+
52+
const HERE = path.dirname(fileURLToPath(import.meta.url));
53+
/** `packages/cli/tmp` — throwaway projects, the placement sibling suites use. */
54+
const TMP_ROOT = path.resolve(HERE, '..', '..', 'tmp');
55+
/** The two commands that print the strict-parse failure to a human. */
56+
const COMPILE_SRC = path.resolve(HERE, '..', 'commands', 'compile.ts');
57+
const VALIDATE_SRC = path.resolve(HERE, '..', 'commands', 'validate.ts');
58+
59+
/** The card's own probe export — the name a reader greps for. */
60+
const PROBE = 'ProbeNamedExport';
61+
62+
const MANIFEST = `{
63+
id: 'com.example.probe',
64+
namespace: 'probe',
65+
version: '0.1.0',
66+
type: 'app',
67+
name: 'Probe',
68+
engines: { protocol: '^17' },
69+
}`;
70+
71+
const roots: string[] = [];
72+
afterAll(() => {
73+
for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true });
74+
});
75+
76+
/** Write a throwaway project carrying `body` as its config, return that path. */
77+
function writeConfig(tag: string, body: string): string {
78+
fs.mkdirSync(TMP_ROOT, { recursive: true });
79+
const dir = fs.mkdtempSync(path.join(TMP_ROOT, `named-export-${tag}-`));
80+
roots.push(dir);
81+
const file = path.join(dir, 'objectstack.config.ts');
82+
fs.writeFileSync(file, body);
83+
return file;
84+
}
85+
86+
describe('#18171 — a named export of the config module IS a top-level stack key', () => {
87+
it('still refuses the helper export, by name — the property this change must not spend', async () => {
88+
const file = writeConfig('refusal', `import { defineStack } from '@objectstack/spec';
89+
90+
export default defineStack({
91+
manifest: ${MANIFEST},
92+
});
93+
94+
export const ${PROBE} = [1, 2, 3];
95+
`);
96+
97+
const { config, namedExports } = await loadConfig(file);
98+
99+
// The merge is what makes this a stack key at all — the author wrote it as
100+
// an export, and it arrives at the schema as a top-level key.
101+
expect(Object.keys(config)).toContain(PROBE);
102+
expect(namedExports).toEqual([PROBE]);
103+
104+
const result = ObjectStackDefinitionSchema.safeParse(config);
105+
expect(result.success).toBe(false);
106+
if (result.success) return;
107+
108+
const unrecognized = result.error.issues.filter((i) => i.code === 'unrecognized_keys');
109+
expect(unrecognized).toHaveLength(1);
110+
expect((unrecognized[0] as unknown as { keys: string[] }).keys).toEqual([PROBE]);
111+
// The surface is still named and the key still echoed — a relaxed parse
112+
// would have produced a silent success here instead.
113+
expect(unrecognized[0].message).toContain(PROBE);
114+
}, 60_000);
115+
116+
it('the hint carries the RULE and the FIX, and names the key', async () => {
117+
const file = writeConfig('hint', `import { defineStack } from '@objectstack/spec';
118+
119+
export default defineStack({
120+
manifest: ${MANIFEST},
121+
});
122+
123+
export const ${PROBE} = [1, 2, 3];
124+
`);
125+
126+
const { config, namedExports } = await loadConfig(file);
127+
const result = ObjectStackDefinitionSchema.safeParse(config);
128+
expect(result.success).toBe(false);
129+
if (result.success) return;
130+
131+
const hints = namedExportRejectionHints(result.error.issues, namedExports).join('\n');
132+
133+
// The three things an author who hits this needs, and did not have.
134+
expect(hints).toContain(PROBE); // which export
135+
expect(hints).toContain('NAMED EXPORT'); // what it is
136+
expect(hints).toContain('merged onto the default-exported'); // why it is judged
137+
expect(hints).toContain('sibling module'); // what to do instead
138+
// …and the rule stated as a rule, not as a description of this one run.
139+
expect(hints).toMatch(/legal only when its name is a key the stack\s+schema declares/);
140+
}, 60_000);
141+
142+
it('says nothing about a key the author really did write inside defineStack()', () => {
143+
// No named export at all: the existing refusal is already the whole truth,
144+
// and pointing at a sibling module would send this author to the wrong file.
145+
const issues = [{ code: 'unrecognized_keys', keys: ['objectz'], path: [] }];
146+
expect(namedExportRejectionHints(issues, [])).toEqual([]);
147+
// …and the same with an unrelated named export present: `objectz` was not
148+
// merged, so it is not this rule's to explain.
149+
expect(namedExportRejectionHints(issues, ['onEnable'])).toEqual([]);
150+
});
151+
152+
it('says nothing about a NESTED unrecognised key', () => {
153+
// A key inside an object/view/package body is a different surface. Even
154+
// when the name collides with something the merge put on the root, the
155+
// nested occurrence is not the merge's doing.
156+
const nested = [{ code: 'unrecognized_keys', keys: [PROBE], path: ['objects', 0] }];
157+
expect(namedExportRejectionHints(nested, [PROBE])).toEqual([]);
158+
});
159+
160+
it('both authoring doors print it — os build and os validate do not disagree', () => {
161+
for (const [label, src] of [['compile', COMPILE_SRC], ['validate', VALIDATE_SRC]] as const) {
162+
const text = fs.readFileSync(src, 'utf8');
163+
expect(text, `${label} does not import the hint`).toContain('namedExportRejectionHints');
164+
expect(
165+
text.indexOf('namedExportRejectionHints('),
166+
`${label} imports the hint but never calls it`,
167+
).toBeGreaterThan(text.indexOf("from '../utils/config.js'"));
168+
}
169+
});
170+
});

0 commit comments

Comments
 (0)