Skip to content

Commit 0bd1126

Browse files
fix(cli): os init and os generate object declare the scaffolded object with ObjectSchema.create (#20195)
Fixes #19722 Clause-②: no Ruling `5644350230` (director seat, decision batch #122 item 1, maintainer 「同意」 2026-09-12), item 1: 「`packages/cli/src/commands/init.ts` `TEMPLATES` emit the factory shape; `content/docs/deployment/cli.mdx:1323` describes it.」 Item 3: 「the changeset states how a user converts theirs (one mechanical rewrite: wrap the literal).」 This is the `domain:cli` half; the spec/scripts half landed as PR #19720 (`42339e2f`). #17418 remains open (it carries `Blocked-by` on this card and is the spec lane's to move). #19098 remains open (the other `generate.ts` card, serial behind this one). ## What changed Both doors that write a `*.object.ts` now emit the one authorised shape, `ObjectSchema.create({ … })`: | door | before | after | |:--|:--|:--| | `os init -t app` / `-t plugin` (`TEMPLATES[…].srcFiles`) | `import * as Data …` + `const myAppItem: Data.ServiceObject = { … };` | `import { ObjectSchema } …` + `const myAppItem = ObjectSchema.create({ … });` | | `os generate object` (`GENERATORS.object`) | `const orderLine: Data.ServiceObject = { … };` | `const orderLine = ObjectSchema.create({ … });` | - The emitted shape is exactly what the changeset's user rewrite produces from the old one (wrap the literal, drop the annotation, import the factory), so a scaffold and a converted file look the same. - `ObjectSchema` is a **value** import: `import type` is erased at compile time and the module would throw on first evaluation. - The binding stays the file's **default export**. Both barrels (`os init`'s `src/objects/index.ts` and the line `os generate` appends for all seven generators) re-export `default`, so no barrel spelling moves and no user barrel needs touching. - The authored OWD comment block is unchanged byte for byte in all three emitters (only the closing `};` became `});`); `init-template-comments-self-contained.test.ts` is green. - `generate.ts`'s docblock states that the init/generate parity now covers the declaration shape as well as the `sharingModel` value, and names the pin that holds it. ## Premise check (on `origin/main`, sites located by symbol) - `TEMPLATES` (both object-bearing entries) and `GENERATORS.object.generate` emitted the annotated literal: confirmed. - `create-objectstack`'s bundled `blank/src/objects/note.object.ts` is already `export const Note = ObjectSchema.create({ … })`: confirmed. - `scripts/sync-scaffold-emission-policy.mjs` syncs the pnpm/TypeScript ranges only and reads no declaration shape; `pnpm check:scaffold-emission-policy` was run (read-only `--check`) and is green. - The ruling's `cli.mdx:1323` anchor has drifted with later edits. The page's only description of the scaffolded object shape was the `os generate` "What it does" line (it named `Data.ServiceObject`); that line now describes the factory (and names `defineSkill({ … })` for `skill`, the one non-object type that is not a typed literal), and the `os init` section gains a short paragraph naming the shape and the one mechanical rewrite for older projects. ## Measured: does the #19720 gate reach a scaffold? Before and after Built `@objectstack/cli` at the base and at this branch, ran `os init my-app -t app` and `os init my-plugin -t plugin` (`--no-install`, under `packages/cli/node_modules` so `@objectstack/spec` is found by the upward walk), then `os g object my_app_order_line` in each, then the project's own gates. The repo gate was driven through its exported `sweep()` over a tree holding the four scaffolded object files (plus the driver file it reads its text family from). | reading | before (base `3bd28e2b`) | after (this branch) | |:--|:--|:--| | `os validate` / `os compile` / `tsc --noEmit`, init only | exit 0 / 0 / 0 (both templates) | exit 0 / 0 / 0 | | same, after `os g object` | exit 0 / 0 / 0 | exit 0 / 0 / 0 | | `check-keyed-text-bounds` `sweep()` over the 4 scaffolded files | 0 objects parsed, **4 shape violations** (`… is declared as a plain object literal — use ObjectSchema.create`) | **4 objects parsed, 0 shape violations**, 0 refusals | | compiled `dist/objectstack.json` | sha256 `3981f1ab…` (app), `e6d2c61d…` (plugin) | **byte-identical** (`cmp` equal) | So the platform's own shape gate refused every scaffold before this change, but only as a repo script: a user project carries no `scripts/`, and `os validate` / `os compile` never judged the shape. After it, the gate parses all four. The compiled artifact is byte-identical, which is the measured basis for `Clause-②: no` (no published payload changes). ## Pins - **New**: `packages/cli/test/scaffold-object-declaration-shape.test.ts` reads every emitter's bytes with the TypeScript parser (roster derived from `TEMPLATES` and `GENERATOR_SCAFFOLD_TARGETS`) and asserts: value import of `ObjectSchema` from `@objectstack/spec/data`; exactly one top-level declaration, initialised by `ObjectSchema.create({…})`, with no annotation; the default export is that binding; and **one signature across `os init` and `os generate object`**, which is the parity the docblock claims. Two controls prove the reader can refuse each half (the pre-ruling annotated literal; a type-only factory import). - **Repointed** (they asserted the refused spelling, per the `domain:services` pointer `5788276757`): `generate-emission-parses.test.ts` (`:148` and the `class` discriminator, which asserted `const class:`), `generate-refuses-unparseable-name.test.ts:255`, and the worked examples in `emitted-source-parses.ts`, `generate-emission-parses.test.ts` and the `generate.ts` refusal comment. Docblock-only: `scaffold-emission-typechecks.test.ts` (why the pin still stands after the annotation is gone) and `generate-refuses-name-outside-charset.test.ts` (`const class:` → `const class =`). - Unchanged and still covering it: `scaffold-emission-typechecks.test.ts` (tsc over every emitted scaffold), `generate-scaffold-validates.test.ts` and `init-scaffold-authoring-rules.test.ts` (runtime loads, which now execute the factory), `init.test.ts` (its assertions are name and barrel, not shape). ### Ablation (the new pin can fail) Committed first, then `node scripts/ablation-replace.mjs` swapped the `os generate object` emitter's `import { ObjectSchema }` for `import type { ObjectSchema }` and ran the pin: **2 failed / 5 passed**. The failures were `'os generate object order_line'` (`is not value-imported … (type-only)`) and `one signature across every door` (the generate door's signature diverged). Restore proven by the tool: blob `03b8006959dc` == HEAD and `git diff HEAD` empty. The direction observed was red, as expected. ## Verification (head `3082b024`, after merging `origin/main` `836aad2a`; round 1 at `468000c4` below) `main` moved under this branch with PR #20164 (same package), so the suite was re-run after the merge: - `@objectstack/cli` unit tier, `vitest run --project unit --maxWorkers=2 --shard=N/4` × 4: **226 files / 3199 tests passed** (949 + 780 + 723 + 747). - `@objectstack/cli` integration tier, run locally because the diff touches two integration-tier files: `generate-refuses-unparseable-name` + `generate-refuses-name-outside-charset`, **2 files / 25 tests passed**. The rest of the integration tier is declared to CI. - `pnpm --filter @objectstack/cli typecheck` (tsc + `check:test-typecheck`): exit 0; the new test file is in the test program (`tsc -p tsconfig.test.json --listFilesOnly` counts it). - `pnpm lint` (full, `eslint . --no-inline-config`): exit 0. - Gates derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (94): all 94 exit 0; `--ran` verdict: `94 derived famil(ies) accounted for — 94 run, 0 NOT-MEASURED`. - Before the merge (head `20526f3d`): unit tier 225 files / 3163 tests passed, the same two integration files 25/25, typecheck exit 0. - Round 1 (head `468000c4`: `origin/main` `d7c02413` merged as `e5499d52`, then the one-sentence `cli.mdx` correction naming `defineSkill` for `skill`): the 41 docs-scoped gates (`dispatch-gates --commands content/docs/deployment/cli.mdx`) all exit 0, `--ran` 41 of 41 accounted for, 0 NOT-MEASURED; `pnpm lint` exit 0; `node scripts/check-issue-citations.mjs` answered `no issue citations added against d7c0241 (3 file(s) read)`. The cli test tiers were not re-run locally on this head; CI runs them. ## Acceptance notes - `scripts/check-keyed-text-bounds.mjs`'s refusal text says 「the `os init` shape imports only `* as Data`」. After this change that describes the shape older `os init` releases emitted, not the current one; it is still the right advice for a converted file. `scripts/**` is read-only for this lane. Carrier: the spec lane when it next touches that gate (for example when #17418 is unblocked). Noted, not filed. - Reported to the seat, not addressed here: in an `os init` project, `os g object order_line` writes `name: 'order_line'`, and the project's own `os validate` then refuses it (`Object 'order_line' is missing the package namespace prefix`). Measured at the base; this PR does not change it. - Local tooling observation: `pnpm check:type-check-debt` (`--re-measure`) runs a whole-workspace `turbo run build` before tsc. A local timeout that kills it mid-build leaves some packages' `dist/` without declarations, and `check:dual-build-cjs-loads` then flags them. Rebuilding the two packages cleared it; CI builds fresh. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 172b4cf commit 0bd1126

10 files changed

Lines changed: 278 additions & 20 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os init` (the `app` and `plugin` templates) and `os generate object` now declare the object they scaffold with `ObjectSchema.create({ … })` — the one authorised shape for a `*.object.ts` — instead of a `Data.ServiceObject`-annotated object literal (#19722).
6+
7+
The factory parses the declaration against `ObjectSchema` when the file is evaluated, so a mistake surfaces in the file where it was written; the typed literal deferred every check to a build the author might never run. `create-objectstack`'s starter, the data-modeling docs ("Every object definition follows this pattern") and every object file in this repository already used the factory — the two CLI doors were the outliers, and they now write the same shape as each other and as everything else.
8+
9+
- **What a new scaffold contains**: `import { ObjectSchema } from '@objectstack/spec/data';` (a value import — the factory runs), `const myAppItem = ObjectSchema.create({ … });`, and the unchanged `export default myAppItem;`. The barrel lines both commands write (`export { default as … }`) are unchanged, as are the object's fields, its `sharingModel` and the comment explaining it.
10+
- **Projects you already scaffolded keep working.** Nothing reads the old file differently at runtime, and nothing here renames or rewrites a file you have.
11+
- **Converting an existing file is one mechanical rewrite** — wrap the literal in `ObjectSchema.create( … )`, drop the annotation, and import the factory:
12+
13+
```ts
14+
// before
15+
import * as Data from '@objectstack/spec/data';
16+
const myAppItem: Data.ServiceObject = { name: 'my_app_item', /* … */ };
17+
export default myAppItem;
18+
19+
// after
20+
import { ObjectSchema } from '@objectstack/spec/data';
21+
const myAppItem = ObjectSchema.create({ name: 'my_app_item', /* … */ });
22+
export default myAppItem;
23+
```
24+
25+
If the converted file now throws when it loads, the factory has found something the literal was carrying unchecked — an unknown top-level key, for example — and the message names it.

‎content/docs/deployment/cli.mdx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,15 @@ os init my-app --no-install # Skip dependency installation
141141
| `plugin` | **Metadata package**: declarative objects, built by `objectstack compile`, `private` — *not* the kernel code plugin `os create plugin` emits |
142142
| `empty` | Minimal project with just `objectstack.config.ts` |
143143

144+
The `app` and `plugin` templates declare their starter object with
145+
`ObjectSchema.create({ … })` — the one authorised shape for a `*.object.ts`, and the
146+
same one `os generate object` writes. The factory validates the declaration against
147+
the object protocol when the file is evaluated, so a mistake surfaces in the file
148+
where it was written. A project scaffolded by an earlier release carries a
149+
`Data.ServiceObject`-annotated object literal instead; converting it is one mechanical
150+
rewrite — wrap the literal in `ObjectSchema.create( … )`, drop the annotation, and
151+
import `ObjectSchema` from `@objectstack/spec/data`.
152+
144153
#### `os dev`
145154

146155
Starts development mode. Three usage shapes:
@@ -1438,7 +1447,7 @@ third-party extension primitive, authored as `src/skills/<name>.skill.ts` with
14381447
- `--dry-run` — Preview without writing files
14391448
14401449
**What it does:**
1441-
1. Creates a typed TypeScript file using `Data.ServiceObject`, `UI.View`, `Automation.Flow`, etc.
1450+
1. Creates the TypeScript file — an `object` declared with `ObjectSchema.create({ … })`, the same shape the `os init` templates write; a `skill` declared with `defineSkill({ … })`; the other types as typed literals (`UI.View`, `UI.Action`, `Automation.Flow`, `UI.Dashboard`, `UI.App`)
14421451
2. Creates or updates the barrel `index.ts` in the target directory
14431452
3. Shows a hint to run `objectstack validate`
14441453

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,25 @@ const GENERATORS: Record<string, {
9090
* `os init` templates, and this emits the SAME value with the same
9191
* explanation, so the two doors an author can arrive through agree. If
9292
* that template's value ever moves, this one moves with it.
93+
*
94+
* The same parity covers the DECLARATION SHAPE. Both doors declare the
95+
* object through `ObjectSchema.create({ … })`, the one authorised shape
96+
* for a `*.object.ts` (ruling 5644350230, decision batch #122 item 1): the
97+
* factory parses the declaration against `ObjectSchema` when the file is
98+
* evaluated, so a mistake surfaces where it was written instead of at a
99+
* build the author may never run. `ObjectSchema` is imported as a VALUE —
100+
* a type-only import is erased at compile time, and the emitted module
101+
* would then throw on its first evaluation. The binding stays the file's
102+
* default export because the barrel line below re-exports `default` for
103+
* every generator. `scaffold-object-declaration-shape.test.ts` pins both
104+
* doors to one shape, so neither can move alone.
93105
*/
94-
generate: (name: string) => `import * as Data from '@objectstack/spec/data';
106+
generate: (name: string) => `import { ObjectSchema } from '@objectstack/spec/data';
95107
96108
/**
97109
* ${toTitleCase(name)} Object
98110
*/
99-
const ${toCamelCase(name)}: Data.ServiceObject = {
111+
const ${toCamelCase(name)} = ObjectSchema.create({
100112
name: '${toSnakeCase(name)}',
101113
label: '${toTitleCase(name)}',
102114
pluralLabel: '${toTitleCase(name)}s',
@@ -119,7 +131,7 @@ const ${toCamelCase(name)}: Data.ServiceObject = {
119131
// authored decision rather than an accident. The other values, and how to
120132
// widen access safely: https://objectstack.ai/docs/permissions/sharing-rules
121133
sharingModel: 'private',
122-
};
134+
});
123135
124136
export default ${toCamelCase(name)};
125137
`,
@@ -961,9 +973,9 @@ async function runMetadataGeneration(type: string, name: string, flags: { dir?:
961973
//
962974
// This command ran no name validation at all, so a name that is legal as a
963975
// NAME but not as an IDENTIFIER was interpolated straight into a binding
964-
// position and written out under `exit 0` — `const foo.bar:
965-
// Data.ServiceObject = {`, plus a matching barrel line: two files that are
966-
// not TypeScript, from a command that reported success.
976+
// position and written out under `exit 0` — `const foo.bar =
977+
// ObjectSchema.create({` in today's emission, plus a matching barrel line:
978+
// two files that are not TypeScript, from a command that reported success.
967979
//
968980
// The criterion is PARSEABILITY, not a charset. `findEmissionParseFailures`
969981
// asks the compiler about the bytes above and about nothing else, which is

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -646,9 +646,9 @@ export default defineStack({
646646
srcFiles: {
647647
'src/objects/index.ts': (_name, namespace) => `export { default as ${toCamelCase(namespace)}Item } from './${namespace}_item.object';
648648
`,
649-
'src/objects/__name___item.object.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data';
649+
'src/objects/__name___item.object.ts': (_name, namespace) => `import { ObjectSchema } from '@objectstack/spec/data';
650650
651-
const ${toCamelCase(namespace)}Item: Data.ServiceObject = {
651+
const ${toCamelCase(namespace)}Item = ObjectSchema.create({
652652
name: '${namespace}_item',
653653
label: '${toTitleCase(namespace)} Item',
654654
fields: {
@@ -679,7 +679,7 @@ const ${toCamelCase(namespace)}Item: Data.ServiceObject = {
679679
// authored decision rather than an accident. The other values, and how to
680680
// widen access safely: https://objectstack.ai/docs/permissions/sharing-rules
681681
sharingModel: 'private',
682-
};
682+
});
683683
684684
export default ${toCamelCase(namespace)}Item;
685685
`,
@@ -741,9 +741,9 @@ export default defineStack({
741741
srcFiles: {
742742
'src/objects/index.ts': (_name, namespace) => `export { default as ${toCamelCase(namespace)}Item } from './${namespace}_item.object';
743743
`,
744-
'src/objects/__name___item.object.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data';
744+
'src/objects/__name___item.object.ts': (_name, namespace) => `import { ObjectSchema } from '@objectstack/spec/data';
745745
746-
const ${toCamelCase(namespace)}Item: Data.ServiceObject = {
746+
const ${toCamelCase(namespace)}Item = ObjectSchema.create({
747747
name: '${namespace}_item',
748748
label: '${toTitleCase(namespace)} Item',
749749
fields: {
@@ -760,7 +760,7 @@ const ${toCamelCase(namespace)}Item: Data.ServiceObject = {
760760
// authored decision rather than an accident. The other values, and how to
761761
// widen access safely: https://objectstack.ai/docs/permissions/sharing-rules
762762
sharingModel: 'private',
763-
};
763+
});
764764
765765
export default ${toCamelCase(namespace)}Item;
766766
`,

‎packages/cli/src/utils/emitted-source-parses.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* straight into a binding position:
1212
*
1313
* os generate object foo.bar exit 0
14-
* src/objects/foo.bar.object.ts -> const foo.bar: Data.ServiceObject = {
14+
* src/objects/foo.bar.object.ts -> const foo.bar = ObjectSchema.create({
1515
* src/objects/index.ts -> export { default as foo.bar } from './foo.bar.object';
1616
*
1717
* Two files, neither of them TypeScript, and a command that reported success.

‎packages/cli/test/generate-emission-parses.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* the name went into a binding position untouched:
1212
*
1313
* os generate object foo.bar exit 0
14-
* src/objects/foo.bar.object.ts -> const foo.bar: Data.ServiceObject = {
14+
* src/objects/foo.bar.object.ts -> const foo.bar = ObjectSchema.create({
1515
* src/objects/index.ts -> export { default as foo.bar } from './foo.bar.object';
1616
*
1717
* One name, TWO broken files, and a command that reported success. The blast
@@ -145,7 +145,7 @@ describe('[#16541] CANARY — the card`s measured name is refused, at both emiss
145145
const object = ROSTER.find((t) => t.type === 'object');
146146
if (!object) throw new Error('the `object` generator is gone');
147147
const { scaffold } = emissionsFor('object', object.generate, 'foo.bar');
148-
expect(scaffold.source).toContain('const foo.bar: Data.ServiceObject = {');
148+
expect(scaffold.source).toContain('const foo.bar = ObjectSchema.create({');
149149
const failures = await findEmissionParseFailures([scaffold]);
150150
expect(failures).toHaveLength(1);
151151
expect(failures[0].diagnostics.length).toBeGreaterThan(0);
@@ -164,7 +164,7 @@ describe('[#16541] DISCRIMINATOR — the verdict comes from the compiler, not fr
164164
const object = ROSTER.find((t) => t.type === 'object');
165165
if (!object) throw new Error('the `object` generator is gone');
166166
const { scaffold } = emissionsFor('object', object.generate, 'class');
167-
expect(scaffold.source).toContain('const class:');
167+
expect(scaffold.source).toContain('const class =');
168168
expect(await findEmissionParseFailures([scaffold])).not.toEqual([]);
169169
});
170170

‎packages/cli/test/generate-refuses-name-outside-charset.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
* ACCEPTED it (its emission parses clean, asserted here against the very
3838
* instrument the command runs). Delete the gate and this name generates.
3939
* - `class` — ADMITTED by the gate (every character is in the charset), and
40-
* REFUSED by the parse check for `object`, because `const class:` is not a
40+
* REFUSED by the parse check for `object`, because `const class =` is not a
4141
* declaration. Delete the parse check and this name generates.
4242
*
4343
* ## Why a child process

‎packages/cli/test/generate-refuses-unparseable-name.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
*
4343
* So the parse check is measured through `class`, added here for that purpose.
4444
* It is inside the charset (every character is a lowercase letter), the gate
45-
* admits it, and `const class:` is still not a declaration — so it is the name
45+
* admits it, and `const class =` is still not a declaration — so it is the name
4646
* that proves this command consults the compiler before it writes, and that
4747
* the layer in front did not swallow the layer behind. ⛔ Nothing was deleted
4848
* to make room for it: every `foo.bar` assertion that is still about the
@@ -252,7 +252,7 @@ describe('[#16541] CONTROL — an ordinary name is untouched by this change', ()
252252
it('writes a scaffold that parses, still binding `orderLine`', () => {
253253
const scaffold = readFileSync(join(controlDir, 'src', 'objects', 'order_line.object.ts'), 'utf8');
254254
expect(parseErrors(scaffold)).toEqual([]);
255-
expect(scaffold).toContain('const orderLine: Data.ServiceObject = {');
255+
expect(scaffold).toContain('const orderLine = ObjectSchema.create({');
256256
});
257257

258258
it('writes a barrel that parses, still re-exporting `orderLine`', () => {

‎packages/cli/test/scaffold-emission-typechecks.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@
2828
* the hand-written docs already used (`concepts/metadata-driven.mdx`,
2929
* `getting-started/quick-reference.mdx`). Nothing was added to the spec.
3030
*
31+
* The object scaffolds have since moved off the annotation altogether: they
32+
* declare through `ObjectSchema.create({ … })`, the one authorised shape for a
33+
* `*.object.ts` (ruling 5644350230), whose return type is derived from the
34+
* declaration it validates. This pin is unchanged by that — the other
35+
* generators still annotate with namespace members (`UI.View`,
36+
* `Automation.Flow`, …), and the factory's VALUE import from
37+
* `@objectstack/spec/data` is exactly what this sandbox has to resolve.
38+
*
3139
* ## ⭐ Why every existing scaffold pin was green through it
3240
*
3341
* This package already had two scaffold sweeps, and NEITHER could see this

0 commit comments

Comments
 (0)