@hominis/fireforge 0.18.1 → 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.
package/README.md CHANGED
@@ -210,7 +210,11 @@ This re-exports the fixed patch and clears the conflict state. The command is de
210
210
 
211
211
  The optional `lintIgnore` field lists lint check IDs to suppress for that patch specifically. Useful for the class of patch that is advisory-noisy by nature — a cohesive branding bundle, a localised-resource pack, an auto-generated manifest — where `--skip-lint` is too blunt and a per-line marker cannot exist (the `.patch` body is regenerated on every export). Threaded through `export`, `re-export`, `re-export --files`, and `lint --per-patch`. Unknown check IDs are a no-op.
212
212
 
213
- The optional `tier` field (only `"branding"` recognised) forces the branding threshold tier for the `large-patch-lines` rule regardless of what `filesAffected` looks like. The automatic branding-tier detection already fires when every file is under `browser/branding/` plus a narrow allowlist of branding-registration siblings (`browser/moz.configure`, `browser/confvars.sh`) covering the canonical Firefox fork shape. Declare `tier: "branding"` only when the patch legitimately also touches a non-allowlisted sibling the auto-detector cannot reach (a fork-specific theme override under `browser/themes/<name>/`, a vendor-specific icon resource, etc.). Precedence is `test > branding > general`: a patch of all-tests always gets the more permissive test-tier thresholds even if it declares `tier: "branding"`. Unknown tier values are rejected at load time rather than silently stripped, so a typo surfaces as a loader error. Prefer `tier` over `lintIgnore: ["large-patch-lines"]` when the patch is legitimately branding-shaped `tier` keeps the rule running at the correct thresholds (so the warning still surfaces if the patch crosses them); `lintIgnore` drops the rule entirely.
213
+ Settable through the CLI in two places. `fireforge export --lint-ignore <check-id>` (repeatable) writes the field on creation; `fireforge re-export <name> --lint-ignore <check-id>` (repeatable, append/union semantics, de-duplicated) adds entries to an existing patch on the next refresh. For metadata-only edits that should **not** regenerate the `.patch` body including the inverse `--remove` and `--clear` modes that re-export's append-only flag cannot express use `fireforge patch lint-ignore <name> --add <id> | --remove <id> | --clear`.
214
+
215
+ The optional `tier` field (only `"branding"` recognised) forces the branding threshold tier for the `large-patch-files` and `large-patch-lines` rules regardless of what `filesAffected` looks like. The automatic branding-tier detection already fires when every file is under `browser/branding/` plus a narrow allowlist of branding-registration siblings (`browser/moz.configure`, `browser/confvars.sh`) — covering the canonical Firefox fork shape. Declare `tier: "branding"` only when the patch legitimately also touches a non-allowlisted sibling the auto-detector cannot reach (a fork-specific theme override under `browser/themes/<name>/`, a vendor-specific icon resource, etc.). Precedence is `test > branding > general`: a patch of all-tests always gets the more permissive test-tier thresholds even if it declares `tier: "branding"`. Unknown tier values are rejected at load time rather than silently stripped, so a typo surfaces as a loader error. Prefer `tier` over `lintIgnore: ["large-patch-lines"]` when the patch is legitimately branding-shaped — `tier` keeps the rule running at the correct thresholds (so the warning still surfaces if the patch crosses them); `lintIgnore` drops the rule entirely.
216
+
217
+ Settable via `fireforge export --tier branding` on creation, `fireforge re-export <name> --tier branding` on refresh, and `fireforge patch tier <name> --tier branding | --clear` for a metadata-only edit that does not rewrite the `.patch` body. The CLI rejects values other than `branding` up-front (matching the validator's strictness), and `re-export --tier` / `--lint-ignore` refuse `--all` because mass tier/ignore edits across a heterogeneous queue are virtually always footguns.
214
218
 
215
219
  If the manifest drifts after an interrupted export or manual edits, `fireforge import` will stop rather then silently applying a stale stack. Use `fireforge doctor --repair-patches-manifest` to rebuild it from disk. Because the rebuild is deterministic, the result will always be consistent with what is actually on the filesystem.
216
220
 
@@ -223,24 +227,24 @@ If the manifest drifts after an interrupted export or manual edits, `fireforge i
223
227
 
224
228
  By default, a standalone `fireforge lint` (no arguments) lints the **aggregate** `git diff HEAD` — i.e. every applied patch summed — with tool-managed branding paths (`browser/branding/<binaryName>/`) excluded. A fresh-setup workspace carries a large generated branding diff that operators did not author directly, and letting it through tripped the patch-size and license-header rules on content that matches the `branding` bucket in `fireforge status`. When the exclusion fires the command prints a one-line note naming the excluded count so the filter is visible. On a repo where `fireforge import` or `fireforge rebase` has just applied the full queue, the patch-size rules (`large-patch-lines`, `large-patch-files`) fire against the sum, which reads as "my queue is broken" when it is really an artefact of aggregation. Use `fireforge lint --per-patch` to rescope the diff to each patch's own `filesAffected`, honouring the patch's own `lintIgnore`. Cross-patch rules (`duplicate-new-file-creation`, `forward-import`) still run once over the whole queue either way. Pass explicit file paths to narrow the scope further — explicit-path mode does lint branding files (the operator's explicit request wins over the branding exclusion); the three modes (aggregate, file-scoped, per-patch) are mutually exclusive.
225
229
 
226
- | Check | Scope | Severity |
227
- | ------------------------------ | ----------------------------------------------------------------------------------------------- | ------------------------ |
228
- | `missing-license-header` | New files (JS/CSS/FTL) | error |
229
- | `relative-import` | JS/MJS files | error |
230
- | `token-prefix-violation` | CSS files (with furnace) | error |
231
- | `raw-color-value` | Introduced CSS color values (allowlist via `patchLint.rawColorAllowlist`) | error |
232
- | `duplicate-new-file-creation` | Same path created by multiple patches | error |
233
- | `forward-import` | Patch imports from a later-patch file | error |
234
- | `missing-jsdoc` | Exports in patch-owned `.sys.mjs` | error |
235
- | `jsdoc-param-mismatch` | Exports in patch-owned `.sys.mjs` | error |
236
- | `jsdoc-missing-returns` | Exports in patch-owned `.sys.mjs` | error |
237
- | `checkjs-type-error` | Patch-owned `.sys.mjs` (opt-in) | error |
238
- | `missing-modification-comment` | Modified upstream JS/MJS | warning |
239
- | `modified-file-missing-header` | Modified upstream files (JS/CSS/FTL) | warning |
240
- | `file-too-large` | New files (tiered: 500/750/900 general, 1200/1400/1600 test) | notice / warning / error |
241
- | `observer-topic-naming` | Observer topics with binaryName | warning |
242
- | `large-patch-files` | Patches affecting >5 files | warning |
243
- | `large-patch-lines` | Patch line count (tiered: 800/1500/3000 general, 1500/3000/6000 test, 3000/8000/20000 branding) | notice / warning / error |
230
+ | Check | Scope | Severity |
231
+ | ------------------------------ | ------------------------------------------------------------------------------------------------ | ------------------------ |
232
+ | `missing-license-header` | New files (JS/CSS/FTL) | error |
233
+ | `relative-import` | JS/MJS files | error |
234
+ | `token-prefix-violation` | CSS files (with furnace) | error |
235
+ | `raw-color-value` | Introduced CSS color values (allowlist via `patchLint.rawColorAllowlist`) | error |
236
+ | `duplicate-new-file-creation` | Same path created by multiple patches | error |
237
+ | `forward-import` | Patch imports from a later-patch file | error |
238
+ | `missing-jsdoc` | Exports in patch-owned `.sys.mjs` | error |
239
+ | `jsdoc-param-mismatch` | Exports in patch-owned `.sys.mjs` | error |
240
+ | `jsdoc-missing-returns` | Exports in patch-owned `.sys.mjs` | error |
241
+ | `checkjs-type-error` | Patch-owned `.sys.mjs` (opt-in) | error |
242
+ | `missing-modification-comment` | Modified upstream JS/MJS | warning |
243
+ | `modified-file-missing-header` | Modified upstream files (JS/CSS/FTL) | warning |
244
+ | `file-too-large` | New files (tiered: 500/750/900 general, 1200/1400/1600 test) | notice / warning / error |
245
+ | `observer-topic-naming` | Observer topics with binaryName | warning |
246
+ | `large-patch-files` | Patches affecting many files (tiered: >5 general, >5 test, >60 branding) | warning |
247
+ | `large-patch-lines` | Patch line count (tiered: 800/1500/3000 general, 1500/3000/6000 test, 8000/18000/30000 branding) | notice / warning / error |
244
248
 
245
249
  **JSDoc validation** uses AST-based analysis (Acorn) to validate exported APIs in patch-owned `.sys.mjs` files. A file is "patch-owned" if it was newly created by the current diff or by an existing patch in the queue. Functions must document every `@param` (names must match) and include `@returns` when the function returns a value. Exported constants and classes require a JSDoc block.
246
250
 
@@ -436,11 +440,21 @@ fireforge patch reorder 003-ui-sidebar.patch --before 001-branding-logo.patch
436
440
 
437
441
  # Close ordinal gaps after deletes or splits (e.g. 1, 3, 7 → 1, 2, 3)
438
442
  fireforge patch compact
443
+
444
+ # Set or clear the threshold-tier override on a single patch
445
+ # (no .patch body rewrite — manifest entry only)
446
+ fireforge patch tier 001-branding-assets.patch --tier branding
447
+ fireforge patch tier 001-branding-assets.patch --clear
448
+
449
+ # Edit the lintIgnore list on a single patch (one mode per invocation)
450
+ fireforge patch lint-ignore 001-branding-assets.patch --add large-patch-lines --add large-patch-files
451
+ fireforge patch lint-ignore 001-branding-assets.patch --remove large-patch-lines
452
+ fireforge patch lint-ignore 001-branding-assets.patch --clear
439
453
  ```
440
454
 
441
- `delete` and `reorder` accept three identifier forms for their target: the ordinal number (`fireforge patch reorder 3 --to 1`), the full filename (`003-ui-sidebar-tweaks.patch`), or the manifest `name` handle the patch was exported with (`ui-sidebar-tweaks`). Anchors passed to `--before` / `--after` accept the same three forms.
455
+ All `patch <verb>` subcommands accept three identifier forms for their target: the ordinal number (`fireforge patch reorder 3 --to 1`), the full filename (`003-ui-sidebar-tweaks.patch`), or the manifest `name` handle the patch was exported with (`ui-sidebar-tweaks`). Anchors passed to `--before` / `--after` accept the same three forms. All subcommands support `--dry-run` and `--yes`.
442
456
 
443
- All subcommands support `--dry-run` and `--yes`.
457
+ `patch tier` and `patch lint-ignore` are metadata-only edits: they update `patches/patches.json` under the patch directory lock and never rewrite the `.patch` body. Reach for them when an operator-visible advisory (e.g. `large-patch-files` firing on a 56-file fresh-fork branding bundle) needs the threshold-tier override or a per-patch lint suppression but the patch body is already correct — running `re-export` for that case wastes an engine read + diff regeneration. `patch lint-ignore` modes (`--add` / `--remove` / `--clear`) are mutually exclusive; `patch tier` rejects `--tier` and `--clear` together. The history log records each invocation under `operation: "patch-tier"` / `"patch-lint-ignore"` for audit.
444
458
 
445
459
  ### Additional workflow commands
446
460
 
@@ -82,6 +82,10 @@ export interface DryRunPreviewInput {
82
82
  filesAffected: string[];
83
83
  sourceEsrVersion: string;
84
84
  explicitSupersede: boolean;
85
+ /** Optional `PatchMetadata.tier` opt-in carried from the CLI. */
86
+ tier?: 'branding';
87
+ /** Optional `PatchMetadata.lintIgnore` carried from the CLI. */
88
+ lintIgnore?: string[];
85
89
  }
86
90
  /**
87
91
  * Renders the plain (non-placement) dry-run preview: calls planExport,
@@ -314,12 +314,20 @@ export async function renderDryRunPreview(input) {
314
314
  description: input.description,
315
315
  filesAffected: input.filesAffected,
316
316
  sourceEsrVersion: input.sourceEsrVersion,
317
+ ...(input.tier !== undefined ? { tier: input.tier } : {}),
318
+ ...(input.lintIgnore !== undefined ? { lintIgnore: input.lintIgnore } : {}),
317
319
  });
318
320
  info(`\n[dry-run] Would write: patches/${plan.patchFilename}`);
319
321
  info(` category: ${plan.metadata.category}`);
320
322
  info(` order: ${plan.metadata.order}`);
321
323
  info(` description: ${plan.metadata.description || '(none)'}`);
322
324
  info(` filesAffected (${plan.metadata.filesAffected.length}): ${plan.metadata.filesAffected.join(', ')}`);
325
+ if (plan.metadata.tier !== undefined) {
326
+ info(` tier: ${plan.metadata.tier}`);
327
+ }
328
+ if (plan.metadata.lintIgnore !== undefined && plan.metadata.lintIgnore.length > 0) {
329
+ info(` lintIgnore: ${plan.metadata.lintIgnore.join(', ')}`);
330
+ }
323
331
  if (supersedeDetails.length > 0) {
324
332
  info(`\n[dry-run] Would supersede ${supersedeDetails.length} existing patch(es):`);
325
333
  for (const detail of supersedeDetails) {
@@ -172,7 +172,15 @@ export async function exportCommand(projectRoot, files, options) {
172
172
  try {
173
173
  // Extract affected files from diff
174
174
  const filesAffected = extractAffectedFiles(diff);
175
- await runPatchLint(paths.engine, filesAffected, diff, config, options.skipLint);
175
+ // Apply the just-set --tier and --lint-ignore on the lint pass so the
176
+ // operator's intent takes effect on this invocation, not only on the
177
+ // next one. Without this, a fresh export with `--tier branding` would
178
+ // still hit general thresholds because the lint runs before the
179
+ // metadata is committed.
180
+ const exportIgnoreChecks = options.lintIgnore && options.lintIgnore.length > 0
181
+ ? new Set(options.lintIgnore)
182
+ : undefined;
183
+ await runPatchLint(paths.engine, filesAffected, diff, config, options.skipLint, undefined, exportIgnoreChecks, options.tier);
176
184
  // Resolve placement (if any flag was given). Placement is mutually
177
185
  // exclusive with supersede — the semantics overlap confusingly.
178
186
  let placementPlan = null;
@@ -225,6 +233,10 @@ export async function exportCommand(projectRoot, files, options) {
225
233
  filesAffected,
226
234
  sourceEsrVersion: config.firefox.version,
227
235
  explicitSupersede: options.supersede === true,
236
+ ...(options.tier !== undefined ? { tier: options.tier } : {}),
237
+ ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
238
+ ? { lintIgnore: options.lintIgnore }
239
+ : {}),
228
240
  });
229
241
  outro('Dry run complete — no changes made');
230
242
  return;
@@ -243,6 +255,10 @@ export async function exportCommand(projectRoot, files, options) {
243
255
  createdAt: new Date().toISOString(),
244
256
  sourceEsrVersion: config.firefox.version,
245
257
  filesAffected,
258
+ ...(options.tier !== undefined ? { tier: options.tier } : {}),
259
+ ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
260
+ ? { lintIgnore: options.lintIgnore }
261
+ : {}),
246
262
  };
247
263
  const committedPlan = await commitPlacementExport({
248
264
  patchesDir: paths.patches,
@@ -314,6 +330,10 @@ export async function exportCommand(projectRoot, files, options) {
314
330
  diff,
315
331
  filesAffected,
316
332
  sourceEsrVersion: config.firefox.version,
333
+ ...(options.tier !== undefined ? { tier: options.tier } : {}),
334
+ ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
335
+ ? { lintIgnore: options.lintIgnore }
336
+ : {}),
317
337
  });
318
338
  for (const oldPatch of superseded) {
319
339
  info(`Superseded: ${oldPatch.filename}`);
@@ -348,11 +368,15 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
348
368
  .option('--force-unsafe', 'Bypass cross-patch lint refusal on projected placement')
349
369
  .option('--exclude-furnace', 'Exclude furnace-managed file paths from the export')
350
370
  .option('--allow-overlap', 'Acknowledge cross-patch ownership overlap (default mode only; the resulting queue fails verify)')
371
+ .addOption(new Option('--tier <tier>', 'Force a tier override on the new patch (only "branding" recognised)').choices(['branding']))
372
+ .option('--lint-ignore <check-id>', 'Suppress a lint check on this patch (writes to PatchMetadata.lintIgnore; repeatable)', (value, prev) => [...prev, value], [])
351
373
  .action(withErrorHandling(async (paths, options) => {
352
- const { category, ...rest } = options;
374
+ const { category, tier, lintIgnore, ...rest } = options;
353
375
  await exportCommand(getProjectRoot(), paths, {
354
376
  ...pickDefined(rest),
355
377
  ...(category !== undefined ? { category: category } : {}),
378
+ ...(tier !== undefined ? { tier: tier } : {}),
379
+ ...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
356
380
  });
357
381
  }));
358
382
  }
@@ -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;