@hominis/fireforge 0.39.0 → 0.41.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.
- package/CHANGELOG.md +100 -0
- package/README.md +35 -5
- package/dist/bin/fireforge.js +25 -4
- package/dist/build-info.json +9 -0
- package/dist/src/cli.js +27 -3
- package/dist/src/commands/bootstrap.js +5 -1
- package/dist/src/commands/discard.js +2 -22
- package/dist/src/commands/doctor/post-rebase-audit.js +27 -4
- package/dist/src/commands/doctor-check-core.d.ts +20 -0
- package/dist/src/commands/doctor-check-core.js +28 -0
- package/dist/src/commands/doctor-external-toolchains.js +2 -0
- package/dist/src/commands/doctor-furnace-manifest-sync.js +144 -46
- package/dist/src/commands/doctor-furnace.js +13 -11
- package/dist/src/commands/doctor-orphaned-harness.js +2 -1
- package/dist/src/commands/doctor.js +2 -2
- package/dist/src/commands/export-all.js +9 -1
- package/dist/src/commands/export-flow.js +27 -9
- package/dist/src/commands/export-placement-conflicts.d.ts +17 -0
- package/dist/src/commands/export-placement-conflicts.js +74 -0
- package/dist/src/commands/export-placement-gate.js +1 -1
- package/dist/src/commands/export-shared.d.ts +8 -1
- package/dist/src/commands/export-shared.js +30 -10
- package/dist/src/commands/export.js +2 -2
- package/dist/src/commands/furnace/apply.js +4 -3
- package/dist/src/commands/furnace/chrome-doc-remove.js +13 -2
- package/dist/src/commands/furnace/chrome-doc.js +13 -2
- package/dist/src/commands/furnace/create-browser-test.d.ts +5 -0
- package/dist/src/commands/furnace/create-browser-test.js +5 -0
- package/dist/src/commands/furnace/create.js +2 -2
- package/dist/src/commands/furnace/diff.js +2 -1
- package/dist/src/commands/furnace/init.js +4 -6
- package/dist/src/commands/furnace/override.js +2 -2
- package/dist/src/commands/furnace/remove-mutations.d.ts +40 -0
- package/dist/src/commands/furnace/remove-mutations.js +171 -0
- package/dist/src/commands/furnace/remove.js +21 -128
- package/dist/src/commands/furnace/rename-browser-test.js +54 -5
- package/dist/src/commands/furnace/scan.js +27 -36
- package/dist/src/commands/furnace/status.js +30 -28
- package/dist/src/commands/furnace/validate.js +25 -18
- package/dist/src/commands/import.js +6 -20
- package/dist/src/commands/lint-per-patch.js +73 -51
- package/dist/src/commands/lint-per-run-checkjs.d.ts +64 -0
- package/dist/src/commands/lint-per-run-checkjs.js +102 -0
- package/dist/src/commands/lint-report.d.ts +21 -0
- package/dist/src/commands/lint-report.js +62 -0
- package/dist/src/commands/lint.js +26 -22
- package/dist/src/commands/manifest.js +3 -1
- package/dist/src/commands/package.js +9 -16
- package/dist/src/commands/patch/compact.js +5 -5
- package/dist/src/commands/patch/delete.js +5 -5
- package/dist/src/commands/patch/lint-ignore.js +3 -2
- package/dist/src/commands/patch/move-files-create.js +22 -4
- package/dist/src/commands/patch/move-files-into.js +14 -10
- package/dist/src/commands/patch/move-files.js +6 -5
- package/dist/src/commands/patch/rename.d.ts +2 -2
- package/dist/src/commands/patch/rename.js +152 -51
- package/dist/src/commands/patch/reorder.d.ts +9 -0
- package/dist/src/commands/patch/reorder.js +14 -10
- package/dist/src/commands/patch/split-plan.d.ts +13 -6
- package/dist/src/commands/patch/split-plan.js +22 -12
- package/dist/src/commands/patch/split.d.ts +1 -1
- package/dist/src/commands/patch/split.js +23 -9
- package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
- package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
- package/dist/src/commands/patch/staged-dependency.js +112 -14
- package/dist/src/commands/re-export-adjacent.d.ts +34 -0
- package/dist/src/commands/re-export-adjacent.js +105 -0
- package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
- package/dist/src/commands/re-export-bulk-scan.js +142 -2
- package/dist/src/commands/re-export-drift.d.ts +74 -0
- package/dist/src/commands/re-export-drift.js +234 -0
- package/dist/src/commands/re-export-files.js +5 -3
- package/dist/src/commands/re-export-lint.d.ts +97 -0
- package/dist/src/commands/re-export-lint.js +210 -0
- package/dist/src/commands/re-export-options.js +11 -0
- package/dist/src/commands/re-export-register.d.ts +18 -0
- package/dist/src/commands/re-export-register.js +61 -0
- package/dist/src/commands/re-export-scan.js +13 -7
- package/dist/src/commands/re-export-single.d.ts +20 -0
- package/dist/src/commands/re-export-single.js +261 -0
- package/dist/src/commands/re-export.d.ts +0 -4
- package/dist/src/commands/re-export.js +123 -289
- package/dist/src/commands/rebase/abort.js +13 -4
- package/dist/src/commands/rebase/continue.js +11 -4
- package/dist/src/commands/rebase/index.js +19 -29
- package/dist/src/commands/rebase/patch-loop.js +12 -0
- package/dist/src/commands/reset.js +4 -23
- package/dist/src/commands/resolve.js +2 -9
- package/dist/src/commands/run.js +29 -23
- package/dist/src/commands/source.js +3 -9
- package/dist/src/commands/status-check.d.ts +48 -0
- package/dist/src/commands/status-check.js +106 -0
- package/dist/src/commands/status-json.d.ts +31 -0
- package/dist/src/commands/status-json.js +76 -0
- package/dist/src/commands/status-output.d.ts +1 -0
- package/dist/src/commands/status-output.js +10 -1
- package/dist/src/commands/status-ownership.d.ts +52 -0
- package/dist/src/commands/status-ownership.js +57 -0
- package/dist/src/commands/status.js +190 -170
- package/dist/src/commands/test-build-phase.d.ts +19 -0
- package/dist/src/commands/test-build-phase.js +168 -0
- package/dist/src/commands/test-diagnose.js +30 -2
- package/dist/src/commands/test-modes.d.ts +9 -1
- package/dist/src/commands/test-modes.js +46 -13
- package/dist/src/commands/test-register.js +59 -27
- package/dist/src/commands/test-run.d.ts +29 -3
- package/dist/src/commands/test-run.js +59 -7
- package/dist/src/commands/test-verdict.d.ts +32 -0
- package/dist/src/commands/test-verdict.js +48 -0
- package/dist/src/commands/test.d.ts +9 -0
- package/dist/src/commands/test.js +100 -123
- package/dist/src/commands/token-coverage.js +4 -9
- package/dist/src/commands/token.js +4 -1
- package/dist/src/commands/tree.d.ts +19 -0
- package/dist/src/commands/tree.js +320 -0
- package/dist/src/commands/verify.js +2 -2
- package/dist/src/commands/watch.js +22 -21
- package/dist/src/commands/wire.js +1 -1
- package/dist/src/core/ast-utils.d.ts +11 -0
- package/dist/src/core/ast-utils.js +11 -0
- package/dist/src/core/build-audit-platform.js +6 -1
- package/dist/src/core/build-audit-registration.js +2 -0
- package/dist/src/core/build-audit-resolve.d.ts +13 -0
- package/dist/src/core/build-audit-resolve.js +34 -13
- package/dist/src/core/build-audit.js +4 -20
- package/dist/src/core/build-baseline-types.d.ts +29 -8
- package/dist/src/core/build-baseline-types.js +2 -1
- package/dist/src/core/build-baseline.d.ts +10 -2
- package/dist/src/core/build-baseline.js +39 -8
- package/dist/src/core/build-prepare.d.ts +4 -0
- package/dist/src/core/build-prepare.js +7 -1
- package/dist/src/core/config-paths.d.ts +1 -1
- package/dist/src/core/config-paths.js +2 -0
- package/dist/src/core/config-validate-test-toolchains.js +3 -2
- package/dist/src/core/config-validate.js +45 -30
- package/dist/src/core/config.js +16 -3
- package/dist/src/core/coverage-extend.d.ts +89 -0
- package/dist/src/core/coverage-extend.js +188 -0
- package/dist/src/core/destructive.js +3 -1
- package/dist/src/core/discard-baseline.js +18 -4
- package/dist/src/core/engine-precondition.d.ts +35 -0
- package/dist/src/core/engine-precondition.js +50 -0
- package/dist/src/core/engine-session-lock.d.ts +38 -1
- package/dist/src/core/engine-session-lock.js +78 -4
- package/dist/src/core/file-lock.d.ts +7 -0
- package/dist/src/core/file-lock.js +20 -30
- package/dist/src/core/firefox.js +5 -2
- package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
- package/dist/src/core/furnace-apply-helpers.js +12 -3
- package/dist/src/core/furnace-apply-output.js +9 -0
- package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
- package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
- package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
- package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
- package/dist/src/core/furnace-apply.d.ts +16 -4
- package/dist/src/core/furnace-apply.js +39 -43
- package/dist/src/core/furnace-config.js +2 -0
- package/dist/src/core/furnace-css-fragments.js +2 -0
- package/dist/src/core/furnace-jsconfig.js +14 -7
- package/dist/src/core/furnace-registration-ast.js +8 -8
- package/dist/src/core/furnace-registration-remove.js +6 -3
- package/dist/src/core/furnace-registration.js +9 -12
- package/dist/src/core/furnace-rollback.js +1 -1
- package/dist/src/core/furnace-scanner.js +2 -1
- package/dist/src/core/furnace-stale-export.js +2 -1
- package/dist/src/core/furnace-state-persist.d.ts +4 -6
- package/dist/src/core/furnace-validate-compatibility.js +4 -7
- package/dist/src/core/furnace-validate-helpers.js +33 -1
- package/dist/src/core/furnace-validate-registration.js +16 -0
- package/dist/src/core/furnace-validate.js +4 -2
- package/dist/src/core/git-diff.js +37 -2
- package/dist/src/core/lint-cache.d.ts +15 -5
- package/dist/src/core/lint-cache.js +49 -14
- package/dist/src/core/mach-build-artifacts.d.ts +63 -1
- package/dist/src/core/mach-build-artifacts.js +108 -2
- package/dist/src/core/mach-resource-shim.js +1 -0
- package/dist/src/core/marionette-port.d.ts +16 -0
- package/dist/src/core/marionette-port.js +93 -8
- package/dist/src/core/marionette-preflight.js +6 -5
- package/dist/src/core/patch-apply.d.ts +20 -13
- package/dist/src/core/patch-apply.js +32 -33
- package/dist/src/core/patch-export.d.ts +2 -2
- package/dist/src/core/patch-export.js +4 -6
- package/dist/src/core/patch-lint-checkjs.d.ts +42 -2
- package/dist/src/core/patch-lint-checkjs.js +132 -8
- package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
- package/dist/src/core/patch-lint-creators.d.ts +30 -0
- package/dist/src/core/patch-lint-creators.js +45 -0
- package/dist/src/core/patch-lint-cross.d.ts +1 -17
- package/dist/src/core/patch-lint-cross.js +16 -32
- package/dist/src/core/patch-lint-css.js +64 -16
- package/dist/src/core/patch-lint-diff.d.ts +8 -0
- package/dist/src/core/patch-lint-diff.js +31 -0
- package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
- package/dist/src/core/patch-lint-jsdoc.js +15 -5
- package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
- package/dist/src/core/patch-lint-module-registration.js +88 -0
- package/dist/src/core/patch-lint-mozbuild.js +99 -11
- package/dist/src/core/patch-lint-ownership.d.ts +21 -0
- package/dist/src/core/patch-lint-ownership.js +30 -0
- package/dist/src/core/patch-lint-reexports.d.ts +2 -1
- package/dist/src/core/patch-lint-reexports.js +1 -0
- package/dist/src/core/patch-lint.d.ts +31 -0
- package/dist/src/core/patch-lint.js +114 -8
- package/dist/src/core/patch-lock.d.ts +12 -1
- package/dist/src/core/patch-lock.js +40 -4
- package/dist/src/core/patch-manifest-consistency.js +15 -3
- package/dist/src/core/patch-manifest.d.ts +1 -1
- package/dist/src/core/patch-parse.d.ts +8 -0
- package/dist/src/core/patch-parse.js +10 -0
- package/dist/src/core/patch-policy.d.ts +7 -0
- package/dist/src/core/patch-policy.js +4 -1
- package/dist/src/core/patch-transform.d.ts +10 -0
- package/dist/src/core/patch-transform.js +13 -2
- package/dist/src/core/rebase-session.d.ts +44 -2
- package/dist/src/core/rebase-session.js +114 -17
- package/dist/src/core/register-browser-content.js +2 -1
- package/dist/src/core/register-shared-css.js +2 -1
- package/dist/src/core/smoke-patterns.d.ts +24 -16
- package/dist/src/core/smoke-patterns.js +26 -14
- package/dist/src/core/status-binary.d.ts +55 -0
- package/dist/src/core/status-binary.js +124 -0
- package/dist/src/core/status-classify.d.ts +22 -1
- package/dist/src/core/status-classify.js +112 -70
- package/dist/src/core/test-harness-crash.d.ts +32 -1
- package/dist/src/core/test-harness-crash.js +59 -4
- package/dist/src/core/test-path-scope.js +2 -0
- package/dist/src/core/test-stale-check.js +1 -20
- package/dist/src/core/token-category.d.ts +40 -0
- package/dist/src/core/token-category.js +264 -0
- package/dist/src/core/token-manager.d.ts +7 -0
- package/dist/src/core/token-manager.js +48 -196
- package/dist/src/core/toolchain-preflight.js +24 -26
- package/dist/src/core/tree-cow.d.ts +18 -0
- package/dist/src/core/tree-cow.js +71 -0
- package/dist/src/core/tree-guard.d.ts +35 -0
- package/dist/src/core/tree-guard.js +179 -0
- package/dist/src/core/tree-store.d.ts +129 -0
- package/dist/src/core/tree-store.js +452 -0
- package/dist/src/core/typecheck-shim.d.ts +14 -0
- package/dist/src/core/typecheck-shim.js +41 -2
- package/dist/src/core/typecheck.js +2 -1
- package/dist/src/core/wire-destroy.js +14 -3
- package/dist/src/core/wire-init.js +37 -40
- package/dist/src/core/wire-subscript.js +11 -3
- package/dist/src/core/wire-utils.js +4 -4
- package/dist/src/core/xpcshell-appdir.js +3 -0
- package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
- package/dist/src/core/xpcshell-profile-dir.js +45 -0
- package/dist/src/errors/base.d.ts +13 -0
- package/dist/src/errors/base.js +13 -0
- package/dist/src/errors/git.d.ts +8 -0
- package/dist/src/errors/git.js +17 -0
- package/dist/src/errors/rebase.d.ts +13 -0
- package/dist/src/errors/rebase.js +17 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +1 -0
- package/dist/src/types/commands/options.d.ts +119 -3
- package/dist/src/types/commands/patches.d.ts +8 -0
- package/dist/src/types/config.d.ts +19 -0
- package/dist/src/types/furnace.d.ts +7 -0
- package/dist/src/utils/build-info.d.ts +31 -0
- package/dist/src/utils/build-info.js +98 -0
- package/dist/src/utils/concurrency.d.ts +8 -0
- package/dist/src/utils/concurrency.js +21 -0
- package/dist/src/utils/errors.d.ts +39 -0
- package/dist/src/utils/errors.js +55 -0
- package/dist/src/utils/fs.js +5 -12
- package/dist/src/utils/logger.d.ts +14 -2
- package/dist/src/utils/logger.js +43 -13
- package/dist/src/utils/options.d.ts +21 -4
- package/dist/src/utils/options.js +26 -6
- package/dist/src/utils/package-root.js +7 -4
- package/dist/src/utils/process-group.js +13 -2
- package/dist/src/utils/stdio-drain.d.ts +28 -0
- package/dist/src/utils/stdio-drain.js +54 -0
- package/dist/src/utils/validation.d.ts +33 -2
- package/dist/src/utils/validation.js +57 -2
- package/package.json +13 -13
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* `test --build/--build-only --extend-coverage` (FORGE L1): unions the
|
|
4
|
+
* requested paths into the recorded `testPackagingCoverage` instead of
|
|
5
|
+
* replacing it, under an anchor guard.
|
|
6
|
+
*
|
|
7
|
+
* Coverage REPLACES by default for a documented reason
|
|
8
|
+
* (`build-baseline-types.ts`): every baseline write refreshes
|
|
9
|
+
* `packageableFingerprints` for ALL dirty packageable paths, so a blind
|
|
10
|
+
* union would whitewash an earlier scope's edited fixtures while
|
|
11
|
+
* `obj-*`/`_tests/` still holds that scope's stale staging. Extending is
|
|
12
|
+
* honest only while everything the PREVIOUS record vouched for is still
|
|
13
|
+
* true, which is what {@link checkExtendCoverageAnchor} and
|
|
14
|
+
* {@link checkExtendMozconfigAnchor} verify:
|
|
15
|
+
*
|
|
16
|
+
* 1. engine HEAD is unchanged — every committed edit is excluded;
|
|
17
|
+
* 2. every path the previous baseline fingerprinted still hashes to the
|
|
18
|
+
* recorded digest — the earlier scope's staging inputs are byte-identical.
|
|
19
|
+
* Files that became dirty SINCE that build are fine: the current
|
|
20
|
+
* whole-tree `mach build faster` repackages `dist/` and vouches for them;
|
|
21
|
+
* 3. the generated `engine/mozconfig` is unchanged. Engine HEAD does not
|
|
22
|
+
* cover this — the mozconfig is regenerated from project-side
|
|
23
|
+
* `configs/*.mozconfig` templates plus `fireforge.json` on every build,
|
|
24
|
+
* so two builds at the same engine SHA can configure differently.
|
|
25
|
+
*
|
|
26
|
+
* Every failure refuses fail-closed; the operator's remedy is always a plain
|
|
27
|
+
* scoped build, which resets the claim to the paths it actually vouches for.
|
|
28
|
+
*
|
|
29
|
+
* Known boundary, documented rather than closed: dirty NON-packageable
|
|
30
|
+
* fixtures under previously covered paths (plain test files, manifests
|
|
31
|
+
* `isPackageablePath` rejects) are invisible to check 2. Editing one
|
|
32
|
+
* uncommitted between two builds leaves the earlier scope's `_tests/`
|
|
33
|
+
* staging stale while extend still vouches for it. Plain REPLACE has no
|
|
34
|
+
* such window, because the earlier scope simply stops being covered.
|
|
35
|
+
*/
|
|
36
|
+
import { createHash } from 'node:crypto';
|
|
37
|
+
import { readFile } from 'node:fs/promises';
|
|
38
|
+
import { join } from 'node:path';
|
|
39
|
+
import { getNodeErrorCode, toError } from '../utils/errors.js';
|
|
40
|
+
import { verbose } from '../utils/logger.js';
|
|
41
|
+
import { DELETED_FILE_FINGERPRINT, } from './build-baseline-types.js';
|
|
42
|
+
import { getHead, isMissingHeadError } from './git.js';
|
|
43
|
+
/** Filename of the generated mozconfig, relative to the engine directory. */
|
|
44
|
+
const MOZCONFIG_FILENAME = 'mozconfig';
|
|
45
|
+
/** How many diverged paths a refusal names before truncating. */
|
|
46
|
+
const MAX_DIVERGED_REPORTED = 5;
|
|
47
|
+
/**
|
|
48
|
+
* Verifies the parts of the anchor knowable before the build runs: a usable
|
|
49
|
+
* previous baseline, an unchanged engine HEAD, and byte-identical content
|
|
50
|
+
* for every path that baseline fingerprinted.
|
|
51
|
+
* @param engineDir - Path to the engine directory
|
|
52
|
+
* @param previousBaseline - Baseline recorded by the previous build
|
|
53
|
+
*/
|
|
54
|
+
export async function checkExtendCoverageAnchor(engineDir, previousBaseline) {
|
|
55
|
+
if (previousBaseline === undefined) {
|
|
56
|
+
return { ok: false, reason: 'no-baseline', detail: [] };
|
|
57
|
+
}
|
|
58
|
+
if (previousBaseline.mozconfigHash === undefined) {
|
|
59
|
+
return { ok: false, reason: 'no-mozconfig-hash', detail: [] };
|
|
60
|
+
}
|
|
61
|
+
const recordedFingerprints = previousBaseline.packageableFingerprints;
|
|
62
|
+
if (recordedFingerprints === undefined) {
|
|
63
|
+
return { ok: false, reason: 'no-fingerprints', detail: [] };
|
|
64
|
+
}
|
|
65
|
+
const currentHead = await readEngineHead(engineDir);
|
|
66
|
+
if (currentHead === undefined || previousBaseline.engineHeadSha === '') {
|
|
67
|
+
// An unborn or unreadable HEAD gives nothing to anchor to.
|
|
68
|
+
return { ok: false, reason: 'head-moved', detail: [] };
|
|
69
|
+
}
|
|
70
|
+
if (currentHead !== previousBaseline.engineHeadSha) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
reason: 'head-moved',
|
|
74
|
+
detail: [`recorded ${previousBaseline.engineHeadSha}`, `current ${currentHead}`],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const diverged = [];
|
|
78
|
+
for (const [relPath, recordedHash] of Object.entries(recordedFingerprints)) {
|
|
79
|
+
const liveHash = await hashEngineFile(engineDir, relPath);
|
|
80
|
+
if (liveHash !== recordedHash)
|
|
81
|
+
diverged.push(relPath);
|
|
82
|
+
}
|
|
83
|
+
if (diverged.length > 0) {
|
|
84
|
+
return { ok: false, reason: 'fingerprint-diverged', detail: diverged.sort() };
|
|
85
|
+
}
|
|
86
|
+
return { ok: true };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Verifies the mozconfig half of the anchor. Split from
|
|
90
|
+
* {@link checkExtendCoverageAnchor} because it can only run AFTER
|
|
91
|
+
* `prepareBuildEnvironment` regenerates `engine/mozconfig` — that file is
|
|
92
|
+
* the one this build will configure with — and must still run BEFORE mach,
|
|
93
|
+
* so a refusal costs no build time.
|
|
94
|
+
* @param engineDir - Path to the engine directory
|
|
95
|
+
* @param previousBaseline - Baseline recorded by the previous build
|
|
96
|
+
*/
|
|
97
|
+
export async function checkExtendMozconfigAnchor(engineDir, previousBaseline) {
|
|
98
|
+
const recorded = previousBaseline?.mozconfigHash;
|
|
99
|
+
if (recorded === undefined) {
|
|
100
|
+
return { ok: false, reason: 'no-mozconfig-hash', detail: [] };
|
|
101
|
+
}
|
|
102
|
+
const live = await hashEngineFile(engineDir, MOZCONFIG_FILENAME);
|
|
103
|
+
if (live === undefined || live !== recorded) {
|
|
104
|
+
return { ok: false, reason: 'mozconfig-changed', detail: [] };
|
|
105
|
+
}
|
|
106
|
+
return { ok: true };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Unions the newly built paths onto the recorded claim. A `'full'` claim
|
|
110
|
+
* (or a pre-0.37.0 absent one, which means full historically) stays
|
|
111
|
+
* `'full'` — it already covers everything.
|
|
112
|
+
* @param previous - Coverage recorded by the previous build
|
|
113
|
+
* @param requested - Normalized request paths this build packaged
|
|
114
|
+
*/
|
|
115
|
+
export function unionTestPackagingCoverage(previous, requested) {
|
|
116
|
+
if (previous === undefined || previous === 'full') {
|
|
117
|
+
return 'full';
|
|
118
|
+
}
|
|
119
|
+
return [...new Set([...previous, ...requested])].sort();
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Renders the fail-closed refusal for an anchor check that did not pass.
|
|
123
|
+
* Kept separate from the probes so tests can assert the structured result
|
|
124
|
+
* without matching copy.
|
|
125
|
+
* @param result - Failing result from an anchor check
|
|
126
|
+
*/
|
|
127
|
+
export function formatExtendCoverageRefusal(result) {
|
|
128
|
+
const remedy = 'Re-run without --extend-coverage: a plain scoped build REPLACES the coverage claim ' +
|
|
129
|
+
'with the paths it actually vouches for.';
|
|
130
|
+
switch (result.reason) {
|
|
131
|
+
case 'no-baseline':
|
|
132
|
+
return `--extend-coverage has nothing to extend: no previous build baseline is recorded. ${remedy}`;
|
|
133
|
+
case 'no-mozconfig-hash':
|
|
134
|
+
return ('--extend-coverage refused: the recorded baseline predates the mozconfig anchor, so it ' +
|
|
135
|
+
`cannot be proven that this build configures identically. ${remedy}`);
|
|
136
|
+
case 'no-fingerprints':
|
|
137
|
+
return ('--extend-coverage refused: the recorded baseline carries no packageable fingerprints, so ' +
|
|
138
|
+
`the earlier scope's staging inputs cannot be verified unchanged. ${remedy}`);
|
|
139
|
+
case 'head-moved':
|
|
140
|
+
return ('--extend-coverage refused: engine HEAD moved since the recorded build ' +
|
|
141
|
+
`(${result.detail.join(', ') || 'no usable HEAD'}), so the earlier scope's packaging is no ` +
|
|
142
|
+
`longer anchored. ${remedy}`);
|
|
143
|
+
case 'fingerprint-diverged':
|
|
144
|
+
return ('--extend-coverage refused: ' +
|
|
145
|
+
`${String(result.detail.length)} packageable file(s) the recorded build staged have changed ` +
|
|
146
|
+
`since (${formatDivergedPaths(result.detail)}). Extending would vouch for the earlier scope ` +
|
|
147
|
+
`while obj-*/_tests/ still holds its stale staging. ${remedy}`);
|
|
148
|
+
case 'mozconfig-changed':
|
|
149
|
+
return ('--extend-coverage refused: engine/mozconfig differs from the recorded build (it is ' +
|
|
150
|
+
'regenerated from configs/*.mozconfig and fireforge.json on every build, so engine HEAD ' +
|
|
151
|
+
`does not cover it). ${remedy}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function formatDivergedPaths(paths) {
|
|
155
|
+
const head = paths.slice(0, MAX_DIVERGED_REPORTED).join(', ');
|
|
156
|
+
const extra = paths.length - Math.min(paths.length, MAX_DIVERGED_REPORTED);
|
|
157
|
+
return extra > 0 ? `${head}, … (+${String(extra)} more)` : head;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Hex-encoded SHA-256 of an engine-relative file, matching the digest the
|
|
161
|
+
* baseline writer produces. `undefined` on any IO error, which every caller
|
|
162
|
+
* treats as "cannot prove unchanged" — i.e. a refusal.
|
|
163
|
+
* @param engineDir - Path to the engine directory
|
|
164
|
+
* @param relPath - Engine-relative POSIX path
|
|
165
|
+
*/
|
|
166
|
+
export async function hashEngineFile(engineDir, relPath) {
|
|
167
|
+
try {
|
|
168
|
+
const buffer = await readFile(join(engineDir, relPath));
|
|
169
|
+
return createHash('sha256').update(buffer).digest('hex');
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (getNodeErrorCode(error) === 'ENOENT') {
|
|
173
|
+
return DELETED_FILE_FINGERPRINT;
|
|
174
|
+
}
|
|
175
|
+
verbose(`Extend-coverage anchor: could not hash ${relPath} — ${toError(error).message}`);
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function readEngineHead(engineDir) {
|
|
180
|
+
try {
|
|
181
|
+
return await getHead(engineDir);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (isMissingHeadError(error))
|
|
185
|
+
return undefined;
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -109,7 +109,9 @@ export async function confirmDestructive(input) {
|
|
|
109
109
|
message: `${title} — proceed?`,
|
|
110
110
|
initialValue: false,
|
|
111
111
|
});
|
|
112
|
-
|
|
112
|
+
// `isCancel` narrows the clack sentinel away, leaving a plain boolean —
|
|
113
|
+
// the defensive `!== true` this replaced is now provably redundant.
|
|
114
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
113
115
|
return 'cancelled';
|
|
114
116
|
}
|
|
115
117
|
return 'proceed';
|
|
@@ -20,7 +20,7 @@ import { GeneralError } from '../errors/base.js';
|
|
|
20
20
|
import { toError } from '../utils/errors.js';
|
|
21
21
|
import { readText, removeFile, writeText } from '../utils/fs.js';
|
|
22
22
|
import { discardStatusEntry, fileExistsInHead, listTrackedInHead, restoreTrackedPath, unstageFiles, } from './git-file-ops.js';
|
|
23
|
-
import {
|
|
23
|
+
import { createPatchedContentContext } from './patch-apply.js';
|
|
24
24
|
import { loadPatchesManifestState } from './patch-manifest-io.js';
|
|
25
25
|
import { parseDiffSections } from './patch-parse.js';
|
|
26
26
|
import { buildPatchClaims } from './status-classify.js';
|
|
@@ -53,7 +53,7 @@ export async function planDiscardBaselines(patchesDir, engineDir, entries) {
|
|
|
53
53
|
if (claimedEntries.length === 0) {
|
|
54
54
|
return planUpstreamDiscards(entries);
|
|
55
55
|
}
|
|
56
|
-
const computePatched = await
|
|
56
|
+
const { computePatched } = await createPatchedContentContext(patchesDir, engineDir);
|
|
57
57
|
const claimedFiles = [
|
|
58
58
|
...new Set(claimedEntries.flatMap((entry) => entry.originalPath !== undefined ? [entry.file, entry.originalPath] : [entry.file])),
|
|
59
59
|
];
|
|
@@ -84,8 +84,22 @@ export async function planDiscardBaselines(patchesDir, engineDir, entries) {
|
|
|
84
84
|
try {
|
|
85
85
|
sections = parseDiffSections(await readText(join(patchesDir, owner)));
|
|
86
86
|
}
|
|
87
|
-
catch {
|
|
88
|
-
sections
|
|
87
|
+
catch (error) {
|
|
88
|
+
// Fail closed. Swallowing this yielded no sections, which falls
|
|
89
|
+
// through to `return false` — "not deleted at baseline" — so a path
|
|
90
|
+
// the owning patch DELETES would be restored as present, i.e.
|
|
91
|
+
// rewritten instead of removed: silent data loss on the command
|
|
92
|
+
// whose header promises a refusal for exactly this class of failure.
|
|
93
|
+
//
|
|
94
|
+
// In the current flow `computeExpected` above reads the same patch
|
|
95
|
+
// first and refuses, so this is defence-in-depth against a TOCTOU
|
|
96
|
+
// (the patch file removed or made unreadable mid-plan) rather than a
|
|
97
|
+
// reachable branch today. It refuses with the same remediation as
|
|
98
|
+
// its sibling so the two cannot drift into disagreeing.
|
|
99
|
+
throw new GeneralError(`Cannot read the owning patch ${owner} to determine the baseline for ${path} ` +
|
|
100
|
+
`(${toError(error).message}). ` +
|
|
101
|
+
'Fix the owning patch, or pass --to-upstream to explicitly revert to pristine ' +
|
|
102
|
+
'upstream (HEAD).');
|
|
89
103
|
}
|
|
90
104
|
sectionCache.set(owner, sections);
|
|
91
105
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared engine precondition ladder for commands that read or mutate
|
|
3
|
+
* `engine/` through git.
|
|
4
|
+
*
|
|
5
|
+
* Five commands carried their own copy of this three-rung check, and **two of
|
|
6
|
+
* them were truncated**: `resolve.ts` and `token-coverage.ts` stopped after
|
|
7
|
+
* the `isGitRepository` rung and omitted the unborn-HEAD guard entirely
|
|
8
|
+
* (neither even imported `getHead`/`isMissingHeadError`). Both then went on to
|
|
9
|
+
* enumerate working-tree status, and against an unborn HEAD the whole
|
|
10
|
+
* ~300k-file Firefox tree reads as untracked — so an engine left half
|
|
11
|
+
* initialised by an interrupted `download` produced a nonsensical result
|
|
12
|
+
* rather than the actionable refusal the other three commands gave.
|
|
13
|
+
*
|
|
14
|
+
* The remediation paragraph also existed in three independently-editable
|
|
15
|
+
* copies that had already drifted. It lives here once now.
|
|
16
|
+
*/
|
|
17
|
+
/** Options for {@link assertEngineGitReady}. */
|
|
18
|
+
export interface EngineGitReadyOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Appended to the unborn-HEAD remediation, e.g. `', then retry the rebase.'`
|
|
21
|
+
* Preserves the one deliberate wording divergence between the copies. The
|
|
22
|
+
* base message deliberately ends WITHOUT punctuation so the suffix reads as
|
|
23
|
+
* one sentence; callers that pass nothing get a plain full stop.
|
|
24
|
+
*/
|
|
25
|
+
unbornHeadSuffix?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Asserts that `engineDir` exists, is a git repository, and has a baseline
|
|
29
|
+
* commit — in that order, so the operator gets the most specific applicable
|
|
30
|
+
* remediation rather than a downstream git error.
|
|
31
|
+
*
|
|
32
|
+
* @param engineDir - Absolute path to the engine checkout
|
|
33
|
+
* @param options - Optional per-command message tailoring
|
|
34
|
+
*/
|
|
35
|
+
export declare function assertEngineGitReady(engineDir: string, options?: EngineGitReadyOptions): Promise<void>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Shared engine precondition ladder for commands that read or mutate
|
|
4
|
+
* `engine/` through git.
|
|
5
|
+
*
|
|
6
|
+
* Five commands carried their own copy of this three-rung check, and **two of
|
|
7
|
+
* them were truncated**: `resolve.ts` and `token-coverage.ts` stopped after
|
|
8
|
+
* the `isGitRepository` rung and omitted the unborn-HEAD guard entirely
|
|
9
|
+
* (neither even imported `getHead`/`isMissingHeadError`). Both then went on to
|
|
10
|
+
* enumerate working-tree status, and against an unborn HEAD the whole
|
|
11
|
+
* ~300k-file Firefox tree reads as untracked — so an engine left half
|
|
12
|
+
* initialised by an interrupted `download` produced a nonsensical result
|
|
13
|
+
* rather than the actionable refusal the other three commands gave.
|
|
14
|
+
*
|
|
15
|
+
* The remediation paragraph also existed in three independently-editable
|
|
16
|
+
* copies that had already drifted. It lives here once now.
|
|
17
|
+
*/
|
|
18
|
+
import { GeneralError } from '../errors/base.js';
|
|
19
|
+
import { pathExists } from '../utils/fs.js';
|
|
20
|
+
import { getHead, isGitRepository, isMissingHeadError } from './git.js';
|
|
21
|
+
/** Remediation for an engine whose baseline commit was never created. */
|
|
22
|
+
const UNBORN_HEAD_REMEDIATION = 'Engine repository has no baseline commit yet — a previous "fireforge download" was ' +
|
|
23
|
+
'interrupted before git created the initial Firefox source commit. ' +
|
|
24
|
+
// No terminating period: the suffix below supplies its own punctuation, and
|
|
25
|
+
// hardcoding one here rendered as "…cleanly., then retry the rebase."
|
|
26
|
+
'Re-run "fireforge download --force" to recreate the baseline repository cleanly';
|
|
27
|
+
/**
|
|
28
|
+
* Asserts that `engineDir` exists, is a git repository, and has a baseline
|
|
29
|
+
* commit — in that order, so the operator gets the most specific applicable
|
|
30
|
+
* remediation rather than a downstream git error.
|
|
31
|
+
*
|
|
32
|
+
* @param engineDir - Absolute path to the engine checkout
|
|
33
|
+
* @param options - Optional per-command message tailoring
|
|
34
|
+
*/
|
|
35
|
+
export async function assertEngineGitReady(engineDir, options = {}) {
|
|
36
|
+
if (!(await pathExists(engineDir))) {
|
|
37
|
+
throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
|
|
38
|
+
}
|
|
39
|
+
if (!(await isGitRepository(engineDir))) {
|
|
40
|
+
throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
await getHead(engineDir);
|
|
44
|
+
}
|
|
45
|
+
catch (headError) {
|
|
46
|
+
if (!isMissingHeadError(headError))
|
|
47
|
+
throw headError;
|
|
48
|
+
throw new GeneralError(`${UNBORN_HEAD_REMEDIATION}${options.unbornHeadSuffix ?? '.'}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -8,14 +8,51 @@ export interface EngineSessionLockOptions {
|
|
|
8
8
|
waitLockSeconds?: number | undefined;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* Runs `operation` while holding the project's engine-session lock, so only
|
|
12
|
+
* one engine-mutating FireForge command touches `engine/` at a time.
|
|
12
13
|
*/
|
|
13
14
|
export declare function withEngineSessionLock<T>(projectRoot: string, command: string, operation: () => Promise<T>, options?: EngineSessionLockOptions): Promise<T>;
|
|
14
15
|
/**
|
|
16
|
+
* Captures an opaque token describing the engine's current commit and working
|
|
17
|
+
* tree, for comparison by {@link assertEngineGenerationUnchanged}.
|
|
15
18
|
*
|
|
19
|
+
* A failed probe yields a failure token. Callers must branch on
|
|
20
|
+
* {@link isUnavailableGenerationToken} rather than comparing the token,
|
|
21
|
+
* because a probe failure means "nothing was measured", not "nothing changed".
|
|
16
22
|
*/
|
|
17
23
|
export declare function snapshotEngineGeneration(engineDir: string): Promise<string>;
|
|
18
24
|
/**
|
|
25
|
+
* True when a generation token records a failed probe — i.e. nothing was
|
|
26
|
+
* measured, so the token must never be compared (or hashed) as if it
|
|
27
|
+
* described the engine's state.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isUnavailableGenerationToken(token: string): boolean;
|
|
30
|
+
/** Strips the marker prefix off an unavailable token for display. */
|
|
31
|
+
export declare function unavailableGenerationReason(token: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Throws when the engine's generation token differs from `before`, i.e. the
|
|
34
|
+
* engine was mutated by another writer while a test run was in flight.
|
|
35
|
+
*
|
|
36
|
+
* **An unmeasurable engine warns rather than throws.** `engine/` is not always
|
|
37
|
+
* a git checkout — `fireforge download` extracts a source tarball — so a
|
|
38
|
+
* failed probe is a legitimate state and must not fail the run.
|
|
39
|
+
*
|
|
40
|
+
* What it must also not do is pass *silently*. Before 0.41.0 the failure token
|
|
41
|
+
* was `unavailable:<message>` and was compared for equality like any other, so
|
|
42
|
+
* a git failure that reproduced across both probes (unreadable `.git`, a
|
|
43
|
+
* permissions problem, a corrupt index) produced identical tokens, took the
|
|
44
|
+
* `after === before` early return, and blessed a verdict the guard had never
|
|
45
|
+
* verified — the precise outcome this guard exists to prevent. Two *differing*
|
|
46
|
+
* failure messages were worse still: they fell through to the mutation branch
|
|
47
|
+
* and reported a spurious "engine/ changed".
|
|
19
48
|
*
|
|
49
|
+
* The tolerance is deliberately limited to a probe that failed *both* times:
|
|
50
|
+
* that is the steady state of a non-git `engine/`, and it warns. A one-sided
|
|
51
|
+
* transition is not that state. `engine/` that was measurable and then was not
|
|
52
|
+
* means the second probe measured nothing about a checkout that demonstrably
|
|
53
|
+
* had something to measure; the reverse means there was never a baseline to
|
|
54
|
+
* compare against. Neither is a run whose verdict can be trusted, so both
|
|
55
|
+
* throw. Compare on the prefix rather than the token, because the token embeds
|
|
56
|
+
* the error message and two genuine failures rarely phrase it identically.
|
|
20
57
|
*/
|
|
21
58
|
export declare function assertEngineGenerationUnchanged(engineDir: string, before: string): Promise<void>;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { GeneralError } from '../errors/base.js';
|
|
4
|
-
import {
|
|
4
|
+
import { toError } from '../utils/errors.js';
|
|
5
|
+
import { info, warn } from '../utils/logger.js';
|
|
5
6
|
import { withFileLock } from './file-lock.js';
|
|
6
7
|
import { git } from './git-base.js';
|
|
7
8
|
const ENGINE_SESSION_LOCK_PATH = join('.fireforge', 'engine-session.lock');
|
|
@@ -20,9 +21,16 @@ function formatWaitProgressLine(waitedMs, timeoutMs, holder) {
|
|
|
20
21
|
return `Waiting for the FireForge engine lock held by PID ${String(holder.pid)}${details} — ${progress}.`;
|
|
21
22
|
}
|
|
22
23
|
/**
|
|
23
|
-
*
|
|
24
|
+
* Runs `operation` while holding the project's engine-session lock, so only
|
|
25
|
+
* one engine-mutating FireForge command touches `engine/` at a time.
|
|
24
26
|
*/
|
|
25
27
|
export async function withEngineSessionLock(projectRoot, command, operation, options = {}) {
|
|
28
|
+
// The lock is bypassed under test by default, and that is deliberate: the
|
|
29
|
+
// suite runs many engine-mutating commands in-process, which would either
|
|
30
|
+
// serialise on this lock or deadlock outright.
|
|
31
|
+
// `FIREFORGE_ENABLE_ENGINE_SESSION_LOCK_IN_TEST=1` is what lets the lock's
|
|
32
|
+
// own tests opt back in. Do not delete this branch — it is the reason the
|
|
33
|
+
// suite completes, not an oversight.
|
|
26
34
|
if (process.env['FIREFORGE_ENABLE_ENGINE_SESSION_LOCK_IN_TEST'] !== '1' &&
|
|
27
35
|
(process.env['NODE_ENV'] === 'test' || process.env['VITEST'] !== undefined)) {
|
|
28
36
|
return operation();
|
|
@@ -41,13 +49,26 @@ export async function withEngineSessionLock(projectRoot, command, operation, opt
|
|
|
41
49
|
}
|
|
42
50
|
: {}),
|
|
43
51
|
ownerMetadata: [`command=${command}`, `started=${new Date().toISOString()}`],
|
|
52
|
+
// Reason first, remedy second (FORGE H5) — the no-flag default waits
|
|
53
|
+
// only ~1 s, so contention is the common case and `--wait-lock` is the
|
|
54
|
+
// genuine remedy. withFileLock appends the holder identification from
|
|
55
|
+
// the lock's owner metadata. The leading sentence is a message contract
|
|
56
|
+
// (pinned by engine-session-lock tests); extend, don't reword.
|
|
44
57
|
onTimeoutMessage: `Another FireForge engine-mutating command is already running. ` +
|
|
45
|
-
`Wait for it to finish, then retry \`${command}
|
|
58
|
+
`Wait for it to finish, then retry \`${command}\` — or pass --wait-lock [seconds] ` +
|
|
59
|
+
`to wait for the lock (bare --wait-lock waits up to 60 seconds).`,
|
|
46
60
|
onStaleLockMessage: (ageMs) => `Removed stale FireForge engine session lock (${Math.round(ageMs / 1000)}s old).`,
|
|
47
61
|
});
|
|
48
62
|
}
|
|
63
|
+
/** Prefix marking a generation token whose probe failed. */
|
|
64
|
+
const UNAVAILABLE_PREFIX = 'unavailable:';
|
|
49
65
|
/**
|
|
66
|
+
* Captures an opaque token describing the engine's current commit and working
|
|
67
|
+
* tree, for comparison by {@link assertEngineGenerationUnchanged}.
|
|
50
68
|
*
|
|
69
|
+
* A failed probe yields a failure token. Callers must branch on
|
|
70
|
+
* {@link isUnavailableGenerationToken} rather than comparing the token,
|
|
71
|
+
* because a probe failure means "nothing was measured", not "nothing changed".
|
|
51
72
|
*/
|
|
52
73
|
export async function snapshotEngineGeneration(engineDir) {
|
|
53
74
|
try {
|
|
@@ -56,14 +77,67 @@ export async function snapshotEngineGeneration(engineDir) {
|
|
|
56
77
|
return `${head}\0${status}`;
|
|
57
78
|
}
|
|
58
79
|
catch (error) {
|
|
59
|
-
return
|
|
80
|
+
return `${UNAVAILABLE_PREFIX}${toError(error).message}`;
|
|
60
81
|
}
|
|
61
82
|
}
|
|
62
83
|
/**
|
|
84
|
+
* True when a generation token records a failed probe — i.e. nothing was
|
|
85
|
+
* measured, so the token must never be compared (or hashed) as if it
|
|
86
|
+
* described the engine's state.
|
|
87
|
+
*/
|
|
88
|
+
export function isUnavailableGenerationToken(token) {
|
|
89
|
+
return token.startsWith(UNAVAILABLE_PREFIX);
|
|
90
|
+
}
|
|
91
|
+
/** Strips the marker prefix off an unavailable token for display. */
|
|
92
|
+
export function unavailableGenerationReason(token) {
|
|
93
|
+
return token.slice(UNAVAILABLE_PREFIX.length);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Throws when the engine's generation token differs from `before`, i.e. the
|
|
97
|
+
* engine was mutated by another writer while a test run was in flight.
|
|
63
98
|
*
|
|
99
|
+
* **An unmeasurable engine warns rather than throws.** `engine/` is not always
|
|
100
|
+
* a git checkout — `fireforge download` extracts a source tarball — so a
|
|
101
|
+
* failed probe is a legitimate state and must not fail the run.
|
|
102
|
+
*
|
|
103
|
+
* What it must also not do is pass *silently*. Before 0.41.0 the failure token
|
|
104
|
+
* was `unavailable:<message>` and was compared for equality like any other, so
|
|
105
|
+
* a git failure that reproduced across both probes (unreadable `.git`, a
|
|
106
|
+
* permissions problem, a corrupt index) produced identical tokens, took the
|
|
107
|
+
* `after === before` early return, and blessed a verdict the guard had never
|
|
108
|
+
* verified — the precise outcome this guard exists to prevent. Two *differing*
|
|
109
|
+
* failure messages were worse still: they fell through to the mutation branch
|
|
110
|
+
* and reported a spurious "engine/ changed".
|
|
111
|
+
*
|
|
112
|
+
* The tolerance is deliberately limited to a probe that failed *both* times:
|
|
113
|
+
* that is the steady state of a non-git `engine/`, and it warns. A one-sided
|
|
114
|
+
* transition is not that state. `engine/` that was measurable and then was not
|
|
115
|
+
* means the second probe measured nothing about a checkout that demonstrably
|
|
116
|
+
* had something to measure; the reverse means there was never a baseline to
|
|
117
|
+
* compare against. Neither is a run whose verdict can be trusted, so both
|
|
118
|
+
* throw. Compare on the prefix rather than the token, because the token embeds
|
|
119
|
+
* the error message and two genuine failures rarely phrase it identically.
|
|
64
120
|
*/
|
|
65
121
|
export async function assertEngineGenerationUnchanged(engineDir, before) {
|
|
66
122
|
const after = await snapshotEngineGeneration(engineDir);
|
|
123
|
+
const beforeUnavailable = isUnavailableGenerationToken(before);
|
|
124
|
+
const afterUnavailable = isUnavailableGenerationToken(after);
|
|
125
|
+
if (beforeUnavailable && afterUnavailable) {
|
|
126
|
+
warn('Could not verify that engine/ stayed unchanged while the tests ran ' +
|
|
127
|
+
`(${unavailableGenerationReason(after)}). If another writer touched engine/ during the run, ` +
|
|
128
|
+
'this verdict is stale.');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (beforeUnavailable || afterUnavailable) {
|
|
132
|
+
const detail = beforeUnavailable
|
|
133
|
+
? `engine/ could not be probed before the run (${unavailableGenerationReason(before)}) but could be ` +
|
|
134
|
+
'probed afterwards, so there is no baseline to compare against.'
|
|
135
|
+
: `engine/ was probed before the run but could not be probed afterwards ` +
|
|
136
|
+
`(${unavailableGenerationReason(after)}), so nothing was measured to compare against.`;
|
|
137
|
+
throw new GeneralError(`${detail}\n\n` +
|
|
138
|
+
'This test verdict is inconclusive. Restore engine/ to a readable state, then rebuild ' +
|
|
139
|
+
'and rerun the affected tests.');
|
|
140
|
+
}
|
|
67
141
|
if (after === before)
|
|
68
142
|
return;
|
|
69
143
|
throw new GeneralError('engine/ changed while `fireforge test` was running, so this test verdict is invalid/inconclusive.\n\n' +
|
|
@@ -48,5 +48,12 @@ export interface FileLockOptions {
|
|
|
48
48
|
}
|
|
49
49
|
/** Derives the sibling lock-directory path used to guard a file-based resource. */
|
|
50
50
|
export declare function createSiblingLockPath(filePath: string, suffix?: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* Filename of a lock directory's owner record, relative to the lock dir.
|
|
53
|
+
*
|
|
54
|
+
* Exported so external readers (`tree-store.ts`) share the constant instead of
|
|
55
|
+
* re-spelling `'pid'`; the file *format* is documented on {@link readLockOwner}.
|
|
56
|
+
*/
|
|
57
|
+
export declare const LOCK_PID_FILE = "pid";
|
|
51
58
|
/** Runs an async operation while holding a directory lock, with stale-lock recovery. */
|
|
52
59
|
export declare function withFileLock<T>(lockPath: string, operation: () => Promise<T>, options?: FileLockOptions): Promise<T>;
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { LockContentionError } from '../errors/base.js';
|
|
6
|
+
import { getNodeErrorCode, isProcessAlive, toError } from '../utils/errors.js';
|
|
6
7
|
import { ensureDir } from '../utils/fs.js';
|
|
7
8
|
import { verbose, warn } from '../utils/logger.js';
|
|
8
9
|
const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
|
|
@@ -22,33 +23,17 @@ function sleep(ms) {
|
|
|
22
23
|
setTimeout(resolve, ms);
|
|
23
24
|
});
|
|
24
25
|
}
|
|
25
|
-
function getNodeErrorCode(error) {
|
|
26
|
-
if (typeof error === 'object' &&
|
|
27
|
-
error !== null &&
|
|
28
|
-
'code' in error &&
|
|
29
|
-
typeof error.code === 'string') {
|
|
30
|
-
return error.code;
|
|
31
|
-
}
|
|
32
|
-
return undefined;
|
|
33
|
-
}
|
|
34
26
|
/** Derives the sibling lock-directory path used to guard a file-based resource. */
|
|
35
27
|
export function createSiblingLockPath(filePath, suffix = '.fireforge.lock') {
|
|
36
28
|
return `${filePath}${suffix}`;
|
|
37
29
|
}
|
|
38
|
-
const LOCK_PID_FILE = 'pid';
|
|
39
30
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
31
|
+
* Filename of a lock directory's owner record, relative to the lock dir.
|
|
32
|
+
*
|
|
33
|
+
* Exported so external readers (`tree-store.ts`) share the constant instead of
|
|
34
|
+
* re-spelling `'pid'`; the file *format* is documented on {@link readLockOwner}.
|
|
42
35
|
*/
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
process.kill(pid, 0);
|
|
46
|
-
return true;
|
|
47
|
-
}
|
|
48
|
-
catch (error) {
|
|
49
|
-
return getNodeErrorCode(error) !== 'ESRCH';
|
|
50
|
-
}
|
|
51
|
-
}
|
|
36
|
+
export const LOCK_PID_FILE = 'pid';
|
|
52
37
|
/**
|
|
53
38
|
* Reads the owner PID (line 1), acquisition token (line 2), and diagnostic
|
|
54
39
|
* metadata (lines 3+, see {@link FileLockOptions.ownerMetadata}) from a lock
|
|
@@ -242,12 +227,7 @@ export async function withFileLock(lockPath, operation, options = {}) {
|
|
|
242
227
|
break;
|
|
243
228
|
}
|
|
244
229
|
catch (error) {
|
|
245
|
-
|
|
246
|
-
error !== null &&
|
|
247
|
-
'code' in error &&
|
|
248
|
-
typeof error.code === 'string' &&
|
|
249
|
-
error.code === 'EEXIST';
|
|
250
|
-
if (!isAlreadyLocked) {
|
|
230
|
+
if (getNodeErrorCode(error) !== 'EEXIST') {
|
|
251
231
|
throw error;
|
|
252
232
|
}
|
|
253
233
|
if (lastStaleProbeAt === undefined || Date.now() - lastStaleProbeAt >= staleReprobeMs) {
|
|
@@ -259,9 +239,19 @@ export async function withFileLock(lockPath, operation, options = {}) {
|
|
|
259
239
|
if (Date.now() >= deadline) {
|
|
260
240
|
const owner = await readLockOwner(lockPath);
|
|
261
241
|
const holderHint = owner.present && isProcessAlive(owner.pid)
|
|
262
|
-
? ` It is held by running process ${String(owner.pid)} — wait for it to finish, or stop it and retry.`
|
|
242
|
+
? ` It is held by running process ${String(owner.pid)}${owner.metadata.length > 0 ? ` (${owner.metadata.join(', ')})` : ''} — wait for it to finish, or stop it and retry.`
|
|
263
243
|
: ' If no other FireForge process is running, remove the lock directory and retry.';
|
|
264
|
-
|
|
244
|
+
// Typed as LockContentionError (a FireForgeError) so the CLI
|
|
245
|
+
// boundary prints the refusal as one clean line instead of an
|
|
246
|
+
// "Unexpected error" stack (FORGE H5). Callers supplying
|
|
247
|
+
// onTimeoutMessage still get the holder identification appended —
|
|
248
|
+
// the reason-first copy stays theirs, the "who holds it" is ours.
|
|
249
|
+
const identifiedHolder = options.onTimeoutMessage !== undefined && owner.present && isProcessAlive(owner.pid)
|
|
250
|
+
? ` The lock is held by PID ${String(owner.pid)}${owner.metadata.length > 0 ? ` (${owner.metadata.join(', ')})` : ''}.`
|
|
251
|
+
: '';
|
|
252
|
+
throw new LockContentionError(options.onTimeoutMessage !== undefined
|
|
253
|
+
? `${options.onTimeoutMessage}${identifiedHolder}`
|
|
254
|
+
: `Timed out waiting for file lock ${lockPath}.${holderHint}`, error);
|
|
265
255
|
}
|
|
266
256
|
if (options.onWaitProgress !== undefined &&
|
|
267
257
|
options.waitProgressMs !== undefined &&
|
package/dist/src/core/firefox.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
9
|
import { rename } from 'node:fs/promises';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
|
+
import { getNodeErrorCode, toError } from '../utils/errors.js';
|
|
11
12
|
import { ensureDir, removeDir } from '../utils/fs.js';
|
|
12
13
|
import { resolveArchive } from './firefox-archive.js';
|
|
13
14
|
import { ensureCachedArchive, invalidateArchiveCache } from './firefox-cache.js';
|
|
@@ -43,8 +44,8 @@ export function getTarballFilename(version, product = 'firefox', candidate) {
|
|
|
43
44
|
* genuinely bad archive can never survive in the cache.
|
|
44
45
|
*/
|
|
45
46
|
function isLikelyArchiveCorruptionError(error) {
|
|
46
|
-
const message = error
|
|
47
|
-
const code = error
|
|
47
|
+
const message = toError(error).message;
|
|
48
|
+
const code = getNodeErrorCode(error);
|
|
48
49
|
if (code === 'ENOSPC' || code === 'EACCES' || code === 'EPERM' || code === 'EDQUOT') {
|
|
49
50
|
return false;
|
|
50
51
|
}
|
|
@@ -81,6 +82,8 @@ export async function sweepOrphanedEngineWorkDirs(destDir) {
|
|
|
81
82
|
entries = await readdir(parent);
|
|
82
83
|
}
|
|
83
84
|
catch {
|
|
85
|
+
// An unreadable parent directory holds no sweepable work dirs. The sweep is
|
|
86
|
+
// best-effort housekeeping and must never fail the download it follows.
|
|
84
87
|
return [];
|
|
85
88
|
}
|
|
86
89
|
const removed = [];
|