dsh-autorestart 0.8.4 → 0.8.6
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 +313 -0
- package/README.md +465 -40
- package/assets/icon-9c245e5b.ico +0 -0
- package/assets/icon-rescue-1902bb15.ico +0 -0
- package/assets/icon-rescue-1902bb15.png +0 -0
- package/client.js +3002 -170
- package/cordis.patch.yml +12 -0
- package/docs/README.zh.md +231 -23
- package/docs/platform-matrix.md +171 -55
- package/docs/real-machine-verification-0.8.6.md +202 -0
- package/index.js +13272 -8513
- package/lib/autostart-advice.js +31 -2
- package/lib/backup.js +2768 -21
- package/lib/checkpoint.js +214 -7
- package/lib/cookie-clear.js +260 -0
- package/lib/lifecycle.js +23 -40
- package/lib/plugin-compare.js +499 -0
- package/lib/plugin-failure.js +382 -0
- package/lib/plugin-recover.js +1557 -0
- package/lib/plugin-watch.js +213 -0
- package/lib/rescue-paths.js +982 -56
- package/lib/restart-phase.js +5 -0
- package/lib/restart-verdict.js +379 -0
- package/lib/restart.js +244 -7
- package/lib/scheduler-capability.js +19 -3
- package/lib/sleep.js +190 -29
- package/lib/watchdog-disarm.js +46 -2
- package/lib/watchdog-schedule.js +834 -17
- package/lib/watchdog-tier.js +8 -1
- package/lib/watchdog.js +1222 -47
- package/locale/en.json +175 -68
- package/locale/zh.json +175 -68
- package/package.json +3 -2
- package/screenshots.json +6 -6
- package/scripts/README.md +6 -3
- package/scripts/build-icon.mjs +998 -0
- package/scripts/clear-dsh-auth-cookies.mjs +257 -0
- package/scripts/emergency-disable.sh +69 -2
- package/scripts/emergency-toggle.ps1 +85 -69
- package/scripts/rescue-strings.zh.json +36 -18
- package/scripts/restore-profile.ps1 +182 -1
- package/scripts/watchdog.mjs +432 -79
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — "which plugin did not load?" (the REPORTED kind of failure).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS MODULE EXISTS. A plugin that fails to import does not always keep the
|
|
5
|
+
* host from booting: the host comes up, the entry silently never activates, and
|
|
6
|
+
* the feature behind it simply 404s. The startup log says so, in a handful of
|
|
7
|
+
* lines. This module turns that text into structured failures; nothing else.
|
|
8
|
+
*
|
|
9
|
+
* EVERYTHING HERE IS PURE: text in, array out. No file system, no DSH, no npm.
|
|
10
|
+
* The caller decides which log to read (lib/plugin-recover.js owns the file
|
|
11
|
+
* discovery) and what to do about the answer.
|
|
12
|
+
*
|
|
13
|
+
* THE FOUR LINE SHAPES THIS RECOGNISES (all four were seen in real startup logs
|
|
14
|
+
* and in the diagnostic dump of a boot audit):
|
|
15
|
+
*
|
|
16
|
+
* 1. `dsh: warning: 1 entry did not activate` — the aggregate warning
|
|
17
|
+
* 2. `<plugin>: failed to import: <error>` — the import error itself
|
|
18
|
+
* 3. `<plugin>: import failed ...` — the same, other wording
|
|
19
|
+
* 4. `- <plugin>: loading` — an entry still loading
|
|
20
|
+
* when the audit ran, i.e.
|
|
21
|
+
* it never activated
|
|
22
|
+
*
|
|
23
|
+
* EVERYTHING ELSE IS IGNORED, on purpose. In particular the process-level dump
|
|
24
|
+
* (`dsh: startup failed: 2 required plugins did not activate` followed by the
|
|
25
|
+
* `Failed plugins (1):` block with `Package:` / `Error:` lines) is NOT this
|
|
26
|
+
* module's business: a boot that does not come up is the existing watchdog's
|
|
27
|
+
* failure, not a plugin to be swapped out. Recognising it here would make the
|
|
28
|
+
* two mechanisms fight over the same incident.
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-autorestart/lib/plugin-failure
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import crypto from 'node:crypto';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How much of one error message is kept.
|
|
37
|
+
*
|
|
38
|
+
* A stack trace of thirty frames is useless on a card and unreadable in a JSON
|
|
39
|
+
* report; the first line plus a little context is what tells a user what broke.
|
|
40
|
+
*/
|
|
41
|
+
export const MAX_ERROR_CHARS = 240;
|
|
42
|
+
|
|
43
|
+
/** How long the raw log line is kept for the report. */
|
|
44
|
+
export const MAX_TEXT_CHARS = 400;
|
|
45
|
+
|
|
46
|
+
/** The kinds this parser distinguishes (they end up in the report). */
|
|
47
|
+
export const FAILURE_KINDS = Object.freeze({
|
|
48
|
+
/** `<plugin>: failed to import: <error>` */
|
|
49
|
+
FAILED_TO_IMPORT: 'failed-to-import',
|
|
50
|
+
/** `<plugin>: import failed ...` */
|
|
51
|
+
IMPORT_FAILED: 'import-failed',
|
|
52
|
+
/** `- <plugin>: loading` (never activated) */
|
|
53
|
+
NOT_ACTIVATED: 'not-activated',
|
|
54
|
+
/** `dsh: warning: N entry did not activate` (names no plugin) */
|
|
55
|
+
WARNING: 'warning',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The aggregate warning line: `dsh: warning: 1 entry did not activate`.
|
|
60
|
+
*
|
|
61
|
+
* It names no plugin, so it cannot be recovered on its own — but it is the line
|
|
62
|
+
* that says "look at the lines around me", and dropping it would make a report
|
|
63
|
+
* say "no failure found" next to the host's own warning.
|
|
64
|
+
*
|
|
65
|
+
* @type {RegExp}
|
|
66
|
+
*/
|
|
67
|
+
const WARNING_LINE = /^(?:dsh:\s*)?(?:warning:\s*)?(\d+)\s+entr(?:y|ies)\s+(?:did not activate|failed to activate|was not activated)\b/i;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `<plugin>: failed to import: <error>` / `<plugin>: import failed: <error>`.
|
|
71
|
+
*
|
|
72
|
+
* The optional `- ` bullet is what the audit's list uses, and the optional
|
|
73
|
+
* `(required)` / `(optional)` suffix is what the process-level dump uses.
|
|
74
|
+
*
|
|
75
|
+
* @type {RegExp}
|
|
76
|
+
*/
|
|
77
|
+
const IMPORT_FAILED_LINE = /^(?:dsh:\s*)?[-*]?\s*([@A-Za-z0-9_][\w@./-]*)\s*(?:\((?:required|optional)\))?\s*:\s*(failed to import|import failed)\b\s*:?\s*(.*)$/i;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `- <plugin>: loading` — the entry was still loading when the audit ran.
|
|
81
|
+
*
|
|
82
|
+
* A plugin in this state did not activate; the line is a failure even though it
|
|
83
|
+
* carries no error message. The match is anchored at the end of the line on
|
|
84
|
+
* purpose: `dsh: loading profile web` is the host talking about its own work,
|
|
85
|
+
* not a plugin that never activated.
|
|
86
|
+
*
|
|
87
|
+
* @type {RegExp}
|
|
88
|
+
*/
|
|
89
|
+
const LOADING_LINE = /^(?:dsh:\s*)?[-*]?\s*([@A-Za-z0-9_][\w@./-]*)\s*(?:\((?:required|optional)\))?\s*:\s*loading\b\s*$/i;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Names that belong to the HOST, never to a plugin.
|
|
93
|
+
*
|
|
94
|
+
* `dsh: <something>` is the host's own prefix; treating `dsh` as a plugin name
|
|
95
|
+
* would try to restore the harness itself.
|
|
96
|
+
*
|
|
97
|
+
* @type {Set<string>}
|
|
98
|
+
*/
|
|
99
|
+
const RESERVED_NAMES = new Set(['dsh', 'harness', 'node', 'npm']);
|
|
100
|
+
|
|
101
|
+
/** An ISO timestamp at the start of a line (the host's own log prefix). */
|
|
102
|
+
const ISO_PREFIX = /^\[?(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(Z|[+-]\d{2}:?\d{2})?\]?/;
|
|
103
|
+
|
|
104
|
+
/** A bare clock at the start of a line: `[15:33:37]`. */
|
|
105
|
+
const CLOCK_PREFIX = /^\[(\d{2}):(\d{2}):(\d{2})\]/;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The timestamp prefix of a log line, as it is STRIPPED before matching.
|
|
109
|
+
*
|
|
110
|
+
* A real startup log prefixes each line with its time; the patterns below only
|
|
111
|
+
* describe the message, so the timestamp is removed first (and read separately
|
|
112
|
+
* by {@link parseLogLineTime}).
|
|
113
|
+
*
|
|
114
|
+
* @type {RegExp}
|
|
115
|
+
*/
|
|
116
|
+
const TIMESTAMP_PREFIX = /^\[?\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})?\]?[ \t]*/;
|
|
117
|
+
|
|
118
|
+
/** A bracketed clock prefix: `[15:33:37] `. */
|
|
119
|
+
const CLOCK_ONLY_PREFIX = /^\[\d{2}:\d{2}:\d{2}\][ \t]*/;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Drop the timestamp a log line starts with (and any `dsh: ` host prefix), so
|
|
123
|
+
* the message patterns can be anchored at the message itself.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} line
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
function messageOf(line) {
|
|
129
|
+
const text = typeof line === 'string' ? line.trim() : '';
|
|
130
|
+
return text.replace(TIMESTAMP_PREFIX, '').replace(CLOCK_ONLY_PREFIX, '').trim();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Trim one value to a bounded, single-line string.
|
|
135
|
+
*
|
|
136
|
+
* Control characters are dropped (a log line may carry an ANSI colour code, and
|
|
137
|
+
* a JSON report with escape sequences in it is unreadable), whitespace is
|
|
138
|
+
* collapsed, and an over-long value is cut with an ellipsis so the caller can
|
|
139
|
+
* tell "short" from "shortened".
|
|
140
|
+
*
|
|
141
|
+
* @param {unknown} value
|
|
142
|
+
* @param {number} [max]
|
|
143
|
+
* @returns {string}
|
|
144
|
+
*/
|
|
145
|
+
export function clipText(value, max = MAX_ERROR_CHARS) {
|
|
146
|
+
const text = typeof value === 'string' ? value : (value === null || value === undefined ? '' : String(value));
|
|
147
|
+
const limit = Number.isFinite(Number(max)) && Number(max) > 0 ? Math.trunc(Number(max)) : MAX_ERROR_CHARS;
|
|
148
|
+
// eslint-disable-next-line no-control-regex
|
|
149
|
+
const flat = text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').replace(/\s+/g, ' ').trim();
|
|
150
|
+
if (flat.length <= limit) return flat;
|
|
151
|
+
return flat.slice(0, Math.max(0, limit - 1)) + '…';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The FIRST line of an error message — the part that says what broke.
|
|
156
|
+
*
|
|
157
|
+
* @param {unknown} value
|
|
158
|
+
* @returns {string}
|
|
159
|
+
*/
|
|
160
|
+
export function errorFirstLine(value) {
|
|
161
|
+
const text = typeof value === 'string' ? value : (value === null || value === undefined ? '' : String(value));
|
|
162
|
+
const first = text.split(/\r?\n/).find((line) => line.trim() !== '') || '';
|
|
163
|
+
return clipText(first, MAX_ERROR_CHARS);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The time a log line carries, in milliseconds (null when it carries none).
|
|
168
|
+
*
|
|
169
|
+
* Two prefixes are recognised because two forms are common: the host's ISO
|
|
170
|
+
* prefix and a bracketed clock. A bare clock has no date, so TODAY's date (taken
|
|
171
|
+
* from `now`) is used — it is only ever compared against other lines of the same
|
|
172
|
+
* file, so the day boundary is the only inaccuracy, and a log does not span days.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} line
|
|
175
|
+
* @param {number} [now]
|
|
176
|
+
* @returns {number|null}
|
|
177
|
+
*/
|
|
178
|
+
export function parseLogLineTime(line, now = Date.now()) {
|
|
179
|
+
const text = typeof line === 'string' ? line : '';
|
|
180
|
+
const iso = ISO_PREFIX.exec(text);
|
|
181
|
+
if (iso) {
|
|
182
|
+
const [, year, month, day, hour, minute, second, fraction = '', zone = 'Z'] = iso;
|
|
183
|
+
const millis = fraction === '' ? '000' : (fraction + '000').slice(0, 3);
|
|
184
|
+
const normalizedZone = zone === 'Z' || zone === undefined
|
|
185
|
+
? 'Z'
|
|
186
|
+
: (zone.includes(':') ? zone : zone.slice(0, 3) + ':' + zone.slice(3));
|
|
187
|
+
const stamp = Date.parse(`${year}-${month}-${day}T${hour}:${minute}:${second}.${millis}${normalizedZone}`);
|
|
188
|
+
if (Number.isFinite(stamp)) return stamp;
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const clock = CLOCK_PREFIX.exec(text);
|
|
192
|
+
if (clock) {
|
|
193
|
+
const base = new Date(Number.isFinite(Number(now)) ? Number(now) : Date.now());
|
|
194
|
+
base.setHours(Number(clock[1]), Number(clock[2]), Number(clock[3]), 0);
|
|
195
|
+
const stamp = base.getTime();
|
|
196
|
+
return Number.isFinite(stamp) ? stamp : null;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* THE FINGERPRINT of one failure: "the same failure seen twice".
|
|
203
|
+
*
|
|
204
|
+
* Built from the plugin, the FIRST LINE of the error and the log time (rounded
|
|
205
|
+
* to the second, so a re-read of the same file produces the same value). It is
|
|
206
|
+
* what makes "this exact failure was already handled" decidable across boots:
|
|
207
|
+
* the same old log line read again next boot yields the same fingerprint, so it
|
|
208
|
+
* is NOT treated as a new incident and does not trigger another restore.
|
|
209
|
+
*
|
|
210
|
+
* The error TEXT is deliberately reduced to its first line: stack traces differ
|
|
211
|
+
* between runs (line numbers, addresses), the message does not.
|
|
212
|
+
*
|
|
213
|
+
* @param {{ plugin?: string|null, error?: string|null, at?: number|null }} failure
|
|
214
|
+
* @returns {string}
|
|
215
|
+
*/
|
|
216
|
+
export function failureFingerprint(failure = {}) {
|
|
217
|
+
const plugin = typeof failure.plugin === 'string' ? failure.plugin : '';
|
|
218
|
+
const error = errorFirstLine(failure.error);
|
|
219
|
+
const at = Number.isFinite(Number(failure.at)) ? String(Math.floor(Number(failure.at) / 1000)) : '';
|
|
220
|
+
return crypto.createHash('sha1').update([plugin, error, at].join('\u0000')).digest('hex').slice(0, 16);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Recognise the plugin failures of one startup log.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} text the log text
|
|
227
|
+
* @param {{
|
|
228
|
+
* sinceMs?: number|null,
|
|
229
|
+
* logAt?: number|null,
|
|
230
|
+
* now?: number,
|
|
231
|
+
* dedupe?: boolean,
|
|
232
|
+
* }} [options]
|
|
233
|
+
* `sinceMs` — lines stamped EARLIER than this are not new information and are
|
|
234
|
+
* dropped (the caller passes the start of the boot the log belongs to). An
|
|
235
|
+
* entry whose time cannot be determined is KEPT: throwing it away would hide
|
|
236
|
+
* the only record of the failure.
|
|
237
|
+
* `logAt` — the fallback time for a line without a timestamp (normally the
|
|
238
|
+
* log file's mtime).
|
|
239
|
+
* `dedupe` — drop repeated fingerprints inside the same text (default true).
|
|
240
|
+
* @returns {Array<{
|
|
241
|
+
* plugin: string|null,
|
|
242
|
+
* error: string,
|
|
243
|
+
* line: number,
|
|
244
|
+
* text: string,
|
|
245
|
+
* at: number|null,
|
|
246
|
+
* kind: string,
|
|
247
|
+
* fingerprint: string,
|
|
248
|
+
* }>}
|
|
249
|
+
*/
|
|
250
|
+
export function findPluginFailures(text, options = {}) {
|
|
251
|
+
const source = typeof text === 'string' ? text : '';
|
|
252
|
+
if (source.trim() === '') return [];
|
|
253
|
+
const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now();
|
|
254
|
+
const sinceMs = Number.isFinite(Number(options.sinceMs)) ? Number(options.sinceMs) : null;
|
|
255
|
+
const logAt = Number.isFinite(Number(options.logAt)) ? Number(options.logAt) : null;
|
|
256
|
+
const dedupe = options.dedupe !== false;
|
|
257
|
+
|
|
258
|
+
const lines = source.split(/\r?\n/);
|
|
259
|
+
const failures = [];
|
|
260
|
+
const seen = new Set();
|
|
261
|
+
|
|
262
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
263
|
+
const raw = typeof lines[index] === 'string' ? lines[index] : '';
|
|
264
|
+
const trimmed = raw.trim();
|
|
265
|
+
if (trimmed === '') continue;
|
|
266
|
+
// The timestamp (and the `dsh: ` host prefix) is dropped before matching, so
|
|
267
|
+
// a real prefixed log line and a bare one are the same message.
|
|
268
|
+
const message = messageOf(trimmed);
|
|
269
|
+
|
|
270
|
+
/** @type {{ plugin: string|null, error: string, kind: string, matched: string }|null} */
|
|
271
|
+
let found = null;
|
|
272
|
+
|
|
273
|
+
const warning = WARNING_LINE.exec(message);
|
|
274
|
+
if (warning) {
|
|
275
|
+
// No plugin is named: the aggregate line is a SIGNAL (kind 'warning') and
|
|
276
|
+
// the recovery planner never acts on it — there is nothing to swap.
|
|
277
|
+
found = {
|
|
278
|
+
plugin: null,
|
|
279
|
+
error: clipText(`warning: ${warning[1]} entry/entries did not activate`),
|
|
280
|
+
kind: FAILURE_KINDS.WARNING,
|
|
281
|
+
matched: trimmed,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (found === null) {
|
|
286
|
+
const failed = IMPORT_FAILED_LINE.exec(message);
|
|
287
|
+
if (failed) {
|
|
288
|
+
let detail = typeof failed[3] === 'string' ? failed[3].trim() : '';
|
|
289
|
+
if (detail === '') {
|
|
290
|
+
// The message may sit on the next (indented) line of the log.
|
|
291
|
+
for (let look = index + 1; look < Math.min(lines.length, index + 4); look += 1) {
|
|
292
|
+
const next = typeof lines[look] === 'string' ? lines[look] : '';
|
|
293
|
+
if (next.trim() === '') continue;
|
|
294
|
+
if (!/^\s+/.test(next)) break;
|
|
295
|
+
detail = next.trim();
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
found = {
|
|
300
|
+
plugin: failed[1],
|
|
301
|
+
error: clipText(detail === '' ? 'the import failed without a message' : detail),
|
|
302
|
+
kind: /failed to import/i.test(failed[2])
|
|
303
|
+
? FAILURE_KINDS.FAILED_TO_IMPORT
|
|
304
|
+
: FAILURE_KINDS.IMPORT_FAILED,
|
|
305
|
+
matched: trimmed,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (found === null) {
|
|
311
|
+
const loading = LOADING_LINE.exec(message);
|
|
312
|
+
if (loading) {
|
|
313
|
+
found = {
|
|
314
|
+
plugin: loading[1],
|
|
315
|
+
error: clipText('the entry was still loading when the startup audit ran: it never activated'),
|
|
316
|
+
kind: FAILURE_KINDS.NOT_ACTIVATED,
|
|
317
|
+
matched: trimmed,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Anything else is not a plugin failure this module knows (a stack frame, a
|
|
323
|
+
// config line, the process-level dump, the usual chatter): ignored.
|
|
324
|
+
if (found === null) continue;
|
|
325
|
+
// `dsh: …` is the HOST talking: the harness itself is not a plugin to swap.
|
|
326
|
+
if (typeof found.plugin === 'string' && RESERVED_NAMES.has(found.plugin.toLowerCase())) continue;
|
|
327
|
+
|
|
328
|
+
const at = parseLogLineTime(trimmed, now) ?? logAt;
|
|
329
|
+
if (sinceMs !== null && at !== null && at < sinceMs) continue;
|
|
330
|
+
|
|
331
|
+
const entry = {
|
|
332
|
+
plugin: found.plugin,
|
|
333
|
+
error: found.error,
|
|
334
|
+
line: index + 1,
|
|
335
|
+
text: clipText(trimmed, MAX_TEXT_CHARS),
|
|
336
|
+
at: at === null ? null : Math.trunc(at),
|
|
337
|
+
kind: found.kind,
|
|
338
|
+
fingerprint: '',
|
|
339
|
+
};
|
|
340
|
+
entry.fingerprint = failureFingerprint(entry);
|
|
341
|
+
if (dedupe && seen.has(entry.fingerprint)) continue;
|
|
342
|
+
seen.add(entry.fingerprint);
|
|
343
|
+
failures.push(entry);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return failures;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The plugins a failure list really names, one entry per plugin, newest first.
|
|
351
|
+
*
|
|
352
|
+
* Several lines can describe the SAME plugin (the aggregate warning, an import
|
|
353
|
+
* error, a "still loading" line). The recovery works per plugin, and the BEST
|
|
354
|
+
* description is the one that carries an error message — so a silent
|
|
355
|
+
* "did not activate" line never overwrites a real error.
|
|
356
|
+
*
|
|
357
|
+
* @param {Array<object>} failures
|
|
358
|
+
* @returns {Array<object>}
|
|
359
|
+
*/
|
|
360
|
+
export function mergeFailuresByPlugin(failures = []) {
|
|
361
|
+
const byPlugin = new Map();
|
|
362
|
+
for (const failure of Array.isArray(failures) ? failures : []) {
|
|
363
|
+
if (!failure || typeof failure !== 'object') continue;
|
|
364
|
+
const plugin = typeof failure.plugin === 'string' ? failure.plugin : '';
|
|
365
|
+
// An aggregate warning names no plugin: it can never be restored, and it is
|
|
366
|
+
// not merged into anybody (it is reported on its own).
|
|
367
|
+
const key = plugin === '' ? `\u0000warning:${failure.line}:${failure.fingerprint}` : plugin;
|
|
368
|
+
const previous = byPlugin.get(key);
|
|
369
|
+
if (previous === undefined) {
|
|
370
|
+
byPlugin.set(key, failure);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const previousHasError = typeof previous.kind === 'string'
|
|
374
|
+
&& previous.kind !== FAILURE_KINDS.NOT_ACTIVATED
|
|
375
|
+
&& previous.kind !== FAILURE_KINDS.WARNING;
|
|
376
|
+
const currentHasError = typeof failure.kind === 'string'
|
|
377
|
+
&& failure.kind !== FAILURE_KINDS.NOT_ACTIVATED
|
|
378
|
+
&& failure.kind !== FAILURE_KINDS.WARNING;
|
|
379
|
+
if (!previousHasError && currentHasError) byPlugin.set(key, failure);
|
|
380
|
+
}
|
|
381
|
+
return [...byPlugin.values()];
|
|
382
|
+
}
|