From a39526bc1961df2e1689104958778409b2eaad4c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 22:25:24 +0000 Subject: [PATCH 1/4] fix(cli): os generate schema falls back like every other toJSONSchema call site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runSchemaGeneration` was the one `z.toJSONSchema` call site in this repo that neither fell back nor used the `unrepresentable` convention. Its bare two-argument call throws in BOTH io directions on today's tree — a transform in output mode, a function type in input mode — so the `catch` below it printed and exited 1 and the command could never reach its own `fs.writeFileSync`. Adopt the ladder `packages/spec/scripts/build-schemas.ts` already runs, with `packages/metadata-protocol/src/protocol.ts`'s `unrepresentable: 'any'` as the third tier. Each tier re-raises anything the known-unsupported predicate does not recognise. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude --- packages/cli/src/commands/generate.ts | 80 ++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index c5f73a2d42..1fccbeffad 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -2838,6 +2838,25 @@ async function runMigrationGeneration(configPath: string | undefined, flags: { o // ─── JSON Schema Generator ────────────────────────────────────────── +/** + * Error messages for schema nodes that inherently have no JSON Schema form. + * + * ⛔ Deliberately the SAME single substring `packages/spec/scripts/build-schemas.ts` + * matches on, and for the same reason: zod names the offending node kind in the + * PREFIX (`Transforms …`, `Function types …`), so a list of kinds here would go + * stale against zod while the suffix is what all of them share. Anything this + * does NOT recognise is a real conversion failure, and every tier below + * re-raises it instead of degrading past it. + */ +const KNOWN_UNSUPPORTED_JSON_SCHEMA_PATTERNS = [ + 'cannot be represented in JSON Schema', +]; + +function isKnownUnsupportedJsonSchema(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error); + return KNOWN_UNSUPPORTED_JSON_SCHEMA_PATTERNS.some((p) => msg.includes(p)); +} + async function runSchemaGeneration(flags: { output: string; dryRun?: boolean }): Promise { printHeader('Generate Schema'); @@ -2849,9 +2868,64 @@ async function runSchemaGeneration(flags: { output: string; dryRun?: boolean }): const { ObjectStackDefinitionSchema } = await import('@objectstack/spec'); printStep('Converting to JSON Schema...'); - const jsonSchema = z.toJSONSchema(ObjectStackDefinitionSchema, { - target: 'draft-2020-12', - }); + + // [#17873] The three-tier ladder `packages/spec/scripts/build-schemas.ts` + // already runs for every schema it publishes, with the third tier spelled + // as `packages/metadata-protocol/src/protocol.ts`'s `unrepresentable: 'any'` + // rather than spec's union-branch projection (a spec-private helper). + // + // Before this, the call below was the ONE `toJSONSchema` call site in the + // repository that neither fell back nor used that convention — and + // `ObjectStackDefinitionSchema` has no JSON form in EITHER direction, so + // the bare call threw for every repository and every flag combination and + // the `catch` at the bottom of this function exited 1. The command could + // never reach its own `fs.writeFileSync`. + // + // tier 1 output, strict — what this command asked for, kept first. + // tier 2 input, strict — an IDE schema describes what an author WRITES, + // and the input side of a transform pipe is + // plain data (build-schemas.ts carries the + // full argument). + // tier 3 input, `unrepresentable: 'any'` — the callable leaves + // (`onEnable`, hook/function `handler`s) have no + // JSON form in any direction; widening THOSE + // LEAVES to "accepts anything" is what buys the + // other 40 members a published schema. + let jsonSchema: Record; + let io: 'output' | 'input' = 'output'; + let widenedUnrepresentable = false; + try { + jsonSchema = z.toJSONSchema(ObjectStackDefinitionSchema, { + target: 'draft-2020-12', + }) as Record; + } catch (outputError) { + if (!isKnownUnsupportedJsonSchema(outputError)) throw outputError; + io = 'input'; + try { + jsonSchema = z.toJSONSchema(ObjectStackDefinitionSchema, { + target: 'draft-2020-12', + io: 'input', + }) as Record; + } catch (inputError) { + if (!isKnownUnsupportedJsonSchema(inputError)) throw inputError; + widenedUnrepresentable = true; + jsonSchema = z.toJSONSchema(ObjectStackDefinitionSchema, { + target: 'draft-2020-12', + io: 'input', + unrepresentable: 'any', + }) as Record; + } + } + + // Absence must be loud: a degraded artifact says so at the moment it is + // produced, rather than leaving an IDE user to discover that some subtree + // accepts anything. + if (io === 'input') { + printInfo('Converted in the authoring (input) direction — the output direction contains a transform with no JSON form'); + } + if (widenedUnrepresentable) { + printInfo('Nodes with no JSON form (live callables) are published as unconstrained — they accept any value in this schema'); + } // Add metadata const schema = { From fb5434d1f9f1d50cba900c9874728f0e87638691 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 22:36:26 +0000 Subject: [PATCH 2/4] test(cli): pin that os generate schema WRITES a valid IDE JSON Schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asserts the written file exists (listed, not assumed), its bytes parse, the document is a JSON Schema of the declared draft, and each of the four members #17873 required declaring carries the fragment that declaration names. A fifth, derived assertion pins the authoring derivation: no object schema anywhere lists a defaulted property as required — 752 do in the output derivation, each one an IDE reporting a valid config as missing a key nobody had to write. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude --- ...rate-schema-writes-json-schema.e2e.test.ts | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 packages/cli/test/generate-schema-writes-json-schema.e2e.test.ts diff --git a/packages/cli/test/generate-schema-writes-json-schema.e2e.test.ts b/packages/cli/test/generate-schema-writes-json-schema.e2e.test.ts new file mode 100644 index 0000000000..cad8ec01a8 --- /dev/null +++ b/packages/cli/test/generate-schema-writes-json-schema.e2e.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#17873) — `os generate schema` WRITES a published IDE schema, and the + * document it writes is the one this repo declared it writes. + * + * ## Why "it did not throw" is the wrong assertion + * + * The defect this pins was total: `runSchemaGeneration` called + * `z.toJSONSchema(ObjectStackDefinitionSchema, { target: 'draft-2020-12' })` + * bare, that call throws on this tree in BOTH io directions (a transform in + * output mode, a function type in input mode), and the `catch` below it did + * `printError` + `process.exit(1)`. The command could not reach its own + * `fs.writeFileSync` for any repository or any flag combination — so a test + * asserting "the process exited 0" or "nothing threw" could be satisfied by a + * command that emits nothing at all, which is exactly the shape being fixed. + * + * So the four assertions below are, in order: + * + * (a) the OUTPUT FILE exists, found by listing the directory rather than by + * assuming the name — a generator that wrote somewhere else fails the + * existence assertion instead of passing a name check nobody ran; + * (b) its BYTES PARSE as JSON; + * (c) the parsed document is a JSON Schema OF THE DECLARED DRAFT — `$schema` + * names draft 2020-12, and the document carries the `type` / `properties` + * shape a consumer actually reads; + * (d) each of the four members whose promise #17873 required the delivering + * PR to DECLARE — `packages`, `hooks`, `functions`, `onEnable` — is + * present with the fragment that declaration names. + * + * ## The fifth assertion, and why it is not a brittle path pin + * + * The ladder's landing TIER is what decides the promise, and two tiers both + * produce a document that satisfies (a)-(d): the authoring (`io: 'input'`) + * direction this command lands on, and the output direction. They differ on a + * property an IDE user feels immediately — in the OUTPUT direction every + * property carrying a `default` becomes `required`, so a perfectly valid + * `objectstack.config.ts` is reported as missing 752 keys it never had to + * write (measured on this tree; the authoring direction answers 0). + * + * That is asserted as a DERIVED invariant — "no object schema anywhere lists a + * defaulted property as required" — computed from the document itself, so it + * pins the direction without pinning any path that an ordinary spec change + * would move. + * + * Assertions run against a REAL CHILD PROCESS, for the two reasons + * `generate-agent-retired.e2e.test.ts` documents: `process.exitCode` inside a + * vitest worker is not an exit status, and these commands print through + * `utils/format.ts`. Spawned through `bin/run-dev.js` + tsx, so the suite does + * not depend on `packages/cli/dist`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { childEnv } from './helpers/serve-process.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +/** oclif + tsx cold start, with every command module loaded; ~2-10 s when healthy. */ +const RUN_TIMEOUT_MS = 180_000; + +/** The draft the command's own options object names. */ +const DECLARED_DRAFT = 'https://json-schema.org/draft/2020-12/schema'; + +/** The file name `os generate schema` defaults to, passed explicitly here. */ +const OUT_NAME = 'objectstack.schema.json'; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runTsx(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + args, + { cwd, maxBuffer: 32 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + resolvePromise({ + // `err.code` is the real exit status; null/undefined means the child + // was signalled — a different failure, never reported as 0. + code: err + ? typeof (err as { code?: unknown }).code === 'number' + ? (err as unknown as { code: number }).code + : 1 + : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +type JsonObject = Record; + +const isObject = (v: unknown): v is JsonObject => + typeof v === 'object' && v !== null && !Array.isArray(v); + +/** A schema position that constrains nothing — `{}` accepts every value. */ +const isUnconstrained = (v: unknown): boolean => isObject(v) && Object.keys(v).length === 0; + +/** + * Every object schema in `doc` that lists a property as `required` while that + * property declares a `default`. + * + * This is the io-direction discriminator: zod's output derivation makes a + * defaulted property present-and-required, its input derivation leaves it + * optional. Derived from the document so no path is hard-coded. + */ +function defaultedYetRequired(node: unknown, path = '$', acc: string[] = []): string[] { + if (Array.isArray(node)) { + node.forEach((child, i) => defaultedYetRequired(child, `${path}[${i}]`, acc)); + return acc; + } + if (!isObject(node)) return acc; + if (Array.isArray(node.required) && isObject(node.properties)) { + for (const key of node.required) { + const prop = typeof key === 'string' ? node.properties[key] : undefined; + if (isObject(prop) && 'default' in prop) acc.push(`${path}.required:${String(key)}`); + } + } + for (const [key, child] of Object.entries(node)) { + defaultedYetRequired(child, `${path}.${key}`, acc); + } + return acc; +} + +let dir: string; +let run: Run; +let written: string[]; +let raw: string; +let doc: JsonObject; + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-generate-schema-')); + run = await runTsx([CLI, 'generate', 'schema', '-o', OUT_NAME], dir); + // Listed, not assumed: a generator that wrote elsewhere must fail (a), not + // slip past a hard-coded name. + written = existsSync(dir) ? readdirSync(dir) : []; + const outPath = join(dir, OUT_NAME); + raw = existsSync(outPath) ? readFileSync(outPath, 'utf8') : ''; + try { + doc = JSON.parse(raw) as JsonObject; + } catch { + doc = {}; + } +}, RUN_TIMEOUT_MS); + +afterAll(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); +}); + +describe('os generate schema', () => { + it('(a) writes its output file', () => { + expect({ code: run.code, written }).toEqual({ code: 0, written: [OUT_NAME] }); + }); + + it('(b) writes bytes that parse as JSON', () => { + expect(raw.length).toBeGreaterThan(0); + expect(() => JSON.parse(raw)).not.toThrow(); + }); + + it('(c) writes a JSON Schema of the draft the command declares', () => { + expect(doc.$schema).toBe(DECLARED_DRAFT); + expect(doc.$id).toBe('https://schema.objectstack.io/objectstack.config.json'); + expect(doc.type).toBe('object'); + expect(isObject(doc.properties)).toBe(true); + // A consumer reads the member map; an empty one is a husk that would still + // satisfy every assertion above. + expect(Object.keys(doc.properties as JsonObject).length).toBeGreaterThan(30); + expect(doc.additionalProperties).toBe(false); + }); + + describe('(d) the four members whose promise #17873 required declaring', () => { + const member = (name: string): JsonObject => { + const props = doc.properties as JsonObject | undefined; + const value = props?.[name]; + expect(isObject(value)).toBe(true); + return value as JsonObject; + }; + + it('`onEnable` is published UNCONSTRAINED — description only, no type, accepts any value', () => { + const onEnable = member('onEnable'); + expect(Object.keys(onEnable)).toEqual(['description']); + expect(typeof onEnable.description).toBe('string'); + }); + + it('`hooks` keeps its full array shape; only the inline-callable branch of `handler` is unconstrained', () => { + const hooks = member('hooks'); + expect(hooks.type).toBe('array'); + const items = hooks.items as JsonObject; + expect(items.type).toBe('object'); + expect(items.required).toEqual(['name', 'object', 'events']); + expect(items.additionalProperties).toBe(false); + const handler = (items.properties as JsonObject).handler as JsonObject; + const branches = handler.anyOf as unknown[]; + expect(branches.some((b) => isObject(b) && b.type === 'string')).toBe(true); + expect(branches.filter(isUnconstrained)).toHaveLength(1); + }); + + it('`functions` keeps both authored forms; the `handler` positions are unconstrained', () => { + const functions = member('functions'); + const branches = functions.anyOf as unknown[]; + expect(branches).toHaveLength(2); + // The map form: `{ [name]: entry }`, entry being a callable or a record. + const mapForm = branches.find((b) => isObject(b) && b.type === 'object') as JsonObject; + expect(isObject(mapForm.additionalProperties)).toBe(true); + const entry = (mapForm.additionalProperties as JsonObject).anyOf as unknown[]; + expect(entry.some(isUnconstrained)).toBe(true); + }); + + it('`packages` keeps its full array shape; the callables nested in it are unconstrained', () => { + const packages = member('packages'); + expect(packages.type).toBe('array'); + const items = packages.items as JsonObject; + expect(items.type).toBe('object'); + const manifest = (items.properties as JsonObject).manifest as JsonObject; + expect(manifest.type).toBe('object'); + const nestedHooks = (manifest.properties as JsonObject).hooks as JsonObject; + const nestedHandler = ((nestedHooks.items as JsonObject).properties as JsonObject) + .handler as JsonObject; + expect((nestedHandler.anyOf as unknown[]).filter(isUnconstrained)).toHaveLength(1); + }); + }); + + it('(e) is the AUTHORING derivation — no defaulted property is published as required', () => { + // In the output derivation this count is 752 on this tree, and every one of + // them is an IDE reporting a valid config as missing a key its author never + // had to write. + expect(defaultedYetRequired(doc)).toEqual([]); + }); +}); From 0d64427dc9908ddc86b1324a722f4d8c356a75f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 22:38:34 +0000 Subject: [PATCH 3/4] test(cli): light the control inside the io-direction assertion Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude --- ...rate-schema-writes-json-schema.e2e.test.ts | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/cli/test/generate-schema-writes-json-schema.e2e.test.ts b/packages/cli/test/generate-schema-writes-json-schema.e2e.test.ts index cad8ec01a8..84c5ee7f73 100644 --- a/packages/cli/test/generate-schema-writes-json-schema.e2e.test.ts +++ b/packages/cli/test/generate-schema-writes-json-schema.e2e.test.ts @@ -109,27 +109,39 @@ const isObject = (v: unknown): v is JsonObject => const isUnconstrained = (v: unknown): boolean => isObject(v) && Object.keys(v).length === 0; /** - * Every object schema in `doc` that lists a property as `required` while that - * property declares a `default`. + * The io-direction census: every object schema that constrains a `required` + * list against a `properties` map (`inspected` — the instrument), and those of + * them listing a property that declares a `default` (`offenders` — the reading). * - * This is the io-direction discriminator: zod's output derivation makes a - * defaulted property present-and-required, its input derivation leaves it - * optional. Derived from the document so no path is hard-coded. + * zod's OUTPUT derivation makes a defaulted property present-and-required; its + * INPUT derivation leaves it optional. Derived from the document, so the + * direction is pinned without hard-coding any path an ordinary spec change + * would move. + * + * ⛔ `offenders` is only a reading while `inspected` is non-zero. An empty + * document — which is what a command that wrote nothing leaves behind — has no + * offenders either, and a zero from a dark instrument is exactly the shape this + * whole file exists to refuse. */ -function defaultedYetRequired(node: unknown, path = '$', acc: string[] = []): string[] { +function censusDirection( + node: unknown, + path = '$', + acc: { inspected: number; offenders: string[] } = { inspected: 0, offenders: [] }, +): { inspected: number; offenders: string[] } { if (Array.isArray(node)) { - node.forEach((child, i) => defaultedYetRequired(child, `${path}[${i}]`, acc)); + node.forEach((child, i) => censusDirection(child, `${path}[${i}]`, acc)); return acc; } if (!isObject(node)) return acc; if (Array.isArray(node.required) && isObject(node.properties)) { + acc.inspected++; for (const key of node.required) { const prop = typeof key === 'string' ? node.properties[key] : undefined; - if (isObject(prop) && 'default' in prop) acc.push(`${path}.required:${String(key)}`); + if (isObject(prop) && 'default' in prop) acc.offenders.push(`${path}.required:${String(key)}`); } } for (const [key, child] of Object.entries(node)) { - defaultedYetRequired(child, `${path}.${key}`, acc); + censusDirection(child, `${path}.${key}`, acc); } return acc; } @@ -233,9 +245,12 @@ describe('os generate schema', () => { }); it('(e) is the AUTHORING derivation — no defaulted property is published as required', () => { - // In the output derivation this count is 752 on this tree, and every one of - // them is an IDE reporting a valid config as missing a key its author never - // had to write. - expect(defaultedYetRequired(doc)).toEqual([]); + // In the output derivation the offender count is 752 on this tree, and every + // one of them is an IDE reporting a valid config as missing a key its author + // never had to write. `inspected` is the lit control: the zero below is only + // a reading while the instrument found object schemas to judge at all. + const census = censusDirection(doc); + expect(census.inspected).toBeGreaterThan(0); + expect(census.offenders).toEqual([]); }); }); From 30361ba9b45626203f1e95267f0fb0baa22a9f0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 22:39:18 +0000 Subject: [PATCH 4/4] chore(cli): changeset for the generate-schema fallback Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude --- .changeset/sour-moons-smile.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .changeset/sour-moons-smile.md diff --git a/.changeset/sour-moons-smile.md b/.changeset/sour-moons-smile.md new file mode 100644 index 0000000000..abfed44a45 --- /dev/null +++ b/.changeset/sour-moons-smile.md @@ -0,0 +1,26 @@ +--- +'@objectstack/cli': patch +--- + +`os generate schema` can now reach its own `fs.writeFileSync`. + +`runSchemaGeneration` called `z.toJSONSchema(ObjectStackDefinitionSchema, { target: 'draft-2020-12' })` +bare — the one `toJSONSchema` call site in this repository that neither fell back nor used the +`unrepresentable` convention. That call has no JSON form in either io direction on today's tree (a +transform in the output direction, a function type in the authoring direction), so the `catch` below +it printed and exited 1 for every repository and every flag combination: the command could never +write the IDE schema it exists to write. + +It now runs the same three-tier ladder `packages/spec/scripts/build-schemas.ts` already runs for +every schema it publishes — output, then the authoring (`io: 'input'`) direction, then that direction +with `unrepresentable: 'any'` as `packages/metadata-protocol` spells it — and each tier re-raises any +error the known-unsupported predicate does not recognise, so a real conversion failure is still loud. + +No new flag, no new key and no new exported symbol: the change is confined to the body of a +module-private function. + +The published document lands on the third tier today. It is the authoring derivation, so a property +carrying a `default` is not reported as required; the nodes that have no JSON form in any direction — +`onEnable`, and the inline-callable branch of each `handler` under `hooks`, `functions` and +`packages` — are published as unconstrained, which means an IDE validates everything else in +`objectstack.config.ts` and asks nothing about those.