From 2eafd48098ab760b4866a0e97492952722efae66 Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 4 Sep 2026 15:55:13 -0400 Subject: [PATCH 1/2] Let a consumer of the shared name reach the schema prose Hover on a field in an editor showed its structural facts and no sentence saying what the field is for. The sentence exists, ships, and is already resolved by describeObjectType: what a consumer could not do is obtain the pool. idfkit re-exports httpSource but not nodeSource or readBundleFileSync, which live behind @idfkit/schemas/node, a package a consumer of the shared name did not install. idfkit/node exposes schemas() and schemaFor() and nothing for prose. So the only route was to depend on a package the consumer did not declare, hardcode the file name docs.json, and cast the unknown that read returns. Both the name and the layout are implementation details, and both would have broken in silence. SchemaBundle gains loadProse() and prose(), the same pair as load(version) and loaded(version), with the same in-flight sharing. ON THE BUNDLE, and not as a free-standing loader, which is the one decision here worth arguing. The indices are only meaningful against manifests built in the same run: build.mjs writes docs.json and the manifests together, and SlimType.m and SlimField.n point into that exact array. A pool paired with manifests from another build resolves every sentence to a real sentence belonging to a different field, and nothing fails. Putting the loader on the bundle that carries the indices makes that pairing hard to reach by accident, where a module-level singleton beside httpSource would make it easy: the browser consumer is precisely the one that supplies its own source. It also answers both runtimes at once. envelop reaches schemas through httpSource today and gets the pool the same way, with nothing node-specific in the path. THE SYNCHRONOUS ACCESSOR IS THE POINT. Every reader of prose is synchronous. describeObjectType takes a pool rather than fetching one, and every answer in @idfkit/language is a pure function, which is what lets an editor server answer a cursor without holding a thread. A consumer calls loadProse once when a document arrives and reads prose() on the request path. With only a promise it would either await inside a path it deliberately keeps synchronous, or build a second cache beside the one SchemaBundle already has. ProsePool moves from @idfkit/core to @idfkit/schemas because schemas cannot import from core and the pool is a schemas artifact. Core re-exports it, so the name a consumer imports is unchanged and the register entry still resolves. Nothing about the parse path moves. The pool stays its own file, loaded by its own call, and check:bundle-purity still reports a minimal read-and-write bundle at 18.3 KB carrying no schema data. A caller who never asks for prose still pays nothing, and describeObjectType with no pool still returns structural facts with the prose undefined rather than text derived from a field name. MEASURED. Over ten common types in 26.1.0: with the pool, 10 of 10 carry a memo and 52 of 103 fields carry a note; without it, none do. The remaining 51 have no note in the schema itself. The install grows 5,657 bytes, from 1,795,857 to 1,801,514 against a budget of 1,835,008. That is 98.2 percent with 32.7 KB of headroom, down from 38.2 KB. The pool itself was already installed; this is the loader. The budget is not moved. --- packages/core/src/index.ts | 4 +- packages/core/src/introspect/describe.ts | 14 +---- packages/schemas/src/index.ts | 56 +++++++++++++++++++- packages/schemas/src/types.ts | 13 +++++ packages/schemas/tests/bundle.test.ts | 65 +++++++++++++++++++++++- 5 files changed, 135 insertions(+), 17 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 60bb660..e40a097 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -59,7 +59,7 @@ export { Severity, validateDocument, validateObject } from './validate/index.js' export type { ValidationError, ValidationResult } from './validate/index.js'; export { describeObjectType } from './introspect/describe.js'; -export type { FieldDescription, ObjectDescription, ProsePool } from './introspect/describe.js'; +export type { FieldDescription, ObjectDescription } from './introspect/describe.js'; export { docsUrlForObject, @@ -70,4 +70,4 @@ export { export type { DocsUrl } from './docs-url/index.js'; export { Schema, SchemaBundle, httpSource } from '@idfkit/schemas'; -export type { BundleSource, SchemaDelta, SlimField, SlimType } from '@idfkit/schemas'; +export type { BundleSource, ProsePool, SchemaDelta, SlimField, SlimType } from '@idfkit/schemas'; diff --git a/packages/core/src/introspect/describe.ts b/packages/core/src/introspect/describe.ts index 43f97a9..8c44c02 100644 --- a/packages/core/src/introspect/describe.ts +++ b/packages/core/src/introspect/describe.ts @@ -1,4 +1,4 @@ -import type { Schema, SlimField, SlimType } from '@idfkit/schemas'; +import type { ProsePool, Schema, SlimField, SlimType } from '@idfkit/schemas'; /** * Description of a single field in an EnergyPlus object type. @@ -105,18 +105,6 @@ export interface ObjectDescription { * Python raises `UnknownObjectTypeError`, which has no registered TypeScript * counterpart and so must not become a new exported class. */ -/** - * The schema's explanatory prose, loaded on demand. - * - * A plain array of strings, indexed by `SlimType.m` and `SlimField.n`. It is - * passed in rather than reached for, and that is deliberate: reading it is - * asynchronous, `describeObjectType` is synchronous, and making the function - * async to fetch a file most callers do not want would be a breaking change - * serving the minority. Loading it is the caller's step, and its cost is - * visible at the call site instead of hidden inside a description. - */ -export type ProsePool = readonly string[]; - export function describeObjectType( schema: Schema, objType: string, diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index dc56da3..9b8910e 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -1,5 +1,5 @@ import { BlobStore, Schema } from './schema.js'; -import type { BundleIndex, Manifest, SlimType } from './types.js'; +import type { BundleIndex, Manifest, ProsePool, SlimType } from './types.js'; export { BlobStore, Schema } from './schema.js'; export type { SchemaDelta } from './schema.js'; @@ -7,6 +7,7 @@ export type { BundleIndex, FieldKind, Manifest, + ProsePool, SlimExtensible, SlimField, SlimType, @@ -82,6 +83,9 @@ export class SchemaBundle { #schemas = new Map(); /** In-flight loads, so concurrent callers share one request. */ #pending = new Map>(); + #prose: ProsePool | undefined; + /** In-flight prose read, on the same terms as `#pending`. */ + #proseInFlight: Promise | undefined; constructor(source: BundleSource) { this.#source = source; @@ -122,6 +126,56 @@ export class SchemaBundle { return this.#schemas.get(version); } + /** + * Load the explanatory prose this bundle's manifests index into. + * + * One pool serves every version: `build.mjs` deduplicates the memos and notes + * of all seventeen schemas into a single array and the manifests carry indices + * into it, so there is no version to pass here. + * + * It is a separate call rather than part of `load` because the two are wanted + * at different moments by different callers. Parsing a model needs the records + * and never needs a sentence of English; describing a type for a reader needs + * both. Keeping the fetch separate is what lets the parse path stay unaware the + * pool exists, which `check-bundle-purity.mjs` asserts by building a minimal + * read-and-write graph and failing on any input under `data/`. + * + * Repeat calls return the same array; concurrent calls share one read. + */ + async loadProse(): Promise { + if (this.#prose !== undefined) return this.#prose; + + const inFlight = this.#proseInFlight; + if (inFlight !== undefined) return inFlight; + + const promise = this.#loadProse().finally(() => { + this.#proseInFlight = undefined; + }); + this.#proseInFlight = promise; + return promise; + } + + /** + * The prose pool if it has been loaded, or undefined. Synchronous by design. + * + * The counterpart of `loaded`, and it exists for the same reason. Everything + * that reads prose is synchronous: `describeObjectType` takes a pool rather + * than fetching one, and every answer in `@idfkit/language` is a pure function + * so an editor server can answer a cursor without holding a thread. A consumer + * calls `loadProse` once when a document arrives and reads this on the request + * path. Without the pair, a caller would either await inside a path it keeps + * synchronous or build a second cache beside the one this class already has. + */ + prose(): ProsePool | undefined { + return this.#prose; + } + + async #loadProse(): Promise { + const pool = (await this.#source.read('docs.json')) as ProsePool; + this.#prose = pool; + return pool; + } + async #load(version: string): Promise { const index = await this.#loadIndex(); const fileName = index.manifests[version]; diff --git a/packages/schemas/src/types.ts b/packages/schemas/src/types.ts index 4258e5c..9cd7dc1 100644 --- a/packages/schemas/src/types.ts +++ b/packages/schemas/src/types.ts @@ -160,3 +160,16 @@ export interface BundleIndex { /** Per-version manifest file names, keyed by version string. */ manifests: Record; } + +/** + * The schema's explanatory prose, deduplicated across every bundled version. + * + * A plain array of strings, indexed by `SlimType.m` and `SlimField.n`. It lives + * here rather than beside the code that reads it because THE INDICES ARE ONLY + * MEANINGFUL AGAINST THE MANIFESTS BUILT IN THE SAME RUN: `build.mjs` writes + * `docs.json` and the manifests together, and a pool paired with manifests from + * a different build resolves every sentence to the wrong one, silently. Keeping + * the type and the loader on `SchemaBundle` is what makes that pairing hard to + * get wrong. + */ +export type ProsePool = readonly string[]; diff --git a/packages/schemas/tests/bundle.test.ts b/packages/schemas/tests/bundle.test.ts index a549dbb..d4bed77 100644 --- a/packages/schemas/tests/bundle.test.ts +++ b/packages/schemas/tests/bundle.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { httpSource } from '@idfkit/schemas'; +import { httpSource, SchemaBundle } from '@idfkit/schemas'; import { localBundle, nodeSource } from '@idfkit/schemas/node'; const bundle = localBundle(); @@ -331,3 +331,66 @@ describe('field order and accepted values in the bundle', () => { expect(flagged.every((f) => !(f.e ?? []).includes(''))).toBe(true); }); }); + +/** + * Reaching the pool through the bundle that indexes into it. + * + * The pool is only meaningful against manifests built in the same run, because + * `build.mjs` writes `docs.json` and the manifests together and the indices point + * into that exact array. Putting the loader on `SchemaBundle` is what keeps the + * two together: a consumer cannot pair this pool with somebody else's manifests + * without going out of its way. + */ +describe('SchemaBundle prose', () => { + it('loads the pool and then serves it synchronously', async () => { + const own = localBundle(); + + // Nothing before the load, and no throw for asking. + expect(own.prose()).toBeUndefined(); + + const loaded = await own.loadProse(); + expect(Array.isArray(loaded)).toBe(true); + expect(loaded.length).toBeGreaterThan(4000); + + // The synchronous accessor is the point: every reader of prose is sync, so a + // consumer loads once when a document arrives and reads on the request path. + expect(own.prose()).toBe(loaded); + }); + + it('returns the same array on repeat calls and shares one read', async () => { + const own = localBundle(); + + // Concurrent callers must not each fetch. Both promises resolve to one array. + const [a, b] = await Promise.all([own.loadProse(), own.loadProse()]); + expect(a).toBe(b); + expect(await own.loadProse()).toBe(a); + }); + + it('reads docs.json and nothing else', async () => { + const read = vi.fn(async (name: string) => nodeSource().read(name)); + const own = new SchemaBundle({ read }); + + await own.loadProse(); + + // Not index.json, not types.json, not a manifest: the pool is one file and + // loading it must not drag the parse path's inputs in behind it. + expect(read.mock.calls.map(([name]) => name)).toEqual(['docs.json']); + }); + + it('indexes what the manifests point at', async () => { + const own = localBundle(); + const schema = await own.load('26.1.0'); + const pool = await own.loadProse(); + + // A pool that is present but wrongly indexed produces prose that looks + // plausible and belongs to another field, so this checks the join rather + // than the array. + const type = schema.require('BuildingSurface:Detailed'); + expect(type.m).toBeDefined(); + expect(pool[type.m!]).toContain('heat transfer surfaces'); + + const field = schema.field('BuildingSurface:Detailed', 'construction_name'); + expect(field?.n).toBeDefined(); + expect(typeof pool[field!.n!]).toBe('string'); + }); +}); From 207c6c6b809720834388e4366d6a3acd33e26491 Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 4 Sep 2026 16:02:20 -0400 Subject: [PATCH 2/2] Adopt governance-2026.12 The level carrying the register entries for SchemaBundle.loadProse and SchemaBundle.prose, the two names this branch adds. conformance-2026.8 does not move: no corpus case changes. --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index c6a3518..c5a6255 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,7 +43,7 @@ }, "idfkit": { "conformance": "conformance-2026.8", - "governance": "governance-2026.11" + "governance": "governance-2026.12" }, "dependencies": { "@idfkit/schemas": "0.0.0"