@hominis/fireforge 0.31.0 → 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 (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/src/commands/export-all.js +4 -1
  3. package/dist/src/commands/export-shared.js +10 -1
  4. package/dist/src/commands/export.js +5 -1
  5. package/dist/src/commands/lint-per-patch.d.ts +2 -0
  6. package/dist/src/commands/lint-per-patch.js +206 -44
  7. package/dist/src/commands/lint.js +100 -7
  8. package/dist/src/commands/re-export-files.js +4 -1
  9. package/dist/src/commands/re-export.js +8 -1
  10. package/dist/src/commands/test-run.d.ts +10 -0
  11. package/dist/src/commands/test-run.js +13 -4
  12. package/dist/src/commands/test.js +46 -7
  13. package/dist/src/core/config-validate.js +26 -0
  14. package/dist/src/core/furnace-jsconfig.js +22 -2
  15. package/dist/src/core/git-base.d.ts +15 -0
  16. package/dist/src/core/git-base.js +32 -0
  17. package/dist/src/core/git-diff.d.ts +8 -0
  18. package/dist/src/core/git-diff.js +224 -59
  19. package/dist/src/core/git-file-ops.d.ts +39 -0
  20. package/dist/src/core/git-file-ops.js +82 -1
  21. package/dist/src/core/mach.d.ts +17 -0
  22. package/dist/src/core/mach.js +21 -0
  23. package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
  24. package/dist/src/core/patch-lint-checkjs.js +213 -67
  25. package/dist/src/core/patch-lint-css.d.ts +23 -0
  26. package/dist/src/core/patch-lint-css.js +172 -0
  27. package/dist/src/core/patch-lint.d.ts +34 -11
  28. package/dist/src/core/patch-lint.js +19 -163
  29. package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
  30. package/dist/src/core/test-xpcshell-retry.js +9 -4
  31. package/dist/src/core/typecheck-shim.d.ts +3 -1
  32. package/dist/src/core/typecheck-shim.js +43 -3
  33. package/dist/src/types/commands/options.d.ts +17 -0
  34. package/dist/src/types/config.d.ts +11 -2
  35. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.32.0
4
+
5
+ - Fixed `fireforge lint <files>` to evaluate the `large-patch-files` rule against each file's resolved owning patch instead of the ad-hoc file-list cardinality, so a cross-patch selection no longer synthesizes a phantom oversized patch (e.g. eight files across four patches no longer report `Patch affects 8 files` when no single owner exceeds the threshold). The size rules now run per owning patch — using its real `filesAffected` count, diff, and `tier` — and unowned files are evaluated together as one prospective new patch.
6
+ - Taught the ad-hoc `lint <files>` path to honour each file's owning-patch `lintIgnore`, so a check waived via `fireforge patch lint-ignore` is suppressed consistently across `lint <files>`, `lint --per-patch`, and `re-export --dry-run` — the three invocation modes now agree on the same warning set for the same files.
7
+ - Added `fireforge lint --per-patch --patches <name…>` to lint a named subset of the queue (matched by filename or manifest `name`) instead of forcing a full queue run to verify a few touched patches; queue-level policy and cross-patch findings are scoped to files the subset touches. The "`--per-patch` cannot be combined with explicit file paths" error now points at the flag.
8
+ - Rebuilt the per-patch `checkJs` pass to construct the queue-wide TypeScript program **once per `lint --per-patch` run** (lazily, on first cache miss) and attribute each finding to its owning patch, instead of rebuilding the whole-queue program for every patch — a single queue-wide type regression now surfaces once against its owner rather than duplicated once per patch, and full runs no longer pay to recompile the same program ~N times.
9
+ - Resolved cross-patch `resource:///` / `chrome://` imports during `export`/`re-export` lint by threading the whole-queue ownership context into the isolated patch's `checkJs` pass (the export/re-export half of the cross-patch resolution that 0.31.0 landed for the full-queue path), while scoping reported diagnostics to the patch under export. Re-exporting a widget-runtime patch whose module imports another patch's `resource:///` module now type-checks against the real owning sources **without** a hand-generated ambient `declare module` stub shim. `patchLint.checkJsCompilerOptions` additionally accepts a reviewed `paths` mapping (host-resolved against the engine directory, so no `baseUrl` is needed — TS5090-safe), and `extraShim` / `typecheck.extraShim` now inline triple-slash `/// <reference>` directives instead of silently dropping them at the synthetic shim path.
10
+ - Fixed `furnace sync` to emit `./`-prefixed relative `compilerOptions.paths` values (e.g. `./components/custom/moz-widget/moz-widget.mjs`) so a synced jsconfig type-checks under TypeScript without `baseUrl` (no TS5090) and without `ignoreDeprecations` on TS6 (no TS5101); the sync reconciler now treats a leading `./` as insignificant, so neither a freshly-synced value nor a hand-written prefix churns as "stale" on the next run. Removes the downstream `baseUrl: "."` + `ignoreDeprecations: "6.0"` workaround.
11
+ - Made `fireforge test` auto-dispatch a single-suite run to the suite-specific mach command (`mach xpcshell-test` / `mach mochitest`), which degrade a broken macOS mozlog resource monitor to a warning instead of crashing generic `mach test` at startup — so a sharded single-suite run reaches its tests instead of burning the whole `--harness-retries` budget on a startup traceback. Mixed runs are still rejected and a path-less "run all" stays on `mach test`; `--generic-mach-test` forces the generic command.
12
+ - Extended the harness-crash classifier and `--harness-retries` budget to cover the pre-test `--build` step, so `fireforge test --build` retries a `mach build faster` that dies with the same resource-monitor startup crash instead of hard-failing with a bare "Pre-test build failed". Non-crash build failures are not retried.
13
+
3
14
  ## 0.31.0
4
15
 
5
16
  - Made `patch compact` range-aware: with `patchPolicy.ranges` configured, each category range compacts independently (anchored at its first occupied ordinal, treating reserved orders as non-gaps), so a mid-range gap under `allowGaps: false` can finally be closed without projecting patches across category boundaries. Reserved-range patches and out-of-range strays are left in place with a warning. Without ranges the historical whole-queue renumber from 1 is unchanged.
@@ -248,7 +248,10 @@ export async function exportAllCommand(projectRoot, options = {}) {
248
248
  try {
249
249
  // Extract affected files from diff
250
250
  const filesAffected = extractAffectedFiles(diff);
251
- await runPatchLint(paths.engine, filesAffected, diff, config, options.skipLint);
251
+ const patchQueueCtx = (await pathExists(paths.patches))
252
+ ? await buildPatchQueueContext(paths.patches)
253
+ : undefined;
254
+ await runPatchLint(paths.engine, filesAffected, diff, config, options.skipLint, patchQueueCtx);
252
255
  // Dry-run: enumerate filename, metadata, and supersede coverage without
253
256
  // writing. Mirrors `fireforge export --dry-run` so the same preview
254
257
  // surface is available for both targeted and aggregate exports. Runs
@@ -4,6 +4,7 @@ import { confirm, select, text } from '@clack/prompts';
4
4
  import { addLicenseHeaderToFile, getLicenseHeader } from '../core/license-headers.js';
5
5
  import { findAllPatchesForFiles } from '../core/patch-export.js';
6
6
  import { commentStyleForFile, detectNewFilesInDiff, lintExportedPatch, resolvePatchSizeTier, } from '../core/patch-lint.js';
7
+ import { resolvePatchOwnedSysMjs } from '../core/patch-lint-ownership.js';
7
8
  import { loadPatchesManifest } from '../core/patch-manifest.js';
8
9
  import { getPatchPolicyCategories, isCategoryAllowedByConfig } from '../core/patch-policy.js';
9
10
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
@@ -45,7 +46,15 @@ export async function runPatchLint(engineDir, filesAffected, diffContent, config
45
46
  ? 'Lint: branding threshold tier applied via patches.json `tier: "branding"` opt-in.'
46
47
  : 'Lint: branding threshold tier applied (patch is all under browser/branding/ plus registration siblings).');
47
48
  }
48
- const issues = await lintExportedPatch(engineDir, filesAffected, diffContent, config, patchQueueCtx, ignoreChecks, patchTier);
49
+ // When a whole-queue context is supplied, checkJs resolves cross-patch
50
+ // `resource:///`/`chrome://` imports against every patch-owned module, but
51
+ // only this patch's own new modules should report diagnostics. Scope the
52
+ // report to the files this diff creates so re-exporting one patch does not
53
+ // surface another patch's findings (and no ambient stub shim is needed).
54
+ const checkJsReportScope = patchQueueCtx
55
+ ? resolvePatchOwnedSysMjs(detectNewFilesInDiff(diffContent))
56
+ : undefined;
57
+ const issues = await lintExportedPatch(engineDir, filesAffected, diffContent, config, patchQueueCtx, ignoreChecks, patchTier, checkJsReportScope ? { checkJsReportScope } : undefined);
49
58
  if (issues.length === 0)
50
59
  return;
51
60
  const errors = issues.filter((i) => i.severity === 'error');
@@ -11,6 +11,7 @@ import { isBinaryFile } from '../core/git-file-ops.js';
11
11
  import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
12
12
  import { extractAffectedFiles } from '../core/patch-apply.js';
13
13
  import { commitExportedPatch } from '../core/patch-export.js';
14
+ import { buildPatchQueueContext } from '../core/patch-lint.js';
14
15
  import { buildPatchSourceMetadata } from '../core/patch-source-metadata.js';
15
16
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
16
17
  import { toError } from '../utils/errors.js';
@@ -192,7 +193,10 @@ export async function exportCommand(projectRoot, files, options) {
192
193
  const exportIgnoreChecks = options.lintIgnore && options.lintIgnore.length > 0
193
194
  ? new Set(options.lintIgnore)
194
195
  : undefined;
195
- await runPatchLint(paths.engine, filesAffected, diff, config, options.skipLint, undefined, exportIgnoreChecks, options.tier);
196
+ const patchQueueCtx = (await pathExists(paths.patches))
197
+ ? await buildPatchQueueContext(paths.patches)
198
+ : undefined;
199
+ await runPatchLint(paths.engine, filesAffected, diff, config, options.skipLint, patchQueueCtx, exportIgnoreChecks, options.tier);
196
200
  // Resolve placement (if any flag was given). Placement is mutually
197
201
  // exclusive with supersede — the semantics overlap confusingly.
198
202
  let placementPlan = null;
@@ -4,5 +4,7 @@ import type { LintCommandOptions } from '../types/commands/index.js';
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,21 +24,22 @@ 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 one queued patch against its own isolated diff, reusing the cache
26
- * entry when the cache key matches. Pushes the patch's issues (prefixed
27
- * with its filename) onto `ctx.issues`. Returns whether the patch was
28
- * skipped (no files present / empty diff), served from cache, or linted
29
- * fresh and whether a fresh result was written to the cache.
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`.
30
33
  */
31
34
  async function lintQueuedPatch(patch, lintCtx) {
32
- const { projectRoot, paths, config, ctx, cache, engineHeadSha, issues } = lintCtx;
35
+ const { projectRoot, paths, config, ctx, cache, engineHeadSha } = lintCtx;
33
36
  const existing = [];
34
37
  for (const f of patch.filesAffected) {
35
38
  if (await pathExists(join(paths.engine, f)))
36
39
  existing.push(f);
37
40
  }
38
41
  if (existing.length === 0) {
39
- return { status: 'skipped', wroteCache: false };
42
+ return { status: 'skipped', existingFiles: [], rawIssues: [], usedCheckJs: false };
40
43
  }
41
44
  const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
42
45
  let cacheKey;
@@ -53,28 +56,111 @@ async function lintQueuedPatch(patch, lintCtx) {
53
56
  });
54
57
  const cached = getCachedPerPatchLintIssues(cache, patch.filename, cacheKey);
55
58
  if (cached) {
56
- emitTierNotice(patch.filename, existing, patch.tier);
57
- for (const issue of cached) {
58
- issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
59
- }
60
- return { status: 'cached', wroteCache: false };
59
+ return { status: 'cached', existingFiles: existing, rawIssues: cached, usedCheckJs: false };
61
60
  }
62
61
  }
63
62
  const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
64
63
  if (!diff.trim()) {
65
- return { status: 'skipped', wroteCache: false };
64
+ return { status: 'skipped', existingFiles: [], rawIssues: [], usedCheckJs: false };
65
+ }
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) ?? []));
78
+ }
79
+ lintOptions = { precomputedCheckJs };
66
80
  }
67
- emitTierNotice(patch.filename, existing, patch.tier);
68
- const patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore, patch.tier);
69
- let wroteCache = false;
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
+ };
70
88
  if (cache && cacheKey) {
71
- setCachedPerPatchLintIssues(cache, patch.filename, cacheKey, patchIssues);
72
- wroteCache = true;
89
+ result.cacheWrite = { key: cacheKey, issues: patchIssues };
73
90
  }
74
- for (const issue of patchIssues) {
75
- issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
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)
112
+ continue;
113
+ if (result.status === 'skipped') {
114
+ totals.skipped++;
115
+ continue;
116
+ }
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()));
123
+ }
124
+ if (result.cacheWrite && cache) {
125
+ setCachedPerPatchLintIssues(cache, patch.filename, result.cacheWrite.key, result.cacheWrite.issues);
126
+ totals.cacheDirty = true;
127
+ }
128
+ for (const issue of result.rawIssues) {
129
+ issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
130
+ }
131
+ if (result.status === 'cached')
132
+ totals.reusedCacheEntries++;
133
+ totals.linted++;
134
+ }
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
+ }
76
160
  }
77
- return { status: 'linted', wroteCache };
161
+ const workers = Array.from({ length: Math.min(PER_PATCH_LINT_CONCURRENCY, subset.length) }, () => worker());
162
+ await Promise.all(workers);
163
+ return results;
78
164
  }
79
165
  /**
80
166
  * Reporting + exit phase of per-patch lint: renders every issue row,
@@ -121,10 +207,71 @@ function reportPerPatchOutcome(issues, linted, skipped, options) {
121
207
  outro('Lint passed');
122
208
  }
123
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
+ }
124
269
  /**
125
270
  * Lints each patch in the queue as its own isolated diff, honouring
126
271
  * per-patch `lintIgnore` entries. Cross-patch rules still run once over
127
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).
128
275
  */
129
276
  export async function lintPerPatch(projectRoot, paths, options = {}) {
130
277
  const manifest = await loadPatchesManifest(paths.patches);
@@ -133,12 +280,33 @@ export async function lintPerPatch(projectRoot, paths, options = {}) {
133
280
  outro('Nothing to lint');
134
281
  return;
135
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;
136
288
  const config = await loadConfig(projectRoot);
137
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
+ }
138
304
  const cache = options.noCache === true ? undefined : await loadPerPatchLintCache(projectRoot);
139
305
  const engineHeadSha = cache ? await getPerPatchLintCacheHeadSha(paths.engine) : undefined;
140
306
  const issues = [];
141
307
  for (const issue of evaluatePatchPolicy(config, manifest)) {
308
+ if (isSubset && !subsetNames.has(issue.filename))
309
+ continue;
142
310
  issues.push({
143
311
  file: issue.filename,
144
312
  check: `patch-policy/${issue.code}`,
@@ -146,31 +314,25 @@ export async function lintPerPatch(projectRoot, paths, options = {}) {
146
314
  severity: issue.severity,
147
315
  });
148
316
  }
149
- let linted = 0;
150
- let skipped = 0;
151
- let cacheDirty = false;
152
- let reusedCacheEntries = 0;
153
- for (const patch of manifest.patches) {
154
- const result = await lintQueuedPatch(patch, {
155
- projectRoot,
156
- paths,
157
- config,
158
- ctx,
159
- cache,
160
- engineHeadSha,
161
- issues,
162
- });
163
- if (result.status === 'skipped') {
164
- skipped++;
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))
165
333
  continue;
166
- }
167
- if (result.status === 'cached')
168
- reusedCacheEntries++;
169
- if (result.wroteCache)
170
- cacheDirty = true;
171
- linted++;
334
+ issues.push(issue);
172
335
  }
173
- issues.push(...lintPatchQueue(ctx));
174
336
  if (cache && cacheDirty)
175
337
  await savePerPatchLintCache(projectRoot, cache);
176
338
  if (reusedCacheEntries > 0) {
@@ -9,7 +9,7 @@ import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
9
9
  import { expandUntrackedDirectoryEntries, getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, getWorkingTreeStatus, } from '../core/git-status.js';
10
10
  import { clearPerPatchLintCache } from '../core/lint-cache.js';
11
11
  import { extractAffectedFiles } from '../core/patch-apply.js';
12
- import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue } from '../core/patch-lint.js';
12
+ import { buildPatchQueueContext, countNonBinaryDiffLines, lintExportedPatch, lintPatchQueue, lintPatchSize, } from '../core/patch-lint.js';
13
13
  import { collectDiffFilePaths, tagLintIssues } from '../core/patch-lint-diff-tag.js';
14
14
  import { GeneralError } from '../errors/base.js';
15
15
  import { pathExists } from '../utils/fs.js';
@@ -234,12 +234,20 @@ function validateLintFlags(options, files) {
234
234
  throw new GeneralError('--max-warnings must be a non-negative integer.');
235
235
  }
236
236
  // `--per-patch` rescopes the diff from "aggregate engine state" to "each
237
- // patch's own filesAffected". Mixing in explicit file paths would produce
238
- // an ambiguous set — is the file list an additional filter, or does it
239
- // replace the per-patch scope? Reject up-front so the operator gets a
240
- // clear error rather than a silently-narrowed result.
237
+ // patch's own filesAffected". Mixing in explicit engine file paths would
238
+ // produce an ambiguous set — is the file list an additional filter, or
239
+ // does it replace the per-patch scope? Reject up-front, but point at the
240
+ // first-class subset filter so an operator who wanted to target patches
241
+ // (not engine files) knows the supported syntax.
241
242
  if (options.perPatch && files.length > 0) {
242
- throw new GeneralError('--per-patch cannot be combined with explicit file paths. Pass either --per-patch or a file list, not both.');
243
+ throw new GeneralError('--per-patch cannot be combined with explicit engine file paths. ' +
244
+ 'To lint a subset of patches, use `--per-patch --patches <name…>`; ' +
245
+ 'to lint specific engine files, drop --per-patch.');
246
+ }
247
+ // `--patches` only means something in per-patch mode (it filters the
248
+ // queue); in aggregate/file-list mode there is no patch loop to narrow.
249
+ if (options.patches !== undefined && !options.perPatch) {
250
+ throw new GeneralError('--patches requires --per-patch.');
243
251
  }
244
252
  }
245
253
  /**
@@ -263,6 +271,79 @@ function downgradeAggregateSizeRules(issues, files, ctx) {
263
271
  }
264
272
  }
265
273
  }
274
+ /**
275
+ * Evaluates the patch-size rules (`large-patch-files` / `large-patch-lines`)
276
+ * for an ad-hoc explicit-file-list lint, scoped to each file's **owning
277
+ * patch** rather than the combined file list.
278
+ *
279
+ * The default file-list path used to feed every passed file to
280
+ * `lintExportedPatch` as one synthetic patch, so a cross-patch selection of
281
+ * eight files belonging to four patches reported `Patch affects 8 files`
282
+ * even though no single owning patch was oversized. This helper instead
283
+ * groups the affected files by their owning patch (via the manifest's
284
+ * `filesAffected`), then runs `lintPatchSize` against each owner's real file
285
+ * count + diff, honouring that owner's `tier` and `lintIgnore` — so
286
+ * `lint <files>`, `lint --per-patch`, and `re-export --dry-run` agree on the
287
+ * same size findings for the same files. Files no patch claims are evaluated
288
+ * together as one prospective new patch, preserving the pre-export
289
+ * oversized-change warning.
290
+ *
291
+ * @param engineDir - Absolute engine directory
292
+ * @param filesAffected - Engine-relative files touched by the ad-hoc diff
293
+ * @param ctx - Patch queue context used to attribute file → owning patch
294
+ * @returns Size issues, each attributed to its owning patch by message prefix
295
+ */
296
+ async function lintOwningPatchSizes(engineDir, filesAffected, ctx) {
297
+ const listed = new Set(filesAffected);
298
+ const owners = new Map();
299
+ const ownedListed = new Set();
300
+ for (const entry of ctx.entries) {
301
+ const md = entry.metadata;
302
+ if (!md)
303
+ continue;
304
+ let ownsAny = false;
305
+ for (const f of md.filesAffected) {
306
+ if (listed.has(f)) {
307
+ ownedListed.add(f);
308
+ ownsAny = true;
309
+ }
310
+ }
311
+ if (ownsAny)
312
+ owners.set(entry.filename, entry);
313
+ }
314
+ const issues = [];
315
+ const lineCountForFiles = async (relPaths) => {
316
+ const existing = [];
317
+ for (const f of relPaths) {
318
+ if (await pathExists(join(engineDir, f)))
319
+ existing.push(f);
320
+ }
321
+ if (existing.length === 0)
322
+ return 0;
323
+ const diff = await getDiffForFilesAgainstHead(engineDir, existing);
324
+ return countNonBinaryDiffLines(diff).textLines;
325
+ };
326
+ for (const entry of owners.values()) {
327
+ const md = entry.metadata;
328
+ if (!md)
329
+ continue;
330
+ const lineCount = await lineCountForFiles(md.filesAffected);
331
+ const ignore = md.lintIgnore?.length ? new Set(md.lintIgnore) : undefined;
332
+ for (const issue of lintPatchSize(md.filesAffected, lineCount, md.tier)) {
333
+ if (ignore?.has(issue.check))
334
+ continue;
335
+ issues.push({ ...issue, message: `${entry.filename}: ${issue.message}` });
336
+ }
337
+ }
338
+ // Files no patch claims are a prospective new patch: evaluate them as one
339
+ // unit so a genuinely oversized fresh change still surfaces.
340
+ const unowned = filesAffected.filter((f) => !ownedListed.has(f));
341
+ if (unowned.length > 0) {
342
+ const lineCount = await lineCountForFiles(unowned);
343
+ issues.push(...lintPatchSize(unowned, lineCount));
344
+ }
345
+ return issues;
346
+ }
266
347
  /**
267
348
  * Reporting + exit phase of `lintCommand`: tags issues against `--since`,
268
349
  * renders every notice/warning/error row, prints the summary, and applies
@@ -381,9 +462,17 @@ export async function lintCommand(projectRoot, files, options = {}) {
381
462
  if (await pathExists(paths.patches)) {
382
463
  ctx = await buildPatchQueueContext(paths.patches);
383
464
  }
465
+ // Ad-hoc explicit-file-list mode evaluates the patch-size rules per
466
+ // owning patch (see `lintOwningPatchSizes`), so suppress the synthetic
467
+ // combined-list size check in the shared pass — otherwise a cross-patch
468
+ // selection synthesises a phantom oversized patch from the file count.
469
+ const fileListMode = files.length > 0 && ctx !== undefined;
384
470
  let issues = [
385
- ...(await lintExportedPatch(paths.engine, filesAffected, diff, config, ctx)),
471
+ ...(await lintExportedPatch(paths.engine, filesAffected, diff, config, ctx, undefined, undefined, fileListMode ? { skipPatchSize: true } : undefined)),
386
472
  ];
473
+ if (files.length > 0 && ctx) {
474
+ issues.push(...(await lintOwningPatchSizes(paths.engine, filesAffected, ctx)));
475
+ }
387
476
  // Cross-patch rules operate over the whole queue, so run them whenever a
388
477
  // patches directory exists — they surface duplicate /dev/null creations
389
478
  // and forward-import chains that the per-patch orchestrator cannot see.
@@ -421,6 +510,7 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
421
510
  .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)')
422
511
  .option('--only-introduced', 'Fail only on issues tagged [introduced] (requires --since). Cumulative errors still print but do not set a non-zero exit.')
423
512
  .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.")
513
+ .option('--patches <names...>', 'With --per-patch, lint only the named patches (by filename or manifest name) instead of the whole queue. Queue-level findings are scoped to files those patches touch.')
424
514
  .option('--max-warnings <n>', 'Fail when lint reports more than <n> warning(s); use 0 for warning-clean release gates.')
425
515
  .option('--no-cache', 'Bypass per-patch lint result cache reads and writes.')
426
516
  .action(withErrorHandling(async (paths, options) => {
@@ -434,6 +524,9 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
434
524
  if (options.perPatch !== undefined) {
435
525
  lintOptions.perPatch = options.perPatch;
436
526
  }
527
+ if (options.patches !== undefined) {
528
+ lintOptions.patches = options.patches;
529
+ }
437
530
  if (options.maxWarnings !== undefined) {
438
531
  const maxWarnings = Number(options.maxWarnings);
439
532
  if (!Number.isInteger(maxWarnings) || maxWarnings < 0) {
@@ -198,7 +198,10 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
198
198
  // standard re-export path).
199
199
  const { effectiveTier, effectiveLintIgnore, flagIgnoreSet } = resolveEffectiveTierAndLintIgnore(target, options);
200
200
  const ignoreChecks = effectiveLintIgnore ? new Set(effectiveLintIgnore) : undefined;
201
- await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, undefined, ignoreChecks, effectiveTier);
201
+ const patchQueueCtx = (await pathExists(paths.patches))
202
+ ? await buildPatchQueueContext(paths.patches)
203
+ : undefined;
204
+ await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, patchQueueCtx, ignoreChecks, effectiveTier);
202
205
  const conflicts = await runProjectedCrossPatchLint(paths.patches, target.filename, projectedDiff);
203
206
  const filesUpdates = buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveLintIgnore, flagIgnoreSet);
204
207
  const manifest = await loadPatchesManifest(paths.patches);
@@ -7,6 +7,7 @@ import { isGitRepository } from '../core/git.js';
7
7
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
8
8
  import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
9
9
  import { updatePatchAndMetadata } from '../core/patch-export.js';
10
+ import { buildPatchQueueContext } from '../core/patch-lint.js';
10
11
  import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
11
12
  import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
12
13
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
@@ -185,7 +186,13 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
185
186
  command: 're-export',
186
187
  forceUnsafe: options.forceUnsafe === true,
187
188
  });
188
- await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, undefined, ignoreChecks, effectiveTier);
189
+ // Pass the whole-queue context so checkJs resolves cross-patch
190
+ // `resource:///` imports against the real owning sources (report scope
191
+ // stays this patch — see runPatchLint).
192
+ const patchQueueCtx = (await pathExists(paths.patches))
193
+ ? await buildPatchQueueContext(paths.patches)
194
+ : undefined;
195
+ await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, patchQueueCtx, ignoreChecks, effectiveTier);
189
196
  if (isDryRun) {
190
197
  info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
191
198
  if (effectiveTier !== undefined && effectiveTier !== patch.tier) {
@@ -14,6 +14,14 @@ import { type MachCommandResult } from '../core/mach.js';
14
14
  import { type HarnessRunVerdict } from '../core/test-harness-crash.js';
15
15
  /** Default bounded retry budget for recognized harness crashes. */
16
16
  export declare const DEFAULT_HARNESS_RETRIES = 2;
17
+ /**
18
+ * Which mach command a run dispatches to. Single-suite runs use the
19
+ * suite-specific command (`mach xpcshell-test` / `mach mochitest`), which
20
+ * skips the mozlog resource monitor that crashes generic `mach test` on a
21
+ * broken host (field report E1). `generic` is the historical `mach test`
22
+ * path (mixed/all-tests runs, or the `--generic-mach-test` opt-out).
23
+ */
24
+ export type TestSuite = 'xpcshell' | 'mochitest' | 'generic';
17
25
  /** Inputs shared by every harness invocation in one `fireforge test` run. */
18
26
  export interface TestRunContext {
19
27
  engineDir: string;
@@ -22,6 +30,8 @@ export interface TestRunContext {
22
30
  xpcshell: string[];
23
31
  nonXpcshell: string[];
24
32
  };
33
+ /** Suite-specific dispatch target for this run (E1). */
34
+ suite: TestSuite;
25
35
  /** Extra mach args before per-shard appdir injection. */
26
36
  baseExtraArgs: readonly string[];
27
37
  /** Bounded harness-crash retry budget (0 disables retries). */