@hominis/fireforge 0.18.1 → 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.
@@ -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) => ({
@@ -65,14 +65,55 @@ const PATCH_LINE_THRESHOLDS = {
65
65
  * Branding patches have a legitimate reason to be large: they include
66
66
  * every locale's `brand.ftl`, copied upstream CSS/PNG assets, and the
67
67
  * fork-specific `configure.sh` / `brand.properties` under a single
68
- * `browser/branding/<name>/` subtree. The general hard limit of 3000
69
- * lines fires on even a first-export branding patch (the eval saw 15904
70
- * lines for a freshly-setup fork), which is loud but not actionable —
71
- * the patch already is the minimum branding diff. A dedicated tier keeps
72
- * the size guidance while moving the hard limit to a threshold that
73
- * 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.
74
91
  */
75
- 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,
76
117
  };
77
118
  /**
78
119
  * Fixed allowlist of non-branding sibling paths that real-world Firefox
@@ -502,50 +543,58 @@ export function resolvePatchSizeTier(filesAffected, patchTier) {
502
543
  */
503
544
  export function lintPatchSize(filesAffected, lineCount, patchTier) {
504
545
  const issues = [];
505
- if (filesAffected.length > 5) {
506
- issues.push({
507
- file: AGGREGATE_PATCH_FILE,
508
- check: 'large-patch-files',
509
- message: `Patch affects ${filesAffected.length} files (recommended: ≤5). Consider splitting into smaller, focused patches.`,
510
- severity: 'warning',
511
- });
512
- }
513
546
  // Tier selection: test > branding > general. Tests keep their elevated
514
547
  // thresholds because a big regression test is legitimate (table-driven
515
- // harnesses run into the thousands of lines). Branding patches get their
516
- // own tier so a first-export of setup-generated branding doesn't fire
517
- // the general hard limit — see `PATCH_LINE_THRESHOLDS.branding` above
518
- // for the eval data motivating this tier. An explicit `patchTier`
519
- // opt-in forces branding even when `isBrandingOnlyPatch` cannot reach
520
- // the patch's actual shape (a branding patch that also touches a
521
- // 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.
522
558
  const decision = resolvePatchSizeTier(filesAffected, patchTier);
523
- 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'
524
565
  ? PATCH_LINE_THRESHOLDS.test
525
566
  : decision.tier === 'branding'
526
567
  ? PATCH_LINE_THRESHOLDS.branding
527
568
  : PATCH_LINE_THRESHOLDS.general;
528
- 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) {
529
578
  issues.push({
530
579
  file: AGGREGATE_PATCH_FILE,
531
580
  check: 'large-patch-lines',
532
- 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.`,
533
582
  severity: 'error',
534
583
  });
535
584
  }
536
- else if (lineCount >= thresholds.warning) {
585
+ else if (lineCount >= lineThresholds.warning) {
537
586
  issues.push({
538
587
  file: AGGREGATE_PATCH_FILE,
539
588
  check: 'large-patch-lines',
540
- 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.`,
541
590
  severity: 'warning',
542
591
  });
543
592
  }
544
- else if (lineCount >= thresholds.notice) {
593
+ else if (lineCount >= lineThresholds.notice) {
545
594
  issues.push({
546
595
  file: AGGREGATE_PATCH_FILE,
547
596
  check: 'large-patch-lines',
548
- 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.`,
549
598
  severity: 'notice',
550
599
  });
551
600
  }
@@ -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, FurnaceRefreshOptions, FurnaceRemoveOptions, FurnaceSyncOptions, FurnaceValidateOptions, GlobalOptions, ImportOptions, PackageOptions, PatchCompactOptions, PatchDeleteOptions, PatchReorderOptions, 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, PatchCompactOptions, PatchDeleteOptions, PatchLintIgnoreOptions, PatchReorderOptions, PatchTierOptions, 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';
@@ -93,6 +93,24 @@ export interface ExportOptions {
93
93
  * another patch, because the resulting queue fails `verify` immediately.
94
94
  */
95
95
  allowOverlap?: boolean;
96
+ /**
97
+ * Force a tier override on the new patch's `PatchMetadata.tier`. Only
98
+ * `"branding"` is currently recognised — Commander rejects other values
99
+ * before the handler runs. Use when a branding patch legitimately
100
+ * touches a non-allowlisted sibling that `isBrandingOnlyPatch` cannot
101
+ * reach (a fork-specific theme override under `browser/themes/<name>/`,
102
+ * a vendor-specific icon resource, etc.).
103
+ */
104
+ tier?: 'branding';
105
+ /**
106
+ * Lint check IDs to suppress on this patch. Writes to
107
+ * `PatchMetadata.lintIgnore`. Repeatable on the CLI; each occurrence
108
+ * appends to the list. Useful when a patch is advisory-noisy by nature
109
+ * (a cohesive branding bundle, an auto-generated manifest) and a
110
+ * specific check does not apply, but `--skip-lint` is too coarse a
111
+ * hammer.
112
+ */
113
+ lintIgnore?: string[];
96
114
  }
97
115
  /**
98
116
  * Options for the reset command.
@@ -172,6 +190,55 @@ export interface ReExportOptions {
172
190
  * `rebase`.
173
191
  */
174
192
  stamp?: boolean;
193
+ /**
194
+ * Force a tier override on the selected patch(es). Only `"branding"` is
195
+ * currently recognised. Mutually exclusive with `--all` — mass tier
196
+ * changes are virtually always footguns, since different patches in
197
+ * the queue have different shapes.
198
+ */
199
+ tier?: 'branding';
200
+ /**
201
+ * Lint check IDs to suppress, **appended** (union) to the patch's
202
+ * existing `lintIgnore` list. De-duplicated. Mutually exclusive with
203
+ * `--all`. To remove an entry or clear the list entirely, use the
204
+ * `fireforge patch lint-ignore` subcommand (which has explicit
205
+ * `--add` / `--remove` / `--clear` modes); re-export's append-only
206
+ * semantics match the operator's most common intent ("I want this
207
+ * patch to also suppress X").
208
+ */
209
+ lintIgnore?: string[];
210
+ }
211
+ /**
212
+ * Options for the `fireforge patch tier` subcommand. Sets or clears the
213
+ * `PatchMetadata.tier` field on a single patch without rewriting the
214
+ * `.patch` file body — the manifest is the only thing that changes.
215
+ */
216
+ export interface PatchTierOptions {
217
+ /** Force the named tier on the patch. Only `"branding"` is recognised. */
218
+ tier?: 'branding';
219
+ /** Remove the `tier` override entirely, restoring auto-detection. */
220
+ clear?: boolean;
221
+ /** Print the planned change without writing. */
222
+ dryRun?: boolean;
223
+ /** Skip the confirmation prompt (required for non-TTY). */
224
+ yes?: boolean;
225
+ }
226
+ /**
227
+ * Options for the `fireforge patch lint-ignore` subcommand. Modes are
228
+ * mutually exclusive — exactly one of `add`, `remove`, or `clear` must
229
+ * be set per invocation.
230
+ */
231
+ export interface PatchLintIgnoreOptions {
232
+ /** Lint check IDs to add to the patch's `lintIgnore` list (union, de-duped). */
233
+ add?: string[];
234
+ /** Lint check IDs to remove from the patch's `lintIgnore` list. */
235
+ remove?: string[];
236
+ /** Drop the `lintIgnore` field entirely. */
237
+ clear?: boolean;
238
+ /** Print the planned change without writing. */
239
+ dryRun?: boolean;
240
+ /** Skip the confirmation prompt (required for non-TTY). */
241
+ yes?: boolean;
175
242
  }
176
243
  /**
177
244
  * Options for the rebase command.
@@ -86,11 +86,12 @@ export interface PatchMetadata {
86
86
  * limit on what is legitimately one branding diff.
87
87
  *
88
88
  * Declaring `tier: "branding"` here forces the branding thresholds
89
- * (notice 3000 / warning 8000 / error 20000) regardless of
90
- * `filesAffected`. The tier is the weaker claim than test — a patch
91
- * of all-tests still lands in the test tier even if this field is
92
- * set, because the test-tier thresholds are already more permissive
93
- * and a test that is also branding-shaped is vanishingly rare.
89
+ * (notice 8000 / warning 18000 / error 30000 lines, ≤60 files)
90
+ * regardless of `filesAffected`. The tier is the weaker claim than
91
+ * test — a patch of all-tests still lands in the test tier even if
92
+ * this field is set, because the test-tier thresholds are already
93
+ * more permissive and a test that is also branding-shaped is
94
+ * vanishingly rare.
94
95
  *
95
96
  * Only `"branding"` is currently recognised. Unknown values are
96
97
  * rejected by the manifest validator, not silently stripped.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",