@hominis/fireforge 0.28.5 → 0.30.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 +8 -0
- package/README.md +6 -0
- package/dist/src/commands/lint-per-patch.d.ts +8 -0
- package/dist/src/commands/lint-per-patch.js +140 -0
- package/dist/src/commands/lint.d.ts +5 -0
- package/dist/src/commands/lint.js +19 -116
- package/dist/src/commands/test.js +38 -34
- package/dist/src/core/lint-cache.d.ts +52 -0
- package/dist/src/core/lint-cache.js +171 -0
- package/dist/src/core/test-harness-output.d.ts +13 -0
- package/dist/src/core/test-harness-output.js +39 -0
- package/dist/src/core/test-xpcshell-retry.d.ts +7 -0
- package/dist/src/core/test-xpcshell-retry.js +16 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.30.0
|
|
4
|
+
|
|
5
|
+
- Added safe repo-local per-patch lint result caching for `lint --per-patch`, plus `--no-cache` and `lint cache clear` escape hatches while preserving release-gate severity accounting and queue-wide checks.
|
|
6
|
+
|
|
7
|
+
## 0.29.0
|
|
8
|
+
|
|
9
|
+
- Improved `fireforge test --build` failure reporting so post-rebuild focused test failures name the rebuild command, requested paths, and first failure line separately from stale-artifact rebuild advice.
|
|
10
|
+
|
|
3
11
|
## 0.28.0
|
|
4
12
|
|
|
5
13
|
- Restored mach lint compatibility for FireForge-managed Git-backed Firefox checkouts by materializing a `.hgignore` copy of `.gitignore` when Firefox's ignorefile linter config is present.
|
package/README.md
CHANGED
|
@@ -57,6 +57,7 @@ npx fireforge export browser/base/content/browser.js --name custom-toolbar --cat
|
|
|
57
57
|
npx fireforge re-export custom-toolbar
|
|
58
58
|
npx fireforge re-export --scan --scan-files generated-assets.json --dry-run
|
|
59
59
|
npx fireforge lint --per-patch
|
|
60
|
+
npx fireforge lint --per-patch --max-warnings 0
|
|
60
61
|
npx fireforge verify
|
|
61
62
|
npm run whitespace:check
|
|
62
63
|
npx fireforge build
|
|
@@ -65,6 +66,11 @@ npx fireforge test browser/base/content/test/browser/
|
|
|
65
66
|
|
|
66
67
|
Use `fireforge --help` for the full set of commands.
|
|
67
68
|
|
|
69
|
+
`lint --per-patch` reuses safe repo-local results for unchanged patches from
|
|
70
|
+
`.fireforge/lint-cache/`, while still running queue-wide checks every time. Use
|
|
71
|
+
`npx fireforge lint --per-patch --no-cache` to force a fresh run, or
|
|
72
|
+
`npx fireforge lint cache clear` to drop cached per-patch lint results.
|
|
73
|
+
|
|
68
74
|
For large generated asset batches, `re-export --scan --scan-files <manifest>` assigns files to
|
|
69
75
|
their owner patches without broad directory scanning. The manifest is JSON:
|
|
70
76
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { getProjectPaths } from '../core/config.js';
|
|
2
|
+
import type { LintCommandOptions } from './lint.js';
|
|
3
|
+
/**
|
|
4
|
+
* Lints each patch in the queue as its own isolated diff, honouring
|
|
5
|
+
* per-patch `lintIgnore` entries. Cross-patch rules still run once over
|
|
6
|
+
* the whole queue so queue-level findings are not lost by the rescoping.
|
|
7
|
+
*/
|
|
8
|
+
export declare function lintPerPatch(projectRoot: string, paths: ReturnType<typeof getProjectPaths>, options?: LintCommandOptions): Promise<void>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { loadConfig } from '../core/config.js';
|
|
4
|
+
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
5
|
+
import { buildPerPatchLintCacheKey, getCachedPerPatchLintIssues, loadPerPatchLintCache, savePerPatchLintCache, setCachedPerPatchLintIssues, } from '../core/lint-cache.js';
|
|
6
|
+
import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue, resolvePatchSizeTier, } from '../core/patch-lint.js';
|
|
7
|
+
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
8
|
+
import { evaluatePatchPolicy } from '../core/patch-policy.js';
|
|
9
|
+
import { GeneralError } from '../errors/base.js';
|
|
10
|
+
import { pathExists } from '../utils/fs.js';
|
|
11
|
+
import { info, outro, success, warn } from '../utils/logger.js';
|
|
12
|
+
function buildPerPatchMaxWarningsMessage(count, maxWarnings, linted) {
|
|
13
|
+
return (`Patch lint found ${count} warning(s) across ${linted} patch(es), exceeding --max-warnings ${maxWarnings}.` +
|
|
14
|
+
' If this is a release gate, run with --per-patch to identify the owning patch. For intentional staged imports, use patch staged-dependency; for ownership repairs, preview patch move-files, patch reorder --dry-run, or re-export --files --dry-run; add scoped lintIgnore only after review.');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Lints each patch in the queue as its own isolated diff, honouring
|
|
18
|
+
* per-patch `lintIgnore` entries. Cross-patch rules still run once over
|
|
19
|
+
* the whole queue so queue-level findings are not lost by the rescoping.
|
|
20
|
+
*/
|
|
21
|
+
export async function lintPerPatch(projectRoot, paths, options = {}) {
|
|
22
|
+
const manifest = await loadPatchesManifest(paths.patches);
|
|
23
|
+
if (!manifest || manifest.patches.length === 0) {
|
|
24
|
+
info('No patches in manifest — nothing to lint per-patch.');
|
|
25
|
+
outro('Nothing to lint');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const config = await loadConfig(projectRoot);
|
|
29
|
+
const ctx = await buildPatchQueueContext(paths.patches);
|
|
30
|
+
const cache = options.noCache === true ? undefined : await loadPerPatchLintCache(projectRoot);
|
|
31
|
+
let cacheDirty = false;
|
|
32
|
+
let reusedCacheEntries = 0;
|
|
33
|
+
const issues = [];
|
|
34
|
+
for (const issue of evaluatePatchPolicy(config, manifest)) {
|
|
35
|
+
issues.push({
|
|
36
|
+
file: issue.filename,
|
|
37
|
+
check: `patch-policy/${issue.code}`,
|
|
38
|
+
message: issue.message,
|
|
39
|
+
severity: issue.severity,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
let linted = 0;
|
|
43
|
+
let skipped = 0;
|
|
44
|
+
for (const patch of manifest.patches) {
|
|
45
|
+
const existing = [];
|
|
46
|
+
for (const f of patch.filesAffected) {
|
|
47
|
+
if (await pathExists(join(paths.engine, f)))
|
|
48
|
+
existing.push(f);
|
|
49
|
+
}
|
|
50
|
+
if (existing.length === 0) {
|
|
51
|
+
skipped++;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
|
|
55
|
+
if (!diff.trim()) {
|
|
56
|
+
skipped++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
|
|
60
|
+
const decision = resolvePatchSizeTier(existing, patch.tier);
|
|
61
|
+
if (decision.tier === 'branding') {
|
|
62
|
+
info(decision.source === 'explicit'
|
|
63
|
+
? `${patch.filename}: branding threshold tier applied via patches.json \`tier: "branding"\` opt-in.`
|
|
64
|
+
: `${patch.filename}: branding threshold tier applied (all files under browser/branding/ plus registration siblings).`);
|
|
65
|
+
}
|
|
66
|
+
let patchIssues;
|
|
67
|
+
if (cache) {
|
|
68
|
+
const cacheKey = await buildPerPatchLintCacheKey({
|
|
69
|
+
projectRoot,
|
|
70
|
+
engineDir: paths.engine,
|
|
71
|
+
patchesDir: paths.patches,
|
|
72
|
+
patch,
|
|
73
|
+
existingFiles: existing,
|
|
74
|
+
config,
|
|
75
|
+
queueContext: ctx,
|
|
76
|
+
});
|
|
77
|
+
patchIssues = getCachedPerPatchLintIssues(cache, patch.filename, cacheKey);
|
|
78
|
+
if (patchIssues) {
|
|
79
|
+
reusedCacheEntries++;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore, patch.tier);
|
|
83
|
+
setCachedPerPatchLintIssues(cache, patch.filename, cacheKey, patchIssues);
|
|
84
|
+
cacheDirty = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore, patch.tier);
|
|
89
|
+
}
|
|
90
|
+
for (const issue of patchIssues) {
|
|
91
|
+
issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
|
|
92
|
+
}
|
|
93
|
+
linted++;
|
|
94
|
+
}
|
|
95
|
+
issues.push(...lintPatchQueue(ctx));
|
|
96
|
+
if (cache && cacheDirty)
|
|
97
|
+
await savePerPatchLintCache(projectRoot, cache);
|
|
98
|
+
if (reusedCacheEntries > 0) {
|
|
99
|
+
info(`Reused lint cache for ${reusedCacheEntries} patch${reusedCacheEntries === 1 ? '' : 'es'}.`);
|
|
100
|
+
}
|
|
101
|
+
if (issues.length === 0) {
|
|
102
|
+
if (linted === 0 && skipped > 0) {
|
|
103
|
+
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.`);
|
|
104
|
+
}
|
|
105
|
+
const summary = skipped > 0
|
|
106
|
+
? `No lint issues found across ${linted} patch(es) (${skipped} skipped — files not present in engine/).`
|
|
107
|
+
: `No lint issues found across ${linted} patch(es).`;
|
|
108
|
+
success(summary);
|
|
109
|
+
outro('Lint passed');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const errors = issues.filter((i) => i.severity === 'error');
|
|
113
|
+
const warnings = issues.filter((i) => i.severity === 'warning');
|
|
114
|
+
const notices = issues.filter((i) => i.severity === 'notice');
|
|
115
|
+
for (const issue of notices)
|
|
116
|
+
info(`NOTICE [${issue.check}] ${issue.file}: ${issue.message}`);
|
|
117
|
+
for (const issue of warnings)
|
|
118
|
+
warn(`[${issue.check}] ${issue.file}: ${issue.message}`);
|
|
119
|
+
for (const issue of errors)
|
|
120
|
+
warn(`ERROR [${issue.check}] ${issue.file}: ${issue.message}`);
|
|
121
|
+
info(`\nLint (per-patch over ${linted} patch(es)): ${errors.length} error(s), ${warnings.length} warning(s)`);
|
|
122
|
+
if (errors.length > 0) {
|
|
123
|
+
outro('Lint failed');
|
|
124
|
+
throw new GeneralError(`Patch lint found ${errors.length} error(s) across ${linted} patch(es). Fix these before exporting.`);
|
|
125
|
+
}
|
|
126
|
+
if (options.maxWarnings !== undefined && warnings.length > options.maxWarnings) {
|
|
127
|
+
outro('Lint failed');
|
|
128
|
+
throw new GeneralError(buildPerPatchMaxWarningsMessage(warnings.length, options.maxWarnings, linted));
|
|
129
|
+
}
|
|
130
|
+
if (warnings.length > 0) {
|
|
131
|
+
outro('Lint passed with warnings');
|
|
132
|
+
}
|
|
133
|
+
else if (notices.length > 0) {
|
|
134
|
+
outro('Lint passed with notices');
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
outro('Lint passed');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=lint-per-patch.js.map
|
|
@@ -52,6 +52,11 @@ export interface LintCommandOptions {
|
|
|
52
52
|
* findings to become blocking without changing default CLI behavior.
|
|
53
53
|
*/
|
|
54
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;
|
|
55
60
|
}
|
|
56
61
|
/**
|
|
57
62
|
* Result of {@link applyAggregateLintIgnoreSuppression}.
|
|
@@ -7,15 +7,15 @@ import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
|
7
7
|
import { getStatusWithCodes, hasChanges, isGitRepository } from '../core/git.js';
|
|
8
8
|
import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
9
9
|
import { expandUntrackedDirectoryEntries, getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, getWorkingTreeStatus, } from '../core/git-status.js';
|
|
10
|
+
import { clearPerPatchLintCache } from '../core/lint-cache.js';
|
|
10
11
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
11
|
-
import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue
|
|
12
|
+
import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue } from '../core/patch-lint.js';
|
|
12
13
|
import { collectDiffFilePaths, tagLintIssues } from '../core/patch-lint-diff-tag.js';
|
|
13
|
-
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
14
|
-
import { evaluatePatchPolicy } from '../core/patch-policy.js';
|
|
15
14
|
import { GeneralError } from '../errors/base.js';
|
|
16
15
|
import { pathExists } from '../utils/fs.js';
|
|
17
16
|
import { info, intro, outro, success, warn } from '../utils/logger.js';
|
|
18
17
|
import { stripEnginePrefix } from '../utils/paths.js';
|
|
18
|
+
import { lintPerPatch } from './lint-per-patch.js';
|
|
19
19
|
/**
|
|
20
20
|
* Resolves the diff the lint command should run against. Returns `null` when
|
|
21
21
|
* there is nothing to lint (e.g. no matching files, clean tree, or empty
|
|
@@ -399,121 +399,9 @@ export async function lintCommand(projectRoot, files, options = {}) {
|
|
|
399
399
|
outro('Lint passed');
|
|
400
400
|
}
|
|
401
401
|
}
|
|
402
|
-
/**
|
|
403
|
-
* Lints each patch in the queue as its own isolated diff, honouring
|
|
404
|
-
* per-patch `lintIgnore` entries. Cross-patch rules still run once over
|
|
405
|
-
* the whole queue so queue-level findings (duplicate creations, forward
|
|
406
|
-
* imports) are not lost by the rescoping.
|
|
407
|
-
*
|
|
408
|
-
* Kept separate from {@link lintCommand}'s aggregate path because the
|
|
409
|
-
* two scopes have genuinely different contracts — the aggregate path
|
|
410
|
-
* reports what `git diff HEAD` looks like right now, the per-patch
|
|
411
|
-
* path reports what each patch's own slice of that diff looks like.
|
|
412
|
-
* Sharing a loop would hide the distinction and force the caller to
|
|
413
|
-
* decide semantics mid-function.
|
|
414
|
-
*/
|
|
415
|
-
async function lintPerPatch(projectRoot, paths, options = {}) {
|
|
416
|
-
const manifest = await loadPatchesManifest(paths.patches);
|
|
417
|
-
if (!manifest || manifest.patches.length === 0) {
|
|
418
|
-
info('No patches in manifest — nothing to lint per-patch.');
|
|
419
|
-
outro('Nothing to lint');
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
const config = await loadConfig(projectRoot);
|
|
423
|
-
const ctx = await buildPatchQueueContext(paths.patches);
|
|
424
|
-
const issues = [];
|
|
425
|
-
for (const issue of evaluatePatchPolicy(config, manifest)) {
|
|
426
|
-
issues.push({
|
|
427
|
-
file: issue.filename,
|
|
428
|
-
check: `patch-policy/${issue.code}`,
|
|
429
|
-
message: issue.message,
|
|
430
|
-
severity: issue.severity,
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
let linted = 0;
|
|
434
|
-
let skipped = 0;
|
|
435
|
-
for (const patch of manifest.patches) {
|
|
436
|
-
const existing = [];
|
|
437
|
-
for (const f of patch.filesAffected) {
|
|
438
|
-
if (await pathExists(join(paths.engine, f)))
|
|
439
|
-
existing.push(f);
|
|
440
|
-
}
|
|
441
|
-
if (existing.length === 0) {
|
|
442
|
-
skipped++;
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
|
|
446
|
-
if (!diff.trim()) {
|
|
447
|
-
skipped++;
|
|
448
|
-
continue;
|
|
449
|
-
}
|
|
450
|
-
const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
|
|
451
|
-
const decision = resolvePatchSizeTier(existing, patch.tier);
|
|
452
|
-
if (decision.tier === 'branding') {
|
|
453
|
-
info(decision.source === 'explicit'
|
|
454
|
-
? `${patch.filename}: branding threshold tier applied via patches.json \`tier: "branding"\` opt-in.`
|
|
455
|
-
: `${patch.filename}: branding threshold tier applied (all files under browser/branding/ plus registration siblings).`);
|
|
456
|
-
}
|
|
457
|
-
const patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore, patch.tier);
|
|
458
|
-
for (const issue of patchIssues) {
|
|
459
|
-
issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
|
|
460
|
-
}
|
|
461
|
-
linted++;
|
|
462
|
-
}
|
|
463
|
-
// Cross-patch rules over the whole queue — rescoping per-patch would
|
|
464
|
-
// lose these findings, so they run exactly once against the full
|
|
465
|
-
// context.
|
|
466
|
-
issues.push(...lintPatchQueue(ctx));
|
|
467
|
-
if (issues.length === 0) {
|
|
468
|
-
// 2026-04-26 eval Finding 7: pre-fix the success line read
|
|
469
|
-
// `No lint issues found across 0 patch(es).` whenever the queue
|
|
470
|
-
// had not been applied to the engine — every patch's
|
|
471
|
-
// `filesAffected` filtered out, so `existing` was empty and the
|
|
472
|
-
// patch was silently skipped. Operators read that as "the queue
|
|
473
|
-
// is clean" when in reality nothing was checked. Surface the
|
|
474
|
-
// skipped count and, when nothing was linted at all, point at
|
|
475
|
-
// `fireforge import` as the missing prerequisite.
|
|
476
|
-
if (linted === 0 && skipped > 0) {
|
|
477
|
-
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.`);
|
|
478
|
-
}
|
|
479
|
-
const summary = skipped > 0
|
|
480
|
-
? `No lint issues found across ${linted} patch(es) (${skipped} skipped — files not present in engine/).`
|
|
481
|
-
: `No lint issues found across ${linted} patch(es).`;
|
|
482
|
-
success(summary);
|
|
483
|
-
outro('Lint passed');
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
const errors = issues.filter((i) => i.severity === 'error');
|
|
487
|
-
const warnings = issues.filter((i) => i.severity === 'warning');
|
|
488
|
-
const notices = issues.filter((i) => i.severity === 'notice');
|
|
489
|
-
for (const issue of notices)
|
|
490
|
-
info(`NOTICE [${issue.check}] ${issue.file}: ${issue.message}`);
|
|
491
|
-
for (const issue of warnings)
|
|
492
|
-
warn(`[${issue.check}] ${issue.file}: ${issue.message}`);
|
|
493
|
-
for (const issue of errors)
|
|
494
|
-
warn(`ERROR [${issue.check}] ${issue.file}: ${issue.message}`);
|
|
495
|
-
info(`\nLint (per-patch over ${linted} patch(es)): ${errors.length} error(s), ${warnings.length} warning(s)`);
|
|
496
|
-
if (errors.length > 0) {
|
|
497
|
-
outro('Lint failed');
|
|
498
|
-
throw new GeneralError(`Patch lint found ${errors.length} error(s) across ${linted} patch(es). Fix these before exporting.`);
|
|
499
|
-
}
|
|
500
|
-
if (options.maxWarnings !== undefined && warnings.length > options.maxWarnings) {
|
|
501
|
-
outro('Lint failed');
|
|
502
|
-
throw new GeneralError(buildMaxWarningsMessage(warnings.length, options.maxWarnings, `across ${linted} patch(es)`));
|
|
503
|
-
}
|
|
504
|
-
if (warnings.length > 0) {
|
|
505
|
-
outro('Lint passed with warnings');
|
|
506
|
-
}
|
|
507
|
-
else if (notices.length > 0) {
|
|
508
|
-
outro('Lint passed with notices');
|
|
509
|
-
}
|
|
510
|
-
else {
|
|
511
|
-
outro('Lint passed');
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
402
|
/** Registers the lint command on the CLI program. */
|
|
515
403
|
export function registerLint(program, { getProjectRoot, withErrorHandling }) {
|
|
516
|
-
program
|
|
404
|
+
const lint = program
|
|
517
405
|
.command('lint [paths...]')
|
|
518
406
|
.description('Lint engine changes against patch quality rules. Default: aggregate diff against HEAD ' +
|
|
519
407
|
'(every applied patch summed). Use --per-patch for per-patch scope, or pass explicit ' +
|
|
@@ -522,6 +410,7 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
|
|
|
522
410
|
.option('--only-introduced', 'Fail only on issues tagged [introduced] (requires --since). Cumulative errors still print but do not set a non-zero exit.')
|
|
523
411
|
.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.")
|
|
524
412
|
.option('--max-warnings <n>', 'Fail when lint reports more than <n> warning(s); use 0 for warning-clean release gates.')
|
|
413
|
+
.option('--no-cache', 'Bypass per-patch lint result cache reads and writes.')
|
|
525
414
|
.action(withErrorHandling(async (paths, options) => {
|
|
526
415
|
const lintOptions = {};
|
|
527
416
|
if (options.since !== undefined) {
|
|
@@ -540,7 +429,21 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
|
|
|
540
429
|
}
|
|
541
430
|
lintOptions.maxWarnings = maxWarnings;
|
|
542
431
|
}
|
|
432
|
+
if (options.cache === false) {
|
|
433
|
+
lintOptions.noCache = true;
|
|
434
|
+
}
|
|
543
435
|
await lintCommand(getProjectRoot(), paths, lintOptions);
|
|
544
436
|
}));
|
|
437
|
+
lint
|
|
438
|
+
.command('cache')
|
|
439
|
+
.description('Manage the per-patch lint result cache')
|
|
440
|
+
.command('clear')
|
|
441
|
+
.description('Clear cached per-patch lint results')
|
|
442
|
+
.action(withErrorHandling(async () => {
|
|
443
|
+
intro('FireForge Lint Cache');
|
|
444
|
+
await clearPerPatchLintCache(getProjectRoot());
|
|
445
|
+
success('Cleared per-patch lint cache.');
|
|
446
|
+
outro('Lint cache cleared');
|
|
447
|
+
}));
|
|
545
448
|
}
|
|
546
449
|
//# sourceMappingURL=lint.js.map
|
|
@@ -5,9 +5,9 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
|
5
5
|
import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, testWithOutput, withBuildLock, } from '../core/mach.js';
|
|
6
6
|
import { assertMarionettePortAvailable, extractForwardedMarionettePort, forwardedMachArgsIncludeMarionetteClient, shouldAutoForwardMarionettePortToMach, } from '../core/marionette-port.js';
|
|
7
7
|
import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
|
|
8
|
-
import { buildHarnessEarlyExitMessage, classifyHarnessEarlyExit, } from '../core/test-harness-output.js';
|
|
8
|
+
import { buildHarnessEarlyExitMessage, classifyHarnessEarlyExit, completePostRebuildFailureContext, createPostRebuildFailureContext, prependPostRebuildFailureContext, } from '../core/test-harness-output.js';
|
|
9
9
|
import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
|
|
10
|
-
import {
|
|
10
|
+
import { retryAfterXpcshellSymlinkRepair } from '../core/test-xpcshell-retry.js';
|
|
11
11
|
import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
|
|
12
12
|
import { GeneralError } from '../errors/base.js';
|
|
13
13
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
@@ -34,7 +34,12 @@ function buildUnknownTestMessage(testPaths) {
|
|
|
34
34
|
'The file may exist, but Firefox does not currently resolve it as a runnable test.\n\n' +
|
|
35
35
|
'Check the nearest test manifest (for example browser.toml or xpcshell.toml), confirm the file is listed under the correct test type, and make sure each parent moz.build registers that manifest before retrying.');
|
|
36
36
|
}
|
|
37
|
-
function buildStaleBuildMessage() {
|
|
37
|
+
function buildStaleBuildMessage(postRebuild) {
|
|
38
|
+
if (postRebuild) {
|
|
39
|
+
return ('Firefox test runtime still reported stale-artifact-shaped resource failures after the rebuild completed.\n\n' +
|
|
40
|
+
'FireForge already ran the requested rebuild before this focused test, so treat the remaining failure as a real runtime, registration, routing, or test-contract regression rather than another stale deployed-artifact-only blocker.\n\n' +
|
|
41
|
+
'Check the first post-rebuild failure above and the raw mach output for the concrete path or module that still fails.');
|
|
42
|
+
}
|
|
38
43
|
return ('Firefox test runtime appears to be using stale build artifacts.\n\n' +
|
|
39
44
|
'The failing output referenced missing branding or distribution resources, which usually means the current obj-* build does not match recent engine or branding changes.\n\n' +
|
|
40
45
|
'Re-run "fireforge build --ui" or "fireforge test --build" and then retry.');
|
|
@@ -171,6 +176,15 @@ async function runPreTestBuild(projectRoot, paths, projectConfig) {
|
|
|
171
176
|
s.stop('Build complete');
|
|
172
177
|
});
|
|
173
178
|
}
|
|
179
|
+
function logTestSelection(normalizedPaths) {
|
|
180
|
+
if (normalizedPaths.length > 0) {
|
|
181
|
+
info(`Running tests: ${normalizedPaths.join(', ')}`);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
info('Running all tests...');
|
|
185
|
+
}
|
|
186
|
+
info('');
|
|
187
|
+
}
|
|
174
188
|
// Detects the `AttributeError: 'MochitestDesktop' object has no attribute
|
|
175
189
|
// 'http3Server'` teardown crash. The attribute is lazy-initialized inside
|
|
176
190
|
// harness code paths that presume chrome://branding resolves correctly; a
|
|
@@ -188,16 +202,23 @@ function buildMochitestHttp3ServerMessage() {
|
|
|
188
202
|
" - The `BROWSER_CHROME_MANIFESTS` entry for your fork's chrome.manifest is registered.\n\n" +
|
|
189
203
|
'This is an upstream Firefox harness interaction; FireForge can only diagnose it.');
|
|
190
204
|
}
|
|
191
|
-
function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted, binaryName) {
|
|
205
|
+
function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted, binaryName, postRebuildContext) {
|
|
192
206
|
if (result.exitCode === 0 || result.exitCode === 130)
|
|
193
207
|
return;
|
|
194
208
|
const combinedOutput = `${result.stdout}\n${result.stderr}`;
|
|
209
|
+
const failureContext = postRebuildContext
|
|
210
|
+
? completePostRebuildFailureContext(postRebuildContext, combinedOutput)
|
|
211
|
+
: undefined;
|
|
212
|
+
const withContext = (message) => prependPostRebuildFailureContext(message, failureContext);
|
|
213
|
+
const throwGeneral = (message) => {
|
|
214
|
+
throw new GeneralError(withContext(message));
|
|
215
|
+
};
|
|
195
216
|
if (/UNKNOWN TEST\b/i.test(combinedOutput)) {
|
|
196
|
-
|
|
217
|
+
throwGeneral(buildUnknownTestMessage(normalizedPaths));
|
|
197
218
|
}
|
|
198
219
|
const earlyExit = classifyHarnessEarlyExit(combinedOutput, normalizedPaths);
|
|
199
220
|
if (earlyExit) {
|
|
200
|
-
|
|
221
|
+
throwGeneral(buildHarnessEarlyExitMessage(earlyExit, normalizedPaths));
|
|
201
222
|
}
|
|
202
223
|
// Fork-owned module load failures must beat the branding stale-build
|
|
203
224
|
// branch: 2026-04-21 eval (Finding #14) saw a fork's test fail with
|
|
@@ -206,7 +227,7 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
|
|
|
206
227
|
// stale-build pattern matched, so the operator was told to rebuild
|
|
207
228
|
// when the real fix is to register the missing module.
|
|
208
229
|
if (hasForkModuleSignal(combinedOutput, binaryName)) {
|
|
209
|
-
|
|
230
|
+
throwGeneral(buildForkModuleMessage(binaryName));
|
|
210
231
|
}
|
|
211
232
|
// Branding-specific stale-build signals keep priority over the broader
|
|
212
233
|
// xpcshell-appdir hint: when `chrome://branding/locale/brand.properties`
|
|
@@ -219,24 +240,24 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
|
|
|
219
240
|
// which is the more useful diagnosis in practice for `Failed to load
|
|
220
241
|
// resource:///modules/…`.
|
|
221
242
|
if (hasStaleBuildArtifactsSignal(combinedOutput)) {
|
|
222
|
-
|
|
243
|
+
throwGeneral(buildStaleBuildMessage(Boolean(failureContext)));
|
|
223
244
|
}
|
|
224
245
|
if (hasXpcshellAppdirSignal(combinedOutput)) {
|
|
225
|
-
|
|
246
|
+
throwGeneral(buildXpcshellAppdirMessage(appdirInjectionAttempted));
|
|
226
247
|
}
|
|
227
248
|
if (hasMochitestHttp3ServerSignal(combinedOutput)) {
|
|
228
|
-
|
|
249
|
+
throwGeneral(buildMochitestHttp3ServerMessage());
|
|
229
250
|
}
|
|
230
251
|
if (/FileExistsError/i.test(combinedOutput) &&
|
|
231
252
|
/(mochitest|xpcshell|_tests)/i.test(combinedOutput)) {
|
|
232
|
-
|
|
253
|
+
throwGeneral(buildHarnessSymlinkMessage());
|
|
233
254
|
}
|
|
234
255
|
if (/invalid filename/i.test(combinedOutput) ||
|
|
235
256
|
/chrome:\/\/mochitests.*not found/i.test(combinedOutput)) {
|
|
236
257
|
info('Hint: The test file may not be registered in browser.toml or jar.mn.');
|
|
237
258
|
info('Run "fireforge register <test-path>" to register it.');
|
|
238
259
|
}
|
|
239
|
-
throw new BuildError(`Tests failed with exit code ${result.exitCode}. Check the output above for details
|
|
260
|
+
throw new BuildError(withContext(`Tests failed with exit code ${result.exitCode}. Check the output above for details.`), 'mach test');
|
|
240
261
|
}
|
|
241
262
|
/**
|
|
242
263
|
* Runs the test command to execute mach tests.
|
|
@@ -362,10 +383,6 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
362
383
|
throw new GeneralError('Marionette preflight reported FAIL — see output above. Aborting before mach test runs.');
|
|
363
384
|
}
|
|
364
385
|
}
|
|
365
|
-
// Normalize test paths (strip engine/ prefix if present). Uses the
|
|
366
|
-
// shared `stripEnginePrefix` helper so `test`, `register`, `lint`, and
|
|
367
|
-
// `export` all accept the same prefix forms. Also trim to match the
|
|
368
|
-
// previous case-insensitive + leading-whitespace-tolerant contract.
|
|
369
386
|
const normalizedPaths = testPaths.map((p) => stripEnginePrefix(p).trim());
|
|
370
387
|
await assertTestPathsExist(paths.engine, normalizedPaths);
|
|
371
388
|
const classification = await classifyTestHarnesses(paths.engine, normalizedPaths);
|
|
@@ -375,7 +392,6 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
375
392
|
const forwardedMachArgs = options.machArg && options.machArg.length > 0
|
|
376
393
|
? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
|
|
377
394
|
: [];
|
|
378
|
-
// Build extra args
|
|
379
395
|
const extraArgs = [];
|
|
380
396
|
if (options.headless) {
|
|
381
397
|
extraArgs.push('--headless');
|
|
@@ -430,14 +446,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
430
446
|
// overrides via `--mach-arg=--app-path=…` always win — we skip injection
|
|
431
447
|
// when the operator already passed one.
|
|
432
448
|
const appdirInjection = await maybeInjectAppdirArg(paths.engine, normalizedPaths, buildCheck.objDir, extraArgs);
|
|
433
|
-
|
|
434
|
-
if (normalizedPaths.length > 0) {
|
|
435
|
-
info(`Running tests: ${normalizedPaths.join(', ')}`);
|
|
436
|
-
}
|
|
437
|
-
else {
|
|
438
|
-
info('Running all tests...');
|
|
439
|
-
}
|
|
440
|
-
info('');
|
|
449
|
+
logTestSelection(normalizedPaths);
|
|
441
450
|
let result;
|
|
442
451
|
try {
|
|
443
452
|
result = await testWithOutput(paths.engine, normalizedPaths, extraArgs);
|
|
@@ -445,15 +454,10 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
445
454
|
catch (error) {
|
|
446
455
|
throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
|
|
447
456
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
if (repaired) {
|
|
453
|
-
result = await testWithOutput(paths.engine, normalizedPaths, extraArgs);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
handleNonZeroTestExit(result, normalizedPaths, appdirInjection, projectConfig.binaryName);
|
|
457
|
+
result = await retryAfterXpcshellSymlinkRepair(paths.engine, buildCheck.objDir, result, classification, normalizedPaths, extraArgs);
|
|
458
|
+
handleNonZeroTestExit(result, normalizedPaths, appdirInjection, projectConfig.binaryName, options.build
|
|
459
|
+
? createPostRebuildFailureContext('fireforge test --build', normalizedPaths)
|
|
460
|
+
: undefined);
|
|
457
461
|
}
|
|
458
462
|
/** Registers the test command on the CLI program. */
|
|
459
463
|
export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { PatchLintIssue, PatchMetadata } from '../types/commands/index.js';
|
|
2
|
+
import type { FireForgeConfig } from '../types/config.js';
|
|
3
|
+
import { type PatchQueueContext } from './patch-lint.js';
|
|
4
|
+
export declare const LINT_CACHE_SCHEMA_VERSION = 1;
|
|
5
|
+
export declare const LINT_IMPLEMENTATION_VERSION = 1;
|
|
6
|
+
export interface PerPatchLintCacheEntry {
|
|
7
|
+
key: string;
|
|
8
|
+
patchFilename: string;
|
|
9
|
+
issues: PatchLintIssue[];
|
|
10
|
+
updatedAt: string;
|
|
11
|
+
}
|
|
12
|
+
export interface PerPatchLintCacheFile {
|
|
13
|
+
schemaVersion: typeof LINT_CACHE_SCHEMA_VERSION;
|
|
14
|
+
entries: Record<string, PerPatchLintCacheEntry>;
|
|
15
|
+
}
|
|
16
|
+
export interface PerPatchLintCacheKeyInput {
|
|
17
|
+
projectRoot: string;
|
|
18
|
+
engineDir: string;
|
|
19
|
+
patchesDir: string;
|
|
20
|
+
patch: PatchMetadata;
|
|
21
|
+
existingFiles: string[];
|
|
22
|
+
config: FireForgeConfig;
|
|
23
|
+
queueContext: PatchQueueContext;
|
|
24
|
+
packageVersion?: string;
|
|
25
|
+
}
|
|
26
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
27
|
+
[key: string]: JsonValue | undefined;
|
|
28
|
+
};
|
|
29
|
+
/** Computes a SHA-256 hex digest for text or binary content. */
|
|
30
|
+
export declare function sha256Hex(content: string | Buffer): string;
|
|
31
|
+
/** Computes a stable SHA-256 digest for JSON-compatible data. */
|
|
32
|
+
export declare function stableHash(value: JsonValue): string;
|
|
33
|
+
/** Returns the repo-local per-patch lint cache file path. */
|
|
34
|
+
export declare function getPerPatchLintCachePath(projectRoot: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Builds the complete per-patch lint cache key for one lintable patch.
|
|
37
|
+
* The key includes source, metadata, config, engine state, and ownership inputs.
|
|
38
|
+
*/
|
|
39
|
+
export declare function buildPerPatchLintCacheKey(input: PerPatchLintCacheKeyInput): Promise<string>;
|
|
40
|
+
/** Creates an empty cache document using the current cache schema. */
|
|
41
|
+
export declare function createEmptyPerPatchLintCache(): PerPatchLintCacheFile;
|
|
42
|
+
/** Loads the per-patch lint cache, treating missing or invalid files as empty. */
|
|
43
|
+
export declare function loadPerPatchLintCache(projectRoot: string): Promise<PerPatchLintCacheFile>;
|
|
44
|
+
/** Persists the per-patch lint cache atomically through the shared JSON writer. */
|
|
45
|
+
export declare function savePerPatchLintCache(projectRoot: string, cache: PerPatchLintCacheFile): Promise<void>;
|
|
46
|
+
/** Clears the per-patch lint cache by replacing it with an empty document. */
|
|
47
|
+
export declare function clearPerPatchLintCache(projectRoot: string): Promise<void>;
|
|
48
|
+
/** Returns cached issues for a patch when the stored key still matches. */
|
|
49
|
+
export declare function getCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string): PatchLintIssue[] | undefined;
|
|
50
|
+
/** Stores per-patch lint issues after a successful lint calculation. */
|
|
51
|
+
export declare function setCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string, issues: PatchLintIssue[]): void;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { pathExists, readJson, writeJson } from '../utils/fs.js';
|
|
6
|
+
import { getPackageVersion } from '../utils/package-root.js';
|
|
7
|
+
import { getFurnacePaths } from './furnace-config.js';
|
|
8
|
+
import { collectNewFileCreatorsByPath } from './patch-lint.js';
|
|
9
|
+
export const LINT_CACHE_SCHEMA_VERSION = 1;
|
|
10
|
+
export const LINT_IMPLEMENTATION_VERSION = 1;
|
|
11
|
+
const LINT_CACHE_DIRNAME = 'lint-cache';
|
|
12
|
+
const PER_PATCH_CACHE_FILENAME = 'per-patch-v1.json';
|
|
13
|
+
function stableJson(value) {
|
|
14
|
+
if (value === null || typeof value !== 'object') {
|
|
15
|
+
return JSON.stringify(value);
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(value)) {
|
|
18
|
+
return `[${value.map((item) => stableJson(item)).join(',')}]`;
|
|
19
|
+
}
|
|
20
|
+
const entries = Object.entries(value)
|
|
21
|
+
.filter(([, item]) => item !== undefined)
|
|
22
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
23
|
+
return `{${entries
|
|
24
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
|
|
25
|
+
.join(',')}}`;
|
|
26
|
+
}
|
|
27
|
+
/** Computes a SHA-256 hex digest for text or binary content. */
|
|
28
|
+
export function sha256Hex(content) {
|
|
29
|
+
return createHash('sha256').update(content).digest('hex');
|
|
30
|
+
}
|
|
31
|
+
/** Computes a stable SHA-256 digest for JSON-compatible data. */
|
|
32
|
+
export function stableHash(value) {
|
|
33
|
+
return sha256Hex(stableJson(value));
|
|
34
|
+
}
|
|
35
|
+
/** Returns the repo-local per-patch lint cache file path. */
|
|
36
|
+
export function getPerPatchLintCachePath(projectRoot) {
|
|
37
|
+
return join(projectRoot, '.fireforge', LINT_CACHE_DIRNAME, PER_PATCH_CACHE_FILENAME);
|
|
38
|
+
}
|
|
39
|
+
async function fileHash(path) {
|
|
40
|
+
if (!(await pathExists(path))) {
|
|
41
|
+
return { exists: false };
|
|
42
|
+
}
|
|
43
|
+
return { exists: true, sha256: sha256Hex(await readFile(path)) };
|
|
44
|
+
}
|
|
45
|
+
function normalizePatchMetadata(patch) {
|
|
46
|
+
return {
|
|
47
|
+
filesAffected: patch.filesAffected,
|
|
48
|
+
lintIgnore: patch.lintIgnore,
|
|
49
|
+
stagedDependencies: patch.stagedDependencies,
|
|
50
|
+
tier: patch.tier,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function normalizeLintConfig(config) {
|
|
54
|
+
return {
|
|
55
|
+
binaryName: config.binaryName,
|
|
56
|
+
license: config.license,
|
|
57
|
+
patchLint: config.patchLint,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function isOwnershipRelevantFile(file) {
|
|
61
|
+
return file.endsWith('.js') || file.endsWith('.mjs') || file.endsWith('.jsm');
|
|
62
|
+
}
|
|
63
|
+
function buildOwnershipFingerprint(files, ctx) {
|
|
64
|
+
const creators = collectNewFileCreatorsByPath(ctx);
|
|
65
|
+
const entries = {};
|
|
66
|
+
const relevantFiles = [...files]
|
|
67
|
+
.filter(isOwnershipRelevantFile)
|
|
68
|
+
.sort((a, b) => a.localeCompare(b));
|
|
69
|
+
for (const file of relevantFiles) {
|
|
70
|
+
entries[file] = [...(creators.get(file) ?? [])].sort((a, b) => a.localeCompare(b));
|
|
71
|
+
}
|
|
72
|
+
return entries;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Builds the complete per-patch lint cache key for one lintable patch.
|
|
76
|
+
* The key includes source, metadata, config, engine state, and ownership inputs.
|
|
77
|
+
*/
|
|
78
|
+
export async function buildPerPatchLintCacheKey(input) {
|
|
79
|
+
const engineFiles = {};
|
|
80
|
+
for (const file of [...input.existingFiles].sort((a, b) => a.localeCompare(b))) {
|
|
81
|
+
engineFiles[file] = await fileHash(join(input.engineDir, file));
|
|
82
|
+
}
|
|
83
|
+
const furnaceConfigPath = getFurnacePaths(input.projectRoot).furnaceConfig;
|
|
84
|
+
const extraShimPath = input.config.patchLint?.checkJsExtraShim;
|
|
85
|
+
const extraShim = extraShimPath === undefined
|
|
86
|
+
? null
|
|
87
|
+
: {
|
|
88
|
+
path: extraShimPath,
|
|
89
|
+
hash: await fileHash(resolve(input.projectRoot, extraShimPath)),
|
|
90
|
+
};
|
|
91
|
+
return stableHash({
|
|
92
|
+
cacheSchemaVersion: LINT_CACHE_SCHEMA_VERSION,
|
|
93
|
+
lintImplementationVersion: LINT_IMPLEMENTATION_VERSION,
|
|
94
|
+
packageVersion: input.packageVersion ?? getPackageVersion(),
|
|
95
|
+
patchFile: await fileHash(join(input.patchesDir, input.patch.filename)),
|
|
96
|
+
patchMetadata: normalizePatchMetadata(input.patch),
|
|
97
|
+
lintConfig: normalizeLintConfig(input.config),
|
|
98
|
+
furnaceConfig: await fileHash(furnaceConfigPath),
|
|
99
|
+
checkJsExtraShim: extraShim,
|
|
100
|
+
engineFiles,
|
|
101
|
+
queueOwnership: buildOwnershipFingerprint(input.existingFiles, input.queueContext),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** Creates an empty cache document using the current cache schema. */
|
|
105
|
+
export function createEmptyPerPatchLintCache() {
|
|
106
|
+
return { schemaVersion: LINT_CACHE_SCHEMA_VERSION, entries: {} };
|
|
107
|
+
}
|
|
108
|
+
function isCacheEntry(value) {
|
|
109
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
110
|
+
return false;
|
|
111
|
+
const entry = value;
|
|
112
|
+
return (typeof entry.key === 'string' &&
|
|
113
|
+
typeof entry.patchFilename === 'string' &&
|
|
114
|
+
Array.isArray(entry.issues) &&
|
|
115
|
+
typeof entry.updatedAt === 'string');
|
|
116
|
+
}
|
|
117
|
+
/** Loads the per-patch lint cache, treating missing or invalid files as empty. */
|
|
118
|
+
export async function loadPerPatchLintCache(projectRoot) {
|
|
119
|
+
const cachePath = getPerPatchLintCachePath(projectRoot);
|
|
120
|
+
if (!(await pathExists(cachePath))) {
|
|
121
|
+
return createEmptyPerPatchLintCache();
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const raw = await readJson(cachePath);
|
|
125
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
126
|
+
return createEmptyPerPatchLintCache();
|
|
127
|
+
}
|
|
128
|
+
const candidate = raw;
|
|
129
|
+
if (candidate.schemaVersion !== LINT_CACHE_SCHEMA_VERSION || !candidate.entries) {
|
|
130
|
+
return createEmptyPerPatchLintCache();
|
|
131
|
+
}
|
|
132
|
+
const entries = {};
|
|
133
|
+
for (const [filename, entry] of Object.entries(candidate.entries)) {
|
|
134
|
+
if (isCacheEntry(entry)) {
|
|
135
|
+
entries[filename] = entry;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { schemaVersion: LINT_CACHE_SCHEMA_VERSION, entries };
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return createEmptyPerPatchLintCache();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Persists the per-patch lint cache atomically through the shared JSON writer. */
|
|
145
|
+
export async function savePerPatchLintCache(projectRoot, cache) {
|
|
146
|
+
await writeJson(getPerPatchLintCachePath(projectRoot), {
|
|
147
|
+
schemaVersion: LINT_CACHE_SCHEMA_VERSION,
|
|
148
|
+
entries: cache.entries,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** Clears the per-patch lint cache by replacing it with an empty document. */
|
|
152
|
+
export async function clearPerPatchLintCache(projectRoot) {
|
|
153
|
+
await writeJson(getPerPatchLintCachePath(projectRoot), createEmptyPerPatchLintCache());
|
|
154
|
+
}
|
|
155
|
+
/** Returns cached issues for a patch when the stored key still matches. */
|
|
156
|
+
export function getCachedPerPatchLintIssues(cache, patchFilename, key) {
|
|
157
|
+
const entry = cache.entries[patchFilename];
|
|
158
|
+
if (!entry || entry.key !== key)
|
|
159
|
+
return undefined;
|
|
160
|
+
return entry.issues.map((issue) => ({ ...issue }));
|
|
161
|
+
}
|
|
162
|
+
/** Stores per-patch lint issues after a successful lint calculation. */
|
|
163
|
+
export function setCachedPerPatchLintIssues(cache, patchFilename, key, issues) {
|
|
164
|
+
cache.entries[patchFilename] = {
|
|
165
|
+
key,
|
|
166
|
+
patchFilename,
|
|
167
|
+
issues: issues.map((issue) => ({ ...issue })),
|
|
168
|
+
updatedAt: new Date().toISOString(),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=lint-cache.js.map
|
|
@@ -3,6 +3,19 @@ export interface HarnessEarlyExit {
|
|
|
3
3
|
kind: HarnessEarlyExitKind;
|
|
4
4
|
line: string;
|
|
5
5
|
}
|
|
6
|
+
export interface PostRebuildFailureContext {
|
|
7
|
+
rebuildCommand: string;
|
|
8
|
+
requestedPaths: readonly string[];
|
|
9
|
+
firstFailureLine?: string;
|
|
10
|
+
}
|
|
11
|
+
/** Finds the first high-signal failure line from captured mach test output. */
|
|
12
|
+
export declare function findFirstUsefulFailureLine(output: string): string | undefined;
|
|
13
|
+
/** Starts a post-rebuild context block for a focused test failure. */
|
|
14
|
+
export declare function createPostRebuildFailureContext(rebuildCommand: string, requestedPaths: readonly string[]): PostRebuildFailureContext;
|
|
15
|
+
/** Adds the first useful failure line from captured output to an existing context block. */
|
|
16
|
+
export declare function completePostRebuildFailureContext(context: PostRebuildFailureContext, output: string): PostRebuildFailureContext;
|
|
17
|
+
/** Prepends post-rebuild context when the test failure happened after a successful rebuild. */
|
|
18
|
+
export declare function prependPostRebuildFailureContext(message: string, context: PostRebuildFailureContext | undefined): string;
|
|
6
19
|
/** Classifies mach output where no requested test actually began running. */
|
|
7
20
|
export declare function classifyHarnessEarlyExit(output: string, normalizedPaths: readonly string[]): HarnessEarlyExit | undefined;
|
|
8
21
|
/** Builds the user-facing message for a harness startup or zero-run failure. */
|
|
@@ -8,6 +8,45 @@ function getNonEmptyOutputLines(output) {
|
|
|
8
8
|
function findFirstMatchingLine(lines, patterns) {
|
|
9
9
|
return lines.find((line) => patterns.some((pattern) => pattern.test(line)));
|
|
10
10
|
}
|
|
11
|
+
/** Finds the first high-signal failure line from captured mach test output. */
|
|
12
|
+
export function findFirstUsefulFailureLine(output) {
|
|
13
|
+
const lines = getNonEmptyOutputLines(output);
|
|
14
|
+
const matched = findFirstMatchingLine(lines, [
|
|
15
|
+
/\bTEST-UNEXPECTED-[A-Z-]+\b/,
|
|
16
|
+
/\bPROCESS-CRASH\b/i,
|
|
17
|
+
/\bTIMEOUT\b/i,
|
|
18
|
+
/timed out/i,
|
|
19
|
+
/HominisBrowserUnavailableError/i,
|
|
20
|
+
/Marionette.*(?:session|startup|start).*fail/i,
|
|
21
|
+
/(?:failed|unable) to (?:start|create|open).*Marionette/i,
|
|
22
|
+
/SessionNotCreatedException/i,
|
|
23
|
+
/Browser process exited during spawn/i,
|
|
24
|
+
/Failed to load (?:resource|chrome):\/\//i,
|
|
25
|
+
/\b(?:Error|Exception|TypeError|ReferenceError|SyntaxError):\s+/,
|
|
26
|
+
/AttributeError:\s+/,
|
|
27
|
+
]);
|
|
28
|
+
return matched ?? lines[0];
|
|
29
|
+
}
|
|
30
|
+
/** Starts a post-rebuild context block for a focused test failure. */
|
|
31
|
+
export function createPostRebuildFailureContext(rebuildCommand, requestedPaths) {
|
|
32
|
+
return { rebuildCommand, requestedPaths };
|
|
33
|
+
}
|
|
34
|
+
/** Adds the first useful failure line from captured output to an existing context block. */
|
|
35
|
+
export function completePostRebuildFailureContext(context, output) {
|
|
36
|
+
const firstFailureLine = findFirstUsefulFailureLine(output);
|
|
37
|
+
return firstFailureLine ? { ...context, firstFailureLine } : context;
|
|
38
|
+
}
|
|
39
|
+
/** Prepends post-rebuild context when the test failure happened after a successful rebuild. */
|
|
40
|
+
export function prependPostRebuildFailureContext(message, context) {
|
|
41
|
+
if (!context)
|
|
42
|
+
return message;
|
|
43
|
+
const requestedPaths = context.requestedPaths.length > 0 ? context.requestedPaths.join(', ') : '(all tests)';
|
|
44
|
+
return ('Post-rebuild test failure:\n\n' +
|
|
45
|
+
`Rebuild command: ${context.rebuildCommand}\n` +
|
|
46
|
+
`Requested paths: ${requestedPaths}\n` +
|
|
47
|
+
`First post-rebuild failure: ${context.firstFailureLine ?? '(no captured output)'}\n\n` +
|
|
48
|
+
message);
|
|
49
|
+
}
|
|
11
50
|
/** Classifies mach output where no requested test actually began running. */
|
|
12
51
|
export function classifyHarnessEarlyExit(output, normalizedPaths) {
|
|
13
52
|
const lines = getNonEmptyOutputLines(output);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type MachCommandResult } from './mach.js';
|
|
2
|
+
export interface XpcshellRetryClassification {
|
|
3
|
+
xpcshell: readonly string[];
|
|
4
|
+
nonXpcshell: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
/** Removes a stale xpcshell install symlink and retries the focused mach test once. */
|
|
7
|
+
export declare function retryAfterXpcshellSymlinkRepair(engineDir: string, objDir: string | undefined, result: MachCommandResult, classification: XpcshellRetryClassification, normalizedPaths: string[], extraArgs: string[]): Promise<MachCommandResult>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { testWithOutput } from './mach.js';
|
|
3
|
+
import { tryRepairStaleXpcshellTestSymlink } from './test-stale-symlink.js';
|
|
4
|
+
/** Removes a stale xpcshell install symlink and retries the focused mach test once. */
|
|
5
|
+
export async function retryAfterXpcshellSymlinkRepair(engineDir, objDir, result, classification, normalizedPaths, extraArgs) {
|
|
6
|
+
if (result.exitCode !== 0 &&
|
|
7
|
+
classification.xpcshell.length > 0 &&
|
|
8
|
+
classification.nonXpcshell.length === 0) {
|
|
9
|
+
const repaired = await tryRepairStaleXpcshellTestSymlink(engineDir, objDir, `${result.stdout}\n${result.stderr}`);
|
|
10
|
+
if (repaired) {
|
|
11
|
+
return testWithOutput(engineDir, normalizedPaths, extraArgs);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=test-xpcshell-retry.js.map
|