@hominis/fireforge 0.16.3 → 0.17.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 +39 -1
- package/README.md +11 -3
- package/dist/src/commands/build.js +16 -7
- package/dist/src/commands/config.js +32 -20
- package/dist/src/commands/doctor.js +14 -1
- package/dist/src/commands/download.js +44 -13
- package/dist/src/commands/export-all.js +19 -2
- package/dist/src/commands/export-shared.d.ts +36 -0
- package/dist/src/commands/export-shared.js +76 -0
- package/dist/src/commands/export.js +23 -2
- package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
- package/dist/src/commands/furnace/create-readback.d.ts +23 -0
- package/dist/src/commands/furnace/create-readback.js +34 -0
- 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/create.js +2 -0
- package/dist/src/commands/furnace/init.js +97 -9
- package/dist/src/commands/furnace/preview.d.ts +12 -0
- package/dist/src/commands/furnace/preview.js +34 -2
- package/dist/src/commands/furnace/rename.js +110 -0
- package/dist/src/commands/furnace/status.js +1 -1
- package/dist/src/commands/lint.js +55 -4
- package/dist/src/commands/patch/index.js +10 -1
- package/dist/src/commands/re-export.js +79 -6
- package/dist/src/commands/resolve.d.ts +25 -1
- package/dist/src/commands/resolve.js +40 -16
- package/dist/src/commands/run.js +27 -5
- package/dist/src/commands/status.js +100 -122
- package/dist/src/commands/test.js +23 -3
- package/dist/src/commands/token-coverage.js +55 -1
- package/dist/src/commands/token.js +12 -1
- package/dist/src/commands/wire.js +56 -10
- 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/mach-error-hints.js +16 -0
- package/dist/src/core/mach.d.ts +31 -0
- package/dist/src/core/mach.js +59 -6
- package/dist/src/core/marionette-port.d.ts +50 -0
- package/dist/src/core/marionette-port.js +215 -0
- package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
- package/dist/src/core/patch-manifest-consistency.js +16 -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-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.js +18 -5
- 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.js +20 -5
- package/dist/src/core/wire-utils.d.ts +15 -0
- package/dist/src/core/wire-utils.js +17 -0
- package/dist/src/types/commands/options.d.ts +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Default Marionette control port set by `-marionette`. */
|
|
2
|
+
export declare const DEFAULT_MARIONETTE_PORT = 2828;
|
|
3
|
+
/**
|
|
4
|
+
* Information about a process holding the Marionette port.
|
|
5
|
+
*/
|
|
6
|
+
export interface MarionettePortHolder {
|
|
7
|
+
/** OS process ID. */
|
|
8
|
+
pid: number;
|
|
9
|
+
/** Process basename (e.g. `forgefresh`, `firefox`). */
|
|
10
|
+
command: string;
|
|
11
|
+
/**
|
|
12
|
+
* Full command line the holder was launched with, when the probe
|
|
13
|
+
* can recover it. `lsof` by itself only returns the basename, so
|
|
14
|
+
* POSIX callers see `command === commandLine`; Windows callers
|
|
15
|
+
* recover the full command line via `Get-Process`. Used to detect
|
|
16
|
+
* the `-marionette` flag, which positively identifies a stale
|
|
17
|
+
* browser rather than an unrelated listener.
|
|
18
|
+
*/
|
|
19
|
+
commandLine: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Result of a Marionette port probe.
|
|
23
|
+
*/
|
|
24
|
+
export interface MarionettePortProbeResult {
|
|
25
|
+
/** True when something is listening on the probed port. */
|
|
26
|
+
inUse: boolean;
|
|
27
|
+
/** Details about the holder, when the probe recovered them. */
|
|
28
|
+
holder?: MarionettePortHolder;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Probes whether the Marionette port is currently bound by a
|
|
32
|
+
* listener. The probe is best-effort: missing tooling returns
|
|
33
|
+
* `{ inUse: false }` without failing.
|
|
34
|
+
*
|
|
35
|
+
* @param port - Port to probe (default {@link DEFAULT_MARIONETTE_PORT}).
|
|
36
|
+
*/
|
|
37
|
+
export declare function probeMarionettePort(port?: number): Promise<MarionettePortProbeResult>;
|
|
38
|
+
/**
|
|
39
|
+
* Raises a targeted {@link GeneralError} when the Marionette port
|
|
40
|
+
* is held by a browser process; raises a softer warning-shaped
|
|
41
|
+
* error when the holder is unrelated (so the operator still sees
|
|
42
|
+
* a useful signal but can decide whether to wait it out).
|
|
43
|
+
*
|
|
44
|
+
* @param port - Port to probe (default {@link DEFAULT_MARIONETTE_PORT}).
|
|
45
|
+
* @param options - Extra context for the error message (the project's
|
|
46
|
+
* `binaryName` is used to recognise fork-branded browser binaries).
|
|
47
|
+
*/
|
|
48
|
+
export declare function assertMarionettePortAvailable(port?: number, options?: {
|
|
49
|
+
binaryName?: string;
|
|
50
|
+
}): Promise<void>;
|
|
@@ -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
|
|
@@ -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,6 +109,18 @@ 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++;
|
|
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
|
+
}
|
|
109
124
|
rebuiltPatches.push({
|
|
110
125
|
filename: patch.filename,
|
|
111
126
|
order: recoveredOrder,
|
|
@@ -124,7 +139,7 @@ export async function rebuildPatchesManifest(patchesDir, fallbackSourceEsrVersio
|
|
|
124
139
|
patches: rebuiltPatches,
|
|
125
140
|
};
|
|
126
141
|
await savePatchesManifest(patchesDir, rebuiltManifest);
|
|
127
|
-
return rebuiltManifest;
|
|
142
|
+
return { manifest: rebuiltManifest, recoveredFilenames };
|
|
128
143
|
}
|
|
129
144
|
function normalizeFiles(files) {
|
|
130
145
|
return Array.from(new Set(files)).sort((left, right) => left.localeCompare(right));
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status classifier: partitions engine file changes into
|
|
3
|
+
* patch-backed / unmanaged / branding / furnace / conflict buckets.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `src/commands/status.ts` so that command file stays
|
|
6
|
+
* under the per-file line budget as the number of buckets grows.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Classification buckets for engine file changes:
|
|
10
|
+
* - `patch-backed`: content matches the expected post-patch state —
|
|
11
|
+
* normal after `fireforge import`.
|
|
12
|
+
* - `unmanaged`: edits not explained by any patch or tool — local
|
|
13
|
+
* drift to export or discard.
|
|
14
|
+
* - `branding`: files under tool-managed branding paths, written by
|
|
15
|
+
* FireForge's branding pipeline.
|
|
16
|
+
* - `furnace`: files under Furnace-managed component prefixes.
|
|
17
|
+
* - `conflict`: the file is claimed by two or more patches in
|
|
18
|
+
* `patches.json`. The human `--ownership` mode already surfaces
|
|
19
|
+
* this bucket as `CONFLICT`; the classification is carried through
|
|
20
|
+
* the JSON pipeline so machine consumers can detect the same
|
|
21
|
+
* ownership breakage the human output shows. Before 0.16.0,
|
|
22
|
+
* cross-patch conflicts silently rolled into the `unmanaged` bucket
|
|
23
|
+
* in `--json`, which misled scripts built on top of the JSON view
|
|
24
|
+
* into treating the file as routine local drift.
|
|
25
|
+
*/
|
|
26
|
+
export type FileClassification = 'patch-backed' | 'unmanaged' | 'branding' | 'furnace' | 'conflict';
|
|
27
|
+
export interface StatusFile {
|
|
28
|
+
status: string;
|
|
29
|
+
file: string;
|
|
30
|
+
}
|
|
31
|
+
export interface ClassifiedFile extends StatusFile {
|
|
32
|
+
classification: FileClassification;
|
|
33
|
+
/**
|
|
34
|
+
* Names of patch files that claim this path in `patches.json`.
|
|
35
|
+
* Populated only when `classification === 'conflict'` — single-claim
|
|
36
|
+
* patch-backed entries don't need to expose their owner because the
|
|
37
|
+
* single claim is fully captured by the classification itself.
|
|
38
|
+
*/
|
|
39
|
+
claimedBy?: string[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Classifies files into patch-backed, unmanaged, branding, furnace, or
|
|
43
|
+
* conflict buckets.
|
|
44
|
+
*
|
|
45
|
+
* Tracks patch ownership as a `Map<file, patchFilename[]>` rather than
|
|
46
|
+
* a plain `Set<file>` so the classifier can surface cross-patch
|
|
47
|
+
* ownership conflicts the same way the human `--ownership` mode does.
|
|
48
|
+
* The 2026-04-21 eval's `status --json` run reported
|
|
49
|
+
* `classification: "unmanaged"` on two files (`browser/base/jar.mn`,
|
|
50
|
+
* `browser/themes/shared/jar.inc.mn`) that `--ownership` correctly
|
|
51
|
+
* flagged as `CONFLICT`; the JSON output was effectively lying to
|
|
52
|
+
* machine consumers about the nature of the drift.
|
|
53
|
+
*/
|
|
54
|
+
export declare function classifyFiles(files: StatusFile[], engineDir: string, patchesDir: string, binaryName: string, furnacePrefixes: Set<string>): Promise<ClassifiedFile[]>;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Status classifier: partitions engine file changes into
|
|
4
|
+
* patch-backed / unmanaged / branding / furnace / conflict buckets.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from `src/commands/status.ts` so that command file stays
|
|
7
|
+
* under the per-file line budget as the number of buckets grows.
|
|
8
|
+
*/
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { toError } from '../utils/errors.js';
|
|
11
|
+
import { readText } from '../utils/fs.js';
|
|
12
|
+
import { verbose } from '../utils/logger.js';
|
|
13
|
+
import { isBrandingManagedPath } from './branding.js';
|
|
14
|
+
import { computePatchedContent } from './patch-apply.js';
|
|
15
|
+
import { loadPatchesManifest } from './patch-manifest.js';
|
|
16
|
+
function getPrimaryStatusCode(status) {
|
|
17
|
+
if (status.includes('?'))
|
|
18
|
+
return '?';
|
|
19
|
+
if (status.includes('!'))
|
|
20
|
+
return '!';
|
|
21
|
+
for (const code of status) {
|
|
22
|
+
if (code !== ' ') {
|
|
23
|
+
return code;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return status;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Classifies files into patch-backed, unmanaged, branding, furnace, or
|
|
30
|
+
* conflict buckets.
|
|
31
|
+
*
|
|
32
|
+
* Tracks patch ownership as a `Map<file, patchFilename[]>` rather than
|
|
33
|
+
* a plain `Set<file>` so the classifier can surface cross-patch
|
|
34
|
+
* ownership conflicts the same way the human `--ownership` mode does.
|
|
35
|
+
* The 2026-04-21 eval's `status --json` run reported
|
|
36
|
+
* `classification: "unmanaged"` on two files (`browser/base/jar.mn`,
|
|
37
|
+
* `browser/themes/shared/jar.inc.mn`) that `--ownership` correctly
|
|
38
|
+
* flagged as `CONFLICT`; the JSON output was effectively lying to
|
|
39
|
+
* machine consumers about the nature of the drift.
|
|
40
|
+
*/
|
|
41
|
+
export async function classifyFiles(files, engineDir, patchesDir, binaryName, furnacePrefixes) {
|
|
42
|
+
const manifest = await loadPatchesManifest(patchesDir);
|
|
43
|
+
// Build a multimap from file path → list of claiming patch
|
|
44
|
+
// filenames so we can detect cross-patch ownership conflicts. The
|
|
45
|
+
// previous `Set<string>` captured only whether a path was claimed,
|
|
46
|
+
// not by whom, and collapsed multi-owner files into the single-owner
|
|
47
|
+
// branch where the expected-vs-actual content comparison then routed
|
|
48
|
+
// them into `unmanaged` when the content didn't match either owner's
|
|
49
|
+
// expectation.
|
|
50
|
+
const patchClaims = new Map();
|
|
51
|
+
if (manifest) {
|
|
52
|
+
for (const patch of manifest.patches) {
|
|
53
|
+
for (const f of patch.filesAffected) {
|
|
54
|
+
const owners = patchClaims.get(f);
|
|
55
|
+
if (owners) {
|
|
56
|
+
owners.push(patch.filename);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
patchClaims.set(f, [patch.filename]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const results = [];
|
|
65
|
+
for (const entry of files) {
|
|
66
|
+
// Branding check first
|
|
67
|
+
if (isBrandingManagedPath(entry.file, binaryName)) {
|
|
68
|
+
results.push({ ...entry, classification: 'branding' });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
// Furnace-managed component paths
|
|
72
|
+
if (furnacePrefixes.size > 0) {
|
|
73
|
+
let isFurnace = false;
|
|
74
|
+
for (const prefix of furnacePrefixes) {
|
|
75
|
+
if (entry.file.startsWith(prefix)) {
|
|
76
|
+
isFurnace = true;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (isFurnace) {
|
|
81
|
+
results.push({ ...entry, classification: 'furnace' });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const owners = patchClaims.get(entry.file);
|
|
86
|
+
// Multiple patches claim this file — surface the cross-patch
|
|
87
|
+
// ownership conflict regardless of whether the current content
|
|
88
|
+
// matches any single claim. `--ownership` reports the same state
|
|
89
|
+
// as `CONFLICT`; `--json` must agree so machine consumers of the
|
|
90
|
+
// two views see the same truth.
|
|
91
|
+
if (owners && owners.length >= 2) {
|
|
92
|
+
results.push({
|
|
93
|
+
...entry,
|
|
94
|
+
classification: 'conflict',
|
|
95
|
+
claimedBy: [...owners],
|
|
96
|
+
});
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// Not in any patch → unmanaged
|
|
100
|
+
if (!owners) {
|
|
101
|
+
results.push({ ...entry, classification: 'unmanaged' });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// File is claimed by exactly one patch — compare content.
|
|
105
|
+
const primaryCode = getPrimaryStatusCode(entry.status);
|
|
106
|
+
if (primaryCode === 'D') {
|
|
107
|
+
// Deleted file: patch-backed only if patch expects deletion
|
|
108
|
+
const expected = await computePatchedContent(patchesDir, engineDir, entry.file);
|
|
109
|
+
results.push({
|
|
110
|
+
...entry,
|
|
111
|
+
classification: expected === null ? 'patch-backed' : 'unmanaged',
|
|
112
|
+
});
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// File exists on disk — compare actual vs expected
|
|
116
|
+
try {
|
|
117
|
+
const [expected, actual] = await Promise.all([
|
|
118
|
+
computePatchedContent(patchesDir, engineDir, entry.file),
|
|
119
|
+
readText(join(engineDir, entry.file)),
|
|
120
|
+
]);
|
|
121
|
+
results.push({
|
|
122
|
+
...entry,
|
|
123
|
+
classification: actual === expected ? 'patch-backed' : 'unmanaged',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
verbose(`Treating ${entry.file} as unmanaged because patch-backed classification failed: ${toError(error).message}`);
|
|
128
|
+
// If we can't read the file, treat as unmanaged
|
|
129
|
+
results.push({ ...entry, classification: 'unmanaged' });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return results;
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=status-classify.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dark-mode insertion helpers for the tokens CSS scaffold.
|
|
3
|
+
*
|
|
4
|
+
* The 2026-04-21 eval reproduced a bug where `fireforge token add
|
|
5
|
+
* --mode override --dark-value ...` landed the dark declaration
|
|
6
|
+
* AFTER the nested `:root { }` inside the
|
|
7
|
+
* `@media (prefers-color-scheme: dark)` block had already closed,
|
|
8
|
+
* producing a declaration outside any rule block. The helpers here
|
|
9
|
+
* scan the comment-stripped source lines to find the *inner* `:root`
|
|
10
|
+
* block's closing `}` and return a line index the caller can splice
|
|
11
|
+
* into. When the inner `:root` is missing (a scaffold that drifted
|
|
12
|
+
* from the default), the fallback helper returns the outer `@media`
|
|
13
|
+
* block's close so the caller can materialise a fresh `:root` wrapper
|
|
14
|
+
* rather than dropping the dark value.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Strips the content of `/* ... *\/` block comments from an array of
|
|
18
|
+
* CSS source lines while preserving each line's length. Indexed scans
|
|
19
|
+
* over the returned mirror line up with the original, so callers that
|
|
20
|
+
* compute an insertion index against the stripped array can splice
|
|
21
|
+
* into the original array at the same index.
|
|
22
|
+
*
|
|
23
|
+
* We blank the comment body with spaces (rather than removing it) so
|
|
24
|
+
* any downstream consumer that indexes by column — or derives an
|
|
25
|
+
* insertion index as a line number in the original array — still
|
|
26
|
+
* agrees on line numbers.
|
|
27
|
+
*/
|
|
28
|
+
export declare function stripBlockCommentsInLines(lines: string[]): string[];
|
|
29
|
+
/**
|
|
30
|
+
* Finds the closing `}` line of the nested `:root { ... }` block inside
|
|
31
|
+
* a `@media (prefers-color-scheme: dark)` block. Returns `-1` when the
|
|
32
|
+
* media block exists but the nested `:root` block is missing; returns
|
|
33
|
+
* `null` when the `@media` block itself is absent.
|
|
34
|
+
*
|
|
35
|
+
* Runs the scan over a comment-stripped mirror of the source lines so
|
|
36
|
+
* braces inside CSS comments (`/* before { after *\/`) do not offset
|
|
37
|
+
* the depth counter. The scan is deliberately line-indexed so callers
|
|
38
|
+
* can splice into the original `lines` array at the returned index.
|
|
39
|
+
*/
|
|
40
|
+
export declare function findDarkRootInsertionIndex(lines: string[]): number | null;
|
|
41
|
+
/**
|
|
42
|
+
* Finds the closing `}` of the outermost
|
|
43
|
+
* `@media (prefers-color-scheme: dark)` block. Used as the fallback
|
|
44
|
+
* landing site when the scaffold has no nested `:root { }` — the
|
|
45
|
+
* insertion helper uses this index to splice a brand-new `:root`
|
|
46
|
+
* wrapper containing the dark declaration, rather than dropping the
|
|
47
|
+
* value.
|
|
48
|
+
*/
|
|
49
|
+
export declare function findDarkMediaCloseIndex(lines: string[]): number;
|