@xemahq/repo-build-tooling 0.8.0 → 0.8.2

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.8.0",
3
+ "version": "0.8.2",
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
@@ -62,7 +62,8 @@
62
62
  * honestly.
63
63
  */
64
64
  import { execFileSync } from 'node:child_process';
65
- import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
65
+ import { createRequire } from 'node:module';
66
+ import { existsSync, readdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
66
67
  import path from 'node:path';
67
68
  import { fileURLToPath } from 'node:url';
68
69
  import process from 'node:process';
@@ -228,6 +229,38 @@ function exportsNamedByRefusal(output) {
228
229
  return [...named];
229
230
  }
230
231
 
232
+ /** Tracked-or-untracked paths under `dir` git reports as dirty, as a Set of `XY path` lines. */
233
+ function trackedDirtyPaths(dir) {
234
+ try {
235
+ return new Set(
236
+ sh(['git', 'status', '--porcelain', '--untracked-files=all', '--', dir], { capture: true })
237
+ .split('\n')
238
+ .filter(Boolean),
239
+ );
240
+ } catch {
241
+ return new Set(); // not a git checkout: nothing can be restored, and nothing is claimed
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Put back what a refused regeneration wrote: every path under `packages/clients`
247
+ * that is dirty NOW and was not dirty BEFORE the attempt. Tracked files return
248
+ * to HEAD; files the refusal created are removed. Nothing that was already
249
+ * dirty before the attempt is touched — that is somebody's in-flight work.
250
+ */
251
+ export function newlyDirtyPaths(before, after) {
252
+ return [...after].filter((line) => !before.has(line));
253
+ }
254
+
255
+ function restoreWhatTheRefusalWrote(dirtyBefore) {
256
+ const lines = newlyDirtyPaths(dirtyBefore, trackedDirtyPaths('packages/clients'));
257
+ const created = lines.filter((l) => l.startsWith('??')).map((l) => l.slice(3));
258
+ const modified = lines.filter((l) => !l.startsWith('??')).map((l) => l.slice(3));
259
+ if (modified.length > 0) sh(['git', 'checkout', '--', ...modified], { capture: true });
260
+ for (const rel of created) rmSync(path.join(ROOT, rel), { force: true });
261
+ return [...modified, ...created];
262
+ }
263
+
231
264
  function sh(argv, { capture = false, env } = {}) {
232
265
  return execFileSync(argv[0], argv.slice(1), {
233
266
  cwd: ROOT,
@@ -252,6 +285,13 @@ function servicesWith(script) {
252
285
  }
253
286
  const names = [];
254
287
  for (const entry of Array.isArray(parsed) ? parsed : [parsed]) {
288
+ // The ROOT is not a service. A root `client:generate: turbo run
289
+ // client:generate` is an AGGREGATE over every service, so running it here
290
+ // regenerates all of them at once, turbo-prefixes the one refusal that
291
+ // matters, and then applies a recorded decision to services it was never
292
+ // recorded for. Measured 2026-09-20 in xema-community. The per-service
293
+ // loop below is the whole point: a failure NAMES the service.
294
+ if (path.resolve(entry.path ?? '') === ROOT) continue;
255
295
  const manifest = path.join(entry.path ?? '', 'package.json');
256
296
  if (!existsSync(manifest)) continue;
257
297
  try {
@@ -399,10 +439,18 @@ export function packagesOwningPaths(root, relPaths, script = 'test') {
399
439
  * REPO-WIDE, labelled, because skipping would drop a proof.
400
440
  * - `inapplicable`— nothing declares it. Genuinely nothing to run.
401
441
  */
402
- export function affectedStagePlan({ base, paths, packages, rootDeclares }) {
403
- if (!base) return { mode: 'workspace', viaRoot: rootDeclares };
442
+ export function affectedStagePlan({ base, paths, packages, rootDeclares, membersDeclare }) {
443
+ // REPO-WIDE means WHAT CI RUNS, and every CI in this fleet runs the stage as
444
+ // `pnpm -r --if-present run <script>` — per member, never the root script.
445
+ // Measured 2026-09-20 in xema-kernel-sdk: the root `lint` is `eslint .`,
446
+ // which also lints `tooling/**` and re-lints every package under the ROOT
447
+ // config, and reports 139 errors CI has never seen. Running it here made
448
+ // readiness a SECOND lint authority that disagreed with the first. The root
449
+ // script is used only where no member declares the stage at all.
450
+ if (!base) return { mode: 'workspace', viaRoot: rootDeclares && !membersDeclare };
404
451
  if (packages.length > 0) return { mode: 'affected', packages };
405
- if (paths.length > 0 && rootDeclares) return { mode: 'root' };
452
+ if (paths.length > 0 && rootDeclares && !membersDeclare) return { mode: 'root' };
453
+ if (paths.length > 0 && membersDeclare) return { mode: 'workspace', viaRoot: false };
406
454
  return { mode: 'inapplicable' };
407
455
  }
408
456
 
@@ -415,6 +463,7 @@ function runAffectedStage({ id, script, why }) {
415
463
  paths,
416
464
  packages,
417
465
  rootDeclares: Boolean(scripts[script]),
466
+ membersDeclare: servicesWith(script).length > 0,
418
467
  });
419
468
  switch (plan.mode) {
420
469
  case 'workspace':
@@ -442,10 +491,9 @@ function runAffectedStage({ id, script, why }) {
442
491
  return;
443
492
  case 'root':
444
493
  // 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.
494
+ // checked": a repository that owns a stage ONLY at the root (no member
495
+ // declares it). Skipping there would silently drop a real proof, so the
496
+ // root script runs, LABELLED as repo-wide rather than affected.
449
497
  console.log(
450
498
  ` ${paths.length} changed path(s) since ${base.slice(0, 9)}; no changed` +
451
499
  ` package declares \`${script}\`, but the ROOT does — running it\n` +
@@ -497,8 +545,34 @@ function runReleaseReadiness() {
497
545
  );
498
546
  return;
499
547
  }
500
- console.log(' delegating to `pnpm verify:release-readiness` — the checker CI runs');
501
- sh(['pnpm', 'run', 'verify:release-readiness']);
548
+ // This runs BEFORE the commit, over the clients and ledgers the stages above
549
+ // just regenerated — so the checker's committed-tree refusal would report
550
+ // every package this run touched as NOT JUDGED, i.e. red exactly when
551
+ // readiness did its job. `--check --working-tree` (release-policy >= 12.2.0)
552
+ // grades the bytes the next commit would pack; an older checker is called
553
+ // without it and says so, rather than being passed a flag it refuses.
554
+ const flag = releasePolicySupportsWorkingTree() ? ['--working-tree'] : [];
555
+ console.log(
556
+ ` delegating to \`pnpm verify:release-readiness${flag.length ? ' --working-tree' : ''}\` — the checker CI runs` +
557
+ (flag.length
558
+ ? ''
559
+ : '\n (the installed @xemahq/release-policy predates --working-tree; packages this run\n' +
560
+ ' regenerated will read NOT JUDGED until they are committed — raise it to >=12.2.0)'),
561
+ );
562
+ sh(['pnpm', 'run', 'verify:release-readiness', ...flag]);
563
+ }
564
+
565
+ /** Does the checker this repository resolves accept `--check --working-tree`? Added in 12.2.0. */
566
+ function releasePolicySupportsWorkingTree() {
567
+ try {
568
+ const manifest = createRequire(path.join(ROOT, 'package.json')).resolve(
569
+ '@xemahq/release-policy/package.json',
570
+ );
571
+ const [major, minor] = JSON.parse(readFileSync(manifest, 'utf8')).version.split('.').map(Number);
572
+ return major > 12 || (major === 12 && minor >= 2);
573
+ } catch {
574
+ return false;
575
+ }
502
576
  }
503
577
 
504
578
  function run() {
@@ -575,6 +649,7 @@ function run() {
575
649
  for (const name of services) {
576
650
  // One at a time on purpose: a failure must name the service, and a single
577
651
  // `--filter a --filter b` invocation reports only the first.
652
+ const dirtyBefore = trackedDirtyPaths('packages/clients');
578
653
  try {
579
654
  sh(['pnpm', '--filter', name, step.perService]);
580
655
  } catch (error) {
@@ -612,6 +687,20 @@ function run() {
612
687
  throw error;
613
688
  }
614
689
 
690
+ // The refused run may have REGENERATED before it refused — every
691
+ // `@xemahq/api-client-generator` below 1.0.2 does — and a re-run over
692
+ // the regenerated bytes compares them with themselves, holds the
693
+ // version line, and leaves changed client bytes under a served
694
+ // version. Measured 2026-09-20 in xema-operator and xema-store-api. So
695
+ // what the refusal wrote is put back to the committed bytes first; a
696
+ // refused run has produced nothing worth keeping.
697
+ const written = restoreWhatTheRefusalWrote(dirtyBefore);
698
+ if (written.length > 0) {
699
+ console.log(
700
+ ` ${name}: the refused run had already written ${written.length} client file(s); ` +
701
+ 'restored to the committed bytes so the re-run sees the real change',
702
+ );
703
+ }
615
704
  // Not a retry loop: ONE re-run, driven by recorded data rather than by hope,
616
705
  // for a refusal whose every subject is already decided.
617
706
  console.log(
@@ -696,6 +785,14 @@ try {
696
785
  // imported for its exports; nothing to do
697
786
  } else process.exit(run());
698
787
  } catch (error) {
788
+ // `sh` PIPES stderr so a generator refusal can be matched against a recorded
789
+ // decision — which means a failing stage's own explanation never reached the
790
+ // terminal. Measured 2026-09-20: `verify:release-readiness` refused a
791
+ // first-party override with a full paragraph naming the package and the
792
+ // remedy, and this report showed only "Command failed: pnpm run
793
+ // verify:release-readiness". The captured text is re-emitted here, whole.
794
+ const captured = typeof error?.stderr === 'string' ? error.stderr.trim() : '';
795
+ if (captured.length > 0) console.error(`\n${captured}\n`);
699
796
  console.error(
700
797
  `\nreadiness: FAILED at the step above. That is the ROOT cause — the steps after it\n` +
701
798
  'were not reached, so do not read their absence as a second failure.\n' +
@@ -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 { affectedStagePlan, invokedDirectly, packagesOwningPaths } from './readiness.mjs';
7
+ import { affectedStagePlan, invokedDirectly, newlyDirtyPaths, packagesOwningPaths } from './readiness.mjs';
8
8
 
9
9
  /** A throwaway workspace: root + four members with different shapes. */
10
10
  async function fixture() {
@@ -218,28 +218,55 @@ test('an affected stage with NO branch point runs REPO-WIDE — it never skips',
218
218
  // The defect this pins: a scratch export or an archive has no origin/develop,
219
219
  // and the stage used to SKIP and let the run end "converged — safe to push"
220
220
  // with typecheck, lint and tests all unexecuted. Green over nothing.
221
+ // Members declare it: CI's `pnpm -r` form, even when the root ALSO has a
222
+ // script — the root's is a second authority (kernel-sdk's `eslint .`).
221
223
  assert.deepEqual(
222
- affectedStagePlan({ base: undefined, paths: [], packages: [], rootDeclares: true }),
224
+ affectedStagePlan({ base: undefined, paths: [], packages: [], rootDeclares: true, membersDeclare: true }),
225
+ { mode: 'workspace', viaRoot: false },
226
+ );
227
+ // Only the root declares it: the root script is the only place it lives.
228
+ assert.deepEqual(
229
+ affectedStagePlan({ base: undefined, paths: [], packages: [], rootDeclares: true, membersDeclare: false }),
223
230
  { mode: 'workspace', viaRoot: true },
224
231
  );
225
232
  assert.deepEqual(
226
- affectedStagePlan({ base: undefined, paths: [], packages: [], rootDeclares: false }),
233
+ affectedStagePlan({ base: undefined, paths: [], packages: [], rootDeclares: false, membersDeclare: false }),
227
234
  { mode: 'workspace', viaRoot: false },
228
235
  );
229
236
  // A base with owning packages is the ordinary affected run.
230
237
  assert.deepEqual(
231
- affectedStagePlan({ base: 'abc', paths: ['a/x.ts'], packages: ['a'], rootDeclares: true }),
238
+ affectedStagePlan({ base: 'abc', paths: ['a/x.ts'], packages: ['a'], rootDeclares: true, membersDeclare: true }),
232
239
  { mode: 'affected', packages: ['a'] },
233
240
  );
234
241
  // A base where only the ROOT declares the script runs the root, labelled.
235
242
  assert.deepEqual(
236
- affectedStagePlan({ base: 'abc', paths: ['README.md'], packages: [], rootDeclares: true }),
243
+ affectedStagePlan({ base: 'abc', paths: ['README.md'], packages: [], rootDeclares: true, membersDeclare: false }),
237
244
  { mode: 'root' },
238
245
  );
246
+ // A root-only path change where members own the stage: CI's form, not the root's.
247
+ assert.deepEqual(
248
+ affectedStagePlan({ base: 'abc', paths: ['README.md'], packages: [], rootDeclares: true, membersDeclare: true }),
249
+ { mode: 'workspace', viaRoot: false },
250
+ );
239
251
  // Nothing declares it: the only outcome that runs nothing, and it needs a
240
252
  // base to be trusted — without one it is the workspace run above.
241
253
  assert.deepEqual(
242
- affectedStagePlan({ base: 'abc', paths: ['README.md'], packages: [], rootDeclares: false }),
254
+ affectedStagePlan({ base: 'abc', paths: ['README.md'], packages: [], rootDeclares: false, membersDeclare: false }),
243
255
  { mode: 'inapplicable' },
244
256
  );
245
257
  });
258
+
259
+ test('only what the REFUSED run wrote is restored — in-flight work that was dirty before is untouched', () => {
260
+ const before = new Set([' M packages/clients/a/src/models/x.ts', '?? packages/clients/a/src/models/draft.ts']);
261
+ const after = new Set([
262
+ ' M packages/clients/a/src/models/x.ts', // was dirty before: somebody's work
263
+ '?? packages/clients/a/src/models/draft.ts', // was there before: somebody's work
264
+ ' M packages/clients/a/src/models/problem.ts', // the refusal wrote this
265
+ '?? packages/clients/b/src/endpoints/new.ts', // and created this
266
+ ]);
267
+ assert.deepEqual(newlyDirtyPaths(before, after), [
268
+ ' M packages/clients/a/src/models/problem.ts',
269
+ '?? packages/clients/b/src/endpoints/new.ts',
270
+ ]);
271
+ assert.deepEqual(newlyDirtyPaths(new Set(), new Set()), []);
272
+ });