@hominis/fireforge 0.44.3 → 0.45.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 +47 -0
- package/dist/build-info.json +4 -4
- package/dist/src/commands/build.js +9 -4
- package/dist/src/commands/doctor-source-pin.d.ts +29 -0
- package/dist/src/commands/doctor-source-pin.js +81 -0
- package/dist/src/commands/doctor.js +2 -0
- package/dist/src/commands/export-flow.js +3 -18
- package/dist/src/commands/furnace/create-browser-test.d.ts +9 -0
- package/dist/src/commands/furnace/create-browser-test.js +6 -6
- package/dist/src/commands/furnace/create-dry-run.d.ts +10 -0
- package/dist/src/commands/furnace/create-dry-run.js +16 -11
- package/dist/src/commands/furnace/create-xpcshell.js +2 -2
- package/dist/src/commands/furnace/create.js +5 -0
- package/dist/src/commands/patch/move-files-into.js +3 -6
- package/dist/src/commands/patch/split-plan.js +3 -6
- package/dist/src/commands/re-export-adjacent.d.ts +8 -0
- package/dist/src/commands/re-export-adjacent.js +26 -1
- package/dist/src/commands/re-export-drift.d.ts +8 -0
- package/dist/src/commands/re-export-drift.js +61 -0
- package/dist/src/commands/re-export-files.js +3 -6
- package/dist/src/commands/re-export-options.js +19 -5
- package/dist/src/commands/re-export-register.js +5 -1
- package/dist/src/commands/re-export-scan.js +3 -6
- package/dist/src/commands/re-export.js +16 -16
- package/dist/src/commands/test-build-phase.js +3 -2
- package/dist/src/commands/test-diagnose.js +14 -5
- package/dist/src/commands/test-register.js +1 -0
- package/dist/src/commands/test-run.js +4 -3
- package/dist/src/commands/test-stale-gate.js +5 -2
- package/dist/src/commands/test.js +9 -2
- package/dist/src/core/build-audit.d.ts +20 -0
- package/dist/src/core/build-audit.js +69 -3
- package/dist/src/core/build-baseline-types.d.ts +28 -0
- package/dist/src/core/build-baseline.d.ts +16 -1
- package/dist/src/core/build-baseline.js +38 -2
- package/dist/src/core/build-jar-escalation.d.ts +64 -0
- package/dist/src/core/build-jar-escalation.js +127 -0
- package/dist/src/core/build-prepare.d.ts +12 -1
- package/dist/src/core/build-prepare.js +68 -6
- package/dist/src/core/config-validate-file-size.d.ts +12 -0
- package/dist/src/core/config-validate-file-size.js +65 -0
- package/dist/src/core/config-validate.js +6 -0
- package/dist/src/core/engine-changes.d.ts +18 -0
- package/dist/src/core/engine-changes.js +34 -0
- package/dist/src/core/furnace-constants.d.ts +21 -0
- package/dist/src/core/furnace-constants.js +26 -1
- package/dist/src/core/harness-orphans.d.ts +73 -0
- package/dist/src/core/harness-orphans.js +186 -0
- package/dist/src/core/license-headers.d.ts +9 -5
- package/dist/src/core/license-headers.js +33 -7
- package/dist/src/core/mach-known-noise-filter.d.ts +21 -0
- package/dist/src/core/mach-known-noise-filter.js +24 -1
- package/dist/src/core/mach.d.ts +7 -0
- package/dist/src/core/mach.js +18 -2
- package/dist/src/core/patch-apply.js +49 -20
- package/dist/src/core/patch-lint-checkjs.js +6 -1
- package/dist/src/core/patch-lint-file-size.d.ts +52 -0
- package/dist/src/core/patch-lint-file-size.js +53 -0
- package/dist/src/core/patch-lint-prettier.d.ts +36 -0
- package/dist/src/core/patch-lint-prettier.js +126 -0
- package/dist/src/core/patch-lint-unmanaged-companion.d.ts +106 -0
- package/dist/src/core/patch-lint-unmanaged-companion.js +214 -0
- package/dist/src/core/patch-lint.js +23 -24
- package/dist/src/core/patch-transform.d.ts +21 -0
- package/dist/src/core/patch-transform.js +38 -0
- package/dist/src/core/smoke-patterns.js +12 -0
- package/dist/src/core/test-harness-crash.js +61 -48
- package/dist/src/core/test-harness-verdict-notes.d.ts +55 -0
- package/dist/src/core/test-harness-verdict-notes.js +48 -0
- package/dist/src/core/test-stale-check.d.ts +23 -6
- package/dist/src/core/test-stale-check.js +77 -15
- package/dist/src/core/test-stall-triage.js +37 -19
- package/dist/src/errors/build.d.ts +28 -0
- package/dist/src/errors/build.js +49 -0
- package/dist/src/types/commands/options.d.ts +19 -0
- package/dist/src/types/commands/patches.d.ts +17 -6
- package/dist/src/types/config.d.ts +41 -0
- package/dist/src/utils/fs.d.ts +4 -2
- package/dist/src/utils/fs.js +3 -1
- package/docs/configuration.md +47 -0
- package/docs/exit-codes.md +4 -2
- package/docs/testing.md +83 -0
- package/package.json +1 -1
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Recognizes the one shape where the undefined-identifier hint sends the
|
|
4
|
+
* operator at the wrong remedy — and where taking that remedy would MASK a
|
|
5
|
+
* real bug.
|
|
6
|
+
*
|
|
7
|
+
* The shape: helpers are hoisted out of duplicated test files into a new
|
|
8
|
+
* `head_settings.js` that a managed `head.js` pulls in with
|
|
9
|
+
* `loadSubScript`. Until that companion is exported it belongs to no patch,
|
|
10
|
+
* so the per-patch checkJs program — which only ever loads patch-owned
|
|
11
|
+
* roots — cannot see any of its declarations. Every use of every hoisted
|
|
12
|
+
* helper is then reported as `Cannot find name`, and the generic hint
|
|
13
|
+
* offers three remedies of which the shim is the easiest: adding the
|
|
14
|
+
* globals to `extraShim` makes all of the warnings disappear AND makes a
|
|
15
|
+
* genuinely missing helper undetectable forever after.
|
|
16
|
+
*
|
|
17
|
+
* The warnings themselves are correct — the per-patch program's honest view
|
|
18
|
+
* of a file that is in no patch. Only the advice is wrong. FireForge
|
|
19
|
+
* already knows ownership, so when every undefined name in a file is
|
|
20
|
+
* declared at the top level of an UNMANAGED file that a managed head loads,
|
|
21
|
+
* it can name that file and the adoption command instead.
|
|
22
|
+
*
|
|
23
|
+
* Detection is deliberately conservative on both halves: a companion must
|
|
24
|
+
* be reachable from an actual `loadSubScript` call, and EVERY undefined
|
|
25
|
+
* name must resolve inside it. One unresolved name means the file may
|
|
26
|
+
* genuinely be missing a helper, and the generic hint stays.
|
|
27
|
+
*/
|
|
28
|
+
import { readFile } from 'node:fs/promises';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { UNDEFINED_IDENTIFIER_HINT } from './typecheck-shim.js';
|
|
31
|
+
/**
|
|
32
|
+
* String literals passed anywhere inside a `loadSubScript(...)` call.
|
|
33
|
+
*
|
|
34
|
+
* The mochitest idiom is
|
|
35
|
+
* `Services.scriptloader.loadSubScript(getRootDirectory(gTestPath) + "head_settings.js", this)`,
|
|
36
|
+
* so the useful signal is the `.js` literal inside the call, not a
|
|
37
|
+
* resolvable URL — the path half is computed at runtime.
|
|
38
|
+
*/
|
|
39
|
+
export function findLoadSubScriptTargets(source) {
|
|
40
|
+
const targets = [];
|
|
41
|
+
// Scanned to the statement terminator rather than to a closing paren:
|
|
42
|
+
// the idiomatic argument is itself a call (`getRootDirectory(gTestPath) +
|
|
43
|
+
// "head_settings.js"`), so a non-greedy `\)` stops on the INNER paren and
|
|
44
|
+
// never reaches the literal.
|
|
45
|
+
for (const call of source.matchAll(/loadSubScript\s*\(/g)) {
|
|
46
|
+
const from = call.index + call[0].length;
|
|
47
|
+
const semicolon = source.indexOf(';', from);
|
|
48
|
+
const to = semicolon === -1 ? from + CALL_SCAN_LIMIT : Math.min(semicolon, from + CALL_SCAN_LIMIT);
|
|
49
|
+
for (const literal of source.slice(from, to).matchAll(/["'`]([^"'`]*\.js)["'`]/g)) {
|
|
50
|
+
const value = literal[1];
|
|
51
|
+
if (value !== undefined && value.length > 0)
|
|
52
|
+
targets.push(value);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return targets;
|
|
56
|
+
}
|
|
57
|
+
/** How far past a `loadSubScript(` the argument scan looks. */
|
|
58
|
+
const CALL_SCAN_LIMIT = 400;
|
|
59
|
+
/**
|
|
60
|
+
* Top-level declaration names in a script-scope file.
|
|
61
|
+
*
|
|
62
|
+
* Regex rather than a TS program on purpose: this runs only to decide the
|
|
63
|
+
* WORDING of a hint, the file is by definition outside every program the
|
|
64
|
+
* pass built, and a missed declaration degrades to the generic hint rather
|
|
65
|
+
* than to a wrong answer. Matching is anchored at column zero so a nested
|
|
66
|
+
* declaration inside a function body is not mistaken for a global.
|
|
67
|
+
*/
|
|
68
|
+
export function collectTopLevelDeclarations(source) {
|
|
69
|
+
const names = new Set();
|
|
70
|
+
const patterns = [
|
|
71
|
+
/^(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/gm,
|
|
72
|
+
/^(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gm,
|
|
73
|
+
/^class\s+([A-Za-z_$][\w$]*)/gm,
|
|
74
|
+
];
|
|
75
|
+
for (const pattern of patterns) {
|
|
76
|
+
for (const match of source.matchAll(pattern)) {
|
|
77
|
+
const name = match[1];
|
|
78
|
+
if (name !== undefined)
|
|
79
|
+
names.add(name);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return names;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Extracts the identifier from a `Cannot find name 'X'` diagnostic message,
|
|
86
|
+
* or undefined when the message is not one.
|
|
87
|
+
*/
|
|
88
|
+
function extractUndefinedIdentifier(message) {
|
|
89
|
+
const match = /Cannot find name '([^']+)'/.exec(message);
|
|
90
|
+
return match?.[1];
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolves the unmanaged companions reachable from a set of managed head
|
|
94
|
+
* files, given a reader for repo-relative file content.
|
|
95
|
+
*
|
|
96
|
+
* A candidate qualifies only when it is NOT in `ownedFiles` and its content
|
|
97
|
+
* can be read — an unreadable or absent target is simply not a companion,
|
|
98
|
+
* and the generic hint applies.
|
|
99
|
+
*
|
|
100
|
+
* @param heads - Repo-relative managed head files, with their sources
|
|
101
|
+
* @param ownedFiles - Every patch-owned repo-relative path
|
|
102
|
+
* @param readFile - Reads a repo-relative file, or returns undefined
|
|
103
|
+
*/
|
|
104
|
+
export async function resolveUnmanagedCompanions(heads, ownedFiles, readFile) {
|
|
105
|
+
const companions = [];
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
for (const head of heads) {
|
|
108
|
+
const dir = head.file.slice(0, head.file.lastIndexOf('/') + 1);
|
|
109
|
+
for (const target of findLoadSubScriptTargets(head.source)) {
|
|
110
|
+
// Only same-directory targets: the runtime path is computed from the
|
|
111
|
+
// test's own directory, and a bare basename is what that idiom yields.
|
|
112
|
+
const candidate = `${dir}${target.slice(target.lastIndexOf('/') + 1)}`;
|
|
113
|
+
if (candidate === head.file || ownedFiles.has(candidate) || seen.has(candidate))
|
|
114
|
+
continue;
|
|
115
|
+
seen.add(candidate);
|
|
116
|
+
const source = await readFile(candidate);
|
|
117
|
+
if (source === undefined)
|
|
118
|
+
continue;
|
|
119
|
+
companions.push({
|
|
120
|
+
file: candidate,
|
|
121
|
+
loadedBy: head.file,
|
|
122
|
+
declarations: collectTopLevelDeclarations(source),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return companions;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The replacement hint, when every undefined name resolves in one
|
|
130
|
+
* unmanaged companion.
|
|
131
|
+
*
|
|
132
|
+
* States the ownership fact first (that is the diagnosis), then the
|
|
133
|
+
* adoption command, and explicitly rules the shim OUT — the shim is what an
|
|
134
|
+
* operator reaches for by default here, and it is the one remedy that
|
|
135
|
+
* permanently hides a genuinely missing helper.
|
|
136
|
+
*/
|
|
137
|
+
export function formatUnmanagedCompanionHint(companion) {
|
|
138
|
+
return (`(undefined identifier — every one of them is declared in ${companion.file}, which ` +
|
|
139
|
+
`${companion.loadedBy} loads via loadSubScript but which NO patch owns yet, so the ` +
|
|
140
|
+
`per-patch program cannot see it. Adopt it with ` +
|
|
141
|
+
`"fireforge re-export <patch> --scan --scan-file ${companion.file}". Do NOT add these ` +
|
|
142
|
+
`globals to the extra shim: that silences these warnings and every future genuinely ` +
|
|
143
|
+
`missing helper with them.)`);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Picks the companion that explains EVERY undefined identifier in
|
|
147
|
+
* `messages`, or undefined when none does.
|
|
148
|
+
*
|
|
149
|
+
* @param messages - Diagnostic messages reported for one file
|
|
150
|
+
* @param companions - Candidates from {@link resolveUnmanagedCompanions}
|
|
151
|
+
*/
|
|
152
|
+
export function explainUndefinedIdentifiers(messages, companions) {
|
|
153
|
+
const names = messages
|
|
154
|
+
.map((message) => extractUndefinedIdentifier(message))
|
|
155
|
+
.filter((name) => name !== undefined);
|
|
156
|
+
if (names.length === 0)
|
|
157
|
+
return undefined;
|
|
158
|
+
// Only the undefined-identifier diagnostics are explained here; a file
|
|
159
|
+
// carrying OTHER type errors still gets those reported untouched.
|
|
160
|
+
return companions.find((companion) => names.every((name) => companion.declarations.has(name)));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Reads the head files in the owned test set and resolves the UNMANAGED
|
|
164
|
+
* companions they pull in with `loadSubScript`. Hoisting shared helpers
|
|
165
|
+
* into such a companion leaves it unowned until export, and every use of
|
|
166
|
+
* every helper then reports `Cannot find name` from an honest per-patch
|
|
167
|
+
* program — see `patch-lint-unmanaged-companion.ts` for why the generic
|
|
168
|
+
* hint is actively harmful there.
|
|
169
|
+
*
|
|
170
|
+
* Best-effort: an unreadable head simply contributes no companions.
|
|
171
|
+
*/
|
|
172
|
+
export async function collectUnmanagedCompanions(repoDir, files, ownedFiles) {
|
|
173
|
+
const heads = [];
|
|
174
|
+
for (const file of files) {
|
|
175
|
+
const base = file.split('/').pop() ?? '';
|
|
176
|
+
if (!/^head(?:_.*)?\.js$/.test(base))
|
|
177
|
+
continue;
|
|
178
|
+
try {
|
|
179
|
+
heads.push({ file, source: await readFile(join(repoDir, file), 'utf8') });
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (heads.length === 0)
|
|
186
|
+
return [];
|
|
187
|
+
return resolveUnmanagedCompanions(heads, ownedFiles, async (rel) => {
|
|
188
|
+
try {
|
|
189
|
+
return await readFile(join(repoDir, rel), 'utf8');
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Swaps the generic undefined-identifier hint for the ownership hint when
|
|
198
|
+
* one unmanaged companion explains EVERY undefined name in the file. Any
|
|
199
|
+
* other issue in the list is returned untouched.
|
|
200
|
+
*/
|
|
201
|
+
export function retargetUnmanagedCompanionHints(issues, companions) {
|
|
202
|
+
if (companions.length === 0)
|
|
203
|
+
return [...issues];
|
|
204
|
+
const undefinedIssues = issues.filter((issue) => issue.message.includes(UNDEFINED_IDENTIFIER_HINT));
|
|
205
|
+
if (undefinedIssues.length === 0)
|
|
206
|
+
return [...issues];
|
|
207
|
+
const companion = explainUndefinedIdentifiers(undefinedIssues.map((issue) => issue.message), companions);
|
|
208
|
+
if (!companion)
|
|
209
|
+
return [...issues];
|
|
210
|
+
const hint = formatUnmanagedCompanionHint(companion);
|
|
211
|
+
return issues.map((issue) => issue.message.includes(UNDEFINED_IDENTIFIER_HINT)
|
|
212
|
+
? { ...issue, message: issue.message.replace(UNDEFINED_IDENTIFIER_HINT, hint) }
|
|
213
|
+
: issue);
|
|
214
|
+
}
|
|
@@ -8,11 +8,13 @@ import { lintChromeScriptJsDocForFile } from './patch-lint-chrome-jsdoc.js';
|
|
|
8
8
|
import { lintPatchedCss } from './patch-lint-css.js';
|
|
9
9
|
import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
|
|
10
10
|
import { AGGREGATE_PATCH_FILE } from './patch-lint-diff-tag.js';
|
|
11
|
+
import { formatFileTooLargeMessage, resolveFileSizeThresholds } from './patch-lint-file-size.js';
|
|
11
12
|
import { hasRelativeImport } from './patch-lint-imports.js';
|
|
12
13
|
import { validateExportJsDoc } from './patch-lint-jsdoc.js';
|
|
13
14
|
import { lintMozBuildSortedLists } from './patch-lint-mozbuild.js';
|
|
14
15
|
import { lintObserverTopics } from './patch-lint-observer.js';
|
|
15
16
|
import { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs, resolvePatchOwnedTestScripts, } from './patch-lint-ownership.js';
|
|
17
|
+
import { invokePatchLintPrettier } from './patch-lint-prettier.js';
|
|
16
18
|
// ---------------------------------------------------------------------------
|
|
17
19
|
// Cross-patch lint re-exports
|
|
18
20
|
// ---------------------------------------------------------------------------
|
|
@@ -31,10 +33,6 @@ export { lintPatchedCss };
|
|
|
31
33
|
// Helpers
|
|
32
34
|
// ---------------------------------------------------------------------------
|
|
33
35
|
const JS_EXTENSIONS = ['.js', '.mjs', '.jsm'];
|
|
34
|
-
const FILE_SIZE_THRESHOLDS = {
|
|
35
|
-
general: { notice: 500, warning: 750, error: 900 },
|
|
36
|
-
test: { notice: 1200, warning: 1400, error: 1600 },
|
|
37
|
-
};
|
|
38
36
|
/**
|
|
39
37
|
* Counts the lines of `content` the way `wc -l` reports them: a trailing
|
|
40
38
|
* newline terminates the last line rather than starting an empty extra one.
|
|
@@ -407,31 +405,22 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
|
|
|
407
405
|
if (isNew) {
|
|
408
406
|
const lineCount = countContentLines(content);
|
|
409
407
|
const isTest = isTestFile(file);
|
|
410
|
-
const thresholds =
|
|
408
|
+
const thresholds = resolveFileSizeThresholds(config.patchLint?.fileSizeThresholds, isTest);
|
|
411
409
|
const label = isTest ? 'Test file' : 'New file';
|
|
412
410
|
const verb = isTest ? 'splitting' : 'decomposing';
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
else if (lineCount > thresholds.warning) {
|
|
411
|
+
const band = lineCount > thresholds.error
|
|
412
|
+
? 'error'
|
|
413
|
+
: lineCount > thresholds.warning
|
|
414
|
+
? 'warning'
|
|
415
|
+
: lineCount > thresholds.notice
|
|
416
|
+
? 'notice'
|
|
417
|
+
: undefined;
|
|
418
|
+
if (band !== undefined) {
|
|
422
419
|
issues.push({
|
|
423
420
|
file,
|
|
424
421
|
check: 'file-too-large',
|
|
425
|
-
message:
|
|
426
|
-
severity:
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
else if (lineCount > thresholds.notice) {
|
|
430
|
-
issues.push({
|
|
431
|
-
file,
|
|
432
|
-
check: 'file-too-large',
|
|
433
|
-
message: `${label} has ${lineCount} lines (soft limit: ${thresholds.warning}, hard limit: ${thresholds.error}). Consider ${verb}.`,
|
|
434
|
-
severity: 'notice',
|
|
422
|
+
message: formatFileTooLargeMessage({ label, lineCount, thresholds, band, verb }),
|
|
423
|
+
severity: band,
|
|
435
424
|
});
|
|
436
425
|
}
|
|
437
426
|
}
|
|
@@ -715,6 +704,16 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
|
|
|
715
704
|
}
|
|
716
705
|
}
|
|
717
706
|
}
|
|
707
|
+
// Prettier over patch-owned .sys.mjs. Deliberately NOT part of the
|
|
708
|
+
// precomputedCheckJs short-circuit: it is a separate tool with its own
|
|
709
|
+
// gate, and a queue-wide program has no equivalent to share. Scoped to
|
|
710
|
+
// the files THIS patch owns, run from engine/ so the engine's own config
|
|
711
|
+
// and ignore file decide (see `patch-lint-prettier.ts`).
|
|
712
|
+
const prettierGate = config.patchLint?.prettier ?? 'off';
|
|
713
|
+
if (prettierGate !== 'off' && patchOwnedFiles.size > 0) {
|
|
714
|
+
const scoped = [...patchOwnedFiles].filter((file) => !options?.checkJsReportScope || options.checkJsReportScope.has(file));
|
|
715
|
+
issues.push(...(await invokePatchLintPrettier(repoDir, dirname(repoDir), scoped.sort(), prettierGate)));
|
|
716
|
+
}
|
|
718
717
|
// Filter out ignored checks last so every rule still runs (keeps the
|
|
719
718
|
// implementation uniform) but suppressed rules do not surface. We do not
|
|
720
719
|
// reclassify severities — an ignored error simply drops, mirroring how
|
|
@@ -18,6 +18,27 @@
|
|
|
18
18
|
* binary new-file conflicts as unresolvable rather than auto-resolving.
|
|
19
19
|
*/
|
|
20
20
|
export declare function extractNewFileContentFromDiff(diff: string, targetFile?: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Projects every TEXT new file a diff creates into a path → content map, for
|
|
23
|
+
* the cross-patch lint projections that build a synthetic patch-queue entry.
|
|
24
|
+
*
|
|
25
|
+
* Binary new files are SKIPPED rather than refused. The map feeds only the
|
|
26
|
+
* forward-import rule, which scans content for import statements — a binary
|
|
27
|
+
* blob authors none, so omitting it contributes exactly what including it
|
|
28
|
+
* would. Refusing instead (which is what
|
|
29
|
+
* {@link extractNewFileContentFromDiff} correctly does for a caller that
|
|
30
|
+
* named ONE file) made vendoring a new binary impossible: every projection
|
|
31
|
+
* site fed it every detected new file, so `export --order`, `re-export
|
|
32
|
+
* --scan --scan-file`, `patch move-files-into` and `patch split` all died on
|
|
33
|
+
* a file the export half had just written a valid `GIT binary patch` for.
|
|
34
|
+
* Nothing is lost by skipping: binary bodies are covered independently by the
|
|
35
|
+
* `binary-body-not-reconstructable` queue lint, and
|
|
36
|
+
* `buildPatchQueueContext` already tolerates them for real queue entries.
|
|
37
|
+
*
|
|
38
|
+
* One parse for the whole diff, rather than one per new file as the five
|
|
39
|
+
* call sites previously did.
|
|
40
|
+
*/
|
|
41
|
+
export declare function buildNewFileTextProjection(diff: string): Map<string, string>;
|
|
21
42
|
/**
|
|
22
43
|
* Extracts the complete file content from a "new file" patch.
|
|
23
44
|
* When targetFile is provided, only extracts content for that file
|
|
@@ -27,6 +27,15 @@ export function extractNewFileContentFromDiff(diff, targetFile) {
|
|
|
27
27
|
if (binary) {
|
|
28
28
|
throw new PatchError(`Cannot extract text content from binary patch section for ${binary.targetPath}`, binary.targetPath);
|
|
29
29
|
}
|
|
30
|
+
return contentFromSections(sections);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Walks the added (`+`) lines of already-parsed sections into file content.
|
|
34
|
+
* Shared by {@link extractNewFileContentFromDiff} and
|
|
35
|
+
* {@link buildNewFileTextProjection} so the two cannot drift on the empty-file
|
|
36
|
+
* and no-trailing-newline cases below.
|
|
37
|
+
*/
|
|
38
|
+
function contentFromSections(sections) {
|
|
30
39
|
// An empty new file is a legitimate git diff: `new file mode` with no
|
|
31
40
|
// hunks at all. It must extract as '' — the historical line-walker
|
|
32
41
|
// returned '\n' for it, creating a one-byte file that failed checksum
|
|
@@ -51,6 +60,35 @@ export function extractNewFileContentFromDiff(diff, targetFile) {
|
|
|
51
60
|
const result = contentLines.join('\n');
|
|
52
61
|
return hasNoNewlineMarker ? result : result + '\n';
|
|
53
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Projects every TEXT new file a diff creates into a path → content map, for
|
|
65
|
+
* the cross-patch lint projections that build a synthetic patch-queue entry.
|
|
66
|
+
*
|
|
67
|
+
* Binary new files are SKIPPED rather than refused. The map feeds only the
|
|
68
|
+
* forward-import rule, which scans content for import statements — a binary
|
|
69
|
+
* blob authors none, so omitting it contributes exactly what including it
|
|
70
|
+
* would. Refusing instead (which is what
|
|
71
|
+
* {@link extractNewFileContentFromDiff} correctly does for a caller that
|
|
72
|
+
* named ONE file) made vendoring a new binary impossible: every projection
|
|
73
|
+
* site fed it every detected new file, so `export --order`, `re-export
|
|
74
|
+
* --scan --scan-file`, `patch move-files-into` and `patch split` all died on
|
|
75
|
+
* a file the export half had just written a valid `GIT binary patch` for.
|
|
76
|
+
* Nothing is lost by skipping: binary bodies are covered independently by the
|
|
77
|
+
* `binary-body-not-reconstructable` queue lint, and
|
|
78
|
+
* `buildPatchQueueContext` already tolerates them for real queue entries.
|
|
79
|
+
*
|
|
80
|
+
* One parse for the whole diff, rather than one per new file as the five
|
|
81
|
+
* call sites previously did.
|
|
82
|
+
*/
|
|
83
|
+
export function buildNewFileTextProjection(diff) {
|
|
84
|
+
const newFiles = new Map();
|
|
85
|
+
for (const section of parseDiffSections(diff)) {
|
|
86
|
+
if (!section.isNewFile || section.isBinary)
|
|
87
|
+
continue;
|
|
88
|
+
newFiles.set(section.targetPath, contentFromSections([section]));
|
|
89
|
+
}
|
|
90
|
+
return newFiles;
|
|
91
|
+
}
|
|
54
92
|
/**
|
|
55
93
|
* Extracts the complete file content from a "new file" patch.
|
|
56
94
|
* When targetFile is provided, only extracts content for that file
|
|
@@ -29,6 +29,18 @@ export const SMOKE_ERROR_PATTERNS = [
|
|
|
29
29
|
/^\s*\[JavaScript (Error|Warning)\]/i,
|
|
30
30
|
// IPC-layer fatal assertions — Firefox prints `###!!! [Parent] Error: …` on content-process crashes.
|
|
31
31
|
/^\s*###!!! \[Parent\]/,
|
|
32
|
+
// A chrome:// or resource:// URL that resolves to nothing. Outside
|
|
33
|
+
// automation Gecko only PRINTS this (printf_stderr in
|
|
34
|
+
// `CheckForBrokenChromeURL`, netwerk/base/nsNetUtil.cpp); under
|
|
35
|
+
// `xpc::IsInAutomation()` the same condition is a
|
|
36
|
+
// `MOZ_CRASH_UNSAFE_PRINTF` in whichever process opened the channel. So
|
|
37
|
+
// the smoke probe — which runs OUTSIDE automation — sees the printed
|
|
38
|
+
// line for the same defect that hard-crashes every harness run, and a
|
|
39
|
+
// probe that exits 0 on it reports "startup is healthy" for a build that
|
|
40
|
+
// hangs at "Waiting for browser…". Both spellings are matched: the
|
|
41
|
+
// non-automation singular `URL:` and the automation plural `URLs:`.
|
|
42
|
+
// Allowlistable through `--console-allow` like any other pattern.
|
|
43
|
+
/^\s*Missing chrome or resource URLs?:/,
|
|
32
44
|
];
|
|
33
45
|
/**
|
|
34
46
|
* Returns `true` when `line` matches any pattern in
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* reporting phantom test failures (or phantom passes).
|
|
24
24
|
*/
|
|
25
25
|
import { hasKnownTeardownNoise } from './mach-known-noise-filter.js';
|
|
26
|
+
import { truncateEvidence, unmarkedFailureEvidenceNote, } from './test-harness-verdict-notes.js';
|
|
26
27
|
// The no-output-stall triage text lives in `test-stall-triage.ts` (it is
|
|
27
28
|
// operator advice, not classification) and is re-exported here so the
|
|
28
29
|
// command layer keeps importing its harness diagnostics from one module.
|
|
@@ -487,12 +488,22 @@ export function classifyHarnessRun(exitCode, output, requestedPaths) {
|
|
|
487
488
|
// shutdown-re-entry shape, or a requested file that never started keeps
|
|
488
489
|
// the run failing. Only the missing shutdown marker is forgiven, and only
|
|
489
490
|
// when the recognized teardown traceback explains it.
|
|
490
|
-
|
|
491
|
+
const teardownOverride = evaluateGreenTeardownOverride(output, counts, realFailures, requestedPaths);
|
|
492
|
+
if (teardownOverride.accepted) {
|
|
491
493
|
return { kind: 'tests-ran-ok', note: 'harness teardown noise ignored', ...counts };
|
|
492
494
|
}
|
|
495
|
+
// A run that carried the recognized teardown noise and still failed says
|
|
496
|
+
// WHICH condition rejected the override, on the one greppable line.
|
|
497
|
+
const teardownNote = teardownOverride.rejectedBy !== undefined
|
|
498
|
+
? `green-teardown override rejected: ${teardownOverride.rejectedBy}`
|
|
499
|
+
: undefined;
|
|
500
|
+
const withNotes = (verdict, extra) => {
|
|
501
|
+
const notes = [teardownNote, extra].filter((n) => n !== undefined);
|
|
502
|
+
return notes.length > 0 ? { ...verdict, note: notes.join('; ') } : verdict;
|
|
503
|
+
};
|
|
493
504
|
const signature = detectHarnessCrashSignature(output);
|
|
494
505
|
if (signature) {
|
|
495
|
-
return { kind: 'harness-crash', signature, ...counts };
|
|
506
|
+
return withNotes({ kind: 'harness-crash', signature, ...counts });
|
|
496
507
|
}
|
|
497
508
|
const ranTests = hasExecutionSignal(output);
|
|
498
509
|
if (!ranTests && requestedPaths.length > 0) {
|
|
@@ -518,7 +529,7 @@ export function classifyHarnessRun(exitCode, output, requestedPaths) {
|
|
|
518
529
|
if (crashLine !== undefined ||
|
|
519
530
|
completeness.neverStarted.length > 0 ||
|
|
520
531
|
completeness.neverEnded.length > 0) {
|
|
521
|
-
return {
|
|
532
|
+
return withNotes({
|
|
522
533
|
kind: 'test-failures',
|
|
523
534
|
...counts,
|
|
524
535
|
...(firstRealFailure !== undefined ? { realFailureLine: firstRealFailure } : {}),
|
|
@@ -529,43 +540,18 @@ export function classifyHarnessRun(exitCode, output, requestedPaths) {
|
|
|
529
540
|
neverStarted: completeness.neverStarted,
|
|
530
541
|
neverEnded: completeness.neverEnded,
|
|
531
542
|
},
|
|
532
|
-
};
|
|
543
|
+
});
|
|
533
544
|
}
|
|
534
545
|
return { kind: 'tests-ran-ok', greenSummaryOverride: true, ...counts };
|
|
535
546
|
}
|
|
536
547
|
const wordMatchNote = unmarkedFailureEvidenceNote(counts, realFailures);
|
|
537
|
-
return {
|
|
548
|
+
return withNotes({
|
|
538
549
|
kind: 'test-failures',
|
|
539
550
|
...counts,
|
|
540
551
|
...(firstRealFailure !== undefined ? { realFailureLine: firstRealFailure } : {}),
|
|
541
552
|
...(failureBlocks.length > 0 ? { realFailureBlocks: failureBlocks } : {}),
|
|
542
553
|
...(secondaryHarnessSignature !== undefined ? { secondaryHarnessSignature } : {}),
|
|
543
|
-
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
/**
|
|
547
|
-
* Belt for the residual case the tightened {@link FAIL_LINE_PATTERN} /
|
|
548
|
-
* {@link ASSERTION_LINE_PATTERN} cannot reach: the summary reported
|
|
549
|
-
* `Unexpected results: 0`, yet the run is still classified `test-failures`
|
|
550
|
-
* on evidence lines that carry NO `TEST-UNEXPECTED-*` marker.
|
|
551
|
-
*
|
|
552
|
-
* `unexpected=0` printed beside `reason=test-failures` is the tell that the
|
|
553
|
-
* verdict rests on pattern matching rather than on a harness result, and a
|
|
554
|
-
* reader who cannot see that spends hours attributing a red run to the
|
|
555
|
-
* change under test. The note says so on the verdict line itself.
|
|
556
|
-
*
|
|
557
|
-
* Deliberately a NOTE and not a reclassification: a non-zero exit with no
|
|
558
|
-
* green-shaped summary is still a failing run, and inventing a new `reason`
|
|
559
|
-
* would break the `FIREFORGE-VERDICT:` contract its consumers parse.
|
|
560
|
-
*/
|
|
561
|
-
function unmarkedFailureEvidenceNote(counts, realFailures) {
|
|
562
|
-
if (counts.unexpected !== 0)
|
|
563
|
-
return undefined;
|
|
564
|
-
if (realFailures.length === 0)
|
|
565
|
-
return undefined;
|
|
566
|
-
if (realFailures.some((line) => UNEXPECTED_LINE_SINGLE.test(line)))
|
|
567
|
-
return undefined;
|
|
568
|
-
return 'summary reported 0 unexpected; no TEST-UNEXPECTED marker in the matched evidence';
|
|
554
|
+
}, wordMatchNote);
|
|
569
555
|
}
|
|
570
556
|
/**
|
|
571
557
|
* True when the capture shows a substantively GREEN suite whose only
|
|
@@ -577,29 +563,56 @@ function unmarkedFailureEvidenceNote(counts, realFailures) {
|
|
|
577
563
|
* unexpected count anywhere; no real `TEST-UNEXPECTED-*`/assertion line;
|
|
578
564
|
* no crash marker; every requested test file both started and ended; and
|
|
579
565
|
* the recognized teardown traceback is present. Anything short of that
|
|
580
|
-
* falls through to the normal (failing) chain
|
|
566
|
+
* falls through to the normal (failing) chain — with the failing condition
|
|
567
|
+
* NAMED, so the next occurrence is a filed bug rather than another
|
|
568
|
+
* undiagnosable re-run.
|
|
569
|
+
*
|
|
570
|
+
* The conditions are evaluated in a fixed order and the FIRST failing one
|
|
571
|
+
* is reported; a run can violate several, and reporting one deterministic
|
|
572
|
+
* condition beats an unordered list of everything that happened to be true.
|
|
581
573
|
*/
|
|
582
|
-
function
|
|
574
|
+
function evaluateGreenTeardownOverride(output, counts, realFailures, requestedPaths) {
|
|
583
575
|
if (!hasKnownTeardownNoise(output))
|
|
584
|
-
return false;
|
|
576
|
+
return { accepted: false };
|
|
577
|
+
const reject = (rejectedBy) => ({ accepted: false, rejectedBy });
|
|
585
578
|
if (!hasExecutionSignal(output))
|
|
586
|
-
return
|
|
587
|
-
if (counts.checks === undefined
|
|
588
|
-
return
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
if (
|
|
596
|
-
return
|
|
579
|
+
return reject('no execution signal (no TEST-START/TEST_START)');
|
|
580
|
+
if (counts.checks === undefined)
|
|
581
|
+
return reject('summary printed no "Ran N checks" line');
|
|
582
|
+
// `counts.unexpected === 0` IS the explicit-`Unexpected results: 0` test
|
|
583
|
+
// the previous revision spelled a second time with
|
|
584
|
+
// GREEN_UNEXPECTED_SUMMARY_PATTERN: the count is parsed from the same
|
|
585
|
+
// line and an absent line leaves it undefined. The two are kept apart in
|
|
586
|
+
// the message because "printed no such line" and "printed a non-zero one"
|
|
587
|
+
// are different findings for the reader.
|
|
588
|
+
if (counts.unexpected === undefined) {
|
|
589
|
+
return reject('summary printed no "Unexpected results:" line');
|
|
590
|
+
}
|
|
591
|
+
if (counts.unexpected !== 0) {
|
|
592
|
+
return reject(`summary reported unexpected=${String(counts.unexpected)}`);
|
|
593
|
+
}
|
|
594
|
+
if (NONZERO_UNEXPECTED_SUMMARY_PATTERN.test(output)) {
|
|
595
|
+
return reject('a non-zero "Unexpected results:" line is present somewhere in the output');
|
|
596
|
+
}
|
|
597
|
+
if (realFailures.length > 0) {
|
|
598
|
+
return reject(`${String(realFailures.length)} matched failure line(s), first: ${truncateEvidence(realFailures[0] ?? '')}`);
|
|
599
|
+
}
|
|
600
|
+
const crashLine = findCrashMarkerLine(output);
|
|
601
|
+
if (crashLine !== undefined)
|
|
602
|
+
return reject(`crash marker present: ${truncateEvidence(crashLine)}`);
|
|
597
603
|
// The post-green shutdown re-entry shape is deliberately a crash verdict
|
|
598
604
|
// on an otherwise-green log; it must not be swept up here.
|
|
599
|
-
if (SHUTDOWN_REENTRY_PATTERN.test(output))
|
|
600
|
-
return
|
|
605
|
+
if (SHUTDOWN_REENTRY_PATTERN.test(output)) {
|
|
606
|
+
return reject('post-green shutdown re-entry shape present');
|
|
607
|
+
}
|
|
601
608
|
const completeness = analyzeTestCompleteness(output, requestedPaths);
|
|
602
|
-
|
|
609
|
+
if (completeness.neverStarted.length > 0) {
|
|
610
|
+
return reject(`requested file(s) never started: ${completeness.neverStarted.join(', ')}`);
|
|
611
|
+
}
|
|
612
|
+
if (completeness.neverEnded.length > 0) {
|
|
613
|
+
return reject(`requested file(s) never ended: ${completeness.neverEnded.join(', ')}`);
|
|
614
|
+
}
|
|
615
|
+
return { accepted: true };
|
|
603
616
|
}
|
|
604
617
|
/**
|
|
605
618
|
* Parses the numeric counts from the harness's embedded result summary
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verdict-note helpers for the harness classifier.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `test-harness-crash.ts` (at its per-file line budget). These
|
|
5
|
+
* decide the ADVISORY parenthetical on the `FIREFORGE-VERDICT:` line; none
|
|
6
|
+
* of them changes a status or a reason key, which the verdict contract's
|
|
7
|
+
* consumers parse.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The only part of the classifier's summary counts these notes read.
|
|
11
|
+
* Structural rather than an import of `HarnessSummaryCounts`: a type edge
|
|
12
|
+
* back into `test-harness-crash.ts` is still an edge, and the cycle gate
|
|
13
|
+
* rejects it.
|
|
14
|
+
*/
|
|
15
|
+
interface SummaryCountsView {
|
|
16
|
+
unexpected?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Belt for the residual case the tightened {@link FAIL_LINE_PATTERN} /
|
|
20
|
+
* {@link ASSERTION_LINE_PATTERN} cannot reach: the summary reported
|
|
21
|
+
* `Unexpected results: 0`, yet the run is still classified `test-failures`
|
|
22
|
+
* on evidence lines that carry NO `TEST-UNEXPECTED-*` marker.
|
|
23
|
+
*
|
|
24
|
+
* `unexpected=0` printed beside `reason=test-failures` is the tell that the
|
|
25
|
+
* verdict rests on pattern matching rather than on a harness result, and a
|
|
26
|
+
* reader who cannot see that spends hours attributing a red run to the
|
|
27
|
+
* change under test. The note says so on the verdict line itself.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately a NOTE and not a reclassification: a non-zero exit with no
|
|
30
|
+
* green-shaped summary is still a failing run, and inventing a new `reason`
|
|
31
|
+
* would break the `FIREFORGE-VERDICT:` contract its consumers parse.
|
|
32
|
+
*/
|
|
33
|
+
export declare function unmarkedFailureEvidenceNote(counts: SummaryCountsView, realFailures: readonly string[]): string | undefined;
|
|
34
|
+
/** Caps an evidence line so the one-line verdict note stays one line. */
|
|
35
|
+
export declare function truncateEvidence(line: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Outcome of the green-teardown belt.
|
|
38
|
+
*
|
|
39
|
+
* `accepted` is the pass. A rejection carries `rejectedBy` ONLY when the
|
|
40
|
+
* recognized teardown noise was present — i.e. when the belt was the thing
|
|
41
|
+
* that could have applied and did not. That distinction is the whole point
|
|
42
|
+
* of the field: a run with no teardown traceback was never a candidate and
|
|
43
|
+
* naming a "rejecting condition" for it would be noise, whereas a run that
|
|
44
|
+
* DOES carry the traceback and still fails is the shape an operator
|
|
45
|
+
* otherwise cannot diagnose from outside — every condition is
|
|
46
|
+
* all-or-nothing, so the failing verdict looks identical whichever one
|
|
47
|
+
* rejected it, and a downstream report spent a re-run and a filed-bug's
|
|
48
|
+
* worth of effort guessing between them.
|
|
49
|
+
*/
|
|
50
|
+
export interface GreenTeardownOverride {
|
|
51
|
+
accepted: boolean;
|
|
52
|
+
/** Which single condition rejected, when the teardown noise was present. */
|
|
53
|
+
rejectedBy?: string;
|
|
54
|
+
}
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Verdict-note helpers for the harness classifier.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `test-harness-crash.ts` (at its per-file line budget). These
|
|
6
|
+
* decide the ADVISORY parenthetical on the `FIREFORGE-VERDICT:` line; none
|
|
7
|
+
* of them changes a status or a reason key, which the verdict contract's
|
|
8
|
+
* consumers parse.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* A single `TEST-UNEXPECTED-*` marker. Local copy of the classifier's line
|
|
12
|
+
* pattern: importing the value would draw a runtime edge back into
|
|
13
|
+
* `test-harness-crash.ts` and make the split circular.
|
|
14
|
+
*/
|
|
15
|
+
const UNEXPECTED_MARKER = /TEST-UNEXPECTED-\w+/;
|
|
16
|
+
/**
|
|
17
|
+
* Belt for the residual case the tightened {@link FAIL_LINE_PATTERN} /
|
|
18
|
+
* {@link ASSERTION_LINE_PATTERN} cannot reach: the summary reported
|
|
19
|
+
* `Unexpected results: 0`, yet the run is still classified `test-failures`
|
|
20
|
+
* on evidence lines that carry NO `TEST-UNEXPECTED-*` marker.
|
|
21
|
+
*
|
|
22
|
+
* `unexpected=0` printed beside `reason=test-failures` is the tell that the
|
|
23
|
+
* verdict rests on pattern matching rather than on a harness result, and a
|
|
24
|
+
* reader who cannot see that spends hours attributing a red run to the
|
|
25
|
+
* change under test. The note says so on the verdict line itself.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately a NOTE and not a reclassification: a non-zero exit with no
|
|
28
|
+
* green-shaped summary is still a failing run, and inventing a new `reason`
|
|
29
|
+
* would break the `FIREFORGE-VERDICT:` contract its consumers parse.
|
|
30
|
+
*/
|
|
31
|
+
export function unmarkedFailureEvidenceNote(counts, realFailures) {
|
|
32
|
+
if (counts.unexpected !== 0)
|
|
33
|
+
return undefined;
|
|
34
|
+
if (realFailures.length === 0)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (realFailures.some((line) => UNEXPECTED_MARKER.test(line)))
|
|
37
|
+
return undefined;
|
|
38
|
+
return 'summary reported 0 unexpected; no TEST-UNEXPECTED marker in the matched evidence';
|
|
39
|
+
}
|
|
40
|
+
/** Caps an evidence line so the one-line verdict note stays one line. */
|
|
41
|
+
export function truncateEvidence(line) {
|
|
42
|
+
const flat = line.trim().replace(/\s+/g, ' ');
|
|
43
|
+
return flat.length <= GREEN_TEARDOWN_EVIDENCE_LIMIT
|
|
44
|
+
? flat
|
|
45
|
+
: `${flat.slice(0, GREEN_TEARDOWN_EVIDENCE_LIMIT)}…`;
|
|
46
|
+
}
|
|
47
|
+
/** Character budget for an evidence excerpt inside the verdict note. */
|
|
48
|
+
const GREEN_TEARDOWN_EVIDENCE_LIMIT = 80;
|