@hominis/fireforge 0.50.0 → 0.52.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 (212) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/check-report.d.ts +17 -4
  4. package/dist/src/commands/check-report.js +19 -6
  5. package/dist/src/commands/check-run.d.ts +5 -0
  6. package/dist/src/commands/check-run.js +9 -1
  7. package/dist/src/commands/check-steps.d.ts +8 -0
  8. package/dist/src/commands/check-steps.js +31 -6
  9. package/dist/src/commands/check.js +1 -1
  10. package/dist/src/commands/declarations-report.d.ts +43 -0
  11. package/dist/src/commands/declarations-report.js +76 -0
  12. package/dist/src/commands/export-placement-gate.js +8 -0
  13. package/dist/src/commands/furnace/rename-registrations.js +7 -2
  14. package/dist/src/commands/furnace/validate.js +26 -1
  15. package/dist/src/commands/lint-per-patch.js +6 -2
  16. package/dist/src/commands/lint-per-run-checkjs.js +2 -2
  17. package/dist/src/commands/lint-report.js +7 -2
  18. package/dist/src/commands/manifest.js +2 -0
  19. package/dist/src/commands/park.d.ts +22 -0
  20. package/dist/src/commands/park.js +232 -0
  21. package/dist/src/commands/patch/delete.js +11 -6
  22. package/dist/src/commands/patch/index.d.ts +3 -3
  23. package/dist/src/commands/patch/index.js +8 -4
  24. package/dist/src/commands/patch/lint-ignore-from.d.ts +44 -0
  25. package/dist/src/commands/patch/lint-ignore-from.js +142 -0
  26. package/dist/src/commands/patch/lint-ignore.js +39 -29
  27. package/dist/src/commands/patch/move-files-create.js +11 -4
  28. package/dist/src/commands/patch/move-files-into.js +10 -4
  29. package/dist/src/commands/patch/move-files.js +3 -1
  30. package/dist/src/commands/patch/removal-created-files.d.ts +48 -0
  31. package/dist/src/commands/patch/removal-created-files.js +83 -0
  32. package/dist/src/commands/patch/rename.js +15 -3
  33. package/dist/src/commands/patch/reserve.d.ts +33 -0
  34. package/dist/src/commands/patch/reserve.js +152 -0
  35. package/dist/src/commands/patch/retire.js +11 -6
  36. package/dist/src/commands/patch/split-plan.d.ts +10 -1
  37. package/dist/src/commands/patch/split-plan.js +16 -1
  38. package/dist/src/commands/patch/split.js +14 -5
  39. package/dist/src/commands/patch/stage.d.ts +34 -0
  40. package/dist/src/commands/patch/stage.js +153 -0
  41. package/dist/src/commands/patch/staged-dependency-remove.d.ts +31 -0
  42. package/dist/src/commands/patch/staged-dependency-remove.js +80 -0
  43. package/dist/src/commands/patch/staged-dependency.js +40 -98
  44. package/dist/src/commands/re-export-adjacent.d.ts +5 -0
  45. package/dist/src/commands/re-export-adjacent.js +4 -3
  46. package/dist/src/commands/re-export-bulk-scan.d.ts +2 -1
  47. package/dist/src/commands/re-export-bulk-scan.js +22 -7
  48. package/dist/src/commands/re-export-hunks.d.ts +35 -0
  49. package/dist/src/commands/re-export-hunks.js +220 -0
  50. package/dist/src/commands/re-export-options.js +29 -2
  51. package/dist/src/commands/re-export-purity-scope.d.ts +18 -0
  52. package/dist/src/commands/re-export-purity-scope.js +131 -0
  53. package/dist/src/commands/re-export-register.js +15 -1
  54. package/dist/src/commands/re-export-removed.d.ts +1 -1
  55. package/dist/src/commands/re-export-removed.js +17 -2
  56. package/dist/src/commands/re-export-scan.d.ts +2 -0
  57. package/dist/src/commands/re-export-scan.js +4 -1
  58. package/dist/src/commands/re-export-single.js +102 -42
  59. package/dist/src/commands/re-export-stale-registrations.d.ts +18 -0
  60. package/dist/src/commands/re-export-stale-registrations.js +62 -0
  61. package/dist/src/commands/re-export.js +35 -34
  62. package/dist/src/commands/status-output.js +5 -1
  63. package/dist/src/commands/test-build-furnace.d.ts +3 -2
  64. package/dist/src/commands/test-build-furnace.js +8 -2
  65. package/dist/src/commands/test-build-phase.d.ts +2 -0
  66. package/dist/src/commands/test-build-phase.js +34 -17
  67. package/dist/src/commands/test-checkjs.js +3 -2
  68. package/dist/src/commands/test-dispatch.d.ts +39 -0
  69. package/dist/src/commands/test-dispatch.js +220 -0
  70. package/dist/src/commands/test-furnace-reach.d.ts +37 -0
  71. package/dist/src/commands/test-furnace-reach.js +132 -0
  72. package/dist/src/commands/test-orchestrate.d.ts +14 -0
  73. package/dist/src/commands/test-orchestrate.js +25 -4
  74. package/dist/src/commands/test-perf-scope.d.ts +21 -0
  75. package/dist/src/commands/test-perf-scope.js +55 -0
  76. package/dist/src/commands/test-prelock.js +100 -4
  77. package/dist/src/commands/test-register.js +18 -3
  78. package/dist/src/commands/test-results.d.ts +62 -0
  79. package/dist/src/commands/test-results.js +62 -0
  80. package/dist/src/commands/test-run.d.ts +13 -55
  81. package/dist/src/commands/test-run.js +73 -101
  82. package/dist/src/commands/test-shards.d.ts +110 -0
  83. package/dist/src/commands/test-shards.js +255 -0
  84. package/dist/src/commands/test-stale-gate.d.ts +5 -1
  85. package/dist/src/commands/test-stale-gate.js +11 -2
  86. package/dist/src/commands/test-verdict.d.ts +2 -1
  87. package/dist/src/commands/test.d.ts +1 -1
  88. package/dist/src/commands/test.js +68 -75
  89. package/dist/src/commands/token-lint.js +48 -8
  90. package/dist/src/commands/verify.js +9 -2
  91. package/dist/src/core/build-baseline-types.d.ts +13 -0
  92. package/dist/src/core/build-baseline-validate.js +9 -0
  93. package/dist/src/core/build-baseline.js +7 -0
  94. package/dist/src/core/build-prepare.d.ts +6 -0
  95. package/dist/src/core/build-prepare.js +29 -3
  96. package/dist/src/core/config-paths.d.ts +1 -1
  97. package/dist/src/core/config-paths.js +2 -0
  98. package/dist/src/core/config-validate-check.js +83 -7
  99. package/dist/src/core/config-validate-test-toolchains.js +14 -0
  100. package/dist/src/core/config-validate-token-scales.d.ts +3 -0
  101. package/dist/src/core/config-validate-token-scales.js +47 -5
  102. package/dist/src/core/config-validate.js +6 -0
  103. package/dist/src/core/coverage-extend.js +2 -2
  104. package/dist/src/core/coverage-records.d.ts +11 -0
  105. package/dist/src/core/coverage-records.js +34 -0
  106. package/dist/src/core/engine-session-lock.d.ts +6 -0
  107. package/dist/src/core/engine-session-lock.js +10 -2
  108. package/dist/src/core/file-lock-shared.d.ts +57 -0
  109. package/dist/src/core/file-lock-shared.js +117 -0
  110. package/dist/src/core/file-lock.d.ts +9 -0
  111. package/dist/src/core/file-lock.js +58 -2
  112. package/dist/src/core/furnace-apply.js +10 -10
  113. package/dist/src/core/furnace-config-validate-exceptions.d.ts +11 -3
  114. package/dist/src/core/furnace-config-validate-exceptions.js +59 -8
  115. package/dist/src/core/furnace-config-validate.js +7 -1
  116. package/dist/src/core/furnace-registration-jar-inplace.d.ts +12 -0
  117. package/dist/src/core/furnace-registration-jar-inplace.js +80 -0
  118. package/dist/src/core/furnace-validate-accessibility.d.ts +5 -3
  119. package/dist/src/core/furnace-validate-accessibility.js +75 -30
  120. package/dist/src/core/furnace-validate-exceptions.d.ts +13 -4
  121. package/dist/src/core/furnace-validate-exceptions.js +59 -4
  122. package/dist/src/core/furnace-validate-icon-button.js +30 -1
  123. package/dist/src/core/history-log-file.js +8 -1
  124. package/dist/src/core/line-prefixer.d.ts +18 -0
  125. package/dist/src/core/line-prefixer.js +34 -0
  126. package/dist/src/core/mach.d.ts +7 -0
  127. package/dist/src/core/mach.js +24 -9
  128. package/dist/src/core/module-resolution-preflight.d.ts +19 -3
  129. package/dist/src/core/module-resolution-preflight.js +24 -6
  130. package/dist/src/core/negative-control-compiled.d.ts +36 -0
  131. package/dist/src/core/negative-control-compiled.js +87 -0
  132. package/dist/src/core/negative-control-diff.d.ts +14 -2
  133. package/dist/src/core/negative-control-diff.js +49 -6
  134. package/dist/src/core/negative-control.d.ts +12 -0
  135. package/dist/src/core/negative-control.js +48 -5
  136. package/dist/src/core/park-store.d.ts +63 -0
  137. package/dist/src/core/park-store.js +216 -0
  138. package/dist/src/core/patch-export-update.d.ts +4 -0
  139. package/dist/src/core/patch-export-update.js +12 -3
  140. package/dist/src/core/patch-export.d.ts +3 -2
  141. package/dist/src/core/patch-export.js +21 -14
  142. package/dist/src/core/patch-files.js +26 -8
  143. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +6 -4
  144. package/dist/src/core/patch-lint-checkjs-any-import.js +9 -7
  145. package/dist/src/core/patch-lint-checkjs-diagnostics.d.ts +17 -0
  146. package/dist/src/core/patch-lint-checkjs-diagnostics.js +32 -0
  147. package/dist/src/core/patch-lint-checkjs.d.ts +10 -1
  148. package/dist/src/core/patch-lint-checkjs.js +13 -3
  149. package/dist/src/core/patch-lint-cross.d.ts +3 -1
  150. package/dist/src/core/patch-lint-cross.js +5 -0
  151. package/dist/src/core/patch-lint-css.js +14 -10
  152. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +5 -0
  153. package/dist/src/core/patch-lint-forward-registration-extended.js +1 -1
  154. package/dist/src/core/patch-lint-ignore.d.ts +56 -12
  155. package/dist/src/core/patch-lint-ignore.js +173 -43
  156. package/dist/src/core/patch-lint-ownership.d.ts +10 -0
  157. package/dist/src/core/patch-lint-ownership.js +26 -0
  158. package/dist/src/core/patch-lint-size.js +9 -1
  159. package/dist/src/core/patch-lint-token-scale.d.ts +26 -8
  160. package/dist/src/core/patch-lint-token-scale.js +206 -29
  161. package/dist/src/core/patch-lint.js +2 -2
  162. package/dist/src/core/patch-manifest-io.js +8 -1
  163. package/dist/src/core/patch-manifest-key-order.d.ts +24 -0
  164. package/dist/src/core/patch-manifest-key-order.js +61 -0
  165. package/dist/src/core/patch-manifest-stage.d.ts +46 -0
  166. package/dist/src/core/patch-manifest-stage.js +96 -0
  167. package/dist/src/core/patch-manifest-validate.js +27 -1
  168. package/dist/src/core/patch-order-legal.d.ts +2 -1
  169. package/dist/src/core/patch-order-legal.js +7 -2
  170. package/dist/src/core/patch-policy.d.ts +6 -2
  171. package/dist/src/core/patch-policy.js +14 -7
  172. package/dist/src/core/patch-retired.d.ts +26 -4
  173. package/dist/src/core/patch-retired.js +40 -2
  174. package/dist/src/core/run-log.d.ts +6 -0
  175. package/dist/src/core/run-log.js +17 -1
  176. package/dist/src/core/test-harness-crash.d.ts +2 -1
  177. package/dist/src/core/test-harness-crash.js +5 -8
  178. package/dist/src/core/test-harness-signature.d.ts +16 -0
  179. package/dist/src/core/test-harness-signature.js +19 -1
  180. package/dist/src/core/test-raw-log.d.ts +66 -0
  181. package/dist/src/core/test-raw-log.js +122 -0
  182. package/dist/src/core/test-run-completeness.d.ts +47 -7
  183. package/dist/src/core/test-run-completeness.js +70 -23
  184. package/dist/src/core/test-stale-check.d.ts +10 -1
  185. package/dist/src/core/test-stale-check.js +15 -1
  186. package/dist/src/core/test-xpcshell-retry.d.ts +2 -0
  187. package/dist/src/core/test-xpcshell-retry.js +2 -1
  188. package/dist/src/core/token-lint.d.ts +5 -2
  189. package/dist/src/core/token-lint.js +8 -3
  190. package/dist/src/core/token-sibling-sheets.d.ts +46 -0
  191. package/dist/src/core/token-sibling-sheets.js +119 -0
  192. package/dist/src/core/tree-guard.js +2 -0
  193. package/dist/src/core/typecheck-shim.d.ts +6 -1
  194. package/dist/src/core/typecheck-shim.js +139 -2
  195. package/dist/src/core/typecheck.js +3 -1
  196. package/dist/src/index.d.ts +9 -3
  197. package/dist/src/index.js +4 -1
  198. package/dist/src/types/commands/index.d.ts +2 -2
  199. package/dist/src/types/commands/options.d.ts +68 -7
  200. package/dist/src/types/commands/patches.d.ts +43 -0
  201. package/dist/src/types/config.d.ts +74 -2
  202. package/dist/src/types/furnace.d.ts +29 -0
  203. package/dist/src/types/index.d.ts +1 -1
  204. package/dist/src/utils/options.d.ts +3 -2
  205. package/dist/src/utils/options.js +9 -4
  206. package/docs/configuration.md +110 -9
  207. package/docs/furnace.md +61 -1
  208. package/docs/lifecycle-invariants.md +13 -11
  209. package/docs/machine-output.md +68 -18
  210. package/docs/patch-workflow.md +174 -13
  211. package/docs/testing.md +89 -5
  212. package/package.json +3 -3
@@ -26,12 +26,13 @@ import { Command, Option } from 'commander';
26
26
  import { confirmDestructive } from '../../core/destructive.js';
27
27
  import { appendHistoryBestEffort } from '../../core/history-log.js';
28
28
  import { mutatePatchMetadata } from '../../core/patch-export.js';
29
- import { addedLintIgnoreEntries, formatLintIgnoreEntry, formatLintIgnoreList, LINT_IGNORE_CEILING_RULES, lintIgnoreRule, removeLintIgnoreRules, upsertLintIgnoreEntries, } from '../../core/patch-lint-ignore.js';
29
+ import { addedLintIgnoreEntries, formatLintIgnoreEntry, formatLintIgnoreList, lintIgnoreRule, removeLintIgnoreRules, upsertLintIgnoreEntries, } from '../../core/patch-lint-ignore.js';
30
30
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
31
31
  import { info, intro, outro, warn } from '../../utils/logger.js';
32
32
  import { addWaitLockOption, commanderArgParser, resolveWaitLockSeconds, stringListOption, } from '../../utils/options.js';
33
33
  import { parsePositiveIntegerFlag } from '../../utils/validation.js';
34
34
  import { proceedAfterDecision } from '../destructive-decision.js';
35
+ import { parseShare, readLintIgnoreBatch, resolveAddEntries } from './lint-ignore-from.js';
35
36
  import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
36
37
  /**
37
38
  * Printed whenever `--add` lands a new check id. The waiver this command
@@ -88,37 +89,28 @@ function describeChange(before, after, mode, values) {
88
89
  }
89
90
  /**
90
91
  * Turns the mode's IDs into manifest entries, applying `--max`/`--reason`
91
- * to `--add`. Refuses a ceiling flag without `--add`, a `--max` without a
92
- * `--reason` (and the reverse), and a ceiling on a rule that carries no
93
- * measurement, so a waiver that could never suppress anything is not
94
- * written.
92
+ * and `<rule>=<max>` to `--add` (see `lint-ignore-from.ts`). Refuses a
93
+ * ceiling flag without `--add`, a ceiling without a `--reason`, and a
94
+ * ceiling on a rule that carries no measurement, so a waiver that could
95
+ * never suppress anything is not written. A `--from` add resolves later,
96
+ * once the target patch is known.
95
97
  */
96
98
  function resolveRequestedEntries(mode, options) {
97
- const hasMax = options.max !== undefined;
98
- const hasReason = options.reason !== undefined;
99
- if ((hasMax || hasReason) && mode !== 'add') {
100
- throw new InvalidArgumentError('--max and --reason only apply to --add.', hasMax ? '--max' : '--reason');
101
- }
102
- if (hasMax !== hasReason) {
103
- throw new InvalidArgumentError('A lintIgnore ceiling needs both --max <n> and --reason <text>.', hasMax ? '--reason' : '--max');
99
+ const ceilingFlag = options.max !== undefined ? '--max' : options.share !== undefined ? '--share' : undefined;
100
+ if ((ceilingFlag !== undefined || options.reason !== undefined) && mode !== 'add') {
101
+ throw new InvalidArgumentError('--max, --reason and --share only apply to --add.', ceilingFlag ?? '--reason');
104
102
  }
105
103
  if (mode === 'remove')
106
104
  return [...(options.remove ?? [])];
107
105
  if (mode !== 'add')
108
106
  return [];
109
- const ids = options.add ?? [];
110
- if (options.max === undefined || options.reason === undefined)
111
- return [...ids];
112
- if (options.reason.trim() === '') {
113
- throw new InvalidArgumentError('--reason must not be empty.', '--reason');
114
- }
115
- const unmeasured = ids.filter((id) => !LINT_IGNORE_CEILING_RULES.includes(id));
116
- if (unmeasured.length > 0) {
117
- throw new InvalidArgumentError(`--max only applies to the measured size rules (${LINT_IGNORE_CEILING_RULES.join(', ')}); ` +
118
- `got ${unmeasured.join(', ')}.`, '--max');
107
+ if (options.from !== undefined) {
108
+ if ((options.add?.length ?? 0) > 0 || ceilingFlag !== undefined) {
109
+ throw new InvalidArgumentError('--from takes its entries from the file; it combines only with --reason (the default reason).', (options.add?.length ?? 0) > 0 ? '--add' : ceilingFlag);
110
+ }
111
+ return [];
119
112
  }
120
- const { max, reason } = options;
121
- return ids.map((rule) => ({ rule, max, reason }));
113
+ return resolveAddEntries(options.add ?? [], options);
122
114
  }
123
115
  /**
124
116
  * Runs the `patch lint-ignore` command: reads the patch's existing
@@ -135,7 +127,7 @@ export async function patchLintIgnoreCommand(projectRoot, identifier, options =
135
127
  // `--add foo --remove bar` are rejected: an operator who needs both
136
128
  // runs the command twice (clearer audit trail), and `--clear` plus a
137
129
  // mode is contradictory.
138
- const adding = (options.add?.length ?? 0) > 0;
130
+ const adding = (options.add?.length ?? 0) > 0 || options.from !== undefined;
139
131
  const removing = (options.remove?.length ?? 0) > 0;
140
132
  const clearing = options.clear === true;
141
133
  const modeCount = [adding, removing, clearing].filter(Boolean).length;
@@ -143,12 +135,22 @@ export async function patchLintIgnoreCommand(projectRoot, identifier, options =
143
135
  throw new InvalidArgumentError('--add, --remove, and --clear are mutually exclusive. Pick one mode per invocation.', 'patch lint-ignore');
144
136
  }
145
137
  if (modeCount === 0) {
146
- throw new InvalidArgumentError('Specify --add <id>, --remove <id>, or --clear.', 'patch lint-ignore');
138
+ throw new InvalidArgumentError('Specify --add <id>, --from <json-file>, --remove <id>, or --clear.', 'patch lint-ignore');
147
139
  }
148
140
  const mode = adding ? 'add' : removing ? 'remove' : 'clear';
149
- const values = resolveRequestedEntries(mode, options);
141
+ let values = resolveRequestedEntries(mode, options);
150
142
  const { paths, manifest } = await requirePatchQueue(projectRoot);
151
143
  const target = requirePatchTarget(identifier, manifest.patches);
144
+ if (options.from !== undefined) {
145
+ const batch = await readLintIgnoreBatch(options.from, target.filename, options.reason);
146
+ if (batch.skippedForOtherPatches > 0) {
147
+ info(`--from: ${batch.skippedForOtherPatches} entry/entries for other patches left out.`);
148
+ }
149
+ if (batch.entries.length === 0) {
150
+ throw new InvalidArgumentError(`--from ${options.from} holds no lintIgnore entry for ${target.filename}.`, '--from');
151
+ }
152
+ values = batch.entries;
153
+ }
152
154
  // Destructive-operation contract (docs/lifecycle-invariants.md): manifest
153
155
  // metadata mutations get summary + dry-run + confirmation/--yes + history
154
156
  // uniformly. Accepting --yes without ever prompting makes the flag appear
@@ -216,12 +218,16 @@ export function registerPatchLintIgnore(parent, context) {
216
218
  const command = parent
217
219
  .command('lint-ignore <name>')
218
220
  .description('Edit PatchMetadata.lintIgnore on a single patch (no .patch body rewrite). One mode per invocation.')
219
- .option('--add <check-id>', 'Lint check ID to add to the patch lintIgnore list (repeatable)', ...stringListOption())
221
+ .option('--add <check-id>', 'Lint check ID to add to the patch lintIgnore list (repeatable); <rule>=<max> adds a ceiling at <max>', ...stringListOption())
220
222
  .option('--remove <check-id>', 'Lint check ID to remove from the patch lintIgnore list (repeatable)', ...stringListOption())
221
223
  .option('--clear', 'Drop the lintIgnore field entirely')
222
224
  .addOption(new Option('--max <n>', 'With --add: write a size waiver with a ceiling, suppressing the rule only while its ' +
223
225
  'measurement is at most <n> (file-too-large, large-patch-lines, large-patch-files)').argParser(commanderArgParser((v) => parsePositiveIntegerFlag('--max', v))))
224
- .option('--reason <text>', 'With --add --max: why the ceiling exists (required with --max)')
226
+ .option('--reason <text>', 'Why the ceiling exists (required with --max or <rule>=<max>; with --from: the default for entries without one)')
227
+ .addOption(new Option('--share <share>', 'With a large-patch-lines ceiling: bound only the "product" or "test" share of the measurement').argParser(commanderArgParser(parseShare)))
228
+ .option('--from <json-file>', 'Add every entry in a JSON file under one lock hold: an array of check IDs or ' +
229
+ '{ rule, max, reason?, share?, meta?, patch? } objects, or a "lint --per-patch --report" ' +
230
+ 'document, whose ceilings for this patch are rebaselined to their current measurement')
225
231
  .option('--dry-run', 'Show what would change without writing')
226
232
  .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)');
227
233
  addWaitLockOption(command).action(withErrorHandling(async (name, options) => {
@@ -240,6 +246,10 @@ export function registerPatchLintIgnore(parent, context) {
240
246
  normalized.max = options.max;
241
247
  if (options.reason !== undefined)
242
248
  normalized.reason = options.reason;
249
+ if (options.share !== undefined)
250
+ normalized.share = options.share;
251
+ if (options.from !== undefined)
252
+ normalized.from = options.from;
243
253
  if (options.dryRun === true)
244
254
  normalized.dryRun = true;
245
255
  if (options.yes === true)
@@ -21,11 +21,12 @@ import { enforcePatchPolicy } from '../../core/patch-policy.js';
21
21
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
22
22
  import { info, intro, outro, success } from '../../utils/logger.js';
23
23
  import { normalizePatchDisplayName } from '../../utils/validation.js';
24
+ import { withDeclarationsReport } from '../declarations-report.js';
24
25
  import { proceedAfterDecision } from '../destructive-decision.js';
25
26
  import { resolvePlacementPlan } from '../export-flow.js';
26
27
  import { runPatchLint } from '../export-shared.js';
27
28
  import { commitPatchSplit, reportSplitStagedChanges } from './split.js';
28
- import { assertSourceOwnsFiles, buildNewPatchMetadata, buildSplitDiff, buildSplitSummary, projectSplitManifest, runProjectedSplitLint, } from './split-plan.js';
29
+ import { assertSourceOwnsFiles, buildNewPatchMetadata, buildSplitDiff, buildSplitSummary, projectSplitManifest, runProjectedSplitLint, splitDeclarationRenamer, } from './split-plan.js';
29
30
  import { emptyStagedMaintenance, findOwnerRewriteHolders } from './split-staged.js';
30
31
  /**
31
32
  * Runs `patch move-files --create --order <n>`: plans the create+move as a
@@ -136,9 +137,15 @@ export async function patchMoveFilesCreateCommand(projectRoot, fromIdentifier, n
136
137
  ignoreChecks,
137
138
  patchTier: source.tier,
138
139
  });
139
- const { conflicts, stagedDependencyAdditions, stagedMaintenance } = runProjectedSplitLint(plan, patchQueueCtx);
140
- plan.stagedDependencyAdditions = stagedDependencyAdditions;
141
- plan.stagedMaintenance = stagedMaintenance;
140
+ const projected = runProjectedSplitLint(plan, patchQueueCtx);
141
+ plan.stagedDependencyAdditions = projected.stagedDependencyAdditions;
142
+ plan.stagedMaintenance = projected.stagedMaintenance;
143
+ const conflicts = await withDeclarationsReport(projected.conflicts, {
144
+ projectRoot,
145
+ regressions: projected.regressions,
146
+ out: options.declarationsOut,
147
+ rename: splitDeclarationRenamer(plan),
148
+ });
142
149
  const newMetadata = buildNewPatchMetadata(plan, config);
143
150
  const decision = await confirmDestructive({
144
151
  operation: 'patch-move-files-create',
@@ -14,7 +14,7 @@
14
14
  * reflect both patches' content, because both bodies are regenerated from
15
15
  * the worktree.
16
16
  */
17
- import { join } from 'node:path';
17
+ import { dirname, join, resolve } from 'node:path';
18
18
  import { assertConfirmationAvailable, confirmDestructive, } from '../../core/destructive.js';
19
19
  import { appendHistoryBestEffort } from '../../core/history-log.js';
20
20
  import { buildPatchQueueContext, collectForwardImportEdges, formatPatchLintIssue, lintPatchQueue, } from '../../core/patch-lint.js';
@@ -27,6 +27,7 @@ import { toError } from '../../utils/errors.js';
27
27
  import { readText, writeText } from '../../utils/fs.js';
28
28
  import { info, outro, success, warn } from '../../utils/logger.js';
29
29
  import { resolveWaitLockSeconds } from '../../utils/options.js';
30
+ import { withDeclarationsReport } from '../declarations-report.js';
30
31
  import { proceedAfterDecision } from '../destructive-decision.js';
31
32
  import { runPatchLint } from '../export-shared.js';
32
33
  import { projectEntryBody } from './entry-projection.js';
@@ -91,7 +92,7 @@ function runProjectedMoveLint(plan, baseCtx) {
91
92
  reason: `move would introduce ${regressions.length} cross-patch lint error(s)`,
92
93
  details: regressions.map(formatPatchLintIssue),
93
94
  };
94
- return { conflicts, stagedDependencyAdditions };
95
+ return { conflicts, regressions, stagedDependencyAdditions };
95
96
  }
96
97
  /**
97
98
  * Commits a confirmed move under the patch-directory lock: write both
@@ -225,8 +226,13 @@ export async function runMoveFilesInto(args) {
225
226
  ownerRewrites: findOwnerRewriteHolders(manifest?.patches ?? [], source.filename, new Set(files)),
226
227
  stagedDependencyAdditions: new Map(),
227
228
  };
228
- const { conflicts, stagedDependencyAdditions } = runProjectedMoveLint(plan, patchQueueCtx);
229
- plan.stagedDependencyAdditions = stagedDependencyAdditions;
229
+ const projected = runProjectedMoveLint(plan, patchQueueCtx);
230
+ plan.stagedDependencyAdditions = projected.stagedDependencyAdditions;
231
+ const conflicts = await withDeclarationsReport(projected.conflicts, {
232
+ projectRoot: dirname(resolve(patchesDir)),
233
+ regressions: projected.regressions,
234
+ out: options.declarationsOut,
235
+ });
230
236
  const decision = await confirmDestructive({
231
237
  operation: 'patch-move-files',
232
238
  title: `Move ${files.length} file(s) from ${source.filename} to ${target.filename}`,
@@ -172,7 +172,8 @@ export function registerPatchMoveFiles(parent, context) {
172
172
  .option('--dry-run', 'Show what would happen without writing')
173
173
  .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
174
174
  .option('--force-unsafe', 'Bypass projected-lint refusals')
175
- .option('--skip-lint', 'Skip per-patch lint of the projected bodies');
175
+ .option('--skip-lint', 'Skip per-patch lint of the projected bodies')
176
+ .option('--declarations-out <path>', 'Where a refusal writes its one --from-ready staged-declaration report (default: .fireforge/declarations/<timestamp>.json)');
176
177
  addWaitLockOption(command).action(withErrorHandling(async (from, to, options) => {
177
178
  await patchMoveFilesCommand(getProjectRoot(), from, to, {
178
179
  ...pickDefined({
@@ -185,6 +186,7 @@ export function registerPatchMoveFiles(parent, context) {
185
186
  yes: options.yes,
186
187
  forceUnsafe: options.forceUnsafe,
187
188
  skipLint: options.skipLint,
189
+ declarationsOut: options.declarationsOut,
188
190
  waitLock: options.waitLock,
189
191
  }),
190
192
  });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * What `patch delete` and `patch retire` do to the files the removed patch
3
+ * claims, shared so both commands say and do the same.
4
+ *
5
+ * A file the patch modifies stays in the engine with its edits, unmanaged.
6
+ * A file the patch creates is different: once the patch is gone nothing
7
+ * owns it, so a created file still on disk turns into an unmanaged file the
8
+ * engine-clean gate reports. The summary names each such file (in the dry
9
+ * run and at the prompt), and `--delete-created` removes them as part of
10
+ * the confirmed operation.
11
+ */
12
+ import type { PatchQueueContext } from '../../core/patch-lint.js';
13
+ import type { PatchMetadata } from '../../types/commands/index.js';
14
+ /** The summary lines for a removal's claimed files, and what to delete. */
15
+ export interface ClaimedFilePlan {
16
+ /** One summary line per claimed file, plus a header. */
17
+ summary: string[];
18
+ /** Created files still on disk that the removal will delete. */
19
+ deleteOnCommit: string[];
20
+ }
21
+ /**
22
+ * Plans the claimed-file half of a removal.
23
+ *
24
+ * @param ctx - Queue context built before the removal
25
+ * @param target - The patch about to leave the queue
26
+ * @param engineDir - Engine directory
27
+ * @param deleteCreated - Whether `--delete-created` was passed
28
+ */
29
+ export declare function planClaimedFiles(ctx: PatchQueueContext, target: PatchMetadata, engineDir: string, deleteCreated: boolean): Promise<ClaimedFilePlan>;
30
+ /**
31
+ * Deletes the created files a confirmed removal planned to delete.
32
+ *
33
+ * @param engineDir - Engine directory
34
+ * @param files - Engine-relative paths from {@link planClaimedFiles}
35
+ */
36
+ export declare function deleteCreatedFiles(engineDir: string, files: readonly string[]): Promise<void>;
37
+ /**
38
+ * Runs a removal's locked commit, inside the engine-session lock as well
39
+ * when it will delete engine files, so no engine-mutating peer runs while
40
+ * created files disappear under it.
41
+ *
42
+ * @param projectRoot - Project root directory
43
+ * @param command - Command name for lock attribution
44
+ * @param plan - The claimed-file plan
45
+ * @param waitLockSeconds - Wait budget for the engine lock
46
+ * @param operation - The patch-directory-locked commit
47
+ */
48
+ export declare function withCreatedFileLock(projectRoot: string, command: string, plan: ClaimedFilePlan, waitLockSeconds: number | undefined, operation: () => Promise<void>): Promise<void>;
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * What `patch delete` and `patch retire` do to the files the removed patch
4
+ * claims, shared so both commands say and do the same.
5
+ *
6
+ * A file the patch modifies stays in the engine with its edits, unmanaged.
7
+ * A file the patch creates is different: once the patch is gone nothing
8
+ * owns it, so a created file still on disk turns into an unmanaged file the
9
+ * engine-clean gate reports. The summary names each such file (in the dry
10
+ * run and at the prompt), and `--delete-created` removes them as part of
11
+ * the confirmed operation.
12
+ */
13
+ import { join } from 'node:path';
14
+ import { withEngineSessionLock } from '../../core/engine-session-lock.js';
15
+ import { pathExists, removeFile } from '../../utils/fs.js';
16
+ /**
17
+ * Plans the claimed-file half of a removal.
18
+ *
19
+ * @param ctx - Queue context built before the removal
20
+ * @param target - The patch about to leave the queue
21
+ * @param engineDir - Engine directory
22
+ * @param deleteCreated - Whether `--delete-created` was passed
23
+ */
24
+ export async function planClaimedFiles(ctx, target, engineDir, deleteCreated) {
25
+ const created = ctx.entries.find((entry) => entry.filename === target.filename)?.createdFiles;
26
+ const summary = [`files currently claimed by this patch (${target.filesAffected.length}):`];
27
+ const deleteOnCommit = [];
28
+ let lingering = 0;
29
+ for (const file of target.filesAffected) {
30
+ if (created?.has(file) !== true) {
31
+ summary.push(` ${file} → will become unmanaged`);
32
+ continue;
33
+ }
34
+ if (!(await pathExists(join(engineDir, file)))) {
35
+ summary.push(` ${file} → created by this patch; already gone`);
36
+ continue;
37
+ }
38
+ if (deleteCreated) {
39
+ deleteOnCommit.push(file);
40
+ summary.push(` ${file} → created by this patch; will be deleted (--delete-created)`);
41
+ }
42
+ else {
43
+ lingering++;
44
+ summary.push(` ${file} → created by this patch and still on disk: will become unmanaged`);
45
+ }
46
+ }
47
+ if (lingering > 0) {
48
+ summary.push(`${lingering} created file(s) are still on disk. Delete them first, or pass ` +
49
+ '--delete-created to remove them with the patch; otherwise status reports them as unmanaged.');
50
+ }
51
+ return { summary, deleteOnCommit };
52
+ }
53
+ /**
54
+ * Deletes the created files a confirmed removal planned to delete.
55
+ *
56
+ * @param engineDir - Engine directory
57
+ * @param files - Engine-relative paths from {@link planClaimedFiles}
58
+ */
59
+ export async function deleteCreatedFiles(engineDir, files) {
60
+ for (const file of files) {
61
+ await removeFile(join(engineDir, file));
62
+ }
63
+ }
64
+ /**
65
+ * Runs a removal's locked commit, inside the engine-session lock as well
66
+ * when it will delete engine files, so no engine-mutating peer runs while
67
+ * created files disappear under it.
68
+ *
69
+ * @param projectRoot - Project root directory
70
+ * @param command - Command name for lock attribution
71
+ * @param plan - The claimed-file plan
72
+ * @param waitLockSeconds - Wait budget for the engine lock
73
+ * @param operation - The patch-directory-locked commit
74
+ */
75
+ export async function withCreatedFileLock(projectRoot, command, plan, waitLockSeconds, operation) {
76
+ if (plan.deleteOnCommit.length === 0) {
77
+ await operation();
78
+ return;
79
+ }
80
+ await withEngineSessionLock(projectRoot, command, operation, {
81
+ ...(waitLockSeconds !== undefined ? { waitLockSeconds } : {}),
82
+ });
83
+ }
@@ -26,7 +26,7 @@ import { buildPatchQueueContext, formatPatchLintIssue, lintPatchQueue, } from '.
26
26
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
27
27
  import { loadPatchesManifest, rewriteStagedDependencyOwners, savePatchesManifest, } from '../../core/patch-manifest.js';
28
28
  import { buildProjectedManifest, enforcePatchPolicy, getPatchPolicyCategories, isCategoryAllowedByConfig, } from '../../core/patch-policy.js';
29
- import { carryRetiredSlots } from '../../core/patch-retired.js';
29
+ import { carryReservedSlots, carryRetiredSlots } from '../../core/patch-retired.js';
30
30
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
31
31
  import { toError } from '../../utils/errors.js';
32
32
  import { pathExists } from '../../utils/fs.js';
@@ -116,12 +116,17 @@ async function commitRenameUnderLock(input) {
116
116
  ...(descriptionChanging ? { description: newDescription ?? '' } : {}),
117
117
  };
118
118
  }
119
- // A rename onto a retired order fills that slot.
119
+ // A rename onto a retired or reserved order fills that slot.
120
120
  const retired = carryRetiredSlots(fresh, fresh.patches);
121
121
  if (retired === undefined)
122
122
  delete fresh.retired;
123
123
  else
124
124
  fresh.retired = retired;
125
+ const reserved = carryReservedSlots(fresh, fresh.patches);
126
+ if (reserved === undefined)
127
+ delete fresh.reserved;
128
+ else
129
+ fresh.reserved = reserved;
125
130
  enforcePatchPolicy({
126
131
  config: input.config,
127
132
  manifest: buildProjectedManifest(fresh, fresh.patches),
@@ -306,11 +311,18 @@ export async function patchRenameCommand(projectRoot, identifier, options = {})
306
311
  command: 'patch rename',
307
312
  forceUnsafe: options.forceUnsafe === true,
308
313
  });
314
+ // A description-only edit renames nothing and moves no order, so it needs
315
+ // no confirmation: scripts may change a description without --yes.
316
+ const descriptionOnly = descriptionChanging &&
317
+ !filenameChanging &&
318
+ !nameChanging &&
319
+ !orderChanging &&
320
+ !categoryChanging;
309
321
  const decision = await confirmDestructive({
310
322
  operation: 'patch-rename',
311
323
  title: `Rename ${target.filename}`,
312
324
  summary,
313
- yes: options.yes === true,
325
+ yes: options.yes === true || descriptionOnly,
314
326
  dryRun: isDryRun,
315
327
  conflicts: null,
316
328
  });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `fireforge patch reserve <order>`: holds an order number for a patch a
3
+ * session has agreed to export but has not exported yet.
4
+ *
5
+ * The case it exists for: under `patchPolicy.allowGaps: false`, a session
6
+ * cannot `export --order 571` while a peer has claimed 569 and 570 but not
7
+ * exported them, because the unfilled orders read as a gap. A reservation
8
+ * is a placeholder in `patches.json#reserved` that the gap check counts as
9
+ * occupied and the earliest-legal-order search counts as taken. The export
10
+ * that lands on the order fills it (see `core/patch-retired.ts`), and
11
+ * `--release` drops a reservation whose patch will not come.
12
+ *
13
+ * Metadata-only: no `.patch` file is written and no row is added, so
14
+ * apply, lint and rebase never see a reservation.
15
+ */
16
+ import { Command } from 'commander';
17
+ import type { CommandContext } from '../../types/cli.js';
18
+ import type { PatchReserveOptions } from '../../types/commands/index.js';
19
+ /**
20
+ * Runs the `patch reserve` command.
21
+ *
22
+ * @param projectRoot - Project root directory
23
+ * @param order - Order number to reserve or release
24
+ * @param options - Command options
25
+ */
26
+ export declare function patchReserveCommand(projectRoot: string, order: number, options?: PatchReserveOptions): Promise<void>;
27
+ /**
28
+ * Registers the `patch reserve` subcommand on the `patch` parent.
29
+ *
30
+ * @param parent - Parent Commander command
31
+ * @param context - Shared CLI registration context
32
+ */
33
+ export declare function registerPatchReserve(parent: Command, context: CommandContext): void;
@@ -0,0 +1,152 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge patch reserve <order>`: holds an order number for a patch a
4
+ * session has agreed to export but has not exported yet.
5
+ *
6
+ * The case it exists for: under `patchPolicy.allowGaps: false`, a session
7
+ * cannot `export --order 571` while a peer has claimed 569 and 570 but not
8
+ * exported them, because the unfilled orders read as a gap. A reservation
9
+ * is a placeholder in `patches.json#reserved` that the gap check counts as
10
+ * occupied and the earliest-legal-order search counts as taken. The export
11
+ * that lands on the order fills it (see `core/patch-retired.ts`), and
12
+ * `--release` drops a reservation whose patch will not come.
13
+ *
14
+ * Metadata-only: no `.patch` file is written and no row is added, so
15
+ * apply, lint and rebase never see a reservation.
16
+ */
17
+ import { Command } from 'commander';
18
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
19
+ import { appendHistoryBestEffort } from '../../core/history-log.js';
20
+ import { withPatchDirectoryLock } from '../../core/patch-lock.js';
21
+ import { loadPatchesManifestForWrite, savePatchesManifest } from '../../core/patch-manifest.js';
22
+ import { formatPatchOrder } from '../../core/patch-parse.js';
23
+ import { categoryRangeForOrder, categoryRangeLabel, isCategoryAllowedByConfig, } from '../../core/patch-policy.js';
24
+ import { InvalidArgumentError } from '../../errors/base.js';
25
+ import { info, intro, outro } from '../../utils/logger.js';
26
+ import { addWaitLockOption, pickDefined, resolveWaitLockSeconds } from '../../utils/options.js';
27
+ import { parsePositiveIntegerFlag } from '../../utils/validation.js';
28
+ /**
29
+ * Refuses a reservation the manifest or the policy cannot hold: an order a
30
+ * patch, a retired slot or another reservation already takes, or one
31
+ * outside the category's configured ranges.
32
+ */
33
+ function assertReservable(manifest, config, order, category) {
34
+ const label = formatPatchOrder(order);
35
+ const patch = manifest.patches.find((entry) => entry.order === order);
36
+ if (patch !== undefined) {
37
+ throw new InvalidArgumentError(`Order ${label} is taken by ${patch.filename}.`, '<order>');
38
+ }
39
+ if ((manifest.retired ?? []).some((slot) => slot.order === order)) {
40
+ throw new InvalidArgumentError(`Order ${label} is a retired slot; "fireforge export --order ${label}" fills it without a reservation.`, '<order>');
41
+ }
42
+ const held = manifest.reserved?.find((slot) => slot.order === order);
43
+ if (held !== undefined) {
44
+ throw new InvalidArgumentError(`Order ${label} is already reserved by ${held.owner} (since ${held.reservedAt}).`, '<order>');
45
+ }
46
+ const policy = config.patchPolicy;
47
+ if (policy !== undefined && categoryRangeForOrder(policy, category, order) === null) {
48
+ throw new InvalidArgumentError(`Order ${label} is outside the ${category} range (${categoryRangeLabel(policy.ranges, category)}).`, '<order>');
49
+ }
50
+ }
51
+ /**
52
+ * Runs the `patch reserve` command.
53
+ *
54
+ * @param projectRoot - Project root directory
55
+ * @param order - Order number to reserve or release
56
+ * @param options - Command options
57
+ */
58
+ export async function patchReserveCommand(projectRoot, order, options = {}) {
59
+ const releasing = options.release === true;
60
+ const verb = releasing ? 'release' : 'reserve';
61
+ intro(options.dryRun ? `FireForge patch ${verb} (dry run)` : `FireForge patch ${verb}`);
62
+ const config = await loadConfig(projectRoot);
63
+ const label = formatPatchOrder(order);
64
+ let category;
65
+ let owner;
66
+ if (!releasing) {
67
+ category = options.category;
68
+ owner = options.owner?.trim();
69
+ if (category === undefined || owner === undefined || owner === '') {
70
+ throw new InvalidArgumentError('patch reserve requires --category and --owner (or --release to drop a reservation).', [category === undefined ? '--category' : '', owner ? '' : '--owner']
71
+ .filter(Boolean)
72
+ .join(', '));
73
+ }
74
+ if (!isCategoryAllowedByConfig(config, category)) {
75
+ throw new InvalidArgumentError(`Unknown patch category "${category}".`, '--category');
76
+ }
77
+ }
78
+ else if (options.category !== undefined || options.owner !== undefined) {
79
+ throw new InvalidArgumentError('--release takes only the order; --category and --owner apply when reserving.', '--release');
80
+ }
81
+ const { patches: patchesDir } = getProjectPaths(projectRoot);
82
+ await withPatchDirectoryLock(patchesDir, async () => {
83
+ const manifest = (await loadPatchesManifestForWrite(patchesDir)) ?? {
84
+ version: 1,
85
+ patches: [],
86
+ };
87
+ const reserved = manifest.reserved ?? [];
88
+ let slot;
89
+ let next;
90
+ if (releasing) {
91
+ const held = reserved.find((entry) => entry.order === order);
92
+ if (held === undefined) {
93
+ throw new InvalidArgumentError(`Order ${label} is not reserved.`, '<order>');
94
+ }
95
+ slot = held;
96
+ next = reserved.filter((entry) => entry.order !== order);
97
+ }
98
+ else {
99
+ assertReservable(manifest, config, order, category);
100
+ slot = {
101
+ order,
102
+ category: category,
103
+ owner: owner,
104
+ reservedAt: new Date().toISOString(),
105
+ ...(options.reason !== undefined ? { reason: options.reason } : {}),
106
+ };
107
+ next = [...reserved, slot].sort((a, b) => a.order - b.order);
108
+ }
109
+ const summary = releasing
110
+ ? `release order ${label} (reserved by ${slot.owner})`
111
+ : `reserve order ${label} for ${slot.owner} (category: ${slot.category})`;
112
+ if (options.dryRun === true) {
113
+ info(`[dry-run] ${summary}`);
114
+ return;
115
+ }
116
+ if (next.length > 0)
117
+ manifest.reserved = next;
118
+ else
119
+ delete manifest.reserved;
120
+ await savePatchesManifest(patchesDir, manifest);
121
+ await appendHistoryBestEffort(patchesDir, {
122
+ operation: releasing ? 'patch-reserve-release' : 'patch-reserve',
123
+ args: { ...slot },
124
+ result: 'ok',
125
+ }, `patch ${verb} committed (${label})`);
126
+ info(releasing
127
+ ? `Released order ${label}.`
128
+ : `Reserved order ${label} for ${slot.owner}; "fireforge export --order ${label}" fills it.`);
129
+ }, { waitLockSeconds: resolveWaitLockSeconds(options.waitLock), command: `patch ${verb}` });
130
+ outro(options.dryRun ? 'Dry run complete — no changes made' : `Patch ${verb} complete`);
131
+ }
132
+ /**
133
+ * Registers the `patch reserve` subcommand on the `patch` parent.
134
+ *
135
+ * @param parent - Parent Commander command
136
+ * @param context - Shared CLI registration context
137
+ */
138
+ export function registerPatchReserve(parent, context) {
139
+ const { getProjectRoot, withErrorHandling } = context;
140
+ const command = parent
141
+ .command('reserve <order>')
142
+ .description('Hold an order number for a patch not exported yet, so patchPolicy.allowGaps: false ' +
143
+ 'sees no gap; the export that takes the order fills it (metadata only)')
144
+ .option('--category <category>', 'Category the reserved patch will carry')
145
+ .option('--owner <label>', 'Who holds the reservation (a session label or planned filename)')
146
+ .option('--reason <text>', 'Rationale recorded on the reserved slot')
147
+ .option('--release', 'Drop the reservation at <order> instead of creating one')
148
+ .option('--dry-run', 'Show what would change without writing');
149
+ addWaitLockOption(command).action(withErrorHandling(async (rawOrder, options) => {
150
+ await patchReserveCommand(getProjectRoot(), parsePositiveIntegerFlag('<order>', rawOrder), pickDefined(options));
151
+ }));
152
+ }