@hominis/fireforge 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/src/commands/build.js +8 -3
  3. package/dist/src/commands/discard.js +75 -50
  4. package/dist/src/commands/export-flow.d.ts +1 -1
  5. package/dist/src/commands/export-flow.js +7 -4
  6. package/dist/src/commands/export-placement-gate.js +1 -1
  7. package/dist/src/commands/export-shared.js +17 -2
  8. package/dist/src/commands/import.js +5 -0
  9. package/dist/src/commands/lint-per-patch.js +15 -5
  10. package/dist/src/commands/lint.js +1 -1
  11. package/dist/src/commands/patch/lint-ignore.js +20 -1
  12. package/dist/src/commands/patch/move-files-into.d.ts +47 -0
  13. package/dist/src/commands/patch/move-files-into.js +245 -0
  14. package/dist/src/commands/patch/move-files.js +27 -13
  15. package/dist/src/commands/patch/rename.js +4 -1
  16. package/dist/src/commands/reset.js +5 -0
  17. package/dist/src/commands/status-output.d.ts +9 -0
  18. package/dist/src/commands/status-output.js +33 -1
  19. package/dist/src/commands/status.js +17 -3
  20. package/dist/src/commands/test-diagnose.d.ts +1 -1
  21. package/dist/src/commands/test-diagnose.js +9 -3
  22. package/dist/src/commands/test-modes.d.ts +21 -0
  23. package/dist/src/commands/test-modes.js +42 -0
  24. package/dist/src/commands/test-run.d.ts +2 -0
  25. package/dist/src/commands/test-run.js +8 -2
  26. package/dist/src/commands/test.js +38 -32
  27. package/dist/src/core/build-baseline-types.d.ts +7 -0
  28. package/dist/src/core/build-baseline.d.ts +1 -1
  29. package/dist/src/core/build-baseline.js +2 -1
  30. package/dist/src/core/build-prepare.d.ts +9 -0
  31. package/dist/src/core/build-prepare.js +24 -0
  32. package/dist/src/core/config-validate.js +8 -0
  33. package/dist/src/core/discard-baseline.d.ts +60 -0
  34. package/dist/src/core/discard-baseline.js +232 -0
  35. package/dist/src/core/license-headers.d.ts +12 -0
  36. package/dist/src/core/license-headers.js +27 -0
  37. package/dist/src/core/patch-export.d.ts +10 -1
  38. package/dist/src/core/patch-export.js +12 -2
  39. package/dist/src/core/patch-lint-checkjs.d.ts +6 -1
  40. package/dist/src/core/patch-lint-checkjs.js +30 -14
  41. package/dist/src/core/patch-lint-cross.d.ts +9 -1
  42. package/dist/src/core/patch-lint-cross.js +69 -9
  43. package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
  44. package/dist/src/core/patch-lint-mozbuild.js +104 -0
  45. package/dist/src/core/patch-lint.js +4 -1
  46. package/dist/src/core/patch-policy.d.ts +14 -1
  47. package/dist/src/core/patch-policy.js +13 -2
  48. package/dist/src/core/test-harness-crash.d.ts +10 -0
  49. package/dist/src/core/test-harness-crash.js +16 -0
  50. package/dist/src/core/test-stale-check.d.ts +15 -0
  51. package/dist/src/core/test-stale-check.js +37 -1
  52. package/dist/src/core/token-manager.js +39 -2
  53. package/dist/src/core/token-variant.d.ts +7 -0
  54. package/dist/src/core/token-variant.js +9 -0
  55. package/dist/src/core/typecheck-shim.d.ts +13 -0
  56. package/dist/src/core/typecheck-shim.js +17 -2
  57. package/dist/src/core/typecheck.js +14 -3
  58. package/dist/src/types/commands/options.d.ts +12 -0
  59. package/dist/src/types/config.d.ts +15 -0
  60. package/package.json +1 -1
@@ -75,9 +75,19 @@ export function stripRedundantCategoryPrefix(sanitizedName, category) {
75
75
  }
76
76
  return stripped.length > 0 ? stripped : sanitizedName;
77
77
  }
78
- /** Sanitizes a patch name and drops redundant category/full-stem prefixes. */
78
+ /**
79
+ * Sanitizes a patch name and drops redundant category/full-stem prefixes.
80
+ *
81
+ * A single trailing `.patch` extension is stripped before sanitisation:
82
+ * operators frequently pass a full patch FILENAME where a name is expected
83
+ * (`move-files <from> 348-ui-foo.patch --create`, `rename --to foo.patch`),
84
+ * and 0.38.0 slugged the extension into `-patch`, producing double-suffixed
85
+ * `...-patch.patch` files. A name that deliberately ends in a literal
86
+ * `-patch` slug segment must now be written without the dot form.
87
+ */
79
88
  export function patchNameSlug(name, category) {
80
- return stripRedundantCategoryPrefix(sanitizeName(name), category);
89
+ const stem = name.replace(/\.patch$/i, '');
90
+ return stripRedundantCategoryPrefix(sanitizeName(stem), category);
81
91
  }
82
92
  /**
83
93
  * Generates the next patch filename with category.
@@ -25,11 +25,16 @@
25
25
  * while reporting only the patch under export.
26
26
  */
27
27
  import type { PatchLintIssue } from '../types/commands/index.js';
28
- import type { PatchLintCheckJsCompilerOptions, PatchLintConfig } from '../types/config.js';
28
+ import type { PatchLintCheckJsCompilerOptions, PatchLintConfig, PatchLintSeverityGate } from '../types/config.js';
29
29
  /** How a checkJs run controls reporting and resolution; see module docs. */
30
30
  export interface CheckJsMode {
31
31
  strict: boolean;
32
32
  compilerOptions?: PatchLintCheckJsCompilerOptions;
33
+ /**
34
+ * How to report undefined free identifiers (TS2304/TS2552). Default
35
+ * 'warning' — see `patchLint.undefinedIdentifiers` (FORGE F12).
36
+ */
37
+ undefinedIdentifiers?: PatchLintSeverityGate;
33
38
  }
34
39
  /**
35
40
  * Result of {@link runCheckJsGrouped}: diagnostics attributed to the
@@ -28,7 +28,7 @@
28
28
  import { basename, resolve } from 'node:path';
29
29
  import { pathExists } from '../utils/fs.js';
30
30
  import { verbose } from '../utils/logger.js';
31
- import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES } from './typecheck-shim.js';
31
+ import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
32
32
  // ---------------------------------------------------------------------------
33
33
  // Public API
34
34
  // ---------------------------------------------------------------------------
@@ -312,7 +312,7 @@ export async function runCheckJsGrouped(repoDir, resolutionOwned, extraShimPath,
312
312
  },
313
313
  };
314
314
  const program = ts.createProgram(rootFiles, options, host);
315
- const byFile = groupOwnedDiagnostics(ts, [...program.getSemanticDiagnostics(), ...program.getSyntacticDiagnostics()], relByAbsolute);
315
+ const byFile = groupOwnedDiagnostics(ts, [...program.getSemanticDiagnostics(), ...program.getSyntacticDiagnostics()], relByAbsolute, mode?.undefinedIdentifiers ?? 'warning');
316
316
  verbose(`checkJs: analyzed ${rootFiles.length - 1} file(s) across ${byFile.size} owning file(s)`);
317
317
  return { byFile, global: [] };
318
318
  }
@@ -323,11 +323,14 @@ export async function runCheckJsGrouped(repoDir, resolutionOwned, extraShimPath,
323
323
  * or shim files are dropped — only owned files (in `relByAbsolute`) carry
324
324
  * findings.
325
325
  */
326
- function groupOwnedDiagnostics(ts, diagnostics, relByAbsolute) {
326
+ function groupOwnedDiagnostics(ts, diagnostics, relByAbsolute, undefinedIdentifiers = 'warning') {
327
327
  const byFile = new Map();
328
328
  for (const diag of diagnostics) {
329
329
  if (SUPPRESSED_DIAGNOSTIC_CODES.has(diag.code))
330
330
  continue;
331
+ const isUndefinedIdentifier = UNDEFINED_IDENTIFIER_CODES.has(diag.code);
332
+ if (isUndefinedIdentifier && undefinedIdentifiers === 'off')
333
+ continue;
331
334
  const sourceFile = diag.file;
332
335
  if (!sourceFile)
333
336
  continue;
@@ -339,12 +342,20 @@ function groupOwnedDiagnostics(ts, diagnostics, relByAbsolute) {
339
342
  const messageText = typeof diag.messageText === 'string'
340
343
  ? diag.messageText
341
344
  : ts.flattenDiagnosticMessageText(diag.messageText, '\n');
342
- const severity = diag.category === ts.DiagnosticCategory.Error ? 'error' : 'warning';
345
+ const severity = isUndefinedIdentifier
346
+ ? undefinedIdentifiers === 'error'
347
+ ? 'error'
348
+ : 'warning'
349
+ : diag.category === ts.DiagnosticCategory.Error
350
+ ? 'error'
351
+ : 'warning';
343
352
  const bucket = byFile.get(relPath) ?? [];
344
353
  bucket.push({
345
354
  file: relPath,
346
355
  check: 'checkjs-type-error',
347
- message: `Line ${line}: ${messageText}`,
356
+ message: isUndefinedIdentifier
357
+ ? `Line ${line}: ${messageText} ${UNDEFINED_IDENTIFIER_HINT}`
358
+ : `Line ${line}: ${messageText}`,
348
359
  severity,
349
360
  });
350
361
  byFile.set(relPath, bucket);
@@ -389,11 +400,20 @@ export async function runCheckJs(repoDir, patchOwnedFiles, extraShimPath, projec
389
400
  * not surface other patches' diagnostics)
390
401
  */
391
402
  export async function invokePatchLintCheckJs(repoDir, patchOwnedFiles, patchLint, projectRoot, reportScope) {
403
+ return runCheckJs(repoDir, patchOwnedFiles, patchLint.checkJsExtraShim, projectRoot, modeFromPatchLintConfig(patchLint), reportScope);
404
+ }
405
+ /** Derives the {@link CheckJsMode} from a `patchLint` config block. */
406
+ function modeFromPatchLintConfig(patchLint) {
392
407
  const strict = patchLint.checkJsStrict === true;
393
- const mode = strict && patchLint.checkJsCompilerOptions
394
- ? { strict, compilerOptions: patchLint.checkJsCompilerOptions }
395
- : { strict };
396
- return runCheckJs(repoDir, patchOwnedFiles, patchLint.checkJsExtraShim, projectRoot, mode, reportScope);
408
+ return {
409
+ strict,
410
+ ...(strict && patchLint.checkJsCompilerOptions
411
+ ? { compilerOptions: patchLint.checkJsCompilerOptions }
412
+ : {}),
413
+ ...(patchLint.undefinedIdentifiers !== undefined
414
+ ? { undefinedIdentifiers: patchLint.undefinedIdentifiers }
415
+ : {}),
416
+ };
397
417
  }
398
418
  /**
399
419
  * Grouped variant of {@link invokePatchLintCheckJs}: builds one queue-wide
@@ -408,9 +428,5 @@ export async function invokePatchLintCheckJs(repoDir, patchOwnedFiles, patchLint
408
428
  * @param projectRoot - FireForge project root for shim resolution
409
429
  */
410
430
  export async function invokePatchLintCheckJsGrouped(repoDir, patchOwnedFiles, patchLint, projectRoot) {
411
- const strict = patchLint.checkJsStrict === true;
412
- const mode = strict && patchLint.checkJsCompilerOptions
413
- ? { strict, compilerOptions: patchLint.checkJsCompilerOptions }
414
- : { strict };
415
- return runCheckJsGrouped(repoDir, patchOwnedFiles, patchLint.checkJsExtraShim, projectRoot, mode);
431
+ return runCheckJsGrouped(repoDir, patchOwnedFiles, patchLint.checkJsExtraShim, projectRoot, modeFromPatchLintConfig(patchLint));
416
432
  }
@@ -10,6 +10,7 @@
10
10
  * single module.
11
11
  */
12
12
  import type { PatchLintIssue, PatchMetadata } from '../types/commands/index.js';
13
+ import type { FireForgeConfig, PatchPolicyConfig } from '../types/config.js';
13
14
  /**
14
15
  * One patch's contribution to {@link PatchQueueContext}.
15
16
  *
@@ -57,6 +58,13 @@ export interface PatchQueueEntry {
57
58
  export interface PatchQueueContext {
58
59
  /** Entries in application order (lowest `order` first). */
59
60
  entries: PatchQueueEntry[];
61
+ /**
62
+ * Optional patchPolicy config. When present, the forward-import rule
63
+ * validates its "Closest legal ordinal" suggestion against the importing
64
+ * patch's category range and suppresses it when no legal ordinal exists
65
+ * (FORGE F14). Absent = current behavior (hint always printed).
66
+ */
67
+ patchPolicy?: PatchPolicyConfig;
60
68
  }
61
69
  /**
62
70
  * Builds a {@link PatchQueueContext} by reading every .patch file in the
@@ -70,7 +78,7 @@ export interface PatchQueueContext {
70
78
  *
71
79
  * @param patchesDir - Path to the patches directory
72
80
  */
73
- export declare function buildPatchQueueContext(patchesDir: string): Promise<PatchQueueContext>;
81
+ export declare function buildPatchQueueContext(patchesDir: string, config?: FireForgeConfig): Promise<PatchQueueContext>;
74
82
  /**
75
83
  * Returns the raw `path → patches[]` map of files created in `new file
76
84
  * mode` by at least one patch in the queue. Paths created by only one
@@ -18,6 +18,7 @@ import { stripJsComments } from '../utils/regex.js';
18
18
  import { discoverPatches } from './patch-files.js';
19
19
  import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
20
20
  import { loadPatchesManifest } from './patch-manifest-io.js';
21
+ import { categoryRangeForOrder, categoryRangeLabel } from './patch-policy.js';
21
22
  import { extractNewFileContent } from './patch-transform.js';
22
23
  /**
23
24
  * Builds a {@link PatchQueueContext} by reading every .patch file in the
@@ -31,7 +32,7 @@ import { extractNewFileContent } from './patch-transform.js';
31
32
  *
32
33
  * @param patchesDir - Path to the patches directory
33
34
  */
34
- export async function buildPatchQueueContext(patchesDir) {
35
+ export async function buildPatchQueueContext(patchesDir, config) {
35
36
  const patches = await discoverPatches(patchesDir);
36
37
  const manifest = await loadPatchesManifest(patchesDir);
37
38
  const metadataByFilename = new Map();
@@ -78,7 +79,7 @@ export async function buildPatchQueueContext(patchesDir) {
78
79
  }
79
80
  // Sort by order so rules can rely on entries being in apply order.
80
81
  entries.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
81
- return { entries };
82
+ return { entries, ...(config?.patchPolicy ? { patchPolicy: config.patchPolicy } : {}) };
82
83
  }
83
84
  /**
84
85
  * Returns the raw `path → patches[]` map of files created in `new file
@@ -292,6 +293,34 @@ export function extractImportSpecifiersWithLines(source) {
292
293
  collectGetterSpecifiers(stripped, results, offsetToLine);
293
294
  return results;
294
295
  }
296
+ /**
297
+ * Bare `Name: "url"` property lines added inside a PRE-EXISTING
298
+ * `defineESModuleGetters` (or `XPCOMUtils` lazy-getter) map. Applied ONLY to
299
+ * `modifiedFileAdditions`: a patch that inserts one property into an
300
+ * existing getter object never carries the `defineESModuleGetters(` opener
301
+ * in its added lines, so the balanced-brace walk in
302
+ * {@link extractImportSpecifiersWithLines} cannot see it (FORGE F3). The
303
+ * URL-scheme restriction keeps ordinary object literals
304
+ * (`label: "Foo.sys.mjs"`) out; the leaf-extension filter downstream does
305
+ * the rest.
306
+ */
307
+ const BARE_GETTER_PROPERTY_LINE = /^\s*(?:["'])?[A-Za-z_$][\w$]*(?:["'])?\s*:\s*["']((?:resource|chrome|moz-src):\/\/[^"']+)["']\s*,?\s*$/;
308
+ /**
309
+ * Extracts specifiers from bare getter-property lines in added-lines-only
310
+ * content. Comment stripping preserves line alignment, so the reported line
311
+ * numbers match what {@link findForwardImportIgnoreLines} walks.
312
+ */
313
+ function extractBareGetterSpecifiers(source) {
314
+ const stripped = stripJsComments(source);
315
+ const results = [];
316
+ const lines = stripped.split('\n');
317
+ for (let i = 0; i < lines.length; i++) {
318
+ const match = BARE_GETTER_PROPERTY_LINE.exec(lines[i] ?? '');
319
+ if (match?.[1])
320
+ results.push({ specifier: match[1], line: i });
321
+ }
322
+ return results;
323
+ }
295
324
  /**
296
325
  * Marker comment operators can use to suppress the forward-import rule
297
326
  * for imports that resolve to a basename false positive (two unrelated
@@ -376,11 +405,22 @@ function buildNewFileIndex(ctx) {
376
405
  * pre-existing HEAD imports that happen to collide with a later-created file.
377
406
  */
378
407
  function eachForwardImportSite(ctx, newFileIndex, visit) {
379
- const checkSite = (entry, sitePath, content) => {
408
+ const checkSite = (entry, sitePath, content, includeBareGetterLines = false) => {
380
409
  if (!isForwardImportableFile(sitePath))
381
410
  return;
411
+ const sites = extractImportSpecifiersWithLines(content);
412
+ if (includeBareGetterLines) {
413
+ // Dedupe by (specifier, line): a patch that adds the whole getter
414
+ // call carries both the opener (found by the balanced walk) and the
415
+ // property line (found here) — report each site once.
416
+ const seen = new Set(sites.map((site) => `${site.specifier}|${String(site.line)}`));
417
+ for (const extra of extractBareGetterSpecifiers(content)) {
418
+ if (!seen.has(`${extra.specifier}|${String(extra.line)}`))
419
+ sites.push(extra);
420
+ }
421
+ }
382
422
  const ignoreLines = findForwardImportIgnoreLines(content);
383
- for (const { specifier, line } of extractImportSpecifiersWithLines(content)) {
423
+ for (const { specifier, line } of sites) {
384
424
  if (ignoreLines.has(line))
385
425
  continue;
386
426
  const cleaned = specifier.split(/[?#]/)[0] ?? specifier;
@@ -400,7 +440,7 @@ function eachForwardImportSite(ctx, newFileIndex, visit) {
400
440
  for (const [path, content] of entry.newFiles)
401
441
  checkSite(entry, path, content);
402
442
  for (const [path, added] of entry.modifiedFileAdditions)
403
- checkSite(entry, path, added);
443
+ checkSite(entry, path, added, true);
404
444
  }
405
445
  }
406
446
  /**
@@ -454,8 +494,28 @@ export function lintPatchQueueForwardImports(ctx) {
454
494
  .join(',');
455
495
  // Lowest ordinal that lands AFTER every later-ordered creator —
456
496
  // turns the operator's "guess and re-run" loop into a single shot
457
- // when the only fix is reordering.
497
+ // when the only fix is reordering. When patchPolicy is available and
498
+ // that ordinal falls outside the importing patch's category range,
499
+ // `patch reorder` would refuse it — suppress the hint and point at
500
+ // the staged-dependency remedy instead (FORGE F14).
458
501
  const suggestedOrder = Math.max(...laterOwners.map((o) => o.order)) + 1;
502
+ const category = entry.metadata?.category ?? /^\d+-([a-z]+)-/.exec(entry.filename)?.[1];
503
+ let ordinalHint = `Closest legal ordinal that satisfies this dependency: ${suggestedOrder}.`;
504
+ if (ctx.patchPolicy &&
505
+ category !== undefined &&
506
+ categoryRangeForOrder(ctx.patchPolicy, category, suggestedOrder) === null) {
507
+ const label = categoryRangeLabel(ctx.patchPolicy.ranges, category);
508
+ ordinalHint =
509
+ `No legal ordinal in the ${category} category range (${label}) lands after the ` +
510
+ 'creating patch(es); declaring the staged dependency is the recommended remedy.';
511
+ }
512
+ // The exact per-owner invocation turns the refusal into a paste-and-run
513
+ // remedy instead of a flag-discovery exercise (FORGE F3).
514
+ const stagedCommands = laterOwners
515
+ .map((o) => `fireforge patch staged-dependency ${entry.filename} --add ` +
516
+ `--file ${sitePath} --specifier "${specifier}" ` +
517
+ `--creates ${o.fullPath} --owner ${o.filename}`)
518
+ .join('; ');
459
519
  issues.push({
460
520
  file: sitePath,
461
521
  check: 'forward-import',
@@ -463,10 +523,10 @@ export function lintPatchQueueForwardImports(ctx) {
463
523
  message: `${sitePath} in ${entry.filename} imports "${specifier}", ` +
464
524
  `but the matching new file is created by a later patch: ${ownersSummary}. ` +
465
525
  'Reorder the patches so the dependency is created first, move the import ' +
466
- 'into the later patch, declare the intentional staged dependency with ' +
467
- '"fireforge patch staged-dependency --add", or mark the import with ' +
526
+ 'into the later patch, declare the intentional staged dependency with: ' +
527
+ `${stagedCommands}; or mark the import with ` +
468
528
  `"// ${FORWARD_IMPORT_IGNORE_MARKER}" if the basename collision is a false positive. ` +
469
- `Closest legal ordinal that satisfies this dependency: ${suggestedOrder}.`,
529
+ ordinalHint,
470
530
  severity: 'error',
471
531
  });
472
532
  });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Per-patch lint check for moz.build sorted-list violations (FORGE F2).
3
+ *
4
+ * mozbuild enforces alphabetical ordering on `StrictOrderingOnAppendList`
5
+ * variables and raises `mozbuild.util.UnsortedError: … expected "X" but got
6
+ * "Y"` — but only at CONFIGURE time, i.e. minutes into a `fireforge build`
7
+ * or `fireforge test --build` dispatch. This check reads the patched
8
+ * `moz.build` files from the engine tree (the working tree carries the
9
+ * applied patch state, same as every other per-patch rule) and reports the
10
+ * exact expected/got pair before any build starts.
11
+ */
12
+ import type { PatchLintIssue } from '../types/commands/index.js';
13
+ /**
14
+ * Lints the patched content of every `moz.build` file the patch touches for
15
+ * unsorted strict-ordered lists, mirroring mozbuild's `UnsortedError`
16
+ * report: the first index where the list diverges from its sorted copy.
17
+ */
18
+ export declare function lintMozBuildSortedLists(repoDir: string, affectedFiles: string[]): Promise<PatchLintIssue[]>;
@@ -0,0 +1,104 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Per-patch lint check for moz.build sorted-list violations (FORGE F2).
4
+ *
5
+ * mozbuild enforces alphabetical ordering on `StrictOrderingOnAppendList`
6
+ * variables and raises `mozbuild.util.UnsortedError: … expected "X" but got
7
+ * "Y"` — but only at CONFIGURE time, i.e. minutes into a `fireforge build`
8
+ * or `fireforge test --build` dispatch. This check reads the patched
9
+ * `moz.build` files from the engine tree (the working tree carries the
10
+ * applied patch state, same as every other per-patch rule) and reports the
11
+ * exact expected/got pair before any build starts.
12
+ */
13
+ import { join } from 'node:path';
14
+ import { pathExists, readText } from '../utils/fs.js';
15
+ import { mozbuildSortCompare } from './manifest-helpers.js';
16
+ /**
17
+ * moz.build list variables mozbuild declares as `StrictOrderingOnAppendList`
18
+ * (python/mozbuild/mozbuild/frontend/context.py). configure raises
19
+ * `UnsortedError` on append when out of order. `DIRS` is deliberately absent
20
+ * — directory order is meaningful and not strict-ordered. Dotted namespaces
21
+ * (`EXTRA_JS_MODULES.<subdir>`, `EXPORTS.<ns>`) inherit strict ordering
22
+ * from their parent variable.
23
+ */
24
+ const STRICT_ORDERED_MOZBUILD_LISTS = [
25
+ 'EXTRA_COMPONENTS',
26
+ 'EXTRA_JS_MODULES',
27
+ 'EXTRA_PP_COMPONENTS',
28
+ 'EXPORTS',
29
+ 'PYTHON_UNITTEST_MANIFESTS',
30
+ 'TESTING_JS_MODULES',
31
+ 'XPIDL_SOURCES',
32
+ ];
33
+ const LIST_OPENER = new RegExp(`^\\s*((?:${STRICT_ORDERED_MOZBUILD_LISTS.join('|')})(?:\\.[A-Za-z_][A-Za-z0-9_]*)*)\\s*\\+?=\\s*\\[`);
34
+ /** Extracts every strict-ordered list (with items in file order) from moz.build content. */
35
+ function collectStrictOrderedLists(content) {
36
+ const lines = content.split('\n');
37
+ const occurrences = [];
38
+ for (let i = 0; i < lines.length; i++) {
39
+ const line = lines[i] ?? '';
40
+ const opener = LIST_OPENER.exec(line);
41
+ if (!opener)
42
+ continue;
43
+ const varName = opener[1] ?? '';
44
+ const items = [];
45
+ // Items may start on the opener line (`VAR += ["a", "b"]`) or span
46
+ // following lines until the closing bracket.
47
+ let rest = line.slice(opener[0].length);
48
+ let closed = false;
49
+ for (;;) {
50
+ for (const quoted of rest.matchAll(/["']([^"']+)["']/g)) {
51
+ items.push(quoted[1] ?? '');
52
+ }
53
+ if (/\]/.test(rest)) {
54
+ closed = true;
55
+ break;
56
+ }
57
+ i += 1;
58
+ if (i >= lines.length)
59
+ break;
60
+ rest = lines[i] ?? '';
61
+ if (/^\s*\]/.test(rest)) {
62
+ closed = true;
63
+ break;
64
+ }
65
+ }
66
+ if (closed)
67
+ occurrences.push({ varName, items });
68
+ }
69
+ return occurrences;
70
+ }
71
+ /**
72
+ * Lints the patched content of every `moz.build` file the patch touches for
73
+ * unsorted strict-ordered lists, mirroring mozbuild's `UnsortedError`
74
+ * report: the first index where the list diverges from its sorted copy.
75
+ */
76
+ export async function lintMozBuildSortedLists(repoDir, affectedFiles) {
77
+ const issues = [];
78
+ for (const file of affectedFiles) {
79
+ if (!(file === 'moz.build' || file.endsWith('/moz.build')))
80
+ continue;
81
+ const filePath = join(repoDir, file);
82
+ if (!(await pathExists(filePath)))
83
+ continue;
84
+ const content = await readText(filePath);
85
+ for (const { varName, items } of collectStrictOrderedLists(content)) {
86
+ const sorted = [...items].sort(mozbuildSortCompare);
87
+ const divergence = items.findIndex((item, index) => item !== sorted[index]);
88
+ if (divergence === -1)
89
+ continue;
90
+ const expected = sorted[divergence] ?? '';
91
+ const got = items[divergence] ?? '';
92
+ issues.push({
93
+ file,
94
+ check: 'mozbuild-unsorted-list',
95
+ fingerprint: `mozbuild-unsorted-list|${file}|${varName}|${expected}|${got}`,
96
+ message: `moz.build list ${varName} is not alphabetically sorted: ` +
97
+ `expected "${expected}" but got "${got}" ` +
98
+ '(mozbuild will fail at configure time with UnsortedError)',
99
+ severity: 'error',
100
+ });
101
+ }
102
+ }
103
+ return issues;
104
+ }
@@ -10,6 +10,7 @@ import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-dif
10
10
  import { AGGREGATE_PATCH_FILE } from './patch-lint-diff-tag.js';
11
11
  import { hasRelativeImport } from './patch-lint-imports.js';
12
12
  import { validateExportJsDoc } from './patch-lint-jsdoc.js';
13
+ import { lintMozBuildSortedLists } from './patch-lint-mozbuild.js';
13
14
  import { lintObserverTopics } from './patch-lint-observer.js';
14
15
  import { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
15
16
  // ---------------------------------------------------------------------------
@@ -616,11 +617,12 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
616
617
  const { textLines: lineCount } = countNonBinaryDiffLines(diffContent);
617
618
  const patchOwnedFiles = resolvePatchOwnedSysMjs(newFiles, patchQueueCtx);
618
619
  const patchOwnedChromeScripts = resolvePatchOwnedChromeScripts(newFiles, patchQueueCtx);
619
- const [cssIssues, headerIssues, jsIssues, modifiedHeaderIssues] = await Promise.all([
620
+ const [cssIssues, headerIssues, jsIssues, modifiedHeaderIssues, mozBuildIssues] = await Promise.all([
620
621
  lintPatchedCss(repoDir, affectedFiles, diffContent, config),
621
622
  lintNewFileHeaders(repoDir, [...newFiles], config),
622
623
  lintPatchedJs(repoDir, affectedFiles, newFiles, config, patchOwnedFiles, patchOwnedChromeScripts),
623
624
  lintModifiedFileHeaders(repoDir, affectedFiles, newFiles),
625
+ lintMozBuildSortedLists(repoDir, affectedFiles),
624
626
  ]);
625
627
  const modCommentIssues = lintModificationComments(diffContent, config);
626
628
  const sizeIssues = options?.skipPatchSize
@@ -633,6 +635,7 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
633
635
  ...modifiedHeaderIssues,
634
636
  ...jsIssues,
635
637
  ...modCommentIssues,
638
+ ...mozBuildIssues,
636
639
  ];
637
640
  if (options?.precomputedCheckJs) {
638
641
  // Per-patch lint built one queue-wide program and already attributed
@@ -7,7 +7,7 @@
7
7
  * `patch reorder`, etc.).
8
8
  */
9
9
  import type { PatchesManifest, PatchMetadata } from '../types/commands/index.js';
10
- import type { FireForgeConfig } from '../types/config.js';
10
+ import type { FireForgeConfig, PatchPolicyConfig, PatchPolicyRange } from '../types/config.js';
11
11
  /** Stable issue codes returned by patch policy evaluation. */
12
12
  export type PatchPolicyIssueCode = 'filename-pattern' | 'filename-captures' | 'filename-metadata-mismatch' | 'order-collision' | 'category-range' | 'reserved-range' | 'reserved-documentation' | 'reserved-files' | 'description-required' | 'numeric-gap';
13
13
  /** A single patch policy validation finding. */
@@ -28,6 +28,19 @@ export interface PatchPolicyEnforcementInput {
28
28
  export declare function getPatchPolicyCategories(config: FireForgeConfig): string[];
29
29
  /** Checks whether a category is accepted by legacy defaults or the policy ranges. */
30
30
  export declare function isCategoryAllowedByConfig(config: FireForgeConfig, category: string): boolean;
31
+ /**
32
+ * Human-readable label for a category's configured ranges, e.g. `300-399`.
33
+ * Exported for the forward-import hint (FORGE F14), which must render the
34
+ * range it found no legal ordinal in.
35
+ */
36
+ export declare function categoryRangeLabel(ranges: readonly PatchPolicyRange[], category: string): string;
37
+ /**
38
+ * Returns the configured range that contains `order` for `category`, or
39
+ * null when no such range exists. Exported for the forward-import hint
40
+ * (FORGE F14), which suppresses ordinal suggestions the reorder policy
41
+ * would refuse.
42
+ */
43
+ export declare function categoryRangeForOrder(cfg: PatchPolicyConfig, category: string, order: number): PatchPolicyRange | null;
31
44
  /** Evaluates an entire patch manifest against the configured policy. */
32
45
  export declare function evaluatePatchPolicy(config: FireForgeConfig, manifest: PatchesManifest): PatchPolicyIssue[];
33
46
  /** Builds a sorted manifest snapshot from projected patch metadata. */
@@ -45,7 +45,12 @@ export function isCategoryAllowedByConfig(config, category) {
45
45
  function rangeLabel(range) {
46
46
  return `${String(range.from).padStart(3, '0')}-${String(range.to).padStart(3, '0')}`;
47
47
  }
48
- function categoryRangeLabel(ranges, category) {
48
+ /**
49
+ * Human-readable label for a category's configured ranges, e.g. `300-399`.
50
+ * Exported for the forward-import hint (FORGE F14), which must render the
51
+ * range it found no legal ordinal in.
52
+ */
53
+ export function categoryRangeLabel(ranges, category) {
49
54
  const matches = ranges.filter((range) => range.category === category);
50
55
  if (matches.length === 0)
51
56
  return '(no configured range)';
@@ -54,7 +59,13 @@ function categoryRangeLabel(ranges, category) {
54
59
  function reservedRangeForOrder(cfg, order) {
55
60
  return cfg.reservedRanges?.find((range) => order >= range.from && order <= range.to) ?? null;
56
61
  }
57
- function categoryRangeForOrder(cfg, category, order) {
62
+ /**
63
+ * Returns the configured range that contains `order` for `category`, or
64
+ * null when no such range exists. Exported for the forward-import hint
65
+ * (FORGE F14), which suppresses ordinal suggestions the reorder policy
66
+ * would refuse.
67
+ */
68
+ export function categoryRangeForOrder(cfg, category, order) {
58
69
  return (cfg.ranges.find((range) => {
59
70
  return range.category === category && order >= range.from && order <= range.to;
60
71
  }) ?? null);
@@ -154,6 +154,16 @@ export declare function classifyHarnessRun(exitCode: number, output: string, req
154
154
  export declare function buildGreenSummaryRejectedMessage(rejection: GreenSummaryRejection, exitCode: number): string;
155
155
  /** Builds the operator-facing failure message after retries are exhausted. */
156
156
  export declare function buildHarnessCrashMessage(signature: HarnessCrashSignature, attempts: number, commandLabel?: string): string;
157
+ /**
158
+ * Optional hint appended to the harness-crash message when a HEADED run on
159
+ * macOS died at the no-output timeout: the display may have slept and frozen
160
+ * the headed browser before any test started (FORGE F17). Pure — the
161
+ * platform is injected so unit tests need no mocking.
162
+ */
163
+ export declare function headedNoOutputTimeoutHint(signature: HarnessCrashSignature, context: {
164
+ headless: boolean;
165
+ platform: string;
166
+ }): string | undefined;
157
167
  /**
158
168
  * Builds the message for a run that produced no `TEST-START` despite
159
169
  * requesting paths — including exit-code-zero runs whose `Passed: 0`
@@ -537,6 +537,22 @@ export function buildHarnessCrashMessage(signature, attempts, commandLabel = 'ma
537
537
  'If it persists across many runs, inspect the mach virtualenv (mach resyncs psutil on its own; ' +
538
538
  'patching it manually does not stick).');
539
539
  }
540
+ /**
541
+ * Optional hint appended to the harness-crash message when a HEADED run on
542
+ * macOS died at the no-output timeout: the display may have slept and frozen
543
+ * the headed browser before any test started (FORGE F17). Pure — the
544
+ * platform is injected so unit tests need no mocking.
545
+ */
546
+ export function headedNoOutputTimeoutHint(signature, context) {
547
+ if (context.platform !== 'darwin' || context.headless)
548
+ return undefined;
549
+ if (!signature.reason.includes('no-output timeout'))
550
+ return undefined;
551
+ return ('Hint: this was a HEADED run on macOS that died at the no-output timeout. ' +
552
+ 'A common cause is the display going to sleep mid-run, which freezes the headed browser ' +
553
+ 'before any test starts. Wrap headed runs in `caffeinate -dimsu <command>` to keep the ' +
554
+ 'display awake, or pass --headless.');
555
+ }
540
556
  /**
541
557
  * Builds the message for a run that produced no `TEST-START` despite
542
558
  * requesting paths — including exit-code-zero runs whose `Passed: 0`
@@ -108,3 +108,18 @@ export declare function checkStaticComponentsStale(engineDir: string, baseline:
108
108
  * independently.
109
109
  */
110
110
  export declare function formatStaticComponentsRefusal(changedManifests: string[]): string;
111
+ /**
112
+ * Formats the post-mutation advisory printed by `fireforge reset` /
113
+ * `fireforge import` when `components.conf` diverged from the last full
114
+ * build (FORGE F13). Same probe as {@link formatStaticComponentsRefusal},
115
+ * different moment: this fires at mutation time so the operator learns a
116
+ * full build is needed BEFORE the next gate run refuses.
117
+ */
118
+ export declare function formatPostMutationStaticComponentsWarning(changedManifests: string[]): string;
119
+ /**
120
+ * Post-mutation advisory used by `fireforge reset` / `fireforge import`:
121
+ * warns (never throws, never blocks) when `components.conf` now differs
122
+ * from the last full-build anchor. Silently skipped when no baseline or
123
+ * anchor exists, or when any probe fails.
124
+ */
125
+ export declare function warnIfStaticComponentsStale(projectRoot: string, engineDir: string): Promise<void>;
@@ -27,7 +27,7 @@ import { createHash } from 'node:crypto';
27
27
  import { readFile } from 'node:fs/promises';
28
28
  import { join } from 'node:path';
29
29
  import { toError } from '../utils/errors.js';
30
- import { verbose } from '../utils/logger.js';
30
+ import { verbose, warn } from '../utils/logger.js';
31
31
  import { isPackageablePath, isXpcomManifestPath } from './build-audit.js';
32
32
  import { readBuildBaseline } from './build-baseline.js';
33
33
  import { collectChangedEnginePaths } from './engine-changes.js';
@@ -245,3 +245,39 @@ export function formatStaticComponentsRefusal(changedManifests) {
245
245
  'it accepts stale packaged content, not a stale compiled registration. Pass ' +
246
246
  '--allow-stale-components only if you rebuilt out-of-band and accept the risk.)');
247
247
  }
248
+ /**
249
+ * Formats the post-mutation advisory printed by `fireforge reset` /
250
+ * `fireforge import` when `components.conf` diverged from the last full
251
+ * build (FORGE F13). Same probe as {@link formatStaticComponentsRefusal},
252
+ * different moment: this fires at mutation time so the operator learns a
253
+ * full build is needed BEFORE the next gate run refuses.
254
+ */
255
+ export function formatPostMutationStaticComponentsWarning(changedManifests) {
256
+ const head = changedManifests.slice(0, STALE_PATHS_LIMIT);
257
+ const truncated = changedManifests.length - head.length;
258
+ const list = head.join(', ') + (truncated > 0 ? `, … (+${truncated} more)` : '');
259
+ return (`components.conf changed relative to the last full "fireforge build": ${list}.\n` +
260
+ 'The compiled StaticComponents table in obj-* no longer matches the tree, so the next ' +
261
+ '"fireforge test" will require a full "fireforge build" first — a scoped ' +
262
+ '"fireforge test --build" cannot regenerate the compiled table.');
263
+ }
264
+ /**
265
+ * Post-mutation advisory used by `fireforge reset` / `fireforge import`:
266
+ * warns (never throws, never blocks) when `components.conf` now differs
267
+ * from the last full-build anchor. Silently skipped when no baseline or
268
+ * anchor exists, or when any probe fails.
269
+ */
270
+ export async function warnIfStaticComponentsStale(projectRoot, engineDir) {
271
+ try {
272
+ const baseline = await readBuildBaseline(projectRoot);
273
+ if (baseline === undefined)
274
+ return;
275
+ const result = await checkStaticComponentsStale(engineDir, baseline);
276
+ if (!result.stale)
277
+ return;
278
+ warn(formatPostMutationStaticComponentsWarning(result.changedManifests));
279
+ }
280
+ catch (error) {
281
+ verbose(`Static-components post-mutation check skipped: ${toError(error).message}`);
282
+ }
283
+ }