@hominis/fireforge 0.48.0 → 0.49.1

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 (102) 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 +167 -0
  64. package/dist/src/core/patch-lint-checkjs.js +2 -1
  65. package/dist/src/core/patch-parse.js +8 -6
  66. package/dist/src/core/preflight-wait.d.ts +2 -0
  67. package/dist/src/core/preflight-wait.js +24 -0
  68. package/dist/src/core/process-owner.d.ts +9 -0
  69. package/dist/src/core/process-owner.js +43 -0
  70. package/dist/src/core/test-harness-crash.d.ts +1 -1
  71. package/dist/src/core/test-harness-crash.js +5 -5
  72. package/dist/src/core/test-host-state.d.ts +14 -0
  73. package/dist/src/core/test-host-state.js +80 -0
  74. package/dist/src/core/test-profile-files.d.ts +17 -0
  75. package/dist/src/core/test-profile-files.js +75 -0
  76. package/dist/src/core/test-stale-check.d.ts +6 -7
  77. package/dist/src/core/test-stale-check.js +9 -32
  78. package/dist/src/core/token-category.d.ts +7 -26
  79. package/dist/src/core/token-category.js +91 -117
  80. package/dist/src/core/token-inventory.js +12 -7
  81. package/dist/src/core/typecheck.js +6 -6
  82. package/dist/src/types/commands/options.d.ts +14 -3
  83. package/dist/src/utils/assert.js +2 -7
  84. package/dist/src/utils/child-env.d.ts +0 -3
  85. package/dist/src/utils/child-env.js +0 -3
  86. package/dist/src/utils/concurrency.d.ts +2 -2
  87. package/dist/src/utils/concurrency.js +15 -4
  88. package/dist/src/utils/fs.js +11 -1
  89. package/dist/src/utils/options.d.ts +16 -1
  90. package/dist/src/utils/options.js +5 -3
  91. package/dist/src/utils/paths.js +3 -2
  92. package/dist/src/utils/process-group.d.ts +1 -2
  93. package/dist/src/utils/process-group.js +1 -2
  94. package/dist/src/utils/process-input.js +0 -3
  95. package/dist/src/utils/process.d.ts +3 -2
  96. package/dist/src/utils/process.js +19 -7
  97. package/docs/configuration.md +12 -11
  98. package/docs/lifecycle-invariants.md +9 -5
  99. package/docs/machine-output.md +36 -19
  100. package/docs/patch-workflow.md +15 -0
  101. package/docs/testing.md +77 -12
  102. package/package.json +6 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.49.0
4
+
5
+ ### Furnace and builds
6
+
7
+ - Safer Furnace deployment and objdir cloning protect component sources and peer checkouts from shared files and directory aliases. Failed copies finish before rollback; cloned build metadata is relocated and verified.
8
+ - Build and test preflight detect broken partial configuration, stale artifacts and invalid build baselines. Scoped builds retain coverage only while their inputs remain unchanged, and builds remove dangling test links.
9
+
10
+ ### Test harness
11
+
12
+ - Added `test --wait-browser [seconds]`, `--wait-port [seconds]` and repeatable `--profile-file source=destination`. Browser ownership checks protect running builds; orphan cleanup waits for process and port release.
13
+ - Test verdicts include host load and perf power-source information. Power changes make perf results inconclusive. Seeded shuffle reproduces shard order; native file shuffle remains unseeded.
14
+
15
+ ### Typecheck and lint
16
+
17
+ - Project typecheck uses fresh diagnostics with consistent cached and uncached results. Gecko constructor shims support instance narrowing and pen input; selected test scripts receive configured checkJs checks before pre-test builds.
18
+ - Improved Lit accessibility checks and token category parsing. Doctor checks engine source pins and reports missing engine-local ESLint setup early.
19
+
20
+ ### Patch queue
21
+
22
+ - Added `re-export --expect-removed <path>` for safely retiring deleted patch-created files. Patch deletion rechecks the approved queue under lock.
23
+
24
+ ### Command reliability
25
+
26
+ - Fixed leaf-command `--wait-lock` handling, JSON error reporting and watch retries. Process tracking and independent cleanup preserve the original run outcome through cancellation and cleanup failures.
27
+
28
+ ### Tooling
29
+
30
+ - Full-Firefox integration preserves existing workspace files and Git index state, and fails on incomplete recovery. Build identity includes untracked contents and binary changes. Release checks enforce the pinned toolchain and additional module coverage floors.
31
+
3
32
  ## 0.48.0
4
33
 
5
34
  ### Engine lifecycle
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.48.0",
4
- "commit": "9215c35e3907f14cc03379fb04c867ebd954085a",
5
- "shortCommit": "9215c35e3907",
3
+ "version": "0.49.1",
4
+ "commit": "6348d3cae2e93096039d40ce0b52bfce0a0b9308",
5
+ "shortCommit": "6348d3cae2e9",
6
6
  "dirty": false,
7
7
  "dirtyHash": null,
8
- "buildTime": "2026-09-22T21:34:33.874Z"
8
+ "buildTime": "2026-10-04T15:20:41.231Z"
9
9
  }
package/dist/src/cli.js CHANGED
@@ -154,44 +154,32 @@ export function withErrorHandling(handler) {
154
154
  if (error instanceof CommandError) {
155
155
  throw error;
156
156
  }
157
+ const normalizedError = toError(error);
158
+ const exitCode = error instanceof FireForgeError ? error.code : ExitCode.GENERAL_ERROR;
157
159
  if (error instanceof CancellationError) {
158
160
  cancel('Operation cancelled');
159
- // 130 (128+SIGINT) is the conventional "user interrupted" code, so
160
- // scripts/CI can distinguish a prompt cancellation from a real
161
- // failure (which exits 1).
162
- throw new CommandError(ExitCode.USER_CANCELLED);
163
161
  }
164
- // An invariant failure is the one FireForgeError whose stack is part
165
- // of the report: the userMessage asks the operator to file the run,
166
- // and without the stack there is nothing in it that locates the bug.
167
- // console.error keeps it on stderr, so a --json payload stays intact.
168
- if (error instanceof InternalInvariantError) {
162
+ else if (error instanceof InternalInvariantError) {
163
+ // Invariant failures always include their stack for a bug report.
169
164
  logError(error.userMessage);
170
- if (error.stack) {
165
+ if (error.stack)
171
166
  console.error(error.stack);
172
- }
173
- throw new CommandError(error.code);
174
167
  }
175
- if (error instanceof FireForgeError) {
176
- // In machine mode the payload contract owes the consumer a parseable
177
- // refusal on stdout, not just a non-zero exit. `logError` has already
178
- // been routed to stderr by the mode, so the two do not collide.
168
+ else if (error instanceof FireForgeError) {
179
169
  logError(error.userMessage);
180
170
  printCauseChain(error);
181
- // Not when a payload already owns stdout: `status --json --fail-on`
182
- // writes its full document and then refuses, and appending an
183
- // envelope would make that two JSON documents.
184
- if (machineOutput && !isStdoutSealed()) {
185
- emitMachineError(machineErrorCode(error), error.message, error.code);
186
- }
187
- throw new CommandError(error.code);
188
171
  }
189
- const normalizedError = toError(error);
190
- logError(`Unexpected error: ${normalizedError.message}`);
191
- if (normalizedError.stack) {
192
- console.error(normalizedError.stack);
172
+ else {
173
+ logError(`Unexpected error: ${normalizedError.message}`);
174
+ if (normalizedError.stack)
175
+ console.error(normalizedError.stack);
176
+ }
177
+ // One final sink covers every classification. A command that already
178
+ // sealed stdout owns its payload; appending an envelope would corrupt it.
179
+ if (machineOutput && !isStdoutSealed()) {
180
+ emitMachineError(error instanceof FireForgeError ? machineErrorCode(error) : 'unexpected-error', normalizedError.message, exitCode);
193
181
  }
194
- throw new CommandError(ExitCode.GENERAL_ERROR);
182
+ throw new CommandError(exitCode);
195
183
  }
196
184
  finally {
197
185
  // Central machine-mode and stdout-seal reset. Commands leave both
@@ -1,4 +1,5 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { join } from 'node:path';
2
3
  import { Command, InvalidArgumentError as CommanderInvalidArgumentError } from 'commander';
3
4
  import { validateBrandOverride } from '../core/brand-validation.js';
4
5
  import { auditBuildArtifacts } from '../core/build-audit.js';
@@ -8,6 +9,10 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
8
9
  import { assertEngineExists } from '../core/engine-precondition.js';
9
10
  import { withEngineSessionLock } from '../core/engine-session-lock.js';
10
11
  import { attemptMozinfoRewrite, build, buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, runMach, withBuildLock, } from '../core/mach.js';
12
+ import { findObjdirRelocationViolation } from '../core/mach-build-artifacts.js';
13
+ import { ensureLaunchableBrowserNotRunning } from '../core/marionette-port.js';
14
+ import { assertHealthyPartialConfig, pruneDanglingTestLinks } from '../core/objdir-maintenance.js';
15
+ import { waitForPreflight } from '../core/preflight-wait.js';
11
16
  import { closeActiveRunLog, getActiveRunLogPath, openRunLog, setActiveRunLog, } from '../core/run-log.js';
12
17
  import { buildHarnessCrashMessage } from '../core/test-harness-crash.js';
13
18
  import { formatToolchainMismatchMessage, runToolchainPreflight, } from '../core/toolchain-preflight.js';
@@ -16,8 +21,8 @@ import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
16
21
  import { elapsedSince } from '../utils/elapsed.js';
17
22
  import { toError } from '../utils/errors.js';
18
23
  import { checkDiskSpace } from '../utils/fs.js';
19
- import { error, info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
20
- import { addWaitLockOption, pickDefined, resolveWaitLockSeconds } from '../utils/options.js';
24
+ import { error, info, intro, notice, outro, spinner, verbose, warn } from '../utils/logger.js';
25
+ import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, } from '../utils/options.js';
21
26
  import { isPositiveInteger } from '../utils/validation.js';
22
27
  function parseJobCount(value) {
23
28
  const parsed = Number(value);
@@ -63,6 +68,13 @@ async function rewriteAndReconfigure(engineDir, objDir, mismatchMessage) {
63
68
  throw new BuildError(`mach configure exited non-zero (${exitCode}) after mozinfo rewrite; a clean rebuild is required.`, 'mach configure');
64
69
  }
65
70
  configureSpinner.stop('mach configure regenerated the backend');
71
+ const violation = await findObjdirRelocationViolation({
72
+ engineDir,
73
+ objDir,
74
+ forbiddenDir: rewrite.oldTopsrcdir ?? '',
75
+ });
76
+ if (violation)
77
+ throw new GeneralError(`Relocation incomplete: ${violation}`);
66
78
  info('Backend path-rewrite complete; continuing with the build.');
67
79
  }
68
80
  function resolveJobCount(options, configJobs) {
@@ -122,8 +134,6 @@ function extractLikelyFailingCommand(captured) {
122
134
  continue;
123
135
  if (isWarningOnlyLine(line))
124
136
  continue;
125
- if (/^make(?:\[\d+\])?:/.test(line))
126
- continue;
127
137
  if (/^g?make(?:\[\d+\])?:/.test(line))
128
138
  continue;
129
139
  if (/^Error running mach:/.test(line))
@@ -186,6 +196,20 @@ export async function buildCommand(projectRoot, options) {
186
196
  info(`Full build output: ${logPath}`);
187
197
  }
188
198
  }
199
+ /** Checks objdir health and prevents deployments under a running browser. */
200
+ async function preflightBuildObjdir(engineDir, binaryName, objDir, options) {
201
+ if (!objDir)
202
+ return;
203
+ await assertHealthyPartialConfig(engineDir, objDir);
204
+ const bundle = await hasRunnableBundle(engineDir, binaryName, objDir);
205
+ const expectedPath = bundle.expectedPath;
206
+ if (bundle.runnable && expectedPath) {
207
+ await waitForPreflight(() => ensureLaunchableBrowserNotRunning(join(engineDir, expectedPath)), options.waitBrowser === undefined ? undefined : resolveWaitLockSeconds(options.waitBrowser));
208
+ }
209
+ const pruned = await pruneDanglingTestLinks(engineDir, objDir);
210
+ if (pruned > 0)
211
+ notice(`Pruned ${pruned} dangling _tests symlink(s).`);
212
+ }
189
213
  async function runBuildCommandBody(projectRoot, options) {
190
214
  const buildType = options.ui ? 'UI-only' : 'Full';
191
215
  // Load configuration
@@ -253,6 +277,7 @@ async function runBuildCommandBody(projectRoot, options) {
253
277
  // auto-configure step there can detect moz.build-family changes since the
254
278
  // last successful build. The post-build audit below reuses the same
255
279
  // baseline to diff engine changes against dist artifacts.
280
+ await preflightBuildObjdir(paths.engine, config.binaryName, buildCheck.objDir, options);
256
281
  const previousBaseline = await readBuildBaseline(projectRoot);
257
282
  // Shared pre-flight: branding, Furnace, mozconfig, auto-configure
258
283
  await prepareBuildEnvironment(projectRoot, paths, config, {
@@ -374,6 +399,7 @@ export function registerBuild(program, { getProjectRoot, withErrorHandling }) {
374
399
  const build = program
375
400
  .command('build')
376
401
  .description('Build the browser (auto-applies Furnace components first)')
402
+ .option('--wait-browser [seconds]', 'Wait for this objdir browser to exit before changing its build (default 60)', commanderArgParser((raw) => resolveWaitLockSeconds(raw)))
377
403
  .option('--ui', 'Fast UI-only rebuild')
378
404
  .option('-j, --jobs <n>', 'Number of parallel jobs', parseJobCount)
379
405
  .option('--brand <name>', 'Build specific brand')
@@ -0,0 +1,3 @@
1
+ import type { DoctorCheckDefinition } from './doctor-check-core.js';
2
+ /** Surfaces the source-refresh ESLint precondition before an expensive gate/build. */
3
+ export declare const ENGINE_ESLINT_DOCTOR_CHECK: DoctorCheckDefinition;
@@ -0,0 +1,12 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { join } from 'node:path';
3
+ import { pathExists } from '../utils/fs.js';
4
+ import { ok, warning } from './doctor-check-core.js';
5
+ /** Surfaces the source-refresh ESLint precondition before an expensive gate/build. */
6
+ export const ENGINE_ESLINT_DOCTOR_CHECK = {
7
+ name: 'Engine ESLint installed',
8
+ skipIf: (ctx) => !ctx.engineExists,
9
+ run: async (ctx) => (await pathExists(join(ctx.paths.engine, 'node_modules/eslint/bin/eslint.js')))
10
+ ? ok('Engine ESLint installed')
11
+ : warning('Engine ESLint installed', 'Engine-local ESLint is missing after source extraction; an engine-eslint ratchet cannot run.', 'Run "cd engine && ./mach eslint --setup" after each source refresh, before building or running the gate.'),
12
+ };
@@ -13,12 +13,14 @@
13
13
  * selects, and on failure names the mismatch and lists the SDKs installed.
14
14
  *
15
15
  * darwin only, and only when a bootstrapped clang exists. A warning, never
16
- * a failure: the SDK may be one the operator is about to replace.
16
+ * a failure: the SDK may be one the operator is about to replace. Host SDK
17
+ * and mozconfig paths are built with `posix.join`: they are macOS paths
18
+ * whatever platform the module is loaded on.
17
19
  */
18
20
  import { existsSync } from 'node:fs';
19
21
  import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
20
22
  import { tmpdir } from 'node:os';
21
- import { join } from 'node:path';
23
+ import { join, posix } from 'node:path';
22
24
  import { mozbuildStateDir } from '../core/toolchain-preflight.js';
23
25
  import { toError } from '../utils/errors.js';
24
26
  import { exec } from '../utils/process.js';
@@ -45,7 +47,7 @@ const SDK_FROM_MOZCONFIG_SCRIPT = [
45
47
  * auto-detects when the mozconfig names none.
46
48
  */
47
49
  async function resolveSelectedSdk(engineDir) {
48
- const mozconfig = join(engineDir, 'mozconfig');
50
+ const mozconfig = posix.join(engineDir, 'mozconfig');
49
51
  if (existsSync(mozconfig)) {
50
52
  const sourced = await exec('/bin/sh', ['-c', SDK_FROM_MOZCONFIG_SCRIPT, 'sh', mozconfig], {
51
53
  cwd: engineDir,
@@ -83,14 +85,14 @@ async function listInstalledSdks() {
83
85
  const roots = [mozbuildStateDir(), '/Library/Developer/CommandLineTools/SDKs'];
84
86
  const developer = await exec('xcode-select', ['-p'], { timeout: 10_000 });
85
87
  if (developer.exitCode === 0 && developer.stdout.trim().length > 0) {
86
- roots.push(join(developer.stdout.trim(), 'Platforms/MacOSX.platform/Developer/SDKs'));
88
+ roots.push(posix.join(developer.stdout.trim(), 'Platforms/MacOSX.platform/Developer/SDKs'));
87
89
  }
88
90
  const found = [];
89
91
  for (const root of roots) {
90
92
  try {
91
93
  for (const entry of await readdir(root)) {
92
94
  if (/^MacOSX.*\.sdk$/.test(entry))
93
- found.push(join(root, entry));
95
+ found.push(posix.join(root, entry));
94
96
  }
95
97
  }
96
98
  catch {
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { getFirefoxVersion } from '../core/firefox.js';
25
25
  import { toError } from '../utils/errors.js';
26
+ import { exec } from '../utils/process.js';
26
27
  import { ok, warning } from './doctor-check-core.js';
27
28
  const CHECK_NAME = 'Source pin matches engine';
28
29
  /**
@@ -42,7 +43,13 @@ async function runSourcePinCheck(ctx) {
42
43
  // A blank or whitespace-only version.txt (a truncated write, a partial
43
44
  // extraction) carries no version to compare, so treat it as absent
44
45
  // rather than reporting the engine as being at version "".
45
- actual = (await getFirefoxVersion(ctx.paths.engine))?.trim() || undefined;
46
+ const pristine = await exec('git', ['show', 'HEAD:browser/config/version.txt'], {
47
+ cwd: ctx.paths.engine,
48
+ }).catch(() => undefined);
49
+ actual =
50
+ (pristine?.exitCode === 0
51
+ ? pristine.stdout
52
+ : await getFirefoxVersion(ctx.paths.engine))?.trim() || undefined;
46
53
  }
47
54
  catch (error) {
48
55
  // Best-effort: an unreadable version.txt is not a pin mismatch, and a
@@ -52,7 +59,7 @@ async function runSourcePinCheck(ctx) {
52
59
  const downloaded = ctx.state.downloadedVersion;
53
60
  const mismatches = [];
54
61
  if (actual !== undefined && actual !== pinned) {
55
- mismatches.push(`engine/browser/config/version.txt reads ${actual}`);
62
+ mismatches.push(`engine baseline browser/config/version.txt reads ${actual}`);
56
63
  }
57
64
  if (downloaded !== undefined && downloaded !== pinned) {
58
65
  mismatches.push(`the last download recorded ${downloaded}`);
@@ -15,6 +15,7 @@ import { error, info, intro, outro, success, warn } from '../utils/logger.js';
15
15
  import { addWaitLockOption } from '../utils/options.js';
16
16
  import { findExecutable } from '../utils/process.js';
17
17
  import { failure, ok, warning } from './doctor-check-core.js';
18
+ import { ENGINE_ESLINT_DOCTOR_CHECK } from './doctor-engine-eslint.js';
18
19
  import { EXTERNAL_TOOLCHAIN_DOCTOR_CHECK } from './doctor-external-toolchains.js';
19
20
  import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
20
21
  import { MACOS_SDK_LINK_DOCTOR_CHECK } from './doctor-macos-sdk.js';
@@ -211,6 +212,7 @@ const DOCTOR_CHECKS = [
211
212
  },
212
213
  },
213
214
  SOURCE_PIN_DOCTOR_CHECK,
215
+ ENGINE_ESLINT_DOCTOR_CHECK,
214
216
  {
215
217
  name: 'Engine is git repository',
216
218
  skipIf: (ctx) => !ctx.engineExists,
@@ -94,6 +94,7 @@ async function runWatchLoop(projectRoot) {
94
94
  // because the post-apply checksum snapshot already reflects the edit so
95
95
  // the 30s poll also sees no diff.
96
96
  let pendingChange = false;
97
+ let retryNeeded = false;
97
98
  let lastChecksums = new Map();
98
99
  let pollTimer = null;
99
100
  const runApplyCycle = async () => {
@@ -102,18 +103,21 @@ async function runWatchLoop(projectRoot) {
102
103
  info('\nChange detected — re-applying...');
103
104
  const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
104
105
  logApplyResult(result, false);
106
+ if (result.errors.length > 0 || countEntriesWithBlockingStepErrors(result.applied) > 0) {
107
+ throw new FurnaceError('Watch apply did not complete; retrying on the next poll.');
108
+ }
105
109
  const applied = result.applied.length;
106
110
  const skipped = result.skipped.length;
107
111
  info(`Re-applied: ${applied} applied, ${skipped} skipped`);
112
+ retryNeeded = false;
113
+ lastChecksums = await snapshotWatchedChecksums(watchDirs);
108
114
  }
109
115
  catch (err) {
116
+ retryNeeded = true;
110
117
  warn(classifyWatchApplyError(err));
111
118
  }
112
119
  finally {
113
120
  applyInFlight = false;
114
- // Update checksums after apply so the next poll does not re-trigger
115
- // for changes that are already reflected in the engine.
116
- lastChecksums = await snapshotWatchedChecksums(watchDirs);
117
121
  }
118
122
  // Another change arrived while we were applying, so run again and the
119
123
  // edit is not silently absorbed into the post-apply checksum bump.
@@ -224,7 +228,9 @@ async function runWatchLoop(projectRoot) {
224
228
  return;
225
229
  }
226
230
  const current = await snapshotWatchedChecksums(watchDirs);
227
- if (!checksumMapsEqual(current, lastChecksums)) {
231
+ // A failed apply remains dirty even if source checksums did not
232
+ // change. The polling interval bounds retries without a busy loop.
233
+ if (retryNeeded || !checksumMapsEqual(current, lastChecksums)) {
228
234
  triggerApply();
229
235
  }
230
236
  }
@@ -80,9 +80,6 @@ class ${className} extends MozLitElement {
80
80
  /** @type {Record<string, unknown>} */
81
81
  static properties = {};
82
82
 
83
- constructor() {
84
- super();
85
- }
86
83
  ${lifecycleHooks}
87
84
  render() {
88
85
  return html\`
@@ -16,6 +16,7 @@ import { collectDiffFilePaths, tagLintIssues } from '../core/patch-lint-diff-tag
16
16
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
17
17
  import { pathExists } from '../utils/fs.js';
18
18
  import { info, intro, outro, success, warn } from '../utils/logger.js';
19
+ import { addAcceptedWaitLockOption } from '../utils/options.js';
19
20
  import { stripEnginePrefix } from '../utils/paths.js';
20
21
  import { lintPerPatch } from './lint-per-patch.js';
21
22
  /**
@@ -538,6 +539,8 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
538
539
  }
539
540
  await lintCommand(getProjectRoot(), paths, lintOptions);
540
541
  }));
542
+ // This action also has subcommands; all its wait flags are accept-only.
543
+ addAcceptedWaitLockOption(lint);
541
544
  const lintCache = lint
542
545
  .command('cache')
543
546
  .description('Manage the per-patch lint result cache')
@@ -550,6 +553,7 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
550
553
  'To lint a directory named cache/, disambiguate with a trailing slash: "fireforge lint cache/".');
551
554
  return Promise.resolve();
552
555
  }));
556
+ addAcceptedWaitLockOption(lintCache);
553
557
  lintCache
554
558
  .command('clear')
555
559
  .description('Clear cached per-patch lint results')
@@ -25,8 +25,7 @@ export interface CommandManifestEntry {
25
25
  */
26
26
  name: string;
27
27
  /**
28
- * Short one-line group label, used purely for grouping in generated
29
- * documentation. Not surfaced in the CLI itself.
28
+ * Group label used by the root CLI help and generated documentation.
30
29
  */
31
30
  group: 'project' | 'workflow' | 'engine' | 'diagnostics' | 'components';
32
31
  /** Registers the command (and any subcommands) on the Commander program. */
@@ -15,6 +15,7 @@ import { appendHistoryBestEffort } from '../../core/history-log.js';
15
15
  import { buildPatchQueueContext, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, isForwardImportableFile, } from '../../core/patch-lint.js';
16
16
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
17
17
  import { removePatchFileAndManifest } from '../../core/patch-manifest.js';
18
+ import { GeneralError } from '../../errors/base.js';
18
19
  import { info, intro, outro, warn } from '../../utils/logger.js';
19
20
  import { addWaitLockOption, pickDefined, resolveWaitLockSeconds } from '../../utils/options.js';
20
21
  import { proceedAfterDecision } from '../destructive-decision.js';
@@ -43,11 +44,12 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
43
44
  // check is built directly from `baseCtx` rather than by diffing lint runs
44
45
  // over a projected state.
45
46
  const targetEntry = baseCtx.entries.find((e) => e.filename === target.filename);
47
+ if (!targetEntry || JSON.stringify(targetEntry.metadata) !== JSON.stringify(target)) {
48
+ throw new GeneralError('Patch queue changed while preparing deletion. Re-run patch delete to review the current target.');
49
+ }
46
50
  const targetNewFileLeaves = new Set();
47
- if (targetEntry) {
48
- for (const fullPath of targetEntry.newFiles.keys()) {
49
- targetNewFileLeaves.add(basename(fullPath));
50
- }
51
+ for (const fullPath of targetEntry.newFiles.keys()) {
52
+ targetNewFileLeaves.add(basename(fullPath));
51
53
  }
52
54
  // Scan every later patch's new files and its added lines on pre-existing
53
55
  // files for import specifiers that resolve to a leaf owned by the target.
@@ -161,6 +163,18 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
161
163
  // re-thrown: by that point the mutation has committed, and reporting
162
164
  // failure to the caller would mislead.
163
165
  await withPatchDirectoryLock(paths.patches, async () => {
166
+ const current = await buildPatchQueueContext(paths.patches);
167
+ const snapshot = (context) => JSON.stringify(context.entries.map(({ filename, order, diff, metadata }) => ({
168
+ filename,
169
+ order,
170
+ diff,
171
+ metadata,
172
+ })));
173
+ // A changed body or manifest invalidates the dependency scan approved
174
+ // before the prompt. Recheck all of its inputs while holding the lock.
175
+ if (snapshot(current) !== snapshot(baseCtx)) {
176
+ throw new GeneralError('Patch queue changed while waiting for confirmation or the lock. Re-run patch delete to review the current dependencies.');
177
+ }
164
178
  await removePatchFileAndManifest(paths.patches, target.filename);
165
179
  await appendHistoryBestEffort(paths.patches, {
166
180
  operation: 'patch-delete',
@@ -63,8 +63,15 @@ function validateWhitelistFlagDependencies(options) {
63
63
  throw new InvalidArgumentError('--expect-unmanaged names approved exceptions to --refuse-adjacent-unmanaged and has no effect without it. Pass --refuse-adjacent-unmanaged, or drop --expect-unmanaged.', '--expect-unmanaged');
64
64
  }
65
65
  }
66
+ function validateExpectedRemovalUsage(patches, options) {
67
+ if (options.expectRemoved?.length &&
68
+ (options.scan || options.all || options.files !== undefined || patches.length === 0)) {
69
+ throw new InvalidArgumentError('--expect-removed requires explicit selected patches and the scan-less path (no --scan, --files, or --all).', '--expect-removed');
70
+ }
71
+ }
66
72
  /** Validates mutually exclusive `re-export` targeting and metadata options. */
67
73
  export function validateReExportOptionCombinations(patches, options) {
74
+ validateExpectedRemovalUsage(patches, options);
68
75
  if (options.files !== undefined) {
69
76
  if (options.all || options.scan) {
70
77
  throw new InvalidArgumentError('--files cannot be combined with --scan or --all.', '--files');
@@ -30,6 +30,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
30
30
  .option('--refuse-foreign-drift', "Refuse a scan-less re-export (non-zero exit, patch not written) when the refreshed body would absorb engine lines not present in the old patch body — protects multi-session checkouts from silently capturing another session's uncommitted edits. Mutually exclusive with --scan and --files.")
31
31
  .option('--expect <path>', "With --refuse-foreign-drift, name an engine-relative file whose drift is this session's intended edit: drift confined to --expect files proceeds, drift anywhere else still refuses. Repeatable, and the whitelist applies across EVERY patch in this invocation, not per patch — so pass all the patches and all their --expect paths in one call rather than looping one patch at a time.", ...stringListOption())
32
32
  .option('--expect-unmanaged <path>', 'With --refuse-adjacent-unmanaged, name an engine-relative file that is a REVIEWED, recorded exception to the adjacency refusal: an approved unmanaged path is still listed, but no longer refuses. Repeatable, and the carve-out applies across EVERY patch in this invocation, not per patch. Use it to keep the belt armed for everything else instead of dropping --refuse-adjacent-unmanaged entirely.', ...stringListOption())
33
+ .option('--expect-removed <path>', 'Retire an absent file created by the selected patch while keeping scan-less refusals armed. Repeatable.', ...stringListOption())
33
34
  .option('--dry-run', 'Show what would change without writing')
34
35
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
35
36
  .option('--no-cache', 'Bypass per-patch lint result cache reads and writes for this re-export')
@@ -0,0 +1,4 @@
1
+ import type { PatchMetadata, ReExportOptions } from '../types/commands/index.js';
2
+ import type { ProjectPaths } from '../types/config.js';
3
+ /** Validates every retirement before any patch is written; typos never silently grant a carve-out. */
4
+ export declare function validateExpectedRemovals(paths: ProjectPaths, patches: PatchMetadata[], options: ReExportOptions): Promise<string[]>;
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { lstat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { listTrackedInHead } from '../core/git-file-ops.js';
5
+ import { parseDiffSections } from '../core/patch-parse.js';
6
+ import { InvalidArgumentError } from '../errors/base.js';
7
+ import { getNodeErrorCode } from '../utils/errors.js';
8
+ import { readText } from '../utils/fs.js';
9
+ import { normalizeEngineRelativeInput } from './re-export-scan.js';
10
+ /** Validates every retirement before any patch is written; typos never silently grant a carve-out. */
11
+ export async function validateExpectedRemovals(paths, patches, options) {
12
+ const removed = [
13
+ ...new Set((options.expectRemoved ?? []).map((file) => normalizeEngineRelativeInput(file, '--expect-removed'))),
14
+ ];
15
+ if (removed.length === 0)
16
+ return removed;
17
+ const tracked = await listTrackedInHead(paths.engine, removed);
18
+ for (const file of removed) {
19
+ const owners = patches.filter((patch) => patch.filesAffected.includes(file));
20
+ if (owners.length !== 1 ||
21
+ tracked.has(file) ||
22
+ (await removalPathPresent(join(paths.engine, file)))) {
23
+ throw new InvalidArgumentError(`--expect-removed ${file} must be absent, untracked in engine HEAD, and owned by exactly one selected patch.`, '--expect-removed');
24
+ }
25
+ const owner = owners[0];
26
+ if (!owner)
27
+ throw new InvalidArgumentError(`No selected owner for ${file}`, '--expect-removed');
28
+ const body = await readText(join(paths.patches, owner.filename));
29
+ if (!parseDiffSections(body).some((section) => section.targetPath === file && section.isNewFile)) {
30
+ throw new InvalidArgumentError(`--expect-removed ${file} was not created by its selected patch.`, '--expect-removed');
31
+ }
32
+ if (owner.filesAffected.every((path) => removed.includes(path))) {
33
+ throw new InvalidArgumentError('Retiring every file would empty the patch; use patch delete instead.', '--expect-removed');
34
+ }
35
+ }
36
+ return removed;
37
+ }
38
+ /** A dangling symlink is still an existing owned path, not an approved absence. */
39
+ async function removalPathPresent(path) {
40
+ try {
41
+ await lstat(path);
42
+ return true;
43
+ }
44
+ catch (error) {
45
+ if (getNodeErrorCode(error) === 'ENOENT')
46
+ return false;
47
+ throw error;
48
+ }
49
+ }
@@ -41,7 +41,10 @@ export async function reExportSinglePatchWithIndexLockRetry(args) {
41
41
  }
42
42
  async function reExportSinglePatch(args) {
43
43
  const { patch, paths, manifest, options, isDryRun, config, adjacentCtx, driftCtx, lintCtx } = args;
44
- let currentFilesAffected = [...patch.filesAffected];
44
+ const removed = new Set(options.expectRemoved ?? []);
45
+ let currentFilesAffected = patch.filesAffected.filter((file) => !removed.has(file));
46
+ for (const file of patch.filesAffected.filter((path) => removed.has(path)))
47
+ info(` - ${file} (--expect-removed)`);
45
48
  // --- Scan for new/removed files ---
46
49
  if (options.scan) {
47
50
  const scanResult = await scanPatchFilesForReExport({
@@ -15,6 +15,7 @@ import { warnUnseenExpectedDrift } from './re-export-drift.js';
15
15
  import { reExportFilesInPlace } from './re-export-files.js';
16
16
  import { buildReExportLintContext, finishReExportLint } from './re-export-lint.js';
17
17
  import { applyReExportFilesPositionalFolding, validateReExportOptionCombinations, } from './re-export-options.js';
18
+ import { validateExpectedRemovals } from './re-export-removed.js';
18
19
  import { normalizeEngineRelativeInput, normalizeScanFiles } from './re-export-scan.js';
19
20
  import { reExportSinglePatchWithIndexLockRetry } from './re-export-single.js';
20
21
  async function resolveSelectedPatches(patches, options, manifest) {
@@ -98,6 +99,9 @@ export async function reExportCommand(projectRoot, patches, options) {
98
99
  outro('Nothing to re-export');
99
100
  return;
100
101
  }
102
+ const expectedRemovals = await validateExpectedRemovals(paths, selectedPatches, options);
103
+ if (expectedRemovals.length > 0)
104
+ options = { ...options, expectRemoved: expectedRemovals };
101
105
  if (scanFilesByPatch !== undefined) {
102
106
  info(`Bulk scan assignments from ${options.scanFilesManifest}`);
103
107
  for (const patch of selectedPatches) {
@@ -0,0 +1,28 @@
1
+ import type { TestOptions } from '../types/commands/index.js';
2
+ import type { FireForgeConfig } from '../types/config.js';
3
+ /**
4
+ * Runs the `--doctor` marionette handshake probe. With no test paths the
5
+ * probe is the entire command (returns `'stop'` after reporting). With
6
+ * paths it gates the mach invocation, where a FAIL throws before mach runs.
7
+ */
8
+ export declare function runDoctorPreflight(args: {
9
+ engineDir: string;
10
+ effectivePort: number | undefined;
11
+ hasTestPaths: boolean;
12
+ objDir: string | undefined;
13
+ binaryName: string;
14
+ launchablePath: string | undefined;
15
+ }): Promise<'stop' | 'continue'>;
16
+ /**
17
+ * Auto-forwards `--marionette-port` to mach (`--setpref=marionette.port`
18
+ * for the listener, `--marionette=127.0.0.1:<n>` for the mochitest
19
+ * client), skipping each piece the operator already forwarded via
20
+ * `--mach-arg` and the xpcshell flavor that ignores the pref entirely.
21
+ * Mutates `extraArgs` in place.
22
+ */
23
+ export declare function appendMarionetteForwardingArgs(extraArgs: string[], options: TestOptions, forwardedPort: number | undefined, xpcshellOnly?: boolean): void;
24
+ /** Prepares profile overlays and browser/port ownership preconditions. */
25
+ export declare function ensureTestBrowserEnvironment(engineDir: string, launchablePath: string | undefined, xpcshellOnly: boolean, projectConfig: FireForgeConfig, options: TestOptions, objDir: string | undefined): Promise<{
26
+ forwardedPort: number | undefined;
27
+ effectivePort: number | undefined;
28
+ }>;