@hominis/fireforge 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/cli.js +16 -28
  4. package/dist/src/commands/build.js +30 -4
  5. package/dist/src/commands/doctor-engine-eslint.d.ts +3 -0
  6. package/dist/src/commands/doctor-engine-eslint.js +12 -0
  7. package/dist/src/commands/doctor-macos-sdk.js +7 -5
  8. package/dist/src/commands/doctor-source-pin.js +9 -2
  9. package/dist/src/commands/doctor.js +2 -0
  10. package/dist/src/commands/furnace/apply.js +10 -4
  11. package/dist/src/commands/furnace/create-templates.js +0 -3
  12. package/dist/src/commands/lint.js +4 -0
  13. package/dist/src/commands/manifest.d.ts +1 -2
  14. package/dist/src/commands/patch/delete.js +18 -4
  15. package/dist/src/commands/re-export-options.js +7 -0
  16. package/dist/src/commands/re-export-register.js +1 -0
  17. package/dist/src/commands/re-export-removed.d.ts +4 -0
  18. package/dist/src/commands/re-export-removed.js +49 -0
  19. package/dist/src/commands/re-export-single.js +4 -1
  20. package/dist/src/commands/re-export.js +4 -0
  21. package/dist/src/commands/test-browser-preflight.d.ts +28 -0
  22. package/dist/src/commands/test-browser-preflight.js +181 -0
  23. package/dist/src/commands/test-build-phase.js +30 -7
  24. package/dist/src/commands/test-checkjs.d.ts +3 -0
  25. package/dist/src/commands/test-checkjs.js +27 -0
  26. package/dist/src/commands/test-diagnose.js +3 -0
  27. package/dist/src/commands/test-harness-env.d.ts +5 -6
  28. package/dist/src/commands/test-harness-env.js +27 -7
  29. package/dist/src/commands/test-modes.d.ts +2 -4
  30. package/dist/src/commands/test-register.js +11 -5
  31. package/dist/src/commands/test-run.d.ts +2 -0
  32. package/dist/src/commands/test-run.js +21 -1
  33. package/dist/src/commands/test-verdict.d.ts +2 -2
  34. package/dist/src/commands/test.js +83 -211
  35. package/dist/src/core/build-baseline-types.d.ts +12 -14
  36. package/dist/src/core/build-baseline-validate.d.ts +3 -0
  37. package/dist/src/core/build-baseline-validate.js +53 -0
  38. package/dist/src/core/build-baseline.d.ts +4 -3
  39. package/dist/src/core/build-baseline.js +23 -11
  40. package/dist/src/core/component-source-guard.d.ts +20 -0
  41. package/dist/src/core/component-source-guard.js +68 -0
  42. package/dist/src/core/coverage-extend.d.ts +5 -34
  43. package/dist/src/core/coverage-extend.js +26 -37
  44. package/dist/src/core/engine-write-boundary.d.ts +5 -0
  45. package/dist/src/core/engine-write-boundary.js +41 -0
  46. package/dist/src/core/furnace-apply-helpers.js +8 -2
  47. package/dist/src/core/furnace-apply.js +7 -0
  48. package/dist/src/core/furnace-validate-accessibility.js +9 -12
  49. package/dist/src/core/furnace-validate.js +0 -43
  50. package/dist/src/core/gecko-dom-types.d.ts +2 -0
  51. package/dist/src/core/gecko-dom-types.js +17 -0
  52. package/dist/src/core/lit-template-regions.d.ts +4 -0
  53. package/dist/src/core/lit-template-regions.js +50 -0
  54. package/dist/src/core/mach-build-artifacts.d.ts +6 -6
  55. package/dist/src/core/mach-build-artifacts.js +13 -7
  56. package/dist/src/core/mach-resource-shim.d.ts +1 -1
  57. package/dist/src/core/mach-resource-shim.js +2 -0
  58. package/dist/src/core/marionette-port.d.ts +2 -0
  59. package/dist/src/core/marionette-port.js +38 -53
  60. package/dist/src/core/mochitest-server-port.d.ts +1 -1
  61. package/dist/src/core/mochitest-server-port.js +20 -17
  62. package/dist/src/core/objdir-maintenance.d.ts +10 -0
  63. package/dist/src/core/objdir-maintenance.js +151 -0
  64. package/dist/src/core/patch-lint-checkjs.js +2 -1
  65. package/dist/src/core/preflight-wait.d.ts +2 -0
  66. package/dist/src/core/preflight-wait.js +24 -0
  67. package/dist/src/core/process-owner.d.ts +9 -0
  68. package/dist/src/core/process-owner.js +43 -0
  69. package/dist/src/core/test-harness-crash.d.ts +1 -1
  70. package/dist/src/core/test-harness-crash.js +5 -5
  71. package/dist/src/core/test-host-state.d.ts +14 -0
  72. package/dist/src/core/test-host-state.js +80 -0
  73. package/dist/src/core/test-profile-files.d.ts +17 -0
  74. package/dist/src/core/test-profile-files.js +75 -0
  75. package/dist/src/core/test-stale-check.d.ts +6 -7
  76. package/dist/src/core/test-stale-check.js +9 -32
  77. package/dist/src/core/token-category.d.ts +7 -26
  78. package/dist/src/core/token-category.js +68 -121
  79. package/dist/src/core/token-inventory.js +12 -7
  80. package/dist/src/core/typecheck.js +6 -6
  81. package/dist/src/types/commands/options.d.ts +14 -3
  82. package/dist/src/utils/assert.js +2 -7
  83. package/dist/src/utils/child-env.d.ts +0 -3
  84. package/dist/src/utils/child-env.js +0 -3
  85. package/dist/src/utils/concurrency.d.ts +2 -2
  86. package/dist/src/utils/concurrency.js +15 -4
  87. package/dist/src/utils/fs.js +11 -1
  88. package/dist/src/utils/options.d.ts +16 -1
  89. package/dist/src/utils/options.js +5 -3
  90. package/dist/src/utils/paths.js +3 -2
  91. package/dist/src/utils/process-group.d.ts +1 -2
  92. package/dist/src/utils/process-group.js +1 -2
  93. package/dist/src/utils/process-input.js +0 -3
  94. package/dist/src/utils/process.d.ts +3 -2
  95. package/dist/src/utils/process.js +19 -7
  96. package/docs/configuration.md +12 -11
  97. package/docs/lifecycle-invariants.md +9 -5
  98. package/docs/machine-output.md +36 -19
  99. package/docs/patch-workflow.md +15 -0
  100. package/docs/testing.md +77 -12
  101. package/package.json +4 -3
@@ -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
- * `test --build/--build-only --extend-coverage`: unions the requested paths
3
- * into the recorded `testPackagingCoverage` instead of replacing it, under
4
- * an anchor guard.
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
- * `test --build/--build-only --extend-coverage`: unions the requested paths
4
- * into the recorded `testPackagingCoverage` instead of replacing it, under
5
- * an anchor guard.
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.packageableFingerprints;
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 packageable fingerprints, so ' +
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} packageable file(s) the recorded build staged have changed ` +
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.all(filesToCopy.map(async (entry) => {
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.all(candidateEntries.map(async (entry) => {
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([^>]*)>/gi;
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 (/aria-label\s*=/.test(attrs) ||
112
- /aria-labelledby\s*=/.test(attrs) ||
113
- /\bid\s*=/.test(attrs) ||
114
- /type\s*=\s*["']hidden["']/i.test(attrs)) {
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
- // Match `html\`…\`` regions, anchored on a non-identifier char before `html`
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,2 @@
1
+ /** Adds Gecko's cross-compartment DOM predicates to virtual lib.dom constructor types. */
2
+ export declare function withGeckoDomTypes(ts: typeof import('typescript'), host: import('typescript').CompilerHost): import('typescript').CompilerHost;
@@ -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
- * Firefox build artefacts bake the topsrcdir into many generated files
104
- * (Makefiles, config.status, backend.mk, .deps dependency files, anything
105
- * produced by `mach configure`). A fresh `mach configure` rebuilds those
106
- * from the top, so the rewriter only needs to patch the one file `mach`
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
- * Firefox build artefacts bake the topsrcdir into many generated files
243
- * (Makefiles, config.status, backend.mk, .deps dependency files, anything
244
- * produced by `mach configure`). A fresh `mach configure` rebuilds those
245
- * from the top, so the rewriter only needs to patch the one file `mach`
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 undefined;
435
+ return await findStaleObjdirLink(engineDir, objDir, forbidden);
430
436
  }