Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/init-generate-emit-service-object-annotation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@objectstack/cli": patch
---

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

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:

```
error TS2694: Namespace '.../@objectstack/spec/dist/data/index' has no exported member 'Object'
tsc exit 2
```

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.

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.

**Nothing was added to `@objectstack/spec`**: `ServiceObject` has been exported from `@objectstack/spec/data` throughout.

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.

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

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.

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.
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1299,7 +1299,7 @@ third-party extension primitive, authored as `src/skills/<name>.skill.ts` with
- `--dry-run` — Preview without writing files

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

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const GENERATORS: Record<string, {
/**
* ${toTitleCase(name)} Object
*/
const ${toCamelCase(name)}: Data.Object = {
const ${toCamelCase(name)}: Data.ServiceObject = {
name: '${toSnakeCase(name)}',
label: '${toTitleCase(name)}',
pluralLabel: '${toTitleCase(name)}s',
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ export default defineStack({
`,
'src/objects/__name___item.object.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data';

const ${toCamelCase(namespace)}Item: Data.Object = {
const ${toCamelCase(namespace)}Item: Data.ServiceObject = {
name: '${namespace}_item',
label: '${toTitleCase(namespace)} Item',
fields: {
Expand Down Expand Up @@ -650,7 +650,7 @@ export default defineStack({
`,
'src/objects/__name___item.object.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data';

const ${toCamelCase(namespace)}Item: Data.Object = {
const ${toCamelCase(namespace)}Item: Data.ServiceObject = {
name: '${namespace}_item',
label: '${toTitleCase(namespace)} Item',
fields: {
Expand Down
263 changes: 263 additions & 0 deletions packages/cli/test/scaffold-emission-typechecks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* PIN (#15976) — every scaffold this package emits must survive the
* `tsc --noEmit` that the emitted project's OWN `typecheck` script runs.
*
* ## The defect
*
* `os init -t app`, `os init -t plugin` and `os g object` all wrote the same
* annotation into the object file they emit:
*
* import * as Data from '@objectstack/spec/data';
* const myAppItem: Data.Object = { … };
*
* `@objectstack/spec/data` exports no member named `Object`. So the primary
* scaffolder — the first command a new user runs — produced a project that
* fails its own `pnpm typecheck`:
*
* error TS2694: Namespace '…/@objectstack/spec/dist/data/index'
* has no exported member 'Object'
* tsc exit 2
*
* Measured on the PUBLISHED tarball (`npm pack @objectstack/spec@17.3.0`,
* extracted and linked into a driven emission), which is what a real user
* installs, and identically at TypeScript 5.3.3, 5.8.3 and 6.0.3 — so it was
* never a compiler-version effect. The repair is `Data.ServiceObject`, the
* `z.input` authoring type that `object.zod.ts` has always exported and that
* the hand-written docs already used (`concepts/metadata-driven.mdx`,
* `getting-started/quick-reference.mdx`). Nothing was added to the spec.
*
* ## ⭐ Why every existing scaffold pin was green through it
*
* This package already had two scaffold sweeps, and NEITHER could see this
* defect — not by omission, but by construction:
*
* `generate-scaffold-validates.test.ts` loads each scaffold via
* `bundle-require`
* `init-scaffold-authoring-rules.test.ts` loads each template via the
* command's own `validateScaffold`
*
* Both are RUNTIME pins: they materialize the TypeScript and then execute it.
* The loader underneath is esbuild, which **erases type annotations without
* checking them**. `const x: Data.Object = {…}` and `const x: Data.Whatever =
* {…}` transpile to byte-identical JavaScript, so a broken annotation is
* invisible to every runtime-shaped assertion this package can write. The
* scaffolds genuinely did parse, validate and load — they simply did not
* COMPILE, and nothing here had ever asked a compiler.
*
* That is the gap this file fills, and it is why it spawns `tsc` over a
* materialized project instead of importing the module. The type layer is a
* separate axis from the schema layer, and it needs its own instrument.
*
* ## The rosters are derived, both of them
*
* `TEMPLATES` (what `os init` emits) and `GENERATOR_SCAFFOLD_TARGETS` (what
* `os g` emits, itself derived from `GENERATORS`) are read directly. A
* template or generator added tomorrow is type-checked on the day it lands,
* not the day somebody remembers to extend a hand-kept list — the same reason
* the two sibling sweeps derive their rosters.
*
* ## The compiler options are the scaffolder's own
*
* The `tsconfig.json` each sandbox gets comes from `renderScaffoldTsconfig`,
* the renderer `init` writes the real file with. Restating the options here
* would let this pin drift into type-checking under a profile no user has —
* `moduleResolution: 'bundler'` in particular is what resolves the
* `@objectstack/spec/data` subpath at all, so a restatement that lost it would
* turn every case into TS2307 or, worse, green over an unresolved module.
*
* ## The canary — what makes this a reading that CAN fail
*
* A tsc harness that resolves nothing, or discovers no files, reports zero
* errors and reads exactly like a pass. So `CANARY` compiles a deliberately
* absent member of the SAME namespace under the SAME profile and is asserted
* to fail with TS2694 — the incident's own error code. Red there proves the
* sandbox resolves `@objectstack/spec/data`, that tsc reaches the file, and
* that this exact defect class surfaces. If the canary ever reports TS2307
* instead, the spec package's `dist/` is not built and no verdict below means
* anything; build it with `pnpm --filter '@objectstack/cli^...' build`.
*
* ## Sandbox placement
*
* Under this package's own `node_modules`, the placement
* `generate-scaffold-validates.test.ts` measured and documented: git-ignored
* (a materialized scaffold is a build artifact, not a fixture), and beneath
* `packages/cli`, so an emitted `import … from '@objectstack/spec/…'` resolves
* by the ordinary upward walk exactly as it does for a real user's project.
* ⛔ Not `os.tmpdir()`: nothing up the tree from there resolves the spec
* package, and every case would degrade to TS2307.
*/

import { afterAll, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import {
TEMPLATES,
sanitizeNamespace,
writeTemplateSrcFiles,
renderScaffoldTsconfig,
SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG,
SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY,
} from '../src/commands/init.js';
import { GENERATOR_SCAFFOLD_TARGETS } from '../src/commands/generate.js';
import { childEnv } from './helpers/serve-process.js';

const HERE = path.dirname(fileURLToPath(import.meta.url));

/** See the docblock's "Sandbox placement". */
const TMP_ROOT = fs.mkdtempSync(
path.join(HERE, '..', 'node_modules', '.scaffold-typecheck-'),
);

afterAll(() => {
fs.rmSync(TMP_ROOT, { recursive: true, force: true });
});

/** The project name / artifact stem every case below is driven with. */
const PROJECT_NAME = 'my-app';
const STEM = 'probe_thing';

/**
* Run the emitted project's own `typecheck` script: `tsc --noEmit`.
*
* The child's environment is DECLARED (`check:cli-test-child-env`, #11595):
* every spawn under `packages/cli/test/**` owes one, so that what a child
* inherits is legible at the call site rather than being the vitest worker's
* environment by default. `childEnv()` is this directory's choke point — the
* environment minus the `VITEST_*` family — and `NO_COLOR` pairs with
* `--pretty false` to keep tsc's diagnostics greppable in the failure message.
*/
function typecheckProject(root: string): { code: number; output: string } {
const tscBin = createRequire(import.meta.url).resolve('typescript/bin/tsc');
const res = spawnSync(
process.execPath,
[tscBin, '--pretty', 'false', '--noEmit', '-p', root],
{ cwd: root, encoding: 'utf-8', env: childEnv({ NO_COLOR: '1' }) },
);
return { code: res.status ?? 1, output: `${res.stdout ?? ''}${res.stderr ?? ''}` };
}

/** A sandbox project carrying the tsconfig `init` really writes. */
function sandbox(label: string, include: readonly string[], rootDir: string): string {
const root = fs.mkdtempSync(path.join(TMP_ROOT, `${label}-`));
fs.writeFileSync(
path.join(root, 'tsconfig.json'),
JSON.stringify(renderScaffoldTsconfig({ rootDir, include: [...include] }), null, 2) + '\n',
);
return root;
}

/** Materialize one `os init` template exactly as the command emits it. */
function emitInitTemplate(templateKey: string): string {
const template = TEMPLATES[templateKey];
const namespace = sanitizeNamespace(PROJECT_NAME);
const root = sandbox(`init-${templateKey}`, SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG, '.');
fs.writeFileSync(
path.join(root, 'objectstack.config.ts'),
template.configContent(PROJECT_NAME, namespace),
);
writeTemplateSrcFiles(template.srcFiles, root, PROJECT_NAME, namespace);
return root;
}

describe('every emitted scaffold compiles under the tsconfig it ships with', () => {
// ── Controls ──────────────────────────────────────────────────────────
//
// Without these the suite below could pass by measuring nothing at all.

it('the canary proves the harness resolves the spec package and CAN go red', () => {
const root = sandbox('canary', SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY, 'src');
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
fs.writeFileSync(
path.join(root, 'src', 'canary.ts'),
`import * as Data from '@objectstack/spec/data';

const probe: Data.NoSuchMemberForTheCanary = { name: 'probe' };

export default probe;
`,
);

const { code, output } = typecheckProject(root);

expect(code, `the canary must NOT compile — this harness reports:\n${output}`).not.toBe(0);
expect(
output,
'the canary must fail with TS2694 (the incident\'s own error code). ' +
'TS2307 here means `@objectstack/spec` dist is not built, and every ' +
"verdict in this file is meaningless until it is:\n" +
" pnpm --filter '@objectstack/cli^...' build\n" +
`tsc said:\n${output}`,
).toContain('TS2694');
expect(output).toContain('NoSuchMemberForTheCanary');
}, 120_000);

it('both rosters are populated, and the incident\'s own surface is still emitted', () => {
const templateKeys = Object.keys(TEMPLATES);
expect(templateKeys.length).toBeGreaterThan(0);
expect(GENERATOR_SCAFFOLD_TARGETS.length).toBeGreaterThan(0);

// #15976 was an annotation of a `@objectstack/spec/data` namespace member
// in an emitted object file. If a future edit stopped emitting object
// files altogether, every assertion below would still pass while covering
// none of the incident — so the surface itself is pinned as present.
const namespace = sanitizeNamespace(PROJECT_NAME);
const initObjectSources = templateKeys.flatMap((key) =>
Object.entries(TEMPLATES[key].srcFiles ?? {})
.filter(([p]) => p.includes('src/objects/') && !p.endsWith('index.ts'))
.map(([, contentFn]) => contentFn(PROJECT_NAME, namespace)),
);
expect(initObjectSources.length).toBeGreaterThan(0);
expect(
initObjectSources.filter((src) => src.includes("from '@objectstack/spec/data'")).length,
'no `os init` template emits an object file importing `@objectstack/spec/data` any more — ' +
'this pin has stopped covering #15976',
).toBeGreaterThan(0);

const objectGenerator = GENERATOR_SCAFFOLD_TARGETS.find((g) => g.type === 'object');
expect(objectGenerator, '`os g object` is gone from the roster').toBeDefined();
expect(objectGenerator!.generate(STEM)).toContain("from '@objectstack/spec/data'");
});

// ── The sweep ─────────────────────────────────────────────────────────

it.each(Object.keys(TEMPLATES))(
'`os init -t %s` emits a project that passes its own `pnpm typecheck`',
(templateKey) => {
const root = emitInitTemplate(templateKey);
const { code, output } = typecheckProject(root);

expect(
code,
`\`os init -t ${templateKey}\` emits a project that fails the \`tsc --noEmit\` its own ` +
`package.json \`typecheck\` script runs. This is what a new user meets on their ` +
`first command:\n${output}`,
).toBe(0);
},
120_000,
);

it.each(GENERATOR_SCAFFOLD_TARGETS.map((g) => [g.type, g] as const))(
'`os g %s` emits a file that type-checks',
(type, generator) => {
const root = sandbox(`gen-${type}`, SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY, 'src');
const dir = path.join(root, generator.defaultDir);
fs.mkdirSync(dir, { recursive: true });
// The file NAME is `generate-file-name-registry-parity.test.ts`'s axis,
// not this one's; what is measured here is the SOURCE that lands in it.
fs.writeFileSync(path.join(dir, `${STEM}.ts`), generator.generate(STEM));

const { code, output } = typecheckProject(root);

expect(
code,
`\`os g ${type} ${STEM}\` emits a file the author's own \`tsc --noEmit\` refuses:\n${output}`,
).toBe(0);
},
120_000,
);
});
Loading