dsh-ensp 0.1.9 → 0.2.1

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/src/cli/ensp.mjs CHANGED
@@ -31,6 +31,8 @@ import {
31
31
  waitForConsole,
32
32
  } from '../core/launcher.mjs'
33
33
  import { calibrateAnchors, inspectAnchors, DEFAULT_ANCHORS } from '../core/calibrate.mjs'
34
+ import { doctor, formatDoctor, modelsOf } from '../core/doctor.mjs'
35
+ import { runAcceptance, formatAcceptance, hasChecks, planFor } from '../core/acceptance.mjs'
34
36
  import { pushConfig } from '../core/provision.mjs'
35
37
  import { SCENARIOS, SCENARIO_IDS, byLevel, renderScenario, searchScenarios } from '../core/presets.mjs'
36
38
  import {
@@ -58,6 +60,8 @@ ${C.bold('USAGE')}
58
60
  ensp up <topology.topo> [--stop] [--keep-window]
59
61
  ensp apply <config-dir> --topology <topology.topo> [--save] [--device NAME]
60
62
  ensp status [--topology <topology.topo>]
63
+ ensp doctor [--topology <topology.topo>] [--json] [--shallow]
64
+ ensp verify <scenario-id> --topology <topology.topo> [--json] [--wait MS]
61
65
  ensp probe [--json]
62
66
  ensp stop [--topology <topology.topo>]
63
67
  ensp preset [<id>] [--search <keyword>] [--topo <out.topo>] [--json]
@@ -69,12 +73,17 @@ ${C.bold('OPTIONS')}
69
73
  --save run "save" on each device after its configuration
70
74
  --device NAME apply only to one device
71
75
  --timeout MS per-device readiness timeout (default 240000)
76
+ --wait MS verify: how long a failing assertion may keep being retried (default 60000)
72
77
  --topo PATH write the scenario's topology blueprint to a .topo file
78
+ --topology PATH the .topo file that is running (doctor, verify)
79
+ --shallow doctor: skip the input-injection probe
73
80
  --json machine-readable output
74
81
  -h, --help show this help
75
82
 
76
83
  ${C.bold('EXAMPLES')}
77
84
  ensp up lab.topo
85
+ ensp doctor --topology lab.topo
86
+ ensp verify ospf-single --topology lab.topo
78
87
  ensp apply ./configs --topology lab.topo --save
79
88
  ensp status --topology lab.topo
80
89
  ensp probe
@@ -86,8 +95,10 @@ ${C.bold('EXAMPLES')}
86
95
  ${C.bold('NOTES')}
87
96
  · eNSP must be installed and runnable; it requires administrator rights.
88
97
  · Starting devices briefly focuses the eNSP window (it has to be clicked),
89
- then the window is minimised again.
98
+ then the window is minimised again. Run doctor first if a start does nothing.
90
99
  · Readiness is verified per device by polling its console, never by sleeping.
100
+ · Verification is separate from configuration: ensp verify asserts on what
101
+ each device actually reports.
91
102
  `
92
103
 
93
104
  /** Parse argv into { verb, positional, flags }. */
@@ -145,7 +156,9 @@ function deviceLine(event) {
145
156
 
146
157
  // --------------------------------------------------------------------------- up
147
158
  async function cmdUp({ positional, flags }) {
148
- const topoPath = positional[0]
159
+ // `ensp stop --topology x.topo` is documented in the help text, so accept the flag
160
+ // as well as the positional form; both mean the same file.
161
+ const topoPath = positional[0] ?? flags.topology
149
162
  if (!topoPath) {
150
163
  console.error(C.red('error:') + ' missing topology path')
151
164
  return 2
@@ -424,6 +437,82 @@ async function cmdProbe({ flags }) {
424
437
  return r.resolved?.method === 'measured' ? 0 : 1
425
438
  }
426
439
 
440
+ /**
441
+ * `ensp doctor [--topology t.topo] [--json] [--shallow]`
442
+ *
443
+ * Answers "why did nothing happen?" before a lab starts. Every failing check
444
+ * carries its own fix, because the failure modes here (missing image, covered
445
+ * window, dead input queue) all look the same from the outside.
446
+ */
447
+ async function cmdDoctor({ positional, flags }) {
448
+ let models
449
+ if (flags.topology || positional[0]) {
450
+ const file = flags.topology || positional[0]
451
+ try {
452
+ models = modelsOf(await readTopology(file))
453
+ } catch (error) {
454
+ console.error(C.red('error:') + ' ' + error.message)
455
+ return 1
456
+ }
457
+ }
458
+ const report = doctor({
459
+ exe: flags.exe,
460
+ vbox: flags.vbox,
461
+ models,
462
+ deep: !flags.shallow,
463
+ })
464
+ if (flags.json) {
465
+ console.log(JSON.stringify(report, null, 2))
466
+ return report.ok ? 0 : 1
467
+ }
468
+ console.log(formatDoctor(report))
469
+ return report.ok ? 0 : 1
470
+ }
471
+
472
+ /**
473
+ * `ensp verify <scenario-id> --topology t.topo [--json]`
474
+ *
475
+ * Runs the scenario's acceptance checks against a lab that is already up. This
476
+ * is the difference between "the commands were sent" and "the lab works": a
477
+ * curated check asserts on what the device reports, and a smoke check at least
478
+ * proves every verification command still parses.
479
+ */
480
+ async function cmdVerify({ positional, flags }) {
481
+ const id = positional[0]
482
+ if (!id) {
483
+ console.error(C.red('error:') + ' usage: ensp verify <scenario-id> --topology <topology.topo>')
484
+ return 2
485
+ }
486
+ if (!SCENARIOS[id]) {
487
+ console.error(C.red('error:') + ` unknown scenario "${id}"`)
488
+ return 2
489
+ }
490
+ const topology = flags.topology
491
+ if (!topology) {
492
+ console.error(C.red('error:') + ' verify needs --topology <the .topo file that is running>')
493
+ return 2
494
+ }
495
+ const report = await runAcceptance({
496
+ id,
497
+ topology,
498
+ timeoutMs: flags.timeout ? Number(flags.timeout) : undefined,
499
+ waitMs: flags.wait ? Number(flags.wait) : undefined,
500
+ onProgress: flags.json
501
+ ? undefined
502
+ : (e) => {
503
+ if (e.phase === 'check') process.stderr.write(C.dim(` checking ${e.id} on ${e.device}\n`))
504
+ else if (e.phase === 'retry')
505
+ process.stderr.write(C.dim(` waiting ${Math.round(e.waited / 1000)}s for ${e.id} on ${e.device} (${e.why})\n`))
506
+ },
507
+ })
508
+ if (flags.json) {
509
+ console.log(JSON.stringify(report, null, 2))
510
+ return report.ok ? 0 : 1
511
+ }
512
+ console.log(formatAcceptance(report))
513
+ return report.ok ? 0 : 1
514
+ }
515
+
427
516
  // ------------------------------------------------------------------------- main
428
517
  async function main() {
429
518
  const { verb, positional, flags } = parseArgs(process.argv.slice(2))
@@ -441,6 +530,10 @@ async function main() {
441
530
  return cmdStatus({ positional, flags })
442
531
  case 'probe':
443
532
  return cmdProbe({ positional, flags })
533
+ case 'doctor':
534
+ return cmdDoctor({ positional, flags })
535
+ case 'verify':
536
+ return cmdVerify({ positional, flags })
444
537
  case 'stop':
445
538
  return cmdUp({ positional, flags: { ...flags, stop: true } })
446
539
  case 'preset':
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Acceptance checks: what "this lab works" actually means, expressed as facts.
3
+ *
4
+ * A scenario's `steps[].verify` field names a command to look at, which is a
5
+ * hint for a human, not a pass/fail test. This module turns the important ones
6
+ * into assertions: run a command on a device, then decide from the output
7
+ * whether the lab reached the intended state.
8
+ *
9
+ * Assertions are deliberately small and textual:
10
+ *
11
+ * { contains: 'Full' } output must contain the literal text
12
+ * { notContains: 'Init' } output must not contain it
13
+ * { matches: /^\\s*Up/m } output must match the pattern
14
+ * { minLines: 2 } output must have at least N non-empty lines
15
+ *
16
+ * Textual checks are honest about their limits: they prove the device reports a
17
+ * state, not that packets flow. Checks that really need packets use `ping` and
18
+ * assert on its summary line, which is the only place VRP reports loss.
19
+ *
20
+ * @module dsh-ensp/acceptance
21
+ */
22
+
23
+ import { queryDevice } from './provision.mjs'
24
+ import { SCENARIOS } from './presets.mjs'
25
+ import { readTopology } from './topology.mjs'
26
+
27
+ /**
28
+ * Per-scenario assertions. `on` is the device name from the blueprint, so a
29
+ * console port can be resolved from the running topology.
30
+ *
31
+ * Only scenarios whose outcome is unambiguous from a single `display` command
32
+ * get curated checks. Everything else falls back to smoke checks built from
33
+ * `steps[].verify` (see `planFor`).
34
+ */
35
+ export const EXPECTATIONS = {
36
+ 'vlan-access': [
37
+ { id: 'vlans-exist', on: 'SW1', command: 'display vlan', expect: { minLines: 3 } },
38
+ { id: 'port-in-vlan', on: 'SW1', command: 'display port vlan active', expect: { contains: 'U' } },
39
+ ],
40
+ 'trunk-interconnect': [
41
+ { id: 'trunk-up', on: 'SW1', command: 'display interface brief | include up', expect: { minLines: 2 } },
42
+ ],
43
+ 'vlanif-routing': [
44
+ { id: 'vlanif-up', on: 'SW1', command: 'display ip interface brief', expect: { matches: /Vlanif\d+\s+up/ } },
45
+ { id: 'route-present', on: 'SW1', command: 'display ip routing-table', expect: { matches: /\/2[0-9]\s+Static|Direct/ } },
46
+ ],
47
+ 'stp-rstp': [
48
+ { id: 'stp-running', on: 'SW1', command: 'display stp brief', expect: { minLines: 2 } },
49
+ { id: 'one-root', on: 'SW1', command: 'display stp brief', expect: { matches: /ROOT/ } },
50
+ ],
51
+ 'link-aggregation': [
52
+ { id: 'eth-trunk-up', on: 'SW1', command: 'display eth-trunk', expect: { matches: /Up/ } },
53
+ ],
54
+ 'static-route': [
55
+ { id: 'static-in-table', on: 'R1', command: 'display ip routing-table protocol static', expect: { minLines: 2 } },
56
+ ],
57
+ 'rip': [
58
+ { id: 'rip-neighbour', on: 'R1', command: 'display rip 1 neighbor', expect: { minLines: 2 } },
59
+ { id: 'rip-route', on: 'R1', command: 'display ip routing-table protocol rip', expect: { minLines: 2 } },
60
+ ],
61
+ 'ospf-single': [
62
+ { id: 'neighbour-full', on: 'R1', command: 'display ospf peer brief', expect: { contains: 'Full' } },
63
+ { id: 'ospf-route', on: 'R1', command: 'display ip routing-table protocol ospf', expect: { minLines: 2 } },
64
+ ],
65
+ 'ospf-multi-area': [
66
+ { id: 'neighbour-full', on: 'R1', command: 'display ospf peer brief', expect: { contains: 'Full' } },
67
+ { id: 'inter-area-route', on: 'R1', command: 'display ip routing-table protocol ospf', expect: { matches: /OSPF_ASE|OSPF|\/2[0-9]/ } },
68
+ ],
69
+ 'ospf-stub-nssa': [
70
+ { id: 'neighbour-full', on: 'R1', command: 'display ospf peer brief', expect: { contains: 'Full' } },
71
+ { id: 'stub-area', on: 'R1', command: 'display ospf brief', expect: { matches: /Stub|NSSA/i } },
72
+ ],
73
+ 'bgp-ebgp-ibgp': [
74
+ { id: 'bgp-established', on: 'R1', command: 'display bgp peer', expect: { contains: 'Established' } },
75
+ { id: 'bgp-route', on: 'R1', command: 'display bgp routing-table', expect: { minLines: 3 } },
76
+ ],
77
+ 'bgp-route-reflector': [
78
+ { id: 'bgp-established', on: 'RR', command: 'display bgp peer', expect: { contains: 'Established' } },
79
+ { id: 'reflected-route', on: 'RR', command: 'display bgp routing-table', expect: { minLines: 3 } },
80
+ ],
81
+ 'dhcp': [
82
+ { id: 'pool-exists', on: 'R1', command: 'display ip pool', expect: { minLines: 2 } },
83
+ ],
84
+ 'nat': [
85
+ { id: 'nat-enabled', on: 'R1', command: 'display nat outbound', expect: { matches: /ACL|acl/i } },
86
+ ],
87
+ 'acl-filter': [
88
+ { id: 'acl-defined', on: 'R1', command: 'display acl all', expect: { minLines: 2 } },
89
+ ],
90
+ 'aaa-local': [
91
+ { id: 'user-exists', on: 'R1', command: 'display aaa local-user', expect: { minLines: 3 } },
92
+ ],
93
+ 'isis-multi-level': [
94
+ { id: 'isis-neighbour', on: 'R1', command: 'display isis peer', expect: { contains: 'Up' } },
95
+ { id: 'isis-route', on: 'R1', command: 'display isis route', expect: { minLines: 3 } },
96
+ ],
97
+ 'ipv6': [
98
+ { id: 'ipv6-address', on: 'R1', command: 'display ipv6 interface brief', expect: { contains: '2001' } },
99
+ { id: 'ipv6-route', on: 'R1', command: 'display ipv6 routing-table', expect: { minLines: 3 } },
100
+ ],
101
+ 'vrrp-basics': [
102
+ { id: 'vrrp-master', on: 'R1', command: 'display vrrp', expect: { contains: 'Master' } },
103
+ { id: 'vrrp-backup', on: 'R2', command: 'display vrrp', expect: { contains: 'Backup' } },
104
+ ],
105
+ 'ospfv3-ipv6': [
106
+ { id: 'ospfv3-neighbour', on: 'R1', command: 'display ospfv3 peer', expect: { contains: 'Full' } },
107
+ ],
108
+ 'multicast-pim-sm': [
109
+ { id: 'pim-neighbour', on: 'R1', command: 'display pim neighbor', expect: { minLines: 2 } },
110
+ { id: 'rp-set', on: 'R1', command: 'display pim rp-info', expect: { minLines: 2 } },
111
+ ],
112
+ 'mpls-basic': [
113
+ { id: 'mpls-enabled', on: 'PE1', command: 'display mpls ldp session', expect: { minLines: 2 } },
114
+ { id: 'ldp-peer', on: 'PE1', command: 'display mpls ldp peer', expect: { minLines: 2 } },
115
+ ],
116
+ 'mpls-l3vpn': [
117
+ { id: 'vpnv4-peer', on: 'PE1', command: 'display bgp vpnv4 all peer', expect: { contains: 'Established' } },
118
+ { id: 'vrf-route', on: 'PE1', command: 'display ip routing-table vpn-instance vpn1', expect: { minLines: 3 } },
119
+ ],
120
+ 'stack-istack': [
121
+ { id: 'stack-members', on: 'SW1', command: 'display stack', expect: { minLines: 3 } },
122
+ ],
123
+ 'dhcp-snooping': [
124
+ { id: 'snooping-on', on: 'SW1', command: 'display dhcp snooping', expect: { matches: /enable|Enable/ } },
125
+ ],
126
+ 'qos-basics': [
127
+ { id: 'qos-policy', on: 'R1', command: 'display traffic policy user-defined', expect: { minLines: 3 } },
128
+ ],
129
+ 'port-security': [
130
+ { id: 'port-security-on', on: 'SW1', command: 'display port-security', expect: { matches: /enable|Enable/ } },
131
+ ],
132
+ 'super-vlan': [
133
+ { id: 'super-vlan', on: 'SW1', command: 'display vlan', expect: { minLines: 4 } },
134
+ ],
135
+ 'network-management': [
136
+ { id: 'snmp-agent', on: 'R1', command: 'display snmp-agent sys-info', expect: { minLines: 2 } },
137
+ ],
138
+ }
139
+
140
+ /** Longest useful slice of a command's output kept for a report. */
141
+ const SNIPPET = 400
142
+
143
+ /**
144
+ * Decide one assertion against a command's output.
145
+ * @returns {{pass: boolean, why: string}}
146
+ */
147
+ export function evaluateAssertion(output, expect = {}) {
148
+ const text = String(output || '')
149
+ const checks = []
150
+ if (expect.contains !== undefined) {
151
+ checks.push({ ok: text.includes(expect.contains), why: `contains "${expect.contains}"` })
152
+ }
153
+ if (expect.notContains !== undefined) {
154
+ checks.push({ ok: !text.includes(expect.notContains), why: `does not contain "${expect.notContains}"` })
155
+ }
156
+ if (expect.matches !== undefined) {
157
+ const re = expect.matches instanceof RegExp ? expect.matches : new RegExp(expect.matches, 'm')
158
+ checks.push({ ok: re.test(text), why: `matches ${re}` })
159
+ }
160
+ if (expect.minLines !== undefined) {
161
+ const n = text.split('\n').filter((l) => l.trim()).length
162
+ checks.push({ ok: n >= expect.minLines, why: `at least ${expect.minLines} non-empty lines (got ${n})` })
163
+ }
164
+ if (!checks.length) return { pass: true, why: 'no assertion (command must merely succeed)' }
165
+ const failed = checks.filter((c) => !c.ok)
166
+ return {
167
+ pass: failed.length === 0,
168
+ why: (failed.length ? failed : checks).map((c) => (c.ok ? '' : 'not ') + c.why).join('; '),
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Build the acceptance plan for a scenario.
174
+ *
175
+ * Curated expectations win. Otherwise the scenario's `steps[].verify` commands
176
+ * become smoke checks: the command has to run without a VRP error, which still
177
+ * catches a lab that was never configured.
178
+ */
179
+ export function planFor(id, { devices } = {}) {
180
+ const scenario = SCENARIOS[id]
181
+ if (!scenario) throw new Error(`unknown scenario: ${id}`)
182
+ const curated = EXPECTATIONS[id]
183
+ if (curated && curated.length) {
184
+ return {
185
+ scenario: id,
186
+ title: scenario.title,
187
+ source: 'curated',
188
+ checks: curated.map((c) => ({ ...c, kind: 'assert' })),
189
+ }
190
+ }
191
+ const first = (devices && devices.length && devices[0]) || scenario.devices?.split(/[、,,\s]+/)[0]
192
+ const commands = [...new Set((scenario.steps || []).map((s) => s.verify).filter(Boolean))]
193
+ const checks = commands.flatMap((raw) =>
194
+ String(raw)
195
+ .split('/')
196
+ .map((c) => c.trim())
197
+ .filter(Boolean)
198
+ .map((command, i) => ({
199
+ id: `smoke-${i + 1}`,
200
+ on: first,
201
+ command,
202
+ kind: 'smoke',
203
+ expect: {},
204
+ })),
205
+ )
206
+ return { scenario: id, title: scenario.title, source: 'smoke', checks }
207
+ }
208
+
209
+ /** Does this scenario have curated (strict) assertions rather than smoke checks? */
210
+ export function hasChecks(id) {
211
+ return Boolean(EXPECTATIONS[id] && EXPECTATIONS[id].length)
212
+ }
213
+
214
+ /** Map device name → console port from a parsed topology. */
215
+ export function consolePorts(topology) {
216
+ const map = new Map()
217
+ for (const d of topology?.devices ?? []) {
218
+ if (d.comPort) map.set(d.name, Number(d.comPort))
219
+ }
220
+ return map
221
+ }
222
+
223
+ /**
224
+ * Run a scenario's acceptance plan against a live lab.
225
+ *
226
+ * @param {object} options
227
+ * @param {string} options.id scenario id
228
+ * @param {string} [options.topology] path to the running .topo (for ports)
229
+ * @param {Map<string,number>} [options.ports] explicit device → port map
230
+ * @param {number} [options.timeoutMs] per-command timeout
231
+ * @param {number} [options.waitMs] how long a failing assertion may keep being retried
232
+ * @param {number} [options.intervalMs] delay between those retries
233
+ * @param {Function} [options.query] override the console query (tests)
234
+ * @param {Function} [options.onProgress]
235
+ */
236
+ export async function runAcceptance(options = {}) {
237
+ const { id, topology, timeoutMs = 25000, waitMs = 60000, intervalMs = 3000, onProgress } = options
238
+ // Injectable so the convergence loop can be tested without a live lab.
239
+ const query = options.query ?? queryDevice
240
+ let ports = options.ports
241
+ let parsed
242
+ if (!ports) {
243
+ if (!topology) throw new Error('runAcceptance needs either a topology path or a ports map')
244
+ parsed = await readTopology(topology)
245
+ ports = consolePorts(parsed)
246
+ }
247
+ const plan = planFor(id, { devices: parsed ? [...ports.keys()] : [...(ports?.keys() ?? [])] })
248
+ const results = []
249
+ for (const check of plan.checks) {
250
+ const port = ports.get(check.on)
251
+ onProgress?.({ phase: 'check', id: check.id, device: check.on })
252
+ if (!port) {
253
+ results.push({
254
+ ...check,
255
+ device: check.on,
256
+ pass: false,
257
+ why: `no console port for device "${check.on}"`,
258
+ snippet: '',
259
+ })
260
+ continue
261
+ }
262
+ let res
263
+ try {
264
+ res = await query({ port, command: check.command, timeoutMs })
265
+ } catch (err) {
266
+ results.push({ ...check, device: check.on, pass: false, why: `command failed: ${err.message}`, snippet: '' })
267
+ continue
268
+ }
269
+ const output = res?.output || ''
270
+ // A console that never opened is a different failure from a command that ran and
271
+ // printed the wrong thing. Saying "not contains Full" when nothing answered sends
272
+ // the reader looking at OSPF instead of at the device that is not running.
273
+ const unreachable = res?.ok === false
274
+ const errored = /(^|\n)\s*(Error|Unrecognized command|Incomplete command|Wrong parameter|Too many parameters)/.test(output)
275
+ let verdict = unreachable
276
+ ? { pass: false, why: `console unreachable: ${res.error || 'no answer'}` }
277
+ : errored
278
+ ? { pass: false, why: 'VRP rejected the command' }
279
+ : evaluateAssertion(output, check.expect)
280
+
281
+ // Routing protocols converge after the command returns, not before it. A single
282
+ // snapshot taken two seconds after `ensp apply` reports "not contains Full" for a
283
+ // session that reaches Full ten seconds later, which reads as a broken lab. Retry
284
+ // the assertion itself, never a rejected command and never a dead console.
285
+ let waited = 0
286
+ let lastOutput = output
287
+ while (!verdict.pass && !unreachable && !errored && waited < waitMs) {
288
+ onProgress?.({ phase: 'retry', id: check.id, device: check.on, waited, why: verdict.why })
289
+ const nap = Math.min(intervalMs, waitMs - waited)
290
+ await new Promise((r) => setTimeout(r, nap))
291
+ waited += nap
292
+ let again
293
+ try {
294
+ again = await query({ port, command: check.command, timeoutMs })
295
+ } catch (err) {
296
+ verdict = { pass: false, why: `command failed: ${err.message}` }
297
+ break
298
+ }
299
+ if (again?.ok === false) {
300
+ verdict = { pass: false, why: `console unreachable: ${again.error || 'no answer'}` }
301
+ break
302
+ }
303
+ lastOutput = again?.output || ''
304
+ verdict = evaluateAssertion(lastOutput, check.expect)
305
+ }
306
+ results.push({
307
+ ...check,
308
+ device: check.on,
309
+ pass: verdict.pass,
310
+ why: verdict.why,
311
+ waitedMs: waited,
312
+ snippet: lastOutput.trim().slice(0, SNIPPET),
313
+ })
314
+ }
315
+ const failed = results.filter((r) => !r.pass)
316
+ return {
317
+ scenario: plan.scenario,
318
+ title: plan.title,
319
+ source: plan.source,
320
+ ok: failed.length === 0,
321
+ total: results.length,
322
+ passed: results.length - failed.length,
323
+ results,
324
+ }
325
+ }
326
+
327
+ /** Render an acceptance report for a terminal. */
328
+ export function formatAcceptance(report) {
329
+ const lines = [`${report.scenario} — ${report.title}`, `(${report.source} checks)`]
330
+ for (const r of report.results) {
331
+ lines.push(
332
+ `[${r.pass ? 'pass' : 'FAIL'}] ${r.id} on ${r.on}: ${r.command}` +
333
+ (r.pass && r.waitedMs ? ` (converged after ${Math.round(r.waitedMs / 1000)}s)` : '') +
334
+ (r.pass ? '' : `\n ${r.why}`) +
335
+ (process.env.ENSP_VERBOSE && r.snippet ? `\n ${r.snippet.replace(/\n/g, '\n ')}` : ''),
336
+ )
337
+ }
338
+ lines.push('')
339
+ lines.push(
340
+ report.ok
341
+ ? `accepted: ${report.passed}/${report.total} checks passed`
342
+ : `rejected: ${report.total - report.passed}/${report.total} checks failed`,
343
+ )
344
+ return lines.join('\n')
345
+ }