@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +46 -24
  3. package/dist/src/commands/build.js +33 -10
  4. package/dist/src/commands/config.js +32 -20
  5. package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
  6. package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
  7. package/dist/src/commands/doctor-furnace.js +2 -0
  8. package/dist/src/commands/doctor-working-tree.d.ts +29 -0
  9. package/dist/src/commands/doctor-working-tree.js +93 -0
  10. package/dist/src/commands/doctor.js +23 -12
  11. package/dist/src/commands/export-all.js +11 -3
  12. package/dist/src/commands/export-shared.d.ts +7 -1
  13. package/dist/src/commands/export-shared.js +21 -3
  14. package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
  15. package/dist/src/commands/furnace/create-templates.d.ts +11 -0
  16. package/dist/src/commands/furnace/create-templates.js +11 -2
  17. package/dist/src/commands/furnace/init.js +97 -9
  18. package/dist/src/commands/furnace/override.js +23 -13
  19. package/dist/src/commands/furnace/remove.js +8 -0
  20. package/dist/src/commands/furnace/rename.js +133 -4
  21. package/dist/src/commands/lint.js +70 -6
  22. package/dist/src/commands/patch/delete.js +4 -1
  23. package/dist/src/commands/patch/reorder.js +4 -1
  24. package/dist/src/commands/re-export-files.js +3 -1
  25. package/dist/src/commands/re-export.js +4 -1
  26. package/dist/src/commands/register.js +11 -0
  27. package/dist/src/commands/resolve.d.ts +25 -1
  28. package/dist/src/commands/resolve.js +25 -15
  29. package/dist/src/commands/status.js +100 -122
  30. package/dist/src/commands/test.js +68 -14
  31. package/dist/src/commands/token-coverage.js +10 -3
  32. package/dist/src/commands/wire.js +50 -8
  33. package/dist/src/core/browser-wire.js +21 -4
  34. package/dist/src/core/build-audit.js +10 -0
  35. package/dist/src/core/config.d.ts +33 -0
  36. package/dist/src/core/config.js +43 -0
  37. package/dist/src/core/furnace-config.d.ts +23 -2
  38. package/dist/src/core/furnace-config.js +26 -3
  39. package/dist/src/core/git-diff.js +21 -2
  40. package/dist/src/core/mach.d.ts +43 -6
  41. package/dist/src/core/mach.js +57 -7
  42. package/dist/src/core/manifest-rules.js +10 -1
  43. package/dist/src/core/manifest-tokenizers.d.ts +6 -0
  44. package/dist/src/core/manifest-tokenizers.js +28 -0
  45. package/dist/src/core/marionette-port.d.ts +50 -0
  46. package/dist/src/core/marionette-port.js +215 -0
  47. package/dist/src/core/patch-lint.d.ts +47 -2
  48. package/dist/src/core/patch-lint.js +89 -14
  49. package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
  50. package/dist/src/core/patch-manifest-consistency.js +31 -3
  51. package/dist/src/core/patch-manifest-io.js +10 -0
  52. package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
  53. package/dist/src/core/patch-manifest-resolve.js +29 -2
  54. package/dist/src/core/patch-manifest-validate.js +25 -1
  55. package/dist/src/core/status-classify.d.ts +54 -0
  56. package/dist/src/core/status-classify.js +134 -0
  57. package/dist/src/core/token-coverage.js +24 -0
  58. package/dist/src/core/token-dark-mode.d.ts +49 -0
  59. package/dist/src/core/token-dark-mode.js +182 -0
  60. package/dist/src/core/token-manager.js +17 -33
  61. package/dist/src/core/wire-destroy.d.ts +7 -3
  62. package/dist/src/core/wire-destroy.js +11 -6
  63. package/dist/src/core/wire-dom-fragment.d.ts +17 -0
  64. package/dist/src/core/wire-dom-fragment.js +40 -0
  65. package/dist/src/core/wire-init.d.ts +9 -3
  66. package/dist/src/core/wire-init.js +18 -6
  67. package/dist/src/core/wire-subscript.d.ts +7 -3
  68. package/dist/src/core/wire-subscript.js +11 -4
  69. package/dist/src/types/commands/patches.d.ts +23 -0
  70. package/dist/src/types/furnace.d.ts +9 -0
  71. package/dist/src/utils/parse.d.ts +7 -0
  72. package/dist/src/utils/parse.js +15 -0
  73. 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
- await updateEngineRegistrations(args.engineDir, oldName, newName, newDir, ftlDir, journal);
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
- const diff = await resolveLintDiff(paths.engine, files);
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 patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore);
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
- throw new InvalidArgumentError(`Patch "${identifier}" not found. Available: ${manifest.patches.map((p) => p.filename).join(', ')}`, identifier);
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
- throw new InvalidArgumentError(`Patch "${identifier}" not found. Available: ${manifest.patches.map((p) => p.filename).join(', ')}`, identifier);
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
- if (!process.stdin.isTTY) {
39
- 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.');
40
47
  }
41
- const finished = await confirm({
42
- message: 'Have you finished manually fixing the files in engine/?',
43
- initialValue: true,
44
- });
45
- if (isCancel(finished) || !finished) {
46
- info('Please fix the conflicts and run "fireforge resolve" again.');
47
- outro('Resolution paused');
48
- 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
+ }
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 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.');
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 and continue')
155
- .action(withErrorHandling(async () => {
156
- 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);
157
167
  }));
158
168
  }
159
169
  //# sourceMappingURL=resolve.js.map