@hominis/fireforge 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +35 -5
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +27 -3
  6. package/dist/src/commands/bootstrap.js +5 -1
  7. package/dist/src/commands/discard.js +2 -22
  8. package/dist/src/commands/doctor/post-rebase-audit.js +27 -4
  9. package/dist/src/commands/doctor-check-core.d.ts +20 -0
  10. package/dist/src/commands/doctor-check-core.js +28 -0
  11. package/dist/src/commands/doctor-external-toolchains.js +2 -0
  12. package/dist/src/commands/doctor-furnace-manifest-sync.js +144 -46
  13. package/dist/src/commands/doctor-furnace.js +13 -11
  14. package/dist/src/commands/doctor-orphaned-harness.js +2 -1
  15. package/dist/src/commands/doctor.js +2 -2
  16. package/dist/src/commands/export-all.js +9 -1
  17. package/dist/src/commands/export-flow.js +27 -9
  18. package/dist/src/commands/export-placement-conflicts.d.ts +17 -0
  19. package/dist/src/commands/export-placement-conflicts.js +74 -0
  20. package/dist/src/commands/export-placement-gate.js +1 -1
  21. package/dist/src/commands/export-shared.d.ts +8 -1
  22. package/dist/src/commands/export-shared.js +30 -10
  23. package/dist/src/commands/export.js +2 -2
  24. package/dist/src/commands/furnace/apply.js +4 -3
  25. package/dist/src/commands/furnace/chrome-doc-remove.js +13 -2
  26. package/dist/src/commands/furnace/chrome-doc.js +13 -2
  27. package/dist/src/commands/furnace/create-browser-test.d.ts +5 -0
  28. package/dist/src/commands/furnace/create-browser-test.js +5 -0
  29. package/dist/src/commands/furnace/create.js +2 -2
  30. package/dist/src/commands/furnace/diff.js +2 -1
  31. package/dist/src/commands/furnace/init.js +4 -6
  32. package/dist/src/commands/furnace/override.js +2 -2
  33. package/dist/src/commands/furnace/remove-mutations.d.ts +40 -0
  34. package/dist/src/commands/furnace/remove-mutations.js +171 -0
  35. package/dist/src/commands/furnace/remove.js +21 -128
  36. package/dist/src/commands/furnace/rename-browser-test.js +54 -5
  37. package/dist/src/commands/furnace/scan.js +27 -36
  38. package/dist/src/commands/furnace/status.js +30 -28
  39. package/dist/src/commands/furnace/validate.js +25 -18
  40. package/dist/src/commands/import.js +6 -20
  41. package/dist/src/commands/lint-per-patch.js +73 -51
  42. package/dist/src/commands/lint-per-run-checkjs.d.ts +64 -0
  43. package/dist/src/commands/lint-per-run-checkjs.js +102 -0
  44. package/dist/src/commands/lint-report.d.ts +21 -0
  45. package/dist/src/commands/lint-report.js +62 -0
  46. package/dist/src/commands/lint.js +26 -22
  47. package/dist/src/commands/manifest.js +3 -1
  48. package/dist/src/commands/package.js +9 -16
  49. package/dist/src/commands/patch/compact.js +5 -5
  50. package/dist/src/commands/patch/delete.js +5 -5
  51. package/dist/src/commands/patch/lint-ignore.js +3 -2
  52. package/dist/src/commands/patch/move-files-create.js +22 -4
  53. package/dist/src/commands/patch/move-files-into.js +14 -10
  54. package/dist/src/commands/patch/move-files.js +6 -5
  55. package/dist/src/commands/patch/rename.d.ts +2 -2
  56. package/dist/src/commands/patch/rename.js +152 -51
  57. package/dist/src/commands/patch/reorder.d.ts +9 -0
  58. package/dist/src/commands/patch/reorder.js +14 -10
  59. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  60. package/dist/src/commands/patch/split-plan.js +22 -12
  61. package/dist/src/commands/patch/split.d.ts +1 -1
  62. package/dist/src/commands/patch/split.js +23 -9
  63. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  64. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  65. package/dist/src/commands/patch/staged-dependency.js +112 -14
  66. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  67. package/dist/src/commands/re-export-adjacent.js +105 -0
  68. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  69. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  70. package/dist/src/commands/re-export-drift.d.ts +74 -0
  71. package/dist/src/commands/re-export-drift.js +234 -0
  72. package/dist/src/commands/re-export-files.js +5 -3
  73. package/dist/src/commands/re-export-lint.d.ts +97 -0
  74. package/dist/src/commands/re-export-lint.js +210 -0
  75. package/dist/src/commands/re-export-options.js +11 -0
  76. package/dist/src/commands/re-export-register.d.ts +18 -0
  77. package/dist/src/commands/re-export-register.js +61 -0
  78. package/dist/src/commands/re-export-scan.js +13 -7
  79. package/dist/src/commands/re-export-single.d.ts +20 -0
  80. package/dist/src/commands/re-export-single.js +261 -0
  81. package/dist/src/commands/re-export.d.ts +0 -4
  82. package/dist/src/commands/re-export.js +123 -289
  83. package/dist/src/commands/rebase/abort.js +13 -4
  84. package/dist/src/commands/rebase/continue.js +11 -4
  85. package/dist/src/commands/rebase/index.js +19 -29
  86. package/dist/src/commands/rebase/patch-loop.js +12 -0
  87. package/dist/src/commands/reset.js +4 -23
  88. package/dist/src/commands/resolve.js +2 -9
  89. package/dist/src/commands/run.js +29 -23
  90. package/dist/src/commands/source.js +3 -9
  91. package/dist/src/commands/status-check.d.ts +48 -0
  92. package/dist/src/commands/status-check.js +106 -0
  93. package/dist/src/commands/status-json.d.ts +31 -0
  94. package/dist/src/commands/status-json.js +76 -0
  95. package/dist/src/commands/status-output.d.ts +1 -0
  96. package/dist/src/commands/status-output.js +10 -1
  97. package/dist/src/commands/status-ownership.d.ts +52 -0
  98. package/dist/src/commands/status-ownership.js +57 -0
  99. package/dist/src/commands/status.js +190 -170
  100. package/dist/src/commands/test-build-phase.d.ts +19 -0
  101. package/dist/src/commands/test-build-phase.js +168 -0
  102. package/dist/src/commands/test-diagnose.js +30 -2
  103. package/dist/src/commands/test-modes.d.ts +9 -1
  104. package/dist/src/commands/test-modes.js +46 -13
  105. package/dist/src/commands/test-register.js +59 -27
  106. package/dist/src/commands/test-run.d.ts +29 -3
  107. package/dist/src/commands/test-run.js +59 -7
  108. package/dist/src/commands/test-verdict.d.ts +32 -0
  109. package/dist/src/commands/test-verdict.js +48 -0
  110. package/dist/src/commands/test.d.ts +9 -0
  111. package/dist/src/commands/test.js +100 -123
  112. package/dist/src/commands/token-coverage.js +4 -9
  113. package/dist/src/commands/token.js +4 -1
  114. package/dist/src/commands/tree.d.ts +19 -0
  115. package/dist/src/commands/tree.js +320 -0
  116. package/dist/src/commands/verify.js +2 -2
  117. package/dist/src/commands/watch.js +22 -21
  118. package/dist/src/commands/wire.js +1 -1
  119. package/dist/src/core/ast-utils.d.ts +11 -0
  120. package/dist/src/core/ast-utils.js +11 -0
  121. package/dist/src/core/build-audit-platform.js +6 -1
  122. package/dist/src/core/build-audit-registration.js +2 -0
  123. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  124. package/dist/src/core/build-audit-resolve.js +34 -13
  125. package/dist/src/core/build-audit.js +4 -20
  126. package/dist/src/core/build-baseline-types.d.ts +29 -8
  127. package/dist/src/core/build-baseline-types.js +2 -1
  128. package/dist/src/core/build-baseline.d.ts +10 -2
  129. package/dist/src/core/build-baseline.js +39 -8
  130. package/dist/src/core/build-prepare.d.ts +4 -0
  131. package/dist/src/core/build-prepare.js +7 -1
  132. package/dist/src/core/config-paths.d.ts +1 -1
  133. package/dist/src/core/config-paths.js +2 -0
  134. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  135. package/dist/src/core/config-validate.js +45 -30
  136. package/dist/src/core/config.js +16 -3
  137. package/dist/src/core/coverage-extend.d.ts +89 -0
  138. package/dist/src/core/coverage-extend.js +188 -0
  139. package/dist/src/core/destructive.js +3 -1
  140. package/dist/src/core/discard-baseline.js +18 -4
  141. package/dist/src/core/engine-precondition.d.ts +35 -0
  142. package/dist/src/core/engine-precondition.js +50 -0
  143. package/dist/src/core/engine-session-lock.d.ts +38 -1
  144. package/dist/src/core/engine-session-lock.js +78 -4
  145. package/dist/src/core/file-lock.d.ts +7 -0
  146. package/dist/src/core/file-lock.js +20 -30
  147. package/dist/src/core/firefox.js +5 -2
  148. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  149. package/dist/src/core/furnace-apply-helpers.js +12 -3
  150. package/dist/src/core/furnace-apply-output.js +9 -0
  151. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  152. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  153. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  154. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  155. package/dist/src/core/furnace-apply.d.ts +16 -4
  156. package/dist/src/core/furnace-apply.js +39 -43
  157. package/dist/src/core/furnace-config.js +2 -0
  158. package/dist/src/core/furnace-css-fragments.js +2 -0
  159. package/dist/src/core/furnace-jsconfig.js +14 -7
  160. package/dist/src/core/furnace-registration-ast.js +8 -8
  161. package/dist/src/core/furnace-registration-remove.js +6 -3
  162. package/dist/src/core/furnace-registration.js +9 -12
  163. package/dist/src/core/furnace-rollback.js +1 -1
  164. package/dist/src/core/furnace-scanner.js +2 -1
  165. package/dist/src/core/furnace-stale-export.js +2 -1
  166. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  167. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  168. package/dist/src/core/furnace-validate-helpers.js +33 -1
  169. package/dist/src/core/furnace-validate-registration.js +16 -0
  170. package/dist/src/core/furnace-validate.js +4 -2
  171. package/dist/src/core/git-diff.js +37 -2
  172. package/dist/src/core/lint-cache.d.ts +15 -5
  173. package/dist/src/core/lint-cache.js +49 -14
  174. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  175. package/dist/src/core/mach-build-artifacts.js +108 -2
  176. package/dist/src/core/mach-resource-shim.js +1 -0
  177. package/dist/src/core/marionette-port.d.ts +16 -0
  178. package/dist/src/core/marionette-port.js +93 -8
  179. package/dist/src/core/marionette-preflight.js +6 -5
  180. package/dist/src/core/patch-apply.d.ts +20 -13
  181. package/dist/src/core/patch-apply.js +32 -33
  182. package/dist/src/core/patch-export.d.ts +2 -2
  183. package/dist/src/core/patch-export.js +4 -6
  184. package/dist/src/core/patch-lint-checkjs.d.ts +42 -2
  185. package/dist/src/core/patch-lint-checkjs.js +132 -8
  186. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  187. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  188. package/dist/src/core/patch-lint-creators.js +45 -0
  189. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  190. package/dist/src/core/patch-lint-cross.js +16 -32
  191. package/dist/src/core/patch-lint-css.js +64 -16
  192. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  193. package/dist/src/core/patch-lint-diff.js +31 -0
  194. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  195. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  196. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  197. package/dist/src/core/patch-lint-module-registration.js +88 -0
  198. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  199. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  200. package/dist/src/core/patch-lint-ownership.js +30 -0
  201. package/dist/src/core/patch-lint-reexports.d.ts +2 -1
  202. package/dist/src/core/patch-lint-reexports.js +1 -0
  203. package/dist/src/core/patch-lint.d.ts +31 -0
  204. package/dist/src/core/patch-lint.js +114 -8
  205. package/dist/src/core/patch-lock.d.ts +12 -1
  206. package/dist/src/core/patch-lock.js +40 -4
  207. package/dist/src/core/patch-manifest-consistency.js +15 -3
  208. package/dist/src/core/patch-manifest.d.ts +1 -1
  209. package/dist/src/core/patch-parse.d.ts +8 -0
  210. package/dist/src/core/patch-parse.js +10 -0
  211. package/dist/src/core/patch-policy.d.ts +7 -0
  212. package/dist/src/core/patch-policy.js +4 -1
  213. package/dist/src/core/patch-transform.d.ts +10 -0
  214. package/dist/src/core/patch-transform.js +13 -2
  215. package/dist/src/core/rebase-session.d.ts +44 -2
  216. package/dist/src/core/rebase-session.js +114 -17
  217. package/dist/src/core/register-browser-content.js +2 -1
  218. package/dist/src/core/register-shared-css.js +2 -1
  219. package/dist/src/core/smoke-patterns.d.ts +24 -16
  220. package/dist/src/core/smoke-patterns.js +26 -14
  221. package/dist/src/core/status-binary.d.ts +55 -0
  222. package/dist/src/core/status-binary.js +124 -0
  223. package/dist/src/core/status-classify.d.ts +22 -1
  224. package/dist/src/core/status-classify.js +112 -70
  225. package/dist/src/core/test-harness-crash.d.ts +32 -1
  226. package/dist/src/core/test-harness-crash.js +59 -4
  227. package/dist/src/core/test-path-scope.js +2 -0
  228. package/dist/src/core/test-stale-check.js +1 -20
  229. package/dist/src/core/token-category.d.ts +40 -0
  230. package/dist/src/core/token-category.js +264 -0
  231. package/dist/src/core/token-manager.d.ts +7 -0
  232. package/dist/src/core/token-manager.js +48 -196
  233. package/dist/src/core/toolchain-preflight.js +24 -26
  234. package/dist/src/core/tree-cow.d.ts +18 -0
  235. package/dist/src/core/tree-cow.js +71 -0
  236. package/dist/src/core/tree-guard.d.ts +35 -0
  237. package/dist/src/core/tree-guard.js +179 -0
  238. package/dist/src/core/tree-store.d.ts +129 -0
  239. package/dist/src/core/tree-store.js +452 -0
  240. package/dist/src/core/typecheck-shim.d.ts +14 -0
  241. package/dist/src/core/typecheck-shim.js +41 -2
  242. package/dist/src/core/typecheck.js +2 -1
  243. package/dist/src/core/wire-destroy.js +14 -3
  244. package/dist/src/core/wire-init.js +37 -40
  245. package/dist/src/core/wire-subscript.js +11 -3
  246. package/dist/src/core/wire-utils.js +4 -4
  247. package/dist/src/core/xpcshell-appdir.js +3 -0
  248. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  249. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  250. package/dist/src/errors/base.d.ts +13 -0
  251. package/dist/src/errors/base.js +13 -0
  252. package/dist/src/errors/git.d.ts +8 -0
  253. package/dist/src/errors/git.js +17 -0
  254. package/dist/src/errors/rebase.d.ts +13 -0
  255. package/dist/src/errors/rebase.js +17 -0
  256. package/dist/src/index.d.ts +2 -0
  257. package/dist/src/index.js +1 -0
  258. package/dist/src/types/commands/options.d.ts +119 -3
  259. package/dist/src/types/commands/patches.d.ts +8 -0
  260. package/dist/src/types/config.d.ts +19 -0
  261. package/dist/src/types/furnace.d.ts +7 -0
  262. package/dist/src/utils/build-info.d.ts +31 -0
  263. package/dist/src/utils/build-info.js +98 -0
  264. package/dist/src/utils/concurrency.d.ts +8 -0
  265. package/dist/src/utils/concurrency.js +21 -0
  266. package/dist/src/utils/errors.d.ts +39 -0
  267. package/dist/src/utils/errors.js +55 -0
  268. package/dist/src/utils/fs.js +5 -12
  269. package/dist/src/utils/logger.d.ts +14 -2
  270. package/dist/src/utils/logger.js +43 -13
  271. package/dist/src/utils/options.d.ts +21 -4
  272. package/dist/src/utils/options.js +26 -6
  273. package/dist/src/utils/package-root.js +7 -4
  274. package/dist/src/utils/process-group.js +13 -2
  275. package/dist/src/utils/stdio-drain.d.ts +28 -0
  276. package/dist/src/utils/stdio-drain.js +54 -0
  277. package/dist/src/utils/validation.d.ts +33 -2
  278. package/dist/src/utils/validation.js +57 -2
  279. package/package.json +13 -13
@@ -10,7 +10,7 @@ import { toError } from '../utils/errors.js';
10
10
  import { pathExists, readText, writeText } from '../utils/fs.js';
11
11
  import { verbose, warn } from '../utils/logger.js';
12
12
  import { escapeRegex } from '../utils/regex.js';
13
- import { detectIndent, getNodeSource, parseScript, walkAST, } from './ast-utils.js';
13
+ import { asEstree, detectIndent, getNodeSource, parseScript, walkAST, } from './ast-utils.js';
14
14
  import { CUSTOM_ELEMENTS_JS } from './furnace-constants.js';
15
15
  import { validateRegistrationPlacement, validateTagName } from './furnace-registration-validate.js';
16
16
  /**
@@ -62,7 +62,7 @@ function isInsideDOMContentLoaded(ancestors, content) {
62
62
  const ancestor = ancestors[i];
63
63
  if (!ancestor || ancestor.type !== 'CallExpression')
64
64
  continue;
65
- const call = ancestor;
65
+ const call = asEstree(ancestor);
66
66
  if (call.callee.type === 'MemberExpression' &&
67
67
  call.callee.object.type === 'Identifier' &&
68
68
  call.callee.object.name === 'document' &&
@@ -87,18 +87,18 @@ function collectArrayDeclarations(ast) {
87
87
  enter(node) {
88
88
  if (node.type !== 'VariableDeclarator')
89
89
  return;
90
- const declarator = node;
90
+ const declarator = asEstree(node);
91
91
  if (declarator.id.type !== 'Identifier' || declarator.init?.type !== 'ArrayExpression') {
92
92
  return;
93
93
  }
94
- arrays.set(declarator.id.name, declarator.init);
94
+ arrays.set(declarator.id.name, asEstree(declarator.init));
95
95
  },
96
96
  });
97
97
  return arrays;
98
98
  }
99
99
  function resolveForOfArray(right, declaredArrays) {
100
100
  if (right.type === 'ArrayExpression') {
101
- return right;
101
+ return asEstree(right);
102
102
  }
103
103
  if (right.type === 'Identifier') {
104
104
  return declaredArrays.get(right.name);
@@ -148,7 +148,7 @@ function addRegistrationAST(content, tagName, modulePath, isESModule, markerComm
148
148
  enter(node) {
149
149
  ancestors.push(node);
150
150
  if (node.type === 'ForOfStatement') {
151
- const forOf = node;
151
+ const forOf = asEstree(node);
152
152
  const array = resolveForOfArray(forOf.right, declaredArrays);
153
153
  if (array) {
154
154
  forOfs.push({
@@ -170,7 +170,7 @@ function addRegistrationAST(content, tagName, modulePath, isESModule, markerComm
170
170
  for (const el of array.elements) {
171
171
  if (!el || el.type !== 'ArrayExpression')
172
172
  continue;
173
- const entryArr = el;
173
+ const entryArr = asEstree(el);
174
174
  const firstEl = entryArr.elements[0];
175
175
  if (!firstEl || firstEl.type !== 'Literal')
176
176
  continue;
@@ -181,7 +181,7 @@ function addRegistrationAST(content, tagName, modulePath, isESModule, markerComm
181
181
  const indent = detectIndent(content, entryArr.start);
182
182
  let innerIndent;
183
183
  if (isMultiLine) {
184
- const firstElNode = firstEl;
184
+ const firstElNode = asEstree(firstEl);
185
185
  innerIndent = detectIndent(content, firstElNode.start);
186
186
  }
187
187
  entries.push({ tag, node: entryArr, isMultiLine, indent, innerIndent });
@@ -25,7 +25,7 @@
25
25
  import { join } from 'node:path';
26
26
  import MagicString from 'magic-string';
27
27
  import { pathExists, readText, writeText } from '../utils/fs.js';
28
- import { parseScript, walkAST } from './ast-utils.js';
28
+ import { asEstree, parseScript, walkAST } from './ast-utils.js';
29
29
  import { CUSTOM_ELEMENTS_JS } from './furnace-constants.js';
30
30
  /**
31
31
  * Expands `[start, end)` to consume a trailing comma and any whitespace up
@@ -147,7 +147,7 @@ function collectRemovalRanges(ast, content, tagName) {
147
147
  }
148
148
  // Standalone call: customElements.setElementCreationCallback(tag, …)
149
149
  if (node.type === 'CallExpression') {
150
- const call = node;
150
+ const call = asEstree(node);
151
151
  if (isStandaloneCallbackCallFor(call, tagName)) {
152
152
  // Find the enclosing statement so we can delete `call(...);` as
153
153
  // a unit, not just the call expression body.
@@ -158,7 +158,7 @@ function collectRemovalRanges(ast, content, tagName) {
158
158
  continue;
159
159
  if (ancestor.type === 'ExpressionStatement' ||
160
160
  ancestor.type === 'VariableDeclaration') {
161
- enclosing = ancestor;
161
+ enclosing = asEstree(ancestor);
162
162
  break;
163
163
  }
164
164
  }
@@ -203,6 +203,9 @@ export async function removeCustomElementRegistration(engineDir, tagName) {
203
203
  ast = parseScript(content);
204
204
  }
205
205
  catch {
206
+ // An unparsable customElements.js is left untouched on purpose: this
207
+ // function only removes registrations, and editing a file it cannot parse
208
+ // risks corrupting it. The caller's regex fallback handles the retry.
206
209
  return;
207
210
  }
208
211
  const ranges = collectRemovalRanges(ast, content, tagName);
@@ -2,10 +2,6 @@
2
2
  import { join } from 'node:path';
3
3
  import { FurnaceError } from '../errors/furnace.js';
4
4
  import { pathExists, readText, writeText } from '../utils/fs.js';
5
- /** Escapes special regex characters in a literal string. */
6
- function escapeForRegex(str) {
7
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
- }
9
5
  /**
10
6
  * Detects the indentation used by existing `content/global/elements/` lines
11
7
  * in jar.mn. Falls back to 3 spaces (the historical Firefox convention) when
@@ -22,6 +18,7 @@ function detectJarMnIndent(lines) {
22
18
  }
23
19
  // Re-export everything from the AST module so existing imports keep working
24
20
  export { addCustomElementRegistration, removeCustomElementRegistration, validateCustomElementRegistration, } from './furnace-registration-ast.js';
21
+ import { escapeRegex } from '../utils/regex.js';
25
22
  import { JAR_MN } from './furnace-constants.js';
26
23
  /**
27
24
  * Adds jar.mn entries that map chrome:// URIs to on-disk paths for a
@@ -66,7 +63,7 @@ export async function addJarMnEntries(engineDir, tagName, files) {
66
63
  const lines = content.split('\n');
67
64
  // Filter to files not already registered. Use a word-boundary-aware
68
65
  // check so that "moz-card.css" does not match "moz-card-group.css".
69
- const newFiles = files.filter((f) => !new RegExp(`content/global/elements/${escapeForRegex(f)}(?:\\s|$)`, 'm').test(content));
66
+ const newFiles = files.filter((f) => !new RegExp(`content/global/elements/${escapeRegex(f)}(?:\\s|$)`, 'm').test(content));
70
67
  if (newFiles.length === 0)
71
68
  return 0;
72
69
  // Build new entry lines using the indent detected from existing entries.
@@ -141,8 +138,8 @@ export async function addLocaleFtlJarMnEntry(engineDir, jarMnRelPath, tagName, c
141
138
  const content = await readText(filePath);
142
139
  const lines = content.split('\n');
143
140
  const ftlFile = `${tagName}.ftl`;
144
- const escapedTag = escapeForRegex(tagName);
145
- const escapedChrome = escapeForRegex(chromeSubPath);
141
+ const escapedTag = escapeRegex(tagName);
142
+ const escapedChrome = escapeRegex(chromeSubPath);
146
143
  const presencePattern = new RegExp(`locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapedChrome}\\/${escapedTag}\\.ftl`, 'm');
147
144
  if (presencePattern.test(content)) {
148
145
  return 0;
@@ -176,7 +173,7 @@ export async function addLocaleFtlJarMnEntry(engineDir, jarMnRelPath, tagName, c
176
173
  }
177
174
  /** Detects locale jar.mn indentation by sampling an existing matching entry. */
178
175
  function detectLocaleJarMnIndent(lines, chromeSubPath) {
179
- const escapedChrome = escapeForRegex(chromeSubPath);
176
+ const escapedChrome = escapeRegex(chromeSubPath);
180
177
  const pattern = new RegExp(`^(\\s+)locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapedChrome}\\/`);
181
178
  for (const line of lines) {
182
179
  const match = pattern.exec(line);
@@ -202,7 +199,7 @@ export async function removeLocaleFtlJarMnEntry(engineDir, jarMnRelPath, tagName
202
199
  }
203
200
  const content = await readText(filePath);
204
201
  const lines = content.split('\n');
205
- const pattern = new RegExp(`locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapeForRegex(chromeSubPath)}\\/${escapeForRegex(tagName)}\\.ftl`);
202
+ const pattern = new RegExp(`locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapeRegex(chromeSubPath)}\\/${escapeRegex(tagName)}\\.ftl`);
206
203
  const filtered = lines.filter((line) => !pattern.test(line));
207
204
  if (filtered.length === lines.length)
208
205
  return;
@@ -232,8 +229,8 @@ export async function removeJarMnEntries(engineDir, tagName) {
232
229
  // report). The legacy target-path match is kept as an OR for lines
233
230
  // written by older FireForge versions without a source mapping. Word
234
231
  // boundaries keep "moz-card" from matching "moz-card-group".
235
- const sourcePattern = new RegExp(`\\(widgets/${escapeForRegex(tagName)}/`);
236
- const legacyTargetPattern = new RegExp(`content/global/elements/${escapeForRegex(tagName)}\\.`);
232
+ const sourcePattern = new RegExp(`\\(widgets/${escapeRegex(tagName)}/`);
233
+ const legacyTargetPattern = new RegExp(`content/global/elements/${escapeRegex(tagName)}\\.`);
237
234
  const filtered = lines.filter((line) => !sourcePattern.test(line) && !legacyTargetPattern.test(line));
238
235
  if (filtered.length === lines.length)
239
236
  return;
@@ -296,7 +293,7 @@ export async function validateJarMnInsertionForFiles(engineDir, tagName, files)
296
293
  }
297
294
  const content = await readText(filePath);
298
295
  const lines = content.split('\n');
299
- const newFiles = files.filter((f) => !new RegExp(`content/global/elements/${escapeForRegex(f)}(?:\\s|$)`, 'm').test(content));
296
+ const newFiles = files.filter((f) => !new RegExp(`content/global/elements/${escapeRegex(f)}(?:\\s|$)`, 'm').test(content));
300
297
  if (newFiles.length === 0)
301
298
  return;
302
299
  const elementLinePattern = /^\s+content\/global\/elements\/([^.]+)\./;
@@ -108,7 +108,7 @@ export async function restoreRollbackJournal(journal) {
108
108
  catch (error) {
109
109
  errors.push({
110
110
  path: filePath,
111
- error: error instanceof Error ? error.message : String(error),
111
+ error: toError(error).message,
112
112
  });
113
113
  }
114
114
  }
@@ -2,6 +2,7 @@
2
2
  import { createHash } from 'node:crypto';
3
3
  import { readdir } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
+ import { toError } from '../utils/errors.js';
5
6
  import { pathExists, readText } from '../utils/fs.js';
6
7
  import { verbose, warn } from '../utils/logger.js';
7
8
  import { asEstree, getNodeSource, parseScript, walkAST } from './ast-utils.js';
@@ -116,7 +117,7 @@ export async function scanCustomElementsRegistrations(engineDir) {
116
117
  // Best-effort scanner: if upstream syntax changes or the file is damaged,
117
118
  // fall back to the old literal callback heuristic instead of failing the
118
119
  // whole scan command.
119
- const reason = parseError instanceof Error ? parseError.message : String(parseError);
120
+ const reason = toError(parseError).message;
120
121
  warn(`AST parsing of customElements.js failed (${reason}). Falling back to regex-based heuristic. ` +
121
122
  'Results may be incomplete — run "fireforge furnace validate" to verify registration consistency.');
122
123
  verbose(`Scanner regex fallback activated due to parse error: ${reason}`);
@@ -20,6 +20,7 @@
20
20
  * block non-furnace patch work.
21
21
  */
22
22
  import { GeneralError } from '../errors/base.js';
23
+ import { toError } from '../utils/errors.js';
23
24
  import { pathExists } from '../utils/fs.js';
24
25
  import { verbose, warn } from '../utils/logger.js';
25
26
  import { extractComponentChecksums, hasComponentChanged } from './furnace-apply-helpers.js';
@@ -89,7 +90,7 @@ export async function findStaleFurnaceComponentsForFiles(projectRoot, files) {
89
90
  return stale;
90
91
  }
91
92
  catch (error) {
92
- verbose(`Stale-furnace export gate skipped due to an error: ${error instanceof Error ? error.message : String(error)}`);
93
+ verbose(`Stale-furnace export gate skipped due to an error: ${toError(error).message}`);
93
94
  return [];
94
95
  }
95
96
  }
@@ -21,14 +21,13 @@
21
21
  * <name>` had exactly this bug while `furnace deploy <name>` documented
22
22
  * and avoided it).
23
23
  */
24
- import { type applyAllComponents } from './furnace-apply.js';
24
+ import { type ApplyAllComponentsResult } from './furnace-apply.js';
25
25
  import { type getFurnacePaths } from './furnace-config.js';
26
- type ApplyAllResult = Awaited<ReturnType<typeof applyAllComponents>>;
27
26
  /**
28
27
  * Counts applied entries that carry at least one BLOCKING step error
29
28
  * (advisory step errors are warnings and never fail a run).
30
29
  */
31
- export declare function getStepFailureCount(result: ApplyAllResult): number;
30
+ export declare function getStepFailureCount(result: ApplyAllComponentsResult): number;
32
31
  /**
33
32
  * Decides whether a single-component apply/deploy completed cleanly enough
34
33
  * to persist its checksums into furnace-state.json.
@@ -40,7 +39,7 @@ export declare function getStepFailureCount(result: ApplyAllResult): number;
40
39
  * future refactor cannot drift them apart and accidentally persist partial
41
40
  * state.
42
41
  */
43
- export declare function shouldPersistSingleComponentState(result: ApplyAllResult, isDryRun: boolean): boolean;
42
+ export declare function shouldPersistSingleComponentState(result: ApplyAllComponentsResult, isDryRun: boolean): boolean;
44
43
  /**
45
44
  * Validates and narrows the applied entry a named run should persist.
46
45
  *
@@ -52,7 +51,7 @@ export declare function shouldPersistSingleComponentState(result: ApplyAllResult
52
51
  *
53
52
  * @param commandLabel - Human label for error messages ("Deploy", "Apply")
54
53
  */
55
- export declare function getPersistableAppliedEntry(commandLabel: string, name: string, appliedEntry: ApplyAllResult['applied'][number] | undefined): {
54
+ export declare function getPersistableAppliedEntry(commandLabel: string, name: string, appliedEntry: ApplyAllComponentsResult['applied'][number] | undefined): {
56
55
  name: string;
57
56
  type: 'override' | 'custom';
58
57
  };
@@ -69,4 +68,3 @@ export declare function persistSingleComponentState(projectRoot: string, applied
69
68
  name: string;
70
69
  type: 'override' | 'custom';
71
70
  }, furnacePaths: ReturnType<typeof getFurnacePaths>): Promise<void>;
72
- export {};
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
3
  import { pathExists, readText } from '../utils/fs.js';
4
- import { hasRawCssColors } from '../utils/regex.js';
4
+ import { escapeRegex, hasRawCssColors } from '../utils/regex.js';
5
5
  import { classExtendsMozLitElement, collectCssVariableDeclarations, collectCssVariableReferences, createIssue, getTokenPrefixContext, hasCustomElementDefineCall, hasRelativeModuleImport, stripCssBlockComments, } from './furnace-validate-helpers.js';
6
6
  async function validateMjsCompatibility(mjsPath, tagName, isLibrary) {
7
7
  if (!(await pathExists(mjsPath)))
@@ -78,22 +78,19 @@ async function validateCssCompatibility(cssPath, tagName, type, config, root) {
78
78
  */
79
79
  function isReferencedAsElement(source, tagName) {
80
80
  // Match as an HTML element: <tagName or </tagName
81
- const htmlPattern = new RegExp(`</?${escapeForValidation(tagName)}[\\s/>]`);
81
+ const htmlPattern = new RegExp(`</?${escapeRegex(tagName)}[\\s/>]`);
82
82
  if (htmlPattern.test(source))
83
83
  return true;
84
84
  // Match as a CSS tag selector: standalone word at start of line or after combinators
85
- const cssPattern = new RegExp(`(?:^|[\\s,>+~])${escapeForValidation(tagName)}(?:[\\s,{:>+~.[#]|$)`, 'm');
85
+ const cssPattern = new RegExp(`(?:^|[\\s,>+~])${escapeRegex(tagName)}(?:[\\s,{:>+~.[#]|$)`, 'm');
86
86
  if (cssPattern.test(source))
87
87
  return true;
88
88
  // Also accept querySelector/querySelectorAll('tagName') as intentional usage
89
- const querySelectorPattern = new RegExp(`querySelector(?:All)?\\(\\s*["'\`]${escapeForValidation(tagName)}(?:[\\s"'\`.,>+~:[#])`);
89
+ const querySelectorPattern = new RegExp(`querySelector(?:All)?\\(\\s*["'\`]${escapeRegex(tagName)}(?:[\\s"'\`.,>+~:[#])`);
90
90
  if (querySelectorPattern.test(source))
91
91
  return true;
92
92
  return false;
93
93
  }
94
- function escapeForValidation(str) {
95
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
96
- }
97
94
  /**
98
95
  * Validates that composed tags declared in furnace.json are actually referenced
99
96
  * in the component's source (.mjs and .css) in an HTML template or CSS context.
@@ -180,10 +180,42 @@ function isClickOnSyntheticInteractive(content, clickIndex) {
180
180
  }
181
181
  return !NATIVE_CLICK_INTERACTIVE_TAGS.has(tagName);
182
182
  }
183
+ /** ASCII punctuation that reads as decoration rather than localizable text. */
184
+ const SYMBOL_ONLY_ASCII = '+-*=<>|/\\^~@#&!?%';
185
+ /**
186
+ * Reports whether `text` is decoration (arrows, maths, emoji, bare
187
+ * punctuation) rather than user-facing prose that needs localizing.
188
+ *
189
+ * The letter/number/mark test comes first and is script-agnostic. Until
190
+ * 0.41.0 the only non-ASCII rule was `code > 0xff`, which classified *every*
191
+ * code point above U+00FF as a symbol — so all CJK, Cyrillic, Greek, Arabic,
192
+ * Hebrew, Devanagari and Thai text was exempted from a **localization**
193
+ * validator, i.e. the rule was blind to precisely the strings most likely to
194
+ * need translating.
195
+ *
196
+ * Combining marks are deliberately NOT counted as text: the check is an
197
+ * `every()`, so a script's base letters already disqualify the string, and
198
+ * treating marks as text would flag emoji carrying a variation selector
199
+ * (U+FE0F is category Mn — `⚙️` is U+2699 + U+FE0F).
200
+ *
201
+ * High code points that are not letters — emoji, arrows, CJK punctuation —
202
+ * stay exempt, which is the rule's original intent.
203
+ *
204
+ * A handful of emoji are ALSO Unicode letters, and the letter test alone gets
205
+ * them wrong. U+2139 (`ℹ`, the base of the very common `ℹ️`) is category Ll, so
206
+ * the `\p{L}` branch classified an info badge as prose needing localization.
207
+ * Non-ASCII code points carrying `Emoji` are therefore read as symbols before
208
+ * the letter test. The ASCII guard matters: `\p{Emoji}` also covers the digits
209
+ * `0`-`9`, `#` and `*`, which must keep their existing classification.
210
+ */
183
211
  function isSymbolOnlyText(text) {
184
212
  return Array.from(text).every((character) => {
185
213
  const code = character.codePointAt(0) ?? 0;
186
- return code > 0xff || '+-*=<>|/\\^~@#&!?%'.includes(character);
214
+ if (code > 0x7f && /\p{Emoji}/u.test(character))
215
+ return true;
216
+ if (/[\p{L}\p{N}]/u.test(character))
217
+ return false;
218
+ return code > 0xff || SYMBOL_ONLY_ASCII.includes(character);
187
219
  });
188
220
  }
189
221
  function isWithinLocalizedElement(content, matchIndex) {
@@ -35,6 +35,18 @@ export async function validateRegistrationPatterns(root, config) {
35
35
  for (const [name, customConfig] of Object.entries(config.custom)) {
36
36
  if (!customConfig.register)
37
37
  continue;
38
+ // A component that is not mentioned at all is a distinct defect from one
39
+ // registered in the wrong block: `--fix` can repair the former (idempotent
40
+ // insert) but deliberately refuses the latter (moving code between blocks).
41
+ if (!isTagAlreadyRegistered(content, name)) {
42
+ issues.push({
43
+ component: name,
44
+ severity: 'error',
45
+ check: 'missing-custom-element-registration',
46
+ message: `${name} has register: true but no registration in ${CUSTOM_ELEMENTS_JS}. Run "fireforge furnace validate ${name} --fix" to add it.`,
47
+ });
48
+ continue;
49
+ }
38
50
  const stripped = stripJsComments(content);
39
51
  const tagPattern = new RegExp(`["']${escapeRegex(name)}["']`);
40
52
  if (tagPattern.test(stripped) && !isTagInCorrectCustomElementsPlacement(content, name, true)) {
@@ -268,6 +280,8 @@ async function autoDetectTokenHostDocuments(engineDir, tagName, already) {
268
280
  entries = await readdir(contentDir);
269
281
  }
270
282
  catch {
283
+ // No readable chrome-document directory means no documents reference the
284
+ // tag, which is the same verdict as an empty directory.
271
285
  return [];
272
286
  }
273
287
  const alreadySet = new Set(already);
@@ -284,6 +298,8 @@ async function autoDetectTokenHostDocuments(engineDir, tagName, already) {
284
298
  content = await readText(absPath);
285
299
  }
286
300
  catch {
301
+ // An unreadable chrome document cannot be searched for the tag; skip it and
302
+ // keep scanning the rest.
287
303
  continue;
288
304
  }
289
305
  if (content.includes(tagName)) {
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { readdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { toError } from '../utils/errors.js';
4
5
  import { pathExists } from '../utils/fs.js';
5
6
  import { getProjectPaths, loadConfig } from './config.js';
6
7
  import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState } from './furnace-config.js';
@@ -109,7 +110,7 @@ export async function validateAllComponents(root) {
109
110
  validateComposesReferences(config.stock, config.overrides, config.custom);
110
111
  }
111
112
  catch (err) {
112
- const message = err instanceof Error ? err.message : String(err);
113
+ const message = toError(err).message;
113
114
  // Attribute the issue to the first custom component with a bad composes reference
114
115
  for (const [name, cfg] of Object.entries(config.custom)) {
115
116
  if (cfg.composes) {
@@ -129,7 +130,7 @@ export async function validateAllComponents(root) {
129
130
  detectComposesCycles(config.custom);
130
131
  }
131
132
  catch (err) {
132
- const message = err instanceof Error ? err.message : String(err);
133
+ const message = toError(err).message;
133
134
  // Attribute the cycle issue to the first custom component in the cycle
134
135
  for (const name of Object.keys(config.custom)) {
135
136
  if (config.custom[name]?.composes) {
@@ -274,6 +275,7 @@ async function findOrphanXpcshellScaffolds(root, config) {
274
275
  entries = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
275
276
  }
276
277
  catch {
278
+ // An unreadable parent directory yields no sibling component dirs.
277
279
  return [];
278
280
  }
279
281
  const known = new Set([
@@ -460,6 +460,15 @@ export async function generateBinaryFilePatch(repoDir, filePath) {
460
460
  // stage/unstage pair mutates the index, so it must not interleave with
461
461
  // another concurrent binary patch (see runWithBinaryStagingLock).
462
462
  return runWithBinaryStagingLock(async () => {
463
+ // Snapshot the path's exact index entry before touching it. The cleanup
464
+ // used to be a blanket `git reset HEAD -- <file>`, which restores the
465
+ // path to its HEAD state — for a path absent from HEAD that means
466
+ // *removing* whatever entry the index carried, so any staged state that
467
+ // arrived between the read-only diff above and this staging block (a
468
+ // concurrent writer, or a staged-add whose worktree file was deleted)
469
+ // was silently discarded (FORGE H1). Restoring the captured entry
470
+ // instead makes the cleanup exact regardless of how the entry got there.
471
+ const priorEntry = (await execGitWithAllowedExitCodes(repoDir, ['ls-files', '--stage', '--', filePath])).stdout;
463
472
  try {
464
473
  await execGitWithAllowedExitCodes(repoDir, ['add', '--intent-to-add', '--', filePath]);
465
474
  const diffResult = await execGitWithAllowedExitCodes(repoDir, [
@@ -471,8 +480,34 @@ export async function generateBinaryFilePatch(repoDir, filePath) {
471
480
  return diffResult.stdout;
472
481
  }
473
482
  finally {
474
- // Always unstage, even if diff fails
475
- await execGitWithAllowedExitCodes(repoDir, ['reset', 'HEAD', '--', filePath]);
483
+ await restoreIndexEntry(repoDir, filePath, priorEntry);
476
484
  }
477
485
  });
478
486
  }
487
+ /**
488
+ * Restores a path's index entry to a previously captured `ls-files --stage`
489
+ * snapshot after a temporary `--intent-to-add` staging. No prior entry means
490
+ * the temporary entry is dropped via `reset HEAD` (the path's HEAD state IS
491
+ * "absent" — this branch only runs for files untracked in HEAD). A prior
492
+ * stage-0 entry is re-pointed at its recorded mode and blob. Unmerged entries
493
+ * (stages 1–3) cannot be rebuilt through `--cacheinfo`; that degenerate state
494
+ * keeps the legacy reset-to-HEAD behavior rather than corrupting the conflict.
495
+ * @param repoDir - Repository directory
496
+ * @param filePath - File path (relative to repo root)
497
+ * @param priorEntry - Raw `git ls-files --stage -- <file>` output from before staging
498
+ */
499
+ async function restoreIndexEntry(repoDir, filePath, priorEntry) {
500
+ const entryMatch = /^(\d{6}) ([0-9a-f]{4,64}) 0\t/.exec(priorEntry.trim());
501
+ const mode = entryMatch?.[1];
502
+ const oid = entryMatch?.[2];
503
+ if (mode === undefined || oid === undefined) {
504
+ await execGitWithAllowedExitCodes(repoDir, ['reset', 'HEAD', '--', filePath]);
505
+ return;
506
+ }
507
+ await execGitWithAllowedExitCodes(repoDir, [
508
+ 'update-index',
509
+ '--add',
510
+ '--cacheinfo',
511
+ `${mode},${oid},${filePath}`,
512
+ ]);
513
+ }
@@ -1,11 +1,15 @@
1
1
  import type { PatchLintIssue, PatchMetadata } from '../types/commands/index.js';
2
2
  import type { FireForgeConfig } from '../types/config.js';
3
3
  import { type PatchQueueContext } from './patch-lint.js';
4
- export declare const LINT_CACHE_SCHEMA_VERSION = 1;
4
+ export declare const LINT_CACHE_SCHEMA_VERSION = 3;
5
5
  export interface PerPatchLintCacheEntry {
6
6
  key: string;
7
7
  patchFilename: string;
8
8
  issues: PatchLintIssue[];
9
+ /** Issues dropped by the patch's lintIgnore waivers (FORGE G10). */
10
+ suppressed: PatchLintIssue[];
11
+ /** Non-binary diff line count measured by the cached lint run. */
12
+ lineCount: number;
9
13
  updatedAt: string;
10
14
  }
11
15
  export interface PerPatchLintCacheFile {
@@ -36,7 +40,13 @@ export declare function loadPerPatchLintCache(projectRoot: string): Promise<PerP
36
40
  export declare function savePerPatchLintCache(projectRoot: string, cache: PerPatchLintCacheFile): Promise<void>;
37
41
  /** Clears the per-patch lint cache by replacing it with an empty document. */
38
42
  export declare function clearPerPatchLintCache(projectRoot: string): Promise<void>;
39
- /** Returns cached issues for a patch when the stored key still matches. */
40
- export declare function getCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string): PatchLintIssue[] | undefined;
41
- /** Stores per-patch lint issues after a successful lint calculation. */
42
- export declare function setCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string, issues: PatchLintIssue[]): void;
43
+ /** Cached per-patch lint payload returned on a key match. */
44
+ export interface CachedPerPatchLint {
45
+ issues: PatchLintIssue[];
46
+ suppressed: PatchLintIssue[];
47
+ lineCount: number;
48
+ }
49
+ /** Returns the cached lint payload for a patch when the stored key still matches. */
50
+ export declare function getCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string): CachedPerPatchLint | undefined;
51
+ /** Stores the per-patch lint payload after a successful lint calculation. */
52
+ export declare function setCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string, issues: PatchLintIssue[], suppressed: PatchLintIssue[], lineCount: number): void;
@@ -7,7 +7,15 @@ import { getPackageVersion } from '../utils/package-root.js';
7
7
  import { getFurnacePaths } from './furnace-config.js';
8
8
  import { git } from './git-base.js';
9
9
  import { collectNewFileCreatorsByPath } from './patch-lint.js';
10
- export const LINT_CACHE_SCHEMA_VERSION = 1;
10
+ // Schema 2 (FORGE G10): entries additionally carry the lintIgnore-suppressed
11
+ // issues and the measured non-binary line count, so a warm run can report
12
+ // waived size measurements identically to a cold one (the F5-class hazard:
13
+ // a cache hit must never surface LESS than a fresh lint).
14
+ // Schema 3 (FORGE H2): the key additionally content-hashes the
15
+ // `patchLint.checkJsTestShim` file exactly as `checkJsExtraShim`'s already
16
+ // was — before this, editing the test shim in place replayed every cached
17
+ // verdict (the same warm-run-reports-less-than-cold hazard class).
18
+ export const LINT_CACHE_SCHEMA_VERSION = 3;
11
19
  const LINT_IMPLEMENTATION_VERSION = 1;
12
20
  const LINT_CACHE_DIRNAME = 'lint-cache';
13
21
  const PER_PATCH_CACHE_FILENAME = 'per-patch-v1.json';
@@ -81,28 +89,44 @@ function buildOwnershipFingerprint(files, ctx) {
81
89
  * The key includes source, metadata, config, engine state, and ownership inputs.
82
90
  */
83
91
  export async function buildPerPatchLintCacheKey(input) {
92
+ // Hash in parallel, then insert in sorted order. The sort is load-bearing:
93
+ // `stableHash` below serialises this record, so insertion order is part of
94
+ // the cache key — assigning as results land would make the key
95
+ // nondeterministic. Hashing was strictly sequential until 0.41.0, in the hot
96
+ // path of the cache whose entire purpose is making lint faster.
97
+ const sortedFiles = [...input.existingFiles].sort((a, b) => a.localeCompare(b));
98
+ const hashes = await Promise.all(sortedFiles.map((file) => fileHash(join(input.engineDir, file))));
84
99
  const engineFiles = {};
85
- for (const file of [...input.existingFiles].sort((a, b) => a.localeCompare(b))) {
86
- engineFiles[file] = await fileHash(join(input.engineDir, file));
87
- }
100
+ hashes.forEach((hash, index) => {
101
+ const file = sortedFiles[index];
102
+ if (file !== undefined)
103
+ engineFiles[file] = hash;
104
+ });
88
105
  const furnaceConfigPath = getFurnacePaths(input.projectRoot).furnaceConfig;
89
- const extraShimPath = input.config.patchLint?.checkJsExtraShim;
90
- const extraShim = extraShimPath === undefined
106
+ const shimHashOf = async (shimPath) => shimPath === undefined
91
107
  ? null
92
- : {
93
- path: extraShimPath,
94
- hash: await fileHash(resolve(input.projectRoot, extraShimPath)),
95
- };
108
+ : { path: shimPath, hash: await fileHash(resolve(input.projectRoot, shimPath)) };
109
+ // Both shims feed compiled checkJs programs, so both are content-hashed —
110
+ // the config block alone only carries their PATHS, and an in-place edit
111
+ // must invalidate the programs it feeds (FORGE H2).
112
+ const [extraShim, testShim] = await Promise.all([
113
+ shimHashOf(input.config.patchLint?.checkJsExtraShim),
114
+ shimHashOf(input.config.patchLint?.checkJsTestShim),
115
+ ]);
96
116
  return stableHash({
97
117
  cacheSchemaVersion: LINT_CACHE_SCHEMA_VERSION,
98
118
  engineHeadSha: input.engineHeadSha ?? null,
99
119
  lintImplementationVersion: LINT_IMPLEMENTATION_VERSION,
120
+ // Deliberately the PLAIN semver, not the +g<sha> build identity
121
+ // (FORGE K2): identity churns every commit at the same version and
122
+ // would invalidate the cache for no correctness gain.
100
123
  packageVersion: input.packageVersion ?? getPackageVersion(),
101
124
  patchFile: await fileHash(join(input.patchesDir, input.patch.filename)),
102
125
  patchMetadata: normalizePatchMetadata(input.patch),
103
126
  lintConfig: normalizeLintConfig(input.config),
104
127
  furnaceConfig: await fileHash(furnaceConfigPath),
105
128
  checkJsExtraShim: extraShim,
129
+ checkJsTestShim: testShim,
106
130
  engineFiles,
107
131
  queueOwnership: buildOwnershipFingerprint(input.existingFiles, input.queueContext),
108
132
  });
@@ -118,6 +142,8 @@ function isCacheEntry(value) {
118
142
  return (typeof entry.key === 'string' &&
119
143
  typeof entry.patchFilename === 'string' &&
120
144
  Array.isArray(entry.issues) &&
145
+ Array.isArray(entry.suppressed) &&
146
+ typeof entry.lineCount === 'number' &&
121
147
  typeof entry.updatedAt === 'string');
122
148
  }
123
149
  /** Loads the per-patch lint cache, treating missing or invalid files as empty. */
@@ -144,6 +170,9 @@ export async function loadPerPatchLintCache(projectRoot) {
144
170
  return { schemaVersion: LINT_CACHE_SCHEMA_VERSION, entries };
145
171
  }
146
172
  catch {
173
+ // Any unreadable or malformed cache file is treated as a cold cache. The
174
+ // cache is a pure optimisation, so discarding it is always safe — the
175
+ // alternative is failing lint over a corrupt sidecar.
147
176
  return createEmptyPerPatchLintCache();
148
177
  }
149
178
  }
@@ -158,19 +187,25 @@ export async function savePerPatchLintCache(projectRoot, cache) {
158
187
  export async function clearPerPatchLintCache(projectRoot) {
159
188
  await writeJson(getPerPatchLintCachePath(projectRoot), createEmptyPerPatchLintCache());
160
189
  }
161
- /** Returns cached issues for a patch when the stored key still matches. */
190
+ /** Returns the cached lint payload for a patch when the stored key still matches. */
162
191
  export function getCachedPerPatchLintIssues(cache, patchFilename, key) {
163
192
  const entry = cache.entries[patchFilename];
164
193
  if (!entry || entry.key !== key)
165
194
  return undefined;
166
- return entry.issues.map((issue) => ({ ...issue }));
195
+ return {
196
+ issues: entry.issues.map((issue) => ({ ...issue })),
197
+ suppressed: entry.suppressed.map((issue) => ({ ...issue })),
198
+ lineCount: entry.lineCount,
199
+ };
167
200
  }
168
- /** Stores per-patch lint issues after a successful lint calculation. */
169
- export function setCachedPerPatchLintIssues(cache, patchFilename, key, issues) {
201
+ /** Stores the per-patch lint payload after a successful lint calculation. */
202
+ export function setCachedPerPatchLintIssues(cache, patchFilename, key, issues, suppressed, lineCount) {
170
203
  cache.entries[patchFilename] = {
171
204
  key,
172
205
  patchFilename,
173
206
  issues: issues.map((issue) => ({ ...issue })),
207
+ suppressed: suppressed.map((issue) => ({ ...issue })),
208
+ lineCount,
174
209
  updatedAt: new Date().toISOString(),
175
210
  };
176
211
  }