@hominis/fireforge 0.18.1 → 0.18.3
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 +54 -33
- package/dist/src/commands/discard.js +93 -1
- package/dist/src/commands/doctor.js +17 -4
- package/dist/src/commands/download.js +21 -0
- package/dist/src/commands/export-all.js +35 -6
- 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/furnace/remove.js +68 -0
- package/dist/src/commands/import.js +9 -1
- package/dist/src/commands/lint.js +56 -10
- 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/commands/status.js +27 -0
- package/dist/src/commands/test.js +20 -1
- package/dist/src/commands/token.js +1 -1
- package/dist/src/core/furnace-config.js +19 -0
- package/dist/src/core/git-diff.js +34 -2
- package/dist/src/core/license-headers.d.ts +8 -0
- package/dist/src/core/license-headers.js +15 -1
- package/dist/src/core/manifest-rules.js +9 -1
- 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 +86 -29
- package/dist/src/core/register-shared-css.js +8 -2
- 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
|
@@ -74,7 +74,15 @@ async function checkUncommittedPatchFiles(engineDir, patchesDir, forceImport) {
|
|
|
74
74
|
if (dirtyFiles.length > 0) {
|
|
75
75
|
const unmanagedDirtyFiles = await getUnmanagedDirtyFiles(engineDir, patchesDir, dirtyFiles);
|
|
76
76
|
if (unmanagedDirtyFiles.length === 0) {
|
|
77
|
-
|
|
77
|
+
// Common path here: operator just ran `fireforge resolve` to
|
|
78
|
+
// regenerate a patch from manual conflict edits, so the engine
|
|
79
|
+
// already carries the patch's effects. The import below will
|
|
80
|
+
// still re-apply each patch (a no-op for files whose contents
|
|
81
|
+
// already match), so phrase the line as "no resync needed"
|
|
82
|
+
// rather than "patches already applied" — the latter contradicts
|
|
83
|
+
// the "Applied N patch(es)" summary `applyPatchesWithContinue`
|
|
84
|
+
// prints next, which the 2026-04-25 eval flagged as ambiguous.
|
|
85
|
+
info('Patch-touched files already match the stored patch stack — no engine resync needed before re-applying.');
|
|
78
86
|
}
|
|
79
87
|
else if (!forceImport) {
|
|
80
88
|
warn('Uncommitted changes detected in files that patches will modify:');
|
|
@@ -3,6 +3,7 @@ import { stat } from 'node:fs/promises';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { isBrandingManagedPath } from '../core/branding.js';
|
|
5
5
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
6
|
+
import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
6
7
|
import { getStatusWithCodes, hasChanges, isGitRepository } from '../core/git.js';
|
|
7
8
|
import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
8
9
|
import { expandUntrackedDirectoryEntries, getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, getWorkingTreeStatus, } from '../core/git-status.js';
|
|
@@ -35,7 +36,7 @@ import { stripEnginePrefix } from '../utils/paths.js';
|
|
|
35
36
|
* previous behaviour: passing a branding file explicitly still lints
|
|
36
37
|
* it, so operators who need to audit branding content can do so.
|
|
37
38
|
*/
|
|
38
|
-
async function resolveLintDiff(engineDir, files, binaryName) {
|
|
39
|
+
async function resolveLintDiff(engineDir, files, binaryName, furnacePrefixes) {
|
|
39
40
|
if (files.length > 0) {
|
|
40
41
|
const collectedFiles = new Set();
|
|
41
42
|
let fileStatuses;
|
|
@@ -115,16 +116,31 @@ async function resolveLintDiff(engineDir, files, binaryName) {
|
|
|
115
116
|
const expanded = await expandUntrackedDirectoryEntries(engineDir, rawStatus);
|
|
116
117
|
const allPaths = [...new Set(expanded.map((entry) => entry.file))];
|
|
117
118
|
const nonBrandingPaths = allPaths.filter((path) => !isBrandingManagedPath(path, binaryName));
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
const brandingExcluded = allPaths.length - nonBrandingPaths.length;
|
|
120
|
+
// Drop Furnace-managed paths the same way branding is dropped: their
|
|
121
|
+
// contents are tool output (overrides, custom widgets, preview-
|
|
122
|
+
// generated stories) that the operator did not author and never
|
|
123
|
+
// intended to land on the patch queue. Without this carve-out, a
|
|
124
|
+
// post-`furnace preview` aggregate `lint` failed with one
|
|
125
|
+
// `missing-license-header` error per generated story file (eval
|
|
126
|
+
// Finding 19) — each story is intentionally header-less because it's
|
|
127
|
+
// re-generated from component metadata on every preview run.
|
|
128
|
+
const filteredPaths = furnacePrefixes
|
|
129
|
+
? nonBrandingPaths.filter((path) => ![...furnacePrefixes].some((p) => path.startsWith(p)))
|
|
130
|
+
: nonBrandingPaths;
|
|
131
|
+
const furnaceExcluded = nonBrandingPaths.length - filteredPaths.length;
|
|
132
|
+
if (brandingExcluded > 0) {
|
|
133
|
+
info(`Excluded ${brandingExcluded} tool-managed branding file${brandingExcluded === 1 ? '' : 's'} from lint. Pass the path explicitly or use \`fireforge lint <path>\` to include them.`);
|
|
121
134
|
}
|
|
122
|
-
if (
|
|
123
|
-
info('
|
|
135
|
+
if (furnaceExcluded > 0) {
|
|
136
|
+
info(`Excluded ${furnaceExcluded} Furnace-managed file${furnaceExcluded === 1 ? '' : 's'} from lint (deployed components and preview-generated stories). Pass the path explicitly to include them.`);
|
|
137
|
+
}
|
|
138
|
+
if (filteredPaths.length === 0) {
|
|
139
|
+
info('No non-branding, non-Furnace changes to lint.');
|
|
124
140
|
outro('Nothing to lint');
|
|
125
141
|
return null;
|
|
126
142
|
}
|
|
127
|
-
const diff = await getDiffForFilesAgainstHead(engineDir,
|
|
143
|
+
const diff = await getDiffForFilesAgainstHead(engineDir, filteredPaths.sort());
|
|
128
144
|
if (!diff.trim()) {
|
|
129
145
|
info('No diff content to lint.');
|
|
130
146
|
outro('Nothing to lint');
|
|
@@ -185,7 +201,13 @@ export async function lintCommand(projectRoot, files, options = {}) {
|
|
|
185
201
|
// the diff was resolved; hoisting it is cheap and keeps the two
|
|
186
202
|
// call sites close together.
|
|
187
203
|
const config = await loadConfig(projectRoot);
|
|
188
|
-
|
|
204
|
+
// Pull the Furnace-managed prefix set up-front so aggregate lint can
|
|
205
|
+
// mirror the branding exclusion for Furnace material — without it,
|
|
206
|
+
// preview-generated stories under `browser/components/storybook/
|
|
207
|
+
// stories/furnace/` show up as license-header errors on every
|
|
208
|
+
// post-preview lint run.
|
|
209
|
+
const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
|
|
210
|
+
const diff = await resolveLintDiff(paths.engine, files, config.binaryName, furnacePrefixes);
|
|
189
211
|
if (diff === null)
|
|
190
212
|
return;
|
|
191
213
|
const filesAffected = extractAffectedFiles(diff);
|
|
@@ -284,7 +306,23 @@ export async function lintCommand(projectRoot, files, options = {}) {
|
|
|
284
306
|
: '';
|
|
285
307
|
throw new GeneralError(`Patch lint found ${failingErrors.length} ${options.onlyIntroduced ? 'introduced ' : ''}error(s). Fix these before exporting.${cumulativeSuppressed}`);
|
|
286
308
|
}
|
|
287
|
-
|
|
309
|
+
// Notices are advisory and don't count as warnings — emitting "passed
|
|
310
|
+
// with warnings" when only notices fired contradicts the preceding
|
|
311
|
+
// `0 warning(s)` summary line and reads as a regression. Distinguish
|
|
312
|
+
// the three pass states explicitly. Errors suppressed by
|
|
313
|
+
// --only-introduced still warrant the "with warnings" outro — they
|
|
314
|
+
// print as ERROR rows but no longer fail the run, which is the same
|
|
315
|
+
// contract the operator gets from a real warning.
|
|
316
|
+
const suppressedErrors = options.onlyIntroduced && errors.length > 0;
|
|
317
|
+
if (warnings.length > 0 || suppressedErrors) {
|
|
318
|
+
outro('Lint passed with warnings');
|
|
319
|
+
}
|
|
320
|
+
else if (notices.length > 0) {
|
|
321
|
+
outro('Lint passed with notices');
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
outro('Lint passed');
|
|
325
|
+
}
|
|
288
326
|
}
|
|
289
327
|
/**
|
|
290
328
|
* Lints each patch in the queue as its own isolated diff, honouring
|
|
@@ -357,7 +395,15 @@ async function lintPerPatch(projectRoot, paths) {
|
|
|
357
395
|
outro('Lint failed');
|
|
358
396
|
throw new GeneralError(`Patch lint found ${errors.length} error(s) across ${linted} patch(es). Fix these before exporting.`);
|
|
359
397
|
}
|
|
360
|
-
|
|
398
|
+
if (warnings.length > 0) {
|
|
399
|
+
outro('Lint passed with warnings');
|
|
400
|
+
}
|
|
401
|
+
else if (notices.length > 0) {
|
|
402
|
+
outro('Lint passed with notices');
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
outro('Lint passed');
|
|
406
|
+
}
|
|
361
407
|
}
|
|
362
408
|
/** Registers the lint command on the CLI program. */
|
|
363
409
|
export function registerLint(program, { getProjectRoot, withErrorHandling }) {
|
|
@@ -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;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* `fireforge patch tier <name>` — sets or clears `PatchMetadata.tier` on
|
|
4
|
+
* a single patch without rewriting the `.patch` file body.
|
|
5
|
+
*
|
|
6
|
+
* Companion to `fireforge re-export <name> --tier <tier>`. Re-export is
|
|
7
|
+
* the right tool when the patch body itself needs to be regenerated; this
|
|
8
|
+
* subcommand exists for the metadata-only adjustment, where the operator
|
|
9
|
+
* has discovered (e.g. from a `lint --per-patch` warning) that the
|
|
10
|
+
* threshold-tier override should be set but the patch body is already
|
|
11
|
+
* correct. Avoiding the re-export saves the engine read + diff
|
|
12
|
+
* regeneration roundtrip and leaves the `.patch` file's mtime alone.
|
|
13
|
+
*
|
|
14
|
+
* Modes are mutually exclusive: exactly one of `--tier <branding>` or
|
|
15
|
+
* `--clear` must be supplied per invocation.
|
|
16
|
+
*/
|
|
17
|
+
import { Option } from 'commander';
|
|
18
|
+
import { getProjectPaths } from '../../core/config.js';
|
|
19
|
+
import { appendHistory } from '../../core/destructive.js';
|
|
20
|
+
import { updatePatchMetadata } from '../../core/patch-export.js';
|
|
21
|
+
import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
|
|
22
|
+
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
23
|
+
import { toError } from '../../utils/errors.js';
|
|
24
|
+
import { pathExists } from '../../utils/fs.js';
|
|
25
|
+
import { info, intro, outro, warn } from '../../utils/logger.js';
|
|
26
|
+
import { pickDefined } from '../../utils/options.js';
|
|
27
|
+
/**
|
|
28
|
+
* Runs the `patch tier` command: updates `PatchMetadata.tier` on the
|
|
29
|
+
* named patch (or clears the field) and writes the manifest.
|
|
30
|
+
*
|
|
31
|
+
* @param projectRoot - Project root directory
|
|
32
|
+
* @param identifier - Patch filename, ordinal, or manifest `name`
|
|
33
|
+
* @param options - Command options
|
|
34
|
+
*/
|
|
35
|
+
export async function patchTierCommand(projectRoot, identifier, options = {}) {
|
|
36
|
+
const isDryRun = options.dryRun === true;
|
|
37
|
+
intro(isDryRun ? 'FireForge patch tier (dry run)' : 'FireForge patch tier');
|
|
38
|
+
// Mode mutex: a single invocation either sets or clears the tier.
|
|
39
|
+
// Combining both is ambiguous — the operator's intent is not obvious
|
|
40
|
+
// and silently picking one would mask the typo.
|
|
41
|
+
const setting = options.tier !== undefined;
|
|
42
|
+
const clearing = options.clear === true;
|
|
43
|
+
if (setting && clearing) {
|
|
44
|
+
throw new InvalidArgumentError('--tier and --clear are mutually exclusive. Pick one mode per invocation.', 'patch tier');
|
|
45
|
+
}
|
|
46
|
+
if (!setting && !clearing) {
|
|
47
|
+
throw new InvalidArgumentError('Specify --tier <tier> to set the override, or --clear to remove it.', 'patch tier');
|
|
48
|
+
}
|
|
49
|
+
const paths = getProjectPaths(projectRoot);
|
|
50
|
+
if (!(await pathExists(paths.patches))) {
|
|
51
|
+
throw new GeneralError('Patches directory not found.');
|
|
52
|
+
}
|
|
53
|
+
const manifest = await loadPatchesManifest(paths.patches);
|
|
54
|
+
if (!manifest || manifest.patches.length === 0) {
|
|
55
|
+
throw new GeneralError('No patches in manifest.');
|
|
56
|
+
}
|
|
57
|
+
const target = resolvePatchIdentifier(identifier, manifest.patches);
|
|
58
|
+
if (!target) {
|
|
59
|
+
const available = manifest.patches
|
|
60
|
+
.map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
|
|
61
|
+
.join(', ');
|
|
62
|
+
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);
|
|
63
|
+
}
|
|
64
|
+
const before = target.tier;
|
|
65
|
+
const after = setting ? options.tier : undefined;
|
|
66
|
+
if (before === after) {
|
|
67
|
+
info(after === undefined
|
|
68
|
+
? `${target.filename}: tier is already absent — no change.`
|
|
69
|
+
: `${target.filename}: tier is already "${after}" — no change.`);
|
|
70
|
+
outro(isDryRun ? 'Dry run complete — no changes made' : 'Patch tier (no-op)');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const action = after === undefined
|
|
74
|
+
? `clear tier (was "${before}")`
|
|
75
|
+
: before === undefined
|
|
76
|
+
? `set tier to "${after}"`
|
|
77
|
+
: `change tier from "${before}" to "${after}"`;
|
|
78
|
+
if (isDryRun) {
|
|
79
|
+
info(`[dry-run] ${target.filename}: would ${action}.`);
|
|
80
|
+
outro('Dry run complete — no changes made');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Single write under the patch directory lock (delegated inside
|
|
84
|
+
// updatePatchMetadata). Setting routes through `updates`; clearing
|
|
85
|
+
// routes through `unsetFields` so TypeScript's exact optional types
|
|
86
|
+
// do not have to carry an explicit `undefined` on the `tier` field.
|
|
87
|
+
if (after !== undefined) {
|
|
88
|
+
await updatePatchMetadata(paths.patches, target.filename, { tier: after });
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
await updatePatchMetadata(paths.patches, target.filename, {}, ['tier']);
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
await appendHistory(paths.patches, {
|
|
95
|
+
operation: 'patch-tier',
|
|
96
|
+
args: {
|
|
97
|
+
filename: target.filename,
|
|
98
|
+
...(before !== undefined ? { before } : {}),
|
|
99
|
+
...(after !== undefined ? { after } : {}),
|
|
100
|
+
},
|
|
101
|
+
...(options.yes === true ? { yes: true } : {}),
|
|
102
|
+
result: 'ok',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (historyError) {
|
|
106
|
+
warn(`History log append failed after patch tier committed (${target.filename}): ${toError(historyError).message}`);
|
|
107
|
+
}
|
|
108
|
+
info(`${target.filename}: ${action}.`);
|
|
109
|
+
outro('Patch tier complete');
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Registers the `patch tier` subcommand on the `patch` parent.
|
|
113
|
+
*
|
|
114
|
+
* @param parent - Parent Commander command
|
|
115
|
+
* @param context - Shared CLI registration context
|
|
116
|
+
*/
|
|
117
|
+
export function registerPatchTier(parent, context) {
|
|
118
|
+
const { getProjectRoot, withErrorHandling } = context;
|
|
119
|
+
parent
|
|
120
|
+
.command('tier <name>')
|
|
121
|
+
.description('Set or clear PatchMetadata.tier on a single patch (no .patch body rewrite). Use --tier <branding> to set, --clear to remove.')
|
|
122
|
+
.addOption(new Option('--tier <tier>', 'Force the tier override on the patch (only "branding" recognised)').choices(['branding']))
|
|
123
|
+
.option('--clear', 'Remove the tier override (restores tier auto-detection)')
|
|
124
|
+
.option('--dry-run', 'Show what would change without writing')
|
|
125
|
+
.option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
|
|
126
|
+
.action(withErrorHandling(async (name, options) => {
|
|
127
|
+
const { tier, ...rest } = options;
|
|
128
|
+
await patchTierCommand(getProjectRoot(), name, {
|
|
129
|
+
...pickDefined(rest),
|
|
130
|
+
...(tier !== undefined ? { tier: tier } : {}),
|
|
131
|
+
});
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=tier.js.map
|