@hominis/fireforge 0.37.0 → 0.39.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 (99) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/dist/src/commands/build.js +13 -8
  4. package/dist/src/commands/config.js +1 -0
  5. package/dist/src/commands/discard.js +75 -50
  6. package/dist/src/commands/download.js +5 -2
  7. package/dist/src/commands/export-flow.d.ts +6 -2
  8. package/dist/src/commands/export-flow.js +17 -11
  9. package/dist/src/commands/export-placement-gate.js +4 -6
  10. package/dist/src/commands/export-placement-policy.d.ts +11 -3
  11. package/dist/src/commands/export-placement-policy.js +32 -29
  12. package/dist/src/commands/export-shared.js +17 -2
  13. package/dist/src/commands/export.js +5 -5
  14. package/dist/src/commands/furnace/index.js +23 -18
  15. package/dist/src/commands/import.js +5 -0
  16. package/dist/src/commands/lint-per-patch.js +15 -5
  17. package/dist/src/commands/lint.js +1 -1
  18. package/dist/src/commands/patch/lint-ignore.js +20 -1
  19. package/dist/src/commands/patch/move-files-create.d.ts +19 -0
  20. package/dist/src/commands/patch/move-files-create.js +116 -0
  21. package/dist/src/commands/patch/move-files-into.d.ts +47 -0
  22. package/dist/src/commands/patch/move-files-into.js +245 -0
  23. package/dist/src/commands/patch/move-files.d.ts +3 -1
  24. package/dist/src/commands/patch/move-files.js +74 -15
  25. package/dist/src/commands/patch/rename.js +4 -1
  26. package/dist/src/commands/patch/split.d.ts +11 -1
  27. package/dist/src/commands/patch/split.js +6 -5
  28. package/dist/src/commands/re-export-scan.d.ts +17 -0
  29. package/dist/src/commands/re-export-scan.js +51 -0
  30. package/dist/src/commands/re-export.js +17 -6
  31. package/dist/src/commands/reset.js +5 -0
  32. package/dist/src/commands/source.js +28 -3
  33. package/dist/src/commands/status-output.d.ts +9 -0
  34. package/dist/src/commands/status-output.js +33 -1
  35. package/dist/src/commands/status.js +17 -3
  36. package/dist/src/commands/test-diagnose.d.ts +1 -1
  37. package/dist/src/commands/test-diagnose.js +9 -3
  38. package/dist/src/commands/test-modes.d.ts +21 -0
  39. package/dist/src/commands/test-modes.js +42 -0
  40. package/dist/src/commands/test-register.js +6 -5
  41. package/dist/src/commands/test-run.d.ts +2 -0
  42. package/dist/src/commands/test-run.js +8 -2
  43. package/dist/src/commands/test-stale-gate.d.ts +44 -0
  44. package/dist/src/commands/test-stale-gate.js +81 -0
  45. package/dist/src/commands/test.js +52 -81
  46. package/dist/src/core/build-audit.d.ts +12 -0
  47. package/dist/src/core/build-audit.js +14 -0
  48. package/dist/src/core/build-baseline-types.d.ts +41 -0
  49. package/dist/src/core/build-baseline.d.ts +6 -1
  50. package/dist/src/core/build-baseline.js +48 -7
  51. package/dist/src/core/build-prepare.d.ts +9 -0
  52. package/dist/src/core/build-prepare.js +24 -0
  53. package/dist/src/core/config-paths.d.ts +1 -1
  54. package/dist/src/core/config-paths.js +1 -0
  55. package/dist/src/core/config-validate.js +14 -1
  56. package/dist/src/core/discard-baseline.d.ts +60 -0
  57. package/dist/src/core/discard-baseline.js +232 -0
  58. package/dist/src/core/engine-session-lock.d.ts +10 -1
  59. package/dist/src/core/engine-session-lock.js +28 -2
  60. package/dist/src/core/file-lock.d.ts +33 -0
  61. package/dist/src/core/file-lock.js +32 -6
  62. package/dist/src/core/firefox-archive.d.ts +2 -1
  63. package/dist/src/core/firefox-archive.js +32 -8
  64. package/dist/src/core/firefox.d.ts +8 -3
  65. package/dist/src/core/firefox.js +11 -6
  66. package/dist/src/core/furnace-validate-helpers.js +28 -1
  67. package/dist/src/core/license-headers.d.ts +24 -0
  68. package/dist/src/core/license-headers.js +103 -1
  69. package/dist/src/core/patch-export.d.ts +10 -1
  70. package/dist/src/core/patch-export.js +12 -2
  71. package/dist/src/core/patch-lint-checkjs.d.ts +6 -1
  72. package/dist/src/core/patch-lint-checkjs.js +30 -14
  73. package/dist/src/core/patch-lint-cross.d.ts +9 -1
  74. package/dist/src/core/patch-lint-cross.js +69 -9
  75. package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
  76. package/dist/src/core/patch-lint-mozbuild.js +104 -0
  77. package/dist/src/core/patch-lint.d.ts +3 -0
  78. package/dist/src/core/patch-lint.js +29 -8
  79. package/dist/src/core/patch-policy.d.ts +14 -1
  80. package/dist/src/core/patch-policy.js +13 -2
  81. package/dist/src/core/status-classify.d.ts +11 -2
  82. package/dist/src/core/status-classify.js +49 -25
  83. package/dist/src/core/test-harness-crash.d.ts +10 -0
  84. package/dist/src/core/test-harness-crash.js +16 -0
  85. package/dist/src/core/test-stale-check.d.ts +56 -1
  86. package/dist/src/core/test-stale-check.js +114 -4
  87. package/dist/src/core/token-manager.js +39 -2
  88. package/dist/src/core/token-variant.d.ts +7 -0
  89. package/dist/src/core/token-variant.js +9 -0
  90. package/dist/src/core/typecheck-shim.d.ts +13 -0
  91. package/dist/src/core/typecheck-shim.js +17 -2
  92. package/dist/src/core/typecheck.js +14 -3
  93. package/dist/src/types/commands/options.d.ts +69 -3
  94. package/dist/src/types/config.d.ts +22 -0
  95. package/dist/src/utils/options.d.ts +20 -0
  96. package/dist/src/utils/options.js +39 -0
  97. package/dist/src/utils/validation.d.ts +5 -0
  98. package/dist/src/utils/validation.js +7 -0
  99. package/package.json +3 -3
@@ -10,17 +10,16 @@ import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionette
10
10
  import { buildHarnessCrashMessage } from '../core/test-harness-crash.js';
11
11
  import { createPostRebuildFailureContext } from '../core/test-harness-output.js';
12
12
  import { analyzeTestPathScopes, formatScopeNotice, } from '../core/test-path-scope.js';
13
- import { checkStaleBuildForTest, findUncoveredRequestPaths, formatStaleBuildWarning, formatTestCoverageRefusal, } from '../core/test-stale-check.js';
14
- import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
15
13
  import { GeneralError } from '../errors/base.js';
16
14
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
17
15
  import { toError } from '../utils/errors.js';
18
16
  import { pathExists } from '../utils/fs.js';
19
- import { info, intro, outro, spinner, success, verbose, warn } from '../utils/logger.js';
17
+ import { info, intro, outro, spinner, success, verbose } from '../utils/logger.js';
20
18
  import { stripEnginePrefix } from '../utils/paths.js';
21
19
  import { diagnoseShardOutcome, finalizeSingleRunOutcome } from './test-diagnose.js';
22
- import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, reportCanaryOutcome, resolveCanaryPath, } from './test-modes.js';
20
+ import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, classifyBeforeDispatch, reportCanaryOutcome, resolveCanaryPath, } from './test-modes.js';
23
21
  import { DEFAULT_HARNESS_RETRIES, runShardedTests, runTestsWithRetries, } from './test-run.js';
22
+ import { enforceStaleBuildGate, enforceStaticComponentsGate } from './test-stale-gate.js';
24
23
  async function assertTestPathsExist(engineDir, testPaths) {
25
24
  const missingPaths = [];
26
25
  for (const testPath of testPaths) {
@@ -34,19 +33,6 @@ async function assertTestPathsExist(engineDir, testPaths) {
34
33
  throw new GeneralError(`Test path${missingPaths.length === 1 ? '' : 's'} not found under engine/: ${missingPaths.join(', ')}\n\n` +
35
34
  'If you expected these files to come from your patch stack, run "fireforge import" first.');
36
35
  }
37
- async function classifyTestHarnesses(engineDir, normalizedPaths) {
38
- const result = { xpcshell: [], nonXpcshell: [] };
39
- for (const testPath of normalizedPaths) {
40
- const manifest = await findNearestXpcshellManifest(engineDir, testPath);
41
- if (manifest) {
42
- result.xpcshell.push(testPath);
43
- }
44
- else {
45
- result.nonXpcshell.push(testPath);
46
- }
47
- }
48
- return result;
49
- }
50
36
  /**
51
37
  * Picks the mach dispatch target for a (non-mixed) run. A single-suite run
52
38
  * auto-routes to the suite-specific command (`mach xpcshell-test` /
@@ -66,12 +52,6 @@ function resolveTestSuite(classification, forceGeneric) {
66
52
  }
67
53
  return 'generic';
68
54
  }
69
- function buildMixedHarnessMessage(classification) {
70
- return ('FireForge cannot run xpcshell and browser/mochitest paths in the same mach invocation.\n\n' +
71
- 'Split this into separate `fireforge test` commands so each manifest selects its own harness:\n' +
72
- ` - xpcshell: ${classification.xpcshell.join(', ')}\n` +
73
- ` - browser/mochitest: ${classification.nonXpcshell.join(', ')}`);
74
- }
75
55
  function filterRedundantXpcshellFlavorArgs(machArgs, classification) {
76
56
  if (classification.xpcshell.length === 0 || classification.nonXpcshell.length > 0) {
77
57
  return [...machArgs];
@@ -135,9 +115,14 @@ async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries
135
115
  // same files — in any invocation shape — is not refused. A failed
136
116
  // write never fails the run. The coverage claim is scoped to the
137
117
  // requested test paths, since a file-scoped `test --build` only
138
- // guarantees packaging for those manifests.
118
+ // guarantees packaging for those manifests. The previous baseline is
119
+ // passed through so a scoped write carries the static-components
120
+ // anchor forward (a `mach build faster` does not rebake
121
+ // components.conf into the compiled table).
139
122
  try {
140
- await writeBuildBaseline(projectRoot, paths.engine, projectConfig.binaryName, testPackagingCoverage);
123
+ await writeBuildBaseline(projectRoot, paths.engine, projectConfig.binaryName, testPackagingCoverage, previousBaseline, testPackagingCoverage === 'full'
124
+ ? 'fireforge test --build'
125
+ : `fireforge test --build ${testPackagingCoverage.join(' ')}`);
141
126
  }
142
127
  catch (baselineError) {
143
128
  verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
@@ -150,51 +135,6 @@ async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries
150
135
  : 'Pre-test build failed', 'mach build faster');
151
136
  });
152
137
  }
153
- /**
154
- * Stale-build preflight — when `--build` was NOT requested, detect
155
- * packageable engine edits since the last successful build and fail
156
- * UP-FRONT unless the operator explicitly accepts the stale package risk.
157
- *
158
- * Packaging COVERAGE is checked first, on EVERY non-`--build` run and
159
- * regardless of staleness or `--allow-stale-build`: a runtime packaged by
160
- * a file-scoped `test --build` can lack support fixtures for OTHER
161
- * manifests even when nothing changed since — dispatching such a run
162
- * hangs on missing fixtures rather than failing, so the flag (which only
163
- * accepts stale CONTENT) must not be the trigger. Field incident: a
164
- * three-file scoped rebuild left `file_tiles_audio.html` unpackaged and a
165
- * later run over different files timed out twice at 45s waiting on
166
- * `DOMAudioPlaybackStarted`.
167
- *
168
- * Exception: a path-less `test --doctor` stops at the Marionette health
169
- * check (`runDoctorPreflight` returns 'stop' when no test paths were
170
- * given) and never dispatches a test, so it needs no packaging coverage —
171
- * treating it as a full-suite request would refuse a probe that touches
172
- * no fixtures. The stale-content refusal still applies to it unchanged.
173
- */
174
- async function enforceStaleBuildGate(projectRoot, engineDir, options, normalizedPaths) {
175
- const stale = await checkStaleBuildForTest(projectRoot, engineDir);
176
- const dispatchesNoTests = options.doctor === true && normalizedPaths.length === 0;
177
- if (!dispatchesNoTests) {
178
- const recordedCoverage = stale.baseline?.testPackagingCoverage;
179
- const uncovered = findUncoveredRequestPaths(recordedCoverage, normalizedPaths);
180
- if (uncovered.length > 0) {
181
- throw new GeneralError(formatTestCoverageRefusal(uncovered, Array.isArray(recordedCoverage) ? recordedCoverage : []));
182
- }
183
- }
184
- const staleMessage = stale.stale
185
- ? `${formatStaleBuildWarning(stale)}\n\n` +
186
- 'Run `fireforge test --build` to refresh the packaged runtime first, or pass ' +
187
- '`--allow-stale-build` if you intentionally rebuilt out-of-band and accept the risk.'
188
- : undefined;
189
- if (staleMessage !== undefined) {
190
- if (options.allowStaleBuild === true) {
191
- warn(staleMessage);
192
- }
193
- else {
194
- throw new GeneralError(staleMessage);
195
- }
196
- }
197
- }
198
138
  function logTestSelection(scopes) {
199
139
  if (scopes.length > 0) {
200
140
  const labels = scopes.map((scope) => scope.isDirectory && scope.testFileCount > 0
@@ -281,7 +221,7 @@ async function runDoctorPreflight(args) {
281
221
  * `--mach-arg` and the xpcshell flavor that ignores the pref entirely.
282
222
  * Mutates `extraArgs` in place.
283
223
  */
284
- function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort) {
224
+ function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort, xpcshellOnly = false) {
285
225
  // Auto-forward the Marionette port to mach when `--marionette-port` is
286
226
  // set. `--setpref=marionette.port=<n>` configures where the browser
287
227
  // listener binds; `--marionette=127.0.0.1:<n>` tells the mochitest harness
@@ -300,6 +240,14 @@ function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort) {
300
240
  // `--marionette=...` (or two-token `--marionette host:port`).
301
241
  if (options.marionettePort === undefined)
302
242
  return;
243
+ if (xpcshellOnly) {
244
+ // Manifest classification says every requested path is xpcshell —
245
+ // xpcshell ignores the browser Marionette path entirely, and the
246
+ // mochitest client flags previously forwarded here made mach reject
247
+ // the dispatch (FORGE F10).
248
+ info(`--marionette-port=${options.marionettePort} applied to the preflight probe only: the requested paths are xpcshell-only, and xpcshell ignores the browser Marionette port. Not forwarding --setpref=marionette.port or --marionette to mach.`);
249
+ return;
250
+ }
303
251
  {
304
252
  const operatorAlreadyForwarded = forwardedPort !== undefined;
305
253
  const machArgs = options.machArg ?? [];
@@ -318,8 +266,23 @@ function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort) {
318
266
  }
319
267
  }
320
268
  }
321
- async function ensureTestMarionettePortAvailable(port, binaryName, killStaleBrowser) {
322
- if (killStaleBrowser) {
269
+ async function ensureTestMarionettePortAvailable(port, binaryName, options, skip) {
270
+ if (skip.xpcshellOnly && !skip.doctor) {
271
+ // xpcshell does not bind the browser Marionette port, so a developer's
272
+ // interactive browser holding 2828 must not kill an xpcshell run
273
+ // (FORGE F10). --doctor keeps the preflight: its probe launches a
274
+ // Marionette browser regardless of the requested harness.
275
+ const message = 'Skipping the Marionette stale-port preflight: all requested paths are xpcshell ' +
276
+ '(xpcshell does not bind the browser Marionette port).';
277
+ if (options.marionettePort !== undefined || options.killStaleMarionette === true) {
278
+ info(message);
279
+ }
280
+ else {
281
+ verbose(message);
282
+ }
283
+ return;
284
+ }
285
+ if (options.killStaleMarionette === true) {
323
286
  await ensureMarionettePortAvailable(port, { binaryName, killStaleBrowser: true });
324
287
  return;
325
288
  }
@@ -363,11 +326,19 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
363
326
  // stale/coverage refusals keep precedence over missing-path errors.)
364
327
  const requestedPaths = canaryPath !== undefined ? [canaryPath] : testPaths;
365
328
  const normalizedPaths = requestedPaths.map((p) => stripEnginePrefix(p).trim());
329
+ const { classification, xpcshellOnly } = await classifyBeforeDispatch(paths.engine, normalizedPaths);
366
330
  // Run incremental build if requested
367
331
  if (options.build) {
368
332
  // A path-less `test --build` runs (and packages for) the full suite;
369
- // a scoped invocation only vouches for the requested paths.
333
+ // a scoped invocation only vouches for the requested paths. A SCOPED
334
+ // rebuild also cannot regenerate the compiled StaticComponents table,
335
+ // so it runs the components.conf gate up-front (the path-less shape
336
+ // refreshes the anchor itself and skips it).
370
337
  const coverage = normalizedPaths.length === 0 ? 'full' : normalizedPaths;
338
+ if (normalizedPaths.length > 0) {
339
+ const previousBaseline = await readBuildBaseline(projectRoot);
340
+ await enforceStaticComponentsGate(paths.engine, previousBaseline, options);
341
+ }
371
342
  await runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries, coverage);
372
343
  info('');
373
344
  }
@@ -396,7 +367,10 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
396
367
  // generic bind failure. 2026-04-21 eval (Finding #20): a stale
397
368
  // `-marionette` process from `fresh/` poisoned a later test run in
398
369
  // the sibling `mybrowser/` workspace.
399
- await ensureTestMarionettePortAvailable(effectivePort, projectConfig.binaryName, options.killStaleMarionette === true);
370
+ await ensureTestMarionettePortAvailable(effectivePort, projectConfig.binaryName, options, {
371
+ xpcshellOnly,
372
+ doctor: options.doctor === true,
373
+ });
400
374
  if (options.doctor) {
401
375
  const doctorOutcome = await runDoctorPreflight({
402
376
  engineDir: paths.engine,
@@ -410,10 +384,6 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
410
384
  return;
411
385
  }
412
386
  await assertTestPathsExist(paths.engine, normalizedPaths);
413
- const classification = await classifyTestHarnesses(paths.engine, normalizedPaths);
414
- if (classification.xpcshell.length > 0 && classification.nonXpcshell.length > 0) {
415
- throw new GeneralError(buildMixedHarnessMessage(classification));
416
- }
417
387
  const suite = resolveTestSuite(classification, options.genericMachTest === true);
418
388
  const forwardedMachArgs = options.machArg && options.machArg.length > 0
419
389
  ? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
@@ -436,7 +406,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
436
406
  if (forwardedMachArgs.length > 0) {
437
407
  extraArgs.push(...forwardedMachArgs);
438
408
  }
439
- appendMarionetteForwardingArgs(extraArgs, options, forwardedPort);
409
+ appendMarionetteForwardingArgs(extraArgs, options, forwardedPort, xpcshellOnly);
440
410
  // Directory arguments mean EXACTLY that directory: mozbuild's test
441
411
  // resolver matches paths by string prefix, so a bare directory arg
442
412
  // silently swept in prefix-named siblings (152.0b7 → 153.0b8 drill:
@@ -472,6 +442,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
472
442
  suite,
473
443
  baseExtraArgs: extraArgs,
474
444
  harnessRetries,
445
+ headless: options.headless === true,
475
446
  ...(perfSampleEnv ? { env: perfSampleEnv } : {}),
476
447
  };
477
448
  const postRebuildContext = options.build
@@ -517,7 +488,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
517
488
  reportCanaryOutcome(outcome);
518
489
  return;
519
490
  }
520
- finalizeSingleRunOutcome(outcome, normalizedPaths, projectConfig.binaryName, postRebuildContext);
491
+ finalizeSingleRunOutcome(outcome, normalizedPaths, projectConfig.binaryName, postRebuildContext, options.headless === true);
521
492
  }
522
493
  /**
523
494
  * Builds the perf-sample env contract for the harness run (field report
@@ -36,6 +36,18 @@ export interface AuditSummary {
36
36
  * @returns True when the path implies packaging.
37
37
  */
38
38
  export declare function isPackageablePath(sourcePath: string): boolean;
39
+ /**
40
+ * Decides whether a source path is an XPCOM static-component manifest —
41
+ * i.e. a file whose registrations are baked into the compiled
42
+ * StaticComponents table at FULL-build time rather than packaged into
43
+ * `dist/`. This basename check is the single extension point for that
44
+ * classification; parsing `moz.build` `XPCOM_MANIFESTS` entries to follow
45
+ * renamed manifests is out of scope.
46
+ *
47
+ * @param sourcePath Engine-relative POSIX path.
48
+ * @returns True when the path is a `components.conf` manifest.
49
+ */
50
+ export declare function isXpcomManifestPath(sourcePath: string): boolean;
39
51
  /**
40
52
  * Runs the post-build audit. Emits per-file warnings for missing or
41
53
  * stale artifacts and a summary info line at the end. Always returns
@@ -111,6 +111,20 @@ export function isPackageablePath(sourcePath) {
111
111
  }
112
112
  return false;
113
113
  }
114
+ /**
115
+ * Decides whether a source path is an XPCOM static-component manifest —
116
+ * i.e. a file whose registrations are baked into the compiled
117
+ * StaticComponents table at FULL-build time rather than packaged into
118
+ * `dist/`. This basename check is the single extension point for that
119
+ * classification; parsing `moz.build` `XPCOM_MANIFESTS` entries to follow
120
+ * renamed manifests is out of scope.
121
+ *
122
+ * @param sourcePath Engine-relative POSIX path.
123
+ * @returns True when the path is a `components.conf` manifest.
124
+ */
125
+ export function isXpcomManifestPath(sourcePath) {
126
+ return basename(sourcePath.replace(/\\/g, '/')) === 'components.conf';
127
+ }
114
128
  /*
115
129
  * Finds the unique obj-star directory with a dist subtree, or undefined
116
130
  * when zero or multiple match. The ambiguous case is already rejected
@@ -55,8 +55,49 @@ export interface BuildBaseline {
55
55
  * The record is project-scoped, which is also per-obj-dir: multi-objdir
56
56
  * checkouts are refused up-front (`AmbiguousBuildArtifactsError`), so at
57
57
  * most one obj dir exists per project.
58
+ *
59
+ * Union/"shared coverage" across successive scoped builds was evaluated
60
+ * and rejected as unsound — every baseline write refreshes
61
+ * `packageableFingerprints` for ALL dirty packageable paths, so a union
62
+ * would whitewash an earlier scope's edited fixtures while
63
+ * `obj-*`/`_tests/` still holds its stale staging; coverage therefore
64
+ * REPLACES.
58
65
  */
59
66
  testPackagingCoverage?: TestPackagingCoverage;
67
+ /**
68
+ * Anchor for the compiled StaticComponents table: the engine HEAD SHA of
69
+ * the last FULL-coverage build plus content fingerprints of the
70
+ * `components.conf` manifests that were dirty at that moment. Written
71
+ * fresh only on FULL-coverage baseline writes (`fireforge build`,
72
+ * `build --ui`, path-less `test --build`); a scoped `test --build`
73
+ * carries the previous record forward verbatim, because `mach build
74
+ * faster` does not rebake `components.conf` registrations into the
75
+ * compiled table. Missing on pre-0.38.0 baselines — the
76
+ * static-components stale check degrades to "fresh" in that case.
77
+ */
78
+ staticComponentsBaseline?: StaticComponentsBaseline;
79
+ /**
80
+ * Invocation shape that recorded this baseline (`'fireforge build'`,
81
+ * `'fireforge build --ui'`, `'fireforge test --build [paths]'`).
82
+ * Informational — surfaced by `fireforge status --test-coverage`.
83
+ * Missing on pre-0.39.0 baselines; render as "unknown".
84
+ */
85
+ recordedBy?: string;
86
+ }
87
+ /**
88
+ * State of the engine at the last FULL build, as far as compiled-in XPCOM
89
+ * component registration is concerned. See
90
+ * {@link BuildBaseline.staticComponentsBaseline}.
91
+ */
92
+ export interface StaticComponentsBaseline {
93
+ /** Engine HEAD SHA at the time of the last full build. */
94
+ engineHeadSha: string;
95
+ /**
96
+ * Hex-encoded SHA-256 per `components.conf` path that was dirty
97
+ * (modified-against-HEAD or untracked) when the full build ran. Keys are
98
+ * engine-relative POSIX paths.
99
+ */
100
+ fingerprints: Record<string, string>;
60
101
  }
61
102
  /** Coverage claim of the packaged test runtime. See {@link BuildBaseline.testPackagingCoverage}. */
62
103
  export type TestPackagingCoverage = 'full' | string[];
@@ -42,5 +42,10 @@ export declare function readBuildBaseline(projectRoot: string): Promise<BuildBas
42
42
  * @param testPackagingCoverage - Coverage claim of the packaged test
43
43
  * runtime this build produced (`'full'`, or the scoped request paths of
44
44
  * a `test --build` invocation). Omitted → field left off the marker.
45
+ * @param previousBaseline - The baseline this write replaces, when the
46
+ * caller has it. A scoped (non-`'full'`) write carries its
47
+ * `staticComponentsBaseline` forward verbatim — `mach build faster`
48
+ * does not rebake `components.conf` registrations, so the last FULL
49
+ * build stays the honest anchor for the compiled StaticComponents table.
45
50
  */
46
- export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage): Promise<void>;
51
+ export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage, previousBaseline?: BuildBaseline, recordedBy?: string): Promise<void>;
@@ -22,7 +22,7 @@ import { join } from 'node:path';
22
22
  import { toError } from '../utils/errors.js';
23
23
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
24
24
  import { verbose } from '../utils/logger.js';
25
- import { isPackageablePath } from './build-audit.js';
25
+ import { isPackageablePath, isXpcomManifestPath } from './build-audit.js';
26
26
  import { FIREFORGE_DIR } from './config-paths.js';
27
27
  import { getHead, hasChanges, isMissingHeadError } from './git.js';
28
28
  import { git } from './git-base.js';
@@ -68,8 +68,13 @@ export async function readBuildBaseline(projectRoot) {
68
68
  * @param testPackagingCoverage - Coverage claim of the packaged test
69
69
  * runtime this build produced (`'full'`, or the scoped request paths of
70
70
  * a `test --build` invocation). Omitted → field left off the marker.
71
+ * @param previousBaseline - The baseline this write replaces, when the
72
+ * caller has it. A scoped (non-`'full'`) write carries its
73
+ * `staticComponentsBaseline` forward verbatim — `mach build faster`
74
+ * does not rebake `components.conf` registrations, so the last FULL
75
+ * build stays the honest anchor for the compiled StaticComponents table.
71
76
  */
72
- export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage) {
77
+ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage, previousBaseline, recordedBy) {
73
78
  let engineHeadSha = '';
74
79
  try {
75
80
  engineHeadSha = await getHead(engineDir);
@@ -84,12 +89,21 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, tes
84
89
  }
85
90
  }
86
91
  const packageableFingerprints = await collectPackageableFingerprints(engineDir);
92
+ // A full-coverage write (and the legacy no-claim `fireforge build` shape)
93
+ // just recompiled the StaticComponents table, so it anchors the table to
94
+ // the current engine state. A scoped write did not — carry the previous
95
+ // anchor forward verbatim.
96
+ const staticComponentsBaseline = testPackagingCoverage === undefined || testPackagingCoverage === 'full'
97
+ ? await collectStaticComponentsBaseline(engineDir, engineHeadSha)
98
+ : previousBaseline?.staticComponentsBaseline;
87
99
  const baseline = {
88
100
  engineHeadSha,
89
101
  builtAt: new Date().toISOString(),
90
102
  binaryName,
91
103
  ...(packageableFingerprints !== undefined ? { packageableFingerprints } : {}),
92
104
  ...(testPackagingCoverage !== undefined ? { testPackagingCoverage } : {}),
105
+ ...(staticComponentsBaseline !== undefined ? { staticComponentsBaseline } : {}),
106
+ ...(recordedBy !== undefined ? { recordedBy } : {}),
93
107
  };
94
108
  await writeJson(getBuildBaselinePath(projectRoot), baseline);
95
109
  }
@@ -107,6 +121,33 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, tes
107
121
  * back to the pre-0.16.0 "path-only" behavior on the next test run.
108
122
  */
109
123
  async function collectPackageableFingerprints(engineDir) {
124
+ return collectDirtyFingerprints(engineDir, isPackageablePath, 'packageable fingerprint');
125
+ }
126
+ /**
127
+ * Anchor for the compiled StaticComponents table: the engine HEAD SHA of
128
+ * this (full) build plus content fingerprints of the `components.conf`
129
+ * manifests dirty right now — the same dirty-path probe family as
130
+ * {@link collectPackageableFingerprints}, filtered to XPCOM manifests.
131
+ * Returns `undefined` on probe failure so a broken probe omits the field
132
+ * (the static-components stale check then degrades to "fresh") rather than
133
+ * anchoring to a garbage record.
134
+ */
135
+ async function collectStaticComponentsBaseline(engineDir, engineHeadSha) {
136
+ const fingerprints = await collectDirtyFingerprints(engineDir, isXpcomManifestPath, 'static-components fingerprint');
137
+ if (fingerprints === undefined) {
138
+ return undefined;
139
+ }
140
+ return { engineHeadSha, fingerprints };
141
+ }
142
+ /**
143
+ * Shared dirty-path fingerprint probe backing
144
+ * {@link collectPackageableFingerprints} and
145
+ * {@link collectStaticComponentsBaseline}: enumerates engine workdir
146
+ * modifications (tracked and untracked), keeps the paths `includePath`
147
+ * accepts, and hashes each. Returns `undefined` on any git failure so a
148
+ * broken probe never corrupts the on-disk baseline with `{}`.
149
+ */
150
+ async function collectDirtyFingerprints(engineDir, includePath, contextLabel) {
110
151
  try {
111
152
  const dirtyPaths = new Set();
112
153
  if (await hasChanges(engineDir)) {
@@ -120,12 +161,12 @@ async function collectPackageableFingerprints(engineDir) {
120
161
  dirtyPaths.add(untracked);
121
162
  }
122
163
  }
123
- const packageable = [...dirtyPaths].filter(isPackageablePath);
124
- if (packageable.length === 0) {
164
+ const included = [...dirtyPaths].filter(includePath);
165
+ if (included.length === 0) {
125
166
  return {};
126
167
  }
127
168
  const fingerprints = {};
128
- for (const relPath of packageable) {
169
+ for (const relPath of included) {
129
170
  try {
130
171
  const buffer = await readFile(join(engineDir, relPath));
131
172
  fingerprints[relPath] = createHash('sha256').update(buffer).digest('hex');
@@ -134,13 +175,13 @@ async function collectPackageableFingerprints(engineDir) {
134
175
  // A file that disappeared between status probe and hash is
135
176
  // expected in concurrent scenarios; skip it without failing the
136
177
  // whole baseline write.
137
- verbose(`Build baseline: skipping fingerprint for ${relPath} — ${toError(fileError).message}`);
178
+ verbose(`Build baseline: skipping ${contextLabel} for ${relPath} — ${toError(fileError).message}`);
138
179
  }
139
180
  }
140
181
  return fingerprints;
141
182
  }
142
183
  catch (error) {
143
- verbose(`Build baseline: packageable fingerprint probe failed — ${toError(error).message}`);
184
+ verbose(`Build baseline: ${contextLabel} probe failed — ${toError(error).message}`);
144
185
  return undefined;
145
186
  }
146
187
  }
@@ -24,6 +24,15 @@ export interface PrepareBuildOptions {
24
24
  */
25
25
  previousBaseline?: BuildBaseline | undefined;
26
26
  }
27
+ /**
28
+ * Describes an exit code in the shell's 128+N signal convention. Truncated
29
+ * configure/build logs with a signal-shaped exit (e.g. 144 = 128+16, SIGURG
30
+ * on macOS) are environmental interruptions, not compiler failures — naming
31
+ * that in the failure text saves the operator a fruitless log hunt
32
+ * (FORGE F16). Returns undefined for codes <= 128 (regular failures) and
33
+ * for codes past the conventional signal range, so callers append nothing.
34
+ */
35
+ export declare function describeSignalShapedExit(exitCode: number): string | undefined;
27
36
  /**
28
37
  * Returns true when the file path matches a pattern that forces
29
38
  * `mach configure` to regenerate the backend. Exported for testing.
@@ -3,6 +3,7 @@
3
3
  * Shared pre-flight logic for build and package commands:
4
4
  * story cleanup, branding setup, Furnace component application, and mozconfig generation.
5
5
  */
6
+ import { constants as osConstants } from 'node:os';
6
7
  import { BuildError } from '../errors/build.js';
7
8
  import { FurnaceError } from '../errors/furnace.js';
8
9
  import { toError } from '../utils/errors.js';
@@ -33,6 +34,27 @@ function extractMachConfigureError(result) {
33
34
  return '';
34
35
  return combined.split('\n').slice(-40).join('\n').trim();
35
36
  }
37
+ /**
38
+ * Describes an exit code in the shell's 128+N signal convention. Truncated
39
+ * configure/build logs with a signal-shaped exit (e.g. 144 = 128+16, SIGURG
40
+ * on macOS) are environmental interruptions, not compiler failures — naming
41
+ * that in the failure text saves the operator a fruitless log hunt
42
+ * (FORGE F16). Returns undefined for codes <= 128 (regular failures) and
43
+ * for codes past the conventional signal range, so callers append nothing.
44
+ */
45
+ export function describeSignalShapedExit(exitCode) {
46
+ if (exitCode <= 128 || exitCode > 192)
47
+ return undefined;
48
+ const signalNumber = exitCode - 128;
49
+ const names = Object.entries(osConstants.signals)
50
+ .filter(([, value]) => value === signalNumber)
51
+ .map(([name]) => name);
52
+ const label = names.length > 0 ? names.join('/') : `signal ${signalNumber}`;
53
+ return (`Exit ${exitCode} is signal-shaped (${exitCode} - 128 = ${signalNumber}, ${label} on this host): ` +
54
+ 'the process was likely interrupted externally (OOM-killer, terminal disconnect, ' +
55
+ 'display/session teardown) rather than failing on its own, and the log may be ' +
56
+ 'truncated mid-step.');
57
+ }
36
58
  /**
37
59
  * Builds the `BuildError` for a non-zero auto-configure exit: the output
38
60
  * tail (so the underlying mozbuild failure survives), any matched
@@ -43,8 +65,10 @@ function extractMachConfigureError(result) {
43
65
  function buildConfigureFailureError(captured) {
44
66
  const detail = extractMachConfigureError(captured);
45
67
  const hints = explainMachError(`${captured.stderr}\n${captured.stdout}`);
68
+ const signalNote = describeSignalShapedExit(captured.exitCode);
46
69
  return new BuildError(`Backend regeneration failed: mach configure exited with code ${captured.exitCode}.` +
47
70
  (detail ? `\n\nmach configure output (tail):\n${detail}` : '') +
71
+ (signalNote ? `\n\n${signalNote}` : '') +
48
72
  (hints.length > 0 ? `\n\n${hints.map((hint) => `Hint: ${hint}`).join('\n')}` : '') +
49
73
  '\n\nBuild stopped because continuing would hide the real configure failure.', 'mach configure');
50
74
  }
@@ -11,7 +11,7 @@ export declare const STATE_FILENAME = "state.json";
11
11
  /** Supported top-level fireforge.json keys backed by the current schema. */
12
12
  export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "test", "externalToolchains", "license", "wire", "patchLint", "patchPolicy", "typecheck", "markerComment"];
13
13
  /** Supported config paths that can be read or set without --force. */
14
- export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "build", "build.jobs", "test", "test.canaryPath", "test.canaryTimeoutSeconds", "externalToolchains", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
14
+ export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "firefox.candidate", "build", "build.jobs", "test", "test.canaryPath", "test.canaryTimeoutSeconds", "externalToolchains", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
15
15
  /**
16
16
  * Gets all project paths based on a root directory.
17
17
  * @param root - Root directory of the project
@@ -45,6 +45,7 @@ export const SUPPORTED_CONFIG_PATHS = [
45
45
  'firefox.version',
46
46
  'firefox.product',
47
47
  'firefox.sha256',
48
+ 'firefox.candidate',
48
49
  'build',
49
50
  'build.jobs',
50
51
  'test',
@@ -6,7 +6,7 @@ import { ConfigError } from '../errors/config.js';
6
6
  import { verbose } from '../utils/logger.js';
7
7
  import { parseObject } from '../utils/parse.js';
8
8
  import { isContainedRelativePath, isExplicitAbsolutePath } from '../utils/paths.js';
9
- import { isValidAppId, isValidFirefoxVersion, isValidProjectLicense, PROJECT_LICENSES, validateFirefoxProductVersionCompatibility, } from '../utils/validation.js';
9
+ import { isValidAppId, isValidFirefoxCandidate, isValidFirefoxVersion, isValidProjectLicense, PROJECT_LICENSES, validateFirefoxProductVersionCompatibility, } from '../utils/validation.js';
10
10
  import { SUPPORTED_CONFIG_ROOT_KEYS } from './config-paths.js';
11
11
  import { parsePatchPolicyBlock } from './config-validate-patch-policy.js';
12
12
  import { parseExternalToolchainsBlock, parseTestBlock } from './config-validate-test-toolchains.js';
@@ -81,10 +81,15 @@ function parseFirefoxBlock(rec) {
81
81
  if (firefoxSha256 !== undefined && !/^[a-f0-9]{64}$/i.test(firefoxSha256)) {
82
82
  throw new ConfigError('Config field "firefox.sha256" must be a 64-character SHA-256 hex digest');
83
83
  }
84
+ const firefoxCandidate = optionalConfigString(firefoxRec, 'candidate', 'firefox.candidate');
85
+ if (firefoxCandidate !== undefined && !isValidFirefoxCandidate(firefoxCandidate)) {
86
+ throw new ConfigError('Config field "firefox.candidate" must look like "buildN" (e.g. "build2")');
87
+ }
84
88
  return {
85
89
  version: firefoxVersion,
86
90
  product: firefoxProduct,
87
91
  ...(firefoxSha256 !== undefined ? { sha256: firefoxSha256.toLowerCase() } : {}),
92
+ ...(firefoxCandidate !== undefined ? { candidate: firefoxCandidate } : {}),
88
93
  };
89
94
  }
90
95
  /** Parses the optional `build` block (currently just `build.jobs`). */
@@ -343,6 +348,10 @@ function parsePatchLintBlock(rec) {
343
348
  if (chromeScriptJsDoc !== undefined) {
344
349
  out.chromeScriptJsDoc = chromeScriptJsDoc;
345
350
  }
351
+ const undefinedIdentifiers = parseSeverityGate(rec.raw('undefinedIdentifiers'), 'patchLint.undefinedIdentifiers');
352
+ if (undefinedIdentifiers !== undefined) {
353
+ out.undefinedIdentifiers = undefinedIdentifiers;
354
+ }
346
355
  if (out.checkJsStrict === true && out.checkJs !== true) {
347
356
  throw new ConfigError('Config field "patchLint.checkJsStrict" requires "patchLint.checkJs": true');
348
357
  }
@@ -408,6 +417,10 @@ function parseTypecheckBlock(rec) {
408
417
  if (overrides) {
409
418
  out.projectOverrides = overrides;
410
419
  }
420
+ const undefinedIdentifiers = parseSeverityGate(rec.raw('undefinedIdentifiers'), 'typecheck.undefinedIdentifiers');
421
+ if (undefinedIdentifiers !== undefined) {
422
+ out.undefinedIdentifiers = undefinedIdentifiers;
423
+ }
411
424
  return out;
412
425
  }
413
426
  /**
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Patch-aware restore planning for `fireforge discard` (FORGE F1, P0).
3
+ *
4
+ * The engine convention is: HEAD = pristine upstream Firefox, and the
5
+ * applied patch queue lives as UNCOMMITTED worktree changes on top of it
6
+ * (`git apply` without `--index` → patch-edited tracked files are unstaged
7
+ * modifications, patch-created files are untracked). The pre-0.39.0
8
+ * `discard` was purely git-mechanical — it restored to HEAD and deleted
9
+ * untracked files, silently reverting patch-backed files PAST their owning
10
+ * patch and deleting patch-created files outright.
11
+ *
12
+ * This module classifies each status entry against the patch manifest's
13
+ * ownership claims and plans the correct restore target: the PATCH-APPLIED
14
+ * baseline (HEAD content folded through every affecting patch in order) for
15
+ * claimed files, and the legacy git mechanics for unmanaged ones.
16
+ */
17
+ import type { GitStatusEntry } from './git-base.js';
18
+ /** How one status entry's canonical path will be restored. */
19
+ export type DiscardKind = 'unmanaged' | 'patch-backed' | 'patch-created' | 'patch-deleted';
20
+ /** Restore plan for one git status entry. */
21
+ export interface DiscardBaselinePlan {
22
+ entry: GitStatusEntry;
23
+ kind: DiscardKind;
24
+ /** Claiming patch filenames for `entry.file` ∪ `entry.originalPath`. */
25
+ owners: string[];
26
+ /** True when more than one patch claims the path (ownership conflict). */
27
+ conflicted: boolean;
28
+ /** Baseline content for `entry.file`; null = absent at baseline. Unset for 'unmanaged'. */
29
+ expectedContent?: string | null;
30
+ /** Rename/copy only: baseline content for `originalPath` when that side is claimed. */
31
+ expectedOriginalContent?: string | null;
32
+ }
33
+ /** Builds an all-unmanaged plan set — the `--to-upstream` (legacy) shape. */
34
+ export declare function planUpstreamDiscards(entries: readonly GitStatusEntry[]): DiscardBaselinePlan[];
35
+ /**
36
+ * Plans the restore target for each status entry. Entries claimed by the
37
+ * patch manifest restore to the patch-applied baseline; unclaimed entries
38
+ * keep the legacy git mechanics. A missing patches dir or empty manifest
39
+ * degrades every plan to `unmanaged` (identical to pre-0.39.0 behavior).
40
+ *
41
+ * A corrupt manifest or an unreconstructible patch baseline REFUSES with a
42
+ * GeneralError naming `--to-upstream` — silently falling back to pristine
43
+ * HEAD is exactly the data-loss path this module exists to close.
44
+ */
45
+ export declare function planDiscardBaselines(patchesDir: string, engineDir: string, entries: readonly GitStatusEntry[]): Promise<DiscardBaselinePlan[]>;
46
+ /**
47
+ * Applies one restore plan. Patch-claimed paths end up exactly as `import`
48
+ * leaves them: patched content as an unstaged worktree state (tracked files
49
+ * read ` M`, created files `??`), nothing staged. Unmanaged plans delegate
50
+ * to the legacy {@link discardStatusEntry} mechanics.
51
+ */
52
+ export declare function applyDiscardBaseline(engineDir: string, plan: DiscardBaselinePlan): Promise<void>;
53
+ /** One-line annotation for dry-run listings and confirmation summaries. */
54
+ export declare function describeDiscardBaseline(plan: DiscardBaselinePlan): string;
55
+ /** Single-file outro naming the baseline that was restored. */
56
+ export declare function describeDiscardOutcome(plan: DiscardBaselinePlan, toUpstream: boolean): string;
57
+ /** Batch outro summarising how many files restored to which baseline. */
58
+ export declare function summarizeDiscardBaselines(plans: readonly DiscardBaselinePlan[], succeeded: number): string;
59
+ /** Warn text for a multi-owner (conflicted) restore. */
60
+ export declare function describeConflictWarning(plan: DiscardBaselinePlan): string;