@hominis/fireforge 0.40.0 → 0.42.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 (256) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +16 -10
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +20 -3
  6. package/dist/src/commands/bootstrap.js +5 -1
  7. package/dist/src/commands/discard.js +2 -22
  8. package/dist/src/commands/doctor/post-rebase-audit.js +27 -4
  9. package/dist/src/commands/doctor-check-core.d.ts +20 -0
  10. package/dist/src/commands/doctor-check-core.js +28 -0
  11. package/dist/src/commands/doctor-external-toolchains.js +2 -0
  12. package/dist/src/commands/doctor-furnace-manifest-sync.js +144 -46
  13. package/dist/src/commands/doctor-furnace.js +13 -11
  14. package/dist/src/commands/doctor-orphaned-harness.js +2 -1
  15. package/dist/src/commands/doctor.js +2 -2
  16. package/dist/src/commands/export-all.js +9 -1
  17. package/dist/src/commands/export-flow.js +13 -3
  18. package/dist/src/commands/export-placement-conflicts.d.ts +17 -0
  19. package/dist/src/commands/export-placement-conflicts.js +74 -0
  20. package/dist/src/commands/export-placement-gate.js +1 -1
  21. package/dist/src/commands/export-shared.d.ts +8 -1
  22. package/dist/src/commands/export-shared.js +16 -7
  23. package/dist/src/commands/export.js +2 -2
  24. package/dist/src/commands/furnace/apply.js +4 -3
  25. package/dist/src/commands/furnace/chrome-doc-remove.js +13 -2
  26. package/dist/src/commands/furnace/chrome-doc.js +13 -2
  27. package/dist/src/commands/furnace/create-browser-test.d.ts +5 -0
  28. package/dist/src/commands/furnace/create-browser-test.js +5 -0
  29. package/dist/src/commands/furnace/create.js +2 -2
  30. package/dist/src/commands/furnace/diff.js +2 -1
  31. package/dist/src/commands/furnace/init.js +4 -6
  32. package/dist/src/commands/furnace/override.js +2 -2
  33. package/dist/src/commands/furnace/remove-mutations.d.ts +40 -0
  34. package/dist/src/commands/furnace/remove-mutations.js +171 -0
  35. package/dist/src/commands/furnace/remove.js +21 -128
  36. package/dist/src/commands/furnace/rename-browser-test.js +54 -5
  37. package/dist/src/commands/furnace/scan.js +27 -36
  38. package/dist/src/commands/furnace/status.js +30 -28
  39. package/dist/src/commands/furnace/validate.js +25 -18
  40. package/dist/src/commands/import.js +6 -20
  41. package/dist/src/commands/lint-per-patch.js +12 -59
  42. package/dist/src/commands/lint-per-run-checkjs.d.ts +64 -0
  43. package/dist/src/commands/lint-per-run-checkjs.js +102 -0
  44. package/dist/src/commands/lint.js +4 -4
  45. package/dist/src/commands/manifest.js +1 -1
  46. package/dist/src/commands/package.js +9 -16
  47. package/dist/src/commands/patch/compact.js +5 -5
  48. package/dist/src/commands/patch/delete.js +5 -5
  49. package/dist/src/commands/patch/lint-ignore.js +3 -2
  50. package/dist/src/commands/patch/move-files-create.js +22 -4
  51. package/dist/src/commands/patch/move-files-into.js +14 -10
  52. package/dist/src/commands/patch/move-files.js +6 -5
  53. package/dist/src/commands/patch/rename.d.ts +2 -2
  54. package/dist/src/commands/patch/rename.js +151 -50
  55. package/dist/src/commands/patch/reorder.d.ts +9 -0
  56. package/dist/src/commands/patch/reorder.js +14 -10
  57. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  58. package/dist/src/commands/patch/split-plan.js +22 -12
  59. package/dist/src/commands/patch/split.d.ts +1 -1
  60. package/dist/src/commands/patch/split.js +23 -9
  61. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  62. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  63. package/dist/src/commands/patch/staged-dependency.js +10 -0
  64. package/dist/src/commands/re-export-adjacent.js +5 -6
  65. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  66. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  67. package/dist/src/commands/re-export-drift.d.ts +74 -0
  68. package/dist/src/commands/re-export-drift.js +234 -0
  69. package/dist/src/commands/re-export-files.js +5 -3
  70. package/dist/src/commands/re-export-lint.d.ts +97 -0
  71. package/dist/src/commands/re-export-lint.js +210 -0
  72. package/dist/src/commands/re-export-options.js +8 -0
  73. package/dist/src/commands/re-export-register.d.ts +18 -0
  74. package/dist/src/commands/re-export-register.js +61 -0
  75. package/dist/src/commands/re-export-scan.js +13 -7
  76. package/dist/src/commands/re-export-single.d.ts +20 -0
  77. package/dist/src/commands/re-export-single.js +261 -0
  78. package/dist/src/commands/re-export.d.ts +0 -4
  79. package/dist/src/commands/re-export.js +113 -268
  80. package/dist/src/commands/rebase/abort.js +13 -4
  81. package/dist/src/commands/rebase/continue.js +11 -4
  82. package/dist/src/commands/rebase/index.js +19 -29
  83. package/dist/src/commands/rebase/patch-loop.js +12 -0
  84. package/dist/src/commands/reset.js +4 -23
  85. package/dist/src/commands/resolve.js +2 -9
  86. package/dist/src/commands/run.js +11 -21
  87. package/dist/src/commands/source.js +3 -9
  88. package/dist/src/commands/status-check.d.ts +13 -0
  89. package/dist/src/commands/status-check.js +31 -13
  90. package/dist/src/commands/status-json.d.ts +31 -0
  91. package/dist/src/commands/status-json.js +76 -0
  92. package/dist/src/commands/status-output.d.ts +1 -0
  93. package/dist/src/commands/status-output.js +10 -1
  94. package/dist/src/commands/status-ownership.d.ts +52 -0
  95. package/dist/src/commands/status-ownership.js +57 -0
  96. package/dist/src/commands/status.js +184 -175
  97. package/dist/src/commands/test-build-phase.d.ts +19 -0
  98. package/dist/src/commands/test-build-phase.js +168 -0
  99. package/dist/src/commands/test-diagnose.js +30 -2
  100. package/dist/src/commands/test-modes.d.ts +9 -1
  101. package/dist/src/commands/test-modes.js +46 -13
  102. package/dist/src/commands/test-register.js +59 -27
  103. package/dist/src/commands/test-run.d.ts +29 -3
  104. package/dist/src/commands/test-run.js +41 -4
  105. package/dist/src/commands/test-verdict.d.ts +32 -0
  106. package/dist/src/commands/test-verdict.js +48 -0
  107. package/dist/src/commands/test.d.ts +9 -0
  108. package/dist/src/commands/test.js +100 -123
  109. package/dist/src/commands/token-coverage.js +4 -9
  110. package/dist/src/commands/tree.d.ts +3 -0
  111. package/dist/src/commands/tree.js +171 -25
  112. package/dist/src/commands/verify.js +2 -2
  113. package/dist/src/commands/watch.js +22 -21
  114. package/dist/src/commands/wire.js +1 -1
  115. package/dist/src/core/ast-utils.d.ts +11 -0
  116. package/dist/src/core/ast-utils.js +11 -0
  117. package/dist/src/core/branding.d.ts +16 -0
  118. package/dist/src/core/branding.js +18 -2
  119. package/dist/src/core/build-audit-platform.js +6 -1
  120. package/dist/src/core/build-audit-registration.js +2 -0
  121. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  122. package/dist/src/core/build-audit-resolve.js +34 -13
  123. package/dist/src/core/build-audit.js +4 -20
  124. package/dist/src/core/build-baseline-types.d.ts +29 -8
  125. package/dist/src/core/build-baseline-types.js +2 -1
  126. package/dist/src/core/build-baseline.d.ts +10 -2
  127. package/dist/src/core/build-baseline.js +39 -8
  128. package/dist/src/core/build-prepare.d.ts +4 -0
  129. package/dist/src/core/build-prepare.js +7 -1
  130. package/dist/src/core/config-paths.d.ts +1 -1
  131. package/dist/src/core/config-paths.js +2 -0
  132. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  133. package/dist/src/core/config-validate.js +34 -36
  134. package/dist/src/core/config.js +16 -3
  135. package/dist/src/core/coverage-extend.d.ts +89 -0
  136. package/dist/src/core/coverage-extend.js +188 -0
  137. package/dist/src/core/destructive.js +3 -1
  138. package/dist/src/core/discard-baseline.js +18 -4
  139. package/dist/src/core/engine-precondition.d.ts +35 -0
  140. package/dist/src/core/engine-precondition.js +50 -0
  141. package/dist/src/core/engine-session-lock.d.ts +38 -1
  142. package/dist/src/core/engine-session-lock.js +78 -4
  143. package/dist/src/core/file-lock.d.ts +7 -0
  144. package/dist/src/core/file-lock.js +20 -30
  145. package/dist/src/core/firefox.js +5 -2
  146. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  147. package/dist/src/core/furnace-apply-helpers.js +12 -3
  148. package/dist/src/core/furnace-apply-output.js +9 -0
  149. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  150. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  151. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  152. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  153. package/dist/src/core/furnace-apply.d.ts +16 -4
  154. package/dist/src/core/furnace-apply.js +39 -43
  155. package/dist/src/core/furnace-config.js +2 -0
  156. package/dist/src/core/furnace-css-fragments.js +2 -0
  157. package/dist/src/core/furnace-jsconfig.js +14 -7
  158. package/dist/src/core/furnace-registration-ast.js +8 -8
  159. package/dist/src/core/furnace-registration-remove.js +6 -3
  160. package/dist/src/core/furnace-registration.js +9 -12
  161. package/dist/src/core/furnace-rollback.js +1 -1
  162. package/dist/src/core/furnace-scanner.js +2 -1
  163. package/dist/src/core/furnace-stale-export.js +2 -1
  164. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  165. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  166. package/dist/src/core/furnace-validate-helpers.js +33 -1
  167. package/dist/src/core/furnace-validate-registration.js +16 -0
  168. package/dist/src/core/furnace-validate.js +4 -2
  169. package/dist/src/core/git-diff.js +37 -2
  170. package/dist/src/core/lint-cache.d.ts +1 -1
  171. package/dist/src/core/lint-cache.js +33 -10
  172. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  173. package/dist/src/core/mach-build-artifacts.js +108 -2
  174. package/dist/src/core/mach-mozconfig.js +9 -1
  175. package/dist/src/core/mach-resource-shim.js +1 -0
  176. package/dist/src/core/marionette-port.d.ts +16 -0
  177. package/dist/src/core/marionette-port.js +93 -8
  178. package/dist/src/core/marionette-preflight.js +6 -5
  179. package/dist/src/core/patch-apply.d.ts +20 -13
  180. package/dist/src/core/patch-apply.js +32 -33
  181. package/dist/src/core/patch-export.js +3 -5
  182. package/dist/src/core/patch-lint-checkjs.d.ts +25 -3
  183. package/dist/src/core/patch-lint-checkjs.js +78 -7
  184. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  185. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  186. package/dist/src/core/patch-lint-creators.js +45 -0
  187. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  188. package/dist/src/core/patch-lint-cross.js +16 -32
  189. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  190. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  191. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  192. package/dist/src/core/patch-lint-module-registration.js +88 -0
  193. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  194. package/dist/src/core/patch-lint-reexports.d.ts +1 -0
  195. package/dist/src/core/patch-lint-reexports.js +1 -0
  196. package/dist/src/core/patch-lint.d.ts +8 -0
  197. package/dist/src/core/patch-lint.js +84 -6
  198. package/dist/src/core/patch-lock.d.ts +12 -1
  199. package/dist/src/core/patch-lock.js +40 -4
  200. package/dist/src/core/patch-manifest-consistency.js +15 -3
  201. package/dist/src/core/patch-parse.d.ts +8 -0
  202. package/dist/src/core/patch-parse.js +10 -0
  203. package/dist/src/core/patch-policy.d.ts +7 -0
  204. package/dist/src/core/patch-policy.js +4 -1
  205. package/dist/src/core/patch-transform.d.ts +10 -0
  206. package/dist/src/core/patch-transform.js +13 -2
  207. package/dist/src/core/rebase-session.d.ts +44 -2
  208. package/dist/src/core/rebase-session.js +114 -17
  209. package/dist/src/core/register-browser-content.js +2 -1
  210. package/dist/src/core/register-shared-css.js +2 -1
  211. package/dist/src/core/smoke-patterns.d.ts +0 -8
  212. package/dist/src/core/smoke-patterns.js +3 -2
  213. package/dist/src/core/status-binary.d.ts +55 -0
  214. package/dist/src/core/status-binary.js +124 -0
  215. package/dist/src/core/status-classify.d.ts +6 -1
  216. package/dist/src/core/status-classify.js +101 -73
  217. package/dist/src/core/test-harness-crash.d.ts +32 -1
  218. package/dist/src/core/test-harness-crash.js +59 -4
  219. package/dist/src/core/test-path-scope.js +2 -0
  220. package/dist/src/core/test-stale-check.js +1 -20
  221. package/dist/src/core/toolchain-preflight.js +24 -26
  222. package/dist/src/core/tree-guard.d.ts +1 -1
  223. package/dist/src/core/tree-guard.js +67 -12
  224. package/dist/src/core/tree-store.d.ts +86 -10
  225. package/dist/src/core/tree-store.js +310 -72
  226. package/dist/src/core/typecheck.js +2 -1
  227. package/dist/src/core/wire-destroy.js +14 -3
  228. package/dist/src/core/wire-init.js +37 -40
  229. package/dist/src/core/wire-subscript.js +11 -3
  230. package/dist/src/core/wire-utils.js +4 -4
  231. package/dist/src/core/xpcshell-appdir.js +3 -0
  232. package/dist/src/errors/base.d.ts +13 -0
  233. package/dist/src/errors/base.js +13 -0
  234. package/dist/src/errors/rebase.d.ts +13 -0
  235. package/dist/src/errors/rebase.js +17 -0
  236. package/dist/src/types/commands/options.d.ts +86 -1
  237. package/dist/src/types/commands/patches.d.ts +8 -0
  238. package/dist/src/types/furnace.d.ts +7 -0
  239. package/dist/src/utils/build-info.d.ts +31 -0
  240. package/dist/src/utils/build-info.js +98 -0
  241. package/dist/src/utils/concurrency.d.ts +8 -0
  242. package/dist/src/utils/concurrency.js +21 -0
  243. package/dist/src/utils/errors.d.ts +39 -0
  244. package/dist/src/utils/errors.js +55 -0
  245. package/dist/src/utils/fs.js +5 -12
  246. package/dist/src/utils/logger.d.ts +14 -2
  247. package/dist/src/utils/logger.js +43 -13
  248. package/dist/src/utils/options.d.ts +21 -4
  249. package/dist/src/utils/options.js +26 -6
  250. package/dist/src/utils/package-root.js +7 -4
  251. package/dist/src/utils/process-group.js +13 -2
  252. package/dist/src/utils/stdio-drain.d.ts +28 -0
  253. package/dist/src/utils/stdio-drain.js +54 -0
  254. package/dist/src/utils/validation.d.ts +23 -2
  255. package/dist/src/utils/validation.js +29 -3
  256. package/package.json +9 -9
@@ -23,6 +23,7 @@ import { getProjectPaths, loadConfig } from '../../core/config.js';
23
23
  import { appendHistory, confirmDestructive } from '../../core/destructive.js';
24
24
  import { normalizePatchArtifact } from '../../core/patch-artifact-normalize.js';
25
25
  import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
26
+ import { buildPatchQueueContext } from '../../core/patch-lint.js';
26
27
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
27
28
  import { loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, savePatchesManifest, validatePatchesManifest, } from '../../core/patch-manifest.js';
28
29
  import { enforcePatchPolicy } from '../../core/patch-policy.js';
@@ -30,7 +31,8 @@ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
30
31
  import { toError } from '../../utils/errors.js';
31
32
  import { readText, removeFile, writeText } from '../../utils/fs.js';
32
33
  import { info, intro, outro, success, warn } from '../../utils/logger.js';
33
- import { commanderArgParser, pickDefined } from '../../utils/options.js';
34
+ import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, } from '../../utils/options.js';
35
+ import { normalizePatchDisplayName } from '../../utils/validation.js';
34
36
  import { placementPlansEqual, resolvePlacementPlan } from '../export-flow.js';
35
37
  import { runPatchLint } from '../export-shared.js';
36
38
  import { assertSourceOwnsFiles, buildNewPatchMetadata, buildSplitDiff, buildSplitSummary, findOwnerRewriteHolders, mergeStagedForwardImports, projectSplitManifest, rewriteSplitOwners, runProjectedSplitLint, } from './split-plan.js';
@@ -156,7 +158,7 @@ export async function commitPatchSplit(patchesDir, plan, newMetadata, options, c
156
158
  }
157
159
  throw error;
158
160
  }
159
- });
161
+ }, { waitLockSeconds: resolveWaitLockSeconds(options.waitLock), command: 'patch split' });
160
162
  }
161
163
  /**
162
164
  * Runs the `patch split` command: plans the split, lints the projection,
@@ -195,6 +197,13 @@ export async function patchSplitCommand(projectRoot, sourceId, options) {
195
197
  (options.order === undefined && options.before === undefined ? source.filename : undefined),
196
198
  });
197
199
  const placement = await resolvePlacementPlan(paths.patches, placementOptions, category, options.name, config);
200
+ // The filename slug pipeline strips redundant category prefixes; the
201
+ // manifest display name must agree with the bare-slug naming policy,
202
+ // exactly as `export --name` already does (FORGE G13/H4).
203
+ const displayName = normalizePatchDisplayName(options.name, category);
204
+ if (displayName !== options.name) {
205
+ info(`Patch name normalized: "${options.name}" → "${displayName}" (the filename carries the order and category prefix).`);
206
+ }
198
207
  const plan = {
199
208
  source,
200
209
  movedFiles,
@@ -204,7 +213,7 @@ export async function patchSplitCommand(projectRoot, sourceId, options) {
204
213
  placement,
205
214
  placementOptions,
206
215
  category,
207
- name: options.name,
216
+ name: displayName,
208
217
  description: options.description ?? '',
209
218
  ownerRewrites: findOwnerRewriteHolders(manifest.patches, source.filename, movedSet),
210
219
  // Populated by runProjectedSplitLint below (forward edges into the new patch).
@@ -212,10 +221,14 @@ export async function patchSplitCommand(projectRoot, sourceId, options) {
212
221
  };
213
222
  // Per-patch lint both projected bodies, threading the source patch's
214
223
  // tier/lintIgnore so an intentional-advisory patch can still split.
224
+ // The whole-queue context (built once, with the config for committed-gate
225
+ // parity) resolves cross-patch imports and sibling head.js harness roots
226
+ // instead of linting each projected body blind (FORGE I3).
227
+ const patchQueueCtx = await buildPatchQueueContext(paths.patches, config);
215
228
  const ignoreChecks = source.lintIgnore ? new Set(source.lintIgnore) : undefined;
216
- await runPatchLint(paths.engine, remainingFiles, remainingDiff, config, options.skipLint, undefined, ignoreChecks, source.tier);
217
- await runPatchLint(paths.engine, movedFiles, movedDiff, config, options.skipLint, undefined, ignoreChecks, source.tier);
218
- const { conflicts, stagedDependencyAdditions } = await runProjectedSplitLint(paths.patches, plan);
229
+ await runPatchLint(paths.engine, remainingFiles, remainingDiff, config, options.skipLint, patchQueueCtx, ignoreChecks, source.tier);
230
+ await runPatchLint(paths.engine, movedFiles, movedDiff, config, options.skipLint, patchQueueCtx, ignoreChecks, source.tier);
231
+ const { conflicts, stagedDependencyAdditions } = runProjectedSplitLint(plan, patchQueueCtx);
219
232
  plan.stagedDependencyAdditions = stagedDependencyAdditions;
220
233
  const newMetadata = buildNewPatchMetadata(plan, config);
221
234
  enforcePatchPolicy({
@@ -253,7 +266,7 @@ export async function patchSplitCommand(projectRoot, sourceId, options) {
253
266
  */
254
267
  export function registerPatchSplit(parent, context) {
255
268
  const { getProjectRoot, withErrorHandling } = context;
256
- parent
269
+ const command = parent
257
270
  .command('split <source>')
258
271
  .description('Move files out of a patch into a new patch as one transaction (shrink + create + staged-dependency owner rewrites)')
259
272
  .requiredOption('--files <path...>', 'Engine-relative files to move from the source patch to the new patch')
@@ -272,8 +285,8 @@ export function registerPatchSplit(parent, context) {
272
285
  .option('--dry-run', 'Show what would happen without writing')
273
286
  .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
274
287
  .option('--force-unsafe', 'Bypass projected-lint refusals')
275
- .option('--skip-lint', 'Skip per-patch lint of the projected bodies')
276
- .action(withErrorHandling(async (sourceId, options) => {
288
+ .option('--skip-lint', 'Skip per-patch lint of the projected bodies');
289
+ addWaitLockOption(command).action(withErrorHandling(async (sourceId, options) => {
277
290
  await patchSplitCommand(getProjectRoot(), sourceId, {
278
291
  files: options.files,
279
292
  name: options.name,
@@ -287,6 +300,7 @@ export function registerPatchSplit(parent, context) {
287
300
  yes: options.yes,
288
301
  forceUnsafe: options.forceUnsafe,
289
302
  skipLint: options.skipLint,
303
+ waitLock: options.waitLock,
290
304
  }),
291
305
  });
292
306
  }));
@@ -0,0 +1,15 @@
1
+ import type { PatchMetadata } from '../../types/commands/index.js';
2
+ /** The declaration fields common to both staged-dependency kinds. */
3
+ interface StagedDependencyShape {
4
+ file: string;
5
+ creates: string;
6
+ owner?: string;
7
+ }
8
+ /**
9
+ * Validates the `--add` declaration fields against the loaded queue.
10
+ * Throws on a clear flag mixup; warns (only) when `--owner` is
11
+ * well-formed but absent from the queue, because the owner may
12
+ * legitimately be exported moments later and renumbering rewrites owners.
13
+ */
14
+ export declare function validateStagedDependencyAdd(dependency: StagedDependencyShape, queuePatches: readonly PatchMetadata[]): void;
15
+ export {};
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Shape validation for `patch staged-dependency --add` (FORGE K10).
4
+ *
5
+ * `--creates`/`--file` take engine-relative FILE paths and `--owner`
6
+ * takes a patch FILENAME, but all three were accepted as arbitrary
7
+ * strings — a `--creates <patch-name>` mixup produced a declaration
8
+ * nothing could ever discharge, surfacing only later as a
9
+ * `staged-dependency-unused` warning. Refusals here fire only on a clear
10
+ * patch-name shape (slash-free values that end in `.patch` or match a
11
+ * queue patch filename/stem): legitimate engine paths always contain a
12
+ * `/`, so deep paths can never false-positive.
13
+ */
14
+ import { InvalidArgumentError } from '../../errors/base.js';
15
+ import { warn } from '../../utils/logger.js';
16
+ function matchQueuePatch(value, queuePatches) {
17
+ return queuePatches.find((patch) => patch.filename === value || patch.filename === `${value}.patch`)?.filename;
18
+ }
19
+ function refusePatchShapedPath(flag, value, role, queuePatches) {
20
+ if (value.includes('/'))
21
+ return;
22
+ const matched = matchQueuePatch(value, queuePatches);
23
+ if (matched === undefined && !value.endsWith('.patch'))
24
+ return;
25
+ const evidence = matched !== undefined ? `matches patch ${matched} in the queue` : 'looks like a patch filename';
26
+ throw new InvalidArgumentError(`${flag} takes ${role} (e.g. browser/components/foo/Foo.sys.mjs), not a patch name — ` +
27
+ `"${value}" ${evidence}. If you meant to name the owning patch, pass it with --owner.`, flag);
28
+ }
29
+ /**
30
+ * Validates the `--add` declaration fields against the loaded queue.
31
+ * Throws on a clear flag mixup; warns (only) when `--owner` is
32
+ * well-formed but absent from the queue, because the owner may
33
+ * legitimately be exported moments later and renumbering rewrites owners.
34
+ */
35
+ export function validateStagedDependencyAdd(dependency, queuePatches) {
36
+ refusePatchShapedPath('--creates', dependency.creates, 'the engine-relative path of the file a later patch creates', queuePatches);
37
+ refusePatchShapedPath('--file', dependency.file, 'the engine-relative file containing the declaring site inside this patch', queuePatches);
38
+ const owner = dependency.owner;
39
+ if (owner === undefined)
40
+ return;
41
+ if (owner.includes('/') || !owner.endsWith('.patch')) {
42
+ throw new InvalidArgumentError(`--owner names the owning patch artifact (e.g. 012-infra-foo.patch); ` +
43
+ `"${owner}" does not look like a patch filename. The created file path goes in --creates.`, '--owner');
44
+ }
45
+ if (!queuePatches.some((patch) => patch.filename === owner)) {
46
+ warn(`--owner ${owner} matches no patch currently in the queue. The declaration only ` +
47
+ 'discharges once a patch with exactly this filename creates the --creates path.');
48
+ }
49
+ }
@@ -10,6 +10,7 @@ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
10
10
  import { toError } from '../../utils/errors.js';
11
11
  import { info, intro, outro, warn } from '../../utils/logger.js';
12
12
  import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
13
+ import { validateStagedDependencyAdd } from './staged-dependency-validate.js';
13
14
  /** Joins the names of the missing flags for error attribution (FORGE G12). */
14
15
  function missingFlagList(pairs) {
15
16
  return pairs
@@ -231,6 +232,15 @@ export async function patchStagedDependencyCommand(projectRoot, identifier, opti
231
232
  : mode === 'add'
232
233
  ? requireRegistrationOptions(options)
233
234
  : resolveRegistrationRemovalTarget(options, targetPatch.stagedDependencies);
235
+ // Shape-check --add declarations against the loaded queue (FORGE K10):
236
+ // a patch-name-shaped --creates/--file is refused HERE instead of
237
+ // surfacing later as an undischargeable staged-dependency-unused.
238
+ if (mode === 'add') {
239
+ const added = importDependency ?? registrationDependency;
240
+ if (added !== undefined) {
241
+ validateStagedDependencyAdd(added, manifest.patches);
242
+ }
243
+ }
234
244
  const target = importDependency !== undefined
235
245
  ? importView(importDependency)
236
246
  : registrationDependency !== undefined
@@ -17,16 +17,15 @@ import { isBrandingManagedPath } from '../core/branding.js';
17
17
  import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
18
18
  import { getClaimedFiles } from '../core/patch-manifest.js';
19
19
  import { isGeneratedBrandingPath } from '../core/status-classify.js';
20
+ import { mapWithConcurrency } from '../utils/concurrency.js';
20
21
  import { pathExists } from '../utils/fs.js';
21
22
  import { info, warn } from '../utils/logger.js';
23
+ /** Concurrency bound for existence probes (matches the classify/lint pools). */
24
+ const PATH_PROBE_CONCURRENCY = 8;
22
25
  /** Returns the subset of `files` that no longer exist under `engineDir`. */
23
26
  export async function findMissingFiles(engineDir, files) {
24
- const missingFiles = [];
25
- for (const file of files) {
26
- if (!(await pathExists(join(engineDir, file))))
27
- missingFiles.push(file);
28
- }
29
- return missingFiles;
27
+ const exists = await mapWithConcurrency(files, PATH_PROBE_CONCURRENCY, (file) => pathExists(join(engineDir, file)));
28
+ return files.filter((_, index) => exists[index] !== true);
30
29
  }
31
30
  function isToolManagedPath(file, isNewFile, ctx) {
32
31
  // Mirrors status-classify: a generated branding path is tool-managed
@@ -1,5 +1,18 @@
1
1
  import type { PatchesManifest } from '../types/commands/index.js';
2
2
  /** Loads and validates a `re-export --scan-files` assignment manifest. */
3
3
  export declare function loadScanFilesAssignments(manifestPath: string, manifest: PatchesManifest): Promise<Map<string, string[]>>;
4
+ /**
5
+ * Runtime enforcement of the dry-run purity contract (FORGE H1): fingerprints
6
+ * the engine tree and every patch artifact before and after `operation`, and
7
+ * throws when a dry-run changed anything it promised only to inspect. This
8
+ * turns any recurrence of the 0.40.0 "dry-run reverted a just-written export"
9
+ * incident into a hard, named failure instead of silent data loss discovered
10
+ * only when a later gate fails. The post-fingerprint runs whether `operation`
11
+ * resolves or rejects — a dry-run that mutates state and THEN fails is exactly
12
+ * the case where a rollback defect must not go unreported; a rejection with a
13
+ * purity violation surfaces the violation with the original error as `cause`.
14
+ * No-op outside dry-run.
15
+ */
16
+ export declare function withDryRunPurityGuard<T>(engineDir: string, patchesDir: string, isDryRun: boolean, operation: () => Promise<T>): Promise<T>;
4
17
  /** Serializes dry-run re-export git inspection against the same project tree. */
5
18
  export declare function withDryRunReExportLock<T>(fireforgeDir: string, isDryRun: boolean, operation: () => Promise<T>): Promise<T>;
@@ -1,11 +1,17 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { createHash } from 'node:crypto';
3
+ import { readdir, readFile } from 'node:fs/promises';
2
4
  import { join } from 'node:path';
5
+ import { isUnavailableGenerationToken, snapshotEngineGeneration, unavailableGenerationReason, } from '../core/engine-session-lock.js';
3
6
  import { createSiblingLockPath, withFileLock } from '../core/file-lock.js';
4
7
  import { resolvePatchIdentifier } from '../core/patch-manifest.js';
5
- import { InvalidArgumentError } from '../errors/base.js';
6
- import { toError } from '../utils/errors.js';
8
+ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
9
+ import { mapWithConcurrency } from '../utils/concurrency.js';
10
+ import { getNodeErrorCode, toError } from '../utils/errors.js';
7
11
  import { readText } from '../utils/fs.js';
8
12
  import { normalizeEngineRelativeInput } from './re-export-scan.js';
13
+ /** Concurrency bound for patch-body hashing (matches the classify/lint pools). */
14
+ const PATCH_HASH_CONCURRENCY = 8;
9
15
  function isRecord(value) {
10
16
  return typeof value === 'object' && value !== null && !Array.isArray(value);
11
17
  }
@@ -66,6 +72,140 @@ export async function loadScanFilesAssignments(manifestPath, manifest) {
66
72
  }
67
73
  return new Map([...filesByPatch.entries()].map(([patchFilename, files]) => [patchFilename, [...files].sort()]));
68
74
  }
75
+ /**
76
+ * Fingerprints every piece of state a `re-export --dry-run` promises not to
77
+ * disturb: the engine's commit + working-tree status (via the same generation
78
+ * token `fireforge test` uses) and the byte content of every regular file
79
+ * under `patches/` — ALL of them, not just the selected patches, because the
80
+ * FORGE H1 field incident was a dry-run of patch B reverting a just-written
81
+ * export of patch A. A missing patches directory (ENOENT/ENOTDIR) fingerprints
82
+ * as empty — there is nothing there to protect — but any other listing failure
83
+ * (EACCES, EIO, …) throws: an unreadable directory is not evidence it is
84
+ * empty, and treating it as such would let the guard silently vouch for state
85
+ * it never saw (fail closed). The same rule covers an unmeasurable engine
86
+ * generation and an unreadable individual patch file; a before-pass failure
87
+ * aborts the dry run before it starts, since a guard that measured nothing
88
+ * cannot vouch for anything.
89
+ */
90
+ async function fingerprintDryRunState(engineDir, patchesDir) {
91
+ const fingerprint = new Map();
92
+ const generation = await snapshotEngineGeneration(engineDir);
93
+ if (isUnavailableGenerationToken(generation)) {
94
+ // A failed probe measured nothing — hashing the failure token would let
95
+ // two identical failures compare "unchanged" (vouching for unmeasured
96
+ // state) and two differing messages report a spurious violation.
97
+ throw new GeneralError(`[dry-run] cannot fingerprint the engine working tree for the purity guard: ` +
98
+ unavailableGenerationReason(generation));
99
+ }
100
+ fingerprint.set('the engine working tree', createHash('sha256').update(generation).digest('hex'));
101
+ let names = [];
102
+ try {
103
+ const entries = await readdir(patchesDir, { withFileTypes: true });
104
+ names = entries
105
+ .filter((entry) => entry.isFile())
106
+ .map((entry) => entry.name)
107
+ .sort();
108
+ }
109
+ catch (error) {
110
+ // No patches directory — nothing there to protect; the same condition on
111
+ // the post-run pass produces the same empty set, and a directory that
112
+ // appears mid-dry-run correctly reports as a violation. Any OTHER failure
113
+ // must not read as "empty": the guard would be vouching for state it
114
+ // never saw.
115
+ const code = getNodeErrorCode(error);
116
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') {
117
+ throw new GeneralError(`[dry-run] cannot fingerprint ${patchesDir} for the purity guard: ${toError(error).message}`, error);
118
+ }
119
+ }
120
+ const results = await mapWithConcurrency(names, PATCH_HASH_CONCURRENCY, async (name) => {
121
+ try {
122
+ return {
123
+ hash: createHash('sha256')
124
+ .update(await readFile(join(patchesDir, name)))
125
+ .digest('hex'),
126
+ };
127
+ }
128
+ catch (error) {
129
+ // A file that vanished between the listing and the read is omitted, so
130
+ // a dry run that DELETED a patch artifact reports as the violation it
131
+ // is (key present before, absent after) instead of hiding behind a
132
+ // fingerprinting error. Every other failure fails closed: a constant
133
+ // placeholder would make unreadable-before equal unreadable-after even
134
+ // when the bytes changed underneath.
135
+ const code = getNodeErrorCode(error);
136
+ if (code === 'ENOENT' || code === 'ENOTDIR')
137
+ return { vanished: true };
138
+ return { error };
139
+ }
140
+ });
141
+ for (const [index, name] of names.entries()) {
142
+ const result = results[index];
143
+ if (result === undefined)
144
+ continue;
145
+ if ('error' in result) {
146
+ throw new GeneralError(`[dry-run] cannot fingerprint ${join(patchesDir, name)} for the purity guard: ${toError(result.error).message}`, result.error);
147
+ }
148
+ if ('vanished' in result)
149
+ continue;
150
+ fingerprint.set(`patches/${name}`, result.hash);
151
+ }
152
+ return fingerprint;
153
+ }
154
+ /**
155
+ * Runtime enforcement of the dry-run purity contract (FORGE H1): fingerprints
156
+ * the engine tree and every patch artifact before and after `operation`, and
157
+ * throws when a dry-run changed anything it promised only to inspect. This
158
+ * turns any recurrence of the 0.40.0 "dry-run reverted a just-written export"
159
+ * incident into a hard, named failure instead of silent data loss discovered
160
+ * only when a later gate fails. The post-fingerprint runs whether `operation`
161
+ * resolves or rejects — a dry-run that mutates state and THEN fails is exactly
162
+ * the case where a rollback defect must not go unreported; a rejection with a
163
+ * purity violation surfaces the violation with the original error as `cause`.
164
+ * No-op outside dry-run.
165
+ */
166
+ export async function withDryRunPurityGuard(engineDir, patchesDir, isDryRun, operation) {
167
+ if (!isDryRun)
168
+ return operation();
169
+ const before = await fingerprintDryRunState(engineDir, patchesDir);
170
+ let result;
171
+ let operationError;
172
+ let failed = false;
173
+ try {
174
+ result = await operation();
175
+ }
176
+ catch (error) {
177
+ failed = true;
178
+ operationError = error;
179
+ }
180
+ let after;
181
+ try {
182
+ after = await fingerprintDryRunState(engineDir, patchesDir);
183
+ }
184
+ catch (fingerprintError) {
185
+ // The post-run fingerprint failing must not swallow the dry run's own
186
+ // failure — an operation that mutated state, broke it, AND made it
187
+ // unreadable is exactly the case where both facts matter. The combined
188
+ // message carries both; the operation error stays the `cause`.
189
+ if (failed) {
190
+ throw new GeneralError(`[dry-run] this dry run failed (${toError(operationError).message}), and its purity could ` +
191
+ `not be verified afterwards: ${toError(fingerprintError).message}. Inspect engine/ and ` +
192
+ 'your patches/ diff before trusting them.', operationError);
193
+ }
194
+ throw fingerprintError;
195
+ }
196
+ const touched = [...new Set([...before.keys(), ...after.keys()])]
197
+ .sort()
198
+ .filter((key) => before.get(key) !== after.get(key));
199
+ if (touched.length > 0) {
200
+ throw new GeneralError(`[dry-run] invariant violated: this dry run modified ${touched.join(', ')}. ` +
201
+ 'This is a FireForge bug — please report it, and inspect the listed state ' +
202
+ '(e.g. `git status` in engine/ and your patches/ diff) before trusting it.' +
203
+ (failed ? ` The dry run also failed: ${toError(operationError).message}` : ''), operationError);
204
+ }
205
+ if (failed)
206
+ throw operationError;
207
+ return result;
208
+ }
69
209
  /** Serializes dry-run re-export git inspection against the same project tree. */
70
210
  export async function withDryRunReExportLock(fireforgeDir, isDryRun, operation) {
71
211
  if (!isDryRun)
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Foreign-drift preview + `--refuse-foreign-drift` gate for `re-export`
3
+ * (FORGE J2).
4
+ *
5
+ * A scan-less re-export rebuilds each patch body as a whole-file
6
+ * `git diff HEAD` over the owned files — so a CONCURRENT session's
7
+ * uncommitted lines inside a file the patch already owns are silently
8
+ * absorbed into the refreshed body. The adjacency advisory
9
+ * (`re-export-adjacent.ts`) warns about unmanaged NEIGHBOUR files but is
10
+ * structurally blind to drift inside owned files. This module compares
11
+ * the old body against the refreshed body per file and reports the
12
+ * payload lines that are about to ENTER the body; under
13
+ * `--refuse-foreign-drift` the patch is skipped and the run exits
14
+ * non-zero, mirroring `--refuse-adjacent-unmanaged`.
15
+ *
16
+ * "Foreign" is decided by content, not authorship: re-export's legitimate
17
+ * job is capturing intentional engine edits, so the preview always prints
18
+ * and the hard stop is an explicit opt-in for multi-session checkouts.
19
+ * The comparison is offset-insensitive — per-file MULTISETS of `+`/`-`
20
+ * payload lines — because a refresh legitimately shifts hunk offsets and
21
+ * context without changing what the patch does.
22
+ */
23
+ import type { PatchMetadata } from '../types/commands/index.js';
24
+ /** Why a patch was refused under `--refuse-foreign-drift`. */
25
+ export type ForeignDriftRefusalReason = 'foreign-drift' | 'baseline-unreadable';
26
+ /** Per-run context threaded through the re-export loop (mirrors FORGE G2). */
27
+ export interface ForeignDriftContext {
28
+ refuseForeignDrift: boolean;
29
+ /**
30
+ * Engine-relative files whose drift is expected (`--expect`, FORGE L6):
31
+ * drift confined to these files does not refuse. Files here that never
32
+ * drift are reported after the run as likely typos.
33
+ */
34
+ expectedDriftFiles: ReadonlySet<string>;
35
+ /** Expected files that actually drifted at least once this run. */
36
+ expectedSeen: Set<string>;
37
+ /** Number of patches whose old/new bodies reached the drift comparison. */
38
+ evaluationRuns: number;
39
+ refusals: {
40
+ patchFilename: string;
41
+ files: string[];
42
+ reason: ForeignDriftRefusalReason;
43
+ }[];
44
+ }
45
+ /** One owned file's foreign drift between the old and refreshed body. */
46
+ export interface FileForeignDrift {
47
+ file: string;
48
+ /** `+` payload lines in the new body not present in the old body. */
49
+ addedLines: number;
50
+ /** `-` payload lines in the new body not present in the old body. */
51
+ removedLines: number;
52
+ /** `@@` headers of new-body hunks carrying foreign lines (capped). */
53
+ hunkSummaries: string[];
54
+ /** True when a binary section's recorded new-side blob hash changed. */
55
+ binaryChanged: boolean;
56
+ }
57
+ /**
58
+ * Pure comparison: per owned file, which `+`/`-` payload lines the
59
+ * refreshed body carries that the old body did not. Files NOT in
60
+ * `previousFilesAffected` are skipped — those are intentional adoptions
61
+ * (`--scan`), not drift.
62
+ */
63
+ export declare function computeForeignDrift(oldBody: string, newBody: string, previousFilesAffected: readonly string[]): FileForeignDrift[];
64
+ /**
65
+ * Reads the old body, prints the always-on drift preview (including under
66
+ * dry-run and `--scan`), and — when `ctx.refuseForeignDrift` is set —
67
+ * records a refusal and returns `true` so the caller skips the write.
68
+ */
69
+ export declare function reportForeignDrift(args: {
70
+ patch: PatchMetadata;
71
+ patchesDir: string;
72
+ newDiffContent: string;
73
+ ctx: ForeignDriftContext;
74
+ }): Promise<boolean>;