@hominis/fireforge 0.39.0 → 0.41.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 +100 -0
- package/README.md +35 -5
- package/dist/bin/fireforge.js +25 -4
- package/dist/build-info.json +9 -0
- package/dist/src/cli.js +27 -3
- package/dist/src/commands/bootstrap.js +5 -1
- package/dist/src/commands/discard.js +2 -22
- package/dist/src/commands/doctor/post-rebase-audit.js +27 -4
- package/dist/src/commands/doctor-check-core.d.ts +20 -0
- package/dist/src/commands/doctor-check-core.js +28 -0
- package/dist/src/commands/doctor-external-toolchains.js +2 -0
- package/dist/src/commands/doctor-furnace-manifest-sync.js +144 -46
- package/dist/src/commands/doctor-furnace.js +13 -11
- package/dist/src/commands/doctor-orphaned-harness.js +2 -1
- package/dist/src/commands/doctor.js +2 -2
- package/dist/src/commands/export-all.js +9 -1
- package/dist/src/commands/export-flow.js +27 -9
- package/dist/src/commands/export-placement-conflicts.d.ts +17 -0
- package/dist/src/commands/export-placement-conflicts.js +74 -0
- package/dist/src/commands/export-placement-gate.js +1 -1
- package/dist/src/commands/export-shared.d.ts +8 -1
- package/dist/src/commands/export-shared.js +30 -10
- package/dist/src/commands/export.js +2 -2
- package/dist/src/commands/furnace/apply.js +4 -3
- package/dist/src/commands/furnace/chrome-doc-remove.js +13 -2
- package/dist/src/commands/furnace/chrome-doc.js +13 -2
- package/dist/src/commands/furnace/create-browser-test.d.ts +5 -0
- package/dist/src/commands/furnace/create-browser-test.js +5 -0
- package/dist/src/commands/furnace/create.js +2 -2
- package/dist/src/commands/furnace/diff.js +2 -1
- package/dist/src/commands/furnace/init.js +4 -6
- package/dist/src/commands/furnace/override.js +2 -2
- package/dist/src/commands/furnace/remove-mutations.d.ts +40 -0
- package/dist/src/commands/furnace/remove-mutations.js +171 -0
- package/dist/src/commands/furnace/remove.js +21 -128
- package/dist/src/commands/furnace/rename-browser-test.js +54 -5
- package/dist/src/commands/furnace/scan.js +27 -36
- package/dist/src/commands/furnace/status.js +30 -28
- package/dist/src/commands/furnace/validate.js +25 -18
- package/dist/src/commands/import.js +6 -20
- package/dist/src/commands/lint-per-patch.js +73 -51
- package/dist/src/commands/lint-per-run-checkjs.d.ts +64 -0
- package/dist/src/commands/lint-per-run-checkjs.js +102 -0
- package/dist/src/commands/lint-report.d.ts +21 -0
- package/dist/src/commands/lint-report.js +62 -0
- package/dist/src/commands/lint.js +26 -22
- package/dist/src/commands/manifest.js +3 -1
- package/dist/src/commands/package.js +9 -16
- package/dist/src/commands/patch/compact.js +5 -5
- package/dist/src/commands/patch/delete.js +5 -5
- package/dist/src/commands/patch/lint-ignore.js +3 -2
- package/dist/src/commands/patch/move-files-create.js +22 -4
- package/dist/src/commands/patch/move-files-into.js +14 -10
- package/dist/src/commands/patch/move-files.js +6 -5
- package/dist/src/commands/patch/rename.d.ts +2 -2
- package/dist/src/commands/patch/rename.js +152 -51
- package/dist/src/commands/patch/reorder.d.ts +9 -0
- package/dist/src/commands/patch/reorder.js +14 -10
- package/dist/src/commands/patch/split-plan.d.ts +13 -6
- package/dist/src/commands/patch/split-plan.js +22 -12
- package/dist/src/commands/patch/split.d.ts +1 -1
- package/dist/src/commands/patch/split.js +23 -9
- package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
- package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
- package/dist/src/commands/patch/staged-dependency.js +112 -14
- package/dist/src/commands/re-export-adjacent.d.ts +34 -0
- package/dist/src/commands/re-export-adjacent.js +105 -0
- package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
- package/dist/src/commands/re-export-bulk-scan.js +142 -2
- package/dist/src/commands/re-export-drift.d.ts +74 -0
- package/dist/src/commands/re-export-drift.js +234 -0
- package/dist/src/commands/re-export-files.js +5 -3
- package/dist/src/commands/re-export-lint.d.ts +97 -0
- package/dist/src/commands/re-export-lint.js +210 -0
- package/dist/src/commands/re-export-options.js +11 -0
- package/dist/src/commands/re-export-register.d.ts +18 -0
- package/dist/src/commands/re-export-register.js +61 -0
- package/dist/src/commands/re-export-scan.js +13 -7
- package/dist/src/commands/re-export-single.d.ts +20 -0
- package/dist/src/commands/re-export-single.js +261 -0
- package/dist/src/commands/re-export.d.ts +0 -4
- package/dist/src/commands/re-export.js +123 -289
- package/dist/src/commands/rebase/abort.js +13 -4
- package/dist/src/commands/rebase/continue.js +11 -4
- package/dist/src/commands/rebase/index.js +19 -29
- package/dist/src/commands/rebase/patch-loop.js +12 -0
- package/dist/src/commands/reset.js +4 -23
- package/dist/src/commands/resolve.js +2 -9
- package/dist/src/commands/run.js +29 -23
- package/dist/src/commands/source.js +3 -9
- package/dist/src/commands/status-check.d.ts +48 -0
- package/dist/src/commands/status-check.js +106 -0
- package/dist/src/commands/status-json.d.ts +31 -0
- package/dist/src/commands/status-json.js +76 -0
- package/dist/src/commands/status-output.d.ts +1 -0
- package/dist/src/commands/status-output.js +10 -1
- package/dist/src/commands/status-ownership.d.ts +52 -0
- package/dist/src/commands/status-ownership.js +57 -0
- package/dist/src/commands/status.js +190 -170
- package/dist/src/commands/test-build-phase.d.ts +19 -0
- package/dist/src/commands/test-build-phase.js +168 -0
- package/dist/src/commands/test-diagnose.js +30 -2
- package/dist/src/commands/test-modes.d.ts +9 -1
- package/dist/src/commands/test-modes.js +46 -13
- package/dist/src/commands/test-register.js +59 -27
- package/dist/src/commands/test-run.d.ts +29 -3
- package/dist/src/commands/test-run.js +59 -7
- package/dist/src/commands/test-verdict.d.ts +32 -0
- package/dist/src/commands/test-verdict.js +48 -0
- package/dist/src/commands/test.d.ts +9 -0
- package/dist/src/commands/test.js +100 -123
- package/dist/src/commands/token-coverage.js +4 -9
- package/dist/src/commands/token.js +4 -1
- package/dist/src/commands/tree.d.ts +19 -0
- package/dist/src/commands/tree.js +320 -0
- package/dist/src/commands/verify.js +2 -2
- package/dist/src/commands/watch.js +22 -21
- package/dist/src/commands/wire.js +1 -1
- package/dist/src/core/ast-utils.d.ts +11 -0
- package/dist/src/core/ast-utils.js +11 -0
- package/dist/src/core/build-audit-platform.js +6 -1
- package/dist/src/core/build-audit-registration.js +2 -0
- package/dist/src/core/build-audit-resolve.d.ts +13 -0
- package/dist/src/core/build-audit-resolve.js +34 -13
- package/dist/src/core/build-audit.js +4 -20
- package/dist/src/core/build-baseline-types.d.ts +29 -8
- package/dist/src/core/build-baseline-types.js +2 -1
- package/dist/src/core/build-baseline.d.ts +10 -2
- package/dist/src/core/build-baseline.js +39 -8
- package/dist/src/core/build-prepare.d.ts +4 -0
- package/dist/src/core/build-prepare.js +7 -1
- package/dist/src/core/config-paths.d.ts +1 -1
- package/dist/src/core/config-paths.js +2 -0
- package/dist/src/core/config-validate-test-toolchains.js +3 -2
- package/dist/src/core/config-validate.js +45 -30
- package/dist/src/core/config.js +16 -3
- package/dist/src/core/coverage-extend.d.ts +89 -0
- package/dist/src/core/coverage-extend.js +188 -0
- package/dist/src/core/destructive.js +3 -1
- package/dist/src/core/discard-baseline.js +18 -4
- package/dist/src/core/engine-precondition.d.ts +35 -0
- package/dist/src/core/engine-precondition.js +50 -0
- package/dist/src/core/engine-session-lock.d.ts +38 -1
- package/dist/src/core/engine-session-lock.js +78 -4
- package/dist/src/core/file-lock.d.ts +7 -0
- package/dist/src/core/file-lock.js +20 -30
- package/dist/src/core/firefox.js +5 -2
- package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
- package/dist/src/core/furnace-apply-helpers.js +12 -3
- package/dist/src/core/furnace-apply-output.js +9 -0
- package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
- package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
- package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
- package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
- package/dist/src/core/furnace-apply.d.ts +16 -4
- package/dist/src/core/furnace-apply.js +39 -43
- package/dist/src/core/furnace-config.js +2 -0
- package/dist/src/core/furnace-css-fragments.js +2 -0
- package/dist/src/core/furnace-jsconfig.js +14 -7
- package/dist/src/core/furnace-registration-ast.js +8 -8
- package/dist/src/core/furnace-registration-remove.js +6 -3
- package/dist/src/core/furnace-registration.js +9 -12
- package/dist/src/core/furnace-rollback.js +1 -1
- package/dist/src/core/furnace-scanner.js +2 -1
- package/dist/src/core/furnace-stale-export.js +2 -1
- package/dist/src/core/furnace-state-persist.d.ts +4 -6
- package/dist/src/core/furnace-validate-compatibility.js +4 -7
- package/dist/src/core/furnace-validate-helpers.js +33 -1
- package/dist/src/core/furnace-validate-registration.js +16 -0
- package/dist/src/core/furnace-validate.js +4 -2
- package/dist/src/core/git-diff.js +37 -2
- package/dist/src/core/lint-cache.d.ts +15 -5
- package/dist/src/core/lint-cache.js +49 -14
- package/dist/src/core/mach-build-artifacts.d.ts +63 -1
- package/dist/src/core/mach-build-artifacts.js +108 -2
- package/dist/src/core/mach-resource-shim.js +1 -0
- package/dist/src/core/marionette-port.d.ts +16 -0
- package/dist/src/core/marionette-port.js +93 -8
- package/dist/src/core/marionette-preflight.js +6 -5
- package/dist/src/core/patch-apply.d.ts +20 -13
- package/dist/src/core/patch-apply.js +32 -33
- package/dist/src/core/patch-export.d.ts +2 -2
- package/dist/src/core/patch-export.js +4 -6
- package/dist/src/core/patch-lint-checkjs.d.ts +42 -2
- package/dist/src/core/patch-lint-checkjs.js +132 -8
- package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
- package/dist/src/core/patch-lint-creators.d.ts +30 -0
- package/dist/src/core/patch-lint-creators.js +45 -0
- package/dist/src/core/patch-lint-cross.d.ts +1 -17
- package/dist/src/core/patch-lint-cross.js +16 -32
- package/dist/src/core/patch-lint-css.js +64 -16
- package/dist/src/core/patch-lint-diff.d.ts +8 -0
- package/dist/src/core/patch-lint-diff.js +31 -0
- package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
- package/dist/src/core/patch-lint-jsdoc.js +15 -5
- package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
- package/dist/src/core/patch-lint-module-registration.js +88 -0
- package/dist/src/core/patch-lint-mozbuild.js +99 -11
- package/dist/src/core/patch-lint-ownership.d.ts +21 -0
- package/dist/src/core/patch-lint-ownership.js +30 -0
- package/dist/src/core/patch-lint-reexports.d.ts +2 -1
- package/dist/src/core/patch-lint-reexports.js +1 -0
- package/dist/src/core/patch-lint.d.ts +31 -0
- package/dist/src/core/patch-lint.js +114 -8
- package/dist/src/core/patch-lock.d.ts +12 -1
- package/dist/src/core/patch-lock.js +40 -4
- package/dist/src/core/patch-manifest-consistency.js +15 -3
- package/dist/src/core/patch-manifest.d.ts +1 -1
- package/dist/src/core/patch-parse.d.ts +8 -0
- package/dist/src/core/patch-parse.js +10 -0
- package/dist/src/core/patch-policy.d.ts +7 -0
- package/dist/src/core/patch-policy.js +4 -1
- package/dist/src/core/patch-transform.d.ts +10 -0
- package/dist/src/core/patch-transform.js +13 -2
- package/dist/src/core/rebase-session.d.ts +44 -2
- package/dist/src/core/rebase-session.js +114 -17
- package/dist/src/core/register-browser-content.js +2 -1
- package/dist/src/core/register-shared-css.js +2 -1
- package/dist/src/core/smoke-patterns.d.ts +24 -16
- package/dist/src/core/smoke-patterns.js +26 -14
- package/dist/src/core/status-binary.d.ts +55 -0
- package/dist/src/core/status-binary.js +124 -0
- package/dist/src/core/status-classify.d.ts +22 -1
- package/dist/src/core/status-classify.js +112 -70
- package/dist/src/core/test-harness-crash.d.ts +32 -1
- package/dist/src/core/test-harness-crash.js +59 -4
- package/dist/src/core/test-path-scope.js +2 -0
- package/dist/src/core/test-stale-check.js +1 -20
- package/dist/src/core/token-category.d.ts +40 -0
- package/dist/src/core/token-category.js +264 -0
- package/dist/src/core/token-manager.d.ts +7 -0
- package/dist/src/core/token-manager.js +48 -196
- package/dist/src/core/toolchain-preflight.js +24 -26
- package/dist/src/core/tree-cow.d.ts +18 -0
- package/dist/src/core/tree-cow.js +71 -0
- package/dist/src/core/tree-guard.d.ts +35 -0
- package/dist/src/core/tree-guard.js +179 -0
- package/dist/src/core/tree-store.d.ts +129 -0
- package/dist/src/core/tree-store.js +452 -0
- package/dist/src/core/typecheck-shim.d.ts +14 -0
- package/dist/src/core/typecheck-shim.js +41 -2
- package/dist/src/core/typecheck.js +2 -1
- package/dist/src/core/wire-destroy.js +14 -3
- package/dist/src/core/wire-init.js +37 -40
- package/dist/src/core/wire-subscript.js +11 -3
- package/dist/src/core/wire-utils.js +4 -4
- package/dist/src/core/xpcshell-appdir.js +3 -0
- package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
- package/dist/src/core/xpcshell-profile-dir.js +45 -0
- package/dist/src/errors/base.d.ts +13 -0
- package/dist/src/errors/base.js +13 -0
- package/dist/src/errors/git.d.ts +8 -0
- package/dist/src/errors/git.js +17 -0
- package/dist/src/errors/rebase.d.ts +13 -0
- package/dist/src/errors/rebase.js +17 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +1 -0
- package/dist/src/types/commands/options.d.ts +119 -3
- package/dist/src/types/commands/patches.d.ts +8 -0
- package/dist/src/types/config.d.ts +19 -0
- package/dist/src/types/furnace.d.ts +7 -0
- package/dist/src/utils/build-info.d.ts +31 -0
- package/dist/src/utils/build-info.js +98 -0
- package/dist/src/utils/concurrency.d.ts +8 -0
- package/dist/src/utils/concurrency.js +21 -0
- package/dist/src/utils/errors.d.ts +39 -0
- package/dist/src/utils/errors.js +55 -0
- package/dist/src/utils/fs.js +5 -12
- package/dist/src/utils/logger.d.ts +14 -2
- package/dist/src/utils/logger.js +43 -13
- package/dist/src/utils/options.d.ts +21 -4
- package/dist/src/utils/options.js +26 -6
- package/dist/src/utils/package-root.js +7 -4
- package/dist/src/utils/process-group.js +13 -2
- package/dist/src/utils/stdio-drain.d.ts +28 -0
- package/dist/src/utils/stdio-drain.js +54 -0
- package/dist/src/utils/validation.d.ts +33 -2
- package/dist/src/utils/validation.js +57 -2
- package/package.json +13 -13
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
4
|
import { join } from 'node:path';
|
|
5
|
+
import { isUnavailableGenerationToken, snapshotEngineGeneration, unavailableGenerationReason, } from '../core/engine-session-lock.js';
|
|
3
6
|
import { createSiblingLockPath, withFileLock } from '../core/file-lock.js';
|
|
4
7
|
import { resolvePatchIdentifier } from '../core/patch-manifest.js';
|
|
5
|
-
import { InvalidArgumentError } from '../errors/base.js';
|
|
6
|
-
import {
|
|
8
|
+
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
9
|
+
import { mapWithConcurrency } from '../utils/concurrency.js';
|
|
10
|
+
import { getNodeErrorCode, toError } from '../utils/errors.js';
|
|
7
11
|
import { readText } from '../utils/fs.js';
|
|
8
12
|
import { normalizeEngineRelativeInput } from './re-export-scan.js';
|
|
13
|
+
/** Concurrency bound for patch-body hashing (matches the classify/lint pools). */
|
|
14
|
+
const PATCH_HASH_CONCURRENCY = 8;
|
|
9
15
|
function isRecord(value) {
|
|
10
16
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
11
17
|
}
|
|
@@ -66,6 +72,140 @@ export async function loadScanFilesAssignments(manifestPath, manifest) {
|
|
|
66
72
|
}
|
|
67
73
|
return new Map([...filesByPatch.entries()].map(([patchFilename, files]) => [patchFilename, [...files].sort()]));
|
|
68
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Fingerprints every piece of state a `re-export --dry-run` promises not to
|
|
77
|
+
* disturb: the engine's commit + working-tree status (via the same generation
|
|
78
|
+
* token `fireforge test` uses) and the byte content of every regular file
|
|
79
|
+
* under `patches/` — ALL of them, not just the selected patches, because the
|
|
80
|
+
* FORGE H1 field incident was a dry-run of patch B reverting a just-written
|
|
81
|
+
* export of patch A. A missing patches directory (ENOENT/ENOTDIR) fingerprints
|
|
82
|
+
* as empty — there is nothing there to protect — but any other listing failure
|
|
83
|
+
* (EACCES, EIO, …) throws: an unreadable directory is not evidence it is
|
|
84
|
+
* empty, and treating it as such would let the guard silently vouch for state
|
|
85
|
+
* it never saw (fail closed). The same rule covers an unmeasurable engine
|
|
86
|
+
* generation and an unreadable individual patch file; a before-pass failure
|
|
87
|
+
* aborts the dry run before it starts, since a guard that measured nothing
|
|
88
|
+
* cannot vouch for anything.
|
|
89
|
+
*/
|
|
90
|
+
async function fingerprintDryRunState(engineDir, patchesDir) {
|
|
91
|
+
const fingerprint = new Map();
|
|
92
|
+
const generation = await snapshotEngineGeneration(engineDir);
|
|
93
|
+
if (isUnavailableGenerationToken(generation)) {
|
|
94
|
+
// A failed probe measured nothing — hashing the failure token would let
|
|
95
|
+
// two identical failures compare "unchanged" (vouching for unmeasured
|
|
96
|
+
// state) and two differing messages report a spurious violation.
|
|
97
|
+
throw new GeneralError(`[dry-run] cannot fingerprint the engine working tree for the purity guard: ` +
|
|
98
|
+
unavailableGenerationReason(generation));
|
|
99
|
+
}
|
|
100
|
+
fingerprint.set('the engine working tree', createHash('sha256').update(generation).digest('hex'));
|
|
101
|
+
let names = [];
|
|
102
|
+
try {
|
|
103
|
+
const entries = await readdir(patchesDir, { withFileTypes: true });
|
|
104
|
+
names = entries
|
|
105
|
+
.filter((entry) => entry.isFile())
|
|
106
|
+
.map((entry) => entry.name)
|
|
107
|
+
.sort();
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
// No patches directory — nothing there to protect; the same condition on
|
|
111
|
+
// the post-run pass produces the same empty set, and a directory that
|
|
112
|
+
// appears mid-dry-run correctly reports as a violation. Any OTHER failure
|
|
113
|
+
// must not read as "empty": the guard would be vouching for state it
|
|
114
|
+
// never saw.
|
|
115
|
+
const code = getNodeErrorCode(error);
|
|
116
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
117
|
+
throw new GeneralError(`[dry-run] cannot fingerprint ${patchesDir} for the purity guard: ${toError(error).message}`, error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const results = await mapWithConcurrency(names, PATCH_HASH_CONCURRENCY, async (name) => {
|
|
121
|
+
try {
|
|
122
|
+
return {
|
|
123
|
+
hash: createHash('sha256')
|
|
124
|
+
.update(await readFile(join(patchesDir, name)))
|
|
125
|
+
.digest('hex'),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
// A file that vanished between the listing and the read is omitted, so
|
|
130
|
+
// a dry run that DELETED a patch artifact reports as the violation it
|
|
131
|
+
// is (key present before, absent after) instead of hiding behind a
|
|
132
|
+
// fingerprinting error. Every other failure fails closed: a constant
|
|
133
|
+
// placeholder would make unreadable-before equal unreadable-after even
|
|
134
|
+
// when the bytes changed underneath.
|
|
135
|
+
const code = getNodeErrorCode(error);
|
|
136
|
+
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
137
|
+
return { vanished: true };
|
|
138
|
+
return { error };
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
for (const [index, name] of names.entries()) {
|
|
142
|
+
const result = results[index];
|
|
143
|
+
if (result === undefined)
|
|
144
|
+
continue;
|
|
145
|
+
if ('error' in result) {
|
|
146
|
+
throw new GeneralError(`[dry-run] cannot fingerprint ${join(patchesDir, name)} for the purity guard: ${toError(result.error).message}`, result.error);
|
|
147
|
+
}
|
|
148
|
+
if ('vanished' in result)
|
|
149
|
+
continue;
|
|
150
|
+
fingerprint.set(`patches/${name}`, result.hash);
|
|
151
|
+
}
|
|
152
|
+
return fingerprint;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Runtime enforcement of the dry-run purity contract (FORGE H1): fingerprints
|
|
156
|
+
* the engine tree and every patch artifact before and after `operation`, and
|
|
157
|
+
* throws when a dry-run changed anything it promised only to inspect. This
|
|
158
|
+
* turns any recurrence of the 0.40.0 "dry-run reverted a just-written export"
|
|
159
|
+
* incident into a hard, named failure instead of silent data loss discovered
|
|
160
|
+
* only when a later gate fails. The post-fingerprint runs whether `operation`
|
|
161
|
+
* resolves or rejects — a dry-run that mutates state and THEN fails is exactly
|
|
162
|
+
* the case where a rollback defect must not go unreported; a rejection with a
|
|
163
|
+
* purity violation surfaces the violation with the original error as `cause`.
|
|
164
|
+
* No-op outside dry-run.
|
|
165
|
+
*/
|
|
166
|
+
export async function withDryRunPurityGuard(engineDir, patchesDir, isDryRun, operation) {
|
|
167
|
+
if (!isDryRun)
|
|
168
|
+
return operation();
|
|
169
|
+
const before = await fingerprintDryRunState(engineDir, patchesDir);
|
|
170
|
+
let result;
|
|
171
|
+
let operationError;
|
|
172
|
+
let failed = false;
|
|
173
|
+
try {
|
|
174
|
+
result = await operation();
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
failed = true;
|
|
178
|
+
operationError = error;
|
|
179
|
+
}
|
|
180
|
+
let after;
|
|
181
|
+
try {
|
|
182
|
+
after = await fingerprintDryRunState(engineDir, patchesDir);
|
|
183
|
+
}
|
|
184
|
+
catch (fingerprintError) {
|
|
185
|
+
// The post-run fingerprint failing must not swallow the dry run's own
|
|
186
|
+
// failure — an operation that mutated state, broke it, AND made it
|
|
187
|
+
// unreadable is exactly the case where both facts matter. The combined
|
|
188
|
+
// message carries both; the operation error stays the `cause`.
|
|
189
|
+
if (failed) {
|
|
190
|
+
throw new GeneralError(`[dry-run] this dry run failed (${toError(operationError).message}), and its purity could ` +
|
|
191
|
+
`not be verified afterwards: ${toError(fingerprintError).message}. Inspect engine/ and ` +
|
|
192
|
+
'your patches/ diff before trusting them.', operationError);
|
|
193
|
+
}
|
|
194
|
+
throw fingerprintError;
|
|
195
|
+
}
|
|
196
|
+
const touched = [...new Set([...before.keys(), ...after.keys()])]
|
|
197
|
+
.sort()
|
|
198
|
+
.filter((key) => before.get(key) !== after.get(key));
|
|
199
|
+
if (touched.length > 0) {
|
|
200
|
+
throw new GeneralError(`[dry-run] invariant violated: this dry run modified ${touched.join(', ')}. ` +
|
|
201
|
+
'This is a FireForge bug — please report it, and inspect the listed state ' +
|
|
202
|
+
'(e.g. `git status` in engine/ and your patches/ diff) before trusting it.' +
|
|
203
|
+
(failed ? ` The dry run also failed: ${toError(operationError).message}` : ''), operationError);
|
|
204
|
+
}
|
|
205
|
+
if (failed)
|
|
206
|
+
throw operationError;
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
69
209
|
/** Serializes dry-run re-export git inspection against the same project tree. */
|
|
70
210
|
export async function withDryRunReExportLock(fireforgeDir, isDryRun, operation) {
|
|
71
211
|
if (!isDryRun)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foreign-drift preview + `--refuse-foreign-drift` gate for `re-export`
|
|
3
|
+
* (FORGE J2).
|
|
4
|
+
*
|
|
5
|
+
* A scan-less re-export rebuilds each patch body as a whole-file
|
|
6
|
+
* `git diff HEAD` over the owned files — so a CONCURRENT session's
|
|
7
|
+
* uncommitted lines inside a file the patch already owns are silently
|
|
8
|
+
* absorbed into the refreshed body. The adjacency advisory
|
|
9
|
+
* (`re-export-adjacent.ts`) warns about unmanaged NEIGHBOUR files but is
|
|
10
|
+
* structurally blind to drift inside owned files. This module compares
|
|
11
|
+
* the old body against the refreshed body per file and reports the
|
|
12
|
+
* payload lines that are about to ENTER the body; under
|
|
13
|
+
* `--refuse-foreign-drift` the patch is skipped and the run exits
|
|
14
|
+
* non-zero, mirroring `--refuse-adjacent-unmanaged`.
|
|
15
|
+
*
|
|
16
|
+
* "Foreign" is decided by content, not authorship: re-export's legitimate
|
|
17
|
+
* job is capturing intentional engine edits, so the preview always prints
|
|
18
|
+
* and the hard stop is an explicit opt-in for multi-session checkouts.
|
|
19
|
+
* The comparison is offset-insensitive — per-file MULTISETS of `+`/`-`
|
|
20
|
+
* payload lines — because a refresh legitimately shifts hunk offsets and
|
|
21
|
+
* context without changing what the patch does.
|
|
22
|
+
*/
|
|
23
|
+
import type { PatchMetadata } from '../types/commands/index.js';
|
|
24
|
+
/** Why a patch was refused under `--refuse-foreign-drift`. */
|
|
25
|
+
export type ForeignDriftRefusalReason = 'foreign-drift' | 'baseline-unreadable';
|
|
26
|
+
/** Per-run context threaded through the re-export loop (mirrors FORGE G2). */
|
|
27
|
+
export interface ForeignDriftContext {
|
|
28
|
+
refuseForeignDrift: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Engine-relative files whose drift is expected (`--expect`, FORGE L6):
|
|
31
|
+
* drift confined to these files does not refuse. Files here that never
|
|
32
|
+
* drift are reported after the run as likely typos.
|
|
33
|
+
*/
|
|
34
|
+
expectedDriftFiles: ReadonlySet<string>;
|
|
35
|
+
/** Expected files that actually drifted at least once this run. */
|
|
36
|
+
expectedSeen: Set<string>;
|
|
37
|
+
/** Number of patches whose old/new bodies reached the drift comparison. */
|
|
38
|
+
evaluationRuns: number;
|
|
39
|
+
refusals: {
|
|
40
|
+
patchFilename: string;
|
|
41
|
+
files: string[];
|
|
42
|
+
reason: ForeignDriftRefusalReason;
|
|
43
|
+
}[];
|
|
44
|
+
}
|
|
45
|
+
/** One owned file's foreign drift between the old and refreshed body. */
|
|
46
|
+
export interface FileForeignDrift {
|
|
47
|
+
file: string;
|
|
48
|
+
/** `+` payload lines in the new body not present in the old body. */
|
|
49
|
+
addedLines: number;
|
|
50
|
+
/** `-` payload lines in the new body not present in the old body. */
|
|
51
|
+
removedLines: number;
|
|
52
|
+
/** `@@` headers of new-body hunks carrying foreign lines (capped). */
|
|
53
|
+
hunkSummaries: string[];
|
|
54
|
+
/** True when a binary section's recorded new-side blob hash changed. */
|
|
55
|
+
binaryChanged: boolean;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Pure comparison: per owned file, which `+`/`-` payload lines the
|
|
59
|
+
* refreshed body carries that the old body did not. Files NOT in
|
|
60
|
+
* `previousFilesAffected` are skipped — those are intentional adoptions
|
|
61
|
+
* (`--scan`), not drift.
|
|
62
|
+
*/
|
|
63
|
+
export declare function computeForeignDrift(oldBody: string, newBody: string, previousFilesAffected: readonly string[]): FileForeignDrift[];
|
|
64
|
+
/**
|
|
65
|
+
* Reads the old body, prints the always-on drift preview (including under
|
|
66
|
+
* dry-run and `--scan`), and — when `ctx.refuseForeignDrift` is set —
|
|
67
|
+
* records a refusal and returns `true` so the caller skips the write.
|
|
68
|
+
*/
|
|
69
|
+
export declare function reportForeignDrift(args: {
|
|
70
|
+
patch: PatchMetadata;
|
|
71
|
+
patchesDir: string;
|
|
72
|
+
newDiffContent: string;
|
|
73
|
+
ctx: ForeignDriftContext;
|
|
74
|
+
}): Promise<boolean>;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Foreign-drift preview + `--refuse-foreign-drift` gate for `re-export`
|
|
4
|
+
* (FORGE J2).
|
|
5
|
+
*
|
|
6
|
+
* A scan-less re-export rebuilds each patch body as a whole-file
|
|
7
|
+
* `git diff HEAD` over the owned files — so a CONCURRENT session's
|
|
8
|
+
* uncommitted lines inside a file the patch already owns are silently
|
|
9
|
+
* absorbed into the refreshed body. The adjacency advisory
|
|
10
|
+
* (`re-export-adjacent.ts`) warns about unmanaged NEIGHBOUR files but is
|
|
11
|
+
* structurally blind to drift inside owned files. This module compares
|
|
12
|
+
* the old body against the refreshed body per file and reports the
|
|
13
|
+
* payload lines that are about to ENTER the body; under
|
|
14
|
+
* `--refuse-foreign-drift` the patch is skipped and the run exits
|
|
15
|
+
* non-zero, mirroring `--refuse-adjacent-unmanaged`.
|
|
16
|
+
*
|
|
17
|
+
* "Foreign" is decided by content, not authorship: re-export's legitimate
|
|
18
|
+
* job is capturing intentional engine edits, so the preview always prints
|
|
19
|
+
* and the hard stop is an explicit opt-in for multi-session checkouts.
|
|
20
|
+
* The comparison is offset-insensitive — per-file MULTISETS of `+`/`-`
|
|
21
|
+
* payload lines — because a refresh legitimately shifts hunk offsets and
|
|
22
|
+
* context without changing what the patch does.
|
|
23
|
+
*/
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { parseDiffSections } from '../core/patch-parse.js';
|
|
26
|
+
import { toError } from '../utils/errors.js';
|
|
27
|
+
import { pathExists, readText } from '../utils/fs.js';
|
|
28
|
+
import { info, verbose, warn } from '../utils/logger.js';
|
|
29
|
+
const MAX_HUNK_SUMMARIES_PER_FILE = 3;
|
|
30
|
+
const MAX_FILES_REPORTED = 10;
|
|
31
|
+
function collectPayloadByFile(body) {
|
|
32
|
+
const byFile = new Map();
|
|
33
|
+
for (const section of parseDiffSections(body)) {
|
|
34
|
+
const file = section.targetPath;
|
|
35
|
+
let payload = byFile.get(file);
|
|
36
|
+
if (!payload) {
|
|
37
|
+
payload = {
|
|
38
|
+
added: new Map(),
|
|
39
|
+
removed: new Map(),
|
|
40
|
+
binaryNewHash: undefined,
|
|
41
|
+
isBinary: false,
|
|
42
|
+
sections: [],
|
|
43
|
+
};
|
|
44
|
+
byFile.set(file, payload);
|
|
45
|
+
}
|
|
46
|
+
payload.sections.push(section);
|
|
47
|
+
if (section.isBinary) {
|
|
48
|
+
payload.isBinary = true;
|
|
49
|
+
payload.binaryNewHash = section.indexNewHash;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
for (const hunk of section.hunks) {
|
|
53
|
+
for (const line of hunk.lines) {
|
|
54
|
+
if (line.startsWith('+')) {
|
|
55
|
+
const text = line.slice(1);
|
|
56
|
+
payload.added.set(text, (payload.added.get(text) ?? 0) + 1);
|
|
57
|
+
}
|
|
58
|
+
else if (line.startsWith('-')) {
|
|
59
|
+
const text = line.slice(1);
|
|
60
|
+
payload.removed.set(text, (payload.removed.get(text) ?? 0) + 1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return byFile;
|
|
66
|
+
}
|
|
67
|
+
/** Multiset difference: occurrences of `next` not consumed by `previous`. */
|
|
68
|
+
function multisetExcess(next, previous) {
|
|
69
|
+
const excess = new Map();
|
|
70
|
+
for (const [text, count] of next) {
|
|
71
|
+
const remaining = count - (previous.get(text) ?? 0);
|
|
72
|
+
if (remaining > 0)
|
|
73
|
+
excess.set(text, remaining);
|
|
74
|
+
}
|
|
75
|
+
return excess;
|
|
76
|
+
}
|
|
77
|
+
function summarizeForeignHunks(payload, foreignAdded, foreignRemoved) {
|
|
78
|
+
const summaries = [];
|
|
79
|
+
const addedBudget = new Map(foreignAdded);
|
|
80
|
+
const removedBudget = new Map(foreignRemoved);
|
|
81
|
+
for (const section of payload.sections) {
|
|
82
|
+
for (const hunk of section.hunks) {
|
|
83
|
+
let added = 0;
|
|
84
|
+
let removed = 0;
|
|
85
|
+
for (const line of hunk.lines) {
|
|
86
|
+
if (line.startsWith('+')) {
|
|
87
|
+
const text = line.slice(1);
|
|
88
|
+
const budget = addedBudget.get(text) ?? 0;
|
|
89
|
+
if (budget > 0) {
|
|
90
|
+
addedBudget.set(text, budget - 1);
|
|
91
|
+
added++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (line.startsWith('-')) {
|
|
95
|
+
const text = line.slice(1);
|
|
96
|
+
const budget = removedBudget.get(text) ?? 0;
|
|
97
|
+
if (budget > 0) {
|
|
98
|
+
removedBudget.set(text, budget - 1);
|
|
99
|
+
removed++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (added > 0 || removed > 0) {
|
|
104
|
+
summaries.push(`@@ -${String(hunk.oldStart)},${String(hunk.oldCount)} +${String(hunk.newStart)},${String(hunk.newCount)} @@ (+${String(added)}/-${String(removed)})`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return summaries;
|
|
109
|
+
}
|
|
110
|
+
function countExcess(excess) {
|
|
111
|
+
let total = 0;
|
|
112
|
+
for (const count of excess.values())
|
|
113
|
+
total += count;
|
|
114
|
+
return total;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Pure comparison: per owned file, which `+`/`-` payload lines the
|
|
118
|
+
* refreshed body carries that the old body did not. Files NOT in
|
|
119
|
+
* `previousFilesAffected` are skipped — those are intentional adoptions
|
|
120
|
+
* (`--scan`), not drift.
|
|
121
|
+
*/
|
|
122
|
+
export function computeForeignDrift(oldBody, newBody, previousFilesAffected) {
|
|
123
|
+
const owned = new Set(previousFilesAffected);
|
|
124
|
+
const oldByFile = collectPayloadByFile(oldBody);
|
|
125
|
+
const newByFile = collectPayloadByFile(newBody);
|
|
126
|
+
const drift = [];
|
|
127
|
+
for (const [file, next] of newByFile) {
|
|
128
|
+
if (!owned.has(file))
|
|
129
|
+
continue;
|
|
130
|
+
const previous = oldByFile.get(file);
|
|
131
|
+
if (next.isBinary || previous?.isBinary === true) {
|
|
132
|
+
const binaryChanged = next.binaryNewHash === undefined ||
|
|
133
|
+
previous?.binaryNewHash === undefined ||
|
|
134
|
+
!(next.binaryNewHash.startsWith(previous.binaryNewHash) ||
|
|
135
|
+
previous.binaryNewHash.startsWith(next.binaryNewHash));
|
|
136
|
+
if (binaryChanged) {
|
|
137
|
+
drift.push({ file, addedLines: 0, removedLines: 0, hunkSummaries: [], binaryChanged });
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const foreignAdded = multisetExcess(next.added, previous?.added ?? new Map());
|
|
142
|
+
const foreignRemoved = multisetExcess(next.removed, previous?.removed ?? new Map());
|
|
143
|
+
const addedLines = countExcess(foreignAdded);
|
|
144
|
+
const removedLines = countExcess(foreignRemoved);
|
|
145
|
+
if (addedLines === 0 && removedLines === 0)
|
|
146
|
+
continue;
|
|
147
|
+
drift.push({
|
|
148
|
+
file,
|
|
149
|
+
addedLines,
|
|
150
|
+
removedLines,
|
|
151
|
+
hunkSummaries: summarizeForeignHunks(next, foreignAdded, foreignRemoved).slice(0, MAX_HUNK_SUMMARIES_PER_FILE),
|
|
152
|
+
binaryChanged: false,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return drift;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Reads the old body, prints the always-on drift preview (including under
|
|
159
|
+
* dry-run and `--scan`), and — when `ctx.refuseForeignDrift` is set —
|
|
160
|
+
* records a refusal and returns `true` so the caller skips the write.
|
|
161
|
+
*/
|
|
162
|
+
export async function reportForeignDrift(args) {
|
|
163
|
+
const { patch, patchesDir, newDiffContent, ctx } = args;
|
|
164
|
+
const oldPath = join(patchesDir, patch.filename);
|
|
165
|
+
let oldBody;
|
|
166
|
+
try {
|
|
167
|
+
if (!(await pathExists(oldPath))) {
|
|
168
|
+
return refuseUnreadableBaseline(ctx, patch.filename, 'old patch body missing');
|
|
169
|
+
}
|
|
170
|
+
oldBody = await readText(oldPath);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
return refuseUnreadableBaseline(ctx, patch.filename, `old body unreadable (${toError(error).message})`);
|
|
174
|
+
}
|
|
175
|
+
const drift = computeForeignDrift(oldBody, newDiffContent, patch.filesAffected);
|
|
176
|
+
ctx.evaluationRuns += 1;
|
|
177
|
+
if (drift.length === 0)
|
|
178
|
+
return false;
|
|
179
|
+
const unexpected = drift.filter((d) => !ctx.expectedDriftFiles.has(d.file));
|
|
180
|
+
for (const entry of drift) {
|
|
181
|
+
if (ctx.expectedDriftFiles.has(entry.file))
|
|
182
|
+
ctx.expectedSeen.add(entry.file);
|
|
183
|
+
}
|
|
184
|
+
const totalLines = drift.reduce((sum, d) => sum + d.addedLines + d.removedLines, 0);
|
|
185
|
+
const lineNoun = totalLines === 1 ? 'line' : 'lines';
|
|
186
|
+
warn(`${patch.filename}: refreshed body absorbs ${String(totalLines)} ${lineNoun} not present in the old patch body (${String(drift.length)} file(s)):`);
|
|
187
|
+
for (const entry of drift.slice(0, MAX_FILES_REPORTED)) {
|
|
188
|
+
const expectedTag = ctx.expectedDriftFiles.has(entry.file) ? ' (expected via --expect)' : '';
|
|
189
|
+
if (entry.binaryChanged) {
|
|
190
|
+
info(` ${entry.file}: binary content changed${expectedTag}`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
info(` ${entry.file}: +${String(entry.addedLines)}/-${String(entry.removedLines)} newly captured line(s)${expectedTag}`);
|
|
194
|
+
for (const summary of entry.hunkSummaries) {
|
|
195
|
+
info(` ${summary}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (drift.length > MAX_FILES_REPORTED) {
|
|
199
|
+
info(` +${String(drift.length - MAX_FILES_REPORTED)} more file(s)`);
|
|
200
|
+
}
|
|
201
|
+
if (ctx.refuseForeignDrift) {
|
|
202
|
+
if (unexpected.length === 0) {
|
|
203
|
+
info(`${patch.filename}: drift confined to --expect file(s); proceeding under --refuse-foreign-drift.`);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
ctx.refusals.push({
|
|
207
|
+
patchFilename: patch.filename,
|
|
208
|
+
files: unexpected.map((d) => d.file),
|
|
209
|
+
reason: 'foreign-drift',
|
|
210
|
+
});
|
|
211
|
+
warn(`${patch.filename}: skipped (--refuse-foreign-drift).`);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
warn(' If these are intentional engine edits, this is the normal re-export flow. ' +
|
|
215
|
+
'FireForge cannot infer authorship; if another session owns them, re-run with ' +
|
|
216
|
+
"--refuse-foreign-drift, or commit/stash that session's edits first.");
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Fail-closed baseline handling (FORGE L6): a missing or unreadable old
|
|
221
|
+
* patch body means the drift comparison CANNOT run, so under
|
|
222
|
+
* `--refuse-foreign-drift` the patch is refused rather than written on the
|
|
223
|
+
* strength of a check that never happened. Without the flag this stays the
|
|
224
|
+
* historical advisory skip (the preview is best-effort there).
|
|
225
|
+
*/
|
|
226
|
+
function refuseUnreadableBaseline(ctx, patchFilename, detail) {
|
|
227
|
+
if (!ctx.refuseForeignDrift) {
|
|
228
|
+
verbose(`Skipping foreign-drift preview for ${patchFilename}: ${detail}`);
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
ctx.refusals.push({ patchFilename, files: [], reason: 'baseline-unreadable' });
|
|
232
|
+
warn(`${patchFilename}: skipped (--refuse-foreign-drift) — ${detail}; the drift comparison cannot run, refusing fail-closed.`);
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
@@ -7,7 +7,7 @@ import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
|
7
7
|
import { computeProjectedLintRegressions } from '../core/lint-projection.js';
|
|
8
8
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
9
9
|
import { updatePatchAndMetadata } from '../core/patch-export.js';
|
|
10
|
-
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
|
|
10
|
+
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, formatPatchLintIssue, lintPatchQueue, } from '../core/patch-lint.js';
|
|
11
11
|
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
12
12
|
import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
|
|
13
13
|
import { extractNewFileContentFromDiff } from '../core/patch-transform.js';
|
|
@@ -73,7 +73,7 @@ async function runProjectedCrossPatchLint(patchesDir, targetFilename, projectedD
|
|
|
73
73
|
return null;
|
|
74
74
|
return {
|
|
75
75
|
reason: `projected --files state introduces ${regressions.length} new cross-patch lint error(s)`,
|
|
76
|
-
details: regressions.map(
|
|
76
|
+
details: regressions.map(formatPatchLintIssue),
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
/**
|
|
@@ -117,7 +117,9 @@ async function confirmFilesModeProjection(args) {
|
|
|
117
117
|
for (const line of summary) {
|
|
118
118
|
info(` ${line}`);
|
|
119
119
|
}
|
|
120
|
-
throw new InvalidArgumentError(`Refusing to re-export ${target.filename} with --files because it would remove ${removed.length} existing patch-owned file${removed.length === 1 ? '' : 's'}. Run again with --allow-shrink after reviewing the dry-run output
|
|
120
|
+
throw new InvalidArgumentError(`Refusing to re-export ${target.filename} with --files because it would remove ${removed.length} existing patch-owned file${removed.length === 1 ? '' : 's'}. Run again with --allow-shrink after reviewing the dry-run output. ` +
|
|
121
|
+
'If the intent is to move those files into their own patch instead, ' +
|
|
122
|
+
'"fireforge patch move-files <from> <to> --file <path> --create --order <n>" does the shrink and the new patch in one transaction.', '--allow-shrink');
|
|
121
123
|
}
|
|
122
124
|
return confirmDestructive({
|
|
123
125
|
operation: 're-export-files',
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hoisted + cached lint context for `re-export` (FORGE J1).
|
|
3
|
+
*
|
|
4
|
+
* The per-patch loop used to rebuild the whole patch-queue context AND a
|
|
5
|
+
* fresh queue-wide checkJs TypeScript program for every patch — ~37 s of
|
|
6
|
+
* fixed setup per patch (measured on a 290-patch consumer queue), so an
|
|
7
|
+
* N-patch re-export cost N×37 s. This module:
|
|
8
|
+
*
|
|
9
|
+
* - builds the queue context and the {@link PerRunCheckJs} program
|
|
10
|
+
* controller ONCE per invocation (`buildReExportLintContext`) and slices
|
|
11
|
+
* per-patch findings out of it (the FORGE I3 shape `move-files`/`split`
|
|
12
|
+
* already use, plus the program-level hoist `lint --per-patch` has);
|
|
13
|
+
* - reuses the existing per-patch lint RESULT cache
|
|
14
|
+
* (`.fireforge/lint-cache/per-patch-v1.json`) across invocations —
|
|
15
|
+
* re-export lints the identical input `lint --per-patch` does (the
|
|
16
|
+
* fresh `git diff HEAD` of the owned files), and the key already
|
|
17
|
+
* hashes engine HEAD, per-file content, patch body, config, shims, and
|
|
18
|
+
* ownership, so a repeat single re-export is a warm hit (~4 s instead
|
|
19
|
+
* of ~41 s). `--no-cache` opts out; run-level checkJs globals are
|
|
20
|
+
* never cached (FORGE F5) and are emitted once per invocation;
|
|
21
|
+
* - keeps the in-memory queue context honest after each write
|
|
22
|
+
* (`refreshQueueCtxEntry`), so later iterations of an `--all` run lint
|
|
23
|
+
* against the just-refreshed body instead of the stale one.
|
|
24
|
+
*/
|
|
25
|
+
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
26
|
+
import { type PerPatchLintCacheFile } from '../core/lint-cache.js';
|
|
27
|
+
import { type PatchQueueContext } from '../core/patch-lint.js';
|
|
28
|
+
import type { PatchLintIssue, PatchMetadata } from '../types/commands/index.js';
|
|
29
|
+
import type { FireForgeConfig } from '../types/config.js';
|
|
30
|
+
import { type PerRunCheckJs } from './lint-per-run-checkjs.js';
|
|
31
|
+
/** Per-invocation lint context, built once and threaded through the loop. */
|
|
32
|
+
export interface ReExportLintContext {
|
|
33
|
+
projectRoot: string;
|
|
34
|
+
paths: ReturnType<typeof getProjectPaths>;
|
|
35
|
+
config: FireForgeConfig;
|
|
36
|
+
/** Whole-queue context, built WITH config so the forward-import hints
|
|
37
|
+
* match `lint --per-patch` (FORGE F14); undefined when no patches dir. */
|
|
38
|
+
patchQueueCtx: PatchQueueContext | undefined;
|
|
39
|
+
/** Queue-wide checkJs controller; undefined when checkJs is off. */
|
|
40
|
+
checkJs: PerRunCheckJs | undefined;
|
|
41
|
+
/** Per-patch result cache; undefined under `--no-cache`. */
|
|
42
|
+
cache: PerPatchLintCacheFile | undefined;
|
|
43
|
+
engineHeadSha: string | undefined;
|
|
44
|
+
cacheDirty: boolean;
|
|
45
|
+
reusedCacheEntries: number;
|
|
46
|
+
/** Run-level checkJs globals are emitted once per invocation. */
|
|
47
|
+
globalsEmitted: boolean;
|
|
48
|
+
}
|
|
49
|
+
/** Builds the once-per-invocation lint context (FORGE J1). */
|
|
50
|
+
export declare function buildReExportLintContext(projectRoot: string, paths: ReturnType<typeof getProjectPaths>, config: Awaited<ReturnType<typeof loadConfig>>, noCache: boolean): Promise<ReExportLintContext>;
|
|
51
|
+
/** Fresh lint payload carried to the post-write cache store. */
|
|
52
|
+
export interface ReExportLintResult {
|
|
53
|
+
issues: PatchLintIssue[];
|
|
54
|
+
suppressed: PatchLintIssue[];
|
|
55
|
+
lineCount: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Lints one re-exported patch body using the hoisted context: cache hit →
|
|
59
|
+
* replay the stored issues through the shared reporter (returns `null`);
|
|
60
|
+
* miss → lint with the queue-wide program's pre-attributed slices (or a
|
|
61
|
+
* per-patch build when the fresh diff creates files the hoisted program
|
|
62
|
+
* never saw — the `--scan` adoption case) and return the payload for
|
|
63
|
+
* {@link storeReExportLintResult}. Throws exactly like `runPatchLint` on
|
|
64
|
+
* errors (unless `skipLint`).
|
|
65
|
+
*
|
|
66
|
+
* The cache key is built over the PROJECTED metadata (current
|
|
67
|
+
* filesAffected, merged lintIgnore, effective tier) so `--tier` /
|
|
68
|
+
* `--lint-ignore` flags change the key. The entry itself is stored AFTER
|
|
69
|
+
* the body write (the key hashes the on-disk `.patch` file, which this
|
|
70
|
+
* very run is about to rewrite — a pre-write store could never match a
|
|
71
|
+
* later run).
|
|
72
|
+
*/
|
|
73
|
+
export declare function lintReExportedPatch(args: {
|
|
74
|
+
lintCtx: ReExportLintContext;
|
|
75
|
+
patch: PatchMetadata;
|
|
76
|
+
projectedMetadata: PatchMetadata;
|
|
77
|
+
existingFiles: string[];
|
|
78
|
+
diffContent: string;
|
|
79
|
+
skipLint?: boolean;
|
|
80
|
+
ignoreChecks?: ReadonlySet<string>;
|
|
81
|
+
patchTier?: 'branding';
|
|
82
|
+
}): Promise<ReExportLintResult | null>;
|
|
83
|
+
/**
|
|
84
|
+
* Stores a fresh lint result AFTER the body write, keying against the
|
|
85
|
+
* just-written `.patch` file and the refreshed queue context — the state
|
|
86
|
+
* a later identical invocation will observe (call
|
|
87
|
+
* {@link refreshQueueCtxEntry} first).
|
|
88
|
+
*/
|
|
89
|
+
export declare function storeReExportLintResult(lintCtx: ReExportLintContext, patchFilename: string, projectedMetadata: PatchMetadata, existingFiles: string[], result: ReExportLintResult): Promise<void>;
|
|
90
|
+
/**
|
|
91
|
+
* Refreshes the in-memory queue entry for a just-rewritten patch so later
|
|
92
|
+
* iterations (and later cache keys) lint against the new body instead of
|
|
93
|
+
* the stale one. The on-disk write is the authority; this mirrors it.
|
|
94
|
+
*/
|
|
95
|
+
export declare function refreshQueueCtxEntry(lintCtx: ReExportLintContext, patchFilename: string, diffContent: string): void;
|
|
96
|
+
/** Persists cache writes and reports reuse, once after the loop. */
|
|
97
|
+
export declare function finishReExportLint(lintCtx: ReExportLintContext): Promise<void>;
|