@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 +1 -1
- package/src/readiness.mjs +92 -3
- package/src/readiness.test.mjs +16 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.8.
|
|
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 {
|
|
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
|
-
|
|
501
|
-
|
|
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' +
|
package/src/readiness.test.mjs
CHANGED
|
@@ -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
|
+
});
|