Skip to content

Commit a2dda36

Browse files
committed
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 0bd1126. 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 <noreply@anthropic.com>
1 parent 9f702ee commit a2dda36

3 files changed

Lines changed: 195 additions & 1 deletion

File tree

‎content/docs/deployment/cli.mdx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1461,6 +1461,24 @@ third-party extension primitive, authored as `src/skills/<name>.skill.ts` with
14611461
`os g skill <name>`, which writes exactly that path.
14621462
</Callout>
14631463
1464+
<Callout type="warn" title="`os generate schema` is retired">
1465+
There is no `schema` type. Running `os generate schema` (or `os g schema`) fails
1466+
and writes nothing, not even `objectstack.schema.json`. The message says it was
1467+
retired by maintainer ruling and points at [`os validate`](#os-validate) and the
1468+
per-type schemas `@objectstack/spec` publishes, rather than the generic "unknown
1469+
type" listing. No replacement file is generated.
1470+
1471+
The file it wrote described only the shape of a stack. Every rule the platform
1472+
enforces beyond that shape (a non-blank string, a required one-of, a banned key)
1473+
was missing from it, so an editor showed a config as valid that the platform then
1474+
refused. `objectstack.config.ts` needs no JSON Schema: `defineStack` types it in
1475+
your editor. Check a project against the rules that actually run with
1476+
`os validate`. For JSON metadata, point your editor at
1477+
`node_modules/@objectstack/spec/json-schema/<category>/<Type>.json`, which states
1478+
the rules a JSON Schema can express and names the rest under
1479+
`x-dropped-refinements`.
1480+
</Callout>
1481+
14641482
**Options:**
14651483
- `-d, --dir <directory>` — Override target directory
14661484
- `--dry-run` — Preview without writing files

‎packages/cli/test/generate-agent-retired.e2e.test.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
3737
import { tmpdir } from 'node:os';
3838
import { join, resolve } from 'node:path';
3939
import { fileURLToPath } from 'node:url';
40+
import { GENERATOR_SCAFFOLD_TARGETS } from '../src/commands/generate.js';
4041
import { childEnv } from './helpers/serve-process.js';
4142

4243
const HERE = resolve(fileURLToPath(import.meta.url), '..');
@@ -147,6 +148,14 @@ describe('[#10359] the generators that were not retired still work', () => {
147148
it('`os g object … --dry-run` still previews a typed object file', () => {
148149
expect(survivor.code).toBe(0);
149150
expect(survivor.stdout).toContain('Dry run');
150-
expect(survivor.stdout).toContain("import * as Data from '@objectstack/spec/data'");
151+
// Anchored to the object template itself, not to a copied line of it: the
152+
// preview prints the template's output, each line indented two spaces, and
153+
// this directory has no config, so no namespace. A copied import line went
154+
// stale once already, when the template moved from a namespace import to
155+
// `ObjectSchema.create`; this assertion moves with the template instead.
156+
const objectTemplate = GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === 'object');
157+
expect(objectTemplate).toBeDefined();
158+
const preview = objectTemplate!.generate('customer').split('\n').map((l) => ` ${l}`).join('\n');
159+
expect(survivor.stdout).toContain(preview);
151160
});
152161
});
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PIN (#19098) — the retired-generator door of `os generate`, in the run that
5+
* gates the merge queue.
6+
*
7+
* `os generate schema` was retired by maintainer ruling (comment 5856790152 on
8+
* #19098, letter C): the JSON Schema it wrote passed configs the platform then
9+
* refused. Retiring it moved the `RETIRED_GENERATORS` lookup to the top of
10+
* `Generate.run`, ahead of the sub-command routing and the `<name>`
11+
* requirement, because `schema` was a routed sub-command that took no name:
12+
* behind either of those, the ledger was never read for it. The lookup is an
13+
* own-key read, so an inherited name such as `constructor` is not taken for a
14+
* retired type (it used to be, and the refusal then crashed on
15+
* `retired.detail`).
16+
*
17+
* Held here, each to exit 1 and bytes on disk:
18+
*
19+
* 1. `os generate schema`, the documented spelling, with no name: the
20+
* retirement refusal, not "Missing required argument" (what the old
21+
* position answered) and not "Unknown type:"; it names the ruling,
22+
* `os validate` and the per-type schemas `@objectstack/spec` publishes;
23+
* and it writes nothing, not even the default `objectstack.schema.json`.
24+
* 2. `os g schema -o FILE`: the same door for the alias with the old flag,
25+
* and the `-o` target stays unwritten.
26+
* 3. `os g constructor NAME`: an own-key miss. It is not answered as
27+
* retired, it does not crash, and it writes nothing.
28+
*
29+
* One control keeps the refusals from being satisfied by a command that
30+
* refuses everything: in the same directory `os g object customer --dry-run`
31+
* exits 0 and previews exactly what the object template emits (anchored to
32+
* the exported template rather than to a copied line of it).
33+
*
34+
* ## Why a child process, and why this file is NOT named `.e2e`
35+
*
36+
* The same reasons `generate-refuses-namespace-prefix.test.ts` gives: an exit
37+
* code plus bytes on disk are the contract, `process.exitCode` inside a
38+
* vitest worker is not an exit status, and `printError` writes to stdout.
39+
* Spawning puts the file in the `integration` project; the name keeps it in
40+
* the per-PR run. `generate-schema-retired.e2e.test.ts` and
41+
* `generate-agent-retired.e2e.test.ts` hold the fuller refusal texts nightly;
42+
* this file is the per-PR guard for the door.
43+
*/
44+
45+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
46+
import { execFile } from 'node:child_process';
47+
import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
48+
import { tmpdir } from 'node:os';
49+
import { join, resolve } from 'node:path';
50+
import { fileURLToPath } from 'node:url';
51+
import { GENERATOR_SCAFFOLD_TARGETS } from '../src/commands/generate.js';
52+
import { childEnv } from './helpers/serve-process.js';
53+
54+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
55+
const CLI = resolve(HERE, '../bin/run-dev.js');
56+
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
57+
58+
/** oclif + tsx cold starts, four of them, sequential. */
59+
const RUN_TIMEOUT_MS = 240_000;
60+
61+
interface Run {
62+
code: number;
63+
stdout: string;
64+
stderr: string;
65+
}
66+
67+
function runTsx(args: string[], cwd: string): Promise<Run> {
68+
return new Promise((resolvePromise) => {
69+
execFile(
70+
TSX,
71+
args,
72+
{ cwd, maxBuffer: 8 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
73+
(err, stdout, stderr) => {
74+
resolvePromise({
75+
// `err.code` is the real exit status; null/undefined means the child
76+
// was signalled — a different failure, never reported as 0.
77+
code: err
78+
? typeof (err as { code?: unknown }).code === 'number'
79+
? (err as unknown as { code: number }).code
80+
: 1
81+
: 0,
82+
stdout: String(stdout),
83+
stderr: String(stderr),
84+
});
85+
},
86+
);
87+
});
88+
}
89+
90+
const objectTemplate = GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === 'object');
91+
92+
let dir: string;
93+
let schema: Run;
94+
let schemaAlias: Run;
95+
let inherited: Run;
96+
let control: Run;
97+
98+
/** The directory as each refusal left it, read before the control runs. */
99+
let afterSchemaRuns: string[];
100+
let afterInherited: string[];
101+
102+
beforeAll(async () => {
103+
dir = mkdtempSync(join(tmpdir(), 'os-g-refuses-retired-'));
104+
105+
// Sequential on purpose: cold tsx starts in a container several agents share.
106+
// Every refusal runs BEFORE the control, so the listings below are what each
107+
// refusal was measured against.
108+
schema = await runTsx([CLI, 'generate', 'schema'], dir);
109+
schemaAlias = await runTsx([CLI, 'g', 'schema', '-o', 'custom.schema.json'], dir);
110+
afterSchemaRuns = readdirSync(dir);
111+
inherited = await runTsx([CLI, 'g', 'constructor', 'thing'], dir);
112+
afterInherited = readdirSync(dir);
113+
control = await runTsx([CLI, 'g', 'object', 'customer', '--dry-run'], dir);
114+
}, RUN_TIMEOUT_MS);
115+
116+
afterAll(() => {
117+
if (dir) rmSync(dir, { recursive: true, force: true });
118+
});
119+
120+
describe('[#19098] `os generate schema` answers the retirement refusal', () => {
121+
it('exits 1, for the documented spelling and for the alias with `-o`', () => {
122+
expect(schema.code, schema.stdout + schema.stderr).toBe(1);
123+
expect(schemaAlias.code, schemaAlias.stdout + schemaAlias.stderr).toBe(1);
124+
});
125+
126+
it('writes nothing: neither the default `objectstack.schema.json` nor the `-o` target', () => {
127+
expect(afterSchemaRuns).toEqual([]);
128+
});
129+
130+
it('is the retirement, not a missing name and not an unknown type', () => {
131+
for (const run of [schema, schemaAlias]) {
132+
expect(run.stdout).toContain('`os g schema` was retired');
133+
expect(run.stdout).not.toContain('Missing required argument');
134+
expect(run.stdout).not.toContain('Unknown type:');
135+
}
136+
});
137+
138+
it('names the ruling, `os validate` and the per-type schemas `@objectstack/spec` publishes', () => {
139+
expect(schema.stdout).toContain('maintainer ruling');
140+
expect(schema.stdout).toContain('os validate');
141+
expect(schema.stdout).toContain('@objectstack/spec/json-schema/');
142+
});
143+
});
144+
145+
describe('[#19098] the ledger is read by own key only', () => {
146+
it('`os g constructor thing` is not taken for a retired type, and does not crash', () => {
147+
expect(inherited.code, inherited.stdout + inherited.stderr).toBe(1);
148+
expect(inherited.stdout).not.toContain('was retired');
149+
expect(`${inherited.stdout}\n${inherited.stderr}`).not.toContain('TypeError');
150+
});
151+
152+
it('writes nothing', () => {
153+
expect(afterInherited).toEqual([]);
154+
});
155+
});
156+
157+
describe('[#19098] CONTROL — the door is not a command that refuses everything', () => {
158+
it('`os g object customer --dry-run` exits 0 and previews what the object template emits', () => {
159+
expect(control.code, control.stdout + control.stderr).toBe(0);
160+
expect(control.stdout).toContain('Dry run');
161+
expect(objectTemplate).toBeDefined();
162+
// The preview prints the template's output, each line indented two spaces;
163+
// this directory has no config, so the template is called with no namespace.
164+
const preview = objectTemplate!.generate('customer').split('\n').map((l) => ` ${l}`).join('\n');
165+
expect(control.stdout).toContain(preview);
166+
});
167+
});

0 commit comments

Comments
 (0)