@xemahq/repo-build-tooling 0.7.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 +210 -36
- package/src/readiness.test.mjs +60 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "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 {
|
|
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 {
|
|
@@ -376,51 +416,156 @@ export function packagesOwningPaths(root, relPaths, script = 'test') {
|
|
|
376
416
|
* largest repository here, and CI owns it. What this owns is "the thing I just
|
|
377
417
|
* edited still passes its own proofs".
|
|
378
418
|
*/
|
|
419
|
+
/**
|
|
420
|
+
* The DECISION for one affected stage, pure over its inputs so it is testable.
|
|
421
|
+
*
|
|
422
|
+
* Four outcomes, and the first is the one this used to get wrong:
|
|
423
|
+
*
|
|
424
|
+
* - `workspace` — there is NO branch point (`origin/develop` and
|
|
425
|
+
* `origin/main` are both unfetched, as in a scratch export
|
|
426
|
+
* or an archive). This used to SKIP, print "fetch, or run
|
|
427
|
+
* this by hand", and let the run end on "converged — safe to
|
|
428
|
+
* push". Measured 2026-09-20 in a scratch export: typecheck,
|
|
429
|
+
* lint AND tests all skipped, exit 0, banner green — the
|
|
430
|
+
* "reports green while executing nothing" shape this fleet
|
|
431
|
+
* names as its most-repeated defect, in the one tool written
|
|
432
|
+
* to end it. Without a diff there is no affected set, so the
|
|
433
|
+
* honest scope is the WHOLE repository: the root script when
|
|
434
|
+
* the root declares one (a root `lint` or `typecheck` covers
|
|
435
|
+
* every member), else every member declaring it — which is
|
|
436
|
+
* exactly what CI runs.
|
|
437
|
+
* - `affected` — the changed paths are owned by members declaring it.
|
|
438
|
+
* - `root` — no changed member declares it but the root does: run it
|
|
439
|
+
* REPO-WIDE, labelled, because skipping would drop a proof.
|
|
440
|
+
* - `inapplicable`— nothing declares it. Genuinely nothing to run.
|
|
441
|
+
*/
|
|
442
|
+
export function affectedStagePlan({ base, paths, packages, rootDeclares }) {
|
|
443
|
+
if (!base) return { mode: 'workspace', viaRoot: rootDeclares };
|
|
444
|
+
if (packages.length > 0) return { mode: 'affected', packages };
|
|
445
|
+
if (paths.length > 0 && rootDeclares) return { mode: 'root' };
|
|
446
|
+
return { mode: 'inapplicable' };
|
|
447
|
+
}
|
|
448
|
+
|
|
379
449
|
function runAffectedStage({ id, script, why }) {
|
|
380
450
|
console.log(`\n=== ${id} — ${why} ===`);
|
|
381
451
|
const { base, paths } = changedPackages();
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
452
|
+
const packages = base ? packagesOwningPaths(ROOT, paths, script) : [];
|
|
453
|
+
const plan = affectedStagePlan({
|
|
454
|
+
base,
|
|
455
|
+
paths,
|
|
456
|
+
packages,
|
|
457
|
+
rootDeclares: Boolean(scripts[script]),
|
|
458
|
+
});
|
|
459
|
+
switch (plan.mode) {
|
|
460
|
+
case 'workspace':
|
|
461
|
+
console.log(
|
|
462
|
+
' no branch point — neither origin/develop nor origin/main is fetched here,\n' +
|
|
463
|
+
' so there is no affected set to derive. Running REPO-WIDE instead, which\n' +
|
|
464
|
+
' is what CI runs; this is reported as repo-wide, never as affected.',
|
|
465
|
+
);
|
|
466
|
+
if (plan.viaRoot) sh(['pnpm', 'run', script]);
|
|
467
|
+
else sh(['pnpm', '-r', '--if-present', 'run', script]);
|
|
468
|
+
return;
|
|
469
|
+
case 'affected':
|
|
470
|
+
// The CORPUS, printed with BOTH counts: "0 ran" and "0 failed" look
|
|
471
|
+
// identical otherwise, and a green command over an empty scan is not proof.
|
|
472
|
+
console.log(
|
|
473
|
+
` ${paths.length} changed path(s) since ${base.slice(0, 9)} -> ` +
|
|
474
|
+
`${plan.packages.length} package(s) declaring \`${script}\``,
|
|
475
|
+
);
|
|
476
|
+
for (const name of plan.packages) console.log(` - ${name}`);
|
|
477
|
+
for (const name of plan.packages) {
|
|
478
|
+
// One at a time, so a failure NAMES the package; a single multi-filter
|
|
479
|
+
// invocation reports only the first.
|
|
480
|
+
sh(['pnpm', '--filter', name, script]);
|
|
481
|
+
}
|
|
482
|
+
return;
|
|
483
|
+
case 'root':
|
|
484
|
+
// FALLBACK, and it is the difference between "inapplicable" and "not
|
|
485
|
+
// checked". Some repositories own a stage only at the root — `lint:
|
|
486
|
+
// eslint .` covers every package without any of them declaring `lint`.
|
|
487
|
+
// Skipping there would silently drop a real proof, so the root script
|
|
488
|
+
// runs, LABELLED as repo-wide rather than affected.
|
|
489
|
+
console.log(
|
|
490
|
+
` ${paths.length} changed path(s) since ${base.slice(0, 9)}; no changed` +
|
|
491
|
+
` package declares \`${script}\`, but the ROOT does — running it\n` +
|
|
492
|
+
' REPO-WIDE. This is applicable-but-not-affected, and is reported as such.',
|
|
493
|
+
);
|
|
494
|
+
sh(['pnpm', 'run', script]);
|
|
495
|
+
return;
|
|
496
|
+
default:
|
|
497
|
+
console.log(
|
|
498
|
+
` nothing to run — no changed package declares \`${script}\` and the root\n` +
|
|
499
|
+
' declares none either, so this stage is genuinely inapplicable here.',
|
|
500
|
+
);
|
|
404
501
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Publishability — DELEGATED to the checker that already owns the verdict.
|
|
507
|
+
*
|
|
508
|
+
* Every publishing repository declares `verify:release-readiness` as
|
|
509
|
+
* `xema-publish-packages --check`: it packs every public package and validates
|
|
510
|
+
* the tarball offline — identity, source binding, access, resolved ranges, entry
|
|
511
|
+
* points — and CI runs that exact command after build. It never contacts the
|
|
512
|
+
* registry and never uploads, so it is the same proof in the same built state,
|
|
513
|
+
* with one more caller.
|
|
514
|
+
*
|
|
515
|
+
* This is deliberately NOT a readiness-local predicate. A first version of this
|
|
516
|
+
* tool omitted publishability on the reasoning that deciding whether a change
|
|
517
|
+
* OWES a changeset needs packed bytes compared to the registry, which only the
|
|
518
|
+
* publisher can do — true, and a false dichotomy: the choice was never "duplicate
|
|
519
|
+
* the publisher" or "omit the proof". The owning checker was already exposed as
|
|
520
|
+
* a root script in all six repositories, so the correct stage is one call to it.
|
|
521
|
+
* One authority, two callers.
|
|
522
|
+
*
|
|
523
|
+
* It runs LAST, after the generators and the affected stages, because it packs
|
|
524
|
+
* what is on disk: a tarball packed before `build` grades stale `dist/`. And it
|
|
525
|
+
* SKIPS — printed, never silently — in a repository that declares no such script,
|
|
526
|
+
* because there the fact is genuinely inapplicable rather than unchecked.
|
|
527
|
+
*
|
|
528
|
+
* If this checker ever behaves badly for a local call, the fix is to that shared
|
|
529
|
+
* checker, where CI benefits too. Never a weaker copy here.
|
|
530
|
+
*/
|
|
531
|
+
function runReleaseReadiness() {
|
|
532
|
+
console.log('\n=== release-readiness — the tarballs this tree would publish ===');
|
|
533
|
+
if (!scripts['verify:release-readiness']) {
|
|
411
534
|
console.log(
|
|
412
|
-
|
|
413
|
-
'
|
|
535
|
+
' SKIPPED — this repository declares no `verify:release-readiness`, so it\n' +
|
|
536
|
+
' publishes nothing from here and there is no tarball to grade.',
|
|
414
537
|
);
|
|
415
|
-
sh(['pnpm', 'run', script]);
|
|
416
538
|
return;
|
|
417
539
|
}
|
|
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'] : [];
|
|
418
547
|
console.log(
|
|
419
|
-
`
|
|
420
|
-
|
|
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)'),
|
|
421
553
|
);
|
|
554
|
+
sh(['pnpm', 'run', 'verify:release-readiness', ...flag]);
|
|
422
555
|
}
|
|
423
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
|
+
}
|
|
568
|
+
}
|
|
424
569
|
|
|
425
570
|
function run() {
|
|
426
571
|
// ── WHAT `--allow-dirty` INCLUDES, STATED RATHER THAN ASSUMED ─────────────
|
|
@@ -470,7 +615,10 @@ function run() {
|
|
|
470
615
|
}
|
|
471
616
|
|
|
472
617
|
const only = process.argv.find((a) => a.startsWith('--only='))?.slice('--only='.length);
|
|
473
|
-
console.log(
|
|
618
|
+
console.log(
|
|
619
|
+
`readiness: ${path.basename(ROOT)} — ` +
|
|
620
|
+
'prisma -> build -> specs -> clients -> ledgers -> checks -> typecheck -> lint -> tests -> release-readiness\n',
|
|
621
|
+
);
|
|
474
622
|
|
|
475
623
|
for (const step of STEPS) {
|
|
476
624
|
if (only && only !== step.id) continue;
|
|
@@ -493,6 +641,7 @@ function run() {
|
|
|
493
641
|
for (const name of services) {
|
|
494
642
|
// One at a time on purpose: a failure must name the service, and a single
|
|
495
643
|
// `--filter a --filter b` invocation reports only the first.
|
|
644
|
+
const dirtyBefore = trackedDirtyPaths('packages/clients');
|
|
496
645
|
try {
|
|
497
646
|
sh(['pnpm', '--filter', name, step.perService]);
|
|
498
647
|
} catch (error) {
|
|
@@ -530,6 +679,20 @@ function run() {
|
|
|
530
679
|
throw error;
|
|
531
680
|
}
|
|
532
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
|
+
}
|
|
533
696
|
// Not a retry loop: ONE re-run, driven by recorded data rather than by hope,
|
|
534
697
|
// for a refusal whose every subject is already decided.
|
|
535
698
|
console.log(
|
|
@@ -564,6 +727,8 @@ function run() {
|
|
|
564
727
|
runAffectedStage(stage);
|
|
565
728
|
}
|
|
566
729
|
|
|
730
|
+
runReleaseReadiness();
|
|
731
|
+
|
|
567
732
|
console.log(
|
|
568
733
|
'\nreadiness: converged. Every derived artifact describes the source beside it,\n' +
|
|
569
734
|
'and the repository\'s own checks agree. Safe to push.',
|
|
@@ -612,12 +777,21 @@ try {
|
|
|
612
777
|
// imported for its exports; nothing to do
|
|
613
778
|
} else process.exit(run());
|
|
614
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`);
|
|
615
788
|
console.error(
|
|
616
789
|
`\nreadiness: FAILED at the step above. That is the ROOT cause — the steps after it\n` +
|
|
617
790
|
'were not reached, so do not read their absence as a second failure.\n' +
|
|
618
791
|
`\n ${error?.message?.split('\n')[0] ?? error}\n` +
|
|
619
792
|
'\nIf a CHECK failed rather than a generator, a dependency in\n' +
|
|
620
|
-
'prisma -> build -> specs -> clients -> ledgers was violated. Find it; do not
|
|
793
|
+
'prisma -> build -> specs -> clients -> ledgers was violated. Find it; do not\n' +
|
|
794
|
+
're-run. A stage AFTER `checks` failing is a real finding about your change.\n',
|
|
621
795
|
);
|
|
622
796
|
process.exit(1);
|
|
623
797
|
}
|
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 { 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() {
|
|
@@ -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
|
|
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,48 @@ 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
|
+
});
|
|
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
|
+
});
|