spine-rigc 1.9.3 → 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,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
+ }
@@ -3,7 +3,9 @@
3
3
  * `ingest`, `diff`, `check`, `render`, `pose`, `chainfit` and `skills` —
4
4
  * moved here unchanged from `cli.ts` (issue #1052, step 4e of #380), with the
5
5
  * helpers only they call. Both entries register them (`CORE_COMMAND_RUNS`):
6
- * `cli.ts` beside the runtime's, `cli_core.ts` alone.
6
+ * `cli.ts` beside the runtime's, `cli_core.ts` alone. And the body
7
+ * `cli_core.ts` runs as `build` (`CORE_ENTRY_RUNS`, issue #1060): `runBuild`
8
+ * with a gate that links none of the runtime.
7
9
  *
8
10
  * Which commands these are is not a list kept here: it is `COMMANDS`'s
9
11
  * `runtime` field (`./shared.ts`), which `RC26` in `selftest.ts` holds to the
@@ -12,6 +14,9 @@
12
14
  * absent, which `RC24` runs.
13
15
  */
14
16
  import { parseAtlasText } from '../atlas.ts';
17
+ import { validateEmittedText } from '../assertions/emitted/index.ts';
18
+ import { validateModel } from '../assertions/model/index.ts';
19
+ import { reportLines } from '../assertions/report.ts';
15
20
  import { chainFitLines, type ChainFitOptions, estimateChainFit } from '../chainfit.ts';
16
21
  import { checkLines, CheckPlates } from '../check.ts';
17
22
  import { writeCheckPictures } from '../checkpics.ts';
@@ -29,7 +34,7 @@ import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
29
34
  import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser } from '../render_shared.ts';
30
35
  import { type CompileResult } from '../types.ts';
31
36
  import { attachmentRegionJoins } from '../region_joins.ts';
32
- import { ATLAS_ABSENT, COMMANDS, type CommandRun, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPoserFlag, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson } from './shared.ts';
37
+ import { ATLAS_ABSENT, COMMANDS, type CommandRun, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPoserFlag, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson } from './shared.ts';
33
38
  import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
34
39
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
35
40
 
@@ -321,11 +326,17 @@ function readAnimationPairs(values: string[], candidate: unknown, reference: unk
321
326
  export function cmdDiff(flags: Record<string, string>, lists: Record<string, string[]>, positional: string[]): void {
322
327
  const [candidate, reference] = positional;
323
328
  if (!candidate || !reference) throw new UsageError('diff takes two paths: <candidate.json> <reference.json>');
324
- const candidatePath = resolve(candidate);
325
- const referencePath = resolve(reference);
326
- for (const path of [candidatePath, referencePath]) {
327
- if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
328
- }
329
+ // A file is read as the JSON it is; a directory is a build, and its skeleton is what is compared — through the one
330
+ // statement of a build (issue #1046), which refuses a directory holding none. A directory used to reach the JSON
331
+ // reader and die on an EISDIR and a stack.
332
+ const skeletonAt = (path: string): string => {
333
+ const at = resolve(path);
334
+ if (existsSync(at) && statSync(at).isDirectory()) return resolveBuild(at, undefined).skeletonPath;
335
+ if (!existsSync(at)) throw new UsageError(`nothing at ${at}`);
336
+ return at;
337
+ };
338
+ const candidatePath = skeletonAt(candidate);
339
+ const referencePath = skeletonAt(reference);
329
340
  const candidateJson = readJsonFile(candidatePath);
330
341
  const referenceJson = readJsonFile(referencePath);
331
342
  const animationPairs = readAnimationPairs(lists.as ?? [], candidateJson, referenceJson);
@@ -696,6 +707,9 @@ export function cmdChainFit(flags: Record<string, string>): void {
696
707
  'skeleton is all it needs of the candidate. Drop --atlas',
697
708
  );
698
709
  }
710
+ // The candidate through the one statement of a build (issue #1046): a path, or a directory holding no skeleton.json,
711
+ // is refused here as by every command that takes a build, rather than by `src/chainfit.ts`'s own reader in its own words.
712
+ resolveBuild(flags.candidate, undefined);
699
713
  const options: ChainFitOptions = {
700
714
  candidatePath: flags.candidate,
701
715
  imagesDir: flags.images,
@@ -1515,6 +1529,67 @@ export function cmdSkills(flags: Record<string, string>, positional: string[]):
1515
1529
  );
1516
1530
  }
1517
1531
 
1532
+ // ---------------------------------------------------------------------------
1533
+ // build — the second entry's gate (issue #1060, step 4e of #380)
1534
+ // ---------------------------------------------------------------------------
1535
+
1536
+ /**
1537
+ * The gate `cli_core.ts build` runs (`BuildGate`), where `cli.ts build` runs
1538
+ * the round trip: the model side over the document (`validateModel`), and the
1539
+ * round trip's own rules restated over the text the emitter wrote
1540
+ * (`validateEmittedText`) — A18 among them, comparing a second, independent
1541
+ * compile's skeleton, atlas and document byte for byte — with A00, spine-core's
1542
+ * parse, a SKIP naming it. One report, printed by the printer `cli.ts build`
1543
+ * prints with, and a line saying which rules ran here and which did not.
1544
+ *
1545
+ * ⚠️ The document the model side reads is the one this gate judges: spelled
1546
+ * from the atlas text the gate is handed, so under `--copy-images` it names
1547
+ * the pages where the compile measured them — the files the round trip's own
1548
+ * A17 reads on `cli.ts` — and A18 compares the documents as written, the
1549
+ * copies' names in them. Under `--pack` the two are one text.
1550
+ */
1551
+ const MODEL_AND_TEXT_GATE: BuildGate = {
1552
+ heading: (profile) =>
1553
+ ` .. validate (the model side over the document + the round trip's rules restated over the emitted text, profile ${profile}; this entry links no spine-core, so the round trip does not run)`,
1554
+ run: ({ result, atlasText, atlasDir, modelText, reEmit, profile }) => {
1555
+ const model = validateModel({ modelText: modelDocument(result.model, result.skeletonText, atlasText), atlasDir, profile });
1556
+ const text = validateEmittedText({ skeletonText: result.skeletonText, atlasText, modelText, reEmit, profile });
1557
+ const report = {
1558
+ failures: [...model.failures, ...text.failures],
1559
+ passed: [...model.passed, ...text.passed],
1560
+ skipped: [...model.skipped, ...text.skipped],
1561
+ profileSkipped: [...model.profileSkipped, ...text.profileSkipped],
1562
+ stats: { ...model.stats, ...text.stats },
1563
+ profile,
1564
+ };
1565
+ for (const line of reportLines(report)) console.log(line);
1566
+ console.log(
1567
+ ` .. ${Object.entries(report.stats)
1568
+ .map(([k, v]) => `${k}=${v}`)
1569
+ .join(' ')}`,
1570
+ );
1571
+ const ran = (r: { passed: string[]; failures: Array<{ assertion: string }>; skipped: Array<{ assertion: string }>; profileSkipped: Array<{ assertion: string }> }): number =>
1572
+ new Set([...r.passed, ...r.failures.map((f) => f.assertion), ...r.skipped.map((x) => x.assertion), ...r.profileSkipped.map((x) => x.assertion)]).size;
1573
+ const notRun = text.skipped.filter((x) => x.assertion === 'A00_ROUNDTRIP_PARSE').map((x) => x.assertion);
1574
+ console.log(
1575
+ ` .. here: ${ran(model)} rule(s) on the model side over the document, ${ran(text) - notRun.length} of the round trip's own restated over the emitted text; ` +
1576
+ `not run: ${notRun.join(', ') || 'none'} — spine-core's parse, which only \`bun cli.ts build\` and \`rigc validate\` run`,
1577
+ );
1578
+ return report.failures.length;
1579
+ },
1580
+ look: (outDir) => `rigc: look at it: rigc render --candidate ${outDir}`,
1581
+ };
1582
+
1583
+ /**
1584
+ * The bodies the second entry runs under a name whose full-entry body is the
1585
+ * runtime's (`runtime.core` in `COMMANDS`, issue #1060) — registered by
1586
+ * `cli_core.ts` alone, beside `CORE_COMMAND_RUNS`. `build` is `runBuild` with
1587
+ * this entry's gate: it writes what `cli.ts build` writes.
1588
+ */
1589
+ export const CORE_ENTRY_RUNS: Readonly<Record<string, CommandRun>> = {
1590
+ build: ({ flags }) => runBuild(flags, MODEL_AND_TEXT_GATE),
1591
+ };
1592
+
1518
1593
  /**
1519
1594
  * The bodies of every command whose `runtime` is `false` (`COMMANDS`), by
1520
1595
  * name — what both entries register. Nothing this module imports links