@hominis/fireforge 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/src/commands/patch/split-plan.d.ts +18 -2
  3. package/dist/src/commands/patch/split-plan.js +90 -16
  4. package/dist/src/commands/patch/split.js +12 -3
  5. package/dist/src/commands/token.js +12 -1
  6. package/dist/src/commands/typecheck.js +35 -0
  7. package/dist/src/core/build-prepare.js +23 -3
  8. package/dist/src/core/config-validate.js +26 -0
  9. package/dist/src/core/furnace-apply-dry-run.d.ts +17 -0
  10. package/dist/src/core/furnace-apply-dry-run.js +105 -0
  11. package/dist/src/core/furnace-apply-ftl.d.ts +12 -0
  12. package/dist/src/core/furnace-apply-ftl.js +97 -1
  13. package/dist/src/core/furnace-apply-helpers.js +10 -80
  14. package/dist/src/core/mach-resource-shim.d.ts +21 -0
  15. package/dist/src/core/mach-resource-shim.js +92 -0
  16. package/dist/src/core/mach.js +9 -2
  17. package/dist/src/core/manifest-helpers.js +29 -4
  18. package/dist/src/core/patch-lint-cross.d.ts +31 -0
  19. package/dist/src/core/patch-lint-cross.js +83 -63
  20. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  21. package/dist/src/core/patch-lint-reexports.js +1 -1
  22. package/dist/src/core/test-harness-crash.d.ts +6 -3
  23. package/dist/src/core/test-harness-crash.js +32 -4
  24. package/dist/src/core/token-dark-mode.d.ts +9 -0
  25. package/dist/src/core/token-dark-mode.js +1 -1
  26. package/dist/src/core/token-docs.d.ts +32 -0
  27. package/dist/src/core/token-docs.js +101 -0
  28. package/dist/src/core/token-manager.d.ts +8 -0
  29. package/dist/src/core/token-manager.js +77 -95
  30. package/dist/src/core/token-variant.d.ts +39 -0
  31. package/dist/src/core/token-variant.js +141 -0
  32. package/dist/src/core/typecheck.js +56 -28
  33. package/dist/src/types/commands/options.d.ts +5 -0
  34. package/dist/src/types/config.d.ts +13 -0
  35. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.33.0
4
+
5
+ - Fixed `furnace deploy`/`apply` to prune a dangling per-widget locale `jar.mn` entry for a `localized: true` widget that uses the `sharedFtl` browser-bundle convention. A stale `locale/@AB_CD@/toolkit/global/<name>.ftl` line (written by an older FireForge) pointed at a `.ftl` that never exists, so `mach build` failed hard (`Cannot find toolkit/global/<name>.ftl`) and blocked every build; apply now drops that per-widget line idempotently while leaving the shared bundle's own line untouched.
6
+ - Added `fireforge token add --variant '[data-skin=precision]'` (also `[data-private]`) to author a declaration inside an attribute-keyed `:root[<attr>]` block, creating the block if absent or appending to it if present — so skin/state token overrides no longer have to be hand-edited. The selector is validated and quoted-normalized, variant overrides are CSS-only (the base token owns its docs row), and `token coverage` still accepts the result.
7
+ - Fixed `fireforge register` to sort `EXTRA_JS_MODULES` (and the sibling sorted moz.build / jar.mn list helpers) **case-insensitively**, matching mozbuild's `UnsortedError` rule — so e.g. `AppearanceController.sys.mjs` lands before `AppMenuIntegration.sys.mjs` (`appe` < `appm`) instead of in raw byte order, which made `mach configure` abort. The build wrapper now runs `mach configure` with output capture and surfaces the underlying mozbuild error text (e.g. `UnsortedError`) instead of a bare "configure failed with exit code N".
8
+ - Changed `fireforge typecheck` to staleness-check and regenerate the Furnace-managed jsconfig (`furnace.json` → `typecheckJsconfig`) before running, using the same reconciler `furnace deploy`/`sync` use — so a stale generated `compilerOptions.paths` shim no longer reports phantom type errors in files the session never touched.
9
+ - Fixed `fireforge patch split --dry-run` projected lint to model the forward edge a split introduces into the freshly-created patch: it auto-declares the staged forward-import (the new patch's owner is known) so the dry-run diagnostics match the real `lint --per-patch --max-warnings 0` gate, and persists the declaration on commit so a sound split reads as sound instead of as a cross-patch "has no exported member" error.
10
+ - Fixed `fireforge test <one xpcshell .js>` to recognize the suite-specific xpcshell result-summary block (`TEST_END`, `Ran N checks`, `Unexpected results: 0`) as a valid execution signal. The post-run guard keyed only on `TEST-START` lines — which the xpcshell dispatch never prints — so a passing single-file xpcshell run was reported as "finished without starting any of the requested tests" and exited 1; it now exits 0 while a failing summary still flows to the failure diagnosis.
11
+ - Changed `fireforge build` / `build --ui` to inject a resource-monitor degrade shim (a `sitecustomize.py` on the mach subprocess `PYTHONPATH`) so a host `psutil.virtual_memory()` failure (`host_statistics64(HOST_VM_INFO64) syscall failed`) degrades to a non-fatal warning instead of aborting `mach build` / `mach build faster` in `start_resource_recording`. The build path no longer depends on which mach entry was used.
12
+ - Added per-project `typecheck.projectOverrides` so a project can override or opt out of the shared `extraShim` (`null` opts out, a path overrides). The composed shim is now built per project rather than injected identically everywhere, so a project that narrows `lib`/`types` (e.g. `lib: ["ES2024","DOM"]`) is not forced to absorb another project's Gecko declaration libraries (Element/Node identity splits, nsIPrincipal mismatch).
13
+
3
14
  ## 0.32.0
4
15
 
5
16
  - Fixed `fireforge lint <files>` to evaluate the `large-patch-files` rule against each file's resolved owning patch instead of the ad-hoc file-list cardinality, so a cross-patch selection no longer synthesizes a phantom oversized patch (e.g. eight files across four patches no longer report `Patch affects 8 files` when no single owner exceeds the threshold). The size rules now run per owning patch — using its real `filesAffected` count, diff, and `tier` — and unowned files are evaluated together as one prospective new patch.
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { type ConflictReport } from '../../core/destructive.js';
9
9
  import { buildProjectedManifest } from '../../core/patch-policy.js';
10
+ import type { PatchStagedForwardImport } from '../../types/commands/index.js';
10
11
  import type { PatchCategory, PatchMetadata } from '../../types/commands/index.js';
11
12
  import type { FireForgeConfig } from '../../types/config.js';
12
13
  import { type PlacementPlan } from '../export-flow.js';
@@ -29,6 +30,14 @@ export interface SplitPlan {
29
30
  description: string;
30
31
  /** Patches (by current filename) whose staged-dependency owners re-point to the new patch. */
31
32
  ownerRewrites: string[];
33
+ /**
34
+ * Staged forward-import declarations the split introduces, keyed by the
35
+ * importing patch's post-rename filename. These are the new forward edges
36
+ * from existing patches into the freshly-created patch (owner known); they
37
+ * are injected into the projected lint so dry-run matches the real gate,
38
+ * and persisted on commit so the real per-patch gate stays clean.
39
+ */
40
+ stagedDependencyAdditions: Map<string, PatchStagedForwardImport[]>;
32
41
  }
33
42
  /**
34
43
  *
@@ -45,12 +54,19 @@ export declare function buildSplitDiff(engineDir: string, files: readonly string
45
54
  export declare function findOwnerRewriteHolders(patches: readonly PatchMetadata[], sourceFilename: string, movedSet: ReadonlySet<string>): string[];
46
55
  /** Rewrites split-affected owners on one manifest row. */
47
56
  export declare function rewriteSplitOwners(patch: PatchMetadata, sourceFilename: string, movedSet: ReadonlySet<string>, newFilename: string): PatchMetadata;
57
+ /** Merges `decls` into a patch's `stagedDependencies.forwardImports` (no duplicates). */
58
+ export declare function mergeStagedForwardImports(patch: PatchMetadata, decls: readonly PatchStagedForwardImport[]): PatchMetadata;
48
59
  /**
49
60
  * Projects the full split (renumber + shrunken source + synthetic new
50
61
  * patch + owner rewrites) through cross-patch lint, reporting only the
51
- * regressions the split itself would introduce.
62
+ * regressions the split itself would introduce. Forward edges into the new
63
+ * patch are auto-declared (and the declarations returned) so the projection
64
+ * matches the real per-patch gate the split leaves behind.
52
65
  */
53
- export declare function runProjectedSplitLint(patchesDir: string, plan: SplitPlan): Promise<ConflictReport | null>;
66
+ export declare function runProjectedSplitLint(patchesDir: string, plan: SplitPlan): Promise<{
67
+ conflicts: ConflictReport | null;
68
+ stagedDependencyAdditions: Map<string, PatchStagedForwardImport[]>;
69
+ }>;
54
70
  /** Builds the projected manifest for policy enforcement. */
55
71
  export declare function projectSplitManifest(manifest: {
56
72
  version: 1;
@@ -10,7 +10,7 @@ import { join } from 'node:path';
10
10
  import { getDiffForFilesAgainstHead } from '../../core/git-diff.js';
11
11
  import { computeProjectedLintRegressions } from '../../core/lint-projection.js';
12
12
  import { extractAffectedFiles } from '../../core/patch-apply.js';
13
- import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../../core/patch-lint.js';
13
+ import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, collectForwardImportEdges, detectNewFilesInDiff, lintPatchQueue, } from '../../core/patch-lint.js';
14
14
  import { rewriteStagedDependencyOwners } from '../../core/patch-manifest.js';
15
15
  import { applyRenameMapToManifest, buildProjectedManifest } from '../../core/patch-policy.js';
16
16
  import { buildPatchSourceMetadata } from '../../core/patch-source-metadata.js';
@@ -82,16 +82,12 @@ function buildEntryProjection(diff) {
82
82
  }
83
83
  return { diff, newFiles, modifiedFileAdditions: buildModifiedFileAdditionsFromDiff(diff) };
84
84
  }
85
- /**
86
- * Projects the full split (renumber + shrunken source + synthetic new
87
- * patch + owner rewrites) through cross-patch lint, reporting only the
88
- * regressions the split itself would introduce.
89
- */
90
- export async function runProjectedSplitLint(patchesDir, plan) {
85
+ /** Builds the projected post-split queue entries (renumber + shrunken source + new patch). */
86
+ async function buildProjectedSplitEntries(patchesDir, plan) {
91
87
  const movedSet = new Set(plan.movedFiles);
92
88
  const ownerLookup = (old) => plan.placement.renameMap.get(old)?.newFilename;
93
89
  const baseCtx = await buildPatchQueueContext(patchesDir);
94
- const projectedEntries = baseCtx.entries.map((entry) => {
90
+ const entries = baseCtx.entries.map((entry) => {
95
91
  let metadata = entry.metadata;
96
92
  if (metadata) {
97
93
  metadata = rewriteStagedDependencyOwners(metadata, ownerLookup);
@@ -105,13 +101,90 @@ export async function runProjectedSplitLint(patchesDir, plan) {
105
101
  return base;
106
102
  return { ...base, ...buildEntryProjection(plan.remainingDiff) };
107
103
  });
108
- projectedEntries.push({
104
+ entries.push({
109
105
  filename: plan.placement.newFilename,
110
106
  order: plan.placement.insertionOrder,
111
107
  metadata: null,
112
108
  ...buildEntryProjection(plan.movedDiff),
113
109
  });
114
- projectedEntries.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
110
+ entries.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
111
+ return { baseCtx, entries };
112
+ }
113
+ /**
114
+ * Computes the staged forward-import declarations the split introduces:
115
+ * forward edges from existing patches into the freshly-created patch (its
116
+ * `creates` files are the moved files; the owner is the new patch, so it is
117
+ * known). Keyed by the importing patch's projected (post-rename) filename.
118
+ *
119
+ * These edges did not exist before the split (importer and imported file
120
+ * lived in the same patch), so they have no declaration yet — without this
121
+ * the projected lint flags them while the real per-patch gate would resolve
122
+ * them once declared. Auto-declaring keeps the two in lock-step and lets a
123
+ * sound split read as sound.
124
+ */
125
+ function computeSplitStagedDependencyAdditions(projectedEntries, newFilename) {
126
+ const additions = new Map();
127
+ for (const edge of collectForwardImportEdges({ entries: projectedEntries })) {
128
+ if (edge.owner !== newFilename)
129
+ continue;
130
+ const decl = {
131
+ file: edge.sitePath,
132
+ specifier: edge.specifier,
133
+ creates: edge.creates,
134
+ owner: newFilename,
135
+ };
136
+ const list = additions.get(edge.entry) ?? [];
137
+ const dup = list.some((d) => d.file === decl.file &&
138
+ d.specifier === decl.specifier &&
139
+ d.creates === decl.creates &&
140
+ d.owner === decl.owner);
141
+ if (!dup)
142
+ list.push(decl);
143
+ additions.set(edge.entry, list);
144
+ }
145
+ return additions;
146
+ }
147
+ /** Merges `decls` into a patch's `stagedDependencies.forwardImports` (no duplicates). */
148
+ export function mergeStagedForwardImports(patch, decls) {
149
+ if (decls.length === 0)
150
+ return patch;
151
+ const existing = patch.stagedDependencies?.forwardImports ?? [];
152
+ const merged = [...existing];
153
+ for (const decl of decls) {
154
+ const dup = merged.some((d) => d.file === decl.file &&
155
+ d.specifier === decl.specifier &&
156
+ d.creates === decl.creates &&
157
+ (d.owner ?? '') === (decl.owner ?? ''));
158
+ if (!dup)
159
+ merged.push(decl);
160
+ }
161
+ return {
162
+ ...patch,
163
+ stagedDependencies: { ...patch.stagedDependencies, forwardImports: merged },
164
+ };
165
+ }
166
+ /** Injects the computed staged-dependency additions into projected entries' metadata. */
167
+ function injectStagedDependencyAdditions(entries, additions) {
168
+ for (const entry of entries) {
169
+ const decls = additions.get(entry.filename);
170
+ if (!decls?.length || !entry.metadata)
171
+ continue;
172
+ entry.metadata = mergeStagedForwardImports(entry.metadata, decls);
173
+ }
174
+ }
175
+ /**
176
+ * Projects the full split (renumber + shrunken source + synthetic new
177
+ * patch + owner rewrites) through cross-patch lint, reporting only the
178
+ * regressions the split itself would introduce. Forward edges into the new
179
+ * patch are auto-declared (and the declarations returned) so the projection
180
+ * matches the real per-patch gate the split leaves behind.
181
+ */
182
+ export async function runProjectedSplitLint(patchesDir, plan) {
183
+ const { baseCtx, entries: projectedEntries } = await buildProjectedSplitEntries(patchesDir, plan);
184
+ // Discover and auto-declare the forward edges this split introduces into
185
+ // the new patch, then inject them before linting so they resolve.
186
+ const stagedDependencyAdditions = computeSplitStagedDependencyAdditions(projectedEntries, plan.placement.newFilename);
187
+ injectStagedDependencyAdditions(projectedEntries, stagedDependencyAdditions);
115
188
  const baselineIssues = lintPatchQueue(baseCtx).filter((i) => i.severity === 'error');
116
189
  const projectedIssues = lintPatchQueue({ entries: projectedEntries }).filter((i) => i.severity === 'error');
117
190
  const regressions = computeProjectedLintRegressions(baselineIssues, projectedIssues);
@@ -119,12 +192,13 @@ export async function runProjectedSplitLint(patchesDir, plan) {
119
192
  warn(`Note: projected queue still has ${baselineIssues.length} pre-existing cross-patch ` +
120
193
  'error(s) unrelated to this split. Run "fireforge verify" to list them.');
121
194
  }
122
- if (regressions.length === 0)
123
- return null;
124
- return {
125
- reason: `split would introduce ${regressions.length} cross-patch lint error(s)`,
126
- details: regressions.map((i) => `[${i.check}] ${i.file}: ${i.message}`),
127
- };
195
+ const conflicts = regressions.length === 0
196
+ ? null
197
+ : {
198
+ reason: `split would introduce ${regressions.length} cross-patch lint error(s)`,
199
+ details: regressions.map((i) => `[${i.check}] ${i.file}: ${i.message}`),
200
+ };
201
+ return { conflicts, stagedDependencyAdditions };
128
202
  }
129
203
  /** Builds the projected manifest for policy enforcement. */
130
204
  export function projectSplitManifest(manifest, plan, newMetadata) {
@@ -32,7 +32,7 @@ import { info, intro, outro, success, warn } from '../../utils/logger.js';
32
32
  import { pickDefined } from '../../utils/options.js';
33
33
  import { placementPlansEqual, resolvePlacementPlan } from '../export-flow.js';
34
34
  import { runPatchLint } from '../export-shared.js';
35
- import { assertSourceOwnsFiles, buildNewPatchMetadata, buildSplitDiff, buildSplitSummary, findOwnerRewriteHolders, projectSplitManifest, rewriteSplitOwners, runProjectedSplitLint, } from './split-plan.js';
35
+ import { assertSourceOwnsFiles, buildNewPatchMetadata, buildSplitDiff, buildSplitSummary, findOwnerRewriteHolders, mergeStagedForwardImports, projectSplitManifest, rewriteSplitOwners, runProjectedSplitLint, } from './split-plan.js';
36
36
  /**
37
37
  * Commits a confirmed split under the patch directory lock: renumber →
38
38
  * write new patch body → write shrunken source body → single manifest
@@ -74,7 +74,13 @@ async function commitPatchSplit(patchesDir, plan, newMetadata, options) {
74
74
  if (!fresh)
75
75
  throw new GeneralError('Manifest disappeared during split commit.');
76
76
  const updatedPatches = fresh.patches.map((patch) => {
77
- const withOwners = rewriteSplitOwners(patch, effectiveSourceFilename, movedSet, plan.placement.newFilename);
77
+ let withOwners = rewriteSplitOwners(patch, effectiveSourceFilename, movedSet, plan.placement.newFilename);
78
+ // Persist the auto-declared forward edges into the new patch so the
79
+ // real per-patch gate stays clean (keyed by post-rename filename).
80
+ const decls = plan.stagedDependencyAdditions.get(withOwners.filename);
81
+ if (decls?.length) {
82
+ withOwners = mergeStagedForwardImports(withOwners, decls);
83
+ }
78
84
  if (patch.filename !== effectiveSourceFilename)
79
85
  return withOwners;
80
86
  return { ...withOwners, filesAffected: plan.remainingFiles };
@@ -199,13 +205,16 @@ export async function patchSplitCommand(projectRoot, sourceId, options) {
199
205
  name: options.name,
200
206
  description: options.description ?? '',
201
207
  ownerRewrites: findOwnerRewriteHolders(manifest.patches, source.filename, movedSet),
208
+ // Populated by runProjectedSplitLint below (forward edges into the new patch).
209
+ stagedDependencyAdditions: new Map(),
202
210
  };
203
211
  // Per-patch lint both projected bodies, threading the source patch's
204
212
  // tier/lintIgnore so an intentional-advisory patch can still split.
205
213
  const ignoreChecks = source.lintIgnore ? new Set(source.lintIgnore) : undefined;
206
214
  await runPatchLint(paths.engine, remainingFiles, remainingDiff, config, options.skipLint, undefined, ignoreChecks, source.tier);
207
215
  await runPatchLint(paths.engine, movedFiles, movedDiff, config, options.skipLint, undefined, ignoreChecks, source.tier);
208
- const conflicts = await runProjectedSplitLint(paths.patches, plan);
216
+ const { conflicts, stagedDependencyAdditions } = await runProjectedSplitLint(paths.patches, plan);
217
+ plan.stagedDependencyAdditions = stagedDependencyAdditions;
209
218
  const newMetadata = buildNewPatchMetadata(plan, config);
210
219
  enforcePatchPolicy({
211
220
  config,
@@ -74,12 +74,18 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
74
74
  ...(options.description !== undefined ? { description: options.description } : {}),
75
75
  ...(options.darkValue !== undefined ? { darkValue: options.darkValue } : {}),
76
76
  ...(options.createCategory === true ? { createCategory: true } : {}),
77
+ ...(options.variant !== undefined ? { variant: options.variant } : {}),
77
78
  dryRun: true,
78
79
  });
79
80
  info('[dry-run] Would add token:');
80
81
  info(` Name: ${tokenName}`);
81
82
  info(` Value: ${value}`);
82
- info(` Category: ${options.category}${options.createCategory === true ? ' (created if missing)' : ''}`);
83
+ if (options.variant !== undefined) {
84
+ info(` Variant: :root${options.variant}`);
85
+ }
86
+ else {
87
+ info(` Category: ${options.category}${options.createCategory === true ? ' (created if missing)' : ''}`);
88
+ }
83
89
  info(` Mode: ${options.mode}`);
84
90
  if (options.description)
85
91
  info(` Description: ${options.description}`);
@@ -96,6 +102,7 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
96
102
  ...(options.description !== undefined ? { description: options.description } : {}),
97
103
  ...(options.darkValue !== undefined ? { darkValue: options.darkValue } : {}),
98
104
  ...(options.createCategory === true ? { createCategory: true } : {}),
105
+ ...(options.variant !== undefined ? { variant: options.variant } : {}),
99
106
  });
100
107
  if (result.skipped) {
101
108
  info(`Token ${tokenName} already exists (skipped)`);
@@ -151,6 +158,9 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
151
158
  .makeOptionMandatory(true))
152
159
  .option('--description <desc>', 'Comment description for the CSS file')
153
160
  .option('--dark-value <val>', 'Dark mode value (required if mode is "override")')
161
+ .option('--variant <selector>', 'Attribute selector fragment routing the declaration into a `:root<selector>` block ' +
162
+ "(e.g. '[data-skin=precision]' or '[data-private]'); creates or updates the block. " +
163
+ 'CSS-only — cannot be combined with --mode override.')
154
164
  .option('--create-category', 'Declare the category banner in the tokens CSS if it does not exist yet')
155
165
  .option('--dry-run', 'Show what would be changed without writing')
156
166
  .action(withErrorHandling(async (tokenName, value, options) => {
@@ -162,6 +172,7 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
162
172
  darkValue: options.darkValue,
163
173
  dryRun: options.dryRun,
164
174
  createCategory: options.createCategory,
175
+ variant: options.variant,
165
176
  }),
166
177
  });
167
178
  }));
@@ -16,6 +16,8 @@
16
16
  * but do not fail. Designed for CI use.
17
17
  */
18
18
  import { getProjectPaths, loadConfig } from '../core/config.js';
19
+ import { furnaceConfigExists, loadFurnaceConfig } from '../core/furnace-config.js';
20
+ import { findJsconfigPathsDrift, syncFurnaceJsconfigPaths } from '../core/furnace-jsconfig.js';
19
21
  import { relativeForDisplay, runTypecheck } from '../core/typecheck.js';
20
22
  import { GeneralError } from '../errors/base.js';
21
23
  import { info, intro, outro, success, warn } from '../utils/logger.js';
@@ -32,6 +34,11 @@ export function resolveTypecheckProjects(configTypecheck, override) {
32
34
  return {
33
35
  projects: [override],
34
36
  ...(configTypecheck?.extraShim !== undefined ? { extraShim: configTypecheck.extraShim } : {}),
37
+ // Preserve any per-project override for the targeted path so a one-off
38
+ // `--project` run honours its opt-out / shim override just like a full run.
39
+ ...(configTypecheck?.projectOverrides !== undefined
40
+ ? { projectOverrides: configTypecheck.projectOverrides }
41
+ : {}),
35
42
  };
36
43
  }
37
44
  if (!configTypecheck) {
@@ -64,10 +71,38 @@ export async function typecheckCommand(projectRoot, options) {
64
71
  getProjectPaths(projectRoot);
65
72
  const config = await loadConfig(projectRoot);
66
73
  const cfg = resolveTypecheckProjects(config.typecheck, options.project);
74
+ // Regenerate a stale Furnace-managed jsconfig before running: the generated
75
+ // `compilerOptions.paths` shim drifts when components are added/renamed
76
+ // without a re-deploy, and a stale shim reports type errors in files the
77
+ // session never touched (e.g. 47 phantom errors from an out-of-date
78
+ // chrome-module mapping). Run the same reconciler `furnace deploy`/`sync`
79
+ // use so typecheck checks against the current workspace.
80
+ await regenerateStaleGeneratedJsconfig(projectRoot);
67
81
  info(`Running typecheck across ${String(cfg.projects.length)} project(s): ${cfg.projects.join(', ')}`);
68
82
  const results = await runTypecheck(projectRoot, cfg);
69
83
  reportResults(projectRoot, results);
70
84
  }
85
+ /**
86
+ * Staleness-checks and regenerates the Furnace-managed jsconfig
87
+ * (`furnace.json` → `typecheckJsconfig`) before typecheck runs. No-op when
88
+ * the project has no furnace.json or no `typecheckJsconfig` is configured.
89
+ * A missing `typecheckJsconfig` file surfaces the reconciler's own clear
90
+ * error rather than producing phantom type diagnostics.
91
+ */
92
+ async function regenerateStaleGeneratedJsconfig(projectRoot) {
93
+ if (!(await furnaceConfigExists(projectRoot)))
94
+ return;
95
+ const furnaceConfig = await loadFurnaceConfig(projectRoot);
96
+ if (!furnaceConfig.typecheckJsconfig)
97
+ return;
98
+ const drift = await findJsconfigPathsDrift(projectRoot, furnaceConfig);
99
+ if (!drift.changed)
100
+ return;
101
+ info(`Regenerating stale generated jsconfig ${furnaceConfig.typecheckJsconfig} before typecheck ` +
102
+ `(+${String(drift.added.length)} added, ~${String(drift.updated.length)} updated, ` +
103
+ `-${String(drift.pruned.length)} pruned).`);
104
+ await syncFurnaceJsconfigPaths(projectRoot, furnaceConfig);
105
+ }
71
106
  /**
72
107
  * Prints all issues, computes the per-project + total counts, and
73
108
  * throws on errors. Extracted so it can be exercised directly by
@@ -14,9 +14,23 @@ import { applyAllComponents } from './furnace-apply.js';
14
14
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
15
15
  import { runFurnaceMutation } from './furnace-operation.js';
16
16
  import { cleanStories } from './furnace-stories.js';
17
- import { generateMozconfig, runMach } from './mach.js';
17
+ import { generateMozconfig, runMachCapture } from './mach.js';
18
18
  /** Path fragments of files whose edits invalidate the recursive-make backend. */
19
19
  const BACKEND_INVALIDATING_SUFFIXES = ['moz.build', 'moz.configure', 'Makefile.in'];
20
+ /**
21
+ * Extracts the tail of captured `mach configure` output so the underlying
22
+ * mozbuild failure (e.g. `mozbuild.util.UnsortedError: ... is not in sorted
23
+ * order`) is carried into the thrown `BuildError` instead of being reduced to
24
+ * a bare exit code. mozbuild writes the error and its traceback to stderr;
25
+ * stdout is included as a fallback for shells that interleave the streams.
26
+ * Returns an empty string when nothing useful was captured.
27
+ */
28
+ function extractMachConfigureError(result) {
29
+ const combined = `${result.stderr}\n${result.stdout}`.trim();
30
+ if (!combined)
31
+ return '';
32
+ return combined.split('\n').slice(-40).join('\n').trim();
33
+ }
20
34
  /**
21
35
  * Returns true when the file path matches a pattern that forces
22
36
  * `mach configure` to regenerate the backend. Exported for testing.
@@ -65,10 +79,16 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
65
79
  info(`Backend command: mach configure`);
66
80
  const configureSpinner = spinner('Running mach configure...');
67
81
  try {
68
- const exitCode = await runMach(['configure'], paths.engine);
82
+ const captured = await runMachCapture(['configure'], paths.engine);
83
+ const exitCode = captured.exitCode;
69
84
  if (exitCode !== 0) {
70
85
  configureSpinner.error(`mach configure failed with exit code ${exitCode}`);
71
- throw new BuildError(`Backend regeneration failed: mach configure exited with code ${exitCode}. Build stopped because continuing would hide the real configure failure.`, 'mach configure');
86
+ // Surface the underlying mozbuild error (e.g. UnsortedError) instead
87
+ // of a bare exit code — the generic message hid the actual cause.
88
+ const detail = extractMachConfigureError(captured);
89
+ throw new BuildError(`Backend regeneration failed: mach configure exited with code ${exitCode}.` +
90
+ (detail ? `\n\nmach configure output (tail):\n${detail}` : '') +
91
+ '\n\nBuild stopped because continuing would hide the real configure failure.', 'mach configure');
72
92
  }
73
93
  else {
74
94
  configureSpinner.stop('Backend regenerated successfully (mach configure exit code 0)');
@@ -401,6 +401,32 @@ function parseTypecheckBlock(rec) {
401
401
  if (extraShim !== undefined) {
402
402
  out.extraShim = parseShimPath(extraShim, 'typecheck.extraShim');
403
403
  }
404
+ const overrides = parseTypecheckProjectOverrides(rec.raw('projectOverrides'), projects);
405
+ if (overrides) {
406
+ out.projectOverrides = overrides;
407
+ }
408
+ return out;
409
+ }
410
+ /**
411
+ * Validates the optional `typecheck.projectOverrides` map: keys must name a
412
+ * declared project, values must be either `null` (opt out of the shared extra
413
+ * shim) or a contained relative `.d.ts` path (per-project override). Returns
414
+ * `undefined` when the field is absent.
415
+ */
416
+ function parseTypecheckProjectOverrides(raw, projects) {
417
+ if (raw === undefined)
418
+ return undefined;
419
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
420
+ throw new ConfigError('Config field "typecheck.projectOverrides" must be an object');
421
+ }
422
+ const known = new Set(projects);
423
+ const out = {};
424
+ for (const [key, value] of Object.entries(raw)) {
425
+ if (!known.has(key)) {
426
+ throw new ConfigError(`Config field "typecheck.projectOverrides" key "${key}" does not match any entry in "typecheck.projects"`);
427
+ }
428
+ out[key] = value === null ? null : parseShimPath(value, `typecheck.projectOverrides["${key}"]`);
429
+ }
404
430
  return out;
405
431
  }
406
432
  //# sourceMappingURL=config-validate.js.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Dry-run action planning for custom-component apply. Extracted from
3
+ * `furnace-apply-helpers.ts` so the apply path and its dry-run mirror each
4
+ * stay within the per-file line budget. Consumed only by that module.
5
+ */
6
+ import type { CustomComponentConfig, DryRunAction, StepError } from '../types/furnace.js';
7
+ interface DirectoryEntry {
8
+ isFile(): boolean;
9
+ isSymbolicLink?(): boolean;
10
+ name: string;
11
+ }
12
+ /** Computes the planned dry-run actions (and pre-flight step errors) for a custom component. */
13
+ export declare function buildCustomDryRunActions(name: string, componentDir: string, engineDir: string, config: CustomComponentConfig, targetDir: string, entries: DirectoryEntry[], ftlDir: string): Promise<{
14
+ actions: DryRunAction[];
15
+ stepErrors: StepError[];
16
+ }>;
17
+ export {};
@@ -0,0 +1,105 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Dry-run action planning for custom-component apply. Extracted from
4
+ * `furnace-apply-helpers.ts` so the apply path and its dry-run mirror each
5
+ * stay within the per-file line budget. Consumed only by that module.
6
+ */
7
+ import { join } from 'node:path';
8
+ import { toError } from '../utils/errors.js';
9
+ import { pathExists } from '../utils/fs.js';
10
+ import { describeLocaleFtlJarMnRegistration, describeSharedFtlPrune } from './furnace-apply-ftl.js';
11
+ import { describeFragmentExpansion } from './furnace-css-fragments.js';
12
+ import { validateCustomElementRegistration, validateJarMnInsertionForFiles, } from './furnace-registration.js';
13
+ function isRegularFile(entry) {
14
+ if (!entry.isFile())
15
+ return false;
16
+ if (typeof entry.isSymbolicLink === 'function' && entry.isSymbolicLink())
17
+ return false;
18
+ return true;
19
+ }
20
+ /** Computes the planned dry-run actions (and pre-flight step errors) for a custom component. */
21
+ export async function buildCustomDryRunActions(name, componentDir, engineDir, config, targetDir, entries, ftlDir) {
22
+ const actions = [];
23
+ const stepErrors = [];
24
+ for (const entry of entries) {
25
+ if (!isRegularFile(entry))
26
+ continue;
27
+ if (!entry.name.endsWith('.mjs') && !entry.name.endsWith('.css'))
28
+ continue;
29
+ const fragmentNote = await describeFragmentExpansion(join(componentDir, entry.name));
30
+ actions.push({
31
+ component: name,
32
+ action: fragmentNote ? 'expand-fragments' : 'copy',
33
+ source: join(componentDir, entry.name),
34
+ target: join(targetDir, entry.name),
35
+ description: `Copy ${entry.name} to ${config.targetPath}${fragmentNote}`,
36
+ });
37
+ }
38
+ // Per-component .ftl handling is skipped when the component opts into a
39
+ // shared feature-scoped bundle via `sharedFtl`. The shared file is
40
+ // registered (and copied) by whoever owns the feature bundle, so
41
+ // emitting a copy-ftl / register-jar action here would duplicate (or
42
+ // later orphan) the entry.
43
+ if (config.localized && !config.sharedFtl) {
44
+ const ftlFile = `${name}.ftl`;
45
+ const ftlSrc = join(componentDir, ftlFile);
46
+ if (await pathExists(ftlSrc)) {
47
+ actions.push({
48
+ component: name,
49
+ action: 'copy-ftl',
50
+ source: ftlSrc,
51
+ target: join(engineDir, ftlDir, ftlFile),
52
+ description: `Copy ${ftlFile} to ${ftlDir}`,
53
+ });
54
+ const localeAction = describeLocaleFtlJarMnRegistration(name, ftlDir, ftlFile);
55
+ if (localeAction) {
56
+ actions.push(localeAction);
57
+ }
58
+ }
59
+ }
60
+ // A sharedFtl widget owns its strings via the shared bundle; surface the
61
+ // removal of any dangling per-widget locale jar.mn entry so the dry-run
62
+ // plan matches what apply will do (and explains the unblocked build).
63
+ const pruneAction = await describeSharedFtlPrune(engineDir, name, ftlDir, config);
64
+ if (pruneAction) {
65
+ actions.push(pruneAction);
66
+ }
67
+ if (config.register) {
68
+ try {
69
+ const modulePath = `chrome://global/content/elements/${name}.mjs`;
70
+ await validateCustomElementRegistration(engineDir, name, modulePath);
71
+ }
72
+ catch (error) {
73
+ stepErrors.push({
74
+ step: 'customElements.js registration',
75
+ error: toError(error).message,
76
+ });
77
+ }
78
+ actions.push({
79
+ component: name,
80
+ action: 'register-ce',
81
+ description: `Register ${name} in customElements.js (DOMContentLoaded block)`,
82
+ });
83
+ }
84
+ const copiedFileNames = entries
85
+ .filter((entry) => isRegularFile(entry) && (entry.name.endsWith('.mjs') || entry.name.endsWith('.css')))
86
+ .map((entry) => entry.name);
87
+ if (copiedFileNames.length > 0) {
88
+ try {
89
+ await validateJarMnInsertionForFiles(engineDir, name, copiedFileNames);
90
+ }
91
+ catch (error) {
92
+ stepErrors.push({
93
+ step: 'jar.mn registration',
94
+ error: toError(error).message,
95
+ });
96
+ }
97
+ actions.push({
98
+ component: name,
99
+ action: 'register-jar',
100
+ description: `Add ${copiedFileNames.join(', ')} to jar.mn`,
101
+ });
102
+ }
103
+ return { actions, stepErrors };
104
+ }
105
+ //# sourceMappingURL=furnace-apply-dry-run.js.map
@@ -10,6 +10,18 @@
10
10
  */
11
11
  import type { CustomComponentConfig, DryRunAction, StepError } from '../types/furnace.js';
12
12
  import { type RollbackJournal } from './furnace-rollback.js';
13
+ /**
14
+ * Apply-path wrapper around {@link pruneSharedFtlPerWidgetLocaleEntry} that
15
+ * records the affected path / step error in the caller's collectors, mirroring
16
+ * {@link applyCustomFtlFile}'s contract so the main apply helper stays terse.
17
+ */
18
+ export declare function applySharedFtlPrune(engineDir: string, name: string, ftlDir: string, config: CustomComponentConfig, affectedPaths: string[], stepErrors: StepError[], rollbackJournal?: RollbackJournal): Promise<void>;
19
+ /**
20
+ * Read-only dry-run describer for {@link pruneSharedFtlPerWidgetLocaleEntry}:
21
+ * returns an action when a dangling per-widget locale entry exists for a
22
+ * `sharedFtl` widget, else `undefined`.
23
+ */
24
+ export declare function describeSharedFtlPrune(engineDir: string, name: string, ftlDir: string, config: CustomComponentConfig): Promise<DryRunAction | undefined>;
13
25
  /**
14
26
  * Copies a component's `.ftl` into the FTL tree and registers the chrome URI
15
27
  * in the locale jar.mn.