@hominis/fireforge 0.37.0 → 0.38.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 +13 -0
- package/README.md +1 -1
- package/dist/src/commands/build.js +5 -5
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/download.js +5 -2
- package/dist/src/commands/export-flow.d.ts +5 -1
- package/dist/src/commands/export-flow.js +10 -7
- package/dist/src/commands/export-placement-gate.js +3 -5
- package/dist/src/commands/export-placement-policy.d.ts +11 -3
- package/dist/src/commands/export-placement-policy.js +32 -29
- package/dist/src/commands/export.js +5 -5
- package/dist/src/commands/furnace/index.js +23 -18
- package/dist/src/commands/patch/move-files-create.d.ts +19 -0
- package/dist/src/commands/patch/move-files-create.js +116 -0
- package/dist/src/commands/patch/move-files.d.ts +3 -1
- package/dist/src/commands/patch/move-files.js +49 -4
- package/dist/src/commands/patch/split.d.ts +11 -1
- package/dist/src/commands/patch/split.js +6 -5
- package/dist/src/commands/re-export-scan.d.ts +17 -0
- package/dist/src/commands/re-export-scan.js +51 -0
- package/dist/src/commands/re-export.js +17 -6
- package/dist/src/commands/source.js +28 -3
- package/dist/src/commands/test-register.js +6 -5
- package/dist/src/commands/test-stale-gate.d.ts +44 -0
- package/dist/src/commands/test-stale-gate.js +81 -0
- package/dist/src/commands/test.js +15 -50
- package/dist/src/core/build-audit.d.ts +12 -0
- package/dist/src/core/build-audit.js +14 -0
- package/dist/src/core/build-baseline-types.d.ts +34 -0
- package/dist/src/core/build-baseline.d.ts +6 -1
- package/dist/src/core/build-baseline.js +47 -7
- package/dist/src/core/config-paths.d.ts +1 -1
- package/dist/src/core/config-paths.js +1 -0
- package/dist/src/core/config-validate.js +6 -1
- package/dist/src/core/engine-session-lock.d.ts +10 -1
- package/dist/src/core/engine-session-lock.js +28 -2
- package/dist/src/core/file-lock.d.ts +33 -0
- package/dist/src/core/file-lock.js +32 -6
- package/dist/src/core/firefox-archive.d.ts +2 -1
- package/dist/src/core/firefox-archive.js +32 -8
- package/dist/src/core/firefox.d.ts +8 -3
- package/dist/src/core/firefox.js +11 -6
- package/dist/src/core/furnace-validate-helpers.js +28 -1
- package/dist/src/core/license-headers.d.ts +12 -0
- package/dist/src/core/license-headers.js +76 -1
- package/dist/src/core/patch-lint.d.ts +3 -0
- package/dist/src/core/patch-lint.js +25 -7
- package/dist/src/core/status-classify.d.ts +11 -2
- package/dist/src/core/status-classify.js +49 -25
- package/dist/src/core/test-stale-check.d.ts +41 -1
- package/dist/src/core/test-stale-check.js +77 -3
- package/dist/src/types/commands/options.d.ts +57 -3
- package/dist/src/types/config.d.ts +7 -0
- package/dist/src/utils/options.d.ts +20 -0
- package/dist/src/utils/options.js +39 -0
- package/dist/src/utils/validation.d.ts +5 -0
- package/dist/src/utils/validation.js +7 -0
- package/package.json +3 -3
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
/**
|
|
3
3
|
* `fireforge patch move-files <from> <to>` previews the explicit
|
|
4
|
-
* re-export choreography needed to move file ownership between two
|
|
4
|
+
* re-export choreography needed to move file ownership between two
|
|
5
|
+
* patches. With `--create --order <n>` the target patch is created and
|
|
6
|
+
* the files move into it as one transaction (see move-files-create.ts).
|
|
5
7
|
*/
|
|
6
8
|
import { relative } from 'node:path';
|
|
7
9
|
import { Command } from 'commander';
|
|
@@ -11,6 +13,8 @@ import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-ma
|
|
|
11
13
|
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
12
14
|
import { pathExists } from '../../utils/fs.js';
|
|
13
15
|
import { info, intro, note, outro, warn } from '../../utils/logger.js';
|
|
16
|
+
import { commanderArgParser, pickDefined } from '../../utils/options.js';
|
|
17
|
+
import { patchMoveFilesCreateCommand } from './move-files-create.js';
|
|
14
18
|
function collectOption(value, previous) {
|
|
15
19
|
previous.push(value);
|
|
16
20
|
return previous;
|
|
@@ -67,6 +71,19 @@ function computeFileMovePlan(source, target, files) {
|
|
|
67
71
|
* @param options - Files to move and display mode
|
|
68
72
|
*/
|
|
69
73
|
export async function patchMoveFilesCommand(projectRoot, fromIdentifier, toIdentifier, options = {}) {
|
|
74
|
+
if (options.create === true && options.order === undefined) {
|
|
75
|
+
throw new InvalidArgumentError('--create requires --order <n> to place the new patch.', '--create');
|
|
76
|
+
}
|
|
77
|
+
if (options.create !== true && options.order !== undefined) {
|
|
78
|
+
throw new InvalidArgumentError('--order is only valid together with --create.', '--order');
|
|
79
|
+
}
|
|
80
|
+
if (options.create === true && options.order !== undefined) {
|
|
81
|
+
await patchMoveFilesCreateCommand(projectRoot, fromIdentifier, toIdentifier, {
|
|
82
|
+
...options,
|
|
83
|
+
order: options.order,
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
70
87
|
intro('FireForge patch move-files');
|
|
71
88
|
if (fromIdentifier === toIdentifier) {
|
|
72
89
|
throw new InvalidArgumentError('Source and target patch identifiers must be different.', 'patch move-files');
|
|
@@ -118,7 +135,8 @@ export async function patchMoveFilesCommand(projectRoot, fromIdentifier, toIdent
|
|
|
118
135
|
note(`${dryRunSource}\n${dryRunTarget}`, 'Preview commands');
|
|
119
136
|
note(`${applySource}\n${applyTarget}`, 'Apply commands');
|
|
120
137
|
info('Tip: to move files into a brand-new patch in one transaction (including ' +
|
|
121
|
-
'staged-dependency owner rewrites),
|
|
138
|
+
'staged-dependency owner rewrites), re-run with --create --order <n> to create ' +
|
|
139
|
+
'the target patch and move the files in one step, or use "fireforge patch split".');
|
|
122
140
|
outro('Move plan complete - no changes made');
|
|
123
141
|
}
|
|
124
142
|
/**
|
|
@@ -131,9 +149,36 @@ export function registerPatchMoveFiles(parent, context) {
|
|
|
131
149
|
const { getProjectRoot, withErrorHandling } = context;
|
|
132
150
|
parent
|
|
133
151
|
.command('move-files <from> <to>')
|
|
134
|
-
.description('Preview the re-export commands needed to move file ownership between patches
|
|
152
|
+
.description('Preview the re-export commands needed to move file ownership between patches, ' +
|
|
153
|
+
'or create the target patch and move the files in one transaction with --create --order <n>.')
|
|
135
154
|
.option('--file <path>', 'File path relative to engine/ to move (repeatable)', collectOption, [])
|
|
155
|
+
.option('--create', 'Create <to> as a new patch and move the files into it (requires --order)')
|
|
156
|
+
.option('--order <n>', 'Exact sparse order for the created patch (only valid with --create)', commanderArgParser((raw) => {
|
|
157
|
+
const n = Number.parseInt(raw, 10);
|
|
158
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
159
|
+
throw new InvalidArgumentError(`--order must be a positive integer, got "${raw}".`, '--order');
|
|
160
|
+
}
|
|
161
|
+
return n;
|
|
162
|
+
}))
|
|
163
|
+
.option('--category <category>', "Category for the created patch (default: the source patch's)")
|
|
164
|
+
.option('--description <desc>', 'Description for the created patch')
|
|
165
|
+
.option('--dry-run', 'Show what would happen without writing')
|
|
166
|
+
.option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
|
|
167
|
+
.option('--force-unsafe', 'Bypass projected-lint refusals')
|
|
168
|
+
.option('--skip-lint', 'Skip per-patch lint of the projected bodies')
|
|
136
169
|
.action(withErrorHandling(async (from, to, options) => {
|
|
137
|
-
await patchMoveFilesCommand(getProjectRoot(), from, to,
|
|
170
|
+
await patchMoveFilesCommand(getProjectRoot(), from, to, {
|
|
171
|
+
...pickDefined({
|
|
172
|
+
file: options.file,
|
|
173
|
+
create: options.create,
|
|
174
|
+
order: options.order,
|
|
175
|
+
category: options.category,
|
|
176
|
+
description: options.description,
|
|
177
|
+
dryRun: options.dryRun,
|
|
178
|
+
yes: options.yes,
|
|
179
|
+
forceUnsafe: options.forceUnsafe,
|
|
180
|
+
skipLint: options.skipLint,
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
138
183
|
}));
|
|
139
184
|
}
|
|
@@ -18,7 +18,17 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { Command } from 'commander';
|
|
20
20
|
import type { CommandContext } from '../../types/cli.js';
|
|
21
|
-
import type { PatchSplitOptions } from '../../types/commands/index.js';
|
|
21
|
+
import type { PatchMetadata, PatchSplitOptions } from '../../types/commands/index.js';
|
|
22
|
+
import type { FireForgeConfig } from '../../types/config.js';
|
|
23
|
+
import { type SplitPlan } from './split-plan.js';
|
|
24
|
+
/**
|
|
25
|
+
* Commits a confirmed split under the patch directory lock: renumber →
|
|
26
|
+
* write new patch body → write shrunken source body → single manifest
|
|
27
|
+
* rewrite (new row + shrunken source row + owner rewrites). On any
|
|
28
|
+
* failure the steps are rolled back in reverse order. Exported for
|
|
29
|
+
* `patch move-files --create`, which commits the same transaction shape.
|
|
30
|
+
*/
|
|
31
|
+
export declare function commitPatchSplit(patchesDir: string, plan: SplitPlan, newMetadata: PatchMetadata, options: Pick<PatchSplitOptions, 'yes' | 'forceUnsafe'>, config: FireForgeConfig): Promise<void>;
|
|
22
32
|
/**
|
|
23
33
|
* Runs the `patch split` command: plans the split, lints the projection,
|
|
24
34
|
* confirms, and commits transactionally.
|
|
@@ -38,9 +38,10 @@ import { assertSourceOwnsFiles, buildNewPatchMetadata, buildSplitDiff, buildSpli
|
|
|
38
38
|
* Commits a confirmed split under the patch directory lock: renumber →
|
|
39
39
|
* write new patch body → write shrunken source body → single manifest
|
|
40
40
|
* rewrite (new row + shrunken source row + owner rewrites). On any
|
|
41
|
-
* failure the steps are rolled back in reverse order.
|
|
41
|
+
* failure the steps are rolled back in reverse order. Exported for
|
|
42
|
+
* `patch move-files --create`, which commits the same transaction shape.
|
|
42
43
|
*/
|
|
43
|
-
async function commitPatchSplit(patchesDir, plan, newMetadata, options) {
|
|
44
|
+
export async function commitPatchSplit(patchesDir, plan, newMetadata, options, config) {
|
|
44
45
|
await withPatchDirectoryLock(patchesDir, async () => {
|
|
45
46
|
const manifest = await loadPatchesManifest(patchesDir);
|
|
46
47
|
if (!manifest)
|
|
@@ -49,7 +50,7 @@ async function commitPatchSplit(patchesDir, plan, newMetadata, options) {
|
|
|
49
50
|
if (!current || current.filesAffected.join('\n') !== plan.source.filesAffected.join('\n')) {
|
|
50
51
|
throw new InvalidArgumentError('Patch queue changed while waiting for split confirmation. Re-run the command.', 'patch split');
|
|
51
52
|
}
|
|
52
|
-
const currentPlacement = await resolvePlacementPlan(patchesDir, plan.placementOptions, plan.category, plan.name);
|
|
53
|
+
const currentPlacement = await resolvePlacementPlan(patchesDir, plan.placementOptions, plan.category, plan.name, config);
|
|
53
54
|
if (!placementPlansEqual(currentPlacement, plan.placement)) {
|
|
54
55
|
throw new InvalidArgumentError('Patch queue changed while waiting for split confirmation. Re-run the command.', 'patch split');
|
|
55
56
|
}
|
|
@@ -193,7 +194,7 @@ export async function patchSplitCommand(projectRoot, sourceId, options) {
|
|
|
193
194
|
after: options.after ??
|
|
194
195
|
(options.order === undefined && options.before === undefined ? source.filename : undefined),
|
|
195
196
|
});
|
|
196
|
-
const placement = await resolvePlacementPlan(paths.patches, placementOptions, category, options.name);
|
|
197
|
+
const placement = await resolvePlacementPlan(paths.patches, placementOptions, category, options.name, config);
|
|
197
198
|
const plan = {
|
|
198
199
|
source,
|
|
199
200
|
movedFiles,
|
|
@@ -240,7 +241,7 @@ export async function patchSplitCommand(projectRoot, sourceId, options) {
|
|
|
240
241
|
outro('Split cancelled');
|
|
241
242
|
return;
|
|
242
243
|
}
|
|
243
|
-
await commitPatchSplit(paths.patches, plan, newMetadata, options);
|
|
244
|
+
await commitPatchSplit(paths.patches, plan, newMetadata, options, config);
|
|
244
245
|
success(`Split ${source.filename}: ${placement.newFilename} now owns ${plan.movedFiles.length} file(s)`);
|
|
245
246
|
if (plan.ownerRewrites.length > 0) {
|
|
246
247
|
info(`Re-pointed staged-dependency owners in: ${plan.ownerRewrites.join(', ')}`);
|
|
@@ -28,6 +28,23 @@ export declare function confirmBroadScanAdditions(args: {
|
|
|
28
28
|
yes: boolean;
|
|
29
29
|
isInteractive: boolean;
|
|
30
30
|
}): Promise<boolean>;
|
|
31
|
+
/**
|
|
32
|
+
* Refuses scan adoptions whose candidate files import modules created by
|
|
33
|
+
* LATER patches. Modeled on the `re-export --files` cross-patch
|
|
34
|
+
* projection: the candidates' diffs are projected into the adopting
|
|
35
|
+
* patch's queue entry, the queue lint runs baseline vs projection, and
|
|
36
|
+
* only forward-import regressions attributable to the candidates block.
|
|
37
|
+
* Staged-dependency declarations and inline lint-ignore markers are
|
|
38
|
+
* honored automatically because the real lint rule evaluates them.
|
|
39
|
+
* Covers broad `--scan`, `--scan-file`, and the `--scan-files` bulk flow
|
|
40
|
+
* (all adopt through the same scan result), including dry-run.
|
|
41
|
+
*/
|
|
42
|
+
export declare function assertScanAdoptionsHaveNoForwardImports(args: {
|
|
43
|
+
patchesDir: string;
|
|
44
|
+
engineDir: string;
|
|
45
|
+
patchFilename: string;
|
|
46
|
+
added: readonly string[];
|
|
47
|
+
}): Promise<void>;
|
|
31
48
|
/** Refuses explicit `--scan-file` additions that did not produce patch hunks. */
|
|
32
49
|
export declare function assertScanFileAdditionsHaveDiffHunks(args: {
|
|
33
50
|
diffContent: string;
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { confirm } from '@clack/prompts';
|
|
4
|
+
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
4
5
|
import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
|
|
6
|
+
import { computeProjectedLintRegressions } from '../core/lint-projection.js';
|
|
5
7
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
8
|
+
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
|
|
6
9
|
import { getClaimedFiles } from '../core/patch-manifest.js';
|
|
10
|
+
import { extractNewFileContentFromDiff } from '../core/patch-transform.js';
|
|
7
11
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
8
12
|
import { pathExists } from '../utils/fs.js';
|
|
9
13
|
import { cancel, info, isCancel, warn } from '../utils/logger.js';
|
|
@@ -132,6 +136,53 @@ function scanAdditionsNeedConfirmation(added) {
|
|
|
132
136
|
return true;
|
|
133
137
|
return new Set(added.map((f) => dirname(f))).size >= SCAN_DIR_COUNT_THRESHOLD;
|
|
134
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Refuses scan adoptions whose candidate files import modules created by
|
|
141
|
+
* LATER patches. Modeled on the `re-export --files` cross-patch
|
|
142
|
+
* projection: the candidates' diffs are projected into the adopting
|
|
143
|
+
* patch's queue entry, the queue lint runs baseline vs projection, and
|
|
144
|
+
* only forward-import regressions attributable to the candidates block.
|
|
145
|
+
* Staged-dependency declarations and inline lint-ignore markers are
|
|
146
|
+
* honored automatically because the real lint rule evaluates them.
|
|
147
|
+
* Covers broad `--scan`, `--scan-file`, and the `--scan-files` bulk flow
|
|
148
|
+
* (all adopt through the same scan result), including dry-run.
|
|
149
|
+
*/
|
|
150
|
+
export async function assertScanAdoptionsHaveNoForwardImports(args) {
|
|
151
|
+
const { patchesDir, engineDir, patchFilename, added } = args;
|
|
152
|
+
if (added.length === 0)
|
|
153
|
+
return;
|
|
154
|
+
const candidateDiff = await getDiffForFilesAgainstHead(engineDir, [...added]);
|
|
155
|
+
if (!candidateDiff.trim())
|
|
156
|
+
return;
|
|
157
|
+
const candidateNewFiles = new Map();
|
|
158
|
+
for (const path of detectNewFilesInDiff(candidateDiff)) {
|
|
159
|
+
candidateNewFiles.set(path, extractNewFileContentFromDiff(candidateDiff, path));
|
|
160
|
+
}
|
|
161
|
+
const candidateAdditions = buildModifiedFileAdditionsFromDiff(candidateDiff);
|
|
162
|
+
const baseCtx = await buildPatchQueueContext(patchesDir);
|
|
163
|
+
const projectedEntries = baseCtx.entries.map((entry) => {
|
|
164
|
+
if (entry.filename !== patchFilename)
|
|
165
|
+
return entry;
|
|
166
|
+
return {
|
|
167
|
+
...entry,
|
|
168
|
+
diff: `${entry.diff}\n${candidateDiff}`,
|
|
169
|
+
newFiles: new Map([...entry.newFiles, ...candidateNewFiles]),
|
|
170
|
+
modifiedFileAdditions: new Map([...entry.modifiedFileAdditions, ...candidateAdditions]),
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
const baselineIssues = lintPatchQueue(baseCtx).filter((i) => i.severity === 'error');
|
|
174
|
+
const projectedIssues = lintPatchQueue({ entries: projectedEntries }).filter((i) => i.severity === 'error');
|
|
175
|
+
const addedSet = new Set(added);
|
|
176
|
+
const offending = computeProjectedLintRegressions(baselineIssues, projectedIssues).filter((issue) => issue.check === 'forward-import' && addedSet.has(issue.file));
|
|
177
|
+
if (offending.length === 0)
|
|
178
|
+
return;
|
|
179
|
+
const details = offending.map((issue) => ` - ${issue.file}: ${issue.message}`).join('\n');
|
|
180
|
+
throw new GeneralError(`Refusing to adopt ${offending.length} scanned file${offending.length === 1 ? '' : 's'} into ${patchFilename} ` +
|
|
181
|
+
`because they import modules created by later patches:\n${details}\n` +
|
|
182
|
+
'Export those files as their own later patch ("fireforge export --order <n>" / ' +
|
|
183
|
+
'"fireforge patch split --order <n>") or declare the intentional dependency with ' +
|
|
184
|
+
'"fireforge patch staged-dependency --add" before re-running the scan.');
|
|
185
|
+
}
|
|
135
186
|
/** Refuses explicit `--scan-file` additions that did not produce patch hunks. */
|
|
136
187
|
export function assertScanFileAdditionsHaveDiffHunks(args) {
|
|
137
188
|
const { diffContent, patchFilename, previousFilesAffected, scanFiles } = args;
|
|
@@ -18,12 +18,12 @@ import { elapsedSince } from '../utils/elapsed.js';
|
|
|
18
18
|
import { toError } from '../utils/errors.js';
|
|
19
19
|
import { pathExists } from '../utils/fs.js';
|
|
20
20
|
import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
|
|
21
|
-
import { pickDefined } from '../utils/options.js';
|
|
21
|
+
import { addWaitLockOption, pickDefined, resolveWaitLockSeconds } from '../utils/options.js';
|
|
22
22
|
import { runPatchLint } from './export-shared.js';
|
|
23
23
|
import { loadScanFilesAssignments, withDryRunReExportLock } from './re-export-bulk-scan.js';
|
|
24
24
|
import { reExportFilesInPlace } from './re-export-files.js';
|
|
25
25
|
import { applyReExportFilesPositionalFolding, validateReExportOptionCombinations, } from './re-export-options.js';
|
|
26
|
-
import { assertScanFileAdditionsHaveDiffHunks, confirmBroadScanAdditions, normalizeScanFiles, scanPatchFilesForReExport, } from './re-export-scan.js';
|
|
26
|
+
import { assertScanAdoptionsHaveNoForwardImports, assertScanFileAdditionsHaveDiffHunks, confirmBroadScanAdditions, normalizeScanFiles, scanPatchFilesForReExport, } from './re-export-scan.js';
|
|
27
27
|
async function findMissingFiles(engineDir, files) {
|
|
28
28
|
const missingFiles = [];
|
|
29
29
|
for (const file of files) {
|
|
@@ -87,6 +87,17 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
87
87
|
isDryRun,
|
|
88
88
|
...(options.scanFiles !== undefined ? { scanFiles: options.scanFiles } : {}),
|
|
89
89
|
});
|
|
90
|
+
// Forward-import gate at adoption time (runs in dry-run too): refuse
|
|
91
|
+
// to adopt unmanaged files that import modules created by LATER
|
|
92
|
+
// patches — the after-the-fact lint failure this leaves behind is the
|
|
93
|
+
// field-reported footgun. Applies to broad --scan, --scan-file, and
|
|
94
|
+
// the --scan-files bulk assignments alike.
|
|
95
|
+
await assertScanAdoptionsHaveNoForwardImports({
|
|
96
|
+
patchesDir: paths.patches,
|
|
97
|
+
engineDir: paths.engine,
|
|
98
|
+
patchFilename: patch.filename,
|
|
99
|
+
added: scanResult.added,
|
|
100
|
+
});
|
|
90
101
|
if (options.scanFiles === undefined) {
|
|
91
102
|
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
92
103
|
const proceed = await confirmBroadScanAdditions({
|
|
@@ -407,7 +418,7 @@ export async function reExportCommand(projectRoot, patches, options) {
|
|
|
407
418
|
}
|
|
408
419
|
/** Registers the re-export command on the CLI program. */
|
|
409
420
|
export function registerReExport(program, { getProjectRoot, withErrorHandling }) {
|
|
410
|
-
program
|
|
421
|
+
const reExport = program
|
|
411
422
|
.command('re-export [patches...]')
|
|
412
423
|
.description('Refresh existing patch bodies (and filesAffected with --scan) from the current engine ' +
|
|
413
424
|
'state. Does NOT change sourceVersion/sourceProduct by default — use --stamp or run ' +
|
|
@@ -428,8 +439,8 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
|
|
|
428
439
|
.option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
|
|
429
440
|
.option('--stamp', "After every selected patch refreshes cleanly, stamp each re-exported patch's sourceVersion/sourceProduct in patches.json to firefox.version/firefox.product from fireforge.json. No effect on a partial run.")
|
|
430
441
|
.addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
|
|
431
|
-
.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], [])
|
|
432
|
-
|
|
442
|
+
.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], []);
|
|
443
|
+
addWaitLockOption(reExport).action(withErrorHandling(async (patches, options) => {
|
|
433
444
|
const { tier, lintIgnore, scanFile, scanFiles, ...rest } = options;
|
|
434
445
|
const projectRoot = getProjectRoot();
|
|
435
446
|
await withEngineSessionLock(projectRoot, 're-export', () => reExportCommand(projectRoot, patches, {
|
|
@@ -438,6 +449,6 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
|
|
|
438
449
|
...(scanFiles !== undefined ? { scanFilesManifest: scanFiles } : {}),
|
|
439
450
|
...(tier !== undefined ? { tier: tier } : {}),
|
|
440
451
|
...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
|
|
441
|
-
}));
|
|
452
|
+
}), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
|
|
442
453
|
}));
|
|
443
454
|
}
|
|
@@ -4,7 +4,7 @@ import { configExists, loadRawConfigDocument, validateConfig, withConfigFileLock
|
|
|
4
4
|
import { resolveArchive } from '../core/firefox-archive.js';
|
|
5
5
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
6
6
|
import { info, intro, outro, success } from '../utils/logger.js';
|
|
7
|
-
import { isValidFirefoxProduct } from '../utils/validation.js';
|
|
7
|
+
import { isValidFirefoxCandidate, isValidFirefoxProduct } from '../utils/validation.js';
|
|
8
8
|
const SOURCE_PRODUCTS = [
|
|
9
9
|
'firefox',
|
|
10
10
|
'firefox-esr',
|
|
@@ -27,6 +27,12 @@ function parseSourceProduct(product) {
|
|
|
27
27
|
}
|
|
28
28
|
throw new InvalidArgumentError(`--product must be one of: ${SOURCE_PRODUCTS.join(', ')}`, '--product');
|
|
29
29
|
}
|
|
30
|
+
function parseSourceCandidate(candidate) {
|
|
31
|
+
if (isValidFirefoxCandidate(candidate)) {
|
|
32
|
+
return candidate;
|
|
33
|
+
}
|
|
34
|
+
throw new InvalidArgumentError(`--candidate must look like "buildN" (e.g. "build2"), got "${candidate}"`, '--candidate');
|
|
35
|
+
}
|
|
30
36
|
/**
|
|
31
37
|
* Atomically updates the Firefox source tuple in fireforge.json.
|
|
32
38
|
*/
|
|
@@ -38,6 +44,9 @@ export async function sourceSetCommand(projectRoot, options) {
|
|
|
38
44
|
if (options.sha256 !== undefined && options.clearSha256 === true) {
|
|
39
45
|
throw new InvalidArgumentError('--sha256 cannot be combined with --clear-sha256', '--sha256');
|
|
40
46
|
}
|
|
47
|
+
if (options.candidate !== undefined && options.clearCandidate === true) {
|
|
48
|
+
throw new InvalidArgumentError('--candidate cannot be combined with --clear-candidate', '--candidate');
|
|
49
|
+
}
|
|
41
50
|
const written = await withConfigFileLock(projectRoot, async () => {
|
|
42
51
|
const raw = await loadRawConfigDocument(projectRoot);
|
|
43
52
|
const updated = cloneRawConfig(raw);
|
|
@@ -50,6 +59,12 @@ export async function sourceSetCommand(projectRoot, options) {
|
|
|
50
59
|
else if (options.sha256 !== undefined) {
|
|
51
60
|
firefox['sha256'] = options.sha256;
|
|
52
61
|
}
|
|
62
|
+
if (options.clearCandidate === true) {
|
|
63
|
+
delete firefox['candidate'];
|
|
64
|
+
}
|
|
65
|
+
else if (options.candidate !== undefined) {
|
|
66
|
+
firefox['candidate'] = options.candidate;
|
|
67
|
+
}
|
|
53
68
|
updated['firefox'] = firefox;
|
|
54
69
|
const validated = validateConfig(updated);
|
|
55
70
|
if (validated.firefox.sha256 !== undefined) {
|
|
@@ -58,7 +73,7 @@ export async function sourceSetCommand(projectRoot, options) {
|
|
|
58
73
|
await writeConfigDocument(projectRoot, updated);
|
|
59
74
|
return validated.firefox;
|
|
60
75
|
});
|
|
61
|
-
const archive = resolveArchive(written.version, written.product);
|
|
76
|
+
const archive = resolveArchive(written.version, written.product, written.candidate);
|
|
62
77
|
success(`Set firefox.version = ${written.version}`);
|
|
63
78
|
success(`Set firefox.product = ${written.product}`);
|
|
64
79
|
success(`Resolved source URL: ${archive.url}`);
|
|
@@ -68,6 +83,12 @@ export async function sourceSetCommand(projectRoot, options) {
|
|
|
68
83
|
else if (options.clearSha256 === true) {
|
|
69
84
|
info('Cleared firefox.sha256');
|
|
70
85
|
}
|
|
86
|
+
if (written.candidate !== undefined) {
|
|
87
|
+
success(`Set firefox.candidate = ${written.candidate}`);
|
|
88
|
+
}
|
|
89
|
+
else if (options.clearCandidate === true) {
|
|
90
|
+
info('Cleared firefox.candidate');
|
|
91
|
+
}
|
|
71
92
|
outro('');
|
|
72
93
|
}
|
|
73
94
|
/** Registers the source command on the CLI program. */
|
|
@@ -82,13 +103,17 @@ export function registerSource(program, { getProjectRoot, withErrorHandling }) {
|
|
|
82
103
|
.makeOptionMandatory())
|
|
83
104
|
.option('--sha256 <hash>', 'Pinned SHA-256 for the resolved source archive')
|
|
84
105
|
.option('--clear-sha256', 'Clear any existing pinned SHA-256')
|
|
106
|
+
.option('--candidate <buildN>', 'Release-candidate build directory (e.g. "build2")', parseSourceCandidate)
|
|
107
|
+
.option('--clear-candidate', 'Clear any existing release-candidate build directory')
|
|
85
108
|
.action(withErrorHandling(async (options) => {
|
|
86
|
-
const { product, version, sha256, clearSha256 } = options;
|
|
109
|
+
const { product, version, sha256, clearSha256, candidate, clearCandidate } = options;
|
|
87
110
|
await sourceSetCommand(getProjectRoot(), {
|
|
88
111
|
version,
|
|
89
112
|
product: parseSourceProduct(product),
|
|
90
113
|
...(sha256 !== undefined ? { sha256 } : {}),
|
|
91
114
|
...(clearSha256 !== undefined ? { clearSha256 } : {}),
|
|
115
|
+
...(candidate !== undefined ? { candidate } : {}),
|
|
116
|
+
...(clearCandidate !== undefined ? { clearCandidate } : {}),
|
|
92
117
|
});
|
|
93
118
|
}));
|
|
94
119
|
}
|
|
@@ -2,18 +2,19 @@
|
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { withEngineSessionLock } from '../core/engine-session-lock.js';
|
|
4
4
|
import { GeneralError } from '../errors/base.js';
|
|
5
|
-
import { commanderArgParser, pickDefined } from '../utils/options.js';
|
|
5
|
+
import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, } from '../utils/options.js';
|
|
6
6
|
import { testCommand } from './test.js';
|
|
7
7
|
import { DEFAULT_HARNESS_RETRIES } from './test-run.js';
|
|
8
8
|
/** Registers the test command on the CLI program. */
|
|
9
9
|
export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
10
|
-
program
|
|
10
|
+
const test = program
|
|
11
11
|
.command('test [paths...]')
|
|
12
12
|
.description('Run tests via mach test')
|
|
13
13
|
.option('--headless', 'Run tests in headless mode')
|
|
14
14
|
.option('--build', 'Run incremental UI build before testing')
|
|
15
15
|
.option('--auto', 'Forward mach test --auto. Valid only when no explicit paths are provided.')
|
|
16
16
|
.option('--allow-stale-build', 'Allow tests to run even when packageable engine files changed since the last successful FireForge build')
|
|
17
|
+
.option('--allow-stale-components', 'Run tests despite components.conf changes that only a full "fireforge build" compiles in (the packaged child process will resolve the OLD StaticComponents table)')
|
|
17
18
|
.option('--kill-stale-marionette', 'Terminate a recognized stale browser process holding the Marionette port before running tests')
|
|
18
19
|
.option('--canary [path]', 'Run one short browser-chrome harness canary. Uses test.canaryPath from fireforge.json when no path is supplied.')
|
|
19
20
|
.option('--doctor', 'Run a marionette handshake preflight before tests (exit 1 on FAIL). With no paths, runs the preflight only.')
|
|
@@ -56,9 +57,9 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
|
56
57
|
'runs (one browser instance per argument) by default, which does',
|
|
57
58
|
'not exercise cross-argument state; --no-shard restores the',
|
|
58
59
|
'combined single-instance invocation.',
|
|
59
|
-
].join('\n'))
|
|
60
|
-
|
|
60
|
+
].join('\n'));
|
|
61
|
+
addWaitLockOption(test).action(withErrorHandling(async (paths, options) => {
|
|
61
62
|
const projectRoot = getProjectRoot();
|
|
62
|
-
await withEngineSessionLock(projectRoot, 'test', () => testCommand(projectRoot, paths, pickDefined(options)));
|
|
63
|
+
await withEngineSessionLock(projectRoot, 'test', () => testCommand(projectRoot, paths, pickDefined(options)), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
|
|
63
64
|
}));
|
|
64
65
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-dispatch gates for `fireforge test` that stand between the request
|
|
3
|
+
* and a run against a stale or under-packaged runtime: the packaging
|
|
4
|
+
* coverage refusal, the stale-content refusal, and the compiled
|
|
5
|
+
* StaticComponents refusal. Split out of `test.ts` so the command module
|
|
6
|
+
* stays wiring; the probes and refusal copy live in
|
|
7
|
+
* `src/core/test-stale-check.ts`.
|
|
8
|
+
*/
|
|
9
|
+
import type { BuildBaseline } from '../core/build-baseline-types.js';
|
|
10
|
+
import type { TestOptions } from '../types/commands/index.js';
|
|
11
|
+
/**
|
|
12
|
+
* Stale-build preflight — when `--build` was NOT requested, detect
|
|
13
|
+
* packageable engine edits since the last successful build and fail
|
|
14
|
+
* UP-FRONT unless the operator explicitly accepts the stale package risk.
|
|
15
|
+
*
|
|
16
|
+
* Packaging COVERAGE is checked first, on EVERY non-`--build` run and
|
|
17
|
+
* regardless of staleness or `--allow-stale-build`: a runtime packaged by
|
|
18
|
+
* a file-scoped `test --build` can lack support fixtures for OTHER
|
|
19
|
+
* manifests even when nothing changed since — dispatching such a run
|
|
20
|
+
* hangs on missing fixtures rather than failing, so the flag (which only
|
|
21
|
+
* accepts stale CONTENT) must not be the trigger. Field incident: a
|
|
22
|
+
* three-file scoped rebuild left `file_tiles_audio.html` unpackaged and a
|
|
23
|
+
* later run over different files timed out twice at 45s waiting on
|
|
24
|
+
* `DOMAudioPlaybackStarted`.
|
|
25
|
+
*
|
|
26
|
+
* Exception: a path-less `test --doctor` stops at the Marionette health
|
|
27
|
+
* check (`runDoctorPreflight` returns 'stop' when no test paths were
|
|
28
|
+
* given) and never dispatches a test, so it needs no packaging coverage —
|
|
29
|
+
* treating it as a full-suite request would refuse a probe that touches
|
|
30
|
+
* no fixtures. The stale-content refusal still applies to it unchanged.
|
|
31
|
+
*/
|
|
32
|
+
export declare function enforceStaleBuildGate(projectRoot: string, engineDir: string, options: TestOptions, normalizedPaths: readonly string[]): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Compiled-StaticComponents gate — refuses runs whose child process would
|
|
35
|
+
* resolve a stale compiled component table. `components.conf` entries bake
|
|
36
|
+
* into compiled code that only a FULL build regenerates; a scoped
|
|
37
|
+
* `test --build` repackages the file but the failure surfaces as
|
|
38
|
+
* `NS_ERROR_MALFORMED_URI` inside the test. Applies to build-less runs
|
|
39
|
+
* (after the coverage refusal) AND to scoped `test --build` runs (before
|
|
40
|
+
* the pre-test build — that build cannot fix the table). A path-less
|
|
41
|
+
* `test --build` is exempt: its full build refreshes the anchor itself.
|
|
42
|
+
* `--allow-stale-components` downgrades the refusal to a warning.
|
|
43
|
+
*/
|
|
44
|
+
export declare function enforceStaticComponentsGate(engineDir: string, baseline: BuildBaseline | undefined, options: TestOptions): Promise<void>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Pre-dispatch gates for `fireforge test` that stand between the request
|
|
4
|
+
* and a run against a stale or under-packaged runtime: the packaging
|
|
5
|
+
* coverage refusal, the stale-content refusal, and the compiled
|
|
6
|
+
* StaticComponents refusal. Split out of `test.ts` so the command module
|
|
7
|
+
* stays wiring; the probes and refusal copy live in
|
|
8
|
+
* `src/core/test-stale-check.ts`.
|
|
9
|
+
*/
|
|
10
|
+
import { checkStaleBuildForTest, checkStaticComponentsStale, findUncoveredRequestPaths, formatStaleBuildWarning, formatStaticComponentsRefusal, formatTestCoverageRefusal, } from '../core/test-stale-check.js';
|
|
11
|
+
import { GeneralError } from '../errors/base.js';
|
|
12
|
+
import { warn } from '../utils/logger.js';
|
|
13
|
+
/**
|
|
14
|
+
* Stale-build preflight — when `--build` was NOT requested, detect
|
|
15
|
+
* packageable engine edits since the last successful build and fail
|
|
16
|
+
* UP-FRONT unless the operator explicitly accepts the stale package risk.
|
|
17
|
+
*
|
|
18
|
+
* Packaging COVERAGE is checked first, on EVERY non-`--build` run and
|
|
19
|
+
* regardless of staleness or `--allow-stale-build`: a runtime packaged by
|
|
20
|
+
* a file-scoped `test --build` can lack support fixtures for OTHER
|
|
21
|
+
* manifests even when nothing changed since — dispatching such a run
|
|
22
|
+
* hangs on missing fixtures rather than failing, so the flag (which only
|
|
23
|
+
* accepts stale CONTENT) must not be the trigger. Field incident: a
|
|
24
|
+
* three-file scoped rebuild left `file_tiles_audio.html` unpackaged and a
|
|
25
|
+
* later run over different files timed out twice at 45s waiting on
|
|
26
|
+
* `DOMAudioPlaybackStarted`.
|
|
27
|
+
*
|
|
28
|
+
* Exception: a path-less `test --doctor` stops at the Marionette health
|
|
29
|
+
* check (`runDoctorPreflight` returns 'stop' when no test paths were
|
|
30
|
+
* given) and never dispatches a test, so it needs no packaging coverage —
|
|
31
|
+
* treating it as a full-suite request would refuse a probe that touches
|
|
32
|
+
* no fixtures. The stale-content refusal still applies to it unchanged.
|
|
33
|
+
*/
|
|
34
|
+
export async function enforceStaleBuildGate(projectRoot, engineDir, options, normalizedPaths) {
|
|
35
|
+
const stale = await checkStaleBuildForTest(projectRoot, engineDir);
|
|
36
|
+
const dispatchesNoTests = options.doctor === true && normalizedPaths.length === 0;
|
|
37
|
+
if (!dispatchesNoTests) {
|
|
38
|
+
const recordedCoverage = stale.baseline?.testPackagingCoverage;
|
|
39
|
+
const uncovered = findUncoveredRequestPaths(recordedCoverage, normalizedPaths);
|
|
40
|
+
if (uncovered.length > 0) {
|
|
41
|
+
throw new GeneralError(formatTestCoverageRefusal(uncovered, Array.isArray(recordedCoverage) ? recordedCoverage : []));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
await enforceStaticComponentsGate(engineDir, stale.baseline, options);
|
|
45
|
+
const staleMessage = stale.stale
|
|
46
|
+
? `${formatStaleBuildWarning(stale)}\n\n` +
|
|
47
|
+
'Run `fireforge test --build` to refresh the packaged runtime first, or pass ' +
|
|
48
|
+
'`--allow-stale-build` if you intentionally rebuilt out-of-band and accept the risk.'
|
|
49
|
+
: undefined;
|
|
50
|
+
if (staleMessage !== undefined) {
|
|
51
|
+
if (options.allowStaleBuild === true) {
|
|
52
|
+
warn(staleMessage);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
throw new GeneralError(staleMessage);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Compiled-StaticComponents gate — refuses runs whose child process would
|
|
61
|
+
* resolve a stale compiled component table. `components.conf` entries bake
|
|
62
|
+
* into compiled code that only a FULL build regenerates; a scoped
|
|
63
|
+
* `test --build` repackages the file but the failure surfaces as
|
|
64
|
+
* `NS_ERROR_MALFORMED_URI` inside the test. Applies to build-less runs
|
|
65
|
+
* (after the coverage refusal) AND to scoped `test --build` runs (before
|
|
66
|
+
* the pre-test build — that build cannot fix the table). A path-less
|
|
67
|
+
* `test --build` is exempt: its full build refreshes the anchor itself.
|
|
68
|
+
* `--allow-stale-components` downgrades the refusal to a warning.
|
|
69
|
+
*/
|
|
70
|
+
export async function enforceStaticComponentsGate(engineDir, baseline, options) {
|
|
71
|
+
const result = await checkStaticComponentsStale(engineDir, baseline);
|
|
72
|
+
if (!result.stale) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const message = formatStaticComponentsRefusal(result.changedManifests);
|
|
76
|
+
if (options.allowStaleComponents === true) {
|
|
77
|
+
warn(message);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
throw new GeneralError(message);
|
|
81
|
+
}
|