@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.
- package/CHANGELOG.md +53 -0
- package/README.md +60 -33
- package/dist/src/commands/build.js +18 -4
- package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
- package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
- package/dist/src/commands/doctor-furnace.js +2 -0
- package/dist/src/commands/doctor-working-tree.d.ts +29 -0
- package/dist/src/commands/doctor-working-tree.js +93 -0
- package/dist/src/commands/doctor.js +22 -12
- package/dist/src/commands/export-all.js +74 -4
- package/dist/src/commands/export-shared.d.ts +7 -1
- package/dist/src/commands/export-shared.js +21 -3
- package/dist/src/commands/furnace/create-xpcshell.js +4 -2
- package/dist/src/commands/furnace/override.js +23 -13
- package/dist/src/commands/furnace/preview.js +38 -0
- package/dist/src/commands/furnace/remove.js +75 -1
- package/dist/src/commands/furnace/rename-xpcshell.d.ts +35 -0
- package/dist/src/commands/furnace/rename-xpcshell.js +97 -0
- package/dist/src/commands/furnace/rename.js +32 -4
- package/dist/src/commands/lint.js +19 -6
- package/dist/src/commands/patch/delete.js +4 -1
- package/dist/src/commands/patch/reorder.js +4 -1
- package/dist/src/commands/re-export-files.js +3 -1
- package/dist/src/commands/re-export.js +4 -1
- package/dist/src/commands/rebase/index.js +19 -1
- package/dist/src/commands/register.js +11 -0
- package/dist/src/commands/status.js +44 -5
- package/dist/src/commands/test.js +68 -16
- package/dist/src/commands/token-coverage.js +10 -3
- package/dist/src/commands/verify.js +81 -6
- package/dist/src/commands/watch.js +43 -7
- package/dist/src/commands/wire.js +16 -0
- package/dist/src/core/browser-wire.js +21 -4
- package/dist/src/core/build-audit.js +10 -0
- package/dist/src/core/furnace-constants.d.ts +14 -0
- package/dist/src/core/furnace-constants.js +16 -0
- package/dist/src/core/furnace-validate.js +67 -1
- package/dist/src/core/git-base.d.ts +27 -2
- package/dist/src/core/git-base.js +41 -3
- package/dist/src/core/git-diff.js +21 -2
- package/dist/src/core/git.js +53 -14
- package/dist/src/core/mach.d.ts +26 -8
- package/dist/src/core/mach.js +24 -8
- package/dist/src/core/manifest-rules.js +10 -1
- package/dist/src/core/manifest-tokenizers.d.ts +6 -0
- package/dist/src/core/manifest-tokenizers.js +28 -0
- package/dist/src/core/marionette-preflight.d.ts +16 -0
- package/dist/src/core/marionette-preflight.js +19 -0
- package/dist/src/core/patch-lint-diff-tag.d.ts +20 -0
- package/dist/src/core/patch-lint-diff-tag.js +25 -0
- package/dist/src/core/patch-lint.d.ts +47 -2
- package/dist/src/core/patch-lint.js +94 -18
- package/dist/src/core/patch-manifest-consistency.js +15 -2
- package/dist/src/core/patch-manifest-io.js +10 -0
- package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
- package/dist/src/core/patch-manifest-resolve.js +29 -2
- package/dist/src/core/patch-manifest-validate.js +25 -1
- package/dist/src/core/patch-registration-refs.d.ts +42 -0
- package/dist/src/core/patch-registration-refs.js +117 -0
- package/dist/src/core/token-coverage.js +24 -0
- package/dist/src/core/wire-destroy.d.ts +7 -3
- package/dist/src/core/wire-destroy.js +11 -6
- package/dist/src/core/wire-init.d.ts +9 -3
- package/dist/src/core/wire-init.js +18 -6
- package/dist/src/core/wire-subscript.d.ts +7 -3
- package/dist/src/core/wire-subscript.js +11 -4
- package/dist/src/core/xpcshell-appdir.d.ts +19 -5
- package/dist/src/core/xpcshell-appdir.js +46 -20
- package/dist/src/errors/git.d.ts +20 -0
- package/dist/src/errors/git.js +39 -0
- package/dist/src/types/commands/patches.d.ts +23 -0
- package/dist/src/types/furnace.d.ts +9 -0
- package/dist/src/utils/parse.d.ts +7 -0
- package/dist/src/utils/parse.js +15 -0
- 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
|
-
*
|
|
78
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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}// ${
|
|
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
|
-
` // ${
|
|
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
|
}
|