@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.
- package/CHANGELOG.md +78 -0
- package/README.md +158 -15
- package/dist/src/commands/build.js +60 -3
- package/dist/src/commands/furnace/chrome-doc-templates.d.ts +17 -0
- package/dist/src/commands/furnace/chrome-doc-templates.js +18 -0
- package/dist/src/commands/furnace/chrome-doc-tests.d.ts +23 -0
- package/dist/src/commands/furnace/chrome-doc-tests.js +120 -0
- package/dist/src/commands/furnace/chrome-doc.d.ts +11 -0
- package/dist/src/commands/furnace/chrome-doc.js +37 -4
- package/dist/src/commands/furnace/create-dry-run.d.ts +38 -0
- package/dist/src/commands/furnace/create-dry-run.js +100 -0
- package/dist/src/commands/furnace/create-features.d.ts +24 -0
- package/dist/src/commands/furnace/create-features.js +56 -0
- package/dist/src/commands/furnace/create-templates.d.ts +9 -5
- package/dist/src/commands/furnace/create-templates.js +28 -6
- package/dist/src/commands/furnace/create.js +62 -63
- package/dist/src/commands/furnace/index.js +4 -1
- package/dist/src/commands/lint.d.ts +17 -2
- package/dist/src/commands/lint.js +25 -2
- package/dist/src/commands/register.d.ts +1 -1
- package/dist/src/commands/register.js +30 -7
- package/dist/src/commands/run.d.ts +15 -1
- package/dist/src/commands/run.js +202 -7
- package/dist/src/commands/test.js +113 -3
- package/dist/src/core/build-audit-registration.d.ts +80 -0
- package/dist/src/core/build-audit-registration.js +187 -0
- package/dist/src/core/build-audit-transforms.d.ts +23 -0
- package/dist/src/core/build-audit-transforms.js +94 -0
- package/dist/src/core/build-audit.js +107 -7
- package/dist/src/core/furnace-apply-ftl.d.ts +5 -3
- package/dist/src/core/furnace-apply-ftl.js +6 -2
- package/dist/src/core/furnace-apply-helpers.js +14 -4
- package/dist/src/core/furnace-config-custom.d.ts +14 -0
- package/dist/src/core/furnace-config-custom.js +64 -0
- package/dist/src/core/furnace-config.js +2 -39
- package/dist/src/core/furnace-validate-accessibility.d.ts +9 -2
- package/dist/src/core/furnace-validate-accessibility.js +17 -3
- package/dist/src/core/furnace-validate-helpers.d.ts +13 -1
- package/dist/src/core/furnace-validate-helpers.js +19 -0
- package/dist/src/core/furnace-validate-registration.d.ts +6 -4
- package/dist/src/core/furnace-validate-registration.js +66 -6
- package/dist/src/core/furnace-validate-structure.js +6 -2
- package/dist/src/core/furnace-validate.js +6 -3
- package/dist/src/core/mach-build-artifacts.d.ts +44 -0
- package/dist/src/core/mach-build-artifacts.js +104 -3
- package/dist/src/core/mach.d.ts +27 -1
- package/dist/src/core/mach.js +26 -2
- package/dist/src/core/shared-ftl.d.ts +28 -0
- package/dist/src/core/shared-ftl.js +42 -0
- package/dist/src/core/smoke-patterns.d.ts +45 -0
- package/dist/src/core/smoke-patterns.js +100 -0
- package/dist/src/core/test-stale-check.d.ts +42 -0
- package/dist/src/core/test-stale-check.js +114 -0
- package/dist/src/core/xpcshell-appdir.d.ts +143 -0
- package/dist/src/core/xpcshell-appdir.js +273 -0
- package/dist/src/errors/codes.d.ts +13 -0
- package/dist/src/errors/codes.js +13 -0
- package/dist/src/errors/run.d.ts +16 -0
- package/dist/src/errors/run.js +22 -0
- package/dist/src/types/commands/options.d.ts +64 -0
- package/dist/src/types/furnace.d.ts +39 -0
- package/dist/src/utils/process.d.ts +63 -0
- package/dist/src/utils/process.js +122 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
43
|
-
issues.push(...(await
|
|
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
|
package/dist/src/core/mach.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
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
|
package/dist/src/core/mach.js
CHANGED
|
@@ -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
|