@hominis/fireforge 0.18.9 → 0.18.10
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/dist/src/commands/lint.d.ts +36 -0
- package/dist/src/commands/lint.js +61 -1
- package/dist/src/commands/patch/index.d.ts +5 -3
- package/dist/src/commands/patch/index.js +8 -4
- package/dist/src/commands/patch/lint-ignore.d.ts +8 -0
- package/dist/src/commands/patch/lint-ignore.js +8 -4
- package/dist/src/commands/patch/rename.d.ts +36 -0
- package/dist/src/commands/patch/rename.js +244 -0
- package/dist/src/core/license-headers.d.ts +5 -0
- package/dist/src/core/license-headers.js +46 -5
- package/dist/src/core/patch-export.d.ts +10 -0
- package/dist/src/core/patch-export.js +8 -2
- package/dist/src/core/patch-lint-cross.js +6 -1
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +28 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import type { CommandContext } from '../types/cli.js';
|
|
3
|
+
import type { PatchLintIssue } from '../types/commands/index.js';
|
|
3
4
|
/** Options controlling how the lint command filters and tags its output. */
|
|
4
5
|
export interface LintCommandOptions {
|
|
5
6
|
/**
|
|
@@ -46,6 +47,41 @@ export interface LintCommandOptions {
|
|
|
46
47
|
*/
|
|
47
48
|
perPatch?: boolean;
|
|
48
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Result of {@link applyAggregateLintIgnoreSuppression}.
|
|
52
|
+
*/
|
|
53
|
+
export interface AggregateLintIgnoreResult {
|
|
54
|
+
/** Issues remaining after suppression. */
|
|
55
|
+
issues: PatchLintIssue[];
|
|
56
|
+
/** Number of issues dropped because an owning patch listed the check in `lintIgnore`. */
|
|
57
|
+
dropped: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Filters aggregate-mode lint issues against per-patch `lintIgnore`
|
|
61
|
+
* lists drawn from the manifest. An issue is dropped when at least one
|
|
62
|
+
* patch whose `filesAffected` covers `issue.file` lists `issue.check`
|
|
63
|
+
* in its `lintIgnore`.
|
|
64
|
+
*
|
|
65
|
+
* Mirrors the per-patch contract: `--per-patch` mode threads each
|
|
66
|
+
* patch's `lintIgnore` directly into `lintExportedPatch`, so a check
|
|
67
|
+
* the operator explicitly waived in `patches.json` does not surface.
|
|
68
|
+
* Aggregate `--since` mode previously rediscovered the suppressed
|
|
69
|
+
* warning every CI run because the diff was treated as a single unit
|
|
70
|
+
* with no patch-level scope. Attributing each issue's file to its
|
|
71
|
+
* owning patch via `filesAffected` re-establishes the same suppression
|
|
72
|
+
* semantics. Cross-patch findings (forward-import, duplicate-creation)
|
|
73
|
+
* still attribute via `issue.file` because the `file` field is the
|
|
74
|
+
* offending site, which is owned by some patch.
|
|
75
|
+
*
|
|
76
|
+
* Multiple owners: an issue is dropped if **any** owning patch waived
|
|
77
|
+
* the rule. Conservative — never adds new findings, only drops
|
|
78
|
+
* already-explicitly-waived ones.
|
|
79
|
+
*
|
|
80
|
+
* @param issues - Issues collected from the aggregate lint run.
|
|
81
|
+
* @param ctx - Patch queue context used to attribute file → patch.
|
|
82
|
+
* @returns Filtered issue list and the count of dropped findings.
|
|
83
|
+
*/
|
|
84
|
+
export declare function applyAggregateLintIgnoreSuppression(issues: PatchLintIssue[], ctx: import('../core/patch-lint.js').PatchQueueContext): AggregateLintIgnoreResult;
|
|
49
85
|
/**
|
|
50
86
|
* Runs the lint command to check engine changes against patch quality rules.
|
|
51
87
|
* @param projectRoot - Root directory of the project
|
|
@@ -160,6 +160,53 @@ async function resolveLintDiff(engineDir, files, binaryName, furnacePrefixes) {
|
|
|
160
160
|
}
|
|
161
161
|
return diff;
|
|
162
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* Filters aggregate-mode lint issues against per-patch `lintIgnore`
|
|
165
|
+
* lists drawn from the manifest. An issue is dropped when at least one
|
|
166
|
+
* patch whose `filesAffected` covers `issue.file` lists `issue.check`
|
|
167
|
+
* in its `lintIgnore`.
|
|
168
|
+
*
|
|
169
|
+
* Mirrors the per-patch contract: `--per-patch` mode threads each
|
|
170
|
+
* patch's `lintIgnore` directly into `lintExportedPatch`, so a check
|
|
171
|
+
* the operator explicitly waived in `patches.json` does not surface.
|
|
172
|
+
* Aggregate `--since` mode previously rediscovered the suppressed
|
|
173
|
+
* warning every CI run because the diff was treated as a single unit
|
|
174
|
+
* with no patch-level scope. Attributing each issue's file to its
|
|
175
|
+
* owning patch via `filesAffected` re-establishes the same suppression
|
|
176
|
+
* semantics. Cross-patch findings (forward-import, duplicate-creation)
|
|
177
|
+
* still attribute via `issue.file` because the `file` field is the
|
|
178
|
+
* offending site, which is owned by some patch.
|
|
179
|
+
*
|
|
180
|
+
* Multiple owners: an issue is dropped if **any** owning patch waived
|
|
181
|
+
* the rule. Conservative — never adds new findings, only drops
|
|
182
|
+
* already-explicitly-waived ones.
|
|
183
|
+
*
|
|
184
|
+
* @param issues - Issues collected from the aggregate lint run.
|
|
185
|
+
* @param ctx - Patch queue context used to attribute file → patch.
|
|
186
|
+
* @returns Filtered issue list and the count of dropped findings.
|
|
187
|
+
*/
|
|
188
|
+
export function applyAggregateLintIgnoreSuppression(issues, ctx) {
|
|
189
|
+
const suppressionsByFile = new Map();
|
|
190
|
+
for (const entry of ctx.entries) {
|
|
191
|
+
const ignoreList = entry.metadata?.lintIgnore;
|
|
192
|
+
if (!ignoreList || ignoreList.length === 0)
|
|
193
|
+
continue;
|
|
194
|
+
for (const f of entry.metadata?.filesAffected ?? []) {
|
|
195
|
+
let bucket = suppressionsByFile.get(f);
|
|
196
|
+
if (!bucket) {
|
|
197
|
+
bucket = new Set();
|
|
198
|
+
suppressionsByFile.set(f, bucket);
|
|
199
|
+
}
|
|
200
|
+
for (const id of ignoreList)
|
|
201
|
+
bucket.add(id);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (suppressionsByFile.size === 0) {
|
|
205
|
+
return { issues, dropped: 0 };
|
|
206
|
+
}
|
|
207
|
+
const filtered = issues.filter((issue) => !suppressionsByFile.get(issue.file)?.has(issue.check));
|
|
208
|
+
return { issues: filtered, dropped: issues.length - filtered.length };
|
|
209
|
+
}
|
|
163
210
|
/**
|
|
164
211
|
* Runs the lint command to check engine changes against patch quality rules.
|
|
165
212
|
* @param projectRoot - Root directory of the project
|
|
@@ -217,7 +264,7 @@ export async function lintCommand(projectRoot, files, options = {}) {
|
|
|
217
264
|
if (await pathExists(paths.patches)) {
|
|
218
265
|
ctx = await buildPatchQueueContext(paths.patches);
|
|
219
266
|
}
|
|
220
|
-
|
|
267
|
+
let issues = [
|
|
221
268
|
...(await lintExportedPatch(paths.engine, filesAffected, diff, config, ctx)),
|
|
222
269
|
];
|
|
223
270
|
// Cross-patch rules operate over the whole queue, so run them whenever a
|
|
@@ -226,6 +273,19 @@ export async function lintCommand(projectRoot, files, options = {}) {
|
|
|
226
273
|
if (ctx) {
|
|
227
274
|
issues.push(...lintPatchQueue(ctx));
|
|
228
275
|
}
|
|
276
|
+
// Honor per-patch `lintIgnore` in aggregate mode by attributing each
|
|
277
|
+
// issue's file to its owning patches via the manifest's
|
|
278
|
+
// `filesAffected`. Per-patch mode threads `lintIgnore` directly into
|
|
279
|
+
// `lintExportedPatch`; aggregate mode previously had no patch-level
|
|
280
|
+
// scope to consult, so a check an operator had explicitly waived in
|
|
281
|
+
// `patches.json` re-surfaced on every `--since` run (CI default).
|
|
282
|
+
if (ctx) {
|
|
283
|
+
const result = applyAggregateLintIgnoreSuppression(issues, ctx);
|
|
284
|
+
issues = result.issues;
|
|
285
|
+
if (result.dropped > 0) {
|
|
286
|
+
info(`Suppressed ${result.dropped} issue(s) via per-patch lintIgnore (aggregate mode).`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
229
289
|
// When a queue manifest exists AND files were NOT scoped explicitly, the
|
|
230
290
|
// "diff" we just linted is every applied patch summed together. Patch-
|
|
231
291
|
// size rules (`large-patch-lines`, `large-patch-files`) then fire against
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `fireforge patch <verb>` parent command. Groups single-patch
|
|
3
|
-
* mutations (`compact`, `delete`, `lint-ignore`, `
|
|
4
|
-
* they do not clutter the top-level command list.
|
|
5
|
-
* like `lint`, `export`, `verify`, and `status` stay
|
|
3
|
+
* mutations (`compact`, `delete`, `lint-ignore`, `rename`, `reorder`,
|
|
4
|
+
* `tier`) so they do not clutter the top-level command list.
|
|
5
|
+
* Queue-level verbs like `lint`, `export`, `verify`, and `status` stay
|
|
6
|
+
* flat.
|
|
6
7
|
*/
|
|
7
8
|
import { Command } from 'commander';
|
|
8
9
|
import type { CommandContext } from '../../types/cli.js';
|
|
9
10
|
export { patchCompactCommand } from './compact.js';
|
|
10
11
|
export { patchDeleteCommand } from './delete.js';
|
|
11
12
|
export { patchLintIgnoreCommand } from './lint-ignore.js';
|
|
13
|
+
export { patchRenameCommand } from './rename.js';
|
|
12
14
|
export { patchReorderCommand } from './reorder.js';
|
|
13
15
|
export { patchTierCommand } from './tier.js';
|
|
14
16
|
/**
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
/**
|
|
3
3
|
* `fireforge patch <verb>` parent command. Groups single-patch
|
|
4
|
-
* mutations (`compact`, `delete`, `lint-ignore`, `
|
|
5
|
-
* they do not clutter the top-level command list.
|
|
6
|
-
* like `lint`, `export`, `verify`, and `status` stay
|
|
4
|
+
* mutations (`compact`, `delete`, `lint-ignore`, `rename`, `reorder`,
|
|
5
|
+
* `tier`) so they do not clutter the top-level command list.
|
|
6
|
+
* Queue-level verbs like `lint`, `export`, `verify`, and `status` stay
|
|
7
|
+
* flat.
|
|
7
8
|
*/
|
|
8
9
|
import { registerPatchCompact } from './compact.js';
|
|
9
10
|
import { registerPatchDelete } from './delete.js';
|
|
10
11
|
import { registerPatchLintIgnore } from './lint-ignore.js';
|
|
12
|
+
import { registerPatchRename } from './rename.js';
|
|
11
13
|
import { registerPatchReorder } from './reorder.js';
|
|
12
14
|
import { registerPatchTier } from './tier.js';
|
|
13
15
|
export { patchCompactCommand } from './compact.js';
|
|
14
16
|
export { patchDeleteCommand } from './delete.js';
|
|
15
17
|
export { patchLintIgnoreCommand } from './lint-ignore.js';
|
|
18
|
+
export { patchRenameCommand } from './rename.js';
|
|
16
19
|
export { patchReorderCommand } from './reorder.js';
|
|
17
20
|
export { patchTierCommand } from './tier.js';
|
|
18
21
|
/**
|
|
@@ -24,7 +27,7 @@ export { patchTierCommand } from './tier.js';
|
|
|
24
27
|
export function registerPatch(program, context) {
|
|
25
28
|
const patch = program
|
|
26
29
|
.command('patch')
|
|
27
|
-
.description('Manage individual patches in the queue (compact, delete, lint-ignore, reorder, tier)')
|
|
30
|
+
.description('Manage individual patches in the queue (compact, delete, lint-ignore, rename, reorder, tier)')
|
|
28
31
|
// Match `fireforge furnace`'s no-args contract: print the group's help and
|
|
29
32
|
// exit 0. Without this default action, commander routes `fireforge patch`
|
|
30
33
|
// (no subcommand) through its own help-then-exit-1 path, so scripts that
|
|
@@ -37,6 +40,7 @@ export function registerPatch(program, context) {
|
|
|
37
40
|
registerPatchCompact(patch, context);
|
|
38
41
|
registerPatchDelete(patch, context);
|
|
39
42
|
registerPatchLintIgnore(patch, context);
|
|
43
|
+
registerPatchRename(patch, context);
|
|
40
44
|
registerPatchReorder(patch, context);
|
|
41
45
|
registerPatchTier(patch, context);
|
|
42
46
|
}
|
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
import { Command } from 'commander';
|
|
22
22
|
import type { CommandContext } from '../../types/cli.js';
|
|
23
23
|
import type { PatchLintIgnoreOptions } from '../../types/commands/index.js';
|
|
24
|
+
type LintIgnoreMode = 'add' | 'remove' | 'clear';
|
|
25
|
+
/**
|
|
26
|
+
* Renders a one-line summary of the planned change for use in
|
|
27
|
+
* `info()` / dry-run / history args. Exported for unit-testing the
|
|
28
|
+
* message format directly without mocking the logger transport.
|
|
29
|
+
*/
|
|
30
|
+
export declare function describeChange(before: ReadonlyArray<string>, after: ReadonlyArray<string>, mode: LintIgnoreMode, values: ReadonlyArray<string>): string;
|
|
24
31
|
/**
|
|
25
32
|
* Runs the `patch lint-ignore` command: reads the patch's existing
|
|
26
33
|
* `lintIgnore`, applies the requested mode, and writes the manifest.
|
|
@@ -37,3 +44,4 @@ export declare function patchLintIgnoreCommand(projectRoot: string, identifier:
|
|
|
37
44
|
* @param context - Shared CLI registration context
|
|
38
45
|
*/
|
|
39
46
|
export declare function registerPatchLintIgnore(parent: Command, context: CommandContext): void;
|
|
47
|
+
export {};
|
|
@@ -53,9 +53,10 @@ function applyMode(existing, mode, values) {
|
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
55
|
* Renders a one-line summary of the planned change for use in
|
|
56
|
-
* `info()` / dry-run / history args.
|
|
56
|
+
* `info()` / dry-run / history args. Exported for unit-testing the
|
|
57
|
+
* message format directly without mocking the logger transport.
|
|
57
58
|
*/
|
|
58
|
-
function describeChange(before, after, mode, values) {
|
|
59
|
+
export function describeChange(before, after, mode, values) {
|
|
59
60
|
const beforeSet = new Set(before);
|
|
60
61
|
const afterSet = new Set(after);
|
|
61
62
|
if (mode === 'clear') {
|
|
@@ -63,17 +64,20 @@ function describeChange(before, after, mode, values) {
|
|
|
63
64
|
? 'lintIgnore was already empty — no change'
|
|
64
65
|
: `lintIgnore cleared (was ${before.join(', ')})`;
|
|
65
66
|
}
|
|
67
|
+
const currentLabel = before.length > 0 ? `[${before.join(', ')}]` : '(empty)';
|
|
66
68
|
if (mode === 'add') {
|
|
67
69
|
const added = values.filter((v) => !beforeSet.has(v));
|
|
68
70
|
if (added.length === 0) {
|
|
69
|
-
|
|
71
|
+
// Surface the existing list so a no-op `--add` does not require a
|
|
72
|
+
// follow-up `patches.json` read to confirm what was already present.
|
|
73
|
+
return `lintIgnore unchanged (current: ${currentLabel}; all requested IDs already present)`;
|
|
70
74
|
}
|
|
71
75
|
return `lintIgnore += ${added.join(', ')} → ${[...afterSet].join(', ') || '(empty)'}`;
|
|
72
76
|
}
|
|
73
77
|
// mode === 'remove'
|
|
74
78
|
const removed = values.filter((v) => beforeSet.has(v));
|
|
75
79
|
if (removed.length === 0) {
|
|
76
|
-
return
|
|
80
|
+
return `lintIgnore unchanged (current: ${currentLabel}; none of the requested IDs were present)`;
|
|
77
81
|
}
|
|
78
82
|
return `lintIgnore −= ${removed.join(', ')} → ${[...afterSet].join(', ') || '(empty)'}`;
|
|
79
83
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fireforge patch rename <name>` — relabels a patch's filename, manifest
|
|
3
|
+
* `name`, and (optionally) `description` without rewriting the `.patch`
|
|
4
|
+
* file body.
|
|
5
|
+
*
|
|
6
|
+
* Companion to `re-export --files <subset>`. Re-export shrinks the body
|
|
7
|
+
* + `filesAffected`, but leaves the patch's identity describing the
|
|
8
|
+
* pre-shrink scope. Before this verb existed, the only workaround for
|
|
9
|
+
* that drift was `delete` + re-export, which briefly removed the patch
|
|
10
|
+
* from the queue (any forward-import dependent would refuse the
|
|
11
|
+
* re-export until the deleted patch's siblings were rewritten).
|
|
12
|
+
*
|
|
13
|
+
* The filename rename and the manifest mutation happen under the patch
|
|
14
|
+
* directory lock so concurrent exports cannot allocate the new
|
|
15
|
+
* filename, and a filesystem rename failure rolls back before the
|
|
16
|
+
* manifest is touched.
|
|
17
|
+
*/
|
|
18
|
+
import { Command } from 'commander';
|
|
19
|
+
import type { CommandContext } from '../../types/cli.js';
|
|
20
|
+
import type { PatchRenameOptions } from '../../types/commands/index.js';
|
|
21
|
+
/**
|
|
22
|
+
* Runs the `patch rename` command: relabels filename + manifest entry
|
|
23
|
+
* for a single patch atomically.
|
|
24
|
+
*
|
|
25
|
+
* @param projectRoot - Project root directory
|
|
26
|
+
* @param identifier - Patch filename, ordinal, or manifest `name`
|
|
27
|
+
* @param options - Command options (`--to <new-name>` is required)
|
|
28
|
+
*/
|
|
29
|
+
export declare function patchRenameCommand(projectRoot: string, identifier: string, options?: PatchRenameOptions): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Registers the `patch rename` subcommand on the `patch` parent.
|
|
32
|
+
*
|
|
33
|
+
* @param parent - Parent Commander command
|
|
34
|
+
* @param context - Shared CLI registration context
|
|
35
|
+
*/
|
|
36
|
+
export declare function registerPatchRename(parent: Command, context: CommandContext): void;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* `fireforge patch rename <name>` — relabels a patch's filename, manifest
|
|
4
|
+
* `name`, and (optionally) `description` without rewriting the `.patch`
|
|
5
|
+
* file body.
|
|
6
|
+
*
|
|
7
|
+
* Companion to `re-export --files <subset>`. Re-export shrinks the body
|
|
8
|
+
* + `filesAffected`, but leaves the patch's identity describing the
|
|
9
|
+
* pre-shrink scope. Before this verb existed, the only workaround for
|
|
10
|
+
* that drift was `delete` + re-export, which briefly removed the patch
|
|
11
|
+
* from the queue (any forward-import dependent would refuse the
|
|
12
|
+
* re-export until the deleted patch's siblings were rewritten).
|
|
13
|
+
*
|
|
14
|
+
* The filename rename and the manifest mutation happen under the patch
|
|
15
|
+
* directory lock so concurrent exports cannot allocate the new
|
|
16
|
+
* filename, and a filesystem rename failure rolls back before the
|
|
17
|
+
* manifest is touched.
|
|
18
|
+
*/
|
|
19
|
+
import { rename as fsRename } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { getProjectPaths } from '../../core/config.js';
|
|
22
|
+
import { appendHistory, confirmDestructive } from '../../core/destructive.js';
|
|
23
|
+
import { sanitizeName } from '../../core/patch-export.js';
|
|
24
|
+
import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
|
|
25
|
+
import { withPatchDirectoryLock } from '../../core/patch-lock.js';
|
|
26
|
+
import { loadPatchesManifest, resolvePatchIdentifier, savePatchesManifest, } from '../../core/patch-manifest.js';
|
|
27
|
+
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
28
|
+
import { toError } from '../../utils/errors.js';
|
|
29
|
+
import { pathExists } from '../../utils/fs.js';
|
|
30
|
+
import { info, intro, outro, warn } from '../../utils/logger.js';
|
|
31
|
+
import { pickDefined } from '../../utils/options.js';
|
|
32
|
+
/**
|
|
33
|
+
* Pulls the ordinal-string + category prefix out of a patch filename so
|
|
34
|
+
* the rename keeps the existing ordinal padding verbatim. Returning the
|
|
35
|
+
* literal substring (rather than recomputing from the parsed integer)
|
|
36
|
+
* avoids any chance of the new filename's ordinal differing from the
|
|
37
|
+
* old by a leading-zero count.
|
|
38
|
+
*/
|
|
39
|
+
function splitPatchFilename(filename) {
|
|
40
|
+
const m = /^(\d+)-([a-z]+)-(.+)\.patch$/.exec(filename);
|
|
41
|
+
if (!m?.[1] || !m[2] || !m[3])
|
|
42
|
+
return null;
|
|
43
|
+
return { ordinalStr: m[1], category: m[2], slug: m[3] };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Performs the rename's transactional core under the patch directory
|
|
47
|
+
* lock: re-reads the manifest, re-checks for filename collisions,
|
|
48
|
+
* renames the `.patch` file on disk (when applicable), writes the
|
|
49
|
+
* updated manifest, and appends a history entry. Filesystem rename
|
|
50
|
+
* happens before the manifest save so an interrupted run never leaves
|
|
51
|
+
* the manifest pointing at a missing file; a manifest-save failure
|
|
52
|
+
* rolls the filesystem rename back.
|
|
53
|
+
*/
|
|
54
|
+
async function commitRenameUnderLock(input) {
|
|
55
|
+
const { patchesDir, target, newFilename, newName, newDescription, filenameChanging, nameChanging, descriptionChanging, } = input;
|
|
56
|
+
await withPatchDirectoryLock(patchesDir, async () => {
|
|
57
|
+
const fresh = await loadPatchesManifest(patchesDir);
|
|
58
|
+
if (!fresh) {
|
|
59
|
+
throw new GeneralError('Manifest disappeared between resolution and rename.');
|
|
60
|
+
}
|
|
61
|
+
const idx = fresh.patches.findIndex((p) => p.filename === target.filename);
|
|
62
|
+
if (idx === -1) {
|
|
63
|
+
throw new GeneralError(`Patch ${target.filename} disappeared from the manifest during rename. Re-run after investigating.`);
|
|
64
|
+
}
|
|
65
|
+
const before = fresh.patches[idx];
|
|
66
|
+
if (!before) {
|
|
67
|
+
throw new GeneralError(`Patch ${target.filename} disappeared from the manifest during rename.`);
|
|
68
|
+
}
|
|
69
|
+
if (filenameChanging) {
|
|
70
|
+
const collisionInLock = fresh.patches.find((p) => p.filename === newFilename && p.filename !== target.filename);
|
|
71
|
+
if (collisionInLock) {
|
|
72
|
+
throw new InvalidArgumentError(`Cannot rename to "${newFilename}" — a different patch claimed that filename concurrently.`, 'patch rename');
|
|
73
|
+
}
|
|
74
|
+
const oldPath = join(patchesDir, target.filename);
|
|
75
|
+
const newPath = join(patchesDir, newFilename);
|
|
76
|
+
if (await pathExists(newPath)) {
|
|
77
|
+
throw new InvalidArgumentError(`Cannot rename: ${newFilename} already exists on disk. Resolve manually before retrying.`, 'patch rename');
|
|
78
|
+
}
|
|
79
|
+
await fsRename(oldPath, newPath);
|
|
80
|
+
fresh.patches[idx] = {
|
|
81
|
+
...before,
|
|
82
|
+
filename: newFilename,
|
|
83
|
+
name: newName,
|
|
84
|
+
...(descriptionChanging ? { description: newDescription ?? '' } : {}),
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
await savePatchesManifest(patchesDir, fresh);
|
|
88
|
+
}
|
|
89
|
+
catch (saveError) {
|
|
90
|
+
try {
|
|
91
|
+
await fsRename(newPath, oldPath);
|
|
92
|
+
}
|
|
93
|
+
catch (rollbackError) {
|
|
94
|
+
warn(`Rollback warning: could not restore ${target.filename} after manifest write failure: ${toError(rollbackError).message}`);
|
|
95
|
+
}
|
|
96
|
+
throw saveError;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
fresh.patches[idx] = {
|
|
101
|
+
...before,
|
|
102
|
+
...(nameChanging ? { name: newName } : {}),
|
|
103
|
+
...(descriptionChanging ? { description: newDescription ?? '' } : {}),
|
|
104
|
+
};
|
|
105
|
+
await savePatchesManifest(patchesDir, fresh);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await appendHistory(patchesDir, {
|
|
109
|
+
operation: 'patch-rename',
|
|
110
|
+
args: {
|
|
111
|
+
oldFilename: target.filename,
|
|
112
|
+
newFilename,
|
|
113
|
+
oldName: target.name,
|
|
114
|
+
newName,
|
|
115
|
+
...(descriptionChanging ? { oldDescription: target.description, newDescription } : {}),
|
|
116
|
+
},
|
|
117
|
+
...(input.yes === true ? { yes: true } : {}),
|
|
118
|
+
result: 'ok',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (historyError) {
|
|
122
|
+
warn(`History log append failed after patch rename committed (${newFilename}): ${toError(historyError).message}`);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Runs the `patch rename` command: relabels filename + manifest entry
|
|
128
|
+
* for a single patch atomically.
|
|
129
|
+
*
|
|
130
|
+
* @param projectRoot - Project root directory
|
|
131
|
+
* @param identifier - Patch filename, ordinal, or manifest `name`
|
|
132
|
+
* @param options - Command options (`--to <new-name>` is required)
|
|
133
|
+
*/
|
|
134
|
+
export async function patchRenameCommand(projectRoot, identifier, options = {}) {
|
|
135
|
+
const isDryRun = options.dryRun === true;
|
|
136
|
+
intro(isDryRun ? 'FireForge patch rename (dry run)' : 'FireForge patch rename');
|
|
137
|
+
if (options.to === undefined || options.to.trim() === '') {
|
|
138
|
+
throw new InvalidArgumentError('Specify --to <new-name>. The new name is sanitised into the filename slug the same way `export --name` is.', 'patch rename');
|
|
139
|
+
}
|
|
140
|
+
const paths = getProjectPaths(projectRoot);
|
|
141
|
+
if (!(await pathExists(paths.patches))) {
|
|
142
|
+
throw new GeneralError('Patches directory not found.');
|
|
143
|
+
}
|
|
144
|
+
const manifest = await loadPatchesManifest(paths.patches);
|
|
145
|
+
if (!manifest || manifest.patches.length === 0) {
|
|
146
|
+
throw new GeneralError('No patches in manifest.');
|
|
147
|
+
}
|
|
148
|
+
const target = resolvePatchIdentifier(identifier, manifest.patches);
|
|
149
|
+
if (!target) {
|
|
150
|
+
throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
|
|
151
|
+
}
|
|
152
|
+
const split = splitPatchFilename(target.filename);
|
|
153
|
+
if (!split) {
|
|
154
|
+
throw new GeneralError(`Cannot rename ${target.filename}: filename does not match the expected {ordinal}-{category}-{slug}.patch convention. Re-export the patch instead.`);
|
|
155
|
+
}
|
|
156
|
+
const newSlug = sanitizeName(options.to);
|
|
157
|
+
if (newSlug === '') {
|
|
158
|
+
throw new InvalidArgumentError('--to must contain at least one alphanumeric character after sanitisation.', 'patch rename');
|
|
159
|
+
}
|
|
160
|
+
const newFilename = `${split.ordinalStr}-${split.category}-${newSlug}.patch`;
|
|
161
|
+
const filenameChanging = newFilename !== target.filename;
|
|
162
|
+
const nameChanging = options.to !== target.name;
|
|
163
|
+
const descriptionChanging = options.description !== undefined && options.description !== target.description;
|
|
164
|
+
if (!filenameChanging && !nameChanging && !descriptionChanging) {
|
|
165
|
+
info(`${target.filename}: name and description already match — nothing to change.`);
|
|
166
|
+
outro(isDryRun ? 'Dry run complete — no changes made' : 'Patch rename (no-op)');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Pre-flight collision check against the manifest snapshot we already
|
|
170
|
+
// loaded. The authoritative check happens again inside the lock to
|
|
171
|
+
// close the TOCTOU window — surface a helpful error here when the
|
|
172
|
+
// collision is obvious so the operator does not get surprised by a
|
|
173
|
+
// late refusal after a confirmation prompt.
|
|
174
|
+
if (filenameChanging) {
|
|
175
|
+
const collision = manifest.patches.find((p) => p.filename === newFilename && p.filename !== target.filename);
|
|
176
|
+
if (collision) {
|
|
177
|
+
throw new InvalidArgumentError(`Cannot rename to "${newFilename}" — a different patch already uses that filename.`, 'patch rename');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const summary = [];
|
|
181
|
+
if (filenameChanging) {
|
|
182
|
+
summary.push(`rename ${target.filename} → ${newFilename}`);
|
|
183
|
+
}
|
|
184
|
+
if (nameChanging) {
|
|
185
|
+
summary.push(`name: "${target.name}" → "${options.to}"`);
|
|
186
|
+
}
|
|
187
|
+
if (descriptionChanging) {
|
|
188
|
+
summary.push(`description: "${target.description || '(none)'}" → "${options.description ?? '(none)'}"`);
|
|
189
|
+
}
|
|
190
|
+
const decision = await confirmDestructive({
|
|
191
|
+
operation: 'patch-rename',
|
|
192
|
+
title: `Rename ${target.filename}`,
|
|
193
|
+
summary,
|
|
194
|
+
yes: options.yes === true,
|
|
195
|
+
dryRun: isDryRun,
|
|
196
|
+
conflicts: null,
|
|
197
|
+
});
|
|
198
|
+
if (decision === 'dry-run') {
|
|
199
|
+
outro('Dry run complete — no changes made');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (decision === 'cancelled') {
|
|
203
|
+
outro('Rename cancelled');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
await commitRenameUnderLock({
|
|
207
|
+
patchesDir: paths.patches,
|
|
208
|
+
target,
|
|
209
|
+
newFilename,
|
|
210
|
+
newName: options.to,
|
|
211
|
+
...(options.description !== undefined ? { newDescription: options.description } : {}),
|
|
212
|
+
filenameChanging,
|
|
213
|
+
nameChanging,
|
|
214
|
+
descriptionChanging,
|
|
215
|
+
...(options.yes === true ? { yes: true } : {}),
|
|
216
|
+
});
|
|
217
|
+
if (filenameChanging) {
|
|
218
|
+
info(`${target.filename} → ${newFilename}`);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
info(`${target.filename}: metadata updated.`);
|
|
222
|
+
}
|
|
223
|
+
outro('Patch rename complete');
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Registers the `patch rename` subcommand on the `patch` parent.
|
|
227
|
+
*
|
|
228
|
+
* @param parent - Parent Commander command
|
|
229
|
+
* @param context - Shared CLI registration context
|
|
230
|
+
*/
|
|
231
|
+
export function registerPatchRename(parent, context) {
|
|
232
|
+
const { getProjectRoot, withErrorHandling } = context;
|
|
233
|
+
parent
|
|
234
|
+
.command('rename <name>')
|
|
235
|
+
.description('Rename a patch: filename + manifest name (and optional description) update without rewriting the .patch body.')
|
|
236
|
+
.requiredOption('--to <new-name>', 'New human-readable name (sanitised into the filename slug)')
|
|
237
|
+
.option('--description <text>', 'Replacement description (omit to leave description unchanged)')
|
|
238
|
+
.option('--dry-run', 'Show what would change without writing')
|
|
239
|
+
.option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
|
|
240
|
+
.action(withErrorHandling(async (name, options) => {
|
|
241
|
+
await patchRenameCommand(getProjectRoot(), name, pickDefined(options));
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
//# sourceMappingURL=rename.js.map
|
|
@@ -29,6 +29,11 @@ export declare function getLicenseHeader(license: ProjectLicense, style: Comment
|
|
|
29
29
|
* standard MPL header — operators were forced to `--skip-lint` over a real
|
|
30
30
|
* false positive.
|
|
31
31
|
*
|
|
32
|
+
* Editor-directive block comments (`/* -*- ... -*- *\/`, `/* vim: ... *\/`)
|
|
33
|
+
* leading the file are tolerated — Mozilla's canonical layout puts those
|
|
34
|
+
* on lines 1–2 with the MPL header on lines 3+, which the raw
|
|
35
|
+
* `startsWith` check would otherwise miss.
|
|
36
|
+
*
|
|
32
37
|
* @param content - File content to check
|
|
33
38
|
* @param style - Comment syntax of the file
|
|
34
39
|
*/
|
|
@@ -53,6 +53,39 @@ export function getLicenseHeader(license, style) {
|
|
|
53
53
|
return lines.map((l) => `# ${l}`).join('\n');
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Single-line `/* ... *\/` block comments containing either an Emacs
|
|
58
|
+
* file-mode marker (`-*-`) or a vim modeline (`vim:`) — Mozilla's
|
|
59
|
+
* canonical first-line editor directives that legitimately precede the
|
|
60
|
+
* license header in many Firefox source files.
|
|
61
|
+
*
|
|
62
|
+
* Restricted to single-line blocks so a multi-line license header never
|
|
63
|
+
* gets accidentally consumed.
|
|
64
|
+
*/
|
|
65
|
+
const EDITOR_DIRECTIVE_BLOCK_COMMENT = /^[ \t]*\/\*[^\r\n]*?(?:-\*-|\bvim:)[^\r\n]*?\*\/[ \t]*\r?\n?/;
|
|
66
|
+
/**
|
|
67
|
+
* Strips any leading run of editor-directive block comments and blank
|
|
68
|
+
* lines, returning the remaining content.
|
|
69
|
+
*
|
|
70
|
+
* Mozilla's coding convention places editor directives like
|
|
71
|
+
* `/* -*- Mode: javascript; ... -*- *\/` and `/* vim: set ... *\/` on
|
|
72
|
+
* lines 1–2, with the canonical license header following on lines 3+.
|
|
73
|
+
* The raw `content.startsWith(...)` check used by {@link hasAnyLicenseHeader}
|
|
74
|
+
* never matches in that shape; this helper lets the caller test the
|
|
75
|
+
* post-directive prefix as a fallback.
|
|
76
|
+
*
|
|
77
|
+
* @param content - File content to strip
|
|
78
|
+
*/
|
|
79
|
+
function stripLeadingEditorDirectives(content) {
|
|
80
|
+
let result = content;
|
|
81
|
+
let prev;
|
|
82
|
+
do {
|
|
83
|
+
prev = result;
|
|
84
|
+
result = result.replace(/^[ \t]*\r?\n/, '');
|
|
85
|
+
result = result.replace(EDITOR_DIRECTIVE_BLOCK_COMMENT, '');
|
|
86
|
+
} while (result !== prev);
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
56
89
|
/**
|
|
57
90
|
* Returns true if `content` starts with any known license header for the
|
|
58
91
|
* given comment style.
|
|
@@ -65,16 +98,24 @@ export function getLicenseHeader(license, style) {
|
|
|
65
98
|
* standard MPL header — operators were forced to `--skip-lint` over a real
|
|
66
99
|
* false positive.
|
|
67
100
|
*
|
|
101
|
+
* Editor-directive block comments (`/* -*- ... -*- *\/`, `/* vim: ... *\/`)
|
|
102
|
+
* leading the file are tolerated — Mozilla's canonical layout puts those
|
|
103
|
+
* on lines 1–2 with the MPL header on lines 3+, which the raw
|
|
104
|
+
* `startsWith` check would otherwise miss.
|
|
105
|
+
*
|
|
68
106
|
* @param content - File content to check
|
|
69
107
|
* @param style - Comment syntax of the file
|
|
70
108
|
*/
|
|
71
109
|
export function hasAnyLicenseHeader(content, style) {
|
|
110
|
+
const candidates = [content, stripLeadingEditorDirectives(content)];
|
|
72
111
|
const licenses = Object.keys(HEADER_LINES);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
112
|
+
for (const candidate of candidates) {
|
|
113
|
+
if (licenses.some((license) => candidate.startsWith(getLicenseHeader(license, style)))) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
if (style === 'js' && candidate.startsWith(getLicenseHeader('MPL-2.0', 'css'))) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
78
119
|
}
|
|
79
120
|
return false;
|
|
80
121
|
}
|
|
@@ -5,6 +5,16 @@ import type { PatchCategory, PatchesManifest, PatchInfo, PatchMetadata } from '.
|
|
|
5
5
|
* @returns Next patch number (e.g., "005" for 4 existing patches)
|
|
6
6
|
*/
|
|
7
7
|
export declare function getNextPatchNumber(patchesDir: string): Promise<string>;
|
|
8
|
+
/**
|
|
9
|
+
* Sanitizes a human-readable name into a filename slug.
|
|
10
|
+
*
|
|
11
|
+
* Exported so `patch rename` can produce a filename slug from its
|
|
12
|
+
* `--to <new-name>` argument using the exact same convention `export`
|
|
13
|
+
* uses, without duplicating the lowercase + non-alnum collapse + length
|
|
14
|
+
* cap rules. Drift between the two would let an operator rename a patch
|
|
15
|
+
* to a slug `export` could never reach.
|
|
16
|
+
*/
|
|
17
|
+
export declare function sanitizeName(name: string): string;
|
|
8
18
|
/**
|
|
9
19
|
* Generates the next patch filename with category.
|
|
10
20
|
* @param patchesDir - Path to the patches directory
|
|
@@ -25,9 +25,15 @@ export async function getNextPatchNumber(patchesDir) {
|
|
|
25
25
|
return String(nextNumber).padStart(Math.max(3, String(nextNumber).length), '0');
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
|
-
* Sanitizes a
|
|
28
|
+
* Sanitizes a human-readable name into a filename slug.
|
|
29
|
+
*
|
|
30
|
+
* Exported so `patch rename` can produce a filename slug from its
|
|
31
|
+
* `--to <new-name>` argument using the exact same convention `export`
|
|
32
|
+
* uses, without duplicating the lowercase + non-alnum collapse + length
|
|
33
|
+
* cap rules. Drift between the two would let an operator rename a patch
|
|
34
|
+
* to a slug `export` could never reach.
|
|
29
35
|
*/
|
|
30
|
-
function sanitizeName(name) {
|
|
36
|
+
export function sanitizeName(name) {
|
|
31
37
|
return name
|
|
32
38
|
.toLowerCase()
|
|
33
39
|
.replace(/[^a-z0-9]+/g, '-')
|
|
@@ -396,6 +396,10 @@ export function lintPatchQueueForwardImports(ctx) {
|
|
|
396
396
|
.map((o) => `${o.filename}:${o.fullPath}`)
|
|
397
397
|
.sort((a, b) => a.localeCompare(b))
|
|
398
398
|
.join(',');
|
|
399
|
+
// Lowest ordinal that lands AFTER every later-ordered creator —
|
|
400
|
+
// turns the operator's "guess and re-run" loop into a single shot
|
|
401
|
+
// when the only fix is reordering.
|
|
402
|
+
const suggestedOrder = Math.max(...laterOwners.map((o) => o.order)) + 1;
|
|
399
403
|
issues.push({
|
|
400
404
|
file: sitePath,
|
|
401
405
|
check: 'forward-import',
|
|
@@ -404,7 +408,8 @@ export function lintPatchQueueForwardImports(ctx) {
|
|
|
404
408
|
`but the matching new file is created by a later patch: ${ownersSummary}. ` +
|
|
405
409
|
'Reorder the patches so the dependency is created first, move the import ' +
|
|
406
410
|
'into the later patch, or mark the import with ' +
|
|
407
|
-
`"// ${FORWARD_IMPORT_IGNORE_MARKER}" if the basename collision is a false positive
|
|
411
|
+
`"// ${FORWARD_IMPORT_IGNORE_MARKER}" if the basename collision is a false positive. ` +
|
|
412
|
+
`Closest legal ordinal that satisfies this dependency: ${suggestedOrder}.`,
|
|
408
413
|
severity: 'error',
|
|
409
414
|
});
|
|
410
415
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Re-exports all command-related types from focused sub-modules.
|
|
3
3
|
*/
|
|
4
|
-
export type { BuildOptions, DiscardOptions, DoctorOptions, DownloadOptions, ExportOptions, FurnaceApplyOptions, FurnaceCreateOptions, FurnaceDeployOptions, FurnaceOverrideOptions, FurnacePreviewOptions, FurnaceRefreshOptions, FurnaceRemoveOptions, FurnaceSyncOptions, FurnaceValidateOptions, GlobalOptions, ImportOptions, PackageOptions, PatchCompactOptions, PatchDeleteOptions, PatchLintIgnoreOptions, PatchReorderOptions, PatchTierOptions, RebaseOptions, ReExportOptions, RegisterOptions, ResetOptions, RunOptions, SetupOptions, StatusOptions, TestOptions, TokenAddOptions, WireOptions, } from './options.js';
|
|
4
|
+
export type { BuildOptions, DiscardOptions, DoctorOptions, DownloadOptions, ExportOptions, FurnaceApplyOptions, FurnaceCreateOptions, FurnaceDeployOptions, FurnaceOverrideOptions, FurnacePreviewOptions, FurnaceRefreshOptions, FurnaceRemoveOptions, FurnaceSyncOptions, FurnaceValidateOptions, GlobalOptions, ImportOptions, PackageOptions, PatchCompactOptions, PatchDeleteOptions, PatchLintIgnoreOptions, PatchRenameOptions, PatchReorderOptions, PatchTierOptions, RebaseOptions, ReExportOptions, RegisterOptions, ResetOptions, RunOptions, SetupOptions, StatusOptions, TestOptions, TokenAddOptions, WireOptions, } from './options.js';
|
|
5
5
|
export type { ImportSummary, PatchCategory, PatchesManifest, PatchInfo, PatchLintIssue, PatchMetadata, PatchResult, } from './patches.js';
|
|
6
6
|
export type { DoctorCheck, ProjectStatus, TokenCoverageFileEntry, TokenCoverageReport, } from './project.js';
|
|
@@ -501,6 +501,34 @@ export interface PatchDeleteOptions {
|
|
|
501
501
|
/** Bypass the hard refusal when later patches depend on the target. */
|
|
502
502
|
forceUnsafe?: boolean;
|
|
503
503
|
}
|
|
504
|
+
/**
|
|
505
|
+
* Options for the `fireforge patch rename` subcommand. Updates the
|
|
506
|
+
* patch's filename, manifest `name`, and (optionally) `description`
|
|
507
|
+
* atomically without rewriting the `.patch` file body. Companion to
|
|
508
|
+
* `re-export --files` for the case where the body is already correct
|
|
509
|
+
* but the patch's identity (filename + description) describes a
|
|
510
|
+
* pre-shrink scope; before this verb existed the only workaround was
|
|
511
|
+
* `delete` + re-export, which briefly dropped the patch from the queue.
|
|
512
|
+
*/
|
|
513
|
+
export interface PatchRenameOptions {
|
|
514
|
+
/**
|
|
515
|
+
* New human-readable name. Sanitised the same way `export --name`
|
|
516
|
+
* sanitises into the filename slug (lowercase, non-alphanumerics
|
|
517
|
+
* collapsed to `-`, length-capped). The patch's `name` field stores
|
|
518
|
+
* the raw value; the filename uses the sanitised slug.
|
|
519
|
+
*/
|
|
520
|
+
to?: string;
|
|
521
|
+
/**
|
|
522
|
+
* Replacement description. Omit to leave the description unchanged
|
|
523
|
+
* (intentional — operators frequently want to relabel the slug
|
|
524
|
+
* without touching the description).
|
|
525
|
+
*/
|
|
526
|
+
description?: string;
|
|
527
|
+
/** Print the planned change without writing. */
|
|
528
|
+
dryRun?: boolean;
|
|
529
|
+
/** Skip the confirmation prompt (required for non-TTY). */
|
|
530
|
+
yes?: boolean;
|
|
531
|
+
}
|
|
504
532
|
/**
|
|
505
533
|
* Options for the patch reorder command.
|
|
506
534
|
*/
|