@hominis/fireforge 0.50.0 → 0.51.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 (205) hide show
  1. package/CHANGELOG.md +47 -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/test-build-furnace.d.ts +3 -2
  63. package/dist/src/commands/test-build-furnace.js +8 -2
  64. package/dist/src/commands/test-build-phase.js +24 -7
  65. package/dist/src/commands/test-checkjs.js +3 -2
  66. package/dist/src/commands/test-dispatch.d.ts +39 -0
  67. package/dist/src/commands/test-dispatch.js +211 -0
  68. package/dist/src/commands/test-furnace-reach.d.ts +37 -0
  69. package/dist/src/commands/test-furnace-reach.js +132 -0
  70. package/dist/src/commands/test-orchestrate.d.ts +9 -0
  71. package/dist/src/commands/test-orchestrate.js +22 -4
  72. package/dist/src/commands/test-perf-scope.d.ts +21 -0
  73. package/dist/src/commands/test-perf-scope.js +55 -0
  74. package/dist/src/commands/test-prelock.js +100 -4
  75. package/dist/src/commands/test-register.js +18 -3
  76. package/dist/src/commands/test-results.d.ts +62 -0
  77. package/dist/src/commands/test-results.js +62 -0
  78. package/dist/src/commands/test-run.d.ts +13 -55
  79. package/dist/src/commands/test-run.js +73 -101
  80. package/dist/src/commands/test-shards.d.ts +110 -0
  81. package/dist/src/commands/test-shards.js +255 -0
  82. package/dist/src/commands/test-stale-gate.d.ts +5 -1
  83. package/dist/src/commands/test-stale-gate.js +4 -2
  84. package/dist/src/commands/test-verdict.d.ts +2 -1
  85. package/dist/src/commands/test.d.ts +1 -1
  86. package/dist/src/commands/test.js +65 -75
  87. package/dist/src/commands/token-lint.js +48 -8
  88. package/dist/src/commands/verify.js +9 -2
  89. package/dist/src/core/build-prepare.d.ts +6 -0
  90. package/dist/src/core/build-prepare.js +29 -3
  91. package/dist/src/core/config-paths.d.ts +1 -1
  92. package/dist/src/core/config-paths.js +2 -0
  93. package/dist/src/core/config-validate-check.js +83 -7
  94. package/dist/src/core/config-validate-test-toolchains.js +14 -0
  95. package/dist/src/core/config-validate-token-scales.d.ts +3 -0
  96. package/dist/src/core/config-validate-token-scales.js +47 -5
  97. package/dist/src/core/config-validate.js +6 -0
  98. package/dist/src/core/coverage-extend.js +2 -2
  99. package/dist/src/core/coverage-records.d.ts +11 -0
  100. package/dist/src/core/coverage-records.js +34 -0
  101. package/dist/src/core/engine-session-lock.d.ts +6 -0
  102. package/dist/src/core/engine-session-lock.js +10 -2
  103. package/dist/src/core/file-lock-shared.d.ts +57 -0
  104. package/dist/src/core/file-lock-shared.js +117 -0
  105. package/dist/src/core/file-lock.d.ts +9 -0
  106. package/dist/src/core/file-lock.js +58 -2
  107. package/dist/src/core/furnace-apply.js +10 -10
  108. package/dist/src/core/furnace-config-validate-exceptions.d.ts +10 -3
  109. package/dist/src/core/furnace-config-validate-exceptions.js +39 -8
  110. package/dist/src/core/furnace-config-validate.js +7 -1
  111. package/dist/src/core/furnace-registration-jar-inplace.d.ts +12 -0
  112. package/dist/src/core/furnace-registration-jar-inplace.js +80 -0
  113. package/dist/src/core/furnace-validate-accessibility.d.ts +4 -2
  114. package/dist/src/core/furnace-validate-accessibility.js +61 -27
  115. package/dist/src/core/furnace-validate-exceptions.d.ts +12 -4
  116. package/dist/src/core/furnace-validate-exceptions.js +41 -4
  117. package/dist/src/core/furnace-validate-icon-button.js +30 -1
  118. package/dist/src/core/history-log-file.js +8 -1
  119. package/dist/src/core/line-prefixer.d.ts +18 -0
  120. package/dist/src/core/line-prefixer.js +34 -0
  121. package/dist/src/core/mach.d.ts +7 -0
  122. package/dist/src/core/mach.js +24 -9
  123. package/dist/src/core/module-resolution-preflight.d.ts +19 -3
  124. package/dist/src/core/module-resolution-preflight.js +24 -6
  125. package/dist/src/core/negative-control-compiled.d.ts +27 -0
  126. package/dist/src/core/negative-control-compiled.js +73 -0
  127. package/dist/src/core/negative-control-diff.d.ts +14 -2
  128. package/dist/src/core/negative-control-diff.js +49 -6
  129. package/dist/src/core/negative-control.d.ts +12 -0
  130. package/dist/src/core/negative-control.js +48 -5
  131. package/dist/src/core/park-store.d.ts +63 -0
  132. package/dist/src/core/park-store.js +216 -0
  133. package/dist/src/core/patch-export-update.d.ts +4 -0
  134. package/dist/src/core/patch-export-update.js +12 -3
  135. package/dist/src/core/patch-export.d.ts +3 -2
  136. package/dist/src/core/patch-export.js +21 -14
  137. package/dist/src/core/patch-files.js +26 -8
  138. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +6 -4
  139. package/dist/src/core/patch-lint-checkjs-any-import.js +9 -7
  140. package/dist/src/core/patch-lint-checkjs-diagnostics.d.ts +17 -0
  141. package/dist/src/core/patch-lint-checkjs-diagnostics.js +32 -0
  142. package/dist/src/core/patch-lint-checkjs.d.ts +10 -1
  143. package/dist/src/core/patch-lint-checkjs.js +13 -3
  144. package/dist/src/core/patch-lint-cross.d.ts +3 -1
  145. package/dist/src/core/patch-lint-cross.js +5 -0
  146. package/dist/src/core/patch-lint-css.js +14 -10
  147. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +5 -0
  148. package/dist/src/core/patch-lint-forward-registration-extended.js +1 -1
  149. package/dist/src/core/patch-lint-ignore.d.ts +56 -12
  150. package/dist/src/core/patch-lint-ignore.js +173 -43
  151. package/dist/src/core/patch-lint-ownership.d.ts +10 -0
  152. package/dist/src/core/patch-lint-ownership.js +26 -0
  153. package/dist/src/core/patch-lint-size.js +9 -1
  154. package/dist/src/core/patch-lint-token-scale.d.ts +26 -8
  155. package/dist/src/core/patch-lint-token-scale.js +206 -29
  156. package/dist/src/core/patch-lint.js +2 -2
  157. package/dist/src/core/patch-manifest-io.js +8 -1
  158. package/dist/src/core/patch-manifest-key-order.d.ts +24 -0
  159. package/dist/src/core/patch-manifest-key-order.js +61 -0
  160. package/dist/src/core/patch-manifest-stage.d.ts +46 -0
  161. package/dist/src/core/patch-manifest-stage.js +96 -0
  162. package/dist/src/core/patch-manifest-validate.js +27 -1
  163. package/dist/src/core/patch-order-legal.d.ts +2 -1
  164. package/dist/src/core/patch-order-legal.js +7 -2
  165. package/dist/src/core/patch-policy.d.ts +6 -2
  166. package/dist/src/core/patch-policy.js +14 -7
  167. package/dist/src/core/patch-retired.d.ts +26 -4
  168. package/dist/src/core/patch-retired.js +40 -2
  169. package/dist/src/core/run-log.d.ts +6 -0
  170. package/dist/src/core/run-log.js +17 -1
  171. package/dist/src/core/test-harness-crash.d.ts +2 -1
  172. package/dist/src/core/test-harness-crash.js +5 -8
  173. package/dist/src/core/test-harness-signature.d.ts +16 -0
  174. package/dist/src/core/test-harness-signature.js +19 -1
  175. package/dist/src/core/test-raw-log.d.ts +66 -0
  176. package/dist/src/core/test-raw-log.js +122 -0
  177. package/dist/src/core/test-run-completeness.d.ts +36 -3
  178. package/dist/src/core/test-run-completeness.js +57 -17
  179. package/dist/src/core/test-xpcshell-retry.d.ts +2 -0
  180. package/dist/src/core/test-xpcshell-retry.js +2 -1
  181. package/dist/src/core/token-lint.d.ts +5 -2
  182. package/dist/src/core/token-lint.js +8 -3
  183. package/dist/src/core/token-sibling-sheets.d.ts +46 -0
  184. package/dist/src/core/token-sibling-sheets.js +119 -0
  185. package/dist/src/core/tree-guard.js +2 -0
  186. package/dist/src/core/typecheck-shim.d.ts +5 -1
  187. package/dist/src/core/typecheck-shim.js +115 -2
  188. package/dist/src/core/typecheck.js +3 -1
  189. package/dist/src/index.d.ts +9 -3
  190. package/dist/src/index.js +4 -1
  191. package/dist/src/types/commands/index.d.ts +2 -2
  192. package/dist/src/types/commands/options.d.ts +68 -7
  193. package/dist/src/types/commands/patches.d.ts +43 -0
  194. package/dist/src/types/config.d.ts +74 -2
  195. package/dist/src/types/furnace.d.ts +20 -0
  196. package/dist/src/types/index.d.ts +1 -1
  197. package/dist/src/utils/options.d.ts +3 -2
  198. package/dist/src/utils/options.js +9 -4
  199. package/docs/configuration.md +94 -9
  200. package/docs/furnace.md +43 -1
  201. package/docs/lifecycle-invariants.md +13 -11
  202. package/docs/machine-output.md +64 -18
  203. package/docs/patch-workflow.md +174 -13
  204. package/docs/testing.md +73 -4
  205. package/package.json +3 -3
@@ -0,0 +1,22 @@
1
+ import type { Command } from 'commander';
2
+ import type { CommandContext } from '../types/cli.js';
3
+ /** Options for `fireforge park`. */
4
+ export interface ParkOptions {
5
+ label?: string;
6
+ hunks?: string;
7
+ restore?: string;
8
+ list?: boolean;
9
+ drop?: string;
10
+ dryRun?: boolean;
11
+ waitLock?: number | boolean;
12
+ }
13
+ /**
14
+ * Runs `fireforge park`.
15
+ *
16
+ * @param projectRoot - Project root
17
+ * @param identifier - The patch to park (omitted for --restore/--list/--drop)
18
+ * @param options - See {@link ParkOptions}
19
+ */
20
+ export declare function parkCommand(projectRoot: string, identifier: string | undefined, options: ParkOptions): Promise<void>;
21
+ /** Registers `fireforge park`. */
22
+ export declare function registerPark(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;
@@ -0,0 +1,232 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge park`: set one session's edits in a patch's files aside and
4
+ * put them back after the owner commits.
5
+ *
6
+ * park <patch> [--label <l>] [--hunks <diff>] park and restore baseline
7
+ * park --restore <label> three-way merge them back
8
+ * park --list | park --drop <label>
9
+ *
10
+ * Without `--hunks` every drifted file of the patch goes back to its
11
+ * queue-applied baseline. With `--hunks` only the named hunks are taken out
12
+ * of the working tree. Either way the park warns when a hunk it takes out
13
+ * removes a Fluent message id, or a whole file, that files outside the
14
+ * patch still reference: a parked string still used by markup another
15
+ * patch owns makes `document.l10n.ready` reject in every new window.
16
+ */
17
+ import { basename, join } from 'node:path';
18
+ import { getProjectPaths } from '../core/config.js';
19
+ import { withEngineSessionLock } from '../core/engine-session-lock.js';
20
+ import { appendHistoryBestEffort } from '../core/history-log.js';
21
+ import { dropPark, fluentIds, listParks, planRestore, readEngineFile, readPark, removedLines, writeEngineFile, writePark, } from '../core/park-store.js';
22
+ import { createPatchedContentContext } from '../core/patch-apply.js';
23
+ import { formatPatchNotFoundError } from '../core/patch-identifier-suggest.js';
24
+ import { loadPatchesManifest, resolvePatchIdentifier } from '../core/patch-manifest.js';
25
+ import { parseDiffSections } from '../core/patch-parse.js';
26
+ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
27
+ import { pathExists, readText } from '../utils/fs.js';
28
+ import { info, intro, outro, success, warn } from '../utils/logger.js';
29
+ import { addWaitLockOption, resolveWaitLockSeconds } from '../utils/options.js';
30
+ import { applyHunksByContext } from './re-export-hunks.js';
31
+ import { normalizeEngineRelativeInput } from './re-export-scan.js';
32
+ /** The patch-applied baseline of each of `patch`'s files (null = absent). */
33
+ async function baselinesOf(paths, patch) {
34
+ const { computePatched } = await createPatchedContentContext(paths.patches, paths.engine);
35
+ const deleted = new Set(parseDiffSections(await readText(join(paths.patches, patch.filename)))
36
+ .filter((section) => section.isDeletedFile)
37
+ .map((section) => section.targetPath));
38
+ const baselines = new Map();
39
+ for (const file of patch.filesAffected) {
40
+ baselines.set(file, deleted.has(file) ? null : await computePatched(file));
41
+ }
42
+ return baselines;
43
+ }
44
+ /** The files a park would change, with both sides. */
45
+ async function planPark(paths, patch, hunksPath) {
46
+ if (hunksPath === undefined) {
47
+ const baselines = await baselinesOf(paths, patch);
48
+ const files = [];
49
+ for (const [path, after] of baselines) {
50
+ const before = await readEngineFile(paths.engine, path);
51
+ if (before !== after)
52
+ files.push({ path, before, after });
53
+ }
54
+ return files;
55
+ }
56
+ if (!(await pathExists(hunksPath))) {
57
+ throw new InvalidArgumentError(`--hunks ${hunksPath} does not exist.`, '--hunks');
58
+ }
59
+ const owned = new Set(patch.filesAffected);
60
+ const files = [];
61
+ for (const section of parseDiffSections(await readText(hunksPath))) {
62
+ const path = normalizeEngineRelativeInput(section.targetPath, '--hunks');
63
+ if (!owned.has(path)) {
64
+ throw new InvalidArgumentError(`--hunks names ${path}, which ${patch.filename} does not own.`, '--hunks');
65
+ }
66
+ const before = await readEngineFile(paths.engine, path);
67
+ const after = applyHunksByContext(before ?? '', section.hunks, true, path);
68
+ if (before !== after)
69
+ files.push({ path, before, after });
70
+ }
71
+ return files;
72
+ }
73
+ /**
74
+ * Referrers outside the patch of what the park takes away: Fluent ids its
75
+ * removed lines declare, and files it removes outright.
76
+ */
77
+ async function findOutsideReferrers(paths, manifest, patch, files) {
78
+ const needles = new Map();
79
+ for (const file of files) {
80
+ if (file.path.endsWith('.ftl')) {
81
+ for (const id of fluentIds(removedLines(file.before, file.after))) {
82
+ needles.set(id, `Fluent id "${id}" (${file.path})`);
83
+ }
84
+ }
85
+ if (file.before !== null && file.after === null) {
86
+ needles.set(basename(file.path), `file ${file.path}`);
87
+ }
88
+ }
89
+ if (needles.size === 0)
90
+ return [];
91
+ const own = new Set(patch.filesAffected);
92
+ const outside = [...new Set(manifest.patches.flatMap((entry) => entry.filesAffected))].filter((file) => !own.has(file));
93
+ const findings = [];
94
+ for (const file of outside) {
95
+ const content = await readEngineFile(paths.engine, file);
96
+ if (content === null)
97
+ continue;
98
+ for (const [needle, label] of needles) {
99
+ if (content.includes(needle))
100
+ findings.push(`${label} is still referenced by ${file}`);
101
+ }
102
+ }
103
+ return findings;
104
+ }
105
+ async function runPark(projectRoot, identifier, options) {
106
+ const paths = getProjectPaths(projectRoot);
107
+ const manifest = await loadPatchesManifest(paths.patches);
108
+ if (!manifest)
109
+ throw new GeneralError('No patches/patches.json; nothing to park against.');
110
+ const patch = resolvePatchIdentifier(identifier, manifest.patches);
111
+ if (!patch) {
112
+ throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), 'park');
113
+ }
114
+ const files = await planPark(paths, patch, options.hunks);
115
+ if (files.length === 0) {
116
+ info(`${patch.filename}: nothing to park; its files match ${options.hunks ?? 'the baseline'}.`);
117
+ return;
118
+ }
119
+ for (const finding of await findOutsideReferrers(paths, manifest, patch, files)) {
120
+ warn(`Parking removes what is still in use: ${finding}. Move it somewhere you own first.`);
121
+ }
122
+ const label = options.label ?? `${patch.filename.replace(/\.patch$/, '')}-${Date.now().toString(36)}`;
123
+ for (const file of files)
124
+ info(` ${file.path}`);
125
+ if (options.dryRun === true) {
126
+ info(`[dry-run] would park ${files.length} file(s) of ${patch.filename} as "${label}".`);
127
+ return;
128
+ }
129
+ const dir = await writePark(projectRoot, {
130
+ label,
131
+ patch: patch.filename,
132
+ createdAt: new Date().toISOString(),
133
+ mode: options.hunks === undefined ? 'baseline' : 'hunks',
134
+ files,
135
+ });
136
+ for (const file of files)
137
+ await writeEngineFile(paths.engine, file.path, file.after);
138
+ await appendHistoryBestEffort(paths.patches, {
139
+ operation: 'park',
140
+ args: { label, patch: patch.filename, files: files.map((file) => file.path) },
141
+ result: 'ok',
142
+ }, `park ${label}`);
143
+ success(`Parked ${files.length} file(s) of ${patch.filename} as "${label}" (${dir}).`);
144
+ info(`Restore with: fireforge park --restore ${label}`);
145
+ }
146
+ async function runRestore(projectRoot, label, dryRun) {
147
+ const paths = getProjectPaths(projectRoot);
148
+ const park = await readPark(projectRoot, label);
149
+ const conflicts = [];
150
+ const plans = [];
151
+ for (const file of park.files) {
152
+ const now = await readEngineFile(paths.engine, file.path);
153
+ const plan = await planRestore(file, now);
154
+ plans.push({ file, ...plan });
155
+ if (plan.outcome === 'conflict')
156
+ conflicts.push(file.path);
157
+ }
158
+ for (const { file, outcome } of plans)
159
+ info(` ${file.path}: ${outcome}`);
160
+ if (dryRun) {
161
+ info(`[dry-run] restoring "${label}" would leave ${conflicts.length} conflict(s).`);
162
+ return;
163
+ }
164
+ for (const { file, content, outcome } of plans) {
165
+ if (outcome !== 'unchanged')
166
+ await writeEngineFile(paths.engine, file.path, content);
167
+ }
168
+ await appendHistoryBestEffort(paths.patches, {
169
+ operation: 'park-restore',
170
+ args: { label, patch: park.patch, conflicts },
171
+ result: conflicts.length === 0 ? 'ok' : 'conflict',
172
+ }, `park --restore ${label}`);
173
+ if (conflicts.length > 0) {
174
+ throw new GeneralError(`Restoring "${label}" left conflict markers in ${conflicts.join(', ')}. Resolve them, ` +
175
+ `then drop the park with "fireforge park --drop ${label}".`);
176
+ }
177
+ await dropPark(projectRoot, label);
178
+ success(`Restored "${label}" into ${park.files.length} file(s) of ${park.patch}.`);
179
+ }
180
+ /**
181
+ * Runs `fireforge park`.
182
+ *
183
+ * @param projectRoot - Project root
184
+ * @param identifier - The patch to park (omitted for --restore/--list/--drop)
185
+ * @param options - See {@link ParkOptions}
186
+ */
187
+ export async function parkCommand(projectRoot, identifier, options) {
188
+ intro('FireForge park');
189
+ const modes = [identifier !== undefined, options.restore, options.list, options.drop].filter((mode) => mode !== undefined && mode !== false);
190
+ if (modes.length !== 1) {
191
+ throw new InvalidArgumentError('Pass exactly one of: a patch to park, --restore <label>, --list or --drop <label>.', 'park');
192
+ }
193
+ if (options.list === true) {
194
+ const parks = await listParks(projectRoot);
195
+ if (parks.length === 0)
196
+ info('No parks.');
197
+ for (const park of parks) {
198
+ info(`${park.label} ${park.patch} ${park.files.length} file(s) ${park.createdAt}`);
199
+ }
200
+ }
201
+ else if (options.drop !== undefined) {
202
+ await dropPark(projectRoot, options.drop);
203
+ success(`Dropped park "${options.drop}".`);
204
+ }
205
+ else if (options.restore !== undefined) {
206
+ await runRestore(projectRoot, options.restore, options.dryRun === true);
207
+ }
208
+ else if (identifier !== undefined) {
209
+ await runPark(projectRoot, identifier, options);
210
+ }
211
+ outro('Park complete');
212
+ }
213
+ /** Registers `fireforge park`. */
214
+ export function registerPark(program, { getProjectRoot, withErrorHandling }) {
215
+ const command = program
216
+ .command('park [patch]')
217
+ .description("Set a session's edits in a patch's files aside (back to the queue-applied baseline, or minus the hunks a diff names) and three-way merge them back later")
218
+ .option('--label <label>', 'Name for the park (default: <patch>-<time>)')
219
+ .option('--hunks <diff>', 'Park only the hunks this diff names, not every drifted line')
220
+ .option('--restore <label>', 'Three-way merge a park back into the engine, then drop it')
221
+ .option('--list', 'List the parks')
222
+ .option('--drop <label>', 'Delete a park without restoring it')
223
+ .option('--dry-run', 'Show what would be parked or restored');
224
+ addWaitLockOption(command).action(withErrorHandling(async (patch, options) => {
225
+ const projectRoot = getProjectRoot();
226
+ if (options.list === true || options.drop !== undefined || options.dryRun === true) {
227
+ await parkCommand(projectRoot, patch, options);
228
+ return;
229
+ }
230
+ await withEngineSessionLock(projectRoot, 'park', () => parkCommand(projectRoot, patch, options), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
231
+ }));
232
+ }
@@ -20,6 +20,7 @@ import { addWaitLockOption, pickDefined, resolveWaitLockSeconds } from '../../ut
20
20
  import { proceedAfterDecision } from '../destructive-decision.js';
21
21
  import { describeProjectedGaps } from './delete-gap-notice.js';
22
22
  import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
23
+ import { deleteCreatedFiles, planClaimedFiles, withCreatedFileLock, } from './removal-created-files.js';
23
24
  import { collectRemovalDependents, queueSnapshot, removalConflict, warnDanglingStagedOwners, } from './removal-dependents.js';
24
25
  /**
25
26
  * Runs the `patch delete` command: removes a patch file and its manifest
@@ -55,14 +56,12 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
55
56
  warn(line);
56
57
  }
57
58
  const conflicts = removalConflict(dependents, target);
59
+ const claimed = await planClaimedFiles(baseCtx, target, paths.engine, options.deleteCreated === true);
58
60
  const summary = [
59
61
  `delete ${target.filename} (category: ${target.category}, order: ${target.order})`,
60
62
  `description: ${target.description || '(none)'}`,
61
- `files currently claimed by this patch (${target.filesAffected.length}):`,
63
+ ...claimed.summary,
62
64
  ];
63
- for (const file of target.filesAffected) {
64
- summary.push(` ${file} → will become unmanaged`);
65
- }
66
65
  summary.push(...gapNotices);
67
66
  const decision = await confirmDestructive({
68
67
  operation: 'patch-delete',
@@ -83,7 +82,8 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
83
82
  // mutation with no audit trail. A history append failure is warned but not
84
83
  // re-thrown: by that point the mutation has committed, and reporting
85
84
  // failure to the caller would mislead.
86
- await withPatchDirectoryLock(paths.patches, async () => {
85
+ const waitLockSeconds = resolveWaitLockSeconds(options.waitLock);
86
+ await withCreatedFileLock(projectRoot, 'patch delete', claimed, waitLockSeconds, () => withPatchDirectoryLock(paths.patches, async () => {
87
87
  const current = await buildPatchQueueContext(paths.patches);
88
88
  // A changed body or manifest invalidates the dependency scan approved
89
89
  // before the prompt. Recheck all of its inputs while holding the lock.
@@ -91,18 +91,22 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
91
91
  throw new GeneralError('Patch queue changed while waiting for confirmation or the lock. Re-run patch delete to review the current dependencies.');
92
92
  }
93
93
  await removePatchFileAndManifest(paths.patches, target.filename);
94
+ await deleteCreatedFiles(paths.engine, claimed.deleteOnCommit);
94
95
  await appendHistoryBestEffort(paths.patches, {
95
96
  operation: 'patch-delete',
96
97
  args: {
97
98
  filename: target.filename,
98
99
  order: target.order,
99
100
  filesAffected: target.filesAffected,
101
+ ...(claimed.deleteOnCommit.length > 0
102
+ ? { deletedCreatedFiles: claimed.deleteOnCommit }
103
+ : {}),
100
104
  },
101
105
  ...(options.yes === true ? { yes: true } : {}),
102
106
  ...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
103
107
  result: 'ok',
104
108
  }, `patch delete committed (${target.filename})`);
105
- }, { waitLockSeconds: resolveWaitLockSeconds(options.waitLock), command: 'patch delete' });
109
+ }, { waitLockSeconds, command: 'patch delete' }));
106
110
  info(`Deleted ${target.filename}.`);
107
111
  outro('Delete complete');
108
112
  }
@@ -119,6 +123,7 @@ export function registerPatchDelete(parent, context) {
119
123
  .description('Delete a patch from the queue (destructive)')
120
124
  .option('--dry-run', 'Show what would happen without writing')
121
125
  .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
126
+ .option('--delete-created', 'Also delete the engine files this patch creates that are still on disk, instead of leaving them unmanaged')
122
127
  .option('--force-unsafe', 'Bypass the refusal when a later patch depends on this patch (last resort)');
123
128
  addWaitLockOption(command).action(withErrorHandling(async (name, options) => {
124
129
  await patchDeleteCommand(getProjectRoot(), name, pickDefined(options));
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * `fireforge patch <verb>` parent command. Groups single-patch mutations and
3
3
  * planners (`compact`, `delete`, `lint-ignore`, `move-files`, `rename`,
4
- * `reorder`, `retire`, `staged-dependency`, `tier`) so they do not clutter the
5
- * top-level command list. Queue-level verbs like `lint`, `export`, `verify`,
6
- * and `status` stay flat.
4
+ * `reorder`, `reserve`, `retire`, `split`, `stage`, `staged-dependency`,
5
+ * `tier`) so they do not clutter the top-level command list. Queue-level
6
+ * verbs like `lint`, `export`, `verify`, and `status` stay flat.
7
7
  */
8
8
  import { Command } from 'commander';
9
9
  import type { CommandContext } from '../../types/cli.js';
@@ -2,9 +2,9 @@
2
2
  /**
3
3
  * `fireforge patch <verb>` parent command. Groups single-patch mutations and
4
4
  * planners (`compact`, `delete`, `lint-ignore`, `move-files`, `rename`,
5
- * `reorder`, `retire`, `staged-dependency`, `tier`) so they do not clutter the
6
- * top-level command list. Queue-level verbs like `lint`, `export`, `verify`,
7
- * and `status` stay flat.
5
+ * `reorder`, `reserve`, `retire`, `split`, `stage`, `staged-dependency`,
6
+ * `tier`) so they do not clutter the top-level command list. Queue-level
7
+ * verbs like `lint`, `export`, `verify`, and `status` stay flat.
8
8
  */
9
9
  import { Command } from 'commander';
10
10
  import { registerPatchCompact } from './compact.js';
@@ -13,8 +13,10 @@ import { registerPatchLintIgnore } from './lint-ignore.js';
13
13
  import { registerPatchMoveFiles } from './move-files.js';
14
14
  import { registerPatchRename } from './rename.js';
15
15
  import { registerPatchReorder } from './reorder.js';
16
+ import { registerPatchReserve } from './reserve.js';
16
17
  import { registerPatchRetire } from './retire.js';
17
18
  import { registerPatchSplit } from './split.js';
19
+ import { registerPatchStage } from './stage.js';
18
20
  import { registerPatchStagedDependency } from './staged-dependency.js';
19
21
  import { registerPatchTier } from './tier.js';
20
22
  /**
@@ -26,7 +28,7 @@ import { registerPatchTier } from './tier.js';
26
28
  export function registerPatch(program, context) {
27
29
  const patch = program
28
30
  .command('patch')
29
- .description('Manage individual patches in the queue (compact, delete, lint-ignore, move-files, rename, reorder, retire, staged-dependency, tier)')
31
+ .description('Manage individual patches in the queue (compact, delete, lint-ignore, move-files, rename, reorder, reserve, retire, split, stage, staged-dependency, tier)')
30
32
  // Match `fireforge furnace`'s no-args contract: print the group's help
31
33
  // and exit 0. Without this default action, commander routes
32
34
  // `fireforge patch` (no subcommand) through its own help-then-exit-1
@@ -41,8 +43,10 @@ export function registerPatch(program, context) {
41
43
  registerPatchMoveFiles(patch, context);
42
44
  registerPatchRename(patch, context);
43
45
  registerPatchReorder(patch, context);
46
+ registerPatchReserve(patch, context);
44
47
  registerPatchRetire(patch, context);
45
48
  registerPatchSplit(patch, context);
49
+ registerPatchStage(patch, context);
46
50
  registerPatchStagedDependency(patch, context);
47
51
  registerPatchTier(patch, context);
48
52
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Batch and per-entry input for `patch lint-ignore --add`. Split out of
3
+ * `lint-ignore.ts` for the per-file line budget.
4
+ *
5
+ * A rebaseline that sets a ceiling per rule used to take one call per rule,
6
+ * each taking the patch lock and rewriting `patches.json` (138 calls on one
7
+ * consumer's queue), because `--max` applied to every `--add` of a call.
8
+ * Two forms lift that:
9
+ *
10
+ * - `--add <rule>=<max>` gives each entry its own ceiling in one call.
11
+ * - `--from <json-file>` reads every entry from a file, under one lock hold
12
+ * and one write. The file is a JSON array of entries (a check ID string,
13
+ * or `{ rule, max, reason?, share?, meta?, patch? }`), or a
14
+ * `lint --per-patch --report` document, whose `lintIgnoreCeilings` rows
15
+ * for the target patch are rebaselined to their current measurement.
16
+ */
17
+ import type { LintIgnoreEntry, PatchLintIgnoreOptions, PatchSizeShare } from '../../types/commands/index.js';
18
+ /**
19
+ * Turns `--add` values into entries: `<rule>=<max>` is a ceiling at `max`,
20
+ * a bare ID is a ceiling at `--max` when that is given and a bare waiver
21
+ * otherwise. Every ceiling takes `--reason` (required) and `--share`.
22
+ *
23
+ * @param ids - The `--add` values
24
+ * @param options - Command options (`max`, `reason`, `share`)
25
+ * @returns The entries to upsert
26
+ */
27
+ export declare function resolveAddEntries(ids: readonly string[], options: Pick<PatchLintIgnoreOptions, 'max' | 'reason' | 'share'>): LintIgnoreEntry[];
28
+ /** Entries read from a `--from` file for one target patch. */
29
+ export interface LintIgnoreBatch {
30
+ entries: LintIgnoreEntry[];
31
+ /** Entries naming a different patch, left out. */
32
+ skippedForOtherPatches: number;
33
+ }
34
+ /**
35
+ * Reads a `--from` file into entries for `targetFilename`.
36
+ *
37
+ * @param path - JSON file path
38
+ * @param targetFilename - The patch the command edits
39
+ * @param defaultReason - `--reason`, used for object entries without one
40
+ * @returns The validated entries and the count left out for other patches
41
+ */
42
+ export declare function readLintIgnoreBatch(path: string, targetFilename: string, defaultReason: string | undefined): Promise<LintIgnoreBatch>;
43
+ /** Parses `--share`. */
44
+ export declare function parseShare(value: string): PatchSizeShare;
@@ -0,0 +1,142 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Batch and per-entry input for `patch lint-ignore --add`. Split out of
4
+ * `lint-ignore.ts` for the per-file line budget.
5
+ *
6
+ * A rebaseline that sets a ceiling per rule used to take one call per rule,
7
+ * each taking the patch lock and rewriting `patches.json` (138 calls on one
8
+ * consumer's queue), because `--max` applied to every `--add` of a call.
9
+ * Two forms lift that:
10
+ *
11
+ * - `--add <rule>=<max>` gives each entry its own ceiling in one call.
12
+ * - `--from <json-file>` reads every entry from a file, under one lock hold
13
+ * and one write. The file is a JSON array of entries (a check ID string,
14
+ * or `{ rule, max, reason?, share?, meta?, patch? }`), or a
15
+ * `lint --per-patch --report` document, whose `lintIgnoreCeilings` rows
16
+ * for the target patch are rebaselined to their current measurement.
17
+ */
18
+ import { LINT_IGNORE_CEILING_RULES, parseLintIgnoreEntries } from '../../core/patch-lint-ignore.js';
19
+ import { InvalidArgumentError } from '../../errors/base.js';
20
+ import { toError } from '../../utils/errors.js';
21
+ import { readJson } from '../../utils/fs.js';
22
+ import { isArray, isObject } from '../../utils/validation.js';
23
+ const RULE_MAX_PATTERN = /^(?<rule>[^=\s]+)=(?<max>[1-9]\d*)$/;
24
+ /**
25
+ * Turns `--add` values into entries: `<rule>=<max>` is a ceiling at `max`,
26
+ * a bare ID is a ceiling at `--max` when that is given and a bare waiver
27
+ * otherwise. Every ceiling takes `--reason` (required) and `--share`.
28
+ *
29
+ * @param ids - The `--add` values
30
+ * @param options - Command options (`max`, `reason`, `share`)
31
+ * @returns The entries to upsert
32
+ */
33
+ export function resolveAddEntries(ids, options) {
34
+ const parsed = ids.map((id) => {
35
+ if (id.includes('=')) {
36
+ const match = RULE_MAX_PATTERN.exec(id);
37
+ if (match?.groups === undefined) {
38
+ throw new InvalidArgumentError(`--add ${id}: expected <rule>=<max> with a positive integer max.`, '--add');
39
+ }
40
+ return { rule: match.groups['rule'], max: Number(match.groups['max']) };
41
+ }
42
+ return { rule: id, max: options.max };
43
+ });
44
+ const ceilings = parsed.filter((entry) => entry.max !== undefined);
45
+ if (ceilings.length === 0) {
46
+ if (options.reason !== undefined || options.share !== undefined) {
47
+ throw new InvalidArgumentError('--reason and --share apply to ceilings: pass --max <n> or --add <rule>=<max>.', options.reason !== undefined ? '--reason' : '--share');
48
+ }
49
+ return parsed.map((entry) => entry.rule);
50
+ }
51
+ if (options.reason === undefined || options.reason.trim() === '') {
52
+ throw new InvalidArgumentError('A lintIgnore ceiling needs --reason <text> (with --max <n> or --add <rule>=<max>).', '--reason');
53
+ }
54
+ const unmeasured = ceilings.filter((entry) => !LINT_IGNORE_CEILING_RULES.includes(entry.rule));
55
+ if (unmeasured.length > 0) {
56
+ throw new InvalidArgumentError(`A ceiling only applies to the measured size rules (${LINT_IGNORE_CEILING_RULES.join(', ')}); ` +
57
+ `got ${unmeasured.map((entry) => entry.rule).join(', ')}.`, '--max');
58
+ }
59
+ const { reason, share } = options;
60
+ return validated(parsed.map((entry) => entry.max === undefined
61
+ ? entry.rule
62
+ : { rule: entry.rule, max: entry.max, reason, ...(share !== undefined ? { share } : {}) }), '--add');
63
+ }
64
+ /** Runs the manifest validator over CLI-built entries, as an argument error. */
65
+ function validated(entries, flag) {
66
+ try {
67
+ return parseLintIgnoreEntries(entries, flag);
68
+ }
69
+ catch (error) {
70
+ throw new InvalidArgumentError(toError(error).message, flag);
71
+ }
72
+ }
73
+ /** The target patch's ceiling rows of a lint report, rebaselined to their measurement. */
74
+ function rebaselineFromReport(data, targetFilename) {
75
+ const patches = isArray(data['patches']) ? data['patches'] : [];
76
+ const row = patches.find((patch) => isObject(patch) && patch['filename'] === targetFilename);
77
+ const ceilings = isObject(row) && isArray(row['lintIgnoreCeilings']) ? row['lintIgnoreCeilings'] : [];
78
+ const raw = [];
79
+ for (const ceiling of ceilings) {
80
+ if (!isObject(ceiling) || typeof ceiling['measurement'] !== 'number')
81
+ continue;
82
+ if (ceiling['measurement'] <= 0)
83
+ continue;
84
+ const { measurement, rule, reason, share, meta } = ceiling;
85
+ raw.push({
86
+ rule,
87
+ max: measurement,
88
+ reason,
89
+ ...(share !== undefined ? { share } : {}),
90
+ ...(meta !== undefined ? { meta } : {}),
91
+ });
92
+ }
93
+ return { raw, skipped: patches.length - (row === undefined ? 0 : 1) };
94
+ }
95
+ /**
96
+ * Reads a `--from` file into entries for `targetFilename`.
97
+ *
98
+ * @param path - JSON file path
99
+ * @param targetFilename - The patch the command edits
100
+ * @param defaultReason - `--reason`, used for object entries without one
101
+ * @returns The validated entries and the count left out for other patches
102
+ */
103
+ export async function readLintIgnoreBatch(path, targetFilename, defaultReason) {
104
+ let data;
105
+ try {
106
+ data = await readJson(path);
107
+ }
108
+ catch (error) {
109
+ throw new InvalidArgumentError(`Cannot read --from ${path}: ${toError(error).message}`, '--from');
110
+ }
111
+ if (isObject(data)) {
112
+ const { raw, skipped } = rebaselineFromReport(data, targetFilename);
113
+ return { entries: validated(raw, '--from'), skippedForOtherPatches: skipped };
114
+ }
115
+ if (!isArray(data)) {
116
+ throw new InvalidArgumentError('--from must hold a JSON array of lintIgnore entries or a "lint --per-patch --report" document.', '--from');
117
+ }
118
+ const raw = [];
119
+ let skipped = 0;
120
+ for (const entry of data) {
121
+ if (!isObject(entry)) {
122
+ raw.push(entry);
123
+ continue;
124
+ }
125
+ const { patch, ...rest } = entry;
126
+ if (patch !== undefined && patch !== targetFilename) {
127
+ skipped++;
128
+ continue;
129
+ }
130
+ raw.push(rest['reason'] === undefined && defaultReason !== undefined
131
+ ? { ...rest, reason: defaultReason }
132
+ : rest);
133
+ }
134
+ return { entries: validated(raw, '--from'), skippedForOtherPatches: skipped };
135
+ }
136
+ /** Parses `--share`. */
137
+ export function parseShare(value) {
138
+ if (value !== 'test' && value !== 'product') {
139
+ throw new InvalidArgumentError(`--share must be "test" or "product" (got "${value}").`, '--share');
140
+ }
141
+ return value;
142
+ }