@hominis/fireforge 0.18.2 → 0.18.5
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 +29 -16
- package/dist/src/commands/build.js +27 -12
- package/dist/src/commands/config.js +56 -3
- 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/furnace/chrome-doc-templates.d.ts +59 -8
- package/dist/src/commands/furnace/chrome-doc-templates.js +95 -12
- package/dist/src/commands/furnace/chrome-doc.js +24 -2
- package/dist/src/commands/furnace/deploy.js +10 -1
- package/dist/src/commands/furnace/init.js +28 -2
- package/dist/src/commands/furnace/remove.js +68 -0
- package/dist/src/commands/import.js +9 -1
- package/dist/src/commands/lint.js +78 -13
- package/dist/src/commands/patch/delete.js +2 -4
- package/dist/src/commands/patch/lint-ignore.js +2 -4
- package/dist/src/commands/patch/reorder.js +2 -4
- package/dist/src/commands/patch/tier.js +2 -4
- package/dist/src/commands/status.js +39 -1
- package/dist/src/commands/test.js +20 -1
- package/dist/src/commands/token.js +1 -1
- package/dist/src/core/furnace-apply.js +11 -3
- package/dist/src/core/furnace-config.js +19 -0
- package/dist/src/core/furnace-marker.d.ts +16 -0
- package/dist/src/core/furnace-marker.js +23 -0
- package/dist/src/core/git.js +66 -10
- 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-identifier-suggest.d.ts +25 -0
- package/dist/src/core/patch-identifier-suggest.js +108 -0
- package/dist/src/core/patch-lint.js +8 -0
- package/dist/src/core/register-shared-css.d.ts +28 -0
- package/dist/src/core/register-shared-css.js +67 -3
- package/package.json +1 -1
|
@@ -7,7 +7,8 @@ import { buildOwnershipTable, renderOwnershipTable } from '../core/ownership-tab
|
|
|
7
7
|
import { buildPatchQueueContext, collectNewFileCreatorsByPath } from '../core/patch-lint.js';
|
|
8
8
|
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
9
9
|
import { classifyFiles, } from '../core/status-classify.js';
|
|
10
|
-
import { GeneralError } from '../errors/base.js';
|
|
10
|
+
import { CommandError, GeneralError } from '../errors/base.js';
|
|
11
|
+
import { ExitCode } from '../errors/codes.js';
|
|
11
12
|
import { FIREFORGE_TMP_PATH_PATTERN, pathExists } from '../utils/fs.js';
|
|
12
13
|
import { info, intro, outro, warn } from '../utils/logger.js';
|
|
13
14
|
/**
|
|
@@ -260,6 +261,14 @@ async function assertEngineHasBaselineCommit(engineDir, options) {
|
|
|
260
261
|
warn(guidance);
|
|
261
262
|
outro('Engine baseline missing — re-run download --force');
|
|
262
263
|
}
|
|
264
|
+
if (options.json) {
|
|
265
|
+
// Mirror `--json`'s contract: errors must be machine-parseable too.
|
|
266
|
+
// Without this branch the human guidance above is suppressed but the
|
|
267
|
+
// throw still falls through to the styled error renderer in
|
|
268
|
+
// withErrorHandling, leaving JSON consumers with non-JSON output on
|
|
269
|
+
// exactly the failure mode they care about catching.
|
|
270
|
+
process.stdout.write(JSON.stringify({ error: guidance, code: 'engine-baseline-missing' }) + '\n');
|
|
271
|
+
}
|
|
263
272
|
throw new GeneralError(guidance);
|
|
264
273
|
}
|
|
265
274
|
}
|
|
@@ -278,6 +287,29 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
278
287
|
}
|
|
279
288
|
const paths = getProjectPaths(projectRoot);
|
|
280
289
|
const config = await loadConfig(projectRoot);
|
|
290
|
+
// `--json` mode contracts to machine-parseable output on every code path,
|
|
291
|
+
// including failure modes. Before this guard, errors raised below
|
|
292
|
+
// ("Firefox source not found", "engine is not a git repository") flowed
|
|
293
|
+
// through the normal styled error renderer in `withErrorHandling`, so
|
|
294
|
+
// scripts piping `status --json | jq` broke precisely when the engine was
|
|
295
|
+
// missing. Surface a structured `{ "error": ..., "code": ... }` payload
|
|
296
|
+
// and exit non-zero via GeneralError so the exit code still reflects the
|
|
297
|
+
// failure but stdout remains valid JSON. The same guard runs for
|
|
298
|
+
// ownership mode below because that path also throws on missing engine.
|
|
299
|
+
// 2026-04-26 eval Finding 1: throw `CommandError` rather than
|
|
300
|
+
// `GeneralError` after the JSON line lands on stdout. `GeneralError`
|
|
301
|
+
// is a `FireForgeError`, so the `withErrorHandling` wrapper in cli.ts
|
|
302
|
+
// calls `logError(error.userMessage)` on it, which routes the styled
|
|
303
|
+
// human banner through clack to stdout — `status --json` therefore
|
|
304
|
+
// emitted both the JSON object AND the `■ Firefox source not found …`
|
|
305
|
+
// line on stdout, breaking every script that pipes the command into
|
|
306
|
+
// a JSON parser. `CommandError` is the bin-only sentinel that
|
|
307
|
+
// `withErrorHandling` does not log: bin/fireforge.ts catches it,
|
|
308
|
+
// exits with the carried code, and stdout stays a single JSON line.
|
|
309
|
+
const emitJsonError = (code, message) => {
|
|
310
|
+
process.stdout.write(JSON.stringify({ error: message, code }) + '\n');
|
|
311
|
+
throw new CommandError(ExitCode.GENERAL_ERROR);
|
|
312
|
+
};
|
|
281
313
|
// Ownership mode is a flat file→patch table; sources are the manifest's
|
|
282
314
|
// filesAffected, any worktree drift, and the cross-patch
|
|
283
315
|
// duplicate-new-file-creation map produced by walking each patch
|
|
@@ -326,10 +358,16 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
326
358
|
}
|
|
327
359
|
// Check if engine exists
|
|
328
360
|
if (!(await pathExists(paths.engine))) {
|
|
361
|
+
if (options.json) {
|
|
362
|
+
emitJsonError('engine-missing', 'Firefox source not found. Run "fireforge download" first.');
|
|
363
|
+
}
|
|
329
364
|
throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
|
|
330
365
|
}
|
|
331
366
|
// Check if it's a git repository
|
|
332
367
|
if (!(await isGitRepository(paths.engine))) {
|
|
368
|
+
if (options.json) {
|
|
369
|
+
emitJsonError('engine-not-git', 'Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
370
|
+
}
|
|
333
371
|
throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
334
372
|
}
|
|
335
373
|
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
|
|
@@ -9,6 +9,7 @@ import { applyCustomComponent, applyOverrideComponent, computeComponentChecksums
|
|
|
9
9
|
import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState, updateFurnaceState, } from './furnace-config.js';
|
|
10
10
|
import { CUSTOM_ELEMENTS_JS, JAR_MN, resolveFtlDir } from './furnace-constants.js';
|
|
11
11
|
import { topologicalSortCustom } from './furnace-graph-utils.js';
|
|
12
|
+
import { resolveFurnaceMarkerComment } from './furnace-marker.js';
|
|
12
13
|
import { recordFurnaceRollbackFailure } from './furnace-operation.js';
|
|
13
14
|
import { addJarMnEntries, removeCustomElementRegistration, removeJarMnEntries, } from './furnace-registration.js';
|
|
14
15
|
import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotFile, } from './furnace-rollback.js';
|
|
@@ -304,9 +305,16 @@ export async function applyAllComponents(root, dryRun = false, options) {
|
|
|
304
305
|
const { engine: engineDir } = getProjectPaths(root);
|
|
305
306
|
const furnacePaths = getFurnacePaths(root);
|
|
306
307
|
const ftlDir = resolveFtlDir(config.ftlBasePath);
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
308
|
+
// 2026-04-26 eval Finding 6: when `markerComment` is unset in
|
|
309
|
+
// fireforge.json, default it to `binaryName.toUpperCase()` so the
|
|
310
|
+
// furnace-emitted edits to upstream files (e.g. customElements.js)
|
|
311
|
+
// carry a marker that satisfies `lintModificationComments` — that
|
|
312
|
+
// rule keys on `${binaryName.toUpperCase()}:` and was firing
|
|
313
|
+
// `[missing-modification-comment]` on every furnace-applied
|
|
314
|
+
// upstream edit because the implicit default was `undefined`. An
|
|
315
|
+
// explicit `markerComment` in fireforge.json still wins.
|
|
316
|
+
const forgeConfig = await loadConfig(root).catch(() => undefined);
|
|
317
|
+
const markerComment = resolveFurnaceMarkerComment(forgeConfig);
|
|
310
318
|
if (!(await pathExists(engineDir))) {
|
|
311
319
|
throw new FurnaceError('Engine directory not found. Run "fireforge download" first.');
|
|
312
320
|
}
|
|
@@ -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
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker-comment resolution shared between furnace apply and deploy.
|
|
3
|
+
*
|
|
4
|
+
* 2026-04-26 eval Finding 6: when `markerComment` is unset in
|
|
5
|
+
* fireforge.json, fall back to `binaryName.toUpperCase()` so the
|
|
6
|
+
* patch-lint rule `lintModificationComments` (which keys on
|
|
7
|
+
* `${binaryName.toUpperCase()}:`) accepts furnace-emitted edits on the
|
|
8
|
+
* next `lint`/`export` round-trip. The helper tolerates the
|
|
9
|
+
* undefined-config case (a project that hasn't run `fireforge setup`
|
|
10
|
+
* yet) and the missing-binaryName case (test fixtures that mock
|
|
11
|
+
* `loadConfig` with a partial shape).
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveFurnaceMarkerComment(forgeConfig: {
|
|
14
|
+
markerComment?: string;
|
|
15
|
+
binaryName?: string;
|
|
16
|
+
} | undefined): string | undefined;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Marker-comment resolution shared between furnace apply and deploy.
|
|
4
|
+
*
|
|
5
|
+
* 2026-04-26 eval Finding 6: when `markerComment` is unset in
|
|
6
|
+
* fireforge.json, fall back to `binaryName.toUpperCase()` so the
|
|
7
|
+
* patch-lint rule `lintModificationComments` (which keys on
|
|
8
|
+
* `${binaryName.toUpperCase()}:`) accepts furnace-emitted edits on the
|
|
9
|
+
* next `lint`/`export` round-trip. The helper tolerates the
|
|
10
|
+
* undefined-config case (a project that hasn't run `fireforge setup`
|
|
11
|
+
* yet) and the missing-binaryName case (test fixtures that mock
|
|
12
|
+
* `loadConfig` with a partial shape).
|
|
13
|
+
*/
|
|
14
|
+
export function resolveFurnaceMarkerComment(forgeConfig) {
|
|
15
|
+
if (!forgeConfig)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (forgeConfig.markerComment !== undefined)
|
|
18
|
+
return forgeConfig.markerComment;
|
|
19
|
+
if (forgeConfig.binaryName)
|
|
20
|
+
return forgeConfig.binaryName.toUpperCase();
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=furnace-marker.js.map
|
package/dist/src/core/git.js
CHANGED
|
@@ -65,6 +65,35 @@ async function cleanupIndexLock(dir) {
|
|
|
65
65
|
verbose('Cleaned up stale .git/index.lock after timeout');
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Returns true when {@link relativePath} is ignored by `.gitignore` (or
|
|
70
|
+
* any other exclusion mechanism git considers, e.g. `.git/info/exclude`,
|
|
71
|
+
* core.excludesFile). Used by the chunked staging fallback to skip
|
|
72
|
+
* entries that would otherwise fail `git add -- <path>` with the fatal
|
|
73
|
+
* "The following paths are ignored by one of your .gitignore files"
|
|
74
|
+
* error — a state the monolithic `git add -A` path silently handles.
|
|
75
|
+
*
|
|
76
|
+
* Implementation: `git check-ignore -q -- <path>` exits 0 when the path
|
|
77
|
+
* is ignored, 1 when it isn't, and >=128 on real failures. Treat
|
|
78
|
+
* anything other than 0/1 as "unknown" and conservatively return false
|
|
79
|
+
* so the chunk runs and any real underlying failure surfaces normally.
|
|
80
|
+
*
|
|
81
|
+
* 2026-04-26 eval Finding 4: a Firefox checkout's top-level `.vscode/`
|
|
82
|
+
* is gitignored by the source tree's own `.gitignore`. Pre-fix, the
|
|
83
|
+
* chunked `git add -- .vscode` invocation aborted the entire fallback
|
|
84
|
+
* and turned a recoverable monolithic timeout into a hard setup
|
|
85
|
+
* failure that required `fireforge download --force`.
|
|
86
|
+
*/
|
|
87
|
+
async function isPathIgnored(dir, relativePath) {
|
|
88
|
+
const result = await exec('git', ['check-ignore', '-q', '--', relativePath], { cwd: dir });
|
|
89
|
+
if (result.exitCode === 0)
|
|
90
|
+
return true;
|
|
91
|
+
if (result.exitCode === 1)
|
|
92
|
+
return false;
|
|
93
|
+
// Any other shape is "we don't know" — let the caller proceed and
|
|
94
|
+
// surface the real error if `git add` rejects the path.
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
68
97
|
/**
|
|
69
98
|
* Stages every file by walking top-level directories one at a time.
|
|
70
99
|
* This avoids a single monolithic `git add -A` that may time out on
|
|
@@ -98,11 +127,26 @@ async function stageAllFilesChunked(dir, options = {}) {
|
|
|
98
127
|
}
|
|
99
128
|
}
|
|
100
129
|
for (const dirName of directories) {
|
|
130
|
+
if (await isPathIgnored(dir, dirName)) {
|
|
131
|
+
options.onProgress?.(`Skipping gitignored: ${dirName}/`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
101
134
|
options.onProgress?.(`Staging directory: ${dirName}/...`);
|
|
102
135
|
await runChunk(['add', '--', dirName], dirName);
|
|
103
136
|
}
|
|
104
|
-
// Stage any top-level files
|
|
105
|
-
|
|
137
|
+
// Stage any top-level files (excluding gitignored ones — `git add`
|
|
138
|
+
// on an explicit ignored path errors out, which would otherwise
|
|
139
|
+
// abort the chunked fallback after the monolithic path has already
|
|
140
|
+
// timed out).
|
|
141
|
+
const topLevelCandidates = entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
142
|
+
const topLevelFiles = [];
|
|
143
|
+
for (const name of topLevelCandidates) {
|
|
144
|
+
if (await isPathIgnored(dir, name)) {
|
|
145
|
+
options.onProgress?.(`Skipping gitignored: ${name}`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
topLevelFiles.push(name);
|
|
149
|
+
}
|
|
106
150
|
if (topLevelFiles.length > 0) {
|
|
107
151
|
options.onProgress?.('Staging top-level files...');
|
|
108
152
|
await runChunk(['add', '--', ...topLevelFiles], 'top-level files');
|
|
@@ -125,16 +169,23 @@ const GIT_ADD_HEARTBEAT_MS = 15_000;
|
|
|
125
169
|
export async function stageAllFiles(dir, options = {}) {
|
|
126
170
|
const timeout = options.timeout ?? GIT_ADD_TIMEOUT_MS;
|
|
127
171
|
const reportProgress = options.onProgress;
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
172
|
+
// 2026-04-26 eval Finding 5: the pre-fix heartbeat used a single
|
|
173
|
+
// `heartbeatStartedAt` set at function entry and reported cumulative
|
|
174
|
+
// elapsed for the whole `stageAllFiles` invocation. After a
|
|
175
|
+
// monolithic timeout, the chunked-phase ticks therefore named
|
|
176
|
+
// numbers that already included the entire monolithic budget plus
|
|
177
|
+
// any host-sleep time, with no way for an operator watching the log
|
|
178
|
+
// to tell where the monolithic attempt ended and the chunked pass
|
|
179
|
+
// began. The heartbeat now tracks a per-phase start timestamp and
|
|
180
|
+
// labels each tick with the phase, so the chunked pass reports its
|
|
181
|
+
// own elapsed window and the monolithic→chunked handoff is visible.
|
|
182
|
+
let phase = 'monolithic';
|
|
183
|
+
let phaseStartedAt = Date.now();
|
|
134
184
|
const heartbeatTimer = reportProgress
|
|
135
185
|
? setInterval(() => {
|
|
136
|
-
const elapsedS = Math.round((Date.now() -
|
|
137
|
-
|
|
186
|
+
const elapsedS = Math.round((Date.now() - phaseStartedAt) / 1000);
|
|
187
|
+
const label = phase === 'monolithic' ? 'monolithic' : 'chunked staging';
|
|
188
|
+
reportProgress(`Indexing Firefox source (${label}, ${elapsedS}s elapsed)`);
|
|
138
189
|
}, GIT_ADD_HEARTBEAT_MS)
|
|
139
190
|
: null;
|
|
140
191
|
heartbeatTimer?.unref();
|
|
@@ -158,6 +209,11 @@ export async function stageAllFiles(dir, options = {}) {
|
|
|
158
209
|
}
|
|
159
210
|
// The killed process may have left an index lock
|
|
160
211
|
await cleanupIndexLock(dir);
|
|
212
|
+
// Reset elapsed accounting for the chunked phase so its heartbeat
|
|
213
|
+
// names a believable per-phase number rather than rolling the
|
|
214
|
+
// monolithic budget forward.
|
|
215
|
+
phase = 'chunked';
|
|
216
|
+
phaseStartedAt = Date.now();
|
|
161
217
|
try {
|
|
162
218
|
await stageAllFilesChunked(dir, options);
|
|
163
219
|
}
|
|
@@ -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');
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a concise "patch not found" error message with did-you-mean
|
|
3
|
+
* suggestions in place of the full queue enumeration.
|
|
4
|
+
*
|
|
5
|
+
* 2026-04-26 eval Finding 12: pre-fix every `patch` subcommand
|
|
6
|
+
* (`delete`, `reorder`, `tier`, `lint-ignore`) caught a missing
|
|
7
|
+
* identifier by joining every queued patch's filename and (optional)
|
|
8
|
+
* manifest name into a single comma-separated `Available: ...` tail.
|
|
9
|
+
* On a 29-patch queue the resulting line ran ~1500 characters and
|
|
10
|
+
* buried the actual error under noise that was almost never useful in
|
|
11
|
+
* CI. The new shape ranks each known identifier (ordinal,
|
|
12
|
+
* filename-with-and-without-`.patch`, manifest name) by Levenshtein
|
|
13
|
+
* distance from the operator's input, surfaces up to three suggestions
|
|
14
|
+
* close enough to be plausibly the intended target, and falls back to
|
|
15
|
+
* a count-only summary that points at `fireforge patch list` when no
|
|
16
|
+
* close match exists. The full enumeration is no longer ever inlined.
|
|
17
|
+
*/
|
|
18
|
+
import type { PatchMetadata } from '../types/commands/index.js';
|
|
19
|
+
/**
|
|
20
|
+
* Formats the user-facing "patch not found" error message used by
|
|
21
|
+
* `patch delete`, `patch reorder`, `patch tier`, and
|
|
22
|
+
* `patch lint-ignore`. Returns a single string suitable for the
|
|
23
|
+
* `InvalidArgumentError` body — never the full queue enumeration.
|
|
24
|
+
*/
|
|
25
|
+
export declare function formatPatchNotFoundError(identifier: string, patches: readonly PatchMetadata[]): string;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Builds a concise "patch not found" error message with did-you-mean
|
|
4
|
+
* suggestions in place of the full queue enumeration.
|
|
5
|
+
*
|
|
6
|
+
* 2026-04-26 eval Finding 12: pre-fix every `patch` subcommand
|
|
7
|
+
* (`delete`, `reorder`, `tier`, `lint-ignore`) caught a missing
|
|
8
|
+
* identifier by joining every queued patch's filename and (optional)
|
|
9
|
+
* manifest name into a single comma-separated `Available: ...` tail.
|
|
10
|
+
* On a 29-patch queue the resulting line ran ~1500 characters and
|
|
11
|
+
* buried the actual error under noise that was almost never useful in
|
|
12
|
+
* CI. The new shape ranks each known identifier (ordinal,
|
|
13
|
+
* filename-with-and-without-`.patch`, manifest name) by Levenshtein
|
|
14
|
+
* distance from the operator's input, surfaces up to three suggestions
|
|
15
|
+
* close enough to be plausibly the intended target, and falls back to
|
|
16
|
+
* a count-only summary that points at `fireforge patch list` when no
|
|
17
|
+
* close match exists. The full enumeration is no longer ever inlined.
|
|
18
|
+
*/
|
|
19
|
+
/** Maximum Levenshtein distance accepted as a "did you mean" suggestion. */
|
|
20
|
+
const SUGGESTION_DISTANCE_THRESHOLD = 3;
|
|
21
|
+
/** Maximum number of suggestions to surface in the error message. */
|
|
22
|
+
const SUGGESTION_LIMIT = 3;
|
|
23
|
+
/**
|
|
24
|
+
* Computes the Levenshtein edit distance between two strings. Used by
|
|
25
|
+
* `formatPatchNotFoundError` to rank candidate identifiers; the small
|
|
26
|
+
* upper bound on input lengths (filenames, ordinals, names) makes the
|
|
27
|
+
* O(m*n) implementation trivially fast.
|
|
28
|
+
*/
|
|
29
|
+
function levenshtein(a, b) {
|
|
30
|
+
if (a === b)
|
|
31
|
+
return 0;
|
|
32
|
+
if (a.length === 0)
|
|
33
|
+
return b.length;
|
|
34
|
+
if (b.length === 0)
|
|
35
|
+
return a.length;
|
|
36
|
+
// Allocate the row buffers up-front and fill with zero so every index in
|
|
37
|
+
// [0, b.length] is populated before any read. Using `Array.fill(0)` keeps
|
|
38
|
+
// the type as `number[]` (not `(number | undefined)[]`) so subsequent
|
|
39
|
+
// index reads compose without optional-chaining noise.
|
|
40
|
+
const prev = new Array(b.length + 1).fill(0);
|
|
41
|
+
const curr = new Array(b.length + 1).fill(0);
|
|
42
|
+
for (let j = 0; j <= b.length; j++)
|
|
43
|
+
prev[j] = j;
|
|
44
|
+
for (let i = 1; i <= a.length; i++) {
|
|
45
|
+
curr[0] = i;
|
|
46
|
+
for (let j = 1; j <= b.length; j++) {
|
|
47
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
48
|
+
const left = curr[j - 1] ?? 0;
|
|
49
|
+
const up = prev[j] ?? 0;
|
|
50
|
+
const diag = prev[j - 1] ?? 0;
|
|
51
|
+
curr[j] = Math.min(left + 1, up + 1, diag + cost);
|
|
52
|
+
}
|
|
53
|
+
for (let j = 0; j <= b.length; j++)
|
|
54
|
+
prev[j] = curr[j] ?? 0;
|
|
55
|
+
}
|
|
56
|
+
return prev[b.length] ?? 0;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Collects every identifier shape FireForge accepts for a queue entry:
|
|
60
|
+
* - the ordinal (string form), e.g. `"2"`
|
|
61
|
+
* - the filename, e.g. `"002-ui-foo.patch"`
|
|
62
|
+
* - the filename without the `.patch` suffix, e.g. `"002-ui-foo"`
|
|
63
|
+
* - the manifest `name` field, when distinct from the filename
|
|
64
|
+
* Returned as a flat list so the suggestion ranking can compare each
|
|
65
|
+
* candidate independently.
|
|
66
|
+
*/
|
|
67
|
+
function collectAcceptedIdentifiers(patches) {
|
|
68
|
+
const set = new Set();
|
|
69
|
+
for (const patch of patches) {
|
|
70
|
+
set.add(String(patch.order));
|
|
71
|
+
set.add(patch.filename);
|
|
72
|
+
if (patch.filename.endsWith('.patch')) {
|
|
73
|
+
set.add(patch.filename.slice(0, -'.patch'.length));
|
|
74
|
+
}
|
|
75
|
+
if (patch.name && patch.name !== patch.filename)
|
|
76
|
+
set.add(patch.name);
|
|
77
|
+
}
|
|
78
|
+
return Array.from(set);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Returns up to {@link SUGGESTION_LIMIT} accepted identifiers ordered
|
|
82
|
+
* by closest Levenshtein distance to {@link identifier}, dropping any
|
|
83
|
+
* candidate whose distance exceeds {@link SUGGESTION_DISTANCE_THRESHOLD}.
|
|
84
|
+
*/
|
|
85
|
+
function rankSuggestions(identifier, candidates) {
|
|
86
|
+
return candidates
|
|
87
|
+
.map((candidate) => ({ candidate, distance: levenshtein(identifier, candidate) }))
|
|
88
|
+
.filter((entry) => entry.distance <= SUGGESTION_DISTANCE_THRESHOLD)
|
|
89
|
+
.sort((a, b) => a.distance - b.distance || a.candidate.localeCompare(b.candidate))
|
|
90
|
+
.slice(0, SUGGESTION_LIMIT)
|
|
91
|
+
.map((entry) => entry.candidate);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Formats the user-facing "patch not found" error message used by
|
|
95
|
+
* `patch delete`, `patch reorder`, `patch tier`, and
|
|
96
|
+
* `patch lint-ignore`. Returns a single string suitable for the
|
|
97
|
+
* `InvalidArgumentError` body — never the full queue enumeration.
|
|
98
|
+
*/
|
|
99
|
+
export function formatPatchNotFoundError(identifier, patches) {
|
|
100
|
+
const accepted = collectAcceptedIdentifiers(patches);
|
|
101
|
+
const suggestions = rankSuggestions(identifier, accepted);
|
|
102
|
+
const lead = `Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo).`;
|
|
103
|
+
if (suggestions.length > 0) {
|
|
104
|
+
return `${lead} Did you mean: ${suggestions.join(', ')}? (${patches.length} patches in queue — run "fireforge patch list" for the full list.)`;
|
|
105
|
+
}
|
|
106
|
+
return `${lead} No close match found among ${patches.length} patches in the queue. Run "fireforge patch list" to see them.`;
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=patch-identifier-suggest.js.map
|
|
@@ -359,6 +359,14 @@ export async function lintNewFileHeaders(repoDir, newFiles, config) {
|
|
|
359
359
|
continue;
|
|
360
360
|
if (file.startsWith('browser/branding/') && hasAnyLicenseHeader(content, style))
|
|
361
361
|
continue;
|
|
362
|
+
// Accept the MPL-2.0 block-comment form (`/* ... */` with leading `*`)
|
|
363
|
+
// for any JS file — that is the canonical upstream Firefox header
|
|
364
|
+
// shape, and `hasAnyLicenseHeader` above also recognises it. The
|
|
365
|
+
// explicit guard mirrors the branding carve-out so operators using the
|
|
366
|
+
// standard Mozilla header on a JS file (e.g. one copied from upstream
|
|
367
|
+
// browser/base/content) do not need `--skip-lint` to land it.
|
|
368
|
+
if (license === 'MPL-2.0' && hasAnyLicenseHeader(content, style))
|
|
369
|
+
continue;
|
|
362
370
|
issues.push({
|
|
363
371
|
file,
|
|
364
372
|
check: 'missing-license-header',
|
|
@@ -2,10 +2,38 @@
|
|
|
2
2
|
* CSS registration in browser/themes/shared/jar.inc.mn.
|
|
3
3
|
*/
|
|
4
4
|
import type { RegisterResult } from './manifest-register.js';
|
|
5
|
+
/**
|
|
6
|
+
* Measures the column at which the `(source)` parenthesis opens in
|
|
7
|
+
* adjacent `skin/classic/browser/<x>.css (...)` entries inside an
|
|
8
|
+
* existing jar.inc.mn body, and returns the maximum so a newly inserted
|
|
9
|
+
* entry can align its source column to match.
|
|
10
|
+
*
|
|
11
|
+
* 2026-04-26 eval Finding 3: pre-fix `registerSharedCSS` always emitted
|
|
12
|
+
* a four-space gap between the target path and the parenthesis,
|
|
13
|
+
* regardless of how the rest of the file was aligned. Adjacent Firefox
|
|
14
|
+
* entries are typically padded to a wider column, so a freshly
|
|
15
|
+
* registered file landed at the wrong column and produced avoidable
|
|
16
|
+
* formatting churn. Returns `undefined` when no existing entries
|
|
17
|
+
* provide an alignment signal — callers fall back to the four-space
|
|
18
|
+
* default in that case.
|
|
19
|
+
*/
|
|
20
|
+
export declare function measureSourceColumn(content: string): number | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Builds a `skin/classic/browser/<name>.css (../shared/<name>.css)`
|
|
23
|
+
* line padded so the parenthesis lands at {@link sourceColumn} (when
|
|
24
|
+
* supplied) or at the default four-space gap (when {@link sourceColumn}
|
|
25
|
+
* is `undefined` or would force the parenthesis closer to the target
|
|
26
|
+
* than {@link MIN_SOURCE_GAP}).
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildEntry(name: string, sourceColumn: number | undefined): string;
|
|
5
29
|
/**
|
|
6
30
|
* Registers a CSS file in browser/themes/shared/jar.inc.mn.
|
|
7
31
|
*
|
|
8
32
|
* Entry format:
|
|
9
33
|
* skin/classic/browser/{name}.css (../shared/{name}.css)
|
|
34
|
+
*
|
|
35
|
+
* The gap between target and source is sized to align with adjacent
|
|
36
|
+
* entries when the manifest already uses a wider column; falls back to
|
|
37
|
+
* a four-space minimum otherwise.
|
|
10
38
|
*/
|
|
11
39
|
export declare function registerSharedCSS(engineDir: string, fileName: string, after?: string, dryRun?: boolean): Promise<RegisterResult>;
|