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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/sour-moons-smile.md
Original file line number Diff line number Diff line change
@@ -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.
80 changes: 77 additions & 3 deletions packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
printHeader('Generate Schema');

Expand All @@ -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<string, unknown>;
let io: 'output' | 'input' = 'output';
let widenedUnrepresentable = false;
try {
jsonSchema = z.toJSONSchema(ObjectStackDefinitionSchema, {
target: 'draft-2020-12',
}) as Record<string, unknown>;
} catch (outputError) {
if (!isKnownUnsupportedJsonSchema(outputError)) throw outputError;
io = 'input';
try {
jsonSchema = z.toJSONSchema(ObjectStackDefinitionSchema, {
target: 'draft-2020-12',
io: 'input',
}) as Record<string, unknown>;
} catch (inputError) {
if (!isKnownUnsupportedJsonSchema(inputError)) throw inputError;
widenedUnrepresentable = true;
jsonSchema = z.toJSONSchema(ObjectStackDefinitionSchema, {
target: 'draft-2020-12',
io: 'input',
unrepresentable: 'any',
}) as Record<string, unknown>;
}
}

// 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 = {
Expand Down
256 changes: 256 additions & 0 deletions packages/cli/test/generate-schema-writes-json-schema.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
// 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<Run> {
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<string, unknown>;

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([]);
});
});
Loading