@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.
Files changed (36) hide show
  1. package/README.md +29 -16
  2. package/dist/src/commands/build.js +27 -12
  3. package/dist/src/commands/config.js +56 -3
  4. package/dist/src/commands/discard.js +93 -1
  5. package/dist/src/commands/doctor.js +17 -4
  6. package/dist/src/commands/download.js +21 -0
  7. package/dist/src/commands/export-all.js +35 -6
  8. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +59 -8
  9. package/dist/src/commands/furnace/chrome-doc-templates.js +95 -12
  10. package/dist/src/commands/furnace/chrome-doc.js +24 -2
  11. package/dist/src/commands/furnace/deploy.js +10 -1
  12. package/dist/src/commands/furnace/init.js +28 -2
  13. package/dist/src/commands/furnace/remove.js +68 -0
  14. package/dist/src/commands/import.js +9 -1
  15. package/dist/src/commands/lint.js +78 -13
  16. package/dist/src/commands/patch/delete.js +2 -4
  17. package/dist/src/commands/patch/lint-ignore.js +2 -4
  18. package/dist/src/commands/patch/reorder.js +2 -4
  19. package/dist/src/commands/patch/tier.js +2 -4
  20. package/dist/src/commands/status.js +39 -1
  21. package/dist/src/commands/test.js +20 -1
  22. package/dist/src/commands/token.js +1 -1
  23. package/dist/src/core/furnace-apply.js +11 -3
  24. package/dist/src/core/furnace-config.js +19 -0
  25. package/dist/src/core/furnace-marker.d.ts +16 -0
  26. package/dist/src/core/furnace-marker.js +23 -0
  27. package/dist/src/core/git.js +66 -10
  28. package/dist/src/core/license-headers.d.ts +8 -0
  29. package/dist/src/core/license-headers.js +15 -1
  30. package/dist/src/core/manifest-rules.js +9 -1
  31. package/dist/src/core/patch-identifier-suggest.d.ts +25 -0
  32. package/dist/src/core/patch-identifier-suggest.js +108 -0
  33. package/dist/src/core/patch-lint.js +8 -0
  34. package/dist/src/core/register-shared-css.d.ts +28 -0
  35. package/dist/src/core/register-shared-css.js +67 -3
  36. 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
- const markerComment = await loadConfig(root)
308
- .then((forgeConfig) => forgeConfig.markerComment)
309
- .catch(() => undefined);
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
@@ -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
- const topLevelFiles = entries.filter((e) => e.isFile()).map((e) => e.name);
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
- const heartbeatStartedAt = Date.now();
129
- // Periodic heartbeat so non-TTY log scrapers (CI, tail -f) AND operators
130
- // watching a spinner both see that the add is still making progress
131
- // rather than a dead process. Each tick reports elapsed seconds so the
132
- // expected 1–3 minute window (see `download.ts`' info banner) is
133
- // observable as it unfolds.
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() - heartbeatStartedAt) / 1000);
137
- reportProgress(`Indexing Firefox source (still staging, ${elapsedS}s elapsed)`);
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
- return licenses.some((license) => content.startsWith(getLicenseHeader(license, style)));
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
- return content.includes(`skin/classic/browser/${name}.css`);
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>;