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/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, as far as anybody has measured it** — natural and case-insensitive
155
- * (#539) — with every name set refused on which one of that comparator's four
156
- * UNMEASURED choices could decide a pair.
157
- *
158
- * ⚠️ This emitted **codepoint** order until issue #543, and the refusal was the
159
- * thing that made codepoint agree with the editor: every pair the two could order
160
- * differently was a `CompileError`. That is sound and it over-refuses by
161
- * construction, because codepoint is not a member of the family it is being
162
- * defended against — it is neither natural nor case-insensitive — so `Turn`
163
- * against `sweep` and `turn10` against `turn2`, **the two pairs the editor was
164
- * directly measured on**, were refused rather than emitted in the order the
165
- * editor was measured returning. Sorting by a member of the measured family
166
- * instead moves the refusal onto what is actually unmeasured, and moves no byte:
167
- * on every set the codepoint rule accepted, the two orders are the same (that is
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
- * ⚠️ "Natural, case-insensitive" is a **family** of comparators rather than one.
226
- * Leading zeros (`turn01` against `turn1`), a pure case tie (`Turn` against
227
- * `turn`), whether a digit run sorts before a word, and what a separator is worth
228
- * are each a free choice, and **the editor's answers to them are not measured**.
229
- * Writing a comparator that sorts every name set means choosing all four, and a
230
- * chosen-but-unmeasured comparator is exactly how #537 landed.
231
- *
232
- * ⇒ 🔑 **So the family is never asked to sort a pair one of those four decides.**
233
- * `measuredOrder` returns a verdict only where every member of the family must
234
- * agree, and `refuseNamesTheEditorCouldKeyDifferently` stops the build on any pair
235
- * where it cannot — which means the four choices below are **unobservable in the
236
- * output**, and the claim the emit makes is the one #542 established, widened:
237
- * *on this name set, every comparator consistent with what has been measured
238
- * produces this order.* Checkable inside rigc, with no editor and no oracle.
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 the quantifier: the order is not *a* comparator's answer,
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 `measuredOrder` reproduces **105 of 105** — including the
247
- * three no codepoint sort can — and refuses **none** of them. The codepoint rule
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 family is hand-rolled and locale-independent, which `A18` requires:
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 names) ordered[name] = animations[name];
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. That is the whole
296
- * evidence, and it is decisive: the runtime's own `defaultSkin` is index 0.
297
- * - **The rest came back `alpha, mike, zulu`.** ⚠️ Which separates nothing.
298
- * Those three names are lower-case ASCII with no digits and no separators, so
299
- * codepoint, case-folded, natural, and every collator agree on them. The
300
- * editor's skin comparator is **not established**, and a rig with `Zulu`,
301
- * `mike10`, `mike2` in it is what would establish it.
302
- *
303
- * 🔑 So this deliberately does NOT reuse `measuredOrder` alone. #539 measured
304
- * the editor's comparator for `animations` and `events` — natural, and
305
- * case-insensitive — and #543 narrowed the animation refusal onto what is left
306
- * unmeasured *inside that family*. None of that is a measurement about skins:
307
- * it is one program, and the inference that one program sorts two collections
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
- const verdicts = refuseNamesTheEditorCouldKeyDifferently(
322
- rest.map((skin) => skin.name),
323
- SKIN_ORDER,
324
- );
325
- rest.sort((a, b) => verdicts.get(a.name)?.get(b.name) ?? 0);
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
- * The characters whose relative order every member of the measured family agrees
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
- /** Maximal runs of digits and of non-digits, which is what "natural" compares. */
339
- const runsOf = (name: string): string[] => name.match(/\d+|\D+/g) ?? [];
363
+ /** An ASCII digit — the characters a run of which the editor reads as a number. */
364
+ const DIGIT = /[0-9]/;
340
365
 
341
- const codepoint = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
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: 'case' | 'number' | 'separator';
374
+ kind: 'number' | 'separator' | 'folder';
346
375
  because: string;
347
376
  repair: string;
348
377
  }
349
378
 
350
379
  /**
351
- * How the editor's comparator orders these two names — or, when one of its four
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
- * ## It is a certificate, not a hazard list
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
- * Issue #539 proposed checking for "two names differing only in case, or sharing
358
- * a prefix followed by digit runs of unequal length". **That list misses the rig
359
- * that produced the measurement**: `Turn` and `sweep` differ in much more than
360
- * case and carry no digits, and they are the pair the editor reordered. A list of
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 measuredOrder(a: string, b: string): number | Ambiguity {
414
- const caseRepair = 'rename one of them so they differ by more than letter case';
415
- const zerosRepair = 'write the number one way — rename so the digit run has a single spelling, with leading zeros or without';
416
- const wordRepair = 'rename so a run of digits never has to be compared against a word';
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: 'case',
423
- because: `they are one name in two cases, and which of them the editor puts first is not measured`,
424
- repair: caseRepair,
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
- let i = 0;
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 first character that differs is ${JSON.stringify(deciding)}, which is neither a letter nor a digit, ` +
447
- 'and what that is worth is a property of the comparator',
448
- repair: separatorRepair,
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
- const ra = runsOf(la);
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 SKIN names — or what makes the pair a matter of
490
- * opinion, under a family wider than `measuredOrder`'s by exactly one member.
491
- *
492
- * ## The extra member is plain codepoint, and it is here because nothing ruled
493
- * it out
494
- *
495
- * #539 put two name sets through the editor and read them back, and what those
496
- * two sets refute is that the editor sorts ANIMATIONS by codepoint: `Turn`
497
- * before `sweep` and `turn10` before `turn2` are the codepoint answers, and the
498
- * editor gave the other one both times. #543 is built on that refutation — it
499
- * sorts by the natural, case-insensitive family and refuses only that family's
500
- * four unmeasured choices.
501
- *
502
- * ⚠️ **The skins measurement refutes nothing.** `alpha, mike, zulu` is the
503
- * answer every candidate gives, so codepoint is still standing for this
504
- * collection. Carrying #543's narrowing over would be assuming that one editor
505
- * sorts two collections by one comparator — plausible, unmeasured, and the same
506
- * move that made `skins` "an array the editor leaves alone" for two releases.
507
- *
508
- * ⇒ A verdict is returned only where `measuredOrder` certifies the pair **and**
509
- * codepoint agrees with it. On the pairs where they differ, the disagreement is
510
- * itself the explanation: either folding the two reverses them, or reading a
511
- * digit run as a number does, and the editor's choice between those readings is
512
- * measured for animation names and not for skin names.
513
- *
514
- * 🔒 It is deliberately a *narrowing of `measuredOrder`* rather than a second
515
- * comparator: one certificate, one place a family member is decided, and no way
516
- * for the two to come to disagree about what "settled" means.
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 measuredSkinOrder(a: string, b: string): number | Ambiguity {
519
- const verdict = measuredOrder(a, b);
520
- if (typeof verdict !== 'number' || codepoint(a, b) === verdict) return verdict;
521
- // `measuredOrder` and codepoint can only part company where folding or a digit
522
- // run decides the pair — everything else it already refuses. Which of the two
523
- // it is, is what the author has to read.
524
- const foldingDecides = codepoint(a.toLowerCase(), b.toLowerCase()) !== codepoint(a, b);
525
- return foldingDecides
526
- ? {
527
- kind: 'case',
528
- because:
529
- `folded to one case "${a}" and "${b}" order the other way round, so whether the editor folds SKIN ` +
530
- 'names decides this pair — and only its ANIMATION and EVENT names have been measured folded (#539)',
531
- repair: 'rename one of them so their order does not turn on letter case',
532
- }
533
- : {
534
- kind: 'number',
535
- because:
536
- `read as numbers the digit runs in "${a}" and "${b}" order the other way round from the same runs read ` +
537
- 'as text, so whether the editor sorts SKIN names naturally decides this pair — and only its ANIMATION ' +
538
- 'and EVENT names have been measured sorted naturally (#539)',
539
- repair: 'pad the digit runs to the same width, or rename so no number decides the order',
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, which comparator family it certified them against,
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 family and the stakes are
554
- * different and a reader acts on both.
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
- /** The certificate. A number is an order; an `Ambiguity` stops the build. */
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, which is natural and case-insensitive (#539) — but four of that " +
571
- "comparator's choices have never been measured (a pure case tie, one number written two ways, a run of " +
572
- 'digits against a word, and what a separator is worth), and each pair below is decided by one of them. A ' +
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 order the editor was ' +
582
- 'measured returning them (#541) — but that measurement was taken on `alpha, mike, zulu`, which every ' +
583
- "candidate comparator orders the same way, so the editor's skin comparator is not established and each " +
584
- 'pair below is one the candidates disagree about. A skin is an ORDINAL in the format\'s binary half — ' +
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. What may NOT be shared is the family: `skins`
608
- * and `animations` have been measured to different depths, and
609
- * `OrderedCollection.order` is where each says which.
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 = what.order(names[i], names[j]);
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);