@heroiclands/package-build 20.0.0 → 20.2.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.
@@ -47,14 +47,38 @@
47
47
  * actually needed: every accessor in the engine funnels through here, so
48
48
  * anything that reads configuration throws with the message below.
49
49
  *
50
- * **Located by walking up from this module, not from the working directory.**
51
- * The config file sits at the root of the repository that installed the
52
- * toolchain, so climbing out of
53
- * `node_modules/@heroiclands/package-build/engine/` lands on it either way.
54
- * Resolving it against `process.cwd()` instead would make the build read a
55
- * different tree depending on where it was launched from, which is the very
56
- * property #1508 removed. `PACKAGE_BUILD_CONFIG` names the file explicitly when
57
- * a consumer keeps it somewhere else.
50
+ * **Located by walking up from the working directory, and from this module only
51
+ * when that finds nothing.** The config file sits at the root of the repository
52
+ * being built, and a build is run inside that repository — so the walk from
53
+ * `process.cwd()` finds it from the root, from `packages/`, from anywhere
54
+ * below. Climbing from this module instead finds the same file too, right up
55
+ * until the installed package is not the one the caller is standing in: a git
56
+ * worktree nested under its parent checkout with no `node_modules` of its own
57
+ * resolves `@heroiclands/package-build` out of the *parent's*, because Node's
58
+ * resolution walks parent directories. `import.meta.dirname` is then inside the
59
+ * parent, the walk lands on the parent's configuration, and the build compiles
60
+ * the parent's content tree into the parent's `build/` and exits 0 — saying so
61
+ * only in paths that are easy to read past (#364).
62
+ *
63
+ * That failure is undetectable on exactly the work most likely to provoke it.
64
+ * The usual tell is a zero diff where a change was expected; an
65
+ * output-preserving sweep *expects* zero differences, so the tell is gone and a
66
+ * wrong-tree build produces confident evidence for a tree nobody touched. Order
67
+ * of resolution is the fix, because reviewer vigilance cannot be.
68
+ *
69
+ * The module walk stays, as the fallback for an invocation from outside any
70
+ * repository, and is never *preferred*: a configuration found above the
71
+ * installed package rather than above the working directory is not the one a
72
+ * caller meant. When both walks find one and they disagree, the ignored one is
73
+ * named in a warning rather than passed over — that disagreement is also the
74
+ * only cheap signal that this tree is building on another checkout's
75
+ * `node_modules`. `PACKAGE_BUILD_CONFIG` names the file explicitly and skips
76
+ * both walks, which is why it was the workaround.
77
+ *
78
+ * What #1508 removed stays removed. The property it bought was not "resolve
79
+ * from the module"; it was that a build reads one tree however it was launched,
80
+ * and an upward walk from the working directory keeps that — every directory
81
+ * inside a repository resolves that repository's single configuration.
58
82
  *
59
83
  * **Loaded synchronously.** A YAML config is parsed synchronously as a matter
60
84
  * of course; an `.mjs` one is loaded with `require` rather than `await import`,
@@ -81,7 +105,12 @@ import { createRequire } from "node:module";
81
105
  import YAML from "yaml";
82
106
 
83
107
  import { defineConfig, DERIVED_SYSTEM_VERSION } from "../content-config.mjs";
84
- import { formatDiagnostic, positionOfYamlPath, yamlKeyPath } from "./diagnostics.mjs";
108
+ import {
109
+ emitDiagnostic,
110
+ formatDiagnostic,
111
+ positionOfYamlPath,
112
+ yamlKeyPath,
113
+ } from "./diagnostics.mjs";
85
114
 
86
115
  /** The stem every consuming repository declares its build under. */
87
116
  export const CONFIG_BASENAME = "package-build.config";
@@ -144,6 +173,41 @@ export function findConfigFile(from) {
144
173
  }
145
174
  }
146
175
 
176
+ /**
177
+ * Which configuration file a build launched here should read, and what each
178
+ * walk found.
179
+ *
180
+ * Kept separate from {@link loadPackConfig} because the *choice* is worth being
181
+ * able to ask about without loading anything: the two walks disagreeing is the
182
+ * observable form of #364, and a caller that wants to report it — or a test
183
+ * that wants to describe it — should not have to reproduce the resolution and
184
+ * risk disagreeing with the loader about it. It performs I/O, and is named for
185
+ * it, like the {@link findConfigFile} it calls twice.
186
+ *
187
+ * `PACKAGE_BUILD_CONFIG` is deliberately not consulted here. An explicit name
188
+ * is not a search result: {@link loadPackConfig} short-circuits on it before it
189
+ * ever asks, so there is no walk to report and nothing to disagree with.
190
+ *
191
+ * @param {object} [from] - Where to walk up from; both default to the real
192
+ * thing, and are parameters only so a caller can describe a tree it is not
193
+ * standing in.
194
+ * @param {string} [from.cwd] - The directory the build was launched in.
195
+ * @param {string} [from.moduleDir] - The directory this module sits in.
196
+ * @returns {{path: string|undefined, fromCwd: string|undefined, fromModule: string|undefined}}
197
+ * The file to read, and each walk's own answer — the same file in an ordinary
198
+ * build, different ones in a worktree resolving the toolchain out of its
199
+ * parent checkout.
200
+ * @throws {Error} As {@link findConfigFile}, when one directory holds more than
201
+ * one configuration.
202
+ */
203
+ export function resolveConfigFile({ cwd = process.cwd(), moduleDir = import.meta.dirname } = {}) {
204
+ const fromCwd = findConfigFile(cwd);
205
+ const fromModule = findConfigFile(moduleDir);
206
+ // `??`, not `||`: the module walk is a fallback for finding *nothing*, never
207
+ // a tie-break between two answers.
208
+ return { path: fromCwd ?? fromModule, fromCwd, fromModule };
209
+ }
210
+
147
211
  const require = createRequire(import.meta.url);
148
212
 
149
213
  /**
@@ -607,20 +671,46 @@ export function loadPackConfig() {
607
671
  if (loaded) return loaded;
608
672
 
609
673
  const explicit = process.env.PACKAGE_BUILD_CONFIG;
610
- const configPath = explicit ? path.resolve(explicit) : findConfigFile(import.meta.dirname);
674
+ const found = explicit ? undefined : resolveConfigFile();
675
+ const configPath = explicit ? path.resolve(explicit) : found.path;
611
676
 
612
677
  if (!configPath || !fs.existsSync(configPath)) {
613
678
  throw new Error(
614
679
  explicit ?
615
680
  `package-build: PACKAGE_BUILD_CONFIG names ${configPath}, ` +
616
681
  `which does not exist.`
682
+ // Both origins, because either walk could have found one and
683
+ // naming only the module's would send a reader looking inside
684
+ // `node_modules/` for a file that belongs in their own root.
617
685
  : `package-build: no ${CONFIG_FILENAMES.join(" or ")} found at ` +
618
- `or above ${import.meta.dirname}. A consuming repository ` +
619
- `declares its build in one file at its root; set ` +
686
+ `or above ${process.cwd()}, nor at or above ` +
687
+ `${import.meta.dirname}. A consuming repository declares ` +
688
+ `its build in one file at its root; set ` +
620
689
  `PACKAGE_BUILD_CONFIG to name it elsewhere.`,
621
690
  );
622
691
  }
623
692
 
693
+ // Two different files, one of which is about to be ignored. Said out loud
694
+ // because the alternative is what #364 was: a build that reads the parent
695
+ // checkout's configuration, compiles the parent's tree, and reports it only
696
+ // in absolute paths nobody rereads. A warning rather than an error — the
697
+ // shape is legitimate, and the working directory's answer is the right one
698
+ // — but never silence.
699
+ if (found?.fromCwd && found.fromModule && found.fromCwd !== found.fromModule) {
700
+ emitDiagnostic({
701
+ severity: "warning",
702
+ message:
703
+ `package-build: reading ${found.fromCwd}, the configuration ` +
704
+ `above this working directory. The installed ` +
705
+ `@heroiclands/package-build sits under a different ` +
706
+ `repository, whose own ${found.fromModule} is being ignored — ` +
707
+ `usually because this tree has no \`node_modules\` of its own ` +
708
+ `and resolved the toolchain out of a parent checkout. Run ` +
709
+ `\`npm ci\` here, or set PACKAGE_BUILD_CONFIG, to say which ` +
710
+ `tree is meant.`,
711
+ });
712
+ }
713
+
624
714
  loaded =
625
715
  configPath.endsWith(".mjs") ?
626
716
  loadCodeConfig(configPath)
Binary file
@@ -134,6 +134,48 @@ export function sharedPrettierOptionsFor(file) {
134
134
  return /\.md$/i.test(file) ? { ...PRETTIER_BASE, ...PRETTIER_MARKDOWN } : { ...PRETTIER_BASE };
135
135
  }
136
136
 
137
+ /**
138
+ * Where a resolved Prettier configuration disagrees with the shared one.
139
+ *
140
+ * The runner resolves each file's options as *either* the consumer's own config
141
+ * or {@link sharedPrettierOptionsFor}, never a merge. That is what bare Prettier
142
+ * does and it is the contract — but it means the conventions this package exists
143
+ * to publish hold by convention alone, and they lapse in two opposite directions
144
+ * (#133). A consumer that declares any config of its own gets whatever that
145
+ * config says: spread {@link PRETTIER_BASE} without the markdown override and
146
+ * every note reindents at 4, the reindentation the override was added to prevent
147
+ * (#76); write a partial `.prettierrc` such as `{"tabWidth": 2}` and
148
+ * `printWidth`, `trailingComma`, `experimentalTernaries` and the rest fall back
149
+ * to Prettier's own defaults. A consumer that declares *nothing* formats one way
150
+ * under this command and another under a bare `npx prettier`.
151
+ *
152
+ * This is the comparison that makes either absence visible. It is a **report,
153
+ * not a merge**: what a consumer declared still wins, and a deliberate local
154
+ * choice stays possible — it stops being silent, and nothing here fails a build
155
+ * over it.
156
+ *
157
+ * Every shared value is a primitive, so `!==` is the whole comparison. An option
158
+ * holding an object would need a deeper one, and the shared set has none —
159
+ * `overrides` is not compared, because `resolveConfig` has already applied and
160
+ * removed it by the time a configuration reaches this.
161
+ *
162
+ * @param {object|null|undefined} resolved - What `prettier.resolveConfig`
163
+ * returned for `file`, with the consumer's own `overrides` already applied.
164
+ * `null` — no configuration at all — reports every shared key as absent.
165
+ * @param {string} file - Path the options were resolved for. Decides whether
166
+ * {@link PRETTIER_MARKDOWN} is part of what is expected.
167
+ * @returns {Array<{key: string, shared: unknown, local: unknown}>} One entry per
168
+ * shared key the resolved configuration does not carry the value of, in the
169
+ * order {@link PRETTIER_BASE} declares them. `local` is `undefined` where the
170
+ * key is absent entirely, which is not the same finding as a key set to
171
+ * something else and is reported differently.
172
+ */
173
+ export function sharedPrettierDivergence(resolved, file) {
174
+ return Object.entries(sharedPrettierOptionsFor(file))
175
+ .filter(([key, shared]) => resolved?.[key] !== shared)
176
+ .map(([key, shared]) => ({ key, shared, local: resolved?.[key] }));
177
+ }
178
+
137
179
  /**
138
180
  * The markdownlint rules — the structural checks Prettier cannot make.
139
181
  *
@@ -38,6 +38,7 @@ import {
38
38
  MARKDOWNLINT_CONFIG,
39
39
  MARKDOWN_GLOBS,
40
40
  MARKDOWN_IGNORES,
41
+ sharedPrettierDivergence,
41
42
  sharedPrettierOptionsFor,
42
43
  } from "./prose-config.mjs";
43
44
 
@@ -78,6 +79,27 @@ const IGNORE_FILES = Object.freeze([".gitignore", ".prettierignore"]);
78
79
  */
79
80
  const MAX_FORMAT_PASSES = 3;
80
81
 
82
+ /**
83
+ * The two paths whose resolved configuration stands for the repository's.
84
+ *
85
+ * Prettier resolves a configuration *per file*, so asking what a repository is
86
+ * configured to do means asking about a file. These are the two answers that
87
+ * differ: markdown carries the shared `tabWidth` override and everything else
88
+ * does not, so a single probe would check half the conventions and miss the one
89
+ * most worth checking (#133).
90
+ *
91
+ * Ordinary names at the repository root, and neither has to exist —
92
+ * `resolveConfig` reads the path to walk up from it and to match `overrides`
93
+ * against it, never the file. That is also the limit of what this can say: it
94
+ * reports the configuration a file *at the root* resolves to, so an override a
95
+ * consumer scoped to some subtree of its own is outside the question being
96
+ * asked, and rightly so.
97
+ */
98
+ const CONVENTION_PROBES = Object.freeze({ code: "index.mjs", markdown: "README.md" });
99
+
100
+ /** The one line a consumer writes to adopt the shared configuration verbatim. */
101
+ const SHARED_CONFIG_RE_EXPORT = 'export { default } from "@heroiclands/package-build/prettier";';
102
+
81
103
  /**
82
104
  * Every file under a root, minus the directories nothing should walk.
83
105
  *
@@ -245,6 +267,110 @@ export async function checkFormatting(root, opts = {}) {
245
267
  return { findings, checked, written };
246
268
  }
247
269
 
270
+ /**
271
+ * One divergence as the sentence a diagnostic carries.
272
+ *
273
+ * The two cases read differently on purpose. A key set to something else is a
274
+ * choice someone made and can defend; a key that is simply absent is the
275
+ * silent half of #133 — the consumer did not choose Prettier's default, it
276
+ * arrived because declaring one option discards every option not restated.
277
+ *
278
+ * @param {{key: string, shared: unknown, local: unknown}} divergence - From
279
+ * {@link sharedPrettierDivergence}.
280
+ * @param {string} [scope=""] - Which files this is about, when it is not all of
281
+ * them. Prefixed to the key, so the line reads `markdown \`tabWidth\` …`.
282
+ * @returns {string} The message.
283
+ */
284
+ function divergenceMessage({ key, shared, local }, scope = "") {
285
+ const here =
286
+ local === undefined ?
287
+ "is not set here, so Prettier's own default applies"
288
+ : `is ${JSON.stringify(local)} here`;
289
+ return `${scope}\`${key}\` ${here}; the shared configuration says ${JSON.stringify(shared)}`;
290
+ }
291
+
292
+ /**
293
+ * Report where a repository's own Prettier configuration parts from the shared
294
+ * one — or that it has none at all (#133).
295
+ *
296
+ * **Warnings, every one of them.** A consumer's config wins by design and this
297
+ * does not change that; it only refuses to let the divergence be silent, which
298
+ * is the whole of what the issue asks for. Turning any of this into an error
299
+ * would make the shared conventions mandatory, and they are a default.
300
+ *
301
+ * The no-configuration case is the sharper one and is reported even though the
302
+ * command itself behaves correctly there: with no config file the shared
303
+ * conventions reach `content-build format` and reach *nothing else*, so an
304
+ * editor's format-on-save and a bare `npx prettier --check .` apply Prettier's
305
+ * own defaults to the same tree, and the two take turns rewriting the same
306
+ * lines. That is not hypothetical — it is what the config files in
307
+ * `sohl-thalorna` and `sohl-kethira-basic` were added to stop.
308
+ *
309
+ * @param {string} root - Repository to ask about.
310
+ * @param {object} [opts]
311
+ * @param {object} [opts.prettier] - The Prettier module, for tests.
312
+ * @returns {Promise<{findings: Array<{file?: string, severity: string,
313
+ * message: string}>, configFile: string|null}>} The findings and the config
314
+ * file they are about, which is `null` when the repository declares none. A
315
+ * finding about a missing file carries no `file`: #17's rule is to drop a
316
+ * field rather than invent one.
317
+ */
318
+ export async function checkPrettierConventions(root, opts = {}) {
319
+ const prettier = opts.prettier ?? (await import("prettier"));
320
+ const base = path.resolve(root);
321
+ const probe = (name) => path.join(base, name);
322
+
323
+ const configFile = await prettier.resolveConfigFile(probe(CONVENTION_PROBES.code));
324
+ if (!configFile) {
325
+ return {
326
+ findings: [
327
+ {
328
+ severity: "warning",
329
+ message:
330
+ "this repository declares no Prettier configuration, so `content-build " +
331
+ "format` applies the shared conventions while an editor and a bare `npx " +
332
+ "prettier` apply Prettier's own to the same tree; declare them in a " +
333
+ `prettier.config.mjs — ${SHARED_CONFIG_RE_EXPORT}`,
334
+ },
335
+ ],
336
+ configFile: null,
337
+ };
338
+ }
339
+
340
+ /** @param {string} name - One of {@link CONVENTION_PROBES}. */
341
+ const divergenceFor = async (name) =>
342
+ sharedPrettierDivergence(
343
+ await prettier.resolveConfig(probe(name), { editorconfig: false }),
344
+ probe(name),
345
+ );
346
+
347
+ const code = await divergenceFor(CONVENTION_PROBES.code);
348
+ const markdown = await divergenceFor(CONVENTION_PROBES.markdown);
349
+
350
+ const findings = code.map((divergence) => ({
351
+ file: configFile,
352
+ severity: "warning",
353
+ message: divergenceMessage(divergence),
354
+ }));
355
+ for (const divergence of markdown) {
356
+ // A key that resolves the same way everywhere is one finding, not two.
357
+ // Only what markdown alone gets wrong is worth a line of its own — and
358
+ // it is the line that matters most, `tabWidth` being the value a note
359
+ // moving between repositories reindents on.
360
+ const everywhere = code.some(
361
+ (other) => other.key === divergence.key && Object.is(other.local, divergence.local),
362
+ );
363
+ if (everywhere) continue;
364
+ findings.push({
365
+ file: configFile,
366
+ severity: "warning",
367
+ message: divergenceMessage(divergence, "markdown "),
368
+ });
369
+ }
370
+
371
+ return { findings, configFile };
372
+ }
373
+
248
374
  /**
249
375
  * One markdownlint result as a diagnostic.
250
376
  *
@@ -1,3 +1,16 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
1
14
  /**
2
15
  * Read a package's DataModel field sets out of its source, as data.
3
16
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "20.0.0",
3
+ "version": "20.2.1",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -55,6 +55,8 @@
55
55
  * @property {string} e2eStage Which stage the suite runs against.
56
56
  * @property {Readonly<{run: readonly string[], open: readonly string[]|null}>|null} e2eSuite
57
57
  * What to run against the served world; `null` when the repository has none.
58
+ * @property {readonly string[]} e2eResults Where the suite writes its results,
59
+ * so a run that wrote none is not mistaken for one that passed.
58
60
  * @property {Readonly<Record<string, Readonly<{script: string, recreate: boolean}>>>} e2eBuild
59
61
  * Build targets the fast loop can produce, in declaration order.
60
62
  * @property {Readonly<Record<string, string>>} e2eWorld Declared world identity.
@@ -260,6 +262,11 @@ export type PackageBuildConfig = {
260
262
  run: readonly string[];
261
263
  open: readonly string[] | null;
262
264
  }> | null;
265
+ /**
266
+ * Where the suite writes its results,
267
+ * so a run that wrote none is not mistaken for one that passed.
268
+ */
269
+ e2eResults: readonly string[];
263
270
  /**
264
271
  * Build targets the fast loop can produce, in declaration order.
265
272
  */
package/types/e2e.d.mts CHANGED
@@ -188,6 +188,106 @@ export function waitForWorld({ url, container, stage, timeoutMs, log }: {
188
188
  timeoutMs?: number | undefined;
189
189
  log?: ((message: string) => void) | undefined;
190
190
  }): Promise<void>;
191
+ /**
192
+ * Every executable that must exist for a suite command to run at all.
193
+ *
194
+ * One name for a plain command, two when a package runner is standing in for a
195
+ * tool. This is deliberately a *reading* of the command rather than a guess:
196
+ * anything it cannot read reduces to the program alone, because naming the
197
+ * wrong missing thing would send someone after a dependency they already have.
198
+ *
199
+ * @param {readonly string[]} command - The program and its arguments.
200
+ * @returns {string[]} The executables to resolve, in the order to report them.
201
+ */
202
+ export function suiteExecutables(command: readonly string[]): string[];
203
+ /**
204
+ * Find an executable the way the child process will: a path is a path, and a
205
+ * bare name is looked for in the repository's `node_modules/.bin` first, then
206
+ * along `PATH`.
207
+ *
208
+ * @param {string} name - The program, as the command line spells it.
209
+ * @param {object} [opts]
210
+ * @param {string} [opts.cwd] - The repository root, for `node_modules/.bin`.
211
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read `PATH` from.
212
+ * @returns {string|null} Where it is, or `null` if it is nowhere.
213
+ */
214
+ export function findExecutable(name: string, { cwd, env }?: {
215
+ cwd?: string | undefined;
216
+ env?: NodeJS.ProcessEnv | undefined;
217
+ }): string | null;
218
+ /**
219
+ * Which of a suite command's executables are not there.
220
+ *
221
+ * Asked twice per run, and the second asking is the point: an install running
222
+ * alongside the suite can take the runner out from under it mid-flight, which
223
+ * is precisely the failure that reported itself as green (#153).
224
+ *
225
+ * @param {object} opts
226
+ * @param {readonly string[]} opts.command - The program and its arguments.
227
+ * @param {string} [opts.cwd] - The repository root.
228
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
229
+ * @returns {string[]} The names that resolve to nothing.
230
+ */
231
+ export function missingExecutables({ command, cwd, env }: {
232
+ command: readonly string[];
233
+ cwd?: string | undefined;
234
+ env?: NodeJS.ProcessEnv | undefined;
235
+ }): string[];
236
+ /**
237
+ * Which declared result paths the suite actually wrote to during this run.
238
+ *
239
+ * Existence is not the test. A results directory left behind by the previous
240
+ * run exists, and reading that as evidence would make the check agree with
241
+ * exactly the thing it was built to catch. What counts is a file modified since
242
+ * the spawn.
243
+ *
244
+ * @param {object} opts
245
+ * @param {readonly string[]} opts.paths - Declared result paths, repo-relative.
246
+ * @param {number} opts.since - Milliseconds since the epoch, at spawn time.
247
+ * @param {string} [opts.cwd] - The repository root.
248
+ * @returns {string[]} The declared paths carrying something new.
249
+ */
250
+ export function freshResults({ paths, since, cwd }: {
251
+ paths: readonly string[];
252
+ since: number;
253
+ cwd?: string | undefined;
254
+ }): string[];
255
+ /**
256
+ * What the harness reports for a finished suite.
257
+ *
258
+ * @typedef {object} SuiteVerdict
259
+ * @property {number} status The exit status to hand back.
260
+ * @property {string|null} message What to say about it, if anything.
261
+ */
262
+ /**
263
+ * Decide what a finished suite is worth, given what it exited with and what it
264
+ * left behind.
265
+ *
266
+ * The point of the e2e suite is to be *evidence*: `compatibility.verified`
267
+ * moves on a green run, and a sweep exists to produce a citable result. So an
268
+ * exit status on its own cannot call a run green, because every way of stopping
269
+ * a runner before it starts — a corrupt install, a missing browser, a killed
270
+ * process, the concurrent `npm ci` that surfaced this — produces a run that
271
+ * executed nothing, and nothing is not a pass (#153).
272
+ *
273
+ * This can only ever make a verdict worse. A suite that failed keeps its own
274
+ * status; a suite that passed on no evidence loses the claim. Never the other
275
+ * way round — a harness that could *upgrade* a result would be a second way to
276
+ * report a green that did not happen.
277
+ *
278
+ * @param {object} opts
279
+ * @param {number} opts.status - What the suite process exited with.
280
+ * @param {readonly string[]} [opts.vanished] - Executables gone since it started.
281
+ * @param {readonly string[]} [opts.declared] - Result paths the repository declares.
282
+ * @param {readonly string[]} [opts.fresh] - Those of them it wrote to.
283
+ * @returns {SuiteVerdict} The status to report, and why.
284
+ */
285
+ export function suiteVerdict({ status, vanished, declared, fresh }: {
286
+ status: number;
287
+ vanished?: readonly string[] | undefined;
288
+ declared?: readonly string[] | undefined;
289
+ fresh?: readonly string[] | undefined;
290
+ }): SuiteVerdict;
191
291
  /**
192
292
  * Run the repository's suite.
193
293
  *
@@ -196,18 +296,32 @@ export function waitForWorld({ url, container, stage, timeoutMs, log }: {
196
296
  * runner launches as plain Node, rejects its own flags, and dies with a
197
297
  * `MODULE_NOT_FOUND` naming nothing relevant.
198
298
  *
299
+ * The suite is bracketed by checks rather than trusted on its exit status,
300
+ * because a run that never started used to report as green (#153):
301
+ *
302
+ * - **Before.** Every executable the command needs is resolved, and a missing
303
+ * one is an error naming it — rather than a container stood up, a world
304
+ * seeded, and a failure three minutes later that names nothing.
305
+ * - **After.** The same question again, because the reported failure was an
306
+ * install pulling the runner out from under a run already in progress; and,
307
+ * where the repository declares where its results land, whether anything was
308
+ * written there while the suite ran.
309
+ *
199
310
  * @param {object} opts
200
- * @param {string[]} opts.command - The program and its arguments.
311
+ * @param {readonly string[]} opts.command - The program and its arguments.
201
312
  * @param {string[]} [opts.args] - Extra arguments, appended verbatim.
202
313
  * @param {string} opts.cwd - The repository root.
314
+ * @param {readonly string[]} [opts.results] - Declared result paths to check.
203
315
  * @param {NodeJS.ProcessEnv} [opts.env] - Environment for the child.
204
316
  * @param {(message: string) => void} [opts.log] - Progress reporting.
205
317
  * @returns {number} The suite's exit status.
318
+ * @throws {Error} When the command names an executable that is not installed.
206
319
  */
207
- export function runSuite({ command, args, cwd, env, log }: {
208
- command: string[];
320
+ export function runSuite({ command, args, cwd, results, env, log, }: {
321
+ command: readonly string[];
209
322
  args?: string[] | undefined;
210
323
  cwd: string;
324
+ results?: readonly string[] | undefined;
211
325
  env?: NodeJS.ProcessEnv | undefined;
212
326
  log?: ((message: string) => void) | undefined;
213
327
  }): number;
@@ -349,3 +463,16 @@ export type FastArgs = {
349
463
  */
350
464
  suiteArgs: string[];
351
465
  };
466
+ /**
467
+ * What the harness reports for a finished suite.
468
+ */
469
+ export type SuiteVerdict = {
470
+ /**
471
+ * The exit status to hand back.
472
+ */
473
+ status: number;
474
+ /**
475
+ * What to say about it, if anything.
476
+ */
477
+ message: string | null;
478
+ };