@hominis/fireforge 0.10.0 → 0.11.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 (174) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +126 -239
  3. package/dist/bin/fireforge.js +26 -0
  4. package/dist/src/cli.d.ts +1 -1
  5. package/dist/src/cli.js +131 -52
  6. package/dist/src/commands/bootstrap.js +6 -2
  7. package/dist/src/commands/build.js +4 -2
  8. package/dist/src/commands/discard.js +16 -4
  9. package/dist/src/commands/doctor-furnace.d.ts +8 -0
  10. package/dist/src/commands/doctor-furnace.js +422 -0
  11. package/dist/src/commands/doctor.d.ts +115 -0
  12. package/dist/src/commands/doctor.js +327 -258
  13. package/dist/src/commands/download.js +16 -1
  14. package/dist/src/commands/export-all.js +15 -0
  15. package/dist/src/commands/export-flow.d.ts +91 -0
  16. package/dist/src/commands/export-flow.js +344 -0
  17. package/dist/src/commands/export.js +151 -5
  18. package/dist/src/commands/furnace/apply.d.ts +3 -2
  19. package/dist/src/commands/furnace/apply.js +169 -36
  20. package/dist/src/commands/furnace/create.js +162 -52
  21. package/dist/src/commands/furnace/deploy.js +156 -144
  22. package/dist/src/commands/furnace/diff.d.ts +8 -4
  23. package/dist/src/commands/furnace/diff.js +142 -73
  24. package/dist/src/commands/furnace/index.d.ts +6 -2
  25. package/dist/src/commands/furnace/index.js +76 -25
  26. package/dist/src/commands/furnace/init.d.ts +11 -0
  27. package/dist/src/commands/furnace/init.js +76 -0
  28. package/dist/src/commands/furnace/list.d.ts +4 -1
  29. package/dist/src/commands/furnace/list.js +35 -3
  30. package/dist/src/commands/furnace/override.d.ts +8 -0
  31. package/dist/src/commands/furnace/override.js +216 -26
  32. package/dist/src/commands/furnace/preview.js +184 -30
  33. package/dist/src/commands/furnace/refresh.d.ts +10 -0
  34. package/dist/src/commands/furnace/refresh.js +268 -0
  35. package/dist/src/commands/furnace/remove.js +285 -89
  36. package/dist/src/commands/furnace/rename.d.ts +5 -0
  37. package/dist/src/commands/furnace/rename.js +308 -0
  38. package/dist/src/commands/furnace/scan.d.ts +4 -1
  39. package/dist/src/commands/furnace/scan.js +72 -11
  40. package/dist/src/commands/furnace/status.js +85 -20
  41. package/dist/src/commands/furnace/sync.d.ts +12 -0
  42. package/dist/src/commands/furnace/sync.js +77 -0
  43. package/dist/src/commands/furnace/validate.d.ts +4 -1
  44. package/dist/src/commands/furnace/validate.js +99 -3
  45. package/dist/src/commands/furnace/validation-output.d.ts +24 -1
  46. package/dist/src/commands/furnace/validation-output.js +93 -1
  47. package/dist/src/commands/import.js +37 -4
  48. package/dist/src/commands/lint.js +11 -2
  49. package/dist/src/commands/manifest.d.ts +39 -0
  50. package/dist/src/commands/manifest.js +59 -0
  51. package/dist/src/commands/patch/delete.d.ts +28 -0
  52. package/dist/src/commands/patch/delete.js +209 -0
  53. package/dist/src/commands/patch/index.d.ts +17 -0
  54. package/dist/src/commands/patch/index.js +25 -0
  55. package/dist/src/commands/patch/reorder.d.ts +30 -0
  56. package/dist/src/commands/patch/reorder.js +377 -0
  57. package/dist/src/commands/re-export-files.d.ts +17 -0
  58. package/dist/src/commands/re-export-files.js +177 -0
  59. package/dist/src/commands/re-export.js +44 -0
  60. package/dist/src/commands/rebase/abort.d.ts +1 -1
  61. package/dist/src/commands/rebase/abort.js +12 -3
  62. package/dist/src/commands/rebase/confirm.d.ts +3 -3
  63. package/dist/src/commands/rebase/confirm.js +4 -4
  64. package/dist/src/commands/rebase/index.js +13 -4
  65. package/dist/src/commands/reset.js +20 -4
  66. package/dist/src/commands/run.js +46 -1
  67. package/dist/src/commands/setup-support.js +5 -5
  68. package/dist/src/commands/status.js +97 -6
  69. package/dist/src/commands/test.js +5 -37
  70. package/dist/src/commands/verify.d.ts +31 -0
  71. package/dist/src/commands/verify.js +126 -0
  72. package/dist/src/core/build-prepare.js +40 -16
  73. package/dist/src/core/destructive.d.ts +96 -0
  74. package/dist/src/core/destructive.js +137 -0
  75. package/dist/src/core/diff-hunks.d.ts +73 -0
  76. package/dist/src/core/diff-hunks.js +268 -0
  77. package/dist/src/core/firefox.d.ts +1 -1
  78. package/dist/src/core/firefox.js +1 -1
  79. package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
  80. package/dist/src/core/furnace-apply-helpers.js +302 -57
  81. package/dist/src/core/furnace-apply-output.d.ts +16 -0
  82. package/dist/src/core/furnace-apply-output.js +57 -0
  83. package/dist/src/core/furnace-apply.d.ts +21 -3
  84. package/dist/src/core/furnace-apply.js +260 -29
  85. package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
  86. package/dist/src/core/furnace-checksum-utils.js +24 -0
  87. package/dist/src/core/furnace-config.d.ts +28 -1
  88. package/dist/src/core/furnace-config.js +180 -17
  89. package/dist/src/core/furnace-constants.d.ts +22 -0
  90. package/dist/src/core/furnace-constants.js +36 -0
  91. package/dist/src/core/furnace-graph-utils.d.ts +11 -0
  92. package/dist/src/core/furnace-graph-utils.js +94 -0
  93. package/dist/src/core/furnace-operation.d.ts +108 -0
  94. package/dist/src/core/furnace-operation.js +220 -0
  95. package/dist/src/core/furnace-refresh.d.ts +20 -0
  96. package/dist/src/core/furnace-refresh.js +118 -0
  97. package/dist/src/core/furnace-registration-ast.d.ts +5 -0
  98. package/dist/src/core/furnace-registration-ast.js +134 -4
  99. package/dist/src/core/furnace-registration-remove.d.ts +25 -3
  100. package/dist/src/core/furnace-registration-remove.js +196 -62
  101. package/dist/src/core/furnace-registration-validate.d.ts +13 -1
  102. package/dist/src/core/furnace-registration-validate.js +15 -3
  103. package/dist/src/core/furnace-registration.d.ts +27 -4
  104. package/dist/src/core/furnace-registration.js +93 -11
  105. package/dist/src/core/furnace-rollback.d.ts +11 -0
  106. package/dist/src/core/furnace-rollback.js +78 -7
  107. package/dist/src/core/furnace-scanner.d.ts +8 -2
  108. package/dist/src/core/furnace-scanner.js +152 -55
  109. package/dist/src/core/furnace-stories.js +7 -5
  110. package/dist/src/core/furnace-validate-accessibility.js +7 -1
  111. package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
  112. package/dist/src/core/furnace-validate-compatibility.js +85 -1
  113. package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
  114. package/dist/src/core/furnace-validate-helpers.js +31 -0
  115. package/dist/src/core/furnace-validate-registration.d.ts +17 -2
  116. package/dist/src/core/furnace-validate-registration.js +73 -3
  117. package/dist/src/core/furnace-validate-structure.d.ts +10 -2
  118. package/dist/src/core/furnace-validate-structure.js +45 -3
  119. package/dist/src/core/furnace-validate.d.ts +10 -1
  120. package/dist/src/core/furnace-validate.js +80 -6
  121. package/dist/src/core/furnace-version-drift.d.ts +55 -0
  122. package/dist/src/core/furnace-version-drift.js +101 -0
  123. package/dist/src/core/git-file-ops.d.ts +8 -0
  124. package/dist/src/core/git-file-ops.js +19 -6
  125. package/dist/src/core/lint-projection.d.ts +25 -0
  126. package/dist/src/core/lint-projection.js +44 -0
  127. package/dist/src/core/mach.d.ts +4 -2
  128. package/dist/src/core/mach.js +17 -2
  129. package/dist/src/core/markdown-table.d.ts +104 -0
  130. package/dist/src/core/markdown-table.js +266 -0
  131. package/dist/src/core/ownership-table.d.ts +53 -0
  132. package/dist/src/core/ownership-table.js +144 -0
  133. package/dist/src/core/patch-apply.d.ts +17 -3
  134. package/dist/src/core/patch-apply.js +86 -8
  135. package/dist/src/core/patch-export.d.ts +119 -5
  136. package/dist/src/core/patch-export.js +183 -25
  137. package/dist/src/core/patch-lint-cross.d.ts +195 -0
  138. package/dist/src/core/patch-lint-cross.js +428 -0
  139. package/dist/src/core/patch-lint-diff.d.ts +33 -0
  140. package/dist/src/core/patch-lint-diff.js +84 -0
  141. package/dist/src/core/patch-lint.d.ts +2 -4
  142. package/dist/src/core/patch-lint.js +12 -50
  143. package/dist/src/core/patch-lock.js +2 -1
  144. package/dist/src/core/patch-manifest-io.d.ts +102 -1
  145. package/dist/src/core/patch-manifest-io.js +270 -2
  146. package/dist/src/core/patch-manifest-query.d.ts +1 -1
  147. package/dist/src/core/patch-manifest-query.js +1 -1
  148. package/dist/src/core/patch-manifest.d.ts +1 -1
  149. package/dist/src/core/patch-manifest.js +1 -1
  150. package/dist/src/core/patch-transform.d.ts +12 -0
  151. package/dist/src/core/patch-transform.js +21 -7
  152. package/dist/src/core/token-manager.js +67 -69
  153. package/dist/src/core/wire-destroy.js +6 -3
  154. package/dist/src/core/wire-init.js +10 -4
  155. package/dist/src/core/wire-subscript.js +9 -3
  156. package/dist/src/core/wire-utils.d.ts +52 -5
  157. package/dist/src/core/wire-utils.js +69 -6
  158. package/dist/src/errors/base.d.ts +20 -0
  159. package/dist/src/errors/base.js +24 -0
  160. package/dist/src/errors/furnace.js +7 -1
  161. package/dist/src/errors/rebase.js +6 -1
  162. package/dist/src/types/commands/index.d.ts +1 -1
  163. package/dist/src/types/commands/options.d.ts +125 -4
  164. package/dist/src/types/commands/patches.d.ts +11 -1
  165. package/dist/src/types/config.d.ts +1 -1
  166. package/dist/src/types/furnace.d.ts +55 -1
  167. package/dist/src/utils/fs.d.ts +12 -0
  168. package/dist/src/utils/fs.js +30 -1
  169. package/dist/src/utils/package-root.d.ts +5 -0
  170. package/dist/src/utils/package-root.js +12 -0
  171. package/dist/src/utils/process.js +9 -4
  172. package/dist/src/utils/validation.d.ts +20 -2
  173. package/dist/src/utils/validation.js +26 -3
  174. package/package.json +1 -1
@@ -2,9 +2,10 @@
2
2
  import { join } from 'node:path';
3
3
  import { getProjectPaths, loadConfig, updateState } from '../core/config.js';
4
4
  import { downloadFirefoxSource, formatBytes } from '../core/firefox.js';
5
+ import { getFurnacePaths, updateFurnaceState } from '../core/furnace-config.js';
5
6
  import { getHead, initRepository, isGitRepository, isMissingHeadError, resumeRepository, } from '../core/git.js';
6
7
  import { EngineExistsError, PartialEngineExistsError } from '../errors/download.js';
7
- import { ensureDir, pathExists, removeDir } from '../utils/fs.js';
8
+ import { checkDiskSpace, ensureDir, pathExists, removeDir } from '../utils/fs.js';
8
9
  import { info, intro, outro, spinner, step, warn } from '../utils/logger.js';
9
10
  import { pickDefined } from '../utils/options.js';
10
11
  /**
@@ -19,6 +20,8 @@ export async function downloadCommand(projectRoot, options) {
19
20
  const paths = getProjectPaths(projectRoot);
20
21
  const version = config.firefox.version;
21
22
  info(`Firefox version: ${version}`);
23
+ // Disk space pre-flight: Firefox source is ~5 GB
24
+ await checkDiskSpace(projectRoot, 5 * 1024 * 1024 * 1024, warn);
22
25
  // Check if engine already exists
23
26
  if (await pathExists(paths.engine)) {
24
27
  if (!options.force) {
@@ -64,6 +67,18 @@ export async function downloadCommand(projectRoot, options) {
64
67
  }
65
68
  warn('Removing existing engine directory...');
66
69
  await removeDir(paths.engine);
70
+ // --force installs a new baseCommit, which invalidates every applied
71
+ // checksum in furnace-state.json. Clearing the state now prevents a
72
+ // subsequent `furnace apply` from reporting "up to date" against an
73
+ // engine that no longer contains any of the deployed files. Preserve
74
+ // pendingRepair: authoring-side rollback markers describe unresolved
75
+ // component workspace state and should survive an engine refresh.
76
+ const furnacePaths = getFurnacePaths(projectRoot);
77
+ if (await pathExists(furnacePaths.furnaceState)) {
78
+ await updateFurnaceState(projectRoot, (current) => ({
79
+ ...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
80
+ }));
81
+ }
67
82
  }
68
83
  // Ensure cache directory exists
69
84
  const cacheDir = join(paths.fireforgeDir, 'cache');
@@ -2,6 +2,7 @@
2
2
  import { Option } from 'commander';
3
3
  import { isBrandingManagedPath } from '../core/branding.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
+ import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
5
6
  import { hasChanges, isGitRepository } from '../core/git.js';
6
7
  import { getAllDiff } from '../core/git-diff.js';
7
8
  import { getWorkingTreeStatus } from '../core/git-status.js';
@@ -23,6 +24,19 @@ async function checkBrandingManagedFiles(paths, config) {
23
24
  'Review these files with "fireforge status" first. If you intentionally want a branding patch, export the specific branding paths explicitly with "fireforge export ...".');
24
25
  }
25
26
  }
27
+ async function checkFurnaceManagedFiles(paths, projectRoot) {
28
+ const prefixes = await collectFurnaceManagedPrefixes(projectRoot);
29
+ if (prefixes.size === 0)
30
+ return;
31
+ const changedFiles = await getWorkingTreeStatus(paths.engine);
32
+ const furnaceManagedFiles = changedFiles
33
+ .flatMap((entry) => [entry.file, entry.originalPath].filter((value) => !!value))
34
+ .filter((file) => [...prefixes].some((prefix) => file.startsWith(prefix)));
35
+ if (furnaceManagedFiles.length > 0) {
36
+ throw new GeneralError('Export-all refuses to capture Furnace-managed component changes.\n\n' +
37
+ 'These files are deployed by "fireforge furnace apply" and should be managed through the Furnace workflow. Review them with "fireforge status" or "fireforge furnace status".');
38
+ }
39
+ }
26
40
  /**
27
41
  * Runs the export-all command to export all changes as a patch.
28
42
  * @param projectRoot - Root directory of the project
@@ -47,6 +61,7 @@ export async function exportAllCommand(projectRoot, options = {}) {
47
61
  }
48
62
  const config = await loadConfig(projectRoot);
49
63
  await checkBrandingManagedFiles(paths, config);
64
+ await checkFurnaceManagedFiles(paths, projectRoot);
50
65
  // Get the full diff
51
66
  let diff = await getAllDiff(paths.engine);
52
67
  if (!diff.trim()) {
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Planning + dry-run + placement helpers extracted from `export.ts`.
3
+ *
4
+ * These functions are pure or narrowly-scoped async helpers that compose
5
+ * into `exportCommand`. Splitting them out keeps `export.ts` under the
6
+ * per-file / per-function line budgets and makes each step individually
7
+ * testable without dragging the whole command harness along for the ride.
8
+ */
9
+ import { type ConflictReport } from '../core/destructive.js';
10
+ import { type PatchRenameEntry } from '../core/patch-manifest.js';
11
+ import type { ExportOptions, PatchCategory, PatchMetadata } from '../types/commands/index.js';
12
+ /**
13
+ * Shape for the rename map computed when a placement flag forces existing
14
+ * patches to move out of the new slot. Keys are current filenames.
15
+ */
16
+ export interface PlacementPlan {
17
+ insertionOrder: number;
18
+ newFilename: string;
19
+ renameMap: Map<string, PatchRenameEntry>;
20
+ }
21
+ /**
22
+ * Computes the shift map that moves existing patches out of the requested
23
+ * slot to make room for a new patch at `requestedOrder`.
24
+ */
25
+ export declare function computePlacementPlan(manifestPatches: PatchMetadata[], newPatchCategory: PatchCategory, newPatchName: string, requestedOrder: number): PlacementPlan;
26
+ /**
27
+ * Resolves a placement plan from CLI flags against the current manifest.
28
+ */
29
+ export declare function resolvePlacementPlan(patchesDir: string, options: ExportOptions, category: PatchCategory, name: string): Promise<PlacementPlan>;
30
+ /**
31
+ * Projects the placement through cross-patch lint to detect forward-imports
32
+ * the renumber would introduce *or* that the new patch itself would
33
+ * introduce by landing earlier than one of its dependencies. Returns null
34
+ * when the projection is clean.
35
+ */
36
+ export declare function projectPlacementForLint(patchesDir: string, plan: PlacementPlan, diff: string): Promise<ConflictReport | null>;
37
+ /**
38
+ * Builds the change-summary lines printed by the placement confirmation.
39
+ */
40
+ export declare function placementSummary(plan: PlacementPlan): string[];
41
+ /**
42
+ * Writes a placement-mode export under the patch directory lock after
43
+ * re-resolving the plan against the current queue state. If the queue has
44
+ * changed since the user confirmed the preview, the command aborts instead
45
+ * of silently applying a different renumber than the one that was shown.
46
+ */
47
+ export interface CommitPlacementExportInput {
48
+ patchesDir: string;
49
+ options: ExportOptions;
50
+ category: PatchCategory;
51
+ name: string;
52
+ diff: string;
53
+ metadata: PatchMetadata;
54
+ expectedPlan: PlacementPlan;
55
+ unsafeOverride?: boolean;
56
+ /**
57
+ * Optional post-commit hook that runs inside the patch directory lock,
58
+ * after the mutation has succeeded but before the lock is released.
59
+ * Intended for the caller's history-log append so the audit record
60
+ * lands atomically with the mutation — a crash between mutation and
61
+ * hook leaves no room for another process's history record to sneak
62
+ * in first.
63
+ *
64
+ * Failures in the hook are warned but never re-thrown: by the time it
65
+ * runs, the mutation is already committed, and there is nothing to
66
+ * roll back. History is advisory.
67
+ */
68
+ onCommitted?: (plan: PlacementPlan) => Promise<void>;
69
+ }
70
+ /**
71
+ * Commits a previously-confirmed placement export under the patch
72
+ * directory lock. Re-resolves the placement plan against the current
73
+ * queue and aborts if anything changed since the preview so the command
74
+ * never applies a silently different rename set than the user saw.
75
+ */
76
+ export declare function commitPlacementExport(input: CommitPlacementExportInput): Promise<PlacementPlan>;
77
+ export interface DryRunPreviewInput {
78
+ patchesDir: string;
79
+ category: PatchCategory;
80
+ name: string;
81
+ description: string;
82
+ filesAffected: string[];
83
+ sourceEsrVersion: string;
84
+ explicitSupersede: boolean;
85
+ }
86
+ /**
87
+ * Renders the plain (non-placement) dry-run preview: calls planExport,
88
+ * prints the allocated filename + metadata, and with supersede enumerates
89
+ * the per-patch coverage detail that was opaque before this refactor.
90
+ */
91
+ export declare function renderDryRunPreview(input: DryRunPreviewInput): Promise<void>;
@@ -0,0 +1,344 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Planning + dry-run + placement helpers extracted from `export.ts`.
4
+ *
5
+ * These functions are pure or narrowly-scoped async helpers that compose
6
+ * into `exportCommand`. Splitting them out keeps `export.ts` under the
7
+ * per-file / per-function line budgets and makes each step individually
8
+ * testable without dragging the whole command harness along for the ride.
9
+ */
10
+ import { join } from 'node:path';
11
+ import { findAllPatchesForFilesWithDetails, planExport } from '../core/patch-export.js';
12
+ import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
13
+ import { withPatchDirectoryLock } from '../core/patch-lock.js';
14
+ import { addPatchToManifest, loadPatchesManifest, renumberPatchesInManifest, savePatchesManifest, } from '../core/patch-manifest.js';
15
+ import { extractNewFileContentFromDiff } from '../core/patch-transform.js';
16
+ import { InvalidArgumentError } from '../errors/base.js';
17
+ import { toError } from '../utils/errors.js';
18
+ import { pathExists, readText, removeFile, writeText } from '../utils/fs.js';
19
+ import { info, warn } from '../utils/logger.js';
20
+ /**
21
+ * Sanitizes a patch name for use in a filename. Mirrors the private helper
22
+ * in patch-export.ts.
23
+ */
24
+ function sanitizeExportName(name) {
25
+ return name
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9]+/g, '-')
28
+ .replace(/^-+|-+$/g, '')
29
+ .slice(0, 50);
30
+ }
31
+ function buildFilenameForPlacement(category, name, order, width) {
32
+ const padded = String(order).padStart(Math.max(3, width), '0');
33
+ return `${padded}-${category}-${sanitizeExportName(name)}.patch`;
34
+ }
35
+ function resolvePatchByIdentifier(identifier, patches) {
36
+ if (/^\d+$/.test(identifier)) {
37
+ const order = parseInt(identifier, 10);
38
+ return patches.find((p) => p.order === order) ?? null;
39
+ }
40
+ const normalized = identifier.endsWith('.patch') ? identifier : `${identifier}.patch`;
41
+ return patches.find((p) => p.filename === normalized) ?? null;
42
+ }
43
+ function getSortedRenameEntries(renameMap) {
44
+ return Array.from(renameMap.entries()).sort((a, b) => a[1].newOrder - b[1].newOrder);
45
+ }
46
+ function placementPlansEqual(left, right) {
47
+ if (left.insertionOrder !== right.insertionOrder || left.newFilename !== right.newFilename) {
48
+ return false;
49
+ }
50
+ const leftEntries = getSortedRenameEntries(left.renameMap);
51
+ const rightEntries = getSortedRenameEntries(right.renameMap);
52
+ if (leftEntries.length !== rightEntries.length) {
53
+ return false;
54
+ }
55
+ return leftEntries.every(([leftFilename, leftEntry], index) => {
56
+ const rightTuple = rightEntries[index];
57
+ if (!rightTuple)
58
+ return false;
59
+ const [rightFilename, rightEntry] = rightTuple;
60
+ return (leftFilename === rightFilename &&
61
+ leftEntry.newFilename === rightEntry.newFilename &&
62
+ leftEntry.newOrder === rightEntry.newOrder);
63
+ });
64
+ }
65
+ /**
66
+ * Computes the shift map that moves existing patches out of the requested
67
+ * slot to make room for a new patch at `requestedOrder`.
68
+ */
69
+ export function computePlacementPlan(manifestPatches, newPatchCategory, newPatchName, requestedOrder) {
70
+ // Defense-in-depth: the --order argParser already validates, but this
71
+ // function is exported and reachable from tests / future callers.
72
+ // Failing fast here prevents a NaN requestedOrder from producing a
73
+ // filename like "NaN-ui-foo.patch".
74
+ if (!Number.isInteger(requestedOrder) || requestedOrder <= 0) {
75
+ throw new InvalidArgumentError(`computePlacementPlan requires a positive integer order, got ${String(requestedOrder)}.`, 'requestedOrder');
76
+ }
77
+ const sorted = [...manifestPatches].sort((a, b) => a.order - b.order);
78
+ const renameMap = new Map();
79
+ // Decide the canonical prefix width by inspecting the widest existing
80
+ // filename (falling back to 3). Keeps zero-padding consistent post-shift.
81
+ const prefixWidth = sorted.reduce((w, p) => {
82
+ const match = /^(\d+)-/.exec(p.filename);
83
+ return match ? Math.max(w, match[1]?.length ?? 3) : w;
84
+ }, 3);
85
+ // Every existing patch at requestedOrder or later shifts up by one.
86
+ for (const patch of sorted) {
87
+ if (patch.order >= requestedOrder) {
88
+ const newOrder = patch.order + 1;
89
+ const currentRest = patch.filename.replace(/^\d+-/, '');
90
+ const newFilename = `${String(newOrder).padStart(prefixWidth, '0')}-${currentRest}`;
91
+ renameMap.set(patch.filename, { newOrder, newFilename });
92
+ }
93
+ }
94
+ const newFilename = buildFilenameForPlacement(newPatchCategory, newPatchName, requestedOrder, prefixWidth);
95
+ return {
96
+ insertionOrder: requestedOrder,
97
+ newFilename,
98
+ renameMap,
99
+ };
100
+ }
101
+ /**
102
+ * Resolves a placement plan from CLI flags against the current manifest.
103
+ */
104
+ export async function resolvePlacementPlan(patchesDir, options, category, name) {
105
+ const manifest = await loadPatchesManifest(patchesDir);
106
+ const existingPatches = manifest?.patches ?? [];
107
+ let targetOrder;
108
+ if (options.order !== undefined) {
109
+ // Defense-in-depth — argParser covers the CLI path, but this
110
+ // function is called directly from the command body which could
111
+ // reach here with a NaN/0/negative value passed in via test harness.
112
+ if (!Number.isInteger(options.order) || options.order <= 0) {
113
+ throw new InvalidArgumentError(`--order must be a positive integer, got ${String(options.order)}.`, '--order');
114
+ }
115
+ targetOrder = options.order;
116
+ }
117
+ else if (options.before !== undefined) {
118
+ const anchor = resolvePatchByIdentifier(options.before, existingPatches);
119
+ if (!anchor) {
120
+ throw new InvalidArgumentError(`--before anchor "${options.before}" not found.`, '--before');
121
+ }
122
+ targetOrder = anchor.order;
123
+ }
124
+ else {
125
+ const afterAnchorId = options.after;
126
+ if (afterAnchorId === undefined) {
127
+ throw new InvalidArgumentError('Placement flag resolver reached --after branch with no value set.', '--after');
128
+ }
129
+ const anchor = resolvePatchByIdentifier(afterAnchorId, existingPatches);
130
+ if (!anchor) {
131
+ throw new InvalidArgumentError(`--after anchor "${afterAnchorId}" not found.`, '--after');
132
+ }
133
+ targetOrder = anchor.order + 1;
134
+ }
135
+ return computePlacementPlan(existingPatches, category, name, targetOrder);
136
+ }
137
+ /**
138
+ * Extracts the newly-created files a diff would produce and builds the
139
+ * `newFiles` map in the shape expected by {@link PatchQueueEntry}. Used
140
+ * to build a faithful synthetic entry for the pending patch when
141
+ * projecting through cross-patch lint — without this the forward-import
142
+ * rule cannot see imports authored by the new patch itself.
143
+ */
144
+ function buildNewFilesFromDiff(diff) {
145
+ const newFiles = new Map();
146
+ const newFilePaths = detectNewFilesInDiff(diff);
147
+ for (const path of newFilePaths) {
148
+ newFiles.set(path, extractNewFileContentFromDiff(diff, path));
149
+ }
150
+ return newFiles;
151
+ }
152
+ /**
153
+ * Projects the placement through cross-patch lint to detect forward-imports
154
+ * the renumber would introduce *or* that the new patch itself would
155
+ * introduce by landing earlier than one of its dependencies. Returns null
156
+ * when the projection is clean.
157
+ */
158
+ export async function projectPlacementForLint(patchesDir, plan, diff) {
159
+ const baseCtx = await buildPatchQueueContext(patchesDir);
160
+ const projectedEntries = baseCtx.entries.map((entry) => {
161
+ const rename = plan.renameMap.get(entry.filename);
162
+ if (!rename)
163
+ return entry;
164
+ return { ...entry, filename: rename.newFilename, order: rename.newOrder };
165
+ });
166
+ // Synthetic entry for the pending patch, populated with both its
167
+ // new-file content AND its added-line content for files it modifies
168
+ // so the forward-import rule can inspect imports the patch *itself*
169
+ // authors — whether they live in a brand-new file or are added to an
170
+ // existing file. Leaving either map empty lets a patch land before
171
+ // one of its own dependencies and still pass the gate.
172
+ projectedEntries.push({
173
+ filename: plan.newFilename,
174
+ order: plan.insertionOrder,
175
+ metadata: null,
176
+ diff,
177
+ newFiles: buildNewFilesFromDiff(diff),
178
+ modifiedFileAdditions: buildModifiedFileAdditionsFromDiff(diff),
179
+ });
180
+ projectedEntries.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
181
+ const projectedIssues = lintPatchQueue({ entries: projectedEntries }).filter((i) => i.severity === 'error');
182
+ if (projectedIssues.length === 0)
183
+ return null;
184
+ return {
185
+ reason: `placement would introduce ${projectedIssues.length} cross-patch lint error(s)`,
186
+ details: projectedIssues.map((i) => `[${i.check}] ${i.file}: ${i.message}`),
187
+ };
188
+ }
189
+ /**
190
+ * Builds the change-summary lines printed by the placement confirmation.
191
+ */
192
+ export function placementSummary(plan) {
193
+ const summary = [
194
+ `place new patch as ${plan.newFilename} (order ${plan.insertionOrder})`,
195
+ ];
196
+ const sortedRenames = getSortedRenameEntries(plan.renameMap);
197
+ if (sortedRenames.length > 0) {
198
+ summary.push(`${sortedRenames.length} existing patch(es) would be renumbered:`);
199
+ for (const [oldName, rename] of sortedRenames) {
200
+ summary.push(` ${oldName} → ${rename.newFilename}`);
201
+ }
202
+ }
203
+ return summary;
204
+ }
205
+ /**
206
+ * Commits a previously-confirmed placement export under the patch
207
+ * directory lock. Re-resolves the placement plan against the current
208
+ * queue and aborts if anything changed since the preview so the command
209
+ * never applies a silently different rename set than the user saw.
210
+ */
211
+ export async function commitPlacementExport(input) {
212
+ return withPatchDirectoryLock(input.patchesDir, async () => {
213
+ const currentPlan = await resolvePlacementPlan(input.patchesDir, input.options, input.category, input.name);
214
+ if (!placementPlansEqual(currentPlan, input.expectedPlan)) {
215
+ throw new InvalidArgumentError('Patch queue changed while waiting for export confirmation. Re-run the command to recompute placement.', 'export placement');
216
+ }
217
+ const conflicts = await projectPlacementForLint(input.patchesDir, currentPlan, input.diff);
218
+ if (conflicts && input.unsafeOverride !== true) {
219
+ throw new InvalidArgumentError(`Refusing to run export: ${conflicts.reason}. Pass --force-unsafe to override.`, '--force-unsafe');
220
+ }
221
+ // Snapshot pre-mutation state so we can best-effort restore the queue
222
+ // if any of the three steps below fail mid-flight. Mirrors the
223
+ // rollback shape in commitExportedPatch (src/core/patch-export.ts), but
224
+ // inlined because the two rollbacks operate on different state shapes
225
+ // (rename map vs. supersede set) and sharing a helper would be forced.
226
+ const patchPath = join(input.patchesDir, currentPlan.newFilename);
227
+ const originalManifest = await loadPatchesManifest(input.patchesDir);
228
+ const originalNewPatchContent = (await pathExists(patchPath))
229
+ ? await readText(patchPath)
230
+ : null;
231
+ let renumberApplied = false;
232
+ try {
233
+ if (currentPlan.renameMap.size > 0) {
234
+ await renumberPatchesInManifest(input.patchesDir, currentPlan.renameMap);
235
+ renumberApplied = true;
236
+ }
237
+ await writeText(patchPath, input.diff);
238
+ await addPatchToManifest(input.patchesDir, {
239
+ ...input.metadata,
240
+ filename: currentPlan.newFilename,
241
+ order: currentPlan.insertionOrder,
242
+ });
243
+ if (input.onCommitted) {
244
+ try {
245
+ await input.onCommitted(currentPlan);
246
+ }
247
+ catch (hookError) {
248
+ // Mutation has already committed and is not reversible. Warn
249
+ // so operators know the audit trail has a gap, but do not
250
+ // re-throw — that would look like the export itself failed.
251
+ warn(`History log append failed after export committed (export-order, ${currentPlan.newFilename}): ` +
252
+ toError(hookError).message);
253
+ }
254
+ }
255
+ return currentPlan;
256
+ }
257
+ catch (error) {
258
+ // Best-effort rollback. Each restoration step gets its own nested
259
+ // try/catch so a secondary failure warns without masking the
260
+ // original error we are about to rethrow.
261
+ try {
262
+ if (originalNewPatchContent === null) {
263
+ if (await pathExists(patchPath)) {
264
+ await removeFile(patchPath);
265
+ }
266
+ }
267
+ else {
268
+ await writeText(patchPath, originalNewPatchContent);
269
+ }
270
+ }
271
+ catch (rollbackError) {
272
+ warn(`Rollback warning: could not restore new patch file: ${toError(rollbackError).message}`);
273
+ }
274
+ if (renumberApplied) {
275
+ // Invert the forward rename map and re-apply through the same
276
+ // two-phase staging renumber. The oldFilename encodes its
277
+ // original order in the leading digits, so parsing them back
278
+ // avoids tracking a second map during the forward pass.
279
+ const inverseMap = new Map();
280
+ for (const [oldFilename, entry] of currentPlan.renameMap) {
281
+ const oldOrder = parseInt(oldFilename.split('-')[0] ?? '0', 10);
282
+ inverseMap.set(entry.newFilename, {
283
+ newOrder: oldOrder,
284
+ newFilename: oldFilename,
285
+ });
286
+ }
287
+ try {
288
+ await renumberPatchesInManifest(input.patchesDir, inverseMap);
289
+ }
290
+ catch (rollbackError) {
291
+ warn(`Rollback warning: could not invert placement renumber: ${toError(rollbackError).message}`);
292
+ }
293
+ }
294
+ // Belt-and-braces: overwrite the manifest with the original
295
+ // snapshot so a partial addPatchToManifest write (new entry
296
+ // appended but inverse renumber skipped or incomplete) is erased.
297
+ // Safe because by this point the disk filenames should match the
298
+ // original manifest's filenames.
299
+ if (originalManifest) {
300
+ try {
301
+ await savePatchesManifest(input.patchesDir, originalManifest);
302
+ }
303
+ catch (rollbackError) {
304
+ warn(`Rollback warning: could not restore manifest: ${toError(rollbackError).message}`);
305
+ }
306
+ }
307
+ throw error;
308
+ }
309
+ });
310
+ }
311
+ /**
312
+ * Renders the plain (non-placement) dry-run preview: calls planExport,
313
+ * prints the allocated filename + metadata, and with supersede enumerates
314
+ * the per-patch coverage detail that was opaque before this refactor.
315
+ */
316
+ export async function renderDryRunPreview(input) {
317
+ const supersedeDetails = await findAllPatchesForFilesWithDetails(input.patchesDir, input.filesAffected);
318
+ const plan = await planExport({
319
+ patchesDir: input.patchesDir,
320
+ category: input.category,
321
+ name: input.name,
322
+ description: input.description,
323
+ filesAffected: input.filesAffected,
324
+ sourceEsrVersion: input.sourceEsrVersion,
325
+ });
326
+ info(`\n[dry-run] Would write: patches/${plan.patchFilename}`);
327
+ info(` category: ${plan.metadata.category}`);
328
+ info(` order: ${plan.metadata.order}`);
329
+ info(` description: ${plan.metadata.description || '(none)'}`);
330
+ info(` filesAffected (${plan.metadata.filesAffected.length}): ${plan.metadata.filesAffected.join(', ')}`);
331
+ if (supersedeDetails.length > 0) {
332
+ info(`\n[dry-run] Would supersede ${supersedeDetails.length} existing patch(es):`);
333
+ for (const detail of supersedeDetails) {
334
+ info(` - ${detail.patch.filename} (covered by: ${detail.coverage.byFiles.join(', ')})`);
335
+ }
336
+ if (!input.explicitSupersede) {
337
+ warn('Real run would prompt for confirmation or require --supersede in non-interactive mode.');
338
+ }
339
+ }
340
+ else {
341
+ info('\n[dry-run] No patches would be superseded.');
342
+ }
343
+ }
344
+ //# sourceMappingURL=export-flow.js.map