@hominis/fireforge 0.16.2 → 0.16.5

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 (57) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +15 -2
  3. package/dist/bin/fireforge.js +11 -2
  4. package/dist/src/commands/doctor-furnace.js +83 -1
  5. package/dist/src/commands/doctor.js +18 -0
  6. package/dist/src/commands/download.js +58 -12
  7. package/dist/src/commands/export-all.js +19 -2
  8. package/dist/src/commands/export-shared.d.ts +36 -0
  9. package/dist/src/commands/export-shared.js +76 -0
  10. package/dist/src/commands/export.js +23 -2
  11. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +21 -3
  12. package/dist/src/commands/furnace/chrome-doc-templates.js +23 -5
  13. package/dist/src/commands/furnace/chrome-doc-tests.js +42 -17
  14. package/dist/src/commands/furnace/create-readback.d.ts +23 -0
  15. package/dist/src/commands/furnace/create-readback.js +34 -0
  16. package/dist/src/commands/furnace/create-templates.d.ts +17 -7
  17. package/dist/src/commands/furnace/create-templates.js +85 -31
  18. package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
  19. package/dist/src/commands/furnace/create-xpcshell.js +1 -1
  20. package/dist/src/commands/furnace/create.js +2 -0
  21. package/dist/src/commands/furnace/preview.d.ts +12 -0
  22. package/dist/src/commands/furnace/preview.js +34 -2
  23. package/dist/src/commands/furnace/status.js +1 -1
  24. package/dist/src/commands/import.js +63 -11
  25. package/dist/src/commands/patch/delete.js +10 -1
  26. package/dist/src/commands/patch/index.js +10 -1
  27. package/dist/src/commands/re-export.js +79 -6
  28. package/dist/src/commands/resolve.js +15 -1
  29. package/dist/src/commands/run.js +27 -5
  30. package/dist/src/commands/setup-support.js +60 -7
  31. package/dist/src/commands/status.js +28 -1
  32. package/dist/src/commands/test.js +28 -5
  33. package/dist/src/commands/token-coverage.js +55 -1
  34. package/dist/src/commands/token.js +19 -2
  35. package/dist/src/commands/wire.js +22 -2
  36. package/dist/src/core/branding.d.ts +10 -0
  37. package/dist/src/core/branding.js +7 -9
  38. package/dist/src/core/build-prepare.js +8 -1
  39. package/dist/src/core/file-lock.js +49 -15
  40. package/dist/src/core/furnace-operation.d.ts +17 -0
  41. package/dist/src/core/furnace-operation.js +30 -1
  42. package/dist/src/core/furnace-validate-helpers.d.ts +33 -1
  43. package/dist/src/core/furnace-validate-helpers.js +53 -2
  44. package/dist/src/core/git.js +39 -10
  45. package/dist/src/core/mach-error-hints.js +16 -0
  46. package/dist/src/core/mach.js +15 -6
  47. package/dist/src/core/manifest-rules.js +16 -0
  48. package/dist/src/core/marionette-preflight.js +43 -12
  49. package/dist/src/core/patch-files.d.ts +12 -1
  50. package/dist/src/core/patch-files.js +14 -11
  51. package/dist/src/core/patch-lint.js +62 -11
  52. package/dist/src/core/wire-destroy.js +18 -5
  53. package/dist/src/core/wire-init.js +20 -5
  54. package/dist/src/core/wire-utils.d.ts +15 -0
  55. package/dist/src/core/wire-utils.js +17 -0
  56. package/dist/src/types/commands/options.d.ts +7 -0
  57. package/package.json +1 -1
@@ -196,7 +196,7 @@ export async function furnaceStatusCommand(projectRoot, name) {
196
196
  warn('Engine drift detected since last apply (reset/download/manual edit). Run `fireforge furnace apply` to re-deploy.');
197
197
  }
198
198
  }
199
- info('Tip: run `furnace status <name>` for detailed component info, or `furnace --help` for all subcommands.');
199
+ info('Tip: run `fireforge furnace status <name>` for detailed component info, or `fireforge furnace --help` for all subcommands.');
200
200
  outro('Status complete');
201
201
  }
202
202
  //# sourceMappingURL=status.js.map
@@ -165,6 +165,32 @@ async function checkEngineDrift(engineDir, baseCommit, forceImport) {
165
165
  }
166
166
  return true;
167
167
  }
168
+ /**
169
+ * Builds the set of patch filenames in scope when `--until <name>` is set.
170
+ * Accepts either the full filename (e.g. `001-foo.patch`) or the name
171
+ * without the `.patch` suffix (matching `applyPatchesWithContinue`'s
172
+ * `untilFilename` resolver).
173
+ *
174
+ * Returns an empty set when no match is found — the caller treats that as
175
+ * "no scope filter applies" so the import behaves identically to an
176
+ * unrecognised `--until` target (which `applyPatchesWithContinue` will
177
+ * later surface as a normal error).
178
+ */
179
+ function buildUntilFilenameSet(patches, until) {
180
+ const set = new Set();
181
+ if (until === undefined)
182
+ return set;
183
+ const normalized = until.endsWith('.patch') ? until : `${until}.patch`;
184
+ const target = patches.find((p) => p.filename === until || p.filename === normalized);
185
+ if (!target)
186
+ return set;
187
+ for (const patch of patches) {
188
+ if (patch.order <= target.order) {
189
+ set.add(patch.filename);
190
+ }
191
+ }
192
+ return set;
193
+ }
168
194
  /**
169
195
  * Runs the import command to apply patches.
170
196
  * @param projectRoot - Root directory of the project
@@ -200,20 +226,41 @@ export async function importCommand(projectRoot, options = {}) {
200
226
  outro('Import complete (no patches)');
201
227
  return;
202
228
  }
203
- info(`Found ${patchCount} patch${patchCount === 1 ? '' : 'es'} to apply`);
229
+ // Load manifest early so we can scope the integrity / consistency checks to
230
+ // the `--until` subset. The manifest-consistency check stays global because
231
+ // structural manifest corruption (missing / duplicate rows) should block any
232
+ // import regardless of scope, but per-patch integrity and files-affected
233
+ // issues are legitimately skippable when the operator has asked to stop at
234
+ // an earlier patch.
235
+ const manifest = await loadPatchesManifest(paths.patches);
236
+ const untilFilenameSet = buildUntilFilenameSet(manifest?.patches ?? [], options.until);
237
+ const scopedPatchCount = options.until !== undefined ? untilFilenameSet.size : patchCount;
238
+ info(`Found ${scopedPatchCount} patch${scopedPatchCount === 1 ? '' : 'es'} to apply${options.until !== undefined ? ` (up to ${options.until})` : ''}`);
204
239
  const manifestConsistencyIssues = await validatePatchesManifestConsistency(paths.patches);
205
- if (manifestConsistencyIssues.length > 0) {
206
- const issueSummary = manifestConsistencyIssues.map((issue) => issue.message).join('\n ');
240
+ const scopedManifestIssues = options.until !== undefined
241
+ ? manifestConsistencyIssues.filter((issue) =>
242
+ // Global (manifest-level) issues have no specific filename to scope
243
+ // against — a missing or unparseable patches.json blocks any
244
+ // import. Per-patch issues only block when the patch is in scope.
245
+ issue.code === 'manifest-missing' ||
246
+ issue.code === 'manifest-invalid' ||
247
+ untilFilenameSet.has(issue.filename))
248
+ : manifestConsistencyIssues;
249
+ if (scopedManifestIssues.length > 0) {
250
+ const issueSummary = scopedManifestIssues.map((issue) => issue.message).join('\n ');
207
251
  throw new GeneralError('Patch manifest consistency check failed. Repair patches/patches.json before importing.\n' +
208
252
  ` ${issueSummary}\n\n` +
209
253
  'Run "fireforge doctor --repair-patches-manifest" to rebuild the manifest from on-disk patch files.');
210
254
  }
211
- // Load manifest and check version compatibility
212
- const manifest = await loadPatchesManifest(paths.patches);
255
+ // Version compatibility warnings (advisory only)
213
256
  if (manifest) {
214
257
  const config = await loadConfig(projectRoot);
215
258
  const currentVersion = config.firefox.version;
216
259
  for (const patch of manifest.patches) {
260
+ // Scope the advisory warnings too: an operator running with --until
261
+ // doesn't need to see version warnings for patches outside the range.
262
+ if (options.until !== undefined && !untilFilenameSet.has(patch.filename))
263
+ continue;
217
264
  const warning = checkVersionCompatibility(patch.sourceEsrVersion, currentVersion);
218
265
  if (warning) {
219
266
  warn(`${patch.filename}: ${warning}`);
@@ -225,7 +272,15 @@ export async function importCommand(projectRoot, options = {}) {
225
272
  // warn-and-continue behaviour hid the real root cause because import
226
273
  // would later fail during patch application with a secondary, unrelated
227
274
  // error that made diagnosis harder.
228
- const integrityIssues = await validatePatchIntegrity(paths.patches, paths.engine);
275
+ //
276
+ // Scope the surfaced issues to the `--until` range: a later patch with
277
+ // integrity problems should not block importing an earlier good subset,
278
+ // which is exactly what operators reach for when the tail of the queue
279
+ // is broken and they want to keep working against an earlier checkpoint.
280
+ const allIntegrityIssues = await validatePatchIntegrity(paths.patches, paths.engine);
281
+ const integrityIssues = options.until !== undefined
282
+ ? allIntegrityIssues.filter((issue) => untilFilenameSet.has(issue.filename))
283
+ : allIntegrityIssues;
229
284
  if (integrityIssues.length > 0) {
230
285
  warn('\nPatch integrity issues detected:');
231
286
  for (const issue of integrityIssues) {
@@ -253,11 +308,8 @@ export async function importCommand(projectRoot, options = {}) {
253
308
  // Dry-run: list patches that would be applied and exit
254
309
  if (isDryRun) {
255
310
  if (manifest) {
256
- const patches = options.until
257
- ? manifest.patches.filter((p) => {
258
- const untilPatch = manifest.patches.find((u) => u.filename === options.until || u.filename === `${options.until}.patch`);
259
- return untilPatch ? p.order <= untilPatch.order : true;
260
- })
311
+ const patches = options.until !== undefined
312
+ ? manifest.patches.filter((p) => untilFilenameSet.has(p.filename))
261
313
  : manifest.patches;
262
314
  info(`\n[dry-run] Would apply ${patches.length} patch(es) in order:`);
263
315
  for (const patch of patches) {
@@ -114,7 +114,16 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
114
114
  }
115
115
  const conflicts = dependents.length > 0
116
116
  ? {
117
- reason: `${dependents.length} later patch(es) depend on files created by ${target.filename}`,
117
+ // Wording deliberately clarifies the *runtime* impact: `git apply`
118
+ // doesn't resolve imports and will succeed even when a later patch
119
+ // imports a file the target created (the eval observed this
120
+ // directly — forcing the delete and re-importing the remaining
121
+ // 20-patch queue was clean). The breakage surfaces at browser
122
+ // startup when `ChromeUtils.importESModule` can't locate the
123
+ // deleted module. Operators who deliberately plan to re-introduce
124
+ // the imported files (rename, refactor) need to know this is the
125
+ // impact model, not a patch-application failure.
126
+ reason: `${dependents.length} later patch(es) contain import statements that reference files created by ${target.filename}. Patch application itself will still succeed, but runtime imports will fail at browser startup until those files are re-introduced.`,
118
127
  details: dependents,
119
128
  }
120
129
  : null;
@@ -20,7 +20,16 @@ export { patchReorderCommand } from './reorder.js';
20
20
  export function registerPatch(program, context) {
21
21
  const patch = program
22
22
  .command('patch')
23
- .description('Manage individual patches in the queue (compact, delete, reorder)');
23
+ .description('Manage individual patches in the queue (compact, delete, reorder)')
24
+ // Match `fireforge furnace`'s no-args contract: print the group's help and
25
+ // exit 0. Without this default action, commander routes `fireforge patch`
26
+ // (no subcommand) through its own help-then-exit-1 path, so scripts that
27
+ // probe the CLI surface see a misleading non-zero exit for a purely
28
+ // informational invocation. The action prints the exact same help commander
29
+ // would otherwise print, but returns successfully.
30
+ .action(() => {
31
+ patch.outputHelp();
32
+ });
24
33
  registerPatchCompact(patch, context);
25
34
  registerPatchDelete(patch, context);
26
35
  registerPatchReorder(patch, context);
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { dirname, join } from 'node:path';
3
- import { multiselect } from '@clack/prompts';
3
+ import { confirm, multiselect } from '@clack/prompts';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { isGitRepository } from '../core/git.js';
6
6
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
@@ -11,6 +11,14 @@ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
11
11
  import { toError } from '../utils/errors.js';
12
12
  import { pathExists } from '../utils/fs.js';
13
13
  import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
14
+ /**
15
+ * Threshold above which `--scan` must be explicitly confirmed. Values were
16
+ * picked so the common "refresh after one-or-two-file tweak" case stays
17
+ * frictionless while catching the eval finding #13 scenario where `--scan`
18
+ * silently pulled in an entire sibling feature (xhtml + tests + theme CSS).
19
+ */
20
+ const SCAN_ADD_COUNT_THRESHOLD = 3;
21
+ const SCAN_DIR_COUNT_THRESHOLD = 2;
14
22
  import { pickDefined } from '../utils/options.js';
15
23
  import { runPatchLint } from './export-shared.js';
16
24
  import { reExportFilesInPlace } from './re-export-files.js';
@@ -39,25 +47,90 @@ async function scanPatchFiles(currentFilesAffected, engineDir, manifest, patchFi
39
47
  removed.push(f);
40
48
  }
41
49
  }
42
- for (const f of added.sort()) {
50
+ const sortedAdded = [...added].sort();
51
+ const sortedRemoved = [...removed].sort();
52
+ for (const f of sortedAdded) {
43
53
  info(` + ${f}`);
44
54
  }
45
- for (const f of removed.sort()) {
55
+ for (const f of sortedRemoved) {
46
56
  info(` - ${f}`);
47
57
  }
48
58
  if (added.length > 0 || removed.length > 0) {
49
59
  const removedSet = new Set(removed);
50
60
  const updated = [...currentFilesAffected.filter((f) => !removedSet.has(f)), ...added].sort();
51
61
  info(` ${isDryRun ? 'Would update' : 'Updated'} ${patchFilename}: +${added.length} / -${removed.length} files`);
52
- return updated;
62
+ return { updated, added: sortedAdded, removed: sortedRemoved };
53
63
  }
54
- return currentFilesAffected;
64
+ return { updated: currentFilesAffected, added: [], removed: [] };
65
+ }
66
+ /**
67
+ * Returns true when the caller-confirmed threshold is exceeded for this
68
+ * scan's additions. The heuristic treats "small, same-directory" additions
69
+ * as friction-free (the common refresh case) and flags larger or
70
+ * multi-directory expansions so operators see them before they land.
71
+ *
72
+ * Pre-0.16.0 `--scan` silently broadened patches to include any modified or
73
+ * untracked file that shared a parent directory with the existing
74
+ * filesAffected — in practice, pulling adjacent feature code into a patch
75
+ * that had nothing to do with it. The gate below turns the broadening into
76
+ * an explicit opt-in.
77
+ */
78
+ function scanAdditionsNeedConfirmation(added) {
79
+ if (added.length === 0)
80
+ return false;
81
+ if (added.length > SCAN_ADD_COUNT_THRESHOLD)
82
+ return true;
83
+ const dirs = new Set(added.map((f) => dirname(f)));
84
+ return dirs.size >= SCAN_DIR_COUNT_THRESHOLD;
85
+ }
86
+ /**
87
+ * Gate for broad `--scan` additions. Enforces explicit acknowledgement when
88
+ * the scan would pull in more files than a narrow refresh. Dry-run always
89
+ * proceeds (the preview is the whole point).
90
+ *
91
+ * @returns true if the caller should proceed; false if the user cancelled.
92
+ */
93
+ async function confirmBroadScanAdditions(args) {
94
+ const { patchFilename, added, isDryRun, yes, isInteractive } = args;
95
+ if (isDryRun)
96
+ return true;
97
+ if (!scanAdditionsNeedConfirmation(added))
98
+ return true;
99
+ if (yes)
100
+ return true;
101
+ warn(`${patchFilename}: --scan would add ${String(added.length)} file(s) that span ${String(new Set(added.map((f) => dirname(f))).size)} director${new Set(added.map((f) => dirname(f))).size === 1 ? 'y' : 'ies'}. ` +
102
+ 'Broad scans can silently pull adjacent features into a patch — review the diff before continuing.');
103
+ if (!isInteractive) {
104
+ throw new GeneralError(`Refusing to broaden "${patchFilename}" via --scan in non-interactive mode. ` +
105
+ 'Pass --yes to acknowledge the expansion, or run with --dry-run first to review.');
106
+ }
107
+ const confirmed = await confirm({
108
+ message: `Proceed and broaden ${patchFilename} with ${String(added.length)} newly discovered file(s)?`,
109
+ initialValue: false,
110
+ });
111
+ if (isCancel(confirmed) || !confirmed) {
112
+ cancel(`Skipped ${patchFilename}`);
113
+ return false;
114
+ }
115
+ return true;
55
116
  }
56
117
  async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, config) {
57
118
  let currentFilesAffected = [...patch.filesAffected];
58
119
  // --- Scan for new/removed files ---
59
120
  if (options.scan) {
60
- currentFilesAffected = await scanPatchFiles(currentFilesAffected, paths.engine, manifest, patch.filename, isDryRun);
121
+ const scanResult = await scanPatchFiles(currentFilesAffected, paths.engine, manifest, patch.filename, isDryRun);
122
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
123
+ const proceed = await confirmBroadScanAdditions({
124
+ patchFilename: patch.filename,
125
+ added: scanResult.added,
126
+ isDryRun,
127
+ yes: options.yes === true,
128
+ isInteractive,
129
+ });
130
+ if (!proceed) {
131
+ return false;
132
+ }
133
+ currentFilesAffected = scanResult.updated;
61
134
  }
62
135
  else if (options.files === undefined) {
63
136
  // Finding #16: when neither `--scan` nor `--files` is set and some
@@ -5,6 +5,7 @@ import { getProjectPaths, loadConfig, loadState, updateState } from '../core/con
5
5
  import { isGitRepository } from '../core/git.js';
6
6
  import { getStagedDiffForFiles } from '../core/git-diff.js';
7
7
  import { stageFiles, unstageFiles } from '../core/git-file-ops.js';
8
+ import { extractAffectedFiles } from '../core/patch-apply.js';
8
9
  import { updatePatchAndMetadata } from '../core/patch-export.js';
9
10
  import { loadPatchesManifest } from '../core/patch-manifest.js';
10
11
  import { GeneralError, ResolutionError } from '../errors/base.js';
@@ -106,9 +107,22 @@ export async function resolveCommand(projectRoot) {
106
107
  // import / export / re-export / patch reorder / patch compact could
107
108
  // interleave with and leave the manifest disagreeing with the
108
109
  // freshly-written patch body.
110
+ //
111
+ // Always recompute `filesAffected` from the diff content itself. The
112
+ // eval finding #16 scenario: the user's manual fix removed every
113
+ // hunk for one file while the file still existed on disk, so the
114
+ // pre-0.16.0 gate of "update filesAffected only when files were
115
+ // deleted from disk" left the manifest claiming a file the patch
116
+ // body no longer targeted. The next `fireforge import` then failed
117
+ // the patch-manifest consistency check even though resolve reported
118
+ // success. `extractAffectedFiles` already owns the canonical
119
+ // "parse a diff, return its target paths" logic used by export and
120
+ // consistency — using it here keeps resolve in agreement with every
121
+ // other writer.
122
+ const diffFilesAffected = extractAffectedFiles(diffContent);
109
123
  const config = await loadConfig(projectRoot);
110
124
  await updatePatchAndMetadata(paths.patches, patchFilename, diffContent, {
111
- ...(activeFiles.length < existingFiles.length ? { filesAffected: activeFiles } : {}),
125
+ filesAffected: diffFilesAffected,
112
126
  sourceEsrVersion: config.firefox.version,
113
127
  });
114
128
  // Cleanup: Clear pendingResolution from state.json transactionally so
@@ -183,17 +183,31 @@ async function runSmokeExit(engineDir, options) {
183
183
  warn(`--capture-console stream error: ${err.message}`);
184
184
  });
185
185
  const findings = [];
186
- let allowlistedHits = 0;
186
+ let allowlistedErrorHits = 0;
187
+ let allowlistedTotalHits = 0;
187
188
  const handleLine = (stream, line) => {
188
189
  // Mirror raw output to the terminal so operators watching the smoke
189
190
  // run still see what the browser is printing. Stream selection on the
190
191
  // mirror preserves stdout/stderr separation for downstream piping.
191
192
  const sink = stream === 'stdout' ? process.stdout : process.stderr;
192
193
  sink.write(`${line}\n`);
194
+ // Count allowlist hits up-front, regardless of error-pattern match.
195
+ // Pre-0.16.0 the counter only incremented when the line ALSO matched
196
+ // an error pattern — so an allowlist regex that visibly matched
197
+ // `console.warn: RSLoader:` still reported 0 hits because
198
+ // `console.warn:` is not a smoke error class, confusing operators
199
+ // who were tuning their allowlist. We now surface two numbers: the
200
+ // total set of allowlisted lines (what the operator sees in the
201
+ // console) and the subset that were error-class (what the smoke
202
+ // exit contract cares about). The exit contract itself is unchanged.
203
+ const isAllowlisted = allowlist.length > 0 && matchesAllowlist(line, allowlist);
204
+ if (isAllowlisted) {
205
+ allowlistedTotalHits += 1;
206
+ }
193
207
  if (!matchesSmokeError(line))
194
208
  return;
195
- if (matchesAllowlist(line, allowlist)) {
196
- allowlistedHits += 1;
209
+ if (isAllowlisted) {
210
+ allowlistedErrorHits += 1;
197
211
  return;
198
212
  }
199
213
  findings.push({ stream, line });
@@ -221,7 +235,8 @@ async function runSmokeExit(engineDir, options) {
221
235
  smokeTimeoutMs,
222
236
  elapsedMs,
223
237
  timedOut: result.timedOut,
224
- allowlistedHits,
238
+ allowlistedErrorHits,
239
+ allowlistedTotalHits,
225
240
  findings,
226
241
  exitCode: result.exitCode,
227
242
  });
@@ -278,7 +293,14 @@ function reportSmokeSummary(args) {
278
293
  info('');
279
294
  info(`Smoke run complete: ${seconds}s elapsed of ${windowSeconds}s window${suffix}`);
280
295
  info(` Unallowed errors: ${String(args.findings.length)}`);
281
- info(` Allowlisted hits: ${String(args.allowlistedHits)}`);
296
+ // The "suppressed errors" count is what the exit contract cares about —
297
+ // it is the subset of allowlisted hits that would otherwise have been
298
+ // tallied as findings. The "all allowlisted lines" count answers the
299
+ // operator's mental model ("my --console-allow pattern matched N
300
+ // console lines"), which pre-0.16.0 was missing and led to 0-hit
301
+ // reports on visibly matching regexes.
302
+ info(` Allowlisted error hits (suppressed): ${String(args.allowlistedErrorHits)}`);
303
+ info(` Allowlisted lines total: ${String(args.allowlistedTotalHits)}`);
282
304
  info(` Child exit code: ${String(args.exitCode)}`);
283
305
  if (args.findings.length === 0)
284
306
  return;
@@ -263,6 +263,59 @@ export function buildSetupConfig(inputs) {
263
263
  },
264
264
  };
265
265
  }
266
+ /**
267
+ * Creates or updates the root `package.json` so its `license` field matches
268
+ * the project license selected during setup. When the file already exists we
269
+ * ONLY touch the `license` field — preserving `name`, `description`,
270
+ * `dependencies`, `scripts`, and every other author-editorial field the
271
+ * operator may have added. Without this sync, a `fireforge setup --force`
272
+ * that picked a new license left the old license in `package.json`, which
273
+ * then disagreed with `fireforge.json` (the motivating eval finding:
274
+ * setup rewrote fireforge.json but left the original package.json
275
+ * untouched, so the two files described different projects).
276
+ *
277
+ * Preserves the file's trailing newline state so a hand-edited
278
+ * `package.json` with a specific EOL convention is not silently
279
+ * re-normalised.
280
+ */
281
+ async function syncRootPackageJson(projectRoot, license) {
282
+ const rootPackageJsonPath = join(projectRoot, 'package.json');
283
+ if (!(await pathExists(rootPackageJsonPath))) {
284
+ const rootPackageJson = {
285
+ private: true,
286
+ license,
287
+ };
288
+ await writeText(rootPackageJsonPath, JSON.stringify(rootPackageJson, null, 2) + '\n');
289
+ return;
290
+ }
291
+ const raw = await readText(rootPackageJsonPath);
292
+ let parsed;
293
+ try {
294
+ parsed = JSON.parse(raw);
295
+ }
296
+ catch {
297
+ // Malformed package.json is the operator's editorial responsibility to
298
+ // repair; rewriting it would risk clobbering hand-authored content that
299
+ // the parser happens to reject. Leave the file alone and rely on the
300
+ // doctor / lint paths that already surface invalid JSON.
301
+ return;
302
+ }
303
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
304
+ return;
305
+ }
306
+ // Treat the object as a typed shape with only the one field we modify.
307
+ // Keeping it narrowly typed rather than `Record<string, unknown>` avoids
308
+ // eslint's `dot-notation` / `noPropertyAccessFromIndexSignature`
309
+ // friction, and the rest of the package.json body is preserved via
310
+ // object spread at the write site so we don't lose author-editorial
311
+ // fields.
312
+ const packageJson = parsed;
313
+ if (packageJson.license === license) {
314
+ return;
315
+ }
316
+ const trailingNewline = raw.endsWith('\n') ? '\n' : '';
317
+ await writeText(rootPackageJsonPath, JSON.stringify({ ...packageJson, license }, null, 2) + trailingNewline);
318
+ }
266
319
  /** Writes the initial project files produced by the setup workflow. */
267
320
  export async function writeSetupProjectFiles(projectRoot, config) {
268
321
  const paths = getProjectPaths(projectRoot);
@@ -291,13 +344,13 @@ export async function writeSetupProjectFiles(projectRoot, config) {
291
344
  else {
292
345
  await writeText(gitignorePath, requiredIgnores.join('\n') + '\n');
293
346
  }
294
- const rootPackageJsonPath = join(projectRoot, 'package.json');
295
- if (!(await pathExists(rootPackageJsonPath))) {
296
- const rootPackageJson = {
297
- private: true,
298
- license: config.license,
299
- };
300
- await writeText(rootPackageJsonPath, JSON.stringify(rootPackageJson, null, 2) + '\n');
347
+ // FireForgeConfig types license as optional, but `buildSetupConfig` always
348
+ // fills it from the resolved setup inputs (which default to `EUPL-1.2`).
349
+ // Narrow explicitly so the helper takes a concrete license rather than
350
+ // widening its own signature for a field that is always set at this call
351
+ // site.
352
+ if (config.license !== undefined) {
353
+ await syncRootPackageJson(projectRoot, config.license);
301
354
  }
302
355
  const templatesDir = getTemplatesDir();
303
356
  if (config.license !== undefined) {
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { isBrandingManagedPath } from '../core/branding.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
6
- import { getStatusWithCodes, isGitRepository } from '../core/git.js';
6
+ import { getHead, getStatusWithCodes, isGitRepository, isMissingHeadError } from '../core/git.js';
7
7
  import { getUntrackedFilesInDir } from '../core/git-status.js';
8
8
  import { isFileRegistered, matchesRegistrablePattern } from '../core/manifest-rules.js';
9
9
  import { buildOwnershipTable, renderOwnershipTable } from '../core/ownership-table.js';
@@ -262,6 +262,32 @@ async function renderJsonStatus(files, paths, projectRoot, binaryName) {
262
262
  }));
263
263
  process.stdout.write(JSON.stringify(output, null, 2) + '\n');
264
264
  }
265
+ /**
266
+ * Detects the "unborn HEAD" aftermath of an interrupted `fireforge download`
267
+ * — git init succeeded but the initial Firefox source commit was never
268
+ * created, so every file in engine/ reads as untracked. On a ~600 MB
269
+ * Firefox tree this would flood the output with hundreds of thousands of
270
+ * entries and a truncation warning, which is technically correct but not
271
+ * actionable. Throws a `GeneralError` with a single recovery banner
272
+ * pointing at `fireforge download --force`. `raw` / `json` modes skip the
273
+ * banner so their consumers see the structural failure in error form
274
+ * only.
275
+ */
276
+ async function assertEngineHasBaselineCommit(engineDir, options) {
277
+ try {
278
+ await getHead(engineDir);
279
+ }
280
+ catch (err) {
281
+ if (!isMissingHeadError(err))
282
+ throw err;
283
+ const guidance = 'Engine repository has no baseline commit yet — a previous "fireforge download" was interrupted before git created the initial Firefox source commit. Re-run "fireforge download --force" to recreate the baseline repository cleanly.';
284
+ if (!options.raw && !options.json) {
285
+ warn(guidance);
286
+ outro('Engine baseline missing — re-run download --force');
287
+ }
288
+ throw new GeneralError(guidance);
289
+ }
290
+ }
265
291
  /**
266
292
  * Runs the status command to show modified files.
267
293
  * @param projectRoot - Root directory of the project
@@ -331,6 +357,7 @@ export async function statusCommand(projectRoot, options = {}) {
331
357
  if (!(await isGitRepository(paths.engine))) {
332
358
  throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
333
359
  }
360
+ await assertEngineHasBaselineCommit(paths.engine, options);
334
361
  const rawFiles = await getStatusWithCodes(paths.engine);
335
362
  const { entries: expanded, truncations } = await expandDirectoryEntries(rawFiles, paths.engine);
336
363
  // Strip atomic-write temp files (Finding #18) before every mode
@@ -9,7 +9,7 @@ import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xp
9
9
  import { GeneralError } from '../errors/base.js';
10
10
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
11
11
  import { pathExists } from '../utils/fs.js';
12
- import { info, intro, spinner, warn } from '../utils/logger.js';
12
+ import { info, intro, outro, spinner, warn } from '../utils/logger.js';
13
13
  import { pickDefined } from '../utils/options.js';
14
14
  import { stripEnginePrefix } from '../utils/paths.js';
15
15
  async function assertTestPathsExist(engineDir, testPaths) {
@@ -36,8 +36,14 @@ function buildStaleBuildMessage() {
36
36
  'Re-run "fireforge build --ui" or "fireforge test --build" and then retry.');
37
37
  }
38
38
  function hasStaleBuildArtifactsSignal(output) {
39
+ // Deliberately narrow: only fire on branding-specific resource paths
40
+ // that are always a stale-artifact symptom. The earlier pattern also
41
+ // matched `resource:///modules/distribution.sys.mjs`, which surfaced on
42
+ // real packaging / module-resolution failures too (e.g. a fork's
43
+ // `HominisStore.sys.mjs` missing from the installed app dir after a
44
+ // successful build). That false-positive pushed operators toward
45
+ // "rebuild" advice for what was actually a module-registration issue.
39
46
  return (/chrome:\/\/branding\/locale\/brand\.properties/i.test(output) ||
40
- /resource:\/\/\/modules\/distribution\.sys\.mjs/i.test(output) ||
41
47
  /browser\/branding\/[^/\s]+\/moz\.build/i.test(output));
42
48
  }
43
49
  // Detects the broader xpcshell symptom where every `resource:///modules/...`
@@ -87,15 +93,25 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
87
93
  if (/UNKNOWN TEST\b/i.test(combinedOutput)) {
88
94
  throw new GeneralError(buildUnknownTestMessage(normalizedPaths));
89
95
  }
96
+ // Branding-specific stale-build signals keep priority over the broader
97
+ // xpcshell-appdir hint: when `chrome://branding/locale/brand.properties`
98
+ // fails to resolve, the fix really is "rebuild", not "pass --app-path".
99
+ // But the stale-build check is now narrower — it no longer matches
100
+ // `resource:///modules/distribution.sys.mjs` alone, which was producing
101
+ // false-positive rebuild advice on fork-custom module-load failures
102
+ // (the eval saw this for `HominisStore.sys.mjs`). Cases that once
103
+ // landed on `distribution.sys.mjs` fall through to xpcshell-appdir,
104
+ // which is the more useful diagnosis in practice for `Failed to load
105
+ // resource:///modules/…`.
90
106
  if (hasStaleBuildArtifactsSignal(combinedOutput)) {
91
107
  throw new GeneralError(buildStaleBuildMessage());
92
108
  }
93
- if (hasMochitestHttp3ServerSignal(combinedOutput)) {
94
- throw new GeneralError(buildMochitestHttp3ServerMessage());
95
- }
96
109
  if (hasXpcshellAppdirSignal(combinedOutput)) {
97
110
  throw new GeneralError(buildXpcshellAppdirMessage(appdirInjectionAttempted));
98
111
  }
112
+ if (hasMochitestHttp3ServerSignal(combinedOutput)) {
113
+ throw new GeneralError(buildMochitestHttp3ServerMessage());
114
+ }
99
115
  if (/invalid filename/i.test(combinedOutput) ||
100
116
  /chrome:\/\/mochitests.*not found/i.test(combinedOutput)) {
101
117
  info('Hint: The test file may not be registered in browser.toml or jar.mn.');
@@ -171,6 +187,13 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
171
187
  if (!preflight.ok) {
172
188
  throw new GeneralError('Marionette preflight reported FAIL — see output above.');
173
189
  }
190
+ // Close the intro frame explicitly. Without an outro, clack's
191
+ // grouped-output mode left the PASS line hanging inside an
192
+ // unclosed tree — in the eval's non-TTY capture the info line
193
+ // itself failed to render, so `test --doctor` looked like it had
194
+ // exited silently after the spinner start line. The outro also
195
+ // gives scripts a deterministic "done" marker to parse.
196
+ outro(`Marionette preflight: PASS (${preflight.durationMs}ms)`);
174
197
  return;
175
198
  }
176
199
  if (!preflight.ok) {
@@ -1,5 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { join } from 'node:path';
2
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
4
+ import { furnaceConfigExists, loadFurnaceConfig } from '../core/furnace-config.js';
3
5
  import { getStatusWithCodes, isGitRepository } from '../core/git.js';
4
6
  import { measureTokenCoverage } from '../core/token-coverage.js';
5
7
  import { getTokensCssPath } from '../core/token-manager.js';
@@ -22,9 +24,19 @@ export async function tokenCoverageCommand(projectRoot) {
22
24
  const config = await loadConfig(projectRoot);
23
25
  const tokensCssPath = getTokensCssPath(config.binaryName);
24
26
  const files = await getStatusWithCodes(paths.engine);
25
- const cssFiles = files
27
+ const statusCssFiles = files
26
28
  .filter((f) => f.file.endsWith('.css') && f.file !== tokensCssPath)
27
29
  .map((f) => f.file);
30
+ // Also scan CSS files deployed by Furnace custom components. Deployed
31
+ // files can be committed (and therefore absent from `git status`) while
32
+ // still being the primary surface where token adoption matters. Before
33
+ // 0.16.0, coverage only looked at modified files, which silently
34
+ // undercounted projects where Furnace writes many component-CSS files
35
+ // into the engine and they are already tracked.
36
+ const furnaceCssFiles = await collectFurnaceCustomCssFiles(projectRoot, paths.engine, tokensCssPath);
37
+ // De-dupe so a file that is both a custom deploy target AND modified is
38
+ // scanned exactly once.
39
+ const cssFiles = [...new Set([...statusCssFiles, ...furnaceCssFiles])];
28
40
  if (cssFiles.length === 0) {
29
41
  info('No modified CSS files');
30
42
  outro('Nothing to measure');
@@ -54,4 +66,46 @@ export async function tokenCoverageCommand(projectRoot) {
54
66
  }
55
67
  outro(`${report.filesScanned} CSS file${report.filesScanned === 1 ? '' : 's'} scanned`);
56
68
  }
69
+ /**
70
+ * Returns engine-relative `.css` paths deployed by every Furnace custom
71
+ * component registered in `furnace.json`. Only files that actually exist
72
+ * on disk are included — a component whose deploy target is missing (e.g.
73
+ * `furnace apply` has not run yet) is skipped silently so a fresh
74
+ * `furnace init` followed immediately by `token coverage` does not error.
75
+ *
76
+ * Returns an empty array when the project has no furnace.json, no custom
77
+ * components, or when loading the config fails (a warn is emitted in the
78
+ * last case so the user can diagnose a broken furnace.json without losing
79
+ * coverage results on the non-furnace CSS files).
80
+ */
81
+ async function collectFurnaceCustomCssFiles(projectRoot, engineDir, tokensCssPath) {
82
+ if (!(await furnaceConfigExists(projectRoot))) {
83
+ return [];
84
+ }
85
+ let furnaceConfig;
86
+ try {
87
+ furnaceConfig = await loadFurnaceConfig(projectRoot);
88
+ }
89
+ catch (error) {
90
+ warn(`Could not load furnace.json for token coverage — scanning modified files only (${error.message})`);
91
+ return [];
92
+ }
93
+ const results = [];
94
+ for (const [componentName, customConfig] of Object.entries(furnaceConfig.custom)) {
95
+ // Upstream Firefox widget layout: every component lives at
96
+ // `toolkit/content/widgets/<tagName>/` and ships at least
97
+ // `<tagName>.css`. `targetPath` already resolves to that directory
98
+ // (the create command writes `toolkit/content/widgets/<name>` into
99
+ // furnace.json) so we can probe the default layout directly without
100
+ // walking the whole tree.
101
+ const candidate = `${customConfig.targetPath}/${componentName}.css`;
102
+ if (candidate === tokensCssPath)
103
+ continue;
104
+ const absolutePath = join(engineDir, candidate);
105
+ if (await pathExists(absolutePath)) {
106
+ results.push(candidate);
107
+ }
108
+ }
109
+ return results;
110
+ }
57
111
  //# sourceMappingURL=token-coverage.js.map