@hominis/fireforge 0.30.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +22 -0
  3. package/dist/src/commands/export-all.js +9 -16
  4. package/dist/src/commands/export-flow.d.ts +6 -0
  5. package/dist/src/commands/export-flow.js +6 -1
  6. package/dist/src/commands/export-placement-gate.d.ts +38 -0
  7. package/dist/src/commands/export-placement-gate.js +105 -0
  8. package/dist/src/commands/export-shared.d.ts +28 -0
  9. package/dist/src/commands/export-shared.js +46 -1
  10. package/dist/src/commands/export.js +52 -113
  11. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +0 -13
  12. package/dist/src/commands/furnace/chrome-doc-templates.js +1 -1
  13. package/dist/src/commands/furnace/create-dry-run.d.ts +1 -1
  14. package/dist/src/commands/furnace/create.d.ts +1 -2
  15. package/dist/src/commands/furnace/deploy.js +36 -114
  16. package/dist/src/commands/furnace/refresh.js +52 -32
  17. package/dist/src/commands/furnace/sync.js +2 -0
  18. package/dist/src/commands/import.js +108 -73
  19. package/dist/src/commands/lint-per-patch.d.ts +3 -1
  20. package/dist/src/commands/lint-per-patch.js +265 -74
  21. package/dist/src/commands/lint.d.ts +1 -58
  22. package/dist/src/commands/lint.js +193 -88
  23. package/dist/src/commands/patch/compact.d.ts +5 -2
  24. package/dist/src/commands/patch/compact.js +85 -25
  25. package/dist/src/commands/patch/delete.js +17 -17
  26. package/dist/src/commands/patch/index.js +2 -0
  27. package/dist/src/commands/patch/lint-ignore.js +3 -16
  28. package/dist/src/commands/patch/move-files.js +2 -0
  29. package/dist/src/commands/patch/patch-context.d.ts +41 -0
  30. package/dist/src/commands/patch/patch-context.js +53 -0
  31. package/dist/src/commands/patch/rename.js +10 -15
  32. package/dist/src/commands/patch/reorder.d.ts +0 -2
  33. package/dist/src/commands/patch/reorder.js +18 -19
  34. package/dist/src/commands/patch/split-plan.d.ts +66 -0
  35. package/dist/src/commands/patch/split-plan.js +178 -0
  36. package/dist/src/commands/patch/split.d.ts +30 -0
  37. package/dist/src/commands/patch/split.js +283 -0
  38. package/dist/src/commands/patch/staged-dependency.d.ts +1 -7
  39. package/dist/src/commands/patch/staged-dependency.js +4 -17
  40. package/dist/src/commands/patch/tier.js +4 -17
  41. package/dist/src/commands/re-export-files.js +4 -1
  42. package/dist/src/commands/re-export-scan.js +8 -1
  43. package/dist/src/commands/re-export.js +8 -1
  44. package/dist/src/commands/rebase/summary.d.ts +1 -5
  45. package/dist/src/commands/rebase/summary.js +1 -1
  46. package/dist/src/commands/status-output.js +77 -68
  47. package/dist/src/commands/test-diagnose.d.ts +23 -0
  48. package/dist/src/commands/test-diagnose.js +210 -0
  49. package/dist/src/commands/test-run.d.ts +68 -0
  50. package/dist/src/commands/test-run.js +97 -0
  51. package/dist/src/commands/test.js +214 -263
  52. package/dist/src/commands/token.js +15 -1
  53. package/dist/src/commands/wire.js +109 -78
  54. package/dist/src/core/build-audit.d.ts +1 -1
  55. package/dist/src/core/build-audit.js +2 -46
  56. package/dist/src/core/build-baseline-types.d.ts +38 -0
  57. package/dist/src/core/build-baseline-types.js +10 -0
  58. package/dist/src/core/build-baseline.d.ts +1 -31
  59. package/dist/src/core/build-prepare.d.ts +1 -1
  60. package/dist/src/core/build-prepare.js +2 -45
  61. package/dist/src/core/config-paths.d.ts +0 -8
  62. package/dist/src/core/config-paths.js +4 -4
  63. package/dist/src/core/config-state.d.ts +0 -6
  64. package/dist/src/core/config-state.js +1 -1
  65. package/dist/src/core/config-validate-patch-policy.js +12 -13
  66. package/dist/src/core/config-validate.js +74 -28
  67. package/dist/src/core/engine-changes.d.ts +24 -0
  68. package/dist/src/core/engine-changes.js +64 -0
  69. package/dist/src/core/firefox-cache.d.ts +0 -5
  70. package/dist/src/core/firefox-cache.js +1 -1
  71. package/dist/src/core/firefox-download.d.ts +0 -6
  72. package/dist/src/core/firefox-download.js +1 -1
  73. package/dist/src/core/furnace-apply-helpers.d.ts +1 -8
  74. package/dist/src/core/furnace-apply-helpers.js +11 -20
  75. package/dist/src/core/furnace-apply.d.ts +1 -1
  76. package/dist/src/core/furnace-apply.js +1 -1
  77. package/dist/src/core/furnace-checksum-utils.d.ts +7 -0
  78. package/dist/src/core/furnace-checksum-utils.js +15 -0
  79. package/dist/src/core/furnace-config-validate.d.ts +31 -0
  80. package/dist/src/core/furnace-config-validate.js +133 -0
  81. package/dist/src/core/furnace-config.d.ts +4 -32
  82. package/dist/src/core/furnace-config.js +15 -111
  83. package/dist/src/core/furnace-constants.d.ts +0 -10
  84. package/dist/src/core/furnace-constants.js +2 -2
  85. package/dist/src/core/furnace-css-fragments.d.ts +79 -0
  86. package/dist/src/core/furnace-css-fragments.js +243 -0
  87. package/dist/src/core/furnace-jsconfig.d.ts +63 -0
  88. package/dist/src/core/furnace-jsconfig.js +191 -0
  89. package/dist/src/core/furnace-validate-helpers.d.ts +16 -14
  90. package/dist/src/core/furnace-validate-helpers.js +40 -1
  91. package/dist/src/core/furnace-validate-registration.js +16 -1
  92. package/dist/src/core/furnace-validate.js +54 -2
  93. package/dist/src/core/git-base.d.ts +15 -0
  94. package/dist/src/core/git-base.js +32 -0
  95. package/dist/src/core/git-diff.d.ts +8 -0
  96. package/dist/src/core/git-diff.js +224 -59
  97. package/dist/src/core/git-file-ops.d.ts +39 -12
  98. package/dist/src/core/git-file-ops.js +84 -3
  99. package/dist/src/core/lint-cache.d.ts +0 -13
  100. package/dist/src/core/lint-cache.js +5 -5
  101. package/dist/src/core/mach.d.ts +22 -1
  102. package/dist/src/core/mach.js +27 -2
  103. package/dist/src/core/manifest-register.d.ts +5 -16
  104. package/dist/src/core/manifest-register.js +3 -1
  105. package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
  106. package/dist/src/core/patch-lint-checkjs.js +263 -71
  107. package/dist/src/core/patch-lint-css.d.ts +23 -0
  108. package/dist/src/core/patch-lint-css.js +172 -0
  109. package/dist/src/core/patch-lint-jsdoc.js +63 -4
  110. package/dist/src/core/patch-lint-observer.d.ts +37 -0
  111. package/dist/src/core/patch-lint-observer.js +168 -0
  112. package/dist/src/core/patch-lint.d.ts +34 -11
  113. package/dist/src/core/patch-lint.js +24 -161
  114. package/dist/src/core/patch-manifest-io.d.ts +16 -0
  115. package/dist/src/core/patch-manifest-io.js +44 -2
  116. package/dist/src/core/patch-manifest-validate.d.ts +1 -8
  117. package/dist/src/core/patch-manifest-validate.js +1 -1
  118. package/dist/src/core/patch-manifest.d.ts +1 -1
  119. package/dist/src/core/patch-manifest.js +1 -1
  120. package/dist/src/core/patch-policy.d.ts +0 -4
  121. package/dist/src/core/patch-policy.js +10 -4
  122. package/dist/src/core/register-browser-content.d.ts +1 -1
  123. package/dist/src/core/register-module.d.ts +1 -1
  124. package/dist/src/core/register-result.d.ts +21 -0
  125. package/dist/src/core/register-result.js +9 -0
  126. package/dist/src/core/register-shared-css.d.ts +1 -1
  127. package/dist/src/core/register-test-manifest.d.ts +1 -1
  128. package/dist/src/core/test-harness-crash.d.ts +61 -0
  129. package/dist/src/core/test-harness-crash.js +140 -0
  130. package/dist/src/core/test-stale-check.d.ts +1 -1
  131. package/dist/src/core/test-stale-check.js +2 -46
  132. package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
  133. package/dist/src/core/test-xpcshell-retry.js +10 -3
  134. package/dist/src/core/token-dark-mode.js +14 -26
  135. package/dist/src/core/token-manager.d.ts +4 -0
  136. package/dist/src/core/token-manager.js +70 -16
  137. package/dist/src/core/typecheck-shim.d.ts +3 -22
  138. package/dist/src/core/typecheck-shim.js +69 -7
  139. package/dist/src/core/wire-utils.js +37 -44
  140. package/dist/src/types/commands/index.d.ts +1 -1
  141. package/dist/src/types/commands/options.d.ts +122 -0
  142. package/dist/src/types/config.d.ts +11 -2
  143. package/dist/src/types/furnace.d.ts +12 -1
  144. package/dist/src/utils/elapsed.d.ts +0 -2
  145. package/dist/src/utils/elapsed.js +1 -1
  146. package/dist/src/utils/fs.d.ts +0 -5
  147. package/dist/src/utils/fs.js +1 -1
  148. package/dist/src/utils/regex.d.ts +0 -6
  149. package/dist/src/utils/regex.js +3 -3
  150. package/dist/src/utils/validation.d.ts +0 -8
  151. package/dist/src/utils/validation.js +2 -2
  152. package/package.json +6 -4
@@ -9,7 +9,7 @@ import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
9
9
  import { expandUntrackedDirectoryEntries, getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, getWorkingTreeStatus, } from '../core/git-status.js';
10
10
  import { clearPerPatchLintCache } from '../core/lint-cache.js';
11
11
  import { extractAffectedFiles } from '../core/patch-apply.js';
12
- import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue } from '../core/patch-lint.js';
12
+ import { buildPatchQueueContext, countNonBinaryDiffLines, lintExportedPatch, lintPatchQueue, lintPatchSize, } from '../core/patch-lint.js';
13
13
  import { collectDiffFilePaths, tagLintIssues } from '../core/patch-lint-diff-tag.js';
14
14
  import { GeneralError } from '../errors/base.js';
15
15
  import { pathExists } from '../utils/fs.js';
@@ -215,13 +215,12 @@ export function applyAggregateLintIgnoreSuppression(issues, ctx) {
215
215
  return { issues: filtered, dropped: issues.length - filtered.length };
216
216
  }
217
217
  /**
218
- * Runs the lint command to check engine changes against patch quality rules.
219
- * @param projectRoot - Root directory of the project
220
- * @param files - Optional file/directory paths to lint (relative to engine/)
221
- * @param options - Additional lint options such as `--since` diff-scoping
218
+ * Up-front flag validation for `lintCommand`: rejects `--only-introduced`
219
+ * without `--since`, non-integer `--max-warnings`, and `--per-patch`
220
+ * combined with explicit file paths each a misconfiguration that should
221
+ * fail loud rather than silently narrow the result.
222
222
  */
223
- export async function lintCommand(projectRoot, files, options = {}) {
224
- intro('FireForge Lint');
223
+ function validateLintFlags(options, files) {
225
224
  // `--only-introduced` scopes the exit code to `--since`-tagged issues, so
226
225
  // without a revision to anchor the diff there is no "introduced" subset
227
226
  // to scope to — reject the combination up-front so a misconfigured CI
@@ -235,85 +234,31 @@ export async function lintCommand(projectRoot, files, options = {}) {
235
234
  throw new GeneralError('--max-warnings must be a non-negative integer.');
236
235
  }
237
236
  // `--per-patch` rescopes the diff from "aggregate engine state" to "each
238
- // patch's own filesAffected". Mixing in explicit file paths would produce
239
- // an ambiguous set — is the file list an additional filter, or does it
240
- // replace the per-patch scope? Reject up-front so the operator gets a
241
- // clear error rather than a silently-narrowed result.
237
+ // patch's own filesAffected". Mixing in explicit engine file paths would
238
+ // produce an ambiguous set — is the file list an additional filter, or
239
+ // does it replace the per-patch scope? Reject up-front, but point at the
240
+ // first-class subset filter so an operator who wanted to target patches
241
+ // (not engine files) knows the supported syntax.
242
242
  if (options.perPatch && files.length > 0) {
243
- throw new GeneralError('--per-patch cannot be combined with explicit file paths. Pass either --per-patch or a file list, not both.');
244
- }
245
- const paths = getProjectPaths(projectRoot);
246
- if (!(await pathExists(paths.engine))) {
247
- throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
243
+ throw new GeneralError('--per-patch cannot be combined with explicit engine file paths. ' +
244
+ 'To lint a subset of patches, use `--per-patch --patches <name…>`; ' +
245
+ 'to lint specific engine files, drop --per-patch.');
248
246
  }
249
- if (!(await isGitRepository(paths.engine))) {
250
- throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
251
- }
252
- if (options.perPatch) {
253
- await lintPerPatch(projectRoot, paths, options);
254
- return;
247
+ // `--patches` only means something in per-patch mode (it filters the
248
+ // queue); in aggregate/file-list mode there is no patch loop to narrow.
249
+ if (options.patches !== undefined && !options.perPatch) {
250
+ throw new GeneralError('--patches requires --per-patch.');
255
251
  }
256
- // Load the config before resolving the diff so we can pass
257
- // `binaryName` into the aggregate-mode branding exclusion in
258
- // `resolveLintDiff`. The config was previously loaded only after
259
- // the diff was resolved; hoisting it is cheap and keeps the two
260
- // call sites close together.
261
- const config = await loadConfig(projectRoot);
262
- // Pull the Furnace-managed prefix set up-front so aggregate lint can
263
- // mirror the branding exclusion for Furnace material — without it,
264
- // preview-generated stories under `browser/components/storybook/
265
- // stories/furnace/` show up as license-header errors on every
266
- // post-preview lint run.
267
- const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
268
- const diff = await resolveLintDiff(paths.engine, files, config.binaryName, furnacePrefixes);
269
- if (diff === null)
270
- return;
271
- const filesAffected = extractAffectedFiles(diff);
272
- // Build patch queue context once so it can be shared between the
273
- // per-patch ownership resolver and the cross-patch rules.
274
- let ctx;
275
- if (await pathExists(paths.patches)) {
276
- ctx = await buildPatchQueueContext(paths.patches);
277
- }
278
- let issues = [
279
- ...(await lintExportedPatch(paths.engine, filesAffected, diff, config, ctx)),
280
- ];
281
- // Cross-patch rules operate over the whole queue, so run them whenever a
282
- // patches directory exists — they surface duplicate /dev/null creations
283
- // and forward-import chains that the per-patch orchestrator cannot see.
284
- if (ctx) {
285
- issues.push(...lintPatchQueue(ctx));
286
- }
287
- // Honor per-patch `lintIgnore` in aggregate mode by attributing each
288
- // issue's file to its owning patches via the manifest's
289
- // `filesAffected`. Per-patch mode threads `lintIgnore` directly into
290
- // `lintExportedPatch`; aggregate mode previously had no patch-level
291
- // scope to consult, so a check an operator had explicitly waived in
292
- // `patches.json` re-surfaced on every `--since` run (CI default).
293
- if (ctx) {
294
- const result = applyAggregateLintIgnoreSuppression(issues, ctx);
295
- issues = result.issues;
296
- if (result.dropped > 0) {
297
- info(`Suppressed ${result.dropped} issue(s) via per-patch lintIgnore (aggregate mode).`);
298
- }
299
- }
300
- // When a queue manifest exists AND files were NOT scoped explicitly, the
301
- // "diff" we just linted is every applied patch summed together. Patch-
302
- // size rules (`large-patch-lines`, `large-patch-files`) then fire against
303
- // the aggregate rather than any individual patch, producing counts like
304
- // "Patch is 37529 lines" that read as a task-specific regression but are
305
- // really an artefact of aggregation. Surface a one-line note pointing at
306
- // `--per-patch` so the operator knows the per-patch scope exists before
307
- // they read the error message as "my queue is broken".
308
- //
309
- // In aggregate mode over a multi-patch queue we also downgrade the two
310
- // size rules from `error` to `warning`. Before this downgrade, a
311
- // fresh-imported patch stack of 20+ patches hard-failed `fireforge lint`
312
- // on lines-per-aggregate counts that are mathematically impossible to
313
- // satisfy without splitting patches that were already split — the
314
- // actionable unit is the individual patch, and `--per-patch` is the
315
- // mode that matches. Per-patch mode keeps errors as errors (see
316
- // `lintPerPatch` below).
252
+ }
253
+ /**
254
+ * Aggregate-mode patch-size softening: when the linted diff is every
255
+ * applied patch summed (no explicit file scope, multi-patch queue), the
256
+ * `large-patch-lines` / `large-patch-files` counts are an artefact of
257
+ * aggregation rather than a property of any one patch. Surface the
258
+ * `--per-patch` hint and downgrade those two rules to warnings; per-patch
259
+ * mode keeps them as errors.
260
+ */
261
+ function downgradeAggregateSizeRules(issues, files, ctx) {
317
262
  const aggregateHintApplicable = files.length === 0 && ctx !== undefined && ctx.entries.length > 1;
318
263
  if (aggregateHintApplicable &&
319
264
  issues.some((i) => i.check === 'large-patch-lines' || i.check === 'large-patch-files')) {
@@ -325,18 +270,94 @@ export async function lintCommand(projectRoot, files, options = {}) {
325
270
  }
326
271
  }
327
272
  }
328
- if (issues.length === 0) {
329
- success('No lint issues found.');
330
- outro('Lint passed');
331
- return;
273
+ }
274
+ /**
275
+ * Evaluates the patch-size rules (`large-patch-files` / `large-patch-lines`)
276
+ * for an ad-hoc explicit-file-list lint, scoped to each file's **owning
277
+ * patch** rather than the combined file list.
278
+ *
279
+ * The default file-list path used to feed every passed file to
280
+ * `lintExportedPatch` as one synthetic patch, so a cross-patch selection of
281
+ * eight files belonging to four patches reported `Patch affects 8 files`
282
+ * even though no single owning patch was oversized. This helper instead
283
+ * groups the affected files by their owning patch (via the manifest's
284
+ * `filesAffected`), then runs `lintPatchSize` against each owner's real file
285
+ * count + diff, honouring that owner's `tier` and `lintIgnore` — so
286
+ * `lint <files>`, `lint --per-patch`, and `re-export --dry-run` agree on the
287
+ * same size findings for the same files. Files no patch claims are evaluated
288
+ * together as one prospective new patch, preserving the pre-export
289
+ * oversized-change warning.
290
+ *
291
+ * @param engineDir - Absolute engine directory
292
+ * @param filesAffected - Engine-relative files touched by the ad-hoc diff
293
+ * @param ctx - Patch queue context used to attribute file → owning patch
294
+ * @returns Size issues, each attributed to its owning patch by message prefix
295
+ */
296
+ async function lintOwningPatchSizes(engineDir, filesAffected, ctx) {
297
+ const listed = new Set(filesAffected);
298
+ const owners = new Map();
299
+ const ownedListed = new Set();
300
+ for (const entry of ctx.entries) {
301
+ const md = entry.metadata;
302
+ if (!md)
303
+ continue;
304
+ let ownsAny = false;
305
+ for (const f of md.filesAffected) {
306
+ if (listed.has(f)) {
307
+ ownedListed.add(f);
308
+ ownsAny = true;
309
+ }
310
+ }
311
+ if (ownsAny)
312
+ owners.set(entry.filename, entry);
313
+ }
314
+ const issues = [];
315
+ const lineCountForFiles = async (relPaths) => {
316
+ const existing = [];
317
+ for (const f of relPaths) {
318
+ if (await pathExists(join(engineDir, f)))
319
+ existing.push(f);
320
+ }
321
+ if (existing.length === 0)
322
+ return 0;
323
+ const diff = await getDiffForFilesAgainstHead(engineDir, existing);
324
+ return countNonBinaryDiffLines(diff).textLines;
325
+ };
326
+ for (const entry of owners.values()) {
327
+ const md = entry.metadata;
328
+ if (!md)
329
+ continue;
330
+ const lineCount = await lineCountForFiles(md.filesAffected);
331
+ const ignore = md.lintIgnore?.length ? new Set(md.lintIgnore) : undefined;
332
+ for (const issue of lintPatchSize(md.filesAffected, lineCount, md.tier)) {
333
+ if (ignore?.has(issue.check))
334
+ continue;
335
+ issues.push({ ...issue, message: `${entry.filename}: ${issue.message}` });
336
+ }
332
337
  }
338
+ // Files no patch claims are a prospective new patch: evaluate them as one
339
+ // unit so a genuinely oversized fresh change still surfaces.
340
+ const unowned = filesAffected.filter((f) => !ownedListed.has(f));
341
+ if (unowned.length > 0) {
342
+ const lineCount = await lineCountForFiles(unowned);
343
+ issues.push(...lintPatchSize(unowned, lineCount));
344
+ }
345
+ return issues;
346
+ }
347
+ /**
348
+ * Reporting + exit phase of `lintCommand`: tags issues against `--since`,
349
+ * renders every notice/warning/error row, prints the summary, and applies
350
+ * the failure criteria (`--only-introduced` scoping, `--max-warnings`)
351
+ * by throwing GeneralError. Issues must be non-empty.
352
+ */
353
+ async function reportLintOutcome(engineDir, issues, options) {
333
354
  // Diff-scoping: tag each issue as introduced-in-current-task vs
334
355
  // cumulative-pre-existing-drift. Never filters — full set still prints
335
356
  // and exit code semantics are unchanged — but the per-line prefix and
336
357
  // summary make triage trivial on a large patch series.
337
358
  const sinceActive = Boolean(options.since);
338
359
  if (options.since) {
339
- const diffFiles = await collectDiffFilePaths(paths.engine, options.since);
360
+ const diffFiles = await collectDiffFilePaths(engineDir, options.since);
340
361
  tagLintIssues(issues, diffFiles);
341
362
  }
342
363
  const errors = issues.filter((i) => i.severity === 'error');
@@ -399,6 +420,86 @@ export async function lintCommand(projectRoot, files, options = {}) {
399
420
  outro('Lint passed');
400
421
  }
401
422
  }
423
+ /**
424
+ * Runs the lint command to check engine changes against patch quality rules.
425
+ * @param projectRoot - Root directory of the project
426
+ * @param files - Optional file/directory paths to lint (relative to engine/)
427
+ * @param options - Additional lint options such as `--since` diff-scoping
428
+ */
429
+ export async function lintCommand(projectRoot, files, options = {}) {
430
+ intro('FireForge Lint');
431
+ validateLintFlags(options, files);
432
+ const paths = getProjectPaths(projectRoot);
433
+ if (!(await pathExists(paths.engine))) {
434
+ throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
435
+ }
436
+ if (!(await isGitRepository(paths.engine))) {
437
+ throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
438
+ }
439
+ if (options.perPatch) {
440
+ await lintPerPatch(projectRoot, paths, options);
441
+ return;
442
+ }
443
+ // Load the config before resolving the diff so we can pass
444
+ // `binaryName` into the aggregate-mode branding exclusion in
445
+ // `resolveLintDiff`. The config was previously loaded only after
446
+ // the diff was resolved; hoisting it is cheap and keeps the two
447
+ // call sites close together.
448
+ const config = await loadConfig(projectRoot);
449
+ // Pull the Furnace-managed prefix set up-front so aggregate lint can
450
+ // mirror the branding exclusion for Furnace material — without it,
451
+ // preview-generated stories under `browser/components/storybook/
452
+ // stories/furnace/` show up as license-header errors on every
453
+ // post-preview lint run.
454
+ const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
455
+ const diff = await resolveLintDiff(paths.engine, files, config.binaryName, furnacePrefixes);
456
+ if (diff === null)
457
+ return;
458
+ const filesAffected = extractAffectedFiles(diff);
459
+ // Build patch queue context once so it can be shared between the
460
+ // per-patch ownership resolver and the cross-patch rules.
461
+ let ctx;
462
+ if (await pathExists(paths.patches)) {
463
+ ctx = await buildPatchQueueContext(paths.patches);
464
+ }
465
+ // Ad-hoc explicit-file-list mode evaluates the patch-size rules per
466
+ // owning patch (see `lintOwningPatchSizes`), so suppress the synthetic
467
+ // combined-list size check in the shared pass — otherwise a cross-patch
468
+ // selection synthesises a phantom oversized patch from the file count.
469
+ const fileListMode = files.length > 0 && ctx !== undefined;
470
+ let issues = [
471
+ ...(await lintExportedPatch(paths.engine, filesAffected, diff, config, ctx, undefined, undefined, fileListMode ? { skipPatchSize: true } : undefined)),
472
+ ];
473
+ if (files.length > 0 && ctx) {
474
+ issues.push(...(await lintOwningPatchSizes(paths.engine, filesAffected, ctx)));
475
+ }
476
+ // Cross-patch rules operate over the whole queue, so run them whenever a
477
+ // patches directory exists — they surface duplicate /dev/null creations
478
+ // and forward-import chains that the per-patch orchestrator cannot see.
479
+ if (ctx) {
480
+ issues.push(...lintPatchQueue(ctx));
481
+ }
482
+ // Honor per-patch `lintIgnore` in aggregate mode by attributing each
483
+ // issue's file to its owning patches via the manifest's
484
+ // `filesAffected`. Per-patch mode threads `lintIgnore` directly into
485
+ // `lintExportedPatch`; aggregate mode previously had no patch-level
486
+ // scope to consult, so a check an operator had explicitly waived in
487
+ // `patches.json` re-surfaced on every `--since` run (CI default).
488
+ if (ctx) {
489
+ const result = applyAggregateLintIgnoreSuppression(issues, ctx);
490
+ issues = result.issues;
491
+ if (result.dropped > 0) {
492
+ info(`Suppressed ${result.dropped} issue(s) via per-patch lintIgnore (aggregate mode).`);
493
+ }
494
+ }
495
+ downgradeAggregateSizeRules(issues, files, ctx);
496
+ if (issues.length === 0) {
497
+ success('No lint issues found.');
498
+ outro('Lint passed');
499
+ return;
500
+ }
501
+ await reportLintOutcome(paths.engine, issues, options);
502
+ }
402
503
  /** Registers the lint command on the CLI program. */
403
504
  export function registerLint(program, { getProjectRoot, withErrorHandling }) {
404
505
  const lint = program
@@ -409,6 +510,7 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
409
510
  .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)')
410
511
  .option('--only-introduced', 'Fail only on issues tagged [introduced] (requires --since). Cumulative errors still print but do not set a non-zero exit.')
411
512
  .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.")
513
+ .option('--patches <names...>', 'With --per-patch, lint only the named patches (by filename or manifest name) instead of the whole queue. Queue-level findings are scoped to files those patches touch.')
412
514
  .option('--max-warnings <n>', 'Fail when lint reports more than <n> warning(s); use 0 for warning-clean release gates.')
413
515
  .option('--no-cache', 'Bypass per-patch lint result cache reads and writes.')
414
516
  .action(withErrorHandling(async (paths, options) => {
@@ -422,6 +524,9 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
422
524
  if (options.perPatch !== undefined) {
423
525
  lintOptions.perPatch = options.perPatch;
424
526
  }
527
+ if (options.patches !== undefined) {
528
+ lintOptions.patches = options.patches;
529
+ }
425
530
  if (options.maxWarnings !== undefined) {
426
531
  const maxWarnings = Number(options.maxWarnings);
427
532
  if (!Number.isInteger(maxWarnings) || maxWarnings < 0) {
@@ -2,8 +2,11 @@
2
2
  * `fireforge patch compact` — closes ordinal gaps in the patch queue.
3
3
  *
4
4
  * After deletes or splits, patch ordinals may have gaps (e.g. 1, 3, 7).
5
- * This command renumbers all patches to sequential ordinals (1, 2, 3, …)
6
- * in a single atomic operation, preserving relative order.
5
+ * This command renumbers patches to close those gaps in a single atomic
6
+ * operation, preserving relative order. Without a patch policy the whole
7
+ * queue is renumbered from 1; with `patchPolicy.ranges` configured the
8
+ * compaction is range-aware (each category range compacts independently,
9
+ * reserved ranges and out-of-range strays are left untouched).
7
10
  */
8
11
  import { Command } from 'commander';
9
12
  import type { CommandContext } from '../../types/cli.js';
@@ -3,38 +3,96 @@
3
3
  * `fireforge patch compact` — closes ordinal gaps in the patch queue.
4
4
  *
5
5
  * After deletes or splits, patch ordinals may have gaps (e.g. 1, 3, 7).
6
- * This command renumbers all patches to sequential ordinals (1, 2, 3, …)
7
- * in a single atomic operation, preserving relative order.
6
+ * This command renumbers patches to close those gaps in a single atomic
7
+ * operation, preserving relative order. Without a patch policy the whole
8
+ * queue is renumbered from 1; with `patchPolicy.ranges` configured the
9
+ * compaction is range-aware (each category range compacts independently,
10
+ * reserved ranges and out-of-range strays are left untouched).
8
11
  */
9
- import { getProjectPaths, loadConfig } from '../../core/config.js';
12
+ import { loadConfig } from '../../core/config.js';
10
13
  import { appendHistory, confirmDestructive } from '../../core/destructive.js';
11
14
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
12
15
  import { loadPatchesManifest, renumberPatchesInManifest, } from '../../core/patch-manifest.js';
13
16
  import { applyRenameMapToManifest, enforcePatchPolicy } from '../../core/patch-policy.js';
14
17
  import { GeneralError } from '../../errors/base.js';
15
18
  import { toError } from '../../utils/errors.js';
16
- import { pathExists } from '../../utils/fs.js';
17
19
  import { info, intro, outro, warn } from '../../utils/logger.js';
18
20
  import { pickDefined } from '../../utils/options.js';
21
+ import { requirePatchQueue } from './patch-context.js';
19
22
  import { rebuildFilenameForOrder } from './reorder.js';
23
+ /** True when `order` falls inside a configured reserved range. */
24
+ function isReservedOrder(policyCfg, order) {
25
+ return (policyCfg.reservedRanges ?? []).some((r) => order >= r.from && order <= r.to);
26
+ }
20
27
  /**
21
- * Computes a rename map that assigns sequential ordinals (1, 2, 3, …)
22
- * to all patches, sorted by their current order.
28
+ * Computes a rename map that closes ordinal gaps.
29
+ *
30
+ * Without a patch policy, all patches are renumbered to 1, 2, 3, … in
31
+ * current sort order (historical behaviour). With `patchPolicy.ranges`
32
+ * configured, compaction happens *within* each category range instead:
33
+ * each range's members are renumbered consecutively starting at the
34
+ * range's first occupied ordinal, skipping reserved orders — mirroring
35
+ * what `evaluateGaps` treats as gapless under `allowGaps: false`.
36
+ * Reserved-range patches and patches outside their category's range are
37
+ * never moved (a global renumber would project them across range
38
+ * boundaries and trip `category-range` refusals).
23
39
  */
24
- function computeCompactRenameMap(patches) {
25
- const sorted = [...patches].sort((a, b) => a.order - b.order);
40
+ function computeCompactRenameMap(patches, policyCfg) {
41
+ if (!policyCfg || policyCfg.ranges.length === 0) {
42
+ const sorted = [...patches].sort((a, b) => a.order - b.order);
43
+ const renames = new Map();
44
+ for (const [i, patch] of sorted.entries()) {
45
+ const newOrder = i + 1;
46
+ if (patch.order !== newOrder) {
47
+ renames.set(patch.filename, {
48
+ newOrder,
49
+ newFilename: rebuildFilenameForOrder(patch, newOrder),
50
+ });
51
+ }
52
+ }
53
+ return renames;
54
+ }
26
55
  const renames = new Map();
27
- for (const [i, patch] of sorted.entries()) {
28
- const newOrder = i + 1;
29
- if (patch.order !== newOrder) {
30
- renames.set(patch.filename, {
31
- newOrder,
32
- newFilename: rebuildFilenameForOrder(patch, newOrder),
33
- });
56
+ for (const range of policyCfg.ranges) {
57
+ const members = patches
58
+ .filter((p) => p.category === range.category &&
59
+ p.order >= range.from &&
60
+ p.order <= range.to &&
61
+ !isReservedOrder(policyCfg, p.order))
62
+ .sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
63
+ if (members.length === 0)
64
+ continue;
65
+ // Anchor at the first occupied ordinal rather than range.from: gap
66
+ // evaluation only requires contiguity between first and last occupied,
67
+ // and anchoring minimizes renames.
68
+ let next = members[0].order;
69
+ for (const patch of members) {
70
+ while (isReservedOrder(policyCfg, next))
71
+ next++;
72
+ if (patch.order !== next) {
73
+ renames.set(patch.filename, {
74
+ newOrder: next,
75
+ newFilename: rebuildFilenameForOrder(patch, next),
76
+ });
77
+ }
78
+ next++;
34
79
  }
35
80
  }
36
81
  return renames;
37
82
  }
83
+ /**
84
+ * Patches a range-aware compact leaves in place because they sit outside
85
+ * their category's configured range (or outside all ranges) without a
86
+ * reserved-range exception. They already violate `category-range`; moving
87
+ * them is a policy decision compact must not make silently.
88
+ */
89
+ function findCompactStrays(patches, policyCfg) {
90
+ return patches.filter((p) => {
91
+ if (isReservedOrder(policyCfg, p.order))
92
+ return false;
93
+ return !policyCfg.ranges.some((range) => range.category === p.category && p.order >= range.from && p.order <= range.to);
94
+ });
95
+ }
38
96
  /**
39
97
  * Runs the `patch compact` command: renumbers all patches to close ordinal
40
98
  * gaps in a single atomic operation.
@@ -44,16 +102,18 @@ function computeCompactRenameMap(patches) {
44
102
  */
45
103
  export async function patchCompactCommand(projectRoot, options = {}) {
46
104
  intro(options.dryRun ? 'FireForge patch compact (dry run)' : 'FireForge patch compact');
47
- const paths = getProjectPaths(projectRoot);
48
105
  const config = await loadConfig(projectRoot);
49
- if (!(await pathExists(paths.patches))) {
50
- throw new GeneralError('Patches directory not found.');
51
- }
52
- const manifest = await loadPatchesManifest(paths.patches);
53
- if (!manifest || manifest.patches.length === 0) {
54
- throw new GeneralError('No patches in manifest.');
106
+ const { paths, manifest } = await requirePatchQueue(projectRoot);
107
+ const policyCfg = config.patchPolicy;
108
+ if (policyCfg && policyCfg.ranges.length > 0) {
109
+ const strays = findCompactStrays(manifest.patches, policyCfg);
110
+ for (const stray of strays) {
111
+ warn(`${stray.filename} (order ${stray.order}, category ${stray.category}) sits outside its ` +
112
+ 'configured category range; compact leaves it in place. Use "fireforge patch reorder" ' +
113
+ 'to move it into range first.');
114
+ }
55
115
  }
56
- const renameMap = computeCompactRenameMap(manifest.patches);
116
+ const renameMap = computeCompactRenameMap(manifest.patches, policyCfg);
57
117
  if (renameMap.size === 0) {
58
118
  info('Patch queue is already compact. Nothing to do.');
59
119
  outro('Compact complete (no-op)');
@@ -91,7 +151,7 @@ export async function patchCompactCommand(projectRoot, options = {}) {
91
151
  if (!currentManifest) {
92
152
  throw new GeneralError('Manifest disappeared while waiting for lock.');
93
153
  }
94
- const currentRenameMap = computeCompactRenameMap(currentManifest.patches);
154
+ const currentRenameMap = computeCompactRenameMap(currentManifest.patches, policyCfg);
95
155
  if (currentRenameMap.size === 0) {
96
156
  info('Patch queue was compacted by another process. Nothing to do.');
97
157
  return;
@@ -138,7 +198,7 @@ export function registerPatchCompact(parent, context) {
138
198
  const { getProjectRoot, withErrorHandling } = context;
139
199
  parent
140
200
  .command('compact')
141
- .description('Close ordinal gaps in the patch queue (renumber sequentially)')
201
+ .description('Close ordinal gaps in the patch queue (range-aware when patchPolicy.ranges is configured)')
142
202
  .option('--dry-run', 'Show what would happen without writing')
143
203
  .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
144
204
  .option('--force-unsafe', 'Bypass force-mode patchPolicy refusals')
@@ -8,17 +8,14 @@
8
8
  * `--dry-run`, and appends to `patches/.fireforge-history.jsonl` on success.
9
9
  */
10
10
  import { basename } from 'node:path';
11
- import { getProjectPaths } from '../../core/config.js';
12
11
  import { appendHistory, confirmDestructive } from '../../core/destructive.js';
13
- import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
14
12
  import { buildPatchQueueContext, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, isForwardImportableFile, } from '../../core/patch-lint.js';
15
13
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
16
- import { loadPatchesManifest, removePatchFileAndManifest, resolvePatchIdentifier, } from '../../core/patch-manifest.js';
17
- import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
14
+ import { removePatchFileAndManifest } from '../../core/patch-manifest.js';
18
15
  import { toError } from '../../utils/errors.js';
19
- import { pathExists } from '../../utils/fs.js';
20
16
  import { info, intro, outro, warn } from '../../utils/logger.js';
21
17
  import { pickDefined } from '../../utils/options.js';
18
+ import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
22
19
  /**
23
20
  * Runs the `patch delete` command: removes a patch file and its manifest
24
21
  * row atomically, refusing when a later patch imports a leaf owned by the
@@ -30,18 +27,10 @@ import { pickDefined } from '../../utils/options.js';
30
27
  */
31
28
  export async function patchDeleteCommand(projectRoot, identifier, options = {}) {
32
29
  intro(options.dryRun ? 'FireForge patch delete (dry run)' : 'FireForge patch delete');
33
- const paths = getProjectPaths(projectRoot);
34
- if (!(await pathExists(paths.patches))) {
35
- throw new GeneralError('Patches directory not found. No patches to delete.');
36
- }
37
- const manifest = await loadPatchesManifest(paths.patches);
38
- if (!manifest || manifest.patches.length === 0) {
39
- throw new GeneralError('No patches in manifest.');
40
- }
41
- const target = resolvePatchIdentifier(identifier, manifest.patches);
42
- if (!target) {
43
- throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
44
- }
30
+ const { paths, manifest } = await requirePatchQueue(projectRoot, {
31
+ missingDirMessage: 'Patches directory not found. No patches to delete.',
32
+ });
33
+ const target = requirePatchTarget(identifier, manifest.patches);
45
34
  // Build the full queue context once so we can scan each patch's newFiles
46
35
  // without re-parsing for the dependency check below.
47
36
  const baseCtx = await buildPatchQueueContext(paths.patches);
@@ -113,6 +102,17 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
113
102
  break;
114
103
  }
115
104
  }
105
+ // Staged-dependency declarations on other patches may name the deleted
106
+ // patch as their forward-import owner. The dangling reference also
107
+ // surfaces via cross-patch lint later, but warning here puts the exact
108
+ // cleanup command in front of the operator at decision time.
109
+ const danglingOwnerHolders = baseCtx.entries.filter((entry) => entry.filename !== target.filename &&
110
+ (entry.metadata?.stagedDependencies?.forwardImports ?? []).some((fi) => fi.owner === target.filename));
111
+ for (const holder of danglingOwnerHolders) {
112
+ warn(`${holder.filename} declares a staged dependency with owner ${target.filename}; ` +
113
+ `after the delete, update it via "fireforge patch staged-dependency ${holder.filename} --remove ..." ` +
114
+ 'or re-point the owner at the patch that will create the file.');
115
+ }
116
116
  const conflicts = dependents.length > 0
117
117
  ? {
118
118
  // Wording deliberately clarifies the *runtime* impact: `git apply`
@@ -13,6 +13,7 @@ import { registerPatchLintIgnore } from './lint-ignore.js';
13
13
  import { registerPatchMoveFiles } from './move-files.js';
14
14
  import { registerPatchRename } from './rename.js';
15
15
  import { registerPatchReorder } from './reorder.js';
16
+ import { registerPatchSplit } from './split.js';
16
17
  import { registerPatchStagedDependency } from './staged-dependency.js';
17
18
  import { registerPatchTier } from './tier.js';
18
19
  /**
@@ -40,6 +41,7 @@ export function registerPatch(program, context) {
40
41
  registerPatchMoveFiles(patch, context);
41
42
  registerPatchRename(patch, context);
42
43
  registerPatchReorder(patch, context);
44
+ registerPatchSplit(patch, context);
43
45
  registerPatchStagedDependency(patch, context);
44
46
  registerPatchTier(patch, context);
45
47
  }
@@ -19,15 +19,12 @@
19
19
  * write — important when an operator scripts repeated invocations or
20
20
  * runs `--add` and `--remove` back-to-back.
21
21
  */
22
- import { getProjectPaths } from '../../core/config.js';
23
22
  import { appendHistory } from '../../core/destructive.js';
24
23
  import { mutatePatchMetadata } from '../../core/patch-export.js';
25
- import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
26
- import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
27
24
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
28
25
  import { toError } from '../../utils/errors.js';
29
- import { pathExists } from '../../utils/fs.js';
30
26
  import { info, intro, outro, warn } from '../../utils/logger.js';
27
+ import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
31
28
  /**
32
29
  * Computes the post-mutation `lintIgnore` list for a given mode.
33
30
  * Returns `undefined` when the result should drop the field from the
@@ -108,18 +105,8 @@ export async function patchLintIgnoreCommand(projectRoot, identifier, options =
108
105
  }
109
106
  const mode = adding ? 'add' : removing ? 'remove' : 'clear';
110
107
  const values = mode === 'add' ? (options.add ?? []) : mode === 'remove' ? (options.remove ?? []) : [];
111
- const paths = getProjectPaths(projectRoot);
112
- if (!(await pathExists(paths.patches))) {
113
- throw new GeneralError('Patches directory not found.');
114
- }
115
- const manifest = await loadPatchesManifest(paths.patches);
116
- if (!manifest || manifest.patches.length === 0) {
117
- throw new GeneralError('No patches in manifest.');
118
- }
119
- const target = resolvePatchIdentifier(identifier, manifest.patches);
120
- if (!target) {
121
- throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
122
- }
108
+ const { paths, manifest } = await requirePatchQueue(projectRoot);
109
+ const target = requirePatchTarget(identifier, manifest.patches);
123
110
  if (isDryRun) {
124
111
  const existing = target.lintIgnore ?? [];
125
112
  const projected = applyMode(existing, mode, values) ?? [];
@@ -116,6 +116,8 @@ export async function patchMoveFilesCommand(projectRoot, fromIdentifier, toIdent
116
116
  const applyTarget = formatReExportCommand(target.filename, targetAfter, []);
117
117
  note(`${dryRunSource}\n${dryRunTarget}`, 'Preview commands');
118
118
  note(`${applySource}\n${applyTarget}`, 'Apply commands');
119
+ info('Tip: to move files into a brand-new patch in one transaction (including ' +
120
+ 'staged-dependency owner rewrites), use "fireforge patch split" instead.');
119
121
  outro('Move plan complete - no changes made');
120
122
  }
121
123
  /**