From 0f03edc8ef0c40e410d3973a08b16d251f4ddf8b Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 14 Sep 2026 16:16:49 +0200 Subject: [PATCH 1/3] 11b: the gate --- src/adf-schema.test.ts | 153 ++++++++++++++++++++++++++ src/{spec.test.ts => flavour.test.ts} | 0 2 files changed, 153 insertions(+) rename src/{spec.test.ts => flavour.test.ts} (100%) diff --git a/src/adf-schema.test.ts b/src/adf-schema.test.ts index 515f864..339d5d8 100644 --- a/src/adf-schema.test.ts +++ b/src/adf-schema.test.ts @@ -5,10 +5,163 @@ import { dirname, join } from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' +import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts' +import { blockDirectives } from './adf/block-directives.ts' +import { inlineDirectives } from './adf/inline-directives.ts' +import { markAttributes } from './adf/mark-attributes.ts' +import { blockArgument } from './markdown/block-directive-arguments.ts' + +type Held = Map> +type Properties = Map +type SchemaObject = Readonly> +type Spelled = [string, Map] + +const carried = ['alignment', 'annotation', 'backgroundColor', 'blockCard', 'bodiedRule', 'breakout', 'dataConsumer', 'embedCard', 'fontSize', 'fragment', 'indentation', 'inlineExtension', 'placeholder'] +const definitionReference = '#/definitions/' +const gaps = new Map(Object.entries({ layoutSection: ['columnRuleStyle'], link: ['collection', 'id', 'occurrenceKey'], rule: ['color', 'style', 'weight'] })) +const grammarOwn = ['doc', 'text'] const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'spec', 'adf-schema') +const schemaFiles = ['full.json', 'stage-0.json'] test('the ADF JSON Schemas are @atlaskit/adf-schema 57.4.9, vendored byte-exact', () => { const digest = (name: string) => createHash('sha256').update(readFileSync(join(root, name))).digest('hex') assert.equal(digest('full.json'), '75f080928a970250eb8289e9cae5374e3c2a6c0ac3ca22478acaa9d3f39484a3') assert.equal(digest('stage-0.json'), '56747e5a71c0d5f8c58f94180d69e4482f62c08020d66aaf327333681fcc1c8f') }) + +test('the tables spell the attribute names and kinds the ADF JSON Schemas give each type they spell, the pinned gaps apart', () => { + const held = schemaTypes() + const spelledTypes = spelled() + const found: string[] = [] + for (const [type, spelledKinds] of spelledTypes) { + const kinds = held.get(type) + if (kinds === undefined) { + found.push(`${type}: the tables spell the type, the schema holds no definition of it`) + continue + } + const pinned = gaps.get(type) ?? [] + for (const [attribute, schemaKinds] of kinds) { + const kind = spelledKinds.get(attribute) + const holds = [...schemaKinds].sort().join(' or ') + if (kind === undefined && !pinned.includes(attribute)) found.push(`${type}.${attribute}: the schema holds ${holds}, the tables spell nothing and the gaps list does not name it`) + if (kind !== undefined && pinned.includes(attribute)) found.push(`${type}.${attribute}: the tables spell ${kind}, and the gaps list still names it`) + if (kind !== undefined && (schemaKinds.size !== 1 || !schemaKinds.has(kind))) found.push(`${type}.${attribute}: the tables spell ${kind}, the schema holds ${holds}`) + } + for (const [attribute, kind] of spelledKinds) if (!kinds.has(attribute)) found.push(`${type}.${attribute}: the tables spell ${kind}, the schema holds nothing`) + for (const attribute of pinned) if (!kinds.has(attribute)) found.push(`${type}.${attribute}: the gaps list names it, the schema holds nothing`) + } + const spelledNames = new Set(spelledTypes.map(([type]) => type)) + for (const type of gaps.keys()) if (!spelledNames.has(type)) found.push(`${type}: the gaps list names the type, the tables spell none of it`) + assert.deepEqual(found, []) +}) + +test("the ADF JSON Schemas hold no type the tables leave unspelled, the pinned carried ones and the grammar's own apart", () => { + const held = schemaTypes() + const spelledNames = new Set(spelled().map(([type]) => type)) + const found: string[] = [] + for (const type of held.keys()) { + if (!spelledNames.has(type) && !carried.includes(type) && !grammarOwn.includes(type)) found.push(`${type}: the schema holds the type, the tables spell none of it and the carried list does not name it`) + } + for (const type of carried) { + if (!held.has(type)) found.push(`${type}: the carried list names the type, the schema holds no definition of it`) + if (spelledNames.has(type)) found.push(`${type}: the carried list names the type, and the tables spell it`) + } + assert.deepEqual(found, []) +}) + +function spelled(): Spelled[] { + return [ + ...Object.entries(blockDirectives).map(([type, entry]) => spelledType(type, entry.attributes, blockArgument(type))), + ...Object.entries(inlineDirectives).map(([type, entry]) => spelledType(type, entry.attributes)), + ...Object.entries(markAttributes).map(([type, attributes]) => spelledType(type, attributes)), + ] +} + +function spelledType(type: string, attributes: AttributeVocabulary, argument?: string): Spelled { + const kinds = new Map(Object.entries(attributes)) + if (argument !== undefined) kinds.set(argument, 'string') + return [type, kinds] +} + +function schemaTypes(): Map { + const held = new Map() + for (const file of schemaFiles) { + const definitions = schemaObject(schemaObject(JSON.parse(readFileSync(join(root, file), 'utf8')), file)['definitions'], `${file} definitions`) + for (const [name, definition] of Object.entries(definitions)) { + const where = `${file} ${name}` + for (const properties of alternatives(schemaObject(definition, where), definitions, where)) { + const attributes = (properties.get('attrs') ?? []).flatMap((attrs) => alternatives(attrs, definitions, `${where} attrs`)).flatMap((alternative) => [...alternative]) + for (const type of (properties.get('type') ?? []).flatMap(enumStrings)) { + const kinds = held.get(type) ?? new Map>() + held.set(type, kinds) + for (const [attribute, schemas] of attributes) { + const attributeKinds = kinds.get(attribute) ?? new Set() + kinds.set(attribute, attributeKinds) + for (const schema of schemas) for (const kind of propertyKinds(schema, `${where} ${type}.${attribute}`)) attributeKinds.add(kind) + } + } + } + } + } + return held +} + +function alternatives(schema: SchemaObject, definitions: SchemaObject, where: string): Properties[] { + const own = Object.entries(schemaObject(schema['properties'] ?? {}, `${where} properties`)) + let found: Properties[] = [new Map(own.map(([name, property]): [string, SchemaObject[]] => [name, [schemaObject(property, `${where} ${name}`)]]))] + if (schema['$ref'] !== undefined) found = intersect(found, alternatives(referenced(schema['$ref'], definitions, where), definitions, `${where} $ref`)) + for (const branch of branches(schema['allOf'], `${where} allOf`)) found = intersect(found, alternatives(branch, definitions, `${where} allOf`)) + if (schema['anyOf'] !== undefined) found = intersect(found, branches(schema['anyOf'], `${where} anyOf`).flatMap((branch) => alternatives(branch, definitions, `${where} anyOf`))) + return found +} + +function intersect(left: readonly Properties[], right: readonly Properties[]): Properties[] { + return left.flatMap((own) => + right.map((other) => { + const merged = new Map(own) + for (const [name, schemas] of other) merged.set(name, [...(merged.get(name) ?? []), ...schemas]) + return merged + }), + ) +} + +function referenced(reference: unknown, definitions: SchemaObject, where: string): SchemaObject { + const name = typeof reference === 'string' && reference.startsWith(definitionReference) ? reference.slice(definitionReference.length) : undefined + return schemaObject(name === undefined ? undefined : definitions[name], `${where}: the reference ${String(reference)}`) +} + +function branches(value: unknown, where: string): SchemaObject[] { + if (value === undefined) return [] + return Array.isArray(value) ? value.map((branch, index) => schemaObject(branch, `${where} ${index}`)) : assert.fail(`${where} is no array of schemas`) +} + +function enumStrings(schema: SchemaObject): string[] { + const values = schema['enum'] + return Array.isArray(values) ? values.filter((value: unknown): value is string => typeof value === 'string') : [] +} + +function propertyKinds(property: SchemaObject, where: string): AttributeKind[] { + const type = property['type'] + if (type === 'boolean') return ['boolean'] + if (type === 'integer' || type === 'number') return ['number'] + if (type === 'string') return ['string'] + if (type === 'array' || type === 'object') return ['json'] + if (type !== undefined) return assert.fail(`${where}: the schema types it ${JSON.stringify(type)}, which reads as no attribute kind`) + const values = property['enum'] + return Array.isArray(values) ? values.map(valueKind) : ['json'] +} + +function valueKind(value: unknown): AttributeKind { + if (typeof value === 'boolean') return 'boolean' + if (typeof value === 'number') return 'number' + if (typeof value === 'string') return 'string' + return 'json' +} + +function schemaObject(value: unknown, where: string): SchemaObject { + return isSchemaObject(value) ? value : assert.fail(`${where} is no JSON Schema object`) +} + +function isSchemaObject(value: unknown): value is SchemaObject { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/spec.test.ts b/src/flavour.test.ts similarity index 100% rename from src/spec.test.ts rename to src/flavour.test.ts -- 2.52.0 From 25d851cc671b5fd5c341963f8ff9fb1ae64f4338 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 14 Sep 2026 16:54:32 +0200 Subject: [PATCH 2/3] 11b: guard the gate against empty gaps and unread keywords --- src/adf-schema.test.ts | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/adf-schema.test.ts b/src/adf-schema.test.ts index 339d5d8..742f24b 100644 --- a/src/adf-schema.test.ts +++ b/src/adf-schema.test.ts @@ -18,8 +18,9 @@ type Spelled = [string, Map] const carried = ['alignment', 'annotation', 'backgroundColor', 'blockCard', 'bodiedRule', 'breakout', 'dataConsumer', 'embedCard', 'fontSize', 'fragment', 'indentation', 'inlineExtension', 'placeholder'] const definitionReference = '#/definitions/' -const gaps = new Map(Object.entries({ layoutSection: ['columnRuleStyle'], link: ['collection', 'id', 'occurrenceKey'], rule: ['color', 'style', 'weight'] })) +const gaps = ['layoutSection.columnRuleStyle', 'link.collection', 'link.id', 'link.occurrenceKey', 'rule.color', 'rule.style', 'rule.weight'] const grammarOwn = ['doc', 'text'] +const readKeywords = ['$ref', 'additionalProperties', 'allOf', 'anyOf', 'enum', 'items', 'maxItems', 'maximum', 'minItems', 'minLength', 'minimum', 'pattern', 'properties', 'required', 'type'] const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'spec', 'adf-schema') const schemaFiles = ['full.json', 'stage-0.json'] @@ -33,25 +34,31 @@ test('the tables spell the attribute names and kinds the ADF JSON Schemas give e const held = schemaTypes() const spelledTypes = spelled() const found: string[] = [] + const gapsHeld = new Set() for (const [type, spelledKinds] of spelledTypes) { const kinds = held.get(type) if (kinds === undefined) { found.push(`${type}: the tables spell the type, the schema holds no definition of it`) continue } - const pinned = gaps.get(type) ?? [] for (const [attribute, schemaKinds] of kinds) { const kind = spelledKinds.get(attribute) const holds = [...schemaKinds].sort().join(' or ') - if (kind === undefined && !pinned.includes(attribute)) found.push(`${type}.${attribute}: the schema holds ${holds}, the tables spell nothing and the gaps list does not name it`) - if (kind !== undefined && pinned.includes(attribute)) found.push(`${type}.${attribute}: the tables spell ${kind}, and the gaps list still names it`) - if (kind !== undefined && (schemaKinds.size !== 1 || !schemaKinds.has(kind))) found.push(`${type}.${attribute}: the tables spell ${kind}, the schema holds ${holds}`) + if (kind === undefined && gaps.includes(`${type}.${attribute}`)) gapsHeld.add(`${type}.${attribute}`) + else if (kind === undefined) found.push(`${type}.${attribute}: the schema holds ${holds}, the tables spell nothing and the gaps list does not name it`) + else if (schemaKinds.size !== 1 || !schemaKinds.has(kind)) found.push(`${type}.${attribute}: the tables spell ${kind}, the schema holds ${holds}`) } for (const [attribute, kind] of spelledKinds) if (!kinds.has(attribute)) found.push(`${type}.${attribute}: the tables spell ${kind}, the schema holds nothing`) - for (const attribute of pinned) if (!kinds.has(attribute)) found.push(`${type}.${attribute}: the gaps list names it, the schema holds nothing`) } - const spelledNames = new Set(spelledTypes.map(([type]) => type)) - for (const type of gaps.keys()) if (!spelledNames.has(type)) found.push(`${type}: the gaps list names the type, the tables spell none of it`) + for (const gap of gaps) { + if (gapsHeld.has(gap)) continue + const [type = '', attribute = ''] = gap.split('.') + const spelledKinds = spelledTypes.find(([name]) => name === type)?.[1] + const kind = spelledKinds?.get(attribute) + if (spelledKinds === undefined) found.push(`${gap}: the gaps list names it, the tables spell no ${type} type`) + else if (kind === undefined) found.push(`${gap}: the gaps list names it, the schema holds nothing`) + else found.push(`${gap}: the gaps list names it, the tables spell ${kind}`) + } assert.deepEqual(found, []) }) @@ -91,7 +98,7 @@ function schemaTypes(): Map { const where = `${file} ${name}` for (const properties of alternatives(schemaObject(definition, where), definitions, where)) { const attributes = (properties.get('attrs') ?? []).flatMap((attrs) => alternatives(attrs, definitions, `${where} attrs`)).flatMap((alternative) => [...alternative]) - for (const type of (properties.get('type') ?? []).flatMap(enumStrings)) { + for (const type of (properties.get('type') ?? []).flatMap((schema) => enumStrings(schema, `${where} type`))) { const kinds = held.get(type) ?? new Map>() held.set(type, kinds) for (const [attribute, schemas] of attributes) { @@ -107,14 +114,21 @@ function schemaTypes(): Map { } function alternatives(schema: SchemaObject, definitions: SchemaObject, where: string): Properties[] { - const own = Object.entries(schemaObject(schema['properties'] ?? {}, `${where} properties`)) - let found: Properties[] = [new Map(own.map(([name, property]): [string, SchemaObject[]] => [name, [schemaObject(property, `${where} ${name}`)]]))] - if (schema['$ref'] !== undefined) found = intersect(found, alternatives(referenced(schema['$ref'], definitions, where), definitions, `${where} $ref`)) + const own = Object.entries(schemaObject(readSchema(schema, where)['properties'] ?? {}, `${where} properties`)) + let found: Properties[] = [new Map(own.map(([name, property]): [string, SchemaObject[]] => [name, [readSchema(schemaObject(property, `${where} ${name}`), `${where} ${name}`)]]))] + if (schema['$ref'] !== undefined) found = intersect(found, alternatives(referenced(schema['$ref'], definitions, where), definitions, `${where} ${String(schema['$ref'])}`)) for (const branch of branches(schema['allOf'], `${where} allOf`)) found = intersect(found, alternatives(branch, definitions, `${where} allOf`)) if (schema['anyOf'] !== undefined) found = intersect(found, branches(schema['anyOf'], `${where} anyOf`).flatMap((branch) => alternatives(branch, definitions, `${where} anyOf`))) return found } +function readSchema(schema: SchemaObject, where: string): SchemaObject { + const unread = Object.keys(schema).find((keyword) => !readKeywords.includes(keyword)) + if (unread !== undefined) return assert.fail(`${where}: the schema holds the keyword ${unread}, which the gate does not read`) + const extra = schema['additionalProperties'] + return extra === undefined || typeof extra === 'boolean' ? schema : assert.fail(`${where}: additionalProperties holds a schema, which the gate does not read`) +} + function intersect(left: readonly Properties[], right: readonly Properties[]): Properties[] { return left.flatMap((own) => right.map((other) => { @@ -135,9 +149,9 @@ function branches(value: unknown, where: string): SchemaObject[] { return Array.isArray(value) ? value.map((branch, index) => schemaObject(branch, `${where} ${index}`)) : assert.fail(`${where} is no array of schemas`) } -function enumStrings(schema: SchemaObject): string[] { +function enumStrings(schema: SchemaObject, where: string): string[] { const values = schema['enum'] - return Array.isArray(values) ? values.filter((value: unknown): value is string => typeof value === 'string') : [] + return Array.isArray(values) ? values.filter((value: unknown): value is string => typeof value === 'string') : assert.fail(`${where}: the type property holds no enum naming the type`) } function propertyKinds(property: SchemaObject, where: string): AttributeKind[] { -- 2.52.0 From dcea401ec2cd8b569d85d2fa13a04a60cbf9df0e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 14 Sep 2026 17:33:13 +0200 Subject: [PATCH 3/3] 11b: fail on combinators an attribute kind cannot read --- src/adf-schema.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/adf-schema.test.ts b/src/adf-schema.test.ts index 742f24b..de8eb89 100644 --- a/src/adf-schema.test.ts +++ b/src/adf-schema.test.ts @@ -161,6 +161,8 @@ function propertyKinds(property: SchemaObject, where: string): AttributeKind[] { if (type === 'string') return ['string'] if (type === 'array' || type === 'object') return ['json'] if (type !== undefined) return assert.fail(`${where}: the schema types it ${JSON.stringify(type)}, which reads as no attribute kind`) + const combinator = ['$ref', 'allOf', 'anyOf'].find((keyword) => property[keyword] !== undefined) + if (combinator !== undefined) return assert.fail(`${where}: the schema holds the keyword ${combinator}, which the gate reads as no attribute kind`) const values = property['enum'] return Array.isArray(values) ? values.map(valueKind) : ['json'] } -- 2.52.0