@heroiclands/package-build 20.0.0 → 20.2.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.
@@ -1370,6 +1370,52 @@ function normalizeCompatibility(value, where, requireMinimum = true) {
1370
1370
  * @param {unknown} value - The declared `systems:` mapping.
1371
1371
  * @returns {Readonly<Record<string, Readonly<object>>>} Frozen; `{}` when absent.
1372
1372
  */
1373
+ /**
1374
+ * The **package-wide** system, or `null` where the configuration names none
1375
+ * (#48).
1376
+ *
1377
+ * A *system* package is its own system, which is true by construction and needs
1378
+ * no declaration. A *module* takes the one it requires, or the one system it
1379
+ * declares when there is exactly one; with several and no gate there is no
1380
+ * package-wide answer, and each pack carries its own.
1381
+ *
1382
+ * A lone `relationships.systems` entry is a declaration of the system as much as
1383
+ * a gate, so it still answers. That matters because the relationship carries
1384
+ * `itemCatalog` too — a separate concern the `systems:` split does not replace —
1385
+ * so a repository using it would otherwise have to restate its compatibility
1386
+ * under `systems:` purely to keep stamping, which is the duplication that split
1387
+ * exists to remove. Several entries have no single answer and get none.
1388
+ *
1389
+ * **Written once and read twice**, which is why it is a function rather than the
1390
+ * expression it used to be: the value stamped into `stats.systemId` and the
1391
+ * value a pack's `system:` is validated against are the same fact, and two
1392
+ * spellings of it would be free to disagree about exactly the case that has no
1393
+ * answer.
1394
+ *
1395
+ * @param {object} parts - The resolved pieces of the configuration.
1396
+ * @param {string} parts.packageKind - `systems` or `modules`.
1397
+ * @param {unknown} parts.foundryPackage - The package id.
1398
+ * @param {string|null} parts.requiresSystem - The declared gate, if any.
1399
+ * @param {Readonly<Record<string, object>>} parts.systems - The `systems:` block.
1400
+ * @param {readonly {id?: string}[]} parts.relationshipSystems - System
1401
+ * relationships.
1402
+ * @returns {string|null} The system id, or `null` where there is no single one.
1403
+ */
1404
+ function packageWideSystemId({
1405
+ packageKind,
1406
+ foundryPackage,
1407
+ requiresSystem,
1408
+ systems,
1409
+ relationshipSystems,
1410
+ }) {
1411
+ if (packageKind === "systems") return /** @type {string} */ (foundryPackage);
1412
+ if (requiresSystem) return requiresSystem;
1413
+ const declared = Object.keys(systems);
1414
+ if (declared.length === 1) return declared[0];
1415
+ if (relationshipSystems.length === 1) return relationshipSystems[0]?.id ?? null;
1416
+ return null;
1417
+ }
1418
+
1373
1419
  function normalizeSystems(value) {
1374
1420
  if (value === undefined || value === null) return Object.freeze({});
1375
1421
  if (!isPlainObject(value)) fail("systems", "must be a mapping of id to spec");
@@ -1875,6 +1921,15 @@ export function defineConfig(config) {
1875
1921
  const relationshipSystems = /** @type {{id?: string}[]} */ (
1876
1922
  (isPlainObject(input.relationships) ? input.relationships.systems : null) ?? []
1877
1923
  );
1924
+ // Read here as well as stamped below, so the check that a pack's `system:`
1925
+ // resolves to something and the value it resolves to are one statement.
1926
+ const packageWide = packageWideSystemId({
1927
+ packageKind,
1928
+ foundryPackage: input.foundryPackage,
1929
+ requiresSystem,
1930
+ systems,
1931
+ relationshipSystems,
1932
+ });
1878
1933
 
1879
1934
  // A name that resolves to nothing is a build error rather than a
1880
1935
  // fall-through, in the spirit the rest of this file already follows: a pack
@@ -1891,11 +1946,35 @@ export function defineConfig(config) {
1891
1946
  }
1892
1947
  for (const pack of packs.flatMap((p) => [p, ...p.companions])) {
1893
1948
  if (!pack.system) continue;
1894
- if (declaredSystems.size && !declaredSystems.has(pack.system)) {
1949
+ // **A pack's `system:` must resolve to a stamp**, and there are exactly
1950
+ // two things it can resolve to: a `systems:` entry, which carries the
1951
+ // verified version `statsForPack` reads, or this package's own
1952
+ // package-wide system, whose stats answer for every pack of it.
1953
+ //
1954
+ // This used to be skipped entirely when `systems:` was empty or absent
1955
+ // — `declaredSystems.size &&` guarded it — which left the case the
1956
+ // comment above was written about wide open. `harn-ensemble` declares
1957
+ // `system: sohl` and `system: hm3` on its packs, no `systems:` block,
1958
+ // and no package-wide system, so every pack fell through to a
1959
+ // package-wide stat that is null: 2,513 compiled actors stamped
1960
+ // `_stats.systemId: null` in a pack that says `system: sohl` on the
1961
+ // line above. That is the plausible lie #43 was about, reached by the
1962
+ // one path this check did not cover, and the `requiresSystem` check ten
1963
+ // lines up already refuses its own version of it in as many words.
1964
+ if (!declaredSystems.has(pack.system) && pack.system !== packageWide) {
1895
1965
  fail(
1896
1966
  `packs.${pack.name}.system`,
1897
- `names \`${pack.system}\`, which \`systems:\` does not ` +
1898
- `declare. Declared: ${[...declaredSystems].join(", ")}`,
1967
+ `names \`${pack.system}\`, which \`systems:\` does not declare` +
1968
+ (declaredSystems.size ?
1969
+ ` (declared: ${[...declaredSystems].join(", ")})`
1970
+ : ` — the \`systems:\` block is empty or absent`) +
1971
+ (packageWide ?
1972
+ `, and which is not this package's own system \`${packageWide}\``
1973
+ : `, and this package has no package-wide system either`) +
1974
+ `. Every document in the pack is stamped \`_stats.systemId\` ` +
1975
+ `and \`systemVersion\` from one of those two, so with ` +
1976
+ `neither it would be stamped null. Add \`systems:\` naming ` +
1977
+ `\`${pack.system}\` with a \`compatibility.verified\` version`,
1899
1978
  );
1900
1979
  }
1901
1980
  // With a gate set, a pack for any other system could never be seen:
@@ -1979,21 +2058,13 @@ export function defineConfig(config) {
1979
2058
  // when there is exactly one; with several and no gate there is no
1980
2059
  // package-wide answer, and each pack carries its own.
1981
2060
  stats: normalizeStats(input.stats, {
1982
- systemId:
1983
- packageKind === "systems" ? foundryPackage
1984
- : requiresSystem ? requiresSystem
1985
- : Object.keys(systems).length === 1 ? Object.keys(systems)[0]
1986
- // A lone `relationships.systems` entry is a declaration of
1987
- // the system as much as a gate, so it still answers. That
1988
- // matters because the relationship carries `itemCatalog`
1989
- // too — a separate concern the split does not replace — so
1990
- // a repository using it would otherwise have to restate its
1991
- // compatibility under `systems:` purely to keep stamping,
1992
- // which is the duplication this whole change exists to
1993
- // remove. Several entries have no single answer and get
1994
- // none.
1995
- : relationshipSystems.length === 1 ? (relationshipSystems[0]?.id ?? null)
1996
- : null,
2061
+ systemId: packageWideSystemId({
2062
+ packageKind,
2063
+ foundryPackage,
2064
+ requiresSystem,
2065
+ systems,
2066
+ relationshipSystems,
2067
+ }),
1997
2068
  // Derived here where the answer is pure data — the `verified` of
1998
2069
  // whichever system the package-wide block takes — and supplied by
1999
2070
  // the loader otherwise. The loader is the half that may do I/O, and
package/e2e.mjs CHANGED
@@ -40,6 +40,7 @@
40
40
  */
41
41
 
42
42
  import crypto from "node:crypto";
43
+ import { readdirSync, statSync } from "node:fs";
43
44
  import fs from "node:fs/promises";
44
45
  import path from "node:path";
45
46
  import process from "node:process";
@@ -557,6 +558,253 @@ function captureContainerLog(container) {
557
558
  return `${result.stdout ?? ""}${result.stderr ?? ""}`;
558
559
  }
559
560
 
561
+ /**
562
+ * Programs a package runner is only a way of *naming*.
563
+ *
564
+ * `npx cypress run` does not fail when Cypress is gone. `npx` is a way of
565
+ * saying "find `cypress`", and what it does when it cannot find one is fetch
566
+ * some other copy from the registry — so the executable the harness checks has
567
+ * to be the tool, not the runner, or the only thing it ever verifies is the one
568
+ * program that is never missing.
569
+ *
570
+ * @type {readonly string[]}
571
+ */
572
+ const DIRECT_RUNNERS = Object.freeze(["npx", "pnpx", "bunx"]);
573
+
574
+ /**
575
+ * The same idea spelled as a subcommand: `pnpm dlx cypress`, `npm exec
576
+ * cypress`. The subcommand matters — `npm run e2e` names a *script*, not a
577
+ * program, and there is nothing there for the harness to resolve.
578
+ *
579
+ * @type {Readonly<Record<string, readonly string[]>>}
580
+ */
581
+ const SUBCOMMAND_RUNNERS = Object.freeze({
582
+ npm: ["exec"],
583
+ pnpm: ["dlx", "exec"],
584
+ yarn: ["dlx"],
585
+ bun: ["x"],
586
+ });
587
+
588
+ /**
589
+ * Runner flags that name the package themselves instead of taking it from the
590
+ * first bare word. Meeting one means the harness cannot read this command, and
591
+ * a guess would put the wrong name in the diagnostic — which is worse than
592
+ * checking the runner alone.
593
+ *
594
+ * @type {readonly string[]}
595
+ */
596
+ const OPAQUE_RUNNER_FLAGS = Object.freeze(["-p", "--package", "-c", "--call"]);
597
+
598
+ /**
599
+ * A program's lookup name: its basename, minus a Windows executable extension,
600
+ * lower-cased. `C:\\…\\npx.cmd` and `npx` are the same question.
601
+ *
602
+ * @param {string} program - The program as the command line spells it.
603
+ * @returns {string} The name to compare against the runner tables.
604
+ */
605
+ function executableName(program) {
606
+ return path
607
+ .basename(program)
608
+ .replace(/\.(cmd|exe|bat|ps1)$/i, "")
609
+ .toLowerCase();
610
+ }
611
+
612
+ /**
613
+ * Every executable that must exist for a suite command to run at all.
614
+ *
615
+ * One name for a plain command, two when a package runner is standing in for a
616
+ * tool. This is deliberately a *reading* of the command rather than a guess:
617
+ * anything it cannot read reduces to the program alone, because naming the
618
+ * wrong missing thing would send someone after a dependency they already have.
619
+ *
620
+ * @param {readonly string[]} command - The program and its arguments.
621
+ * @returns {string[]} The executables to resolve, in the order to report them.
622
+ */
623
+ export function suiteExecutables(command) {
624
+ const [program, ...rest] = command;
625
+ if (!program) return [];
626
+ const name = executableName(program);
627
+
628
+ let args = rest;
629
+ const subcommands = SUBCOMMAND_RUNNERS[name];
630
+ if (subcommands) {
631
+ if (args.length === 0 || !subcommands.includes(args[0])) return [program];
632
+ args = args.slice(1);
633
+ } else if (!DIRECT_RUNNERS.includes(name)) return [program];
634
+
635
+ for (let i = 0; i < args.length; i += 1) {
636
+ const arg = args[i];
637
+ if (arg === "--") return args[i + 1] ? [program, args[i + 1]] : [program];
638
+ if (OPAQUE_RUNNER_FLAGS.includes(arg)) return [program];
639
+ if (arg.startsWith("--package=") || arg.startsWith("--call=")) return [program];
640
+ if (arg.startsWith("-")) continue;
641
+ return [program, arg];
642
+ }
643
+ return [program];
644
+ }
645
+
646
+ /**
647
+ * @param {string} candidate - An absolute path.
648
+ * @returns {boolean} Whether it is there and is a file.
649
+ */
650
+ function isExecutableFile(candidate) {
651
+ return statSync(candidate, { throwIfNoEntry: false })?.isFile() ?? false;
652
+ }
653
+
654
+ /**
655
+ * Find an executable the way the child process will: a path is a path, and a
656
+ * bare name is looked for in the repository's `node_modules/.bin` first, then
657
+ * along `PATH`.
658
+ *
659
+ * @param {string} name - The program, as the command line spells it.
660
+ * @param {object} [opts]
661
+ * @param {string} [opts.cwd] - The repository root, for `node_modules/.bin`.
662
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read `PATH` from.
663
+ * @returns {string|null} Where it is, or `null` if it is nowhere.
664
+ */
665
+ export function findExecutable(name, { cwd, env = process.env } = {}) {
666
+ if (name.includes("/") || name.includes(path.sep)) {
667
+ const candidate = path.resolve(cwd ?? process.cwd(), name);
668
+ return isExecutableFile(candidate) ? candidate : null;
669
+ }
670
+ const directories = cwd ? [path.join(cwd, "node_modules", ".bin")] : [];
671
+ directories.push(...(env.PATH ?? "").split(path.delimiter).filter(Boolean));
672
+ const extensions =
673
+ process.platform === "win32" ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""];
674
+ for (const directory of directories) {
675
+ for (const extension of extensions) {
676
+ const candidate = path.join(directory, `${name}${extension}`);
677
+ if (isExecutableFile(candidate)) return candidate;
678
+ }
679
+ }
680
+ return null;
681
+ }
682
+
683
+ /**
684
+ * Which of a suite command's executables are not there.
685
+ *
686
+ * Asked twice per run, and the second asking is the point: an install running
687
+ * alongside the suite can take the runner out from under it mid-flight, which
688
+ * is precisely the failure that reported itself as green (#153).
689
+ *
690
+ * @param {object} opts
691
+ * @param {readonly string[]} opts.command - The program and its arguments.
692
+ * @param {string} [opts.cwd] - The repository root.
693
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
694
+ * @returns {string[]} The names that resolve to nothing.
695
+ */
696
+ export function missingExecutables({ command, cwd, env = process.env }) {
697
+ return suiteExecutables(command).filter((name) => findExecutable(name, { cwd, env }) === null);
698
+ }
699
+
700
+ /**
701
+ * Names, quoted, for a diagnostic.
702
+ *
703
+ * @param {readonly string[]} names - What to list.
704
+ * @returns {string} A comma-separated list, back-quoted.
705
+ */
706
+ function quotedList(names) {
707
+ return names.map((name) => `\`${name}\``).join(", ");
708
+ }
709
+
710
+ /**
711
+ * Filesystem timestamps are not all millisecond-precise, and a suite can write
712
+ * its first result in the same tick the harness spawned it. A second of slack
713
+ * costs nothing: a *stale* result is one from a previous run, minutes or days
714
+ * old, not one written a moment early.
715
+ */
716
+ const RESULT_MTIME_SLACK_MS = 1000;
717
+
718
+ /**
719
+ * Which declared result paths the suite actually wrote to during this run.
720
+ *
721
+ * Existence is not the test. A results directory left behind by the previous
722
+ * run exists, and reading that as evidence would make the check agree with
723
+ * exactly the thing it was built to catch. What counts is a file modified since
724
+ * the spawn.
725
+ *
726
+ * @param {object} opts
727
+ * @param {readonly string[]} opts.paths - Declared result paths, repo-relative.
728
+ * @param {number} opts.since - Milliseconds since the epoch, at spawn time.
729
+ * @param {string} [opts.cwd] - The repository root.
730
+ * @returns {string[]} The declared paths carrying something new.
731
+ */
732
+ export function freshResults({ paths, since, cwd }) {
733
+ const floor = since - RESULT_MTIME_SLACK_MS;
734
+
735
+ /**
736
+ * @param {string} candidate - An absolute path.
737
+ * @returns {boolean} Whether it, or anything under it, is newer than the floor.
738
+ */
739
+ const touched = (candidate) => {
740
+ const stats = statSync(candidate, { throwIfNoEntry: false });
741
+ if (!stats) return false;
742
+ if (stats.isFile()) return stats.mtimeMs >= floor;
743
+ if (!stats.isDirectory()) return false;
744
+ return readdirSync(candidate, { withFileTypes: true }).some((entry) =>
745
+ touched(path.join(candidate, entry.name)),
746
+ );
747
+ };
748
+
749
+ return paths.filter((declared) => touched(path.resolve(cwd ?? process.cwd(), declared)));
750
+ }
751
+
752
+ /**
753
+ * What the harness reports for a finished suite.
754
+ *
755
+ * @typedef {object} SuiteVerdict
756
+ * @property {number} status The exit status to hand back.
757
+ * @property {string|null} message What to say about it, if anything.
758
+ */
759
+
760
+ /**
761
+ * Decide what a finished suite is worth, given what it exited with and what it
762
+ * left behind.
763
+ *
764
+ * The point of the e2e suite is to be *evidence*: `compatibility.verified`
765
+ * moves on a green run, and a sweep exists to produce a citable result. So an
766
+ * exit status on its own cannot call a run green, because every way of stopping
767
+ * a runner before it starts — a corrupt install, a missing browser, a killed
768
+ * process, the concurrent `npm ci` that surfaced this — produces a run that
769
+ * executed nothing, and nothing is not a pass (#153).
770
+ *
771
+ * This can only ever make a verdict worse. A suite that failed keeps its own
772
+ * status; a suite that passed on no evidence loses the claim. Never the other
773
+ * way round — a harness that could *upgrade* a result would be a second way to
774
+ * report a green that did not happen.
775
+ *
776
+ * @param {object} opts
777
+ * @param {number} opts.status - What the suite process exited with.
778
+ * @param {readonly string[]} [opts.vanished] - Executables gone since it started.
779
+ * @param {readonly string[]} [opts.declared] - Result paths the repository declares.
780
+ * @param {readonly string[]} [opts.fresh] - Those of them it wrote to.
781
+ * @returns {SuiteVerdict} The status to report, and why.
782
+ */
783
+ export function suiteVerdict({ status, vanished = [], declared = [], fresh = [] }) {
784
+ if (vanished.length > 0) {
785
+ return {
786
+ status: status || 1,
787
+ message:
788
+ `✗ ${quotedList(vanished)} disappeared while the suite was ` +
789
+ `running, so it cannot have finished. Something reinstalled ` +
790
+ `\`node_modules\` underneath it — \`npm ci\` removes the tree ` +
791
+ `before it rebuilds it. Reporting the run as failed: whatever ` +
792
+ `status it exited with, it ran nothing to completion.`,
793
+ };
794
+ }
795
+ if (declared.length > 0 && fresh.length === 0) {
796
+ return {
797
+ status: status || 1,
798
+ message:
799
+ `✗ The suite exited ${status} but wrote nothing to ` +
800
+ `${quotedList(declared)} while it ran, so there is no evidence ` +
801
+ `it executed anything. Reporting the run as failed: a run that ` +
802
+ `produced no results is not a pass.`,
803
+ };
804
+ }
805
+ return { status, message: null };
806
+ }
807
+
560
808
  /**
561
809
  * Run the repository's suite.
562
810
  *
@@ -565,19 +813,51 @@ function captureContainerLog(container) {
565
813
  * runner launches as plain Node, rejects its own flags, and dies with a
566
814
  * `MODULE_NOT_FOUND` naming nothing relevant.
567
815
  *
816
+ * The suite is bracketed by checks rather than trusted on its exit status,
817
+ * because a run that never started used to report as green (#153):
818
+ *
819
+ * - **Before.** Every executable the command needs is resolved, and a missing
820
+ * one is an error naming it — rather than a container stood up, a world
821
+ * seeded, and a failure three minutes later that names nothing.
822
+ * - **After.** The same question again, because the reported failure was an
823
+ * install pulling the runner out from under a run already in progress; and,
824
+ * where the repository declares where its results land, whether anything was
825
+ * written there while the suite ran.
826
+ *
568
827
  * @param {object} opts
569
- * @param {string[]} opts.command - The program and its arguments.
828
+ * @param {readonly string[]} opts.command - The program and its arguments.
570
829
  * @param {string[]} [opts.args] - Extra arguments, appended verbatim.
571
830
  * @param {string} opts.cwd - The repository root.
831
+ * @param {readonly string[]} [opts.results] - Declared result paths to check.
572
832
  * @param {NodeJS.ProcessEnv} [opts.env] - Environment for the child.
573
833
  * @param {(message: string) => void} [opts.log] - Progress reporting.
574
834
  * @returns {number} The suite's exit status.
835
+ * @throws {Error} When the command names an executable that is not installed.
575
836
  */
576
- export function runSuite({ command, args = [], cwd, env = process.env, log = () => {} }) {
837
+ export function runSuite({
838
+ command,
839
+ args = [],
840
+ cwd,
841
+ results = [],
842
+ env = process.env,
843
+ log = () => {},
844
+ }) {
845
+ const missing = missingExecutables({ command, cwd, env });
846
+ if (missing.length > 0) {
847
+ throw new Error(
848
+ `The end-to-end suite cannot start: ${quotedList(missing)} ` +
849
+ `${missing.length === 1 ? "is" : "are"} not installed — ` +
850
+ `nothing of that name is in \`node_modules/.bin\` or on ` +
851
+ `\`PATH\`. The declared command is \`${command.join(" ")}\`. ` +
852
+ `Install the repository's dependencies and run again.`,
853
+ );
854
+ }
855
+
577
856
  const [program, ...rest] = command;
578
857
  const childEnv = { ...env };
579
858
  delete childEnv.ELECTRON_RUN_AS_NODE;
580
859
  log(`▸ ${[...command, ...args].join(" ")}`);
860
+ const startedAt = Date.now();
581
861
  const result = spawnSync(/** @type {string} */ (program), [...rest, ...args], {
582
862
  stdio: "inherit",
583
863
  cwd,
@@ -585,7 +865,15 @@ export function runSuite({ command, args = [], cwd, env = process.env, log = ()
585
865
  shell: process.platform === "win32",
586
866
  });
587
867
  if (result.error) throw result.error;
588
- return result.status ?? 1;
868
+
869
+ const verdict = suiteVerdict({
870
+ status: result.status ?? 1,
871
+ vanished: missingExecutables({ command, cwd, env }),
872
+ declared: results,
873
+ fresh: freshResults({ paths: results, since: startedAt, cwd }),
874
+ });
875
+ if (verdict.message) log(verdict.message);
876
+ return verdict.status;
589
877
  }
590
878
 
591
879
  /**
@@ -696,6 +984,11 @@ export async function e2eRun({
696
984
  command,
697
985
  args: suiteArgs,
698
986
  cwd: config.rootDir,
987
+ // Only a headless run makes a claim worth checking. `open` hands
988
+ // the runner to a person, who decides what to execute and when to
989
+ // close it; "it wrote no results" is a description of that session,
990
+ // not a fault in it.
991
+ results: mode === "run" ? config.e2eResults : [],
699
992
  env: runEnv,
700
993
  log,
701
994
  });
@@ -781,6 +1074,7 @@ export async function e2eFast({ config, argv = [], env = process.env, log = () =
781
1074
  command,
782
1075
  args: suiteArgs,
783
1076
  cwd: config.rootDir,
1077
+ results: config.e2eResults,
784
1078
  env,
785
1079
  log,
786
1080
  });