@hominis/fireforge 0.21.3 → 0.22.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.
@@ -15,7 +15,7 @@
15
15
  * Lives in a sibling module to keep `doctor-furnace.ts` under the
16
16
  * per-file LOC budget.
17
17
  */
18
- import { readdir } from 'node:fs/promises';
18
+ import { readdir, rm } from 'node:fs/promises';
19
19
  import { join } from 'node:path';
20
20
  import { getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig } from '../core/furnace-config.js';
21
21
  import { toError } from '../utils/errors.js';
@@ -120,6 +120,37 @@ async function repairOrphanOverrides(projectRoot, orphans) {
120
120
  }
121
121
  return { restored, unrecoverable };
122
122
  }
123
+ async function repairCustomOrphans(projectRoot, customNames) {
124
+ const deleted = [];
125
+ const retained = [];
126
+ const errors = [];
127
+ if (customNames.length === 0)
128
+ return { deleted, retained, errors };
129
+ const furnacePaths = getFurnacePaths(projectRoot);
130
+ for (const name of customNames) {
131
+ const dir = join(furnacePaths.customDir, name);
132
+ let entries;
133
+ try {
134
+ entries = await readdir(dir, { withFileTypes: true });
135
+ }
136
+ catch (err) {
137
+ errors.push(`${name}: ${toError(err).message}`);
138
+ continue;
139
+ }
140
+ if (entries.length > 0) {
141
+ retained.push(name);
142
+ continue;
143
+ }
144
+ try {
145
+ await rm(dir);
146
+ deleted.push(name);
147
+ }
148
+ catch (err) {
149
+ errors.push(`${name}: ${toError(err).message}`);
150
+ }
151
+ }
152
+ return { deleted, retained, errors };
153
+ }
123
154
  export const furnaceManifestSyncCheck = {
124
155
  name: 'Furnace manifest sync',
125
156
  dependsOn: ['Furnace configuration'],
@@ -142,6 +173,7 @@ export const furnaceManifestSyncCheck = {
142
173
  if (repairResult.writeError) {
143
174
  return failure('Furnace manifest sync', `Repair failed while writing furnace.json: ${repairResult.writeError}`, 'Fix the underlying filesystem error and retry the doctor command.');
144
175
  }
176
+ const customRepair = await repairCustomOrphans(ctx.projectRoot, orphans.customNames);
145
177
  const { restored, unrecoverable } = repairResult;
146
178
  const restoreDetail = restored.length > 0
147
179
  ? `Re-registered ${restored.length} override${restored.length === 1 ? '' : 's'} (${restored.join(', ')}) from their override.json sidecars.`
@@ -149,10 +181,16 @@ export const furnaceManifestSyncCheck = {
149
181
  const unrecoverableDetail = unrecoverable.length > 0
150
182
  ? ` Could not recover ${unrecoverable.length} override${unrecoverable.length === 1 ? '' : 's'} without a valid override.json (${unrecoverable.join(', ')}) — delete components/overrides/<name> or re-run "fireforge furnace override" to restore the entry.`
151
183
  : '';
152
- const customDetail = customCount > 0
153
- ? ` ${customCount} custom ${customCount === 1 ? 'directory requires' : 'directories require'} manual action: re-run "fireforge furnace create" or delete components/custom/<name>/ to reconcile.`
184
+ const customDetail = customRepair.deleted.length > 0
185
+ ? ` Deleted ${customRepair.deleted.length} empty custom orphan ${customRepair.deleted.length === 1 ? 'directory' : 'directories'} (${customRepair.deleted.join(', ')}).`
186
+ : '';
187
+ const retainedCustomDetail = customRepair.retained.length > 0
188
+ ? ` ${customRepair.retained.length} non-empty custom orphan ${customRepair.retained.length === 1 ? 'directory requires' : 'directories require'} manual action (${customRepair.retained.join(', ')}): re-run "fireforge furnace create" or delete components/custom/<name>/ to reconcile.`
189
+ : '';
190
+ const customErrorDetail = customRepair.errors.length > 0
191
+ ? ` Could not inspect or delete ${customRepair.errors.length} custom orphan ${customRepair.errors.length === 1 ? 'directory' : 'directories'} (${customRepair.errors.join('; ')}).`
154
192
  : '';
155
- return warning('Furnace manifest sync', `${restoreDetail}${unrecoverableDetail}${customDetail}`.trim() ||
193
+ return warning('Furnace manifest sync', `${restoreDetail}${unrecoverableDetail}${customDetail}${retainedCustomDetail}${customErrorDetail}`.trim() ||
156
194
  'Nothing to repair (orphans surfaced but all were already recoverable).');
157
195
  },
158
196
  };
@@ -1,4 +1,4 @@
1
- import { configExists, getProjectPaths, loadConfig, loadState } from '../core/config.js';
1
+ import { configExists, getProjectPaths, loadConfig, loadState, updateState, } from '../core/config.js';
2
2
  import { furnaceConfigExists as checkFurnaceConfigExists } from '../core/furnace-config.js';
3
3
  import { getCurrentBranch, getHead, isGitRepository, isMissingHeadError } from '../core/git.js';
4
4
  import { ensureGit } from '../core/git-base.js';
@@ -13,6 +13,7 @@ import { findExecutable } from '../utils/process.js';
13
13
  import { failure, ok, warning } from './doctor-check-core.js';
14
14
  import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
15
15
  import { inspectEngineWorkingTree } from './doctor-working-tree.js';
16
+ import { collectPatchQueueHealth } from './verify.js';
16
17
  /**
17
18
  * Runs a single check definition, converting thrown errors into
18
19
  * DoctorCheck failure rows. Always returns an array so the caller can
@@ -184,9 +185,21 @@ const DOCTOR_CHECKS = [
184
185
  {
185
186
  name: 'Pending Resolution',
186
187
  skipIf: (ctx) => !ctx.state.pendingResolution,
187
- run: (ctx) => {
188
+ run: async (ctx) => {
188
189
  const patchFilename = ctx.state.pendingResolution?.patchFilename ?? 'unknown';
189
- return failure('Pending Resolution', `You are currently resolving a conflict for patch ${patchFilename}.`, 'Build and Export commands may behave unexpectedly until "fireforge resolve" is completed.');
190
+ if (ctx.options.clearResolution) {
191
+ const health = await collectPatchQueueHealth(ctx.projectRoot);
192
+ if (health.errorCount > 0) {
193
+ return failure('Pending Resolution', `Refusing to clear pending resolution for ${patchFilename}: patch queue health check found ${health.errorCount} error(s).`, 'Run "fireforge verify" for details, fix the queue, then retry "fireforge doctor --clear-resolution".');
194
+ }
195
+ await updateState(ctx.projectRoot, (current) => {
196
+ const next = { ...current };
197
+ delete next.pendingResolution;
198
+ return next;
199
+ });
200
+ return ok('Pending Resolution');
201
+ }
202
+ return failure('Pending Resolution', `You are currently resolving a conflict for patch ${patchFilename}.`, 'Build and Export commands may behave unexpectedly until "fireforge resolve" is completed. If the queue now verifies cleanly, run "fireforge doctor --clear-resolution" to discard the stale marker.');
190
203
  },
191
204
  },
192
205
  {
@@ -452,6 +465,7 @@ export function registerDoctor(program, { getProjectRoot, withErrorHandling }) {
452
465
  .description('Diagnose project issues')
453
466
  .option('--repair-patches-manifest', 'Rebuild patches/patches.json from the current patch files before reporting results')
454
467
  .option('--repair-furnace', 'Reconcile furnace state: clear stale furnace-state.json entries, re-run furnace apply to fix engine drift, and clear the pending-repair marker set by a failed preview teardown')
468
+ .option('--clear-resolution', 'Clear stale pendingResolution state after the patch queue health check reports no errors')
455
469
  .action(withErrorHandling(async (options) => {
456
470
  const result = await doctorCommand(getProjectRoot(), options);
457
471
  if (result.exitCode !== 0) {
@@ -269,7 +269,7 @@ export async function downloadCommand(projectRoot, options) {
269
269
  // CI job notes the expected duration. The progress callbacks below
270
270
  // still fire as usual; this is an additional up-front signal, not a
271
271
  // replacement.
272
- info('Indexing downloaded source into git (one-time; typically 13 minutes on a ~600 MB Firefox tree)...');
272
+ info('Indexing downloaded source into git (one-time; typically 35 minutes on a ~600 MB Firefox tree)...');
273
273
  // Initialize git repository
274
274
  const gitSpinner = spinner('Initializing git repository (this may take a few minutes)...');
275
275
  let baseCommit;
@@ -24,6 +24,13 @@ export interface PlacementPlan {
24
24
  * slot to make room for a new patch at `requestedOrder`.
25
25
  */
26
26
  export declare function computePlacementPlan(manifestPatches: PatchMetadata[], newPatchCategory: PatchCategory, newPatchName: string, requestedOrder: number): PlacementPlan;
27
+ /**
28
+ * Computes an exact sparse placement plan for `--order <N>`. Unlike
29
+ * positional insertion, this never renumbers existing patches: the order
30
+ * must be unused, and policy validation decides whether the requested
31
+ * category/order is allowed.
32
+ */
33
+ export declare function computeExactPlacementPlan(manifestPatches: PatchMetadata[], newPatchCategory: PatchCategory, newPatchName: string, requestedOrder: number): PlacementPlan;
27
34
  /**
28
35
  * Resolves a placement plan from CLI flags against the current manifest.
29
36
  */
@@ -18,11 +18,18 @@ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
18
18
  import { toError } from '../utils/errors.js';
19
19
  import { pathExists, readText, removeFile, writeText } from '../utils/fs.js';
20
20
  import { info, warn } from '../utils/logger.js';
21
+ import { assertPlacementPreservesReservedRanges } from './export-placement-policy.js';
21
22
  import { findPartialOwnershipOverlap } from './export-shared.js';
22
23
  function buildFilenameForPlacement(category, name, order, width) {
23
24
  const padded = String(order).padStart(Math.max(3, width), '0');
24
25
  return `${padded}-${category}-${sanitizeName(name)}.patch`;
25
26
  }
27
+ function prefixWidthForPatches(manifestPatches, requestedOrder) {
28
+ return manifestPatches.reduce((width, patch) => {
29
+ const match = /^(\d+)-/.exec(patch.filename);
30
+ return Math.max(width, match?.[1]?.length ?? 3, String(requestedOrder).length);
31
+ }, 3);
32
+ }
26
33
  function getSortedRenameEntries(renameMap) {
27
34
  return Array.from(renameMap.entries()).sort((a, b) => a[1].newOrder - b[1].newOrder);
28
35
  }
@@ -59,12 +66,7 @@ export function computePlacementPlan(manifestPatches, newPatchCategory, newPatch
59
66
  }
60
67
  const sorted = [...manifestPatches].sort((a, b) => a.order - b.order);
61
68
  const renameMap = new Map();
62
- // Decide the canonical prefix width by inspecting the widest existing
63
- // filename (falling back to 3). Keeps zero-padding consistent post-shift.
64
- const prefixWidth = sorted.reduce((w, p) => {
65
- const match = /^(\d+)-/.exec(p.filename);
66
- return match ? Math.max(w, match[1]?.length ?? 3) : w;
67
- }, 3);
69
+ const prefixWidth = prefixWidthForPatches(sorted, requestedOrder);
68
70
  // Every existing patch at requestedOrder or later shifts up by one.
69
71
  for (const patch of sorted) {
70
72
  if (patch.order >= requestedOrder) {
@@ -81,6 +83,27 @@ export function computePlacementPlan(manifestPatches, newPatchCategory, newPatch
81
83
  renameMap,
82
84
  };
83
85
  }
86
+ /**
87
+ * Computes an exact sparse placement plan for `--order <N>`. Unlike
88
+ * positional insertion, this never renumbers existing patches: the order
89
+ * must be unused, and policy validation decides whether the requested
90
+ * category/order is allowed.
91
+ */
92
+ export function computeExactPlacementPlan(manifestPatches, newPatchCategory, newPatchName, requestedOrder) {
93
+ if (!Number.isInteger(requestedOrder) || requestedOrder <= 0) {
94
+ throw new InvalidArgumentError(`--order must be a positive integer, got ${String(requestedOrder)}.`, '--order');
95
+ }
96
+ const occupied = manifestPatches.find((patch) => patch.order === requestedOrder);
97
+ if (occupied) {
98
+ throw new InvalidArgumentError(`--order ${String(requestedOrder)} is already occupied by ${occupied.filename}. ` +
99
+ 'Choose an unused order or use --before/--after for positional insertion.', '--order');
100
+ }
101
+ return {
102
+ insertionOrder: requestedOrder,
103
+ newFilename: buildFilenameForPlacement(newPatchCategory, newPatchName, requestedOrder, prefixWidthForPatches(manifestPatches, requestedOrder)),
104
+ renameMap: new Map(),
105
+ };
106
+ }
84
107
  /**
85
108
  * Resolves a placement plan from CLI flags against the current manifest.
86
109
  */
@@ -95,7 +118,7 @@ export async function resolvePlacementPlan(patchesDir, options, category, name)
95
118
  if (!Number.isInteger(options.order) || options.order <= 0) {
96
119
  throw new InvalidArgumentError(`--order must be a positive integer, got ${String(options.order)}.`, '--order');
97
120
  }
98
- targetOrder = options.order;
121
+ return computeExactPlacementPlan(existingPatches, category, name, options.order);
99
122
  }
100
123
  else if (options.before !== undefined) {
101
124
  const anchor = resolvePatchIdentifier(options.before, existingPatches);
@@ -202,6 +225,9 @@ export async function commitPlacementExport(input) {
202
225
  throw new InvalidArgumentError(`Refusing to run export: ${conflicts.reason}. Pass --force-unsafe to override.`, '--force-unsafe');
203
226
  }
204
227
  const originalManifest = await loadPatchesManifest(input.patchesDir);
228
+ if (originalManifest !== null) {
229
+ assertPlacementPreservesReservedRanges(currentPlan, originalManifest.patches, input.config, input.category);
230
+ }
205
231
  if (input.config !== undefined) {
206
232
  const renamed = originalManifest !== null
207
233
  ? applyRenameMapToManifest(originalManifest, currentPlan.renameMap)
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Policy-aware checks for export placement plans.
3
+ */
4
+ import type { PatchCategory, PatchMetadata } from '../types/commands/index.js';
5
+ import type { FireForgeConfig } from '../types/config.js';
6
+ export interface PlacementPolicyPlan {
7
+ insertionOrder: number;
8
+ renameMap: ReadonlyMap<string, {
9
+ newFilename: string;
10
+ newOrder: number;
11
+ }>;
12
+ }
13
+ /** Refuses positional export plans that would renumber exact reserved patches. */
14
+ export declare function assertPlacementPreservesReservedRanges(plan: PlacementPolicyPlan, manifestPatches: readonly PatchMetadata[], config: FireForgeConfig | undefined, category: PatchCategory): void;
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Policy-aware checks for export placement plans.
4
+ */
5
+ import { InvalidArgumentError } from '../errors/base.js';
6
+ function reservedRangeLabel(range) {
7
+ return `${String(range.from).padStart(3, '0')}-${String(range.to).padStart(3, '0')}`;
8
+ }
9
+ function findReservedRange(config, order) {
10
+ return (config.patchPolicy?.reservedRanges?.find((range) => order >= range.from && order <= range.to) ??
11
+ null);
12
+ }
13
+ function suggestSparseOrder(config, patches, category, insertionOrder) {
14
+ const ranges = (config.patchPolicy?.ranges ?? [])
15
+ .filter((range) => range.category === category)
16
+ .sort((a, b) => a.from - b.from || a.to - b.to);
17
+ const occupied = new Set(patches.map((patch) => patch.order));
18
+ for (const range of ranges) {
19
+ for (let order = Math.max(insertionOrder, range.from); order <= range.to; order++) {
20
+ if (!occupied.has(order) && findReservedRange(config, order) === null)
21
+ return order;
22
+ }
23
+ }
24
+ for (const range of ranges) {
25
+ for (let order = range.from; order <= range.to; order++) {
26
+ if (!occupied.has(order) && findReservedRange(config, order) === null)
27
+ return order;
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+ /** Refuses positional export plans that would renumber exact reserved patches. */
33
+ export function assertPlacementPreservesReservedRanges(plan, manifestPatches, config, category) {
34
+ if (config?.patchPolicy === undefined || plan.renameMap.size === 0)
35
+ return;
36
+ const byFilename = new Map(manifestPatches.map((patch) => [patch.filename, patch]));
37
+ for (const [filename, rename] of plan.renameMap) {
38
+ const patch = byFilename.get(filename);
39
+ if (!patch)
40
+ continue;
41
+ const reserved = findReservedRange(config, patch.order);
42
+ if (reserved === null)
43
+ continue;
44
+ const suggestion = suggestSparseOrder(config, manifestPatches, category, plan.insertionOrder);
45
+ const suggestionText = suggestion !== null
46
+ ? ` Use --order ${String(suggestion).padStart(3, '0')} to create the new patch in an unused ${category} slot without renumbering reserved patches.`
47
+ : ` Choose an unused order in the ${category} policy range or adjust patchPolicy.`;
48
+ throw new InvalidArgumentError(`Positional export would renumber reserved patch ${patch.filename} ` +
49
+ `from ${String(patch.order).padStart(3, '0')} to ${rename.newFilename} ` +
50
+ `(reserved range ${reservedRangeLabel(reserved)}).` +
51
+ suggestionText, 'export placement');
52
+ }
53
+ }
54
+ //# sourceMappingURL=export-placement-policy.js.map
@@ -21,6 +21,7 @@ import { pickDefined } from '../utils/options.js';
21
21
  import { stripEnginePrefix } from '../utils/paths.js';
22
22
  import { parsePositiveIntegerFlag } from '../utils/validation.js';
23
23
  import { commitPlacementExport, placementSummary, projectPlacementForLint, renderDryRunPreview, resolvePlacementPlan, } from './export-flow.js';
24
+ import { assertPlacementPreservesReservedRanges } from './export-placement-policy.js';
24
25
  import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
25
26
  async function collectExportFiles(paths, files) {
26
27
  const collectedFiles = new Set();
@@ -191,8 +192,11 @@ export async function exportCommand(projectRoot, files, options) {
191
192
  throw new InvalidArgumentError('Placement flags (--order/--before/--after) cannot be combined with --supersede.', 'export placement');
192
193
  }
193
194
  placementPlan = await resolvePlacementPlan(paths.patches, options, selectedCategory, patchName);
194
- const conflicts = await projectPlacementForLint(paths.patches, placementPlan, diff);
195
195
  const currentManifest = await loadPatchesManifest(paths.patches);
196
+ if (currentManifest !== null) {
197
+ assertPlacementPreservesReservedRanges(placementPlan, currentManifest.patches, config, selectedCategory);
198
+ }
199
+ const conflicts = await projectPlacementForLint(paths.patches, placementPlan, diff);
196
200
  const renamed = currentManifest !== null
197
201
  ? applyRenameMapToManifest(currentManifest, placementPlan.renameMap)
198
202
  : buildProjectedManifest(null, []);
@@ -397,7 +401,7 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
397
401
  .option('--supersede', 'Allow superseding multiple existing patches')
398
402
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
399
403
  .option('--dry-run', 'Print the export plan (including supersede preview) without writing')
400
- .addOption(new Option('--order <N>', 'Place the new patch at this ordinal, shifting subsequent patches up').argParser((v) => parsePositiveIntegerFlag('--order', v)))
404
+ .addOption(new Option('--order <N>', 'Place the new patch at this exact unused order without renumbering existing patches').argParser((v) => parsePositiveIntegerFlag('--order', v)))
401
405
  .option('--before <anchor>', 'Place the new patch immediately before <anchor>')
402
406
  .option('--after <anchor>', 'Place the new patch immediately after <anchor>')
403
407
  .option('-y, --yes', 'Skip confirmation for placement renumbers (required for non-TTY)')
@@ -4,6 +4,7 @@ export interface DryRunPlanInput {
4
4
  localized: boolean;
5
5
  register: boolean;
6
6
  composes: string[] | undefined;
7
+ stockAdditions?: string[];
7
8
  /**
8
9
  * Feature-scoped Fluent bundle the component participates in (the same
9
10
  * value that will be written to `furnace.json`'s `sharedFtl`). When set,
@@ -75,7 +75,7 @@ export function formatSuccessNote(args) {
75
75
  * `components/custom/` to match the wording of the real success note.
76
76
  */
77
77
  export function formatDryRunPlan(args) {
78
- const { componentName, localized, register, composes, sharedFtl, testStyle, description, binaryName, } = args;
78
+ const { componentName, localized, register, composes, stockAdditions, sharedFtl, testStyle, description, binaryName, } = args;
79
79
  const componentFiles = [`${componentName}.mjs`, `${componentName}.css`];
80
80
  // A per-component .ftl is scaffolded only when the component does NOT
81
81
  // opt into a shared feature-scoped bundle. Mirrors writeComponentFiles.
@@ -92,6 +92,12 @@ export function formatDryRunPlan(args) {
92
92
  if (composes && composes.length > 0) {
93
93
  plan += `\n composes: ${composes.join(', ')}`;
94
94
  }
95
+ if (stockAdditions && stockAdditions.length > 0) {
96
+ plan += `\n\nWould add discovered stock to furnace.json:`;
97
+ for (const name of stockAdditions) {
98
+ plan += `\n ${name}`;
99
+ }
100
+ }
95
101
  if (sharedFtl) {
96
102
  plan += `\n sharedFtl: ${sharedFtl}`;
97
103
  }
@@ -3,4 +3,4 @@ import type { FurnaceConfig } from '../../types/furnace.js';
3
3
  /**
4
4
  * Validates a proposed custom component against the current furnace config.
5
5
  */
6
- export declare function validateCreateAgainstConfig(config: FurnaceConfig, componentName: string, allowPrefixMismatch: FurnaceCreateOptions['allowPrefixMismatch'], composes: string[] | undefined): void;
6
+ export declare function validateCreateAgainstConfig(config: FurnaceConfig, componentName: string, allowPrefixMismatch: FurnaceCreateOptions['allowPrefixMismatch'], composes: string[] | undefined, stockAdditions?: string[]): void;
@@ -11,11 +11,12 @@ function checkNameConflict(config, name) {
11
11
  }
12
12
  return undefined;
13
13
  }
14
- function validateComposesTargets(config, componentName, composes) {
14
+ function validateComposesTargets(config, componentName, composes, stockAdditions = []) {
15
15
  if (!composes || composes.length === 0)
16
16
  return;
17
17
  const known = new Set([
18
18
  ...config.stock,
19
+ ...stockAdditions,
19
20
  ...Object.keys(config.overrides),
20
21
  ...Object.keys(config.custom),
21
22
  ]);
@@ -42,7 +43,7 @@ function validateComposesTargets(config, componentName, composes) {
42
43
  /**
43
44
  * Validates a proposed custom component against the current furnace config.
44
45
  */
45
- export function validateCreateAgainstConfig(config, componentName, allowPrefixMismatch, composes) {
46
+ export function validateCreateAgainstConfig(config, componentName, allowPrefixMismatch, composes, stockAdditions = []) {
46
47
  const conflict = checkNameConflict(config, componentName);
47
48
  if (conflict) {
48
49
  throw new FurnaceError(conflict, componentName);
@@ -54,6 +55,6 @@ export function validateCreateAgainstConfig(config, componentName, allowPrefixMi
54
55
  `Use a prefixed name (e.g. "${config.componentPrefix}${componentName}"), update ` +
55
56
  '`componentPrefix` in furnace.json, or pass --allow-prefix-mismatch to create the component anyway.', 'name');
56
57
  }
57
- validateComposesTargets(config, componentName, composes);
58
+ validateComposesTargets(config, componentName, composes, stockAdditions);
58
59
  }
59
60
  //# sourceMappingURL=create-validation.js.map
@@ -7,7 +7,7 @@ import { resolveFtlChromeSubPath, tagNameToClassName } from '../../core/furnace-
7
7
  import { recordFurnaceRollbackFailure, runFurnaceMutation, } from '../../core/furnace-operation.js';
8
8
  import { CUSTOM_ELEMENT_TAG_PATTERN, CUSTOM_ELEMENT_TAG_RULES, } from '../../core/furnace-registration-validate.js';
9
9
  import { createRollbackJournal, recordCreatedDir, restoreRollbackJournalOrThrow, snapshotFile, } from '../../core/furnace-rollback.js';
10
- import { isComponentInEngine } from '../../core/furnace-scanner.js';
10
+ import { isComponentInEngine, scanWidgetsDirectory } from '../../core/furnace-scanner.js';
11
11
  import { DEFAULT_LICENSE, getLicenseHeader } from '../../core/license-headers.js';
12
12
  import { registerTestManifest } from '../../core/manifest-register.js';
13
13
  import { validateSharedFtl } from '../../core/shared-ftl.js';
@@ -29,6 +29,26 @@ async function loadAuthoringFurnaceConfig(projectRoot) {
29
29
  }
30
30
  return createDefaultFurnaceConfig();
31
31
  }
32
+ function knownComponentSet(config) {
33
+ return new Set([
34
+ ...config.stock,
35
+ ...Object.keys(config.overrides),
36
+ ...Object.keys(config.custom),
37
+ ]);
38
+ }
39
+ async function resolveComposeStockAdditions(args) {
40
+ const { engineDir, config, componentName, composes } = args;
41
+ if (!composes || composes.length === 0)
42
+ return [];
43
+ const known = knownComponentSet(config);
44
+ const unresolved = composes.filter((tag) => tag !== componentName && !known.has(tag));
45
+ if (unresolved.length === 0 || !(await pathExists(engineDir)))
46
+ return [];
47
+ const scanPaths = config.scanPaths && config.scanPaths.length > 0 ? config.scanPaths : undefined;
48
+ const discovered = await scanWidgetsDirectory(engineDir, undefined, scanPaths);
49
+ const discoveredTags = new Set(discovered.map((component) => component.tagName));
50
+ return unresolved.filter((tag, index) => discoveredTags.has(tag) && unresolved.indexOf(tag) === index);
51
+ }
32
52
  /**
33
53
  * Validates a custom element tag name.
34
54
  * @returns Error message if invalid, undefined if valid
@@ -238,7 +258,13 @@ async function performCreateMutations(args) {
238
258
  let files;
239
259
  try {
240
260
  const freshConfig = await loadAuthoringFurnaceConfig(args.projectRoot);
241
- validateCreateAgainstConfig(freshConfig, args.componentName, args.allowPrefixMismatch, args.composes);
261
+ const freshStockAdditions = await resolveComposeStockAdditions({
262
+ engineDir: args.paths.engine,
263
+ config: freshConfig,
264
+ componentName: args.componentName,
265
+ composes: args.composes,
266
+ });
267
+ validateCreateAgainstConfig(freshConfig, args.componentName, args.allowPrefixMismatch, args.composes, freshStockAdditions);
242
268
  if (await pathExists(args.componentDir)) {
243
269
  throw new FurnaceError(`Directory already exists: components/custom/${args.componentName}`, args.componentName);
244
270
  }
@@ -259,6 +285,11 @@ async function performCreateMutations(args) {
259
285
  if (args.sharedFtl) {
260
286
  customEntry.sharedFtl = args.sharedFtl;
261
287
  }
288
+ for (const name of freshStockAdditions) {
289
+ if (!freshConfig.stock.includes(name)) {
290
+ freshConfig.stock.push(name);
291
+ }
292
+ }
262
293
  freshConfig.custom[args.componentName] = customEntry;
263
294
  await snapshotFile(journal, args.furnacePaths.furnaceConfig);
264
295
  await writeFurnaceConfig(args.projectRoot, freshConfig);
@@ -352,7 +383,13 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
352
383
  // furnace.json behind.
353
384
  const config = await loadAuthoringFurnaceConfig(projectRoot);
354
385
  const composes = options.compose;
355
- validateCreateAgainstConfig(config, componentName, options.allowPrefixMismatch, composes);
386
+ const stockAdditions = await resolveComposeStockAdditions({
387
+ engineDir: paths.engine,
388
+ config,
389
+ componentName,
390
+ composes,
391
+ });
392
+ validateCreateAgainstConfig(config, componentName, options.allowPrefixMismatch, composes, stockAdditions);
356
393
  // Check if it already exists in the engine source tree
357
394
  if (await pathExists(paths.engine)) {
358
395
  if (await isComponentInEngine(paths.engine, componentName)) {
@@ -407,6 +444,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
407
444
  localized,
408
445
  register,
409
446
  composes,
447
+ stockAdditions,
410
448
  // Spread rather than assign so the key is absent when sharedFtl is
411
449
  // undefined — the DryRunPlanInput type uses strict-optional shape.
412
450
  ...(sharedFtl !== undefined ? { sharedFtl } : {}),
@@ -46,6 +46,12 @@ export interface LintCommandOptions {
46
46
  * scope contracts are different.
47
47
  */
48
48
  perPatch?: boolean;
49
+ /**
50
+ * Maximum warning count tolerated before lint exits non-zero. Mirrors
51
+ * ESLint's `--max-warnings` shape for release gates that want advisory
52
+ * findings to become blocking without changing default CLI behavior.
53
+ */
54
+ maxWarnings?: number;
49
55
  }
50
56
  /**
51
57
  * Result of {@link applyAggregateLintIgnoreSuppression}.
@@ -224,6 +224,10 @@ export async function lintCommand(projectRoot, files, options = {}) {
224
224
  if (options.onlyIntroduced && !options.since) {
225
225
  throw new GeneralError('--only-introduced requires --since <git-rev> so introduced-vs-cumulative can be distinguished.');
226
226
  }
227
+ if (options.maxWarnings !== undefined &&
228
+ (!Number.isInteger(options.maxWarnings) || options.maxWarnings < 0)) {
229
+ throw new GeneralError('--max-warnings must be a non-negative integer.');
230
+ }
227
231
  // `--per-patch` rescopes the diff from "aggregate engine state" to "each
228
232
  // patch's own filesAffected". Mixing in explicit file paths would produce
229
233
  // an ambiguous set — is the file list an additional filter, or does it
@@ -240,7 +244,7 @@ export async function lintCommand(projectRoot, files, options = {}) {
240
244
  throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
241
245
  }
242
246
  if (options.perPatch) {
243
- await lintPerPatch(projectRoot, paths);
247
+ await lintPerPatch(projectRoot, paths, options);
244
248
  return;
245
249
  }
246
250
  // Load the config before resolving the diff so we can pass
@@ -367,6 +371,10 @@ export async function lintCommand(projectRoot, files, options = {}) {
367
371
  : '';
368
372
  throw new GeneralError(`Patch lint found ${failingErrors.length} ${options.onlyIntroduced ? 'introduced ' : ''}error(s). Fix these before exporting.${cumulativeSuppressed}`);
369
373
  }
374
+ if (options.maxWarnings !== undefined && warnings.length > options.maxWarnings) {
375
+ outro('Lint failed');
376
+ throw new GeneralError(`Patch lint found ${warnings.length} warning(s), exceeding --max-warnings ${options.maxWarnings}.`);
377
+ }
370
378
  // Notices are advisory and don't count as warnings — emitting "passed
371
379
  // with warnings" when only notices fired contradicts the preceding
372
380
  // `0 warning(s)` summary line and reads as a regression. Distinguish
@@ -398,7 +406,7 @@ export async function lintCommand(projectRoot, files, options = {}) {
398
406
  * Sharing a loop would hide the distinction and force the caller to
399
407
  * decide semantics mid-function.
400
408
  */
401
- async function lintPerPatch(projectRoot, paths) {
409
+ async function lintPerPatch(projectRoot, paths, options = {}) {
402
410
  const manifest = await loadPatchesManifest(paths.patches);
403
411
  if (!manifest || manifest.patches.length === 0) {
404
412
  info('No patches in manifest — nothing to lint per-patch.');
@@ -483,6 +491,10 @@ async function lintPerPatch(projectRoot, paths) {
483
491
  outro('Lint failed');
484
492
  throw new GeneralError(`Patch lint found ${errors.length} error(s) across ${linted} patch(es). Fix these before exporting.`);
485
493
  }
494
+ if (options.maxWarnings !== undefined && warnings.length > options.maxWarnings) {
495
+ outro('Lint failed');
496
+ throw new GeneralError(`Patch lint found ${warnings.length} warning(s) across ${linted} patch(es), exceeding --max-warnings ${options.maxWarnings}.`);
497
+ }
486
498
  if (warnings.length > 0) {
487
499
  outro('Lint passed with warnings');
488
500
  }
@@ -503,6 +515,7 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
503
515
  .option('--since <git-rev>', 'Tag issues as [introduced] or [cumulative] based on whether the file changed since <git-rev> (e.g. HEAD, a branch, a SHA)')
504
516
  .option('--only-introduced', 'Fail only on issues tagged [introduced] (requires --since). Cumulative errors still print but do not set a non-zero exit.')
505
517
  .option('--per-patch', "Lint each patch in the queue as its own isolated diff. Rescopes patch-size rules so they fire against individual patches rather than the aggregate. Honours each patch's `lintIgnore` entries.")
518
+ .option('--max-warnings <n>', 'Fail when lint reports more than <n> warning(s); use 0 for warning-clean release gates.')
506
519
  .action(withErrorHandling(async (paths, options) => {
507
520
  const lintOptions = {};
508
521
  if (options.since !== undefined) {
@@ -514,6 +527,13 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
514
527
  if (options.perPatch !== undefined) {
515
528
  lintOptions.perPatch = options.perPatch;
516
529
  }
530
+ if (options.maxWarnings !== undefined) {
531
+ const maxWarnings = Number(options.maxWarnings);
532
+ if (!Number.isInteger(maxWarnings) || maxWarnings < 0) {
533
+ throw new GeneralError('--max-warnings must be a non-negative integer.');
534
+ }
535
+ lintOptions.maxWarnings = maxWarnings;
536
+ }
517
537
  await lintCommand(getProjectRoot(), paths, lintOptions);
518
538
  }));
519
539
  }
@@ -6,10 +6,11 @@
6
6
  * This command renumbers all patches to sequential ordinals (1, 2, 3, …)
7
7
  * in a single atomic operation, preserving relative order.
8
8
  */
9
- import { getProjectPaths } from '../../core/config.js';
9
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
10
10
  import { appendHistory, confirmDestructive } from '../../core/destructive.js';
11
11
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
12
12
  import { loadPatchesManifest, renumberPatchesInManifest, } from '../../core/patch-manifest.js';
13
+ import { applyRenameMapToManifest, enforcePatchPolicy } from '../../core/patch-policy.js';
13
14
  import { GeneralError } from '../../errors/base.js';
14
15
  import { toError } from '../../utils/errors.js';
15
16
  import { pathExists } from '../../utils/fs.js';
@@ -44,6 +45,7 @@ function computeCompactRenameMap(patches) {
44
45
  export async function patchCompactCommand(projectRoot, options = {}) {
45
46
  intro(options.dryRun ? 'FireForge patch compact (dry run)' : 'FireForge patch compact');
46
47
  const paths = getProjectPaths(projectRoot);
48
+ const config = await loadConfig(projectRoot);
47
49
  if (!(await pathExists(paths.patches))) {
48
50
  throw new GeneralError('Patches directory not found.');
49
51
  }
@@ -62,12 +64,19 @@ export async function patchCompactCommand(projectRoot, options = {}) {
62
64
  for (const [oldFilename, entry] of sorted) {
63
65
  summary.push(` ${oldFilename} → ${entry.newFilename} (order ${entry.newOrder})`);
64
66
  }
67
+ enforcePatchPolicy({
68
+ config,
69
+ manifest: applyRenameMapToManifest(manifest, renameMap),
70
+ command: 'patch compact',
71
+ forceUnsafe: options.forceUnsafe === true,
72
+ });
65
73
  const decision = await confirmDestructive({
66
74
  operation: 'patch-compact',
67
75
  title: `Compact ${manifest.patches.length} patches (${renameMap.size} rename(s))`,
68
76
  summary,
69
77
  yes: options.yes === true,
70
78
  dryRun: options.dryRun === true,
79
+ unsafeOverride: options.forceUnsafe === true,
71
80
  });
72
81
  if (decision === 'dry-run') {
73
82
  outro('Dry run complete — no changes made');
@@ -87,6 +96,12 @@ export async function patchCompactCommand(projectRoot, options = {}) {
87
96
  info('Patch queue was compacted by another process. Nothing to do.');
88
97
  return;
89
98
  }
99
+ enforcePatchPolicy({
100
+ config,
101
+ manifest: applyRenameMapToManifest(currentManifest, currentRenameMap),
102
+ command: 'patch compact',
103
+ forceUnsafe: options.forceUnsafe === true,
104
+ });
90
105
  await renumberPatchesInManifest(paths.patches, currentRenameMap);
91
106
  const historyEntry = {
92
107
  operation: 'patch-compact',
@@ -100,6 +115,7 @@ export async function patchCompactCommand(projectRoot, options = {}) {
100
115
  })),
101
116
  },
102
117
  ...(options.yes === true ? { yes: true } : {}),
118
+ ...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
103
119
  result: 'ok',
104
120
  };
105
121
  try {
@@ -125,6 +141,7 @@ export function registerPatchCompact(parent, context) {
125
141
  .description('Close ordinal gaps in the patch queue (renumber sequentially)')
126
142
  .option('--dry-run', 'Show what would happen without writing')
127
143
  .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
144
+ .option('--force-unsafe', 'Bypass force-mode patchPolicy refusals')
128
145
  .action(withErrorHandling(async (options) => {
129
146
  await patchCompactCommand(getProjectRoot(), pickDefined(options));
130
147
  }));