spine-rigc 0.25.5 → 0.26.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/README.md CHANGED
@@ -413,8 +413,11 @@ that does not belong to it. See
413
413
  against frames of itself — is the floor the rest are read against, because
414
414
  `check` grades nothing and has no pass mark. It is the same instrument and the
415
415
  same commands; what changes is that the reference is a build of yours you have
416
- already looked at, so what it measures is **what your edit did**. AUTHORING.md
417
- §9.2 says what that floor reads and why it is not zero.
416
+ already looked at, so what it measures is **what your edit did**. Every drift
417
+ the report prints carries the bound its own match gives it on the line under
418
+ it, so a figure is read against that rather than against a number from a page;
419
+ AUTHORING.md §9.2 says what the two halves of that bound are and why the floor
420
+ is not zero.
418
421
  - [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) is the benchmark: the same job, from a brief
419
422
  and rendered frames, scored. [docs/PILOT.md](https://github.com/firejune/rigc/blob/main/docs/PILOT.md) is how to run an
420
423
  agent through it and score what comes back.
@@ -530,9 +533,9 @@ first three work on any reference you have, and `bench` is a repository workflow
530
533
  and `bun run fetch-examples`. The reasoning behind them is in
531
534
  [the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
532
535
 
533
- `build` and `validate` both default to `--profile spine` — the 28 validity rules, which
536
+ `build` and `validate` both default to `--profile spine` — the 29 validity rules, which
534
537
  ask *is this valid Spine 4.3 that any runtime plays correctly?* `--profile spine-html`
535
- adds all 43: the other 15 are one renderer's policy and one canvas budget's, and they
538
+ adds all 44: the other 15 are one renderer's policy and one canvas budget's, and they
536
539
  fire on perfectly correct editor-produced Spine data, which is why they are opt-in.
537
540
  ⇒ **That reason is about foreign data and does not carry to a rig you are authoring
538
541
  yourself: author under `--profile spine-html` and read the extra 15 as findings, and
@@ -584,7 +587,7 @@ what each is worth.
584
587
  **What it reads is skeleton JSON and nothing else** — no `.spine` project, no binary
585
588
  `.skel`, no atlas, no art. So it never invents, and the things it cannot get out of
586
589
  the file are **findings** with codes rather than plausible values: a construct the
587
- spec format cannot hold (`linkedmesh`, `point`, a `sequence` block, an unknown field
590
+ spec format cannot hold (`point`, a `sequence` block, an unknown field
588
591
  on a bone, slot or constraint) is a blocker, the command exits non-zero, and both
589
592
  specs are still written — a spec plus a list of what is missing from it beats no spec.
590
593
  One thing it drops on purpose and says so: a path attachment's `lengths`, which is
@@ -678,7 +681,7 @@ letting `A17` blame the editor for the harness's own doing.
678
681
  | 📥 **[docs/INGEST.md](docs/INGEST.md)** | **working with a skeleton you did not author.** What every command can and cannot do with a foreign `skeleton.json`, reading it with the toolchain, transcription as the route that makes it yours, what each validator complaint means on an export, and the re-pivot/rename/extend recipes. Ships in the package too |
679
682
  | 🤖 **[docs/PROMPTING.md](docs/PROMPTING.md)** | **handing the authoring to an AI agent** — the prompt clauses a measured pilot run paid for, and what you can leave unsaid. Ships in the package too |
680
683
  | 🔬 **[docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md)** | Spine 4.3's full export surface against what rigc emits and what the official examples measurably use, with the ordered gap list |
681
- | 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 43 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
684
+ | 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 44 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
682
685
  | 📋 [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) · [GATE.md](https://github.com/firejune/rigc/blob/main/docs/GATE.md) · [PILOT.md](https://github.com/firejune/rigc/blob/main/docs/PILOT.md) | the live rung ledger, the clause statements a candidate is graded against, and how to run an agent through the ladder and score what comes back |
683
686
  | 🗺️ [ROADMAP.md](https://github.com/firejune/rigc/blob/main/ROADMAP.md) | where this is going, and where it has been. What 1.0 has to mean before the number is claimed — conditions rather than a feature list, because direction here comes from what users hit |
684
687
  | 📐 [CLAUDE.md](https://github.com/firejune/rigc/blob/main/CLAUDE.md) | **the doctrine** — why the validator's messages are the product, why nothing reaches disk before green, why no number is ever invented, and what a change has to keep. [CONTRIBUTING.md](https://github.com/firejune/rigc/blob/main/CONTRIBUTING.md) calls it worth ten minutes before a first patch. Repository material |
@@ -735,7 +738,7 @@ quality."* All six, with their verdicts, are in
735
738
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
736
739
 
737
740
  The whole dossier — the yardstick, `diff` and `check` and what neither of them can
738
- see, every rung, the run viewer, the 43 assertions and the selftest behind them — is
741
+ see, every rung, the run viewer, the 44 assertions and the selftest behind them — is
739
742
  [docs/BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
740
743
  Live rung status is
741
744
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
package/cli.ts CHANGED
@@ -67,9 +67,9 @@ import {
67
67
  type DeformSpan,
68
68
  } from './src/deformmeasure.ts';
69
69
  import { diffLines, diffSkeletons, reportedFigures, sectionFigures, type DiffReport } from './src/diff.ts';
70
- import { ingest, IngestError, INGEST_GUTTERS, type IngestStage } from './src/ingest.ts';
71
- import { copyAtlasImages } from './src/emit.ts';
72
- import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE, packAtlas } from './src/atlas.ts';
70
+ import { ingest, IngestError, IngestSpecRefused, INGEST_GUTTERS, type IngestFinding, type IngestStage } from './src/ingest.ts';
71
+ import { copyAtlasPages } from './src/emit.ts';
72
+ import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE, packAtlas, parseAtlasText } from './src/atlas.ts';
73
73
  import { parseJsonWithPosition } from './src/json-position.ts';
74
74
  import { KEY_TIME_EPSILON } from './src/timelines.ts';
75
75
  import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
@@ -117,6 +117,7 @@ import {
117
117
  } from './src/render.ts';
118
118
  import {
119
119
  assertionCountForProfile,
120
+ attachmentRegionJoins,
120
121
  CLI_DEFAULT_PROFILE,
121
122
  reportLines,
122
123
  validate,
@@ -148,6 +149,14 @@ export type CutTable = Record<string, CutEntry>;
148
149
 
149
150
  class UsageError extends Error {}
150
151
 
152
+ /**
153
+ * `explain` refusing a pair it cannot pose — a usage error in kind, printed
154
+ * without the usage block for `PoseError`'s and `IngestError`'s reason: the
155
+ * message names an attachment, a region and two flags, and reprinting every
156
+ * command's usage under it buries the one line that says what to change.
157
+ */
158
+ class ExplainError extends Error {}
159
+
151
160
  // ---------------------------------------------------------------------------
152
161
  // package metadata — the installed version and repository, for `--version`
153
162
  // and for naming a remedy `bench` can only give from a repo checkout.
@@ -1242,14 +1251,21 @@ function cmdBuild(flags: Record<string, string>): void {
1242
1251
  // correct for a build sitting beside the project it came from and breaks the
1243
1252
  // moment the directory is zipped, committed or moved on its own (issue #217).
1244
1253
  // Opt-in only: the default stays exactly what it has always been.
1254
+ //
1255
+ // What is copied is what the ATLAS names, not what the image list holds: under
1256
+ // `--atlas-in` the two are different lists, and rebuilding the text from the
1257
+ // second wrote a file the pack never contained — zero bytes for a rig that
1258
+ // declares no parts, one fabricated page per part for a rig that does, both of
1259
+ // them green here because the gate above had already read the compile's own
1260
+ // text (issue #693, `src/emit.ts`).
1245
1261
  let atlasText = result.atlasText;
1246
1262
  if (flags['copy-images'] !== undefined) {
1247
- const copied = copyAtlasImages(result.images, opts.outDir);
1263
+ const copied = copyAtlasPages(atlasText, opts.outDir);
1248
1264
  atlasText = copied.atlasText;
1249
1265
  console.log(` .. copy-images: ${copied.pages.length} page(s) copied into ${opts.outDir}`);
1250
1266
  for (const p of copied.pages) {
1251
- const note = p.to === basename(p.from) ? '' : ` (renamed from ${basename(p.from)} — basename collision)`;
1252
- console.log(` .. ${p.region.padEnd(24)} <- ${p.to}${note}`);
1267
+ const note = p.to === basename(p.from) ? '' : ' (renamed — basename collision)';
1268
+ console.log(` .. ${p.to.padEnd(24)} <- ${p.from} (${p.regions} region(s))${note}`);
1253
1269
  }
1254
1270
  }
1255
1271
 
@@ -2434,6 +2450,81 @@ function cmdBoneDist(flags: Record<string, string>): void {
2434
2450
  if (flags.json !== undefined) writeJson(flags.json, report);
2435
2451
  }
2436
2452
 
2453
+ /**
2454
+ * Refuse a compiled pair whose art `explain` cannot pose through, by name.
2455
+ *
2456
+ * 🚨 `explain` poses the rig — `deformReportLines` loads the emitted pair through
2457
+ * `spine-core` to measure what each deform key did — and a pose resolves EVERY
2458
+ * attachment against the atlas, whether or not anything deforms it. On the specs
2459
+ * `ingest --art none` writes there is nothing to resolve against: the entries
2460
+ * state a size and name no `image`, so the compile atlases nothing, and the load
2461
+ * threw the runtime's own `Region not found in atlas: rear-upper-arm (attachment:
2462
+ * rear-upper-arm)` with a spine-core stack trace under it and exit 1 (measured on
2463
+ * `examples/spineboy/export/spineboy-ess.json`, issue #697). That is the tool
2464
+ * telling an agent about its own internals instead of about the rig, on the one
2465
+ * input `docs/INGEST.md` §2.0 documents as the route through a foreign skeleton.
2466
+ *
2467
+ * ⭐ Both halves of the join are rigc's own readers rather than a second opinion
2468
+ * on somebody else's format: the walk is `attachmentRegionJoins`, which `PS127`
2469
+ * measures against the loader's own `findRegion` calls, and the region names come
2470
+ * from `parseAtlasText`, which is what `--atlas-in` already resolves against.
2471
+ * Names are compared EXACTLY — as `A08` compares them and as `findRegion`
2472
+ * matches them — so a padded region name is a miss on both sides.
2473
+ *
2474
+ * ⚠️ What it deliberately does not do is catch the pose. A blanket `try` around
2475
+ * `skeletonDataFromText` would convert any exception the runtime raises into a
2476
+ * sentence claiming the cause is missing art, and a rigc defect reported under
2477
+ * somebody else's name is the doctrine's second bullet inverted. This refuses
2478
+ * the case it can NAME, before a line of the report is printed, and leaves
2479
+ * anything else to arrive as itself.
2480
+ */
2481
+ function refuseUnposableArt(result: CompileResult, opts: CompileOptions): void {
2482
+ const regionNames = parseAtlasText(result.atlasText).pages.flatMap((page) => page.regions.map((region) => region.name));
2483
+ const have = new Set(regionNames);
2484
+ const misses: Array<{ at: string; attachment: string; lookup: string }> = [];
2485
+ let lookups = 0;
2486
+ for (const join of attachmentRegionJoins(JSON.parse(result.skeletonText))) {
2487
+ // A `sequence` this walk will not guess at names no region it can check, and
2488
+ // `A08` passes over it for the same reason.
2489
+ if (join.lookups === null) continue;
2490
+ for (const lookup of join.lookups) {
2491
+ lookups++;
2492
+ if (!have.has(lookup)) {
2493
+ misses.push({
2494
+ at: `skin "${join.skin}" slot "${join.slot}" placeholder "${join.placeholder}"`,
2495
+ attachment: join.name,
2496
+ lookup,
2497
+ });
2498
+ }
2499
+ }
2500
+ }
2501
+ if (misses.length === 0) return;
2502
+ const first = misses[0];
2503
+ // Reported because it was measured, and absent where there is none — `A08`'s
2504
+ // own near-miss clause, in `A08`'s own words.
2505
+ const near = regionNames.find((region) => region.trim().toLowerCase() === first.lookup.toLowerCase());
2506
+ const has =
2507
+ regionNames.length === 0
2508
+ ? 'it declares no region at all'
2509
+ : `it declares ${regionNames.length} region(s) and none of them is that${
2510
+ near === undefined ? '' : `, though it does have ${JSON.stringify(near)}`
2511
+ }`;
2512
+ const remedy =
2513
+ opts.atlasInPath === undefined
2514
+ ? 'Art reaches a compile two ways and this run took neither: `--atlas-in <pack.atlas>` resolves the parts ' +
2515
+ 'against a pack somebody already made, and an "image" per attachment resolves them as loose PNGs under ' +
2516
+ '`--images <dir>` — a spec that states a size and names no image is what `ingest --art none` writes, and ' +
2517
+ '`--atlas-in` is what reads it'
2518
+ : `Either the spec's region name or ${opts.atlasInPath} is the one that moved: fix the name, or point ` +
2519
+ '`--atlas-in` at the pack that has it';
2520
+ throw new ExplainError(
2521
+ `${first.at}: attachment ${JSON.stringify(first.attachment)} wants region ${JSON.stringify(first.lookup)}, ` +
2522
+ `which this build's atlas does not have (${has}). \`explain\` poses the rig to measure its deform keys and a ` +
2523
+ `pose resolves every attachment against the atlas, so there is nothing to pose it against. ${remedy}. ` +
2524
+ `${misses.length} of ${lookups} attachment lookup(s) here resolve to no region.`,
2525
+ );
2526
+ }
2527
+
2437
2528
  function cmdExplain(flags: Record<string, string>): void {
2438
2529
  const { label, opts } = resolveCut(flags);
2439
2530
  console.log(`rigc explain ${label}`);
@@ -2442,6 +2533,13 @@ function cmdExplain(flags: Record<string, string>): void {
2442
2533
  console.log(` .. rig ${opts.rigPath}`);
2443
2534
  console.log(` .. motion ${opts.motionPath}`);
2444
2535
  const result = compile(opts);
2536
+ // Before a line of the report, rather than at the pose two hundred lines in:
2537
+ // the blocks that need the art — DEFORM, meshes, dropped states — all sit
2538
+ // BELOW the pose, so a run that printed the bone and timeline dump and then
2539
+ // refused would be a report missing everything the missing art decides, with
2540
+ // the sentence saying so scrolled off the top. It is the invocation that has
2541
+ // to change, so it is refused before the report it cannot finish (issue #697).
2542
+ refuseUnposableArt(result, opts);
2445
2543
  // `compile` has already parsed this file, so the read below cannot fail — but
2446
2544
  // it goes through the same parser rather than a cast, because the cast was the
2447
2545
  // last one in the repository and issue #307 was about exactly that.
@@ -2825,14 +2923,30 @@ function cmdIngest(flags: Record<string, string>, positional: string[]): void {
2825
2923
  console.log(` .. art ${art}`);
2826
2924
  if (specImages !== undefined) console.log(` .. images ${specImages} (the rig spec's own, from ${outDir})`);
2827
2925
 
2828
- const result = ingest(readJsonFile(skeletonPath), {
2829
- name: flags.name ?? basename(skeletonPath, '.json'),
2830
- art,
2831
- images: specImages,
2832
- stage,
2833
- source: basename(skeletonPath),
2834
- version: readVersion(),
2835
- });
2926
+ /**
2927
+ * What the run has to report, however the parse went.
2928
+ *
2929
+ * 🔒 A spec the tree's own parser refuses is a **finding**, not an escape
2930
+ * (issue #692): the three files are written, the coded `BLOCK` line is
2931
+ * printed, and the exit code comes off the findings like every other run's.
2932
+ * The two specs are read as `unknown` because that is all this function does
2933
+ * with them — `JSON.stringify` — and a cast to `RigSpec` here would be this
2934
+ * file claiming a parse that did not happen.
2935
+ */
2936
+ let result: { rig: unknown; motion: unknown; findings: IngestFinding[] };
2937
+ try {
2938
+ result = ingest(readJsonFile(skeletonPath), {
2939
+ name: flags.name ?? basename(skeletonPath, '.json'),
2940
+ art,
2941
+ images: specImages,
2942
+ stage,
2943
+ source: basename(skeletonPath),
2944
+ version: readVersion(),
2945
+ });
2946
+ } catch (err) {
2947
+ if (!(err instanceof IngestSpecRefused)) throw err;
2948
+ result = { rig: err.rig, motion: err.motion, findings: err.findings };
2949
+ }
2836
2950
 
2837
2951
  mkdirSync(outDir, { recursive: true });
2838
2952
  // Indent 2, which is what `compile` writes the skeleton with. One emitter
@@ -3108,8 +3222,20 @@ const COMMANDS: CommandDoc[] = [
3108
3222
  },
3109
3223
  {
3110
3224
  name: 'explain',
3111
- usage: ['rigc explain (same arguments as build, minus --profile — it never gates)'],
3112
- flags: ['rig', 'motion', 'out', 'manifest', 'images', 'cut', 'cuts'],
3225
+ usage: [
3226
+ 'rigc explain --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] (it never gates, and writes nothing)',
3227
+ 'rigc explain … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made, as build does)',
3228
+ 'rigc explain --cut <name> --cuts <cuts.json>',
3229
+ ],
3230
+ flags: ['rig', 'motion', 'out', 'manifest', 'images', 'atlas-in', 'cut', 'cuts'],
3231
+ notes: [
3232
+ 'this line said "the same arguments as build, minus --profile" and was false in both',
3233
+ 'directions (issue #697): --atlas-in was not listed here, so the one flag that lets this',
3234
+ 'command read what `ingest --art none` writes was reachable and undocumented, while',
3235
+ '--pack, --page-size, --padding and --copy-images are build\'s and do nothing here —',
3236
+ 'they decide what is WRITTEN, and this command writes nothing. What it takes is listed',
3237
+ 'above, and that is now the whole of it.',
3238
+ ],
3113
3239
  },
3114
3240
  {
3115
3241
  name: 'validate',
@@ -3482,6 +3608,13 @@ try {
3482
3608
  console.error(`rigc pose: ${err.message}`);
3483
3609
  process.exit(2);
3484
3610
  }
3611
+ // A refusal of the invocation, like the two below it, and exit 2 for the same
3612
+ // reason: nothing was posed and nothing was written, so it is the command line
3613
+ // that has to change (issue #697).
3614
+ if (err instanceof ExplainError) {
3615
+ console.error(`rigc explain: ${err.message}`);
3616
+ process.exit(2);
3617
+ }
3485
3618
  // Same kind as a PoseError, and printed the same way for the same reason: the
3486
3619
  // messages name a path, a bone or an attachment, and reprinting the whole
3487
3620
  // usage under them buries the one line that says what to change.