@hominis/fireforge 0.41.0 → 0.43.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 (201) hide show
  1. package/CHANGELOG.md +81 -34
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/cli.js +21 -2
  4. package/dist/src/commands/build.js +7 -1
  5. package/dist/src/commands/config.js +16 -27
  6. package/dist/src/commands/discard.js +1 -1
  7. package/dist/src/commands/export-flow.js +13 -3
  8. package/dist/src/commands/export-placement-conflicts.js +1 -1
  9. package/dist/src/commands/export-shared.d.ts +1 -1
  10. package/dist/src/commands/export-shared.js +4 -5
  11. package/dist/src/commands/furnace/chrome-doc-remove.js +3 -0
  12. package/dist/src/commands/furnace/chrome-doc.js +3 -0
  13. package/dist/src/commands/furnace/create.js +3 -0
  14. package/dist/src/commands/furnace/override.js +3 -0
  15. package/dist/src/commands/furnace/preview.js +3 -0
  16. package/dist/src/commands/furnace/refresh.js +3 -0
  17. package/dist/src/commands/furnace/remove.js +3 -0
  18. package/dist/src/commands/furnace/rename.js +3 -0
  19. package/dist/src/commands/furnace/scan.js +3 -0
  20. package/dist/src/commands/import.js +1 -1
  21. package/dist/src/commands/lint-per-patch.js +16 -8
  22. package/dist/src/commands/lint-per-run-checkjs.d.ts +4 -4
  23. package/dist/src/commands/lint-per-run-checkjs.js +4 -4
  24. package/dist/src/commands/lint-report.js +1 -1
  25. package/dist/src/commands/lint.js +2 -2
  26. package/dist/src/commands/patch/lint-ignore.js +1 -1
  27. package/dist/src/commands/patch/move-files-create.js +5 -3
  28. package/dist/src/commands/patch/move-files-into.d.ts +1 -1
  29. package/dist/src/commands/patch/move-files-into.js +5 -3
  30. package/dist/src/commands/patch/move-files.js +1 -1
  31. package/dist/src/commands/patch/rename.js +1 -1
  32. package/dist/src/commands/patch/reorder.d.ts +1 -2
  33. package/dist/src/commands/patch/reorder.js +1 -2
  34. package/dist/src/commands/patch/split.js +5 -3
  35. package/dist/src/commands/patch/staged-dependency-validate.js +1 -1
  36. package/dist/src/commands/patch/staged-dependency.js +4 -4
  37. package/dist/src/commands/re-export-adjacent.js +1 -1
  38. package/dist/src/commands/re-export-bulk-scan.d.ts +1 -1
  39. package/dist/src/commands/re-export-bulk-scan.js +5 -4
  40. package/dist/src/commands/re-export-drift.d.ts +4 -4
  41. package/dist/src/commands/re-export-drift.js +47 -8
  42. package/dist/src/commands/re-export-lint.d.ts +7 -5
  43. package/dist/src/commands/re-export-lint.js +9 -8
  44. package/dist/src/commands/re-export-single.d.ts +1 -1
  45. package/dist/src/commands/re-export-single.js +5 -4
  46. package/dist/src/commands/re-export.js +26 -14
  47. package/dist/src/commands/reset.js +1 -1
  48. package/dist/src/commands/run.js +1 -1
  49. package/dist/src/commands/source.js +6 -6
  50. package/dist/src/commands/status-check.d.ts +1 -1
  51. package/dist/src/commands/status-check.js +1 -1
  52. package/dist/src/commands/status-json.d.ts +2 -2
  53. package/dist/src/commands/status-json.js +1 -1
  54. package/dist/src/commands/status-output.d.ts +1 -1
  55. package/dist/src/commands/status-output.js +1 -1
  56. package/dist/src/commands/status.js +21 -7
  57. package/dist/src/commands/test-build-phase.d.ts +2 -2
  58. package/dist/src/commands/test-build-phase.js +9 -6
  59. package/dist/src/commands/test-diagnose.js +8 -1
  60. package/dist/src/commands/test-modes.d.ts +2 -2
  61. package/dist/src/commands/test-modes.js +3 -3
  62. package/dist/src/commands/test-register.js +7 -2
  63. package/dist/src/commands/test-run.d.ts +6 -0
  64. package/dist/src/commands/test-run.js +32 -3
  65. package/dist/src/commands/test-stale-gate.js +7 -2
  66. package/dist/src/commands/test.js +4 -4
  67. package/dist/src/commands/tree.js +2 -2
  68. package/dist/src/commands/typecheck.d.ts +2 -1
  69. package/dist/src/commands/typecheck.js +59 -3
  70. package/dist/src/commands/verify.js +26 -1
  71. package/dist/src/core/ast-utils.js +14 -2
  72. package/dist/src/core/branding.d.ts +16 -0
  73. package/dist/src/core/branding.js +18 -2
  74. package/dist/src/core/browser-wire.js +9 -1
  75. package/dist/src/core/build-baseline-types.d.ts +1 -1
  76. package/dist/src/core/build-baseline.d.ts +1 -1
  77. package/dist/src/core/build-baseline.js +1 -1
  78. package/dist/src/core/build-overwrite-guard.d.ts +49 -0
  79. package/dist/src/core/build-overwrite-guard.js +112 -0
  80. package/dist/src/core/build-prepare.d.ts +13 -5
  81. package/dist/src/core/build-prepare.js +42 -9
  82. package/dist/src/core/config-mutate.d.ts +3 -2
  83. package/dist/src/core/config-mutate.js +5 -2
  84. package/dist/src/core/config.d.ts +3 -2
  85. package/dist/src/core/config.js +2 -0
  86. package/dist/src/core/coverage-extend.d.ts +1 -1
  87. package/dist/src/core/coverage-extend.js +1 -1
  88. package/dist/src/core/destructive.d.ts +23 -0
  89. package/dist/src/core/destructive.js +45 -4
  90. package/dist/src/core/diff-hunks.js +31 -17
  91. package/dist/src/core/discard-baseline.d.ts +1 -1
  92. package/dist/src/core/discard-baseline.js +1 -1
  93. package/dist/src/core/display-state.d.ts +52 -0
  94. package/dist/src/core/display-state.js +89 -0
  95. package/dist/src/core/engine-session-lock.d.ts +20 -0
  96. package/dist/src/core/engine-session-lock.js +84 -14
  97. package/dist/src/core/export-moved-code-shape.d.ts +60 -0
  98. package/dist/src/core/export-moved-code-shape.js +129 -0
  99. package/dist/src/core/file-lock.d.ts +44 -0
  100. package/dist/src/core/file-lock.js +157 -42
  101. package/dist/src/core/furnace-apply-helpers.d.ts +1 -1
  102. package/dist/src/core/furnace-apply-helpers.js +1 -1
  103. package/dist/src/core/furnace-apply-output.js +1 -1
  104. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +2 -2
  105. package/dist/src/core/furnace-apply-overwrite-warn.js +2 -2
  106. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +1 -1
  107. package/dist/src/core/furnace-apply-undeploy-actions.js +1 -1
  108. package/dist/src/core/furnace-apply.js +13 -1
  109. package/dist/src/core/furnace-config-custom.d.ts +2 -1
  110. package/dist/src/core/furnace-config-order.d.ts +2 -1
  111. package/dist/src/core/furnace-config-order.js +9 -4
  112. package/dist/src/core/furnace-config-validate.d.ts +4 -3
  113. package/dist/src/core/furnace-config-validate.js +3 -3
  114. package/dist/src/core/furnace-config.js +5 -0
  115. package/dist/src/core/furnace-operation.d.ts +12 -0
  116. package/dist/src/core/furnace-operation.js +125 -1
  117. package/dist/src/core/furnace-rollback.d.ts +17 -0
  118. package/dist/src/core/furnace-rollback.js +35 -1
  119. package/dist/src/core/git-base.js +7 -2
  120. package/dist/src/core/git-diff.js +11 -2
  121. package/dist/src/core/git-readonly-index.d.ts +59 -0
  122. package/dist/src/core/git-readonly-index.js +137 -0
  123. package/dist/src/core/license-headers.d.ts +1 -1
  124. package/dist/src/core/license-headers.js +1 -1
  125. package/dist/src/core/lint-cache.d.ts +15 -5
  126. package/dist/src/core/lint-cache.js +35 -8
  127. package/dist/src/core/mach-known-noise-filter.d.ts +21 -0
  128. package/dist/src/core/mach-known-noise-filter.js +23 -0
  129. package/dist/src/core/mach-mozconfig.js +21 -3
  130. package/dist/src/core/module-resolution-preflight.d.ts +51 -0
  131. package/dist/src/core/module-resolution-preflight.js +155 -0
  132. package/dist/src/core/patch-export.js +2 -1
  133. package/dist/src/core/patch-identifier-suggest.js +29 -3
  134. package/dist/src/core/patch-lint-checkjs.d.ts +5 -5
  135. package/dist/src/core/patch-lint-checkjs.js +6 -6
  136. package/dist/src/core/patch-lint-creators.d.ts +1 -1
  137. package/dist/src/core/patch-lint-creators.js +1 -1
  138. package/dist/src/core/patch-lint-cross.d.ts +1 -1
  139. package/dist/src/core/patch-lint-cross.js +4 -4
  140. package/dist/src/core/patch-lint-css.js +1 -1
  141. package/dist/src/core/patch-lint-diff.d.ts +1 -1
  142. package/dist/src/core/patch-lint-diff.js +1 -1
  143. package/dist/src/core/patch-lint-jsdoc.js +4 -2
  144. package/dist/src/core/patch-lint-module-registration.d.ts +13 -0
  145. package/dist/src/core/patch-lint-module-registration.js +13 -4
  146. package/dist/src/core/patch-lint-mozbuild.d.ts +1 -1
  147. package/dist/src/core/patch-lint-mozbuild.js +1 -1
  148. package/dist/src/core/patch-lint-ownership.d.ts +2 -2
  149. package/dist/src/core/patch-lint-ownership.js +2 -2
  150. package/dist/src/core/patch-lint.d.ts +2 -2
  151. package/dist/src/core/patch-lint.js +3 -3
  152. package/dist/src/core/patch-lock.d.ts +21 -0
  153. package/dist/src/core/patch-lock.js +29 -2
  154. package/dist/src/core/patch-manifest-io.d.ts +2 -1
  155. package/dist/src/core/patch-manifest-io.js +49 -0
  156. package/dist/src/core/patch-parse.d.ts +18 -0
  157. package/dist/src/core/patch-parse.js +24 -1
  158. package/dist/src/core/patch-policy.d.ts +2 -2
  159. package/dist/src/core/patch-policy.js +2 -2
  160. package/dist/src/core/smoke-patterns.d.ts +1 -1
  161. package/dist/src/core/status-binary.d.ts +1 -1
  162. package/dist/src/core/status-binary.js +1 -1
  163. package/dist/src/core/status-classify.d.ts +1 -1
  164. package/dist/src/core/status-classify.js +2 -2
  165. package/dist/src/core/test-harness-crash.d.ts +69 -3
  166. package/dist/src/core/test-harness-crash.js +174 -9
  167. package/dist/src/core/test-stale-check.d.ts +1 -1
  168. package/dist/src/core/test-stale-check.js +1 -1
  169. package/dist/src/core/token-category.js +1 -1
  170. package/dist/src/core/token-manager.d.ts +1 -1
  171. package/dist/src/core/token-manager.js +5 -5
  172. package/dist/src/core/tree-cow.js +1 -1
  173. package/dist/src/core/tree-guard.js +2 -2
  174. package/dist/src/core/tree-store.js +1 -1
  175. package/dist/src/core/typecheck-shim.d.ts +2 -2
  176. package/dist/src/core/typecheck-shim.js +2 -2
  177. package/dist/src/core/typecheck.js +10 -0
  178. package/dist/src/core/xpcshell-profile-dir.js +1 -1
  179. package/dist/src/errors/base.d.ts +22 -1
  180. package/dist/src/errors/base.js +32 -1
  181. package/dist/src/errors/codes.d.ts +10 -0
  182. package/dist/src/errors/codes.js +10 -0
  183. package/dist/src/types/commands/options.d.ts +27 -11
  184. package/dist/src/types/commands/patches.d.ts +1 -1
  185. package/dist/src/types/config.d.ts +2 -2
  186. package/dist/src/types/furnace.d.ts +1 -1
  187. package/dist/src/types/json.d.ts +22 -0
  188. package/dist/src/types/json.js +11 -0
  189. package/dist/src/utils/assert.d.ts +46 -0
  190. package/dist/src/utils/assert.js +83 -0
  191. package/dist/src/utils/build-info.d.ts +1 -1
  192. package/dist/src/utils/build-info.js +1 -1
  193. package/dist/src/utils/logger.d.ts +20 -0
  194. package/dist/src/utils/logger.js +26 -0
  195. package/dist/src/utils/options.d.ts +12 -0
  196. package/dist/src/utils/options.js +39 -0
  197. package/dist/src/utils/stdio-drain.d.ts +1 -1
  198. package/dist/src/utils/stdio-drain.js +1 -1
  199. package/dist/src/utils/validation.d.ts +12 -1
  200. package/dist/src/utils/validation.js +13 -1
  201. package/package.json +3 -3
@@ -41,7 +41,7 @@ async function loadCssTokenContext(repoDir) {
41
41
  /**
42
42
  * Masks CSS block comments with spaces, preserving newlines so line
43
43
  * numbers stay stable. An unclosed trailing `/*` is masked to EOF —
44
- * matching how a CSS parser treats it (FORGE G4).
44
+ * matching how a CSS parser treats it.
45
45
  */
46
46
  function maskCssComments(source) {
47
47
  const masked = source.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
@@ -23,7 +23,7 @@ export declare function extractAddedLinesPerFile(diffContent: string): Map<strin
23
23
  * unified diff. Walking each hunk with a new-file line counter (`+` and
24
24
  * context lines advance it, `-` lines do not) lets rules that need
25
25
  * full-file context — e.g. comment masking across the context/added
26
- * boundary (FORGE G4) — map the patch's additions onto the applied file.
26
+ * boundary — map the patch's additions onto the applied file.
27
27
  */
28
28
  export declare function extractAddedLineNumbersPerFile(diffContent: string): Map<string, number[]>;
29
29
  /**
@@ -50,7 +50,7 @@ export function extractAddedLinesPerFile(diffContent) {
50
50
  * unified diff. Walking each hunk with a new-file line counter (`+` and
51
51
  * context lines advance it, `-` lines do not) lets rules that need
52
52
  * full-file context — e.g. comment masking across the context/added
53
- * boundary (FORGE G4) — map the patch's additions onto the applied file.
53
+ * boundary — map the patch's additions onto the applied file.
54
54
  */
55
55
  export function extractAddedLineNumbersPerFile(diffContent) {
56
56
  const result = new Map();
@@ -133,10 +133,12 @@ function walkForReturn(node) {
133
133
  node.type === 'ArrowFunctionExpression') {
134
134
  return false;
135
135
  }
136
- for (const key of Object.keys(node)) {
136
+ // `entries` erases the node's static shape without asserting a new one;
137
+ // the `any` from `Object.entries`' fallback overload widens to `unknown`.
138
+ const entries = Object.entries(node);
139
+ for (const [key, val] of entries) {
137
140
  if (key === 'type')
138
141
  continue;
139
- const val = node[key];
140
142
  if (val && typeof val === 'object') {
141
143
  if (Array.isArray(val)) {
142
144
  for (const child of val) {
@@ -7,6 +7,19 @@ interface ModuleRegistrationQueueEntry {
7
7
  interface ModuleRegistrationQueueContext {
8
8
  entries: readonly ModuleRegistrationQueueEntry[];
9
9
  }
10
+ /**
11
+ * Maps Firefox's common system-module resource URLs to their source
12
+ * suffix. Exported alongside {@link extractResourceModuleSpecifiers} so
13
+ * the resolution preflight resolves specifiers identically.
14
+ */
15
+ export declare function moduleSourceSuffix(specifier: string): string | undefined;
16
+ /**
17
+ * Resource-module strings in actual code (comments removed), de-duplicated.
18
+ * Exported for the engine-aware resolution preflight, which must recognise
19
+ * exactly the same specifier shapes this queue-level rule does — two
20
+ * extractors would drift.
21
+ */
22
+ export declare function extractResourceModuleSpecifiers(content: string): string[];
10
23
  /**
11
24
  * Flags a new `.sys.mjs` imported through a Firefox resource URL when the
12
25
  * projected queue never adds it to a moz.build list.
@@ -5,8 +5,12 @@
5
5
  import { basename } from 'node:path';
6
6
  import { escapeRegex, stripJsComments } from '../utils/regex.js';
7
7
  const IMPORTABLE_EXTENSIONS = ['.mjs', '.sys.mjs', '.js', '.jsm'];
8
- /** Maps Firefox's common system-module resource URLs to their source suffix. */
9
- function moduleSourceSuffix(specifier) {
8
+ /**
9
+ * Maps Firefox's common system-module resource URLs to their source
10
+ * suffix. Exported alongside {@link extractResourceModuleSpecifiers} so
11
+ * the resolution preflight resolves specifiers identically.
12
+ */
13
+ export function moduleSourceSuffix(specifier) {
10
14
  const cleaned = specifier.split(/[?#]/)[0] ?? specifier;
11
15
  for (const prefix of ['resource:///modules/', 'resource://gre/modules/']) {
12
16
  if (cleaned.startsWith(prefix))
@@ -14,8 +18,13 @@ function moduleSourceSuffix(specifier) {
14
18
  }
15
19
  return undefined;
16
20
  }
17
- /** Resource-module strings in actual code (comments removed), de-duplicated. */
18
- function extractResourceModuleSpecifiers(content) {
21
+ /**
22
+ * Resource-module strings in actual code (comments removed), de-duplicated.
23
+ * Exported for the engine-aware resolution preflight, which must recognise
24
+ * exactly the same specifier shapes this queue-level rule does — two
25
+ * extractors would drift.
26
+ */
27
+ export function extractResourceModuleSpecifiers(content) {
19
28
  const stripped = stripJsComments(content);
20
29
  const found = new Set();
21
30
  const pattern = /["'](resource:\/\/(?:\/|gre\/)modules\/[^"']+\.sys\.mjs(?:[?#][^"']*)?)["']/g;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Per-patch lint check for moz.build sorted-list violations (FORGE F2).
2
+ * Per-patch lint check for moz.build sorted-list violations.
3
3
  *
4
4
  * mozbuild enforces alphabetical ordering on `StrictOrderingOnAppendList`
5
5
  * variables and raises `mozbuild.util.UnsortedError: … expected "X" but got
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
- * Per-patch lint check for moz.build sorted-list violations (FORGE F2).
3
+ * Per-patch lint check for moz.build sorted-list violations.
4
4
  *
5
5
  * mozbuild enforces alphabetical ordering on `StrictOrderingOnAppendList`
6
6
  * variables and raises `mozbuild.util.UnsortedError: … expected "X" but got
@@ -42,7 +42,7 @@ export declare function resolvePatchOwnedSysMjs(currentNewFiles: Set<string>, pa
42
42
  */
43
43
  export declare function resolvePatchOwnedChromeScripts(currentNewFiles: Set<string>, patchQueueCtx?: PatchQueueContext): Set<string>;
44
44
  /**
45
- * Test-script predicate for the `checkJsTestFiles` pass (FORGE G5):
45
+ * Test-script predicate for the `checkJsTestFiles` pass:
46
46
  * plain `.js` files (not `.sys.mjs` modules) that live under a `/test/`
47
47
  * path or carry a `browser_` / `test_` / `xpcshell_` basename. Duplicates
48
48
  * `patch-lint.ts`'s `isTestFile` shape rather than importing it —
@@ -52,7 +52,7 @@ export declare function resolvePatchOwnedChromeScripts(currentNewFiles: Set<stri
52
52
  */
53
53
  export declare function isTestScriptFile(file: string): boolean;
54
54
  /**
55
- * Returns the set of patch-owned test `.js` files (FORGE G5). Same
55
+ * Returns the set of patch-owned test `.js` files. Same
56
56
  * ownership semantics as {@link resolvePatchOwnedSysMjs}; consumed by the
57
57
  * `patchLint.checkJsTestFiles` pass, which checks each as its own small
58
58
  * script-scope program.
@@ -68,7 +68,7 @@ export function resolvePatchOwnedChromeScripts(currentNewFiles, patchQueueCtx) {
68
68
  return resolveOwned(currentNewFiles, patchQueueCtx, (file) => file.endsWith('.js') && !file.endsWith('.sys.mjs'));
69
69
  }
70
70
  /**
71
- * Test-script predicate for the `checkJsTestFiles` pass (FORGE G5):
71
+ * Test-script predicate for the `checkJsTestFiles` pass:
72
72
  * plain `.js` files (not `.sys.mjs` modules) that live under a `/test/`
73
73
  * path or carry a `browser_` / `test_` / `xpcshell_` basename. Duplicates
74
74
  * `patch-lint.ts`'s `isTestFile` shape rather than importing it —
@@ -85,7 +85,7 @@ export function isTestScriptFile(file) {
85
85
  return /^(?:browser_|test_|xpcshell_).*\.js$/.test(basename);
86
86
  }
87
87
  /**
88
- * Returns the set of patch-owned test `.js` files (FORGE G5). Same
88
+ * Returns the set of patch-owned test `.js` files. Same
89
89
  * ownership semantics as {@link resolvePatchOwnedSysMjs}; consumed by the
90
90
  * `patchLint.checkJsTestFiles` pass, which checks each as its own small
91
91
  * script-scope program.
@@ -137,7 +137,7 @@ export type PatchSizeTierDecision = {
137
137
  */
138
138
  export declare function resolvePatchSizeTier(filesAffected: ReadonlyArray<string>, patchTier?: 'branding'): PatchSizeTierDecision;
139
139
  /**
140
- * Read-only view of the size thresholds a tier enforces (FORGE G9).
140
+ * Read-only view of the size thresholds a tier enforces.
141
141
  * Public-API companion to {@link resolvePatchSizeTier} /
142
142
  * {@link countNonBinaryDiffLines} so consumers can build waiver-freshness
143
143
  * checks against the SAME numbers `large-patch-lines` /
@@ -204,7 +204,7 @@ export interface LintExportedPatchOptions {
204
204
  precomputedCheckJs?: readonly PatchLintIssue[];
205
205
  /**
206
206
  * Invoked with the issues DROPPED by the `ignoreChecks` waiver filter,
207
- * when any were (FORGE G10). Suppression semantics are unchanged — the
207
+ * when any were. Suppression semantics are unchanged — the
208
208
  * returned issue list still excludes them — but the caller can report
209
209
  * the measurement a waived size finding carried (per-patch NOTICE lines
210
210
  * and the `--report` JSON) instead of losing it entirely.
@@ -145,7 +145,7 @@ const PATCH_FILES_THRESHOLDS = {
145
145
  branding: 60,
146
146
  };
147
147
  /**
148
- * Shared remedy sentence for the over-budget findings (FORGE H4): the two
148
+ * Shared remedy sentence for the over-budget findings: the two
149
149
  * places an operator stands when a patch outgrows its budget must name the
150
150
  * sanctioned one-transaction way out, or they reach for the wrong tools
151
151
  * first (the field report tried two before finding it). One constant, four
@@ -560,7 +560,7 @@ export function resolvePatchSizeTier(filesAffected, patchTier) {
560
560
  return { tier: 'general' };
561
561
  }
562
562
  /**
563
- * Read-only view of the size thresholds a tier enforces (FORGE G9).
563
+ * Read-only view of the size thresholds a tier enforces.
564
564
  * Public-API companion to {@link resolvePatchSizeTier} /
565
565
  * {@link countNonBinaryDiffLines} so consumers can build waiver-freshness
566
566
  * checks against the SAME numbers `large-patch-lines` /
@@ -735,7 +735,7 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
735
735
  }
736
736
  else if (config.patchLint?.checkJs) {
737
737
  issues.push(...(await invokePatchLintCheckJs(repoDir, patchOwnedFiles, config.patchLint, dirname(repoDir), options?.checkJsReportScope)));
738
- // Export-time twin of per-patch lint's test-file pass (FORGE G5), so
738
+ // Export-time twin of per-patch lint's test-file pass, so
739
739
  // export/re-export and `lint --per-patch` agree on the checked surface.
740
740
  if (config.patchLint.checkJsTestFiles === true) {
741
741
  const testGrouped = await runCheckJsTestFilesGrouped(repoDir, resolvePatchOwnedTestScripts(newFiles, patchQueueCtx), config.patchLint, dirname(repoDir));
@@ -12,6 +12,27 @@ export interface PatchDirectoryLockOptions {
12
12
  /** Command name recorded in the lock's owner metadata for holder diagnostics. */
13
13
  command?: string | undefined;
14
14
  }
15
+ /**
16
+ * Asserts that this process is inside {@link withPatchDirectoryLock} for the
17
+ * given patches directory.
18
+ *
19
+ * The manifest mutators document "under the caller's lock" and are reached
20
+ * from six different command bodies, none of which the mutator can see. This
21
+ * turns that comment into a check at the point it matters: filename
22
+ * allocation and manifest read-modify-writes are only atomic while the lock
23
+ * is held, so an unlocked caller silently reintroduces the interleaving the
24
+ * lock exists to prevent.
25
+ *
26
+ * Fail-open on an unreadable owner record, deliberately: `withFileLock`
27
+ * treats writing that record as non-fatal, so a live holder can legitimately
28
+ * have no readable PID and asserting on its presence would fire on a lock we
29
+ * really do hold.
30
+ *
31
+ * @param patchesDir - The patches directory whose lock must be held
32
+ * @param context - What is about to be done, for the failure message
33
+ * @throws {@link InternalInvariantError} when the lock is not held by us.
34
+ */
35
+ export declare function assertPatchDirectoryLockHeld(patchesDir: string, context: string): Promise<void>;
15
36
  /**
16
37
  * Runs a patch directory mutation while holding an exclusive filesystem lock.
17
38
  * This serializes filename allocation and manifest writes across parallel exports.
@@ -5,9 +5,10 @@
5
5
  import { join } from 'node:path';
6
6
  import { FireForgeError } from '../errors/base.js';
7
7
  import { PatchError } from '../errors/patch.js';
8
+ import { assert } from '../utils/assert.js';
8
9
  import { toError } from '../utils/errors.js';
9
10
  import { info } from '../utils/logger.js';
10
- import { withFileLock } from './file-lock.js';
11
+ import { readLockStatus, withFileLock } from './file-lock.js';
11
12
  const PATCH_DIRECTORY_LOCK = '.fireforge-patches.lock';
12
13
  /**
13
14
  * Formats the periodic operator-facing waiting line printed while
@@ -22,6 +23,32 @@ function formatWaitProgressLine(waitedMs, timeoutMs, holder) {
22
23
  const details = holder.metadata.length > 0 ? ` (${holder.metadata.join(', ')})` : '';
23
24
  return `Waiting for the FireForge patch directory lock held by PID ${String(holder.pid)}${details} — ${progress}.`;
24
25
  }
26
+ /**
27
+ * Asserts that this process is inside {@link withPatchDirectoryLock} for the
28
+ * given patches directory.
29
+ *
30
+ * The manifest mutators document "under the caller's lock" and are reached
31
+ * from six different command bodies, none of which the mutator can see. This
32
+ * turns that comment into a check at the point it matters: filename
33
+ * allocation and manifest read-modify-writes are only atomic while the lock
34
+ * is held, so an unlocked caller silently reintroduces the interleaving the
35
+ * lock exists to prevent.
36
+ *
37
+ * Fail-open on an unreadable owner record, deliberately: `withFileLock`
38
+ * treats writing that record as non-fatal, so a live holder can legitimately
39
+ * have no readable PID and asserting on its presence would fire on a lock we
40
+ * really do hold.
41
+ *
42
+ * @param patchesDir - The patches directory whose lock must be held
43
+ * @param context - What is about to be done, for the failure message
44
+ * @throws {@link InternalInvariantError} when the lock is not held by us.
45
+ */
46
+ export async function assertPatchDirectoryLockHeld(patchesDir, context) {
47
+ const status = await readLockStatus(join(patchesDir, PATCH_DIRECTORY_LOCK));
48
+ assert(status.held, () => `patch directory lock is held before ${context}`);
49
+ assert(status.holder === undefined || status.holder.pid === process.pid, () => `patch directory lock is owned by this process before ${context} ` +
50
+ `(held by PID ${String(status.holder?.pid)}, we are ${String(process.pid)})`);
51
+ }
25
52
  /**
26
53
  * Runs a patch directory mutation while holding an exclusive filesystem lock.
27
54
  * This serializes filename allocation and manifest writes across parallel exports.
@@ -44,7 +71,7 @@ export async function withPatchDirectoryLock(patchesDir, operation, options = {}
44
71
  ...(command !== undefined
45
72
  ? { ownerMetadata: [`command=${command}`, `started=${new Date().toISOString()}`] }
46
73
  : {}),
47
- // Reason first, remedy second (FORGE H5). The remedy is waiting, not
74
+ // Reason first, remedy second. The remedy is waiting, not
48
75
  // deleting: bulk exports/re-exports legitimately hold this lock for
49
76
  // minutes, FireForge reaps genuinely stale locks on its own, and the
50
77
  // old advice ("rm -rf" while a holder was alive) destroyed a live
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { FireForgeError } from '../errors/base.js';
10
10
  import type { PatchesManifest, PatchMetadata } from '../types/commands/index.js';
11
+ import type { JsonObject } from '../types/json.js';
11
12
  /** Filename for the patches manifest */
12
13
  export declare const PATCHES_MANIFEST = "patches.json";
13
14
  /** Internal state returned by loadPatchesManifestState. */
@@ -69,7 +70,7 @@ export declare function savePatchesManifest(patchesDir: string, manifest: Patche
69
70
  * `sourceVersion` derived from legacy `sourceEsrVersion`, into unrelated patch
70
71
  * entries during partial metadata updates.
71
72
  */
72
- export declare function mutatePatchRowsInManifest(patchesDir: string, filenames: readonly string[], mutator: (existing: PatchMetadata, rawExisting: Readonly<Record<string, unknown>>) => PatchManifestRowMutation | null): Promise<PatchManifestRowMutationResult[] | null>;
73
+ export declare function mutatePatchRowsInManifest(patchesDir: string, filenames: readonly string[], mutator: (existing: PatchMetadata, rawExisting: Readonly<JsonObject>) => PatchManifestRowMutation | null): Promise<PatchManifestRowMutationResult[] | null>;
73
74
  /**
74
75
  * Adds or updates a patch entry in the manifest.
75
76
  * @param patchesDir - Path to the patches directory
@@ -13,10 +13,12 @@ import { join } from 'node:path';
13
13
  import { FireForgeError } from '../errors/base.js';
14
14
  import { ExitCode } from '../errors/codes.js';
15
15
  import { PatchManifestCorruptError } from '../errors/patch.js';
16
+ import { assert } from '../utils/assert.js';
16
17
  import { toError } from '../utils/errors.js';
17
18
  import { pathExistsStrict, readJson, removeFile, writeJson } from '../utils/fs.js';
18
19
  import { warn } from '../utils/logger.js';
19
20
  import { isArray, isObject } from '../utils/validation.js';
21
+ import { assertPatchDirectoryLockHeld } from './patch-lock.js';
20
22
  import { validatePatchesManifest } from './patch-manifest-validate.js';
21
23
  /** Filename for the patches manifest */
22
24
  export const PATCHES_MANIFEST = 'patches.json';
@@ -117,6 +119,11 @@ export async function mutatePatchRowsInManifest(patchesDir, filenames, mutator)
117
119
  const existing = beforeManifest.patches[index];
118
120
  if (!existing)
119
121
  continue;
122
+ // Manifest rows come out of JSON.parse and were object-checked above,
123
+ // so the JsonObject contract holds. The row stays a plain record
124
+ // internally because `mutation.set` values carry interface types
125
+ // (`Partial<PatchMetadata>`) that the compiler cannot relate to
126
+ // `JsonValue` structurally.
120
127
  const mutation = mutator(existing, rawPatch);
121
128
  if (!mutation)
122
129
  continue;
@@ -191,6 +198,36 @@ export async function removePatchFromManifest(patchesDir, filename) {
191
198
  await savePatchesManifest(patchesDir, manifest);
192
199
  return true;
193
200
  }
201
+ /**
202
+ * Checks the shape a renumbered manifest is supposed to have, immediately
203
+ * before it becomes durable.
204
+ *
205
+ * Three properties, each of which the queue silently depends on: `order` is
206
+ * unique (two patches claiming one slot make apply order arbitrary),
207
+ * `order` matches the numeric prefix of `filename` (the prefix is what an
208
+ * operator reads and what `patch` commands parse back out), and the rows are
209
+ * sorted ascending (readers take manifest order as apply order without
210
+ * re-sorting).
211
+ *
212
+ * Contiguity itself is deliberately NOT checked: `patchPolicy.ranges`
213
+ * reserves gaps on purpose, so a hole is a legitimate queue shape.
214
+ *
215
+ * @param patches - The manifest rows about to be written
216
+ */
217
+ function assertRenumberedOrderIsWellFormed(patches) {
218
+ const seenOrders = new Set();
219
+ let previousOrder = Number.NEGATIVE_INFINITY;
220
+ for (const patch of patches) {
221
+ assert(!seenOrders.has(patch.order), () => `renumbered patch orders are unique (order ${patch.order} appears twice)`);
222
+ seenOrders.add(patch.order);
223
+ assert(patch.order >= previousOrder, () => `renumbered manifest rows are sorted by order (${patch.filename} is out of place)`);
224
+ previousOrder = patch.order;
225
+ const prefixMatch = /^(\d+)-/.exec(patch.filename);
226
+ assert(prefixMatch !== null, () => `renumbered patch filename carries a numeric prefix (got "${patch.filename}")`);
227
+ assert(Number(prefixMatch[1]) === patch.order, () => `renumbered patch filename prefix matches its order ` +
228
+ `("${patch.filename}" carries order ${patch.order})`);
229
+ }
230
+ }
194
231
  /**
195
232
  * Rewrites `stagedDependencies.forwardImports[].owner` and
196
233
  * `stagedDependencies.registrations[].owner` references on one patch
@@ -275,6 +312,7 @@ export function rewriteStagedDependencyOwners(patch, renameLookup) {
275
312
  export async function renumberPatchesInManifest(patchesDir, renameMap) {
276
313
  if (renameMap.size === 0)
277
314
  return;
315
+ await assertPatchDirectoryLockHeld(patchesDir, 'renumbering the patch manifest');
278
316
  const manifest = await loadPatchesManifestForWrite(patchesDir);
279
317
  if (!manifest) {
280
318
  throw new Error('Cannot renumber patches: patches.json is missing.');
@@ -389,6 +427,16 @@ export async function renumberPatchesInManifest(patchesDir, renameMap) {
389
427
  });
390
428
  updatedPatches.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
391
429
  try {
430
+ // Phase 3 is the one write that makes the renumber visible. Every file
431
+ // has already moved on disk, so a manifest that disagrees with the new
432
+ // filenames is the worst outcome available here — the queue would apply
433
+ // in an order nothing on disk reflects. The callers compute the rename
434
+ // map (compact, reorder, export placement) and each maintains contiguity
435
+ // on its own; this checks the property they are all supposed to produce,
436
+ // at the single point where it becomes durable. Inside the try, so a
437
+ // violation unwinds through the Phase 3 rollback that puts the filenames
438
+ // back.
439
+ assertRenumberedOrderIsWellFormed(updatedPatches);
392
440
  await savePatchesManifest(patchesDir, {
393
441
  ...manifest,
394
442
  patches: updatedPatches,
@@ -459,6 +507,7 @@ export class PatchDeleteRollbackError extends FireForgeError {
459
507
  * @param filename - Patch filename to delete
460
508
  */
461
509
  export async function removePatchFileAndManifest(patchesDir, filename) {
510
+ await assertPatchDirectoryLockHeld(patchesDir, 'deleting a patch and its manifest row');
462
511
  const patchPath = join(patchesDir, filename);
463
512
  // ForWrite: deleting the patch FILE while a corrupt manifest still
464
513
  // references it would strand the queue; abort on corruption instead.
@@ -18,6 +18,24 @@
18
18
  * @returns Order number, or Infinity if no prefix
19
19
  */
20
20
  export declare function extractOrder(filename: string): number;
21
+ /**
22
+ * Extracts the order number from a patch filename that is about to be
23
+ * written into the manifest.
24
+ *
25
+ * The write-side counterpart to {@link extractOrder}. That one answers
26
+ * `Infinity` for a prefix-less filename, which sorts such a patch last and
27
+ * is the right answer for a read: an unparseable name should not crash a
28
+ * status listing. Persisting `Infinity` (or the `0` some call sites used to
29
+ * fall back to) is a different matter — it puts an order in the manifest
30
+ * that no filename on disk agrees with, and the queue then applies in an
31
+ * order the operator cannot see. Callers here have always just built or
32
+ * looked up a real prefixed filename, so absence is a bug, not input.
33
+ *
34
+ * @param filename - Patch filename carrying a numeric order prefix
35
+ * @returns The parsed order
36
+ * @throws {@link InternalInvariantError} when `filename` has no numeric prefix.
37
+ */
38
+ export declare function requirePatchOrder(filename: string): number;
21
39
  /**
22
40
  * A parsed `diff --git` header: both sides' paths, unquoted and with any
23
41
  * CRLF residue stripped.
@@ -1,4 +1,5 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { assert } from '../utils/assert.js';
2
3
  /**
3
4
  * Pure parsing functions for extracting information from patch files.
4
5
  * All functions are synchronous and operate on string content.
@@ -25,6 +26,28 @@ export function extractOrder(filename) {
25
26
  }
26
27
  return Infinity;
27
28
  }
29
+ /**
30
+ * Extracts the order number from a patch filename that is about to be
31
+ * written into the manifest.
32
+ *
33
+ * The write-side counterpart to {@link extractOrder}. That one answers
34
+ * `Infinity` for a prefix-less filename, which sorts such a patch last and
35
+ * is the right answer for a read: an unparseable name should not crash a
36
+ * status listing. Persisting `Infinity` (or the `0` some call sites used to
37
+ * fall back to) is a different matter — it puts an order in the manifest
38
+ * that no filename on disk agrees with, and the queue then applies in an
39
+ * order the operator cannot see. Callers here have always just built or
40
+ * looked up a real prefixed filename, so absence is a bug, not input.
41
+ *
42
+ * @param filename - Patch filename carrying a numeric order prefix
43
+ * @returns The parsed order
44
+ * @throws {@link InternalInvariantError} when `filename` has no numeric prefix.
45
+ */
46
+ export function requirePatchOrder(filename) {
47
+ const match = /^(\d+)-/.exec(filename);
48
+ assert(match?.[1] !== undefined, () => `patch filename carries a numeric order prefix (got "${filename}")`);
49
+ return parseInt(match[1], 10);
50
+ }
28
51
  /**
29
52
  * Un-escapes a path from git's C-style quoted form (`"a/pfad m\303\244..."`).
30
53
  * Handles the escapes git emits: `\\`, `\"`, `\t`, `\n`, `\r` and three-digit
@@ -172,7 +195,7 @@ export function parseDiffSections(diffContent) {
172
195
  // `git diff --binary` always records full blob hashes here (git
173
196
  // apply requires them); text sections carry abbreviated ones. The
174
197
  // hashes let binary sections be compared by identity when their
175
- // payload cannot be applied as text (FORGE J3).
198
+ // payload cannot be applied as text.
176
199
  const indexMatch = /^index ([0-9a-f]{7,64})\.\.([0-9a-f]{7,64})(?: \d{6})?$/.exec(line);
177
200
  if (indexMatch?.[1] !== undefined && indexMatch[2] !== undefined) {
178
201
  current.indexOldHash = indexMatch[1];
@@ -37,14 +37,14 @@ export declare function getPatchPolicyCategories(config: FireForgeConfig): strin
37
37
  export declare function isCategoryAllowedByConfig(config: FireForgeConfig, category: string): boolean;
38
38
  /**
39
39
  * Human-readable label for a category's configured ranges, e.g. `300-399`.
40
- * Exported for the forward-import hint (FORGE F14), which must render the
40
+ * Exported for the forward-import hint, which must render the
41
41
  * range it found no legal ordinal in.
42
42
  */
43
43
  export declare function categoryRangeLabel(ranges: readonly PatchPolicyRange[], category: string): string;
44
44
  /**
45
45
  * Returns the configured range that contains `order` for `category`, or
46
46
  * null when no such range exists. Exported for the forward-import hint
47
- * (FORGE F14), which suppresses ordinal suggestions the reorder policy
47
+ *, which suppresses ordinal suggestions the reorder policy
48
48
  * would refuse.
49
49
  */
50
50
  export declare function categoryRangeForOrder(cfg: PatchPolicyConfig, category: string, order: number): PatchPolicyRange | null;
@@ -47,7 +47,7 @@ function rangeLabel(range) {
47
47
  }
48
48
  /**
49
49
  * Human-readable label for a category's configured ranges, e.g. `300-399`.
50
- * Exported for the forward-import hint (FORGE F14), which must render the
50
+ * Exported for the forward-import hint, which must render the
51
51
  * range it found no legal ordinal in.
52
52
  */
53
53
  export function categoryRangeLabel(ranges, category) {
@@ -62,7 +62,7 @@ function reservedRangeForOrder(cfg, order) {
62
62
  /**
63
63
  * Returns the configured range that contains `order` for `category`, or
64
64
  * null when no such range exists. Exported for the forward-import hint
65
- * (FORGE F14), which suppresses ordinal suggestions the reorder policy
65
+ *, which suppresses ordinal suggestions the reorder policy
66
66
  * would refuse.
67
67
  */
68
68
  export function categoryRangeForOrder(cfg, category, order) {
@@ -20,7 +20,7 @@ export declare const SMOKE_ERROR_PATTERNS: readonly RegExp[];
20
20
  export declare function matchesSmokeError(line: string): boolean;
21
21
  /**
22
22
  * One compiled allowlist entry with its provenance retained so the smoke
23
- * summary can attribute hits per entry (FORGE G8) — an entry that silently
23
+ * summary can attribute hits per entry — an entry that silently
24
24
  * stops matching (its suppressed shape changed upstream) is only
25
25
  * detectable when zero-hit entries are visible.
26
26
  */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Binary-aware drift classification for patch-owned files (FORGE J3).
2
+ * Binary-aware drift classification for patch-owned files.
3
3
  *
4
4
  * The text classifier compares utf-8 decoded content against
5
5
  * `computePatchedContent`, which cannot apply `GIT binary patch` bodies —
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
- * Binary-aware drift classification for patch-owned files (FORGE J3).
3
+ * Binary-aware drift classification for patch-owned files.
4
4
  *
5
5
  * The text classifier compares utf-8 decoded content against
6
6
  * `computePatchedContent`, which cannot apply `GIT binary patch` bodies —
@@ -59,7 +59,7 @@ export interface ClassifiedFile extends StatusFile {
59
59
  * (patch-backed, patch-owned-drift, and single-owner furnace entries).
60
60
  * Unset for unowned, branding-generated, unowned-furnace, and conflict
61
61
  * entries — conflicts carry `claimedBy` instead. Exposed through
62
- * `status --json` as the `patch` field (FORGE G11).
62
+ * `status --json` as the `patch` field.
63
63
  */
64
64
  owner?: string;
65
65
  }
@@ -76,7 +76,7 @@ async function classifySingleOwnerFile(entry, engineDir, patchesDir, matchClassi
76
76
  // Binary comparisons cannot go through the utf-8 content path below —
77
77
  // binary patch bodies parse to zero hunks, so the patched-content
78
78
  // computation returned HEAD content unchanged and the comparison
79
- // reported `patch-owned-drift` forever (FORGE J3). Settle by blob hash,
79
+ // reported `patch-owned-drift` forever. Settle by blob hash,
80
80
  // or classify explicitly as `binary-unsupported` when no hash exists.
81
81
  const binaryResult = await classifyBinaryOwnedFile({
82
82
  entry,
@@ -133,7 +133,7 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
133
133
  // One manifest load + patch discovery + memoized body reads for the
134
134
  // whole batch — the previous per-file computation re-ran all three for
135
135
  // every dirty file (O(dirtyFiles × patches) redundant IO on a broad
136
- // engine edit session, FORGE K1).
136
+ // engine edit session).
137
137
  const ctx = await createPatchedContentContext(patchesDir, engineDir);
138
138
  // Build a multimap from file path → list of claiming patch
139
139
  // filenames so we can detect cross-patch ownership conflicts. The