@hominis/fireforge 0.16.5 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +5 -3
  3. package/dist/src/commands/build.js +16 -7
  4. package/dist/src/commands/config.js +32 -20
  5. package/dist/src/commands/doctor.js +14 -1
  6. package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
  7. package/dist/src/commands/furnace/create-templates.d.ts +11 -0
  8. package/dist/src/commands/furnace/create-templates.js +11 -2
  9. package/dist/src/commands/furnace/init.js +97 -9
  10. package/dist/src/commands/furnace/rename.js +110 -0
  11. package/dist/src/commands/lint.js +55 -4
  12. package/dist/src/commands/resolve.d.ts +25 -1
  13. package/dist/src/commands/resolve.js +25 -15
  14. package/dist/src/commands/status.js +100 -122
  15. package/dist/src/commands/test.js +15 -2
  16. package/dist/src/commands/wire.js +34 -8
  17. package/dist/src/core/config.d.ts +33 -0
  18. package/dist/src/core/config.js +43 -0
  19. package/dist/src/core/furnace-config.d.ts +23 -2
  20. package/dist/src/core/furnace-config.js +26 -3
  21. package/dist/src/core/mach.d.ts +31 -0
  22. package/dist/src/core/mach.js +45 -1
  23. package/dist/src/core/marionette-port.d.ts +50 -0
  24. package/dist/src/core/marionette-port.js +215 -0
  25. package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
  26. package/dist/src/core/patch-manifest-consistency.js +16 -1
  27. package/dist/src/core/status-classify.d.ts +54 -0
  28. package/dist/src/core/status-classify.js +134 -0
  29. package/dist/src/core/token-dark-mode.d.ts +49 -0
  30. package/dist/src/core/token-dark-mode.js +182 -0
  31. package/dist/src/core/token-manager.js +17 -33
  32. package/dist/src/core/wire-dom-fragment.d.ts +17 -0
  33. package/dist/src/core/wire-dom-fragment.js +40 -0
  34. package/package.json +1 -1
@@ -8,11 +8,13 @@
8
8
  * config-mutate.ts — immutable config mutation
9
9
  * config-state.ts — state file management
10
10
  */
11
+ import { basename } from 'node:path';
11
12
  import { ConfigError, ConfigNotFoundError } from '../errors/config.js';
12
13
  import { toError } from '../utils/errors.js';
13
14
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
14
15
  import { getProjectPaths } from './config-paths.js';
15
16
  import { validateConfig } from './config-validate.js';
17
+ import { createSiblingLockPath, withFileLock } from './file-lock.js';
16
18
  // ---- re-exports ----
17
19
  export { mutateConfig } from './config-mutate.js';
18
20
  export { CONFIG_FILENAME, CONFIGS_DIR, ENGINE_DIR, FIREFORGE_DIR, getProjectPaths, PATCHES_DIR, SRC_DIR, STATE_FILENAME, SUPPORTED_CONFIG_PATHS, SUPPORTED_CONFIG_ROOT_KEYS, } from './config-paths.js';
@@ -97,9 +99,50 @@ export async function writeConfig(root, config) {
97
99
  * Writes a raw config document to fireforge.json.
98
100
  * This is used by CLI `config --force`, where callers may intentionally write
99
101
  * keys or value shapes outside the validated FireForgeConfig schema.
102
+ *
103
+ * Individual writes are atomic via {@link writeJson} (temp file + rename),
104
+ * but atomicity alone does not prevent lost updates across concurrent
105
+ * writers: each writer reads an old copy, mutates its own in-memory view,
106
+ * and writes it back, so the second writer's rename clobbers the first
107
+ * writer's changes. Callers that do read → mutate → write must hold
108
+ * {@link withConfigFileLock} for the full round-trip to serialise
109
+ * against other writers.
100
110
  */
101
111
  export async function writeConfigDocument(root, config) {
102
112
  const paths = getProjectPaths(root);
103
113
  await writeJson(paths.config, config);
104
114
  }
115
+ /**
116
+ * Runs an operation while holding a sidecar lock on `fireforge.json`.
117
+ *
118
+ * Motivating case (2026-04-21 eval): two concurrent `fireforge config
119
+ * <key> <value>` invocations each ran load → mutate → writeJson against
120
+ * the same on-disk fireforge.json. The second rename landed after the
121
+ * first, silently dropping the first writer's key — both commands exited
122
+ * `0`, but only one change survived. This helper turns the same
123
+ * read-modify-write sequence into a serialised operation so a concurrent
124
+ * writer now waits for the lock rather than racing on the document.
125
+ *
126
+ * Reads (`loadConfig`, `loadRawConfigDocument`) stay lock-free: writers
127
+ * always use `writeJson`'s atomic temp-file + rename, so a reader observes
128
+ * either the pre- or post-write document but never a torn file. The lock
129
+ * only serialises writers against other writers.
130
+ *
131
+ * The lock is a sidecar directory `${config}.fireforge-config.lock`, and
132
+ * `withFileLock` handles stale-lock recovery (PID-alive probe, age-based
133
+ * fallback) — a crashed writer does not permanently block future writes.
134
+ *
135
+ * @param root - Root directory of the project
136
+ * @param operation - Async function to run while holding the lock
137
+ * @returns Whatever the operation returns
138
+ */
139
+ export async function withConfigFileLock(root, operation) {
140
+ const paths = getProjectPaths(root);
141
+ return withFileLock(createSiblingLockPath(paths.config, '.fireforge-config.lock'), operation, {
142
+ onTimeoutMessage: `Timed out waiting to update ${basename(paths.config)}. ` +
143
+ 'If no other fireforge process is running, remove the stale lock directory and retry.',
144
+ onStaleLockMessage: (ageMs) => `Removing stale FireForge config lock for ${basename(paths.config)} ` +
145
+ `(age: ${Math.round(ageMs / 1000)}s). A previous fireforge process may have crashed.`,
146
+ });
147
+ }
105
148
  //# sourceMappingURL=config.js.map
@@ -111,9 +111,30 @@ export declare function writeFurnaceConfig(root: string, config: FurnaceConfig):
111
111
  export declare function stampFurnaceOverrideBaseVersions(root: string, version: string): Promise<number>;
112
112
  /**
113
113
  * Creates a default furnace configuration.
114
- * @returns A valid empty FurnaceConfig
114
+ *
115
+ * When a `binaryName` is provided, the default config carries a
116
+ * `tokenPrefix` derived as `--<binaryName>-`. Without that default,
117
+ * `fireforge token coverage` on a fresh project reports `0 tokens` and
118
+ * labels every custom-property reference as `unknown` — the scan has
119
+ * no prefix to key off. The 2026-04-21 eval walked directly into this
120
+ * state (`furnace init` → `token add` → `token coverage` → zero
121
+ * tokens), and only recovered after hand-editing furnace.json. Deriving
122
+ * the prefix from the binary name matches the convention the scaffolded
123
+ * tokens CSS already uses for its `--<binaryName>-*` declarations.
124
+ *
125
+ * `validateFurnaceConfig` treats `tokenPrefix` as optional, so callers
126
+ * on the legacy no-arg call shape (existing tests, programmatic callers
127
+ * bootstrapping from a not-yet-loaded config) still get a valid config
128
+ * without a prefix; the CLI init path always has a `binaryName` from
129
+ * `fireforge.json` and always sets one.
130
+ *
131
+ * @param options - Optional init context; pass `{ binaryName }` to
132
+ * derive the token prefix.
133
+ * @returns A valid FurnaceConfig
115
134
  */
116
- export declare function createDefaultFurnaceConfig(): FurnaceConfig;
135
+ export declare function createDefaultFurnaceConfig(options?: {
136
+ binaryName?: string;
137
+ }): FurnaceConfig;
117
138
  /**
118
139
  * Loads furnace config if it exists, or creates and writes a default config.
119
140
  * @param root - Root directory of the project
@@ -460,16 +460,39 @@ export async function stampFurnaceOverrideBaseVersions(root, version) {
460
460
  }
461
461
  /**
462
462
  * Creates a default furnace configuration.
463
- * @returns A valid empty FurnaceConfig
463
+ *
464
+ * When a `binaryName` is provided, the default config carries a
465
+ * `tokenPrefix` derived as `--<binaryName>-`. Without that default,
466
+ * `fireforge token coverage` on a fresh project reports `0 tokens` and
467
+ * labels every custom-property reference as `unknown` — the scan has
468
+ * no prefix to key off. The 2026-04-21 eval walked directly into this
469
+ * state (`furnace init` → `token add` → `token coverage` → zero
470
+ * tokens), and only recovered after hand-editing furnace.json. Deriving
471
+ * the prefix from the binary name matches the convention the scaffolded
472
+ * tokens CSS already uses for its `--<binaryName>-*` declarations.
473
+ *
474
+ * `validateFurnaceConfig` treats `tokenPrefix` as optional, so callers
475
+ * on the legacy no-arg call shape (existing tests, programmatic callers
476
+ * bootstrapping from a not-yet-loaded config) still get a valid config
477
+ * without a prefix; the CLI init path always has a `binaryName` from
478
+ * `fireforge.json` and always sets one.
479
+ *
480
+ * @param options - Optional init context; pass `{ binaryName }` to
481
+ * derive the token prefix.
482
+ * @returns A valid FurnaceConfig
464
483
  */
465
- export function createDefaultFurnaceConfig() {
466
- return {
484
+ export function createDefaultFurnaceConfig(options = {}) {
485
+ const config = {
467
486
  version: 1,
468
487
  componentPrefix: 'moz-',
469
488
  stock: [],
470
489
  overrides: {},
471
490
  custom: {},
472
491
  };
492
+ if (options.binaryName && options.binaryName.length > 0) {
493
+ config.tokenPrefix = `--${options.binaryName}-`;
494
+ }
495
+ return config;
473
496
  }
474
497
  /**
475
498
  * Loads furnace config if it exists, or creates and writes a default config.
@@ -73,6 +73,37 @@ export declare function build(engineDir: string, jobs?: number): Promise<number>
73
73
  * @returns Exit code
74
74
  */
75
75
  export declare function buildUI(engineDir: string): Promise<number>;
76
+ /**
77
+ * Runs an operation while holding a sidecar build lock keyed on the
78
+ * project root. Concurrent `fireforge build` / `fireforge build --ui`
79
+ * invocations against the same tree serialise instead of racing through
80
+ * the mach obj-dir.
81
+ *
82
+ * Motivating case (2026-04-21 eval): a `fireforge build --ui` run
83
+ * kicked off while a full `fireforge build` was still in flight against
84
+ * the same engine tree accepted the command and handed off to `mach
85
+ * build faster`, which failed almost immediately with `No rule to make
86
+ * target 'XUL'`. The real problem is that the first build had not yet
87
+ * materialised the full backend; the operator was left staring at a
88
+ * low-level make error with no link to the actual cause (a concurrent
89
+ * build in flight). The lock intercepts the second invocation before
90
+ * it touches mach, and the refusal message names the PID currently
91
+ * holding the lock so the operator can decide whether to wait or
92
+ * investigate a hung process.
93
+ *
94
+ * Stale-lock recovery: the lock stores the owner PID; a crashed build
95
+ * (SIGINT, SIGTERM, or a kernel kill) leaves the lock dir behind but
96
+ * not the owning process, and `withFileLock` removes the lock on the
97
+ * next attempt when `process.kill(pid, 0)` shows the owner is gone.
98
+ *
99
+ * The project-root variant is the right granularity: a single machine
100
+ * may have several FireForge projects side by side, and nothing says
101
+ * they cannot build in parallel. The lock serialises *within* one
102
+ * project, not across unrelated ones.
103
+ *
104
+ * Returns whatever the inner operation returns.
105
+ */
106
+ export declare function withBuildLock<T>(projectRoot: string, operation: () => Promise<T>): Promise<T>;
76
107
  /**
77
108
  * Runs the built browser.
78
109
  * @param engineDir - Path to the engine directory
@@ -1,9 +1,10 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { join } from 'node:path';
2
+ import { basename, join } from 'node:path';
3
3
  import { MachNotFoundError } from '../errors/build.js';
4
4
  import { pathExists } from '../utils/fs.js';
5
5
  import { warn } from '../utils/logger.js';
6
6
  import { exec, execInherit, execInheritCapture, execSmokeRun, execStream, } from '../utils/process.js';
7
+ import { createSiblingLockPath, withFileLock } from './file-lock.js';
7
8
  import { explainMachError } from './mach-error-hints.js';
8
9
  import { getPython } from './mach-python.js';
9
10
  // Re-export sub-modules so existing `from './mach.js'` imports keep working.
@@ -165,6 +166,49 @@ export async function buildUI(engineDir) {
165
166
  }
166
167
  return result.exitCode;
167
168
  }
169
+ /**
170
+ * Runs an operation while holding a sidecar build lock keyed on the
171
+ * project root. Concurrent `fireforge build` / `fireforge build --ui`
172
+ * invocations against the same tree serialise instead of racing through
173
+ * the mach obj-dir.
174
+ *
175
+ * Motivating case (2026-04-21 eval): a `fireforge build --ui` run
176
+ * kicked off while a full `fireforge build` was still in flight against
177
+ * the same engine tree accepted the command and handed off to `mach
178
+ * build faster`, which failed almost immediately with `No rule to make
179
+ * target 'XUL'`. The real problem is that the first build had not yet
180
+ * materialised the full backend; the operator was left staring at a
181
+ * low-level make error with no link to the actual cause (a concurrent
182
+ * build in flight). The lock intercepts the second invocation before
183
+ * it touches mach, and the refusal message names the PID currently
184
+ * holding the lock so the operator can decide whether to wait or
185
+ * investigate a hung process.
186
+ *
187
+ * Stale-lock recovery: the lock stores the owner PID; a crashed build
188
+ * (SIGINT, SIGTERM, or a kernel kill) leaves the lock dir behind but
189
+ * not the owning process, and `withFileLock` removes the lock on the
190
+ * next attempt when `process.kill(pid, 0)` shows the owner is gone.
191
+ *
192
+ * The project-root variant is the right granularity: a single machine
193
+ * may have several FireForge projects side by side, and nothing says
194
+ * they cannot build in parallel. The lock serialises *within* one
195
+ * project, not across unrelated ones.
196
+ *
197
+ * Returns whatever the inner operation returns.
198
+ */
199
+ export async function withBuildLock(projectRoot, operation) {
200
+ const lockPath = createSiblingLockPath(join(projectRoot, '.fireforge-build'), '.lock');
201
+ return withFileLock(lockPath, operation, {
202
+ // Default lock timeout is 30s; bump to 24h so a slow full build does
203
+ // not trip the timeout while the second invocation waits. A real
204
+ // operator will ^C long before 24h elapses; the ceiling is there
205
+ // purely so a forgotten lock cannot wedge the command forever.
206
+ timeoutMs: 24 * 60 * 60 * 1000,
207
+ onTimeoutMessage: `Timed out waiting for the FireForge build lock at ${lockPath}. ` +
208
+ 'If no other `fireforge build` is running, remove the lock directory and retry.',
209
+ onStaleLockMessage: (ageMs) => `Removing stale FireForge build lock ${basename(lockPath)} (age: ${Math.round(ageMs / 1000)}s). A previous build process may have crashed.`,
210
+ });
211
+ }
168
212
  /**
169
213
  * Runs the built browser.
170
214
  * @param engineDir - Path to the engine directory
@@ -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<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,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[]>;