@hominis/fireforge 0.46.2 → 0.47.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 (48) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/config.js +1 -2
  4. package/dist/src/commands/furnace/deploy-fragment-includers.d.ts +59 -0
  5. package/dist/src/commands/furnace/deploy-fragment-includers.js +120 -0
  6. package/dist/src/commands/furnace/deploy.js +36 -3
  7. package/dist/src/commands/furnace/validation-output.d.ts +5 -0
  8. package/dist/src/commands/furnace/validation-output.js +35 -9
  9. package/dist/src/commands/patch/delete-gap-notice.d.ts +24 -0
  10. package/dist/src/commands/patch/delete-gap-notice.js +46 -0
  11. package/dist/src/commands/patch/delete.js +11 -0
  12. package/dist/src/commands/re-export-lint.d.ts +26 -1
  13. package/dist/src/commands/re-export-lint.js +60 -20
  14. package/dist/src/commands/re-export-scan.d.ts +8 -0
  15. package/dist/src/commands/re-export-scan.js +12 -2
  16. package/dist/src/commands/re-export-single.js +24 -5
  17. package/dist/src/commands/run.js +42 -17
  18. package/dist/src/commands/test-harness-env.d.ts +24 -0
  19. package/dist/src/commands/test-harness-env.js +61 -0
  20. package/dist/src/commands/test-register.js +6 -2
  21. package/dist/src/commands/test-verdict.d.ts +3 -1
  22. package/dist/src/commands/test-verdict.js +17 -2
  23. package/dist/src/commands/test.js +9 -16
  24. package/dist/src/core/build-jar-escalation.d.ts +1 -4
  25. package/dist/src/core/build-jar-escalation.js +1 -4
  26. package/dist/src/core/build-prepare.js +72 -55
  27. package/dist/src/core/firefox-cache.js +1 -2
  28. package/dist/src/core/furnace-apply-output.d.ts +10 -0
  29. package/dist/src/core/furnace-apply-output.js +19 -4
  30. package/dist/src/core/furnace-css-fragments.d.ts +7 -0
  31. package/dist/src/core/furnace-css-fragments.js +12 -3
  32. package/dist/src/core/furnace-fragment-includers.d.ts +56 -0
  33. package/dist/src/core/furnace-fragment-includers.js +122 -0
  34. package/dist/src/core/git-diff-new-file.js +1 -2
  35. package/dist/src/core/git-file-ops.js +1 -2
  36. package/dist/src/core/git.js +1 -2
  37. package/dist/src/core/mach-build-artifacts.js +1 -2
  38. package/dist/src/core/mochitest-server-port.d.ts +43 -4
  39. package/dist/src/core/mochitest-server-port.js +64 -11
  40. package/dist/src/core/patch-lint-imports.js +4 -4
  41. package/dist/src/core/smoke-patterns.d.ts +24 -0
  42. package/dist/src/core/smoke-patterns.js +50 -5
  43. package/dist/src/types/commands/options.d.ts +6 -0
  44. package/docs/configuration.md +8 -0
  45. package/docs/exit-codes.md +19 -19
  46. package/docs/machine-output.md +22 -10
  47. package/docs/testing.md +13 -1
  48. package/package.json +5 -5
@@ -23,6 +23,7 @@
23
23
  * port-generic, rather than growing a second probe.
24
24
  */
25
25
  import { GeneralError } from '../errors/base.js';
26
+ import { isExplicitAbsolutePath, isPathInsideRoot } from '../utils/paths.js';
26
27
  import { exec } from '../utils/process.js';
27
28
  import { probeMarionettePort } from './marionette-port.js';
28
29
  /** Default port the mochitest harness binds its httpd to. */
@@ -43,6 +44,40 @@ export function isMochitestServerHolder(holder) {
43
44
  return false;
44
45
  return /\bxpcshell\b/.test(line) || /\/obj-[^/\s]*\//.test(line) || /\b_tests\b/.test(line);
45
46
  }
47
+ /**
48
+ * Classifies the holder against this project's engine directory. Every
49
+ * absolute path on the command line is tested with {@link isPathInsideRoot}
50
+ * (a token test, not a substring test, so `/a/hominis/engine` does not claim
51
+ * a holder living under `/a/hominis-2/engine`). On POSIX the command line is
52
+ * the full `ps -o args=` output, so the objdir path is always present for a
53
+ * harness httpd; a holder whose paths all lie elsewhere belongs to another
54
+ * checkout.
55
+ *
56
+ * @param holder - The listener found on the port
57
+ * @param engineDir - Absolute engine directory of THIS project
58
+ */
59
+ export function classifyMochitestServerHolder(holder, engineDir) {
60
+ if (!isMochitestServerHolder(holder))
61
+ return 'unrecognized';
62
+ const absoluteTokens = holder.commandLine.split(/\s+/).filter(isExplicitAbsolutePath);
63
+ return absoluteTokens.some((token) => isPathInsideRoot(engineDir, token))
64
+ ? 'this-checkout'
65
+ : 'foreign-checkout';
66
+ }
67
+ /**
68
+ * The objdir a foreign holder is serving from, for the refusal text: the
69
+ * first absolute command-line token with an `obj-…` segment, cut at that
70
+ * segment. Falls back to the first absolute token, then to a placeholder.
71
+ */
72
+ export function describeHolderObjdir(commandLine) {
73
+ const tokens = commandLine.split(/\s+/).filter(isExplicitAbsolutePath);
74
+ for (const token of tokens) {
75
+ const match = /^(.*?[/\\]obj-[^/\\]*)(?:[/\\]|$)/.exec(token);
76
+ if (match?.[1])
77
+ return match[1];
78
+ }
79
+ return tokens[0] ?? '(no path visible on the command line)';
80
+ }
46
81
  /**
47
82
  * Refuses a mochitest dispatch when the harness's server port is held. It
48
83
  * terminates the holder first only for a recognized stale harness httpd,
@@ -57,16 +92,24 @@ export function isMochitestServerHolder(holder) {
57
92
  * Best effort: {@link probeMarionettePort} reports an
58
93
  * unprobeable port as free, so a host without `lsof` runs exactly as before.
59
94
  *
95
+ * A recognized httpd from ANOTHER checkout is a refusal and never a kill
96
+ * either, flag or no flag: it is a peer's live suite, not our debris. The
97
+ * browser-side probe draws the same line for a bare launch it cannot
98
+ * attribute; this one can attribute, because the objdir is on the command
99
+ * line, so it names the other checkout instead of offering `kill -9`.
100
+ *
60
101
  * @param port - Port to probe (default {@link DEFAULT_MOCHITEST_SERVER_PORT})
61
- * @param options - `killStaleServer` opts into terminating a recognized holder
102
+ * @param options - `engineDir` is this project's absolute engine directory,
103
+ * the ownership boundary; `killStaleServer` opts into terminating a holder
104
+ * recognized as THIS checkout's debris
62
105
  */
63
- export async function ensureMochitestServerPortAvailable(port = DEFAULT_MOCHITEST_SERVER_PORT, options = {}) {
106
+ export async function ensureMochitestServerPortAvailable(port = DEFAULT_MOCHITEST_SERVER_PORT, options) {
64
107
  const probe = await probeMarionettePort(port);
65
108
  if (!probe.inUse || !probe.holder)
66
109
  return;
67
110
  const holder = probe.holder;
68
- const recognized = isMochitestServerHolder(holder);
69
- if (recognized && options.killStaleServer === true) {
111
+ const holderClass = classifyMochitestServerHolder(holder, options.engineDir);
112
+ if (holderClass === 'this-checkout' && options.killStaleServer === true) {
70
113
  try {
71
114
  if (process.platform === 'win32') {
72
115
  await exec('powershell.exe', [
@@ -87,16 +130,16 @@ export async function ensureMochitestServerPortAvailable(port = DEFAULT_MOCHITES
87
130
  // pretending the port is now free.
88
131
  }
89
132
  }
90
- throw new GeneralError(describeMochitestServerRefusal(port, holder, recognized));
133
+ throw new GeneralError(describeMochitestServerRefusal(port, holder, holderClass));
91
134
  }
92
135
  /**
93
136
  * Builds the operator-facing refusal. Exported for direct unit testing.
94
137
  *
95
138
  * @param port - The probed port
96
139
  * @param holder - The listener found on it
97
- * @param recognized - Whether it is the harness's own `server.js`
140
+ * @param holderClass - Whose harness `server.js` it is, if it is one at all
98
141
  */
99
- export function describeMochitestServerRefusal(port, holder, recognized) {
142
+ export function describeMochitestServerRefusal(port, holder, holderClass) {
100
143
  const killHint = process.platform === 'win32'
101
144
  ? `Stop-Process -Id ${holder.pid} -Force`
102
145
  : `kill -9 ${holder.pid}`;
@@ -106,11 +149,21 @@ export function describeMochitestServerRefusal(port, holder, recognized) {
106
149
  " A browser launched now would connect to THAT server, which cannot serve this run's " +
107
150
  'manifest — the run would stall before TEST_START and die on the no-output timeout with ' +
108
151
  '"Ran 0 checks".\n';
109
- if (recognized) {
152
+ if (holderClass === 'this-checkout') {
153
+ return (evidence +
154
+ " This is the mochitest harness's own server.js from THIS checkout's objdir, so it is " +
155
+ 'debris from an interrupted run. Retry with "--kill-stale-marionette" to have FireForge ' +
156
+ `stop it, or stop it yourself with "${killHint}" (a wedged httpd can ignore SIGTERM).`);
157
+ }
158
+ if (holderClass === 'foreign-checkout') {
159
+ // No kill hint on this branch, on purpose: the PID is a peer's live
160
+ // suite, and "kill -9 <pid>" is exactly the instruction that would have
161
+ // destroyed it.
110
162
  return (evidence +
111
- " This is the mochitest harness's own server.js, so it is debris from an interrupted run. " +
112
- 'Retry with "--kill-stale-marionette" to have FireForge stop it, or stop it yourself with ' +
113
- `"${killHint}" (a wedged httpd can ignore SIGTERM).`);
163
+ ` This is a mochitest httpd from ANOTHER checkout: ${describeHolderObjdir(holder.commandLine)}. ` +
164
+ 'It is serving a live run there, so FireForge will not stop it and "--kill-stale-marionette" ' +
165
+ 'will not either. Wait for that run to finish or free the port yourself, then retry. ' +
166
+ `Diagnose with "lsof -nP -iTCP:${port}".`);
114
167
  }
115
168
  return (evidence +
116
169
  " This is NOT the mochitest harness's server.js, so FireForge will not offer to kill it. " +
@@ -54,14 +54,14 @@ export function hasRelativeImport(content) {
54
54
  try {
55
55
  return astHasRelativeImport(content, 'module');
56
56
  }
57
- catch (moduleError) {
58
- void moduleError;
57
+ catch {
58
+ // Not parseable as an ES module; try classic-script mode next.
59
59
  }
60
60
  try {
61
61
  return astHasRelativeImport(content, 'script');
62
62
  }
63
- catch (scriptError) {
64
- void scriptError;
63
+ catch {
64
+ // Not parseable at all; fall back to stripped-text matching below.
65
65
  }
66
66
  return RELATIVE_IMPORT_FALLBACK_PATTERN.test(stripJsComments(content));
67
67
  }
@@ -29,6 +29,12 @@ export interface CompiledAllowlistEntry {
29
29
  source: string;
30
30
  /** Human-readable origin: `allow.txt:12` or `--console-allow #2`. */
31
31
  origin: string;
32
+ /**
33
+ * Ceiling from a `# max-hits: N` directive above the entry: how often the
34
+ * suppressed cause can physically fire in one boot. More hits than this
35
+ * is a flood wearing an allowlisted shape, and fails the run.
36
+ */
37
+ maxHits?: number;
32
38
  }
33
39
  /**
34
40
  * Returns the index of the FIRST entry in `allow` matching `line`, or -1.
@@ -43,8 +49,26 @@ export declare function matchAllowlist(line: string, allow: readonly CompiledAll
43
49
  * compiled as a RegExp with its `<file>:<line>` origin retained. A bad
44
50
  * pattern throws immediately: better to fail fast at CLI parse time than
45
51
  * to silently let a typo match nothing.
52
+ *
53
+ * A `# max-hits: N` comment sets the ceiling of the entry that follows it
54
+ * (other comment lines may sit between). A blank line or end of file with
55
+ * no entry after the directive is an error rather than a silently dropped
56
+ * cap, since a cap the operator wrote and the run ignored is the exact
57
+ * shape of failure this directive exists to prevent.
46
58
  */
47
59
  export declare function compileAllowlistFromFile(body: string, sourcePath: string): CompiledAllowlistEntry[];
60
+ /** An allowlist entry that matched more lines than its `# max-hits` ceiling. */
61
+ export interface OverCapAllowlistEntry {
62
+ entry: CompiledAllowlistEntry;
63
+ hits: number;
64
+ maxHits: number;
65
+ }
66
+ /**
67
+ * Entries whose per-entry hit count (every matched line, error-class or
68
+ * not: the `N×` number the attribution block prints and the operator tunes
69
+ * against) exceeds their ceiling. Entries without a ceiling never appear.
70
+ */
71
+ export declare function findOverCapAllowlistEntries(allow: readonly CompiledAllowlistEntry[], hits: readonly number[]): OverCapAllowlistEntry[];
48
72
  /**
49
73
  * Compiles an array of regex-string inputs (e.g. repeated `--console-allow`
50
74
  * flag values). Same fail-fast semantics as {@link compileAllowlistFromFile}.
@@ -56,6 +56,14 @@ export function matchesSmokeError(line) {
56
56
  }
57
57
  return false;
58
58
  }
59
+ /** `# max-hits: N` on its own comment line, case-insensitive. */
60
+ const MAX_HITS_DIRECTIVE = /^#\s*max-hits:\s*(\S+)\s*$/i;
61
+ function parseMaxHitsDirective(raw, at) {
62
+ if (!/^[1-9]\d*$/.test(raw)) {
63
+ throw new Error(`Invalid allowlist max-hits directive at ${at}: expected a positive integer (got "${raw}")`);
64
+ }
65
+ return Number.parseInt(raw, 10);
66
+ }
59
67
  /**
60
68
  * Returns the index of the FIRST entry in `allow` matching `line`, or -1.
61
69
  * First-match attribution is deterministic and cheap. A line matching
@@ -71,30 +79,67 @@ export function matchAllowlist(line, allow) {
71
79
  * compiled as a RegExp with its `<file>:<line>` origin retained. A bad
72
80
  * pattern throws immediately: better to fail fast at CLI parse time than
73
81
  * to silently let a typo match nothing.
82
+ *
83
+ * A `# max-hits: N` comment sets the ceiling of the entry that follows it
84
+ * (other comment lines may sit between). A blank line or end of file with
85
+ * no entry after the directive is an error rather than a silently dropped
86
+ * cap, since a cap the operator wrote and the run ignored is the exact
87
+ * shape of failure this directive exists to prevent.
74
88
  */
75
89
  export function compileAllowlistFromFile(body, sourcePath) {
76
90
  const lines = body.split(/\r?\n/);
77
91
  const compiled = [];
92
+ let pending;
93
+ const assertNoOrphanDirective = () => {
94
+ if (pending) {
95
+ throw new Error(`Invalid allowlist max-hits directive at ${pending.at}: no entry follows it (the ceiling applies to the next non-comment line)`);
96
+ }
97
+ };
78
98
  lines.forEach((raw, index) => {
79
99
  const line = raw.trim();
80
- if (!line || line.startsWith('#'))
100
+ const at = `${sourcePath}:${index + 1}`;
101
+ if (!line) {
102
+ assertNoOrphanDirective();
81
103
  return;
104
+ }
105
+ if (line.startsWith('#')) {
106
+ const directive = MAX_HITS_DIRECTIVE.exec(line);
107
+ if (directive)
108
+ pending = { maxHits: parseMaxHitsDirective(directive[1] ?? '', at), at };
109
+ return;
110
+ }
82
111
  try {
83
112
  compiled.push({
84
113
  pattern: new RegExp(line),
85
114
  source: line,
86
- origin: `${sourcePath}:${index + 1}`,
115
+ origin: at,
116
+ ...(pending ? { maxHits: pending.maxHits } : {}),
87
117
  });
88
118
  }
89
119
  catch (error) {
90
120
  const message = toError(error).message;
91
- throw new Error(`Invalid allowlist regex at ${sourcePath}:${index + 1}: ${message}`, {
92
- cause: error,
93
- });
121
+ throw new Error(`Invalid allowlist regex at ${at}: ${message}`, { cause: error });
94
122
  }
123
+ pending = undefined;
95
124
  });
125
+ assertNoOrphanDirective();
96
126
  return compiled;
97
127
  }
128
+ /**
129
+ * Entries whose per-entry hit count (every matched line, error-class or
130
+ * not: the `N×` number the attribution block prints and the operator tunes
131
+ * against) exceeds their ceiling. Entries without a ceiling never appear.
132
+ */
133
+ export function findOverCapAllowlistEntries(allow, hits) {
134
+ const over = [];
135
+ allow.forEach((entry, index) => {
136
+ const count = hits[index] ?? 0;
137
+ if (entry.maxHits !== undefined && count > entry.maxHits) {
138
+ over.push({ entry, hits: count, maxHits: entry.maxHits });
139
+ }
140
+ });
141
+ return over;
142
+ }
98
143
  /**
99
144
  * Compiles an array of regex-string inputs (e.g. repeated `--console-allow`
100
145
  * flag values). Same fail-fast semantics as {@link compileAllowlistFromFile}.
@@ -655,6 +655,12 @@ export interface TestOptions extends EngineLockWaitable {
655
655
  * checkers that consume a sample artifact after the run.
656
656
  */
657
657
  perfSamples?: string;
658
+ /**
659
+ * Seeded file-order shuffle for mochitest runs. `true` draws a fresh seed;
660
+ * a number replays one. Forwarded as mach `--shuffle` with the seed
661
+ * exported as `FIREFORGE_SHUFFLE_SEED` and stamped on the verdict line.
662
+ */
663
+ shuffle?: number | boolean;
658
664
  }
659
665
  /**
660
666
  * Options for the furnace apply command.
@@ -219,6 +219,14 @@ non-CI launches print a warning saying so. Exit codes 12 and 13 separate a
219
219
  console regression from a launch failure. See
220
220
  [`exit-codes.md`](exit-codes.md).
221
221
 
222
+ The allowlist (`--console-allow-file`) is count-blind by default: a line that
223
+ matches an entry never counts, however often it fires. A `# max-hits: N`
224
+ comment directly above an entry (other comments may sit between) caps how
225
+ many lines that entry may match in one run. The attribution block prints
226
+ capped entries as `hits×/N`, and an entry that fires past its ceiling is
227
+ reported and fails the run with exit 12, the same as an unallowed error. A
228
+ directive with no entry after it is refused rather than silently ignored.
229
+
222
230
  ## Lock waits
223
231
 
224
232
  Every FireForge command that mutates the engine takes the engine-session
@@ -7,25 +7,25 @@ branches on it, and CI uses it to decide whether to retry or escalate. The
7
7
  values live in `src/errors/codes.ts`. This file says what they mean and
8
8
  which error class produces each one.
9
9
 
10
- | Code | Name | Meaning | Produced by |
11
- | ---: | ---------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
12
- | 0 | `SUCCESS` | Completed. Also a run that prompted and the operator declined: declining is a successful run that chose not to proceed. | (none) |
13
- | 1 | `GENERAL_ERROR` | Unspecified failure the operator has to act on. | `GeneralError` (and `PreflightRefusalError`), `ParserFallbackError`, `ExecTimeoutError` |
14
- | 2 | `CONFIG_ERROR` | `fireforge.json` missing or invalid. | `ConfigError` and subclasses |
15
- | 3 | `DOWNLOAD_ERROR` | Could not download or extract Firefox source. | `DownloadError` and subclasses |
16
- | 4 | `GIT_ERROR` | A git operation failed. | `GitError` and subclasses, except `GitNotFoundError` |
17
- | 5 | `BUILD_ERROR` | `mach build` failed, or a test suite went red. | `BuildError` and subclasses, `TestFailureError` |
18
- | 6 | `PATCH_ERROR` | A patch failed to apply, or the patch queue could not be mutated. | `PatchError`, `PatchManifestCorruptError`, `RebaseError` and subclasses |
19
- | 7 | `MISSING_DEPENDENCY` | A required tool (python3, git, tar) was not found. | `PythonNotFoundError`, `GitNotFoundError`, `MachNotFoundError` |
20
- | 8 | `INVALID_ARGUMENT` | The flags were wrong. A usage problem, not an environment one. | `InvalidArgumentError`, plus commander usage errors (unknown command or option, missing argument) |
21
- | 9 | `FURNACE_ERROR` | A Furnace component operation failed. | `FurnaceError` |
22
- | 10 | `RESOLUTION_ERROR` | Conflict resolution failed. | `ResolutionError` |
23
- | 11 | `INTERNAL_ERROR` | A FireForge invariant did not hold. This is a bug in FireForge, not something the operator can fix. | `InternalInvariantError` |
24
- | 12 | `SMOKE_EXIT_FAILURE` | `run --smoke-exit` saw console errors that were not allowed in the smoke window. | `SmokeRunError` |
25
- | 13 | `SMOKE_LAUNCH_FAILURE` | `run --smoke-exit` saw the browser exit uncleanly before the window elapsed. | `SmokeRunError` |
26
- | 14 | `INCONCLUSIVE` | A test run's verdict was thrown away because `engine/` moved while the harness ran. This is not red. Re-run it. | `InconclusiveVerdictError` |
27
- | 15 | `LOCK_TIMEOUT` | A lock wait expired, so the run never started. Re-queue it with a larger `--wait-lock`. | `LockContentionError` |
28
- | 130 | `USER_CANCELLED` | The operator interrupted a prompt (Esc or Ctrl+C). 128 + SIGINT. | `CancellationError` |
10
+ | Code | Name | Meaning | Produced by |
11
+ | ---: | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
12
+ | 0 | `SUCCESS` | Completed. Also a run that prompted and the operator declined: declining is a successful run that chose not to proceed. | (none) |
13
+ | 1 | `GENERAL_ERROR` | Unspecified failure the operator has to act on. | `GeneralError` (and `PreflightRefusalError`), `ParserFallbackError`, `ExecTimeoutError` |
14
+ | 2 | `CONFIG_ERROR` | `fireforge.json` missing or invalid. | `ConfigError` and subclasses |
15
+ | 3 | `DOWNLOAD_ERROR` | Could not download or extract Firefox source. | `DownloadError` and subclasses |
16
+ | 4 | `GIT_ERROR` | A git operation failed. | `GitError` and subclasses, except `GitNotFoundError` |
17
+ | 5 | `BUILD_ERROR` | `mach build` failed, or a test suite went red. | `BuildError` and subclasses, `TestFailureError` |
18
+ | 6 | `PATCH_ERROR` | A patch failed to apply, or the patch queue could not be mutated. | `PatchError`, `PatchManifestCorruptError`, `RebaseError` and subclasses |
19
+ | 7 | `MISSING_DEPENDENCY` | A required tool (python3, git, tar) was not found. | `PythonNotFoundError`, `GitNotFoundError`, `MachNotFoundError` |
20
+ | 8 | `INVALID_ARGUMENT` | The flags were wrong. A usage problem, not an environment one. | `InvalidArgumentError`, plus commander usage errors (unknown command or option, missing argument) |
21
+ | 9 | `FURNACE_ERROR` | A Furnace component operation failed. | `FurnaceError` |
22
+ | 10 | `RESOLUTION_ERROR` | Conflict resolution failed. | `ResolutionError` |
23
+ | 11 | `INTERNAL_ERROR` | A FireForge invariant did not hold. This is a bug in FireForge, not something the operator can fix. | `InternalInvariantError` |
24
+ | 12 | `SMOKE_EXIT_FAILURE` | `run --smoke-exit` saw console errors that were not allowed in the smoke window, or an allowlisted shape fired past its `# max-hits` ceiling. | `SmokeRunError` |
25
+ | 13 | `SMOKE_LAUNCH_FAILURE` | `run --smoke-exit` saw the browser exit uncleanly before the window elapsed. | `SmokeRunError` |
26
+ | 14 | `INCONCLUSIVE` | A test run's verdict was thrown away because `engine/` moved while the harness ran. This is not red. Re-run it. | `InconclusiveVerdictError` |
27
+ | 15 | `LOCK_TIMEOUT` | A lock wait expired, so the run never started. Re-queue it with a larger `--wait-lock`. | `LockContentionError` |
28
+ | 130 | `USER_CANCELLED` | The operator interrupted a prompt (Esc or Ctrl+C). 128 + SIGINT. | `CancellationError` |
29
29
 
30
30
  Usage errors that commander detects itself (an unknown command, an unknown
31
31
  or malformed option, a missing required argument) never reach a command
@@ -53,16 +53,16 @@ if (options.json === true) {
53
53
 
54
54
  ## Current surfaces
55
55
 
56
- | Surface | Shape | Notes |
57
- | ---------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
- | `status --json` | `{ schemaVersion: 1, … }` | Error envelope on three engine preconditions |
59
- | `tree list --json` | `{ schemaVersion: 1, trees }` | Error envelope added in 0.44.0 |
60
- | `token list --json` | `{ schemaVersion: 1, tokensCssPath, categories }` | Categories in file order, each with its tokens (`name`, `line`, `value`) |
61
- | `token show --json` | `{ schemaVersion: 1, name, category, declarations }` | One entry per declaring block, with its selector trail and line |
62
- | `test` verdict line | `FIREFORGE-VERDICT: PASS\|FAIL reason=… [note=<class>] [log=<path>]` | Not JSON. A single final stdout line, sealed by `setStdoutSealed` so nothing can displace it. `log=` names the run's own complete output under `.fireforge/logs/`, so a piped or truncated run still leaves a re-readable artifact |
63
- | `lint --per-patch --report <path>` | `{ schemaVersion: 1, … }` | Written to a file, never read back by FireForge |
64
- | `status --raw` | `git status --porcelain` lines | Not JSON, but still a machine surface: it engages the same stdout discipline, so rules 3 and 4 apply and rules 1 and 2 do not. A clean tree prints nothing |
65
- | `build` log announcement | `Full build output: <path>` | Not JSON. `build` prints no verdict line, so it names its run log directly on the way out |
56
+ | Surface | Shape | Notes |
57
+ | ---------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
+ | `status --json` | `{ schemaVersion: 1, … }` | Error envelope on three engine preconditions |
59
+ | `tree list --json` | `{ schemaVersion: 1, trees }` | Error envelope added in 0.44.0 |
60
+ | `token list --json` | `{ schemaVersion: 1, tokensCssPath, categories }` | Categories in file order, each with its tokens (`name`, `line`, `value`) |
61
+ | `token show --json` | `{ schemaVersion: 1, name, category, declarations }` | One entry per declaring block, with its selector trail and line |
62
+ | `test` verdict line | `FIREFORGE-VERDICT: PASS\|FAIL reason=… [note=<class>] [shuffle=<seed>] [log=<path>]` | Not JSON. A single final stdout line, sealed by `setStdoutSealed` so nothing can displace it. `log=` names the run's own complete output under `.fireforge/logs/`, so a piped or truncated run still leaves a re-readable artifact |
63
+ | `lint --per-patch --report <path>` | `{ schemaVersion: 1, … }` | Written to a file, never read back by FireForge |
64
+ | `status --raw` | `git status --porcelain` lines | Not JSON, but still a machine surface: it engages the same stdout discipline, so rules 3 and 4 apply and rules 1 and 2 do not. A clean tree prints nothing |
65
+ | `build` log announcement | `Full build output: <path>` | Not JSON. `build` prints no verdict line, so it names its run log directly on the way out |
66
66
 
67
67
  `--raw` is why `cli.ts` reads `process.argv` for `--json` or `--raw` before
68
68
  dispatching. Rule 3 is about who owns stdout, and that question is settled
@@ -112,6 +112,18 @@ consumers should treat an unrecognised one as opaque. Current values:
112
112
  `stale-browser`, `stale-browser-kill-failed`, `marionette-port-busy`,
113
113
  `coverage-replaced`, `stale-build`, `stale-components`.
114
114
 
115
+ ### The additive `shuffle=` key
116
+
117
+ A `fireforge test --shuffle [seed]` run forwards mach's `--shuffle` (an
118
+ unseeded shuffle of file order in the harness) and exports the seed FireForge
119
+ drew or was given as `FIREFORGE_SHUFFLE_SEED` in the harness environment. The
120
+ seed is appended to the verdict line as `shuffle=<seed>` on every emission
121
+ form, so a red found by shuffling carries its own reproduction
122
+ (`fireforge test --shuffle=<seed> …`) on the one line a pipe keeps. Reordering
123
+ tasks _inside_ a file is harness code that reads the exported seed; FireForge
124
+ owns the seed, the plumbing and the record. Absent when the run did not
125
+ shuffle.
126
+
115
127
  A preflight refusal's own text is written to stdout before the verdict line,
116
128
  and into the run log, so the verdict stays the last stdout write while the
117
129
  reason survives both a redirect and a `tail`. See
package/docs/testing.md CHANGED
@@ -101,7 +101,7 @@ a slow build for a silently wrong test.
101
101
  Every test run ends with one machine-readable line:
102
102
 
103
103
  ```
104
- FIREFORGE-VERDICT: PASS|FAIL reason=… [note=<class>] [log=<path>]
104
+ FIREFORGE-VERDICT: PASS|FAIL reason=… [note=<class>] [shuffle=<seed>] [log=<path>]
105
105
  ```
106
106
 
107
107
  Automation should branch on this line rather than on the raw process code,
@@ -117,6 +117,18 @@ text is written to stdout before the verdict line and into the run log, so a
117
117
  redirected run keeps the reason as well as the verdict. See
118
118
  [`run-logs.md`](run-logs.md).
119
119
 
120
+ ## Seeded shuffle
121
+
122
+ `fireforge test --shuffle` runs a mochitest selection in a seeded random file
123
+ order and prints the seed; `--shuffle=<seed>` replays it. FireForge forwards
124
+ mach's own `--shuffle`, exports `FIREFORGE_SHUFFLE_SEED=<seed>` to the harness
125
+ so in-file task reordering (harness `head.js` code, not FireForge) can key off
126
+ the same number, and stamps `shuffle=<seed>` on the verdict line. It is
127
+ mochitest-only: the xpcshell harness has no shuffle, and generic `mach test`
128
+ dispatch would hand the flag to every suite. Write the flag as
129
+ `--shuffle=<seed>` or after the paths, because a bare `--shuffle <path>` reads
130
+ the path as the seed.
131
+
120
132
  Exit code 14 (`INCONCLUSIVE`) is not red: it means `engine/` moved while the
121
133
  harness ran and the result was thrown away. Exit 15 (`LOCK_TIMEOUT`) means
122
134
  the run never started. See [`exit-codes.md`](exit-codes.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.46.2",
3
+ "version": "0.47.0",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -66,8 +66,8 @@
66
66
  "devDependencies": {
67
67
  "@eslint/js": "^10.0.0",
68
68
  "@types/estree": "^1.0.8",
69
- "@types/node": "^22.0.0",
70
- "@vitest/coverage-v8": "^4.1.2",
69
+ "@types/node": "^26.4.1",
70
+ "@vitest/coverage-v8": "^5.0.0",
71
71
  "dpdm": "4.3.0",
72
72
  "eslint": "^10.0.0",
73
73
  "eslint-config-prettier": "^10.1.8",
@@ -75,14 +75,14 @@
75
75
  "eslint-plugin-simple-import-sort": "^14.0.0",
76
76
  "fast-check": "^4.6.0",
77
77
  "husky": "^9.1.7",
78
- "knip": "6.32.3",
78
+ "knip": "6.35.1",
79
79
  "lint-staged": "^17.0.4",
80
80
  "prettier": "3.9.6",
81
81
  "tsx": "^4.7.0",
82
82
  "typescript": "~6.0.0",
83
83
  "typescript-eslint": "^8.0.0",
84
84
  "vite": "^8.0.7",
85
- "vitest": "^4.0.18"
85
+ "vitest": "^5.0.0"
86
86
  },
87
87
  "engines": {
88
88
  "node": ">=22.22.2"