diff --git a/packages/spec/scripts/lib/schema-section.ts b/packages/spec/scripts/lib/schema-section.ts index 37f2f31bb8c..a92ba895b1f 100644 --- a/packages/spec/scripts/lib/schema-section.ts +++ b/packages/spec/scripts/lib/schema-section.ts @@ -259,6 +259,42 @@ function externalVocabularyNote(prop: any): string { return ` (unit per ${standard.trim()})`; } +/** + * The published half of the `dimensionless` exemption (#15676, ruling B on + * #14478) — the sibling of {@link externalVocabularyNote}, and one grammar with + * it rather than a second one. + * + * A key that carries `.meta({ dimensionless: '' })` is a count, + * a multiplier or a ratio whose describe prose happens to name a time unit + * belonging to something else in the sentence: `Failures seen in the last 5 + * minutes` is a number of failures, not a number of minutes. The marker rides + * `z.toJSONSchema` verbatim, the same channel `externalVocabulary` / `xRef` / + * `xExpression` / `xEnumDeprecated` use, so it arrives here as a property of + * the JSON-Schema node. + * + * Printing it is what makes THIS exemption honest, on exactly the argument its + * sibling rests on. `check:duration-unit-keys` exists because a naked number + * beside prose naming a unit leaves the reader guessing; the rename is waived + * here because the number HAS no unit, and that reason is invisible on the + * page unless the page says it. A key exempted silently publishes the same + * guess the gate was built to remove — with the marker printed, the answer is + * stated: no unit, and here is what it counts instead. + * + * Both halves read the SAME declaration the gate reads — a non-empty string + * literal — so an empty or non-string marker, which exempts no key there, + * publishes nothing here. The page must never name a count the contract did + * not. + * + * Appended to the description cell for the reason its sibling is: it qualifies + * the prose already in that cell, and a marker on a handful of keys does not + * earn a column on every table in the reference. + */ +function dimensionlessNote(prop: any): string { + const counts = prop?.dimensionless; + if (typeof counts !== 'string' || counts.trim() === '') return ''; + return ` (dimensionless — counts ${counts.trim()})`; +} + /** * Render one schema's section, heading included. * @@ -465,7 +501,9 @@ export function renderSchemaSection(schemaName: string, schema: any, ctx: Sectio // pipe), then pipes — an unescaped `|` (even inside a code span) // splits the cell. const desc = escapeMdxDescription( - ((prop.description || '') + externalVocabularyNote(prop)).replace(/\n/g, ' '), + ((prop.description || '') + + externalVocabularyNote(prop) + + dimensionlessNote(prop)).replace(/\n/g, ' '), ) .replace(/\\/g, '\\\\') .replace(/\|/g, '\\|'); diff --git a/packages/spec/scripts/schema-section.test.ts b/packages/spec/scripts/schema-section.test.ts index 6b58e0c3f80..9773556ca85 100644 --- a/packages/spec/scripts/schema-section.test.ts +++ b/packages/spec/scripts/schema-section.test.ts @@ -657,3 +657,118 @@ describe('externalVocabulary — the published half of the duration-rule exempti } }); }); + +/** + * [#18500] The PUBLISHED half of the `dimensionless` exemption — the sibling of + * the block above, ruling B on #14478: the marker is one the gate honours AND + * the docs generator publishes. `check:duration-unit-keys` shipped the reader + * first; this is the other half. + * + * The argument is its sibling's, one step further. A bare `maxAge` leaves the + * reference-page reader guessing seconds from milliseconds; a bare + * `recentFailures` described as `Failures seen in the last 5 minutes` leaves + * them guessing whether the number IS that span. The marker says it is not — + * it counts failed attempts — and that reason reaches the page only if the + * page prints it. + * + * The marker reaches this renderer as a property of the JSON-Schema node, + * riding `z.toJSONSchema` verbatim (measured on zod 4.4.3 against + * `z.number().meta({ dimensionless: 'failed attempts' })`: the key arrives on + * the emitted node unchanged) — the same channel `externalVocabulary` / `xRef` + * / `xExpression` / `xEnumDeprecated` use. + * + * MEASURED (reverse verification): deleting the `dimensionlessNote(prop)` term + * from the description cell turns the first four cases below red and leaves the + * last two green — the last two assert the note's ABSENCE, which is what keeps + * it from decorating every row in the reference. + */ +describe('dimensionless — the published half of the duration-rule exemption', () => { + const withMarker = (marker: unknown) => ({ + type: 'object', + properties: { + recentFailures: { + type: 'number', + description: 'Failures seen in the last 5 minutes', + ...(marker === undefined ? {} : { dimensionless: marker }), + }, + }, + }); + + it('prints what the number counts, beside the prose whose unit belongs to something else', () => { + const md = renderSchemaSection('HealthSignal', withMarker('failed attempts')); + + expect(md).toContain( + 'Failures seen in the last 5 minutes (dimensionless — counts failed attempts)', + ); + }); + + it('keeps the describe prose — the note QUALIFIES the number, it does not replace it', () => { + const md = renderSchemaSection('BackoffPolicy', withMarker('retry attempts')); + + expect(md).toContain('Failures seen in the last 5 minutes'); + expect(md).toContain('(dimensionless — counts retry attempts)'); + }); + + it('renders inside a nested shape table too — one grammar, not two', () => { + const md = renderSchemaSection('RetryConfig', { + type: 'object', + properties: { + backoff: { + type: 'object', + description: 'Backoff options', + properties: { + multiplier: { + type: 'number', + description: 'Growth applied to the previous 30 second delay', + dimensionless: 'the factor each delay is multiplied by', + }, + }, + }, + }, + }); + + expect(md).toContain( + 'Growth applied to the previous 30 second delay (dimensionless — counts the factor each delay is multiplied by)', + ); + }); + + it('composes with the sibling marker — a key declaring both publishes both reasons', () => { + // Nothing in the gate makes the two exemptions exclusive, so the cell is + // built by appending both notes rather than choosing between them. Pinned + // because a renderer that picked one would look identical on every key that + // carries only one. + const md = renderSchemaSection('CacheStats', { + type: 'object', + properties: { + maxAge: { + type: 'number', + description: 'Entries seen in the last 60 seconds', + externalVocabulary: 'HTTP Cache-Control `max-age`', + dimensionless: 'cached entries', + }, + }, + }); + + expect(md).toContain( + 'Entries seen in the last 60 seconds (unit per HTTP Cache-Control `max-age`) (dimensionless — counts cached entries)', + ); + }); + + it('prints nothing for a key that declares no marker — the note is not decoration', () => { + const md = renderSchemaSection('HealthSignal', withMarker(undefined)); + + expect(md).toContain('Failures seen in the last 5 minutes'); + expect(md).not.toContain('dimensionless'); + }); + + it('prints nothing for an empty or non-string marker — an unverifiable claim publishes nothing', () => { + // The gate refuses these too (they exempt no key), so the page must not + // name a count the contract never declared. Held on the SAME inputs from + // both sides so the two halves cannot drift into disagreeing about what + // counts as a declaration. + for (const marker of ['', ' ', 42, null, { counts: 'failed attempts' }]) { + const md = renderSchemaSection('HealthSignal', withMarker(marker)); + expect(md, `marker ${JSON.stringify(marker)}`).not.toContain('dimensionless'); + } + }); +});