@hominis/fireforge 0.18.0 → 0.18.2
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 +18 -2
- package/README.md +55 -34
- package/dist/src/commands/doctor.js +13 -1
- package/dist/src/commands/export-all.js +63 -1
- package/dist/src/commands/export-flow.d.ts +4 -0
- package/dist/src/commands/export-flow.js +8 -0
- package/dist/src/commands/export.js +26 -2
- package/dist/src/commands/furnace/create-xpcshell.js +4 -2
- package/dist/src/commands/furnace/preview.js +38 -0
- package/dist/src/commands/furnace/remove.js +67 -1
- package/dist/src/commands/furnace/rename-xpcshell.d.ts +35 -0
- package/dist/src/commands/furnace/rename-xpcshell.js +97 -0
- package/dist/src/commands/furnace/rename.js +9 -0
- package/dist/src/commands/patch/index.d.ts +5 -3
- package/dist/src/commands/patch/index.js +10 -4
- package/dist/src/commands/patch/lint-ignore.d.ts +39 -0
- package/dist/src/commands/patch/lint-ignore.js +200 -0
- package/dist/src/commands/patch/tier.d.ts +34 -0
- package/dist/src/commands/patch/tier.js +134 -0
- package/dist/src/commands/re-export-files.js +88 -45
- package/dist/src/commands/re-export.js +49 -6
- package/dist/src/commands/rebase/index.js +19 -1
- package/dist/src/commands/status.js +44 -5
- package/dist/src/commands/test.js +27 -16
- package/dist/src/commands/verify.js +81 -6
- package/dist/src/commands/watch.js +43 -7
- package/dist/src/core/furnace-constants.d.ts +14 -0
- package/dist/src/core/furnace-constants.js +16 -0
- package/dist/src/core/furnace-validate.js +67 -1
- package/dist/src/core/git-base.d.ts +27 -2
- package/dist/src/core/git-base.js +41 -3
- package/dist/src/core/git-diff.js +34 -2
- package/dist/src/core/git.js +53 -14
- package/dist/src/core/mach.d.ts +14 -2
- package/dist/src/core/mach.js +12 -2
- package/dist/src/core/marionette-preflight.d.ts +16 -0
- package/dist/src/core/marionette-preflight.js +19 -0
- package/dist/src/core/patch-export.d.ts +77 -2
- package/dist/src/core/patch-export.js +82 -3
- package/dist/src/core/patch-lint-diff-tag.d.ts +20 -0
- package/dist/src/core/patch-lint-diff-tag.js +25 -0
- package/dist/src/core/patch-lint.js +82 -32
- package/dist/src/core/patch-registration-refs.d.ts +42 -0
- package/dist/src/core/patch-registration-refs.js +117 -0
- package/dist/src/core/xpcshell-appdir.d.ts +19 -5
- package/dist/src/core/xpcshell-appdir.js +46 -20
- package/dist/src/errors/git.d.ts +20 -0
- package/dist/src/errors/git.js +39 -0
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +67 -0
- package/dist/src/types/commands/patches.d.ts +6 -5
- package/package.json +1 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* `fireforge patch tier <name>` — sets or clears `PatchMetadata.tier` on
|
|
4
|
+
* a single patch without rewriting the `.patch` file body.
|
|
5
|
+
*
|
|
6
|
+
* Companion to `fireforge re-export <name> --tier <tier>`. Re-export is
|
|
7
|
+
* the right tool when the patch body itself needs to be regenerated; this
|
|
8
|
+
* subcommand exists for the metadata-only adjustment, where the operator
|
|
9
|
+
* has discovered (e.g. from a `lint --per-patch` warning) that the
|
|
10
|
+
* threshold-tier override should be set but the patch body is already
|
|
11
|
+
* correct. Avoiding the re-export saves the engine read + diff
|
|
12
|
+
* regeneration roundtrip and leaves the `.patch` file's mtime alone.
|
|
13
|
+
*
|
|
14
|
+
* Modes are mutually exclusive: exactly one of `--tier <branding>` or
|
|
15
|
+
* `--clear` must be supplied per invocation.
|
|
16
|
+
*/
|
|
17
|
+
import { Option } from 'commander';
|
|
18
|
+
import { getProjectPaths } from '../../core/config.js';
|
|
19
|
+
import { appendHistory } from '../../core/destructive.js';
|
|
20
|
+
import { updatePatchMetadata } from '../../core/patch-export.js';
|
|
21
|
+
import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
|
|
22
|
+
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
23
|
+
import { toError } from '../../utils/errors.js';
|
|
24
|
+
import { pathExists } from '../../utils/fs.js';
|
|
25
|
+
import { info, intro, outro, warn } from '../../utils/logger.js';
|
|
26
|
+
import { pickDefined } from '../../utils/options.js';
|
|
27
|
+
/**
|
|
28
|
+
* Runs the `patch tier` command: updates `PatchMetadata.tier` on the
|
|
29
|
+
* named patch (or clears the field) and writes the manifest.
|
|
30
|
+
*
|
|
31
|
+
* @param projectRoot - Project root directory
|
|
32
|
+
* @param identifier - Patch filename, ordinal, or manifest `name`
|
|
33
|
+
* @param options - Command options
|
|
34
|
+
*/
|
|
35
|
+
export async function patchTierCommand(projectRoot, identifier, options = {}) {
|
|
36
|
+
const isDryRun = options.dryRun === true;
|
|
37
|
+
intro(isDryRun ? 'FireForge patch tier (dry run)' : 'FireForge patch tier');
|
|
38
|
+
// Mode mutex: a single invocation either sets or clears the tier.
|
|
39
|
+
// Combining both is ambiguous — the operator's intent is not obvious
|
|
40
|
+
// and silently picking one would mask the typo.
|
|
41
|
+
const setting = options.tier !== undefined;
|
|
42
|
+
const clearing = options.clear === true;
|
|
43
|
+
if (setting && clearing) {
|
|
44
|
+
throw new InvalidArgumentError('--tier and --clear are mutually exclusive. Pick one mode per invocation.', 'patch tier');
|
|
45
|
+
}
|
|
46
|
+
if (!setting && !clearing) {
|
|
47
|
+
throw new InvalidArgumentError('Specify --tier <tier> to set the override, or --clear to remove it.', 'patch tier');
|
|
48
|
+
}
|
|
49
|
+
const paths = getProjectPaths(projectRoot);
|
|
50
|
+
if (!(await pathExists(paths.patches))) {
|
|
51
|
+
throw new GeneralError('Patches directory not found.');
|
|
52
|
+
}
|
|
53
|
+
const manifest = await loadPatchesManifest(paths.patches);
|
|
54
|
+
if (!manifest || manifest.patches.length === 0) {
|
|
55
|
+
throw new GeneralError('No patches in manifest.');
|
|
56
|
+
}
|
|
57
|
+
const target = resolvePatchIdentifier(identifier, manifest.patches);
|
|
58
|
+
if (!target) {
|
|
59
|
+
const available = manifest.patches
|
|
60
|
+
.map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
|
|
61
|
+
.join(', ');
|
|
62
|
+
throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
|
|
63
|
+
}
|
|
64
|
+
const before = target.tier;
|
|
65
|
+
const after = setting ? options.tier : undefined;
|
|
66
|
+
if (before === after) {
|
|
67
|
+
info(after === undefined
|
|
68
|
+
? `${target.filename}: tier is already absent — no change.`
|
|
69
|
+
: `${target.filename}: tier is already "${after}" — no change.`);
|
|
70
|
+
outro(isDryRun ? 'Dry run complete — no changes made' : 'Patch tier (no-op)');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const action = after === undefined
|
|
74
|
+
? `clear tier (was "${before}")`
|
|
75
|
+
: before === undefined
|
|
76
|
+
? `set tier to "${after}"`
|
|
77
|
+
: `change tier from "${before}" to "${after}"`;
|
|
78
|
+
if (isDryRun) {
|
|
79
|
+
info(`[dry-run] ${target.filename}: would ${action}.`);
|
|
80
|
+
outro('Dry run complete — no changes made');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Single write under the patch directory lock (delegated inside
|
|
84
|
+
// updatePatchMetadata). Setting routes through `updates`; clearing
|
|
85
|
+
// routes through `unsetFields` so TypeScript's exact optional types
|
|
86
|
+
// do not have to carry an explicit `undefined` on the `tier` field.
|
|
87
|
+
if (after !== undefined) {
|
|
88
|
+
await updatePatchMetadata(paths.patches, target.filename, { tier: after });
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
await updatePatchMetadata(paths.patches, target.filename, {}, ['tier']);
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
await appendHistory(paths.patches, {
|
|
95
|
+
operation: 'patch-tier',
|
|
96
|
+
args: {
|
|
97
|
+
filename: target.filename,
|
|
98
|
+
...(before !== undefined ? { before } : {}),
|
|
99
|
+
...(after !== undefined ? { after } : {}),
|
|
100
|
+
},
|
|
101
|
+
...(options.yes === true ? { yes: true } : {}),
|
|
102
|
+
result: 'ok',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (historyError) {
|
|
106
|
+
warn(`History log append failed after patch tier committed (${target.filename}): ${toError(historyError).message}`);
|
|
107
|
+
}
|
|
108
|
+
info(`${target.filename}: ${action}.`);
|
|
109
|
+
outro('Patch tier complete');
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Registers the `patch tier` subcommand on the `patch` parent.
|
|
113
|
+
*
|
|
114
|
+
* @param parent - Parent Commander command
|
|
115
|
+
* @param context - Shared CLI registration context
|
|
116
|
+
*/
|
|
117
|
+
export function registerPatchTier(parent, context) {
|
|
118
|
+
const { getProjectRoot, withErrorHandling } = context;
|
|
119
|
+
parent
|
|
120
|
+
.command('tier <name>')
|
|
121
|
+
.description('Set or clear PatchMetadata.tier on a single patch (no .patch body rewrite). Use --tier <branding> to set, --clear to remove.')
|
|
122
|
+
.addOption(new Option('--tier <tier>', 'Force the tier override on the patch (only "branding" recognised)').choices(['branding']))
|
|
123
|
+
.option('--clear', 'Remove the tier override (restores tier auto-detection)')
|
|
124
|
+
.option('--dry-run', 'Show what would change without writing')
|
|
125
|
+
.option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
|
|
126
|
+
.action(withErrorHandling(async (name, options) => {
|
|
127
|
+
const { tier, ...rest } = options;
|
|
128
|
+
await patchTierCommand(getProjectRoot(), name, {
|
|
129
|
+
...pickDefined(rest),
|
|
130
|
+
...(tier !== undefined ? { tier: tier } : {}),
|
|
131
|
+
});
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=tier.js.map
|
|
@@ -11,6 +11,85 @@ import { InvalidArgumentError } from '../errors/base.js';
|
|
|
11
11
|
import { pathExists } from '../utils/fs.js';
|
|
12
12
|
import { info, outro, success, warn } from '../utils/logger.js';
|
|
13
13
|
import { runPatchLint } from './export-shared.js';
|
|
14
|
+
/**
|
|
15
|
+
* Computes the effective `tier` and `lintIgnore` carrying both the
|
|
16
|
+
* patch's existing values and the CLI flag overrides. Pure helper —
|
|
17
|
+
* extracted from {@link reExportFilesInPlace} both to share with the
|
|
18
|
+
* standard re-export path conceptually and to keep the orchestrator
|
|
19
|
+
* function under the per-file LOC budget.
|
|
20
|
+
*
|
|
21
|
+
* Tier resolution: the CLI flag takes precedence; the patch's existing
|
|
22
|
+
* tier is the fallback. Lint-ignore resolution: union of the patch's
|
|
23
|
+
* existing list and the CLI flag values, de-duplicated; an empty
|
|
24
|
+
* result returns `undefined` so the caller can drop the field rather
|
|
25
|
+
* than write an empty array.
|
|
26
|
+
*/
|
|
27
|
+
function resolveEffectiveTierAndLintIgnore(target, options) {
|
|
28
|
+
const existingIgnoreSet = new Set(target.lintIgnore ?? []);
|
|
29
|
+
const flagIgnoreSet = new Set(options.lintIgnore ?? []);
|
|
30
|
+
const mergedIgnoreSet = new Set([...existingIgnoreSet, ...flagIgnoreSet]);
|
|
31
|
+
const effectiveLintIgnore = mergedIgnoreSet.size > 0 ? [...mergedIgnoreSet] : undefined;
|
|
32
|
+
const effectiveTier = options.tier ?? target.tier;
|
|
33
|
+
return { effectiveTier, effectiveLintIgnore, flagIgnoreSet };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Projects the cross-patch context (replace the target entry with its
|
|
37
|
+
* shrunken self), runs the patch-queue lint against the projection,
|
|
38
|
+
* and returns a conflict report only for regressions introduced *by*
|
|
39
|
+
* this shrink. Pre-existing cross-patch errors are surfaced as a
|
|
40
|
+
* non-blocking warning so the user does not walk away thinking the
|
|
41
|
+
* queue is clean. Extracted from {@link reExportFilesInPlace} to keep
|
|
42
|
+
* the orchestrator function under the per-file LOC budget.
|
|
43
|
+
*/
|
|
44
|
+
async function runProjectedCrossPatchLint(patchesDir, targetFilename, projectedDiff) {
|
|
45
|
+
const baseCtx = await buildPatchQueueContext(patchesDir);
|
|
46
|
+
const projectedNewFiles = new Map();
|
|
47
|
+
for (const path of detectNewFilesInDiff(projectedDiff)) {
|
|
48
|
+
projectedNewFiles.set(path, extractNewFileContentFromDiff(projectedDiff, path));
|
|
49
|
+
}
|
|
50
|
+
const projectedModifiedFileAdditions = buildModifiedFileAdditionsFromDiff(projectedDiff);
|
|
51
|
+
const projectedEntries = baseCtx.entries.map((entry) => {
|
|
52
|
+
if (entry.filename !== targetFilename)
|
|
53
|
+
return entry;
|
|
54
|
+
return {
|
|
55
|
+
...entry,
|
|
56
|
+
diff: projectedDiff,
|
|
57
|
+
newFiles: projectedNewFiles,
|
|
58
|
+
modifiedFileAdditions: projectedModifiedFileAdditions,
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
const baselineIssues = lintPatchQueue(baseCtx).filter((i) => i.severity === 'error');
|
|
62
|
+
const projectedIssues = lintPatchQueue({ entries: projectedEntries }).filter((i) => i.severity === 'error');
|
|
63
|
+
const regressions = computeProjectedLintRegressions(baselineIssues, projectedIssues);
|
|
64
|
+
if (baselineIssues.length > 0 && regressions.length === 0) {
|
|
65
|
+
warn(`Note: projected queue still has ${baselineIssues.length} pre-existing ` +
|
|
66
|
+
`cross-patch error(s) unrelated to this shrink. Run "fireforge verify" to list them.`);
|
|
67
|
+
}
|
|
68
|
+
if (regressions.length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
return {
|
|
71
|
+
reason: `projected --files state introduces ${regressions.length} new cross-patch lint error(s)`,
|
|
72
|
+
details: regressions.map((i) => `[${i.check}] ${i.file}: ${i.message}`),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Builds the `Partial<PatchMetadata>` payload for the `--files` write,
|
|
77
|
+
* folding in the CLI flag overrides for `tier` and `lintIgnore` only
|
|
78
|
+
* when the operator actually asked for them. Extracted to keep
|
|
79
|
+
* {@link reExportFilesInPlace} under the per-file LOC budget.
|
|
80
|
+
*/
|
|
81
|
+
function buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveLintIgnore, flagIgnoreSet) {
|
|
82
|
+
const updates = {
|
|
83
|
+
filesAffected: actualProjectedFiles,
|
|
84
|
+
};
|
|
85
|
+
if (options.tier !== undefined) {
|
|
86
|
+
updates.tier = options.tier;
|
|
87
|
+
}
|
|
88
|
+
if (effectiveLintIgnore !== undefined && flagIgnoreSet.size > 0) {
|
|
89
|
+
updates.lintIgnore = effectiveLintIgnore;
|
|
90
|
+
}
|
|
91
|
+
return updates;
|
|
92
|
+
}
|
|
14
93
|
/**
|
|
15
94
|
* Handles `re-export --files` end-to-end: computes the projected diff,
|
|
16
95
|
* runs the per-patch and cross-patch lint against a context in which the
|
|
@@ -77,50 +156,13 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
77
156
|
// have to choose between `--skip-lint` (blunt) and the full rebase path.
|
|
78
157
|
// `target.tier` threads the explicit branding-threshold opt-in for
|
|
79
158
|
// the branding patch that also touches a non-allowlisted sibling.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// real rebuild would see them.
|
|
88
|
-
const baseCtx = await buildPatchQueueContext(paths.patches);
|
|
89
|
-
const projectedNewFiles = new Map();
|
|
90
|
-
for (const path of detectNewFilesInDiff(projectedDiff)) {
|
|
91
|
-
projectedNewFiles.set(path, extractNewFileContentFromDiff(projectedDiff, path));
|
|
92
|
-
}
|
|
93
|
-
const projectedModifiedFileAdditions = buildModifiedFileAdditionsFromDiff(projectedDiff);
|
|
94
|
-
const projectedEntries = baseCtx.entries.map((entry) => {
|
|
95
|
-
if (entry.filename !== target.filename)
|
|
96
|
-
return entry;
|
|
97
|
-
return {
|
|
98
|
-
...entry,
|
|
99
|
-
diff: projectedDiff,
|
|
100
|
-
newFiles: projectedNewFiles,
|
|
101
|
-
modifiedFileAdditions: projectedModifiedFileAdditions,
|
|
102
|
-
};
|
|
103
|
-
});
|
|
104
|
-
// Baseline-vs-projected diffing: only regressions introduced *by* this
|
|
105
|
-
// shrink should block. A pre-existing cross-patch error elsewhere in
|
|
106
|
-
// the queue must not prevent the user from shrinking an unrelated
|
|
107
|
-
// patch (which is often exactly the tool they reach for to repair
|
|
108
|
-
// such a queue).
|
|
109
|
-
const baselineIssues = lintPatchQueue(baseCtx).filter((i) => i.severity === 'error');
|
|
110
|
-
const projectedIssues = lintPatchQueue({ entries: projectedEntries }).filter((i) => i.severity === 'error');
|
|
111
|
-
const regressions = computeProjectedLintRegressions(baselineIssues, projectedIssues);
|
|
112
|
-
const conflicts = regressions.length > 0
|
|
113
|
-
? {
|
|
114
|
-
reason: `projected --files state introduces ${regressions.length} new cross-patch lint error(s)`,
|
|
115
|
-
details: regressions.map((i) => `[${i.check}] ${i.file}: ${i.message}`),
|
|
116
|
-
}
|
|
117
|
-
: null;
|
|
118
|
-
// Surface pre-existing errors as a non-blocking warning so the user
|
|
119
|
-
// doesn't walk away thinking the queue is clean.
|
|
120
|
-
if (baselineIssues.length > 0 && regressions.length === 0) {
|
|
121
|
-
warn(`Note: projected queue still has ${baselineIssues.length} pre-existing ` +
|
|
122
|
-
`cross-patch error(s) unrelated to this shrink. Run "fireforge verify" to list them.`);
|
|
123
|
-
}
|
|
159
|
+
// CLI flags `--tier` and `--lint-ignore` participate too, with
|
|
160
|
+
// append/union semantics on the lint-ignore list (matching the
|
|
161
|
+
// standard re-export path).
|
|
162
|
+
const { effectiveTier, effectiveLintIgnore, flagIgnoreSet } = resolveEffectiveTierAndLintIgnore(target, options);
|
|
163
|
+
const ignoreChecks = effectiveLintIgnore ? new Set(effectiveLintIgnore) : undefined;
|
|
164
|
+
await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, undefined, ignoreChecks, effectiveTier);
|
|
165
|
+
const conflicts = await runProjectedCrossPatchLint(paths.patches, target.filename, projectedDiff);
|
|
124
166
|
// Shrinks are destructive (previously-owned files become unmanaged).
|
|
125
167
|
// Additive-only changes still deserve a prompt because --files asserts
|
|
126
168
|
// an authoritative file set.
|
|
@@ -163,7 +205,8 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
163
205
|
// directory lock as the mutation (via the onCommitted hook) so two
|
|
164
206
|
// concurrent re-exports cannot interleave records and a crash between
|
|
165
207
|
// mutation and append cannot orphan the audit trail.
|
|
166
|
-
|
|
208
|
+
const filesUpdates = buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveLintIgnore, flagIgnoreSet);
|
|
209
|
+
await updatePatchAndMetadata(paths.patches, target.filename, projectedDiff, filesUpdates, async () => {
|
|
167
210
|
await appendHistory(paths.patches, {
|
|
168
211
|
operation: 're-export-files',
|
|
169
212
|
args: {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { confirm, multiselect } from '@clack/prompts';
|
|
4
|
+
import { Option } from 'commander';
|
|
4
5
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
5
6
|
import { isGitRepository } from '../core/git.js';
|
|
6
7
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
@@ -204,10 +205,29 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
204
205
|
// The paired `patch.tier` threads the explicit branding-threshold
|
|
205
206
|
// opt-in the same way, for the branding patch that also touches a
|
|
206
207
|
// non-allowlisted registration sibling.
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
//
|
|
209
|
+
// The CLI flags `--tier` and `--lint-ignore` participate too, with
|
|
210
|
+
// append/union semantics on the lint-ignore list (the operator's
|
|
211
|
+
// intuition for "I want this patch to also suppress X" — explicit
|
|
212
|
+
// removal lives on the `fireforge patch lint-ignore` subcommand).
|
|
213
|
+
// Computed before the lint pass so the new intent takes effect on
|
|
214
|
+
// this invocation, not the next one.
|
|
215
|
+
const existingIgnoreSet = new Set(patch.lintIgnore ?? []);
|
|
216
|
+
const flagIgnoreSet = new Set(options.lintIgnore ?? []);
|
|
217
|
+
const mergedIgnoreSet = new Set([...existingIgnoreSet, ...flagIgnoreSet]);
|
|
218
|
+
const effectiveLintIgnore = mergedIgnoreSet.size > 0 ? [...mergedIgnoreSet] : undefined;
|
|
219
|
+
const ignoreChecks = effectiveLintIgnore ? new Set(effectiveLintIgnore) : undefined;
|
|
220
|
+
const effectiveTier = options.tier ?? patch.tier;
|
|
221
|
+
await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, undefined, ignoreChecks, effectiveTier);
|
|
209
222
|
if (isDryRun) {
|
|
210
223
|
info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
|
|
224
|
+
if (effectiveTier !== undefined && effectiveTier !== patch.tier) {
|
|
225
|
+
info(`[dry-run] ${patch.filename}: tier would become ${effectiveTier}`);
|
|
226
|
+
}
|
|
227
|
+
const addedIgnores = [...flagIgnoreSet].filter((id) => !existingIgnoreSet.has(id));
|
|
228
|
+
if (addedIgnores.length > 0) {
|
|
229
|
+
info(`[dry-run] ${patch.filename}: lintIgnore would gain ${addedIgnores.join(', ')}`);
|
|
230
|
+
}
|
|
211
231
|
}
|
|
212
232
|
else {
|
|
213
233
|
// Atomic body + manifest update under a single patch-directory lock.
|
|
@@ -215,9 +235,16 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
215
235
|
// sequence allows a concurrent `resolve` / `rebase --continue` / `patch
|
|
216
236
|
// compact` / `patch reorder` to rewrite the manifest between the two
|
|
217
237
|
// writes and leave patch body and `filesAffected` disagreeing.
|
|
218
|
-
|
|
238
|
+
const updates = {
|
|
219
239
|
filesAffected: currentFilesAffected,
|
|
220
|
-
}
|
|
240
|
+
};
|
|
241
|
+
if (options.tier !== undefined) {
|
|
242
|
+
updates.tier = options.tier;
|
|
243
|
+
}
|
|
244
|
+
if (effectiveLintIgnore !== undefined && flagIgnoreSet.size > 0) {
|
|
245
|
+
updates.lintIgnore = effectiveLintIgnore;
|
|
246
|
+
}
|
|
247
|
+
await updatePatchAndMetadata(paths.patches, patch.filename, diffContent, updates);
|
|
221
248
|
// Keep the in-memory manifest in sync so subsequent iterations (notably
|
|
222
249
|
// `--all --scan`, where `getClaimedFiles` reads from this manifest) see
|
|
223
250
|
// the just-written `filesAffected`. The on-disk write above is the
|
|
@@ -228,7 +255,7 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
228
255
|
if (existingEntry) {
|
|
229
256
|
manifest.patches[patchIndex] = {
|
|
230
257
|
...existingEntry,
|
|
231
|
-
|
|
258
|
+
...updates,
|
|
232
259
|
};
|
|
233
260
|
}
|
|
234
261
|
}
|
|
@@ -291,6 +318,15 @@ export async function reExportCommand(projectRoot, patches, options) {
|
|
|
291
318
|
throw new InvalidArgumentError('--files operates on exactly one target patch. Pass a single patch identifier.', '--files');
|
|
292
319
|
}
|
|
293
320
|
}
|
|
321
|
+
// --tier and --lint-ignore are per-patch metadata edits; combining them
|
|
322
|
+
// with --all silently rewrites every patch's tier/ignore list, which is
|
|
323
|
+
// virtually always wrong (different patches have different shapes).
|
|
324
|
+
// Refuse the combination so the operator must enumerate the targets.
|
|
325
|
+
const usingTierFlag = options.tier !== undefined;
|
|
326
|
+
const usingLintIgnoreFlag = options.lintIgnore !== undefined && options.lintIgnore.length > 0;
|
|
327
|
+
if (options.all && (usingTierFlag || usingLintIgnoreFlag)) {
|
|
328
|
+
throw new InvalidArgumentError('--tier and --lint-ignore require explicit patch identifiers and cannot be combined with --all (different patches typically need different metadata).', '--all');
|
|
329
|
+
}
|
|
294
330
|
const paths = getProjectPaths(projectRoot);
|
|
295
331
|
// Check if engine exists
|
|
296
332
|
if (!(await pathExists(paths.engine))) {
|
|
@@ -396,8 +432,15 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
|
|
|
396
432
|
.option('-y, --yes', 'Skip confirmation when --files shrinks a patch (required for non-TTY)')
|
|
397
433
|
.option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
|
|
398
434
|
.option('--stamp', "After every selected patch refreshes cleanly, stamp each re-exported patch's sourceEsrVersion in patches.json to firefox.version from fireforge.json. No effect on a partial run.")
|
|
435
|
+
.addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
|
|
436
|
+
.option('--lint-ignore <check-id>', 'Append a lint check ID to the patch\'s PatchMetadata.lintIgnore (union, de-duped, repeatable). Mutually exclusive with --all. Use "fireforge patch lint-ignore" for --remove / --clear.', (value, prev) => [...prev, value], [])
|
|
399
437
|
.action(withErrorHandling(async (patches, options) => {
|
|
400
|
-
|
|
438
|
+
const { tier, lintIgnore, ...rest } = options;
|
|
439
|
+
await reExportCommand(getProjectRoot(), patches, {
|
|
440
|
+
...pickDefined(rest),
|
|
441
|
+
...(tier !== undefined ? { tier: tier } : {}),
|
|
442
|
+
...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
|
|
443
|
+
});
|
|
401
444
|
}));
|
|
402
445
|
}
|
|
403
446
|
//# sourceMappingURL=re-export.js.map
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { getProjectPaths, loadConfig } from '../../core/config.js';
|
|
14
14
|
import { getFurnacePaths, updateFurnaceState } from '../../core/furnace-config.js';
|
|
15
|
-
import { getHead, isGitRepository, resetChanges } from '../../core/git.js';
|
|
15
|
+
import { getHead, isGitRepository, isMissingHeadError, resetChanges } from '../../core/git.js';
|
|
16
16
|
import { discoverPatches } from '../../core/patch-files.js';
|
|
17
17
|
import { loadPatchesManifest } from '../../core/patch-manifest.js';
|
|
18
18
|
import { hasActiveRebaseSession, saveRebaseSession } from '../../core/rebase-session.js';
|
|
@@ -40,6 +40,24 @@ async function handleFreshStart(projectRoot, options) {
|
|
|
40
40
|
if (!(await isGitRepository(paths.engine))) {
|
|
41
41
|
throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
42
42
|
}
|
|
43
|
+
// 2026-04-24 eval Finding 11: `rebase --dry-run` used to print
|
|
44
|
+
// "Dry run complete" without validating that the engine had a valid
|
|
45
|
+
// HEAD. A previous `download --force` abort could leave `.git/`
|
|
46
|
+
// initialized but unborn (no baseline commit); the real rebase then
|
|
47
|
+
// failed immediately with `fatal: ambiguous argument 'HEAD'` on the
|
|
48
|
+
// first `git rev-parse HEAD` call. Replicate the same baseline check
|
|
49
|
+
// here so dry-run mirrors the real-run preconditions and operators
|
|
50
|
+
// cannot mistake a broken baseline for a ready-to-rebase tree.
|
|
51
|
+
try {
|
|
52
|
+
await getHead(paths.engine);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (isMissingHeadError(err)) {
|
|
56
|
+
throw new GeneralError('Engine repository has no baseline commit yet — a previous "fireforge download" was interrupted before git created the initial Firefox source commit. ' +
|
|
57
|
+
'Re-run "fireforge download --force" to recreate the baseline repository cleanly, then retry the rebase.');
|
|
58
|
+
}
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
43
61
|
const config = await loadConfig(projectRoot);
|
|
44
62
|
const currentVersion = config.firefox.version;
|
|
45
63
|
const manifest = await loadPatchesManifest(paths.patches);
|
|
@@ -70,11 +70,39 @@ async function printUnregisteredWarnings(files, projectRoot, binaryName) {
|
|
|
70
70
|
if (newFiles.length === 0)
|
|
71
71
|
return;
|
|
72
72
|
const registrableFiles = newFiles.filter((f) => matchesRegistrablePattern(f.file, binaryName));
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
// `isFileRegistered` throws `GeneralError("Manifest not found: ...")` when a
|
|
74
|
+
// rule sees a file whose parent manifest does not yet exist on disk — e.g.
|
|
75
|
+
// a brand-new `browser/modules/<binary>/` directory with no `moz.build`.
|
|
76
|
+
// `status` is a read-only reporter; before 0.18.1 the rejected promise
|
|
77
|
+
// bubbled through `Promise.all` and exited status with code 1, breaking the
|
|
78
|
+
// "use status --unmanaged to discover new files before running register"
|
|
79
|
+
// workflow. We now bucket missing-manifest cases into a distinct warning
|
|
80
|
+
// list while still surfacing the same actionable signal. Other error
|
|
81
|
+
// shapes continue to propagate (permission denied, corrupt file, etc.) so
|
|
82
|
+
// we do not silently hide anything surprising.
|
|
83
|
+
const registrationChecks = await Promise.all(registrableFiles.map(async (f) => {
|
|
84
|
+
try {
|
|
85
|
+
return {
|
|
86
|
+
file: f.file,
|
|
87
|
+
registered: await isFileRegistered(projectRoot, f.file),
|
|
88
|
+
manifestMissing: false,
|
|
89
|
+
manifestMissingMessage: undefined,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
if (err instanceof GeneralError && /^Manifest not found:/i.test(err.message)) {
|
|
94
|
+
return {
|
|
95
|
+
file: f.file,
|
|
96
|
+
registered: false,
|
|
97
|
+
manifestMissing: true,
|
|
98
|
+
manifestMissingMessage: err.message,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
}));
|
|
104
|
+
const unregistered = registrationChecks.filter((f) => !f.registered && !f.manifestMissing);
|
|
105
|
+
const manifestMissing = registrationChecks.filter((f) => f.manifestMissing);
|
|
78
106
|
if (unregistered.length > 0) {
|
|
79
107
|
info('');
|
|
80
108
|
warn('Potentially unregistered files:');
|
|
@@ -82,6 +110,17 @@ async function printUnregisteredWarnings(files, projectRoot, binaryName) {
|
|
|
82
110
|
info(` ${f.file} — run 'fireforge register ${f.file}'`);
|
|
83
111
|
}
|
|
84
112
|
}
|
|
113
|
+
if (manifestMissing.length > 0) {
|
|
114
|
+
info('');
|
|
115
|
+
warn('Files whose registration manifest does not exist yet:');
|
|
116
|
+
for (const f of manifestMissing) {
|
|
117
|
+
// `manifestMissingMessage` is always the specific
|
|
118
|
+
// "Manifest not found: <path>" string when manifestMissing is
|
|
119
|
+
// true (see the catch branch above that sets them together).
|
|
120
|
+
info(` ${f.file} — ${f.manifestMissingMessage}`);
|
|
121
|
+
info(` Create the parent manifest, then run 'fireforge register ${f.file}'.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
85
124
|
}
|
|
86
125
|
/**
|
|
87
126
|
* Renders raw worktree status as machine-parseable porcelain-style output.
|
|
@@ -4,7 +4,7 @@ import { prepareBuildEnvironment } from '../core/build-prepare.js';
|
|
|
4
4
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
5
5
|
import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, testWithOutput, } from '../core/mach.js';
|
|
6
6
|
import { assertMarionettePortAvailable } from '../core/marionette-port.js';
|
|
7
|
-
import { reportMarionettePreflight, runMarionettePreflight } from '../core/marionette-preflight.js';
|
|
7
|
+
import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
|
|
8
8
|
import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
|
|
9
9
|
import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
|
|
10
10
|
import { GeneralError } from '../errors/base.js';
|
|
@@ -82,17 +82,22 @@ function hasXpcshellAppdirSignal(output) {
|
|
|
82
82
|
return /Failed to load resource:\/\/\/modules\//i.test(output);
|
|
83
83
|
}
|
|
84
84
|
function buildXpcshellAppdirMessage(injectionAttempted) {
|
|
85
|
+
const isMacos = process.platform === 'darwin';
|
|
86
|
+
const macosNote = isMacos
|
|
87
|
+
? 'Detected: macOS host. On macOS the xpcshell harness binds `-a` to `<obj>/dist/<App>.app/Contents/Resources` by default and frequently ignores `--app-path` overrides when the `.app` bundle is present — the surest fix is the `<appname>-appdir` migration below rather than trying to force a different path.\n\n'
|
|
88
|
+
: '';
|
|
85
89
|
const triggerLines = injectionAttempted
|
|
86
|
-
? 'FireForge auto-injected `--app-path=<absolute>` against the resolved obj-dir before mach test ran, but the failure persists. The injected path either does not match the appdir layout your harness expects, or the harness
|
|
90
|
+
? 'FireForge auto-injected `--app-path=<absolute>` against the resolved obj-dir before mach test ran, but the failure persists. The injected path either does not match the appdir layout your harness expects, or (on macOS) the harness bound `-a` to the `.app/Contents/Resources` default and ignored the override.\n\n'
|
|
87
91
|
: 'Likely triggers:\n' +
|
|
88
92
|
' - The nearest xpcshell.toml sets `firefox-appdir = "browser"` but the harness reads `<appname>-appdir` instead — the literal `firefox-appdir` directive is silently ignored on rebranded forks (appname != "firefox").\n' +
|
|
89
93
|
' - FireForge could not find an xpcshell.toml above the test path, so the auto-injection never ran.\n\n';
|
|
90
94
|
return ('xpcshell failed to load core resource:///modules/*.sys.mjs imports.\n\n' +
|
|
91
95
|
'This is the canonical symptom of xpcshell running with the wrong app directory: the runtime resolves `resource:///modules/` against the parent of the expected app root, so every `ChromeUtils.importESModule("resource:///modules/…")` throws.\n\n' +
|
|
96
|
+
macosNote +
|
|
92
97
|
triggerLines +
|
|
93
98
|
'Options:\n' +
|
|
94
|
-
' - Add `<appname>-appdir = "browser"` alongside `firefox-appdir = "browser"` in the xpcshell.toml [DEFAULT] so the harness reads the appname-keyed value directly.\n' +
|
|
95
|
-
' - Pass overrides through `fireforge test <path> --mach-arg="--app-path=<absolute>"` to inject the path verbatim (operator overrides always win over auto-injection).\n' +
|
|
99
|
+
' - Add `<appname>-appdir = "browser"` alongside `firefox-appdir = "browser"` in the xpcshell.toml [DEFAULT] so the harness reads the appname-keyed value directly. This is the most reliable fix on rebranded macOS builds.\n' +
|
|
100
|
+
' - Pass overrides through `fireforge test <path> --mach-arg="--app-path=<absolute>"` to inject the path verbatim (operator overrides always win over auto-injection, but see the macOS caveat above).\n' +
|
|
96
101
|
' - 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' +
|
|
97
102
|
' - If the test only touches toolkit chrome (chrome://global/*), drop the `firefox-appdir` setting entirely — toolkit chrome is registered without it.');
|
|
98
103
|
}
|
|
@@ -228,26 +233,32 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
228
233
|
// no paths are supplied this is the only step — it's the fastest way to tell
|
|
229
234
|
// marionette-wedged apart from test-discovery-failure.
|
|
230
235
|
if (options.doctor) {
|
|
236
|
+
// Write the "Running marionette preflight..." banner via
|
|
237
|
+
// `process.stdout.write` directly before `info()` so non-TTY captures
|
|
238
|
+
// always see the banner even if clack's renderer defers output in
|
|
239
|
+
// pipe mode. `info()` is still called so TTY users keep the normal
|
|
240
|
+
// clack box-drawing framing.
|
|
241
|
+
process.stdout.write('Running marionette preflight...\n');
|
|
231
242
|
info('Running marionette preflight...');
|
|
232
243
|
const preflight = await runMarionettePreflight(paths.engine);
|
|
244
|
+
// 2026-04-24 eval Finding 7: the pre-0.18.1 code used
|
|
245
|
+
// `success()` + `outro()` + a direct `process.stdout.write` as a
|
|
246
|
+
// belt-and-suspenders but still reproducibly dropped the PASS summary
|
|
247
|
+
// under non-TTY capture (observed: `tee`-wrapped eval output saw only
|
|
248
|
+
// the intro). The fix writes the authoritative PASS/FAIL line via
|
|
249
|
+
// `process.stdout.write` as the very first output after the probe
|
|
250
|
+
// returns, so the captured stream has an unambiguous summary no
|
|
251
|
+
// matter what clack does on top. The clack-rendered banner
|
|
252
|
+
// (`info`/`warn`) is retained so TTY users keep the visual framing.
|
|
253
|
+
const directLine = formatMarionettePreflightLine(preflight);
|
|
254
|
+
process.stdout.write(`${directLine}\n`);
|
|
233
255
|
reportMarionettePreflight(preflight);
|
|
234
256
|
if (testPaths.length === 0) {
|
|
235
257
|
if (!preflight.ok) {
|
|
236
258
|
throw new GeneralError('Marionette preflight reported FAIL — see output above.');
|
|
237
259
|
}
|
|
238
|
-
|
|
239
|
-
// AND `outro()` AND a direct stdout write. The eval
|
|
240
|
-
// reproducibly captured the intro + info line but nothing
|
|
241
|
-
// after the preflight returned, which we believe is a
|
|
242
|
-
// non-TTY clack rendering quirk that occasionally swallows
|
|
243
|
-
// the last log line before process exit. `success()` routes
|
|
244
|
-
// through a different clack entry point than `info()`, and
|
|
245
|
-
// `process.stdout.write` bypasses clack entirely so the
|
|
246
|
-
// PASS status is always visible in the captured output.
|
|
247
|
-
const summary = `Marionette preflight: PASS (${preflight.durationMs}ms)`;
|
|
248
|
-
success(summary);
|
|
260
|
+
success(directLine);
|
|
249
261
|
outro('Test completed');
|
|
250
|
-
process.stdout.write(`${summary}\n`);
|
|
251
262
|
return;
|
|
252
263
|
}
|
|
253
264
|
if (!preflight.ok) {
|