@hominis/fireforge 0.17.0 → 0.18.0

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 (51) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +40 -20
  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 +9 -11
  10. package/dist/src/commands/export-all.js +11 -3
  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/override.js +23 -13
  14. package/dist/src/commands/furnace/remove.js +8 -0
  15. package/dist/src/commands/furnace/rename.js +23 -4
  16. package/dist/src/commands/lint.js +19 -6
  17. package/dist/src/commands/patch/delete.js +4 -1
  18. package/dist/src/commands/patch/reorder.js +4 -1
  19. package/dist/src/commands/re-export-files.js +3 -1
  20. package/dist/src/commands/re-export.js +4 -1
  21. package/dist/src/commands/register.js +11 -0
  22. package/dist/src/commands/test.js +53 -12
  23. package/dist/src/commands/token-coverage.js +10 -3
  24. package/dist/src/commands/wire.js +16 -0
  25. package/dist/src/core/browser-wire.js +21 -4
  26. package/dist/src/core/build-audit.js +10 -0
  27. package/dist/src/core/git-diff.js +21 -2
  28. package/dist/src/core/mach.d.ts +12 -6
  29. package/dist/src/core/mach.js +12 -6
  30. package/dist/src/core/manifest-rules.js +10 -1
  31. package/dist/src/core/manifest-tokenizers.d.ts +6 -0
  32. package/dist/src/core/manifest-tokenizers.js +28 -0
  33. package/dist/src/core/patch-lint.d.ts +47 -2
  34. package/dist/src/core/patch-lint.js +89 -14
  35. package/dist/src/core/patch-manifest-consistency.js +15 -2
  36. package/dist/src/core/patch-manifest-io.js +10 -0
  37. package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
  38. package/dist/src/core/patch-manifest-resolve.js +29 -2
  39. package/dist/src/core/patch-manifest-validate.js +25 -1
  40. package/dist/src/core/token-coverage.js +24 -0
  41. package/dist/src/core/wire-destroy.d.ts +7 -3
  42. package/dist/src/core/wire-destroy.js +11 -6
  43. package/dist/src/core/wire-init.d.ts +9 -3
  44. package/dist/src/core/wire-init.js +18 -6
  45. package/dist/src/core/wire-subscript.d.ts +7 -3
  46. package/dist/src/core/wire-subscript.js +11 -4
  47. package/dist/src/types/commands/patches.d.ts +23 -0
  48. package/dist/src/types/furnace.d.ts +9 -0
  49. package/dist/src/utils/parse.d.ts +7 -0
  50. package/dist/src/utils/parse.js +15 -0
  51. package/package.json +1 -1
@@ -9,7 +9,7 @@ import { verbose } from '../utils/logger.js';
9
9
  import { exec } from '../utils/process.js';
10
10
  import { ensureGit, git } from './git-base.js';
11
11
  import { fileExistsInHead } from './git-file-ops.js';
12
- import { getUntrackedFiles } from './git-status.js';
12
+ import { getUntrackedFiles, getUntrackedFilesInDir } from './git-status.js';
13
13
  async function execGitWithAllowedExitCodes(repoDir, args, allowedExitCodes = [0]) {
14
14
  const result = await exec('git', args, { cwd: repoDir });
15
15
  if (allowedExitCodes.includes(result.exitCode)) {
@@ -183,7 +183,26 @@ export async function getAllDiff(repoDir) {
183
183
  */
184
184
  export async function getDiffForFilesAgainstHead(repoDir, files) {
185
185
  await ensureGit();
186
- const uniqueFiles = [...new Set(files)].sort();
186
+ // Expand any directory entries (paths ending with `/`) into their
187
+ // individual untracked files before diffing. `git status --porcelain=v1`
188
+ // reports collapsed untracked directories as `?? dir/`, and every caller
189
+ // that feeds the aggregate working-tree state into this function must
190
+ // not trigger an EISDIR when the diff pass reads `dir/` as if it were a
191
+ // file. Belt-and-suspenders: the caller-side expansion in `lint.ts`
192
+ // and `export-all.ts` covers the common path, but a single bad call
193
+ // site re-introduced the bug in 0.17.0 — guarding here makes the
194
+ // regression impossible at this layer.
195
+ const expandedFiles = [];
196
+ for (const file of files) {
197
+ if (file.endsWith('/')) {
198
+ const inner = await getUntrackedFilesInDir(repoDir, file);
199
+ for (const entry of inner)
200
+ expandedFiles.push(entry);
201
+ continue;
202
+ }
203
+ expandedFiles.push(file);
204
+ }
205
+ const uniqueFiles = [...new Set(expandedFiles)].sort();
187
206
  const diffs = [];
188
207
  for (const file of uniqueFiles) {
189
208
  if (await fileExistsInHead(repoDir, file)) {
@@ -60,19 +60,25 @@ export declare function bootstrapWithOutput(engineDir: string): Promise<MachComm
60
60
  /**
61
61
  * Runs a full mach build. On a non-zero exit, any matched error hints are
62
62
  * surfaced on top of the raw mach output so operators get an actionable
63
- * nudge alongside the cryptic mozbuild traceback.
63
+ * nudge alongside the cryptic mozbuild traceback. Returns the captured
64
+ * result so the caller (e.g. `fireforge build`) can inspect the tail
65
+ * for post-build diagnostics that mach prints AFTER "Your build was
66
+ * successful!" — notably the stale `config.status is out of date`
67
+ * notice that mach emits when a tool-managed edit landed on
68
+ * `moz.configure` before the build.
64
69
  * @param engineDir - Path to the engine directory
65
70
  * @param jobs - Number of parallel jobs (optional)
66
- * @returns Exit code
71
+ * @returns Captured mach result (stdout tail, stderr tail, exit code)
67
72
  */
68
- export declare function build(engineDir: string, jobs?: number): Promise<number>;
73
+ export declare function build(engineDir: string, jobs?: number): Promise<MachCommandResult>;
69
74
  /**
70
75
  * Runs a fast UI-only build. On a non-zero exit, any matched error hints are
71
- * surfaced on top of the raw mach output.
76
+ * surfaced on top of the raw mach output. See {@link build} for why the
77
+ * full captured result is returned rather than just the exit code.
72
78
  * @param engineDir - Path to the engine directory
73
- * @returns Exit code
79
+ * @returns Captured mach result
74
80
  */
75
- export declare function buildUI(engineDir: string): Promise<number>;
81
+ export declare function buildUI(engineDir: string): Promise<MachCommandResult>;
76
82
  /**
77
83
  * Runs an operation while holding a sidecar build lock keyed on the
78
84
  * project root. Concurrent `fireforge build` / `fireforge build --ui`
@@ -137,10 +137,15 @@ function surfaceMachErrorHints(result) {
137
137
  /**
138
138
  * Runs a full mach build. On a non-zero exit, any matched error hints are
139
139
  * surfaced on top of the raw mach output so operators get an actionable
140
- * nudge alongside the cryptic mozbuild traceback.
140
+ * nudge alongside the cryptic mozbuild traceback. Returns the captured
141
+ * result so the caller (e.g. `fireforge build`) can inspect the tail
142
+ * for post-build diagnostics that mach prints AFTER "Your build was
143
+ * successful!" — notably the stale `config.status is out of date`
144
+ * notice that mach emits when a tool-managed edit landed on
145
+ * `moz.configure` before the build.
141
146
  * @param engineDir - Path to the engine directory
142
147
  * @param jobs - Number of parallel jobs (optional)
143
- * @returns Exit code
148
+ * @returns Captured mach result (stdout tail, stderr tail, exit code)
144
149
  */
145
150
  export async function build(engineDir, jobs) {
146
151
  const args = ['build'];
@@ -151,20 +156,21 @@ export async function build(engineDir, jobs) {
151
156
  if (result.exitCode !== 0) {
152
157
  surfaceMachErrorHints(result);
153
158
  }
154
- return result.exitCode;
159
+ return result;
155
160
  }
156
161
  /**
157
162
  * Runs a fast UI-only build. On a non-zero exit, any matched error hints are
158
- * surfaced on top of the raw mach output.
163
+ * surfaced on top of the raw mach output. See {@link build} for why the
164
+ * full captured result is returned rather than just the exit code.
159
165
  * @param engineDir - Path to the engine directory
160
- * @returns Exit code
166
+ * @returns Captured mach result
161
167
  */
162
168
  export async function buildUI(engineDir) {
163
169
  const result = await runMachInheritCapture(['build', 'faster'], engineDir);
164
170
  if (result.exitCode !== 0) {
165
171
  surfaceMachErrorHints(result);
166
172
  }
167
- return result.exitCode;
173
+ return result;
168
174
  }
169
175
  /**
170
176
  * Runs an operation while holding a sidecar build lock keyed on the
@@ -24,7 +24,16 @@ export function getRules(binaryName) {
24
24
  // proposed a bogus jar.mn entry. The lookahead blocks the match so
25
25
  // `getUnregistrableAdvice` gets a chance to emit the correct
26
26
  // guidance for the `.inc.xhtml` case.
27
- pattern: /^browser\/base\/content\/(?!.+\.inc\.xhtml$)(.+\.(?:js|mjs|xhtml|css))$/,
27
+ //
28
+ // Test implementation files under `browser/base/content/test/` are
29
+ // also excluded: they belong in the nearest `browser.toml` manifest,
30
+ // not in jar.mn. 2026-04-23 eval 2: `status --unmanaged` proposed
31
+ // `fireforge register browser/base/content/test/<dir>/browser_*.js`
32
+ // which would have clutter-registered a test file as browser
33
+ // chrome content. The negative lookahead routes those paths to
34
+ // `getUnregistrableAdvice`, which returns the correct
35
+ // browser.toml-centric guidance.
36
+ pattern: /^browser\/base\/content\/(?!.+\.inc\.xhtml$)(?!test\/)(.+\.(?:js|mjs|xhtml|css))$/,
28
37
  isRegistered: (engineDir, fileName) => isBrowserContentRegistered(engineDir, fileName),
29
38
  register: (engineDir, after, dryRun, fileName) => registerBrowserContent(engineDir, fileName, after, undefined, dryRun),
30
39
  extractArgs: (m) => [m[1] ?? ''],
@@ -26,6 +26,12 @@ export declare function tokenizeJarMn(lines: string[]): JarMnToken[];
26
26
  /**
27
27
  * Tokenizes a moz.build Python list block, returning the tokens and their
28
28
  * line range within the file.
29
+ *
30
+ * Supports both multi-line lists (the common shape) and single-line
31
+ * empty lists of the form `EXTRA_JS_MODULES += []` — the eval-2 finding
32
+ * case where a freshly-scaffolded module directory's `moz.build`
33
+ * started with an empty list and the tokenizer returned `null`,
34
+ * leaving `register` unable to add the first entry.
29
35
  */
30
36
  export declare function tokenizeMozBuildList(lines: string[], listPattern: RegExp): {
31
37
  tokens: MozBuildToken[];
@@ -44,6 +44,12 @@ export function tokenizeJarMn(lines) {
44
44
  /**
45
45
  * Tokenizes a moz.build Python list block, returning the tokens and their
46
46
  * line range within the file.
47
+ *
48
+ * Supports both multi-line lists (the common shape) and single-line
49
+ * empty lists of the form `EXTRA_JS_MODULES += []` — the eval-2 finding
50
+ * case where a freshly-scaffolded module directory's `moz.build`
51
+ * started with an empty list and the tokenizer returned `null`,
52
+ * leaving `register` unable to add the first entry.
47
53
  */
48
54
  export function tokenizeMozBuildList(lines, listPattern) {
49
55
  const tokens = [];
@@ -53,6 +59,28 @@ export function tokenizeMozBuildList(lines, listPattern) {
53
59
  const raw = lines[i] ?? '';
54
60
  if (startLine === -1) {
55
61
  if (listPattern.test(raw)) {
62
+ // Single-line empty-list handling: a fresh scaffold sometimes
63
+ // writes `EXTRA_JS_MODULES += []` on one line. The pre-fix
64
+ // tokenizer returned `null` because it never saw a line
65
+ // starting with `]`, which stranded `register` with a "Could
66
+ // not find module list section" error against the documented
67
+ // browser/modules/<fork>/ scaffold (eval 2).
68
+ //
69
+ // The in-place split rewrites the single-line form into the
70
+ // canonical multi-line shape so the caller's
71
+ // `lines.splice(insertIndex, 0, entry)` lands inside the list
72
+ // body. The tokens are emitted to mirror the new structure.
73
+ const singleLineMatch = /^([^[]*\[)\s*\]\s*$/.exec(raw);
74
+ if (singleLineMatch) {
75
+ const openPart = singleLineMatch[1] ?? '';
76
+ lines[i] = openPart;
77
+ lines.splice(i + 1, 0, ']');
78
+ startLine = i;
79
+ endLine = i + 1;
80
+ tokens.push({ type: 'list-open', raw: openPart, lineIndex: i });
81
+ tokens.push({ type: 'list-close', raw: ']', lineIndex: i + 1 });
82
+ break;
83
+ }
56
84
  startLine = i;
57
85
  tokens.push({ type: 'list-open', raw, lineIndex: i });
58
86
  }
@@ -67,10 +67,49 @@ export declare function lintPatchedJs(repoDir: string, affectedFiles: string[],
67
67
  * @returns Array of lint issues
68
68
  */
69
69
  export declare function lintModificationComments(diffContent: string, config: FireForgeConfig): PatchLintIssue[];
70
+ /**
71
+ * Describes which tier `resolvePatchSizeTier` selected and why.
72
+ * Consumers that want to surface the tier choice to the operator
73
+ * (e.g. a one-line `info()` when branding thresholds kick in) read
74
+ * this alongside the issues array from `lintPatchSize`.
75
+ */
76
+ export type PatchSizeTierDecision = {
77
+ tier: 'general';
78
+ } | {
79
+ tier: 'test';
80
+ } | {
81
+ tier: 'branding';
82
+ source: 'auto' | 'explicit';
83
+ };
84
+ /**
85
+ * Decides which `large-patch-lines` threshold tier applies to a patch.
86
+ * Exported so `runPatchLint` and the per-patch `lint` command can
87
+ * surface the tier choice to the operator *without* depending on
88
+ * `lintPatchSize`'s internal return shape — the rule itself stays a
89
+ * pure issues-array API, and the decision is computed separately for
90
+ * the sole purpose of reporting.
91
+ *
92
+ * Precedence: test > branding (explicit) > branding (auto) > general.
93
+ * The test tier beats branding because a table-driven regression test
94
+ * is legitimately large independent of whether the patch also claims
95
+ * branding shape, and the test-tier thresholds are already more
96
+ * permissive than branding — so "tests beat branding" is the
97
+ * defensive-for-tests choice.
98
+ */
99
+ export declare function resolvePatchSizeTier(filesAffected: ReadonlyArray<string>, patchTier?: 'branding'): PatchSizeTierDecision;
70
100
  /**
71
101
  * Checks patch size and emits advisory warnings.
102
+ *
103
+ * @param filesAffected - Files touched by the patch
104
+ * @param lineCount - Non-binary line count of the unified diff
105
+ * @param patchTier - Optional explicit tier override declared on
106
+ * `PatchMetadata.tier`. When `"branding"`, forces the branding
107
+ * thresholds regardless of `filesAffected`. Tests still win over
108
+ * branding (precedence `test > branding > general`) because the
109
+ * test-tier thresholds are already more permissive and an all-tests
110
+ * patch that is also branding-shaped is vanishingly rare.
72
111
  */
73
- export declare function lintPatchSize(filesAffected: string[], lineCount: number): PatchLintIssue[];
112
+ export declare function lintPatchSize(filesAffected: string[], lineCount: number, patchTier?: 'branding'): PatchLintIssue[];
74
113
  /**
75
114
  * Checks that modified (non-new) files with a supported extension still
76
115
  * start with a recognized license header.
@@ -94,6 +133,12 @@ export declare function lintModifiedFileHeaders(repoDir: string, affectedFiles:
94
133
  * is advisory-noisy by nature (a cohesive branding bundle, auto-generated
95
134
  * manifest, etc.) can opt out of a specific rule without reaching for the
96
135
  * blunt `--skip-lint` hammer. Not mutated by this function.
136
+ * @param patchTier - Optional explicit tier override, threaded from
137
+ * `PatchMetadata.tier`. When `"branding"` forces the branding
138
+ * thresholds on the `large-patch-lines` rule. Callers with a
139
+ * per-patch manifest context (re-export, per-patch lint) should
140
+ * pass this; aggregate-mode callers without a specific patch
141
+ * context skip it and fall through to auto-detection.
97
142
  * @returns Array of all lint issues found
98
143
  */
99
- export declare function lintExportedPatch(repoDir: string, affectedFiles: string[], diffContent: string, config: FireForgeConfig, patchQueueCtx?: import('./patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string>): Promise<PatchLintIssue[]>;
144
+ export declare function lintExportedPatch(repoDir: string, affectedFiles: string[], diffContent: string, config: FireForgeConfig, patchQueueCtx?: import('./patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string>, patchTier?: 'branding'): Promise<PatchLintIssue[]>;
@@ -74,13 +74,49 @@ const PATCH_LINE_THRESHOLDS = {
74
74
  branding: { notice: 3000, warning: 8000, error: 20000 },
75
75
  };
76
76
  /**
77
- * Returns true when every file in a patch lives under `browser/branding/`.
78
- * Used by `lintPatchSize` to pick the branding threshold tier.
77
+ * Fixed allowlist of non-branding sibling paths that real-world Firefox
78
+ * branding patches legitimately need to touch to register the new
79
+ * branding flavor with the top-level configure. The 2026-04-21
80
+ * external eval showed that a branding patch which also touches
81
+ * `browser/moz.configure` (the canonical registration point) fell
82
+ * through to the general lint tier because the original predicate
83
+ * required every file to live under `browser/branding/`. This
84
+ * allowlist stays intentionally narrow — additions require a real
85
+ * operator data point, not a speculative expansion. Add new entries
86
+ * only when a genuine branding patch cannot be expressed without a
87
+ * specific registration sibling.
88
+ *
89
+ * Pinned against ESR 140.x conventions at time of writing.
90
+ */
91
+ const BRANDING_REGISTRATION_FILES = new Set([
92
+ 'browser/moz.configure',
93
+ 'browser/confvars.sh',
94
+ ]);
95
+ /**
96
+ * Returns true when a patch qualifies for the branding threshold tier:
97
+ * every file lives either under `browser/branding/` or in the narrow
98
+ * registration allowlist, AND the patch contains at least one file
99
+ * under `browser/branding/` (guard against a config-only patch
100
+ * accidentally qualifying as branding).
101
+ *
102
+ * Used by `lintPatchSize` to pick the branding threshold tier. The
103
+ * explicit `tier: "branding"` field on `PatchMetadata` bypasses this
104
+ * heuristic and forces the branding tier directly.
79
105
  */
80
106
  function isBrandingOnlyPatch(files) {
81
107
  if (files.length === 0)
82
108
  return false;
83
- return files.every((file) => file.startsWith('browser/branding/'));
109
+ let hasBrandingFile = false;
110
+ for (const file of files) {
111
+ if (file.startsWith('browser/branding/')) {
112
+ hasBrandingFile = true;
113
+ continue;
114
+ }
115
+ if (BRANDING_REGISTRATION_FILES.has(file))
116
+ continue;
117
+ return false;
118
+ }
119
+ return hasBrandingFile;
84
120
  }
85
121
  /**
86
122
  * Returns true if the filename looks like a JS/MJS/JSM file.
@@ -426,13 +462,44 @@ export function lintModificationComments(diffContent, config) {
426
462
  }
427
463
  return issues;
428
464
  }
429
- // ---------------------------------------------------------------------------
430
- // Patch size lint (moved from export-shared.ts warnLargePatch)
431
- // ---------------------------------------------------------------------------
465
+ /**
466
+ * Decides which `large-patch-lines` threshold tier applies to a patch.
467
+ * Exported so `runPatchLint` and the per-patch `lint` command can
468
+ * surface the tier choice to the operator *without* depending on
469
+ * `lintPatchSize`'s internal return shape — the rule itself stays a
470
+ * pure issues-array API, and the decision is computed separately for
471
+ * the sole purpose of reporting.
472
+ *
473
+ * Precedence: test > branding (explicit) > branding (auto) > general.
474
+ * The test tier beats branding because a table-driven regression test
475
+ * is legitimately large independent of whether the patch also claims
476
+ * branding shape, and the test-tier thresholds are already more
477
+ * permissive than branding — so "tests beat branding" is the
478
+ * defensive-for-tests choice.
479
+ */
480
+ export function resolvePatchSizeTier(filesAffected, patchTier) {
481
+ const allTests = filesAffected.length > 0 && filesAffected.every(isTestFile);
482
+ if (allTests)
483
+ return { tier: 'test' };
484
+ if (patchTier === 'branding')
485
+ return { tier: 'branding', source: 'explicit' };
486
+ if (isBrandingOnlyPatch(filesAffected))
487
+ return { tier: 'branding', source: 'auto' };
488
+ return { tier: 'general' };
489
+ }
432
490
  /**
433
491
  * Checks patch size and emits advisory warnings.
492
+ *
493
+ * @param filesAffected - Files touched by the patch
494
+ * @param lineCount - Non-binary line count of the unified diff
495
+ * @param patchTier - Optional explicit tier override declared on
496
+ * `PatchMetadata.tier`. When `"branding"`, forces the branding
497
+ * thresholds regardless of `filesAffected`. Tests still win over
498
+ * branding (precedence `test > branding > general`) because the
499
+ * test-tier thresholds are already more permissive and an all-tests
500
+ * patch that is also branding-shaped is vanishingly rare.
434
501
  */
435
- export function lintPatchSize(filesAffected, lineCount) {
502
+ export function lintPatchSize(filesAffected, lineCount, patchTier) {
436
503
  const issues = [];
437
504
  if (filesAffected.length > 5) {
438
505
  issues.push({
@@ -447,12 +514,14 @@ export function lintPatchSize(filesAffected, lineCount) {
447
514
  // harnesses run into the thousands of lines). Branding patches get their
448
515
  // own tier so a first-export of setup-generated branding doesn't fire
449
516
  // 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
517
+ // for the eval data motivating this tier. An explicit `patchTier`
518
+ // opt-in forces branding even when `isBrandingOnlyPatch` cannot reach
519
+ // the patch's actual shape (a branding patch that also touches a
520
+ // non-allowlisted sibling like a vendor-specific icon resource).
521
+ const decision = resolvePatchSizeTier(filesAffected, patchTier);
522
+ const thresholds = decision.tier === 'test'
454
523
  ? PATCH_LINE_THRESHOLDS.test
455
- : branding
524
+ : decision.tier === 'branding'
456
525
  ? PATCH_LINE_THRESHOLDS.branding
457
526
  : PATCH_LINE_THRESHOLDS.general;
458
527
  if (lineCount >= thresholds.error) {
@@ -534,9 +603,15 @@ export async function lintModifiedFileHeaders(repoDir, affectedFiles, newFiles)
534
603
  * is advisory-noisy by nature (a cohesive branding bundle, auto-generated
535
604
  * manifest, etc.) can opt out of a specific rule without reaching for the
536
605
  * blunt `--skip-lint` hammer. Not mutated by this function.
606
+ * @param patchTier - Optional explicit tier override, threaded from
607
+ * `PatchMetadata.tier`. When `"branding"` forces the branding
608
+ * thresholds on the `large-patch-lines` rule. Callers with a
609
+ * per-patch manifest context (re-export, per-patch lint) should
610
+ * pass this; aggregate-mode callers without a specific patch
611
+ * context skip it and fall through to auto-detection.
537
612
  * @returns Array of all lint issues found
538
613
  */
539
- export async function lintExportedPatch(repoDir, affectedFiles, diffContent, config, patchQueueCtx, ignoreChecks) {
614
+ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, config, patchQueueCtx, ignoreChecks, patchTier) {
540
615
  const newFiles = detectNewFilesInDiff(diffContent);
541
616
  const { textLines: lineCount } = countNonBinaryDiffLines(diffContent);
542
617
  const patchOwnedFiles = resolvePatchOwnedSysMjs(newFiles, patchQueueCtx);
@@ -547,7 +622,7 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
547
622
  lintModifiedFileHeaders(repoDir, affectedFiles, newFiles),
548
623
  ]);
549
624
  const modCommentIssues = lintModificationComments(diffContent, config);
550
- const sizeIssues = lintPatchSize(affectedFiles, lineCount);
625
+ const sizeIssues = lintPatchSize(affectedFiles, lineCount, patchTier);
551
626
  const issues = [
552
627
  ...sizeIssues,
553
628
  ...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) {
@@ -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>;