@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
@@ -20,7 +20,16 @@ export { patchReorderCommand } from './reorder.js';
20
20
  export function registerPatch(program, context) {
21
21
  const patch = program
22
22
  .command('patch')
23
- .description('Manage individual patches in the queue (compact, delete, reorder)');
23
+ .description('Manage individual patches in the queue (compact, delete, reorder)')
24
+ // Match `fireforge furnace`'s no-args contract: print the group's help and
25
+ // exit 0. Without this default action, commander routes `fireforge patch`
26
+ // (no subcommand) through its own help-then-exit-1 path, so scripts that
27
+ // probe the CLI surface see a misleading non-zero exit for a purely
28
+ // informational invocation. The action prints the exact same help commander
29
+ // would otherwise print, but returns successfully.
30
+ .action(() => {
31
+ patch.outputHelp();
32
+ });
24
33
  registerPatchCompact(patch, context);
25
34
  registerPatchDelete(patch, context);
26
35
  registerPatchReorder(patch, context);
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { dirname, join } from 'node:path';
3
- import { multiselect } from '@clack/prompts';
3
+ import { confirm, multiselect } from '@clack/prompts';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { isGitRepository } from '../core/git.js';
6
6
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
@@ -11,6 +11,14 @@ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
11
11
  import { toError } from '../utils/errors.js';
12
12
  import { pathExists } from '../utils/fs.js';
13
13
  import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
14
+ /**
15
+ * Threshold above which `--scan` must be explicitly confirmed. Values were
16
+ * picked so the common "refresh after one-or-two-file tweak" case stays
17
+ * frictionless while catching the eval finding #13 scenario where `--scan`
18
+ * silently pulled in an entire sibling feature (xhtml + tests + theme CSS).
19
+ */
20
+ const SCAN_ADD_COUNT_THRESHOLD = 3;
21
+ const SCAN_DIR_COUNT_THRESHOLD = 2;
14
22
  import { pickDefined } from '../utils/options.js';
15
23
  import { runPatchLint } from './export-shared.js';
16
24
  import { reExportFilesInPlace } from './re-export-files.js';
@@ -39,25 +47,90 @@ async function scanPatchFiles(currentFilesAffected, engineDir, manifest, patchFi
39
47
  removed.push(f);
40
48
  }
41
49
  }
42
- for (const f of added.sort()) {
50
+ const sortedAdded = [...added].sort();
51
+ const sortedRemoved = [...removed].sort();
52
+ for (const f of sortedAdded) {
43
53
  info(` + ${f}`);
44
54
  }
45
- for (const f of removed.sort()) {
55
+ for (const f of sortedRemoved) {
46
56
  info(` - ${f}`);
47
57
  }
48
58
  if (added.length > 0 || removed.length > 0) {
49
59
  const removedSet = new Set(removed);
50
60
  const updated = [...currentFilesAffected.filter((f) => !removedSet.has(f)), ...added].sort();
51
61
  info(` ${isDryRun ? 'Would update' : 'Updated'} ${patchFilename}: +${added.length} / -${removed.length} files`);
52
- return updated;
62
+ return { updated, added: sortedAdded, removed: sortedRemoved };
53
63
  }
54
- return currentFilesAffected;
64
+ return { updated: currentFilesAffected, added: [], removed: [] };
65
+ }
66
+ /**
67
+ * Returns true when the caller-confirmed threshold is exceeded for this
68
+ * scan's additions. The heuristic treats "small, same-directory" additions
69
+ * as friction-free (the common refresh case) and flags larger or
70
+ * multi-directory expansions so operators see them before they land.
71
+ *
72
+ * Pre-0.16.0 `--scan` silently broadened patches to include any modified or
73
+ * untracked file that shared a parent directory with the existing
74
+ * filesAffected — in practice, pulling adjacent feature code into a patch
75
+ * that had nothing to do with it. The gate below turns the broadening into
76
+ * an explicit opt-in.
77
+ */
78
+ function scanAdditionsNeedConfirmation(added) {
79
+ if (added.length === 0)
80
+ return false;
81
+ if (added.length > SCAN_ADD_COUNT_THRESHOLD)
82
+ return true;
83
+ const dirs = new Set(added.map((f) => dirname(f)));
84
+ return dirs.size >= SCAN_DIR_COUNT_THRESHOLD;
85
+ }
86
+ /**
87
+ * Gate for broad `--scan` additions. Enforces explicit acknowledgement when
88
+ * the scan would pull in more files than a narrow refresh. Dry-run always
89
+ * proceeds (the preview is the whole point).
90
+ *
91
+ * @returns true if the caller should proceed; false if the user cancelled.
92
+ */
93
+ async function confirmBroadScanAdditions(args) {
94
+ const { patchFilename, added, isDryRun, yes, isInteractive } = args;
95
+ if (isDryRun)
96
+ return true;
97
+ if (!scanAdditionsNeedConfirmation(added))
98
+ return true;
99
+ if (yes)
100
+ return true;
101
+ warn(`${patchFilename}: --scan would add ${String(added.length)} file(s) that span ${String(new Set(added.map((f) => dirname(f))).size)} director${new Set(added.map((f) => dirname(f))).size === 1 ? 'y' : 'ies'}. ` +
102
+ 'Broad scans can silently pull adjacent features into a patch — review the diff before continuing.');
103
+ if (!isInteractive) {
104
+ throw new GeneralError(`Refusing to broaden "${patchFilename}" via --scan in non-interactive mode. ` +
105
+ 'Pass --yes to acknowledge the expansion, or run with --dry-run first to review.');
106
+ }
107
+ const confirmed = await confirm({
108
+ message: `Proceed and broaden ${patchFilename} with ${String(added.length)} newly discovered file(s)?`,
109
+ initialValue: false,
110
+ });
111
+ if (isCancel(confirmed) || !confirmed) {
112
+ cancel(`Skipped ${patchFilename}`);
113
+ return false;
114
+ }
115
+ return true;
55
116
  }
56
117
  async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, config) {
57
118
  let currentFilesAffected = [...patch.filesAffected];
58
119
  // --- Scan for new/removed files ---
59
120
  if (options.scan) {
60
- currentFilesAffected = await scanPatchFiles(currentFilesAffected, paths.engine, manifest, patch.filename, isDryRun);
121
+ const scanResult = await scanPatchFiles(currentFilesAffected, paths.engine, manifest, patch.filename, isDryRun);
122
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
123
+ const proceed = await confirmBroadScanAdditions({
124
+ patchFilename: patch.filename,
125
+ added: scanResult.added,
126
+ isDryRun,
127
+ yes: options.yes === true,
128
+ isInteractive,
129
+ });
130
+ if (!proceed) {
131
+ return false;
132
+ }
133
+ currentFilesAffected = scanResult.updated;
61
134
  }
62
135
  else if (options.files === undefined) {
63
136
  // Finding #16: when neither `--scan` nor `--files` is set and some
@@ -1,9 +1,33 @@
1
1
  import { Command } from 'commander';
2
2
  import type { CommandContext } from '../types/cli.js';
3
+ /**
4
+ * Options accepted by {@link resolveCommand}.
5
+ */
6
+ export interface ResolveCommandOptions {
7
+ /**
8
+ * Skip the interactive "Have you finished fixing the files?"
9
+ * confirmation prompt and treat the resolution as complete.
10
+ *
11
+ * Motivating case (2026-04-21 eval, Finding #18): a scripted or
12
+ * CI-assisted recovery flow that has already completed the manual
13
+ * merge step cannot advance through `fireforge resolve` because the
14
+ * TTY guard refuses non-interactive invocations outright. `--yes`
15
+ * is the explicit opt-in for those flows: the operator is asserting
16
+ * they have already done the merge, and the command proceeds
17
+ * straight to the patch-refresh + state-clear path.
18
+ *
19
+ * The guard without `--yes` is preserved — running `resolve` with
20
+ * no TTY and no `--yes` still refuses so an accidental pipe-into
21
+ * invocation doesn't silently commit whatever the engine happens
22
+ * to contain.
23
+ */
24
+ yes?: boolean;
25
+ }
3
26
  /**
4
27
  * Runs the resolve command to fix broken patches.
5
28
  * @param projectRoot - Root directory of the project
29
+ * @param options - Optional flags; see {@link ResolveCommandOptions}.
6
30
  */
7
- export declare function resolveCommand(projectRoot: string): Promise<void>;
31
+ export declare function resolveCommand(projectRoot: string, options?: ResolveCommandOptions): Promise<void>;
8
32
  /** Registers the resolve command on the CLI program. */
9
33
  export declare function registerResolve(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;
@@ -5,6 +5,7 @@ import { getProjectPaths, loadConfig, loadState, updateState } from '../core/con
5
5
  import { isGitRepository } from '../core/git.js';
6
6
  import { getStagedDiffForFiles } from '../core/git-diff.js';
7
7
  import { stageFiles, unstageFiles } from '../core/git-file-ops.js';
8
+ import { extractAffectedFiles } from '../core/patch-apply.js';
8
9
  import { updatePatchAndMetadata } from '../core/patch-export.js';
9
10
  import { loadPatchesManifest } from '../core/patch-manifest.js';
10
11
  import { GeneralError, ResolutionError } from '../errors/base.js';
@@ -14,8 +15,9 @@ import { error as logError, info, intro, isCancel, outro, spinner, success, } fr
14
15
  /**
15
16
  * Runs the resolve command to fix broken patches.
16
17
  * @param projectRoot - Root directory of the project
18
+ * @param options - Optional flags; see {@link ResolveCommandOptions}.
17
19
  */
18
- export async function resolveCommand(projectRoot) {
20
+ export async function resolveCommand(projectRoot, options = {}) {
19
21
  intro('FireForge Resolve');
20
22
  const paths = getProjectPaths(projectRoot);
21
23
  const state = await loadState(projectRoot);
@@ -34,17 +36,25 @@ export async function resolveCommand(projectRoot) {
34
36
  if (!(await isGitRepository(paths.engine))) {
35
37
  throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
36
38
  }
37
- if (!process.stdin.isTTY) {
38
- throw new GeneralError('Cannot run "fireforge resolve" in non-interactive mode. Use a terminal with TTY support.');
39
+ // Non-interactive mode requires an explicit `--yes` to proceed: the
40
+ // operator is asserting the manual merge is complete and the
41
+ // refreshed diff is the one to record. Without `--yes`, an accidental
42
+ // pipe / CI shell could otherwise commit whatever the engine
43
+ // currently contains. 2026-04-21 eval (Finding #18): a scripted
44
+ // recovery flow was dead-ended by the unconditional TTY refusal.
45
+ if (!process.stdin.isTTY && !options.yes) {
46
+ throw new GeneralError('Cannot run "fireforge resolve" in non-interactive mode. Use a terminal with TTY support, or pass "--yes" to skip the interactive confirmation once the manual merge is complete.');
39
47
  }
40
- const finished = await confirm({
41
- message: 'Have you finished manually fixing the files in engine/?',
42
- initialValue: true,
43
- });
44
- if (isCancel(finished) || !finished) {
45
- info('Please fix the conflicts and run "fireforge resolve" again.');
46
- outro('Resolution paused');
47
- return;
48
+ if (!options.yes) {
49
+ const finished = await confirm({
50
+ message: 'Have you finished manually fixing the files in engine/?',
51
+ initialValue: true,
52
+ });
53
+ if (isCancel(finished) || !finished) {
54
+ info('Please fix the conflicts and run "fireforge resolve" again.');
55
+ outro('Resolution paused');
56
+ return;
57
+ }
48
58
  }
49
59
  const manifest = await loadPatchesManifest(paths.patches);
50
60
  if (!manifest) {
@@ -106,9 +116,22 @@ export async function resolveCommand(projectRoot) {
106
116
  // import / export / re-export / patch reorder / patch compact could
107
117
  // interleave with and leave the manifest disagreeing with the
108
118
  // freshly-written patch body.
119
+ //
120
+ // Always recompute `filesAffected` from the diff content itself. The
121
+ // eval finding #16 scenario: the user's manual fix removed every
122
+ // hunk for one file while the file still existed on disk, so the
123
+ // pre-0.16.0 gate of "update filesAffected only when files were
124
+ // deleted from disk" left the manifest claiming a file the patch
125
+ // body no longer targeted. The next `fireforge import` then failed
126
+ // the patch-manifest consistency check even though resolve reported
127
+ // success. `extractAffectedFiles` already owns the canonical
128
+ // "parse a diff, return its target paths" logic used by export and
129
+ // consistency — using it here keeps resolve in agreement with every
130
+ // other writer.
131
+ const diffFilesAffected = extractAffectedFiles(diffContent);
109
132
  const config = await loadConfig(projectRoot);
110
133
  await updatePatchAndMetadata(paths.patches, patchFilename, diffContent, {
111
- ...(activeFiles.length < existingFiles.length ? { filesAffected: activeFiles } : {}),
134
+ filesAffected: diffFilesAffected,
112
135
  sourceEsrVersion: config.firefox.version,
113
136
  });
114
137
  // Cleanup: Clear pendingResolution from state.json transactionally so
@@ -124,7 +147,7 @@ export async function resolveCommand(projectRoot) {
124
147
  });
125
148
  s.stop(`Updated ${patchFilename}`);
126
149
  success('Patch updated successfully and resolution state cleared.');
127
- info('Run "fireforge import" to apply the remaining patches.');
150
+ info('Patch updated. Run "fireforge import" next to resume the queue from this point — resolve only refreshes the one broken patch, it does not continue applying the remaining patches itself.');
128
151
  outro('Resolution complete');
129
152
  }
130
153
  catch (error) {
@@ -137,9 +160,10 @@ export async function resolveCommand(projectRoot) {
137
160
  export function registerResolve(program, { getProjectRoot, withErrorHandling }) {
138
161
  program
139
162
  .command('resolve')
140
- .description('Update a broken patch with manual fixes and continue')
141
- .action(withErrorHandling(async () => {
142
- await resolveCommand(getProjectRoot());
163
+ .description('Update a broken patch with manual fixes (then run "fireforge import" to resume the queue)')
164
+ .option('-y, --yes', 'Skip the interactive confirmation prompt. Use for non-interactive automation flows (CI, scripted recovery) after the manual merge is complete.')
165
+ .action(withErrorHandling(async (options) => {
166
+ await resolveCommand(getProjectRoot(), options);
143
167
  }));
144
168
  }
145
169
  //# sourceMappingURL=resolve.js.map
@@ -183,17 +183,31 @@ async function runSmokeExit(engineDir, options) {
183
183
  warn(`--capture-console stream error: ${err.message}`);
184
184
  });
185
185
  const findings = [];
186
- let allowlistedHits = 0;
186
+ let allowlistedErrorHits = 0;
187
+ let allowlistedTotalHits = 0;
187
188
  const handleLine = (stream, line) => {
188
189
  // Mirror raw output to the terminal so operators watching the smoke
189
190
  // run still see what the browser is printing. Stream selection on the
190
191
  // mirror preserves stdout/stderr separation for downstream piping.
191
192
  const sink = stream === 'stdout' ? process.stdout : process.stderr;
192
193
  sink.write(`${line}\n`);
194
+ // Count allowlist hits up-front, regardless of error-pattern match.
195
+ // Pre-0.16.0 the counter only incremented when the line ALSO matched
196
+ // an error pattern — so an allowlist regex that visibly matched
197
+ // `console.warn: RSLoader:` still reported 0 hits because
198
+ // `console.warn:` is not a smoke error class, confusing operators
199
+ // who were tuning their allowlist. We now surface two numbers: the
200
+ // total set of allowlisted lines (what the operator sees in the
201
+ // console) and the subset that were error-class (what the smoke
202
+ // exit contract cares about). The exit contract itself is unchanged.
203
+ const isAllowlisted = allowlist.length > 0 && matchesAllowlist(line, allowlist);
204
+ if (isAllowlisted) {
205
+ allowlistedTotalHits += 1;
206
+ }
193
207
  if (!matchesSmokeError(line))
194
208
  return;
195
- if (matchesAllowlist(line, allowlist)) {
196
- allowlistedHits += 1;
209
+ if (isAllowlisted) {
210
+ allowlistedErrorHits += 1;
197
211
  return;
198
212
  }
199
213
  findings.push({ stream, line });
@@ -221,7 +235,8 @@ async function runSmokeExit(engineDir, options) {
221
235
  smokeTimeoutMs,
222
236
  elapsedMs,
223
237
  timedOut: result.timedOut,
224
- allowlistedHits,
238
+ allowlistedErrorHits,
239
+ allowlistedTotalHits,
225
240
  findings,
226
241
  exitCode: result.exitCode,
227
242
  });
@@ -278,7 +293,14 @@ function reportSmokeSummary(args) {
278
293
  info('');
279
294
  info(`Smoke run complete: ${seconds}s elapsed of ${windowSeconds}s window${suffix}`);
280
295
  info(` Unallowed errors: ${String(args.findings.length)}`);
281
- info(` Allowlisted hits: ${String(args.allowlistedHits)}`);
296
+ // The "suppressed errors" count is what the exit contract cares about —
297
+ // it is the subset of allowlisted hits that would otherwise have been
298
+ // tallied as findings. The "all allowlisted lines" count answers the
299
+ // operator's mental model ("my --console-allow pattern matched N
300
+ // console lines"), which pre-0.16.0 was missing and led to 0-hit
301
+ // reports on visibly matching regexes.
302
+ info(` Allowlisted error hits (suppressed): ${String(args.allowlistedErrorHits)}`);
303
+ info(` Allowlisted lines total: ${String(args.allowlistedTotalHits)}`);
282
304
  info(` Child exit code: ${String(args.exitCode)}`);
283
305
  if (args.findings.length === 0)
284
306
  return;
@@ -1,19 +1,15 @@
1
- // SPDX-License-Identifier: EUPL-1.2
2
- import { join } from 'node:path';
3
- import { isBrandingManagedPath } from '../core/branding.js';
4
1
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
2
  import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
6
3
  import { getHead, getStatusWithCodes, isGitRepository, isMissingHeadError } from '../core/git.js';
7
4
  import { getUntrackedFilesInDir } from '../core/git-status.js';
8
5
  import { isFileRegistered, matchesRegistrablePattern } from '../core/manifest-rules.js';
9
6
  import { buildOwnershipTable, renderOwnershipTable } from '../core/ownership-table.js';
10
- import { computePatchedContent } from '../core/patch-apply.js';
11
7
  import { buildPatchQueueContext, collectNewFileCreatorsByPath } from '../core/patch-lint.js';
12
8
  import { loadPatchesManifest } from '../core/patch-manifest.js';
9
+ import { classifyFiles, } from '../core/status-classify.js';
13
10
  import { GeneralError } from '../errors/base.js';
14
- import { toError } from '../utils/errors.js';
15
- import { FIREFORGE_TMP_PATH_PATTERN, pathExists, readText } from '../utils/fs.js';
16
- import { info, intro, outro, verbose, warn } from '../utils/logger.js';
11
+ import { FIREFORGE_TMP_PATH_PATTERN, pathExists } from '../utils/fs.js';
12
+ import { info, intro, outro, warn } from '../utils/logger.js';
17
13
  /**
18
14
  * Status code descriptions for git status.
19
15
  */
@@ -179,87 +175,27 @@ async function expandDirectoryEntries(files, engineDir) {
179
175
  function filterFireForgeTempFiles(files) {
180
176
  return files.filter((entry) => !FIREFORGE_TMP_PATH_PATTERN.test(entry.file));
181
177
  }
182
- /**
183
- * Classifies files into patch-backed, unmanaged, or branding buckets.
184
- */
185
- async function classifyFiles(files, engineDir, patchesDir, binaryName, furnacePrefixes) {
186
- const manifest = await loadPatchesManifest(patchesDir);
187
- // Build set of all patch-claimed file paths
188
- const patchClaimedFiles = new Set();
189
- if (manifest) {
190
- for (const patch of manifest.patches) {
191
- for (const f of patch.filesAffected) {
192
- patchClaimedFiles.add(f);
193
- }
194
- }
195
- }
196
- const results = [];
197
- for (const entry of files) {
198
- // Branding check first
199
- if (isBrandingManagedPath(entry.file, binaryName)) {
200
- results.push({ ...entry, classification: 'branding' });
201
- continue;
202
- }
203
- // Furnace-managed component paths
204
- if (furnacePrefixes.size > 0) {
205
- let isFurnace = false;
206
- for (const prefix of furnacePrefixes) {
207
- if (entry.file.startsWith(prefix)) {
208
- isFurnace = true;
209
- break;
210
- }
211
- }
212
- if (isFurnace) {
213
- results.push({ ...entry, classification: 'furnace' });
214
- continue;
215
- }
216
- }
217
- // Not in any patch → unmanaged
218
- if (!patchClaimedFiles.has(entry.file)) {
219
- results.push({ ...entry, classification: 'unmanaged' });
220
- continue;
221
- }
222
- // File is claimed by a patch — compare content
223
- const primaryCode = getPrimaryStatusCode(entry.status);
224
- if (primaryCode === 'D') {
225
- // Deleted file: patch-backed only if patch expects deletion
226
- const expected = await computePatchedContent(patchesDir, engineDir, entry.file);
227
- results.push({
228
- ...entry,
229
- classification: expected === null ? 'patch-backed' : 'unmanaged',
230
- });
231
- continue;
232
- }
233
- // File exists on disk — compare actual vs expected
234
- try {
235
- const [expected, actual] = await Promise.all([
236
- computePatchedContent(patchesDir, engineDir, entry.file),
237
- readText(join(engineDir, entry.file)),
238
- ]);
239
- results.push({
240
- ...entry,
241
- classification: actual === expected ? 'patch-backed' : 'unmanaged',
242
- });
243
- }
244
- catch (error) {
245
- verbose(`Treating ${entry.file} as unmanaged because patch-backed classification failed: ${toError(error).message}`);
246
- // If we can't read the file, treat as unmanaged
247
- results.push({ ...entry, classification: 'unmanaged' });
248
- }
249
- }
250
- return results;
251
- }
252
178
  /**
253
179
  * Renders classified file status as machine-readable JSON to stdout.
254
180
  */
255
181
  async function renderJsonStatus(files, paths, projectRoot, binaryName) {
256
182
  const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
257
183
  const classified = await classifyFiles(files, paths.engine, paths.patches, binaryName, furnacePrefixes);
258
- const output = classified.map((f) => ({
259
- file: f.file,
260
- status: f.status.trim(),
261
- classification: f.classification,
262
- }));
184
+ const output = classified.map((f) => {
185
+ const entry = {
186
+ file: f.file,
187
+ status: f.status.trim(),
188
+ classification: f.classification,
189
+ };
190
+ // `claimedBy` is an optional field present only on conflict
191
+ // entries, so non-conflict output stays byte-identical to the
192
+ // pre-0.16.0 shape (no unconditional schema change for the
193
+ // 99% of entries that are not cross-patch conflicts).
194
+ if (f.classification === 'conflict' && f.claimedBy && f.claimedBy.length > 0) {
195
+ entry.claimedBy = [...f.claimedBy];
196
+ }
197
+ return entry;
198
+ });
263
199
  process.stdout.write(JSON.stringify(output, null, 2) + '\n');
264
200
  }
265
201
  /**
@@ -394,65 +330,107 @@ export async function statusCommand(projectRoot, options = {}) {
394
330
  // Patch-aware classification
395
331
  const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
396
332
  const classified = await classifyFiles(files, paths.engine, paths.patches, config.binaryName, furnacePrefixes);
397
- const unmanagedFiles = classified.filter((f) => f.classification === 'unmanaged');
398
- const patchBackedFiles = classified.filter((f) => f.classification === 'patch-backed');
399
- const brandingFiles = classified.filter((f) => f.classification === 'branding');
400
- const furnaceFiles = classified.filter((f) => f.classification === 'furnace');
333
+ const buckets = {
334
+ conflict: classified.filter((f) => f.classification === 'conflict'),
335
+ unmanaged: classified.filter((f) => f.classification === 'unmanaged'),
336
+ patchBacked: classified.filter((f) => f.classification === 'patch-backed'),
337
+ branding: classified.filter((f) => f.classification === 'branding'),
338
+ furnace: classified.filter((f) => f.classification === 'furnace'),
339
+ };
401
340
  // --unmanaged mode: only show unmanaged
402
341
  if (options.unmanaged) {
403
- info(`${unmanagedFiles.length} unmanaged file${unmanagedFiles.length === 1 ? '' : 's'} (${files.length} total modified):\n`);
404
- if (unmanagedFiles.length > 0) {
405
- printStatusGroups(unmanagedFiles);
406
- await printUnregisteredWarnings(unmanagedFiles, projectRoot, config.binaryName);
407
- }
408
- else {
409
- info('No unmanaged changes');
410
- }
411
- outro(unmanagedFiles.length === 0
412
- ? 'No unmanaged changes'
413
- : `${unmanagedFiles.length} unmanaged change${unmanagedFiles.length === 1 ? '' : 's'}`);
342
+ await renderUnmanagedOnly(buckets.unmanaged, files.length, projectRoot, config.binaryName);
414
343
  return;
415
344
  }
416
- // Default mode: three-bucket display
417
- info(`${files.length} modified file${files.length === 1 ? '' : 's'}:\n`);
345
+ await renderDefaultStatus(files.length, buckets, projectRoot, config.binaryName);
346
+ }
347
+ async function renderUnmanagedOnly(unmanagedFiles, totalModified, projectRoot, binaryName) {
348
+ info(`${unmanagedFiles.length} unmanaged file${unmanagedFiles.length === 1 ? '' : 's'} (${totalModified} total modified):\n`);
418
349
  if (unmanagedFiles.length > 0) {
419
- warn('Unmanaged changes:');
420
350
  printStatusGroups(unmanagedFiles);
421
- await printUnregisteredWarnings(unmanagedFiles, projectRoot, config.binaryName);
351
+ await printUnregisteredWarnings(unmanagedFiles, projectRoot, binaryName);
352
+ }
353
+ else {
354
+ info('No unmanaged changes');
355
+ }
356
+ outro(unmanagedFiles.length === 0
357
+ ? 'No unmanaged changes'
358
+ : `${unmanagedFiles.length} unmanaged change${unmanagedFiles.length === 1 ? '' : 's'}`);
359
+ }
360
+ /**
361
+ * Renders the default five-bucket status display: conflicts first
362
+ * (they block export/import/rebase), then unmanaged, patch-backed,
363
+ * branding, and furnace-managed sections. Cross-bucket separators
364
+ * ensure the sections are visually distinct without trailing empty
365
+ * groups. Empty buckets are omitted — the very-empty case surfaces a
366
+ * single `No changes` line.
367
+ */
368
+ async function renderDefaultStatus(totalModified, buckets, projectRoot, binaryName) {
369
+ const { conflict, unmanaged, patchBacked, branding, furnace } = buckets;
370
+ info(`${totalModified} modified file${totalModified === 1 ? '' : 's'}:\n`);
371
+ if (conflict.length > 0) {
372
+ // Surface cross-patch ownership conflicts at the top of the default
373
+ // output — they block export/import/rebase and want immediate
374
+ // attention. The `--ownership` view already renders the full table;
375
+ // here we just name the files and point the operator at the
376
+ // canonical recovery path.
377
+ warn('Cross-patch ownership conflicts (same file claimed by multiple patches):');
378
+ printStatusGroups(conflict);
379
+ for (const entry of conflict) {
380
+ if (entry.claimedBy && entry.claimedBy.length > 0) {
381
+ info(` ${entry.file} — claimed by ${entry.claimedBy.join(', ')}`);
382
+ }
383
+ }
384
+ info('Run "fireforge status --ownership" for the full conflict table, then repartition with "fireforge re-export --files <paths> <patch>".');
422
385
  }
423
- if (patchBackedFiles.length > 0) {
424
- if (unmanagedFiles.length > 0)
386
+ if (unmanaged.length > 0) {
387
+ if (conflict.length > 0)
388
+ info('');
389
+ warn('Unmanaged changes:');
390
+ printStatusGroups(unmanaged);
391
+ await printUnregisteredWarnings(unmanaged, projectRoot, binaryName);
392
+ }
393
+ if (patchBacked.length > 0) {
394
+ if (conflict.length > 0 || unmanaged.length > 0)
425
395
  info('');
426
396
  warn('Patch-backed materialized changes:');
427
- printStatusGroups(patchBackedFiles);
397
+ printStatusGroups(patchBacked);
428
398
  }
429
- if (brandingFiles.length > 0) {
430
- if (unmanagedFiles.length > 0 || patchBackedFiles.length > 0)
399
+ if (branding.length > 0) {
400
+ if (conflict.length > 0 || unmanaged.length > 0 || patchBacked.length > 0) {
431
401
  info('');
402
+ }
432
403
  warn('Tool-managed branding changes:');
433
- printStatusGroups(brandingFiles);
404
+ printStatusGroups(branding);
434
405
  }
435
- if (furnaceFiles.length > 0) {
436
- if (unmanagedFiles.length > 0 || patchBackedFiles.length > 0 || brandingFiles.length > 0)
406
+ if (furnace.length > 0) {
407
+ if (conflict.length > 0 ||
408
+ unmanaged.length > 0 ||
409
+ patchBacked.length > 0 ||
410
+ branding.length > 0) {
437
411
  info('');
412
+ }
438
413
  warn('Furnace-managed component changes:');
439
- printStatusGroups(furnaceFiles);
414
+ printStatusGroups(furnace);
440
415
  }
441
- if (unmanagedFiles.length === 0 &&
442
- patchBackedFiles.length === 0 &&
443
- brandingFiles.length === 0 &&
444
- furnaceFiles.length === 0) {
416
+ if (conflict.length === 0 &&
417
+ unmanaged.length === 0 &&
418
+ patchBacked.length === 0 &&
419
+ branding.length === 0 &&
420
+ furnace.length === 0) {
445
421
  info('No changes');
446
422
  }
447
423
  const parts = [];
448
- if (unmanagedFiles.length > 0)
449
- parts.push(`${unmanagedFiles.length} unmanaged`);
450
- if (patchBackedFiles.length > 0)
451
- parts.push(`${patchBackedFiles.length} patch-backed`);
452
- if (brandingFiles.length > 0)
453
- parts.push(`${brandingFiles.length} branding`);
454
- if (furnaceFiles.length > 0)
455
- parts.push(`${furnaceFiles.length} furnace`);
424
+ if (conflict.length > 0)
425
+ parts.push(`${conflict.length} conflict`);
426
+ if (unmanaged.length > 0)
427
+ parts.push(`${unmanaged.length} unmanaged`);
428
+ if (patchBacked.length > 0)
429
+ parts.push(`${patchBacked.length} patch-backed`);
430
+ if (branding.length > 0)
431
+ parts.push(`${branding.length} branding`);
432
+ if (furnace.length > 0)
433
+ parts.push(`${furnace.length} furnace`);
456
434
  outro(parts.join(', '));
457
435
  }
458
436
  /** Registers the status command on the CLI program. */