@hominis/fireforge 0.30.1 → 0.32.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 (152) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +22 -0
  3. package/dist/src/commands/export-all.js +9 -16
  4. package/dist/src/commands/export-flow.d.ts +6 -0
  5. package/dist/src/commands/export-flow.js +6 -1
  6. package/dist/src/commands/export-placement-gate.d.ts +38 -0
  7. package/dist/src/commands/export-placement-gate.js +105 -0
  8. package/dist/src/commands/export-shared.d.ts +28 -0
  9. package/dist/src/commands/export-shared.js +46 -1
  10. package/dist/src/commands/export.js +52 -113
  11. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +0 -13
  12. package/dist/src/commands/furnace/chrome-doc-templates.js +1 -1
  13. package/dist/src/commands/furnace/create-dry-run.d.ts +1 -1
  14. package/dist/src/commands/furnace/create.d.ts +1 -2
  15. package/dist/src/commands/furnace/deploy.js +36 -114
  16. package/dist/src/commands/furnace/refresh.js +52 -32
  17. package/dist/src/commands/furnace/sync.js +2 -0
  18. package/dist/src/commands/import.js +108 -73
  19. package/dist/src/commands/lint-per-patch.d.ts +3 -1
  20. package/dist/src/commands/lint-per-patch.js +265 -74
  21. package/dist/src/commands/lint.d.ts +1 -58
  22. package/dist/src/commands/lint.js +193 -88
  23. package/dist/src/commands/patch/compact.d.ts +5 -2
  24. package/dist/src/commands/patch/compact.js +85 -25
  25. package/dist/src/commands/patch/delete.js +17 -17
  26. package/dist/src/commands/patch/index.js +2 -0
  27. package/dist/src/commands/patch/lint-ignore.js +3 -16
  28. package/dist/src/commands/patch/move-files.js +2 -0
  29. package/dist/src/commands/patch/patch-context.d.ts +41 -0
  30. package/dist/src/commands/patch/patch-context.js +53 -0
  31. package/dist/src/commands/patch/rename.js +10 -15
  32. package/dist/src/commands/patch/reorder.d.ts +0 -2
  33. package/dist/src/commands/patch/reorder.js +18 -19
  34. package/dist/src/commands/patch/split-plan.d.ts +66 -0
  35. package/dist/src/commands/patch/split-plan.js +178 -0
  36. package/dist/src/commands/patch/split.d.ts +30 -0
  37. package/dist/src/commands/patch/split.js +283 -0
  38. package/dist/src/commands/patch/staged-dependency.d.ts +1 -7
  39. package/dist/src/commands/patch/staged-dependency.js +4 -17
  40. package/dist/src/commands/patch/tier.js +4 -17
  41. package/dist/src/commands/re-export-files.js +4 -1
  42. package/dist/src/commands/re-export-scan.js +8 -1
  43. package/dist/src/commands/re-export.js +8 -1
  44. package/dist/src/commands/rebase/summary.d.ts +1 -5
  45. package/dist/src/commands/rebase/summary.js +1 -1
  46. package/dist/src/commands/status-output.js +77 -68
  47. package/dist/src/commands/test-diagnose.d.ts +23 -0
  48. package/dist/src/commands/test-diagnose.js +210 -0
  49. package/dist/src/commands/test-run.d.ts +68 -0
  50. package/dist/src/commands/test-run.js +97 -0
  51. package/dist/src/commands/test.js +214 -263
  52. package/dist/src/commands/token.js +15 -1
  53. package/dist/src/commands/wire.js +109 -78
  54. package/dist/src/core/build-audit.d.ts +1 -1
  55. package/dist/src/core/build-audit.js +2 -46
  56. package/dist/src/core/build-baseline-types.d.ts +38 -0
  57. package/dist/src/core/build-baseline-types.js +10 -0
  58. package/dist/src/core/build-baseline.d.ts +1 -31
  59. package/dist/src/core/build-prepare.d.ts +1 -1
  60. package/dist/src/core/build-prepare.js +2 -45
  61. package/dist/src/core/config-paths.d.ts +0 -8
  62. package/dist/src/core/config-paths.js +4 -4
  63. package/dist/src/core/config-state.d.ts +0 -6
  64. package/dist/src/core/config-state.js +1 -1
  65. package/dist/src/core/config-validate-patch-policy.js +12 -13
  66. package/dist/src/core/config-validate.js +74 -28
  67. package/dist/src/core/engine-changes.d.ts +24 -0
  68. package/dist/src/core/engine-changes.js +64 -0
  69. package/dist/src/core/firefox-cache.d.ts +0 -5
  70. package/dist/src/core/firefox-cache.js +1 -1
  71. package/dist/src/core/firefox-download.d.ts +0 -6
  72. package/dist/src/core/firefox-download.js +1 -1
  73. package/dist/src/core/furnace-apply-helpers.d.ts +1 -8
  74. package/dist/src/core/furnace-apply-helpers.js +11 -20
  75. package/dist/src/core/furnace-apply.d.ts +1 -1
  76. package/dist/src/core/furnace-apply.js +1 -1
  77. package/dist/src/core/furnace-checksum-utils.d.ts +7 -0
  78. package/dist/src/core/furnace-checksum-utils.js +15 -0
  79. package/dist/src/core/furnace-config-validate.d.ts +31 -0
  80. package/dist/src/core/furnace-config-validate.js +133 -0
  81. package/dist/src/core/furnace-config.d.ts +4 -32
  82. package/dist/src/core/furnace-config.js +15 -111
  83. package/dist/src/core/furnace-constants.d.ts +0 -10
  84. package/dist/src/core/furnace-constants.js +2 -2
  85. package/dist/src/core/furnace-css-fragments.d.ts +79 -0
  86. package/dist/src/core/furnace-css-fragments.js +243 -0
  87. package/dist/src/core/furnace-jsconfig.d.ts +63 -0
  88. package/dist/src/core/furnace-jsconfig.js +191 -0
  89. package/dist/src/core/furnace-validate-helpers.d.ts +16 -14
  90. package/dist/src/core/furnace-validate-helpers.js +40 -1
  91. package/dist/src/core/furnace-validate-registration.js +16 -1
  92. package/dist/src/core/furnace-validate.js +54 -2
  93. package/dist/src/core/git-base.d.ts +15 -0
  94. package/dist/src/core/git-base.js +32 -0
  95. package/dist/src/core/git-diff.d.ts +8 -0
  96. package/dist/src/core/git-diff.js +224 -59
  97. package/dist/src/core/git-file-ops.d.ts +39 -12
  98. package/dist/src/core/git-file-ops.js +84 -3
  99. package/dist/src/core/lint-cache.d.ts +0 -13
  100. package/dist/src/core/lint-cache.js +5 -5
  101. package/dist/src/core/mach.d.ts +22 -1
  102. package/dist/src/core/mach.js +27 -2
  103. package/dist/src/core/manifest-register.d.ts +5 -16
  104. package/dist/src/core/manifest-register.js +3 -1
  105. package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
  106. package/dist/src/core/patch-lint-checkjs.js +263 -71
  107. package/dist/src/core/patch-lint-css.d.ts +23 -0
  108. package/dist/src/core/patch-lint-css.js +172 -0
  109. package/dist/src/core/patch-lint-jsdoc.js +63 -4
  110. package/dist/src/core/patch-lint-observer.d.ts +37 -0
  111. package/dist/src/core/patch-lint-observer.js +168 -0
  112. package/dist/src/core/patch-lint.d.ts +34 -11
  113. package/dist/src/core/patch-lint.js +24 -161
  114. package/dist/src/core/patch-manifest-io.d.ts +16 -0
  115. package/dist/src/core/patch-manifest-io.js +44 -2
  116. package/dist/src/core/patch-manifest-validate.d.ts +1 -8
  117. package/dist/src/core/patch-manifest-validate.js +1 -1
  118. package/dist/src/core/patch-manifest.d.ts +1 -1
  119. package/dist/src/core/patch-manifest.js +1 -1
  120. package/dist/src/core/patch-policy.d.ts +0 -4
  121. package/dist/src/core/patch-policy.js +10 -4
  122. package/dist/src/core/register-browser-content.d.ts +1 -1
  123. package/dist/src/core/register-module.d.ts +1 -1
  124. package/dist/src/core/register-result.d.ts +21 -0
  125. package/dist/src/core/register-result.js +9 -0
  126. package/dist/src/core/register-shared-css.d.ts +1 -1
  127. package/dist/src/core/register-test-manifest.d.ts +1 -1
  128. package/dist/src/core/test-harness-crash.d.ts +61 -0
  129. package/dist/src/core/test-harness-crash.js +140 -0
  130. package/dist/src/core/test-stale-check.d.ts +1 -1
  131. package/dist/src/core/test-stale-check.js +2 -46
  132. package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
  133. package/dist/src/core/test-xpcshell-retry.js +10 -3
  134. package/dist/src/core/token-dark-mode.js +14 -26
  135. package/dist/src/core/token-manager.d.ts +4 -0
  136. package/dist/src/core/token-manager.js +70 -16
  137. package/dist/src/core/typecheck-shim.d.ts +3 -22
  138. package/dist/src/core/typecheck-shim.js +69 -7
  139. package/dist/src/core/wire-utils.js +37 -44
  140. package/dist/src/types/commands/index.d.ts +1 -1
  141. package/dist/src/types/commands/options.d.ts +122 -0
  142. package/dist/src/types/config.d.ts +11 -2
  143. package/dist/src/types/furnace.d.ts +12 -1
  144. package/dist/src/utils/elapsed.d.ts +0 -2
  145. package/dist/src/utils/elapsed.js +1 -1
  146. package/dist/src/utils/fs.d.ts +0 -5
  147. package/dist/src/utils/fs.js +1 -1
  148. package/dist/src/utils/regex.d.ts +0 -6
  149. package/dist/src/utils/regex.js +3 -3
  150. package/dist/src/utils/validation.d.ts +0 -8
  151. package/dist/src/utils/validation.js +2 -2
  152. package/package.json +6 -4
@@ -0,0 +1,283 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge patch split <source> --files <p...> --name <n>` — moves files
4
+ * out of an existing patch into a brand-new patch as one transaction
5
+ * (field report A4).
6
+ *
7
+ * Before this command, splitting a patch whose files have inbound
8
+ * forward-imports required a precise manual order: re-point each
9
+ * staged-dependency owner at a not-yet-created patch, shrink the source
10
+ * via `re-export --files --allow-shrink`, then `export --order` — any
11
+ * other order refused or needed `--force-unsafe`. Split performs the
12
+ * shrink, the new-patch creation, and the dependent owner rewrites under
13
+ * one patch-directory lock with rollback, validating only the final
14
+ * projection.
15
+ *
16
+ * Preconditions match `re-export`: the engine worktree must currently
17
+ * reflect both patches' content, because both bodies are regenerated from
18
+ * the worktree.
19
+ */
20
+ import { join } from 'node:path';
21
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
22
+ import { appendHistory, confirmDestructive } from '../../core/destructive.js';
23
+ import { normalizePatchArtifact } from '../../core/patch-artifact-normalize.js';
24
+ import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
25
+ import { withPatchDirectoryLock } from '../../core/patch-lock.js';
26
+ import { loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, savePatchesManifest, validatePatchesManifest, } from '../../core/patch-manifest.js';
27
+ import { enforcePatchPolicy } from '../../core/patch-policy.js';
28
+ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
29
+ import { toError } from '../../utils/errors.js';
30
+ import { readText, removeFile, writeText } from '../../utils/fs.js';
31
+ import { info, intro, outro, success, warn } from '../../utils/logger.js';
32
+ import { pickDefined } from '../../utils/options.js';
33
+ import { placementPlansEqual, resolvePlacementPlan } from '../export-flow.js';
34
+ import { runPatchLint } from '../export-shared.js';
35
+ import { assertSourceOwnsFiles, buildNewPatchMetadata, buildSplitDiff, buildSplitSummary, findOwnerRewriteHolders, projectSplitManifest, rewriteSplitOwners, runProjectedSplitLint, } from './split-plan.js';
36
+ /**
37
+ * Commits a confirmed split under the patch directory lock: renumber →
38
+ * write new patch body → write shrunken source body → single manifest
39
+ * rewrite (new row + shrunken source row + owner rewrites). On any
40
+ * failure the steps are rolled back in reverse order.
41
+ */
42
+ async function commitPatchSplit(patchesDir, plan, newMetadata, options) {
43
+ await withPatchDirectoryLock(patchesDir, async () => {
44
+ const manifest = await loadPatchesManifest(patchesDir);
45
+ if (!manifest)
46
+ throw new GeneralError('Manifest disappeared while waiting for lock.');
47
+ const current = manifest.patches.find((p) => p.filename === plan.source.filename);
48
+ if (!current || current.filesAffected.join('\n') !== plan.source.filesAffected.join('\n')) {
49
+ throw new InvalidArgumentError('Patch queue changed while waiting for split confirmation. Re-run the command.', 'patch split');
50
+ }
51
+ const currentPlacement = await resolvePlacementPlan(patchesDir, plan.placementOptions, plan.category, plan.name);
52
+ if (!placementPlansEqual(currentPlacement, plan.placement)) {
53
+ throw new InvalidArgumentError('Patch queue changed while waiting for split confirmation. Re-run the command.', 'patch split');
54
+ }
55
+ const movedSet = new Set(plan.movedFiles);
56
+ const effectiveSourceFilename = plan.placement.renameMap.get(plan.source.filename)?.newFilename ?? plan.source.filename;
57
+ const newPatchPath = join(patchesDir, plan.placement.newFilename);
58
+ const sourcePathBefore = join(patchesDir, plan.source.filename);
59
+ const sourcePathAfter = join(patchesDir, effectiveSourceFilename);
60
+ const originalSourceBody = await readText(sourcePathBefore);
61
+ let renumberApplied = false;
62
+ let newPatchWritten = false;
63
+ let sourceRewritten = false;
64
+ try {
65
+ if (plan.placement.renameMap.size > 0) {
66
+ await renumberPatchesInManifest(patchesDir, plan.placement.renameMap);
67
+ renumberApplied = true;
68
+ }
69
+ await writeText(newPatchPath, normalizePatchArtifact(plan.movedDiff));
70
+ newPatchWritten = true;
71
+ await writeText(sourcePathAfter, normalizePatchArtifact(plan.remainingDiff));
72
+ sourceRewritten = true;
73
+ const fresh = await loadPatchesManifest(patchesDir);
74
+ if (!fresh)
75
+ throw new GeneralError('Manifest disappeared during split commit.');
76
+ const updatedPatches = fresh.patches.map((patch) => {
77
+ const withOwners = rewriteSplitOwners(patch, effectiveSourceFilename, movedSet, plan.placement.newFilename);
78
+ if (patch.filename !== effectiveSourceFilename)
79
+ return withOwners;
80
+ return { ...withOwners, filesAffected: plan.remainingFiles };
81
+ });
82
+ updatedPatches.push(newMetadata);
83
+ updatedPatches.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
84
+ const updated = validatePatchesManifest({ ...fresh, patches: updatedPatches });
85
+ await savePatchesManifest(patchesDir, updated);
86
+ try {
87
+ await appendHistory(patchesDir, {
88
+ operation: 'patch-split',
89
+ args: {
90
+ source: effectiveSourceFilename,
91
+ newFilename: plan.placement.newFilename,
92
+ order: plan.placement.insertionOrder,
93
+ files: plan.movedFiles,
94
+ ownerRewrites: plan.ownerRewrites,
95
+ renames: [...plan.placement.renameMap.entries()].map(([from, entry]) => ({
96
+ from,
97
+ to: entry.newFilename,
98
+ })),
99
+ },
100
+ ...(options.yes === true ? { yes: true } : {}),
101
+ ...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
102
+ result: 'ok',
103
+ });
104
+ }
105
+ catch (historyError) {
106
+ warn(`History log append failed after patch split committed: ${toError(historyError).message}`);
107
+ }
108
+ }
109
+ catch (error) {
110
+ // Reverse-order rollback; each step warns on its own failure so the
111
+ // original error stays visible.
112
+ if (sourceRewritten) {
113
+ try {
114
+ await writeText(sourcePathAfter, originalSourceBody);
115
+ }
116
+ catch (rollbackError) {
117
+ warn(`Rollback warning: could not restore source body: ${toError(rollbackError).message}`);
118
+ }
119
+ }
120
+ if (newPatchWritten) {
121
+ try {
122
+ await removeFile(newPatchPath);
123
+ }
124
+ catch (rollbackError) {
125
+ warn(`Rollback warning: could not remove new patch file: ${toError(rollbackError).message}`);
126
+ }
127
+ }
128
+ if (renumberApplied) {
129
+ const inverseMap = new Map([...plan.placement.renameMap.entries()].map(([oldFilename, entry]) => [
130
+ entry.newFilename,
131
+ {
132
+ newOrder: parseInt(oldFilename.split('-')[0] ?? '0', 10),
133
+ newFilename: oldFilename,
134
+ },
135
+ ]));
136
+ try {
137
+ await renumberPatchesInManifest(patchesDir, inverseMap);
138
+ }
139
+ catch (rollbackError) {
140
+ warn(`Rollback warning: could not invert renumber: ${toError(rollbackError).message}`);
141
+ }
142
+ }
143
+ try {
144
+ await savePatchesManifest(patchesDir, manifest);
145
+ }
146
+ catch (rollbackError) {
147
+ warn(`Rollback warning: could not restore manifest: ${toError(rollbackError).message}`);
148
+ }
149
+ throw error;
150
+ }
151
+ });
152
+ }
153
+ /**
154
+ * Runs the `patch split` command: plans the split, lints the projection,
155
+ * confirms, and commits transactionally.
156
+ */
157
+ export async function patchSplitCommand(projectRoot, sourceId, options) {
158
+ intro(options.dryRun ? 'FireForge patch split (dry run)' : 'FireForge patch split');
159
+ const paths = getProjectPaths(projectRoot);
160
+ const config = await loadConfig(projectRoot);
161
+ const manifest = await loadPatchesManifest(paths.patches);
162
+ if (!manifest || manifest.patches.length === 0) {
163
+ throw new GeneralError('No patches in manifest.');
164
+ }
165
+ const source = resolvePatchIdentifier(sourceId, manifest.patches);
166
+ if (!source) {
167
+ throw new InvalidArgumentError(formatPatchNotFoundError(sourceId, manifest.patches), 'patch split');
168
+ }
169
+ const movedFiles = [...new Set(options.files.map((f) => f.trim()).filter(Boolean))].sort();
170
+ if (movedFiles.length === 0) {
171
+ throw new InvalidArgumentError('patch split requires at least one --files path.', '--files');
172
+ }
173
+ assertSourceOwnsFiles(source, movedFiles);
174
+ const movedSet = new Set(movedFiles);
175
+ const remainingFiles = source.filesAffected.filter((f) => !movedSet.has(f));
176
+ if (remainingFiles.length === 0) {
177
+ throw new InvalidArgumentError(`Splitting every file out of ${source.filename} would leave it empty. ` +
178
+ 'Use "fireforge patch rename" / "fireforge patch reorder" to repurpose or move the whole patch instead.', '--files');
179
+ }
180
+ const movedDiff = await buildSplitDiff(paths.engine, movedFiles, 'moved', source.filename);
181
+ const remainingDiff = await buildSplitDiff(paths.engine, remainingFiles, 'remaining', source.filename);
182
+ const category = options.category ?? source.category;
183
+ const placementOptions = pickDefined({
184
+ order: options.order,
185
+ before: options.before,
186
+ after: options.after ??
187
+ (options.order === undefined && options.before === undefined ? source.filename : undefined),
188
+ });
189
+ const placement = await resolvePlacementPlan(paths.patches, placementOptions, category, options.name);
190
+ const plan = {
191
+ source,
192
+ movedFiles,
193
+ remainingFiles,
194
+ movedDiff,
195
+ remainingDiff,
196
+ placement,
197
+ placementOptions,
198
+ category,
199
+ name: options.name,
200
+ description: options.description ?? '',
201
+ ownerRewrites: findOwnerRewriteHolders(manifest.patches, source.filename, movedSet),
202
+ };
203
+ // Per-patch lint both projected bodies, threading the source patch's
204
+ // tier/lintIgnore so an intentional-advisory patch can still split.
205
+ const ignoreChecks = source.lintIgnore ? new Set(source.lintIgnore) : undefined;
206
+ await runPatchLint(paths.engine, remainingFiles, remainingDiff, config, options.skipLint, undefined, ignoreChecks, source.tier);
207
+ await runPatchLint(paths.engine, movedFiles, movedDiff, config, options.skipLint, undefined, ignoreChecks, source.tier);
208
+ const conflicts = await runProjectedSplitLint(paths.patches, plan);
209
+ const newMetadata = buildNewPatchMetadata(plan, config);
210
+ enforcePatchPolicy({
211
+ config,
212
+ manifest: projectSplitManifest(manifest, plan, newMetadata),
213
+ command: 'patch split',
214
+ forceUnsafe: options.forceUnsafe === true,
215
+ });
216
+ const decision = await confirmDestructive({
217
+ operation: 'patch-split',
218
+ title: `Split ${plan.movedFiles.length} file(s) out of ${source.filename} into ${placement.newFilename}`,
219
+ summary: buildSplitSummary(plan),
220
+ yes: options.yes === true,
221
+ dryRun: options.dryRun === true,
222
+ unsafeOverride: options.forceUnsafe === true,
223
+ conflicts,
224
+ });
225
+ if (decision === 'dry-run') {
226
+ outro('Dry run complete — no changes made');
227
+ return;
228
+ }
229
+ if (decision === 'cancelled') {
230
+ outro('Split cancelled');
231
+ return;
232
+ }
233
+ await commitPatchSplit(paths.patches, plan, newMetadata, options);
234
+ success(`Split ${source.filename}: ${placement.newFilename} now owns ${plan.movedFiles.length} file(s)`);
235
+ if (plan.ownerRewrites.length > 0) {
236
+ info(`Re-pointed staged-dependency owners in: ${plan.ownerRewrites.join(', ')}`);
237
+ }
238
+ outro('Split complete');
239
+ }
240
+ /**
241
+ * Registers the `patch split` subcommand on the `patch` parent.
242
+ */
243
+ export function registerPatchSplit(parent, context) {
244
+ const { getProjectRoot, withErrorHandling } = context;
245
+ parent
246
+ .command('split <source>')
247
+ .description('Move files out of a patch into a new patch as one transaction (shrink + create + staged-dependency owner rewrites)')
248
+ .requiredOption('--files <path...>', 'Engine-relative files to move from the source patch to the new patch')
249
+ .requiredOption('--name <name>', 'Name for the new patch')
250
+ .option('--category <category>', "Category for the new patch (default: the source patch's)")
251
+ .option('--description <desc>', 'Description for the new patch')
252
+ .option('--order <n>', 'Exact sparse order for the new patch', (raw) => {
253
+ const n = Number.parseInt(raw, 10);
254
+ if (!Number.isInteger(n) || n <= 0) {
255
+ throw new InvalidArgumentError(`--order must be a positive integer, got "${raw}".`, '--order');
256
+ }
257
+ return n;
258
+ })
259
+ .option('--before <patch>', 'Place the new patch before this patch')
260
+ .option('--after <patch>', 'Place the new patch after this patch (default: the source patch)')
261
+ .option('--dry-run', 'Show what would happen without writing')
262
+ .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
263
+ .option('--force-unsafe', 'Bypass projected-lint refusals')
264
+ .option('--skip-lint', 'Skip per-patch lint of the projected bodies')
265
+ .action(withErrorHandling(async (sourceId, options) => {
266
+ await patchSplitCommand(getProjectRoot(), sourceId, {
267
+ files: options.files,
268
+ name: options.name,
269
+ ...pickDefined({
270
+ category: options.category,
271
+ description: options.description,
272
+ order: options.order,
273
+ before: options.before,
274
+ after: options.after,
275
+ dryRun: options.dryRun,
276
+ yes: options.yes,
277
+ forceUnsafe: options.forceUnsafe,
278
+ skipLint: options.skipLint,
279
+ }),
280
+ });
281
+ }));
282
+ }
283
+ //# sourceMappingURL=split.js.map
@@ -4,12 +4,7 @@
4
4
  */
5
5
  import { Command } from 'commander';
6
6
  import type { CommandContext } from '../../types/cli.js';
7
- import type { PatchStagedDependencyOptions, PatchStagedForwardImport } from '../../types/commands/index.js';
8
- type StagedDependencyMode = 'add' | 'remove' | 'clear';
9
- /**
10
- * Renders a one-line summary of a staged-dependency metadata change.
11
- */
12
- export declare function describeStagedDependencyChange(before: readonly PatchStagedForwardImport[], after: readonly PatchStagedForwardImport[], mode: StagedDependencyMode, dependency: PatchStagedForwardImport | undefined): string;
7
+ import type { PatchStagedDependencyOptions } from '../../types/commands/index.js';
13
8
  /**
14
9
  * Runs the metadata-only staged-dependency mutation command.
15
10
  *
@@ -25,4 +20,3 @@ export declare function patchStagedDependencyCommand(projectRoot: string, identi
25
20
  * @param context - Shared CLI registration context
26
21
  */
27
22
  export declare function registerPatchStagedDependency(parent: Command, context: CommandContext): void;
28
- export {};
@@ -3,15 +3,12 @@
3
3
  * `fireforge patch staged-dependency <name>` — edits
4
4
  * PatchMetadata.stagedDependencies without rewriting the .patch body.
5
5
  */
6
- import { getProjectPaths } from '../../core/config.js';
7
6
  import { appendHistory } from '../../core/destructive.js';
8
7
  import { mutatePatchMetadata } from '../../core/patch-export.js';
9
- import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
10
- import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
11
8
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
12
9
  import { toError } from '../../utils/errors.js';
13
- import { pathExists } from '../../utils/fs.js';
14
10
  import { info, intro, outro, warn } from '../../utils/logger.js';
11
+ import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
15
12
  function modeFromOptions(options) {
16
13
  const adding = options.add === true;
17
14
  const removing = options.remove === true;
@@ -69,7 +66,7 @@ function applyMode(existing, mode, dependency) {
69
66
  /**
70
67
  * Renders a one-line summary of a staged-dependency metadata change.
71
68
  */
72
- export function describeStagedDependencyChange(before, after, mode, dependency) {
69
+ function describeStagedDependencyChange(before, after, mode, dependency) {
73
70
  if (mode === 'clear') {
74
71
  return before.length === 0
75
72
  ? 'stagedDependencies was already empty — no change'
@@ -98,18 +95,8 @@ export async function patchStagedDependencyCommand(projectRoot, identifier, opti
98
95
  intro(isDryRun ? 'FireForge patch staged-dependency (dry run)' : 'FireForge patch staged-dependency');
99
96
  const mode = modeFromOptions(options);
100
97
  const dependency = mode === 'clear' ? undefined : requireForwardImportOptions(options, mode);
101
- const paths = getProjectPaths(projectRoot);
102
- if (!(await pathExists(paths.patches))) {
103
- throw new GeneralError('Patches directory not found.');
104
- }
105
- const manifest = await loadPatchesManifest(paths.patches);
106
- if (!manifest || manifest.patches.length === 0) {
107
- throw new GeneralError('No patches in manifest.');
108
- }
109
- const target = resolvePatchIdentifier(identifier, manifest.patches);
110
- if (!target) {
111
- throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
112
- }
98
+ const { paths, manifest } = await requirePatchQueue(projectRoot);
99
+ const target = requirePatchTarget(identifier, manifest.patches);
113
100
  const existing = target.stagedDependencies?.forwardImports ?? [];
114
101
  const projected = applyMode(existing, mode, dependency);
115
102
  const summary = describeStagedDependencyChange(existing, projected, mode, dependency);
@@ -15,16 +15,13 @@
15
15
  * `--clear` must be supplied per invocation.
16
16
  */
17
17
  import { Option } from 'commander';
18
- import { getProjectPaths } from '../../core/config.js';
19
18
  import { appendHistory } from '../../core/destructive.js';
20
19
  import { updatePatchMetadata } from '../../core/patch-export.js';
21
- import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
22
- import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
23
- import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
20
+ import { InvalidArgumentError } from '../../errors/base.js';
24
21
  import { toError } from '../../utils/errors.js';
25
- import { pathExists } from '../../utils/fs.js';
26
22
  import { info, intro, outro, warn } from '../../utils/logger.js';
27
23
  import { pickDefined } from '../../utils/options.js';
24
+ import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
28
25
  /**
29
26
  * Runs the `patch tier` command: updates `PatchMetadata.tier` on the
30
27
  * named patch (or clears the field) and writes the manifest.
@@ -47,18 +44,8 @@ export async function patchTierCommand(projectRoot, identifier, options = {}) {
47
44
  if (!setting && !clearing) {
48
45
  throw new InvalidArgumentError('Specify --tier <tier> to set the override, or --clear to remove it.', 'patch tier');
49
46
  }
50
- const paths = getProjectPaths(projectRoot);
51
- if (!(await pathExists(paths.patches))) {
52
- throw new GeneralError('Patches directory not found.');
53
- }
54
- const manifest = await loadPatchesManifest(paths.patches);
55
- if (!manifest || manifest.patches.length === 0) {
56
- throw new GeneralError('No patches in manifest.');
57
- }
58
- const target = resolvePatchIdentifier(identifier, manifest.patches);
59
- if (!target) {
60
- throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
61
- }
47
+ const { paths, manifest } = await requirePatchQueue(projectRoot);
48
+ const target = requirePatchTarget(identifier, manifest.patches);
62
49
  const before = target.tier;
63
50
  const after = setting ? options.tier : undefined;
64
51
  if (before === after) {
@@ -198,7 +198,10 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
198
198
  // standard re-export path).
199
199
  const { effectiveTier, effectiveLintIgnore, flagIgnoreSet } = resolveEffectiveTierAndLintIgnore(target, options);
200
200
  const ignoreChecks = effectiveLintIgnore ? new Set(effectiveLintIgnore) : undefined;
201
- await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, undefined, ignoreChecks, effectiveTier);
201
+ const patchQueueCtx = (await pathExists(paths.patches))
202
+ ? await buildPatchQueueContext(paths.patches)
203
+ : undefined;
204
+ await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, patchQueueCtx, ignoreChecks, effectiveTier);
202
205
  const conflicts = await runProjectedCrossPatchLint(paths.patches, target.filename, projectedDiff);
203
206
  const filesUpdates = buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveLintIgnore, flagIgnoreSet);
204
207
  const manifest = await loadPatchesManifest(paths.patches);
@@ -47,9 +47,16 @@ async function scanPatchFiles(args) {
47
47
  for (const f of [...modifiedFiles, ...untrackedFiles])
48
48
  discoveredFiles.add(f);
49
49
  }
50
+ // Git pathspecs recurse, so a claimed file in a shallow directory would
51
+ // sweep entire subtrees into the candidate set — with several patches
52
+ // sharing a parent directory, every unmanaged file in the tree gets
53
+ // offered to whichever patch is scanned first. Constrain the broad scan
54
+ // to the patch's exact directory footprint; deeper paths need an
55
+ // explicit --scan-file / --scan-files assignment.
56
+ const parentDirSet = new Set(parentDirs);
50
57
  const currentSet = new Set(currentFilesAffected);
51
58
  const added = [...discoveredFiles]
52
- .filter((f) => !currentSet.has(f) && !claimedByOthers.has(f))
59
+ .filter((f) => parentDirSet.has(dirname(f)) && !currentSet.has(f) && !claimedByOthers.has(f))
53
60
  .sort();
54
61
  const removed = await findRemovedFiles(currentFilesAffected, engineDir);
55
62
  return reportScanResult(currentFilesAffected, patchFilename, isDryRun, added, removed);
@@ -7,6 +7,7 @@ import { isGitRepository } from '../core/git.js';
7
7
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
8
8
  import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
9
9
  import { updatePatchAndMetadata } from '../core/patch-export.js';
10
+ import { buildPatchQueueContext } from '../core/patch-lint.js';
10
11
  import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
11
12
  import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
12
13
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
@@ -185,7 +186,13 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
185
186
  command: 're-export',
186
187
  forceUnsafe: options.forceUnsafe === true,
187
188
  });
188
- await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, undefined, ignoreChecks, effectiveTier);
189
+ // Pass the whole-queue context so checkJs resolves cross-patch
190
+ // `resource:///` imports against the real owning sources (report scope
191
+ // stays this patch — see runPatchLint).
192
+ const patchQueueCtx = (await pathExists(paths.patches))
193
+ ? await buildPatchQueueContext(paths.patches)
194
+ : undefined;
195
+ await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, patchQueueCtx, ignoreChecks, effectiveTier);
189
196
  if (isDryRun) {
190
197
  info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
191
198
  if (effectiveTier !== undefined && effectiveTier !== patch.tier) {
@@ -1,11 +1,7 @@
1
1
  /**
2
2
  * Rebase summary and status label formatting.
3
3
  */
4
- import type { RebasePatchEntry, RebaseSession } from '../../core/rebase-session.js';
5
- /**
6
- * Formats a status label for a rebase patch entry.
7
- */
8
- export declare function statusLabel(status: RebasePatchEntry['status'], fuzzFactor?: number): string;
4
+ import type { RebaseSession } from '../../core/rebase-session.js';
9
5
  /**
10
6
  * Prints the rebase summary table.
11
7
  */
@@ -6,7 +6,7 @@ import { info } from '../../utils/logger.js';
6
6
  /**
7
7
  * Formats a status label for a rebase patch entry.
8
8
  */
9
- export function statusLabel(status, fuzzFactor) {
9
+ function statusLabel(status, fuzzFactor) {
10
10
  switch (status) {
11
11
  case 'applied-clean':
12
12
  return 'applied cleanly';
@@ -105,82 +105,91 @@ export async function renderUnmanagedOnly(unmanagedFiles, totalModified, project
105
105
  ? 'No unmanaged changes'
106
106
  : `${unmanagedFiles.length} unmanaged change${unmanagedFiles.length === 1 ? '' : 's'}`);
107
107
  }
108
+ /** Renders the cross-patch ownership conflict section. */
109
+ function renderConflictSection(conflict) {
110
+ warn('Cross-patch ownership conflicts (same file claimed by multiple patches):');
111
+ printStatusGroups(conflict);
112
+ for (const entry of conflict) {
113
+ if (entry.claimedBy && entry.claimedBy.length > 0) {
114
+ info(` ${entry.file} — claimed by ${entry.claimedBy.join(', ')}`);
115
+ }
116
+ }
117
+ info('Run "fireforge status --ownership" for the full conflict table, then repartition with "fireforge re-export --files <paths> <patch>".');
118
+ }
108
119
  /** Renders the default classified status buckets. */
109
120
  export async function renderDefaultStatus(totalModified, buckets, projectRoot, binaryName) {
110
121
  const { conflict, unmanaged, patchBacked, patchOwnedDrift, branding, furnace } = buckets;
111
122
  info(`${totalModified} modified file${totalModified === 1 ? '' : 's'}:\n`);
112
- if (conflict.length > 0) {
113
- warn('Cross-patch ownership conflicts (same file claimed by multiple patches):');
114
- printStatusGroups(conflict);
115
- for (const entry of conflict) {
116
- if (entry.claimedBy && entry.claimedBy.length > 0) {
117
- info(` ${entry.file} — claimed by ${entry.claimedBy.join(', ')}`);
118
- }
119
- }
120
- info('Run "fireforge status --ownership" for the full conflict table, then repartition with "fireforge re-export --files <paths> <patch>".');
121
- }
122
- if (unmanaged.length > 0) {
123
- if (conflict.length > 0)
124
- info('');
125
- warn('Unmanaged changes:');
126
- printStatusGroups(unmanaged);
127
- await printUnregisteredWarnings(unmanaged, projectRoot, binaryName);
128
- }
129
- if (patchBacked.length > 0) {
130
- if (conflict.length > 0 || unmanaged.length > 0)
123
+ // Sections render in this fixed order, separated by a blank line
124
+ // whenever an earlier section already printed (the pre-refactor code
125
+ // expressed the same rule as per-section "any earlier bucket
126
+ // non-empty" conditions).
127
+ const sections = [
128
+ {
129
+ files: conflict,
130
+ label: 'conflict',
131
+ render: () => {
132
+ renderConflictSection(conflict);
133
+ },
134
+ },
135
+ {
136
+ files: unmanaged,
137
+ label: 'unmanaged',
138
+ render: async () => {
139
+ warn('Unmanaged changes:');
140
+ printStatusGroups(unmanaged);
141
+ await printUnregisteredWarnings(unmanaged, projectRoot, binaryName);
142
+ },
143
+ },
144
+ {
145
+ files: patchBacked,
146
+ label: 'patch-backed',
147
+ render: () => {
148
+ warn('Patch-backed materialized changes:');
149
+ printStatusGroups(patchBacked);
150
+ },
151
+ },
152
+ {
153
+ files: patchOwnedDrift,
154
+ label: 'patch-owned drift',
155
+ render: () => {
156
+ warn('Patch-owned drift:');
157
+ printStatusGroups(patchOwnedDrift);
158
+ info('These files are claimed by exactly one patch, but engine/ no longer matches that patch output. Re-export the owning patch after reviewing the manual resolution.');
159
+ },
160
+ },
161
+ {
162
+ files: branding,
163
+ label: 'branding',
164
+ render: () => {
165
+ warn('Tool-managed branding changes:');
166
+ printStatusGroups(branding);
167
+ },
168
+ },
169
+ {
170
+ files: furnace,
171
+ label: 'furnace',
172
+ render: () => {
173
+ warn('Furnace-managed component changes:');
174
+ printStatusGroups(furnace);
175
+ },
176
+ },
177
+ ];
178
+ let printedAny = false;
179
+ for (const section of sections) {
180
+ if (section.files.length === 0)
181
+ continue;
182
+ if (printedAny)
131
183
  info('');
132
- warn('Patch-backed materialized changes:');
133
- printStatusGroups(patchBacked);
134
- }
135
- if (patchOwnedDrift.length > 0) {
136
- if (conflict.length > 0 || unmanaged.length > 0 || patchBacked.length > 0)
137
- info('');
138
- warn('Patch-owned drift:');
139
- printStatusGroups(patchOwnedDrift);
140
- info('These files are claimed by exactly one patch, but engine/ no longer matches that patch output. Re-export the owning patch after reviewing the manual resolution.');
141
- }
142
- if (branding.length > 0) {
143
- if (conflict.length > 0 ||
144
- unmanaged.length > 0 ||
145
- patchBacked.length > 0 ||
146
- patchOwnedDrift.length > 0) {
147
- info('');
148
- }
149
- warn('Tool-managed branding changes:');
150
- printStatusGroups(branding);
151
- }
152
- if (furnace.length > 0) {
153
- if (conflict.length > 0 ||
154
- unmanaged.length > 0 ||
155
- patchBacked.length > 0 ||
156
- patchOwnedDrift.length > 0 ||
157
- branding.length > 0) {
158
- info('');
159
- }
160
- warn('Furnace-managed component changes:');
161
- printStatusGroups(furnace);
184
+ await section.render();
185
+ printedAny = true;
162
186
  }
163
- if (conflict.length === 0 &&
164
- unmanaged.length === 0 &&
165
- patchBacked.length === 0 &&
166
- patchOwnedDrift.length === 0 &&
167
- branding.length === 0 &&
168
- furnace.length === 0) {
187
+ if (!printedAny) {
169
188
  info('No changes');
170
189
  }
171
- const parts = [];
172
- if (conflict.length > 0)
173
- parts.push(`${conflict.length} conflict`);
174
- if (unmanaged.length > 0)
175
- parts.push(`${unmanaged.length} unmanaged`);
176
- if (patchBacked.length > 0)
177
- parts.push(`${patchBacked.length} patch-backed`);
178
- if (patchOwnedDrift.length > 0)
179
- parts.push(`${patchOwnedDrift.length} patch-owned drift`);
180
- if (branding.length > 0)
181
- parts.push(`${branding.length} branding`);
182
- if (furnace.length > 0)
183
- parts.push(`${furnace.length} furnace`);
190
+ const parts = sections
191
+ .filter((section) => section.files.length > 0)
192
+ .map((section) => `${section.files.length} ${section.label}`);
184
193
  outro(parts.join(', '));
185
194
  }
186
195
  //# sourceMappingURL=status-output.js.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Failure diagnosis for `fireforge test`: maps captured mach output to
3
+ * actionable operator messages (unknown test paths, stale build artifacts,
4
+ * fork-module registration, xpcshell appdir, harness symlinks, mochitest
5
+ * branding interactions), and applies harness-run verdicts from
6
+ * `test-harness-crash.ts` for single and sharded invocations. Split out of
7
+ * `test.ts` to keep both files within the per-file line budget.
8
+ */
9
+ import { type PostRebuildFailureContext } from '../core/test-harness-output.js';
10
+ import type { TestRunOutcome } from './test-run.js';
11
+ /**
12
+ * Applies the harness-run verdict for a single (non-sharded) invocation:
13
+ * exhausted harness-crash retries and silent zero-TEST-START runs are
14
+ * harness problems with their own messages; everything else flows into
15
+ * the regular non-zero-exit diagnosis chain.
16
+ */
17
+ export declare function finalizeSingleRunOutcome(outcome: TestRunOutcome, normalizedPaths: string[], binaryName: string, postRebuildContext: PostRebuildFailureContext | undefined): void;
18
+ /**
19
+ * Shard-mode adapter over {@link handleNonZeroTestExit}: produces the
20
+ * diagnosis text as a string (to warn per shard) instead of throwing, so
21
+ * later shards still run and the aggregate error stays singular.
22
+ */
23
+ export declare function diagnoseShardOutcome(outcome: TestRunOutcome, path: string, binaryName: string, postRebuildContext: PostRebuildFailureContext | undefined): string | undefined;