spine-rigc 2.16.0 → 2.17.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 CHANGED
@@ -234,6 +234,7 @@ What the flags mean:
234
234
  | `--cut` | `build`, `explain` and `validate`: look up a named cut in `--cuts <cuts.json>`, **instead of** `--rig`/`--motion`/`--out` — the two spellings are one build stated two ways and are refused together. A `cuts.json` is `{ "<name>": { "rig": …, "motion": …, "out": …, "manifest"?: … } }`, every path in it relative to the table's own file, so the table lives with the project that owns the art |
235
235
  | `--cuts` | the `cuts.json` `--cut` names. Required beside it — `--cut` alone is refused, with no guess at where the table lives |
236
236
  | `--profile` | `spine` = the 34 validity rules (**the default**) · `spine-html` = all 50, opt-in |
237
+ | `--report` | `build` and `repack`: also write the gate's report as a JSON document (`build-report/1`) to this file — every gate's `PASS`, `SKIP` and `FAIL` rows, its summary figures and stats line, which supplier judged it, and every `pack:` line's figures. Written when the command writes `--out` and when a gate is red, before the exit; a file already at the path is removed first, so a compile error or a refused repack leaves none. Never inside `--out`, refused by name. The printed lines are byte-identical with it and without it — **§5.3** |
237
238
  | `--candidate` | `check`, `bench`, `render`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. A directory holding no `skeleton.json`, or a path with nothing at it, is refused `nothing at <path>`, exit 2, nothing written, by every command that takes a build — `validate <dir>` and a directory given to `diff` included (`RC27`). `--atlas <path>` names the atlas when it does not sit beside the skeleton. On `render` and `check` a rigc build whose `skeleton.model.json` is `rigc-compiled/3` or `/2` needs no atlas at all: with no `--atlas` and no atlas beside it, it is drawn from the document's `pages`, the same frames, geometry and figures as with it, and the `atlas` line says `— not there`. An atlas that is there is still read, and held to `pages`: one that differs (edited after the build) is drawn through spine-core, and the `poser` line names the first difference. A build drawn through an atlas that is not there — a `rigc-compiled/1` document, or a build the core refuses — is refused `nothing at <the atlas>: … is drawn through its atlas (<why>)`, exit 2, nothing written; a Spine export without its atlas is refused `nothing at <the atlas>` as it always was. A directory whose files are not one build — a `skeleton.json` or a `skeleton.atlas` from another build beside this one's `skeleton.model.json` — is drawn through spine-core where the runtime can load the pair, the `poser` line naming the two digests or the atlas's first difference; where it cannot, it is refused `<skeleton> does not load against <the atlas>: spine-core draws this pair (<the poser line's why>) and could not resolve it — "<the runtime's own words>". <dir> is not one build: …`, exit 2, nothing written (`RC18`) — another build's skeleton beside this build's atlas typically ends `"Region not found in atlas: <region> (attachment: <name>)"`. Rebuild, or put the build's own three files back beside each other. A Spine export beside an atlas it was not exported with is refused in the same words, ending `The skeleton and the atlas are not one pair`. An atlas copied from another build's directory usually stops earlier, at its pages — their paths are relative to the directory it was written in — and is refused naming the first one: `nothing at <the page>: <the atlas> names it as a page, and spine-core draws this pair through that atlas (<why>). … <dir> is not one build: …`, exit 2, nothing written (`RC18`); an export whose atlas names a page that is not there is refused the same way, ending `and the page has to be there`. A build moved or copied whole away from the directory it was built in stops at the same place, on either poser, and is refused naming the page and the file that names it: `nothing at <the page>: <the skeleton.model.json> names it as a page, and the core draws this build from it. A page path is relative to the build's directory, so a build moved or copied away from the directory it was built in names pages that are not here — build it again where it is, or build it with --copy-images, which writes its pages beside it` (under `--poser spine`, `<the atlas> names it as a page, and spine-core draws this pair through that atlas (--poser spine)` with the same ending), exit 2, nothing written (`RC21`); only where the core refused the files does a page refusal say the directory is not one build. `bench --bones` and `rigc bonedist` — on its `--candidate` and on its `--reference` — refuse a pair the runtime cannot load in the words above with `spine-core poses this pair (bonedist's candidate: …)` or `(bonedist's reference: …)`, exit 2, nothing written (`RC20`); they read no page image, so a build whose pages are elsewhere is measured as it is in place. A `skeleton.json` that is not JSON is refused `rigc: cannot read <the file>: <the parser's words> (line N, column N)`, exit 2, by `render`, `check`, `bench` and `bonedist` as by every other command that reads one (`RC22`). With the atlas gone, `check` on a `rigc-compiled/3` build reads its pages' `scale:` lines off the document, so its texture note and `check.json`'s `textureFrom.candidateScales` are what they are with the atlas; on a `/2` build its texture note says whether the atlas declares a `scale:` line is not read (that document does not state one), and `candidateScales` is `null` (`RC13`). **`vote` and `preview` take it more than once**: `vote` 2–4 times, one per pane, labelled A, B, C, D in the order given; `preview` any number of times, one pane per candidate in the order given, each headed by its path and its gate line, and the same skeleton twice (a directory and its `skeleton.json` are one) is refused by name. `--atlas` goes with one candidate only. Everywhere else a repeat is a typo and is refused |
238
239
  | `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview` (each candidate's own first, with several), and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote` and a several-candidate `preview`, so is a name that only *some* candidates have, naming the one that lacks it |
239
240
  | `--geometry` | `render` only: also write a `geometry.json` into each frame directory — per frame, every bone's world transform and every slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry and topology, on the frames' own grid and viewport. Every other file is byte for byte what the render writes without it. Refused with `--slot`/`--hide`, because the geometry is the whole pose whatever is drawn — **§8.2** |
@@ -6426,6 +6427,53 @@ Fix A00 and run it again.
6426
6427
  `both ◑` marks a mixed assertion: its validity half always runs and its policy
6427
6428
  clauses are gated by profile.
6428
6429
 
6430
+ ### 5.3 The report as a document — `--report <file>`
6431
+
6432
+ The lines above are written for an agent to read. A program that acts on a build —
6433
+ take it or refuse it, read where the packer put the pages — should not read them with
6434
+ regular expressions: every wording change this guide tracks as the interface would
6435
+ break it, and nothing promised it the wording. `build --report <file>` and
6436
+ `repack --report <file>` write the same report as a JSON document beside the build,
6437
+ and **the printed lines do not change by a byte** with the flag (`BR03`).
6438
+
6439
+ | key | what it is | the line it restates |
6440
+ | --- | --- | --- |
6441
+ | `spec` | `"build-report/1"` | — |
6442
+ | `command` | `"build"` or `"repack"` | the command that ran |
6443
+ | `supplier` | `"round-trip"` (the entry that links spine-core) or `"model"` (`cli_core.ts`, the model side and the rules restated over the emitted text) | the `validate (…)` heading |
6444
+ | `gates[]` | one per gate, in the order run: the compile's, then under `--pack` the packed pages' | each `profile` line opens one |
6445
+ | `gates[].atlas` | `"compiled"` or `"packed"` | `validate (packed atlas, pages on disk)` before the second |
6446
+ | `gates[].passed` | the rule names, in the order printed | the `PASS` lines |
6447
+ | `gates[].skipped[]` | `{ code, reason }`, in the order printed | the `SKIP` lines |
6448
+ | `gates[].failures[]` | `{ code, detail }`, one per line — a rule that fails twice is two entries | the `FAIL` lines |
6449
+ | `gates[].summary` | `{ assertions, measured, passed, failed, skipped, notInProfile, profile }`, every figure a count of assertions | the `N assertions: …` line |
6450
+ | `gates[].stats` | the keys and values, in order | the `pages=… regions=…` line |
6451
+ | `gates[].here` | `{ modelSide, restated, notRun }` on the core entry, `null` on the round trip | the `here:` line |
6452
+ | `pack` | `null` without `--pack`; else one `{ page, width, height, regions, coveredPct, padding, pageEdges, packShape }` per page, `coveredPct` at the line's one decimal and `pageEdges` `"pot"` or `"free"` | the `pack:` lines |
6453
+
6454
+ A `PROF` row is counted in `summary.notInProfile` and not listed; the per-region
6455
+ placement lines under `pack:`, the `MESH`, `DROP` and `copy-images` lines and the
6456
+ `rigc: wrote` lines are not in it. That is not an oversight: the first version carries
6457
+ the fields a dependant was observed reading off the sentences and the figures the
6458
+ summary line states, and nothing else (`BR09` holds the keys to that census and names a
6459
+ key outside it). Every value is read from the lists the lines are printed from, so the
6460
+ document and the lines cannot disagree (`BR01`, `BR02`, `BR05`, `BR08`).
6461
+
6462
+ - **When it is written.** When the command writes `--out`, after the last file; and
6463
+ when a gate is red, before the exit — so a red build's document carries its `FAIL`
6464
+ rows (`BR05`). A file already at the path is removed before anything is compiled, so
6465
+ any other ending — a compile error, a usage refusal after the flag is read, a repack
6466
+ refused by its checks — leaves **no** document, never an earlier run's (`BR07`). A
6467
+ compile error is not in it because its message names files by absolute path; its
6468
+ absence is what says no gate ran, and the reason is on stderr.
6469
+ - **Where.** Never inside `--out` — that directory holds the build's own files, which
6470
+ `A18` and `emit_hashes` hold byte-identical — and refused by name when it is (`BR06`).
6471
+ - **What it never carries.** A time, an absolute path or anything of the machine: two
6472
+ reports of one build are byte-identical, whatever `--out` they were built into
6473
+ (`BR04`).
6474
+ - **Versioned and additive.** A field is added under `build-report/1`; a field that
6475
+ changes meaning or goes away moves the spec. A reader checks `spec` first.
6476
+
6429
6477
  ---
6430
6478
 
6431
6479
  ## 6. What rigc will refuse — do not spend a loop on these
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,12 +53,137 @@ export function reportLines(report: VerdictLists & { profile: AssertionProfile }
53
53
  // 42. The four buckets partition `ASSERTION_NAMES`, so the total is derived
54
54
  // by adding them rather than stated — a `42` written here would be the one
55
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;
56
+ //
57
+ // The figures are `gateSummary`'s, the one computation the line and the
58
+ // `--report` document (`buildReportGate`) both spell (issue #1213).
59
+ const s = gateSummary(report);
59
60
  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}"`,
61
+ ` .. ${s.assertions} assertions: ${s.measured} measured (${s.passed} passed, ${s.failed} failed), ` +
62
+ `${s.skipped} skipped, ${s.notInProfile} not in profile "${s.profile}"`,
62
63
  );
63
64
  return lines;
64
65
  }
66
+
67
+ /**
68
+ * The figures the summary line states, as values: what `reportLines` prints
69
+ * as its last line and what the `--report` document carries as a gate's
70
+ * `summary` — one computation, so the two cannot disagree. Every figure counts
71
+ * assertions, not rows (the comment in `reportLines` says why `failed` is a
72
+ * set), and `assertions` is the sum of the four buckets, never a constant.
73
+ */
74
+ export interface GateSummary {
75
+ assertions: number;
76
+ measured: number;
77
+ passed: number;
78
+ failed: number;
79
+ skipped: number;
80
+ notInProfile: number;
81
+ profile: AssertionProfile;
82
+ }
83
+
84
+ export function gateSummary(report: VerdictLists & { profile: AssertionProfile }): GateSummary {
85
+ const failed = new Set(report.failures.map((f) => f.assertion)).size;
86
+ const measured = report.passed.length + failed;
87
+ return {
88
+ assertions: measured + report.skipped.length + report.profileSkipped.length,
89
+ measured,
90
+ passed: report.passed.length,
91
+ failed,
92
+ skipped: report.skipped.length,
93
+ notInProfile: report.profileSkipped.length,
94
+ profile: report.profile,
95
+ };
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // the build report document (`--report <file>`, issue #1213)
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * The `spec` of the document `build --report` and `repack --report` write.
104
+ * Versioned and additive: a field is added under the same spec, and one that
105
+ * changes meaning or goes away moves the spec — a dependant that read `/1`
106
+ * keeps reading what `/1` promised.
107
+ */
108
+ export const BUILD_REPORT_SPEC = 'build-report/1';
109
+
110
+ /** Which supplier judged the gates: the round trip through spine-core (`cli.ts`) or the model side and the rules restated over the emitted text (`cli_core.ts`). */
111
+ export type BuildReportSupplier = 'round-trip' | 'model';
112
+
113
+ /**
114
+ * The core entry's `here:` line as values: how many rules ran on the model
115
+ * side over the document, how many of the round trip's own were restated over
116
+ * the emitted text, and the codes that did not run. `null` on the round trip,
117
+ * which prints no such line.
118
+ */
119
+ export interface GateHere {
120
+ modelSide: number;
121
+ restated: number;
122
+ notRun: string[];
123
+ }
124
+
125
+ /**
126
+ * What one gate's report states, as values: the rows a reader takes off the
127
+ * `PASS`, `SKIP` and `FAIL` lines (the rule names, the SKIP reasons, the FAIL
128
+ * details, each in the order printed), the summary line's figures, the stats
129
+ * line's keys and values, and the core entry's `here:` line. A `PROF` row is
130
+ * counted in `summary.notInProfile` and not listed: nothing reads its row.
131
+ */
132
+ export interface BuildReportGate {
133
+ /** `compiled` for the gate over the compile, `packed` for `--pack`'s second gate over the pages on disk. */
134
+ atlas: 'compiled' | 'packed';
135
+ passed: string[];
136
+ skipped: Array<{ code: string; reason: string }>;
137
+ failures: Array<{ code: string; detail: string }>;
138
+ summary: GateSummary;
139
+ stats: Record<string, number | string>;
140
+ here: GateHere | null;
141
+ }
142
+
143
+ /**
144
+ * One `pack:` line as values. `coveredPct` is the figure the line prints, at
145
+ * its one decimal; `pageEdges` is the `--page-edges` the build packed under,
146
+ * which the line states as `, page edges free` or by its absence.
147
+ */
148
+ export interface PackPageFigures {
149
+ page: string;
150
+ width: number;
151
+ height: number;
152
+ regions: number;
153
+ coveredPct: number;
154
+ padding: number;
155
+ pageEdges: 'pot' | 'free';
156
+ packShape: 'rect' | 'polygon';
157
+ }
158
+
159
+ /** The document, keys in the order written. Nothing in it is a time, a path or a machine's: two reports of one build are byte-identical. */
160
+ export interface BuildReportDocument {
161
+ spec: typeof BUILD_REPORT_SPEC;
162
+ command: 'build' | 'repack';
163
+ supplier: BuildReportSupplier;
164
+ gates: BuildReportGate[];
165
+ /** Every `pack:` line, in the order printed; `null` for a build that did not pack. */
166
+ pack: PackPageFigures[] | null;
167
+ }
168
+
169
+ /** One gate's report as the document carries it — read off the lists `reportLines` prints, so a row is in the document exactly when its line is printed. */
170
+ export function buildReportGate(
171
+ atlas: BuildReportGate['atlas'],
172
+ report: VerdictLists & { profile: AssertionProfile },
173
+ here: GateHere | null,
174
+ ): BuildReportGate {
175
+ return {
176
+ atlas,
177
+ passed: [...report.passed],
178
+ skipped: report.skipped.map((s) => ({ code: s.assertion, reason: s.reason })),
179
+ failures: report.failures.map((f) => ({ code: f.assertion, detail: f.detail })),
180
+ summary: gateSummary(report),
181
+ stats: { ...report.stats },
182
+ here,
183
+ };
184
+ }
185
+
186
+ /** The document's text: two-space JSON and a final newline, keys in the order `BuildReportDocument` states them. */
187
+ export function buildReportText(doc: BuildReportDocument): string {
188
+ return `${JSON.stringify(doc, null, 2)}\n`;
189
+ }
@@ -1560,6 +1560,7 @@ export function cmdSkills(flags: Record<string, string>, positional: string[]):
1560
1560
  * through this entry's dispatch with a hook between the lift and the build.
1561
1561
  */
1562
1562
  export const MODEL_AND_TEXT_GATE: BuildGate = {
1563
+ supplier: 'model',
1563
1564
  heading: (profile) =>
1564
1565
  ` .. 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)`,
1565
1566
  run: ({ result, atlasText, atlasDir, modelText, reEmit, profile }) => {
@@ -1584,12 +1585,14 @@ export const MODEL_AND_TEXT_GATE: BuildGate = {
1584
1585
  const ran = (r: { passed: string[]; failures: Array<{ assertion: string }>; skipped: Array<{ assertion: string }>; profileSkipped: Array<{ assertion: string }> }): number =>
1585
1586
  new Set([...r.passed, ...r.failures.map((f) => f.assertion), ...r.skipped.map((x) => x.assertion), ...r.profileSkipped.map((x) => x.assertion)]).size;
1586
1587
  const notRun = text.skipped.filter((x) => x.assertion === 'A00_ROUNDTRIP_PARSE').map((x) => x.assertion);
1588
+ // The line is spelled from the values the `--report` document carries as the gate's `here` (issue #1213).
1589
+ const here = { modelSide: ran(model), restated: ran(text) - notRun.length, notRun };
1587
1590
  console.log(
1588
- ` .. 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; ` +
1589
- `not run: ${notRun.join(', ') || 'none'} — spine-core's parse, which only the entry that links spine-core runs, on build and validate: ` +
1591
+ ` .. here: ${here.modelSide} rule(s) on the model side over the document, ${here.restated} of the round trip's own restated over the emitted text; ` +
1592
+ `not run: ${here.notRun.join(', ') || 'none'} — spine-core's parse, which only the entry that links spine-core runs, on build and validate: ` +
1590
1593
  `installed, the same \`rigc\` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, \`bun cli.ts\``,
1591
1594
  );
1592
- return report.failures.length;
1595
+ return { report, here };
1593
1596
  },
1594
1597
  look: (outDir) => `rigc: look at it: rigc render --candidate ${outDir}`,
1595
1598
  };
package/src/cli/repack.ts CHANGED
@@ -46,6 +46,7 @@ import { MODEL_DOCUMENT_FILE } from '../model.ts';
46
46
  import { atlasRefusals, documentDisagreements, firstLineApart, type InputPage, liftAtlas, polygonOwnedTexels, regionDifferences, REPACK_WORK_PREFIX, RepackError, skeletonDifferences } from '../repack.ts';
47
47
  import {
48
48
  type BuildGate,
49
+ type BuildReport,
49
50
  type CommandArgs,
50
51
  documentStageBeside,
51
52
  modelTextBeside,
@@ -54,11 +55,13 @@ import {
54
55
  readPackShape,
55
56
  readPageEdges,
56
57
  readProfile,
58
+ readReportFlag,
57
59
  readSkeletonText,
58
60
  readVersion,
59
61
  resolveBuild,
60
62
  runBuild,
61
63
  UsageError,
64
+ writeBuildReport,
62
65
  } from './shared.ts';
63
66
  import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE } from '../atlas.ts';
64
67
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
@@ -75,7 +78,7 @@ import { basename, dirname, join, resolve } from 'node:path';
75
78
  */
76
79
  export const ACCEPT_FLAG = 'accept-skeleton-differences';
77
80
 
78
- export const REPACK_FLAGS = ['out', 'atlas', 'page-size', 'padding', 'page-edges', 'pack-shape', 'profile', 'stage', 'stage-box', ACCEPT_FLAG] as const;
81
+ export const REPACK_FLAGS = ['out', 'atlas', 'page-size', 'padding', 'page-edges', 'pack-shape', 'profile', 'stage', 'stage-box', ACCEPT_FLAG, 'report'] as const;
79
82
 
80
83
  /** `build`'s flags that `repack` does not take, and why — each refused by name rather than ignored. */
81
84
  const BUILD_FLAGS_REFUSED: Readonly<Record<string, string>> = {
@@ -211,7 +214,7 @@ function findingLines(findings: readonly IngestFinding[]): string[] {
211
214
  * `repack` names `--out` itself once it has written there. Every other line is
212
215
  * printed as it comes, so a gate that exits mid-way loses none.
213
216
  */
214
- function stagedBuild(flags: Record<string, string>, gate: BuildGate): void {
217
+ function stagedBuild(flags: Record<string, string>, gate: BuildGate, record: BuildReport | null): void {
215
218
  const print = console.log;
216
219
  console.log = (...args: unknown[]): void => {
217
220
  const line = args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ');
@@ -219,7 +222,7 @@ function stagedBuild(flags: Record<string, string>, gate: BuildGate): void {
219
222
  else if (!line.startsWith(LOOK_LINE)) print(...args);
220
223
  };
221
224
  try {
222
- runBuild(flags, gate);
225
+ runBuild(flags, gate, record);
223
226
  } finally {
224
227
  console.log = print;
225
228
  }
@@ -258,6 +261,8 @@ export function cmdRepack({ flags, positional }: CommandArgs, gate: BuildGate, p
258
261
  };
259
262
  const stage = readStageFlag(flags.stage);
260
263
  const out = readOut(flags);
264
+ // `--report` (issue #1213): refused inside --out, written on a red gate by the build body and here once --out is written.
265
+ const record = readReportFlag(flags, 'repack', out, gate.supplier);
261
266
  const input = resolveBuild(positional[0], flags.atlas);
262
267
  if (input.atlas !== 'there') throw new UsageError(input.atlasRefusal);
263
268
  const skeletonText = readSkeletonText(input.skeletonPath);
@@ -338,7 +343,7 @@ export function cmdRepack({ flags, positional }: CommandArgs, gate: BuildGate, p
338
343
  writeFileSync(motionPath, `${JSON.stringify(result.motion, null, 2)}\n`);
339
344
 
340
345
  // (c) `build --pack` through the entry's own gate. It returns only green; a red gate exits from inside it.
341
- stagedBuild({ rig: rigPath, motion: motionPath, out: buildDir, pack: 'true', ...packing }, gate);
346
+ stagedBuild({ rig: rigPath, motion: motionPath, out: buildDir, pack: 'true', ...packing }, gate, record);
342
347
 
343
348
  // (a) and (b), both before the first write.
344
349
  const outAtlas = readFileSync(join(buildDir, 'skeleton.atlas'), 'utf8');
@@ -400,6 +405,7 @@ export function cmdRepack({ flags, positional }: CommandArgs, gate: BuildGate, p
400
405
  copyFileSync(join(buildDir, name), join(out, name));
401
406
  console.log(`rigc: wrote ${join(out, name)}`);
402
407
  }
408
+ if (record !== null) writeBuildReport(record);
403
409
  console.log(gate.look(out));
404
410
  } finally {
405
411
  removeWork();
package/src/cli/shared.ts CHANGED
@@ -16,7 +16,19 @@
16
16
  */
17
17
  import { type AtlasRegion, DEFAULT_PACK_SHAPE, DEFAULT_PADDING, DEFAULT_PAGE_EDGES, DEFAULT_PAGE_SIZE, PACK_SHAPES, packAtlas, type PackInput, packFootprints, type PackShape, PAGE_EDGES, type PageEdges, pageFootprint } from '../atlas.ts';
18
18
  import { copyAtlasPages, plannedPageCopies } from '../emit.ts';
19
- import { CLI_DEFAULT_PROFILE, VALIDATE_PROFILES } from '../assertions/report.ts';
19
+ import {
20
+ BUILD_REPORT_SPEC,
21
+ type BuildReportDocument,
22
+ type BuildReportGate,
23
+ type BuildReportSupplier,
24
+ buildReportGate,
25
+ buildReportText,
26
+ CLI_DEFAULT_PROFILE,
27
+ type GateHere,
28
+ type PackPageFigures,
29
+ VALIDATE_PROFILES,
30
+ } from '../assertions/report.ts';
31
+ import type { VerdictLists } from '../assertions/harness.ts';
20
32
  import type { AssertionProfile } from '../assertions/kinds.ts';
21
33
  import { MAX_CANDIDATES, MIN_CANDIDATES } from '../ballot.ts';
22
34
  import { BONEDIST_SPEC, IDENTITY_CORRESPONDENCE } from '../correspondence.ts';
@@ -48,8 +60,8 @@ import { PoseError } from '../pose.ts';
48
60
  import { RepackError } from '../repack.ts';
49
61
  import { SpineRuntimeError, SPINE_SIDE_ABSENT } from '../spine_side.ts';
50
62
  import type { CompileResult, DroppedState } from '../types.ts';
51
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
52
- import { basename, dirname, join, resolve } from 'node:path';
63
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
64
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
53
65
 
54
66
 
55
67
  /**
@@ -1113,6 +1125,12 @@ const FLAG_MEANINGS: Record<string, string> = {
1113
1125
  'is the setup-pose bounding box, and the rebuilt spec asks for the same box rather than transcribing it. Only a ' +
1114
1126
  'box `build` could write back is read: anything else in that slot is refused by name, and without the flag no ' +
1115
1127
  'slot is read as the stage because of its name',
1128
+ report:
1129
+ `also write the gate's report as a JSON document (\`${BUILD_REPORT_SPEC}\`) to this file: every gate's PASS, SKIP and FAIL rows, ` +
1130
+ 'its summary figures and stats, the supplier that judged it, and every pack line\'s figures — written when the command ' +
1131
+ 'writes --out and when a gate is red, before the exit. A file already at the path is removed first, so any other ending ' +
1132
+ '(a compile error, a repack refused) leaves none rather than an earlier run\'s. Never inside --out, refused by name. ' +
1133
+ 'The lines printed are the same with it and without it',
1116
1134
  help: "show this command's flags and exit",
1117
1135
  };
1118
1136
 
@@ -1168,6 +1186,7 @@ const FLAG_VALUES: Record<string, string> = {
1168
1186
  stage: '<x,y,w,h>',
1169
1187
  'stage-box': '<slot>',
1170
1188
  dir: '<path>',
1189
+ report: '<file>',
1171
1190
  };
1172
1191
 
1173
1192
  /**
@@ -1261,7 +1280,7 @@ export const COMMANDS: CommandDoc[] = [
1261
1280
  for: 'the gate round-trips every build through it before anything is written',
1262
1281
  core: {
1263
1282
  usage: [
1264
- 'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images] (the same files the round-tripped build writes; gated without spine-core — see build --help)',
1283
+ 'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images] [--report <file>] (the same files the round-tripped build writes; gated without spine-core — see build --help)',
1265
1284
  `rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] [--pack-shape rect|polygon] (parts onto shared pages, written into --out)`,
1266
1285
  'rigc build … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made)',
1267
1286
  'rigc build --cut <name> --cuts <cuts.json>',
@@ -1285,7 +1304,7 @@ export const COMMANDS: CommandDoc[] = [
1285
1304
  },
1286
1305
  spineFormat: true,
1287
1306
  usage: [
1288
- 'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images]',
1307
+ 'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images] [--report <file>]',
1289
1308
  `rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] [--pack-shape rect|polygon] (parts onto shared pages, written into --out)`,
1290
1309
  'rigc build … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made)',
1291
1310
  'rigc build --cut <name> --cuts <cuts.json>',
@@ -1306,6 +1325,7 @@ export const COMMANDS: CommandDoc[] = [
1306
1325
  'cut',
1307
1326
  'cuts',
1308
1327
  'profile',
1328
+ 'report',
1309
1329
  ],
1310
1330
  },
1311
1331
  {
@@ -1314,7 +1334,7 @@ export const COMMANDS: CommandDoc[] = [
1314
1334
  for: 'the rebuild it writes is gated by build\'s gate, which round-trips it through spine-core',
1315
1335
  core: {
1316
1336
  usage: [
1317
- `rigc repack <dir | skeleton.json> --out <dir> [--atlas <path>] [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] [--pack-shape rect|polygon] [--profile spine|spine-html] [--stage x,y,w,h] [--stage-box <slot>] [--accept-skeleton-differences] (gated without spine-core — see repack --help)`,
1337
+ `rigc repack <dir | skeleton.json> --out <dir> [--atlas <path>] [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] [--pack-shape rect|polygon] [--profile spine|spine-html] [--stage x,y,w,h] [--stage-box <slot>] [--accept-skeleton-differences] [--report <file>] (gated without spine-core — see repack --help)`,
1318
1338
  ],
1319
1339
  notes: [
1320
1340
  'a packed build repacked from its own output — skeleton.json, skeleton.atlas and the',
@@ -1335,9 +1355,9 @@ export const COMMANDS: CommandDoc[] = [
1335
1355
  },
1336
1356
  spineFormat: true,
1337
1357
  usage: [
1338
- `rigc repack <dir | skeleton.json> --out <dir> [--atlas <path>] [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] [--pack-shape rect|polygon] [--profile spine|spine-html] [--stage x,y,w,h] [--stage-box <slot>] [--accept-skeleton-differences]`,
1358
+ `rigc repack <dir | skeleton.json> --out <dir> [--atlas <path>] [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] [--pack-shape rect|polygon] [--profile spine|spine-html] [--stage x,y,w,h] [--stage-box <slot>] [--accept-skeleton-differences] [--report <file>]`,
1339
1359
  ],
1340
- flags: ['out', 'atlas', 'page-size', 'padding', 'page-edges', 'pack-shape', 'profile', 'stage', 'stage-box', 'accept-skeleton-differences'],
1360
+ flags: ['out', 'atlas', 'page-size', 'padding', 'page-edges', 'pack-shape', 'profile', 'stage', 'stage-box', 'accept-skeleton-differences', 'report'],
1341
1361
  overrides: {
1342
1362
  out: {
1343
1363
  value: '<dir>',
@@ -2134,16 +2154,20 @@ export interface BuildGateInput {
2134
2154
  * `runBuild`'s, written once, so the two entries write the same build.
2135
2155
  */
2136
2156
  export interface BuildGate {
2157
+ /** Which supplier this gate is, as the `--report` document names it (issue #1213). */
2158
+ supplier: BuildReportSupplier;
2137
2159
  /** The line before the first gate's report, naming what judges it. */
2138
2160
  heading: (profile: AssertionProfile) => string;
2139
- /** Run the gate, print its report, return the failure count. */
2140
- run: (input: BuildGateInput) => number;
2161
+ /** Run the gate, print its report, and return the lists it printed — and, on the core entry, its `here:` line's values. */
2162
+ run: (input: BuildGateInput) => { report: VerdictLists & { profile: AssertionProfile }; here: GateHere | null };
2141
2163
  /** The command a green build ends by naming, for its own `--out`. */
2142
2164
  look: (outDir: string) => string;
2143
2165
  }
2144
2166
 
2145
2167
  function runGate(
2146
2168
  gate: BuildGate,
2169
+ record: BuildReport | null,
2170
+ atlasKind: BuildReportGate['atlas'],
2147
2171
  result: CompileResult,
2148
2172
  modelText: string,
2149
2173
  opts: CompileOptions,
@@ -2158,7 +2182,7 @@ function runGate(
2158
2182
  // issue #1016): so the second document is spelled from the second compile's
2159
2183
  // atlas as it would be written, or from the second, independent pack.
2160
2184
  const again = compile(opts);
2161
- return gate.run({
2185
+ const { report, here } = gate.run({
2162
2186
  result,
2163
2187
  atlasText: atlas ? atlas.text : result.atlasText,
2164
2188
  atlasDir: opts.outDir,
@@ -2166,6 +2190,89 @@ function runGate(
2166
2190
  reEmit: { skeletonText: again.skeletonText, atlasText: atlas ? atlas.again : again.atlasText, modelText: modelDocument(again.model, again.skeletonText, atlas ? atlas.again : written(again.atlasText)) },
2167
2191
  profile,
2168
2192
  });
2193
+ record?.gates.push(buildReportGate(atlasKind, report, here));
2194
+ return report.failures.length;
2195
+ }
2196
+
2197
+ // ---------------------------------------------------------------------------
2198
+ // --report: the build's report as a document (issue #1213)
2199
+ // ---------------------------------------------------------------------------
2200
+
2201
+ /**
2202
+ * A `--report` in the making: where it goes, and what the run has stated so
2203
+ * far — every gate's lists and every `pack:` line's figures, recorded where
2204
+ * the line is printed and from the values it is printed from. `write` spells
2205
+ * the document (`buildReportText`) and writes it; nothing else in it is read
2206
+ * off the disk, the clock or the machine.
2207
+ */
2208
+ export interface BuildReport {
2209
+ path: string;
2210
+ command: BuildReportDocument['command'];
2211
+ supplier: BuildReportSupplier;
2212
+ gates: BuildReportGate[];
2213
+ pack: PackPageFigures[] | null;
2214
+ }
2215
+
2216
+ /** The document a recorded run states, keys in the order the spec writes them. */
2217
+ export function buildReportDocument(record: BuildReport): BuildReportDocument {
2218
+ return { spec: BUILD_REPORT_SPEC, command: record.command, supplier: record.supplier, gates: record.gates, pack: record.pack };
2219
+ }
2220
+
2221
+ /** Write what the run stated to `--report`. Called when a gate has reached its verdict: before a red gate's exit, after a green build's last write. */
2222
+ export function writeBuildReport(record: BuildReport): void {
2223
+ writeFileSync(record.path, buildReportText(buildReportDocument(record)));
2224
+ }
2225
+
2226
+ /**
2227
+ * `--report <file>` read for a command whose build goes into `outDir`: the
2228
+ * file resolved against the working directory, refused by name when it is
2229
+ * inside `outDir` or is `outDir` — the build's directory holds the files A18
2230
+ * and `emit_hashes` hold byte-identical, and a report there would be a file of
2231
+ * the build that is not the build — and refused when a directory stands at the
2232
+ * path. A file already at the path is removed here, before anything is
2233
+ * compiled, so the file at `--report` is always this run's: a run that reaches
2234
+ * no gate (a usage refusal after this point, a compile error, a repack refused
2235
+ * before or after its gate) leaves no document rather than an earlier run's.
2236
+ * `null` when the flag is absent.
2237
+ */
2238
+ export function readReportFlag(flags: Record<string, string>, command: BuildReport['command'], outDir: string, supplier: BuildReportSupplier): BuildReport | null {
2239
+ const raw = flags.report;
2240
+ if (raw === undefined) return null;
2241
+ const path = resolve(raw);
2242
+ const out = resolve(outDir);
2243
+ const within = relative(out, path);
2244
+ if (within === '' || (!within.startsWith('..') && !isAbsolute(within))) {
2245
+ throw new UsageError(
2246
+ `--report ${raw} is inside --out ${outDir}: the report is written beside a build, never into it — --out holds the build's own files, ` +
2247
+ 'which A18 and emit_hashes hold byte-identical. Name a path outside --out',
2248
+ );
2249
+ }
2250
+ if (existsSync(path) && statSync(path).isDirectory()) throw new UsageError(`--report ${raw} is a directory; it takes the path of the file to write`);
2251
+ rmSync(path, { force: true });
2252
+ return { path, command, supplier, gates: [], pack: null };
2253
+ }
2254
+
2255
+ /**
2256
+ * One `pack:` line's figures (issue #1213): what the line prints, as values —
2257
+ * `coveredPct` at the one decimal the line spells, so the document and the
2258
+ * line state the same number.
2259
+ */
2260
+ function packPageFigures(page: { name: string; width: number; height: number; occupancy: number }, regions: number, padding: number, pageEdges: PageEdges, packShape: PackShape): PackPageFigures {
2261
+ return { page: page.name, width: page.width, height: page.height, regions, coveredPct: Number((page.occupancy * 100).toFixed(1)), padding, pageEdges, packShape };
2262
+ }
2263
+
2264
+ /** The `pack:` line, spelled from its figures. */
2265
+ function packLine(f: PackPageFigures): string {
2266
+ return (
2267
+ ` .. pack: ${f.page} ${f.width}x${f.height}, ` +
2268
+ `${f.regions} region(s), ` +
2269
+ `${f.coveredPct.toFixed(1)}% covered, padding ${f.padding}` +
2270
+ (f.pageEdges === 'free' ? ', page edges free' : '') +
2271
+ // Appended, never inserted: a reader that takes the line whole keeps
2272
+ // every field it read before #1099, and the mode is named under the
2273
+ // default too, so no reader infers it from an absent word.
2274
+ `, shape ${f.packShape}`
2275
+ );
2169
2276
  }
2170
2277
 
2171
2278
  /**
@@ -2239,8 +2346,13 @@ function printHeaderBox(result: CompileResult): void {
2239
2346
  * for its gate, which the entry hands in (`BuildGate`): both entries write
2240
2347
  * the Spine pair, the model document and the pages by this one body.
2241
2348
  */
2242
- export function runBuild(flags: Record<string, string>, gate: BuildGate): void {
2349
+ export function runBuild(flags: Record<string, string>, gate: BuildGate, caller: BuildReport | null = null): void {
2243
2350
  const { label, opts, profile, packing } = readBuildInvocation(flags);
2351
+ // `--report` (issue #1213): `build`'s own, written by this body on either verdict — or the caller's
2352
+ // (`repack`'s), which this body writes on a red gate, since the exit leaves the caller no turn, and which the
2353
+ // caller writes when it has written its own output.
2354
+ const own = caller === null ? readReportFlag(flags, 'build', opts.outDir, gate.supplier) : null;
2355
+ const record = caller ?? own;
2244
2356
  printBuildHeader(label, opts);
2245
2357
  const result = compile(opts);
2246
2358
  printCompiled(opts, result);
@@ -2258,8 +2370,9 @@ export function runBuild(flags: Record<string, string>, gate: BuildGate): void {
2258
2370
  const writtenAtlas = (atlasText: string): string => (copying ? plannedPageCopies(atlasText, opts.outDir).atlasText : atlasText);
2259
2371
  let modelText = modelDocument(result.model, result.skeletonText, writtenAtlas(result.atlasText));
2260
2372
  console.log(gate.heading(profile));
2261
- const failures = runGate(gate, result, modelText, opts, profile, writtenAtlas);
2373
+ const failures = runGate(gate, record, 'compiled', result, modelText, opts, profile, writtenAtlas);
2262
2374
  if (failures > 0) {
2375
+ if (record !== null) writeBuildReport(record);
2263
2376
  console.error(`rigc: ${failures} assertion(s) failed — nothing written`);
2264
2377
  process.exit(1);
2265
2378
  }
@@ -2308,18 +2421,12 @@ export function runBuild(flags: Record<string, string>, gate: BuildGate): void {
2308
2421
  };
2309
2422
  const packed = packAtlas(packInputs(result, packOpts.shape), packOpts);
2310
2423
  atlasText = packed.atlasText;
2424
+ if (record !== null) record.pack = [];
2311
2425
  for (const page of packed.pages) {
2312
2426
  page.plate.writePng(join(opts.outDir, page.name));
2313
- console.log(
2314
- ` .. pack: ${page.name} ${page.width}x${page.height}, ` +
2315
- `${packed.placements.filter((p) => packed.pages[p.page].name === page.name).length} region(s), ` +
2316
- `${(page.occupancy * 100).toFixed(1)}% covered, padding ${packed.padding}` +
2317
- (packOpts.pageEdges === 'free' ? ', page edges free' : '') +
2318
- // Appended, never inserted: a reader that takes the line whole keeps
2319
- // every field it read before #1099, and the mode is named under the
2320
- // default too, so no reader infers it from an absent word.
2321
- `, shape ${packed.shape}`,
2322
- );
2427
+ const figures = packPageFigures(page, packed.placements.filter((p) => packed.pages[p.page].name === page.name).length, packed.padding, packOpts.pageEdges, packed.shape);
2428
+ record?.pack?.push(figures);
2429
+ console.log(packLine(figures));
2323
2430
  }
2324
2431
  for (const place of packed.placements) {
2325
2432
  console.log(
@@ -2337,8 +2444,9 @@ export function runBuild(flags: Record<string, string>, gate: BuildGate): void {
2337
2444
  // The document written is spelled from the packed atlas, and this gate's A18 compares it with a
2338
2445
  // second compile's spelled from the second, independent pack (issue #1016).
2339
2446
  modelText = modelDocument(result.model, result.skeletonText, atlasText);
2340
- const packFailures = runGate(gate, result, modelText, opts, profile, (text) => text, { text: atlasText, again: packAgain.atlasText });
2447
+ const packFailures = runGate(gate, record, 'packed', result, modelText, opts, profile, (text) => text, { text: atlasText, again: packAgain.atlasText });
2341
2448
  if (packFailures > 0) {
2449
+ if (record !== null) writeBuildReport(record);
2342
2450
  console.error(
2343
2451
  `rigc: ${packFailures} assertion(s) failed on the PACKED atlas — the pages were written to ` +
2344
2452
  `${opts.outDir}, the skeleton/atlas pair was not`,
@@ -2357,6 +2465,8 @@ export function runBuild(flags: Record<string, string>, gate: BuildGate): void {
2357
2465
  console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
2358
2466
  console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.atlas')}`);
2359
2467
  console.log(`rigc: wrote ${join(opts.outDir, MODEL_DOCUMENT_FILE)}`);
2468
+ // Written after the build, and printed nowhere: the lines a build prints are the same with the flag and without it.
2469
+ if (own !== null) writeBuildReport(own);
2360
2470
  // The next command is part of the message (issue #837). A green build is the
2361
2471
  // moment somebody wants to see what came out, and the one page rigc writes
2362
2472
  // for that is `preview` of exactly this directory — so the line names it,
@@ -41,6 +41,7 @@ function repositoryUrl(): string {
41
41
  * with the arguments it has always been handed, and the same lines.
42
42
  */
43
43
  const ROUND_TRIP_GATE: BuildGate = {
44
+ supplier: 'round-trip',
44
45
  heading: (profile) => ` .. validate (spine-core round trip + machine assertions, profile ${profile})`,
45
46
  run: ({ result, atlasText, atlasDir, modelText, reEmit, profile }) => {
46
47
  const report = validate({
@@ -59,7 +60,7 @@ const ROUND_TRIP_GATE: BuildGate = {
59
60
  .map(([k, v]) => `${k}=${v}`)
60
61
  .join(' ')}`,
61
62
  );
62
- return report.failures.length;
63
+ return { report, here: null };
63
64
  },
64
65
  look: (outDir) => `rigc: look at it: rigc preview --candidate ${outDir}`,
65
66
  };