@heroiclands/package-build 19.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.
Files changed (85) hide show
  1. package/CHANGELOG.md +1100 -0
  2. package/CONTENT.md +264 -33
  3. package/README.md +43 -4
  4. package/bin/content-build.mjs +94 -3
  5. package/config.mjs +9 -1
  6. package/content-config.mjs +99 -19
  7. package/docs/content-format.md +394 -72
  8. package/e2e.mjs +297 -3
  9. package/engine/actor-compiler.mjs +197 -7
  10. package/engine/address-charset.mjs +23 -5
  11. package/engine/base-compiler.mjs +63 -2
  12. package/engine/bundles.mjs +9 -0
  13. package/engine/content-address.mjs +92 -1
  14. package/engine/content-charset.mjs +434 -0
  15. package/engine/content-format.mjs +102 -0
  16. package/engine/content-icons.mjs +388 -0
  17. package/engine/content-index.mjs +11 -8
  18. package/engine/content-links.mjs +37 -21
  19. package/engine/field-reference.mjs +57 -5
  20. package/engine/field-spec.mjs +214 -7
  21. package/engine/folder-notes.mjs +24 -1
  22. package/engine/foreign-catalog.mjs +112 -7
  23. package/engine/foundry-entries.mjs +14 -0
  24. package/engine/frontmatter-lint.mjs +377 -56
  25. package/engine/frontmatter.mjs +11 -11
  26. package/engine/generate.mjs +72 -12
  27. package/engine/helpers.mjs +96 -10
  28. package/engine/index.mjs +9 -0
  29. package/engine/item-compiler.mjs +37 -0
  30. package/engine/journals.mjs +21 -4
  31. package/engine/macros.mjs +8 -0
  32. package/engine/map-notes.mjs +7 -7
  33. package/engine/note-claims.mjs +208 -5
  34. package/engine/note-ids.mjs +25 -1
  35. package/engine/note-vocabulary.mjs +76 -9
  36. package/engine/pack-config.mjs +102 -12
  37. package/engine/pack-router.mjs +0 -0
  38. package/engine/prose-config.mjs +42 -0
  39. package/engine/prose-lint.mjs +126 -0
  40. package/engine/retired-fields.mjs +57 -16
  41. package/engine/runtime-only-fields.mjs +204 -0
  42. package/engine/scenes.mjs +12 -19
  43. package/engine/schema-check.mjs +23 -1
  44. package/engine/schema-extract.mjs +13 -0
  45. package/engine/site-index.mjs +17 -0
  46. package/engine/subtype-registry.mjs +30 -0
  47. package/engine/system-block.mjs +81 -3
  48. package/engine/web-wikilinks.mjs +33 -27
  49. package/engine/wikilink-syntax.mjs +7 -0
  50. package/engine/wikilinks.mjs +74 -16
  51. package/hm3/actors.mjs +63 -13
  52. package/package.json +2 -2
  53. package/sohl/actors.mjs +106 -7
  54. package/sohl/item-fields.mjs +203 -0
  55. package/sohl/note-schemas.mjs +6 -3
  56. package/types/config.d.mts +7 -0
  57. package/types/e2e.d.mts +130 -3
  58. package/types/engine/actor-compiler.d.mts +83 -3
  59. package/types/engine/address-charset.d.mts +22 -4
  60. package/types/engine/base-compiler.d.mts +54 -3
  61. package/types/engine/content-address.d.mts +64 -0
  62. package/types/engine/content-charset.d.mts +127 -0
  63. package/types/engine/content-format.d.mts +9 -0
  64. package/types/engine/content-icons.d.mts +151 -0
  65. package/types/engine/field-spec.d.mts +271 -3
  66. package/types/engine/folder-notes.d.mts +20 -0
  67. package/types/engine/foreign-catalog.d.mts +38 -2
  68. package/types/engine/foundry-entries.d.mts +6 -0
  69. package/types/engine/frontmatter-lint.d.mts +164 -30
  70. package/types/engine/frontmatter.d.mts +11 -11
  71. package/types/engine/generate.d.mts +27 -0
  72. package/types/engine/helpers.d.mts +45 -9
  73. package/types/engine/index.d.mts +3 -0
  74. package/types/engine/map-notes.d.mts +2 -2
  75. package/types/engine/note-claims.d.mts +67 -0
  76. package/types/engine/note-ids.d.mts +14 -0
  77. package/types/engine/pack-config.d.mts +35 -0
  78. package/types/engine/prose-config.d.mts +41 -0
  79. package/types/engine/prose-lint.d.mts +36 -0
  80. package/types/engine/retired-fields.d.mts +29 -13
  81. package/types/engine/runtime-only-fields.d.mts +102 -0
  82. package/types/engine/schema-check.d.mts +10 -1
  83. package/types/engine/subtype-registry.d.mts +21 -0
  84. package/types/engine/system-block.d.mts +28 -2
  85. package/types/sohl/actors.d.mts +3 -3
package/sohl/actors.mjs CHANGED
@@ -63,6 +63,14 @@ import { SOHL_DOCUMENT_SUBTYPES } from "./document-subtypes.mjs";
63
63
  // verbatim, and `sohl.img` / `sohl.effects` / `sohl.flags` overriding their
64
64
  // shared top-level forms for this system alone (#58).
65
65
  import { blockProperty, mergeSystemData } from "../engine/system-block.mjs";
66
+ import { readField, retiredTopLevelKey } from "../engine/field-spec.mjs";
67
+ // The retirement window's reports, shared with the frontmatter lint so the two
68
+ // cannot say different things about the same key (#305, #332).
69
+ import {
70
+ legacyKeyMessage,
71
+ locateFrontmatterKey,
72
+ retiredTopLevelMessage,
73
+ } from "../engine/retired-fields.mjs";
66
74
 
67
75
  /**
68
76
  * The system this pass compiles for — the block its notes write.
@@ -81,6 +89,38 @@ const DEFAULT_IMG = {
81
89
  being: "systems/sohl/assets/icons/game-icons/delapouite/person.svg",
82
90
  };
83
91
 
92
+ /**
93
+ * The being's sheet portrait — the one row of the content format's actor
94
+ * mapping table that is authored rather than derived.
95
+ *
96
+ * **Declared, because the position is not a spelling anyone can guess.** This
97
+ * was read with `blockProperty(fm, SYSTEM, "portrait")`, which knows the block
98
+ * and the note's top level and nothing else — so `data.portrait`, the position
99
+ * the specification names and `sohl-thalorna` writes on 646 beings, was
100
+ * invisible, and `?? defaultImg` on the next line turned every miss into the
101
+ * generic person icon rather than into a complaint (#332). Going through
102
+ * {@link module:engine/field-spec.readField} is what makes the mapping table
103
+ * executable here as it already is for HM3's `data.species`.
104
+ *
105
+ * `img` is deliberately **not** declared beside it: the mapping table keeps a
106
+ * note's token art at the top level, so `blockProperty` is the whole of its
107
+ * resolution and there is no `data.img` to reach for.
108
+ *
109
+ * @type {import("../engine/field-spec.mjs").FieldSpec}
110
+ */
111
+ const PORTRAIT_FIELD = Object.freeze({
112
+ name: "data.portrait",
113
+ legacyKey: "portrait",
114
+ to: "portrait",
115
+ shape: "path",
116
+ // The two empties survive, because the caller's `?? defaultImg` is what
117
+ // tells them apart: `null` and an absent key mean "no art named, default
118
+ // me", `""` means "ship blank on purpose" (#218).
119
+ read: (raw) => resolveImg(raw),
120
+ default: null,
121
+ describe: "Path to the portrait image.",
122
+ });
123
+
84
124
  /**
85
125
  * The default art for an actor subtype.
86
126
  *
@@ -206,21 +246,48 @@ export class Actors extends SystemActorCompiler {
206
246
  this.errorCount++;
207
247
  return;
208
248
  }
209
- const { shortcode, type, ...rest } = entry;
210
- if (!type) {
211
- this.noteError(`${ctx}: sohl.items[${index}] missing type`);
249
+ const { model, shortcode, type, ...rest } = entry;
250
+ if (shortcode !== undefined) {
251
+ this.noteError(
252
+ `${ctx}: sohl.items[${index}] carries a top-level ` +
253
+ `\`shortcode\` — that key is retired. Name the item ` +
254
+ `this entry copies with \`model:\` (an address, e.g. ` +
255
+ `\`${type ?? "skill"}-${shortcode}\`), and use ` +
256
+ `\`system.shortcode\` for this item's own identity.`,
257
+ );
258
+ this.errorCount++;
259
+ return;
260
+ }
261
+ const read = model === undefined ? null : this.readModel(model, index, ctx);
262
+ if (model !== undefined && !read) return;
263
+ if (read && type !== undefined) {
264
+ this.noteError(
265
+ `${ctx}: sohl.items[${index}] states both \`model\` and ` +
266
+ `\`type\` — the model's address already names the ` +
267
+ `type, so the second is a place to be wrong. Drop \`type\`.`,
268
+ );
269
+ this.errorCount++;
270
+ return;
271
+ }
272
+ const effectiveType = read ? read.type : type;
273
+ if (!effectiveType) {
274
+ this.noteError(
275
+ `${ctx}: sohl.items[${index}] names no \`model\` and no ` +
276
+ `\`type\` — an entry that copies nothing must state ` +
277
+ `\`name\`, \`type\` and \`system.shortcode\`.`,
278
+ );
212
279
  this.errorCount++;
213
280
  return;
214
281
  }
215
282
  const embedded = this.resolveEmbedded(
216
283
  itemsMap,
217
284
  actorId,
218
- type,
219
- shortcode || null,
285
+ effectiveType,
286
+ read ? read.shortcode : null,
220
287
  rest,
221
288
  `items:${index}`,
222
289
  ctx,
223
- { fmKey: "items" },
290
+ { fmKey: "items", modelPackage: read?.package ?? null },
224
291
  );
225
292
  if (embedded) items.push(embedded);
226
293
  });
@@ -298,6 +365,36 @@ export class Actors extends SystemActorCompiler {
298
365
  const { value: authoredFolder, isAddress } = folderField(fm);
299
366
  const folder = this.folderResolver(authoredFolder, { isAddress });
300
367
 
368
+ // The two retiring positions a declared field may be read from (#305,
369
+ // #332). **Warnings**, on the pattern every retirement in this package
370
+ // follows: the note compiles to the correct document either way, so
371
+ // reddening a tree over one would refuse before the sweep rather than
372
+ // after it. What they buy is a count — the whole reason #332 was
373
+ // invisible for so long is that nothing said which position a value
374
+ // had come from, and a default is indistinguishable from a miss.
375
+ const portraitReports = {
376
+ block: SYSTEM,
377
+ onLegacyKey: (field) =>
378
+ this.noteWarn(
379
+ legacyKeyMessage(SYSTEM, field),
380
+ locateFrontmatterKey(this.currentNote?.absPath, field.legacyKey),
381
+ ),
382
+ // Anchored at column 1: the two positions share a spelling here —
383
+ // `sohl.portrait` and `portrait` — so a locator that took the first
384
+ // match would point at the block key while the message named the
385
+ // top-level one.
386
+ onRetiredTopLevel: (field) =>
387
+ this.noteWarn(
388
+ retiredTopLevelMessage(field),
389
+ locateFrontmatterKey(
390
+ this.currentNote?.absPath,
391
+ retiredTopLevelKey(field),
392
+ undefined,
393
+ { topLevel: true },
394
+ ),
395
+ ),
396
+ };
397
+
301
398
  const system = {
302
399
  // The frontmatter shortcode is the actor's stable `(type, shortcode)`
303
400
  // key — and, for a being that is an archetype, its archetype
@@ -311,7 +408,9 @@ export class Actors extends SystemActorCompiler {
311
408
  templatePriority: systemTemplatePriority(fm, ctx),
312
409
  // Nullish, not `||` (#218): a note that names no portrait gets the
313
410
  // subtype's default, one that writes `""` ships blank on purpose.
314
- portrait: resolveImg(blockProperty(fm, SYSTEM, "portrait")) ?? defaultImg,
411
+ // Resolved through the declaration so `data.portrait` is reached at
412
+ // all — see {@link PORTRAIT_FIELD} (#332).
413
+ portrait: readField(PORTRAIT_FIELD, fm, portraitReports) ?? defaultImg,
315
414
  appearance: renderSection(body || "", "appearance"),
316
415
  dossier: renderSection(body || "", "dossier"),
317
416
  };
@@ -103,6 +103,40 @@ function noteContext(fm, type) {
103
103
  return `${type} "${fm?.name?.full ?? fm?.shortcode ?? "?"}"`;
104
104
  }
105
105
 
106
+ /**
107
+ * A timed phase's duration **formula** — what is rolled to seed the interval.
108
+ *
109
+ * SoHL's own field is a nullable, non-blank `StringField`, and the interval it
110
+ * yields is in **seconds** (`"432000"` is the five days the world setting
111
+ * defaults to). A bare number is as valid as a dice expression, and both are
112
+ * stringified here so a note may write either — which is also why no `kind` is
113
+ * declared: a lint claiming this must be a string would report `86400` as an
114
+ * authoring mistake when it is the commonest thing to write.
115
+ *
116
+ * Blank reads as unset rather than as `""`, matching the field's `blank: false`
117
+ * — but a field declaring {@link FieldSpec.omitWhenAbsent} skips before this
118
+ * runs when the note carries nothing, so the `null` is only ever reached by a
119
+ * note that cleared the key on purpose.
120
+ */
121
+ const DURATION_FORMULA = Object.freeze({
122
+ shape: "roll formula, or a whole number of seconds",
123
+ read: (raw) => (raw == null || raw === "" ? null : String(raw)),
124
+ });
125
+
126
+ /**
127
+ * A timed phase's rolled duration, in **seconds** — the fact, where the formula
128
+ * above is the definition.
129
+ *
130
+ * Authoring it states the interval outright instead of leaving it to a roll,
131
+ * which is what a fixed-cadence phase wants. Non-numeric reads `0` rather than
132
+ * `NaN`, as every other count in this vocabulary does.
133
+ */
134
+ const DURATION_BASE = Object.freeze({
135
+ shape: "whole number of seconds",
136
+ kind: "number",
137
+ read: (raw) => (raw == null || raw === "" ? null : Number(raw) || 0),
138
+ });
139
+
106
140
  /** Aptitude weights per skill selector, validated as whole numbers. */
107
141
  const SKILL_APTITUDES = Object.freeze({
108
142
  shape: "map of skill selector → whole number",
@@ -414,6 +448,104 @@ export const ITEM_FIELDS = Object.freeze({
414
448
  describe:
415
449
  "What running the course to the end does to the host: `death`, or the benign default `cured`.",
416
450
  },
451
+ // The two **authored** thirds of each timed-phase triplet (#329). Every
452
+ // one omits its key when the note does not carry it, because the value
453
+ // that stands otherwise is the DataModel's `initial: null` — writing a
454
+ // compile-time `null` over it would say "this phase takes no time",
455
+ // which is a different claim from "this note does not set the phase",
456
+ // and `AfflictionLogic.rollDuration()` opens `if (!formula) return 0`.
457
+ //
458
+ // Until they were declared here the only way to author one was the raw
459
+ // `system:` passthrough — undocumented, uncoerced, and absent from the
460
+ // field list every author-facing surface is built from — so no note in
461
+ // any tree wrote one and every shipped affliction had zero-length
462
+ // phases.
463
+ // Spelled out, one entry per field, for the reason the dates below are:
464
+ // a name assembled from a phase argument is not in the source, so it
465
+ // cannot be grepped, and each phase's prose differs anyway — which is
466
+ // the whole content of the declaration.
467
+ {
468
+ name: "onsetDurationFormula",
469
+ to: "onsetDurationFormula",
470
+ ...DURATION_FORMULA,
471
+ omitWhenAbsent: true,
472
+ describe:
473
+ "Interval from contracting the affliction to the start of onset. Omitted when unset, leaving no incubation.",
474
+ },
475
+ {
476
+ name: "onsetDurationBase",
477
+ to: "onsetDurationBase",
478
+ ...DURATION_BASE,
479
+ omitWhenAbsent: true,
480
+ describe:
481
+ "The onset interval in seconds, standing in for a roll of the formula. Omitted when unset.",
482
+ },
483
+ {
484
+ name: "healingCheckDurationFormula",
485
+ to: "healingCheckDurationFormula",
486
+ ...DURATION_FORMULA,
487
+ omitWhenAbsent: true,
488
+ describe:
489
+ "Interval between healing checks, once the affliction is symptomatic. Omitted when unset.",
490
+ },
491
+ {
492
+ name: "healingCheckDurationBase",
493
+ to: "healingCheckDurationBase",
494
+ ...DURATION_BASE,
495
+ omitWhenAbsent: true,
496
+ describe:
497
+ "The healing-check interval in seconds, standing in for a roll of the formula. Omitted when unset.",
498
+ },
499
+ {
500
+ name: "resolutionDurationFormula",
501
+ to: "resolutionDurationFormula",
502
+ ...DURATION_FORMULA,
503
+ omitWhenAbsent: true,
504
+ describe:
505
+ "Interval from onset to the affliction running its course. Omitted when unset.",
506
+ },
507
+ {
508
+ name: "resolutionDurationBase",
509
+ to: "resolutionDurationBase",
510
+ ...DURATION_BASE,
511
+ omitWhenAbsent: true,
512
+ describe:
513
+ "The resolution interval in seconds, standing in for a roll of the formula. Omitted when unset.",
514
+ },
515
+ // The `…Date` third of every timed-phase triplet, declared as **runtime
516
+ // state** (#330). A phase is authored as its `…DurationFormula`; the
517
+ // `…DurationBase` records what that rolled to; the date records *when
518
+ // it happened*, and only play can know that. All four are
519
+ // `worldTimeDateField()`s in SoHL's schema — nullable rather than
520
+ // sentinelled precisely because `0` is itself a valid world time — so
521
+ // there is no blank an author could honestly write either.
522
+ //
523
+ // Declaring them does two things at once: the key is left out of the
524
+ // compiled document, so SoHL's own `initial: null` stands, and a note
525
+ // authoring one is refused with a message naming the field and its
526
+ // reason. Written out rather than generated from a phase name, as the
527
+ // schema they mirror now is: a field name assembled from an argument is
528
+ // not in the source, and cannot be grepped, read or reported on.
529
+ {
530
+ to: "contractDate",
531
+ runtimeOnly: "the world time the host contracted the affliction",
532
+ describe: "When the host contracted it. Play state, never authored.",
533
+ },
534
+ {
535
+ to: "onsetDate",
536
+ runtimeOnly: "the world time onset fired at",
537
+ describe: "When onset fired. Play state, never authored.",
538
+ },
539
+ {
540
+ to: "treatmentDate",
541
+ runtimeOnly: "the world time the affliction was last treated",
542
+ describe: "When it was last treated. Play state, never authored.",
543
+ },
544
+ {
545
+ to: "resolutionDate",
546
+ runtimeOnly: "the world time the affliction ran its course",
547
+ describe: "When it resolved. Play state, never authored.",
548
+ },
417
549
  ]),
418
550
 
419
551
  armorgear: Object.freeze([
@@ -817,6 +949,77 @@ export const ITEM_FIELDS = Object.freeze({
817
949
  default: null,
818
950
  describe: "Shortcode of the body location injured. Unset on a descriptive condition.",
819
951
  },
952
+ // A trauma's three timed phases (#329). Its healing check and course
953
+ // both fall back to a **world setting** when the trauma sets neither
954
+ // half, which is the sharper reason these omit rather than default: a
955
+ // compile-time `null` written here is still a value, and the seeding in
956
+ // `TraumaDataModel._preCreate` reads `data.courseDurationFormula == null`
957
+ // to decide whether the GM's configured interval applies. A default
958
+ // would answer that question for every trauma in every world.
959
+ //
960
+ // `bloodLossAdvance*` is the one an author reaches for most: a trauma
961
+ // that sets it bleeds, and one that leaves it unset does not.
962
+ {
963
+ name: "healingCheckDurationFormula",
964
+ to: "healingCheckDurationFormula",
965
+ ...DURATION_FORMULA,
966
+ omitWhenAbsent: true,
967
+ describe:
968
+ "Interval between healing checks. Omitted when unset, leaving the world's configured interval to apply.",
969
+ },
970
+ {
971
+ name: "healingCheckDurationBase",
972
+ to: "healingCheckDurationBase",
973
+ ...DURATION_BASE,
974
+ omitWhenAbsent: true,
975
+ describe:
976
+ "The healing-check interval in seconds, standing in for a roll of the formula. Omitted when unset.",
977
+ },
978
+ {
979
+ name: "bloodLossAdvanceDurationFormula",
980
+ to: "bloodLossAdvanceDurationFormula",
981
+ ...DURATION_FORMULA,
982
+ omitWhenAbsent: true,
983
+ describe:
984
+ "Interval between blood-loss advances. Omitted when unset, leaving the world's configured interval to apply.",
985
+ },
986
+ {
987
+ name: "bloodLossAdvanceDurationBase",
988
+ to: "bloodLossAdvanceDurationBase",
989
+ ...DURATION_BASE,
990
+ omitWhenAbsent: true,
991
+ describe:
992
+ "The blood-loss interval in seconds. Setting it is what makes the wound bleed; omitted when unset, and the wound does not.",
993
+ },
994
+ {
995
+ name: "courseDurationFormula",
996
+ to: "courseDurationFormula",
997
+ ...DURATION_FORMULA,
998
+ omitWhenAbsent: true,
999
+ describe:
1000
+ "Interval between course tests, for a condition that runs one — shock, coma, infection. Omitted when unset.",
1001
+ },
1002
+ {
1003
+ name: "courseDurationBase",
1004
+ to: "courseDurationBase",
1005
+ ...DURATION_BASE,
1006
+ omitWhenAbsent: true,
1007
+ describe:
1008
+ "The course-test interval in seconds, standing in for a roll of the formula. Omitted when unset.",
1009
+ },
1010
+ // Runtime state, for the reason `affliction`'s four are (#330): a
1011
+ // trauma's dates are crystallized when the phase fires, and world time
1012
+ // does not exist while content is compiled.
1013
+ {
1014
+ to: "contractDate",
1015
+ runtimeOnly: "the world time the injury was taken",
1016
+ describe: "When the injury was taken. Play state, never authored.",
1017
+ },
1018
+ {
1019
+ to: "treatmentDate",
1020
+ runtimeOnly: "the world time the injury was last treated",
1021
+ describe: "When it was last treated. Play state, never authored.",
1022
+ },
820
1023
  ]),
821
1024
 
822
1025
  weapongear: Object.freeze([
@@ -148,8 +148,8 @@ const BEING_FIELDS = Object.freeze([
148
148
  *
149
149
  * `img` is the one required field — the compiler refuses a map note without it,
150
150
  * since a scene with no background is not a map. It was spelled `image` and
151
- * read from the `sohl:` block until #142; both spellings still compile, and the
152
- * retired one is reported rather than refused.
151
+ * read from the `sohl:` block until #142, which read both while the trees were
152
+ * swept; that retirement has since completed and `image` is gone (#149).
153
153
  *
154
154
  * @type {readonly import("../engine/field-spec.mjs").FieldSpec[]}
155
155
  */
@@ -161,7 +161,10 @@ const MAP_FIELDS = Object.freeze([
161
161
  // Art is not system-specific — a Scene is a core Foundry document, and
162
162
  // every other note type carries its `img` at the note's top level.
163
163
  shared: true,
164
- describe: "The scene's background image.",
164
+ describe:
165
+ "The scene's background image. Owned by whichever package its first segment " +
166
+ "names — `systems/…` and `modules/…` are emitted unchanged, anything else is " +
167
+ "this package's own and is rooted under its assets.",
165
168
  },
166
169
  {
167
170
  name: "dimensions",
@@ -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
+ };