diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index f149c5e537..f8a03b9788 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -75,6 +75,11 @@ import { authorableDefaultsShardTexts, parseDefaultEntries, } from './lib/authorable-defaults'; +import { + UNEMITTED_BASELINE_FILE, + type UnemittedBaseline, + type UnemittedEntry, +} from './lib/unemitted-schemas'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const PKG = path.resolve(HERE, '..'); @@ -356,6 +361,21 @@ function mountSandbox(dir: string): void { surfaceBasePath = path.join(dir, 'authorable-surface.base.json'); } +/** + * Copy the committed never-published ledger (#16431) into a fixture tree. + * + * `build-schemas.ts` resolves it from its own `__dirname/..`, so any tree that + * copies `scripts/` without it fails the #16431 gate on a MISSING ledger, + * before reaching whatever that fixture is about — which is how all four + * sandbox builders in this file came to need one line each. Copied rather than + * symlinked so a fixture may mutate it without writing to the real file; `src/` + * is the fixture's own, so the population a run observes is the repo's and the + * copied ledger is green without any seeding. + */ +function mountUnemittedLedger(dir: string): void { + fs.cpSync(path.join(PKG, UNEMITTED_BASELINE_FILE), path.join(dir, UNEMITTED_BASELINE_FILE)); +} + /** * Build a sandbox — a temp tree that COPIES `scripts/` (so `__dirname` lands * there) and symlinks the read-only inputs — mount it, and seed it to the state @@ -376,6 +396,7 @@ function createSandbox(prefix: string): string { for (const entry of ['src', 'node_modules', 'package.json']) { fs.symlinkSync(path.join(PKG, entry), path.join(dir, entry)); } + mountUnemittedLedger(dir); mountSandbox(dir); // The authorable-surface ratchet runs after the manifest one; give it the // committed snapshot so a check that gets that far judges the same contract. @@ -555,6 +576,202 @@ describe('build-schemas.ts --check — a check reports, it does not write (#4711 ); }); +// ───────────────────────────────────────────────────────────────────────────── +// #16431 — an export that was NEVER published. +// +// The block above pins the ratchet for a schema that STOPS being emitted. This +// one pins its sibling, and the two are deliberately not the same instrument: +// the disappearance ratchet's baseline is `json-schema.manifest/`, which an +// export that never emitted has never been in, so it has nothing to miss. The +// last case here is that separation, asserted rather than assumed. +// +// The population is the repo's own — `src/` is symlinked into the sandbox — so +// every fixture works by mutating the LEDGER and letting the real build +// adjudicate it. Each case restores the ledger afterwards, because the shared +// sandbox outlives the block. +// ───────────────────────────────────────────────────────────────────────────── + +/** An export this build emits a JSON Schema for — the negative control's subject. */ +const EMITTED_EXPORT = 'Data.QueryFilterSchema'; +/** A ledger entry naming no export at all. */ +const PHANTOM_EXPORT = 'Data.ZzzNeverExportedByAnyBuild'; + +const unemittedPath = (): string => path.join(sandbox, UNEMITTED_BASELINE_FILE); +const readUnemittedBytes = (): string => fs.readFileSync(unemittedPath(), 'utf8'); + +/** Rewrite the sandbox ledger's `entries`, keeping `$comment`; returns the bytes. */ +function seedUnemitted( + mutate: (entries: Record) => Record, +): string { + const doc = JSON.parse(readUnemittedBytes()) as UnemittedBaseline & { $comment?: unknown }; + const text = JSON.stringify({ ...doc, entries: mutate({ ...doc.entries }) }, null, 2) + '\n'; + fs.writeFileSync(unemittedPath(), text); + return text; +} + +describe('build-schemas.ts — an export that never published must be declared (#16431)', () => { + let pristineUnemitted: string; + /** A real member of the committed population, whatever it is called today. */ + let someUnemitted: string; + + beforeAll(() => { + pristineUnemitted = fs.readFileSync(path.join(PKG, UNEMITTED_BASELINE_FILE), 'utf8'); + const entries = (JSON.parse(pristineUnemitted) as UnemittedBaseline).entries; + const keys = Object.keys(entries); + expect(keys.length, `${UNEMITTED_BASELINE_FILE} is empty — it is a committed ledger (#16431)`) + .toBeGreaterThan(0); + someUnemitted = keys[0]; + }); + + // The manifest is seeded per test everywhere in this file rather than by + // `createSandbox`, so a block run in isolation (`-t`) starts with none at all + // — and every case here would then fail on a stale manifest instead of on the + // thing it is testing. Seed before, restore after: the ledger has to go back + // too, because the shared sandbox outlives this block. + beforeEach(() => { + seedManifest((s) => s); + }); + + afterEach(() => { + fs.writeFileSync(unemittedPath(), pristineUnemitted); + seedManifest((s) => s); + }); + + it( + 'refuses GROWTH: an un-emitted export missing from the ledger exits 1, and the ledger is untouched', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // Deleting the line is how a NEW un-emitted export looks to this gate: + // the build observes it, the ledger does not name it. That equivalence is + // what lets the fixture stay inside the sandbox instead of mutating `src/`. + const withoutOne = seedUnemitted((e) => { + delete e[someUnemitted]; + return e; + }); + + const { status, output } = run(['--check']); + + expect(status).toBe(1); + expect(output).toMatch(/exported schema\(s\) emit NO JSON Schema and are not declared/); + expect(output).toContain(`+ ${someUnemitted}`); + // A check reports and never writes — the same discipline as #4711 above. + expect(readUnemittedBytes()).toBe(withoutOne); + }, + ); + + it( + 'refuses a STALE entry: a ledger line whose export emits a JSON Schema exits 1', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + const withStale = seedUnemitted((e) => ({ + ...e, + [EMITTED_EXPORT]: { cause: 'date', reason: 'fixture: this export emits and must not be listed' }, + })); + + const { status, output } = run(['--check']); + + expect(status).toBe(1); + expect(output).toMatch(/ledger entry\(ies\) in .* now EMIT a JSON Schema/); + expect(output).toContain(`- ${EMITTED_EXPORT}`); + expect(readUnemittedBytes()).toBe(withStale); + }, + ); + + it( + 'refuses a ledger line that names no export at all', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + seedUnemitted((e) => ({ + ...e, + [PHANTOM_EXPORT]: { cause: 'function', reason: 'fixture: no such export' }, + })); + + const { status, output } = run(['--check']); + + expect(status).toBe(1); + expect(output).toMatch(/ledger entry\(ies\) in .* name no exported schema/); + expect(output).toContain(`- ${PHANTOM_EXPORT}`); + }, + ); + + it( + 'refuses a recorded cause this build does not observe', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // The reason prose is written ABOUT the cause. Left unchecked, an entry + // explaining a `z.date()` would keep reading as current after the date + // became a function — a repair nobody made, recorded as one. + seedUnemitted((e) => ({ + ...e, + [someUnemitted]: { ...e[someUnemitted], cause: 'map' }, + })); + + const { status, output } = run(['--check']); + + expect(status).toBe(1); + expect(output).toMatch(/ledger entry\(ies\) record a cause this build does not observe/); + expect(output).toContain(`~ ${someUnemitted}: recorded "map"`); + }, + ); + + it( + 'refuses an entry whose reason is empty — a count is not a ledger', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + seedUnemitted((e) => ({ + ...e, + [someUnemitted]: { ...e[someUnemitted], reason: ' ' }, + })); + + const { status, output } = run(['--check']); + + expect(status).toBe(1); + expect(output).toMatch(/ledger entry\(ies\) carry an empty `reason`/); + expect(output).toContain(`- ${someUnemitted}`); + }, + ); + + it( + 'does not replace the disappearance ratchet: a schema that STOPS being emitted is still its case', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // The two ratchets guard opposite directions and must not trade domains. + // A manifest key no build emits is #2978's case, and its remedy — delete + // the key and declare it in RETIRED_DEFS_BY_MAJOR — is the right one; the + // #16431 ledger's "declare it here" would be wrong advice for a schema + // that was published yesterday. So that red must still be the one that + // speaks, with the ledger untouched and unmentioned. + seedManifest((s) => [...s, PHANTOM_KEY].sort()); + + const { status, output } = run(['--check']); + + expect(status).toBe(1); + expect(output).toMatch(/1 previously published schema\(s\) disappeared from this build/); + expect(output).toContain(`- json-schema/${PHANTOM_KEY}.json`); + expect(output).not.toMatch(/emit NO JSON Schema and are not declared/); + }, + ); + + it( + 'is green on the committed ledger, and names the population it accepted', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // Negative control, twice over. Without it, "always exit 1" would satisfy + // every assertion above; and because the sandbox reads the repo's own + // `src/`, a pass here re-proves that the COMMITTED ledger describes this + // tree — the same thing `check:authorable-surface` asserts in CI. + const current = seedUnemitted((e) => e); + + const { status, output } = run(['--check']); + + expect(status).toBe(0); + expect(output).toMatch(/exported schema\(s\) emit no JSON Schema — all declared in/); + expect(output).toContain(someUnemitted); + expect(readUnemittedBytes()).toBe(current); + }, + ); +}); + // ───────────────────────────────────────────────────────────────────────────── // #4650 — a deleted authorable-surface line must prove itself. // @@ -2482,6 +2699,7 @@ describe('build-schemas.ts — check (b) matches the exact retired key, not its for (const entry of ['node_modules', 'package.json']) { fs.symlinkSync(path.join(PKG, entry), path.join(box, entry)); } + mountUnemittedLedger(box); writeManifestShards(path.join(box, SCHEMA_MANIFEST_DIR_NAME), pristine); boxSurfaceDir = path.join(box, AUTHORABLE_SURFACE_DIR_NAME); writeSurfaceShards(boxSurfaceDir, pristineSurface); @@ -2780,6 +2998,7 @@ describe('build-schemas.ts — a deleted manifest key must prove itself (#4725)' for (const entry of ['node_modules', 'package.json']) { fs.symlinkSync(path.join(PKG, entry), path.join(box, entry)); } + mountUnemittedLedger(box); boxScript = path.join(box, 'scripts', 'build-schemas.ts'); boxManifestDir = path.join(box, SCHEMA_MANIFEST_DIR_NAME); boxSurfaceDir = path.join(box, AUTHORABLE_SURFACE_DIR_NAME); @@ -3145,6 +3364,7 @@ describe('build-schemas.ts — check (c) dates a tombstone by its exact key (#58 for (const entry of ['node_modules', 'package.json']) { fs.symlinkSync(path.join(PKG, entry), path.join(box, entry)); } + mountUnemittedLedger(box); writeManifestShards(path.join(box, SCHEMA_MANIFEST_DIR_NAME), pristine); boxSurfaceDir = path.join(box, AUTHORABLE_SURFACE_DIR_NAME); writeSurfaceShards(boxSurfaceDir, pristineSurface); diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index e5fcfaff75..24c2023ec3 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -22,6 +22,20 @@ import { RENAMED_DEFS, carryAuthorableKey, checkRenameTable } from './lib/rename // at #5317 so the pipe-direction rule (#4488) is assertable without running the // whole generator — see scripts/zod-graph.test.ts. import { zodChildSchemas, zodShapeOf } from './lib/zod-graph'; +// The never-published ratchet (#16431). Sibling of the disappearance ratchet +// below, and deliberately a different domain: that one guards "was published, +// stopped being published", this one guards "never was published". Its module +// header is the authority on why one cannot see the other. +import { + UNEMITTED_BASELINE_FILE, + checkUnemittedSchemas, + countByCause, + causeOf, + hasUnemittedProblems, + ledgerKey, + readUnemittedBaseline, + type UnemittedSkip, +} from './lib/unemitted-schemas'; // Who owns what under json-schema/. This generator shares that directory with // gen:openapi, and used to clear it by deleting the directory itself (#5371). import { @@ -350,6 +364,16 @@ const zodByDefKey = new Map(); // in it the loser is already gone — the record has to be kept alongside (#5832). const emittedDefs: EmittedDef[] = []; +// Every export this build saw as a `z.ZodType`, emitted or not, and every one +// it could not project — the two inputs the never-published ratchet (#16431) +// adjudicates against its committed ledger near the end of this file. Collected +// here rather than re-derived later because this loop is the only place that +// sees an export the generator produced NOTHING for: `generatedSchemas`, +// `zodByDefKey` and `emittedDefs` are all keyed by a def key such an export +// never gets, which is precisely why nothing downstream could ever count them. +const exportedZodKeys = new Set(); +const unemittedSkips: UnemittedSkip[] = []; + // Error messages for schema types that inherently cannot be represented in JSON Schema. // These are expected warnings, not build-breaking errors. const KNOWN_UNSUPPORTED_PATTERNS = [ @@ -384,6 +408,7 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) { if (value instanceof z.ZodType) { // Suffix-only strip — shared with build-docs.ts; see lib/schema-name.ts (#4592). const schemaName = schemaNameFromExportKey(key); + exportedZodKeys.add(`${namespaceName}.${key}`); try { // Convert to JSON Schema using Zod v4's built-in toJSONSchema(). @@ -440,6 +465,10 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) { const msg = error instanceof Error ? error.message : String(error); console.warn(` ⊘ ${namespaceName}.${key}: ${msg} (skipped)`); skippedCount++; + // The population the #16431 ratchet holds closed. This line used to + // be the ONLY record that an export reached no published surface, + // and it is a warn in a build that exits 0. + unemittedSkips.push({ namespace: namespaceName, exportKey: key, message: msg }); } else { console.error(` ✗ Failed to generate schema for ${namespaceName}.${key}:`, error); errorCount++; @@ -2589,6 +2618,151 @@ if (defaultsChanged && !CHECK) { ); } +// ─── Ratchet: an export that was NEVER published (#16431) ──────────── +// +// The disappearance ratchet above guards one direction — a def key the manifest +// records that this build no longer emits. It cannot see the other: an export +// that produced no JSON Schema on its FIRST build never entered the manifest, +// so there is no baseline entry for it to go missing from. Until this block, +// the whole record of such an export was the `⊘ … (skipped)` warn printed +// during the loop, in a build that exits 0 — which made "this contract is +// deliberately not a published JSON-Schema surface" and "this contract's +// `.describe()` prose reaches no reference page at all" the same colour on +// every instrument this repo had. +// +// Measured on the tree that landed this: 23 exports, across six namespaces, +// four distinct causes. So the criterion here is structural — an exported +// `z.ZodType` with no emitted JSON Schema, whatever the reason — never a test +// for one unrepresentable type. Growth is refused; the recorded population is +// printed in full on every run, the same way an authorised default change is +// (#4666), because a population that passes in silence is the failure #4690 +// names. +// +// This runs LAST of the ratchets, deliberately. Every red the file could +// already produce keeps its exact wording and precedence — an export that +// STOPS emitting is adjudicated above, by the ratchet whose remedy +// (RETIRED_DEFS_BY_MAJOR + the manifest deletion) is the right one for it, and +// never by this block's "declare it in the ledger", which would be wrong advice +// for a schema that was published yesterday. +const unemittedBaseline = readUnemittedBaseline(PKG_DIR); +if (!unemittedBaseline) { + console.error(`\n❌ ${UNEMITTED_BASELINE_FILE} is missing — it is a committed, hand-edited ledger (#16431).`); + console.error( + `\n ${unemittedSkips.length} export(s) in this build produce no JSON Schema. Without the ledger\n` + + ` there is nothing to hold that population closed, and the 24th member arrives as a\n` + + ` \`console.warn\` in a build that exits 0 — the exact state #16431 measured. Restore\n` + + ` packages/spec/${UNEMITTED_BASELINE_FILE} from git rather than regenerating it: it has\n` + + ` no generator on purpose (see scripts/lib/unemitted-schemas.ts).`, + ); + process.exit(1); +} + +const unemittedProblems = checkUnemittedSchemas({ + skips: unemittedSkips, + exportedZodKeys, + baseline: unemittedBaseline, +}); + +if (hasUnemittedProblems(unemittedProblems)) { + const { undeclared, repaired, vanished, miscaused, unreasoned } = unemittedProblems; + + if (undeclared.length > 0) { + console.error( + `\n❌ ${undeclared.length} exported schema(s) emit NO JSON Schema and are not declared in ${UNEMITTED_BASELINE_FILE}:`, + ); + for (const skip of undeclared) { + console.error(` + ${ledgerKey(skip)} (${causeOf(skip.message)}) — ${skip.message}`); + } + console.error( + `\n Such an export publishes nothing: no file under json-schema/, no entry in\n` + + ` json-schema.manifest/, and — because content/docs/references/** renders from that\n` + + ` directory — no reference section. Its \`.describe()\` prose reaches no reader, and\n` + + ` the disappearance ratchet can never report it later, because it was never in the\n` + + ` baseline (#2978, #16431).\n\n` + + ` Preferred remedy: make it emit — narrow the unrepresentable member, or move the\n` + + ` non-serialisable part out of the exported schema.\n\n` + + ` If the export genuinely does not belong on a published JSON-Schema surface (a React\n` + + ` props contract, a driver interface of \`z.function()\` members), admit it DELIBERATELY\n` + + ` by adding it to packages/spec/${UNEMITTED_BASELINE_FILE}:\n\n` + + undeclared + .map( + (skip) => + ` "${ledgerKey(skip)}": {\n` + + ` "cause": "${causeOf(skip.message)}",\n` + + ` "reason": "…what this export is, why no reader loses anything by its absence from content/docs/references/**…"\n` + + ` },\n`, + ) + .join('') + + `\n \`reason\` is required and is printed by every build that accepts the population, so\n` + + ` write it for the author who goes looking for this schema's reference page.`, + ); + } + + if (repaired.length > 0) { + console.error(`\n❌ ${repaired.length} ledger entry(ies) in ${UNEMITTED_BASELINE_FILE} now EMIT a JSON Schema:`); + for (const key of repaired) console.error(` - ${key}`); + console.error( + `\n Good news, and the line has to go with it — in this same PR. A ledger that keeps an\n` + + ` entry after its export was repaired has stopped describing the tree and started\n` + + ` covering for it: the next un-emitted export can then arrive under a name that is\n` + + ` already spoken for, and nobody can see which member was replaced.`, + ); + } + + if (vanished.length > 0) { + console.error(`\n❌ ${vanished.length} ledger entry(ies) in ${UNEMITTED_BASELINE_FILE} name no exported schema:`); + for (const key of vanished) console.error(` - ${key}`); + console.error( + `\n The export was removed or renamed. Delete the line (a rename gets a new line under\n` + + ` the new name, carrying the same reason), so the ledger keeps naming exactly the\n` + + ` population this build measures.`, + ); + } + + if (miscaused.length > 0) { + console.error(`\n❌ ${miscaused.length} ledger entry(ies) record a cause this build does not observe:`); + for (const m of miscaused) { + console.error(` ~ ${m.key}: recorded "${m.recorded}", this build sees "${m.observed}" — ${m.message}`); + } + console.error( + `\n The recorded \`cause\` is re-checked on every run for the same reason a declared\n` + + ` default change re-checks both its endpoints (#4666): a \`reason\` written about a\n` + + ` \`z.date()\` that is now a \`z.function()\` describes a repair that never happened, and\n` + + ` would keep reading as current forever. Re-read the entry and rewrite BOTH fields —\n` + + ` or, if a Zod upgrade re-worded the message, extend CAUSE_PATTERNS in\n` + + ` scripts/lib/unemitted-schemas.ts so the family survives the rewording.`, + ); + } + + if (unreasoned.length > 0) { + console.error(`\n❌ ${unreasoned.length} ledger entry(ies) carry an empty \`reason\`:`); + for (const key of unreasoned) console.error(` - ${key}`); + console.error( + `\n A baseline that records only that a member EXISTS is a count wearing a ledger's\n` + + ` shape. The reason is the whole instrument: it is what tells the next reader whether\n` + + ` this export is fine unpublished or is a reference page somebody is still missing.`, + ); + } + + process.exit(1); +} + +// The accepted population, printed in full on every run — see the #4666 block +// above for the same discipline. This is the report #16431 exists to produce: +// before it, the only way to learn the size of this population was to read +// 1600 lines of build output looking for `⊘`. +if (unemittedSkips.length > 0) { + const byCause = [...countByCause(unemittedSkips)].map(([cause, n]) => `${n} ${cause}`).join(', '); + console.log( + `\n🕳️ ${unemittedSkips.length} exported schema(s) emit no JSON Schema — all declared in ` + + `${UNEMITTED_BASELINE_FILE} (${byCause}) (#16431):`, + ); + for (const skip of unemittedSkips) { + console.log(` ${ledgerKey(skip)} (${causeOf(skip.message)})`); + console.log(` ${unemittedBaseline.entries[ledgerKey(skip)].reason}`); + } +} + // ─── Generate Bundled Schema ───────────────────────────────────────── // Single-file bundled schema containing all generated schemas for IDE autocomplete diff --git a/packages/spec/scripts/lib/unemitted-schemas.ts b/packages/spec/scripts/lib/unemitted-schemas.ts new file mode 100644 index 0000000000..f7fa5dcdf8 --- /dev/null +++ b/packages/spec/scripts/lib/unemitted-schemas.ts @@ -0,0 +1,253 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The never-published ratchet (#16431) — an exported `z.ZodType` that produces + * NO JSON Schema, held to a declared population instead of a `console.warn`. + * + * ## The blind spot this closes, stated exactly + * + * `build-schemas.ts` already runs a disappearance ratchet (#2978 / #4725): a + * def key recorded in `json-schema.manifest/` that this build does not emit + * fails the build. That ratchet's domain is **"was published, stopped being + * published"**. It is structurally blind to **"never was published"** — a + * schema absent from the manifest was never in its baseline, so there is + * nothing for it to miss. + * + * The skip that produces such an export is a `console.warn` in a build that + * exits 0. So on every instrument this repo owned, two very different states + * were the same colour: + * + * - an export whose contract genuinely does not belong on a published + * JSON-Schema surface (a React props contract, a driver interface made of + * `z.function()` members), and + * - an export whose prose, constraints and `.describe()` text an author is + * expected to read on `content/docs/references/**` and which silently + * reaches no page at all. + * + * #16431 measured the population for the first time: **23 exports**, across + * `Automation` / `Cloud` / `Data` / `Kernel` / `System` / `UI`, in one build. + * The card that found it saw four of them. Nobody was tracking the other 19, + * and nothing would have reported the 24th. + * + * ## What this module is, and what it deliberately is NOT + * + * It is a **visibility ratchet**: it reports what is already true and refuses + * GROWTH of the population. It changes no schema, changes nothing about the + * generator's projection ability, and makes nothing start or stop publishing — + * the baseline is anchored to the tree as it stands, so it is green the moment + * it lands. + * + * It is **not** a criterion about `z.date()`, or about any one unrepresentable + * type. The population has at least four distinct causes today (`function`, + * `date`, `custom`, `undefined`), and a criterion written against one of them + * would have been blind to the other three the same way the disappearance + * ratchet is blind to this whole class. + * + * ## Shrink-only in BOTH directions + * + * Same discipline as `entry-nameability.baseline.json` and + * `dual-source-exports.baseline.json`, and for the same reason: + * + * - an export that is not emitted and NOT recorded fails the build — growth + * has to be a reviewed line in a diff, never a silent warn; + * - an entry that no longer describes the tree ALSO fails, with an + * instruction to delete it. A ledger that keeps entries after the export + * starts emitting (or stops existing) has stopped describing the tree and + * started covering for it — and a stale line is exactly the room the 24th + * member needs to arrive looking like the 23rd. + * + * The recorded `cause` is re-checked against what the build observes for the + * same reason `DEFAULT_CHANGES_BY_MAJOR` re-checks both endpoints of a declared + * default change (#4666): a reason written about a `z.date()` that is now a + * `z.function()` describes a repair that never happened, and it would keep + * reading as current forever. + * + * ## Why the ledger is HAND-EDITED and has no `gen:` script + * + * Identical to the reasoning recorded in `entry-nameability.baseline.json`: a + * generator for this file would let a new un-emitted export be admitted by + * running a command instead of by a decision — which is the whole failure mode + * being closed. Every entry carries a `reason` in prose, and the gate requires + * it to be non-empty, because a baseline that records only a COUNT lets the + * 24th member slip in behind a repaired 23rd with nobody able to see which one + * was replaced. + */ +import fs from 'fs'; +import path from 'path'; + +/** File name of the committed ledger, resolved against the package root. */ +export const UNEMITTED_BASELINE_FILE = 'unemitted-schemas.baseline.json'; + +/** + * The unrepresentable-type families Zod's `toJSONSchema()` throws for, keyed by + * the family name this ledger records. + * + * Derived from `zod/v4/core/json-schema-processors` (zod 4.4.3), which is the + * only producer of the `… cannot be represented in JSON Schema` messages + * `build-schemas.ts` classifies as a known skip. Matched on the distinctive + * word rather than the whole sentence, so a re-worded message keeps its family. + * Order matters: `BigInt literals` must reach `bigint` before any later + * pattern, and ``Literal `undefined` `` must reach `undefined`. + */ +const CAUSE_PATTERNS: ReadonlyArray = [ + [/\bbigint\b/i, 'bigint'], + [/\bsymbols?\b/i, 'symbol'], + [/\bundefined\b/i, 'undefined'], + [/\bvoid\b/i, 'void'], + [/\bdate\b/i, 'date'], + [/\bnan\b/i, 'nan'], + [/\bcustom\b/i, 'custom'], + [/\bfunctions?\b/i, 'function'], + [/\btransforms?\b/i, 'transform'], + [/\bmap\b/i, 'map'], + [/\bset\b/i, 'set'], +]; + +/** The families above, plus `other` for a message none of them classifies. */ +export type UnemittedCause = + | 'bigint' + | 'custom' + | 'date' + | 'function' + | 'map' + | 'nan' + | 'other' + | 'set' + | 'symbol' + | 'transform' + | 'undefined' + | 'void'; + +/** One export this build could not project, as observed by the generator. */ +export interface UnemittedSkip { + /** The protocol namespace, e.g. `Data`. */ + readonly namespace: string; + /** The export name inside it, e.g. `ComparisonOperatorSchema`. */ + readonly exportKey: string; + /** The message `z.toJSONSchema()` threw, verbatim. */ + readonly message: string; +} + +/** One recorded member of the accepted population. */ +export interface UnemittedEntry { + /** The unrepresentable family, re-checked against this build. */ + readonly cause: UnemittedCause; + /** Why this export has no published JSON Schema. Required, never empty. */ + readonly reason: string; +} + +/** The committed ledger's shape. */ +export interface UnemittedBaseline { + readonly entries: Readonly>; +} + +/** `Namespace.ExportKey` — the unit this ledger is keyed by. */ +export function ledgerKey(skip: Pick): string { + return `${skip.namespace}.${skip.exportKey}`; +} + +/** + * Classify a skip message into its unrepresentable family. + * + * An unclassified message is `other` rather than a throw: a Zod upgrade that + * re-words a message must fail as a LEDGER mismatch naming the raw text, not as + * a crash inside the classifier. + */ +export function causeOf(message: string): UnemittedCause { + for (const [pattern, cause] of CAUSE_PATTERNS) { + if (pattern.test(message)) return cause; + } + return 'other'; +} + +/** Everything the gate found wrong with the ledger, in one pass. */ +export interface UnemittedProblems { + /** Not emitted, not recorded — the growth this ratchet refuses. */ + readonly undeclared: readonly UnemittedSkip[]; + /** Recorded, but the export emits a JSON Schema now. Delete the line. */ + readonly repaired: readonly string[]; + /** Recorded, but no such export exists any more. Delete the line. */ + readonly vanished: readonly string[]; + /** Recorded with a `cause` this build does not observe. */ + readonly miscaused: ReadonlyArray<{ key: string; recorded: UnemittedCause; observed: UnemittedCause; message: string }>; + /** Recorded with an empty `reason` — a count pretending to be a ledger. */ + readonly unreasoned: readonly string[]; +} + +/** True when nothing above needs saying. */ +export function hasUnemittedProblems(p: UnemittedProblems): boolean { + return ( + p.undeclared.length > 0 || + p.repaired.length > 0 || + p.vanished.length > 0 || + p.miscaused.length > 0 || + p.unreasoned.length > 0 + ); +} + +/** + * Adjudicate one build's observed population against the committed ledger. + * + * `exportedZodKeys` is every `Namespace.ExportKey` this build saw as a + * `z.ZodType`, emitted or not — it is what separates "this entry was repaired" + * from "this export no longer exists", two states whose remedy is the same line + * deletion but whose PR description is not. + */ +export function checkUnemittedSchemas(args: { + readonly skips: readonly UnemittedSkip[]; + readonly exportedZodKeys: ReadonlySet; + readonly baseline: UnemittedBaseline; +}): UnemittedProblems { + const { skips, exportedZodKeys, baseline } = args; + const observed = new Map(skips.map((s) => [ledgerKey(s), s])); + + const undeclared = skips.filter((s) => !(ledgerKey(s) in baseline.entries)); + const repaired: string[] = []; + const vanished: string[] = []; + const miscaused: Array<{ key: string; recorded: UnemittedCause; observed: UnemittedCause; message: string }> = []; + const unreasoned: string[] = []; + + for (const [key, entry] of Object.entries(baseline.entries)) { + if (entry.reason.trim() === '') unreasoned.push(key); + const skip = observed.get(key); + if (skip) { + const seen = causeOf(skip.message); + if (seen !== entry.cause) { + miscaused.push({ key, recorded: entry.cause, observed: seen, message: skip.message }); + } + continue; + } + if (exportedZodKeys.has(key)) repaired.push(key); + else vanished.push(key); + } + + return { undeclared, repaired, vanished, miscaused, unreasoned }; +} + +/** Read the committed ledger, or `null` when the file is absent. */ +export function readUnemittedBaseline(pkgDir: string): UnemittedBaseline | null { + const file = path.join(pkgDir, UNEMITTED_BASELINE_FILE); + if (!fs.existsSync(file)) return null; + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as { entries?: unknown }; + const entries = parsed.entries; + if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) { + throw new Error(`${UNEMITTED_BASELINE_FILE}: "entries" must be an object of key -> { cause, reason }`); + } + for (const [key, value] of Object.entries(entries as Record)) { + const entry = value as Partial; + if (typeof entry?.cause !== 'string' || typeof entry?.reason !== 'string') { + throw new Error(`${UNEMITTED_BASELINE_FILE}: entry "${key}" needs a string \`cause\` and a string \`reason\``); + } + } + return { entries: entries as Readonly> }; +} + +/** Group a population by cause, for the accepted-population report. */ +export function countByCause(skips: readonly UnemittedSkip[]): Map { + const counts = new Map(); + for (const skip of skips) { + const cause = causeOf(skip.message); + counts.set(cause, (counts.get(cause) ?? 0) + 1); + } + return new Map([...counts].sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))); +} diff --git a/packages/spec/scripts/unemitted-schemas.test.ts b/packages/spec/scripts/unemitted-schemas.test.ts new file mode 100644 index 0000000000..00b6a58db8 --- /dev/null +++ b/packages/spec/scripts/unemitted-schemas.test.ts @@ -0,0 +1,240 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pins the never-published ratchet's adjudication and its cause classifier + * (#16431), separately from the generator that feeds them. + * + * `build-schemas.ts` is a top-level script with side effects, so this logic is + * extracted for the same reason `def-key-collisions` (#5832) and `zod-graph` + * (#5317) were: the only other way to assert on it is to run the whole + * generator, and the end-to-end cases that do (in + * `build-schemas-check-mode.test.ts`) cost a spawn each. + * + * The properties that matter, and why each one is a test rather than a comment: + * + * 1. GROWTH IS REFUSED — an un-emitted export absent from the ledger is the + * one thing this ratchet exists for. Everything else here defends that + * signal against a ledger that has stopped describing the tree. + * 2. SHRINK-ONLY IN BOTH DIRECTIONS — an entry whose export now emits, and an + * entry naming no export at all, are separate reports because their PR + * descriptions differ ("we fixed it" vs "it was renamed"), even though the + * remedy is the same deleted line. + * 3. THE CAUSE IS RE-CHECKED — the `reason` prose is written about the cause, + * so an entry whose cause moved is prose describing a repair nobody made. + * 4. A REASON IS REQUIRED — a ledger that records only that a member EXISTS is + * a count wearing a ledger's shape, and cannot show which member a new + * arrival replaced. + * + * Plus the classifier's own boundary: an unrecognised message must degrade to + * `other`, never throw. A Zod upgrade that re-words a message has to surface as + * a ledger mismatch naming the raw text — a crash inside the classifier would + * take the whole generator down for a wording change. + */ +import { describe, expect, it } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { + UNEMITTED_BASELINE_FILE, + causeOf, + checkUnemittedSchemas, + countByCause, + hasUnemittedProblems, + ledgerKey, + readUnemittedBaseline, + type UnemittedBaseline, + type UnemittedSkip, +} from './lib/unemitted-schemas'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PKG = path.resolve(HERE, '..'); + +const FUNCTION_MSG = 'Function types cannot be represented in JSON Schema'; +const DATE_MSG = 'Date cannot be represented in JSON Schema'; + +const skip = (namespace: string, exportKey: string, message: string): UnemittedSkip => ({ + namespace, + exportKey, + message, +}); + +const ledger = (entries: UnemittedBaseline['entries']): UnemittedBaseline => ({ entries }); + +const check = (args: { + skips: readonly UnemittedSkip[]; + exported: readonly string[]; + baseline: UnemittedBaseline; +}) => + checkUnemittedSchemas({ + skips: args.skips, + exportedZodKeys: new Set(args.exported), + baseline: args.baseline, + }); + +describe('causeOf — the unrepresentable family, by distinctive word', () => { + it.each([ + [FUNCTION_MSG, 'function'], + [DATE_MSG, 'date'], + ['Custom types cannot be represented in JSON Schema', 'custom'], + ['Undefined cannot be represented in JSON Schema', 'undefined'], + ['Literal `undefined` cannot be represented in JSON Schema', 'undefined'], + ['BigInt cannot be represented in JSON Schema', 'bigint'], + ['BigInt literals cannot be represented in JSON Schema', 'bigint'], + ['Symbols cannot be represented in JSON Schema', 'symbol'], + ['Void cannot be represented in JSON Schema', 'void'], + ['NaN cannot be represented in JSON Schema', 'nan'], + ['Transforms cannot be represented in JSON Schema', 'transform'], + ['Map cannot be represented in JSON Schema', 'map'], + ['Set cannot be represented in JSON Schema', 'set'], + ])('classifies %j as %s', (message, expected) => { + expect(causeOf(message)).toBe(expected); + }); + + it('degrades an unrecognised message to `other` instead of throwing', () => { + // A Zod upgrade that re-words a message must fail as a LEDGER mismatch + // naming the raw text, never as a crash inside the classifier. + expect(causeOf('Quaternions cannot be represented in JSON Schema')).toBe('other'); + }); +}); + +describe('checkUnemittedSchemas — growth is refused, and the ledger must keep describing the tree', () => { + it('reports an un-emitted export the ledger does not name — the growth this ratchet exists for', () => { + const problems = check({ + skips: [skip('Data', 'NewThingSchema', DATE_MSG)], + exported: ['Data.NewThingSchema', 'Data.EmittedSchema'], + baseline: ledger({}), + }); + + expect(problems.undeclared.map(ledgerKey)).toEqual(['Data.NewThingSchema']); + expect(hasUnemittedProblems(problems)).toBe(true); + }); + + it('is silent when the ledger names exactly the population, with matching causes', () => { + // The negative control. Without it, "always report something" would satisfy + // every other case in this file. + const problems = check({ + skips: [skip('Data', 'KnownSchema', FUNCTION_MSG)], + exported: ['Data.KnownSchema', 'Data.EmittedSchema'], + baseline: ledger({ 'Data.KnownSchema': { cause: 'function', reason: 'a code interface' } }), + }); + + expect(hasUnemittedProblems(problems)).toBe(false); + expect(problems).toEqual({ + undeclared: [], + repaired: [], + vanished: [], + miscaused: [], + unreasoned: [], + }); + }); + + it('separates a REPAIRED entry from a VANISHED one — same remedy, different PR', () => { + const problems = check({ + skips: [], + // `Data.FixedSchema` still exists and now emits; `Data.GoneSchema` is not + // an export any more. + exported: ['Data.FixedSchema'], + baseline: ledger({ + 'Data.FixedSchema': { cause: 'date', reason: 'was unprojectable' }, + 'Data.GoneSchema': { cause: 'function', reason: 'was a code interface' }, + }), + }); + + expect(problems.repaired).toEqual(['Data.FixedSchema']); + expect(problems.vanished).toEqual(['Data.GoneSchema']); + }); + + it('reports an entry whose recorded cause this build does not observe', () => { + const problems = check({ + skips: [skip('Data', 'MovedSchema', FUNCTION_MSG)], + exported: ['Data.MovedSchema'], + baseline: ledger({ 'Data.MovedSchema': { cause: 'date', reason: 'the date comparand' } }), + }); + + expect(problems.miscaused).toEqual([ + { key: 'Data.MovedSchema', recorded: 'date', observed: 'function', message: FUNCTION_MSG }, + ]); + // And it is NOT reported as undeclared: the entry exists, it is just wrong. + expect(problems.undeclared).toEqual([]); + }); + + it('reports an empty reason, whitespace included', () => { + const problems = check({ + skips: [skip('Data', 'BlankSchema', DATE_MSG)], + exported: ['Data.BlankSchema'], + baseline: ledger({ 'Data.BlankSchema': { cause: 'date', reason: ' ' } }), + }); + + expect(problems.unreasoned).toEqual(['Data.BlankSchema']); + }); + + it('keys by EXPORT, not by schema name — an alias pair is two members', () => { + // `System.BatchTask` and `System.BatchTaskSchema` are one Zod object reached + // by two export names, and NEITHER reaches a published surface. The unit is + // the export, because that is what an author or a `gen:docs` run looks up. + const problems = check({ + skips: [skip('System', 'Thing', FUNCTION_MSG), skip('System', 'ThingSchema', FUNCTION_MSG)], + exported: ['System.Thing', 'System.ThingSchema'], + baseline: ledger({ 'System.Thing': { cause: 'function', reason: 'alias' } }), + }); + + expect(problems.undeclared.map(ledgerKey)).toEqual(['System.ThingSchema']); + }); +}); + +describe('countByCause — the population report groups by family, widest first', () => { + it('counts each family and orders by size, then name', () => { + const counts = countByCause([ + skip('Data', 'A', FUNCTION_MSG), + skip('Data', 'B', DATE_MSG), + skip('Data', 'C', FUNCTION_MSG), + skip('Data', 'D', 'Custom types cannot be represented in JSON Schema'), + ]); + + expect([...counts]).toEqual([ + ['function', 2], + ['custom', 1], + ['date', 1], + ]); + }); +}); + +describe('the committed ledger', () => { + it('parses, and every entry carries a non-empty reason', () => { + const baseline = readUnemittedBaseline(PKG); + expect(baseline, `packages/spec/${UNEMITTED_BASELINE_FILE} is missing`).not.toBeNull(); + + const entries = Object.entries(baseline!.entries); + expect(entries.length).toBeGreaterThan(0); + for (const [key, entry] of entries) { + expect(entry.reason.trim(), `${key} has an empty reason`).not.toBe(''); + expect(key, `${key} is not \`Namespace.ExportKey\``).toMatch(/^[A-Z][A-Za-z0-9]*\.[A-Za-z0-9_]+$/); + } + }); + + it('rejects a malformed entry loudly rather than reading it as empty', () => { + // A ledger that silently reads as `{}` is a ratchet that silently accepts + // the whole population — the state this file exists to end. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'unemitted-ledger-fixture-')); + try { + fs.writeFileSync( + path.join(dir, UNEMITTED_BASELINE_FILE), + JSON.stringify({ entries: { 'Data.X': { cause: 'date' } } }), + ); + expect(() => readUnemittedBaseline(dir)).toThrow(/needs a string `cause` and a string `reason`/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns null for an absent ledger, so the generator can report it in its own words', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'unemitted-ledger-absent-')); + try { + expect(readUnemittedBaseline(dir)).toBeNull(); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/spec/unemitted-schemas.baseline.json b/packages/spec/unemitted-schemas.baseline.json new file mode 100644 index 0000000000..e79fca3512 --- /dev/null +++ b/packages/spec/unemitted-schemas.baseline.json @@ -0,0 +1,127 @@ +{ + "$comment": [ + "Shrink-only ledger for the never-published ratchet in scripts/build-schemas.ts (#16431).", + "Each entry records one exported `z.ZodType` that this build emits NO JSON Schema for, so it", + "reaches no file under json-schema/, no line in json-schema.manifest/, and — because", + "content/docs/references/** renders from that directory — no reference section.", + "", + "WHY THE LEDGER EXISTS. The sibling disappearance ratchet (#2978 / #4725) guards one", + "direction: a def key the manifest records that a build stops emitting. It is structurally", + "blind to the other — an export never emitted was never in the manifest, so there is nothing", + "for it to miss. Until this file, the entire record of such an export was one `console.warn`", + "in a build that exits 0, which made 'deliberately not a published JSON-Schema surface' and", + "'a contract whose prose reaches no reader' the same colour on every instrument this repo had.", + "", + "HAND-EDITED, NEVER GENERATED, and deliberately so — the same reason", + "entry-nameability.baseline.json and dual-source-exports.baseline.json are: a `gen:` script", + "for this file would let a new un-emitted export be admitted by running a command instead of", + "by a decision, which is the whole failure mode being closed.", + "", + "SHRINK-ONLY IN BOTH DIRECTIONS. An un-emitted export that is not recorded here fails the", + "build. An entry whose export now emits — or no longer exists — ALSO fails, with an", + "instruction to delete the line: a ledger that keeps entries after the tree moved has stopped", + "describing it and started covering for it, and a stale line is exactly the room a new member", + "needs to arrive looking like the old one. `cause` is re-checked against what the build", + "observes, and `reason` may not be empty: a baseline that records only a COUNT cannot show", + "which member was replaced.", + "", + "SEEDED FROM THE TREE AS IT STOOD, not as anyone wishes it were. Several entries below record", + "a real documentation gap rather than blessing one — that is the point. This ratchet reports;", + "it repairs nothing and makes nothing start or stop publishing. The four `Data` filter entries", + "are #16431's own (a)/(b) remedies and remain open on that card." + ], + "entries": { + "Automation.FlowFunctionDeclarationSchema": { + "cause": "function", + "reason": "A `functions` map entry as AUTHORED in TypeScript: `handler` is the live callable (`z.function()`, automation/flow-function.zod.ts), so there is no JSON document of this shape to describe. `objectstack build` lowers every callable to a string ref before a stack is parsed, and the lowered record — FlowFunctionLoweredDeclarationSchema — is the serialisable half; that is the one a manifest reader needs and it publishes normally." + }, + "Automation.FlowFunctionEntrySchema": { + "cause": "function", + "reason": "The union of all four `functions` entry shapes, and its first member is the bare `z.function()` above, so the whole union is unprojectable even though its two lowered members are plain JSON. A per-branch projection would publish those two; today the generator's fallback is per-SCHEMA, which is the same limitation #16431 records against the `Data` filter entries below." + }, + "Cloud.EnvironmentArtifactSchema": { + "cause": "function", + "reason": "Re-export of System.EnvironmentArtifactSchema (system/environment-artifact.zod.ts) — see that entry. Both export names are recorded because the ratchet is keyed by EXPORT, which is the unit that reaches or fails to reach a published surface." + }, + "Data.ComparisonOperatorSchema": { + "cause": "date", + "reason": "⚠ A REAL GAP, NOT A BLESSING: this is #16431's own finding. `$gt`/`$gte`/`$lt`/`$lte` carry ~1050 characters of `.describe()` each — the #5685 comparand contract — and reach no reference row at all, because `orderingComparandSchema` includes `z.date()` and the generator's `io: 'input'` fallback applies per SCHEMA rather than per BRANCH. Recorded here so the gap is counted; the remedy is #16431 (a)/(b), which stays open." + }, + "Data.FieldOperatorsSchema": { + "cause": "date", + "reason": "⚠ A REAL GAP: same cause as ComparisonOperatorSchema — it restates the ordering operators, so the whole record is unprojectable. Its `$null`/`$exists` members are described on the reference page only because #15059's prose carries a second copy of them. Remedy: #16431 (a)/(b)." + }, + "Data.NormalizedFilterSchema": { + "cause": "date", + "reason": "⚠ A REAL GAP: the normalised filter form reached through `$not` → field → `$gt`, so it inherits the same `z.date()` comparand. Remedy: #16431 (a)/(b)." + }, + "Data.RangeOperatorSchema": { + "cause": "date", + "reason": "⚠ A REAL GAP: `$between` carries the #6571 endpoint contract and the #7596 no-`{ $field }`-in-a-list rule, ~1010 characters, and reaches no reference row — `rangeEndpointSchema` includes `z.date()`. Remedy: #16431 (a)/(b)." + }, + "Data.HookSchema": { + "cause": "custom", + "reason": "⚠ A REAL GAP, and the widest one in this ledger: `hook` is a declared metadata type (`**/*.hook.ts`, `**/*.hook.yml`, `allowRuntimeCreate: true`), so authors write these documents — yet the schema is unprojectable because its DEPRECATED inline-function handler form is a `z.custom<(...args) => any>`. Measured consequence beyond the missing reference page: authorable-surface/ holds 14 `data/HookContext:` keys and zero `data/Hook:` keys, so every authorable key on this type is outside the #3855/#4650 key ratchet and the #4666 default ratchet too. Narrowing the handler union to its string form would publish it." + }, + "Data.DataEngineContractSchema": { + "cause": "function", + "reason": "The engine method contract — `find`/`findOne`/`insert`/`update`/`delete` are `z.function()` members. A CODE interface implemented by an engine, never a document anyone authors or transmits, so no reader loses a reference page." + }, + "Data.DriverInterfaceSchema": { + "cause": "function", + "reason": "The driver method contract (`connect`, `disconnect`, `checkHealth`, `getPoolStats`, `execute` …), all `z.function()`. A code interface implemented by a driver package; nothing is authored against it." + }, + "Data.CustomPersistenceConfigSchema": { + "cause": "function", + "reason": "The `{ type: 'custom', adapter }` branch of the memory driver's persistence config: `adapter` is a live PersistenceAdapter whose members are functions. The branch exists to let a HOST pass an object it constructed in code, so it has no JSON form by construction — the file/local/auto branches, which are what an author writes, publish normally." + }, + "Data.MemoryConfigSchema": { + "cause": "function", + "reason": "⚠ A REAL GAP: this is a memory datasource's authorable `config`, and it is unprojectable only because ONE branch of its `persistence` union is the code-only custom adapter above. Everything an author can write in YAML is representable. A per-branch projection, or excluding the custom branch from the published shape, would give this type its reference page back." + }, + "Data.MemoryPersistenceConfigSchema": { + "cause": "function", + "reason": "⚠ A REAL GAP, same cause and same remedy as MemoryConfigSchema: the union of five persistence shapes, four of them plain JSON, unprojectable because the fifth carries the live adapter." + }, + "Data.PersistenceAdapterSchema": { + "cause": "function", + "reason": "The adapter interface itself — `load`, `save`, `flush`, all `z.function()`. A code contract a host implements; no document has this shape." + }, + "Kernel.PluginContextSchema": { + "cause": "function", + "reason": "The runtime context the kernel hands a plugin's `init(ctx)` — `ql.object`, `ql.query`, `getCurrentUser`, `getConfig` … all `z.function()`. Constructed only by the kernel and exposed to nobody; it is a TypeScript surface, and the reference tree documents it as prose in the plugin pages rather than as a schema section." + }, + "System.BatchTask": { + "cause": "function", + "reason": "Alias export of BatchTaskSchema (system/worker.zod.ts) — see that entry. Both names are recorded because the ratchet is keyed by export." + }, + "System.BatchTaskSchema": { + "cause": "function", + "reason": "A batch task handed to the worker at RUNTIME: `onProgress` is a `z.function()` callback the caller supplies. Callbacks do not survive serialisation, so this record is not a document and no author writes one." + }, + "System.WorkerConfig": { + "cause": "function", + "reason": "Alias export of WorkerConfigSchema (system/worker.zod.ts) — see that entry." + }, + "System.WorkerConfigSchema": { + "cause": "function", + "reason": "Worker construction options: `handlers` is a `z.record(z.string(), z.function())` of task handlers registered in code. A host builds this object; it is never authored as metadata." + }, + "System.EnvironmentArtifactSchema": { + "cause": "function", + "reason": "The environment-artifact envelope the control plane assembles for `GET /api/v1/cloud/environments/:id/artifact`. Unprojectable through `metadata.onEnable`, a plugin lifecycle hook that rides on the compiled metadata block. Machine-produced and machine-consumed rather than authored — but it IS a wire contract between two services, so it is the strongest candidate in this ledger for a future repair that lifts the callable members out of the transmitted shape." + }, + "UI.ViewMetadataSchema": { + "cause": "undefined", + "reason": "The view metadata DOOR — the union that decides which view shapes are legal — whose flattened-overlay members exclude their siblings with `config: z.undefined()` guards (ui/view.zod.ts). `z.undefined()` has no JSON Schema form, so the door is unprojectable while the contract underneath it is not: `ui/View` publishes normally and carries the authorable keys, so an author reading the reference pages is not missing the View shape itself, only the union envelope." + }, + "UI.AssembledViewArtifactSchema": { + "cause": "undefined", + "reason": "The non-container half of the same union, for artifacts travelling in a runtime-assembled manifest's `viewItems:`. Same `z.undefined()` guard, same conclusion — machine-assembled (package export, environment artifact), never hand-authored." + }, + "UI.FieldWidgetPropsSchema": { + "cause": "function", + "reason": "A REACT PROPS CONTRACT, not authorable metadata — `onChange` is a `z.function()`. The docblock above the export already states this and states the consequence ('so no JSON Schema is emitted'); this entry is that statement moved somewhere a gate can read it." + } + } +}