Skip to content

Commit 212eaba

Browse files
os-litantclaude
andauthored
fix(cli): os init and os generate emit Data.ServiceObject so scaffolded projects type-check (#16193)
* fix(cli): emit `Data.ServiceObject` so scaffolded projects type-check `os init -t app`, `os init -t plugin` and `os g object` all wrote `const … : Data.Object`, and `@objectstack/spec/data` exports no member named `Object`. The primary scaffolder therefore emitted a project that failed its own `pnpm typecheck` with TS2694 (tsc exit 2), measured against the published tarball a real user installs and identical at TypeScript 5.3.3, 5.8.3 and 6.0.3. `Data.ServiceObject` is `z.input<typeof ObjectSchemaBase>` — the authoring shape of an object, and the structural analogue of the annotations the sibling generators already emit (`UI.View`, `UI.Action`, `UI.Dashboard`, `Automation.Flow`). It has always been exported; nothing was added to `@objectstack/spec`. The hand-written docs already used it, so this moves the scaffolders onto the repo's own spelling. `content/docs/deployment/cli.mdx` documented the broken annotation and is corrected with the emitters. Adds a type-layer pin over both emitter rosters. The two existing scaffold sweeps load the emission through esbuild, which erases type annotations without checking them, so a broken annotation is invisible to them by construction; the new pin spawns a real `tsc` over the materialized project under the tsconfig the scaffolder itself renders, with a canary that must fail TS2694 so the harness cannot pass by resolving nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * test(cli): declare the tsc child's environment in the scaffold typecheck pin `check:cli-test-child-env` (#11595) requires every spawn under `packages/cli/test/**` to declare its child's `env`, so that what the child inherits is legible at the call site instead of being the vitest worker's environment by default. The new pin's `spawnSync` passed an options object with no `env` key and the gate named it. Routed through `childEnv()`, this directory's choke point, with `NO_COLOR` to match the `--pretty false` already passed to tsc. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * chore(changeset): grade the scaffolder annotation fix `patch`, not `minor` AGENTS.md: "A bug fix in a released package takes a `patch` changeset -- never none, and never `skip-changeset`". This is that shape -- a scaffolder emitted broken text and now emits working text -- matching two `@objectstack/cli` 17.3.0 Patch-section precedents, 5dee191 (#13871, `os generate` ghost field types) and 9786d39 (`os create example` manifest identity block). The `minor` grade rested on a precedent of the wrong class (an additive key on a machine-readable --json payload, i.e. a contract widening), and on the claim that "patch would assert no surface moved" -- which is not what patch asserts, since every fix moves output bytes. The body now rests the annotation choice on ADR-0122 D1, which already ruled that the bare alias denotes the author state and is the name authoring surfaces use, rather than on two docs-page citations that do not support it: `metadata-driven.mdx`'s `const Account: ServiceObject` line is that page's "Deprecated" example, and `quick-reference.mdx` only names the type in an import list. Adds the measured result for the parsed-state alias: TS2740 on all three emissions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1ebde49 commit 212eaba

5 files changed

Lines changed: 293 additions & 4 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os init -t app`, `os init -t plugin` and `os g object` now emit an object file that compiles. All three wrote `const … : Data.Object`, and `@objectstack/spec/data` exports no member named `Object`, so the first command a new user runs produced a project that failed its own `pnpm typecheck`.
6+
7+
Measured against the **published** package a real user installs (`npm pack @objectstack/spec@17.3.0`, extracted and linked into a driven emission), not against the workspace:
8+
9+
```
10+
error TS2694: Namespace '.../@objectstack/spec/dist/data/index' has no exported member 'Object'
11+
tsc exit 2
12+
```
13+
14+
Identical at TypeScript 5.3.3, 5.8.3 and 6.0.3, so it was never a compiler-version effect. `os create example` type-checked clean on the same tarball in the same run — the failure was specific to these emissions.
15+
16+
The annotation is now `Data.ServiceObject`. That name was not chosen here — it is what [ADR-0122](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0122-schema-type-alias-naming-convention.md) D1 already ruled: for a schema `XSchema`, the **bare** alias denotes the author state (`z.input<typeof XSchema>`), and it is "the name documentation, examples, skills and AI authoring surfaces use for the thing an author writes". An emitted scaffold is the thing an author writes, so the bare alias is the one it owes. The sibling generators were already on that convention — `UI.View`, `UI.Action`, `UI.Dashboard` and `Automation.Flow` are each the bare alias of their own schema — and only the object emitters had drifted off it.
17+
18+
**Nothing was added to `@objectstack/spec`**: `ServiceObject` has been exported from `@objectstack/spec/data` throughout.
19+
20+
The parsed-state alias is not an alternative here. Annotating the same emitted literal `Data.ServiceObjectParsed` fails all three cases with `error TS2740`, because every field literal is then missing the keys the schema supplies by default — which is exactly the author-state/parsed-state distinction ADR-0122 D2 draws.
21+
22+
`content/docs/deployment/cli.mdx` taught the broken spelling too, and is corrected with them — a reader copying from the docs wrote the same uncompilable line.
23+
24+
The whole emitter roster was swept rather than the three reported sites: driving every `os init` template and every `os g` generator through `tsc --noEmit` under the tsconfig the scaffolder itself writes, `Data.Object` was the only non-existent member any of them named. In particular `UI.View` and `Automation.Flow` — named alongside `Data.Object` in the docs line and explicitly not swept when this was reported — are genuinely exported, and their generators compile at exit 0.
25+
26+
Why nothing caught this: both existing scaffold sweeps are runtime pins that load the emitted TypeScript through esbuild, which erases type annotations **without checking them**, so a broken annotation transpiles to byte-identical JavaScript and is invisible to them by construction. The scaffolds parsed, validated and loaded; they simply did not compile. A new pin runs the emitted projects through a real `tsc` program, with a canary that must fail with TS2694 so the harness cannot pass by resolving nothing.

content/docs/deployment/cli.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1299,7 +1299,7 @@ third-party extension primitive, authored as `src/skills/<name>.skill.ts` with
12991299
- `--dry-run` — Preview without writing files
13001300

13011301
**What it does:**
1302-
1. Creates a typed TypeScript file using `Data.Object`, `UI.View`, `Automation.Flow`, etc.
1302+
1. Creates a typed TypeScript file using `Data.ServiceObject`, `UI.View`, `Automation.Flow`, etc.
13031303
2. Creates or updates the barrel `index.ts` in the target directory
13041304
3. Shows a hint to run `objectstack validate`
13051305

packages/cli/src/commands/generate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const GENERATORS: Record<string, {
5757
/**
5858
* ${toTitleCase(name)} Object
5959
*/
60-
const ${toCamelCase(name)}: Data.Object = {
60+
const ${toCamelCase(name)}: Data.ServiceObject = {
6161
name: '${toSnakeCase(name)}',
6262
label: '${toTitleCase(name)}',
6363
pluralLabel: '${toTitleCase(name)}s',

packages/cli/src/commands/init.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -562,7 +562,7 @@ export default defineStack({
562562
`,
563563
'src/objects/__name___item.object.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data';
564564
565-
const ${toCamelCase(namespace)}Item: Data.Object = {
565+
const ${toCamelCase(namespace)}Item: Data.ServiceObject = {
566566
name: '${namespace}_item',
567567
label: '${toTitleCase(namespace)} Item',
568568
fields: {
@@ -650,7 +650,7 @@ export default defineStack({
650650
`,
651651
'src/objects/__name___item.object.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data';
652652
653-
const ${toCamelCase(namespace)}Item: Data.Object = {
653+
const ${toCamelCase(namespace)}Item: Data.ServiceObject = {
654654
name: '${namespace}_item',
655655
label: '${toTitleCase(namespace)} Item',
656656
fields: {
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PIN (#15976) — every scaffold this package emits must survive the
5+
* `tsc --noEmit` that the emitted project's OWN `typecheck` script runs.
6+
*
7+
* ## The defect
8+
*
9+
* `os init -t app`, `os init -t plugin` and `os g object` all wrote the same
10+
* annotation into the object file they emit:
11+
*
12+
* import * as Data from '@objectstack/spec/data';
13+
* const myAppItem: Data.Object = { … };
14+
*
15+
* `@objectstack/spec/data` exports no member named `Object`. So the primary
16+
* scaffolder — the first command a new user runs — produced a project that
17+
* fails its own `pnpm typecheck`:
18+
*
19+
* error TS2694: Namespace '…/@objectstack/spec/dist/data/index'
20+
* has no exported member 'Object'
21+
* tsc exit 2
22+
*
23+
* Measured on the PUBLISHED tarball (`npm pack @objectstack/spec@17.3.0`,
24+
* extracted and linked into a driven emission), which is what a real user
25+
* installs, and identically at TypeScript 5.3.3, 5.8.3 and 6.0.3 — so it was
26+
* never a compiler-version effect. The repair is `Data.ServiceObject`, the
27+
* `z.input` authoring type that `object.zod.ts` has always exported and that
28+
* the hand-written docs already used (`concepts/metadata-driven.mdx`,
29+
* `getting-started/quick-reference.mdx`). Nothing was added to the spec.
30+
*
31+
* ## ⭐ Why every existing scaffold pin was green through it
32+
*
33+
* This package already had two scaffold sweeps, and NEITHER could see this
34+
* defect — not by omission, but by construction:
35+
*
36+
* `generate-scaffold-validates.test.ts` loads each scaffold via
37+
* `bundle-require`
38+
* `init-scaffold-authoring-rules.test.ts` loads each template via the
39+
* command's own `validateScaffold`
40+
*
41+
* Both are RUNTIME pins: they materialize the TypeScript and then execute it.
42+
* The loader underneath is esbuild, which **erases type annotations without
43+
* checking them**. `const x: Data.Object = {…}` and `const x: Data.Whatever =
44+
* {…}` transpile to byte-identical JavaScript, so a broken annotation is
45+
* invisible to every runtime-shaped assertion this package can write. The
46+
* scaffolds genuinely did parse, validate and load — they simply did not
47+
* COMPILE, and nothing here had ever asked a compiler.
48+
*
49+
* That is the gap this file fills, and it is why it spawns `tsc` over a
50+
* materialized project instead of importing the module. The type layer is a
51+
* separate axis from the schema layer, and it needs its own instrument.
52+
*
53+
* ## The rosters are derived, both of them
54+
*
55+
* `TEMPLATES` (what `os init` emits) and `GENERATOR_SCAFFOLD_TARGETS` (what
56+
* `os g` emits, itself derived from `GENERATORS`) are read directly. A
57+
* template or generator added tomorrow is type-checked on the day it lands,
58+
* not the day somebody remembers to extend a hand-kept list — the same reason
59+
* the two sibling sweeps derive their rosters.
60+
*
61+
* ## The compiler options are the scaffolder's own
62+
*
63+
* The `tsconfig.json` each sandbox gets comes from `renderScaffoldTsconfig`,
64+
* the renderer `init` writes the real file with. Restating the options here
65+
* would let this pin drift into type-checking under a profile no user has —
66+
* `moduleResolution: 'bundler'` in particular is what resolves the
67+
* `@objectstack/spec/data` subpath at all, so a restatement that lost it would
68+
* turn every case into TS2307 or, worse, green over an unresolved module.
69+
*
70+
* ## The canary — what makes this a reading that CAN fail
71+
*
72+
* A tsc harness that resolves nothing, or discovers no files, reports zero
73+
* errors and reads exactly like a pass. So `CANARY` compiles a deliberately
74+
* absent member of the SAME namespace under the SAME profile and is asserted
75+
* to fail with TS2694 — the incident's own error code. Red there proves the
76+
* sandbox resolves `@objectstack/spec/data`, that tsc reaches the file, and
77+
* that this exact defect class surfaces. If the canary ever reports TS2307
78+
* instead, the spec package's `dist/` is not built and no verdict below means
79+
* anything; build it with `pnpm --filter '@objectstack/cli^...' build`.
80+
*
81+
* ## Sandbox placement
82+
*
83+
* Under this package's own `node_modules`, the placement
84+
* `generate-scaffold-validates.test.ts` measured and documented: git-ignored
85+
* (a materialized scaffold is a build artifact, not a fixture), and beneath
86+
* `packages/cli`, so an emitted `import … from '@objectstack/spec/…'` resolves
87+
* by the ordinary upward walk exactly as it does for a real user's project.
88+
* ⛔ Not `os.tmpdir()`: nothing up the tree from there resolves the spec
89+
* package, and every case would degrade to TS2307.
90+
*/
91+
92+
import { afterAll, describe, expect, it } from 'vitest';
93+
import fs from 'node:fs';
94+
import path from 'node:path';
95+
import { spawnSync } from 'node:child_process';
96+
import { createRequire } from 'node:module';
97+
import { fileURLToPath } from 'node:url';
98+
import {
99+
TEMPLATES,
100+
sanitizeNamespace,
101+
writeTemplateSrcFiles,
102+
renderScaffoldTsconfig,
103+
SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG,
104+
SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY,
105+
} from '../src/commands/init.js';
106+
import { GENERATOR_SCAFFOLD_TARGETS } from '../src/commands/generate.js';
107+
import { childEnv } from './helpers/serve-process.js';
108+
109+
const HERE = path.dirname(fileURLToPath(import.meta.url));
110+
111+
/** See the docblock's "Sandbox placement". */
112+
const TMP_ROOT = fs.mkdtempSync(
113+
path.join(HERE, '..', 'node_modules', '.scaffold-typecheck-'),
114+
);
115+
116+
afterAll(() => {
117+
fs.rmSync(TMP_ROOT, { recursive: true, force: true });
118+
});
119+
120+
/** The project name / artifact stem every case below is driven with. */
121+
const PROJECT_NAME = 'my-app';
122+
const STEM = 'probe_thing';
123+
124+
/**
125+
* Run the emitted project's own `typecheck` script: `tsc --noEmit`.
126+
*
127+
* The child's environment is DECLARED (`check:cli-test-child-env`, #11595):
128+
* every spawn under `packages/cli/test/**` owes one, so that what a child
129+
* inherits is legible at the call site rather than being the vitest worker's
130+
* environment by default. `childEnv()` is this directory's choke point — the
131+
* environment minus the `VITEST_*` family — and `NO_COLOR` pairs with
132+
* `--pretty false` to keep tsc's diagnostics greppable in the failure message.
133+
*/
134+
function typecheckProject(root: string): { code: number; output: string } {
135+
const tscBin = createRequire(import.meta.url).resolve('typescript/bin/tsc');
136+
const res = spawnSync(
137+
process.execPath,
138+
[tscBin, '--pretty', 'false', '--noEmit', '-p', root],
139+
{ cwd: root, encoding: 'utf-8', env: childEnv({ NO_COLOR: '1' }) },
140+
);
141+
return { code: res.status ?? 1, output: `${res.stdout ?? ''}${res.stderr ?? ''}` };
142+
}
143+
144+
/** A sandbox project carrying the tsconfig `init` really writes. */
145+
function sandbox(label: string, include: readonly string[], rootDir: string): string {
146+
const root = fs.mkdtempSync(path.join(TMP_ROOT, `${label}-`));
147+
fs.writeFileSync(
148+
path.join(root, 'tsconfig.json'),
149+
JSON.stringify(renderScaffoldTsconfig({ rootDir, include: [...include] }), null, 2) + '\n',
150+
);
151+
return root;
152+
}
153+
154+
/** Materialize one `os init` template exactly as the command emits it. */
155+
function emitInitTemplate(templateKey: string): string {
156+
const template = TEMPLATES[templateKey];
157+
const namespace = sanitizeNamespace(PROJECT_NAME);
158+
const root = sandbox(`init-${templateKey}`, SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG, '.');
159+
fs.writeFileSync(
160+
path.join(root, 'objectstack.config.ts'),
161+
template.configContent(PROJECT_NAME, namespace),
162+
);
163+
writeTemplateSrcFiles(template.srcFiles, root, PROJECT_NAME, namespace);
164+
return root;
165+
}
166+
167+
describe('every emitted scaffold compiles under the tsconfig it ships with', () => {
168+
// ── Controls ──────────────────────────────────────────────────────────
169+
//
170+
// Without these the suite below could pass by measuring nothing at all.
171+
172+
it('the canary proves the harness resolves the spec package and CAN go red', () => {
173+
const root = sandbox('canary', SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY, 'src');
174+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
175+
fs.writeFileSync(
176+
path.join(root, 'src', 'canary.ts'),
177+
`import * as Data from '@objectstack/spec/data';
178+
179+
const probe: Data.NoSuchMemberForTheCanary = { name: 'probe' };
180+
181+
export default probe;
182+
`,
183+
);
184+
185+
const { code, output } = typecheckProject(root);
186+
187+
expect(code, `the canary must NOT compile — this harness reports:\n${output}`).not.toBe(0);
188+
expect(
189+
output,
190+
'the canary must fail with TS2694 (the incident\'s own error code). ' +
191+
'TS2307 here means `@objectstack/spec` dist is not built, and every ' +
192+
"verdict in this file is meaningless until it is:\n" +
193+
" pnpm --filter '@objectstack/cli^...' build\n" +
194+
`tsc said:\n${output}`,
195+
).toContain('TS2694');
196+
expect(output).toContain('NoSuchMemberForTheCanary');
197+
}, 120_000);
198+
199+
it('both rosters are populated, and the incident\'s own surface is still emitted', () => {
200+
const templateKeys = Object.keys(TEMPLATES);
201+
expect(templateKeys.length).toBeGreaterThan(0);
202+
expect(GENERATOR_SCAFFOLD_TARGETS.length).toBeGreaterThan(0);
203+
204+
// #15976 was an annotation of a `@objectstack/spec/data` namespace member
205+
// in an emitted object file. If a future edit stopped emitting object
206+
// files altogether, every assertion below would still pass while covering
207+
// none of the incident — so the surface itself is pinned as present.
208+
const namespace = sanitizeNamespace(PROJECT_NAME);
209+
const initObjectSources = templateKeys.flatMap((key) =>
210+
Object.entries(TEMPLATES[key].srcFiles ?? {})
211+
.filter(([p]) => p.includes('src/objects/') && !p.endsWith('index.ts'))
212+
.map(([, contentFn]) => contentFn(PROJECT_NAME, namespace)),
213+
);
214+
expect(initObjectSources.length).toBeGreaterThan(0);
215+
expect(
216+
initObjectSources.filter((src) => src.includes("from '@objectstack/spec/data'")).length,
217+
'no `os init` template emits an object file importing `@objectstack/spec/data` any more — ' +
218+
'this pin has stopped covering #15976',
219+
).toBeGreaterThan(0);
220+
221+
const objectGenerator = GENERATOR_SCAFFOLD_TARGETS.find((g) => g.type === 'object');
222+
expect(objectGenerator, '`os g object` is gone from the roster').toBeDefined();
223+
expect(objectGenerator!.generate(STEM)).toContain("from '@objectstack/spec/data'");
224+
});
225+
226+
// ── The sweep ─────────────────────────────────────────────────────────
227+
228+
it.each(Object.keys(TEMPLATES))(
229+
'`os init -t %s` emits a project that passes its own `pnpm typecheck`',
230+
(templateKey) => {
231+
const root = emitInitTemplate(templateKey);
232+
const { code, output } = typecheckProject(root);
233+
234+
expect(
235+
code,
236+
`\`os init -t ${templateKey}\` emits a project that fails the \`tsc --noEmit\` its own ` +
237+
`package.json \`typecheck\` script runs. This is what a new user meets on their ` +
238+
`first command:\n${output}`,
239+
).toBe(0);
240+
},
241+
120_000,
242+
);
243+
244+
it.each(GENERATOR_SCAFFOLD_TARGETS.map((g) => [g.type, g] as const))(
245+
'`os g %s` emits a file that type-checks',
246+
(type, generator) => {
247+
const root = sandbox(`gen-${type}`, SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY, 'src');
248+
const dir = path.join(root, generator.defaultDir);
249+
fs.mkdirSync(dir, { recursive: true });
250+
// The file NAME is `generate-file-name-registry-parity.test.ts`'s axis,
251+
// not this one's; what is measured here is the SOURCE that lands in it.
252+
fs.writeFileSync(path.join(dir, `${STEM}.ts`), generator.generate(STEM));
253+
254+
const { code, output } = typecheckProject(root);
255+
256+
expect(
257+
code,
258+
`\`os g ${type} ${STEM}\` emits a file the author's own \`tsc --noEmit\` refuses:\n${output}`,
259+
).toBe(0);
260+
},
261+
120_000,
262+
);
263+
});

0 commit comments

Comments
 (0)