@hominis/fireforge 0.21.0 → 0.21.2

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 (44) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +41 -0
  3. package/dist/src/commands/config.js +5 -0
  4. package/dist/src/commands/export-all.js +10 -6
  5. package/dist/src/commands/export-flow.d.ts +10 -0
  6. package/dist/src/commands/export-flow.js +50 -2
  7. package/dist/src/commands/export-shared.d.ts +1 -1
  8. package/dist/src/commands/export-shared.js +12 -13
  9. package/dist/src/commands/export.js +40 -4
  10. package/dist/src/commands/furnace/create-templates.js +10 -3
  11. package/dist/src/commands/furnace/create.js +1 -0
  12. package/dist/src/commands/furnace/deploy.js +1 -1
  13. package/dist/src/commands/furnace/validation-output.d.ts +2 -2
  14. package/dist/src/commands/furnace/validation-output.js +20 -4
  15. package/dist/src/commands/lint.js +9 -0
  16. package/dist/src/commands/patch/rename.js +40 -9
  17. package/dist/src/commands/patch/reorder.js +17 -3
  18. package/dist/src/commands/re-export-files.js +16 -1
  19. package/dist/src/commands/re-export.js +21 -10
  20. package/dist/src/commands/verify.js +15 -1
  21. package/dist/src/core/config-paths.d.ts +2 -2
  22. package/dist/src/core/config-paths.js +2 -0
  23. package/dist/src/core/config-validate-patch-policy.d.ts +7 -0
  24. package/dist/src/core/config-validate-patch-policy.js +176 -0
  25. package/dist/src/core/config-validate.js +6 -0
  26. package/dist/src/core/furnace-config-order.d.ts +7 -0
  27. package/dist/src/core/furnace-config-order.js +86 -0
  28. package/dist/src/core/furnace-config.js +13 -1
  29. package/dist/src/core/furnace-validate.js +3 -0
  30. package/dist/src/core/patch-export-coverage.d.ts +58 -0
  31. package/dist/src/core/patch-export-coverage.js +103 -0
  32. package/dist/src/core/patch-export-metadata.d.ts +36 -0
  33. package/dist/src/core/patch-export-metadata.js +69 -0
  34. package/dist/src/core/patch-export-update.d.ts +20 -0
  35. package/dist/src/core/patch-export-update.js +67 -0
  36. package/dist/src/core/patch-export.d.ts +13 -153
  37. package/dist/src/core/patch-export.js +23 -262
  38. package/dist/src/core/patch-manifest-validate.js +2 -2
  39. package/dist/src/core/patch-policy.d.ts +47 -0
  40. package/dist/src/core/patch-policy.js +350 -0
  41. package/dist/src/types/commands/options.d.ts +2 -0
  42. package/dist/src/types/commands/patches.d.ts +1 -1
  43. package/dist/src/types/config.d.ts +51 -0
  44. package/package.json +1 -1
@@ -18,12 +18,13 @@
18
18
  */
19
19
  import { rename as fsRename } from 'node:fs/promises';
20
20
  import { join } from 'node:path';
21
- import { getProjectPaths } from '../../core/config.js';
21
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
22
22
  import { appendHistory, confirmDestructive } from '../../core/destructive.js';
23
23
  import { sanitizeName } from '../../core/patch-export.js';
24
24
  import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
25
25
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
26
26
  import { loadPatchesManifest, resolvePatchIdentifier, savePatchesManifest, } from '../../core/patch-manifest.js';
27
+ import { buildProjectedManifest, enforcePatchPolicy } from '../../core/patch-policy.js';
27
28
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
28
29
  import { toError } from '../../utils/errors.js';
29
30
  import { pathExists } from '../../utils/fs.js';
@@ -66,23 +67,40 @@ async function commitRenameUnderLock(input) {
66
67
  if (!before) {
67
68
  throw new GeneralError(`Patch ${target.filename} disappeared from the manifest during rename.`);
68
69
  }
70
+ let oldPath;
71
+ let newPath;
69
72
  if (filenameChanging) {
70
73
  const collisionInLock = fresh.patches.find((p) => p.filename === newFilename && p.filename !== target.filename);
71
74
  if (collisionInLock) {
72
75
  throw new InvalidArgumentError(`Cannot rename to "${newFilename}" — a different patch claimed that filename concurrently.`, 'patch rename');
73
76
  }
74
- const oldPath = join(patchesDir, target.filename);
75
- const newPath = join(patchesDir, newFilename);
77
+ oldPath = join(patchesDir, target.filename);
78
+ newPath = join(patchesDir, newFilename);
76
79
  if (await pathExists(newPath)) {
77
80
  throw new InvalidArgumentError(`Cannot rename: ${newFilename} already exists on disk. Resolve manually before retrying.`, 'patch rename');
78
81
  }
79
- await fsRename(oldPath, newPath);
80
82
  fresh.patches[idx] = {
81
83
  ...before,
82
84
  filename: newFilename,
83
85
  name: newName,
84
86
  ...(descriptionChanging ? { description: newDescription ?? '' } : {}),
85
87
  };
88
+ }
89
+ else {
90
+ fresh.patches[idx] = {
91
+ ...before,
92
+ ...(nameChanging ? { name: newName } : {}),
93
+ ...(descriptionChanging ? { description: newDescription ?? '' } : {}),
94
+ };
95
+ }
96
+ enforcePatchPolicy({
97
+ config: input.config,
98
+ manifest: buildProjectedManifest(fresh, fresh.patches),
99
+ command: 'patch rename',
100
+ forceUnsafe: input.forceUnsafe === true,
101
+ });
102
+ if (filenameChanging && oldPath !== undefined && newPath !== undefined) {
103
+ await fsRename(oldPath, newPath);
86
104
  try {
87
105
  await savePatchesManifest(patchesDir, fresh);
88
106
  }
@@ -97,11 +115,6 @@ async function commitRenameUnderLock(input) {
97
115
  }
98
116
  }
99
117
  else {
100
- fresh.patches[idx] = {
101
- ...before,
102
- ...(nameChanging ? { name: newName } : {}),
103
- ...(descriptionChanging ? { description: newDescription ?? '' } : {}),
104
- };
105
118
  await savePatchesManifest(patchesDir, fresh);
106
119
  }
107
120
  try {
@@ -115,6 +128,7 @@ async function commitRenameUnderLock(input) {
115
128
  ...(descriptionChanging ? { oldDescription: target.description, newDescription } : {}),
116
129
  },
117
130
  ...(input.yes === true ? { yes: true } : {}),
131
+ ...(input.forceUnsafe === true ? { unsafeOverride: true } : {}),
118
132
  result: 'ok',
119
133
  });
120
134
  }
@@ -138,6 +152,7 @@ export async function patchRenameCommand(projectRoot, identifier, options = {})
138
152
  throw new InvalidArgumentError('Specify --to <new-name>. The new name is sanitised into the filename slug the same way `export --name` is.', 'patch rename');
139
153
  }
140
154
  const paths = getProjectPaths(projectRoot);
155
+ const config = await loadConfig(projectRoot);
141
156
  if (!(await pathExists(paths.patches))) {
142
157
  throw new GeneralError('Patches directory not found.');
143
158
  }
@@ -187,6 +202,19 @@ export async function patchRenameCommand(projectRoot, identifier, options = {})
187
202
  if (descriptionChanging) {
188
203
  summary.push(`description: "${target.description || '(none)'}" → "${options.description ?? '(none)'}"`);
189
204
  }
205
+ enforcePatchPolicy({
206
+ config,
207
+ manifest: buildProjectedManifest(manifest, manifest.patches.map((entry) => entry.filename === target.filename
208
+ ? {
209
+ ...entry,
210
+ filename: newFilename,
211
+ name: nameChanging ? (options.to ?? entry.name) : entry.name,
212
+ ...(descriptionChanging ? { description: options.description ?? '' } : {}),
213
+ }
214
+ : entry)),
215
+ command: 'patch rename',
216
+ forceUnsafe: options.forceUnsafe === true,
217
+ });
190
218
  const decision = await confirmDestructive({
191
219
  operation: 'patch-rename',
192
220
  title: `Rename ${target.filename}`,
@@ -213,6 +241,8 @@ export async function patchRenameCommand(projectRoot, identifier, options = {})
213
241
  nameChanging,
214
242
  descriptionChanging,
215
243
  ...(options.yes === true ? { yes: true } : {}),
244
+ ...(options.forceUnsafe === true ? { forceUnsafe: true } : {}),
245
+ config,
216
246
  });
217
247
  if (filenameChanging) {
218
248
  info(`${target.filename} → ${newFilename}`);
@@ -237,6 +267,7 @@ export function registerPatchRename(parent, context) {
237
267
  .option('--description <text>', 'Replacement description (omit to leave description unchanged)')
238
268
  .option('--dry-run', 'Show what would change without writing')
239
269
  .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
270
+ .option('--force-unsafe', 'Bypass force-mode patchPolicy refusals')
240
271
  .action(withErrorHandling(async (name, options) => {
241
272
  await patchRenameCommand(getProjectRoot(), name, pickDefined(options));
242
273
  }));
@@ -9,12 +9,13 @@
9
9
  * before any bytes move.
10
10
  */
11
11
  import { Option } from 'commander';
12
- import { getProjectPaths } from '../../core/config.js';
12
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
13
13
  import { appendHistory, confirmDestructive, } from '../../core/destructive.js';
14
14
  import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
15
15
  import { buildPatchQueueContext, lintPatchQueue, } from '../../core/patch-lint.js';
16
16
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
17
17
  import { loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, } from '../../core/patch-manifest.js';
18
+ import { applyRenameMapToManifest, enforcePatchPolicy } from '../../core/patch-policy.js';
18
19
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
19
20
  import { toError } from '../../utils/errors.js';
20
21
  import { pathExists } from '../../utils/fs.js';
@@ -184,7 +185,7 @@ function resolveDestination(target, manifestPatches, options) {
184
185
  }
185
186
  return { destinationOrder: anchor.order + 1, anchorFilename: anchor.filename };
186
187
  }
187
- async function commitReorderPlan(patchesDir, target, renameMap, anchorFilename, options, buildHistoryEntry) {
188
+ async function commitReorderPlan(patchesDir, target, renameMap, anchorFilename, options, config, buildHistoryEntry) {
188
189
  await withPatchDirectoryLock(patchesDir, async () => {
189
190
  const currentManifest = await loadPatchesManifest(patchesDir);
190
191
  if (!currentManifest || currentManifest.patches.length === 0) {
@@ -216,6 +217,12 @@ async function commitReorderPlan(patchesDir, target, renameMap, anchorFilename,
216
217
  if (!renameMapsEqual(renameMap, currentRenameMap)) {
217
218
  throw new GeneralError('Patch queue changed while waiting for confirmation. Re-run reorder to recompute the rename plan.');
218
219
  }
220
+ enforcePatchPolicy({
221
+ config,
222
+ manifest: applyRenameMapToManifest(currentManifest, currentRenameMap),
223
+ command: 'patch reorder',
224
+ forceUnsafe: options.forceUnsafe === true,
225
+ });
219
226
  const currentProjected = projectReorder(await buildPatchQueueContext(patchesDir), currentRenameMap);
220
227
  const currentConflicts = lintPatchQueue(currentProjected).filter((i) => i.severity === 'error');
221
228
  if (currentConflicts.length > 0 && options.forceUnsafe !== true) {
@@ -262,6 +269,7 @@ export async function patchReorderCommand(projectRoot, identifier, options = {})
262
269
  throw new InvalidArgumentError('--to, --before, and --after are mutually exclusive.', 'patch reorder');
263
270
  }
264
271
  const paths = getProjectPaths(projectRoot);
272
+ const config = await loadConfig(projectRoot);
265
273
  if (!(await pathExists(paths.patches))) {
266
274
  throw new GeneralError('Patches directory not found.');
267
275
  }
@@ -286,6 +294,12 @@ export async function patchReorderCommand(projectRoot, identifier, options = {})
286
294
  const projected = projectReorder(baseCtx, renameMap);
287
295
  const projectedIssues = lintPatchQueue(projected);
288
296
  const errorIssues = projectedIssues.filter((i) => i.severity === 'error');
297
+ enforcePatchPolicy({
298
+ config,
299
+ manifest: applyRenameMapToManifest(manifest, renameMap),
300
+ command: 'patch reorder',
301
+ forceUnsafe: options.forceUnsafe === true,
302
+ });
289
303
  const conflicts = errorIssues.length > 0
290
304
  ? {
291
305
  reason: `reorder would introduce ${errorIssues.length} cross-patch lint error(s)`,
@@ -344,7 +358,7 @@ export async function patchReorderCommand(projectRoot, identifier, options = {})
344
358
  result: 'ok',
345
359
  };
346
360
  };
347
- await commitReorderPlan(paths.patches, target, renameMap, anchorFilename, options, buildHistoryEntry);
361
+ await commitReorderPlan(paths.patches, target, renameMap, anchorFilename, options, config, buildHistoryEntry);
348
362
  info(`Reordered ${renameMap.size} patch(es).`);
349
363
  outro('Reorder complete');
350
364
  }
@@ -6,6 +6,8 @@ import { computeProjectedLintRegressions } from '../core/lint-projection.js';
6
6
  import { extractAffectedFiles } from '../core/patch-apply.js';
7
7
  import { updatePatchAndMetadata } from '../core/patch-export.js';
8
8
  import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
9
+ import { loadPatchesManifest } from '../core/patch-manifest.js';
10
+ import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
9
11
  import { extractNewFileContentFromDiff } from '../core/patch-transform.js';
10
12
  import { InvalidArgumentError } from '../errors/base.js';
11
13
  import { pathExists } from '../utils/fs.js';
@@ -163,6 +165,16 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
163
165
  const ignoreChecks = effectiveLintIgnore ? new Set(effectiveLintIgnore) : undefined;
164
166
  await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, undefined, ignoreChecks, effectiveTier);
165
167
  const conflicts = await runProjectedCrossPatchLint(paths.patches, target.filename, projectedDiff);
168
+ const filesUpdates = buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveLintIgnore, flagIgnoreSet);
169
+ const manifest = await loadPatchesManifest(paths.patches);
170
+ if (manifest) {
171
+ enforcePatchPolicy({
172
+ config,
173
+ manifest: buildProjectedManifest(manifest, manifest.patches.map((entry) => entry.filename === target.filename ? { ...entry, ...filesUpdates } : entry)),
174
+ command: 're-export --files',
175
+ forceUnsafe: options.forceUnsafe === true,
176
+ });
177
+ }
166
178
  // Shrinks are destructive (previously-owned files become unmanaged).
167
179
  // Additive-only changes still deserve a prompt because --files asserts
168
180
  // an authoritative file set.
@@ -205,7 +217,6 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
205
217
  // directory lock as the mutation (via the onCommitted hook) so two
206
218
  // concurrent re-exports cannot interleave records and a crash between
207
219
  // mutation and append cannot orphan the audit trail.
208
- const filesUpdates = buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveLintIgnore, flagIgnoreSet);
209
220
  await updatePatchAndMetadata(paths.patches, target.filename, projectedDiff, filesUpdates, async () => {
210
221
  await appendHistory(paths.patches, {
211
222
  operation: 're-export-files',
@@ -219,6 +230,10 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
219
230
  ...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
220
231
  result: 'ok',
221
232
  });
233
+ }, {
234
+ config,
235
+ command: 're-export --files',
236
+ forceUnsafe: options.forceUnsafe === true,
222
237
  });
223
238
  success(`Re-exported ${target.filename}`);
224
239
  outro('Re-export complete');
@@ -8,6 +8,7 @@ import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
8
8
  import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
9
9
  import { updatePatchAndMetadata } from '../core/patch-export.js';
10
10
  import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
11
+ import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
11
12
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
12
13
  import { toError } from '../utils/errors.js';
13
14
  import { pathExists } from '../utils/fs.js';
@@ -218,6 +219,21 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
218
219
  const effectiveLintIgnore = mergedIgnoreSet.size > 0 ? [...mergedIgnoreSet] : undefined;
219
220
  const ignoreChecks = effectiveLintIgnore ? new Set(effectiveLintIgnore) : undefined;
220
221
  const effectiveTier = options.tier ?? patch.tier;
222
+ const updates = {
223
+ filesAffected: currentFilesAffected,
224
+ };
225
+ if (options.tier !== undefined) {
226
+ updates.tier = options.tier;
227
+ }
228
+ if (effectiveLintIgnore !== undefined && flagIgnoreSet.size > 0) {
229
+ updates.lintIgnore = effectiveLintIgnore;
230
+ }
231
+ enforcePatchPolicy({
232
+ config,
233
+ manifest: buildProjectedManifest(manifest, manifest.patches.map((entry) => entry.filename === patch.filename ? { ...entry, ...updates } : entry)),
234
+ command: 're-export',
235
+ forceUnsafe: options.forceUnsafe === true,
236
+ });
221
237
  await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, undefined, ignoreChecks, effectiveTier);
222
238
  if (isDryRun) {
223
239
  info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
@@ -235,16 +251,11 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
235
251
  // sequence allows a concurrent `resolve` / `rebase --continue` / `patch
236
252
  // compact` / `patch reorder` to rewrite the manifest between the two
237
253
  // writes and leave patch body and `filesAffected` disagreeing.
238
- const updates = {
239
- filesAffected: currentFilesAffected,
240
- };
241
- if (options.tier !== undefined) {
242
- updates.tier = options.tier;
243
- }
244
- if (effectiveLintIgnore !== undefined && flagIgnoreSet.size > 0) {
245
- updates.lintIgnore = effectiveLintIgnore;
246
- }
247
- await updatePatchAndMetadata(paths.patches, patch.filename, diffContent, updates);
254
+ await updatePatchAndMetadata(paths.patches, patch.filename, diffContent, updates, undefined, {
255
+ config,
256
+ command: 're-export',
257
+ forceUnsafe: options.forceUnsafe === true,
258
+ });
248
259
  // Keep the in-memory manifest in sync so subsequent iterations (notably
249
260
  // `--all --scan`, where `getClaimedFiles` reads from this manifest) see
250
261
  // the just-written `filesAffected`. The on-disk write above is the
@@ -15,9 +15,10 @@
15
15
  * treat the output as pass/fail.
16
16
  */
17
17
  import { join } from 'node:path';
18
- import { getProjectPaths } from '../core/config.js';
18
+ import { getProjectPaths, loadConfig } from '../core/config.js';
19
19
  import { buildPatchQueueContext, lintPatchQueue } from '../core/patch-lint.js';
20
20
  import { loadPatchesManifest, validatePatchesManifestConsistency } from '../core/patch-manifest.js';
21
+ import { evaluatePatchPolicy } from '../core/patch-policy.js';
21
22
  import { collectPatchRegistrationReferences } from '../core/patch-registration-refs.js';
22
23
  import { GeneralError } from '../errors/base.js';
23
24
  import { pathExists, readText } from '../utils/fs.js';
@@ -108,6 +109,7 @@ function detectCrossPatchFileClaims(manifestPatches) {
108
109
  export async function verifyCommand(projectRoot) {
109
110
  intro('FireForge Verify');
110
111
  const paths = getProjectPaths(projectRoot);
112
+ const config = await loadConfig(projectRoot);
111
113
  if (!(await pathExists(paths.patches))) {
112
114
  info('No patches directory. Nothing to verify.');
113
115
  outro('Verify clean');
@@ -129,6 +131,18 @@ export async function verifyCommand(projectRoot) {
129
131
  // same path in filesAffected. Not caught by per-patch consistency.
130
132
  const manifest = await loadPatchesManifest(paths.patches);
131
133
  if (manifest) {
134
+ const policyIssues = evaluatePatchPolicy(config, manifest);
135
+ if (policyIssues.length > 0) {
136
+ warn(`Patch policy issues (${policyIssues.length}):`);
137
+ for (const issue of policyIssues) {
138
+ const label = issue.severity === 'error' ? 'ERROR' : 'WARN';
139
+ warn(` ${label} [${issue.code}] ${issue.filename}: ${issue.message}`);
140
+ if (issue.severity === 'error')
141
+ errorCount += 1;
142
+ else
143
+ warningCount += 1;
144
+ }
145
+ }
132
146
  const crossClaims = detectCrossPatchFileClaims(manifest.patches);
133
147
  if (crossClaims.length > 0) {
134
148
  warn(`Cross-patch filesAffected conflicts (${crossClaims.length}):`);
@@ -17,9 +17,9 @@ export declare const CONFIGS_DIR = "configs";
17
17
  /** Name of the source directory */
18
18
  export declare const SRC_DIR = "src";
19
19
  /** Supported top-level fireforge.json keys backed by the current schema. */
20
- export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint", "typecheck", "markerComment"];
20
+ export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint", "patchPolicy", "typecheck", "markerComment"];
21
21
  /** Supported config paths that can be read or set without --force. */
22
- export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
22
+ export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
23
23
  /**
24
24
  * Gets all project paths based on a root directory.
25
25
  * @param root - Root directory of the project
@@ -28,6 +28,7 @@ export const SUPPORTED_CONFIG_ROOT_KEYS = [
28
28
  'license',
29
29
  'wire',
30
30
  'patchLint',
31
+ 'patchPolicy',
31
32
  'typecheck',
32
33
  'markerComment',
33
34
  ];
@@ -55,6 +56,7 @@ export const SUPPORTED_CONFIG_PATHS = [
55
56
  'patchLint.jsdocClassMethods',
56
57
  'patchLint.testAssertionFloor',
57
58
  'patchLint.chromeScriptJsDoc',
59
+ 'patchPolicy',
58
60
  'typecheck',
59
61
  'typecheck.projects',
60
62
  'typecheck.extraShim',
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Validation helpers for `fireforge.json#patchPolicy`.
3
+ */
4
+ import type { PatchPolicyConfig } from '../types/config.js';
5
+ import { parseObject } from '../utils/parse.js';
6
+ /** Parses and validates the optional patch policy config block. */
7
+ export declare function parsePatchPolicyBlock(rec: ReturnType<typeof parseObject>): PatchPolicyConfig;
@@ -0,0 +1,176 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Validation helpers for `fireforge.json#patchPolicy`.
4
+ */
5
+ import { ConfigError } from '../errors/config.js';
6
+ import { toError } from '../utils/errors.js';
7
+ import { parseObject } from '../utils/parse.js';
8
+ import { isContainedRelativePath } from '../utils/paths.js';
9
+ const PATCH_POLICY_MUTATION_MODES = ['error', 'warn', 'force'];
10
+ function optionalConfigString(rec, key, label) {
11
+ const value = rec.raw(key);
12
+ if (value === undefined)
13
+ return undefined;
14
+ if (typeof value !== 'string') {
15
+ throw new ConfigError(`Config field "${label}" must be a string`);
16
+ }
17
+ return value;
18
+ }
19
+ function parsePositiveRangeEndpoint(raw, label) {
20
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 0) {
21
+ throw new ConfigError(`Config field "${label}" must be a positive integer`);
22
+ }
23
+ return raw;
24
+ }
25
+ function parsePatchPolicyCategory(raw, label) {
26
+ if (typeof raw !== 'string' || !/^[a-z][a-z0-9-]*$/.test(raw)) {
27
+ throw new ConfigError(`Config field "${label}" must be a lowercase category identifier (letters, numbers, hyphens)`);
28
+ }
29
+ return raw;
30
+ }
31
+ function parsePatchPolicyRange(raw, label) {
32
+ let rec;
33
+ try {
34
+ rec = parseObject(raw, label);
35
+ }
36
+ catch {
37
+ throw new ConfigError(`Config field "${label}" must be an object`);
38
+ }
39
+ const from = parsePositiveRangeEndpoint(rec.raw('from'), `${label}.from`);
40
+ const to = parsePositiveRangeEndpoint(rec.raw('to'), `${label}.to`);
41
+ if (to < from) {
42
+ throw new ConfigError(`Config field "${label}.to" must be greater than or equal to from`);
43
+ }
44
+ return {
45
+ from,
46
+ to,
47
+ category: parsePatchPolicyCategory(rec.raw('category'), `${label}.category`),
48
+ };
49
+ }
50
+ function parsePatchPolicyDocumentPath(raw, label) {
51
+ if (raw === undefined)
52
+ return undefined;
53
+ if (typeof raw !== 'string' || raw.trim() === '') {
54
+ throw new ConfigError(`Config field "${label}" must be a non-empty string`);
55
+ }
56
+ if (!isContainedRelativePath(raw)) {
57
+ throw new ConfigError(`Config field "${label}" must be a project-relative path`);
58
+ }
59
+ return raw;
60
+ }
61
+ function parseReservedAllowedPatch(raw, label) {
62
+ let rec;
63
+ try {
64
+ rec = parseObject(raw, label);
65
+ }
66
+ catch {
67
+ throw new ConfigError(`Config field "${label}" must be an object`);
68
+ }
69
+ const filename = optionalConfigString(rec, 'filename', `${label}.filename`);
70
+ if (filename === undefined || filename.trim() === '') {
71
+ throw new ConfigError(`Config field "${label}.filename" must be a non-empty string`);
72
+ }
73
+ const files = rec.raw('files');
74
+ let parsedFiles;
75
+ if (files !== undefined) {
76
+ if (!Array.isArray(files) || files.some((value) => typeof value !== 'string')) {
77
+ throw new ConfigError(`Config field "${label}.files" must be an array of strings`);
78
+ }
79
+ parsedFiles = files;
80
+ }
81
+ const adr = parsePatchPolicyDocumentPath(rec.raw('adr'), `${label}.adr`);
82
+ const documentation = parsePatchPolicyDocumentPath(rec.raw('documentation'), `${label}.documentation`);
83
+ const out = { filename };
84
+ if (parsedFiles !== undefined)
85
+ out.files = parsedFiles;
86
+ if (adr !== undefined)
87
+ out.adr = adr;
88
+ if (documentation !== undefined)
89
+ out.documentation = documentation;
90
+ return out;
91
+ }
92
+ function parsePatchPolicyReservedRange(raw, label) {
93
+ let rec;
94
+ try {
95
+ rec = parseObject(raw, label);
96
+ }
97
+ catch {
98
+ throw new ConfigError(`Config field "${label}" must be an object`);
99
+ }
100
+ const from = parsePositiveRangeEndpoint(rec.raw('from'), `${label}.from`);
101
+ const to = parsePositiveRangeEndpoint(rec.raw('to'), `${label}.to`);
102
+ if (to < from) {
103
+ throw new ConfigError(`Config field "${label}.to" must be greater than or equal to from`);
104
+ }
105
+ const allowedRaw = rec.raw('allowed');
106
+ if (!Array.isArray(allowedRaw)) {
107
+ throw new ConfigError(`Config field "${label}.allowed" must be an array`);
108
+ }
109
+ return {
110
+ from,
111
+ to,
112
+ allowed: allowedRaw.map((entry, index) => parseReservedAllowedPatch(entry, `${label}.allowed[${String(index)}]`)),
113
+ };
114
+ }
115
+ function assertPolicyRangesDoNotOverlap(ranges, label) {
116
+ const sorted = [...ranges].sort((a, b) => a.from - b.from || a.to - b.to);
117
+ for (let i = 1; i < sorted.length; i++) {
118
+ const previous = sorted[i - 1];
119
+ const current = sorted[i];
120
+ if (previous && current && current.from <= previous.to) {
121
+ throw new ConfigError(`Config field "${label}" must not contain overlapping ranges (${previous.from}-${previous.to} overlaps ${current.from}-${current.to})`);
122
+ }
123
+ }
124
+ }
125
+ /** Parses and validates the optional patch policy config block. */
126
+ export function parsePatchPolicyBlock(rec) {
127
+ const out = { ranges: [] };
128
+ const filenamePattern = optionalConfigString(rec, 'filenamePattern', 'patchPolicy.filenamePattern');
129
+ if (filenamePattern !== undefined) {
130
+ try {
131
+ new RegExp(filenamePattern);
132
+ }
133
+ catch (error) {
134
+ throw new ConfigError(`Config field "patchPolicy.filenamePattern" must be a valid regular expression: ${toError(error).message}`);
135
+ }
136
+ out.filenamePattern = filenamePattern;
137
+ }
138
+ const requireDescription = rec.raw('requireDescription');
139
+ if (requireDescription !== undefined) {
140
+ if (typeof requireDescription !== 'boolean') {
141
+ throw new ConfigError('Config field "patchPolicy.requireDescription" must be a boolean');
142
+ }
143
+ out.requireDescription = requireDescription;
144
+ }
145
+ const allowGaps = rec.raw('allowGaps');
146
+ if (allowGaps !== undefined) {
147
+ if (typeof allowGaps !== 'boolean') {
148
+ throw new ConfigError('Config field "patchPolicy.allowGaps" must be a boolean');
149
+ }
150
+ out.allowGaps = allowGaps;
151
+ }
152
+ const mutationMode = rec.raw('mutationMode');
153
+ if (mutationMode !== undefined) {
154
+ if (typeof mutationMode !== 'string' ||
155
+ !PATCH_POLICY_MUTATION_MODES.includes(mutationMode)) {
156
+ throw new ConfigError(`Config field "patchPolicy.mutationMode" must be one of: ${PATCH_POLICY_MUTATION_MODES.join(', ')}`);
157
+ }
158
+ out.mutationMode = mutationMode;
159
+ }
160
+ const rangesRaw = rec.raw('ranges');
161
+ if (!Array.isArray(rangesRaw) || rangesRaw.length === 0) {
162
+ throw new ConfigError('Config field "patchPolicy.ranges" must be a non-empty array');
163
+ }
164
+ out.ranges = rangesRaw.map((entry, index) => parsePatchPolicyRange(entry, `patchPolicy.ranges[${String(index)}]`));
165
+ assertPolicyRangesDoNotOverlap(out.ranges, 'patchPolicy.ranges');
166
+ const reservedRangesRaw = rec.raw('reservedRanges');
167
+ if (reservedRangesRaw !== undefined) {
168
+ if (!Array.isArray(reservedRangesRaw)) {
169
+ throw new ConfigError('Config field "patchPolicy.reservedRanges" must be an array');
170
+ }
171
+ out.reservedRanges = reservedRangesRaw.map((entry, index) => parsePatchPolicyReservedRange(entry, `patchPolicy.reservedRanges[${String(index)}]`));
172
+ assertPolicyRangesDoNotOverlap(out.reservedRanges, 'patchPolicy.reservedRanges');
173
+ }
174
+ return out;
175
+ }
176
+ //# sourceMappingURL=config-validate-patch-policy.js.map
@@ -8,6 +8,7 @@ import { parseObject } from '../utils/parse.js';
8
8
  import { isContainedRelativePath, isExplicitAbsolutePath } from '../utils/paths.js';
9
9
  import { isValidAppId, isValidFirefoxVersion, isValidProjectLicense, PROJECT_LICENSES, validateFirefoxProductVersionCompatibility, } from '../utils/validation.js';
10
10
  import { SUPPORTED_CONFIG_ROOT_KEYS } from './config-paths.js';
11
+ import { parsePatchPolicyBlock } from './config-validate-patch-policy.js';
11
12
  /**
12
13
  * Validates a raw config object and returns a typed FireForgeConfig.
13
14
  * @param data - Raw data to validate
@@ -136,6 +137,11 @@ export function validateConfig(data) {
136
137
  if (patchLintRec) {
137
138
  config.patchLint = parsePatchLintBlock(patchLintRec);
138
139
  }
140
+ // PatchPolicy
141
+ const patchPolicyRec = optionalConfigObject(rec, 'patchPolicy');
142
+ if (patchPolicyRec) {
143
+ config.patchPolicy = parsePatchPolicyBlock(patchPolicyRec);
144
+ }
139
145
  // Typecheck (top-level, distinct from patchLint — see TypecheckConfig docs).
140
146
  const typecheckRec = optionalConfigObject(rec, 'typecheck');
141
147
  if (typecheckRec) {
@@ -0,0 +1,7 @@
1
+ import type { FurnaceConfig } from '../types/furnace.js';
2
+ /**
3
+ * Orders furnace.json output using the existing file as the primary key
4
+ * sequence, preserving unknown extension keys and appending newly supported
5
+ * fields only when needed.
6
+ */
7
+ export declare function orderFurnaceConfigForWrite(existing: Record<string, unknown> | undefined, config: FurnaceConfig): Record<string, unknown>;
@@ -0,0 +1,86 @@
1
+ import { isObject } from '../utils/validation.js';
2
+ const FURNACE_CONFIG_TOP_LEVEL_KEYS = new Set([
3
+ 'version',
4
+ 'componentPrefix',
5
+ 'tokenPrefix',
6
+ 'tokenAllowlist',
7
+ 'platformPrefixes',
8
+ 'runtimeVariables',
9
+ 'tokenHostDocuments',
10
+ 'ftlBasePath',
11
+ 'scanPaths',
12
+ 'stock',
13
+ 'overrides',
14
+ 'custom',
15
+ ]);
16
+ function orderObjectLikeExisting(existing, next) {
17
+ if (!existing)
18
+ return next;
19
+ const ordered = {};
20
+ for (const key of Object.keys(existing)) {
21
+ if (Object.hasOwn(next, key)) {
22
+ ordered[key] = next[key];
23
+ }
24
+ }
25
+ for (const key of Object.keys(next)) {
26
+ if (!Object.hasOwn(ordered, key)) {
27
+ ordered[key] = next[key];
28
+ }
29
+ }
30
+ return ordered;
31
+ }
32
+ function orderComponentMapLikeExisting(existing, next) {
33
+ if (!isObject(next))
34
+ return next;
35
+ if (!isObject(existing))
36
+ return next;
37
+ const ordered = {};
38
+ for (const key of Object.keys(existing)) {
39
+ if (Object.hasOwn(next, key)) {
40
+ const existingValue = existing[key];
41
+ const nextValue = next[key];
42
+ ordered[key] =
43
+ isObject(existingValue) && isObject(nextValue)
44
+ ? orderObjectLikeExisting(existingValue, nextValue)
45
+ : nextValue;
46
+ }
47
+ }
48
+ for (const key of Object.keys(next)) {
49
+ if (!Object.hasOwn(ordered, key)) {
50
+ ordered[key] = next[key];
51
+ }
52
+ }
53
+ return ordered;
54
+ }
55
+ /**
56
+ * Orders furnace.json output using the existing file as the primary key
57
+ * sequence, preserving unknown extension keys and appending newly supported
58
+ * fields only when needed.
59
+ */
60
+ export function orderFurnaceConfigForWrite(existing, config) {
61
+ const next = config;
62
+ if (!existing)
63
+ return next;
64
+ const ordered = {};
65
+ for (const key of Object.keys(existing)) {
66
+ if (key === 'overrides' || key === 'custom') {
67
+ ordered[key] = orderComponentMapLikeExisting(existing[key], next[key]);
68
+ }
69
+ else if (Object.hasOwn(next, key)) {
70
+ ordered[key] = next[key];
71
+ }
72
+ else if (!FURNACE_CONFIG_TOP_LEVEL_KEYS.has(key)) {
73
+ ordered[key] = existing[key];
74
+ }
75
+ }
76
+ for (const key of Object.keys(next)) {
77
+ if (Object.hasOwn(ordered, key))
78
+ continue;
79
+ ordered[key] =
80
+ key === 'overrides' || key === 'custom'
81
+ ? orderComponentMapLikeExisting(existing[key], next[key])
82
+ : next[key];
83
+ }
84
+ return ordered;
85
+ }
86
+ //# sourceMappingURL=furnace-config-order.js.map