@xemahq/repo-build-tooling 0.7.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/repo-build-tooling",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM with zero dependencies so the published artifact is the reviewed source.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
package/src/readiness.mjs CHANGED
@@ -376,52 +376,131 @@ export function packagesOwningPaths(root, relPaths, script = 'test') {
376
376
  * largest repository here, and CI owns it. What this owns is "the thing I just
377
377
  * edited still passes its own proofs".
378
378
  */
379
+ /**
380
+ * The DECISION for one affected stage, pure over its inputs so it is testable.
381
+ *
382
+ * Four outcomes, and the first is the one this used to get wrong:
383
+ *
384
+ * - `workspace` — there is NO branch point (`origin/develop` and
385
+ * `origin/main` are both unfetched, as in a scratch export
386
+ * or an archive). This used to SKIP, print "fetch, or run
387
+ * this by hand", and let the run end on "converged — safe to
388
+ * push". Measured 2026-09-20 in a scratch export: typecheck,
389
+ * lint AND tests all skipped, exit 0, banner green — the
390
+ * "reports green while executing nothing" shape this fleet
391
+ * names as its most-repeated defect, in the one tool written
392
+ * to end it. Without a diff there is no affected set, so the
393
+ * honest scope is the WHOLE repository: the root script when
394
+ * the root declares one (a root `lint` or `typecheck` covers
395
+ * every member), else every member declaring it — which is
396
+ * exactly what CI runs.
397
+ * - `affected` — the changed paths are owned by members declaring it.
398
+ * - `root` — no changed member declares it but the root does: run it
399
+ * REPO-WIDE, labelled, because skipping would drop a proof.
400
+ * - `inapplicable`— nothing declares it. Genuinely nothing to run.
401
+ */
402
+ export function affectedStagePlan({ base, paths, packages, rootDeclares }) {
403
+ if (!base) return { mode: 'workspace', viaRoot: rootDeclares };
404
+ if (packages.length > 0) return { mode: 'affected', packages };
405
+ if (paths.length > 0 && rootDeclares) return { mode: 'root' };
406
+ return { mode: 'inapplicable' };
407
+ }
408
+
379
409
  function runAffectedStage({ id, script, why }) {
380
410
  console.log(`\n=== ${id} — ${why} ===`);
381
411
  const { base, paths } = changedPackages();
382
- if (!base) {
383
- console.log(
384
- ' SKIPPED — neither origin/develop nor origin/main is fetched here, so\n' +
385
- ' there is no branch point to diff against. Fetch, or run this by hand.',
386
- );
387
- return;
388
- }
389
- const packages = packagesOwningPaths(ROOT, paths, script);
390
- // The CORPUS, printed with BOTH counts: "0 ran" and "0 failed" look identical
391
- // otherwise, and a green command over an empty scan is not proof.
392
- console.log(
393
- ` ${paths.length} changed path(s) since ${base.slice(0, 9)} -> ` +
394
- `${packages.length} package(s) declaring \`${script}\``,
395
- );
396
- if (packages.length > 0) {
397
- for (const name of packages) console.log(` - ${name}`);
398
- for (const name of packages) {
399
- // One at a time, so a failure NAMES the package; a single multi-filter
400
- // invocation reports only the first.
401
- sh(['pnpm', '--filter', name, script]);
402
- }
403
- return;
412
+ const packages = base ? packagesOwningPaths(ROOT, paths, script) : [];
413
+ const plan = affectedStagePlan({
414
+ base,
415
+ paths,
416
+ packages,
417
+ rootDeclares: Boolean(scripts[script]),
418
+ });
419
+ switch (plan.mode) {
420
+ case 'workspace':
421
+ console.log(
422
+ ' no branch point — neither origin/develop nor origin/main is fetched here,\n' +
423
+ ' so there is no affected set to derive. Running REPO-WIDE instead, which\n' +
424
+ ' is what CI runs; this is reported as repo-wide, never as affected.',
425
+ );
426
+ if (plan.viaRoot) sh(['pnpm', 'run', script]);
427
+ else sh(['pnpm', '-r', '--if-present', 'run', script]);
428
+ return;
429
+ case 'affected':
430
+ // The CORPUS, printed with BOTH counts: "0 ran" and "0 failed" look
431
+ // identical otherwise, and a green command over an empty scan is not proof.
432
+ console.log(
433
+ ` ${paths.length} changed path(s) since ${base.slice(0, 9)} -> ` +
434
+ `${plan.packages.length} package(s) declaring \`${script}\``,
435
+ );
436
+ for (const name of plan.packages) console.log(` - ${name}`);
437
+ for (const name of plan.packages) {
438
+ // One at a time, so a failure NAMES the package; a single multi-filter
439
+ // invocation reports only the first.
440
+ sh(['pnpm', '--filter', name, script]);
441
+ }
442
+ return;
443
+ case 'root':
444
+ // FALLBACK, and it is the difference between "inapplicable" and "not
445
+ // checked". Some repositories own a stage only at the root — `lint:
446
+ // eslint .` covers every package without any of them declaring `lint`.
447
+ // Skipping there would silently drop a real proof, so the root script
448
+ // runs, LABELLED as repo-wide rather than affected.
449
+ console.log(
450
+ ` ${paths.length} changed path(s) since ${base.slice(0, 9)}; no changed` +
451
+ ` package declares \`${script}\`, but the ROOT does — running it\n` +
452
+ ' REPO-WIDE. This is applicable-but-not-affected, and is reported as such.',
453
+ );
454
+ sh(['pnpm', 'run', script]);
455
+ return;
456
+ default:
457
+ console.log(
458
+ ` nothing to run — no changed package declares \`${script}\` and the root\n` +
459
+ ' declares none either, so this stage is genuinely inapplicable here.',
460
+ );
404
461
  }
405
- // FALLBACK, and it is the difference between "inapplicable" and "not checked".
406
- // Some repositories own a stage only at the root — `lint: eslint .` covers
407
- // every package without any of them declaring `lint`. Skipping there would
408
- // silently drop a real proof, so the root script runs, LABELLED as repo-wide
409
- // rather than affected, so nobody reads it as an affected result.
410
- if (paths.length > 0 && scripts[script]) {
462
+ }
463
+
464
+
465
+ /**
466
+ * Publishability — DELEGATED to the checker that already owns the verdict.
467
+ *
468
+ * Every publishing repository declares `verify:release-readiness` as
469
+ * `xema-publish-packages --check`: it packs every public package and validates
470
+ * the tarball offline — identity, source binding, access, resolved ranges, entry
471
+ * points — and CI runs that exact command after build. It never contacts the
472
+ * registry and never uploads, so it is the same proof in the same built state,
473
+ * with one more caller.
474
+ *
475
+ * This is deliberately NOT a readiness-local predicate. A first version of this
476
+ * tool omitted publishability on the reasoning that deciding whether a change
477
+ * OWES a changeset needs packed bytes compared to the registry, which only the
478
+ * publisher can do — true, and a false dichotomy: the choice was never "duplicate
479
+ * the publisher" or "omit the proof". The owning checker was already exposed as
480
+ * a root script in all six repositories, so the correct stage is one call to it.
481
+ * One authority, two callers.
482
+ *
483
+ * It runs LAST, after the generators and the affected stages, because it packs
484
+ * what is on disk: a tarball packed before `build` grades stale `dist/`. And it
485
+ * SKIPS — printed, never silently — in a repository that declares no such script,
486
+ * because there the fact is genuinely inapplicable rather than unchecked.
487
+ *
488
+ * If this checker ever behaves badly for a local call, the fix is to that shared
489
+ * checker, where CI benefits too. Never a weaker copy here.
490
+ */
491
+ function runReleaseReadiness() {
492
+ console.log('\n=== release-readiness — the tarballs this tree would publish ===');
493
+ if (!scripts['verify:release-readiness']) {
411
494
  console.log(
412
- ` no changed package declares \`${script}\`, but the ROOT does — running it\n` +
413
- ' REPO-WIDE. This is applicable-but-not-affected, and is reported as such.',
495
+ ' SKIPPED — this repository declares no `verify:release-readiness`, so it\n' +
496
+ ' publishes nothing from here and there is no tarball to grade.',
414
497
  );
415
- sh(['pnpm', 'run', script]);
416
498
  return;
417
499
  }
418
- console.log(
419
- ` nothing to run — no changed package declares \`${script}\` and the root\n` +
420
- ' declares none either, so this stage is genuinely inapplicable here.',
421
- );
500
+ console.log(' delegating to `pnpm verify:release-readiness` — the checker CI runs');
501
+ sh(['pnpm', 'run', 'verify:release-readiness']);
422
502
  }
423
503
 
424
-
425
504
  function run() {
426
505
  // ── WHAT `--allow-dirty` INCLUDES, STATED RATHER THAN ASSUMED ─────────────
427
506
  // This regenerates derived artifacts and hashes them into biome ledgers, and a
@@ -470,7 +549,10 @@ function run() {
470
549
  }
471
550
 
472
551
  const only = process.argv.find((a) => a.startsWith('--only='))?.slice('--only='.length);
473
- console.log(`readiness: ${path.basename(ROOT)} — prisma -> build -> specs -> clients -> ledgers -> checks\n`);
552
+ console.log(
553
+ `readiness: ${path.basename(ROOT)} — ` +
554
+ 'prisma -> build -> specs -> clients -> ledgers -> checks -> typecheck -> lint -> tests -> release-readiness\n',
555
+ );
474
556
 
475
557
  for (const step of STEPS) {
476
558
  if (only && only !== step.id) continue;
@@ -564,6 +646,8 @@ function run() {
564
646
  runAffectedStage(stage);
565
647
  }
566
648
 
649
+ runReleaseReadiness();
650
+
567
651
  console.log(
568
652
  '\nreadiness: converged. Every derived artifact describes the source beside it,\n' +
569
653
  'and the repository\'s own checks agree. Safe to push.',
@@ -617,7 +701,8 @@ try {
617
701
  'were not reached, so do not read their absence as a second failure.\n' +
618
702
  `\n ${error?.message?.split('\n')[0] ?? error}\n` +
619
703
  '\nIf a CHECK failed rather than a generator, a dependency in\n' +
620
- 'prisma -> build -> specs -> clients -> ledgers was violated. Find it; do not re-run.\n',
704
+ 'prisma -> build -> specs -> clients -> ledgers was violated. Find it; do not\n' +
705
+ 're-run. A stage AFTER `checks` failing is a real finding about your change.\n',
621
706
  );
622
707
  process.exit(1);
623
708
  }
@@ -4,7 +4,7 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import test from 'node:test';
6
6
 
7
- import { invokedDirectly, packagesOwningPaths } from './readiness.mjs';
7
+ import { affectedStagePlan, invokedDirectly, packagesOwningPaths } from './readiness.mjs';
8
8
 
9
9
  /** A throwaway workspace: root + four members with different shapes. */
10
10
  async function fixture() {
@@ -178,7 +178,20 @@ test('the run guard sees through a SYMLINK — pnpm links every package this way
178
178
  const link = path.join(root, 'linked.mjs');
179
179
  await fs.symlink(real, link);
180
180
 
181
- const url = `file://${real}`;
181
+ const realUrl = `file://${real}`;
182
+ const linkUrl = `file://${link}`;
183
+ // ALL FOUR SPELLINGS, because the bug was SPELLING-DEPENDENCE itself and a
184
+ // single-spelling test passes for whoever happens to write it. Measured: the
185
+ // two of us hit opposite answers from the same package at the same version,
186
+ // decided only by whether pnpm's `.bin` shim pointed at the symlink or at the
187
+ // `.pnpm/` realpath. Every combination names ONE file, so every one is true.
188
+ for (const argv of [real, link]) {
189
+ for (const url of [realUrl, linkUrl]) {
190
+ assert.equal(invokedDirectly(argv, url), true, `${argv} vs ${url}`);
191
+ }
192
+ }
193
+
194
+ const url = realUrl;
182
195
  // THE CASE THAT FAILED: invoked by the symlink, module loaded from the
183
196
  // realpath. Unresolved string comparison says false; the answer is true.
184
197
  assert.equal(invokedDirectly(link, url), true);
@@ -200,3 +213,33 @@ test('the run guard sees through a SYMLINK — pnpm links every package this way
200
213
  await fs.rm(root, { recursive: true, force: true });
201
214
  }
202
215
  });
216
+
217
+ test('an affected stage with NO branch point runs REPO-WIDE — it never skips', () => {
218
+ // The defect this pins: a scratch export or an archive has no origin/develop,
219
+ // and the stage used to SKIP and let the run end "converged — safe to push"
220
+ // with typecheck, lint and tests all unexecuted. Green over nothing.
221
+ assert.deepEqual(
222
+ affectedStagePlan({ base: undefined, paths: [], packages: [], rootDeclares: true }),
223
+ { mode: 'workspace', viaRoot: true },
224
+ );
225
+ assert.deepEqual(
226
+ affectedStagePlan({ base: undefined, paths: [], packages: [], rootDeclares: false }),
227
+ { mode: 'workspace', viaRoot: false },
228
+ );
229
+ // A base with owning packages is the ordinary affected run.
230
+ assert.deepEqual(
231
+ affectedStagePlan({ base: 'abc', paths: ['a/x.ts'], packages: ['a'], rootDeclares: true }),
232
+ { mode: 'affected', packages: ['a'] },
233
+ );
234
+ // A base where only the ROOT declares the script runs the root, labelled.
235
+ assert.deepEqual(
236
+ affectedStagePlan({ base: 'abc', paths: ['README.md'], packages: [], rootDeclares: true }),
237
+ { mode: 'root' },
238
+ );
239
+ // Nothing declares it: the only outcome that runs nothing, and it needs a
240
+ // base to be trusted — without one it is the workspace run above.
241
+ assert.deepEqual(
242
+ affectedStagePlan({ base: 'abc', paths: ['README.md'], packages: [], rootDeclares: false }),
243
+ { mode: 'inapplicable' },
244
+ );
245
+ });