@hominis/fireforge 0.22.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 +15 -0
- package/dist/src/commands/lint.js +8 -2
- 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/re-export-files.js +5 -4
- package/dist/src/commands/test.js +93 -25
- package/dist/src/commands/verify.js +1 -1
- package/dist/src/core/branding.js +10 -11
- package/dist/src/core/git-diff.js +7 -3
- package/dist/src/core/marionette-port.js +4 -1
- 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 +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
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
|
+
|
|
11
|
+
## 0.23.0
|
|
12
|
+
|
|
13
|
+
- Improved xpcshell test argument filtering and mixed-harness diagnostics.
|
|
14
|
+
- Locked pre-test build phases and improved stale harness diagnostics.
|
|
15
|
+
- Fixed binary-safe re-export for new untracked files.
|
|
16
|
+
- Improved additive `re-export --files` and lint warning guidance.
|
|
17
|
+
|
|
3
18
|
## 0.22.0
|
|
4
19
|
|
|
5
20
|
- Added `doctor --clear-resolution` with verify-backed safety checks.
|
|
@@ -161,6 +161,12 @@ async function resolveLintDiff(engineDir, files, binaryName, furnacePrefixes) {
|
|
|
161
161
|
}
|
|
162
162
|
return diff;
|
|
163
163
|
}
|
|
164
|
+
function buildMaxWarningsMessage(count, maxWarnings, scope) {
|
|
165
|
+
const scoped = scope ? ` ${scope}` : '';
|
|
166
|
+
const base = `Patch lint found ${count} warning(s)${scoped}, exceeding --max-warnings ${maxWarnings}.`;
|
|
167
|
+
return (base +
|
|
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
|
+
}
|
|
164
170
|
/**
|
|
165
171
|
* Filters aggregate-mode lint issues against per-patch `lintIgnore`
|
|
166
172
|
* lists drawn from the manifest. An issue is dropped when at least one
|
|
@@ -373,7 +379,7 @@ export async function lintCommand(projectRoot, files, options = {}) {
|
|
|
373
379
|
}
|
|
374
380
|
if (options.maxWarnings !== undefined && warnings.length > options.maxWarnings) {
|
|
375
381
|
outro('Lint failed');
|
|
376
|
-
throw new GeneralError(
|
|
382
|
+
throw new GeneralError(buildMaxWarningsMessage(warnings.length, options.maxWarnings));
|
|
377
383
|
}
|
|
378
384
|
// Notices are advisory and don't count as warnings — emitting "passed
|
|
379
385
|
// with warnings" when only notices fired contradicts the preceding
|
|
@@ -493,7 +499,7 @@ async function lintPerPatch(projectRoot, paths, options = {}) {
|
|
|
493
499
|
}
|
|
494
500
|
if (options.maxWarnings !== undefined && warnings.length > options.maxWarnings) {
|
|
495
501
|
outro('Lint failed');
|
|
496
|
-
throw new GeneralError(
|
|
502
|
+
throw new GeneralError(buildMaxWarningsMessage(warnings.length, options.maxWarnings, `across ${linted} patch(es)`));
|
|
497
503
|
}
|
|
498
504
|
if (warnings.length > 0) {
|
|
499
505
|
outro('Lint passed with warnings');
|
|
@@ -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
|
|
@@ -175,9 +175,10 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
175
175
|
forceUnsafe: options.forceUnsafe === true,
|
|
176
176
|
});
|
|
177
177
|
}
|
|
178
|
-
// Shrinks are destructive (previously-owned files become unmanaged)
|
|
179
|
-
//
|
|
180
|
-
//
|
|
178
|
+
// Shrinks are destructive (previously-owned files become unmanaged), so
|
|
179
|
+
// they keep the explicit confirmation gate. Additive-only scopes are safe
|
|
180
|
+
// to run non-interactively after lint/policy projection because no existing
|
|
181
|
+
// patch ownership is being dropped.
|
|
181
182
|
const summary = [
|
|
182
183
|
`re-export ${target.filename} with --files scope`,
|
|
183
184
|
`current files (${target.filesAffected.length}): ${target.filesAffected.join(', ') || '(none)'}`,
|
|
@@ -196,7 +197,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
196
197
|
operation: 're-export-files',
|
|
197
198
|
title: `Re-export ${target.filename} with --files`,
|
|
198
199
|
summary,
|
|
199
|
-
yes: options.yes === true,
|
|
200
|
+
yes: removed.length === 0 && missingFiles.length === 0 ? true : options.yes === true,
|
|
200
201
|
dryRun: isDryRun,
|
|
201
202
|
unsafeOverride: options.forceUnsafe === true,
|
|
202
203
|
conflicts,
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { prepareBuildEnvironment } from '../core/build-prepare.js';
|
|
4
4
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
5
|
-
import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, testWithOutput, } from '../core/mach.js';
|
|
5
|
+
import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, testWithOutput, withBuildLock, } from '../core/mach.js';
|
|
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 {
|
|
9
|
+
import { tryRepairStaleXpcshellTestSymlink } from '../core/test-stale-symlink.js';
|
|
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';
|
|
12
13
|
import { pathExists } from '../utils/fs.js';
|
|
@@ -36,6 +37,43 @@ function buildStaleBuildMessage() {
|
|
|
36
37
|
'The failing output referenced missing branding or distribution resources, which usually means the current obj-* build does not match recent engine or branding changes.\n\n' +
|
|
37
38
|
'Re-run "fireforge build --ui" or "fireforge test --build" and then retry.');
|
|
38
39
|
}
|
|
40
|
+
async function classifyTestHarnesses(engineDir, normalizedPaths) {
|
|
41
|
+
const result = { xpcshell: [], nonXpcshell: [] };
|
|
42
|
+
for (const testPath of normalizedPaths) {
|
|
43
|
+
const manifest = await findNearestXpcshellManifest(engineDir, testPath);
|
|
44
|
+
if (manifest) {
|
|
45
|
+
result.xpcshell.push(testPath);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
result.nonXpcshell.push(testPath);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
function buildMixedHarnessMessage(classification) {
|
|
54
|
+
return ('FireForge cannot run xpcshell and browser/mochitest paths in the same mach invocation.\n\n' +
|
|
55
|
+
'Split this into separate `fireforge test` commands so each manifest selects its own harness:\n' +
|
|
56
|
+
` - xpcshell: ${classification.xpcshell.join(', ')}\n` +
|
|
57
|
+
` - browser/mochitest: ${classification.nonXpcshell.join(', ')}`);
|
|
58
|
+
}
|
|
59
|
+
function filterRedundantXpcshellFlavorArgs(machArgs, classification) {
|
|
60
|
+
if (classification.xpcshell.length === 0 || classification.nonXpcshell.length > 0) {
|
|
61
|
+
return [...machArgs];
|
|
62
|
+
}
|
|
63
|
+
const filtered = [];
|
|
64
|
+
for (let i = 0; i < machArgs.length; i += 1) {
|
|
65
|
+
const arg = machArgs[i] ?? '';
|
|
66
|
+
if (/^--flavor=xpcshell(?:-tests)?$/.test(arg)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (arg === '--flavor' && /^xpcshell(?:-tests)?$/.test(machArgs[i + 1] ?? '')) {
|
|
70
|
+
i += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
filtered.push(arg);
|
|
74
|
+
}
|
|
75
|
+
return filtered;
|
|
76
|
+
}
|
|
39
77
|
function hasStaleBuildArtifactsSignal(output) {
|
|
40
78
|
// Deliberately narrow: only fire on branding-specific resource paths
|
|
41
79
|
// that are always a stale-artifact symptom. The earlier pattern also
|
|
@@ -101,6 +139,36 @@ function buildXpcshellAppdirMessage(injectionAttempted) {
|
|
|
101
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' +
|
|
102
140
|
' - If the test only touches toolkit chrome (chrome://global/*), drop the `firefox-appdir` setting entirely — toolkit chrome is registered without it.');
|
|
103
141
|
}
|
|
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.');
|
|
145
|
+
}
|
|
146
|
+
async function resolveLaunchablePathForTests(engineDir, binaryName, objDir) {
|
|
147
|
+
if (!objDir)
|
|
148
|
+
return undefined;
|
|
149
|
+
const bundleCheck = await hasRunnableBundle(engineDir, binaryName, objDir);
|
|
150
|
+
if (!bundleCheck.runnable) {
|
|
151
|
+
const expectedSuffix = bundleCheck.expectedPath
|
|
152
|
+
? ` (expected at engine/${bundleCheck.expectedPath})`
|
|
153
|
+
: '';
|
|
154
|
+
throw new GeneralError(`Tests require a complete launchable build${expectedSuffix}. ` +
|
|
155
|
+
'The obj-*/dist/ tree exists but the launchable binary is missing — typically the result of an interrupted or partially failed `fireforge build`.\n\n' +
|
|
156
|
+
'Run "fireforge build" again and let it finish before retrying "fireforge test".');
|
|
157
|
+
}
|
|
158
|
+
return bundleCheck.expectedPath;
|
|
159
|
+
}
|
|
160
|
+
async function runPreTestBuild(projectRoot, paths, projectConfig) {
|
|
161
|
+
await withBuildLock(projectRoot, async () => {
|
|
162
|
+
await prepareBuildEnvironment(projectRoot, paths, projectConfig);
|
|
163
|
+
const s = spinner('Running incremental build...');
|
|
164
|
+
const buildResult = await buildUI(paths.engine);
|
|
165
|
+
if (buildResult.exitCode !== 0) {
|
|
166
|
+
s.error('Pre-test build failed');
|
|
167
|
+
throw new BuildError('Pre-test build failed', 'mach build faster');
|
|
168
|
+
}
|
|
169
|
+
s.stop('Build complete');
|
|
170
|
+
});
|
|
171
|
+
}
|
|
104
172
|
// Detects the `AttributeError: 'MochitestDesktop' object has no attribute
|
|
105
173
|
// 'http3Server'` teardown crash. The attribute is lazy-initialized inside
|
|
106
174
|
// harness code paths that presume chrome://branding resolves correctly; a
|
|
@@ -153,6 +221,10 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
|
|
|
153
221
|
if (hasMochitestHttp3ServerSignal(combinedOutput)) {
|
|
154
222
|
throw new GeneralError(buildMochitestHttp3ServerMessage());
|
|
155
223
|
}
|
|
224
|
+
if (/FileExistsError/i.test(combinedOutput) &&
|
|
225
|
+
/(mochitest|xpcshell|_tests)/i.test(combinedOutput)) {
|
|
226
|
+
throw new GeneralError(buildHarnessSymlinkMessage());
|
|
227
|
+
}
|
|
156
228
|
if (/invalid filename/i.test(combinedOutput) ||
|
|
157
229
|
/chrome:\/\/mochitests.*not found/i.test(combinedOutput)) {
|
|
158
230
|
info('Hint: The test file may not be registered in browser.toml or jar.mn.');
|
|
@@ -201,29 +273,10 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
201
273
|
// here so `test --doctor` against an incomplete build surfaces the
|
|
202
274
|
// missing-bundle path instead of a cryptic `Browser process exited
|
|
203
275
|
// during spawn (exit code 1, signal none). stderr tail: (empty)`.
|
|
204
|
-
|
|
205
|
-
if (buildCheck.objDir) {
|
|
206
|
-
const bundleCheck = await hasRunnableBundle(paths.engine, projectConfig.binaryName, buildCheck.objDir);
|
|
207
|
-
launchablePath = bundleCheck.expectedPath;
|
|
208
|
-
if (!bundleCheck.runnable) {
|
|
209
|
-
const expectedSuffix = bundleCheck.expectedPath
|
|
210
|
-
? ` (expected at engine/${bundleCheck.expectedPath})`
|
|
211
|
-
: '';
|
|
212
|
-
throw new GeneralError(`Tests require a complete launchable build${expectedSuffix}. ` +
|
|
213
|
-
'The obj-*/dist/ tree exists but the launchable binary is missing — typically the result of an interrupted or partially failed `fireforge build`.\n\n' +
|
|
214
|
-
'Run "fireforge build" again and let it finish before retrying "fireforge test".');
|
|
215
|
-
}
|
|
216
|
-
}
|
|
276
|
+
const launchablePath = await resolveLaunchablePathForTests(paths.engine, projectConfig.binaryName, buildCheck.objDir);
|
|
217
277
|
// Run incremental build if requested
|
|
218
278
|
if (options.build) {
|
|
219
|
-
await
|
|
220
|
-
const s = spinner('Running incremental build...');
|
|
221
|
-
const buildResult = await buildUI(paths.engine);
|
|
222
|
-
if (buildResult.exitCode !== 0) {
|
|
223
|
-
s.error('Pre-test build failed');
|
|
224
|
-
throw new BuildError('Pre-test build failed', 'mach build faster');
|
|
225
|
-
}
|
|
226
|
-
s.stop('Build complete');
|
|
279
|
+
await runPreTestBuild(projectRoot, paths, projectConfig);
|
|
227
280
|
info('');
|
|
228
281
|
}
|
|
229
282
|
else {
|
|
@@ -309,6 +362,13 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
309
362
|
// previous case-insensitive + leading-whitespace-tolerant contract.
|
|
310
363
|
const normalizedPaths = testPaths.map((p) => stripEnginePrefix(p).trim());
|
|
311
364
|
await assertTestPathsExist(paths.engine, normalizedPaths);
|
|
365
|
+
const classification = await classifyTestHarnesses(paths.engine, normalizedPaths);
|
|
366
|
+
if (classification.xpcshell.length > 0 && classification.nonXpcshell.length > 0) {
|
|
367
|
+
throw new GeneralError(buildMixedHarnessMessage(classification));
|
|
368
|
+
}
|
|
369
|
+
const forwardedMachArgs = options.machArg && options.machArg.length > 0
|
|
370
|
+
? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
|
|
371
|
+
: [];
|
|
312
372
|
// Build extra args
|
|
313
373
|
const extraArgs = [];
|
|
314
374
|
if (options.headless) {
|
|
@@ -319,8 +379,8 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
319
379
|
// above for the motivating case). Appended AFTER --headless so mach sees
|
|
320
380
|
// the FireForge-managed flags first and the escape-valve ones last, which
|
|
321
381
|
// keeps the override precedence predictable.
|
|
322
|
-
if (
|
|
323
|
-
extraArgs.push(...
|
|
382
|
+
if (forwardedMachArgs.length > 0) {
|
|
383
|
+
extraArgs.push(...forwardedMachArgs);
|
|
324
384
|
}
|
|
325
385
|
// Auto-forward the Marionette port to mach when `--marionette-port` is
|
|
326
386
|
// set. `--setpref=marionette.port=<n>` configures where the browser
|
|
@@ -379,6 +439,14 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
379
439
|
catch (error) {
|
|
380
440
|
throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
|
|
381
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
|
+
}
|
|
382
450
|
handleNonZeroTestExit(result, normalizedPaths, appdirInjection, projectConfig.binaryName);
|
|
383
451
|
}
|
|
384
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.
|
|
@@ -8,7 +8,7 @@ import { pathExists, readText } from '../utils/fs.js';
|
|
|
8
8
|
import { verbose } from '../utils/logger.js';
|
|
9
9
|
import { exec } from '../utils/process.js';
|
|
10
10
|
import { ensureGit, git } from './git-base.js';
|
|
11
|
-
import { fileExistsInHead } from './git-file-ops.js';
|
|
11
|
+
import { fileExistsInHead, isBinaryFile } from './git-file-ops.js';
|
|
12
12
|
import { getUntrackedFiles, getUntrackedFilesInDir } from './git-status.js';
|
|
13
13
|
async function execGitWithAllowedExitCodes(repoDir, args, allowedExitCodes = [0]) {
|
|
14
14
|
const result = await exec('git', args, { cwd: repoDir });
|
|
@@ -174,7 +174,9 @@ export async function getAllDiff(repoDir) {
|
|
|
174
174
|
// Generate diffs for untracked files
|
|
175
175
|
const untrackedDiffs = [];
|
|
176
176
|
for (const file of untrackedFiles) {
|
|
177
|
-
const diff = await
|
|
177
|
+
const diff = (await isBinaryFile(repoDir, file))
|
|
178
|
+
? await generateBinaryFilePatch(repoDir, file)
|
|
179
|
+
: await generateNewFileDiff(repoDir, file);
|
|
178
180
|
untrackedDiffs.push(diff);
|
|
179
181
|
}
|
|
180
182
|
// Combine all diffs — each already ends with \n, so concatenate directly
|
|
@@ -247,7 +249,9 @@ export async function getDiffForFilesAgainstHead(repoDir, files) {
|
|
|
247
249
|
}
|
|
248
250
|
continue;
|
|
249
251
|
}
|
|
250
|
-
const diff = await
|
|
252
|
+
const diff = (await isBinaryFile(repoDir, file))
|
|
253
|
+
? await generateBinaryFilePatch(repoDir, file)
|
|
254
|
+
: await generateNewFileDiff(repoDir, file);
|
|
251
255
|
if (diff.trim()) {
|
|
252
256
|
diffs.push(diff);
|
|
253
257
|
}
|
|
@@ -291,9 +291,12 @@ export function forwardedMachArgsIncludeMarionetteClient(machArgs) {
|
|
|
291
291
|
* for runs where the pref is ignored anyway.
|
|
292
292
|
*/
|
|
293
293
|
export function hasExplicitXpcshellFlavor(machArgs) {
|
|
294
|
-
for (
|
|
294
|
+
for (let i = 0; i < machArgs.length; i += 1) {
|
|
295
|
+
const arg = machArgs[i] ?? '';
|
|
295
296
|
if (/^--flavor=xpcshell\b/.test(arg) || arg === '--flavor=xpcshell-tests')
|
|
296
297
|
return true;
|
|
298
|
+
if (arg === '--flavor' && /^xpcshell(?:-tests)?$/.test(machArgs[i + 1] ?? ''))
|
|
299
|
+
return true;
|
|
297
300
|
}
|
|
298
301
|
return false;
|
|
299
302
|
}
|
|
@@ -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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hominis/fireforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "FireForge — a build tool for customizing Firefox",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@vitest/coverage-v8": "^4.1.2",
|
|
67
67
|
"eslint": "^10.0.0",
|
|
68
68
|
"eslint-config-prettier": "^10.1.8",
|
|
69
|
-
"eslint-plugin-jsdoc": "^
|
|
69
|
+
"eslint-plugin-jsdoc": "^63.0.0",
|
|
70
70
|
"eslint-plugin-simple-import-sort": "^13.0.0",
|
|
71
71
|
"fast-check": "^4.6.0",
|
|
72
72
|
"husky": "^9.1.7",
|