@hominis/fireforge 0.16.5 → 0.18.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 +56 -0
- package/README.md +46 -24
- package/dist/src/commands/build.js +33 -10
- package/dist/src/commands/config.js +32 -20
- package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
- package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
- package/dist/src/commands/doctor-furnace.js +2 -0
- package/dist/src/commands/doctor-working-tree.d.ts +29 -0
- package/dist/src/commands/doctor-working-tree.js +93 -0
- package/dist/src/commands/doctor.js +23 -12
- package/dist/src/commands/export-all.js +11 -3
- package/dist/src/commands/export-shared.d.ts +7 -1
- package/dist/src/commands/export-shared.js +21 -3
- package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
- 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/init.js +97 -9
- package/dist/src/commands/furnace/override.js +23 -13
- package/dist/src/commands/furnace/remove.js +8 -0
- package/dist/src/commands/furnace/rename.js +133 -4
- package/dist/src/commands/lint.js +70 -6
- package/dist/src/commands/patch/delete.js +4 -1
- package/dist/src/commands/patch/reorder.js +4 -1
- package/dist/src/commands/re-export-files.js +3 -1
- package/dist/src/commands/re-export.js +4 -1
- package/dist/src/commands/register.js +11 -0
- package/dist/src/commands/resolve.d.ts +25 -1
- package/dist/src/commands/resolve.js +25 -15
- package/dist/src/commands/status.js +100 -122
- package/dist/src/commands/test.js +68 -14
- package/dist/src/commands/token-coverage.js +10 -3
- package/dist/src/commands/wire.js +50 -8
- package/dist/src/core/browser-wire.js +21 -4
- package/dist/src/core/build-audit.js +10 -0
- 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/git-diff.js +21 -2
- package/dist/src/core/mach.d.ts +43 -6
- package/dist/src/core/mach.js +57 -7
- package/dist/src/core/manifest-rules.js +10 -1
- package/dist/src/core/manifest-tokenizers.d.ts +6 -0
- package/dist/src/core/manifest-tokenizers.js +28 -0
- package/dist/src/core/marionette-port.d.ts +50 -0
- package/dist/src/core/marionette-port.js +215 -0
- package/dist/src/core/patch-lint.d.ts +47 -2
- package/dist/src/core/patch-lint.js +89 -14
- package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
- package/dist/src/core/patch-manifest-consistency.js +31 -3
- package/dist/src/core/patch-manifest-io.js +10 -0
- package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
- package/dist/src/core/patch-manifest-resolve.js +29 -2
- package/dist/src/core/patch-manifest-validate.js +25 -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-coverage.js +24 -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.d.ts +7 -3
- package/dist/src/core/wire-destroy.js +11 -6
- 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.d.ts +9 -3
- package/dist/src/core/wire-init.js +18 -6
- package/dist/src/core/wire-subscript.d.ts +7 -3
- package/dist/src/core/wire-subscript.js +11 -4
- package/dist/src/types/commands/patches.d.ts +23 -0
- package/dist/src/types/furnace.d.ts +9 -0
- package/dist/src/utils/parse.d.ts +7 -0
- package/dist/src/utils/parse.js +15 -0
- package/package.json +1 -1
|
@@ -3,9 +3,9 @@ import { readdir } from 'node:fs/promises';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { getProjectPaths, loadConfig } from '../../core/config.js';
|
|
5
5
|
import { getFurnacePaths, loadFurnaceConfig, updateFurnaceState, writeFurnaceConfig, } from '../../core/furnace-config.js';
|
|
6
|
-
import { isComponentSourceFile, resolveFtlDir, tagNameToClassName, } from '../../core/furnace-constants.js';
|
|
6
|
+
import { isComponentSourceFile, resolveFtlChromeSubPath, resolveFtlDir, resolveFtlLocaleJarMnPath, tagNameToClassName, } from '../../core/furnace-constants.js';
|
|
7
7
|
import { recordFurnaceRollbackFailure, runFurnaceMutation } from '../../core/furnace-operation.js';
|
|
8
|
-
import { addCustomElementRegistration, addJarMnEntries, removeCustomElementRegistration, removeJarMnEntries, } from '../../core/furnace-registration.js';
|
|
8
|
+
import { addCustomElementRegistration, addJarMnEntries, addLocaleFtlJarMnEntry, removeCustomElementRegistration, removeJarMnEntries, removeLocaleFtlJarMnEntry, } from '../../core/furnace-registration.js';
|
|
9
9
|
import { CUSTOM_ELEMENT_TAG_PATTERN, CUSTOM_ELEMENT_TAG_RULES, } from '../../core/furnace-registration-validate.js';
|
|
10
10
|
import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotDir, snapshotFile, } from '../../core/furnace-rollback.js';
|
|
11
11
|
import { getStoriesDir } from '../../core/furnace-stories.js';
|
|
@@ -122,6 +122,94 @@ async function renameTestFiles(engineDir, projectRoot, oldName, newName, journal
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Removes the deployed custom-widget directory at the old target path so
|
|
127
|
+
* a subsequent `furnace apply` is the single writer of the new name's
|
|
128
|
+
* deployment. Best-effort: logs a warning but never blocks the rename.
|
|
129
|
+
*
|
|
130
|
+
* 2026-04-21 eval: renaming `ff-chip-row` → `ff-chip-stack` registered
|
|
131
|
+
* and deployed the new name correctly but left `engine/toolkit/content/
|
|
132
|
+
* widgets/ff-chip-row/` in place. Subsequent `furnace sync` runs could
|
|
133
|
+
* not clear the stale widget, and packaging would have pulled in both
|
|
134
|
+
* copies. The snapshot is taken before the remove so the rollback
|
|
135
|
+
* journal restores the old directory if any later step in
|
|
136
|
+
* `performRenameMutations` fails.
|
|
137
|
+
*/
|
|
138
|
+
async function removeStaleDeployedComponentDir(engineDir, oldTargetPath, journal) {
|
|
139
|
+
const oldDeployed = join(engineDir, oldTargetPath);
|
|
140
|
+
if (!(await pathExists(oldDeployed)))
|
|
141
|
+
return;
|
|
142
|
+
try {
|
|
143
|
+
await snapshotDir(journal, oldDeployed);
|
|
144
|
+
await removeDir(oldDeployed);
|
|
145
|
+
info(`Removed stale deployed widget directory: ${oldTargetPath}`);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
warn(`Could not remove stale deployed widget directory at ${oldTargetPath}: ${toError(error).message}. Remove it manually if needed.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Renames the mochikit test scaffold produced by `furnace create
|
|
153
|
+
* --with-tests` when the default test style is used. The scaffold lives
|
|
154
|
+
* at `engine/toolkit/content/tests/widgets/test_<name>.html`, and the
|
|
155
|
+
* accompanying `chrome.toml` entry names the same file. Neither piece
|
|
156
|
+
* was handled by the pre-0.16.0 rename, so operators were left with a
|
|
157
|
+
* `test_<old>.html` file that still imported `chrome://global/content/
|
|
158
|
+
* elements/<old>.mjs` and referenced `customElements.whenDefined("<old>")`
|
|
159
|
+
* — the test ran against a component that no longer existed under that
|
|
160
|
+
* name and either failed or (if the old component was still deployed)
|
|
161
|
+
* passed for the wrong reason.
|
|
162
|
+
*
|
|
163
|
+
* Best-effort: individual failures log a warning. The same journal used
|
|
164
|
+
* for the rest of the rename snapshots every touched file so a later
|
|
165
|
+
* failure rolls the pair back together.
|
|
166
|
+
*/
|
|
167
|
+
async function renameMochikitTestFiles(engineDir, oldName, newName, journal) {
|
|
168
|
+
const testDir = join(engineDir, 'toolkit/content/tests/widgets');
|
|
169
|
+
if (!(await pathExists(testDir)))
|
|
170
|
+
return;
|
|
171
|
+
const oldTestFileName = `test_${oldName}.html`;
|
|
172
|
+
const newTestFileName = `test_${newName}.html`;
|
|
173
|
+
const oldTestPath = join(testDir, oldTestFileName);
|
|
174
|
+
const newTestPath = join(testDir, newTestFileName);
|
|
175
|
+
if (await pathExists(oldTestPath)) {
|
|
176
|
+
try {
|
|
177
|
+
await snapshotFile(journal, oldTestPath);
|
|
178
|
+
const content = await readText(oldTestPath);
|
|
179
|
+
const updatedContent = content
|
|
180
|
+
.replace(new RegExp(`chrome://global/content/elements/${escapeRegex(oldName)}\\.mjs`, 'g'), `chrome://global/content/elements/${newName}.mjs`)
|
|
181
|
+
.replace(new RegExp(`customElements\\.whenDefined\\("${escapeRegex(oldName)}"\\)`, 'g'), `customElements.whenDefined("${newName}")`)
|
|
182
|
+
.replace(new RegExp(`Test the ${escapeRegex(oldName)} `, 'g'), `Test the ${newName} `)
|
|
183
|
+
.replace(new RegExp(`add_task\\(async function test_${escapeRegex(oldName.replace(/-/g, '_'))}_defined\\(`, 'g'), `add_task(async function test_${newName.replace(/-/g, '_')}_defined(`)
|
|
184
|
+
.replace(new RegExp(`"${escapeRegex(oldName)} custom element`, 'g'), `"${newName} custom element`);
|
|
185
|
+
await writeText(newTestPath, updatedContent);
|
|
186
|
+
await removeFile(oldTestPath);
|
|
187
|
+
info(`Renamed mochikit test: ${oldTestFileName} → ${newTestFileName}`);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
warn(`Could not rename mochikit test file — ${toError(error).message}. Rename it manually if needed.`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Update `chrome.toml` entry if present. The file may live in the
|
|
194
|
+
// same widgets/tests directory as the test file itself; upstream
|
|
195
|
+
// convention places exactly one `chrome.toml` there for all widget
|
|
196
|
+
// scaffolds.
|
|
197
|
+
const chromeTomlPath = join(testDir, 'chrome.toml');
|
|
198
|
+
if (await pathExists(chromeTomlPath)) {
|
|
199
|
+
try {
|
|
200
|
+
const toml = await readText(chromeTomlPath);
|
|
201
|
+
if (toml.includes(`["${oldTestFileName}"]`)) {
|
|
202
|
+
await snapshotFile(journal, chromeTomlPath);
|
|
203
|
+
const updated = toml.replace(`["${oldTestFileName}"]`, `["${newTestFileName}"]`);
|
|
204
|
+
await writeText(chromeTomlPath, updated);
|
|
205
|
+
info(`Updated chrome.toml: ${oldTestFileName} → ${newTestFileName}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
warn(`Could not update widgets chrome.toml — ${toError(error).message}. Update it manually if needed.`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
125
213
|
/**
|
|
126
214
|
* Performs the transactional rename mutation inside a furnace lock.
|
|
127
215
|
*/
|
|
@@ -129,6 +217,11 @@ async function performRenameMutations(args) {
|
|
|
129
217
|
const { projectRoot, oldName, newName, oldDir, newDir, isCustom, componentType, config } = args;
|
|
130
218
|
const oldClassName = tagNameToClassName(oldName);
|
|
131
219
|
const newClassName = tagNameToClassName(newName);
|
|
220
|
+
// Capture the pre-rename deployed target path so we know what to
|
|
221
|
+
// clean up in the engine tree. `updateConfigForCustomRename` rewrites
|
|
222
|
+
// `targetPath` in-place once the mutation enters phase 2, so we read
|
|
223
|
+
// it here while it still points at the old name's deployment.
|
|
224
|
+
const oldCustomTargetPath = isCustom ? config.custom[oldName]?.targetPath : undefined;
|
|
132
225
|
await runFurnaceMutation(projectRoot, 'rename-rollback', async (ctx) => {
|
|
133
226
|
const journal = createRollbackJournal();
|
|
134
227
|
ctx.registerJournal(journal);
|
|
@@ -179,7 +272,8 @@ async function performRenameMutations(args) {
|
|
|
179
272
|
// 3. Update engine registrations (custom components only)
|
|
180
273
|
if (isCustom && config.custom[newName]?.register && (await pathExists(args.engineDir))) {
|
|
181
274
|
const ftlDir = resolveFtlDir(config.ftlBasePath);
|
|
182
|
-
|
|
275
|
+
const isLocalized = config.custom[newName].localized;
|
|
276
|
+
await updateEngineRegistrations(args.engineDir, oldName, newName, newDir, ftlDir, isLocalized, journal);
|
|
183
277
|
}
|
|
184
278
|
// 4. Re-key furnace-state.json checksums from old name to new name
|
|
185
279
|
await rekeyStateChecksums(args.projectRoot, componentType, oldName, newName);
|
|
@@ -197,6 +291,23 @@ async function performRenameMutations(args) {
|
|
|
197
291
|
// 7. Rename test files created by `furnace create --with-tests` (custom only).
|
|
198
292
|
if (isCustom && (await pathExists(args.engineDir))) {
|
|
199
293
|
await renameTestFiles(args.engineDir, projectRoot, oldName, newName, journal);
|
|
294
|
+
// Mochikit scaffold + widgets/chrome.toml live in a different
|
|
295
|
+
// tree than browser.toml-registered browser-chrome tests, so
|
|
296
|
+
// renameTestFiles doesn't reach them. 2026-04-21 eval: a rename
|
|
297
|
+
// left `engine/toolkit/content/tests/widgets/test_<old>.html`
|
|
298
|
+
// and its `chrome.toml` entry pointing at the old name, which
|
|
299
|
+
// either failed the test run outright or (worse) passed for the
|
|
300
|
+
// wrong component.
|
|
301
|
+
await renameMochikitTestFiles(args.engineDir, oldName, newName, journal);
|
|
302
|
+
// Clear the stale deployed component directory so the next
|
|
303
|
+
// `furnace apply` is the single writer of the new name's
|
|
304
|
+
// deployment. Without this, eval runs showed the old widget
|
|
305
|
+
// still living at `engine/toolkit/content/widgets/<old>/`
|
|
306
|
+
// alongside the newly-deployed `engine/toolkit/content/
|
|
307
|
+
// widgets/<new>/`, with no signal to `status` / `verify`.
|
|
308
|
+
if (oldCustomTargetPath) {
|
|
309
|
+
await removeStaleDeployedComponentDir(args.engineDir, oldCustomTargetPath, journal);
|
|
310
|
+
}
|
|
200
311
|
}
|
|
201
312
|
info(`Renamed ${componentType} component: ${oldName} → ${newName}`);
|
|
202
313
|
}
|
|
@@ -248,7 +359,7 @@ async function rekeyStateChecksums(projectRoot, componentType, oldName, newName)
|
|
|
248
359
|
return result;
|
|
249
360
|
});
|
|
250
361
|
}
|
|
251
|
-
async function updateEngineRegistrations(engineDir, oldName, newName, newDir, ftlDir, journal) {
|
|
362
|
+
async function updateEngineRegistrations(engineDir, oldName, newName, newDir, ftlDir, isLocalized, journal) {
|
|
252
363
|
const customElementsPath = join(engineDir, 'toolkit/content/customElements.js');
|
|
253
364
|
const jarMnPath = join(engineDir, 'toolkit/content/jar.mn');
|
|
254
365
|
if (await pathExists(customElementsPath)) {
|
|
@@ -276,6 +387,24 @@ async function updateEngineRegistrations(engineDir, oldName, newName, newDir, ft
|
|
|
276
387
|
await writeText(newFtlPath, ftlContent);
|
|
277
388
|
await removeFile(oldFtlPath);
|
|
278
389
|
}
|
|
390
|
+
// Re-wire the locale jar.mn chrome registration when the component is
|
|
391
|
+
// localized. Before this, `updateEngineRegistrations` renamed the .ftl
|
|
392
|
+
// file on disk but left the locale jar.mn pointing at
|
|
393
|
+
// `locale/.../${oldName}.ftl`, so `furnace validate` passed while the
|
|
394
|
+
// engine still carried a stale registration for the now-missing file
|
|
395
|
+
// (eval finding: stale old-name registration after rename).
|
|
396
|
+
if (isLocalized) {
|
|
397
|
+
const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
|
|
398
|
+
const localeJarRel = resolveFtlLocaleJarMnPath(ftlDir);
|
|
399
|
+
if (chromeSubPath !== undefined && localeJarRel !== undefined) {
|
|
400
|
+
const localeJarAbs = join(engineDir, localeJarRel);
|
|
401
|
+
if (await pathExists(localeJarAbs)) {
|
|
402
|
+
await snapshotFile(journal, localeJarAbs);
|
|
403
|
+
await removeLocaleFtlJarMnEntry(engineDir, localeJarRel, oldName, chromeSubPath);
|
|
404
|
+
await addLocaleFtlJarMnEntry(engineDir, localeJarRel, newName, chromeSubPath);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
279
408
|
}
|
|
280
409
|
/**
|
|
281
410
|
* Renames a custom or override component atomically: updates directory name,
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { stat } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { isBrandingManagedPath } from '../core/branding.js';
|
|
4
5
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
5
6
|
import { getStatusWithCodes, hasChanges, isGitRepository } from '../core/git.js';
|
|
6
7
|
import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
7
|
-
import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
|
|
8
|
+
import { expandUntrackedDirectoryEntries, getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, getWorkingTreeStatus, } from '../core/git-status.js';
|
|
8
9
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
9
|
-
import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue } from '../core/patch-lint.js';
|
|
10
|
+
import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue, resolvePatchSizeTier, } from '../core/patch-lint.js';
|
|
10
11
|
import { collectDiffFilePaths, tagLintIssues } from '../core/patch-lint-diff-tag.js';
|
|
11
12
|
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
12
13
|
import { GeneralError } from '../errors/base.js';
|
|
@@ -22,8 +23,19 @@ import { stripEnginePrefix } from '../utils/paths.js';
|
|
|
22
23
|
* per-function LOC budget as the command grows; the two file-mode and
|
|
23
24
|
* aggregate-mode branches share no state with the post-lint reporting
|
|
24
25
|
* pipeline, so the split is a pure rename rather than a refactor.
|
|
26
|
+
*
|
|
27
|
+
* When `binaryName` is provided, the aggregate-mode branch (no
|
|
28
|
+
* explicit file list) excludes paths under `browser/branding/<binaryName>/`
|
|
29
|
+
* from the diff. `status` classifies those paths as `branding` —
|
|
30
|
+
* tool-managed material the operator did not author directly — and
|
|
31
|
+
* the 2026-04-21 eval (Finding #2) reported that `fireforge lint` on
|
|
32
|
+
* a fresh project immediately failed `large-patch-lines` /
|
|
33
|
+
* `large-patch-files` / `missing-license-header` on the generated
|
|
34
|
+
* branding tree. File-list mode (explicit paths) preserves the
|
|
35
|
+
* previous behaviour: passing a branding file explicitly still lints
|
|
36
|
+
* it, so operators who need to audit branding content can do so.
|
|
25
37
|
*/
|
|
26
|
-
async function resolveLintDiff(engineDir, files) {
|
|
38
|
+
async function resolveLintDiff(engineDir, files, binaryName) {
|
|
27
39
|
if (files.length > 0) {
|
|
28
40
|
const collectedFiles = new Set();
|
|
29
41
|
let fileStatuses;
|
|
@@ -83,6 +95,47 @@ async function resolveLintDiff(engineDir, files) {
|
|
|
83
95
|
outro('Nothing to lint');
|
|
84
96
|
return null;
|
|
85
97
|
}
|
|
98
|
+
// Aggregate-mode branding exclusion. A fresh-setup workspace (after
|
|
99
|
+
// `fireforge setup` + `download` + `bootstrap` + `build`) carries a
|
|
100
|
+
// large tool-managed branding diff that the operator did not
|
|
101
|
+
// author; running the default lint against it fires size and
|
|
102
|
+
// license-header rules on content that was never intended to
|
|
103
|
+
// survive in the patch queue as-is. The exclusion mirrors the
|
|
104
|
+
// `branding` bucket in `fireforge status` so the two views stay
|
|
105
|
+
// consistent.
|
|
106
|
+
//
|
|
107
|
+
// `expandUntrackedDirectoryEntries` promotes collapsed `?? dir/`
|
|
108
|
+
// status rows to individual file entries before the diff pass.
|
|
109
|
+
// Without it, a patch that introduces a new directory shows up as
|
|
110
|
+
// `?? browser/modules/<fork>/` and `getDiffForFilesAgainstHead`
|
|
111
|
+
// crashed with EISDIR reading the directory as if it were a file
|
|
112
|
+
// (eval finding: aggregate lint unusable on a real imported queue).
|
|
113
|
+
if (binaryName) {
|
|
114
|
+
const rawStatus = await getWorkingTreeStatus(engineDir);
|
|
115
|
+
const expanded = await expandUntrackedDirectoryEntries(engineDir, rawStatus);
|
|
116
|
+
const allPaths = [...new Set(expanded.map((entry) => entry.file))];
|
|
117
|
+
const nonBrandingPaths = allPaths.filter((path) => !isBrandingManagedPath(path, binaryName));
|
|
118
|
+
const excludedCount = allPaths.length - nonBrandingPaths.length;
|
|
119
|
+
if (excludedCount > 0) {
|
|
120
|
+
info(`Excluded ${excludedCount} tool-managed branding file${excludedCount === 1 ? '' : 's'} from lint. Pass the path explicitly or use \`fireforge lint <path>\` to include them.`);
|
|
121
|
+
}
|
|
122
|
+
if (nonBrandingPaths.length === 0) {
|
|
123
|
+
info('No non-branding changes to lint.');
|
|
124
|
+
outro('Nothing to lint');
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const diff = await getDiffForFilesAgainstHead(engineDir, nonBrandingPaths.sort());
|
|
128
|
+
if (!diff.trim()) {
|
|
129
|
+
info('No diff content to lint.');
|
|
130
|
+
outro('Nothing to lint');
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return diff;
|
|
134
|
+
}
|
|
135
|
+
// Fallback path: no binaryName available (e.g. a legacy caller
|
|
136
|
+
// without a loaded config). Retain the pre-0.16.0 behaviour of
|
|
137
|
+
// linting the full diff so the lint surface is at least as broad
|
|
138
|
+
// as before.
|
|
86
139
|
const diff = await getAllDiff(engineDir);
|
|
87
140
|
if (!diff.trim()) {
|
|
88
141
|
info('No diff content to lint.');
|
|
@@ -126,10 +179,15 @@ export async function lintCommand(projectRoot, files, options = {}) {
|
|
|
126
179
|
await lintPerPatch(projectRoot, paths);
|
|
127
180
|
return;
|
|
128
181
|
}
|
|
129
|
-
|
|
182
|
+
// Load the config before resolving the diff so we can pass
|
|
183
|
+
// `binaryName` into the aggregate-mode branding exclusion in
|
|
184
|
+
// `resolveLintDiff`. The config was previously loaded only after
|
|
185
|
+
// the diff was resolved; hoisting it is cheap and keeps the two
|
|
186
|
+
// call sites close together.
|
|
187
|
+
const config = await loadConfig(projectRoot);
|
|
188
|
+
const diff = await resolveLintDiff(paths.engine, files, config.binaryName);
|
|
130
189
|
if (diff === null)
|
|
131
190
|
return;
|
|
132
|
-
const config = await loadConfig(projectRoot);
|
|
133
191
|
const filesAffected = extractAffectedFiles(diff);
|
|
134
192
|
// Build patch queue context once so it can be shared between the
|
|
135
193
|
// per-patch ownership resolver and the cross-patch rules.
|
|
@@ -264,7 +322,13 @@ async function lintPerPatch(projectRoot, paths) {
|
|
|
264
322
|
if (!diff.trim())
|
|
265
323
|
continue;
|
|
266
324
|
const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
|
|
267
|
-
const
|
|
325
|
+
const decision = resolvePatchSizeTier(existing, patch.tier);
|
|
326
|
+
if (decision.tier === 'branding') {
|
|
327
|
+
info(decision.source === 'explicit'
|
|
328
|
+
? `${patch.filename}: branding threshold tier applied via patches.json \`tier: "branding"\` opt-in.`
|
|
329
|
+
: `${patch.filename}: branding threshold tier applied (all files under browser/branding/ plus registration siblings).`);
|
|
330
|
+
}
|
|
331
|
+
const patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore, patch.tier);
|
|
268
332
|
for (const issue of patchIssues) {
|
|
269
333
|
issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
|
|
270
334
|
}
|
|
@@ -39,7 +39,10 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
|
|
|
39
39
|
}
|
|
40
40
|
const target = resolvePatchIdentifier(identifier, manifest.patches);
|
|
41
41
|
if (!target) {
|
|
42
|
-
|
|
42
|
+
const available = manifest.patches
|
|
43
|
+
.map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
|
|
44
|
+
.join(', ');
|
|
45
|
+
throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
|
|
43
46
|
}
|
|
44
47
|
// Build the full queue context once so we can scan each patch's newFiles
|
|
45
48
|
// without re-parsing for the dependency check below.
|
|
@@ -270,7 +270,10 @@ export async function patchReorderCommand(projectRoot, identifier, options = {})
|
|
|
270
270
|
}
|
|
271
271
|
const target = resolvePatchIdentifier(identifier, manifest.patches);
|
|
272
272
|
if (!target) {
|
|
273
|
-
|
|
273
|
+
const available = manifest.patches
|
|
274
|
+
.map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
|
|
275
|
+
.join(', ');
|
|
276
|
+
throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
|
|
274
277
|
}
|
|
275
278
|
const { destinationOrder, anchorFilename } = resolveDestination(target, manifest.patches, options);
|
|
276
279
|
const renameMap = computeRenameMap(manifest.patches, target, destinationOrder);
|
|
@@ -75,8 +75,10 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
75
75
|
// `lintIgnore` threads through so a shrink of an advisory-noisy-but-
|
|
76
76
|
// intentional patch (branding bundle, localised-resource pack) does not
|
|
77
77
|
// have to choose between `--skip-lint` (blunt) and the full rebase path.
|
|
78
|
+
// `target.tier` threads the explicit branding-threshold opt-in for
|
|
79
|
+
// the branding patch that also touches a non-allowlisted sibling.
|
|
78
80
|
const ignoreChecks = target.lintIgnore?.length ? new Set(target.lintIgnore) : undefined;
|
|
79
|
-
await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, undefined, ignoreChecks);
|
|
81
|
+
await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, undefined, ignoreChecks, target.tier);
|
|
80
82
|
// Project the cross-patch context: replace the target entry with its
|
|
81
83
|
// would-be shrunken self (new diff + new newFiles + new
|
|
82
84
|
// modifiedFileAdditions). The projected entry must repopulate both
|
|
@@ -201,8 +201,11 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
201
201
|
// intentional patch (a cohesive branding bundle, a localised-resource
|
|
202
202
|
// pack) without either `--skip-lint` (too blunt) or falling through to
|
|
203
203
|
// the full `rebase` flow (which internally skips the lint pipeline).
|
|
204
|
+
// The paired `patch.tier` threads the explicit branding-threshold
|
|
205
|
+
// opt-in the same way, for the branding patch that also touches a
|
|
206
|
+
// non-allowlisted registration sibling.
|
|
204
207
|
const ignoreChecks = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
|
|
205
|
-
await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, undefined, ignoreChecks);
|
|
208
|
+
await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, undefined, ignoreChecks, patch.tier);
|
|
206
209
|
if (isDryRun) {
|
|
207
210
|
info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
|
|
208
211
|
}
|
|
@@ -45,6 +45,17 @@ export async function registerCommand(projectRoot, filePath, options = {}) {
|
|
|
45
45
|
}
|
|
46
46
|
const result = await registerFile(projectRoot, engineRelativePath, options.dryRun, options.after);
|
|
47
47
|
if (options.dryRun) {
|
|
48
|
+
// 2026-04-21 eval (Finding #8): dry-run always said "Would
|
|
49
|
+
// register" even when the rule's idempotency check already knew
|
|
50
|
+
// the entry was present, so automation read the plan as "work to
|
|
51
|
+
// do" and the following real run then reported "Already
|
|
52
|
+
// registered". Surface the idempotency decision in dry-run too so
|
|
53
|
+
// the plan mirrors the real command's outcome.
|
|
54
|
+
if (result.skipped) {
|
|
55
|
+
info(`[dry-run] Already registered: ${engineRelativePath} in ${result.manifest}`);
|
|
56
|
+
outro('Dry run complete');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
48
59
|
info(`[dry-run] Would register ${engineRelativePath}`);
|
|
49
60
|
info(` manifest: ${result.manifest}`);
|
|
50
61
|
info(` entry: ${result.entry}`);
|
|
@@ -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;
|
|
@@ -15,8 +15,9 @@ import { error as logError, info, intro, isCancel, outro, spinner, success, } fr
|
|
|
15
15
|
/**
|
|
16
16
|
* Runs the resolve command to fix broken patches.
|
|
17
17
|
* @param projectRoot - Root directory of the project
|
|
18
|
+
* @param options - Optional flags; see {@link ResolveCommandOptions}.
|
|
18
19
|
*/
|
|
19
|
-
export async function resolveCommand(projectRoot) {
|
|
20
|
+
export async function resolveCommand(projectRoot, options = {}) {
|
|
20
21
|
intro('FireForge Resolve');
|
|
21
22
|
const paths = getProjectPaths(projectRoot);
|
|
22
23
|
const state = await loadState(projectRoot);
|
|
@@ -35,17 +36,25 @@ export async function resolveCommand(projectRoot) {
|
|
|
35
36
|
if (!(await isGitRepository(paths.engine))) {
|
|
36
37
|
throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
-
|
|
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.');
|
|
40
47
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
}
|
|
49
58
|
}
|
|
50
59
|
const manifest = await loadPatchesManifest(paths.patches);
|
|
51
60
|
if (!manifest) {
|
|
@@ -138,7 +147,7 @@ export async function resolveCommand(projectRoot) {
|
|
|
138
147
|
});
|
|
139
148
|
s.stop(`Updated ${patchFilename}`);
|
|
140
149
|
success('Patch updated successfully and resolution state cleared.');
|
|
141
|
-
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.');
|
|
142
151
|
outro('Resolution complete');
|
|
143
152
|
}
|
|
144
153
|
catch (error) {
|
|
@@ -151,9 +160,10 @@ export async function resolveCommand(projectRoot) {
|
|
|
151
160
|
export function registerResolve(program, { getProjectRoot, withErrorHandling }) {
|
|
152
161
|
program
|
|
153
162
|
.command('resolve')
|
|
154
|
-
.description('Update a broken patch with manual fixes
|
|
155
|
-
.
|
|
156
|
-
|
|
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);
|
|
157
167
|
}));
|
|
158
168
|
}
|
|
159
169
|
//# sourceMappingURL=resolve.js.map
|