@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.
- package/CHANGELOG.md +56 -0
- package/README.md +46 -24
- package/dist/src/commands/build.js +33 -10
- package/dist/src/commands/config.js +32 -20
- 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 +23 -12
- package/dist/src/commands/export-all.js +11 -3
- package/dist/src/commands/export-shared.d.ts +7 -1
- package/dist/src/commands/export-shared.js +21 -3
- package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
- package/dist/src/commands/furnace/create-templates.d.ts +11 -0
- package/dist/src/commands/furnace/create-templates.js +11 -2
- package/dist/src/commands/furnace/init.js +97 -9
- package/dist/src/commands/furnace/override.js +23 -13
- package/dist/src/commands/furnace/remove.js +8 -0
- package/dist/src/commands/furnace/rename.js +133 -4
- package/dist/src/commands/lint.js +70 -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/register.js +11 -0
- package/dist/src/commands/resolve.d.ts +25 -1
- package/dist/src/commands/resolve.js +25 -15
- package/dist/src/commands/status.js +100 -122
- package/dist/src/commands/test.js +68 -14
- package/dist/src/commands/token-coverage.js +10 -3
- package/dist/src/commands/wire.js +50 -8
- package/dist/src/core/browser-wire.js +21 -4
- package/dist/src/core/build-audit.js +10 -0
- package/dist/src/core/config.d.ts +33 -0
- package/dist/src/core/config.js +43 -0
- package/dist/src/core/furnace-config.d.ts +23 -2
- package/dist/src/core/furnace-config.js +26 -3
- package/dist/src/core/git-diff.js +21 -2
- package/dist/src/core/mach.d.ts +43 -6
- package/dist/src/core/mach.js +57 -7
- 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-port.d.ts +50 -0
- package/dist/src/core/marionette-port.js +215 -0
- package/dist/src/core/patch-lint.d.ts +47 -2
- package/dist/src/core/patch-lint.js +89 -14
- package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
- package/dist/src/core/patch-manifest-consistency.js +31 -3
- 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/status-classify.d.ts +54 -0
- package/dist/src/core/status-classify.js +134 -0
- package/dist/src/core/token-coverage.js +24 -0
- package/dist/src/core/token-dark-mode.d.ts +49 -0
- package/dist/src/core/token-dark-mode.js +182 -0
- package/dist/src/core/token-manager.js +17 -33
- package/dist/src/core/wire-destroy.d.ts +7 -3
- package/dist/src/core/wire-destroy.js +11 -6
- package/dist/src/core/wire-dom-fragment.d.ts +17 -0
- package/dist/src/core/wire-dom-fragment.js +40 -0
- 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/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
|
@@ -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
|
|
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
|
-
*
|
|
78
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
|
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) {
|