@hominis/fireforge 0.16.3 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -1
- package/README.md +11 -3
- package/dist/src/commands/build.js +16 -7
- package/dist/src/commands/config.js +32 -20
- package/dist/src/commands/doctor.js +14 -1
- package/dist/src/commands/download.js +44 -13
- package/dist/src/commands/export-all.js +19 -2
- package/dist/src/commands/export-shared.d.ts +36 -0
- package/dist/src/commands/export-shared.js +76 -0
- package/dist/src/commands/export.js +23 -2
- package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
- package/dist/src/commands/furnace/create-readback.d.ts +23 -0
- package/dist/src/commands/furnace/create-readback.js +34 -0
- package/dist/src/commands/furnace/create-templates.d.ts +11 -0
- package/dist/src/commands/furnace/create-templates.js +11 -2
- package/dist/src/commands/furnace/create.js +2 -0
- package/dist/src/commands/furnace/init.js +97 -9
- package/dist/src/commands/furnace/preview.d.ts +12 -0
- package/dist/src/commands/furnace/preview.js +34 -2
- package/dist/src/commands/furnace/rename.js +110 -0
- package/dist/src/commands/furnace/status.js +1 -1
- package/dist/src/commands/lint.js +55 -4
- package/dist/src/commands/patch/index.js +10 -1
- package/dist/src/commands/re-export.js +79 -6
- package/dist/src/commands/resolve.d.ts +25 -1
- package/dist/src/commands/resolve.js +40 -16
- package/dist/src/commands/run.js +27 -5
- package/dist/src/commands/status.js +100 -122
- package/dist/src/commands/test.js +23 -3
- package/dist/src/commands/token-coverage.js +55 -1
- package/dist/src/commands/token.js +12 -1
- package/dist/src/commands/wire.js +56 -10
- package/dist/src/core/config.d.ts +33 -0
- package/dist/src/core/config.js +43 -0
- package/dist/src/core/furnace-config.d.ts +23 -2
- package/dist/src/core/furnace-config.js +26 -3
- package/dist/src/core/mach-error-hints.js +16 -0
- package/dist/src/core/mach.d.ts +31 -0
- package/dist/src/core/mach.js +59 -6
- package/dist/src/core/marionette-port.d.ts +50 -0
- package/dist/src/core/marionette-port.js +215 -0
- package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
- package/dist/src/core/patch-manifest-consistency.js +16 -1
- package/dist/src/core/status-classify.d.ts +54 -0
- package/dist/src/core/status-classify.js +134 -0
- package/dist/src/core/token-dark-mode.d.ts +49 -0
- package/dist/src/core/token-dark-mode.js +182 -0
- package/dist/src/core/token-manager.js +17 -33
- package/dist/src/core/wire-destroy.js +18 -5
- package/dist/src/core/wire-dom-fragment.d.ts +17 -0
- package/dist/src/core/wire-dom-fragment.js +40 -0
- package/dist/src/core/wire-init.js +20 -5
- package/dist/src/core/wire-utils.d.ts +15 -0
- package/dist/src/core/wire-utils.js +17 -0
- package/dist/src/types/commands/options.d.ts +7 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
141
|
-
.
|
|
142
|
-
|
|
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
|
package/dist/src/commands/run.js
CHANGED
|
@@ -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
|
|
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 (
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
15
|
-
import {
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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,
|
|
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 (
|
|
424
|
-
if (
|
|
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(
|
|
397
|
+
printStatusGroups(patchBacked);
|
|
428
398
|
}
|
|
429
|
-
if (
|
|
430
|
-
if (
|
|
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(
|
|
404
|
+
printStatusGroups(branding);
|
|
434
405
|
}
|
|
435
|
-
if (
|
|
436
|
-
if (
|
|
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(
|
|
414
|
+
printStatusGroups(furnace);
|
|
440
415
|
}
|
|
441
|
-
if (
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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 (
|
|
449
|
-
parts.push(`${
|
|
450
|
-
if (
|
|
451
|
-
parts.push(`${
|
|
452
|
-
if (
|
|
453
|
-
parts.push(`${
|
|
454
|
-
if (
|
|
455
|
-
parts.push(`${
|
|
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. */
|