@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.
- package/CHANGELOG.md +93 -1
- package/README.md +126 -239
- package/dist/bin/fireforge.js +26 -0
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +131 -52
- package/dist/src/commands/bootstrap.js +6 -2
- package/dist/src/commands/build.js +4 -2
- package/dist/src/commands/discard.js +16 -4
- package/dist/src/commands/doctor-furnace.d.ts +8 -0
- package/dist/src/commands/doctor-furnace.js +422 -0
- package/dist/src/commands/doctor.d.ts +115 -0
- package/dist/src/commands/doctor.js +327 -258
- package/dist/src/commands/download.js +16 -1
- package/dist/src/commands/export-all.js +15 -0
- package/dist/src/commands/export-flow.d.ts +91 -0
- package/dist/src/commands/export-flow.js +344 -0
- package/dist/src/commands/export.js +151 -5
- package/dist/src/commands/furnace/apply.d.ts +3 -2
- package/dist/src/commands/furnace/apply.js +169 -36
- package/dist/src/commands/furnace/create.js +162 -52
- package/dist/src/commands/furnace/deploy.js +156 -144
- package/dist/src/commands/furnace/diff.d.ts +8 -4
- package/dist/src/commands/furnace/diff.js +142 -73
- package/dist/src/commands/furnace/index.d.ts +6 -2
- package/dist/src/commands/furnace/index.js +76 -25
- package/dist/src/commands/furnace/init.d.ts +11 -0
- package/dist/src/commands/furnace/init.js +76 -0
- package/dist/src/commands/furnace/list.d.ts +4 -1
- package/dist/src/commands/furnace/list.js +35 -3
- package/dist/src/commands/furnace/override.d.ts +8 -0
- package/dist/src/commands/furnace/override.js +216 -26
- package/dist/src/commands/furnace/preview.js +184 -30
- package/dist/src/commands/furnace/refresh.d.ts +10 -0
- package/dist/src/commands/furnace/refresh.js +268 -0
- package/dist/src/commands/furnace/remove.js +285 -89
- package/dist/src/commands/furnace/rename.d.ts +5 -0
- package/dist/src/commands/furnace/rename.js +308 -0
- package/dist/src/commands/furnace/scan.d.ts +4 -1
- package/dist/src/commands/furnace/scan.js +72 -11
- package/dist/src/commands/furnace/status.js +85 -20
- package/dist/src/commands/furnace/sync.d.ts +12 -0
- package/dist/src/commands/furnace/sync.js +77 -0
- package/dist/src/commands/furnace/validate.d.ts +4 -1
- package/dist/src/commands/furnace/validate.js +99 -3
- package/dist/src/commands/furnace/validation-output.d.ts +24 -1
- package/dist/src/commands/furnace/validation-output.js +93 -1
- package/dist/src/commands/import.js +37 -4
- package/dist/src/commands/lint.js +11 -2
- package/dist/src/commands/manifest.d.ts +39 -0
- package/dist/src/commands/manifest.js +59 -0
- package/dist/src/commands/patch/delete.d.ts +28 -0
- package/dist/src/commands/patch/delete.js +209 -0
- package/dist/src/commands/patch/index.d.ts +17 -0
- package/dist/src/commands/patch/index.js +25 -0
- package/dist/src/commands/patch/reorder.d.ts +30 -0
- package/dist/src/commands/patch/reorder.js +377 -0
- package/dist/src/commands/re-export-files.d.ts +17 -0
- package/dist/src/commands/re-export-files.js +177 -0
- package/dist/src/commands/re-export.js +44 -0
- package/dist/src/commands/rebase/abort.d.ts +1 -1
- package/dist/src/commands/rebase/abort.js +12 -3
- package/dist/src/commands/rebase/confirm.d.ts +3 -3
- package/dist/src/commands/rebase/confirm.js +4 -4
- package/dist/src/commands/rebase/index.js +13 -4
- package/dist/src/commands/reset.js +20 -4
- package/dist/src/commands/run.js +46 -1
- package/dist/src/commands/setup-support.js +5 -5
- package/dist/src/commands/status.js +97 -6
- package/dist/src/commands/test.js +5 -37
- package/dist/src/commands/verify.d.ts +31 -0
- package/dist/src/commands/verify.js +126 -0
- package/dist/src/core/build-prepare.js +40 -16
- package/dist/src/core/destructive.d.ts +96 -0
- package/dist/src/core/destructive.js +137 -0
- package/dist/src/core/diff-hunks.d.ts +73 -0
- package/dist/src/core/diff-hunks.js +268 -0
- package/dist/src/core/firefox.d.ts +1 -1
- package/dist/src/core/firefox.js +1 -1
- package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
- package/dist/src/core/furnace-apply-helpers.js +302 -57
- package/dist/src/core/furnace-apply-output.d.ts +16 -0
- package/dist/src/core/furnace-apply-output.js +57 -0
- package/dist/src/core/furnace-apply.d.ts +21 -3
- package/dist/src/core/furnace-apply.js +260 -29
- package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
- package/dist/src/core/furnace-checksum-utils.js +24 -0
- package/dist/src/core/furnace-config.d.ts +28 -1
- package/dist/src/core/furnace-config.js +180 -17
- package/dist/src/core/furnace-constants.d.ts +22 -0
- package/dist/src/core/furnace-constants.js +36 -0
- package/dist/src/core/furnace-graph-utils.d.ts +11 -0
- package/dist/src/core/furnace-graph-utils.js +94 -0
- package/dist/src/core/furnace-operation.d.ts +108 -0
- package/dist/src/core/furnace-operation.js +220 -0
- package/dist/src/core/furnace-refresh.d.ts +20 -0
- package/dist/src/core/furnace-refresh.js +118 -0
- package/dist/src/core/furnace-registration-ast.d.ts +5 -0
- package/dist/src/core/furnace-registration-ast.js +134 -4
- package/dist/src/core/furnace-registration-remove.d.ts +25 -3
- package/dist/src/core/furnace-registration-remove.js +196 -62
- package/dist/src/core/furnace-registration-validate.d.ts +13 -1
- package/dist/src/core/furnace-registration-validate.js +15 -3
- package/dist/src/core/furnace-registration.d.ts +27 -4
- package/dist/src/core/furnace-registration.js +93 -11
- package/dist/src/core/furnace-rollback.d.ts +11 -0
- package/dist/src/core/furnace-rollback.js +78 -7
- package/dist/src/core/furnace-scanner.d.ts +8 -2
- package/dist/src/core/furnace-scanner.js +152 -55
- package/dist/src/core/furnace-stories.js +7 -5
- package/dist/src/core/furnace-validate-accessibility.js +7 -1
- package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
- package/dist/src/core/furnace-validate-compatibility.js +85 -1
- package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
- package/dist/src/core/furnace-validate-helpers.js +31 -0
- package/dist/src/core/furnace-validate-registration.d.ts +17 -2
- package/dist/src/core/furnace-validate-registration.js +73 -3
- package/dist/src/core/furnace-validate-structure.d.ts +10 -2
- package/dist/src/core/furnace-validate-structure.js +45 -3
- package/dist/src/core/furnace-validate.d.ts +10 -1
- package/dist/src/core/furnace-validate.js +80 -6
- package/dist/src/core/furnace-version-drift.d.ts +55 -0
- package/dist/src/core/furnace-version-drift.js +101 -0
- package/dist/src/core/git-file-ops.d.ts +8 -0
- package/dist/src/core/git-file-ops.js +19 -6
- package/dist/src/core/lint-projection.d.ts +25 -0
- package/dist/src/core/lint-projection.js +44 -0
- package/dist/src/core/mach.d.ts +4 -2
- package/dist/src/core/mach.js +17 -2
- package/dist/src/core/markdown-table.d.ts +104 -0
- package/dist/src/core/markdown-table.js +266 -0
- package/dist/src/core/ownership-table.d.ts +53 -0
- package/dist/src/core/ownership-table.js +144 -0
- package/dist/src/core/patch-apply.d.ts +17 -3
- package/dist/src/core/patch-apply.js +86 -8
- package/dist/src/core/patch-export.d.ts +119 -5
- package/dist/src/core/patch-export.js +183 -25
- package/dist/src/core/patch-lint-cross.d.ts +195 -0
- package/dist/src/core/patch-lint-cross.js +428 -0
- package/dist/src/core/patch-lint-diff.d.ts +33 -0
- package/dist/src/core/patch-lint-diff.js +84 -0
- package/dist/src/core/patch-lint.d.ts +2 -4
- package/dist/src/core/patch-lint.js +12 -50
- package/dist/src/core/patch-lock.js +2 -1
- package/dist/src/core/patch-manifest-io.d.ts +102 -1
- package/dist/src/core/patch-manifest-io.js +270 -2
- package/dist/src/core/patch-manifest-query.d.ts +1 -1
- package/dist/src/core/patch-manifest-query.js +1 -1
- package/dist/src/core/patch-manifest.d.ts +1 -1
- package/dist/src/core/patch-manifest.js +1 -1
- package/dist/src/core/patch-transform.d.ts +12 -0
- package/dist/src/core/patch-transform.js +21 -7
- package/dist/src/core/token-manager.js +67 -69
- package/dist/src/core/wire-destroy.js +6 -3
- package/dist/src/core/wire-init.js +10 -4
- package/dist/src/core/wire-subscript.js +9 -3
- package/dist/src/core/wire-utils.d.ts +52 -5
- package/dist/src/core/wire-utils.js +69 -6
- package/dist/src/errors/base.d.ts +20 -0
- package/dist/src/errors/base.js +24 -0
- package/dist/src/errors/furnace.js +7 -1
- package/dist/src/errors/rebase.js +6 -1
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +125 -4
- package/dist/src/types/commands/patches.d.ts +11 -1
- package/dist/src/types/config.d.ts +1 -1
- package/dist/src/types/furnace.d.ts +55 -1
- package/dist/src/utils/fs.d.ts +12 -0
- package/dist/src/utils/fs.js +30 -1
- package/dist/src/utils/package-root.d.ts +5 -0
- package/dist/src/utils/package-root.js +12 -0
- package/dist/src/utils/process.js +9 -4
- package/dist/src/utils/validation.d.ts +20 -2
- package/dist/src/utils/validation.js +26 -3
- 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
|