@hominis/fireforge 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/check-report.d.ts +17 -4
  4. package/dist/src/commands/check-report.js +19 -6
  5. package/dist/src/commands/check-run.d.ts +5 -0
  6. package/dist/src/commands/check-run.js +9 -1
  7. package/dist/src/commands/check-steps.d.ts +8 -0
  8. package/dist/src/commands/check-steps.js +31 -6
  9. package/dist/src/commands/check.js +1 -1
  10. package/dist/src/commands/declarations-report.d.ts +43 -0
  11. package/dist/src/commands/declarations-report.js +76 -0
  12. package/dist/src/commands/export-placement-gate.js +8 -0
  13. package/dist/src/commands/furnace/rename-registrations.js +7 -2
  14. package/dist/src/commands/furnace/validate.js +26 -1
  15. package/dist/src/commands/lint-per-patch.js +6 -2
  16. package/dist/src/commands/lint-per-run-checkjs.js +2 -2
  17. package/dist/src/commands/lint-report.js +7 -2
  18. package/dist/src/commands/manifest.js +2 -0
  19. package/dist/src/commands/park.d.ts +22 -0
  20. package/dist/src/commands/park.js +232 -0
  21. package/dist/src/commands/patch/delete.js +11 -6
  22. package/dist/src/commands/patch/index.d.ts +3 -3
  23. package/dist/src/commands/patch/index.js +8 -4
  24. package/dist/src/commands/patch/lint-ignore-from.d.ts +44 -0
  25. package/dist/src/commands/patch/lint-ignore-from.js +142 -0
  26. package/dist/src/commands/patch/lint-ignore.js +39 -29
  27. package/dist/src/commands/patch/move-files-create.js +11 -4
  28. package/dist/src/commands/patch/move-files-into.js +10 -4
  29. package/dist/src/commands/patch/move-files.js +3 -1
  30. package/dist/src/commands/patch/removal-created-files.d.ts +48 -0
  31. package/dist/src/commands/patch/removal-created-files.js +83 -0
  32. package/dist/src/commands/patch/rename.js +15 -3
  33. package/dist/src/commands/patch/reserve.d.ts +33 -0
  34. package/dist/src/commands/patch/reserve.js +152 -0
  35. package/dist/src/commands/patch/retire.js +11 -6
  36. package/dist/src/commands/patch/split-plan.d.ts +10 -1
  37. package/dist/src/commands/patch/split-plan.js +16 -1
  38. package/dist/src/commands/patch/split.js +14 -5
  39. package/dist/src/commands/patch/stage.d.ts +34 -0
  40. package/dist/src/commands/patch/stage.js +153 -0
  41. package/dist/src/commands/patch/staged-dependency-remove.d.ts +31 -0
  42. package/dist/src/commands/patch/staged-dependency-remove.js +80 -0
  43. package/dist/src/commands/patch/staged-dependency.js +40 -98
  44. package/dist/src/commands/re-export-adjacent.d.ts +5 -0
  45. package/dist/src/commands/re-export-adjacent.js +4 -3
  46. package/dist/src/commands/re-export-bulk-scan.d.ts +2 -1
  47. package/dist/src/commands/re-export-bulk-scan.js +22 -7
  48. package/dist/src/commands/re-export-hunks.d.ts +35 -0
  49. package/dist/src/commands/re-export-hunks.js +220 -0
  50. package/dist/src/commands/re-export-options.js +29 -2
  51. package/dist/src/commands/re-export-purity-scope.d.ts +18 -0
  52. package/dist/src/commands/re-export-purity-scope.js +131 -0
  53. package/dist/src/commands/re-export-register.js +15 -1
  54. package/dist/src/commands/re-export-removed.d.ts +1 -1
  55. package/dist/src/commands/re-export-removed.js +17 -2
  56. package/dist/src/commands/re-export-scan.d.ts +2 -0
  57. package/dist/src/commands/re-export-scan.js +4 -1
  58. package/dist/src/commands/re-export-single.js +102 -42
  59. package/dist/src/commands/re-export-stale-registrations.d.ts +18 -0
  60. package/dist/src/commands/re-export-stale-registrations.js +62 -0
  61. package/dist/src/commands/re-export.js +35 -34
  62. package/dist/src/commands/test-build-furnace.d.ts +3 -2
  63. package/dist/src/commands/test-build-furnace.js +8 -2
  64. package/dist/src/commands/test-build-phase.js +24 -7
  65. package/dist/src/commands/test-checkjs.js +3 -2
  66. package/dist/src/commands/test-dispatch.d.ts +39 -0
  67. package/dist/src/commands/test-dispatch.js +211 -0
  68. package/dist/src/commands/test-furnace-reach.d.ts +37 -0
  69. package/dist/src/commands/test-furnace-reach.js +132 -0
  70. package/dist/src/commands/test-orchestrate.d.ts +9 -0
  71. package/dist/src/commands/test-orchestrate.js +22 -4
  72. package/dist/src/commands/test-perf-scope.d.ts +21 -0
  73. package/dist/src/commands/test-perf-scope.js +55 -0
  74. package/dist/src/commands/test-prelock.js +100 -4
  75. package/dist/src/commands/test-register.js +18 -3
  76. package/dist/src/commands/test-results.d.ts +62 -0
  77. package/dist/src/commands/test-results.js +62 -0
  78. package/dist/src/commands/test-run.d.ts +13 -55
  79. package/dist/src/commands/test-run.js +73 -101
  80. package/dist/src/commands/test-shards.d.ts +110 -0
  81. package/dist/src/commands/test-shards.js +255 -0
  82. package/dist/src/commands/test-stale-gate.d.ts +5 -1
  83. package/dist/src/commands/test-stale-gate.js +4 -2
  84. package/dist/src/commands/test-verdict.d.ts +2 -1
  85. package/dist/src/commands/test.d.ts +1 -1
  86. package/dist/src/commands/test.js +65 -75
  87. package/dist/src/commands/token-lint.js +48 -8
  88. package/dist/src/commands/verify.js +9 -2
  89. package/dist/src/core/build-prepare.d.ts +6 -0
  90. package/dist/src/core/build-prepare.js +29 -3
  91. package/dist/src/core/config-paths.d.ts +1 -1
  92. package/dist/src/core/config-paths.js +2 -0
  93. package/dist/src/core/config-validate-check.js +83 -7
  94. package/dist/src/core/config-validate-test-toolchains.js +14 -0
  95. package/dist/src/core/config-validate-token-scales.d.ts +3 -0
  96. package/dist/src/core/config-validate-token-scales.js +47 -5
  97. package/dist/src/core/config-validate.js +6 -0
  98. package/dist/src/core/coverage-extend.js +2 -2
  99. package/dist/src/core/coverage-records.d.ts +11 -0
  100. package/dist/src/core/coverage-records.js +34 -0
  101. package/dist/src/core/engine-session-lock.d.ts +6 -0
  102. package/dist/src/core/engine-session-lock.js +10 -2
  103. package/dist/src/core/file-lock-shared.d.ts +57 -0
  104. package/dist/src/core/file-lock-shared.js +117 -0
  105. package/dist/src/core/file-lock.d.ts +9 -0
  106. package/dist/src/core/file-lock.js +58 -2
  107. package/dist/src/core/furnace-apply.js +10 -10
  108. package/dist/src/core/furnace-config-validate-exceptions.d.ts +10 -3
  109. package/dist/src/core/furnace-config-validate-exceptions.js +39 -8
  110. package/dist/src/core/furnace-config-validate.js +7 -1
  111. package/dist/src/core/furnace-registration-jar-inplace.d.ts +12 -0
  112. package/dist/src/core/furnace-registration-jar-inplace.js +80 -0
  113. package/dist/src/core/furnace-validate-accessibility.d.ts +4 -2
  114. package/dist/src/core/furnace-validate-accessibility.js +61 -27
  115. package/dist/src/core/furnace-validate-exceptions.d.ts +12 -4
  116. package/dist/src/core/furnace-validate-exceptions.js +41 -4
  117. package/dist/src/core/furnace-validate-icon-button.js +30 -1
  118. package/dist/src/core/history-log-file.js +8 -1
  119. package/dist/src/core/line-prefixer.d.ts +18 -0
  120. package/dist/src/core/line-prefixer.js +34 -0
  121. package/dist/src/core/mach.d.ts +7 -0
  122. package/dist/src/core/mach.js +24 -9
  123. package/dist/src/core/module-resolution-preflight.d.ts +19 -3
  124. package/dist/src/core/module-resolution-preflight.js +24 -6
  125. package/dist/src/core/negative-control-compiled.d.ts +27 -0
  126. package/dist/src/core/negative-control-compiled.js +73 -0
  127. package/dist/src/core/negative-control-diff.d.ts +14 -2
  128. package/dist/src/core/negative-control-diff.js +49 -6
  129. package/dist/src/core/negative-control.d.ts +12 -0
  130. package/dist/src/core/negative-control.js +48 -5
  131. package/dist/src/core/park-store.d.ts +63 -0
  132. package/dist/src/core/park-store.js +216 -0
  133. package/dist/src/core/patch-export-update.d.ts +4 -0
  134. package/dist/src/core/patch-export-update.js +12 -3
  135. package/dist/src/core/patch-export.d.ts +3 -2
  136. package/dist/src/core/patch-export.js +21 -14
  137. package/dist/src/core/patch-files.js +26 -8
  138. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +6 -4
  139. package/dist/src/core/patch-lint-checkjs-any-import.js +9 -7
  140. package/dist/src/core/patch-lint-checkjs-diagnostics.d.ts +17 -0
  141. package/dist/src/core/patch-lint-checkjs-diagnostics.js +32 -0
  142. package/dist/src/core/patch-lint-checkjs.d.ts +10 -1
  143. package/dist/src/core/patch-lint-checkjs.js +13 -3
  144. package/dist/src/core/patch-lint-cross.d.ts +3 -1
  145. package/dist/src/core/patch-lint-cross.js +5 -0
  146. package/dist/src/core/patch-lint-css.js +14 -10
  147. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +5 -0
  148. package/dist/src/core/patch-lint-forward-registration-extended.js +1 -1
  149. package/dist/src/core/patch-lint-ignore.d.ts +56 -12
  150. package/dist/src/core/patch-lint-ignore.js +173 -43
  151. package/dist/src/core/patch-lint-ownership.d.ts +10 -0
  152. package/dist/src/core/patch-lint-ownership.js +26 -0
  153. package/dist/src/core/patch-lint-size.js +9 -1
  154. package/dist/src/core/patch-lint-token-scale.d.ts +26 -8
  155. package/dist/src/core/patch-lint-token-scale.js +206 -29
  156. package/dist/src/core/patch-lint.js +2 -2
  157. package/dist/src/core/patch-manifest-io.js +8 -1
  158. package/dist/src/core/patch-manifest-key-order.d.ts +24 -0
  159. package/dist/src/core/patch-manifest-key-order.js +61 -0
  160. package/dist/src/core/patch-manifest-stage.d.ts +46 -0
  161. package/dist/src/core/patch-manifest-stage.js +96 -0
  162. package/dist/src/core/patch-manifest-validate.js +27 -1
  163. package/dist/src/core/patch-order-legal.d.ts +2 -1
  164. package/dist/src/core/patch-order-legal.js +7 -2
  165. package/dist/src/core/patch-policy.d.ts +6 -2
  166. package/dist/src/core/patch-policy.js +14 -7
  167. package/dist/src/core/patch-retired.d.ts +26 -4
  168. package/dist/src/core/patch-retired.js +40 -2
  169. package/dist/src/core/run-log.d.ts +6 -0
  170. package/dist/src/core/run-log.js +17 -1
  171. package/dist/src/core/test-harness-crash.d.ts +2 -1
  172. package/dist/src/core/test-harness-crash.js +5 -8
  173. package/dist/src/core/test-harness-signature.d.ts +16 -0
  174. package/dist/src/core/test-harness-signature.js +19 -1
  175. package/dist/src/core/test-raw-log.d.ts +66 -0
  176. package/dist/src/core/test-raw-log.js +122 -0
  177. package/dist/src/core/test-run-completeness.d.ts +36 -3
  178. package/dist/src/core/test-run-completeness.js +57 -17
  179. package/dist/src/core/test-xpcshell-retry.d.ts +2 -0
  180. package/dist/src/core/test-xpcshell-retry.js +2 -1
  181. package/dist/src/core/token-lint.d.ts +5 -2
  182. package/dist/src/core/token-lint.js +8 -3
  183. package/dist/src/core/token-sibling-sheets.d.ts +46 -0
  184. package/dist/src/core/token-sibling-sheets.js +119 -0
  185. package/dist/src/core/tree-guard.js +2 -0
  186. package/dist/src/core/typecheck-shim.d.ts +5 -1
  187. package/dist/src/core/typecheck-shim.js +115 -2
  188. package/dist/src/core/typecheck.js +3 -1
  189. package/dist/src/index.d.ts +9 -3
  190. package/dist/src/index.js +4 -1
  191. package/dist/src/types/commands/index.d.ts +2 -2
  192. package/dist/src/types/commands/options.d.ts +68 -7
  193. package/dist/src/types/commands/patches.d.ts +43 -0
  194. package/dist/src/types/config.d.ts +74 -2
  195. package/dist/src/types/furnace.d.ts +20 -0
  196. package/dist/src/types/index.d.ts +1 -1
  197. package/dist/src/utils/options.d.ts +3 -2
  198. package/dist/src/utils/options.js +9 -4
  199. package/docs/configuration.md +94 -9
  200. package/docs/furnace.md +43 -1
  201. package/docs/lifecycle-invariants.md +13 -11
  202. package/docs/machine-output.md +64 -18
  203. package/docs/patch-workflow.md +174 -13
  204. package/docs/testing.md +73 -4
  205. package/package.json +3 -3
@@ -0,0 +1,131 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * The scoped half of the `re-export --dry-run` purity guard.
4
+ *
5
+ * A dry run takes no engine-session lock since 0.51.0, so peers keep
6
+ * writing while it runs. The whole-tree fingerprint then reported every
7
+ * peer's write as "invariant violated … This is a FireForge bug", and an
8
+ * immediate repeat was clean. The scoped fingerprint covers only what the
9
+ * dry run could touch: the selected patches' bodies and manifest rows, the
10
+ * engine files they own, the engine HEAD and the engine index. A mismatch
11
+ * that remains is attributed to a foreign writer when the history log or
12
+ * the engine lock shows one in the window.
13
+ */
14
+ import { readFile } from 'node:fs/promises';
15
+ import { dirname, join, resolve } from 'node:path';
16
+ import { readEngineSessionLockStatus } from '../core/engine-session-lock.js';
17
+ import { git } from '../core/git-base.js';
18
+ import { historyRecordIdentity, resolveHistoryLogPath } from '../core/history-log-file.js';
19
+ import { PATCHES_MANIFEST } from '../core/patch-manifest-io.js';
20
+ import { GeneralError } from '../errors/base.js';
21
+ import { getNodeErrorCode, toError } from '../utils/errors.js';
22
+ import { sha256Hex } from '../utils/hash.js';
23
+ import { isArray, isObject } from '../utils/validation.js';
24
+ /** Reads a file's bytes as a hash, `absent` when it does not exist. */
25
+ async function hashOrAbsent(path, label) {
26
+ try {
27
+ return sha256Hex(await readFile(path));
28
+ }
29
+ catch (error) {
30
+ const code = getNodeErrorCode(error);
31
+ if (code === 'ENOENT' || code === 'ENOTDIR')
32
+ return 'absent';
33
+ throw new GeneralError(`[dry-run] cannot fingerprint ${label} for the purity guard: ${toError(error).message}`, error);
34
+ }
35
+ }
36
+ /** The selected rows of `patches.json`, so a peer's write to another row is not a violation. */
37
+ async function selectedRowsFingerprint(patchesDir, filenames) {
38
+ let raw;
39
+ try {
40
+ raw = await readFile(join(patchesDir, PATCHES_MANIFEST), 'utf8');
41
+ }
42
+ catch (error) {
43
+ if (getNodeErrorCode(error) === 'ENOENT')
44
+ return 'absent';
45
+ throw error;
46
+ }
47
+ try {
48
+ const parsed = JSON.parse(raw);
49
+ const rows = isObject(parsed) && isArray(parsed['patches']) ? parsed['patches'] : [];
50
+ const selected = rows.filter((row) => isObject(row) && typeof row['filename'] === 'string' && filenames.has(row['filename']));
51
+ return sha256Hex(JSON.stringify(selected));
52
+ }
53
+ catch {
54
+ // A manifest that no longer parses changed, whoever wrote it.
55
+ return sha256Hex(raw);
56
+ }
57
+ }
58
+ /**
59
+ * Fingerprints exactly the state a scoped `re-export --dry-run` could
60
+ * disturb. Every key names what it covers, so a violation reads as the
61
+ * path that moved.
62
+ */
63
+ export async function fingerprintScopedDryRunState(engineDir, patchesDir, scope) {
64
+ const fingerprint = new Map();
65
+ try {
66
+ fingerprint.set('engine HEAD', (await git(['rev-parse', 'HEAD'], engineDir)).trim());
67
+ // The index as a whole: the historic purity defect was a transient
68
+ // `--intent-to-add` entry, which may land on any path a scan reads.
69
+ fingerprint.set('the engine index', sha256Hex(await git(['ls-files', '--stage', '-z'], engineDir)));
70
+ }
71
+ catch (error) {
72
+ throw new GeneralError(`[dry-run] cannot fingerprint the engine for the purity guard: ${toError(error).message}`, error);
73
+ }
74
+ const filenames = new Set(scope.patches.map((patch) => patch.filename));
75
+ fingerprint.set(`${PATCHES_MANIFEST} rows of ${[...filenames].sort().join(', ')}`, await selectedRowsFingerprint(patchesDir, filenames));
76
+ for (const filename of [...filenames].sort()) {
77
+ fingerprint.set(`patches/${filename}`, await hashOrAbsent(join(patchesDir, filename), `patches/${filename}`));
78
+ }
79
+ const owned = [...new Set(scope.patches.flatMap((patch) => patch.filesAffected))].sort();
80
+ for (const file of owned) {
81
+ fingerprint.set(`engine/${file}`, await hashOrAbsent(join(engineDir, file), `engine/${file}`));
82
+ }
83
+ return fingerprint;
84
+ }
85
+ /** History records another invocation wrote at or after `sinceMs`. */
86
+ async function foreignHistoryRecords(projectRoot, sinceMs) {
87
+ let text;
88
+ try {
89
+ text = await readFile(await resolveHistoryLogPath(projectRoot), 'utf8');
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ const own = historyRecordIdentity().invocationId;
95
+ const writers = [];
96
+ for (const line of text.split('\n').slice(-500)) {
97
+ let record;
98
+ try {
99
+ record = JSON.parse(line);
100
+ }
101
+ catch {
102
+ continue;
103
+ }
104
+ const ts = typeof record.ts === 'string' ? Date.parse(record.ts) : Number.NaN;
105
+ if (!(ts >= sinceMs) || record.invocationId === own)
106
+ continue;
107
+ const session = typeof record.sessionId === 'string' ? `, session ${record.sessionId}` : '';
108
+ writers.push(`${String(record.operation)} (PID ${String(record.pid)}${session})`);
109
+ }
110
+ return writers;
111
+ }
112
+ /**
113
+ * Names whoever else wrote during the dry run: history records from other
114
+ * invocations since it started, and a live holder of the engine-session
115
+ * lock (which a dry run no longer takes). Empty when there is no evidence.
116
+ */
117
+ export async function describeForeignWriters(patchesDir, sinceMs) {
118
+ const projectRoot = dirname(resolve(patchesDir));
119
+ const writers = await foreignHistoryRecords(projectRoot, sinceMs);
120
+ try {
121
+ const lock = await readEngineSessionLockStatus(projectRoot);
122
+ if (lock.held && lock.holder !== undefined && lock.holder.pid !== process.pid) {
123
+ const detail = lock.holder.metadata.length > 0 ? ` (${lock.holder.metadata.join(', ')})` : '';
124
+ writers.push(`the engine-session lock holder, PID ${lock.holder.pid}${detail}`);
125
+ }
126
+ }
127
+ catch {
128
+ // Attribution is best effort; the violation is reported either way.
129
+ }
130
+ return writers;
131
+ }
@@ -8,7 +8,9 @@
8
8
  * the orchestrator would go the wrong way.
9
9
  */
10
10
  import { Command, Option } from 'commander';
11
+ import { getProjectPaths } from '../core/config.js';
11
12
  import { withEngineSessionLock } from '../core/engine-session-lock.js';
13
+ import { withPrivateGitIndex } from '../core/git-readonly-index.js';
12
14
  import { addWaitLockOption, pickDefined, resolveWaitLockSeconds, stringListOption, } from '../utils/options.js';
13
15
  import { reExportCommand } from './re-export.js';
14
16
  import { preflightReExportArguments, readPathListFile } from './re-export-preflight.js';
@@ -27,7 +29,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
27
29
  .split(',')
28
30
  .map((v) => v.trim())
29
31
  .filter((v) => v.length > 0))
30
- .option('--refuse-adjacent-unmanaged', 'Refuse a scan-less re-export (non-zero exit, patch not written) when unmanaged files exist adjacent to the patch ownership, instead of warning. Adjacent means the same directory as an owned file (not a sibling or nested directory), or named by a registration line the patch adds. Mutually exclusive with --scan and --files.')
32
+ .option('--refuse-adjacent-unmanaged', 'Refuse a re-export (non-zero exit, patch not written) when unmanaged files exist adjacent to the patch ownership, instead of warning. Adjacent means the same directory as an owned file (not a sibling or nested directory), or named by a registration line the patch adds. Beside --scan it judges the files the scan did not adopt. Mutually exclusive with --files.')
31
33
  .option('--refuse-foreign-drift', "Refuse a re-export (non-zero exit, patch not written) when the refreshed body would add or drop lines, in a file the patch already owns, that the old patch body did not carry — protects multi-session checkouts from silently capturing another session's uncommitted edits. Beside --scan it guards each patch's existing files (adopted files count as expected). Mutually exclusive with --files.")
32
34
  .option('--expect <path>', "With --refuse-foreign-drift, name an engine-relative file whose drift is this session's intended edit: drift confined to --expect files proceeds, drift anywhere else still refuses. Repeatable, and the whitelist applies across EVERY patch in this invocation, not per patch — so pass all the patches and all their --expect paths in one call rather than looping one patch at a time.", ...stringListOption())
33
35
  .option('--expect-unmanaged <path>', 'With --refuse-adjacent-unmanaged, name an engine-relative file that is a REVIEWED, recorded exception to the adjacency refusal: an approved unmanaged path is still listed, but no longer refuses. Repeatable, and the carve-out applies across EVERY patch in this invocation, not per patch. Use it to keep the belt armed for everything else instead of dropping --refuse-adjacent-unmanaged entirely.', ...stringListOption())
@@ -40,6 +42,9 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
40
42
  .option('--no-cache', 'Bypass per-patch lint result cache reads and writes for this re-export')
41
43
  .option('--allow-stale-furnace', 'Export the deployed engine copy even when the components/ source changed since the last furnace apply')
42
44
  .option('--allow-shrink', 'Allow --files to remove paths currently owned by the patch. Required before --yes can bypass the shrink confirmation.')
45
+ .option('--declarations-out <path>', 'Where a refused --scan adoption writes its one --from-ready staged-declaration report (default: .fireforge/declarations/<timestamp>.json).')
46
+ .option('--hunks-from <diff>', "Export each file the diff names as the patch's current body plus only the diff's hunks, so another session's edits in the same file stay out. One explicit patch; not with --scan, --files or --all.")
47
+ .option('--exclude-hunks <diff>', "Export each file the diff names as the working tree minus the diff's hunks. One explicit patch; not with --scan, --files or --all.")
43
48
  .option('-y, --yes', 'Skip confirmation prompts (required for non-TTY destructive writes)')
44
49
  .option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
45
50
  .option('--stamp', "After every selected patch refreshes cleanly, stamp each re-exported patch's sourceVersion/sourceProduct in patches.json to firefox.version/firefox.product from fireforge.json. No effect on a partial run.")
@@ -51,6 +56,15 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
51
56
  // Refuse what the locked run would refuse before queueing for the
52
57
  // lock, which can be a long wait under contention.
53
58
  await preflightReExportArguments(projectRoot, patches, reExportOptions);
59
+ // A dry run writes nothing, so it does not queue behind peers' builds
60
+ // and test runs for the engine lock. Its git plumbing runs on a
61
+ // private index, so not even git's stat-cache refresh reaches the
62
+ // shared `.git/index`; dry runs still serialize among themselves.
63
+ if (reExportOptions.dryRun === true) {
64
+ const engineDir = getProjectPaths(projectRoot).engine;
65
+ await withPrivateGitIndex(engineDir, () => reExportCommand(projectRoot, patches, reExportOptions));
66
+ return;
67
+ }
54
68
  await withEngineSessionLock(projectRoot, 're-export', () => reExportCommand(projectRoot, patches, reExportOptions), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
55
69
  }));
56
70
  }
@@ -12,4 +12,4 @@ import type { ProjectPaths } from '../types/config.js';
12
12
  * `filesAffected` as it is, so without this the stale entry would stay
13
13
  * in the manifest and in the reserved-range policy check.
14
14
  */
15
- export declare function validateExpectedRemovals(paths: ProjectPaths, patches: PatchMetadata[], options: ReExportOptions): Promise<string[]>;
15
+ export declare function validateExpectedRemovals(paths: ProjectPaths, patches: PatchMetadata[], options: ReExportOptions, queue?: readonly PatchMetadata[]): Promise<string[]>;
@@ -20,7 +20,7 @@ import { normalizeEngineRelativeInput } from './re-export-scan.js';
20
20
  * `filesAffected` as it is, so without this the stale entry would stay
21
21
  * in the manifest and in the reserved-range policy check.
22
22
  */
23
- export async function validateExpectedRemovals(paths, patches, options) {
23
+ export async function validateExpectedRemovals(paths, patches, options, queue = patches) {
24
24
  const removed = [
25
25
  ...new Set((options.expectRemoved ?? []).map((file) => normalizeEngineRelativeInput(file, '--expect-removed'))),
26
26
  ];
@@ -32,7 +32,8 @@ export async function validateExpectedRemovals(paths, patches, options) {
32
32
  const owners = patches.filter((patch) => patch.filesAffected.includes(file));
33
33
  const owner = owners[0];
34
34
  if (owners.length !== 1 || owner === undefined) {
35
- throw new InvalidArgumentError(`--expect-removed ${file} must be owned by exactly one selected patch.`, '--expect-removed');
35
+ throw new InvalidArgumentError(`--expect-removed ${file} must be owned by exactly one selected patch.` +
36
+ unselectedOwnerHint(file, patches, queue), '--expect-removed');
36
37
  }
37
38
  const section = parseDiffSections(await readText(join(paths.patches, owner.filename))).find((candidate) => candidate.targetPath === file);
38
39
  if (tracked.has(file)) {
@@ -47,6 +48,20 @@ export async function validateExpectedRemovals(paths, patches, options) {
47
48
  }
48
49
  return removed;
49
50
  }
51
+ /**
52
+ * Each path retires from its own owner, so one run may retire paths from
53
+ * several selected patches. A path whose owner was left out of the
54
+ * selection names that owner, since adding it is the usual fix.
55
+ */
56
+ function unselectedOwnerHint(file, selected, queue) {
57
+ const selectedNames = new Set(selected.map((patch) => patch.filename));
58
+ const outside = queue
59
+ .filter((patch) => !selectedNames.has(patch.filename) && patch.filesAffected.includes(file))
60
+ .map((patch) => patch.filename);
61
+ if (outside.length === 0)
62
+ return '';
63
+ return ` It is owned by ${outside.join(', ')}, which this run did not select; add it to the selection to retire both in one run.`;
64
+ }
50
65
  async function assertCreatedFileAbsent(paths, file, owner, createdByOwner) {
51
66
  if (await removalPathPresent(join(paths.engine, file))) {
52
67
  throw new InvalidArgumentError(`--expect-removed ${file} is untracked in engine HEAD and still present on disk; ` +
@@ -54,6 +54,8 @@ export declare function assertScanAdoptionsHaveNoForwardImports(args: {
54
54
  * too, and `patchPolicy`, so the remedy names a legal order.
55
55
  */
56
56
  config?: FireForgeConfig;
57
+ /** `--declarations-out`: where the one `--from`-ready report goes. */
58
+ declarationsOut?: string | undefined;
57
59
  }): Promise<void>;
58
60
  /** The manifest rows a queue context carries, for remedy computations. */
59
61
  export declare function manifestFromQueue(ctx: PatchQueueContext): PatchesManifest;
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { dirname, join } from 'node:path';
2
+ import { dirname, join, resolve } from 'node:path';
3
3
  import { confirm } from '@clack/prompts';
4
4
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
5
5
  import { listTrackedInHead } from '../core/git-file-ops.js';
@@ -15,6 +15,7 @@ import { mapWithConcurrency } from '../utils/concurrency.js';
15
15
  import { pathExists } from '../utils/fs.js';
16
16
  import { cancel, info, isCancel, warn } from '../utils/logger.js';
17
17
  import { isContainedRelativePath, normalizePathSlashes, stripEnginePrefix, } from '../utils/paths.js';
18
+ import { formatDeclarationsReport, writeDeclarationsReport } from './declarations-report.js';
18
19
  import { formatForwardImportRemedy } from './forward-import-remedy.js';
19
20
  import { joinScanFileOwner, splitScanFileOwner } from './re-export-scan-owner.js';
20
21
  const SCAN_ADD_COUNT_THRESHOLD = 3;
@@ -227,7 +228,9 @@ export async function assertScanAdoptionsHaveNoForwardImports(args) {
227
228
  if (registrations.length > 0) {
228
229
  parts.push(`${registrations.length} forward registration${registrations.length === 1 ? '' : 's'} the adoption would leave dangling:\n${registrationDetails}`);
229
230
  }
231
+ const report = await writeDeclarationsReport(dirname(resolve(patchesDir)), [...offending, ...registrations], args.declarationsOut);
230
232
  throw new GeneralError(`Refusing to adopt scanned files into ${patchFilename}: ${parts.join('\n')}\n` +
233
+ (report !== undefined ? `${formatDeclarationsReport(report).join('\n')}\n` : '') +
231
234
  formatForwardImportRemedy({
232
235
  reRunHint: 're-running the scan',
233
236
  issues: [...offending, ...registrations],
@@ -5,20 +5,25 @@
5
5
  * loop calls {@link reExportSinglePatchWithIndexLockRetry} per selected
6
6
  * patch.
7
7
  */
8
+ import { join } from 'node:path';
8
9
  import { stdioIsInteractive } from '../core/destructive.js';
9
10
  import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
10
11
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
11
12
  import { listTrackedInHead } from '../core/git-file-ops.js';
12
13
  import { updatePatchAndMetadata } from '../core/patch-export.js';
14
+ import { withoutFields } from '../core/patch-export-update.js';
13
15
  import { buildLintIgnoreChecks, lintIgnoreRule, mergeLintIgnoreIds, } from '../core/patch-lint-ignore.js';
14
16
  import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
15
17
  import { isGitIndexLockConflict } from '../errors/git.js';
18
+ import { readText } from '../utils/fs.js';
16
19
  import { info, success, warn } from '../utils/logger.js';
17
20
  import { sleep } from '../utils/sleep.js';
18
21
  import { findMissingFiles, reportAdjacentUnmanagedFiles } from './re-export-adjacent.js';
19
22
  import { reportForeignDrift } from './re-export-drift.js';
23
+ import { buildHunkSelectedDiff, hunkSelectionOf } from './re-export-hunks.js';
20
24
  import { assertRefreshedBodyHasNoNewQueueErrors, lintReExportedPatch, refreshQueueCtxEntry, storeReExportLintResult, } from './re-export-lint.js';
21
25
  import { assertScanAdoptionsHaveNoForwardImports, assertScanFileAdditionsHaveDiffHunks, confirmBroadScanAdditions, scanPatchFilesForReExport, } from './re-export-scan.js';
26
+ import { planStaleRegistrationDrop, recordStaleRegistrationDrop, reportStaleRegistrationDrop, } from './re-export-stale-registrations.js';
22
27
  const GIT_INDEX_LOCK_RETRY_MS = 300;
23
28
  /**
24
29
  * Retries a single patch re-export exactly once when the failure is
@@ -40,6 +45,59 @@ export async function reExportSinglePatchWithIndexLockRetry(args) {
40
45
  return await reExportSinglePatch(args);
41
46
  }
42
47
  }
48
+ /**
49
+ * The `--scan` branch: scans, gates the adoption on forward imports,
50
+ * confirms a broad scan and, under `--refuse-adjacent-unmanaged`, judges
51
+ * the neighbours the scan left. Undefined means the patch is not written.
52
+ */
53
+ async function adoptScannedFiles(args, currentFilesAffected) {
54
+ const { patch, paths, manifest, options, isDryRun, config, adjacentCtx } = args;
55
+ const scanResult = await scanPatchFilesForReExport({
56
+ currentFilesAffected,
57
+ engineDir: paths.engine,
58
+ manifest,
59
+ patchFilename: patch.filename,
60
+ isDryRun,
61
+ ...(options.scanFiles !== undefined ? { scanFiles: options.scanFiles } : {}),
62
+ });
63
+ // Forward-import gate at adoption time (runs in dry-run too): refuse
64
+ // to adopt unmanaged files that import modules created by later
65
+ // patches, because the after-the-fact lint failure this leaves behind
66
+ // is the field-reported footgun. Applies to broad --scan, --scan-file,
67
+ // and the --scan-files bulk assignments alike.
68
+ await assertScanAdoptionsHaveNoForwardImports({
69
+ patchesDir: paths.patches,
70
+ engineDir: paths.engine,
71
+ patchFilename: patch.filename,
72
+ added: scanResult.added,
73
+ config,
74
+ declarationsOut: options.declarationsOut,
75
+ });
76
+ if (options.scanFiles === undefined) {
77
+ const proceed = await confirmBroadScanAdditions({
78
+ patchFilename: patch.filename,
79
+ added: scanResult.added,
80
+ isDryRun,
81
+ yes: options.yes === true,
82
+ isInteractive: stdioIsInteractive(),
83
+ });
84
+ if (!proceed)
85
+ return undefined;
86
+ }
87
+ if (adjacentCtx.refuseAdjacentUnmanaged) {
88
+ const adjacentArgs = {
89
+ patch,
90
+ paths,
91
+ manifest,
92
+ currentFilesAffected: scanResult.updated,
93
+ ctx: adjacentCtx,
94
+ afterScan: true,
95
+ };
96
+ if (await reportAdjacentUnmanagedFiles(adjacentArgs))
97
+ return undefined;
98
+ }
99
+ return { updated: scanResult.updated, added: scanResult.added };
100
+ }
43
101
  async function reExportSinglePatch(args) {
44
102
  const { patch, paths, manifest, options, isDryRun, config, adjacentCtx, driftCtx, lintCtx } = args;
45
103
  const removed = new Set(options.expectRemoved ?? []);
@@ -50,41 +108,11 @@ async function reExportSinglePatch(args) {
50
108
  info(` - ${file} (--expect-removed)`);
51
109
  // --- Scan for new/removed files ---
52
110
  if (options.scan) {
53
- const scanResult = await scanPatchFilesForReExport({
54
- currentFilesAffected,
55
- engineDir: paths.engine,
56
- manifest,
57
- patchFilename: patch.filename,
58
- isDryRun,
59
- ...(options.scanFiles !== undefined ? { scanFiles: options.scanFiles } : {}),
60
- });
61
- // Forward-import gate at adoption time (runs in dry-run too): refuse
62
- // to adopt unmanaged files that import modules created by later
63
- // patches, because the after-the-fact lint failure this leaves behind
64
- // is the field-reported footgun. Applies to broad --scan, --scan-file,
65
- // and the --scan-files bulk assignments alike.
66
- await assertScanAdoptionsHaveNoForwardImports({
67
- patchesDir: paths.patches,
68
- engineDir: paths.engine,
69
- patchFilename: patch.filename,
70
- added: scanResult.added,
71
- config,
72
- });
73
- if (options.scanFiles === undefined) {
74
- const isInteractive = stdioIsInteractive();
75
- const proceed = await confirmBroadScanAdditions({
76
- patchFilename: patch.filename,
77
- added: scanResult.added,
78
- isDryRun,
79
- yes: options.yes === true,
80
- isInteractive,
81
- });
82
- if (!proceed) {
83
- return false;
84
- }
85
- }
86
- currentFilesAffected = scanResult.updated;
87
- adoptedFiles = scanResult.added;
111
+ const adopted = await adoptScannedFiles(args, currentFilesAffected);
112
+ if (adopted === undefined)
113
+ return false;
114
+ currentFilesAffected = adopted.updated;
115
+ adoptedFiles = adopted.added;
88
116
  }
89
117
  else if (options.files === undefined) {
90
118
  // When neither `--scan` nor `--files` is set and some of the manifest's
@@ -143,7 +171,17 @@ async function reExportSinglePatch(args) {
143
171
  }
144
172
  const untrackedSet = new Set(untrackedAbsent);
145
173
  const existingFiles = currentFilesAffected.filter((f) => !untrackedSet.has(f));
146
- const diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
174
+ let diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
175
+ const selection = hunkSelectionOf(options);
176
+ if (selection !== undefined) {
177
+ diffContent = await buildHunkSelectedDiff({
178
+ engineDir: paths.engine,
179
+ selection,
180
+ oldBody: await readText(join(paths.patches, patch.filename)),
181
+ normalDiff: diffContent,
182
+ files: existingFiles,
183
+ });
184
+ }
147
185
  assertScanFileAdditionsHaveDiffHunks({
148
186
  diffContent,
149
187
  patchFilename: patch.filename,
@@ -173,6 +211,10 @@ async function reExportSinglePatch(args) {
173
211
  return false;
174
212
  }
175
213
  const { updates, ignoreChecks, effectiveTier, existingIgnoreSet, flagIgnoreSet } = computeReExportUpdates(patch, options, currentFilesAffected);
214
+ const stalePlan = planStaleRegistrationDrop(manifest, patch, currentFilesAffected);
215
+ if (stalePlan !== undefined)
216
+ reportStaleRegistrationDrop(patch.filename, stalePlan, isDryRun);
217
+ const unset = applyStaleRegistrationDrop(stalePlan, updates);
176
218
  enforceProjectedManifestPolicy({ config, manifest, patch, updates, options });
177
219
  // Cross-patch projection gate (runs in dry-run too): the refreshed body
178
220
  // must not introduce queue-level errors the current queue lacks, e.g. an
@@ -188,7 +230,7 @@ async function reExportSinglePatch(args) {
188
230
  // Lint against the once-per-invocation hoisted context (queue context +
189
231
  // queue-wide checkJs program + per-patch result cache) instead of
190
232
  // rebuilding ~37 s of setup for every patch in the loop.
191
- const projectedMetadata = { ...patch, ...updates };
233
+ const projectedMetadata = withoutFields({ ...patch, ...updates }, unset);
192
234
  const lintResult = await lintReExportedPatch({
193
235
  lintCtx,
194
236
  patch,
@@ -222,6 +264,8 @@ async function reExportSinglePatch(args) {
222
264
  lintCtx,
223
265
  diffContent,
224
266
  updates,
267
+ unset,
268
+ stalePlan,
225
269
  projectedMetadata,
226
270
  existingFiles,
227
271
  lintResult,
@@ -263,7 +307,8 @@ async function commitRefreshedPatch(args) {
263
307
  filename: patch.filename,
264
308
  newContent: diffContent,
265
309
  updates,
266
- onCommitted: undefined,
310
+ ...(args.unset.length > 0 ? { unset: args.unset } : {}),
311
+ onCommitted: staleRecorder(paths.patches, patch.filename, args.stalePlan),
267
312
  policyGate: {
268
313
  config,
269
314
  command: 're-export',
@@ -279,10 +324,7 @@ async function commitRefreshedPatch(args) {
279
324
  if (patchIndex !== -1) {
280
325
  const existingEntry = manifest.patches[patchIndex];
281
326
  if (existingEntry) {
282
- manifest.patches[patchIndex] = {
283
- ...existingEntry,
284
- ...updates,
285
- };
327
+ manifest.patches[patchIndex] = withoutFields({ ...existingEntry, ...updates }, args.unset);
286
328
  }
287
329
  }
288
330
  // A bulk run refreshes every patch. Saying "Re-exported" for one whose body
@@ -295,6 +337,24 @@ async function commitRefreshedPatch(args) {
295
337
  info(`Unchanged ${patch.filename}`);
296
338
  }
297
339
  }
340
+ /** The history hook for a write that drops stale registrations, if any. */
341
+ function staleRecorder(patchesDir, patchFilename, plan) {
342
+ if (plan === undefined)
343
+ return undefined;
344
+ return () => recordStaleRegistrationDrop(patchesDir, patchFilename, plan);
345
+ }
346
+ /**
347
+ * Folds the stale-registration drop into `updates`: the kept staged block
348
+ * is set, or the field is returned for unsetting when nothing is left.
349
+ */
350
+ function applyStaleRegistrationDrop(plan, updates) {
351
+ if (plan === undefined)
352
+ return [];
353
+ if (plan.stagedDependencies === undefined)
354
+ return ['stagedDependencies'];
355
+ updates.stagedDependencies = plan.stagedDependencies;
356
+ return [];
357
+ }
298
358
  /**
299
359
  * Threads the patch's own `lintIgnore` list through so the per-patch
300
360
  * suppression honoured by export/export-all is also honoured here. A
@@ -0,0 +1,18 @@
1
+ import type { PatchesManifest, PatchMetadata, PatchStagedDependencies, PatchStagedRegistration } from '../types/commands/index.js';
2
+ /** The holder's registrations split by whether the queue still owns `creates`. */
3
+ export interface StaleRegistrationPlan {
4
+ /** Registrations whose `creates` path no patch owns. */
5
+ dropped: PatchStagedRegistration[];
6
+ /** The staged block to write, or undefined when nothing is left. */
7
+ stagedDependencies: PatchStagedDependencies | undefined;
8
+ }
9
+ /**
10
+ * Finds `patch`'s registrations whose `creates` path no patch in the queue
11
+ * owns, judging `patch` by the file list this re-export will write.
12
+ * Returns undefined when nothing is stale.
13
+ */
14
+ export declare function planStaleRegistrationDrop(manifest: PatchesManifest, patch: PatchMetadata, currentFilesAffected: readonly string[]): StaleRegistrationPlan | undefined;
15
+ /** Names each dropped registration, in both a dry run and a write. */
16
+ export declare function reportStaleRegistrationDrop(patchFilename: string, plan: StaleRegistrationPlan, isDryRun: boolean): void;
17
+ /** Records the dropped declarations as a delta, like `staged-dependency --remove`. */
18
+ export declare function recordStaleRegistrationDrop(patchesDir: string, patchFilename: string, plan: StaleRegistrationPlan): Promise<void>;
@@ -0,0 +1,62 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Re-export prunes a staged registration the queue can no longer satisfy.
4
+ *
5
+ * A merge that deletes a file whose jar.mn line lives in another patch
6
+ * left the staged registration on that patch: re-export kept it, lint then
7
+ * warned `staged-dependency-unused`, and the stale declaration also kept
8
+ * the holder measuring above a just-lowered size ceiling. When no patch in
9
+ * the queue owns a registration's `creates` path any more, the declaration
10
+ * cannot be met, so the holder's own re-export drops it and says so.
11
+ */
12
+ import { appendHistoryBestEffort } from '../core/history-log.js';
13
+ import { info } from '../utils/logger.js';
14
+ /**
15
+ * Finds `patch`'s registrations whose `creates` path no patch in the queue
16
+ * owns, judging `patch` by the file list this re-export will write.
17
+ * Returns undefined when nothing is stale.
18
+ */
19
+ export function planStaleRegistrationDrop(manifest, patch, currentFilesAffected) {
20
+ const registrations = patch.stagedDependencies?.registrations ?? [];
21
+ if (registrations.length === 0)
22
+ return undefined;
23
+ const owned = new Set(currentFilesAffected);
24
+ for (const entry of manifest.patches) {
25
+ if (entry.filename === patch.filename)
26
+ continue;
27
+ for (const file of entry.filesAffected)
28
+ owned.add(file);
29
+ }
30
+ const dropped = registrations.filter((registration) => !owned.has(registration.creates));
31
+ if (dropped.length === 0)
32
+ return undefined;
33
+ const kept = registrations.filter((registration) => owned.has(registration.creates));
34
+ const forwardImports = patch.stagedDependencies?.forwardImports ?? [];
35
+ const staged = {
36
+ ...(forwardImports.length > 0 ? { forwardImports } : {}),
37
+ ...(kept.length > 0 ? { registrations: kept } : {}),
38
+ };
39
+ return {
40
+ dropped,
41
+ stagedDependencies: Object.keys(staged).length > 0 ? staged : undefined,
42
+ };
43
+ }
44
+ /** Names each dropped registration, in both a dry run and a write. */
45
+ export function reportStaleRegistrationDrop(patchFilename, plan, isDryRun) {
46
+ const verb = isDryRun ? 'would drop' : 'drops';
47
+ for (const registration of plan.dropped) {
48
+ info(`${patchFilename}: ${verb} staged registration ${registration.file} "${registration.line.trim()}": ` +
49
+ `no patch in the queue creates ${registration.creates} any more.`);
50
+ }
51
+ }
52
+ /** Records the dropped declarations as a delta, like `staged-dependency --remove`. */
53
+ export async function recordStaleRegistrationDrop(patchesDir, patchFilename, plan) {
54
+ await appendHistoryBestEffort(patchesDir, {
55
+ operation: 're-export-staged-prune',
56
+ args: {
57
+ filename: patchFilename,
58
+ removed: plan.dropped.map((registration) => ({ kind: 'registration', ...registration })),
59
+ },
60
+ result: 'ok',
61
+ }, `re-export pruned stale staged registrations on ${patchFilename}`);
62
+ }