@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +11 -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/download.js +44 -13
  7. package/dist/src/commands/export-all.js +19 -2
  8. package/dist/src/commands/export-shared.d.ts +36 -0
  9. package/dist/src/commands/export-shared.js +76 -0
  10. package/dist/src/commands/export.js +23 -2
  11. package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
  12. package/dist/src/commands/furnace/create-readback.d.ts +23 -0
  13. package/dist/src/commands/furnace/create-readback.js +34 -0
  14. package/dist/src/commands/furnace/create-templates.d.ts +11 -0
  15. package/dist/src/commands/furnace/create-templates.js +11 -2
  16. package/dist/src/commands/furnace/create.js +2 -0
  17. package/dist/src/commands/furnace/init.js +97 -9
  18. package/dist/src/commands/furnace/preview.d.ts +12 -0
  19. package/dist/src/commands/furnace/preview.js +34 -2
  20. package/dist/src/commands/furnace/rename.js +110 -0
  21. package/dist/src/commands/furnace/status.js +1 -1
  22. package/dist/src/commands/lint.js +55 -4
  23. package/dist/src/commands/patch/index.js +10 -1
  24. package/dist/src/commands/re-export.js +79 -6
  25. package/dist/src/commands/resolve.d.ts +25 -1
  26. package/dist/src/commands/resolve.js +40 -16
  27. package/dist/src/commands/run.js +27 -5
  28. package/dist/src/commands/status.js +100 -122
  29. package/dist/src/commands/test.js +23 -3
  30. package/dist/src/commands/token-coverage.js +55 -1
  31. package/dist/src/commands/token.js +12 -1
  32. package/dist/src/commands/wire.js +56 -10
  33. package/dist/src/core/config.d.ts +33 -0
  34. package/dist/src/core/config.js +43 -0
  35. package/dist/src/core/furnace-config.d.ts +23 -2
  36. package/dist/src/core/furnace-config.js +26 -3
  37. package/dist/src/core/mach-error-hints.js +16 -0
  38. package/dist/src/core/mach.d.ts +31 -0
  39. package/dist/src/core/mach.js +59 -6
  40. package/dist/src/core/marionette-port.d.ts +50 -0
  41. package/dist/src/core/marionette-port.js +215 -0
  42. package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
  43. package/dist/src/core/patch-manifest-consistency.js +16 -1
  44. package/dist/src/core/status-classify.d.ts +54 -0
  45. package/dist/src/core/status-classify.js +134 -0
  46. package/dist/src/core/token-dark-mode.d.ts +49 -0
  47. package/dist/src/core/token-dark-mode.js +182 -0
  48. package/dist/src/core/token-manager.js +17 -33
  49. package/dist/src/core/wire-destroy.js +18 -5
  50. package/dist/src/core/wire-dom-fragment.d.ts +17 -0
  51. package/dist/src/core/wire-dom-fragment.js +40 -0
  52. package/dist/src/core/wire-init.js +20 -5
  53. package/dist/src/core/wire-utils.d.ts +15 -0
  54. package/dist/src/core/wire-utils.js +17 -0
  55. package/dist/src/types/commands/options.d.ts +7 -0
  56. 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<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[]>;
@@ -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;