From 99bdf693cd2f20e53c57f7ef75a00a435744acba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 18:42:25 +0000 Subject: [PATCH 1/3] fix(spec): x-schema-count counts the definitions the bundle carries The bundled objectstack.json took x-schema-count from the per-emit counter while its $defs is keyed by def key, so every self-aliased key inflated the published field: 1596 declared, 1585 shipped. Assemble $defs first and count what the artifact contains. Also name the exempt population the guard allows through, so the collapsed emits are reported rather than left implicit in a subtraction. --- packages/spec/scripts/build-schemas.ts | 51 +++++++++--- .../spec/scripts/lib/def-key-collisions.ts | 83 +++++++++++++++++-- 2 files changed, 116 insertions(+), 18 deletions(-) diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 184aa4c02f..16f926aa06 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -11,7 +11,9 @@ import { spawnSync } from 'child_process'; import { z } from 'zod'; import { schemaNameFromExportKey } from './lib/schema-name'; import { + collapsedEmitCount, findDefKeyCollisions, + findSelfAliasedDefKeys, formatDefKeyCollisions, type EmittedDef, } from './lib/def-key-collisions'; @@ -475,6 +477,28 @@ if (defKeyCollisions.length > 0) { process.exit(1); } +// ─── Report: the writes the guard above exempted (#12588) ───────────── +// Reaching here means every def key written twice is a self-alias, because the +// guard exits on any that is not. Those writes still collapse — `count` is one +// per EMIT while `generatedSchemas` is keyed by def key — so the emit total is +// higher than the number of definitions this build publishes. That difference +// used to be visible only as a subtraction between two summary lines, and it +// leaked into the published bundle as an `x-schema-count` nobody could reconcile +// with the `$defs` beside it. Name the population instead of implying it. +// A report, not a gate: there is no threshold here and no exit path. +const selfAliasedDefKeys = findSelfAliasedDefKeys(emittedDefs); +const collapsedEmits = collapsedEmitCount(selfAliasedDefKeys); +if (collapsedEmits > 0) { + console.log( + `\nℹ️ ${collapsedEmits} emit(s) collapsed into ${selfAliasedDefKeys.length} existing def key(s) ` + + `— all self-aliases (one schema object reached by two export names), so ${count} emits publish ` + + `${generatedSchemas.size} definitions:`, + ); + for (const alias of selfAliasedDefKeys) { + console.log(` json-schema/${alias.defKey}.json <- ${alias.exportKeys.join(', ')}`); + } +} + // ─── Ratchet: a published schema must never silently disappear ──────── // json-schema/ is a public contract surface (IDE validation, gen:docs input, // $id URLs under schema.objectstack.io). The manifest is the committed record @@ -2531,24 +2555,31 @@ if (defaultsChanged && !CHECK) { // ─── Generate Bundled Schema ───────────────────────────────────────── // Single-file bundled schema containing all generated schemas for IDE autocomplete +// Assemble bundled $defs from the in-memory map populated during generation. +// (Avoid re-reading the json-schema/ tree to dodge CI filesystem races.) +// +// Assembled BEFORE the envelope, so `x-schema-count` below is taken from what +// this bundle actually carries (#12588). It used to be `count`, the per-EMIT +// counter, while `$defs` is keyed by def key — so every self-aliased key +// reported above widened the gap, and the published artifact declared 1596 +// definitions while shipping 1585. A self-describing artifact counts what it +// contains; the emit total is a property of the build, not of the file, and is +// still reported on the summary line at the end of this script. +const defs: Record = {}; +for (const [defKey, schema] of generatedSchemas) { + defs[defKey] = schema; +} + const bundledSchema: Record = { $schema: 'https://json-schema.org/draft/2020-12/schema', $id: `${SCHEMA_BASE_URL}/objectstack.json`, title: 'ObjectStack Protocol', description: `ObjectStack Protocol v${SPEC_VERSION} — Complete bundled JSON Schema for IDE autocomplete`, 'x-spec-version': SPEC_VERSION, - 'x-schema-count': count, - $defs: {} as Record, + 'x-schema-count': Object.keys(defs).length, + $defs: defs, }; -const defs = bundledSchema.$defs as Record; - -// Assemble bundled $defs from the in-memory map populated during generation. -// (Avoid re-reading the json-schema/ tree to dodge CI filesystem races.) -for (const [defKey, schema] of generatedSchemas) { - defs[defKey] = schema; -} - const bundledPath = path.join(OUT_DIR, 'objectstack.json'); writeFileWithRetry(bundledPath, JSON.stringify(bundledSchema, null, 2)); console.log(`\n✅ Generated bundled schema: objectstack.json (${Object.keys(defs).length} definitions)`); diff --git a/packages/spec/scripts/lib/def-key-collisions.ts b/packages/spec/scripts/lib/def-key-collisions.ts index 57f49990a6..ffba560bf3 100644 --- a/packages/spec/scripts/lib/def-key-collisions.ts +++ b/packages/spec/scripts/lib/def-key-collisions.ts @@ -46,6 +46,22 @@ * The remedy is always at the source, never here: rename the loser to a def * name of its own (#4684's `RateLimitConfig` precedent, ADR-0112 D9 — one name * means one thing), or delete the duplicate and re-export the survivor. + * + * ## The exempt population is enumerable, not implied (#12588) + * + * The exemption above is silent by design — a self-alias publishes one artifact, + * so there is nothing to report as a *problem*. But it is not nothing: those + * writes are the reason the generator's emit count exceeds the number of + * definitions it publishes, and for a long time that delta was the only + * externally visible trace of them. It surfaced as a published artifact + * describing itself wrongly: `objectstack.json` carried `x-schema-count` taken + * from the emit counter while its `$defs` held one entry per def key, so the + * bundle claimed 1596 definitions and shipped 1585. + * + * `findSelfAliasedDefKeys` is the other half of `findDefKeyCollisions`: same + * bucketing, same identity predicate, opposite verdict. Together they partition + * every multiply-written def key, so "how many emits collapsed, and into what" + * is answerable rather than inferred from a subtraction. */ /** One export as `build-schemas.ts` met it, before anything is written. */ @@ -71,14 +87,21 @@ export interface DefKeyCollision { exportKeys: string[]; } +/** A def key written more than once, every write the SAME schema instance. */ +export interface SelfAliasedDefKey { + /** `/` — the one file all of these writes produce. */ + defKey: string; + /** Every export key that resolves to it, in encounter order. */ + exportKeys: string[]; +} + /** - * Def keys written more than once by DIFFERENT schema instances. - * - * Self-aliases (every entry for a key is the identical object) are not - * collisions and are not reported. Result order follows first encounter, so a - * build's report is stable across runs. + * Group entries by the def key they publish to, preserving encounter order both + * between buckets and inside them, so every report built from this is stable + * across runs. Shared by both verdicts below: they must never disagree about + * which entries belong to one key. */ -export function findDefKeyCollisions(entries: Iterable): DefKeyCollision[] { +function bucketByDefKey(entries: Iterable): Map { const byDefKey = new Map(); for (const entry of entries) { const defKey = `${entry.category}/${entry.schemaName}`; @@ -86,17 +109,61 @@ export function findDefKeyCollisions(entries: Iterable): DefKeyColli if (bucket) bucket.push(entry); else byDefKey.set(defKey, [entry]); } + return byDefKey; +} + +/** Every write in the bucket names the identical object — the exempt shape. */ +function isSelfAlias(bucket: readonly EmittedDef[]): boolean { + return bucket.every((e) => e.schema === bucket[0].schema); +} +/** + * Def keys written more than once by DIFFERENT schema instances. + * + * Self-aliases (every entry for a key is the identical object) are not + * collisions and are not reported — `findSelfAliasedDefKeys` returns exactly + * those. Result order follows first encounter, so a build's report is stable + * across runs. + */ +export function findDefKeyCollisions(entries: Iterable): DefKeyCollision[] { const collisions: DefKeyCollision[] = []; - for (const [defKey, bucket] of byDefKey) { + for (const [defKey, bucket] of bucketByDefKey(entries)) { if (bucket.length < 2) continue; // One object reached by two names publishes one artifact — no ambiguity. - if (bucket.every((e) => e.schema === bucket[0].schema)) continue; + if (isSelfAlias(bucket)) continue; collisions.push({ defKey, exportKeys: bucket.map((e) => e.exportKey) }); } return collisions; } +/** + * Def keys written more than once where every write is the SAME instance — the + * population `findDefKeyCollisions` exempts, and the reason a build's emit + * count exceeds the number of definitions it publishes (#12588). + * + * This is a report, never a verdict: each of these publishes one artifact and + * nothing about it depends on export order. Callers use it to *account for* the + * difference between emits and definitions, not to fail a build. + */ +export function findSelfAliasedDefKeys(entries: Iterable): SelfAliasedDefKey[] { + const aliases: SelfAliasedDefKey[] = []; + for (const [defKey, bucket] of bucketByDefKey(entries)) { + if (bucket.length < 2) continue; + if (!isSelfAlias(bucket)) continue; + aliases.push({ defKey, exportKeys: bucket.map((e) => e.exportKey) }); + } + return aliases; +} + +/** + * How many emits these self-aliased keys absorb — the count by which a build's + * emit total exceeds its published definition count. A key written N times + * contributes N-1: the first write is the definition, the rest collapse onto it. + */ +export function collapsedEmitCount(aliases: readonly SelfAliasedDefKey[]): number { + return aliases.reduce((total, alias) => total + alias.exportKeys.length - 1, 0); +} + /** The build-stopping message for `findDefKeyCollisions()`. */ export function formatDefKeyCollisions(collisions: readonly DefKeyCollision[]): string { const lines = collisions.map( From abb19d167402893648089f2e30313b3d4e41ac5c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 18:44:40 +0000 Subject: [PATCH 2/3] test(spec): pin that the bundle's x-schema-count equals its $defs size Unit half: the exempt self-alias population and the emits it absorbs. End-to-end half: the artifact the generator really writes, cross-checked against the files on disk and the run's own console. --- .../scripts/build-schemas-check-mode.test.ts | 112 ++++++++++++++++++ .../spec/scripts/def-key-collisions.test.ts | 110 +++++++++++++++++ 2 files changed, 222 insertions(+) diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index 0c0ed85898..628e8beff0 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -2185,6 +2185,118 @@ describe('build-schemas.ts — the output clean spares a sibling generator (#537 ); }); +// ───────────────────────────────────────────────────────────────────────────── +// #12588 — the bundle's `x-schema-count` counts what the bundle carries. +// +// `objectstack.json` ships in the npm tarball and a docs page publishes the +// field's meaning ("its `x-schema-count` field reports the total number of +// definitions"), so the number is a contract, not a build log. It used to be +// taken from `count` — incremented once per EMIT — while `$defs` is assembled +// from a map keyed by `/`. Every def key written twice therefore +// widened a gap nothing reconciled: the published bundle declared 1596 +// definitions and shipped 1585. +// +// The unit half (scripts/def-key-collisions.test.ts) pins the arithmetic. What +// only this sandbox can assert is that the artifact the generator really writes +// describes itself correctly — the assertions below read the emitted bytes, not +// a helper's return value. `src/` is symlinked into the sandbox, so this runs +// over the REAL spec surface (~1600 schemas), which is also what makes the +// non-vacuity guard below meaningful: this build genuinely collapses emits. +// +// The invariant is pinned, never today's absolute number — 1585 moves with +// every schema anyone adds, and a test that has to be edited by unrelated PRs +// gets edited without being read. +describe('build-schemas.ts — the bundle counts the definitions it carries (#12588)', () => { + const OUT = () => path.join(sandbox, 'json-schema'); + + beforeEach(() => { + // A current, self-consistent tree, so the run exits 0 and these assertions + // are about the bundle rather than about some ratchet upstream of it. + seedManifest((s) => s); + const tip = seedBase((s) => s); + seedSurface((s) => s); + seedSurfaceBase(tip, (k) => k); + }); + + it( + 'writes x-schema-count equal to its own $defs size, and to the files on disk', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + const { status, output } = run([]); + expect(status).toBe(0); + + const bundle = JSON.parse( + fs.readFileSync(path.join(OUT(), 'objectstack.json'), 'utf8'), + ) as { 'x-schema-count': number; $defs: Record }; + const defCount = Object.keys(bundle.$defs).length; + + // 1. The artifact describes itself. + expect(bundle['x-schema-count']).toBe(defCount); + + // 2. A second, independent instrument: one file per def key on disk. The + // per-schema writes collapse the same way the map does, so the tree is + // a witness the bundle cannot fabricate. `openapi.json` belongs to + // gen:openapi and objectstack.json is the bundle itself. + const onDisk = fs + .readdirSync(OUT(), { recursive: true, encoding: 'utf8' }) + .filter( + (entry) => + entry.endsWith('.json') && + path.basename(entry) !== 'objectstack.json' && + path.basename(entry) !== 'openapi.json', + ); + expect(onDisk).toHaveLength(defCount); + + // 3. The generator's own console agrees, so a reader of the build log and + // a reader of the artifact reach the same number. + expect(output).toContain(`objectstack.json (${defCount} definitions)`); + }, + ); + + it( + 'accounts for every emit the definition count does not include', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + const { status, output } = run([]); + expect(status).toBe(0); + + const bundle = JSON.parse( + fs.readFileSync(path.join(OUT(), 'objectstack.json'), 'utf8'), + ) as { 'x-schema-count': number }; + const emitted = Number(/Successfully generated (\d+) schemas/.exec(output)?.[1]); + expect(Number.isFinite(emitted), 'summary line must report the emit total').toBe(true); + + // Non-vacuity: this build must still collapse emits, or the case proves + // nothing about the defect. If a future PR removes the last self-alias + // from the spec, `x-schema-count: count` and the correct expression stop + // differing and this pin can no longer fail — delete it deliberately + // then, rather than discovering later that it had gone quiet. + expect( + emitted, + 'no emit collapses any more — this build no longer models #12588', + ).toBeGreaterThan(bundle['x-schema-count']); + + // The delta is reported, not left as a subtraction between two lines — + // that silence is what let a wrong number ship unnoticed. The reported + // figure must reconcile the two totals exactly. + const collapsed = Number(/ℹ️\s+(\d+) emit\(s\) collapsed/.exec(output)?.[1]); + expect(Number.isFinite(collapsed), 'the collapsed-emit report line must be printed').toBe(true); + expect(emitted - collapsed).toBe(bundle['x-schema-count']); + + // Every collapsed key is named, and named as a self-alias: the guard + // upstream exits on any def key written twice by DIFFERENT schemas, so a + // build that reaches here has only benign ones. Stating it in the report + // is what makes that population readable instead of implied. + expect(output).toContain('all self-aliases'); + const named = [...output.matchAll(/ {5}json-schema\/(\S+)\.json {2}<- {2}/g)].map((m) => m[1]); + expect(named.length).toBeGreaterThan(0); + for (const defKey of named) { + expect(fs.existsSync(path.join(OUT(), `${defKey}.json`))).toBe(true); + } + }, + ); +}); + // ───────────────────────────────────────────────────────────────────────────── // #4659 — check (b) registers a tombstone by its EXACT key, not by its leaf. // diff --git a/packages/spec/scripts/def-key-collisions.test.ts b/packages/spec/scripts/def-key-collisions.test.ts index 6b9190188e..4a03cd3894 100644 --- a/packages/spec/scripts/def-key-collisions.test.ts +++ b/packages/spec/scripts/def-key-collisions.test.ts @@ -30,7 +30,9 @@ import path from 'node:path'; import { SCHEMA_MANIFEST_DIR_NAME } from './lib/sharded-artifacts'; import { + collapsedEmitCount, findDefKeyCollisions, + findSelfAliasedDefKeys, formatDefKeyCollisions, type EmittedDef, } from './lib/def-key-collisions'; @@ -110,6 +112,114 @@ describe('findDefKeyCollisions', () => { }); }); +// ───────────────────────────────────────────────────────────────────────── +// #12588 — the exempt population, enumerated. +// ───────────────────────────────────────────────────────────────────────── +// +// `findDefKeyCollisions` is silent about self-aliases because they are not a +// problem. They are, however, the reason a build's emit count exceeds the +// number of definitions it publishes — and that unexplained delta is what put a +// wrong `x-schema-count` into the shipped bundle. These pin the other half. + +describe('findSelfAliasedDefKeys — the population the guard exempts (#12588)', () => { + it('reports a self-alias, which is exactly what findDefKeyCollisions stays silent about', () => { + const task = { type: 'object' }; + const entries = [emitted('system', 'Task', task), emitted('system', 'TaskSchema', task)]; + + expect(findSelfAliasedDefKeys(entries)).toEqual([ + { defKey: 'system/Task', exportKeys: ['Task', 'TaskSchema'] }, + ]); + // The two verdicts are complementary, not overlapping. + expect(findDefKeyCollisions(entries)).toEqual([]); + }); + + it('is silent about a real collision — that one belongs to the guard, not to a report', () => { + const entries = [ + emitted('shared', 'HttpMethod', { enum: ['GET', 'HEAD'] }), + emitted('shared', 'HttpMethodSchema', { enum: ['GET'] }), + ]; + + expect(findSelfAliasedDefKeys(entries)).toEqual([]); + expect(findDefKeyCollisions(entries)).toHaveLength(1); + }); + + it('ignores a def key written once — one write collapses nothing', () => { + expect(findSelfAliasedDefKeys([emitted('data', 'FieldSchema', {})])).toEqual([]); + }); + + it('partitions every multiply-written def key between the two functions', () => { + // The invariant that makes "emits - definitions" fully accounted for: a key + // written more than once is either exempt or a collision, never neither and + // never both. Asserted over a mixed build rather than stated in prose. + const alias = {}; + const entries = [ + emitted('ui', 'ThemeModeSchema', alias), + emitted('ui', 'ThemeMode', alias), + emitted('shared', 'HttpMethod', { a: 1 }), + emitted('shared', 'HttpMethodSchema', { a: 2 }), + emitted('data', 'FieldSchema', {}), + ]; + + const aliases = findSelfAliasedDefKeys(entries).map((a) => a.defKey); + const collisions = findDefKeyCollisions(entries).map((c) => c.defKey); + + expect(aliases).toEqual(['ui/ThemeMode']); + expect(collisions).toEqual(['shared/HttpMethod']); + expect(aliases.filter((k) => collisions.includes(k))).toEqual([]); + // Every key with more than one write is claimed by exactly one of them. + const writtenTwice = ['ui/ThemeMode', 'shared/HttpMethod']; + expect([...aliases, ...collisions].sort()).toEqual([...writtenTwice].sort()); + }); + + it('follows first-encounter order, so a build report is stable across runs', () => { + const a = {}; + const b = {}; + expect( + findSelfAliasedDefKeys([ + emitted('system', 'QueueConfig', b), + emitted('api', 'ApiEndpoint', a), + emitted('api', 'ApiEndpointSchema', a), + emitted('system', 'QueueConfigSchema', b), + ]).map((x) => x.defKey), + ).toEqual(['system/QueueConfig', 'api/ApiEndpoint']); + }); +}); + +describe('collapsedEmitCount — the emits a self-aliased key absorbs (#12588)', () => { + it('counts N-1 per key: the first write is the definition, the rest collapse onto it', () => { + expect( + collapsedEmitCount([ + { defKey: 'api/ApiEndpoint', exportKeys: ['ApiEndpoint', 'ApiEndpointSchema'] }, + { defKey: 'system/Task', exportKeys: ['Task', 'TaskSchema'] }, + ]), + ).toBe(2); + }); + + it('counts a triple alias as two collapsed emits, not one', () => { + expect( + collapsedEmitCount([{ defKey: 'api/Thing', exportKeys: ['Thing', 'ThingSchema', 'ThingZod'] }]), + ).toBe(2); + }); + + it('is zero on a build where nothing collapsed', () => { + expect(collapsedEmitCount([])).toBe(0); + }); + + it('reconciles the two totals: emits - collapsed = definitions', () => { + // The arithmetic the published `x-schema-count` got wrong, in miniature. + const alias = {}; + const entries = [ + emitted('api', 'ApiEndpoint', alias), + emitted('api', 'ApiEndpointSchema', alias), + emitted('data', 'FieldSchema', {}), + emitted('data', 'ObjectSchema', {}), + ]; + const definitions = new Set(entries.map((e) => `${e.category}/${e.schemaName}`)).size; + + expect(entries.length - collapsedEmitCount(findSelfAliasedDefKeys(entries))).toBe(definitions); + }); +}); + describe('formatDefKeyCollisions', () => { it('names the file that would be written, both export keys, and the source-side remedies', () => { const message = formatDefKeyCollisions([ From c7aee902b027bcbce7b1b23bb84459fff2d5b052 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 18:58:36 +0000 Subject: [PATCH 3/3] chore(spec): changeset for the x-schema-count correction --- ...ma-count-counts-the-definitions-shipped.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .changeset/schema-count-counts-the-definitions-shipped.md diff --git a/.changeset/schema-count-counts-the-definitions-shipped.md b/.changeset/schema-count-counts-the-definitions-shipped.md new file mode 100644 index 0000000000..36f4130018 --- /dev/null +++ b/.changeset/schema-count-counts-the-definitions-shipped.md @@ -0,0 +1,36 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): the bundled JSON Schema's `x-schema-count` counts the definitions it carries (#12588) + +`json-schema/objectstack.json` ships in the tarball (`json-schema` is in the +package's `files`), and `content/docs/deployment/troubleshooting.mdx` publishes +what the field means: "its `x-schema-count` field reports the total number of +definitions". It did not. The generator took the number from `count` — a +counter incremented once per emitted schema — while the bundle's `$defs` is +assembled from a map keyed by `/`. Every def key written more +than once therefore widened a gap nothing reconciled: the published bundle +declared **1596** definitions while carrying **1585**. + +`$defs` is now assembled before the envelope and the field is taken from its +size, so the artifact describes itself. The per-schema files on disk already +agreed with `$defs` (1585) — the same key collapses the file writes — so this +brings the one disagreeing number into line with both of the others, and the +docs sentence is true as written without changing it. + +**The collapsed emits are now named rather than implied.** The 11 def keys +written twice are all **benign self-aliases** — `export const X = XSchema` +spelled as `Object.assign(XSchema, …)`, one schema object reached by two export +names, so the second write cannot change what is published. Eight in `api` +(`ApiEndpoint`, `RestApiConfig`, `RestServerConfig`, `ApiDocumentationConfig`, +`ApiTestCollection`, `OpenApiSpec`, `RestApiPluginConfig`, +`RestApiRouteRegistration`) and three in `system` (`MiddlewareConfig`, +`QueueConfig`, `Task`). No schema is being silently dropped: the existing +`findDefKeyCollisions` guard exits the build on any def key claimed by two +*different* schemas, so a build that produces a bundle at all has only exempt +ones — and `gen:schema` now prints that population instead of leaving it +visible only as a subtraction between two summary lines. + +No schema content changes; only the bundle's self-description and the +generator's console output.