@hominis/fireforge 0.16.5 → 0.18.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 +56 -0
- package/README.md +46 -24
- package/dist/src/commands/build.js +33 -10
- package/dist/src/commands/config.js +32 -20
- package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
- package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
- package/dist/src/commands/doctor-furnace.js +2 -0
- package/dist/src/commands/doctor-working-tree.d.ts +29 -0
- package/dist/src/commands/doctor-working-tree.js +93 -0
- package/dist/src/commands/doctor.js +23 -12
- package/dist/src/commands/export-all.js +11 -3
- package/dist/src/commands/export-shared.d.ts +7 -1
- package/dist/src/commands/export-shared.js +21 -3
- package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
- package/dist/src/commands/furnace/create-templates.d.ts +11 -0
- package/dist/src/commands/furnace/create-templates.js +11 -2
- package/dist/src/commands/furnace/init.js +97 -9
- package/dist/src/commands/furnace/override.js +23 -13
- package/dist/src/commands/furnace/remove.js +8 -0
- package/dist/src/commands/furnace/rename.js +133 -4
- package/dist/src/commands/lint.js +70 -6
- package/dist/src/commands/patch/delete.js +4 -1
- package/dist/src/commands/patch/reorder.js +4 -1
- package/dist/src/commands/re-export-files.js +3 -1
- package/dist/src/commands/re-export.js +4 -1
- package/dist/src/commands/register.js +11 -0
- package/dist/src/commands/resolve.d.ts +25 -1
- package/dist/src/commands/resolve.js +25 -15
- package/dist/src/commands/status.js +100 -122
- package/dist/src/commands/test.js +68 -14
- package/dist/src/commands/token-coverage.js +10 -3
- package/dist/src/commands/wire.js +50 -8
- package/dist/src/core/browser-wire.js +21 -4
- package/dist/src/core/build-audit.js +10 -0
- package/dist/src/core/config.d.ts +33 -0
- package/dist/src/core/config.js +43 -0
- package/dist/src/core/furnace-config.d.ts +23 -2
- package/dist/src/core/furnace-config.js +26 -3
- package/dist/src/core/git-diff.js +21 -2
- package/dist/src/core/mach.d.ts +43 -6
- package/dist/src/core/mach.js +57 -7
- package/dist/src/core/manifest-rules.js +10 -1
- package/dist/src/core/manifest-tokenizers.d.ts +6 -0
- package/dist/src/core/manifest-tokenizers.js +28 -0
- package/dist/src/core/marionette-port.d.ts +50 -0
- package/dist/src/core/marionette-port.js +215 -0
- package/dist/src/core/patch-lint.d.ts +47 -2
- package/dist/src/core/patch-lint.js +89 -14
- package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
- package/dist/src/core/patch-manifest-consistency.js +31 -3
- package/dist/src/core/patch-manifest-io.js +10 -0
- package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
- package/dist/src/core/patch-manifest-resolve.js +29 -2
- package/dist/src/core/patch-manifest-validate.js +25 -1
- package/dist/src/core/status-classify.d.ts +54 -0
- package/dist/src/core/status-classify.js +134 -0
- package/dist/src/core/token-coverage.js +24 -0
- package/dist/src/core/token-dark-mode.d.ts +49 -0
- package/dist/src/core/token-dark-mode.js +182 -0
- package/dist/src/core/token-manager.js +17 -33
- package/dist/src/core/wire-destroy.d.ts +7 -3
- package/dist/src/core/wire-destroy.js +11 -6
- package/dist/src/core/wire-dom-fragment.d.ts +17 -0
- package/dist/src/core/wire-dom-fragment.js +40 -0
- package/dist/src/core/wire-init.d.ts +9 -3
- package/dist/src/core/wire-init.js +18 -6
- package/dist/src/core/wire-subscript.d.ts +7 -3
- package/dist/src/core/wire-subscript.js +11 -4
- package/dist/src/types/commands/patches.d.ts +23 -0
- package/dist/src/types/furnace.d.ts +9 -0
- package/dist/src/utils/parse.d.ts +7 -0
- package/dist/src/utils/parse.js +15 -0
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership-aware working-tree check for `fireforge doctor`.
|
|
3
|
+
*
|
|
4
|
+
* Partitions engine-tree dirtiness into `branding`, `patch-backed`,
|
|
5
|
+
* `furnace`, `conflict`, and `unmanaged` buckets, and only warns on the
|
|
6
|
+
* last two — everything else is tool-managed state that the operator
|
|
7
|
+
* did not author directly.
|
|
8
|
+
*
|
|
9
|
+
* Split out of `doctor.ts` so that file stays under the per-file LOC
|
|
10
|
+
* budget; see the call site in `runEngineGitChecks`.
|
|
11
|
+
*/
|
|
12
|
+
import type { DoctorCheck } from '../types/commands/index.js';
|
|
13
|
+
import type { DoctorCheckContext } from './doctor.js';
|
|
14
|
+
/**
|
|
15
|
+
* Inspects the engine working tree and returns a single
|
|
16
|
+
* `DoctorCheck`. Ownership-aware: patch-backed / branding / furnace
|
|
17
|
+
* rows are reported as OK with an ownership summary; unmanaged drift
|
|
18
|
+
* warns; cross-patch conflicts warn loudly with a pointer at
|
|
19
|
+
* `fireforge status --ownership` + `fireforge verify`.
|
|
20
|
+
*
|
|
21
|
+
* Before 0.16.1 this check warned on every dirty row regardless of
|
|
22
|
+
* ownership and told the operator to export/discard/reset — advice
|
|
23
|
+
* that was actively destructive on a patch-backed import (eval
|
|
24
|
+
* Finding: a correctly imported 126-file patch stack was reported as
|
|
25
|
+
* unhealthy and the suggested fix would have dropped the entire
|
|
26
|
+
* import). Returns `undefined` when the worktree is clean so the
|
|
27
|
+
* caller can emit its own ok() row.
|
|
28
|
+
*/
|
|
29
|
+
export declare function inspectEngineWorkingTree(ctx: DoctorCheckContext): Promise<DoctorCheck | undefined>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Ownership-aware working-tree check for `fireforge doctor`.
|
|
4
|
+
*
|
|
5
|
+
* Partitions engine-tree dirtiness into `branding`, `patch-backed`,
|
|
6
|
+
* `furnace`, `conflict`, and `unmanaged` buckets, and only warns on the
|
|
7
|
+
* last two — everything else is tool-managed state that the operator
|
|
8
|
+
* did not author directly.
|
|
9
|
+
*
|
|
10
|
+
* Split out of `doctor.ts` so that file stays under the per-file LOC
|
|
11
|
+
* budget; see the call site in `runEngineGitChecks`.
|
|
12
|
+
*/
|
|
13
|
+
import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
14
|
+
import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
|
|
15
|
+
import { classifyFiles } from '../core/status-classify.js';
|
|
16
|
+
import { ok, warning } from './doctor.js';
|
|
17
|
+
function summarizeWorkingTreeChangeCount(changeCount) {
|
|
18
|
+
return `Engine working tree has ${changeCount} local change${changeCount === 1 ? '' : 's'}. Some FireForge commands assume a clean baseline and may behave differently until these are exported, discarded, or committed.`;
|
|
19
|
+
}
|
|
20
|
+
function formatManagedDetail(counts) {
|
|
21
|
+
return [
|
|
22
|
+
counts.patchBacked > 0 ? `${counts.patchBacked} patch-backed` : null,
|
|
23
|
+
counts.branding > 0 ? `${counts.branding} branding` : null,
|
|
24
|
+
counts.furnace > 0 ? `${counts.furnace} furnace` : null,
|
|
25
|
+
]
|
|
26
|
+
.filter((part) => part !== null)
|
|
27
|
+
.join(', ');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Inspects the engine working tree and returns a single
|
|
31
|
+
* `DoctorCheck`. Ownership-aware: patch-backed / branding / furnace
|
|
32
|
+
* rows are reported as OK with an ownership summary; unmanaged drift
|
|
33
|
+
* warns; cross-patch conflicts warn loudly with a pointer at
|
|
34
|
+
* `fireforge status --ownership` + `fireforge verify`.
|
|
35
|
+
*
|
|
36
|
+
* Before 0.16.1 this check warned on every dirty row regardless of
|
|
37
|
+
* ownership and told the operator to export/discard/reset — advice
|
|
38
|
+
* that was actively destructive on a patch-backed import (eval
|
|
39
|
+
* Finding: a correctly imported 126-file patch stack was reported as
|
|
40
|
+
* unhealthy and the suggested fix would have dropped the entire
|
|
41
|
+
* import). Returns `undefined` when the worktree is clean so the
|
|
42
|
+
* caller can emit its own ok() row.
|
|
43
|
+
*/
|
|
44
|
+
export async function inspectEngineWorkingTree(ctx) {
|
|
45
|
+
const { paths } = ctx;
|
|
46
|
+
const rawStatus = await getWorkingTreeStatus(paths.engine);
|
|
47
|
+
const workingTreeStatus = await expandUntrackedDirectoryEntries(paths.engine, rawStatus);
|
|
48
|
+
if (workingTreeStatus.length === 0) {
|
|
49
|
+
return ok('Engine working tree');
|
|
50
|
+
}
|
|
51
|
+
if (!ctx.config) {
|
|
52
|
+
return warning('Engine working tree', summarizeWorkingTreeChangeCount(workingTreeStatus.length), 'Use "fireforge status" to review changes, then export, discard, or reset them as appropriate.');
|
|
53
|
+
}
|
|
54
|
+
const furnacePrefixes = await collectFurnaceManagedPrefixes(ctx.projectRoot);
|
|
55
|
+
const classified = await classifyFiles(workingTreeStatus.map((entry) => ({ status: entry.status, file: entry.file })), paths.engine, paths.patches, ctx.config.binaryName, furnacePrefixes);
|
|
56
|
+
const counts = {
|
|
57
|
+
branding: 0,
|
|
58
|
+
furnace: 0,
|
|
59
|
+
patchBacked: 0,
|
|
60
|
+
conflict: 0,
|
|
61
|
+
unmanaged: 0,
|
|
62
|
+
};
|
|
63
|
+
for (const entry of classified) {
|
|
64
|
+
if (entry.classification === 'branding')
|
|
65
|
+
counts.branding++;
|
|
66
|
+
else if (entry.classification === 'furnace')
|
|
67
|
+
counts.furnace++;
|
|
68
|
+
else if (entry.classification === 'patch-backed')
|
|
69
|
+
counts.patchBacked++;
|
|
70
|
+
else if (entry.classification === 'conflict')
|
|
71
|
+
counts.conflict++;
|
|
72
|
+
else
|
|
73
|
+
counts.unmanaged++;
|
|
74
|
+
}
|
|
75
|
+
if (counts.conflict > 0) {
|
|
76
|
+
return warning('Engine working tree', `Engine working tree has ${counts.conflict} cross-patch ownership conflict${counts.conflict === 1 ? '' : 's'}. Multiple patches in patches.json claim the same file.`, 'Run "fireforge status --ownership" to see the conflicting patches, then run "fireforge verify" and resolve the overlap.');
|
|
77
|
+
}
|
|
78
|
+
const managedTotal = counts.branding + counts.furnace + counts.patchBacked;
|
|
79
|
+
if (counts.unmanaged === 0) {
|
|
80
|
+
const managedDetail = formatManagedDetail(counts);
|
|
81
|
+
return {
|
|
82
|
+
name: 'Engine working tree',
|
|
83
|
+
passed: true,
|
|
84
|
+
severity: 'ok',
|
|
85
|
+
message: `${managedTotal} tool-managed change${managedTotal === 1 ? '' : 's'} (${managedDetail}), 0 unmanaged. Use "fireforge status --ownership" for details.`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const managedTail = managedTotal > 0
|
|
89
|
+
? ` (${managedTotal} other change${managedTotal === 1 ? '' : 's'} are tool-managed: ${formatManagedDetail(counts)}).`
|
|
90
|
+
: '';
|
|
91
|
+
return warning('Engine working tree', `Engine working tree has ${counts.unmanaged} unmanaged change${counts.unmanaged === 1 ? '' : 's'}.${managedTail}`, 'Use "fireforge status --ownership" to separate patch-backed from unmanaged files, then export, discard, or reset only the unmanaged set.');
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=doctor-working-tree.js.map
|
|
@@ -2,7 +2,6 @@ import { configExists, getProjectPaths, loadConfig, loadState } from '../core/co
|
|
|
2
2
|
import { furnaceConfigExists as checkFurnaceConfigExists } from '../core/furnace-config.js';
|
|
3
3
|
import { getCurrentBranch, getHead, isGitRepository, isMissingHeadError } from '../core/git.js';
|
|
4
4
|
import { ensureGit } from '../core/git-base.js';
|
|
5
|
-
import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
|
|
6
5
|
import { ensureMach, ensurePython } from '../core/mach.js';
|
|
7
6
|
import { countPatches } from '../core/patch-apply.js';
|
|
8
7
|
import { rebuildPatchesManifest, validatePatchesManifestConsistency, validatePatchIntegrity, } from '../core/patch-manifest.js';
|
|
@@ -12,6 +11,7 @@ import { pathExists } from '../utils/fs.js';
|
|
|
12
11
|
import { error, info, intro, outro, success, warn } from '../utils/logger.js';
|
|
13
12
|
import { executableExists } from '../utils/process.js';
|
|
14
13
|
import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
|
|
14
|
+
import { inspectEngineWorkingTree } from './doctor-working-tree.js';
|
|
15
15
|
/**
|
|
16
16
|
* Builds a DoctorCheck object representing a successful "OK" check.
|
|
17
17
|
* Exported for sibling check modules that declare `DoctorCheckDefinition`
|
|
@@ -58,9 +58,6 @@ async function executeCheck(definition, ctx) {
|
|
|
58
58
|
return [failure(definition.name, toError(err).message, definition.fix)];
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
function summarizeWorkingTreeChangeCount(changeCount) {
|
|
62
|
-
return `Engine working tree has ${changeCount} local change${changeCount === 1 ? '' : 's'}. Some FireForge commands assume a clean baseline and may behave differently until these are exported, discarded, or committed.`;
|
|
63
|
-
}
|
|
64
61
|
/**
|
|
65
62
|
* Runs the subset of engine checks that depend on a healthy git repository
|
|
66
63
|
* and HEAD. This group shares mutable state (currentHead, canValidateBranch),
|
|
@@ -89,13 +86,9 @@ async function runEngineGitChecks(ctx) {
|
|
|
89
86
|
rows.push(ok('Engine state consistency'));
|
|
90
87
|
}
|
|
91
88
|
}
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
rows.push(warning('Engine working tree', summarizeWorkingTreeChangeCount(workingTreeStatus.length), 'Use "fireforge status" to review changes, then export, discard, or reset them as appropriate.'));
|
|
96
|
-
}
|
|
97
|
-
else {
|
|
98
|
-
rows.push(ok('Engine working tree'));
|
|
89
|
+
const workingTreeRow = await inspectEngineWorkingTree(ctx);
|
|
90
|
+
if (workingTreeRow) {
|
|
91
|
+
rows.push(workingTreeRow);
|
|
99
92
|
}
|
|
100
93
|
let branch;
|
|
101
94
|
if (canValidateBranch) {
|
|
@@ -227,6 +220,11 @@ const DOCTOR_CHECKS = [
|
|
|
227
220
|
{
|
|
228
221
|
name: 'Engine is git repository',
|
|
229
222
|
skipIf: (ctx) => !ctx.engineExists,
|
|
223
|
+
// runEngineGitChecks consults ctx.config for ownership-aware
|
|
224
|
+
// working-tree classification; declare the dependency so a future
|
|
225
|
+
// reorder doesn't silently regress the doctor back to the
|
|
226
|
+
// count-only fallback.
|
|
227
|
+
dependsOn: ['fireforge.json is valid'],
|
|
230
228
|
run: async (ctx) => {
|
|
231
229
|
const isRepo = await isGitRepository(ctx.paths.engine);
|
|
232
230
|
if (!isRepo) {
|
|
@@ -314,7 +312,20 @@ const DOCTOR_CHECKS = [
|
|
|
314
312
|
}
|
|
315
313
|
try {
|
|
316
314
|
const repaired = await rebuildPatchesManifest(ctx.paths.patches, ctx.config.firefox.version);
|
|
317
|
-
|
|
315
|
+
// 2026-04-21 eval (Finding #17): the repair path silently
|
|
316
|
+
// overwrote useful human-written descriptions on recovered
|
|
317
|
+
// entries, leaving the queue less trustworthy as an audit
|
|
318
|
+
// trail. The rebuilder now returns the list of filenames
|
|
319
|
+
// whose metadata was entirely invented, and we name them
|
|
320
|
+
// explicitly here so the operator knows exactly which
|
|
321
|
+
// patches to review. Names that DID have a preserved entry
|
|
322
|
+
// (only `filesAffected` / ordering drifted) are not flagged.
|
|
323
|
+
if (repaired.recoveredFilenames.length > 0) {
|
|
324
|
+
for (const filename of repaired.recoveredFilenames) {
|
|
325
|
+
warn(`Recovered manifest entry for ${filename} with generic description and mtime-based createdAt. Edit patches.json to restore the original description if you have it backed up.`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return warning('Patch manifest consistency', `Rebuilt patches.json from ${repaired.manifest.patches.length} patch${repaired.manifest.patches.length === 1 ? '' : 'es'}${repaired.recoveredFilenames.length > 0 ? ` (${repaired.recoveredFilenames.length} with reconstructed metadata — see warnings above)` : ''}. Review recovered metadata before release.`);
|
|
318
329
|
}
|
|
319
330
|
catch (err) {
|
|
320
331
|
return failure('Patch manifest consistency', toError(err).message, 'Repair failed. Fix the underlying patch metadata issue and retry the doctor command.');
|
|
@@ -5,7 +5,7 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
|
5
5
|
import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
6
6
|
import { hasChanges, isGitRepository } from '../core/git.js';
|
|
7
7
|
import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
8
|
-
import { getWorkingTreeStatus } from '../core/git-status.js';
|
|
8
|
+
import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
|
|
9
9
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
10
10
|
import { commitExportedPatch, findAllPatchesForFiles } from '../core/patch-export.js';
|
|
11
11
|
import { buildPatchQueueContext, collectNewFileCreatorsByPath, detectNewFilesInDiff, } from '../core/patch-lint.js';
|
|
@@ -41,7 +41,14 @@ async function resolveFurnaceExclusionPolicy(paths, projectRoot, excludeFurnace)
|
|
|
41
41
|
const prefixes = await collectFurnaceManagedPrefixes(projectRoot);
|
|
42
42
|
if (prefixes.size === 0)
|
|
43
43
|
return new Set();
|
|
44
|
-
|
|
44
|
+
// Expand collapsed `?? dir/` entries before matching against Furnace
|
|
45
|
+
// prefixes — otherwise a Furnace-introduced directory slips past the
|
|
46
|
+
// filter and later lands in the non-Furnace path list that feeds the
|
|
47
|
+
// aggregate diff, where `getDiffForFilesAgainstHead` crashes with
|
|
48
|
+
// EISDIR (eval finding: export-all unusable on a fresh project with
|
|
49
|
+
// Furnace scaffolding).
|
|
50
|
+
const rawStatus = await getWorkingTreeStatus(paths.engine);
|
|
51
|
+
const changedFiles = await expandUntrackedDirectoryEntries(paths.engine, rawStatus);
|
|
45
52
|
const furnaceManagedFiles = changedFiles
|
|
46
53
|
.flatMap((entry) => [entry.file, entry.originalPath].filter((value) => !!value))
|
|
47
54
|
.filter((file) => [...prefixes].some((prefix) => file.startsWith(prefix)));
|
|
@@ -129,7 +136,8 @@ export async function exportAllCommand(projectRoot, options = {}) {
|
|
|
129
136
|
// output shape aligned with the single-file `export` command.
|
|
130
137
|
let diff;
|
|
131
138
|
if (furnaceExcluded.size > 0) {
|
|
132
|
-
const
|
|
139
|
+
const rawChanged = await getWorkingTreeStatus(paths.engine);
|
|
140
|
+
const allChanged = await expandUntrackedDirectoryEntries(paths.engine, rawChanged);
|
|
133
141
|
const nonFurnacePaths = [
|
|
134
142
|
...new Set(allChanged
|
|
135
143
|
.flatMap((entry) => [entry.file, entry.originalPath].filter((value) => !!value))
|
|
@@ -17,8 +17,14 @@ import type { SpinnerHandle } from '../utils/logger.js';
|
|
|
17
17
|
* `--skip-lint` when exactly one advisory rule does not apply to a
|
|
18
18
|
* specific patch — e.g. `large-patch-lines` on a cohesive branding
|
|
19
19
|
* bundle that genuinely cannot be split.
|
|
20
|
+
* @param patchTier - Optional explicit tier override (threaded from
|
|
21
|
+
* `PatchMetadata.tier`). Forces the branding-tier thresholds when
|
|
22
|
+
* set, independent of the auto-detect allowlist. When the branding
|
|
23
|
+
* tier is applied (either via this opt-in or the auto-detect), a
|
|
24
|
+
* single `info()` line surfaces the choice so the tier decision is
|
|
25
|
+
* visible rather than silent.
|
|
20
26
|
*/
|
|
21
|
-
export declare function runPatchLint(engineDir: string, filesAffected: string[], diffContent: string, config: FireForgeConfig, skipLint?: boolean, patchQueueCtx?: import('../core/patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string
|
|
27
|
+
export declare function runPatchLint(engineDir: string, filesAffected: string[], diffContent: string, config: FireForgeConfig, skipLint?: boolean, patchQueueCtx?: import('../core/patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string>, patchTier?: 'branding'): Promise<void>;
|
|
22
28
|
/**
|
|
23
29
|
* Resolves patch metadata interactively or from flags, with shared validation.
|
|
24
30
|
* @param options - Export command options
|
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { confirm, select, text } from '@clack/prompts';
|
|
4
4
|
import { addLicenseHeaderToFile, getLicenseHeader } from '../core/license-headers.js';
|
|
5
5
|
import { findAllPatchesForFiles } from '../core/patch-export.js';
|
|
6
|
-
import { commentStyleForFile, detectNewFilesInDiff, lintExportedPatch, } from '../core/patch-lint.js';
|
|
6
|
+
import { commentStyleForFile, detectNewFilesInDiff, lintExportedPatch, resolvePatchSizeTier, } from '../core/patch-lint.js';
|
|
7
7
|
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
8
8
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
9
9
|
import { pathExists, readText } from '../utils/fs.js';
|
|
@@ -24,9 +24,27 @@ import { isValidPatchCategory, PATCH_CATEGORIES, validatePatchName } from '../ut
|
|
|
24
24
|
* `--skip-lint` when exactly one advisory rule does not apply to a
|
|
25
25
|
* specific patch — e.g. `large-patch-lines` on a cohesive branding
|
|
26
26
|
* bundle that genuinely cannot be split.
|
|
27
|
+
* @param patchTier - Optional explicit tier override (threaded from
|
|
28
|
+
* `PatchMetadata.tier`). Forces the branding-tier thresholds when
|
|
29
|
+
* set, independent of the auto-detect allowlist. When the branding
|
|
30
|
+
* tier is applied (either via this opt-in or the auto-detect), a
|
|
31
|
+
* single `info()` line surfaces the choice so the tier decision is
|
|
32
|
+
* visible rather than silent.
|
|
27
33
|
*/
|
|
28
|
-
export async function runPatchLint(engineDir, filesAffected, diffContent, config, skipLint, patchQueueCtx, ignoreChecks) {
|
|
29
|
-
|
|
34
|
+
export async function runPatchLint(engineDir, filesAffected, diffContent, config, skipLint, patchQueueCtx, ignoreChecks, patchTier) {
|
|
35
|
+
// Compute the tier decision independently of the lint pipeline so the
|
|
36
|
+
// decision can be surfaced even when the rule body emitted no issues
|
|
37
|
+
// (e.g. a branding patch under the soft threshold still benefits from
|
|
38
|
+
// operators knowing which tier governed the run). The same helper is
|
|
39
|
+
// reused inside `lintPatchSize`, so the surfaced tier and the tier
|
|
40
|
+
// that actually drove the thresholds never drift.
|
|
41
|
+
const tierDecision = resolvePatchSizeTier(filesAffected, patchTier);
|
|
42
|
+
if (tierDecision.tier === 'branding') {
|
|
43
|
+
info(tierDecision.source === 'explicit'
|
|
44
|
+
? 'Lint: branding threshold tier applied via patches.json `tier: "branding"` opt-in.'
|
|
45
|
+
: 'Lint: branding threshold tier applied (patch is all under browser/branding/ plus registration siblings).');
|
|
46
|
+
}
|
|
47
|
+
const issues = await lintExportedPatch(engineDir, filesAffected, diffContent, config, patchQueueCtx, ignoreChecks, patchTier);
|
|
30
48
|
if (issues.length === 0)
|
|
31
49
|
return;
|
|
32
50
|
const errors = issues.filter((i) => i.severity === 'error');
|
|
@@ -117,9 +117,16 @@ add_task(async function test_${taskSuffix}_files_packaged() {
|
|
|
117
117
|
["browser", "chrome", "browser", "content", "browser", "${name}.xhtml"],
|
|
118
118
|
"${name}.xhtml",
|
|
119
119
|
);
|
|
120
|
+
// The scoped CSS is registered through jar.inc.mn under
|
|
121
|
+
// \`content/browser/<name>-chrome.css\` (see \`chromeDocJarIncMnCssEntry\`
|
|
122
|
+
// in \`src/commands/furnace/chrome-doc-templates.ts\`), so the packaged
|
|
123
|
+
// file lands under \`chrome/browser/content/browser/\`, not under
|
|
124
|
+
// \`skin/classic/browser/\`. The 2026-04-21 eval's first
|
|
125
|
+
// \`fireforge test --build\` against a scaffolded chrome-doc reported
|
|
126
|
+
// a false failure because the probe was looking at the skin layout.
|
|
120
127
|
probeEither(
|
|
121
|
-
["chrome", "browser", "
|
|
122
|
-
["browser", "chrome", "browser", "
|
|
128
|
+
["chrome", "browser", "content", "browser", "${name}-chrome.css"],
|
|
129
|
+
["browser", "chrome", "browser", "content", "browser", "${name}-chrome.css"],
|
|
123
130
|
"${name}-chrome.css",
|
|
124
131
|
);
|
|
125
132
|
});
|
|
@@ -76,6 +76,17 @@ export declare function mochikitTestFileName(name: string): string;
|
|
|
76
76
|
* depend on the component's shape; operators can extend the test using
|
|
77
77
|
* the same SimpleTest APIs upstream toolkit widgets (moz-button, etc.)
|
|
78
78
|
* rely on.
|
|
79
|
+
*
|
|
80
|
+
* The template deliberately omits `SimpleTest.waitForExplicitFinish()`.
|
|
81
|
+
* `add_task` owns the test lifecycle: when every queued task resolves,
|
|
82
|
+
* the task harness calls `SimpleTest.finish()` on its own. Combining
|
|
83
|
+
* `waitForExplicitFinish()` with `add_task` *and* no explicit
|
|
84
|
+
* `SimpleTest.finish()` inside the task body makes the harness wait
|
|
85
|
+
* forever, which the 2026-04-21 eval run tripped into as an indefinite
|
|
86
|
+
* hang on a `fireforge test --headless` against a scaffolded widget
|
|
87
|
+
* test. Leaving `waitForExplicitFinish()` out matches the convention
|
|
88
|
+
* upstream toolkit widget tests use (see `test_moz-button.html` and
|
|
89
|
+
* siblings under `toolkit/content/tests/widgets/`).
|
|
79
90
|
*/
|
|
80
91
|
export declare function generateMochikitTestContent(name: string): string;
|
|
81
92
|
/**
|
|
@@ -227,6 +227,17 @@ export function mochikitTestFileName(name) {
|
|
|
227
227
|
* depend on the component's shape; operators can extend the test using
|
|
228
228
|
* the same SimpleTest APIs upstream toolkit widgets (moz-button, etc.)
|
|
229
229
|
* rely on.
|
|
230
|
+
*
|
|
231
|
+
* The template deliberately omits `SimpleTest.waitForExplicitFinish()`.
|
|
232
|
+
* `add_task` owns the test lifecycle: when every queued task resolves,
|
|
233
|
+
* the task harness calls `SimpleTest.finish()` on its own. Combining
|
|
234
|
+
* `waitForExplicitFinish()` with `add_task` *and* no explicit
|
|
235
|
+
* `SimpleTest.finish()` inside the task body makes the harness wait
|
|
236
|
+
* forever, which the 2026-04-21 eval run tripped into as an indefinite
|
|
237
|
+
* hang on a `fireforge test --headless` against a scaffolded widget
|
|
238
|
+
* test. Leaving `waitForExplicitFinish()` out matches the convention
|
|
239
|
+
* upstream toolkit widget tests use (see `test_moz-button.html` and
|
|
240
|
+
* siblings under `toolkit/content/tests/widgets/`).
|
|
230
241
|
*/
|
|
231
242
|
export function generateMochikitTestContent(name) {
|
|
232
243
|
return `<!DOCTYPE html>
|
|
@@ -244,8 +255,6 @@ export function generateMochikitTestContent(name) {
|
|
|
244
255
|
<script type="module">
|
|
245
256
|
import "chrome://global/content/elements/${name}.mjs";
|
|
246
257
|
|
|
247
|
-
SimpleTest.waitForExplicitFinish();
|
|
248
|
-
|
|
249
258
|
add_task(async function test_${name.replace(/-/g, '_')}_defined() {
|
|
250
259
|
const ctor = await customElements.whenDefined("${name}");
|
|
251
260
|
ok(ctor, "${name} custom element should be defined");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
-
import {
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, normalize } from 'node:path';
|
|
3
4
|
import { text } from '@clack/prompts';
|
|
4
5
|
import { getProjectPaths, loadConfig, mutateConfig, writeConfig } from '../../core/config.js';
|
|
5
6
|
import { createDefaultFurnaceConfig, furnaceConfigExists, writeFurnaceConfig, } from '../../core/furnace-config.js';
|
|
@@ -11,12 +12,46 @@ import { toError } from '../../utils/errors.js';
|
|
|
11
12
|
import { ensureDir, pathExists, writeText } from '../../utils/fs.js';
|
|
12
13
|
import { cancel, info, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* File extensions that are definitely FTL resources (not locale
|
|
16
|
+
* directories). A value ending in one of these is almost certainly the
|
|
17
|
+
* result of the operator pointing at a single FTL file instead of the
|
|
18
|
+
* locale directory that contains it.
|
|
19
|
+
*
|
|
20
|
+
* 2026-04-21 eval: `furnace init --ftl-base-path browser/forgefresh.ftl`
|
|
21
|
+
* produced a misleading success path — the subsequent
|
|
22
|
+
* `furnace create --localized` scaffolded an `.mjs` referencing
|
|
23
|
+
* `insertFTLIfNeeded("<name>.ftl")` while furnace.json had no component
|
|
24
|
+
* entry, leaving the scaffold orphaned. Switching to a locale directory
|
|
25
|
+
* (`toolkit/locales/en-US/toolkit/global`) fixed the downstream path.
|
|
26
|
+
* Rejecting file-shaped values up-front keeps the operator on the
|
|
27
|
+
* correct path before any partial state is written.
|
|
28
|
+
*/
|
|
29
|
+
const FTL_FILE_EXTENSIONS = new Set(['.ftl', '.properties', '.dtd']);
|
|
30
|
+
function hasFtlFileExtension(value) {
|
|
31
|
+
const lower = value.toLowerCase();
|
|
32
|
+
const dotIdx = lower.lastIndexOf('.');
|
|
33
|
+
const slashIdx = Math.max(lower.lastIndexOf('/'), lower.lastIndexOf('\\'));
|
|
34
|
+
if (dotIdx <= slashIdx)
|
|
35
|
+
return false; // No extension in the basename.
|
|
36
|
+
return FTL_FILE_EXTENSIONS.has(lower.slice(dotIdx));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Validates an FTL base path before writing it to furnace.json.
|
|
40
|
+
* Rejects:
|
|
41
|
+
* - empty values and null bytes;
|
|
42
|
+
* - absolute paths (POSIX or Windows-drive) that escape the engine;
|
|
43
|
+
* - `..` segments that escape the engine;
|
|
44
|
+
* - file-shaped values ending in `.ftl` / `.properties` / `.dtd`
|
|
45
|
+
* (these are locale resources, not directories — the operator
|
|
46
|
+
* almost certainly meant to name the parent directory).
|
|
47
|
+
*
|
|
48
|
+
* When {@link engineDir} is provided and exists on disk, the resolved
|
|
49
|
+
* `engine/${value}` path is probed: if it exists but is not a
|
|
50
|
+
* directory, the same file-shape error fires; if it does not exist yet,
|
|
51
|
+
* a non-blocking warning is logged (a fresh project that has not
|
|
52
|
+
* `fireforge download`-ed yet is the legitimate pre-existence case).
|
|
18
53
|
*/
|
|
19
|
-
function validateFtlBasePath(value) {
|
|
54
|
+
async function validateFtlBasePath(value, engineDir) {
|
|
20
55
|
if (value.length === 0) {
|
|
21
56
|
throw new FurnaceError('ftlBasePath must not be empty.');
|
|
22
57
|
}
|
|
@@ -30,6 +65,40 @@ function validateFtlBasePath(value) {
|
|
|
30
65
|
if (normalized === '..' || normalized.startsWith('../')) {
|
|
31
66
|
throw new FurnaceError(`ftlBasePath "${value}" must not escape the engine checkout via parent-directory segments.`);
|
|
32
67
|
}
|
|
68
|
+
if (hasFtlFileExtension(value)) {
|
|
69
|
+
throw new FurnaceError(`ftlBasePath "${value}" looks like a file (basename "${basename(value)}" ends in .ftl/.properties/.dtd), but FireForge expects a locale directory such as toolkit/locales/en-US/toolkit/global or browser/locales/en-US/browser. Use the parent directory instead.`);
|
|
70
|
+
}
|
|
71
|
+
// Shape probe against the real filesystem when we have an engine
|
|
72
|
+
// directory to anchor against. The probe is best-effort: a missing
|
|
73
|
+
// engine directory or a not-yet-extracted locale tree is
|
|
74
|
+
// legitimate (an operator may `furnace init` before `fireforge
|
|
75
|
+
// download`), so we emit a warning rather than refusing.
|
|
76
|
+
if (engineDir) {
|
|
77
|
+
const resolved = join(engineDir, value);
|
|
78
|
+
try {
|
|
79
|
+
const info = await stat(resolved);
|
|
80
|
+
if (!info.isDirectory()) {
|
|
81
|
+
throw new FurnaceError(`ftlBasePath "${value}" resolves to a non-directory at ${resolved}. FireForge expects a locale directory (for example toolkit/locales/en-US/toolkit/global or browser/locales/en-US/browser).`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
// FurnaceError (from the `isDirectory()` branch above) is a real
|
|
86
|
+
// shape failure — re-throw so the operator sees it.
|
|
87
|
+
if (error instanceof FurnaceError)
|
|
88
|
+
throw error;
|
|
89
|
+
// ENOENT is expected on a fresh project before `fireforge
|
|
90
|
+
// download` has populated engine/; only warn.
|
|
91
|
+
const code = typeof error === 'object' && error !== null && 'code' in error
|
|
92
|
+
? error.code
|
|
93
|
+
: undefined;
|
|
94
|
+
if (code === 'ENOENT') {
|
|
95
|
+
warn(`ftlBasePath "${value}" does not yet exist at ${resolved}. This is fine if you have not run "fireforge download" yet; rerun "fireforge furnace init --force" after the engine is extracted to re-validate.`);
|
|
96
|
+
}
|
|
97
|
+
// Any other stat error is also best-effort ignored here — a
|
|
98
|
+
// permission issue or malformed engine checkout will surface on
|
|
99
|
+
// the next command that actually reads the FTL tree.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
33
102
|
}
|
|
34
103
|
/**
|
|
35
104
|
* Runs the furnace init command to create a default furnace.json with
|
|
@@ -42,8 +111,27 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
|
|
|
42
111
|
if ((await furnaceConfigExists(projectRoot)) && !options.force) {
|
|
43
112
|
throw new FurnaceError('furnace.json already exists. Use --force to overwrite it.');
|
|
44
113
|
}
|
|
45
|
-
const
|
|
114
|
+
const paths = getProjectPaths(projectRoot);
|
|
115
|
+
// Seed the default furnace config with a tokenPrefix derived from
|
|
116
|
+
// fireforge.json's binaryName so `token coverage` sees real tokens on
|
|
117
|
+
// the very first run. The 2026-04-21 eval initialised Furnace, added
|
|
118
|
+
// tokens, ran coverage, and got `0 tokens / N unknown` — the prefix
|
|
119
|
+
// default was absent and the scan had nothing to key off. Loading
|
|
120
|
+
// fireforge.json here is best-effort: a project without one (e.g.
|
|
121
|
+
// mid-setup) falls through to the prefix-less default, and
|
|
122
|
+
// `token coverage` emits the existing "no tokenPrefix" warning.
|
|
123
|
+
let derivedBinaryName;
|
|
124
|
+
try {
|
|
125
|
+
const fireForgeConfig = await loadConfig(projectRoot);
|
|
126
|
+
derivedBinaryName = fireForgeConfig.binaryName;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Best-effort only: initialising furnace without a fireforge.json is
|
|
130
|
+
// rare but not forbidden. Skip the prefix default in that case.
|
|
131
|
+
}
|
|
132
|
+
const config = createDefaultFurnaceConfig(derivedBinaryName ? { binaryName: derivedBinaryName } : {});
|
|
46
133
|
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
134
|
+
const engineForValidation = (await pathExists(paths.engine)) ? paths.engine : undefined;
|
|
47
135
|
// Resolve componentPrefix
|
|
48
136
|
if (options.prefix !== undefined) {
|
|
49
137
|
config.componentPrefix = options.prefix;
|
|
@@ -66,7 +154,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
|
|
|
66
154
|
}
|
|
67
155
|
// Resolve ftlBasePath
|
|
68
156
|
if (options.ftlBasePath !== undefined) {
|
|
69
|
-
validateFtlBasePath(options.ftlBasePath);
|
|
157
|
+
await validateFtlBasePath(options.ftlBasePath, engineForValidation);
|
|
70
158
|
config.ftlBasePath = options.ftlBasePath;
|
|
71
159
|
}
|
|
72
160
|
else if (isInteractive) {
|
|
@@ -80,7 +168,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
|
|
|
80
168
|
}
|
|
81
169
|
const ftlValue = ftlResult.trim();
|
|
82
170
|
if (ftlValue) {
|
|
83
|
-
validateFtlBasePath(ftlValue);
|
|
171
|
+
await validateFtlBasePath(ftlValue, engineForValidation);
|
|
84
172
|
config.ftlBasePath = ftlValue;
|
|
85
173
|
}
|
|
86
174
|
}
|
|
@@ -79,17 +79,14 @@ async function copyOverrideFiles(engineDir, srcDir, destDir, componentName, hasF
|
|
|
79
79
|
return copiedFiles;
|
|
80
80
|
}
|
|
81
81
|
/**
|
|
82
|
-
* Writes override metadata to disk and updates furnace.json with the new
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* @param details - Source component metadata from the engine scan
|
|
89
|
-
* @param firefoxVersion - Firefox version recorded in the workspace config
|
|
90
|
-
* @param config - Mutable Furnace config object to update
|
|
82
|
+
* Writes override metadata to disk and updates furnace.json with the new
|
|
83
|
+
* override entry. Re-reads the current on-disk furnace.json inside the
|
|
84
|
+
* operation lock and splices the new entry onto the fresh state so two
|
|
85
|
+
* concurrent `furnace override` commands cannot race their read-modify
|
|
86
|
+
* -write cycles into a single surviving entry (eval 2: parallel overrides
|
|
87
|
+
* both reported success but furnace.json kept only the second writer).
|
|
91
88
|
*/
|
|
92
|
-
async function saveOverrideConfig(projectRoot, destDir, componentName, overrideType, description, details, firefoxVersion,
|
|
89
|
+
async function saveOverrideConfig(projectRoot, destDir, componentName, overrideType, description, details, firefoxVersion, journal, baseCommit) {
|
|
93
90
|
const overrideJson = {
|
|
94
91
|
type: overrideType,
|
|
95
92
|
description,
|
|
@@ -100,14 +97,27 @@ async function saveOverrideConfig(projectRoot, destDir, componentName, overrideT
|
|
|
100
97
|
const overrideJsonPath = join(destDir, 'override.json');
|
|
101
98
|
await snapshotFile(journal, overrideJsonPath);
|
|
102
99
|
await writeJson(overrideJsonPath, overrideJson);
|
|
103
|
-
|
|
100
|
+
// Re-read the current furnace.json inside the lock. The outer caller
|
|
101
|
+
// loaded a snapshot before entering `runFurnaceMutation`, but another
|
|
102
|
+
// furnace mutation (override / init / sync) may have landed in between
|
|
103
|
+
// — writing back the stale snapshot would drop that concurrent write.
|
|
104
|
+
const freshConfig = await loadAuthoringFurnaceConfig(projectRoot);
|
|
105
|
+
freshConfig.overrides[componentName] = {
|
|
104
106
|
type: overrideType,
|
|
105
107
|
description,
|
|
106
108
|
basePath: details.sourcePath,
|
|
107
109
|
baseVersion: firefoxVersion,
|
|
108
110
|
...(baseCommit ? { baseCommit } : {}),
|
|
109
111
|
};
|
|
110
|
-
|
|
112
|
+
// Promote from the stock bucket here, against the fresh state, so the
|
|
113
|
+
// stock→override transition survives even when another concurrent
|
|
114
|
+
// override already rewrote furnace.json between the outer read and
|
|
115
|
+
// this write.
|
|
116
|
+
const stockIndex = freshConfig.stock.indexOf(componentName);
|
|
117
|
+
if (stockIndex !== -1) {
|
|
118
|
+
freshConfig.stock.splice(stockIndex, 1);
|
|
119
|
+
}
|
|
120
|
+
await writeFurnaceConfig(projectRoot, freshConfig);
|
|
111
121
|
}
|
|
112
122
|
/**
|
|
113
123
|
* Performs the transactional mutation phase of furnace override under the
|
|
@@ -123,7 +133,7 @@ async function performOverrideMutations(args) {
|
|
|
123
133
|
try {
|
|
124
134
|
const filesCopied = await copyOverrideFiles(args.engineDir, args.srcDir, args.destDir, args.componentName, args.details.hasFTL, args.overrideType, args.ftlDir, journal);
|
|
125
135
|
await snapshotFile(journal, args.furnacePaths.furnaceConfig);
|
|
126
|
-
await saveOverrideConfig(args.projectRoot, args.destDir, args.componentName, args.overrideType, args.description, args.details, args.firefoxVersion,
|
|
136
|
+
await saveOverrideConfig(args.projectRoot, args.destDir, args.componentName, args.overrideType, args.description, args.details, args.firefoxVersion, journal, args.baseCommit);
|
|
127
137
|
return filesCopied;
|
|
128
138
|
}
|
|
129
139
|
catch (error) {
|
|
@@ -3,6 +3,7 @@ import { readdir, unlink } from 'node:fs/promises';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { confirm } from '@clack/prompts';
|
|
5
5
|
import { getProjectPaths, loadConfig } from '../../core/config.js';
|
|
6
|
+
import { removeCustomFtlJarMnEntry } from '../../core/furnace-apply-ftl.js';
|
|
6
7
|
import { extractComponentChecksums, getOverrideEngineTargetPath, isOverrideCopyCandidate, restoreOverrideFileToBaseline, } from '../../core/furnace-apply-helpers.js';
|
|
7
8
|
import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState, updateFurnaceState, writeFurnaceConfig, } from '../../core/furnace-config.js';
|
|
8
9
|
import { resolveFtlDir } from '../../core/furnace-constants.js';
|
|
@@ -353,6 +354,13 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
|
|
|
353
354
|
await removeFile(ftlPath);
|
|
354
355
|
info(`Deleted localized file engine/${ftlRel}`);
|
|
355
356
|
}
|
|
357
|
+
// Drop the locale jar.mn chrome registration that `applyCustomFtlFile`
|
|
358
|
+
// wrote during deploy — otherwise the engine is left with a
|
|
359
|
+
// `locale/.../${name}.ftl` entry pointing at a file we just
|
|
360
|
+
// deleted. 2026-04-21 eval (Finding #1): `furnace remove` left
|
|
361
|
+
// `browser/locales/jar.mn` referencing the missing FTL, which
|
|
362
|
+
// would break the next package-manifest validation.
|
|
363
|
+
await removeCustomFtlJarMnEntry(paths.engine, `${name}.ftl`, ftlDir, customConfig, journal);
|
|
356
364
|
}
|
|
357
365
|
}
|
|
358
366
|
let testCleanupFailures = [];
|