@hominis/fireforge 0.37.0 → 0.38.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 (58) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +1 -1
  3. package/dist/src/commands/build.js +5 -5
  4. package/dist/src/commands/config.js +1 -0
  5. package/dist/src/commands/download.js +5 -2
  6. package/dist/src/commands/export-flow.d.ts +5 -1
  7. package/dist/src/commands/export-flow.js +10 -7
  8. package/dist/src/commands/export-placement-gate.js +3 -5
  9. package/dist/src/commands/export-placement-policy.d.ts +11 -3
  10. package/dist/src/commands/export-placement-policy.js +32 -29
  11. package/dist/src/commands/export.js +5 -5
  12. package/dist/src/commands/furnace/index.js +23 -18
  13. package/dist/src/commands/patch/move-files-create.d.ts +19 -0
  14. package/dist/src/commands/patch/move-files-create.js +116 -0
  15. package/dist/src/commands/patch/move-files.d.ts +3 -1
  16. package/dist/src/commands/patch/move-files.js +49 -4
  17. package/dist/src/commands/patch/split.d.ts +11 -1
  18. package/dist/src/commands/patch/split.js +6 -5
  19. package/dist/src/commands/re-export-scan.d.ts +17 -0
  20. package/dist/src/commands/re-export-scan.js +51 -0
  21. package/dist/src/commands/re-export.js +17 -6
  22. package/dist/src/commands/source.js +28 -3
  23. package/dist/src/commands/test-register.js +6 -5
  24. package/dist/src/commands/test-stale-gate.d.ts +44 -0
  25. package/dist/src/commands/test-stale-gate.js +81 -0
  26. package/dist/src/commands/test.js +15 -50
  27. package/dist/src/core/build-audit.d.ts +12 -0
  28. package/dist/src/core/build-audit.js +14 -0
  29. package/dist/src/core/build-baseline-types.d.ts +34 -0
  30. package/dist/src/core/build-baseline.d.ts +6 -1
  31. package/dist/src/core/build-baseline.js +47 -7
  32. package/dist/src/core/config-paths.d.ts +1 -1
  33. package/dist/src/core/config-paths.js +1 -0
  34. package/dist/src/core/config-validate.js +6 -1
  35. package/dist/src/core/engine-session-lock.d.ts +10 -1
  36. package/dist/src/core/engine-session-lock.js +28 -2
  37. package/dist/src/core/file-lock.d.ts +33 -0
  38. package/dist/src/core/file-lock.js +32 -6
  39. package/dist/src/core/firefox-archive.d.ts +2 -1
  40. package/dist/src/core/firefox-archive.js +32 -8
  41. package/dist/src/core/firefox.d.ts +8 -3
  42. package/dist/src/core/firefox.js +11 -6
  43. package/dist/src/core/furnace-validate-helpers.js +28 -1
  44. package/dist/src/core/license-headers.d.ts +12 -0
  45. package/dist/src/core/license-headers.js +76 -1
  46. package/dist/src/core/patch-lint.d.ts +3 -0
  47. package/dist/src/core/patch-lint.js +25 -7
  48. package/dist/src/core/status-classify.d.ts +11 -2
  49. package/dist/src/core/status-classify.js +49 -25
  50. package/dist/src/core/test-stale-check.d.ts +41 -1
  51. package/dist/src/core/test-stale-check.js +77 -3
  52. package/dist/src/types/commands/options.d.ts +57 -3
  53. package/dist/src/types/config.d.ts +7 -0
  54. package/dist/src/utils/options.d.ts +20 -0
  55. package/dist/src/utils/options.js +39 -0
  56. package/dist/src/utils/validation.d.ts +5 -0
  57. package/dist/src/utils/validation.js +7 -0
  58. package/package.json +3 -3
@@ -13,16 +13,18 @@ export { formatBytes, getFirefoxVersion } from './firefox-extract.js';
13
13
  * Gets the download URL for a Firefox source tarball.
14
14
  * @param version - Firefox version (e.g., "140.9.0esr")
15
15
  * @param product - Firefox product type
16
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
16
17
  * @returns Full URL to the source tarball
17
18
  */
18
- export declare function getDownloadUrl(version: string, product?: FirefoxProduct): string;
19
+ export declare function getDownloadUrl(version: string, product?: FirefoxProduct, candidate?: string): string;
19
20
  /**
20
21
  * Gets the filename for a Firefox source tarball.
21
22
  * @param version - Firefox version
22
23
  * @param product - Firefox product type
24
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
23
25
  * @returns Tarball filename
24
26
  */
25
- export declare function getTarballFilename(version: string, product?: FirefoxProduct): string;
27
+ export declare function getTarballFilename(version: string, product?: FirefoxProduct, candidate?: string): string;
26
28
  /**
27
29
  * Lifecycle phase reported by {@link downloadFirefoxSource}. The download
28
30
  * CLI command uses this to swap spinners between the bytes-on-the-wire
@@ -57,5 +59,8 @@ export declare function sweepOrphanedEngineWorkDirs(destDir: string): Promise<st
57
59
  * @param onPhase - Optional callback fired when the function transitions
58
60
  * between phases (`'download'` → `'extract'`). Fires exactly once per
59
61
  * phase even if the cached archive path skips the wire entirely.
62
+ * @param candidate - Optional release-candidate build directory (e.g.
63
+ * "build2") resolving the archive under `candidates/` instead of
64
+ * `releases/`.
60
65
  */
61
- export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback, expectedSha256?: string, onPhaseProgress?: FirefoxSourceProgressCallback): Promise<void>;
66
+ export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback, expectedSha256?: string, onPhaseProgress?: FirefoxSourceProgressCallback, candidate?: string): Promise<void>;
@@ -19,19 +19,21 @@ export { formatBytes, getFirefoxVersion } from './firefox-extract.js';
19
19
  * Gets the download URL for a Firefox source tarball.
20
20
  * @param version - Firefox version (e.g., "140.9.0esr")
21
21
  * @param product - Firefox product type
22
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
22
23
  * @returns Full URL to the source tarball
23
24
  */
24
- export function getDownloadUrl(version, product = 'firefox') {
25
- return resolveArchive(version, product).url;
25
+ export function getDownloadUrl(version, product = 'firefox', candidate) {
26
+ return resolveArchive(version, product, candidate).url;
26
27
  }
27
28
  /**
28
29
  * Gets the filename for a Firefox source tarball.
29
30
  * @param version - Firefox version
30
31
  * @param product - Firefox product type
32
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
31
33
  * @returns Tarball filename
32
34
  */
33
- export function getTarballFilename(version, product = 'firefox') {
34
- return resolveArchive(version, product).filename;
35
+ export function getTarballFilename(version, product = 'firefox', candidate) {
36
+ return resolveArchive(version, product, candidate).filename;
35
37
  }
36
38
  /**
37
39
  * Classifies an extraction failure as archive corruption (invalidate the
@@ -100,9 +102,12 @@ export async function sweepOrphanedEngineWorkDirs(destDir) {
100
102
  * @param onPhase - Optional callback fired when the function transitions
101
103
  * between phases (`'download'` → `'extract'`). Fires exactly once per
102
104
  * phase even if the cached archive path skips the wire entirely.
105
+ * @param candidate - Optional release-candidate build directory (e.g.
106
+ * "build2") resolving the archive under `candidates/` instead of
107
+ * `releases/`.
103
108
  */
104
- export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase, expectedSha256, onPhaseProgress) {
105
- const archive = resolveArchive(version, product);
109
+ export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase, expectedSha256, onPhaseProgress, candidate) {
110
+ const archive = resolveArchive(version, product, candidate);
106
111
  const tarballPath = join(cacheDir, archive.filename);
107
112
  // Ensure cache directory exists
108
113
  await ensureDir(cacheDir);
@@ -27,10 +27,33 @@ export function hasPositiveTabindex(content) {
27
27
  return value !== undefined && parseInt(value, 10) > 0;
28
28
  });
29
29
  }
30
+ /**
31
+ * Collects `[start, end)` spans of `<label>…</label>` elements whose content
32
+ * includes actual label text. Labels cannot nest per HTML, so the non-greedy
33
+ * inner match is sound. A span qualifies only when the inner content minus
34
+ * tags still contains non-whitespace — an empty or tag-only wrapper provides
35
+ * no accessible name. A `${…}` Lit binding counts as label text: dynamic
36
+ * (usually localized) text is a legitimate accessible name.
37
+ */
38
+ function collectLabelledSpans(content) {
39
+ const spans = [];
40
+ const labelPattern = /<label\b[^>]*>([\s\S]*?)<\/label>/gi;
41
+ let labelMatch;
42
+ while ((labelMatch = labelPattern.exec(content)) !== null) {
43
+ const inner = labelMatch[1] ?? '';
44
+ if (/\S/.test(inner.replace(/<[^>]*>/g, ' '))) {
45
+ spans.push({ start: labelMatch.index, end: labelMatch.index + labelMatch[0].length });
46
+ }
47
+ }
48
+ return spans;
49
+ }
30
50
  /** Detects form inputs without associated labels. */
31
51
  export function hasUnlabelledFormInput(content) {
32
52
  // Look for <input> or <select> or <textarea> without aria-label, aria-labelledby, or id
33
- // (id implies an external <label for="..."> could exist)
53
+ // (id implies an external <label for="..."> could exist). A control wrapped
54
+ // in a <label> that carries actual text is implicitly associated and is
55
+ // exempt as well.
56
+ const labelledSpans = collectLabelledSpans(content);
34
57
  const inputPattern = /<(input|select|textarea)\b([^>]*)>/gi;
35
58
  let inputMatch;
36
59
  while ((inputMatch = inputPattern.exec(content)) !== null) {
@@ -41,6 +64,10 @@ export function hasUnlabelledFormInput(content) {
41
64
  /type\s*=\s*["']hidden["']/i.test(attrs)) {
42
65
  continue;
43
66
  }
67
+ const index = inputMatch.index;
68
+ if (labelledSpans.some((span) => index >= span.start && index < span.end)) {
69
+ continue;
70
+ }
44
71
  return true;
45
72
  }
46
73
  return false;
@@ -34,6 +34,11 @@ export declare function getLicenseHeader(license: ProjectLicense, style: Comment
34
34
  * on lines 1–2 with the MPL header on lines 3+, which the raw
35
35
  * `startsWith` check would otherwise miss.
36
36
  *
37
+ * The MPL-2.0 header is additionally matched on normalized whitespace
38
+ * (see {@link normalizeCommentHead}) so upstream files using the older
39
+ * Mozilla wrap — breaking after "file," instead of "with this" — are
40
+ * accepted too; only the wrap position differs, never the wording.
41
+ *
37
42
  * @param content - File content to check
38
43
  * @param style - Comment syntax of the file
39
44
  */
@@ -51,6 +56,13 @@ export declare function hasAnyLicenseHeader(content: string, style: CommentStyle
51
56
  * patch-lint, making it dead code for every other project license and
52
57
  * forcing repo-side audit workarounds.
53
58
  *
59
+ * Matching is wrap-agnostic: after the exact `startsWith` fast path, the
60
+ * leading block comment is compared on normalized whitespace (see
61
+ * {@link normalizeCommentHead}) so the older upstream wrap — breaking
62
+ * after "file," instead of "with this", as `ext-browser.js` ships —
63
+ * passes too. Only the line-break position may differ; altered wording
64
+ * still rejects.
65
+ *
54
66
  * @param content - File content to check
55
67
  */
56
68
  export declare function hasUpstreamMplBlockHeader(content: string): boolean;
@@ -86,6 +86,59 @@ function stripLeadingEditorDirectives(content) {
86
86
  } while (result !== prev);
87
87
  return result;
88
88
  }
89
+ /**
90
+ * Collapses all whitespace runs to single spaces and trims, so header
91
+ * comparisons ignore the exact line-break/wrap positions.
92
+ */
93
+ function normalizeText(text) {
94
+ return text.replace(/\s+/g, ' ').trim();
95
+ }
96
+ /**
97
+ * The canonical upstream MPL-2.0 notice as a single normalized sentence
98
+ * (including the `http://mozilla.org/MPL/2.0/.` URL). Wrap-agnostic
99
+ * header checks match against this instead of an exact rendering.
100
+ */
101
+ const MPL_HEADER_NORMALIZED = normalizeText(HEADER_LINES['MPL-2.0'].join(' '));
102
+ /**
103
+ * Extracts ONLY the leading comment span of `content` for the given
104
+ * comment style and returns it whitespace-normalized:
105
+ *
106
+ * - `js`: a leading run of `//` lines (markers stripped), or — when the
107
+ * file opens with a block comment instead — the first
108
+ * `/* … *\/` block;
109
+ * - `css`: the first `/* … *\/` block, with the ` * ` continuation-line
110
+ * prefixes stripped (same collapsing
111
+ * {@link normalizeLicenseHeadForScan} does);
112
+ * - `hash`: a leading run of `#` lines (markers stripped).
113
+ *
114
+ * A non-comment first line yields `''`. This lets callers accept a known
115
+ * header text regardless of where upstream wrapped its lines (e.g. the
116
+ * older Mozilla wrap that breaks after "file,"), without ever matching
117
+ * text beyond the leading comment.
118
+ */
119
+ function normalizeCommentHead(content, style) {
120
+ const src = content.replace(/\r\n?/g, '\n');
121
+ const collectLineRun = (marker) => {
122
+ const parts = [];
123
+ for (const line of src.split('\n')) {
124
+ const m = marker.exec(line);
125
+ if (!m)
126
+ break;
127
+ parts.push(m[1] ?? '');
128
+ }
129
+ return normalizeText(parts.join(' '));
130
+ };
131
+ if (style === 'hash') {
132
+ return collectLineRun(/^[ \t]*#[ \t]?(.*)$/);
133
+ }
134
+ if (style === 'js' && /^[ \t]*\/\//.test(src)) {
135
+ return collectLineRun(/^[ \t]*\/\/[ \t]?(.*)$/);
136
+ }
137
+ const block = /^[ \t]*\/\*([\s\S]*?)\*\//.exec(src);
138
+ if (!block)
139
+ return '';
140
+ return normalizeText((block[1] ?? '').replace(/\n[ \t]*\*[ \t]*/g, ' '));
141
+ }
89
142
  /**
90
143
  * Returns true if `content` starts with any known license header for the
91
144
  * given comment style.
@@ -103,6 +156,11 @@ function stripLeadingEditorDirectives(content) {
103
156
  * on lines 1–2 with the MPL header on lines 3+, which the raw
104
157
  * `startsWith` check would otherwise miss.
105
158
  *
159
+ * The MPL-2.0 header is additionally matched on normalized whitespace
160
+ * (see {@link normalizeCommentHead}) so upstream files using the older
161
+ * Mozilla wrap — breaking after "file," instead of "with this" — are
162
+ * accepted too; only the wrap position differs, never the wording.
163
+ *
106
164
  * @param content - File content to check
107
165
  * @param style - Comment syntax of the file
108
166
  */
@@ -116,6 +174,9 @@ export function hasAnyLicenseHeader(content, style) {
116
174
  if (style === 'js' && candidate.startsWith(getLicenseHeader('MPL-2.0', 'css'))) {
117
175
  return true;
118
176
  }
177
+ if (normalizeCommentHead(candidate, style).startsWith(MPL_HEADER_NORMALIZED)) {
178
+ return true;
179
+ }
119
180
  }
120
181
  return false;
121
182
  }
@@ -132,11 +193,25 @@ export function hasAnyLicenseHeader(content, style) {
132
193
  * patch-lint, making it dead code for every other project license and
133
194
  * forcing repo-side audit workarounds.
134
195
  *
196
+ * Matching is wrap-agnostic: after the exact `startsWith` fast path, the
197
+ * leading block comment is compared on normalized whitespace (see
198
+ * {@link normalizeCommentHead}) so the older upstream wrap — breaking
199
+ * after "file," instead of "with this", as `ext-browser.js` ships —
200
+ * passes too. Only the line-break position may differ; altered wording
201
+ * still rejects.
202
+ *
135
203
  * @param content - File content to check
136
204
  */
137
205
  export function hasUpstreamMplBlockHeader(content) {
138
206
  const blockHeader = getLicenseHeader('MPL-2.0', 'css');
139
- return (content.startsWith(blockHeader) || stripLeadingEditorDirectives(content).startsWith(blockHeader));
207
+ const candidates = [content, stripLeadingEditorDirectives(content)];
208
+ for (const candidate of candidates) {
209
+ if (candidate.startsWith(blockHeader))
210
+ return true;
211
+ if (normalizeCommentHead(candidate, 'css').startsWith(MPL_HEADER_NORMALIZED))
212
+ return true;
213
+ }
214
+ return false;
140
215
  }
141
216
  /**
142
217
  * Returns true if `content` starts with any known license header in any
@@ -8,6 +8,9 @@ export { lintPatchedCss };
8
8
  * Counts the total lines in a unified diff and the number of non-binary
9
9
  * text lines, so binary hunks do not inflate patch size checks.
10
10
  *
11
+ * Line counting agrees with `wc -l`: a trailing newline does not add a
12
+ * phantom empty line to either `total` or the binary-hunk accounting.
13
+ *
11
14
  * @param diffContent - Raw unified diff string
12
15
  * @returns Object with `total` line count and `textLines` (total minus binary hunk lines)
13
16
  */
@@ -35,15 +35,33 @@ const FILE_SIZE_THRESHOLDS = {
35
35
  general: { notice: 500, warning: 750, error: 900 },
36
36
  test: { notice: 1200, warning: 1400, error: 1600 },
37
37
  };
38
+ /**
39
+ * Counts the lines of `content` the way `wc -l` reports them: a trailing
40
+ * newline terminates the last line rather than starting an empty extra
41
+ * one. A naive `split('\n').length` over-counts by one for any content
42
+ * ending in `\n` (the common case), which made the size rules fire one
43
+ * line early against the operator's own `wc -l` measurement.
44
+ */
45
+ function countContentLines(content) {
46
+ const lines = content.split('\n');
47
+ if (lines[lines.length - 1] === '')
48
+ lines.pop();
49
+ return lines.length;
50
+ }
38
51
  /**
39
52
  * Counts the total lines in a unified diff and the number of non-binary
40
53
  * text lines, so binary hunks do not inflate patch size checks.
41
54
  *
55
+ * Line counting agrees with `wc -l`: a trailing newline does not add a
56
+ * phantom empty line to either `total` or the binary-hunk accounting.
57
+ *
42
58
  * @param diffContent - Raw unified diff string
43
59
  * @returns Object with `total` line count and `textLines` (total minus binary hunk lines)
44
60
  */
45
61
  export function countNonBinaryDiffLines(diffContent) {
46
62
  const lines = diffContent.split('\n');
63
+ if (lines[lines.length - 1] === '')
64
+ lines.pop();
47
65
  const total = lines.length;
48
66
  let binaryLines = 0;
49
67
  let inBinaryHunk = false;
@@ -329,12 +347,12 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
329
347
  }
330
348
  // 2. File size check (new files only)
331
349
  if (isNew) {
332
- const lineCount = content.split('\n').length;
350
+ const lineCount = countContentLines(content);
333
351
  const isTest = isTestFile(file);
334
352
  const thresholds = isTest ? FILE_SIZE_THRESHOLDS.test : FILE_SIZE_THRESHOLDS.general;
335
353
  const label = isTest ? 'Test file' : 'New file';
336
354
  const verb = isTest ? 'splitting' : 'decomposing';
337
- if (lineCount >= thresholds.error) {
355
+ if (lineCount > thresholds.error) {
338
356
  issues.push({
339
357
  file,
340
358
  check: 'file-too-large',
@@ -342,7 +360,7 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
342
360
  severity: 'error',
343
361
  });
344
362
  }
345
- else if (lineCount >= thresholds.warning) {
363
+ else if (lineCount > thresholds.warning) {
346
364
  issues.push({
347
365
  file,
348
366
  check: 'file-too-large',
@@ -350,7 +368,7 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
350
368
  severity: 'warning',
351
369
  });
352
370
  }
353
- else if (lineCount >= thresholds.notice) {
371
+ else if (lineCount > thresholds.notice) {
354
372
  issues.push({
355
373
  file,
356
374
  check: 'file-too-large',
@@ -507,7 +525,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
507
525
  severity: 'warning',
508
526
  });
509
527
  }
510
- if (lineCount >= lineThresholds.error) {
528
+ if (lineCount > lineThresholds.error) {
511
529
  issues.push({
512
530
  file: AGGREGATE_PATCH_FILE,
513
531
  check: 'large-patch-lines',
@@ -515,7 +533,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
515
533
  severity: 'error',
516
534
  });
517
535
  }
518
- else if (lineCount >= lineThresholds.warning) {
536
+ else if (lineCount > lineThresholds.warning) {
519
537
  issues.push({
520
538
  file: AGGREGATE_PATCH_FILE,
521
539
  check: 'large-patch-lines',
@@ -523,7 +541,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
523
541
  severity: 'warning',
524
542
  });
525
543
  }
526
- else if (lineCount >= lineThresholds.notice) {
544
+ else if (lineCount > lineThresholds.notice) {
527
545
  issues.push({
528
546
  file: AGGREGATE_PATCH_FILE,
529
547
  check: 'large-patch-lines',
@@ -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
  }
@@ -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,38 @@ 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;
@@ -28,9 +28,10 @@ import { readFile } from 'node:fs/promises';
28
28
  import { join } from 'node:path';
29
29
  import { toError } from '../utils/errors.js';
30
30
  import { verbose } from '../utils/logger.js';
31
- import { isPackageablePath } from './build-audit.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,53 @@ 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
+ }