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/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