@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
@@ -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
  }
@@ -41,3 +41,24 @@ export declare function resolvePatchOwnedSysMjs(currentNewFiles: Set<string>, pa
41
41
  * @returns Set of patch-owned chrome-subscript file paths
42
42
  */
43
43
  export declare function resolvePatchOwnedChromeScripts(currentNewFiles: Set<string>, patchQueueCtx?: PatchQueueContext): Set<string>;
44
+ /**
45
+ * Test-script predicate for the `checkJsTestFiles` pass (FORGE G5):
46
+ * plain `.js` files (not `.sys.mjs` modules) that live under a `/test/`
47
+ * path or carry a `browser_` / `test_` / `xpcshell_` basename. Duplicates
48
+ * `patch-lint.ts`'s `isTestFile` shape rather than importing it —
49
+ * patch-lint.ts imports this module, so the reverse edge would create an
50
+ * import cycle (dpdm gate). The agreement is pinned by
51
+ * `patch-lint-ownership.test.ts`.
52
+ */
53
+ export declare function isTestScriptFile(file: string): boolean;
54
+ /**
55
+ * Returns the set of patch-owned test `.js` files (FORGE G5). Same
56
+ * ownership semantics as {@link resolvePatchOwnedSysMjs}; consumed by the
57
+ * `patchLint.checkJsTestFiles` pass, which checks each as its own small
58
+ * script-scope program.
59
+ *
60
+ * @param currentNewFiles - Files newly created in the current diff
61
+ * @param patchQueueCtx - Optional cross-patch context for queue-wide ownership
62
+ * @returns Set of patch-owned test-script file paths
63
+ */
64
+ export declare function resolvePatchOwnedTestScripts(currentNewFiles: Set<string>, patchQueueCtx?: PatchQueueContext): Set<string>;
@@ -67,3 +67,33 @@ export function resolvePatchOwnedSysMjs(currentNewFiles, patchQueueCtx) {
67
67
  export function resolvePatchOwnedChromeScripts(currentNewFiles, patchQueueCtx) {
68
68
  return resolveOwned(currentNewFiles, patchQueueCtx, (file) => file.endsWith('.js') && !file.endsWith('.sys.mjs'));
69
69
  }
70
+ /**
71
+ * Test-script predicate for the `checkJsTestFiles` pass (FORGE G5):
72
+ * plain `.js` files (not `.sys.mjs` modules) that live under a `/test/`
73
+ * path or carry a `browser_` / `test_` / `xpcshell_` basename. Duplicates
74
+ * `patch-lint.ts`'s `isTestFile` shape rather than importing it —
75
+ * patch-lint.ts imports this module, so the reverse edge would create an
76
+ * import cycle (dpdm gate). The agreement is pinned by
77
+ * `patch-lint-ownership.test.ts`.
78
+ */
79
+ export function isTestScriptFile(file) {
80
+ if (!file.endsWith('.js') || file.endsWith('.sys.mjs'))
81
+ return false;
82
+ if (file.includes('/test/'))
83
+ return true;
84
+ const basename = file.split('/').pop() ?? '';
85
+ return /^(?:browser_|test_|xpcshell_).*\.js$/.test(basename);
86
+ }
87
+ /**
88
+ * Returns the set of patch-owned test `.js` files (FORGE G5). Same
89
+ * ownership semantics as {@link resolvePatchOwnedSysMjs}; consumed by the
90
+ * `patchLint.checkJsTestFiles` pass, which checks each as its own small
91
+ * script-scope program.
92
+ *
93
+ * @param currentNewFiles - Files newly created in the current diff
94
+ * @param patchQueueCtx - Optional cross-patch context for queue-wide ownership
95
+ * @returns Set of patch-owned test-script file paths
96
+ */
97
+ export function resolvePatchOwnedTestScripts(currentNewFiles, patchQueueCtx) {
98
+ return resolveOwned(currentNewFiles, patchQueueCtx, isTestScriptFile);
99
+ }
@@ -2,5 +2,6 @@
2
2
  * Public re-exports for {@link ./patch-lint.ts}. Split out so the
3
3
  * orchestrator stays within the ESLint `max-lines` budget.
4
4
  */
5
- export { buildPatchQueueContext, collectForwardImportEdges, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, type ForwardImportEdge, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, type PatchQueueContext, type PatchQueueEntry, } from './patch-lint-cross.js';
5
+ export { buildPatchQueueContext, collectForwardImportEdges, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, type PatchQueueContext, type PatchQueueEntry, } from './patch-lint-cross.js';
6
6
  export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
7
+ export { lintPatchQueueModuleRegistrations } from './patch-lint-module-registration.js';
@@ -5,3 +5,4 @@
5
5
  */
6
6
  export { buildPatchQueueContext, collectForwardImportEdges, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, } from './patch-lint-cross.js';
7
7
  export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
8
+ export { lintPatchQueueModuleRegistrations } from './patch-lint-module-registration.js';
@@ -4,6 +4,12 @@ import { type CommentStyle } from './license-headers.js';
4
4
  import { lintPatchedCss } from './patch-lint-css.js';
5
5
  export * from './patch-lint-reexports.js';
6
6
  export { lintPatchedCss };
7
+ /**
8
+ * Canonical `[check] file: message` rendering of a patch-lint issue — the
9
+ * one string shape every reporter and refusal message uses, so operators
10
+ * can grep one format across lint output and refusal details.
11
+ */
12
+ export declare function formatPatchLintIssue(issue: Pick<PatchLintIssue, 'check' | 'file' | 'message'>): string;
7
13
  /**
8
14
  * Counts the total lines in a unified diff and the number of non-binary
9
15
  * text lines, so binary hunks do not inflate patch size checks.
@@ -24,6 +30,8 @@ export declare function countNonBinaryDiffLines(diffContent: string): {
24
30
  * `browser_`, `test_`, or `xpcshell_` (all `.js`).
25
31
  */
26
32
  export declare function isTestFile(file: string): boolean;
33
+ /** True when `strippedContent` contains at least one assertion call. */
34
+ export declare function hasBrowserChromeAssertion(strippedContent: string): boolean;
27
35
  /**
28
36
  * Detects comment style from file extension for license header checks.
29
37
  */
@@ -128,6 +136,21 @@ export type PatchSizeTierDecision = {
128
136
  * defensive-for-tests choice.
129
137
  */
130
138
  export declare function resolvePatchSizeTier(filesAffected: ReadonlyArray<string>, patchTier?: 'branding'): PatchSizeTierDecision;
139
+ /**
140
+ * Read-only view of the size thresholds a tier enforces (FORGE G9).
141
+ * Public-API companion to {@link resolvePatchSizeTier} /
142
+ * {@link countNonBinaryDiffLines} so consumers can build waiver-freshness
143
+ * checks against the SAME numbers `large-patch-lines` /
144
+ * `large-patch-files` fire on instead of mirroring them.
145
+ */
146
+ export declare function getPatchSizeThresholds(tier: 'general' | 'test' | 'branding'): {
147
+ lines: {
148
+ notice: number;
149
+ warning: number;
150
+ error: number;
151
+ };
152
+ maxFiles: number;
153
+ };
131
154
  /**
132
155
  * Checks patch size and emits advisory warnings.
133
156
  *
@@ -179,6 +202,14 @@ export interface LintExportedPatchOptions {
179
202
  * attributes findings per patch instead of rebuilding per patch.
180
203
  */
181
204
  precomputedCheckJs?: readonly PatchLintIssue[];
205
+ /**
206
+ * Invoked with the issues DROPPED by the `ignoreChecks` waiver filter,
207
+ * when any were (FORGE G10). Suppression semantics are unchanged — the
208
+ * returned issue list still excludes them — but the caller can report
209
+ * the measurement a waived size finding carried (per-patch NOTICE lines
210
+ * and the `--report` JSON) instead of losing it entirely.
211
+ */
212
+ onSuppressed?: (suppressed: PatchLintIssue[]) => void;
182
213
  }
183
214
  /**
184
215
  * Runs all patch lint checks and returns combined issues.
@@ -3,7 +3,7 @@ import { dirname, join } from 'node:path';
3
3
  import { pathExists, readText } from '../utils/fs.js';
4
4
  import { stripJsComments } from '../utils/regex.js';
5
5
  import { containsUpstreamLicenseText, getLicenseHeader, hasAnyLicenseHeader, hasAnyLicenseHeaderAnyStyle, hasUpstreamMplBlockHeader, } from './license-headers.js';
6
- import { invokePatchLintCheckJs } from './patch-lint-checkjs.js';
6
+ import { invokePatchLintCheckJs, runCheckJsTestFilesGrouped } from './patch-lint-checkjs.js';
7
7
  import { lintChromeScriptJsDocForFile } from './patch-lint-chrome-jsdoc.js';
8
8
  import { lintPatchedCss } from './patch-lint-css.js';
9
9
  import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
@@ -12,7 +12,7 @@ import { hasRelativeImport } from './patch-lint-imports.js';
12
12
  import { validateExportJsDoc } from './patch-lint-jsdoc.js';
13
13
  import { lintMozBuildSortedLists } from './patch-lint-mozbuild.js';
14
14
  import { lintObserverTopics } from './patch-lint-observer.js';
15
- import { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
15
+ import { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs, resolvePatchOwnedTestScripts, } from './patch-lint-ownership.js';
16
16
  // ---------------------------------------------------------------------------
17
17
  // Cross-patch lint re-exports
18
18
  // ---------------------------------------------------------------------------
@@ -49,6 +49,14 @@ function countContentLines(content) {
49
49
  lines.pop();
50
50
  return lines.length;
51
51
  }
52
+ /**
53
+ * Canonical `[check] file: message` rendering of a patch-lint issue — the
54
+ * one string shape every reporter and refusal message uses, so operators
55
+ * can grep one format across lint output and refusal details.
56
+ */
57
+ export function formatPatchLintIssue(issue) {
58
+ return `[${issue.check}] ${issue.file}: ${issue.message}`;
59
+ }
52
60
  /**
53
61
  * Counts the total lines in a unified diff and the number of non-binary
54
62
  * text lines, so binary hunks do not inflate patch size checks.
@@ -136,6 +144,16 @@ const PATCH_FILES_THRESHOLDS = {
136
144
  test: 5,
137
145
  branding: 60,
138
146
  };
147
+ /**
148
+ * Shared remedy sentence for the over-budget findings (FORGE H4): the two
149
+ * places an operator stands when a patch outgrows its budget must name the
150
+ * sanctioned one-transaction way out, or they reach for the wrong tools
151
+ * first (the field report tried two before finding it). One constant, four
152
+ * emission sites — the wording cannot drift.
153
+ */
154
+ const SPLIT_HINT = 'Consider splitting into smaller, focused patches — ' +
155
+ '"fireforge patch move-files <from> <to> --file <path> --create --order <n>" ' +
156
+ 'moves files into a new patch in one transaction.';
139
157
  /**
140
158
  * Fixed allowlist of non-branding sibling paths that real-world Firefox
141
159
  * branding patches legitimately need to touch to register the new
@@ -214,6 +232,67 @@ function isBrowserChromeTestFile(file) {
214
232
  return false;
215
233
  return basename.startsWith('browser_');
216
234
  }
235
+ /**
236
+ * Bare assertion-call detection for the `test-needs-assertion` floor.
237
+ *
238
+ * Each alternative must sit at a call boundary: preceded by something that is
239
+ * not an identifier character, `.`, `#`, or `-`. Until 0.41.0 this was a plain
240
+ * `strippedContent.includes(tok)` over the tokens `Assert.`, `ok(`, `is(`,
241
+ * `isnot(`, `isDeeply(`, so any identifier merely *ending* in one satisfied
242
+ * the gate — `this.axis(3)` contains `is(`, `book(` contains `ok(`,
243
+ * `promiseIsDeeply(` contains `isDeeply(`. An assertion-free smoke test using
244
+ * any such call passed the floor, which is exactly what the rule exists to
245
+ * catch.
246
+ *
247
+ * The namespaced arm must name a member and a call. Matching the namespace and
248
+ * its dot alone — as `(?:Assert|SimpleTest)\.` did — re-opened the same hole
249
+ * from the other side: `SimpleTest` is mostly *harness* API, so
250
+ * `SimpleTest.finish()`, `SimpleTest.waitForExplicitFinish()` and
251
+ * `SimpleTest.requestCompleteLog()` each cleared the floor on their own. Those
252
+ * are the exact calls an assertion-free smoke test contains. The enumeration
253
+ * below is the mochitest/xpcshell assertion surface both namespaces share, and
254
+ * it is what the user-facing message already names.
255
+ *
256
+ * Two qualified spellings of the bare four still count: `window.ok(...)` (the
257
+ * globals ARE window properties) and an optional-chained `win?.ok(...)`.
258
+ * Blocking those flagged tests whose only assertions are real as
259
+ * assertion-free. `SpecialPowers.ok(` stays excluded — that is API surface,
260
+ * not an assertion.
261
+ *
262
+ * Deliberately still textual, not AST-based: this runs over patch bodies
263
+ * rather than resolvable sources, and the surrounding rule is a floor, not a
264
+ * proof.
265
+ */
266
+ const ASSERTION_MEMBERS = [
267
+ 'ok',
268
+ 'is',
269
+ 'isnot',
270
+ 'isDeeply',
271
+ 'equal',
272
+ 'notEqual',
273
+ 'deepEqual',
274
+ 'notDeepEqual',
275
+ 'strictEqual',
276
+ 'notStrictEqual',
277
+ 'greater',
278
+ 'greaterOrEqual',
279
+ 'less',
280
+ 'lessOrEqual',
281
+ 'stringMatches',
282
+ 'stringContains',
283
+ 'throws',
284
+ 'rejects',
285
+ // `todo`, `todo_is`, `todo_isnot`, `info`-free — todo* record an expected
286
+ // failure, which is still a recorded assertion outcome.
287
+ 'todo',
288
+ 'todo_is',
289
+ 'todo_isnot',
290
+ ].join('|');
291
+ const BROWSER_CHROME_ASSERTION = new RegExp(`(?<![\\w.#$-])(?:(?:Assert|SimpleTest)\\.(?:${ASSERTION_MEMBERS})\\s*\\(|(?:window\\.|[\\w$]+\\?\\.)?(?:ok|is|isnot|isDeeply)\\s*\\()`);
292
+ /** True when `strippedContent` contains at least one assertion call. */
293
+ export function hasBrowserChromeAssertion(strippedContent) {
294
+ return BROWSER_CHROME_ASSERTION.test(strippedContent);
295
+ }
217
296
  /**
218
297
  * Detects comment style from file extension for license header checks.
219
298
  */
@@ -406,8 +485,7 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
406
485
  // browser_*.js silently passed under the old `isNew` gate).
407
486
  const assertionFloor = config.patchLint?.testAssertionFloor;
408
487
  if (assertionFloor && assertionFloor !== 'off' && isBrowserChromeTestFile(file)) {
409
- const ASSERTION_TOKENS = ['Assert.', 'ok(', 'is(', 'isnot(', 'isDeeply('];
410
- const hasAssertion = ASSERTION_TOKENS.some((tok) => strippedContent.includes(tok));
488
+ const hasAssertion = hasBrowserChromeAssertion(strippedContent);
411
489
  if (!hasAssertion) {
412
490
  issues.push({
413
491
  file,
@@ -481,6 +559,19 @@ export function resolvePatchSizeTier(filesAffected, patchTier) {
481
559
  return { tier: 'branding', source: 'auto' };
482
560
  return { tier: 'general' };
483
561
  }
562
+ /**
563
+ * Read-only view of the size thresholds a tier enforces (FORGE G9).
564
+ * Public-API companion to {@link resolvePatchSizeTier} /
565
+ * {@link countNonBinaryDiffLines} so consumers can build waiver-freshness
566
+ * checks against the SAME numbers `large-patch-lines` /
567
+ * `large-patch-files` fire on instead of mirroring them.
568
+ */
569
+ export function getPatchSizeThresholds(tier) {
570
+ return {
571
+ lines: { ...PATCH_LINE_THRESHOLDS[tier] },
572
+ maxFiles: PATCH_FILES_THRESHOLDS[tier],
573
+ };
574
+ }
484
575
  /**
485
576
  * Checks patch size and emits advisory warnings.
486
577
  *
@@ -522,7 +613,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
522
613
  issues.push({
523
614
  file: AGGREGATE_PATCH_FILE,
524
615
  check: 'large-patch-files',
525
- message: `Patch affects ${filesAffected.length} files (recommended: ≤${fileThreshold}). Consider splitting into smaller, focused patches.`,
616
+ message: `Patch affects ${filesAffected.length} files (recommended: ≤${fileThreshold}). ${SPLIT_HINT}`,
526
617
  severity: 'warning',
527
618
  });
528
619
  }
@@ -530,7 +621,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
530
621
  issues.push({
531
622
  file: AGGREGATE_PATCH_FILE,
532
623
  check: 'large-patch-lines',
533
- message: `Patch is ${lineCount} lines (hard limit: ${lineThresholds.error}). Consider splitting into smaller, focused patches.`,
624
+ message: `Patch is ${lineCount} lines (hard limit: ${lineThresholds.error}). ${SPLIT_HINT}`,
534
625
  severity: 'error',
535
626
  });
536
627
  }
@@ -538,7 +629,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
538
629
  issues.push({
539
630
  file: AGGREGATE_PATCH_FILE,
540
631
  check: 'large-patch-lines',
541
- message: `Patch is ${lineCount} lines (soft limit: ${lineThresholds.warning}, hard limit: ${lineThresholds.error}). Consider splitting into smaller, focused patches.`,
632
+ message: `Patch is ${lineCount} lines (soft limit: ${lineThresholds.warning}, hard limit: ${lineThresholds.error}). ${SPLIT_HINT}`,
542
633
  severity: 'warning',
543
634
  });
544
635
  }
@@ -546,7 +637,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
546
637
  issues.push({
547
638
  file: AGGREGATE_PATCH_FILE,
548
639
  check: 'large-patch-lines',
549
- message: `Patch is ${lineCount} lines (soft limit: ${lineThresholds.warning}, hard limit: ${lineThresholds.error}). Consider splitting into smaller, focused patches.`,
640
+ message: `Patch is ${lineCount} lines (soft limit: ${lineThresholds.warning}, hard limit: ${lineThresholds.error}). ${SPLIT_HINT}`,
550
641
  severity: 'notice',
551
642
  });
552
643
  }
@@ -644,6 +735,17 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
644
735
  }
645
736
  else if (config.patchLint?.checkJs) {
646
737
  issues.push(...(await invokePatchLintCheckJs(repoDir, patchOwnedFiles, config.patchLint, dirname(repoDir), options?.checkJsReportScope)));
738
+ // Export-time twin of per-patch lint's test-file pass (FORGE G5), so
739
+ // export/re-export and `lint --per-patch` agree on the checked surface.
740
+ if (config.patchLint.checkJsTestFiles === true) {
741
+ const testGrouped = await runCheckJsTestFilesGrouped(repoDir, resolvePatchOwnedTestScripts(newFiles, patchQueueCtx), config.patchLint, dirname(repoDir));
742
+ issues.push(...testGrouped.global);
743
+ for (const [rel, list] of testGrouped.byFile) {
744
+ if (options?.checkJsReportScope && !options.checkJsReportScope.has(rel))
745
+ continue;
746
+ issues.push(...list);
747
+ }
748
+ }
647
749
  }
648
750
  // Filter out ignored checks last so every rule still runs (keeps the
649
751
  // implementation uniform) but suppressed rules do not surface. We do not
@@ -651,6 +753,10 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
651
753
  // inline `fireforge-ignore: <check>` markers work in the CSS and
652
754
  // forward-import rules.
653
755
  if (ignoreChecks && ignoreChecks.size > 0) {
756
+ const suppressed = issues.filter((issue) => ignoreChecks.has(issue.check));
757
+ if (suppressed.length > 0) {
758
+ options?.onSuppressed?.(suppressed);
759
+ }
654
760
  return issues.filter((issue) => !ignoreChecks.has(issue.check));
655
761
  }
656
762
  return issues;