|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Every scaffold this package ships must emit a `manifest:` block that |
| 5 | + * `ManifestSchema` accepts — the schema the user's very first command parses |
| 6 | + * it with. |
| 7 | + * |
| 8 | + * ## The defect this pins |
| 9 | + * |
| 10 | + * `os create example <name>` wrote an `objectstack.config.ts` whose manifest |
| 11 | + * was three keys — `name`, `version`, `description` — and nothing else. |
| 12 | + * `ManifestSchema` requires `id` (the reverse-domain package id) and `type` |
| 13 | + * (`app` | `plugin` | …), and `namespace` decides every object name, table |
| 14 | + * name and REST path (there was none). Parsed against the schema the block |
| 15 | + * answered: |
| 16 | + * |
| 17 | + * success : false |
| 18 | + * issues : invalid_type@id · invalid_value@type |
| 19 | + * |
| 20 | + * `defineStack` throws on exactly that, so a project scaffolded by a |
| 21 | + * documented command refused on its first run, before the author had written |
| 22 | + * a line. The three `os init` templates all stamped the identity block; the |
| 23 | + * `create` template was the one scaffold that had drifted, and nothing |
| 24 | + * noticed because no test ever looked at these templates as DATA. |
| 25 | + * |
| 26 | + * ## Why the sweep spans both scaffolders, and why it is derived |
| 27 | + * |
| 28 | + * The defect class is "a shipped scaffold whose manifest the shipped schema |
| 29 | + * refuses", and this package has two independent scaffold sources — |
| 30 | + * `init.ts`'s `TEMPLATES` and `create.ts`'s `templates`. Pinning only the |
| 31 | + * reported one would leave the other free to drift the same way, which is how |
| 32 | + * this one arrived. So the population is DERIVED from both maps (every entry |
| 33 | + * that emits an `objectstack.config.ts`), never written down: a template added |
| 34 | + * later is swept the day it is added, with nobody remembering to extend this |
| 35 | + * file. |
| 36 | + * |
| 37 | + * `create`'s `plugin` template contributes nothing here on purpose — it emits |
| 38 | + * no `objectstack.config.ts` at all. Its scaffolded `src/index.ts` declares a |
| 39 | + * `Plugin` object, a different contract from this package manifest, and a |
| 40 | + * sweep that pretended otherwise would report on a surface `ManifestSchema` |
| 41 | + * does not govern. |
| 42 | + * |
| 43 | + * ## Why the manifest is read back off a LOADED config, not off the source text |
| 44 | + * |
| 45 | + * Both scaffolders render their config as a template literal, so the only |
| 46 | + * honest reading of "what the scaffold declares" is the object the rendered |
| 47 | + * file actually evaluates to. The rendered file is written to disk and loaded |
| 48 | + * through `bundle-require` — the same loader `scaffold-validate.ts` uses for |
| 49 | + * `init`'s self-test — so what is parsed here is the real emitted artifact and |
| 50 | + * not a literal copied into a test, which is free to agree with a template |
| 51 | + * that has since changed. |
| 52 | + * |
| 53 | + * Temp projects go under this package's git-ignored `tmp/` (not |
| 54 | + * `os.tmpdir()`) because the rendered config imports `@objectstack/spec`, |
| 55 | + * which only resolves where Node can walk up into this package's |
| 56 | + * `node_modules` — the same constraint, for the same reason, as |
| 57 | + * `init-scaffold-authoring-rules.test.ts`. Keeping generated `.ts` out of |
| 58 | + * `test/` also keeps it away from any glob that collects sources. |
| 59 | + * |
| 60 | + * ## Why `ManifestSchema` is parsed explicitly, when `defineStack` already ran |
| 61 | + * |
| 62 | + * `defineStack` validates through `ObjectStackDefinitionSchema`, where |
| 63 | + * `manifest` is `ManifestSchema.optional()`. Two live gaps follow from that |
| 64 | + * `.optional()`, and both are the failure this file exists to catch: |
| 65 | + * |
| 66 | + * - a template that drops the `manifest:` block ENTIRELY loads green — the |
| 67 | + * stack door has nothing to check — and ships a project with no id, no |
| 68 | + * namespace and no type; |
| 69 | + * - a template that calls `defineStack(config, { strict: false })` skips the |
| 70 | + * parse altogether. |
| 71 | + * |
| 72 | + * Neither would redden a pin that only asserted "the config loads". So the |
| 73 | + * load is the first assertion, and the standalone parse is the one that does |
| 74 | + * not depend on the door staying the way it is today. |
| 75 | + */ |
| 76 | + |
| 77 | +import { describe, it, expect, afterAll } from 'vitest'; |
| 78 | +import fs from 'node:fs'; |
| 79 | +import path from 'node:path'; |
| 80 | +import { fileURLToPath } from 'node:url'; |
| 81 | +import { ManifestSchema } from '@objectstack/spec/kernel'; |
| 82 | +import { TEMPLATES, sanitizeNamespace, writeTemplateSrcFiles } from '../src/commands/init.js'; |
| 83 | +import { templates as createTemplates } from '../src/commands/create.js'; |
| 84 | + |
| 85 | +const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 86 | +const TMP_ROOT = path.resolve(HERE, '../tmp'); |
| 87 | +const PROJECT_NAME = 'my-app'; |
| 88 | +const CONFIG_FILE = 'objectstack.config.ts'; |
| 89 | + |
| 90 | +const roots: string[] = []; |
| 91 | +afterAll(() => { |
| 92 | + for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true }); |
| 93 | +}); |
| 94 | + |
| 95 | +interface Scaffold { |
| 96 | + /** `<command>:<template key>` — the command a user would have typed. */ |
| 97 | + id: string; |
| 98 | + /** |
| 99 | + * Write this scaffold's TypeScript into `root`, through its own emitter. |
| 100 | + * |
| 101 | + * TypeScript only, and deliberately: a config that imports `./src/objects` |
| 102 | + * needs that module on disk to load at all, while the `package.json` and |
| 103 | + * `tsconfig.json` the scaffolders also write are monorepo-relative |
| 104 | + * (`workspace:*` deps, `extends: '../../tsconfig.json'`) and resolve to |
| 105 | + * nothing from a throwaway directory. Neither one can change what the |
| 106 | + * manifest declares, so emitting them would buy a resolution failure and no |
| 107 | + * coverage. A future template whose config imports a NON-TypeScript file it |
| 108 | + * emits would fail loudly here, on the resolve, rather than quietly. |
| 109 | + */ |
| 110 | + emit: (root: string) => void; |
| 111 | +} |
| 112 | + |
| 113 | +/** |
| 114 | + * `os init -t <key>`: every template renders a config, so the whole map |
| 115 | + * contributes. Both halves go through `init`'s own emitter — the same |
| 116 | + * `configContent` / `writeTemplateSrcFiles` pair the command calls, and the |
| 117 | + * pair `init-scaffold-authoring-rules.test.ts` drives, so neither test can |
| 118 | + * drift from what `init` really writes. |
| 119 | + */ |
| 120 | +const initScaffolds: Scaffold[] = Object.keys(TEMPLATES).map((key) => ({ |
| 121 | + id: `init:${key}`, |
| 122 | + emit: (root: string) => { |
| 123 | + const namespace = sanitizeNamespace(PROJECT_NAME); |
| 124 | + fs.writeFileSync( |
| 125 | + path.join(root, CONFIG_FILE), |
| 126 | + TEMPLATES[key].configContent(PROJECT_NAME, namespace), |
| 127 | + ); |
| 128 | + writeTemplateSrcFiles(TEMPLATES[key].srcFiles, root, PROJECT_NAME, namespace); |
| 129 | + }, |
| 130 | +})); |
| 131 | + |
| 132 | +/** |
| 133 | + * `os create <key> <name>`: only the templates whose file map carries an |
| 134 | + * `objectstack.config.ts` contribute — derived from the map, so a template |
| 135 | + * that grows one later is swept without an edit here. `create` has no |
| 136 | + * `srcFiles` split; every file it writes lives in one `files` map, keyed by |
| 137 | + * the path it lands at, and is rendered by calling that entry — which is |
| 138 | + * exactly what `Create.run()` does. |
| 139 | + */ |
| 140 | +const createScaffolds: Scaffold[] = Object.entries(createTemplates) |
| 141 | + .filter(([, template]) => CONFIG_FILE in template.files) |
| 142 | + .map(([key, template]) => ({ |
| 143 | + id: `create:${key}`, |
| 144 | + emit: (root: string) => { |
| 145 | + const files = template.files as Record<string, (name: string) => unknown>; |
| 146 | + for (const [filePath, render] of Object.entries(files)) { |
| 147 | + if (!filePath.endsWith('.ts')) continue; |
| 148 | + const abs = path.join(root, filePath); |
| 149 | + fs.mkdirSync(path.dirname(abs), { recursive: true }); |
| 150 | + fs.writeFileSync(abs, String(render(PROJECT_NAME))); |
| 151 | + } |
| 152 | + }, |
| 153 | + })); |
| 154 | + |
| 155 | +const SCAFFOLDS: Scaffold[] = [...initScaffolds, ...createScaffolds]; |
| 156 | + |
| 157 | +/** Emit one scaffold into a throwaway directory and load its config back. */ |
| 158 | +async function loadStack(scaffold: Scaffold): Promise<Record<string, unknown>> { |
| 159 | + fs.mkdirSync(TMP_ROOT, { recursive: true }); |
| 160 | + const root = fs.mkdtempSync(path.join(TMP_ROOT, `manifest-${scaffold.id.replace(':', '-')}-`)); |
| 161 | + roots.push(root); |
| 162 | + scaffold.emit(root); |
| 163 | + |
| 164 | + const { bundleRequire } = await import('bundle-require'); |
| 165 | + const { mod } = await bundleRequire({ filepath: path.join(root, CONFIG_FILE), cwd: root }); |
| 166 | + return (mod.default ?? mod) as Record<string, unknown>; |
| 167 | +} |
| 168 | + |
| 169 | +describe('every shipped scaffold emits a manifest `ManifestSchema` accepts', () => { |
| 170 | + // A sweep that swept nothing reports exactly what a clean tree reports. The |
| 171 | + // counts are DERIVED from the two maps rather than frozen, so this stays a |
| 172 | + // check that the filters still match something — not a copy of today's |
| 173 | + // template list that the next added template makes stale. |
| 174 | + it('sweeps both scaffolders, and every template that emits a config', () => { |
| 175 | + expect(initScaffolds.length).toBe(Object.keys(TEMPLATES).length); |
| 176 | + expect(initScaffolds.length).toBeGreaterThan(0); |
| 177 | + expect(createScaffolds.length).toBeGreaterThan(0); |
| 178 | + expect(SCAFFOLDS.length).toBe(initScaffolds.length + createScaffolds.length); |
| 179 | + }); |
| 180 | + |
| 181 | + // The reported instance, named so a future edit that drops the identity |
| 182 | + // block again fails with the incident's own vocabulary rather than a bare |
| 183 | + // count. |
| 184 | + it('includes `os create example` — the scaffold that drifted', () => { |
| 185 | + expect(SCAFFOLDS.map((s) => s.id)).toContain('create:example'); |
| 186 | + }); |
| 187 | + |
| 188 | + it.each(SCAFFOLDS.map((s) => s.id))( |
| 189 | + 'scaffold "%s" declares a manifest the protocol schema accepts', |
| 190 | + async (id) => { |
| 191 | + const scaffold = SCAFFOLDS.find((s) => s.id === id)!; |
| 192 | + |
| 193 | + let stack: Record<string, unknown>; |
| 194 | + try { |
| 195 | + stack = await loadStack(scaffold); |
| 196 | + } catch (error) { |
| 197 | + // `defineStack` refuses an invalid manifest by throwing, so this IS |
| 198 | + // the first-run failure the user sees. Re-raise it with the scaffold |
| 199 | + // named, because the thrown text alone does not say which one. |
| 200 | + throw new Error( |
| 201 | + `scaffold "${id}" produces a project that refuses to load — the user's very first ` |
| 202 | + + `command fails on this:\n${error instanceof Error ? error.message : String(error)}`, |
| 203 | + ); |
| 204 | + } |
| 205 | + |
| 206 | + // `ObjectStackDefinitionSchema.manifest` is `.optional()`, so a missing |
| 207 | + // block is silent at the door above. It is not silent here. |
| 208 | + expect(stack.manifest, `scaffold "${id}" declares no \`manifest:\` block`).toBeDefined(); |
| 209 | + |
| 210 | + const result = ManifestSchema.safeParse(stack.manifest); |
| 211 | + const issues = result.success |
| 212 | + ? '' |
| 213 | + : result.error.issues |
| 214 | + .map((i) => `${i.code}@${i.path.join('.') || '<root>'}: ${i.message}`) |
| 215 | + .join('\n '); |
| 216 | + expect( |
| 217 | + result.success, |
| 218 | + `scaffold "${id}" emits a manifest \`ManifestSchema\` refuses:\n ${issues}`, |
| 219 | + ).toBe(true); |
| 220 | + }, |
| 221 | + 120_000, |
| 222 | + ); |
| 223 | +}); |
0 commit comments