@hominis/fireforge 0.18.1 → 0.18.3
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/README.md +54 -33
- package/dist/src/commands/discard.js +93 -1
- package/dist/src/commands/doctor.js +17 -4
- package/dist/src/commands/download.js +21 -0
- package/dist/src/commands/export-all.js +35 -6
- 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/remove.js +68 -0
- package/dist/src/commands/import.js +9 -1
- package/dist/src/commands/lint.js +56 -10
- 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/status.js +27 -0
- package/dist/src/commands/test.js +20 -1
- package/dist/src/commands/token.js +1 -1
- package/dist/src/core/furnace-config.js +19 -0
- package/dist/src/core/git-diff.js +34 -2
- package/dist/src/core/license-headers.d.ts +8 -0
- package/dist/src/core/license-headers.js +15 -1
- package/dist/src/core/manifest-rules.js +9 -1
- 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.js +86 -29
- package/dist/src/core/register-shared-css.js +8 -2
- 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
|
@@ -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
|
|
@@ -260,6 +260,14 @@ async function assertEngineHasBaselineCommit(engineDir, options) {
|
|
|
260
260
|
warn(guidance);
|
|
261
261
|
outro('Engine baseline missing — re-run download --force');
|
|
262
262
|
}
|
|
263
|
+
if (options.json) {
|
|
264
|
+
// Mirror `--json`'s contract: errors must be machine-parseable too.
|
|
265
|
+
// Without this branch the human guidance above is suppressed but the
|
|
266
|
+
// throw still falls through to the styled error renderer in
|
|
267
|
+
// withErrorHandling, leaving JSON consumers with non-JSON output on
|
|
268
|
+
// exactly the failure mode they care about catching.
|
|
269
|
+
process.stdout.write(JSON.stringify({ error: guidance, code: 'engine-baseline-missing' }) + '\n');
|
|
270
|
+
}
|
|
263
271
|
throw new GeneralError(guidance);
|
|
264
272
|
}
|
|
265
273
|
}
|
|
@@ -278,6 +286,19 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
278
286
|
}
|
|
279
287
|
const paths = getProjectPaths(projectRoot);
|
|
280
288
|
const config = await loadConfig(projectRoot);
|
|
289
|
+
// `--json` mode contracts to machine-parseable output on every code path,
|
|
290
|
+
// including failure modes. Before this guard, errors raised below
|
|
291
|
+
// ("Firefox source not found", "engine is not a git repository") flowed
|
|
292
|
+
// through the normal styled error renderer in `withErrorHandling`, so
|
|
293
|
+
// scripts piping `status --json | jq` broke precisely when the engine was
|
|
294
|
+
// missing. Surface a structured `{ "error": ..., "code": ... }` payload
|
|
295
|
+
// and exit non-zero via GeneralError so the exit code still reflects the
|
|
296
|
+
// failure but stdout remains valid JSON. The same guard runs for
|
|
297
|
+
// ownership mode below because that path also throws on missing engine.
|
|
298
|
+
const emitJsonError = (code, message) => {
|
|
299
|
+
process.stdout.write(JSON.stringify({ error: message, code }) + '\n');
|
|
300
|
+
throw new GeneralError(message);
|
|
301
|
+
};
|
|
281
302
|
// Ownership mode is a flat file→patch table; sources are the manifest's
|
|
282
303
|
// filesAffected, any worktree drift, and the cross-patch
|
|
283
304
|
// duplicate-new-file-creation map produced by walking each patch
|
|
@@ -326,10 +347,16 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
326
347
|
}
|
|
327
348
|
// Check if engine exists
|
|
328
349
|
if (!(await pathExists(paths.engine))) {
|
|
350
|
+
if (options.json) {
|
|
351
|
+
emitJsonError('engine-missing', 'Firefox source not found. Run "fireforge download" first.');
|
|
352
|
+
}
|
|
329
353
|
throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
|
|
330
354
|
}
|
|
331
355
|
// Check if it's a git repository
|
|
332
356
|
if (!(await isGitRepository(paths.engine))) {
|
|
357
|
+
if (options.json) {
|
|
358
|
+
emitJsonError('engine-not-git', 'Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
359
|
+
}
|
|
333
360
|
throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
334
361
|
}
|
|
335
362
|
await assertEngineHasBaselineCommit(paths.engine, options);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { prepareBuildEnvironment } from '../core/build-prepare.js';
|
|
4
4
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
5
|
-
import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, testWithOutput, } from '../core/mach.js';
|
|
5
|
+
import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, testWithOutput, } from '../core/mach.js';
|
|
6
6
|
import { assertMarionettePortAvailable } from '../core/marionette-port.js';
|
|
7
7
|
import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
|
|
8
8
|
import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
|
|
@@ -193,6 +193,25 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
193
193
|
// probe have access to `binaryName` (the port probe uses it to
|
|
194
194
|
// recognise a fork-branded browser holding the Marionette port).
|
|
195
195
|
const projectConfig = await loadConfig(projectRoot);
|
|
196
|
+
// `hasBuildArtifacts` only confirms `obj-*/dist/` exists; a partial
|
|
197
|
+
// build (linker failed, packaging step interrupted, etc.) can satisfy
|
|
198
|
+
// that check without ever writing the launchable binary the marionette
|
|
199
|
+
// preflight needs to spawn. `fireforge run` already uses
|
|
200
|
+
// `hasRunnableBundle` to fail fast with a precise message; mirror that
|
|
201
|
+
// here so `test --doctor` against an incomplete build surfaces the
|
|
202
|
+
// missing-bundle path instead of a cryptic `Browser process exited
|
|
203
|
+
// during spawn (exit code 1, signal none). stderr tail: (empty)`.
|
|
204
|
+
if (buildCheck.objDir) {
|
|
205
|
+
const bundleCheck = await hasRunnableBundle(paths.engine, projectConfig.binaryName, buildCheck.objDir);
|
|
206
|
+
if (!bundleCheck.runnable) {
|
|
207
|
+
const expectedSuffix = bundleCheck.expectedPath
|
|
208
|
+
? ` (expected at engine/${bundleCheck.expectedPath})`
|
|
209
|
+
: '';
|
|
210
|
+
throw new GeneralError(`Tests require a complete launchable build${expectedSuffix}. ` +
|
|
211
|
+
'The obj-*/dist/ tree exists but the launchable binary is missing — typically the result of an interrupted or partially failed `fireforge build`.\n\n' +
|
|
212
|
+
'Run "fireforge build" again and let it finish before retrying "fireforge test".');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
196
215
|
// Run incremental build if requested
|
|
197
216
|
if (options.build) {
|
|
198
217
|
await prepareBuildEnvironment(projectRoot, paths, projectConfig);
|
|
@@ -120,7 +120,7 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
|
|
|
120
120
|
});
|
|
121
121
|
token
|
|
122
122
|
.command('add <token-name> <value>')
|
|
123
|
-
.description('Add a design token to CSS and documentation')
|
|
123
|
+
.description('Add a design token to CSS and documentation. The token name is a positional argument, but most tokens start with `--` (CSS custom property syntax), which Commander reads as an option flag. Use the standard `--` separator to mark the end of options before the token name, e.g. `fireforge token add --mode static --category Colors -- --my-token "#fff"`. Bare names without `--` are accepted directly and prefixed using the configured Furnace `tokenPrefix`.')
|
|
124
124
|
.requiredOption('--category <cat>', 'Token category (e.g., "Colors — Canvas", "Spacing")')
|
|
125
125
|
.addOption(
|
|
126
126
|
// Use Commander's .choices() so invalid --mode values are rejected with
|
|
@@ -541,6 +541,20 @@ export async function updateFurnaceState(root, updates) {
|
|
|
541
541
|
await writeJson(paths.furnaceState, validateFurnaceState(nextState));
|
|
542
542
|
});
|
|
543
543
|
}
|
|
544
|
+
/**
|
|
545
|
+
* Engine-relative path of the directory `furnace preview` writes its
|
|
546
|
+
* generated Storybook story files into. Treated as Furnace-managed so
|
|
547
|
+
* `status` does not flag them as unmanaged and `lint` does not fail on
|
|
548
|
+
* their (intentionally bare) license headers.
|
|
549
|
+
*
|
|
550
|
+
* 2026-04-25 eval Finding 19: a successful `furnace preview` run synced
|
|
551
|
+
* 23 stories under this prefix; afterwards `status` showed all 23 as
|
|
552
|
+
* untracked unmanaged changes and aggregate `lint` failed with 23
|
|
553
|
+
* `missing-license-header` errors. The files are tool output — operators
|
|
554
|
+
* are not expected to commit or hand-edit them — so the right shape is
|
|
555
|
+
* to bucket them with the rest of Furnace's managed material.
|
|
556
|
+
*/
|
|
557
|
+
const FURNACE_STORYBOOK_STORIES_PREFIX = 'browser/components/storybook/stories/furnace/';
|
|
544
558
|
/**
|
|
545
559
|
* Collects engine-relative path prefixes that are managed by the Furnace
|
|
546
560
|
* component system (overrides, custom components, and their Fluent l10n
|
|
@@ -571,6 +585,11 @@ export async function collectFurnaceManagedPrefixes(root) {
|
|
|
571
585
|
prefixes.add(ftlDir.endsWith('/') ? ftlDir : ftlDir + '/');
|
|
572
586
|
}
|
|
573
587
|
}
|
|
588
|
+
// Always include the preview-generated stories prefix when furnace is
|
|
589
|
+
// initialised. The directory may not exist yet (no preview ever ran),
|
|
590
|
+
// but classifying it as furnace-managed is safe even when empty —
|
|
591
|
+
// status simply has nothing to bucket.
|
|
592
|
+
prefixes.add(FURNACE_STORYBOOK_STORIES_PREFIX);
|
|
574
593
|
return prefixes;
|
|
575
594
|
}
|
|
576
595
|
//# sourceMappingURL=furnace-config.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { basename, join } from 'node:path';
|
|
5
5
|
import { GitError } from '../errors/git.js';
|
|
@@ -35,6 +35,16 @@ export async function getFileDiff(repoDir, filePath) {
|
|
|
35
35
|
*/
|
|
36
36
|
export async function generateNewFileDiff(repoDir, filePath) {
|
|
37
37
|
const fullPath = join(repoDir, filePath);
|
|
38
|
+
// Defensive check: a directory here means a caller bypassed the
|
|
39
|
+
// expansion layers and handed the leaf reader a path it cannot
|
|
40
|
+
// read. Surface it with an actionable message naming the offending
|
|
41
|
+
// path rather than the raw `EISDIR` that `readText` would throw —
|
|
42
|
+
// recurring bug class (see the belt-and-suspenders note in
|
|
43
|
+
// `getDiffForFilesAgainstHead`).
|
|
44
|
+
const fileStat = await stat(fullPath);
|
|
45
|
+
if (fileStat.isDirectory()) {
|
|
46
|
+
throw new GitError(`expected a file but found a directory at '${filePath}' — caller must expand directory entries before diffing`, `hash-object ${filePath}`);
|
|
47
|
+
}
|
|
38
48
|
const content = await readText(fullPath);
|
|
39
49
|
// Compute the abbreviated git blob hash for the index line
|
|
40
50
|
let blobHash = '0000000000';
|
|
@@ -212,7 +222,29 @@ export async function getDiffForFilesAgainstHead(repoDir, files) {
|
|
|
212
222
|
}
|
|
213
223
|
continue;
|
|
214
224
|
}
|
|
215
|
-
|
|
225
|
+
const fullPath = join(repoDir, file);
|
|
226
|
+
if (!(await pathExists(fullPath))) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
// Second defence against the EISDIR regression: a non-HEAD path
|
|
230
|
+
// that exists on disk is usually a new file, but can also be a
|
|
231
|
+
// directory that arrived without the trailing slash
|
|
232
|
+
// `expandUntrackedDirectoryEntries` would have produced (caller
|
|
233
|
+
// stripped it, submodule entry, tracked-file-replaced-by-dir).
|
|
234
|
+
// Expand it via the same helper used by the slash branch and
|
|
235
|
+
// recurse so each contained file is diffed individually; fail
|
|
236
|
+
// loud when the directory has no readable content rather than
|
|
237
|
+
// silently skipping it.
|
|
238
|
+
const fileStat = await stat(fullPath);
|
|
239
|
+
if (fileStat.isDirectory()) {
|
|
240
|
+
const innerFiles = await getUntrackedFilesInDir(repoDir, file);
|
|
241
|
+
if (innerFiles.length === 0) {
|
|
242
|
+
throw new GitError(`'${file}' is a directory with no untracked content (submodule or gitignored?) — cannot diff as a file`, `ls-files --others -- ${file}`);
|
|
243
|
+
}
|
|
244
|
+
const innerDiff = await getDiffForFilesAgainstHead(repoDir, innerFiles);
|
|
245
|
+
if (innerDiff.trim()) {
|
|
246
|
+
diffs.push(innerDiff);
|
|
247
|
+
}
|
|
216
248
|
continue;
|
|
217
249
|
}
|
|
218
250
|
const diff = await generateNewFileDiff(repoDir, file);
|
|
@@ -21,6 +21,14 @@ export declare function getLicenseHeader(license: ProjectLicense, style: Comment
|
|
|
21
21
|
* Returns true if `content` starts with any known license header for the
|
|
22
22
|
* given comment style.
|
|
23
23
|
*
|
|
24
|
+
* For `js` files, MPL-2.0 is also accepted in the upstream Mozilla block-
|
|
25
|
+
* comment form (`/* ... *\/`) used by the Firefox source tree, not just the
|
|
26
|
+
* `// ` line-comment form `getLicenseHeader` emits. Without that, a new JS
|
|
27
|
+
* file copied from upstream Firefox (or written to match the surrounding
|
|
28
|
+
* code's convention) hit `missing-license-header` even with a verbatim
|
|
29
|
+
* standard MPL header — operators were forced to `--skip-lint` over a real
|
|
30
|
+
* false positive.
|
|
31
|
+
*
|
|
24
32
|
* @param content - File content to check
|
|
25
33
|
* @param style - Comment syntax of the file
|
|
26
34
|
*/
|
|
@@ -57,12 +57,26 @@ export function getLicenseHeader(license, style) {
|
|
|
57
57
|
* Returns true if `content` starts with any known license header for the
|
|
58
58
|
* given comment style.
|
|
59
59
|
*
|
|
60
|
+
* For `js` files, MPL-2.0 is also accepted in the upstream Mozilla block-
|
|
61
|
+
* comment form (`/* ... *\/`) used by the Firefox source tree, not just the
|
|
62
|
+
* `// ` line-comment form `getLicenseHeader` emits. Without that, a new JS
|
|
63
|
+
* file copied from upstream Firefox (or written to match the surrounding
|
|
64
|
+
* code's convention) hit `missing-license-header` even with a verbatim
|
|
65
|
+
* standard MPL header — operators were forced to `--skip-lint` over a real
|
|
66
|
+
* false positive.
|
|
67
|
+
*
|
|
60
68
|
* @param content - File content to check
|
|
61
69
|
* @param style - Comment syntax of the file
|
|
62
70
|
*/
|
|
63
71
|
export function hasAnyLicenseHeader(content, style) {
|
|
64
72
|
const licenses = Object.keys(HEADER_LINES);
|
|
65
|
-
|
|
73
|
+
if (licenses.some((license) => content.startsWith(getLicenseHeader(license, style)))) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (style === 'js' && content.startsWith(getLicenseHeader('MPL-2.0', 'css'))) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
66
80
|
}
|
|
67
81
|
/**
|
|
68
82
|
* Returns true if `content` starts with any known license header in any
|
|
@@ -65,7 +65,15 @@ async function isSharedCSSRegistered(engineDir, fileName) {
|
|
|
65
65
|
}
|
|
66
66
|
const name = basename(fileName, '.css');
|
|
67
67
|
const content = await readText(manifestPath);
|
|
68
|
-
|
|
68
|
+
// `register` writes the canonical `skin/classic/browser/<name>.css` form;
|
|
69
|
+
// `furnace chrome-doc create` writes a `content/browser/<name>.css` entry
|
|
70
|
+
// because the CSS is loaded by a chrome document via a `chrome://browser/
|
|
71
|
+
// content/<name>.css` URI. Match either prefix so paths registered by the
|
|
72
|
+
// chrome-doc scaffolder are not flagged as "potentially unregistered" by
|
|
73
|
+
// `status` and so a re-run of `register` against the same file recognises
|
|
74
|
+
// the existing entry instead of proposing a duplicate.
|
|
75
|
+
return (content.includes(`skin/classic/browser/${name}.css`) ||
|
|
76
|
+
content.includes(`content/browser/${name}.css`));
|
|
69
77
|
}
|
|
70
78
|
async function isBrowserContentRegistered(engineDir, fileName) {
|
|
71
79
|
const manifestPath = join(engineDir, 'browser/base/jar.mn');
|
|
@@ -21,6 +21,10 @@ export interface CommitExportedPatchInput {
|
|
|
21
21
|
diff: string;
|
|
22
22
|
filesAffected: string[];
|
|
23
23
|
sourceEsrVersion: string;
|
|
24
|
+
/** Optional `PatchMetadata.tier` opt-in (only `"branding"` recognised). */
|
|
25
|
+
tier?: 'branding';
|
|
26
|
+
/** Optional `PatchMetadata.lintIgnore` (empty array treated as absent). */
|
|
27
|
+
lintIgnore?: string[];
|
|
24
28
|
}
|
|
25
29
|
export interface CommitExportedPatchResult {
|
|
26
30
|
patchFilename: string;
|
|
@@ -88,13 +92,71 @@ export type UpdatePatchCommittedHook = () => Promise<void>;
|
|
|
88
92
|
* the mutation succeeds. See {@link UpdatePatchCommittedHook}.
|
|
89
93
|
*/
|
|
90
94
|
export declare function updatePatchAndMetadata(patchesDir: string, filename: string, newContent: string, updates: Partial<PatchMetadata>, onCommitted?: UpdatePatchCommittedHook): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* Optional `PatchMetadata` keys safe to clear via the helpers below.
|
|
97
|
+
* Required keys (filename, order, etc.) are excluded by construction so
|
|
98
|
+
* an over-eager `unsetFields: ['filename']` cannot delete a field the
|
|
99
|
+
* manifest validator requires. Add new keys here only when they become
|
|
100
|
+
* optional on the type.
|
|
101
|
+
*/
|
|
102
|
+
export type ClearablePatchMetadataField = 'tier' | 'lintIgnore';
|
|
91
103
|
/**
|
|
92
104
|
* Updates metadata for a patch in the manifest.
|
|
105
|
+
*
|
|
106
|
+
* Required-field updates go through the `updates` partial. Clearing an
|
|
107
|
+
* optional field (e.g. removing the `tier` override) goes through
|
|
108
|
+
* `unsetFields` because TypeScript's `exactOptionalPropertyTypes` does
|
|
109
|
+
* not let `Partial<PatchMetadata>` carry an explicit `undefined` value
|
|
110
|
+
* for fields whose declared type does not include `undefined`. The
|
|
111
|
+
* implementation deletes the listed keys from the merged record before
|
|
112
|
+
* writing, so the on-disk JSON omits them and the validator's
|
|
113
|
+
* "preserve only when present" contract is preserved.
|
|
114
|
+
*
|
|
93
115
|
* @param patchesDir - Path to the patches directory
|
|
94
116
|
* @param filename - Patch filename
|
|
95
|
-
* @param updates -
|
|
117
|
+
* @param updates - Field values to set. Pass an empty object when only
|
|
118
|
+
* clearing fields.
|
|
119
|
+
* @param unsetFields - Optional fields to remove from the entry (so
|
|
120
|
+
* serialization drops them).
|
|
121
|
+
*/
|
|
122
|
+
export declare function updatePatchMetadata(patchesDir: string, filename: string, updates: Partial<PatchMetadata>, unsetFields?: ReadonlyArray<ClearablePatchMetadataField>): Promise<void>;
|
|
123
|
+
/**
|
|
124
|
+
* Return shape from a {@link mutatePatchMetadata} mutator.
|
|
125
|
+
*/
|
|
126
|
+
export interface PatchMetadataMutation {
|
|
127
|
+
/** Field values to set on the entry. */
|
|
128
|
+
set?: Partial<PatchMetadata>;
|
|
129
|
+
/** Optional fields to remove from the entry entirely. */
|
|
130
|
+
unset?: ReadonlyArray<ClearablePatchMetadataField>;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Result of a successful {@link mutatePatchMetadata} call.
|
|
96
134
|
*/
|
|
97
|
-
export
|
|
135
|
+
export interface PatchMetadataMutationResult {
|
|
136
|
+
/** Pre-mutation snapshot of the patch's metadata. */
|
|
137
|
+
before: PatchMetadata;
|
|
138
|
+
/** Post-mutation state of the patch's metadata. */
|
|
139
|
+
after: PatchMetadata;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Reads a patch's metadata under the directory lock, applies a mutator
|
|
143
|
+
* function to compute the update, and writes the result back — all
|
|
144
|
+
* under a single lock so a concurrent writer cannot interleave a
|
|
145
|
+
* read-modify-write cycle. Useful for operations that need to compute
|
|
146
|
+
* the new value from the old (e.g. unioning a `lintIgnore` list,
|
|
147
|
+
* removing a specific entry), which {@link updatePatchMetadata}'s flat
|
|
148
|
+
* merge cannot express on its own.
|
|
149
|
+
*
|
|
150
|
+
* The mutator returns `{ set, unset }` so it can both write fields
|
|
151
|
+
* and drop optional ones. `set` and `unset` are merged before write:
|
|
152
|
+
* `set` runs first via spread, then `unset` deletes the listed keys.
|
|
153
|
+
*
|
|
154
|
+
* @returns The pre/post metadata pair when the patch is found and the
|
|
155
|
+
* write succeeds; `null` when the manifest is missing or the named
|
|
156
|
+
* patch is not in it. Callers should treat `null` as "no-op, nothing
|
|
157
|
+
* to log".
|
|
158
|
+
*/
|
|
159
|
+
export declare function mutatePatchMetadata(patchesDir: string, filename: string, mutator: (existing: PatchMetadata) => PatchMetadataMutation): Promise<PatchMetadataMutationResult | null>;
|
|
98
160
|
/**
|
|
99
161
|
* Finds patches that are completely superseded by newer patches.
|
|
100
162
|
* A patch is superseded if all its affected files are covered by newer patches.
|
|
@@ -196,6 +258,19 @@ export interface PlanExportInput {
|
|
|
196
258
|
description: string;
|
|
197
259
|
filesAffected: string[];
|
|
198
260
|
sourceEsrVersion: string;
|
|
261
|
+
/**
|
|
262
|
+
* Optional `PatchMetadata.tier` opt-in carried from the CLI flag.
|
|
263
|
+
* Only `"branding"` is currently recognised. When provided the field
|
|
264
|
+
* is written into the new patch's metadata; when absent the field
|
|
265
|
+
* stays unset and tier resolution falls back to auto-detection.
|
|
266
|
+
*/
|
|
267
|
+
tier?: 'branding';
|
|
268
|
+
/**
|
|
269
|
+
* Optional `PatchMetadata.lintIgnore` carried from the CLI flag.
|
|
270
|
+
* Empty arrays are treated as "field absent" — the validator only
|
|
271
|
+
* preserves the field when it has at least one entry.
|
|
272
|
+
*/
|
|
273
|
+
lintIgnore?: string[];
|
|
199
274
|
}
|
|
200
275
|
/**
|
|
201
276
|
* Read-only planning function — computes everything a real export would
|