@hominis/fireforge 0.23.0 → 0.24.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 +8 -0
- package/dist/src/commands/lint.js +1 -1
- package/dist/src/commands/patch/index.d.ts +3 -2
- package/dist/src/commands/patch/index.js +8 -3
- package/dist/src/commands/patch/move-files.d.ts +23 -0
- package/dist/src/commands/patch/move-files.js +137 -0
- package/dist/src/commands/patch/staged-dependency.d.ts +28 -0
- package/dist/src/commands/patch/staged-dependency.js +177 -0
- package/dist/src/commands/test.js +15 -5
- package/dist/src/commands/verify.js +1 -1
- package/dist/src/core/branding.js +10 -11
- package/dist/src/core/patch-export-metadata.d.ts +1 -1
- package/dist/src/core/patch-export-metadata.js +3 -0
- package/dist/src/core/patch-lint-cross.js +39 -1
- package/dist/src/core/patch-manifest-validate.js +33 -0
- package/dist/src/core/test-stale-symlink.d.ts +12 -0
- package/dist/src/core/test-stale-symlink.js +40 -0
- package/dist/src/types/commands/index.d.ts +2 -2
- package/dist/src/types/commands/options.d.ts +36 -0
- package/dist/src/types/commands/patches.d.ts +27 -0
- package/dist/src/utils/fs.d.ts +7 -0
- package/dist/src/utils/fs.js +16 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.24.0
|
|
4
|
+
|
|
5
|
+
- Moved branding vendor identity into generated branding configure scripts and made `browser/moz.configure` vendor patching optional.
|
|
6
|
+
- Added metadata-backed staged forward-import declarations plus `patch staged-dependency` editing.
|
|
7
|
+
- Added stale xpcshell `_tests` symlink repair with a single safe retry.
|
|
8
|
+
- Added `patch move-files` for previewable ownership-transfer repair plans.
|
|
9
|
+
- Improved queue self-containment guidance for staged dependencies and patch repairs.
|
|
10
|
+
|
|
3
11
|
## 0.23.0
|
|
4
12
|
|
|
5
13
|
- Improved xpcshell test argument filtering and mixed-harness diagnostics.
|
|
@@ -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
|
|
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`, `
|
|
4
|
-
* `tier`) so they do not clutter the top-level command
|
|
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`, `
|
|
5
|
-
* `tier`) so they do not clutter the top-level command
|
|
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
|
|
142
|
-
return ('mach failed while preparing
|
|
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
|
|
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) &&
|
|
224
|
-
|
|
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
|
}
|
|
@@ -96,6 +96,7 @@ function buildConfigureScriptContent(config) {
|
|
|
96
96
|
return `${header}
|
|
97
97
|
|
|
98
98
|
MOZ_APP_DISPLAYNAME="${escapeShellValue(config.name)}"
|
|
99
|
+
MOZ_APP_VENDOR="${escapeShellValue(config.vendor)}"
|
|
99
100
|
MOZ_MACBUNDLE_ID="${escapeShellValue(config.appId)}"
|
|
100
101
|
`;
|
|
101
102
|
}
|
|
@@ -148,21 +149,24 @@ trademarkInfo = { " " }
|
|
|
148
149
|
`;
|
|
149
150
|
}
|
|
150
151
|
/**
|
|
151
|
-
* Patches browser/moz.configure to set custom vendor
|
|
152
|
+
* Patches browser/moz.configure to set custom vendor when the upstream
|
|
153
|
+
* configure surface still owns an existing MOZ_APP_VENDOR imply_option.
|
|
152
154
|
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
+
* Newer FireForge branding writes MOZ_APP_VENDOR from the branding-owned
|
|
156
|
+
* configure.sh instead, so an absent browser/moz.configure line is valid.
|
|
157
|
+
* Keeping this best-effort replacement preserves compatibility for queues
|
|
158
|
+
* that still carry the older process-wide registration line.
|
|
155
159
|
*/
|
|
156
160
|
async function patchMozConfigure(engineDir, config) {
|
|
157
161
|
const mozConfigurePath = join(engineDir, 'browser', 'moz.configure');
|
|
158
162
|
if (!(await pathExists(mozConfigurePath))) {
|
|
159
|
-
|
|
163
|
+
return;
|
|
160
164
|
}
|
|
161
165
|
let content = await readText(mozConfigurePath);
|
|
162
166
|
// Replace MOZ_APP_VENDOR imply_option
|
|
163
167
|
const vendorRegex = /imply_option\("MOZ_APP_VENDOR",\s*"[^"]*"\)/;
|
|
164
168
|
if (!vendorRegex.test(content)) {
|
|
165
|
-
|
|
169
|
+
return;
|
|
166
170
|
}
|
|
167
171
|
content = content.replace(vendorRegex, buildMozConfigureVendorLine(config));
|
|
168
172
|
await writeTextIfChanged(mozConfigurePath, content);
|
|
@@ -223,7 +227,6 @@ export async function isBrandingSetup(engineDir, config) {
|
|
|
223
227
|
const configureShPath = join(brandingDir, 'configure.sh');
|
|
224
228
|
const propsPath = join(brandingDir, 'locales', 'en-US', 'brand.properties');
|
|
225
229
|
const ftlPath = join(brandingDir, 'locales', 'en-US', 'brand.ftl');
|
|
226
|
-
const mozConfigurePath = join(engineDir, 'browser', 'moz.configure');
|
|
227
230
|
if (!(await pathExists(configureShPath))) {
|
|
228
231
|
return false;
|
|
229
232
|
}
|
|
@@ -243,11 +246,7 @@ export async function isBrandingSetup(engineDir, config) {
|
|
|
243
246
|
return false;
|
|
244
247
|
}
|
|
245
248
|
}
|
|
246
|
-
|
|
247
|
-
return false;
|
|
248
|
-
}
|
|
249
|
-
const mozConfigureContent = await readText(mozConfigurePath);
|
|
250
|
-
return mozConfigureContent.includes(buildMozConfigureVendorLine(config));
|
|
249
|
+
return configureContent.includes(`MOZ_APP_VENDOR="${escapeShellValue(config.vendor)}"`);
|
|
251
250
|
}
|
|
252
251
|
/**
|
|
253
252
|
* Checks whether a file path belongs to the tool-managed branding directory.
|
|
@@ -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
|
*
|
|
@@ -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,
|
|
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.
|
package/dist/src/utils/fs.d.ts
CHANGED
|
@@ -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.
|
package/dist/src/utils/fs.js
CHANGED
|
@@ -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.
|