tend-cli 0.1.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/README.md +95 -0
- package/configs/default.eslint.config.mjs +47 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +1754 -0
- package/dist/config-B5rO-fvz.js +1745 -0
- package/dist/index.d.ts +1274 -0
- package/dist/index.js +87 -0
- package/package.json +67 -0
- package/prompts/fix.md +29 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,1754 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ClaudeSession, EFFORT_LEVELS, EventBus, ReportBuilder, ReportSchema, Snapshot, addUsage, applyCliOverrides, assertGitRepo, buildProgram, changedVsHead, createGit, detectPackageManager, filesUnder, filterToChanged, formatClock, loadConfig, makeTheme, normalize, orchestrate, planWork, reasonLabel, renderSummary, resolveRetryTarget, retryCommand, runScanner, scannerStatus, showCommand, zeroUsage } from "./config-B5rO-fvz.js";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { execa } from "execa";
|
|
6
|
+
import { ESLint } from "eslint";
|
|
7
|
+
import sonarjs from "eslint-plugin-sonarjs";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { Listr, ListrDefaultRendererLogLevels } from "listr2";
|
|
12
|
+
|
|
13
|
+
//#region src/scanners/eslint-default-config.ts
|
|
14
|
+
/** Walk up from this module to tend's own package root (dir of its package.json named tend-cli). */
|
|
15
|
+
function tendPackageRoot() {
|
|
16
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
for (let i = 0; i < 8; i++) {
|
|
18
|
+
const pkgJson = join(dir, "package.json");
|
|
19
|
+
if (existsSync(pkgJson)) try {
|
|
20
|
+
if (JSON.parse(readFileSync(pkgJson, "utf8")).name === "tend-cli") return dir;
|
|
21
|
+
} catch {}
|
|
22
|
+
const parent = dirname(dir);
|
|
23
|
+
if (parent === dir) break;
|
|
24
|
+
dir = parent;
|
|
25
|
+
}
|
|
26
|
+
return dirname(dirname(fileURLToPath(import.meta.url)));
|
|
27
|
+
}
|
|
28
|
+
/** Absolute path to tend's bundled default config (eslint recommended + sonarjs). */
|
|
29
|
+
function defaultEslintConfigPath() {
|
|
30
|
+
return join(tendPackageRoot(), "configs", "default.eslint.config.mjs");
|
|
31
|
+
}
|
|
32
|
+
const ESLINT_CONFIG_FILES = [
|
|
33
|
+
"eslint.config.js",
|
|
34
|
+
"eslint.config.mjs",
|
|
35
|
+
"eslint.config.cjs",
|
|
36
|
+
"eslint.config.ts",
|
|
37
|
+
"eslint.config.mts",
|
|
38
|
+
"eslint.config.cts",
|
|
39
|
+
".eslintrc.js",
|
|
40
|
+
".eslintrc.cjs",
|
|
41
|
+
".eslintrc.yaml",
|
|
42
|
+
".eslintrc.yml",
|
|
43
|
+
".eslintrc.json",
|
|
44
|
+
".eslintrc"
|
|
45
|
+
];
|
|
46
|
+
function readPackageJson(cwd$1) {
|
|
47
|
+
const p = join(cwd$1, "package.json");
|
|
48
|
+
if (!existsSync(p)) return null;
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Does the project have any eslint config (a config file, or an `eslintConfig` key in package.json)? */
|
|
56
|
+
function projectHasEslintConfig(cwd$1) {
|
|
57
|
+
if (ESLINT_CONFIG_FILES.some((name) => existsSync(join(cwd$1, name)))) return true;
|
|
58
|
+
return Boolean(readPackageJson(cwd$1)?.["eslintConfig"]);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Nearest directory at or above `startDir`, up to and including `boundaryDir`, that holds an
|
|
62
|
+
* eslint config — or null if none. Lets tend resolve each scoped file's governing config by
|
|
63
|
+
* walking upward from the file, so a monorepo package keeps its own config even when tend is
|
|
64
|
+
* invoked from the repo root (where there may be no config at all).
|
|
65
|
+
*/
|
|
66
|
+
function findEslintConfigDir(startDir, boundaryDir) {
|
|
67
|
+
const boundary = resolve(boundaryDir);
|
|
68
|
+
let dir = resolve(startDir);
|
|
69
|
+
for (;;) {
|
|
70
|
+
if (projectHasEslintConfig(dir)) return dir;
|
|
71
|
+
if (dir === boundary) return null;
|
|
72
|
+
const parent = dirname(dir);
|
|
73
|
+
if (parent === dir) return null;
|
|
74
|
+
dir = parent;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function dependsOnSonarjs(cwd$1) {
|
|
78
|
+
const pkg = readPackageJson(cwd$1);
|
|
79
|
+
if (!pkg) return false;
|
|
80
|
+
for (const field of [
|
|
81
|
+
"dependencies",
|
|
82
|
+
"devDependencies",
|
|
83
|
+
"peerDependencies",
|
|
84
|
+
"optionalDependencies"
|
|
85
|
+
]) {
|
|
86
|
+
const deps = pkg[field];
|
|
87
|
+
if (deps?.["eslint-plugin-sonarjs"]) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
function configMentionsSonarjs(cwd$1) {
|
|
92
|
+
for (const name of ESLINT_CONFIG_FILES) {
|
|
93
|
+
const p = join(cwd$1, name);
|
|
94
|
+
if (existsSync(p)) try {
|
|
95
|
+
if (readFileSync(p, "utf8").includes("sonarjs")) return true;
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
const eslintConfig = readPackageJson(cwd$1)?.["eslintConfig"];
|
|
99
|
+
return eslintConfig ? JSON.stringify(eslintConfig).includes("sonarjs") : false;
|
|
100
|
+
}
|
|
101
|
+
/** Project configures sonarjs = plugin is a dependency AND a config references it. */
|
|
102
|
+
function projectConfiguresSonarjs(cwd$1) {
|
|
103
|
+
return dependsOnSonarjs(cwd$1) && configMentionsSonarjs(cwd$1);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* How tend should run eslint+sonarjs for a project:
|
|
107
|
+
* - `default` — no project eslint config → use tend's config (eslint recommended + sonarjs)
|
|
108
|
+
* - `layer` — project eslint config without sonarjs → use theirs + sonarjs layered on top
|
|
109
|
+
* - `defer` — project eslint config already includes sonarjs → use theirs untouched
|
|
110
|
+
*/
|
|
111
|
+
function eslintMode(cwd$1) {
|
|
112
|
+
if (!projectHasEslintConfig(cwd$1)) return "default";
|
|
113
|
+
return projectConfiguresSonarjs(cwd$1) ? "defer" : "layer";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/scanners/paths.ts
|
|
118
|
+
/** Make a scanner-reported path repo-relative (POSIX separators); pass relatives through. */
|
|
119
|
+
function toRepoRelative(cwd$1, file) {
|
|
120
|
+
const rel = isAbsolute(file) ? relative(cwd$1, file) : file;
|
|
121
|
+
return rel.split("\\").join("/");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/scanners/eslint-sonarjs.ts
|
|
126
|
+
/** Map ESLint results (CLI JSON or Node-API LintResult[]) into tend's RawFindings. */
|
|
127
|
+
function mapEslintResults(results, ctx) {
|
|
128
|
+
const findings = [];
|
|
129
|
+
for (const result of results) {
|
|
130
|
+
const file = toRepoRelative(ctx.cwd, result.filePath);
|
|
131
|
+
for (const msg of result.messages) {
|
|
132
|
+
if (msg.ruleId === null) continue;
|
|
133
|
+
findings.push({
|
|
134
|
+
tool: "sonarjs",
|
|
135
|
+
rule: msg.ruleId,
|
|
136
|
+
category: "smell",
|
|
137
|
+
severity: msg.severity === 2 ? "error" : "warning",
|
|
138
|
+
file,
|
|
139
|
+
range: {
|
|
140
|
+
startLine: msg.line,
|
|
141
|
+
startCol: msg.column,
|
|
142
|
+
endLine: msg.endLine ?? msg.line,
|
|
143
|
+
endCol: msg.endColumn ?? msg.column
|
|
144
|
+
},
|
|
145
|
+
message: msg.message
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return findings;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Group scoped files by their governing eslint config. Each file's config is resolved by walking
|
|
153
|
+
* up from the file's directory (bounded by ctx.cwd) — NOT from ctx.cwd alone — so files in a
|
|
154
|
+
* monorepo package use that package's config even when tend runs from the repo root.
|
|
155
|
+
*/
|
|
156
|
+
function groupByConfig(ctx) {
|
|
157
|
+
const boundary = resolve(ctx.cwd);
|
|
158
|
+
const byDir = new Map();
|
|
159
|
+
for (const file of ctx.files) {
|
|
160
|
+
const abs = resolve(ctx.cwd, file);
|
|
161
|
+
const configDir = findEslintConfigDir(dirname(abs), boundary);
|
|
162
|
+
const key = configDir ?? "";
|
|
163
|
+
(byDir.get(key) ?? byDir.set(key, []).get(key)).push(abs);
|
|
164
|
+
}
|
|
165
|
+
return [...byDir.entries()].map(([key, absFiles]) => {
|
|
166
|
+
if (key === "") return {
|
|
167
|
+
configDir: null,
|
|
168
|
+
mode: "default",
|
|
169
|
+
cwd: ctx.cwd,
|
|
170
|
+
targets: absFiles.map((f) => relative(ctx.cwd, f))
|
|
171
|
+
};
|
|
172
|
+
return {
|
|
173
|
+
configDir: key,
|
|
174
|
+
mode: projectConfiguresSonarjs(key) ? "defer" : "layer",
|
|
175
|
+
cwd: key,
|
|
176
|
+
targets: absFiles.map((f) => relative(key, f))
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/** Lint one group through the Node API; ESLint returns absolute filePaths regardless of cwd. */
|
|
181
|
+
async function lintGroup(group) {
|
|
182
|
+
const options = {
|
|
183
|
+
cwd: group.cwd,
|
|
184
|
+
errorOnUnmatchedPattern: false
|
|
185
|
+
};
|
|
186
|
+
if (group.mode === "default") options.overrideConfigFile = defaultEslintConfigPath();
|
|
187
|
+
else if (group.mode === "layer") options.overrideConfig = [sonarjs.configs.recommended];
|
|
188
|
+
const eslint = new ESLint(options);
|
|
189
|
+
return await eslint.lintFiles(group.targets);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Run eslint+sonarjs via the Node API (eslint is bundled). Resolves the applicable config PER
|
|
193
|
+
* FILE and runs one pass per config group, so monorepo packages are linted under their own
|
|
194
|
+
* config. Three modes per group:
|
|
195
|
+
* default → tend's config · layer → project config + sonarjs · defer → project config.
|
|
196
|
+
* Output paths stay relative to the original ctx.cwd so finding IDs/filtering are unaffected.
|
|
197
|
+
*/
|
|
198
|
+
async function runEslintSonarjs(ctx) {
|
|
199
|
+
const groups = ctx.files.length === 0 ? [{
|
|
200
|
+
configDir: null,
|
|
201
|
+
mode: eslintMode(ctx.cwd),
|
|
202
|
+
cwd: ctx.cwd,
|
|
203
|
+
targets: ["."]
|
|
204
|
+
}] : groupByConfig(ctx);
|
|
205
|
+
try {
|
|
206
|
+
const results = [];
|
|
207
|
+
for (const group of groups) results.push(...await lintGroup(group));
|
|
208
|
+
const findings = mapEslintResults(results, ctx).map((r) => normalize(r, ctx.loop));
|
|
209
|
+
return {
|
|
210
|
+
tool: "sonarjs",
|
|
211
|
+
findings,
|
|
212
|
+
skipped: false
|
|
213
|
+
};
|
|
214
|
+
} catch (err$1) {
|
|
215
|
+
return {
|
|
216
|
+
tool: "sonarjs",
|
|
217
|
+
findings: [],
|
|
218
|
+
skipped: false,
|
|
219
|
+
error: err$1 instanceof Error ? err$1.message : String(err$1)
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region src/scanners/gitleaks.ts
|
|
226
|
+
const gitleaksScanner = {
|
|
227
|
+
tool: "gitleaks",
|
|
228
|
+
binary: "gitleaks",
|
|
229
|
+
buildArgs() {
|
|
230
|
+
return [
|
|
231
|
+
"git",
|
|
232
|
+
"--report-format",
|
|
233
|
+
"json",
|
|
234
|
+
"--report-path",
|
|
235
|
+
"/dev/stdout",
|
|
236
|
+
"--no-banner"
|
|
237
|
+
];
|
|
238
|
+
},
|
|
239
|
+
parse(raw, ctx) {
|
|
240
|
+
const report = JSON.parse(raw.stdout);
|
|
241
|
+
return report.map((f) => ({
|
|
242
|
+
tool: "gitleaks",
|
|
243
|
+
rule: f.RuleID,
|
|
244
|
+
category: "secret",
|
|
245
|
+
severity: "error",
|
|
246
|
+
file: toRepoRelative(ctx.cwd, f.File),
|
|
247
|
+
range: {
|
|
248
|
+
startLine: f.StartLine,
|
|
249
|
+
startCol: f.StartColumn,
|
|
250
|
+
endLine: f.EndLine,
|
|
251
|
+
endCol: f.EndColumn
|
|
252
|
+
},
|
|
253
|
+
message: f.Description
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region src/scanners/jscpd.ts
|
|
260
|
+
const DEFAULT_JSCPD_IGNORE_PATTERNS = [
|
|
261
|
+
"**/node_modules/**",
|
|
262
|
+
"**/.git/**",
|
|
263
|
+
"**/.next/**",
|
|
264
|
+
"**/.turbo/**",
|
|
265
|
+
"**/.vercel/**",
|
|
266
|
+
"**/coverage/**",
|
|
267
|
+
"**/dist/**",
|
|
268
|
+
"**/build/**",
|
|
269
|
+
"**/out/**",
|
|
270
|
+
"**/report/**"
|
|
271
|
+
];
|
|
272
|
+
/**
|
|
273
|
+
* Where jscpd's JSON report lives for this loop: a throwaway dir OUTSIDE the repo, so the
|
|
274
|
+
* `--reporters json` file never dirties the user's working tree. Deterministic in (pid, loop)
|
|
275
|
+
* so `buildArgs` (which creates it) and `parse` (which reads it) agree without shared state.
|
|
276
|
+
*/
|
|
277
|
+
function jscpdReportPath(ctx) {
|
|
278
|
+
const dir = join(tmpdir(), `tend-jscpd-${process.pid}-loop${ctx.loop}`);
|
|
279
|
+
return {
|
|
280
|
+
dir,
|
|
281
|
+
file: join(dir, "jscpd-report.json")
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/** Turn a parsed jscpd report into duplication findings. Pure — no IO. */
|
|
285
|
+
function mapJscpdReport(report, ctx) {
|
|
286
|
+
return (report.duplicates ?? []).map((dup) => {
|
|
287
|
+
const first = dup.firstFile;
|
|
288
|
+
const second = dup.secondFile;
|
|
289
|
+
const file = toRepoRelative(ctx.cwd, first.name);
|
|
290
|
+
const cloneFile = toRepoRelative(ctx.cwd, second.name);
|
|
291
|
+
return {
|
|
292
|
+
tool: "jscpd",
|
|
293
|
+
rule: "duplicate-code",
|
|
294
|
+
category: "duplication",
|
|
295
|
+
severity: "warning",
|
|
296
|
+
file,
|
|
297
|
+
range: {
|
|
298
|
+
startLine: first.start,
|
|
299
|
+
startCol: first.startLoc?.column ?? 0,
|
|
300
|
+
endLine: first.end,
|
|
301
|
+
endCol: second.endLoc?.column ?? 0
|
|
302
|
+
},
|
|
303
|
+
message: `Duplicated ${dup.lines} lines, also at ${cloneFile}:${second.start}-${second.end}`,
|
|
304
|
+
flowPath: [{
|
|
305
|
+
file,
|
|
306
|
+
line: first.start
|
|
307
|
+
}, {
|
|
308
|
+
file: cloneFile,
|
|
309
|
+
line: second.start
|
|
310
|
+
}]
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const jscpdScanner = {
|
|
315
|
+
tool: "jscpd",
|
|
316
|
+
binary: "jscpd",
|
|
317
|
+
buildArgs(ctx) {
|
|
318
|
+
const { dir } = jscpdReportPath(ctx);
|
|
319
|
+
mkdirSync(dir, { recursive: true });
|
|
320
|
+
return [
|
|
321
|
+
"--absolute",
|
|
322
|
+
"--reporters",
|
|
323
|
+
"json",
|
|
324
|
+
"--silent",
|
|
325
|
+
"--output",
|
|
326
|
+
dir,
|
|
327
|
+
"--ignore",
|
|
328
|
+
DEFAULT_JSCPD_IGNORE_PATTERNS.join(","),
|
|
329
|
+
ctx.cwd
|
|
330
|
+
];
|
|
331
|
+
},
|
|
332
|
+
parse(_raw, ctx) {
|
|
333
|
+
const { dir, file } = jscpdReportPath(ctx);
|
|
334
|
+
let json;
|
|
335
|
+
try {
|
|
336
|
+
json = readFileSync(file, "utf8");
|
|
337
|
+
} catch {
|
|
338
|
+
return [];
|
|
339
|
+
} finally {
|
|
340
|
+
rmSync(dir, {
|
|
341
|
+
recursive: true,
|
|
342
|
+
force: true
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return mapJscpdReport(JSON.parse(json), ctx);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region src/scanners/knip.ts
|
|
351
|
+
/** Each knip issue type → (rule name, human label). */
|
|
352
|
+
const ISSUE_TYPES = [
|
|
353
|
+
{
|
|
354
|
+
key: "files",
|
|
355
|
+
rule: "unused-file",
|
|
356
|
+
label: "Unused file"
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
key: "exports",
|
|
360
|
+
rule: "unused-export",
|
|
361
|
+
label: "Unused export"
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
key: "types",
|
|
365
|
+
rule: "unused-type",
|
|
366
|
+
label: "Unused exported type"
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
key: "enumMembers",
|
|
370
|
+
rule: "unused-enum-member",
|
|
371
|
+
label: "Unused enum member"
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
key: "dependencies",
|
|
375
|
+
rule: "unused-dependency",
|
|
376
|
+
label: "Unused dependency"
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
key: "devDependencies",
|
|
380
|
+
rule: "unused-dependency",
|
|
381
|
+
label: "Unused devDependency"
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
key: "optionalPeerDependencies",
|
|
385
|
+
rule: "unused-dependency",
|
|
386
|
+
label: "Unused optional peer dependency"
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
key: "unlisted",
|
|
390
|
+
rule: "unlisted-dependency",
|
|
391
|
+
label: "Unlisted dependency"
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
key: "unresolved",
|
|
395
|
+
rule: "unresolved-import",
|
|
396
|
+
label: "Unresolved import"
|
|
397
|
+
}
|
|
398
|
+
];
|
|
399
|
+
const knipScanner = {
|
|
400
|
+
tool: "knip",
|
|
401
|
+
binary: "knip",
|
|
402
|
+
buildArgs() {
|
|
403
|
+
return [
|
|
404
|
+
"--reporter",
|
|
405
|
+
"json",
|
|
406
|
+
"--no-progress"
|
|
407
|
+
];
|
|
408
|
+
},
|
|
409
|
+
parse(raw, ctx) {
|
|
410
|
+
const report = JSON.parse(raw.stdout);
|
|
411
|
+
const findings = [];
|
|
412
|
+
for (const path of report.files ?? []) findings.push({
|
|
413
|
+
tool: "knip",
|
|
414
|
+
rule: "unused-file",
|
|
415
|
+
category: "dead-code",
|
|
416
|
+
severity: "warning",
|
|
417
|
+
file: toRepoRelative(ctx.cwd, path),
|
|
418
|
+
range: {
|
|
419
|
+
startLine: 0,
|
|
420
|
+
startCol: 0,
|
|
421
|
+
endLine: 0,
|
|
422
|
+
endCol: 0
|
|
423
|
+
},
|
|
424
|
+
message: `Unused file: ${path}`
|
|
425
|
+
});
|
|
426
|
+
for (const entry of report.issues ?? []) {
|
|
427
|
+
const file = toRepoRelative(ctx.cwd, entry.file);
|
|
428
|
+
for (const { key, rule, label } of ISSUE_TYPES) {
|
|
429
|
+
const items = entry[key];
|
|
430
|
+
if (!Array.isArray(items)) continue;
|
|
431
|
+
for (const item of items) {
|
|
432
|
+
const line = item.line ?? 0;
|
|
433
|
+
findings.push({
|
|
434
|
+
tool: "knip",
|
|
435
|
+
rule,
|
|
436
|
+
category: "dead-code",
|
|
437
|
+
severity: "warning",
|
|
438
|
+
file,
|
|
439
|
+
range: {
|
|
440
|
+
startLine: line,
|
|
441
|
+
startCol: item.col ?? 0,
|
|
442
|
+
endLine: line,
|
|
443
|
+
endCol: item.col ?? 0
|
|
444
|
+
},
|
|
445
|
+
message: `${label}: ${item.name}`
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return findings;
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region src/scanners/osv.ts
|
|
456
|
+
/** First `fixed` version across a vulnerability's affected ranges, if any. */
|
|
457
|
+
function fixedVersion(vuln) {
|
|
458
|
+
for (const affected of vuln.affected ?? []) for (const range of affected.ranges ?? []) for (const event of range.events ?? []) if (event.fixed) return event.fixed;
|
|
459
|
+
return void 0;
|
|
460
|
+
}
|
|
461
|
+
const osvScanner = {
|
|
462
|
+
tool: "osv",
|
|
463
|
+
binary: "osv-scanner",
|
|
464
|
+
buildArgs(ctx) {
|
|
465
|
+
return [
|
|
466
|
+
"--format",
|
|
467
|
+
"json",
|
|
468
|
+
"--recursive",
|
|
469
|
+
ctx.cwd
|
|
470
|
+
];
|
|
471
|
+
},
|
|
472
|
+
parse(raw, ctx) {
|
|
473
|
+
const report = JSON.parse(raw.stdout);
|
|
474
|
+
const findings = [];
|
|
475
|
+
for (const result of report.results ?? []) {
|
|
476
|
+
const file = toRepoRelative(ctx.cwd, result.source.path);
|
|
477
|
+
for (const pkg of result.packages ?? []) {
|
|
478
|
+
const { name, version } = pkg.package;
|
|
479
|
+
for (const vuln of pkg.vulnerabilities ?? []) {
|
|
480
|
+
const fixed = fixedVersion(vuln);
|
|
481
|
+
const finding = {
|
|
482
|
+
tool: "osv",
|
|
483
|
+
rule: vuln.id,
|
|
484
|
+
category: "vuln-dep",
|
|
485
|
+
severity: "error",
|
|
486
|
+
file,
|
|
487
|
+
range: {
|
|
488
|
+
startLine: 0,
|
|
489
|
+
startCol: 0,
|
|
490
|
+
endLine: 0,
|
|
491
|
+
endCol: 0
|
|
492
|
+
},
|
|
493
|
+
message: vuln.summary ?? `${name}@${version} is vulnerable (${vuln.id})`
|
|
494
|
+
};
|
|
495
|
+
if (fixed) finding.remediation = `Bump ${name} from ${version} to ${fixed}`;
|
|
496
|
+
const ref = vuln.references?.[0]?.url;
|
|
497
|
+
if (ref) finding.helpUri = ref;
|
|
498
|
+
findings.push(finding);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return findings;
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
//#endregion
|
|
507
|
+
//#region src/scanners/semgrep.ts
|
|
508
|
+
const SEVERITY = {
|
|
509
|
+
ERROR: "error",
|
|
510
|
+
WARNING: "warning",
|
|
511
|
+
INFO: "info"
|
|
512
|
+
};
|
|
513
|
+
/** Pull the anchored location out of a taint_source/taint_sink tagged tuple. */
|
|
514
|
+
function locOf(trace) {
|
|
515
|
+
if (!Array.isArray(trace)) return void 0;
|
|
516
|
+
const [tag, payload] = trace;
|
|
517
|
+
if (tag === "CliLoc") return payload[0];
|
|
518
|
+
if (tag === "CliCall") return payload[0][0];
|
|
519
|
+
return void 0;
|
|
520
|
+
}
|
|
521
|
+
const semgrepScanner = {
|
|
522
|
+
tool: "semgrep",
|
|
523
|
+
binary: "semgrep",
|
|
524
|
+
buildArgs(ctx) {
|
|
525
|
+
return [
|
|
526
|
+
"--json",
|
|
527
|
+
"--quiet",
|
|
528
|
+
...ctx.files
|
|
529
|
+
];
|
|
530
|
+
},
|
|
531
|
+
parse(raw, ctx) {
|
|
532
|
+
const report = JSON.parse(raw.stdout);
|
|
533
|
+
const rel = (p) => toRepoRelative(ctx.cwd, p);
|
|
534
|
+
return (report.results ?? []).map((r) => {
|
|
535
|
+
const finding = {
|
|
536
|
+
tool: "semgrep",
|
|
537
|
+
rule: r.check_id,
|
|
538
|
+
category: "security",
|
|
539
|
+
severity: SEVERITY[r.extra.severity] ?? "warning",
|
|
540
|
+
file: rel(r.path),
|
|
541
|
+
range: {
|
|
542
|
+
startLine: r.start.line,
|
|
543
|
+
startCol: r.start.col,
|
|
544
|
+
endLine: r.end.line,
|
|
545
|
+
endCol: r.end.col
|
|
546
|
+
},
|
|
547
|
+
message: r.extra.message
|
|
548
|
+
};
|
|
549
|
+
const ref = r.extra.metadata?.references?.[0];
|
|
550
|
+
if (ref) finding.helpUri = ref;
|
|
551
|
+
const trace = r.extra.dataflow_trace;
|
|
552
|
+
if (trace) {
|
|
553
|
+
const steps = [];
|
|
554
|
+
const source = locOf(trace.taint_source);
|
|
555
|
+
if (source) steps.push({
|
|
556
|
+
file: rel(source.path),
|
|
557
|
+
line: source.start.line
|
|
558
|
+
});
|
|
559
|
+
for (const v of trace.intermediate_vars ?? []) steps.push({
|
|
560
|
+
file: rel(v.location.path),
|
|
561
|
+
line: v.location.start.line
|
|
562
|
+
});
|
|
563
|
+
const sink = locOf(trace.taint_sink);
|
|
564
|
+
if (sink) steps.push({
|
|
565
|
+
file: rel(sink.path),
|
|
566
|
+
line: sink.start.line
|
|
567
|
+
});
|
|
568
|
+
if (steps.length > 0) finding.flowPath = steps;
|
|
569
|
+
}
|
|
570
|
+
return finding;
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
//#endregion
|
|
576
|
+
//#region src/scanners/all.ts
|
|
577
|
+
/** Spawn-based scanners. eslint+sonarjs runs separately via the Node API (see runEslintSonarjs). */
|
|
578
|
+
const SPAWN_SCANNERS = [
|
|
579
|
+
knipScanner,
|
|
580
|
+
jscpdScanner,
|
|
581
|
+
semgrepScanner,
|
|
582
|
+
osvScanner,
|
|
583
|
+
gitleaksScanner
|
|
584
|
+
];
|
|
585
|
+
/** Bundled scanners that do not require an external binary on PATH. */
|
|
586
|
+
const BUNDLED_SCANNERS = ["sonarjs"];
|
|
587
|
+
/** External scanner binary names, for the preflight availability hint. */
|
|
588
|
+
const EXTERNAL_SCANNER_BINARIES = SPAWN_SCANNERS.map((scanner) => scanner.binary);
|
|
589
|
+
async function runScanners(deps, files, loop) {
|
|
590
|
+
const ctx = {
|
|
591
|
+
cwd: deps.cwd,
|
|
592
|
+
files,
|
|
593
|
+
loop
|
|
594
|
+
};
|
|
595
|
+
const spawned = await Promise.all(SPAWN_SCANNERS.map((scanner) => runScanner(scanner, ctx, {
|
|
596
|
+
which: deps.which,
|
|
597
|
+
spawn: deps.spawn,
|
|
598
|
+
timeout: deps.timeoutMs
|
|
599
|
+
})));
|
|
600
|
+
const eslint = await runEslintSonarjs(ctx);
|
|
601
|
+
const results = [...spawned, eslint];
|
|
602
|
+
return {
|
|
603
|
+
results,
|
|
604
|
+
scannerStatuses: results.map(scannerStatus)
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
/** Re-scan an explicit file scope and discard findings outside that affected scope. */
|
|
608
|
+
async function scanFiles(deps, files, loop) {
|
|
609
|
+
const { results, scannerStatuses } = await runScanners(deps, files, loop);
|
|
610
|
+
const findings = results.flatMap((r) => r.findings);
|
|
611
|
+
const scoped = files.includes(".") ? findings : filterToChanged(findings, files);
|
|
612
|
+
const attempted = results.filter((r) => !r.skipped);
|
|
613
|
+
return {
|
|
614
|
+
findings: scoped,
|
|
615
|
+
allScannersMissing: attempted.length === 0,
|
|
616
|
+
scanned: files.includes(".") ? void 0 : files.length,
|
|
617
|
+
scannerStatuses
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
/** Assemble the six scanners into an audit function for the orchestrator. */
|
|
621
|
+
function buildAudit(deps) {
|
|
622
|
+
return async (loop) => {
|
|
623
|
+
const files = deps.scope ?? ["."];
|
|
624
|
+
const { results, scannerStatuses } = await runScanners(deps, files, loop);
|
|
625
|
+
const attempted = results.filter((r) => !r.skipped);
|
|
626
|
+
const findings = results.flatMap((r) => r.findings);
|
|
627
|
+
const scanned = deps.scope ? deps.scope.length : void 0;
|
|
628
|
+
return {
|
|
629
|
+
findings,
|
|
630
|
+
allScannersMissing: attempted.length === 0,
|
|
631
|
+
scanned,
|
|
632
|
+
scannerStatuses
|
|
633
|
+
};
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
/** Tools available vs missing, for the preflight install hint. Bundled sonarjs is always available. */
|
|
637
|
+
async function scannerAvailability(which) {
|
|
638
|
+
const available = [...BUNDLED_SCANNERS];
|
|
639
|
+
const missing = [];
|
|
640
|
+
for (const binary of EXTERNAL_SCANNER_BINARIES) if (await which(binary)) available.push(binary);
|
|
641
|
+
else missing.push(binary);
|
|
642
|
+
return {
|
|
643
|
+
available,
|
|
644
|
+
missing
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
//#endregion
|
|
649
|
+
//#region src/scanners/exec.ts
|
|
650
|
+
/** Scanner binary name → the npm package tend bundles for it. */
|
|
651
|
+
const BUNDLED_PACKAGE = {
|
|
652
|
+
eslint: "eslint",
|
|
653
|
+
knip: "knip",
|
|
654
|
+
jscpd: "jscpd"
|
|
655
|
+
};
|
|
656
|
+
/**
|
|
657
|
+
* Resolve a bin script from a package.json at `pkgDir`.
|
|
658
|
+
* When `expectedName` is supplied, skips the directory if the package name does not match —
|
|
659
|
+
* used by resolveBinFrom when walking up from an entry-point to find the owning package root.
|
|
660
|
+
*/
|
|
661
|
+
function binScriptIn(pkgDir, binary, expectedName) {
|
|
662
|
+
const pkgJson = join(pkgDir, "package.json");
|
|
663
|
+
if (!existsSync(pkgJson)) return null;
|
|
664
|
+
try {
|
|
665
|
+
const json = JSON.parse(readFileSync(pkgJson, "utf8"));
|
|
666
|
+
if (expectedName && json.name !== expectedName) return null;
|
|
667
|
+
const rel = typeof json.bin === "string" ? json.bin : json.bin?.[binary];
|
|
668
|
+
if (!rel) return null;
|
|
669
|
+
const script = join(pkgDir, rel);
|
|
670
|
+
return existsSync(script) ? script : null;
|
|
671
|
+
} catch {
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/** Find a package's bin script via a given resolver base, robust to `exports` hiding package.json. */
|
|
676
|
+
function resolveBinFrom(base, pkg, binary) {
|
|
677
|
+
try {
|
|
678
|
+
const req = createRequire(base);
|
|
679
|
+
let dir = dirname(req.resolve(pkg));
|
|
680
|
+
for (let i = 0; i < 8; i++) {
|
|
681
|
+
const found = binScriptIn(dir, binary, pkg);
|
|
682
|
+
if (found) return found;
|
|
683
|
+
const parent = dirname(dir);
|
|
684
|
+
if (parent === dir) break;
|
|
685
|
+
dir = parent;
|
|
686
|
+
}
|
|
687
|
+
} catch {}
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
/** A scanner's bin resolved from tend's own dependencies (always present for bundled tools). */
|
|
691
|
+
function resolveBundledScanner(binary) {
|
|
692
|
+
const pkg = BUNDLED_PACKAGE[binary];
|
|
693
|
+
return pkg ? resolveBinFrom(import.meta.url, pkg, binary) : null;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* A scanner's bin resolved strictly from the TARGET PROJECT's node_modules (walking up
|
|
697
|
+
* workspace roots), if it ships its own copy. No fallback to cwd/global — so an unrelated
|
|
698
|
+
* install never leaks in. Returns null when the project doesn't have its own copy.
|
|
699
|
+
*/
|
|
700
|
+
function resolveProjectScanner(binary, cwd$1) {
|
|
701
|
+
const pkg = BUNDLED_PACKAGE[binary];
|
|
702
|
+
if (!pkg) return null;
|
|
703
|
+
let dir = cwd$1;
|
|
704
|
+
for (let i = 0; i < 12; i++) {
|
|
705
|
+
const found = binScriptIn(join(dir, "node_modules", pkg), binary);
|
|
706
|
+
if (found) return found;
|
|
707
|
+
const parent = dirname(dir);
|
|
708
|
+
if (parent === dir) break;
|
|
709
|
+
dir = parent;
|
|
710
|
+
}
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
async function onPath(binary) {
|
|
714
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
715
|
+
const result = await execa(finder, [binary], { reject: false });
|
|
716
|
+
return result.exitCode === 0;
|
|
717
|
+
}
|
|
718
|
+
/** Available if tend bundles it, or it's on PATH. */
|
|
719
|
+
const realWhich = async (binary) => {
|
|
720
|
+
if (resolveBundledScanner(binary)) return true;
|
|
721
|
+
return onPath(binary);
|
|
722
|
+
};
|
|
723
|
+
/**
|
|
724
|
+
* Run a scanner. For the npm-based tools, prefer the PROJECT's own installed version (respects
|
|
725
|
+
* their pinned version + config), falling back to tend's bundled copy; run either via node.
|
|
726
|
+
* Native tools fall back to the PATH binary. Never rejects on non-zero exit.
|
|
727
|
+
*/
|
|
728
|
+
const realSpawn = async (binary, args, opts) => {
|
|
729
|
+
const resolved = resolveProjectScanner(binary, opts.cwd) ?? resolveBundledScanner(binary);
|
|
730
|
+
const [cmd, cmdArgs] = resolved ? [process.execPath, [resolved, ...args]] : [binary, args];
|
|
731
|
+
const result = await execa(cmd, cmdArgs, {
|
|
732
|
+
cwd: opts.cwd,
|
|
733
|
+
timeout: opts.timeout,
|
|
734
|
+
reject: false,
|
|
735
|
+
all: false
|
|
736
|
+
});
|
|
737
|
+
if (result.timedOut) throw new Error(`${binary} timed out after ${opts.timeout}ms`);
|
|
738
|
+
return {
|
|
739
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
740
|
+
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
|
741
|
+
exitCode: result.exitCode ?? 0
|
|
742
|
+
};
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
//#endregion
|
|
746
|
+
//#region src/detect/test-runner.ts
|
|
747
|
+
const CONFIG_GLOBS = {
|
|
748
|
+
vitest: [
|
|
749
|
+
"vitest.config.ts",
|
|
750
|
+
"vitest.config.js",
|
|
751
|
+
"vitest.config.mjs",
|
|
752
|
+
"vitest.config.mts"
|
|
753
|
+
],
|
|
754
|
+
jest: [
|
|
755
|
+
"jest.config.ts",
|
|
756
|
+
"jest.config.js",
|
|
757
|
+
"jest.config.mjs",
|
|
758
|
+
"jest.config.cjs",
|
|
759
|
+
"jest.config.json"
|
|
760
|
+
]
|
|
761
|
+
};
|
|
762
|
+
function dependsOn(cwd$1, pkg) {
|
|
763
|
+
const pkgJsonPath = join(cwd$1, "package.json");
|
|
764
|
+
if (!existsSync(pkgJsonPath)) return false;
|
|
765
|
+
try {
|
|
766
|
+
const json = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
767
|
+
return Boolean(json.dependencies?.[pkg] ?? json.devDependencies?.[pkg]);
|
|
768
|
+
} catch {
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const hasConfig = (cwd$1, runner) => CONFIG_GLOBS[runner].some((f) => existsSync(join(cwd$1, f)));
|
|
773
|
+
/** cwd and each ancestor directory up to (and including) the filesystem root. */
|
|
774
|
+
function ancestors(cwd$1) {
|
|
775
|
+
const dirs = [];
|
|
776
|
+
for (let dir = cwd$1;; dir = dirname(dir)) {
|
|
777
|
+
dirs.push(dir);
|
|
778
|
+
if (dirname(dir) === dir) return dirs;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Detect the test runner from config files + package.json deps; `undefined` if none.
|
|
783
|
+
* Walks up the directory tree so it still resolves when run from a nested directory
|
|
784
|
+
* inside a workspace (the config/deps usually live at the package root, not the cwd).
|
|
785
|
+
*/
|
|
786
|
+
function detectTestRunner(cwd$1) {
|
|
787
|
+
for (const dir of ancestors(cwd$1)) for (const runner of ["vitest", "jest"]) if (hasConfig(dir, runner) || dependsOn(dir, runner)) return runner;
|
|
788
|
+
return void 0;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
//#endregion
|
|
792
|
+
//#region src/detect/typescript.ts
|
|
793
|
+
/** TS mode when a tsconfig is present; otherwise JS mode. */
|
|
794
|
+
function detectTypeScript(cwd$1) {
|
|
795
|
+
return existsSync(join(cwd$1, "tsconfig.json"));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
//#endregion
|
|
799
|
+
//#region src/detect/project-root.ts
|
|
800
|
+
const PACKAGE_JSON = "package.json";
|
|
801
|
+
const PROJECT_ROOT_MARKERS = [
|
|
802
|
+
"tsconfig.json",
|
|
803
|
+
"vitest.config.ts",
|
|
804
|
+
"vitest.config.js",
|
|
805
|
+
"vitest.config.mjs",
|
|
806
|
+
"vitest.config.mts",
|
|
807
|
+
"jest.config.ts",
|
|
808
|
+
"jest.config.js",
|
|
809
|
+
"jest.config.mjs",
|
|
810
|
+
"jest.config.cjs",
|
|
811
|
+
"jest.config.json",
|
|
812
|
+
"eslint.config.js",
|
|
813
|
+
"eslint.config.mjs",
|
|
814
|
+
"eslint.config.cjs",
|
|
815
|
+
"eslint.config.ts",
|
|
816
|
+
"eslint.config.mts",
|
|
817
|
+
"eslint.config.cts"
|
|
818
|
+
];
|
|
819
|
+
/**
|
|
820
|
+
* Nearest directory at or above `from` (bounded by `stopAt`, inclusive) that holds any of
|
|
821
|
+
* `markers`. Returns `null` when none is found before reaching `stopAt` or the filesystem
|
|
822
|
+
* root — never walks above `stopAt` (the repo root we were invoked from).
|
|
823
|
+
*/
|
|
824
|
+
function nearestWithMarker(from, stopAt, markers) {
|
|
825
|
+
for (let dir = from;; dir = dirname(dir)) {
|
|
826
|
+
if (markers.some((m) => existsSync(join(dir, m)))) return dir;
|
|
827
|
+
if (dir === stopAt) return null;
|
|
828
|
+
const parent = dirname(dir);
|
|
829
|
+
if (parent === dir) return null;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* The package/project root owning `from`: the nearest ancestor with a package.json, or —
|
|
834
|
+
* only when none exists up to `stopAt` — the nearest ancestor carrying a tsconfig/test/lint
|
|
835
|
+
* config. `null` when neither is found.
|
|
836
|
+
*/
|
|
837
|
+
function nearestOwnerRoot(from, stopAt) {
|
|
838
|
+
return nearestWithMarker(from, stopAt, [PACKAGE_JSON]) ?? nearestWithMarker(from, stopAt, PROJECT_ROOT_MARKERS);
|
|
839
|
+
}
|
|
840
|
+
/** Deepest directory that is an ancestor of every path in `absDirs` (all absolute). */
|
|
841
|
+
function commonAncestorDir(absDirs) {
|
|
842
|
+
let parts = absDirs[0].split(sep);
|
|
843
|
+
for (const dir of absDirs.slice(1)) {
|
|
844
|
+
const other = dir.split(sep);
|
|
845
|
+
let i = 0;
|
|
846
|
+
while (i < parts.length && i < other.length && parts[i] === other[i]) i++;
|
|
847
|
+
parts = parts.slice(0, i);
|
|
848
|
+
}
|
|
849
|
+
return parts.join(sep) || sep;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Resolve the package root that owns the scoped files, for stack detection and gate
|
|
853
|
+
* execution. Takes the common ancestor directory of the scoped files and walks up from
|
|
854
|
+
* there to the nearest package/project root (a package.json, or failing that a
|
|
855
|
+
* tsconfig/test/lint config), bounded by `cwd` (the repo root tend was invoked from).
|
|
856
|
+
*
|
|
857
|
+
* Using the common ancestor — rather than resolving each file independently — means
|
|
858
|
+
* nested packages *below* the scope (e.g. test fixtures with their own package.json under
|
|
859
|
+
* `packages/tend/test/fixtures/`) don't fragment the result: a `tend run packages/tend`
|
|
860
|
+
* still resolves to `packages/tend`. A scope that genuinely straddles sibling packages
|
|
861
|
+
* has a common ancestor above any one package, so it falls back to `cwd` — the
|
|
862
|
+
* conservative repo-root behavior. Empty scope also falls back to `cwd`.
|
|
863
|
+
*
|
|
864
|
+
* `files` must be concrete repo-relative paths (not `.`); callers pass `cwd` directly
|
|
865
|
+
* for whole-repo runs rather than routing through here.
|
|
866
|
+
*/
|
|
867
|
+
function resolveOwnerRoot(cwd$1, files) {
|
|
868
|
+
if (files.length === 0) return cwd$1;
|
|
869
|
+
const dirs = files.map((file) => dirname(resolve(cwd$1, file)));
|
|
870
|
+
return nearestOwnerRoot(commonAncestorDir(dirs), cwd$1) ?? cwd$1;
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Re-base repo-relative file paths onto `ownerRoot` so a test runner invoked with its
|
|
874
|
+
* cwd set to the owning package receives paths it can resolve. Identity when the owner
|
|
875
|
+
* root is the repo root (`cwd`), so whole-repo and single-package-at-root runs are
|
|
876
|
+
* unchanged.
|
|
877
|
+
*/
|
|
878
|
+
function toOwnerRelative(files, cwd$1, ownerRoot) {
|
|
879
|
+
if (ownerRoot === cwd$1) return files;
|
|
880
|
+
return files.map((file) => relative(ownerRoot, resolve(cwd$1, file)) || ".");
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
//#endregion
|
|
884
|
+
//#region src/gate/check.ts
|
|
885
|
+
const pass = () => ({ ok: true });
|
|
886
|
+
const reject = (reason, detail) => ({
|
|
887
|
+
ok: false,
|
|
888
|
+
reason,
|
|
889
|
+
detail
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
//#endregion
|
|
893
|
+
//#region src/gate/checks/anti-regression.ts
|
|
894
|
+
/**
|
|
895
|
+
* Reject if the fix introduced any finding that wasn't present before — no lateral
|
|
896
|
+
* moves. A fix must strictly reduce findings; trading one issue for another is what
|
|
897
|
+
* would let the loop oscillate instead of converge.
|
|
898
|
+
*/
|
|
899
|
+
function antiRegression(before, after) {
|
|
900
|
+
const knownIds = new Set(before.map((f) => f.id));
|
|
901
|
+
const introduced = after.filter((f) => !knownIds.has(f.id));
|
|
902
|
+
if (introduced.length > 0) {
|
|
903
|
+
const detail = introduced.map((f) => `${f.file}:${f.range.startLine} ${f.rule}`).join(", ");
|
|
904
|
+
return reject("regression", `Fix introduced new finding(s): ${detail}`);
|
|
905
|
+
}
|
|
906
|
+
return pass();
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
//#endregion
|
|
910
|
+
//#region src/gate/checks/anti-suppression.ts
|
|
911
|
+
const SUPPRESSION_PATTERNS = [
|
|
912
|
+
{
|
|
913
|
+
re: /eslint-disable/,
|
|
914
|
+
what: "eslint-disable"
|
|
915
|
+
},
|
|
916
|
+
{
|
|
917
|
+
re: /@ts-ignore/,
|
|
918
|
+
what: "@ts-ignore"
|
|
919
|
+
},
|
|
920
|
+
{
|
|
921
|
+
re: /@ts-nocheck/,
|
|
922
|
+
what: "@ts-nocheck"
|
|
923
|
+
},
|
|
924
|
+
{
|
|
925
|
+
re: /\bas\s+any\b/,
|
|
926
|
+
what: "cast to any"
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
re: /:\s*any\b/,
|
|
930
|
+
what: "any type annotation"
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
re: /<any>/,
|
|
934
|
+
what: "cast to any"
|
|
935
|
+
}
|
|
936
|
+
];
|
|
937
|
+
function splitDiff(diff) {
|
|
938
|
+
const added = [];
|
|
939
|
+
const removed = [];
|
|
940
|
+
for (const line of diff.split("\n")) {
|
|
941
|
+
if (line.startsWith("+++") || line.startsWith("---")) continue;
|
|
942
|
+
if (line.startsWith("+")) added.push(line.slice(1));
|
|
943
|
+
else if (line.startsWith("-")) removed.push(line.slice(1));
|
|
944
|
+
}
|
|
945
|
+
return {
|
|
946
|
+
added,
|
|
947
|
+
removed
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
const nonBlank = (lines) => lines.filter((l) => l.trim().length > 0);
|
|
951
|
+
/**
|
|
952
|
+
* Reject a change-set that cheats the scanner rather than fixing the code:
|
|
953
|
+
* newly-added suppression comments / any-casts, or code deleted instead of fixed.
|
|
954
|
+
* Only NEW (added) lines are inspected — pre-existing suppressions in context are ignored.
|
|
955
|
+
*/
|
|
956
|
+
function antiSuppression(diff) {
|
|
957
|
+
const { added, removed } = splitDiff(diff);
|
|
958
|
+
for (const line of added) for (const { re, what } of SUPPRESSION_PATTERNS) if (re.test(line)) return reject("suppression", `Fix added ${what}`);
|
|
959
|
+
if (nonBlank(removed).length > 0 && nonBlank(added).length === 0) return reject("suppression", "Code was deleted instead of fixed");
|
|
960
|
+
return pass();
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
//#endregion
|
|
964
|
+
//#region src/gate/checks/typecheck.ts
|
|
965
|
+
/** Reject a fix that breaks `tsc --noEmit`. Skipped (pass) when there's no tsconfig. */
|
|
966
|
+
async function typecheck(deps) {
|
|
967
|
+
if (!await deps.hasTsconfig()) return pass();
|
|
968
|
+
const { exitCode, output } = await deps.runTsc();
|
|
969
|
+
if (exitCode === 0) return pass();
|
|
970
|
+
return reject("typecheck", output.trim() || "tsc --noEmit failed");
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
//#endregion
|
|
974
|
+
//#region src/gate/checks/tests.ts
|
|
975
|
+
/** Baseline-green tests that are red now. */
|
|
976
|
+
function regressions(baseline, outcomes) {
|
|
977
|
+
return outcomes.filter((o) => o.status === "fail" && baseline.has(o.name));
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Apply→test→repair flow. A red previously-green test opens a bounded repair window
|
|
981
|
+
* rather than an instant revert; exhausting it without going green is a reject.
|
|
982
|
+
*/
|
|
983
|
+
async function runTestPhase(deps) {
|
|
984
|
+
if (deps.hasTestRunner === false) return {
|
|
985
|
+
ok: true,
|
|
986
|
+
warning: "No test suite detected — behavior can't be verified"
|
|
987
|
+
};
|
|
988
|
+
let regressed = regressions(deps.baseline, await deps.runRelated());
|
|
989
|
+
if (regressed.length === 0) return pass();
|
|
990
|
+
for (let attempt = 1; attempt <= deps.maxRepairs; attempt++) {
|
|
991
|
+
await deps.repair(attempt);
|
|
992
|
+
regressed = regressions(deps.baseline, await deps.runRelated());
|
|
993
|
+
if (regressed.length === 0) return pass();
|
|
994
|
+
}
|
|
995
|
+
const names = regressed.map((o) => o.name).join(", ");
|
|
996
|
+
return reject("broke-test", `Fix left previously-green test(s) red: ${names}`);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
//#endregion
|
|
1000
|
+
//#region src/fixing/fix-unit.ts
|
|
1001
|
+
/** Render the fix prompt for a unit's findings. */
|
|
1002
|
+
function renderPrompt(unit) {
|
|
1003
|
+
const lines = unit.findings.map((f) => `- ${f.file}:${f.range.startLine} [${f.tool}/${f.rule}] ${f.message}`);
|
|
1004
|
+
return [
|
|
1005
|
+
`Fix the following findings in ${unit.file} (and its sibling test only).`,
|
|
1006
|
+
"Fix the underlying issue — never suppress, cast to any, or delete code to silence a scanner.",
|
|
1007
|
+
"Emit the full corrected file contents with the Write tool.",
|
|
1008
|
+
"",
|
|
1009
|
+
"Findings:",
|
|
1010
|
+
...lines
|
|
1011
|
+
].join("\n");
|
|
1012
|
+
}
|
|
1013
|
+
/** Build a minimal unified diff from captured before/after contents. */
|
|
1014
|
+
function buildDiff(before, after) {
|
|
1015
|
+
const out$1 = [];
|
|
1016
|
+
for (const [path, afterContent] of after) {
|
|
1017
|
+
const beforeLines = (before.get(path) ?? "").split("\n");
|
|
1018
|
+
const afterLines = (afterContent ?? "").split("\n");
|
|
1019
|
+
for (const l of beforeLines) if (!afterLines.includes(l)) out$1.push(`-${l}`);
|
|
1020
|
+
for (const l of afterLines) if (!beforeLines.includes(l)) out$1.push(`+${l}`);
|
|
1021
|
+
}
|
|
1022
|
+
return out$1.join("\n");
|
|
1023
|
+
}
|
|
1024
|
+
/** A file's current contents, or null if it doesn't exist. */
|
|
1025
|
+
const snapshotFile = (abs) => existsSync(abs) ? readFileSync(abs, "utf8") : null;
|
|
1026
|
+
/**
|
|
1027
|
+
* Production fix worker. The session edits files directly on disk (`claude -p
|
|
1028
|
+
* --allowedTools Read,Write,Edit`), so the **disk is the source of truth** — we
|
|
1029
|
+
* snapshot the unit's files before the session and judge by what actually changed,
|
|
1030
|
+
* never by the session's stream-json (which can under-report or read as errored even
|
|
1031
|
+
* when a file was written). What changed runs the gate (anti-suppression · typecheck ·
|
|
1032
|
+
* tests with a bounded repair window · anti-regression re-scan); any gate failure or
|
|
1033
|
+
* session error reverts the files to the snapshot. Nothing changed → not a fix.
|
|
1034
|
+
*/
|
|
1035
|
+
function makeFixUnit(deps) {
|
|
1036
|
+
return async (unit) => {
|
|
1037
|
+
const abs = (f) => join(deps.cwd, f);
|
|
1038
|
+
const before = new Map(unit.files.map((f) => [f, snapshotFile(abs(f))]));
|
|
1039
|
+
const restore = () => {
|
|
1040
|
+
for (const [f, original] of before) {
|
|
1041
|
+
const p = abs(f);
|
|
1042
|
+
if (original === null) {
|
|
1043
|
+
if (existsSync(p)) rmSync(p, { force: true });
|
|
1044
|
+
} else writeFileSync(p, original);
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
const diskNow = () => new Map(unit.files.map((f) => [f, snapshotFile(abs(f))]));
|
|
1048
|
+
const changedOnDisk = () => unit.files.some((f) => snapshotFile(abs(f)) !== before.get(f));
|
|
1049
|
+
let usage = zeroUsage();
|
|
1050
|
+
const res = await deps.session.run({
|
|
1051
|
+
file: unit.file,
|
|
1052
|
+
findings: unit.findings,
|
|
1053
|
+
prompt: renderPrompt(unit)
|
|
1054
|
+
});
|
|
1055
|
+
if (res.usage) usage = addUsage(usage, res.usage);
|
|
1056
|
+
if (!changedOnDisk()) return {
|
|
1057
|
+
kept: false,
|
|
1058
|
+
reason: "session-error",
|
|
1059
|
+
usage
|
|
1060
|
+
};
|
|
1061
|
+
if (!res.ok) {
|
|
1062
|
+
restore();
|
|
1063
|
+
return {
|
|
1064
|
+
kept: false,
|
|
1065
|
+
reason: "session-error",
|
|
1066
|
+
usage
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
const supp = antiSuppression(buildDiff(before, diskNow()));
|
|
1070
|
+
if (!supp.ok) {
|
|
1071
|
+
restore();
|
|
1072
|
+
return {
|
|
1073
|
+
kept: false,
|
|
1074
|
+
reason: supp.reason,
|
|
1075
|
+
usage
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
const tc = await typecheck({
|
|
1079
|
+
hasTsconfig: () => deps.typescript,
|
|
1080
|
+
runTsc: deps.runTsc
|
|
1081
|
+
});
|
|
1082
|
+
if (!tc.ok) {
|
|
1083
|
+
restore();
|
|
1084
|
+
return {
|
|
1085
|
+
kept: false,
|
|
1086
|
+
reason: tc.reason,
|
|
1087
|
+
usage
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
const phase = await runTestPhase({
|
|
1091
|
+
baseline: deps.baseline,
|
|
1092
|
+
runRelated: () => deps.runRelated(unit.files),
|
|
1093
|
+
repair: async () => {
|
|
1094
|
+
const repair = await deps.session.run({
|
|
1095
|
+
file: unit.file,
|
|
1096
|
+
findings: unit.findings,
|
|
1097
|
+
prompt: `${renderPrompt(unit)}\n\nThe previous edit left a test red — diagnose and fix.`
|
|
1098
|
+
});
|
|
1099
|
+
if (repair.usage) usage = addUsage(usage, repair.usage);
|
|
1100
|
+
},
|
|
1101
|
+
maxRepairs: deps.maxRepairs,
|
|
1102
|
+
hasTestRunner: deps.hasTestRunner
|
|
1103
|
+
});
|
|
1104
|
+
if (!phase.ok) {
|
|
1105
|
+
restore();
|
|
1106
|
+
return {
|
|
1107
|
+
kept: false,
|
|
1108
|
+
reason: phase.reason,
|
|
1109
|
+
usage
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
const afterFindings = await deps.scanFindings(unit.files);
|
|
1113
|
+
const regression = antiRegression(unit.findings, afterFindings);
|
|
1114
|
+
if (!regression.ok) {
|
|
1115
|
+
restore();
|
|
1116
|
+
return {
|
|
1117
|
+
kept: false,
|
|
1118
|
+
reason: regression.reason,
|
|
1119
|
+
usage
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
return {
|
|
1123
|
+
kept: true,
|
|
1124
|
+
usage
|
|
1125
|
+
};
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
//#endregion
|
|
1130
|
+
//#region src/output/env.ts
|
|
1131
|
+
/** Truthy in the env-var sense: present and not an explicit off value. */
|
|
1132
|
+
function flagOn(value) {
|
|
1133
|
+
return value !== void 0 && value !== "" && value !== "0" && value !== "false";
|
|
1134
|
+
}
|
|
1135
|
+
function isCI(env) {
|
|
1136
|
+
return flagOn(env.CI) || flagOn(env.CONTINUOUS_INTEGRATION) || env.GITHUB_ACTIONS !== void 0;
|
|
1137
|
+
}
|
|
1138
|
+
function unicodeSupported(env, platform) {
|
|
1139
|
+
if (platform !== "win32") return true;
|
|
1140
|
+
return Boolean(env.WT_SESSION) || env.TERM_PROGRAM === "vscode" || env.TERM === "xterm-256color";
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Resolve color + interactivity from the environment and flags.
|
|
1144
|
+
*
|
|
1145
|
+
* Color is disabled when stdout is not a TTY, NO_COLOR is set, TERM=dumb, --no-color,
|
|
1146
|
+
* TEND_NO_COLOR, or --plain. FORCE_COLOR re-enables it for a non-TTY (but never overrides
|
|
1147
|
+
* an explicit opt-out). Interactivity additionally requires not being in CI.
|
|
1148
|
+
*/
|
|
1149
|
+
function detectOutputEnv(input = {}) {
|
|
1150
|
+
const env = input.env ?? {};
|
|
1151
|
+
const platform = input.platform ?? "linux";
|
|
1152
|
+
const isTTY = Boolean(input.stream?.isTTY);
|
|
1153
|
+
const explicitlyOff = input.noColor === true || input.plain === true || "NO_COLOR" in env || flagOn(env.TEND_NO_COLOR) || env.TERM === "dumb";
|
|
1154
|
+
const forced = !explicitlyOff && flagOn(env.FORCE_COLOR);
|
|
1155
|
+
const color = !explicitlyOff && (isTTY || forced);
|
|
1156
|
+
const interactive = isTTY && !input.plain && env.TERM !== "dumb" && !isCI(env);
|
|
1157
|
+
return {
|
|
1158
|
+
color,
|
|
1159
|
+
interactive,
|
|
1160
|
+
unicode: unicodeSupported(env, platform)
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
//#endregion
|
|
1165
|
+
//#region src/output/base-reporter.ts
|
|
1166
|
+
var BaseReporter = class {
|
|
1167
|
+
theme;
|
|
1168
|
+
write;
|
|
1169
|
+
constructor(deps) {
|
|
1170
|
+
this.theme = deps.theme;
|
|
1171
|
+
this.write = deps.write;
|
|
1172
|
+
}
|
|
1173
|
+
start() {
|
|
1174
|
+
this.write(this.theme.wordmark());
|
|
1175
|
+
}
|
|
1176
|
+
note(line) {
|
|
1177
|
+
this.write(this.theme.dim(line));
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
//#endregion
|
|
1182
|
+
//#region src/output/live-reporter.ts
|
|
1183
|
+
/** A one-shot value channel: take() resolves now if buffered, else when the next push lands. */
|
|
1184
|
+
var Channel = class {
|
|
1185
|
+
buffer = [];
|
|
1186
|
+
waiters = [];
|
|
1187
|
+
push(value) {
|
|
1188
|
+
const waiter = this.waiters.shift();
|
|
1189
|
+
if (waiter) waiter(value);
|
|
1190
|
+
else this.buffer.push(value);
|
|
1191
|
+
}
|
|
1192
|
+
take() {
|
|
1193
|
+
if (this.buffer.length > 0) return Promise.resolve(this.buffer.shift());
|
|
1194
|
+
return new Promise((resolve$1) => this.waiters.push(resolve$1));
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
const CLOSED = Symbol("closed");
|
|
1198
|
+
/**
|
|
1199
|
+
* The live TTY view. Scanning shows a spinner with elapsed time; fixing shows one compact
|
|
1200
|
+
* redrawing listr2 progress row with X-of-Y · running · queued · outcome counts.
|
|
1201
|
+
*
|
|
1202
|
+
* Events arrive synchronously on the bus while the orchestrator runs; this reporter buffers
|
|
1203
|
+
* them into channels and drives a sequence of listr instances (scan → fix → scan → …) from
|
|
1204
|
+
* `run()`, which the caller awaits concurrently with the orchestration.
|
|
1205
|
+
*/
|
|
1206
|
+
var LiveReporter = class extends BaseReporter {
|
|
1207
|
+
env;
|
|
1208
|
+
scanStarts = new Channel();
|
|
1209
|
+
audits = new Channel();
|
|
1210
|
+
phases = new Channel();
|
|
1211
|
+
fixTicks = new Channel();
|
|
1212
|
+
closed = false;
|
|
1213
|
+
resolveClosed;
|
|
1214
|
+
closedSignal = new Promise((resolve$1) => {
|
|
1215
|
+
this.resolveClosed = () => resolve$1(CLOSED);
|
|
1216
|
+
});
|
|
1217
|
+
fixTotal = 0;
|
|
1218
|
+
started = 0;
|
|
1219
|
+
finished = 0;
|
|
1220
|
+
fixed = 0;
|
|
1221
|
+
reverted = 0;
|
|
1222
|
+
left = 0;
|
|
1223
|
+
currentLoop = 0;
|
|
1224
|
+
currentFile;
|
|
1225
|
+
currentConcurrency;
|
|
1226
|
+
rules = new Map();
|
|
1227
|
+
header;
|
|
1228
|
+
labelWidth = 0;
|
|
1229
|
+
constructor(deps) {
|
|
1230
|
+
super(deps);
|
|
1231
|
+
this.env = deps.env;
|
|
1232
|
+
}
|
|
1233
|
+
onEvent(event) {
|
|
1234
|
+
switch (event.type) {
|
|
1235
|
+
case "audit":
|
|
1236
|
+
this.audits.push({
|
|
1237
|
+
loop: event.loop,
|
|
1238
|
+
findings: event.findings,
|
|
1239
|
+
files: event.files,
|
|
1240
|
+
scanned: event.scanned
|
|
1241
|
+
});
|
|
1242
|
+
break;
|
|
1243
|
+
case "loop-start":
|
|
1244
|
+
this.currentLoop = event.loop;
|
|
1245
|
+
this.fixTotal = event.files.length;
|
|
1246
|
+
this.started = 0;
|
|
1247
|
+
this.finished = 0;
|
|
1248
|
+
this.fixed = 0;
|
|
1249
|
+
this.reverted = 0;
|
|
1250
|
+
this.left = 0;
|
|
1251
|
+
this.currentFile = void 0;
|
|
1252
|
+
this.currentConcurrency = event.concurrency;
|
|
1253
|
+
this.rules.clear();
|
|
1254
|
+
this.labelWidth = Math.max(0, ...event.files.map((f) => basename(f).length));
|
|
1255
|
+
this.phases.push({
|
|
1256
|
+
kind: "fix",
|
|
1257
|
+
info: {
|
|
1258
|
+
loop: event.loop,
|
|
1259
|
+
files: event.files,
|
|
1260
|
+
concurrency: event.concurrency
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
break;
|
|
1264
|
+
case "file-start":
|
|
1265
|
+
this.started += 1;
|
|
1266
|
+
this.currentFile = event.file;
|
|
1267
|
+
if (event.rule) this.rules.set(event.file, event.rule);
|
|
1268
|
+
this.refreshHeader();
|
|
1269
|
+
break;
|
|
1270
|
+
case "file-result":
|
|
1271
|
+
this.finished += 1;
|
|
1272
|
+
if (event.outcome === "fixed") this.fixed += 1;
|
|
1273
|
+
else if (event.outcome === "reverted") this.reverted += 1;
|
|
1274
|
+
else this.left += 1;
|
|
1275
|
+
this.currentFile = void 0;
|
|
1276
|
+
this.refreshHeader();
|
|
1277
|
+
this.fixTicks.push();
|
|
1278
|
+
break;
|
|
1279
|
+
case "done":
|
|
1280
|
+
this.phases.push({ kind: "done" });
|
|
1281
|
+
break;
|
|
1282
|
+
case "scan-start":
|
|
1283
|
+
this.scanStarts.push(event.loop);
|
|
1284
|
+
break;
|
|
1285
|
+
case "snapshot":
|
|
1286
|
+
case "detected":
|
|
1287
|
+
case "loop-complete": break;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
close() {
|
|
1291
|
+
if (this.closed) return;
|
|
1292
|
+
this.closed = true;
|
|
1293
|
+
this.phases.push({ kind: "done" });
|
|
1294
|
+
this.resolveClosed();
|
|
1295
|
+
}
|
|
1296
|
+
async run() {
|
|
1297
|
+
while (!this.closed) {
|
|
1298
|
+
const stillRunning = await this.scanPhase();
|
|
1299
|
+
if (!stillRunning) break;
|
|
1300
|
+
const phase = await this.race(this.phases.take());
|
|
1301
|
+
if (phase === CLOSED || phase.kind === "done") break;
|
|
1302
|
+
await this.fixPhase(phase.info);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
/** Race a promise against close() so the view can wind down even mid-wait. */
|
|
1306
|
+
race(promise) {
|
|
1307
|
+
return Promise.race([promise, this.closedSignal]);
|
|
1308
|
+
}
|
|
1309
|
+
/** Spinner + elapsed until the next audit lands. Returns false if we were closed first. */
|
|
1310
|
+
async scanPhase() {
|
|
1311
|
+
let live = true;
|
|
1312
|
+
const list = new Listr([{
|
|
1313
|
+
title: this.theme.dim("scanning…"),
|
|
1314
|
+
task: async (_ctx, task) => {
|
|
1315
|
+
const loop = await this.race(this.scanStarts.take());
|
|
1316
|
+
if (loop === CLOSED) {
|
|
1317
|
+
live = false;
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
task.title = this.scanTitle(loop);
|
|
1321
|
+
const audit = await this.race(this.audits.take());
|
|
1322
|
+
if (audit === CLOSED) {
|
|
1323
|
+
live = false;
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
task.title = this.scannedTitle(audit);
|
|
1327
|
+
}
|
|
1328
|
+
}], this.listrOptions());
|
|
1329
|
+
await list.run();
|
|
1330
|
+
return live;
|
|
1331
|
+
}
|
|
1332
|
+
/** The redrawing progress row for one fix loop. Counters are reset by loop-start. */
|
|
1333
|
+
async fixPhase(info) {
|
|
1334
|
+
const list = new Listr([{
|
|
1335
|
+
title: this.headerTitle(),
|
|
1336
|
+
task: async (_ctx, task) => {
|
|
1337
|
+
this.header = task;
|
|
1338
|
+
this.currentLoop = info.loop;
|
|
1339
|
+
this.currentConcurrency = info.concurrency;
|
|
1340
|
+
task.title = this.headerTitle();
|
|
1341
|
+
while (this.finished < this.fixTotal) {
|
|
1342
|
+
const tick = await this.race(this.fixTicks.take());
|
|
1343
|
+
if (tick === CLOSED) return;
|
|
1344
|
+
task.title = this.headerTitle();
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}], this.listrOptions());
|
|
1348
|
+
await list.run();
|
|
1349
|
+
this.header = void 0;
|
|
1350
|
+
}
|
|
1351
|
+
listrOptions() {
|
|
1352
|
+
const accent = this.theme.accent;
|
|
1353
|
+
const icon = {
|
|
1354
|
+
[ListrDefaultRendererLogLevels.COMPLETED]: this.theme.fixed(this.theme.glyph.fixed),
|
|
1355
|
+
[ListrDefaultRendererLogLevels.FAILED]: this.theme.reverted(this.theme.glyph.reverted)
|
|
1356
|
+
};
|
|
1357
|
+
const color = { [ListrDefaultRendererLogLevels.PENDING]: (message) => accent(message ?? "") };
|
|
1358
|
+
const timer = {
|
|
1359
|
+
field: (duration) => formatClock(duration),
|
|
1360
|
+
format: () => (message) => this.theme.dim(message ?? "")
|
|
1361
|
+
};
|
|
1362
|
+
return {
|
|
1363
|
+
concurrent: false,
|
|
1364
|
+
exitOnError: false,
|
|
1365
|
+
registerSignalListeners: false,
|
|
1366
|
+
rendererOptions: {
|
|
1367
|
+
collapseSubtasks: true,
|
|
1368
|
+
lazy: !this.env.interactive,
|
|
1369
|
+
showErrorMessage: false,
|
|
1370
|
+
timer,
|
|
1371
|
+
icon,
|
|
1372
|
+
color
|
|
1373
|
+
}
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
scannedTitle(a) {
|
|
1377
|
+
const scope = a.scanned != null ? `${a.scanned} files eligible for fixes` : "whole repo";
|
|
1378
|
+
const label = a.loop === 1 ? "initial audit" : `re-audit after fix pass ${a.loop - 1}`;
|
|
1379
|
+
const meta = this.theme.dim(`${label}: fix scope ${scope} ${this.theme.glyph.bullet} in-scope findings ${a.findings} across ${a.files} files`);
|
|
1380
|
+
return meta;
|
|
1381
|
+
}
|
|
1382
|
+
scanTitle(loop) {
|
|
1383
|
+
return this.theme.dim(loop === 1 ? "initial audit: scanning…" : `re-audit after fix pass ${loop - 1}: scanning…`);
|
|
1384
|
+
}
|
|
1385
|
+
headerTitle() {
|
|
1386
|
+
const running = Math.max(0, this.started - this.finished);
|
|
1387
|
+
const queued = Math.max(0, this.fixTotal - this.started);
|
|
1388
|
+
const bullet = this.theme.glyph.bullet;
|
|
1389
|
+
const outcomes = `${this.fixed} fixed ${bullet} ${this.reverted} reverted ${bullet} ${this.left} left`;
|
|
1390
|
+
const parallel = this.currentConcurrency ? `${bullet} ${this.currentConcurrency} concurrent ` : "";
|
|
1391
|
+
const current = this.currentFile ? `${bullet} ${this.fileTitle(this.currentFile)}` : "";
|
|
1392
|
+
const detail = `${bullet} ${running} running ${bullet} ${queued} queued ${bullet} ${outcomes} ${parallel}${current}`;
|
|
1393
|
+
return `fix pass ${this.currentLoop} ${this.finished}/${this.fixTotal} ${this.theme.dim(detail)}`;
|
|
1394
|
+
}
|
|
1395
|
+
refreshHeader() {
|
|
1396
|
+
if (this.header) this.header.title = this.headerTitle();
|
|
1397
|
+
}
|
|
1398
|
+
fileLabel(file) {
|
|
1399
|
+
return basename(file).padEnd(this.labelWidth);
|
|
1400
|
+
}
|
|
1401
|
+
fileTitle(file) {
|
|
1402
|
+
const rule = this.rules.get(file);
|
|
1403
|
+
const suffix = rule ? ` ${this.theme.dim(rule)}` : "";
|
|
1404
|
+
return `${this.fileLabel(file)}${suffix}`;
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1408
|
+
//#endregion
|
|
1409
|
+
//#region src/output/plain-reporter.ts
|
|
1410
|
+
/**
|
|
1411
|
+
* The non-TTY / CI / piped / `--plain` view: one line per meaningful event, no spinners, no
|
|
1412
|
+
* redraw, no color (the theme is already colorless in this mode). Deterministic and easy to
|
|
1413
|
+
* grep or pipe into another tool. The final summary is rendered separately by the caller.
|
|
1414
|
+
*/
|
|
1415
|
+
var PlainReporter = class extends BaseReporter {
|
|
1416
|
+
constructor(deps) {
|
|
1417
|
+
super(deps);
|
|
1418
|
+
}
|
|
1419
|
+
onEvent(event) {
|
|
1420
|
+
const { glyph } = this.theme;
|
|
1421
|
+
switch (event.type) {
|
|
1422
|
+
case "scan-start":
|
|
1423
|
+
this.write(event.loop === 1 ? "initial audit: scanning…" : `re-audit after fix pass ${event.loop - 1}: scanning…`);
|
|
1424
|
+
break;
|
|
1425
|
+
case "audit": {
|
|
1426
|
+
const scope = event.scanned != null ? `${event.scanned} files eligible for fixes` : "whole repo";
|
|
1427
|
+
const phase = event.loop === 1 ? "initial audit" : `re-audit after fix pass ${event.loop - 1}`;
|
|
1428
|
+
this.write(`${glyph.scanned} ${phase}: fix scope ${scope} ${glyph.bullet} in-scope findings ${event.findings} across ${event.files} files`);
|
|
1429
|
+
break;
|
|
1430
|
+
}
|
|
1431
|
+
case "loop-start":
|
|
1432
|
+
this.write(`fix pass ${event.loop} ${glyph.bullet} ${event.files.length} files ${glyph.bullet} ${event.concurrency} concurrent`);
|
|
1433
|
+
break;
|
|
1434
|
+
case "file-result":
|
|
1435
|
+
if (event.outcome === "fixed") this.write(`${glyph.fixed} fixed ${event.file}`);
|
|
1436
|
+
else if (event.outcome === "reverted") this.write(`${glyph.reverted} reverted ${event.file} — ${reasonLabel(event.reason)}`);
|
|
1437
|
+
else this.write(`${glyph.left} left ${event.file}`);
|
|
1438
|
+
break;
|
|
1439
|
+
case "snapshot":
|
|
1440
|
+
case "detected":
|
|
1441
|
+
case "file-start":
|
|
1442
|
+
case "loop-complete":
|
|
1443
|
+
case "done": break;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
run() {
|
|
1447
|
+
return Promise.resolve();
|
|
1448
|
+
}
|
|
1449
|
+
close() {}
|
|
1450
|
+
};
|
|
1451
|
+
|
|
1452
|
+
//#endregion
|
|
1453
|
+
//#region src/output/reporter.ts
|
|
1454
|
+
/** Pick the reporter that fits the environment. */
|
|
1455
|
+
function createReporter(deps) {
|
|
1456
|
+
return deps.env.interactive ? new LiveReporter(deps) : new PlainReporter(deps);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
//#endregion
|
|
1460
|
+
//#region src/bin.ts
|
|
1461
|
+
const cwd = process.cwd();
|
|
1462
|
+
const TEND_DIR = join(cwd, ".tend");
|
|
1463
|
+
const SNAPSHOT_PATH = join(TEND_DIR, "snapshot.json");
|
|
1464
|
+
const REPORT_PATH = join(TEND_DIR, "report.json");
|
|
1465
|
+
const CLAUDE_TIMEOUT_MS = 10 * 6e4;
|
|
1466
|
+
const TSC_TIMEOUT_MS = 5 * 6e4;
|
|
1467
|
+
const TEST_TIMEOUT_MS = 5 * 6e4;
|
|
1468
|
+
const out = (s) => process.stdout.write(`${s}\n`);
|
|
1469
|
+
const err = (s) => process.stderr.write(`${s}\n`);
|
|
1470
|
+
const plural = (n, one) => `${n} ${n === 1 ? one : one + "s"}`;
|
|
1471
|
+
function persist(path, value) {
|
|
1472
|
+
mkdirSync(TEND_DIR, { recursive: true });
|
|
1473
|
+
writeFileSync(path, JSON.stringify(value, null, 2));
|
|
1474
|
+
}
|
|
1475
|
+
function loadJson(path) {
|
|
1476
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
1477
|
+
}
|
|
1478
|
+
function loadReport() {
|
|
1479
|
+
if (!existsSync(REPORT_PATH)) throw new Error("No .tend/report.json found. Run `tend run` first.");
|
|
1480
|
+
return ReportSchema.parse(loadJson(REPORT_PATH));
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Run the detected test runner over the given files and parse pass/fail per test.
|
|
1484
|
+
* `files` are repo-relative; `root` is the package that owns them (the cwd the runner
|
|
1485
|
+
* executes in). Files are re-based onto `root` so `vitest related` / `jest
|
|
1486
|
+
* --findRelatedTests` resolve them inside the owning package, not the repo root.
|
|
1487
|
+
*/
|
|
1488
|
+
async function runTests(runner, files, root) {
|
|
1489
|
+
const targets = toOwnerRelative(files, cwd, root);
|
|
1490
|
+
const args = runner === "vitest" ? [
|
|
1491
|
+
"vitest",
|
|
1492
|
+
"related",
|
|
1493
|
+
...targets,
|
|
1494
|
+
"--run",
|
|
1495
|
+
"--reporter=json"
|
|
1496
|
+
] : [
|
|
1497
|
+
"jest",
|
|
1498
|
+
"--findRelatedTests",
|
|
1499
|
+
...targets,
|
|
1500
|
+
"--json"
|
|
1501
|
+
];
|
|
1502
|
+
const res = await execa("npx", args, {
|
|
1503
|
+
cwd: root,
|
|
1504
|
+
reject: false,
|
|
1505
|
+
timeout: TEST_TIMEOUT_MS
|
|
1506
|
+
});
|
|
1507
|
+
try {
|
|
1508
|
+
const json = JSON.parse(res.stdout);
|
|
1509
|
+
const outcomes = [];
|
|
1510
|
+
for (const file of json.testResults ?? []) for (const a of file.assertionResults ?? []) outcomes.push({
|
|
1511
|
+
name: a.fullName ?? a.title ?? "",
|
|
1512
|
+
status: a.status === "passed" ? "pass" : "fail"
|
|
1513
|
+
});
|
|
1514
|
+
return outcomes;
|
|
1515
|
+
} catch {
|
|
1516
|
+
return [];
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
async function makeProductionFixUnit(config, baselineTargets, ownerRoot = cwd) {
|
|
1520
|
+
const typescript = detectTypeScript(ownerRoot);
|
|
1521
|
+
const runner = detectTestRunner(ownerRoot) ?? null;
|
|
1522
|
+
const baseline = new Set(runner && baselineTargets.length > 0 ? (await runTests(runner, baselineTargets, ownerRoot)).filter((t) => t.status === "pass").map((t) => t.name) : []);
|
|
1523
|
+
const session = new ClaudeSession({ spawn: async (req) => {
|
|
1524
|
+
const r = await execa("claude", [
|
|
1525
|
+
"-p",
|
|
1526
|
+
req.prompt,
|
|
1527
|
+
"--model",
|
|
1528
|
+
config.model,
|
|
1529
|
+
...config.effort ? ["--effort", config.effort] : [],
|
|
1530
|
+
"--output-format",
|
|
1531
|
+
"stream-json",
|
|
1532
|
+
"--verbose",
|
|
1533
|
+
"--allowedTools",
|
|
1534
|
+
"Read,Write,Edit"
|
|
1535
|
+
], {
|
|
1536
|
+
cwd,
|
|
1537
|
+
reject: false,
|
|
1538
|
+
timeout: CLAUDE_TIMEOUT_MS
|
|
1539
|
+
});
|
|
1540
|
+
const exitCode = r.exitCode ?? (r.failed ? 1 : 0);
|
|
1541
|
+
return {
|
|
1542
|
+
stdout: typeof r.stdout === "string" ? r.stdout : "",
|
|
1543
|
+
exitCode
|
|
1544
|
+
};
|
|
1545
|
+
} });
|
|
1546
|
+
return {
|
|
1547
|
+
typescript,
|
|
1548
|
+
runner,
|
|
1549
|
+
fixUnit: makeFixUnit({
|
|
1550
|
+
cwd,
|
|
1551
|
+
session,
|
|
1552
|
+
typescript,
|
|
1553
|
+
runTsc: async () => {
|
|
1554
|
+
const r = await execa("npx", ["tsc", "--noEmit"], {
|
|
1555
|
+
cwd: ownerRoot,
|
|
1556
|
+
reject: false,
|
|
1557
|
+
timeout: TSC_TIMEOUT_MS
|
|
1558
|
+
});
|
|
1559
|
+
return {
|
|
1560
|
+
exitCode: r.exitCode ?? 1,
|
|
1561
|
+
output: `${r.stdout}\n${r.stderr}`
|
|
1562
|
+
};
|
|
1563
|
+
},
|
|
1564
|
+
hasTestRunner: Boolean(runner),
|
|
1565
|
+
runRelated: (files) => runner ? runTests(runner, files, ownerRoot) : Promise.resolve([]),
|
|
1566
|
+
scanFindings: async (files) => (await scanFiles({
|
|
1567
|
+
cwd,
|
|
1568
|
+
which: realWhich,
|
|
1569
|
+
spawn: realSpawn,
|
|
1570
|
+
timeoutMs: 12e4
|
|
1571
|
+
}, files, 0)).findings,
|
|
1572
|
+
baseline,
|
|
1573
|
+
maxRepairs: 3
|
|
1574
|
+
})
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
function describeScopeNote(all, paths, scope) {
|
|
1578
|
+
if (all) return "whole repo";
|
|
1579
|
+
if (paths.length > 0) return `${plural(scope?.length ?? 0, "file")} under ${paths.join(", ")}`;
|
|
1580
|
+
return `${plural(scope?.length ?? 0, "changed file")}`;
|
|
1581
|
+
}
|
|
1582
|
+
async function runRun(opts) {
|
|
1583
|
+
const env = detectOutputEnv({
|
|
1584
|
+
stream: process.stdout,
|
|
1585
|
+
env: process.env,
|
|
1586
|
+
plain: opts.plain,
|
|
1587
|
+
noColor: opts.color === false
|
|
1588
|
+
});
|
|
1589
|
+
const theme = makeTheme(env);
|
|
1590
|
+
const reporter = createReporter({
|
|
1591
|
+
env,
|
|
1592
|
+
theme,
|
|
1593
|
+
write: out
|
|
1594
|
+
});
|
|
1595
|
+
reporter.start();
|
|
1596
|
+
const git = createGit(cwd);
|
|
1597
|
+
await assertGitRepo(git);
|
|
1598
|
+
if (opts.effort && !EFFORT_LEVELS.includes(opts.effort)) {
|
|
1599
|
+
err(`✖ invalid --effort "${opts.effort}" (expected: ${EFFORT_LEVELS.join(" | ")})`);
|
|
1600
|
+
process.exitCode = 1;
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
const config = applyCliOverrides(await loadConfig(cwd), {
|
|
1604
|
+
maxLoops: opts.maxLoops,
|
|
1605
|
+
maxSessions: opts.maxSessions,
|
|
1606
|
+
model: opts.model,
|
|
1607
|
+
effort: opts.effort,
|
|
1608
|
+
includeTests: opts.includeTests
|
|
1609
|
+
});
|
|
1610
|
+
const snapshot = await Snapshot.capture(git, cwd);
|
|
1611
|
+
persist(SNAPSHOT_PATH, snapshot.toJSON());
|
|
1612
|
+
reporter.note("snapshot saved · undo: tend undo");
|
|
1613
|
+
const modelLabel = config.effort ? `${config.model} (effort ${config.effort})` : config.model;
|
|
1614
|
+
const { available, missing } = await scannerAvailability(realWhich);
|
|
1615
|
+
if (available.length === 0) {
|
|
1616
|
+
err(`No scanners found. Install at least one of: ${missing.join(", ")}`);
|
|
1617
|
+
process.exitCode = 1;
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
if (missing.length > 0) reporter.note(`skipping missing external scanners: ${missing.join(", ")}`);
|
|
1621
|
+
const paths = opts.paths ?? [];
|
|
1622
|
+
let scope;
|
|
1623
|
+
if (opts.all) scope = null;
|
|
1624
|
+
else if (paths.length > 0) {
|
|
1625
|
+
scope = await filesUnder(git, paths);
|
|
1626
|
+
if (scope.length === 0) {
|
|
1627
|
+
err(`✖ no files under ${paths.join(", ")}`);
|
|
1628
|
+
process.exitCode = 1;
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
} else scope = await changedVsHead(git);
|
|
1632
|
+
const baselineTargets = scope ?? ["."];
|
|
1633
|
+
const ownerRoot = scope ? resolveOwnerRoot(cwd, scope) : cwd;
|
|
1634
|
+
const { fixUnit, runner, typescript } = await makeProductionFixUnit(config, baselineTargets, ownerRoot);
|
|
1635
|
+
const pm = detectPackageManager(cwd);
|
|
1636
|
+
reporter.note(`${pm} · ${typescript ? "TypeScript" : "JavaScript"} · ${runner ?? "no test runner"} · ${modelLabel}`);
|
|
1637
|
+
const scopeNote = describeScopeNote(opts.all, paths, scope);
|
|
1638
|
+
reporter.note(`${scopeNote} · ${plural(available.length, "scanner")}`);
|
|
1639
|
+
const bus = new EventBus();
|
|
1640
|
+
bus.on((e) => reporter.onEvent(e));
|
|
1641
|
+
const start = Date.now();
|
|
1642
|
+
const drawing = reporter.run();
|
|
1643
|
+
let result;
|
|
1644
|
+
try {
|
|
1645
|
+
result = await orchestrate({
|
|
1646
|
+
audit: buildAudit({
|
|
1647
|
+
cwd,
|
|
1648
|
+
which: realWhich,
|
|
1649
|
+
spawn: realSpawn,
|
|
1650
|
+
scope,
|
|
1651
|
+
timeoutMs: 12e4
|
|
1652
|
+
}),
|
|
1653
|
+
fixUnit,
|
|
1654
|
+
config,
|
|
1655
|
+
inScope: scope ? (fs) => filterToChanged(fs, scope) : void 0,
|
|
1656
|
+
bus
|
|
1657
|
+
});
|
|
1658
|
+
} finally {
|
|
1659
|
+
reporter.close();
|
|
1660
|
+
}
|
|
1661
|
+
await drawing;
|
|
1662
|
+
const durationMs = Date.now() - start;
|
|
1663
|
+
const builder = new ReportBuilder();
|
|
1664
|
+
builder.recordOutcomes(result.findings);
|
|
1665
|
+
builder.recordScannerStatuses(result.scannerStatuses);
|
|
1666
|
+
const report = builder.build({
|
|
1667
|
+
loops: result.loops,
|
|
1668
|
+
durationMs,
|
|
1669
|
+
exitStatus: result.exitStatus,
|
|
1670
|
+
aiUsage: result.usage
|
|
1671
|
+
});
|
|
1672
|
+
persist(REPORT_PATH, report);
|
|
1673
|
+
out("");
|
|
1674
|
+
out(renderSummary(report, {
|
|
1675
|
+
theme,
|
|
1676
|
+
verbose: opts.verbose,
|
|
1677
|
+
plain: Boolean(opts.plain) || !env.interactive
|
|
1678
|
+
}));
|
|
1679
|
+
process.exitCode = result.exitStatus;
|
|
1680
|
+
}
|
|
1681
|
+
async function runRetry(id) {
|
|
1682
|
+
const git = createGit(cwd);
|
|
1683
|
+
await assertGitRepo(git);
|
|
1684
|
+
const report = loadReport();
|
|
1685
|
+
const target = resolveRetryTarget(id, report.findings);
|
|
1686
|
+
if ("error" in target) {
|
|
1687
|
+
err(`✖ ${target.error}`);
|
|
1688
|
+
process.exitCode = 1;
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
const config = await loadConfig(cwd);
|
|
1692
|
+
let snapshotSaved = false;
|
|
1693
|
+
const result = await retryCommand(id, {
|
|
1694
|
+
report,
|
|
1695
|
+
baseBudget: config.perIssueBudget,
|
|
1696
|
+
runFix: async (finding) => {
|
|
1697
|
+
const unit = planWork([finding])[0];
|
|
1698
|
+
if (!unit) return {
|
|
1699
|
+
kept: false,
|
|
1700
|
+
reason: "session-error"
|
|
1701
|
+
};
|
|
1702
|
+
if (!snapshotSaved) {
|
|
1703
|
+
const snapshot = await Snapshot.capture(git, cwd);
|
|
1704
|
+
persist(SNAPSHOT_PATH, snapshot.toJSON());
|
|
1705
|
+
snapshotSaved = true;
|
|
1706
|
+
}
|
|
1707
|
+
const ownerRoot = resolveOwnerRoot(cwd, unit.files);
|
|
1708
|
+
const { fixUnit } = await makeProductionFixUnit(config, unit.files, ownerRoot);
|
|
1709
|
+
return fixUnit(unit, 1);
|
|
1710
|
+
}
|
|
1711
|
+
});
|
|
1712
|
+
if ("error" in result) {
|
|
1713
|
+
err(`✖ ${result.error}`);
|
|
1714
|
+
process.exitCode = 1;
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
persist(REPORT_PATH, report);
|
|
1718
|
+
if (result.outcome === "fixed") {
|
|
1719
|
+
out(`✔ fixed ${result.finding.file} (retry budget ${result.budget})`);
|
|
1720
|
+
process.exitCode = 0;
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
out(`↩ reverted ${result.finding.file} — ${reasonLabel(result.reason)} (retry budget ${result.budget})`);
|
|
1724
|
+
process.exitCode = 1;
|
|
1725
|
+
}
|
|
1726
|
+
const program = buildProgram({
|
|
1727
|
+
run: (opts) => runRun(opts),
|
|
1728
|
+
diff: async () => {
|
|
1729
|
+
const snapshot = Snapshot.fromJSON(loadJson(SNAPSHOT_PATH));
|
|
1730
|
+
const changed = await snapshot.changedSince(createGit(cwd));
|
|
1731
|
+
out(changed.length ? changed.join("\n") : "No tool edits.");
|
|
1732
|
+
},
|
|
1733
|
+
undo: async () => {
|
|
1734
|
+
const snapshot = Snapshot.fromJSON(loadJson(SNAPSHOT_PATH));
|
|
1735
|
+
await snapshot.restore(createGit(cwd));
|
|
1736
|
+
out("✔ Restored pre-run snapshot.");
|
|
1737
|
+
},
|
|
1738
|
+
show: (id) => {
|
|
1739
|
+
const report = loadReport();
|
|
1740
|
+
out(showCommand(id, report.findings));
|
|
1741
|
+
},
|
|
1742
|
+
retry: (id) => runRetry(id)
|
|
1743
|
+
});
|
|
1744
|
+
const argv = process.argv.slice(2).length === 0 ? [...process.argv, "run"] : process.argv;
|
|
1745
|
+
program.parseAsync(argv).catch((e) => {
|
|
1746
|
+
if (e instanceof Error && e.name === "CommanderError") {
|
|
1747
|
+
process.exitCode = e.exitCode ?? 1;
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
err(e instanceof Error ? e.message : String(e));
|
|
1751
|
+
process.exitCode = 1;
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1754
|
+
//#endregion
|