@zerotal/core 1.6.3 → 1.7.2
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 +176 -0
- package/api-surface.md +3609 -0
- package/package.json +3 -1
- package/src/application/Application.ts +174 -11
- package/src/command/builtin/DoctorCommand.ts +53 -6
- package/src/command/builtin/RouteTypesCommand.ts +1 -0
- package/src/dev/DevDeck.ts +144 -20
- package/src/dev/DevOrchestrator.ts +1 -1
- package/src/doctor/HeaderProbe.ts +164 -0
- package/src/events/Emitter.ts +24 -0
- package/src/events/FrameworkEvents.ts +42 -0
- package/src/helpers/index.ts +43 -28
- package/src/index.ts +2 -0
- package/src/middleware/BaseMiddleware.ts +12 -1
- package/src/middleware/SecureHeadersMiddleware.ts +54 -23
- package/src/provider/StorageProvider.ts +4 -1
- package/src/router/RouteHandler.ts +5 -0
- package/src/router/Router.ts +62 -3
- package/src/router/routeTypes.ts +52 -11
- package/src/router/routes.ts +115 -0
- package/src/security/index.ts +6 -0
- package/src/security/redactGraph.ts +107 -0
- package/src/support/deepMerge.ts +42 -2
- package/src/support/env.ts +48 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
".": "./src/index.ts",
|
|
12
12
|
"./routes": "./src/router/routes.ts",
|
|
13
13
|
"./contracts": "./src/contracts/index.ts",
|
|
14
|
+
"./errors": "./src/errors/index.ts",
|
|
14
15
|
"./lock": "./src/lock/index.ts",
|
|
15
16
|
"./logger": "./src/logger/index.ts",
|
|
16
17
|
"./commands": "./src/command/builtin/index.ts",
|
|
@@ -34,6 +35,7 @@
|
|
|
34
35
|
},
|
|
35
36
|
"files": [
|
|
36
37
|
"CHANGELOG.md",
|
|
38
|
+
"api-surface.md",
|
|
37
39
|
"src",
|
|
38
40
|
"!src/**/*.test.ts",
|
|
39
41
|
"!src/**/*.test.tsx",
|
|
@@ -14,6 +14,7 @@ import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
|
14
14
|
import { Pipeline } from "../pipeline/Pipeline.ts";
|
|
15
15
|
import { ExceptionHandler } from "./ExceptionHandler.ts";
|
|
16
16
|
import { Router, RouterState } from "../router/Router.ts";
|
|
17
|
+
import { defineRouteMethods, defineRoutes } from "../router/routes.ts";
|
|
17
18
|
import type { StaticOptions } from "../router/Router.ts";
|
|
18
19
|
import { Health, resolveHealthConfig, checkHealthAccess } from "../health/Health.ts";
|
|
19
20
|
import type { HealthConfigShape } from "../health/Health.ts";
|
|
@@ -27,7 +28,10 @@ import {
|
|
|
27
28
|
import { builtinConcerns } from "../conventions/builtinConcerns.ts";
|
|
28
29
|
import { ConfigManager } from "../config/ConfigManager.ts";
|
|
29
30
|
import { DEFAULT_MAX_REQUEST_BODY_SIZE } from "../config/AppConfig.ts";
|
|
30
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
SecureHeadersMiddleware,
|
|
33
|
+
staticSecurityHeaders,
|
|
34
|
+
} from "../middleware/SecureHeadersMiddleware.ts";
|
|
31
35
|
import { isAllowedOrigin, allowedOriginsFrom } from "../http/originGuard.ts";
|
|
32
36
|
import { rescueSync } from "../helpers/index.ts";
|
|
33
37
|
import { configureAssets, setAssetVersion, assetVersion } from "../assets/assets.ts";
|
|
@@ -39,7 +43,7 @@ import { NotFoundError } from "../errors/HttpError.ts";
|
|
|
39
43
|
import type { ContainerBindings } from "../container/types.ts";
|
|
40
44
|
import { dispatchRequest } from "../router/RouteHandler.ts";
|
|
41
45
|
import type { ProviderHooks } from "../router/RouteHandler.ts";
|
|
42
|
-
import { isProdLike, deployEnv } from "../support/env.ts";
|
|
46
|
+
import { isProdLike, deployEnv, runtimeMode } from "../support/env.ts";
|
|
43
47
|
import { appKeyStrengthWarning } from "../support/appKey.ts";
|
|
44
48
|
import { runBootDoctor } from "./BootDoctor.ts";
|
|
45
49
|
import { runConfigValidators } from "../config/validation.ts";
|
|
@@ -237,10 +241,13 @@ export async function _lazyStaticResponse(
|
|
|
237
241
|
const relative = pathname.slice(trimmedPrefix.length).replace(/^\//, "");
|
|
238
242
|
const file = Bun.file(`${rootDir}/${relative}`);
|
|
239
243
|
if (await file.exists()) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
+
// The same headers Bun's native static routes carry (see Router.compile).
|
|
245
|
+
// This path returns before the pipeline runs, so without it a file served
|
|
246
|
+
// lazily — every file under the dev worker — would be the one response in
|
|
247
|
+
// the app with no security headers on it.
|
|
248
|
+
return new Response(file as unknown as BodyInit, {
|
|
249
|
+
headers: { ...staticSecurityHeaders(), ...options?.headers },
|
|
250
|
+
});
|
|
244
251
|
}
|
|
245
252
|
}
|
|
246
253
|
return undefined;
|
|
@@ -248,6 +255,8 @@ export async function _lazyStaticResponse(
|
|
|
248
255
|
|
|
249
256
|
/** Minimal WebSocket handler shape accepted by Bun.serve(). */
|
|
250
257
|
export interface WebSocketHandlers {
|
|
258
|
+
/** Seconds a connection may go quiet before Bun closes it. Bun's default is 10. */
|
|
259
|
+
idleTimeout?: number;
|
|
251
260
|
open?(ws: unknown): void;
|
|
252
261
|
message(ws: unknown, message: string | Uint8Array): void;
|
|
253
262
|
close?(ws: unknown, code: number, reason: string): void;
|
|
@@ -260,6 +269,39 @@ export interface WebSocketHandlers {
|
|
|
260
269
|
*/
|
|
261
270
|
export type AppScopeInstaller = () => () => void;
|
|
262
271
|
|
|
272
|
+
/**
|
|
273
|
+
* What one provider cost to boot, and what it contributed.
|
|
274
|
+
*
|
|
275
|
+
* @see Application.providerReport
|
|
276
|
+
*/
|
|
277
|
+
export interface ProviderReport {
|
|
278
|
+
/** Provider class name. The array is in boot order. */
|
|
279
|
+
name: string;
|
|
280
|
+
/**
|
|
281
|
+
* Wall-clock milliseconds across all three lifecycle hooks.
|
|
282
|
+
*
|
|
283
|
+
* `onBooted` runs concurrently across providers, so these do not sum to the
|
|
284
|
+
* application's boot time — they overlap, and the report reports that rather
|
|
285
|
+
* than serialising the boot to produce a tidier number.
|
|
286
|
+
*/
|
|
287
|
+
durationMs: number;
|
|
288
|
+
/** Container tokens this provider bound, as names. */
|
|
289
|
+
bindings: string[];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* A container token as a readable name.
|
|
294
|
+
*
|
|
295
|
+
* Tokens are class constructors or plain strings; the map keys are neither
|
|
296
|
+
* sorted nor serialisable as they stand, and a reader wants `CacheManager`, not
|
|
297
|
+
* `[class CacheManager]`.
|
|
298
|
+
*/
|
|
299
|
+
function _tokenName(token: unknown): string {
|
|
300
|
+
if (typeof token === "string") return token;
|
|
301
|
+
if (typeof token === "function") return token.name || "‹anonymous›";
|
|
302
|
+
return String(token);
|
|
303
|
+
}
|
|
304
|
+
|
|
263
305
|
const _appScopeInstallers: AppScopeInstaller[] = [];
|
|
264
306
|
|
|
265
307
|
/**
|
|
@@ -318,6 +360,8 @@ export class Application {
|
|
|
318
360
|
_env: Environment = "web";
|
|
319
361
|
private _booted = false;
|
|
320
362
|
private _bootDurationMs: number | undefined = undefined;
|
|
363
|
+
/** Per-provider boot cost and container provenance; see {@link providerReport}. */
|
|
364
|
+
private _providerReport: ProviderReport[] = [];
|
|
321
365
|
private _static?: ReturnType<typeof Bun.serve>;
|
|
322
366
|
private _configMap: Record<string, Record<string, unknown>> | undefined = undefined;
|
|
323
367
|
/** Tracks where config came from, to reject conflicting overrides via useConfig(). */
|
|
@@ -460,8 +504,12 @@ export class Application {
|
|
|
460
504
|
);
|
|
461
505
|
}
|
|
462
506
|
|
|
463
|
-
//
|
|
464
|
-
|
|
507
|
+
// The runtime mode, which is what provider filtering is keyed on. Reading
|
|
508
|
+
// `APP_ENV` here used to be right only because `setAppEnv()` had overwritten
|
|
509
|
+
// it with the mode; now the mode has its own variable and this asks for it
|
|
510
|
+
// directly. `_normaliseEnv` still maps a deployment name onto a mode, for an
|
|
511
|
+
// explicit `options.env`.
|
|
512
|
+
const rawEnv = options.env ?? runtimeMode("web");
|
|
465
513
|
const resolvedEnv: Environment = _normaliseEnv(rawEnv);
|
|
466
514
|
|
|
467
515
|
const app = new Application();
|
|
@@ -609,6 +657,60 @@ export class Application {
|
|
|
609
657
|
return this._bootDurationMs;
|
|
610
658
|
}
|
|
611
659
|
|
|
660
|
+
/**
|
|
661
|
+
* What each provider cost to boot, and what it put in the container.
|
|
662
|
+
*
|
|
663
|
+
* `bootDurationMs` says the app took 240ms and nothing said which provider
|
|
664
|
+
* spent it; the container lists a hundred bindings and nothing said who bound
|
|
665
|
+
* them. Both are answered here, in boot order — which is itself the answer to
|
|
666
|
+
* a third question, since provider order decides who wins a contested binding.
|
|
667
|
+
*
|
|
668
|
+
* Empty until `boot()` runs. Populated in every environment: it costs one
|
|
669
|
+
* `performance.now()` pair and one registry diff per provider at boot, and a
|
|
670
|
+
* report that only exists in development is one you cannot ask for when a
|
|
671
|
+
* staging boot is the slow one.
|
|
672
|
+
*
|
|
673
|
+
* @category Lifecycle
|
|
674
|
+
*/
|
|
675
|
+
get providerReport(): readonly ProviderReport[] {
|
|
676
|
+
return this._providerReport;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Time one provider lifecycle hook and attribute anything it bound.
|
|
681
|
+
*
|
|
682
|
+
* Accumulates across the three phases, so a provider that binds in
|
|
683
|
+
* `onRegister` and spends its time in `onBooting` reads as one row.
|
|
684
|
+
*/
|
|
685
|
+
private _recordProviderWork<T>(provider: ServiceProvider, work: () => T): T {
|
|
686
|
+
const name = provider.constructor.name;
|
|
687
|
+
let row = this._providerReport.find((entry) => entry.name === name);
|
|
688
|
+
if (!row) {
|
|
689
|
+
row = { name, durationMs: 0, bindings: [] };
|
|
690
|
+
this._providerReport.push(row);
|
|
691
|
+
}
|
|
692
|
+
const before = new Set(this.container.registry.keys());
|
|
693
|
+
const started = performance.now();
|
|
694
|
+
const finish = (): T => {
|
|
695
|
+
row!.durationMs = Math.round((row!.durationMs + performance.now() - started) * 100) / 100;
|
|
696
|
+
for (const token of this.container.registry.keys()) {
|
|
697
|
+
if (!before.has(token)) row!.bindings.push(_tokenName(token));
|
|
698
|
+
}
|
|
699
|
+
return undefined as T;
|
|
700
|
+
};
|
|
701
|
+
const result = work();
|
|
702
|
+
// An async hook is only finished when its promise is — timing it
|
|
703
|
+
// synchronously would report every `await` in it as free.
|
|
704
|
+
if (result instanceof Promise) {
|
|
705
|
+
return result.then((value: unknown) => {
|
|
706
|
+
finish();
|
|
707
|
+
return value;
|
|
708
|
+
}) as T;
|
|
709
|
+
}
|
|
710
|
+
finish();
|
|
711
|
+
return result;
|
|
712
|
+
}
|
|
713
|
+
|
|
612
714
|
/**
|
|
613
715
|
* The runtime environment this application is running in.
|
|
614
716
|
*
|
|
@@ -973,6 +1075,17 @@ export class Application {
|
|
|
973
1075
|
};
|
|
974
1076
|
|
|
975
1077
|
return {
|
|
1078
|
+
// Bun closes an idle WebSocket after 10 seconds by default, and the client
|
|
1079
|
+
// pings every 30 — so a connection that is merely *quiet* was being cut
|
|
1080
|
+
// before it ever had reason to speak, taking its channel subscriptions
|
|
1081
|
+
// with it. Nothing surfaced: the page stayed rendered, the client kept its
|
|
1082
|
+
// channel objects, and broadcasts simply stopped arriving for anyone who
|
|
1083
|
+
// had been reading for more than ten seconds.
|
|
1084
|
+
//
|
|
1085
|
+
// 120s leaves room for four missed pings before a genuinely dead socket is
|
|
1086
|
+
// reaped, which is the direction to err: a stale connection costs memory,
|
|
1087
|
+
// a reaped live one costs the feature.
|
|
1088
|
+
idleTimeout: 120,
|
|
976
1089
|
open: (ws: unknown) => {
|
|
977
1090
|
if ((ws as AnyWS).data._dev) {
|
|
978
1091
|
DevWsServer.open(ws as AnyWS);
|
|
@@ -1056,7 +1169,15 @@ export class Application {
|
|
|
1056
1169
|
for (const callback of this._bindCallbacks) callback(this.container);
|
|
1057
1170
|
|
|
1058
1171
|
// Phase 1 — synchronous, binds into container.
|
|
1059
|
-
|
|
1172
|
+
//
|
|
1173
|
+
// Timed, and the container is diffed around each provider, so the inspector
|
|
1174
|
+
// can answer "who bound `cache`, and what did booting it cost". Provenance
|
|
1175
|
+
// by diff rather than by having the container record a registrar: it keeps
|
|
1176
|
+
// the cost at boot instead of on every binding, and adds no mutable state to
|
|
1177
|
+
// the container for a question only a debugging tool asks.
|
|
1178
|
+
for (const provider of this._activeProviders) {
|
|
1179
|
+
this._recordProviderWork(provider, () => provider.onRegister());
|
|
1180
|
+
}
|
|
1060
1181
|
|
|
1061
1182
|
// Config validation — providers have registered their namespace validators
|
|
1062
1183
|
// in onRegister; run them before anything boots. In a production-like
|
|
@@ -1069,10 +1190,20 @@ export class Application {
|
|
|
1069
1190
|
}
|
|
1070
1191
|
|
|
1071
1192
|
// Phase 2 — sequential in registration order.
|
|
1072
|
-
for (const provider of this._activeProviders)
|
|
1193
|
+
for (const provider of this._activeProviders) {
|
|
1194
|
+
await this._recordProviderWork(provider, () => provider.onBooting());
|
|
1195
|
+
}
|
|
1073
1196
|
|
|
1074
1197
|
// Phase 3 — async, all providers have finished booting.
|
|
1075
|
-
|
|
1198
|
+
//
|
|
1199
|
+
// These run concurrently, so the recorded durations overlap and do not sum
|
|
1200
|
+
// to the phase. That is the truth about this phase and the report says so
|
|
1201
|
+
// rather than serialising the boot to make a tidier number.
|
|
1202
|
+
await Promise.all(
|
|
1203
|
+
this._activeProviders.map((provider) =>
|
|
1204
|
+
this._recordProviderWork(provider, () => provider.onBooted()),
|
|
1205
|
+
),
|
|
1206
|
+
);
|
|
1076
1207
|
|
|
1077
1208
|
// Ensure config and events are resolved so makeSync() works below.
|
|
1078
1209
|
await this.container.make("config");
|
|
@@ -1100,6 +1231,21 @@ export class Application {
|
|
|
1100
1231
|
await this._loadFileRoutes();
|
|
1101
1232
|
}
|
|
1102
1233
|
|
|
1234
|
+
// Install the route table for `zerotal/routes`, now that every route is
|
|
1235
|
+
// registered.
|
|
1236
|
+
//
|
|
1237
|
+
// That module is the standalone URL builder a browser bundle imports, so it
|
|
1238
|
+
// cannot reach for `Router` itself — importing the router would drag the
|
|
1239
|
+
// server into every client bundle. The dependency therefore points this way:
|
|
1240
|
+
// the server, which already has both, pushes the table in.
|
|
1241
|
+
//
|
|
1242
|
+
// Without this, `route()` threw on the server for any app that renders its
|
|
1243
|
+
// own markup — a `view` build produces every href and form action there —
|
|
1244
|
+
// and the fix was a `defineRoutes()` call each app had to know to write.
|
|
1245
|
+
// A browser entry still calls it; that is a different process with no router
|
|
1246
|
+
// to read. See T24.
|
|
1247
|
+
this._installRouteTable();
|
|
1248
|
+
|
|
1103
1249
|
// A routes/ directory nobody routed is a silent 404 for every path in it — the file
|
|
1104
1250
|
// imports cleanly and registers nothing, which looks identical to a typo'd URL.
|
|
1105
1251
|
this._warnUnroutedRoutesDir(process.cwd());
|
|
@@ -1153,6 +1299,23 @@ export class Application {
|
|
|
1153
1299
|
if (warning) frameworkLog("app").warn(warning);
|
|
1154
1300
|
}
|
|
1155
1301
|
|
|
1302
|
+
/**
|
|
1303
|
+
* Hand the registered routes to the standalone `route()` builder.
|
|
1304
|
+
*
|
|
1305
|
+
* Name → pattern for the URLs, and name → verb for `action()`. `RouteDefinition`
|
|
1306
|
+
* carries its own `name` beside `method`, so the pair comes from one record —
|
|
1307
|
+
* a path join would be wrong, since `GET /login` and `POST /login` share a path.
|
|
1308
|
+
*/
|
|
1309
|
+
private _installRouteTable(): void {
|
|
1310
|
+
const methods = new Map<string, string>();
|
|
1311
|
+
for (const definition of Router.routes.values()) {
|
|
1312
|
+
if (definition.name) methods.set(definition.name, definition.method);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
defineRoutes(Router.namedRoutes);
|
|
1316
|
+
defineRouteMethods(Object.fromEntries(methods));
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1156
1319
|
private async _loadFileRoutes(): Promise<void> {
|
|
1157
1320
|
for (const { dir, prefix, middleware } of this._fileRouteGroups) {
|
|
1158
1321
|
await Router.groupAsync({ prefix, middleware }, () => scanFileRoutes(dir).then(() => {}));
|
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
* `bun zt doctor` — run every static sanity check against this app and print
|
|
3
3
|
* the findings with their fixes. Exits 1 when anything is broken outright.
|
|
4
4
|
*
|
|
5
|
-
* With `--url`, it also
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* With `--url`, it also reads the deployed app from the outside, through whatever proxy is
|
|
6
|
+
* in front of it — the only way to see two classes of failure. The WebSocket transport,
|
|
7
|
+
* which a proxy can gate or drop while leaving the app healthy from the inside and inert in
|
|
8
|
+
* the browser. And security headers sent twice, because a header the app sets and the proxy
|
|
9
|
+
* also sets is invisible from in here: the app's own view is the value it wrote.
|
|
8
10
|
*/
|
|
9
11
|
import { Command } from "../Command.ts";
|
|
10
12
|
import type { Application } from "../../application/Application.ts";
|
|
11
13
|
import { runDoctor } from "../../doctor/AppDoctor.ts";
|
|
12
14
|
import type { DoctorReportEntry } from "../../doctor/AppDoctor.ts";
|
|
13
15
|
import { probeTransport } from "../../doctor/TransportProbe.ts";
|
|
16
|
+
import { probeHeaders } from "../../doctor/HeaderProbe.ts";
|
|
14
17
|
|
|
15
18
|
export class DoctorCommand extends Command {
|
|
16
19
|
static override commandName = "doctor";
|
|
@@ -21,7 +24,8 @@ export class DoctorCommand extends Command {
|
|
|
21
24
|
name: "url",
|
|
22
25
|
type: "string" as const,
|
|
23
26
|
description:
|
|
24
|
-
"Also
|
|
27
|
+
"Also read the deployed app at this public URL: handshake its WebSocket transport " +
|
|
28
|
+
"as a browser would, and report security headers the response carries twice",
|
|
25
29
|
},
|
|
26
30
|
];
|
|
27
31
|
|
|
@@ -34,9 +38,13 @@ export class DoctorCommand extends Command {
|
|
|
34
38
|
for (const entry of report) this._print(entry);
|
|
35
39
|
|
|
36
40
|
const probeFailures = await this._probeTransport(app);
|
|
41
|
+
const headerFindings = await this._probeHeaders();
|
|
37
42
|
|
|
38
|
-
const warns = report.filter((e) => e.result.status === "warn").length;
|
|
39
|
-
const fails =
|
|
43
|
+
const warns = report.filter((e) => e.result.status === "warn").length + headerFindings.warnings;
|
|
44
|
+
const fails =
|
|
45
|
+
report.filter((e) => e.result.status === "fail").length +
|
|
46
|
+
probeFailures +
|
|
47
|
+
headerFindings.failures;
|
|
40
48
|
this.newLine();
|
|
41
49
|
// Throw rather than `process.exit(1)` — same exit code from the CLI (the runner
|
|
42
50
|
// converts it), but composable. Exiting here killed any caller running the
|
|
@@ -86,6 +94,45 @@ export class DoctorCommand extends Command {
|
|
|
86
94
|
return failures;
|
|
87
95
|
}
|
|
88
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Report security headers the deployed response carries more than once.
|
|
99
|
+
*
|
|
100
|
+
* Only reachable from outside: the app's own view of a header is the value it
|
|
101
|
+
* wrote, which is correct as far as it goes — the proxy's copy is invisible
|
|
102
|
+
* from in here. Conflicting values count as failures because a control that
|
|
103
|
+
* different browsers apply differently is not a control; identical duplicates
|
|
104
|
+
* are a warning, because they are a conflict waiting for someone to edit one
|
|
105
|
+
* side.
|
|
106
|
+
*/
|
|
107
|
+
private async _probeHeaders(): Promise<{ failures: number; warnings: number }> {
|
|
108
|
+
const url = this.flags["url"] as string | undefined;
|
|
109
|
+
if (!url) return { failures: 0, warnings: 0 };
|
|
110
|
+
|
|
111
|
+
const findings = await probeHeaders(url);
|
|
112
|
+
this.newLine();
|
|
113
|
+
this.section("Response headers");
|
|
114
|
+
|
|
115
|
+
if (findings.length === 0) {
|
|
116
|
+
this.line("✓ No duplicated security headers.");
|
|
117
|
+
return { failures: 0, warnings: 0 };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let failures = 0;
|
|
121
|
+
let warnings = 0;
|
|
122
|
+
for (const finding of findings) {
|
|
123
|
+
const line = `${finding.conflicting ? "✗" : "!"} ${finding.header} — ${finding.message}`;
|
|
124
|
+
if (finding.conflicting) {
|
|
125
|
+
this.error(line);
|
|
126
|
+
failures++;
|
|
127
|
+
} else {
|
|
128
|
+
this.warn(line);
|
|
129
|
+
warnings++;
|
|
130
|
+
}
|
|
131
|
+
if (finding.fix) this.line(` fix: ${finding.fix}`);
|
|
132
|
+
}
|
|
133
|
+
return { failures, warnings };
|
|
134
|
+
}
|
|
135
|
+
|
|
89
136
|
private _print({ check, result }: DoctorReportEntry): void {
|
|
90
137
|
const mark = result.status === "ok" ? "✓" : result.status === "warn" ? "!" : "✗";
|
|
91
138
|
const line = `${mark} ${check.label} — ${result.message}`;
|