@hominis/fireforge 0.40.0 → 0.42.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 (256) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +16 -10
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +20 -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 +13 -3
  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 +16 -7
  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 +12 -59
  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.js +4 -4
  45. package/dist/src/commands/manifest.js +1 -1
  46. package/dist/src/commands/package.js +9 -16
  47. package/dist/src/commands/patch/compact.js +5 -5
  48. package/dist/src/commands/patch/delete.js +5 -5
  49. package/dist/src/commands/patch/lint-ignore.js +3 -2
  50. package/dist/src/commands/patch/move-files-create.js +22 -4
  51. package/dist/src/commands/patch/move-files-into.js +14 -10
  52. package/dist/src/commands/patch/move-files.js +6 -5
  53. package/dist/src/commands/patch/rename.d.ts +2 -2
  54. package/dist/src/commands/patch/rename.js +151 -50
  55. package/dist/src/commands/patch/reorder.d.ts +9 -0
  56. package/dist/src/commands/patch/reorder.js +14 -10
  57. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  58. package/dist/src/commands/patch/split-plan.js +22 -12
  59. package/dist/src/commands/patch/split.d.ts +1 -1
  60. package/dist/src/commands/patch/split.js +23 -9
  61. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  62. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  63. package/dist/src/commands/patch/staged-dependency.js +10 -0
  64. package/dist/src/commands/re-export-adjacent.js +5 -6
  65. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  66. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  67. package/dist/src/commands/re-export-drift.d.ts +74 -0
  68. package/dist/src/commands/re-export-drift.js +234 -0
  69. package/dist/src/commands/re-export-files.js +5 -3
  70. package/dist/src/commands/re-export-lint.d.ts +97 -0
  71. package/dist/src/commands/re-export-lint.js +210 -0
  72. package/dist/src/commands/re-export-options.js +8 -0
  73. package/dist/src/commands/re-export-register.d.ts +18 -0
  74. package/dist/src/commands/re-export-register.js +61 -0
  75. package/dist/src/commands/re-export-scan.js +13 -7
  76. package/dist/src/commands/re-export-single.d.ts +20 -0
  77. package/dist/src/commands/re-export-single.js +261 -0
  78. package/dist/src/commands/re-export.d.ts +0 -4
  79. package/dist/src/commands/re-export.js +113 -268
  80. package/dist/src/commands/rebase/abort.js +13 -4
  81. package/dist/src/commands/rebase/continue.js +11 -4
  82. package/dist/src/commands/rebase/index.js +19 -29
  83. package/dist/src/commands/rebase/patch-loop.js +12 -0
  84. package/dist/src/commands/reset.js +4 -23
  85. package/dist/src/commands/resolve.js +2 -9
  86. package/dist/src/commands/run.js +11 -21
  87. package/dist/src/commands/source.js +3 -9
  88. package/dist/src/commands/status-check.d.ts +13 -0
  89. package/dist/src/commands/status-check.js +31 -13
  90. package/dist/src/commands/status-json.d.ts +31 -0
  91. package/dist/src/commands/status-json.js +76 -0
  92. package/dist/src/commands/status-output.d.ts +1 -0
  93. package/dist/src/commands/status-output.js +10 -1
  94. package/dist/src/commands/status-ownership.d.ts +52 -0
  95. package/dist/src/commands/status-ownership.js +57 -0
  96. package/dist/src/commands/status.js +184 -175
  97. package/dist/src/commands/test-build-phase.d.ts +19 -0
  98. package/dist/src/commands/test-build-phase.js +168 -0
  99. package/dist/src/commands/test-diagnose.js +30 -2
  100. package/dist/src/commands/test-modes.d.ts +9 -1
  101. package/dist/src/commands/test-modes.js +46 -13
  102. package/dist/src/commands/test-register.js +59 -27
  103. package/dist/src/commands/test-run.d.ts +29 -3
  104. package/dist/src/commands/test-run.js +41 -4
  105. package/dist/src/commands/test-verdict.d.ts +32 -0
  106. package/dist/src/commands/test-verdict.js +48 -0
  107. package/dist/src/commands/test.d.ts +9 -0
  108. package/dist/src/commands/test.js +100 -123
  109. package/dist/src/commands/token-coverage.js +4 -9
  110. package/dist/src/commands/tree.d.ts +3 -0
  111. package/dist/src/commands/tree.js +171 -25
  112. package/dist/src/commands/verify.js +2 -2
  113. package/dist/src/commands/watch.js +22 -21
  114. package/dist/src/commands/wire.js +1 -1
  115. package/dist/src/core/ast-utils.d.ts +11 -0
  116. package/dist/src/core/ast-utils.js +11 -0
  117. package/dist/src/core/branding.d.ts +16 -0
  118. package/dist/src/core/branding.js +18 -2
  119. package/dist/src/core/build-audit-platform.js +6 -1
  120. package/dist/src/core/build-audit-registration.js +2 -0
  121. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  122. package/dist/src/core/build-audit-resolve.js +34 -13
  123. package/dist/src/core/build-audit.js +4 -20
  124. package/dist/src/core/build-baseline-types.d.ts +29 -8
  125. package/dist/src/core/build-baseline-types.js +2 -1
  126. package/dist/src/core/build-baseline.d.ts +10 -2
  127. package/dist/src/core/build-baseline.js +39 -8
  128. package/dist/src/core/build-prepare.d.ts +4 -0
  129. package/dist/src/core/build-prepare.js +7 -1
  130. package/dist/src/core/config-paths.d.ts +1 -1
  131. package/dist/src/core/config-paths.js +2 -0
  132. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  133. package/dist/src/core/config-validate.js +34 -36
  134. package/dist/src/core/config.js +16 -3
  135. package/dist/src/core/coverage-extend.d.ts +89 -0
  136. package/dist/src/core/coverage-extend.js +188 -0
  137. package/dist/src/core/destructive.js +3 -1
  138. package/dist/src/core/discard-baseline.js +18 -4
  139. package/dist/src/core/engine-precondition.d.ts +35 -0
  140. package/dist/src/core/engine-precondition.js +50 -0
  141. package/dist/src/core/engine-session-lock.d.ts +38 -1
  142. package/dist/src/core/engine-session-lock.js +78 -4
  143. package/dist/src/core/file-lock.d.ts +7 -0
  144. package/dist/src/core/file-lock.js +20 -30
  145. package/dist/src/core/firefox.js +5 -2
  146. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  147. package/dist/src/core/furnace-apply-helpers.js +12 -3
  148. package/dist/src/core/furnace-apply-output.js +9 -0
  149. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  150. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  151. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  152. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  153. package/dist/src/core/furnace-apply.d.ts +16 -4
  154. package/dist/src/core/furnace-apply.js +39 -43
  155. package/dist/src/core/furnace-config.js +2 -0
  156. package/dist/src/core/furnace-css-fragments.js +2 -0
  157. package/dist/src/core/furnace-jsconfig.js +14 -7
  158. package/dist/src/core/furnace-registration-ast.js +8 -8
  159. package/dist/src/core/furnace-registration-remove.js +6 -3
  160. package/dist/src/core/furnace-registration.js +9 -12
  161. package/dist/src/core/furnace-rollback.js +1 -1
  162. package/dist/src/core/furnace-scanner.js +2 -1
  163. package/dist/src/core/furnace-stale-export.js +2 -1
  164. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  165. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  166. package/dist/src/core/furnace-validate-helpers.js +33 -1
  167. package/dist/src/core/furnace-validate-registration.js +16 -0
  168. package/dist/src/core/furnace-validate.js +4 -2
  169. package/dist/src/core/git-diff.js +37 -2
  170. package/dist/src/core/lint-cache.d.ts +1 -1
  171. package/dist/src/core/lint-cache.js +33 -10
  172. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  173. package/dist/src/core/mach-build-artifacts.js +108 -2
  174. package/dist/src/core/mach-mozconfig.js +9 -1
  175. package/dist/src/core/mach-resource-shim.js +1 -0
  176. package/dist/src/core/marionette-port.d.ts +16 -0
  177. package/dist/src/core/marionette-port.js +93 -8
  178. package/dist/src/core/marionette-preflight.js +6 -5
  179. package/dist/src/core/patch-apply.d.ts +20 -13
  180. package/dist/src/core/patch-apply.js +32 -33
  181. package/dist/src/core/patch-export.js +3 -5
  182. package/dist/src/core/patch-lint-checkjs.d.ts +25 -3
  183. package/dist/src/core/patch-lint-checkjs.js +78 -7
  184. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  185. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  186. package/dist/src/core/patch-lint-creators.js +45 -0
  187. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  188. package/dist/src/core/patch-lint-cross.js +16 -32
  189. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  190. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  191. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  192. package/dist/src/core/patch-lint-module-registration.js +88 -0
  193. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  194. package/dist/src/core/patch-lint-reexports.d.ts +1 -0
  195. package/dist/src/core/patch-lint-reexports.js +1 -0
  196. package/dist/src/core/patch-lint.d.ts +8 -0
  197. package/dist/src/core/patch-lint.js +84 -6
  198. package/dist/src/core/patch-lock.d.ts +12 -1
  199. package/dist/src/core/patch-lock.js +40 -4
  200. package/dist/src/core/patch-manifest-consistency.js +15 -3
  201. package/dist/src/core/patch-parse.d.ts +8 -0
  202. package/dist/src/core/patch-parse.js +10 -0
  203. package/dist/src/core/patch-policy.d.ts +7 -0
  204. package/dist/src/core/patch-policy.js +4 -1
  205. package/dist/src/core/patch-transform.d.ts +10 -0
  206. package/dist/src/core/patch-transform.js +13 -2
  207. package/dist/src/core/rebase-session.d.ts +44 -2
  208. package/dist/src/core/rebase-session.js +114 -17
  209. package/dist/src/core/register-browser-content.js +2 -1
  210. package/dist/src/core/register-shared-css.js +2 -1
  211. package/dist/src/core/smoke-patterns.d.ts +0 -8
  212. package/dist/src/core/smoke-patterns.js +3 -2
  213. package/dist/src/core/status-binary.d.ts +55 -0
  214. package/dist/src/core/status-binary.js +124 -0
  215. package/dist/src/core/status-classify.d.ts +6 -1
  216. package/dist/src/core/status-classify.js +101 -73
  217. package/dist/src/core/test-harness-crash.d.ts +32 -1
  218. package/dist/src/core/test-harness-crash.js +59 -4
  219. package/dist/src/core/test-path-scope.js +2 -0
  220. package/dist/src/core/test-stale-check.js +1 -20
  221. package/dist/src/core/toolchain-preflight.js +24 -26
  222. package/dist/src/core/tree-guard.d.ts +1 -1
  223. package/dist/src/core/tree-guard.js +67 -12
  224. package/dist/src/core/tree-store.d.ts +86 -10
  225. package/dist/src/core/tree-store.js +310 -72
  226. package/dist/src/core/typecheck.js +2 -1
  227. package/dist/src/core/wire-destroy.js +14 -3
  228. package/dist/src/core/wire-init.js +37 -40
  229. package/dist/src/core/wire-subscript.js +11 -3
  230. package/dist/src/core/wire-utils.js +4 -4
  231. package/dist/src/core/xpcshell-appdir.js +3 -0
  232. package/dist/src/errors/base.d.ts +13 -0
  233. package/dist/src/errors/base.js +13 -0
  234. package/dist/src/errors/rebase.d.ts +13 -0
  235. package/dist/src/errors/rebase.js +17 -0
  236. package/dist/src/types/commands/options.d.ts +86 -1
  237. package/dist/src/types/commands/patches.d.ts +8 -0
  238. package/dist/src/types/furnace.d.ts +7 -0
  239. package/dist/src/utils/build-info.d.ts +31 -0
  240. package/dist/src/utils/build-info.js +98 -0
  241. package/dist/src/utils/concurrency.d.ts +8 -0
  242. package/dist/src/utils/concurrency.js +21 -0
  243. package/dist/src/utils/errors.d.ts +39 -0
  244. package/dist/src/utils/errors.js +55 -0
  245. package/dist/src/utils/fs.js +5 -12
  246. package/dist/src/utils/logger.d.ts +14 -2
  247. package/dist/src/utils/logger.js +43 -13
  248. package/dist/src/utils/options.d.ts +21 -4
  249. package/dist/src/utils/options.js +26 -6
  250. package/dist/src/utils/package-root.js +7 -4
  251. package/dist/src/utils/process-group.js +13 -2
  252. package/dist/src/utils/stdio-drain.d.ts +28 -0
  253. package/dist/src/utils/stdio-drain.js +54 -0
  254. package/dist/src/utils/validation.d.ts +23 -2
  255. package/dist/src/utils/validation.js +29 -3
  256. package/package.json +9 -9
@@ -26,6 +26,7 @@
26
26
  * while reporting only the patch under export.
27
27
  */
28
28
  import { basename, resolve } from 'node:path';
29
+ import { toError } from '../utils/errors.js';
29
30
  import { pathExists } from '../utils/fs.js';
30
31
  import { verbose } from '../utils/logger.js';
31
32
  import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, TEST_HARNESS_SHIM, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
@@ -146,9 +147,17 @@ function createPathsResolver(ts, paths, baseDir, fileExists, onResolved) {
146
147
  * the built-in Firefox-globals shim (from `patchLint.checkJsExtraShim`)
147
148
  * @param projectRoot - Absolute project root for resolving `extraShimPath`
148
149
  * @param mode - Strictness preset plus allowlisted compiler-option overrides
150
+ * @param builtinShimSuffix - Optional shim text appended AFTER the consumer shim
151
+ * @param rootScope - When set, only these repo-relative files become program
152
+ * ROOTS; resolution (and the host allowlist) still spans all of
153
+ * `resolutionOwned`, so a subset root's cross-patch imports type-check
154
+ * against the real owning sources while unrelated owned files are never
155
+ * parsed. `.mjs` files are module-scoped, so a root's diagnostics are
156
+ * identical whether other owned files are roots or mere resolution
157
+ * targets (pinned by the rootScope parity test — FORGE J1c).
149
158
  * @returns Diagnostics grouped per owning file plus run-level errors
150
159
  */
151
- export async function runCheckJsGrouped(repoDir, resolutionOwned, extraShimPath, projectRoot, mode, builtinShimSuffix) {
160
+ export async function runCheckJsGrouped(repoDir, resolutionOwned, extraShimPath, projectRoot, mode, builtinShimSuffix, rootScope) {
152
161
  const empty = { byFile: new Map(), global: [] };
153
162
  if (resolutionOwned.size === 0)
154
163
  return empty;
@@ -171,14 +180,18 @@ export async function runCheckJsGrouped(repoDir, resolutionOwned, extraShimPath,
171
180
  ],
172
181
  };
173
182
  }
174
- // Resolve absolute paths for root files, filtering to files that exist
183
+ // Resolve absolute paths for root files, filtering to files that exist.
184
+ // Under a rootScope, non-scoped owned files stay resolvable (host
185
+ // allowlist + relByAbsolute) but are not program roots — they are only
186
+ // parsed if a scoped root's import closure reaches them.
175
187
  const rootFiles = [];
176
188
  const ownedAbsolute = new Set();
177
189
  const relByAbsolute = new Map();
178
190
  for (const rel of resolutionOwned) {
179
191
  const abs = resolve(repoDir, rel);
180
192
  if (await pathExists(abs)) {
181
- rootFiles.push(abs);
193
+ if (rootScope === undefined || rootScope.has(rel))
194
+ rootFiles.push(abs);
182
195
  ownedAbsolute.add(abs);
183
196
  relByAbsolute.set(abs, rel);
184
197
  }
@@ -209,7 +222,7 @@ export async function runCheckJsGrouped(repoDir, resolutionOwned, extraShimPath,
209
222
  {
210
223
  file: extraShimPath ?? '(checkJs)',
211
224
  check: 'checkjs-type-error',
212
- message: err instanceof Error ? err.message : String(err),
225
+ message: toError(err).message,
213
226
  severity: 'error',
214
227
  },
215
228
  ],
@@ -423,7 +436,7 @@ export async function invokePatchLintCheckJs(repoDir, patchOwnedFiles, patchLint
423
436
  * @param patchLint - The resolved `patchLint` config block
424
437
  * @param projectRoot - FireForge project root for shim resolution
425
438
  */
426
- export async function runCheckJsTestFilesGrouped(repoDir, testFiles, patchLint, projectRoot) {
439
+ export async function runCheckJsTestFilesGrouped(repoDir, testFiles, patchLint, projectRoot, rootScope) {
427
440
  const merged = { byFile: new Map(), global: [] };
428
441
  if (testFiles.size === 0)
429
442
  return merged;
@@ -431,6 +444,11 @@ export async function runCheckJsTestFilesGrouped(repoDir, testFiles, patchLint,
431
444
  const seenGlobal = new Set();
432
445
  const files = [...testFiles].sort((a, b) => a.localeCompare(b));
433
446
  for (const file of files) {
447
+ // Under a rootScope only the scoped files get their own program, but
448
+ // head.js helper discovery still spans the full owned set so a scoped
449
+ // test keeps its cross-patch harness globals (FORGE J1c).
450
+ if (rootScope !== undefined && !rootScope.has(file))
451
+ continue;
434
452
  const dir = file.slice(0, file.lastIndexOf('/') + 1);
435
453
  const roots = new Set([file]);
436
454
  for (const candidate of files) {
@@ -479,7 +497,60 @@ function modeFromPatchLintConfig(patchLint) {
479
497
  * @param patchOwnedFiles - Every patch-owned `.sys.mjs` in the queue
480
498
  * @param patchLint - The resolved `patchLint` config block
481
499
  * @param projectRoot - FireForge project root for shim resolution
500
+ * @param rootScope - Optional subset of files to use as program roots
501
+ * (`--patches`); resolution still spans the whole queue (FORGE J1c)
502
+ */
503
+ export async function invokePatchLintCheckJsGrouped(repoDir, patchOwnedFiles, patchLint, projectRoot, rootScope) {
504
+ return runCheckJsGrouped(repoDir, patchOwnedFiles, patchLint.checkJsExtraShim, projectRoot, modeFromPatchLintConfig(patchLint), undefined, rootScope);
505
+ }
506
+ /**
507
+ * Cheap probe reproducing the only run-level ("global") checkJs findings
508
+ * the build path can produce — a missing `typescript` package and an
509
+ * unreadable consumer shim (`checkJsExtraShim`, and `checkJsTestShim`
510
+ * when `checkJsTestFiles` is on). The built program itself never
511
+ * contributes globals (see {@link runCheckJsGrouped}), so a warm
512
+ * all-cache-hit run can satisfy the "warm never reports less than cold"
513
+ * invariant (FORGE F5) with this probe instead of building the whole
514
+ * TypeScript program. Issue objects are byte-identical to the build
515
+ * path's, deduplicated by message like {@link runCheckJsTestFilesGrouped}.
482
516
  */
483
- export async function invokePatchLintCheckJsGrouped(repoDir, patchOwnedFiles, patchLint, projectRoot) {
484
- return runCheckJsGrouped(repoDir, patchOwnedFiles, patchLint.checkJsExtraShim, projectRoot, modeFromPatchLintConfig(patchLint));
517
+ export async function probeCheckJsGlobalIssues(patchLint, projectRoot) {
518
+ try {
519
+ await import('typescript');
520
+ }
521
+ catch {
522
+ return [
523
+ {
524
+ file: '(checkJs)',
525
+ check: 'checkjs-type-error',
526
+ message: 'patchLint.checkJs is enabled but the "typescript" package is not installed. ' +
527
+ 'Run "npm install typescript" to enable type checking.',
528
+ severity: 'error',
529
+ },
530
+ ];
531
+ }
532
+ const issues = [];
533
+ const seen = new Set();
534
+ const probeShim = async (shimPath) => {
535
+ try {
536
+ await composeShimSource(projectRoot, shimPath);
537
+ }
538
+ catch (err) {
539
+ const message = toError(err).message;
540
+ if (seen.has(message))
541
+ return;
542
+ seen.add(message);
543
+ issues.push({
544
+ file: shimPath ?? '(checkJs)',
545
+ check: 'checkjs-type-error',
546
+ message,
547
+ severity: 'error',
548
+ });
549
+ }
550
+ };
551
+ await probeShim(patchLint.checkJsExtraShim);
552
+ if (patchLint.checkJsTestFiles === true) {
553
+ await probeShim(patchLint.checkJsTestShim);
554
+ }
555
+ return issues;
485
556
  }
@@ -40,6 +40,15 @@ export function validateChromeScriptJsDoc(source, options) {
40
40
  ast = parseScript(source, comments);
41
41
  }
42
42
  catch {
43
+ // Deliberate carve-out, unlike the `.sys.mjs` walker in
44
+ // `patch-lint-jsdoc.ts` (which reports a parse failure as an issue).
45
+ // `parseScript` rejects `import`/`export`, so a chrome subscript that was
46
+ // misclassified as a `.js` file — or that mistakenly uses module syntax —
47
+ // would emit a pseudo-issue for every rule here. The orchestrator already
48
+ // runs the export walker on `.sys.mjs` separately, so silently declining
49
+ // to lint an unparseable script is the correct degradation for this file
50
+ // and only this file. Pinned by "returns no issues when the source uses
51
+ // module-only syntax" in patch-lint-chrome-jsdoc.test.ts.
43
52
  return [];
44
53
  }
45
54
  const issues = [];
@@ -0,0 +1,30 @@
1
+ /**
2
+ * New-file creators map for the patch queue, split out of
3
+ * `patch-lint-cross.ts` (at the per-file line budget). Re-exported from
4
+ * there so callers keep importing from `patch-lint.ts` unchanged.
5
+ *
6
+ * Memoised per context object (FORGE J1): ownership resolution and
7
+ * cache-key fingerprints call this 2-3× per linted patch, and each call
8
+ * used to re-run `detectNewFilesInDiff` over EVERY queue entry's diff.
9
+ * Keyed weakly so a discarded context frees its map; a caller that
10
+ * mutates a context entry in place must call
11
+ * {@link invalidateNewFileCreatorsCache}.
12
+ */
13
+ /** The slice of a queue context the creators map is derived from. */
14
+ interface CreatorsContext {
15
+ entries: {
16
+ filename: string;
17
+ diff: string;
18
+ }[];
19
+ }
20
+ /** Drops the memoised creators map after an in-place context entry update. */
21
+ export declare function invalidateNewFileCreatorsCache(ctx: CreatorsContext): void;
22
+ /**
23
+ * Returns the raw `path → patches[]` map of files created in `new file
24
+ * mode` by at least one patch in the queue. Paths created by only one
25
+ * patch are also included so callers can distinguish "no creator" from
26
+ * "exactly one creator" without re-scanning the diffs. The returned map
27
+ * is memoised and shared — treat it as read-only.
28
+ */
29
+ export declare function collectNewFileCreatorsByPath(ctx: CreatorsContext): Map<string, string[]>;
30
+ export {};
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * New-file creators map for the patch queue, split out of
4
+ * `patch-lint-cross.ts` (at the per-file line budget). Re-exported from
5
+ * there so callers keep importing from `patch-lint.ts` unchanged.
6
+ *
7
+ * Memoised per context object (FORGE J1): ownership resolution and
8
+ * cache-key fingerprints call this 2-3× per linted patch, and each call
9
+ * used to re-run `detectNewFilesInDiff` over EVERY queue entry's diff.
10
+ * Keyed weakly so a discarded context frees its map; a caller that
11
+ * mutates a context entry in place must call
12
+ * {@link invalidateNewFileCreatorsCache}.
13
+ */
14
+ import { detectNewFilesInDiff } from './patch-lint-diff.js';
15
+ const newFileCreatorsMemo = new WeakMap();
16
+ /** Drops the memoised creators map after an in-place context entry update. */
17
+ export function invalidateNewFileCreatorsCache(ctx) {
18
+ newFileCreatorsMemo.delete(ctx);
19
+ }
20
+ /**
21
+ * Returns the raw `path → patches[]` map of files created in `new file
22
+ * mode` by at least one patch in the queue. Paths created by only one
23
+ * patch are also included so callers can distinguish "no creator" from
24
+ * "exactly one creator" without re-scanning the diffs. The returned map
25
+ * is memoised and shared — treat it as read-only.
26
+ */
27
+ export function collectNewFileCreatorsByPath(ctx) {
28
+ const memoised = newFileCreatorsMemo.get(ctx);
29
+ if (memoised)
30
+ return memoised;
31
+ const creators = new Map();
32
+ for (const entry of ctx.entries) {
33
+ const newFiles = detectNewFilesInDiff(entry.diff);
34
+ for (const file of newFiles) {
35
+ let owners = creators.get(file);
36
+ if (!owners) {
37
+ owners = [];
38
+ creators.set(file, owners);
39
+ }
40
+ owners.push(entry.filename);
41
+ }
42
+ }
43
+ newFileCreatorsMemo.set(ctx, creators);
44
+ return creators;
45
+ }
@@ -79,23 +79,7 @@ export interface PatchQueueContext {
79
79
  * @param patchesDir - Path to the patches directory
80
80
  */
81
81
  export declare function buildPatchQueueContext(patchesDir: string, config?: FireForgeConfig): Promise<PatchQueueContext>;
82
- /**
83
- * Returns the raw `path → patches[]` map of files created in `new file
84
- * mode` by at least one patch in the queue. Paths created by only one
85
- * patch are also included so callers can distinguish "no creator" from
86
- * "exactly one creator" without re-scanning the diffs.
87
- *
88
- * Split out from {@link lintPatchQueueDuplicateCreations} so
89
- * `status --ownership` (and any future caller that wants ownership
90
- * without a rendered PatchLintIssue) can consume the same structured
91
- * data the rule itself relies on. Previously status had to parse the
92
- * rule's human-readable message to recover the patch list, which was
93
- * both fragile and made the lint message format part of an implicit
94
- * contract.
95
- *
96
- * @param ctx - Pre-built queue context
97
- */
98
- export declare function collectNewFileCreatorsByPath(ctx: PatchQueueContext): Map<string, string[]>;
82
+ export { collectNewFileCreatorsByPath, invalidateNewFileCreatorsCache, } from './patch-lint-creators.js';
99
83
  /**
100
84
  * Cross-patch lint rule: the same path is newly created (`--- /dev/null →
101
85
  * +++ b/path`) by more than one patch. This is the failure mode that
@@ -16,7 +16,9 @@ import { readText } from '../utils/fs.js';
16
16
  import { verbose } from '../utils/logger.js';
17
17
  import { stripJsComments } from '../utils/regex.js';
18
18
  import { discoverPatches } from './patch-files.js';
19
+ import { collectNewFileCreatorsByPath } from './patch-lint-creators.js';
19
20
  import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
21
+ import { lintPatchQueueModuleRegistrations } from './patch-lint-module-registration.js';
20
22
  import { loadPatchesManifest } from './patch-manifest-io.js';
21
23
  import { categoryRangeForOrder, categoryRangeLabel } from './patch-policy.js';
22
24
  import { extractNewFileContent } from './patch-transform.js';
@@ -81,37 +83,11 @@ export async function buildPatchQueueContext(patchesDir, config) {
81
83
  entries.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
82
84
  return { entries, ...(config?.patchPolicy ? { patchPolicy: config.patchPolicy } : {}) };
83
85
  }
84
- /**
85
- * Returns the raw `path → patches[]` map of files created in `new file
86
- * mode` by at least one patch in the queue. Paths created by only one
87
- * patch are also included so callers can distinguish "no creator" from
88
- * "exactly one creator" without re-scanning the diffs.
89
- *
90
- * Split out from {@link lintPatchQueueDuplicateCreations} so
91
- * `status --ownership` (and any future caller that wants ownership
92
- * without a rendered PatchLintIssue) can consume the same structured
93
- * data the rule itself relies on. Previously status had to parse the
94
- * rule's human-readable message to recover the patch list, which was
95
- * both fragile and made the lint message format part of an implicit
96
- * contract.
97
- *
98
- * @param ctx - Pre-built queue context
99
- */
100
- export function collectNewFileCreatorsByPath(ctx) {
101
- const creators = new Map();
102
- for (const entry of ctx.entries) {
103
- const newFiles = detectNewFilesInDiff(entry.diff);
104
- for (const file of newFiles) {
105
- let owners = creators.get(file);
106
- if (!owners) {
107
- owners = [];
108
- creators.set(file, owners);
109
- }
110
- owners.push(entry.filename);
111
- }
112
- }
113
- return creators;
114
- }
86
+ // The creators map (split out of `lintPatchQueueDuplicateCreations` so
87
+ // `status --ownership` can consume the structured data) lives in
88
+ // `patch-lint-creators.ts` with its per-context memoisation (FORGE J1);
89
+ // re-exported here so callers keep importing from `patch-lint.ts`.
90
+ export { collectNewFileCreatorsByPath, invalidateNewFileCreatorsCache, } from './patch-lint-creators.js';
115
91
  /**
116
92
  * Cross-patch lint rule: the same path is newly created (`--- /dev/null →
117
93
  * +++ b/path`) by more than one patch. This is the failure mode that
@@ -131,6 +107,7 @@ export function lintPatchQueueDuplicateCreations(ctx) {
131
107
  issues.push({
132
108
  file,
133
109
  check: 'duplicate-new-file-creation',
110
+ patches: [...owners].sort((a, b) => a.localeCompare(b)),
134
111
  fingerprint: `duplicate-new-file-creation|${file}|${[...owners].sort((a, b) => a.localeCompare(b)).join(',')}`,
135
112
  message: `File "${file}" is created (new file mode) by multiple patches: ${owners.join(', ')}. ` +
136
113
  'Only one patch may create a given path. Use "patch delete" or ' +
@@ -519,6 +496,7 @@ export function lintPatchQueueForwardImports(ctx) {
519
496
  issues.push({
520
497
  file: sitePath,
521
498
  check: 'forward-import',
499
+ patches: [entry.filename],
522
500
  fingerprint: `forward-import|${sitePath}|${cleaned}|${fingerprintOwners}`,
523
501
  message: `${sitePath} in ${entry.filename} imports "${specifier}", ` +
524
502
  `but the matching new file is created by a later patch: ${ownersSummary}. ` +
@@ -537,6 +515,7 @@ export function lintPatchQueueForwardImports(ctx) {
537
515
  issues.push({
538
516
  file: dependency.file,
539
517
  check: 'staged-dependency-unused',
518
+ patches: [entry.filename],
540
519
  fingerprint: `staged-dependency-unused|${entry.filename}|${dependency.file}|${dependency.specifier}|${dependency.creates}|${dependency.owner ?? ''}`,
541
520
  message: `${entry.filename} declares a staged forward import from ${dependency.file} ` +
542
521
  `to "${dependency.specifier}" / ${dependency.creates}, but that exact forward dependency was not found. ` +
@@ -559,6 +538,7 @@ export function lintPatchQueueForwardImports(ctx) {
559
538
  issues.push({
560
539
  file: registration.file,
561
540
  check: 'staged-dependency-unused',
541
+ patches: [entry.filename],
562
542
  fingerprint: `staged-dependency-unused|${entry.filename}|${registration.file}|reg:${registration.line}|${registration.creates}|${registration.owner ?? ''}`,
563
543
  message: `${entry.filename} declares a staged registration in ${registration.file} ` +
564
544
  `("${registration.line}") for ${registration.creates}, but ${describeRegistrationFailure(failure)} ` +
@@ -631,5 +611,9 @@ function isRegistrationLinePresent(entry, registration) {
631
611
  * manually for projected/hypothetical states)
632
612
  */
633
613
  export function lintPatchQueue(ctx) {
634
- return [...lintPatchQueueDuplicateCreations(ctx), ...lintPatchQueueForwardImports(ctx)];
614
+ return [
615
+ ...lintPatchQueueDuplicateCreations(ctx),
616
+ ...lintPatchQueueForwardImports(ctx),
617
+ ...lintPatchQueueModuleRegistrations(ctx),
618
+ ];
635
619
  }
@@ -9,7 +9,9 @@
9
9
  import type * as acorn from 'acorn';
10
10
  import type { ClassDeclaration, FunctionDeclaration } from 'estree';
11
11
  import type { AcornESTreeNode } from './ast-utils.js';
12
- export type JsDocCheck = 'missing-jsdoc' | 'jsdoc-param-mismatch' | 'jsdoc-missing-returns' | 'missing-jsdoc-class-method' | 'jsdoc-class-method-param-mismatch' | 'jsdoc-class-method-missing-returns';
12
+ export type JsDocCheck =
13
+ /** The source could not be parsed, so no JSDoc rule could run on it. */
14
+ 'jsdoc-unparseable-source' | 'missing-jsdoc' | 'jsdoc-param-mismatch' | 'jsdoc-missing-returns' | 'missing-jsdoc-class-method' | 'jsdoc-class-method-param-mismatch' | 'jsdoc-class-method-missing-returns';
13
15
  export interface JsDocIssue {
14
16
  line: number;
15
17
  check: JsDocCheck;
@@ -7,7 +7,8 @@
7
7
  * Separated from `patch-lint.ts` to keep both files within the
8
8
  * project's per-file line budget.
9
9
  */
10
- import { parseModule } from './ast-utils.js';
10
+ import { toError } from '../utils/errors.js';
11
+ import { asEstree, parseModule } from './ast-utils.js';
11
12
  // ---------------------------------------------------------------------------
12
13
  // JSDoc comment helpers
13
14
  // ---------------------------------------------------------------------------
@@ -339,7 +340,7 @@ export function validateClassMethods(cls, comments, source, issues, severity) {
339
340
  for (const member of cls.body.body) {
340
341
  if (member.type !== 'MethodDefinition')
341
342
  continue;
342
- const method = member;
343
+ const method = asEstree(member);
343
344
  if (isPrivateMethodKey(method))
344
345
  continue;
345
346
  const name = staticMethodName(method);
@@ -369,7 +370,7 @@ export function validateClassMethods(cls, comments, source, issues, severity) {
369
370
  continue;
370
371
  }
371
372
  const skipReturns = method.kind === 'constructor' || method.kind === 'set';
372
- validateParamsAndReturns(method.value, jsDoc, issues, {
373
+ validateParamsAndReturns(asEstree(method.value), jsDoc, issues, {
373
374
  label,
374
375
  line,
375
376
  paramCheck: 'jsdoc-class-method-param-mismatch',
@@ -396,8 +397,17 @@ export function validateExportJsDoc(source, options) {
396
397
  try {
397
398
  ast = parseModule(source, comments);
398
399
  }
399
- catch {
400
- return [];
400
+ catch (error) {
401
+ // An unparseable source is NOT a clean file. Returning `[]` here was the
402
+ // same value as "fully documented", so a syntax error silently cleared
403
+ // every rule this module enforces. Report it instead.
404
+ return [
405
+ {
406
+ line: 1,
407
+ check: 'jsdoc-unparseable-source',
408
+ message: `Source could not be parsed for JSDoc analysis: ${toError(error).message}`,
409
+ },
410
+ ];
401
411
  }
402
412
  const issues = [];
403
413
  const body = ast.body;
@@ -0,0 +1,15 @@
1
+ import type { PatchLintIssue } from '../types/commands/index.js';
2
+ interface ModuleRegistrationQueueEntry {
3
+ filename: string;
4
+ newFiles: ReadonlyMap<string, string>;
5
+ modifiedFileAdditions: ReadonlyMap<string, string>;
6
+ }
7
+ interface ModuleRegistrationQueueContext {
8
+ entries: readonly ModuleRegistrationQueueEntry[];
9
+ }
10
+ /**
11
+ * Flags a new `.sys.mjs` imported through a Firefox resource URL when the
12
+ * projected queue never adds it to a moz.build list.
13
+ */
14
+ export declare function lintPatchQueueModuleRegistrations(ctx: ModuleRegistrationQueueContext): PatchLintIssue[];
15
+ export {};
@@ -0,0 +1,88 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Queue-wide registration lint for newly-created Firefox system modules.
4
+ */
5
+ import { basename } from 'node:path';
6
+ import { escapeRegex, stripJsComments } from '../utils/regex.js';
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) {
10
+ const cleaned = specifier.split(/[?#]/)[0] ?? specifier;
11
+ for (const prefix of ['resource:///modules/', 'resource://gre/modules/']) {
12
+ if (cleaned.startsWith(prefix))
13
+ return `/modules/${cleaned.slice(prefix.length)}`;
14
+ }
15
+ return undefined;
16
+ }
17
+ /** Resource-module strings in actual code (comments removed), de-duplicated. */
18
+ function extractResourceModuleSpecifiers(content) {
19
+ const stripped = stripJsComments(content);
20
+ const found = new Set();
21
+ const pattern = /["'](resource:\/\/(?:\/|gre\/)modules\/[^"']+\.sys\.mjs(?:[?#][^"']*)?)["']/g;
22
+ for (const match of stripped.matchAll(pattern)) {
23
+ if (match[1] !== undefined)
24
+ found.add(match[1]);
25
+ }
26
+ return [...found];
27
+ }
28
+ /** True when some patch in the projected queue adds the module to moz.build. */
29
+ function queueRegistersModule(ctx, leaf) {
30
+ const escaped = escapeRegex(leaf);
31
+ const quotedLeaf = new RegExp(`["']${escaped}["']`);
32
+ for (const entry of ctx.entries) {
33
+ for (const [path, content] of [...entry.newFiles, ...entry.modifiedFileAdditions]) {
34
+ if ((path === 'moz.build' || path.endsWith('/moz.build')) && quotedLeaf.test(content)) {
35
+ return true;
36
+ }
37
+ }
38
+ }
39
+ return false;
40
+ }
41
+ /**
42
+ * Flags a new `.sys.mjs` imported through a Firefox resource URL when the
43
+ * projected queue never adds it to a moz.build list.
44
+ */
45
+ export function lintPatchQueueModuleRegistrations(ctx) {
46
+ const imported = new Map();
47
+ const collect = (entry, path, content) => {
48
+ if (!IMPORTABLE_EXTENSIONS.some((extension) => path.endsWith(extension)))
49
+ return;
50
+ for (const specifier of extractResourceModuleSpecifiers(content)) {
51
+ const suffix = moduleSourceSuffix(specifier);
52
+ if (suffix === undefined)
53
+ continue;
54
+ const importers = imported.get(suffix) ?? new Set();
55
+ importers.add(entry.filename);
56
+ imported.set(suffix, importers);
57
+ }
58
+ };
59
+ for (const entry of ctx.entries) {
60
+ for (const [path, content] of entry.newFiles)
61
+ collect(entry, path, content);
62
+ for (const [path, content] of entry.modifiedFileAdditions)
63
+ collect(entry, path, content);
64
+ }
65
+ const issues = [];
66
+ for (const entry of ctx.entries) {
67
+ for (const path of entry.newFiles.keys()) {
68
+ if (!path.endsWith('.sys.mjs'))
69
+ continue;
70
+ const matched = [...imported.entries()].find(([suffix]) => path.endsWith(suffix));
71
+ if (!matched || queueRegistersModule(ctx, basename(path)))
72
+ continue;
73
+ const [suffix, importers] = matched;
74
+ issues.push({
75
+ file: path,
76
+ check: 'unregistered-system-module',
77
+ patches: [...new Set([entry.filename, ...importers])],
78
+ fingerprint: `unregistered-system-module|${path}|${suffix}`,
79
+ message: `${path} is newly created and imported as a resource module, but no patch adds ` +
80
+ `${basename(path)} to a moz.build list. Register it in EXTRA_JS_MODULES (or the ` +
81
+ 'appropriate namespaced list) before export; source-level typechecks can resolve the ' +
82
+ 'file even though the packaged browser cannot.',
83
+ severity: 'error',
84
+ });
85
+ }
86
+ }
87
+ return issues;
88
+ }
@@ -31,40 +31,128 @@ const STRICT_ORDERED_MOZBUILD_LISTS = [
31
31
  'XPIDL_SOURCES',
32
32
  ];
33
33
  const LIST_OPENER = new RegExp(`^\\s*((?:${STRICT_ORDERED_MOZBUILD_LISTS.join('|')})(?:\\.[A-Za-z_][A-Za-z0-9_]*)*)\\s*\\+?=\\s*\\[`);
34
- /** Extracts every strict-ordered list (with items in file order) from moz.build content. */
34
+ /**
35
+ * Strips a Python trailing comment from a moz.build line.
36
+ *
37
+ * `#` inside a quoted string is not a comment, so the scan tracks quote state
38
+ * rather than using `indexOf('#')`.
39
+ */
40
+ /**
41
+ * True when `line` contains a `]` outside any quoted span. A bracket inside an
42
+ * item (`"icons[2x].png"`) is filename text, not the list's close — reading it
43
+ * as one truncated the item set and left every later item unchecked.
44
+ */
45
+ function hasUnquotedCloseBracket(line) {
46
+ let quote;
47
+ for (let i = 0; i < line.length; i++) {
48
+ const ch = line[i];
49
+ if (quote !== undefined) {
50
+ if (ch === '\\')
51
+ i += 1;
52
+ else if (ch === quote)
53
+ quote = undefined;
54
+ }
55
+ else if (ch === '"' || ch === "'") {
56
+ quote = ch;
57
+ }
58
+ else if (ch === ']') {
59
+ return true;
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+ function stripMozBuildComment(line) {
65
+ let quote;
66
+ for (let i = 0; i < line.length; i++) {
67
+ const ch = line[i];
68
+ if (quote !== undefined) {
69
+ if (ch === '\\')
70
+ i += 1;
71
+ else if (ch === quote)
72
+ quote = undefined;
73
+ }
74
+ else if (ch === '"' || ch === "'") {
75
+ quote = ch;
76
+ }
77
+ else if (ch === '#') {
78
+ return line.slice(0, i);
79
+ }
80
+ }
81
+ return line;
82
+ }
83
+ /**
84
+ * Extracts every strict-ordered list (with items in file order) from moz.build
85
+ * content.
86
+ *
87
+ * Comments are stripped before both the item scan and the close-bracket test.
88
+ * moz.build is Python, and before 0.41.0 neither step knew that:
89
+ *
90
+ * - a quoted string in a trailing comment was scraped in as a phantom list
91
+ * item (`"Zeta.cpp", # replaces "Beta.cpp"` yielded a `Beta.cpp` entry), so
92
+ * the sort check reported an unsorted list naming a file that is not in it —
93
+ * with a fingerprint that could never stabilise, because the item does not
94
+ * exist to be moved;
95
+ * - a `]` anywhere in a comment (`# see foo[0]`) closed the list early and
96
+ * silently truncated the item set.
97
+ *
98
+ * The scan also no longer advances the caller's loop counter: an unterminated
99
+ * list used to consume the rest of the file, so every *later* list in it was
100
+ * skipped entirely.
101
+ *
102
+ * Nor does an unterminated list borrow the NEXT list's bracket. The forward
103
+ * scan stops at the next list opener rather than reading through it: without
104
+ * that, an unclosed `EXTRA_COMPONENTS` swallowed the following
105
+ * `EXTRA_JS_MODULES` — its items merged into the first list's item set and its
106
+ * `]` accepted as the first list's close — reporting a sorting error against a
107
+ * variable that never contained those items, and skipping the second list.
108
+ */
35
109
  function collectStrictOrderedLists(content) {
36
110
  const lines = content.split('\n');
37
111
  const occurrences = [];
38
112
  for (let i = 0; i < lines.length; i++) {
39
- const line = lines[i] ?? '';
40
- const opener = LIST_OPENER.exec(line);
113
+ const opener = LIST_OPENER.exec(lines[i] ?? '');
41
114
  if (!opener)
42
115
  continue;
43
116
  const varName = opener[1] ?? '';
44
117
  const items = [];
45
118
  // Items may start on the opener line (`VAR += ["a", "b"]`) or span
46
119
  // following lines until the closing bracket.
47
- let rest = line.slice(opener[0].length);
120
+ let rest = stripMozBuildComment((lines[i] ?? '').slice(opener[0].length));
48
121
  let closed = false;
122
+ // Scan ahead on a local cursor so an unterminated list costs only itself.
123
+ let cursor = i;
49
124
  for (;;) {
50
- for (const quoted of rest.matchAll(/["']([^"']+)["']/g)) {
51
- items.push(quoted[1] ?? '');
125
+ // Quote types are matched pairwise so an apostrophe inside a
126
+ // double-quoted item ("don't.cpp") cannot terminate the match early and
127
+ // scrape in a phantom item.
128
+ for (const quoted of rest.matchAll(/"([^"]+)"|'([^']+)'/g)) {
129
+ items.push(quoted[1] ?? quoted[2] ?? '');
52
130
  }
53
- if (/\]/.test(rest)) {
131
+ if (hasUnquotedCloseBracket(rest)) {
54
132
  closed = true;
55
133
  break;
56
134
  }
57
- i += 1;
58
- if (i >= lines.length)
135
+ cursor += 1;
136
+ if (cursor >= lines.length)
59
137
  break;
60
- rest = lines[i] ?? '';
138
+ rest = stripMozBuildComment(lines[cursor] ?? '');
139
+ if (LIST_OPENER.test(rest)) {
140
+ // A new list starts here, so the previous one never closed. Rewind so
141
+ // the outer loop examines this line as its own opener.
142
+ cursor -= 1;
143
+ break;
144
+ }
61
145
  if (/^\s*\]/.test(rest)) {
62
146
  closed = true;
63
147
  break;
64
148
  }
65
149
  }
66
- if (closed)
150
+ if (closed) {
67
151
  occurrences.push({ varName, items });
152
+ // Resume after the list we just consumed; a list that never closed
153
+ // leaves `i` untouched so the next line is still examined.
154
+ i = cursor;
155
+ }
68
156
  }
69
157
  return occurrences;
70
158
  }