@hominis/fireforge 0.50.0 → 0.51.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 (205) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/check-report.d.ts +17 -4
  4. package/dist/src/commands/check-report.js +19 -6
  5. package/dist/src/commands/check-run.d.ts +5 -0
  6. package/dist/src/commands/check-run.js +9 -1
  7. package/dist/src/commands/check-steps.d.ts +8 -0
  8. package/dist/src/commands/check-steps.js +31 -6
  9. package/dist/src/commands/check.js +1 -1
  10. package/dist/src/commands/declarations-report.d.ts +43 -0
  11. package/dist/src/commands/declarations-report.js +76 -0
  12. package/dist/src/commands/export-placement-gate.js +8 -0
  13. package/dist/src/commands/furnace/rename-registrations.js +7 -2
  14. package/dist/src/commands/furnace/validate.js +26 -1
  15. package/dist/src/commands/lint-per-patch.js +6 -2
  16. package/dist/src/commands/lint-per-run-checkjs.js +2 -2
  17. package/dist/src/commands/lint-report.js +7 -2
  18. package/dist/src/commands/manifest.js +2 -0
  19. package/dist/src/commands/park.d.ts +22 -0
  20. package/dist/src/commands/park.js +232 -0
  21. package/dist/src/commands/patch/delete.js +11 -6
  22. package/dist/src/commands/patch/index.d.ts +3 -3
  23. package/dist/src/commands/patch/index.js +8 -4
  24. package/dist/src/commands/patch/lint-ignore-from.d.ts +44 -0
  25. package/dist/src/commands/patch/lint-ignore-from.js +142 -0
  26. package/dist/src/commands/patch/lint-ignore.js +39 -29
  27. package/dist/src/commands/patch/move-files-create.js +11 -4
  28. package/dist/src/commands/patch/move-files-into.js +10 -4
  29. package/dist/src/commands/patch/move-files.js +3 -1
  30. package/dist/src/commands/patch/removal-created-files.d.ts +48 -0
  31. package/dist/src/commands/patch/removal-created-files.js +83 -0
  32. package/dist/src/commands/patch/rename.js +15 -3
  33. package/dist/src/commands/patch/reserve.d.ts +33 -0
  34. package/dist/src/commands/patch/reserve.js +152 -0
  35. package/dist/src/commands/patch/retire.js +11 -6
  36. package/dist/src/commands/patch/split-plan.d.ts +10 -1
  37. package/dist/src/commands/patch/split-plan.js +16 -1
  38. package/dist/src/commands/patch/split.js +14 -5
  39. package/dist/src/commands/patch/stage.d.ts +34 -0
  40. package/dist/src/commands/patch/stage.js +153 -0
  41. package/dist/src/commands/patch/staged-dependency-remove.d.ts +31 -0
  42. package/dist/src/commands/patch/staged-dependency-remove.js +80 -0
  43. package/dist/src/commands/patch/staged-dependency.js +40 -98
  44. package/dist/src/commands/re-export-adjacent.d.ts +5 -0
  45. package/dist/src/commands/re-export-adjacent.js +4 -3
  46. package/dist/src/commands/re-export-bulk-scan.d.ts +2 -1
  47. package/dist/src/commands/re-export-bulk-scan.js +22 -7
  48. package/dist/src/commands/re-export-hunks.d.ts +35 -0
  49. package/dist/src/commands/re-export-hunks.js +220 -0
  50. package/dist/src/commands/re-export-options.js +29 -2
  51. package/dist/src/commands/re-export-purity-scope.d.ts +18 -0
  52. package/dist/src/commands/re-export-purity-scope.js +131 -0
  53. package/dist/src/commands/re-export-register.js +15 -1
  54. package/dist/src/commands/re-export-removed.d.ts +1 -1
  55. package/dist/src/commands/re-export-removed.js +17 -2
  56. package/dist/src/commands/re-export-scan.d.ts +2 -0
  57. package/dist/src/commands/re-export-scan.js +4 -1
  58. package/dist/src/commands/re-export-single.js +102 -42
  59. package/dist/src/commands/re-export-stale-registrations.d.ts +18 -0
  60. package/dist/src/commands/re-export-stale-registrations.js +62 -0
  61. package/dist/src/commands/re-export.js +35 -34
  62. package/dist/src/commands/test-build-furnace.d.ts +3 -2
  63. package/dist/src/commands/test-build-furnace.js +8 -2
  64. package/dist/src/commands/test-build-phase.js +24 -7
  65. package/dist/src/commands/test-checkjs.js +3 -2
  66. package/dist/src/commands/test-dispatch.d.ts +39 -0
  67. package/dist/src/commands/test-dispatch.js +211 -0
  68. package/dist/src/commands/test-furnace-reach.d.ts +37 -0
  69. package/dist/src/commands/test-furnace-reach.js +132 -0
  70. package/dist/src/commands/test-orchestrate.d.ts +9 -0
  71. package/dist/src/commands/test-orchestrate.js +22 -4
  72. package/dist/src/commands/test-perf-scope.d.ts +21 -0
  73. package/dist/src/commands/test-perf-scope.js +55 -0
  74. package/dist/src/commands/test-prelock.js +100 -4
  75. package/dist/src/commands/test-register.js +18 -3
  76. package/dist/src/commands/test-results.d.ts +62 -0
  77. package/dist/src/commands/test-results.js +62 -0
  78. package/dist/src/commands/test-run.d.ts +13 -55
  79. package/dist/src/commands/test-run.js +73 -101
  80. package/dist/src/commands/test-shards.d.ts +110 -0
  81. package/dist/src/commands/test-shards.js +255 -0
  82. package/dist/src/commands/test-stale-gate.d.ts +5 -1
  83. package/dist/src/commands/test-stale-gate.js +4 -2
  84. package/dist/src/commands/test-verdict.d.ts +2 -1
  85. package/dist/src/commands/test.d.ts +1 -1
  86. package/dist/src/commands/test.js +65 -75
  87. package/dist/src/commands/token-lint.js +48 -8
  88. package/dist/src/commands/verify.js +9 -2
  89. package/dist/src/core/build-prepare.d.ts +6 -0
  90. package/dist/src/core/build-prepare.js +29 -3
  91. package/dist/src/core/config-paths.d.ts +1 -1
  92. package/dist/src/core/config-paths.js +2 -0
  93. package/dist/src/core/config-validate-check.js +83 -7
  94. package/dist/src/core/config-validate-test-toolchains.js +14 -0
  95. package/dist/src/core/config-validate-token-scales.d.ts +3 -0
  96. package/dist/src/core/config-validate-token-scales.js +47 -5
  97. package/dist/src/core/config-validate.js +6 -0
  98. package/dist/src/core/coverage-extend.js +2 -2
  99. package/dist/src/core/coverage-records.d.ts +11 -0
  100. package/dist/src/core/coverage-records.js +34 -0
  101. package/dist/src/core/engine-session-lock.d.ts +6 -0
  102. package/dist/src/core/engine-session-lock.js +10 -2
  103. package/dist/src/core/file-lock-shared.d.ts +57 -0
  104. package/dist/src/core/file-lock-shared.js +117 -0
  105. package/dist/src/core/file-lock.d.ts +9 -0
  106. package/dist/src/core/file-lock.js +58 -2
  107. package/dist/src/core/furnace-apply.js +10 -10
  108. package/dist/src/core/furnace-config-validate-exceptions.d.ts +10 -3
  109. package/dist/src/core/furnace-config-validate-exceptions.js +39 -8
  110. package/dist/src/core/furnace-config-validate.js +7 -1
  111. package/dist/src/core/furnace-registration-jar-inplace.d.ts +12 -0
  112. package/dist/src/core/furnace-registration-jar-inplace.js +80 -0
  113. package/dist/src/core/furnace-validate-accessibility.d.ts +4 -2
  114. package/dist/src/core/furnace-validate-accessibility.js +61 -27
  115. package/dist/src/core/furnace-validate-exceptions.d.ts +12 -4
  116. package/dist/src/core/furnace-validate-exceptions.js +41 -4
  117. package/dist/src/core/furnace-validate-icon-button.js +30 -1
  118. package/dist/src/core/history-log-file.js +8 -1
  119. package/dist/src/core/line-prefixer.d.ts +18 -0
  120. package/dist/src/core/line-prefixer.js +34 -0
  121. package/dist/src/core/mach.d.ts +7 -0
  122. package/dist/src/core/mach.js +24 -9
  123. package/dist/src/core/module-resolution-preflight.d.ts +19 -3
  124. package/dist/src/core/module-resolution-preflight.js +24 -6
  125. package/dist/src/core/negative-control-compiled.d.ts +27 -0
  126. package/dist/src/core/negative-control-compiled.js +73 -0
  127. package/dist/src/core/negative-control-diff.d.ts +14 -2
  128. package/dist/src/core/negative-control-diff.js +49 -6
  129. package/dist/src/core/negative-control.d.ts +12 -0
  130. package/dist/src/core/negative-control.js +48 -5
  131. package/dist/src/core/park-store.d.ts +63 -0
  132. package/dist/src/core/park-store.js +216 -0
  133. package/dist/src/core/patch-export-update.d.ts +4 -0
  134. package/dist/src/core/patch-export-update.js +12 -3
  135. package/dist/src/core/patch-export.d.ts +3 -2
  136. package/dist/src/core/patch-export.js +21 -14
  137. package/dist/src/core/patch-files.js +26 -8
  138. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +6 -4
  139. package/dist/src/core/patch-lint-checkjs-any-import.js +9 -7
  140. package/dist/src/core/patch-lint-checkjs-diagnostics.d.ts +17 -0
  141. package/dist/src/core/patch-lint-checkjs-diagnostics.js +32 -0
  142. package/dist/src/core/patch-lint-checkjs.d.ts +10 -1
  143. package/dist/src/core/patch-lint-checkjs.js +13 -3
  144. package/dist/src/core/patch-lint-cross.d.ts +3 -1
  145. package/dist/src/core/patch-lint-cross.js +5 -0
  146. package/dist/src/core/patch-lint-css.js +14 -10
  147. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +5 -0
  148. package/dist/src/core/patch-lint-forward-registration-extended.js +1 -1
  149. package/dist/src/core/patch-lint-ignore.d.ts +56 -12
  150. package/dist/src/core/patch-lint-ignore.js +173 -43
  151. package/dist/src/core/patch-lint-ownership.d.ts +10 -0
  152. package/dist/src/core/patch-lint-ownership.js +26 -0
  153. package/dist/src/core/patch-lint-size.js +9 -1
  154. package/dist/src/core/patch-lint-token-scale.d.ts +26 -8
  155. package/dist/src/core/patch-lint-token-scale.js +206 -29
  156. package/dist/src/core/patch-lint.js +2 -2
  157. package/dist/src/core/patch-manifest-io.js +8 -1
  158. package/dist/src/core/patch-manifest-key-order.d.ts +24 -0
  159. package/dist/src/core/patch-manifest-key-order.js +61 -0
  160. package/dist/src/core/patch-manifest-stage.d.ts +46 -0
  161. package/dist/src/core/patch-manifest-stage.js +96 -0
  162. package/dist/src/core/patch-manifest-validate.js +27 -1
  163. package/dist/src/core/patch-order-legal.d.ts +2 -1
  164. package/dist/src/core/patch-order-legal.js +7 -2
  165. package/dist/src/core/patch-policy.d.ts +6 -2
  166. package/dist/src/core/patch-policy.js +14 -7
  167. package/dist/src/core/patch-retired.d.ts +26 -4
  168. package/dist/src/core/patch-retired.js +40 -2
  169. package/dist/src/core/run-log.d.ts +6 -0
  170. package/dist/src/core/run-log.js +17 -1
  171. package/dist/src/core/test-harness-crash.d.ts +2 -1
  172. package/dist/src/core/test-harness-crash.js +5 -8
  173. package/dist/src/core/test-harness-signature.d.ts +16 -0
  174. package/dist/src/core/test-harness-signature.js +19 -1
  175. package/dist/src/core/test-raw-log.d.ts +66 -0
  176. package/dist/src/core/test-raw-log.js +122 -0
  177. package/dist/src/core/test-run-completeness.d.ts +36 -3
  178. package/dist/src/core/test-run-completeness.js +57 -17
  179. package/dist/src/core/test-xpcshell-retry.d.ts +2 -0
  180. package/dist/src/core/test-xpcshell-retry.js +2 -1
  181. package/dist/src/core/token-lint.d.ts +5 -2
  182. package/dist/src/core/token-lint.js +8 -3
  183. package/dist/src/core/token-sibling-sheets.d.ts +46 -0
  184. package/dist/src/core/token-sibling-sheets.js +119 -0
  185. package/dist/src/core/tree-guard.js +2 -0
  186. package/dist/src/core/typecheck-shim.d.ts +5 -1
  187. package/dist/src/core/typecheck-shim.js +115 -2
  188. package/dist/src/core/typecheck.js +3 -1
  189. package/dist/src/index.d.ts +9 -3
  190. package/dist/src/index.js +4 -1
  191. package/dist/src/types/commands/index.d.ts +2 -2
  192. package/dist/src/types/commands/options.d.ts +68 -7
  193. package/dist/src/types/commands/patches.d.ts +43 -0
  194. package/dist/src/types/config.d.ts +74 -2
  195. package/dist/src/types/furnace.d.ts +20 -0
  196. package/dist/src/types/index.d.ts +1 -1
  197. package/dist/src/utils/options.d.ts +3 -2
  198. package/dist/src/utils/options.js +9 -4
  199. package/docs/configuration.md +94 -9
  200. package/docs/furnace.md +43 -1
  201. package/docs/lifecycle-invariants.md +13 -11
  202. package/docs/machine-output.md +64 -18
  203. package/docs/patch-workflow.md +174 -13
  204. package/docs/testing.md +73 -4
  205. package/package.json +3 -3
@@ -5,6 +5,7 @@
5
5
  import { parseObject } from '../utils/parse.js';
6
6
  import { isArray, isObject, isValidFirefoxProduct, isValidFirefoxVersion, PATCH_CATEGORIES, } from '../utils/validation.js';
7
7
  import { parseLintIgnoreEntries } from './patch-lint-ignore.js';
8
+ import { canonicalizePatchRow } from './patch-manifest-key-order.js';
8
9
  import { checkDocumentVersion, describeNewerDocument } from './state-file.js';
9
10
  function parseForwardImports(data, label) {
10
11
  if (!isArray(data)) {
@@ -134,7 +135,7 @@ function validatePatchMetadata(data, index) {
134
135
  if (rawStagedDependencies !== undefined) {
135
136
  result.stagedDependencies = parseStagedDependencies(rawStagedDependencies, `patches[${index}].stagedDependencies`);
136
137
  }
137
- return result;
138
+ return canonicalizePatchRow(result);
138
139
  }
139
140
  /**
140
141
  * Validates the `retired` placeholders `patch retire` leaves behind. Kept on
@@ -160,6 +161,29 @@ function parseRetiredSlots(data) {
160
161
  return slot;
161
162
  });
162
163
  }
164
+ /**
165
+ * Validates the `reserved` placeholders `patch reserve` writes. Same
166
+ * survival rule as {@link parseRetiredSlots}: dropping one on a round trip
167
+ * would reopen the gap a peer reserved the order to close.
168
+ */
169
+ function parseReservedSlots(data) {
170
+ if (!isArray(data)) {
171
+ throw new Error('patches.json field "reserved" must be an array');
172
+ }
173
+ return data.map((entry, index) => {
174
+ const rec = parseObject(entry, `reserved[${index}]`);
175
+ const slot = {
176
+ order: rec.nonNegativeInteger('order'),
177
+ category: rec.validatedString('category', (value) => /^[a-z][a-z0-9-]*$/.test(value), 'a lowercase category identifier (letters, numbers, hyphens)'),
178
+ owner: rec.validatedString('owner', (value) => value.trim() !== '', 'a non-empty label'),
179
+ reservedAt: rec.string('reservedAt'),
180
+ };
181
+ const reason = rec.optionalString('reason');
182
+ if (reason !== undefined)
183
+ slot.reason = reason;
184
+ return slot;
185
+ });
186
+ }
163
187
  /** Manifest schema version this build writes and understands. */
164
188
  const PATCHES_MANIFEST_VERSION = 1;
165
189
  /**
@@ -191,6 +215,8 @@ export function validatePatchesManifest(data) {
191
215
  };
192
216
  if (data['retired'] !== undefined)
193
217
  manifest.retired = parseRetiredSlots(data['retired']);
218
+ if (data['reserved'] !== undefined)
219
+ manifest.reserved = parseReservedSlots(data['reserved']);
194
220
  return manifest;
195
221
  }
196
222
  /**
@@ -14,7 +14,8 @@ import type { FireForgeConfig } from '../types/config.js';
14
14
  * Smallest order strictly greater than `after` that no patch occupies and
15
15
  * that adds no order-related `patchPolicy` finding (collision, category
16
16
  * range, reserved range, numeric gap) for a `category` patch. A retired
17
- * slot counts as free, since a patch may fill it. Returns null when no such
17
+ * slot counts as free, since a patch may fill it; a reservation does not,
18
+ * since it is held for its owner's export. Returns null when no such
18
19
  * order exists inside the category's configured ranges.
19
20
  *
20
21
  * @param config - Project config (only `patchPolicy` is read)
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { formatPatchOrder } from './patch-parse.js';
13
13
  import { evaluatePatchPolicy } from './patch-policy.js';
14
+ import { reservedOrderSet } from './patch-retired.js';
14
15
  /** Policy codes an order choice alone can trip. */
15
16
  const ORDER_CODES = new Set([
16
17
  'order-collision',
@@ -46,7 +47,8 @@ function orderIssueKeys(config, manifest) {
46
47
  * Smallest order strictly greater than `after` that no patch occupies and
47
48
  * that adds no order-related `patchPolicy` finding (collision, category
48
49
  * range, reserved range, numeric gap) for a `category` patch. A retired
49
- * slot counts as free, since a patch may fill it. Returns null when no such
50
+ * slot counts as free, since a patch may fill it; a reservation does not,
51
+ * since it is held for its owner's export. Returns null when no such
50
52
  * order exists inside the category's configured ranges.
51
53
  *
52
54
  * @param config - Project config (only `patchPolicy` is read)
@@ -55,7 +57,10 @@ function orderIssueKeys(config, manifest) {
55
57
  * @param after - The order the new patch must come after
56
58
  */
57
59
  export function earliestLegalOrderAfter(config, manifest, category, after) {
58
- const occupied = new Set(manifest.patches.map((patch) => patch.order));
60
+ const occupied = new Set([
61
+ ...manifest.patches.map((patch) => patch.order),
62
+ ...reservedOrderSet(manifest),
63
+ ]);
59
64
  const ranges = config.patchPolicy?.ranges.filter((range) => range.category === category) ?? [];
60
65
  const limit = ranges.length > 0 ? Math.max(...ranges.map((range) => range.to)) : after + UNBOUNDED_SEARCH;
61
66
  const baseline = config.patchPolicy ? orderIssueKeys(config, manifest) : new Set();
@@ -72,5 +72,9 @@ export declare function applyRenameMapToManifest(manifest: PatchesManifest, rena
72
72
  }>): PatchesManifest;
73
73
  /** Enforces patch policy according to the configured mutation mode. */
74
74
  export declare function enforcePatchPolicy(input: PatchPolicyEnforcementInput): void;
75
- /** Allocates the next available order inside the configured ranges for a category. */
76
- export declare function allocatePolicyOrder(config: FireForgeConfig, patches: readonly PatchMetadata[], category: string): number | null;
75
+ /**
76
+ * Allocates the next available order inside the configured ranges for a
77
+ * category. Orders in `reserved` (`patch reserve`) are skipped: they are
78
+ * held for their owner's export.
79
+ */
80
+ export declare function allocatePolicyOrder(config: FireForgeConfig, patches: readonly PatchMetadata[], category: string, reserved?: ReadonlySet<number>): number | null;
@@ -12,7 +12,7 @@ import { warn } from '../utils/logger.js';
12
12
  import { PATCH_CATEGORIES } from '../utils/validation.js';
13
13
  import { rewriteStagedDependencyOwners } from './patch-manifest-io.js';
14
14
  import { formatOrderRange, formatPatchOrder } from './patch-parse.js';
15
- import { manifestWithRetiredSlots, retiredOrderSet } from './patch-retired.js';
15
+ import { manifestWithRetiredSlots, reservedOrderSet, retiredOrderSet } from './patch-retired.js';
16
16
  /** Default patch filename contract used when a policy omits `filenamePattern`. */
17
17
  const DEFAULT_PATCH_POLICY_FILENAME_PATTERN = '^(?<order>\\d{3})-(?<category>[a-z][a-z0-9-]*)-(?<slug>[a-z0-9-]+)\\.patch$';
18
18
  function policy(config) {
@@ -244,8 +244,8 @@ function evaluateGaps(cfg, patches, severity, retiredOrders = new Set()) {
244
244
  const last = occupied[occupied.length - 1];
245
245
  const missing = [];
246
246
  for (let order = first; order <= last; order++) {
247
- // A retired slot (`patch retire`) is an occupied order with no
248
- // patch behind it: the number stays spent, so it is not a gap.
247
+ // A retired slot (`patch retire`) or a reservation (`patch reserve`)
248
+ // is an occupied order with no patch behind it, so it is not a gap.
249
249
  if (!occupiedSet.has(order) &&
250
250
  !retiredOrders.has(order) &&
251
251
  reservedRangeForOrder(cfg, order) === null) {
@@ -295,7 +295,10 @@ export function evaluatePatchPolicy(config, manifest) {
295
295
  const severity = mutationMode(config) === 'warn' ? 'warning' : 'error';
296
296
  const issues = manifest.patches.flatMap((patch) => evaluatePatchMetadata(cfg, patch, severity));
297
297
  issues.push(...evaluateOrderCollisions(manifest.patches, severity));
298
- issues.push(...evaluateGaps(cfg, manifest.patches, severity, retiredOrderSet(manifest)));
298
+ // Retired and reserved slots are both spent orders with no patch behind
299
+ // them: neither is a gap.
300
+ const placeholders = new Set([...retiredOrderSet(manifest), ...reservedOrderSet(manifest)]);
301
+ issues.push(...evaluateGaps(cfg, manifest.patches, severity, placeholders));
299
302
  return issues;
300
303
  }
301
304
  /**
@@ -357,8 +360,12 @@ export function enforcePatchPolicy(input) {
357
360
  : '';
358
361
  throw new InvalidArgumentError(`${input.command} would violate patchPolicy:\n${details.join('\n')}${suffix}`, mode === 'force' ? '--force-unsafe' : 'patchPolicy');
359
362
  }
360
- /** Allocates the next available order inside the configured ranges for a category. */
361
- export function allocatePolicyOrder(config, patches, category) {
363
+ /**
364
+ * Allocates the next available order inside the configured ranges for a
365
+ * category. Orders in `reserved` (`patch reserve`) are skipped: they are
366
+ * held for their owner's export.
367
+ */
368
+ export function allocatePolicyOrder(config, patches, category, reserved = new Set()) {
362
369
  const cfg = policy(config);
363
370
  if (!cfg)
364
371
  return null;
@@ -367,7 +374,7 @@ export function allocatePolicyOrder(config, patches, category) {
367
374
  .sort((a, b) => a.from - b.from || a.to - b.to);
368
375
  if (ranges.length === 0)
369
376
  return null;
370
- const occupied = new Set(patches.map((patch) => patch.order));
377
+ const occupied = new Set([...patches.map((patch) => patch.order), ...reserved]);
371
378
  const highestInRanges = patches.reduce((highest, patch) => {
372
379
  if (!ranges.some((range) => patch.order >= range.from && patch.order <= range.to)) {
373
380
  return highest;
@@ -10,8 +10,12 @@
10
10
  * occupied. A patch that later takes the same order fills the slot, and
11
11
  * {@link carryRetiredSlots} drops it on that write, so the manifest never
12
12
  * holds a slot and a patch at one order.
13
+ *
14
+ * Reserved slots (`patch reserve`) follow the same rules: a placeholder for
15
+ * an order a session has claimed but not exported yet, counted as occupied
16
+ * by the gap check and filled (and dropped) by the export that lands on it.
13
17
  */
14
- import type { PatchesManifest, PatchMetadata, PatchRetiredSlot } from '../types/commands/index.js';
18
+ import type { PatchesManifest, PatchMetadata, PatchReservedSlot, PatchRetiredSlot } from '../types/commands/index.js';
15
19
  /**
16
20
  * The retired slots of `current` that no patch in `patches` occupies, or
17
21
  * undefined when none remain. Every manifest write that rebuilds the
@@ -22,14 +26,32 @@ import type { PatchesManifest, PatchMetadata, PatchRetiredSlot } from '../types/
22
26
  * @param patches - The rows the written manifest will hold
23
27
  */
24
28
  export declare function carryRetiredSlots(current: Pick<PatchesManifest, 'retired'> | null | undefined, patches: readonly Pick<PatchMetadata, 'order'>[]): PatchRetiredSlot[] | undefined;
29
+ /**
30
+ * Reserved twin of {@link carryRetiredSlots}: the reservations of `current`
31
+ * that no patch in `patches` has filled yet.
32
+ *
33
+ * @param current - Manifest the slots come from
34
+ * @param patches - The rows the written manifest will hold
35
+ */
36
+ export declare function carryReservedSlots(current: Pick<PatchesManifest, 'reserved'> | null | undefined, patches: readonly Pick<PatchMetadata, 'order'>[]): PatchReservedSlot[] | undefined;
25
37
  /**
26
38
  * Builds a manifest from `patches` that keeps `current`'s unfilled retired
27
- * slots. The `retired` key is omitted entirely when no slot remains, so a
28
- * queue that never retired anything writes the same JSON as before.
39
+ * and reserved slots. Each key is omitted entirely when no slot remains, so
40
+ * a queue that never retired or reserved anything writes the same JSON as
41
+ * before.
29
42
  *
30
43
  * @param current - Manifest the slots come from
31
44
  * @param patches - The rows the written manifest will hold
32
45
  */
33
- export declare function manifestWithRetiredSlots(current: Pick<PatchesManifest, 'retired'> | null | undefined, patches: PatchMetadata[]): PatchesManifest;
46
+ export declare function manifestWithRetiredSlots(current: Pick<PatchesManifest, 'retired' | 'reserved'> | null | undefined, patches: PatchMetadata[]): PatchesManifest;
34
47
  /** The set of order numbers held by retired slots. */
35
48
  export declare function retiredOrderSet(manifest: Pick<PatchesManifest, 'retired'>): Set<number>;
49
+ /** The set of order numbers held by reserved slots. */
50
+ export declare function reservedOrderSet(manifest: Pick<PatchesManifest, 'reserved'>): Set<number>;
51
+ /**
52
+ * The reservation at `order`, if any.
53
+ *
54
+ * @param manifest - Manifest to search (null: none)
55
+ * @param order - Order number
56
+ */
57
+ export declare function reservationAt(manifest: Pick<PatchesManifest, 'reserved'> | null | undefined, order: number): PatchReservedSlot | undefined;
@@ -11,6 +11,10 @@
11
11
  * occupied. A patch that later takes the same order fills the slot, and
12
12
  * {@link carryRetiredSlots} drops it on that write, so the manifest never
13
13
  * holds a slot and a patch at one order.
14
+ *
15
+ * Reserved slots (`patch reserve`) follow the same rules: a placeholder for
16
+ * an order a session has claimed but not exported yet, counted as occupied
17
+ * by the gap check and filled (and dropped) by the export that lands on it.
14
18
  */
15
19
  /**
16
20
  * The retired slots of `current` that no patch in `patches` occupies, or
@@ -32,23 +36,57 @@ export function carryRetiredSlots(current, patches) {
32
36
  .sort((a, b) => a.order - b.order);
33
37
  return kept.length > 0 ? kept : undefined;
34
38
  }
39
+ /**
40
+ * Reserved twin of {@link carryRetiredSlots}: the reservations of `current`
41
+ * that no patch in `patches` has filled yet.
42
+ *
43
+ * @param current - Manifest the slots come from
44
+ * @param patches - The rows the written manifest will hold
45
+ */
46
+ export function carryReservedSlots(current, patches) {
47
+ const slots = current?.reserved;
48
+ if (slots === undefined || slots.length === 0)
49
+ return undefined;
50
+ const occupied = new Set(patches.map((patch) => patch.order));
51
+ const kept = slots
52
+ .filter((slot) => !occupied.has(slot.order))
53
+ .map((slot) => ({ ...slot }))
54
+ .sort((a, b) => a.order - b.order);
55
+ return kept.length > 0 ? kept : undefined;
56
+ }
35
57
  /**
36
58
  * Builds a manifest from `patches` that keeps `current`'s unfilled retired
37
- * slots. The `retired` key is omitted entirely when no slot remains, so a
38
- * queue that never retired anything writes the same JSON as before.
59
+ * and reserved slots. Each key is omitted entirely when no slot remains, so
60
+ * a queue that never retired or reserved anything writes the same JSON as
61
+ * before.
39
62
  *
40
63
  * @param current - Manifest the slots come from
41
64
  * @param patches - The rows the written manifest will hold
42
65
  */
43
66
  export function manifestWithRetiredSlots(current, patches) {
44
67
  const retired = carryRetiredSlots(current, patches);
68
+ const reserved = carryReservedSlots(current, patches);
45
69
  return {
46
70
  version: 1,
47
71
  patches,
48
72
  ...(retired !== undefined ? { retired } : {}),
73
+ ...(reserved !== undefined ? { reserved } : {}),
49
74
  };
50
75
  }
51
76
  /** The set of order numbers held by retired slots. */
52
77
  export function retiredOrderSet(manifest) {
53
78
  return new Set((manifest.retired ?? []).map((slot) => slot.order));
54
79
  }
80
+ /** The set of order numbers held by reserved slots. */
81
+ export function reservedOrderSet(manifest) {
82
+ return new Set((manifest.reserved ?? []).map((slot) => slot.order));
83
+ }
84
+ /**
85
+ * The reservation at `order`, if any.
86
+ *
87
+ * @param manifest - Manifest to search (null: none)
88
+ * @param order - Order number
89
+ */
90
+ export function reservationAt(manifest, order) {
91
+ return manifest?.reserved?.find((slot) => slot.order === order);
92
+ }
@@ -65,6 +65,12 @@ export declare function openRunLog(projectRoot: string, command: string, now?: D
65
65
  export declare function keepRunLogCopy(log: RunLog | undefined, flag: string | undefined, command: string): Promise<RunLog | undefined>;
66
66
  /** Installs (or clears, with `undefined`) the run log for this process. */
67
67
  export declare function setActiveRunLog(log: RunLog | undefined): void;
68
+ /**
69
+ * The 1-based line of the active run log the next write starts on.
70
+ * Redaction rewrites within a line and never adds or drops one, so the
71
+ * number holds for the file on disk.
72
+ */
73
+ export declare function activeRunLogLine(): number;
68
74
  /** Absolute path of the active run log, or undefined when none is open. */
69
75
  export declare function getActiveRunLogPath(): string | undefined;
70
76
  /** Appends to the active run log. A no-op when none is open. */
@@ -271,9 +271,20 @@ export async function keepRunLogCopy(log, flag, command) {
271
271
  * One command runs per process, so a single slot is the whole lifetime.
272
272
  */
273
273
  let activeRunLog;
274
+ /** Newlines written to the active log so far, for line references into it. */
275
+ let activeRunLogNewlines = 0;
274
276
  /** Installs (or clears, with `undefined`) the run log for this process. */
275
277
  export function setActiveRunLog(log) {
276
278
  activeRunLog = log;
279
+ activeRunLogNewlines = 0;
280
+ }
281
+ /**
282
+ * The 1-based line of the active run log the next write starts on.
283
+ * Redaction rewrites within a line and never adds or drops one, so the
284
+ * number holds for the file on disk.
285
+ */
286
+ export function activeRunLogLine() {
287
+ return activeRunLogNewlines + 1;
277
288
  }
278
289
  /** Absolute path of the active run log, or undefined when none is open. */
279
290
  export function getActiveRunLogPath() {
@@ -281,7 +292,12 @@ export function getActiveRunLogPath() {
281
292
  }
282
293
  /** Appends to the active run log. A no-op when none is open. */
283
294
  export function writeToActiveRunLog(chunk) {
284
- activeRunLog?.write(chunk);
295
+ if (activeRunLog === undefined)
296
+ return;
297
+ for (let index = chunk.indexOf('\n'); index !== -1; index = chunk.indexOf('\n', index + 1)) {
298
+ activeRunLogNewlines += 1;
299
+ }
300
+ activeRunLog.write(chunk);
285
301
  }
286
302
  /**
287
303
  * Closes and clears the active run log. Resolves even when none is open.
@@ -22,7 +22,7 @@
22
22
  * reporting phantom test failures (or phantom passes).
23
23
  */
24
24
  export type { HarnessCrashSignature } from './test-harness-signature.js';
25
- import type { HarnessCrashSignature, IncompleteRunEvidence } from './test-harness-signature.js';
25
+ import { type HarnessCrashSignature, type IncompleteRunEvidence } from './test-harness-signature.js';
26
26
  export { headedDisplayAsleepVerdictNote, headedNoOutputTimeoutHint } from './test-stall-triage.js';
27
27
  /** How a completed harness run should be interpreted. */
28
28
  export type HarnessRunClassification = 'tests-ran-ok' | 'test-failures' | 'harness-crash' | 'harness-arguments' | 'no-tests';
@@ -198,6 +198,7 @@ export declare function extractSummaryCounts(output: string): HarnessSummaryCoun
198
198
  export declare function formatFireforgeVerdictLine(verdict: HarnessRunVerdict, shards?: {
199
199
  passed: number;
200
200
  total: number;
201
+ failed?: readonly string[];
201
202
  }): string;
202
203
  /**
203
204
  * Builds the operator-facing failure message for a green-looking summary
@@ -25,6 +25,7 @@
25
25
  import { hasKnownTeardownNoise } from './mach-known-noise-filter.js';
26
26
  import { truncateEvidence, unmarkedFailureEvidenceNote, } from './test-harness-verdict-notes.js';
27
27
  import { classifyProfileArgumentFailure } from './test-profile-files.js';
28
+ import { formatIncompleteSuffix, formatShardsSuffix, } from './test-harness-signature.js';
28
29
  // The no-output-stall triage text lives in `test-stall-triage.ts` (it is
29
30
  // operator advice, not classification) and is re-exported here so the
30
31
  // command layer keeps importing its harness diagnostics from one module.
@@ -659,15 +660,11 @@ function lastCapturedCount(output, pattern) {
659
660
  export function formatFireforgeVerdictLine(verdict, shards) {
660
661
  const counts = (verdict.checks !== undefined ? ` checks=${verdict.checks}` : '') +
661
662
  (verdict.unexpected !== undefined ? ` unexpected=${verdict.unexpected}` : '');
662
- const shardSuffix = shards ? ` shards=${shards.passed}/${shards.total}` : '';
663
- const { started: n, requested: m, firstUnstarted: first } = verdict.incomplete ?? {};
664
- const truncation = first === undefined ? '' : ` started=${n}/${m} first-unstarted=${first}`;
665
- const note = verdict.note !== undefined ? ` (${verdict.note})` : '';
666
- if (verdict.kind === 'tests-ran-ok') {
667
- return `FIREFORGE-VERDICT: PASS${counts}${shardSuffix}${note}`;
668
- }
663
+ const tail = `${formatShardsSuffix(shards)}${verdict.note !== undefined ? ` (${verdict.note})` : ''}`;
664
+ if (verdict.kind === 'tests-ran-ok')
665
+ return `FIREFORGE-VERDICT: PASS${counts}${tail}`;
669
666
  const reason = verdict.kind === 'harness-crash' ? 'crash' : verdict.kind;
670
- return `FIREFORGE-VERDICT: FAIL reason=${reason}${counts}${truncation}${shardSuffix}${note}`;
667
+ return `FIREFORGE-VERDICT: FAIL reason=${reason}${counts}${formatIncompleteSuffix(verdict.incomplete)}${tail}`;
671
668
  }
672
669
  /**
673
670
  * Builds the operator-facing failure message for a green-looking summary
@@ -20,6 +20,17 @@ export interface HarnessCrashSignature {
20
20
  /** The concrete output line the recognition was based on. */
21
21
  line: string;
22
22
  }
23
+ /**
24
+ * The verdict line's `started=<n>/<m> first-unstarted=<path>
25
+ * [started-source=<src>]` keys, or nothing when every file started.
26
+ */
27
+ export declare function formatIncompleteSuffix(evidence: IncompleteRunEvidence | undefined): string;
28
+ /** The verdict line's `shards=<p>/<t> [failed-shards=<labels>]` keys of a sharded run. */
29
+ export declare function formatShardsSuffix(shards: {
30
+ passed: number;
31
+ total: number;
32
+ failed?: readonly string[];
33
+ } | undefined): string;
23
34
  /** Why an exit-0 run that would have read as a pass was failed instead. */
24
35
  export interface IncompleteRunEvidence {
25
36
  /** Requested test files that produced a start (or skip) marker. */
@@ -28,6 +39,11 @@ export interface IncompleteRunEvidence {
28
39
  requested: number;
29
40
  /** First requested file, in dispatch order, that never started. */
30
41
  firstUnstarted?: string;
42
+ /**
43
+ * Which evidence the start count came from, stamped with a short count:
44
+ * the structured raw log, the streamed output, or the capped capture.
45
+ */
46
+ startedSource?: 'raw' | 'stream' | 'capture';
31
47
  /** The harness's "shut down in the middle of a test" line, when present. */
32
48
  shutdownLine?: string;
33
49
  /** The summary's non-zero `Unexpected results:` count, when it printed one. */
@@ -14,4 +14,22 @@
14
14
  * `test-run-completeness.ts` produces it from helpers in
15
15
  * `test-harness-crash.ts`, whose verdict type carries it.
16
16
  */
17
- export {};
17
+ /**
18
+ * The verdict line's `started=<n>/<m> first-unstarted=<path>
19
+ * [started-source=<src>]` keys, or nothing when every file started.
20
+ */
21
+ export function formatIncompleteSuffix(evidence) {
22
+ if (evidence?.firstUnstarted === undefined)
23
+ return '';
24
+ const source = evidence.startedSource === undefined ? '' : ` started-source=${evidence.startedSource}`;
25
+ return ` started=${evidence.started}/${evidence.requested} first-unstarted=${evidence.firstUnstarted}${source}`;
26
+ }
27
+ /** The verdict line's `shards=<p>/<t> [failed-shards=<labels>]` keys of a sharded run. */
28
+ export function formatShardsSuffix(shards) {
29
+ if (shards === undefined)
30
+ return '';
31
+ const failed = shards.failed !== undefined && shards.failed.length > 0
32
+ ? ` failed-shards=${shards.failed.join(',')}`
33
+ : '';
34
+ return ` shards=${shards.passed}/${shards.total}${failed}`;
35
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The mozlog structured ("raw") log of one mach test dispatch.
3
+ *
4
+ * Everything else FireForge reads from a run is human text: the
5
+ * `TEST_START`/`TEST_END` lines and the `Unexpected results:` summary, in two
6
+ * dialects, out of a capture that keeps only its last 2 MB. A gate that
7
+ * wants to name the files a run failed had to regex that text, and agent-
8
+ * quiet mozbuild drops `TEST_START` from it altogether. The raw log is the
9
+ * record the summary itself is computed from: one JSON object per line,
10
+ * with `test_start`, `test_status` and `test_end` actions naming each test
11
+ * by its manifest id.
12
+ *
13
+ * It is requested per dispatch with mozlog's `--log-raw=<path>`, which
14
+ * `mach test`, `mach mochitest` and `mach xpcshell-test` all accept, only
15
+ * when a run asks for per-file results (`--results`, `--shard-size`).
16
+ */
17
+ import type { StartMarkers } from './test-run-completeness.js';
18
+ /** One test file's outcome, from its `test_end` and failing `test_status` records. */
19
+ export interface RawTestResult {
20
+ /** The test id mozlog reports (a manifest-relative path). */
21
+ test: string;
22
+ /** `test_end` status (`OK`, `PASS`, `FAIL`, `ERROR`, `TIMEOUT`, `CRASH`, `SKIP`). */
23
+ status: string;
24
+ /** Expected status; equal to `status` when mozlog omitted it. */
25
+ expected: string;
26
+ /** True when the file ended unexpectedly or any subtest did. */
27
+ unexpected: boolean;
28
+ /** Subtests that reported an unexpected status. */
29
+ unexpectedSubtests: number;
30
+ /** Wall time between `test_start` and `test_end`, when both carried a time. */
31
+ durationMs?: number;
32
+ }
33
+ /** Everything one raw log says about the run. */
34
+ export interface RawTestLog {
35
+ /** Files in `test_end` order. A started file with no end is not listed. */
36
+ results: RawTestResult[];
37
+ /** Start markers for the completeness veto: every `test_start` id. */
38
+ markers: StartMarkers;
39
+ /** Started tests that never ended (a crash or a cut-off run). */
40
+ unfinished: string[];
41
+ }
42
+ /**
43
+ * Parses a mozlog raw log. Unknown actions and malformed lines are skipped,
44
+ * so a log truncated by a crash still yields the files that finished.
45
+ *
46
+ * @param text - The raw log's contents
47
+ */
48
+ export declare function parseRawTestLog(text: string): RawTestLog;
49
+ /**
50
+ * Reads and parses a raw log. Returns undefined when the file is missing or
51
+ * unreadable (the harness never started, or the engine's mach ignored the
52
+ * flag), so callers fall back to the streamed text.
53
+ *
54
+ * @param path - The `--log-raw` destination
55
+ */
56
+ export declare function readRawTestLog(path: string): Promise<RawTestLog | undefined>;
57
+ /**
58
+ * The `--log-raw` destination the operator already forwarded with
59
+ * `--mach-arg`, when it names a file. FireForge then reads that file instead
60
+ * of adding its own flag, so the operator's log is not replaced.
61
+ *
62
+ * @param args - The dispatch's extra mach arguments
63
+ */
64
+ export declare function forwardedRawLogPath(args: readonly string[]): string | undefined;
65
+ /** True when the dispatch arguments already carry any `--log-raw`. */
66
+ export declare function hasForwardedRawLog(args: readonly string[]): boolean;
@@ -0,0 +1,122 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * The mozlog structured ("raw") log of one mach test dispatch.
4
+ *
5
+ * Everything else FireForge reads from a run is human text: the
6
+ * `TEST_START`/`TEST_END` lines and the `Unexpected results:` summary, in two
7
+ * dialects, out of a capture that keeps only its last 2 MB. A gate that
8
+ * wants to name the files a run failed had to regex that text, and agent-
9
+ * quiet mozbuild drops `TEST_START` from it altogether. The raw log is the
10
+ * record the summary itself is computed from: one JSON object per line,
11
+ * with `test_start`, `test_status` and `test_end` actions naming each test
12
+ * by its manifest id.
13
+ *
14
+ * It is requested per dispatch with mozlog's `--log-raw=<path>`, which
15
+ * `mach test`, `mach mochitest` and `mach xpcshell-test` all accept, only
16
+ * when a run asks for per-file results (`--results`, `--shard-size`).
17
+ */
18
+ import { readFile } from 'node:fs/promises';
19
+ function parseRecord(line) {
20
+ if (!line.startsWith('{'))
21
+ return undefined;
22
+ try {
23
+ const parsed = JSON.parse(line);
24
+ return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
25
+ }
26
+ catch {
27
+ // A line cut off by a killed harness is not a record.
28
+ return undefined;
29
+ }
30
+ }
31
+ /**
32
+ * Parses a mozlog raw log. Unknown actions and malformed lines are skipped,
33
+ * so a log truncated by a crash still yields the files that finished.
34
+ *
35
+ * @param text - The raw log's contents
36
+ */
37
+ export function parseRawTestLog(text) {
38
+ const results = [];
39
+ const started = [];
40
+ const startTimes = new Map();
41
+ const subtestFailures = new Map();
42
+ for (const line of text.split(/\r?\n/)) {
43
+ const record = parseRecord(line.trim());
44
+ if (record === undefined || typeof record.test !== 'string')
45
+ continue;
46
+ const test = record.test;
47
+ if (record.action === 'test_start') {
48
+ started.push(test);
49
+ if (typeof record.time === 'number')
50
+ startTimes.set(test, record.time);
51
+ continue;
52
+ }
53
+ if (record.action === 'test_status' && typeof record.expected === 'string') {
54
+ // mozlog sets `expected` on a status record only when it differs.
55
+ subtestFailures.set(test, (subtestFailures.get(test) ?? 0) + 1);
56
+ continue;
57
+ }
58
+ if (record.action !== 'test_end' || typeof record.status !== 'string')
59
+ continue;
60
+ const expected = typeof record.expected === 'string' ? record.expected : record.status;
61
+ const unexpectedSubtests = subtestFailures.get(test) ?? 0;
62
+ const start = startTimes.get(test);
63
+ results.push({
64
+ test,
65
+ status: record.status,
66
+ expected,
67
+ unexpected: expected !== record.status || unexpectedSubtests > 0,
68
+ unexpectedSubtests,
69
+ ...(start !== undefined && typeof record.time === 'number'
70
+ ? { durationMs: record.time - start }
71
+ : {}),
72
+ });
73
+ }
74
+ const ended = new Set(results.map((result) => result.test));
75
+ return {
76
+ results,
77
+ markers: {
78
+ started,
79
+ skipped: results.filter((r) => r.status === 'SKIP').map((r) => r.test),
80
+ },
81
+ unfinished: started.filter((test) => !ended.has(test)),
82
+ };
83
+ }
84
+ /**
85
+ * Reads and parses a raw log. Returns undefined when the file is missing or
86
+ * unreadable (the harness never started, or the engine's mach ignored the
87
+ * flag), so callers fall back to the streamed text.
88
+ *
89
+ * @param path - The `--log-raw` destination
90
+ */
91
+ export async function readRawTestLog(path) {
92
+ try {
93
+ return parseRawTestLog(await readFile(path, 'utf8'));
94
+ }
95
+ catch {
96
+ return undefined;
97
+ }
98
+ }
99
+ /**
100
+ * The `--log-raw` destination the operator already forwarded with
101
+ * `--mach-arg`, when it names a file. FireForge then reads that file instead
102
+ * of adding its own flag, so the operator's log is not replaced.
103
+ *
104
+ * @param args - The dispatch's extra mach arguments
105
+ */
106
+ export function forwardedRawLogPath(args) {
107
+ for (let index = 0; index < args.length; index += 1) {
108
+ const arg = args[index] ?? '';
109
+ const value = arg.startsWith('--log-raw=')
110
+ ? arg.slice('--log-raw='.length)
111
+ : arg === '--log-raw'
112
+ ? args[index + 1]
113
+ : undefined;
114
+ if (value !== undefined)
115
+ return value === '-' ? undefined : value;
116
+ }
117
+ return undefined;
118
+ }
119
+ /** True when the dispatch arguments already carry any `--log-raw`. */
120
+ export function hasForwardedRawLog(args) {
121
+ return args.some((arg) => arg === '--log-raw' || arg.startsWith('--log-raw='));
122
+ }