diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index d59f2e1fe8..010cdaea39 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -46,6 +46,12 @@ import { type RootIndexCategory, } from './lib/root-index'; import { rootCategoryDirs } from './lib/root-meta'; +import { + formatSchemaClosureExemptionCoverage, + schemaClosureAbsenceIsDeclared, + schemaClosureExemptionCoverage, + schemaClosureExemptionsAreClean, +} from './lib/schema-closure'; import { buildSchemaIndex, formatConflicts, @@ -313,13 +319,24 @@ function schemaHrefFrom(fromCategory: string): (name: string) => string | null { */ function groupSchemasByPage(): Map>> { const byCategory = new Map>>(); + /** Fed to the exemption coverage check below — never a second walk of the tree. */ + const categoriesWithSchemaDir: string[] = []; for (const category of Object.keys(CATEGORIES)) { const categorySchemaDir = path.join(SCHEMA_DIR, category); if (!fs.existsSync(categorySchemaDir)) { - console.log(`Warning: Schema directory ${categorySchemaDir} does not exist`); + // Absent AND undeclared is the reading this warning exists to deliver, so + // it still prints. Absent and declared is `CATEGORIES_WITHOUT_SCHEMA_CLOSURE` + // — the exemption is a hand-signed claim about design carrying its + // citation, not "the Protocol map in build-schemas.ts happens to omit it", + // which would make a category dropped from that map by accident exempt + // itself from the very check that would have caught it (#15870). + if (!schemaClosureAbsenceIsDeclared(category)) { + console.log(`Warning: Schema directory ${categorySchemaDir} does not exist`); + } continue; } + categoriesWithSchemaDir.push(category); const pages = new Map>(); for (const file of fs.readdirSync(categorySchemaDir).filter(f => f.endsWith('.json'))) { @@ -339,6 +356,18 @@ function groupSchemasByPage(): Map/`. That tree is written by `gen:schema`, which + * iterates the hard-coded `Protocol` namespace map in `build-schemas.ts` and + * `ensureDir`s one directory per entry. Measured on this tree: 18 module + * directories, 15 `Protocol` entries, so three categories have no directory at + * all and `groupSchemasByPage` warned about each of them on EVERY run. + * + * `contracts` is the shape that does not warn and is worth stating, because it + * is the one people reach for as the counter-example: it IS on the `Protocol` + * map, so it gets `json-schema/contracts/` — empty, because its service + * interfaces are plain TypeScript rather than Zod. Present-and-empty and + * absent-entirely are different states and only the second one warns. + * + * ## Why an unconditional warning is worth removing + * + * A warning that fires for an intended condition on every run is a warning + * readers learn to skim, which is how a real one later gets skimmed too. The + * channel is only worth having if a line in it means something happened. + * + * ## Why a DECLARED list and not "absent from the Protocol map" + * + * Reading the exemption off `build-schemas.ts`'s map would make the warning + * unfalsifiable: a category dropped from that map by accident would be + * self-exempting, which is precisely the case the warning exists to catch. So + * the exemption is declared here, by hand, one entry at a time, each carrying + * the citation that declares it — the same idiom `CATEGORY_TITLES`, + * `FOREIGN_JSON_SCHEMA_ARTIFACTS` and `browser-reachable-entries.json` already + * use one datum over: a promotion into this map is a judgement someone makes + * and signs, never something a generator can grant itself. + * + * A category that is genuinely, unintentionally missing is not in this map, so + * it still warns — pinned by `schema-closure.test.ts`, both directions. + */ + +/** + * Category directory -> the citation in this repo that declares it ships no + * schema closure. + * + * ⛔ An entry here is a claim about DESIGN, so it is added only when the tree + * already says so somewhere a reader can check. Do not add one because a + * directory happens to be missing today — that is the state the warning + * reports, and silencing it without a citation converts a report into a guess. + * + * ⛔ And never satisfy the warning by creating an empty `json-schema//`: + * an empty directory claims a closure it does not have, which is worse than + * the noise it removes. + * + * ## What is deliberately NOT here (measured on this tree, #15870) + * + * `conversions` and `migrations` are the other two categories with no schema + * directory, and **neither carries an equivalent declaration**. A repo-wide + * search for the declaration phrasings (`schema closure`, `schema-free`, + * `no JSON Schema`, `without the schema machinery`) returns the `meta-spelling` + * citations below and nothing for either of them; the only text that mentions + * their missing directory at all is a comment in `build-docs.ts` §2 recording + * it as an asymmetry that comment's guard deliberately does NOT act on. Both + * are titled `... Protocol` in `CATEGORY_TITLES` — the same word every + * category WITH a schema closure is titled with — where `meta-spelling` is + * titled `Meta-Spelling Vocabulary` for exactly this reason. + * + * `migrations` is the further one from an exemption, not the nearer: its + * `spec-changes.ts` exports five real Zod schemas, so "no schema closure" is + * not even factually true of it. What is true is that it is not on the + * `Protocol` map — which is a fact about a generator's input list, not a + * declaration about the module. + * + * So their warnings still fire, and that is the deliberate outcome: whether + * either is intentional is an open question this fix does not answer, and a + * silenced warning would close it by default in the wrong direction. + */ +export const CATEGORIES_WITHOUT_SCHEMA_CLOSURE: Readonly> = { + 'meta-spelling': + 'scripts/build-meta-url-spelling.ts — "`/meta-spelling` entry ships vocabulary with no schema closure." ' + + 'Also src/meta-spelling/manifest-collection-spelling.ts ("no schema closure on the vocabulary path"), ' + + 'scripts/lib/category-title.ts (titled "Vocabulary", not "Protocol", because the entry "folds without ' + + 'the schema machinery every Protocol category links"), and browser-reachable-entries.json, which ' + + 'declares `./meta-spelling` browser-reachable under the standing schema-free principle.', +}; + +/** + * Is this category's missing `json-schema//` a DECLARED state? + * + * The one predicate `build-docs.ts` branches its warning on, exported so both + * of its answers can be pinned directly. A unit test over it is not sufficient + * on its own — the caller could stop asking — so `build-docs.ts` calling it is + * pinned end-to-end as well; neither half replaces the other. + */ +export function schemaClosureAbsenceIsDeclared( + category: string, + exempt: Readonly> = CATEGORIES_WITHOUT_SCHEMA_CLOSURE, +): boolean { + return Object.hasOwn(exempt, category); +} + +/** Directories whose exemption no longer describes the tree. */ +export interface SchemaClosureExemptionCoverage { + /** + * Declared exempt, and `json-schema//` is now there. The + * declaration outlived the condition: either the category grew a schema + * closure, or the citation was never true. + */ + stale: string[]; + /** + * Declared exempt, and no such module directory exists. The declaration + * outlived the module — the `src/hub` failure `categoryTitleCoverage` + * guards against, one datum over. + */ + orphaned: string[]; +} + +/** + * Place the declared exemptions against the tree, in BOTH directions. + * + * Pure over its inputs so the self-test drives every branch offline. The + * direction that is NOT reported here is the interesting one: a category that + * is absent and undeclared is not an error, it is the warning's whole job, and + * `build-docs.ts` prints it rather than failing. + */ +export function schemaClosureExemptionCoverage( + allCategories: readonly string[], + categoriesWithSchemaDir: readonly string[], + exempt: Readonly> = CATEGORIES_WITHOUT_SCHEMA_CLOSURE, +): SchemaClosureExemptionCoverage { + const onDisk = new Set(allCategories); + const withSchemaDir = new Set(categoriesWithSchemaDir); + const declared = Object.keys(exempt).sort(); + + return { + stale: declared.filter((c) => withSchemaDir.has(c)), + orphaned: declared.filter((c) => !onDisk.has(c)), + }; +} + +/** True when {@link schemaClosureExemptionCoverage} found nothing to report. */ +export function schemaClosureExemptionsAreClean(coverage: SchemaClosureExemptionCoverage): boolean { + return coverage.stale.length === 0 && coverage.orphaned.length === 0; +} + +/** + * The build-stopping message for {@link schemaClosureExemptionCoverage}. + * + * Names the entry, the file holding it, and what to do — a message that only + * said "exemption mismatch" would leave the reader to work out which of the + * two directions fired and which way to edit. + */ +export function formatSchemaClosureExemptionCoverage(coverage: SchemaClosureExemptionCoverage): string { + return ( + `CATEGORIES_WITHOUT_SCHEMA_CLOSURE in scripts/lib/schema-closure.ts no longer describes this tree:\n\n` + + [ + ...coverage.stale.map( + (c) => + ` - ${c} (declared to ship no schema closure, but json-schema/${c}/ now exists — ` + + `delete the entry so the category is checked like every other one)`, + ), + ...coverage.orphaned.map( + (c) => ` - ${c} (declared, but packages/spec/src/${c}/ is gone — delete the entry)`, + ), + ].join('\n') + + `\n\nThe map exists so that a category with no json-schema/ directory stops warning ONLY when the\n` + + `tree says its absence is intended. An entry that no longer matches the tree silences a reading\n` + + `nobody decided to silence, which is the failure this map was added to remove rather than move.\n` + ); +} diff --git a/packages/spec/scripts/schema-closure.test.ts b/packages/spec/scripts/schema-closure.test.ts new file mode 100644 index 0000000000..5e77a035a9 --- /dev/null +++ b/packages/spec/scripts/schema-closure.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Unit pins for the schema-closure exemption (#15870): `gen:docs` stops warning +// about `json-schema//` ONLY where the tree declares the absence is +// intended, and keeps warning everywhere else. +// +// These run in milliseconds over pure functions and over source text. The last +// describe block is the CALLER half, and it is here for the reason +// `json-schema-out-dir.test.ts` states about its own pair: a unit test over an +// extracted helper stays green forever if the caller stops calling it. +// +// ⚠️ That caller half is a SOURCE-TEXT pin, and what it cannot see is stated +// rather than left to be discovered: it proves the warning's only emission site +// is behind the predicate, NOT that a run of the generator behaves that way. A +// spawned end-to-end run was weighed and rejected — `build-docs.ts` resolves +// `json-schema/`, `api-surface/` and the repo's `content/docs/references/` from +// its own `__dirname`, so driving it takes the whole sandbox +// `build-schemas-check-mode.test.ts` builds, for one console line. The +// behavioural reading was taken once, by hand, at the change: with +// `json-schema/data/` moved aside, `gen:docs` printed the warning for `data` +// and still printed nothing for `meta-spelling`. + +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + CATEGORIES_WITHOUT_SCHEMA_CLOSURE, + formatSchemaClosureExemptionCoverage, + schemaClosureAbsenceIsDeclared, + schemaClosureExemptionCoverage, + schemaClosureExemptionsAreClean, +} from './lib/schema-closure'; +import { CATEGORY_TITLES } from './lib/category-title'; + +/** This package's root — every read below stays inside it. */ +const PKG_DIR = path.resolve(__dirname, '..'); +const read = (rel: string) => fs.readFileSync(path.join(PKG_DIR, rel), 'utf-8'); + +describe('CATEGORIES_WITHOUT_SCHEMA_CLOSURE — the declared list', () => { + it('declares meta-spelling, and nothing else', () => { + // The boundary is the finding, not an implementation detail: `conversions` + // and `migrations` are the OTHER two categories with no json-schema + // directory, and a search of this tree turns up no equivalent declaration + // for either. Pinned so that adding one is a decision someone writes down + // rather than a line that slips in beside a refactor. + expect(Object.keys(CATEGORIES_WITHOUT_SCHEMA_CLOSURE)).toEqual(['meta-spelling']); + }); + + it('leaves conversions and migrations warning — the un-silenced half', () => { + expect(schemaClosureAbsenceIsDeclared('conversions')).toBe(false); + expect(schemaClosureAbsenceIsDeclared('migrations')).toBe(false); + expect(schemaClosureAbsenceIsDeclared('meta-spelling')).toBe(true); + }); + + it('says no for a category nobody has heard of', () => { + // The default answer for anything unlisted, which is what makes a category + // that goes missing by accident visible instead of self-exempting. + expect(schemaClosureAbsenceIsDeclared('iam')).toBe(false); + expect(schemaClosureAbsenceIsDeclared('constructor')).toBe(false); + expect(schemaClosureAbsenceIsDeclared('toString')).toBe(false); + }); + + it('names a real citation for every entry, and the cited file really says it', () => { + // A citation nobody checked is the shape this map exists to replace. The + // control that makes each assertion a reading rather than a tautology: the + // phrase is grepped out of the cited file, so deleting the declaration + // upstream turns this red instead of leaving a dangling reference. + expect(CATEGORIES_WITHOUT_SCHEMA_CLOSURE['meta-spelling']).toContain( + 'scripts/build-meta-url-spelling.ts', + ); + expect(read('scripts/build-meta-url-spelling.ts')).toContain( + '`/meta-spelling` entry ships vocabulary with no schema closure.', + ); + expect(read('src/meta-spelling/manifest-collection-spelling.ts')).toContain( + 'no schema closure on the vocabulary path', + ); + expect(JSON.parse(read('browser-reachable-entries.json')).browserReachable).toHaveProperty( + './meta-spelling', + ); + }); + + it('is corroborated by the declared TITLES — Vocabulary, not Protocol', () => { + // `CATEGORY_TITLES` is an independent, hand-declared surface, and it draws + // the same boundary: the schema-free entry is titled "Vocabulary" while the + // two undeclared ones carry the same word every category WITH a schema + // closure carries. That agreement is why one entry is defensible and the + // other two are not. + expect(CATEGORY_TITLES['meta-spelling']).toBe('Meta-Spelling Vocabulary'); + expect(CATEGORY_TITLES['meta-spelling']).not.toContain('Protocol'); + expect(CATEGORY_TITLES.conversions).toContain('Protocol'); + expect(CATEGORY_TITLES.migrations).toContain('Protocol'); + }); +}); + +describe('schemaClosureExemptionCoverage — an exemption may not outlive its condition', () => { + const declared = { 'meta-spelling': 'because the tree says so' }; + const categories = ['data', 'meta-spelling', 'ui']; + + it('is clean when the declared category is on disk with no schema directory', () => { + const coverage = schemaClosureExemptionCoverage(categories, ['data', 'ui'], declared); + + expect(coverage).toEqual({ stale: [], orphaned: [] }); + expect(schemaClosureExemptionsAreClean(coverage)).toBe(true); + }); + + it('reports an exemption whose json-schema directory has appeared', () => { + // The category grew a closure, or the citation was never true. Either way + // the exemption now hides a real reading, so it has to go. + const coverage = schemaClosureExemptionCoverage(categories, ['data', 'meta-spelling', 'ui'], declared); + + expect(coverage.stale).toEqual(['meta-spelling']); + expect(coverage.orphaned).toEqual([]); + expect(schemaClosureExemptionsAreClean(coverage)).toBe(false); + }); + + it('reports an exemption whose module directory is gone', () => { + const coverage = schemaClosureExemptionCoverage(['data', 'ui'], ['data', 'ui'], declared); + + expect(coverage.stale).toEqual([]); + expect(coverage.orphaned).toEqual(['meta-spelling']); + expect(schemaClosureExemptionsAreClean(coverage)).toBe(false); + }); + + it('does NOT report an absent, undeclared category — that is the warning, not an error', () => { + // The direction deliberately left out. `conversions` here is absent from + // the schema-dir list and absent from `declared`, and the coverage check + // stays silent so `build-docs.ts` can print its warning instead of exiting. + const coverage = schemaClosureExemptionCoverage( + ['conversions', 'data', 'meta-spelling'], + ['data'], + declared, + ); + + expect(coverage).toEqual({ stale: [], orphaned: [] }); + expect(schemaClosureExemptionsAreClean(coverage)).toBe(true); + }); + + it('names the entry, the file to edit, and which direction fired', () => { + const stale = formatSchemaClosureExemptionCoverage({ stale: ['meta-spelling'], orphaned: [] }); + expect(stale).toContain('meta-spelling'); + expect(stale).toContain('scripts/lib/schema-closure.ts'); + expect(stale).toContain('json-schema/meta-spelling/ now exists'); + + const orphaned = formatSchemaClosureExemptionCoverage({ stale: [], orphaned: ['hub'] }); + expect(orphaned).toContain('packages/spec/src/hub/ is gone'); + }); +}); + +describe('the caller — build-docs.ts, where the predicate has to be asked', () => { + const BUILD_DOCS = read('scripts/build-docs.ts'); + + it('emits the warning from exactly one place', () => { + // Everything below is a claim about THAT site. A second emission would make + // the pin describe half the behaviour while reading fully green. + const sites = BUILD_DOCS.match(/Warning: Schema directory/g) ?? []; + expect(sites).toHaveLength(1); + }); + + it('guards it with the predicate, so an undeclared absence still warns', () => { + expect(BUILD_DOCS).toContain("from './lib/schema-closure'"); + expect(BUILD_DOCS).toMatch( + /if \(!schemaClosureAbsenceIsDeclared\(category\)\) \{\s*\n\s*console\.log\(`Warning: Schema directory/, + ); + }); + + it('runs the both-directions coverage check on the same walk', () => { + // The exemption is only safe because it expires: without this call a + // declaration would outlive its condition silently, which is the failure + // the map replaces rather than the one it introduces. + expect(BUILD_DOCS).toContain('schemaClosureExemptionCoverage(Object.keys(CATEGORIES), categoriesWithSchemaDir)'); + expect(BUILD_DOCS).toContain('schemaClosureExemptionsAreClean(exemptions)'); + }); +});