@hominis/fireforge 0.23.0 → 0.25.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.25.0
4
+
5
+ - Kept `MOZ_APP_VENDOR` in `browser/moz.configure` for Firefox ESR 140 project-flag trees instead of generated branding `configure.sh`.
6
+ - Added a regression for stale xpcshell install symlink repair under shared `_tests/testing/mochitest/` harness paths.
7
+
8
+ ## 0.24.0
9
+
10
+ - Moved branding vendor identity into generated branding configure scripts and made `browser/moz.configure` vendor patching optional.
11
+ - Added metadata-backed staged forward-import declarations plus `patch staged-dependency` editing.
12
+ - Added stale xpcshell `_tests` symlink repair with a single safe retry.
13
+ - Added `patch move-files` for previewable ownership-transfer repair plans.
14
+ - Improved queue self-containment guidance for staged dependencies and patch repairs.
15
+
3
16
  ## 0.23.0
4
17
 
5
18
  - Improved xpcshell test argument filtering and mixed-harness diagnostics.
package/README.md CHANGED
@@ -20,7 +20,7 @@ Inspired by [fern.js](https://github.com/ghostery/user-agent-desktop) and [Melon
20
20
 
21
21
  ## Requirements
22
22
 
23
- - Node.js 20+
23
+ - Node.js 22.22.1+
24
24
  - Python 3
25
25
  - Git
26
26
  - The normal Firefox platform build tools: Xcode command line tools on macOS, `build-essential`-style packages on Linux, Visual Studio Build Tools on Windows (never tested on Windows tbh)
@@ -165,7 +165,7 @@ function buildMaxWarningsMessage(count, maxWarnings, scope) {
165
165
  const scoped = scope ? ` ${scope}` : '';
166
166
  const base = `Patch lint found ${count} warning(s)${scoped}, exceeding --max-warnings ${maxWarnings}.`;
167
167
  return (base +
168
- ' If this is a release gate and the warnings are historical patch-size advisories, run with --per-patch to identify the owning patch and split/re-export that patch, or add a scoped lintIgnore entry only after review.');
168
+ ' If this is a release gate, run with --per-patch to identify the owning patch. For intentional staged imports, use patch staged-dependency; for ownership repairs, preview patch move-files, patch reorder --dry-run, or re-export --files --dry-run; add scoped lintIgnore only after review.');
169
169
  }
170
170
  /**
171
171
  * Filters aggregate-mode lint issues against per-patch `lintIgnore`
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * `fireforge patch <verb>` parent command. Groups single-patch
3
- * mutations (`compact`, `delete`, `lint-ignore`, `rename`, `reorder`,
4
- * `tier`) so they do not clutter the top-level command list.
3
+ * mutations and planners (`compact`, `delete`, `lint-ignore`, `move-files`,
4
+ * `rename`, `reorder`, `staged-dependency`, `tier`) so they do not clutter the top-level command
5
+ * list.
5
6
  * Queue-level verbs like `lint`, `export`, `verify`, and `status` stay
6
7
  * flat.
7
8
  */
@@ -1,16 +1,19 @@
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`, `rename`, `reorder`,
5
- * `tier`) so they do not clutter the top-level command list.
4
+ * mutations and planners (`compact`, `delete`, `lint-ignore`, `move-files`,
5
+ * `rename`, `reorder`, `staged-dependency`, `tier`) so they do not clutter the top-level command
6
+ * list.
6
7
  * Queue-level verbs like `lint`, `export`, `verify`, and `status` stay
7
8
  * flat.
8
9
  */
9
10
  import { registerPatchCompact } from './compact.js';
10
11
  import { registerPatchDelete } from './delete.js';
11
12
  import { registerPatchLintIgnore } from './lint-ignore.js';
13
+ import { registerPatchMoveFiles } from './move-files.js';
12
14
  import { registerPatchRename } from './rename.js';
13
15
  import { registerPatchReorder } from './reorder.js';
16
+ import { registerPatchStagedDependency } from './staged-dependency.js';
14
17
  import { registerPatchTier } from './tier.js';
15
18
  /**
16
19
  * Registers the `patch` subcommand parent and its verbs on the CLI.
@@ -21,7 +24,7 @@ import { registerPatchTier } from './tier.js';
21
24
  export function registerPatch(program, context) {
22
25
  const patch = program
23
26
  .command('patch')
24
- .description('Manage individual patches in the queue (compact, delete, lint-ignore, rename, reorder, tier)')
27
+ .description('Manage individual patches in the queue (compact, delete, lint-ignore, move-files, rename, reorder, staged-dependency, tier)')
25
28
  // Match `fireforge furnace`'s no-args contract: print the group's help and
26
29
  // exit 0. Without this default action, commander routes `fireforge patch`
27
30
  // (no subcommand) through its own help-then-exit-1 path, so scripts that
@@ -34,8 +37,10 @@ export function registerPatch(program, context) {
34
37
  registerPatchCompact(patch, context);
35
38
  registerPatchDelete(patch, context);
36
39
  registerPatchLintIgnore(patch, context);
40
+ registerPatchMoveFiles(patch, context);
37
41
  registerPatchRename(patch, context);
38
42
  registerPatchReorder(patch, context);
43
+ registerPatchStagedDependency(patch, context);
39
44
  registerPatchTier(patch, context);
40
45
  }
41
46
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `fireforge patch move-files <from> <to>` previews the explicit
3
+ * re-export choreography needed to move file ownership between two patches.
4
+ */
5
+ import { Command } from 'commander';
6
+ import type { CommandContext } from '../../types/cli.js';
7
+ import type { PatchMoveFilesOptions } from '../../types/commands/index.js';
8
+ /**
9
+ * Builds and prints a safe, no-write file ownership move plan.
10
+ *
11
+ * @param projectRoot - Project root directory
12
+ * @param fromIdentifier - Patch filename, ordinal, or manifest name that currently owns the files
13
+ * @param toIdentifier - Patch filename, ordinal, or manifest name that should own the files
14
+ * @param options - Files to move and display mode
15
+ */
16
+ export declare function patchMoveFilesCommand(projectRoot: string, fromIdentifier: string, toIdentifier: string, options?: PatchMoveFilesOptions): Promise<void>;
17
+ /**
18
+ * Registers the preview-only `patch move-files` subcommand.
19
+ *
20
+ * @param parent - Parent `patch` command
21
+ * @param context - Shared CLI registration context
22
+ */
23
+ export declare function registerPatchMoveFiles(parent: Command, context: CommandContext): void;
@@ -0,0 +1,137 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge patch move-files <from> <to>` previews the explicit
4
+ * re-export choreography needed to move file ownership between two patches.
5
+ */
6
+ import { relative } from 'node:path';
7
+ import { getProjectPaths } from '../../core/config.js';
8
+ import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
9
+ import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
10
+ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
11
+ import { pathExists } from '../../utils/fs.js';
12
+ import { info, intro, note, outro, warn } from '../../utils/logger.js';
13
+ function collectOption(value, previous) {
14
+ previous.push(value);
15
+ return previous;
16
+ }
17
+ function normalizeFileList(files) {
18
+ const cleaned = (files ?? []).map((file) => file.trim()).filter((file) => file.length > 0);
19
+ return [...new Set(cleaned)].sort((left, right) => left.localeCompare(right));
20
+ }
21
+ function shellQuote(value) {
22
+ if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value))
23
+ return value;
24
+ return `'${value.replaceAll("'", "'\\''")}'`;
25
+ }
26
+ function formatFiles(files) {
27
+ return files.length === 0 ? '(none)' : files.map((file) => ` - ${file}`).join('\n');
28
+ }
29
+ function formatReExportCommand(identifier, files, extraFlags) {
30
+ return [
31
+ 'fireforge',
32
+ 're-export',
33
+ shellQuote(identifier),
34
+ '--files',
35
+ ...files.map(shellQuote),
36
+ ...extraFlags,
37
+ ].join(' ');
38
+ }
39
+ function assertPatchHasFiles(owner, files) {
40
+ const owned = new Set(owner.filesAffected);
41
+ const missing = files.filter((file) => !owned.has(file));
42
+ if (missing.length === 0)
43
+ return;
44
+ throw new InvalidArgumentError(`${owner.filename} does not currently own ${missing.length} requested file(s):\n${formatFiles(missing)}`, 'patch move-files');
45
+ }
46
+ function assertTargetDoesNotAlreadyOwnFiles(target, files) {
47
+ const owned = new Set(target.filesAffected);
48
+ const duplicates = files.filter((file) => owned.has(file));
49
+ if (duplicates.length === 0)
50
+ return;
51
+ throw new InvalidArgumentError(`${target.filename} already owns ${duplicates.length} requested file(s):\n${formatFiles(duplicates)}`, 'patch move-files');
52
+ }
53
+ function computeFileMovePlan(source, target, files) {
54
+ const moved = new Set(files);
55
+ return {
56
+ sourceAfter: source.filesAffected.filter((file) => !moved.has(file)).sort(),
57
+ targetAfter: [...new Set([...target.filesAffected, ...files])].sort(),
58
+ };
59
+ }
60
+ /**
61
+ * Builds and prints a safe, no-write file ownership move plan.
62
+ *
63
+ * @param projectRoot - Project root directory
64
+ * @param fromIdentifier - Patch filename, ordinal, or manifest name that currently owns the files
65
+ * @param toIdentifier - Patch filename, ordinal, or manifest name that should own the files
66
+ * @param options - Files to move and display mode
67
+ */
68
+ export async function patchMoveFilesCommand(projectRoot, fromIdentifier, toIdentifier, options = {}) {
69
+ intro('FireForge patch move-files');
70
+ if (fromIdentifier === toIdentifier) {
71
+ throw new InvalidArgumentError('Source and target patch identifiers must be different.', 'patch move-files');
72
+ }
73
+ const files = normalizeFileList(options.file);
74
+ if (files.length === 0) {
75
+ throw new InvalidArgumentError('Specify at least one --file path to move.', 'patch move-files');
76
+ }
77
+ const paths = getProjectPaths(projectRoot);
78
+ if (!(await pathExists(paths.patches))) {
79
+ throw new GeneralError('Patches directory not found.');
80
+ }
81
+ const manifest = await loadPatchesManifest(paths.patches);
82
+ if (!manifest || manifest.patches.length === 0) {
83
+ throw new GeneralError('No patches in manifest.');
84
+ }
85
+ const source = resolvePatchIdentifier(fromIdentifier, manifest.patches);
86
+ if (!source) {
87
+ throw new InvalidArgumentError(formatPatchNotFoundError(fromIdentifier, manifest.patches), fromIdentifier);
88
+ }
89
+ const target = resolvePatchIdentifier(toIdentifier, manifest.patches);
90
+ if (!target) {
91
+ throw new InvalidArgumentError(formatPatchNotFoundError(toIdentifier, manifest.patches), toIdentifier);
92
+ }
93
+ if (source.filename === target.filename) {
94
+ throw new InvalidArgumentError('Source and target resolved to the same patch.', 'patch move-files');
95
+ }
96
+ assertPatchHasFiles(source, files);
97
+ assertTargetDoesNotAlreadyOwnFiles(target, files);
98
+ const { sourceAfter, targetAfter } = computeFileMovePlan(source, target, files);
99
+ if (sourceAfter.length === 0) {
100
+ throw new InvalidArgumentError(`${source.filename} would have no filesAffected after this move. Delete or re-export that patch explicitly instead.`, 'patch move-files');
101
+ }
102
+ for (const file of files) {
103
+ if (!(await pathExists(`${paths.engine}/${file}`))) {
104
+ warn(`engine/${file} does not exist in the current worktree; re-export may drop or fail it.`);
105
+ }
106
+ }
107
+ const relativePatchesDir = relative(projectRoot, paths.patches) || 'patches';
108
+ info(`Planning ownership move in ${relativePatchesDir}/patches.json.`);
109
+ info(`Move ${files.length} file(s) from ${source.filename} to ${target.filename}:`);
110
+ note(formatFiles(files), 'Files');
111
+ note(formatFiles(sourceAfter), `${source.filename} files after move`);
112
+ note(formatFiles(targetAfter), `${target.filename} files after move`);
113
+ const dryRunSource = formatReExportCommand(source.filename, sourceAfter, ['--dry-run']);
114
+ const dryRunTarget = formatReExportCommand(target.filename, targetAfter, ['--dry-run']);
115
+ const applySource = formatReExportCommand(source.filename, sourceAfter, ['--yes']);
116
+ const applyTarget = formatReExportCommand(target.filename, targetAfter, []);
117
+ note(`${dryRunSource}\n${dryRunTarget}`, 'Preview commands');
118
+ note(`${applySource}\n${applyTarget}`, 'Apply commands');
119
+ outro('Move plan complete - no changes made');
120
+ }
121
+ /**
122
+ * Registers the preview-only `patch move-files` subcommand.
123
+ *
124
+ * @param parent - Parent `patch` command
125
+ * @param context - Shared CLI registration context
126
+ */
127
+ export function registerPatchMoveFiles(parent, context) {
128
+ const { getProjectRoot, withErrorHandling } = context;
129
+ parent
130
+ .command('move-files <from> <to>')
131
+ .description('Preview the re-export commands needed to move file ownership between patches.')
132
+ .option('--file <path>', 'File path relative to engine/ to move (repeatable)', collectOption, [])
133
+ .action(withErrorHandling(async (from, to, options) => {
134
+ await patchMoveFilesCommand(getProjectRoot(), from, to, options);
135
+ }));
136
+ }
137
+ //# sourceMappingURL=move-files.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `fireforge patch staged-dependency <name>` — edits
3
+ * PatchMetadata.stagedDependencies without rewriting the .patch body.
4
+ */
5
+ import { Command } from 'commander';
6
+ import type { CommandContext } from '../../types/cli.js';
7
+ import type { PatchStagedDependencyOptions, PatchStagedForwardImport } from '../../types/commands/index.js';
8
+ type StagedDependencyMode = 'add' | 'remove' | 'clear';
9
+ /**
10
+ * Renders a one-line summary of a staged-dependency metadata change.
11
+ */
12
+ export declare function describeStagedDependencyChange(before: readonly PatchStagedForwardImport[], after: readonly PatchStagedForwardImport[], mode: StagedDependencyMode, dependency: PatchStagedForwardImport | undefined): string;
13
+ /**
14
+ * Runs the metadata-only staged-dependency mutation command.
15
+ *
16
+ * @param projectRoot - Project root directory
17
+ * @param identifier - Patch filename, ordinal, or manifest name
18
+ * @param options - Mutation mode and forward-import fields
19
+ */
20
+ export declare function patchStagedDependencyCommand(projectRoot: string, identifier: string, options?: PatchStagedDependencyOptions): Promise<void>;
21
+ /**
22
+ * Registers the `patch staged-dependency` subcommand.
23
+ *
24
+ * @param parent - Parent `patch` command
25
+ * @param context - Shared CLI registration context
26
+ */
27
+ export declare function registerPatchStagedDependency(parent: Command, context: CommandContext): void;
28
+ export {};
@@ -0,0 +1,177 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge patch staged-dependency <name>` — edits
4
+ * PatchMetadata.stagedDependencies without rewriting the .patch body.
5
+ */
6
+ import { getProjectPaths } from '../../core/config.js';
7
+ import { appendHistory } from '../../core/destructive.js';
8
+ import { mutatePatchMetadata } from '../../core/patch-export.js';
9
+ import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
10
+ import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
11
+ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
12
+ import { toError } from '../../utils/errors.js';
13
+ import { pathExists } from '../../utils/fs.js';
14
+ import { info, intro, outro, warn } from '../../utils/logger.js';
15
+ function modeFromOptions(options) {
16
+ const adding = options.add === true;
17
+ const removing = options.remove === true;
18
+ const clearing = options.clear === true;
19
+ const modeCount = [adding, removing, clearing].filter(Boolean).length;
20
+ if (modeCount > 1) {
21
+ throw new InvalidArgumentError('--add, --remove, and --clear are mutually exclusive. Pick one mode per invocation.', 'patch staged-dependency');
22
+ }
23
+ if (modeCount === 0) {
24
+ throw new InvalidArgumentError('Specify --add, --remove, or --clear.', 'patch staged-dependency');
25
+ }
26
+ return adding ? 'add' : removing ? 'remove' : 'clear';
27
+ }
28
+ function requireForwardImportOptions(options, mode) {
29
+ if (!options.file || !options.specifier || !options.creates) {
30
+ throw new InvalidArgumentError(`--${mode} requires --file, --specifier, and --creates.`, 'patch staged-dependency');
31
+ }
32
+ const dependency = {
33
+ file: options.file,
34
+ specifier: options.specifier,
35
+ creates: options.creates,
36
+ };
37
+ if (options.owner !== undefined)
38
+ dependency.owner = options.owner;
39
+ if (options.reason !== undefined)
40
+ dependency.reason = options.reason;
41
+ return dependency;
42
+ }
43
+ function dependencyKey(dependency) {
44
+ return [dependency.file, dependency.specifier, dependency.creates, dependency.owner ?? ''].join('\0');
45
+ }
46
+ function dependencyLabel(dependency) {
47
+ const owner = dependency.owner ? ` owner=${dependency.owner}` : '';
48
+ const reason = dependency.reason ? ` reason="${dependency.reason}"` : '';
49
+ return `${dependency.file} imports "${dependency.specifier}" from ${dependency.creates}${owner}${reason}`;
50
+ }
51
+ function removeMatching(existing, target) {
52
+ return existing.filter((dependency) => dependency.file !== target.file ||
53
+ dependency.specifier !== target.specifier ||
54
+ dependency.creates !== target.creates ||
55
+ (target.owner !== undefined && dependency.owner !== target.owner));
56
+ }
57
+ function applyMode(existing, mode, dependency) {
58
+ if (mode === 'clear')
59
+ return [];
60
+ if (!dependency)
61
+ return [...existing];
62
+ if (mode === 'remove')
63
+ return removeMatching(existing, dependency);
64
+ const seen = new Set(existing.map(dependencyKey));
65
+ if (seen.has(dependencyKey(dependency)))
66
+ return [...existing];
67
+ return [...existing, dependency];
68
+ }
69
+ /**
70
+ * Renders a one-line summary of a staged-dependency metadata change.
71
+ */
72
+ export function describeStagedDependencyChange(before, after, mode, dependency) {
73
+ if (mode === 'clear') {
74
+ return before.length === 0
75
+ ? 'stagedDependencies was already empty — no change'
76
+ : `cleared ${before.length} staged forward-import declaration(s)`;
77
+ }
78
+ if (!dependency)
79
+ return 'stagedDependencies unchanged';
80
+ if (mode === 'add') {
81
+ return after.length === before.length
82
+ ? `staged forward-import already present: ${dependencyLabel(dependency)}`
83
+ : `added staged forward-import: ${dependencyLabel(dependency)}`;
84
+ }
85
+ return after.length === before.length
86
+ ? `no staged forward-import matched: ${dependencyLabel(dependency)}`
87
+ : `removed ${before.length - after.length} staged forward-import declaration(s): ${dependencyLabel(dependency)}`;
88
+ }
89
+ /**
90
+ * Runs the metadata-only staged-dependency mutation command.
91
+ *
92
+ * @param projectRoot - Project root directory
93
+ * @param identifier - Patch filename, ordinal, or manifest name
94
+ * @param options - Mutation mode and forward-import fields
95
+ */
96
+ export async function patchStagedDependencyCommand(projectRoot, identifier, options = {}) {
97
+ const isDryRun = options.dryRun === true;
98
+ intro(isDryRun ? 'FireForge patch staged-dependency (dry run)' : 'FireForge patch staged-dependency');
99
+ const mode = modeFromOptions(options);
100
+ const dependency = mode === 'clear' ? undefined : requireForwardImportOptions(options, mode);
101
+ const paths = getProjectPaths(projectRoot);
102
+ if (!(await pathExists(paths.patches))) {
103
+ throw new GeneralError('Patches directory not found.');
104
+ }
105
+ const manifest = await loadPatchesManifest(paths.patches);
106
+ if (!manifest || manifest.patches.length === 0) {
107
+ throw new GeneralError('No patches in manifest.');
108
+ }
109
+ const target = resolvePatchIdentifier(identifier, manifest.patches);
110
+ if (!target) {
111
+ throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
112
+ }
113
+ const existing = target.stagedDependencies?.forwardImports ?? [];
114
+ const projected = applyMode(existing, mode, dependency);
115
+ const summary = describeStagedDependencyChange(existing, projected, mode, dependency);
116
+ if (isDryRun) {
117
+ info(`[dry-run] ${target.filename}: ${summary}.`);
118
+ outro('Dry run complete — no changes made');
119
+ return;
120
+ }
121
+ const result = await mutatePatchMetadata(paths.patches, target.filename, (current) => {
122
+ const before = current.stagedDependencies?.forwardImports ?? [];
123
+ const after = applyMode(before, mode, dependency);
124
+ if (after.length === 0)
125
+ return { unset: ['stagedDependencies'] };
126
+ return { set: { stagedDependencies: { forwardImports: after } } };
127
+ });
128
+ if (!result) {
129
+ throw new GeneralError(`Patch ${target.filename} disappeared from the manifest during the update. Re-run after investigating.`);
130
+ }
131
+ const before = result.before.stagedDependencies?.forwardImports ?? [];
132
+ const after = result.after.stagedDependencies?.forwardImports ?? [];
133
+ info(`${target.filename}: ${describeStagedDependencyChange(before, after, mode, dependency)}.`);
134
+ try {
135
+ await appendHistory(paths.patches, {
136
+ operation: 'patch-staged-dependency',
137
+ args: {
138
+ filename: target.filename,
139
+ mode,
140
+ before,
141
+ after,
142
+ },
143
+ ...(options.yes === true ? { yes: true } : {}),
144
+ result: 'ok',
145
+ });
146
+ }
147
+ catch (historyError) {
148
+ warn(`History log append failed after patch staged-dependency committed (${target.filename}): ${toError(historyError).message}`);
149
+ }
150
+ outro('Patch staged-dependency complete');
151
+ }
152
+ /**
153
+ * Registers the `patch staged-dependency` subcommand.
154
+ *
155
+ * @param parent - Parent `patch` command
156
+ * @param context - Shared CLI registration context
157
+ */
158
+ export function registerPatchStagedDependency(parent, context) {
159
+ const { getProjectRoot, withErrorHandling } = context;
160
+ parent
161
+ .command('staged-dependency <name>')
162
+ .description('Edit PatchMetadata.stagedDependencies on a single patch (no .patch body rewrite).')
163
+ .option('--add', 'Add a staged forward-import declaration')
164
+ .option('--remove', 'Remove matching staged forward-import declaration(s)')
165
+ .option('--clear', 'Drop the stagedDependencies field entirely')
166
+ .option('--file <path>', 'Importing file path relative to engine/')
167
+ .option('--specifier <specifier>', 'Exact import specifier as it appears in source')
168
+ .option('--creates <path>', 'Later-created file path relative to engine/')
169
+ .option('--owner <patch>', 'Exact later patch filename expected to create --creates')
170
+ .option('--reason <text>', 'Human-readable rationale stored with the declaration')
171
+ .option('--dry-run', 'Show what would change without writing')
172
+ .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
173
+ .action(withErrorHandling(async (name, options) => {
174
+ await patchStagedDependencyCommand(getProjectRoot(), name, options);
175
+ }));
176
+ }
177
+ //# sourceMappingURL=staged-dependency.js.map
@@ -6,6 +6,7 @@ import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBu
6
6
  import { assertMarionettePortAvailable, extractForwardedMarionettePort, forwardedMachArgsIncludeMarionetteClient, shouldAutoForwardMarionettePortToMach, } 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
+ import { tryRepairStaleXpcshellTestSymlink } from '../core/test-stale-symlink.js';
9
10
  import { findNearestXpcshellManifest, operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
10
11
  import { GeneralError } from '../errors/base.js';
11
12
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
@@ -138,9 +139,9 @@ function buildXpcshellAppdirMessage(injectionAttempted) {
138
139
  ' - Remove `firefox-appdir = "browser"` from the xpcshell.toml [DEFAULT] and move browser-chrome dependencies into a browser-chrome mochitest (see `fireforge furnace create --test-style=browser-chrome`).\n' +
139
140
  ' - If the test only touches toolkit chrome (chrome://global/*), drop the `firefox-appdir` setting entirely — toolkit chrome is registered without it.');
140
141
  }
141
- function buildMochitestSymlinkMessage() {
142
- return ('mach failed while preparing mochitest harness symlinks before the requested tests ran.\n\n' +
143
- 'This usually means the objdir contains stale harness setup from an earlier run. Re-run with `fireforge test --build` to refresh the harness state, or remove the stale mochitest symlink in the active obj-* directory before retrying.');
142
+ function buildHarnessSymlinkMessage() {
143
+ return ('mach failed while preparing test harness symlinks before the requested tests ran.\n\n' +
144
+ 'This usually means the objdir contains stale harness setup from an earlier run. Re-run with `fireforge test --build` to refresh the harness state, or remove the stale harness symlink in the active obj-* directory before retrying.');
144
145
  }
145
146
  async function resolveLaunchablePathForTests(engineDir, binaryName, objDir) {
146
147
  if (!objDir)
@@ -220,8 +221,9 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
220
221
  if (hasMochitestHttp3ServerSignal(combinedOutput)) {
221
222
  throw new GeneralError(buildMochitestHttp3ServerMessage());
222
223
  }
223
- if (/FileExistsError/i.test(combinedOutput) && /mochitest/i.test(combinedOutput)) {
224
- throw new GeneralError(buildMochitestSymlinkMessage());
224
+ if (/FileExistsError/i.test(combinedOutput) &&
225
+ /(mochitest|xpcshell|_tests)/i.test(combinedOutput)) {
226
+ throw new GeneralError(buildHarnessSymlinkMessage());
225
227
  }
226
228
  if (/invalid filename/i.test(combinedOutput) ||
227
229
  /chrome:\/\/mochitests.*not found/i.test(combinedOutput)) {
@@ -437,6 +439,14 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
437
439
  catch (error) {
438
440
  throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
439
441
  }
442
+ if (result.exitCode !== 0 &&
443
+ classification.xpcshell.length > 0 &&
444
+ classification.nonXpcshell.length === 0) {
445
+ const repaired = await tryRepairStaleXpcshellTestSymlink(paths.engine, buildCheck.objDir, `${result.stdout}\n${result.stderr}`);
446
+ if (repaired) {
447
+ result = await testWithOutput(paths.engine, normalizedPaths, extraArgs);
448
+ }
449
+ }
440
450
  handleNonZeroTestExit(result, normalizedPaths, appdirInjection, projectConfig.binaryName);
441
451
  }
442
452
  /**
@@ -223,7 +223,7 @@ export async function verifyCommand(projectRoot) {
223
223
  info(`\nVerify: ${health.errorCount} error(s), ${health.warningCount} warning(s)`);
224
224
  if (health.errorCount > 0) {
225
225
  outro('Verify failed');
226
- throw new GeneralError(`fireforge verify found ${health.errorCount} error(s). Fix these before running export/import/rebase.`);
226
+ throw new GeneralError(`fireforge verify found ${health.errorCount} error(s). Fix these before running export/import/rebase. Use "patch staged-dependency" for intentional staged imports, or preview "patch move-files" / "patch reorder --dry-run" / "re-export --files --dry-run" for ownership repairs.`);
227
227
  }
228
228
  outro('Verify passed with warnings');
229
229
  }
@@ -53,6 +53,7 @@ export class BrandingMozconfigMismatchError extends FireForgeError {
53
53
  'The mismatch is caught before mach builds because resolving the build against the wrong branding tree fails deep in moz.build with a confusing "path does not exist" message.');
54
54
  }
55
55
  }
56
+ const MOZ_APP_VENDOR_IMPLY_REGEX = /imply_option\("MOZ_APP_VENDOR",\s*"[^"]*"\)/;
56
57
  /**
57
58
  * Sets up the custom branding directory for the browser.
58
59
  *
@@ -76,28 +77,30 @@ export async function setupBranding(engineDir, config) {
76
77
  if (!(await pathExists(brandingDir))) {
77
78
  await copyDir(unofficialDir, brandingDir);
78
79
  }
80
+ const vendorPlacement = await resolveVendorPlacement(engineDir);
79
81
  // Create/update configure.sh with custom values
80
- await createConfigureScript(brandingDir, config);
82
+ await createConfigureScript(brandingDir, config, vendorPlacement);
81
83
  // Update localization files
82
84
  await updateBrandProperties(brandingDir, config);
83
85
  await updateBrandFtl(brandingDir, config);
84
86
  // Patch moz.configure for MOZ_APP_VENDOR
85
- await patchMozConfigure(engineDir, config);
87
+ await patchMozConfigure(engineDir, config, vendorPlacement);
86
88
  }
87
89
  /**
88
90
  * Creates the branding configure.sh script.
89
91
  */
90
- async function createConfigureScript(brandingDir, config) {
92
+ async function createConfigureScript(brandingDir, config, vendorPlacement) {
91
93
  const configureShPath = join(brandingDir, 'configure.sh');
92
- await writeTextIfChanged(configureShPath, buildConfigureScriptContent(config));
94
+ await writeTextIfChanged(configureShPath, buildConfigureScriptContent(config, vendorPlacement));
93
95
  }
94
- function buildConfigureScriptContent(config) {
96
+ function buildConfigureScriptContent(config, vendorPlacement) {
95
97
  const header = getLicenseHeader(config.license ?? DEFAULT_LICENSE, 'hash');
96
- return `${header}
97
-
98
- MOZ_APP_DISPLAYNAME="${escapeShellValue(config.name)}"
99
- MOZ_MACBUNDLE_ID="${escapeShellValue(config.appId)}"
100
- `;
98
+ const lines = [`MOZ_APP_DISPLAYNAME="${escapeShellValue(config.name)}"`];
99
+ if (vendorPlacement === 'branding-configure') {
100
+ lines.push(`MOZ_APP_VENDOR="${escapeShellValue(config.vendor)}"`);
101
+ }
102
+ lines.push(`MOZ_MACBUNDLE_ID="${escapeShellValue(config.appId)}"`);
103
+ return `${header}\n\n${lines.join('\n')}\n`;
101
104
  }
102
105
  /**
103
106
  * Updates the brand.properties localization file.
@@ -148,28 +151,67 @@ trademarkInfo = { " " }
148
151
  `;
149
152
  }
150
153
  /**
151
- * Patches browser/moz.configure to set custom vendor.
152
- *
153
- * Mozilla's build system requires MOZ_APP_VENDOR to be set via imply_option
154
- * in moz.configure, not through mozconfig.
154
+ * Patches browser/moz.configure to set custom vendor when the upstream
155
+ * configure surface owns MOZ_APP_VENDOR as a project flag. ESR 140 rejects
156
+ * branding configure.sh / confvars origins for that flag, so the value must
157
+ * come from imply_option.
155
158
  */
156
- async function patchMozConfigure(engineDir, config) {
159
+ async function patchMozConfigure(engineDir, config, vendorPlacement) {
160
+ if (vendorPlacement !== 'moz-configure') {
161
+ return;
162
+ }
157
163
  const mozConfigurePath = join(engineDir, 'browser', 'moz.configure');
158
164
  if (!(await pathExists(mozConfigurePath))) {
159
- throw new BrandingError(`browser/moz.configure not found at ${mozConfigurePath}`);
165
+ return;
160
166
  }
161
167
  let content = await readText(mozConfigurePath);
162
- // Replace MOZ_APP_VENDOR imply_option
163
- const vendorRegex = /imply_option\("MOZ_APP_VENDOR",\s*"[^"]*"\)/;
164
- if (!vendorRegex.test(content)) {
165
- throw new BrandingError('Could not find MOZ_APP_VENDOR imply_option in browser/moz.configure');
168
+ if (MOZ_APP_VENDOR_IMPLY_REGEX.test(content)) {
169
+ content = content.replace(MOZ_APP_VENDOR_IMPLY_REGEX, buildMozConfigureVendorLine(config));
170
+ }
171
+ else {
172
+ content = insertMozConfigureVendorLine(content, buildMozConfigureVendorLine(config));
166
173
  }
167
- content = content.replace(vendorRegex, buildMozConfigureVendorLine(config));
168
174
  await writeTextIfChanged(mozConfigurePath, content);
169
175
  }
170
176
  function buildMozConfigureVendorLine(config) {
171
177
  return `imply_option("MOZ_APP_VENDOR", "${escapeString(config.vendor)}")`;
172
178
  }
179
+ async function resolveVendorPlacement(engineDir) {
180
+ const mozConfigurePath = join(engineDir, 'browser', 'moz.configure');
181
+ const toolkitMozConfigurePath = join(engineDir, 'toolkit', 'moz.configure');
182
+ const browserMozConfigureExists = await pathExists(mozConfigurePath);
183
+ const browserMozConfigureContent = browserMozConfigureExists
184
+ ? await readText(mozConfigurePath)
185
+ : undefined;
186
+ if (browserMozConfigureContent !== undefined &&
187
+ MOZ_APP_VENDOR_IMPLY_REGEX.test(browserMozConfigureContent)) {
188
+ return 'moz-configure';
189
+ }
190
+ if (await toolkitMozConfigureUsesVendorProjectFlag(toolkitMozConfigurePath)) {
191
+ if (!browserMozConfigureExists) {
192
+ throw new BrandingError('Firefox toolkit configure declares MOZ_APP_VENDOR as a project_flag, but browser/moz.configure is missing, so FireForge cannot safely set the vendor identity.');
193
+ }
194
+ return 'moz-configure';
195
+ }
196
+ return 'branding-configure';
197
+ }
198
+ async function toolkitMozConfigureUsesVendorProjectFlag(filePath) {
199
+ if (!(await pathExists(filePath))) {
200
+ return false;
201
+ }
202
+ const content = await readText(filePath);
203
+ return /project_flag\(\s*(?:(?!\)\s*\n)[\s\S])*env\s*=\s*"MOZ_APP_VENDOR"/m.test(content);
204
+ }
205
+ function insertMozConfigureVendorLine(content, line) {
206
+ const includeRegex = /^include\((["'])\.\.\/toolkit\/moz\.configure\1\)\s*$/m;
207
+ const match = includeRegex.exec(content);
208
+ if (!match) {
209
+ return `${content.replace(/\s*$/, '')}\n\n${line}\n`;
210
+ }
211
+ const prefix = content.slice(0, match.index).replace(/\s*$/, '');
212
+ const suffix = content.slice(match.index);
213
+ return `${prefix}\n\n${line}\n${suffix}`;
214
+ }
173
215
  /**
174
216
  * Escapes a string for use in Python/configure file.
175
217
  */
@@ -223,12 +265,12 @@ export async function isBrandingSetup(engineDir, config) {
223
265
  const configureShPath = join(brandingDir, 'configure.sh');
224
266
  const propsPath = join(brandingDir, 'locales', 'en-US', 'brand.properties');
225
267
  const ftlPath = join(brandingDir, 'locales', 'en-US', 'brand.ftl');
226
- const mozConfigurePath = join(engineDir, 'browser', 'moz.configure');
227
268
  if (!(await pathExists(configureShPath))) {
228
269
  return false;
229
270
  }
271
+ const vendorPlacement = await resolveVendorPlacement(engineDir);
230
272
  const configureContent = await readText(configureShPath);
231
- if (configureContent !== buildConfigureScriptContent(config)) {
273
+ if (configureContent !== buildConfigureScriptContent(config, vendorPlacement)) {
232
274
  return false;
233
275
  }
234
276
  if (await pathExists(propsPath)) {
@@ -243,6 +285,10 @@ export async function isBrandingSetup(engineDir, config) {
243
285
  return false;
244
286
  }
245
287
  }
288
+ if (vendorPlacement === 'branding-configure') {
289
+ return configureContent.includes(`MOZ_APP_VENDOR="${escapeShellValue(config.vendor)}"`);
290
+ }
291
+ const mozConfigurePath = join(engineDir, 'browser', 'moz.configure');
246
292
  if (!(await pathExists(mozConfigurePath))) {
247
293
  return false;
248
294
  }
@@ -5,7 +5,7 @@ import type { PatchMetadata } from '../types/commands/index.js';
5
5
  /**
6
6
  * Optional `PatchMetadata` keys safe to clear via the helpers below.
7
7
  */
8
- export type ClearablePatchMetadataField = 'tier' | 'lintIgnore';
8
+ export type ClearablePatchMetadataField = 'tier' | 'lintIgnore' | 'stagedDependencies';
9
9
  /**
10
10
  * Updates metadata for a patch in the manifest.
11
11
  *
@@ -17,6 +17,9 @@ function applyMetadataUpdate(existing, updates, unset) {
17
17
  case 'lintIgnore':
18
18
  delete next.lintIgnore;
19
19
  break;
20
+ case 'stagedDependencies':
21
+ delete next.stagedDependencies;
22
+ break;
20
23
  }
21
24
  }
22
25
  return next;
@@ -327,6 +327,22 @@ export function findForwardImportIgnoreLines(source) {
327
327
  }
328
328
  return ignored;
329
329
  }
330
+ function stagedDependencyKey(entry, dependency) {
331
+ return [
332
+ entry.filename,
333
+ dependency.file,
334
+ dependency.specifier,
335
+ dependency.creates,
336
+ dependency.owner ?? '',
337
+ ].join('\0');
338
+ }
339
+ function findMatchingStagedDependency(entry, sitePath, specifier, laterOwners) {
340
+ const declarations = entry.metadata?.stagedDependencies?.forwardImports ?? [];
341
+ return declarations.find((dependency) => dependency.file === sitePath &&
342
+ dependency.specifier === specifier &&
343
+ laterOwners.some((owner) => owner.fullPath === dependency.creates &&
344
+ (dependency.owner === undefined || dependency.owner === owner.filename)));
345
+ }
330
346
  /**
331
347
  * Cross-patch lint rule: a patch imports a module that a later patch is
332
348
  * responsible for creating.
@@ -361,6 +377,7 @@ export function lintPatchQueueForwardImports(ctx) {
361
377
  }
362
378
  }
363
379
  const issues = [];
380
+ const usedStagedDeclarations = new Set();
364
381
  // Runs the forward-import check against one source site — either a file
365
382
  // the patch creates (`content` = full file) or a file the patch modifies
366
383
  // (`content` = concatenated added lines only). We deliberately scan added
@@ -389,6 +406,11 @@ export function lintPatchQueueForwardImports(ctx) {
389
406
  (owner.order === entry.order && owner.filename > entry.filename));
390
407
  if (laterOwners.length === 0)
391
408
  continue;
409
+ const stagedDependency = findMatchingStagedDependency(entry, sitePath, specifier, laterOwners);
410
+ if (stagedDependency) {
411
+ usedStagedDeclarations.add(stagedDependencyKey(entry, stagedDependency));
412
+ continue;
413
+ }
392
414
  const ownersSummary = laterOwners
393
415
  .map((o) => `${o.filename} (creates ${o.fullPath})`)
394
416
  .join(', ');
@@ -407,7 +429,8 @@ export function lintPatchQueueForwardImports(ctx) {
407
429
  message: `${sitePath} in ${entry.filename} imports "${specifier}", ` +
408
430
  `but the matching new file is created by a later patch: ${ownersSummary}. ` +
409
431
  'Reorder the patches so the dependency is created first, move the import ' +
410
- 'into the later patch, or mark the import with ' +
432
+ 'into the later patch, declare the intentional staged dependency with ' +
433
+ '"fireforge patch staged-dependency --add", or mark the import with ' +
411
434
  `"// ${FORWARD_IMPORT_IGNORE_MARKER}" if the basename collision is a false positive. ` +
412
435
  `Closest legal ordinal that satisfies this dependency: ${suggestedOrder}.`,
413
436
  severity: 'error',
@@ -420,6 +443,21 @@ export function lintPatchQueueForwardImports(ctx) {
420
443
  for (const [path, added] of entry.modifiedFileAdditions)
421
444
  checkSite(entry, path, added);
422
445
  }
446
+ for (const entry of ctx.entries) {
447
+ for (const dependency of entry.metadata?.stagedDependencies?.forwardImports ?? []) {
448
+ if (usedStagedDeclarations.has(stagedDependencyKey(entry, dependency)))
449
+ continue;
450
+ issues.push({
451
+ file: dependency.file,
452
+ check: 'staged-dependency-unused',
453
+ fingerprint: `staged-dependency-unused|${entry.filename}|${dependency.file}|${dependency.specifier}|${dependency.creates}|${dependency.owner ?? ''}`,
454
+ message: `${entry.filename} declares a staged forward import from ${dependency.file} ` +
455
+ `to "${dependency.specifier}" / ${dependency.creates}, but that exact forward dependency was not found. ` +
456
+ 'Remove the stale declaration with "fireforge patch staged-dependency --remove" or update it to match the current queue.',
457
+ severity: 'warning',
458
+ });
459
+ }
460
+ }
423
461
  return issues;
424
462
  }
425
463
  /**
@@ -4,6 +4,35 @@
4
4
  */
5
5
  import { parseObject } from '../utils/parse.js';
6
6
  import { isArray, isObject, isValidFirefoxVersion, PATCH_CATEGORIES } from '../utils/validation.js';
7
+ function parseForwardImports(data, label) {
8
+ if (!isArray(data)) {
9
+ throw new Error(`${label} must be an array`);
10
+ }
11
+ return data.map((entry, index) => {
12
+ const rec = parseObject(entry, `${label}[${index}]`);
13
+ const dependency = {
14
+ file: rec.string('file'),
15
+ specifier: rec.string('specifier'),
16
+ creates: rec.string('creates'),
17
+ };
18
+ const owner = rec.optionalString('owner');
19
+ if (owner !== undefined)
20
+ dependency.owner = owner;
21
+ const reason = rec.optionalString('reason');
22
+ if (reason !== undefined)
23
+ dependency.reason = reason;
24
+ return dependency;
25
+ });
26
+ }
27
+ function parseStagedDependencies(data, label) {
28
+ const rec = parseObject(data, label);
29
+ const rawForwardImports = rec.raw('forwardImports');
30
+ const staged = {};
31
+ if (rawForwardImports !== undefined) {
32
+ staged.forwardImports = parseForwardImports(rawForwardImports, `${label}.forwardImports`);
33
+ }
34
+ return staged;
35
+ }
7
36
  /**
8
37
  * Validates a single patch metadata entry from raw data.
9
38
  * @param data - Raw data to validate
@@ -56,6 +85,10 @@ export function validatePatchMetadata(data, index) {
56
85
  result.lintIgnore = lintIgnore;
57
86
  if (tier !== undefined)
58
87
  result.tier = tier;
88
+ const rawStagedDependencies = rec.raw('stagedDependencies');
89
+ if (rawStagedDependencies !== undefined) {
90
+ result.stagedDependencies = parseStagedDependencies(rawStagedDependencies, `patches[${index}].stagedDependencies`);
91
+ }
59
92
  return result;
60
93
  }
61
94
  /** Validates raw patches.json data and returns the typed manifest shape. */
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Safe stale test-harness symlink repair for xpcshell setup failures.
3
+ */
4
+ /**
5
+ * Removes one stale xpcshell `_tests` symlink when mach reports a
6
+ * FileExistsError during test installation.
7
+ *
8
+ * The guard rails are deliberately narrow: only quoted FileExistsError
9
+ * destinations inside the active objdir's `_tests` tree are considered, and
10
+ * only when lstat confirms the destination itself is a symlink.
11
+ */
12
+ export declare function tryRepairStaleXpcshellTestSymlink(engineDir: string, objDir: string | undefined, output: string): Promise<boolean>;
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Safe stale test-harness symlink repair for xpcshell setup failures.
4
+ */
5
+ import { isAbsolute, relative, resolve } from 'node:path';
6
+ import { isSymlink, removeFile } from '../utils/fs.js';
7
+ import { warn } from '../utils/logger.js';
8
+ function extractFileExistsDestination(output) {
9
+ const match = /FileExistsError[^\n]*File exists:\s+(?:(?:'[^']*'|"[^"]*")\s*->\s*)?['"]([^'"]+)['"]/i.exec(output);
10
+ return match?.[1];
11
+ }
12
+ function isInsideDirectory(parent, candidate) {
13
+ const rel = relative(parent, candidate);
14
+ return rel.length === 0 || (!rel.startsWith('..') && !isAbsolute(rel));
15
+ }
16
+ /**
17
+ * Removes one stale xpcshell `_tests` symlink when mach reports a
18
+ * FileExistsError during test installation.
19
+ *
20
+ * The guard rails are deliberately narrow: only quoted FileExistsError
21
+ * destinations inside the active objdir's `_tests` tree are considered, and
22
+ * only when lstat confirms the destination itself is a symlink.
23
+ */
24
+ export async function tryRepairStaleXpcshellTestSymlink(engineDir, objDir, output) {
25
+ if (!objDir || !/FileExistsError/i.test(output))
26
+ return false;
27
+ const destination = extractFileExistsDestination(output);
28
+ if (!destination)
29
+ return false;
30
+ const resolvedDestination = resolve(destination);
31
+ const testsRoot = resolve(engineDir, objDir, '_tests');
32
+ if (!isInsideDirectory(testsRoot, resolvedDestination))
33
+ return false;
34
+ if (!(await isSymlink(resolvedDestination)))
35
+ return false;
36
+ await removeFile(resolvedDestination);
37
+ warn(`Removed stale xpcshell harness symlink under ${objDir}/_tests and retrying mach test once: ${relative(resolve(engineDir), resolvedDestination)}`);
38
+ return true;
39
+ }
40
+ //# sourceMappingURL=test-stale-symlink.js.map
@@ -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, PatchRenameOptions, PatchReorderOptions, PatchTierOptions, RebaseOptions, ReExportOptions, RegisterOptions, ResetOptions, RunOptions, SetupOptions, StatusOptions, TestOptions, TokenAddOptions, WireOptions, } from './options.js';
5
- export type { ImportSummary, PatchCategory, PatchesManifest, PatchInfo, PatchLintIssue, PatchMetadata, PatchResult, } from './patches.js';
4
+ export type { BuildOptions, DiscardOptions, DoctorOptions, DownloadOptions, ExportOptions, FurnaceApplyOptions, FurnaceCreateOptions, FurnaceDeployOptions, FurnaceOverrideOptions, FurnacePreviewOptions, FurnaceRefreshOptions, FurnaceRemoveOptions, FurnaceSyncOptions, FurnaceValidateOptions, GlobalOptions, ImportOptions, PackageOptions, PatchCompactOptions, PatchDeleteOptions, PatchLintIgnoreOptions, PatchMoveFilesOptions, PatchRenameOptions, PatchReorderOptions, PatchStagedDependencyOptions, PatchTierOptions, RebaseOptions, ReExportOptions, RegisterOptions, ResetOptions, RunOptions, SetupOptions, StatusOptions, TestOptions, TokenAddOptions, WireOptions, } from './options.js';
5
+ export type { ImportSummary, PatchCategory, PatchesManifest, PatchInfo, PatchLintIssue, PatchMetadata, PatchResult, PatchStagedDependencies, PatchStagedForwardImport, } from './patches.js';
6
6
  export type { DoctorCheck, ProjectStatus, TokenCoverageFileEntry, TokenCoverageReport, } from './project.js';
@@ -240,6 +240,42 @@ export interface PatchLintIgnoreOptions {
240
240
  /** Skip the confirmation prompt (required for non-TTY). */
241
241
  yes?: boolean;
242
242
  }
243
+ /**
244
+ * Options for the `fireforge patch staged-dependency` subcommand. Modes are
245
+ * mutually exclusive: add a declaration, remove one or more matching
246
+ * declarations, or clear all staged dependencies from the patch.
247
+ */
248
+ export interface PatchStagedDependencyOptions {
249
+ /** Add a forward-import staged dependency. */
250
+ add?: boolean;
251
+ /** Remove matching forward-import staged dependency declarations. */
252
+ remove?: boolean;
253
+ /** Drop the stagedDependencies field entirely. */
254
+ clear?: boolean;
255
+ /** Importing file path relative to engine/. */
256
+ file?: string;
257
+ /** Exact import specifier as it appears in source. */
258
+ specifier?: string;
259
+ /** Later-created file path relative to engine/. */
260
+ creates?: string;
261
+ /** Optional exact patch filename expected to create `creates`. */
262
+ owner?: string;
263
+ /** Optional human-readable rationale stored with the declaration. */
264
+ reason?: string;
265
+ /** Print the planned change without writing. */
266
+ dryRun?: boolean;
267
+ /** Skip the confirmation prompt (required for non-TTY). */
268
+ yes?: boolean;
269
+ }
270
+ /**
271
+ * Options for the preview-only `fireforge patch move-files` subcommand.
272
+ * It validates an ownership transfer and prints the explicit
273
+ * `re-export --files` commands needed to perform it.
274
+ */
275
+ export interface PatchMoveFilesOptions {
276
+ /** File paths relative to engine/ to move from the source patch to the target patch. */
277
+ file?: string[];
278
+ }
243
279
  /**
244
280
  * Options for the rebase command.
245
281
  */
@@ -97,6 +97,33 @@ export interface PatchMetadata {
97
97
  * rejected by the manifest validator, not silently stripped.
98
98
  */
99
99
  tier?: 'branding';
100
+ /**
101
+ * Optional declarations for intentional staged dependencies between
102
+ * patches. These are metadata-only escape hatches for cases where an
103
+ * early patch must import or register a helper created later in the
104
+ * queue during a staged migration. They keep tooling-specific markers
105
+ * out of Firefox source while remaining exact enough that unrelated
106
+ * forward imports still fail.
107
+ */
108
+ stagedDependencies?: PatchStagedDependencies;
109
+ }
110
+ /** Staged dependency metadata owned by a patch. */
111
+ export interface PatchStagedDependencies {
112
+ /** Exact forward-import declarations allowed for this patch. */
113
+ forwardImports?: PatchStagedForwardImport[];
114
+ }
115
+ /** A single intentional forward import to a later-created file. */
116
+ export interface PatchStagedForwardImport {
117
+ /** Importing file path relative to engine/. */
118
+ file: string;
119
+ /** Exact import specifier as it appears in source. */
120
+ specifier: string;
121
+ /** Later-created file path relative to engine/. */
122
+ creates: string;
123
+ /** Optional exact patch filename expected to create `creates`. */
124
+ owner?: string;
125
+ /** Optional human-readable rationale for the staged dependency. */
126
+ reason?: string;
100
127
  }
101
128
  /**
102
129
  * Schema for patches/patches.json file.
@@ -28,6 +28,13 @@ export declare function removeDir(path: string): Promise<void>;
28
28
  * @param path - File path to remove
29
29
  */
30
30
  export declare function removeFile(path: string): Promise<void>;
31
+ /**
32
+ * Returns true when a path exists and is a symbolic link.
33
+ *
34
+ * Uses lstat rather than stat so a symlink to a missing target is still
35
+ * classified as a symlink. That matters for cleanup of stale harness links.
36
+ */
37
+ export declare function isSymlink(path: string): Promise<boolean>;
31
38
  /**
32
39
  * Copies a file from source to destination.
33
40
  * Creates parent directories if needed.
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { randomUUID } from 'node:crypto';
3
- import { access, chmod, copyFile as fsCopyFile, mkdir, open, readdir, readFile, rename, rm, stat, statfs, } from 'node:fs/promises';
3
+ import { access, chmod, copyFile as fsCopyFile, lstat, mkdir, open, readdir, readFile, rename, rm, stat, statfs, } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  const RETRIABLE_REMOVE_ERRORS = new Set(['ENOTEMPTY', 'EBUSY', 'EPERM']);
6
6
  function sleep(ms) {
@@ -84,6 +84,21 @@ export async function removeDir(path) {
84
84
  export async function removeFile(path) {
85
85
  await rm(path, { force: true });
86
86
  }
87
+ /**
88
+ * Returns true when a path exists and is a symbolic link.
89
+ *
90
+ * Uses lstat rather than stat so a symlink to a missing target is still
91
+ * classified as a symlink. That matters for cleanup of stale harness links.
92
+ */
93
+ export async function isSymlink(path) {
94
+ try {
95
+ return (await lstat(path)).isSymbolicLink();
96
+ }
97
+ catch (error) {
98
+ void error;
99
+ return false;
100
+ }
101
+ }
87
102
  /**
88
103
  * Copies a file from source to destination.
89
104
  * Creates parent directories if needed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -79,7 +79,7 @@
79
79
  "vitest": "^4.0.18"
80
80
  },
81
81
  "engines": {
82
- "node": ">=20.0.0"
82
+ "node": ">=22.22.1"
83
83
  },
84
84
  "packageManager": "npm@11.12.1",
85
85
  "license": "EUPL-1.2",