@hominis/fireforge 0.10.1 → 0.11.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 (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 +5 -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
@@ -1,16 +1,187 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
3
  * Removal of custom element registrations from customElements.js.
4
- * Supports three removal strategies: standalone callback, single-line array, multi-line array.
4
+ *
5
+ * Uses the same AST parser as the add path (`furnace-registration-ast.ts`) to
6
+ * locate and delete registration entries. The earlier implementation walked
7
+ * the file line-by-line with a 20-line scan bound for bracket matching, which
8
+ * only worked against Firefox's stock formatting and silently failed on any
9
+ * hand-reformatted customElements.js. AST-based bracket matching is format-
10
+ * agnostic by construction.
11
+ *
12
+ * Contract:
13
+ * - Idempotent: if the tag is not registered, the file is left unchanged.
14
+ * - Non-destructive on parse failure: if customElements.js cannot be parsed,
15
+ * the file is left untouched rather than fall through to a line-based
16
+ * heuristic that could delete the wrong range.
17
+ * - Two registration shapes are recognised:
18
+ * (A) Standalone statement:
19
+ * customElements.setElementCreationCallback("tag", ...);
20
+ * (B) Entry inside a `for (... of [ ... ])` registration array:
21
+ * ["tag", "chrome://..."]
22
+ * Both are deleted together with any trailing comma and newline so the
23
+ * resulting file is still valid JavaScript.
5
24
  */
6
25
  import { join } from 'node:path';
26
+ import MagicString from 'magic-string';
7
27
  import { pathExists, readText, writeText } from '../utils/fs.js';
28
+ import { parseScript, walkAST } from './ast-utils.js';
8
29
  import { CUSTOM_ELEMENTS_JS } from './furnace-constants.js';
30
+ /**
31
+ * Expands `[start, end)` to consume a trailing comma and any whitespace up
32
+ * to (and including) the next newline. This keeps the surrounding file
33
+ * layout stable: deleting a list entry should not leave a dangling comma
34
+ * behind, and deleting an expression statement should not leave a blank
35
+ * line where the statement used to be.
36
+ */
37
+ function expandRemovalRange(content, start, end) {
38
+ let expandedEnd = end;
39
+ // Walk past horizontal whitespace so a trailing comma is reachable even
40
+ // when the source has `[...] ,` or similar.
41
+ while (expandedEnd < content.length &&
42
+ (content[expandedEnd] === ' ' || content[expandedEnd] === '\t')) {
43
+ expandedEnd++;
44
+ }
45
+ if (content[expandedEnd] === ',') {
46
+ expandedEnd++;
47
+ }
48
+ else if (content[expandedEnd] === ';') {
49
+ expandedEnd++;
50
+ }
51
+ // Consume a trailing inline comment up to end-of-line so we do not leave
52
+ // the comment marooned on its own line. A leading inline comment is left
53
+ // alone — it may belong to the next entry.
54
+ while (expandedEnd < content.length &&
55
+ (content[expandedEnd] === ' ' || content[expandedEnd] === '\t')) {
56
+ expandedEnd++;
57
+ }
58
+ if (content[expandedEnd] === '/' && content[expandedEnd + 1] === '/') {
59
+ while (expandedEnd < content.length && content[expandedEnd] !== '\n') {
60
+ expandedEnd++;
61
+ }
62
+ }
63
+ if (content[expandedEnd] === '\n') {
64
+ expandedEnd++;
65
+ }
66
+ // Also consume leading whitespace on the line the removal starts on, so
67
+ // indentation does not survive as a blank-looking line.
68
+ let expandedStart = start;
69
+ while (expandedStart > 0) {
70
+ const prev = content[expandedStart - 1];
71
+ if (prev === ' ' || prev === '\t') {
72
+ expandedStart--;
73
+ continue;
74
+ }
75
+ break;
76
+ }
77
+ // If the removal now starts at the beginning of a line and the preceding
78
+ // line is blank, consume that blank line as well. This matches the older
79
+ // line-based implementation's "eat one leading blank" behaviour so that
80
+ // removing a callback block from between two blank-line-separated
81
+ // sections does not leave a doubled-up gap behind.
82
+ if (expandedStart > 0 &&
83
+ content[expandedStart - 1] === '\n' &&
84
+ expandedStart >= 2 &&
85
+ content[expandedStart - 2] === '\n') {
86
+ expandedStart--;
87
+ }
88
+ return { start: expandedStart, end: expandedEnd };
89
+ }
90
+ /**
91
+ * Returns true if `node` represents a `[tag, module]` entry inside a
92
+ * registration array — i.e. an `ArrayExpression` whose first element is a
93
+ * string literal equal to `tagName`. Callers still have to verify the
94
+ * parent is the outer registration array so we do not accidentally delete
95
+ * an arbitrary user-owned `["moz-card", ...]` literal elsewhere in the file.
96
+ */
97
+ function isEntryArrayFor(node, tagName) {
98
+ if (!node || node.type !== 'ArrayExpression')
99
+ return false;
100
+ const [first] = node.elements;
101
+ if (!first || first.type !== 'Literal')
102
+ return false;
103
+ const literal = first;
104
+ return literal.value === tagName;
105
+ }
106
+ /**
107
+ * Returns true if `call` is `customElements.setElementCreationCallback(tagName, ...)`
108
+ * (optionally prefixed with `lazy.` or similar member chain). We only match
109
+ * the property name rather than the full callee chain so unusual-but-valid
110
+ * receivers (`lazy.customElements…`, `this.customElements…`) still count.
111
+ */
112
+ function isStandaloneCallbackCallFor(call, tagName) {
113
+ if (call.callee.type !== 'MemberExpression')
114
+ return false;
115
+ const prop = call.callee.property;
116
+ if (prop.type !== 'Identifier' || prop.name !== 'setElementCreationCallback')
117
+ return false;
118
+ const [tagArg] = call.arguments;
119
+ if (!tagArg || tagArg.type !== 'Literal')
120
+ return false;
121
+ return tagArg.value === tagName;
122
+ }
123
+ /**
124
+ * Walks the AST and collects every removal range for `tagName`. Ancestor
125
+ * tracking lets us tell an "entry" array from a random `["moz-foo", ...]`
126
+ * literal (only direct children of the outer registration `ArrayExpression`
127
+ * are entries) and lets us lift a `setElementCreationCallback` call up to
128
+ * its enclosing statement.
129
+ */
130
+ function collectRemovalRanges(ast, content, tagName) {
131
+ const ranges = [];
132
+ const ancestors = [];
133
+ walkAST(ast, {
134
+ enter(node) {
135
+ // Entry array: [tag, module] inside the outer for-of array literal.
136
+ // The immediate parent must be an ArrayExpression (the registration
137
+ // list) whose own grandparent is the `for (...) of <array>` loop.
138
+ if (node.type === 'ArrayExpression' && isEntryArrayFor(node, tagName)) {
139
+ const parent = ancestors[ancestors.length - 1];
140
+ const grandparent = ancestors[ancestors.length - 2];
141
+ if (parent &&
142
+ parent.type === 'ArrayExpression' &&
143
+ grandparent &&
144
+ grandparent.type === 'ForOfStatement') {
145
+ ranges.push(expandRemovalRange(content, node.start, node.end));
146
+ }
147
+ }
148
+ // Standalone call: customElements.setElementCreationCallback(tag, …)
149
+ if (node.type === 'CallExpression') {
150
+ const call = node;
151
+ if (isStandaloneCallbackCallFor(call, tagName)) {
152
+ // Find the enclosing statement so we can delete `call(...);` as
153
+ // a unit, not just the call expression body.
154
+ let enclosing = null;
155
+ for (let i = ancestors.length - 1; i >= 0; i--) {
156
+ const ancestor = ancestors[i];
157
+ if (!ancestor)
158
+ continue;
159
+ if (ancestor.type === 'ExpressionStatement' ||
160
+ ancestor.type === 'VariableDeclaration') {
161
+ enclosing = ancestor;
162
+ break;
163
+ }
164
+ }
165
+ const target = enclosing ?? call;
166
+ ranges.push(expandRemovalRange(content, target.start, target.end));
167
+ }
168
+ }
169
+ ancestors.push(node);
170
+ },
171
+ leave() {
172
+ ancestors.pop();
173
+ },
174
+ });
175
+ return ranges;
176
+ }
9
177
  /**
10
178
  * Removes a custom element registration from customElements.js.
11
179
  *
12
- * This operation is idempotent — if the tag is not registered or the file does
13
- * not exist, nothing happens.
180
+ * This operation is idempotent — if the tag is not registered or the file
181
+ * does not exist, nothing happens. If the file exists but cannot be parsed,
182
+ * the file is left unchanged rather than fall back to a line-based
183
+ * heuristic; a corrupted customElements.js is a doctor problem, not
184
+ * something `furnace remove` should "helpfully" edit around.
14
185
  *
15
186
  * @param engineDir - Path to the Firefox engine source root
16
187
  * @param tagName - Custom element tag name to remove
@@ -20,70 +191,33 @@ export async function removeCustomElementRegistration(engineDir, tagName) {
20
191
  if (!(await pathExists(filePath))) {
21
192
  return;
22
193
  }
23
- let content = await readText(filePath);
24
- const lines = content.split('\n');
25
- // Strategy 1: Remove standalone callback block (setElementCreationCallback("tagName" …))
26
- const callbackLine = lines.findIndex((l) => l.includes(`setElementCreationCallback("${tagName}"`));
27
- if (callbackLine !== -1) {
28
- let endLine = callbackLine;
29
- for (let i = callbackLine + 1; i < lines.length; i++) {
30
- const line = lines[i];
31
- if (line === undefined)
32
- continue;
33
- if (/^\s*\}\);/.test(line)) {
34
- endLine = i;
35
- break;
36
- }
37
- }
38
- let startLine = callbackLine;
39
- const precedingLine = lines[startLine - 1];
40
- if (startLine > 0 && precedingLine !== undefined && precedingLine.trim() === '') {
41
- startLine--;
42
- }
43
- lines.splice(startLine, endLine - startLine + 1);
44
- content = lines.join('\n');
45
- await writeText(filePath, content);
194
+ const content = await readText(filePath);
195
+ // Cheap pre-check: if the tag literal never appears in the file there is
196
+ // nothing to remove and we avoid the cost of parsing a large file on the
197
+ // hot path of a no-op remove.
198
+ if (!content.includes(`"${tagName}"`) && !content.includes(`'${tagName}'`)) {
46
199
  return;
47
200
  }
48
- // Strategy 2: Remove single-line array entry ["tagName", "..."],
49
- const singleLineIdx = lines.findIndex((l) => new RegExp(`^\\s*\\["${tagName}",\\s*"[^"]*"\\],?\\s*$`).test(l));
50
- if (singleLineIdx !== -1) {
51
- lines.splice(singleLineIdx, 1);
52
- content = lines.join('\n');
53
- await writeText(filePath, content);
201
+ let ast;
202
+ try {
203
+ ast = parseScript(content);
204
+ }
205
+ catch {
54
206
  return;
55
207
  }
56
- // Strategy 3: Remove multi-line array entry where "tagName", is on its own line
57
- const multiLineTagIdx = lines.findIndex((l) => new RegExp(`^\\s*"${tagName}",\\s*$`).test(l));
58
- if (multiLineTagIdx !== -1) {
59
- // Scan backwards from the tag line to find the opening [ (bounded to 20 lines)
60
- let startLine = multiLineTagIdx;
61
- const scanLimit = Math.max(0, multiLineTagIdx - 20);
62
- for (let i = multiLineTagIdx - 1; i >= scanLimit; i--) {
63
- const line = lines[i];
64
- if (line !== undefined && /^\s*\[$/.test(line)) {
65
- startLine = i;
66
- break;
67
- }
68
- }
69
- const openIndent = (lines[startLine] ?? '').match(/^(\s*)/)?.[1]?.length ?? 0;
70
- // Scan forwards from the tag line to find the closing ],
71
- let endLine = multiLineTagIdx;
72
- for (let i = multiLineTagIdx + 1; i < lines.length; i++) {
73
- const line = lines[i];
74
- if (line !== undefined && /^\s*\],?\s*$/.test(line)) {
75
- const closeIndent = line.match(/^(\s*)/)?.[1]?.length ?? 0;
76
- if (closeIndent === openIndent) {
77
- endLine = i;
78
- break;
79
- }
80
- }
81
- }
82
- lines.splice(startLine, endLine - startLine + 1);
83
- content = lines.join('\n');
84
- await writeText(filePath, content);
208
+ const ranges = collectRemovalRanges(ast, content, tagName);
209
+ if (ranges.length === 0) {
85
210
  return;
86
211
  }
87
- // Tag not found in any form idempotent no-op
212
+ // Apply all removals in a single MagicString pass. MagicString tracks
213
+ // original offsets so the ranges do not need to be sorted or reversed.
214
+ const ms = new MagicString(content);
215
+ for (const range of ranges) {
216
+ ms.remove(range.start, range.end);
217
+ }
218
+ const next = ms.toString();
219
+ if (next !== content) {
220
+ await writeText(filePath, next);
221
+ }
88
222
  }
89
223
  //# sourceMappingURL=furnace-registration-remove.js.map
@@ -2,8 +2,20 @@
2
2
  * Shared validation for furnace custom element registration placement.
3
3
  * Used after both AST and legacy code paths to avoid duplicating logic.
4
4
  */
5
- /** Regex for valid custom element tag names. */
5
+ /**
6
+ * Regex for valid custom element tag names. A valid name is lowercase, starts
7
+ * with a letter, and contains one or more hyphen-separated groups where each
8
+ * group is a non-empty alphanumeric run. Consecutive hyphens and trailing
9
+ * hyphens are both rejected. Kept in sync with the HTML custom element spec
10
+ * requirement that a name contain at least one hyphen.
11
+ *
12
+ * A single shared constant is used by every furnace authoring path
13
+ * (`furnace create`, `furnace override`, and the AST registration helper) so
14
+ * that a name accepted by one command cannot be rejected by another.
15
+ */
6
16
  export declare const CUSTOM_ELEMENT_TAG_PATTERN: RegExp;
17
+ /** Human-readable description of the tag-name rules, for CLI error messages. */
18
+ export declare const CUSTOM_ELEMENT_TAG_RULES = "must be lowercase, start with a letter, and use hyphens to separate non-empty alphanumeric groups (e.g., \"my-widget\")";
7
19
  /**
8
20
  * Validates that a tag name conforms to custom element naming requirements.
9
21
  * @throws FurnaceError if the tag name is invalid
@@ -4,15 +4,27 @@
4
4
  * Used after both AST and legacy code paths to avoid duplicating logic.
5
5
  */
6
6
  import { FurnaceError } from '../errors/furnace.js';
7
- /** Regex for valid custom element tag names. */
8
- export const CUSTOM_ELEMENT_TAG_PATTERN = /^[a-z][a-z0-9]*-[a-z0-9-]*$/;
7
+ /**
8
+ * Regex for valid custom element tag names. A valid name is lowercase, starts
9
+ * with a letter, and contains one or more hyphen-separated groups where each
10
+ * group is a non-empty alphanumeric run. Consecutive hyphens and trailing
11
+ * hyphens are both rejected. Kept in sync with the HTML custom element spec
12
+ * requirement that a name contain at least one hyphen.
13
+ *
14
+ * A single shared constant is used by every furnace authoring path
15
+ * (`furnace create`, `furnace override`, and the AST registration helper) so
16
+ * that a name accepted by one command cannot be rejected by another.
17
+ */
18
+ export const CUSTOM_ELEMENT_TAG_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
19
+ /** Human-readable description of the tag-name rules, for CLI error messages. */
20
+ export const CUSTOM_ELEMENT_TAG_RULES = 'must be lowercase, start with a letter, and use hyphens to separate non-empty alphanumeric groups (e.g., "my-widget")';
9
21
  /**
10
22
  * Validates that a tag name conforms to custom element naming requirements.
11
23
  * @throws FurnaceError if the tag name is invalid
12
24
  */
13
25
  export function validateTagName(tagName) {
14
26
  if (!CUSTOM_ELEMENT_TAG_PATTERN.test(tagName)) {
15
- throw new FurnaceError(`Invalid tag name "${tagName}": must contain a hyphen and match /^[a-z][a-z0-9]*-[a-z0-9-]*$/`, tagName);
27
+ throw new FurnaceError(`Invalid tag name "${tagName}": ${CUSTOM_ELEMENT_TAG_RULES}`, tagName);
16
28
  }
17
29
  }
18
30
  /**
@@ -1,16 +1,34 @@
1
1
  export { CUSTOM_ELEMENTS_JS, JAR_MN } from './furnace-constants.js';
2
- export { addCustomElementRegistration, removeCustomElementRegistration, } from './furnace-registration-ast.js';
2
+ export { addCustomElementRegistration, removeCustomElementRegistration, validateCustomElementRegistration, } from './furnace-registration-ast.js';
3
3
  /**
4
4
  * Adds jar.mn entries that map chrome:// URIs to on-disk paths for a
5
5
  * component's files.
6
6
  *
7
- * Entry format (3-space indent, spaces not tabs):
7
+ * ## Assumed jar.mn format
8
+ *
9
+ * Firefox's `toolkit/content/jar.mn` uses a stable format that has not
10
+ * changed in the custom-element era (Firefox 90+). The entry format is:
11
+ *
8
12
  * ```
9
13
  * content/global/elements/{file} (widgets/{tagName}/{file})
10
14
  * ```
11
15
  *
12
- * New entries are inserted in alphabetical order relative to existing
13
- * `content/global/elements/` entries. The operation is idempotent.
16
+ * - **Indent**: detected dynamically from the nearest existing
17
+ * `content/global/elements/` line. Falls back to 3 spaces when no
18
+ * reference line exists.
19
+ * - **Insertion point**: identified by existing lines matching the regex
20
+ * `^\s+content\/global\/elements\/([^.]+)\.` — new entries are inserted
21
+ * in alphabetical order relative to these.
22
+ * - **Fallback**: if no `content/global/elements/` line exists (empty
23
+ * project), looks for any `content/global/` line and inserts after it.
24
+ * - **Idempotency**: entries already present (checked by exact path match
25
+ * on `content/global/elements/{file}` with a trailing whitespace or
26
+ * end-of-line boundary) are skipped.
27
+ *
28
+ * If Firefox upstream changes the jar.mn section ordering or switches to a
29
+ * different resource registration mechanism, the preflight validation in
30
+ * `validateJarMnEntries` will catch the format mismatch before any writes
31
+ * occur.
14
32
  *
15
33
  * @param engineDir - Path to the Firefox engine source root
16
34
  * @param tagName - Custom element tag name
@@ -27,3 +45,8 @@ export declare function addJarMnEntries(engineDir: string, tagName: string, file
27
45
  * @param tagName - Custom element tag name whose entries should be removed
28
46
  */
29
47
  export declare function removeJarMnEntries(engineDir: string, tagName: string): Promise<void>;
48
+ /**
49
+ * Validates that jar.mn entries *could* be added without writing anything.
50
+ * Used by dry-run to surface structural problems early.
51
+ */
52
+ export declare function validateJarMnEntries(engineDir: string, tagName: string, files: string[]): Promise<void>;
@@ -2,21 +2,57 @@
2
2
  import { join } from 'node:path';
3
3
  import { FurnaceError } from '../errors/furnace.js';
4
4
  import { pathExists, readText, writeText } from '../utils/fs.js';
5
+ /** Escapes special regex characters in a literal string. */
6
+ function escapeForRegex(str) {
7
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
+ }
9
+ /**
10
+ * Detects the indentation used by existing `content/global/elements/` lines
11
+ * in jar.mn. Falls back to 3 spaces (the historical Firefox convention) when
12
+ * no reference line is found.
13
+ */
14
+ function detectJarMnIndent(lines) {
15
+ for (const line of lines) {
16
+ const match = /^(\s+)content\/global\/elements\//.exec(line);
17
+ if (match?.[1]) {
18
+ return match[1];
19
+ }
20
+ }
21
+ return ' ';
22
+ }
5
23
  // Re-export everything from the AST module so existing imports keep working
6
24
  export { CUSTOM_ELEMENTS_JS, JAR_MN } from './furnace-constants.js';
7
- export { addCustomElementRegistration, removeCustomElementRegistration, } from './furnace-registration-ast.js';
25
+ export { addCustomElementRegistration, removeCustomElementRegistration, validateCustomElementRegistration, } from './furnace-registration-ast.js';
8
26
  import { JAR_MN } from './furnace-constants.js';
9
27
  /**
10
28
  * Adds jar.mn entries that map chrome:// URIs to on-disk paths for a
11
29
  * component's files.
12
30
  *
13
- * Entry format (3-space indent, spaces not tabs):
31
+ * ## Assumed jar.mn format
32
+ *
33
+ * Firefox's `toolkit/content/jar.mn` uses a stable format that has not
34
+ * changed in the custom-element era (Firefox 90+). The entry format is:
35
+ *
14
36
  * ```
15
37
  * content/global/elements/{file} (widgets/{tagName}/{file})
16
38
  * ```
17
39
  *
18
- * New entries are inserted in alphabetical order relative to existing
19
- * `content/global/elements/` entries. The operation is idempotent.
40
+ * - **Indent**: detected dynamically from the nearest existing
41
+ * `content/global/elements/` line. Falls back to 3 spaces when no
42
+ * reference line exists.
43
+ * - **Insertion point**: identified by existing lines matching the regex
44
+ * `^\s+content\/global\/elements\/([^.]+)\.` — new entries are inserted
45
+ * in alphabetical order relative to these.
46
+ * - **Fallback**: if no `content/global/elements/` line exists (empty
47
+ * project), looks for any `content/global/` line and inserts after it.
48
+ * - **Idempotency**: entries already present (checked by exact path match
49
+ * on `content/global/elements/{file}` with a trailing whitespace or
50
+ * end-of-line boundary) are skipped.
51
+ *
52
+ * If Firefox upstream changes the jar.mn section ordering or switches to a
53
+ * different resource registration mechanism, the preflight validation in
54
+ * `validateJarMnEntries` will catch the format mismatch before any writes
55
+ * occur.
20
56
  *
21
57
  * @param engineDir - Path to the Firefox engine source root
22
58
  * @param tagName - Custom element tag name
@@ -29,12 +65,14 @@ export async function addJarMnEntries(engineDir, tagName, files) {
29
65
  }
30
66
  let content = await readText(filePath);
31
67
  const lines = content.split('\n');
32
- // Filter to files not already registered
33
- const newFiles = files.filter((f) => !content.includes(`content/global/elements/${f}`));
68
+ // Filter to files not already registered. Use a word-boundary-aware
69
+ // check so that "moz-card.css" does not match "moz-card-group.css".
70
+ const newFiles = files.filter((f) => !new RegExp(`content/global/elements/${escapeForRegex(f)}(?:\\s|$)`, 'm').test(content));
34
71
  if (newFiles.length === 0)
35
72
  return;
36
- // Build new entry lines
37
- const newEntries = newFiles.map((f) => ` content/global/elements/${f} (widgets/${tagName}/${f})`);
73
+ // Build new entry lines using the indent detected from existing entries.
74
+ const indent = detectJarMnIndent(lines);
75
+ const newEntries = newFiles.map((f) => `${indent}content/global/elements/${f} (widgets/${tagName}/${f})`);
38
76
  // Find insertion point among existing content/global/elements/ lines
39
77
  const elementLinePattern = /^\s+content\/global\/elements\/([^.]+)\./;
40
78
  let insertIndex = -1;
@@ -64,7 +102,11 @@ export async function addJarMnEntries(engineDir, tagName, files) {
64
102
  }
65
103
  }
66
104
  if (insertIndex === -1) {
67
- throw new FurnaceError('Could not find insertion point in jar.mn for element entries', tagName);
105
+ const nonEmpty = lines.some((line) => line.trim().length > 0);
106
+ if (!nonEmpty) {
107
+ throw new FurnaceError('jar.mn is empty or contains only whitespace. It may be malformed — verify the engine was downloaded correctly.', tagName);
108
+ }
109
+ throw new FurnaceError('Could not find a content/global/ section in jar.mn for element entries. The file may be malformed.', tagName);
68
110
  }
69
111
  lines.splice(insertIndex, 0, ...newEntries);
70
112
  content = lines.join('\n');
@@ -86,11 +128,51 @@ export async function removeJarMnEntries(engineDir, tagName) {
86
128
  }
87
129
  let content = await readText(filePath);
88
130
  const lines = content.split('\n');
89
- const pattern = `content/global/elements/${tagName}.`;
90
- const filtered = lines.filter((line) => !line.includes(pattern));
131
+ // Use a regex with word boundary so "moz-card" does not match "moz-card-group".
132
+ const pattern = new RegExp(`content/global/elements/${escapeForRegex(tagName)}\\.`);
133
+ const filtered = lines.filter((line) => !pattern.test(line));
91
134
  if (filtered.length === lines.length)
92
135
  return;
93
136
  content = filtered.join('\n');
94
137
  await writeText(filePath, content);
95
138
  }
139
+ /**
140
+ * Validates that jar.mn entries *could* be added without writing anything.
141
+ * Used by dry-run to surface structural problems early.
142
+ */
143
+ export async function validateJarMnEntries(engineDir, tagName, files) {
144
+ const filePath = join(engineDir, JAR_MN);
145
+ if (!(await pathExists(filePath))) {
146
+ throw new FurnaceError('jar.mn not found in engine', tagName);
147
+ }
148
+ const content = await readText(filePath);
149
+ const lines = content.split('\n');
150
+ const newFiles = files.filter((f) => !new RegExp(`content/global/elements/${escapeForRegex(f)}(?:\\s|$)`, 'm').test(content));
151
+ if (newFiles.length === 0)
152
+ return;
153
+ const elementLinePattern = /^\s+content\/global\/elements\/([^.]+)\./;
154
+ let hasInsertionPoint = false;
155
+ for (const line of lines) {
156
+ if (elementLinePattern.test(line)) {
157
+ hasInsertionPoint = true;
158
+ break;
159
+ }
160
+ }
161
+ if (!hasInsertionPoint) {
162
+ for (let i = lines.length - 1; i >= 0; i--) {
163
+ const line = lines[i];
164
+ if (line !== undefined && /^\s+content\/global\//.test(line)) {
165
+ hasInsertionPoint = true;
166
+ break;
167
+ }
168
+ }
169
+ }
170
+ if (!hasInsertionPoint) {
171
+ const nonEmpty = lines.some((line) => line.trim().length > 0);
172
+ if (!nonEmpty) {
173
+ throw new FurnaceError('jar.mn is empty or contains only whitespace. It may be malformed — verify the engine was downloaded correctly.', tagName);
174
+ }
175
+ throw new FurnaceError('Could not find a content/global/ section in jar.mn for element entries. The file may be malformed.', tagName);
176
+ }
177
+ }
96
178
  //# sourceMappingURL=furnace-registration.js.map
@@ -6,11 +6,22 @@ interface FileSnapshot {
6
6
  export interface RollbackJournal {
7
7
  files: Map<string, FileSnapshot>;
8
8
  createdDirs: Set<string>;
9
+ /** Paths that were skipped during snapshotDir because they are symlinks. */
10
+ skippedSymlinks: Set<string>;
9
11
  }
10
12
  /** Creates an empty rollback journal for tracking touched files and created directories. */
11
13
  export declare function createRollbackJournal(): RollbackJournal;
12
14
  /** Records a directory that should be removed if the operation later rolls back. */
13
15
  export declare function recordCreatedDir(journal: RollbackJournal, dirPath: string): void;
16
+ /**
17
+ * Recursively snapshots every file under a directory tree so a later rollback
18
+ * can restore deleted files. Skips symlinks to avoid following them out of the
19
+ * tree. The directory itself is not recorded as "created" — callers that
20
+ * intend to delete and restore the directory should record it explicitly.
21
+ *
22
+ * Safe to call on a missing path: it returns without recording anything.
23
+ */
24
+ export declare function snapshotDir(journal: RollbackJournal, dirPath: string): Promise<void>;
14
25
  /** Snapshots a file once so rollback can restore its previous contents or absence. */
15
26
  export declare function snapshotFile(journal: RollbackJournal, filePath: string): Promise<void>;
16
27
  /** Restores all snapshotted files and removes directories created during the operation. */