@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.
- package/CHANGELOG.md +11 -0
- package/dist/src/commands/export-all.js +4 -1
- package/dist/src/commands/export-shared.js +10 -1
- package/dist/src/commands/export.js +5 -1
- package/dist/src/commands/lint-per-patch.d.ts +2 -0
- package/dist/src/commands/lint-per-patch.js +206 -44
- package/dist/src/commands/lint.js +100 -7
- package/dist/src/commands/re-export-files.js +4 -1
- package/dist/src/commands/re-export.js +8 -1
- package/dist/src/commands/test-run.d.ts +10 -0
- package/dist/src/commands/test-run.js +13 -4
- package/dist/src/commands/test.js +46 -7
- package/dist/src/core/config-validate.js +26 -0
- package/dist/src/core/furnace-jsconfig.js +22 -2
- package/dist/src/core/git-base.d.ts +15 -0
- package/dist/src/core/git-base.js +32 -0
- package/dist/src/core/git-diff.d.ts +8 -0
- package/dist/src/core/git-diff.js +224 -59
- package/dist/src/core/git-file-ops.d.ts +39 -0
- package/dist/src/core/git-file-ops.js +82 -1
- package/dist/src/core/mach.d.ts +17 -0
- package/dist/src/core/mach.js +21 -0
- package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
- package/dist/src/core/patch-lint-checkjs.js +213 -67
- package/dist/src/core/patch-lint-css.d.ts +23 -0
- package/dist/src/core/patch-lint-css.js +172 -0
- package/dist/src/core/patch-lint.d.ts +34 -11
- package/dist/src/core/patch-lint.js +19 -163
- package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
- package/dist/src/core/test-xpcshell-retry.js +9 -4
- package/dist/src/core/typecheck-shim.d.ts +3 -1
- package/dist/src/core/typecheck-shim.js +43 -3
- package/dist/src/types/commands/options.d.ts +17 -0
- package/dist/src/types/config.d.ts +11 -2
- 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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
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',
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
wroteCache = true;
|
|
89
|
+
result.cacheWrite = { key: cacheKey, issues: patchIssues };
|
|
73
90
|
}
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
238
|
-
// an ambiguous set — is the file list an additional filter, or
|
|
239
|
-
// replace the per-patch scope? Reject up-front
|
|
240
|
-
//
|
|
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.
|
|
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
|
|
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
|
-
|
|
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). */
|