@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.
- package/CHANGELOG.md +29 -0
- package/dist/build-info.json +4 -4
- package/dist/src/cli.js +16 -28
- package/dist/src/commands/build.js +30 -4
- package/dist/src/commands/doctor-engine-eslint.d.ts +3 -0
- package/dist/src/commands/doctor-engine-eslint.js +12 -0
- package/dist/src/commands/doctor-macos-sdk.js +7 -5
- package/dist/src/commands/doctor-source-pin.js +9 -2
- package/dist/src/commands/doctor.js +2 -0
- package/dist/src/commands/furnace/apply.js +10 -4
- package/dist/src/commands/furnace/create-templates.js +0 -3
- package/dist/src/commands/lint.js +4 -0
- package/dist/src/commands/manifest.d.ts +1 -2
- package/dist/src/commands/patch/delete.js +18 -4
- package/dist/src/commands/re-export-options.js +7 -0
- package/dist/src/commands/re-export-register.js +1 -0
- package/dist/src/commands/re-export-removed.d.ts +4 -0
- package/dist/src/commands/re-export-removed.js +49 -0
- package/dist/src/commands/re-export-single.js +4 -1
- package/dist/src/commands/re-export.js +4 -0
- package/dist/src/commands/test-browser-preflight.d.ts +28 -0
- package/dist/src/commands/test-browser-preflight.js +181 -0
- package/dist/src/commands/test-build-phase.js +30 -7
- package/dist/src/commands/test-checkjs.d.ts +3 -0
- package/dist/src/commands/test-checkjs.js +27 -0
- package/dist/src/commands/test-diagnose.js +3 -0
- package/dist/src/commands/test-harness-env.d.ts +5 -6
- package/dist/src/commands/test-harness-env.js +27 -7
- package/dist/src/commands/test-modes.d.ts +2 -4
- package/dist/src/commands/test-register.js +11 -5
- package/dist/src/commands/test-run.d.ts +2 -0
- package/dist/src/commands/test-run.js +21 -1
- package/dist/src/commands/test-verdict.d.ts +2 -2
- package/dist/src/commands/test.js +83 -211
- package/dist/src/core/build-baseline-types.d.ts +12 -14
- package/dist/src/core/build-baseline-validate.d.ts +3 -0
- package/dist/src/core/build-baseline-validate.js +53 -0
- package/dist/src/core/build-baseline.d.ts +4 -3
- package/dist/src/core/build-baseline.js +23 -11
- package/dist/src/core/component-source-guard.d.ts +20 -0
- package/dist/src/core/component-source-guard.js +68 -0
- package/dist/src/core/coverage-extend.d.ts +5 -34
- package/dist/src/core/coverage-extend.js +26 -37
- package/dist/src/core/engine-write-boundary.d.ts +5 -0
- package/dist/src/core/engine-write-boundary.js +41 -0
- package/dist/src/core/furnace-apply-helpers.js +8 -2
- package/dist/src/core/furnace-apply.js +7 -0
- package/dist/src/core/furnace-validate-accessibility.js +9 -12
- package/dist/src/core/furnace-validate.js +0 -43
- package/dist/src/core/gecko-dom-types.d.ts +2 -0
- package/dist/src/core/gecko-dom-types.js +17 -0
- package/dist/src/core/lit-template-regions.d.ts +4 -0
- package/dist/src/core/lit-template-regions.js +50 -0
- package/dist/src/core/mach-build-artifacts.d.ts +6 -6
- package/dist/src/core/mach-build-artifacts.js +13 -7
- package/dist/src/core/mach-resource-shim.d.ts +1 -1
- package/dist/src/core/mach-resource-shim.js +2 -0
- package/dist/src/core/marionette-port.d.ts +2 -0
- package/dist/src/core/marionette-port.js +38 -53
- package/dist/src/core/mochitest-server-port.d.ts +1 -1
- package/dist/src/core/mochitest-server-port.js +20 -17
- package/dist/src/core/objdir-maintenance.d.ts +10 -0
- package/dist/src/core/objdir-maintenance.js +167 -0
- package/dist/src/core/patch-lint-checkjs.js +2 -1
- package/dist/src/core/patch-parse.js +8 -6
- package/dist/src/core/preflight-wait.d.ts +2 -0
- package/dist/src/core/preflight-wait.js +24 -0
- package/dist/src/core/process-owner.d.ts +9 -0
- package/dist/src/core/process-owner.js +43 -0
- package/dist/src/core/test-harness-crash.d.ts +1 -1
- package/dist/src/core/test-harness-crash.js +5 -5
- package/dist/src/core/test-host-state.d.ts +14 -0
- package/dist/src/core/test-host-state.js +80 -0
- package/dist/src/core/test-profile-files.d.ts +17 -0
- package/dist/src/core/test-profile-files.js +75 -0
- package/dist/src/core/test-stale-check.d.ts +6 -7
- package/dist/src/core/test-stale-check.js +9 -32
- package/dist/src/core/token-category.d.ts +7 -26
- package/dist/src/core/token-category.js +91 -117
- package/dist/src/core/token-inventory.js +12 -7
- package/dist/src/core/typecheck.js +6 -6
- package/dist/src/types/commands/options.d.ts +14 -3
- package/dist/src/utils/assert.js +2 -7
- package/dist/src/utils/child-env.d.ts +0 -3
- package/dist/src/utils/child-env.js +0 -3
- package/dist/src/utils/concurrency.d.ts +2 -2
- package/dist/src/utils/concurrency.js +15 -4
- package/dist/src/utils/fs.js +11 -1
- package/dist/src/utils/options.d.ts +16 -1
- package/dist/src/utils/options.js +5 -3
- package/dist/src/utils/paths.js +3 -2
- package/dist/src/utils/process-group.d.ts +1 -2
- package/dist/src/utils/process-group.js +1 -2
- package/dist/src/utils/process-input.js +0 -3
- package/dist/src/utils/process.d.ts +3 -2
- package/dist/src/utils/process.js +19 -7
- package/docs/configuration.md +12 -11
- package/docs/lifecycle-invariants.md +9 -5
- package/docs/machine-output.md +36 -19
- package/docs/patch-workflow.md +15 -0
- package/docs/testing.md +77 -12
- 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
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.
|
|
4
|
-
"commit": "
|
|
5
|
-
"shortCommit": "
|
|
3
|
+
"version": "0.49.1",
|
|
4
|
+
"commit": "6348d3cae2e93096039d40ce0b52bfce0a0b9308",
|
|
5
|
+
"shortCommit": "6348d3cae2e9",
|
|
6
6
|
"dirty": false,
|
|
7
7
|
"dirtyHash": null,
|
|
8
|
-
"buildTime": "2026-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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(
|
|
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,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
|
-
|
|
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
|
|
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
|
|
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
|
}
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
+
}>;
|