@hominis/fireforge 0.21.0 → 0.21.1

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 CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  ### Hardening
11
11
 
12
+ - **Eval 0.21.0 release-gate fixes.** `export --dry-run` now performs the same supersede and cross-patch ownership checks as real export before calling a plan safe; `furnace deploy --dry-run` validates successful custom-component plans against projected jar.mn registrations; generated Furnace components and browser-chrome test scaffolds are strict-checkJs and lazy-custom-element ready; chrome-doc packaging xpcshell tests no longer trip component-orphan validation; supported optional config keys such as `firefox.sha256` print `(not set)` when absent; and Furnace manifest writes preserve existing top-level/component ordering while appending new entries predictably.
12
13
  - **Override removal demotes back to stock.** Removing a Furnace override restores engine files, deletes the override workspace, clears override checksums, and re-adds the component to `stock` tracking instead of dropping it from `furnace.json`. Optional Furnace config fields, including `platformPrefixes`, are preserved across the write.
13
14
  - **Rename updates browser-chrome test bodies.** `furnace rename` now rewrites generated browser-chrome mochitest contents as well as filenames and `browser.toml`, preventing stale `waitForElement("<old>")` references after a component rename.
14
15
  - **UI build preflight is stricter.** `fireforge build --ui` now refuses before `mach build faster` when the current objdir lacks a completed launchable bundle, guiding fresh imports and partial builds through a full `fireforge build` first.
@@ -99,6 +99,11 @@ export async function configCommand(projectRoot, key, value, options = {}) {
99
99
  const rawConfig = await loadRawConfigDocument(projectRoot);
100
100
  const currentValue = getNestedValue(rawConfig, key);
101
101
  if (currentValue === undefined) {
102
+ if (SUPPORTED_CONFIG_PATHS.includes(key)) {
103
+ info(`${key} = ${formatValue(currentValue)}`);
104
+ outro('');
105
+ return;
106
+ }
102
107
  throw new InvalidArgumentError(`Unknown config key: ${key}`);
103
108
  }
104
109
  else {
@@ -267,6 +267,7 @@ export async function exportAllCommand(projectRoot, options = {}) {
267
267
  filesAffected,
268
268
  sourceEsrVersion: config.firefox.version,
269
269
  explicitSupersede: options.supersede === true,
270
+ allowOverlap: options.allowOverlap === true,
270
271
  });
271
272
  outro('Dry run complete — no changes made');
272
273
  return;
@@ -82,6 +82,7 @@ export interface DryRunPreviewInput {
82
82
  filesAffected: string[];
83
83
  sourceEsrVersion: string;
84
84
  explicitSupersede: boolean;
85
+ allowOverlap: boolean;
85
86
  /** Optional `PatchMetadata.tier` opt-in carried from the CLI. */
86
87
  tier?: 'branding';
87
88
  /** Optional `PatchMetadata.lintIgnore` carried from the CLI. */
@@ -13,10 +13,11 @@ import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFi
13
13
  import { withPatchDirectoryLock } from '../core/patch-lock.js';
14
14
  import { addPatchToManifest, loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, savePatchesManifest, } from '../core/patch-manifest.js';
15
15
  import { extractNewFileContentFromDiff } from '../core/patch-transform.js';
16
- import { InvalidArgumentError } from '../errors/base.js';
16
+ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
17
17
  import { toError } from '../utils/errors.js';
18
18
  import { pathExists, readText, removeFile, writeText } from '../utils/fs.js';
19
19
  import { info, warn } from '../utils/logger.js';
20
+ import { findPartialOwnershipOverlap } from './export-shared.js';
20
21
  function buildFilenameForPlacement(category, name, order, width) {
21
22
  const padded = String(order).padStart(Math.max(3, width), '0');
22
23
  return `${padded}-${category}-${sanitizeName(name)}.patch`;
@@ -296,6 +297,11 @@ export async function commitPlacementExport(input) {
296
297
  */
297
298
  export async function renderDryRunPreview(input) {
298
299
  const supersedeDetails = await findAllPatchesForFilesWithDetails(input.patchesDir, input.filesAffected);
300
+ const supersedingFilenames = new Set(supersedeDetails.map((detail) => detail.patch.filename));
301
+ const manifest = await loadPatchesManifest(input.patchesDir);
302
+ const overlap = manifest !== null
303
+ ? findPartialOwnershipOverlap(manifest, input.filesAffected, supersedingFilenames)
304
+ : new Map();
299
305
  const plan = await planExport({
300
306
  patchesDir: input.patchesDir,
301
307
  category: input.category,
@@ -329,5 +335,19 @@ export async function renderDryRunPreview(input) {
329
335
  else {
330
336
  info('\n[dry-run] No patches would be superseded.');
331
337
  }
338
+ if (overlap.size > 0) {
339
+ const entries = [...overlap.entries()].sort(([a], [b]) => a.localeCompare(b));
340
+ warn(`\n[dry-run] Would create cross-patch ownership overlap on ${String(entries.length)} file${entries.length === 1 ? '' : 's'}:`);
341
+ for (const [file, owners] of entries) {
342
+ warn(` - ${file} already claimed by: ${owners.join(', ')}`);
343
+ }
344
+ warn('The real export would leave the queue verify-failing. Repartition ownership with `fireforge re-export --files <paths> <existing-patch>` before exporting, or pass --allow-overlap to acknowledge the conflict.');
345
+ if (!input.allowOverlap) {
346
+ throw new GeneralError('Dry-run detected cross-patch ownership overlap. Pass --allow-overlap to preview the acknowledged conflict, or repartition ownership via `fireforge re-export --files`.');
347
+ }
348
+ }
349
+ else {
350
+ info('[dry-run] No cross-patch ownership overlap detected.');
351
+ }
332
352
  }
333
353
  //# sourceMappingURL=export-flow.js.map
@@ -233,6 +233,7 @@ export async function exportCommand(projectRoot, files, options) {
233
233
  filesAffected,
234
234
  sourceEsrVersion: config.firefox.version,
235
235
  explicitSupersede: options.supersede === true,
236
+ allowOverlap: options.allowOverlap === true,
236
237
  ...(options.tier !== undefined ? { tier: options.tier } : {}),
237
238
  ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
238
239
  ? { lintIgnore: options.lintIgnore }
@@ -39,12 +39,18 @@ window.MozXULElement?.insertFTLIfNeeded("${ftlPath}");
39
39
  ? `
40
40
  connectedCallback() {
41
41
  super.connectedCallback();
42
- this.ownerDocument.l10n?.connectRoot(this.shadowRoot);
42
+ const { shadowRoot } = this;
43
+ if (shadowRoot) {
44
+ this.ownerDocument.l10n?.connectRoot(shadowRoot);
45
+ }
43
46
  }
44
47
 
45
48
  disconnectedCallback() {
46
49
  super.disconnectedCallback();
47
- this.ownerDocument.l10n?.disconnectRoot(this.shadowRoot);
50
+ const { shadowRoot } = this;
51
+ if (shadowRoot) {
52
+ this.ownerDocument.l10n?.disconnectRoot(shadowRoot);
53
+ }
48
54
  }
49
55
  `
50
56
  : '';
@@ -59,6 +65,7 @@ ${ftlModulePreamble}
59
65
  * @tagname ${name}
60
66
  */
61
67
  class ${className} extends MozLitElement {
68
+ /** @type {Record<string, unknown>} */
62
69
  static properties = {};
63
70
 
64
71
  constructor() {
@@ -72,7 +79,7 @@ ${lifecycleHooks}
72
79
  \`;
73
80
  }
74
81
  }
75
- customElements.define("${name}", ${className});
82
+ customElements.define("${name}", /** @type {CustomElementConstructor} */ (${className}));
76
83
  `;
77
84
  }
78
85
  /** Generates the .css file content for a custom component. */
@@ -111,6 +111,7 @@ support-files = ["head.js"]
111
111
  * @returns {Promise<CustomElementConstructor>}
112
112
  */
113
113
  async function waitForElement(tag) {
114
+ document.createElement(tag);
114
115
  return customElements.whenDefined(tag);
115
116
  }
116
117
  `;
@@ -364,7 +364,7 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
364
364
  }
365
365
  const validateSpinner = spinner(isDryRun ? 'Validating (read-only)...' : 'Validating...');
366
366
  const failedComponents = getFailedComponentNames(result);
367
- const validation = await runDeployValidation(validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot);
367
+ const validation = await runDeployValidation(validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot, result.actions);
368
368
  if (validation.done)
369
369
  return;
370
370
  const { totalErrors, totalWarnings, componentCount, skippedValidationCount } = validation;
@@ -1,5 +1,5 @@
1
1
  import type { getFurnacePaths } from '../../core/furnace-config.js';
2
- import type { FurnaceConfig, ValidationIssue } from '../../types/furnace.js';
2
+ import type { DryRunAction, FurnaceConfig, ValidationIssue } from '../../types/furnace.js';
3
3
  import { type SpinnerHandle } from '../../utils/logger.js';
4
4
  /**
5
5
  * Displays validation issues and returns aggregated error and warning counts.
@@ -27,4 +27,4 @@ export type ValidationResult = {
27
27
  * @param projectRoot - Root directory of the project
28
28
  * @returns Validation counts, or `done: true` if the caller should early-return
29
29
  */
30
- export declare function runDeployValidation(validateSpinner: SpinnerHandle, name: string | undefined, config: FurnaceConfig, furnacePaths: ReturnType<typeof getFurnacePaths>, failedComponents: Set<string>, isDryRun: boolean, projectRoot: string): Promise<ValidationResult>;
30
+ export declare function runDeployValidation(validateSpinner: SpinnerHandle, name: string | undefined, config: FurnaceConfig, furnacePaths: ReturnType<typeof getFurnacePaths>, failedComponents: Set<string>, isDryRun: boolean, projectRoot: string, dryRunActions?: DryRunAction[]): Promise<ValidationResult>;
@@ -24,6 +24,18 @@ export function displayValidationIssues(issues) {
24
24
  }
25
25
  return [errors, warnings];
26
26
  }
27
+ function filterProjectedDryRunIssues(issues, actions) {
28
+ if (!actions || actions.length === 0)
29
+ return issues;
30
+ const plannedJarRegistrations = new Set(actions.filter((action) => action.action === 'register-jar').map((action) => action.component));
31
+ return issues.filter((issue) => {
32
+ if (plannedJarRegistrations.has(issue.component) &&
33
+ (issue.check === 'missing-jar-mn-mjs' || issue.check === 'missing-jar-mn-css')) {
34
+ return false;
35
+ }
36
+ return true;
37
+ });
38
+ }
27
39
  function resolveNamedValidationTarget(name, config, furnacePaths) {
28
40
  if (name in config.overrides) {
29
41
  return {
@@ -53,7 +65,7 @@ function resolveNamedValidationTarget(name, config, furnacePaths) {
53
65
  * @param projectRoot - Root directory of the project
54
66
  * @returns Validation counts, or `done: true` if the caller should early-return
55
67
  */
56
- export async function runDeployValidation(validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot) {
68
+ export async function runDeployValidation(validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot, dryRunActions) {
57
69
  let totalErrors = 0;
58
70
  let totalWarnings = 0;
59
71
  let componentCount = 0;
@@ -75,7 +87,8 @@ export async function runDeployValidation(validateSpinner, name, config, furnace
75
87
  validateSpinner.stop('Validation failed');
76
88
  throw new FurnaceError(`Component directory not found for "${name}".`, name);
77
89
  }
78
- const issues = await validateComponent(target.componentDir, name, target.type, config, projectRoot);
90
+ const rawIssues = await validateComponent(target.componentDir, name, target.type, config, projectRoot);
91
+ const issues = isDryRun ? filterProjectedDryRunIssues(rawIssues, dryRunActions) : rawIssues;
79
92
  componentCount = 1;
80
93
  validateSpinner.stop('Validation complete');
81
94
  if (issues.length === 0) {
@@ -96,11 +109,14 @@ export async function runDeployValidation(validateSpinner, name, config, furnace
96
109
  continue;
97
110
  }
98
111
  componentCount++;
99
- if (issues.length === 0) {
112
+ const projectedIssues = isDryRun
113
+ ? filterProjectedDryRunIssues(issues, dryRunActions)
114
+ : issues;
115
+ if (projectedIssues.length === 0) {
100
116
  success(`${componentName} — all checks passed`);
101
117
  }
102
118
  else {
103
- const [errors, warnings] = displayValidationIssues(issues);
119
+ const [errors, warnings] = displayValidationIssues(projectedIssues);
104
120
  totalErrors += errors;
105
121
  totalWarnings += warnings;
106
122
  }
@@ -0,0 +1,7 @@
1
+ import type { FurnaceConfig } from '../types/furnace.js';
2
+ /**
3
+ * Orders furnace.json output using the existing file as the primary key
4
+ * sequence, preserving unknown extension keys and appending newly supported
5
+ * fields only when needed.
6
+ */
7
+ export declare function orderFurnaceConfigForWrite(existing: Record<string, unknown> | undefined, config: FurnaceConfig): Record<string, unknown>;
@@ -0,0 +1,86 @@
1
+ import { isObject } from '../utils/validation.js';
2
+ const FURNACE_CONFIG_TOP_LEVEL_KEYS = new Set([
3
+ 'version',
4
+ 'componentPrefix',
5
+ 'tokenPrefix',
6
+ 'tokenAllowlist',
7
+ 'platformPrefixes',
8
+ 'runtimeVariables',
9
+ 'tokenHostDocuments',
10
+ 'ftlBasePath',
11
+ 'scanPaths',
12
+ 'stock',
13
+ 'overrides',
14
+ 'custom',
15
+ ]);
16
+ function orderObjectLikeExisting(existing, next) {
17
+ if (!existing)
18
+ return next;
19
+ const ordered = {};
20
+ for (const key of Object.keys(existing)) {
21
+ if (Object.hasOwn(next, key)) {
22
+ ordered[key] = next[key];
23
+ }
24
+ }
25
+ for (const key of Object.keys(next)) {
26
+ if (!Object.hasOwn(ordered, key)) {
27
+ ordered[key] = next[key];
28
+ }
29
+ }
30
+ return ordered;
31
+ }
32
+ function orderComponentMapLikeExisting(existing, next) {
33
+ if (!isObject(next))
34
+ return next;
35
+ if (!isObject(existing))
36
+ return next;
37
+ const ordered = {};
38
+ for (const key of Object.keys(existing)) {
39
+ if (Object.hasOwn(next, key)) {
40
+ const existingValue = existing[key];
41
+ const nextValue = next[key];
42
+ ordered[key] =
43
+ isObject(existingValue) && isObject(nextValue)
44
+ ? orderObjectLikeExisting(existingValue, nextValue)
45
+ : nextValue;
46
+ }
47
+ }
48
+ for (const key of Object.keys(next)) {
49
+ if (!Object.hasOwn(ordered, key)) {
50
+ ordered[key] = next[key];
51
+ }
52
+ }
53
+ return ordered;
54
+ }
55
+ /**
56
+ * Orders furnace.json output using the existing file as the primary key
57
+ * sequence, preserving unknown extension keys and appending newly supported
58
+ * fields only when needed.
59
+ */
60
+ export function orderFurnaceConfigForWrite(existing, config) {
61
+ const next = config;
62
+ if (!existing)
63
+ return next;
64
+ const ordered = {};
65
+ for (const key of Object.keys(existing)) {
66
+ if (key === 'overrides' || key === 'custom') {
67
+ ordered[key] = orderComponentMapLikeExisting(existing[key], next[key]);
68
+ }
69
+ else if (Object.hasOwn(next, key)) {
70
+ ordered[key] = next[key];
71
+ }
72
+ else if (!FURNACE_CONFIG_TOP_LEVEL_KEYS.has(key)) {
73
+ ordered[key] = existing[key];
74
+ }
75
+ }
76
+ for (const key of Object.keys(next)) {
77
+ if (Object.hasOwn(ordered, key))
78
+ continue;
79
+ ordered[key] =
80
+ key === 'overrides' || key === 'custom'
81
+ ? orderComponentMapLikeExisting(existing[key], next[key])
82
+ : next[key];
83
+ }
84
+ return ordered;
85
+ }
86
+ //# sourceMappingURL=furnace-config-order.js.map
@@ -8,6 +8,7 @@ import { isObject, isString } from '../utils/validation.js';
8
8
  import { FIREFORGE_DIR } from './config.js';
9
9
  import { parseStringArray } from './furnace-config-array-utils.js';
10
10
  import { parseCustomConfig } from './furnace-config-custom.js';
11
+ import { orderFurnaceConfigForWrite } from './furnace-config-order.js';
11
12
  import { validateRuntimeVariables, validateTokenHostDocuments } from './furnace-config-tokens.js';
12
13
  import { resolveFtlDir } from './furnace-constants.js';
13
14
  import { detectComposesCycles, validateComposesReferences } from './furnace-graph-utils.js';
@@ -410,7 +411,18 @@ export async function loadFurnaceConfig(root) {
410
411
  */
411
412
  export async function writeFurnaceConfig(root, config) {
412
413
  const paths = getFurnacePaths(root);
413
- await writeJson(paths.furnaceConfig, config);
414
+ let existing;
415
+ if (await pathExists(paths.furnaceConfig)) {
416
+ try {
417
+ const raw = await readJson(paths.furnaceConfig);
418
+ if (isObject(raw))
419
+ existing = raw;
420
+ }
421
+ catch {
422
+ existing = undefined;
423
+ }
424
+ }
425
+ await writeJson(paths.furnaceConfig, orderFurnaceConfigForWrite(existing, config));
414
426
  }
415
427
  /**
416
428
  * Stamps every override's `baseVersion` to the supplied version. Used by
@@ -233,6 +233,9 @@ async function findOrphanXpcshellScaffolds(root, config) {
233
233
  for (const entry of entries) {
234
234
  if (known.has(entry))
235
235
  continue;
236
+ const chromeDocPackagingTest = join(parentAbs, entry, `test_${entry}_packaging.js`);
237
+ if (await pathExists(chromeDocPackagingTest))
238
+ continue;
236
239
  issues.push({
237
240
  component: entry,
238
241
  severity: 'error',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",