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
package/bin/cli.js ADDED
@@ -0,0 +1,426 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { fileURLToPath, pathToFileURL } from "url";
5
+ import { DOCTOR_FILE_RE } from "./contract.js";
6
+ import { renderReport, renderVerifyResult, unsafeSkipLine } from "./report.js";
7
+ import { copyToClipboard } from "./clipboard.js";
8
+ import { runDashboard } from "./dashboard.js";
9
+ import { brokenDoctors, discoverDoctors, globalDoctorsDir, unsafeSlugs } from "./discover.js";
10
+ import { causeSummaryLine, describeRunnerError, isRunnerError, runDoctor, verifyDoctor } from "./runner.js";
11
+ import { scanDoctorFile, capabilitySummary } from "./capabilities.js";
12
+ import { selectDoctor } from "./select.js";
13
+ import { canRunTui, processTtyEnv } from "./tty.js";
14
+ import { BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW } from "./palette.js";
15
+ function fail(msg) {
16
+ console.error(RED + msg + RESET);
17
+ }
18
+ function ok(msg) {
19
+ console.log(GREEN + msg + RESET);
20
+ }
21
+ function warn(msg) {
22
+ console.log(YELLOW + msg + RESET);
23
+ }
24
+ function dim(msg) {
25
+ return DIM + msg + RESET;
26
+ }
27
+ function skillText() {
28
+ const p = fileURLToPath(new URL("../skill/any-doctor.skill.md", import.meta.url));
29
+ try {
30
+ return fs.readFileSync(p, "utf8");
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ function useColor() {
37
+ return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
38
+ }
39
+ // Commands compute exit codes; process.exit happens exactly once, in the
40
+ // direct-invocation guard at the bottom of this file. An ExitCode thrown
41
+ // mid-command aborts it with a code — main flattens it into its return
42
+ // value, so callers and tests always get a number, never a rejection.
43
+ class ExitCode extends Error {
44
+ constructor(code) {
45
+ super("exit " + code);
46
+ this.code = code;
47
+ }
48
+ }
49
+ // The one place the command layer crosses the Runner seam: a failure here is
50
+ // a failure of the whole command, so it renders and aborts. Exit policy
51
+ // lives in this layer, never in the Runner.
52
+ async function runOrReport(work) {
53
+ try {
54
+ return await work;
55
+ }
56
+ catch (e) {
57
+ fail(isRunnerError(e) ? describeRunnerError(e) : String(e));
58
+ throw new ExitCode(1);
59
+ }
60
+ }
61
+ function warnBrokenDoctors(skipped) {
62
+ for (const b of skipped) {
63
+ console.log(YELLOW + "\u26a0 skipping broken doctor " + b.slug + RESET + dim(" — " + causeSummaryLine(b.cause)));
64
+ }
65
+ }
66
+ function selectionOutcome(sel) {
67
+ switch (sel.kind) {
68
+ case "doctor":
69
+ warnBrokenDoctors(sel.skipped);
70
+ if (sel.unsafe.length > 0)
71
+ warn("\u26a0 " + unsafeSkipLine(sel.unsafe));
72
+ return { doctorPath: sel.doctorPath };
73
+ case "not-found":
74
+ fail(`no doctor program found for "${sel.arg}"`);
75
+ fail(`searched ./doctors (walking up from ${process.cwd()}) and ~/.any-doctor/doctors`);
76
+ return { exit: 1 };
77
+ case "none-discovered":
78
+ fail(`no doctors discovered in ${process.cwd()}/doctors or ~/.any-doctor/doctors`);
79
+ fail('create one with: any-doctor generate "<intent>"');
80
+ if (sel.unsafe.length > 0)
81
+ warn("\u26a0 " + unsafeSkipLine(sel.unsafe));
82
+ for (const b of sel.broken)
83
+ fail("broken: " + b.slug + " — " + causeSummaryLine(b.cause));
84
+ return { exit: 1 };
85
+ case "non-interactive":
86
+ warnBrokenDoctors(sel.skipped);
87
+ if (sel.unsafe.length > 0)
88
+ warn("\u26a0 " + unsafeSkipLine(sel.unsafe));
89
+ console.log("available doctors:");
90
+ for (const row of sel.rows) {
91
+ console.log(" " + row.scope.padEnd(7) + row.slug.padEnd(32) + dim(row.description));
92
+ }
93
+ fail("non-interactive session — specify a doctor path");
94
+ return { exit: 1 };
95
+ case "cancelled":
96
+ return { exit: 0 };
97
+ }
98
+ }
99
+ function parseArgs(args) {
100
+ const out = { targetDir: path.resolve("."), all: false, global: false };
101
+ let targetDirSet = false;
102
+ for (let i = 0; i < args.length; i++) {
103
+ const a = args[i];
104
+ if (a === "--all")
105
+ out.all = true;
106
+ else if (a === "--global")
107
+ out.global = true;
108
+ else if (out.doctorPath === undefined && DOCTOR_FILE_RE.test(a))
109
+ out.doctorPath = a;
110
+ else if (!targetDirSet) {
111
+ out.targetDir = path.resolve(a);
112
+ targetDirSet = true;
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+ // Discovery and the gate's partition, computed once per command — pure
118
+ // compute; rendering (broken warnings, skip notes) belongs to the callers,
119
+ // matching select.ts's compute/render split.
120
+ async function gatherDoctors() {
121
+ const all = await discoverDoctors(process.cwd());
122
+ return {
123
+ valid: all.filter(d => d.meta !== null),
124
+ skippedUnsafe: unsafeSlugs(all),
125
+ broken: brokenDoctors(all),
126
+ };
127
+ }
128
+ async function scanOnce(doctorAbs, targetDir) {
129
+ const result = await runOrReport(runDoctor({ programPath: doctorAbs, targetDir }));
130
+ return {
131
+ group: { programName: path.basename(doctorAbs), meta: result.meta, findings: result.findings },
132
+ fileCount: result.fileCount,
133
+ };
134
+ }
135
+ // The batch commands' empty-cohort policy: no doctors at all is a setup
136
+ // error; only-skipped doctors are named and fail quietly. True means the
137
+ // caller returns 1.
138
+ function cohortUnusable(cohort) {
139
+ if (cohort.valid.length > 0)
140
+ return false;
141
+ if (cohort.skippedUnsafe.length === 0) {
142
+ fail("no doctors discovered — run from a directory with doctors/, or specify a doctor path");
143
+ }
144
+ else {
145
+ warn("\u26a0 " + unsafeSkipLine(cohort.skippedUnsafe));
146
+ }
147
+ return true;
148
+ }
149
+ async function cmdRun(args) {
150
+ var _a;
151
+ const parsed = parseArgs(args);
152
+ if (parsed.global) {
153
+ fail("--global is a generate-only flag");
154
+ return 1;
155
+ }
156
+ // One RunOutcome for both modes — a doctor path targets one doctor;
157
+ // --all and the no-argument default run every discovered doctor (a crash
158
+ // is data — named, and it fails the command).
159
+ let outcome;
160
+ if (parsed.doctorPath) {
161
+ const sel = await selectDoctor(parsed.doctorPath, {
162
+ cwd: process.cwd(),
163
+ useColor: useColor(),
164
+ env: processTtyEnv(),
165
+ });
166
+ const selection = selectionOutcome(sel);
167
+ if ("exit" in selection)
168
+ return selection.exit;
169
+ const runStarted = Date.now();
170
+ const scan = await scanOnce(selection.doctorPath, parsed.targetDir);
171
+ outcome = {
172
+ groups: [scan.group],
173
+ crashed: [],
174
+ skippedUnsafe: [],
175
+ doctorPaths: new Map([[scan.group.meta.id, selection.doctorPath]]),
176
+ fileCount: scan.fileCount,
177
+ durationMs: Date.now() - runStarted,
178
+ targetDir: parsed.targetDir,
179
+ };
180
+ }
181
+ else {
182
+ const cohort = await gatherDoctors();
183
+ warnBrokenDoctors(cohort.broken);
184
+ if (cohortUnusable(cohort))
185
+ return 1;
186
+ const discovered = cohort.valid;
187
+ const skippedUnsafe = cohort.skippedUnsafe;
188
+ const runStarted = Date.now();
189
+ const groups = [];
190
+ const crashed = [];
191
+ const doctorPaths = new Map();
192
+ let fileCount = 0;
193
+ for (const d of discovered) {
194
+ try {
195
+ const scan = await scanOnce(d.path, parsed.targetDir);
196
+ fileCount = Math.max(fileCount, scan.fileCount);
197
+ doctorPaths.set(d.meta.id, d.path);
198
+ groups.push(scan.group);
199
+ }
200
+ catch (e) {
201
+ if (!(e instanceof ExitCode))
202
+ throw e;
203
+ crashed.push(d.meta.id);
204
+ }
205
+ }
206
+ outcome = {
207
+ groups,
208
+ crashed,
209
+ skippedUnsafe,
210
+ doctorPaths,
211
+ fileCount,
212
+ durationMs: Date.now() - runStarted,
213
+ targetDir: parsed.targetDir,
214
+ };
215
+ }
216
+ const env = processTtyEnv();
217
+ const ttyCols = (_a = process.stdout.columns) !== null && _a !== void 0 ? _a : 0;
218
+ // Report-vs-dashboard policy: --all is the batch/report mode; otherwise
219
+ // a real terminal with room and no headless override gets the tree.
220
+ const interactive = !parsed.all
221
+ && canRunTui(env) && !process.env.ANY_DOCTOR_HEADLESS && (ttyCols === 0 || ttyCols >= 60);
222
+ if (!interactive) {
223
+ console.log(renderReport(outcome, useColor()));
224
+ if (outcome.crashed.length > 0) {
225
+ for (const id of outcome.crashed)
226
+ fail("doctor crashed (results above are partial): " + id);
227
+ return 1;
228
+ }
229
+ return outcome.skippedUnsafe.length > 0 ? 1 : 0;
230
+ }
231
+ const invoker = process.argv[1] ? `node "${fs.realpathSync(process.argv[1])}"` : "any-doctor";
232
+ // Interactive runs always show what did run: skips and crashes cost the
233
+ // exit code, never the results. Crashes are named before the dashboard
234
+ // paints — the dashboard itself renders findings and skips, not crashes.
235
+ for (const id of outcome.crashed)
236
+ fail("doctor crashed (results above are partial): " + id);
237
+ await runDashboard({ outcome, invoker, useColor: useColor() });
238
+ return outcome.crashed.length > 0 || outcome.skippedUnsafe.length > 0 ? 1 : 0;
239
+ }
240
+ async function cmdVerify(args) {
241
+ const parsed = parseArgs(args);
242
+ if (parsed.global) {
243
+ fail("--global is a generate-only flag");
244
+ return 1;
245
+ }
246
+ if (parsed.all) {
247
+ const cohort = await gatherDoctors();
248
+ warnBrokenDoctors(cohort.broken);
249
+ if (cohort.skippedUnsafe.length > 0)
250
+ warn("\u26a0 " + unsafeSkipLine(cohort.skippedUnsafe));
251
+ if (cohortUnusable(cohort))
252
+ return 1;
253
+ const discovered = cohort.valid;
254
+ const skippedUnsafe = cohort.skippedUnsafe;
255
+ let totalFailures = 0;
256
+ const crashed = [];
257
+ for (const d of discovered) {
258
+ console.log(BOLD + d.meta.id + RESET);
259
+ console.log(DIM + " capabilities: " + capabilitySummary(scanDoctorFile(d.path)) + RESET);
260
+ try {
261
+ const r = await runOrReport(verifyDoctor({ programPath: d.path }));
262
+ console.log(renderVerifyResult(r, useColor()));
263
+ totalFailures += r.results.filter(x => !x.ok).length;
264
+ }
265
+ catch (e) {
266
+ if (!(e instanceof ExitCode))
267
+ throw e;
268
+ console.log(RED + " crashed — skipped" + RESET);
269
+ crashed.push(d.meta.id);
270
+ }
271
+ console.log("");
272
+ }
273
+ if (totalFailures > 0 || crashed.length > 0 || skippedUnsafe.length > 0) {
274
+ const parts = [];
275
+ if (totalFailures > 0)
276
+ parts.push(totalFailures + " fixture(s) failed");
277
+ if (crashed.length > 0)
278
+ parts.push(crashed.length + " doctor(s) crashed: " + crashed.join(", "));
279
+ if (skippedUnsafe.length > 0)
280
+ parts.push(unsafeSkipLine(skippedUnsafe));
281
+ fail(parts.join("; "));
282
+ return 1;
283
+ }
284
+ ok("all doctors fixture-green");
285
+ return 0;
286
+ }
287
+ const sel = await selectDoctor(parsed.doctorPath, {
288
+ cwd: process.cwd(),
289
+ useColor: useColor(),
290
+ allowPicker: !process.env.ANY_DOCTOR_HEADLESS,
291
+ env: processTtyEnv(),
292
+ });
293
+ const outcome = selectionOutcome(sel);
294
+ if ("exit" in outcome)
295
+ return outcome.exit;
296
+ console.log(DIM + "capabilities: " + capabilitySummary(scanDoctorFile(outcome.doctorPath)) + RESET);
297
+ const result = await runOrReport(verifyDoctor({ programPath: outcome.doctorPath }));
298
+ console.log(renderVerifyResult(result, useColor()));
299
+ const failures = result.results.filter(x => !x.ok).length;
300
+ console.log("");
301
+ console.log(dim(`${result.results.length - failures}/${result.results.length} fixtures passed for ${result.meta.id}`));
302
+ return failures > 0 ? 1 : 0;
303
+ }
304
+ async function cmdGenerate(args) {
305
+ let intent;
306
+ let global = false;
307
+ for (let i = 0; i < args.length; i++) {
308
+ if (args[i] === "--global")
309
+ global = true;
310
+ else if (intent === undefined)
311
+ intent = args[i];
312
+ }
313
+ if (!intent) {
314
+ fail('usage: any-doctor generate "<one-line intent>" [--global]');
315
+ return 1;
316
+ }
317
+ const skill = skillText();
318
+ if (skill === null) {
319
+ fail("generation skill not found (skill/any-doctor.skill.md missing).");
320
+ return 1;
321
+ }
322
+ const slug = slugify(intent);
323
+ const scopeDir = global
324
+ ? (fs.mkdirSync(globalDoctorsDir(), { recursive: true }), globalDoctorsDir())
325
+ : path.resolve("doctors");
326
+ fs.mkdirSync(scopeDir, { recursive: true });
327
+ const agentsPath = path.join(scopeDir, "AGENTS.md");
328
+ if (!fs.existsSync(agentsPath)) {
329
+ fs.writeFileSync(agentsPath, skill);
330
+ }
331
+ const cliJs = fileURLToPath(new URL("cli.js", import.meta.url));
332
+ const doctorAbs = path.join(scopeDir, slug + ".mjs");
333
+ const prompt = [
334
+ skill,
335
+ "",
336
+ "## Your task",
337
+ "",
338
+ "INTENT (the entire specification):",
339
+ " " + intent,
340
+ "",
341
+ "Working directory is the doctor pack root. Write exactly two files:",
342
+ " " + slug + ".mjs",
343
+ " " + slug + ".fixtures.mjs",
344
+ "",
345
+ "Then verify with exactly this command and iterate until every fixture passes:",
346
+ ' node "' + cliJs + '" verify "' + doctorAbs + '"',
347
+ "Then stop and report.",
348
+ ].join("\n");
349
+ console.log(BOLD + "doctor prompt ready: " + CYAN + slug + RESET + dim(global ? " (global scope)" : ""));
350
+ console.log("");
351
+ if (copyToClipboard(prompt)) {
352
+ ok("prompt copied to clipboard — paste it into your own agent session");
353
+ console.log(dim("run the agent with this as its working directory: " + scopeDir));
354
+ console.log(dim("(the skill is planted there as AGENTS.md — most agents load it automatically)"));
355
+ }
356
+ else {
357
+ console.log(prompt);
358
+ warn("clipboard unavailable — copy the prompt above");
359
+ }
360
+ console.log("");
361
+ console.log(dim("once your agent has written both files, gate it:"));
362
+ console.log(dim(' node "' + cliJs + '" verify "' + doctorAbs + '"'));
363
+ return 0;
364
+ }
365
+ const STOP_WORDS = new Set(["a", "an", "the", "find", "flag", "all", "that", "which", "is", "are", "in", "on", "of", "to", "and", "or", "not"]);
366
+ function slugify(intent) {
367
+ const words = intent.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").trim().split(/\s+/);
368
+ const kept = words.filter(w => w && !STOP_WORDS.has(w)).slice(0, 5);
369
+ return (kept.length ? kept : ["custom-doctor"]).join("-").slice(0, 60);
370
+ }
371
+ function usage() {
372
+ console.log(BOLD + "any-doctor" + RESET + dim(" — your agent writes the analyzer, fixtures prove it, CI reruns it forever"));
373
+ console.log("");
374
+ console.log(' generate "<intent>" [--global] print the exact prompt for your agent to build a doctor');
375
+ console.log(" run [--all] [doctor.(m)js] [dir] scan; no argument = every doctor in one review tree");
376
+ console.log(" verify [--all] [doctor.(m)js] fixture gate (no doctor: fuzzy picker; --all: every doctor)");
377
+ console.log("");
378
+ console.log(dim("doctors live in ./doctors/ (repo) and ~/.any-doctor/doctors/ (global)."));
379
+ console.log(dim("generation delegates to your installed agent — run and verify never touch a model."));
380
+ }
381
+ export async function main(argv = process.argv.slice(2)) {
382
+ const major = Number(process.versions.node.split(".")[0]);
383
+ if (major < 18) {
384
+ fail("any-doctor requires Node >= 18 — you are running " + process.versions.node);
385
+ return 1;
386
+ }
387
+ const cmd = argv[0];
388
+ const rest = argv.slice(1);
389
+ if (!cmd || cmd === "help" || cmd === "--help") {
390
+ usage();
391
+ return 0;
392
+ }
393
+ try {
394
+ if (cmd === "generate")
395
+ return await cmdGenerate(rest);
396
+ if (cmd === "run")
397
+ return await cmdRun(rest);
398
+ if (cmd === "verify")
399
+ return await cmdVerify(rest);
400
+ }
401
+ catch (e) {
402
+ if (e instanceof ExitCode)
403
+ return e.code;
404
+ throw e;
405
+ }
406
+ fail("unknown command: " + cmd);
407
+ usage();
408
+ return 1;
409
+ }
410
+ // Direct-invocation guard (realpath-aware so npm link's symlinks still run):
411
+ // importing this module never executes the CLI — commands are testable
412
+ // through main(argv).
413
+ const invokedDirectly = (() => {
414
+ try {
415
+ return import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href;
416
+ }
417
+ catch {
418
+ return false;
419
+ }
420
+ })();
421
+ if (invokedDirectly) {
422
+ main().then((code) => process.exit(code), (e) => {
423
+ console.error(RED + (e && e.stack ? e.stack : String(e)) + RESET);
424
+ process.exit(1);
425
+ });
426
+ }
@@ -0,0 +1 @@
1
+ export declare function copyToClipboard(text: string): boolean;
@@ -0,0 +1,10 @@
1
+ import { spawnSync } from "child_process";
2
+ export function copyToClipboard(text) {
3
+ const bins = [["pbcopy", []], ["wl-copy", []], ["clip", []]];
4
+ for (const [bin, args] of bins) {
5
+ const r = spawnSync(bin, args, { input: text, encoding: "utf8" });
6
+ if (r.status === 0)
7
+ return true;
8
+ }
9
+ return false;
10
+ }
@@ -0,0 +1,134 @@
1
+ export type Severity = "error" | "warning" | "info";
2
+ export interface CheckMeta {
3
+ id: string;
4
+ description: string;
5
+ severity?: Severity;
6
+ impact?: string;
7
+ why?: string;
8
+ fix?: string;
9
+ }
10
+ export interface DoctorMeta {
11
+ id: string;
12
+ description: string;
13
+ severity: Severity;
14
+ category?: string;
15
+ blindSpots?: string[];
16
+ checks?: CheckMeta[];
17
+ }
18
+ export interface ReportGroup {
19
+ programName: string;
20
+ meta: DoctorMeta;
21
+ findings: Finding[];
22
+ }
23
+ export interface Finding {
24
+ rule?: string;
25
+ file: string;
26
+ line: number;
27
+ column?: number;
28
+ message?: string;
29
+ severity?: Severity;
30
+ }
31
+ export interface Match {
32
+ file: string;
33
+ line: number;
34
+ column: number;
35
+ text: string;
36
+ }
37
+ export interface DoctorCtx {
38
+ root: string;
39
+ files: {
40
+ list(exts?: string[]): string[];
41
+ read(relativePath: string): string;
42
+ };
43
+ search: {
44
+ pattern(pattern: string, language?: "TypeScript" | "JavaScript"): Match[];
45
+ };
46
+ report: {
47
+ finding(f: Finding): void;
48
+ };
49
+ }
50
+ export interface Fixture {
51
+ name: string;
52
+ seed: Record<string, string>;
53
+ expected: {
54
+ file: string;
55
+ line: number;
56
+ }[];
57
+ }
58
+ export declare const PROTOCOL_VERSION = 1;
59
+ export declare const RESULT_SENTINEL = "###ANY_DOCTOR_V1###";
60
+ export declare const SEARCH_REQUEST = "###ANY_DOCTOR_SEARCH###";
61
+ export declare const SEARCH_RESULT = "###ANY_DOCTOR_SEARCH_RESULT###";
62
+ export type Mode = {
63
+ kind: "run";
64
+ root: string;
65
+ } | {
66
+ kind: "verify";
67
+ fixtures: string;
68
+ } | {
69
+ kind: "meta";
70
+ };
71
+ export declare function modeArgs(mode: Mode, programPath: string): string[];
72
+ export declare function decodeLoaderArgs(argv: string[]): {
73
+ program: string;
74
+ mode: Mode;
75
+ } | null;
76
+ export interface RunResult {
77
+ protocolVersion: number;
78
+ kind: "run";
79
+ root: string;
80
+ fileCount: number;
81
+ durationMs: number;
82
+ meta: DoctorMeta;
83
+ findings: Finding[];
84
+ }
85
+ export interface FixtureDiff {
86
+ missing: {
87
+ file: string;
88
+ line: number;
89
+ }[];
90
+ unexpected: {
91
+ file: string;
92
+ line: number;
93
+ }[];
94
+ }
95
+ export interface FixtureResult extends FixtureDiff {
96
+ name: string;
97
+ ok: boolean;
98
+ error?: string;
99
+ }
100
+ export interface VerifyRunResult {
101
+ protocolVersion: number;
102
+ kind: "verify";
103
+ meta: DoctorMeta;
104
+ results: FixtureResult[];
105
+ }
106
+ export interface MetaResult {
107
+ protocolVersion: number;
108
+ kind: "meta";
109
+ meta: DoctorMeta;
110
+ }
111
+ export type Frame = RunResult | VerifyRunResult | MetaResult;
112
+ export declare const DOCTOR_FILE_RE: RegExp;
113
+ export declare const FIXTURES_FILE_RE: RegExp;
114
+ export declare function fixturesPathFor(programPath: string): string;
115
+ export declare function runCommandFor(doctorPath: string, root: string, invoker?: string): string;
116
+ export declare function compareFindings(expected: {
117
+ file: string;
118
+ line: number;
119
+ }[], actual: Finding[]): FixtureDiff;
120
+ export interface JoinedFinding {
121
+ doctorId: string;
122
+ checkId: string;
123
+ checkKey: string;
124
+ description: string;
125
+ severity: Severity;
126
+ declaredSeverity: Severity;
127
+ category: string;
128
+ impact?: string;
129
+ why?: string;
130
+ fix?: string;
131
+ blindSpots?: string[];
132
+ finding: Finding;
133
+ }
134
+ export declare function resolveFinding(meta: DoctorMeta, finding: Finding): JoinedFinding;
@@ -0,0 +1,70 @@
1
+ export const PROTOCOL_VERSION = 1;
2
+ export const RESULT_SENTINEL = "###ANY_DOCTOR_V1###";
3
+ // ctx.search host protocol: doctor children cannot spawn (permission
4
+ // model), so they ask the host to run ast-grep — request out fd 3, result
5
+ // back on stdin.
6
+ export const SEARCH_REQUEST = "###ANY_DOCTOR_SEARCH###";
7
+ export const SEARCH_RESULT = "###ANY_DOCTOR_SEARCH_RESULT###";
8
+ export function modeArgs(mode, programPath) {
9
+ switch (mode.kind) {
10
+ case "run": return [programPath, mode.root];
11
+ case "verify": return [programPath, "--verify", mode.fixtures];
12
+ case "meta": return [programPath, "--meta"];
13
+ }
14
+ }
15
+ export function decodeLoaderArgs(argv) {
16
+ const [program, second, third] = argv;
17
+ if (!program || program.startsWith("-"))
18
+ return null;
19
+ if (second === "--verify" && third !== undefined)
20
+ return { program, mode: { kind: "verify", fixtures: third } };
21
+ if (second === "--meta" && third === undefined)
22
+ return { program, mode: { kind: "meta" } };
23
+ if (second !== undefined && !second.startsWith("-") && third === undefined) {
24
+ return { program, mode: { kind: "run", root: second } };
25
+ }
26
+ return null;
27
+ }
28
+ // Filename conventions of the doctor contract — the one home for what is a
29
+ // doctor file, what is a fixture file, and where a doctor's fixtures live.
30
+ export const DOCTOR_FILE_RE = /\.(m|c)?js$/;
31
+ export const FIXTURES_FILE_RE = /\.fixtures\.(m|c)?js$/;
32
+ // A check id is a short kebab-case noun phrase naming the defect, unique within its doctor,
33
+ // over the charset [a-z0-9-] (never "/" — checkKey joins ids with it).
34
+ // Prefer naming the defect ("uncleared-settimeout-in-effect") over the
35
+ // pattern it searches for.
36
+ export function fixturesPathFor(programPath) {
37
+ return programPath.replace(DOCTOR_FILE_RE, "") + ".fixtures.mjs";
38
+ }
39
+ // The re-run command embedded in copied prompts. invoker defaults to
40
+ // the installed binary name; callers running via node or npx pass their own.
41
+ export function runCommandFor(doctorPath, root, invoker = "any-doctor") {
42
+ return `${invoker} run "${doctorPath}" "${root}"`;
43
+ }
44
+ export function compareFindings(expected, actual) {
45
+ const key = (f) => `${f.file}:${f.line}`;
46
+ const expectedKeys = new Set(expected.map(key));
47
+ const actualKeys = new Set(actual.map(key));
48
+ const missing = expected.filter(f => !actualKeys.has(key(f))).map(f => ({ file: f.file, line: f.line }));
49
+ const unexpected = actual.filter(f => !expectedKeys.has(key(f))).map(f => ({ file: f.file, line: f.line }));
50
+ return { missing, unexpected };
51
+ }
52
+ export function resolveFinding(meta, finding) {
53
+ var _a, _b, _c, _d, _e, _f, _g;
54
+ const checkId = (_a = finding.rule) !== null && _a !== void 0 ? _a : meta.id;
55
+ const check = (_b = meta.checks) === null || _b === void 0 ? void 0 : _b.find(c => c.id === checkId);
56
+ return {
57
+ doctorId: meta.id,
58
+ checkId,
59
+ checkKey: meta.id + "/" + checkId,
60
+ description: (_c = check === null || check === void 0 ? void 0 : check.description) !== null && _c !== void 0 ? _c : meta.description,
61
+ severity: (_e = (_d = finding.severity) !== null && _d !== void 0 ? _d : check === null || check === void 0 ? void 0 : check.severity) !== null && _e !== void 0 ? _e : meta.severity,
62
+ declaredSeverity: (_f = check === null || check === void 0 ? void 0 : check.severity) !== null && _f !== void 0 ? _f : meta.severity,
63
+ category: (_g = meta.category) !== null && _g !== void 0 ? _g : "general",
64
+ impact: check === null || check === void 0 ? void 0 : check.impact,
65
+ why: check === null || check === void 0 ? void 0 : check.why,
66
+ fix: check === null || check === void 0 ? void 0 : check.fix,
67
+ blindSpots: meta.blindSpots,
68
+ finding,
69
+ };
70
+ }