@hominis/fireforge 0.47.1 → 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 (167) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/bin/fireforge.js +5 -0
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/cli.js +16 -28
  5. package/dist/src/commands/build.js +30 -4
  6. package/dist/src/commands/doctor-engine-eslint.d.ts +3 -0
  7. package/dist/src/commands/doctor-engine-eslint.js +12 -0
  8. package/dist/src/commands/doctor-macos-sdk.d.ts +8 -0
  9. package/dist/src/commands/doctor-macos-sdk.js +158 -0
  10. package/dist/src/commands/doctor-source-pin.js +9 -2
  11. package/dist/src/commands/doctor.js +4 -0
  12. package/dist/src/commands/download.js +64 -3
  13. package/dist/src/commands/furnace/apply.js +10 -4
  14. package/dist/src/commands/furnace/create-templates.js +0 -3
  15. package/dist/src/commands/import.js +5 -2
  16. package/dist/src/commands/lint-per-patch.js +44 -6
  17. package/dist/src/commands/lint-per-run-checkjs.d.ts +3 -0
  18. package/dist/src/commands/lint-per-run-checkjs.js +1 -0
  19. package/dist/src/commands/lint.js +14 -0
  20. package/dist/src/commands/manifest.d.ts +1 -2
  21. package/dist/src/commands/patch/delete.js +18 -4
  22. package/dist/src/commands/re-export-lint.d.ts +17 -2
  23. package/dist/src/commands/re-export-lint.js +33 -4
  24. package/dist/src/commands/re-export-options.js +7 -0
  25. package/dist/src/commands/re-export-register.js +1 -0
  26. package/dist/src/commands/re-export-removed.d.ts +4 -0
  27. package/dist/src/commands/re-export-removed.js +49 -0
  28. package/dist/src/commands/re-export-single.js +4 -1
  29. package/dist/src/commands/re-export.js +8 -2
  30. package/dist/src/commands/rebase/confirm.d.ts +12 -1
  31. package/dist/src/commands/rebase/confirm.js +6 -3
  32. package/dist/src/commands/rebase/index.js +77 -13
  33. package/dist/src/commands/run.js +43 -7
  34. package/dist/src/commands/status.js +5 -3
  35. package/dist/src/commands/test-browser-preflight.d.ts +28 -0
  36. package/dist/src/commands/test-browser-preflight.js +181 -0
  37. package/dist/src/commands/test-build-phase.js +30 -7
  38. package/dist/src/commands/test-checkjs.d.ts +3 -0
  39. package/dist/src/commands/test-checkjs.js +27 -0
  40. package/dist/src/commands/test-diagnose.js +3 -0
  41. package/dist/src/commands/test-harness-env.d.ts +5 -6
  42. package/dist/src/commands/test-harness-env.js +27 -7
  43. package/dist/src/commands/test-modes.d.ts +2 -4
  44. package/dist/src/commands/test-register.js +11 -5
  45. package/dist/src/commands/test-run.d.ts +2 -0
  46. package/dist/src/commands/test-run.js +21 -1
  47. package/dist/src/commands/test-verdict.d.ts +2 -2
  48. package/dist/src/commands/test.js +83 -211
  49. package/dist/src/commands/typecheck.d.ts +5 -0
  50. package/dist/src/commands/typecheck.js +7 -1
  51. package/dist/src/core/build-baseline-types.d.ts +12 -14
  52. package/dist/src/core/build-baseline-validate.d.ts +3 -0
  53. package/dist/src/core/build-baseline-validate.js +53 -0
  54. package/dist/src/core/build-baseline.d.ts +4 -3
  55. package/dist/src/core/build-baseline.js +23 -11
  56. package/dist/src/core/build-prepare.d.ts +2 -0
  57. package/dist/src/core/build-prepare.js +67 -35
  58. package/dist/src/core/component-source-guard.d.ts +20 -0
  59. package/dist/src/core/component-source-guard.js +68 -0
  60. package/dist/src/core/config-paths.d.ts +1 -1
  61. package/dist/src/core/config-paths.js +1 -0
  62. package/dist/src/core/config-validate.js +7 -0
  63. package/dist/src/core/coverage-extend.d.ts +5 -34
  64. package/dist/src/core/coverage-extend.js +26 -37
  65. package/dist/src/core/discard-baseline.js +2 -1
  66. package/dist/src/core/engine-write-boundary.d.ts +5 -0
  67. package/dist/src/core/engine-write-boundary.js +41 -0
  68. package/dist/src/core/forward-registrations.d.ts +33 -0
  69. package/dist/src/core/forward-registrations.js +16 -0
  70. package/dist/src/core/furnace-apply-helpers.js +8 -2
  71. package/dist/src/core/furnace-apply.js +7 -0
  72. package/dist/src/core/furnace-validate-accessibility.js +9 -12
  73. package/dist/src/core/furnace-validate.js +0 -43
  74. package/dist/src/core/gecko-dom-types.d.ts +2 -0
  75. package/dist/src/core/gecko-dom-types.js +17 -0
  76. package/dist/src/core/git-file-ops.d.ts +21 -0
  77. package/dist/src/core/git-file-ops.js +75 -0
  78. package/dist/src/core/git-status.d.ts +14 -0
  79. package/dist/src/core/git-status.js +41 -2
  80. package/dist/src/core/lit-template-regions.d.ts +4 -0
  81. package/dist/src/core/lit-template-regions.js +50 -0
  82. package/dist/src/core/mach-build-artifacts.d.ts +6 -6
  83. package/dist/src/core/mach-build-artifacts.js +13 -7
  84. package/dist/src/core/mach-mozconfig.d.ts +2 -1
  85. package/dist/src/core/mach-mozconfig.js +2 -0
  86. package/dist/src/core/mach-resource-shim.d.ts +1 -1
  87. package/dist/src/core/mach-resource-shim.js +2 -0
  88. package/dist/src/core/marionette-port.d.ts +2 -0
  89. package/dist/src/core/marionette-port.js +38 -53
  90. package/dist/src/core/mochitest-server-port.d.ts +1 -1
  91. package/dist/src/core/mochitest-server-port.js +20 -17
  92. package/dist/src/core/objdir-maintenance.d.ts +10 -0
  93. package/dist/src/core/objdir-maintenance.js +151 -0
  94. package/dist/src/core/patch-apply-fuzz.d.ts +12 -0
  95. package/dist/src/core/patch-apply-fuzz.js +27 -6
  96. package/dist/src/core/patch-apply.d.ts +13 -0
  97. package/dist/src/core/patch-apply.js +27 -2
  98. package/dist/src/core/patch-lint-checkjs-cache.d.ts +18 -0
  99. package/dist/src/core/patch-lint-checkjs-cache.js +45 -0
  100. package/dist/src/core/patch-lint-checkjs.d.ts +6 -0
  101. package/dist/src/core/patch-lint-checkjs.js +10 -2
  102. package/dist/src/core/patch-lint-cross.d.ts +7 -1
  103. package/dist/src/core/patch-lint-cross.js +13 -12
  104. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +38 -0
  105. package/dist/src/core/patch-lint-forward-registration-extended.js +240 -0
  106. package/dist/src/core/patch-lint-forward-registration.d.ts +18 -5
  107. package/dist/src/core/patch-lint-forward-registration.js +83 -37
  108. package/dist/src/core/patch-lint.d.ts +11 -0
  109. package/dist/src/core/patch-lint.js +20 -2
  110. package/dist/src/core/patch-transform.d.ts +14 -13
  111. package/dist/src/core/patch-transform.js +29 -17
  112. package/dist/src/core/preflight-wait.d.ts +2 -0
  113. package/dist/src/core/preflight-wait.js +24 -0
  114. package/dist/src/core/process-owner.d.ts +9 -0
  115. package/dist/src/core/process-owner.js +43 -0
  116. package/dist/src/core/rebase-dry-run.d.ts +48 -0
  117. package/dist/src/core/rebase-dry-run.js +105 -0
  118. package/dist/src/core/run-profile.d.ts +50 -0
  119. package/dist/src/core/run-profile.js +131 -0
  120. package/dist/src/core/status-binary.d.ts +1 -1
  121. package/dist/src/core/status-binary.js +3 -1
  122. package/dist/src/core/status-classify.js +29 -2
  123. package/dist/src/core/test-harness-crash.d.ts +1 -1
  124. package/dist/src/core/test-harness-crash.js +5 -5
  125. package/dist/src/core/test-host-state.d.ts +14 -0
  126. package/dist/src/core/test-host-state.js +80 -0
  127. package/dist/src/core/test-profile-files.d.ts +17 -0
  128. package/dist/src/core/test-profile-files.js +75 -0
  129. package/dist/src/core/test-stale-check.d.ts +6 -7
  130. package/dist/src/core/test-stale-check.js +9 -32
  131. package/dist/src/core/token-category.d.ts +7 -26
  132. package/dist/src/core/token-category.js +68 -121
  133. package/dist/src/core/token-inventory.js +12 -7
  134. package/dist/src/core/toolchain-preflight.d.ts +6 -0
  135. package/dist/src/core/toolchain-preflight.js +1 -1
  136. package/dist/src/core/typecheck.d.ts +11 -1
  137. package/dist/src/core/typecheck.js +86 -12
  138. package/dist/src/errors/rebase.d.ts +9 -0
  139. package/dist/src/errors/rebase.js +20 -0
  140. package/dist/src/index.d.ts +3 -1
  141. package/dist/src/index.js +2 -1
  142. package/dist/src/types/commands/options.d.ts +29 -3
  143. package/dist/src/types/config.d.ts +10 -0
  144. package/dist/src/utils/assert.js +2 -7
  145. package/dist/src/utils/child-env.d.ts +0 -3
  146. package/dist/src/utils/child-env.js +0 -3
  147. package/dist/src/utils/concurrency.d.ts +2 -2
  148. package/dist/src/utils/concurrency.js +15 -4
  149. package/dist/src/utils/fs.js +11 -1
  150. package/dist/src/utils/options.d.ts +16 -1
  151. package/dist/src/utils/options.js +5 -3
  152. package/dist/src/utils/paths.js +3 -2
  153. package/dist/src/utils/process-group.d.ts +1 -2
  154. package/dist/src/utils/process-group.js +1 -2
  155. package/dist/src/utils/process-input.d.ts +27 -0
  156. package/dist/src/utils/process-input.js +46 -0
  157. package/dist/src/utils/process.d.ts +5 -2
  158. package/dist/src/utils/process.js +21 -8
  159. package/dist/src/utils/validation.d.ts +11 -0
  160. package/dist/src/utils/validation.js +43 -0
  161. package/docs/configuration.md +82 -0
  162. package/docs/exit-codes.md +1 -1
  163. package/docs/lifecycle-invariants.md +13 -8
  164. package/docs/machine-output.md +36 -19
  165. package/docs/patch-workflow.md +50 -1
  166. package/docs/testing.md +77 -12
  167. package/package.json +10 -5
@@ -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':
@@ -53,11 +53,12 @@ export async function planDiscardBaselines(patchesDir, engineDir, entries) {
53
53
  if (claimedEntries.length === 0) {
54
54
  return planUpstreamDiscards(entries);
55
55
  }
56
- const { computePatched } = await createPatchedContentContext(patchesDir, engineDir);
56
+ const { computePatched, prefetchBase } = 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
  ];
60
60
  const trackedInHead = await listTrackedInHead(engineDir, claimedFiles);
61
+ await prefetchBase(claimedFiles);
61
62
  const computeExpected = async (path) => {
62
63
  try {
63
64
  return await computePatched(path);
@@ -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
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Public, queue-level entry point to the `forward-registration` census.
3
+ *
4
+ * A consumer that keeps its own audit (a ratchet baseline over forward
5
+ * edges, say) runs the rule's own predicate here instead of mirroring it.
6
+ * Lives apart from the rule module because it builds the queue context,
7
+ * which imports the rule back.
8
+ */
9
+ import type { FireForgeConfig } from '../types/config.js';
10
+ import type { ForwardRegistration, ForwardRegistrationScope } from './patch-lint-forward-registration-extended.js';
11
+ /** Options for {@link findForwardRegistrations}. */
12
+ export interface FindForwardRegistrationsOptions {
13
+ /**
14
+ * Which carriers to check. Defaults to `'extended'` (every carrier the
15
+ * rule can resolve), since a census wants the whole picture whatever the
16
+ * lint gate is configured to enforce.
17
+ */
18
+ scope?: ForwardRegistrationScope;
19
+ /** Project config, for the queue's patchPolicy ordering context. Optional. */
20
+ config?: FireForgeConfig;
21
+ }
22
+ /**
23
+ * Lists every undeclared forward registration in a patch queue: a line a
24
+ * patch adds (to a test manifest, jar.mn, moz.build or customElements.js)
25
+ * that names a file only a later-ordered patch creates. Each record carries
26
+ * the paste-and-run `patch staged-dependency` command that declares it.
27
+ *
28
+ * @param patchesDir - The project's `patches/` directory
29
+ * @param options - Scope and optional config
30
+ * @returns One record per (patch, carrier file, created file)
31
+ */
32
+ export declare function findForwardRegistrations(patchesDir: string, options?: FindForwardRegistrationsOptions): Promise<ForwardRegistration[]>;
33
+ export type { ForwardRegistration, ForwardRegistrationKind, ForwardRegistrationScope, } from './patch-lint-forward-registration-extended.js';
@@ -0,0 +1,16 @@
1
+ import { buildPatchQueueContext } from './patch-lint-cross.js';
2
+ import { collectForwardRegistrations } from './patch-lint-forward-registration.js';
3
+ /**
4
+ * Lists every undeclared forward registration in a patch queue: a line a
5
+ * patch adds (to a test manifest, jar.mn, moz.build or customElements.js)
6
+ * that names a file only a later-ordered patch creates. Each record carries
7
+ * the paste-and-run `patch staged-dependency` command that declares it.
8
+ *
9
+ * @param patchesDir - The project's `patches/` directory
10
+ * @param options - Scope and optional config
11
+ * @returns One record per (patch, carrier file, created file)
12
+ */
13
+ export async function findForwardRegistrations(patchesDir, options = {}) {
14
+ const ctx = await buildPatchQueueContext(patchesDir, options.config);
15
+ return collectForwardRegistrations(ctx, options.scope ?? 'extended');
16
+ }
@@ -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
+ }
@@ -77,6 +77,27 @@ export declare function hashObjectBatch(repoDir: string, fullPaths: string[]): P
77
77
  * @returns File content or null if file doesn't exist at that ref
78
78
  */
79
79
  export declare function getFileContentAtRef(repoDir: string, filePath: string, ref?: string): Promise<string | null>;
80
+ /**
81
+ * Batched equivalent of {@link getFileContentAtRef}: reads every path at
82
+ * `ref` through one `git cat-file --batch` process instead of one
83
+ * `git show` per file. A Firefox-sized ownership scan paid 1,493 of those,
84
+ * about 18 ms each and almost all of it spawn overhead.
85
+ *
86
+ * The first request line is `ref` itself, so an unresolvable ref throws a
87
+ * {@link GitError} exactly as the per-file helper does, rather than being
88
+ * read as "every path missing". A path reported `missing` maps to `null`,
89
+ * as the per-file helper returns for a path absent at that ref. Anything
90
+ * the batch cannot answer as a plain blob (a path containing a newline,
91
+ * which the line protocol cannot carry, or an object that is not a blob)
92
+ * is fetched through {@link getFileContentAtRef}, so the result for every
93
+ * path is what that helper returns.
94
+ *
95
+ * @param repoDir - Repository directory
96
+ * @param filePaths - Paths relative to the repository root
97
+ * @param ref - Git ref to read from. Defaults to HEAD.
98
+ * @returns Content (or `null` when absent at `ref`) for every input path
99
+ */
100
+ export declare function getFilesContentAtRef(repoDir: string, filePaths: readonly string[], ref?: string): Promise<Map<string, string | null>>;
80
101
  /**
81
102
  * Checks if a file is binary by looking for NUL bytes in the first 8KB.
82
103
  * Uses the same heuristic as git.
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { GitError } from '../errors/git.js';
5
5
  import { removeFile } from '../utils/fs.js';
6
6
  import { exec } from '../utils/process.js';
7
+ import { execWithInput } from '../utils/process-input.js';
7
8
  import { chunkPathspecs, ensureGit, git } from './git-base.js';
8
9
  /**
9
10
  * Restores a tracked path from HEAD, including staged changes.
@@ -175,6 +176,80 @@ export async function getFileContentAtRef(repoDir, filePath, ref = 'HEAD') {
175
176
  }
176
177
  return result.stdout;
177
178
  }
179
+ /**
180
+ * Batched equivalent of {@link getFileContentAtRef}: reads every path at
181
+ * `ref` through one `git cat-file --batch` process instead of one
182
+ * `git show` per file. A Firefox-sized ownership scan paid 1,493 of those,
183
+ * about 18 ms each and almost all of it spawn overhead.
184
+ *
185
+ * The first request line is `ref` itself, so an unresolvable ref throws a
186
+ * {@link GitError} exactly as the per-file helper does, rather than being
187
+ * read as "every path missing". A path reported `missing` maps to `null`,
188
+ * as the per-file helper returns for a path absent at that ref. Anything
189
+ * the batch cannot answer as a plain blob (a path containing a newline,
190
+ * which the line protocol cannot carry, or an object that is not a blob)
191
+ * is fetched through {@link getFileContentAtRef}, so the result for every
192
+ * path is what that helper returns.
193
+ *
194
+ * @param repoDir - Repository directory
195
+ * @param filePaths - Paths relative to the repository root
196
+ * @param ref - Git ref to read from. Defaults to HEAD.
197
+ * @returns Content (or `null` when absent at `ref`) for every input path
198
+ */
199
+ export async function getFilesContentAtRef(repoDir, filePaths, ref = 'HEAD') {
200
+ const contents = new Map();
201
+ const unique = [...new Set(filePaths)];
202
+ const batchable = unique.filter((path) => !path.includes('\n'));
203
+ const fallback = unique.filter((path) => path.includes('\n'));
204
+ if (batchable.length > 0) {
205
+ await ensureGit();
206
+ const requests = [ref, ...batchable.map((path) => `${ref}:${path}`)];
207
+ const result = await execWithInput('git', ['cat-file', '--batch'], requests.join('\n') + '\n', {
208
+ cwd: repoDir,
209
+ });
210
+ if (result.exitCode !== 0) {
211
+ throw new GitError(result.stderr.trim() || 'git cat-file --batch failed', 'cat-file --batch');
212
+ }
213
+ const out = result.stdout;
214
+ let offset = 0;
215
+ for (const [index, request] of requests.entries()) {
216
+ const newline = out.indexOf(0x0a, offset);
217
+ if (newline === -1) {
218
+ throw new GitError(`Truncated cat-file --batch output at ${request}`, 'cat-file --batch');
219
+ }
220
+ const header = out.toString('utf8', offset, newline);
221
+ offset = newline + 1;
222
+ const path = index === 0 ? undefined : batchable[index - 1];
223
+ if (header.endsWith(' missing') || header.endsWith(' ambiguous')) {
224
+ if (path === undefined) {
225
+ throw new GitError(`Cannot resolve ${ref}: ${header}`, `cat-file --batch ${ref}`);
226
+ }
227
+ if (header.endsWith(' missing'))
228
+ contents.set(path, null);
229
+ else
230
+ fallback.push(path);
231
+ continue;
232
+ }
233
+ const match = /^[0-9a-f]+ (\S+) (\d+)$/.exec(header);
234
+ if (!match) {
235
+ throw new GitError(`Unexpected cat-file --batch header: ${header}`, 'cat-file --batch');
236
+ }
237
+ const size = Number(match[2]);
238
+ const body = out.subarray(offset, offset + size);
239
+ offset += size + 1;
240
+ if (path === undefined)
241
+ continue;
242
+ if (match[1] === 'blob')
243
+ contents.set(path, body.toString('utf8'));
244
+ else
245
+ fallback.push(path);
246
+ }
247
+ }
248
+ for (const path of fallback) {
249
+ contents.set(path, await getFileContentAtRef(repoDir, path, ref));
250
+ }
251
+ return contents;
252
+ }
178
253
  /**
179
254
  * Checks if a file is binary by looking for NUL bytes in the first 8KB.
180
255
  * Uses the same heuristic as git.
@@ -39,6 +39,20 @@ export declare function resolveMaxUntrackedFilesPerDir(): number;
39
39
  * files
40
40
  */
41
41
  export declare function expandUntrackedDirectoryEntries(repoDir: string, entries: GitStatusEntry[]): Promise<GitStatusEntry[]>;
42
+ /**
43
+ * Lists the untracked files under each collapsed `dir/` status entry with
44
+ * one `git ls-files` per ARG_MAX chunk, instead of one spawn per directory
45
+ * (a Firefox-sized worktree scan paid 73 of them). The result maps every
46
+ * requested directory to its files in git's order, which is exactly what
47
+ * {@link getUntrackedFilesInDir} returns for that directory alone. `-z`
48
+ * keeps non-ASCII names literal: the newline form C-quotes them under
49
+ * git's default `core.quotePath`, and a quoted path names no file.
50
+ *
51
+ * @param repoDir - Repository directory
52
+ * @param dirs - Collapsed untracked directories, each ending in `/`
53
+ * @returns Files per directory (every input key present, possibly empty)
54
+ */
55
+ export declare function listUntrackedFilesInDirs(repoDir: string, dirs: readonly string[]): Promise<Map<string, string[]>>;
42
56
  /**
43
57
  * Gets all untracked files (including files inside untracked directories).
44
58
  * @param repoDir - Repository directory
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { warn } from '../utils/logger.js';
3
- import { git } from './git-base.js';
3
+ import { chunkPathspecs, git } from './git-base.js';
4
4
  /**
5
5
  * True when a porcelain status column carries a rename or copy, i.e. the
6
6
  * record is followed by a second NUL-terminated field holding the original
@@ -95,12 +95,16 @@ export function resolveMaxUntrackedFilesPerDir() {
95
95
  export async function expandUntrackedDirectoryEntries(repoDir, entries) {
96
96
  const expanded = [];
97
97
  const maxPerDir = resolveMaxUntrackedFilesPerDir();
98
+ const collapsed = entries
99
+ .filter((entry) => entry.isUntracked && entry.file.endsWith('/'))
100
+ .map((entry) => entry.file);
101
+ const untrackedByDir = await listUntrackedFilesInDirs(repoDir, collapsed);
98
102
  for (const entry of entries) {
99
103
  if (!entry.isUntracked || !entry.file.endsWith('/')) {
100
104
  expanded.push(entry);
101
105
  continue;
102
106
  }
103
- const individualFiles = await getUntrackedFilesInDir(repoDir, entry.file);
107
+ const individualFiles = untrackedByDir.get(entry.file) ?? [];
104
108
  if (individualFiles.length > maxPerDir) {
105
109
  warn(`Untracked directory ${entry.file} contains ${individualFiles.length} files — only the first ${maxPerDir} are listed. Add a .gitignore entry or clean the directory.`);
106
110
  }
@@ -118,6 +122,41 @@ export async function expandUntrackedDirectoryEntries(repoDir, entries) {
118
122
  }
119
123
  return expanded;
120
124
  }
125
+ /**
126
+ * Lists the untracked files under each collapsed `dir/` status entry with
127
+ * one `git ls-files` per ARG_MAX chunk, instead of one spawn per directory
128
+ * (a Firefox-sized worktree scan paid 73 of them). The result maps every
129
+ * requested directory to its files in git's order, which is exactly what
130
+ * {@link getUntrackedFilesInDir} returns for that directory alone. `-z`
131
+ * keeps non-ASCII names literal: the newline form C-quotes them under
132
+ * git's default `core.quotePath`, and a quoted path names no file.
133
+ *
134
+ * @param repoDir - Repository directory
135
+ * @param dirs - Collapsed untracked directories, each ending in `/`
136
+ * @returns Files per directory (every input key present, possibly empty)
137
+ */
138
+ export async function listUntrackedFilesInDirs(repoDir, dirs) {
139
+ const byDir = new Map(dirs.map((dir) => [dir, []]));
140
+ if (byDir.size === 0)
141
+ return byDir;
142
+ for (const chunk of chunkPathspecs([...byDir.keys()])) {
143
+ const output = await git(['ls-files', '--others', '--exclude-standard', '-z', '--', ...chunk], repoDir);
144
+ for (const file of output.split('\0')) {
145
+ if (file.length === 0)
146
+ continue;
147
+ // Collapsed status directories never nest, so the first matching
148
+ // ancestor is the only one.
149
+ for (let slash = file.indexOf('/'); slash !== -1; slash = file.indexOf('/', slash + 1)) {
150
+ const bucket = byDir.get(file.slice(0, slash + 1));
151
+ if (bucket !== undefined) {
152
+ bucket.push(file);
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ }
158
+ return byDir;
159
+ }
121
160
  /**
122
161
  * Gets all untracked files (including files inside untracked directories).
123
162
  * @param repoDir - Repository directory
@@ -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>;