@hominis/fireforge 0.28.1 → 0.28.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/CHANGELOG.md CHANGED
@@ -2,7 +2,13 @@
2
2
 
3
3
  ## 0.28.0
4
4
 
5
+ - Restored mach lint compatibility for FireForge-managed Git-backed Firefox checkouts by materializing a `.hgignore` copy of `.gitignore` when Firefox's ignorefile linter config is present.
5
6
  - Added the product-resolved Firefox source archive URL to `source set` output so pinned checksums can be verified against the exact archive target before download.
7
+ - Added dry-run locking for `re-export` so parallel previews serialize engine git inspection instead of racing on `.git/index.lock`.
8
+ - Added `re-export --scan --scan-files <manifest>` for dry-runnable bulk generated-file assignment across owner patches, with ambiguity and ownership refusals.
9
+ - Improved `fireforge test` diagnostics for harness startup failures and zero selected tests run, including the actionable harness line before generic failure output.
10
+ - Improved build failure summaries so real make/mach failures and target context outrank trailing warning-only output.
11
+ - Normalized whitespace-only blank hunk payloads in generated patch artifacts while documenting `npm run whitespace:check` as the release-safe source whitespace gate.
6
12
  - Fixed re-export serialization so blank context lines keep their unified-diff context marker, preventing FireForge-generated patches from producing false patch-owned drift warnings during `verify`.
7
13
  - Fixed partial `re-export` manifest writes so legacy source metadata is preserved on unselected patch rows unless `--stamp` or another source metadata update explicitly targets them.
8
14
  - Added regression coverage for targeted and full stamped re-export round-trips with blank context lines.
package/README.md CHANGED
@@ -55,14 +55,32 @@ After setup you should have a `fireforge.json`, an `engine/` directory containin
55
55
  npx fireforge status
56
56
  npx fireforge export browser/base/content/browser.js --name custom-toolbar --category ui
57
57
  npx fireforge re-export custom-toolbar
58
+ npx fireforge re-export --scan --scan-files generated-assets.json --dry-run
58
59
  npx fireforge lint --per-patch
59
60
  npx fireforge verify
61
+ npm run whitespace:check
60
62
  npx fireforge build
61
63
  npx fireforge test browser/base/content/test/browser/
62
64
  ```
63
65
 
64
66
  Use `fireforge --help` for the full set of commands.
65
67
 
68
+ For large generated asset batches, `re-export --scan --scan-files <manifest>` assigns files to
69
+ their owner patches without broad directory scanning. The manifest is JSON:
70
+
71
+ ```json
72
+ {
73
+ "assignments": [
74
+ { "patch": "002-branding-runtime-icons.patch", "files": ["browser/branding/hominis/icon.svg"] }
75
+ ]
76
+ }
77
+ ```
78
+
79
+ The command is dry-runnable, refuses ambiguous ownership, and reports each file-to-patch
80
+ assignment before refreshing the patch. For release whitespace checks, use
81
+ `npm run whitespace:check`; it still checks source diffs while excluding generated
82
+ `patches/*.patch` diff syntax.
83
+
66
84
  ## Rebasing Firefox Source
67
85
 
68
86
  When Mozilla publishes a new Firefox source release you need to update the configured version/product, download the new source code and reapply the patches:
@@ -77,27 +77,53 @@ function tailLines(text, maxLines) {
77
77
  return lines.slice(-maxLines).join('\n');
78
78
  }
79
79
  function extractLastMakeError(captured) {
80
- const lines = captured.split(/\r?\n/).filter((line) => /\bmake(?:\[\d+\])?: \*\*\*/.test(line));
81
- return lines.at(-1)?.trim();
80
+ const lines = captured.split(/\r?\n/).filter((line) => /\bg?make(?:\[\d+\])?: \*\*\*/.test(line));
81
+ const actionable = lines.filter((line) => !/\[\s*(?:all|build|default)\s*\]\s+Error\s+\d+/i.test(line));
82
+ return (actionable.at(-1) ?? lines.at(-1))?.trim();
83
+ }
84
+ function isWarningOnlyLine(line) {
85
+ if (/\b(?:error|failed|fatal)\b/i.test(line))
86
+ return false;
87
+ return (/\bwarning\b/i.test(line) ||
88
+ /urllib3|LibreSSL|NotOpenSSLWarning|InsecurePlatformWarning/i.test(line));
89
+ }
90
+ function extractRecentMakeContext(captured) {
91
+ const lines = captured
92
+ .split(/\r?\n/)
93
+ .map((line) => line.trimEnd())
94
+ .filter((line) => line.trim().length > 0);
95
+ const makeLines = lines.filter((line) => /\b(?:g?make)(?:\[\d+\])?:/.test(line));
96
+ if (makeLines.length === 0)
97
+ return undefined;
98
+ return makeLines.slice(-6).join('\n');
82
99
  }
83
100
  function extractLikelyFailingCommand(captured) {
84
101
  const lines = captured
85
102
  .split(/\r?\n/)
86
103
  .map((line) => line.trim())
87
104
  .filter((line) => line.length > 0);
105
+ let lastMakeErrorIndex = -1;
88
106
  for (let index = lines.length - 1; index >= 0; index--) {
107
+ if (/\bg?make(?:\[\d+\])?: \*\*\*/.test(lines[index] ?? '')) {
108
+ lastMakeErrorIndex = index;
109
+ break;
110
+ }
111
+ }
112
+ const startIndex = lastMakeErrorIndex > 0 ? lastMakeErrorIndex - 1 : lines.length - 1;
113
+ for (let index = startIndex; index >= 0; index--) {
89
114
  const line = lines[index];
90
115
  if (!line)
91
116
  continue;
117
+ if (isWarningOnlyLine(line))
118
+ continue;
92
119
  if (/^make(?:\[\d+\])?:/.test(line))
93
120
  continue;
94
121
  if (/^g?make(?:\[\d+\])?:/.test(line))
95
122
  continue;
96
123
  if (/^Error running mach:/.test(line))
97
124
  continue;
98
- if (/^\d+:\d+\.\d+\s+/.test(line))
99
- continue;
100
- if (/\b(?:cp|clang|clang\+\+|rustc|python|node|make|install_name_tool)\b/.test(line)) {
125
+ const comparable = line.replace(/^\d+:\d+\.\d+\s+/, '');
126
+ if (/\b(?:cp|clang|clang\+\+|rustc|python|node|make|install_name_tool)\b/.test(comparable)) {
101
127
  return line;
102
128
  }
103
129
  }
@@ -108,6 +134,7 @@ function buildFailureDiagnostics(result, engineDir, objDir, machCommand) {
108
134
  const stderrTail = tailLines(result.stderr, 20);
109
135
  const combinedTail = tailLines(captured, 30);
110
136
  const makeError = extractLastMakeError(captured);
137
+ const makeContext = extractRecentMakeContext(captured);
111
138
  const failingCommand = extractLikelyFailingCommand(captured);
112
139
  const logHint = objDir
113
140
  ? `engine/${objDir}/ (inspect build logs, warnings, and generated make targets under this objdir)`
@@ -119,6 +146,7 @@ function buildFailureDiagnostics(result, engineDir, objDir, machCommand) {
119
146
  `Build failed with exit code ${result.exitCode}.`,
120
147
  `Mach phase: ${machCommand}`,
121
148
  makeError ? `Last make error: ${makeError}` : undefined,
149
+ makeContext ? `Recent make context:\n${makeContext}` : undefined,
122
150
  failingCommand ? `Final failing command/error line: ${failingCommand}` : undefined,
123
151
  stderrTail ? `Captured stderr tail:\n${stderrTail}` : undefined,
124
152
  `Captured output tail:\n${combinedTail}`,
@@ -0,0 +1,5 @@
1
+ import type { PatchesManifest } from '../types/commands/index.js';
2
+ /** Loads and validates a `re-export --scan-files` assignment manifest. */
3
+ export declare function loadScanFilesAssignments(manifestPath: string, manifest: PatchesManifest): Promise<Map<string, string[]>>;
4
+ /** Serializes dry-run re-export git inspection against the same project tree. */
5
+ export declare function withDryRunReExportLock<T>(fireforgeDir: string, isDryRun: boolean, operation: () => Promise<T>): Promise<T>;
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { join } from 'node:path';
3
+ import { createSiblingLockPath, withFileLock } from '../core/file-lock.js';
4
+ import { resolvePatchIdentifier } from '../core/patch-manifest.js';
5
+ import { InvalidArgumentError } from '../errors/base.js';
6
+ import { toError } from '../utils/errors.js';
7
+ import { readText } from '../utils/fs.js';
8
+ import { normalizeEngineRelativeInput } from './re-export-scan.js';
9
+ function isRecord(value) {
10
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
11
+ }
12
+ function parseScanFilesManifest(raw, manifestPath) {
13
+ let parsed;
14
+ try {
15
+ parsed = JSON.parse(raw);
16
+ }
17
+ catch (error) {
18
+ throw new InvalidArgumentError(`--scan-files manifest is not valid JSON (${manifestPath}): ${toError(error).message}`, '--scan-files');
19
+ }
20
+ if (!isRecord(parsed) || !Array.isArray(parsed['assignments'])) {
21
+ throw new InvalidArgumentError('--scan-files manifest must contain an assignments array.', '--scan-files');
22
+ }
23
+ const assignments = [];
24
+ for (const [index, assignment] of parsed['assignments'].entries()) {
25
+ if (!isRecord(assignment)) {
26
+ throw new InvalidArgumentError(`--scan-files assignments[${index}] must be an object.`, '--scan-files');
27
+ }
28
+ const { patch, files } = assignment;
29
+ if (typeof patch !== 'string' || patch.trim().length === 0) {
30
+ throw new InvalidArgumentError(`--scan-files assignments[${index}].patch must be a non-empty string.`, '--scan-files');
31
+ }
32
+ if (!Array.isArray(files) ||
33
+ files.length === 0 ||
34
+ !files.every((file) => typeof file === 'string')) {
35
+ throw new InvalidArgumentError(`--scan-files assignments[${index}].files must be a non-empty string array.`, '--scan-files');
36
+ }
37
+ assignments.push({ patch, files });
38
+ }
39
+ if (assignments.length === 0) {
40
+ throw new InvalidArgumentError('--scan-files manifest must assign at least one file.', '--scan-files');
41
+ }
42
+ return { assignments };
43
+ }
44
+ /** Loads and validates a `re-export --scan-files` assignment manifest. */
45
+ export async function loadScanFilesAssignments(manifestPath, manifest) {
46
+ const parsed = parseScanFilesManifest(await readText(manifestPath), manifestPath);
47
+ const filesByPatch = new Map();
48
+ const ownerByFile = new Map();
49
+ for (const assignment of parsed.assignments) {
50
+ const patch = resolvePatchIdentifier(assignment.patch, manifest.patches);
51
+ if (!patch) {
52
+ const available = manifest.patches.map((entry) => entry.filename).join(', ');
53
+ throw new InvalidArgumentError(`--scan-files patch "${assignment.patch}" not found in manifest.\n\nAvailable patches: ${available}`, '--scan-files');
54
+ }
55
+ const patchFiles = filesByPatch.get(patch.filename) ?? new Set();
56
+ filesByPatch.set(patch.filename, patchFiles);
57
+ for (const rawFile of assignment.files) {
58
+ const file = normalizeEngineRelativeInput(rawFile, '--scan-files');
59
+ const previousOwner = ownerByFile.get(file);
60
+ if (previousOwner !== undefined && previousOwner !== patch.filename) {
61
+ throw new InvalidArgumentError(`--scan-files path is assigned to more than one patch: ${file} (${previousOwner}, ${patch.filename})`, '--scan-files');
62
+ }
63
+ ownerByFile.set(file, patch.filename);
64
+ patchFiles.add(file);
65
+ }
66
+ }
67
+ return new Map([...filesByPatch.entries()].map(([patchFilename, files]) => [patchFilename, [...files].sort()]));
68
+ }
69
+ /** Serializes dry-run re-export git inspection against the same project tree. */
70
+ export async function withDryRunReExportLock(fireforgeDir, isDryRun, operation) {
71
+ if (!isDryRun)
72
+ return operation();
73
+ const lockPath = createSiblingLockPath(join(fireforgeDir, 're-export-dry-run'), '.lock');
74
+ return withFileLock(lockPath, operation, {
75
+ timeoutMs: 24 * 60 * 60 * 1000,
76
+ onTimeoutMessage: `Timed out waiting for the FireForge re-export dry-run lock at ${lockPath}. ` +
77
+ 'If no other `fireforge re-export --dry-run` is running, remove the lock directory and retry.',
78
+ onStaleLockMessage: (ageMs) => `Removing stale FireForge re-export dry-run lock (age: ${Math.round(ageMs / 1000)}s). A previous dry-run process may have crashed.`,
79
+ });
80
+ }
81
+ //# sourceMappingURL=re-export-bulk-scan.js.map
@@ -0,0 +1,3 @@
1
+ import type { ReExportOptions } from '../types/commands/index.js';
2
+ /** Validates mutually exclusive `re-export` targeting and metadata options. */
3
+ export declare function validateReExportOptionCombinations(patches: readonly string[], options: ReExportOptions): void;
@@ -0,0 +1,39 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { InvalidArgumentError } from '../errors/base.js';
3
+ /** Validates mutually exclusive `re-export` targeting and metadata options. */
4
+ export function validateReExportOptionCombinations(patches, options) {
5
+ if (options.files !== undefined) {
6
+ if (options.all || options.scan) {
7
+ throw new InvalidArgumentError('--files cannot be combined with --scan or --all.', '--files');
8
+ }
9
+ if (options.scanFilesManifest !== undefined) {
10
+ throw new InvalidArgumentError('--files cannot be combined with --scan-files.', '--files');
11
+ }
12
+ if (patches.length !== 1) {
13
+ throw new InvalidArgumentError('--files operates on exactly one target patch. Pass a single patch identifier.', '--files');
14
+ }
15
+ }
16
+ if (options.scanFiles !== undefined) {
17
+ if (!options.scan)
18
+ throw new InvalidArgumentError('--scan-file requires --scan.', '--scan-file');
19
+ if (options.scanFilesManifest !== undefined) {
20
+ throw new InvalidArgumentError('--scan-file cannot be combined with --scan-files.', '--scan-file');
21
+ }
22
+ if (options.all || patches.length !== 1) {
23
+ throw new InvalidArgumentError('--scan-file operates on exactly one target patch. Pass a single patch identifier.', '--scan-file');
24
+ }
25
+ }
26
+ if (options.scanFilesManifest !== undefined) {
27
+ if (!options.scan)
28
+ throw new InvalidArgumentError('--scan-files requires --scan.', '--scan-files');
29
+ if (options.all || patches.length > 0) {
30
+ throw new InvalidArgumentError('--scan-files selects patches from its manifest and cannot be combined with positional patches or --all.', '--scan-files');
31
+ }
32
+ }
33
+ const usingTierFlag = options.tier !== undefined;
34
+ const usingLintIgnoreFlag = options.lintIgnore !== undefined && options.lintIgnore.length > 0;
35
+ if (options.all && (usingTierFlag || usingLintIgnoreFlag)) {
36
+ throw new InvalidArgumentError('--tier and --lint-ignore require explicit patch identifiers and cannot be combined with --all (different patches typically need different metadata).', '--all');
37
+ }
38
+ }
39
+ //# sourceMappingURL=re-export-options.js.map
@@ -6,6 +6,8 @@ export interface ScanResult {
6
6
  }
7
7
  /** Normalizes repeatable `--scan-file` inputs into safe engine-relative paths. */
8
8
  export declare function normalizeScanFiles(scanFiles: readonly string[] | undefined): string[] | undefined;
9
+ /** Normalizes one CLI-provided path into a safe engine-relative path. */
10
+ export declare function normalizeEngineRelativeInput(rawPath: string, flagName: string): string;
9
11
  /** Scans either broad sibling directories or explicit `--scan-file` paths for re-export. */
10
12
  export declare function scanPatchFilesForReExport(args: {
11
13
  currentFilesAffected: string[];
@@ -17,7 +17,8 @@ export function normalizeScanFiles(scanFiles) {
17
17
  .sort();
18
18
  return normalized.length > 0 ? normalized : undefined;
19
19
  }
20
- function normalizeEngineRelativeInput(rawPath, flagName) {
20
+ /** Normalizes one CLI-provided path into a safe engine-relative path. */
21
+ export function normalizeEngineRelativeInput(rawPath, flagName) {
21
22
  const normalized = normalizePathSlashes(stripEnginePrefix(rawPath).trim());
22
23
  if (normalized.length === 0) {
23
24
  throw new InvalidArgumentError(`${flagName} requires a non-empty engine-relative path.`, flagName);
@@ -16,7 +16,9 @@ import { pathExists } from '../utils/fs.js';
16
16
  import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
17
17
  import { pickDefined } from '../utils/options.js';
18
18
  import { runPatchLint } from './export-shared.js';
19
+ import { loadScanFilesAssignments, withDryRunReExportLock } from './re-export-bulk-scan.js';
19
20
  import { reExportFilesInPlace } from './re-export-files.js';
21
+ import { validateReExportOptionCombinations } from './re-export-options.js';
20
22
  import { assertScanFileAdditionsHaveDiffHunks, confirmBroadScanAdditions, normalizeScanFiles, scanPatchFilesForReExport, } from './re-export-scan.js';
21
23
  async function findMissingFiles(engineDir, files) {
22
24
  const missingFiles = [];
@@ -277,33 +279,7 @@ export async function reExportCommand(projectRoot, patches, options) {
277
279
  }
278
280
  const isDryRun = options.dryRun === true;
279
281
  intro(isDryRun ? 'FireForge Re-export (dry run)' : 'FireForge Re-export');
280
- // --files is mutually exclusive with --scan and --all: they select
281
- // different scope contracts.
282
- if (options.files !== undefined) {
283
- if (options.all || options.scan) {
284
- throw new InvalidArgumentError('--files cannot be combined with --scan or --all.', '--files');
285
- }
286
- if (patches.length !== 1) {
287
- throw new InvalidArgumentError('--files operates on exactly one target patch. Pass a single patch identifier.', '--files');
288
- }
289
- }
290
- if (options.scanFiles !== undefined) {
291
- if (!options.scan) {
292
- throw new InvalidArgumentError('--scan-file requires --scan.', '--scan-file');
293
- }
294
- if (options.all || patches.length !== 1) {
295
- throw new InvalidArgumentError('--scan-file operates on exactly one target patch. Pass a single patch identifier.', '--scan-file');
296
- }
297
- }
298
- // --tier and --lint-ignore are per-patch metadata edits; combining them
299
- // with --all silently rewrites every patch's tier/ignore list, which is
300
- // virtually always wrong (different patches have different shapes).
301
- // Refuse the combination so the operator must enumerate the targets.
302
- const usingTierFlag = options.tier !== undefined;
303
- const usingLintIgnoreFlag = options.lintIgnore !== undefined && options.lintIgnore.length > 0;
304
- if (options.all && (usingTierFlag || usingLintIgnoreFlag)) {
305
- throw new InvalidArgumentError('--tier and --lint-ignore require explicit patch identifiers and cannot be combined with --all (different patches typically need different metadata).', '--all');
306
- }
282
+ validateReExportOptionCombinations(patches, options);
307
283
  const paths = getProjectPaths(projectRoot);
308
284
  // Check if engine exists
309
285
  if (!(await pathExists(paths.engine))) {
@@ -318,8 +294,13 @@ export async function reExportCommand(projectRoot, patches, options) {
318
294
  if (!manifest || manifest.patches.length === 0) {
319
295
  throw new GeneralError('No patches found in manifest. Run "fireforge export" to create patches first.');
320
296
  }
297
+ const scanFilesByPatch = options.scanFilesManifest !== undefined
298
+ ? await loadScanFilesAssignments(options.scanFilesManifest, manifest)
299
+ : undefined;
321
300
  // Resolve which patches to re-export
322
- const selectedPatches = await resolveSelectedPatches(patches, options, manifest);
301
+ const selectedPatches = scanFilesByPatch !== undefined
302
+ ? manifest.patches.filter((patch) => scanFilesByPatch.has(patch.filename))
303
+ : await resolveSelectedPatches(patches, options, manifest);
323
304
  if (!selectedPatches)
324
305
  return;
325
306
  if (selectedPatches.length === 0) {
@@ -327,6 +308,15 @@ export async function reExportCommand(projectRoot, patches, options) {
327
308
  outro('Nothing to re-export');
328
309
  return;
329
310
  }
311
+ if (scanFilesByPatch !== undefined) {
312
+ info(`Bulk scan assignments from ${options.scanFilesManifest}`);
313
+ for (const patch of selectedPatches) {
314
+ const files = scanFilesByPatch.get(patch.filename) ?? [];
315
+ info(` ${patch.filename} <= ${files.length} file(s)`);
316
+ for (const file of files)
317
+ info(` + ${file}`);
318
+ }
319
+ }
330
320
  // --files path: handled end-to-end here so we can lint the *projected*
331
321
  // shrunken state (not the current queue) and skip the generic re-export
332
322
  // loop. The projection substitutes the target patch's diff and newFiles
@@ -335,7 +325,7 @@ export async function reExportCommand(projectRoot, patches, options) {
335
325
  // we write anything.
336
326
  if (options.files !== undefined) {
337
327
  const filesConfig = await loadConfig(projectRoot);
338
- await reExportFilesInPlace(paths, selectedPatches, options, filesConfig);
328
+ await withDryRunReExportLock(paths.fireforgeDir, isDryRun, () => reExportFilesInPlace(paths, selectedPatches, options, filesConfig));
339
329
  return;
340
330
  }
341
331
  const config = await loadConfig(projectRoot);
@@ -343,20 +333,24 @@ export async function reExportCommand(projectRoot, patches, options) {
343
333
  const reExportedFilenames = [];
344
334
  const progress = spinner('Preparing re-export...');
345
335
  const startedAt = Date.now();
346
- for (const [index, patch] of selectedPatches.entries()) {
347
- progress.message(`Re-exporting ${index + 1}/${selectedPatches.length}: ${patch.filename} (${patch.filesAffected.length} file(s), ${elapsedSince(startedAt)} elapsed)...`);
348
- try {
349
- const exported = await reExportSinglePatch(patch, paths, manifest, options, isDryRun, config);
350
- if (exported) {
351
- reExported++;
352
- reExportedFilenames.push(patch.filename);
336
+ await withDryRunReExportLock(paths.fireforgeDir, isDryRun, async () => {
337
+ for (const [index, patch] of selectedPatches.entries()) {
338
+ const assignedScanFiles = scanFilesByPatch?.get(patch.filename);
339
+ const patchOptions = assignedScanFiles !== undefined ? { ...options, scanFiles: assignedScanFiles } : options;
340
+ progress.message(`Re-exporting ${index + 1}/${selectedPatches.length}: ${patch.filename} (${patch.filesAffected.length} file(s), ${elapsedSince(startedAt)} elapsed)...`);
341
+ try {
342
+ const exported = await reExportSinglePatch(patch, paths, manifest, patchOptions, isDryRun, config);
343
+ if (exported) {
344
+ reExported++;
345
+ reExportedFilenames.push(patch.filename);
346
+ }
347
+ }
348
+ catch (error) {
349
+ warn(`Failed to re-export ${patch.filename}`);
350
+ warn(toError(error).message);
353
351
  }
354
352
  }
355
- catch (error) {
356
- warn(`Failed to re-export ${patch.filename}`);
357
- warn(toError(error).message);
358
- }
359
- }
353
+ });
360
354
  if (reExported === 0 && selectedPatches.length > 0) {
361
355
  progress.error('Re-export failed');
362
356
  throw new GeneralError('All selected patches failed to re-export. Check the errors above.');
@@ -402,6 +396,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
402
396
  .option('-a, --all', 'Re-export all patches')
403
397
  .option('-s, --scan', 'Scan directories for new/removed files and update filesAffected')
404
398
  .option('--scan-file <path>', 'With --scan, add this explicit engine-relative file to one target patch without collecting adjacent files. Repeatable.', (value, prev) => [...prev, value], [])
399
+ .option('--scan-files <manifest>', 'With --scan, bulk-assign generated files from a JSON manifest: {"assignments":[{"patch":"002-name.patch","files":["path"]}]}. Selects patches from the manifest.')
405
400
  .option('--files <paths>', 'Restrict the re-exported filesAffected to this comma-separated list (single target patch only)', (value) => value
406
401
  .split(',')
407
402
  .map((v) => v.trim())
@@ -415,10 +410,11 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
415
410
  .addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
416
411
  .option('--lint-ignore <check-id>', 'Append a lint check ID to the patch\'s PatchMetadata.lintIgnore (union, de-duped, repeatable). Mutually exclusive with --all. Use "fireforge patch lint-ignore" for --remove / --clear.', (value, prev) => [...prev, value], [])
417
412
  .action(withErrorHandling(async (patches, options) => {
418
- const { tier, lintIgnore, scanFile, ...rest } = options;
413
+ const { tier, lintIgnore, scanFile, scanFiles, ...rest } = options;
419
414
  await reExportCommand(getProjectRoot(), patches, {
420
415
  ...pickDefined(rest),
421
416
  ...(scanFile !== undefined && scanFile.length > 0 ? { scanFiles: scanFile } : {}),
417
+ ...(scanFiles !== undefined ? { scanFilesManifest: scanFiles } : {}),
422
418
  ...(tier !== undefined ? { tier: tier } : {}),
423
419
  ...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
424
420
  });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Resolves and appends an xpcshell `--app-path=<abs>` argument when the
3
+ * selected manifest requests a browser appdir that rebranded forks otherwise
4
+ * fail to discover.
5
+ */
6
+ export declare function maybeInjectAppdirArg(engineDir: string, normalizedPaths: readonly string[], objDir: string | undefined, extraArgs: string[]): Promise<boolean>;
@@ -0,0 +1,30 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
3
+ import { info, warn } from '../utils/logger.js';
4
+ /**
5
+ * Resolves and appends an xpcshell `--app-path=<abs>` argument when the
6
+ * selected manifest requests a browser appdir that rebranded forks otherwise
7
+ * fail to discover.
8
+ */
9
+ export async function maybeInjectAppdirArg(engineDir, normalizedPaths, objDir, extraArgs) {
10
+ if (!objDir)
11
+ return false;
12
+ if (operatorAlreadySetAppPath(extraArgs))
13
+ return false;
14
+ const outcome = await resolveXpcshellAppdirArg(engineDir, normalizedPaths, objDir);
15
+ switch (outcome.kind) {
16
+ case 'none':
17
+ return false;
18
+ case 'mismatch':
19
+ warn(`xpcshell appdir auto-injection skipped — multiple test paths resolved to different app dirs (${outcome.values.join(', ')}). Pass --mach-arg=--app-path=<abs> to disambiguate.`);
20
+ return false;
21
+ case 'unresolved':
22
+ warn(`xpcshell appdir auto-injection skipped — manifest at ${outcome.manifestPath} requests appdir "${outcome.relativeAppdir}" but no matching directory exists under ${objDir}/dist/. Build artifacts may be stale.`);
23
+ return false;
24
+ case 'injected':
25
+ extraArgs.push(`--app-path=${outcome.result.appPath}`);
26
+ info(`xpcshell appdir auto-injected: --app-path=${outcome.result.appPath} (from ${outcome.result.manifestPath} firefox-appdir=${outcome.result.relativeAppdir}).`);
27
+ return true;
28
+ }
29
+ }
30
+ //# sourceMappingURL=test-appdir.js.map
@@ -5,15 +5,17 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, testWithOutput, withBuildLock, } from '../core/mach.js';
6
6
  import { assertMarionettePortAvailable, extractForwardedMarionettePort, forwardedMachArgsIncludeMarionetteClient, shouldAutoForwardMarionettePortToMach, } from '../core/marionette-port.js';
7
7
  import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
8
+ import { buildHarnessEarlyExitMessage, classifyHarnessEarlyExit, } from '../core/test-harness-output.js';
8
9
  import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
9
10
  import { tryRepairStaleXpcshellTestSymlink } from '../core/test-stale-symlink.js';
10
- import { findNearestXpcshellManifest, operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
11
+ import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
11
12
  import { GeneralError } from '../errors/base.js';
12
13
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
13
14
  import { pathExists } from '../utils/fs.js';
14
15
  import { info, intro, outro, spinner, success, warn } from '../utils/logger.js';
15
16
  import { pickDefined } from '../utils/options.js';
16
17
  import { stripEnginePrefix } from '../utils/paths.js';
18
+ import { maybeInjectAppdirArg } from './test-appdir.js';
17
19
  async function assertTestPathsExist(engineDir, testPaths) {
18
20
  const missingPaths = [];
19
21
  for (const testPath of testPaths) {
@@ -193,6 +195,10 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
193
195
  if (/UNKNOWN TEST\b/i.test(combinedOutput)) {
194
196
  throw new GeneralError(buildUnknownTestMessage(normalizedPaths));
195
197
  }
198
+ const earlyExit = classifyHarnessEarlyExit(combinedOutput, normalizedPaths);
199
+ if (earlyExit) {
200
+ throw new GeneralError(buildHarnessEarlyExitMessage(earlyExit, normalizedPaths));
201
+ }
196
202
  // Fork-owned module load failures must beat the branding stale-build
197
203
  // branch: 2026-04-21 eval (Finding #14) saw a fork's test fail with
198
204
  // `Failed to load resource:///modules/mybrowser/MyBrowserStore.sys.mjs`
@@ -449,33 +455,6 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
449
455
  }
450
456
  handleNonZeroTestExit(result, normalizedPaths, appdirInjection, projectConfig.binaryName);
451
457
  }
452
- /**
453
- * Resolves and (when applicable) appends an `--app-path=<abs>` arg to
454
- * `extraArgs`. Returns true iff the arg was injected. The logging branches
455
- * mirror the {@link XpcshellAppdirOutcome} variants so an operator can tell
456
- * from the test output whether FireForge tried to help and what it found.
457
- */
458
- async function maybeInjectAppdirArg(engineDir, normalizedPaths, objDir, extraArgs) {
459
- if (!objDir)
460
- return false;
461
- if (operatorAlreadySetAppPath(extraArgs))
462
- return false;
463
- const outcome = await resolveXpcshellAppdirArg(engineDir, normalizedPaths, objDir);
464
- switch (outcome.kind) {
465
- case 'none':
466
- return false;
467
- case 'mismatch':
468
- warn(`xpcshell appdir auto-injection skipped — multiple test paths resolved to different app dirs (${outcome.values.join(', ')}). Pass --mach-arg=--app-path=<abs> to disambiguate.`);
469
- return false;
470
- case 'unresolved':
471
- warn(`xpcshell appdir auto-injection skipped — manifest at ${outcome.manifestPath} requests appdir "${outcome.relativeAppdir}" but no matching directory exists under ${objDir}/dist/. Build artifacts may be stale.`);
472
- return false;
473
- case 'injected':
474
- extraArgs.push(`--app-path=${outcome.result.appPath}`);
475
- info(`xpcshell appdir auto-injected: --app-path=${outcome.result.appPath} (from ${outcome.result.manifestPath} firefox-appdir=${outcome.result.relativeAppdir}).`);
476
- return true;
477
- }
478
- }
479
458
  /** Registers the test command on the CLI program. */
480
459
  export function registerTest(program, { getProjectRoot, withErrorHandling }) {
481
460
  program
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Result of normalizing Firefox's Mercurial ignore file for Git-backed
3
+ * checkouts.
4
+ */
5
+ export type FirefoxIgnorefileCompatibilityResult = 'created' | 'existing' | 'skipped';
6
+ /**
7
+ * Ensures Firefox's mozlint ignorefile configuration can be parsed in
8
+ * Git-backed source trees.
9
+ *
10
+ * Firefox's `tools/lint/ignorefile.yml` includes both `.gitignore` and
11
+ * `.hgignore`. Source archives and some Git mirrors may omit `.hgignore`,
12
+ * which makes `mach lint --fix <files>` fail before it reaches the scoped
13
+ * linter. For FireForge-managed Git checkouts, copying `.gitignore` gives
14
+ * mozlint the missing include and keeps the ignorefile linter's pattern
15
+ * comparison equivalent without patching upstream lint configuration.
16
+ */
17
+ export declare function ensureFirefoxIgnorefileCompatibility(engineDir: string): Promise<FirefoxIgnorefileCompatibilityResult>;
@@ -0,0 +1,31 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { join } from 'node:path';
3
+ import { pathExists, readText, writeText } from '../utils/fs.js';
4
+ /**
5
+ * Ensures Firefox's mozlint ignorefile configuration can be parsed in
6
+ * Git-backed source trees.
7
+ *
8
+ * Firefox's `tools/lint/ignorefile.yml` includes both `.gitignore` and
9
+ * `.hgignore`. Source archives and some Git mirrors may omit `.hgignore`,
10
+ * which makes `mach lint --fix <files>` fail before it reaches the scoped
11
+ * linter. For FireForge-managed Git checkouts, copying `.gitignore` gives
12
+ * mozlint the missing include and keeps the ignorefile linter's pattern
13
+ * comparison equivalent without patching upstream lint configuration.
14
+ */
15
+ export async function ensureFirefoxIgnorefileCompatibility(engineDir) {
16
+ const lintConfigPath = join(engineDir, 'tools', 'lint', 'ignorefile.yml');
17
+ if (!(await pathExists(lintConfigPath))) {
18
+ return 'skipped';
19
+ }
20
+ const hgignorePath = join(engineDir, '.hgignore');
21
+ if (await pathExists(hgignorePath)) {
22
+ return 'existing';
23
+ }
24
+ const gitignorePath = join(engineDir, '.gitignore');
25
+ if (!(await pathExists(gitignorePath))) {
26
+ return 'skipped';
27
+ }
28
+ await writeText(hgignorePath, await readText(gitignorePath));
29
+ return 'created';
30
+ }
31
+ //# sourceMappingURL=firefox-ignorefile.js.map
@@ -7,6 +7,7 @@ import { toError } from '../utils/errors.js';
7
7
  import { pathExists, removeFile } from '../utils/fs.js';
8
8
  import { verbose } from '../utils/logger.js';
9
9
  import { exec } from '../utils/process.js';
10
+ import { ensureFirefoxIgnorefileCompatibility } from './firefox-ignorefile.js';
10
11
  import { configureGitPerformance, ensureGit, git, GIT_ADD_CHUNK_TIMEOUT_ENV_VAR, GIT_ADD_CHUNK_TIMEOUT_MS, GIT_ADD_TIMEOUT_MS, } from './git-base.js';
11
12
  import { getWorkingTreeStatus } from './git-status.js';
12
13
  // ── Functions that remain in this file ──
@@ -291,6 +292,8 @@ export async function initRepository(dir, branchName = 'main', options = {}) {
291
292
  reportProgress('Configuring origin remote for build compatibility...');
292
293
  await git(['remote', 'add', 'origin', 'https://github.com/mozilla-firefox/firefox'], dir);
293
294
  reportProgress('Git phase complete: source git repository metadata initialized.');
295
+ reportProgress('Normalizing Firefox ignore files for Git-backed mach lint compatibility...');
296
+ await ensureFirefoxIgnorefileCompatibility(dir);
294
297
  // Add all files
295
298
  reportProgress('Indexing Firefox source with git add -A (this can take several minutes on large trees)...');
296
299
  await assertNoGitIndexLock(dir);
@@ -327,6 +330,8 @@ export async function resumeRepository(dir, options = {}) {
327
330
  await cleanupIndexLock(dir);
328
331
  // Ensure origin remote exists (may have been added before the interrupt)
329
332
  await ensureOriginRemote(dir);
333
+ reportProgress('Normalizing Firefox ignore files for Git-backed mach lint compatibility...');
334
+ await ensureFirefoxIgnorefileCompatibility(dir);
330
335
  // Stage all files
331
336
  reportProgress('Indexing Firefox source (resuming)...');
332
337
  await assertNoGitIndexLock(dir);
@@ -5,6 +5,7 @@ import { pathExists } from '../utils/fs.js';
5
5
  import { warn } from '../utils/logger.js';
6
6
  import { exec, execInherit, execInheritCapture, execSmokeRun, execStream, } from '../utils/process.js';
7
7
  import { createSiblingLockPath, withFileLock } from './file-lock.js';
8
+ import { ensureFirefoxIgnorefileCompatibility } from './firefox-ignorefile.js';
8
9
  import { explainMachError } from './mach-error-hints.js';
9
10
  import { getPython } from './mach-python.js';
10
11
  // Re-export sub-modules so existing `from './mach.js'` imports keep working.
@@ -32,6 +33,7 @@ export async function ensureMach(engineDir) {
32
33
  export async function runMach(args, engineDir, options = {}) {
33
34
  const python = await getPython(engineDir);
34
35
  await ensureMach(engineDir);
36
+ await ensureFirefoxIgnorefileCompatibility(engineDir);
35
37
  const machPath = join(engineDir, 'mach');
36
38
  const execOptions = {
37
39
  cwd: engineDir,
@@ -59,6 +61,7 @@ const CAPTURE_TAIL_LIMIT = 2 * 1024 * 1024;
59
61
  export async function runMachCapture(args, engineDir, options = {}) {
60
62
  const python = await getPython(engineDir);
61
63
  await ensureMach(engineDir);
64
+ await ensureFirefoxIgnorefileCompatibility(engineDir);
62
65
  const machPath = join(engineDir, 'mach');
63
66
  let stdout = '';
64
67
  let stderr = '';
@@ -89,6 +92,7 @@ export async function runMachCapture(args, engineDir, options = {}) {
89
92
  export async function runMachInheritCapture(args, engineDir, options = {}) {
90
93
  const python = await getPython(engineDir);
91
94
  await ensureMach(engineDir);
95
+ await ensureFirefoxIgnorefileCompatibility(engineDir);
92
96
  const machPath = join(engineDir, 'mach');
93
97
  return execInheritCapture(python, [machPath, ...args], {
94
98
  cwd: engineDir,
@@ -237,6 +241,7 @@ export async function run(engineDir, args = []) {
237
241
  export async function runMachSmoke(args, engineDir, options) {
238
242
  const python = await getPython(engineDir);
239
243
  await ensureMach(engineDir);
244
+ await ensureFirefoxIgnorefileCompatibility(engineDir);
240
245
  const machPath = join(engineDir, 'mach');
241
246
  return execSmokeRun(python, [machPath, ...args], {
242
247
  cwd: engineDir,
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Normalizes generated patch files before they are written to disk.
3
3
  *
4
- * Kept as a narrow chokepoint for future artifact-level fixes. It must not
5
- * rewrite hunk body lines: unified diffs encode a blank context line as a
6
- * physical line containing the leading context marker (`" "`), and FireForge's
7
- * verifier relies on that marker when replaying patch output.
4
+ * Kept as a narrow chokepoint for future artifact-level fixes. It must keep
5
+ * unified-diff hunk markers intact, but marker-only blank payload lines should
6
+ * not carry extra trailing whitespace (`"+ "` / `"- "` / `" "`), because raw
7
+ * repository whitespace checks flag those generated patch artifact lines even
8
+ * when the engine diff is clean.
8
9
  */
9
10
  export declare function normalizePatchArtifact(content: string): string;
@@ -2,12 +2,21 @@
2
2
  /**
3
3
  * Normalizes generated patch files before they are written to disk.
4
4
  *
5
- * Kept as a narrow chokepoint for future artifact-level fixes. It must not
6
- * rewrite hunk body lines: unified diffs encode a blank context line as a
7
- * physical line containing the leading context marker (`" "`), and FireForge's
8
- * verifier relies on that marker when replaying patch output.
5
+ * Kept as a narrow chokepoint for future artifact-level fixes. It must keep
6
+ * unified-diff hunk markers intact, but marker-only blank payload lines should
7
+ * not carry extra trailing whitespace (`"+ "` / `"- "` / `" "`), because raw
8
+ * repository whitespace checks flag those generated patch artifact lines even
9
+ * when the engine diff is clean.
9
10
  */
10
11
  export function normalizePatchArtifact(content) {
11
- return content;
12
+ return content
13
+ .split('\n')
14
+ .map((line) => {
15
+ if (/^[ +-]\s+$/.test(line)) {
16
+ return line[0] ?? line;
17
+ }
18
+ return line;
19
+ })
20
+ .join('\n');
12
21
  }
13
22
  //# sourceMappingURL=patch-artifact-normalize.js.map
@@ -0,0 +1,9 @@
1
+ export type HarnessEarlyExitKind = 'startup' | 'zero-tests';
2
+ export interface HarnessEarlyExit {
3
+ kind: HarnessEarlyExitKind;
4
+ line: string;
5
+ }
6
+ /** Classifies mach output where no requested test actually began running. */
7
+ export declare function classifyHarnessEarlyExit(output: string, normalizedPaths: readonly string[]): HarnessEarlyExit | undefined;
8
+ /** Builds the user-facing message for a harness startup or zero-run failure. */
9
+ export declare function buildHarnessEarlyExitMessage(earlyExit: HarnessEarlyExit, normalizedPaths: readonly string[]): string;
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ function getNonEmptyOutputLines(output) {
3
+ return output
4
+ .split(/\r?\n/)
5
+ .map((line) => line.trim())
6
+ .filter((line) => line.length > 0);
7
+ }
8
+ function findFirstMatchingLine(lines, patterns) {
9
+ return lines.find((line) => patterns.some((pattern) => pattern.test(line)));
10
+ }
11
+ /** Classifies mach output where no requested test actually began running. */
12
+ export function classifyHarnessEarlyExit(output, normalizedPaths) {
13
+ const lines = getNonEmptyOutputLines(output);
14
+ const startupLine = findFirstMatchingLine(lines, [
15
+ /HominisBrowserUnavailableError/i,
16
+ /Marionette.*(?:session|startup|start).*fail/i,
17
+ /(?:failed|unable) to (?:start|create|open).*Marionette/i,
18
+ /SessionNotCreatedException/i,
19
+ /Browser process exited during spawn/i,
20
+ ]);
21
+ if (startupLine) {
22
+ return { kind: 'startup', line: startupLine };
23
+ }
24
+ if (normalizedPaths.length === 0)
25
+ return undefined;
26
+ const zeroTestsLine = findFirstMatchingLine(lines, [
27
+ /\bRan 0 tests?\b/i,
28
+ /\b0 tests? ran\b/i,
29
+ /\b0 tests? selected\b/i,
30
+ /\b0 subtests\b/i,
31
+ /\bno tests (?:were )?(?:run|ran|selected|collected|found)\b/i,
32
+ ]);
33
+ if (zeroTestsLine) {
34
+ return { kind: 'zero-tests', line: zeroTestsLine };
35
+ }
36
+ return undefined;
37
+ }
38
+ /** Builds the user-facing message for a harness startup or zero-run failure. */
39
+ export function buildHarnessEarlyExitMessage(earlyExit, normalizedPaths) {
40
+ const reason = earlyExit.kind === 'startup'
41
+ ? 'The test harness failed during browser/session startup before the selected tests began.'
42
+ : 'The test harness exited after reporting that zero selected tests ran.';
43
+ const paths = normalizedPaths.length > 0 ? normalizedPaths.join(', ') : '(all tests)';
44
+ return (`mach test did not run the selected tests.\n\n` +
45
+ `${reason}\n\n` +
46
+ `Actionable harness line: ${earlyExit.line}\n\n` +
47
+ `Requested paths: ${paths}\n\n` +
48
+ 'Fix the harness startup/discovery issue above before interpreting this as a test failure.');
49
+ }
50
+ //# sourceMappingURL=test-harness-output.js.map
@@ -183,6 +183,13 @@ export interface ReExportOptions {
183
183
  * Requires `--scan` and exactly one target patch.
184
184
  */
185
185
  scanFiles?: string[];
186
+ /**
187
+ * Path to a JSON manifest containing bulk scan assignments:
188
+ * `{ "assignments": [{ "patch": "<patch>", "files": ["path"] }] }`.
189
+ * Requires `--scan`; mutually exclusive with positional patches, `--all`,
190
+ * `--scan-file`, and `--files`.
191
+ */
192
+ scanFilesManifest?: string;
186
193
  /**
187
194
  * Restrict the re-exported patch's filesAffected to this explicit list.
188
195
  * Files currently in the patch but not in this list are dropped (shrink);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.28.1",
3
+ "version": "0.28.5",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",