|
| 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