@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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { getNodeErrorCode } from '../utils/errors.js';
|
|
5
|
+
import { sha256Hex } from '../utils/hash.js';
|
|
6
|
+
/** Records authored bytes without following directory aliases or writing to source directories. */
|
|
7
|
+
export async function snapshotComponentSources(roots) {
|
|
8
|
+
const files = new Map();
|
|
9
|
+
async function visit(directory) {
|
|
10
|
+
let entries;
|
|
11
|
+
try {
|
|
12
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (getNodeErrorCode(error) === 'ENOENT')
|
|
16
|
+
return;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
const path = join(directory, entry.name);
|
|
21
|
+
if (entry.isDirectory())
|
|
22
|
+
await visit(path);
|
|
23
|
+
else if (entry.isFile())
|
|
24
|
+
files.set(path, sha256Hex(await readFile(path)));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
for (const root of roots)
|
|
28
|
+
await visit(root);
|
|
29
|
+
return files;
|
|
30
|
+
}
|
|
31
|
+
/** Detects reverted, removed or added source files before deployment can be committed. */
|
|
32
|
+
export async function changedComponentSources(roots, before) {
|
|
33
|
+
const after = await snapshotComponentSources(roots);
|
|
34
|
+
return [...new Set([...before.keys(), ...after.keys()])]
|
|
35
|
+
.filter((path) => before.get(path) !== after.get(path))
|
|
36
|
+
.sort();
|
|
37
|
+
}
|
|
38
|
+
/** Captures only configured inputs for a real deploy, plus shared CSS fragments. */
|
|
39
|
+
export async function captureComponentSources(paths, config, dryRun, options) {
|
|
40
|
+
if (dryRun)
|
|
41
|
+
return undefined;
|
|
42
|
+
const componentName = options?.componentName;
|
|
43
|
+
const selected = (name) => componentName === undefined || name === componentName;
|
|
44
|
+
const roots = [
|
|
45
|
+
...Object.keys(config.custom)
|
|
46
|
+
.filter(selected)
|
|
47
|
+
.map((name) => join(paths.customDir, name)),
|
|
48
|
+
...Object.keys(config.overrides)
|
|
49
|
+
.filter(selected)
|
|
50
|
+
.map((name) => join(paths.overridesDir, name)),
|
|
51
|
+
paths.sharedDir,
|
|
52
|
+
];
|
|
53
|
+
return { roots, files: await snapshotComponentSources(roots) };
|
|
54
|
+
}
|
|
55
|
+
/** Produces a blocking apply error before the journal or ownership state is committed. */
|
|
56
|
+
export async function componentSourceErrors(snapshot) {
|
|
57
|
+
if (!snapshot)
|
|
58
|
+
return [];
|
|
59
|
+
const changed = await changedComponentSources(snapshot.roots, snapshot.files);
|
|
60
|
+
return changed.length === 0
|
|
61
|
+
? []
|
|
62
|
+
: [
|
|
63
|
+
{
|
|
64
|
+
name: 'component-sources',
|
|
65
|
+
error: `Component sources changed during deployment: ${changed.join(', ')}. Refusing this build/proof; inspect the source edits before retrying.`,
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
}
|
|
@@ -1,37 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Coverage replaces by default for a reason documented in
|
|
7
|
-
* `build-baseline-types.ts`: every baseline write refreshes
|
|
8
|
-
* `packageableFingerprints` for all dirty packageable paths, so a blind
|
|
9
|
-
* union would whitewash an earlier scope's edited fixtures while
|
|
10
|
-
* `obj-*`/`_tests/` still holds that scope's stale staging. Extending is
|
|
11
|
-
* honest only while everything the previous record vouched for is still
|
|
12
|
-
* true, which is what {@link checkExtendCoverageAnchor} and
|
|
13
|
-
* {@link checkExtendMozconfigAnchor} verify:
|
|
14
|
-
*
|
|
15
|
-
* 1. engine HEAD is unchanged, so every committed edit is excluded.
|
|
16
|
-
* 2. every path the previous baseline fingerprinted still hashes to the
|
|
17
|
-
* recorded digest, so the earlier scope's staging inputs are
|
|
18
|
-
* byte-identical. Files that became dirty since that build are fine:
|
|
19
|
-
* the current whole-tree `mach build faster` repackages `dist/` and
|
|
20
|
-
* 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 replacement has no such
|
|
34
|
-
* window, because the earlier scope simply stops being covered.
|
|
2
|
+
* Retains prior test staging only when HEAD, mozconfig and every recorded
|
|
3
|
+
* dirty input outside the newly rebuilt scope are unchanged. Complete
|
|
4
|
+
* input fingerprints are required: packageable-only legacy records cannot
|
|
5
|
+
* prove that a test fixture or manifest still matches its staged copy.
|
|
35
6
|
*/
|
|
36
7
|
import { type BuildBaseline, type TestPackagingCoverage } from './build-baseline-types.js';
|
|
37
8
|
/** Why `--extend-coverage` could not union onto the recorded claim. */
|
|
@@ -51,7 +22,7 @@ export type ExtendAnchorResult = {
|
|
|
51
22
|
* @param engineDir - Path to the engine directory
|
|
52
23
|
* @param previousBaseline - Baseline recorded by the previous build
|
|
53
24
|
*/
|
|
54
|
-
export declare function checkExtendCoverageAnchor(engineDir: string, previousBaseline: BuildBaseline | undefined): Promise<ExtendAnchorResult>;
|
|
25
|
+
export declare function checkExtendCoverageAnchor(engineDir: string, previousBaseline: BuildBaseline | undefined, rebuiltPaths?: readonly string[]): Promise<ExtendAnchorResult>;
|
|
55
26
|
/**
|
|
56
27
|
* Verifies the mozconfig half of the anchor. Split from
|
|
57
28
|
* {@link checkExtendCoverageAnchor} because it can only run after
|
|
@@ -1,38 +1,9 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Coverage replaces by default for a reason documented in
|
|
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, so every committed edit is excluded.
|
|
17
|
-
* 2. every path the previous baseline fingerprinted still hashes to the
|
|
18
|
-
* recorded digest, so the earlier scope's staging inputs are
|
|
19
|
-
* byte-identical. Files that became dirty since that build are fine:
|
|
20
|
-
* the current whole-tree `mach build faster` repackages `dist/` and
|
|
21
|
-
* vouches for them.
|
|
22
|
-
* 3. the generated `engine/mozconfig` is unchanged. Engine HEAD does not
|
|
23
|
-
* cover this: the mozconfig is regenerated from project-side
|
|
24
|
-
* `configs/*.mozconfig` templates plus `fireforge.json` on every build,
|
|
25
|
-
* so two builds at the same engine SHA can configure differently.
|
|
26
|
-
*
|
|
27
|
-
* Every failure refuses fail-closed. The operator's remedy is always a plain
|
|
28
|
-
* scoped build, which resets the claim to the paths it actually vouches for.
|
|
29
|
-
*
|
|
30
|
-
* Known boundary, documented rather than closed: dirty non-packageable
|
|
31
|
-
* fixtures under previously covered paths (plain test files, manifests
|
|
32
|
-
* `isPackageablePath` rejects) are invisible to check 2. Editing one
|
|
33
|
-
* uncommitted between two builds leaves the earlier scope's `_tests/`
|
|
34
|
-
* staging stale while extend still vouches for it. Plain replacement has no such
|
|
35
|
-
* window, because the earlier scope simply stops being covered.
|
|
3
|
+
* Retains prior test staging only when HEAD, mozconfig and every recorded
|
|
4
|
+
* dirty input outside the newly rebuilt scope are unchanged. Complete
|
|
5
|
+
* input fingerprints are required: packageable-only legacy records cannot
|
|
6
|
+
* prove that a test fixture or manifest still matches its staged copy.
|
|
36
7
|
*/
|
|
37
8
|
import { readFile } from 'node:fs/promises';
|
|
38
9
|
import { join } from 'node:path';
|
|
@@ -41,6 +12,8 @@ import { sha256Hex } from '../utils/hash.js';
|
|
|
41
12
|
import { verbose } from '../utils/logger.js';
|
|
42
13
|
import { DELETED_FILE_FINGERPRINT, } from './build-baseline-types.js';
|
|
43
14
|
import { getHead, isMissingHeadError } from './git.js';
|
|
15
|
+
import { git } from './git-base.js';
|
|
16
|
+
import { getUntrackedFiles } from './git-status.js';
|
|
44
17
|
/** Filename of the generated mozconfig, relative to the engine directory. */
|
|
45
18
|
const MOZCONFIG_FILENAME = 'mozconfig';
|
|
46
19
|
/** How many diverged paths a refusal names before truncating. */
|
|
@@ -52,14 +25,15 @@ const MAX_DIVERGED_REPORTED = 5;
|
|
|
52
25
|
* @param engineDir - Path to the engine directory
|
|
53
26
|
* @param previousBaseline - Baseline recorded by the previous build
|
|
54
27
|
*/
|
|
55
|
-
export async function checkExtendCoverageAnchor(engineDir, previousBaseline) {
|
|
28
|
+
export async function checkExtendCoverageAnchor(engineDir, previousBaseline, rebuiltPaths = []) {
|
|
56
29
|
if (previousBaseline === undefined) {
|
|
57
30
|
return { ok: false, reason: 'no-baseline', detail: [] };
|
|
58
31
|
}
|
|
59
32
|
if (previousBaseline.mozconfigHash === undefined) {
|
|
60
33
|
return { ok: false, reason: 'no-mozconfig-hash', detail: [] };
|
|
61
34
|
}
|
|
62
|
-
const recordedFingerprints = previousBaseline.
|
|
35
|
+
const recordedFingerprints = previousBaseline.testInputFingerprints;
|
|
36
|
+
const rebuilt = (file) => rebuiltPaths.some((path) => file === path || file.startsWith(path.replace(/\/$/, '') + '/'));
|
|
63
37
|
if (recordedFingerprints === undefined) {
|
|
64
38
|
return { ok: false, reason: 'no-fingerprints', detail: [] };
|
|
65
39
|
}
|
|
@@ -77,10 +51,25 @@ export async function checkExtendCoverageAnchor(engineDir, previousBaseline) {
|
|
|
77
51
|
}
|
|
78
52
|
const diverged = [];
|
|
79
53
|
for (const [relPath, recordedHash] of Object.entries(recordedFingerprints)) {
|
|
54
|
+
if (rebuilt(relPath))
|
|
55
|
+
continue;
|
|
80
56
|
const liveHash = await hashEngineFile(engineDir, relPath);
|
|
81
57
|
if (liveHash !== recordedHash)
|
|
82
58
|
diverged.push(relPath);
|
|
83
59
|
}
|
|
60
|
+
// New dirty test inputs are absent from the previous fingerprint map.
|
|
61
|
+
// They invalidate retained staging too; explicitly rebuilt inputs are safe.
|
|
62
|
+
if (previousBaseline.testInputFingerprints !== undefined) {
|
|
63
|
+
const changed = (await git(['diff', '--name-only', 'HEAD'], engineDir)).split('\n');
|
|
64
|
+
changed.push(...(await getUntrackedFiles(engineDir)));
|
|
65
|
+
for (const file of changed.filter(Boolean)) {
|
|
66
|
+
if (file === MOZCONFIG_FILENAME || rebuilt(file) || Object.hasOwn(recordedFingerprints, file))
|
|
67
|
+
continue;
|
|
68
|
+
// A retained manifest may stage a shared fixture outside test-named
|
|
69
|
+
// directories. Without its full dependency graph, refuse conservatively.
|
|
70
|
+
diverged.push(file);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
84
73
|
if (diverged.length > 0) {
|
|
85
74
|
return { ok: false, reason: 'fingerprint-diverged', detail: diverged.sort() };
|
|
86
75
|
}
|
|
@@ -136,7 +125,7 @@ export function formatExtendCoverageRefusal(result) {
|
|
|
136
125
|
return ('--extend-coverage refused: the recorded baseline predates the mozconfig anchor, so it ' +
|
|
137
126
|
`cannot be proven that this build configures identically. ${remedy}`);
|
|
138
127
|
case 'no-fingerprints':
|
|
139
|
-
return ('--extend-coverage refused: the recorded baseline carries no
|
|
128
|
+
return ('--extend-coverage refused: the recorded baseline carries no complete test-input fingerprints, so ' +
|
|
140
129
|
`the earlier scope's staging inputs cannot be verified unchanged. ${remedy}`);
|
|
141
130
|
case 'head-moved':
|
|
142
131
|
return ('--extend-coverage refused: engine HEAD moved since the recorded build ' +
|
|
@@ -144,7 +133,7 @@ export function formatExtendCoverageRefusal(result) {
|
|
|
144
133
|
`longer anchored. ${remedy}`);
|
|
145
134
|
case 'fingerprint-diverged':
|
|
146
135
|
return ('--extend-coverage refused: ' +
|
|
147
|
-
`${result.detail.length}
|
|
136
|
+
`${result.detail.length} staging input(s) the recorded build staged have changed ` +
|
|
148
137
|
`since (${formatDivergedPaths(result.detail)}). Extending would vouch for the earlier scope ` +
|
|
149
138
|
`while obj-*/_tests/ still holds its stale staging. ${remedy}`);
|
|
150
139
|
case 'mozconfig-changed':
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { FurnaceConfig } from '../types/furnace.js';
|
|
2
|
+
/** Checks all component destinations before an apply starts writing. */
|
|
3
|
+
export declare function assertComponentWriteBoundaries(engineDir: string, config: FurnaceConfig, ftlDir: string): Promise<void>;
|
|
4
|
+
/** Resolves the nearest existing parent before any mkdir/copy can write through an alias. */
|
|
5
|
+
export declare function assertEngineWriteBoundary(engineDir: string, directory: string): Promise<void>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
4
|
+
import { FurnaceError } from '../errors/furnace.js';
|
|
5
|
+
import { getNodeErrorCode } from '../utils/errors.js';
|
|
6
|
+
import { CUSTOM_ELEMENTS_JS, JAR_MN } from './furnace-constants.js';
|
|
7
|
+
/** Checks all component destinations before an apply starts writing. */
|
|
8
|
+
export async function assertComponentWriteBoundaries(engineDir, config, ftlDir) {
|
|
9
|
+
const destinations = new Set([
|
|
10
|
+
ftlDir,
|
|
11
|
+
dirname(JAR_MN),
|
|
12
|
+
dirname(CUSTOM_ELEMENTS_JS),
|
|
13
|
+
...Object.values(config.custom).map((entry) => entry.targetPath),
|
|
14
|
+
...Object.values(config.overrides).map((entry) => entry.basePath),
|
|
15
|
+
]);
|
|
16
|
+
for (const destination of destinations) {
|
|
17
|
+
await assertEngineWriteBoundary(engineDir, join(engineDir, destination));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Resolves the nearest existing parent before any mkdir/copy can write through an alias. */
|
|
21
|
+
export async function assertEngineWriteBoundary(engineDir, directory) {
|
|
22
|
+
const root = await realpath(engineDir);
|
|
23
|
+
let parent = resolve(directory);
|
|
24
|
+
for (;;) {
|
|
25
|
+
try {
|
|
26
|
+
const canonical = await realpath(parent);
|
|
27
|
+
if (canonical !== root && !canonical.startsWith(root + sep)) {
|
|
28
|
+
throw new FurnaceError(`Engine destination ${directory} resolves outside engine/ (${canonical}). Refusing to write through into component sources or another checkout.`);
|
|
29
|
+
}
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (getNodeErrorCode(error) !== 'ENOENT')
|
|
34
|
+
throw error;
|
|
35
|
+
const next = dirname(parent);
|
|
36
|
+
if (next === parent)
|
|
37
|
+
throw error;
|
|
38
|
+
parent = next;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -323,13 +323,16 @@ export async function applyCustomComponent(ctx, config, dryRun = false, rollback
|
|
|
323
323
|
// fragment-expanded form. The workspace source keeps only the directive,
|
|
324
324
|
// so shared CSS stays single-sourced.
|
|
325
325
|
const sharedDir = join(componentDir, '..', '..', SHARED_FRAGMENTS_DIR);
|
|
326
|
-
await Promise.
|
|
326
|
+
const copies = await Promise.allSettled(filesToCopy.map(async (entry) => {
|
|
327
327
|
const src = join(componentDir, entry.name);
|
|
328
328
|
const dest = join(targetDir, entry.name);
|
|
329
329
|
await deployFileWithFragments(src, dest, sharedDir);
|
|
330
330
|
affectedPaths.push(normalizePathSlashes(relative(engineDir, dest)));
|
|
331
331
|
copiedFileNames.push(entry.name);
|
|
332
332
|
}));
|
|
333
|
+
const failedCopy = copies.find((copy) => copy.status === 'rejected');
|
|
334
|
+
if (failedCopy?.status === 'rejected')
|
|
335
|
+
throw failedCopy.reason;
|
|
333
336
|
// See buildCustomDryRunActions for the rationale: when `sharedFtl` is set
|
|
334
337
|
// the shared bundle is owned elsewhere and FireForge must not copy or
|
|
335
338
|
// register a per-component `.ftl` on its behalf.
|
|
@@ -416,12 +419,15 @@ export async function applyOverrideComponent(ctx, config, dryRun = false, rollba
|
|
|
416
419
|
}
|
|
417
420
|
}
|
|
418
421
|
// Copy phase (parallel)
|
|
419
|
-
await Promise.
|
|
422
|
+
const copies = await Promise.allSettled(candidateEntries.map(async (entry) => {
|
|
420
423
|
const src = join(componentDir, entry.name);
|
|
421
424
|
const dest = getOverrideEngineTargetPath(engineDir, config, entry.name, ftlDir);
|
|
422
425
|
await copyFile(src, dest);
|
|
423
426
|
affectedPaths.push(normalizePathSlashes(relative(engineDir, dest)));
|
|
424
427
|
}));
|
|
428
|
+
const failedCopy = copies.find((copy) => copy.status === 'rejected');
|
|
429
|
+
if (failedCopy?.status === 'rejected')
|
|
430
|
+
throw failedCopy.reason;
|
|
425
431
|
if (affectedPaths.length === 0) {
|
|
426
432
|
throw new FurnaceError(`No matching files found in override directory for "${name}"`, name);
|
|
427
433
|
}
|
|
@@ -5,7 +5,9 @@ import { assert } from '../utils/assert.js';
|
|
|
5
5
|
import { toError } from '../utils/errors.js';
|
|
6
6
|
import { pathExists } from '../utils/fs.js';
|
|
7
7
|
import { info } from '../utils/logger.js';
|
|
8
|
+
import { captureComponentSources, componentSourceErrors } from './component-source-guard.js';
|
|
8
9
|
import { getProjectPaths, loadConfig } from './config.js';
|
|
10
|
+
import { assertComponentWriteBoundaries } from './engine-write-boundary.js';
|
|
9
11
|
import { applyCustomComponent, applyOverrideComponent, computeComponentChecksums, diffDeletedFiles, extractComponentChecksums, hasComponentChanged, hasCustomEngineDrift, hasOverrideEngineDrift, prefixChecksums, undeployCustomFiles, undeployOverrideFiles, } from './furnace-apply-helpers.js';
|
|
10
12
|
import { findPatchOwnedOverwrites, loadPatchClaimsForApply, recordOverwriteWarnings, } from './furnace-apply-overwrite-warn.js';
|
|
11
13
|
import { buildCustomUndeployActions, buildOverrideUndeployActions, } from './furnace-apply-undeploy-actions.js';
|
|
@@ -334,6 +336,10 @@ export async function applyAllComponents(root, dryRun = false, options) {
|
|
|
334
336
|
const forgeConfig = await loadConfig(root).catch(() => undefined);
|
|
335
337
|
const markerComment = resolveFurnaceMarkerComment(forgeConfig);
|
|
336
338
|
await assertFurnaceEngineDirReady(engineDir);
|
|
339
|
+
if (!dryRun) {
|
|
340
|
+
await assertComponentWriteBoundaries(engineDir, config, ftlDir);
|
|
341
|
+
}
|
|
342
|
+
const sourceSnapshot = await captureComponentSources(furnacePaths, config, dryRun, options);
|
|
337
343
|
const rollbackJournal = dryRun ? undefined : createRollbackJournal();
|
|
338
344
|
if (rollbackJournal && operationContext) {
|
|
339
345
|
operationContext.registerJournal(rollbackJournal);
|
|
@@ -381,6 +387,7 @@ export async function applyAllComponents(root, dryRun = false, options) {
|
|
|
381
387
|
};
|
|
382
388
|
await applyOverrideBatch(batchContext);
|
|
383
389
|
await applyCustomBatch(batchContext, root, markerComment);
|
|
390
|
+
result.errors.push(...(await componentSourceErrors(sourceSnapshot)));
|
|
384
391
|
// Check for any partial failures (blocking step errors on applied
|
|
385
392
|
// components). Advisory step errors (e.g. FTL degradation) are warnings
|
|
386
393
|
// and never gate rollback.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { pathExists, readText } from '../utils/fs.js';
|
|
4
4
|
import { createIssue } from './furnace-validate-helpers.js';
|
|
5
|
+
import { collectLitTemplateRegions, parseTemplateAttributes } from './lit-template-regions.js';
|
|
5
6
|
/**
|
|
6
7
|
* Validates accessibility patterns in a component's .mjs file.
|
|
7
8
|
* Checks for ARIA roles, keyboard handlers, l10n, and focus delegation.
|
|
@@ -103,15 +104,16 @@ function hasUnlabelledFormInput(content) {
|
|
|
103
104
|
// (id implies an external <label for="..."> could exist). A control wrapped
|
|
104
105
|
// in a <label> that carries actual text is implicitly associated and is
|
|
105
106
|
// exempt as well.
|
|
107
|
+
content = collectLitTemplateRegions(content).join('\n');
|
|
106
108
|
const labelledSpans = collectLabelledSpans(content);
|
|
107
|
-
const inputPattern = /<(input|select|textarea)\b([
|
|
109
|
+
const inputPattern = /<(input|select|textarea)\b((?:"[^"]*"|'[^']*'|[^'">])*)>/gi;
|
|
108
110
|
let inputMatch;
|
|
109
111
|
while ((inputMatch = inputPattern.exec(content)) !== null) {
|
|
110
|
-
const attrs = inputMatch[2] ?? '';
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
const attrs = parseTemplateAttributes(inputMatch[2] ?? '');
|
|
113
|
+
if (attrs.has('aria-label') ||
|
|
114
|
+
attrs.has('aria-labelledby') ||
|
|
115
|
+
attrs.has('id') ||
|
|
116
|
+
attrs.get('type')?.toLowerCase() === 'hidden') {
|
|
115
117
|
continue;
|
|
116
118
|
}
|
|
117
119
|
const index = inputMatch.index;
|
|
@@ -315,12 +317,7 @@ function isFlaggableText(text) {
|
|
|
315
317
|
return true;
|
|
316
318
|
}
|
|
317
319
|
function hasFlaggedTextInLitTemplates(content) {
|
|
318
|
-
|
|
319
|
-
// so substrings like `otherhtml` do not spuriously open a template.
|
|
320
|
-
const htmlPattern = /(?:^|[^a-zA-Z0-9_$])html`([\s\S]*?)`/g;
|
|
321
|
-
let litMatch;
|
|
322
|
-
while ((litMatch = htmlPattern.exec(content)) !== null) {
|
|
323
|
-
const region = litMatch[1] ?? '';
|
|
320
|
+
for (const region of collectLitTemplateRegions(content)) {
|
|
324
321
|
const textPattern = />([^<$\s][^<$]*)</g;
|
|
325
322
|
let textMatch;
|
|
326
323
|
while ((textMatch = textPattern.exec(region)) !== null) {
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { readdir } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { toError } from '../utils/errors.js';
|
|
5
4
|
import { pathExists } from '../utils/fs.js';
|
|
6
5
|
import { getProjectPaths, loadConfig } from './config.js';
|
|
7
6
|
import { extractComponentChecksums } from './furnace-checksum-utils.js';
|
|
8
7
|
import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState } from './furnace-config.js';
|
|
9
8
|
import { resolveFtlDir, xpcshellTestParentDir } from './furnace-constants.js';
|
|
10
9
|
import { validateCssFragments } from './furnace-css-fragments.js';
|
|
11
|
-
import { detectComposesCycles, validateComposesReferences } from './furnace-graph-utils.js';
|
|
12
10
|
import { findJsconfigPathsDrift } from './furnace-jsconfig.js';
|
|
13
11
|
import { validateAccessibility, validateCompatibility, validateJarMnEntries, validateRegistrationPatterns, validateStructure, validateTokenLink, } from './furnace-validate-checks.js';
|
|
14
12
|
import { createIssue } from './furnace-validate-helpers.js';
|
|
@@ -110,47 +108,6 @@ export async function validateAllComponents(root) {
|
|
|
110
108
|
const config = await loadFurnaceConfig(root);
|
|
111
109
|
const furnacePaths = getFurnacePaths(root);
|
|
112
110
|
const results = new Map();
|
|
113
|
-
// Validate composition graph integrity (dangling references and cycles)
|
|
114
|
-
try {
|
|
115
|
-
validateComposesReferences(config.stock, config.overrides, config.custom);
|
|
116
|
-
}
|
|
117
|
-
catch (err) {
|
|
118
|
-
const message = toError(err).message;
|
|
119
|
-
// Attribute the issue to the first custom component with a bad composes reference
|
|
120
|
-
for (const [name, cfg] of Object.entries(config.custom)) {
|
|
121
|
-
if (cfg.composes) {
|
|
122
|
-
const existing = results.get(name) ?? [];
|
|
123
|
-
existing.push({
|
|
124
|
-
component: name,
|
|
125
|
-
severity: 'error',
|
|
126
|
-
check: 'composes-dangling-reference',
|
|
127
|
-
message,
|
|
128
|
-
});
|
|
129
|
-
results.set(name, existing);
|
|
130
|
-
break;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
try {
|
|
135
|
-
detectComposesCycles(config.custom);
|
|
136
|
-
}
|
|
137
|
-
catch (err) {
|
|
138
|
-
const message = toError(err).message;
|
|
139
|
-
// Attribute the cycle issue to the first custom component in the cycle
|
|
140
|
-
for (const name of Object.keys(config.custom)) {
|
|
141
|
-
if (config.custom[name]?.composes) {
|
|
142
|
-
const existing = results.get(name) ?? [];
|
|
143
|
-
existing.push({
|
|
144
|
-
component: name,
|
|
145
|
-
severity: 'error',
|
|
146
|
-
check: 'composes-cycle',
|
|
147
|
-
message,
|
|
148
|
-
});
|
|
149
|
-
results.set(name, existing);
|
|
150
|
-
break;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
111
|
// Override components
|
|
155
112
|
for (const name of Object.keys(config.overrides)) {
|
|
156
113
|
const componentDir = join(furnacePaths.overridesDir, name);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/** Adds Gecko's cross-compartment DOM predicates to virtual lib.dom constructor types. */
|
|
3
|
+
export function withGeckoDomTypes(ts, host) {
|
|
4
|
+
const original = host.getSourceFile.bind(host);
|
|
5
|
+
return {
|
|
6
|
+
...host,
|
|
7
|
+
getSourceFile(...args) {
|
|
8
|
+
const file = original(...args);
|
|
9
|
+
if (!file || !args[0].endsWith('/lib.dom.d.ts'))
|
|
10
|
+
return file;
|
|
11
|
+
const source = file.text.replace(/declare var (\w+): \{\s*prototype: (\w+);/g, (declaration, name, instance) => declaration +
|
|
12
|
+
`\n isInstance(value: unknown): value is ${instance};` +
|
|
13
|
+
(name === 'MouseEvent' ? '\n readonly MOZ_SOURCE_PEN: number;' : ''));
|
|
14
|
+
return ts.createSourceFile(args[0], source, args[1], true);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Collects Lit html quasis, masking expressions while preserving nested templates. */
|
|
2
|
+
export declare function collectLitTemplateRegions(content: string): string[];
|
|
3
|
+
/** Reads actual attribute names and values, skipping quoted values as indivisible tokens. */
|
|
4
|
+
export declare function parseTemplateAttributes(source: string): Map<string, string | undefined>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { tokenizer } from 'acorn';
|
|
3
|
+
/** Collects Lit html quasis, masking expressions while preserving nested templates. */
|
|
4
|
+
export function collectLitTemplateRegions(content) {
|
|
5
|
+
const regions = [];
|
|
6
|
+
const stack = [];
|
|
7
|
+
let previous = '';
|
|
8
|
+
let previousValue;
|
|
9
|
+
try {
|
|
10
|
+
for (const token of tokenizer(content, { ecmaVersion: 'latest', sourceType: 'module' })) {
|
|
11
|
+
const label = token.type.label;
|
|
12
|
+
if (label === '`') {
|
|
13
|
+
if (previous === 'template' || previous === 'invalidTemplate') {
|
|
14
|
+
const closed = stack.pop();
|
|
15
|
+
if (closed?.html)
|
|
16
|
+
regions.push(closed.text);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
stack.push({ html: previous === 'name' && previousValue === 'html', text: '' });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
else if (label === 'template') {
|
|
23
|
+
const current = stack.at(-1);
|
|
24
|
+
if (current)
|
|
25
|
+
current.text += content.slice(token.start, token.end);
|
|
26
|
+
}
|
|
27
|
+
else if (label === '${') {
|
|
28
|
+
const current = stack.at(-1);
|
|
29
|
+
if (current)
|
|
30
|
+
current.text += '${expression}';
|
|
31
|
+
}
|
|
32
|
+
previous = label;
|
|
33
|
+
previousValue = content.slice(token.start, token.end);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Keep completed regions from a file being edited; never re-pair backticks.
|
|
38
|
+
}
|
|
39
|
+
return regions;
|
|
40
|
+
}
|
|
41
|
+
/** Reads actual attribute names and values, skipping quoted values as indivisible tokens. */
|
|
42
|
+
export function parseTemplateAttributes(source) {
|
|
43
|
+
const attributes = new Map();
|
|
44
|
+
const pattern = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
45
|
+
for (const match of source.matchAll(pattern)) {
|
|
46
|
+
if (match[1])
|
|
47
|
+
attributes.set(match[1].toLowerCase(), match[2] ?? match[3] ?? match[4]);
|
|
48
|
+
}
|
|
49
|
+
return attributes;
|
|
50
|
+
}
|
|
@@ -92,6 +92,8 @@ export interface MozinfoRewriteResult {
|
|
|
92
92
|
reason?: string;
|
|
93
93
|
/** New `topsrcdir` value written to disk (populated on success). */
|
|
94
94
|
newTopsrcdir?: string;
|
|
95
|
+
/** Original source prefix checked after configure. */
|
|
96
|
+
oldTopsrcdir?: string;
|
|
95
97
|
/** New `topobjdir` value written to disk (populated on success). */
|
|
96
98
|
newTopobjdir?: string;
|
|
97
99
|
/** New `mozconfig` value written to disk (populated on success when it lived inside topsrcdir). */
|
|
@@ -100,12 +102,10 @@ export interface MozinfoRewriteResult {
|
|
|
100
102
|
/**
|
|
101
103
|
* Safe-relocation rewriter for mozinfo.json under the active obj-* tree.
|
|
102
104
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* reads to learn where its checkout actually lives. Once mozinfo.json
|
|
108
|
-
* agrees with the on-disk layout, `mach configure` regenerates the rest.
|
|
105
|
+
* Copied objdirs retain source symlinks, dependency metadata and tracking
|
|
106
|
+
* inventories as well as mozinfo.json. Repair them before configure so
|
|
107
|
+
* config.track cannot write nulls into the primary checkout. Configure then
|
|
108
|
+
* regenerates the root config files; callers verify old-source links too.
|
|
109
109
|
*
|
|
110
110
|
* Safety rules. The rewrite is refused when any of them are violated:
|
|
111
111
|
* - `topsrcdir` and `topobjdir` must both be present and non-empty.
|
|
@@ -9,6 +9,7 @@ import { verbose } from '../utils/logger.js';
|
|
|
9
9
|
import { getPlatform } from '../utils/platform.js';
|
|
10
10
|
import { isObject, isString } from '../utils/validation.js';
|
|
11
11
|
import { extractMozObjdirName } from './mach-mozconfig.js';
|
|
12
|
+
import { findStaleObjdirLink, relocateObjdirProducts } from './objdir-maintenance.js';
|
|
12
13
|
function validateBuildMozinfo(data) {
|
|
13
14
|
if (!isObject(data)) {
|
|
14
15
|
throw new Error('mozinfo metadata must be an object');
|
|
@@ -239,12 +240,10 @@ export function buildArtifactMismatchMessage(engineDir, buildCheck, commandName)
|
|
|
239
240
|
/**
|
|
240
241
|
* Safe-relocation rewriter for mozinfo.json under the active obj-* tree.
|
|
241
242
|
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
* reads to learn where its checkout actually lives. Once mozinfo.json
|
|
247
|
-
* agrees with the on-disk layout, `mach configure` regenerates the rest.
|
|
243
|
+
* Copied objdirs retain source symlinks, dependency metadata and tracking
|
|
244
|
+
* inventories as well as mozinfo.json. Repair them before configure so
|
|
245
|
+
* config.track cannot write nulls into the primary checkout. Configure then
|
|
246
|
+
* regenerates the root config files; callers verify old-source links too.
|
|
248
247
|
*
|
|
249
248
|
* Safety rules. The rewrite is refused when any of them are violated:
|
|
250
249
|
* - `topsrcdir` and `topobjdir` must both be present and non-empty.
|
|
@@ -328,10 +327,17 @@ export async function attemptMozinfoRewrite(engineDir, objDir) {
|
|
|
328
327
|
// its mozconfig will still fail configure. The operator can re-point
|
|
329
328
|
// with `MOZCONFIG=…` or run a full clean rebuild.
|
|
330
329
|
}
|
|
330
|
+
try {
|
|
331
|
+
await relocateObjdirProducts(engineDir, objDir, oldSrcResolved);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
return { rewritten: false, reason: `objdir relocation refused: ${toError(error).message}` };
|
|
335
|
+
}
|
|
331
336
|
await writeJson(mozinfoPath, patched);
|
|
332
337
|
return {
|
|
333
338
|
rewritten: true,
|
|
334
339
|
newTopsrcdir: newSrc,
|
|
340
|
+
oldTopsrcdir: oldSrcResolved,
|
|
335
341
|
newTopobjdir: newObj,
|
|
336
342
|
...(newMozconfig ? { newMozconfig } : {}),
|
|
337
343
|
};
|
|
@@ -426,5 +432,5 @@ export async function findObjdirRelocationViolation(args) {
|
|
|
426
432
|
return `${objDir}/${name} still contains the primary engine path ${forbidden}`;
|
|
427
433
|
}
|
|
428
434
|
}
|
|
429
|
-
return
|
|
435
|
+
return await findStaleObjdirLink(engineDir, objDir, forbidden);
|
|
430
436
|
}
|