@hominis/fireforge 0.15.6 → 0.15.8

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 (64) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +158 -15
  3. package/dist/src/commands/build.js +60 -3
  4. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +17 -0
  5. package/dist/src/commands/furnace/chrome-doc-templates.js +18 -0
  6. package/dist/src/commands/furnace/chrome-doc-tests.d.ts +23 -0
  7. package/dist/src/commands/furnace/chrome-doc-tests.js +120 -0
  8. package/dist/src/commands/furnace/chrome-doc.d.ts +11 -0
  9. package/dist/src/commands/furnace/chrome-doc.js +37 -4
  10. package/dist/src/commands/furnace/create-dry-run.d.ts +38 -0
  11. package/dist/src/commands/furnace/create-dry-run.js +100 -0
  12. package/dist/src/commands/furnace/create-features.d.ts +24 -0
  13. package/dist/src/commands/furnace/create-features.js +56 -0
  14. package/dist/src/commands/furnace/create-templates.d.ts +9 -5
  15. package/dist/src/commands/furnace/create-templates.js +28 -6
  16. package/dist/src/commands/furnace/create.js +62 -63
  17. package/dist/src/commands/furnace/index.js +4 -1
  18. package/dist/src/commands/lint.d.ts +17 -2
  19. package/dist/src/commands/lint.js +25 -2
  20. package/dist/src/commands/register.d.ts +1 -1
  21. package/dist/src/commands/register.js +30 -7
  22. package/dist/src/commands/run.d.ts +15 -1
  23. package/dist/src/commands/run.js +202 -7
  24. package/dist/src/commands/test.js +113 -3
  25. package/dist/src/core/build-audit-registration.d.ts +80 -0
  26. package/dist/src/core/build-audit-registration.js +187 -0
  27. package/dist/src/core/build-audit-transforms.d.ts +23 -0
  28. package/dist/src/core/build-audit-transforms.js +94 -0
  29. package/dist/src/core/build-audit.js +107 -7
  30. package/dist/src/core/furnace-apply-ftl.d.ts +5 -3
  31. package/dist/src/core/furnace-apply-ftl.js +6 -2
  32. package/dist/src/core/furnace-apply-helpers.js +14 -4
  33. package/dist/src/core/furnace-config-custom.d.ts +14 -0
  34. package/dist/src/core/furnace-config-custom.js +64 -0
  35. package/dist/src/core/furnace-config.js +2 -39
  36. package/dist/src/core/furnace-validate-accessibility.d.ts +9 -2
  37. package/dist/src/core/furnace-validate-accessibility.js +17 -3
  38. package/dist/src/core/furnace-validate-helpers.d.ts +13 -1
  39. package/dist/src/core/furnace-validate-helpers.js +19 -0
  40. package/dist/src/core/furnace-validate-registration.d.ts +6 -4
  41. package/dist/src/core/furnace-validate-registration.js +66 -6
  42. package/dist/src/core/furnace-validate-structure.js +6 -2
  43. package/dist/src/core/furnace-validate.js +6 -3
  44. package/dist/src/core/mach-build-artifacts.d.ts +44 -0
  45. package/dist/src/core/mach-build-artifacts.js +104 -3
  46. package/dist/src/core/mach.d.ts +27 -1
  47. package/dist/src/core/mach.js +26 -2
  48. package/dist/src/core/shared-ftl.d.ts +28 -0
  49. package/dist/src/core/shared-ftl.js +42 -0
  50. package/dist/src/core/smoke-patterns.d.ts +45 -0
  51. package/dist/src/core/smoke-patterns.js +100 -0
  52. package/dist/src/core/test-stale-check.d.ts +42 -0
  53. package/dist/src/core/test-stale-check.js +114 -0
  54. package/dist/src/core/xpcshell-appdir.d.ts +143 -0
  55. package/dist/src/core/xpcshell-appdir.js +273 -0
  56. package/dist/src/errors/codes.d.ts +13 -0
  57. package/dist/src/errors/codes.js +13 -0
  58. package/dist/src/errors/run.d.ts +16 -0
  59. package/dist/src/errors/run.js +22 -0
  60. package/dist/src/types/commands/options.d.ts +64 -0
  61. package/dist/src/types/furnace.d.ts +39 -0
  62. package/dist/src/utils/process.d.ts +63 -0
  63. package/dist/src/utils/process.js +122 -0
  64. package/package.json +1 -1
@@ -4,9 +4,9 @@ import { FurnaceError } from '../errors/furnace.js';
4
4
  import { toError } from '../utils/errors.js';
5
5
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
6
6
  import { warn } from '../utils/logger.js';
7
- import { isExplicitAbsolutePath } from '../utils/paths.js';
8
- import { isArray, isBoolean, isObject, isString } from '../utils/validation.js';
7
+ import { isArray, isObject, isString } from '../utils/validation.js';
9
8
  import { FIREFORGE_DIR } from './config.js';
9
+ import { parseCustomConfig } from './furnace-config-custom.js';
10
10
  import { validateRuntimeVariables, validateTokenHostDocuments } from './furnace-config-tokens.js';
11
11
  import { resolveFtlDir } from './furnace-constants.js';
12
12
  import { detectComposesCycles, validateComposesReferences } from './furnace-graph-utils.js';
@@ -89,43 +89,6 @@ function parseOverrideConfig(data, name) {
89
89
  ...(isString(data['baseCommit']) ? { baseCommit: data['baseCommit'] } : {}),
90
90
  };
91
91
  }
92
- /**
93
- * Validates a custom component config object.
94
- * @param data - Raw data to validate
95
- * @param name - Component name for error messages
96
- */
97
- function parseCustomConfig(data, name) {
98
- if (!isString(data['description'])) {
99
- throw new FurnaceError(`Furnace config: custom "${name}.description" must be a string`);
100
- }
101
- if (!isString(data['targetPath'])) {
102
- throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must be a string`);
103
- }
104
- if (data['targetPath'].includes('..') || data['targetPath'].includes('\0')) {
105
- throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must not contain ".." or null bytes (path traversal)`);
106
- }
107
- if (isExplicitAbsolutePath(data['targetPath'])) {
108
- throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must not be an absolute path`);
109
- }
110
- if (!isBoolean(data['register'])) {
111
- throw new FurnaceError(`Furnace config: custom "${name}.register" must be a boolean`);
112
- }
113
- if (!isBoolean(data['localized'])) {
114
- throw new FurnaceError(`Furnace config: custom "${name}.localized" must be a boolean`);
115
- }
116
- if (data['composes'] !== undefined) {
117
- parseStringArray(data['composes'], `${name}.composes`);
118
- }
119
- return {
120
- description: data['description'],
121
- targetPath: data['targetPath'],
122
- register: data['register'],
123
- localized: data['localized'],
124
- ...(data['composes'] !== undefined
125
- ? { composes: parseStringArray(data['composes'], `${name}.composes`) }
126
- : {}),
127
- };
128
- }
129
92
  /** The current (and only) config schema version. */
130
93
  const CURRENT_CONFIG_VERSION = 1;
131
94
  /**
@@ -1,6 +1,13 @@
1
- import type { ValidationIssue } from '../types/furnace.js';
1
+ import type { CustomComponentConfig, ValidationIssue } from '../types/furnace.js';
2
2
  /**
3
3
  * Validates accessibility patterns in a component's .mjs file.
4
4
  * Checks for ARIA roles, keyboard handlers, l10n, and focus delegation.
5
+ *
6
+ * @param customConfig - When the component is custom, its matching entry
7
+ * from `furnace.json`. Used to skip the `no-keyboard-handler` warning
8
+ * when the component declares keyboard coverage either explicitly
9
+ * (`keyboardCovered: true`) or via `composes` naming a native-interactive
10
+ * inner element. Optional so stock/override callers and test fixtures
11
+ * without config in scope can continue to call without changes.
5
12
  */
6
- export declare function validateAccessibility(componentDir: string, tagName: string): Promise<ValidationIssue[]>;
13
+ export declare function validateAccessibility(componentDir: string, tagName: string, customConfig?: CustomComponentConfig): Promise<ValidationIssue[]>;
@@ -1,12 +1,19 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
3
  import { pathExists, readText } from '../utils/fs.js';
4
- import { containsHardcodedTemplateText, createIssue, hasAriaRole, hasDelegatesFocusEnabled, hasGenericInteractiveElement, hasPositiveTabindex, hasTemplateClickHandler, hasTemplateClickOnSyntheticInteractive, hasTemplateKeyboardHandler, hasUnlabelledFormInput, } from './furnace-validate-helpers.js';
4
+ import { containsHardcodedTemplateText, createIssue, hasAriaRole, hasDelegatesFocusEnabled, hasGenericInteractiveElement, hasPositiveTabindex, hasTemplateClickHandler, hasTemplateClickOnSyntheticInteractive, hasTemplateKeyboardHandler, hasUnlabelledFormInput, isKeyboardCoveredByComposition, } from './furnace-validate-helpers.js';
5
5
  /**
6
6
  * Validates accessibility patterns in a component's .mjs file.
7
7
  * Checks for ARIA roles, keyboard handlers, l10n, and focus delegation.
8
+ *
9
+ * @param customConfig - When the component is custom, its matching entry
10
+ * from `furnace.json`. Used to skip the `no-keyboard-handler` warning
11
+ * when the component declares keyboard coverage either explicitly
12
+ * (`keyboardCovered: true`) or via `composes` naming a native-interactive
13
+ * inner element. Optional so stock/override callers and test fixtures
14
+ * without config in scope can continue to call without changes.
8
15
  */
9
- export async function validateAccessibility(componentDir, tagName) {
16
+ export async function validateAccessibility(componentDir, tagName, customConfig) {
10
17
  const mjsPath = join(componentDir, `${tagName}.mjs`);
11
18
  if (!(await pathExists(mjsPath)))
12
19
  return [];
@@ -22,8 +29,15 @@ export async function validateAccessibility(componentDir, tagName) {
22
29
  // platform, so a duplicate keyboard handler would usually double-fire.
23
30
  // Only flag synthetic markup (e.g. `<div @click>`) where the activation
24
31
  // path has to be wired manually.
32
+ //
33
+ // A wrapper component whose `composes` entry lists a native-interactive
34
+ // tag (or that sets `keyboardCovered: true`) is treated the same way:
35
+ // activation flows through the inner element and a duplicate handler on
36
+ // the wrapper would either no-op or fire twice alongside the child's
37
+ // built-in keyboard path.
25
38
  const hasClickOnSynthetic = hasTemplateClickOnSyntheticInteractive(content);
26
- if (hasClickOnSynthetic && !hasKeyboardHandler) {
39
+ const keyboardCovered = isKeyboardCoveredByComposition(customConfig);
40
+ if (hasClickOnSynthetic && !hasKeyboardHandler && !keyboardCovered) {
27
41
  issues.push(createIssue(tagName, 'warning', 'no-keyboard-handler', 'Interactive element has @click but no keyboard event handler (@keydown/@keypress/@keyup).'));
28
42
  }
29
43
  if (containsHardcodedTemplateText(content)) {
@@ -1,4 +1,4 @@
1
- import type { ComponentType, FurnaceConfig, ValidationIssue } from '../types/furnace.js';
1
+ import type { ComponentType, CustomComponentConfig, FurnaceConfig, ValidationIssue } from '../types/furnace.js';
2
2
  /** Creates a normalized validation issue object. */
3
3
  export declare function createIssue(component: string, severity: ValidationIssue['severity'], check: ValidationIssue['check'], message: string): ValidationIssue;
4
4
  /** Detects whether template or script content assigns an ARIA role. */
@@ -13,6 +13,18 @@ export declare function hasUnlabelledFormInput(content: string): boolean;
13
13
  export declare function hasTemplateClickHandler(content: string): boolean;
14
14
  /** Detects Lit-style template keyboard handlers. */
15
15
  export declare function hasTemplateKeyboardHandler(content: string): boolean;
16
+ /**
17
+ * Returns true when `customConfig` declares that the component's keyboard
18
+ * activation path is covered by a wrapped native-interactive inner element,
19
+ * either through an explicit opt-out (`keyboardCovered: true`) or by
20
+ * composing a tag that lives in {@link NATIVE_CLICK_INTERACTIVE_TAGS}.
21
+ *
22
+ * Uses `.some` rather than `.every` so that a wrapper composing e.g.
23
+ * `['moz-button', 'my-tooltip']` still skips the warning: the keyboard
24
+ * activation path flows through the button, even if other composed
25
+ * children are synthetic.
26
+ */
27
+ export declare function isKeyboardCoveredByComposition(customConfig: CustomComponentConfig | undefined): boolean;
16
28
  /**
17
29
  * Returns true when `content` has at least one `@click=${...}` handler on a
18
30
  * *synthetic* interactive element (e.g. `<div @click>`), which lacks native
@@ -76,6 +76,25 @@ const NATIVE_CLICK_INTERACTIVE_TAGS = new Set([
76
76
  'moz-radio-group',
77
77
  'moz-menulist',
78
78
  ]);
79
+ /**
80
+ * Returns true when `customConfig` declares that the component's keyboard
81
+ * activation path is covered by a wrapped native-interactive inner element,
82
+ * either through an explicit opt-out (`keyboardCovered: true`) or by
83
+ * composing a tag that lives in {@link NATIVE_CLICK_INTERACTIVE_TAGS}.
84
+ *
85
+ * Uses `.some` rather than `.every` so that a wrapper composing e.g.
86
+ * `['moz-button', 'my-tooltip']` still skips the warning: the keyboard
87
+ * activation path flows through the button, even if other composed
88
+ * children are synthetic.
89
+ */
90
+ export function isKeyboardCoveredByComposition(customConfig) {
91
+ if (!customConfig)
92
+ return false;
93
+ if (customConfig.keyboardCovered === true)
94
+ return true;
95
+ const composes = customConfig.composes ?? [];
96
+ return composes.some((tag) => NATIVE_CLICK_INTERACTIVE_TAGS.has(tag));
97
+ }
79
98
  /**
80
99
  * Returns true when `content` has at least one `@click=${...}` handler on a
81
100
  * *synthetic* interactive element (e.g. `<div @click>`), which lacks native
@@ -35,10 +35,12 @@ export declare function validateJarMnEntries(root: string, config: FurnaceConfig
35
35
  * linked in at least one chrome host document. Without the link, tokens
36
36
  * silently resolve to nothing at runtime.
37
37
  *
38
- * Forks with multiple chrome host documents (e.g. `mybrowser.xhtml` beside
39
- * `browser.xhtml`) can enumerate them via `tokenHostDocuments` in
40
- * furnace.json; the warning fires only when NONE of the configured
41
- * documents link the tokens CSS.
38
+ * Scan set is the union of (a) the configured `tokenHostDocuments` (or
39
+ * the upstream default when unset) and (b) any `browser/base/content/*.xhtml`
40
+ * document that references `tagName` the auto-detection path catches
41
+ * forks that mount components from a replacement chrome document without
42
+ * having configured `tokenHostDocuments`. The warning fires only when
43
+ * NONE of the documents in the final scan set link the tokens CSS.
42
44
  */
43
45
  export declare function validateTokenLink(componentDir: string, tagName: string, root: string, tokenPrefix?: string, tokenHostDocuments?: string[]): Promise<ValidationIssue[]>;
44
46
  /**
@@ -211,15 +211,73 @@ export async function validateJarMnEntries(root, config) {
211
211
  * `tokenHostDocuments` is not configured in furnace.json.
212
212
  */
213
213
  const DEFAULT_TOKEN_HOST_DOCUMENTS = ['browser/base/content/browser.xhtml'];
214
+ /**
215
+ * Directory scanned for additional chrome host documents that mount the
216
+ * component under audit. Kept narrow (top-level `browser/base/content/`)
217
+ * so the auto-detection stays cheap and only triggers on the well-known
218
+ * location forks use for replacement chrome documents.
219
+ */
220
+ const AUTO_DETECT_HOST_DIR = 'browser/base/content';
221
+ /**
222
+ * Scans `browser/base/content/*.xhtml` for chrome documents that reference
223
+ * `tagName`. Returned paths are engine-relative and deduplicated against
224
+ * `already`, so callers can merge them with the caller-configured set
225
+ * without producing double entries in warning output.
226
+ *
227
+ * Motivating case: a fork that mounts a custom element from its own
228
+ * top-level chrome document (e.g. `mybrowser.xhtml`) without setting
229
+ * `tokenHostDocuments`. The stock `browser.xhtml` was the only thing
230
+ * scanned, so the tokens CSS link in the ACTUAL host document went
231
+ * unnoticed and the warning false-fired.
232
+ *
233
+ * @param engineDir Absolute engine root.
234
+ * @param tagName Custom element tag the CSS belongs to.
235
+ * @param already Paths already in the scan set (POSIX, engine-relative).
236
+ */
237
+ async function autoDetectTokenHostDocuments(engineDir, tagName, already) {
238
+ const contentDir = join(engineDir, AUTO_DETECT_HOST_DIR);
239
+ if (!(await pathExists(contentDir)))
240
+ return [];
241
+ let entries;
242
+ try {
243
+ entries = await readdir(contentDir);
244
+ }
245
+ catch {
246
+ return [];
247
+ }
248
+ const alreadySet = new Set(already);
249
+ const detected = [];
250
+ for (const entry of entries) {
251
+ if (!entry.endsWith('.xhtml'))
252
+ continue;
253
+ const relPath = `${AUTO_DETECT_HOST_DIR}/${entry}`;
254
+ if (alreadySet.has(relPath))
255
+ continue;
256
+ const absPath = join(contentDir, entry);
257
+ let content;
258
+ try {
259
+ content = await readText(absPath);
260
+ }
261
+ catch {
262
+ continue;
263
+ }
264
+ if (content.includes(tagName)) {
265
+ detected.push(relPath);
266
+ }
267
+ }
268
+ return detected;
269
+ }
214
270
  /**
215
271
  * Validates that components using design tokens have the tokens CSS
216
272
  * linked in at least one chrome host document. Without the link, tokens
217
273
  * silently resolve to nothing at runtime.
218
274
  *
219
- * Forks with multiple chrome host documents (e.g. `mybrowser.xhtml` beside
220
- * `browser.xhtml`) can enumerate them via `tokenHostDocuments` in
221
- * furnace.json; the warning fires only when NONE of the configured
222
- * documents link the tokens CSS.
275
+ * Scan set is the union of (a) the configured `tokenHostDocuments` (or
276
+ * the upstream default when unset) and (b) any `browser/base/content/*.xhtml`
277
+ * document that references `tagName` the auto-detection path catches
278
+ * forks that mount components from a replacement chrome document without
279
+ * having configured `tokenHostDocuments`. The warning fires only when
280
+ * NONE of the documents in the final scan set link the tokens CSS.
223
281
  */
224
282
  export async function validateTokenLink(componentDir, tagName, root, tokenPrefix, tokenHostDocuments) {
225
283
  const issues = [];
@@ -233,7 +291,7 @@ export async function validateTokenLink(componentDir, tagName, root, tokenPrefix
233
291
  if (!cssContent.includes(tokenPrefix))
234
292
  return issues;
235
293
  const { engine: engineDir } = getProjectPaths(root);
236
- const hostDocuments = tokenHostDocuments && tokenHostDocuments.length > 0
294
+ const configuredHosts = tokenHostDocuments && tokenHostDocuments.length > 0
237
295
  ? tokenHostDocuments
238
296
  : DEFAULT_TOKEN_HOST_DOCUMENTS;
239
297
  let tokensCssFile;
@@ -247,6 +305,8 @@ export async function validateTokenLink(componentDir, tagName, root, tokenPrefix
247
305
  warn(`Could not resolve token CSS link target for ${tagName} during validation: ${reason}`);
248
306
  return issues;
249
307
  }
308
+ const autoDetected = await autoDetectTokenHostDocuments(engineDir, tagName, configuredHosts);
309
+ const hostDocuments = [...configuredHosts, ...autoDetected];
250
310
  const checkedDocuments = [];
251
311
  let anyLinks = false;
252
312
  for (const relDocPath of hostDocuments) {
@@ -268,7 +328,7 @@ export async function validateTokenLink(componentDir, tagName, root, tokenPrefix
268
328
  component: tagName,
269
329
  severity: 'warning',
270
330
  check: 'missing-token-link',
271
- message: `Component uses ${tokenPrefix}* tokens but none of the configured chrome host documents (${docsList}) link ${tokensCssFile}. Tokens will silently resolve to nothing. Configure additional hosts via furnace.json "tokenHostDocuments" if needed.`,
331
+ message: `Component uses ${tokenPrefix}* tokens but none of the scanned chrome host documents (${docsList}) link ${tokensCssFile}. Tokens will silently resolve to nothing. Configure additional hosts via furnace.json "tokenHostDocuments" if needed.`,
272
332
  });
273
333
  }
274
334
  return issues;
@@ -39,14 +39,18 @@ export async function validateStructure(componentDir, tagName, type, customConfi
39
39
  // Localized custom components must have a {tag}.ftl file. Without one,
40
40
  // apply silently deploys nothing for the locale and the runtime
41
41
  // localization payload is empty, which is hard to spot in review.
42
- if (type === 'custom' && customConfig?.localized) {
42
+ //
43
+ // Components that declare `sharedFtl` participate in a pre-existing
44
+ // feature-scoped bundle, so there is no per-component .ftl to require —
45
+ // the shared file is owned by whoever authored the feature bundle.
46
+ if (type === 'custom' && customConfig?.localized && !customConfig.sharedFtl) {
43
47
  const ftlPath = join(componentDir, `${tagName}.ftl`);
44
48
  if (!(await pathExists(ftlPath))) {
45
49
  issues.push({
46
50
  component: tagName,
47
51
  severity: 'error',
48
52
  check: 'missing-ftl',
49
- message: `Component is marked localized: true but ${tagName}.ftl is missing. Create the file or set localized: false in furnace.json.`,
53
+ message: `Component is marked localized: true but ${tagName}.ftl is missing. Create the file, set localized: false in furnace.json, or switch to sharedFtl.`,
50
54
  });
51
55
  }
52
56
  }
@@ -37,10 +37,13 @@ function buildOverrideVersionDriftIssues(config, currentVersion, tagName) {
37
37
  export async function validateComponent(componentDir, tagName, type, config, root, options) {
38
38
  const issues = [];
39
39
  // Pass the matching custom config so structure validation can enforce
40
- // the .ftl-when-localized invariant. Non-custom validations ignore the
40
+ // the .ftl-when-localized invariant and accessibility validation can
41
+ // recognize wrapper-over-native components (via `composes` or an
42
+ // explicit `keyboardCovered` opt-out). Non-custom validations ignore the
41
43
  // parameter, so this is a no-op for stock and override components.
42
- issues.push(...(await validateStructure(componentDir, tagName, type, type === 'custom' ? config?.custom[tagName] : undefined)));
43
- issues.push(...(await validateAccessibility(componentDir, tagName)));
44
+ const customConfigForTag = type === 'custom' ? config?.custom[tagName] : undefined;
45
+ issues.push(...(await validateStructure(componentDir, tagName, type, customConfigForTag)));
46
+ issues.push(...(await validateAccessibility(componentDir, tagName, customConfigForTag)));
44
47
  issues.push(...(await validateCompatibility(componentDir, tagName, type, config, root)));
45
48
  if (root && config && type === 'override') {
46
49
  const forgeConfig = await loadConfig(root);
@@ -27,3 +27,47 @@ export interface BuildArtifactCheck {
27
27
  export declare function hasBuildArtifacts(engineDir: string): Promise<BuildArtifactCheck>;
28
28
  /** Builds a user-facing explanation when detected build artifacts belong to another workspace. */
29
29
  export declare function buildArtifactMismatchMessage(engineDir: string, buildCheck: BuildArtifactCheck, commandName: string): string | undefined;
30
+ /**
31
+ * Outcome of an in-place mozinfo.json rewrite attempt. A successful rewrite
32
+ * returns the paths written; a refused rewrite returns a human-readable
33
+ * reason so the build flow can surface it alongside the original mismatch
34
+ * message before falling back to the clean-rebuild instruction.
35
+ */
36
+ export interface MozinfoRewriteResult {
37
+ /** Whether mozinfo.json was patched in place. */
38
+ rewritten: boolean;
39
+ /** Reason the rewrite was refused (populated when `rewritten === false`). */
40
+ reason?: string;
41
+ /** New `topsrcdir` value written to disk (populated on success). */
42
+ newTopsrcdir?: string;
43
+ /** New `topobjdir` value written to disk (populated on success). */
44
+ newTopobjdir?: string;
45
+ /** New `mozconfig` value written to disk (populated on success when it lived inside topsrcdir). */
46
+ newMozconfig?: string;
47
+ }
48
+ /**
49
+ * Safe-relocation rewriter for mozinfo.json under the active obj-* tree.
50
+ *
51
+ * Firefox build artefacts bake the topsrcdir into many generated files
52
+ * (Makefiles, config.status, backend.mk, .deps dependency files — anything
53
+ * produced by `mach configure`). A fresh `mach configure` rebuilds those
54
+ * from the top, so the rewriter only needs to patch the one file `mach`
55
+ * reads to learn where its checkout actually lives. Once mozinfo.json
56
+ * agrees with the on-disk layout, `mach configure` regenerates the rest.
57
+ *
58
+ * Safety rules — the rewrite is refused when any of them are violated:
59
+ * - `topsrcdir` and `topobjdir` must both be present and non-empty.
60
+ * - `topobjdir` must resolve to `<topsrcdir>/<objDir>`; a non-in-tree
61
+ * objdir means the previous workspace was configured differently,
62
+ * so a blind prefix-rewrite could point mach at the wrong tree.
63
+ * - The computed new `topobjdir` must be `<engineDir>/<objDir>`; if it
64
+ * is not, the objDir name itself changed and we cannot prove safety.
65
+ *
66
+ * When any rule trips, the caller should fall back to the clean-rebuild
67
+ * instruction — that's always a correct (if expensive) recovery path.
68
+ *
69
+ * @param engineDir Absolute path to the current engine checkout.
70
+ * @param objDir Name of the obj-* directory to rewrite against.
71
+ * @returns Result object; callers inspect `rewritten` and surface `reason`.
72
+ */
73
+ export declare function attemptMozinfoRewrite(engineDir: string, objDir: string): Promise<MozinfoRewriteResult>;
@@ -1,8 +1,8 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { readdir } from 'node:fs/promises';
3
- import { join, resolve } from 'node:path';
3
+ import { join, relative, resolve, sep } from 'node:path';
4
4
  import { toError } from '../utils/errors.js';
5
- import { pathExists, readJson } from '../utils/fs.js';
5
+ import { pathExists, readJson, writeJson } from '../utils/fs.js';
6
6
  import { verbose } from '../utils/logger.js';
7
7
  import { isObject, isString } from '../utils/validation.js';
8
8
  function validateBuildMozinfo(data) {
@@ -112,6 +112,107 @@ export function buildArtifactMismatchMessage(engineDir, buildCheck, commandName)
112
112
  }
113
113
  return (`${commandName} cannot use copied or relocated build artifacts whose metadata still points at a different Firefox workspace.\n\n` +
114
114
  `${details.join('\n')}\n\n` +
115
- 'Delete the stale obj-* directory in this workspace and run "fireforge build" again so mach regenerates build metadata for the current checkout.');
115
+ 'Delete the stale obj-* directory in this workspace and run "fireforge build" again so mach regenerates build metadata for the current checkout.\n' +
116
+ 'If the workspace was simply moved (same tree, different prefix), "fireforge build --rewrite-mozinfo" will patch mozinfo.json paths in place and run mach configure instead of scrubbing the whole tree.');
117
+ }
118
+ /**
119
+ * Safe-relocation rewriter for mozinfo.json under the active obj-* tree.
120
+ *
121
+ * Firefox build artefacts bake the topsrcdir into many generated files
122
+ * (Makefiles, config.status, backend.mk, .deps dependency files — anything
123
+ * produced by `mach configure`). A fresh `mach configure` rebuilds those
124
+ * from the top, so the rewriter only needs to patch the one file `mach`
125
+ * reads to learn where its checkout actually lives. Once mozinfo.json
126
+ * agrees with the on-disk layout, `mach configure` regenerates the rest.
127
+ *
128
+ * Safety rules — the rewrite is refused when any of them are violated:
129
+ * - `topsrcdir` and `topobjdir` must both be present and non-empty.
130
+ * - `topobjdir` must resolve to `<topsrcdir>/<objDir>`; a non-in-tree
131
+ * objdir means the previous workspace was configured differently,
132
+ * so a blind prefix-rewrite could point mach at the wrong tree.
133
+ * - The computed new `topobjdir` must be `<engineDir>/<objDir>`; if it
134
+ * is not, the objDir name itself changed and we cannot prove safety.
135
+ *
136
+ * When any rule trips, the caller should fall back to the clean-rebuild
137
+ * instruction — that's always a correct (if expensive) recovery path.
138
+ *
139
+ * @param engineDir Absolute path to the current engine checkout.
140
+ * @param objDir Name of the obj-* directory to rewrite against.
141
+ * @returns Result object; callers inspect `rewritten` and surface `reason`.
142
+ */
143
+ export async function attemptMozinfoRewrite(engineDir, objDir) {
144
+ const mozinfoPath = join(engineDir, objDir, 'mozinfo.json');
145
+ if (!(await pathExists(mozinfoPath))) {
146
+ return { rewritten: false, reason: 'mozinfo.json not found in obj directory' };
147
+ }
148
+ let raw;
149
+ try {
150
+ raw = await readJson(mozinfoPath);
151
+ }
152
+ catch (error) {
153
+ return { rewritten: false, reason: `mozinfo.json is unreadable: ${toError(error).message}` };
154
+ }
155
+ if (!isObject(raw)) {
156
+ return { rewritten: false, reason: 'mozinfo.json is not a JSON object' };
157
+ }
158
+ let mozinfo;
159
+ try {
160
+ mozinfo = validateBuildMozinfo(raw);
161
+ }
162
+ catch (error) {
163
+ return { rewritten: false, reason: toError(error).message };
164
+ }
165
+ const oldSrc = mozinfo.topsrcdir;
166
+ const oldObj = mozinfo.topobjdir;
167
+ if (!oldSrc || !oldObj) {
168
+ return {
169
+ rewritten: false,
170
+ reason: 'mozinfo.json is missing topsrcdir or topobjdir; cannot rewrite safely',
171
+ };
172
+ }
173
+ const oldSrcResolved = resolve(oldSrc);
174
+ const oldObjResolved = resolve(oldObj);
175
+ const insideTree = oldObjResolved === oldSrcResolved ||
176
+ oldObjResolved.startsWith(oldSrcResolved + sep) ||
177
+ oldObjResolved.startsWith(oldSrcResolved + '/');
178
+ if (!insideTree) {
179
+ return {
180
+ rewritten: false,
181
+ reason: `topobjdir (${oldObjResolved}) is not inside topsrcdir (${oldSrcResolved}) — rewrite would change workspace layout`,
182
+ };
183
+ }
184
+ const relativeObj = relative(oldSrcResolved, oldObjResolved).split(sep).join('/');
185
+ if (relativeObj !== objDir) {
186
+ return {
187
+ rewritten: false,
188
+ reason: `mozinfo objdir "${relativeObj}" does not match detected objdir "${objDir}" — rewrite would change the obj directory name`,
189
+ };
190
+ }
191
+ const newSrc = resolve(engineDir);
192
+ const newObj = resolve(engineDir, objDir);
193
+ const patched = { ...raw, topsrcdir: newSrc, topobjdir: newObj };
194
+ let newMozconfig;
195
+ if (mozinfo.mozconfig) {
196
+ const oldMozconfigResolved = resolve(mozinfo.mozconfig);
197
+ if (oldMozconfigResolved === oldSrcResolved ||
198
+ oldMozconfigResolved.startsWith(oldSrcResolved + sep) ||
199
+ oldMozconfigResolved.startsWith(oldSrcResolved + '/')) {
200
+ const rel = relative(oldSrcResolved, oldMozconfigResolved);
201
+ newMozconfig = resolve(newSrc, rel);
202
+ patched['mozconfig'] = newMozconfig;
203
+ }
204
+ // A mozconfig living outside the old topsrcdir is left as-is — it
205
+ // probably points at a shared configuration file the user kept in
206
+ // place across the relocation. A relocated checkout that also moved
207
+ // its mozconfig will still fail configure; operator can re-point
208
+ // with `MOZCONFIG=…` or run a full clean rebuild.
209
+ }
210
+ await writeJson(mozinfoPath, patched);
211
+ return {
212
+ rewritten: true,
213
+ newTopsrcdir: newSrc,
214
+ newTopobjdir: newObj,
215
+ ...(newMozconfig ? { newMozconfig } : {}),
216
+ };
116
217
  }
117
218
  //# sourceMappingURL=mach-build-artifacts.js.map
@@ -1,4 +1,5 @@
1
- export { type BuildArtifactCheck, buildArtifactMismatchMessage, hasBuildArtifacts, } from './mach-build-artifacts.js';
1
+ import { type SmokeLineCallback, type SmokeRunResult } from '../utils/process.js';
2
+ export { attemptMozinfoRewrite, type BuildArtifactCheck, buildArtifactMismatchMessage, hasBuildArtifacts, type MozinfoRewriteResult, } from './mach-build-artifacts.js';
2
3
  export { generateMozconfig, type MozconfigVariables } from './mach-mozconfig.js';
3
4
  export { ensurePython, resetResolvedPython } from './mach-python.js';
4
5
  /**
@@ -79,6 +80,31 @@ export declare function buildUI(engineDir: string): Promise<number>;
79
80
  * @returns Exit code
80
81
  */
81
82
  export declare function run(engineDir: string, args?: string[]): Promise<number>;
83
+ /**
84
+ * Options for {@link runMachSmoke}.
85
+ */
86
+ export interface RunMachSmokeOptions {
87
+ env?: Record<string, string>;
88
+ smokeTimeoutMs: number;
89
+ killGraceMs?: number;
90
+ onStdoutLine?: SmokeLineCallback;
91
+ onStderrLine?: SmokeLineCallback;
92
+ mirror?: {
93
+ stdout?: NodeJS.WritableStream;
94
+ stderr?: NodeJS.WritableStream;
95
+ };
96
+ }
97
+ /**
98
+ * Launches `mach run` under the smoke-run wrapper: streams line-by-line,
99
+ * enforces a deadline by SIGTERMing the whole process group, and returns
100
+ * the captured output alongside a `timedOut` flag.
101
+ *
102
+ * Unlike {@link run}, this variant does NOT inherit stdio. The child
103
+ * stdout/stderr are piped back through the line callbacks so the caller
104
+ * can scan for `JavaScript error:` / `console.error:` without coupling
105
+ * the runner to chrome-specific pattern logic.
106
+ */
107
+ export declare function runMachSmoke(args: string[], engineDir: string, options: RunMachSmokeOptions): Promise<SmokeRunResult>;
82
108
  /**
83
109
  * Creates a distribution package.
84
110
  * @param engineDir - Path to the engine directory
@@ -3,11 +3,11 @@ import { join } from 'node:path';
3
3
  import { MachNotFoundError } from '../errors/build.js';
4
4
  import { pathExists } from '../utils/fs.js';
5
5
  import { warn } from '../utils/logger.js';
6
- import { exec, execInherit, execInheritCapture, execStream } from '../utils/process.js';
6
+ import { exec, execInherit, execInheritCapture, execSmokeRun, execStream, } from '../utils/process.js';
7
7
  import { explainMachError } from './mach-error-hints.js';
8
8
  import { getPython } from './mach-python.js';
9
9
  // Re-export sub-modules so existing `from './mach.js'` imports keep working.
10
- export { buildArtifactMismatchMessage, hasBuildArtifacts, } from './mach-build-artifacts.js';
10
+ export { attemptMozinfoRewrite, buildArtifactMismatchMessage, hasBuildArtifacts, } from './mach-build-artifacts.js';
11
11
  export { generateMozconfig } from './mach-mozconfig.js';
12
12
  export { ensurePython, resetResolvedPython } from './mach-python.js';
13
13
  /**
@@ -165,6 +165,30 @@ export async function buildUI(engineDir) {
165
165
  export async function run(engineDir, args = []) {
166
166
  return runMach(['run', ...args], engineDir, { inherit: true });
167
167
  }
168
+ /**
169
+ * Launches `mach run` under the smoke-run wrapper: streams line-by-line,
170
+ * enforces a deadline by SIGTERMing the whole process group, and returns
171
+ * the captured output alongside a `timedOut` flag.
172
+ *
173
+ * Unlike {@link run}, this variant does NOT inherit stdio. The child
174
+ * stdout/stderr are piped back through the line callbacks so the caller
175
+ * can scan for `JavaScript error:` / `console.error:` without coupling
176
+ * the runner to chrome-specific pattern logic.
177
+ */
178
+ export async function runMachSmoke(args, engineDir, options) {
179
+ const python = await getPython(engineDir);
180
+ await ensureMach(engineDir);
181
+ const machPath = join(engineDir, 'mach');
182
+ return execSmokeRun(python, [machPath, ...args], {
183
+ cwd: engineDir,
184
+ ...(options.env ? { env: options.env } : {}),
185
+ smokeTimeoutMs: options.smokeTimeoutMs,
186
+ ...(options.killGraceMs !== undefined ? { killGraceMs: options.killGraceMs } : {}),
187
+ ...(options.onStdoutLine ? { onStdoutLine: options.onStdoutLine } : {}),
188
+ ...(options.onStderrLine ? { onStderrLine: options.onStderrLine } : {}),
189
+ ...(options.mirror ? { mirror: options.mirror } : {}),
190
+ });
191
+ }
168
192
  /**
169
193
  * Creates a distribution package.
170
194
  * @param engineDir - Path to the engine directory
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Structural rules for the `sharedFtl` field on a custom component.
3
+ *
4
+ * Both the `--shared-ftl` CLI flag path (`furnace create`) and the
5
+ * furnace.json parser apply the same rules — extracting them here
6
+ * avoids drift between the two entry points and lets the `.mjs`
7
+ * template generator assume the value is safe to interpolate verbatim.
8
+ */
9
+ /**
10
+ * Outcome of {@link validateSharedFtl}. `ok: true` carries the trimmed
11
+ * (operator-safe) value; `ok: false` carries a human-readable message
12
+ * suitable for throwing as a `FurnaceError` or `InvalidArgumentError`.
13
+ */
14
+ export type SharedFtlValidation = {
15
+ ok: true;
16
+ value: string;
17
+ } | {
18
+ ok: false;
19
+ reason: string;
20
+ };
21
+ /**
22
+ * Validates a candidate `sharedFtl` value. Returns the trimmed value
23
+ * when well-formed, or a structured reason when not. Callers throw the
24
+ * error type appropriate to their context (CLI vs config parser).
25
+ */
26
+ export declare function validateSharedFtl(raw: unknown, context: {
27
+ localized: boolean;
28
+ }): SharedFtlValidation;
@@ -0,0 +1,42 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Structural rules for the `sharedFtl` field on a custom component.
4
+ *
5
+ * Both the `--shared-ftl` CLI flag path (`furnace create`) and the
6
+ * furnace.json parser apply the same rules — extracting them here
7
+ * avoids drift between the two entry points and lets the `.mjs`
8
+ * template generator assume the value is safe to interpolate verbatim.
9
+ */
10
+ /**
11
+ * Characters that must not appear in `sharedFtl`:
12
+ * - Backticks close the MJS template literal the scaffold writes.
13
+ * - `\` is a path-escape we do not want to interpret.
14
+ * - `${` opens a template expression and turns the FTL path into
15
+ * executable code.
16
+ */
17
+ const UNSAFE_CHARS = /[`\\]|\$\{/;
18
+ /**
19
+ * Validates a candidate `sharedFtl` value. Returns the trimmed value
20
+ * when well-formed, or a structured reason when not. Callers throw the
21
+ * error type appropriate to their context (CLI vs config parser).
22
+ */
23
+ export function validateSharedFtl(raw, context) {
24
+ if (typeof raw !== 'string') {
25
+ return { ok: false, reason: 'must be a string when set' };
26
+ }
27
+ const value = raw.trim();
28
+ if (value.length === 0) {
29
+ return { ok: false, reason: 'must not be empty' };
30
+ }
31
+ if (UNSAFE_CHARS.test(value)) {
32
+ return {
33
+ ok: false,
34
+ reason: 'must not contain backticks, backslashes, or ${ (would break the generated .mjs)',
35
+ };
36
+ }
37
+ if (!context.localized) {
38
+ return { ok: false, reason: 'requires localized to be true' };
39
+ }
40
+ return { ok: true, value };
41
+ }
42
+ //# sourceMappingURL=shared-ftl.js.map