@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 +35 -21
- package/dist/src/commands/export-flow.d.ts +4 -0
- package/dist/src/commands/export-flow.js +8 -0
- package/dist/src/commands/export.js +26 -2
- package/dist/src/commands/patch/index.d.ts +5 -3
- package/dist/src/commands/patch/index.js +10 -4
- package/dist/src/commands/patch/lint-ignore.d.ts +39 -0
- package/dist/src/commands/patch/lint-ignore.js +200 -0
- package/dist/src/commands/patch/tier.d.ts +34 -0
- package/dist/src/commands/patch/tier.js +134 -0
- package/dist/src/commands/re-export-files.js +88 -45
- package/dist/src/commands/re-export.js +49 -6
- package/dist/src/core/git-diff.js +34 -2
- package/dist/src/core/patch-export.d.ts +77 -2
- package/dist/src/core/patch-export.js +82 -3
- package/dist/src/core/patch-lint.js +78 -29
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +67 -0
- package/dist/src/types/commands/patches.d.ts +6 -5
- package/package.json +1 -1
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
|
-
|
|
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
|
|
227
|
-
| ------------------------------ |
|
|
228
|
-
| `missing-license-header` | New files (JS/CSS/FTL)
|
|
229
|
-
| `relative-import` | JS/MJS files
|
|
230
|
-
| `token-prefix-violation` | CSS files (with furnace)
|
|
231
|
-
| `raw-color-value` | Introduced CSS color values (allowlist via `patchLint.rawColorAllowlist`)
|
|
232
|
-
| `duplicate-new-file-creation` | Same path created by multiple patches
|
|
233
|
-
| `forward-import` | Patch imports from a later-patch file
|
|
234
|
-
| `missing-jsdoc` | Exports in patch-owned `.sys.mjs`
|
|
235
|
-
| `jsdoc-param-mismatch` | Exports in patch-owned `.sys.mjs`
|
|
236
|
-
| `jsdoc-missing-returns` | Exports in patch-owned `.sys.mjs`
|
|
237
|
-
| `checkjs-type-error` | Patch-owned `.sys.mjs` (opt-in)
|
|
238
|
-
| `missing-modification-comment` | Modified upstream JS/MJS
|
|
239
|
-
| `modified-file-missing-header` | Modified upstream files (JS/CSS/FTL)
|
|
240
|
-
| `file-too-large` | New files (tiered: 500/750/900 general, 1200/1400/1600 test)
|
|
241
|
-
| `observer-topic-naming` | Observer topics with binaryName
|
|
242
|
-
| `large-patch-files` | Patches affecting >5
|
|
243
|
-
| `large-patch-lines` | Patch line count (tiered: 800/1500/3000 general, 1500/3000/6000 test,
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
* command list. Queue-level verbs
|
|
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
|
|
5
|
-
* command list. Queue-level verbs
|
|
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;
|