@hominis/fireforge 0.37.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 (99) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/dist/src/commands/build.js +13 -8
  4. package/dist/src/commands/config.js +1 -0
  5. package/dist/src/commands/discard.js +75 -50
  6. package/dist/src/commands/download.js +5 -2
  7. package/dist/src/commands/export-flow.d.ts +6 -2
  8. package/dist/src/commands/export-flow.js +17 -11
  9. package/dist/src/commands/export-placement-gate.js +4 -6
  10. package/dist/src/commands/export-placement-policy.d.ts +11 -3
  11. package/dist/src/commands/export-placement-policy.js +32 -29
  12. package/dist/src/commands/export-shared.js +17 -2
  13. package/dist/src/commands/export.js +5 -5
  14. package/dist/src/commands/furnace/index.js +23 -18
  15. package/dist/src/commands/import.js +5 -0
  16. package/dist/src/commands/lint-per-patch.js +15 -5
  17. package/dist/src/commands/lint.js +1 -1
  18. package/dist/src/commands/patch/lint-ignore.js +20 -1
  19. package/dist/src/commands/patch/move-files-create.d.ts +19 -0
  20. package/dist/src/commands/patch/move-files-create.js +116 -0
  21. package/dist/src/commands/patch/move-files-into.d.ts +47 -0
  22. package/dist/src/commands/patch/move-files-into.js +245 -0
  23. package/dist/src/commands/patch/move-files.d.ts +3 -1
  24. package/dist/src/commands/patch/move-files.js +74 -15
  25. package/dist/src/commands/patch/rename.js +4 -1
  26. package/dist/src/commands/patch/split.d.ts +11 -1
  27. package/dist/src/commands/patch/split.js +6 -5
  28. package/dist/src/commands/re-export-scan.d.ts +17 -0
  29. package/dist/src/commands/re-export-scan.js +51 -0
  30. package/dist/src/commands/re-export.js +17 -6
  31. package/dist/src/commands/reset.js +5 -0
  32. package/dist/src/commands/source.js +28 -3
  33. package/dist/src/commands/status-output.d.ts +9 -0
  34. package/dist/src/commands/status-output.js +33 -1
  35. package/dist/src/commands/status.js +17 -3
  36. package/dist/src/commands/test-diagnose.d.ts +1 -1
  37. package/dist/src/commands/test-diagnose.js +9 -3
  38. package/dist/src/commands/test-modes.d.ts +21 -0
  39. package/dist/src/commands/test-modes.js +42 -0
  40. package/dist/src/commands/test-register.js +6 -5
  41. package/dist/src/commands/test-run.d.ts +2 -0
  42. package/dist/src/commands/test-run.js +8 -2
  43. package/dist/src/commands/test-stale-gate.d.ts +44 -0
  44. package/dist/src/commands/test-stale-gate.js +81 -0
  45. package/dist/src/commands/test.js +52 -81
  46. package/dist/src/core/build-audit.d.ts +12 -0
  47. package/dist/src/core/build-audit.js +14 -0
  48. package/dist/src/core/build-baseline-types.d.ts +41 -0
  49. package/dist/src/core/build-baseline.d.ts +6 -1
  50. package/dist/src/core/build-baseline.js +48 -7
  51. package/dist/src/core/build-prepare.d.ts +9 -0
  52. package/dist/src/core/build-prepare.js +24 -0
  53. package/dist/src/core/config-paths.d.ts +1 -1
  54. package/dist/src/core/config-paths.js +1 -0
  55. package/dist/src/core/config-validate.js +14 -1
  56. package/dist/src/core/discard-baseline.d.ts +60 -0
  57. package/dist/src/core/discard-baseline.js +232 -0
  58. package/dist/src/core/engine-session-lock.d.ts +10 -1
  59. package/dist/src/core/engine-session-lock.js +28 -2
  60. package/dist/src/core/file-lock.d.ts +33 -0
  61. package/dist/src/core/file-lock.js +32 -6
  62. package/dist/src/core/firefox-archive.d.ts +2 -1
  63. package/dist/src/core/firefox-archive.js +32 -8
  64. package/dist/src/core/firefox.d.ts +8 -3
  65. package/dist/src/core/firefox.js +11 -6
  66. package/dist/src/core/furnace-validate-helpers.js +28 -1
  67. package/dist/src/core/license-headers.d.ts +24 -0
  68. package/dist/src/core/license-headers.js +103 -1
  69. package/dist/src/core/patch-export.d.ts +10 -1
  70. package/dist/src/core/patch-export.js +12 -2
  71. package/dist/src/core/patch-lint-checkjs.d.ts +6 -1
  72. package/dist/src/core/patch-lint-checkjs.js +30 -14
  73. package/dist/src/core/patch-lint-cross.d.ts +9 -1
  74. package/dist/src/core/patch-lint-cross.js +69 -9
  75. package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
  76. package/dist/src/core/patch-lint-mozbuild.js +104 -0
  77. package/dist/src/core/patch-lint.d.ts +3 -0
  78. package/dist/src/core/patch-lint.js +29 -8
  79. package/dist/src/core/patch-policy.d.ts +14 -1
  80. package/dist/src/core/patch-policy.js +13 -2
  81. package/dist/src/core/status-classify.d.ts +11 -2
  82. package/dist/src/core/status-classify.js +49 -25
  83. package/dist/src/core/test-harness-crash.d.ts +10 -0
  84. package/dist/src/core/test-harness-crash.js +16 -0
  85. package/dist/src/core/test-stale-check.d.ts +56 -1
  86. package/dist/src/core/test-stale-check.js +114 -4
  87. package/dist/src/core/token-manager.js +39 -2
  88. package/dist/src/core/token-variant.d.ts +7 -0
  89. package/dist/src/core/token-variant.js +9 -0
  90. package/dist/src/core/typecheck-shim.d.ts +13 -0
  91. package/dist/src/core/typecheck-shim.js +17 -2
  92. package/dist/src/core/typecheck.js +14 -3
  93. package/dist/src/types/commands/options.d.ts +69 -3
  94. package/dist/src/types/config.d.ts +22 -0
  95. package/dist/src/utils/options.d.ts +20 -0
  96. package/dist/src/utils/options.js +39 -0
  97. package/dist/src/utils/validation.d.ts +5 -0
  98. package/dist/src/utils/validation.js +7 -0
  99. package/package.json +3 -3
@@ -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
  // ---------------------------------------------------------------------------
@@ -35,15 +36,33 @@ const FILE_SIZE_THRESHOLDS = {
35
36
  general: { notice: 500, warning: 750, error: 900 },
36
37
  test: { notice: 1200, warning: 1400, error: 1600 },
37
38
  };
39
+ /**
40
+ * Counts the lines of `content` the way `wc -l` reports them: a trailing
41
+ * newline terminates the last line rather than starting an empty extra
42
+ * one. A naive `split('\n').length` over-counts by one for any content
43
+ * ending in `\n` (the common case), which made the size rules fire one
44
+ * line early against the operator's own `wc -l` measurement.
45
+ */
46
+ function countContentLines(content) {
47
+ const lines = content.split('\n');
48
+ if (lines[lines.length - 1] === '')
49
+ lines.pop();
50
+ return lines.length;
51
+ }
38
52
  /**
39
53
  * Counts the total lines in a unified diff and the number of non-binary
40
54
  * text lines, so binary hunks do not inflate patch size checks.
41
55
  *
56
+ * Line counting agrees with `wc -l`: a trailing newline does not add a
57
+ * phantom empty line to either `total` or the binary-hunk accounting.
58
+ *
42
59
  * @param diffContent - Raw unified diff string
43
60
  * @returns Object with `total` line count and `textLines` (total minus binary hunk lines)
44
61
  */
45
62
  export function countNonBinaryDiffLines(diffContent) {
46
63
  const lines = diffContent.split('\n');
64
+ if (lines[lines.length - 1] === '')
65
+ lines.pop();
47
66
  const total = lines.length;
48
67
  let binaryLines = 0;
49
68
  let inBinaryHunk = false;
@@ -329,12 +348,12 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
329
348
  }
330
349
  // 2. File size check (new files only)
331
350
  if (isNew) {
332
- const lineCount = content.split('\n').length;
351
+ const lineCount = countContentLines(content);
333
352
  const isTest = isTestFile(file);
334
353
  const thresholds = isTest ? FILE_SIZE_THRESHOLDS.test : FILE_SIZE_THRESHOLDS.general;
335
354
  const label = isTest ? 'Test file' : 'New file';
336
355
  const verb = isTest ? 'splitting' : 'decomposing';
337
- if (lineCount >= thresholds.error) {
356
+ if (lineCount > thresholds.error) {
338
357
  issues.push({
339
358
  file,
340
359
  check: 'file-too-large',
@@ -342,7 +361,7 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
342
361
  severity: 'error',
343
362
  });
344
363
  }
345
- else if (lineCount >= thresholds.warning) {
364
+ else if (lineCount > thresholds.warning) {
346
365
  issues.push({
347
366
  file,
348
367
  check: 'file-too-large',
@@ -350,7 +369,7 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
350
369
  severity: 'warning',
351
370
  });
352
371
  }
353
- else if (lineCount >= thresholds.notice) {
372
+ else if (lineCount > thresholds.notice) {
354
373
  issues.push({
355
374
  file,
356
375
  check: 'file-too-large',
@@ -507,7 +526,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
507
526
  severity: 'warning',
508
527
  });
509
528
  }
510
- if (lineCount >= lineThresholds.error) {
529
+ if (lineCount > lineThresholds.error) {
511
530
  issues.push({
512
531
  file: AGGREGATE_PATCH_FILE,
513
532
  check: 'large-patch-lines',
@@ -515,7 +534,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
515
534
  severity: 'error',
516
535
  });
517
536
  }
518
- else if (lineCount >= lineThresholds.warning) {
537
+ else if (lineCount > lineThresholds.warning) {
519
538
  issues.push({
520
539
  file: AGGREGATE_PATCH_FILE,
521
540
  check: 'large-patch-lines',
@@ -523,7 +542,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
523
542
  severity: 'warning',
524
543
  });
525
544
  }
526
- else if (lineCount >= lineThresholds.notice) {
545
+ else if (lineCount > lineThresholds.notice) {
527
546
  issues.push({
528
547
  file: AGGREGATE_PATCH_FILE,
529
548
  check: 'large-patch-lines',
@@ -598,11 +617,12 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
598
617
  const { textLines: lineCount } = countNonBinaryDiffLines(diffContent);
599
618
  const patchOwnedFiles = resolvePatchOwnedSysMjs(newFiles, patchQueueCtx);
600
619
  const patchOwnedChromeScripts = resolvePatchOwnedChromeScripts(newFiles, patchQueueCtx);
601
- const [cssIssues, headerIssues, jsIssues, modifiedHeaderIssues] = await Promise.all([
620
+ const [cssIssues, headerIssues, jsIssues, modifiedHeaderIssues, mozBuildIssues] = await Promise.all([
602
621
  lintPatchedCss(repoDir, affectedFiles, diffContent, config),
603
622
  lintNewFileHeaders(repoDir, [...newFiles], config),
604
623
  lintPatchedJs(repoDir, affectedFiles, newFiles, config, patchOwnedFiles, patchOwnedChromeScripts),
605
624
  lintModifiedFileHeaders(repoDir, affectedFiles, newFiles),
625
+ lintMozBuildSortedLists(repoDir, affectedFiles),
606
626
  ]);
607
627
  const modCommentIssues = lintModificationComments(diffContent, config);
608
628
  const sizeIssues = options?.skipPatchSize
@@ -615,6 +635,7 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
615
635
  ...modifiedHeaderIssues,
616
636
  ...jsIssues,
617
637
  ...modCommentIssues,
638
+ ...mozBuildIssues,
618
639
  ];
619
640
  if (options?.precomputedCheckJs) {
620
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);
@@ -11,12 +11,21 @@
11
11
  * normal after `fireforge import`.
12
12
  * - `patch-owned-drift`: the file is claimed by exactly one patch, but
13
13
  * the live engine content no longer matches that patch's expected
14
- * post-apply content.
14
+ * post-apply content. This includes furnace-prefixed paths: a path
15
+ * can be both Furnace-managed and patch-claimed (export a deployed
16
+ * component, then edit the workspace source and `furnace deploy`
17
+ * again — the deployed copy now has content the patch body lacks).
18
+ * Before 0.38.0 the furnace prefix check ran first and silently
19
+ * bucketed such files as `furnace`, so `status` reported the stale
20
+ * patch as owned.
15
21
  * - `unmanaged`: edits not explained by any patch or tool — local
16
22
  * drift to export or discard.
17
23
  * - `branding`: files under tool-managed branding paths, written by
18
24
  * FireForge's branding pipeline.
19
- * - `furnace`: files under Furnace-managed component prefixes.
25
+ * - `furnace`: files under Furnace-managed component prefixes. When
26
+ * exactly one patch also claims the path, this bucket asserts the
27
+ * live content matches that patch's expected post-apply content —
28
+ * a mismatch is reported as `patch-owned-drift` instead (see above).
20
29
  * - `conflict`: the file is claimed by two or more patches in
21
30
  * `patches.json`. The human `--ownership` mode already surfaces
22
31
  * this bucket as `CONFLICT`; the classification is carried through
@@ -55,6 +55,40 @@ function isGeneratedBrandingPath(file, binaryName) {
55
55
  normalized === `${brandingRoot}/locales/en-US/brand.properties` ||
56
56
  normalized === `${brandingRoot}/locales/en-US/brand.ftl`);
57
57
  }
58
+ /**
59
+ * Compares a single-owner file's live engine content against its owning
60
+ * patch's expected post-apply content. `matchClassification` is what a
61
+ * clean match reports — `patch-backed` for ordinary patch-claimed paths,
62
+ * `furnace` for furnace-prefixed paths so healthy deployed components
63
+ * keep landing in the pinned furnace bucket. Any mismatch (including a
64
+ * deletion the patch does not expect, or a failed comparison) reports
65
+ * `patch-owned-drift`.
66
+ */
67
+ async function classifySingleOwnerFile(entry, engineDir, patchesDir, matchClassification) {
68
+ if (getPrimaryStatusCode(entry.status) === 'D') {
69
+ // Deleted file: content matches only if the patch expects deletion
70
+ const expected = await computePatchedContent(patchesDir, engineDir, entry.file);
71
+ return {
72
+ ...entry,
73
+ classification: expected === null ? matchClassification : 'patch-owned-drift',
74
+ };
75
+ }
76
+ // File exists on disk — compare actual vs expected
77
+ try {
78
+ const [expected, actual] = await Promise.all([
79
+ computePatchedContent(patchesDir, engineDir, entry.file),
80
+ readText(join(engineDir, entry.file)),
81
+ ]);
82
+ return {
83
+ ...entry,
84
+ classification: actual === expected ? matchClassification : 'patch-owned-drift',
85
+ };
86
+ }
87
+ catch (error) {
88
+ verbose(`Treating ${entry.file} as patch-owned drift because patch-backed classification failed: ${toError(error).message}`);
89
+ return { ...entry, classification: 'patch-owned-drift' };
90
+ }
91
+ }
58
92
  /**
59
93
  * Classifies files into patch-backed, unmanaged, branding, furnace, or
60
94
  * conflict buckets.
@@ -104,7 +138,20 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
104
138
  }
105
139
  }
106
140
  if (isFurnace) {
107
- results.push({ ...entry, classification: 'furnace' });
141
+ // A furnace path claimed by exactly one patch gets the same
142
+ // expected-vs-actual comparison as any other single-owner path:
143
+ // after a `furnace deploy` of an edited component the deployed
144
+ // copy has content the owning patch's body lacks, and the old
145
+ // unconditional short-circuit silently bucketed that drift as
146
+ // `furnace`. Multi-owner and unowned furnace paths keep the
147
+ // short-circuit — the ownership table independently flags
148
+ // filesAffected conflicts.
149
+ if (owners && owners.length === 1) {
150
+ results.push(await classifySingleOwnerFile(entry, engineDir, patchesDir, 'furnace'));
151
+ }
152
+ else {
153
+ results.push({ ...entry, classification: 'furnace' });
154
+ }
108
155
  continue;
109
156
  }
110
157
  }
@@ -127,30 +174,7 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
127
174
  continue;
128
175
  }
129
176
  // File is claimed by exactly one patch — compare content.
130
- if (primaryCode === 'D') {
131
- // Deleted file: patch-backed only if patch expects deletion
132
- const expected = await computePatchedContent(patchesDir, engineDir, entry.file);
133
- results.push({
134
- ...entry,
135
- classification: expected === null ? 'patch-backed' : 'patch-owned-drift',
136
- });
137
- continue;
138
- }
139
- // File exists on disk — compare actual vs expected
140
- try {
141
- const [expected, actual] = await Promise.all([
142
- computePatchedContent(patchesDir, engineDir, entry.file),
143
- readText(join(engineDir, entry.file)),
144
- ]);
145
- results.push({
146
- ...entry,
147
- classification: actual === expected ? 'patch-backed' : 'patch-owned-drift',
148
- });
149
- }
150
- catch (error) {
151
- verbose(`Treating ${entry.file} as patch-owned drift because patch-backed classification failed: ${toError(error).message}`);
152
- results.push({ ...entry, classification: 'patch-owned-drift' });
153
- }
177
+ results.push(await classifySingleOwnerFile(entry, engineDir, patchesDir, 'patch-backed'));
154
178
  }
155
179
  return results;
156
180
  }
@@ -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`
@@ -1,4 +1,6 @@
1
+ import { isXpcomManifestPath } from './build-audit.js';
1
2
  import type { BuildBaseline, TestPackagingCoverage } from './build-baseline-types.js';
3
+ export { isXpcomManifestPath };
2
4
  /** Result of the stale-build preflight probe. */
3
5
  export interface StaleBuildResult {
4
6
  /** True when at least one packageable engine file changed since the baseline. */
@@ -53,7 +55,10 @@ export declare const FULL_SUITE_REQUEST = "(entire suite)";
53
55
  *
54
56
  * Coverage semantics: `undefined` (pre-0.37.0 baseline) and `'full'` cover
55
57
  * everything. A scoped list covers a request path when the request equals a
56
- * covered entry or sits beneath a covered directory entry. Both sides are
58
+ * covered entry, sits beneath a covered directory entry, or shares a
59
+ * manifest granule with a covered entry ({@link toManifestGranule} — a
60
+ * scoped `test --build` packages the whole manifest directory, so a
61
+ * same-manifest sibling of a covered file is packaged too). Both sides are
57
62
  * normalized to forward slashes so Windows-style CLI input cannot defeat
58
63
  * the prefix rule (baseline paths are POSIX by convention). A request with
59
64
  * no paths is a full-suite run and is never covered by a scoped list — the
@@ -68,3 +73,53 @@ export declare function findUncoveredRequestPaths(coverage: TestPackagingCoverag
68
73
  * independently (same split as {@link formatStaleBuildWarning}).
69
74
  */
70
75
  export declare function formatTestCoverageRefusal(uncovered: string[], coverage: string[]): string;
76
+ /** Result of the compiled-StaticComponents staleness probe. */
77
+ export interface StaticComponentsStaleResult {
78
+ /** True when at least one `components.conf` genuinely diverged from the anchor. */
79
+ stale: boolean;
80
+ /**
81
+ * Engine-relative `components.conf` paths changed since the last FULL
82
+ * build. Sorted; NOT capped — {@link formatStaticComponentsRefusal}
83
+ * applies the render cap.
84
+ */
85
+ changedManifests: string[];
86
+ }
87
+ /**
88
+ * Probes whether any `components.conf` changed since the last FULL
89
+ * `fireforge build` — i.e. since the compiled StaticComponents table was
90
+ * last regenerated. `components.conf` entries bake into compiled code; a
91
+ * scoped `test --build` packages the file but the child process resolves
92
+ * the OLD table and fails with `NS_ERROR_MALFORMED_URI` that reads as a
93
+ * test bug.
94
+ *
95
+ * The diff anchors to the baseline's `staticComponentsBaseline` (the last
96
+ * full build's engine HEAD SHA), NOT the baseline's own `engineHeadSha`
97
+ * which a scoped `test --build` advances. Dirty candidates are hash-checked
98
+ * against the anchor's fingerprints so only genuine content divergence
99
+ * counts. No baseline / no anchor (pre-0.38.0 marker) → fresh. Never
100
+ * throws — the probes it composes degrade to verbose lines and empty
101
+ * results on git failure, matching {@link checkStaleBuildForTest}.
102
+ */
103
+ export declare function checkStaticComponentsStale(engineDir: string, baseline: BuildBaseline | undefined): Promise<StaticComponentsStaleResult>;
104
+ /**
105
+ * Formats the refusal shown when a run would dispatch against a stale
106
+ * compiled StaticComponents table. Same probe/copy split as
107
+ * {@link formatStaleBuildWarning} so tests can pin structure and wording
108
+ * independently.
109
+ */
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,10 +27,11 @@ 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';
31
- import { isPackageablePath } from './build-audit.js';
30
+ import { verbose, warn } from '../utils/logger.js';
31
+ import { isPackageablePath, isXpcomManifestPath } from './build-audit.js';
32
32
  import { readBuildBaseline } from './build-baseline.js';
33
33
  import { collectChangedEnginePaths } from './engine-changes.js';
34
+ export { isXpcomManifestPath };
34
35
  /** Cap on the number of changed paths rendered inline. */
35
36
  const STALE_PATHS_LIMIT = 10;
36
37
  /**
@@ -125,7 +126,10 @@ export const FULL_SUITE_REQUEST = '(entire suite)';
125
126
  *
126
127
  * Coverage semantics: `undefined` (pre-0.37.0 baseline) and `'full'` cover
127
128
  * everything. A scoped list covers a request path when the request equals a
128
- * covered entry or sits beneath a covered directory entry. Both sides are
129
+ * covered entry, sits beneath a covered directory entry, or shares a
130
+ * manifest granule with a covered entry ({@link toManifestGranule} — a
131
+ * scoped `test --build` packages the whole manifest directory, so a
132
+ * same-manifest sibling of a covered file is packaged too). Both sides are
129
133
  * normalized to forward slashes so Windows-style CLI input cannot defeat
130
134
  * the prefix rule (baseline paths are POSIX by convention). A request with
131
135
  * no paths is a full-suite run and is never covered by a scoped list — the
@@ -141,9 +145,29 @@ export function findUncoveredRequestPaths(coverage, requestedPaths) {
141
145
  }
142
146
  return requestedPaths.filter((requested) => {
143
147
  const path = normalizeCoveragePath(requested);
144
- return !covered.some((c) => path === c || path.startsWith(`${c}/`));
148
+ const granule = toManifestGranule(path);
149
+ return !covered.some((c) => path === c || path.startsWith(`${c}/`) || granule === toManifestGranule(c));
145
150
  });
146
151
  }
152
+ /**
153
+ * Maps a normalized request/coverage path to the "manifest granule" the
154
+ * packaged runtime actually staged: an extension-bearing basename (a test
155
+ * FILE) maps to its containing directory, a directory (no dot in the
156
+ * basename) maps to itself. Purely lexical — the directory-as-manifest
157
+ * approximation holds because xpcshell/mochitest manifests live next to
158
+ * their test files and a scoped `test --build` stages the whole manifest
159
+ * directory into `obj-*`/`_tests/`, not single files. Caveat: a DIRECTORY
160
+ * whose basename contains a dot is misread as a file and mapped to its
161
+ * parent, which widens (never narrows) the covered granule.
162
+ */
163
+ function toManifestGranule(path) {
164
+ const slash = path.lastIndexOf('/');
165
+ const base = slash === -1 ? path : path.slice(slash + 1);
166
+ if (!base.includes('.')) {
167
+ return path;
168
+ }
169
+ return slash === -1 ? '' : path.slice(0, slash);
170
+ }
147
171
  /** Normalizes a path for coverage comparison: forward slashes, no trailing slash. */
148
172
  function normalizeCoveragePath(path) {
149
173
  return path.trim().replace(/\\/g, '/').replace(/\/+$/, '');
@@ -171,3 +195,89 @@ export function formatTestCoverageRefusal(uncovered, coverage) {
171
195
  `rather than fail. ${rebuildHint} ` +
172
196
  '(--allow-stale-build does not bypass this check — it accepts stale content, not missing coverage.)');
173
197
  }
198
+ /**
199
+ * Probes whether any `components.conf` changed since the last FULL
200
+ * `fireforge build` — i.e. since the compiled StaticComponents table was
201
+ * last regenerated. `components.conf` entries bake into compiled code; a
202
+ * scoped `test --build` packages the file but the child process resolves
203
+ * the OLD table and fails with `NS_ERROR_MALFORMED_URI` that reads as a
204
+ * test bug.
205
+ *
206
+ * The diff anchors to the baseline's `staticComponentsBaseline` (the last
207
+ * full build's engine HEAD SHA), NOT the baseline's own `engineHeadSha`
208
+ * which a scoped `test --build` advances. Dirty candidates are hash-checked
209
+ * against the anchor's fingerprints so only genuine content divergence
210
+ * counts. No baseline / no anchor (pre-0.38.0 marker) → fresh. Never
211
+ * throws — the probes it composes degrade to verbose lines and empty
212
+ * results on git failure, matching {@link checkStaleBuildForTest}.
213
+ */
214
+ export async function checkStaticComponentsStale(engineDir, baseline) {
215
+ const anchor = baseline?.staticComponentsBaseline;
216
+ if (baseline === undefined || anchor === undefined) {
217
+ return { stale: false, changedManifests: [] };
218
+ }
219
+ const changed = await collectChangedEnginePaths(engineDir, { ...baseline, engineHeadSha: anchor.engineHeadSha }, 'Static-components preflight');
220
+ const changedManifests = [];
221
+ for (const path of changed.filter((p) => isXpcomManifestPath(p))) {
222
+ const recorded = anchor.fingerprints[path];
223
+ const live = await hashEngineFile(engineDir, path);
224
+ if (recorded === undefined || live === undefined || recorded !== live) {
225
+ changedManifests.push(path);
226
+ }
227
+ }
228
+ return { stale: changedManifests.length > 0, changedManifests };
229
+ }
230
+ /**
231
+ * Formats the refusal shown when a run would dispatch against a stale
232
+ * compiled StaticComponents table. Same probe/copy split as
233
+ * {@link formatStaleBuildWarning} so tests can pin structure and wording
234
+ * independently.
235
+ */
236
+ export function formatStaticComponentsRefusal(changedManifests) {
237
+ const head = changedManifests.slice(0, STALE_PATHS_LIMIT);
238
+ const truncated = changedManifests.length - head.length;
239
+ const list = head.join(', ') + (truncated > 0 ? `, … (+${truncated} more)` : '');
240
+ return (`The compiled StaticComponents table is stale: ${list} changed since the last full "fireforge build".\n` +
241
+ 'components.conf registrations are baked into compiled code that only a FULL build ' +
242
+ 'regenerates — a scoped "fireforge test --build" repackages files but the child process ' +
243
+ 'resolves the old component table and fails with NS_ERROR_MALFORMED_URI that reads as a ' +
244
+ 'test bug. Run "fireforge build" first. (--allow-stale-build does not bypass this check — ' +
245
+ 'it accepts stale packaged content, not a stale compiled registration. Pass ' +
246
+ '--allow-stale-components only if you rebuilt out-of-band and accept the risk.)');
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
+ }
@@ -4,14 +4,14 @@ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
4
4
  import { FurnaceError } from '../errors/furnace.js';
5
5
  import { toError } from '../utils/errors.js';
6
6
  import { pathExists, readText, writeText } from '../utils/fs.js';
7
- import { warn } from '../utils/logger.js';
7
+ import { info, warn } from '../utils/logger.js';
8
8
  import { escapeRegex } from '../utils/regex.js';
9
9
  import { validateTokenName } from '../utils/validation.js';
10
10
  import { getProjectPaths, loadConfig } from './config.js';
11
11
  import { loadFurnaceConfig } from './furnace-config.js';
12
12
  import { findDarkMediaCloseIndex, findDarkRootInsertionIndex } from './token-dark-mode.js';
13
13
  import { addTokenToDocs } from './token-docs.js';
14
- import { insertVariantDeclaration, validateVariantSelector, variantBlockHasToken, } from './token-variant.js';
14
+ import { insertVariantDeclaration, validateVariantSelector, variantBlockExists, variantBlockHasToken, } from './token-variant.js';
15
15
  /** Returns the token CSS path relative to engine root for a given binary name. */
16
16
  export function getTokensCssPath(binaryName) {
17
17
  return `browser/themes/shared/${binaryName}-tokens.css`;
@@ -389,6 +389,39 @@ function findCategorySection(lines, category, tokensCssPath) {
389
389
  }
390
390
  return { categoryLine, sectionEnd };
391
391
  }
392
+ /**
393
+ * Explicit theme-attribute selectors the override path keeps in sync with
394
+ * the `@media (prefers-color-scheme: dark)` block. Consumer scaffolds that
395
+ * pair the media query with `:root[data-theme="dark"]` /
396
+ * `:root[data-theme="light"]` blocks (viewer-toggle theming) require every
397
+ * themed list to declare identical token sets, so an override add that only
398
+ * wrote the media block was a guaranteed half-finished edit (FORGE F8).
399
+ */
400
+ const THEME_ATTRIBUTE_VARIANTS = [
401
+ { variant: '[data-theme="dark"]', pick: 'dark' },
402
+ { variant: '[data-theme="light"]', pick: 'light' },
403
+ ];
404
+ /**
405
+ * Mirrors an override's light/dark values into existing top-level
406
+ * `:root[data-theme="dark"]` / `:root[data-theme="light"]` blocks. Blocks
407
+ * that do not exist are left alone (scaffolded files have none; behavior is
408
+ * unchanged there). Idempotent per block. Returns the selectors written.
409
+ */
410
+ function insertThemeAttributeOverrides(lines, options) {
411
+ if (options.mode !== 'override' || !options.darkValue)
412
+ return [];
413
+ const written = [];
414
+ for (const { variant, pick } of THEME_ATTRIBUTE_VARIANTS) {
415
+ if (!variantBlockExists(lines, variant))
416
+ continue;
417
+ if (variantBlockHasToken(lines, variant, options.tokenName))
418
+ continue;
419
+ const value = pick === 'dark' ? options.darkValue : options.value;
420
+ insertVariantDeclaration(lines, variant, ` ${options.tokenName}: ${value};`);
421
+ written.push(`:root${variant}`);
422
+ }
423
+ return written;
424
+ }
392
425
  function insertDarkModeOverride(lines, options) {
393
426
  if (options.mode !== 'override' || !options.darkValue)
394
427
  return;
@@ -451,6 +484,10 @@ async function addTokenToCSS(engineDir, options, tokensCssPath) {
451
484
  }
452
485
  lines.splice(insertIndex, 0, ...insertLines);
453
486
  insertDarkModeOverride(lines, options);
487
+ const themeBlocksWritten = insertThemeAttributeOverrides(lines, options);
488
+ if (themeBlocksWritten.length > 0) {
489
+ info(`Override also written to ${themeBlocksWritten.join(' and ')}.`);
490
+ }
454
491
  content = lines.join('\n');
455
492
  await writeText(filePath, content);
456
493
  return { added: true, categoryCreated };
@@ -29,6 +29,13 @@ export type VariantValidation = {
29
29
  * @param raw - Raw `--variant` value from the CLI / programmatic caller.
30
30
  */
31
31
  export declare function validateVariantSelector(raw: unknown): VariantValidation;
32
+ /**
33
+ * True when a top-level `:root<variant>` block exists in `lines`. Used by
34
+ * the override path to write theme-attribute blocks only when the scaffold
35
+ * already declares them — `insertVariantDeclaration` would otherwise create
36
+ * the block, which is only wanted for the explicit `--variant` flow.
37
+ */
38
+ export declare function variantBlockExists(lines: string[], variant: string): boolean;
32
39
  /** True when the `:root<variant>` block already declares `tokenName`. */
33
40
  export declare function variantBlockHasToken(lines: string[], variant: string, tokenName: string): boolean;
34
41
  /**