From fac432abe50a301209022cbeb995e4db6fd3d778 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:19:11 +0000 Subject: [PATCH 1/5] =?UTF-8?q?fix(cli)!:=20retire=20`os=20generate=20sche?= =?UTF-8?q?ma`=20=E2=80=94=20it=20refuses,=20and=20points=20at=20`os=20val?= =?UTF-8?q?idate`=20and=20the=20published=20per-type=20schemas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling on #19098 (comment 5856790152, letter C, confirmed 5856865990). `schema` joins RETIRED_GENERATORS in the `os g agent` shape. The retirement ledger is now read in Generate.run before sub-command routing and before the requirement, as an own-key read; before this, neither `os generate schema` (routed, nameless) nor `os g agent` without a name could reach it. The three z.toJSONSchema rungs over ObjectStackDefinitionSchema, their ladder notices and the known-unsupported predicate are deleted with runSchemaGeneration. The e2e pin of the written file is replaced by the retirement pin. The data-flow docs row that advertised the command, and its diagram node, are removed. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- .changeset/19098-retire-generate-schema.md | 87 ++++++ content/docs/api/data-flow.mdx | 5 +- packages/cli/src/commands/generate.ts | 222 ++++++--------- .../test/generate-schema-retired.e2e.test.ts | 170 ++++++++++++ ...rate-schema-writes-json-schema.e2e.test.ts | 256 ------------------ 5 files changed, 337 insertions(+), 403 deletions(-) create mode 100644 .changeset/19098-retire-generate-schema.md create mode 100644 packages/cli/test/generate-schema-retired.e2e.test.ts delete mode 100644 packages/cli/test/generate-schema-writes-json-schema.e2e.test.ts diff --git a/.changeset/19098-retire-generate-schema.md b/.changeset/19098-retire-generate-schema.md new file mode 100644 index 00000000000..a30b1d71a73 --- /dev/null +++ b/.changeset/19098-retire-generate-schema.md @@ -0,0 +1,87 @@ +--- +'@objectstack/cli': minor +--- + +fix(cli): **BREAKING** — `os generate schema` is retired, and it now says why and points at `os validate` and the per-type JSON Schemas `@objectstack/spec` publishes (#19098) + +Clause-②: no (narrowing) + + + +**⛔ If a script, a Makefile or a CI step in your project runs `os generate schema` +(or `os g schema`), it will now exit 1 and write nothing.** That is the intended +outcome: the command is gone by maintainer ruling, and the failure is how you find +out. Nothing in this repository reads the file it wrote. + +`minor`, not `major`: during the launch window this stack ships breaking changes as +`minor` (pre-1.0 semantics under lockstep versioning — see +`scripts/check-changeset-no-major.mjs`). + +**What the command actually did.** `os generate schema` wrote +`objectstack.schema.json`, a JSON Schema of the whole stack definition for an editor +to check `objectstack.config.ts` against. It projected `ObjectStackDefinitionSchema` +through a bare `z.toJSONSchema`, so the refinements the platform enforces beyond the +shape — a non-blank string, a required one-of, a banned key — were missing from the +file. Measured against the published projection on the tree the ruling was made on, +the file lacked 874 keywords, every one of them an absence. An editor pointed at it +reported a config as valid, and the platform then refused that config. + +**Why it is retired rather than repaired.** A TypeScript configuration is typed by its +own `define*` helper, and `objectstack.config.ts` is typed end to end by +`defineStack`, so no config format this CLI loads is one an editor validates against +a JSON Schema. JSON metadata already has the per-type schemas `@objectstack/spec` +publishes, which carry the published projection. Repairing the command would have +added a permanent public export to `@objectstack/spec` for a file with no reader. The +ruling generates no replacement file. What you see now: + +``` + ✗ `os g schema` was retired — its JSON Schema passed configs the platform refuses (maintainer ruling). + + The file it wrote described only the shape of a stack. Every rule the + platform enforces beyond that shape — a non-blank string, a required + one-of, a banned key — was missing from it, so an editor showed a config + as valid and the platform then refused it. By maintainer ruling it is + retired, not repaired, and no replacement file is generated. + + Check a project against the rules that actually run: + + os validate + + For JSON metadata, point your editor at the per-type schemas that + @objectstack/spec publishes. They state the rules a JSON Schema can + express, and name the ones it cannot under `x-dropped-refinements`: + + node_modules/@objectstack/spec/json-schema//.json + + `objectstack.config.ts` needs neither: `defineStack` types it in your + editor. Delete the `os generate schema` call, and any editor setting + that maps `objectstack.schema.json` — nothing writes that file now. + + Docs: https://objectstack.ai/docs/deployment/cli +``` + +**What to do.** The refusal's pointer is the whole of it. Delete the +`os generate schema` call, and any editor setting that maps `objectstack.schema.json` +(a `json.schemas` or `yaml.schemas` entry, for example). Run `os validate` to check a +project against the rules that actually run. For JSON metadata, point the editor at +`node_modules/@objectstack/spec/json-schema/`, one file per metadata type. There is no +call to rename: nothing replaces the command. + +**Reach outside this repository is NOT MEASURED.** There is no telemetry, so whether +any project runs the command or reads its file is unknown. Inside this repository +nothing does: no reader and no editor mapping of `objectstack.schema.json` exists. +If these release notes also record that `os generate schema` can now write its file, +this retirement supersedes that repair. + +**Two neighbouring answers change with it.** The retirement ledger is now read before +the command routes its sub-commands and before it asks for a ``, which is what +lets `os generate schema` (no name) reach the refusal at all. So `os g agent` with no +name now prints the agent retirement instead of `Missing required argument: `. +The ledger lookup also reads its own keys only: `os g constructor ` used to be +taken for a retired type and crashed with a `TypeError`, and it now falls through to +the ordinary type checks. + +The docs row that advertised the command — "Autocomplete and validation for +`objectstack.config.ts` (via `os generate schema`)" in +`content/docs/api/data-flow.mdx` — is gone, with the diagram's JSON Schema node above +it. diff --git a/content/docs/api/data-flow.mdx b/content/docs/api/data-flow.mdx index 382ccd001e1..9eaa299c98a 100644 --- a/content/docs/api/data-flow.mdx +++ b/content/docs/api/data-flow.mdx @@ -180,10 +180,8 @@ flowchart LR end subgraph "Build Time" - C -->|"generate"| D[JSON Schema] C -->|"generate"| E[TypeScript Types] - D -->|"feed"| F[IDE Autocomplete] - E -->|"feed"| F + E -->|"feed"| F[IDE Autocomplete] end subgraph "Runtime" @@ -197,7 +195,6 @@ flowchart LR | Output | Used By | Purpose | |:---|:---|:---| -| JSON Schema | VS Code, IntelliJ | Autocomplete and validation for `objectstack.config.ts` (via `os generate schema`) | | TypeScript Types | Plugin developers | Type-safe access to object definitions | | Manifest | Kernel | Runtime metadata for query validation and execution | | Metadata API | Client SDK | Dynamic object/field discovery | diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 4b0fd7a3b2c..62cb7ec8b8e 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -504,6 +504,12 @@ export const GENERATOR_SCAFFOLD_TARGETS: readonly { * and the natural next move is to hunt for the right spelling of something * that no longer exists. * + * The ledger is read by {@link refuseRetiredGenerator}, which `Generate.run` + * calls before anything else — ahead of the sub-command routing and ahead of + * the `` requirement. Both positions matter: `schema` was one of the + * routed sub-commands, and it never took a name, so a lookup placed after + * either one answered `os generate schema` with something other than this. + * * `agent` (ADR-0063 §2, which reversed ADR-0040 §3): the kernel ships exactly * two agents, `ask` and `build`, bound by surface and never picked from a * roster. Tenant / app-package agents were withdrawn, and the runtime catalog @@ -514,6 +520,22 @@ export const GENERATOR_SCAFFOLD_TARGETS: readonly { * that silence one step earlier instead of ending it, which is why each entry * owes both halves — the decision that withdrew the surface, and the surface * to author instead. + * + * `schema` (maintainer ruling on #19098, comment 5856790152, letter C — + * retired, not repaired): `os generate schema` wrote a JSON Schema of the + * whole stack definition for an editor to check `objectstack.config.ts` + * against. It projected `ObjectStackDefinitionSchema` through a bare + * `z.toJSONSchema`, so every refinement the platform enforces beyond the + * shape (a non-blank string, a required one-of, a banned key) was missing + * from the file, and an editor reported as valid a config the platform then + * refused — a green at authoring time that the runtime contradicts. No + * config format this CLI loads is one an editor validates against a JSON + * Schema (`objectstack.config.ts` is typed through `defineStack`), and no + * reader of the file was found. The ruling generates no replacement file: + * the refusal points at `os validate`, which runs the real parse, and at the + * per-type schemas `@objectstack/spec` publishes, which carry the published + * projection. The ruling's id lives here and not in the refusal, because + * text an author is shown carries no tracker number. */ const RETIRED_GENERATORS: Record` was retired — …". */ @@ -542,8 +564,54 @@ const RETIRED_GENERATORS: Record/.json', + '', + '`objectstack.config.ts` needs neither: `defineStack` types it in your', + 'editor. Delete the `os generate schema` call, and any editor setting', + 'that maps `objectstack.schema.json` — nothing writes that file now.', + '', + 'Docs: https://objectstack.ai/docs/deployment/cli', + ], + }, }; +/** + * Print a retired generator's refusal and exit 1 — never returns. + * + * Called from `Generate.run` ahead of the sub-command routing and the + * `` requirement (see {@link RETIRED_GENERATORS} for why both), so + * every spelling of a retired type reaches it: `os g agent support`, + * `os g agent`, `os generate schema -o `. + */ +function refuseRetiredGenerator(type: string): never { + const retired = RETIRED_GENERATORS[type]; + printHeader('Generate'); + printError(`\`${CLI_ALIAS} g ${type}\` was retired — ${retired.reason}`); + console.log(''); + for (const line of retired.detail) { + console.log(line ? chalk.dim(` ${line}`) : ''); + } + console.log(''); + process.exit(1); +} + // ─── Helpers ──────────────────────────────────────────────────────── function toCamelCase(str: string): string { @@ -924,18 +992,8 @@ export function generateTypesFromConfig(config: Record): string async function runMetadataGeneration(type: string, name: string, flags: { dir?: string; dryRun?: boolean }): Promise { printHeader('Generate'); - // A withdrawn type answers for itself, ahead of the roster lookup — see - // RETIRED_GENERATORS for why "unknown type" is the wrong answer here. - const retired = RETIRED_GENERATORS[type]; - if (retired) { - printError(`\`${CLI_ALIAS} g ${type}\` was retired — ${retired.reason}`); - console.log(''); - for (const line of retired.detail) { - console.log(line ? chalk.dim(` ${line}`) : ''); - } - console.log(''); - process.exit(1); - } + // A withdrawn type never reaches this function: `Generate.run` answers it + // first, through refuseRetiredGenerator. const generator = GENERATORS[type]; if (!generator) { @@ -3146,132 +3204,6 @@ 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'); - - try { - const timer = createTimer(); - printStep('Loading ObjectStackDefinitionSchema...'); - - const { z } = await import('zod'); - const { ObjectStackDefinitionSchema } = await import('@objectstack/spec'); - - printStep('Converting to JSON Schema...'); - - // [#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 = { - ...jsonSchema, - $id: 'https://schema.objectstack.io/objectstack.config.json', - title: 'ObjectStack Configuration', - description: 'JSON Schema for objectstack.config.ts — generated from ObjectStackDefinitionSchema', - }; - - const content = JSON.stringify(schema, null, 2) + '\n'; - - if (flags.dryRun) { - printInfo('Dry run — no files written'); - console.log(''); - console.log(content); - return; - } - - const outPath = path.resolve(process.cwd(), flags.output); - const outDir = path.dirname(outPath); - if (!fs.existsSync(outDir)) { - fs.mkdirSync(outDir, { recursive: true }); - } - fs.writeFileSync(outPath, content); - printSuccess(`Generated JSON Schema at ${flags.output} (${timer.display()})`); - console.log(''); - console.log(chalk.dim(' Usage: Reference in your IDE or editor for autocomplete')); - console.log(chalk.dim(` Path: ${outPath}`)); - console.log(''); - - } catch (error: any) { - printError(error.message || String(error)); - process.exit(1); - } -} - // ─── Main Generate Command ────────────────────────────────────────── export default class Generate extends Command { @@ -3300,6 +3232,15 @@ export default class Generate extends Command { async run(): Promise { const { args, flags } = await this.parse(Generate); + // A withdrawn type answers for itself before anything else is read: ahead + // of the sub-command routing below and ahead of the `` requirement, + // since `os generate schema` was a routed sub-command that took no name. + // An own-key read, because the ledger is a plain object literal: an + // inherited name such as `constructor` is not a retired type. + if (Object.prototype.hasOwnProperty.call(RETIRED_GENERATORS, args.type)) { + refuseRetiredGenerator(args.type); + } + // Route to sub-commands by type name switch (args.type) { case 'types': @@ -3318,11 +3259,6 @@ export default class Generate extends Command { format: flags.format ?? 'typescript', dryRun: flags['dry-run'], }); - case 'schema': - return runSchemaGeneration({ - output: flags.output ?? 'objectstack.schema.json', - dryRun: flags['dry-run'], - }); } // Metadata generation diff --git a/packages/cli/test/generate-schema-retired.e2e.test.ts b/packages/cli/test/generate-schema-retired.e2e.test.ts new file mode 100644 index 00000000000..3a8c4228d1e --- /dev/null +++ b/packages/cli/test/generate-schema-retired.e2e.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#19098) — `os generate schema` is retired, and its refusal says where + * the truth lives instead. + * + * Maintainer ruling on #19098 (comment 5856790152, letter C). The command + * wrote a JSON Schema of `ObjectStackDefinitionSchema` through a bare + * `z.toJSONSchema`, so every refinement the platform enforces beyond the + * shape was missing from the file, and an editor passed configs the runtime + * then refused. It is retired, not repaired, and no replacement file is + * generated. The refusal names the ruling, `os validate` (the real parse) and + * the per-type schemas `@objectstack/spec` publishes (the published + * projection). + * + * This file replaces `generate-schema-writes-json-schema.e2e.test.ts` + * (#17873), which pinned the document the command wrote — the very file the + * ruling withdrew. + * + * The shape follows `generate-agent-retired.e2e.test.ts` (#10359): the + * assertions are about the CONTENT of the refusal, not only about a non-zero + * exit, because a bare "unknown type" or "missing argument" also exits 1 and + * leaves the author hunting for a spelling of something that no longer + * exists. They run on a REAL CHILD PROCESS and read stdout, for the reasons + * that file gives (`process.exitCode` in a vitest worker is not an exit + * status; `printError` writes to stdout), spawned through `bin/run-dev.js` + + * tsx so the suite does not depend on `packages/cli/dist`. + * + * ## Why the DOOR is pinned, not only the ledger entry + * + * `schema` was a routed sub-command that took no ``. A ledger entry on + * its own is unreachable from `os generate schema`: the sub-command switch in + * `Generate.run` returned first, and with that case gone the `` + * requirement answered "Missing required argument" before the ledger was read + * (measured before this change on `os g agent`, which printed exactly that). + * So the two spellings below are the ones an author or a CI script actually + * runs — the documented `os generate schema` with no name, and the alias with + * the old `-o` flag, whose target must stay unwritten. Two controls hold the + * door's shape: `os g agent` with no name proves the door is the ledger rather + * than a `schema` special case, and `os g constructor` proves it reads own + * keys only (an inherited name used to be taken for a retired type and crash + * the refusal on `retired.detail`). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, 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 = 240_000; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runTsx(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + args, + { cwd, maxBuffer: 8 * 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), + }); + }, + ); + }); +} + +let dir: string; +let retired: Run; +let retiredAlias: Run; +let writtenBySchemaRuns: string[]; +let agentNoName: Run; +let inherited: Run; +let survivor: Run; + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-g-schema-retired-')); + + // Sequential on purpose: five cold tsx starts, each loading every command + // module, in a container several agents share. + retired = await runTsx([CLI, 'generate', 'schema'], dir); + retiredAlias = await runTsx([CLI, 'g', 'schema', '-o', 'custom.schema.json'], dir); + // Listed right after the two schema runs and before anything else runs in + // this directory: the default target and the `-o` target must both be absent. + writtenBySchemaRuns = readdirSync(dir); + agentNoName = await runTsx([CLI, 'g', 'agent'], dir); + inherited = await runTsx([CLI, 'g', 'constructor', 'thing'], dir); + survivor = await runTsx([CLI, 'g', 'object', 'customer', '--dry-run'], dir); +}, RUN_TIMEOUT_MS); + +afterAll(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); +}); + +describe('[#19098] `os generate schema` is retired', () => { + it('fails instead of writing — a CI script that still calls it stops', () => { + expect(retired.code).toBe(1); + expect(retiredAlias.code).toBe(1); + }); + + it('writes nothing — neither the default `objectstack.schema.json` nor an `-o` target', () => { + expect(writtenBySchemaRuns).toEqual([]); + }); + + it('says the command was RETIRED — not an unknown type, not a missing name', () => { + expect(retired.stdout).toContain('`os g schema` was retired'); + expect(retired.stdout).not.toContain('Unknown type:'); + expect(retired.stdout).not.toContain('Missing required argument'); + }); + + it('names the ruling that retired it', () => { + expect(retired.stdout).toContain('maintainer ruling'); + }); + + it('points at `os validate` — the parse that runs the rules the file dropped', () => { + expect(retired.stdout).toContain('os validate'); + }); + + it('points at the per-type schemas `@objectstack/spec` publishes', () => { + expect(retired.stdout).toContain('@objectstack/spec/json-schema/'); + }); + + it('answers the alias with the old `-o` flag through the same door, byte for byte', () => { + expect(retiredAlias.stdout).toBe(retired.stdout); + }); +}); + +describe('[#19098] the retirement door is the ledger, read before routing and before ``', () => { + it('`os g agent` with no name gets the agent retirement, not "Missing required argument"', () => { + expect(agentNoName.code).toBe(1); + expect(agentNoName.stdout).toContain('`os g agent` was retired'); + expect(agentNoName.stdout).toContain('ADR-0063'); + expect(agentNoName.stdout).not.toContain('Missing required argument'); + }); + + it('an inherited name (`constructor`) is not a retired type — own keys only', () => { + expect(inherited.code).toBe(1); + expect(inherited.stdout).not.toContain('was retired'); + expect(`${inherited.stdout}\n${inherited.stderr}`).not.toContain('TypeError'); + }); +}); + +describe('[#19098] the generators that were not retired still work', () => { + it('`os g object … --dry-run` still previews a typed object file', () => { + expect(survivor.code).toBe(0); + expect(survivor.stdout).toContain('Dry run'); + expect(survivor.stdout).toContain("import * as Data from '@objectstack/spec/data'"); + }); +}); 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 deleted file mode 100644 index 84c5ee7f73a..00000000000 --- a/packages/cli/test/generate-schema-writes-json-schema.e2e.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -// 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; - -/** - * 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). - * - * 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 censusDirection( - node: unknown, - path = '$', - acc: { inspected: number; offenders: string[] } = { inspected: 0, offenders: [] }, -): { inspected: number; offenders: string[] } { - if (Array.isArray(node)) { - 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.offenders.push(`${path}.required:${String(key)}`); - } - } - for (const [key, child] of Object.entries(node)) { - censusDirection(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 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 c1036ebb9ac39465612299dd67d212806a075975 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:46:00 +0000 Subject: [PATCH 2/5] test(cli): the schema-retirement pin's live-generator control reads the current object template The control copied the agent pin's `import * as Data` assertion, which the object template stopped emitting in 0bd11261e. It now asserts the previewed file path and the `@objectstack/spec/data` import source. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/cli/test/generate-schema-retired.e2e.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/generate-schema-retired.e2e.test.ts b/packages/cli/test/generate-schema-retired.e2e.test.ts index 3a8c4228d1e..c12ab575538 100644 --- a/packages/cli/test/generate-schema-retired.e2e.test.ts +++ b/packages/cli/test/generate-schema-retired.e2e.test.ts @@ -165,6 +165,10 @@ describe('[#19098] the generators that were not retired still work', () => { it('`os g object … --dry-run` still previews a typed object file', () => { expect(survivor.code).toBe(0); expect(survivor.stdout).toContain('Dry run'); - expect(survivor.stdout).toContain("import * as Data from '@objectstack/spec/data'"); + expect(survivor.stdout).toContain('src/objects/customer.object.ts'); + // The import line, not a spelling of it: the template's binding changed + // once already (a namespace import became `{ ObjectSchema }`), and the + // control asks only that a typed object file is still previewed. + expect(survivor.stdout).toContain("from '@objectstack/spec/data'"); }); }); From a2dda36c215f3a0f2d879818c5005bcb1bee46f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:49:35 +0000 Subject: [PATCH 3/5] test(cli),docs: per-PR pin for the retired-generator door; agent pin control reads the template; cli.mdx retirement callout The retirement pin was nightly-only (`.e2e`). generate-refuses-retired- generator.test.ts is the per-PR spawn guard: `os generate schema` and `os g schema -o FILE` exit 1, write nothing and answer the retirement (not a missing name, not an unknown type); `os g constructor NAME` is an own-key miss with no TypeError; `os g object --dry-run` is the control. The live-generator control in generate-agent-retired.e2e.test.ts asserted a copied `import * as Data` line the object template stopped emitting in 0bd11261e. Both controls now assert the preview equals what the exported object template emits. content/docs/deployment/cli.mdx gains the `os generate schema` callout beside the `os g agent` one, pointing at `os validate` and the per-type schemas. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- content/docs/deployment/cli.mdx | 18 ++ .../test/generate-agent-retired.e2e.test.ts | 11 +- ...generate-refuses-retired-generator.test.ts | 167 ++++++++++++++++++ 3 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/generate-refuses-retired-generator.test.ts diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 3491b5c130c..044fab07a35 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1461,6 +1461,24 @@ third-party extension primitive, authored as `src/skills/.skill.ts` with `os g skill `, which writes exactly that path. + +There is no `schema` type. Running `os generate schema` (or `os g schema`) fails +and writes nothing, not even `objectstack.schema.json`. The message says it was +retired by maintainer ruling and points at [`os validate`](#os-validate) and the +per-type schemas `@objectstack/spec` publishes, rather than the generic "unknown +type" listing. No replacement file is generated. + +The file it wrote described only the shape of a stack. Every rule the platform +enforces beyond that shape (a non-blank string, a required one-of, a banned key) +was missing from it, so an editor showed a config as valid that the platform then +refused. `objectstack.config.ts` needs no JSON Schema: `defineStack` types it in +your editor. Check a project against the rules that actually run with +`os validate`. For JSON metadata, point your editor at +`node_modules/@objectstack/spec/json-schema//.json`, which states +the rules a JSON Schema can express and names the rest under +`x-dropped-refinements`. + + **Options:** - `-d, --dir ` — Override target directory - `--dry-run` — Preview without writing files diff --git a/packages/cli/test/generate-agent-retired.e2e.test.ts b/packages/cli/test/generate-agent-retired.e2e.test.ts index 005dc87c49c..247bb87691b 100644 --- a/packages/cli/test/generate-agent-retired.e2e.test.ts +++ b/packages/cli/test/generate-agent-retired.e2e.test.ts @@ -37,6 +37,7 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { GENERATOR_SCAFFOLD_TARGETS } from '../src/commands/generate.js'; import { childEnv } from './helpers/serve-process.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); @@ -147,6 +148,14 @@ describe('[#10359] the generators that were not retired still work', () => { it('`os g object … --dry-run` still previews a typed object file', () => { expect(survivor.code).toBe(0); expect(survivor.stdout).toContain('Dry run'); - expect(survivor.stdout).toContain("import * as Data from '@objectstack/spec/data'"); + // Anchored to the object template itself, not to a copied line of it: the + // preview prints the template's output, each line indented two spaces, and + // this directory has no config, so no namespace. A copied import line went + // stale once already, when the template moved from a namespace import to + // `ObjectSchema.create`; this assertion moves with the template instead. + const objectTemplate = GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === 'object'); + expect(objectTemplate).toBeDefined(); + const preview = objectTemplate!.generate('customer').split('\n').map((l) => ` ${l}`).join('\n'); + expect(survivor.stdout).toContain(preview); }); }); diff --git a/packages/cli/test/generate-refuses-retired-generator.test.ts b/packages/cli/test/generate-refuses-retired-generator.test.ts new file mode 100644 index 00000000000..4862b08ed98 --- /dev/null +++ b/packages/cli/test/generate-refuses-retired-generator.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#19098) — the retired-generator door of `os generate`, in the run that + * gates the merge queue. + * + * `os generate schema` was retired by maintainer ruling (comment 5856790152 on + * #19098, letter C): the JSON Schema it wrote passed configs the platform then + * refused. Retiring it moved the `RETIRED_GENERATORS` lookup to the top of + * `Generate.run`, ahead of the sub-command routing and the `` + * requirement, because `schema` was a routed sub-command that took no name: + * behind either of those, the ledger was never read for it. The lookup is an + * own-key read, so an inherited name such as `constructor` is not taken for a + * retired type (it used to be, and the refusal then crashed on + * `retired.detail`). + * + * Held here, each to exit 1 and bytes on disk: + * + * 1. `os generate schema`, the documented spelling, with no name: the + * retirement refusal, not "Missing required argument" (what the old + * position answered) and not "Unknown type:"; it names the ruling, + * `os validate` and the per-type schemas `@objectstack/spec` publishes; + * and it writes nothing, not even the default `objectstack.schema.json`. + * 2. `os g schema -o FILE`: the same door for the alias with the old flag, + * and the `-o` target stays unwritten. + * 3. `os g constructor NAME`: an own-key miss. It is not answered as + * retired, it does not crash, and it writes nothing. + * + * One control keeps the refusals from being satisfied by a command that + * refuses everything: in the same directory `os g object customer --dry-run` + * exits 0 and previews exactly what the object template emits (anchored to + * the exported template rather than to a copied line of it). + * + * ## Why a child process, and why this file is NOT named `.e2e` + * + * The same reasons `generate-refuses-namespace-prefix.test.ts` gives: an exit + * code plus bytes on disk are the contract, `process.exitCode` inside a + * vitest worker is not an exit status, and `printError` writes to stdout. + * Spawning puts the file in the `integration` project; the name keeps it in + * the per-PR run. `generate-schema-retired.e2e.test.ts` and + * `generate-agent-retired.e2e.test.ts` hold the fuller refusal texts nightly; + * this file is the per-PR guard for the door. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { GENERATOR_SCAFFOLD_TARGETS } from '../src/commands/generate.js'; +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 starts, four of them, sequential. */ +const RUN_TIMEOUT_MS = 240_000; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runTsx(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + args, + { cwd, maxBuffer: 8 * 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), + }); + }, + ); + }); +} + +const objectTemplate = GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === 'object'); + +let dir: string; +let schema: Run; +let schemaAlias: Run; +let inherited: Run; +let control: Run; + +/** The directory as each refusal left it, read before the control runs. */ +let afterSchemaRuns: string[]; +let afterInherited: string[]; + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-g-refuses-retired-')); + + // Sequential on purpose: cold tsx starts in a container several agents share. + // Every refusal runs BEFORE the control, so the listings below are what each + // refusal was measured against. + schema = await runTsx([CLI, 'generate', 'schema'], dir); + schemaAlias = await runTsx([CLI, 'g', 'schema', '-o', 'custom.schema.json'], dir); + afterSchemaRuns = readdirSync(dir); + inherited = await runTsx([CLI, 'g', 'constructor', 'thing'], dir); + afterInherited = readdirSync(dir); + control = await runTsx([CLI, 'g', 'object', 'customer', '--dry-run'], dir); +}, RUN_TIMEOUT_MS); + +afterAll(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); +}); + +describe('[#19098] `os generate schema` answers the retirement refusal', () => { + it('exits 1, for the documented spelling and for the alias with `-o`', () => { + expect(schema.code, schema.stdout + schema.stderr).toBe(1); + expect(schemaAlias.code, schemaAlias.stdout + schemaAlias.stderr).toBe(1); + }); + + it('writes nothing: neither the default `objectstack.schema.json` nor the `-o` target', () => { + expect(afterSchemaRuns).toEqual([]); + }); + + it('is the retirement, not a missing name and not an unknown type', () => { + for (const run of [schema, schemaAlias]) { + expect(run.stdout).toContain('`os g schema` was retired'); + expect(run.stdout).not.toContain('Missing required argument'); + expect(run.stdout).not.toContain('Unknown type:'); + } + }); + + it('names the ruling, `os validate` and the per-type schemas `@objectstack/spec` publishes', () => { + expect(schema.stdout).toContain('maintainer ruling'); + expect(schema.stdout).toContain('os validate'); + expect(schema.stdout).toContain('@objectstack/spec/json-schema/'); + }); +}); + +describe('[#19098] the ledger is read by own key only', () => { + it('`os g constructor thing` is not taken for a retired type, and does not crash', () => { + expect(inherited.code, inherited.stdout + inherited.stderr).toBe(1); + expect(inherited.stdout).not.toContain('was retired'); + expect(`${inherited.stdout}\n${inherited.stderr}`).not.toContain('TypeError'); + }); + + it('writes nothing', () => { + expect(afterInherited).toEqual([]); + }); +}); + +describe('[#19098] CONTROL — the door is not a command that refuses everything', () => { + it('`os g object customer --dry-run` exits 0 and previews what the object template emits', () => { + expect(control.code, control.stdout + control.stderr).toBe(0); + expect(control.stdout).toContain('Dry run'); + expect(objectTemplate).toBeDefined(); + // The preview prints the template's output, each line indented two spaces; + // this directory has no config, so the template is called with no namespace. + const preview = objectTemplate!.generate('customer').split('\n').map((l) => ` ${l}`).join('\n'); + expect(control.stdout).toContain(preview); + }); +}); From cb9f98c277b20f40328c4d30341fd3e8f10c4a7c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:37:51 +0000 Subject: [PATCH 4/5] test(cli),docs: per-PR pin for the retired-generator door; cli.mdx retirement callout The retirement pin was nightly-only (`.e2e`). generate-refuses-retired- generator.test.ts is the per-PR spawn guard: `os generate schema` and `os g schema -o FILE` exit 1, write nothing and answer the retirement (not a missing name, not an unknown type); `os g constructor NAME` is an own-key miss with no TypeError; `os g object --dry-run` is the control, asserting the preview equals what the exported object template emits. content/docs/deployment/cli.mdx gains the `os generate schema` callout beside the `os g agent` one, pointing at `os validate` and the per-type schemas. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- content/docs/deployment/cli.mdx | 18 ++ ...generate-refuses-retired-generator.test.ts | 167 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 packages/cli/test/generate-refuses-retired-generator.test.ts diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 3491b5c130c..044fab07a35 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1461,6 +1461,24 @@ third-party extension primitive, authored as `src/skills/.skill.ts` with `os g skill `, which writes exactly that path. + +There is no `schema` type. Running `os generate schema` (or `os g schema`) fails +and writes nothing, not even `objectstack.schema.json`. The message says it was +retired by maintainer ruling and points at [`os validate`](#os-validate) and the +per-type schemas `@objectstack/spec` publishes, rather than the generic "unknown +type" listing. No replacement file is generated. + +The file it wrote described only the shape of a stack. Every rule the platform +enforces beyond that shape (a non-blank string, a required one-of, a banned key) +was missing from it, so an editor showed a config as valid that the platform then +refused. `objectstack.config.ts` needs no JSON Schema: `defineStack` types it in +your editor. Check a project against the rules that actually run with +`os validate`. For JSON metadata, point your editor at +`node_modules/@objectstack/spec/json-schema//.json`, which states +the rules a JSON Schema can express and names the rest under +`x-dropped-refinements`. + + **Options:** - `-d, --dir ` — Override target directory - `--dry-run` — Preview without writing files diff --git a/packages/cli/test/generate-refuses-retired-generator.test.ts b/packages/cli/test/generate-refuses-retired-generator.test.ts new file mode 100644 index 00000000000..4862b08ed98 --- /dev/null +++ b/packages/cli/test/generate-refuses-retired-generator.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#19098) — the retired-generator door of `os generate`, in the run that + * gates the merge queue. + * + * `os generate schema` was retired by maintainer ruling (comment 5856790152 on + * #19098, letter C): the JSON Schema it wrote passed configs the platform then + * refused. Retiring it moved the `RETIRED_GENERATORS` lookup to the top of + * `Generate.run`, ahead of the sub-command routing and the `` + * requirement, because `schema` was a routed sub-command that took no name: + * behind either of those, the ledger was never read for it. The lookup is an + * own-key read, so an inherited name such as `constructor` is not taken for a + * retired type (it used to be, and the refusal then crashed on + * `retired.detail`). + * + * Held here, each to exit 1 and bytes on disk: + * + * 1. `os generate schema`, the documented spelling, with no name: the + * retirement refusal, not "Missing required argument" (what the old + * position answered) and not "Unknown type:"; it names the ruling, + * `os validate` and the per-type schemas `@objectstack/spec` publishes; + * and it writes nothing, not even the default `objectstack.schema.json`. + * 2. `os g schema -o FILE`: the same door for the alias with the old flag, + * and the `-o` target stays unwritten. + * 3. `os g constructor NAME`: an own-key miss. It is not answered as + * retired, it does not crash, and it writes nothing. + * + * One control keeps the refusals from being satisfied by a command that + * refuses everything: in the same directory `os g object customer --dry-run` + * exits 0 and previews exactly what the object template emits (anchored to + * the exported template rather than to a copied line of it). + * + * ## Why a child process, and why this file is NOT named `.e2e` + * + * The same reasons `generate-refuses-namespace-prefix.test.ts` gives: an exit + * code plus bytes on disk are the contract, `process.exitCode` inside a + * vitest worker is not an exit status, and `printError` writes to stdout. + * Spawning puts the file in the `integration` project; the name keeps it in + * the per-PR run. `generate-schema-retired.e2e.test.ts` and + * `generate-agent-retired.e2e.test.ts` hold the fuller refusal texts nightly; + * this file is the per-PR guard for the door. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { GENERATOR_SCAFFOLD_TARGETS } from '../src/commands/generate.js'; +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 starts, four of them, sequential. */ +const RUN_TIMEOUT_MS = 240_000; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runTsx(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + args, + { cwd, maxBuffer: 8 * 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), + }); + }, + ); + }); +} + +const objectTemplate = GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === 'object'); + +let dir: string; +let schema: Run; +let schemaAlias: Run; +let inherited: Run; +let control: Run; + +/** The directory as each refusal left it, read before the control runs. */ +let afterSchemaRuns: string[]; +let afterInherited: string[]; + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-g-refuses-retired-')); + + // Sequential on purpose: cold tsx starts in a container several agents share. + // Every refusal runs BEFORE the control, so the listings below are what each + // refusal was measured against. + schema = await runTsx([CLI, 'generate', 'schema'], dir); + schemaAlias = await runTsx([CLI, 'g', 'schema', '-o', 'custom.schema.json'], dir); + afterSchemaRuns = readdirSync(dir); + inherited = await runTsx([CLI, 'g', 'constructor', 'thing'], dir); + afterInherited = readdirSync(dir); + control = await runTsx([CLI, 'g', 'object', 'customer', '--dry-run'], dir); +}, RUN_TIMEOUT_MS); + +afterAll(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); +}); + +describe('[#19098] `os generate schema` answers the retirement refusal', () => { + it('exits 1, for the documented spelling and for the alias with `-o`', () => { + expect(schema.code, schema.stdout + schema.stderr).toBe(1); + expect(schemaAlias.code, schemaAlias.stdout + schemaAlias.stderr).toBe(1); + }); + + it('writes nothing: neither the default `objectstack.schema.json` nor the `-o` target', () => { + expect(afterSchemaRuns).toEqual([]); + }); + + it('is the retirement, not a missing name and not an unknown type', () => { + for (const run of [schema, schemaAlias]) { + expect(run.stdout).toContain('`os g schema` was retired'); + expect(run.stdout).not.toContain('Missing required argument'); + expect(run.stdout).not.toContain('Unknown type:'); + } + }); + + it('names the ruling, `os validate` and the per-type schemas `@objectstack/spec` publishes', () => { + expect(schema.stdout).toContain('maintainer ruling'); + expect(schema.stdout).toContain('os validate'); + expect(schema.stdout).toContain('@objectstack/spec/json-schema/'); + }); +}); + +describe('[#19098] the ledger is read by own key only', () => { + it('`os g constructor thing` is not taken for a retired type, and does not crash', () => { + expect(inherited.code, inherited.stdout + inherited.stderr).toBe(1); + expect(inherited.stdout).not.toContain('was retired'); + expect(`${inherited.stdout}\n${inherited.stderr}`).not.toContain('TypeError'); + }); + + it('writes nothing', () => { + expect(afterInherited).toEqual([]); + }); +}); + +describe('[#19098] CONTROL — the door is not a command that refuses everything', () => { + it('`os g object customer --dry-run` exits 0 and previews what the object template emits', () => { + expect(control.code, control.stdout + control.stderr).toBe(0); + expect(control.stdout).toContain('Dry run'); + expect(objectTemplate).toBeDefined(); + // The preview prints the template's output, each line indented two spaces; + // this directory has no config, so the template is called with no namespace. + const preview = objectTemplate!.generate('customer').split('\n').map((l) => ` ${l}`).join('\n'); + expect(control.stdout).toContain(preview); + }); +}); From a0cec66e9b3f615b78ab685a0756842cf86b1f8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:38:06 +0000 Subject: [PATCH 5/5] test(cli): the agent-retirement pin's live-generator control asserts the ObjectSchema.create shape (#20270) `generate-agent-retired.e2e.test.ts` asserted the pre-#20195 object template line `import * as Data from '@objectstack/spec/data'`, which the template stopped emitting in 0bd11261e, so the nightly e2e tier was red on main. The control now asserts the template's current shape: `ObjectSchema` among the named imports from `@objectstack/spec/data`, and the `ObjectSchema.create({` call, not one exact import line. The case is neither skipped nor quarantined. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/cli/test/generate-agent-retired.e2e.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/generate-agent-retired.e2e.test.ts b/packages/cli/test/generate-agent-retired.e2e.test.ts index 005dc87c49c..a6d6558e3d6 100644 --- a/packages/cli/test/generate-agent-retired.e2e.test.ts +++ b/packages/cli/test/generate-agent-retired.e2e.test.ts @@ -147,6 +147,13 @@ describe('[#10359] the generators that were not retired still work', () => { it('`os g object … --dry-run` still previews a typed object file', () => { expect(survivor.code).toBe(0); expect(survivor.stdout).toContain('Dry run'); - expect(survivor.stdout).toContain("import * as Data from '@objectstack/spec/data'"); + // [#20270] The object template declares the object with + // `ObjectSchema.create` since #20195 (0bd11261e). Asserted as that SHAPE — + // `ObjectSchema` among the named imports from `@objectstack/spec/data`, and + // the factory call — never as one exact import line: the copied + // `import * as Data` line this replaced went stale when the template + // changed, and turned this nightly case red on main. + expect(survivor.stdout).toMatch(/import \{[^}]*\bObjectSchema\b[^}]*\} from '@objectstack\/spec\/data';/); + expect(survivor.stdout).toMatch(/const customer = ObjectSchema\.create\(\{/); }); });