@hominis/fireforge 0.10.1 → 0.11.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.
Files changed (174) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +125 -238
  3. package/dist/bin/fireforge.js +26 -0
  4. package/dist/src/cli.d.ts +1 -1
  5. package/dist/src/cli.js +131 -52
  6. package/dist/src/commands/bootstrap.js +6 -2
  7. package/dist/src/commands/build.js +4 -2
  8. package/dist/src/commands/discard.js +16 -4
  9. package/dist/src/commands/doctor-furnace.d.ts +8 -0
  10. package/dist/src/commands/doctor-furnace.js +422 -0
  11. package/dist/src/commands/doctor.d.ts +115 -0
  12. package/dist/src/commands/doctor.js +327 -258
  13. package/dist/src/commands/download.js +16 -1
  14. package/dist/src/commands/export-all.js +15 -0
  15. package/dist/src/commands/export-flow.d.ts +91 -0
  16. package/dist/src/commands/export-flow.js +344 -0
  17. package/dist/src/commands/export.js +151 -5
  18. package/dist/src/commands/furnace/apply.d.ts +3 -2
  19. package/dist/src/commands/furnace/apply.js +169 -36
  20. package/dist/src/commands/furnace/create.js +162 -52
  21. package/dist/src/commands/furnace/deploy.js +156 -144
  22. package/dist/src/commands/furnace/diff.d.ts +8 -4
  23. package/dist/src/commands/furnace/diff.js +142 -73
  24. package/dist/src/commands/furnace/index.d.ts +6 -2
  25. package/dist/src/commands/furnace/index.js +76 -25
  26. package/dist/src/commands/furnace/init.d.ts +11 -0
  27. package/dist/src/commands/furnace/init.js +76 -0
  28. package/dist/src/commands/furnace/list.d.ts +4 -1
  29. package/dist/src/commands/furnace/list.js +35 -3
  30. package/dist/src/commands/furnace/override.d.ts +8 -0
  31. package/dist/src/commands/furnace/override.js +216 -26
  32. package/dist/src/commands/furnace/preview.js +184 -30
  33. package/dist/src/commands/furnace/refresh.d.ts +10 -0
  34. package/dist/src/commands/furnace/refresh.js +268 -0
  35. package/dist/src/commands/furnace/remove.js +285 -89
  36. package/dist/src/commands/furnace/rename.d.ts +5 -0
  37. package/dist/src/commands/furnace/rename.js +308 -0
  38. package/dist/src/commands/furnace/scan.d.ts +4 -1
  39. package/dist/src/commands/furnace/scan.js +72 -11
  40. package/dist/src/commands/furnace/status.js +85 -20
  41. package/dist/src/commands/furnace/sync.d.ts +12 -0
  42. package/dist/src/commands/furnace/sync.js +77 -0
  43. package/dist/src/commands/furnace/validate.d.ts +4 -1
  44. package/dist/src/commands/furnace/validate.js +99 -3
  45. package/dist/src/commands/furnace/validation-output.d.ts +24 -1
  46. package/dist/src/commands/furnace/validation-output.js +93 -1
  47. package/dist/src/commands/import.js +37 -4
  48. package/dist/src/commands/lint.js +11 -2
  49. package/dist/src/commands/manifest.d.ts +39 -0
  50. package/dist/src/commands/manifest.js +59 -0
  51. package/dist/src/commands/patch/delete.d.ts +28 -0
  52. package/dist/src/commands/patch/delete.js +209 -0
  53. package/dist/src/commands/patch/index.d.ts +17 -0
  54. package/dist/src/commands/patch/index.js +25 -0
  55. package/dist/src/commands/patch/reorder.d.ts +30 -0
  56. package/dist/src/commands/patch/reorder.js +377 -0
  57. package/dist/src/commands/re-export-files.d.ts +17 -0
  58. package/dist/src/commands/re-export-files.js +177 -0
  59. package/dist/src/commands/re-export.js +44 -0
  60. package/dist/src/commands/rebase/abort.d.ts +1 -1
  61. package/dist/src/commands/rebase/abort.js +12 -3
  62. package/dist/src/commands/rebase/confirm.d.ts +3 -3
  63. package/dist/src/commands/rebase/confirm.js +4 -4
  64. package/dist/src/commands/rebase/index.js +13 -4
  65. package/dist/src/commands/reset.js +20 -4
  66. package/dist/src/commands/run.js +46 -1
  67. package/dist/src/commands/setup-support.js +6 -5
  68. package/dist/src/commands/status.js +97 -6
  69. package/dist/src/commands/test.js +5 -37
  70. package/dist/src/commands/verify.d.ts +31 -0
  71. package/dist/src/commands/verify.js +126 -0
  72. package/dist/src/core/build-prepare.js +40 -16
  73. package/dist/src/core/destructive.d.ts +96 -0
  74. package/dist/src/core/destructive.js +137 -0
  75. package/dist/src/core/diff-hunks.d.ts +73 -0
  76. package/dist/src/core/diff-hunks.js +268 -0
  77. package/dist/src/core/firefox.d.ts +1 -1
  78. package/dist/src/core/firefox.js +1 -1
  79. package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
  80. package/dist/src/core/furnace-apply-helpers.js +302 -57
  81. package/dist/src/core/furnace-apply-output.d.ts +16 -0
  82. package/dist/src/core/furnace-apply-output.js +57 -0
  83. package/dist/src/core/furnace-apply.d.ts +21 -3
  84. package/dist/src/core/furnace-apply.js +260 -29
  85. package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
  86. package/dist/src/core/furnace-checksum-utils.js +24 -0
  87. package/dist/src/core/furnace-config.d.ts +28 -1
  88. package/dist/src/core/furnace-config.js +180 -17
  89. package/dist/src/core/furnace-constants.d.ts +22 -0
  90. package/dist/src/core/furnace-constants.js +36 -0
  91. package/dist/src/core/furnace-graph-utils.d.ts +11 -0
  92. package/dist/src/core/furnace-graph-utils.js +94 -0
  93. package/dist/src/core/furnace-operation.d.ts +108 -0
  94. package/dist/src/core/furnace-operation.js +220 -0
  95. package/dist/src/core/furnace-refresh.d.ts +20 -0
  96. package/dist/src/core/furnace-refresh.js +118 -0
  97. package/dist/src/core/furnace-registration-ast.d.ts +5 -0
  98. package/dist/src/core/furnace-registration-ast.js +134 -4
  99. package/dist/src/core/furnace-registration-remove.d.ts +25 -3
  100. package/dist/src/core/furnace-registration-remove.js +196 -62
  101. package/dist/src/core/furnace-registration-validate.d.ts +13 -1
  102. package/dist/src/core/furnace-registration-validate.js +15 -3
  103. package/dist/src/core/furnace-registration.d.ts +27 -4
  104. package/dist/src/core/furnace-registration.js +93 -11
  105. package/dist/src/core/furnace-rollback.d.ts +11 -0
  106. package/dist/src/core/furnace-rollback.js +78 -7
  107. package/dist/src/core/furnace-scanner.d.ts +8 -2
  108. package/dist/src/core/furnace-scanner.js +152 -55
  109. package/dist/src/core/furnace-stories.js +7 -5
  110. package/dist/src/core/furnace-validate-accessibility.js +7 -1
  111. package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
  112. package/dist/src/core/furnace-validate-compatibility.js +85 -1
  113. package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
  114. package/dist/src/core/furnace-validate-helpers.js +31 -0
  115. package/dist/src/core/furnace-validate-registration.d.ts +17 -2
  116. package/dist/src/core/furnace-validate-registration.js +73 -3
  117. package/dist/src/core/furnace-validate-structure.d.ts +10 -2
  118. package/dist/src/core/furnace-validate-structure.js +45 -3
  119. package/dist/src/core/furnace-validate.d.ts +10 -1
  120. package/dist/src/core/furnace-validate.js +80 -6
  121. package/dist/src/core/furnace-version-drift.d.ts +55 -0
  122. package/dist/src/core/furnace-version-drift.js +101 -0
  123. package/dist/src/core/git-file-ops.d.ts +8 -0
  124. package/dist/src/core/git-file-ops.js +19 -6
  125. package/dist/src/core/lint-projection.d.ts +25 -0
  126. package/dist/src/core/lint-projection.js +44 -0
  127. package/dist/src/core/mach.d.ts +4 -2
  128. package/dist/src/core/mach.js +17 -2
  129. package/dist/src/core/markdown-table.d.ts +104 -0
  130. package/dist/src/core/markdown-table.js +266 -0
  131. package/dist/src/core/ownership-table.d.ts +53 -0
  132. package/dist/src/core/ownership-table.js +144 -0
  133. package/dist/src/core/patch-apply.d.ts +17 -3
  134. package/dist/src/core/patch-apply.js +86 -8
  135. package/dist/src/core/patch-export.d.ts +119 -5
  136. package/dist/src/core/patch-export.js +183 -25
  137. package/dist/src/core/patch-lint-cross.d.ts +195 -0
  138. package/dist/src/core/patch-lint-cross.js +428 -0
  139. package/dist/src/core/patch-lint-diff.d.ts +33 -0
  140. package/dist/src/core/patch-lint-diff.js +84 -0
  141. package/dist/src/core/patch-lint.d.ts +2 -4
  142. package/dist/src/core/patch-lint.js +12 -50
  143. package/dist/src/core/patch-lock.js +2 -1
  144. package/dist/src/core/patch-manifest-io.d.ts +102 -1
  145. package/dist/src/core/patch-manifest-io.js +270 -2
  146. package/dist/src/core/patch-manifest-query.d.ts +1 -1
  147. package/dist/src/core/patch-manifest-query.js +1 -1
  148. package/dist/src/core/patch-manifest.d.ts +1 -1
  149. package/dist/src/core/patch-manifest.js +1 -1
  150. package/dist/src/core/patch-transform.d.ts +12 -0
  151. package/dist/src/core/patch-transform.js +21 -7
  152. package/dist/src/core/token-manager.js +67 -69
  153. package/dist/src/core/wire-destroy.js +6 -3
  154. package/dist/src/core/wire-init.js +10 -4
  155. package/dist/src/core/wire-subscript.js +9 -3
  156. package/dist/src/core/wire-utils.d.ts +52 -5
  157. package/dist/src/core/wire-utils.js +69 -6
  158. package/dist/src/errors/base.d.ts +20 -0
  159. package/dist/src/errors/base.js +24 -0
  160. package/dist/src/errors/furnace.js +7 -1
  161. package/dist/src/errors/rebase.js +6 -1
  162. package/dist/src/types/commands/index.d.ts +1 -1
  163. package/dist/src/types/commands/options.d.ts +125 -4
  164. package/dist/src/types/commands/patches.d.ts +11 -1
  165. package/dist/src/types/config.d.ts +1 -1
  166. package/dist/src/types/furnace.d.ts +55 -1
  167. package/dist/src/utils/fs.d.ts +12 -0
  168. package/dist/src/utils/fs.js +30 -1
  169. package/dist/src/utils/package-root.d.ts +5 -0
  170. package/dist/src/utils/package-root.js +12 -0
  171. package/dist/src/utils/process.js +9 -4
  172. package/dist/src/utils/validation.d.ts +20 -2
  173. package/dist/src/utils/validation.js +26 -3
  174. package/package.json +1 -1
@@ -0,0 +1,220 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { join } from 'node:path';
3
+ import { FurnaceError } from '../errors/furnace.js';
4
+ import { toError } from '../utils/errors.js';
5
+ import { warn } from '../utils/logger.js';
6
+ import { FIREFORGE_DIR } from './config-paths.js';
7
+ import { withFileLock } from './file-lock.js';
8
+ import { loadFurnaceState, updateFurnaceState } from './furnace-config.js';
9
+ import { restoreRollbackJournal } from './furnace-rollback.js';
10
+ /** Sidecar lock filename used to serialize concurrent furnace mutations. */
11
+ const FURNACE_LOCK_FILENAME = 'furnace.lock';
12
+ const activeOperations = new Map();
13
+ let nextOperationToken = 1;
14
+ let signalRollbackInFlight = false;
15
+ /**
16
+ * Returns true while a signal-driven rollback is in progress. The bin entry
17
+ * point uses this as a re-entrancy guard so a user mashing Ctrl+C cannot
18
+ * trigger a second rollback that races the first. Exposed for the bin shim
19
+ * (and the test suite); production callers should not need it.
20
+ */
21
+ export function isSignalRollbackInFlight() {
22
+ return signalRollbackInFlight;
23
+ }
24
+ /**
25
+ * Rolls back every in-flight furnace operation and writes a pendingRepair
26
+ * marker for each. The bin entry point installs SIGINT/SIGTERM handlers that
27
+ * call this and then exit; calling it directly from inside the library would
28
+ * violate the "process.exit only in bin" invariant. The function is also
29
+ * exposed under this name so the test suite can exercise the teardown path
30
+ * without going through `process.emit` / `process.exit`.
31
+ */
32
+ /** Maximum time (ms) the signal-driven rollback may take per operation. */
33
+ const SIGNAL_ROLLBACK_TIMEOUT_MS = 15_000;
34
+ /** Races a promise against a deadline, rejecting with a timeout error if the deadline expires. */
35
+ function withTimeout(promise, ms, label) {
36
+ return new Promise((resolve, reject) => {
37
+ const timer = setTimeout(() => {
38
+ reject(new Error(`${label} timed out after ${ms}ms`));
39
+ }, ms);
40
+ promise.then((value) => {
41
+ clearTimeout(timer);
42
+ resolve(value);
43
+ }, (error) => {
44
+ clearTimeout(timer);
45
+ reject(error instanceof Error ? error : new Error(String(error)));
46
+ });
47
+ });
48
+ }
49
+ /**
50
+ * Rolls back every in-flight furnace operation and writes a pendingRepair
51
+ * marker for each. Each cleanup callback and journal restore is bounded by a
52
+ * timeout so a stuck I/O operation cannot hang the process indefinitely.
53
+ */
54
+ export async function rollbackActiveOperationsForSignal(signal) {
55
+ signalRollbackInFlight = true;
56
+ warn(`Received ${signal}; rolling back in-flight furnace mutations…`);
57
+ // Snapshot the active operations so we don't race with `runFurnaceMutation`
58
+ // clearing slots during normal completion.
59
+ const snapshot = [...activeOperations.values()];
60
+ for (const op of snapshot) {
61
+ // If the body completed successfully and is in its finally-block cleanup
62
+ // (deleting the token), skip rollback — the mutation committed cleanly.
63
+ if (op.completed)
64
+ continue;
65
+ const cleanupErrors = [];
66
+ // Run extra cleanup callbacks first (e.g. preview's cleanStories), so the
67
+ // engine is in its tidiest possible shape before the journal restore
68
+ // writes the original file contents back over the top.
69
+ for (const cleanup of op.cleanups) {
70
+ try {
71
+ await withTimeout(cleanup(), SIGNAL_ROLLBACK_TIMEOUT_MS, 'Cleanup callback');
72
+ }
73
+ catch (error) {
74
+ cleanupErrors.push(toError(error).message);
75
+ }
76
+ }
77
+ if (!op.journal) {
78
+ // The body had not yet handed us a journal — nothing to roll back. We
79
+ // still write a marker because the body may have started mutating the
80
+ // engine before reaching the registerJournal call.
81
+ const cleanupSuffix = cleanupErrors.length > 0 ? `; cleanup errors: ${cleanupErrors.join('; ')}` : '';
82
+ await persistPendingRepair(op.root, op.kind, `interrupted by ${signal} before any state was captured${cleanupSuffix}`).catch((error) => {
83
+ warn(`Could not persist pending-repair marker: ${toError(error).message}`);
84
+ });
85
+ continue;
86
+ }
87
+ let rollbackError;
88
+ try {
89
+ await withTimeout(restoreRollbackJournal(op.journal), SIGNAL_ROLLBACK_TIMEOUT_MS, 'Rollback journal restore');
90
+ }
91
+ catch (error) {
92
+ rollbackError = toError(error).message;
93
+ }
94
+ // A clean signal-driven rollback is not itself a repairable problem:
95
+ // preview/apply/deploy/remove were interrupted, but the engine was restored
96
+ // successfully and the next `doctor` run should remain green. Persist a
97
+ // pending-repair marker only when rollback was incomplete or uncertain.
98
+ if (rollbackError || cleanupErrors.length > 0) {
99
+ const reasonParts = [`interrupted by ${signal}`];
100
+ if (rollbackError) {
101
+ reasonParts.push(`automatic rollback failed: ${rollbackError}`);
102
+ }
103
+ else {
104
+ reasonParts.push('automatic rollback succeeded');
105
+ }
106
+ if (cleanupErrors.length > 0) {
107
+ reasonParts.push(`cleanup errors: ${cleanupErrors.join('; ')}`);
108
+ }
109
+ await persistPendingRepair(op.root, op.kind, reasonParts.join('; ')).catch((error) => {
110
+ warn(`Could not persist pending-repair marker: ${toError(error).message}`);
111
+ });
112
+ }
113
+ }
114
+ }
115
+ async function persistPendingRepair(root, operation, reason) {
116
+ await updateFurnaceState(root, (state) => ({
117
+ ...state,
118
+ pendingRepair: {
119
+ operation,
120
+ timestamp: new Date().toISOString(),
121
+ reason,
122
+ },
123
+ }));
124
+ }
125
+ /**
126
+ * Resolves the path of the lock directory used to serialize furnace mutations
127
+ * for a given project root. Exposed for tests; production callers should not
128
+ * touch this directly.
129
+ */
130
+ export function getFurnaceLockPath(root) {
131
+ return join(root, FIREFORGE_DIR, FURNACE_LOCK_FILENAME);
132
+ }
133
+ /**
134
+ * Runs a furnace-mutating body under the apply-wide lock and registers it
135
+ * with the process-wide SIGINT/SIGTERM rollback pathway. The lock prevents
136
+ * two `furnace apply`/`deploy`/`create`/etc. runs from racing on the engine
137
+ * working copy; the CLI entrypoint's global signal handlers consult this
138
+ * registry and invoke rollback (writing a `pendingRepair` marker when needed)
139
+ * if the user hits Ctrl+C mid-run.
140
+ *
141
+ * Dry-run callers should pass `options.dryRun = true` so the wrapper skips
142
+ * the lock entirely (concurrent dry-runs are safe and shouldn't block each
143
+ * other).
144
+ *
145
+ * The body receives a {@link FurnaceOperationContext}; it must call
146
+ * `ctx.registerJournal(journal)` once it has constructed its rollback journal.
147
+ * Bodies that don't manage a journal directly (e.g. apply, which delegates to
148
+ * `applyAllComponents`) can pass an internal callback through.
149
+ */
150
+ export async function runFurnaceMutation(root, kind, body, options = {}) {
151
+ if (options.dryRun) {
152
+ // Dry-run: no lock, no signal handler, no journal registration. The body
153
+ // is still given a no-op context so callers can use the same shape.
154
+ return body({
155
+ registerJournal: () => undefined,
156
+ registerCleanup: () => undefined,
157
+ });
158
+ }
159
+ // Pre-flight: refuse to mutate when a previous operation left the engine in
160
+ // a partially-rolled-back state. The user must run `fireforge doctor
161
+ // --repair-furnace` to reconcile before any new mutations can proceed.
162
+ if (!options.skipPendingRepairCheck) {
163
+ const state = await loadFurnaceState(root);
164
+ if (state.pendingRepair) {
165
+ throw new FurnaceError(`A previous "${state.pendingRepair.operation}" left the engine in an inconsistent state ` +
166
+ `(${state.pendingRepair.reason}). Run "fireforge doctor --repair-furnace" to reconcile ` +
167
+ 'before running further furnace mutations.');
168
+ }
169
+ }
170
+ const token = nextOperationToken++;
171
+ const operation = { root, kind, cleanups: [] };
172
+ const lockPath = getFurnaceLockPath(root);
173
+ const lockOptions = {
174
+ ...(options.lockTimeoutMs !== undefined ? { timeoutMs: options.lockTimeoutMs } : {}),
175
+ onTimeoutMessage: `Timed out waiting for the furnace lock at ${lockPath}. ` +
176
+ 'Another fireforge furnace command may be running. ' +
177
+ 'If no other process is running, remove the stale lock directory and retry.',
178
+ onStaleLockMessage: (ageMs) => `Removing stale furnace lock (age: ${Math.round(ageMs / 1000)}s). ` +
179
+ 'A previous fireforge process may have crashed.',
180
+ };
181
+ return withFileLock(lockPath, async () => {
182
+ activeOperations.set(token, operation);
183
+ try {
184
+ return await body({
185
+ registerJournal: (journal) => {
186
+ operation.journal = journal;
187
+ },
188
+ registerCleanup: (cleanup) => {
189
+ operation.cleanups.push(cleanup);
190
+ },
191
+ });
192
+ }
193
+ finally {
194
+ operation.completed = true;
195
+ activeOperations.delete(token);
196
+ }
197
+ }, lockOptions);
198
+ }
199
+ /**
200
+ * Persists an `apply-rollback` (or other operation-kind) `pendingRepair`
201
+ * marker on behalf of a caller that detected a rollback failure outside the
202
+ * signal-handler path (e.g. apply's own catch-around-restore). Exposed so
203
+ * `furnace-apply.ts` can write the marker without taking on a dependency on
204
+ * the lifecycle wrapper's internals.
205
+ */
206
+ export async function recordFurnaceRollbackFailure(root, operation, reason) {
207
+ await persistPendingRepair(root, operation, reason);
208
+ }
209
+ /**
210
+ * Test-only helper: tears down the module-scoped state. Vitest workers may
211
+ * reuse the module across tests, so the test suite must call this between
212
+ * cases that exercise the signal pathway. Not exported from the package
213
+ * entry point.
214
+ */
215
+ export function __resetFurnaceOperationStateForTests() {
216
+ activeOperations.clear();
217
+ nextOperationToken = 1;
218
+ signalRollbackInFlight = false;
219
+ }
220
+ //# sourceMappingURL=furnace-operation.js.map
@@ -0,0 +1,20 @@
1
+ export interface RefreshFileResult {
2
+ fileName: string;
3
+ status: 'merged' | 'conflict' | 'unchanged' | 'new-file';
4
+ conflictMarkers?: number;
5
+ }
6
+ export interface RefreshResult {
7
+ files: RefreshFileResult[];
8
+ newBaseVersion: string;
9
+ }
10
+ /**
11
+ * Refreshes a single override file against the current engine HEAD.
12
+ *
13
+ * @param engineDir - Path to the engine git repository
14
+ * @param overridePath - Path to the current override file in the workspace
15
+ * @param engineRelPath - Engine-relative path for git show
16
+ * @param baseCommit - The git ref at which the override was originally created
17
+ * @param fileName - Display name for the file
18
+ * @returns Merge result with the updated content written to the override file
19
+ */
20
+ export declare function refreshOverrideFile(engineDir: string, overridePath: string, engineRelPath: string, baseCommit: string, fileName: string, dryRun?: boolean, strategy?: 'ours' | 'theirs'): Promise<RefreshFileResult>;
@@ -0,0 +1,118 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Three-way merge logic for refreshing overrides against a newer Firefox baseline.
4
+ *
5
+ * When Firefox moves forward and an override's `baseVersion` drifts, the user
6
+ * needs a way to incorporate upstream changes into their override workspace
7
+ * without losing local modifications. This module uses `git merge-file` to
8
+ * perform a three-way merge between the old baseline, the current override,
9
+ * and the new upstream content.
10
+ */
11
+ import { randomUUID } from 'node:crypto';
12
+ import { unlink } from 'node:fs/promises';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { FurnaceError } from '../errors/furnace.js';
16
+ import { readText, writeText } from '../utils/fs.js';
17
+ import { exec } from '../utils/process.js';
18
+ import { ensureGit } from './git-base.js';
19
+ import { getFileContentAtRef } from './git-file-ops.js';
20
+ /**
21
+ * Performs a three-way merge on a single file.
22
+ *
23
+ * Uses `git merge-file` with:
24
+ * - base: the original Firefox content at the override's recorded baseCommit
25
+ * - ours: the current override workspace content (local modifications)
26
+ * - theirs: the current Firefox content at HEAD
27
+ *
28
+ * @returns The merged content and the number of conflict markers (0 = clean merge)
29
+ */
30
+ async function threeWayMergeFile(base, ours, theirs, label, strategy) {
31
+ await ensureGit();
32
+ // Write all three versions to temp files for git merge-file.
33
+ // Use crypto.randomUUID() for unique, unpredictable temp file names.
34
+ const id = randomUUID();
35
+ const tempBase = join(tmpdir(), `fireforge-merge-base-${id}`);
36
+ const tempOurs = join(tmpdir(), `fireforge-merge-ours-${id}`);
37
+ const tempTheirs = join(tmpdir(), `fireforge-merge-theirs-${id}`);
38
+ try {
39
+ await writeText(tempBase, base);
40
+ await writeText(tempOurs, ours);
41
+ await writeText(tempTheirs, theirs);
42
+ // git merge-file writes the result to the first file (ours) in-place.
43
+ // Exit code 0 = clean merge, >0 = number of conflicts, <0 = error.
44
+ const mergeArgs = [
45
+ 'merge-file',
46
+ ...(strategy ? [`--${strategy}`] : []),
47
+ '-L',
48
+ label.ours,
49
+ '-L',
50
+ label.base,
51
+ '-L',
52
+ label.theirs,
53
+ tempOurs,
54
+ tempBase,
55
+ tempTheirs,
56
+ ];
57
+ const result = await exec('git', mergeArgs);
58
+ const merged = await readText(tempOurs);
59
+ const conflicts = result.exitCode > 0 ? result.exitCode : 0;
60
+ if (result.exitCode < 0) {
61
+ throw new FurnaceError(`git merge-file failed: ${result.stderr}`);
62
+ }
63
+ return { merged, conflicts };
64
+ }
65
+ finally {
66
+ // Clean up temp files (best-effort)
67
+ await Promise.allSettled([unlink(tempBase), unlink(tempOurs), unlink(tempTheirs)]);
68
+ }
69
+ }
70
+ /**
71
+ * Refreshes a single override file against the current engine HEAD.
72
+ *
73
+ * @param engineDir - Path to the engine git repository
74
+ * @param overridePath - Path to the current override file in the workspace
75
+ * @param engineRelPath - Engine-relative path for git show
76
+ * @param baseCommit - The git ref at which the override was originally created
77
+ * @param fileName - Display name for the file
78
+ * @returns Merge result with the updated content written to the override file
79
+ */
80
+ export async function refreshOverrideFile(engineDir, overridePath, engineRelPath, baseCommit, fileName, dryRun, strategy) {
81
+ // Read the three versions
82
+ const oursContent = await readText(overridePath);
83
+ const baseContent = await getFileContentAtRef(engineDir, engineRelPath, baseCommit);
84
+ if (baseContent === null) {
85
+ // File didn't exist at baseCommit — this is a new file introduced by the override
86
+ return { fileName, status: 'new-file' };
87
+ }
88
+ const theirsContent = await getFileContentAtRef(engineDir, engineRelPath, 'HEAD');
89
+ if (theirsContent === null) {
90
+ // File was removed upstream — no merge needed, keep the override as-is
91
+ return { fileName, status: 'unchanged' };
92
+ }
93
+ // If upstream hasn't changed, nothing to merge
94
+ if (baseContent === theirsContent) {
95
+ return { fileName, status: 'unchanged' };
96
+ }
97
+ // If our override matches the base (no local changes), just take theirs
98
+ if (oursContent === baseContent) {
99
+ if (!dryRun) {
100
+ await writeText(overridePath, theirsContent);
101
+ }
102
+ return { fileName, status: 'merged' };
103
+ }
104
+ // Three-way merge
105
+ const { merged, conflicts } = await threeWayMergeFile(baseContent, oursContent, theirsContent, {
106
+ ours: `components/overrides/${fileName} (your changes)`,
107
+ base: `Firefox ${baseCommit.slice(0, 8)} (original)`,
108
+ theirs: `Firefox HEAD (upstream)`,
109
+ }, strategy);
110
+ if (!dryRun) {
111
+ await writeText(overridePath, merged);
112
+ }
113
+ if (conflicts > 0) {
114
+ return { fileName, status: 'conflict', conflictMarkers: conflicts };
115
+ }
116
+ return { fileName, status: 'merged' };
117
+ }
118
+ //# sourceMappingURL=furnace-refresh.js.map
@@ -22,3 +22,8 @@ export { CUSTOM_ELEMENTS_JS, JAR_MN } from './furnace-constants.js';
22
22
  * @param modulePath - chrome:// URI for the module
23
23
  */
24
24
  export declare function addCustomElementRegistration(engineDir: string, tagName: string, modulePath: string): Promise<void>;
25
+ /**
26
+ * Validates that a custom element registration *would* succeed without
27
+ * writing anything. Used by dry-run to surface registration errors early.
28
+ */
29
+ export declare function validateCustomElementRegistration(engineDir: string, tagName: string, modulePath: string): Promise<void>;
@@ -8,6 +8,7 @@ import MagicString from 'magic-string';
8
8
  import { FurnaceError } from '../errors/furnace.js';
9
9
  import { toError } from '../utils/errors.js';
10
10
  import { pathExists, readText, writeText } from '../utils/fs.js';
11
+ import { verbose, warn } from '../utils/logger.js';
11
12
  import { detectIndent, getNodeSource, parseScript, walkAST, } from './ast-utils.js';
12
13
  import { CUSTOM_ELEMENTS_JS } from './furnace-constants.js';
13
14
  import { validateRegistrationPlacement, validateTagName } from './furnace-registration-validate.js';
@@ -171,6 +172,60 @@ function addRegistrationAST(content, tagName, modulePath, isESModule) {
171
172
  }
172
173
  return ms.toString();
173
174
  }
175
+ /**
176
+ * Regex-based fallback for inserting a registration entry when the AST parser
177
+ * fails. Finds the last existing `["tag", "path"],` line in the appropriate
178
+ * block and inserts the new entry after it in alphabetical order.
179
+ *
180
+ * This is intentionally less precise than the AST approach — it does not
181
+ * validate indentation or multi-line format — but it is robust against
182
+ * upstream syntax changes that break the parser.
183
+ */
184
+ function addRegistrationRegexFallback(content, tagName, modulePath, isESModule) {
185
+ // Find all registration entries: ["tag", "path"],
186
+ const entryPattern = /^(\s*)\["([^"]+)",\s*"[^"]+"\],?\s*$/gm;
187
+ let lastMatch = null;
188
+ let insertAfterMatch = null;
189
+ const allMatches = [];
190
+ let match;
191
+ // For ESM modules, only consider entries inside a DOMContentLoaded block.
192
+ // For non-ESM, consider entries outside DOMContentLoaded.
193
+ const dclStart = content.search(/document\.addEventListener\s*\(\s*["']DOMContentLoaded["']/);
194
+ const dclBlockStart = dclStart >= 0 ? content.indexOf('{', dclStart) : -1;
195
+ while ((match = entryPattern.exec(content)) !== null) {
196
+ const isInDCL = dclBlockStart >= 0 && match.index > dclBlockStart;
197
+ if (isESModule ? isInDCL : !isInDCL) {
198
+ allMatches.push(match);
199
+ }
200
+ }
201
+ // Find alphabetical insertion point
202
+ for (const m of allMatches) {
203
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- capture group [2] always present when regex matches
204
+ const existingTag = m[2];
205
+ if (existingTag < tagName) {
206
+ insertAfterMatch = m;
207
+ }
208
+ lastMatch = m;
209
+ }
210
+ // If we found no entries in the target block, give up
211
+ if (!lastMatch) {
212
+ throw new FurnaceError(`Regex fallback could not find any registration entries in the ${isESModule ? 'DOMContentLoaded' : 'non-DOMContentLoaded'} block of ${CUSTOM_ELEMENTS_JS}.`, tagName);
213
+ }
214
+ const indent = lastMatch[1] ?? ' ';
215
+ const newEntry = `${indent}["${tagName}", "${modulePath}"],`;
216
+ if (insertAfterMatch) {
217
+ // Insert after the last entry that sorts before tagName
218
+ const insertPos = insertAfterMatch.index + insertAfterMatch[0].length;
219
+ return content.slice(0, insertPos) + '\n' + newEntry + content.slice(insertPos);
220
+ }
221
+ // Insert before the first entry (tagName sorts before all existing)
222
+ const firstMatch = allMatches[0];
223
+ if (!firstMatch) {
224
+ throw new FurnaceError(`Regex fallback found no entries in the target block of ${CUSTOM_ELEMENTS_JS}.`, tagName);
225
+ }
226
+ const insertPos = firstMatch.index;
227
+ return content.slice(0, insertPos) + newEntry + '\n' + content.slice(insertPos);
228
+ }
174
229
  /**
175
230
  * Adds a custom element registration entry to customElements.js.
176
231
  *
@@ -195,24 +250,99 @@ export async function addCustomElementRegistration(engineDir, tagName, modulePat
195
250
  }
196
251
  const content = await readText(filePath);
197
252
  // Idempotency: already registered (standalone block or array entry).
253
+ // Check both double-quote and single-quote variants — upstream Firefox
254
+ // sources may use either style.
198
255
  if (content.includes(`setElementCreationCallback("${tagName}"`) ||
256
+ content.includes(`setElementCreationCallback('${tagName}'`) ||
199
257
  content.includes(`["${tagName}",`) ||
200
- new RegExp(`^\\s*"${tagName}",\\s*$`, 'm').test(content)) {
258
+ content.includes(`['${tagName}',`) ||
259
+ new RegExp(`^\\s*["']${tagName}["'],\\s*$`, 'm').test(content)) {
201
260
  return;
202
261
  }
262
+ // Validate upfront — tag name errors must not fall through to the regex fallback.
263
+ validateTagName(tagName);
203
264
  const isESModule = modulePath.endsWith('.mjs');
265
+ // Cheap pre-flight: the AST walker assumes the file contains at least one
266
+ // destructuring `for (... of [...])` loop with an array literal on the
267
+ // right-hand side, and (for ESM tags) at least one such loop inside a
268
+ // `document.addEventListener("DOMContentLoaded", ...)` block. If either
269
+ // assumption is violated the AST path errors with a confusing
270
+ // "Could not find DOMContentLoaded block" message — fail fast here with
271
+ // actionable guidance instead.
272
+ if (!/for\s*\(\s*(?:let|const|var)\s*\[/.test(content)) {
273
+ throw new FurnaceError(`${CUSTOM_ELEMENTS_JS} does not contain a recognizable registration loop; refusing to mutate. ` +
274
+ 'Run "fireforge reset --force" to restore the engine, or inspect the file manually.', tagName);
275
+ }
276
+ if (isESModule && !/document\.addEventListener\s*\(\s*["']DOMContentLoaded["']/.test(content)) {
277
+ throw new FurnaceError(`${CUSTOM_ELEMENTS_JS} has no DOMContentLoaded block; cannot register ESM element ${tagName}. ` +
278
+ 'The file may be corrupt — run "fireforge reset --force" to restore.', tagName);
279
+ }
204
280
  let nextContent;
205
281
  try {
206
282
  nextContent = addRegistrationAST(content, tagName, modulePath, isESModule);
207
283
  }
208
284
  catch (error) {
209
285
  if (error instanceof FurnaceError) {
210
- throw error;
286
+ // AST structural errors (missing DOMContentLoaded block, etc.) — try regex fallback
287
+ warn(`AST-based registration failed for ${tagName}: ${error.message}. ` +
288
+ 'Falling back to regex-based insertion. Please report this so the AST parser can be updated.');
289
+ try {
290
+ nextContent = addRegistrationRegexFallback(content, tagName, modulePath, isESModule);
291
+ verbose(`Regex fallback succeeded for ${tagName}. The registration may be less precise than the AST approach.`);
292
+ }
293
+ catch {
294
+ // If regex fallback also fails, throw the original AST error
295
+ throw error;
296
+ }
297
+ }
298
+ else {
299
+ const parserError = toError(error);
300
+ warn(`AST parser threw an unexpected error for ${tagName}: ${parserError.message}. ` +
301
+ 'Falling back to regex-based insertion.');
302
+ try {
303
+ nextContent = addRegistrationRegexFallback(content, tagName, modulePath, isESModule);
304
+ verbose(`Regex fallback succeeded for ${tagName}. The registration may be less precise than the AST approach.`);
305
+ }
306
+ catch {
307
+ throw new FurnaceError(`Failed to update ${CUSTOM_ELEMENTS_JS} using both AST and regex fallback: ${parserError.message}`, tagName, parserError);
308
+ }
211
309
  }
212
- const parserError = toError(error);
213
- throw new FurnaceError(`Failed to update ${CUSTOM_ELEMENTS_JS} using AST registration parsing: ${parserError.message}`, tagName, parserError);
214
310
  }
215
311
  validateRegistrationPlacement(nextContent, tagName, isESModule);
216
312
  await writeText(filePath, nextContent);
217
313
  }
314
+ /**
315
+ * Validates that a custom element registration *would* succeed without
316
+ * writing anything. Used by dry-run to surface registration errors early.
317
+ */
318
+ export async function validateCustomElementRegistration(engineDir, tagName, modulePath) {
319
+ const filePath = join(engineDir, CUSTOM_ELEMENTS_JS);
320
+ if (!(await pathExists(filePath))) {
321
+ throw new FurnaceError('customElements.js not found in engine', tagName);
322
+ }
323
+ const content = await readText(filePath);
324
+ if (content.includes(`setElementCreationCallback("${tagName}"`) ||
325
+ content.includes(`setElementCreationCallback('${tagName}'`) ||
326
+ content.includes(`["${tagName}",`) ||
327
+ content.includes(`['${tagName}',`) ||
328
+ new RegExp(`^\\s*["']${tagName}["'],\\s*$`, 'm').test(content)) {
329
+ return;
330
+ }
331
+ const isESModule = modulePath.endsWith('.mjs');
332
+ if (!/for\s*\(\s*(?:let|const|var)\s*\[/.test(content)) {
333
+ throw new FurnaceError(`${CUSTOM_ELEMENTS_JS} does not contain a recognizable registration loop; refusing to mutate. ` +
334
+ 'Run "fireforge reset --force" to restore the engine, or inspect the file manually.', tagName);
335
+ }
336
+ if (isESModule && !/document\.addEventListener\s*\(\s*["']DOMContentLoaded["']/.test(content)) {
337
+ throw new FurnaceError(`${CUSTOM_ELEMENTS_JS} has no DOMContentLoaded block; cannot register ESM element ${tagName}. ` +
338
+ 'The file may be corrupt — run "fireforge reset --force" to restore.', tagName);
339
+ }
340
+ try {
341
+ addRegistrationAST(content, tagName, modulePath, isESModule);
342
+ }
343
+ catch {
344
+ // Validation only — if AST fails, try regex to see if the entry could be placed
345
+ addRegistrationRegexFallback(content, tagName, modulePath, isESModule);
346
+ }
347
+ }
218
348
  //# sourceMappingURL=furnace-registration-ast.js.map
@@ -1,12 +1,34 @@
1
1
  /**
2
2
  * Removal of custom element registrations from customElements.js.
3
- * Supports three removal strategies: standalone callback, single-line array, multi-line array.
3
+ *
4
+ * Uses the same AST parser as the add path (`furnace-registration-ast.ts`) to
5
+ * locate and delete registration entries. The earlier implementation walked
6
+ * the file line-by-line with a 20-line scan bound for bracket matching, which
7
+ * only worked against Firefox's stock formatting and silently failed on any
8
+ * hand-reformatted customElements.js. AST-based bracket matching is format-
9
+ * agnostic by construction.
10
+ *
11
+ * Contract:
12
+ * - Idempotent: if the tag is not registered, the file is left unchanged.
13
+ * - Non-destructive on parse failure: if customElements.js cannot be parsed,
14
+ * the file is left untouched rather than fall through to a line-based
15
+ * heuristic that could delete the wrong range.
16
+ * - Two registration shapes are recognised:
17
+ * (A) Standalone statement:
18
+ * customElements.setElementCreationCallback("tag", ...);
19
+ * (B) Entry inside a `for (... of [ ... ])` registration array:
20
+ * ["tag", "chrome://..."]
21
+ * Both are deleted together with any trailing comma and newline so the
22
+ * resulting file is still valid JavaScript.
4
23
  */
5
24
  /**
6
25
  * Removes a custom element registration from customElements.js.
7
26
  *
8
- * This operation is idempotent — if the tag is not registered or the file does
9
- * not exist, nothing happens.
27
+ * This operation is idempotent — if the tag is not registered or the file
28
+ * does not exist, nothing happens. If the file exists but cannot be parsed,
29
+ * the file is left unchanged rather than fall back to a line-based
30
+ * heuristic; a corrupted customElements.js is a doctor problem, not
31
+ * something `furnace remove` should "helpfully" edit around.
10
32
  *
11
33
  * @param engineDir - Path to the Firefox engine source root
12
34
  * @param tagName - Custom element tag name to remove