spine-rigc 0.28.0 → 0.29.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.
- package/docs/AUTHORING.md +192 -83
- package/docs/INGEST.md +13 -1
- package/docs/SPEC_COVERAGE.md +1 -1
- package/package.json +1 -1
- package/src/compile.ts +307 -282
- package/src/ingest.ts +68 -13
- package/src/rig.ts +32 -21
- package/src/timelines.ts +67 -13
- package/src/types.ts +12 -6
- package/src/validate.ts +288 -13
- package/tools/editor_roundtrip.ts +1 -1
package/src/compile.ts
CHANGED
|
@@ -151,22 +151,20 @@ const FRAME = 1 / 60;
|
|
|
151
151
|
|
|
152
152
|
/**
|
|
153
153
|
* The order the emitted `animations` object is keyed in: **the Spine editor's own
|
|
154
|
-
* comparator,
|
|
155
|
-
* (#
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* ⚠️ This emitted **codepoint** order until issue #543
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* what the rule guaranteed), which is why this change is invisible to every rig
|
|
169
|
-
* in the tree.
|
|
154
|
+
* comparator, read off five JSON round trips through a licensed 4.3.26 editor**
|
|
155
|
+
* (issue #728) — with a name set refused only where those round trips leave the
|
|
156
|
+
* pair's order open.
|
|
157
|
+
*
|
|
158
|
+
* ⚠️ This emitted **codepoint** order until issue #543 and a *family* of natural
|
|
159
|
+
* case-insensitive comparators until #728, and both refusals were the thing that
|
|
160
|
+
* made the emitted order agree with the editor: every pair the candidates could
|
|
161
|
+
* order differently was a `CompileError`. That is sound and it over-refuses by
|
|
162
|
+
* construction, because the quantifier stands in for a measurement — so a rig
|
|
163
|
+
* with a folder in a name, an accent, a capital or a space in it was refused
|
|
164
|
+
* rather than emitted in the order the editor returns. Measuring the comparator
|
|
165
|
+
* instead moves the refusal onto what is still open, and moves no byte: on every
|
|
166
|
+
* set the old rule accepted, the two orders are the same, which is why this is
|
|
167
|
+
* invisible to every rig in the tree.
|
|
170
168
|
*
|
|
171
169
|
* ## Why the emitter has an opinion about this at all
|
|
172
170
|
*
|
|
@@ -220,52 +218,91 @@ const FRAME = 1 / 60;
|
|
|
220
218
|
* The editor was then measured directly (issue #539). Two rigs, each varying one
|
|
221
219
|
* axis: `Turn, sweep, wave` came back `sweep, Turn, wave`, and
|
|
222
220
|
* `turn10, turn2, zoom` came back `turn2, turn10, zoom`. The intersection leaves
|
|
223
|
-
* one hypothesis — **natural order, case-insensitive
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
221
|
+
* one hypothesis — **natural order, case-insensitive** — and that is a *family*
|
|
222
|
+
* of comparators rather than one, which is why #543 refused every pair the family
|
|
223
|
+
* could disagree about rather than choosing a member of it.
|
|
224
|
+
*
|
|
225
|
+
* ⇒ 🔑 **Issue #728 replaced the quantifier with an oracle.** Five name sets went
|
|
226
|
+
* through the licensed 4.3.26 editor as JSON and came back as JSON, and they are
|
|
227
|
+
* in the tree: `fixtures/editor-order/probe{1..5}.{in,out}.json`. They are the
|
|
228
|
+
* only source of what follows, and nothing here is typed from memory —
|
|
229
|
+
* `editorNameOrder` reproduces every one of the ten orders they hold (five skin
|
|
230
|
+
* lists, five animation lists), which the selftest re-derives from the files.
|
|
231
|
+
*
|
|
232
|
+
* What the ten orders establish, each clause with the pair that shows it:
|
|
233
|
+
*
|
|
234
|
+
* - **Case is folded, and folded UP.** `_x` comes back after `z` (`_` is 0x5F —
|
|
235
|
+
* above `Z`, below `a`), and `ä, é, ß` in that order (0xC4 < 0xC9 < 0xDF). The
|
|
236
|
+
* fold is per character and only where the character has a single-character
|
|
237
|
+
* upper case: `ß` is returned at 0xDF, which is where it sorts unfolded, and
|
|
238
|
+
* **not** among the `S`s — so `toUpperCase()` on the whole string, which writes
|
|
239
|
+
* `SS`, is refuted.
|
|
240
|
+
* - **A run of digits compares as a number**, at any position and after any
|
|
241
|
+
* script: `x2 < x10`, `2 < 10`, `中2 < 中10`.
|
|
242
|
+
* - **Two runs that are the same number are deferred, not decided.** `a01` comes
|
|
243
|
+
* back before `a1b`, so the comparison walked past the equal runs; and `a1`
|
|
244
|
+
* before `a01`, so the deferred difference — leading zeros later — is what
|
|
245
|
+
* breaks the tie at the end.
|
|
246
|
+
* - **A space is skipped for the comparison** and the name with fewer of them
|
|
247
|
+
* wins the tie: `bc < b c = B C < b c`. The tie-break ORDER is measured too:
|
|
248
|
+
* `a 1` comes back before `a01`, which fewer-spaces-first would reverse.
|
|
249
|
+
* - **Everything else is code point order after folding**, and punctuation is not
|
|
250
|
+
* ignorable: `a-1, a.1, a1 … a1b, a_1` places three separate marks by their own
|
|
251
|
+
* code points, which no punctuation-ignoring collator produces.
|
|
252
|
+
* - **A remaining tie keeps FILE ORDER** (`turn, Turn, TURN`; `b c, B C`; `e, E`;
|
|
253
|
+
* `x2, X2`; `Mango, mango`). ⭐ So a pair the comparator cannot separate is
|
|
254
|
+
* *known* rather than ambiguous: the emitted order is the spec's own
|
|
255
|
+
* declaration order and the editor keeps it, which is why a case-only pair is
|
|
256
|
+
* no longer refused.
|
|
257
|
+
* - **Folders**: at the root, leaves then folders; inside a folder, sub-folders
|
|
258
|
+
* then leaves; siblings at every level by the rule above. `h` comes back before
|
|
259
|
+
* `f/sub1/…`, and `f/sub1/x` before `f/leafA`.
|
|
260
|
+
* - **`skins` and `animations` came back in the same order** — probes 4 and 5
|
|
261
|
+
* carried one name list as both collections, and both came back identically.
|
|
262
|
+
* One comparator, measured, which is what lets the two share one certificate.
|
|
239
263
|
*
|
|
240
264
|
* 🔒 The emitted key order is still the one claim in the emitter no gate can see.
|
|
241
265
|
* spine-core reads back everything else rigc writes; it does not sort. What
|
|
242
|
-
* replaces an oracle is
|
|
243
|
-
* it is the answer they all give.
|
|
266
|
+
* replaces an oracle here is a stored one: the editor's own outputs, in the tree.
|
|
244
267
|
*
|
|
245
268
|
* ⭐ The two-sided result is on editor-written data. Sorting each of the 105
|
|
246
|
-
* collections above by `
|
|
247
|
-
* three no codepoint sort can — and refuses **none** of them.
|
|
248
|
-
* reproduced 102 and refused those same 3.
|
|
269
|
+
* collections above by `editorNameOrder` reproduces **105 of 105** — including
|
|
270
|
+
* the three no codepoint sort can — and refuses **none** of them.
|
|
249
271
|
*
|
|
250
|
-
* The
|
|
272
|
+
* The comparator is hand-rolled and locale-independent, which `A18` requires:
|
|
251
273
|
* `localeCompare` would make the emitted bytes a property of the machine.
|
|
252
274
|
*/
|
|
253
275
|
function editorAnimationOrder<T>(animations: Record<string, T>): Record<string, T> {
|
|
254
|
-
const names = Object.keys(animations);
|
|
255
|
-
// ⭐ The sort is the check's own OUTPUT rather than a second reading of the same
|
|
256
|
-
// names: the refusal walks every pair and hands back the verdict it certified
|
|
257
|
-
// for each, so "the order rigc emits" and "the order rigc checked" cannot drift
|
|
258
|
-
// into two readings the way a shared comparator still can.
|
|
259
|
-
const verdicts = refuseNamesTheEditorCouldKeyDifferently(names, ANIMATION_ORDER);
|
|
260
|
-
// Nested rather than keyed on a joined string: any character this could join
|
|
261
|
-
// on is one an animation name is allowed to contain, and two pairs that
|
|
262
|
-
// collided would silently share one verdict.
|
|
263
|
-
names.sort((a, b) => verdicts.get(a)?.get(b) ?? 0);
|
|
264
276
|
const ordered: Record<string, T> = {};
|
|
265
|
-
for (const name of
|
|
277
|
+
for (const name of editorNamesInOrder(Object.keys(animations), 'animations')) ordered[name] = animations[name];
|
|
266
278
|
return ordered;
|
|
267
279
|
}
|
|
268
280
|
|
|
281
|
+
/**
|
|
282
|
+
* The order the editor writes a list of skin or animation names in, refusing by
|
|
283
|
+
* name every pair the five round trips leave open.
|
|
284
|
+
*
|
|
285
|
+
* ⭐ The sort is the refusal's own OUTPUT rather than a second reading of the
|
|
286
|
+
* same names: the walk visits every pair and hands back the verdict it certified
|
|
287
|
+
* for each, so "the order rigc emits" and "the order rigc checked" cannot drift
|
|
288
|
+
* into two readings the way a shared comparator still can. The verdict map is
|
|
289
|
+
* nested rather than keyed on a joined string — any character it could join on is
|
|
290
|
+
* one a name is allowed to contain, and two pairs that collided would silently
|
|
291
|
+
* share one verdict.
|
|
292
|
+
*
|
|
293
|
+
* 🔒 Exported because it is what the stored probes are re-derived against. Both
|
|
294
|
+
* emitters are this function with a collection's own sentence attached, so a
|
|
295
|
+
* control that sorts a probe's input list with it is measuring the emit rather
|
|
296
|
+
* than a second implementation of it.
|
|
297
|
+
*/
|
|
298
|
+
export function editorNamesInOrder(names: readonly string[], collection: 'animations' | 'skins'): string[] {
|
|
299
|
+
const verdicts = refuseNamesTheEditorCouldKeyDifferently(
|
|
300
|
+
names,
|
|
301
|
+
collection === 'skins' ? SKIN_ORDER : ANIMATION_ORDER,
|
|
302
|
+
);
|
|
303
|
+
return [...names].sort((a, b) => verdicts.get(a)?.get(b) ?? 0);
|
|
304
|
+
}
|
|
305
|
+
|
|
269
306
|
/** The skin the editor keeps at index 0 whatever its name sorts as. */
|
|
270
307
|
const DEFAULT_SKIN = 'default';
|
|
271
308
|
|
|
@@ -292,252 +329,237 @@ const DEFAULT_SKIN = 'default';
|
|
|
292
329
|
* ## What is measured, and it is two separate facts
|
|
293
330
|
*
|
|
294
331
|
* - **`default` is pinned, not sorted.** `alpha` sorts before `default` under
|
|
295
|
-
* every candidate comparator and came back *after* it.
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
* it
|
|
308
|
-
* the same way is exactly the shape of the inference that put `skins` on the
|
|
309
|
-
* safe side of the list in the first place. `measuredSkinOrder` therefore
|
|
310
|
-
* certifies a pair only where the natural case-insensitive family **and plain
|
|
311
|
-
* codepoint** agree, which is the pre-#543 rule — correct here for the reason
|
|
312
|
-
* it was too strong there: for animations, codepoint had been *refuted*; for
|
|
313
|
-
* skins, nothing has refuted anything.
|
|
332
|
+
* every candidate comparator and came back *after* it (#541). The stored
|
|
333
|
+
* probes say it again on names nothing else could explain: probe 1 declares the
|
|
334
|
+
* skins `2` and `10` and probe 3 declares `A`, each of which folds to a
|
|
335
|
+
* character below `D`, and all three came back **after** `default`. `default`
|
|
336
|
+
* is a fixed name in the format — the runtime's own `defaultSkin` is index 0 —
|
|
337
|
+
* so a skin renamed away from it is an ordinary name and sorts as one.
|
|
338
|
+
* - **The rest are ordered by the same comparator `animations` uses.** ⚠️ This
|
|
339
|
+
* was the open question until #728, and the answer is measured rather than
|
|
340
|
+
* inferred: probes 4 and 5 carried one name list as both collections and both
|
|
341
|
+
* came back in one order. `measuredSkinOrder`, which certified a skin pair only
|
|
342
|
+
* where the natural family and plain codepoint agreed — because nothing had
|
|
343
|
+
* ruled codepoint out for skins — is retired by that, together with the two
|
|
344
|
+
* refusals it alone raised.
|
|
314
345
|
*
|
|
315
346
|
* ⇒ Every skin name in this tree is lower-case ASCII without digits, so no
|
|
316
|
-
* emitted byte moves and no rig is refused. What moves is the claim
|
|
347
|
+
* emitted byte moves and no rig is refused. What moves is the claim, and what
|
|
348
|
+
* stops being refused is a rig with capitals, folders or digits in its skins.
|
|
317
349
|
*/
|
|
318
350
|
function editorSkinOrder<T extends { name: string }>(skins: readonly T[]): T[] {
|
|
319
351
|
const pinned = skins.filter((skin) => skin.name === DEFAULT_SKIN);
|
|
320
352
|
const rest = skins.filter((skin) => skin.name !== DEFAULT_SKIN);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
);
|
|
325
|
-
rest.sort((a, b) =>
|
|
326
|
-
return [...pinned, ...rest];
|
|
353
|
+
// Ranked rather than looked up by name: two skins answering to one name are a
|
|
354
|
+
// defect another check names, and a lookup would silently drop one of them.
|
|
355
|
+
// Equal ranks leave the pair where the spec put it, which is what a tie means.
|
|
356
|
+
const rank = new Map(editorNamesInOrder(rest.map((skin) => skin.name), 'skins').map((name, at) => [name, at]));
|
|
357
|
+
return [...pinned, ...[...rest].sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0))];
|
|
327
358
|
}
|
|
328
359
|
|
|
329
|
-
/**
|
|
330
|
-
|
|
331
|
-
* on: the digits and, once case is folded, the lower-case ASCII letters.
|
|
332
|
-
* Everything else — `-`, `_`, a space, an accented letter — is worth something
|
|
333
|
-
* different to a collator that ignores punctuation than to one that does not, so
|
|
334
|
-
* a pair those decide is not settled.
|
|
335
|
-
*/
|
|
336
|
-
const SETTLED_CHARS = /[0-9a-z]/;
|
|
360
|
+
/** What separates a folder from what it holds, in a skin or an animation name. */
|
|
361
|
+
const NAME_FOLDER = '/';
|
|
337
362
|
|
|
338
|
-
/**
|
|
339
|
-
const
|
|
363
|
+
/** An ASCII digit — the characters a run of which the editor reads as a number. */
|
|
364
|
+
const DIGIT = /[0-9]/;
|
|
340
365
|
|
|
341
|
-
|
|
366
|
+
/** The one whitespace character the round trips measured the editor skipping. */
|
|
367
|
+
const MEASURED_SPACE = / /;
|
|
368
|
+
|
|
369
|
+
/** The reading the round trips leave standing beside it: skip every whitespace. */
|
|
370
|
+
const WIDER_SPACE = /\s/;
|
|
342
371
|
|
|
343
372
|
/** What made a pair's order a matter of opinion, and what the author has to do. */
|
|
344
373
|
interface Ambiguity {
|
|
345
|
-
kind: '
|
|
374
|
+
kind: 'number' | 'separator' | 'folder';
|
|
346
375
|
because: string;
|
|
347
376
|
repair: string;
|
|
348
377
|
}
|
|
349
378
|
|
|
350
379
|
/**
|
|
351
|
-
*
|
|
352
|
-
* unmeasured choices is what decides them, what that choice is and what the
|
|
353
|
-
* author has to do about it.
|
|
380
|
+
* One name folded the way the round trips returned it, as code points.
|
|
354
381
|
*
|
|
355
|
-
*
|
|
382
|
+
* ⚠️ Per character and UP, and only where the character has a single-character
|
|
383
|
+
* upper case. `'ß'.toUpperCase()` is `'SS'`, and the editor returned `ß` at its
|
|
384
|
+
* own code point rather than among the `S`s — so whole-string `toUpperCase` is
|
|
385
|
+
* refuted and this leaves such a character alone.
|
|
386
|
+
*/
|
|
387
|
+
function foldUp(name: string): string[] {
|
|
388
|
+
const out: string[] = [];
|
|
389
|
+
for (const char of name) {
|
|
390
|
+
const up = char.toUpperCase();
|
|
391
|
+
out.push([...up].length === 1 ? up : char);
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** What one reading of the comparison skipped, and what it had left to compare. */
|
|
397
|
+
interface ReadName {
|
|
398
|
+
chars: string[];
|
|
399
|
+
skipped: number;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function readName(name: string, skip: RegExp): ReadName {
|
|
403
|
+
const chars: string[] = [];
|
|
404
|
+
let skipped = 0;
|
|
405
|
+
for (const char of foldUp(name)) {
|
|
406
|
+
if (skip.test(char)) skipped++;
|
|
407
|
+
else chars.push(char);
|
|
408
|
+
}
|
|
409
|
+
return { chars, skipped };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The editor's order for two names that hold no folder separator, under ONE
|
|
414
|
+
* reading of which characters the comparison skips.
|
|
415
|
+
*
|
|
416
|
+
* `'deferred'` is the one thing this cannot answer: two names whose only
|
|
417
|
+
* difference is several digit runs that are each the same number written two
|
|
418
|
+
* ways, pointing opposite ways. The round trips measured the deferral (`a01`
|
|
419
|
+
* before `a1b`) and the direction (`a1` before `a01`) over ONE such run, and a
|
|
420
|
+
* comparator that keeps the first deferred difference and one that keeps the
|
|
421
|
+
* last are both consistent with that.
|
|
422
|
+
*/
|
|
423
|
+
function compareLeaf(a: string, b: string, skip: RegExp): number | 'deferred' {
|
|
424
|
+
const ra = readName(a, skip);
|
|
425
|
+
const rb = readName(b, skip);
|
|
426
|
+
const deferred: number[] = [];
|
|
427
|
+
let i = 0;
|
|
428
|
+
let j = 0;
|
|
429
|
+
while (i < ra.chars.length && j < rb.chars.length) {
|
|
430
|
+
const x = ra.chars[i];
|
|
431
|
+
const y = rb.chars[j];
|
|
432
|
+
if (DIGIT.test(x) && DIGIT.test(y)) {
|
|
433
|
+
let ei = i;
|
|
434
|
+
while (ei < ra.chars.length && DIGIT.test(ra.chars[ei])) ei++;
|
|
435
|
+
let ej = j;
|
|
436
|
+
while (ej < rb.chars.length && DIGIT.test(rb.chars[ej])) ej++;
|
|
437
|
+
const runA = ra.chars.slice(i, ei).join('');
|
|
438
|
+
const runB = rb.chars.slice(j, ej).join('');
|
|
439
|
+
const na = BigInt(runA);
|
|
440
|
+
const nb = BigInt(runB);
|
|
441
|
+
if (na !== nb) return na < nb ? -1 : 1;
|
|
442
|
+
// One number written two ways. The editor walked PAST it — `a01` came back
|
|
443
|
+
// before `a1b`, which deciding here would reverse — and settled it at the
|
|
444
|
+
// end, leading zeros later.
|
|
445
|
+
if (runA.length !== runB.length) deferred.push(runA.length < runB.length ? -1 : 1);
|
|
446
|
+
i = ei;
|
|
447
|
+
j = ej;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (x !== y) return (x.codePointAt(0) ?? 0) < (y.codePointAt(0) ?? 0) ? -1 : 1;
|
|
451
|
+
i++;
|
|
452
|
+
j++;
|
|
453
|
+
}
|
|
454
|
+
if (i < ra.chars.length) return 1;
|
|
455
|
+
if (j < rb.chars.length) return -1;
|
|
456
|
+
// The two tie-breaks, in the order the round trips put them: `a 1` came back
|
|
457
|
+
// before `a01`, which the skipped-character count alone would reverse.
|
|
458
|
+
if (deferred.length > 0) {
|
|
459
|
+
if (deferred.some((one) => one !== deferred[0])) return 'deferred';
|
|
460
|
+
return deferred[0];
|
|
461
|
+
}
|
|
462
|
+
if (ra.skipped !== rb.skipped) return ra.skipped < rb.skipped ? -1 : 1;
|
|
463
|
+
return 0;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* How the editor orders two names that sit in one folder — or what is still open
|
|
468
|
+
* about the pair, and what the author has to do about it.
|
|
356
469
|
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
* hazards is open-ended — one was missing the day it was written — so what is
|
|
362
|
-
* implemented is the other direction: a verdict is returned only where the
|
|
363
|
-
* position that decides the pair is one **every** comparator consistent with the
|
|
364
|
-
* measurement must read the same way, and everything else stops the build.
|
|
365
|
-
*
|
|
366
|
-
* ## The four choices this returns an `Ambiguity` for, and why each is free
|
|
367
|
-
*
|
|
368
|
-
* - **case** (`Turn` against `turn`) — the two fold together, so only a tie-break
|
|
369
|
-
* separates them and nobody has measured which way it breaks.
|
|
370
|
-
* - **number**, one number written twice (`turn01` against `turn1`) — the runs are
|
|
371
|
-
* numerically equal, so again only a tie-break separates them: shorter-first,
|
|
372
|
-
* longer-first and lexicographic are all real implementations.
|
|
373
|
-
* - **number**, a digit run against a word (`1turn` against `turn`) — comparators
|
|
374
|
-
* differ on whether a number sorts before a word.
|
|
375
|
-
* - **separator** — the deciding character is neither a letter nor a digit. This
|
|
376
|
-
* one covers two adversaries at once: a collator that treats `-` or a space as
|
|
377
|
-
* ignorable, and the plain fact that `_` sits *between* `Z` and `a`, so
|
|
378
|
-
* `x.toUpperCase()` and `x.toLowerCase()` order `wave_x` against `wavea`
|
|
379
|
-
* oppositely. Neither name needs a capital in it for that to bite.
|
|
380
|
-
*
|
|
381
|
-
* ⚠️ There used to be a fifth and a sixth, and both were artefacts of emitting
|
|
382
|
-
* codepoint rather than facts about the editor (issue #543). A pair folding the
|
|
383
|
-
* other way (`Turn` against `sweep`) and two digit runs of unequal width
|
|
384
|
-
* (`turn10` against `turn2`) are exactly the two pairs the editor **was**
|
|
385
|
-
* measured on, and every comparator in the family orders them the same way — so
|
|
386
|
-
* they are now emitted in that order rather than refused. `flare1` against
|
|
387
|
-
* `flare10`, the over-refusal #542 named and accepted, goes with them.
|
|
388
|
-
*
|
|
389
|
-
* ## What it was tested against
|
|
390
|
-
*
|
|
391
|
-
* 22 comparators — 12 hand-rolled naturals (fold up/down × three leading-zero
|
|
392
|
-
* tie-breaks × digits-before/after words), 2 plain case-insensitive ones, and 8
|
|
393
|
-
* `Intl.Collator`s (`numeric: true`, four sensitivities × `ignorePunctuation`) —
|
|
394
|
-
* over every pair of names up to 4 characters from `{a B 0 1 2 - _ space}`:
|
|
395
|
-
* **10,948,860 pairs**. Three figures come off that bank and each is load-bearing:
|
|
396
|
-
*
|
|
397
|
-
* - **0 escapes**, where an escape is a pair some comparator orders differently
|
|
398
|
-
* from the verdict returned here. Measured over the **20 natural** members —
|
|
399
|
-
* the 12 hand-rolled and the 8 collators, all of which read a digit run as a
|
|
400
|
-
* number. The 2 plain case-insensitive comparators do dissent (291,684 pairs),
|
|
401
|
-
* and they are the two #539's own measurement refutes: a comparator that reads
|
|
402
|
-
* `turn10` as text cannot return `turn2, turn10`.
|
|
403
|
-
* - **0 pairs move.** On every pair the codepoint rule accepted, this returns the
|
|
404
|
-
* codepoint order — which is why no emitted byte in the tree changes.
|
|
405
|
-
* - the refusal covers **9,140,115** pairs where the codepoint rule covered
|
|
406
|
-
* 10,218,136: 1,078,021 pairs are now built instead of renamed.
|
|
407
|
-
*
|
|
408
|
-
* ⭐ And the two-sided result is on real data: across the 105 editor-written
|
|
409
|
-
* collections the survey above defines, sorting by this reproduces all 105 and
|
|
410
|
-
* refuses none. No false positive, no false negative, against names the editor
|
|
411
|
-
* itself wrote.
|
|
470
|
+
* Two readings are compared rather than one chosen, which is the one thing #543's
|
|
471
|
+
* shape got right and is kept: the probes measured the space and nothing else
|
|
472
|
+
* about whitespace, so a pair a tab or a no-break space decides is a pair two
|
|
473
|
+
* readings of the same measurement order differently.
|
|
412
474
|
*/
|
|
413
|
-
function
|
|
414
|
-
const
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
const separatorRepair = 'rename so the first character that differs is a letter or a digit';
|
|
418
|
-
const la = a.toLowerCase();
|
|
419
|
-
const lb = b.toLowerCase();
|
|
420
|
-
if (la === lb) {
|
|
475
|
+
function leafOrder(a: string, b: string): number | Ambiguity {
|
|
476
|
+
const measured = compareLeaf(a, b, MEASURED_SPACE);
|
|
477
|
+
const wider = compareLeaf(a, b, WIDER_SPACE);
|
|
478
|
+
if (measured === 'deferred' || wider === 'deferred') {
|
|
421
479
|
return {
|
|
422
|
-
kind: '
|
|
423
|
-
because:
|
|
424
|
-
|
|
480
|
+
kind: 'number',
|
|
481
|
+
because:
|
|
482
|
+
`"${a}" and "${b}" hold more than one digit run that is the same number written two ways, and those runs ` +
|
|
483
|
+
'point opposite ways — the round trips measured the editor deferring one such run and never two',
|
|
484
|
+
repair: 'write each number one way — rename so no digit run in either name has a second spelling',
|
|
425
485
|
};
|
|
426
486
|
}
|
|
427
|
-
|
|
428
|
-
while (i < la.length && i < lb.length && la[i] === lb[i]) i++;
|
|
429
|
-
if (i === la.length || i === lb.length) {
|
|
430
|
-
const longer = la.length > lb.length ? a : b;
|
|
431
|
-
const rest = (la.length > lb.length ? la : lb).slice(i);
|
|
432
|
-
if (!SETTLED_CHARS.test(rest)) {
|
|
433
|
-
return {
|
|
434
|
-
kind: 'separator',
|
|
435
|
-
because:
|
|
436
|
-
`"${longer}" is the other name followed by ${JSON.stringify(rest)}, which a comparator that ignores ` +
|
|
437
|
-
'punctuation reads as the same name',
|
|
438
|
-
repair: separatorRepair,
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
} else if (!SETTLED_CHARS.test(la[i]) || !SETTLED_CHARS.test(lb[i])) {
|
|
442
|
-
const deciding = SETTLED_CHARS.test(la[i]) ? lb[i] : la[i];
|
|
487
|
+
if (measured !== wider) {
|
|
443
488
|
return {
|
|
444
489
|
kind: 'separator',
|
|
445
490
|
because:
|
|
446
|
-
`the
|
|
447
|
-
'
|
|
448
|
-
repair:
|
|
491
|
+
`the order of "${a}" and "${b}" turns on a whitespace character that is not a space, and the round trips ` +
|
|
492
|
+
'measured the editor skipping the space alone',
|
|
493
|
+
repair: 'rename so the only whitespace in either name is a space',
|
|
449
494
|
};
|
|
450
495
|
}
|
|
451
|
-
|
|
452
|
-
const rb = runsOf(lb);
|
|
453
|
-
for (let k = 0; k < Math.min(ra.length, rb.length); k++) {
|
|
454
|
-
const x = ra[k];
|
|
455
|
-
const y = rb[k];
|
|
456
|
-
if (x === y) continue;
|
|
457
|
-
const xIsDigits = /^\d/.test(x);
|
|
458
|
-
const yIsDigits = /^\d/.test(y);
|
|
459
|
-
if (xIsDigits && yIsDigits) {
|
|
460
|
-
const nx = BigInt(x);
|
|
461
|
-
const ny = BigInt(y);
|
|
462
|
-
if (nx === ny) {
|
|
463
|
-
return {
|
|
464
|
-
kind: 'number',
|
|
465
|
-
because: `"${x}" and "${y}" are the same number written two ways, so only a tie-break separates them`,
|
|
466
|
-
repair: zerosRepair,
|
|
467
|
-
};
|
|
468
|
-
}
|
|
469
|
-
return nx < ny ? -1 : 1;
|
|
470
|
-
}
|
|
471
|
-
if (xIsDigits !== yIsDigits) {
|
|
472
|
-
return {
|
|
473
|
-
kind: 'number',
|
|
474
|
-
because:
|
|
475
|
-
`one has the digits "${xIsDigits ? x : y}" where the other has "${xIsDigits ? y : x}", and comparators ` +
|
|
476
|
-
'differ on whether a number sorts before a word',
|
|
477
|
-
repair: wordRepair,
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
|
-
return codepoint(x, y);
|
|
481
|
-
}
|
|
482
|
-
// One name's runs are a prefix of the other's — `wave` against `wave1`. Every
|
|
483
|
-
// comparator puts the shorter first; `la === lb` above already took the case
|
|
484
|
-
// where neither is longer.
|
|
485
|
-
return ra.length < rb.length ? -1 : 1;
|
|
496
|
+
return measured;
|
|
486
497
|
}
|
|
487
498
|
|
|
488
499
|
/**
|
|
489
|
-
* How the editor orders two
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
* ##
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
500
|
+
* How the editor orders two skin or animation names, folders and all — or what
|
|
501
|
+
* the five round trips leave open about the pair.
|
|
502
|
+
*
|
|
503
|
+
* ## It is a certificate, not a hazard list
|
|
504
|
+
*
|
|
505
|
+
* Issue #539 proposed checking for "two names differing only in case, or sharing
|
|
506
|
+
* a prefix followed by digit runs of unequal length". **That list misses the rig
|
|
507
|
+
* that produced the measurement**: `Turn` and `sweep` differ in much more than
|
|
508
|
+
* case and carry no digits, and they are the pair the editor reordered. A list of
|
|
509
|
+
* hazards is open-ended — one was missing the day it was written — so what is
|
|
510
|
+
* implemented is the other direction: an order is returned where the round trips
|
|
511
|
+
* settle the pair, and everything else stops the build.
|
|
512
|
+
*
|
|
513
|
+
* ## The two things they do not settle, and why each is genuinely open
|
|
514
|
+
*
|
|
515
|
+
* - **number** — several digit runs that are each one number written two ways,
|
|
516
|
+
* disagreeing. `compareLeaf` says why.
|
|
517
|
+
* - **separator** — a whitespace character that is not a space. `leafOrder` says
|
|
518
|
+
* why.
|
|
519
|
+
* - **folder** — two sibling FOLDERS whose names the comparator cannot separate.
|
|
520
|
+
* A tie between two leaves is settled (file order), but a tie between two
|
|
521
|
+
* folders is not the same fact: the editor holds folders as objects and keeps
|
|
522
|
+
* each one's entries together, and a pairwise verdict of "file order" cannot
|
|
523
|
+
* express that. No round trip carried two folders one fold apart.
|
|
524
|
+
*
|
|
525
|
+
* ⚠️ What is deliberately NOT refused is a character no probe carried. The
|
|
526
|
+
* alternative would be an allowlist of characters kept by hand — the shape this
|
|
527
|
+
* file already rejects — and the competing reading it would be defending against,
|
|
528
|
+
* a collator that treats punctuation as ignorable, is refuted three times over by
|
|
529
|
+
* probe 4: `a-1`, `a.1` and `a_1` came back placed by `-`, `.` and `_` at their
|
|
530
|
+
* own code points, on either side of the digits.
|
|
517
531
|
*/
|
|
518
|
-
function
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
532
|
+
function editorNameOrder(a: string, b: string): number | Ambiguity {
|
|
533
|
+
const pa = a.split(NAME_FOLDER);
|
|
534
|
+
const pb = b.split(NAME_FOLDER);
|
|
535
|
+
const depth = Math.min(pa.length, pb.length);
|
|
536
|
+
let level = 0;
|
|
537
|
+
while (level < depth && pa[level] === pb[level]) level++;
|
|
538
|
+
if (level === depth) {
|
|
539
|
+
// One name's whole path is the start of the other's: at that level one is a
|
|
540
|
+
// LEAF and the other is the FOLDER of the same name. `x` against `x/y`.
|
|
541
|
+
if (pa.length === pb.length) return 0;
|
|
542
|
+
const shorterLeads = depth === 1;
|
|
543
|
+
return (pa.length < pb.length) === shorterLeads ? -1 : 1;
|
|
544
|
+
}
|
|
545
|
+
// Both sit in the same parent. Which side of the parent they sit on comes
|
|
546
|
+
// first, and only then their names: at the root leaves lead, inside a folder
|
|
547
|
+
// sub-folders do.
|
|
548
|
+
const aIsLeaf = level === pa.length - 1;
|
|
549
|
+
const bIsLeaf = level === pb.length - 1;
|
|
550
|
+
const leavesLead = level === 0;
|
|
551
|
+
if (aIsLeaf !== bIsLeaf) return aIsLeaf === leavesLead ? -1 : 1;
|
|
552
|
+
const verdict = leafOrder(pa[level], pb[level]);
|
|
553
|
+
if (typeof verdict !== 'number' || verdict !== 0) return verdict;
|
|
554
|
+
if (aIsLeaf) return 0;
|
|
555
|
+
return {
|
|
556
|
+
kind: 'folder',
|
|
557
|
+
because:
|
|
558
|
+
`"${a}" and "${b}" sit in the sibling folders "${pa[level]}" and "${pb[level]}", which the comparator ` +
|
|
559
|
+
'leaves in one place — and a tie is kept in file order, which orders two entries but does not say the ' +
|
|
560
|
+
"editor keeps either folder's entries together",
|
|
561
|
+
repair: 'rename one of the two folders so they differ by more than letter case, spacing or a leading zero',
|
|
562
|
+
};
|
|
541
563
|
}
|
|
542
564
|
|
|
543
565
|
/** How many pairs a refusal spells out before it starts counting them instead. */
|
|
@@ -545,43 +567,45 @@ const PAIRS_SPELLED_OUT = 8;
|
|
|
545
567
|
|
|
546
568
|
/**
|
|
547
569
|
* What one collection's order refusal has to say that the others' do not: what
|
|
548
|
-
* it is counting pairs of,
|
|
549
|
-
* and what an order rigc got wrong would cost.
|
|
570
|
+
* it is counting pairs of, and what an order rigc got wrong would cost.
|
|
550
571
|
*
|
|
551
572
|
* Two collections share the walk below because they share the defect — a
|
|
552
573
|
* name-keyed collection whose ORDINAL is a reference in the format's binary half
|
|
553
|
-
* — and they must not share the sentence, because the
|
|
554
|
-
*
|
|
574
|
+
* — and they must not share the sentence, because the stakes are different and a
|
|
575
|
+
* reader acts on both.
|
|
576
|
+
*
|
|
577
|
+
* ⚠️ They DO share the certificate, and that is a measurement rather than a
|
|
578
|
+
* convenience: `order` was a field here until issue #728, because `skins` and
|
|
579
|
+
* `animations` had been measured to different depths. Probes 4 and 5 carried one
|
|
580
|
+
* name list as both collections and both came back in one order, so a second
|
|
581
|
+
* comparator would now be a second reading of one measured fact.
|
|
555
582
|
*/
|
|
556
583
|
interface OrderedCollection {
|
|
557
584
|
/** Plural, for "N pair(s) of …": `animation names`, `skin names`. */
|
|
558
585
|
noun: string;
|
|
559
|
-
/**
|
|
560
|
-
order: (a: string, b: string) => number | Ambiguity;
|
|
561
|
-
/** Why rigc has an opinion, and what the family leaves open. Ends on a space. */
|
|
586
|
+
/** Why rigc has an opinion, and what the probes leave open. Ends on a space. */
|
|
562
587
|
why: string;
|
|
563
588
|
}
|
|
564
589
|
|
|
565
590
|
const ANIMATION_ORDER: OrderedCollection = {
|
|
566
591
|
noun: 'animation names',
|
|
567
|
-
order: measuredOrder,
|
|
568
592
|
why:
|
|
569
593
|
'rigc keys the emitted "animations" object in ' +
|
|
570
|
-
"the Spine editor's own comparator,
|
|
571
|
-
|
|
572
|
-
'
|
|
594
|
+
"the Spine editor's own comparator, read off five round trips through a licensed 4.3.26 editor (#728) — " +
|
|
595
|
+
'case folded upward, digit runs read as numbers, spaces skipped, leaves before folders at the root and ' +
|
|
596
|
+
'folders before leaves inside one, and a remaining tie left in the order this spec declares. Each pair ' +
|
|
597
|
+
'below is one those round trips leave open. A ' +
|
|
573
598
|
"slider's animation is an ORDINAL in the format's binary half, and an editor that keys these differently " +
|
|
574
599
|
'repoints every slider whose animation moves index — silently, in a file that still parses (#535). ',
|
|
575
600
|
};
|
|
576
601
|
|
|
577
602
|
const SKIN_ORDER: OrderedCollection = {
|
|
578
603
|
noun: 'skin names',
|
|
579
|
-
order: measuredSkinOrder,
|
|
580
604
|
why:
|
|
581
|
-
'rigc writes the emitted "skins" array with "default" first and the rest in the
|
|
582
|
-
'
|
|
583
|
-
|
|
584
|
-
|
|
605
|
+
'rigc writes the emitted "skins" array with "default" first and the rest in the same comparator the ' +
|
|
606
|
+
'"animations" object is keyed in — one comparator, measured on round trips that carried one name list as ' +
|
|
607
|
+
'both collections (#728) — and each pair below is one those round trips leave open. A skin is an ORDINAL ' +
|
|
608
|
+
"in the format's binary half — " +
|
|
585
609
|
'`skins[readInt()]` for an attachment timeline, `skins[skinIndex]` for a linked mesh — so an editor that ' +
|
|
586
610
|
'writes them in another order repoints every such reference, silently, in a file that still parses. ',
|
|
587
611
|
};
|
|
@@ -604,9 +628,10 @@ const SKIN_ORDER: OrderedCollection = {
|
|
|
604
628
|
*
|
|
605
629
|
* ⚠️ `what` is a parameter and not a second copy of this walk because issue #541
|
|
606
630
|
* found the same defect in a second collection, and a copied loop is how the two
|
|
607
|
-
* come to check different things.
|
|
608
|
-
*
|
|
609
|
-
*
|
|
631
|
+
* come to check different things. It carries the SENTENCE and no longer the
|
|
632
|
+
* comparator: #728 measured that one comparator orders both collections, so the
|
|
633
|
+
* certificate below is one function and there is nowhere left for two readings of
|
|
634
|
+
* it to diverge.
|
|
610
635
|
*/
|
|
611
636
|
function refuseNamesTheEditorCouldKeyDifferently(
|
|
612
637
|
names: readonly string[],
|
|
@@ -622,7 +647,7 @@ function refuseNamesTheEditorCouldKeyDifferently(
|
|
|
622
647
|
for (let i = 0; i < names.length; i++) {
|
|
623
648
|
put(names[i], names[i], 0);
|
|
624
649
|
for (let j = i + 1; j < names.length; j++) {
|
|
625
|
-
const verdict =
|
|
650
|
+
const verdict = editorNameOrder(names[i], names[j]);
|
|
626
651
|
if (typeof verdict === 'number') {
|
|
627
652
|
put(names[i], names[j], verdict);
|
|
628
653
|
put(names[j], names[i], -verdict);
|