@hominis/fireforge 0.48.0 → 0.49.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 (101) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/cli.js +16 -28
  4. package/dist/src/commands/build.js +30 -4
  5. package/dist/src/commands/doctor-engine-eslint.d.ts +3 -0
  6. package/dist/src/commands/doctor-engine-eslint.js +12 -0
  7. package/dist/src/commands/doctor-macos-sdk.js +7 -5
  8. package/dist/src/commands/doctor-source-pin.js +9 -2
  9. package/dist/src/commands/doctor.js +2 -0
  10. package/dist/src/commands/furnace/apply.js +10 -4
  11. package/dist/src/commands/furnace/create-templates.js +0 -3
  12. package/dist/src/commands/lint.js +4 -0
  13. package/dist/src/commands/manifest.d.ts +1 -2
  14. package/dist/src/commands/patch/delete.js +18 -4
  15. package/dist/src/commands/re-export-options.js +7 -0
  16. package/dist/src/commands/re-export-register.js +1 -0
  17. package/dist/src/commands/re-export-removed.d.ts +4 -0
  18. package/dist/src/commands/re-export-removed.js +49 -0
  19. package/dist/src/commands/re-export-single.js +4 -1
  20. package/dist/src/commands/re-export.js +4 -0
  21. package/dist/src/commands/test-browser-preflight.d.ts +28 -0
  22. package/dist/src/commands/test-browser-preflight.js +181 -0
  23. package/dist/src/commands/test-build-phase.js +30 -7
  24. package/dist/src/commands/test-checkjs.d.ts +3 -0
  25. package/dist/src/commands/test-checkjs.js +27 -0
  26. package/dist/src/commands/test-diagnose.js +3 -0
  27. package/dist/src/commands/test-harness-env.d.ts +5 -6
  28. package/dist/src/commands/test-harness-env.js +27 -7
  29. package/dist/src/commands/test-modes.d.ts +2 -4
  30. package/dist/src/commands/test-register.js +11 -5
  31. package/dist/src/commands/test-run.d.ts +2 -0
  32. package/dist/src/commands/test-run.js +21 -1
  33. package/dist/src/commands/test-verdict.d.ts +2 -2
  34. package/dist/src/commands/test.js +83 -211
  35. package/dist/src/core/build-baseline-types.d.ts +12 -14
  36. package/dist/src/core/build-baseline-validate.d.ts +3 -0
  37. package/dist/src/core/build-baseline-validate.js +53 -0
  38. package/dist/src/core/build-baseline.d.ts +4 -3
  39. package/dist/src/core/build-baseline.js +23 -11
  40. package/dist/src/core/component-source-guard.d.ts +20 -0
  41. package/dist/src/core/component-source-guard.js +68 -0
  42. package/dist/src/core/coverage-extend.d.ts +5 -34
  43. package/dist/src/core/coverage-extend.js +26 -37
  44. package/dist/src/core/engine-write-boundary.d.ts +5 -0
  45. package/dist/src/core/engine-write-boundary.js +41 -0
  46. package/dist/src/core/furnace-apply-helpers.js +8 -2
  47. package/dist/src/core/furnace-apply.js +7 -0
  48. package/dist/src/core/furnace-validate-accessibility.js +9 -12
  49. package/dist/src/core/furnace-validate.js +0 -43
  50. package/dist/src/core/gecko-dom-types.d.ts +2 -0
  51. package/dist/src/core/gecko-dom-types.js +17 -0
  52. package/dist/src/core/lit-template-regions.d.ts +4 -0
  53. package/dist/src/core/lit-template-regions.js +50 -0
  54. package/dist/src/core/mach-build-artifacts.d.ts +6 -6
  55. package/dist/src/core/mach-build-artifacts.js +13 -7
  56. package/dist/src/core/mach-resource-shim.d.ts +1 -1
  57. package/dist/src/core/mach-resource-shim.js +2 -0
  58. package/dist/src/core/marionette-port.d.ts +2 -0
  59. package/dist/src/core/marionette-port.js +38 -53
  60. package/dist/src/core/mochitest-server-port.d.ts +1 -1
  61. package/dist/src/core/mochitest-server-port.js +20 -17
  62. package/dist/src/core/objdir-maintenance.d.ts +10 -0
  63. package/dist/src/core/objdir-maintenance.js +151 -0
  64. package/dist/src/core/patch-lint-checkjs.js +2 -1
  65. package/dist/src/core/preflight-wait.d.ts +2 -0
  66. package/dist/src/core/preflight-wait.js +24 -0
  67. package/dist/src/core/process-owner.d.ts +9 -0
  68. package/dist/src/core/process-owner.js +43 -0
  69. package/dist/src/core/test-harness-crash.d.ts +1 -1
  70. package/dist/src/core/test-harness-crash.js +5 -5
  71. package/dist/src/core/test-host-state.d.ts +14 -0
  72. package/dist/src/core/test-host-state.js +80 -0
  73. package/dist/src/core/test-profile-files.d.ts +17 -0
  74. package/dist/src/core/test-profile-files.js +75 -0
  75. package/dist/src/core/test-stale-check.d.ts +6 -7
  76. package/dist/src/core/test-stale-check.js +9 -32
  77. package/dist/src/core/token-category.d.ts +7 -26
  78. package/dist/src/core/token-category.js +68 -121
  79. package/dist/src/core/token-inventory.js +12 -7
  80. package/dist/src/core/typecheck.js +6 -6
  81. package/dist/src/types/commands/options.d.ts +14 -3
  82. package/dist/src/utils/assert.js +2 -7
  83. package/dist/src/utils/child-env.d.ts +0 -3
  84. package/dist/src/utils/child-env.js +0 -3
  85. package/dist/src/utils/concurrency.d.ts +2 -2
  86. package/dist/src/utils/concurrency.js +15 -4
  87. package/dist/src/utils/fs.js +11 -1
  88. package/dist/src/utils/options.d.ts +16 -1
  89. package/dist/src/utils/options.js +5 -3
  90. package/dist/src/utils/paths.js +3 -2
  91. package/dist/src/utils/process-group.d.ts +1 -2
  92. package/dist/src/utils/process-group.js +1 -2
  93. package/dist/src/utils/process-input.js +0 -3
  94. package/dist/src/utils/process.d.ts +3 -2
  95. package/dist/src/utils/process.js +19 -7
  96. package/docs/configuration.md +12 -11
  97. package/docs/lifecycle-invariants.md +9 -5
  98. package/docs/machine-output.md +36 -19
  99. package/docs/patch-workflow.md +15 -0
  100. package/docs/testing.md +77 -12
  101. package/package.json +4 -3
@@ -8,12 +8,12 @@
8
8
  * categories a project actually has, so the only way to name one correctly
9
9
  * was to hand-parse a neighbouring `= Category =` banner out of the file.
10
10
  * This module is that report. It reuses the banner and
11
- * `:root`-bounds primitives from `token-category.ts` rather than parsing
11
+ * category-region model from `token-category.ts` rather than parsing
12
12
  * tokens CSS a second way: two readers that disagree on what a banner is
13
13
  * would put `token list` and `token add` in different sections of the same
14
14
  * file.
15
15
  */
16
- import { categoryBannerNameAt, findBaseRootBounds, maskCommentLines } from './token-category.js';
16
+ import { findBaseRootBounds, maskCommentLines, parseTokenCategorySections, } from './token-category.js';
17
17
  /** Matches a custom-property declaration and captures its name and value. */
18
18
  const DECLARATION_PATTERN = /^\s*(--[A-Za-z0-9_-]+)\s*:\s*([^;]*);?/;
19
19
  /**
@@ -32,14 +32,18 @@ export function collectTokenInventory(lines) {
32
32
  if (bounds === undefined || bounds.close === -1)
33
33
  return [];
34
34
  const masked = maskCommentLines(lines);
35
+ const sections = parseTokenCategorySections(lines);
36
+ const headers = new Map(sections.map((section) => [section.categoryLine, section]));
35
37
  const groups = [];
36
38
  let current;
39
+ let sectionEnd = bounds.close;
37
40
  for (let i = bounds.open + 1; i < bounds.close; i++) {
38
- // Banners are read from the raw lines: `maskCommentLines` blanks comment
39
- // bodies, which is exactly what a banner is.
40
- const banner = categoryBannerNameAt(lines, i);
41
- if (banner !== undefined) {
42
- current = { category: banner, tokens: [] };
41
+ if (i >= sectionEnd)
42
+ current = undefined;
43
+ const section = headers.get(i);
44
+ if (section !== undefined) {
45
+ current = { category: section.name, tokens: [] };
46
+ sectionEnd = section.sectionEnd;
43
47
  groups.push(current);
44
48
  continue;
45
49
  }
@@ -48,6 +52,7 @@ export function collectTokenInventory(lines) {
48
52
  continue;
49
53
  if (current === undefined) {
50
54
  current = { category: null, tokens: [] };
55
+ sectionEnd = bounds.close;
51
56
  groups.push(current);
52
57
  }
53
58
  current.tokens.push({ name: match[1], line: i + 1, value: (match[2] ?? '').trim() });
@@ -26,6 +26,7 @@ import { sha256Hex } from '../utils/hash.js';
26
26
  import { verbose } from '../utils/logger.js';
27
27
  import { getPackageVersion } from '../utils/package-root.js';
28
28
  import { normalizePathSlashes } from '../utils/paths.js';
29
+ import { withGeckoDomTypes } from './gecko-dom-types.js';
29
30
  import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
30
31
  /**
31
32
  * Sentinel string surfaced as a `TypecheckIssue.message` when the
@@ -252,7 +253,7 @@ async function runTypecheckForProject(ts, projectRoot, projectPath, shimSource,
252
253
  // filtered out below, so the pass reports failures that do not exist.
253
254
  const shimPath = normalizePathSlashes(resolve(projectDir, `.fireforge-${SHIM_FILENAME}`));
254
255
  const rootFiles = [...parsed.fileNames, shimPath];
255
- const defaultHost = ts.createCompilerHost(options);
256
+ const defaultHost = withGeckoDomTypes(ts, ts.createCompilerHost(options));
256
257
  const host = {
257
258
  ...defaultHost,
258
259
  getSourceFile(fileName, languageVersion, onError, shouldCreate) {
@@ -293,11 +294,10 @@ async function runTypecheckForProject(ts, projectRoot, projectPath, shimSource,
293
294
  program = ts.createProgram(rootFiles, options, host);
294
295
  }
295
296
  else {
296
- const builder = ts.createIncrementalProgram({
297
- rootNames: rootFiles,
298
- options: { ...options, incremental: true, tsBuildInfoFile: buildInfoFile },
299
- host: withSourceFileVersions(host),
300
- });
297
+ // Always check the entire project in root order. Restoring TypeScript's
298
+ // per-file semantic cache makes checkJs inference depend on the first
299
+ // invalidated importer and can both invent and hide diagnostics.
300
+ const builder = ts.createEmitAndSemanticDiagnosticsBuilderProgram(rootFiles, { ...options, incremental: true, tsBuildInfoFile: buildInfoFile }, withSourceFileVersions(host));
301
301
  program = builder;
302
302
  // Under noEmit, emit() writes the build info and nothing else.
303
303
  writeBuildInfo = () => {
@@ -109,6 +109,8 @@ export interface SourceSetOptions {
109
109
  * Options for the build command.
110
110
  */
111
111
  export interface BuildOptions extends EngineLockWaitable {
112
+ /** Wait for the objdir browser before deploying/building. */
113
+ waitBrowser?: number | boolean;
112
114
  /** Fast UI-only rebuild */
113
115
  ui?: boolean;
114
116
  /** Number of parallel jobs */
@@ -368,6 +370,8 @@ export interface ReExportOptions extends EngineLockWaitable {
368
370
  * rather than hidden.
369
371
  */
370
372
  expectUnmanaged?: string[];
373
+ /** Explicitly retire named absent patch-created files in a scan-less export. */
374
+ expectRemoved?: string[];
371
375
  }
372
376
  /**
373
377
  * Options for the `fireforge patch tier` subcommand. Sets or clears the
@@ -551,6 +555,12 @@ export interface RunOptions {
551
555
  * Options for the test command.
552
556
  */
553
557
  export interface TestOptions extends EngineLockWaitable {
558
+ /** Queue behind a browser using this objdir; bare flag means 60 seconds. */
559
+ waitBrowser?: number | boolean;
560
+ /** Queue behind the mochitest/Marionette port holder. */
561
+ waitPort?: number | boolean;
562
+ /** Copy a file into the profile at a relative destination (repeatable source=destination). */
563
+ profileFile?: string[];
554
564
  /** Run tests in headless mode */
555
565
  headless?: boolean;
556
566
  /**
@@ -675,9 +685,10 @@ export interface TestOptions extends EngineLockWaitable {
675
685
  */
676
686
  perfSamples?: string;
677
687
  /**
678
- * Seeded file-order shuffle for mochitest runs. `true` draws a fresh seed;
679
- * a number replays one. Forwarded as mach `--shuffle` with the seed
680
- * exported as `FIREFORGE_SHUFFLE_SEED` and stamped on the verdict line.
688
+ * Seeded isolated path-argument order for mochitest runs. `true` draws a
689
+ * fresh seed; a number replays the shard permutation. Native mach
690
+ * `--shuffle` within a shard remains unseeded. Also exported as
691
+ * `FIREFORGE_SHUFFLE_SEED` for custom task code and recorded on the verdict.
681
692
  */
682
693
  shuffle?: number | boolean;
683
694
  }
@@ -11,13 +11,8 @@
11
11
  * says the opposite: nothing the operator could have done would have
12
12
  * prevented it.
13
13
  *
14
- * Every check routes through a helper here rather than being open-coded
15
- * as `if (!condition) throw`. An inline throw adds a
16
- * permanently-uncovered branch to every function it appears in, and the
17
- * per-module coverage floors in `scripts/check-coverage-thresholds.mjs`
18
- * would pay for it in ~40 places. Routed through a call, the branch lives
19
- * here once and is covered here once, and the `complexity` ceiling in
20
- * `eslint.config.js` does not see it at all.
14
+ * Shared helpers provide consistent classification and TypeScript narrowing,
15
+ * and keep expensive failure descriptions lazy on successful paths.
21
16
  *
22
17
  * This module is a leaf: it imports the error class and nothing else, per
23
18
  * the dependency-direction note in `src/utils/errors.ts`.
@@ -1,8 +1,5 @@
1
1
  /**
2
2
  * Environment construction for spawned children.
3
- *
4
- * Its own module rather than a helper inside `process.ts` only because that
5
- * file sits on a 500-line budget. The logic belongs to the exec layer.
6
3
  */
7
4
  /** The `env` / `envUnset` slice of exec options this module needs. */
8
5
  interface ChildEnvOptions {
@@ -1,9 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
3
  * Environment construction for spawned children.
4
- *
5
- * Its own module rather than a helper inside `process.ts` only because that
6
- * file sits on a 500-line budget. The logic belongs to the exec layer.
7
4
  */
8
5
  /**
9
6
  * The child's environment: `process.env`, with `env` merged over it, then
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Bounded-concurrency helpers shared by commands that fan out per-file IO
3
3
  * (status classification, import's unmanaged-dirty guard, re-export scans,
4
- * the dry-run purity guard). Kept tiny: one order-preserving mapper, no
5
- * queues, no cancellation.
4
+ * the dry-run purity guard). On failure, stop scheduling new items and await
5
+ * every started operation before rejecting, so callers can safely clean up.
6
6
  */
7
7
  /** Maps items with at most `limit` in-flight promises. Preserves order. */
8
8
  export declare function mapWithConcurrency<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
@@ -1,21 +1,32 @@
1
1
  /**
2
2
  * Bounded-concurrency helpers shared by commands that fan out per-file IO
3
3
  * (status classification, import's unmanaged-dirty guard, re-export scans,
4
- * the dry-run purity guard). Kept tiny: one order-preserving mapper, no
5
- * queues, no cancellation.
4
+ * the dry-run purity guard). On failure, stop scheduling new items and await
5
+ * every started operation before rejecting, so callers can safely clean up.
6
6
  */
7
7
  /** Maps items with at most `limit` in-flight promises. Preserves order. */
8
8
  export async function mapWithConcurrency(items, limit, fn) {
9
+ if (!Number.isSafeInteger(limit) || limit < 1) {
10
+ throw new RangeError('Concurrency limit must be a positive integer');
11
+ }
9
12
  const results = new Array(items.length);
10
13
  let next = 0;
14
+ const state = {};
11
15
  const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
12
- for (;;) {
16
+ while (!state.failure) {
13
17
  const index = next++;
14
18
  if (index >= items.length)
15
19
  return;
16
- results[index] = await fn(items[index], index);
20
+ try {
21
+ results[index] = await fn(items[index], index);
22
+ }
23
+ catch (error) {
24
+ state.failure ??= { error };
25
+ }
17
26
  }
18
27
  });
19
28
  await Promise.all(workers);
29
+ if (state.failure)
30
+ throw state.failure.error;
20
31
  return results;
21
32
  }
@@ -99,7 +99,17 @@ export async function isSymlink(path) {
99
99
  */
100
100
  export async function copyFile(src, dest) {
101
101
  await ensureParentDir(dest);
102
- await fsCopyFile(src, dest);
102
+ // Never follow a destination symlink or truncate a shared hardlink:
103
+ // build/deploy copies must not write through into components/ or a peer.
104
+ const tempPath = createAtomicTempPath(dest);
105
+ try {
106
+ await fsCopyFile(src, tempPath);
107
+ await renameWithRetries(tempPath, dest);
108
+ await syncParentDir(dest);
109
+ }
110
+ finally {
111
+ await rm(tempPath, { force: true });
112
+ }
103
113
  }
104
114
  /**
105
115
  * Reads a JSON file and parses it.
@@ -89,7 +89,22 @@ export declare function resolveWaitLockSeconds(value: string | number | boolean
89
89
  */
90
90
  export declare function sessionWaitLockMs(): number | undefined;
91
91
  /**
92
- * Recursively gives every command in the tree a `--wait-lock` flag: the
92
+ * Registers `--wait-lock [seconds]` on a command that takes no lock, so a
93
+ * scripted sequence can blanket-append the flag without hitting a usage
94
+ * error. A command that rejects it with "unknown option" kills the whole
95
+ * sequence with a usage error instead of a lock message.
96
+ *
97
+ * The flag is accepted and ignored here, never silently repurposed, and
98
+ * the help text says so, so nobody reads its presence as evidence that this
99
+ * command waits for anything.
100
+ *
101
+ * The value is parsed and validated exactly as the honoring variant does:
102
+ * `--wait-lock nonsense` must still be a usage error everywhere, or the
103
+ * uniformity would be a lie.
104
+ */
105
+ export declare function addAcceptedWaitLockOption(command: Command): Command;
106
+ /**
107
+ * Recursively gives every leaf command in the tree a `--wait-lock` flag: the
93
108
  * honoring registration where one already exists, the accept-and-ignore
94
109
  * one everywhere else.
95
110
  */
@@ -182,7 +182,7 @@ export function sessionWaitLockMs() {
182
182
  * `--wait-lock nonsense` must still be a usage error everywhere, or the
183
183
  * uniformity would be a lie.
184
184
  */
185
- function addAcceptedWaitLockOption(command) {
185
+ export function addAcceptedWaitLockOption(command) {
186
186
  return command.option('--wait-lock [seconds]', 'Accepted for scripting uniformity and ignored: this command takes no FireForge lock', commanderArgParser((raw) => resolveWaitLockSeconds(raw)));
187
187
  }
188
188
  /**
@@ -194,13 +194,15 @@ function hasWaitLockOption(command) {
194
194
  return command.options.some((option) => option.long === '--wait-lock');
195
195
  }
196
196
  /**
197
- * Recursively gives every command in the tree a `--wait-lock` flag: the
197
+ * Recursively gives every leaf command in the tree a `--wait-lock` flag: the
198
198
  * honoring registration where one already exists, the accept-and-ignore
199
199
  * one everywhere else.
200
200
  */
201
201
  export function ensureWaitLockOptionEverywhere(command) {
202
202
  for (const sub of command.commands) {
203
- if (!hasWaitLockOption(sub))
203
+ // Commander groups otherwise capture a trailing leaf option before the
204
+ // action can see it. Register the uniform flag only on actionable leaves.
205
+ if (sub.commands.length === 0 && !hasWaitLockOption(sub))
204
206
  addAcceptedWaitLockOption(sub);
205
207
  ensureWaitLockOptionEverywhere(sub);
206
208
  }
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
3
  const WINDOWS_ABSOLUTE_PATH = /^[a-zA-Z]:[\\/]/;
4
4
  const RELATIVE_PATH_ROOT = resolve('/__fireforge_path_root__');
5
5
  /**
@@ -55,7 +55,8 @@ export function isPathInsideRoot(root, candidate) {
55
55
  : resolve(resolvedRoot, candidate);
56
56
  const relativePath = relative(resolvedRoot, resolvedCandidate);
57
57
  return (relativePath === '' ||
58
- (!relativePath.startsWith('..') &&
58
+ (relativePath !== '..' &&
59
+ !relativePath.startsWith(`..${sep}`) &&
59
60
  !isAbsolute(relativePath) &&
60
61
  !WINDOWS_ABSOLUTE_PATH.test(relativePath)));
61
62
  }
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Process-group kill and post-run sweep helpers for the exec layer. Split
3
- * out of `process.ts` to keep that file within the per-file line budget.
2
+ * Process-group kill and post-run sweep helpers for the exec layer.
4
3
  * Spawn-based rather than `exec`-based so the two modules do not import
5
4
  * each other cyclically.
6
5
  */
@@ -1,7 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
- * Process-group kill and post-run sweep helpers for the exec layer. Split
4
- * out of `process.ts` to keep that file within the per-file line budget.
3
+ * Process-group kill and post-run sweep helpers for the exec layer.
5
4
  * Spawn-based rather than `exec`-based so the two modules do not import
6
5
  * each other cyclically.
7
6
  */
@@ -1,9 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
3
  * Stdin-fed command execution with byte-exact stdout.
4
- *
5
- * Its own module rather than a function in `process.ts` only because that
6
- * file sits on a 500-line budget. The logic belongs to the exec layer.
7
4
  */
8
5
  import { spawn } from 'node:child_process';
9
6
  import { buildChildEnv } from './child-env.js';
@@ -150,8 +150,9 @@ export declare function execStream(command: string, args: string[], options?: St
150
150
  * Registers a child whose shutdown {@link waitForActiveChildShutdown} must
151
151
  * wait for, and returns the handle that deregisters it.
152
152
  *
153
- * @returns `settle()`. Call from both the child's `close` and `error`
154
- * handlers so a dead child never holds the shutdown wait open
153
+ * @returns `settle()`. Call after child closure and required teardown finish.
154
+ * An abort's `error` event does not prove closure; do not settle there.
155
+ * A failed start may settle when no live child or teardown remains.
155
156
  */
156
157
  export declare function trackChildClosure(): {
157
158
  settle: () => void;
@@ -4,6 +4,7 @@ import { constants as osConstants } from 'node:os';
4
4
  import { StringDecoder } from 'node:string_decoder';
5
5
  import { ExecTimeoutError } from '../errors/base.js';
6
6
  import { buildChildEnv } from './child-env.js';
7
+ import { toError } from './errors.js';
7
8
  import { dispatchCompleteLines } from './line-dispatch.js';
8
9
  import { killProcessTree, sweepProcessGroup } from './process-group.js';
9
10
  // 50 MB cap per stream to prevent OOM. Large toolchain builds (e.g. Firefox, Chromium)
@@ -169,6 +170,7 @@ async function spawnTracked(spec) {
169
170
  ? installGracefulShutdownForwarder(child, spec.graceMs, killTarget)
170
171
  : undefined;
171
172
  const closure = trackChildClosure();
173
+ let rejection;
172
174
  child.on('error', (error) => {
173
175
  // Abort/startup failure: make sure a partially-started tree does not
174
176
  // outlive the dispatch (a mach dying at startup used to strand
@@ -176,15 +178,24 @@ async function spawnTracked(spec) {
176
178
  if (options.processGroup === true) {
177
179
  killProcessTree(child, 'SIGKILL', usesProcessGroup);
178
180
  }
179
- forwarder?.dispose();
180
- closure.settle();
181
- reject(toExecRejection(error, command, args, options.timeout, spec.rejectionOutput?.()));
181
+ // An abort emits error before close. Keep the dispatch tracked until
182
+ // the child closes and both teardown sweeps finish, including on timeout.
183
+ rejection = toExecRejection(error, command, args, options.timeout, spec.rejectionOutput?.());
182
184
  });
183
185
  child.on('close', (code, signal) => {
184
186
  forwarder?.dispose();
185
187
  const finish = () => {
186
188
  closure.settle();
187
- resolve(spec.result(code, signal));
189
+ if (rejection)
190
+ reject(rejection);
191
+ else {
192
+ try {
193
+ resolve(spec.result(code, signal));
194
+ }
195
+ catch (error) {
196
+ reject(toError(error));
197
+ }
198
+ }
188
199
  };
189
200
  // Group sweep first (it takes the helpers that stayed in the group),
190
201
  // then the caller's own sweep (the ones that did not), then settle.
@@ -194,7 +205,7 @@ async function spawnTracked(spec) {
194
205
  finish();
195
206
  return;
196
207
  }
197
- sweep().then(finish, finish);
208
+ Promise.resolve().then(sweep).then(finish, finish);
198
209
  };
199
210
  if (groupPid !== undefined) {
200
211
  sweepProcessGroup(groupPid).then(callerSweep, callerSweep);
@@ -265,8 +276,9 @@ const activeChildClosures = new Set();
265
276
  * Registers a child whose shutdown {@link waitForActiveChildShutdown} must
266
277
  * wait for, and returns the handle that deregisters it.
267
278
  *
268
- * @returns `settle()`. Call from both the child's `close` and `error`
269
- * handlers so a dead child never holds the shutdown wait open
279
+ * @returns `settle()`. Call after child closure and required teardown finish.
280
+ * An abort's `error` event does not prove closure; do not settle there.
281
+ * A failed start may settle when no live child or teardown remains.
270
282
  */
271
283
  export function trackChildClosure() {
272
284
  let resolveClosed;
@@ -149,17 +149,18 @@ When `patchLint.checkJs` is on, the same command also runs the per-patch
149
149
  checkJs pass that `export` enforces, so a green `typecheck` means
150
150
  export-clean types.
151
151
 
152
- Each project keeps incremental build info in `.fireforge/typecheck/`, one
153
- file per project. A warm run re-checks only the files whose content changed
154
- since the last run. The file name carries a key over everything outside the
155
- program's own files that can change a verdict: the composed shim, the
156
- TypeScript version, the compiler options, `typecheck.undefinedIdentifiers`
157
- and the FireForge version. A change to any of them selects a new file, so a
158
- stale verdict is never replayed, and the project's other build-info files are
159
- deleted. `fireforge typecheck --no-cache` checks every project from scratch
160
- and neither reads nor writes the build info. A jsconfig's own `incremental`
161
- and `tsBuildInfoFile` are still ignored, so nothing is written under
162
- `engine/`.
152
+ Project build info is written in `.fireforge/typecheck/`, outside `engine/`,
153
+ with a key covering the shim, TypeScript version, compiler options and
154
+ FireForge version. Diagnostics always use a fresh project
155
+ program: prior incremental diagnostic state is never restored. Cached and
156
+ `--no-cache` runs therefore check the same complete file set. `--no-cache`
157
+ also disables build-info writes. A jsconfig's own `incremental` and
158
+ `tsBuildInfoFile` remain ignored.
159
+
160
+ Both project and per-patch checkJs provide Gecko DOM constructor
161
+ `isInstance(value)` predicates with instance narrowing, plus
162
+ `MouseEvent.MOZ_SOURCE_PEN`. These declarations retain normal DOM member
163
+ checking; they do not turn constructors or instances into `any`.
163
164
 
164
165
  ## `patchPolicy`
165
166
 
@@ -113,11 +113,15 @@ command, read the decision table at the bottom first.
113
113
  carry primary shebangs, and mach rebuilds them in-tree), and runs
114
114
  `mach configure` inside the tree so the configure-generated root files are
115
115
  regenerated against the tree's paths rather than keeping the primary's. It
116
- then verifies exactly that set afterwards (config.status, backend.mk,
117
- Makefile, config/autoconf.mk, via `findObjdirRelocationViolation`, pinned
118
- by `mach-objdir-relocation.test.ts`). Nested Makefiles are products of the
119
- verified config.status, and `.deps` build products are out of scope as
120
- read-only staleness that the first in-tree rebuild corrects. Finally it
116
+ Before configure, relocation rewrites old-source symlinks, `.pp`/`.d`,
117
+ FasterMake and Cargo fingerprint metadata, and removes copied
118
+ `config.statusd/*/config.track` inventories. Those inventories contain
119
+ absolute WRITE destinations and must never be consumed from a clone.
120
+ Aliased objdirs or partial-config directories are refused. Afterwards
121
+ `findObjdirRelocationViolation` verifies config.status, backend.mk,
122
+ Makefile, config/autoconf.mk and every remaining objdir symlink for the
123
+ old source prefix. External toolchain links are allowed; source links
124
+ must no longer consult the old checkout. Finally it
121
125
  copies `last-build.json` so the in-tree stale-build gates anchor
122
126
  correctly.
123
127
 
@@ -86,15 +86,16 @@ moved.
86
86
  `FIREFORGE-VERDICT: FAIL reason=<reason>` uses a closed set
87
87
  (`FireforgeVerdictReason`, `src/commands/test-verdict.ts`):
88
88
 
89
- | Reason | Meaning |
90
- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
91
- | `crash` | The harness or the browser died, so there is no trustworthy suite result. |
92
- | `no-tests` | The run dispatched but nothing ran. |
93
- | `test-failures` | The suite ran and reported unexpected results. |
94
- | `preflight` | The run was refused before the harness was reached. |
95
- | `inconclusive` | A result exists, but `engine/` moved under it, so the result was discarded. |
96
- | `lock-timeout` | The run never started: the engine session lock stayed contended. |
97
- | `killed` | A signal terminated the run, or FireForge ended it because its parent process vanished. Written from the signal handler (or the parent-exit watchdog) so that a log tail always describes itself. A killed run must never be silent, or "killed", "still running" and "never started" all look alike. `signal=` names the cause: a signal name, or `parent-exit`. |
89
+ | Reason | Meaning |
90
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
91
+ | `crash` | The harness or the browser died, so there is no trustworthy suite result. |
92
+ | `harness-arguments` | Profile or other harness argument setup failed before any tests could run; a rebuild is not the repair. |
93
+ | `no-tests` | The run dispatched but nothing ran. |
94
+ | `test-failures` | The suite ran and reported unexpected results. |
95
+ | `preflight` | The run was refused before the harness was reached. |
96
+ | `inconclusive` | A result exists, but `engine/` moved under it or a perf run changed power source, so the result was discarded. |
97
+ | `lock-timeout` | The run never started: the engine session lock stayed contended. |
98
+ | `killed` | A signal terminated the run, or FireForge ended it because its parent process vanished. Written from the signal handler (or the parent-exit watchdog) so that a log tail always describes itself. A killed run must never be silent, or "killed", "still running" and "never started" all look alike. `signal=` names the cause: a signal name, or `parent-exit`. |
98
99
 
99
100
  ### The additive `note=` key
100
101
 
@@ -109,20 +110,19 @@ unchanged and still comes from the closed set above, so a consumer that
109
110
  tokenises `key=value` pairs is unaffected, and a gate that names no class
110
111
  emits no `note=` at all. The classes are not a closed set and may grow, so
111
112
  consumers should treat an unrecognised one as opaque. Current values:
112
- `stale-browser`, `stale-browser-kill-failed`, `marionette-port-busy`,
113
+ `browser-busy`, `stale-browser`, `stale-browser-kill-failed`, `marionette-port-busy`, `mochitest-port-busy`,
113
114
  `coverage-replaced`, `stale-build`, `stale-components`.
114
115
 
115
116
  ### The additive `shuffle=` key
116
117
 
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.
118
+ A `fireforge test --shuffle [seed]` run records the seed used to permute
119
+ FireForge's isolated path-argument shards. Repeating the seed and selection
120
+ repeats that shard order. It also forwards native mach `--shuffle`, which
121
+ randomizes files within an invocation without using this seed. `--no-shard`
122
+ disables FireForge's seeded argument permutation. The seed is exported as
123
+ `FIREFORGE_SHUFFLE_SEED` for custom in-file task code and appended as
124
+ `shuffle=<seed>` to every verdict form. This field does not claim replayable
125
+ native harness file order. Absent when shuffle was not requested.
126
126
 
127
127
  ### The additive `orphans-reaped=` key
128
128
 
@@ -141,3 +141,20 @@ A preflight refusal's own text is written to stdout before the verdict line,
141
141
  and into the run log, so the verdict stays the last stdout write while the
142
142
  reason survives both a redirect and a `tail`. See
143
143
  [`run-logs.md`](run-logs.md).
144
+
145
+ ### Additive host and perf keys
146
+
147
+ `host-load=<number>` records the one-minute load at suite startup. Each suite
148
+ also samples the highest CPU process before and after dispatch. A contention
149
+ warning appends `host-cpu-warning=true`; the diagnostic names the process
150
+ and measured CPU. These diagnostics annotate a test failure without changing
151
+ its classifier reason.
152
+
153
+ Perf runs append `power-source=ac|battery|unknown`. On macOS the source is
154
+ read from `pmset -g ps`, so charging on AC is correctly `ac`. A known source
155
+ transition appends `power-changed=true` and refuses the run as
156
+ `reason=inconclusive`. The producer's sample JSON retains its fields and
157
+ gains `fireforgeHost: { start, end, powerSourceChanged }`, where each host
158
+ state includes `load`, `power` and an optional `topProcess` with CPU and
159
+ command. Consumers should tolerate this additive object. Unknown power
160
+ state is visible but never treated as a proven transition.
@@ -56,6 +56,21 @@ requested path produces no hunk. That is checked against the full requested
56
56
  list rather than the subset left after filtering, so nothing dropped upstream
57
57
  can pass as a silent success.
58
58
 
59
+ An absent file originally created by a selected patch can be retired with
60
+ repeatable `--expect-removed <path>` on scan-less re-export:
61
+
62
+ ```sh
63
+ fireforge re-export 202 --refuse-adjacent-unmanaged --refuse-foreign-drift --expect browser/themes/shared/hominis/widgets.css --expect-removed browser/themes/shared/hominis/stop.svg
64
+ ```
65
+
66
+ The removal must have exactly one selected owner, be absent from disk and
67
+ engine HEAD, and have a `new file mode` section in that owner's patch.
68
+ Every removal is validated before writes, in dry-run too. Other paths keep
69
+ both refusal checks; retirement grants no unmanaged or foreign-drift
70
+ exception. Removing every file would empty the patch and requires `patch
71
+ drop` instead. `--expect-removed` cannot combine with `--scan`, `--files` or
72
+ `--all`.
73
+
59
74
  ## Queue maintenance
60
75
 
61
76
  Under `fireforge patch`: