@hominis/fireforge 0.49.2 → 0.50.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 +40 -1
- package/README.md +1 -1
- package/dist/bin/fireforge.js +6 -0
- package/dist/build-info.json +4 -4
- package/dist/src/commands/build-prelock.d.ts +20 -0
- package/dist/src/commands/build-prelock.js +50 -0
- package/dist/src/commands/build.d.ts +3 -1
- package/dist/src/commands/build.js +13 -6
- package/dist/src/commands/check-report.d.ts +28 -0
- package/dist/src/commands/check-report.js +78 -0
- package/dist/src/commands/check-run.d.ts +28 -0
- package/dist/src/commands/check-run.js +104 -0
- package/dist/src/commands/check-steps.d.ts +67 -0
- package/dist/src/commands/check-steps.js +175 -0
- package/dist/src/commands/check.d.ts +32 -0
- package/dist/src/commands/check.js +160 -0
- package/dist/src/commands/config.js +5 -6
- package/dist/src/commands/doctor-engine-eslint.js +6 -1
- package/dist/src/commands/doctor-source-pin.js +4 -1
- package/dist/src/commands/export-adopt-check.d.ts +38 -0
- package/dist/src/commands/export-adopt-check.js +98 -0
- package/dist/src/commands/export-flow.js +29 -14
- package/dist/src/commands/export-placement-gate.d.ts +6 -2
- package/dist/src/commands/export-shared.d.ts +2 -1
- package/dist/src/commands/export-staged-registration.d.ts +46 -0
- package/dist/src/commands/export-staged-registration.js +91 -0
- package/dist/src/commands/forward-import-remedy.d.ts +32 -0
- package/dist/src/commands/forward-import-remedy.js +63 -0
- package/dist/src/commands/furnace/apply.js +3 -3
- package/dist/src/commands/furnace/chrome-doc-remove.d.ts +2 -1
- package/dist/src/commands/furnace/chrome-doc-remove.js +2 -2
- package/dist/src/commands/furnace/chrome-doc.d.ts +2 -1
- package/dist/src/commands/furnace/chrome-doc.js +2 -2
- package/dist/src/commands/furnace/create.js +2 -2
- package/dist/src/commands/furnace/deploy-named.d.ts +48 -0
- package/dist/src/commands/furnace/deploy-named.js +107 -0
- package/dist/src/commands/furnace/deploy.d.ts +3 -2
- package/dist/src/commands/furnace/deploy.js +50 -86
- package/dist/src/commands/furnace/diff.js +26 -3
- package/dist/src/commands/furnace/index.js +66 -41
- package/dist/src/commands/furnace/init.js +13 -6
- package/dist/src/commands/furnace/override.js +4 -2
- package/dist/src/commands/furnace/preview.js +4 -3
- package/dist/src/commands/furnace/refresh.js +2 -2
- package/dist/src/commands/furnace/remove.js +4 -3
- package/dist/src/commands/furnace/rename-dry-run.d.ts +29 -0
- package/dist/src/commands/furnace/rename-dry-run.js +127 -0
- package/dist/src/commands/furnace/rename-helpers.d.ts +7 -0
- package/dist/src/commands/furnace/rename-helpers.js +15 -1
- package/dist/src/commands/furnace/rename-registrations.d.ts +41 -0
- package/dist/src/commands/furnace/rename-registrations.js +93 -0
- package/dist/src/commands/furnace/rename.d.ts +2 -1
- package/dist/src/commands/furnace/rename.js +28 -66
- package/dist/src/commands/furnace/scan.d.ts +9 -4
- package/dist/src/commands/furnace/scan.js +11 -9
- package/dist/src/commands/furnace/sync.js +3 -2
- package/dist/src/commands/furnace/validate-report.d.ts +45 -0
- package/dist/src/commands/furnace/validate-report.js +91 -0
- package/dist/src/commands/furnace/validate.d.ts +1 -1
- package/dist/src/commands/furnace/validate.js +127 -114
- package/dist/src/commands/furnace/validation-output.js +14 -3
- package/dist/src/commands/lint-per-patch.js +31 -60
- package/dist/src/commands/lint-report.d.ts +8 -4
- package/dist/src/commands/lint-report.js +15 -5
- package/dist/src/commands/lint.js +15 -34
- package/dist/src/commands/manifest.js +2 -0
- package/dist/src/commands/patch/delete-gap-notice.js +3 -1
- package/dist/src/commands/patch/delete.d.ts +1 -1
- package/dist/src/commands/patch/delete.js +13 -98
- package/dist/src/commands/patch/index.d.ts +1 -1
- package/dist/src/commands/patch/index.js +4 -2
- package/dist/src/commands/patch/lint-ignore.d.ts +5 -1
- package/dist/src/commands/patch/lint-ignore.js +65 -28
- package/dist/src/commands/patch/move-files-create.js +26 -17
- package/dist/src/commands/patch/move-files-into.js +5 -3
- package/dist/src/commands/patch/move-files.js +3 -1
- package/dist/src/commands/patch/removal-dependents.d.ts +63 -0
- package/dist/src/commands/patch/removal-dependents.js +133 -0
- package/dist/src/commands/patch/rename.js +7 -0
- package/dist/src/commands/patch/retire.d.ts +34 -0
- package/dist/src/commands/patch/retire.js +128 -0
- package/dist/src/commands/patch/split-plan.d.ts +7 -7
- package/dist/src/commands/patch/split-plan.js +45 -28
- package/dist/src/commands/patch/split-staged.d.ts +108 -0
- package/dist/src/commands/patch/split-staged.js +202 -0
- package/dist/src/commands/patch/split.d.ts +5 -0
- package/dist/src/commands/patch/split.js +51 -15
- package/dist/src/commands/patch/staged-dependency-batch.d.ts +48 -0
- package/dist/src/commands/patch/staged-dependency-batch.js +176 -0
- package/dist/src/commands/patch/staged-dependency-entries.d.ts +36 -0
- package/dist/src/commands/patch/staged-dependency-entries.js +52 -0
- package/dist/src/commands/patch/staged-dependency-from.d.ts +14 -0
- package/dist/src/commands/patch/staged-dependency-from.js +108 -0
- package/dist/src/commands/patch/staged-dependency.js +14 -41
- package/dist/src/commands/re-export-adjacent-registrations.d.ts +20 -0
- package/dist/src/commands/re-export-adjacent-registrations.js +75 -0
- package/dist/src/commands/re-export-adjacent.d.ts +45 -2
- package/dist/src/commands/re-export-adjacent.js +156 -37
- package/dist/src/commands/re-export-drift.d.ts +36 -8
- package/dist/src/commands/re-export-drift.js +73 -49
- package/dist/src/commands/re-export-files.js +11 -8
- package/dist/src/commands/re-export-lint.d.ts +2 -1
- package/dist/src/commands/re-export-lint.js +23 -10
- package/dist/src/commands/re-export-options.js +45 -4
- package/dist/src/commands/re-export-preflight.d.ts +23 -0
- package/dist/src/commands/re-export-preflight.js +160 -0
- package/dist/src/commands/re-export-register.js +42 -17
- package/dist/src/commands/re-export-removed.d.ts +12 -1
- package/dist/src/commands/re-export-removed.js +41 -11
- package/dist/src/commands/re-export-scan-owner.d.ts +44 -0
- package/dist/src/commands/re-export-scan-owner.js +116 -0
- package/dist/src/commands/re-export-scan.d.ts +17 -9
- package/dist/src/commands/re-export-scan.js +56 -23
- package/dist/src/commands/re-export-single.js +22 -6
- package/dist/src/commands/re-export-summary.d.ts +28 -0
- package/dist/src/commands/re-export-summary.js +36 -0
- package/dist/src/commands/re-export.js +37 -24
- package/dist/src/commands/rebase/index.js +17 -17
- package/dist/src/commands/run.js +16 -2
- package/dist/src/commands/status-check.d.ts +55 -16
- package/dist/src/commands/status-check.js +92 -37
- package/dist/src/commands/status-json.d.ts +22 -9
- package/dist/src/commands/status-json.js +47 -32
- package/dist/src/commands/status-output.js +3 -1
- package/dist/src/commands/status-scope.d.ts +37 -0
- package/dist/src/commands/status-scope.js +54 -0
- package/dist/src/commands/status.js +66 -23
- package/dist/src/commands/test-browser-preflight.js +19 -3
- package/dist/src/commands/test-build-furnace.d.ts +21 -0
- package/dist/src/commands/test-build-furnace.js +48 -0
- package/dist/src/commands/test-build-phase.js +47 -41
- package/dist/src/commands/test-checkjs.js +7 -0
- package/dist/src/commands/test-diagnose.js +7 -0
- package/dist/src/commands/test-modes.d.ts +16 -1
- package/dist/src/commands/test-modes.js +24 -1
- package/dist/src/commands/test-multi-verdict.d.ts +61 -0
- package/dist/src/commands/test-multi-verdict.js +111 -0
- package/dist/src/commands/test-orchestrate.d.ts +43 -0
- package/dist/src/commands/test-orchestrate.js +222 -0
- package/dist/src/commands/test-perf-scope.d.ts +29 -0
- package/dist/src/commands/test-perf-scope.js +83 -0
- package/dist/src/commands/test-prelock.d.ts +51 -0
- package/dist/src/commands/test-prelock.js +177 -0
- package/dist/src/commands/test-register.js +54 -10
- package/dist/src/commands/test-run.js +4 -1
- package/dist/src/commands/test-stale-gate.js +57 -27
- package/dist/src/commands/test-verdict.d.ts +10 -1
- package/dist/src/commands/test-verdict.js +28 -1
- package/dist/src/commands/test.d.ts +6 -1
- package/dist/src/commands/test.js +66 -60
- package/dist/src/commands/token-lint.d.ts +14 -0
- package/dist/src/commands/token-lint.js +111 -0
- package/dist/src/commands/token.js +10 -0
- package/dist/src/commands/verify-scope.d.ts +28 -0
- package/dist/src/commands/verify-scope.js +67 -0
- package/dist/src/commands/verify.d.ts +10 -2
- package/dist/src/commands/verify.js +59 -35
- package/dist/src/core/build-baseline-types.d.ts +46 -0
- package/dist/src/core/build-baseline-validate.js +19 -0
- package/dist/src/core/build-baseline.d.ts +5 -1
- package/dist/src/core/build-baseline.js +3 -1
- package/dist/src/core/build-overwrite-guard.d.ts +50 -0
- package/dist/src/core/build-overwrite-guard.js +84 -4
- package/dist/src/core/build-prepare.d.ts +7 -0
- package/dist/src/core/build-prepare.js +14 -8
- package/dist/src/core/build-run-record.d.ts +74 -0
- package/dist/src/core/build-run-record.js +136 -0
- package/dist/src/core/checkjs-support-heads.d.ts +41 -0
- package/dist/src/core/checkjs-support-heads.js +104 -0
- package/dist/src/core/config-paths.d.ts +2 -2
- package/dist/src/core/config-paths.js +8 -0
- package/dist/src/core/config-validate-check.d.ts +8 -0
- package/dist/src/core/config-validate-check.js +143 -0
- package/dist/src/core/config-validate-file-size.d.ts +9 -6
- package/dist/src/core/config-validate-file-size.js +91 -2
- package/dist/src/core/config-validate-history.d.ts +11 -0
- package/dist/src/core/config-validate-history.js +38 -0
- package/dist/src/core/config-validate-patch-policy.js +100 -0
- package/dist/src/core/config-validate-test-toolchains.js +7 -0
- package/dist/src/core/config-validate-token-scales.d.ts +6 -0
- package/dist/src/core/config-validate-token-scales.js +50 -0
- package/dist/src/core/config-validate.js +14 -5
- package/dist/src/core/config.d.ts +5 -0
- package/dist/src/core/config.js +16 -0
- package/dist/src/core/coverage-dispatch.d.ts +24 -0
- package/dist/src/core/coverage-dispatch.js +44 -0
- package/dist/src/core/coverage-extend.d.ts +70 -45
- package/dist/src/core/coverage-extend.js +199 -144
- package/dist/src/core/coverage-records.d.ts +97 -0
- package/dist/src/core/coverage-records.js +350 -0
- package/dist/src/core/destructive.d.ts +7 -4
- package/dist/src/core/destructive.js +10 -10
- package/dist/src/core/engine-session-lock.d.ts +10 -1
- package/dist/src/core/engine-session-lock.js +28 -8
- package/dist/src/core/export-moved-code-shape.d.ts +49 -11
- package/dist/src/core/export-moved-code-shape.js +135 -44
- package/dist/src/core/file-lock-queue.d.ts +67 -0
- package/dist/src/core/file-lock-queue.js +221 -0
- package/dist/src/core/file-lock.d.ts +4 -25
- package/dist/src/core/file-lock.js +105 -138
- package/dist/src/core/furnace-apply-output.d.ts +10 -0
- package/dist/src/core/furnace-apply-output.js +31 -1
- package/dist/src/core/furnace-apply.js +12 -2
- package/dist/src/core/furnace-composed-source.d.ts +76 -0
- package/dist/src/core/furnace-composed-source.js +167 -0
- package/dist/src/core/furnace-config-validate-exceptions.d.ts +9 -0
- package/dist/src/core/furnace-config-validate-exceptions.js +71 -0
- package/dist/src/core/furnace-config-validate.js +8 -0
- package/dist/src/core/furnace-css-fragments.d.ts +10 -0
- package/dist/src/core/furnace-css-fragments.js +26 -0
- package/dist/src/core/furnace-operation.d.ts +3 -3
- package/dist/src/core/furnace-registration-remove.d.ts +22 -1
- package/dist/src/core/furnace-registration-remove.js +53 -17
- package/dist/src/core/furnace-registration-rename.d.ts +26 -0
- package/dist/src/core/furnace-registration-rename.js +77 -0
- package/dist/src/core/furnace-registration.d.ts +14 -4
- package/dist/src/core/furnace-registration.js +44 -10
- package/dist/src/core/furnace-staleness.d.ts +7 -0
- package/dist/src/core/furnace-staleness.js +34 -24
- package/dist/src/core/furnace-validate-accessibility.d.ts +4 -2
- package/dist/src/core/furnace-validate-accessibility.js +10 -1
- package/dist/src/core/furnace-validate-exceptions.d.ts +40 -0
- package/dist/src/core/furnace-validate-exceptions.js +43 -0
- package/dist/src/core/furnace-validate-icon-button.d.ts +7 -0
- package/dist/src/core/furnace-validate-icon-button.js +49 -0
- package/dist/src/core/furnace-validate.js +1 -1
- package/dist/src/core/harness-port-fallback.d.ts +60 -0
- package/dist/src/core/harness-port-fallback.js +96 -0
- package/dist/src/core/history-log-file.d.ts +61 -0
- package/dist/src/core/history-log-file.js +157 -0
- package/dist/src/core/history-log.d.ts +2 -1
- package/dist/src/core/history-log.js +2 -1
- package/dist/src/core/json-preserving-edit.d.ts +22 -0
- package/dist/src/core/json-preserving-edit.js +199 -0
- package/dist/src/core/mach.js +8 -1
- package/dist/src/core/negative-control-diff.d.ts +39 -0
- package/dist/src/core/negative-control-diff.js +136 -0
- package/dist/src/core/negative-control.d.ts +138 -0
- package/dist/src/core/negative-control.js +356 -0
- package/dist/src/core/objdir-install-manifests.d.ts +73 -0
- package/dist/src/core/objdir-install-manifests.js +226 -0
- package/dist/src/core/patch-export-update.d.ts +2 -0
- package/dist/src/core/patch-export-update.js +1 -0
- package/dist/src/core/patch-export.js +3 -4
- package/dist/src/core/patch-lint-checkjs-any-import.d.ts +37 -0
- package/dist/src/core/patch-lint-checkjs-any-import.js +61 -0
- package/dist/src/core/patch-lint-checkjs.js +8 -11
- package/dist/src/core/patch-lint-code-lines.d.ts +59 -0
- package/dist/src/core/patch-lint-code-lines.js +105 -0
- package/dist/src/core/patch-lint-cross.js +12 -4
- package/dist/src/core/patch-lint-css.d.ts +8 -2
- package/dist/src/core/patch-lint-css.js +10 -4
- package/dist/src/core/patch-lint-file-size.d.ts +12 -5
- package/dist/src/core/patch-lint-file-size.js +22 -11
- package/dist/src/core/patch-lint-forward-registration-extended.d.ts +18 -0
- package/dist/src/core/patch-lint-forward-registration-extended.js +35 -10
- package/dist/src/core/patch-lint-forward-registration.js +10 -0
- package/dist/src/core/patch-lint-ignore.d.ts +152 -0
- package/dist/src/core/patch-lint-ignore.js +299 -0
- package/dist/src/core/patch-lint-size-thresholds.d.ts +52 -0
- package/dist/src/core/patch-lint-size-thresholds.js +110 -0
- package/dist/src/core/patch-lint-size.d.ts +117 -0
- package/dist/src/core/patch-lint-size.js +323 -0
- package/dist/src/core/patch-lint-staged-registration.d.ts +18 -1
- package/dist/src/core/patch-lint-staged-registration.js +20 -0
- package/dist/src/core/patch-lint-token-scale.d.ts +39 -0
- package/dist/src/core/patch-lint-token-scale.js +109 -0
- package/dist/src/core/patch-lint.d.ts +11 -83
- package/dist/src/core/patch-lint.js +64 -286
- package/dist/src/core/patch-lock.js +7 -6
- package/dist/src/core/patch-manifest-consistency.js +7 -6
- package/dist/src/core/patch-manifest-io.d.ts +6 -3
- package/dist/src/core/patch-manifest-io.js +19 -8
- package/dist/src/core/patch-manifest-validate.js +34 -2
- package/dist/src/core/patch-order-legal.d.ts +25 -0
- package/dist/src/core/patch-order-legal.js +83 -0
- package/dist/src/core/patch-policy-content.d.ts +40 -0
- package/dist/src/core/patch-policy-content.js +216 -0
- package/dist/src/core/patch-policy.d.ts +16 -3
- package/dist/src/core/patch-policy.js +16 -9
- package/dist/src/core/patch-retired.d.ts +35 -0
- package/dist/src/core/patch-retired.js +54 -0
- package/dist/src/core/patch-subset.d.ts +16 -0
- package/dist/src/core/patch-subset.js +66 -0
- package/dist/src/core/preflight-wait.d.ts +20 -0
- package/dist/src/core/preflight-wait.js +38 -0
- package/dist/src/core/rebase-from-version.d.ts +46 -0
- package/dist/src/core/rebase-from-version.js +71 -0
- package/dist/src/core/run-log.d.ts +20 -0
- package/dist/src/core/run-log.js +89 -3
- package/dist/src/core/run-profile.d.ts +7 -0
- package/dist/src/core/run-profile.js +7 -1
- package/dist/src/core/smoke-patterns.d.ts +5 -0
- package/dist/src/core/smoke-patterns.js +13 -0
- package/dist/src/core/test-harness-crash.d.ts +18 -1
- package/dist/src/core/test-harness-crash.js +6 -4
- package/dist/src/core/test-harness-signature.d.ts +17 -0
- package/dist/src/core/test-harness-signature.js +4 -0
- package/dist/src/core/test-run-completeness.d.ts +57 -0
- package/dist/src/core/test-run-completeness.js +144 -0
- package/dist/src/core/test-stale-check.d.ts +27 -1
- package/dist/src/core/test-stale-check.js +39 -49
- package/dist/src/core/test-stale-exempt.d.ts +40 -0
- package/dist/src/core/test-stale-exempt.js +70 -0
- package/dist/src/core/token-lint.d.ts +50 -0
- package/dist/src/core/token-lint.js +126 -0
- package/dist/src/core/tree-guard.js +3 -0
- package/dist/src/core/typecheck-shim.d.ts +1 -1
- package/dist/src/core/typecheck-shim.js +90 -2
- package/dist/src/index.d.ts +8 -3
- package/dist/src/index.js +3 -1
- package/dist/src/types/commands/index.d.ts +2 -2
- package/dist/src/types/commands/options.d.ts +148 -21
- package/dist/src/types/commands/patches.d.ts +75 -1
- package/dist/src/types/config.d.ts +192 -0
- package/dist/src/types/config.js +9 -0
- package/dist/src/types/furnace.d.ts +43 -1
- package/dist/src/types/furnace.js +7 -0
- package/dist/src/types/index.d.ts +1 -1
- package/dist/src/utils/options.d.ts +34 -6
- package/dist/src/utils/options.js +86 -5
- package/dist/src/utils/path-glob.d.ts +15 -0
- package/dist/src/utils/path-glob.js +47 -0
- package/dist/src/utils/regex.d.ts +15 -3
- package/dist/src/utils/regex.js +37 -16
- package/docs/configuration.md +282 -9
- package/docs/exit-codes.md +18 -1
- package/docs/furnace.md +97 -3
- package/docs/lifecycle-invariants.md +52 -19
- package/docs/machine-output.md +74 -12
- package/docs/patch-workflow.md +297 -22
- package/docs/run-logs.md +20 -1
- package/docs/testing.md +276 -20
- package/package.json +1 -1
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { mkdir,
|
|
4
|
-
import { dirname
|
|
3
|
+
import { mkdir, rename, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
5
|
import { LockContentionError } from '../errors/base.js';
|
|
6
|
-
import { getNodeErrorCode,
|
|
6
|
+
import { getNodeErrorCode, toError } from '../utils/errors.js';
|
|
7
7
|
import { ensureDir } from '../utils/fs.js';
|
|
8
8
|
import { verbose, warn } from '../utils/logger.js';
|
|
9
9
|
import { sleep } from '../utils/sleep.js';
|
|
10
10
|
import { isLockOwnerAlive, LOCK_PID_FILE, readLockOwner, writeLockOwner, } from './file-lock-owner.js';
|
|
11
|
+
import { readLockQueue, registerWaiter, } from './file-lock-queue.js';
|
|
11
12
|
export { LOCK_PID_FILE };
|
|
13
|
+
export { formatLockQueuePosition } from './file-lock-queue.js';
|
|
12
14
|
const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
|
|
13
15
|
const DEFAULT_LOCK_POLL_MS = 50;
|
|
14
16
|
const DEFAULT_STALE_LOCK_MS = 5 * 60_000;
|
|
@@ -30,46 +32,6 @@ const WAIT_PROBE_INTERVAL_MS = 5_000;
|
|
|
30
32
|
export function createSiblingLockPath(filePath, suffix = '.fireforge.lock') {
|
|
31
33
|
return `${filePath}${suffix}`;
|
|
32
34
|
}
|
|
33
|
-
/** Sibling directory in which waiters advertise themselves. */
|
|
34
|
-
function lockWaitersDir(lockPath) {
|
|
35
|
-
return `${lockPath}.waiters`;
|
|
36
|
-
}
|
|
37
|
-
/** Parsed waiter-file name: `<startedAtMs>-<pid>-<uuid>`. */
|
|
38
|
-
function parseWaiterFileName(name) {
|
|
39
|
-
const match = /^(\d+)-(\d+)-/.exec(name);
|
|
40
|
-
const startedAt = match?.[1];
|
|
41
|
-
const pid = match?.[2];
|
|
42
|
-
if (startedAt === undefined || pid === undefined)
|
|
43
|
-
return undefined;
|
|
44
|
-
return { startedAt: Number.parseInt(startedAt, 10), pid: Number.parseInt(pid, 10) };
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Reads the live waiter queue around `lockPath`, dropping entries whose
|
|
48
|
-
* process is gone (a killed waiter must not inflate the depth forever).
|
|
49
|
-
*
|
|
50
|
-
* Advisory and fail-open: an unreadable registry reports an empty queue
|
|
51
|
-
* rather than failing whatever asked.
|
|
52
|
-
*
|
|
53
|
-
* @param lockPath - The lock directory whose queue to inspect
|
|
54
|
-
* @param selfStartedAt - This process's wait start, to compute `ahead`
|
|
55
|
-
*/
|
|
56
|
-
export async function readLockQueue(lockPath, selfStartedAt) {
|
|
57
|
-
let names;
|
|
58
|
-
try {
|
|
59
|
-
names = await readdir(lockWaitersDir(lockPath));
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
return { depth: 0, ahead: 0 };
|
|
63
|
-
}
|
|
64
|
-
const live = names
|
|
65
|
-
.map(parseWaiterFileName)
|
|
66
|
-
.filter((entry) => entry !== undefined)
|
|
67
|
-
.filter((entry) => isProcessAlive(entry.pid));
|
|
68
|
-
const ahead = selfStartedAt === undefined
|
|
69
|
-
? 0
|
|
70
|
-
: live.filter((entry) => entry.startedAt < selfStartedAt).length;
|
|
71
|
-
return { depth: live.length, ahead };
|
|
72
|
-
}
|
|
73
35
|
/**
|
|
74
36
|
* Inspects a lock without acquiring it. Read-only and fail-open: a missing
|
|
75
37
|
* lock is reported as free, never as an error.
|
|
@@ -92,29 +54,6 @@ export async function readLockStatus(lockPath) {
|
|
|
92
54
|
: undefined;
|
|
93
55
|
return { held: true, holder, heldForMs, queueDepth: queue.depth };
|
|
94
56
|
}
|
|
95
|
-
/**
|
|
96
|
-
* Registers this process in the lock's waiter queue. Returns a
|
|
97
|
-
* deregistration function that is always safe to call (including when
|
|
98
|
-
* registration itself failed).
|
|
99
|
-
*/
|
|
100
|
-
async function registerWaiter(lockPath, startedAt) {
|
|
101
|
-
const dir = lockWaitersDir(lockPath);
|
|
102
|
-
const file = join(dir, `${startedAt}-${process.pid}-${randomUUID()}`);
|
|
103
|
-
try {
|
|
104
|
-
await ensureDir(dir);
|
|
105
|
-
await writeFile(file, `${process.pid}\n`, 'utf-8');
|
|
106
|
-
}
|
|
107
|
-
catch (error) {
|
|
108
|
-
// Advisory only. Never fail a lock acquisition over the queue display.
|
|
109
|
-
verbose(`Could not register lock waiter for ${lockPath}: ${toError(error).message}`);
|
|
110
|
-
return async () => {
|
|
111
|
-
/* nothing to remove */
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
return async () => {
|
|
115
|
-
await rm(file, { force: true });
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
57
|
/**
|
|
119
58
|
* Reads the contended lock's owner and queue in one go. One probe per cycle
|
|
120
59
|
* serves both the progress line and the deadline extension, so the two can
|
|
@@ -122,12 +61,12 @@ async function registerWaiter(lockPath, startedAt) {
|
|
|
122
61
|
* degrades to `holder: undefined`, since a diagnostic must never break the
|
|
123
62
|
* wait loop.
|
|
124
63
|
*/
|
|
125
|
-
async function probeWaitState(lockPath,
|
|
64
|
+
async function probeWaitState(lockPath, self) {
|
|
126
65
|
const owner = await readLockOwner(lockPath);
|
|
127
66
|
const holder = owner.present
|
|
128
67
|
? { pid: owner.pid, alive: isLockOwnerAlive(owner), metadata: owner.metadata }
|
|
129
68
|
: undefined;
|
|
130
|
-
return { holder, queue: await readLockQueue(lockPath,
|
|
69
|
+
return { holder, queue: await readLockQueue(lockPath, self) };
|
|
131
70
|
}
|
|
132
71
|
/**
|
|
133
72
|
* Reaps a lock directory believed to be stale, safely against concurrent
|
|
@@ -345,16 +284,24 @@ function formatQueuePositionReached(queue) {
|
|
|
345
284
|
* caller asked for it. Split out of the wait loop so the loop stays a
|
|
346
285
|
* readable state machine.
|
|
347
286
|
*/
|
|
348
|
-
async function runWaitProbe(lockPath,
|
|
349
|
-
const { holder, queue } = await probeWaitState(lockPath,
|
|
287
|
+
async function runWaitProbe(lockPath, self, timeoutMs, options) {
|
|
288
|
+
const { holder, queue } = await probeWaitState(lockPath, self);
|
|
350
289
|
options.onWaitProgress?.({
|
|
351
|
-
waitedMs: Date.now() - startedAt,
|
|
290
|
+
waitedMs: Date.now() - self.startedAt,
|
|
352
291
|
timeoutMs,
|
|
353
292
|
holder,
|
|
354
293
|
queue,
|
|
355
294
|
});
|
|
356
295
|
return { queue };
|
|
357
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Whether it is this waiter's turn to contend for the lock: no live waiter
|
|
299
|
+
* arrived before it. Ties and dead or stalled tickets are settled by
|
|
300
|
+
* {@link readLockQueue}, so every waiter agrees on who is at the head.
|
|
301
|
+
*/
|
|
302
|
+
async function isWaiterTurn(lockPath, startedAt, waiter) {
|
|
303
|
+
return (await readLockQueue(lockPath, { startedAt, ticket: waiter.ticket })).ahead === 0;
|
|
304
|
+
}
|
|
358
305
|
/**
|
|
359
306
|
* Lock directories this process currently holds.
|
|
360
307
|
*
|
|
@@ -404,8 +351,14 @@ export async function withFileLock(lockPath, operation, options = {}) {
|
|
|
404
351
|
let lastProbeAt = startedAt;
|
|
405
352
|
/** Best (lowest) queue position seen so far. Used to detect an advance. */
|
|
406
353
|
let bestAhead;
|
|
407
|
-
/**
|
|
408
|
-
|
|
354
|
+
/**
|
|
355
|
+
* This waiter's ticket in the queue. Taken on first contention, or up
|
|
356
|
+
* front when others are already queued, so an uncontended acquire never
|
|
357
|
+
* writes one.
|
|
358
|
+
*/
|
|
359
|
+
let waiter;
|
|
360
|
+
/** The last `EEXIST`, carried as the cause of a timeout refusal. */
|
|
361
|
+
let contention;
|
|
409
362
|
// One cadence drives both the progress line and the deadline extension.
|
|
410
363
|
// Either feature alone is enough to want the probe. Neither is worth a
|
|
411
364
|
// second timer.
|
|
@@ -414,83 +367,97 @@ export async function withFileLock(lockPath, operation, options = {}) {
|
|
|
414
367
|
extend !== undefined;
|
|
415
368
|
await ensureDir(dirname(lockPath));
|
|
416
369
|
try {
|
|
370
|
+
// Arrival order: a newcomer that finds others already queued takes a
|
|
371
|
+
// ticket behind them instead of racing them for the next free moment.
|
|
372
|
+
// Without this a fresh command could take the lock in the gap between
|
|
373
|
+
// the holder's release and the head waiter's next poll.
|
|
374
|
+
if ((await readLockQueue(lockPath)).depth > 0) {
|
|
375
|
+
waiter = await registerWaiter(lockPath, startedAt);
|
|
376
|
+
}
|
|
417
377
|
for (;;) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
378
|
+
// Only the head of the queue contends for the lock. A waiter without
|
|
379
|
+
// a ticket (no contention seen yet, or a registry that could not be
|
|
380
|
+
// written) contends as every waiter did before tickets existed.
|
|
381
|
+
if (waiter?.ticket === undefined || (await isWaiterTurn(lockPath, startedAt, waiter))) {
|
|
382
|
+
try {
|
|
383
|
+
await mkdir(lockPath);
|
|
384
|
+
break;
|
|
425
385
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
deregisterWaiter ??= await registerWaiter(lockPath, startedAt);
|
|
430
|
-
if (lastStaleProbeAt === undefined || Date.now() - lastStaleProbeAt >= staleReprobeMs) {
|
|
431
|
-
lastStaleProbeAt = Date.now();
|
|
432
|
-
if (await removeIfStaleLock(lockPath, staleMs, options.onStaleLockMessage)) {
|
|
433
|
-
continue;
|
|
386
|
+
catch (error) {
|
|
387
|
+
if (getNodeErrorCode(error) !== 'EEXIST') {
|
|
388
|
+
throw error;
|
|
434
389
|
}
|
|
390
|
+
contention = error;
|
|
435
391
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
// originally requested: once an extension has been granted, the
|
|
447
|
-
// requested figure is stale, and a progress line that keeps
|
|
448
|
-
// quoting it reads as a timeout that does not fire.
|
|
449
|
-
const probe = await runWaitProbe(lockPath, startedAt, deadline - startedAt, options);
|
|
450
|
-
// An advance (someone ahead of us took their turn and finished)
|
|
451
|
-
// buys a fresh budget, because the wait is working. Only a queue
|
|
452
|
-
// that stops moving starves, which is the case the timeout is for.
|
|
453
|
-
//
|
|
454
|
-
// The first observation is a seed, never an advance. `bestAhead`
|
|
455
|
-
// used to start `undefined`, so the opening probe always satisfied
|
|
456
|
-
// this test and handed out a free full budget: a `--wait-lock 300`
|
|
457
|
-
// run outlived its 300 s even against a queue that never moved
|
|
458
|
-
// once, which is the "it does not reliably give up after N
|
|
459
|
-
// seconds" behaviour a downstream fork reported.
|
|
460
|
-
if (hardDeadline !== undefined) {
|
|
461
|
-
if (bestAhead === undefined) {
|
|
462
|
-
bestAhead = probe.queue.ahead;
|
|
463
|
-
}
|
|
464
|
-
else if (probe.queue.ahead < bestAhead) {
|
|
465
|
-
bestAhead = probe.queue.ahead;
|
|
466
|
-
deadline = Math.min(Date.now() + timeoutMs, hardDeadline);
|
|
467
|
-
options.onWaitExtended?.({
|
|
468
|
-
ahead: probe.queue.ahead,
|
|
469
|
-
budgetMs: deadline - startedAt,
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
if (Date.now() >= deadline) {
|
|
475
|
-
// The queue is read here, not carried over from the last progress
|
|
476
|
-
// probe: the position the wait reached is the one number that says
|
|
477
|
-
// whether a larger budget would have helped, and it must not depend
|
|
478
|
-
// on a probe having happened to land inside the budget. We are still
|
|
479
|
-
// registered at this point. `deregisterWaiter` runs in the `finally`
|
|
480
|
-
// below, which is only entered once this refusal has been built.
|
|
481
|
-
throw await buildLockTimeoutError(lockPath, options.onTimeoutMessage, error, await readLockQueue(lockPath, startedAt));
|
|
392
|
+
}
|
|
393
|
+
// Take a ticket so the queue is visible to the wait output and to
|
|
394
|
+
// `fireforge status --lock`, and so the grant follows arrival order.
|
|
395
|
+
waiter ??= await registerWaiter(lockPath, startedAt);
|
|
396
|
+
await waiter.heartbeat();
|
|
397
|
+
const self = { startedAt, ticket: waiter.ticket };
|
|
398
|
+
if (lastStaleProbeAt === undefined || Date.now() - lastStaleProbeAt >= staleReprobeMs) {
|
|
399
|
+
lastStaleProbeAt = Date.now();
|
|
400
|
+
if (await removeIfStaleLock(lockPath, staleMs, options.onStaleLockMessage)) {
|
|
401
|
+
continue;
|
|
482
402
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
403
|
+
}
|
|
404
|
+
// The probe runs before the deadline check, so a due progress line is
|
|
405
|
+
// written before the refusal that follows it, never lost to it. On a
|
|
406
|
+
// slow host (a cold Windows CI runner) the first iteration alone, with
|
|
407
|
+
// its waiter registration and stale probe, can outlast a short budget;
|
|
408
|
+
// checked the other way round, the wait timed out having reported
|
|
409
|
+
// nothing, and an advance that probe would have seen could not extend
|
|
410
|
+
// the deadline it was about to miss.
|
|
411
|
+
if (probeWanted && Date.now() - lastProbeAt >= probeIntervalMs) {
|
|
412
|
+
lastProbeAt = Date.now();
|
|
413
|
+
// Report the budget that is currently in force, not the one
|
|
414
|
+
// originally requested: once an extension has been granted, the
|
|
415
|
+
// requested figure is stale, and a progress line that keeps
|
|
416
|
+
// quoting it reads as a timeout that does not fire.
|
|
417
|
+
const probe = await runWaitProbe(lockPath, self, deadline - startedAt, options);
|
|
418
|
+
// An advance (someone ahead of us took their turn and finished)
|
|
419
|
+
// buys a fresh budget, because the wait is working. Only a queue
|
|
420
|
+
// that stops moving starves, which is the case the timeout is for.
|
|
421
|
+
//
|
|
422
|
+
// The first observation is a seed, never an advance. `bestAhead`
|
|
423
|
+
// used to start `undefined`, so the opening probe always satisfied
|
|
424
|
+
// this test and handed out a free full budget: a `--wait-lock 300`
|
|
425
|
+
// run outlived its 300 s even against a queue that never moved
|
|
426
|
+
// once, which is the "it does not reliably give up after N
|
|
427
|
+
// seconds" behaviour a downstream fork reported.
|
|
428
|
+
if (hardDeadline !== undefined) {
|
|
429
|
+
if (bestAhead === undefined) {
|
|
430
|
+
bestAhead = probe.queue.ahead;
|
|
431
|
+
}
|
|
432
|
+
else if (probe.queue.ahead < bestAhead) {
|
|
433
|
+
bestAhead = probe.queue.ahead;
|
|
434
|
+
deadline = Math.min(Date.now() + timeoutMs, hardDeadline);
|
|
435
|
+
options.onWaitExtended?.({
|
|
436
|
+
ahead: probe.queue.ahead,
|
|
437
|
+
budgetMs: deadline - startedAt,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
486
440
|
}
|
|
487
441
|
}
|
|
442
|
+
if (Date.now() >= deadline) {
|
|
443
|
+
// The queue is read here, not carried over from the last progress
|
|
444
|
+
// probe: the position the wait reached is the one number that says
|
|
445
|
+
// whether a larger budget would have helped, and it must not depend
|
|
446
|
+
// on a probe having happened to land inside the budget. We are still
|
|
447
|
+
// registered at this point. The ticket is returned in the `finally`
|
|
448
|
+
// below, which is only entered once this refusal has been built.
|
|
449
|
+
throw await buildLockTimeoutError(lockPath, options.onTimeoutMessage, contention, await readLockQueue(lockPath, self));
|
|
450
|
+
}
|
|
451
|
+
await sleep(currentPollMs);
|
|
452
|
+
if (options.pollMaxMs !== undefined) {
|
|
453
|
+
currentPollMs = Math.min(currentPollMs * 2, options.pollMaxMs);
|
|
454
|
+
}
|
|
488
455
|
}
|
|
489
456
|
}
|
|
490
457
|
finally {
|
|
491
458
|
// Leaving the queue happens the moment we stop waiting, whether we
|
|
492
459
|
// acquired the lock or timed out.
|
|
493
|
-
await
|
|
460
|
+
await waiter?.deregister();
|
|
494
461
|
}
|
|
495
462
|
// Stamp ownership (PID + per-acquisition token + acquisition time) into
|
|
496
463
|
// the lock directory so stale-lock recovery can check liveness and release
|
|
@@ -2,6 +2,16 @@ import type { ApplyResult, DryRunAction } from '../types/furnace.js';
|
|
|
2
2
|
type ApplyResultWithActions = ApplyResult & {
|
|
3
3
|
actions?: DryRunAction[];
|
|
4
4
|
};
|
|
5
|
+
/**
|
|
6
|
+
* The reason breakdown appended to a "skipped N" summary, e.g.
|
|
7
|
+
* ` (2 already current)`, so `Applied 0, skipped 1` says whether the
|
|
8
|
+
* component was already current or skipped for another reason without
|
|
9
|
+
* diffing the deployed files by hand. Empty when nothing was skipped.
|
|
10
|
+
*
|
|
11
|
+
* @param skipped - `result.skipped` from applyAllComponents
|
|
12
|
+
* @returns The parenthesised breakdown with a leading space, or ''
|
|
13
|
+
*/
|
|
14
|
+
export declare function formatSkipBreakdown(skipped: ApplyResult['skipped']): string;
|
|
5
15
|
/**
|
|
6
16
|
* Prints a standard summary of an apply result for both normal and dry-run flows.
|
|
7
17
|
*
|
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
import { error, info, success, warn } from '../utils/logger.js';
|
|
2
|
+
/** Summary label and per-component explanation for each skip kind. */
|
|
3
|
+
const SKIP_KIND_TEXT = {
|
|
4
|
+
'already-current': {
|
|
5
|
+
label: 'already current',
|
|
6
|
+
detail: 'already current: source unchanged since the last recorded apply (from any session) and the engine copy matches it',
|
|
7
|
+
},
|
|
8
|
+
};
|
|
9
|
+
/** The per-component line text for one skip. */
|
|
10
|
+
function describeSkip(skipped) {
|
|
11
|
+
return skipped.kind !== undefined ? SKIP_KIND_TEXT[skipped.kind].detail : skipped.reason;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The reason breakdown appended to a "skipped N" summary, e.g.
|
|
15
|
+
* ` (2 already current)`, so `Applied 0, skipped 1` says whether the
|
|
16
|
+
* component was already current or skipped for another reason without
|
|
17
|
+
* diffing the deployed files by hand. Empty when nothing was skipped.
|
|
18
|
+
*
|
|
19
|
+
* @param skipped - `result.skipped` from applyAllComponents
|
|
20
|
+
* @returns The parenthesised breakdown with a leading space, or ''
|
|
21
|
+
*/
|
|
22
|
+
export function formatSkipBreakdown(skipped) {
|
|
23
|
+
if (skipped.length === 0)
|
|
24
|
+
return '';
|
|
25
|
+
const counts = new Map();
|
|
26
|
+
for (const entry of skipped) {
|
|
27
|
+
const label = entry.kind !== undefined ? SKIP_KIND_TEXT[entry.kind].label : entry.reason;
|
|
28
|
+
counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
29
|
+
}
|
|
30
|
+
return ` (${[...counts].map(([label, count]) => `${count} ${label}`).join(', ')})`;
|
|
31
|
+
}
|
|
2
32
|
/**
|
|
3
33
|
* Prints a standard summary of an apply result for both normal and dry-run flows.
|
|
4
34
|
*
|
|
@@ -40,7 +70,7 @@ export function logApplyResult(result, isDryRun) {
|
|
|
40
70
|
success(`${applied.name} (${applied.type}) → ${applied.filesAffected.length} files`);
|
|
41
71
|
}
|
|
42
72
|
for (const skipped of result.skipped) {
|
|
43
|
-
info(`${skipped.name} — ${skipped
|
|
73
|
+
info(`${skipped.name} — ${describeSkip(skipped)}`);
|
|
44
74
|
}
|
|
45
75
|
for (const applied of result.applied) {
|
|
46
76
|
if (applied.stepErrors && applied.stepErrors.length > 0) {
|
|
@@ -39,6 +39,16 @@ function addMissingComponentError(result, name, directoryPath) {
|
|
|
39
39
|
error: `Component directory not found: ${directoryPath}`,
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* The skip both batch loops record when a component's source is unchanged
|
|
44
|
+
* since the last recorded apply and its engine copy still matches. `reason`
|
|
45
|
+
* keeps its long-standing text for API consumers; `kind` lets summaries
|
|
46
|
+
* name it.
|
|
47
|
+
*/
|
|
48
|
+
const ALREADY_CURRENT_SKIP = {
|
|
49
|
+
reason: 'No changes since last apply',
|
|
50
|
+
kind: 'already-current',
|
|
51
|
+
};
|
|
42
52
|
async function applyOverrideBatch(ctx) {
|
|
43
53
|
const { config, furnacePaths, state, engineDir, ftlDir, dryRun } = ctx;
|
|
44
54
|
const { result, allActions, newChecksums, rollbackJournal, componentName } = ctx;
|
|
@@ -66,7 +76,7 @@ async function applyOverrideBatch(ctx) {
|
|
|
66
76
|
const cachedEngine = extractComponentChecksums(state.engineChecksums, 'override', name);
|
|
67
77
|
const drifted = await hasOverrideEngineDrift({ engineDir, componentDir, ftlDir }, overrideConfig, cachedEngine);
|
|
68
78
|
if (!drifted) {
|
|
69
|
-
result.skipped.push({ name,
|
|
79
|
+
result.skipped.push({ name, ...ALREADY_CURRENT_SKIP });
|
|
70
80
|
recordComponentChecksums(newChecksums, previous, 'override', name);
|
|
71
81
|
continue;
|
|
72
82
|
}
|
|
@@ -226,7 +236,7 @@ async function applyCustomBatch(ctx, root, markerComment) {
|
|
|
226
236
|
// re-apply even though the workspace is unchanged.
|
|
227
237
|
const drifted = await hasCustomEngineDrift({ root, name, componentDir, ftlDir }, customConfig);
|
|
228
238
|
if (!drifted) {
|
|
229
|
-
result.skipped.push({ name,
|
|
239
|
+
result.skipped.push({ name, ...ALREADY_CURRENT_SKIP });
|
|
230
240
|
recordComponentChecksums(newChecksums, previous, 'custom', name);
|
|
231
241
|
continue;
|
|
232
242
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Furnace-composed form of a component source, and the questions that
|
|
3
|
+
* need it.
|
|
4
|
+
*
|
|
5
|
+
* `furnace apply` does not copy every source verbatim: a stylesheet with
|
|
6
|
+
* `@fireforge-include` directives is deployed with each fragment body from
|
|
7
|
+
* `components/shared/` inlined between the directive and its
|
|
8
|
+
* `@fireforge-end-include` marker. Anything that compares a deployed engine
|
|
9
|
+
* file with its source has to compare against that composed form, or a
|
|
10
|
+
* freshly deployed widget reads as a large difference it is not:
|
|
11
|
+
*
|
|
12
|
+
* - the unexported-drift belt (`build`/`test --refuse-unexported-drift`),
|
|
13
|
+
* which must not refuse to overwrite a deployed file with the very bytes
|
|
14
|
+
* it already holds;
|
|
15
|
+
* - `furnace diff`, which must show what the next apply would change.
|
|
16
|
+
*/
|
|
17
|
+
import type { FurnaceConfig } from '../types/furnace.js';
|
|
18
|
+
/**
|
|
19
|
+
* Composes one component source into the bytes apply deploys: a stylesheet
|
|
20
|
+
* carrying include directives is fragment-expanded, anything else is
|
|
21
|
+
* returned as is.
|
|
22
|
+
*
|
|
23
|
+
* @param content - Workspace source content
|
|
24
|
+
* @param fileName - Source file name (only `.css` is ever expanded)
|
|
25
|
+
* @param sharedDir - Absolute path of `components/shared/`
|
|
26
|
+
* @returns The composed content
|
|
27
|
+
* @throws FurnaceError when an included fragment is missing or nested
|
|
28
|
+
*/
|
|
29
|
+
export declare function composeComponentSource(content: string, fileName: string, sharedDir: string): Promise<string>;
|
|
30
|
+
/** The workspace source apply deploys to one engine path. */
|
|
31
|
+
export interface FurnaceDeployedSource {
|
|
32
|
+
type: 'custom' | 'override';
|
|
33
|
+
component: string;
|
|
34
|
+
/** Source file name inside the component directory. */
|
|
35
|
+
fileName: string;
|
|
36
|
+
/** Absolute path of the workspace source. */
|
|
37
|
+
sourcePath: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Maps every engine-relative path `furnace apply` writes to the workspace
|
|
41
|
+
* source it writes there: custom `.mjs`/`.css` into `targetPath`, a custom
|
|
42
|
+
* component's own `.ftl` into the Fluent tree (never for `sharedFtl`), and
|
|
43
|
+
* each override copy candidate onto its engine target.
|
|
44
|
+
*
|
|
45
|
+
* @param root - Project root
|
|
46
|
+
* @param config - Loaded furnace.json
|
|
47
|
+
* @returns Engine-relative path (POSIX) → deployed source
|
|
48
|
+
*/
|
|
49
|
+
export declare function mapDeployedComponentSources(root: string, config: FurnaceConfig): Promise<Map<string, FurnaceDeployedSource>>;
|
|
50
|
+
/**
|
|
51
|
+
* Why a deployed engine file is safe to overwrite from its component
|
|
52
|
+
* source even though no patch body or baseline explains it:
|
|
53
|
+
*
|
|
54
|
+
* - `composed-source`: it already equals the Furnace-composed current
|
|
55
|
+
* source, so the overwrite writes the same bytes;
|
|
56
|
+
* - `fragment-refresh`: it differs from the composed source only inside
|
|
57
|
+
* Furnace-generated fragment expansions (a fragment changed since it was
|
|
58
|
+
* deployed), so the overwrite only refreshes generated text;
|
|
59
|
+
* - `previous-deploy`: it is exactly the content Furnace's last recorded
|
|
60
|
+
* apply deployed (the source has moved on since), so the overwrite loses
|
|
61
|
+
* nothing that was not already a component source.
|
|
62
|
+
*/
|
|
63
|
+
export type DeployedSourceMatch = 'composed-source' | 'fragment-refresh' | 'previous-deploy';
|
|
64
|
+
/**
|
|
65
|
+
* For each engine-relative path, decides whether its current engine content
|
|
66
|
+
* is a Furnace deployment of its component source (see
|
|
67
|
+
* {@link DeployedSourceMatch}). Paths with no component source, an
|
|
68
|
+
* unreadable engine copy, or content explained by none of the matches are
|
|
69
|
+
* absent from the result.
|
|
70
|
+
*
|
|
71
|
+
* @param root - Project root
|
|
72
|
+
* @param engineDir - Absolute engine directory
|
|
73
|
+
* @param files - Engine-relative paths to classify
|
|
74
|
+
* @returns Engine-relative path → match, for the matching paths only
|
|
75
|
+
*/
|
|
76
|
+
export declare function matchDeployedComponentFiles(root: string, engineDir: string, files: readonly string[]): Promise<Map<string, DeployedSourceMatch>>;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* The Furnace-composed form of a component source, and the questions that
|
|
4
|
+
* need it.
|
|
5
|
+
*
|
|
6
|
+
* `furnace apply` does not copy every source verbatim: a stylesheet with
|
|
7
|
+
* `@fireforge-include` directives is deployed with each fragment body from
|
|
8
|
+
* `components/shared/` inlined between the directive and its
|
|
9
|
+
* `@fireforge-end-include` marker. Anything that compares a deployed engine
|
|
10
|
+
* file with its source has to compare against that composed form, or a
|
|
11
|
+
* freshly deployed widget reads as a large difference it is not:
|
|
12
|
+
*
|
|
13
|
+
* - the unexported-drift belt (`build`/`test --refuse-unexported-drift`),
|
|
14
|
+
* which must not refuse to overwrite a deployed file with the very bytes
|
|
15
|
+
* it already holds;
|
|
16
|
+
* - `furnace diff`, which must show what the next apply would change.
|
|
17
|
+
*/
|
|
18
|
+
import { readdir } from 'node:fs/promises';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { pathExists, readText } from '../utils/fs.js';
|
|
21
|
+
import { sha256Hex } from '../utils/hash.js';
|
|
22
|
+
import { normalizePathSlashes } from '../utils/paths.js';
|
|
23
|
+
import { isOverrideCopyCandidate } from './furnace-apply-helpers.js';
|
|
24
|
+
import { extractComponentChecksums, normalizeForChecksum } from './furnace-checksum-utils.js';
|
|
25
|
+
import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
|
|
26
|
+
import { resolveFtlDir } from './furnace-constants.js';
|
|
27
|
+
import { expandCssFragments, listFragmentIncludes, stripExpandedFragments, } from './furnace-css-fragments.js';
|
|
28
|
+
import { isRegularFile } from './furnace-dir-entry.js';
|
|
29
|
+
/**
|
|
30
|
+
* Composes one component source into the bytes apply deploys: a stylesheet
|
|
31
|
+
* carrying include directives is fragment-expanded, anything else is
|
|
32
|
+
* returned as is.
|
|
33
|
+
*
|
|
34
|
+
* @param content - Workspace source content
|
|
35
|
+
* @param fileName - Source file name (only `.css` is ever expanded)
|
|
36
|
+
* @param sharedDir - Absolute path of `components/shared/`
|
|
37
|
+
* @returns The composed content
|
|
38
|
+
* @throws FurnaceError when an included fragment is missing or nested
|
|
39
|
+
*/
|
|
40
|
+
export async function composeComponentSource(content, fileName, sharedDir) {
|
|
41
|
+
if (!fileName.endsWith('.css') || listFragmentIncludes(content).length === 0)
|
|
42
|
+
return content;
|
|
43
|
+
return (await expandCssFragments(content, sharedDir)).expanded;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Maps every engine-relative path `furnace apply` writes to the workspace
|
|
47
|
+
* source it writes there: custom `.mjs`/`.css` into `targetPath`, a custom
|
|
48
|
+
* component's own `.ftl` into the Fluent tree (never for `sharedFtl`), and
|
|
49
|
+
* each override copy candidate onto its engine target.
|
|
50
|
+
*
|
|
51
|
+
* @param root - Project root
|
|
52
|
+
* @param config - Loaded furnace.json
|
|
53
|
+
* @returns Engine-relative path (POSIX) → deployed source
|
|
54
|
+
*/
|
|
55
|
+
export async function mapDeployedComponentSources(root, config) {
|
|
56
|
+
const furnacePaths = getFurnacePaths(root);
|
|
57
|
+
const ftlDir = resolveFtlDir(config.ftlBasePath);
|
|
58
|
+
const sources = new Map();
|
|
59
|
+
for (const [component, custom] of Object.entries(config.custom)) {
|
|
60
|
+
const componentDir = join(furnacePaths.customDir, component);
|
|
61
|
+
for (const fileName of await listSourceFiles(componentDir)) {
|
|
62
|
+
let engineRel;
|
|
63
|
+
if (fileName.endsWith('.mjs') || fileName.endsWith('.css')) {
|
|
64
|
+
engineRel = normalizePathSlashes(join(custom.targetPath, fileName));
|
|
65
|
+
}
|
|
66
|
+
else if (fileName === `${component}.ftl` && custom.localized && !custom.sharedFtl) {
|
|
67
|
+
engineRel = normalizePathSlashes(join(ftlDir, fileName));
|
|
68
|
+
}
|
|
69
|
+
if (engineRel === undefined)
|
|
70
|
+
continue;
|
|
71
|
+
sources.set(engineRel, {
|
|
72
|
+
type: 'custom',
|
|
73
|
+
component,
|
|
74
|
+
fileName,
|
|
75
|
+
sourcePath: join(componentDir, fileName),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const [component, override] of Object.entries(config.overrides)) {
|
|
80
|
+
const componentDir = join(furnacePaths.overridesDir, component);
|
|
81
|
+
for (const fileName of await listSourceFiles(componentDir)) {
|
|
82
|
+
if (!isOverrideCopyCandidate(fileName, override.type))
|
|
83
|
+
continue;
|
|
84
|
+
// Same target rule as `getOverrideEngineTargetPath`, engine-relative.
|
|
85
|
+
const base = fileName.endsWith('.ftl') ? ftlDir : override.basePath;
|
|
86
|
+
sources.set(normalizePathSlashes(join(base, fileName)), {
|
|
87
|
+
type: 'override',
|
|
88
|
+
component,
|
|
89
|
+
fileName,
|
|
90
|
+
sourcePath: join(componentDir, fileName),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return sources;
|
|
95
|
+
}
|
|
96
|
+
async function listSourceFiles(componentDir) {
|
|
97
|
+
if (!(await pathExists(componentDir)))
|
|
98
|
+
return [];
|
|
99
|
+
const entries = await readdir(componentDir, { withFileTypes: true, encoding: 'utf8' });
|
|
100
|
+
return entries.filter((entry) => isRegularFile(entry)).map((entry) => entry.name);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Classifies one deployed engine file against its component source.
|
|
104
|
+
*
|
|
105
|
+
* @returns The match, or undefined when the engine copy is explained by
|
|
106
|
+
* none of them (a real, unrecorded engine edit)
|
|
107
|
+
*/
|
|
108
|
+
async function matchDeployedSource(engineContent, source, sharedDir, appliedChecksums) {
|
|
109
|
+
const sourceContent = await readText(source.sourcePath);
|
|
110
|
+
const engine = normalizeForChecksum(engineContent);
|
|
111
|
+
let composed;
|
|
112
|
+
try {
|
|
113
|
+
composed = await composeComponentSource(sourceContent, source.fileName, sharedDir);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// A missing fragment: no composed form exists. The other two
|
|
117
|
+
// comparisons below still apply.
|
|
118
|
+
}
|
|
119
|
+
if (composed !== undefined && normalizeForChecksum(composed) === engine) {
|
|
120
|
+
return 'composed-source';
|
|
121
|
+
}
|
|
122
|
+
const strippedEngine = stripExpandedFragments(engine);
|
|
123
|
+
if (listFragmentIncludes(sourceContent).length > 0 &&
|
|
124
|
+
strippedEngine === stripExpandedFragments(normalizeForChecksum(sourceContent))) {
|
|
125
|
+
return 'fragment-refresh';
|
|
126
|
+
}
|
|
127
|
+
const recorded = extractComponentChecksums(appliedChecksums, source.type, source.component)[source.fileName];
|
|
128
|
+
// Apply records the checksum of the (unexpanded) source it deployed, so
|
|
129
|
+
// an engine copy with its expansions collapsed hashes to that value
|
|
130
|
+
// exactly when it is that deployment.
|
|
131
|
+
if (recorded !== undefined && sha256Hex(strippedEngine) === recorded) {
|
|
132
|
+
return 'previous-deploy';
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* For each engine-relative path, decides whether its current engine content
|
|
138
|
+
* is a Furnace deployment of its component source (see
|
|
139
|
+
* {@link DeployedSourceMatch}). Paths with no component source, an
|
|
140
|
+
* unreadable engine copy, or content explained by none of the matches are
|
|
141
|
+
* absent from the result.
|
|
142
|
+
*
|
|
143
|
+
* @param root - Project root
|
|
144
|
+
* @param engineDir - Absolute engine directory
|
|
145
|
+
* @param files - Engine-relative paths to classify
|
|
146
|
+
* @returns Engine-relative path → match, for the matching paths only
|
|
147
|
+
*/
|
|
148
|
+
export async function matchDeployedComponentFiles(root, engineDir, files) {
|
|
149
|
+
const matches = new Map();
|
|
150
|
+
if (files.length === 0 || !(await furnaceConfigExists(root)))
|
|
151
|
+
return matches;
|
|
152
|
+
const sources = await mapDeployedComponentSources(root, await loadFurnaceConfig(root));
|
|
153
|
+
const { sharedDir } = getFurnacePaths(root);
|
|
154
|
+
const { appliedChecksums } = await loadFurnaceState(root);
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
const source = sources.get(file);
|
|
157
|
+
if (source === undefined)
|
|
158
|
+
continue;
|
|
159
|
+
const enginePath = join(engineDir, file);
|
|
160
|
+
if (!(await pathExists(enginePath)))
|
|
161
|
+
continue;
|
|
162
|
+
const match = await matchDeployedSource(await readText(enginePath), source, sharedDir, appliedChecksums);
|
|
163
|
+
if (match !== undefined)
|
|
164
|
+
matches.set(file, match);
|
|
165
|
+
}
|
|
166
|
+
return matches;
|
|
167
|
+
}
|