@hominis/fireforge 0.38.0 → 0.39.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 +20 -0
- package/dist/src/commands/build.js +8 -3
- package/dist/src/commands/discard.js +75 -50
- package/dist/src/commands/export-flow.d.ts +1 -1
- package/dist/src/commands/export-flow.js +7 -4
- package/dist/src/commands/export-placement-gate.js +1 -1
- package/dist/src/commands/export-shared.js +17 -2
- package/dist/src/commands/import.js +5 -0
- package/dist/src/commands/lint-per-patch.js +15 -5
- package/dist/src/commands/lint.js +1 -1
- package/dist/src/commands/patch/lint-ignore.js +20 -1
- package/dist/src/commands/patch/move-files-into.d.ts +47 -0
- package/dist/src/commands/patch/move-files-into.js +245 -0
- package/dist/src/commands/patch/move-files.js +27 -13
- package/dist/src/commands/patch/rename.js +4 -1
- package/dist/src/commands/reset.js +5 -0
- package/dist/src/commands/status-output.d.ts +9 -0
- package/dist/src/commands/status-output.js +33 -1
- package/dist/src/commands/status.js +17 -3
- package/dist/src/commands/test-diagnose.d.ts +1 -1
- package/dist/src/commands/test-diagnose.js +9 -3
- package/dist/src/commands/test-modes.d.ts +21 -0
- package/dist/src/commands/test-modes.js +42 -0
- package/dist/src/commands/test-run.d.ts +2 -0
- package/dist/src/commands/test-run.js +8 -2
- package/dist/src/commands/test.js +38 -32
- package/dist/src/core/build-baseline-types.d.ts +7 -0
- package/dist/src/core/build-baseline.d.ts +1 -1
- package/dist/src/core/build-baseline.js +2 -1
- package/dist/src/core/build-prepare.d.ts +9 -0
- package/dist/src/core/build-prepare.js +24 -0
- package/dist/src/core/config-validate.js +8 -0
- package/dist/src/core/discard-baseline.d.ts +60 -0
- package/dist/src/core/discard-baseline.js +232 -0
- package/dist/src/core/license-headers.d.ts +12 -0
- package/dist/src/core/license-headers.js +27 -0
- package/dist/src/core/patch-export.d.ts +10 -1
- package/dist/src/core/patch-export.js +12 -2
- package/dist/src/core/patch-lint-checkjs.d.ts +6 -1
- package/dist/src/core/patch-lint-checkjs.js +30 -14
- package/dist/src/core/patch-lint-cross.d.ts +9 -1
- package/dist/src/core/patch-lint-cross.js +69 -9
- package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
- package/dist/src/core/patch-lint-mozbuild.js +104 -0
- package/dist/src/core/patch-lint.js +4 -1
- package/dist/src/core/patch-policy.d.ts +14 -1
- package/dist/src/core/patch-policy.js +13 -2
- package/dist/src/core/test-harness-crash.d.ts +10 -0
- package/dist/src/core/test-harness-crash.js +16 -0
- package/dist/src/core/test-stale-check.d.ts +15 -0
- package/dist/src/core/test-stale-check.js +37 -1
- package/dist/src/core/token-manager.js +39 -2
- package/dist/src/core/token-variant.d.ts +7 -0
- package/dist/src/core/token-variant.js +9 -0
- package/dist/src/core/typecheck-shim.d.ts +13 -0
- package/dist/src/core/typecheck-shim.js +17 -2
- package/dist/src/core/typecheck.js +14 -3
- package/dist/src/types/commands/options.d.ts +12 -0
- package/dist/src/types/config.d.ts +15 -0
- package/package.json +1 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Transactional file-ownership move into an EXISTING patch (FORGE F4).
|
|
4
|
+
*
|
|
5
|
+
* Before 0.39.0, `patch move-files <from> <to>` with an existing target was
|
|
6
|
+
* preview-only: it printed two `re-export --files` commands whose real
|
|
7
|
+
* execution cost three further refusals (`--allow-shrink`, `--yes`, and a
|
|
8
|
+
* projected `duplicate-new-file-creation` unless the SHRINK landed first —
|
|
9
|
+
* while the printed plan listed the grow first). This module performs the
|
|
10
|
+
* same shrink→grow as one transaction under the patch-directory lock with
|
|
11
|
+
* rollback, mirroring `patch split` / `move-files --create`.
|
|
12
|
+
*
|
|
13
|
+
* Preconditions match `re-export`: the engine worktree must currently
|
|
14
|
+
* reflect both patches' content, because both bodies are regenerated from
|
|
15
|
+
* the worktree.
|
|
16
|
+
*/
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { appendHistory, confirmDestructive } from '../../core/destructive.js';
|
|
19
|
+
import { computeProjectedLintRegressions } from '../../core/lint-projection.js';
|
|
20
|
+
import { normalizePatchArtifact } from '../../core/patch-artifact-normalize.js';
|
|
21
|
+
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, collectForwardImportEdges, detectNewFilesInDiff, lintPatchQueue, } from '../../core/patch-lint.js';
|
|
22
|
+
import { withPatchDirectoryLock } from '../../core/patch-lock.js';
|
|
23
|
+
import { loadPatchesManifest, savePatchesManifest, validatePatchesManifest, } from '../../core/patch-manifest.js';
|
|
24
|
+
import { extractNewFileContentFromDiff } from '../../core/patch-transform.js';
|
|
25
|
+
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
26
|
+
import { toError } from '../../utils/errors.js';
|
|
27
|
+
import { readText, writeText } from '../../utils/fs.js';
|
|
28
|
+
import { info, outro, success, warn } from '../../utils/logger.js';
|
|
29
|
+
import { runPatchLint } from '../export-shared.js';
|
|
30
|
+
import { buildSplitDiff, findOwnerRewriteHolders, mergeStagedForwardImports, rewriteSplitOwners, } from './split-plan.js';
|
|
31
|
+
function projectEntryBody(diff) {
|
|
32
|
+
const newFiles = new Map();
|
|
33
|
+
for (const path of detectNewFilesInDiff(diff)) {
|
|
34
|
+
newFiles.set(path, extractNewFileContentFromDiff(diff, path));
|
|
35
|
+
}
|
|
36
|
+
return { diff, newFiles, modifiedFileAdditions: buildModifiedFileAdditionsFromDiff(diff) };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Projects the post-move queue through cross-patch lint, auto-declaring the
|
|
40
|
+
* forward edges the move introduces into the target (an importer that used
|
|
41
|
+
* to live in the same patch as the moved file gains a forward edge when the
|
|
42
|
+
* target is later-ordered). Reports only regressions the move itself adds.
|
|
43
|
+
*/
|
|
44
|
+
async function runProjectedMoveLint(patchesDir, plan) {
|
|
45
|
+
const movedSet = new Set(plan.movedFiles);
|
|
46
|
+
const baseCtx = await buildPatchQueueContext(patchesDir);
|
|
47
|
+
const entries = baseCtx.entries.map((entry) => {
|
|
48
|
+
let metadata = entry.metadata;
|
|
49
|
+
if (metadata) {
|
|
50
|
+
metadata = rewriteSplitOwners(metadata, plan.source.filename, movedSet, plan.target.filename);
|
|
51
|
+
}
|
|
52
|
+
if (entry.filename === plan.source.filename) {
|
|
53
|
+
return { ...entry, metadata, ...projectEntryBody(plan.sourceDiff) };
|
|
54
|
+
}
|
|
55
|
+
if (entry.filename === plan.target.filename) {
|
|
56
|
+
return { ...entry, metadata, ...projectEntryBody(plan.targetDiff) };
|
|
57
|
+
}
|
|
58
|
+
return { ...entry, metadata };
|
|
59
|
+
});
|
|
60
|
+
const stagedDependencyAdditions = new Map();
|
|
61
|
+
for (const edge of collectForwardImportEdges({ entries })) {
|
|
62
|
+
if (edge.owner !== plan.target.filename || !movedSet.has(edge.creates))
|
|
63
|
+
continue;
|
|
64
|
+
const decl = {
|
|
65
|
+
file: edge.sitePath,
|
|
66
|
+
specifier: edge.specifier,
|
|
67
|
+
creates: edge.creates,
|
|
68
|
+
owner: plan.target.filename,
|
|
69
|
+
};
|
|
70
|
+
const list = stagedDependencyAdditions.get(edge.entry) ?? [];
|
|
71
|
+
if (!list.some((d) => d.file === decl.file && d.specifier === decl.specifier && d.creates === decl.creates)) {
|
|
72
|
+
list.push(decl);
|
|
73
|
+
}
|
|
74
|
+
stagedDependencyAdditions.set(edge.entry, list);
|
|
75
|
+
}
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const decls = stagedDependencyAdditions.get(entry.filename);
|
|
78
|
+
if (decls?.length && entry.metadata) {
|
|
79
|
+
entry.metadata = mergeStagedForwardImports(entry.metadata, decls);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const baselineIssues = lintPatchQueue(baseCtx).filter((i) => i.severity === 'error');
|
|
83
|
+
const projectedIssues = lintPatchQueue({
|
|
84
|
+
entries,
|
|
85
|
+
...(baseCtx.patchPolicy ? { patchPolicy: baseCtx.patchPolicy } : {}),
|
|
86
|
+
}).filter((i) => i.severity === 'error');
|
|
87
|
+
const regressions = computeProjectedLintRegressions(baselineIssues, projectedIssues);
|
|
88
|
+
if (baselineIssues.length > 0 && regressions.length === 0) {
|
|
89
|
+
warn(`Note: projected queue still has ${baselineIssues.length} pre-existing cross-patch ` +
|
|
90
|
+
'error(s) unrelated to this move. Run "fireforge verify" to list them.');
|
|
91
|
+
}
|
|
92
|
+
const conflicts = regressions.length === 0
|
|
93
|
+
? null
|
|
94
|
+
: {
|
|
95
|
+
reason: `move would introduce ${regressions.length} cross-patch lint error(s)`,
|
|
96
|
+
details: regressions.map((i) => `[${i.check}] ${i.file}: ${i.message}`),
|
|
97
|
+
};
|
|
98
|
+
return { conflicts, stagedDependencyAdditions };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Commits a confirmed move under the patch-directory lock: write both
|
|
102
|
+
* rewritten bodies, then one manifest rewrite (filesAffected on both rows +
|
|
103
|
+
* owner rewrites + auto-declared forward imports). Rolled back in reverse
|
|
104
|
+
* order on any failure.
|
|
105
|
+
*/
|
|
106
|
+
async function commitMoveInto(patchesDir, plan) {
|
|
107
|
+
await withPatchDirectoryLock(patchesDir, async () => {
|
|
108
|
+
const manifest = await loadPatchesManifest(patchesDir);
|
|
109
|
+
if (!manifest)
|
|
110
|
+
throw new GeneralError('Manifest disappeared while waiting for lock.');
|
|
111
|
+
const currentSource = manifest.patches.find((p) => p.filename === plan.source.filename);
|
|
112
|
+
const currentTarget = manifest.patches.find((p) => p.filename === plan.target.filename);
|
|
113
|
+
if (!currentSource ||
|
|
114
|
+
!currentTarget ||
|
|
115
|
+
currentSource.filesAffected.join('\n') !== plan.source.filesAffected.join('\n') ||
|
|
116
|
+
currentTarget.filesAffected.join('\n') !== plan.target.filesAffected.join('\n')) {
|
|
117
|
+
throw new InvalidArgumentError('Patch queue changed while waiting for move confirmation. Re-run the command.', 'patch move-files');
|
|
118
|
+
}
|
|
119
|
+
const movedSet = new Set(plan.movedFiles);
|
|
120
|
+
const sourcePath = join(patchesDir, plan.source.filename);
|
|
121
|
+
const targetPath = join(patchesDir, plan.target.filename);
|
|
122
|
+
const originalSourceBody = await readText(sourcePath);
|
|
123
|
+
const originalTargetBody = await readText(targetPath);
|
|
124
|
+
let sourceWritten = false;
|
|
125
|
+
let targetWritten = false;
|
|
126
|
+
try {
|
|
127
|
+
await writeText(sourcePath, normalizePatchArtifact(plan.sourceDiff));
|
|
128
|
+
sourceWritten = true;
|
|
129
|
+
await writeText(targetPath, normalizePatchArtifact(plan.targetDiff));
|
|
130
|
+
targetWritten = true;
|
|
131
|
+
const updatedPatches = manifest.patches.map((patch) => {
|
|
132
|
+
let updated = rewriteSplitOwners(patch, plan.source.filename, movedSet, plan.target.filename);
|
|
133
|
+
const decls = plan.stagedDependencyAdditions.get(updated.filename);
|
|
134
|
+
if (decls?.length)
|
|
135
|
+
updated = mergeStagedForwardImports(updated, decls);
|
|
136
|
+
if (updated.filename === plan.source.filename) {
|
|
137
|
+
return { ...updated, filesAffected: plan.sourceAfter };
|
|
138
|
+
}
|
|
139
|
+
if (updated.filename === plan.target.filename) {
|
|
140
|
+
return { ...updated, filesAffected: plan.targetAfter };
|
|
141
|
+
}
|
|
142
|
+
return updated;
|
|
143
|
+
});
|
|
144
|
+
const updated = validatePatchesManifest({ ...manifest, patches: updatedPatches });
|
|
145
|
+
await savePatchesManifest(patchesDir, updated);
|
|
146
|
+
try {
|
|
147
|
+
await appendHistory(patchesDir, {
|
|
148
|
+
operation: 'patch-move-files',
|
|
149
|
+
args: {
|
|
150
|
+
source: plan.source.filename,
|
|
151
|
+
target: plan.target.filename,
|
|
152
|
+
files: plan.movedFiles,
|
|
153
|
+
ownerRewrites: plan.ownerRewrites,
|
|
154
|
+
},
|
|
155
|
+
result: 'ok',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch (historyError) {
|
|
159
|
+
warn(`History log append failed after patch move-files committed: ${toError(historyError).message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (targetWritten) {
|
|
164
|
+
try {
|
|
165
|
+
await writeText(targetPath, originalTargetBody);
|
|
166
|
+
}
|
|
167
|
+
catch (rollbackError) {
|
|
168
|
+
warn(`Rollback warning: could not restore target body: ${toError(rollbackError).message}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (sourceWritten) {
|
|
172
|
+
try {
|
|
173
|
+
await writeText(sourcePath, originalSourceBody);
|
|
174
|
+
}
|
|
175
|
+
catch (rollbackError) {
|
|
176
|
+
warn(`Rollback warning: could not restore source body: ${toError(rollbackError).message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await savePatchesManifest(patchesDir, manifest);
|
|
181
|
+
}
|
|
182
|
+
catch (rollbackError) {
|
|
183
|
+
warn(`Rollback warning: could not restore manifest: ${toError(rollbackError).message}`);
|
|
184
|
+
}
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Plans, lints, confirms, and commits a file-ownership move into an
|
|
191
|
+
* existing patch. Called by `patchMoveFilesCommand` after its ownership
|
|
192
|
+
* pre-checks resolved and validated both patches.
|
|
193
|
+
*/
|
|
194
|
+
export async function runMoveFilesInto(args) {
|
|
195
|
+
const { enginePath, patchesDir, source, target, files, sourceAfter, targetAfter, options } = args;
|
|
196
|
+
const sourceDiff = await buildSplitDiff(enginePath, sourceAfter, 'remaining', source.filename);
|
|
197
|
+
const targetDiff = await buildSplitDiff(enginePath, targetAfter, 'moved', target.filename);
|
|
198
|
+
// Per-patch lint both projected bodies with each patch's own waivers.
|
|
199
|
+
await runPatchLint(enginePath, sourceAfter, sourceDiff, args.config, options.skipLint, undefined, source.lintIgnore ? new Set(source.lintIgnore) : undefined, source.tier);
|
|
200
|
+
await runPatchLint(enginePath, targetAfter, targetDiff, args.config, options.skipLint, undefined, target.lintIgnore ? new Set(target.lintIgnore) : undefined, target.tier);
|
|
201
|
+
const manifest = await loadPatchesManifest(patchesDir);
|
|
202
|
+
const plan = {
|
|
203
|
+
source,
|
|
204
|
+
target,
|
|
205
|
+
movedFiles: files,
|
|
206
|
+
sourceAfter,
|
|
207
|
+
targetAfter,
|
|
208
|
+
sourceDiff,
|
|
209
|
+
targetDiff,
|
|
210
|
+
ownerRewrites: findOwnerRewriteHolders(manifest?.patches ?? [], source.filename, new Set(files)),
|
|
211
|
+
stagedDependencyAdditions: new Map(),
|
|
212
|
+
};
|
|
213
|
+
const { conflicts, stagedDependencyAdditions } = await runProjectedMoveLint(patchesDir, plan);
|
|
214
|
+
plan.stagedDependencyAdditions = stagedDependencyAdditions;
|
|
215
|
+
const decision = await confirmDestructive({
|
|
216
|
+
operation: 'patch-move-files',
|
|
217
|
+
title: `Move ${files.length} file(s) from ${source.filename} to ${target.filename}`,
|
|
218
|
+
summary: [
|
|
219
|
+
`moved files (${files.length}): ${files.join(', ')}`,
|
|
220
|
+
`${source.filename} keeps (${sourceAfter.length}): ${sourceAfter.join(', ')}`,
|
|
221
|
+
`${target.filename} grows to (${targetAfter.length}): ${targetAfter.join(', ')}`,
|
|
222
|
+
...(plan.ownerRewrites.length > 0
|
|
223
|
+
? [`staged-dependency owners re-pointed in: ${plan.ownerRewrites.join(', ')}`]
|
|
224
|
+
: []),
|
|
225
|
+
],
|
|
226
|
+
yes: options.yes === true,
|
|
227
|
+
dryRun: options.dryRun === true,
|
|
228
|
+
unsafeOverride: options.forceUnsafe === true,
|
|
229
|
+
conflicts,
|
|
230
|
+
});
|
|
231
|
+
if (decision === 'dry-run') {
|
|
232
|
+
outro('Dry run complete — no changes made');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (decision === 'cancelled') {
|
|
236
|
+
outro('Move cancelled');
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await commitMoveInto(patchesDir, plan);
|
|
240
|
+
success(`Moved ${files.length} file(s) from ${source.filename} to ${target.filename}`);
|
|
241
|
+
if (plan.ownerRewrites.length > 0) {
|
|
242
|
+
info(`Re-pointed staged-dependency owners in: ${plan.ownerRewrites.join(', ')}`);
|
|
243
|
+
}
|
|
244
|
+
outro('Move complete');
|
|
245
|
+
}
|
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { relative } from 'node:path';
|
|
9
9
|
import { Command } from 'commander';
|
|
10
|
-
import { getProjectPaths } from '../../core/config.js';
|
|
10
|
+
import { getProjectPaths, loadConfig } from '../../core/config.js';
|
|
11
11
|
import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
|
|
12
12
|
import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
|
|
13
13
|
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
14
14
|
import { pathExists } from '../../utils/fs.js';
|
|
15
|
-
import { info, intro, note,
|
|
15
|
+
import { info, intro, note, warn } from '../../utils/logger.js';
|
|
16
16
|
import { commanderArgParser, pickDefined } from '../../utils/options.js';
|
|
17
17
|
import { patchMoveFilesCreateCommand } from './move-files-create.js';
|
|
18
|
+
import { runMoveFilesInto } from './move-files-into.js';
|
|
18
19
|
function collectOption(value, previous) {
|
|
19
20
|
previous.push(value);
|
|
20
21
|
return previous;
|
|
@@ -123,21 +124,34 @@ export async function patchMoveFilesCommand(projectRoot, fromIdentifier, toIdent
|
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
const relativePatchesDir = relative(projectRoot, paths.patches) || 'patches';
|
|
126
|
-
info(`
|
|
127
|
+
info(`Moving ownership in ${relativePatchesDir}/patches.json.`);
|
|
127
128
|
info(`Move ${files.length} file(s) from ${source.filename} to ${target.filename}:`);
|
|
128
129
|
note(formatFiles(files), 'Files');
|
|
129
130
|
note(formatFiles(sourceAfter), `${source.filename} files after move`);
|
|
130
131
|
note(formatFiles(targetAfter), `${target.filename} files after move`);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
'
|
|
139
|
-
|
|
140
|
-
|
|
132
|
+
// Manual fallback (e.g. when the worktree cannot carry both patches'
|
|
133
|
+
// content right now): the equivalent re-export choreography, in
|
|
134
|
+
// EXECUTABLE order — the shrink must land before the grow, or the
|
|
135
|
+
// projected cross-patch lint refuses the pair with
|
|
136
|
+
// duplicate-new-file-creation (FORGE F4).
|
|
137
|
+
const applySource = formatReExportCommand(source.filename, sourceAfter, [
|
|
138
|
+
'--allow-shrink',
|
|
139
|
+
'--yes',
|
|
140
|
+
]);
|
|
141
|
+
const applyTarget = formatReExportCommand(target.filename, targetAfter, ['--yes']);
|
|
142
|
+
note(`1. ${applySource}\n2. ${applyTarget}\n(the shrink must land first)`, 'Equivalent manual commands');
|
|
143
|
+
const config = await loadConfig(projectRoot);
|
|
144
|
+
await runMoveFilesInto({
|
|
145
|
+
enginePath: paths.engine,
|
|
146
|
+
patchesDir: paths.patches,
|
|
147
|
+
source,
|
|
148
|
+
target,
|
|
149
|
+
files,
|
|
150
|
+
sourceAfter,
|
|
151
|
+
targetAfter,
|
|
152
|
+
options,
|
|
153
|
+
config,
|
|
154
|
+
});
|
|
141
155
|
}
|
|
142
156
|
/**
|
|
143
157
|
* Registers the preview-only `patch move-files` subcommand.
|
|
@@ -173,7 +173,10 @@ export async function patchRenameCommand(projectRoot, identifier, options = {})
|
|
|
173
173
|
const nameChanging = options.to !== target.name;
|
|
174
174
|
const descriptionChanging = options.description !== undefined && options.description !== target.description;
|
|
175
175
|
if (!filenameChanging && !nameChanging && !descriptionChanging) {
|
|
176
|
-
info(`${target.filename}:
|
|
176
|
+
info(`${target.filename}: nothing to change — filename already "${target.filename}" ` +
|
|
177
|
+
`(slug of "${options.to}" is "${newSlug}"), name already "${target.name}"` +
|
|
178
|
+
(options.description !== undefined ? ', description already matches' : '') +
|
|
179
|
+
'.');
|
|
177
180
|
outro(isDryRun ? 'Dry run complete — no changes made' : 'Patch rename (no-op)');
|
|
178
181
|
return;
|
|
179
182
|
}
|
|
@@ -5,6 +5,7 @@ import { getProjectPaths } from '../core/config.js';
|
|
|
5
5
|
import { clearAppliedFurnaceState } from '../core/furnace-config.js';
|
|
6
6
|
import { getHead, hasChanges, isGitRepository, isMissingHeadError, resetChanges, } from '../core/git.js';
|
|
7
7
|
import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
|
|
8
|
+
import { warnIfStaticComponentsStale } from '../core/test-stale-check.js';
|
|
8
9
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
9
10
|
import { pathExists } from '../utils/fs.js';
|
|
10
11
|
import { cancel, info, intro, isCancel, outro, spinner, warn } from '../utils/logger.js';
|
|
@@ -86,6 +87,10 @@ export async function resetCommand(projectRoot, options) {
|
|
|
86
87
|
// tree, not the engine checkout, so reset must not silently forget them.
|
|
87
88
|
await clearAppliedFurnaceState(projectRoot);
|
|
88
89
|
s.stop('Changes reset');
|
|
90
|
+
// The reset may have moved components.conf away from what the last full
|
|
91
|
+
// build compiled in — surface that now instead of at the next test
|
|
92
|
+
// refusal (FORGE F13).
|
|
93
|
+
await warnIfStaticComponentsStale(projectRoot, paths.engine);
|
|
89
94
|
outro('Working tree restored to clean state');
|
|
90
95
|
}
|
|
91
96
|
catch (error) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BuildBaseline } from '../core/build-baseline-types.js';
|
|
1
2
|
import type { ClassifiedFile } from '../core/status-classify.js';
|
|
2
3
|
export interface ClassifiedBuckets {
|
|
3
4
|
conflict: ClassifiedFile[];
|
|
@@ -11,3 +12,11 @@ export interface ClassifiedBuckets {
|
|
|
11
12
|
export declare function renderUnmanagedOnly(unmanagedFiles: ClassifiedFile[], totalModified: number, projectRoot: string, binaryName: string): Promise<void>;
|
|
12
13
|
/** Renders the default classified status buckets. */
|
|
13
14
|
export declare function renderDefaultStatus(totalModified: number, buckets: ClassifiedBuckets, projectRoot: string, binaryName: string): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Renders `fireforge status --test-coverage` (FORGE F11): a READ-ONLY view
|
|
17
|
+
* of the last build baseline's test-packaging coverage. Before this
|
|
18
|
+
* existed, the only way to learn the recorded coverage scope — which
|
|
19
|
+
* concurrent sessions sharing one engine tree overwrite constantly — was
|
|
20
|
+
* to trip the out-of-coverage refusal on a real test run.
|
|
21
|
+
*/
|
|
22
|
+
export declare function renderTestCoverageStatus(baseline: BuildBaseline | undefined): void;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// SPDX-License-Identifier: EUPL-1.2
|
|
2
1
|
import { isFileRegistered, matchesRegistrablePattern } from '../core/manifest-rules.js';
|
|
3
2
|
import { getPrimaryStatusCode } from '../core/status-classify.js';
|
|
4
3
|
import { GeneralError } from '../errors/base.js';
|
|
@@ -182,3 +181,36 @@ export async function renderDefaultStatus(totalModified, buckets, projectRoot, b
|
|
|
182
181
|
.map((section) => `${section.files.length} ${section.label}`);
|
|
183
182
|
outro(parts.join(', '));
|
|
184
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Renders `fireforge status --test-coverage` (FORGE F11): a READ-ONLY view
|
|
186
|
+
* of the last build baseline's test-packaging coverage. Before this
|
|
187
|
+
* existed, the only way to learn the recorded coverage scope — which
|
|
188
|
+
* concurrent sessions sharing one engine tree overwrite constantly — was
|
|
189
|
+
* to trip the out-of-coverage refusal on a real test run.
|
|
190
|
+
*/
|
|
191
|
+
export function renderTestCoverageStatus(baseline) {
|
|
192
|
+
if (baseline === undefined) {
|
|
193
|
+
info('No build baseline recorded (.fireforge/last-build.json missing or unreadable).\n' +
|
|
194
|
+
'Run "fireforge build" to record one.');
|
|
195
|
+
outro('No test-packaging coverage recorded');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
info(`Last build: ${baseline.builtAt} (binary: ${baseline.binaryName}, ` +
|
|
199
|
+
`engine HEAD: ${baseline.engineHeadSha || '(unborn)'})`);
|
|
200
|
+
info(`Recorded by: ${baseline.recordedBy ?? 'unknown'}`);
|
|
201
|
+
const coverage = baseline.testPackagingCoverage;
|
|
202
|
+
if (coverage === undefined) {
|
|
203
|
+
info('Test packaging coverage: full (implicit — baseline predates coverage recording)');
|
|
204
|
+
outro('Coverage: full');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (coverage === 'full') {
|
|
208
|
+
info('Test packaging coverage: full');
|
|
209
|
+
outro('Coverage: full');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const list = coverage.map((path) => ` - ${path}`).join('\n');
|
|
213
|
+
info(`Test packaging coverage: scoped to ${String(coverage.length)} path(s):\n${list}\n` +
|
|
214
|
+
'A "fireforge test" over paths outside this list will be refused as uncovered.');
|
|
215
|
+
outro(`Coverage: scoped (${String(coverage.length)} paths)`);
|
|
216
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
+
import { readBuildBaseline } from '../core/build-baseline.js';
|
|
3
4
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
4
5
|
import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
5
6
|
import { getHead, getStatusWithCodes, isGitRepository, isMissingHeadError } from '../core/git.js';
|
|
@@ -12,7 +13,7 @@ import { CommandError, GeneralError } from '../errors/base.js';
|
|
|
12
13
|
import { ExitCode } from '../errors/codes.js';
|
|
13
14
|
import { FIREFORGE_TMP_PATH_PATTERN, pathExists } from '../utils/fs.js';
|
|
14
15
|
import { info, intro, isMachineOutputMode, outro, setMachineOutputMode, warn, } from '../utils/logger.js';
|
|
15
|
-
import { renderDefaultStatus, renderUnmanagedOnly, } from './status-output.js';
|
|
16
|
+
import { renderDefaultStatus, renderTestCoverageStatus, renderUnmanagedOnly, } from './status-output.js';
|
|
16
17
|
/**
|
|
17
18
|
* Renders raw worktree status as machine-parseable porcelain-style output.
|
|
18
19
|
* Each line is: STATUS<tab>FILE
|
|
@@ -170,13 +171,25 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
170
171
|
setMachineOutputMode(true);
|
|
171
172
|
}
|
|
172
173
|
try {
|
|
173
|
-
const modeCount = [
|
|
174
|
+
const modeCount = [
|
|
175
|
+
options.raw,
|
|
176
|
+
options.unmanaged,
|
|
177
|
+
options.ownership,
|
|
178
|
+
options.testCoverage,
|
|
179
|
+
options.json,
|
|
180
|
+
].filter((v) => v === true).length;
|
|
174
181
|
if (modeCount > 1) {
|
|
175
|
-
throw new GeneralError('Cannot use --raw, --unmanaged, --ownership, and --json together. Pick at most one.');
|
|
182
|
+
throw new GeneralError('Cannot use --raw, --unmanaged, --ownership, --test-coverage, and --json together. Pick at most one.');
|
|
176
183
|
}
|
|
177
184
|
if (!options.raw && !options.json) {
|
|
178
185
|
intro('FireForge Status');
|
|
179
186
|
}
|
|
187
|
+
// Test-coverage mode needs only the project root (the baseline lives
|
|
188
|
+
// in .fireforge/), so it short-circuits before any engine/git guards.
|
|
189
|
+
if (options.testCoverage) {
|
|
190
|
+
renderTestCoverageStatus(await readBuildBaseline(projectRoot));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
180
193
|
const paths = getProjectPaths(projectRoot);
|
|
181
194
|
const config = await loadConfig(projectRoot);
|
|
182
195
|
const emitJsonError = (code, message) => {
|
|
@@ -309,6 +322,7 @@ export function registerStatus(program, { getProjectRoot, withErrorHandling }) {
|
|
|
309
322
|
.option('--raw', 'Show raw worktree status without patch classification')
|
|
310
323
|
.option('--unmanaged', 'Show only unmanaged changes (not covered by patches or tools)')
|
|
311
324
|
.option('--ownership', 'Show a flat path → owning patch table (flags files claimed by multiple patches)')
|
|
325
|
+
.option('--test-coverage', 'Show what the last recorded build covers for test packaging (full or scoped paths)')
|
|
312
326
|
.option('--json', 'Output classified file status as JSON')
|
|
313
327
|
.action(withErrorHandling(async (options) => {
|
|
314
328
|
await statusCommand(getProjectRoot(), options);
|
|
@@ -14,7 +14,7 @@ import type { TestRunOutcome } from './test-run.js';
|
|
|
14
14
|
* harness problems with their own messages; everything else flows into
|
|
15
15
|
* the regular non-zero-exit diagnosis chain.
|
|
16
16
|
*/
|
|
17
|
-
export declare function finalizeSingleRunOutcome(outcome: TestRunOutcome, normalizedPaths: string[], binaryName: string, postRebuildContext: PostRebuildFailureContext | undefined): void;
|
|
17
|
+
export declare function finalizeSingleRunOutcome(outcome: TestRunOutcome, normalizedPaths: string[], binaryName: string, postRebuildContext: PostRebuildFailureContext | undefined, headless?: boolean): void;
|
|
18
18
|
/**
|
|
19
19
|
* Shard-mode adapter over {@link handleNonZeroTestExit}: produces the
|
|
20
20
|
* diagnosis text as a string (to warn per shard) instead of throwing, so
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* `test-harness-crash.ts` for single and sharded invocations. Split out of
|
|
8
8
|
* `test.ts` to keep both files within the per-file line budget.
|
|
9
9
|
*/
|
|
10
|
-
import { buildGreenSummaryRejectedMessage, buildHarnessCrashMessage, buildNoTestsRanMessage, } from '../core/test-harness-crash.js';
|
|
10
|
+
import { buildGreenSummaryRejectedMessage, buildHarnessCrashMessage, buildNoTestsRanMessage, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
|
|
11
11
|
import { buildHarnessEarlyExitMessage, classifyHarnessEarlyExit, completePostRebuildFailureContext, prependPostRebuildFailureContext, } from '../core/test-harness-output.js';
|
|
12
12
|
import { GeneralError } from '../errors/base.js';
|
|
13
13
|
import { BuildError } from '../errors/build.js';
|
|
14
14
|
import { info } from '../utils/logger.js';
|
|
15
|
+
import { getPlatform } from '../utils/platform.js';
|
|
15
16
|
function buildUnknownTestMessage(testPaths) {
|
|
16
17
|
return (`mach could not discover the requested test path${testPaths.length === 1 ? '' : 's'}: ${testPaths.join(', ')}\n\n` +
|
|
17
18
|
'The file may exist, but Firefox does not currently resolve it as a runnable test.\n\n' +
|
|
@@ -179,9 +180,14 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
|
|
|
179
180
|
* harness problems with their own messages; everything else flows into
|
|
180
181
|
* the regular non-zero-exit diagnosis chain.
|
|
181
182
|
*/
|
|
182
|
-
export function finalizeSingleRunOutcome(outcome, normalizedPaths, binaryName, postRebuildContext) {
|
|
183
|
+
export function finalizeSingleRunOutcome(outcome, normalizedPaths, binaryName, postRebuildContext, headless = false) {
|
|
183
184
|
if (outcome.verdict.kind === 'harness-crash' && outcome.verdict.signature) {
|
|
184
|
-
|
|
185
|
+
const base = buildHarnessCrashMessage(outcome.verdict.signature, outcome.attempts);
|
|
186
|
+
const hint = headedNoOutputTimeoutHint(outcome.verdict.signature, {
|
|
187
|
+
headless,
|
|
188
|
+
platform: getPlatform(),
|
|
189
|
+
});
|
|
190
|
+
throw new GeneralError(hint ? `${base}\n\n${hint}` : base);
|
|
185
191
|
}
|
|
186
192
|
if (outcome.verdict.kind === 'tests-ran-ok') {
|
|
187
193
|
// The verdict is authoritative over the raw exit code: a completed
|
|
@@ -12,4 +12,25 @@ export declare function resolveCanaryPath(options: TestOptions, projectConfig: P
|
|
|
12
12
|
export declare function canaryTimeoutSeconds(projectConfig: ProjectConfig): number;
|
|
13
13
|
/** Emits or throws the one-word canary verdict. */
|
|
14
14
|
export declare function reportCanaryOutcome(outcome: TestRunOutcome): void;
|
|
15
|
+
/** Per-harness bucketing of the requested test paths. */
|
|
16
|
+
export interface HarnessClassification {
|
|
17
|
+
xpcshell: string[];
|
|
18
|
+
nonXpcshell: string[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Harness classification is a pure path→manifest lookup and must precede
|
|
22
|
+
* the build dispatch: a mixed xpcshell+mochitest request can never
|
|
23
|
+
* dispatch, so refusing it before spending minutes in a pre-test build
|
|
24
|
+
* is strictly better (FORGE F7). A nonexistent path classifies as
|
|
25
|
+
* nonXpcshell (findNearestXpcshellManifest returns null), so classifying
|
|
26
|
+
* before assertTestPathsExist is harmless — existence is still asserted
|
|
27
|
+
* after the stale/coverage gates, preserving their precedence over
|
|
28
|
+
* missing-path errors. `xpcshellOnly` (non-empty request, zero
|
|
29
|
+
* nonXpcshell paths) gates the Marionette preflight and the mochitest
|
|
30
|
+
* client flags (FORGE F10); pathless runs keep the full-suite behavior.
|
|
31
|
+
*/
|
|
32
|
+
export declare function classifyBeforeDispatch(engineDir: string, normalizedPaths: string[]): Promise<{
|
|
33
|
+
classification: HarnessClassification;
|
|
34
|
+
xpcshellOnly: boolean;
|
|
35
|
+
}>;
|
|
15
36
|
export {};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { loadConfig } from '../core/config.js';
|
|
3
|
+
import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
|
|
3
4
|
import { GeneralError } from '../errors/base.js';
|
|
4
5
|
import { success } from '../utils/logger.js';
|
|
5
6
|
/** Rejects pathless `fireforge test` unless the operator selected a pathless mode. */
|
|
@@ -59,3 +60,44 @@ function buildPathlessTestMessage() {
|
|
|
59
60
|
' - `fireforge test --doctor` to run the Marionette preflight only\n' +
|
|
60
61
|
' - `fireforge test --canary` to run the configured short harness canary');
|
|
61
62
|
}
|
|
63
|
+
async function classifyTestHarnesses(engineDir, normalizedPaths) {
|
|
64
|
+
const result = { xpcshell: [], nonXpcshell: [] };
|
|
65
|
+
for (const testPath of normalizedPaths) {
|
|
66
|
+
const manifest = await findNearestXpcshellManifest(engineDir, testPath);
|
|
67
|
+
if (manifest) {
|
|
68
|
+
result.xpcshell.push(testPath);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
result.nonXpcshell.push(testPath);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
function buildMixedHarnessMessage(classification) {
|
|
77
|
+
return ('FireForge cannot run xpcshell and browser/mochitest paths in the same mach invocation.\n\n' +
|
|
78
|
+
'Split this into separate `fireforge test` commands so each manifest selects its own harness:\n' +
|
|
79
|
+
` - xpcshell: ${classification.xpcshell.join(', ')}\n` +
|
|
80
|
+
` - browser/mochitest: ${classification.nonXpcshell.join(', ')}`);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Harness classification is a pure path→manifest lookup and must precede
|
|
84
|
+
* the build dispatch: a mixed xpcshell+mochitest request can never
|
|
85
|
+
* dispatch, so refusing it before spending minutes in a pre-test build
|
|
86
|
+
* is strictly better (FORGE F7). A nonexistent path classifies as
|
|
87
|
+
* nonXpcshell (findNearestXpcshellManifest returns null), so classifying
|
|
88
|
+
* before assertTestPathsExist is harmless — existence is still asserted
|
|
89
|
+
* after the stale/coverage gates, preserving their precedence over
|
|
90
|
+
* missing-path errors. `xpcshellOnly` (non-empty request, zero
|
|
91
|
+
* nonXpcshell paths) gates the Marionette preflight and the mochitest
|
|
92
|
+
* client flags (FORGE F10); pathless runs keep the full-suite behavior.
|
|
93
|
+
*/
|
|
94
|
+
export async function classifyBeforeDispatch(engineDir, normalizedPaths) {
|
|
95
|
+
const classification = await classifyTestHarnesses(engineDir, normalizedPaths);
|
|
96
|
+
if (classification.xpcshell.length > 0 && classification.nonXpcshell.length > 0) {
|
|
97
|
+
throw new GeneralError(buildMixedHarnessMessage(classification));
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
classification,
|
|
101
|
+
xpcshellOnly: normalizedPaths.length > 0 && classification.nonXpcshell.length === 0,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -36,6 +36,8 @@ export interface TestRunContext {
|
|
|
36
36
|
baseExtraArgs: readonly string[];
|
|
37
37
|
/** Bounded harness-crash retry budget (0 disables retries). */
|
|
38
38
|
harnessRetries: number;
|
|
39
|
+
/** Whether --headless was requested (crash-hint copy only). */
|
|
40
|
+
headless: boolean;
|
|
39
41
|
/** Extra environment variables for the mach process. */
|
|
40
42
|
env?: Record<string, string>;
|
|
41
43
|
}
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
* combined invocation stays available via `--no-shard`.
|
|
13
13
|
*/
|
|
14
14
|
import { mochitestWithOutput, testWithOutput, xpcshellTestWithOutput, } from '../core/mach.js';
|
|
15
|
-
import { buildHarnessCrashMessage, classifyHarnessRun, } from '../core/test-harness-crash.js';
|
|
15
|
+
import { buildHarnessCrashMessage, classifyHarnessRun, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
|
|
16
16
|
import { retryAfterXpcshellSymlinkRepair } from '../core/test-xpcshell-retry.js';
|
|
17
17
|
import { BuildError } from '../errors/build.js';
|
|
18
18
|
import { info, note, warn } from '../utils/logger.js';
|
|
19
|
+
import { getPlatform } from '../utils/platform.js';
|
|
19
20
|
import { maybeInjectAppdirArg } from './test-appdir.js';
|
|
20
21
|
/** Default bounded retry budget for recognized harness crashes. */
|
|
21
22
|
export const DEFAULT_HARNESS_RETRIES = 2;
|
|
@@ -78,7 +79,12 @@ export async function runShardedTests(ctx, groups, diagnoseShardFailure) {
|
|
|
78
79
|
const outcome = await runTestsWithRetries(ctx, group.paths);
|
|
79
80
|
shards.push({ label: group.label, outcome });
|
|
80
81
|
if (outcome.verdict.kind === 'harness-crash' && outcome.verdict.signature) {
|
|
81
|
-
|
|
82
|
+
const base = buildHarnessCrashMessage(outcome.verdict.signature, outcome.attempts);
|
|
83
|
+
const hint = headedNoOutputTimeoutHint(outcome.verdict.signature, {
|
|
84
|
+
headless: ctx.headless,
|
|
85
|
+
platform: getPlatform(),
|
|
86
|
+
});
|
|
87
|
+
warn(hint ? `${base}\n\n${hint}` : base);
|
|
82
88
|
}
|
|
83
89
|
else if (outcome.verdict.kind !== 'tests-ran-ok') {
|
|
84
90
|
const diagnosis = diagnoseShardFailure(outcome, group.label);
|