@hominis/fireforge 0.18.0 → 0.18.2

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 (52) hide show
  1. package/CHANGELOG.md +18 -2
  2. package/README.md +55 -34
  3. package/dist/src/commands/doctor.js +13 -1
  4. package/dist/src/commands/export-all.js +63 -1
  5. package/dist/src/commands/export-flow.d.ts +4 -0
  6. package/dist/src/commands/export-flow.js +8 -0
  7. package/dist/src/commands/export.js +26 -2
  8. package/dist/src/commands/furnace/create-xpcshell.js +4 -2
  9. package/dist/src/commands/furnace/preview.js +38 -0
  10. package/dist/src/commands/furnace/remove.js +67 -1
  11. package/dist/src/commands/furnace/rename-xpcshell.d.ts +35 -0
  12. package/dist/src/commands/furnace/rename-xpcshell.js +97 -0
  13. package/dist/src/commands/furnace/rename.js +9 -0
  14. package/dist/src/commands/patch/index.d.ts +5 -3
  15. package/dist/src/commands/patch/index.js +10 -4
  16. package/dist/src/commands/patch/lint-ignore.d.ts +39 -0
  17. package/dist/src/commands/patch/lint-ignore.js +200 -0
  18. package/dist/src/commands/patch/tier.d.ts +34 -0
  19. package/dist/src/commands/patch/tier.js +134 -0
  20. package/dist/src/commands/re-export-files.js +88 -45
  21. package/dist/src/commands/re-export.js +49 -6
  22. package/dist/src/commands/rebase/index.js +19 -1
  23. package/dist/src/commands/status.js +44 -5
  24. package/dist/src/commands/test.js +27 -16
  25. package/dist/src/commands/verify.js +81 -6
  26. package/dist/src/commands/watch.js +43 -7
  27. package/dist/src/core/furnace-constants.d.ts +14 -0
  28. package/dist/src/core/furnace-constants.js +16 -0
  29. package/dist/src/core/furnace-validate.js +67 -1
  30. package/dist/src/core/git-base.d.ts +27 -2
  31. package/dist/src/core/git-base.js +41 -3
  32. package/dist/src/core/git-diff.js +34 -2
  33. package/dist/src/core/git.js +53 -14
  34. package/dist/src/core/mach.d.ts +14 -2
  35. package/dist/src/core/mach.js +12 -2
  36. package/dist/src/core/marionette-preflight.d.ts +16 -0
  37. package/dist/src/core/marionette-preflight.js +19 -0
  38. package/dist/src/core/patch-export.d.ts +77 -2
  39. package/dist/src/core/patch-export.js +82 -3
  40. package/dist/src/core/patch-lint-diff-tag.d.ts +20 -0
  41. package/dist/src/core/patch-lint-diff-tag.js +25 -0
  42. package/dist/src/core/patch-lint.js +82 -32
  43. package/dist/src/core/patch-registration-refs.d.ts +42 -0
  44. package/dist/src/core/patch-registration-refs.js +117 -0
  45. package/dist/src/core/xpcshell-appdir.d.ts +19 -5
  46. package/dist/src/core/xpcshell-appdir.js +46 -20
  47. package/dist/src/errors/git.d.ts +20 -0
  48. package/dist/src/errors/git.js +39 -0
  49. package/dist/src/types/commands/index.d.ts +1 -1
  50. package/dist/src/types/commands/options.d.ts +67 -0
  51. package/dist/src/types/commands/patches.d.ts +6 -5
  52. package/package.json +1 -1
@@ -44,3 +44,19 @@ export interface MarionettePreflightOptions {
44
44
  export declare function runMarionettePreflight(engineDir: string, options?: MarionettePreflightOptions): Promise<MarionettePreflightResult>;
45
45
  /** Renders a PASS/FAIL banner to the CLI using the shared logger helpers. */
46
46
  export declare function reportMarionettePreflight(result: MarionettePreflightResult): void;
47
+ /**
48
+ * Formats the PASS/FAIL banner as a plain string for direct
49
+ * `process.stdout.write` use — bypasses the clack logger entirely so
50
+ * operators running `fireforge test --doctor` under a non-TTY (pipe,
51
+ * CI, `tee`-wrapped capture) always see the final line even when the
52
+ * clack renderer swallows trailing log output just before process exit.
53
+ *
54
+ * 2026-04-24 eval Finding 7 reproducibly captured only the `"Running
55
+ * marionette preflight..."` intro and no PASS line at all — the
56
+ * `success()` + `outro()` + direct `stdout.write` belt-and-suspenders
57
+ * we used to ship still lost the summary under some non-TTY flush
58
+ * races. Returning the raw string here lets the caller compose a single
59
+ * authoritative write without any clack layer between the probe and
60
+ * the captured log.
61
+ */
62
+ export declare function formatMarionettePreflightLine(result: MarionettePreflightResult): string;
@@ -288,4 +288,23 @@ export function reportMarionettePreflight(result) {
288
288
  warn(`Marionette preflight: FAIL (${result.durationMs}ms) — ${result.detail}`);
289
289
  }
290
290
  }
291
+ /**
292
+ * Formats the PASS/FAIL banner as a plain string for direct
293
+ * `process.stdout.write` use — bypasses the clack logger entirely so
294
+ * operators running `fireforge test --doctor` under a non-TTY (pipe,
295
+ * CI, `tee`-wrapped capture) always see the final line even when the
296
+ * clack renderer swallows trailing log output just before process exit.
297
+ *
298
+ * 2026-04-24 eval Finding 7 reproducibly captured only the `"Running
299
+ * marionette preflight..."` intro and no PASS line at all — the
300
+ * `success()` + `outro()` + direct `stdout.write` belt-and-suspenders
301
+ * we used to ship still lost the summary under some non-TTY flush
302
+ * races. Returning the raw string here lets the caller compose a single
303
+ * authoritative write without any clack layer between the probe and
304
+ * the captured log.
305
+ */
306
+ export function formatMarionettePreflightLine(result) {
307
+ const status = result.ok ? 'PASS' : 'FAIL';
308
+ return `Marionette preflight: ${status} (${result.durationMs}ms) — ${result.detail}`;
309
+ }
291
310
  //# sourceMappingURL=marionette-preflight.js.map
@@ -21,6 +21,10 @@ export interface CommitExportedPatchInput {
21
21
  diff: string;
22
22
  filesAffected: string[];
23
23
  sourceEsrVersion: string;
24
+ /** Optional `PatchMetadata.tier` opt-in (only `"branding"` recognised). */
25
+ tier?: 'branding';
26
+ /** Optional `PatchMetadata.lintIgnore` (empty array treated as absent). */
27
+ lintIgnore?: string[];
24
28
  }
25
29
  export interface CommitExportedPatchResult {
26
30
  patchFilename: string;
@@ -88,13 +92,71 @@ export type UpdatePatchCommittedHook = () => Promise<void>;
88
92
  * the mutation succeeds. See {@link UpdatePatchCommittedHook}.
89
93
  */
90
94
  export declare function updatePatchAndMetadata(patchesDir: string, filename: string, newContent: string, updates: Partial<PatchMetadata>, onCommitted?: UpdatePatchCommittedHook): Promise<void>;
95
+ /**
96
+ * Optional `PatchMetadata` keys safe to clear via the helpers below.
97
+ * Required keys (filename, order, etc.) are excluded by construction so
98
+ * an over-eager `unsetFields: ['filename']` cannot delete a field the
99
+ * manifest validator requires. Add new keys here only when they become
100
+ * optional on the type.
101
+ */
102
+ export type ClearablePatchMetadataField = 'tier' | 'lintIgnore';
91
103
  /**
92
104
  * Updates metadata for a patch in the manifest.
105
+ *
106
+ * Required-field updates go through the `updates` partial. Clearing an
107
+ * optional field (e.g. removing the `tier` override) goes through
108
+ * `unsetFields` because TypeScript's `exactOptionalPropertyTypes` does
109
+ * not let `Partial<PatchMetadata>` carry an explicit `undefined` value
110
+ * for fields whose declared type does not include `undefined`. The
111
+ * implementation deletes the listed keys from the merged record before
112
+ * writing, so the on-disk JSON omits them and the validator's
113
+ * "preserve only when present" contract is preserved.
114
+ *
93
115
  * @param patchesDir - Path to the patches directory
94
116
  * @param filename - Patch filename
95
- * @param updates - Partial metadata updates
117
+ * @param updates - Field values to set. Pass an empty object when only
118
+ * clearing fields.
119
+ * @param unsetFields - Optional fields to remove from the entry (so
120
+ * serialization drops them).
121
+ */
122
+ export declare function updatePatchMetadata(patchesDir: string, filename: string, updates: Partial<PatchMetadata>, unsetFields?: ReadonlyArray<ClearablePatchMetadataField>): Promise<void>;
123
+ /**
124
+ * Return shape from a {@link mutatePatchMetadata} mutator.
125
+ */
126
+ export interface PatchMetadataMutation {
127
+ /** Field values to set on the entry. */
128
+ set?: Partial<PatchMetadata>;
129
+ /** Optional fields to remove from the entry entirely. */
130
+ unset?: ReadonlyArray<ClearablePatchMetadataField>;
131
+ }
132
+ /**
133
+ * Result of a successful {@link mutatePatchMetadata} call.
96
134
  */
97
- export declare function updatePatchMetadata(patchesDir: string, filename: string, updates: Partial<PatchMetadata>): Promise<void>;
135
+ export interface PatchMetadataMutationResult {
136
+ /** Pre-mutation snapshot of the patch's metadata. */
137
+ before: PatchMetadata;
138
+ /** Post-mutation state of the patch's metadata. */
139
+ after: PatchMetadata;
140
+ }
141
+ /**
142
+ * Reads a patch's metadata under the directory lock, applies a mutator
143
+ * function to compute the update, and writes the result back — all
144
+ * under a single lock so a concurrent writer cannot interleave a
145
+ * read-modify-write cycle. Useful for operations that need to compute
146
+ * the new value from the old (e.g. unioning a `lintIgnore` list,
147
+ * removing a specific entry), which {@link updatePatchMetadata}'s flat
148
+ * merge cannot express on its own.
149
+ *
150
+ * The mutator returns `{ set, unset }` so it can both write fields
151
+ * and drop optional ones. `set` and `unset` are merged before write:
152
+ * `set` runs first via spread, then `unset` deletes the listed keys.
153
+ *
154
+ * @returns The pre/post metadata pair when the patch is found and the
155
+ * write succeeds; `null` when the manifest is missing or the named
156
+ * patch is not in it. Callers should treat `null` as "no-op, nothing
157
+ * to log".
158
+ */
159
+ export declare function mutatePatchMetadata(patchesDir: string, filename: string, mutator: (existing: PatchMetadata) => PatchMetadataMutation): Promise<PatchMetadataMutationResult | null>;
98
160
  /**
99
161
  * Finds patches that are completely superseded by newer patches.
100
162
  * A patch is superseded if all its affected files are covered by newer patches.
@@ -196,6 +258,19 @@ export interface PlanExportInput {
196
258
  description: string;
197
259
  filesAffected: string[];
198
260
  sourceEsrVersion: string;
261
+ /**
262
+ * Optional `PatchMetadata.tier` opt-in carried from the CLI flag.
263
+ * Only `"branding"` is currently recognised. When provided the field
264
+ * is written into the new patch's metadata; when absent the field
265
+ * stays unset and tier resolution falls back to auto-detection.
266
+ */
267
+ tier?: 'branding';
268
+ /**
269
+ * Optional `PatchMetadata.lintIgnore` carried from the CLI flag.
270
+ * Empty arrays are treated as "field absent" — the validator only
271
+ * preserves the field when it has at least one entry.
272
+ */
273
+ lintIgnore?: string[];
199
274
  }
200
275
  /**
201
276
  * Read-only planning function — computes everything a real export would
@@ -63,6 +63,8 @@ export async function commitExportedPatch(input) {
63
63
  description: input.description,
64
64
  filesAffected: input.filesAffected,
65
65
  sourceEsrVersion: input.sourceEsrVersion,
66
+ ...(input.tier !== undefined ? { tier: input.tier } : {}),
67
+ ...(input.lintIgnore !== undefined ? { lintIgnore: input.lintIgnore } : {}),
66
68
  });
67
69
  const patchPath = plan.patchPath;
68
70
  const originalPatchContent = (await pathExists(patchPath)) ? await readText(patchPath) : null;
@@ -236,13 +238,50 @@ export async function updatePatchAndMetadata(patchesDir, filename, newContent, u
236
238
  }
237
239
  });
238
240
  }
241
+ /**
242
+ * Merges `updates` onto `existing` and removes the listed `unset`
243
+ * fields. The unset path is an explicit switch over the
244
+ * {@link ClearablePatchMetadataField} union rather than a dynamic
245
+ * `delete obj[k]` so the typecheck-time guarantee that only optional
246
+ * fields can be cleared survives the runtime erasure — and so the lint
247
+ * rule against dynamic deletes does not have to be silenced. Adding a
248
+ * new clearable field requires extending both the union and this
249
+ * switch in lockstep, which is exactly the constraint we want.
250
+ */
251
+ function applyMetadataUpdate(existing, updates, unset) {
252
+ const next = { ...existing, ...updates };
253
+ for (const field of unset) {
254
+ switch (field) {
255
+ case 'tier':
256
+ delete next.tier;
257
+ break;
258
+ case 'lintIgnore':
259
+ delete next.lintIgnore;
260
+ break;
261
+ }
262
+ }
263
+ return next;
264
+ }
239
265
  /**
240
266
  * Updates metadata for a patch in the manifest.
267
+ *
268
+ * Required-field updates go through the `updates` partial. Clearing an
269
+ * optional field (e.g. removing the `tier` override) goes through
270
+ * `unsetFields` because TypeScript's `exactOptionalPropertyTypes` does
271
+ * not let `Partial<PatchMetadata>` carry an explicit `undefined` value
272
+ * for fields whose declared type does not include `undefined`. The
273
+ * implementation deletes the listed keys from the merged record before
274
+ * writing, so the on-disk JSON omits them and the validator's
275
+ * "preserve only when present" contract is preserved.
276
+ *
241
277
  * @param patchesDir - Path to the patches directory
242
278
  * @param filename - Patch filename
243
- * @param updates - Partial metadata updates
279
+ * @param updates - Field values to set. Pass an empty object when only
280
+ * clearing fields.
281
+ * @param unsetFields - Optional fields to remove from the entry (so
282
+ * serialization drops them).
244
283
  */
245
- export async function updatePatchMetadata(patchesDir, filename, updates) {
284
+ export async function updatePatchMetadata(patchesDir, filename, updates, unsetFields = []) {
246
285
  await withPatchDirectoryLock(patchesDir, async () => {
247
286
  const manifest = await loadPatchesManifest(patchesDir);
248
287
  if (!manifest)
@@ -252,11 +291,47 @@ export async function updatePatchMetadata(patchesDir, filename, updates) {
252
291
  return;
253
292
  const existingPatch = manifest.patches[patchIndex];
254
293
  if (existingPatch) {
255
- manifest.patches[patchIndex] = { ...existingPatch, ...updates };
294
+ manifest.patches[patchIndex] = applyMetadataUpdate(existingPatch, updates, unsetFields);
256
295
  await savePatchesManifest(patchesDir, manifest);
257
296
  }
258
297
  });
259
298
  }
299
+ /**
300
+ * Reads a patch's metadata under the directory lock, applies a mutator
301
+ * function to compute the update, and writes the result back — all
302
+ * under a single lock so a concurrent writer cannot interleave a
303
+ * read-modify-write cycle. Useful for operations that need to compute
304
+ * the new value from the old (e.g. unioning a `lintIgnore` list,
305
+ * removing a specific entry), which {@link updatePatchMetadata}'s flat
306
+ * merge cannot express on its own.
307
+ *
308
+ * The mutator returns `{ set, unset }` so it can both write fields
309
+ * and drop optional ones. `set` and `unset` are merged before write:
310
+ * `set` runs first via spread, then `unset` deletes the listed keys.
311
+ *
312
+ * @returns The pre/post metadata pair when the patch is found and the
313
+ * write succeeds; `null` when the manifest is missing or the named
314
+ * patch is not in it. Callers should treat `null` as "no-op, nothing
315
+ * to log".
316
+ */
317
+ export async function mutatePatchMetadata(patchesDir, filename, mutator) {
318
+ return await withPatchDirectoryLock(patchesDir, async () => {
319
+ const manifest = await loadPatchesManifest(patchesDir);
320
+ if (!manifest)
321
+ return null;
322
+ const patchIndex = manifest.patches.findIndex((p) => p.filename === filename);
323
+ if (patchIndex === -1)
324
+ return null;
325
+ const existingPatch = manifest.patches[patchIndex];
326
+ if (!existingPatch)
327
+ return null;
328
+ const { set = {}, unset = [] } = mutator(existingPatch);
329
+ const updatedPatch = applyMetadataUpdate(existingPatch, set, unset);
330
+ manifest.patches[patchIndex] = updatedPatch;
331
+ await savePatchesManifest(patchesDir, manifest);
332
+ return { before: existingPatch, after: updatedPatch };
333
+ });
334
+ }
260
335
  /**
261
336
  * Finds patches that are completely superseded by newer patches.
262
337
  * A patch is superseded if all its affected files are covered by newer patches.
@@ -419,6 +494,10 @@ async function computeExportPlanUnderLock(input) {
419
494
  createdAt: new Date().toISOString(),
420
495
  sourceEsrVersion: input.sourceEsrVersion,
421
496
  filesAffected: input.filesAffected,
497
+ ...(input.tier !== undefined ? { tier: input.tier } : {}),
498
+ ...(input.lintIgnore !== undefined && input.lintIgnore.length > 0
499
+ ? { lintIgnore: input.lintIgnore }
500
+ : {}),
422
501
  };
423
502
  const supersedeMatches = await findAllPatchesForFilesWithDetails(input.patchesDir, input.filesAffected, patchFilename);
424
503
  const supersededDetails = supersedeMatches.map((m) => ({
@@ -18,6 +18,13 @@ import type { PatchLintIssue } from '../types/commands/index.js';
18
18
  * @param rev Git revision to diff against (e.g. `HEAD`, a branch, a SHA).
19
19
  */
20
20
  export declare function collectDiffFilePaths(engineDir: string, rev: string): Promise<Set<string>>;
21
+ /**
22
+ * Synthetic "file" value used by aggregate patch-size rules
23
+ * (`large-patch-files` / `large-patch-lines`) to flag that a finding
24
+ * describes the whole diff rather than a single path. Exported so callers
25
+ * can keep the tagging contract visible in one place.
26
+ */
27
+ export declare const AGGREGATE_PATCH_FILE = "(patch)";
21
28
  /**
22
29
  * Annotates a list of lint issues with `introduced` / `cumulative` tags
23
30
  * based on whether the issue's file is part of the supplied diff set.
@@ -27,6 +34,19 @@ export declare function collectDiffFilePaths(engineDir: string, rev: string): Pr
27
34
  * describe queue-wide state — are always `cumulative` under `--since`
28
35
  * because they describe drift accumulated across many commits, not a
29
36
  * single current-task edit.
37
+ *
38
+ * Aggregate patch-size rules emit `issue.file === AGGREGATE_PATCH_FILE`,
39
+ * which is a synthetic placeholder that will never appear in a real
40
+ * `diffFiles` set. Without special-casing, `large-patch-files` /
41
+ * `large-patch-lines` were always tagged `[cumulative]` under
42
+ * `--only-introduced` even when the diff WAS the aggregate the rules
43
+ * measured — the eval (Finding #4) reported a stack of 20+ imported
44
+ * patches whose aggregate-size warnings printed as `[cumulative]` under
45
+ * `lint --since HEAD --only-introduced`, which reads as "this pre-existed"
46
+ * to an operator asking "what did this diff introduce?" We promote the
47
+ * aggregate tag to `introduced` whenever the diff set has any content —
48
+ * non-empty `diffFiles` means the operator asked about a specific diff
49
+ * scope and the aggregate-rule finding describes exactly that scope.
30
50
  * @param issues Issues returned by the lint orchestrator.
31
51
  * @param diffFiles File paths touched since the user's revision.
32
52
  */
@@ -58,6 +58,13 @@ export async function collectDiffFilePaths(engineDir, rev) {
58
58
  }
59
59
  return files;
60
60
  }
61
+ /**
62
+ * Synthetic "file" value used by aggregate patch-size rules
63
+ * (`large-patch-files` / `large-patch-lines`) to flag that a finding
64
+ * describes the whole diff rather than a single path. Exported so callers
65
+ * can keep the tagging contract visible in one place.
66
+ */
67
+ export const AGGREGATE_PATCH_FILE = '(patch)';
61
68
  /**
62
69
  * Annotates a list of lint issues with `introduced` / `cumulative` tags
63
70
  * based on whether the issue's file is part of the supplied diff set.
@@ -67,15 +74,33 @@ export async function collectDiffFilePaths(engineDir, rev) {
67
74
  * describe queue-wide state — are always `cumulative` under `--since`
68
75
  * because they describe drift accumulated across many commits, not a
69
76
  * single current-task edit.
77
+ *
78
+ * Aggregate patch-size rules emit `issue.file === AGGREGATE_PATCH_FILE`,
79
+ * which is a synthetic placeholder that will never appear in a real
80
+ * `diffFiles` set. Without special-casing, `large-patch-files` /
81
+ * `large-patch-lines` were always tagged `[cumulative]` under
82
+ * `--only-introduced` even when the diff WAS the aggregate the rules
83
+ * measured — the eval (Finding #4) reported a stack of 20+ imported
84
+ * patches whose aggregate-size warnings printed as `[cumulative]` under
85
+ * `lint --since HEAD --only-introduced`, which reads as "this pre-existed"
86
+ * to an operator asking "what did this diff introduce?" We promote the
87
+ * aggregate tag to `introduced` whenever the diff set has any content —
88
+ * non-empty `diffFiles` means the operator asked about a specific diff
89
+ * scope and the aggregate-rule finding describes exactly that scope.
70
90
  * @param issues Issues returned by the lint orchestrator.
71
91
  * @param diffFiles File paths touched since the user's revision.
72
92
  */
73
93
  export function tagLintIssues(issues, diffFiles) {
94
+ const hasDiffContent = diffFiles.size > 0;
74
95
  for (const issue of issues) {
75
96
  if (!issue.file) {
76
97
  issue.tag = 'cumulative';
77
98
  continue;
78
99
  }
100
+ if (issue.file === AGGREGATE_PATCH_FILE) {
101
+ issue.tag = hasDiffContent ? 'introduced' : 'cumulative';
102
+ continue;
103
+ }
79
104
  issue.tag = diffFiles.has(issue.file) ? 'introduced' : 'cumulative';
80
105
  }
81
106
  return issues;
@@ -8,6 +8,7 @@ import { loadFurnaceConfig } from './furnace-config.js';
8
8
  import { containsUpstreamLicenseText, getLicenseHeader, hasAnyLicenseHeader, hasAnyLicenseHeaderAnyStyle, } from './license-headers.js';
9
9
  import { runCheckJs } from './patch-lint-checkjs.js';
10
10
  import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
11
+ import { AGGREGATE_PATCH_FILE } from './patch-lint-diff-tag.js';
11
12
  import { validateExportJsDoc } from './patch-lint-jsdoc.js';
12
13
  import { resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
13
14
  // ---------------------------------------------------------------------------
@@ -64,14 +65,55 @@ const PATCH_LINE_THRESHOLDS = {
64
65
  * Branding patches have a legitimate reason to be large: they include
65
66
  * every locale's `brand.ftl`, copied upstream CSS/PNG assets, and the
66
67
  * fork-specific `configure.sh` / `brand.properties` under a single
67
- * `browser/branding/<name>/` subtree. The general hard limit of 3000
68
- * lines fires on even a first-export branding patch (the eval saw 15904
69
- * lines for a freshly-setup fork), which is loud but not actionable —
70
- * the patch already is the minimum branding diff. A dedicated tier keeps
71
- * the size guidance while moving the hard limit to a threshold that
72
- * corresponds to "something other than branding is bundled in here too".
68
+ * `browser/branding/<name>/` subtree. Calibrated against:
69
+ *
70
+ * - The 2026-04-21 eval baseline: a fresh-fork branding export landed
71
+ * at 15904 lines (localized brand.ftl across many locales + SVG path
72
+ * data + copied upstream CSS).
73
+ * - The 2026-04-25 operator data point: a freshly setup branding patch
74
+ * (post-binary-exclusion, after Phase 1+2 patch splits) landed at
75
+ * 15650 lines — within 2% of the eval baseline.
76
+ *
77
+ * Both data points need to surface as a soft `notice` rather than a
78
+ * `warning`, since they represent the *minimum* branding diff. The
79
+ * pre-2026-04-25 calibration {3000/8000/20000} put 15904 firmly in the
80
+ * `warning` band, contradicting the docstring's "loud but not
81
+ * actionable" intent. The current calibration moves the warning band
82
+ * above the eval baseline (with ~13% headroom) and the error band to
83
+ * roughly 2× the baseline — reaching `error` strongly suggests
84
+ * non-branding work is bundled in.
85
+ *
86
+ * Permissive thresholds are safe because the *gate* into this tier is
87
+ * narrow (auto-detect requires every file under `browser/branding/`
88
+ * plus a tight registration allowlist, or an explicit
89
+ * `PatchMetadata.tier: "branding"` opt-in). A non-branding patch
90
+ * cannot accidentally land here.
73
91
  */
74
- branding: { notice: 3000, warning: 8000, error: 20000 },
92
+ branding: { notice: 8000, warning: 18000, error: 30000 },
93
+ };
94
+ /**
95
+ * File-count thresholds for the `large-patch-files` rule, mirroring the
96
+ * tier shape of {@link PATCH_LINE_THRESHOLDS}. A single warning-only
97
+ * threshold per tier is intentional — file count expresses scope, not
98
+ * blast radius, and there is no error band that would block export.
99
+ *
100
+ * The branding tier sits well above the typical floor because branding
101
+ * patches inherently span many files: PNG/ICO icon assets in 7+ sizes,
102
+ * MSIX manifests, channel-specific configs, locale `.ftl` files,
103
+ * Windows/macOS launcher resources. The 2026-04-25 operator data point
104
+ * reported a 56-file fresh-fork branding bundle as the minimum shape;
105
+ * 60 leaves headroom for additional channels/locales while still firing
106
+ * on a genuinely bloated patch.
107
+ *
108
+ * Test tier matches general because a test-only patch rarely touches
109
+ * many files (a single regression test usually adds 1–3 fixtures); the
110
+ * elevation in {@link PATCH_LINE_THRESHOLDS.test} addresses big
111
+ * table-driven test bodies, not file fan-out.
112
+ */
113
+ const PATCH_FILES_THRESHOLDS = {
114
+ general: 5,
115
+ test: 5,
116
+ branding: 60,
75
117
  };
76
118
  /**
77
119
  * Fixed allowlist of non-branding sibling paths that real-world Firefox
@@ -501,50 +543,58 @@ export function resolvePatchSizeTier(filesAffected, patchTier) {
501
543
  */
502
544
  export function lintPatchSize(filesAffected, lineCount, patchTier) {
503
545
  const issues = [];
504
- if (filesAffected.length > 5) {
505
- issues.push({
506
- file: '(patch)',
507
- check: 'large-patch-files',
508
- message: `Patch affects ${filesAffected.length} files (recommended: ≤5). Consider splitting into smaller, focused patches.`,
509
- severity: 'warning',
510
- });
511
- }
512
546
  // Tier selection: test > branding > general. Tests keep their elevated
513
547
  // thresholds because a big regression test is legitimate (table-driven
514
- // harnesses run into the thousands of lines). Branding patches get their
515
- // own tier so a first-export of setup-generated branding doesn't fire
516
- // the general hard limit — see `PATCH_LINE_THRESHOLDS.branding` above
517
- // for the eval data motivating this tier. An explicit `patchTier`
518
- // opt-in forces branding even when `isBrandingOnlyPatch` cannot reach
519
- // the patch's actual shape (a branding patch that also touches a
520
- // non-allowlisted sibling like a vendor-specific icon resource).
548
+ // harnesses run into the thousands of lines). Branding patches get
549
+ // their own tier so a first-export of setup-generated branding doesn't
550
+ // fire the general hard limit — see `PATCH_LINE_THRESHOLDS.branding`
551
+ // and `PATCH_FILES_THRESHOLDS.branding` above for the eval data
552
+ // motivating this tier. An explicit `patchTier` opt-in forces branding
553
+ // even when `isBrandingOnlyPatch` cannot reach the patch's actual
554
+ // shape (a branding patch that also touches a non-allowlisted sibling
555
+ // like a vendor-specific icon resource). Both checks read off the
556
+ // same decision so the file-count and line-count rules cannot
557
+ // disagree about which tier applies.
521
558
  const decision = resolvePatchSizeTier(filesAffected, patchTier);
522
- const thresholds = decision.tier === 'test'
559
+ const fileThreshold = decision.tier === 'test'
560
+ ? PATCH_FILES_THRESHOLDS.test
561
+ : decision.tier === 'branding'
562
+ ? PATCH_FILES_THRESHOLDS.branding
563
+ : PATCH_FILES_THRESHOLDS.general;
564
+ const lineThresholds = decision.tier === 'test'
523
565
  ? PATCH_LINE_THRESHOLDS.test
524
566
  : decision.tier === 'branding'
525
567
  ? PATCH_LINE_THRESHOLDS.branding
526
568
  : PATCH_LINE_THRESHOLDS.general;
527
- if (lineCount >= thresholds.error) {
569
+ if (filesAffected.length > fileThreshold) {
570
+ issues.push({
571
+ file: AGGREGATE_PATCH_FILE,
572
+ check: 'large-patch-files',
573
+ message: `Patch affects ${filesAffected.length} files (recommended: ≤${fileThreshold}). Consider splitting into smaller, focused patches.`,
574
+ severity: 'warning',
575
+ });
576
+ }
577
+ if (lineCount >= lineThresholds.error) {
528
578
  issues.push({
529
- file: '(patch)',
579
+ file: AGGREGATE_PATCH_FILE,
530
580
  check: 'large-patch-lines',
531
- message: `Patch is ${lineCount} lines (hard limit: ${thresholds.error}). Consider splitting into smaller, focused patches.`,
581
+ message: `Patch is ${lineCount} lines (hard limit: ${lineThresholds.error}). Consider splitting into smaller, focused patches.`,
532
582
  severity: 'error',
533
583
  });
534
584
  }
535
- else if (lineCount >= thresholds.warning) {
585
+ else if (lineCount >= lineThresholds.warning) {
536
586
  issues.push({
537
- file: '(patch)',
587
+ file: AGGREGATE_PATCH_FILE,
538
588
  check: 'large-patch-lines',
539
- message: `Patch is ${lineCount} lines (soft limit: ${thresholds.warning}, hard limit: ${thresholds.error}). Consider splitting into smaller, focused patches.`,
589
+ message: `Patch is ${lineCount} lines (soft limit: ${lineThresholds.warning}, hard limit: ${lineThresholds.error}). Consider splitting into smaller, focused patches.`,
540
590
  severity: 'warning',
541
591
  });
542
592
  }
543
- else if (lineCount >= thresholds.notice) {
593
+ else if (lineCount >= lineThresholds.notice) {
544
594
  issues.push({
545
- file: '(patch)',
595
+ file: AGGREGATE_PATCH_FILE,
546
596
  check: 'large-patch-lines',
547
- message: `Patch is ${lineCount} lines (soft limit: ${thresholds.warning}, hard limit: ${thresholds.error}). Consider splitting into smaller, focused patches.`,
597
+ message: `Patch is ${lineCount} lines (soft limit: ${lineThresholds.warning}, hard limit: ${lineThresholds.error}). Consider splitting into smaller, focused patches.`,
548
598
  severity: 'notice',
549
599
  });
550
600
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Extracts furnace-shaped registration references from a patch body.
3
+ *
4
+ * 2026-04-24 eval Finding 1: `export-all --exclude-furnace` can land a
5
+ * patch that registers a furnace component (via edits to
6
+ * `toolkit/content/customElements.js`, `toolkit/content/jar.mn`, or
7
+ * `toolkit/locales/jar.mn`) without including the component's source
8
+ * files in the patch. `fireforge verify` then reports "Verify clean" for
9
+ * the broken queue. This module provides a pattern-scoped scan so
10
+ * `verify` can cross-check registrations against available file bodies.
11
+ *
12
+ * The scan is deliberately narrow: it only matches component-shaped
13
+ * references (widget tag names, locale fluent names). Unrelated jar.mn
14
+ * or customElements.js edits pass through without spurious warnings.
15
+ */
16
+ /**
17
+ * A referenced engine path extracted from a registration hunk, together
18
+ * with where it came from. The `source` field lets `verify` point
19
+ * operators at the specific consequence file whose hunk introduced the
20
+ * reference.
21
+ */
22
+ export interface PatchRegistrationReference {
23
+ /** Engine-relative path that the registration hunk adds a reference to. */
24
+ targetPath: string;
25
+ /** The registration file that contained the added hunk. */
26
+ source: string;
27
+ /** Raw hunk line that produced the reference, for diagnostic context. */
28
+ lineText: string;
29
+ }
30
+ /**
31
+ * Walks a unified-diff patch body and returns the set of
32
+ * component-shaped engine paths that the patch ADDS a registration for.
33
+ *
34
+ * Returns the empty array when no registration hunks are present OR
35
+ * when the registration hunks do not mention any component-shaped
36
+ * paths — that leaves the scan silent on the vast majority of patches
37
+ * (branding tweaks, behavioural fixes, module additions) so it only
38
+ * fires when a furnace-managed component is being newly registered.
39
+ *
40
+ * @param patchBody - Full unified-diff body of the patch file.
41
+ */
42
+ export declare function collectPatchRegistrationReferences(patchBody: string): PatchRegistrationReference[];