Skip to content

Commit 146f448

Browse files
os-zhuangclaude
andauthored
fix(spec): the bundled JSON Schema's x-schema-count counts the definitions it carries (#12588) (#12610)
* 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. * 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. * chore(spec): changeset for the x-schema-count correction --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0d7b1f3 commit 146f448

5 files changed

Lines changed: 374 additions & 18 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the bundled JSON Schema's `x-schema-count` counts the definitions it carries (#12588)
6+
7+
`json-schema/objectstack.json` ships in the tarball (`json-schema` is in the
8+
package's `files`), and `content/docs/deployment/troubleshooting.mdx` publishes
9+
what the field means: "its `x-schema-count` field reports the total number of
10+
definitions". It did not. The generator took the number from `count` — a
11+
counter incremented once per emitted schema — while the bundle's `$defs` is
12+
assembled from a map keyed by `<category>/<Name>`. Every def key written more
13+
than once therefore widened a gap nothing reconciled: the published bundle
14+
declared **1596** definitions while carrying **1585**.
15+
16+
`$defs` is now assembled before the envelope and the field is taken from its
17+
size, so the artifact describes itself. The per-schema files on disk already
18+
agreed with `$defs` (1585) — the same key collapses the file writes — so this
19+
brings the one disagreeing number into line with both of the others, and the
20+
docs sentence is true as written without changing it.
21+
22+
**The collapsed emits are now named rather than implied.** The 11 def keys
23+
written twice are all **benign self-aliases**`export const X = XSchema`
24+
spelled as `Object.assign(XSchema, …)`, one schema object reached by two export
25+
names, so the second write cannot change what is published. Eight in `api`
26+
(`ApiEndpoint`, `RestApiConfig`, `RestServerConfig`, `ApiDocumentationConfig`,
27+
`ApiTestCollection`, `OpenApiSpec`, `RestApiPluginConfig`,
28+
`RestApiRouteRegistration`) and three in `system` (`MiddlewareConfig`,
29+
`QueueConfig`, `Task`). No schema is being silently dropped: the existing
30+
`findDefKeyCollisions` guard exits the build on any def key claimed by two
31+
*different* schemas, so a build that produces a bundle at all has only exempt
32+
ones — and `gen:schema` now prints that population instead of leaving it
33+
visible only as a subtraction between two summary lines.
34+
35+
No schema content changes; only the bundle's self-description and the
36+
generator's console output.

packages/spec/scripts/build-schemas-check-mode.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2185,6 +2185,118 @@ describe('build-schemas.ts — the output clean spares a sibling generator (#537
21852185
);
21862186
});
21872187

2188+
// ─────────────────────────────────────────────────────────────────────────────
2189+
// #12588 — the bundle's `x-schema-count` counts what the bundle carries.
2190+
//
2191+
// `objectstack.json` ships in the npm tarball and a docs page publishes the
2192+
// field's meaning ("its `x-schema-count` field reports the total number of
2193+
// definitions"), so the number is a contract, not a build log. It used to be
2194+
// taken from `count` — incremented once per EMIT — while `$defs` is assembled
2195+
// from a map keyed by `<category>/<Name>`. Every def key written twice therefore
2196+
// widened a gap nothing reconciled: the published bundle declared 1596
2197+
// definitions and shipped 1585.
2198+
//
2199+
// The unit half (scripts/def-key-collisions.test.ts) pins the arithmetic. What
2200+
// only this sandbox can assert is that the artifact the generator really writes
2201+
// describes itself correctly — the assertions below read the emitted bytes, not
2202+
// a helper's return value. `src/` is symlinked into the sandbox, so this runs
2203+
// over the REAL spec surface (~1600 schemas), which is also what makes the
2204+
// non-vacuity guard below meaningful: this build genuinely collapses emits.
2205+
//
2206+
// The invariant is pinned, never today's absolute number — 1585 moves with
2207+
// every schema anyone adds, and a test that has to be edited by unrelated PRs
2208+
// gets edited without being read.
2209+
describe('build-schemas.ts — the bundle counts the definitions it carries (#12588)', () => {
2210+
const OUT = () => path.join(sandbox, 'json-schema');
2211+
2212+
beforeEach(() => {
2213+
// A current, self-consistent tree, so the run exits 0 and these assertions
2214+
// are about the bundle rather than about some ratchet upstream of it.
2215+
seedManifest((s) => s);
2216+
const tip = seedBase((s) => s);
2217+
seedSurface((s) => s);
2218+
seedSurfaceBase(tip, (k) => k);
2219+
});
2220+
2221+
it(
2222+
'writes x-schema-count equal to its own $defs size, and to the files on disk',
2223+
{ timeout: SPAWN_TIMEOUT_MS },
2224+
() => {
2225+
const { status, output } = run([]);
2226+
expect(status).toBe(0);
2227+
2228+
const bundle = JSON.parse(
2229+
fs.readFileSync(path.join(OUT(), 'objectstack.json'), 'utf8'),
2230+
) as { 'x-schema-count': number; $defs: Record<string, unknown> };
2231+
const defCount = Object.keys(bundle.$defs).length;
2232+
2233+
// 1. The artifact describes itself.
2234+
expect(bundle['x-schema-count']).toBe(defCount);
2235+
2236+
// 2. A second, independent instrument: one file per def key on disk. The
2237+
// per-schema writes collapse the same way the map does, so the tree is
2238+
// a witness the bundle cannot fabricate. `openapi.json` belongs to
2239+
// gen:openapi and objectstack.json is the bundle itself.
2240+
const onDisk = fs
2241+
.readdirSync(OUT(), { recursive: true, encoding: 'utf8' })
2242+
.filter(
2243+
(entry) =>
2244+
entry.endsWith('.json') &&
2245+
path.basename(entry) !== 'objectstack.json' &&
2246+
path.basename(entry) !== 'openapi.json',
2247+
);
2248+
expect(onDisk).toHaveLength(defCount);
2249+
2250+
// 3. The generator's own console agrees, so a reader of the build log and
2251+
// a reader of the artifact reach the same number.
2252+
expect(output).toContain(`objectstack.json (${defCount} definitions)`);
2253+
},
2254+
);
2255+
2256+
it(
2257+
'accounts for every emit the definition count does not include',
2258+
{ timeout: SPAWN_TIMEOUT_MS },
2259+
() => {
2260+
const { status, output } = run([]);
2261+
expect(status).toBe(0);
2262+
2263+
const bundle = JSON.parse(
2264+
fs.readFileSync(path.join(OUT(), 'objectstack.json'), 'utf8'),
2265+
) as { 'x-schema-count': number };
2266+
const emitted = Number(/Successfully generated (\d+) schemas/.exec(output)?.[1]);
2267+
expect(Number.isFinite(emitted), 'summary line must report the emit total').toBe(true);
2268+
2269+
// Non-vacuity: this build must still collapse emits, or the case proves
2270+
// nothing about the defect. If a future PR removes the last self-alias
2271+
// from the spec, `x-schema-count: count` and the correct expression stop
2272+
// differing and this pin can no longer fail — delete it deliberately
2273+
// then, rather than discovering later that it had gone quiet.
2274+
expect(
2275+
emitted,
2276+
'no emit collapses any more — this build no longer models #12588',
2277+
).toBeGreaterThan(bundle['x-schema-count']);
2278+
2279+
// The delta is reported, not left as a subtraction between two lines —
2280+
// that silence is what let a wrong number ship unnoticed. The reported
2281+
// figure must reconcile the two totals exactly.
2282+
const collapsed = Number(/\s+(\d+) emit\(s\) collapsed/.exec(output)?.[1]);
2283+
expect(Number.isFinite(collapsed), 'the collapsed-emit report line must be printed').toBe(true);
2284+
expect(emitted - collapsed).toBe(bundle['x-schema-count']);
2285+
2286+
// Every collapsed key is named, and named as a self-alias: the guard
2287+
// upstream exits on any def key written twice by DIFFERENT schemas, so a
2288+
// build that reaches here has only benign ones. Stating it in the report
2289+
// is what makes that population readable instead of implied.
2290+
expect(output).toContain('all self-aliases');
2291+
const named = [...output.matchAll(/ {5}json-schema\/(\S+)\.json {2}<- {2}/g)].map((m) => m[1]);
2292+
expect(named.length).toBeGreaterThan(0);
2293+
for (const defKey of named) {
2294+
expect(fs.existsSync(path.join(OUT(), `${defKey}.json`))).toBe(true);
2295+
}
2296+
},
2297+
);
2298+
});
2299+
21882300
// ─────────────────────────────────────────────────────────────────────────────
21892301
// #4659 — check (b) registers a tombstone by its EXACT key, not by its leaf.
21902302
//

packages/spec/scripts/build-schemas.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import { spawnSync } from 'child_process';
1111
import { z } from 'zod';
1212
import { schemaNameFromExportKey } from './lib/schema-name';
1313
import {
14+
collapsedEmitCount,
1415
findDefKeyCollisions,
16+
findSelfAliasedDefKeys,
1517
formatDefKeyCollisions,
1618
type EmittedDef,
1719
} from './lib/def-key-collisions';
@@ -475,6 +477,28 @@ if (defKeyCollisions.length > 0) {
475477
process.exit(1);
476478
}
477479

480+
// ─── Report: the writes the guard above exempted (#12588) ─────────────
481+
// Reaching here means every def key written twice is a self-alias, because the
482+
// guard exits on any that is not. Those writes still collapse — `count` is one
483+
// per EMIT while `generatedSchemas` is keyed by def key — so the emit total is
484+
// higher than the number of definitions this build publishes. That difference
485+
// used to be visible only as a subtraction between two summary lines, and it
486+
// leaked into the published bundle as an `x-schema-count` nobody could reconcile
487+
// with the `$defs` beside it. Name the population instead of implying it.
488+
// A report, not a gate: there is no threshold here and no exit path.
489+
const selfAliasedDefKeys = findSelfAliasedDefKeys(emittedDefs);
490+
const collapsedEmits = collapsedEmitCount(selfAliasedDefKeys);
491+
if (collapsedEmits > 0) {
492+
console.log(
493+
`\nℹ️ ${collapsedEmits} emit(s) collapsed into ${selfAliasedDefKeys.length} existing def key(s) ` +
494+
`— all self-aliases (one schema object reached by two export names), so ${count} emits publish ` +
495+
`${generatedSchemas.size} definitions:`,
496+
);
497+
for (const alias of selfAliasedDefKeys) {
498+
console.log(` json-schema/${alias.defKey}.json <- ${alias.exportKeys.join(', ')}`);
499+
}
500+
}
501+
478502
// ─── Ratchet: a published schema must never silently disappear ────────
479503
// json-schema/ is a public contract surface (IDE validation, gen:docs input,
480504
// $id URLs under schema.objectstack.io). The manifest is the committed record
@@ -2531,24 +2555,31 @@ if (defaultsChanged && !CHECK) {
25312555
// ─── Generate Bundled Schema ─────────────────────────────────────────
25322556
// Single-file bundled schema containing all generated schemas for IDE autocomplete
25332557

2558+
// Assemble bundled $defs from the in-memory map populated during generation.
2559+
// (Avoid re-reading the json-schema/ tree to dodge CI filesystem races.)
2560+
//
2561+
// Assembled BEFORE the envelope, so `x-schema-count` below is taken from what
2562+
// this bundle actually carries (#12588). It used to be `count`, the per-EMIT
2563+
// counter, while `$defs` is keyed by def key — so every self-aliased key
2564+
// reported above widened the gap, and the published artifact declared 1596
2565+
// definitions while shipping 1585. A self-describing artifact counts what it
2566+
// contains; the emit total is a property of the build, not of the file, and is
2567+
// still reported on the summary line at the end of this script.
2568+
const defs: Record<string, unknown> = {};
2569+
for (const [defKey, schema] of generatedSchemas) {
2570+
defs[defKey] = schema;
2571+
}
2572+
25342573
const bundledSchema: Record<string, unknown> = {
25352574
$schema: 'https://json-schema.org/draft/2020-12/schema',
25362575
$id: `${SCHEMA_BASE_URL}/objectstack.json`,
25372576
title: 'ObjectStack Protocol',
25382577
description: `ObjectStack Protocol v${SPEC_VERSION} — Complete bundled JSON Schema for IDE autocomplete`,
25392578
'x-spec-version': SPEC_VERSION,
2540-
'x-schema-count': count,
2541-
$defs: {} as Record<string, unknown>,
2579+
'x-schema-count': Object.keys(defs).length,
2580+
$defs: defs,
25422581
};
25432582

2544-
const defs = bundledSchema.$defs as Record<string, unknown>;
2545-
2546-
// Assemble bundled $defs from the in-memory map populated during generation.
2547-
// (Avoid re-reading the json-schema/ tree to dodge CI filesystem races.)
2548-
for (const [defKey, schema] of generatedSchemas) {
2549-
defs[defKey] = schema;
2550-
}
2551-
25522583
const bundledPath = path.join(OUT_DIR, 'objectstack.json');
25532584
writeFileWithRetry(bundledPath, JSON.stringify(bundledSchema, null, 2));
25542585
console.log(`\n✅ Generated bundled schema: objectstack.json (${Object.keys(defs).length} definitions)`);

packages/spec/scripts/def-key-collisions.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ import path from 'node:path';
3030
import { SCHEMA_MANIFEST_DIR_NAME } from './lib/sharded-artifacts';
3131

3232
import {
33+
collapsedEmitCount,
3334
findDefKeyCollisions,
35+
findSelfAliasedDefKeys,
3436
formatDefKeyCollisions,
3537
type EmittedDef,
3638
} from './lib/def-key-collisions';
@@ -110,6 +112,114 @@ describe('findDefKeyCollisions', () => {
110112
});
111113
});
112114

115+
// ─────────────────────────────────────────────────────────────────────────
116+
// #12588 — the exempt population, enumerated.
117+
// ─────────────────────────────────────────────────────────────────────────
118+
//
119+
// `findDefKeyCollisions` is silent about self-aliases because they are not a
120+
// problem. They are, however, the reason a build's emit count exceeds the
121+
// number of definitions it publishes — and that unexplained delta is what put a
122+
// wrong `x-schema-count` into the shipped bundle. These pin the other half.
123+
124+
describe('findSelfAliasedDefKeys — the population the guard exempts (#12588)', () => {
125+
it('reports a self-alias, which is exactly what findDefKeyCollisions stays silent about', () => {
126+
const task = { type: 'object' };
127+
const entries = [emitted('system', 'Task', task), emitted('system', 'TaskSchema', task)];
128+
129+
expect(findSelfAliasedDefKeys(entries)).toEqual([
130+
{ defKey: 'system/Task', exportKeys: ['Task', 'TaskSchema'] },
131+
]);
132+
// The two verdicts are complementary, not overlapping.
133+
expect(findDefKeyCollisions(entries)).toEqual([]);
134+
});
135+
136+
it('is silent about a real collision — that one belongs to the guard, not to a report', () => {
137+
const entries = [
138+
emitted('shared', 'HttpMethod', { enum: ['GET', 'HEAD'] }),
139+
emitted('shared', 'HttpMethodSchema', { enum: ['GET'] }),
140+
];
141+
142+
expect(findSelfAliasedDefKeys(entries)).toEqual([]);
143+
expect(findDefKeyCollisions(entries)).toHaveLength(1);
144+
});
145+
146+
it('ignores a def key written once — one write collapses nothing', () => {
147+
expect(findSelfAliasedDefKeys([emitted('data', 'FieldSchema', {})])).toEqual([]);
148+
});
149+
150+
it('partitions every multiply-written def key between the two functions', () => {
151+
// The invariant that makes "emits - definitions" fully accounted for: a key
152+
// written more than once is either exempt or a collision, never neither and
153+
// never both. Asserted over a mixed build rather than stated in prose.
154+
const alias = {};
155+
const entries = [
156+
emitted('ui', 'ThemeModeSchema', alias),
157+
emitted('ui', 'ThemeMode', alias),
158+
emitted('shared', 'HttpMethod', { a: 1 }),
159+
emitted('shared', 'HttpMethodSchema', { a: 2 }),
160+
emitted('data', 'FieldSchema', {}),
161+
];
162+
163+
const aliases = findSelfAliasedDefKeys(entries).map((a) => a.defKey);
164+
const collisions = findDefKeyCollisions(entries).map((c) => c.defKey);
165+
166+
expect(aliases).toEqual(['ui/ThemeMode']);
167+
expect(collisions).toEqual(['shared/HttpMethod']);
168+
expect(aliases.filter((k) => collisions.includes(k))).toEqual([]);
169+
// Every key with more than one write is claimed by exactly one of them.
170+
const writtenTwice = ['ui/ThemeMode', 'shared/HttpMethod'];
171+
expect([...aliases, ...collisions].sort()).toEqual([...writtenTwice].sort());
172+
});
173+
174+
it('follows first-encounter order, so a build report is stable across runs', () => {
175+
const a = {};
176+
const b = {};
177+
expect(
178+
findSelfAliasedDefKeys([
179+
emitted('system', 'QueueConfig', b),
180+
emitted('api', 'ApiEndpoint', a),
181+
emitted('api', 'ApiEndpointSchema', a),
182+
emitted('system', 'QueueConfigSchema', b),
183+
]).map((x) => x.defKey),
184+
).toEqual(['system/QueueConfig', 'api/ApiEndpoint']);
185+
});
186+
});
187+
188+
describe('collapsedEmitCount — the emits a self-aliased key absorbs (#12588)', () => {
189+
it('counts N-1 per key: the first write is the definition, the rest collapse onto it', () => {
190+
expect(
191+
collapsedEmitCount([
192+
{ defKey: 'api/ApiEndpoint', exportKeys: ['ApiEndpoint', 'ApiEndpointSchema'] },
193+
{ defKey: 'system/Task', exportKeys: ['Task', 'TaskSchema'] },
194+
]),
195+
).toBe(2);
196+
});
197+
198+
it('counts a triple alias as two collapsed emits, not one', () => {
199+
expect(
200+
collapsedEmitCount([{ defKey: 'api/Thing', exportKeys: ['Thing', 'ThingSchema', 'ThingZod'] }]),
201+
).toBe(2);
202+
});
203+
204+
it('is zero on a build where nothing collapsed', () => {
205+
expect(collapsedEmitCount([])).toBe(0);
206+
});
207+
208+
it('reconciles the two totals: emits - collapsed = definitions', () => {
209+
// The arithmetic the published `x-schema-count` got wrong, in miniature.
210+
const alias = {};
211+
const entries = [
212+
emitted('api', 'ApiEndpoint', alias),
213+
emitted('api', 'ApiEndpointSchema', alias),
214+
emitted('data', 'FieldSchema', {}),
215+
emitted('data', 'ObjectSchema', {}),
216+
];
217+
const definitions = new Set(entries.map((e) => `${e.category}/${e.schemaName}`)).size;
218+
219+
expect(entries.length - collapsedEmitCount(findSelfAliasedDefKeys(entries))).toBe(definitions);
220+
});
221+
});
222+
113223
describe('formatDefKeyCollisions', () => {
114224
it('names the file that would be written, both export keys, and the source-side remedies', () => {
115225
const message = formatDefKeyCollisions([

0 commit comments

Comments
 (0)