@xemahq/repo-build-tooling 0.8.0 → 0.8.1

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.1",
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 {
@@ -497,8 +537,34 @@ function runReleaseReadiness() {
497
537
  );
498
538
  return;
499
539
  }
500
- console.log(' delegating to `pnpm verify:release-readiness` — the checker CI runs');
501
- sh(['pnpm', 'run', 'verify:release-readiness']);
540
+ // This runs BEFORE the commit, over the clients and ledgers the stages above
541
+ // just regenerated — so the checker's committed-tree refusal would report
542
+ // every package this run touched as NOT JUDGED, i.e. red exactly when
543
+ // readiness did its job. `--check --working-tree` (release-policy >= 12.2.0)
544
+ // grades the bytes the next commit would pack; an older checker is called
545
+ // without it and says so, rather than being passed a flag it refuses.
546
+ const flag = releasePolicySupportsWorkingTree() ? ['--working-tree'] : [];
547
+ console.log(
548
+ ` delegating to \`pnpm verify:release-readiness${flag.length ? ' --working-tree' : ''}\` — the checker CI runs` +
549
+ (flag.length
550
+ ? ''
551
+ : '\n (the installed @xemahq/release-policy predates --working-tree; packages this run\n' +
552
+ ' regenerated will read NOT JUDGED until they are committed — raise it to >=12.2.0)'),
553
+ );
554
+ sh(['pnpm', 'run', 'verify:release-readiness', ...flag]);
555
+ }
556
+
557
+ /** Does the checker this repository resolves accept `--check --working-tree`? Added in 12.2.0. */
558
+ function releasePolicySupportsWorkingTree() {
559
+ try {
560
+ const manifest = createRequire(path.join(ROOT, 'package.json')).resolve(
561
+ '@xemahq/release-policy/package.json',
562
+ );
563
+ const [major, minor] = JSON.parse(readFileSync(manifest, 'utf8')).version.split('.').map(Number);
564
+ return major > 12 || (major === 12 && minor >= 2);
565
+ } catch {
566
+ return false;
567
+ }
502
568
  }
503
569
 
504
570
  function run() {
@@ -575,6 +641,7 @@ function run() {
575
641
  for (const name of services) {
576
642
  // One at a time on purpose: a failure must name the service, and a single
577
643
  // `--filter a --filter b` invocation reports only the first.
644
+ const dirtyBefore = trackedDirtyPaths('packages/clients');
578
645
  try {
579
646
  sh(['pnpm', '--filter', name, step.perService]);
580
647
  } catch (error) {
@@ -612,6 +679,20 @@ function run() {
612
679
  throw error;
613
680
  }
614
681
 
682
+ // The refused run may have REGENERATED before it refused — every
683
+ // `@xemahq/api-client-generator` below 1.0.2 does — and a re-run over
684
+ // the regenerated bytes compares them with themselves, holds the
685
+ // version line, and leaves changed client bytes under a served
686
+ // version. Measured 2026-09-20 in xema-operator and xema-store-api. So
687
+ // what the refusal wrote is put back to the committed bytes first; a
688
+ // refused run has produced nothing worth keeping.
689
+ const written = restoreWhatTheRefusalWrote(dirtyBefore);
690
+ if (written.length > 0) {
691
+ console.log(
692
+ ` ${name}: the refused run had already written ${written.length} client file(s); ` +
693
+ 'restored to the committed bytes so the re-run sees the real change',
694
+ );
695
+ }
615
696
  // Not a retry loop: ONE re-run, driven by recorded data rather than by hope,
616
697
  // for a refusal whose every subject is already decided.
617
698
  console.log(
@@ -696,6 +777,14 @@ try {
696
777
  // imported for its exports; nothing to do
697
778
  } else process.exit(run());
698
779
  } catch (error) {
780
+ // `sh` PIPES stderr so a generator refusal can be matched against a recorded
781
+ // decision — which means a failing stage's own explanation never reached the
782
+ // terminal. Measured 2026-09-20: `verify:release-readiness` refused a
783
+ // first-party override with a full paragraph naming the package and the
784
+ // remedy, and this report showed only "Command failed: pnpm run
785
+ // verify:release-readiness". The captured text is re-emitted here, whole.
786
+ const captured = typeof error?.stderr === 'string' ? error.stderr.trim() : '';
787
+ if (captured.length > 0) console.error(`\n${captured}\n`);
699
788
  console.error(
700
789
  `\nreadiness: FAILED at the step above. That is the ROOT cause — the steps after it\n` +
701
790
  '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() {
@@ -243,3 +243,18 @@ test('an affected stage with NO branch point runs REPO-WIDE — it never skips',
243
243
  { mode: 'inapplicable' },
244
244
  );
245
245
  });
246
+
247
+ test('only what the REFUSED run wrote is restored — in-flight work that was dirty before is untouched', () => {
248
+ const before = new Set([' M packages/clients/a/src/models/x.ts', '?? packages/clients/a/src/models/draft.ts']);
249
+ const after = new Set([
250
+ ' M packages/clients/a/src/models/x.ts', // was dirty before: somebody's work
251
+ '?? packages/clients/a/src/models/draft.ts', // was there before: somebody's work
252
+ ' M packages/clients/a/src/models/problem.ts', // the refusal wrote this
253
+ '?? packages/clients/b/src/endpoints/new.ts', // and created this
254
+ ]);
255
+ assert.deepEqual(newlyDirtyPaths(before, after), [
256
+ ' M packages/clients/a/src/models/problem.ts',
257
+ '?? packages/clients/b/src/endpoints/new.ts',
258
+ ]);
259
+ assert.deepEqual(newlyDirtyPaths(new Set(), new Set()), []);
260
+ });