@hominis/fireforge 0.38.0 → 0.40.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 (107) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +25 -1
  3. package/dist/src/cli.js +7 -0
  4. package/dist/src/commands/build.js +8 -3
  5. package/dist/src/commands/discard.js +75 -50
  6. package/dist/src/commands/export-flow.d.ts +1 -1
  7. package/dist/src/commands/export-flow.js +21 -10
  8. package/dist/src/commands/export-placement-gate.js +1 -1
  9. package/dist/src/commands/export-shared.js +31 -5
  10. package/dist/src/commands/import.js +5 -0
  11. package/dist/src/commands/lint-per-patch.js +97 -18
  12. package/dist/src/commands/lint-report.d.ts +21 -0
  13. package/dist/src/commands/lint-report.js +62 -0
  14. package/dist/src/commands/lint.js +23 -19
  15. package/dist/src/commands/manifest.js +2 -0
  16. package/dist/src/commands/patch/lint-ignore.js +20 -1
  17. package/dist/src/commands/patch/move-files-into.d.ts +47 -0
  18. package/dist/src/commands/patch/move-files-into.js +245 -0
  19. package/dist/src/commands/patch/move-files.js +27 -13
  20. package/dist/src/commands/patch/rename.js +5 -2
  21. package/dist/src/commands/patch/staged-dependency.js +102 -14
  22. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  23. package/dist/src/commands/re-export-adjacent.js +106 -0
  24. package/dist/src/commands/re-export-options.js +3 -0
  25. package/dist/src/commands/re-export.js +42 -53
  26. package/dist/src/commands/reset.js +5 -0
  27. package/dist/src/commands/run.js +18 -2
  28. package/dist/src/commands/status-check.d.ts +35 -0
  29. package/dist/src/commands/status-check.js +88 -0
  30. package/dist/src/commands/status-output.d.ts +9 -0
  31. package/dist/src/commands/status-output.js +33 -1
  32. package/dist/src/commands/status.js +33 -8
  33. package/dist/src/commands/test-diagnose.d.ts +1 -1
  34. package/dist/src/commands/test-diagnose.js +9 -3
  35. package/dist/src/commands/test-modes.d.ts +21 -0
  36. package/dist/src/commands/test-modes.js +42 -0
  37. package/dist/src/commands/test-run.d.ts +2 -0
  38. package/dist/src/commands/test-run.js +26 -5
  39. package/dist/src/commands/test.js +38 -32
  40. package/dist/src/commands/token.js +4 -1
  41. package/dist/src/commands/tree.d.ts +16 -0
  42. package/dist/src/commands/tree.js +174 -0
  43. package/dist/src/core/build-baseline-types.d.ts +7 -0
  44. package/dist/src/core/build-baseline.d.ts +1 -1
  45. package/dist/src/core/build-baseline.js +2 -1
  46. package/dist/src/core/build-prepare.d.ts +9 -0
  47. package/dist/src/core/build-prepare.js +24 -0
  48. package/dist/src/core/config-validate.js +25 -0
  49. package/dist/src/core/discard-baseline.d.ts +60 -0
  50. package/dist/src/core/discard-baseline.js +232 -0
  51. package/dist/src/core/license-headers.d.ts +12 -0
  52. package/dist/src/core/license-headers.js +27 -0
  53. package/dist/src/core/lint-cache.d.ts +15 -5
  54. package/dist/src/core/lint-cache.js +17 -5
  55. package/dist/src/core/patch-export.d.ts +12 -3
  56. package/dist/src/core/patch-export.js +13 -3
  57. package/dist/src/core/patch-lint-checkjs.d.ts +25 -2
  58. package/dist/src/core/patch-lint-checkjs.js +85 -16
  59. package/dist/src/core/patch-lint-cross.d.ts +9 -1
  60. package/dist/src/core/patch-lint-cross.js +69 -9
  61. package/dist/src/core/patch-lint-css.js +64 -16
  62. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  63. package/dist/src/core/patch-lint-diff.js +31 -0
  64. package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
  65. package/dist/src/core/patch-lint-mozbuild.js +104 -0
  66. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  67. package/dist/src/core/patch-lint-ownership.js +30 -0
  68. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  69. package/dist/src/core/patch-lint.d.ts +23 -0
  70. package/dist/src/core/patch-lint.js +34 -3
  71. package/dist/src/core/patch-manifest.d.ts +1 -1
  72. package/dist/src/core/patch-policy.d.ts +14 -1
  73. package/dist/src/core/patch-policy.js +13 -2
  74. package/dist/src/core/smoke-patterns.d.ts +24 -8
  75. package/dist/src/core/smoke-patterns.js +23 -12
  76. package/dist/src/core/status-classify.d.ts +16 -0
  77. package/dist/src/core/status-classify.js +20 -6
  78. package/dist/src/core/test-harness-crash.d.ts +10 -0
  79. package/dist/src/core/test-harness-crash.js +16 -0
  80. package/dist/src/core/test-stale-check.d.ts +15 -0
  81. package/dist/src/core/test-stale-check.js +37 -1
  82. package/dist/src/core/token-category.d.ts +40 -0
  83. package/dist/src/core/token-category.js +264 -0
  84. package/dist/src/core/token-manager.d.ts +7 -0
  85. package/dist/src/core/token-manager.js +82 -193
  86. package/dist/src/core/token-variant.d.ts +7 -0
  87. package/dist/src/core/token-variant.js +9 -0
  88. package/dist/src/core/tree-cow.d.ts +18 -0
  89. package/dist/src/core/tree-cow.js +71 -0
  90. package/dist/src/core/tree-guard.d.ts +35 -0
  91. package/dist/src/core/tree-guard.js +124 -0
  92. package/dist/src/core/tree-store.d.ts +53 -0
  93. package/dist/src/core/tree-store.js +214 -0
  94. package/dist/src/core/typecheck-shim.d.ts +27 -0
  95. package/dist/src/core/typecheck-shim.js +58 -4
  96. package/dist/src/core/typecheck.js +14 -3
  97. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  98. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  99. package/dist/src/errors/git.d.ts +8 -0
  100. package/dist/src/errors/git.js +17 -0
  101. package/dist/src/index.d.ts +2 -0
  102. package/dist/src/index.js +1 -0
  103. package/dist/src/types/commands/options.d.ts +45 -2
  104. package/dist/src/types/config.d.ts +34 -0
  105. package/dist/src/utils/validation.d.ts +10 -0
  106. package/dist/src/utils/validation.js +29 -0
  107. package/package.json +6 -6
@@ -67,3 +67,33 @@ export function resolvePatchOwnedSysMjs(currentNewFiles, patchQueueCtx) {
67
67
  export function resolvePatchOwnedChromeScripts(currentNewFiles, patchQueueCtx) {
68
68
  return resolveOwned(currentNewFiles, patchQueueCtx, (file) => file.endsWith('.js') && !file.endsWith('.sys.mjs'));
69
69
  }
70
+ /**
71
+ * Test-script predicate for the `checkJsTestFiles` pass (FORGE G5):
72
+ * plain `.js` files (not `.sys.mjs` modules) that live under a `/test/`
73
+ * path or carry a `browser_` / `test_` / `xpcshell_` basename. Duplicates
74
+ * `patch-lint.ts`'s `isTestFile` shape rather than importing it —
75
+ * patch-lint.ts imports this module, so the reverse edge would create an
76
+ * import cycle (dpdm gate). The agreement is pinned by
77
+ * `patch-lint-ownership.test.ts`.
78
+ */
79
+ export function isTestScriptFile(file) {
80
+ if (!file.endsWith('.js') || file.endsWith('.sys.mjs'))
81
+ return false;
82
+ if (file.includes('/test/'))
83
+ return true;
84
+ const basename = file.split('/').pop() ?? '';
85
+ return /^(?:browser_|test_|xpcshell_).*\.js$/.test(basename);
86
+ }
87
+ /**
88
+ * Returns the set of patch-owned test `.js` files (FORGE G5). Same
89
+ * ownership semantics as {@link resolvePatchOwnedSysMjs}; consumed by the
90
+ * `patchLint.checkJsTestFiles` pass, which checks each as its own small
91
+ * script-scope program.
92
+ *
93
+ * @param currentNewFiles - Files newly created in the current diff
94
+ * @param patchQueueCtx - Optional cross-patch context for queue-wide ownership
95
+ * @returns Set of patch-owned test-script file paths
96
+ */
97
+ export function resolvePatchOwnedTestScripts(currentNewFiles, patchQueueCtx) {
98
+ return resolveOwned(currentNewFiles, patchQueueCtx, isTestScriptFile);
99
+ }
@@ -2,5 +2,5 @@
2
2
  * Public re-exports for {@link ./patch-lint.ts}. Split out so the
3
3
  * orchestrator stays within the ESLint `max-lines` budget.
4
4
  */
5
- export { buildPatchQueueContext, collectForwardImportEdges, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, type ForwardImportEdge, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, type PatchQueueContext, type PatchQueueEntry, } from './patch-lint-cross.js';
5
+ export { buildPatchQueueContext, collectForwardImportEdges, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, type PatchQueueContext, type PatchQueueEntry, } from './patch-lint-cross.js';
6
6
  export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
@@ -128,6 +128,21 @@ export type PatchSizeTierDecision = {
128
128
  * defensive-for-tests choice.
129
129
  */
130
130
  export declare function resolvePatchSizeTier(filesAffected: ReadonlyArray<string>, patchTier?: 'branding'): PatchSizeTierDecision;
131
+ /**
132
+ * Read-only view of the size thresholds a tier enforces (FORGE G9).
133
+ * Public-API companion to {@link resolvePatchSizeTier} /
134
+ * {@link countNonBinaryDiffLines} so consumers can build waiver-freshness
135
+ * checks against the SAME numbers `large-patch-lines` /
136
+ * `large-patch-files` fire on instead of mirroring them.
137
+ */
138
+ export declare function getPatchSizeThresholds(tier: 'general' | 'test' | 'branding'): {
139
+ lines: {
140
+ notice: number;
141
+ warning: number;
142
+ error: number;
143
+ };
144
+ maxFiles: number;
145
+ };
131
146
  /**
132
147
  * Checks patch size and emits advisory warnings.
133
148
  *
@@ -179,6 +194,14 @@ export interface LintExportedPatchOptions {
179
194
  * attributes findings per patch instead of rebuilding per patch.
180
195
  */
181
196
  precomputedCheckJs?: readonly PatchLintIssue[];
197
+ /**
198
+ * Invoked with the issues DROPPED by the `ignoreChecks` waiver filter,
199
+ * when any were (FORGE G10). Suppression semantics are unchanged — the
200
+ * returned issue list still excludes them — but the caller can report
201
+ * the measurement a waived size finding carried (per-patch NOTICE lines
202
+ * and the `--report` JSON) instead of losing it entirely.
203
+ */
204
+ onSuppressed?: (suppressed: PatchLintIssue[]) => void;
182
205
  }
183
206
  /**
184
207
  * Runs all patch lint checks and returns combined issues.
@@ -3,15 +3,16 @@ import { dirname, join } from 'node:path';
3
3
  import { pathExists, readText } from '../utils/fs.js';
4
4
  import { stripJsComments } from '../utils/regex.js';
5
5
  import { containsUpstreamLicenseText, getLicenseHeader, hasAnyLicenseHeader, hasAnyLicenseHeaderAnyStyle, hasUpstreamMplBlockHeader, } from './license-headers.js';
6
- import { invokePatchLintCheckJs } from './patch-lint-checkjs.js';
6
+ import { invokePatchLintCheckJs, runCheckJsTestFilesGrouped } from './patch-lint-checkjs.js';
7
7
  import { lintChromeScriptJsDocForFile } from './patch-lint-chrome-jsdoc.js';
8
8
  import { lintPatchedCss } from './patch-lint-css.js';
9
9
  import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
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
- import { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
15
+ import { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs, resolvePatchOwnedTestScripts, } from './patch-lint-ownership.js';
15
16
  // ---------------------------------------------------------------------------
16
17
  // Cross-patch lint re-exports
17
18
  // ---------------------------------------------------------------------------
@@ -480,6 +481,19 @@ export function resolvePatchSizeTier(filesAffected, patchTier) {
480
481
  return { tier: 'branding', source: 'auto' };
481
482
  return { tier: 'general' };
482
483
  }
484
+ /**
485
+ * Read-only view of the size thresholds a tier enforces (FORGE G9).
486
+ * Public-API companion to {@link resolvePatchSizeTier} /
487
+ * {@link countNonBinaryDiffLines} so consumers can build waiver-freshness
488
+ * checks against the SAME numbers `large-patch-lines` /
489
+ * `large-patch-files` fire on instead of mirroring them.
490
+ */
491
+ export function getPatchSizeThresholds(tier) {
492
+ return {
493
+ lines: { ...PATCH_LINE_THRESHOLDS[tier] },
494
+ maxFiles: PATCH_FILES_THRESHOLDS[tier],
495
+ };
496
+ }
483
497
  /**
484
498
  * Checks patch size and emits advisory warnings.
485
499
  *
@@ -616,11 +630,12 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
616
630
  const { textLines: lineCount } = countNonBinaryDiffLines(diffContent);
617
631
  const patchOwnedFiles = resolvePatchOwnedSysMjs(newFiles, patchQueueCtx);
618
632
  const patchOwnedChromeScripts = resolvePatchOwnedChromeScripts(newFiles, patchQueueCtx);
619
- const [cssIssues, headerIssues, jsIssues, modifiedHeaderIssues] = await Promise.all([
633
+ const [cssIssues, headerIssues, jsIssues, modifiedHeaderIssues, mozBuildIssues] = await Promise.all([
620
634
  lintPatchedCss(repoDir, affectedFiles, diffContent, config),
621
635
  lintNewFileHeaders(repoDir, [...newFiles], config),
622
636
  lintPatchedJs(repoDir, affectedFiles, newFiles, config, patchOwnedFiles, patchOwnedChromeScripts),
623
637
  lintModifiedFileHeaders(repoDir, affectedFiles, newFiles),
638
+ lintMozBuildSortedLists(repoDir, affectedFiles),
624
639
  ]);
625
640
  const modCommentIssues = lintModificationComments(diffContent, config);
626
641
  const sizeIssues = options?.skipPatchSize
@@ -633,6 +648,7 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
633
648
  ...modifiedHeaderIssues,
634
649
  ...jsIssues,
635
650
  ...modCommentIssues,
651
+ ...mozBuildIssues,
636
652
  ];
637
653
  if (options?.precomputedCheckJs) {
638
654
  // Per-patch lint built one queue-wide program and already attributed
@@ -641,6 +657,17 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
641
657
  }
642
658
  else if (config.patchLint?.checkJs) {
643
659
  issues.push(...(await invokePatchLintCheckJs(repoDir, patchOwnedFiles, config.patchLint, dirname(repoDir), options?.checkJsReportScope)));
660
+ // Export-time twin of per-patch lint's test-file pass (FORGE G5), so
661
+ // export/re-export and `lint --per-patch` agree on the checked surface.
662
+ if (config.patchLint.checkJsTestFiles === true) {
663
+ const testGrouped = await runCheckJsTestFilesGrouped(repoDir, resolvePatchOwnedTestScripts(newFiles, patchQueueCtx), config.patchLint, dirname(repoDir));
664
+ issues.push(...testGrouped.global);
665
+ for (const [rel, list] of testGrouped.byFile) {
666
+ if (options?.checkJsReportScope && !options.checkJsReportScope.has(rel))
667
+ continue;
668
+ issues.push(...list);
669
+ }
670
+ }
644
671
  }
645
672
  // Filter out ignored checks last so every rule still runs (keeps the
646
673
  // implementation uniform) but suppressed rules do not surface. We do not
@@ -648,6 +675,10 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
648
675
  // inline `fireforge-ignore: <check>` markers work in the CSS and
649
676
  // forward-import rules.
650
677
  if (ignoreChecks && ignoreChecks.size > 0) {
678
+ const suppressed = issues.filter((issue) => ignoreChecks.has(issue.check));
679
+ if (suppressed.length > 0) {
680
+ options?.onSuppressed?.(suppressed);
681
+ }
651
682
  return issues.filter((issue) => !ignoreChecks.has(issue.check));
652
683
  }
653
684
  return issues;
@@ -5,7 +5,7 @@
5
5
  * is an implementation detail.
6
6
  */
7
7
  export { rebuildPatchesManifest, validatePatchesManifestConsistency, } from './patch-manifest-consistency.js';
8
- export { addPatchToManifest, loadPatchesManifest, loadPatchesManifestForWrite, mutatePatchRowsInManifest, PATCHES_MANIFEST, type PatchManifestRowMutation, type PatchManifestRowMutationResult, type PatchRenameEntry, removePatchFileAndManifest, renumberPatchesInManifest, rewriteStagedDependencyOwners, savePatchesManifest, } from './patch-manifest-io.js';
8
+ export { addPatchToManifest, loadPatchesManifest, loadPatchesManifestForWrite, mutatePatchRowsInManifest, PATCHES_MANIFEST, type PatchRenameEntry, removePatchFileAndManifest, renumberPatchesInManifest, rewriteStagedDependencyOwners, savePatchesManifest, } from './patch-manifest-io.js';
9
9
  export { checkVersionCompatibility, findPatchesAffectingFile, getClaimedFiles, stampPatchVersions, validatePatchIntegrity, } from './patch-manifest-query.js';
10
10
  export { resolvePatchIdentifier } from './patch-manifest-resolve.js';
11
11
  export { validatePatchesManifest } from './patch-manifest-validate.js';
@@ -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);
@@ -22,24 +22,40 @@ export declare const SMOKE_ERROR_PATTERNS: readonly RegExp[];
22
22
  /**
23
23
  * Returns `true` when `line` matches any pattern in
24
24
  * {@link SMOKE_ERROR_PATTERNS}. Does not consult the allowlist — that step
25
- * lives in {@link matchesAllowlist}, so the smoke runner can count
25
+ * lives in {@link matchAllowlist}, so the smoke runner can count
26
26
  * allowlisted hits separately from raw error matches for its summary.
27
27
  */
28
28
  export declare function matchesSmokeError(line: string): boolean;
29
29
  /**
30
- * Returns `true` when `line` matches any regex in `allow`. Safe to call
31
- * with an empty allowlist (always returns `false`).
30
+ * One compiled allowlist entry with its provenance retained so the smoke
31
+ * summary can attribute hits per entry (FORGE G8) — an entry that silently
32
+ * stops matching (its suppressed shape changed upstream) is only
33
+ * detectable when zero-hit entries are visible.
32
34
  */
33
- export declare function matchesAllowlist(line: string, allow: readonly RegExp[]): boolean;
35
+ export interface CompiledAllowlistEntry {
36
+ pattern: RegExp;
37
+ /** Verbatim source text of the entry. */
38
+ source: string;
39
+ /** Human-readable origin: `allow.txt:12` or `--console-allow #2`. */
40
+ origin: string;
41
+ }
42
+ /**
43
+ * Returns the index of the FIRST entry in `allow` matching `line`, or -1.
44
+ * First-match attribution is deterministic and cheap; a line matching
45
+ * several entries credits the earliest one. Safe to call with an empty
46
+ * allowlist (always returns -1).
47
+ */
48
+ export declare function matchAllowlist(line: string, allow: readonly CompiledAllowlistEntry[]): number;
34
49
  /**
35
50
  * Parses a newline-delimited allowlist file body. Lines are trimmed; blank
36
51
  * lines and `#`-prefixed comments are skipped. Each remaining line is
37
- * compiled as a RegExp. A bad pattern throws immediately — better to fail
38
- * fast at CLI parse time than to silently let a typo match nothing.
52
+ * compiled as a RegExp with its `<file>:<line>` origin retained. A bad
53
+ * pattern throws immediately — better to fail fast at CLI parse time than
54
+ * to silently let a typo match nothing.
39
55
  */
40
- export declare function compileAllowlistFromFile(body: string, sourcePath: string): RegExp[];
56
+ export declare function compileAllowlistFromFile(body: string, sourcePath: string): CompiledAllowlistEntry[];
41
57
  /**
42
58
  * Compiles an array of regex-string inputs (e.g. repeated `--console-allow`
43
59
  * flag values). Same fail-fast semantics as {@link compileAllowlistFromFile}.
44
60
  */
45
- export declare function compileAllowlistFromStrings(sources: readonly string[]): RegExp[];
61
+ export declare function compileAllowlistFromStrings(sources: readonly string[]): CompiledAllowlistEntry[];
@@ -32,7 +32,7 @@ export const SMOKE_ERROR_PATTERNS = [
32
32
  /**
33
33
  * Returns `true` when `line` matches any pattern in
34
34
  * {@link SMOKE_ERROR_PATTERNS}. Does not consult the allowlist — that step
35
- * lives in {@link matchesAllowlist}, so the smoke runner can count
35
+ * lives in {@link matchAllowlist}, so the smoke runner can count
36
36
  * allowlisted hits separately from raw error matches for its summary.
37
37
  */
38
38
  export function matchesSmokeError(line) {
@@ -44,22 +44,25 @@ export function matchesSmokeError(line) {
44
44
  return false;
45
45
  }
46
46
  /**
47
- * Returns `true` when `line` matches any regex in `allow`. Safe to call
48
- * with an empty allowlist (always returns `false`).
47
+ * Returns the index of the FIRST entry in `allow` matching `line`, or -1.
48
+ * First-match attribution is deterministic and cheap; a line matching
49
+ * several entries credits the earliest one. Safe to call with an empty
50
+ * allowlist (always returns -1).
49
51
  */
50
- export function matchesAllowlist(line, allow) {
51
- for (const pattern of allow) {
52
- if (pattern.test(line)) {
53
- return true;
52
+ export function matchAllowlist(line, allow) {
53
+ for (let i = 0; i < allow.length; i++) {
54
+ if (allow[i]?.pattern.test(line)) {
55
+ return i;
54
56
  }
55
57
  }
56
- return false;
58
+ return -1;
57
59
  }
58
60
  /**
59
61
  * Parses a newline-delimited allowlist file body. Lines are trimmed; blank
60
62
  * lines and `#`-prefixed comments are skipped. Each remaining line is
61
- * compiled as a RegExp. A bad pattern throws immediately — better to fail
62
- * fast at CLI parse time than to silently let a typo match nothing.
63
+ * compiled as a RegExp with its `<file>:<line>` origin retained. A bad
64
+ * pattern throws immediately — better to fail fast at CLI parse time than
65
+ * to silently let a typo match nothing.
63
66
  */
64
67
  export function compileAllowlistFromFile(body, sourcePath) {
65
68
  const lines = body.split(/\r?\n/);
@@ -69,7 +72,11 @@ export function compileAllowlistFromFile(body, sourcePath) {
69
72
  if (!line || line.startsWith('#'))
70
73
  return;
71
74
  try {
72
- compiled.push(new RegExp(line));
75
+ compiled.push({
76
+ pattern: new RegExp(line),
77
+ source: line,
78
+ origin: `${sourcePath}:${String(index + 1)}`,
79
+ });
73
80
  }
74
81
  catch (error) {
75
82
  const message = error instanceof Error ? error.message : String(error);
@@ -88,7 +95,11 @@ export function compileAllowlistFromStrings(sources) {
88
95
  const compiled = [];
89
96
  sources.forEach((source, index) => {
90
97
  try {
91
- compiled.push(new RegExp(source));
98
+ compiled.push({
99
+ pattern: new RegExp(source),
100
+ source,
101
+ origin: `--console-allow #${String(index + 1)}`,
102
+ });
92
103
  }
93
104
  catch (error) {
94
105
  const message = error instanceof Error ? error.message : String(error);
@@ -49,6 +49,14 @@ export interface ClassifiedFile extends StatusFile {
49
49
  * single claim is fully captured by the classification itself.
50
50
  */
51
51
  claimedBy?: string[];
52
+ /**
53
+ * Owning patch filename when exactly one patch claims this path
54
+ * (patch-backed, patch-owned-drift, and single-owner furnace entries).
55
+ * Unset for unowned, branding-generated, unowned-furnace, and conflict
56
+ * entries — conflicts carry `claimedBy` instead. Exposed through
57
+ * `status --json` as the `patch` field (FORGE G11).
58
+ */
59
+ owner?: string;
52
60
  }
53
61
  /**
54
62
  * Builds the file → owning-patch-filenames multimap from manifest rows.
@@ -66,6 +74,14 @@ export declare function buildPatchClaims(manifestPatches: ReadonlyArray<{
66
74
  * Single source of truth — status-output.ts renders from the same logic.
67
75
  */
68
76
  export declare function getPrimaryStatusCode(status: string): string;
77
+ /**
78
+ * True for the branding paths whose content FireForge generates itself
79
+ * (configure.sh, brand.properties/ftl, browser/moz.configure). Unlike the
80
+ * broader branding-root test, a brand-new unowned file under the branding
81
+ * directory is NOT generated — it is a patch candidate and must stay
82
+ * visible as unmanaged (the Assets.car precedent).
83
+ */
84
+ export declare function isGeneratedBrandingPath(file: string, binaryName: string): boolean;
69
85
  /**
70
86
  * Classifies files into patch-backed, unmanaged, branding, furnace, or
71
87
  * conflict buckets.
@@ -47,7 +47,14 @@ export function getPrimaryStatusCode(status) {
47
47
  }
48
48
  return status;
49
49
  }
50
- function isGeneratedBrandingPath(file, binaryName) {
50
+ /**
51
+ * True for the branding paths whose content FireForge generates itself
52
+ * (configure.sh, brand.properties/ftl, browser/moz.configure). Unlike the
53
+ * broader branding-root test, a brand-new unowned file under the branding
54
+ * directory is NOT generated — it is a patch candidate and must stay
55
+ * visible as unmanaged (the Assets.car precedent).
56
+ */
57
+ export function isGeneratedBrandingPath(file, binaryName) {
51
58
  const normalized = file.replace(/\\/g, '/');
52
59
  const brandingRoot = `browser/branding/${binaryName}`;
53
60
  return (normalized === 'browser/moz.configure' ||
@@ -64,13 +71,14 @@ function isGeneratedBrandingPath(file, binaryName) {
64
71
  * deletion the patch does not expect, or a failed comparison) reports
65
72
  * `patch-owned-drift`.
66
73
  */
67
- async function classifySingleOwnerFile(entry, engineDir, patchesDir, matchClassification) {
74
+ async function classifySingleOwnerFile(entry, engineDir, patchesDir, matchClassification, owner) {
68
75
  if (getPrimaryStatusCode(entry.status) === 'D') {
69
76
  // Deleted file: content matches only if the patch expects deletion
70
77
  const expected = await computePatchedContent(patchesDir, engineDir, entry.file);
71
78
  return {
72
79
  ...entry,
73
80
  classification: expected === null ? matchClassification : 'patch-owned-drift',
81
+ owner,
74
82
  };
75
83
  }
76
84
  // File exists on disk — compare actual vs expected
@@ -82,11 +90,12 @@ async function classifySingleOwnerFile(entry, engineDir, patchesDir, matchClassi
82
90
  return {
83
91
  ...entry,
84
92
  classification: actual === expected ? matchClassification : 'patch-owned-drift',
93
+ owner,
85
94
  };
86
95
  }
87
96
  catch (error) {
88
97
  verbose(`Treating ${entry.file} as patch-owned drift because patch-backed classification failed: ${toError(error).message}`);
89
- return { ...entry, classification: 'patch-owned-drift' };
98
+ return { ...entry, classification: 'patch-owned-drift', owner };
90
99
  }
91
100
  }
92
101
  /**
@@ -146,8 +155,8 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
146
155
  // `furnace`. Multi-owner and unowned furnace paths keep the
147
156
  // short-circuit — the ownership table independently flags
148
157
  // filesAffected conflicts.
149
- if (owners && owners.length === 1) {
150
- results.push(await classifySingleOwnerFile(entry, engineDir, patchesDir, 'furnace'));
158
+ if (owners && owners.length === 1 && owners[0] !== undefined) {
159
+ results.push(await classifySingleOwnerFile(entry, engineDir, patchesDir, 'furnace', owners[0]));
151
160
  }
152
161
  else {
153
162
  results.push({ ...entry, classification: 'furnace' });
@@ -174,7 +183,12 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
174
183
  continue;
175
184
  }
176
185
  // File is claimed by exactly one patch — compare content.
177
- results.push(await classifySingleOwnerFile(entry, engineDir, patchesDir, 'patch-backed'));
186
+ const owner = owners[0];
187
+ if (owner === undefined) {
188
+ results.push({ ...entry, classification: 'unmanaged' });
189
+ continue;
190
+ }
191
+ results.push(await classifySingleOwnerFile(entry, engineDir, patchesDir, 'patch-backed', owner));
178
192
  }
179
193
  return results;
180
194
  }
@@ -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
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * True when `lines` contain a category header (single-line or multi-line
3
+ * banner shape) whose name EQUALS `category`. Shared by the pre-add
4
+ * assertion, the banner creation path, and the section finder so all
5
+ * agree on what "exists" means.
6
+ */
7
+ export declare function categoryHeaderExists(lines: string[], category: string): boolean;
8
+ /**
9
+ * Asserts the category banner exists (or that creation was requested).
10
+ * Throws a GeneralError naming the available categories and the
11
+ * `--create-category` remedy otherwise.
12
+ */
13
+ export declare function assertTokenCategoryExists(engineDir: string, tokensCssPath: string, category: string, createCategory?: boolean): Promise<void>;
14
+ /**
15
+ * Splices a new single-line category banner ("= Name =" comment shape, the
16
+ * same format `discoverCategoryHeaders` recognises) just before the closing
17
+ * brace of the `:root` block, making the new category the last section.
18
+ * Mutates `lines` in place.
19
+ */
20
+ export declare function declareCategoryBanner(lines: string[], category: string, tokensCssPath: string): void;
21
+ /** Locates and bounds the named category section. Throws when absent. */
22
+ export declare function findCategorySection(lines: string[], category: string, tokensCssPath: string): {
23
+ categoryLine: number;
24
+ sectionEnd: number;
25
+ };
26
+ /** Location of an existing base-`:root` token declaration. */
27
+ export interface TokenDeclarationLocation {
28
+ /** 1-based line number in the tokens CSS file. */
29
+ line: number;
30
+ /** Nearest enclosing category banner name, when one precedes the declaration. */
31
+ category?: string;
32
+ }
33
+ /**
34
+ * Finds an existing declaration of `tokenName` inside the base `:root`
35
+ * block. Dark `@media` and `:root[variant]` companion blocks are
36
+ * deliberately excluded — they mirror the base declaration, they do not
37
+ * own the token. Comment content never matches, and the name match is
38
+ * exact (`--foo-bar` does not match `--x-foo-bar`).
39
+ */
40
+ export declare function findTokenDeclarationInRoot(lines: string[], tokenName: string): TokenDeclarationLocation | undefined;