@hominis/fireforge 0.38.0 → 0.40.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 (107) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +25 -1
  3. package/dist/src/cli.js +7 -0
  4. package/dist/src/commands/build.js +8 -3
  5. package/dist/src/commands/discard.js +75 -50
  6. package/dist/src/commands/export-flow.d.ts +1 -1
  7. package/dist/src/commands/export-flow.js +21 -10
  8. package/dist/src/commands/export-placement-gate.js +1 -1
  9. package/dist/src/commands/export-shared.js +31 -5
  10. package/dist/src/commands/import.js +5 -0
  11. package/dist/src/commands/lint-per-patch.js +97 -18
  12. package/dist/src/commands/lint-report.d.ts +21 -0
  13. package/dist/src/commands/lint-report.js +62 -0
  14. package/dist/src/commands/lint.js +23 -19
  15. package/dist/src/commands/manifest.js +2 -0
  16. package/dist/src/commands/patch/lint-ignore.js +20 -1
  17. package/dist/src/commands/patch/move-files-into.d.ts +47 -0
  18. package/dist/src/commands/patch/move-files-into.js +245 -0
  19. package/dist/src/commands/patch/move-files.js +27 -13
  20. package/dist/src/commands/patch/rename.js +5 -2
  21. package/dist/src/commands/patch/staged-dependency.js +102 -14
  22. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  23. package/dist/src/commands/re-export-adjacent.js +106 -0
  24. package/dist/src/commands/re-export-options.js +3 -0
  25. package/dist/src/commands/re-export.js +42 -53
  26. package/dist/src/commands/reset.js +5 -0
  27. package/dist/src/commands/run.js +18 -2
  28. package/dist/src/commands/status-check.d.ts +35 -0
  29. package/dist/src/commands/status-check.js +88 -0
  30. package/dist/src/commands/status-output.d.ts +9 -0
  31. package/dist/src/commands/status-output.js +33 -1
  32. package/dist/src/commands/status.js +33 -8
  33. package/dist/src/commands/test-diagnose.d.ts +1 -1
  34. package/dist/src/commands/test-diagnose.js +9 -3
  35. package/dist/src/commands/test-modes.d.ts +21 -0
  36. package/dist/src/commands/test-modes.js +42 -0
  37. package/dist/src/commands/test-run.d.ts +2 -0
  38. package/dist/src/commands/test-run.js +26 -5
  39. package/dist/src/commands/test.js +38 -32
  40. package/dist/src/commands/token.js +4 -1
  41. package/dist/src/commands/tree.d.ts +16 -0
  42. package/dist/src/commands/tree.js +174 -0
  43. package/dist/src/core/build-baseline-types.d.ts +7 -0
  44. package/dist/src/core/build-baseline.d.ts +1 -1
  45. package/dist/src/core/build-baseline.js +2 -1
  46. package/dist/src/core/build-prepare.d.ts +9 -0
  47. package/dist/src/core/build-prepare.js +24 -0
  48. package/dist/src/core/config-validate.js +25 -0
  49. package/dist/src/core/discard-baseline.d.ts +60 -0
  50. package/dist/src/core/discard-baseline.js +232 -0
  51. package/dist/src/core/license-headers.d.ts +12 -0
  52. package/dist/src/core/license-headers.js +27 -0
  53. package/dist/src/core/lint-cache.d.ts +15 -5
  54. package/dist/src/core/lint-cache.js +17 -5
  55. package/dist/src/core/patch-export.d.ts +12 -3
  56. package/dist/src/core/patch-export.js +13 -3
  57. package/dist/src/core/patch-lint-checkjs.d.ts +25 -2
  58. package/dist/src/core/patch-lint-checkjs.js +85 -16
  59. package/dist/src/core/patch-lint-cross.d.ts +9 -1
  60. package/dist/src/core/patch-lint-cross.js +69 -9
  61. package/dist/src/core/patch-lint-css.js +64 -16
  62. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  63. package/dist/src/core/patch-lint-diff.js +31 -0
  64. package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
  65. package/dist/src/core/patch-lint-mozbuild.js +104 -0
  66. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  67. package/dist/src/core/patch-lint-ownership.js +30 -0
  68. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  69. package/dist/src/core/patch-lint.d.ts +23 -0
  70. package/dist/src/core/patch-lint.js +34 -3
  71. package/dist/src/core/patch-manifest.d.ts +1 -1
  72. package/dist/src/core/patch-policy.d.ts +14 -1
  73. package/dist/src/core/patch-policy.js +13 -2
  74. package/dist/src/core/smoke-patterns.d.ts +24 -8
  75. package/dist/src/core/smoke-patterns.js +23 -12
  76. package/dist/src/core/status-classify.d.ts +16 -0
  77. package/dist/src/core/status-classify.js +20 -6
  78. package/dist/src/core/test-harness-crash.d.ts +10 -0
  79. package/dist/src/core/test-harness-crash.js +16 -0
  80. package/dist/src/core/test-stale-check.d.ts +15 -0
  81. package/dist/src/core/test-stale-check.js +37 -1
  82. package/dist/src/core/token-category.d.ts +40 -0
  83. package/dist/src/core/token-category.js +264 -0
  84. package/dist/src/core/token-manager.d.ts +7 -0
  85. package/dist/src/core/token-manager.js +82 -193
  86. package/dist/src/core/token-variant.d.ts +7 -0
  87. package/dist/src/core/token-variant.js +9 -0
  88. package/dist/src/core/tree-cow.d.ts +18 -0
  89. package/dist/src/core/tree-cow.js +71 -0
  90. package/dist/src/core/tree-guard.d.ts +35 -0
  91. package/dist/src/core/tree-guard.js +124 -0
  92. package/dist/src/core/tree-store.d.ts +53 -0
  93. package/dist/src/core/tree-store.js +214 -0
  94. package/dist/src/core/typecheck-shim.d.ts +27 -0
  95. package/dist/src/core/typecheck-shim.js +58 -4
  96. package/dist/src/core/typecheck.js +14 -3
  97. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  98. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  99. package/dist/src/errors/git.d.ts +8 -0
  100. package/dist/src/errors/git.js +17 -0
  101. package/dist/src/index.d.ts +2 -0
  102. package/dist/src/index.js +1 -0
  103. package/dist/src/types/commands/options.d.ts +45 -2
  104. package/dist/src/types/config.d.ts +34 -0
  105. package/dist/src/utils/validation.d.ts +10 -0
  106. package/dist/src/utils/validation.js +29 -0
  107. package/package.json +6 -6
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { loadConfig } from '../core/config.js';
3
+ import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
3
4
  import { GeneralError } from '../errors/base.js';
4
5
  import { success } from '../utils/logger.js';
5
6
  /** Rejects pathless `fireforge test` unless the operator selected a pathless mode. */
@@ -59,3 +60,44 @@ function buildPathlessTestMessage() {
59
60
  ' - `fireforge test --doctor` to run the Marionette preflight only\n' +
60
61
  ' - `fireforge test --canary` to run the configured short harness canary');
61
62
  }
63
+ async function classifyTestHarnesses(engineDir, normalizedPaths) {
64
+ const result = { xpcshell: [], nonXpcshell: [] };
65
+ for (const testPath of normalizedPaths) {
66
+ const manifest = await findNearestXpcshellManifest(engineDir, testPath);
67
+ if (manifest) {
68
+ result.xpcshell.push(testPath);
69
+ }
70
+ else {
71
+ result.nonXpcshell.push(testPath);
72
+ }
73
+ }
74
+ return result;
75
+ }
76
+ function buildMixedHarnessMessage(classification) {
77
+ return ('FireForge cannot run xpcshell and browser/mochitest paths in the same mach invocation.\n\n' +
78
+ 'Split this into separate `fireforge test` commands so each manifest selects its own harness:\n' +
79
+ ` - xpcshell: ${classification.xpcshell.join(', ')}\n` +
80
+ ` - browser/mochitest: ${classification.nonXpcshell.join(', ')}`);
81
+ }
82
+ /**
83
+ * Harness classification is a pure path→manifest lookup and must precede
84
+ * the build dispatch: a mixed xpcshell+mochitest request can never
85
+ * dispatch, so refusing it before spending minutes in a pre-test build
86
+ * is strictly better (FORGE F7). A nonexistent path classifies as
87
+ * nonXpcshell (findNearestXpcshellManifest returns null), so classifying
88
+ * before assertTestPathsExist is harmless — existence is still asserted
89
+ * after the stale/coverage gates, preserving their precedence over
90
+ * missing-path errors. `xpcshellOnly` (non-empty request, zero
91
+ * nonXpcshell paths) gates the Marionette preflight and the mochitest
92
+ * client flags (FORGE F10); pathless runs keep the full-suite behavior.
93
+ */
94
+ export async function classifyBeforeDispatch(engineDir, normalizedPaths) {
95
+ const classification = await classifyTestHarnesses(engineDir, normalizedPaths);
96
+ if (classification.xpcshell.length > 0 && classification.nonXpcshell.length > 0) {
97
+ throw new GeneralError(buildMixedHarnessMessage(classification));
98
+ }
99
+ return {
100
+ classification,
101
+ xpcshellOnly: normalizedPaths.length > 0 && classification.nonXpcshell.length === 0,
102
+ };
103
+ }
@@ -36,6 +36,8 @@ export interface TestRunContext {
36
36
  baseExtraArgs: readonly string[];
37
37
  /** Bounded harness-crash retry budget (0 disables retries). */
38
38
  harnessRetries: number;
39
+ /** Whether --headless was requested (crash-hint copy only). */
40
+ headless: boolean;
39
41
  /** Extra environment variables for the mach process. */
40
42
  env?: Record<string, string>;
41
43
  }
@@ -12,10 +12,12 @@
12
12
  * combined invocation stays available via `--no-shard`.
13
13
  */
14
14
  import { mochitestWithOutput, testWithOutput, xpcshellTestWithOutput, } from '../core/mach.js';
15
- import { buildHarnessCrashMessage, classifyHarnessRun, } from '../core/test-harness-crash.js';
15
+ import { buildHarnessCrashMessage, classifyHarnessRun, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
16
16
  import { retryAfterXpcshellSymlinkRepair } from '../core/test-xpcshell-retry.js';
17
+ import { withXpcshellProfileDir } from '../core/xpcshell-profile-dir.js';
17
18
  import { BuildError } from '../errors/build.js';
18
19
  import { info, note, warn } from '../utils/logger.js';
20
+ import { getPlatform } from '../utils/platform.js';
19
21
  import { maybeInjectAppdirArg } from './test-appdir.js';
20
22
  /** Default bounded retry budget for recognized harness crashes. */
21
23
  export const DEFAULT_HARNESS_RETRIES = 2;
@@ -33,6 +35,20 @@ function dispatchForSuite(suite) {
33
35
  * stale-xpcshell-symlink repair path the single-run flow already used.
34
36
  */
35
37
  export async function runTestsWithRetries(ctx, paths) {
38
+ // Pure mochitest dispatches keep their env byte-identical — the profile
39
+ // variable is xpcshell-harness-specific (FORGE G15b). Generic `mach
40
+ // test` runs may include xpcshell paths, so they get the isolation too.
41
+ if (ctx.suite === 'mochitest') {
42
+ return runTestsWithRetriesInner(ctx, paths, ctx.env);
43
+ }
44
+ // Fresh profile dir per harness invocation: shards isolate from each
45
+ // other and from concurrent `fireforge test` processes; the crash /
46
+ // symlink-repair retries inside one invocation deliberately REUSE the
47
+ // same directory (they are the same logical run and the harness
48
+ // re-initialises the profile contents itself).
49
+ return withXpcshellProfileDir(ctx.env, (env) => runTestsWithRetriesInner(ctx, paths, env));
50
+ }
51
+ async function runTestsWithRetriesInner(ctx, paths, env) {
36
52
  const extraArgs = [...ctx.baseExtraArgs];
37
53
  const appdirInjectionAttempted = await maybeInjectAppdirArg(ctx.engineDir, paths, ctx.objDir, extraArgs);
38
54
  const dispatch = dispatchForSuite(ctx.suite);
@@ -42,10 +58,10 @@ export async function runTestsWithRetries(ctx, paths) {
42
58
  let verdict;
43
59
  for (;;) {
44
60
  attempts += 1;
45
- result = ctx.env
46
- ? await dispatch(ctx.engineDir, paths, extraArgs, ctx.env)
61
+ result = env
62
+ ? await dispatch(ctx.engineDir, paths, extraArgs, env)
47
63
  : await dispatch(ctx.engineDir, paths, extraArgs);
48
- result = await retryAfterXpcshellSymlinkRepair(ctx.engineDir, ctx.objDir, result, ctx.classification, paths, extraArgs, ctx.env, dispatch);
64
+ result = await retryAfterXpcshellSymlinkRepair(ctx.engineDir, ctx.objDir, result, ctx.classification, paths, extraArgs, env, dispatch);
49
65
  const combined = `${result.stdout}\n${result.stderr}`;
50
66
  verdict = classifyHarnessRun(result.exitCode, combined, paths);
51
67
  if (verdict.kind !== 'harness-crash' || attempts >= maxAttempts)
@@ -78,7 +94,12 @@ export async function runShardedTests(ctx, groups, diagnoseShardFailure) {
78
94
  const outcome = await runTestsWithRetries(ctx, group.paths);
79
95
  shards.push({ label: group.label, outcome });
80
96
  if (outcome.verdict.kind === 'harness-crash' && outcome.verdict.signature) {
81
- warn(buildHarnessCrashMessage(outcome.verdict.signature, outcome.attempts));
97
+ const base = buildHarnessCrashMessage(outcome.verdict.signature, outcome.attempts);
98
+ const hint = headedNoOutputTimeoutHint(outcome.verdict.signature, {
99
+ headless: ctx.headless,
100
+ platform: getPlatform(),
101
+ });
102
+ warn(hint ? `${base}\n\n${hint}` : base);
82
103
  }
83
104
  else if (outcome.verdict.kind !== 'tests-ran-ok') {
84
105
  const diagnosis = diagnoseShardFailure(outcome, group.label);
@@ -10,7 +10,6 @@ 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 { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
14
13
  import { GeneralError } from '../errors/base.js';
15
14
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
16
15
  import { toError } from '../utils/errors.js';
@@ -18,7 +17,7 @@ import { pathExists } from '../utils/fs.js';
18
17
  import { info, intro, outro, spinner, success, verbose } from '../utils/logger.js';
19
18
  import { stripEnginePrefix } from '../utils/paths.js';
20
19
  import { diagnoseShardOutcome, finalizeSingleRunOutcome } from './test-diagnose.js';
21
- import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, reportCanaryOutcome, resolveCanaryPath, } from './test-modes.js';
20
+ import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, classifyBeforeDispatch, reportCanaryOutcome, resolveCanaryPath, } from './test-modes.js';
22
21
  import { DEFAULT_HARNESS_RETRIES, runShardedTests, runTestsWithRetries, } from './test-run.js';
23
22
  import { enforceStaleBuildGate, enforceStaticComponentsGate } from './test-stale-gate.js';
24
23
  async function assertTestPathsExist(engineDir, 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];
@@ -140,7 +120,9 @@ async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries
140
120
  // anchor forward (a `mach build faster` does not rebake
141
121
  // components.conf into the compiled table).
142
122
  try {
143
- await writeBuildBaseline(projectRoot, paths.engine, projectConfig.binaryName, testPackagingCoverage, previousBaseline);
123
+ await writeBuildBaseline(projectRoot, paths.engine, projectConfig.binaryName, testPackagingCoverage, previousBaseline, testPackagingCoverage === 'full'
124
+ ? 'fireforge test --build'
125
+ : `fireforge test --build ${testPackagingCoverage.join(' ')}`);
144
126
  }
145
127
  catch (baselineError) {
146
128
  verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
@@ -239,7 +221,7 @@ async function runDoctorPreflight(args) {
239
221
  * `--mach-arg` and the xpcshell flavor that ignores the pref entirely.
240
222
  * Mutates `extraArgs` in place.
241
223
  */
242
- function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort) {
224
+ function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort, xpcshellOnly = false) {
243
225
  // Auto-forward the Marionette port to mach when `--marionette-port` is
244
226
  // set. `--setpref=marionette.port=<n>` configures where the browser
245
227
  // listener binds; `--marionette=127.0.0.1:<n>` tells the mochitest harness
@@ -258,6 +240,14 @@ function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort) {
258
240
  // `--marionette=...` (or two-token `--marionette host:port`).
259
241
  if (options.marionettePort === undefined)
260
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
+ }
261
251
  {
262
252
  const operatorAlreadyForwarded = forwardedPort !== undefined;
263
253
  const machArgs = options.machArg ?? [];
@@ -276,8 +266,23 @@ function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort) {
276
266
  }
277
267
  }
278
268
  }
279
- async function ensureTestMarionettePortAvailable(port, binaryName, killStaleBrowser) {
280
- 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) {
281
286
  await ensureMarionettePortAvailable(port, { binaryName, killStaleBrowser: true });
282
287
  return;
283
288
  }
@@ -321,6 +326,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
321
326
  // stale/coverage refusals keep precedence over missing-path errors.)
322
327
  const requestedPaths = canaryPath !== undefined ? [canaryPath] : testPaths;
323
328
  const normalizedPaths = requestedPaths.map((p) => stripEnginePrefix(p).trim());
329
+ const { classification, xpcshellOnly } = await classifyBeforeDispatch(paths.engine, normalizedPaths);
324
330
  // Run incremental build if requested
325
331
  if (options.build) {
326
332
  // A path-less `test --build` runs (and packages for) the full suite;
@@ -361,7 +367,10 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
361
367
  // generic bind failure. 2026-04-21 eval (Finding #20): a stale
362
368
  // `-marionette` process from `fresh/` poisoned a later test run in
363
369
  // the sibling `mybrowser/` workspace.
364
- await ensureTestMarionettePortAvailable(effectivePort, projectConfig.binaryName, options.killStaleMarionette === true);
370
+ await ensureTestMarionettePortAvailable(effectivePort, projectConfig.binaryName, options, {
371
+ xpcshellOnly,
372
+ doctor: options.doctor === true,
373
+ });
365
374
  if (options.doctor) {
366
375
  const doctorOutcome = await runDoctorPreflight({
367
376
  engineDir: paths.engine,
@@ -375,10 +384,6 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
375
384
  return;
376
385
  }
377
386
  await assertTestPathsExist(paths.engine, normalizedPaths);
378
- const classification = await classifyTestHarnesses(paths.engine, normalizedPaths);
379
- if (classification.xpcshell.length > 0 && classification.nonXpcshell.length > 0) {
380
- throw new GeneralError(buildMixedHarnessMessage(classification));
381
- }
382
387
  const suite = resolveTestSuite(classification, options.genericMachTest === true);
383
388
  const forwardedMachArgs = options.machArg && options.machArg.length > 0
384
389
  ? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
@@ -401,7 +406,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
401
406
  if (forwardedMachArgs.length > 0) {
402
407
  extraArgs.push(...forwardedMachArgs);
403
408
  }
404
- appendMarionetteForwardingArgs(extraArgs, options, forwardedPort);
409
+ appendMarionetteForwardingArgs(extraArgs, options, forwardedPort, xpcshellOnly);
405
410
  // Directory arguments mean EXACTLY that directory: mozbuild's test
406
411
  // resolver matches paths by string prefix, so a bare directory arg
407
412
  // silently swept in prefix-named siblings (152.0b7 → 153.0b8 drill:
@@ -437,6 +442,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
437
442
  suite,
438
443
  baseExtraArgs: extraArgs,
439
444
  harnessRetries,
445
+ headless: options.headless === true,
440
446
  ...(perfSampleEnv ? { env: perfSampleEnv } : {}),
441
447
  };
442
448
  const postRebuildContext = options.build
@@ -482,7 +488,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
482
488
  reportCanaryOutcome(outcome);
483
489
  return;
484
490
  }
485
- finalizeSingleRunOutcome(outcome, normalizedPaths, projectConfig.binaryName, postRebuildContext);
491
+ finalizeSingleRunOutcome(outcome, normalizedPaths, projectConfig.binaryName, postRebuildContext, options.headless === true);
486
492
  }
487
493
  /**
488
494
  * Builds the perf-sample env contract for the harness run (field report
@@ -105,7 +105,10 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
105
105
  ...(options.variant !== undefined ? { variant: options.variant } : {}),
106
106
  });
107
107
  if (result.skipped) {
108
- info(`Token ${tokenName} already exists (skipped)`);
108
+ const where = result.skippedExisting
109
+ ? ` in category "${result.skippedExisting.category ?? options.category}" (line ${String(result.skippedExisting.line)})`
110
+ : '';
111
+ info(`Token ${tokenName} already exists${where} (skipped)`);
109
112
  }
110
113
  else {
111
114
  const forgeConfig = await loadConfig(projectRoot);
@@ -0,0 +1,16 @@
1
+ import { Command } from 'commander';
2
+ import type { CommandContext } from '../types/cli.js';
3
+ /** Creates a verification tree under `.fireforge/trees/<name>`. */
4
+ export declare function treeCreateCommand(projectRoot: string, name: string, options?: {
5
+ forceCopy?: boolean;
6
+ }): Promise<void>;
7
+ /** Prints the tree list with staleness against the current primary state. */
8
+ export declare function treeListCommand(projectRoot: string, options?: {
9
+ json?: boolean;
10
+ }): Promise<void>;
11
+ /** Removes one tree (or all of them with `--all`). */
12
+ export declare function treeRemoveCommand(projectRoot: string, name: string | undefined, options?: {
13
+ all?: boolean;
14
+ }): Promise<void>;
15
+ /** Registers the tree command group on the CLI program. */
16
+ export declare function registerTree(program: Command, context: CommandContext): void;
@@ -0,0 +1,174 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge tree` — copy-on-write verification clones (FORGE G15).
4
+ *
5
+ * Trees enable concurrent READ-MOSTLY verification (lint, typecheck,
6
+ * status, verify, doctor, export dry-runs) beside a busy primary tree.
7
+ * Exports and every other mutation stay strictly serial in the primary —
8
+ * a tree's patches/components are snapshots with no merge model, and the
9
+ * tree guard (cli.ts preAction + core/tree-guard.ts) refuses mutating
10
+ * commands inside a tree. In-tree `test` and objdir cloning are deferred:
11
+ * mach objdirs embed absolute source paths, so a cloned objdir would
12
+ * silently operate against the primary tree.
13
+ */
14
+ import { spawn } from 'node:child_process';
15
+ import { join } from 'node:path';
16
+ import { Command } from 'commander';
17
+ import { withEngineSessionLock } from '../core/engine-session-lock.js';
18
+ import { detectCowSupport } from '../core/tree-cow.js';
19
+ import { cloneTree, listTrees, removeTree } from '../core/tree-store.js';
20
+ import { assertValidTreeName, computePrimaryFingerprint, getTreesDir, readTreeMarker, withTreeLifecycleLock, } from '../core/tree-store.js';
21
+ import { GeneralError } from '../errors/base.js';
22
+ import { pathExists } from '../utils/fs.js';
23
+ import { info, intro, outro, success, warn } from '../utils/logger.js';
24
+ function assertPosix() {
25
+ if (process.platform === 'win32') {
26
+ throw new GeneralError('fireforge tree is POSIX-only; copy-on-write cloning relies on clonefile/reflink, which are not available on Windows.');
27
+ }
28
+ }
29
+ /** Creates a verification tree under `.fireforge/trees/<name>`. */
30
+ export async function treeCreateCommand(projectRoot, name, options = {}) {
31
+ assertPosix();
32
+ intro('FireForge Tree Create');
33
+ assertValidTreeName(name);
34
+ if (await readTreeMarker(projectRoot)) {
35
+ throw new GeneralError('Verification trees cannot be nested: run tree create in the primary tree.');
36
+ }
37
+ const treesDir = getTreesDir(projectRoot);
38
+ const treeRoot = join(treesDir, name);
39
+ await withTreeLifecycleLock(projectRoot, async () => {
40
+ if (await pathExists(treeRoot)) {
41
+ throw new GeneralError(`A tree named "${name}" already exists. Remove it first (fireforge tree remove ${name}) — refresh is remove + create.`);
42
+ }
43
+ const { mkdir } = await import('node:fs/promises');
44
+ await mkdir(treesDir, { recursive: true });
45
+ const capability = await detectCowSupport(treesDir);
46
+ if (capability === 'none' && options.forceCopy !== true) {
47
+ throw new GeneralError('This filesystem cannot copy-on-write (APFS clonefile or btrfs/XFS reflink required), so a tree ' +
48
+ 'would be a full physical copy of the applied engine tree — potentially tens of gigabytes. ' +
49
+ 'Re-run with --force-copy to accept that explicitly.');
50
+ }
51
+ if (capability === 'none') {
52
+ warn('No copy-on-write support detected — performing a full physical copy (--force-copy).');
53
+ }
54
+ // Snapshot under the PRIMARY engine-session lock so a mid-mutation
55
+ // state (half-applied export, furnace deploy) is never captured.
56
+ const marker = await withEngineSessionLock(projectRoot, 'tree create', () => cloneTree({
57
+ primaryRoot: projectRoot,
58
+ treeRoot,
59
+ name,
60
+ capability,
61
+ createdAt: new Date().toISOString(),
62
+ }));
63
+ success(`Created verification tree "${name}" at ${treeRoot}`);
64
+ info(` Engine HEAD: ${marker.engineHead ?? '(no engine)'}`);
65
+ info(' Read-only: status, lint, typecheck, verify, doctor, and export --dry-run run inside it; mutation commands are refused.');
66
+ });
67
+ outro('Tree created');
68
+ }
69
+ /** Prints the tree list with staleness against the current primary state. */
70
+ export async function treeListCommand(projectRoot, options = {}) {
71
+ const trees = await listTrees(projectRoot);
72
+ if (options.json === true) {
73
+ process.stdout.write(`${JSON.stringify({ schemaVersion: 1, trees }, null, 2)}\n`);
74
+ return;
75
+ }
76
+ intro('FireForge Trees');
77
+ if (trees.length === 0) {
78
+ info('No verification trees. Create one with: fireforge tree create <name>');
79
+ outro('Done');
80
+ return;
81
+ }
82
+ for (const tree of trees) {
83
+ info(`${tree.name.padEnd(20)} ${tree.staleness.padEnd(24)} created ${tree.createdAt}`);
84
+ }
85
+ outro(`${trees.length} tree(s)`);
86
+ }
87
+ /** Removes one tree (or all of them with `--all`). */
88
+ export async function treeRemoveCommand(projectRoot, name, options = {}) {
89
+ assertPosix();
90
+ intro('FireForge Tree Remove');
91
+ if (options.all !== true && name === undefined) {
92
+ throw new GeneralError('Pass a tree name, or --all to remove every tree.');
93
+ }
94
+ await withTreeLifecycleLock(projectRoot, async () => {
95
+ const targets = options.all === true ? (await listTrees(projectRoot)).map((t) => t.name) : [name ?? ''];
96
+ if (targets.length === 0) {
97
+ info('No verification trees to remove.');
98
+ return;
99
+ }
100
+ for (const target of targets) {
101
+ await removeTree(projectRoot, target);
102
+ success(`Removed verification tree "${target}"`);
103
+ }
104
+ });
105
+ outro('Tree removal complete');
106
+ }
107
+ /** Spawns `fireforge <args>` with the tree as its working directory. */
108
+ async function treeExecCommand(projectRoot, name, args) {
109
+ assertPosix();
110
+ assertValidTreeName(name);
111
+ const treeRoot = join(getTreesDir(projectRoot), name);
112
+ const marker = await readTreeMarker(treeRoot);
113
+ if (!marker) {
114
+ throw new GeneralError(`No verification tree named "${name}". List trees with: fireforge tree list`);
115
+ }
116
+ const current = await computePrimaryFingerprint(projectRoot);
117
+ if (marker.engineHead !== current.engineHead) {
118
+ warn(`Tree "${name}" is stale (primary engine advanced since the snapshot).`);
119
+ }
120
+ else if (marker.patchesFingerprint !== current.patchesFingerprint) {
121
+ warn(`Tree "${name}" is stale (primary patches changed since the snapshot).`);
122
+ }
123
+ const cliEntry = process.argv[1];
124
+ if (cliEntry === undefined) {
125
+ throw new GeneralError('Cannot resolve the fireforge CLI entry point for tree exec.');
126
+ }
127
+ const exitCode = await new Promise((resolvePromise, rejectPromise) => {
128
+ const child = spawn(process.execPath, [cliEntry, ...args], {
129
+ cwd: treeRoot,
130
+ stdio: 'inherit',
131
+ });
132
+ child.on('error', rejectPromise);
133
+ child.on('exit', (code) => {
134
+ resolvePromise(code ?? 1);
135
+ });
136
+ });
137
+ if (exitCode !== 0) {
138
+ throw new GeneralError(`tree exec: fireforge ${args.join(' ')} exited with code ${String(exitCode)}.`);
139
+ }
140
+ }
141
+ /** Registers the tree command group on the CLI program. */
142
+ export function registerTree(program, context) {
143
+ const { getProjectRoot, withErrorHandling } = context;
144
+ const tree = program
145
+ .command('tree')
146
+ .description('Manage copy-on-write verification clones of this project for concurrent read-mostly work (lint, typecheck, status, verify, export --dry-run). Mutation commands are refused inside a tree.');
147
+ tree
148
+ .command('create <name>')
149
+ .description('Clone the project (engine included, obj-* excluded) into .fireforge/trees/<name> using filesystem copy-on-write (APFS clonefile / btrfs-XFS reflink). Refuses on filesystems without CoW support unless --force-copy explicitly accepts a full physical copy.')
150
+ .option('--force-copy', 'Allow a full physical copy when the filesystem cannot copy-on-write. This can copy tens of GB; never implied.')
151
+ .action(withErrorHandling(async (name, options) => {
152
+ await treeCreateCommand(getProjectRoot(), name, options);
153
+ }));
154
+ tree
155
+ .command('list')
156
+ .description('List verification trees with creation time and staleness vs the primary tree (engine HEAD and patches.json fingerprint).')
157
+ .option('--json', 'Output the tree list as JSON')
158
+ .action(withErrorHandling(async (options) => {
159
+ await treeListCommand(getProjectRoot(), options);
160
+ }));
161
+ tree
162
+ .command('remove [name]')
163
+ .description("Delete a verification tree. Refuses while a live process holds the tree's build or engine-session lock.")
164
+ .option('--all', 'Remove every verification tree')
165
+ .action(withErrorHandling(async (name, options) => {
166
+ await treeRemoveCommand(getProjectRoot(), name, options);
167
+ }));
168
+ tree
169
+ .command('exec <name> [args...]')
170
+ .description('Run a fireforge command inside the named tree (equivalent to cd-ing into it). Example: fireforge tree exec shard-a -- lint --per-patch')
171
+ .action(withErrorHandling(async (name, args) => {
172
+ await treeExecCommand(getProjectRoot(), name, args);
173
+ }));
174
+ }
@@ -76,6 +76,13 @@ export interface BuildBaseline {
76
76
  * static-components stale check degrades to "fresh" in that case.
77
77
  */
78
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;
79
86
  }
80
87
  /**
81
88
  * State of the engine at the last FULL build, as far as compiled-in XPCOM
@@ -48,4 +48,4 @@ export declare function readBuildBaseline(projectRoot: string): Promise<BuildBas
48
48
  * does not rebake `components.conf` registrations, so the last FULL
49
49
  * build stays the honest anchor for the compiled StaticComponents table.
50
50
  */
51
- export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage, previousBaseline?: BuildBaseline): Promise<void>;
51
+ export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage, previousBaseline?: BuildBaseline, recordedBy?: string): Promise<void>;
@@ -74,7 +74,7 @@ export async function readBuildBaseline(projectRoot) {
74
74
  * does not rebake `components.conf` registrations, so the last FULL
75
75
  * build stays the honest anchor for the compiled StaticComponents table.
76
76
  */
77
- export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage, previousBaseline) {
77
+ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage, previousBaseline, recordedBy) {
78
78
  let engineHeadSha = '';
79
79
  try {
80
80
  engineHeadSha = await getHead(engineDir);
@@ -103,6 +103,7 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, tes
103
103
  ...(packageableFingerprints !== undefined ? { packageableFingerprints } : {}),
104
104
  ...(testPackagingCoverage !== undefined ? { testPackagingCoverage } : {}),
105
105
  ...(staticComponentsBaseline !== undefined ? { staticComponentsBaseline } : {}),
106
+ ...(recordedBy !== undefined ? { recordedBy } : {}),
106
107
  };
107
108
  await writeJson(getBuildBaselinePath(projectRoot), baseline);
108
109
  }
@@ -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
  }
@@ -328,6 +328,17 @@ function parsePatchLintBlock(rec) {
328
328
  if (checkJsExtraShim !== undefined) {
329
329
  out.checkJsExtraShim = parseShimPath(checkJsExtraShim, 'patchLint.checkJsExtraShim');
330
330
  }
331
+ const checkJsTestFiles = rec.raw('checkJsTestFiles');
332
+ if (checkJsTestFiles !== undefined) {
333
+ if (typeof checkJsTestFiles !== 'boolean') {
334
+ throw new ConfigError('Config field "patchLint.checkJsTestFiles" must be a boolean');
335
+ }
336
+ out.checkJsTestFiles = checkJsTestFiles;
337
+ }
338
+ const checkJsTestShim = rec.raw('checkJsTestShim');
339
+ if (checkJsTestShim !== undefined) {
340
+ out.checkJsTestShim = parseShimPath(checkJsTestShim, 'patchLint.checkJsTestShim');
341
+ }
331
342
  const rawColorAllowlist = rec.raw('rawColorAllowlist');
332
343
  if (rawColorAllowlist !== undefined) {
333
344
  if (!Array.isArray(rawColorAllowlist) ||
@@ -348,12 +359,22 @@ function parsePatchLintBlock(rec) {
348
359
  if (chromeScriptJsDoc !== undefined) {
349
360
  out.chromeScriptJsDoc = chromeScriptJsDoc;
350
361
  }
362
+ const undefinedIdentifiers = parseSeverityGate(rec.raw('undefinedIdentifiers'), 'patchLint.undefinedIdentifiers');
363
+ if (undefinedIdentifiers !== undefined) {
364
+ out.undefinedIdentifiers = undefinedIdentifiers;
365
+ }
351
366
  if (out.checkJsStrict === true && out.checkJs !== true) {
352
367
  throw new ConfigError('Config field "patchLint.checkJsStrict" requires "patchLint.checkJs": true');
353
368
  }
354
369
  if (out.checkJsCompilerOptions !== undefined && out.checkJsStrict !== true) {
355
370
  throw new ConfigError('Config field "patchLint.checkJsCompilerOptions" requires "patchLint.checkJsStrict": true');
356
371
  }
372
+ if (out.checkJsTestFiles === true && out.checkJs !== true) {
373
+ throw new ConfigError('Config field "patchLint.checkJsTestFiles" requires "patchLint.checkJs": true');
374
+ }
375
+ if (out.checkJsTestShim !== undefined && out.checkJsTestFiles !== true) {
376
+ throw new ConfigError('Config field "patchLint.checkJsTestShim" requires "patchLint.checkJsTestFiles": true');
377
+ }
357
378
  return out;
358
379
  }
359
380
  /**
@@ -413,6 +434,10 @@ function parseTypecheckBlock(rec) {
413
434
  if (overrides) {
414
435
  out.projectOverrides = overrides;
415
436
  }
437
+ const undefinedIdentifiers = parseSeverityGate(rec.raw('undefinedIdentifiers'), 'typecheck.undefinedIdentifiers');
438
+ if (undefinedIdentifiers !== undefined) {
439
+ out.undefinedIdentifiers = undefinedIdentifiers;
440
+ }
416
441
  return out;
417
442
  }
418
443
  /**