@deepblend/dsh-blender-contracts 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * What a file's first bytes say it is — the content half of SPEC §15.2's
3
+ * "MIME 与扩展名双重校验".
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ---------------
7
+ * The extension check (`IMPORT_OPERATOR_BY_ASSET_TYPE`) decides which import operator a file
8
+ * gets, and it trusts the NAME. That is fine as far as it goes — Blender classifies the import
9
+ * behaviourally and a file that is not really a glTF fails as a branchable result (D10) — but
10
+ * it means the first thing that notices "this `.glb` is actually a ZIP" is a Blender launch
11
+ * that fails several seconds later, with a message about the importer rather than about the
12
+ * file. This module is the cheap gate in front of that: 512 bytes, no subprocess, decided
13
+ * before anything is copied into the project.
14
+ *
15
+ * THE RULE IS ASYMMETRIC ON PURPOSE
16
+ * ---------------------------------
17
+ * It refuses only when the bytes POSITIVELY CONTRADICT the extension — a known signature for
18
+ * a different format, a NUL byte in a format that must be text, or an empty file. Anything it
19
+ * cannot tell about is `inconclusive` and passes, because the alternative (a matcher clever
20
+ * enough to be sure) is also a matcher clever enough to reject somebody's legitimate model.
21
+ * Three of the six importable types have no magic number at all: `.obj` and ascii `.usd` are
22
+ * text, and `.gltf` is JSON.
23
+ *
24
+ * WHAT IT IS NOT
25
+ * --------------
26
+ * Not a security boundary. It cannot tell a real glTF from a crafted one, and it is not meant
27
+ * to: DECIDING what a file is remains Blender's job, and this only refuses the cases where the
28
+ * file says out loud that it is something else.
29
+ *
30
+ * Owner: DeepBlend Studio — M5
31
+ */
32
+
33
+ /** How many bytes of a file this needs. Enough for a text sniff, small enough to be free. */
34
+ export const ASSET_HEAD_BYTES = 512
35
+
36
+ /**
37
+ * The signatures worth knowing, in one table.
38
+ *
39
+ * `format` is the asset type a signature belongs to, or `null` for a format this product
40
+ * cannot import — which is the interesting half: a ZIP or a PNG inside a `.glb` is exactly the
41
+ * case the extension check cannot see.
42
+ */
43
+ const SIGNATURES = Object.freeze([
44
+ // Importable types.
45
+ { format: 'glb', magic: 'glTF', at: 0 },
46
+ { format: 'blend', magic: 'BLENDER', at: 0 },
47
+ { format: 'fbx', magic: 'Kaydara FBX Binary', at: 0 },
48
+ { format: 'usd', magic: 'PXR-USDC', at: 0 },
49
+ // Everything else that is common enough to name, all of it a contradiction for any of the
50
+ // six importable types.
51
+ { format: null, magic: 'PK\u0003\u0004', at: 0, label: 'a ZIP archive' },
52
+ { format: null, magic: '\u0089PNG\r\n\u001a\n', at: 0, label: 'a PNG image' },
53
+ { format: null, magic: '\u00ff\u00d8\u00ff', at: 0, label: 'a JPEG image' },
54
+ { format: null, magic: '%PDF', at: 0, label: 'a PDF' },
55
+ { format: null, magic: '\u007fELF', at: 0, label: 'an ELF binary' },
56
+ { format: null, magic: 'MZ', at: 0, label: 'a Windows executable' },
57
+ { format: null, magic: '\u001f\u008b', at: 0, label: 'a gzip stream' },
58
+ { format: null, magic: 'RIFF', at: 0, label: 'a RIFF container' },
59
+ { format: null, magic: 'OggS', at: 0, label: 'an Ogg stream' },
60
+ { format: null, magic: 'ID3', at: 0, label: 'an MP3' },
61
+ { format: null, magic: 'ftyp', at: 4, label: 'an ISO base media file (MP4/MOV)' },
62
+ { format: null, magic: '\u0000asm', at: 0, label: 'a WebAssembly module' },
63
+ ])
64
+
65
+ /** Asset types whose content is text and must therefore contain no NUL byte. */
66
+ const TEXT_TYPES = Object.freeze(['obj', 'gltf', 'usd', 'fbx'])
67
+
68
+ /** The label a contradiction reports, from the signature it matched. */
69
+ function labelFor(entry) {
70
+ return entry.label ?? `a ${entry.format} file`
71
+ }
72
+
73
+ /** Does `head` begin with `magic` at `at`? Bytes, never a decoded string. */
74
+ function startsWith(head, magic, at) {
75
+ const needle = Buffer.from(magic, 'latin1')
76
+ if (head.length < at + needle.length) return false
77
+ return head.subarray(at, at + needle.length).equals(needle)
78
+ }
79
+
80
+ /** Bytes a text file may legitimately contain besides the printable range. */
81
+ const PRINTABLE_EXCEPTIONS = Object.freeze([0x09, 0x0a, 0x0d])
82
+
83
+ /**
84
+ * What the bytes say, independently of the name.
85
+ *
86
+ * `signature` is the whole verdict about magic numbers: an entry MATCHED, and whether the
87
+ * format it belongs to is importable is a separate question — `format: null` in the table
88
+ * means "a known format this product cannot import". Collapsing those two, "no signature
89
+ * matched" and "a signature for something else matched", is the bug the first version of this
90
+ * function had: it made a ZIP named `.glb` read as *unknown* rather than as a ZIP.
91
+ *
92
+ * @param {Uint8Array|Buffer} head - the first bytes of the file (`ASSET_HEAD_BYTES` is enough)
93
+ * @returns {{ signature: {format: string|null, label: string}|null, text: boolean, empty: boolean }}
94
+ */
95
+ export function describeAssetContent(head) {
96
+ const bytes = Buffer.isBuffer(head) ? head : Buffer.from(head ?? [])
97
+ const empty = bytes.length === 0
98
+ for (const entry of SIGNATURES) {
99
+ if (startsWith(bytes, entry.magic, entry.at)) {
100
+ return { signature: { format: entry.format, label: labelFor(entry) }, text: false, empty }
101
+ }
102
+ }
103
+ // Text, for the purposes of this gate, is bytes a text file can contain: the printable
104
+ // range, UTF-8 continuation bytes, and tab/newline/CR. A NUL fails that, and so does binary
105
+ // garbage that merely happens to contain no NUL — which is what the second version of this
106
+ // function learned, because "contains no zero byte" is not the same claim as "is text".
107
+ const text = !empty && bytes.every(byte => byte >= 0x20 || PRINTABLE_EXCEPTIONS.includes(byte))
108
+ return { signature: null, text, empty }
109
+ }
110
+
111
+ /**
112
+ * Does the content agree with the extension?
113
+ *
114
+ * @param {Uint8Array|Buffer} head
115
+ * @param {string} type - an asset type from `IMPORT_OPERATOR_BY_ASSET_TYPE`
116
+ * @returns {'agrees'|'contradicts'|'inconclusive'}
117
+ */
118
+ export function assetContentVerdict(head, type) {
119
+ const described = describeAssetContent(head)
120
+
121
+ if (described.empty) return 'contradicts'
122
+ if (described.signature !== null) {
123
+ // A signature for THIS type agrees. A signature for anything else contradicts — including
124
+ // for another importable type, which is how a `.obj` holding a `.blend` reads, and
125
+ // including for a format this product cannot import at all, which is how a ZIP named
126
+ // `.glb` reads.
127
+ return described.signature.format === type ? 'agrees' : 'contradicts'
128
+ }
129
+ // No signature. Text formats are satisfied by text; a binary format is not satisfied by
130
+ // anything (its magic was required and did not match), but it is not contradicted either,
131
+ // so it stays inconclusive rather than being refused on a guess.
132
+ if (TEXT_TYPES.includes(type)) return described.text ? 'agrees' : 'contradicts'
133
+ return 'inconclusive'
134
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * A 5x7 bitmap font, for labelling contact-sheet cells.
3
+ *
4
+ * WHY A FONT IS IN THE CONTRACTS PACKAGE AT ALL
5
+ * --------------------------------------------
6
+ * A contact sheet without labels forces the model to guess which cell is which
7
+ * view. It *could* guess — the cells come back in a documented order — but the one
8
+ * thing a vision review must not do is mislabel its own evidence: an occlusion
9
+ * reported against "the top view" that is actually the three-quarter view produces
10
+ * a confident, well-formed, wrong patch. A label removes the guess.
11
+ *
12
+ * The alternative was an image library with real text rendering, which is the
13
+ * dependency this design exists to avoid (see `png.js`). 5x7 uppercase glyphs are
14
+ * about forty lines of data and are unambiguous at 2x scale inside a 1600px sheet.
15
+ *
16
+ * Each glyph is 7 rows of 5 bits, most significant bit on the left. Every row is
17
+ * OR'd with `0b1000000`, which both pads to a byte and makes a truncated or
18
+ * mistyped row visibly wrong rather than a plausible shorter one.
19
+ *
20
+ * Owner: DeepBlend Studio — M2
21
+ */
22
+
23
+ /** One glyph: 7 rows of 5 bits, each padded to a byte by a set high bit. */
24
+ const GLYPHS = {
25
+ ' ': [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
26
+ '-': [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00],
27
+ '_': [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f],
28
+ '.': [0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x0c],
29
+ ',': [0x00, 0x00, 0x00, 0x00, 0x0c, 0x0c, 0x18],
30
+ ':': [0x00, 0x0c, 0x0c, 0x00, 0x0c, 0x0c, 0x00],
31
+ '/': [0x01, 0x02, 0x04, 0x08, 0x10, 0x00, 0x00],
32
+ '#': [0x0a, 0x1f, 0x0a, 0x0a, 0x1f, 0x0a, 0x00],
33
+ '(': [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02],
34
+ ')': [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08],
35
+ '+': [0x00, 0x04, 0x04, 0x1f, 0x04, 0x04, 0x00],
36
+ '=': [0x00, 0x00, 0x1f, 0x00, 0x1f, 0x00, 0x00],
37
+ '?': [0x0e, 0x11, 0x01, 0x06, 0x04, 0x00, 0x04],
38
+ '!': [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04],
39
+ '0': [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e],
40
+ '1': [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e],
41
+ '2': [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f],
42
+ '3': [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e],
43
+ '4': [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02],
44
+ '5': [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e],
45
+ '6': [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e],
46
+ '7': [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
47
+ '8': [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e],
48
+ '9': [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c],
49
+ 'A': [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11],
50
+ 'B': [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e],
51
+ 'C': [0x0e, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0e],
52
+ 'D': [0x1c, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1c],
53
+ 'E': [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f],
54
+ 'F': [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10],
55
+ 'G': [0x0e, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0f],
56
+ 'H': [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11],
57
+ 'I': [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e],
58
+ 'J': [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c],
59
+ 'K': [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
60
+ 'L': [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f],
61
+ 'M': [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11],
62
+ 'N': [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11],
63
+ 'O': [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e],
64
+ 'P': [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10],
65
+ 'Q': [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d],
66
+ 'R': [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11],
67
+ 'S': [0x0f, 0x10, 0x10, 0x0e, 0x01, 0x01, 0x1e],
68
+ 'T': [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
69
+ 'U': [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e],
70
+ 'V': [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04],
71
+ 'W': [0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11],
72
+ 'X': [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11],
73
+ 'Y': [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04],
74
+ 'Z': [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f],
75
+ }
76
+
77
+ /** Glyph cell size in pixels, before scaling. */
78
+ export const GLYPH_WIDTH = 5
79
+ export const GLYPH_HEIGHT = 7
80
+ /** One blank column between glyphs. */
81
+ const GLYPH_ADVANCE = GLYPH_WIDTH + 1
82
+
83
+ /** The character drawn for anything the font has no glyph for. */
84
+ const FALLBACK = '?'
85
+
86
+ /**
87
+ * Measure the text width at a given scale.
88
+ *
89
+ * @param {string} text
90
+ * @param {number} scale
91
+ * @returns {number}
92
+ */
93
+ export function textWidth(text, scale = 1) {
94
+ if (text.length === 0) return 0
95
+ return (text.length * GLYPH_ADVANCE - 1) * scale
96
+ }
97
+
98
+ /**
99
+ * Draw text with the top-left corner at (x, y). Uppercases its input, because the
100
+ * font has only one case and silently drawing blanks for lowercase would be worse
101
+ * than the visible transformation.
102
+ *
103
+ * @param {import('./png.js').RgbaImage} image
104
+ * @param {string} text
105
+ * @param {number} x
106
+ * @param {number} y
107
+ * @param {[number, number, number, number]} color
108
+ * @param {number} [scale]
109
+ */
110
+ export function drawText(image, text, x, y, color, scale = 1) {
111
+ const upper = text.toUpperCase()
112
+ let cursor = x
113
+ for (const character of upper) {
114
+ const glyph = GLYPHS[character] ?? GLYPHS[FALLBACK]
115
+ for (let row = 0; row < GLYPH_HEIGHT; row += 1) {
116
+ const bits = glyph[row]
117
+ for (let column = 0; column < GLYPH_WIDTH; column += 1) {
118
+ // The set high bit is the padding marker, never a pixel: reading the
119
+ // glyph's own 5 data bits means a row written with the wrong length
120
+ // still cannot produce stray pixels to the right of the glyph.
121
+ const on = (bits >> (GLYPH_WIDTH - 1 - column)) & 1
122
+ if (on === 0) continue
123
+ for (let dy = 0; dy < scale; dy += 1) {
124
+ for (let dx = 0; dx < scale; dx += 1) {
125
+ setPixel(image, cursor + column * scale + dx, y + row * scale + dy, color)
126
+ }
127
+ }
128
+ }
129
+ }
130
+ cursor += GLYPH_ADVANCE * scale
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Write one pixel, ignoring anything outside the image.
136
+ *
137
+ * @param {import('./png.js').RgbaImage} image
138
+ * @param {number} x
139
+ * @param {number} y
140
+ * @param {[number, number, number, number]} color
141
+ */
142
+ function setPixel(image, x, y, color) {
143
+ if (x < 0 || y < 0 || x >= image.width || y >= image.height) return
144
+ const offset = (y * image.width + x) * 4
145
+ image.data[offset] = color[0]
146
+ image.data[offset + 1] = color[1]
147
+ image.data[offset + 2] = color[2]
148
+ image.data[offset + 3] = color[3]
149
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Canonical JSON serialization and content hashing.
3
+ *
4
+ * The word "canonical" here is load-bearing (SPEC §11.1 "返回 Canonical JSON"):
5
+ *
6
+ * - **Key order is significant and fixed** (sorted, recursively), because a
7
+ * revision digest, an idempotency record and a test fixture all hash the same
8
+ * document. Two structurally identical SceneSpecs that differ only in key
9
+ * insertion order MUST hash identically, or the same intent would look like
10
+ * two different revisions.
11
+ * - **Absent keys are omitted rather than emitted as `null`**, so "not set" has
12
+ * exactly one representation. `undefined` is never valid JSON and is dropped.
13
+ * - **No whitespace**, so the hash is over bytes and not over formatting.
14
+ *
15
+ * Owner: DeepBlend Studio — M1
16
+ */
17
+
18
+ import { createHash } from 'node:crypto'
19
+
20
+ /**
21
+ * Serialize a JSON value with recursively sorted object keys and no whitespace.
22
+ *
23
+ * @param {unknown} value
24
+ * @returns {string}
25
+ */
26
+ export function canonicalStringify(value) {
27
+ return JSON.stringify(sortValue(value))
28
+ }
29
+
30
+ /**
31
+ * Pretty-print a JSON value with recursively sorted keys.
32
+ *
33
+ * Used for every document DeepBlend writes to disk. Sorted keys make a revision
34
+ * diff between two SceneSpecs read as an actual change rather than as noise from
35
+ * insertion order — which is the whole point of keeping the spec, not the
36
+ * `.blend`, as the source of truth (SPEC §8.1).
37
+ *
38
+ * @param {unknown} value
39
+ * @param {number} [indent]
40
+ * @returns {string}
41
+ */
42
+ export function canonicalPretty(value, indent = 2) {
43
+ return JSON.stringify(sortValue(value), null, indent)
44
+ }
45
+
46
+ /**
47
+ * Recursively rebuild a value with sorted keys, dropping `undefined`.
48
+ *
49
+ * @param {unknown} value
50
+ * @returns {unknown}
51
+ */
52
+ export function sortValue(value) {
53
+ if (Array.isArray(value)) return value.map(entry => sortValue(entry))
54
+ if (value === null || typeof value !== 'object') return value
55
+ /** @type {Record<string, unknown>} */
56
+ const output = {}
57
+ for (const key of Object.keys(value).sort()) {
58
+ const member = /** @type {Record<string, unknown>} */ (value)[key]
59
+ if (member === undefined) continue
60
+ output[key] = sortValue(member)
61
+ }
62
+ return output
63
+ }
64
+
65
+ /**
66
+ * SHA-256 of the canonical serialization, as lowercase hex.
67
+ *
68
+ * @param {unknown} value
69
+ * @returns {string}
70
+ */
71
+ export function sha256Canonical(value) {
72
+ return createHash('sha256').update(canonicalStringify(value), 'utf8').digest('hex')
73
+ }
74
+
75
+ /**
76
+ * SHA-256 of raw bytes or text.
77
+ *
78
+ * @param {Buffer|string} data
79
+ * @returns {string}
80
+ */
81
+ export function sha256(data) {
82
+ return createHash('sha256').update(data).digest('hex')
83
+ }
84
+
85
+ /** Hex prefix length used when hashes appear inside identifiers. */
86
+ const SHORT_HASH_LENGTH = 16
87
+
88
+ /**
89
+ * A short, file-name-safe digest used for cache keys and idempotency records.
90
+ * Long enough that a collision would require a deliberate attack, short enough
91
+ * to keep an on-disk path readable.
92
+ *
93
+ * @param {unknown} value
94
+ * @returns {string}
95
+ */
96
+ export function shortDigest(value) {
97
+ return sha256Canonical(value).slice(0, SHORT_HASH_LENGTH)
98
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Which config keys a row actually reads — the check that turns a silent no-op into a startup error.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * SPEC §17 illustrates the configuration as a NESTED document:
7
+ *
8
+ * { "finalRender": { "requireApprovalAboveFrames": 900 }, "security": { "assetMaxBytes": … } }
9
+ *
10
+ * and the implementation reads FLAT keys (`requireApprovalAboveFrames`, `assetMaxBytes`, …), because
11
+ * each key belongs to the package that enforces it rather than to a group. MEASURED before this
12
+ * module existed: Schemastery accepts the nested document, keeps `finalRender` as an unknown property,
13
+ * and reports nothing — so an operator who follows the spec gets a deployment where the approval
14
+ * threshold is whatever the DEFAULT was, with no error anywhere. The key is not ignored loudly; it is
15
+ * ignored silently, which is the only failure mode this repository treats as unacceptable in a
16
+ * configuration.
17
+ *
18
+ * WHAT IT DOES NOT DO
19
+ * -------------------
20
+ * It does not reject a key for having the wrong TYPE or VALUE — the schema already does that, with a
21
+ * better message than this could write. It answers one question only: does this row read this name at
22
+ * all?
23
+ *
24
+ * Owner: DeepBlend Studio — M5
25
+ */
26
+
27
+ /**
28
+ * The top-level keys a schema declares.
29
+ *
30
+ * Read from `toJSON()` rather than from the builder: the same shape the bundle validator and the
31
+ * settings card use, so there is one description of a row's surface rather than a second one here.
32
+ *
33
+ * @param {{toJSON: () => object}} schema
34
+ * @returns {string[]}
35
+ */
36
+ export function declaredConfigKeys(schema) {
37
+ const json = schema.toJSON()
38
+ // `dict` sits on the ROOT node, which is `refs[uid]` as soon as the schema has any refs at all — a
39
+ // schema of scalars with defaults has them, so reading `json.dict` alone found nothing and this check
40
+ // declared every key unknown. MEASURED against `StudioConfig`: `{uid: 47, refs: {…, 47: {dict: {…}}}}`.
41
+ const dict = json?.refs?.[json.uid]?.dict ?? json?.dict ?? {}
42
+ return Object.keys(dict).sort()
43
+ }
44
+
45
+ /**
46
+ * A message naming every key this row does not read, or `null` when there are none.
47
+ *
48
+ * The known keys are listed in full rather than counted: this runs once, at composition, and the
49
+ * operator reading it is looking for the name they meant to type.
50
+ *
51
+ * @param {{toJSON: () => object}} schema
52
+ * @param {object} config - the row's config as the loader passed it
53
+ * @param {string} label - the row id, so the error says which row is wrong
54
+ * @returns {string|null}
55
+ */
56
+ export function describeUnknownConfigKeys(schema, config, label) {
57
+ const known = declaredConfigKeys(schema)
58
+ const knownSet = new Set(known)
59
+ const given = config !== null && typeof config === 'object' ? Object.keys(config) : []
60
+ const unknown = given.filter(key => !knownSet.has(key))
61
+ if (unknown.length === 0) return null
62
+ return (
63
+ `${label} does not read ${unknown.map(key => `"${key}"`).join(', ')}. ` +
64
+ `It reads: ${known.join(', ')}. ` +
65
+ 'Note that a key with the wrong shape is NOT rejected by the schema — a nested object like SPEC §17\u2019s ' +
66
+ '`finalRender: {…}` is accepted and simply never read, so this check is what makes that loud. ' +
67
+ // THE MOST LIKELY CAUSE IS NOT A TYPO, and the message has to say so: an operator layer is GENERATED from
68
+ // the bundle (`npm run plugin:install`), so a bundle that dropped a key leaves every existing layer
69
+ // carrying it — and this refusal then fires at the next restart, which is a long way from the edit that
70
+ // caused it. MEASURED: exactly that happened in this repository when `serveCachedCapabilities` was
71
+ // deleted, and the only thing that surfaced it was `npm run plugin:check` being run by hand.
72
+ 'If this config came from a generated operator layer, the layer is stale rather than wrong: ' +
73
+ 'run `npm run plugin:install` to regenerate it from the bundle, then restart the profile. ' +
74
+ 'See `deepblend/docs/install.md` for the SPEC §17 name \u2192 real name table.'
75
+ )
76
+ }
77
+
78
+ /**
79
+ * Throw when a row's config carries a key the row does not read.
80
+ *
81
+ * @param {{toJSON: () => object}} schema
82
+ * @param {object} config
83
+ * @param {string} label
84
+ */
85
+ export function assertKnownConfigKeys(schema, config, label) {
86
+ const problem = describeUnknownConfigKeys(schema, config, label)
87
+ if (problem !== null) throw new Error(problem)
88
+ }