@hominis/fireforge 0.16.5 → 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 (73) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +46 -24
  3. package/dist/src/commands/build.js +33 -10
  4. package/dist/src/commands/config.js +32 -20
  5. package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
  6. package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
  7. package/dist/src/commands/doctor-furnace.js +2 -0
  8. package/dist/src/commands/doctor-working-tree.d.ts +29 -0
  9. package/dist/src/commands/doctor-working-tree.js +93 -0
  10. package/dist/src/commands/doctor.js +23 -12
  11. package/dist/src/commands/export-all.js +11 -3
  12. package/dist/src/commands/export-shared.d.ts +7 -1
  13. package/dist/src/commands/export-shared.js +21 -3
  14. package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
  15. package/dist/src/commands/furnace/create-templates.d.ts +11 -0
  16. package/dist/src/commands/furnace/create-templates.js +11 -2
  17. package/dist/src/commands/furnace/init.js +97 -9
  18. package/dist/src/commands/furnace/override.js +23 -13
  19. package/dist/src/commands/furnace/remove.js +8 -0
  20. package/dist/src/commands/furnace/rename.js +133 -4
  21. package/dist/src/commands/lint.js +70 -6
  22. package/dist/src/commands/patch/delete.js +4 -1
  23. package/dist/src/commands/patch/reorder.js +4 -1
  24. package/dist/src/commands/re-export-files.js +3 -1
  25. package/dist/src/commands/re-export.js +4 -1
  26. package/dist/src/commands/register.js +11 -0
  27. package/dist/src/commands/resolve.d.ts +25 -1
  28. package/dist/src/commands/resolve.js +25 -15
  29. package/dist/src/commands/status.js +100 -122
  30. package/dist/src/commands/test.js +68 -14
  31. package/dist/src/commands/token-coverage.js +10 -3
  32. package/dist/src/commands/wire.js +50 -8
  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/config.d.ts +33 -0
  36. package/dist/src/core/config.js +43 -0
  37. package/dist/src/core/furnace-config.d.ts +23 -2
  38. package/dist/src/core/furnace-config.js +26 -3
  39. package/dist/src/core/git-diff.js +21 -2
  40. package/dist/src/core/mach.d.ts +43 -6
  41. package/dist/src/core/mach.js +57 -7
  42. package/dist/src/core/manifest-rules.js +10 -1
  43. package/dist/src/core/manifest-tokenizers.d.ts +6 -0
  44. package/dist/src/core/manifest-tokenizers.js +28 -0
  45. package/dist/src/core/marionette-port.d.ts +50 -0
  46. package/dist/src/core/marionette-port.js +215 -0
  47. package/dist/src/core/patch-lint.d.ts +47 -2
  48. package/dist/src/core/patch-lint.js +89 -14
  49. package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
  50. package/dist/src/core/patch-manifest-consistency.js +31 -3
  51. package/dist/src/core/patch-manifest-io.js +10 -0
  52. package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
  53. package/dist/src/core/patch-manifest-resolve.js +29 -2
  54. package/dist/src/core/patch-manifest-validate.js +25 -1
  55. package/dist/src/core/status-classify.d.ts +54 -0
  56. package/dist/src/core/status-classify.js +134 -0
  57. package/dist/src/core/token-coverage.js +24 -0
  58. package/dist/src/core/token-dark-mode.d.ts +49 -0
  59. package/dist/src/core/token-dark-mode.js +182 -0
  60. package/dist/src/core/token-manager.js +17 -33
  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-dom-fragment.d.ts +17 -0
  64. package/dist/src/core/wire-dom-fragment.js +40 -0
  65. package/dist/src/core/wire-init.d.ts +9 -3
  66. package/dist/src/core/wire-init.js +18 -6
  67. package/dist/src/core/wire-subscript.d.ts +7 -3
  68. package/dist/src/core/wire-subscript.js +11 -4
  69. package/dist/src/types/commands/patches.d.ts +23 -0
  70. package/dist/src/types/furnace.d.ts +9 -0
  71. package/dist/src/utils/parse.d.ts +7 -0
  72. package/dist/src/utils/parse.js +15 -0
  73. package/package.json +1 -1
@@ -0,0 +1,215 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Marionette port probe.
4
+ *
5
+ * Gecko's Marionette control channel binds `127.0.0.1:2828` when a
6
+ * Firefox / ForgeFresh / Hominis instance is launched with
7
+ * `-marionette`. The `fireforge test` harness spawns the browser with
8
+ * that flag, so any test run needs the port to be free at start.
9
+ *
10
+ * Motivating case (2026-04-21 eval, Finding #20): an interrupted
11
+ * `fireforge test --headless` left an orphan
12
+ * `ForgeFresh.app/Contents/MacOS/forgefresh -marionette` process
13
+ * listening on 2828 with parent PID 1. The next `fireforge test` run
14
+ * — in a *different* FireForge project — failed immediately with a
15
+ * Marionette bind error, and FireForge's generic "re-run build" hint
16
+ * did not mention the stale listener. The probe in this module runs
17
+ * before every test launch, detects when the port is held, and —
18
+ * when the holder is a browser process (by command-line `-marionette`
19
+ * flag or by basename matching a known browser binary) — throws a
20
+ * targeted `GeneralError` naming the PID and the exact `kill` command
21
+ * to run.
22
+ *
23
+ * Cross-platform implementation:
24
+ * - POSIX (macOS / Linux): `lsof -i tcp:<port> -P -n -sTCP:LISTEN`
25
+ * - Windows: `Get-NetTCPConnection` via PowerShell, then
26
+ * `Get-Process` to resolve the PID into a command line.
27
+ *
28
+ * Both paths tolerate missing tooling: if `lsof` / PowerShell isn't
29
+ * installed, the probe returns `{ inUse: false }` rather than failing
30
+ * the test run itself — the probe is a best-effort friendliness
31
+ * check, not a prerequisite.
32
+ */
33
+ import { GeneralError } from '../errors/base.js';
34
+ import { toError } from '../utils/errors.js';
35
+ import { getPlatform } from '../utils/platform.js';
36
+ import { exec } from '../utils/process.js';
37
+ /** Default Marionette control port set by `-marionette`. */
38
+ export const DEFAULT_MARIONETTE_PORT = 2828;
39
+ /** Basenames of browser binaries that ship a Marionette listener. */
40
+ const BROWSER_BASENAMES = new Set([
41
+ 'firefox',
42
+ 'firefox-bin',
43
+ 'firefox-esr',
44
+ 'forgefresh',
45
+ 'hominis',
46
+ 'thunderbird',
47
+ ]);
48
+ /**
49
+ * Returns `true` when the holder's command basename or command-line
50
+ * flags clearly identify it as a Firefox-family browser with
51
+ * Marionette enabled. Used to decide whether to raise a targeted
52
+ * "stale browser on port" error vs a soft "unrelated listener"
53
+ * warning.
54
+ *
55
+ * Includes the operator-provided `binaryName` from `fireforge.json`
56
+ * so a fork that ships under a custom name (e.g. Hominis'
57
+ * `hominis-nightly`) is still recognised as a browser.
58
+ */
59
+ function isBrowserHolder(holder, binaryName) {
60
+ if (/\s-marionette(?:\s|$)/.test(holder.commandLine)) {
61
+ return true;
62
+ }
63
+ const name = holder.command.toLowerCase();
64
+ if (BROWSER_BASENAMES.has(name))
65
+ return true;
66
+ if (binaryName && name === binaryName.toLowerCase())
67
+ return true;
68
+ return false;
69
+ }
70
+ /**
71
+ * Probes the given port with `lsof` (macOS / Linux). Returns
72
+ * `{ inUse: false }` when the port is free OR when `lsof` is not
73
+ * available — the probe is a best-effort courtesy check, so a
74
+ * missing tool must not block the test run.
75
+ */
76
+ async function probeWithLsof(port) {
77
+ try {
78
+ // `-sTCP:LISTEN` filters to listeners only; `-P -n` avoids
79
+ // service/host lookups (faster + no DNS-dependent flakiness).
80
+ // `-Fpcn` emits a machine-readable format: one field per line,
81
+ // with `p<pid>`, `c<command>`, `n<name>` records.
82
+ const result = await exec('lsof', ['-i', `tcp:${port}`, '-P', '-n', '-sTCP:LISTEN', '-Fpcn']);
83
+ // `lsof` exits 1 when no matches — that's "port is free", not an
84
+ // error. We key off stdout shape instead of exit code.
85
+ const stdout = result.stdout;
86
+ const lines = stdout.split(/\r?\n/).filter((l) => l.length > 0);
87
+ let pid = -1;
88
+ let command = '';
89
+ for (const line of lines) {
90
+ if (line.startsWith('p'))
91
+ pid = parseInt(line.slice(1), 10);
92
+ else if (line.startsWith('c'))
93
+ command = line.slice(1);
94
+ }
95
+ if (!Number.isFinite(pid) || pid < 0 || command === '') {
96
+ return { inUse: false };
97
+ }
98
+ // `lsof` does not return the full command line; `ps` does. A
99
+ // missing `ps` (exotic Linux container) falls back to `command`
100
+ // alone, which is still enough to match `BROWSER_BASENAMES`.
101
+ let commandLine = command;
102
+ try {
103
+ const psResult = await exec('ps', ['-p', String(pid), '-o', 'args=']);
104
+ const psLine = psResult.stdout.split(/\r?\n/).find((l) => l.trim().length > 0);
105
+ if (psLine)
106
+ commandLine = psLine.trim();
107
+ }
108
+ catch {
109
+ // ps not available — keep the basename.
110
+ }
111
+ return { inUse: true, holder: { pid, command, commandLine } };
112
+ }
113
+ catch (error) {
114
+ // `lsof` missing, or stdout parse failed. Treat as "unknown" ⇒
115
+ // port probe is silently skipped.
116
+ const message = toError(error).message;
117
+ if (/ENOENT|not found|command not found/i.test(message)) {
118
+ return { inUse: false };
119
+ }
120
+ return { inUse: false };
121
+ }
122
+ }
123
+ /**
124
+ * Probes the given port with PowerShell (Windows). Uses
125
+ * `Get-NetTCPConnection` to find listeners, then `Get-Process -Id`
126
+ * to resolve the process name and command line. Gracefully degrades
127
+ * when PowerShell is unavailable.
128
+ */
129
+ async function probeWithPowerShell(port) {
130
+ const script = `$c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue;` +
131
+ ` if ($null -eq $c) { exit 0 }` +
132
+ ` $p = Get-Process -Id $c[0].OwningProcess -ErrorAction SilentlyContinue;` +
133
+ ` if ($null -eq $p) { exit 0 }` +
134
+ ` $w = Get-CimInstance Win32_Process -Filter "ProcessId = $($p.Id)" -ErrorAction SilentlyContinue;` +
135
+ ` Write-Output ("PID=" + $p.Id);` +
136
+ ` Write-Output ("NAME=" + $p.ProcessName);` +
137
+ ` if ($w -ne $null) { Write-Output ("CMD=" + $w.CommandLine) }`;
138
+ try {
139
+ const result = await exec('powershell.exe', [
140
+ '-NoProfile',
141
+ '-NonInteractive',
142
+ '-Command',
143
+ script,
144
+ ]);
145
+ const stdout = result.stdout;
146
+ const pidMatch = /PID=(\d+)/.exec(stdout);
147
+ const nameMatch = /NAME=([^\r\n]+)/.exec(stdout);
148
+ if (!pidMatch || !nameMatch)
149
+ return { inUse: false };
150
+ const pid = parseInt(pidMatch[1] ?? '', 10);
151
+ const command = (nameMatch[1] ?? '').trim();
152
+ const cmdMatch = /CMD=([^\r\n]+)/.exec(stdout);
153
+ const commandLine = cmdMatch ? (cmdMatch[1] ?? '').trim() : command;
154
+ if (!Number.isFinite(pid) || command === '')
155
+ return { inUse: false };
156
+ return { inUse: true, holder: { pid, command, commandLine } };
157
+ }
158
+ catch {
159
+ return { inUse: false };
160
+ }
161
+ }
162
+ /**
163
+ * Probes whether the Marionette port is currently bound by a
164
+ * listener. The probe is best-effort: missing tooling returns
165
+ * `{ inUse: false }` without failing.
166
+ *
167
+ * @param port - Port to probe (default {@link DEFAULT_MARIONETTE_PORT}).
168
+ */
169
+ export async function probeMarionettePort(port = DEFAULT_MARIONETTE_PORT) {
170
+ let platform;
171
+ try {
172
+ platform = getPlatform();
173
+ }
174
+ catch {
175
+ return { inUse: false };
176
+ }
177
+ if (platform === 'darwin' || platform === 'linux') {
178
+ return probeWithLsof(port);
179
+ }
180
+ // win32
181
+ return probeWithPowerShell(port);
182
+ }
183
+ /**
184
+ * Raises a targeted {@link GeneralError} when the Marionette port
185
+ * is held by a browser process; raises a softer warning-shaped
186
+ * error when the holder is unrelated (so the operator still sees
187
+ * a useful signal but can decide whether to wait it out).
188
+ *
189
+ * @param port - Port to probe (default {@link DEFAULT_MARIONETTE_PORT}).
190
+ * @param options - Extra context for the error message (the project's
191
+ * `binaryName` is used to recognise fork-branded browser binaries).
192
+ */
193
+ export async function assertMarionettePortAvailable(port = DEFAULT_MARIONETTE_PORT, options = {}) {
194
+ const probe = await probeMarionettePort(port);
195
+ if (!probe.inUse || !probe.holder)
196
+ return;
197
+ const holder = probe.holder;
198
+ if (isBrowserHolder(holder, options.binaryName)) {
199
+ const killHint = process.platform === 'win32'
200
+ ? `Stop-Process -Id ${holder.pid} -Force`
201
+ : `kill ${holder.pid} # or "kill -9 ${holder.pid}" if it doesn't exit`;
202
+ throw new GeneralError(`Marionette port ${port} is already in use by ${holder.command} (PID ${holder.pid}). ` +
203
+ `This is usually a browser left running by a previously interrupted "fireforge test" run. ` +
204
+ `Kill it with "${killHint}", then retry. ` +
205
+ `(If you expected ${holder.command} to be running on ${port}, stop it manually or pass ` +
206
+ `"--marionette-port <port>" to launch mach test on a different port.)`);
207
+ }
208
+ // Non-browser holder: mach test will still fail to bind, but the
209
+ // cause is not a stale FireForge-launched browser. Flag it
210
+ // explicitly so the operator can decide what to do instead of
211
+ // getting mach's bind error with no FireForge context.
212
+ throw new GeneralError(`Marionette port ${port} is already in use by ${holder.command} (PID ${holder.pid}). ` +
213
+ `This is not a FireForge-launched browser; stop the holder process or free the port before rerunning.`);
214
+ }
215
+ //# sourceMappingURL=marionette-port.js.map
@@ -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,
@@ -14,11 +14,31 @@ export interface PatchManifestConsistencyIssue {
14
14
  * @returns Consistency issues between manifest metadata and on-disk patch files
15
15
  */
16
16
  export declare function validatePatchesManifestConsistency(patchesDir: string): Promise<PatchManifestConsistencyIssue[]>;
17
+ /**
18
+ * Summary of a {@link rebuildPatchesManifest} run. `recoveredFilenames`
19
+ * lists patches whose manifest entry was reconstructed from filename,
20
+ * mtime, and diff alone (no pre-existing manifest entry to preserve).
21
+ * These entries carry generic descriptions and mtime-based
22
+ * timestamps; callers like `doctor --repair-patches-manifest` surface
23
+ * a per-patch review warning so operators know which metadata was
24
+ * invented vs which was restored.
25
+ */
26
+ export interface RebuildPatchesManifestResult {
27
+ /** Rebuilt manifest, ready to be re-applied by callers (already persisted). */
28
+ manifest: PatchesManifest;
29
+ /**
30
+ * Filenames whose manifest entry had no pre-existing metadata to
31
+ * preserve — every descriptive field on these entries is inferred.
32
+ */
33
+ recoveredFilenames: string[];
34
+ }
17
35
  /**
18
36
  * Rebuilds patches.json from the patch files currently present on disk.
19
37
  * Existing metadata is preserved when possible; missing entries are recovered
20
38
  * from filename structure, patch contents, and file mtimes.
21
39
  * @param patchesDir - Path to the patches directory
22
40
  * @param fallbackSourceEsrVersion - ESR version to use for recovered entries
41
+ * @returns {@link RebuildPatchesManifestResult} — the persisted manifest
42
+ * plus the filenames that were reconstructed from generic defaults.
23
43
  */
24
- export declare function rebuildPatchesManifest(patchesDir: string, fallbackSourceEsrVersion: string): Promise<PatchesManifest>;
44
+ export declare function rebuildPatchesManifest(patchesDir: string, fallbackSourceEsrVersion: string): Promise<RebuildPatchesManifestResult>;
@@ -85,6 +85,8 @@ export async function validatePatchesManifestConsistency(patchesDir) {
85
85
  * from filename structure, patch contents, and file mtimes.
86
86
  * @param patchesDir - Path to the patches directory
87
87
  * @param fallbackSourceEsrVersion - ESR version to use for recovered entries
88
+ * @returns {@link RebuildPatchesManifestResult} — the persisted manifest
89
+ * plus the filenames that were reconstructed from generic defaults.
88
90
  */
89
91
  export async function rebuildPatchesManifest(patchesDir, fallbackSourceEsrVersion) {
90
92
  const manifestState = await loadPatchesManifestState(patchesDir);
@@ -96,6 +98,7 @@ export async function rebuildPatchesManifest(patchesDir, fallbackSourceEsrVersio
96
98
  }
97
99
  const patches = await discoverPatches(patchesDir);
98
100
  const rebuiltPatches = [];
101
+ const recoveredFilenames = [];
99
102
  const highestFiniteOrder = patches.reduce((highest, patch) => {
100
103
  return Number.isFinite(patch.order) ? Math.max(highest, patch.order) : highest;
101
104
  }, 0);
@@ -106,7 +109,27 @@ export async function rebuildPatchesManifest(patchesDir, fallbackSourceEsrVersio
106
109
  const patchStats = await stat(patch.path);
107
110
  const inferred = inferPatchMetadataFromFilename(patch.filename);
108
111
  const recoveredOrder = Number.isFinite(patch.order) ? patch.order : nextRecoveredOrder++;
109
- rebuiltPatches.push({
112
+ if (!existing) {
113
+ // Track every filename that had no pre-existing manifest entry
114
+ // so callers can warn the operator per-patch. A missing entry
115
+ // means every descriptive field (`description`, `createdAt`,
116
+ // `category`) was invented rather than preserved. FireForge
117
+ // patch files carry no header metadata that could carry a
118
+ // human description forward, so full fidelity is impossible —
119
+ // visibility is the best we can offer. 2026-04-21 eval
120
+ // (Finding #17) tripped over silent overwrites of useful
121
+ // human-written descriptions during a recovery run.
122
+ recoveredFilenames.push(patch.filename);
123
+ }
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 = {
110
133
  filename: patch.filename,
111
134
  order: recoveredOrder,
112
135
  category: existing?.category ?? inferred.category,
@@ -116,7 +139,12 @@ export async function rebuildPatchesManifest(patchesDir, fallbackSourceEsrVersio
116
139
  createdAt: existing?.createdAt ?? new Date(patchStats.mtimeMs).toISOString(),
117
140
  sourceEsrVersion: existing?.sourceEsrVersion ?? fallbackSourceEsrVersion,
118
141
  filesAffected,
119
- });
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);
120
148
  }
121
149
  rebuiltPatches.sort((left, right) => left.order - right.order || left.filename.localeCompare(right.filename));
122
150
  const rebuiltManifest = {
@@ -124,7 +152,7 @@ export async function rebuildPatchesManifest(patchesDir, fallbackSourceEsrVersio
124
152
  patches: rebuiltPatches,
125
153
  };
126
154
  await savePatchesManifest(patchesDir, rebuiltManifest);
127
- return rebuiltManifest;
155
+ return { manifest: rebuiltManifest, recoveredFilenames };
128
156
  }
129
157
  function normalizeFiles(files) {
130
158
  return Array.from(new Set(files)).sort((left, right) => left.localeCompare(right));
@@ -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) {