@xemahq/repo-build-tooling 0.6.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 +1 -1
- package/src/readiness.mjs +222 -47
- package/src/readiness.test.mjs +117 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "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
|
@@ -62,8 +62,9 @@
|
|
|
62
62
|
* honestly.
|
|
63
63
|
*/
|
|
64
64
|
import { execFileSync } from 'node:child_process';
|
|
65
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
65
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
66
66
|
import path from 'node:path';
|
|
67
|
+
import { fileURLToPath } from 'node:url';
|
|
67
68
|
import process from 'node:process';
|
|
68
69
|
|
|
69
70
|
// THE ROOT IS THE CONSUMER'S REPOSITORY, NEVER THIS PACKAGE'S LOCATION.
|
|
@@ -163,11 +164,26 @@ const STEPS = [
|
|
|
163
164
|
{
|
|
164
165
|
id: 'ledgers',
|
|
165
166
|
why: "each biome's integrity claim over its own bytes, specs and clients included",
|
|
166
|
-
// `--allow-dirty` is
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
167
|
+
// `--allow-dirty` is REQUIRED here: without it the step is a no-op in exactly
|
|
168
|
+
// the case it exists for, because the biomes it must re-hash are the ones the
|
|
169
|
+
// specs and clients just dirtied, so the deriver skips precisely those and
|
|
170
|
+
// exits 0 — a green command over a skipped corpus.
|
|
171
|
+
//
|
|
172
|
+
// WHAT IT DOES NOT MEAN, corrected 2026-09-19. This said "SAFE HERE AND ONLY
|
|
173
|
+
// HERE, because `run()` refuses to start on a dirty tree". `run()` does no
|
|
174
|
+
// such thing: it REPORTS pre-existing modified paths and continues, and its
|
|
175
|
+
// only refusal is for an install behind its own lockfile. The claim made this
|
|
176
|
+
// authority internally contradictory with its own `run()` forty lines down,
|
|
177
|
+
// and it was the load-bearing half — it is the reason the flag reads as safe,
|
|
178
|
+
// so a reader who checked it would have stopped there.
|
|
179
|
+
//
|
|
180
|
+
// The honest statement is narrower. A ledger hashes the WORKING TREE, so
|
|
181
|
+
// anything already modified when this starts is hashed in too. For the
|
|
182
|
+
// workflow this serves — regenerating alongside edits you are about to commit
|
|
183
|
+
// — that is correct, and refusing would break the one loop the command exists
|
|
184
|
+
// for. In a SHARED checkout holding somebody else's modifications it IS a
|
|
185
|
+
// waived check, which is why `run()` prints those paths and asks the reader
|
|
186
|
+
// whether all of them are theirs.
|
|
171
187
|
run: ['node', 'tooling/codegen/derive-biome-index.mjs', '--allow-dirty'],
|
|
172
188
|
needs: 'derive:biome-index',
|
|
173
189
|
gate: 'check:biome-index-ledger',
|
|
@@ -282,7 +298,7 @@ function changedPackages() {
|
|
|
282
298
|
.map((l) => l.trim())
|
|
283
299
|
.filter(Boolean);
|
|
284
300
|
|
|
285
|
-
return { base, paths
|
|
301
|
+
return { base, paths };
|
|
286
302
|
}
|
|
287
303
|
|
|
288
304
|
/**
|
|
@@ -292,13 +308,13 @@ function changedPackages() {
|
|
|
292
308
|
*
|
|
293
309
|
* Two rules, both deliberate:
|
|
294
310
|
* - the ROOT manifest never owns anything. It is not a workspace member, and
|
|
295
|
-
* its
|
|
296
|
-
*
|
|
297
|
-
* - a package
|
|
298
|
-
* rather than present-and-skipped. A skipped member
|
|
299
|
-
* and makes "0 ran" look like "all passed".
|
|
311
|
+
* its script is usually `turbo run <x>` — the whole fleet, which is the
|
|
312
|
+
* opposite of affected. The root is handled separately, as a FALLBACK.
|
|
313
|
+
* - a package not declaring THIS script owns nothing for this stage, so it is
|
|
314
|
+
* absent from the set rather than present-and-skipped. A skipped member
|
|
315
|
+
* inflates the corpus line and makes "0 ran" look like "all passed".
|
|
300
316
|
*/
|
|
301
|
-
export function packagesOwningPaths(root, relPaths) {
|
|
317
|
+
export function packagesOwningPaths(root, relPaths, script = 'test') {
|
|
302
318
|
// CONTAINMENT IS A PATH QUESTION, NEVER A STRING PREFIX. `dir.startsWith(root)`
|
|
303
319
|
// admits a SIBLING whose name merely begins with the same characters — for
|
|
304
320
|
// root `/x/repo`, the path `/x/repo-legacy/pkg` passes — and this fleet keeps
|
|
@@ -314,10 +330,14 @@ export function packagesOwningPaths(root, relPaths) {
|
|
|
314
330
|
let dir = path.dirname(path.resolve(root, rel));
|
|
315
331
|
while (contains(dir)) {
|
|
316
332
|
const manifest = path.join(dir, 'package.json');
|
|
317
|
-
|
|
333
|
+
// No `dir !== root` guard: `contains()` already excludes it, because
|
|
334
|
+
// `path.relative(root, root)` is the empty string. A mutation restoring
|
|
335
|
+
// that guard changed no behaviour, which is what identified it as inert
|
|
336
|
+
// rather than as an untested branch.
|
|
337
|
+
if (existsSync(manifest)) {
|
|
318
338
|
try {
|
|
319
339
|
const json = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
320
|
-
if (json.name && json.scripts?.
|
|
340
|
+
if (json.name && json.scripts?.[script]) owners.add(json.name);
|
|
321
341
|
} catch {
|
|
322
342
|
/* an unreadable manifest owns nothing */
|
|
323
343
|
}
|
|
@@ -337,35 +357,148 @@ export function packagesOwningPaths(root, relPaths) {
|
|
|
337
357
|
* CI owns that question. What this owns is "the thing I just edited still
|
|
338
358
|
* passes its own tests", which is the one that was missing.
|
|
339
359
|
*/
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
360
|
+
/**
|
|
361
|
+
* Run ONE affected stage — the changed packages' own `<script>`.
|
|
362
|
+
*
|
|
363
|
+
* THREE STAGES, NOT ONE, because they are ORTHOGONAL PROOFS and this command is
|
|
364
|
+
* the only place that owns the ordering:
|
|
365
|
+
*
|
|
366
|
+
* tests green != typecheck green (a suite can compile nothing)
|
|
367
|
+
* typecheck green != tests green (compiling proves no behaviour)
|
|
368
|
+
* either green != lint green
|
|
369
|
+
*
|
|
370
|
+
* They run AFTER the generators, not before, and that order is not a preference:
|
|
371
|
+
* a service typechecks against its GENERATED client, so typechecking before
|
|
372
|
+
* `clients` grades a stale artifact and reports the CONSUMER — the same fake
|
|
373
|
+
* cascade the header above describes for spec extraction.
|
|
374
|
+
*
|
|
375
|
+
* Dependents are deliberately excluded: that closure reaches 144 packages in the
|
|
376
|
+
* largest repository here, and CI owns it. What this owns is "the thing I just
|
|
377
|
+
* edited still passes its own proofs".
|
|
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
|
+
|
|
409
|
+
function runAffectedStage({ id, script, why }) {
|
|
410
|
+
console.log(`\n=== ${id} — ${why} ===`);
|
|
411
|
+
const { base, paths } = changedPackages();
|
|
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
|
+
);
|
|
349
461
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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']) {
|
|
357
494
|
console.log(
|
|
358
|
-
'
|
|
359
|
-
'
|
|
495
|
+
' SKIPPED — this repository declares no `verify:release-readiness`, so it\n' +
|
|
496
|
+
' publishes nothing from here and there is no tarball to grade.',
|
|
360
497
|
);
|
|
361
498
|
return;
|
|
362
499
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
// One at a time, so a failure NAMES the package; a single multi-filter
|
|
366
|
-
// invocation reports only the first.
|
|
367
|
-
sh(['pnpm', '--filter', name, 'test']);
|
|
368
|
-
}
|
|
500
|
+
console.log(' delegating to `pnpm verify:release-readiness` — the checker CI runs');
|
|
501
|
+
sh(['pnpm', 'run', 'verify:release-readiness']);
|
|
369
502
|
}
|
|
370
503
|
|
|
371
504
|
function run() {
|
|
@@ -416,7 +549,10 @@ function run() {
|
|
|
416
549
|
}
|
|
417
550
|
|
|
418
551
|
const only = process.argv.find((a) => a.startsWith('--only='))?.slice('--only='.length);
|
|
419
|
-
console.log(
|
|
552
|
+
console.log(
|
|
553
|
+
`readiness: ${path.basename(ROOT)} — ` +
|
|
554
|
+
'prisma -> build -> specs -> clients -> ledgers -> checks -> typecheck -> lint -> tests -> release-readiness\n',
|
|
555
|
+
);
|
|
420
556
|
|
|
421
557
|
for (const step of STEPS) {
|
|
422
558
|
if (only && only !== step.id) continue;
|
|
@@ -502,7 +638,15 @@ function run() {
|
|
|
502
638
|
if (scripts[gate]) sh(['pnpm', gate]);
|
|
503
639
|
}
|
|
504
640
|
|
|
505
|
-
|
|
641
|
+
for (const stage of [
|
|
642
|
+
{ id: 'typecheck', script: 'typecheck', why: 'the compiler, which a passing suite does not imply' },
|
|
643
|
+
{ id: 'lint', script: 'lint', why: 'the rules neither the compiler nor a suite enforces' },
|
|
644
|
+
{ id: 'tests', script: 'test', why: "the affected packages' own suites" },
|
|
645
|
+
]) {
|
|
646
|
+
runAffectedStage(stage);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
runReleaseReadiness();
|
|
506
650
|
|
|
507
651
|
console.log(
|
|
508
652
|
'\nreadiness: converged. Every derived artifact describes the source beside it,\n' +
|
|
@@ -511,11 +655,41 @@ function run() {
|
|
|
511
655
|
return 0;
|
|
512
656
|
}
|
|
513
657
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
658
|
+
/**
|
|
659
|
+
* Was this module RUN, or merely imported (by its own test)?
|
|
660
|
+
*
|
|
661
|
+
* BOTH SIDES ARE REALPATH'D, and that is the whole correctness of it. pnpm's
|
|
662
|
+
* isolated linker puts the package at `node_modules/.pnpm/<pkg>@<ver>/...` and
|
|
663
|
+
* symlinks `node_modules/@scope/<pkg>` to it, so `process.argv[1]` and
|
|
664
|
+
* `import.meta.url` can name the SAME FILE by two different paths. Comparing
|
|
665
|
+
* them unresolved gives an answer that depends on HOW THE PACKAGE WAS LINKED —
|
|
666
|
+
* true through pnpm's bin shim, which invokes the realpath, and false when the
|
|
667
|
+
* symlinked path is invoked. A predicate whose answer depends on the linker is
|
|
668
|
+
* not a predicate.
|
|
669
|
+
*
|
|
670
|
+
* The failure mode is the one this whole tool exists to prevent: a false answer
|
|
671
|
+
* takes the import branch, `run()` is never called, and the process exits 0 —
|
|
672
|
+
* a chain reporting success without running. Caught by a peer, not by me, and
|
|
673
|
+
* not by any test, which is why `invokedDirectly` is exported and asserted
|
|
674
|
+
* below against a REAL symlink.
|
|
675
|
+
*
|
|
676
|
+
* `fileURLToPath` rather than `new URL(...).pathname`: the latter is not the
|
|
677
|
+
* documented conversion and is wrong independently of the symlink — it breaks on
|
|
678
|
+
* a Windows drive letter and on any percent-encoded character, and a single
|
|
679
|
+
* SPACE in a checkout path is enough to produce one.
|
|
680
|
+
*/
|
|
681
|
+
export function invokedDirectly(argv1, metaUrl) {
|
|
682
|
+
if (argv1 === undefined) return false;
|
|
683
|
+
try {
|
|
684
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(metaUrl));
|
|
685
|
+
} catch {
|
|
686
|
+
// An unresolvable path cannot be this module; never guess YES, because a
|
|
687
|
+
// wrong YES runs the chain from a context that did not ask for it.
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const INVOKED_DIRECTLY = invokedDirectly(process.argv[1], import.meta.url);
|
|
519
693
|
|
|
520
694
|
try {
|
|
521
695
|
if (!INVOKED_DIRECTLY) {
|
|
@@ -527,7 +701,8 @@ try {
|
|
|
527
701
|
'were not reached, so do not read their absence as a second failure.\n' +
|
|
528
702
|
`\n ${error?.message?.split('\n')[0] ?? error}\n` +
|
|
529
703
|
'\nIf a CHECK failed rather than a generator, a dependency in\n' +
|
|
530
|
-
'prisma -> build -> specs -> clients -> ledgers was violated. Find it; do not
|
|
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',
|
|
531
706
|
);
|
|
532
707
|
process.exit(1);
|
|
533
708
|
}
|
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 { 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() {
|
|
@@ -20,6 +20,8 @@ async function fixture() {
|
|
|
20
20
|
await write('packages/outer/package.json', JSON.stringify({ name: '@t/outer', scripts: { test: 'jest' } }));
|
|
21
21
|
await write('packages/outer/nested/package.json', JSON.stringify({ name: '@t/nested', scripts: { test: 'jest' } }));
|
|
22
22
|
await write('packages/broken/package.json', '{ this is not json');
|
|
23
|
+
// Declares typecheck+lint but NOT test — the orthogonal-proof case.
|
|
24
|
+
await write('packages/compiled/package.json', JSON.stringify({ name: '@t/compiled', scripts: { typecheck: 'tsc --noEmit', lint: 'eslint .' } }));
|
|
23
25
|
return root;
|
|
24
26
|
}
|
|
25
27
|
|
|
@@ -127,3 +129,117 @@ test('a path escaping the root owns nothing, even reaching a REAL sibling manife
|
|
|
127
129
|
await fs.rm(sibling, { recursive: true, force: true });
|
|
128
130
|
}
|
|
129
131
|
});
|
|
132
|
+
|
|
133
|
+
test('the owning scan is keyed on the SCRIPT, so the three stages see different sets', async () => {
|
|
134
|
+
const root = await fixture();
|
|
135
|
+
try {
|
|
136
|
+
const changed = ['packages/compiled/src/a.ts', 'packages/tested/src/a.ts'];
|
|
137
|
+
// `tests green != typecheck green` is not a slogan here — the two stages
|
|
138
|
+
// genuinely grade different packages, and a single set would silently run
|
|
139
|
+
// one package's suite and call the other proven.
|
|
140
|
+
assert.deepEqual(packagesOwningPaths(root, changed, 'test'), ['@t/tested']);
|
|
141
|
+
assert.deepEqual(packagesOwningPaths(root, changed, 'typecheck'), ['@t/compiled']);
|
|
142
|
+
assert.deepEqual(packagesOwningPaths(root, changed, 'lint'), ['@t/compiled']);
|
|
143
|
+
// Default stays `test`, so the original call shape is unchanged.
|
|
144
|
+
assert.deepEqual(packagesOwningPaths(root, changed), ['@t/tested']);
|
|
145
|
+
} finally {
|
|
146
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('a script no package declares yields an EMPTY set, never the root', async () => {
|
|
151
|
+
const root = await fixture();
|
|
152
|
+
try {
|
|
153
|
+
// The root fixture declares `test`; asking for a script nothing declares must
|
|
154
|
+
// not fall back to it inside this function. The root FALLBACK is a decision
|
|
155
|
+
// made by the caller, which labels it repo-wide — folding it in here would
|
|
156
|
+
// report a repo-wide run as an affected one.
|
|
157
|
+
assert.deepEqual(packagesOwningPaths(root, ['packages/tested/src/a.ts'], 'nonexistent'), []);
|
|
158
|
+
} finally {
|
|
159
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The guard that decides whether this module RUNS. It had no test, and it was
|
|
165
|
+
* wrong: it compared `process.argv[1]` against `new URL(import.meta.url).pathname`
|
|
166
|
+
* UNRESOLVED, so under pnpm's isolated linker — which symlinks
|
|
167
|
+
* `node_modules/@scope/<pkg>` at `node_modules/.pnpm/<pkg>@<ver>/...` — the same
|
|
168
|
+
* file compared unequal whenever the symlinked path was the one invoked. The
|
|
169
|
+
* branch taken then does nothing and the process exits 0: a chain reporting
|
|
170
|
+
* success without running, which is the exact defect this tool exists to prevent.
|
|
171
|
+
*/
|
|
172
|
+
test('the run guard sees through a SYMLINK — pnpm links every package this way', async () => {
|
|
173
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'xema-guard-'));
|
|
174
|
+
try {
|
|
175
|
+
const real = path.join(root, 'real', 'readiness.mjs');
|
|
176
|
+
await fs.mkdir(path.dirname(real), { recursive: true });
|
|
177
|
+
await fs.writeFile(real, '// module\n', 'utf8');
|
|
178
|
+
const link = path.join(root, 'linked.mjs');
|
|
179
|
+
await fs.symlink(real, link);
|
|
180
|
+
|
|
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;
|
|
195
|
+
// THE CASE THAT FAILED: invoked by the symlink, module loaded from the
|
|
196
|
+
// realpath. Unresolved string comparison says false; the answer is true.
|
|
197
|
+
assert.equal(invokedDirectly(link, url), true);
|
|
198
|
+
// And the plain case still holds.
|
|
199
|
+
assert.equal(invokedDirectly(real, url), true);
|
|
200
|
+
|
|
201
|
+
// A DIFFERENT file must stay false, or the guard would run the chain from
|
|
202
|
+
// any import — the opposite failure, and the louder one.
|
|
203
|
+
const other = path.join(root, 'other.mjs');
|
|
204
|
+
await fs.writeFile(other, '// not it\n', 'utf8');
|
|
205
|
+
assert.equal(invokedDirectly(other, url), false);
|
|
206
|
+
|
|
207
|
+
// No argv[1] at all (`node --eval`) is an import, never a run.
|
|
208
|
+
assert.equal(invokedDirectly(undefined, url), false);
|
|
209
|
+
// An unresolvable path must answer NO rather than throw: a wrong YES runs
|
|
210
|
+
// the whole chain from a context that never asked for it.
|
|
211
|
+
assert.equal(invokedDirectly(path.join(root, 'gone.mjs'), url), false);
|
|
212
|
+
} finally {
|
|
213
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
214
|
+
}
|
|
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
|
+
});
|