any-doctor 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CONTEXT.md +128 -0
  2. package/README.md +68 -0
  3. package/bin/capabilities.d.ts +15 -0
  4. package/bin/capabilities.js +131 -0
  5. package/bin/cli.d.ts +2 -0
  6. package/bin/cli.js +426 -0
  7. package/bin/clipboard.d.ts +1 -0
  8. package/bin/clipboard.js +10 -0
  9. package/bin/contract.d.ts +134 -0
  10. package/bin/contract.js +70 -0
  11. package/bin/dashboard.d.ts +108 -0
  12. package/bin/dashboard.js +718 -0
  13. package/bin/discover.d.ts +24 -0
  14. package/bin/discover.js +87 -0
  15. package/bin/doctor-loader.d.mts +1 -0
  16. package/bin/doctor-loader.mjs +161 -0
  17. package/bin/engine.d.ts +18 -0
  18. package/bin/engine.js +22 -0
  19. package/bin/fuzzy.d.ts +2 -0
  20. package/bin/fuzzy.js +31 -0
  21. package/bin/import-guard.mjs +31 -0
  22. package/bin/keys.d.ts +2 -0
  23. package/bin/keys.js +72 -0
  24. package/bin/palette.d.ts +7 -0
  25. package/bin/palette.js +14 -0
  26. package/bin/picker.d.ts +12 -0
  27. package/bin/picker.js +82 -0
  28. package/bin/report.d.ts +18 -0
  29. package/bin/report.js +159 -0
  30. package/bin/runner.d.ts +58 -0
  31. package/bin/runner.js +271 -0
  32. package/bin/score.d.ts +13 -0
  33. package/bin/score.js +39 -0
  34. package/bin/sdk.d.ts +5 -0
  35. package/bin/sdk.js +95 -0
  36. package/bin/search-host.d.ts +6 -0
  37. package/bin/search-host.js +56 -0
  38. package/bin/select.d.ts +35 -0
  39. package/bin/select.js +45 -0
  40. package/bin/tty.d.ts +38 -0
  41. package/bin/tty.js +94 -0
  42. package/docs/REPAIR-LOG.md +45 -0
  43. package/docs/RESULTS.md +70 -0
  44. package/docs/decisions.md +450 -0
  45. package/docs/example-catalog.md +122 -0
  46. package/docs/features.md +67 -0
  47. package/docs/first-shot-results.md +18 -0
  48. package/docs/intents.md +21 -0
  49. package/docs/kill-test.md +54 -0
  50. package/docs/research.md +66 -0
  51. package/docs/vision.md +83 -0
  52. package/doctors/AGENTS.md +103 -0
  53. package/doctors/api-route-files-do-import.fixtures.mjs +61 -0
  54. package/doctors/api-route-files-do-import.mjs +26 -0
  55. package/doctors/async-doctor.fixtures.mjs +147 -0
  56. package/doctors/async-doctor.mjs +295 -0
  57. package/doctors/convex-doctor.fixtures.mjs +177 -0
  58. package/doctors/convex-doctor.mjs +223 -0
  59. package/doctors/date-now-used-inside-effect.fixtures.mjs +46 -0
  60. package/doctors/date-now-used-inside-effect.mjs +132 -0
  61. package/doctors/json-parse-calls-llm-api.fixtures.mjs +37 -0
  62. package/doctors/json-parse-calls-llm-api.mjs +85 -0
  63. package/doctors/route-handlers-touch-database-before.fixtures.mjs +58 -0
  64. package/doctors/route-handlers-touch-database-before.mjs +98 -0
  65. package/doctors/z-record-called-with-single.fixtures.mjs +28 -0
  66. package/doctors/z-record-called-with-single.mjs +19 -0
  67. package/fixtures/sample-app/src/hooks/useChat.ts +15 -0
  68. package/fixtures/sample-app/src/lib/ai/client.ts +5 -0
  69. package/fixtures/sample-app/src/schemas/user.ts +6 -0
  70. package/fixtures/sample-app/src/services/chat.ts +17 -0
  71. package/fixtures/sample-app/src/services/user.ts +10 -0
  72. package/fixtures/sample-app/src/utils/sync.ts +16 -0
  73. package/package.json +42 -0
  74. package/skill/any-doctor.skill.md +188 -0
@@ -0,0 +1,718 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { copyToClipboard } from "./clipboard.js";
4
+ import { resolveFinding, runCommandFor } from "./contract.js";
5
+ import { scoreFromSeverities } from "./score.js";
6
+ import { processTtyEnv } from "./tty.js";
7
+ import * as tty from "./tty.js";
8
+ import { runTty, truncateVisible, visibleWidth } from "./tty.js";
9
+ import { BOLD, colorizer, DIM, GLYPH, gradeColor, GREEN, ORANGE, RESET, SEVERITY_COLOR, YELLOW } from "./palette.js";
10
+ import { unsafeSkipLine } from "./report.js";
11
+ const SPLIT_MIN_COLS = 100;
12
+ const TOKEN_RE = /(\/\/.*$)|('(?:[^'\\]|\\.)*'|"(?:[^'\\]|\\.)*"|`(?:[^`\\]|\\.)*`)|\b(const|let|var|function|return|if|else|for|while|await|async|try|catch|finally|import|export|from|new|class|extends|throw|typeof|instanceof|in|of|do|switch|case|break|continue|default|yield)\b|\b(\d+(?:\.\d+)?)\b/g;
13
+ export function highlightCode(line, useColor) {
14
+ if (!useColor)
15
+ return line;
16
+ return line.replace(TOKEN_RE, (m, comment, str, kw, num) => {
17
+ if (comment)
18
+ return DIM + m + RESET;
19
+ if (str)
20
+ return "\x1b[38;5;114m" + m + RESET;
21
+ if (kw)
22
+ return "\x1b[38;5;75m" + m + RESET;
23
+ if (num)
24
+ return ORANGE + m + RESET;
25
+ return m;
26
+ });
27
+ }
28
+ export function scoreBar(score, width) {
29
+ const filled = Math.round((score / 100) * width);
30
+ return "█".repeat(filled) + "░".repeat(Math.max(0, width - filled));
31
+ }
32
+ export function buildItems(groups) {
33
+ const items = [];
34
+ for (const g of groups) {
35
+ for (const f of g.findings) {
36
+ const j = resolveFinding(g.meta, f);
37
+ items.push({ ...j, readKey: j.checkKey + "@" + f.file + ":" + f.line, site: f });
38
+ }
39
+ }
40
+ return items;
41
+ }
42
+ // ---- copy prompts ----
43
+ //
44
+ // Enter copies the selected finding; `c` copies at whatever level the
45
+ // selection rests on — one finding, a whole check (the overarching
46
+ // explanation plus every site), or an entire doctor. The bulk prompt is
47
+ // the natural agent task: "fix this pattern everywhere it appears."
48
+ function promptHeading(severity, description, checkKey) {
49
+ return `${severity.toUpperCase()} · ${description} (${checkKey})`;
50
+ }
51
+ function scopeTail(scopeLine, verifyCommand, plural = false) {
52
+ return [
53
+ "",
54
+ "Scope:",
55
+ scopeLine,
56
+ `- Fix the root cause; do not suppress, disable, or silence ${plural ? "any of these checks" : "the check"}.`,
57
+ "- Keep unrelated refactors out of this pass.",
58
+ "",
59
+ `Verify with \`${verifyCommand}\` and confirm the finding${plural ? "s are" : " is"} gone before moving on.`,
60
+ ];
61
+ }
62
+ export function fixPrompt(item, verifyCommand) {
63
+ const site = item.site;
64
+ const lines = [
65
+ "Fix exactly one any-doctor finding:",
66
+ "",
67
+ promptHeading(item.severity, item.description, item.checkKey),
68
+ "",
69
+ `Affected site: ${site.file}:${site.line}`,
70
+ ];
71
+ if (item.impact)
72
+ lines.push("", "Impact " + item.impact);
73
+ if (item.why)
74
+ lines.push("", "Why " + item.why);
75
+ if (item.fix)
76
+ lines.push("", "Suggested fix: " + item.fix);
77
+ lines.push(...scopeTail(`- Fix only ${item.checkKey} at this site.`, verifyCommand));
78
+ return lines.join("\n");
79
+ }
80
+ // The re-scan loop is the pagination for display; a copied task still
81
+ // lists generously, then defers the remainder to the next run.
82
+ const PROMPT_SITES_CAP = 100;
83
+ function sitesOf(items, cap) {
84
+ const shown = items.slice(0, cap);
85
+ return {
86
+ lines: shown.map(i => `- ${i.site.file}:${i.site.line}`),
87
+ hidden: items.length - shown.length,
88
+ };
89
+ }
90
+ function pushSites(lines, sites) {
91
+ lines.push(...sites.lines);
92
+ if (sites.hidden > 0) {
93
+ lines.push(`- … and ${sites.hidden} more — fix this batch, then re-run for the rest`);
94
+ }
95
+ }
96
+ export function checkFixPrompt(items, verifyCommand) {
97
+ const check = summarizeCheck(items[0].checkKey, items);
98
+ const lines = [
99
+ "Fix every finding of one any-doctor check:",
100
+ "",
101
+ promptHeading(check.severity, check.description, check.checkKey),
102
+ "",
103
+ `${check.count} finding${check.count === 1 ? "" : "s"} across ${check.files} file${check.files === 1 ? "" : "s"}:`,
104
+ ];
105
+ pushSites(lines, sitesOf(items, PROMPT_SITES_CAP));
106
+ if (check.impact)
107
+ lines.push("", "Impact " + check.impact);
108
+ if (check.why)
109
+ lines.push("", "Why " + check.why);
110
+ if (check.fix)
111
+ lines.push("", "Suggested fix: " + check.fix);
112
+ lines.push(...scopeTail(`- Fix ${check.checkKey} at every listed site — as many as practical in one pass.`, verifyCommand, true));
113
+ return lines.join("\n");
114
+ }
115
+ // Doctor prompts list generously per check but not boundlessly; the
116
+ // re-run note carries the remainder.
117
+ const DOCTOR_PROMPT_SITES_PER_CHECK = 25;
118
+ export function doctorFixPrompt(doc, group, verifyCommand) {
119
+ var _a;
120
+ const lines = [
121
+ "Fix the findings of one any-doctor program:",
122
+ "",
123
+ `${doc.worst.toUpperCase()} · ${doc.doctorId} — ${doc.count} finding${doc.count === 1 ? "" : "s"} across ${doc.files} file${doc.files === 1 ? "" : "s"}`,
124
+ ];
125
+ for (const g of (_a = group === null || group === void 0 ? void 0 : group.checks) !== null && _a !== void 0 ? _a : []) {
126
+ {
127
+ const summary = summarizeCheck(g.checkKey, g.items);
128
+ lines.push("", `${summary.severity.toUpperCase()} · ${summary.description} (${summary.checkKey}) — ${summary.count} finding${summary.count === 1 ? "" : "s"}`);
129
+ if (summary.why)
130
+ lines.push("Why " + summary.why);
131
+ if (summary.fix)
132
+ lines.push("Suggested fix: " + summary.fix);
133
+ pushSites(lines, sitesOf(g.items, DOCTOR_PROMPT_SITES_PER_CHECK));
134
+ }
135
+ }
136
+ lines.push(...scopeTail(`- Fix every check of ${doc.doctorId} at the listed sites — as many as practical in one pass.`, verifyCommand, true));
137
+ return lines.join("\n");
138
+ }
139
+ function padVisible(s, width) {
140
+ return s + " ".repeat(Math.max(0, width - visibleWidth(s)));
141
+ }
142
+ function wordWrap(text, width) {
143
+ if (!text)
144
+ return [];
145
+ const words = text.split(/\s+/);
146
+ const lines = [];
147
+ let current = "";
148
+ for (const word of words) {
149
+ if (!current) {
150
+ current = word;
151
+ continue;
152
+ }
153
+ if (current.length + 1 + word.length <= width)
154
+ current += " " + word;
155
+ else {
156
+ lines.push(current);
157
+ current = word;
158
+ }
159
+ }
160
+ if (current)
161
+ lines.push(current);
162
+ return lines;
163
+ }
164
+ // Fixed chrome around the body: 4 header lines, 2 blank spacers, 2 footer
165
+ // lines (the notice line is always reserved), plus the bottom terminal row,
166
+ // which is never written so no repaint can make the terminal scroll.
167
+ const CHROME_ROWS = 9;
168
+ export function resolveDashboardLayout(cols, rows, itemCount) {
169
+ const bodyRows = Math.max(1, rows - CHROME_ROWS);
170
+ if (cols >= SPLIT_MIN_COLS) {
171
+ const listWidth = Math.min(56, Math.max(32, Math.floor(cols * 0.44)));
172
+ const detailWidth = cols - listWidth - 2;
173
+ return { mode: "split", listWidth, detailWidth, listHeight: bodyRows, detailHeight: bodyRows, bodyRows };
174
+ }
175
+ const listHeight = Math.min(Math.max(2, Math.ceil(bodyRows * 0.4)), Math.max(1, itemCount));
176
+ return {
177
+ mode: "stacked",
178
+ listWidth: cols,
179
+ detailWidth: cols,
180
+ listHeight,
181
+ detailHeight: Math.max(1, bodyRows - listHeight - 2),
182
+ bodyRows,
183
+ };
184
+ }
185
+ // The re-scan loop is the pagination: a check shows its first N findings,
186
+ // then an affordance to fix a few and run again.
187
+ export const FINDINGS_PER_CHECK = 50;
188
+ const SEVERITY_RANK = { error: 0, warning: 1, info: 2 };
189
+ export function buildTree(items) {
190
+ var _a;
191
+ const doctors = [];
192
+ const byDoctor = new Map();
193
+ for (const it of items) {
194
+ let checks = byDoctor.get(it.doctorId);
195
+ if (!checks) {
196
+ checks = new Map();
197
+ byDoctor.set(it.doctorId, checks);
198
+ doctors.push({ doctorId: it.doctorId, checks: [], multiCheck: false, count: 0, worst: "info" });
199
+ }
200
+ const group = (_a = checks.get(it.checkKey)) !== null && _a !== void 0 ? _a : [];
201
+ group.push(it);
202
+ checks.set(it.checkKey, group);
203
+ }
204
+ for (const d of doctors) {
205
+ const entries = [...byDoctor.get(d.doctorId).entries()].map(([checkKey, list]) => ({
206
+ checkKey,
207
+ items: [...list].sort((a, b) => a.site.file === b.site.file
208
+ ? a.site.line - b.site.line
209
+ : a.site.file < b.site.file ? -1 : 1),
210
+ }));
211
+ d.checks = entries.sort((a, b) => {
212
+ const sa = SEVERITY_RANK[a.items[0].declaredSeverity];
213
+ const sb = SEVERITY_RANK[b.items[0].declaredSeverity];
214
+ return sa !== sb ? sa - sb : b.items.length - a.items.length || (a.checkKey < b.checkKey ? -1 : 1);
215
+ });
216
+ d.multiCheck = entries.length > 1;
217
+ d.count = entries.reduce((n, g) => n + g.items.length, 0);
218
+ d.worst = entries.reduce((w, g) => (SEVERITY_RANK[g.items[0].severity] < SEVERITY_RANK[w] ? g.items[0].severity : w), "info");
219
+ }
220
+ // Triage order: worst severity first, then most findings, then name.
221
+ return doctors.sort((a, b) => SEVERITY_RANK[a.worst] - SEVERITY_RANK[b.worst]
222
+ || b.count - a.count
223
+ || (a.doctorId < b.doctorId ? -1 : 1));
224
+ }
225
+ export function summarizeCheck(checkKey, groupItems) {
226
+ const first = groupItems[0];
227
+ const files = new Set(groupItems.map(i => i.site.file));
228
+ return {
229
+ checkKey,
230
+ checkId: first.checkId,
231
+ doctorId: first.doctorId,
232
+ description: first.description,
233
+ severity: first.declaredSeverity,
234
+ impact: first.impact,
235
+ why: first.why,
236
+ fix: first.fix,
237
+ blindSpots: first.blindSpots,
238
+ count: groupItems.length,
239
+ files: files.size,
240
+ };
241
+ }
242
+ export function summarizeDoctor(d) {
243
+ var _a;
244
+ const first = (_a = d.checks[0]) === null || _a === void 0 ? void 0 : _a.items[0];
245
+ const files = new Set();
246
+ for (const g of d.checks)
247
+ for (const i of g.items)
248
+ files.add(i.site.file);
249
+ return {
250
+ doctorId: d.doctorId,
251
+ description: first ? first.description : d.doctorId,
252
+ worst: d.worst,
253
+ count: d.count,
254
+ files: files.size,
255
+ checks: d.checks.map(g => ({
256
+ description: g.items[0].description,
257
+ severity: g.items[0].declaredSeverity,
258
+ count: g.items.length,
259
+ })),
260
+ blindSpots: first === null || first === void 0 ? void 0 : first.blindSpots,
261
+ };
262
+ }
263
+ // Errors open on entry: any doctor carrying error findings, any
264
+ // error-severity check, and the top of the list so the first screen is
265
+ // never an empty overview. Everything else starts collapsed.
266
+ export function initialExpanded(tree) {
267
+ const expanded = new Set();
268
+ const multiDoctor = tree.length > 1;
269
+ tree.forEach((d, i) => {
270
+ if (multiDoctor && (d.worst === "error" || i === 0))
271
+ expanded.add(d.doctorId);
272
+ if (!d.multiCheck)
273
+ return;
274
+ for (const g of d.checks) {
275
+ if (g.items[0].declaredSeverity === "error")
276
+ expanded.add(g.checkKey);
277
+ }
278
+ });
279
+ return expanded;
280
+ }
281
+ // The one way to ask what a row toggles — doctor rows answer their
282
+ // doctorId, check rows their checkKey, everything else nothing.
283
+ function toggleKeyOf(row) {
284
+ return row === null || row === void 0 ? void 0 : row.toggleKey;
285
+ }
286
+ export function buildListRows(tree, useColor, selectedRow, readKeys, expanded) {
287
+ const c = colorizer(useColor);
288
+ const open = expanded !== null && expanded !== void 0 ? expanded : new Set();
289
+ const multiDoctor = tree.length > 1;
290
+ const rows = [];
291
+ for (const d of tree) {
292
+ if (!multiDoctor) {
293
+ rows.push({
294
+ kind: "section",
295
+ text: c(d.doctorId, BOLD),
296
+ severity: d.worst,
297
+ selectable: false,
298
+ });
299
+ }
300
+ else {
301
+ // The dashboard is the selection surface: every doctor is a
302
+ // collapsible row, severity-ordered, with its total count.
303
+ const summary = summarizeDoctor(d);
304
+ const rowIndex = rows.length;
305
+ const isOpen = open.has(d.doctorId);
306
+ rows.push({
307
+ kind: "section",
308
+ text: `${selectedRow === rowIndex ? c("›", BOLD) : " "}${c(isOpen ? "▾" : "▸", DIM)} ${c(GLYPH[summary.worst], SEVERITY_COLOR[summary.worst])} ${c(summary.doctorId, BOLD)} ${c("×" + summary.count, DIM)}`,
309
+ severity: summary.worst,
310
+ selectable: true,
311
+ doctor: summary,
312
+ toggleKey: d.doctorId,
313
+ });
314
+ if (!isOpen)
315
+ continue;
316
+ }
317
+ // Children of a doctor row: checks for multi-check doctors, capped
318
+ // flat findings for single-check ones. Tree connectors make the
319
+ // hierarchy structural — and they follow the DOCTOR's shape, not the
320
+ // frame's: one multi-check doctor run directly nests exactly like the
321
+ // aggregate. Only a single-check doctor alone in a single-doctor
322
+ // frame is flat.
323
+ const nested = multiDoctor || d.multiCheck;
324
+ const childPrefix = (ci, last) => nested ? (ci < last ? " \u251c\u2500 " : " \u2514\u2500 ") : multiDoctor ? " " : "";
325
+ const guidePrefix = (ci, last) => nested ? (ci < last ? " \u2502 " : " ") : multiDoctor ? " " : "";
326
+ if (!d.multiCheck) {
327
+ // Flat findings, capped like checks; the re-scan loop is the
328
+ // pagination.
329
+ const all = d.checks[0].items;
330
+ const flat = all.slice(0, FINDINGS_PER_CHECK);
331
+ flat.forEach((it, ci) => {
332
+ const rowIndex = rows.length;
333
+ rows.push({
334
+ kind: "item",
335
+ text: childPrefix(ci, flat.length - 1 + (all.length > flat.length ? 1 : 0)) + itemRowText(it, selectedRow === rowIndex, readKeys, c, !multiDoctor),
336
+ severity: it.severity,
337
+ selectable: true,
338
+ item: it,
339
+ });
340
+ });
341
+ if (all.length > flat.length) {
342
+ const moreRowIndex = rows.length;
343
+ rows.push({
344
+ kind: "more",
345
+ // The payload makes selecting this row informative (the doctor's
346
+ // story) instead of a blank detail pane, and lets `c` copy the
347
+ // group task from here like from the check row.
348
+ text: childPrefix(flat.length, flat.length) + `${selectedRow === moreRowIndex ? c("›", BOLD) + " " : ""}${c("… and " + (all.length - flat.length) + " more — fix a few and re-scan", DIM)}`,
349
+ severity: d.worst,
350
+ selectable: true,
351
+ doctor: summarizeDoctor(d),
352
+ });
353
+ }
354
+ continue;
355
+ }
356
+ d.checks.forEach((g, ci) => {
357
+ const summary = summarizeCheck(g.checkKey, g.items);
358
+ const checkRowIndex = rows.length;
359
+ const isOpen = open.has(g.checkKey);
360
+ const arrow = isOpen ? "\u25be" : "\u25b8";
361
+ rows.push({
362
+ kind: "check",
363
+ text: childPrefix(ci, d.checks.length - 1)
364
+ + `${selectedRow === checkRowIndex ? c("›", BOLD) : " "}${c(arrow, DIM)} ${c(GLYPH[summary.severity], SEVERITY_COLOR[summary.severity])} ${c(summary.description, selectedRow === checkRowIndex ? BOLD : undefined)} ${c("×" + summary.count, DIM)}`,
365
+ severity: summary.severity,
366
+ selectable: true,
367
+ check: summary,
368
+ toggleKey: g.checkKey,
369
+ });
370
+ if (!isOpen)
371
+ return;
372
+ const shown = g.items.slice(0, FINDINGS_PER_CHECK);
373
+ shown.forEach(it => {
374
+ const rowIndex = rows.length;
375
+ rows.push({
376
+ kind: "item",
377
+ text: guidePrefix(ci, d.checks.length - 1) + itemRowText(it, selectedRow === rowIndex, readKeys, c, false),
378
+ severity: it.severity,
379
+ selectable: true,
380
+ item: it,
381
+ });
382
+ });
383
+ if (g.items.length > shown.length) {
384
+ const moreRowIndex = rows.length;
385
+ rows.push({
386
+ kind: "more",
387
+ text: guidePrefix(ci, d.checks.length - 1) + `${selectedRow === moreRowIndex ? c("›", BOLD) + " " : ""}${c("… and " + (g.items.length - shown.length) + " more — fix a few and re-scan", DIM)}`,
388
+ severity: summary.severity,
389
+ selectable: true,
390
+ check: summary,
391
+ });
392
+ }
393
+ });
394
+ }
395
+ return rows;
396
+ }
397
+ function itemRowText(it, isSelected, readKeys, c, showCheckId = true) {
398
+ const isRead = readKeys.has(it.readKey);
399
+ const glyph = c(GLYPH[it.severity], SEVERITY_COLOR[it.severity]);
400
+ const wrap = isSelected ? BOLD : isRead ? DIM : undefined;
401
+ const suffix = showCheckId && it.checkId !== it.doctorId ? c(" " + it.checkId, DIM) : "";
402
+ return `${isSelected ? c("›", BOLD) : " "}${glyph} ${c(it.site.file + ":" + it.site.line, wrap)}${suffix}`;
403
+ }
404
+ export function dashboardFrame(state) {
405
+ var _a, _b, _c, _d, _e;
406
+ const { tree, selectedRow, readKeys, useColor, cols, rows } = state;
407
+ const c = colorizer(useColor);
408
+ const findings = tree.flatMap(d => d.checks.flatMap(g => g.items));
409
+ const layout = resolveDashboardLayout(cols, rows, findings.length);
410
+ const { score, grade } = scoreFromSeverities(findings.map(it => it.severity));
411
+ const barWidth = Math.min(46, Math.max(16, cols - 60));
412
+ const header = [
413
+ c(`Score: ${score} / 100 — ${grade}`, BOLD + gradeColor(score)),
414
+ c(scoreBar(score, barWidth), gradeColor(score)),
415
+ c(`${findings.length} finding${findings.length === 1 ? "" : "s"} · ${state.fileCount} files · ${state.durationMs}ms`, DIM),
416
+ ];
417
+ if (state.skippedUnsafe !== undefined && state.skippedUnsafe.length > 0) {
418
+ header.push(c(`\u26a0 ${unsafeSkipLine(state.skippedUnsafe)}`, YELLOW));
419
+ }
420
+ header.push("");
421
+ const rowsData = buildListRows(tree, useColor, selectedRow, readKeys, state.expanded);
422
+ const viewport = Math.max(1, Math.min(layout.listHeight, layout.bodyRows));
423
+ let firstVisible = Math.max(0, Math.min(selectedRow - viewport + 1, Math.max(0, rowsData.length - viewport)));
424
+ if (selectedRow >= 0 && selectedRow < firstVisible)
425
+ firstVisible = selectedRow;
426
+ const visibleRows = rowsData.slice(firstVisible, firstVisible + viewport);
427
+ const listLines = [];
428
+ for (const row of visibleRows) {
429
+ listLines.push(truncateVisible(row.text, layout.listWidth));
430
+ }
431
+ while (listLines.length < viewport)
432
+ listLines.push("");
433
+ const detail = [];
434
+ const selRow = rowsData[selectedRow];
435
+ if (selRow === null || selRow === void 0 ? void 0 : selRow.item) {
436
+ const sel = selRow.item;
437
+ detail.push(c(`${sel.site.file}:${sel.site.line}`, BOLD));
438
+ detail.push(c(`${cap(sel.category)} · ${sel.severity}`, DIM));
439
+ detail.push("");
440
+ const impact = (_a = sel.impact) !== null && _a !== void 0 ? _a : sel.description;
441
+ for (const l of wordWrap(impact, layout.detailWidth - 2))
442
+ detail.push(c(l, SEVERITY_COLOR[sel.severity]));
443
+ detail.push("");
444
+ proseSection(detail, "Why", (_b = sel.why) !== null && _b !== void 0 ? _b : "Not documented for this check.", layout.detailWidth - 2, c);
445
+ detail.push("");
446
+ detail.push(c("Code", DIM));
447
+ for (const l of codeFrameLines(state.readSource(sel.site.file), sel.site.line, layout.detailWidth - 2, useColor))
448
+ detail.push(" " + l);
449
+ detail.push("");
450
+ if (sel.fix)
451
+ proseSection(detail, "Fix", sel.fix, layout.detailWidth - 2, c);
452
+ detail.push(...blindSpotLines(sel.blindSpots, layout.detailWidth - 2, c));
453
+ }
454
+ else if (selRow === null || selRow === void 0 ? void 0 : selRow.doctor) {
455
+ const d = selRow.doctor;
456
+ detail.push(c(d.doctorId, BOLD));
457
+ detail.push(c(`${d.count} finding${d.count === 1 ? "" : "s"} across ${d.files} file${d.files === 1 ? "" : "s"} · worst ${d.worst}`, DIM));
458
+ detail.push("");
459
+ for (const ck of d.checks) {
460
+ detail.push(`${c(GLYPH[ck.severity], SEVERITY_COLOR[ck.severity])} ${c(ck.description, d.checks.length > 1 ? BOLD : undefined)} ${c("×" + ck.count, DIM)}`);
461
+ }
462
+ detail.push(...blindSpotLines(d.blindSpots, layout.detailWidth - 2, c));
463
+ }
464
+ else if (selRow === null || selRow === void 0 ? void 0 : selRow.check) {
465
+ // A check row (or its "… and N more" affordance) tells the check's
466
+ // story: what it catches, why it matters, how to fix it, and the
467
+ // blast radius.
468
+ const s = selRow.check;
469
+ detail.push(c(s.checkKey, BOLD));
470
+ detail.push(c(`${s.count} finding${s.count === 1 ? "" : "s"} across ${s.files} file${s.files === 1 ? "" : "s"} · ${s.severity}`, DIM));
471
+ detail.push("");
472
+ for (const l of wordWrap(s.description, layout.detailWidth - 2))
473
+ detail.push(c(l, SEVERITY_COLOR[s.severity]));
474
+ if (s.impact) {
475
+ detail.push("");
476
+ proseSection(detail, "Impact", s.impact, layout.detailWidth - 2, c);
477
+ }
478
+ if (s.why) {
479
+ detail.push("");
480
+ proseSection(detail, "Why", s.why, layout.detailWidth - 2, c);
481
+ }
482
+ if (s.fix) {
483
+ detail.push("");
484
+ proseSection(detail, "Fix", s.fix, layout.detailWidth - 2, c);
485
+ }
486
+ detail.push(...blindSpotLines(s.blindSpots, layout.detailWidth - 2, c));
487
+ }
488
+ // The body always renders exactly layout.bodyRows lines so the frame
489
+ // height is constant (rows - 1) in every state.
490
+ const body = [];
491
+ if (layout.mode === "split") {
492
+ for (let i = 0; i < layout.bodyRows; i++) {
493
+ body.push(padVisible(truncateVisible((_c = listLines[i]) !== null && _c !== void 0 ? _c : "", layout.listWidth), layout.listWidth) + " " + ((_d = detail[i]) !== null && _d !== void 0 ? _d : ""));
494
+ }
495
+ }
496
+ else {
497
+ const stacked = [
498
+ ...listLines,
499
+ "",
500
+ c("─".repeat(Math.max(10, Math.min(cols - 2, 80))), DIM),
501
+ ...detail,
502
+ ];
503
+ for (let i = 0; i < layout.bodyRows; i++)
504
+ body.push((_e = stacked[i]) !== null && _e !== void 0 ? _e : "");
505
+ }
506
+ // Fixed-shape footer: the notice line is always present (blank when idle)
507
+ // so showing or clearing a notice never changes the frame height.
508
+ const footer = [
509
+ state.notice ? c("✔ " + state.notice, GREEN) : "",
510
+ c("↑↓ move · →← expand · enter copy finding · c copy group · q quit", DIM),
511
+ ];
512
+ return [...header, "", ...body, "", ...footer].join("\n");
513
+ }
514
+ function cap(s) {
515
+ return s.charAt(0).toUpperCase() + s.slice(1);
516
+ }
517
+ // The shared tail of every detail story: a labeled prose section and the
518
+ // blind-spot footnote. The item, check, and doctor branches differ in
519
+ // their headers and rollups, not in how prose renders.
520
+ function proseSection(detail, label, text, width, c) {
521
+ detail.push(c(label, DIM));
522
+ for (const l of wordWrap(text, width))
523
+ detail.push(" " + l);
524
+ }
525
+ function blindSpotLines(blindSpots, width, c) {
526
+ if (!blindSpots || blindSpots.length === 0)
527
+ return [];
528
+ return wordWrap("blind spots: " + blindSpots.join("; "), width).map(l => c(" " + l, DIM));
529
+ }
530
+ function codeFrameLines(source, line, width, useColor) {
531
+ var _a;
532
+ const c = colorizer(useColor);
533
+ const out = [];
534
+ if (source === null) {
535
+ out.push(c(" (source unavailable)", DIM));
536
+ return out;
537
+ }
538
+ const from = Math.max(0, line - 3);
539
+ const to = Math.min(source.length, line + 2);
540
+ for (let i = from; i < to; i++) {
541
+ const marker = i === line - 1 ? c("> ", BOLD) : " ";
542
+ const num = c(String(i + 1).padStart(3), DIM);
543
+ const text = truncateVisible((_a = source[i]) !== null && _a !== void 0 ? _a : "", Math.max(10, width));
544
+ out.push(`${marker} ${num} │ ${highlightCode(text, useColor)}`);
545
+ }
546
+ return out;
547
+ }
548
+ export async function runDashboard(input) {
549
+ await runDashboardOn(processTtyEnv(), input);
550
+ }
551
+ export async function runDashboardOn(env, input, deps = {}) {
552
+ const { stdout } = env;
553
+ if (!tty.canRunTui(env))
554
+ return;
555
+ const useColor = input.useColor;
556
+ // The tree is computed once from immutable items; everything downstream
557
+ // — rows, prompts, expansion, detail — reads this frozen shape.
558
+ const tree = buildTree(buildItems(input.outcome.groups));
559
+ const expanded = initialExpanded(tree);
560
+ const readKeys = new Set();
561
+ let notice;
562
+ const currentRows = () => buildListRows(tree, useColor, selectedRow, readKeys, expanded);
563
+ let selectedRow = (() => {
564
+ const rows = buildListRows(tree, useColor, 0, new Set(), expanded);
565
+ const first = rows.findIndex(r => r.selectable);
566
+ return first === -1 ? 0 : first;
567
+ })();
568
+ // A review session re-reads the same files on every selection; caching
569
+ // keeps keypresses off the disk (the frame shows the session-start view).
570
+ const sourceCache = new Map();
571
+ const readSource = (file) => {
572
+ if (sourceCache.has(file))
573
+ return sourceCache.get(file);
574
+ let lines = null;
575
+ try {
576
+ lines = fs.readFileSync(path.resolve(input.outcome.targetDir, file), "utf8").split("\n");
577
+ }
578
+ catch {
579
+ lines = null;
580
+ }
581
+ sourceCache.set(file, lines);
582
+ return lines;
583
+ };
584
+ const verifyCmdFor = (doctorId) => {
585
+ const doctorPath = input.outcome.doctorPaths.get(doctorId);
586
+ if (doctorPath === undefined)
587
+ return "(doctor path unknown — re-run from the CLI)";
588
+ return runCommandFor(doctorPath, input.outcome.targetDir, input.invoker);
589
+ };
590
+ const copy = (text, what) => {
591
+ var _a;
592
+ notice = ((_a = deps.copy) !== null && _a !== void 0 ? _a : copyToClipboard)(text)
593
+ ? `${what} — paste into your agent`
594
+ : "clipboard unavailable";
595
+ };
596
+ const frame = () => {
597
+ try {
598
+ const selRow = currentRows()[selectedRow];
599
+ if (selRow === null || selRow === void 0 ? void 0 : selRow.item)
600
+ readKeys.add(selRow.item.readKey);
601
+ return dashboardFrame({
602
+ tree,
603
+ selectedRow,
604
+ readKeys,
605
+ readSource,
606
+ expanded,
607
+ fileCount: input.outcome.fileCount,
608
+ durationMs: input.outcome.durationMs,
609
+ useColor,
610
+ notice,
611
+ skippedUnsafe: input.outcome.skippedUnsafe,
612
+ cols: stdout.columns || 120,
613
+ rows: stdout.rows || 34,
614
+ });
615
+ }
616
+ catch (e) {
617
+ const err = e;
618
+ return "DASHBOARD RENDER ERROR — the view is frozen, press q to quit.\n"
619
+ + "Send a screenshot of this to the maintainer:\n\n"
620
+ + String(err && err.stack ? err.stack : err);
621
+ }
622
+ };
623
+ const step = (dir) => {
624
+ const rows = currentRows();
625
+ let next = selectedRow + dir;
626
+ while (next >= 0 && next < rows.length && !rows[next].selectable)
627
+ next += dir;
628
+ if (next >= 0 && next < rows.length)
629
+ selectedRow = next;
630
+ notice = undefined;
631
+ };
632
+ await runTty({
633
+ stdin: env.stdin,
634
+ stdout,
635
+ frame,
636
+ onKey: (key, finish) => {
637
+ var _a, _b, _c;
638
+ if (key === "q" || key === "\x03" || key === "esc")
639
+ return finish();
640
+ if (key === "ignore")
641
+ return;
642
+ if (key === "up" || key === "k")
643
+ return step(-1);
644
+ if (key === "down" || key === "j")
645
+ return step(1);
646
+ const rows = currentRows();
647
+ const row = rows[selectedRow];
648
+ const rowKey = toggleKeyOf(row);
649
+ if (key === "c") {
650
+ // Copy at the level you're on: one finding, a whole check, or an
651
+ // entire doctor — the bulk task is the natural agent unit.
652
+ if (row === null || row === void 0 ? void 0 : row.item) {
653
+ copy(fixPrompt(row.item, verifyCmdFor(row.item.doctorId)), "copied finding");
654
+ }
655
+ else if (row === null || row === void 0 ? void 0 : row.check) {
656
+ const check = row.check;
657
+ const items = (_c = (_b = (_a = tree.find(d => d.doctorId === check.doctorId)) === null || _a === void 0 ? void 0 : _a.checks.find(g => g.checkKey === check.checkKey)) === null || _b === void 0 ? void 0 : _b.items) !== null && _c !== void 0 ? _c : [];
658
+ copy(checkFixPrompt(items, verifyCmdFor(check.doctorId)), `copied ${items.length} findings from ${check.checkKey}`);
659
+ }
660
+ else if (row === null || row === void 0 ? void 0 : row.doctor) {
661
+ const doctor = row.doctor;
662
+ const group = tree.find(d => d.doctorId === doctor.doctorId);
663
+ copy(doctorFixPrompt(doctor, group, verifyCmdFor(doctor.doctorId)), `copied ${doctor.count} findings from ${doctor.doctorId}`);
664
+ }
665
+ return;
666
+ }
667
+ if (key === "right") {
668
+ if (rowKey)
669
+ expanded.add(rowKey);
670
+ notice = undefined;
671
+ return;
672
+ }
673
+ if (key === "left" || key === "\x7f" || key === "\b") {
674
+ // Back climbs one level: a finding collapses to its check, a
675
+ // check (or anything beneath a collapsed toggle) collapses to its
676
+ // doctor — landing on the parent so you can select through again.
677
+ const collapseUp = (from) => {
678
+ let i = from;
679
+ while (i >= 0 && toggleKeyOf(rows[i]) === undefined)
680
+ i--;
681
+ if (i < 0)
682
+ return;
683
+ const toggleKey = toggleKeyOf(rows[i]);
684
+ if (expanded.has(toggleKey)) {
685
+ expanded.delete(toggleKey);
686
+ selectedRow = i;
687
+ return;
688
+ }
689
+ // Already collapsed (we're ON it): climb to its parent toggle.
690
+ let j = i - 1;
691
+ while (j >= 0 && toggleKeyOf(rows[j]) === undefined)
692
+ j--;
693
+ if (j < 0)
694
+ return;
695
+ expanded.delete(toggleKeyOf(rows[j]));
696
+ selectedRow = j;
697
+ };
698
+ collapseUp(selectedRow);
699
+ notice = undefined;
700
+ return;
701
+ }
702
+ if (key === "\r" || key === "\n") {
703
+ if (rowKey !== undefined) {
704
+ if (expanded.has(rowKey))
705
+ expanded.delete(rowKey);
706
+ else
707
+ expanded.add(rowKey);
708
+ notice = undefined;
709
+ return;
710
+ }
711
+ if (!(row === null || row === void 0 ? void 0 : row.item))
712
+ return;
713
+ copy(fixPrompt(row.item, verifyCmdFor(row.item.doctorId)), "copied finding");
714
+ return;
715
+ }
716
+ },
717
+ });
718
+ }