@hominis/fireforge 0.39.0 → 0.41.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 (279) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +35 -5
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +27 -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 +27 -9
  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 +30 -10
  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 +73 -51
  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-report.d.ts +21 -0
  45. package/dist/src/commands/lint-report.js +62 -0
  46. package/dist/src/commands/lint.js +26 -22
  47. package/dist/src/commands/manifest.js +3 -1
  48. package/dist/src/commands/package.js +9 -16
  49. package/dist/src/commands/patch/compact.js +5 -5
  50. package/dist/src/commands/patch/delete.js +5 -5
  51. package/dist/src/commands/patch/lint-ignore.js +3 -2
  52. package/dist/src/commands/patch/move-files-create.js +22 -4
  53. package/dist/src/commands/patch/move-files-into.js +14 -10
  54. package/dist/src/commands/patch/move-files.js +6 -5
  55. package/dist/src/commands/patch/rename.d.ts +2 -2
  56. package/dist/src/commands/patch/rename.js +152 -51
  57. package/dist/src/commands/patch/reorder.d.ts +9 -0
  58. package/dist/src/commands/patch/reorder.js +14 -10
  59. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  60. package/dist/src/commands/patch/split-plan.js +22 -12
  61. package/dist/src/commands/patch/split.d.ts +1 -1
  62. package/dist/src/commands/patch/split.js +23 -9
  63. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  64. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  65. package/dist/src/commands/patch/staged-dependency.js +112 -14
  66. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  67. package/dist/src/commands/re-export-adjacent.js +105 -0
  68. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  69. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  70. package/dist/src/commands/re-export-drift.d.ts +74 -0
  71. package/dist/src/commands/re-export-drift.js +234 -0
  72. package/dist/src/commands/re-export-files.js +5 -3
  73. package/dist/src/commands/re-export-lint.d.ts +97 -0
  74. package/dist/src/commands/re-export-lint.js +210 -0
  75. package/dist/src/commands/re-export-options.js +11 -0
  76. package/dist/src/commands/re-export-register.d.ts +18 -0
  77. package/dist/src/commands/re-export-register.js +61 -0
  78. package/dist/src/commands/re-export-scan.js +13 -7
  79. package/dist/src/commands/re-export-single.d.ts +20 -0
  80. package/dist/src/commands/re-export-single.js +261 -0
  81. package/dist/src/commands/re-export.d.ts +0 -4
  82. package/dist/src/commands/re-export.js +123 -289
  83. package/dist/src/commands/rebase/abort.js +13 -4
  84. package/dist/src/commands/rebase/continue.js +11 -4
  85. package/dist/src/commands/rebase/index.js +19 -29
  86. package/dist/src/commands/rebase/patch-loop.js +12 -0
  87. package/dist/src/commands/reset.js +4 -23
  88. package/dist/src/commands/resolve.js +2 -9
  89. package/dist/src/commands/run.js +29 -23
  90. package/dist/src/commands/source.js +3 -9
  91. package/dist/src/commands/status-check.d.ts +48 -0
  92. package/dist/src/commands/status-check.js +106 -0
  93. package/dist/src/commands/status-json.d.ts +31 -0
  94. package/dist/src/commands/status-json.js +76 -0
  95. package/dist/src/commands/status-output.d.ts +1 -0
  96. package/dist/src/commands/status-output.js +10 -1
  97. package/dist/src/commands/status-ownership.d.ts +52 -0
  98. package/dist/src/commands/status-ownership.js +57 -0
  99. package/dist/src/commands/status.js +190 -170
  100. package/dist/src/commands/test-build-phase.d.ts +19 -0
  101. package/dist/src/commands/test-build-phase.js +168 -0
  102. package/dist/src/commands/test-diagnose.js +30 -2
  103. package/dist/src/commands/test-modes.d.ts +9 -1
  104. package/dist/src/commands/test-modes.js +46 -13
  105. package/dist/src/commands/test-register.js +59 -27
  106. package/dist/src/commands/test-run.d.ts +29 -3
  107. package/dist/src/commands/test-run.js +59 -7
  108. package/dist/src/commands/test-verdict.d.ts +32 -0
  109. package/dist/src/commands/test-verdict.js +48 -0
  110. package/dist/src/commands/test.d.ts +9 -0
  111. package/dist/src/commands/test.js +100 -123
  112. package/dist/src/commands/token-coverage.js +4 -9
  113. package/dist/src/commands/token.js +4 -1
  114. package/dist/src/commands/tree.d.ts +19 -0
  115. package/dist/src/commands/tree.js +320 -0
  116. package/dist/src/commands/verify.js +2 -2
  117. package/dist/src/commands/watch.js +22 -21
  118. package/dist/src/commands/wire.js +1 -1
  119. package/dist/src/core/ast-utils.d.ts +11 -0
  120. package/dist/src/core/ast-utils.js +11 -0
  121. package/dist/src/core/build-audit-platform.js +6 -1
  122. package/dist/src/core/build-audit-registration.js +2 -0
  123. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  124. package/dist/src/core/build-audit-resolve.js +34 -13
  125. package/dist/src/core/build-audit.js +4 -20
  126. package/dist/src/core/build-baseline-types.d.ts +29 -8
  127. package/dist/src/core/build-baseline-types.js +2 -1
  128. package/dist/src/core/build-baseline.d.ts +10 -2
  129. package/dist/src/core/build-baseline.js +39 -8
  130. package/dist/src/core/build-prepare.d.ts +4 -0
  131. package/dist/src/core/build-prepare.js +7 -1
  132. package/dist/src/core/config-paths.d.ts +1 -1
  133. package/dist/src/core/config-paths.js +2 -0
  134. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  135. package/dist/src/core/config-validate.js +45 -30
  136. package/dist/src/core/config.js +16 -3
  137. package/dist/src/core/coverage-extend.d.ts +89 -0
  138. package/dist/src/core/coverage-extend.js +188 -0
  139. package/dist/src/core/destructive.js +3 -1
  140. package/dist/src/core/discard-baseline.js +18 -4
  141. package/dist/src/core/engine-precondition.d.ts +35 -0
  142. package/dist/src/core/engine-precondition.js +50 -0
  143. package/dist/src/core/engine-session-lock.d.ts +38 -1
  144. package/dist/src/core/engine-session-lock.js +78 -4
  145. package/dist/src/core/file-lock.d.ts +7 -0
  146. package/dist/src/core/file-lock.js +20 -30
  147. package/dist/src/core/firefox.js +5 -2
  148. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  149. package/dist/src/core/furnace-apply-helpers.js +12 -3
  150. package/dist/src/core/furnace-apply-output.js +9 -0
  151. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  152. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  153. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  154. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  155. package/dist/src/core/furnace-apply.d.ts +16 -4
  156. package/dist/src/core/furnace-apply.js +39 -43
  157. package/dist/src/core/furnace-config.js +2 -0
  158. package/dist/src/core/furnace-css-fragments.js +2 -0
  159. package/dist/src/core/furnace-jsconfig.js +14 -7
  160. package/dist/src/core/furnace-registration-ast.js +8 -8
  161. package/dist/src/core/furnace-registration-remove.js +6 -3
  162. package/dist/src/core/furnace-registration.js +9 -12
  163. package/dist/src/core/furnace-rollback.js +1 -1
  164. package/dist/src/core/furnace-scanner.js +2 -1
  165. package/dist/src/core/furnace-stale-export.js +2 -1
  166. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  167. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  168. package/dist/src/core/furnace-validate-helpers.js +33 -1
  169. package/dist/src/core/furnace-validate-registration.js +16 -0
  170. package/dist/src/core/furnace-validate.js +4 -2
  171. package/dist/src/core/git-diff.js +37 -2
  172. package/dist/src/core/lint-cache.d.ts +15 -5
  173. package/dist/src/core/lint-cache.js +49 -14
  174. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  175. package/dist/src/core/mach-build-artifacts.js +108 -2
  176. package/dist/src/core/mach-resource-shim.js +1 -0
  177. package/dist/src/core/marionette-port.d.ts +16 -0
  178. package/dist/src/core/marionette-port.js +93 -8
  179. package/dist/src/core/marionette-preflight.js +6 -5
  180. package/dist/src/core/patch-apply.d.ts +20 -13
  181. package/dist/src/core/patch-apply.js +32 -33
  182. package/dist/src/core/patch-export.d.ts +2 -2
  183. package/dist/src/core/patch-export.js +4 -6
  184. package/dist/src/core/patch-lint-checkjs.d.ts +42 -2
  185. package/dist/src/core/patch-lint-checkjs.js +132 -8
  186. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  187. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  188. package/dist/src/core/patch-lint-creators.js +45 -0
  189. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  190. package/dist/src/core/patch-lint-cross.js +16 -32
  191. package/dist/src/core/patch-lint-css.js +64 -16
  192. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  193. package/dist/src/core/patch-lint-diff.js +31 -0
  194. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  195. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  196. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  197. package/dist/src/core/patch-lint-module-registration.js +88 -0
  198. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  199. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  200. package/dist/src/core/patch-lint-ownership.js +30 -0
  201. package/dist/src/core/patch-lint-reexports.d.ts +2 -1
  202. package/dist/src/core/patch-lint-reexports.js +1 -0
  203. package/dist/src/core/patch-lint.d.ts +31 -0
  204. package/dist/src/core/patch-lint.js +114 -8
  205. package/dist/src/core/patch-lock.d.ts +12 -1
  206. package/dist/src/core/patch-lock.js +40 -4
  207. package/dist/src/core/patch-manifest-consistency.js +15 -3
  208. package/dist/src/core/patch-manifest.d.ts +1 -1
  209. package/dist/src/core/patch-parse.d.ts +8 -0
  210. package/dist/src/core/patch-parse.js +10 -0
  211. package/dist/src/core/patch-policy.d.ts +7 -0
  212. package/dist/src/core/patch-policy.js +4 -1
  213. package/dist/src/core/patch-transform.d.ts +10 -0
  214. package/dist/src/core/patch-transform.js +13 -2
  215. package/dist/src/core/rebase-session.d.ts +44 -2
  216. package/dist/src/core/rebase-session.js +114 -17
  217. package/dist/src/core/register-browser-content.js +2 -1
  218. package/dist/src/core/register-shared-css.js +2 -1
  219. package/dist/src/core/smoke-patterns.d.ts +24 -16
  220. package/dist/src/core/smoke-patterns.js +26 -14
  221. package/dist/src/core/status-binary.d.ts +55 -0
  222. package/dist/src/core/status-binary.js +124 -0
  223. package/dist/src/core/status-classify.d.ts +22 -1
  224. package/dist/src/core/status-classify.js +112 -70
  225. package/dist/src/core/test-harness-crash.d.ts +32 -1
  226. package/dist/src/core/test-harness-crash.js +59 -4
  227. package/dist/src/core/test-path-scope.js +2 -0
  228. package/dist/src/core/test-stale-check.js +1 -20
  229. package/dist/src/core/token-category.d.ts +40 -0
  230. package/dist/src/core/token-category.js +264 -0
  231. package/dist/src/core/token-manager.d.ts +7 -0
  232. package/dist/src/core/token-manager.js +48 -196
  233. package/dist/src/core/toolchain-preflight.js +24 -26
  234. package/dist/src/core/tree-cow.d.ts +18 -0
  235. package/dist/src/core/tree-cow.js +71 -0
  236. package/dist/src/core/tree-guard.d.ts +35 -0
  237. package/dist/src/core/tree-guard.js +179 -0
  238. package/dist/src/core/tree-store.d.ts +129 -0
  239. package/dist/src/core/tree-store.js +452 -0
  240. package/dist/src/core/typecheck-shim.d.ts +14 -0
  241. package/dist/src/core/typecheck-shim.js +41 -2
  242. package/dist/src/core/typecheck.js +2 -1
  243. package/dist/src/core/wire-destroy.js +14 -3
  244. package/dist/src/core/wire-init.js +37 -40
  245. package/dist/src/core/wire-subscript.js +11 -3
  246. package/dist/src/core/wire-utils.js +4 -4
  247. package/dist/src/core/xpcshell-appdir.js +3 -0
  248. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  249. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  250. package/dist/src/errors/base.d.ts +13 -0
  251. package/dist/src/errors/base.js +13 -0
  252. package/dist/src/errors/git.d.ts +8 -0
  253. package/dist/src/errors/git.js +17 -0
  254. package/dist/src/errors/rebase.d.ts +13 -0
  255. package/dist/src/errors/rebase.js +17 -0
  256. package/dist/src/index.d.ts +2 -0
  257. package/dist/src/index.js +1 -0
  258. package/dist/src/types/commands/options.d.ts +119 -3
  259. package/dist/src/types/commands/patches.d.ts +8 -0
  260. package/dist/src/types/config.d.ts +19 -0
  261. package/dist/src/types/furnace.d.ts +7 -0
  262. package/dist/src/utils/build-info.d.ts +31 -0
  263. package/dist/src/utils/build-info.js +98 -0
  264. package/dist/src/utils/concurrency.d.ts +8 -0
  265. package/dist/src/utils/concurrency.js +21 -0
  266. package/dist/src/utils/errors.d.ts +39 -0
  267. package/dist/src/utils/errors.js +55 -0
  268. package/dist/src/utils/fs.js +5 -12
  269. package/dist/src/utils/logger.d.ts +14 -2
  270. package/dist/src/utils/logger.js +43 -13
  271. package/dist/src/utils/options.d.ts +21 -4
  272. package/dist/src/utils/options.js +26 -6
  273. package/dist/src/utils/package-root.js +7 -4
  274. package/dist/src/utils/process-group.js +13 -2
  275. package/dist/src/utils/stdio-drain.d.ts +28 -0
  276. package/dist/src/utils/stdio-drain.js +54 -0
  277. package/dist/src/utils/validation.d.ts +33 -2
  278. package/dist/src/utils/validation.js +57 -2
  279. package/package.json +13 -13
@@ -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,35 +10,47 @@ 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';
14
+ /** Joins the names of the missing flags for error attribution (FORGE G12). */
15
+ function missingFlagList(pairs) {
16
+ return pairs
17
+ .filter(([, value]) => value === undefined || value === '')
18
+ .map(([flag]) => flag)
19
+ .join(', ');
20
+ }
13
21
  function modeFromOptions(options) {
14
22
  const adding = options.add === true;
15
23
  const removing = options.remove === true;
16
24
  const clearing = options.clear === true;
17
25
  const modeCount = [adding, removing, clearing].filter(Boolean).length;
18
26
  if (modeCount > 1) {
19
- throw new InvalidArgumentError('--add, --remove, and --clear are mutually exclusive. Pick one mode per invocation.', 'patch staged-dependency');
27
+ throw new InvalidArgumentError('--add, --remove, and --clear are mutually exclusive. Pick one mode per invocation.', '--add/--remove/--clear');
20
28
  }
21
29
  if (modeCount === 0) {
22
- throw new InvalidArgumentError('Specify --add, --remove, or --clear.', 'patch staged-dependency');
30
+ throw new InvalidArgumentError('Specify --add, --remove, or --clear.', '--add/--remove/--clear');
23
31
  }
24
32
  return adding ? 'add' : removing ? 'remove' : 'clear';
25
33
  }
26
34
  function kindFromOptions(options) {
27
35
  const kind = options.kind ?? 'import';
28
36
  if (kind !== 'import' && kind !== 'registration') {
29
- throw new InvalidArgumentError(`--kind must be "import" or "registration" (got "${kind}").`, 'patch staged-dependency');
37
+ throw new InvalidArgumentError(`--kind must be "import" or "registration" (got "${kind}").`, '--kind');
30
38
  }
31
39
  if (kind === 'import' && options.line !== undefined) {
32
- throw new InvalidArgumentError('--line only applies to --kind registration; import declarations use --specifier.', 'patch staged-dependency');
40
+ throw new InvalidArgumentError('--line only applies to --kind registration; import declarations use --specifier.', '--line');
33
41
  }
34
42
  if (kind === 'registration' && options.specifier !== undefined) {
35
- throw new InvalidArgumentError('--specifier only applies to --kind import; registration declarations use --line.', 'patch staged-dependency');
43
+ throw new InvalidArgumentError('--specifier only applies to --kind import; registration declarations use --line.', '--specifier');
36
44
  }
37
45
  return kind;
38
46
  }
39
- function requireForwardImportOptions(options, mode) {
47
+ function requireForwardImportOptions(options) {
40
48
  if (!options.file || !options.specifier || !options.creates) {
41
- throw new InvalidArgumentError(`--${mode} requires --file, --specifier, and --creates.`, 'patch staged-dependency');
49
+ throw new InvalidArgumentError('--add requires --file, --specifier, and --creates.', missingFlagList([
50
+ ['--file', options.file],
51
+ ['--specifier', options.specifier],
52
+ ['--creates', options.creates],
53
+ ]));
42
54
  }
43
55
  const dependency = {
44
56
  file: options.file,
@@ -51,9 +63,13 @@ function requireForwardImportOptions(options, mode) {
51
63
  dependency.reason = options.reason;
52
64
  return dependency;
53
65
  }
54
- function requireRegistrationOptions(options, mode) {
66
+ function requireRegistrationOptions(options) {
55
67
  if (!options.file || !options.line || !options.creates) {
56
- throw new InvalidArgumentError(`--${mode} --kind registration requires --file, --line, and --creates.`, 'patch staged-dependency');
68
+ throw new InvalidArgumentError('--add --kind registration requires --file, --line, and --creates.', missingFlagList([
69
+ ['--file', options.file],
70
+ ['--line', options.line],
71
+ ['--creates', options.creates],
72
+ ]));
57
73
  }
58
74
  const dependency = {
59
75
  file: options.file,
@@ -66,6 +82,70 @@ function requireRegistrationOptions(options, mode) {
66
82
  dependency.reason = options.reason;
67
83
  return dependency;
68
84
  }
85
+ /**
86
+ * Resolves the `--remove` target for forward-imports (FORGE G12):
87
+ * `--file` + `--specifier` suffice when they identify at most one staged
88
+ * entry — `--creates` is inferred from a unique match, an ambiguous match
89
+ * refuses with the candidate list, and no match falls through to the
90
+ * honest "no staged forward-import matched" summary.
91
+ */
92
+ function resolveImportRemovalTarget(options, staged) {
93
+ if (!options.file || !options.specifier) {
94
+ throw new InvalidArgumentError('--remove requires --file and --specifier; --creates is only needed to disambiguate when several staged entries share them.', missingFlagList([
95
+ ['--file', options.file],
96
+ ['--specifier', options.specifier],
97
+ ]));
98
+ }
99
+ if (options.creates) {
100
+ return requireForwardImportOptions(options);
101
+ }
102
+ const { file, specifier } = options;
103
+ const candidates = (staged?.forwardImports ?? []).filter((entry) => entry.file === file &&
104
+ entry.specifier === specifier &&
105
+ (options.owner === undefined || entry.owner === options.owner));
106
+ const single = candidates.length === 1 ? candidates[0] : undefined;
107
+ if (single !== undefined) {
108
+ info(`Inferred --creates ${single.creates} from the single matching staged entry.`);
109
+ return { ...single };
110
+ }
111
+ if (candidates.length > 1) {
112
+ const list = candidates
113
+ .map((entry) => ` - ${dependencyLabel('import', importView(entry))}`)
114
+ .join('\n');
115
+ throw new GeneralError(`--remove matches ${String(candidates.length)} staged forward-imports on this patch; ` +
116
+ `pass --creates to pick one:\n${list}`);
117
+ }
118
+ return { file, specifier, creates: '(no match)' };
119
+ }
120
+ /** Registration twin of {@link resolveImportRemovalTarget}, keyed on `--line`. */
121
+ function resolveRegistrationRemovalTarget(options, staged) {
122
+ if (!options.file || !options.line) {
123
+ throw new InvalidArgumentError('--remove --kind registration requires --file and --line; --creates is only needed to disambiguate when several staged entries share them.', missingFlagList([
124
+ ['--file', options.file],
125
+ ['--line', options.line],
126
+ ]));
127
+ }
128
+ if (options.creates) {
129
+ return requireRegistrationOptions(options);
130
+ }
131
+ const { file, line } = options;
132
+ const candidates = (staged?.registrations ?? []).filter((entry) => entry.file === file &&
133
+ entry.line === line &&
134
+ (options.owner === undefined || entry.owner === options.owner));
135
+ const single = candidates.length === 1 ? candidates[0] : undefined;
136
+ if (single !== undefined) {
137
+ info(`Inferred --creates ${single.creates} from the single matching staged entry.`);
138
+ return { ...single };
139
+ }
140
+ if (candidates.length > 1) {
141
+ const list = candidates
142
+ .map((entry) => ` - ${dependencyLabel('registration', registrationView(entry))}`)
143
+ .join('\n');
144
+ throw new GeneralError(`--remove matches ${String(candidates.length)} staged registrations on this patch; ` +
145
+ `pass --creates to pick one:\n${list}`);
146
+ }
147
+ return { file, line, creates: '(no match)' };
148
+ }
69
149
  function importView(dependency) {
70
150
  return { ...dependency, matcher: dependency.specifier };
71
151
  }
@@ -137,17 +217,35 @@ export async function patchStagedDependencyCommand(projectRoot, identifier, opti
137
217
  intro(isDryRun ? 'FireForge patch staged-dependency (dry run)' : 'FireForge patch staged-dependency');
138
218
  const mode = modeFromOptions(options);
139
219
  const kind = kindFromOptions(options);
140
- const importDependency = mode === 'clear' || kind !== 'import' ? undefined : requireForwardImportOptions(options, mode);
220
+ // The patch queue loads BEFORE the declaration fields resolve so
221
+ // `--remove` can infer `--creates` from the target patch's staged
222
+ // entries (FORGE G12).
223
+ const { paths, manifest } = await requirePatchQueue(projectRoot);
224
+ const targetPatch = requirePatchTarget(identifier, manifest.patches);
225
+ const importDependency = mode === 'clear' || kind !== 'import'
226
+ ? undefined
227
+ : mode === 'add'
228
+ ? requireForwardImportOptions(options)
229
+ : resolveImportRemovalTarget(options, targetPatch.stagedDependencies);
141
230
  const registrationDependency = mode === 'clear' || kind !== 'registration'
142
231
  ? undefined
143
- : requireRegistrationOptions(options, mode);
232
+ : mode === 'add'
233
+ ? requireRegistrationOptions(options)
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
+ }
144
244
  const target = importDependency !== undefined
145
245
  ? importView(importDependency)
146
246
  : registrationDependency !== undefined
147
247
  ? registrationView(registrationDependency)
148
248
  : undefined;
149
- const { paths, manifest } = await requirePatchQueue(projectRoot);
150
- const targetPatch = requirePatchTarget(identifier, manifest.patches);
151
249
  const applyToStaged = (staged) => {
152
250
  if (mode === 'clear')
153
251
  return {};
@@ -219,7 +317,7 @@ export function registerPatchStagedDependency(parent, context) {
219
317
  .option('--owner <patch>', 'Exact later patch filename expected to create --creates')
220
318
  .option('--reason <text>', 'Human-readable rationale stored with the declaration')
221
319
  .option('--dry-run', 'Show what would change without writing')
222
- .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
320
+ .option('-y, --yes', 'Record scripted consent in the destructive-operation history log. This command never prompts; the flag exists for workflow uniformity with commands that do.')
223
321
  .action(withErrorHandling(async (name, options) => {
224
322
  await patchStagedDependencyCommand(getProjectRoot(), name, options);
225
323
  }));
@@ -0,0 +1,34 @@
1
+ import type { getProjectPaths } from '../core/config.js';
2
+ import type { PatchesManifest, PatchMetadata } from '../types/commands/index.js';
3
+ /**
4
+ * Shared per-run context for the adjacency advisory: the classification
5
+ * inputs computed once by `reExportCommand`, plus the collection of
6
+ * patches refused under `--refuse-adjacent-unmanaged` (collected across
7
+ * the loop so a multi-patch run reports every offender in one refusal).
8
+ */
9
+ export interface AdjacentUnmanagedContext {
10
+ binaryName: string;
11
+ furnacePrefixes: ReadonlySet<string>;
12
+ refuseAdjacentUnmanaged: boolean;
13
+ refusals: {
14
+ patchFilename: string;
15
+ files: string[];
16
+ }[];
17
+ }
18
+ /** Returns the subset of `files` that no longer exist under `engineDir`. */
19
+ export declare function findMissingFiles(engineDir: string, files: readonly string[]): Promise<string[]>;
20
+ /**
21
+ * Emits the scan-less advisory for one patch: missing manifest files and
22
+ * unmanaged files adjacent to the patch's ownership. When
23
+ * `--refuse-adjacent-unmanaged` is set and any unmanaged adjacent file
24
+ * exists, records the patch into `ctx.refusals` and returns true — the
25
+ * caller then skips the write for this patch and converts the collection
26
+ * into one run-level non-zero refusal after the loop.
27
+ */
28
+ export declare function reportAdjacentUnmanagedFiles(args: {
29
+ patch: PatchMetadata;
30
+ paths: ReturnType<typeof getProjectPaths>;
31
+ manifest: PatchesManifest;
32
+ currentFilesAffected: readonly string[];
33
+ ctx: AdjacentUnmanagedContext;
34
+ }): Promise<boolean>;
@@ -0,0 +1,105 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Scan-less re-export adjacency advisory (FORGE G2). A plain `re-export`
4
+ * keeps the manifest's filesAffected unchanged, so a brand-new file an
5
+ * author just created beside a patch's owned files is silently left out
6
+ * of the refreshed body. This module detects such adjacent files, filters
7
+ * out tool-managed paths (branding, furnace) so the notice names only
8
+ * genuinely unmanaged candidates, and — under
9
+ * `--refuse-adjacent-unmanaged` — lets the caller turn the notice into a
10
+ * non-zero refusal.
11
+ *
12
+ * Split out of re-export.ts to keep that file inside the max-lines
13
+ * budget; exports helpers consumed by re-export.ts, no registrar.
14
+ */
15
+ import { dirname, join } from 'node:path';
16
+ import { isBrandingManagedPath } from '../core/branding.js';
17
+ import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
18
+ import { getClaimedFiles } from '../core/patch-manifest.js';
19
+ import { isGeneratedBrandingPath } from '../core/status-classify.js';
20
+ import { mapWithConcurrency } from '../utils/concurrency.js';
21
+ import { pathExists } from '../utils/fs.js';
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;
25
+ /** Returns the subset of `files` that no longer exist under `engineDir`. */
26
+ export async function findMissingFiles(engineDir, files) {
27
+ const exists = await mapWithConcurrency(files, PATH_PROBE_CONCURRENCY, (file) => pathExists(join(engineDir, file)));
28
+ return files.filter((_, index) => exists[index] !== true);
29
+ }
30
+ function isToolManagedPath(file, isNewFile, ctx) {
31
+ // Mirrors status-classify: a generated branding path is tool-managed
32
+ // outright; a brand-new unowned file under the branding root is still a
33
+ // patch candidate (the Assets.car precedent) and must stay listed.
34
+ if (isBrandingManagedPath(file, ctx.binaryName) &&
35
+ (!isNewFile || isGeneratedBrandingPath(file, ctx.binaryName))) {
36
+ return true;
37
+ }
38
+ for (const prefix of ctx.furnacePrefixes) {
39
+ if (file.startsWith(prefix))
40
+ return true;
41
+ }
42
+ return false;
43
+ }
44
+ async function findAdjacentUnmanagedFiles(args) {
45
+ const { currentFilesAffected, engineDir, manifest, patchFilename, ctx } = args;
46
+ const parentDirs = [...new Set(currentFilesAffected.map((file) => dirname(file)))];
47
+ const currentSet = new Set(currentFilesAffected);
48
+ const claimedByOthers = getClaimedFiles(manifest, patchFilename);
49
+ const candidates = new Set();
50
+ for (const dir of parentDirs) {
51
+ const [modifiedFiles, untrackedFiles] = await Promise.all([
52
+ getModifiedFilesInDir(engineDir, dir),
53
+ getUntrackedFilesInDir(engineDir, dir),
54
+ ]);
55
+ const untrackedSet = new Set(untrackedFiles);
56
+ for (const file of [...modifiedFiles, ...untrackedFiles]) {
57
+ if (currentSet.has(file) || claimedByOthers.has(file))
58
+ continue;
59
+ if (isToolManagedPath(file, untrackedSet.has(file), ctx))
60
+ continue;
61
+ candidates.add(file);
62
+ }
63
+ }
64
+ return [...candidates].sort();
65
+ }
66
+ /**
67
+ * Emits the scan-less advisory for one patch: missing manifest files and
68
+ * unmanaged files adjacent to the patch's ownership. When
69
+ * `--refuse-adjacent-unmanaged` is set and any unmanaged adjacent file
70
+ * exists, records the patch into `ctx.refusals` and returns true — the
71
+ * caller then skips the write for this patch and converts the collection
72
+ * into one run-level non-zero refusal after the loop.
73
+ */
74
+ export async function reportAdjacentUnmanagedFiles(args) {
75
+ const { patch, paths, manifest, currentFilesAffected, ctx } = args;
76
+ const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
77
+ if (missingFiles.length > 0) {
78
+ warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
79
+ `(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
80
+ `filesAffected unchanged and the missing entries will be preserved — ` +
81
+ `\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
82
+ ` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
83
+ `or pass --files <paths> to set the list explicitly.`);
84
+ }
85
+ const unmanagedFiles = await findAdjacentUnmanagedFiles({
86
+ currentFilesAffected,
87
+ engineDir: paths.engine,
88
+ manifest,
89
+ patchFilename: patch.filename,
90
+ ctx,
91
+ });
92
+ if (unmanagedFiles.length === 0)
93
+ return false;
94
+ const firstFew = unmanagedFiles.slice(0, 3).join(', ');
95
+ const more = unmanagedFiles.length > 3 ? `, +${String(unmanagedFiles.length - 3)} more` : '';
96
+ warn(`${patch.filename}: found ${String(unmanagedFiles.length)} unmanaged file(s) adjacent to this patch's ownership (${firstFew}${more}); plain re-export keeps filesAffected unchanged — use --scan-file to include reviewed files.`);
97
+ for (const file of unmanagedFiles) {
98
+ info(` ${file} — fireforge re-export ${patch.filename} --scan --scan-file ${file}`);
99
+ }
100
+ if (ctx.refuseAdjacentUnmanaged) {
101
+ ctx.refusals.push({ patchFilename: patch.filename, files: unmanagedFiles });
102
+ return true;
103
+ }
104
+ return false;
105
+ }
@@ -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>;