@applesnort/crosscheck 0.2.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/LICENSE +21 -0
- package/PROVENANCE.md +57 -0
- package/README.md +244 -0
- package/bin/crosscheck.mjs +426 -0
- package/fixtures/calibration/PREREGISTERED.md +522 -0
- package/fixtures/calibration/expected.json +144 -0
- package/fixtures/calibration/src/session.js +123 -0
- package/foreman.md +140 -0
- package/lenses/architect.md +92 -0
- package/lenses/check.md +87 -0
- package/lenses/security-check.md +105 -0
- package/lenses/taint.md +102 -0
- package/lenses/ux.md +105 -0
- package/lib/baseline.mjs +78 -0
- package/lib/calibrate.mjs +169 -0
- package/lib/corpus.mjs +340 -0
- package/lib/lenses.mjs +200 -0
- package/lib/merge.mjs +310 -0
- package/lib/parse.mjs +96 -0
- package/lib/prompt.mjs +85 -0
- package/lib/run.mjs +129 -0
- package/lib/sarif.mjs +176 -0
- package/package.json +53 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*!
|
|
3
|
+
* Copyright (c) 2026 Joel Mangin. MIT License.
|
|
4
|
+
*/
|
|
5
|
+
// crosscheck — run a review panel, or merge output a panel already produced.
|
|
6
|
+
//
|
|
7
|
+
// `run` dispatches the lenses and reports. The other commands take lens output
|
|
8
|
+
// that already exists and do the deterministic half: parse, merge, dedupe, score
|
|
9
|
+
// consensus, apply a baseline, emit.
|
|
10
|
+
//
|
|
11
|
+
// crosscheck never talks to a model itself. `run --exec` names a command that
|
|
12
|
+
// receives one lens prompt on stdin and returns findings on stdout, so any agent
|
|
13
|
+
// CLI or wrapper works.
|
|
14
|
+
//
|
|
15
|
+
// For the commands below other than `run`, input is JSON on stdin or via --in:
|
|
16
|
+
// [{"lens": "check", "output": "lib/a.js:41 — BLOCK — issue — fix"},
|
|
17
|
+
// {"lens": "ux", "output": null}] <- null means the lens died
|
|
18
|
+
//
|
|
19
|
+
// Usage:
|
|
20
|
+
// crosscheck run <path...> --exec '<command>' [--lenses dir] [--only a,b]
|
|
21
|
+
// [--skip x,y] [--concurrency N] [--out run.json]
|
|
22
|
+
// [--sarif f] [--baseline b] [--mixed] [--dry-run]
|
|
23
|
+
// crosscheck report [--in run.json] [--baseline b.json]
|
|
24
|
+
// crosscheck sarif [--in run.json] [--baseline b.json] [--out x.sarif]
|
|
25
|
+
// crosscheck baseline [--in run.json] --out baseline.json
|
|
26
|
+
// crosscheck overlap [--in run.json] [--out overlap.json]
|
|
27
|
+
// crosscheck calibrate [--in run.json] --expected expected.json
|
|
28
|
+
//
|
|
29
|
+
// Options: --overlap <file> independence data from `overlap` (report/sarif)
|
|
30
|
+
// --lenses <dir> lens directory (routing + SARIF rule metadata)
|
|
31
|
+
//
|
|
32
|
+
// `run` dispatches the lenses itself. crosscheck never talks to a model: --exec
|
|
33
|
+
// names a command that receives one lens prompt on stdin and returns findings on
|
|
34
|
+
// stdout, so any agent CLI works. Examples:
|
|
35
|
+
// crosscheck run lib/ --exec 'claude -p'
|
|
36
|
+
// crosscheck run lib/ --exec 'llm -m gpt-4o'
|
|
37
|
+
// crosscheck run lib/ --exec 'my-wrapper --json' --concurrency 2
|
|
38
|
+
// Use --dry-run to print the roster and prompts without spawning anything.
|
|
39
|
+
|
|
40
|
+
import { spawn } from 'node:child_process';
|
|
41
|
+
import {
|
|
42
|
+
existsSync, readFileSync, readdirSync, statSync, writeFileSync
|
|
43
|
+
} from 'node:fs';
|
|
44
|
+
import { join, relative, resolve } from 'node:path';
|
|
45
|
+
import { formatScore, score } from '../lib/calibrate.mjs';
|
|
46
|
+
import { parseFrontmatter } from '../lib/lenses.mjs';
|
|
47
|
+
import {
|
|
48
|
+
countsBySeverity, lensOverlap, mergeFindings, panelVerdict
|
|
49
|
+
} from '../lib/merge.mjs';
|
|
50
|
+
import { filterAgainstBaseline, staleBaselineEntries, toBaseline }
|
|
51
|
+
from '../lib/baseline.mjs';
|
|
52
|
+
import { parseReports } from '../lib/parse.mjs';
|
|
53
|
+
import { planRun, promptsFor, runPanel } from '../lib/run.mjs';
|
|
54
|
+
import { toSarifJson } from '../lib/sarif.mjs';
|
|
55
|
+
|
|
56
|
+
function fail(message) {
|
|
57
|
+
process.stderr.write(`crosscheck: ${message}\n`);
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const BOOLEAN_FLAGS = new Set(['dry-run', 'mixed']);
|
|
62
|
+
|
|
63
|
+
function parseArgs(argv) {
|
|
64
|
+
const [command, ...rest] = argv;
|
|
65
|
+
const options = {};
|
|
66
|
+
const positional = [];
|
|
67
|
+
for (let i = 0; i < rest.length; i++) {
|
|
68
|
+
const arg = rest[i];
|
|
69
|
+
if (!arg.startsWith('--')) {
|
|
70
|
+
positional.push(arg);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const key = arg.slice(2);
|
|
74
|
+
if (BOOLEAN_FLAGS.has(key)) {
|
|
75
|
+
options[key] = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const value = rest[i + 1];
|
|
79
|
+
if (value == null || value.startsWith('--')) {
|
|
80
|
+
fail(`--${key} requires a value`);
|
|
81
|
+
}
|
|
82
|
+
options[key] = value;
|
|
83
|
+
i += 1;
|
|
84
|
+
}
|
|
85
|
+
return { command, options, positional };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readStdin() {
|
|
89
|
+
try {
|
|
90
|
+
return readFileSync(0, 'utf8');
|
|
91
|
+
} catch {
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function loadRun(options) {
|
|
97
|
+
const raw = options.in ? readFileSync(options.in, 'utf8') : readStdin();
|
|
98
|
+
if (!raw.trim()) {
|
|
99
|
+
fail('no input — pass --in <file> or pipe lens output JSON on stdin');
|
|
100
|
+
}
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(raw);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
fail(`input is not valid JSON: ${error.message}`);
|
|
106
|
+
}
|
|
107
|
+
if (!Array.isArray(parsed)) {
|
|
108
|
+
fail('input must be an array of {lens, output} objects');
|
|
109
|
+
}
|
|
110
|
+
return parsed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function loadJson(path) {
|
|
114
|
+
return path ? JSON.parse(readFileSync(path, 'utf8')) : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function loadLensMeta(dir) {
|
|
118
|
+
if (!dir) {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
const meta = {};
|
|
122
|
+
for (const lens of loadLenses(dir)) {
|
|
123
|
+
meta[lens.name] = lens;
|
|
124
|
+
}
|
|
125
|
+
return meta;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Lens directory: an explicit --lenses, else ./lenses, else the copy shipped
|
|
129
|
+
// with the package. Resolved loudly so a typo does not silently run zero lenses.
|
|
130
|
+
function resolveLensDir(dir) {
|
|
131
|
+
const candidates = dir
|
|
132
|
+
? [resolve(dir)]
|
|
133
|
+
: [resolve('lenses'), new URL('../lenses/', import.meta.url).pathname];
|
|
134
|
+
for (const candidate of candidates) {
|
|
135
|
+
if (existsSync(candidate)) {
|
|
136
|
+
return candidate;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
fail(`no lens directory found (looked in ${candidates.join(', ')})`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function loadLenses(dir) {
|
|
143
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
144
|
+
const lenses = [];
|
|
145
|
+
for (const file of files) {
|
|
146
|
+
const path = join(dir, file);
|
|
147
|
+
const text = readFileSync(path, 'utf8');
|
|
148
|
+
const meta = parseFrontmatter(text);
|
|
149
|
+
if (!meta?.name) {
|
|
150
|
+
process.stderr.write(
|
|
151
|
+
`crosscheck: skipping ${file} — no frontmatter with a name\n`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
lenses.push({ ...meta, definition: text, definitionPath: path });
|
|
155
|
+
}
|
|
156
|
+
if (lenses.length === 0) {
|
|
157
|
+
fail(`no usable lens definitions in ${dir}`);
|
|
158
|
+
}
|
|
159
|
+
return lenses;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Expand the positional targets into a concrete file list. Directories are walked;
|
|
163
|
+
// everything is reported relative to cwd so paths in findings match what the user
|
|
164
|
+
// typed.
|
|
165
|
+
function collectFiles(targets) {
|
|
166
|
+
const out = [];
|
|
167
|
+
const walk = path => {
|
|
168
|
+
const stat = statSync(path);
|
|
169
|
+
if (stat.isDirectory()) {
|
|
170
|
+
for (const entry of readdirSync(path)) {
|
|
171
|
+
if (entry === 'node_modules' || entry.startsWith('.')) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
walk(join(path, entry));
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
out.push(relative(process.cwd(), path) || path);
|
|
179
|
+
};
|
|
180
|
+
for (const target of targets) {
|
|
181
|
+
if (!existsSync(target)) {
|
|
182
|
+
fail(`no such path: ${target}`);
|
|
183
|
+
}
|
|
184
|
+
walk(target);
|
|
185
|
+
}
|
|
186
|
+
if (out.length === 0) {
|
|
187
|
+
fail('the target expanded to zero files');
|
|
188
|
+
}
|
|
189
|
+
return out.sort();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Spawn the user's command with the prompt on stdin. crosscheck stays agnostic
|
|
193
|
+
// about which model or framework produced the text.
|
|
194
|
+
function execCommand(commandLine) {
|
|
195
|
+
return ({ prompt }) => new Promise((resolvePromise, rejectPromise) => {
|
|
196
|
+
const child = spawn(commandLine, {
|
|
197
|
+
shell: true,
|
|
198
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
199
|
+
});
|
|
200
|
+
let stdout = '';
|
|
201
|
+
let stderr = '';
|
|
202
|
+
child.stdout.on('data', d => { stdout += d; });
|
|
203
|
+
child.stderr.on('data', d => { stderr += d; });
|
|
204
|
+
child.on('error', rejectPromise);
|
|
205
|
+
child.on('close', code => resolvePromise({ stdout, stderr, code }));
|
|
206
|
+
child.stdin.on('error', rejectPromise);
|
|
207
|
+
child.stdin.end(prompt);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function buildMerged(options) {
|
|
212
|
+
const reports = parseReports(loadRun(options));
|
|
213
|
+
const overlap = loadJson(options.overlap) ?? undefined;
|
|
214
|
+
const merged = mergeFindings(reports, { overlap });
|
|
215
|
+
const baseline = loadJson(options.baseline);
|
|
216
|
+
if (!baseline) {
|
|
217
|
+
return { merged, reports, suppressed: [], stale: [] };
|
|
218
|
+
}
|
|
219
|
+
const { findings, suppressed } =
|
|
220
|
+
filterAgainstBaseline(merged.findings, baseline);
|
|
221
|
+
const stale = staleBaselineEntries(baseline, merged.findings);
|
|
222
|
+
return {
|
|
223
|
+
merged: { ...merged, findings }, reports, suppressed, stale
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function report({ merged, suppressed, stale }) {
|
|
228
|
+
const counts = countsBySeverity(merged.findings);
|
|
229
|
+
const out = [];
|
|
230
|
+
out.push('# Crosscheck report');
|
|
231
|
+
if (merged.incomplete.length) {
|
|
232
|
+
out.push('', `**Did not complete: ${merged.incomplete.join(', ')}** — ` +
|
|
233
|
+
'their coverage is missing from this report.');
|
|
234
|
+
}
|
|
235
|
+
if (suppressed.length) {
|
|
236
|
+
out.push('', `Suppressed by baseline: ${suppressed.length}.`);
|
|
237
|
+
}
|
|
238
|
+
if (stale.length) {
|
|
239
|
+
out.push('', `Baseline entries no longer reported: ${stale.length} — ` +
|
|
240
|
+
'either fixed, or a lens stopped running.');
|
|
241
|
+
}
|
|
242
|
+
if (merged.unparsed.length) {
|
|
243
|
+
out.push('', `Unparsed lens lines: ${merged.unparsed.length} ` +
|
|
244
|
+
`(${[...new Set(merged.unparsed.map(u => u.lens))].join(', ')}).`);
|
|
245
|
+
}
|
|
246
|
+
for (const severity of ['BLOCK', 'FIX', 'CONSIDER']) {
|
|
247
|
+
const group = merged.findings.filter(f => f.severity === severity);
|
|
248
|
+
out.push('', `## ${severity} (${group.length})`);
|
|
249
|
+
if (group.length === 0) {
|
|
250
|
+
out.push('None.');
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
for (const f of group) {
|
|
254
|
+
const who = f.consensus
|
|
255
|
+
? `CONSENSUS ${f.consensusScore}: ${f.lenses.join(', ')}`
|
|
256
|
+
: f.lenses.join(', ');
|
|
257
|
+
out.push(`- [${who}] ${f.file}:${f.line} — ${f.issue}` +
|
|
258
|
+
(f.fix ? ` — ${f.fix}` : ''));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
out.push('', '## Panel verdict',
|
|
262
|
+
`${panelVerdict(counts)} — ${counts.BLOCK} block, ${counts.FIX} fix, ` +
|
|
263
|
+
`${counts.CONSIDER} consider; ` +
|
|
264
|
+
`${merged.findings.filter(f => f.consensus).length} consensus.`);
|
|
265
|
+
return out.join('\n') + '\n';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function write(options, text) {
|
|
269
|
+
if (options.out) {
|
|
270
|
+
writeFileSync(options.out, text);
|
|
271
|
+
process.stderr.write(`crosscheck: wrote ${options.out}\n`);
|
|
272
|
+
} else {
|
|
273
|
+
process.stdout.write(text);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function runCommand(options, positional) {
|
|
278
|
+
if (positional.length === 0) {
|
|
279
|
+
fail('run needs at least one path to audit');
|
|
280
|
+
}
|
|
281
|
+
if (!options.exec && !options['dry-run']) {
|
|
282
|
+
fail("run needs --exec '<command>' (or --dry-run to see the prompts)");
|
|
283
|
+
}
|
|
284
|
+
const files = collectFiles(positional);
|
|
285
|
+
const lensDir = resolveLensDir(options.lenses);
|
|
286
|
+
const lenses = loadLenses(lensDir);
|
|
287
|
+
const overrides = {
|
|
288
|
+
only: options.only?.split(',').map(s => s.trim()).filter(Boolean),
|
|
289
|
+
skip: options.skip?.split(',').map(s => s.trim()).filter(Boolean)
|
|
290
|
+
};
|
|
291
|
+
const { roster, skipped } = planRun(lenses, files, overrides);
|
|
292
|
+
|
|
293
|
+
process.stderr.write(
|
|
294
|
+
`crosscheck: ${files.length} file(s), lenses from ${lensDir}\n` +
|
|
295
|
+
` roster: ${roster.map(l => l.name).join(', ') || '(none)'}\n` +
|
|
296
|
+
(skipped.length
|
|
297
|
+
? skipped.map(s => ` skipped: ${s.lens} — ${s.reason}`).join('\n') + '\n'
|
|
298
|
+
: ''));
|
|
299
|
+
|
|
300
|
+
if (roster.length === 0) {
|
|
301
|
+
fail('no lens matched the target; nothing to run');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const promptOptions = { mixedCorpus: Boolean(options.mixed) };
|
|
305
|
+
|
|
306
|
+
if (options['dry-run']) {
|
|
307
|
+
for (const job of promptsFor(roster, promptOptions)) {
|
|
308
|
+
process.stdout.write(
|
|
309
|
+
`\n===== ${job.lens} (${job.files.length} file(s)) =====\n${job.prompt}\n`);
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const { reports, failures } = await runPanel({
|
|
315
|
+
roster,
|
|
316
|
+
skipped,
|
|
317
|
+
exec: execCommand(options.exec),
|
|
318
|
+
concurrency: Number(options.concurrency ?? 4),
|
|
319
|
+
promptOptions,
|
|
320
|
+
onLensStart: lens => process.stderr.write(` → ${lens}\n`),
|
|
321
|
+
onLensDone: (lens, r) => process.stderr.write(
|
|
322
|
+
r.ok ? ` ✓ ${lens} (${r.findings} finding(s))\n` : ` ✗ ${lens} did not complete\n`)
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
for (const f of failures) {
|
|
326
|
+
process.stderr.write(`crosscheck: ${f.lens} failed — ${f.reason}\n`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// The raw lens output is written out whenever asked, so a run can be rescored
|
|
330
|
+
// later without paying for the model again.
|
|
331
|
+
if (options.out) {
|
|
332
|
+
writeFileSync(options.out, JSON.stringify(
|
|
333
|
+
reports.map(r => ({ lens: r.lens, output: r.output ?? null })),
|
|
334
|
+
null, 2) + '\n');
|
|
335
|
+
process.stderr.write(`crosscheck: wrote ${options.out}\n`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const overlap = loadJson(options.overlap) ?? undefined;
|
|
339
|
+
let merged = mergeFindings(reports, { overlap });
|
|
340
|
+
let suppressed = [];
|
|
341
|
+
let stale = [];
|
|
342
|
+
const baseline = loadJson(options.baseline);
|
|
343
|
+
if (baseline) {
|
|
344
|
+
const filtered = filterAgainstBaseline(merged.findings, baseline);
|
|
345
|
+
stale = staleBaselineEntries(baseline, merged.findings);
|
|
346
|
+
suppressed = filtered.suppressed;
|
|
347
|
+
merged = { ...merged, findings: filtered.findings };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
process.stdout.write(report({ merged, suppressed, stale }));
|
|
351
|
+
|
|
352
|
+
if (options.sarif) {
|
|
353
|
+
writeFileSync(options.sarif, toSarifJson(merged, {
|
|
354
|
+
lensMeta: loadLensMeta(lensDir)
|
|
355
|
+
}));
|
|
356
|
+
process.stderr.write(`crosscheck: wrote ${options.sarif}\n`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// A panel missing a lens has not produced a full review; say so in the exit
|
|
360
|
+
// code as well as the report.
|
|
361
|
+
if (failures.length > 0) {
|
|
362
|
+
process.exit(1);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function main() {
|
|
367
|
+
const { command, options, positional } = parseArgs(process.argv.slice(2));
|
|
368
|
+
|
|
369
|
+
if (!command || command === 'help' || command === '--help') {
|
|
370
|
+
process.stdout.write(readFileSync(new URL(import.meta.url), 'utf8')
|
|
371
|
+
.split('\n').filter(l => l.startsWith('//')).map(l => l.slice(3))
|
|
372
|
+
.join('\n') + '\n');
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (command === 'run') {
|
|
377
|
+
await runCommand(options, positional);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (command === 'report') {
|
|
382
|
+
write(options, report(buildMerged(options)));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (command === 'sarif') {
|
|
387
|
+
const { merged } = buildMerged(options);
|
|
388
|
+
write(options, toSarifJson(merged, {
|
|
389
|
+
lensMeta: loadLensMeta(options.lenses)
|
|
390
|
+
}));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (command === 'baseline') {
|
|
395
|
+
const { merged } = buildMerged({ ...options, baseline: undefined });
|
|
396
|
+
if (!options.out) {
|
|
397
|
+
fail('baseline requires --out <file>');
|
|
398
|
+
}
|
|
399
|
+
write({ out: options.out },
|
|
400
|
+
JSON.stringify(toBaseline(merged.findings, {
|
|
401
|
+
note: 'Findings present before this baseline was taken.'
|
|
402
|
+
}), null, 2) + '\n');
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (command === 'overlap') {
|
|
407
|
+
const reports = parseReports(loadRun(options));
|
|
408
|
+
write(options, JSON.stringify(lensOverlap(reports), null, 2) + '\n');
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (command === 'calibrate') {
|
|
413
|
+
if (!options.expected) {
|
|
414
|
+
fail('calibrate requires --expected <expected.json>');
|
|
415
|
+
}
|
|
416
|
+
const { merged } = buildMerged(options);
|
|
417
|
+
const result = score(merged.findings, loadJson(options.expected));
|
|
418
|
+
process.stdout.write(formatScore(result) + '\n');
|
|
419
|
+
// A panel that missed a planted defect is a failing panel.
|
|
420
|
+
process.exit(result.missed.length > 0 ? 1 : 0);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
fail(`unknown command: ${command}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
await main();
|