@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
@@ -3,14 +3,15 @@ import { join } from 'node:path';
3
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
4
4
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
5
5
  import { buildPerPatchLintCacheKey, getCachedPerPatchLintIssues, getPerPatchLintCacheHeadSha, loadPerPatchLintCache, savePerPatchLintCache, setCachedPerPatchLintIssues, } from '../core/lint-cache.js';
6
- import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue, resolvePatchSizeTier, } from '../core/patch-lint.js';
7
- import { invokePatchLintCheckJsGrouped, } from '../core/patch-lint-checkjs.js';
8
- import { resolvePatchOwnedSysMjs } from '../core/patch-lint-ownership.js';
6
+ import { buildPatchQueueContext, countNonBinaryDiffLines, formatPatchLintIssue, lintExportedPatch, lintPatchQueue, resolvePatchSizeTier, } from '../core/patch-lint.js';
9
7
  import { loadPatchesManifest } from '../core/patch-manifest.js';
10
8
  import { evaluatePatchPolicy } from '../core/patch-policy.js';
11
9
  import { GeneralError } from '../errors/base.js';
10
+ import { mapWithConcurrency } from '../utils/concurrency.js';
12
11
  import { pathExists } from '../utils/fs.js';
13
12
  import { info, outro, success, warn } from '../utils/logger.js';
13
+ import { buildPerRunCheckJs } from './lint-per-run-checkjs.js';
14
+ import { writePerPatchLintReport } from './lint-report.js';
14
15
  function buildPerPatchMaxWarningsMessage(count, maxWarnings, linted) {
15
16
  return (`Patch lint found ${count} warning(s) across ${linted} patch(es), exceeding --max-warnings ${maxWarnings}.` +
16
17
  ' If this is a release gate, run with --per-patch to identify the owning patch. For intentional staged imports, use patch staged-dependency; for ownership repairs, preview patch move-files, patch reorder --dry-run, or re-export --files --dry-run; add scoped lintIgnore only after review.');
@@ -33,13 +34,17 @@ function emitTierNotice(filename, files, tier) {
33
34
  */
34
35
  async function lintQueuedPatch(patch, lintCtx) {
35
36
  const { projectRoot, paths, config, ctx, cache, engineHeadSha } = lintCtx;
36
- const existing = [];
37
- for (const f of patch.filesAffected) {
38
- if (await pathExists(join(paths.engine, f)))
39
- existing.push(f);
40
- }
37
+ const present = await mapWithConcurrency(patch.filesAffected, 8, (f) => pathExists(join(paths.engine, f)));
38
+ const existing = patch.filesAffected.filter((_, index) => present[index] === true);
41
39
  if (existing.length === 0) {
42
- return { status: 'skipped', existingFiles: [], rawIssues: [], usedCheckJs: false };
40
+ return {
41
+ status: 'skipped',
42
+ existingFiles: [],
43
+ rawIssues: [],
44
+ suppressedIssues: [],
45
+ lineCount: 0,
46
+ usedCheckJs: false,
47
+ };
43
48
  }
44
49
  const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
45
50
  let cacheKey;
@@ -61,16 +66,33 @@ async function lintQueuedPatch(patch, lintCtx) {
61
66
  // an engine-side revert that would empty the diff always changes the
62
67
  // key and misses the cache (pinned by the "engine-side content
63
68
  // revert invalidates the per-patch cache" test — FORGE F5).
64
- return { status: 'cached', existingFiles: existing, rawIssues: cached, usedCheckJs: false };
69
+ return {
70
+ status: 'cached',
71
+ existingFiles: existing,
72
+ rawIssues: cached.issues,
73
+ suppressedIssues: cached.suppressed,
74
+ lineCount: cached.lineCount,
75
+ usedCheckJs: false,
76
+ };
65
77
  }
66
78
  }
67
79
  const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
68
80
  if (!diff.trim()) {
69
- return { status: 'skipped', existingFiles: [], rawIssues: [], usedCheckJs: false };
81
+ return {
82
+ status: 'skipped',
83
+ existingFiles: [],
84
+ rawIssues: [],
85
+ suppressedIssues: [],
86
+ lineCount: 0,
87
+ usedCheckJs: false,
88
+ };
70
89
  }
71
90
  // checkJs: instead of rebuilding the program per patch, slice this patch's
72
91
  // findings out of the one queue-wide program (built lazily on first miss).
73
- let lintOptions;
92
+ const suppressedIssues = [];
93
+ const lintOptions = {
94
+ onSuppressed: (suppressed) => suppressedIssues.push(...suppressed),
95
+ };
74
96
  let usedCheckJs = false;
75
97
  if (lintCtx.checkJs) {
76
98
  const grouped = await lintCtx.checkJs.getGrouped();
@@ -81,20 +103,28 @@ async function lintQueuedPatch(patch, lintCtx) {
81
103
  for (const rel of owned)
82
104
  precomputedCheckJs.push(...(grouped.byFile.get(rel) ?? []));
83
105
  }
84
- lintOptions = { precomputedCheckJs };
106
+ lintOptions.precomputedCheckJs = precomputedCheckJs;
85
107
  }
86
108
  const patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore, patch.tier, lintOptions);
87
109
  const result = {
88
110
  status: 'linted',
89
111
  existingFiles: existing,
90
112
  rawIssues: patchIssues,
113
+ suppressedIssues,
114
+ lineCount: countNonBinaryDiffLines(diff).textLines,
91
115
  usedCheckJs,
92
116
  };
93
117
  if (cache && cacheKey) {
94
- result.cacheWrite = { key: cacheKey, issues: patchIssues };
118
+ result.cacheWrite = { key: cacheKey };
95
119
  }
96
120
  return result;
97
121
  }
122
+ /** Size-rule check IDs whose waived measurement is still reported (FORGE G10). */
123
+ const SUPPRESSED_SIZE_CHECKS = new Set([
124
+ 'large-patch-lines',
125
+ 'large-patch-files',
126
+ 'file-too-large',
127
+ ]);
98
128
  /**
99
129
  * Applies the per-patch results in patch order so the bounded concurrency
100
130
  * cannot reorder output: emits each tier notice, the once-only run-level
@@ -108,6 +138,7 @@ async function applyPerPatchResults(subset, results, issues, checkJs, cache) {
108
138
  skipped: 0,
109
139
  cacheDirty: false,
110
140
  reusedCacheEntries: 0,
141
+ suppressed: 0,
111
142
  };
112
143
  let globalCheckJsEmitted = false;
113
144
  for (let i = 0; i < subset.length; i++) {
@@ -132,12 +163,22 @@ async function applyPerPatchResults(subset, results, issues, checkJs, cache) {
132
163
  issues.push(...(await checkJs.getGlobal()));
133
164
  }
134
165
  if (result.cacheWrite && cache) {
135
- setCachedPerPatchLintIssues(cache, patch.filename, result.cacheWrite.key, result.cacheWrite.issues);
166
+ setCachedPerPatchLintIssues(cache, patch.filename, result.cacheWrite.key, result.rawIssues, result.suppressedIssues, result.lineCount);
136
167
  totals.cacheDirty = true;
137
168
  }
138
169
  for (const issue of result.rawIssues) {
139
170
  issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
140
171
  }
172
+ // A waived size finding still reports its MEASUREMENT (FORGE G10):
173
+ // the finding stays suppressed (no exit-code / --max-warnings effect)
174
+ // but the current count is readable from the tool that enforces it,
175
+ // so a waiver's cited size can be calibrated without hand-measuring.
176
+ for (const suppressedIssue of result.suppressedIssues) {
177
+ if (!SUPPRESSED_SIZE_CHECKS.has(suppressedIssue.check))
178
+ continue;
179
+ info(`NOTICE [${suppressedIssue.check}] ${patch.filename}: suppressed by lintIgnore — ${suppressedIssue.message}`);
180
+ }
181
+ totals.suppressed += result.suppressedIssues.length;
141
182
  if (result.status === 'cached')
142
183
  totals.reusedCacheEntries++;
143
184
  totals.linted++;
@@ -193,11 +234,11 @@ function reportPerPatchOutcome(issues, linted, skipped, options) {
193
234
  const warnings = issues.filter((i) => i.severity === 'warning');
194
235
  const notices = issues.filter((i) => i.severity === 'notice');
195
236
  for (const issue of notices)
196
- info(`NOTICE [${issue.check}] ${issue.file}: ${issue.message}`);
237
+ info(`NOTICE ${formatPatchLintIssue(issue)}`);
197
238
  for (const issue of warnings)
198
- warn(`[${issue.check}] ${issue.file}: ${issue.message}`);
239
+ warn(formatPatchLintIssue(issue));
199
240
  for (const issue of errors)
200
- warn(`ERROR [${issue.check}] ${issue.file}: ${issue.message}`);
241
+ warn(`ERROR ${formatPatchLintIssue(issue)}`);
201
242
  info(`\nLint (per-patch over ${linted} patch(es)): ${errors.length} error(s), ${warnings.length} warning(s)`);
202
243
  if (errors.length > 0) {
203
244
  outro('Lint failed');
@@ -248,7 +289,9 @@ function selectPatchSubset(manifest, requested) {
248
289
  const found = manifest.patches.filter((p) => matches(p, name));
249
290
  if (found.length === 0) {
250
291
  const available = manifest.patches.map((p) => p.filename).join(', ');
251
- throw new GeneralError(`--patches: no patch in the queue matches "${name}". Available patches: ${available}`);
292
+ throw new GeneralError(`No patch in the queue matches "${name}". In --per-patch mode, positional arguments ` +
293
+ `and --patches select patches (filename, stem, manifest name, or slug) — not engine ` +
294
+ `files; drop --per-patch to lint engine paths. Available patches: ${available}`);
252
295
  }
253
296
  for (const p of found) {
254
297
  if (!seen.has(p.filename)) {
@@ -259,36 +302,6 @@ function selectPatchSubset(manifest, requested) {
259
302
  }
260
303
  return selected;
261
304
  }
262
- /**
263
- * Builds the per-run checkJs program controller when `patchLint.checkJs` is
264
- * enabled, or returns undefined. The program is built lazily on the first
265
- * cache miss (so an all-warm run never pays for it) and reused for every
266
- * subsequent patch in the run.
267
- */
268
- function buildPerRunCheckJs(projectRoot, paths, config, ctx) {
269
- const patchLint = config.patchLint;
270
- if (!patchLint?.checkJs)
271
- return undefined;
272
- const ownedByPatch = new Map();
273
- for (const entry of ctx.entries) {
274
- const owned = new Set();
275
- for (const f of entry.newFiles.keys()) {
276
- if (f.endsWith('.sys.mjs'))
277
- owned.add(f);
278
- }
279
- if (owned.size > 0)
280
- ownedByPatch.set(entry.filename, owned);
281
- }
282
- // Memoise the *promise*, not the resolved value: under the bounded pool
283
- // several patches can reach `getGrouped` before the first build resolves, and
284
- // `??=` on the promise (a synchronous expression) guarantees a single build.
285
- let groupedPromise;
286
- return {
287
- ownedByPatch,
288
- getGrouped: () => (groupedPromise ??= invokePatchLintCheckJsGrouped(paths.engine, resolvePatchOwnedSysMjs(new Set(), ctx), patchLint, projectRoot)),
289
- getGlobal: async () => (await (groupedPromise ??= invokePatchLintCheckJsGrouped(paths.engine, resolvePatchOwnedSysMjs(new Set(), ctx), patchLint, projectRoot))).global,
290
- };
291
- }
292
305
  /**
293
306
  * Lints each patch in the queue as its own isolated diff, honouring
294
307
  * per-patch `lintIgnore` entries. Cross-patch rules still run once over
@@ -337,7 +350,10 @@ export async function lintPerPatch(projectRoot, paths, options = {}) {
337
350
  severity: issue.severity,
338
351
  });
339
352
  }
340
- const checkJs = buildPerRunCheckJs(projectRoot, paths, config, ctx);
353
+ // With `--patches`, the checkJs program roots at only the subset's owned
354
+ // files — the full queue stays resolvable, so a cold subset run costs the
355
+ // subset's import closure instead of the whole queue (FORGE J1c).
356
+ const checkJs = buildPerRunCheckJs(projectRoot, paths, config, ctx, isSubset ? subsetNames : undefined);
341
357
  // Lint patches concurrently, then apply every side effect in patch order so
342
358
  // the issue rows, the run-level checkJs errors, and the saved cache are
343
359
  // identical to a serial run.
@@ -350,7 +366,7 @@ export async function lintPerPatch(projectRoot, paths, options = {}) {
350
366
  engineHeadSha,
351
367
  checkJs,
352
368
  });
353
- const { linted, skipped, cacheDirty, reusedCacheEntries } = await applyPerPatchResults(subset, results, issues, checkJs, cache);
369
+ const { linted, skipped, cacheDirty, reusedCacheEntries, suppressed } = await applyPerPatchResults(subset, results, issues, checkJs, cache);
354
370
  for (const issue of lintPatchQueue(ctx)) {
355
371
  if (isSubset && !subsetTouchedFiles.has(issue.file))
356
372
  continue;
@@ -361,5 +377,11 @@ export async function lintPerPatch(projectRoot, paths, options = {}) {
361
377
  if (reusedCacheEntries > 0) {
362
378
  info(`Reused lint cache for ${reusedCacheEntries} patch${reusedCacheEntries === 1 ? '' : 'es'}.`);
363
379
  }
380
+ if (suppressed > 0) {
381
+ info(`Suppressed ${suppressed} issue(s) via per-patch lintIgnore.`);
382
+ }
383
+ if (options.report !== undefined) {
384
+ await writePerPatchLintReport(options.report, subset, results);
385
+ }
364
386
  reportPerPatchOutcome(issues, linted, skipped, options);
365
387
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Per-run checkJs program controller, shared by `lint --per-patch` and
3
+ * `re-export` (FORGE J1).
4
+ *
5
+ * The dominant fixed cost of patch lint is one `ts.createProgram` over the
6
+ * queue's patch-owned `.sys.mjs` files (~37 s on a 290-patch consumer
7
+ * queue). This controller builds that program at most ONCE per command
8
+ * invocation (lazily, promise-memoised) and lets callers slice per-patch
9
+ * findings out of the grouped result. Three levers keep the cost paid only
10
+ * when it buys something:
11
+ *
12
+ * - **Lazy build**: nothing is built until the first cache miss asks.
13
+ * - **Root scoping** (`rootScopePatches`): with `--patches <subset>` the
14
+ * program's roots are only the subset's owned files; the full queue
15
+ * stays resolvable, so cross-patch imports type-check identically while
16
+ * unrelated files are never parsed (FORGE J1c).
17
+ * - **Warm-run probe**: run-level ("global") checkJs findings come only
18
+ * from a missing `typescript` package or an unreadable shim — never
19
+ * from the built program — so an all-cache-hit run satisfies "warm
20
+ * never reports less than cold" (FORGE F5) via
21
+ * `probeCheckJsGlobalIssues` instead of building anything.
22
+ */
23
+ import type { getProjectPaths, loadConfig } from '../core/config.js';
24
+ import { type GroupedCheckJsResult } from '../core/patch-lint-checkjs.js';
25
+ import type { PatchQueueContext } from '../core/patch-lint-cross.js';
26
+ import type { PatchLintIssue } from '../types/commands/index.js';
27
+ /**
28
+ * Queue-wide checkJs program built once per run and sliced per patch, so a
29
+ * single type regression surfaces once against its owning patch instead of
30
+ * being duplicated for every patch in the queue.
31
+ */
32
+ export interface PerRunCheckJs {
33
+ /** Patch filename → the checkJs-relevant files that patch creates. */
34
+ ownedByPatch: Map<string, Set<string>>;
35
+ /** Every checkJs-relevant file in the queue (sys + test scripts) — the
36
+ * resolution universe the program can see. Callers use it to detect
37
+ * files the hoisted program has never heard of (fresh `--scan`
38
+ * adoptions) and fall back to a per-patch build. */
39
+ resolutionSet: ReadonlySet<string>;
40
+ /** Builds (once, lazily on first cache miss) and returns the grouped run.
41
+ * Promise-memoised so the bounded per-patch pool builds the program exactly
42
+ * once even when several patches reach it concurrently. */
43
+ getGrouped: () => Promise<GroupedCheckJsResult>;
44
+ /** Run-level checkJs errors (e.g. TypeScript missing). When the program
45
+ * was never built (all-warm run) this probes the two global failure
46
+ * sources directly instead of building it — FORGE F5 holds because the
47
+ * built program never contributes globals of its own. */
48
+ getGlobal: () => Promise<PatchLintIssue[]>;
49
+ /** The byFile slices for `files` (globals deliberately EXCLUDED — they
50
+ * are run-level, never cached, and emitted once per invocation via
51
+ * `getGlobal`). Feed to `LintExportedPatchOptions.precomputedCheckJs`. */
52
+ sliceFor: (files: ReadonlySet<string>) => Promise<PatchLintIssue[]>;
53
+ /** True when this file kind participates in the run's checkJs surface
54
+ * (`.sys.mjs`, plus test scripts when `checkJsTestFiles` is on). */
55
+ isRelevant: (file: string) => boolean;
56
+ }
57
+ /**
58
+ * Builds the per-run checkJs program controller when `patchLint.checkJs` is
59
+ * enabled, or returns undefined. With `rootScopePatches` set (the
60
+ * `--patches` subset, by patch filename), the program roots at only the
61
+ * subset's owned files and the test-file pass runs only the subset's own
62
+ * scripts (head.js discovery still spans the queue).
63
+ */
64
+ export declare function buildPerRunCheckJs(projectRoot: string, paths: ReturnType<typeof getProjectPaths>, config: Awaited<ReturnType<typeof loadConfig>>, ctx: PatchQueueContext, rootScopePatches?: ReadonlySet<string>): PerRunCheckJs | undefined;
@@ -0,0 +1,102 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Per-run checkJs program controller, shared by `lint --per-patch` and
4
+ * `re-export` (FORGE J1).
5
+ *
6
+ * The dominant fixed cost of patch lint is one `ts.createProgram` over the
7
+ * queue's patch-owned `.sys.mjs` files (~37 s on a 290-patch consumer
8
+ * queue). This controller builds that program at most ONCE per command
9
+ * invocation (lazily, promise-memoised) and lets callers slice per-patch
10
+ * findings out of the grouped result. Three levers keep the cost paid only
11
+ * when it buys something:
12
+ *
13
+ * - **Lazy build**: nothing is built until the first cache miss asks.
14
+ * - **Root scoping** (`rootScopePatches`): with `--patches <subset>` the
15
+ * program's roots are only the subset's owned files; the full queue
16
+ * stays resolvable, so cross-patch imports type-check identically while
17
+ * unrelated files are never parsed (FORGE J1c).
18
+ * - **Warm-run probe**: run-level ("global") checkJs findings come only
19
+ * from a missing `typescript` package or an unreadable shim — never
20
+ * from the built program — so an all-cache-hit run satisfies "warm
21
+ * never reports less than cold" (FORGE F5) via
22
+ * `probeCheckJsGlobalIssues` instead of building anything.
23
+ */
24
+ import { invokePatchLintCheckJsGrouped, probeCheckJsGlobalIssues, runCheckJsTestFilesGrouped, } from '../core/patch-lint-checkjs.js';
25
+ import { isTestScriptFile, resolvePatchOwnedSysMjs, resolvePatchOwnedTestScripts, } from '../core/patch-lint-ownership.js';
26
+ /**
27
+ * Builds the per-run checkJs program controller when `patchLint.checkJs` is
28
+ * enabled, or returns undefined. With `rootScopePatches` set (the
29
+ * `--patches` subset, by patch filename), the program roots at only the
30
+ * subset's owned files and the test-file pass runs only the subset's own
31
+ * scripts (head.js discovery still spans the queue).
32
+ */
33
+ export function buildPerRunCheckJs(projectRoot, paths, config, ctx, rootScopePatches) {
34
+ const patchLint = config.patchLint;
35
+ if (!patchLint?.checkJs)
36
+ return undefined;
37
+ const testFilesEnabled = patchLint.checkJsTestFiles === true;
38
+ const ownedByPatch = new Map();
39
+ for (const entry of ctx.entries) {
40
+ const owned = new Set();
41
+ for (const f of entry.newFiles.keys()) {
42
+ if (f.endsWith('.sys.mjs'))
43
+ owned.add(f);
44
+ else if (testFilesEnabled && isTestScriptFile(f))
45
+ owned.add(f);
46
+ }
47
+ if (owned.size > 0)
48
+ ownedByPatch.set(entry.filename, owned);
49
+ }
50
+ const resolutionSet = new Set();
51
+ for (const files of ownedByPatch.values()) {
52
+ for (const f of files)
53
+ resolutionSet.add(f);
54
+ }
55
+ // The subset's owned files, split by program kind; undefined = full queue.
56
+ let scopedRoots;
57
+ if (rootScopePatches !== undefined) {
58
+ scopedRoots = new Set();
59
+ for (const [patchFilename, files] of ownedByPatch) {
60
+ if (!rootScopePatches.has(patchFilename))
61
+ continue;
62
+ for (const f of files)
63
+ scopedRoots.add(f);
64
+ }
65
+ }
66
+ // One build for the whole run: the queue-wide `.sys.mjs` program plus —
67
+ // when `patchLint.checkJsTestFiles` is on (FORGE G5) — one small
68
+ // script-scope program per patch-owned test file, merged by file.
69
+ const buildAll = async () => {
70
+ const sys = await invokePatchLintCheckJsGrouped(paths.engine, resolvePatchOwnedSysMjs(new Set(), ctx), patchLint, projectRoot, scopedRoots);
71
+ if (!testFilesEnabled)
72
+ return sys;
73
+ const tests = await runCheckJsTestFilesGrouped(paths.engine, resolvePatchOwnedTestScripts(new Set(), ctx), patchLint, projectRoot, scopedRoots);
74
+ const byFile = new Map(sys.byFile);
75
+ for (const [rel, list] of tests.byFile) {
76
+ byFile.set(rel, [...(byFile.get(rel) ?? []), ...list]);
77
+ }
78
+ return { byFile, global: [...sys.global, ...tests.global] };
79
+ };
80
+ // Memoise the *promise*, not the resolved value: under the bounded pool
81
+ // several patches can reach `getGrouped` before the first build resolves, and
82
+ // `??=` on the promise (a synchronous expression) guarantees a single build.
83
+ let groupedPromise;
84
+ const getGrouped = () => (groupedPromise ??= buildAll());
85
+ return {
86
+ ownedByPatch,
87
+ resolutionSet,
88
+ getGrouped,
89
+ getGlobal: async () => groupedPromise !== undefined
90
+ ? (await groupedPromise).global
91
+ : probeCheckJsGlobalIssues(patchLint, projectRoot),
92
+ sliceFor: async (files) => {
93
+ const grouped = await getGrouped();
94
+ const issues = [];
95
+ for (const rel of files) {
96
+ issues.push(...(grouped.byFile.get(rel) ?? []));
97
+ }
98
+ return issues;
99
+ },
100
+ isRelevant: (file) => file.endsWith('.sys.mjs') || (testFilesEnabled && isTestScriptFile(file)),
101
+ };
102
+ }
@@ -0,0 +1,21 @@
1
+ import type { PatchLintIssue, PatchMetadata } from '../types/commands/index.js';
2
+ /**
3
+ * Structural view of one per-patch lint outcome. Mirrors the relevant
4
+ * fields of lint-per-patch's QueuedPatchResult without importing it —
5
+ * that module imports this one, and a type back-edge would trip the
6
+ * dpdm cycle gate.
7
+ */
8
+ export interface PerPatchReportResult {
9
+ status: 'skipped' | 'cached' | 'linted';
10
+ existingFiles: string[];
11
+ rawIssues: PatchLintIssue[];
12
+ suppressedIssues: PatchLintIssue[];
13
+ lineCount: number;
14
+ }
15
+ /**
16
+ * Writes the per-patch lint report JSON. Called after the per-patch
17
+ * results are assembled in patch order; queue-level and policy findings
18
+ * are patch-attributed by their `file`/`filename` fields upstream and
19
+ * are NOT duplicated here — the report is the per-patch view.
20
+ */
21
+ export declare function writePerPatchLintReport(reportPath: string, subset: readonly PatchMetadata[], results: readonly PerPatchReportResult[]): Promise<void>;
@@ -0,0 +1,62 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Machine-readable per-patch lint report (`lint --per-patch --report
4
+ * <path>`, FORGE G9 + G10). One mechanism serves both observability
5
+ * gaps: consumers get each patch's size metrics against the SAME
6
+ * thresholds the size rules fire on (no more mirroring
7
+ * `countNonBinaryDiffLines`), and lintIgnore-suppressed issues stay
8
+ * visible with their measurements instead of vanishing.
9
+ *
10
+ * Helper module consumed by lint-per-patch.ts; no registrar is exported
11
+ * and none is wanted.
12
+ */
13
+ import { getPatchSizeThresholds, resolvePatchSizeTier, } from '../core/patch-lint.js';
14
+ import { writeJson } from '../utils/fs.js';
15
+ import { info } from '../utils/logger.js';
16
+ const LINT_REPORT_SCHEMA_VERSION = 1;
17
+ /**
18
+ * Writes the per-patch lint report JSON. Called after the per-patch
19
+ * results are assembled in patch order; queue-level and policy findings
20
+ * are patch-attributed by their `file`/`filename` fields upstream and
21
+ * are NOT duplicated here — the report is the per-patch view.
22
+ */
23
+ export async function writePerPatchLintReport(reportPath, subset, results) {
24
+ const patches = [];
25
+ let errors = 0;
26
+ let warnings = 0;
27
+ let suppressed = 0;
28
+ let linted = 0;
29
+ let skipped = 0;
30
+ for (let i = 0; i < subset.length; i++) {
31
+ const patch = subset[i];
32
+ const result = results[i];
33
+ if (!patch || !result)
34
+ continue;
35
+ const tier = resolvePatchSizeTier(result.existingFiles, patch.tier);
36
+ patches.push({
37
+ filename: patch.filename,
38
+ name: patch.name,
39
+ status: result.status,
40
+ lineCount: result.lineCount,
41
+ filesAffected: patch.filesAffected.length,
42
+ tier,
43
+ thresholds: getPatchSizeThresholds(tier.tier),
44
+ issues: result.rawIssues,
45
+ suppressedIssues: result.suppressedIssues,
46
+ });
47
+ if (result.status === 'skipped')
48
+ skipped++;
49
+ else
50
+ linted++;
51
+ errors += result.rawIssues.filter((issue) => issue.severity === 'error').length;
52
+ warnings += result.rawIssues.filter((issue) => issue.severity === 'warning').length;
53
+ suppressed += result.suppressedIssues.length;
54
+ }
55
+ await writeJson(reportPath, {
56
+ schemaVersion: LINT_REPORT_SCHEMA_VERSION,
57
+ generatedAt: new Date().toISOString(),
58
+ patches,
59
+ totals: { linted, skipped, errors, warnings, suppressed },
60
+ });
61
+ info(`Per-patch lint report written to ${reportPath}.`);
62
+ }
@@ -10,7 +10,7 @@ import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
10
10
  import { expandUntrackedDirectoryEntries, getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, getWorkingTreeStatus, } from '../core/git-status.js';
11
11
  import { clearPerPatchLintCache } from '../core/lint-cache.js';
12
12
  import { extractAffectedFiles } from '../core/patch-apply.js';
13
- import { buildPatchQueueContext, countNonBinaryDiffLines, lintExportedPatch, lintPatchQueue, lintPatchSize, } from '../core/patch-lint.js';
13
+ import { buildPatchQueueContext, countNonBinaryDiffLines, formatPatchLintIssue, lintExportedPatch, lintPatchQueue, lintPatchSize, } from '../core/patch-lint.js';
14
14
  import { collectDiffFilePaths, tagLintIssues } from '../core/patch-lint-diff-tag.js';
15
15
  import { GeneralError } from '../errors/base.js';
16
16
  import { pathExists } from '../utils/fs.js';
@@ -217,11 +217,12 @@ export function applyAggregateLintIgnoreSuppression(issues, ctx) {
217
217
  }
218
218
  /**
219
219
  * Up-front flag validation for `lintCommand`: rejects `--only-introduced`
220
- * without `--since`, non-integer `--max-warnings`, and `--per-patch`
221
- * combined with explicit file paths — each a misconfiguration that should
222
- * fail loud rather than silently narrow the result.
220
+ * without `--since` and non-integer `--max-warnings` — each a
221
+ * misconfiguration that should fail loud rather than silently narrow the
222
+ * result. Positional arguments in per-patch mode are patch selectors
223
+ * (FORGE G14) and are validated downstream by `selectPatchSubset`.
223
224
  */
224
- function validateLintFlags(options, files) {
225
+ function validateLintFlags(options) {
225
226
  // `--only-introduced` scopes the exit code to `--since`-tagged issues, so
226
227
  // without a revision to anchor the diff there is no "introduced" subset
227
228
  // to scope to — reject the combination up-front so a misconfigured CI
@@ -234,22 +235,16 @@ function validateLintFlags(options, files) {
234
235
  (!Number.isInteger(options.maxWarnings) || options.maxWarnings < 0)) {
235
236
  throw new GeneralError('--max-warnings must be a non-negative integer.');
236
237
  }
237
- // `--per-patch` rescopes the diff from "aggregate engine state" to "each
238
- // patch's own filesAffected". Mixing in explicit engine file paths would
239
- // produce an ambiguous set — is the file list an additional filter, or
240
- // does it replace the per-patch scope? Reject up-front, but point at the
241
- // first-class subset filter so an operator who wanted to target patches
242
- // (not engine files) knows the supported syntax.
243
- if (options.perPatch && files.length > 0) {
244
- throw new GeneralError('--per-patch cannot be combined with explicit engine file paths. ' +
245
- 'To lint a subset of patches, use `--per-patch --patches <name…>`; ' +
246
- 'to lint specific engine files, drop --per-patch.');
247
- }
248
238
  // `--patches` only means something in per-patch mode (it filters the
249
239
  // queue); in aggregate/file-list mode there is no patch loop to narrow.
250
240
  if (options.patches !== undefined && !options.perPatch) {
251
241
  throw new GeneralError('--patches requires --per-patch.');
252
242
  }
243
+ // The JSON report is a per-patch artifact (per-patch size metrics and
244
+ // suppressed issues); aggregate mode has no per-patch rows to report.
245
+ if (options.report !== undefined && !options.perPatch) {
246
+ throw new GeneralError('--report requires --per-patch.');
247
+ }
253
248
  }
254
249
  /**
255
250
  * Aggregate-mode patch-size softening: when the linted diff is every
@@ -366,13 +361,13 @@ async function reportLintOutcome(engineDir, issues, options) {
366
361
  const notices = issues.filter((i) => i.severity === 'notice');
367
362
  const tagPrefix = (issue) => sinceActive && issue.tag ? `[${issue.tag}] ` : '';
368
363
  for (const issue of notices) {
369
- info(`${tagPrefix(issue)}NOTICE [${issue.check}] ${issue.file}: ${issue.message}`);
364
+ info(`${tagPrefix(issue)}NOTICE ${formatPatchLintIssue(issue)}`);
370
365
  }
371
366
  for (const issue of warnings) {
372
- warn(`${tagPrefix(issue)}[${issue.check}] ${issue.file}: ${issue.message}`);
367
+ warn(`${tagPrefix(issue)}${formatPatchLintIssue(issue)}`);
373
368
  }
374
369
  for (const issue of errors) {
375
- warn(`${tagPrefix(issue)}ERROR [${issue.check}] ${issue.file}: ${issue.message}`);
370
+ warn(`${tagPrefix(issue)}ERROR ${formatPatchLintIssue(issue)}`);
376
371
  }
377
372
  if (sinceActive) {
378
373
  const introducedErrors = errors.filter((i) => i.tag === 'introduced').length;
@@ -429,7 +424,7 @@ async function reportLintOutcome(engineDir, issues, options) {
429
424
  */
430
425
  export async function lintCommand(projectRoot, files, options = {}) {
431
426
  intro('FireForge Lint');
432
- validateLintFlags(options, files);
427
+ validateLintFlags(options);
433
428
  const paths = getProjectPaths(projectRoot);
434
429
  if (!(await pathExists(paths.engine))) {
435
430
  throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
@@ -438,7 +433,12 @@ export async function lintCommand(projectRoot, files, options = {}) {
438
433
  throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
439
434
  }
440
435
  if (options.perPatch) {
441
- await lintPerPatch(projectRoot, paths, options);
436
+ // Positional arguments in per-patch mode select PATCHES through the
437
+ // same alias resolution as --patches (FORGE G14) — a non-matching
438
+ // positional fails loud against the queue listing, so an engine path
439
+ // can never silently lint. Explicit --patches entries come first.
440
+ const perPatchOptions = files.length > 0 ? { ...options, patches: [...(options.patches ?? []), ...files] } : options;
441
+ await lintPerPatch(projectRoot, paths, perPatchOptions);
442
442
  return;
443
443
  }
444
444
  // Load the config before resolving the diff so we can pass
@@ -511,9 +511,10 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
511
511
  .option('--since <git-rev>', 'Tag issues as [introduced] or [cumulative] based on whether the file changed since <git-rev> (e.g. HEAD, a branch, a SHA)')
512
512
  .option('--only-introduced', 'Fail only on issues tagged [introduced] (requires --since). Cumulative errors still print but do not set a non-zero exit.')
513
513
  .option('--per-patch', "Lint each patch in the queue as its own isolated diff. Rescopes patch-size rules so they fire against individual patches rather than the aggregate. Honours each patch's `lintIgnore` entries.")
514
- .option('--patches <names...>', 'With --per-patch, lint only the named patches. Accepts repeated flags, comma lists, full filenames/stems, manifest names, category-prefixed slugs, or bare slugs.')
514
+ .option('--patches <names...>', 'With --per-patch, lint only the named patches. Accepts repeated flags, comma lists, full filenames/stems, manifest names, category-prefixed slugs, or bare slugs. Positional arguments after --per-patch are treated the same way.')
515
515
  .option('--max-warnings <n>', 'Fail when lint reports more than <n> warning(s); use 0 for warning-clean release gates.')
516
516
  .option('--no-cache', 'Bypass per-patch lint result cache reads and writes.')
517
+ .option('--report <path>', 'With --per-patch, write a machine-readable JSON report (per-patch size metrics, tier, thresholds, issues, and lintIgnore-suppressed issues) to <path>.')
517
518
  .action(withErrorHandling(async (paths, options) => {
518
519
  const lintOptions = {};
519
520
  if (options.since !== undefined) {
@@ -538,6 +539,9 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
538
539
  if (options.cache === false) {
539
540
  lintOptions.noCache = true;
540
541
  }
542
+ if (options.report !== undefined) {
543
+ lintOptions.report = options.report;
544
+ }
541
545
  await lintCommand(getProjectRoot(), paths, lintOptions);
542
546
  }));
543
547
  const lintCache = lint
@@ -11,7 +11,7 @@ import { registerImport } from './import.js';
11
11
  import { registerLint } from './lint.js';
12
12
  import { registerPackage } from './package.js';
13
13
  import { registerPatch } from './patch/index.js';
14
- import { registerReExport } from './re-export.js';
14
+ import { registerReExport } from './re-export-register.js';
15
15
  import { registerRebase } from './rebase.js';
16
16
  import { registerRegister } from './register.js';
17
17
  import { registerReset } from './reset.js';
@@ -22,6 +22,7 @@ import { registerSource } from './source.js';
22
22
  import { registerStatus } from './status.js';
23
23
  import { registerTest } from './test-register.js';
24
24
  import { registerToken } from './token.js';
25
+ import { registerTree } from './tree.js';
25
26
  import { registerTypecheck } from './typecheck.js';
26
27
  import { registerVerify } from './verify.js';
27
28
  import { registerWatch } from './watch.js';
@@ -50,6 +51,7 @@ export const COMMAND_MANIFEST = [
50
51
  { name: 'package', group: 'workflow', register: registerPackage },
51
52
  { name: 'watch', group: 'workflow', register: registerWatch },
52
53
  { name: 'test', group: 'workflow', register: registerTest },
54
+ { name: 'tree', group: 'workflow', register: registerTree },
53
55
  { name: 'config', group: 'project', register: registerConfig },
54
56
  { name: 'doctor', group: 'diagnostics', register: registerDoctor },
55
57
  { name: 'register', group: 'workflow', register: registerRegister },