@hominis/fireforge 0.47.1 → 0.49.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 (167) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/bin/fireforge.js +5 -0
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/cli.js +16 -28
  5. package/dist/src/commands/build.js +30 -4
  6. package/dist/src/commands/doctor-engine-eslint.d.ts +3 -0
  7. package/dist/src/commands/doctor-engine-eslint.js +12 -0
  8. package/dist/src/commands/doctor-macos-sdk.d.ts +8 -0
  9. package/dist/src/commands/doctor-macos-sdk.js +158 -0
  10. package/dist/src/commands/doctor-source-pin.js +9 -2
  11. package/dist/src/commands/doctor.js +4 -0
  12. package/dist/src/commands/download.js +64 -3
  13. package/dist/src/commands/furnace/apply.js +10 -4
  14. package/dist/src/commands/furnace/create-templates.js +0 -3
  15. package/dist/src/commands/import.js +5 -2
  16. package/dist/src/commands/lint-per-patch.js +44 -6
  17. package/dist/src/commands/lint-per-run-checkjs.d.ts +3 -0
  18. package/dist/src/commands/lint-per-run-checkjs.js +1 -0
  19. package/dist/src/commands/lint.js +14 -0
  20. package/dist/src/commands/manifest.d.ts +1 -2
  21. package/dist/src/commands/patch/delete.js +18 -4
  22. package/dist/src/commands/re-export-lint.d.ts +17 -2
  23. package/dist/src/commands/re-export-lint.js +33 -4
  24. package/dist/src/commands/re-export-options.js +7 -0
  25. package/dist/src/commands/re-export-register.js +1 -0
  26. package/dist/src/commands/re-export-removed.d.ts +4 -0
  27. package/dist/src/commands/re-export-removed.js +49 -0
  28. package/dist/src/commands/re-export-single.js +4 -1
  29. package/dist/src/commands/re-export.js +8 -2
  30. package/dist/src/commands/rebase/confirm.d.ts +12 -1
  31. package/dist/src/commands/rebase/confirm.js +6 -3
  32. package/dist/src/commands/rebase/index.js +77 -13
  33. package/dist/src/commands/run.js +43 -7
  34. package/dist/src/commands/status.js +5 -3
  35. package/dist/src/commands/test-browser-preflight.d.ts +28 -0
  36. package/dist/src/commands/test-browser-preflight.js +181 -0
  37. package/dist/src/commands/test-build-phase.js +30 -7
  38. package/dist/src/commands/test-checkjs.d.ts +3 -0
  39. package/dist/src/commands/test-checkjs.js +27 -0
  40. package/dist/src/commands/test-diagnose.js +3 -0
  41. package/dist/src/commands/test-harness-env.d.ts +5 -6
  42. package/dist/src/commands/test-harness-env.js +27 -7
  43. package/dist/src/commands/test-modes.d.ts +2 -4
  44. package/dist/src/commands/test-register.js +11 -5
  45. package/dist/src/commands/test-run.d.ts +2 -0
  46. package/dist/src/commands/test-run.js +21 -1
  47. package/dist/src/commands/test-verdict.d.ts +2 -2
  48. package/dist/src/commands/test.js +83 -211
  49. package/dist/src/commands/typecheck.d.ts +5 -0
  50. package/dist/src/commands/typecheck.js +7 -1
  51. package/dist/src/core/build-baseline-types.d.ts +12 -14
  52. package/dist/src/core/build-baseline-validate.d.ts +3 -0
  53. package/dist/src/core/build-baseline-validate.js +53 -0
  54. package/dist/src/core/build-baseline.d.ts +4 -3
  55. package/dist/src/core/build-baseline.js +23 -11
  56. package/dist/src/core/build-prepare.d.ts +2 -0
  57. package/dist/src/core/build-prepare.js +67 -35
  58. package/dist/src/core/component-source-guard.d.ts +20 -0
  59. package/dist/src/core/component-source-guard.js +68 -0
  60. package/dist/src/core/config-paths.d.ts +1 -1
  61. package/dist/src/core/config-paths.js +1 -0
  62. package/dist/src/core/config-validate.js +7 -0
  63. package/dist/src/core/coverage-extend.d.ts +5 -34
  64. package/dist/src/core/coverage-extend.js +26 -37
  65. package/dist/src/core/discard-baseline.js +2 -1
  66. package/dist/src/core/engine-write-boundary.d.ts +5 -0
  67. package/dist/src/core/engine-write-boundary.js +41 -0
  68. package/dist/src/core/forward-registrations.d.ts +33 -0
  69. package/dist/src/core/forward-registrations.js +16 -0
  70. package/dist/src/core/furnace-apply-helpers.js +8 -2
  71. package/dist/src/core/furnace-apply.js +7 -0
  72. package/dist/src/core/furnace-validate-accessibility.js +9 -12
  73. package/dist/src/core/furnace-validate.js +0 -43
  74. package/dist/src/core/gecko-dom-types.d.ts +2 -0
  75. package/dist/src/core/gecko-dom-types.js +17 -0
  76. package/dist/src/core/git-file-ops.d.ts +21 -0
  77. package/dist/src/core/git-file-ops.js +75 -0
  78. package/dist/src/core/git-status.d.ts +14 -0
  79. package/dist/src/core/git-status.js +41 -2
  80. package/dist/src/core/lit-template-regions.d.ts +4 -0
  81. package/dist/src/core/lit-template-regions.js +50 -0
  82. package/dist/src/core/mach-build-artifacts.d.ts +6 -6
  83. package/dist/src/core/mach-build-artifacts.js +13 -7
  84. package/dist/src/core/mach-mozconfig.d.ts +2 -1
  85. package/dist/src/core/mach-mozconfig.js +2 -0
  86. package/dist/src/core/mach-resource-shim.d.ts +1 -1
  87. package/dist/src/core/mach-resource-shim.js +2 -0
  88. package/dist/src/core/marionette-port.d.ts +2 -0
  89. package/dist/src/core/marionette-port.js +38 -53
  90. package/dist/src/core/mochitest-server-port.d.ts +1 -1
  91. package/dist/src/core/mochitest-server-port.js +20 -17
  92. package/dist/src/core/objdir-maintenance.d.ts +10 -0
  93. package/dist/src/core/objdir-maintenance.js +151 -0
  94. package/dist/src/core/patch-apply-fuzz.d.ts +12 -0
  95. package/dist/src/core/patch-apply-fuzz.js +27 -6
  96. package/dist/src/core/patch-apply.d.ts +13 -0
  97. package/dist/src/core/patch-apply.js +27 -2
  98. package/dist/src/core/patch-lint-checkjs-cache.d.ts +18 -0
  99. package/dist/src/core/patch-lint-checkjs-cache.js +45 -0
  100. package/dist/src/core/patch-lint-checkjs.d.ts +6 -0
  101. package/dist/src/core/patch-lint-checkjs.js +10 -2
  102. package/dist/src/core/patch-lint-cross.d.ts +7 -1
  103. package/dist/src/core/patch-lint-cross.js +13 -12
  104. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +38 -0
  105. package/dist/src/core/patch-lint-forward-registration-extended.js +240 -0
  106. package/dist/src/core/patch-lint-forward-registration.d.ts +18 -5
  107. package/dist/src/core/patch-lint-forward-registration.js +83 -37
  108. package/dist/src/core/patch-lint.d.ts +11 -0
  109. package/dist/src/core/patch-lint.js +20 -2
  110. package/dist/src/core/patch-transform.d.ts +14 -13
  111. package/dist/src/core/patch-transform.js +29 -17
  112. package/dist/src/core/preflight-wait.d.ts +2 -0
  113. package/dist/src/core/preflight-wait.js +24 -0
  114. package/dist/src/core/process-owner.d.ts +9 -0
  115. package/dist/src/core/process-owner.js +43 -0
  116. package/dist/src/core/rebase-dry-run.d.ts +48 -0
  117. package/dist/src/core/rebase-dry-run.js +105 -0
  118. package/dist/src/core/run-profile.d.ts +50 -0
  119. package/dist/src/core/run-profile.js +131 -0
  120. package/dist/src/core/status-binary.d.ts +1 -1
  121. package/dist/src/core/status-binary.js +3 -1
  122. package/dist/src/core/status-classify.js +29 -2
  123. package/dist/src/core/test-harness-crash.d.ts +1 -1
  124. package/dist/src/core/test-harness-crash.js +5 -5
  125. package/dist/src/core/test-host-state.d.ts +14 -0
  126. package/dist/src/core/test-host-state.js +80 -0
  127. package/dist/src/core/test-profile-files.d.ts +17 -0
  128. package/dist/src/core/test-profile-files.js +75 -0
  129. package/dist/src/core/test-stale-check.d.ts +6 -7
  130. package/dist/src/core/test-stale-check.js +9 -32
  131. package/dist/src/core/token-category.d.ts +7 -26
  132. package/dist/src/core/token-category.js +68 -121
  133. package/dist/src/core/token-inventory.js +12 -7
  134. package/dist/src/core/toolchain-preflight.d.ts +6 -0
  135. package/dist/src/core/toolchain-preflight.js +1 -1
  136. package/dist/src/core/typecheck.d.ts +11 -1
  137. package/dist/src/core/typecheck.js +86 -12
  138. package/dist/src/errors/rebase.d.ts +9 -0
  139. package/dist/src/errors/rebase.js +20 -0
  140. package/dist/src/index.d.ts +3 -1
  141. package/dist/src/index.js +2 -1
  142. package/dist/src/types/commands/options.d.ts +29 -3
  143. package/dist/src/types/config.d.ts +10 -0
  144. package/dist/src/utils/assert.js +2 -7
  145. package/dist/src/utils/child-env.d.ts +0 -3
  146. package/dist/src/utils/child-env.js +0 -3
  147. package/dist/src/utils/concurrency.d.ts +2 -2
  148. package/dist/src/utils/concurrency.js +15 -4
  149. package/dist/src/utils/fs.js +11 -1
  150. package/dist/src/utils/options.d.ts +16 -1
  151. package/dist/src/utils/options.js +5 -3
  152. package/dist/src/utils/paths.js +3 -2
  153. package/dist/src/utils/process-group.d.ts +1 -2
  154. package/dist/src/utils/process-group.js +1 -2
  155. package/dist/src/utils/process-input.d.ts +27 -0
  156. package/dist/src/utils/process-input.js +46 -0
  157. package/dist/src/utils/process.d.ts +5 -2
  158. package/dist/src/utils/process.js +21 -8
  159. package/dist/src/utils/validation.d.ts +11 -0
  160. package/dist/src/utils/validation.js +43 -0
  161. package/docs/configuration.md +82 -0
  162. package/docs/exit-codes.md +1 -1
  163. package/docs/lifecycle-invariants.md +13 -8
  164. package/docs/machine-output.md +36 -19
  165. package/docs/patch-workflow.md +50 -1
  166. package/docs/testing.md +77 -12
  167. package/package.json +10 -5
@@ -94,6 +94,7 @@ async function runWatchLoop(projectRoot) {
94
94
  // because the post-apply checksum snapshot already reflects the edit so
95
95
  // the 30s poll also sees no diff.
96
96
  let pendingChange = false;
97
+ let retryNeeded = false;
97
98
  let lastChecksums = new Map();
98
99
  let pollTimer = null;
99
100
  const runApplyCycle = async () => {
@@ -102,18 +103,21 @@ async function runWatchLoop(projectRoot) {
102
103
  info('\nChange detected — re-applying...');
103
104
  const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
104
105
  logApplyResult(result, false);
106
+ if (result.errors.length > 0 || countEntriesWithBlockingStepErrors(result.applied) > 0) {
107
+ throw new FurnaceError('Watch apply did not complete; retrying on the next poll.');
108
+ }
105
109
  const applied = result.applied.length;
106
110
  const skipped = result.skipped.length;
107
111
  info(`Re-applied: ${applied} applied, ${skipped} skipped`);
112
+ retryNeeded = false;
113
+ lastChecksums = await snapshotWatchedChecksums(watchDirs);
108
114
  }
109
115
  catch (err) {
116
+ retryNeeded = true;
110
117
  warn(classifyWatchApplyError(err));
111
118
  }
112
119
  finally {
113
120
  applyInFlight = false;
114
- // Update checksums after apply so the next poll does not re-trigger
115
- // for changes that are already reflected in the engine.
116
- lastChecksums = await snapshotWatchedChecksums(watchDirs);
117
121
  }
118
122
  // Another change arrived while we were applying, so run again and the
119
123
  // edit is not silently absorbed into the post-apply checksum bump.
@@ -224,7 +228,9 @@ async function runWatchLoop(projectRoot) {
224
228
  return;
225
229
  }
226
230
  const current = await snapshotWatchedChecksums(watchDirs);
227
- if (!checksumMapsEqual(current, lastChecksums)) {
231
+ // A failed apply remains dirty even if source checksums did not
232
+ // change. The polling interval bounds retries without a busy loop.
233
+ if (retryNeeded || !checksumMapsEqual(current, lastChecksums)) {
228
234
  triggerApply();
229
235
  }
230
236
  }
@@ -80,9 +80,6 @@ class ${className} extends MozLitElement {
80
80
  /** @type {Record<string, unknown>} */
81
81
  static properties = {};
82
82
 
83
- constructor() {
84
- super();
85
- }
86
83
  ${lifecycleHooks}
87
84
  render() {
88
85
  return html\`
@@ -30,13 +30,16 @@ function isSafeIoFallback(error) {
30
30
  const code = getNodeErrorCode(error);
31
31
  return code !== undefined && SAFE_IO_FALLBACK_CODES.has(code);
32
32
  }
33
- /** Concurrency bound for per-file classification (each call spawns git). */
33
+ /** Concurrency bound for per-file classification (reads and patch replays). */
34
34
  const UNMANAGED_CLASSIFY_CONCURRENCY = 8;
35
35
  async function getUnmanagedDirtyFiles(engineDir, patchesDir, dirtyFiles) {
36
36
  // One manifest+patch-discovery load for the whole batch (the per-call
37
37
  // computePatchedContent re-read everything for every file), and bounded
38
38
  // concurrency instead of an unbounded Promise.all over git spawns.
39
- const { computePatched: computeExpected } = await createPatchedContentContext(patchesDir, engineDir);
39
+ const { computePatched: computeExpected, prefetchBase } = await createPatchedContentContext(patchesDir, engineDir);
40
+ // One `git cat-file --batch` for every HEAD blob instead of a `git show`
41
+ // per dirty file.
42
+ await prefetchBase(dirtyFiles);
40
43
  const classifications = await mapWithConcurrency(dirtyFiles, UNMANAGED_CLASSIFY_CONCURRENCY, async (file) => {
41
44
  try {
42
45
  const [expected, exists] = await Promise.all([
@@ -138,7 +138,7 @@ const SUPPRESSED_SIZE_CHECKS = new Set([
138
138
  * filename-prefixed issue rows, and the cache writes, all in the same
139
139
  * sequence a serial run produced. Returns the run tallies.
140
140
  */
141
- async function applyPerPatchResults(subset, results, issues, checkJs, cache) {
141
+ async function applyPerPatchResults(subset, results, issues, checkJs, cache, heldNotices) {
142
142
  const totals = {
143
143
  linted: 0,
144
144
  skipped: 0,
@@ -190,6 +190,10 @@ async function applyPerPatchResults(subset, results, issues, checkJs, cache) {
190
190
  for (const suppressedIssue of result.suppressedIssues) {
191
191
  if (!SUPPRESSED_SIZE_CHECKS.has(suppressedIssue.check))
192
192
  continue;
193
+ if (heldNotices !== undefined) {
194
+ heldNotices.push({ check: suppressedIssue.check, patch: patch.filename, suppressed: true });
195
+ continue;
196
+ }
193
197
  info(`NOTICE [${suppressedIssue.check}] ${patch.filename}: suppressed by lintIgnore — ${suppressedIssue.message}`);
194
198
  }
195
199
  totals.suppressed += result.suppressedIssues.length;
@@ -232,7 +236,10 @@ async function lintSubsetConcurrently(subset, lintCtx) {
232
236
  * prints the per-patch summary, and applies the failure criteria
233
237
  * (errors, `--max-warnings`) by throwing GeneralError.
234
238
  */
235
- function reportPerPatchOutcome(issues, linted, skipped, options) {
239
+ function reportPerPatchOutcome(issues, linted, skipped, options, heldNotices) {
240
+ if (heldNotices !== undefined) {
241
+ reportNoticeSummary(heldNotices, issues);
242
+ }
236
243
  if (issues.length === 0) {
237
244
  if (linted === 0 && skipped > 0) {
238
245
  info(`No patches in the queue have been applied to engine/. Run "fireforge import" first if you want lint findings against the staged hunks; otherwise this is expected.`);
@@ -247,8 +254,10 @@ function reportPerPatchOutcome(issues, linted, skipped, options) {
247
254
  const errors = issues.filter((i) => i.severity === 'error');
248
255
  const warnings = issues.filter((i) => i.severity === 'warning');
249
256
  const notices = issues.filter((i) => i.severity === 'notice');
250
- for (const issue of notices)
251
- info(`NOTICE ${formatPatchLintIssue(issue)}`);
257
+ if (heldNotices === undefined) {
258
+ for (const issue of notices)
259
+ info(`NOTICE ${formatPatchLintIssue(issue)}`);
260
+ }
252
261
  for (const issue of warnings)
253
262
  warn(formatPatchLintIssue(issue));
254
263
  for (const issue of errors)
@@ -272,6 +281,34 @@ function reportPerPatchOutcome(issues, linted, skipped, options) {
272
281
  outro('Lint passed');
273
282
  }
274
283
  }
284
+ /**
285
+ * `--notices summary`: one line per check instead of one per finding, for
286
+ * a green queue whose hundreds of NOTICE lines are reviewed waivers and
287
+ * under-threshold measurements. The full list stays in `--report`.
288
+ * Errors and warnings are never summarised.
289
+ */
290
+ function reportNoticeSummary(heldNotices, issues) {
291
+ const byCheck = new Map();
292
+ const tally = (check, patch, suppressed) => {
293
+ const entry = byCheck.get(check) ?? { count: 0, suppressed: 0, patches: new Set() };
294
+ entry.count += 1;
295
+ if (suppressed)
296
+ entry.suppressed += 1;
297
+ entry.patches.add(patch);
298
+ byCheck.set(check, entry);
299
+ };
300
+ for (const held of heldNotices)
301
+ tally(held.check, held.patch, held.suppressed);
302
+ for (const issue of issues) {
303
+ if (issue.severity !== 'notice')
304
+ continue;
305
+ tally(issue.check, issue.patches?.[0] ?? issue.file.split(' :: ')[0] ?? issue.file, false);
306
+ }
307
+ for (const [check, entry] of [...byCheck].sort(([a], [b]) => a.localeCompare(b))) {
308
+ const suppressed = entry.suppressed > 0 ? `, ${entry.suppressed} of them suppressed by lintIgnore` : '';
309
+ info(`NOTICE [${check}]: ${entry.count} across ${entry.patches.size} patch(es)${suppressed} (see --report)`);
310
+ }
311
+ }
275
312
  /**
276
313
  * Resolves the `--patches <name…>` subset filter against the manifest,
277
314
  * matching each requested name tolerantly (exact filename, filename ±
@@ -394,7 +431,8 @@ async function lintPerPatchInner(projectRoot, paths, options = {}) {
394
431
  engineHeadSha,
395
432
  checkJs,
396
433
  });
397
- const { linted, skipped, cacheDirty, reusedCacheEntries, suppressed } = await applyPerPatchResults(subset, results, issues, checkJs, cache);
434
+ const heldNotices = options.notices === 'summary' ? [] : undefined;
435
+ const { linted, skipped, cacheDirty, reusedCacheEntries, suppressed } = await applyPerPatchResults(subset, results, issues, checkJs, cache, heldNotices);
398
436
  for (const issue of lintPatchQueue(ctx)) {
399
437
  if (isSubset && !subsetTouchedFiles.has(issue.file))
400
438
  continue;
@@ -411,5 +449,5 @@ async function lintPerPatchInner(projectRoot, paths, options = {}) {
411
449
  if (options.report !== undefined) {
412
450
  await writePerPatchLintReport(options.report, subset, results);
413
451
  }
414
- reportPerPatchOutcome(issues, linted, skipped, options);
452
+ reportPerPatchOutcome(issues, linted, skipped, options, heldNotices);
415
453
  }
@@ -52,6 +52,9 @@ export interface PerRunCheckJs {
52
52
  /** True when this file kind participates in the run's checkJs surface
53
53
  * (`.sys.mjs`, plus test scripts when `checkJsTestFiles` is on). */
54
54
  isRelevant: (file: string) => boolean;
55
+ /** The program's roots under `rootScopePatches`, or undefined for the
56
+ * whole queue. A file outside it is resolvable but never checked. */
57
+ rootScope: ReadonlySet<string> | undefined;
55
58
  }
56
59
  /**
57
60
  * Builds the per-run checkJs program controller when `patchLint.checkJs` is
@@ -97,5 +97,6 @@ export function buildPerRunCheckJs(projectRoot, paths, config, ctx, rootScopePat
97
97
  return issues;
98
98
  },
99
99
  isRelevant: (file) => file.endsWith('.sys.mjs') || (testFilesEnabled && isTestScriptFile(file)),
100
+ rootScope: scopedRoots,
100
101
  };
101
102
  }
@@ -16,6 +16,7 @@ import { collectDiffFilePaths, tagLintIssues } from '../core/patch-lint-diff-tag
16
16
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
17
17
  import { pathExists } from '../utils/fs.js';
18
18
  import { info, intro, outro, success, warn } from '../utils/logger.js';
19
+ import { addAcceptedWaitLockOption } from '../utils/options.js';
19
20
  import { stripEnginePrefix } from '../utils/paths.js';
20
21
  import { lintPerPatch } from './lint-per-patch.js';
21
22
  /**
@@ -236,6 +237,9 @@ function validateLintFlags(options) {
236
237
  if (options.report !== undefined && !options.perPatch) {
237
238
  throw new InvalidArgumentError('--report requires --per-patch.', '--report');
238
239
  }
240
+ if (options.notices !== undefined && !options.perPatch) {
241
+ throw new InvalidArgumentError('--notices requires --per-patch.', '--notices');
242
+ }
239
243
  }
240
244
  /**
241
245
  * Aggregate-mode patch-size softening: when the linted diff is every
@@ -499,6 +503,7 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
499
503
  .option('--max-warnings <n>', 'Fail when lint reports more than <n> warning(s); use 0 for warning-clean release gates.')
500
504
  .option('--no-cache', 'Bypass per-patch lint result cache reads and writes.')
501
505
  .option('--report <path>', 'With --per-patch, write a machine-readable JSON report (per-patch size metrics, tier, thresholds, issues, and lintIgnore-suppressed issues) to <path>.')
506
+ .option('--notices <mode>', 'With --per-patch: "full" (default) prints every NOTICE line; "summary" prints one line per check with its count and patch count. The full list stays in --report. Errors and warnings always print in full.')
502
507
  .action(withErrorHandling(async (paths, options) => {
503
508
  const lintOptions = {};
504
509
  if (options.since !== undefined) {
@@ -526,8 +531,16 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
526
531
  if (options.report !== undefined) {
527
532
  lintOptions.report = options.report;
528
533
  }
534
+ if (options.notices !== undefined) {
535
+ if (options.notices !== 'summary' && options.notices !== 'full') {
536
+ throw new InvalidArgumentError(`--notices must be "summary" or "full" (got "${options.notices}").`, '--notices');
537
+ }
538
+ lintOptions.notices = options.notices;
539
+ }
529
540
  await lintCommand(getProjectRoot(), paths, lintOptions);
530
541
  }));
542
+ // This action also has subcommands; all its wait flags are accept-only.
543
+ addAcceptedWaitLockOption(lint);
531
544
  const lintCache = lint
532
545
  .command('cache')
533
546
  .description('Manage the per-patch lint result cache')
@@ -540,6 +553,7 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
540
553
  'To lint a directory named cache/, disambiguate with a trailing slash: "fireforge lint cache/".');
541
554
  return Promise.resolve();
542
555
  }));
556
+ addAcceptedWaitLockOption(lintCache);
543
557
  lintCache
544
558
  .command('clear')
545
559
  .description('Clear cached per-patch lint results')
@@ -25,8 +25,7 @@ export interface CommandManifestEntry {
25
25
  */
26
26
  name: string;
27
27
  /**
28
- * Short one-line group label, used purely for grouping in generated
29
- * documentation. Not surfaced in the CLI itself.
28
+ * Group label used by the root CLI help and generated documentation.
30
29
  */
31
30
  group: 'project' | 'workflow' | 'engine' | 'diagnostics' | 'components';
32
31
  /** Registers the command (and any subcommands) on the Commander program. */
@@ -15,6 +15,7 @@ import { appendHistoryBestEffort } from '../../core/history-log.js';
15
15
  import { buildPatchQueueContext, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, isForwardImportableFile, } from '../../core/patch-lint.js';
16
16
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
17
17
  import { removePatchFileAndManifest } from '../../core/patch-manifest.js';
18
+ import { GeneralError } from '../../errors/base.js';
18
19
  import { info, intro, outro, warn } from '../../utils/logger.js';
19
20
  import { addWaitLockOption, pickDefined, resolveWaitLockSeconds } from '../../utils/options.js';
20
21
  import { proceedAfterDecision } from '../destructive-decision.js';
@@ -43,11 +44,12 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
43
44
  // check is built directly from `baseCtx` rather than by diffing lint runs
44
45
  // over a projected state.
45
46
  const targetEntry = baseCtx.entries.find((e) => e.filename === target.filename);
47
+ if (!targetEntry || JSON.stringify(targetEntry.metadata) !== JSON.stringify(target)) {
48
+ throw new GeneralError('Patch queue changed while preparing deletion. Re-run patch delete to review the current target.');
49
+ }
46
50
  const targetNewFileLeaves = new Set();
47
- if (targetEntry) {
48
- for (const fullPath of targetEntry.newFiles.keys()) {
49
- targetNewFileLeaves.add(basename(fullPath));
50
- }
51
+ for (const fullPath of targetEntry.newFiles.keys()) {
52
+ targetNewFileLeaves.add(basename(fullPath));
51
53
  }
52
54
  // Scan every later patch's new files and its added lines on pre-existing
53
55
  // files for import specifiers that resolve to a leaf owned by the target.
@@ -161,6 +163,18 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
161
163
  // re-thrown: by that point the mutation has committed, and reporting
162
164
  // failure to the caller would mislead.
163
165
  await withPatchDirectoryLock(paths.patches, async () => {
166
+ const current = await buildPatchQueueContext(paths.patches);
167
+ const snapshot = (context) => JSON.stringify(context.entries.map(({ filename, order, diff, metadata }) => ({
168
+ filename,
169
+ order,
170
+ diff,
171
+ metadata,
172
+ })));
173
+ // A changed body or manifest invalidates the dependency scan approved
174
+ // before the prompt. Recheck all of its inputs while holding the lock.
175
+ if (snapshot(current) !== snapshot(baseCtx)) {
176
+ throw new GeneralError('Patch queue changed while waiting for confirmation or the lock. Re-run patch delete to review the current dependencies.');
177
+ }
164
178
  await removePatchFileAndManifest(paths.patches, target.filename);
165
179
  await appendHistoryBestEffort(paths.patches, {
166
180
  operation: 'patch-delete',
@@ -44,8 +44,23 @@ export interface ReExportLintContext {
44
44
  /** The pre-existing-queue-errors note is printed once per invocation. */
45
45
  queueBaselineWarned: boolean;
46
46
  }
47
- /** Builds the once-per-invocation lint context. */
48
- export declare function buildReExportLintContext(projectRoot: string, paths: ProjectPaths, config: FireForgeConfig, noCache: boolean): Promise<ReExportLintContext>;
47
+ /**
48
+ * Builds the once-per-invocation lint context.
49
+ *
50
+ * The checkJs program is rooted at the patches being re-exported, exactly as
51
+ * `lint --per-patch --patches` roots it at its subset: the rest of the
52
+ * queue stays resolvable, so cross-patch imports type-check identically,
53
+ * but nothing else is parsed or checked. Unscoped, a one-patch re-export
54
+ * that missed the lint cache type-checked the whole queue (76 s on a
55
+ * 401-patch queue) to report on one patch.
56
+ *
57
+ * @param projectRoot - FireForge project root
58
+ * @param paths - Resolved project paths
59
+ * @param config - Loaded configuration
60
+ * @param noCache - Skip the per-patch lint result cache (`--no-cache`)
61
+ * @param scopePatches - Filenames of the patches this run re-exports
62
+ */
63
+ export declare function buildReExportLintContext(projectRoot: string, paths: ProjectPaths, config: FireForgeConfig, noCache: boolean, scopePatches: ReadonlySet<string>): Promise<ReExportLintContext>;
49
64
  /** Fresh lint payload carried to the post-write cache store. */
50
65
  export interface ReExportLintResult {
51
66
  issues: PatchLintIssue[];
@@ -32,15 +32,39 @@ import { reportPatchLintOutcome } from './export-shared.js';
32
32
  import { buildPerRunCheckJs } from './lint-per-run-checkjs.js';
33
33
  import { projectEntryBody } from './patch/entry-projection.js';
34
34
  import { formatForwardImportRemedy } from './re-export-scan.js';
35
- /** Builds the once-per-invocation lint context. */
36
- export async function buildReExportLintContext(projectRoot, paths, config, noCache) {
35
+ /**
36
+ * Builds the once-per-invocation lint context.
37
+ *
38
+ * The checkJs program is rooted at the patches being re-exported, exactly as
39
+ * `lint --per-patch --patches` roots it at its subset: the rest of the
40
+ * queue stays resolvable, so cross-patch imports type-check identically,
41
+ * but nothing else is parsed or checked. Unscoped, a one-patch re-export
42
+ * that missed the lint cache type-checked the whole queue (76 s on a
43
+ * 401-patch queue) to report on one patch.
44
+ *
45
+ * @param projectRoot - FireForge project root
46
+ * @param paths - Resolved project paths
47
+ * @param config - Loaded configuration
48
+ * @param noCache - Skip the per-patch lint result cache (`--no-cache`)
49
+ * @param scopePatches - Filenames of the patches this run re-exports
50
+ */
51
+ export async function buildReExportLintContext(projectRoot, paths, config, noCache, scopePatches) {
37
52
  const patchQueueCtx = (await pathExists(paths.patches))
38
53
  ? await buildPatchQueueContext(paths.patches, config)
39
54
  : undefined;
40
55
  const checkJs = patchQueueCtx
41
- ? buildPerRunCheckJs(projectRoot, paths, config, patchQueueCtx)
56
+ ? buildPerRunCheckJs(projectRoot, paths, config, patchQueueCtx, scopePatches)
42
57
  : undefined;
43
58
  const cache = noCache ? undefined : await loadPerPatchLintCache(projectRoot);
59
+ if (patchQueueCtx) {
60
+ // The queue rules and the projection below read every patch, so the
61
+ // wait is named rather than left behind one spinner line.
62
+ info(`Lint scope: queue rules over all ${patchQueueCtx.entries.length} patch(es); ` +
63
+ `checkJs rooted at the ${scopePatches.size} re-exported patch(es); ` +
64
+ (cache === undefined
65
+ ? 'lint cache off (--no-cache).'
66
+ : `lint cache ${Object.keys(cache.entries).length === 0 ? 'empty' : 'loaded'}.`));
67
+ }
44
68
  const engineHeadSha = cache ? await getPerPatchLintCacheHeadSha(paths.engine) : undefined;
45
69
  return {
46
70
  projectRoot,
@@ -123,7 +147,12 @@ export async function lintReExportedPatch(args) {
123
147
  lintOptions.onSuppressed = (suppressed) => suppressedIssues.push(...suppressed);
124
148
  if (checkJs) {
125
149
  const created = checkJsFilesInDiff(diffContent, checkJs);
126
- const allKnown = [...created].every((f) => checkJs.resolutionSet.has(f));
150
+ if (created.size > 0) {
151
+ info(`checkJs: type-checking ${created.size} file(s) of ${patch.filename} (lint cache ${cache ? 'miss' : 'off'}).`);
152
+ }
153
+ // A file the scoped program does not root at would be resolvable but
154
+ // never checked, so it takes the per-patch build too.
155
+ const allKnown = [...created].every((f) => checkJs.resolutionSet.has(f) && (checkJs.rootScope?.has(f) ?? true));
127
156
  if (allKnown) {
128
157
  // Slice this patch's findings out of the one queue-wide program.
129
158
  lintOptions.precomputedCheckJs = await checkJs.sliceFor(created);
@@ -63,8 +63,15 @@ function validateWhitelistFlagDependencies(options) {
63
63
  throw new InvalidArgumentError('--expect-unmanaged names approved exceptions to --refuse-adjacent-unmanaged and has no effect without it. Pass --refuse-adjacent-unmanaged, or drop --expect-unmanaged.', '--expect-unmanaged');
64
64
  }
65
65
  }
66
+ function validateExpectedRemovalUsage(patches, options) {
67
+ if (options.expectRemoved?.length &&
68
+ (options.scan || options.all || options.files !== undefined || patches.length === 0)) {
69
+ throw new InvalidArgumentError('--expect-removed requires explicit selected patches and the scan-less path (no --scan, --files, or --all).', '--expect-removed');
70
+ }
71
+ }
66
72
  /** Validates mutually exclusive `re-export` targeting and metadata options. */
67
73
  export function validateReExportOptionCombinations(patches, options) {
74
+ validateExpectedRemovalUsage(patches, options);
68
75
  if (options.files !== undefined) {
69
76
  if (options.all || options.scan) {
70
77
  throw new InvalidArgumentError('--files cannot be combined with --scan or --all.', '--files');
@@ -30,6 +30,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
30
30
  .option('--refuse-foreign-drift', "Refuse a scan-less re-export (non-zero exit, patch not written) when the refreshed body would absorb engine lines not present in the old patch body — protects multi-session checkouts from silently capturing another session's uncommitted edits. Mutually exclusive with --scan and --files.")
31
31
  .option('--expect <path>', "With --refuse-foreign-drift, name an engine-relative file whose drift is this session's intended edit: drift confined to --expect files proceeds, drift anywhere else still refuses. Repeatable, and the whitelist applies across EVERY patch in this invocation, not per patch — so pass all the patches and all their --expect paths in one call rather than looping one patch at a time.", ...stringListOption())
32
32
  .option('--expect-unmanaged <path>', 'With --refuse-adjacent-unmanaged, name an engine-relative file that is a REVIEWED, recorded exception to the adjacency refusal: an approved unmanaged path is still listed, but no longer refuses. Repeatable, and the carve-out applies across EVERY patch in this invocation, not per patch. Use it to keep the belt armed for everything else instead of dropping --refuse-adjacent-unmanaged entirely.', ...stringListOption())
33
+ .option('--expect-removed <path>', 'Retire an absent file created by the selected patch while keeping scan-less refusals armed. Repeatable.', ...stringListOption())
33
34
  .option('--dry-run', 'Show what would change without writing')
34
35
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
35
36
  .option('--no-cache', 'Bypass per-patch lint result cache reads and writes for this re-export')
@@ -0,0 +1,4 @@
1
+ import type { PatchMetadata, ReExportOptions } from '../types/commands/index.js';
2
+ import type { ProjectPaths } from '../types/config.js';
3
+ /** Validates every retirement before any patch is written; typos never silently grant a carve-out. */
4
+ export declare function validateExpectedRemovals(paths: ProjectPaths, patches: PatchMetadata[], options: ReExportOptions): Promise<string[]>;
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { lstat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { listTrackedInHead } from '../core/git-file-ops.js';
5
+ import { parseDiffSections } from '../core/patch-parse.js';
6
+ import { InvalidArgumentError } from '../errors/base.js';
7
+ import { getNodeErrorCode } from '../utils/errors.js';
8
+ import { readText } from '../utils/fs.js';
9
+ import { normalizeEngineRelativeInput } from './re-export-scan.js';
10
+ /** Validates every retirement before any patch is written; typos never silently grant a carve-out. */
11
+ export async function validateExpectedRemovals(paths, patches, options) {
12
+ const removed = [
13
+ ...new Set((options.expectRemoved ?? []).map((file) => normalizeEngineRelativeInput(file, '--expect-removed'))),
14
+ ];
15
+ if (removed.length === 0)
16
+ return removed;
17
+ const tracked = await listTrackedInHead(paths.engine, removed);
18
+ for (const file of removed) {
19
+ const owners = patches.filter((patch) => patch.filesAffected.includes(file));
20
+ if (owners.length !== 1 ||
21
+ tracked.has(file) ||
22
+ (await removalPathPresent(join(paths.engine, file)))) {
23
+ throw new InvalidArgumentError(`--expect-removed ${file} must be absent, untracked in engine HEAD, and owned by exactly one selected patch.`, '--expect-removed');
24
+ }
25
+ const owner = owners[0];
26
+ if (!owner)
27
+ throw new InvalidArgumentError(`No selected owner for ${file}`, '--expect-removed');
28
+ const body = await readText(join(paths.patches, owner.filename));
29
+ if (!parseDiffSections(body).some((section) => section.targetPath === file && section.isNewFile)) {
30
+ throw new InvalidArgumentError(`--expect-removed ${file} was not created by its selected patch.`, '--expect-removed');
31
+ }
32
+ if (owner.filesAffected.every((path) => removed.includes(path))) {
33
+ throw new InvalidArgumentError('Retiring every file would empty the patch; use patch delete instead.', '--expect-removed');
34
+ }
35
+ }
36
+ return removed;
37
+ }
38
+ /** A dangling symlink is still an existing owned path, not an approved absence. */
39
+ async function removalPathPresent(path) {
40
+ try {
41
+ await lstat(path);
42
+ return true;
43
+ }
44
+ catch (error) {
45
+ if (getNodeErrorCode(error) === 'ENOENT')
46
+ return false;
47
+ throw error;
48
+ }
49
+ }
@@ -41,7 +41,10 @@ export async function reExportSinglePatchWithIndexLockRetry(args) {
41
41
  }
42
42
  async function reExportSinglePatch(args) {
43
43
  const { patch, paths, manifest, options, isDryRun, config, adjacentCtx, driftCtx, lintCtx } = args;
44
- let currentFilesAffected = [...patch.filesAffected];
44
+ const removed = new Set(options.expectRemoved ?? []);
45
+ let currentFilesAffected = patch.filesAffected.filter((file) => !removed.has(file));
46
+ for (const file of patch.filesAffected.filter((path) => removed.has(path)))
47
+ info(` - ${file} (--expect-removed)`);
45
48
  // --- Scan for new/removed files ---
46
49
  if (options.scan) {
47
50
  const scanResult = await scanPatchFilesForReExport({
@@ -15,6 +15,7 @@ import { warnUnseenExpectedDrift } from './re-export-drift.js';
15
15
  import { reExportFilesInPlace } from './re-export-files.js';
16
16
  import { buildReExportLintContext, finishReExportLint } from './re-export-lint.js';
17
17
  import { applyReExportFilesPositionalFolding, validateReExportOptionCombinations, } from './re-export-options.js';
18
+ import { validateExpectedRemovals } from './re-export-removed.js';
18
19
  import { normalizeEngineRelativeInput, normalizeScanFiles } from './re-export-scan.js';
19
20
  import { reExportSinglePatchWithIndexLockRetry } from './re-export-single.js';
20
21
  async function resolveSelectedPatches(patches, options, manifest) {
@@ -98,6 +99,9 @@ export async function reExportCommand(projectRoot, patches, options) {
98
99
  outro('Nothing to re-export');
99
100
  return;
100
101
  }
102
+ const expectedRemovals = await validateExpectedRemovals(paths, selectedPatches, options);
103
+ if (expectedRemovals.length > 0)
104
+ options = { ...options, expectRemoved: expectedRemovals };
101
105
  if (scanFilesByPatch !== undefined) {
102
106
  info(`Bulk scan assignments from ${options.scanFilesManifest}`);
103
107
  for (const patch of selectedPatches) {
@@ -132,12 +136,14 @@ export async function reExportCommand(projectRoot, patches, options) {
132
136
  const driftCtx = buildForeignDriftContext(options);
133
137
  // Hoisted lint context, one per run: queue context + checkJs
134
138
  // program + per-patch result cache, shared across every loop iteration.
135
- const lintCtx = await buildReExportLintContext(projectRoot, paths, config, options.noCache === true);
139
+ // Taken before the context build, so the elapsed time the progress line
140
+ // reports includes it.
141
+ const startedAt = Date.now();
142
+ const lintCtx = await buildReExportLintContext(projectRoot, paths, config, options.noCache === true, new Set(selectedPatches.map((patch) => patch.filename)));
136
143
  let reExported = 0;
137
144
  const reExportedFilenames = [];
138
145
  const failedFilenames = [];
139
146
  const progress = spinner('Preparing re-export...');
140
- const startedAt = Date.now();
141
147
  await withDryRunReExportLock(paths.fireforgeDir, isDryRun, async () => withDryRunPurityGuard(paths.engine, paths.patches, isDryRun, async () => {
142
148
  for (const [index, patch] of selectedPatches.entries()) {
143
149
  const assignedScanFiles = scanFilesByPatch?.get(patch.filename);
@@ -22,10 +22,21 @@ export interface DirtyEngineConfirmationOptions {
22
22
  warningMessage: string;
23
23
  promptMessage: string;
24
24
  cancelMessage: string;
25
+ /**
26
+ * Precomputed dirtiness. A caller that already measured what would be
27
+ * lost (and may count more than the working tree, such as commits made
28
+ * on top of the base) passes it here instead of the `hasChanges` probe.
29
+ */
30
+ dirty?: boolean;
31
+ /**
32
+ * Replaces the generic "Engine has uncommitted changes" opening of the
33
+ * non-interactive refusal, so the refusal names what would be lost.
34
+ */
35
+ refusalDetail?: string;
25
36
  }
26
37
  /**
27
38
  * Checks if the engine has uncommitted changes and prompts for confirmation.
28
39
  * Returns true if safe to proceed, false if the user cancelled.
29
40
  * Throws in non-interactive mode without --yes.
30
41
  */
31
- export declare function confirmDirtyEngineReset({ engineDir, yes, nonInteractiveCommand, argumentName, warningMessage, promptMessage, cancelMessage, }: DirtyEngineConfirmationOptions): Promise<boolean>;
42
+ export declare function confirmDirtyEngineReset({ engineDir, yes, nonInteractiveCommand, argumentName, warningMessage, promptMessage, cancelMessage, dirty, refusalDetail, }: DirtyEngineConfirmationOptions): Promise<boolean>;
@@ -12,13 +12,16 @@ import { cancel, isCancel, warn } from '../../utils/logger.js';
12
12
  * Returns true if safe to proceed, false if the user cancelled.
13
13
  * Throws in non-interactive mode without --yes.
14
14
  */
15
- export async function confirmDirtyEngineReset({ engineDir, yes, nonInteractiveCommand, argumentName, warningMessage, promptMessage, cancelMessage, }) {
16
- if (!(await hasChanges(engineDir)) || yes) {
15
+ export async function confirmDirtyEngineReset({ engineDir, yes, nonInteractiveCommand, argumentName, warningMessage, promptMessage, cancelMessage, dirty, refusalDetail, }) {
16
+ if (yes || !(dirty ?? (await hasChanges(engineDir)))) {
17
17
  return true;
18
18
  }
19
19
  const isInteractive = stdioIsInteractive();
20
20
  if (!isInteractive) {
21
- throw new InvalidArgumentError(`Engine has uncommitted changes and interactive confirmation is not available. Run: ${nonInteractiveCommand}`, argumentName);
21
+ const opening = refusalDetail !== undefined
22
+ ? `${refusalDetail} Interactive confirmation is not available.`
23
+ : 'Engine has uncommitted changes and interactive confirmation is not available.';
24
+ throw new InvalidArgumentError(`${opening} Run: ${nonInteractiveCommand}`, argumentName);
22
25
  }
23
26
  warn(warningMessage);
24
27
  const confirmed = await confirm({