spine-rigc 2.1.2 → 2.1.3

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
@@ -699,7 +699,9 @@ declares, or the banner the binary prints about itself — rather than starting
699
699
  and failing downstream. Both refusals point at `--exported <file>`, which
700
700
  measures an export the editor already made and is the half of this tool that
701
701
  needs no editor at all. A build whose `skeleton.json` is missing, is not JSON, or
702
- holds JSON that is not one object is refused by name before the editor starts.
702
+ holds JSON that is not one object is refused by name before the editor starts,
703
+ and so is a build with no atlas or an atlas that is a directory, or an
704
+ `--exported` file that is not one JSON object.
703
705
 
704
706
  ⛔ **Run the round trip by hand, on a machine that has the editor.** CI has no
705
707
  editor, so no automated check runs it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
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": {
@@ -968,31 +968,95 @@ function isRecord(value: unknown): value is Record<string, unknown> {
968
968
  * it was and every line after this prints as it did.
969
969
  */
970
970
  function readBuildSkeleton(source: string): { skeleton: Record<string, unknown> } | { refusal: string } {
971
- const required =
971
+ return readSkeletonObject(
972
+ source,
973
+ "the build's skeleton.json",
972
974
  'The round trip imports that file into the editor, so it has to be one skeleton JSON object — `skeleton`, ' +
973
- '`bones`, `slots`, … — which is what `rigc build` writes there.';
974
- let text: string;
975
+ '`bones`, `slots`, … — which is what `rigc build` writes there.',
976
+ );
977
+ }
978
+
979
+ /**
980
+ * One skeleton file read and parsed, with its bytes — or the sentence the tool
981
+ * refuses it with, naming `what` it is, its path, what was found there and the
982
+ * `required` sentence. The one body behind `readBuildSkeleton` and the
983
+ * `--exported` read (issue #1107), so the two files are refused in one shape.
984
+ *
985
+ * The bytes are handed back because the `--exported` file is written into the
986
+ * candidate from them: the file step 3 measures is then the file this parsed,
987
+ * byte for byte, rather than whatever is at the path by the time step 3 runs.
988
+ */
989
+ function readSkeletonObject(
990
+ path: string,
991
+ what: string,
992
+ required: string,
993
+ ): { skeleton: Record<string, unknown>; bytes: Buffer } | { refusal: string } {
994
+ let bytes: Buffer;
975
995
  try {
976
- text = readFileSync(source, 'utf8');
996
+ bytes = readFileSync(path);
977
997
  } catch (err) {
978
998
  const code = (err as NodeJS.ErrnoException).code;
979
- if (code === 'EISDIR') return { refusal: `the build's skeleton.json at ${source} is a directory, not a file. ${required}` };
980
- return { refusal: `the build's skeleton.json at ${source} could not be read (${code ?? String(err)}). ${required}` };
999
+ if (code === 'EISDIR') return { refusal: `${what} at ${path} is a directory, not a file. ${required}` };
1000
+ return { refusal: `${what} at ${path} could not be read (${code ?? String(err)}). ${required}` };
981
1001
  }
1002
+ const text = bytes.toString('utf8');
982
1003
  if (text.trim() === '') {
983
- return { refusal: `the build's skeleton.json at ${source} is empty (${text.length} byte(s), no JSON value in them). ${required}` };
1004
+ return { refusal: `${what} at ${path} is empty (${text.length} byte(s), no JSON value in them). ${required}` };
984
1005
  }
985
1006
  let parsed: unknown;
986
1007
  try {
987
1008
  parsed = JSON.parse(text);
988
1009
  } catch (err) {
989
1010
  const said = err instanceof Error ? err.message : String(err);
990
- return { refusal: `the build's skeleton.json at ${source} is not JSON — the parser stopped with "${said}". ${required}` };
1011
+ return { refusal: `${what} at ${path} is not JSON — the parser stopped with "${said}". ${required}` };
991
1012
  }
992
1013
  if (!isRecord(parsed)) {
993
- return { refusal: `the build's skeleton.json at ${source} is JSON, but it holds ${jsonKind(parsed)} rather than an object. ${required}` };
1014
+ return { refusal: `${what} at ${path} is JSON, but it holds ${jsonKind(parsed)} rather than an object. ${required}` };
994
1015
  }
995
- return { skeleton: parsed };
1016
+ return { skeleton: parsed, bytes };
1017
+ }
1018
+
1019
+ /**
1020
+ * The build's atlas, read once before step 1, or the sentence the tool refuses
1021
+ * it with (issue #1107): its name and the page names it lists.
1022
+ *
1023
+ * 🚨 This read used to sit at step 3, after the editor had imported and
1024
+ * exported. So a build with no atlas was refused by name only after a licensed
1025
+ * editor had spent an import and an export on it, and a directory named
1026
+ * `*.atlas` beside the build threw `EISDIR` there as a stack. Neither needs the
1027
+ * editor to answer: the atlas is rigc's input to step 3, not the editor's.
1028
+ *
1029
+ * ⭐ Every `*.atlas` entry is read, not only the one whose pages are listed,
1030
+ * because step 3 copies every one of them beside the export, and a directory
1031
+ * among them throws at that copy. The first entry is the atlas, as it always was.
1032
+ *
1033
+ * ⚠️ "Read" is all this can mean. The tool's own reader of an atlas is the line
1034
+ * filter below, which accepts any text, so there is no malformed atlas for it to
1035
+ * refuse; what the atlas SAYS is the gate's to judge at step 3 (`A08`, `A17`).
1036
+ */
1037
+ function readBuildAtlas(build: string): { pageNames: string[] } | { refusal: string } {
1038
+ const atlases = readdirSync(build).filter((f) => f.endsWith('.atlas'));
1039
+ if (atlases.length === 0) return { refusal: `no .atlas in the build directory ${build}` };
1040
+ const required =
1041
+ 'Step 3 copies the build\'s atlas and pages beside the export so the export can be loaded and measured, so ' +
1042
+ 'every `.atlas` there has to be a file — the one `rigc build` writes.';
1043
+ let first: string | null = null;
1044
+ for (const name of atlases) {
1045
+ const path = join(build, name);
1046
+ try {
1047
+ const text = readFileSync(path, 'utf8');
1048
+ if (first === null) first = text;
1049
+ } catch (err) {
1050
+ const code = (err as NodeJS.ErrnoException).code;
1051
+ if (code === 'EISDIR') return { refusal: `the build's atlas at ${path} is a directory, not a file. ${required}` };
1052
+ return { refusal: `the build's atlas at ${path} could not be read (${code ?? String(err)}). ${required}` };
1053
+ }
1054
+ }
1055
+ const pageNames = (first ?? '')
1056
+ .split('\n')
1057
+ .filter((line) => /\.(png|jpg|jpeg)\s*$/i.test(line.trim()) && !line.startsWith(' ') && !line.startsWith('\t'))
1058
+ .map((line) => line.trim());
1059
+ return { pageNames };
996
1060
  }
997
1061
 
998
1062
  /**
@@ -1111,10 +1175,14 @@ function main(): void {
1111
1175
  // makes — and `--pack --copy-images` is refused by `cli.ts`, correctly,
1112
1176
  // because a packed atlas does not reference loose parts.
1113
1177
  //
1114
- // ⛔ It is checked HERE, before step 1, rather than beside the atlas check
1115
- // further down, because each precondition sits before the step it protects:
1116
- // the atlas's page names matter to the harness's own copy in step 3, and this
1117
- // matters to the editor's import in step 1.
1178
+ // ⛔ It is checked HERE, before step 1, because it matters to the editor's
1179
+ // import in step 1. The atlas and an `--exported` file are read before step 1
1180
+ // as well, just below, although nothing before step 3 uses them (issue #1107):
1181
+ // they are rigc's inputs and not the editor's, so reading them first costs the
1182
+ // editor nothing and refuses before a licensed editor is started on a trip
1183
+ // that cannot finish — the reason `readBuildSkeleton` reads the build's
1184
+ // skeleton first (#1090). So the order is: every input rigc reads is read
1185
+ // before the editor starts, and only what the editor writes is read after it.
1118
1186
  {
1119
1187
  interface ImagesProbe {
1120
1188
  skeleton?: { images?: string };
@@ -1167,6 +1235,48 @@ function main(): void {
1167
1235
  }
1168
1236
  }
1169
1237
 
1238
+ // The export is a skeleton only; it needs the build's atlas and pages beside
1239
+ // it to be a candidate anything can load — so the atlas is step 3's, and it is
1240
+ // read here, before step 1, for the reason the sentence above gives.
1241
+ //
1242
+ // 🚨 Which is why the build has to be SELF-CONTAINED, and this refuses when it
1243
+ // is not. An ordinary build's atlas names its pages by a relative path back to
1244
+ // the art directory; copy that atlas to a directory at another depth and every
1245
+ // page name resolves to nothing. Found by running this tool — it reported four
1246
+ // `A17_ATLAS_PAGE_FILES_EXIST` failures that were the harness's fault and not
1247
+ // the editor's, which is the worst kind of red: a real assertion, correctly
1248
+ // fired, pointing at the wrong culprit.
1249
+ const atlas = readBuildAtlas(opts.build);
1250
+ if ('refusal' in atlas) return fail(atlas.refusal);
1251
+ const wandering = atlas.pageNames.filter((n) => n.includes('/'));
1252
+ if (wandering.length > 0) {
1253
+ fail(
1254
+ `the build's atlas names its pages by path, not by filename — the first is "${wandering[0]}". ` +
1255
+ 'The round trip copies the atlas beside the export, at a different depth, so every one of those ' +
1256
+ `${wandering.length} page name(s) would resolve to nothing and A17 would blame the editor for it. ` +
1257
+ 'Rebuild with `--copy-images`, which puts the pages beside the skeleton and names them plainly.',
1258
+ );
1259
+ }
1260
+
1261
+ // 🔒 The `--exported` file, read and parsed here for the same reason (issue
1262
+ // #1107): one skeleton JSON object, in `readBuildSkeleton`'s shape, or refused
1263
+ // by name. It used to be read first by step 4's `skinsDeclaredBy`, bare, so a
1264
+ // file that was not JSON was gated and diffed by steps 3–4 and then left the
1265
+ // tool as a `SyntaxError` stack. Step 3's candidate is written from these
1266
+ // bytes, so what is measured is what was parsed.
1267
+ let exportedBytes: Buffer | null = null;
1268
+ if (opts.exported !== null) {
1269
+ if (!existsSync(opts.exported)) fail(`no such export: ${opts.exported}`);
1270
+ const got = readSkeletonObject(
1271
+ opts.exported,
1272
+ 'the --exported file',
1273
+ 'Steps 3-6 measure that file as the editor\'s JSON export of this build, so it has to be one skeleton JSON ' +
1274
+ 'object — `skeleton`, `bones`, `slots`, … — which is what the editor\'s `-e json` export writes.',
1275
+ );
1276
+ if ('refusal' in got) return fail(got.refusal);
1277
+ exportedBytes = got.bytes;
1278
+ }
1279
+
1170
1280
  rmSync(opts.out, { recursive: true, force: true });
1171
1281
  mkdirSync(join(opts.out, 'export'), { recursive: true });
1172
1282
  mkdirSync(join(opts.out, 'export-cand'), { recursive: true });
@@ -1177,7 +1287,6 @@ function main(): void {
1177
1287
 
1178
1288
  let exportedJson: string;
1179
1289
  if (opts.exported !== null) {
1180
- if (!existsSync(opts.exported)) fail(`no such export: ${opts.exported}`);
1181
1290
  exportedJson = opts.exported;
1182
1291
  emit(` editor NOT RUN — measuring an export the editor already made: ${opts.exported}`);
1183
1292
  emit('');
@@ -1246,33 +1355,12 @@ function main(): void {
1246
1355
  exportedJson = join(opts.out, 'export', written[0]);
1247
1356
  }
1248
1357
 
1249
- // The export is a skeleton only; it needs the build's atlas and pages beside
1250
- // it to be a candidate anything can load.
1251
- //
1252
- // 🚨 Which is why the build has to be SELF-CONTAINED, and this refuses when it
1253
- // is not. An ordinary build's atlas names its pages by a relative path back to
1254
- // the art directory; copy that atlas to a directory at another depth and every
1255
- // page name resolves to nothing. Found by running this tool — it reported four
1256
- // `A17_ATLAS_PAGE_FILES_EXIST` failures that were the harness's fault and not
1257
- // the editor's, which is the worst kind of red: a real assertion, correctly
1258
- // fired, pointing at the wrong culprit.
1358
+ // The candidate: the export beside the build's atlas and pages, which were
1359
+ // read before step 1 (see `readBuildAtlas`). An `--exported` file is written
1360
+ // from the bytes parsed there; the editor's own export is copied from disk.
1259
1361
  const cand = join(opts.out, 'export-cand');
1260
- const atlasName = readdirSync(opts.build).find((f) => f.endsWith('.atlas'));
1261
- if (atlasName === undefined) fail(`no .atlas in the build directory ${opts.build}`);
1262
- const pageNames = readFileSync(join(opts.build, atlasName!), 'utf8')
1263
- .split('\n')
1264
- .filter((line) => /\.(png|jpg|jpeg)\s*$/i.test(line.trim()) && !line.startsWith(' ') && !line.startsWith('\t'))
1265
- .map((line) => line.trim());
1266
- const wandering = pageNames.filter((n) => n.includes('/'));
1267
- if (wandering.length > 0) {
1268
- fail(
1269
- `the build's atlas names its pages by path, not by filename — the first is "${wandering[0]}". ` +
1270
- 'The round trip copies the atlas beside the export, at a different depth, so every one of those ' +
1271
- `${wandering.length} page name(s) would resolve to nothing and A17 would blame the editor for it. ` +
1272
- 'Rebuild with `--copy-images`, which puts the pages beside the skeleton and names them plainly.',
1273
- );
1274
- }
1275
- copyFileSync(exportedJson, join(cand, 'skeleton.json'));
1362
+ if (exportedBytes !== null) writeFileSync(join(cand, 'skeleton.json'), exportedBytes);
1363
+ else copyFileSync(exportedJson, join(cand, 'skeleton.json'));
1276
1364
  for (const f of readdirSync(opts.build)) {
1277
1365
  if (f.endsWith('.atlas') || f.endsWith('.png')) copyFileSync(join(opts.build, f), join(cand, f));
1278
1366
  }