@hominis/fireforge 0.32.0 → 0.34.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 (81) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/src/commands/build.js +9 -1
  3. package/dist/src/commands/doctor-furnace-jar.d.ts +16 -0
  4. package/dist/src/commands/doctor-furnace-jar.js +47 -0
  5. package/dist/src/commands/doctor-furnace.js +2 -0
  6. package/dist/src/commands/export-flow.js +4 -1
  7. package/dist/src/commands/export-placement-gate.js +4 -1
  8. package/dist/src/commands/export.js +61 -4
  9. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +20 -0
  10. package/dist/src/commands/furnace/chrome-doc-templates.js +52 -0
  11. package/dist/src/commands/furnace/chrome-doc.d.ts +10 -0
  12. package/dist/src/commands/furnace/chrome-doc.js +31 -4
  13. package/dist/src/commands/furnace/create-browser-test.d.ts +30 -0
  14. package/dist/src/commands/furnace/create-browser-test.js +180 -0
  15. package/dist/src/commands/furnace/create-mochikit.js +11 -4
  16. package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
  17. package/dist/src/commands/furnace/create-xpcshell.js +38 -12
  18. package/dist/src/commands/furnace/create.js +7 -114
  19. package/dist/src/commands/furnace/index.js +6 -1
  20. package/dist/src/commands/furnace/scan.d.ts +1 -0
  21. package/dist/src/commands/furnace/scan.js +57 -27
  22. package/dist/src/commands/furnace/validate.js +17 -1
  23. package/dist/src/commands/patch/split-plan.d.ts +18 -2
  24. package/dist/src/commands/patch/split-plan.js +90 -16
  25. package/dist/src/commands/patch/split.js +12 -3
  26. package/dist/src/commands/re-export-options.d.ts +26 -0
  27. package/dist/src/commands/re-export-options.js +48 -1
  28. package/dist/src/commands/re-export.js +4 -1
  29. package/dist/src/commands/register.js +10 -1
  30. package/dist/src/commands/test-diagnose.js +12 -0
  31. package/dist/src/commands/test.js +29 -22
  32. package/dist/src/commands/token.js +12 -1
  33. package/dist/src/commands/typecheck.js +35 -0
  34. package/dist/src/core/build-prepare.js +23 -3
  35. package/dist/src/core/config-validate.js +26 -0
  36. package/dist/src/core/furnace-apply-dry-run.d.ts +17 -0
  37. package/dist/src/core/furnace-apply-dry-run.js +105 -0
  38. package/dist/src/core/furnace-apply-ftl.d.ts +12 -0
  39. package/dist/src/core/furnace-apply-ftl.js +97 -1
  40. package/dist/src/core/furnace-apply-helpers.js +10 -80
  41. package/dist/src/core/furnace-registration.d.ts +24 -0
  42. package/dist/src/core/furnace-registration.js +56 -3
  43. package/dist/src/core/furnace-validate-registration.js +18 -0
  44. package/dist/src/core/mach-resource-shim.d.ts +58 -0
  45. package/dist/src/core/mach-resource-shim.js +290 -0
  46. package/dist/src/core/mach.d.ts +51 -10
  47. package/dist/src/core/mach.js +76 -25
  48. package/dist/src/core/manifest-helpers.d.ts +13 -0
  49. package/dist/src/core/manifest-helpers.js +29 -4
  50. package/dist/src/core/manifest-rules.d.ts +13 -3
  51. package/dist/src/core/manifest-rules.js +44 -17
  52. package/dist/src/core/patch-export.d.ts +10 -0
  53. package/dist/src/core/patch-export.js +23 -2
  54. package/dist/src/core/patch-lint-cross.d.ts +31 -0
  55. package/dist/src/core/patch-lint-cross.js +83 -63
  56. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  57. package/dist/src/core/patch-lint-reexports.js +1 -1
  58. package/dist/src/core/register-module.d.ts +1 -1
  59. package/dist/src/core/register-module.js +15 -2
  60. package/dist/src/core/register-result.d.ts +9 -0
  61. package/dist/src/core/register-scaffold.d.ts +55 -0
  62. package/dist/src/core/register-scaffold.js +146 -0
  63. package/dist/src/core/register-test-manifest.js +3 -1
  64. package/dist/src/core/register-xpcshell-test.d.ts +30 -0
  65. package/dist/src/core/register-xpcshell-test.js +96 -0
  66. package/dist/src/core/test-harness-crash.d.ts +32 -4
  67. package/dist/src/core/test-harness-crash.js +152 -14
  68. package/dist/src/core/token-dark-mode.d.ts +9 -0
  69. package/dist/src/core/token-dark-mode.js +1 -1
  70. package/dist/src/core/token-docs.d.ts +32 -0
  71. package/dist/src/core/token-docs.js +101 -0
  72. package/dist/src/core/token-manager.d.ts +8 -0
  73. package/dist/src/core/token-manager.js +77 -95
  74. package/dist/src/core/token-variant.d.ts +39 -0
  75. package/dist/src/core/token-variant.js +141 -0
  76. package/dist/src/core/typecheck.js +56 -28
  77. package/dist/src/core/xpcshell-appdir.d.ts +7 -0
  78. package/dist/src/core/xpcshell-appdir.js +12 -3
  79. package/dist/src/types/commands/options.d.ts +17 -0
  80. package/dist/src/types/config.d.ts +13 -0
  81. package/package.json +4 -4
@@ -401,6 +401,32 @@ function parseTypecheckBlock(rec) {
401
401
  if (extraShim !== undefined) {
402
402
  out.extraShim = parseShimPath(extraShim, 'typecheck.extraShim');
403
403
  }
404
+ const overrides = parseTypecheckProjectOverrides(rec.raw('projectOverrides'), projects);
405
+ if (overrides) {
406
+ out.projectOverrides = overrides;
407
+ }
408
+ return out;
409
+ }
410
+ /**
411
+ * Validates the optional `typecheck.projectOverrides` map: keys must name a
412
+ * declared project, values must be either `null` (opt out of the shared extra
413
+ * shim) or a contained relative `.d.ts` path (per-project override). Returns
414
+ * `undefined` when the field is absent.
415
+ */
416
+ function parseTypecheckProjectOverrides(raw, projects) {
417
+ if (raw === undefined)
418
+ return undefined;
419
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
420
+ throw new ConfigError('Config field "typecheck.projectOverrides" must be an object');
421
+ }
422
+ const known = new Set(projects);
423
+ const out = {};
424
+ for (const [key, value] of Object.entries(raw)) {
425
+ if (!known.has(key)) {
426
+ throw new ConfigError(`Config field "typecheck.projectOverrides" key "${key}" does not match any entry in "typecheck.projects"`);
427
+ }
428
+ out[key] = value === null ? null : parseShimPath(value, `typecheck.projectOverrides["${key}"]`);
429
+ }
404
430
  return out;
405
431
  }
406
432
  //# sourceMappingURL=config-validate.js.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Dry-run action planning for custom-component apply. Extracted from
3
+ * `furnace-apply-helpers.ts` so the apply path and its dry-run mirror each
4
+ * stay within the per-file line budget. Consumed only by that module.
5
+ */
6
+ import type { CustomComponentConfig, DryRunAction, StepError } from '../types/furnace.js';
7
+ interface DirectoryEntry {
8
+ isFile(): boolean;
9
+ isSymbolicLink?(): boolean;
10
+ name: string;
11
+ }
12
+ /** Computes the planned dry-run actions (and pre-flight step errors) for a custom component. */
13
+ export declare function buildCustomDryRunActions(name: string, componentDir: string, engineDir: string, config: CustomComponentConfig, targetDir: string, entries: DirectoryEntry[], ftlDir: string): Promise<{
14
+ actions: DryRunAction[];
15
+ stepErrors: StepError[];
16
+ }>;
17
+ export {};
@@ -0,0 +1,105 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Dry-run action planning for custom-component apply. Extracted from
4
+ * `furnace-apply-helpers.ts` so the apply path and its dry-run mirror each
5
+ * stay within the per-file line budget. Consumed only by that module.
6
+ */
7
+ import { join } from 'node:path';
8
+ import { toError } from '../utils/errors.js';
9
+ import { pathExists } from '../utils/fs.js';
10
+ import { describeLocaleFtlJarMnRegistration, describeSharedFtlPrune } from './furnace-apply-ftl.js';
11
+ import { describeFragmentExpansion } from './furnace-css-fragments.js';
12
+ import { validateCustomElementRegistration, validateJarMnInsertionForFiles, } from './furnace-registration.js';
13
+ function isRegularFile(entry) {
14
+ if (!entry.isFile())
15
+ return false;
16
+ if (typeof entry.isSymbolicLink === 'function' && entry.isSymbolicLink())
17
+ return false;
18
+ return true;
19
+ }
20
+ /** Computes the planned dry-run actions (and pre-flight step errors) for a custom component. */
21
+ export async function buildCustomDryRunActions(name, componentDir, engineDir, config, targetDir, entries, ftlDir) {
22
+ const actions = [];
23
+ const stepErrors = [];
24
+ for (const entry of entries) {
25
+ if (!isRegularFile(entry))
26
+ continue;
27
+ if (!entry.name.endsWith('.mjs') && !entry.name.endsWith('.css'))
28
+ continue;
29
+ const fragmentNote = await describeFragmentExpansion(join(componentDir, entry.name));
30
+ actions.push({
31
+ component: name,
32
+ action: fragmentNote ? 'expand-fragments' : 'copy',
33
+ source: join(componentDir, entry.name),
34
+ target: join(targetDir, entry.name),
35
+ description: `Copy ${entry.name} to ${config.targetPath}${fragmentNote}`,
36
+ });
37
+ }
38
+ // Per-component .ftl handling is skipped when the component opts into a
39
+ // shared feature-scoped bundle via `sharedFtl`. The shared file is
40
+ // registered (and copied) by whoever owns the feature bundle, so
41
+ // emitting a copy-ftl / register-jar action here would duplicate (or
42
+ // later orphan) the entry.
43
+ if (config.localized && !config.sharedFtl) {
44
+ const ftlFile = `${name}.ftl`;
45
+ const ftlSrc = join(componentDir, ftlFile);
46
+ if (await pathExists(ftlSrc)) {
47
+ actions.push({
48
+ component: name,
49
+ action: 'copy-ftl',
50
+ source: ftlSrc,
51
+ target: join(engineDir, ftlDir, ftlFile),
52
+ description: `Copy ${ftlFile} to ${ftlDir}`,
53
+ });
54
+ const localeAction = describeLocaleFtlJarMnRegistration(name, ftlDir, ftlFile);
55
+ if (localeAction) {
56
+ actions.push(localeAction);
57
+ }
58
+ }
59
+ }
60
+ // A sharedFtl widget owns its strings via the shared bundle; surface the
61
+ // removal of any dangling per-widget locale jar.mn entry so the dry-run
62
+ // plan matches what apply will do (and explains the unblocked build).
63
+ const pruneAction = await describeSharedFtlPrune(engineDir, name, ftlDir, config);
64
+ if (pruneAction) {
65
+ actions.push(pruneAction);
66
+ }
67
+ if (config.register) {
68
+ try {
69
+ const modulePath = `chrome://global/content/elements/${name}.mjs`;
70
+ await validateCustomElementRegistration(engineDir, name, modulePath);
71
+ }
72
+ catch (error) {
73
+ stepErrors.push({
74
+ step: 'customElements.js registration',
75
+ error: toError(error).message,
76
+ });
77
+ }
78
+ actions.push({
79
+ component: name,
80
+ action: 'register-ce',
81
+ description: `Register ${name} in customElements.js (DOMContentLoaded block)`,
82
+ });
83
+ }
84
+ const copiedFileNames = entries
85
+ .filter((entry) => isRegularFile(entry) && (entry.name.endsWith('.mjs') || entry.name.endsWith('.css')))
86
+ .map((entry) => entry.name);
87
+ if (copiedFileNames.length > 0) {
88
+ try {
89
+ await validateJarMnInsertionForFiles(engineDir, name, copiedFileNames);
90
+ }
91
+ catch (error) {
92
+ stepErrors.push({
93
+ step: 'jar.mn registration',
94
+ error: toError(error).message,
95
+ });
96
+ }
97
+ actions.push({
98
+ component: name,
99
+ action: 'register-jar',
100
+ description: `Add ${copiedFileNames.join(', ')} to jar.mn`,
101
+ });
102
+ }
103
+ return { actions, stepErrors };
104
+ }
105
+ //# sourceMappingURL=furnace-apply-dry-run.js.map
@@ -10,6 +10,18 @@
10
10
  */
11
11
  import type { CustomComponentConfig, DryRunAction, StepError } from '../types/furnace.js';
12
12
  import { type RollbackJournal } from './furnace-rollback.js';
13
+ /**
14
+ * Apply-path wrapper around {@link pruneSharedFtlPerWidgetLocaleEntry} that
15
+ * records the affected path / step error in the caller's collectors, mirroring
16
+ * {@link applyCustomFtlFile}'s contract so the main apply helper stays terse.
17
+ */
18
+ export declare function applySharedFtlPrune(engineDir: string, name: string, ftlDir: string, config: CustomComponentConfig, affectedPaths: string[], stepErrors: StepError[], rollbackJournal?: RollbackJournal): Promise<void>;
19
+ /**
20
+ * Read-only dry-run describer for {@link pruneSharedFtlPerWidgetLocaleEntry}:
21
+ * returns an action when a dangling per-widget locale entry exists for a
22
+ * `sharedFtl` widget, else `undefined`.
23
+ */
24
+ export declare function describeSharedFtlPrune(engineDir: string, name: string, ftlDir: string, config: CustomComponentConfig): Promise<DryRunAction | undefined>;
13
25
  /**
14
26
  * Copies a component's `.ftl` into the FTL tree and registers the chrome URI
15
27
  * in the locale jar.mn.
@@ -11,10 +11,106 @@
11
11
  */
12
12
  import { join, relative } from 'node:path';
13
13
  import { toError } from '../utils/errors.js';
14
- import { copyFile, pathExists } from '../utils/fs.js';
14
+ import { copyFile, pathExists, readText } from '../utils/fs.js';
15
+ import { escapeRegex } from '../utils/regex.js';
15
16
  import { resolveFtlChromeSubPath, resolveFtlLocaleJarMnPath } from './furnace-constants.js';
16
17
  import { addLocaleFtlJarMnEntry, removeLocaleFtlJarMnEntry } from './furnace-registration.js';
17
18
  import { snapshotFile } from './furnace-rollback.js';
19
+ /**
20
+ * Builds the presence regex for a per-widget locale jar.mn line
21
+ * (`locale/@AB_CD@/<chromeSubPath>/<tagName>.ftl`). Shared by the prune
22
+ * helper and its dry-run describer so both agree on what "dangling" means.
23
+ */
24
+ function perWidgetLocaleEntryPattern(chromeSubPath, tagName) {
25
+ return new RegExp(`locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapeRegex(chromeSubPath)}\\/${escapeRegex(tagName)}\\.ftl`, 'm');
26
+ }
27
+ /**
28
+ * Resolves the engine-relative locale jar.mn and the per-widget entry regex
29
+ * for a `sharedFtl` widget, or `undefined` when the FTL tree exposes no
30
+ * locale jar.mn we can confidently name.
31
+ */
32
+ function resolveSharedFtlPruneTarget(name, ftlDir) {
33
+ const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
34
+ const localeJarRel = resolveFtlLocaleJarMnPath(ftlDir);
35
+ if (chromeSubPath === undefined || localeJarRel === undefined)
36
+ return undefined;
37
+ return { localeJarRel, pattern: perWidgetLocaleEntryPattern(chromeSubPath, name) };
38
+ }
39
+ /**
40
+ * Removes a dangling per-widget locale jar.mn entry for a `sharedFtl` widget.
41
+ *
42
+ * A `localized: true` widget that opts into a feature-scoped `sharedFtl`
43
+ * bundle (its strings live under `browser/...` and load via
44
+ * `insertFTLIfNeeded`) must NOT carry a per-widget
45
+ * `locale/@AB_CD@/<chromeSubPath>/<name>.ftl` line. Such a line — written by
46
+ * an older FireForge before the sharedFtl apply guard, or by a layout
47
+ * migration — points at a `.ftl` that does not exist, so `mach build` fails
48
+ * hard (`Cannot find <chromeSubPath>/<name>.ftl`) and blocks every build.
49
+ *
50
+ * The pruned line is the per-widget toolkit entry only; the shared bundle's
51
+ * own line (a different chrome sub-path / base name) is never matched, so
52
+ * pruning one widget cannot orphan the shared bundle. Idempotent: when no
53
+ * dangling entry exists the file is left untouched (no journal churn).
54
+ * Returns the engine-relative jar.mn path when a line was removed, else
55
+ * `undefined`.
56
+ */
57
+ async function pruneSharedFtlPerWidgetLocaleEntry(engineDir, name, ftlDir, config, rollbackJournal) {
58
+ if (!config.sharedFtl)
59
+ return undefined;
60
+ const target = resolveSharedFtlPruneTarget(name, ftlDir);
61
+ if (!target)
62
+ return undefined;
63
+ const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
64
+ if (chromeSubPath === undefined)
65
+ return undefined;
66
+ const localeJarAbs = join(engineDir, target.localeJarRel);
67
+ if (!(await pathExists(localeJarAbs)))
68
+ return undefined;
69
+ if (!target.pattern.test(await readText(localeJarAbs)))
70
+ return undefined;
71
+ if (rollbackJournal) {
72
+ await snapshotFile(rollbackJournal, localeJarAbs);
73
+ }
74
+ await removeLocaleFtlJarMnEntry(engineDir, target.localeJarRel, name, chromeSubPath);
75
+ return target.localeJarRel;
76
+ }
77
+ /**
78
+ * Apply-path wrapper around {@link pruneSharedFtlPerWidgetLocaleEntry} that
79
+ * records the affected path / step error in the caller's collectors, mirroring
80
+ * {@link applyCustomFtlFile}'s contract so the main apply helper stays terse.
81
+ */
82
+ export async function applySharedFtlPrune(engineDir, name, ftlDir, config, affectedPaths, stepErrors, rollbackJournal) {
83
+ try {
84
+ const prunedPath = await pruneSharedFtlPerWidgetLocaleEntry(engineDir, name, ftlDir, config, rollbackJournal);
85
+ if (prunedPath)
86
+ affectedPaths.push(prunedPath);
87
+ }
88
+ catch (error) {
89
+ stepErrors.push({ step: 'locale jar.mn prune', error: toError(error).message });
90
+ }
91
+ }
92
+ /**
93
+ * Read-only dry-run describer for {@link pruneSharedFtlPerWidgetLocaleEntry}:
94
+ * returns an action when a dangling per-widget locale entry exists for a
95
+ * `sharedFtl` widget, else `undefined`.
96
+ */
97
+ export async function describeSharedFtlPrune(engineDir, name, ftlDir, config) {
98
+ if (!config.sharedFtl)
99
+ return undefined;
100
+ const target = resolveSharedFtlPruneTarget(name, ftlDir);
101
+ if (!target)
102
+ return undefined;
103
+ const localeJarAbs = join(engineDir, target.localeJarRel);
104
+ if (!(await pathExists(localeJarAbs)))
105
+ return undefined;
106
+ if (!target.pattern.test(await readText(localeJarAbs)))
107
+ return undefined;
108
+ return {
109
+ component: name,
110
+ action: 'register-jar',
111
+ description: `Remove dangling per-widget locale entry for ${name} from ${target.localeJarRel} (sharedFtl bundle owns its strings)`,
112
+ };
113
+ }
18
114
  /**
19
115
  * Copies a component's `.ftl` into the FTL tree and registers the chrome URI
20
116
  * in the locale jar.mn.
@@ -6,10 +6,11 @@ import { FurnaceError } from '../errors/furnace.js';
6
6
  import { toError } from '../utils/errors.js';
7
7
  import { copyFile, ensureDir, pathExists, readText, removeFile } from '../utils/fs.js';
8
8
  import { verbose } from '../utils/logger.js';
9
- import { applyCustomFtlFile, describeLocaleFtlJarMnRegistration, removeCustomFtlJarMnEntry, } from './furnace-apply-ftl.js';
9
+ import { buildCustomDryRunActions } from './furnace-apply-dry-run.js';
10
+ import { applyCustomFtlFile, applySharedFtlPrune, removeCustomFtlJarMnEntry, } from './furnace-apply-ftl.js';
10
11
  import { CUSTOM_ELEMENTS_JS, JAR_MN } from './furnace-constants.js';
11
- import { deployFileWithFragments, describeFragmentExpansion, SHARED_FRAGMENTS_DIR, } from './furnace-css-fragments.js';
12
- import { addCustomElementRegistration, addJarMnEntries, validateCustomElementRegistration, validateJarMnInsertionForFiles, } from './furnace-registration.js';
12
+ import { deployFileWithFragments, SHARED_FRAGMENTS_DIR } from './furnace-css-fragments.js';
13
+ import { addCustomElementRegistration, addJarMnEntries } from './furnace-registration.js';
13
14
  import { recordCreatedDir, snapshotFile } from './furnace-rollback.js';
14
15
  import { checkRegistrationConsistency } from './furnace-validate-registration.js';
15
16
  import { isGitRepository } from './git.js';
@@ -278,83 +279,6 @@ export async function hasCustomEngineDrift(root, name, componentDir, config, ftl
278
279
  }
279
280
  return false;
280
281
  }
281
- async function buildCustomDryRunActions(name, componentDir, engineDir, config, targetDir, entries, ftlDir) {
282
- const actions = [];
283
- const stepErrors = [];
284
- for (const entry of entries) {
285
- if (!isRegularFile(entry))
286
- continue;
287
- if (!entry.name.endsWith('.mjs') && !entry.name.endsWith('.css'))
288
- continue;
289
- const fragmentNote = await describeFragmentExpansion(join(componentDir, entry.name));
290
- actions.push({
291
- component: name,
292
- action: fragmentNote ? 'expand-fragments' : 'copy',
293
- source: join(componentDir, entry.name),
294
- target: join(targetDir, entry.name),
295
- description: `Copy ${entry.name} to ${config.targetPath}${fragmentNote}`,
296
- });
297
- }
298
- // Per-component .ftl handling is skipped when the component opts into a
299
- // shared feature-scoped bundle via `sharedFtl`. The shared file is
300
- // registered (and copied) by whoever owns the feature bundle, so
301
- // emitting a copy-ftl / register-jar action here would duplicate (or
302
- // later orphan) the entry.
303
- if (config.localized && !config.sharedFtl) {
304
- const ftlFile = `${name}.ftl`;
305
- const ftlSrc = join(componentDir, ftlFile);
306
- if (await pathExists(ftlSrc)) {
307
- actions.push({
308
- component: name,
309
- action: 'copy-ftl',
310
- source: ftlSrc,
311
- target: join(engineDir, ftlDir, ftlFile),
312
- description: `Copy ${ftlFile} to ${ftlDir}`,
313
- });
314
- const localeAction = describeLocaleFtlJarMnRegistration(name, ftlDir, ftlFile);
315
- if (localeAction) {
316
- actions.push(localeAction);
317
- }
318
- }
319
- }
320
- if (config.register) {
321
- try {
322
- const modulePath = `chrome://global/content/elements/${name}.mjs`;
323
- await validateCustomElementRegistration(engineDir, name, modulePath);
324
- }
325
- catch (error) {
326
- stepErrors.push({
327
- step: 'customElements.js registration',
328
- error: toError(error).message,
329
- });
330
- }
331
- actions.push({
332
- component: name,
333
- action: 'register-ce',
334
- description: `Register ${name} in customElements.js (DOMContentLoaded block)`,
335
- });
336
- }
337
- const copiedFileNames = entries
338
- .filter((entry) => isRegularFile(entry) && (entry.name.endsWith('.mjs') || entry.name.endsWith('.css')))
339
- .map((entry) => entry.name);
340
- if (copiedFileNames.length > 0) {
341
- try {
342
- await validateJarMnInsertionForFiles(engineDir, name, copiedFileNames);
343
- }
344
- catch (error) {
345
- stepErrors.push({
346
- step: 'jar.mn registration',
347
- error: toError(error).message,
348
- });
349
- }
350
- actions.push({
351
- component: name,
352
- action: 'register-jar',
353
- description: `Add ${copiedFileNames.join(', ')} to jar.mn`,
354
- });
355
- }
356
- return { actions, stepErrors };
357
- }
358
282
  /** Applies a custom component into the engine tree and captures registration step errors. */
359
283
  export async function applyCustomComponent(engineDir, name, componentDir, config, ftlDir, dryRun = false, rollbackJournal, applyOptions = {}) {
360
284
  if (!/^[a-z][a-z0-9-]*$/.test(name)) {
@@ -406,6 +330,12 @@ export async function applyCustomComponent(engineDir, name, componentDir, config
406
330
  if (config.localized && !config.sharedFtl) {
407
331
  await applyCustomFtlFile(engineDir, name, componentDir, ftlDir, affectedPaths, stepErrors, rollbackJournal);
408
332
  }
333
+ else if (config.localized && config.sharedFtl) {
334
+ // Drop any dangling per-widget locale jar.mn entry that would point at a
335
+ // non-existent `<chromeSubPath>/<name>.ftl` and fail `mach build`. The
336
+ // shared bundle (a different chrome path/base name) is never touched.
337
+ await applySharedFtlPrune(engineDir, name, ftlDir, config, affectedPaths, stepErrors, rollbackJournal);
338
+ }
409
339
  if (config.register) {
410
340
  try {
411
341
  const modulePath = `chrome://global/content/elements/${name}.mjs`;
@@ -71,6 +71,30 @@ export declare function removeLocaleFtlJarMnEntry(engineDir: string, jarMnRelPat
71
71
  * @param tagName - Custom element tag name whose entries should be removed
72
72
  */
73
73
  export declare function removeJarMnEntries(engineDir: string, tagName: string): Promise<void>;
74
+ /** A jar.mn widget registration line whose source file no longer exists. */
75
+ export interface StaleJarMnEntry {
76
+ /** Component tag name from the `(widgets/<tag>/<file>)` source mapping. */
77
+ tagName: string;
78
+ /** File name from the source mapping. */
79
+ fileName: string;
80
+ /** The full (trimmed) jar.mn line. */
81
+ line: string;
82
+ }
83
+ /**
84
+ * Scans jar.mn for widget registration lines `(widgets/<tag>/<file>)`
85
+ * whose workspace source file no longer exists (0.34.0 field report: a
86
+ * renamed component helper left the old line pointing at a deleted file,
87
+ * and every build failed at packaging). Only tags in `managedTags`
88
+ * (furnace-managed custom components) are inspected so upstream lines are
89
+ * never touched.
90
+ */
91
+ export declare function findStaleJarMnEntries(engineDir: string, customDir: string, managedTags: readonly string[]): Promise<StaleJarMnEntry[]>;
92
+ /**
93
+ * Removes every stale widget registration line found by
94
+ * {@link findStaleJarMnEntries}. Returns the removed entries. Used by
95
+ * `furnace validate --fix` and `doctor --repair-furnace`.
96
+ */
97
+ export declare function pruneStaleJarMnEntries(engineDir: string, customDir: string, managedTags: readonly string[]): Promise<StaleJarMnEntry[]>;
74
98
  /**
75
99
  * Validates that jar.mn entries *could* be added without writing anything.
76
100
  * Used by dry-run to surface structural problems early.
@@ -224,14 +224,67 @@ export async function removeJarMnEntries(engineDir, tagName) {
224
224
  }
225
225
  let content = await readText(filePath);
226
226
  const lines = content.split('\n');
227
- // Use a regex with word boundary so "moz-card" does not match "moz-card-group".
228
- const pattern = new RegExp(`content/global/elements/${escapeForRegex(tagName)}\\.`);
229
- const filtered = lines.filter((line) => !pattern.test(line));
227
+ // Match by the SOURCE MAPPING segment `(widgets/<tagName>/...)` so every
228
+ // line the component registered is removed regardless of the target
229
+ // basename a helper `.mjs` whose name does not start with the tag
230
+ // (e.g. a renamed `foo-utils.mjs`) used to survive the remove pass and
231
+ // leave a stale registration that broke packaging (0.34.0 field
232
+ // report). The legacy target-path match is kept as an OR for lines
233
+ // written by older FireForge versions without a source mapping. Word
234
+ // boundaries keep "moz-card" from matching "moz-card-group".
235
+ const sourcePattern = new RegExp(`\\(widgets/${escapeForRegex(tagName)}/`);
236
+ const legacyTargetPattern = new RegExp(`content/global/elements/${escapeForRegex(tagName)}\\.`);
237
+ const filtered = lines.filter((line) => !sourcePattern.test(line) && !legacyTargetPattern.test(line));
230
238
  if (filtered.length === lines.length)
231
239
  return;
232
240
  content = filtered.join('\n');
233
241
  await writeText(filePath, content);
234
242
  }
243
+ const WIDGET_SOURCE_MAPPING_PATTERN = /\(widgets\/([^/)]+)\/([^)]+)\)/;
244
+ /**
245
+ * Scans jar.mn for widget registration lines `(widgets/<tag>/<file>)`
246
+ * whose workspace source file no longer exists (0.34.0 field report: a
247
+ * renamed component helper left the old line pointing at a deleted file,
248
+ * and every build failed at packaging). Only tags in `managedTags`
249
+ * (furnace-managed custom components) are inspected so upstream lines are
250
+ * never touched.
251
+ */
252
+ export async function findStaleJarMnEntries(engineDir, customDir, managedTags) {
253
+ const filePath = join(engineDir, JAR_MN);
254
+ if (!(await pathExists(filePath)))
255
+ return [];
256
+ const managed = new Set(managedTags);
257
+ const stale = [];
258
+ for (const line of (await readText(filePath)).split('\n')) {
259
+ const match = WIDGET_SOURCE_MAPPING_PATTERN.exec(line);
260
+ if (!match)
261
+ continue;
262
+ const tagName = match[1] ?? '';
263
+ const fileName = match[2] ?? '';
264
+ if (!managed.has(tagName))
265
+ continue;
266
+ if (!(await pathExists(join(customDir, tagName, fileName)))) {
267
+ stale.push({ tagName, fileName, line: line.trim() });
268
+ }
269
+ }
270
+ return stale;
271
+ }
272
+ /**
273
+ * Removes every stale widget registration line found by
274
+ * {@link findStaleJarMnEntries}. Returns the removed entries. Used by
275
+ * `furnace validate --fix` and `doctor --repair-furnace`.
276
+ */
277
+ export async function pruneStaleJarMnEntries(engineDir, customDir, managedTags) {
278
+ const stale = await findStaleJarMnEntries(engineDir, customDir, managedTags);
279
+ if (stale.length === 0)
280
+ return [];
281
+ const filePath = join(engineDir, JAR_MN);
282
+ const staleLines = new Set(stale.map((entry) => entry.line));
283
+ const lines = (await readText(filePath)).split('\n');
284
+ const filtered = lines.filter((line) => !staleLines.has(line.trim()));
285
+ await writeText(filePath, filtered.join('\n'));
286
+ return stale;
287
+ }
235
288
  /**
236
289
  * Validates that jar.mn entries *could* be added without writing anything.
237
290
  * Used by dry-run to surface structural problems early.
@@ -10,6 +10,7 @@ import { getProjectPaths, loadConfig } from './config.js';
10
10
  import { getFurnacePaths } from './furnace-config.js';
11
11
  import { CUSTOM_ELEMENTS_JS, FTL_DIR, JAR_MN } from './furnace-constants.js';
12
12
  import { expandCssFragments, listFragmentIncludes } from './furnace-css-fragments.js';
13
+ import { findStaleJarMnEntries } from './furnace-registration.js';
13
14
  import { isTagInCorrectCustomElementsPlacement } from './furnace-registration-validate.js';
14
15
  import { getTokensCssPath } from './token-manager.js';
15
16
  /**
@@ -210,6 +211,23 @@ export async function validateJarMnEntries(root, config) {
210
211
  });
211
212
  }
212
213
  }
214
+ // Stale registrations: lines pointing at component files that no longer
215
+ // exist in the workspace (left behind by a rename/delete under an older
216
+ // FireForge). These break `mach build` at packaging ("File ... not
217
+ // found"), so they are errors and `--fix` prunes them (0.34.0 field
218
+ // report: both validate --fix and doctor --repair-furnace reported
219
+ // success without pruning).
220
+ const staleEntries = await findStaleJarMnEntries(engineDir, furnacePaths.customDir, Object.keys(config.custom));
221
+ for (const stale of staleEntries) {
222
+ issues.push({
223
+ component: stale.tagName,
224
+ severity: 'error',
225
+ check: 'stale-jar-registration',
226
+ message: `jar.mn registers ${stale.fileName} for ${stale.tagName}, but the source file no longer exists ` +
227
+ `(stale line: "${stale.line}"). Packaging will fail until the line is removed — run ` +
228
+ '"fireforge furnace validate --fix" or "fireforge doctor --repair-furnace" to prune it.',
229
+ });
230
+ }
213
231
  return issues;
214
232
  }
215
233
  /**
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Resource-monitor degrade guard for mach dispatches.
3
+ *
4
+ * mozbuild's build resource monitor calls `psutil.virtual_memory()` at
5
+ * startup (`start_resource_recording`) and again later from
6
+ * `mozbuild.base._run_make`. On some hosts (psutil vs Darwin 27) those
7
+ * raise `RuntimeError: host_statistics64(HOST_VM_INFO64) syscall failed`,
8
+ * and a `SystemResourceMonitor.__init__` that fails partway leaves an
9
+ * instance without `poll_interval`, so later polling dies with
10
+ * `AttributeError: ... poll_interval`. Any of these aborts `mach build` /
11
+ * `mach build faster` before the compiler ever runs.
12
+ *
13
+ * 0.33.0 injected a `sitecustomize.py` via the mach subprocess PYTHONPATH.
14
+ * Field report (0.34.0 cycle): mach re-execs itself into its private
15
+ * virtualenv and drops PYTHONPATH, so the env-var route never loads and
16
+ * the crash family escaped on every protected entry point. FireForge now
17
+ * owns the guard in-process on its mach dispatches by installing a
18
+ * `fireforge_mach_guard.pth` + module pair directly into every discovered
19
+ * mach virtualenv site-packages directory: `.pth` files execute at
20
+ * interpreter startup from the venv's own site-packages, which survives
21
+ * the re-exec. The PYTHONPATH `sitecustomize.py` is retained only as a
22
+ * belt-and-suspenders for the pre-venv bootstrap phase (first build, no
23
+ * `_virtualenvs` on disk yet).
24
+ *
25
+ * The guard itself covers the whole crash family:
26
+ * - wraps `psutil.virtual_memory` / `swap_memory` / `cpu_percent` /
27
+ * `cpu_times` / `disk_io_counters` module-wide, so every call site —
28
+ * including the direct `psutil.virtual_memory()` in
29
+ * `mozbuild.base._run_make` — degrades to a `UserWarning` and a zeroed
30
+ * reading instead of aborting;
31
+ * - guards `SystemResourceMonitor` CONSTRUCTION via an import hook:
32
+ * `poll_interval` is pre-populated before the real `__init__` runs, a
33
+ * failing `__init__` marks the instance degraded instead of raising,
34
+ * and every monitor method no-ops on a degraded instance — so a
35
+ * partially-constructed monitor can never surface the
36
+ * `AttributeError: poll_interval` variant.
37
+ */
38
+ /** Result of installing the mach resource guard before a dispatch. */
39
+ export interface MachResourceGuardInstallation {
40
+ /**
41
+ * Env overlay for the mach subprocess: the PYTHONPATH sitecustomize
42
+ * fallback that covers the pre-venv bootstrap phase. Merged over
43
+ * `process.env` by the exec layer.
44
+ */
45
+ env: Record<string, string>;
46
+ /** site-packages directories the in-venv guard was written into. */
47
+ sitePackagesDirs: string[];
48
+ }
49
+ /**
50
+ * Installs the resource-monitor degrade guard for a mach dispatch:
51
+ * `fireforge_mach_guard.pth` + `fireforge_mach_guard.py` into every
52
+ * discovered mach virtualenv site-packages (survives mach's venv re-exec),
53
+ * plus the PYTHONPATH sitecustomize fallback for the pre-venv bootstrap
54
+ * phase. Idempotent and re-run before EVERY protected dispatch (including
55
+ * each retry) so a venv created by a crashed first attempt is guarded on
56
+ * the next one instead of re-dying on the same wedged state.
57
+ */
58
+ export declare function installMachResourceGuard(engineDir: string): Promise<MachResourceGuardInstallation>;