@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.
- package/CHANGELOG.md +68 -749
- package/README.md +52 -752
- package/dist/src/commands/doctor-furnace-manifest-sync.js +42 -4
- package/dist/src/commands/doctor.js +17 -3
- package/dist/src/commands/download.js +1 -1
- package/dist/src/commands/export-flow.d.ts +7 -0
- package/dist/src/commands/export-flow.js +33 -7
- package/dist/src/commands/export-placement-policy.d.ts +14 -0
- package/dist/src/commands/export-placement-policy.js +54 -0
- package/dist/src/commands/export.js +6 -2
- package/dist/src/commands/furnace/create-dry-run.d.ts +1 -0
- package/dist/src/commands/furnace/create-dry-run.js +7 -1
- package/dist/src/commands/furnace/create-validation.d.ts +1 -1
- package/dist/src/commands/furnace/create-validation.js +4 -3
- package/dist/src/commands/furnace/create.js +41 -3
- package/dist/src/commands/lint.d.ts +6 -0
- package/dist/src/commands/lint.js +22 -2
- package/dist/src/commands/patch/compact.js +18 -1
- package/dist/src/commands/token-coverage.js +61 -2
- package/dist/src/commands/verify.d.ts +19 -0
- package/dist/src/commands/verify.js +90 -61
- package/dist/src/core/patch-policy.d.ts +1 -1
- package/dist/src/core/patch-policy.js +23 -0
- package/dist/src/types/commands/options.d.ts +9 -1
- package/package.json +1 -1
|
@@ -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 =
|
|
153
|
-
? ` ${
|
|
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
|
-
|
|
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
|
|
272
|
+
info('Indexing downloaded source into git (one-time; typically 3–5 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}));
|