@hominis/fireforge 0.18.8 → 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.
Files changed (35) hide show
  1. package/dist/src/commands/lint.d.ts +36 -0
  2. package/dist/src/commands/lint.js +61 -1
  3. package/dist/src/commands/patch/index.d.ts +5 -3
  4. package/dist/src/commands/patch/index.js +8 -4
  5. package/dist/src/commands/patch/lint-ignore.d.ts +8 -0
  6. package/dist/src/commands/patch/lint-ignore.js +8 -4
  7. package/dist/src/commands/patch/rename.d.ts +36 -0
  8. package/dist/src/commands/patch/rename.js +244 -0
  9. package/dist/src/commands/test.js +50 -3
  10. package/dist/src/core/ast-utils.d.ts +5 -1
  11. package/dist/src/core/ast-utils.js +10 -3
  12. package/dist/src/core/build-audit-resolve.d.ts +5 -3
  13. package/dist/src/core/build-audit-resolve.js +12 -3
  14. package/dist/src/core/config-paths.d.ts +1 -1
  15. package/dist/src/core/config-paths.js +1 -0
  16. package/dist/src/core/config-validate.js +4 -0
  17. package/dist/src/core/license-headers.d.ts +5 -0
  18. package/dist/src/core/license-headers.js +46 -5
  19. package/dist/src/core/marionette-port.d.ts +29 -0
  20. package/dist/src/core/marionette-port.js +82 -0
  21. package/dist/src/core/patch-export.d.ts +10 -0
  22. package/dist/src/core/patch-export.js +8 -2
  23. package/dist/src/core/patch-lint-chrome-jsdoc.d.ts +47 -0
  24. package/dist/src/core/patch-lint-chrome-jsdoc.js +87 -0
  25. package/dist/src/core/patch-lint-cross.js +6 -1
  26. package/dist/src/core/patch-lint-jsdoc.d.ts +37 -0
  27. package/dist/src/core/patch-lint-jsdoc.js +24 -3
  28. package/dist/src/core/patch-lint-ownership.d.ts +21 -3
  29. package/dist/src/core/patch-lint-ownership.js +45 -18
  30. package/dist/src/core/patch-lint.d.ts +7 -2
  31. package/dist/src/core/patch-lint.js +24 -6
  32. package/dist/src/types/commands/index.d.ts +1 -1
  33. package/dist/src/types/commands/options.d.ts +36 -0
  34. package/dist/src/types/config.d.ts +12 -1
  35. 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
- const issues = [
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`, `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.
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`, `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.
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
- return 'lintIgnore unchanged (all requested IDs were already present)';
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 'lintIgnore unchanged (none of the requested IDs were present)';
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
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { prepareBuildEnvironment } from '../core/build-prepare.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, testWithOutput, } from '../core/mach.js';
6
- import { assertMarionettePortAvailable } from '../core/marionette-port.js';
6
+ import { assertMarionettePortAvailable, extractForwardedMarionettePort, isMarionetteFlavor, } from '../core/marionette-port.js';
7
7
  import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
8
8
  import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
9
9
  import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
@@ -238,6 +238,20 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
238
238
  warn(formatStaleBuildWarning(stale));
239
239
  }
240
240
  }
241
+ // Resolve the effective Marionette port. Operator precedence:
242
+ // 1. `--marionette-port` (first-class option, parsed at the CLI layer)
243
+ // 2. forwarded `--mach-arg --marionette-port=NNNN` /
244
+ // `--mach-arg --setpref=marionette.port=NNNN`
245
+ // 3. fall back to `DEFAULT_MARIONETTE_PORT` semantics inside the probes
246
+ // (passed as `undefined`).
247
+ // Without (2), an operator working around a stale listener via the
248
+ // documented `--mach-arg --marionette-port=NNNN` workaround would still
249
+ // hit the wrapper preflight refusing on 2828 before the forwarded arg
250
+ // ever reached mach.
251
+ const forwardedPort = options.machArg
252
+ ? extractForwardedMarionettePort(options.machArg)
253
+ : undefined;
254
+ const effectivePort = options.marionettePort ?? forwardedPort;
241
255
  // Stale-browser probe: an interrupted earlier test run can leave a
242
256
  // Firefox/ForgeFresh/Hominis instance listening on the Marionette
243
257
  // control port, which breaks the next mach test launch with a
@@ -246,7 +260,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
246
260
  // generic bind failure. 2026-04-21 eval (Finding #20): a stale
247
261
  // `-marionette` process from `fresh/` poisoned a later test run in
248
262
  // the sibling `hominis/` workspace.
249
- await assertMarionettePortAvailable(undefined, { binaryName: projectConfig.binaryName });
263
+ await assertMarionettePortAvailable(effectivePort, { binaryName: projectConfig.binaryName });
250
264
  // `--doctor` runs a short marionette handshake probe. When test paths are
251
265
  // supplied the probe gates the mach test invocation (a FAIL bails out). When
252
266
  // no paths are supplied this is the only step — it's the fastest way to tell
@@ -259,7 +273,9 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
259
273
  // clack box-drawing framing.
260
274
  process.stdout.write('Running marionette preflight...\n');
261
275
  info('Running marionette preflight...');
262
- const preflight = await runMarionettePreflight(paths.engine);
276
+ const preflight = effectivePort !== undefined
277
+ ? await runMarionettePreflight(paths.engine, { port: effectivePort })
278
+ : await runMarionettePreflight(paths.engine);
263
279
  // 2026-04-24 eval Finding 7: the pre-0.18.1 code used
264
280
  // `success()` + `outro()` + a direct `process.stdout.write` as a
265
281
  // belt-and-suspenders but still reproducibly dropped the PASS summary
@@ -303,6 +319,30 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
303
319
  if (options.machArg && options.machArg.length > 0) {
304
320
  extraArgs.push(...options.machArg);
305
321
  }
322
+ // Auto-forward the Marionette port to mach when `--marionette-port` is
323
+ // set. We use `--setpref=marionette.port=<n>` because the marionette
324
+ // listener reads that pref before binding (browser-chrome / mochitest
325
+ // path); xpcshell never reads it, so the pref is a no-op there.
326
+ //
327
+ // Skip forwarding when the operator already supplied an equivalent arg
328
+ // via `--mach-arg` — duplicates would be confusing without changing
329
+ // semantics. Skip with a notice for clearly-non-marionette flavours
330
+ // (xpcshell, or paths that don't look browser-chrome/mochitest) so the
331
+ // operator knows the preflight took the override but mach was not
332
+ // auto-configured. Same escape valve applies: any mach arg can still
333
+ // be supplied via `--mach-arg`.
334
+ if (options.marionettePort !== undefined) {
335
+ const operatorAlreadyForwarded = forwardedPort !== undefined;
336
+ if (operatorAlreadyForwarded) {
337
+ info(`--marionette-port=${options.marionettePort} set, but the same port is already forwarded via --mach-arg; skipping auto-forward.`);
338
+ }
339
+ else if (isMarionetteFlavor(normalizedPaths, options.machArg ?? [])) {
340
+ extraArgs.push(`--setpref=marionette.port=${options.marionettePort}`);
341
+ }
342
+ else {
343
+ info(`--marionette-port=${options.marionettePort} applied to the preflight probe, but the test paths do not look browser-chrome/mochitest — mach is not auto-configured. Pass --mach-arg --setpref=marionette.port=${options.marionettePort} explicitly if mach should also use this port.`);
344
+ }
345
+ }
306
346
  // xpcshell appdir auto-injection — see src/core/xpcshell-appdir.ts for the
307
347
  // full motivation. On rebranded forks (appname != "firefox") the upstream
308
348
  // harness silently ignores `firefox-appdir = "browser"` directives in the
@@ -368,6 +408,13 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
368
408
  acc.push(value);
369
409
  return acc;
370
410
  }, [])
411
+ .option('--marionette-port <port>', 'Override the Marionette control port (default 2828) for the stale-browser probe, the --doctor preflight, and the auto-forwarded --setpref=marionette.port=<n> arg passed to mach. Use this when a stale process holds 2828 or a CI runner reserves a different port.', (raw) => {
412
+ const n = Number.parseInt(raw, 10);
413
+ if (!Number.isFinite(n) || n < 1 || n > 65535) {
414
+ throw new GeneralError(`--marionette-port must be an integer in 1..65535 (got "${raw}")`);
415
+ }
416
+ return n;
417
+ })
371
418
  .action(withErrorHandling(async (paths, options) => {
372
419
  await testCommand(getProjectRoot(), paths, pickDefined(options));
373
420
  }));
@@ -15,8 +15,12 @@ export type AcornESTreeNode<T extends estree.Node = estree.Node> = T & {
15
15
  * Parse JavaScript source as a **script** (not an ES module).
16
16
  * All Mozilla chrome JS files (`browser-main.js`, `browser-init.js`,
17
17
  * `customElements.js`, etc.) are scripts that run in a privileged scope.
18
+ *
19
+ * @param content - Source text to parse
20
+ * @param onComment - Optional array that acorn fills with comment nodes
21
+ * @returns Parsed program AST with character-offset positions
18
22
  */
19
- export declare function parseScript(content: string): AcornESTreeNode<estree.Program>;
23
+ export declare function parseScript(content: string, onComment?: acorn.Comment[]): AcornESTreeNode<estree.Program>;
20
24
  /**
21
25
  * Parse JavaScript source as an **ES module**.
22
26
  * Used for `.sys.mjs` files which use static import/export syntax.