spine-rigc 1.9.4 → 2.0.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,56 @@
1
+ /**
2
+ * A05, restated over the emitted text (issue #1060): the clause
3
+ * `validate()` runs in `src/validate.ts`, word for word, over the skeleton JSON — the
4
+ * text rigc wrote, read by rigc's own reader rather than by spine-core's. The
5
+ * round trip's copy is unchanged and runs in `cli.ts build`; this one runs in
6
+ * the gate of the entry that links none of the runtime
7
+ * (`../emitted/index.ts`), and the selftest's `RC28` holds the two to the
8
+ * same lines on every recipe and on this assertion's mutants.
9
+ */
10
+ import type { Verdicts } from '../harness.ts';
11
+ import { SKIP_NO_TIMELINE } from '../reasons.ts';
12
+ import { CHANNELS_BY_KIND, walkTimelines } from '../../timelines.ts';
13
+ import { isObj, type Json } from '../values.ts';
14
+
15
+ export function a05CurveArrayLength({ fail, skip }: Verdicts, raw: Json | null): void {
16
+ // Two clauses, so the SKIP needs both to be empty (#580). The vocabulary
17
+ // clause below measures every timeline it is handed — an unchecked name is a
18
+ // finding whether or not any key on it carries a curve — so a skeleton with
19
+ // timelines and no curve at all has still been measured. What measures
20
+ // nothing is a skeleton `walkTimelines` never calls back on.
21
+ let timelines = 0;
22
+ walkTimelines(raw, (path, kind, name, keys) => {
23
+ timelines++;
24
+ const table = CHANNELS_BY_KIND[kind];
25
+ if (!(name in table)) {
26
+ fail('A05_CURVE_ARRAY_LENGTH', `${path}: unchecked ${kind} timeline "${name}" — extend the validator`);
27
+ return;
28
+ }
29
+ const channels = table[name];
30
+ for (const key of keys) {
31
+ if (!isObj(key) || !('curve' in key)) continue;
32
+ const curve = key.curve;
33
+ if (channels === null) {
34
+ fail('A05_CURVE_ARRAY_LENGTH', `${path}: timeline "${name}" cannot carry a curve`);
35
+ continue;
36
+ }
37
+ if (curve === 'stepped') continue;
38
+ if (!Array.isArray(curve)) {
39
+ fail('A05_CURVE_ARRAY_LENGTH', `${path}: curve is ${JSON.stringify(curve)}, expected "stepped" or an array`);
40
+ continue;
41
+ }
42
+ if (curve.length !== channels * 4) {
43
+ fail(
44
+ 'A05_CURVE_ARRAY_LENGTH',
45
+ `${path} (t=${String(key.time ?? 0)}): curve has ${curve.length} numbers, "${name}" needs ${channels} channels x 4 = ${channels * 4}`,
46
+ );
47
+ }
48
+ for (const n of curve) {
49
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
50
+ fail('A05_CURVE_ARRAY_LENGTH', `${path}: curve holds a non-finite value ${JSON.stringify(n)}`);
51
+ }
52
+ }
53
+ }
54
+ });
55
+ if (timelines === 0) return skip('A05_CURVE_ARRAY_LENGTH', SKIP_NO_TIMELINE);
56
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * A07, restated over the emitted text (issue #1060): the clause
3
+ * `validate()` runs in `src/validate.ts`, word for word, over the atlas text — the
4
+ * text rigc wrote, read by rigc's own reader rather than by spine-core's. The
5
+ * round trip's copy is unchanged and runs in `cli.ts build`; this one runs in
6
+ * the gate of the entry that links none of the runtime
7
+ * (`../emitted/index.ts`), and the selftest's `RC28` holds the two to the
8
+ * same lines on every recipe and on this assertion's mutants.
9
+ */
10
+ import type { Verdicts } from '../harness.ts';
11
+ import { SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
12
+
13
+ export function a07AtlasTextShape({ fail, skip }: Verdicts, atlasText: string): void {
14
+ const atlasLines = atlasText.replace(/\n$/, '').split('\n');
15
+ const atlasPageLines = atlasLines.filter((line) => line.trim().length > 0).length;
16
+ if (atlasPageLines === 0) {
17
+ // One of the exported reason constants, not a sentence of its own: the
18
+ // static-rig suite's arithmetic (S50, #580) counts a rule as skipped
19
+ // for want of a SUBJECT only when its reason is one of those constants.
20
+ return skip('A07_ATLAS_TEXT_SHAPE', SKIP_NO_ATLAS_PAGE);
21
+ }
22
+ // A blank line at either END of the file is named as that end (issues #803,
23
+ // #810), and a run of them is one finding stating its length. They are taken
24
+ // off both ends before the walk below, so the walk sees the page blocks and
25
+ // the blank lines between them and nothing else: a blank line after the last
26
+ // region is not a page block, and "consecutive blank lines" and "the last
27
+ // page block declares no region" are about blocks. `TextureAtlas` reads a
28
+ // blank at either end as nothing (`TextureAtlas.js:98-100` skips the leading
29
+ // run, `:116-118` ends a page block on each blank line), and rigc writes
30
+ // neither (`writeAtlasText`, and `--atlas-in` through `canonicalAtlasShape`),
31
+ // so a file that has one was written by something else and is refused.
32
+ let leading = 0;
33
+ while (leading < atlasLines.length && atlasLines[leading].trim().length === 0) leading++;
34
+ if (leading > 0) {
35
+ fail('A07_ATLAS_TEXT_SHAPE', `line 1: the file begins with ${leading === 1 ? 'a blank line' : `${leading} blank lines`}`);
36
+ }
37
+ let trailing = 0;
38
+ while (trailing < atlasLines.length - leading && atlasLines[atlasLines.length - 1 - trailing].trim().length === 0) trailing++;
39
+ const blockEnd = atlasLines.length - trailing;
40
+ let expectPage = true;
41
+ let sawRegionForPage = false;
42
+ for (let i = leading; i < blockEnd; i++) {
43
+ const line = atlasLines[i];
44
+ if (line.trim().length === 0) {
45
+ if (expectPage) fail('A07_ATLAS_TEXT_SHAPE', `line ${i + 1}: consecutive blank lines`);
46
+ else if (!sawRegionForPage) {
47
+ fail('A07_ATLAS_TEXT_SHAPE', `line ${i + 1}: blank line before this page had any region`);
48
+ }
49
+ expectPage = true;
50
+ sawRegionForPage = false;
51
+ continue;
52
+ }
53
+ if (expectPage) {
54
+ expectPage = false;
55
+ continue; // page name line
56
+ }
57
+ if (line.includes(':')) continue; // key: value line
58
+ // A bare non-key line is a region name, and it is used untrimmed.
59
+ if (line !== line.trim()) {
60
+ fail('A07_ATLAS_TEXT_SHAPE', `line ${i + 1}: region name has stray whitespace: ${JSON.stringify(line)}`);
61
+ }
62
+ sawRegionForPage = true;
63
+ }
64
+ if (!sawRegionForPage) fail('A07_ATLAS_TEXT_SHAPE', 'the last page block declares no region');
65
+ if (trailing > 0) {
66
+ fail('A07_ATLAS_TEXT_SHAPE', `line ${blockEnd + 1}: the file ends with ${trailing === 1 ? 'a blank line' : `${trailing} blank lines`}`);
67
+ }
68
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A16, restated over the emitted text (issue #1060): the clause
3
+ * `validate()` runs in `src/validate.ts`, word for word, over the skeleton JSON — the
4
+ * text rigc wrote, read by rigc's own reader rather than by spine-core's. The
5
+ * round trip's copy is unchanged and runs in `cli.ts build`; this one runs in
6
+ * the gate of the entry that links none of the runtime
7
+ * (`../emitted/index.ts`), and the selftest's `RC28` holds the two to the
8
+ * same lines on every recipe and on this assertion's mutants.
9
+ */
10
+ import type { Verdicts } from '../harness.ts';
11
+ import { spineGeneration, type SpineGeneration } from '../../generation.ts';
12
+ import { isObj, type Json } from '../values.ts';
13
+
14
+ const SPINE_4_3: SpineGeneration = '4.3';
15
+
16
+ export function a16SkeletonVersion43({ fail }: Verdicts, raw: Json | null): void {
17
+ const declared = isObj(raw?.skeleton) ? (raw.skeleton as Json).spine : undefined;
18
+ if (typeof declared !== 'string' || spineGeneration(declared) !== SPINE_4_3) {
19
+ fail(
20
+ 'A16_SKELETON_VERSION_4_3',
21
+ `skeleton.spine is ${JSON.stringify(declared)}, expected 4.3, 4.3.<patch> or 4.3.<patch>-<suffix>`,
22
+ );
23
+ }
24
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * A18, the body (issue #1060, step 4e of #380): a second, independent compile
3
+ * writes the same model document, byte for byte.
4
+ *
5
+ * One body, two suppliers, as every moved assertion is — but split. The
6
+ * runtime's side (`validate()` in `src/validate.ts`) compares the second
7
+ * compile's Spine pair as well, `skeleton.json` and `skeleton.atlas`, and
8
+ * hands those findings over as `encoding`, in the order it always printed
9
+ * them; the model side (`../model/index.ts`) compares the document alone,
10
+ * which is all a build with no Spine pair writes (`cli_core.ts build`). So
11
+ * the model's clause is written once, here, and the two sides cannot spell it
12
+ * apart. When neither side was handed a second compile — a re-gate of files on
13
+ * disk — there is nothing determinate about a comparison that never ran, and
14
+ * it SKIPs with the reason the round trip always gave (the vacuous-pass trap
15
+ * `A09` is guarded against too).
16
+ *
17
+ * Moved out of `src/validate.ts` unchanged but for the seam; `firstDifferingLine`
18
+ * moved with it, the one reader of it.
19
+ */
20
+ import type { Verdicts } from '../harness.ts';
21
+
22
+ const A18 = 'A18_DETERMINISTIC_EMIT';
23
+
24
+ /** What A18 reads: whether a second compile was handed over, the encoding's findings, and the two documents. */
25
+ export interface DeterminismFacts {
26
+ /** Whether a second, independent compile was handed over — without one the assertion has nothing to compare. */
27
+ again: boolean;
28
+ /** The findings of the clauses only one side compares (the Spine pair, on the runtime's), each a detail, in the order they print. */
29
+ encoding: readonly string[];
30
+ /** The first compile's model document — `undefined` where the caller handed a second compile and no first document. */
31
+ first: string | undefined;
32
+ /** The second compile's model document; read only where `again`. */
33
+ second: string;
34
+ }
35
+
36
+ /**
37
+ * Where two texts first differ, as A18 names it: the 1-based line and each
38
+ * side's line, trimmed and cut to 120 characters.
39
+ */
40
+ export function firstDifferingLine(first: string, second: string): string {
41
+ const a = first.split('\n');
42
+ const b = second.split('\n');
43
+ let i = 0;
44
+ while (i < a.length && i < b.length && a[i] === b[i]) i += 1;
45
+ const cut = (line: string | undefined): string => (line === undefined ? '(end of text)' : JSON.stringify(line.trim().slice(0, 120)));
46
+ return `${i + 1}: ${cut(a[i])} first, ${cut(b[i])} second`;
47
+ }
48
+
49
+ export function a18DeterministicEmit({ fail, skip }: Verdicts, facts: DeterminismFacts): void {
50
+ if (!facts.again) return skip(A18, 'no second compile to compare against (re-gating artifacts on disk)');
51
+ for (const detail of facts.encoding) fail(A18, detail);
52
+ // The model document is compared like the Spine pair (issue #922): it is
53
+ // written beside them, and a map iterated in an order nothing fixed would
54
+ // reach it before it reached either of them — the document writes every
55
+ // `Map` of the model as an array in the map's order. On a build with no
56
+ // Spine pair it is the only artifact there is to compare.
57
+ if (facts.first === undefined) {
58
+ fail(A18, 'a second compile was handed over with no first model document to compare its skeleton.model.json against');
59
+ } else if (facts.second !== facts.first) {
60
+ fail(A18, `recompiling produced a different skeleton.model.json (first differing line ${firstDifferingLine(facts.first, facts.second)})`);
61
+ }
62
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A31, restated over the emitted text (issue #1060): the clause
3
+ * `validate()` runs in `src/validate.ts`, word for word, over the skeleton JSON — the
4
+ * text rigc wrote, read by rigc's own reader rather than by spine-core's. The
5
+ * round trip's copy is unchanged and runs in `cli.ts build`; this one runs in
6
+ * the gate of the entry that links none of the runtime
7
+ * (`../emitted/index.ts`), and the selftest's `RC28` holds the two to the
8
+ * same lines on every recipe and on this assertion's mutants.
9
+ */
10
+ import type { Verdicts } from '../harness.ts';
11
+ import { isObj, type Json } from '../values.ts';
12
+
13
+ export function a31DrawOrderOffsetsResolve({ fail, skip }: Verdicts, raw: Json | null): void {
14
+ if (!raw) return skip('A31_DRAW_ORDER_OFFSETS_RESOLVE', 'the skeleton JSON did not parse (A00 owns that failure)');
15
+ if (!Array.isArray(raw.slots) || !isObj(raw.animations)) {
16
+ return skip('A31_DRAW_ORDER_OFFSETS_RESOLVE', 'the skeleton declares no slots or no animations');
17
+ }
18
+ const slotIndex = new Map<string, number>();
19
+ (raw.slots as unknown[]).forEach((slot, i) => {
20
+ if (isObj(slot) && typeof slot.name === 'string') slotIndex.set(slot.name, i);
21
+ });
22
+ const slotCount = (raw.slots as unknown[]).length;
23
+ let sawATimeline = false;
24
+ for (const [animName, anim] of Object.entries(raw.animations as Json)) {
25
+ if (!isObj(anim) || !Array.isArray(anim.drawOrder)) continue;
26
+ sawATimeline = true;
27
+ (anim.drawOrder as unknown[]).forEach((key, k) => {
28
+ const at = `animation "${animName}" drawOrder key ${k}`;
29
+ if (!isObj(key) || !Array.isArray(key.offsets)) return; // no offsets = setup order
30
+ let previous = -1;
31
+ for (const entry of key.offsets as unknown[]) {
32
+ if (!isObj(entry) || typeof entry.slot !== 'string' || typeof entry.offset !== 'number') {
33
+ fail('A31_DRAW_ORDER_OFFSETS_RESOLVE', `${at}: an offset is not { slot: string, offset: number }`);
34
+ continue;
35
+ }
36
+ const index = slotIndex.get(entry.slot);
37
+ if (index === undefined) {
38
+ fail('A31_DRAW_ORDER_OFFSETS_RESOLVE', `${at}: slot "${entry.slot}" is not in the skeleton`);
39
+ continue;
40
+ }
41
+ if (index <= previous) {
42
+ const detail =
43
+ `${at}: slot "${entry.slot}" is at index ${index}, after an entry at index ${previous} — ` +
44
+ 'offsets must be in ascending slot order or the loader never finishes reading them';
45
+ fail('A31_DRAW_ORDER_OFFSETS_RESOLVE', detail);
46
+ continue;
47
+ }
48
+ previous = index;
49
+ const landing = index + entry.offset;
50
+ if (!Number.isInteger(entry.offset) || landing < 0 || landing >= slotCount) {
51
+ fail(
52
+ 'A31_DRAW_ORDER_OFFSETS_RESOLVE',
53
+ `${at}: slot "${entry.slot}" is at index ${index} and offset ${entry.offset} puts it at ${landing}, ` +
54
+ `outside the ${slotCount} slots`,
55
+ );
56
+ }
57
+ }
58
+ });
59
+ }
60
+ if (!sawATimeline) return skip('A31_DRAW_ORDER_OFFSETS_RESOLVE', 'no animation carries a drawOrder timeline');
61
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * A35, restated over the emitted text (issue #1060): the clause
3
+ * `validate()` runs in `src/validate.ts`, word for word, over the skeleton JSON — the
4
+ * text rigc wrote, read by rigc's own reader rather than by spine-core's. The
5
+ * round trip's copy is unchanged and runs in `cli.ts build`; this one runs in
6
+ * the gate of the entry that links none of the runtime
7
+ * (`../emitted/index.ts`), and the selftest's `RC28` holds the two to the
8
+ * same lines on every recipe and on this assertion's mutants.
9
+ */
10
+ import type { Verdicts } from '../harness.ts';
11
+ import { isObj, type Json } from '../values.ts';
12
+
13
+ /**
14
+ * How long the array a deform key edits is, read off one raw attachment — or
15
+ * `null` when the file does not say.
16
+ *
17
+ * The rule is `readVertices`' own: the attachment's `vertices` is coordinates
18
+ * when its length equals `worldVerticesLength`, and a weight run otherwise. A
19
+ * deform array is therefore one `x, y` pair per **vertex** in the first case and
20
+ * one per **bone influence** (`vertices.length / 3`) in the second — the same
21
+ * count with two different meanings, which is exactly why this measures rather
22
+ * than assumes.
23
+ *
24
+ * `null` for the two shapes that cannot be measured from this object alone: a
25
+ * type with no vertices at all (nothing to deform, and the parser throws on it),
26
+ * and a `linkedmesh`, whose geometry belongs to another attachment.
27
+ */
28
+ function deformArrayLength(att: Json): number | null {
29
+ const type = typeof att.type === 'string' ? att.type : 'region';
30
+ let worldVerticesLength: number;
31
+ if (type === 'mesh') {
32
+ if (!Array.isArray(att.uvs)) return null;
33
+ worldVerticesLength = att.uvs.length;
34
+ } else if (type === 'boundingbox' || type === 'clipping' || type === 'path') {
35
+ if (typeof att.vertexCount !== 'number') return null;
36
+ worldVerticesLength = att.vertexCount * 2;
37
+ } else {
38
+ return null;
39
+ }
40
+ if (!Array.isArray(att.vertices)) return null;
41
+ const vertices = att.vertices as unknown[];
42
+ if (vertices.length === worldVerticesLength) return worldVerticesLength;
43
+ // ⚠️ Weighted, and the length is the INFLUENCE COUNT — which is the sum of the
44
+ // per-vertex bone counts, not a division of this array's length. The file
45
+ // holds `boneCount` followed by `boneIndex, x, y, weight` per influence, so a
46
+ // one-bone vertex is FIVE numbers; `readVertices` unpacks that into three
47
+ // numbers per influence, which is where the parser's own `/3*2` comes from and
48
+ // exactly why it cannot be applied to the raw form. Applied here it measured
49
+ // `gallery/flex`'s 77-vertex leaf at 256.667 against its true 154 and put
50
+ // A35's bar two thirds too wide on every weighted mesh — the silence A35
51
+ // exists to break, arriving inside A35.
52
+ //
53
+ // Derived here rather than shared with `src/compile.ts`'s own walk on purpose:
54
+ // the gate re-derives from the emitted file so that it is not checking the
55
+ // compiler's assumptions with the compiler's code. Both had this wrong, which
56
+ // is an argument for a control on each and not for one implementation.
57
+ let influences = 0;
58
+ for (let i = 0; i < vertices.length; ) {
59
+ const n = vertices[i++];
60
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) return null;
61
+ influences += n;
62
+ i += n * 4;
63
+ if (i > vertices.length) return null;
64
+ }
65
+ return influences * 2;
66
+ }
67
+
68
+ export function a35DeformKeysFitTheAttachment({ fail, skip }: Verdicts, raw: Json | null): void {
69
+ if (!raw) return skip('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', 'the skeleton JSON did not parse (A00 owns that failure)');
70
+ if (!isObj(raw.animations)) return skip('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', 'the skeleton declares no animations');
71
+ /** skin name -> slot -> attachment, straight off the raw JSON. */
72
+ const skins = new Map<string, Json>();
73
+ for (const skin of Array.isArray(raw.skins) ? (raw.skins as unknown[]) : []) {
74
+ if (isObj(skin) && typeof skin.name === 'string' && isObj(skin.attachments)) {
75
+ skins.set(skin.name, skin.attachments as Json);
76
+ }
77
+ }
78
+ let sawATimeline = false;
79
+ let measured = 0;
80
+ for (const [animName, anim] of Object.entries(raw.animations as Json)) {
81
+ if (!isObj(anim) || !isObj(anim.attachments)) continue;
82
+ for (const [skinName, slotMap] of Object.entries(anim.attachments as Json)) {
83
+ if (!isObj(slotMap)) continue;
84
+ for (const [slotName, attMap] of Object.entries(slotMap)) {
85
+ if (!isObj(attMap)) continue;
86
+ for (const [attName, timelines] of Object.entries(attMap)) {
87
+ if (!isObj(timelines) || !Array.isArray(timelines.deform)) continue;
88
+ sawATimeline = true;
89
+ const at = `animation "${animName}" deform ${skinName}/${slotName}/${attName}`;
90
+ const attachment = isObj(skins.get(skinName)?.[slotName])
91
+ ? ((skins.get(skinName)![slotName] as Json)[attName] as unknown)
92
+ : undefined;
93
+ if (!isObj(attachment)) {
94
+ fail(
95
+ 'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT',
96
+ `${at}: skin "${skinName}" has no attachment "${attName}" on slot "${slotName}"; the parser throws ` +
97
+ '`Timeline attachment not found`',
98
+ );
99
+ continue;
100
+ }
101
+ const length = deformArrayLength(attachment);
102
+ if (length === null) {
103
+ // A linked mesh takes its geometry from another attachment, so the
104
+ // raw file cannot state this one's length. Saying nothing beats
105
+ // inventing a bound: an assertion with a default is how "nothing to
106
+ // measure" becomes a measurement of the wrong thing.
107
+ continue;
108
+ }
109
+ measured++;
110
+ const keys = timelines.deform as unknown[];
111
+ if (keys.length === 0) {
112
+ fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at}: the key array is empty; the parser skips the timeline in silence`);
113
+ continue;
114
+ }
115
+ keys.forEach((key, k) => {
116
+ if (!isObj(key)) {
117
+ fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: not an object`);
118
+ return;
119
+ }
120
+ const vertices = key.vertices;
121
+ if (vertices === undefined || vertices === null) return; // "back to setup" — nothing to fit
122
+ if (!Array.isArray(vertices)) {
123
+ fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: vertices is ${JSON.stringify(vertices)}, not an array`);
124
+ return;
125
+ }
126
+ const offset = key.offset === undefined ? 0 : key.offset;
127
+ if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0) {
128
+ fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: offset is ${JSON.stringify(key.offset)}`);
129
+ return;
130
+ }
131
+ // ⛔ No parity clause here, and the header says why: an odd `offset`
132
+ // and an odd-length run are both what a trimmed editor export looks
133
+ // like, and the parser has no pair arithmetic to be misaligned
134
+ // against (#262).
135
+ if (offset + vertices.length > length) {
136
+ fail(
137
+ 'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT',
138
+ `${at} key ${k}: the run covers ${offset}..${offset + vertices.length} of a ${length}-long deform ` +
139
+ 'array; everything past the end is copied into a Float32Array and dropped without a word',
140
+ );
141
+ }
142
+ for (const n of vertices as unknown[]) {
143
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
144
+ fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: the run holds a non-finite value ${JSON.stringify(n)}`);
145
+ break;
146
+ }
147
+ }
148
+ });
149
+ }
150
+ }
151
+ }
152
+ }
153
+ if (!sawATimeline) return skip('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', 'no animation carries a deform timeline');
154
+ if (measured === 0) {
155
+ return skip(
156
+ 'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT',
157
+ 'every deform timeline here keys an attachment whose vertex count the raw file does not state (a linked mesh)',
158
+ );
159
+ }
160
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The round trip's rules, restated over the text rigc emitted (issue #1060,
3
+ * step 4e of #380) — the half of the gate the entry that links none of
4
+ * spine-core runs beside the model side (`../model/index.ts`).
5
+ *
6
+ * ⭐ **Every one of the 49 is accounted for, by name.** The model side runs
7
+ * the moved assertions (`MOVED_ASSERTIONS`) over the document. What is left —
8
+ * the complement, read off `ASSERTION_KIND` rather than listed — is the
9
+ * round trip's own: A00, A01, A02, A05, A07, A16, A18, A31, A35 at the time of
10
+ * writing. Each of those but one reads the raw text, not what spine-core
11
+ * loaded: the atlas's line layout (A07), the skeleton JSON's draw-order
12
+ * offsets, deform runs, version label, legacy arrays, legacy bone key and
13
+ * curve arrays (A31, A35, A16, A01, A02, A05), and A18's second compile. So
14
+ * each is restated here over the same text, by a body under `../bodies/` that
15
+ * is the round trip's clause word for word, and prints the round trip's line
16
+ * (`RC28` holds the two on every recipe and on each assertion's mutants).
17
+ * The one that cannot be is **A00**: it is spine-core's parser loading the
18
+ * pair, and there is no second reader of the format to stand in for it. It
19
+ * SKIPs, naming spine-core and where the round trip does run — never a PASS
20
+ * on nothing. A code the complement grows by that this file does not know is
21
+ * an internal error, so a new round-trip rule cannot go unrun here in silence.
22
+ *
23
+ * Links nothing from the runtime.
24
+ */
25
+ import { ASSERTION_KIND, type AssertionProfile } from '../kinds.ts';
26
+ import { verdictHarness, type VerdictLists } from '../harness.ts';
27
+ import { MOVED_ASSERTIONS } from '../model/index.ts';
28
+ import type { Verdicts } from '../harness.ts';
29
+ import type { Json } from '../values.ts';
30
+ import { a01NoLegacyToplevelConstraintArrays } from '../bodies/a01.ts';
31
+ import { a02NoBoneTransformKey } from '../bodies/a02.ts';
32
+ import { a05CurveArrayLength } from '../bodies/a05.ts';
33
+ import { a07AtlasTextShape } from '../bodies/a07.ts';
34
+ import { a16SkeletonVersion43 } from '../bodies/a16.ts';
35
+ import { a18DeterministicEmit } from '../bodies/a18.ts';
36
+ import { a31DrawOrderOffsetsResolve } from '../bodies/a31.ts';
37
+ import { a35DeformKeysFitTheAttachment } from '../bodies/a35.ts';
38
+
39
+ /** What the restated rules read: the emitted texts, the document written beside them, and a second compile's three. */
40
+ export interface EmittedTextInput {
41
+ skeletonText: string;
42
+ atlasText: string;
43
+ /** The document `build` writes — A18 compares it with the second compile's. */
44
+ modelText: string;
45
+ /** A second, independent compile's texts; absent, A18 SKIPs as the round trip's does on a re-gate. */
46
+ reEmit?: { skeletonText: string; atlasText: string; modelText: string };
47
+ profile: AssertionProfile;
48
+ }
49
+
50
+ /** The emitted texts as the bodies read them: the skeleton JSON parsed, or `null` where it does not parse. */
51
+ interface EmittedTexts {
52
+ raw: Json | null;
53
+ input: EmittedTextInput;
54
+ }
55
+
56
+ /** A00's SKIP here: what did not run, and where it does. */
57
+ export const SKIP_NO_ROUND_TRIP =
58
+ "spine-core's parser is the subject of this rule and this entry links none of it — `bun cli.ts build` and `rigc validate` run the round trip, and the selftest and CI run it on every corpus build";
59
+
60
+ /** The code this file SKIPs rather than restates. */
61
+ const A00 = 'A00_ROUNDTRIP_PARSE';
62
+
63
+ /**
64
+ * The restated rules, in `validate()`'s report order — A00's place among them
65
+ * (after A35, before A16) is the round trip's, so the report reads in the order
66
+ * the round trip's does.
67
+ */
68
+ export const EMITTED_TEXT_RULES: ReadonlyArray<{ code: string; run: (v: Verdicts, texts: EmittedTexts) => void }> = [
69
+ { code: 'A07_ATLAS_TEXT_SHAPE', run: (v, t) => a07AtlasTextShape(v, t.input.atlasText) },
70
+ { code: 'A31_DRAW_ORDER_OFFSETS_RESOLVE', run: (v, t) => a31DrawOrderOffsetsResolve(v, t.raw) },
71
+ { code: 'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', run: (v, t) => a35DeformKeysFitTheAttachment(v, t.raw) },
72
+ { code: A00, run: (v) => v.skip(A00, SKIP_NO_ROUND_TRIP) },
73
+ { code: 'A16_SKELETON_VERSION_4_3', run: (v, t) => a16SkeletonVersion43(v, t.raw) },
74
+ { code: 'A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS', run: (v, t) => a01NoLegacyToplevelConstraintArrays(v, t.raw) },
75
+ { code: 'A02_NO_BONE_TRANSFORM_KEY', run: (v, t) => a02NoBoneTransformKey(v, t.raw) },
76
+ { code: 'A05_CURVE_ARRAY_LENGTH', run: (v, t) => a05CurveArrayLength(v, t.raw) },
77
+ {
78
+ code: 'A18_DETERMINISTIC_EMIT',
79
+ run: (v, { input }) => {
80
+ const again = input.reEmit;
81
+ const encoding =
82
+ again === undefined
83
+ ? []
84
+ : [
85
+ ...(again.skeletonText !== input.skeletonText ? ['recompiling produced a different skeleton.json'] : []),
86
+ ...(again.atlasText !== input.atlasText ? ['recompiling produced a different skeleton.atlas'] : []),
87
+ ];
88
+ a18DeterministicEmit(v, { again: again !== undefined, encoding, first: input.modelText, second: again?.modelText ?? '' });
89
+ },
90
+ },
91
+ ];
92
+
93
+ /** Every code the model side does not run, read off the registry — what this file must account for. */
94
+ export function roundTripOnlyCodes(): string[] {
95
+ const moved = new Set(MOVED_ASSERTIONS.map((m) => m.code));
96
+ return Object.keys(ASSERTION_KIND).filter((code) => !moved.has(code));
97
+ }
98
+
99
+ /** The restated rules over one build's emitted texts, in `validate()`'s report shape. */
100
+ export function validateEmittedText(input: EmittedTextInput): VerdictLists & { profile: AssertionProfile } {
101
+ const known = new Set(EMITTED_TEXT_RULES.map((r) => r.code));
102
+ const unrun = roundTripOnlyCodes().filter((code) => !known.has(code));
103
+ if (unrun.length > 0) {
104
+ throw new Error(`internal: ${unrun.join(', ')} runs on neither side here — the model side does not run it and src/assertions/emitted/index.ts neither restates nor SKIPs it`);
105
+ }
106
+ const h = verdictHarness(input.profile, ASSERTION_KIND, 'the emitted text');
107
+ let raw: Json | null = null;
108
+ try {
109
+ raw = JSON.parse(input.skeletonText) as Json;
110
+ } catch (err) {
111
+ h.fail(A00, `skeleton JSON is not parseable: ${(err as Error).message}`);
112
+ }
113
+ for (const rule of EMITTED_TEXT_RULES) {
114
+ if (rule.code === A00 && raw === null) continue;
115
+ h.check(rule.code, () => rule.run(h.verdicts, { raw, input }));
116
+ }
117
+ const { failures, passed, skipped, profileSkipped, stats } = h;
118
+ return { failures, passed, skipped, profileSkipped, stats, profile: input.profile };
119
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The profiles a gate judges under, the one the CLI uses when `--profile` is
3
+ * absent, and the printer of a gate's report — moved here unchanged from
4
+ * `src/validate.ts` (issue #1060), which re-exports all three: the entry that
5
+ * links none of spine-core gates a build on the model side
6
+ * (`./model/index.ts`) and prints its report with the same printer, and
7
+ * `src/validate.ts` links the runtime. Nothing here links it.
8
+ */
9
+ import type { AssertionProfile } from './kinds.ts';
10
+ import type { VerdictLists } from './harness.ts';
11
+
12
+ export const VALIDATE_PROFILES: readonly AssertionProfile[] = ['spine', 'spine-html'];
13
+
14
+ /**
15
+ * What the CLI uses when `--profile` is absent — and ONLY the CLI. This is not
16
+ * `validate()`'s default; that function has none (`ValidateProfile` in
17
+ * `src/validate.ts` says why).
18
+ *
19
+ * `spine` since issue #221. The published package's pitch is "the output imports
20
+ * into the Spine editor", which is exactly the question `spine` asks, and a
21
+ * stranger's first build was being judged instead against one renderer's policy
22
+ * and one project's canvas budget — 14 rules they have no stake in, with the
23
+ * escape hatch documented only in prose. Defaults beat prose. `spine-html` is
24
+ * still one flag away, and every report names the profile that judged it.
25
+ */
26
+ export const CLI_DEFAULT_PROFILE: AssertionProfile = 'spine';
27
+
28
+ export function reportLines(report: VerdictLists & { profile: AssertionProfile }): string[] {
29
+ const lines: string[] = [];
30
+ // The profile goes FIRST and names what it left out. A report that says
31
+ // "green" without saying which rulebook produced it is the one thing this
32
+ // switch could make worse than no switch: `--profile spine` green means
33
+ // "valid Spine", never "passes the renderer policy".
34
+ const renderer = report.profileSkipped.filter((p) => p.kind === 'renderer').length;
35
+ const archetype = report.profileSkipped.filter((p) => p.kind === 'archetype').length;
36
+ lines.push(
37
+ report.profileSkipped.length === 0
38
+ ? ` .. profile ${report.profile} — every assertion applies`
39
+ : ` .. profile ${report.profile} — ${renderer} renderer-policy and ${archetype} archetype assertion(s) do not apply`,
40
+ );
41
+ for (const name of report.passed) lines.push(` PASS ${name}`);
42
+ for (const s of report.skipped) lines.push(` SKIP ${s.assertion}: ${s.reason}`);
43
+ for (const p of report.profileSkipped) lines.push(` PROF ${p.assertion}: ${p.kind} rule, not in profile "${report.profile}"`);
44
+ for (const f of report.failures) lines.push(` FAIL ${f.assertion}: ${f.detail}`);
45
+ // How many of them MEASURED anything, which is the figure the rows above do
46
+ // not hand a reader (issue #568). Counting `PASS` lines answers a different
47
+ // question — before the sweep that closed #568 a run could print seven of
48
+ // them over a candidate on which five rules had not executed at all.
49
+ //
50
+ // ⚠️ Every figure here is a count of ASSERTIONS and not of rows, which is why
51
+ // the failed side is a Set: `fail()` is called once per finding, so one
52
+ // assertion can print six `FAIL` lines, and a line-count would report 47 of
53
+ // 42. The four buckets partition `ASSERTION_NAMES`, so the total is derived
54
+ // by adding them rather than stated — a `42` written here would be the one
55
+ // number in the report that no run could contradict.
56
+ const failed = new Set(report.failures.map((f) => f.assertion));
57
+ const measured = report.passed.length + failed.size;
58
+ const total = measured + report.skipped.length + report.profileSkipped.length;
59
+ lines.push(
60
+ ` .. ${total} assertions: ${measured} measured (${report.passed.length} passed, ${failed.size} failed), ` +
61
+ `${report.skipped.length} skipped, ${report.profileSkipped.length} not in profile "${report.profile}"`,
62
+ );
63
+ return lines;
64
+ }