@hominis/fireforge 0.17.0 → 0.18.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 (75) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +60 -33
  3. package/dist/src/commands/build.js +18 -4
  4. package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
  5. package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
  6. package/dist/src/commands/doctor-furnace.js +2 -0
  7. package/dist/src/commands/doctor-working-tree.d.ts +29 -0
  8. package/dist/src/commands/doctor-working-tree.js +93 -0
  9. package/dist/src/commands/doctor.js +22 -12
  10. package/dist/src/commands/export-all.js +74 -4
  11. package/dist/src/commands/export-shared.d.ts +7 -1
  12. package/dist/src/commands/export-shared.js +21 -3
  13. package/dist/src/commands/furnace/create-xpcshell.js +4 -2
  14. package/dist/src/commands/furnace/override.js +23 -13
  15. package/dist/src/commands/furnace/preview.js +38 -0
  16. package/dist/src/commands/furnace/remove.js +75 -1
  17. package/dist/src/commands/furnace/rename-xpcshell.d.ts +35 -0
  18. package/dist/src/commands/furnace/rename-xpcshell.js +97 -0
  19. package/dist/src/commands/furnace/rename.js +32 -4
  20. package/dist/src/commands/lint.js +19 -6
  21. package/dist/src/commands/patch/delete.js +4 -1
  22. package/dist/src/commands/patch/reorder.js +4 -1
  23. package/dist/src/commands/re-export-files.js +3 -1
  24. package/dist/src/commands/re-export.js +4 -1
  25. package/dist/src/commands/rebase/index.js +19 -1
  26. package/dist/src/commands/register.js +11 -0
  27. package/dist/src/commands/status.js +44 -5
  28. package/dist/src/commands/test.js +68 -16
  29. package/dist/src/commands/token-coverage.js +10 -3
  30. package/dist/src/commands/verify.js +81 -6
  31. package/dist/src/commands/watch.js +43 -7
  32. package/dist/src/commands/wire.js +16 -0
  33. package/dist/src/core/browser-wire.js +21 -4
  34. package/dist/src/core/build-audit.js +10 -0
  35. package/dist/src/core/furnace-constants.d.ts +14 -0
  36. package/dist/src/core/furnace-constants.js +16 -0
  37. package/dist/src/core/furnace-validate.js +67 -1
  38. package/dist/src/core/git-base.d.ts +27 -2
  39. package/dist/src/core/git-base.js +41 -3
  40. package/dist/src/core/git-diff.js +21 -2
  41. package/dist/src/core/git.js +53 -14
  42. package/dist/src/core/mach.d.ts +26 -8
  43. package/dist/src/core/mach.js +24 -8
  44. package/dist/src/core/manifest-rules.js +10 -1
  45. package/dist/src/core/manifest-tokenizers.d.ts +6 -0
  46. package/dist/src/core/manifest-tokenizers.js +28 -0
  47. package/dist/src/core/marionette-preflight.d.ts +16 -0
  48. package/dist/src/core/marionette-preflight.js +19 -0
  49. package/dist/src/core/patch-lint-diff-tag.d.ts +20 -0
  50. package/dist/src/core/patch-lint-diff-tag.js +25 -0
  51. package/dist/src/core/patch-lint.d.ts +47 -2
  52. package/dist/src/core/patch-lint.js +94 -18
  53. package/dist/src/core/patch-manifest-consistency.js +15 -2
  54. package/dist/src/core/patch-manifest-io.js +10 -0
  55. package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
  56. package/dist/src/core/patch-manifest-resolve.js +29 -2
  57. package/dist/src/core/patch-manifest-validate.js +25 -1
  58. package/dist/src/core/patch-registration-refs.d.ts +42 -0
  59. package/dist/src/core/patch-registration-refs.js +117 -0
  60. package/dist/src/core/token-coverage.js +24 -0
  61. package/dist/src/core/wire-destroy.d.ts +7 -3
  62. package/dist/src/core/wire-destroy.js +11 -6
  63. package/dist/src/core/wire-init.d.ts +9 -3
  64. package/dist/src/core/wire-init.js +18 -6
  65. package/dist/src/core/wire-subscript.d.ts +7 -3
  66. package/dist/src/core/wire-subscript.js +11 -4
  67. package/dist/src/core/xpcshell-appdir.d.ts +19 -5
  68. package/dist/src/core/xpcshell-appdir.js +46 -20
  69. package/dist/src/errors/git.d.ts +20 -0
  70. package/dist/src/errors/git.js +39 -0
  71. package/dist/src/types/commands/patches.d.ts +23 -0
  72. package/dist/src/types/furnace.d.ts +9 -0
  73. package/dist/src/utils/parse.d.ts +7 -0
  74. package/dist/src/utils/parse.js +15 -0
  75. package/package.json +1 -1
@@ -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
  // ---------------------------------------------------------------------------
@@ -74,13 +75,49 @@ const PATCH_LINE_THRESHOLDS = {
74
75
  branding: { notice: 3000, warning: 8000, error: 20000 },
75
76
  };
76
77
  /**
77
- * Returns true when every file in a patch lives under `browser/branding/`.
78
- * Used by `lintPatchSize` to pick the branding threshold tier.
78
+ * Fixed allowlist of non-branding sibling paths that real-world Firefox
79
+ * branding patches legitimately need to touch to register the new
80
+ * branding flavor with the top-level configure. The 2026-04-21
81
+ * external eval showed that a branding patch which also touches
82
+ * `browser/moz.configure` (the canonical registration point) fell
83
+ * through to the general lint tier because the original predicate
84
+ * required every file to live under `browser/branding/`. This
85
+ * allowlist stays intentionally narrow — additions require a real
86
+ * operator data point, not a speculative expansion. Add new entries
87
+ * only when a genuine branding patch cannot be expressed without a
88
+ * specific registration sibling.
89
+ *
90
+ * Pinned against ESR 140.x conventions at time of writing.
91
+ */
92
+ const BRANDING_REGISTRATION_FILES = new Set([
93
+ 'browser/moz.configure',
94
+ 'browser/confvars.sh',
95
+ ]);
96
+ /**
97
+ * Returns true when a patch qualifies for the branding threshold tier:
98
+ * every file lives either under `browser/branding/` or in the narrow
99
+ * registration allowlist, AND the patch contains at least one file
100
+ * under `browser/branding/` (guard against a config-only patch
101
+ * accidentally qualifying as branding).
102
+ *
103
+ * Used by `lintPatchSize` to pick the branding threshold tier. The
104
+ * explicit `tier: "branding"` field on `PatchMetadata` bypasses this
105
+ * heuristic and forces the branding tier directly.
79
106
  */
80
107
  function isBrandingOnlyPatch(files) {
81
108
  if (files.length === 0)
82
109
  return false;
83
- return files.every((file) => file.startsWith('browser/branding/'));
110
+ let hasBrandingFile = false;
111
+ for (const file of files) {
112
+ if (file.startsWith('browser/branding/')) {
113
+ hasBrandingFile = true;
114
+ continue;
115
+ }
116
+ if (BRANDING_REGISTRATION_FILES.has(file))
117
+ continue;
118
+ return false;
119
+ }
120
+ return hasBrandingFile;
84
121
  }
85
122
  /**
86
123
  * Returns true if the filename looks like a JS/MJS/JSM file.
@@ -426,17 +463,48 @@ export function lintModificationComments(diffContent, config) {
426
463
  }
427
464
  return issues;
428
465
  }
429
- // ---------------------------------------------------------------------------
430
- // Patch size lint (moved from export-shared.ts warnLargePatch)
431
- // ---------------------------------------------------------------------------
466
+ /**
467
+ * Decides which `large-patch-lines` threshold tier applies to a patch.
468
+ * Exported so `runPatchLint` and the per-patch `lint` command can
469
+ * surface the tier choice to the operator *without* depending on
470
+ * `lintPatchSize`'s internal return shape — the rule itself stays a
471
+ * pure issues-array API, and the decision is computed separately for
472
+ * the sole purpose of reporting.
473
+ *
474
+ * Precedence: test > branding (explicit) > branding (auto) > general.
475
+ * The test tier beats branding because a table-driven regression test
476
+ * is legitimately large independent of whether the patch also claims
477
+ * branding shape, and the test-tier thresholds are already more
478
+ * permissive than branding — so "tests beat branding" is the
479
+ * defensive-for-tests choice.
480
+ */
481
+ export function resolvePatchSizeTier(filesAffected, patchTier) {
482
+ const allTests = filesAffected.length > 0 && filesAffected.every(isTestFile);
483
+ if (allTests)
484
+ return { tier: 'test' };
485
+ if (patchTier === 'branding')
486
+ return { tier: 'branding', source: 'explicit' };
487
+ if (isBrandingOnlyPatch(filesAffected))
488
+ return { tier: 'branding', source: 'auto' };
489
+ return { tier: 'general' };
490
+ }
432
491
  /**
433
492
  * Checks patch size and emits advisory warnings.
493
+ *
494
+ * @param filesAffected - Files touched by the patch
495
+ * @param lineCount - Non-binary line count of the unified diff
496
+ * @param patchTier - Optional explicit tier override declared on
497
+ * `PatchMetadata.tier`. When `"branding"`, forces the branding
498
+ * thresholds regardless of `filesAffected`. Tests still win over
499
+ * branding (precedence `test > branding > general`) because the
500
+ * test-tier thresholds are already more permissive and an all-tests
501
+ * patch that is also branding-shaped is vanishingly rare.
434
502
  */
435
- export function lintPatchSize(filesAffected, lineCount) {
503
+ export function lintPatchSize(filesAffected, lineCount, patchTier) {
436
504
  const issues = [];
437
505
  if (filesAffected.length > 5) {
438
506
  issues.push({
439
- file: '(patch)',
507
+ file: AGGREGATE_PATCH_FILE,
440
508
  check: 'large-patch-files',
441
509
  message: `Patch affects ${filesAffected.length} files (recommended: ≤5). Consider splitting into smaller, focused patches.`,
442
510
  severity: 'warning',
@@ -447,17 +515,19 @@ export function lintPatchSize(filesAffected, lineCount) {
447
515
  // harnesses run into the thousands of lines). Branding patches get their
448
516
  // own tier so a first-export of setup-generated branding doesn't fire
449
517
  // the general hard limit — see `PATCH_LINE_THRESHOLDS.branding` above
450
- // for the eval data motivating this tier.
451
- const allTests = filesAffected.length > 0 && filesAffected.every(isTestFile);
452
- const branding = !allTests && isBrandingOnlyPatch(filesAffected);
453
- const thresholds = allTests
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).
522
+ const decision = resolvePatchSizeTier(filesAffected, patchTier);
523
+ const thresholds = decision.tier === 'test'
454
524
  ? PATCH_LINE_THRESHOLDS.test
455
- : branding
525
+ : decision.tier === 'branding'
456
526
  ? PATCH_LINE_THRESHOLDS.branding
457
527
  : PATCH_LINE_THRESHOLDS.general;
458
528
  if (lineCount >= thresholds.error) {
459
529
  issues.push({
460
- file: '(patch)',
530
+ file: AGGREGATE_PATCH_FILE,
461
531
  check: 'large-patch-lines',
462
532
  message: `Patch is ${lineCount} lines (hard limit: ${thresholds.error}). Consider splitting into smaller, focused patches.`,
463
533
  severity: 'error',
@@ -465,7 +535,7 @@ export function lintPatchSize(filesAffected, lineCount) {
465
535
  }
466
536
  else if (lineCount >= thresholds.warning) {
467
537
  issues.push({
468
- file: '(patch)',
538
+ file: AGGREGATE_PATCH_FILE,
469
539
  check: 'large-patch-lines',
470
540
  message: `Patch is ${lineCount} lines (soft limit: ${thresholds.warning}, hard limit: ${thresholds.error}). Consider splitting into smaller, focused patches.`,
471
541
  severity: 'warning',
@@ -473,7 +543,7 @@ export function lintPatchSize(filesAffected, lineCount) {
473
543
  }
474
544
  else if (lineCount >= thresholds.notice) {
475
545
  issues.push({
476
- file: '(patch)',
546
+ file: AGGREGATE_PATCH_FILE,
477
547
  check: 'large-patch-lines',
478
548
  message: `Patch is ${lineCount} lines (soft limit: ${thresholds.warning}, hard limit: ${thresholds.error}). Consider splitting into smaller, focused patches.`,
479
549
  severity: 'notice',
@@ -534,9 +604,15 @@ export async function lintModifiedFileHeaders(repoDir, affectedFiles, newFiles)
534
604
  * is advisory-noisy by nature (a cohesive branding bundle, auto-generated
535
605
  * manifest, etc.) can opt out of a specific rule without reaching for the
536
606
  * blunt `--skip-lint` hammer. Not mutated by this function.
607
+ * @param patchTier - Optional explicit tier override, threaded from
608
+ * `PatchMetadata.tier`. When `"branding"` forces the branding
609
+ * thresholds on the `large-patch-lines` rule. Callers with a
610
+ * per-patch manifest context (re-export, per-patch lint) should
611
+ * pass this; aggregate-mode callers without a specific patch
612
+ * context skip it and fall through to auto-detection.
537
613
  * @returns Array of all lint issues found
538
614
  */
539
- export async function lintExportedPatch(repoDir, affectedFiles, diffContent, config, patchQueueCtx, ignoreChecks) {
615
+ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, config, patchQueueCtx, ignoreChecks, patchTier) {
540
616
  const newFiles = detectNewFilesInDiff(diffContent);
541
617
  const { textLines: lineCount } = countNonBinaryDiffLines(diffContent);
542
618
  const patchOwnedFiles = resolvePatchOwnedSysMjs(newFiles, patchQueueCtx);
@@ -547,7 +623,7 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
547
623
  lintModifiedFileHeaders(repoDir, affectedFiles, newFiles),
548
624
  ]);
549
625
  const modCommentIssues = lintModificationComments(diffContent, config);
550
- const sizeIssues = lintPatchSize(affectedFiles, lineCount);
626
+ const sizeIssues = lintPatchSize(affectedFiles, lineCount, patchTier);
551
627
  const issues = [
552
628
  ...sizeIssues,
553
629
  ...cssIssues,
@@ -121,7 +121,15 @@ export async function rebuildPatchesManifest(patchesDir, fallbackSourceEsrVersio
121
121
  // human-written descriptions during a recovery run.
122
122
  recoveredFilenames.push(patch.filename);
123
123
  }
124
- rebuiltPatches.push({
124
+ // Preserve optional fields the operator declared on the existing
125
+ // entry — `lintIgnore` (per-patch lint suppression) and `tier`
126
+ // (explicit branding-threshold override). Without this, a
127
+ // `doctor --repair-patches-manifest` run silently strips both
128
+ // fields from every entry that had them, and the next `lint`
129
+ // or `re-export` pass fires rules the operator had intentionally
130
+ // quieted. Mirrors how other descriptive fields fall back to
131
+ // existing values when the entry is known.
132
+ const rebuilt = {
125
133
  filename: patch.filename,
126
134
  order: recoveredOrder,
127
135
  category: existing?.category ?? inferred.category,
@@ -131,7 +139,12 @@ export async function rebuildPatchesManifest(patchesDir, fallbackSourceEsrVersio
131
139
  createdAt: existing?.createdAt ?? new Date(patchStats.mtimeMs).toISOString(),
132
140
  sourceEsrVersion: existing?.sourceEsrVersion ?? fallbackSourceEsrVersion,
133
141
  filesAffected,
134
- });
142
+ };
143
+ if (existing?.lintIgnore !== undefined)
144
+ rebuilt.lintIgnore = [...existing.lintIgnore];
145
+ if (existing?.tier !== undefined)
146
+ rebuilt.tier = existing.tier;
147
+ rebuiltPatches.push(rebuilt);
135
148
  }
136
149
  rebuiltPatches.sort((left, right) => left.order - right.order || left.filename.localeCompare(right.filename));
137
150
  const rebuiltManifest = {
@@ -189,6 +189,16 @@ export async function renumberPatchesInManifest(patchesDir, renameMap) {
189
189
  throw new Error(`Cannot renumber: target patch filename already exists on disk: ${toEntry.newFilename}`);
190
190
  }
191
191
  await rename(join(patchesDir, staged), targetPath);
192
+ // Postcondition assert: confirm the target actually exists on
193
+ // disk before we mark the rename complete. A silent rename
194
+ // failure would leave the manifest and the filesystem
195
+ // disagreeing — exactly what the eval 1 Finding #7 report
196
+ // described: manifest rewrote to new filenames while the old
197
+ // files stayed on disk. If the assert ever fires, the Phase 2
198
+ // rollback will undo prior moves before re-throwing.
199
+ if (!(await pathExists(targetPath))) {
200
+ throw new Error(`Rename postcondition failed: expected ${toEntry.newFilename} to exist after rename, but it was not found on disk.`);
201
+ }
192
202
  completedFinalRenames.push(stagedEntry);
193
203
  }
194
204
  }
@@ -1,5 +1,24 @@
1
1
  import type { PatchMetadata } from '../types/commands/index.js';
2
2
  /**
3
- * Resolves a patch identifier (ordinal number or filename) to its manifest entry.
3
+ * Resolves a patch identifier to its manifest entry. Accepts:
4
+ *
5
+ * 1. An ordinal number (e.g. `2`) — matches `PatchMetadata.order`.
6
+ * 2. A full filename with `.patch` suffix (e.g. `002-ui-foo.patch`) —
7
+ * matches `PatchMetadata.filename`.
8
+ * 3. A filename without the `.patch` suffix — the command appends it
9
+ * before matching (e.g. `002-ui-foo`).
10
+ * 4. The manifest `name` field (e.g. `eval-furnace-token-override`) —
11
+ * matches `PatchMetadata.name`. This is the short logical handle
12
+ * the export workflow stamps onto the patch and the natural
13
+ * identifier an operator keeps in their notes. 2026-04-21 eval
14
+ * (Finding #6): `patch reorder`/`delete` rejected the `name`
15
+ * even though the CLI help said `<name>`, forcing the operator
16
+ * to copy the full filename from `patches.json` before every
17
+ * queue mutation.
18
+ *
19
+ * Resolution order is strict: numeric ordinals first, then filename
20
+ * lookup (with + without `.patch` suffix), then name-field lookup.
21
+ * The filename lookup beats the name lookup when the two happen to
22
+ * collide so legacy scripts that pass filenames keep working.
4
23
  */
5
24
  export declare function resolvePatchIdentifier(identifier: string, patches: PatchMetadata[]): PatchMetadata | null;
@@ -1,12 +1,39 @@
1
1
  /**
2
- * Resolves a patch identifier (ordinal number or filename) to its manifest entry.
2
+ * Resolves a patch identifier to its manifest entry. Accepts:
3
+ *
4
+ * 1. An ordinal number (e.g. `2`) — matches `PatchMetadata.order`.
5
+ * 2. A full filename with `.patch` suffix (e.g. `002-ui-foo.patch`) —
6
+ * matches `PatchMetadata.filename`.
7
+ * 3. A filename without the `.patch` suffix — the command appends it
8
+ * before matching (e.g. `002-ui-foo`).
9
+ * 4. The manifest `name` field (e.g. `eval-furnace-token-override`) —
10
+ * matches `PatchMetadata.name`. This is the short logical handle
11
+ * the export workflow stamps onto the patch and the natural
12
+ * identifier an operator keeps in their notes. 2026-04-21 eval
13
+ * (Finding #6): `patch reorder`/`delete` rejected the `name`
14
+ * even though the CLI help said `<name>`, forcing the operator
15
+ * to copy the full filename from `patches.json` before every
16
+ * queue mutation.
17
+ *
18
+ * Resolution order is strict: numeric ordinals first, then filename
19
+ * lookup (with + without `.patch` suffix), then name-field lookup.
20
+ * The filename lookup beats the name lookup when the two happen to
21
+ * collide so legacy scripts that pass filenames keep working.
3
22
  */
4
23
  export function resolvePatchIdentifier(identifier, patches) {
5
24
  if (/^\d+$/.test(identifier)) {
6
25
  const order = parseInt(identifier, 10);
7
26
  return patches.find((p) => p.order === order) ?? null;
8
27
  }
28
+ // Filename lookup — try the input as-is first (covers both the
29
+ // full `.patch` form and a bare name, because `endsWith` treats the
30
+ // bare form as a miss and falls through to the appended variant).
9
31
  const normalized = identifier.endsWith('.patch') ? identifier : `${identifier}.patch`;
10
- return patches.find((p) => p.filename === normalized) ?? null;
32
+ const byFilename = patches.find((p) => p.filename === normalized || p.filename === identifier);
33
+ if (byFilename)
34
+ return byFilename;
35
+ // Name-field lookup — the short logical handle stamped into the
36
+ // manifest at export time. See function docstring.
37
+ return patches.find((p) => p.name === identifier) ?? null;
11
38
  }
12
39
  //# sourceMappingURL=patch-manifest-resolve.js.map
@@ -23,7 +23,26 @@ export function validatePatchMetadata(data, index) {
23
23
  throw new Error(`patches[${index}].sourceEsrVersion must be a valid Firefox version string`);
24
24
  }
25
25
  const filesAffected = rec.stringArray('filesAffected');
26
- return {
26
+ // Optional fields. These were silently stripped before the 0.17.0
27
+ // branding-tier work reached in and audited the loader — the 0.16.0
28
+ // `lintIgnore` escape hatch demonstrably round-tripped only through
29
+ // test fixtures that mocked `loadPatchesManifest` directly. Real
30
+ // operator edits to `patches.json` were dropped on every subsequent
31
+ // load, so any patch that relied on `lintIgnore` to suppress a
32
+ // specific lint rule was quietly re-tripped the next time the
33
+ // manifest validated. Preserve both the pre-existing `lintIgnore`
34
+ // and the new `tier` field here so future-added optional fields
35
+ // have a ready template to follow.
36
+ const lintIgnore = rec.optionalStringArray('lintIgnore');
37
+ const rawTier = rec.raw('tier');
38
+ let tier;
39
+ if (rawTier !== undefined) {
40
+ if (rawTier !== 'branding') {
41
+ throw new Error(`patches[${index}].tier must be "branding" when present (unknown tier values are rejected, not silently ignored).`);
42
+ }
43
+ tier = 'branding';
44
+ }
45
+ const result = {
27
46
  filename,
28
47
  order,
29
48
  category,
@@ -33,6 +52,11 @@ export function validatePatchMetadata(data, index) {
33
52
  sourceEsrVersion,
34
53
  filesAffected,
35
54
  };
55
+ if (lintIgnore !== undefined)
56
+ result.lintIgnore = lintIgnore;
57
+ if (tier !== undefined)
58
+ result.tier = tier;
59
+ return result;
36
60
  }
37
61
  /** Validates raw patches.json data and returns the typed manifest shape. */
38
62
  export function validatePatchesManifest(data) {
@@ -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[];
@@ -0,0 +1,117 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Extracts furnace-shaped registration references from a patch body.
4
+ *
5
+ * 2026-04-24 eval Finding 1: `export-all --exclude-furnace` can land a
6
+ * patch that registers a furnace component (via edits to
7
+ * `toolkit/content/customElements.js`, `toolkit/content/jar.mn`, or
8
+ * `toolkit/locales/jar.mn`) without including the component's source
9
+ * files in the patch. `fireforge verify` then reports "Verify clean" for
10
+ * the broken queue. This module provides a pattern-scoped scan so
11
+ * `verify` can cross-check registrations against available file bodies.
12
+ *
13
+ * The scan is deliberately narrow: it only matches component-shaped
14
+ * references (widget tag names, locale fluent names). Unrelated jar.mn
15
+ * or customElements.js edits pass through without spurious warnings.
16
+ */
17
+ /** Canonical file paths that registration-shaped diffs touch. */
18
+ const REGISTRATION_FILE_PATHS = new Set([
19
+ 'toolkit/content/customElements.js',
20
+ 'toolkit/content/jar.mn',
21
+ 'toolkit/locales/jar.mn',
22
+ ]);
23
+ /**
24
+ * Walks a unified-diff patch body and returns the set of
25
+ * component-shaped engine paths that the patch ADDS a registration for.
26
+ *
27
+ * Returns the empty array when no registration hunks are present OR
28
+ * when the registration hunks do not mention any component-shaped
29
+ * paths — that leaves the scan silent on the vast majority of patches
30
+ * (branding tweaks, behavioural fixes, module additions) so it only
31
+ * fires when a furnace-managed component is being newly registered.
32
+ *
33
+ * @param patchBody - Full unified-diff body of the patch file.
34
+ */
35
+ export function collectPatchRegistrationReferences(patchBody) {
36
+ if (!patchBody)
37
+ return [];
38
+ const refs = [];
39
+ let currentFile;
40
+ // Walk line-by-line. The canonical unified-diff header line is
41
+ // `diff --git a/<path> b/<path>` — we key the file state off the `b/`
42
+ // path because that names the target side and is stable against
43
+ // renames. Additional diff metadata lines (index/---/+++/@@) are
44
+ // ignored for the purposes of tracking the current file.
45
+ const lines = patchBody.split(/\r?\n/);
46
+ for (const line of lines) {
47
+ const diffHeader = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
48
+ if (diffHeader?.[2]) {
49
+ currentFile = diffHeader[2];
50
+ continue;
51
+ }
52
+ if (!currentFile)
53
+ continue;
54
+ if (!REGISTRATION_FILE_PATHS.has(currentFile))
55
+ continue;
56
+ if (!line.startsWith('+'))
57
+ continue;
58
+ // Skip the `+++ b/<path>` header line — only real hunk adds count.
59
+ if (line.startsWith('+++'))
60
+ continue;
61
+ const added = line.slice(1);
62
+ const extracted = extractTargetPathsFromRegistrationLine(currentFile, added);
63
+ for (const target of extracted) {
64
+ refs.push({ targetPath: target, source: currentFile, lineText: added });
65
+ }
66
+ }
67
+ return refs;
68
+ }
69
+ /**
70
+ * Per-source extractor. Each registration file has a distinct syntactic
71
+ * shape; we scope the match to that file so a jar.mn regex does not
72
+ * accidentally match a customElements.js line.
73
+ */
74
+ function extractTargetPathsFromRegistrationLine(sourceFile, added) {
75
+ if (sourceFile === 'toolkit/content/jar.mn') {
76
+ // Example (added line, leading `+` already stripped):
77
+ // ` content/global/elements/moz-qa-panel.mjs (widgets/moz-qa-panel/moz-qa-panel.mjs)`
78
+ // The parenthesised second half is the repo-relative path Firefox's
79
+ // packaging system reads. Widget registrations always live under
80
+ // `widgets/<tag>/<file>` — the enclosing tree is
81
+ // `toolkit/content/widgets/`. Reconstruct the engine-relative
82
+ // target path so callers can check it against patch bodies.
83
+ const widgetMatch = /\(\s*(widgets\/[^\s)]+)\s*\)/.exec(added);
84
+ if (widgetMatch?.[1]) {
85
+ return [`toolkit/content/${widgetMatch[1]}`];
86
+ }
87
+ return [];
88
+ }
89
+ if (sourceFile === 'toolkit/locales/jar.mn') {
90
+ // Example:
91
+ // ` locale/@AB_CD@/toolkit/global/moz-qa-panel.ftl (%toolkit/global/moz-qa-panel.ftl)`
92
+ // The `%`-prefixed repo-relative reference points at
93
+ // `toolkit/locales/en-US/<rel>`, which is the canonical FTL path.
94
+ const localeMatch = /\(%\s*([^\s)]+\.ftl)\s*\)/.exec(added);
95
+ if (localeMatch?.[1]) {
96
+ return [`toolkit/locales/en-US/${localeMatch[1]}`];
97
+ }
98
+ return [];
99
+ }
100
+ if (sourceFile === 'toolkit/content/customElements.js') {
101
+ // Example:
102
+ // ` ["moz-qa-panel", "chrome://global/content/elements/moz-qa-panel.mjs"],`
103
+ // The chrome URL maps back to
104
+ // `toolkit/content/widgets/<tag>/<tag>.mjs` by convention: the
105
+ // packager rewrites `chrome://global/content/elements/<file>` to the
106
+ // widget tree root. The tag name is the identifier we key off.
107
+ const elementMatch = /\[\s*"([a-z][a-z0-9-]*)"\s*,\s*"chrome:\/\/global\/content\/elements\/([a-zA-Z0-9_-]+)\.mjs"\s*\]/.exec(added);
108
+ if (elementMatch?.[1] && elementMatch[2]) {
109
+ const tag = elementMatch[1];
110
+ const fileStem = elementMatch[2];
111
+ return [`toolkit/content/widgets/${tag}/${fileStem}.mjs`];
112
+ }
113
+ return [];
114
+ }
115
+ return [];
116
+ }
117
+ //# sourceMappingURL=patch-registration-refs.js.map
@@ -5,6 +5,21 @@ import { pathExists, readText } from '../utils/fs.js';
5
5
  import { verbose } from '../utils/logger.js';
6
6
  import { countRawCssColors } from '../utils/regex.js';
7
7
  import { loadFurnaceConfig } from './furnace-config.js';
8
+ /**
9
+ * Default platform prefixes treated as allowlisted upstream vars. Any
10
+ * `var(--moz-*)` usage in a fork's CSS is a Firefox platform variable
11
+ * that the fork does not own and should not be counted as an unknown.
12
+ * 2026-04-21 eval (Finding #5): a `furnace override moz-button -t
13
+ * css-only` + one fork token produced 1% coverage because the 84
14
+ * upstream `--moz-*` vars in the copied baseline counted as unknown.
15
+ *
16
+ * Forks that want to opt out can override this via
17
+ * `furnace.json.platformPrefixes = []`; forks that want more can
18
+ * extend it (e.g. `['--moz-', '--in-content-']`). The config is
19
+ * additive — nothing is removed from the defaults unless the operator
20
+ * explicitly writes a shorter list.
21
+ */
22
+ const DEFAULT_PLATFORM_PREFIXES = ['--moz-'];
8
23
  /**
9
24
  * Measures design token coverage across CSS files.
10
25
  *
@@ -19,6 +34,7 @@ export async function measureTokenCoverage(repoDir, cssFiles, projectRoot) {
19
34
  // Load furnace config gracefully
20
35
  let tokenPrefix;
21
36
  let tokenAllowlist;
37
+ let platformPrefixes = DEFAULT_PLATFORM_PREFIXES;
22
38
  try {
23
39
  const root = projectRoot ?? join(repoDir, '..');
24
40
  const config = await loadFurnaceConfig(root);
@@ -26,6 +42,9 @@ export async function measureTokenCoverage(repoDir, cssFiles, projectRoot) {
26
42
  tokenPrefix = config.tokenPrefix;
27
43
  tokenAllowlist = new Set(config.tokenAllowlist ?? []);
28
44
  }
45
+ if (config.platformPrefixes !== undefined) {
46
+ platformPrefixes = config.platformPrefixes;
47
+ }
29
48
  }
30
49
  catch (error) {
31
50
  verbose(`Proceeding without furnace token metadata because furnace.json could not be loaded: ${toError(error).message}`);
@@ -56,6 +75,11 @@ export async function measureTokenCoverage(repoDir, cssFiles, projectRoot) {
56
75
  else if (tokenAllowlist?.has(prop)) {
57
76
  allowlisted++;
58
77
  }
78
+ else if (platformPrefixes.some((prefix) => prop.startsWith(prefix))) {
79
+ // Platform vars (upstream `--moz-*`) are counted as allowlisted
80
+ // so they don't drag the fork-owned coverage percentage down.
81
+ allowlisted++;
82
+ }
59
83
  else {
60
84
  unknownVars++;
61
85
  }
@@ -4,12 +4,16 @@
4
4
  /**
5
5
  * AST-based implementation: finds onUnload()/uninit() method body and
6
6
  * inserts the destroy block at the top (LIFO ordering).
7
+ *
8
+ * `marker` is prefixed to the generated comment so wire-generated
9
+ * edits carry the patch-lint `// <MARKER>:` signature
10
+ * `lintModificationComments` looks for (eval 1 Finding #9).
7
11
  */
8
- export declare function addDestroyAST(content: string, expression: string): string;
12
+ export declare function addDestroyAST(content: string, expression: string, marker?: string): string;
9
13
  /**
10
14
  * Legacy regex/line-based implementation preserved as fallback.
11
15
  */
12
- export declare function legacyAddDestroy(content: string, expression: string): string;
16
+ export declare function legacyAddDestroy(content: string, expression: string, marker?: string): string;
13
17
  /**
14
18
  * Adds a destroy expression to the top of onUnload() or uninit() in
15
19
  * browser-init.js (LIFO ordering — newest first).
@@ -18,4 +22,4 @@ export declare function legacyAddDestroy(content: string, expression: string): s
18
22
  * @param expression - The destroy expression (e.g., "MyComponent.destroy()")
19
23
  * @returns true if added, false if already present
20
24
  */
21
- export declare function addDestroyToBrowserInit(engineDir: string, expression: string): Promise<boolean>;
25
+ export declare function addDestroyToBrowserInit(engineDir: string, expression: string, marker?: string): Promise<boolean>;
@@ -12,11 +12,16 @@ import { detectIndent, parseScript } from './ast-utils.js';
12
12
  import { withParserFallback } from './parser-fallback.js';
13
13
  import { assertBraceBalancePreserved, coerceToCall, extractNameFromExpression, findMethodBody, findMethodBraceIndex, validateWireName, } from './wire-utils.js';
14
14
  const BROWSER_INIT_JS = 'browser/base/content/browser-init.js';
15
+ const DEFAULT_MARKER = 'FIREFORGE:';
15
16
  /**
16
17
  * AST-based implementation: finds onUnload()/uninit() method body and
17
18
  * inserts the destroy block at the top (LIFO ordering).
19
+ *
20
+ * `marker` is prefixed to the generated comment so wire-generated
21
+ * edits carry the patch-lint `// <MARKER>:` signature
22
+ * `lintModificationComments` looks for (eval 1 Finding #9).
18
23
  */
19
- export function addDestroyAST(content, expression) {
24
+ export function addDestroyAST(content, expression, marker = DEFAULT_MARKER) {
20
25
  const name = extractNameFromExpression(expression);
21
26
  // See wire-init.ts for the rationale: the template interpolates the
22
27
  // expression verbatim, so a bare `Foo.bar` compiled to `Foo.bar;`
@@ -44,7 +49,7 @@ export function addDestroyAST(content, expression) {
44
49
  indent = ' ';
45
50
  }
46
51
  const block = [
47
- `${indent}// ${name} destroy`,
52
+ `${indent}// ${marker} wire-destroy ${name}`,
48
53
  `${indent}try {`,
49
54
  `${indent} if (typeof ${name} !== "undefined") {`,
50
55
  `${indent} ${callExpression};`,
@@ -59,7 +64,7 @@ export function addDestroyAST(content, expression) {
59
64
  /**
60
65
  * Legacy regex/line-based implementation preserved as fallback.
61
66
  */
62
- export function legacyAddDestroy(content, expression) {
67
+ export function legacyAddDestroy(content, expression, marker = DEFAULT_MARKER) {
63
68
  const name = extractNameFromExpression(expression);
64
69
  // Match the AST path on the call-coercion contract so fallback vs AST
65
70
  // emits identical blocks (see wire-init.ts).
@@ -73,7 +78,7 @@ export function legacyAddDestroy(content, expression) {
73
78
  }
74
79
  const insertIndex = found.braceIndex + 1;
75
80
  const block = [
76
- ` // ${name} destroy`,
81
+ ` // ${marker} wire-destroy ${name}`,
77
82
  ` try {`,
78
83
  ` if (typeof ${name} !== "undefined") {`,
79
84
  ` ${callExpression};`,
@@ -93,7 +98,7 @@ export function legacyAddDestroy(content, expression) {
93
98
  * @param expression - The destroy expression (e.g., "MyComponent.destroy()")
94
99
  * @returns true if added, false if already present
95
100
  */
96
- export async function addDestroyToBrowserInit(engineDir, expression) {
101
+ export async function addDestroyToBrowserInit(engineDir, expression, marker = DEFAULT_MARKER) {
97
102
  validateWireName(expression, 'destroy expression');
98
103
  const filePath = join(engineDir, BROWSER_INIT_JS);
99
104
  if (!(await pathExists(filePath))) {
@@ -109,7 +114,7 @@ export async function addDestroyToBrowserInit(engineDir, expression) {
109
114
  if (destroyPattern.test(content)) {
110
115
  return false;
111
116
  }
112
- const { value, usedFallback } = withParserFallback(() => addDestroyAST(content, expression), () => legacyAddDestroy(content, expression), BROWSER_INIT_JS);
117
+ const { value, usedFallback } = withParserFallback(() => addDestroyAST(content, expression, marker), () => legacyAddDestroy(content, expression, marker), BROWSER_INIT_JS);
113
118
  if (usedFallback) {
114
119
  assertBraceBalancePreserved(content, value, BROWSER_INIT_JS);
115
120
  }