@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
@@ -7,16 +7,18 @@ import { PatchError } from '../errors/patch.js';
7
7
  import { readText } from '../utils/fs.js';
8
8
  import { isNewFileInPatch, parseHunksForFile } from './patch-parse.js';
9
9
  /**
10
- * Extracts the complete file content from a "new file" patch.
11
- * When targetFile is provided, only extracts content for that file
12
- * (required for multi-file patches).
13
- * @param patchPath - Path to the patch file
10
+ * Extracts the complete file content from a "new file" patch given a raw
11
+ * diff string already in memory. Callers with a patch file path should
12
+ * prefer {@link extractNewFileContent}; this helper exists for code paths
13
+ * that already hold the diff (e.g. the in-flight export planner) and do
14
+ * not want to round-trip through the filesystem.
15
+ *
16
+ * @param diff - Raw unified-diff content
14
17
  * @param targetFile - Optional target file to scope extraction to
15
18
  * @returns The file content that the patch would create
16
19
  */
17
- export async function extractNewFileContent(patchPath, targetFile) {
18
- const content = await readText(patchPath);
19
- const lines = content.split('\n');
20
+ export function extractNewFileContentFromDiff(diff, targetFile) {
21
+ const lines = diff.split('\n');
20
22
  const contentLines = [];
21
23
  let inHunk = false;
22
24
  let inTargetFile = !targetFile; // If no targetFile, accept all sections
@@ -60,6 +62,18 @@ export async function extractNewFileContent(patchPath, targetFile) {
60
62
  const result = contentLines.join('\n');
61
63
  return hasNoNewlineMarker ? result : result + '\n';
62
64
  }
65
+ /**
66
+ * Extracts the complete file content from a "new file" patch.
67
+ * When targetFile is provided, only extracts content for that file
68
+ * (required for multi-file patches).
69
+ * @param patchPath - Path to the patch file
70
+ * @param targetFile - Optional target file to scope extraction to
71
+ * @returns The file content that the patch would create
72
+ */
73
+ export async function extractNewFileContent(patchPath, targetFile) {
74
+ const content = await readText(patchPath);
75
+ return extractNewFileContentFromDiff(content, targetFile);
76
+ }
63
77
  /**
64
78
  * Applies a patch's changes to content.
65
79
  * @param content - Original content (null for new files)
@@ -9,6 +9,7 @@ import { escapeRegex } from '../utils/regex.js';
9
9
  import { validateTokenName } from '../utils/validation.js';
10
10
  import { getProjectPaths, loadConfig } from './config.js';
11
11
  import { loadFurnaceConfig } from './furnace-config.js';
12
+ import { findTableAfterHeading, findTableByColumns, insertRow, rewriteTableRows, updateCellByKey, } from './markdown-table.js';
12
13
  /** Returns the token CSS path relative to engine root for a given binary name. */
13
14
  export function getTokensCssPath(binaryName) {
14
15
  return `browser/themes/shared/${binaryName}-tokens.css`;
@@ -290,7 +291,27 @@ async function addTokenToCSS(engineDir, options, tokensCssPath) {
290
291
  return true;
291
292
  }
292
293
  /**
293
- * Adds a token to the documentation markdown file.
294
+ * Strips surrounding backticks from a cell, if present. Token cells are
295
+ * usually wrapped in inline code fences (`` `--foo` ``) and the parser
296
+ * returns them verbatim.
297
+ */
298
+ function stripInlineCode(cell) {
299
+ const trimmed = cell.trim();
300
+ if (trimmed.startsWith('`') && trimmed.endsWith('`') && trimmed.length >= 2) {
301
+ return trimmed.slice(1, -1);
302
+ }
303
+ return trimmed;
304
+ }
305
+ /**
306
+ * Adds a token row to the main token table, the unmapped table (for
307
+ * literal values), and bumps the mode count table. Each sub-update runs
308
+ * against a freshly parsed view of the document so that splice indices
309
+ * stay valid as rewrites are layered.
310
+ *
311
+ * The old implementation walked `split('\n')` by hand, detected rows by
312
+ * literal `|`-prefix, and used a whitespace-sensitive regex to increment
313
+ * the mode count. Switching to {@link findTableByColumns} and
314
+ * {@link updateCellByKey} removes those formatting traps.
294
315
  */
295
316
  async function addTokenToDocs(engineDir, options) {
296
317
  const filePath = join(engineDir, '..', TOKENS_DOC);
@@ -298,90 +319,67 @@ async function addTokenToDocs(engineDir, options) {
298
319
  // Docs file is optional
299
320
  return { docsAdded: false, unmappedAdded: false, countUpdated: false };
300
321
  }
301
- let content = await readText(filePath);
302
- const lines = content.split('\n');
322
+ const originalContent = await readText(filePath);
323
+ let lines = originalContent.split('\n');
303
324
  let docsAdded = false;
304
325
  let unmappedAdded = false;
305
326
  let countUpdated = false;
306
327
  const annotation = getModeAnnotation(options.mode, options.value);
307
328
  const isLiteral = !options.value.startsWith('var(');
308
- // Find the category group in the token table
309
- // Look for a row containing the category name, then find the last row in that group
310
- let categoryRowStart = -1;
311
- let lastRowInCategory = -1;
312
- for (let i = 0; i < lines.length; i++) {
313
- const line = lines[i] ?? '';
314
- // Table rows start with |
315
- if (line.startsWith('|') && line.includes(options.category)) {
316
- categoryRowStart = i;
317
- lastRowInCategory = i;
318
- }
319
- else if (categoryRowStart !== -1 &&
320
- line.startsWith('|') &&
321
- !line.startsWith('|--') &&
322
- !line.startsWith('| Token')) {
323
- // Check if this row still belongs to the same category (no category cell or empty category)
324
- const cells = line.split('|').map((c) => c.trim());
325
- // If the first data cell (after leading empty) is empty, it belongs to same category
326
- if (cells[1] === '' || !cells[1]) {
327
- lastRowInCategory = i;
329
+ const mapsTo = isLiteral ? '—' : options.value.replace(/var\(([^)]+)\)/, '$1');
330
+ const tokenCell = `\`${options.tokenName}\``;
331
+ const valueCell = `\`${options.value}\``;
332
+ // --- Main token table: Category | Token | Value | Maps to | Mode ---
333
+ const mainTable = findTableByColumns(lines, ['Category', 'Token', 'Value', 'Mode']);
334
+ if (mainTable) {
335
+ // The doc convention allows the Category cell to be blank on
336
+ // continuation rows that belong to the previous category. Group rows
337
+ // by carrying the last non-empty Category value forward.
338
+ let lastGroupRowIndex = -1;
339
+ let currentCategory = '';
340
+ for (let i = 0; i < mainTable.rows.length; i++) {
341
+ const row = mainTable.rows[i];
342
+ if (!row)
343
+ continue;
344
+ const cell = row[0]?.trim() ?? '';
345
+ if (cell) {
346
+ currentCategory = cell;
328
347
  }
329
- else {
330
- break;
348
+ if (currentCategory === options.category) {
349
+ lastGroupRowIndex = i;
331
350
  }
332
351
  }
333
- else if (categoryRowStart !== -1 && !line.startsWith('|')) {
334
- break;
352
+ if (lastGroupRowIndex !== -1) {
353
+ insertRow(mainTable, ['', tokenCell, valueCell, mapsTo, annotation], lastGroupRowIndex + 1);
354
+ lines = rewriteTableRows(lines, mainTable);
355
+ docsAdded = true;
335
356
  }
336
357
  }
337
- if (lastRowInCategory !== -1) {
338
- // Insert a new row after the last row in this category
339
- const mapsTo = isLiteral ? '—' : options.value.replace(/var\(([^)]+)\)/, '$1');
340
- const newRow = `| | \`${options.tokenName}\` | \`${options.value}\` | ${mapsTo} | ${annotation} |`;
341
- lines.splice(lastRowInCategory + 1, 0, newRow);
342
- docsAdded = true;
343
- }
344
- // If the value is a literal (not a var() reference), add to unmapped table
358
+ // --- Unmapped table: populated for literal (non-var()) values only ---
345
359
  if (isLiteral) {
346
- let unmappedTableStart = -1;
347
- for (let i = 0; i < lines.length; i++) {
348
- const line = lines[i] ?? '';
349
- if (/not yet mapped/i.test(line) || /unmapped/i.test(line)) {
350
- unmappedTableStart = i;
351
- break;
352
- }
353
- }
354
- if (unmappedTableStart !== -1) {
355
- // Find the last row of the unmapped table
356
- let lastUnmappedRow = unmappedTableStart;
357
- for (let i = unmappedTableStart + 1; i < lines.length; i++) {
358
- const line = lines[i] ?? '';
359
- if (line.startsWith('|') && !line.startsWith('|--') && !line.startsWith('| Token')) {
360
- lastUnmappedRow = i;
361
- }
362
- else if (!line.startsWith('|')) {
363
- break;
364
- }
365
- }
366
- const unmappedRow = `| \`${options.tokenName}\` | \`${options.value}\` | ${options.description ?? ''} |`;
367
- lines.splice(lastUnmappedRow + 1, 0, unmappedRow);
360
+ const unmappedTable = findTableAfterHeading(lines, /not yet mapped|unmapped/i);
361
+ if (unmappedTable) {
362
+ insertRow(unmappedTable, [tokenCell, valueCell, options.description ?? ''], unmappedTable.rows.length);
363
+ lines = rewriteTableRows(lines, unmappedTable);
368
364
  unmappedAdded = true;
369
365
  }
370
366
  }
371
- // Update dark/light mode behavior count table
372
- const modeCountPattern = new RegExp(`\\|\\s*${options.mode}\\s*\\|\\s*(\\d+)\\s*\\|`);
373
- for (let i = 0; i < lines.length; i++) {
374
- const line = lines[i] ?? '';
375
- const match = modeCountPattern.exec(line);
376
- if (match) {
377
- const oldCount = parseInt(match[1] ?? '0', 10);
378
- lines[i] = line.replace(modeCountPattern, `| ${options.mode} | ${oldCount + 1} |`);
379
- countUpdated = true;
380
- break;
367
+ // --- Mode behavior count table: Mode | Count ---
368
+ const modeTable = findTableByColumns(lines, ['Mode', 'Count']);
369
+ if (modeTable) {
370
+ const modeIndex = modeTable.headers.indexOf('Mode');
371
+ const countIndex = modeTable.headers.indexOf('Count');
372
+ const existing = modeTable.rows.find((row) => stripInlineCode(row[modeIndex] ?? '') === options.mode);
373
+ if (existing) {
374
+ const oldCount = parseInt(existing[countIndex] ?? '0', 10);
375
+ const updated = updateCellByKey(modeTable, 'Mode', existing[modeIndex] ?? options.mode, 'Count', String((Number.isNaN(oldCount) ? 0 : oldCount) + 1));
376
+ if (updated) {
377
+ lines = rewriteTableRows(lines, modeTable);
378
+ countUpdated = true;
379
+ }
381
380
  }
382
381
  }
383
- content = lines.join('\n');
384
- await writeText(filePath, content);
382
+ await writeText(filePath, lines.join('\n'));
385
383
  return { docsAdded, unmappedAdded, countUpdated };
386
384
  }
387
385
  //# sourceMappingURL=token-manager.js.map
@@ -10,7 +10,7 @@ import { pathExists, readText, writeText } from '../utils/fs.js';
10
10
  import { escapeRegex } from '../utils/regex.js';
11
11
  import { detectIndent, parseScript } from './ast-utils.js';
12
12
  import { withParserFallback } from './parser-fallback.js';
13
- import { extractNameFromExpression, findMethodBody, findMethodBraceIndex, validateWireName, } from './wire-utils.js';
13
+ import { assertBraceBalancePreserved, extractNameFromExpression, findMethodBody, findMethodBraceIndex, validateWireName, } from './wire-utils.js';
14
14
  const BROWSER_INIT_JS = 'browser/base/content/browser-init.js';
15
15
  /**
16
16
  * AST-based implementation: finds onUnload()/uninit() method body and
@@ -57,7 +57,7 @@ export function legacyAddDestroy(content, expression) {
57
57
  const name = extractNameFromExpression(expression);
58
58
  const lines = content.split('\n');
59
59
  const destroyRegex = /\b(?:async\s+)?(onUnload|uninit)\s*[(:]/;
60
- const found = findMethodBraceIndex(lines, destroyRegex);
60
+ const found = findMethodBraceIndex(lines, destroyRegex, { requireBrace: true });
61
61
  if (!found) {
62
62
  throw new GeneralError('Could not find "onUnload" or "uninit" method in browser-init.js.\n' +
63
63
  'FireForge was looking for a signature matching: \\b(?:async\\s+)?(onUnload|uninit)\\s*[(:]');
@@ -96,7 +96,10 @@ export async function addDestroyToBrowserInit(engineDir, expression) {
96
96
  if (destroyPattern.test(content)) {
97
97
  return false;
98
98
  }
99
- const { value } = withParserFallback(() => addDestroyAST(content, expression), () => legacyAddDestroy(content, expression), BROWSER_INIT_JS);
99
+ const { value, usedFallback } = withParserFallback(() => addDestroyAST(content, expression), () => legacyAddDestroy(content, expression), BROWSER_INIT_JS);
100
+ if (usedFallback) {
101
+ assertBraceBalancePreserved(content, value, BROWSER_INIT_JS);
102
+ }
100
103
  await writeText(filePath, value);
101
104
  return true;
102
105
  }
@@ -10,7 +10,7 @@ import { pathExists, readText, writeText } from '../utils/fs.js';
10
10
  import { escapeRegex } from '../utils/regex.js';
11
11
  import { detectIndent, getNodeSource, parseScript } from './ast-utils.js';
12
12
  import { withParserFallback } from './parser-fallback.js';
13
- import { extractNameFromExpression, findInsertionAfterFireforgeBlocks, findMethodBody, findMethodBraceIndex, validateWireName, walkToTryBlockEnd, } from './wire-utils.js';
13
+ import { assertBraceBalancePreserved, extractNameFromExpression, findInsertionAfterFireforgeBlocks, findMethodBody, findMethodBraceIndex, validateWireName, walkToTryBlockEnd, } from './wire-utils.js';
14
14
  const BROWSER_INIT_JS = 'browser/base/content/browser-init.js';
15
15
  /**
16
16
  * AST-based implementation: finds onLoad() method body, locates existing
@@ -113,7 +113,7 @@ export function legacyAddInit(content, expression, after) {
113
113
  const name = extractNameFromExpression(expression);
114
114
  const lines = content.split('\n');
115
115
  const onLoadRegex = /\b(?:async\s+)?onLoad\s*[(:]/;
116
- const found = findMethodBraceIndex(lines, onLoadRegex);
116
+ const found = findMethodBraceIndex(lines, onLoadRegex, { requireBrace: true });
117
117
  if (!found) {
118
118
  throw new GeneralError('Could not find "onLoad" method in browser-init.js.\n' +
119
119
  'FireForge was looking for a signature matching: \\b(?:async\\s+)?onLoad\\s*[(:]');
@@ -138,7 +138,10 @@ export function legacyAddInit(content, expression, after) {
138
138
  break;
139
139
  }
140
140
  }
141
- insertIndex = walkToTryBlockEnd(lines, tryStart);
141
+ insertIndex = walkToTryBlockEnd(lines, tryStart, {
142
+ strict: true,
143
+ context: BROWSER_INIT_JS,
144
+ });
142
145
  located = true;
143
146
  break;
144
147
  }
@@ -194,7 +197,10 @@ export async function addInitToBrowserInit(engineDir, expression, after) {
194
197
  if (initPattern.test(content)) {
195
198
  return false;
196
199
  }
197
- const { value } = withParserFallback(() => addInitAST(content, expression, after), () => legacyAddInit(content, expression, after), BROWSER_INIT_JS);
200
+ const { value, usedFallback } = withParserFallback(() => addInitAST(content, expression, after), () => legacyAddInit(content, expression, after), BROWSER_INIT_JS);
201
+ if (usedFallback) {
202
+ assertBraceBalancePreserved(content, value, BROWSER_INIT_JS);
203
+ }
198
204
  await writeText(filePath, value);
199
205
  return true;
200
206
  }
@@ -9,7 +9,7 @@ import { BuildError } from '../errors/build.js';
9
9
  import { pathExists, readText, writeText } from '../utils/fs.js';
10
10
  import { detectIndent, getNodeSource, parseScript, walkAST, } from './ast-utils.js';
11
11
  import { withParserFallback } from './parser-fallback.js';
12
- import { findNearestTryLine, validateWireName, walkToTryBlockEnd } from './wire-utils.js';
12
+ import { assertBraceBalancePreserved, findNearestTryLine, validateWireName, walkToTryBlockEnd, } from './wire-utils.js';
13
13
  const BROWSER_MAIN_JS = 'browser/base/content/browser-main.js';
14
14
  /**
15
15
  * AST-based implementation: finds the last try/catch containing
@@ -81,7 +81,10 @@ export function legacyAddSubscript(content, name) {
81
81
  let insertIndex;
82
82
  if (lastSubScriptLine !== -1) {
83
83
  const tryStart = findNearestTryLine(lines, lastSubScriptLine - 1, -1);
84
- insertIndex = tryStart !== -1 ? walkToTryBlockEnd(lines, tryStart) : lastSubScriptLine + 1;
84
+ insertIndex =
85
+ tryStart !== -1
86
+ ? walkToTryBlockEnd(lines, tryStart, { strict: true, context: BROWSER_MAIN_JS })
87
+ : lastSubScriptLine + 1;
85
88
  }
86
89
  else {
87
90
  insertIndex = lines.length;
@@ -127,7 +130,10 @@ export async function addSubscriptToBrowserMain(engineDir, name) {
127
130
  if (content.includes(`content/${name}.js"`)) {
128
131
  return false;
129
132
  }
130
- const { value } = withParserFallback(() => addSubscriptAST(content, name), () => legacyAddSubscript(content, name), BROWSER_MAIN_JS);
133
+ const { value, usedFallback } = withParserFallback(() => addSubscriptAST(content, name), () => legacyAddSubscript(content, name), BROWSER_MAIN_JS);
134
+ if (usedFallback) {
135
+ assertBraceBalancePreserved(content, value, BROWSER_MAIN_JS);
136
+ }
131
137
  await writeText(filePath, value);
132
138
  return true;
133
139
  }
@@ -51,9 +51,20 @@ export declare function tokenizeXhtml(lines: string[]): XhtmlToken[];
51
51
  * Finds the line index of a method signature matching `pattern`, then
52
52
  * advances to the line containing the opening brace.
53
53
  *
54
- * @returns `{ methodLine, braceIndex }`, or `null` if the pattern is not found.
54
+ * By default this helper is tolerant: when no `{` is found anywhere after
55
+ * the signature, it still returns `braceIndex: methodLine` — which is the
56
+ * correct answer when the signature and body brace live on the same line,
57
+ * but is ambiguous when the method is abstract or truncated. Opt into
58
+ * stricter behaviour by passing `requireBrace: true`; the function will
59
+ * return `null` instead of guessing, letting the caller surface a clean
60
+ * {@link ParserFallbackError} rather than inserting into a wrong offset.
61
+ *
62
+ * @returns `{ methodLine, braceIndex }`, or `null` if the pattern is not
63
+ * found (or, under `requireBrace`, no brace follows the signature).
55
64
  */
56
- export declare function findMethodBraceIndex(lines: string[], pattern: RegExp): {
65
+ export declare function findMethodBraceIndex(lines: string[], pattern: RegExp, options?: {
66
+ requireBrace?: boolean;
67
+ }): {
57
68
  methodLine: number;
58
69
  braceIndex: number;
59
70
  } | null;
@@ -61,10 +72,46 @@ export declare function findMethodBraceIndex(lines: string[], pattern: RegExp):
61
72
  * Starting from `startLine`, walks lines using {@link countBraceDepth}
62
73
  * until the brace depth returns to zero (i.e., the enclosing block closes).
63
74
  *
64
- * @returns The line index *after* the closing brace, or `startLine + 1` if
65
- * the block never closes (defensive).
75
+ * Default behaviour is defensive if the block never closes, the helper
76
+ * returns `startLine + 1` so a single malformed file does not stop the
77
+ * entire fallback path. Pass `{ strict: true }` to opt into failing loudly
78
+ * with a {@link ParserFallbackError} instead; new callers should prefer
79
+ * strict mode so silent mis-insertions surface as the fallback refusing
80
+ * to touch the file.
81
+ *
82
+ * @param lines - The full file split by newline
83
+ * @param startLine - Line index of the `try {` (or other block opener) to walk
84
+ * @param options - Pass `{ strict: true }` to throw when the block never closes
85
+ * @returns The line index *after* the closing brace
86
+ */
87
+ export declare function walkToTryBlockEnd(lines: string[], startLine: number, options?: {
88
+ strict?: boolean;
89
+ context?: string;
90
+ }): number;
91
+ /**
92
+ * Scans the entire file and returns the net brace balance so callers can
93
+ * assert that a legacy fallback mutation did not silently introduce or
94
+ * drop a `{` / `}`. The helper reuses {@link countBraceDepth} so strings,
95
+ * comments, and regex literals are handled consistently with the walker.
96
+ *
97
+ * @param content - Full file contents (will be split by newline)
98
+ * @returns The net depth across the file (`opens - closes`) and a
99
+ * convenience `balanced` flag equal to `depth === 0`.
100
+ */
101
+ export declare function computeFileBraceBalance(content: string): {
102
+ depth: number;
103
+ balanced: boolean;
104
+ };
105
+ /**
106
+ * Round-trip guard used after a legacy fallback mutation: if the file's
107
+ * net brace balance drifts between `before` and `after`, something went
108
+ * wrong and the fallback is refusing to write a corrupted file. Expects
109
+ * the delta to be exactly zero — wire fallbacks only insert whole
110
+ * try/catch blocks, which always contribute equal opens and closes.
111
+ *
112
+ * @throws {@link ParserFallbackError} when the balance delta is non-zero.
66
113
  */
67
- export declare function walkToTryBlockEnd(lines: string[], startLine: number): number;
114
+ export declare function assertBraceBalancePreserved(before: string, after: string, context: string): void;
68
115
  /**
69
116
  * Looks backward from `fromLine` (exclusive) to find the nearest `try {`
70
117
  * line. If nothing is found searching backward, also searches forward.
@@ -1,4 +1,4 @@
1
- import { GeneralError } from '../errors/base.js';
1
+ import { GeneralError, ParserFallbackError } from '../errors/base.js';
2
2
  import { walkAST } from './ast-utils.js';
3
3
  /**
4
4
  * Validates a name for safe interpolation into generated JavaScript string literals.
@@ -158,9 +158,18 @@ export function tokenizeXhtml(lines) {
158
158
  * Finds the line index of a method signature matching `pattern`, then
159
159
  * advances to the line containing the opening brace.
160
160
  *
161
- * @returns `{ methodLine, braceIndex }`, or `null` if the pattern is not found.
161
+ * By default this helper is tolerant: when no `{` is found anywhere after
162
+ * the signature, it still returns `braceIndex: methodLine` — which is the
163
+ * correct answer when the signature and body brace live on the same line,
164
+ * but is ambiguous when the method is abstract or truncated. Opt into
165
+ * stricter behaviour by passing `requireBrace: true`; the function will
166
+ * return `null` instead of guessing, letting the caller surface a clean
167
+ * {@link ParserFallbackError} rather than inserting into a wrong offset.
168
+ *
169
+ * @returns `{ methodLine, braceIndex }`, or `null` if the pattern is not
170
+ * found (or, under `requireBrace`, no brace follows the signature).
162
171
  */
163
- export function findMethodBraceIndex(lines, pattern) {
172
+ export function findMethodBraceIndex(lines, pattern, options) {
164
173
  let methodLine = -1;
165
174
  for (let i = 0; i < lines.length; i++) {
166
175
  if (pattern.test(lines[i] ?? '')) {
@@ -171,22 +180,36 @@ export function findMethodBraceIndex(lines, pattern) {
171
180
  if (methodLine === -1)
172
181
  return null;
173
182
  let braceIndex = methodLine;
183
+ let braceFound = false;
174
184
  for (let i = methodLine; i < lines.length; i++) {
175
185
  if (lines[i]?.includes('{')) {
176
186
  braceIndex = i;
187
+ braceFound = true;
177
188
  break;
178
189
  }
179
190
  }
191
+ if (!braceFound && options?.requireBrace) {
192
+ return null;
193
+ }
180
194
  return { methodLine, braceIndex };
181
195
  }
182
196
  /**
183
197
  * Starting from `startLine`, walks lines using {@link countBraceDepth}
184
198
  * until the brace depth returns to zero (i.e., the enclosing block closes).
185
199
  *
186
- * @returns The line index *after* the closing brace, or `startLine + 1` if
187
- * the block never closes (defensive).
200
+ * Default behaviour is defensive if the block never closes, the helper
201
+ * returns `startLine + 1` so a single malformed file does not stop the
202
+ * entire fallback path. Pass `{ strict: true }` to opt into failing loudly
203
+ * with a {@link ParserFallbackError} instead; new callers should prefer
204
+ * strict mode so silent mis-insertions surface as the fallback refusing
205
+ * to touch the file.
206
+ *
207
+ * @param lines - The full file split by newline
208
+ * @param startLine - Line index of the `try {` (or other block opener) to walk
209
+ * @param options - Pass `{ strict: true }` to throw when the block never closes
210
+ * @returns The line index *after* the closing brace
188
211
  */
189
- export function walkToTryBlockEnd(lines, startLine) {
212
+ export function walkToTryBlockEnd(lines, startLine, options) {
190
213
  let depth = 0;
191
214
  let inBlock = false;
192
215
  for (let j = startLine; j < lines.length; j++) {
@@ -197,8 +220,48 @@ export function walkToTryBlockEnd(lines, startLine) {
197
220
  return j + 1;
198
221
  }
199
222
  }
223
+ if (options?.strict) {
224
+ throw new ParserFallbackError(`Block starting at line ${startLine + 1} never closes — fallback parser refuses to insert`, options.context);
225
+ }
200
226
  return startLine + 1;
201
227
  }
228
+ /**
229
+ * Scans the entire file and returns the net brace balance so callers can
230
+ * assert that a legacy fallback mutation did not silently introduce or
231
+ * drop a `{` / `}`. The helper reuses {@link countBraceDepth} so strings,
232
+ * comments, and regex literals are handled consistently with the walker.
233
+ *
234
+ * @param content - Full file contents (will be split by newline)
235
+ * @returns The net depth across the file (`opens - closes`) and a
236
+ * convenience `balanced` flag equal to `depth === 0`.
237
+ */
238
+ export function computeFileBraceBalance(content) {
239
+ const lines = content.split('\n');
240
+ let depth = 0;
241
+ let inBlock = false;
242
+ for (const line of lines) {
243
+ const r = countBraceDepth(line, inBlock);
244
+ depth += r.depth;
245
+ inBlock = r.inBlockComment;
246
+ }
247
+ return { depth, balanced: depth === 0 };
248
+ }
249
+ /**
250
+ * Round-trip guard used after a legacy fallback mutation: if the file's
251
+ * net brace balance drifts between `before` and `after`, something went
252
+ * wrong and the fallback is refusing to write a corrupted file. Expects
253
+ * the delta to be exactly zero — wire fallbacks only insert whole
254
+ * try/catch blocks, which always contribute equal opens and closes.
255
+ *
256
+ * @throws {@link ParserFallbackError} when the balance delta is non-zero.
257
+ */
258
+ export function assertBraceBalancePreserved(before, after, context) {
259
+ const beforeDepth = computeFileBraceBalance(before).depth;
260
+ const afterDepth = computeFileBraceBalance(after).depth;
261
+ if (beforeDepth !== afterDepth) {
262
+ throw new ParserFallbackError(`Brace balance drifted from ${beforeDepth} to ${afterDepth} after fallback mutation; refusing to write`, context);
263
+ }
264
+ }
202
265
  /**
203
266
  * Looks backward from `fromLine` (exclusive) to find the nearest `try {`
204
267
  * line. If nothing is found searching backward, also searches forward.
@@ -25,6 +25,26 @@ export declare abstract class FireForgeError extends Error {
25
25
  export declare class GeneralError extends FireForgeError {
26
26
  readonly code: 1;
27
27
  }
28
+ /**
29
+ * Raised when a legacy regex/brace-depth fallback parser decides it
30
+ * cannot safely perform its mutation — e.g. because a block it expected
31
+ * to walk never closes, because the inserted result fails a round-trip
32
+ * brace balance check, or because an expected pattern is missing.
33
+ *
34
+ * Dedicated subclass (rather than raw GeneralError) so callers and tests
35
+ * can distinguish "the fallback refused to corrupt this file" from other
36
+ * failure modes, and so {@link withParserFallback} callers can opt into
37
+ * re-throwing fallback refusals instead of silently swallowing them.
38
+ */
39
+ export declare class ParserFallbackError extends FireForgeError {
40
+ /** Filename or logical context where the fallback ran (e.g. `browser-init.js`). */
41
+ readonly context?: string | undefined;
42
+ readonly code: 1;
43
+ constructor(message: string,
44
+ /** Filename or logical context where the fallback ran (e.g. `browser-init.js`). */
45
+ context?: string | undefined, cause?: unknown);
46
+ get userMessage(): string;
47
+ }
28
48
  /**
29
49
  * Error thrown when a command-line argument is invalid.
30
50
  */
@@ -36,6 +36,30 @@ export class FireForgeError extends Error {
36
36
  export class GeneralError extends FireForgeError {
37
37
  code = ExitCode.GENERAL_ERROR;
38
38
  }
39
+ /**
40
+ * Raised when a legacy regex/brace-depth fallback parser decides it
41
+ * cannot safely perform its mutation — e.g. because a block it expected
42
+ * to walk never closes, because the inserted result fails a round-trip
43
+ * brace balance check, or because an expected pattern is missing.
44
+ *
45
+ * Dedicated subclass (rather than raw GeneralError) so callers and tests
46
+ * can distinguish "the fallback refused to corrupt this file" from other
47
+ * failure modes, and so {@link withParserFallback} callers can opt into
48
+ * re-throwing fallback refusals instead of silently swallowing them.
49
+ */
50
+ export class ParserFallbackError extends FireForgeError {
51
+ context;
52
+ code = ExitCode.GENERAL_ERROR;
53
+ constructor(message,
54
+ /** Filename or logical context where the fallback ran (e.g. `browser-init.js`). */
55
+ context, cause) {
56
+ super(message, cause);
57
+ this.context = context;
58
+ }
59
+ get userMessage() {
60
+ return this.context ? `${this.message} (in ${this.context})` : this.message;
61
+ }
62
+ }
39
63
  /**
40
64
  * Error thrown when a command-line argument is invalid.
41
65
  */
@@ -15,7 +15,13 @@ export class FurnaceError extends FireForgeError {
15
15
  let msg = this.component
16
16
  ? `Furnace Error (${this.component}): ${this.message}`
17
17
  : `Furnace Error: ${this.message}`;
18
- msg += '\n\nRun "fireforge furnace validate" to diagnose issues.';
18
+ msg += '\n\nTo fix this:\n';
19
+ msg += ' 1. Check the error message above for specifics\n';
20
+ // Avoid circular advice when the error is thrown during validation itself.
21
+ if (!this.message.includes('furnace validate')) {
22
+ msg += ' 2. Run "fireforge furnace validate" to diagnose issues\n';
23
+ }
24
+ msg += ` ${this.message.includes('furnace validate') ? '2' : '3'}. Use "fireforge doctor --repair-furnace" if state is inconsistent`;
19
25
  return msg;
20
26
  }
21
27
  }
@@ -7,7 +7,12 @@ import { ExitCode } from './codes.js';
7
7
  export class RebaseError extends FireForgeError {
8
8
  code = ExitCode.PATCH_ERROR;
9
9
  get userMessage() {
10
- return `Rebase Error: ${this.message}`;
10
+ let msg = `Rebase Error: ${this.message}`;
11
+ msg += '\n\nTo fix this:\n';
12
+ msg += ' 1. Check the error message above for specifics\n';
13
+ msg += ' 2. Use "fireforge rebase --continue" to resume an interrupted rebase\n';
14
+ msg += ' 3. Use "fireforge rebase --abort" to cancel and restore engine state';
15
+ return msg;
11
16
  }
12
17
  }
13
18
  /**
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Re-exports all command-related types from focused sub-modules.
3
3
  */
4
- export type { BuildOptions, DiscardOptions, DoctorOptions, DownloadOptions, ExportOptions, FurnaceApplyOptions, FurnaceCreateOptions, FurnaceDeployOptions, FurnaceOverrideOptions, FurnacePreviewOptions, FurnaceRemoveOptions, GlobalOptions, ImportOptions, PackageOptions, RebaseOptions, ReExportOptions, RegisterOptions, ResetOptions, RunOptions, SetupOptions, StatusOptions, TestOptions, TokenAddOptions, WireOptions, } from './options.js';
4
+ export type { BuildOptions, DiscardOptions, DoctorOptions, DownloadOptions, ExportOptions, FurnaceApplyOptions, FurnaceCreateOptions, FurnaceDeployOptions, FurnaceOverrideOptions, FurnacePreviewOptions, FurnaceRefreshOptions, FurnaceRemoveOptions, FurnaceSyncOptions, FurnaceValidateOptions, GlobalOptions, ImportOptions, PackageOptions, PatchDeleteOptions, PatchReorderOptions, RebaseOptions, ReExportOptions, RegisterOptions, ResetOptions, RunOptions, SetupOptions, StatusOptions, TestOptions, TokenAddOptions, WireOptions, } from './options.js';
5
5
  export type { ImportSummary, PatchCategory, PatchesManifest, PatchInfo, PatchLintIssue, PatchMetadata, PatchResult, } from './patches.js';
6
6
  export type { DoctorCheck, ProjectStatus, TokenCoverageFileEntry, TokenCoverageReport, } from './project.js';