@hominis/fireforge 0.30.1 → 0.32.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 (152) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +22 -0
  3. package/dist/src/commands/export-all.js +9 -16
  4. package/dist/src/commands/export-flow.d.ts +6 -0
  5. package/dist/src/commands/export-flow.js +6 -1
  6. package/dist/src/commands/export-placement-gate.d.ts +38 -0
  7. package/dist/src/commands/export-placement-gate.js +105 -0
  8. package/dist/src/commands/export-shared.d.ts +28 -0
  9. package/dist/src/commands/export-shared.js +46 -1
  10. package/dist/src/commands/export.js +52 -113
  11. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +0 -13
  12. package/dist/src/commands/furnace/chrome-doc-templates.js +1 -1
  13. package/dist/src/commands/furnace/create-dry-run.d.ts +1 -1
  14. package/dist/src/commands/furnace/create.d.ts +1 -2
  15. package/dist/src/commands/furnace/deploy.js +36 -114
  16. package/dist/src/commands/furnace/refresh.js +52 -32
  17. package/dist/src/commands/furnace/sync.js +2 -0
  18. package/dist/src/commands/import.js +108 -73
  19. package/dist/src/commands/lint-per-patch.d.ts +3 -1
  20. package/dist/src/commands/lint-per-patch.js +265 -74
  21. package/dist/src/commands/lint.d.ts +1 -58
  22. package/dist/src/commands/lint.js +193 -88
  23. package/dist/src/commands/patch/compact.d.ts +5 -2
  24. package/dist/src/commands/patch/compact.js +85 -25
  25. package/dist/src/commands/patch/delete.js +17 -17
  26. package/dist/src/commands/patch/index.js +2 -0
  27. package/dist/src/commands/patch/lint-ignore.js +3 -16
  28. package/dist/src/commands/patch/move-files.js +2 -0
  29. package/dist/src/commands/patch/patch-context.d.ts +41 -0
  30. package/dist/src/commands/patch/patch-context.js +53 -0
  31. package/dist/src/commands/patch/rename.js +10 -15
  32. package/dist/src/commands/patch/reorder.d.ts +0 -2
  33. package/dist/src/commands/patch/reorder.js +18 -19
  34. package/dist/src/commands/patch/split-plan.d.ts +66 -0
  35. package/dist/src/commands/patch/split-plan.js +178 -0
  36. package/dist/src/commands/patch/split.d.ts +30 -0
  37. package/dist/src/commands/patch/split.js +283 -0
  38. package/dist/src/commands/patch/staged-dependency.d.ts +1 -7
  39. package/dist/src/commands/patch/staged-dependency.js +4 -17
  40. package/dist/src/commands/patch/tier.js +4 -17
  41. package/dist/src/commands/re-export-files.js +4 -1
  42. package/dist/src/commands/re-export-scan.js +8 -1
  43. package/dist/src/commands/re-export.js +8 -1
  44. package/dist/src/commands/rebase/summary.d.ts +1 -5
  45. package/dist/src/commands/rebase/summary.js +1 -1
  46. package/dist/src/commands/status-output.js +77 -68
  47. package/dist/src/commands/test-diagnose.d.ts +23 -0
  48. package/dist/src/commands/test-diagnose.js +210 -0
  49. package/dist/src/commands/test-run.d.ts +68 -0
  50. package/dist/src/commands/test-run.js +97 -0
  51. package/dist/src/commands/test.js +214 -263
  52. package/dist/src/commands/token.js +15 -1
  53. package/dist/src/commands/wire.js +109 -78
  54. package/dist/src/core/build-audit.d.ts +1 -1
  55. package/dist/src/core/build-audit.js +2 -46
  56. package/dist/src/core/build-baseline-types.d.ts +38 -0
  57. package/dist/src/core/build-baseline-types.js +10 -0
  58. package/dist/src/core/build-baseline.d.ts +1 -31
  59. package/dist/src/core/build-prepare.d.ts +1 -1
  60. package/dist/src/core/build-prepare.js +2 -45
  61. package/dist/src/core/config-paths.d.ts +0 -8
  62. package/dist/src/core/config-paths.js +4 -4
  63. package/dist/src/core/config-state.d.ts +0 -6
  64. package/dist/src/core/config-state.js +1 -1
  65. package/dist/src/core/config-validate-patch-policy.js +12 -13
  66. package/dist/src/core/config-validate.js +74 -28
  67. package/dist/src/core/engine-changes.d.ts +24 -0
  68. package/dist/src/core/engine-changes.js +64 -0
  69. package/dist/src/core/firefox-cache.d.ts +0 -5
  70. package/dist/src/core/firefox-cache.js +1 -1
  71. package/dist/src/core/firefox-download.d.ts +0 -6
  72. package/dist/src/core/firefox-download.js +1 -1
  73. package/dist/src/core/furnace-apply-helpers.d.ts +1 -8
  74. package/dist/src/core/furnace-apply-helpers.js +11 -20
  75. package/dist/src/core/furnace-apply.d.ts +1 -1
  76. package/dist/src/core/furnace-apply.js +1 -1
  77. package/dist/src/core/furnace-checksum-utils.d.ts +7 -0
  78. package/dist/src/core/furnace-checksum-utils.js +15 -0
  79. package/dist/src/core/furnace-config-validate.d.ts +31 -0
  80. package/dist/src/core/furnace-config-validate.js +133 -0
  81. package/dist/src/core/furnace-config.d.ts +4 -32
  82. package/dist/src/core/furnace-config.js +15 -111
  83. package/dist/src/core/furnace-constants.d.ts +0 -10
  84. package/dist/src/core/furnace-constants.js +2 -2
  85. package/dist/src/core/furnace-css-fragments.d.ts +79 -0
  86. package/dist/src/core/furnace-css-fragments.js +243 -0
  87. package/dist/src/core/furnace-jsconfig.d.ts +63 -0
  88. package/dist/src/core/furnace-jsconfig.js +191 -0
  89. package/dist/src/core/furnace-validate-helpers.d.ts +16 -14
  90. package/dist/src/core/furnace-validate-helpers.js +40 -1
  91. package/dist/src/core/furnace-validate-registration.js +16 -1
  92. package/dist/src/core/furnace-validate.js +54 -2
  93. package/dist/src/core/git-base.d.ts +15 -0
  94. package/dist/src/core/git-base.js +32 -0
  95. package/dist/src/core/git-diff.d.ts +8 -0
  96. package/dist/src/core/git-diff.js +224 -59
  97. package/dist/src/core/git-file-ops.d.ts +39 -12
  98. package/dist/src/core/git-file-ops.js +84 -3
  99. package/dist/src/core/lint-cache.d.ts +0 -13
  100. package/dist/src/core/lint-cache.js +5 -5
  101. package/dist/src/core/mach.d.ts +22 -1
  102. package/dist/src/core/mach.js +27 -2
  103. package/dist/src/core/manifest-register.d.ts +5 -16
  104. package/dist/src/core/manifest-register.js +3 -1
  105. package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
  106. package/dist/src/core/patch-lint-checkjs.js +263 -71
  107. package/dist/src/core/patch-lint-css.d.ts +23 -0
  108. package/dist/src/core/patch-lint-css.js +172 -0
  109. package/dist/src/core/patch-lint-jsdoc.js +63 -4
  110. package/dist/src/core/patch-lint-observer.d.ts +37 -0
  111. package/dist/src/core/patch-lint-observer.js +168 -0
  112. package/dist/src/core/patch-lint.d.ts +34 -11
  113. package/dist/src/core/patch-lint.js +24 -161
  114. package/dist/src/core/patch-manifest-io.d.ts +16 -0
  115. package/dist/src/core/patch-manifest-io.js +44 -2
  116. package/dist/src/core/patch-manifest-validate.d.ts +1 -8
  117. package/dist/src/core/patch-manifest-validate.js +1 -1
  118. package/dist/src/core/patch-manifest.d.ts +1 -1
  119. package/dist/src/core/patch-manifest.js +1 -1
  120. package/dist/src/core/patch-policy.d.ts +0 -4
  121. package/dist/src/core/patch-policy.js +10 -4
  122. package/dist/src/core/register-browser-content.d.ts +1 -1
  123. package/dist/src/core/register-module.d.ts +1 -1
  124. package/dist/src/core/register-result.d.ts +21 -0
  125. package/dist/src/core/register-result.js +9 -0
  126. package/dist/src/core/register-shared-css.d.ts +1 -1
  127. package/dist/src/core/register-test-manifest.d.ts +1 -1
  128. package/dist/src/core/test-harness-crash.d.ts +61 -0
  129. package/dist/src/core/test-harness-crash.js +140 -0
  130. package/dist/src/core/test-stale-check.d.ts +1 -1
  131. package/dist/src/core/test-stale-check.js +2 -46
  132. package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
  133. package/dist/src/core/test-xpcshell-retry.js +10 -3
  134. package/dist/src/core/token-dark-mode.js +14 -26
  135. package/dist/src/core/token-manager.d.ts +4 -0
  136. package/dist/src/core/token-manager.js +70 -16
  137. package/dist/src/core/typecheck-shim.d.ts +3 -22
  138. package/dist/src/core/typecheck-shim.js +69 -7
  139. package/dist/src/core/wire-utils.js +37 -44
  140. package/dist/src/types/commands/index.d.ts +1 -1
  141. package/dist/src/types/commands/options.d.ts +122 -0
  142. package/dist/src/types/config.d.ts +11 -2
  143. package/dist/src/types/furnace.d.ts +12 -1
  144. package/dist/src/utils/elapsed.d.ts +0 -2
  145. package/dist/src/utils/elapsed.js +1 -1
  146. package/dist/src/utils/fs.d.ts +0 -5
  147. package/dist/src/utils/fs.js +1 -1
  148. package/dist/src/utils/regex.d.ts +0 -6
  149. package/dist/src/utils/regex.js +3 -3
  150. package/dist/src/utils/validation.d.ts +0 -8
  151. package/dist/src/utils/validation.js +2 -2
  152. package/package.json +6 -4
@@ -200,6 +200,108 @@ function buildUntilFilenameSet(patches, until) {
200
200
  }
201
201
  return set;
202
202
  }
203
+ /**
204
+ * Runs the manifest consistency check, scoped to the `--until` subset:
205
+ * global (manifest-level) issues always block, per-patch issues only
206
+ * block when the patch is in scope. Throws GeneralError with the repair
207
+ * hint when anything in scope is broken.
208
+ */
209
+ async function assertScopedManifestConsistency(patchesDir, untilFilenameSet, until) {
210
+ const manifestConsistencyIssues = await validatePatchesManifestConsistency(patchesDir);
211
+ const scopedManifestIssues = until !== undefined
212
+ ? manifestConsistencyIssues.filter((issue) =>
213
+ // Global (manifest-level) issues have no specific filename to scope
214
+ // against — a missing or unparseable patches.json blocks any
215
+ // import. Per-patch issues only block when the patch is in scope.
216
+ issue.code === 'manifest-missing' ||
217
+ issue.code === 'manifest-invalid' ||
218
+ untilFilenameSet.has(issue.filename))
219
+ : manifestConsistencyIssues;
220
+ if (scopedManifestIssues.length > 0) {
221
+ const issueSummary = scopedManifestIssues.map((issue) => issue.message).join('\n ');
222
+ throw new GeneralError('Patch manifest consistency check failed. Repair patches/patches.json before importing.\n' +
223
+ ` ${issueSummary}\n\n` +
224
+ 'Run "fireforge doctor --repair-patches-manifest" to rebuild the manifest from on-disk patch files.');
225
+ }
226
+ }
227
+ /**
228
+ * Prints advisory version-compatibility warnings for every in-scope patch
229
+ * whose recorded source version differs meaningfully from the configured
230
+ * Firefox version. Advisory only — never blocks the import.
231
+ */
232
+ async function warnVersionCompatibility(projectRoot, manifest, untilFilenameSet, until) {
233
+ if (!manifest)
234
+ return;
235
+ const config = await loadConfig(projectRoot);
236
+ const currentVersion = config.firefox.version;
237
+ for (const patch of manifest.patches) {
238
+ // Scope the advisory warnings too: an operator running with --until
239
+ // doesn't need to see version warnings for patches outside the range.
240
+ if (until !== undefined && !untilFilenameSet.has(patch.filename))
241
+ continue;
242
+ const warning = checkVersionCompatibility(getPatchSourceVersion(patch), currentVersion);
243
+ if (warning) {
244
+ warn(`${patch.filename}: ${warning}`);
245
+ }
246
+ }
247
+ }
248
+ /**
249
+ * Patch-integrity gate: surfaces orphaned-modification issues scoped to
250
+ * the `--until` range and decides whether the import may proceed —
251
+ * `--force` continues with a warning, non-TTY refuses loudly, and an
252
+ * interactive operator is prompted. Returns false when the import should
253
+ * stop (the cancel outro has been printed).
254
+ */
255
+ async function gateImportIntegrity(paths, untilFilenameSet, until, forceImport) {
256
+ const allIntegrityIssues = await validatePatchIntegrity(paths.patches, paths.engine);
257
+ const integrityIssues = until !== undefined
258
+ ? allIntegrityIssues.filter((issue) => untilFilenameSet.has(issue.filename))
259
+ : allIntegrityIssues;
260
+ if (integrityIssues.length > 0) {
261
+ warn('\nPatch integrity issues detected:');
262
+ for (const issue of integrityIssues) {
263
+ warn(` ${issue.filename}: ${issue.message}`);
264
+ }
265
+ info('Run "fireforge doctor" for more details.');
266
+ if (forceImport) {
267
+ warn('Continuing because --force was provided. Integrity issues were not resolved.\n');
268
+ }
269
+ else if (!process.stdin.isTTY) {
270
+ throw new GeneralError(`Refusing to import while ${integrityIssues.length} patch integrity issue(s) are unresolved. ` +
271
+ `Fix the issues reported above (see "fireforge doctor") or re-run with --force to continue anyway.`);
272
+ }
273
+ else {
274
+ const shouldContinue = await confirm({
275
+ message: 'Patch integrity issues detected. Continuing may fail with cascading errors during patch application. Continue anyway?',
276
+ initialValue: false,
277
+ });
278
+ if (isCancel(shouldContinue) || !shouldContinue) {
279
+ outro('Import cancelled — fix the integrity issues and re-run');
280
+ return false;
281
+ }
282
+ }
283
+ }
284
+ return true;
285
+ }
286
+ /**
287
+ * Dry-run rendering: lists the in-scope patches (or the bare count when no
288
+ * manifest exists) and prints the dry-run outro.
289
+ */
290
+ function renderImportDryRun(manifest, untilFilenameSet, until, patchCount) {
291
+ if (manifest) {
292
+ const patches = until !== undefined
293
+ ? manifest.patches.filter((p) => untilFilenameSet.has(p.filename))
294
+ : manifest.patches;
295
+ info(`\n[dry-run] Would apply ${patches.length} patch(es) in order:`);
296
+ for (const patch of patches) {
297
+ info(` ${patch.filename} (${patch.filesAffected.length} file${patch.filesAffected.length === 1 ? '' : 's'})`);
298
+ }
299
+ }
300
+ else {
301
+ info(`\n[dry-run] Would apply ${patchCount} patch(es)`);
302
+ }
303
+ outro('Dry run complete — no changes made');
304
+ }
203
305
  /**
204
306
  * Runs the import command to apply patches.
205
307
  * @param projectRoot - Root directory of the project
@@ -245,37 +347,8 @@ export async function importCommand(projectRoot, options = {}) {
245
347
  const untilFilenameSet = buildUntilFilenameSet(manifest?.patches ?? [], options.until);
246
348
  const scopedPatchCount = options.until !== undefined ? untilFilenameSet.size : patchCount;
247
349
  info(`Found ${scopedPatchCount} patch${scopedPatchCount === 1 ? '' : 'es'} to apply${options.until !== undefined ? ` (up to ${options.until})` : ''}`);
248
- const manifestConsistencyIssues = await validatePatchesManifestConsistency(paths.patches);
249
- const scopedManifestIssues = options.until !== undefined
250
- ? manifestConsistencyIssues.filter((issue) =>
251
- // Global (manifest-level) issues have no specific filename to scope
252
- // against — a missing or unparseable patches.json blocks any
253
- // import. Per-patch issues only block when the patch is in scope.
254
- issue.code === 'manifest-missing' ||
255
- issue.code === 'manifest-invalid' ||
256
- untilFilenameSet.has(issue.filename))
257
- : manifestConsistencyIssues;
258
- if (scopedManifestIssues.length > 0) {
259
- const issueSummary = scopedManifestIssues.map((issue) => issue.message).join('\n ');
260
- throw new GeneralError('Patch manifest consistency check failed. Repair patches/patches.json before importing.\n' +
261
- ` ${issueSummary}\n\n` +
262
- 'Run "fireforge doctor --repair-patches-manifest" to rebuild the manifest from on-disk patch files.');
263
- }
264
- // Version compatibility warnings (advisory only)
265
- if (manifest) {
266
- const config = await loadConfig(projectRoot);
267
- const currentVersion = config.firefox.version;
268
- for (const patch of manifest.patches) {
269
- // Scope the advisory warnings too: an operator running with --until
270
- // doesn't need to see version warnings for patches outside the range.
271
- if (options.until !== undefined && !untilFilenameSet.has(patch.filename))
272
- continue;
273
- const warning = checkVersionCompatibility(getPatchSourceVersion(patch), currentVersion);
274
- if (warning) {
275
- warn(`${patch.filename}: ${warning}`);
276
- }
277
- }
278
- }
350
+ await assertScopedManifestConsistency(paths.patches, untilFilenameSet, options.until);
351
+ await warnVersionCompatibility(projectRoot, manifest, untilFilenameSet, options.until);
279
352
  // Validate patch integrity (detect orphaned modification patches). Warn
280
353
  // and prompt the operator to confirm before proceeding — the legacy
281
354
  // warn-and-continue behaviour hid the real root cause because import
@@ -286,49 +359,11 @@ export async function importCommand(projectRoot, options = {}) {
286
359
  // integrity problems should not block importing an earlier good subset,
287
360
  // which is exactly what operators reach for when the tail of the queue
288
361
  // is broken and they want to keep working against an earlier checkpoint.
289
- const allIntegrityIssues = await validatePatchIntegrity(paths.patches, paths.engine);
290
- const integrityIssues = options.until !== undefined
291
- ? allIntegrityIssues.filter((issue) => untilFilenameSet.has(issue.filename))
292
- : allIntegrityIssues;
293
- if (integrityIssues.length > 0) {
294
- warn('\nPatch integrity issues detected:');
295
- for (const issue of integrityIssues) {
296
- warn(` ${issue.filename}: ${issue.message}`);
297
- }
298
- info('Run "fireforge doctor" for more details.');
299
- if (forceImport) {
300
- warn('Continuing because --force was provided. Integrity issues were not resolved.\n');
301
- }
302
- else if (!process.stdin.isTTY) {
303
- throw new GeneralError(`Refusing to import while ${integrityIssues.length} patch integrity issue(s) are unresolved. ` +
304
- `Fix the issues reported above (see "fireforge doctor") or re-run with --force to continue anyway.`);
305
- }
306
- else {
307
- const shouldContinue = await confirm({
308
- message: 'Patch integrity issues detected. Continuing may fail with cascading errors during patch application. Continue anyway?',
309
- initialValue: false,
310
- });
311
- if (isCancel(shouldContinue) || !shouldContinue) {
312
- outro('Import cancelled — fix the integrity issues and re-run');
313
- return;
314
- }
315
- }
316
- }
317
- // Dry-run: list patches that would be applied and exit
362
+ const integrityOk = await gateImportIntegrity(paths, untilFilenameSet, options.until, forceImport);
363
+ if (!integrityOk)
364
+ return;
318
365
  if (isDryRun) {
319
- if (manifest) {
320
- const patches = options.until !== undefined
321
- ? manifest.patches.filter((p) => untilFilenameSet.has(p.filename))
322
- : manifest.patches;
323
- info(`\n[dry-run] Would apply ${patches.length} patch(es) in order:`);
324
- for (const patch of patches) {
325
- info(` ${patch.filename} (${patch.filesAffected.length} file${patch.filesAffected.length === 1 ? '' : 's'})`);
326
- }
327
- }
328
- else {
329
- info(`\n[dry-run] Would apply ${patchCount} patch(es)`);
330
- }
331
- outro('Dry run complete — no changes made');
366
+ renderImportDryRun(manifest, untilFilenameSet, options.until, patchCount);
332
367
  return;
333
368
  }
334
369
  await checkUncommittedPatchFiles(paths.engine, paths.patches, forceImport);
@@ -1,8 +1,10 @@
1
1
  import { getProjectPaths } from '../core/config.js';
2
- import type { LintCommandOptions } from './lint.js';
2
+ import type { LintCommandOptions } from '../types/commands/index.js';
3
3
  /**
4
4
  * Lints each patch in the queue as its own isolated diff, honouring
5
5
  * per-patch `lintIgnore` entries. Cross-patch rules still run once over
6
6
  * the whole queue so queue-level findings are not lost by the rescoping.
7
+ * With `options.patches` set, only the named subset is linted (and the
8
+ * queue-level findings are scoped to files those patches touch).
7
9
  */
8
10
  export declare function lintPerPatch(projectRoot: string, paths: ReturnType<typeof getProjectPaths>, options?: LintCommandOptions): Promise<void>;
@@ -4,6 +4,8 @@ import { 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
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';
7
9
  import { loadPatchesManifest } from '../core/patch-manifest.js';
8
10
  import { evaluatePatchPolicy } from '../core/patch-policy.js';
9
11
  import { GeneralError } from '../errors/base.js';
@@ -22,94 +24,150 @@ function emitTierNotice(filename, files, tier) {
22
24
  : `${filename}: branding threshold tier applied (all files under browser/branding/ plus registration siblings).`);
23
25
  }
24
26
  /**
25
- * Lints each patch in the queue as its own isolated diff, honouring
26
- * per-patch `lintIgnore` entries. Cross-patch rules still run once over
27
- * the whole queue so queue-level findings are not lost by the rescoping.
27
+ * Lints one queued patch against its own isolated diff, reusing the cache entry
28
+ * when the cache key matches. Returns the outcome and the patch's (unprefixed)
29
+ * issues without touching shared state the orchestrator applies the tier
30
+ * notice, issue prefixing, and cache write in patch order after the pool
31
+ * drains, so the bounded concurrency cannot reorder output. Returns `skipped`
32
+ * (no files present / empty diff), `cached`, or `linted`.
28
33
  */
29
- export async function lintPerPatch(projectRoot, paths, options = {}) {
30
- const manifest = await loadPatchesManifest(paths.patches);
31
- if (!manifest || manifest.patches.length === 0) {
32
- info('No patches in manifest — nothing to lint per-patch.');
33
- outro('Nothing to lint');
34
- return;
34
+ async function lintQueuedPatch(patch, lintCtx) {
35
+ 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);
35
40
  }
36
- const config = await loadConfig(projectRoot);
37
- const ctx = await buildPatchQueueContext(paths.patches);
38
- const cache = options.noCache === true ? undefined : await loadPerPatchLintCache(projectRoot);
39
- const engineHeadSha = cache ? await getPerPatchLintCacheHeadSha(paths.engine) : undefined;
40
- let cacheDirty = false;
41
- let reusedCacheEntries = 0;
42
- const issues = [];
43
- for (const issue of evaluatePatchPolicy(config, manifest)) {
44
- issues.push({
45
- file: issue.filename,
46
- check: `patch-policy/${issue.code}`,
47
- message: issue.message,
48
- severity: issue.severity,
41
+ if (existing.length === 0) {
42
+ return { status: 'skipped', existingFiles: [], rawIssues: [], usedCheckJs: false };
43
+ }
44
+ const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
45
+ let cacheKey;
46
+ if (cache) {
47
+ cacheKey = await buildPerPatchLintCacheKey({
48
+ projectRoot,
49
+ engineDir: paths.engine,
50
+ patchesDir: paths.patches,
51
+ patch,
52
+ existingFiles: existing,
53
+ config,
54
+ queueContext: ctx,
55
+ ...(engineHeadSha === undefined ? {} : { engineHeadSha }),
49
56
  });
57
+ const cached = getCachedPerPatchLintIssues(cache, patch.filename, cacheKey);
58
+ if (cached) {
59
+ return { status: 'cached', existingFiles: existing, rawIssues: cached, usedCheckJs: false };
60
+ }
61
+ }
62
+ const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
63
+ if (!diff.trim()) {
64
+ return { status: 'skipped', existingFiles: [], rawIssues: [], usedCheckJs: false };
50
65
  }
51
- let linted = 0;
52
- let skipped = 0;
53
- for (const patch of manifest.patches) {
54
- const existing = [];
55
- for (const f of patch.filesAffected) {
56
- if (await pathExists(join(paths.engine, f)))
57
- existing.push(f);
66
+ // checkJs: instead of rebuilding the program per patch, slice this patch's
67
+ // findings out of the one queue-wide program (built lazily on first miss).
68
+ let lintOptions;
69
+ let usedCheckJs = false;
70
+ if (lintCtx.checkJs) {
71
+ const grouped = await lintCtx.checkJs.getGrouped();
72
+ usedCheckJs = true;
73
+ const owned = lintCtx.checkJs.ownedByPatch.get(patch.filename);
74
+ const precomputedCheckJs = [];
75
+ if (owned) {
76
+ for (const rel of owned)
77
+ precomputedCheckJs.push(...(grouped.byFile.get(rel) ?? []));
58
78
  }
59
- if (existing.length === 0) {
60
- skipped++;
79
+ lintOptions = { precomputedCheckJs };
80
+ }
81
+ const patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore, patch.tier, lintOptions);
82
+ const result = {
83
+ status: 'linted',
84
+ existingFiles: existing,
85
+ rawIssues: patchIssues,
86
+ usedCheckJs,
87
+ };
88
+ if (cache && cacheKey) {
89
+ result.cacheWrite = { key: cacheKey, issues: patchIssues };
90
+ }
91
+ return result;
92
+ }
93
+ /**
94
+ * Applies the per-patch results in patch order so the bounded concurrency
95
+ * cannot reorder output: emits each tier notice, the once-only run-level
96
+ * checkJs errors (before the first freshly linted patch's issues), the
97
+ * filename-prefixed issue rows, and the cache writes — all in the same sequence
98
+ * a serial run produced. Returns the run tallies.
99
+ */
100
+ async function applyPerPatchResults(subset, results, issues, checkJs, cache) {
101
+ const totals = {
102
+ linted: 0,
103
+ skipped: 0,
104
+ cacheDirty: false,
105
+ reusedCacheEntries: 0,
106
+ };
107
+ let globalCheckJsEmitted = false;
108
+ for (let i = 0; i < subset.length; i++) {
109
+ const patch = subset[i];
110
+ const result = results[i];
111
+ if (!patch || !result)
61
112
  continue;
62
- }
63
- const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
64
- let patchIssues;
65
- let cacheKey;
66
- if (cache) {
67
- cacheKey = await buildPerPatchLintCacheKey({
68
- projectRoot,
69
- engineDir: paths.engine,
70
- patchesDir: paths.patches,
71
- patch,
72
- existingFiles: existing,
73
- config,
74
- queueContext: ctx,
75
- ...(engineHeadSha === undefined ? {} : { engineHeadSha }),
76
- });
77
- patchIssues = getCachedPerPatchLintIssues(cache, patch.filename, cacheKey);
78
- if (patchIssues) {
79
- reusedCacheEntries++;
80
- emitTierNotice(patch.filename, existing, patch.tier);
81
- for (const issue of patchIssues) {
82
- issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
83
- }
84
- linted++;
85
- continue;
86
- }
87
- }
88
- const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
89
- if (!diff.trim()) {
90
- skipped++;
113
+ if (result.status === 'skipped') {
114
+ totals.skipped++;
91
115
  continue;
92
116
  }
93
- emitTierNotice(patch.filename, existing, patch.tier);
94
- if (cache && cacheKey) {
95
- patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore, patch.tier);
96
- setCachedPerPatchLintIssues(cache, patch.filename, cacheKey, patchIssues);
97
- cacheDirty = true;
117
+ emitTierNotice(patch.filename, result.existingFiles, patch.tier);
118
+ // Run-level checkJs errors are emitted once, before the first freshly
119
+ // linted patch's own issues matching the serial emit point.
120
+ if (result.usedCheckJs && checkJs && !globalCheckJsEmitted) {
121
+ globalCheckJsEmitted = true;
122
+ issues.push(...(await checkJs.getGlobal()));
98
123
  }
99
- else {
100
- patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore, patch.tier);
124
+ if (result.cacheWrite && cache) {
125
+ setCachedPerPatchLintIssues(cache, patch.filename, result.cacheWrite.key, result.cacheWrite.issues);
126
+ totals.cacheDirty = true;
101
127
  }
102
- for (const issue of patchIssues) {
128
+ for (const issue of result.rawIssues) {
103
129
  issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
104
130
  }
105
- linted++;
131
+ if (result.status === 'cached')
132
+ totals.reusedCacheEntries++;
133
+ totals.linted++;
106
134
  }
107
- issues.push(...lintPatchQueue(ctx));
108
- if (cache && cacheDirty)
109
- await savePerPatchLintCache(projectRoot, cache);
110
- if (reusedCacheEntries > 0) {
111
- info(`Reused lint cache for ${reusedCacheEntries} patch${reusedCacheEntries === 1 ? '' : 'es'}.`);
135
+ return totals;
136
+ }
137
+ /**
138
+ * Maximum patches linted concurrently. After the per-file→batched git change,
139
+ * each patch is only a handful of git spawns, so a small pool overlaps their
140
+ * I/O without oversubscribing git on the shared repository.
141
+ */
142
+ const PER_PATCH_LINT_CONCURRENCY = 8;
143
+ /**
144
+ * Lints every patch in `subset` with bounded concurrency, returning results in
145
+ * patch order (each slot index matches `subset`). Mirrors the worker-pool idiom
146
+ * used by the rollback restore path. Side effects are deferred to the caller so
147
+ * issue ordering and cache writes stay deterministic.
148
+ */
149
+ async function lintSubsetConcurrently(subset, lintCtx) {
150
+ const results = new Array(subset.length);
151
+ let index = 0;
152
+ async function worker() {
153
+ while (index < subset.length) {
154
+ const current = index++;
155
+ const patch = subset[current];
156
+ if (!patch)
157
+ break;
158
+ results[current] = await lintQueuedPatch(patch, lintCtx);
159
+ }
112
160
  }
161
+ const workers = Array.from({ length: Math.min(PER_PATCH_LINT_CONCURRENCY, subset.length) }, () => worker());
162
+ await Promise.all(workers);
163
+ return results;
164
+ }
165
+ /**
166
+ * Reporting + exit phase of per-patch lint: renders every issue row,
167
+ * prints the per-patch summary, and applies the failure criteria
168
+ * (errors, `--max-warnings`) by throwing GeneralError.
169
+ */
170
+ function reportPerPatchOutcome(issues, linted, skipped, options) {
113
171
  if (issues.length === 0) {
114
172
  if (linted === 0 && skipped > 0) {
115
173
  info(`No patches in the queue have been applied to engine/. Run "fireforge import" first if you want lint findings against the staged hunks; otherwise this is expected.`);
@@ -149,4 +207,137 @@ export async function lintPerPatch(projectRoot, paths, options = {}) {
149
207
  outro('Lint passed');
150
208
  }
151
209
  }
210
+ /**
211
+ * Resolves the `--patches <name…>` subset filter against the manifest,
212
+ * matching each requested name tolerantly (exact filename, filename ±
213
+ * `.patch`, or the manifest `name` field). Throws listing the available
214
+ * patches when a requested name matches none, so a typo fails loud rather
215
+ * than silently linting nothing.
216
+ */
217
+ function selectPatchSubset(manifest, requested) {
218
+ const matches = (p, name) => p.filename === name ||
219
+ p.filename === `${name}.patch` ||
220
+ p.filename.replace(/\.patch$/, '') === name.replace(/\.patch$/, '') ||
221
+ p.name === name;
222
+ const selected = [];
223
+ const seen = new Set();
224
+ for (const name of requested) {
225
+ const found = manifest.patches.filter((p) => matches(p, name));
226
+ if (found.length === 0) {
227
+ const available = manifest.patches.map((p) => p.filename).join(', ');
228
+ throw new GeneralError(`--patches: no patch in the queue matches "${name}". Available patches: ${available}`);
229
+ }
230
+ for (const p of found) {
231
+ if (!seen.has(p.filename)) {
232
+ seen.add(p.filename);
233
+ selected.push(p);
234
+ }
235
+ }
236
+ }
237
+ return selected;
238
+ }
239
+ /**
240
+ * Builds the per-run checkJs program controller when `patchLint.checkJs` is
241
+ * enabled, or returns undefined. The program is built lazily on the first
242
+ * cache miss (so an all-warm run never pays for it) and reused for every
243
+ * subsequent patch in the run.
244
+ */
245
+ function buildPerRunCheckJs(projectRoot, paths, config, ctx) {
246
+ const patchLint = config.patchLint;
247
+ if (!patchLint?.checkJs)
248
+ return undefined;
249
+ const ownedByPatch = new Map();
250
+ for (const entry of ctx.entries) {
251
+ const owned = new Set();
252
+ for (const f of entry.newFiles.keys()) {
253
+ if (f.endsWith('.sys.mjs'))
254
+ owned.add(f);
255
+ }
256
+ if (owned.size > 0)
257
+ ownedByPatch.set(entry.filename, owned);
258
+ }
259
+ // Memoise the *promise*, not the resolved value: under the bounded pool
260
+ // several patches can reach `getGrouped` before the first build resolves, and
261
+ // `??=` on the promise (a synchronous expression) guarantees a single build.
262
+ let groupedPromise;
263
+ return {
264
+ ownedByPatch,
265
+ getGrouped: () => (groupedPromise ??= invokePatchLintCheckJsGrouped(paths.engine, resolvePatchOwnedSysMjs(new Set(), ctx), patchLint, projectRoot)),
266
+ getGlobal: async () => (groupedPromise ? (await groupedPromise).global : []),
267
+ };
268
+ }
269
+ /**
270
+ * Lints each patch in the queue as its own isolated diff, honouring
271
+ * per-patch `lintIgnore` entries. Cross-patch rules still run once over
272
+ * the whole queue so queue-level findings are not lost by the rescoping.
273
+ * With `options.patches` set, only the named subset is linted (and the
274
+ * queue-level findings are scoped to files those patches touch).
275
+ */
276
+ export async function lintPerPatch(projectRoot, paths, options = {}) {
277
+ const manifest = await loadPatchesManifest(paths.patches);
278
+ if (!manifest || manifest.patches.length === 0) {
279
+ info('No patches in manifest — nothing to lint per-patch.');
280
+ outro('Nothing to lint');
281
+ return;
282
+ }
283
+ const subset = options.patches && options.patches.length > 0
284
+ ? selectPatchSubset(manifest, options.patches)
285
+ : manifest.patches;
286
+ const subsetNames = new Set(subset.map((p) => p.filename));
287
+ const isSubset = subset.length !== manifest.patches.length;
288
+ const config = await loadConfig(projectRoot);
289
+ const ctx = await buildPatchQueueContext(paths.patches);
290
+ // Queue-level findings (policy, cross-patch) are scoped to the requested
291
+ // subset: a 5-patch slice should not fail on a policy or forward-import
292
+ // problem owned entirely by patches the operator did not target.
293
+ const subsetTouchedFiles = new Set();
294
+ if (isSubset) {
295
+ for (const entry of ctx.entries) {
296
+ if (!subsetNames.has(entry.filename))
297
+ continue;
298
+ for (const f of entry.newFiles.keys())
299
+ subsetTouchedFiles.add(f);
300
+ for (const f of entry.modifiedFileAdditions.keys())
301
+ subsetTouchedFiles.add(f);
302
+ }
303
+ }
304
+ const cache = options.noCache === true ? undefined : await loadPerPatchLintCache(projectRoot);
305
+ const engineHeadSha = cache ? await getPerPatchLintCacheHeadSha(paths.engine) : undefined;
306
+ const issues = [];
307
+ for (const issue of evaluatePatchPolicy(config, manifest)) {
308
+ if (isSubset && !subsetNames.has(issue.filename))
309
+ continue;
310
+ issues.push({
311
+ file: issue.filename,
312
+ check: `patch-policy/${issue.code}`,
313
+ message: issue.message,
314
+ severity: issue.severity,
315
+ });
316
+ }
317
+ const checkJs = buildPerRunCheckJs(projectRoot, paths, config, ctx);
318
+ // Lint patches concurrently, then apply every side effect in patch order so
319
+ // the issue rows, the run-level checkJs errors, and the saved cache are
320
+ // identical to a serial run.
321
+ const results = await lintSubsetConcurrently(subset, {
322
+ projectRoot,
323
+ paths,
324
+ config,
325
+ ctx,
326
+ cache,
327
+ engineHeadSha,
328
+ checkJs,
329
+ });
330
+ const { linted, skipped, cacheDirty, reusedCacheEntries } = await applyPerPatchResults(subset, results, issues, checkJs, cache);
331
+ for (const issue of lintPatchQueue(ctx)) {
332
+ if (isSubset && !subsetTouchedFiles.has(issue.file))
333
+ continue;
334
+ issues.push(issue);
335
+ }
336
+ if (cache && cacheDirty)
337
+ await savePerPatchLintCache(projectRoot, cache);
338
+ if (reusedCacheEntries > 0) {
339
+ info(`Reused lint cache for ${reusedCacheEntries} patch${reusedCacheEntries === 1 ? '' : 'es'}.`);
340
+ }
341
+ reportPerPatchOutcome(issues, linted, skipped, options);
342
+ }
152
343
  //# sourceMappingURL=lint-per-patch.js.map
@@ -1,63 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import type { CommandContext } from '../types/cli.js';
3
- import type { PatchLintIssue } from '../types/commands/index.js';
4
- /** Options controlling how the lint command filters and tags its output. */
5
- export interface LintCommandOptions {
6
- /**
7
- * When set, tag each issue as `introduced` or `cumulative` based on
8
- * whether its file changed since this git revision (e.g. `HEAD`, a
9
- * branch name, or a SHA). Issues are not filtered — the full set still
10
- * prints — but a diff-scoped summary makes it trivial to see which
11
- * errors the current task introduced.
12
- */
13
- since?: string;
14
- /**
15
- * When set together with {@link since}, scope the exit code to issues
16
- * tagged `introduced`. Cumulative pre-existing errors still print (so
17
- * the operator can still see the full queue state) but do not fail
18
- * lint. Motivating case: a branch whose diff is clean but whose repo
19
- * already carries unrelated `raw-color` / license-header errors from
20
- * older patches. Without this flag, CI treats the clean branch as
21
- * failing; with it, a branch "breaks the build" only when its own diff
22
- * introduced a new error.
23
- *
24
- * Requires {@link since}: without a revision to diff against there is
25
- * no distinction between introduced and cumulative, so the flag is
26
- * rejected up-front rather than silently ignored.
27
- */
28
- onlyIntroduced?: boolean;
29
- /**
30
- * Lint each patch in the queue as its own isolated diff, rather than
31
- * the aggregate `git diff HEAD` across all applied patches.
32
- *
33
- * Motivating case: running `fireforge lint` (no args) on a repo where
34
- * `fireforge import` or `fireforge rebase` has just applied the full
35
- * patch queue produces an aggregate diff (every patch's changes
36
- * summed). The patch-size advisory rules (`large-patch-lines`,
37
- * `large-patch-files`) then fire against the sum — e.g. "Patch is
38
- * 37529 lines" on a queue of 22 individually-fine patches — which
39
- * reads as a task-specific regression when it is really an artefact
40
- * of the aggregation. `--per-patch` rescopes the diff to each patch's
41
- * own `filesAffected`, honours the patch's own `lintIgnore`, and runs
42
- * the cross-patch rules once over the whole queue so queue-level
43
- * findings (duplicate creations, forward imports) still surface.
44
- *
45
- * Mutually exclusive with passing explicit file paths — the two
46
- * scope contracts are different.
47
- */
48
- perPatch?: boolean;
49
- /**
50
- * Maximum warning count tolerated before lint exits non-zero. Mirrors
51
- * ESLint's `--max-warnings` shape for release gates that want advisory
52
- * findings to become blocking without changing default CLI behavior.
53
- */
54
- maxWarnings?: number;
55
- /**
56
- * Bypass per-patch lint cache reads and writes. Accepted in aggregate mode
57
- * for CLI consistency, but only `--per-patch` currently uses the cache.
58
- */
59
- noCache?: boolean;
60
- }
3
+ import type { LintCommandOptions, PatchLintIssue } from '../types/commands/index.js';
61
4
  /**
62
5
  * Result of {@link applyAggregateLintIgnoreSuppression}.
63
6
  */