@hominis/fireforge 0.18.0 → 0.18.2

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 (52) hide show
  1. package/CHANGELOG.md +18 -2
  2. package/README.md +55 -34
  3. package/dist/src/commands/doctor.js +13 -1
  4. package/dist/src/commands/export-all.js +63 -1
  5. package/dist/src/commands/export-flow.d.ts +4 -0
  6. package/dist/src/commands/export-flow.js +8 -0
  7. package/dist/src/commands/export.js +26 -2
  8. package/dist/src/commands/furnace/create-xpcshell.js +4 -2
  9. package/dist/src/commands/furnace/preview.js +38 -0
  10. package/dist/src/commands/furnace/remove.js +67 -1
  11. package/dist/src/commands/furnace/rename-xpcshell.d.ts +35 -0
  12. package/dist/src/commands/furnace/rename-xpcshell.js +97 -0
  13. package/dist/src/commands/furnace/rename.js +9 -0
  14. package/dist/src/commands/patch/index.d.ts +5 -3
  15. package/dist/src/commands/patch/index.js +10 -4
  16. package/dist/src/commands/patch/lint-ignore.d.ts +39 -0
  17. package/dist/src/commands/patch/lint-ignore.js +200 -0
  18. package/dist/src/commands/patch/tier.d.ts +34 -0
  19. package/dist/src/commands/patch/tier.js +134 -0
  20. package/dist/src/commands/re-export-files.js +88 -45
  21. package/dist/src/commands/re-export.js +49 -6
  22. package/dist/src/commands/rebase/index.js +19 -1
  23. package/dist/src/commands/status.js +44 -5
  24. package/dist/src/commands/test.js +27 -16
  25. package/dist/src/commands/verify.js +81 -6
  26. package/dist/src/commands/watch.js +43 -7
  27. package/dist/src/core/furnace-constants.d.ts +14 -0
  28. package/dist/src/core/furnace-constants.js +16 -0
  29. package/dist/src/core/furnace-validate.js +67 -1
  30. package/dist/src/core/git-base.d.ts +27 -2
  31. package/dist/src/core/git-base.js +41 -3
  32. package/dist/src/core/git-diff.js +34 -2
  33. package/dist/src/core/git.js +53 -14
  34. package/dist/src/core/mach.d.ts +14 -2
  35. package/dist/src/core/mach.js +12 -2
  36. package/dist/src/core/marionette-preflight.d.ts +16 -0
  37. package/dist/src/core/marionette-preflight.js +19 -0
  38. package/dist/src/core/patch-export.d.ts +77 -2
  39. package/dist/src/core/patch-export.js +82 -3
  40. package/dist/src/core/patch-lint-diff-tag.d.ts +20 -0
  41. package/dist/src/core/patch-lint-diff-tag.js +25 -0
  42. package/dist/src/core/patch-lint.js +82 -32
  43. package/dist/src/core/patch-registration-refs.d.ts +42 -0
  44. package/dist/src/core/patch-registration-refs.js +117 -0
  45. package/dist/src/core/xpcshell-appdir.d.ts +19 -5
  46. package/dist/src/core/xpcshell-appdir.js +46 -20
  47. package/dist/src/errors/git.d.ts +20 -0
  48. package/dist/src/errors/git.js +39 -0
  49. package/dist/src/types/commands/index.d.ts +1 -1
  50. package/dist/src/types/commands/options.d.ts +67 -0
  51. package/dist/src/types/commands/patches.d.ts +6 -5
  52. package/package.json +1 -1
@@ -6,7 +6,7 @@ import { getProjectPaths, loadConfig } from '../../core/config.js';
6
6
  import { removeCustomFtlJarMnEntry } from '../../core/furnace-apply-ftl.js';
7
7
  import { extractComponentChecksums, getOverrideEngineTargetPath, isOverrideCopyCandidate, restoreOverrideFileToBaseline, } from '../../core/furnace-apply-helpers.js';
8
8
  import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState, updateFurnaceState, writeFurnaceConfig, } from '../../core/furnace-config.js';
9
- import { resolveFtlDir } from '../../core/furnace-constants.js';
9
+ import { resolveFtlDir, xpcshellTestParentDir } from '../../core/furnace-constants.js';
10
10
  import { recordFurnaceRollbackFailure, runFurnaceMutation } from '../../core/furnace-operation.js';
11
11
  import { removeCustomElementRegistration, removeJarMnEntries, } from '../../core/furnace-registration.js';
12
12
  import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotDir, snapshotFile, } from '../../core/furnace-rollback.js';
@@ -212,6 +212,64 @@ async function cleanupCustomTestFiles(name, projectRoot, journal) {
212
212
  }
213
213
  return { partialFailures };
214
214
  }
215
+ /**
216
+ * Removes generated xpcshell test scaffolds associated with a custom
217
+ * component. 2026-04-24 eval Finding 5: `furnace remove` handled
218
+ * browser mochitests via `cleanupCustomTestFiles` but never touched the
219
+ * xpcshell scaffold tree, so an operator who ran
220
+ * `furnace create --with-tests --xpcshell` followed by `furnace remove`
221
+ * was left with orphan `xpcshell.toml` + `test_<name>_packaged.js`
222
+ * files still referencing the removed component. This cleanup pass
223
+ * mirrors the mochitest one — snapshot before removal, warn-and-
224
+ * continue semantics, explicit summary when partial failures occur.
225
+ */
226
+ async function cleanupCustomXpcshellTestFiles(name, projectRoot, journal) {
227
+ const partialFailures = [];
228
+ let forgeConfig;
229
+ try {
230
+ forgeConfig = await loadConfig(projectRoot);
231
+ }
232
+ catch (error) {
233
+ const msg = `Could not load config for xpcshell test cleanup — ${toError(error).message}. Remove xpcshell test files manually if needed.`;
234
+ warn(msg);
235
+ partialFailures.push(msg);
236
+ return { partialFailures };
237
+ }
238
+ const paths = getProjectPaths(projectRoot);
239
+ const xpcshellRoot = join(paths.engine, xpcshellTestParentDir(forgeConfig.binaryName));
240
+ const componentXpcshellDir = join(xpcshellRoot, name);
241
+ if (!(await pathExists(componentXpcshellDir)))
242
+ return { partialFailures };
243
+ try {
244
+ await snapshotDir(journal, componentXpcshellDir);
245
+ await removeDir(componentXpcshellDir);
246
+ info(`Deleted xpcshell test scaffold directory: ${componentXpcshellDir.replace(paths.engine + '/', 'engine/')}`);
247
+ }
248
+ catch (error) {
249
+ const msg = `Could not delete xpcshell test scaffold — ${toError(error).message}. Remove it manually if needed.`;
250
+ warn(msg);
251
+ partialFailures.push(msg);
252
+ }
253
+ // If the xpcshell parent directory is now empty (no other components
254
+ // had scaffolds), drop it too so `furnace validate` stays quiet about
255
+ // the empty per-binary tree. Warn-and-continue on any failure.
256
+ try {
257
+ if (await pathExists(xpcshellRoot)) {
258
+ const remaining = await readdir(xpcshellRoot);
259
+ if (remaining.length === 0) {
260
+ await snapshotDir(journal, xpcshellRoot);
261
+ await removeDir(xpcshellRoot);
262
+ info(`Deleted empty xpcshell parent directory: ${xpcshellRoot.replace(paths.engine + '/', 'engine/')}`);
263
+ }
264
+ }
265
+ }
266
+ catch (error) {
267
+ const msg = `Could not clean up xpcshell parent directory — ${toError(error).message}. Remove it manually if needed.`;
268
+ warn(msg);
269
+ partialFailures.push(msg);
270
+ }
271
+ return { partialFailures };
272
+ }
215
273
  function dropChecksumsByPrefix(state, prefix) {
216
274
  const result = { ...state };
217
275
  if (state.appliedChecksums) {
@@ -367,6 +425,14 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
367
425
  if (type === 'custom') {
368
426
  const result = await cleanupCustomTestFiles(name, projectRoot, journal);
369
427
  testCleanupFailures = result.partialFailures;
428
+ // 2026-04-24 eval Finding 5: also clean up xpcshell scaffolds
429
+ // generated by `furnace create --with-tests --xpcshell`. The
430
+ // mochitest cleanup above covers `browser/base/content/test/
431
+ // <binary>/`, but xpcshell scaffolds live in the sibling
432
+ // `<binary>-xpcshell/` directory and were orphaned by prior
433
+ // versions.
434
+ const xpcshellResult = await cleanupCustomXpcshellTestFiles(name, projectRoot, journal);
435
+ testCleanupFailures.push(...xpcshellResult.partialFailures);
370
436
  }
371
437
  // Remove entry from furnace.json
372
438
  if (type === 'stock') {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * xpcshell scaffold rename helper extracted from `rename.ts`.
3
+ *
4
+ * 2026-04-24 eval Finding 5: `furnace create --with-tests --xpcshell`
5
+ * writes a scaffold at `browser/base/content/test/<binary>-xpcshell/
6
+ * <name>/` and `furnace rename` did not update it. The helper below
7
+ * renames the directory, updates the test filename, rewrites the
8
+ * `xpcshell.toml` section header, and re-writes the test body so word-
9
+ * boundary occurrences of the old tag / underscored name map to the new
10
+ * ones.
11
+ *
12
+ * Extracted to keep `rename.ts` under the per-file LOC budget —
13
+ * `rename.ts` already carries mochikit + browser-mochitest + FTL
14
+ * handling, and tacking xpcshell onto that tree pushed the file past
15
+ * the limit.
16
+ */
17
+ import { type RollbackJournal } from '../../core/furnace-rollback.js';
18
+ /**
19
+ * Renames an xpcshell test scaffold in place. Moves the directory,
20
+ * rewrites the test filename, updates the `[test_name]` section header
21
+ * in `xpcshell.toml`, and word-boundary-rewrites occurrences of the
22
+ * old tag / old underscored name inside the test body.
23
+ *
24
+ * Best-effort: any failure logs a warning through the shared logger
25
+ * but never throws — the component rename itself has already succeeded
26
+ * at this point, and blocking on a test rewrite would leave the
27
+ * operator with a half-renamed component.
28
+ *
29
+ * @param engineDir - Absolute path to the engine directory under the project.
30
+ * @param projectRoot - Absolute path to the project root, used to load the binary name.
31
+ * @param oldName - Pre-rename component tag name.
32
+ * @param newName - Post-rename component tag name.
33
+ * @param journal - Rollback journal that the rename mutation writes to before touching files.
34
+ */
35
+ export declare function renameXpcshellTestFiles(engineDir: string, projectRoot: string, oldName: string, newName: string, journal: RollbackJournal): Promise<void>;
@@ -0,0 +1,97 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * xpcshell scaffold rename helper extracted from `rename.ts`.
4
+ *
5
+ * 2026-04-24 eval Finding 5: `furnace create --with-tests --xpcshell`
6
+ * writes a scaffold at `browser/base/content/test/<binary>-xpcshell/
7
+ * <name>/` and `furnace rename` did not update it. The helper below
8
+ * renames the directory, updates the test filename, rewrites the
9
+ * `xpcshell.toml` section header, and re-writes the test body so word-
10
+ * boundary occurrences of the old tag / underscored name map to the new
11
+ * ones.
12
+ *
13
+ * Extracted to keep `rename.ts` under the per-file LOC budget —
14
+ * `rename.ts` already carries mochikit + browser-mochitest + FTL
15
+ * handling, and tacking xpcshell onto that tree pushed the file past
16
+ * the limit.
17
+ */
18
+ import { readdir } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+ import { loadConfig } from '../../core/config.js';
21
+ import { xpcshellTestParentDir } from '../../core/furnace-constants.js';
22
+ import { snapshotFile } from '../../core/furnace-rollback.js';
23
+ import { toError } from '../../utils/errors.js';
24
+ import { ensureDir, pathExists, readText, removeDir, removeFile, writeText, } from '../../utils/fs.js';
25
+ import { info, warn } from '../../utils/logger.js';
26
+ /** Escapes regex metacharacters so a user-supplied name stays literal. */
27
+ function escapeRegex(input) {
28
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
29
+ }
30
+ /**
31
+ * Renames an xpcshell test scaffold in place. Moves the directory,
32
+ * rewrites the test filename, updates the `[test_name]` section header
33
+ * in `xpcshell.toml`, and word-boundary-rewrites occurrences of the
34
+ * old tag / old underscored name inside the test body.
35
+ *
36
+ * Best-effort: any failure logs a warning through the shared logger
37
+ * but never throws — the component rename itself has already succeeded
38
+ * at this point, and blocking on a test rewrite would leave the
39
+ * operator with a half-renamed component.
40
+ *
41
+ * @param engineDir - Absolute path to the engine directory under the project.
42
+ * @param projectRoot - Absolute path to the project root, used to load the binary name.
43
+ * @param oldName - Pre-rename component tag name.
44
+ * @param newName - Post-rename component tag name.
45
+ * @param journal - Rollback journal that the rename mutation writes to before touching files.
46
+ */
47
+ export async function renameXpcshellTestFiles(engineDir, projectRoot, oldName, newName, journal) {
48
+ let forgeConfig;
49
+ try {
50
+ forgeConfig = await loadConfig(projectRoot);
51
+ }
52
+ catch {
53
+ return; // Cannot determine scaffold path without config.
54
+ }
55
+ const parentDir = join(engineDir, xpcshellTestParentDir(forgeConfig.binaryName));
56
+ if (!(await pathExists(parentDir)))
57
+ return;
58
+ const oldScaffoldDir = join(parentDir, oldName);
59
+ const newScaffoldDir = join(parentDir, newName);
60
+ if (!(await pathExists(oldScaffoldDir)))
61
+ return;
62
+ const oldUnderscored = oldName.replace(/-/g, '_');
63
+ const newUnderscored = newName.replace(/-/g, '_');
64
+ const oldTestFileName = `test_${oldUnderscored}_packaged.js`;
65
+ const newTestFileName = `test_${newUnderscored}_packaged.js`;
66
+ try {
67
+ await ensureDir(newScaffoldDir);
68
+ const entries = await readdir(oldScaffoldDir, { withFileTypes: true });
69
+ for (const entry of entries) {
70
+ if (!entry.isFile())
71
+ continue;
72
+ const oldFilePath = join(oldScaffoldDir, entry.name);
73
+ const renamedFileName = entry.name === oldTestFileName ? newTestFileName : entry.name;
74
+ const newFilePath = join(newScaffoldDir, renamedFileName);
75
+ await snapshotFile(journal, oldFilePath);
76
+ const body = await readText(oldFilePath);
77
+ let updated = body;
78
+ if (entry.name === 'xpcshell.toml') {
79
+ updated = updated.replace(new RegExp(`\\[${escapeRegex(`"${oldTestFileName}"`)}\\]`, 'g'), `["${newTestFileName}"]`);
80
+ }
81
+ else if (entry.name === oldTestFileName) {
82
+ const oldTagPattern = new RegExp(`(?<![\\w-])${escapeRegex(oldName)}(?![\\w-])`, 'g');
83
+ updated = updated.replace(oldTagPattern, newName);
84
+ const oldUnderscoredPattern = new RegExp(`(?<![\\w])${escapeRegex(oldUnderscored)}(?![\\w])`, 'g');
85
+ updated = updated.replace(oldUnderscoredPattern, newUnderscored);
86
+ }
87
+ await writeText(newFilePath, updated);
88
+ await removeFile(oldFilePath);
89
+ }
90
+ await removeDir(oldScaffoldDir);
91
+ info(`Renamed xpcshell scaffold directory: ${xpcshellTestParentDir(forgeConfig.binaryName)}/${oldName} → ${xpcshellTestParentDir(forgeConfig.binaryName)}/${newName}`);
92
+ }
93
+ catch (error) {
94
+ warn(`Could not rename xpcshell scaffold — ${toError(error).message}. Rename the scaffold files manually if needed.`);
95
+ }
96
+ }
97
+ //# sourceMappingURL=rename-xpcshell.js.map
@@ -14,6 +14,7 @@ import { FurnaceError } from '../../errors/furnace.js';
14
14
  import { toError } from '../../utils/errors.js';
15
15
  import { copyFile, ensureDir, pathExists, readText, removeDir, removeFile, writeText, } from '../../utils/fs.js';
16
16
  import { info, intro, note, outro, warn } from '../../utils/logger.js';
17
+ import { renameXpcshellTestFiles } from './rename-xpcshell.js';
17
18
  /** Escapes regex metacharacters so a user-supplied name is literal inside a RegExp. */
18
19
  function escapeRegex(input) {
19
20
  return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -299,6 +300,14 @@ async function performRenameMutations(args) {
299
300
  // either failed the test run outright or (worse) passed for the
300
301
  // wrong component.
301
302
  await renameMochikitTestFiles(args.engineDir, oldName, newName, journal);
303
+ // 2026-04-24 eval Finding 5: xpcshell scaffolds live in yet
304
+ // another tree (`browser/base/content/test/<binary>-xpcshell/
305
+ // <name>/`). Before this call, renaming a component scaffolded
306
+ // with `--with-tests --xpcshell` left a directory whose name
307
+ // still referenced the pre-rename component, plus a test file
308
+ // whose underscored name referenced the old tag — both of
309
+ // which then failed to match the new component.
310
+ await renameXpcshellTestFiles(args.engineDir, projectRoot, oldName, newName, journal);
302
311
  // Clear the stale deployed component directory so the next
303
312
  // `furnace apply` is the single writer of the new name's
304
313
  // deployment. Without this, eval runs showed the old widget
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * `fireforge patch <verb>` parent command. Groups single-patch
3
- * mutations (`delete`, `reorder`) so they do not clutter the top-level
4
- * command list. Queue-level verbs like `lint`, `export`, `verify`, and
5
- * `status` stay flat.
3
+ * mutations (`compact`, `delete`, `lint-ignore`, `reorder`, `tier`) so
4
+ * they do not clutter the top-level command list. Queue-level verbs
5
+ * like `lint`, `export`, `verify`, and `status` stay flat.
6
6
  */
7
7
  import { Command } from 'commander';
8
8
  import type { CommandContext } from '../../types/cli.js';
9
9
  export { patchCompactCommand } from './compact.js';
10
10
  export { patchDeleteCommand } from './delete.js';
11
+ export { patchLintIgnoreCommand } from './lint-ignore.js';
11
12
  export { patchReorderCommand } from './reorder.js';
13
+ export { patchTierCommand } from './tier.js';
12
14
  /**
13
15
  * Registers the `patch` subcommand parent and its verbs on the CLI.
14
16
  *
@@ -1,16 +1,20 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
3
  * `fireforge patch <verb>` parent command. Groups single-patch
4
- * mutations (`delete`, `reorder`) so they do not clutter the top-level
5
- * command list. Queue-level verbs like `lint`, `export`, `verify`, and
6
- * `status` stay flat.
4
+ * mutations (`compact`, `delete`, `lint-ignore`, `reorder`, `tier`) so
5
+ * they do not clutter the top-level command list. Queue-level verbs
6
+ * like `lint`, `export`, `verify`, and `status` stay flat.
7
7
  */
8
8
  import { registerPatchCompact } from './compact.js';
9
9
  import { registerPatchDelete } from './delete.js';
10
+ import { registerPatchLintIgnore } from './lint-ignore.js';
10
11
  import { registerPatchReorder } from './reorder.js';
12
+ import { registerPatchTier } from './tier.js';
11
13
  export { patchCompactCommand } from './compact.js';
12
14
  export { patchDeleteCommand } from './delete.js';
15
+ export { patchLintIgnoreCommand } from './lint-ignore.js';
13
16
  export { patchReorderCommand } from './reorder.js';
17
+ export { patchTierCommand } from './tier.js';
14
18
  /**
15
19
  * Registers the `patch` subcommand parent and its verbs on the CLI.
16
20
  *
@@ -20,7 +24,7 @@ export { patchReorderCommand } from './reorder.js';
20
24
  export function registerPatch(program, context) {
21
25
  const patch = program
22
26
  .command('patch')
23
- .description('Manage individual patches in the queue (compact, delete, reorder)')
27
+ .description('Manage individual patches in the queue (compact, delete, lint-ignore, reorder, tier)')
24
28
  // Match `fireforge furnace`'s no-args contract: print the group's help and
25
29
  // exit 0. Without this default action, commander routes `fireforge patch`
26
30
  // (no subcommand) through its own help-then-exit-1 path, so scripts that
@@ -32,6 +36,8 @@ export function registerPatch(program, context) {
32
36
  });
33
37
  registerPatchCompact(patch, context);
34
38
  registerPatchDelete(patch, context);
39
+ registerPatchLintIgnore(patch, context);
35
40
  registerPatchReorder(patch, context);
41
+ registerPatchTier(patch, context);
36
42
  }
37
43
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `fireforge patch lint-ignore <name>` — adds, removes, or clears entries
3
+ * in `PatchMetadata.lintIgnore` without rewriting the `.patch` file body.
4
+ *
5
+ * Companion to `fireforge re-export <name> --lint-ignore <id>` (which is
6
+ * append-only). Existence is justified by the cases re-export cannot
7
+ * express:
8
+ * - Removing a single entry without dropping the rest of the list.
9
+ * - Clearing the entire list when the operator wants the rule(s) to
10
+ * start firing again.
11
+ * - Editing metadata when the patch body is already correct, so the
12
+ * re-export's engine read + diff regeneration roundtrip is wasted.
13
+ *
14
+ * Modes are mutually exclusive: exactly one of `--add`, `--remove`, or
15
+ * `--clear` must be supplied per invocation. The read-modify-write
16
+ * happens inside the patch directory lock via {@link mutatePatchMetadata}
17
+ * so a concurrent writer cannot interleave between the read and the
18
+ * write — important when an operator scripts repeated invocations or
19
+ * runs `--add` and `--remove` back-to-back.
20
+ */
21
+ import { Command } from 'commander';
22
+ import type { CommandContext } from '../../types/cli.js';
23
+ import type { PatchLintIgnoreOptions } from '../../types/commands/index.js';
24
+ /**
25
+ * Runs the `patch lint-ignore` command: reads the patch's existing
26
+ * `lintIgnore`, applies the requested mode, and writes the manifest.
27
+ *
28
+ * @param projectRoot - Project root directory
29
+ * @param identifier - Patch filename, ordinal, or manifest `name`
30
+ * @param options - Command options (exactly one of `add`/`remove`/`clear`)
31
+ */
32
+ export declare function patchLintIgnoreCommand(projectRoot: string, identifier: string, options?: PatchLintIgnoreOptions): Promise<void>;
33
+ /**
34
+ * Registers the `patch lint-ignore` subcommand on the `patch` parent.
35
+ *
36
+ * @param parent - Parent Commander command
37
+ * @param context - Shared CLI registration context
38
+ */
39
+ export declare function registerPatchLintIgnore(parent: Command, context: CommandContext): void;
@@ -0,0 +1,200 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge patch lint-ignore <name>` — adds, removes, or clears entries
4
+ * in `PatchMetadata.lintIgnore` without rewriting the `.patch` file body.
5
+ *
6
+ * Companion to `fireforge re-export <name> --lint-ignore <id>` (which is
7
+ * append-only). Existence is justified by the cases re-export cannot
8
+ * express:
9
+ * - Removing a single entry without dropping the rest of the list.
10
+ * - Clearing the entire list when the operator wants the rule(s) to
11
+ * start firing again.
12
+ * - Editing metadata when the patch body is already correct, so the
13
+ * re-export's engine read + diff regeneration roundtrip is wasted.
14
+ *
15
+ * Modes are mutually exclusive: exactly one of `--add`, `--remove`, or
16
+ * `--clear` must be supplied per invocation. The read-modify-write
17
+ * happens inside the patch directory lock via {@link mutatePatchMetadata}
18
+ * so a concurrent writer cannot interleave between the read and the
19
+ * write — important when an operator scripts repeated invocations or
20
+ * runs `--add` and `--remove` back-to-back.
21
+ */
22
+ import { getProjectPaths } from '../../core/config.js';
23
+ import { appendHistory } from '../../core/destructive.js';
24
+ import { mutatePatchMetadata } from '../../core/patch-export.js';
25
+ import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
26
+ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
27
+ import { toError } from '../../utils/errors.js';
28
+ import { pathExists } from '../../utils/fs.js';
29
+ import { info, intro, outro, warn } from '../../utils/logger.js';
30
+ /**
31
+ * Computes the post-mutation `lintIgnore` list for a given mode.
32
+ * Returns `undefined` when the result should drop the field from the
33
+ * manifest entirely (matching the validator's "preserve only when
34
+ * present" contract).
35
+ */
36
+ function applyMode(existing, mode, values) {
37
+ const existingSet = new Set(existing);
38
+ if (mode === 'add') {
39
+ for (const v of values)
40
+ existingSet.add(v);
41
+ const merged = [...existingSet];
42
+ return merged.length > 0 ? merged : undefined;
43
+ }
44
+ if (mode === 'remove') {
45
+ for (const v of values)
46
+ existingSet.delete(v);
47
+ const remaining = [...existingSet];
48
+ return remaining.length > 0 ? remaining : undefined;
49
+ }
50
+ // mode === 'clear'
51
+ return undefined;
52
+ }
53
+ /**
54
+ * Renders a one-line summary of the planned change for use in
55
+ * `info()` / dry-run / history args.
56
+ */
57
+ function describeChange(before, after, mode, values) {
58
+ const beforeSet = new Set(before);
59
+ const afterSet = new Set(after);
60
+ if (mode === 'clear') {
61
+ return before.length === 0
62
+ ? 'lintIgnore was already empty — no change'
63
+ : `lintIgnore cleared (was ${before.join(', ')})`;
64
+ }
65
+ if (mode === 'add') {
66
+ const added = values.filter((v) => !beforeSet.has(v));
67
+ if (added.length === 0) {
68
+ return 'lintIgnore unchanged (all requested IDs were already present)';
69
+ }
70
+ return `lintIgnore += ${added.join(', ')} → ${[...afterSet].join(', ') || '(empty)'}`;
71
+ }
72
+ // mode === 'remove'
73
+ const removed = values.filter((v) => beforeSet.has(v));
74
+ if (removed.length === 0) {
75
+ return 'lintIgnore unchanged (none of the requested IDs were present)';
76
+ }
77
+ return `lintIgnore −= ${removed.join(', ')} → ${[...afterSet].join(', ') || '(empty)'}`;
78
+ }
79
+ /**
80
+ * Runs the `patch lint-ignore` command: reads the patch's existing
81
+ * `lintIgnore`, applies the requested mode, and writes the manifest.
82
+ *
83
+ * @param projectRoot - Project root directory
84
+ * @param identifier - Patch filename, ordinal, or manifest `name`
85
+ * @param options - Command options (exactly one of `add`/`remove`/`clear`)
86
+ */
87
+ export async function patchLintIgnoreCommand(projectRoot, identifier, options = {}) {
88
+ const isDryRun = options.dryRun === true;
89
+ intro(isDryRun ? 'FireForge patch lint-ignore (dry run)' : 'FireForge patch lint-ignore');
90
+ // Mode mutex: exactly one mode per invocation. Combinations like
91
+ // `--add foo --remove bar` are rejected — an operator who needs both
92
+ // runs the command twice (clearer audit trail) and `--clear` plus a
93
+ // mode is contradictory.
94
+ const adding = (options.add?.length ?? 0) > 0;
95
+ const removing = (options.remove?.length ?? 0) > 0;
96
+ const clearing = options.clear === true;
97
+ const modeCount = [adding, removing, clearing].filter(Boolean).length;
98
+ if (modeCount > 1) {
99
+ throw new InvalidArgumentError('--add, --remove, and --clear are mutually exclusive. Pick one mode per invocation.', 'patch lint-ignore');
100
+ }
101
+ if (modeCount === 0) {
102
+ throw new InvalidArgumentError('Specify --add <id>, --remove <id>, or --clear.', 'patch lint-ignore');
103
+ }
104
+ const mode = adding ? 'add' : removing ? 'remove' : 'clear';
105
+ const values = mode === 'add' ? (options.add ?? []) : mode === 'remove' ? (options.remove ?? []) : [];
106
+ const paths = getProjectPaths(projectRoot);
107
+ if (!(await pathExists(paths.patches))) {
108
+ throw new GeneralError('Patches directory not found.');
109
+ }
110
+ const manifest = await loadPatchesManifest(paths.patches);
111
+ if (!manifest || manifest.patches.length === 0) {
112
+ throw new GeneralError('No patches in manifest.');
113
+ }
114
+ const target = resolvePatchIdentifier(identifier, manifest.patches);
115
+ if (!target) {
116
+ const available = manifest.patches
117
+ .map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
118
+ .join(', ');
119
+ 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);
120
+ }
121
+ if (isDryRun) {
122
+ const existing = target.lintIgnore ?? [];
123
+ const projected = applyMode(existing, mode, values) ?? [];
124
+ info(`[dry-run] ${target.filename}: ${describeChange(existing, projected, mode, values)}.`);
125
+ outro('Dry run complete — no changes made');
126
+ return;
127
+ }
128
+ const result = await mutatePatchMetadata(paths.patches, target.filename, (existing) => {
129
+ const next = applyMode(existing.lintIgnore ?? [], mode, values);
130
+ // Either set the new list when non-empty or unset the field
131
+ // entirely. The mutation API splits these to keep the
132
+ // exactOptionalPropertyTypes contract clean — only set values land
133
+ // in the typed `Partial<PatchMetadata>`, and the unset list is
134
+ // applied via `delete` after spread.
135
+ return next !== undefined ? { set: { lintIgnore: next } } : { unset: ['lintIgnore'] };
136
+ });
137
+ if (!result) {
138
+ // Race: target vanished between the manifest read above and the
139
+ // locked mutate. Surfacing as a hard error rather than a silent
140
+ // no-op — the operator's intent did not land.
141
+ throw new GeneralError(`Patch ${target.filename} disappeared from the manifest during the update. Re-run after investigating.`);
142
+ }
143
+ const existing = result.before.lintIgnore ?? [];
144
+ const projected = result.after.lintIgnore ?? [];
145
+ info(`${target.filename}: ${describeChange(existing, projected, mode, values)}.`);
146
+ try {
147
+ await appendHistory(paths.patches, {
148
+ operation: 'patch-lint-ignore',
149
+ args: {
150
+ filename: target.filename,
151
+ mode,
152
+ values: [...values],
153
+ before: existing,
154
+ after: projected,
155
+ },
156
+ ...(options.yes === true ? { yes: true } : {}),
157
+ result: 'ok',
158
+ });
159
+ }
160
+ catch (historyError) {
161
+ warn(`History log append failed after patch lint-ignore committed (${target.filename}): ${toError(historyError).message}`);
162
+ }
163
+ outro('Patch lint-ignore complete');
164
+ }
165
+ /**
166
+ * Registers the `patch lint-ignore` subcommand on the `patch` parent.
167
+ *
168
+ * @param parent - Parent Commander command
169
+ * @param context - Shared CLI registration context
170
+ */
171
+ export function registerPatchLintIgnore(parent, context) {
172
+ const { getProjectRoot, withErrorHandling } = context;
173
+ parent
174
+ .command('lint-ignore <name>')
175
+ .description('Edit PatchMetadata.lintIgnore on a single patch (no .patch body rewrite). One mode per invocation.')
176
+ .option('--add <check-id>', 'Lint check ID to add to the patch lintIgnore list (repeatable)', (value, prev) => [...prev, value], [])
177
+ .option('--remove <check-id>', 'Lint check ID to remove from the patch lintIgnore list (repeatable)', (value, prev) => [...prev, value], [])
178
+ .option('--clear', 'Drop the lintIgnore field entirely')
179
+ .option('--dry-run', 'Show what would change without writing')
180
+ .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
181
+ .action(withErrorHandling(async (name, options) => {
182
+ // Commander defaults `--add`/`--remove` to `[]` so they appear in
183
+ // the options object even when unused. Strip empty arrays so
184
+ // `pickDefined` sees them as absent — otherwise the mode-count
185
+ // mutex would treat zero-length arrays as a present mode.
186
+ const normalized = {};
187
+ if (options.add !== undefined && options.add.length > 0)
188
+ normalized.add = options.add;
189
+ if (options.remove !== undefined && options.remove.length > 0)
190
+ normalized.remove = options.remove;
191
+ if (options.clear === true)
192
+ normalized.clear = true;
193
+ if (options.dryRun === true)
194
+ normalized.dryRun = true;
195
+ if (options.yes === true)
196
+ normalized.yes = true;
197
+ await patchLintIgnoreCommand(getProjectRoot(), name, normalized);
198
+ }));
199
+ }
200
+ //# sourceMappingURL=lint-ignore.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `fireforge patch tier <name>` — sets or clears `PatchMetadata.tier` on
3
+ * a single patch without rewriting the `.patch` file body.
4
+ *
5
+ * Companion to `fireforge re-export <name> --tier <tier>`. Re-export is
6
+ * the right tool when the patch body itself needs to be regenerated; this
7
+ * subcommand exists for the metadata-only adjustment, where the operator
8
+ * has discovered (e.g. from a `lint --per-patch` warning) that the
9
+ * threshold-tier override should be set but the patch body is already
10
+ * correct. Avoiding the re-export saves the engine read + diff
11
+ * regeneration roundtrip and leaves the `.patch` file's mtime alone.
12
+ *
13
+ * Modes are mutually exclusive: exactly one of `--tier <branding>` or
14
+ * `--clear` must be supplied per invocation.
15
+ */
16
+ import { Command } from 'commander';
17
+ import type { CommandContext } from '../../types/cli.js';
18
+ import type { PatchTierOptions } from '../../types/commands/index.js';
19
+ /**
20
+ * Runs the `patch tier` command: updates `PatchMetadata.tier` on the
21
+ * named patch (or clears the field) and writes the manifest.
22
+ *
23
+ * @param projectRoot - Project root directory
24
+ * @param identifier - Patch filename, ordinal, or manifest `name`
25
+ * @param options - Command options
26
+ */
27
+ export declare function patchTierCommand(projectRoot: string, identifier: string, options?: PatchTierOptions): Promise<void>;
28
+ /**
29
+ * Registers the `patch tier` subcommand on the `patch` parent.
30
+ *
31
+ * @param parent - Parent Commander command
32
+ * @param context - Shared CLI registration context
33
+ */
34
+ export declare function registerPatchTier(parent: Command, context: CommandContext): void;