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,24 @@
1
+ import { DoctorMeta } from "./contract.js";
2
+ import { RunnerError } from "./runner.js";
3
+ export type Scope = "repo" | "global";
4
+ export interface DiscoveredDoctor {
5
+ slug: string;
6
+ scope: Scope;
7
+ path: string;
8
+ meta: DoctorMeta | null;
9
+ cause?: RunnerError;
10
+ }
11
+ export interface BrokenDoctor {
12
+ slug: string;
13
+ cause?: RunnerError;
14
+ }
15
+ export declare function unsafeSlugs(discovered: DiscoveredDoctor[]): string[];
16
+ export declare function brokenDoctors(discovered: DiscoveredDoctor[]): BrokenDoctor[];
17
+ export declare function globalDoctorsDir(): string;
18
+ export declare function findRepoDoctorsDir(cwd: string): string | null;
19
+ export declare function discoverDoctors(cwd: string, opts?: {
20
+ globalDir?: string;
21
+ }): Promise<DiscoveredDoctor[]>;
22
+ export declare function resolveDoctorPath(arg: string, cwd: string, opts?: {
23
+ globalDir?: string;
24
+ }): string | null;
@@ -0,0 +1,87 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { DOCTOR_FILE_RE, FIXTURES_FILE_RE } from "./contract.js";
5
+ import { metaDoctor } from "./runner.js";
6
+ // The gate's partition over discovery — owned by the data so every surface
7
+ // (run cohorts, verify, the picker) splits identically. Only null-meta
8
+ // doctors belong to either bucket; a healthy doctor is neither skipped nor
9
+ // named.
10
+ export function unsafeSlugs(discovered) {
11
+ return discovered.filter(d => { var _a; return d.meta === null && ((_a = d.cause) === null || _a === void 0 ? void 0 : _a._tag) === "DoctorUnsafe"; }).map(d => d.slug);
12
+ }
13
+ export function brokenDoctors(discovered) {
14
+ return discovered
15
+ .filter(d => { var _a; return d.meta === null && ((_a = d.cause) === null || _a === void 0 ? void 0 : _a._tag) !== "DoctorUnsafe"; })
16
+ .map(d => ({ slug: d.slug, cause: d.cause }));
17
+ }
18
+ export function globalDoctorsDir() {
19
+ return path.join(os.homedir(), ".any-doctor", "doctors");
20
+ }
21
+ export function findRepoDoctorsDir(cwd) {
22
+ let dir = path.resolve(cwd);
23
+ for (;;) {
24
+ const candidate = path.join(dir, "doctors");
25
+ if (fs.existsSync(candidate))
26
+ return candidate;
27
+ const parent = path.dirname(dir);
28
+ if (parent === dir)
29
+ return null;
30
+ dir = parent;
31
+ }
32
+ }
33
+ export async function discoverDoctors(cwd, opts) {
34
+ var _a;
35
+ const repoDir = findRepoDoctorsDir(cwd);
36
+ const scopes = [
37
+ ...(repoDir ? [{ scope: "repo", dir: repoDir }] : []),
38
+ { scope: "global", dir: (_a = opts === null || opts === void 0 ? void 0 : opts.globalDir) !== null && _a !== void 0 ? _a : globalDoctorsDir() },
39
+ ];
40
+ const bySlug = new Map();
41
+ for (const { scope, dir } of scopes) {
42
+ if (!fs.existsSync(dir))
43
+ continue;
44
+ const files = fs.readdirSync(dir)
45
+ .filter(f => (f.endsWith(".mjs") || f.endsWith(".js")) && !FIXTURES_FILE_RE.test(f))
46
+ .sort();
47
+ // Discovery fans out: every command pays this latency, metaDoctor never
48
+ // throws, and doctor counts grow once bundled doctors ship.
49
+ const reads = await Promise.all(files.map(async (f) => ({
50
+ slug: f.replace(DOCTOR_FILE_RE, ""),
51
+ abs: path.join(dir, f),
52
+ read: await metaDoctor({ programPath: path.join(dir, f) }),
53
+ })));
54
+ for (const { slug, abs, read } of reads) {
55
+ if (bySlug.has(slug))
56
+ continue;
57
+ bySlug.set(slug, { slug, scope, path: abs, meta: read.meta, cause: read.cause });
58
+ }
59
+ }
60
+ return [...bySlug.values()];
61
+ }
62
+ // Explicit paths (absolute, or containing separators) resolve directly.
63
+ // Bare filenames are slugs: scopes win - repo-local first - so a stray
64
+ // slug.mjs in the working directory cannot shadow an installed doctor.
65
+ export function resolveDoctorPath(arg, cwd, opts) {
66
+ var _a;
67
+ const bare = path.basename(arg) === arg && !path.isAbsolute(arg);
68
+ if (!bare) {
69
+ const direct = path.resolve(cwd, arg);
70
+ if (fs.existsSync(direct))
71
+ return direct;
72
+ }
73
+ if (bare) {
74
+ const base = path.basename(arg);
75
+ const repoDir = findRepoDoctorsDir(cwd);
76
+ const scopes = [repoDir, (_a = opts === null || opts === void 0 ? void 0 : opts.globalDir) !== null && _a !== void 0 ? _a : globalDoctorsDir()].filter((d) => Boolean(d));
77
+ for (const dir of scopes) {
78
+ const candidate = path.join(dir, base);
79
+ if (fs.existsSync(candidate))
80
+ return candidate;
81
+ }
82
+ const direct = path.resolve(cwd, arg);
83
+ if (fs.existsSync(direct))
84
+ return direct;
85
+ }
86
+ return null;
87
+ }
@@ -0,0 +1 @@
1
+ export declare function confineProcess(): void;
@@ -0,0 +1,161 @@
1
+ import { pathToFileURL } from "url";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { register } from "node:module";
6
+ import { buildCtx } from "./sdk.js";
7
+ import * as contract from "./contract.js";
8
+ // The doctor loader: the child process every doctor program runs inside.
9
+ // It owns the Doctor-run choreography — decode the Mode, confine the
10
+ // process, import the doctor, frame the result — and nothing else. The
11
+ // runner owns the protocol on the parent side; this file is the other end.
12
+ // The confinement prologue: a doctor's process has no network (the globals
13
+ // are deleted before any doctor code can run) and may not import anything
14
+ // (a resolve hook refuses every resolution that is not the loader's own).
15
+ // Ordering is load-bearing: this must run AFTER this module's own imports
16
+ // resolve (sdk, contract — which is why they are static imports above) and
17
+ // BEFORE the doctor import below. Exported so the choreography has a direct
18
+ // test seam instead of being provable only end-to-end.
19
+ export function confineProcess() {
20
+ const g = globalThis;
21
+ delete g.fetch;
22
+ try {
23
+ delete g.WebSocket;
24
+ }
25
+ catch { /* already absent */ }
26
+ try {
27
+ register(new URL("./import-guard.mjs", import.meta.url));
28
+ }
29
+ catch {
30
+ // No loader hooks on this runtime — the other confinement layers still apply.
31
+ }
32
+ }
33
+ const USAGE = "usage: doctor-loader.mjs <program.(m)js> <root> | <program.(m)js> --verify <fixtures.(m)js> | <program.(m)js> --meta";
34
+ const SEVERITIES = new Set(["error", "warning", "info"]);
35
+ function validateMeta(mod) {
36
+ const m = mod.meta;
37
+ const problems = [];
38
+ if (!m || typeof m !== "object")
39
+ problems.push("missing meta export");
40
+ if (m && typeof m.id !== "string")
41
+ problems.push("meta.id must be a string");
42
+ if (m && typeof m.description !== "string")
43
+ problems.push("meta.description must be a string");
44
+ if (m && !SEVERITIES.has(String(m.severity)))
45
+ problems.push(`meta.severity must be one of error|warning|info — got ${JSON.stringify(m.severity)}`);
46
+ if (problems.length) {
47
+ console.error("invalid doctor meta: " + problems.join("; "));
48
+ process.exit(3);
49
+ }
50
+ }
51
+ function runOnce(root, mod) {
52
+ const started = Date.now();
53
+ const { ctx, getFindings } = buildCtx(root);
54
+ const fileCount = ctx.files.list().length;
55
+ const result = mod.doctor(ctx);
56
+ if (!result || typeof result.then !== "function") {
57
+ throw new Error("doctor() must be async — declare it `export async function doctor(ctx)`");
58
+ }
59
+ return result.then(() => ({
60
+ protocolVersion: contract.PROTOCOL_VERSION,
61
+ kind: "run",
62
+ root,
63
+ fileCount,
64
+ durationMs: Date.now() - started,
65
+ meta: mod.meta,
66
+ findings: getFindings(),
67
+ }));
68
+ }
69
+ function materializeSeed(tmp, rel, content) {
70
+ const abs = path.resolve(tmp, rel);
71
+ if (abs !== tmp && !abs.startsWith(tmp + path.sep)) {
72
+ throw new Error(`fixture seed path escapes the sandbox: ${rel}`);
73
+ }
74
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
75
+ fs.writeFileSync(abs, content);
76
+ }
77
+ async function main() {
78
+ confineProcess();
79
+ // The mode arrives as argv and is decoded exactly once, here, into a value.
80
+ const decoded = contract.decodeLoaderArgs(process.argv.slice(2));
81
+ if (decoded === null) {
82
+ console.error(USAGE);
83
+ process.exit(2);
84
+ }
85
+ const prog = decoded.program;
86
+ const mode = decoded.mode;
87
+ const mod = (await import(pathToFileURL(prog).href));
88
+ if (typeof mod.doctor !== "function") {
89
+ console.error("doctor program must export `doctor(ctx)` — got: " + Object.keys(mod).join(", "));
90
+ process.exit(3);
91
+ }
92
+ validateMeta(mod);
93
+ const originalLog = console.log;
94
+ console.log = (...args) => { process.stderr.write(args.map(a => String(a)).join(" ") + "\n"); };
95
+ try {
96
+ if (mode.kind === "meta") {
97
+ process.stdout.write("\n" + contract.RESULT_SENTINEL + JSON.stringify({
98
+ protocolVersion: contract.PROTOCOL_VERSION,
99
+ kind: "meta",
100
+ meta: mod.meta,
101
+ }) + "\n");
102
+ }
103
+ else if (mode.kind === "run") {
104
+ const result = await runOnce(mode.root, mod);
105
+ process.stdout.write("\n" + contract.RESULT_SENTINEL + JSON.stringify(result) + "\n");
106
+ }
107
+ else {
108
+ const fixturesMod = (await import(pathToFileURL(mode.fixtures).href));
109
+ const fixtures = fixturesMod.fixtures;
110
+ if (!Array.isArray(fixtures)) {
111
+ console.error("fixture module must export `fixtures` (array) — got: " + Object.keys(fixturesMod).join(", "));
112
+ process.exit(3);
113
+ }
114
+ const results = [];
115
+ for (const fixture of fixtures) {
116
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "any-doctor-verify-"));
117
+ try {
118
+ for (const [rel, content] of Object.entries(fixture.seed)) {
119
+ materializeSeed(tmp, rel, content);
120
+ }
121
+ const result = await runOnce(tmp, mod);
122
+ const diff = contract.compareFindings(fixture.expected, result.findings);
123
+ results.push({ name: fixture.name, ok: diff.missing.length === 0 && diff.unexpected.length === 0, ...diff });
124
+ }
125
+ catch (e) {
126
+ results.push({ name: fixture.name, ok: false, missing: [], unexpected: [], error: e instanceof Error ? e.message : String(e) });
127
+ }
128
+ finally {
129
+ fs.rmSync(tmp, { recursive: true, force: true });
130
+ }
131
+ }
132
+ process.stdout.write("\n" + contract.RESULT_SENTINEL + JSON.stringify({
133
+ protocolVersion: contract.PROTOCOL_VERSION,
134
+ kind: "verify",
135
+ meta: mod.meta,
136
+ results,
137
+ }) + "\n");
138
+ }
139
+ }
140
+ finally {
141
+ console.log = originalLog;
142
+ }
143
+ }
144
+ // Direct-invocation guard: importing this module (tests, future tooling)
145
+ // never executes a Doctor run — the script body runs only when spawned.
146
+ const invokedDirectly = (() => {
147
+ try {
148
+ return import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href;
149
+ }
150
+ catch {
151
+ return false;
152
+ }
153
+ })();
154
+ try {
155
+ if (invokedDirectly)
156
+ await main();
157
+ }
158
+ catch (e) {
159
+ console.error(e instanceof Error && e.stack ? e.stack : String(e));
160
+ process.exit(1);
161
+ }
@@ -0,0 +1,18 @@
1
+ export interface RawSgMatch {
2
+ file?: string;
3
+ text?: string;
4
+ range?: {
5
+ start?: {
6
+ line?: number;
7
+ column?: number;
8
+ };
9
+ };
10
+ }
11
+ export type EngineResult = {
12
+ ok: true;
13
+ matches: RawSgMatch[];
14
+ } | {
15
+ ok: false;
16
+ error: string;
17
+ };
18
+ export declare function runEngineSearch(pattern: string, language: string, root: string): EngineResult;
package/bin/engine.js ADDED
@@ -0,0 +1,22 @@
1
+ import { spawnSync } from "child_process";
2
+ export function runEngineSearch(pattern, language, root) {
3
+ const r = spawnSync("sg", ["run", "-p", pattern, "-l", language, "--json", root], {
4
+ encoding: "utf8",
5
+ timeout: 120000,
6
+ });
7
+ if (r.error && r.error.code === "ENOENT") {
8
+ return { ok: false, error: "ctx.search requires ast-grep (sg) on PATH — install: brew install ast-grep" };
9
+ }
10
+ if (r.status !== 0 && !r.stdout.trim()) {
11
+ return { ok: false, error: "ctx.search failed: " + (r.stderr || "sg exited " + r.status) };
12
+ }
13
+ try {
14
+ return { ok: true, matches: JSON.parse(r.stdout) };
15
+ }
16
+ catch {
17
+ return {
18
+ ok: false,
19
+ error: `ctx.search produced unparseable output from sg — refusing to report a false green. First bytes: ${JSON.stringify(r.stdout.slice(0, 120))}`,
20
+ };
21
+ }
22
+ }
package/bin/fuzzy.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare function fuzzyScore(query: string, text: string): number;
2
+ export declare function fuzzyFilter<T>(items: T[], textOf: (item: T) => string, query: string): T[];
package/bin/fuzzy.js ADDED
@@ -0,0 +1,31 @@
1
+ export function fuzzyScore(query, text) {
2
+ var _a;
3
+ const q = query.toLowerCase();
4
+ const t = text.toLowerCase();
5
+ if (!q)
6
+ return 1;
7
+ let qi = 0;
8
+ let score = 0;
9
+ let streak = 0;
10
+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
11
+ if (t[ti] === q[qi]) {
12
+ streak++;
13
+ const boundary = ti === 0 || /[\W_]/.test((_a = t[ti - 1]) !== null && _a !== void 0 ? _a : "");
14
+ score += 2 + streak + (boundary ? 4 : 0);
15
+ qi++;
16
+ }
17
+ else {
18
+ streak = 0;
19
+ }
20
+ }
21
+ return qi === q.length ? score : 0;
22
+ }
23
+ export function fuzzyFilter(items, textOf, query) {
24
+ if (!query.trim())
25
+ return items;
26
+ return items
27
+ .map(item => ({ item, score: fuzzyScore(query.trim(), textOf(item)) }))
28
+ .filter(e => e.score > 0)
29
+ .sort((a, b) => b.score - a.score)
30
+ .map(e => e.item);
31
+ }
@@ -0,0 +1,31 @@
1
+ // The import guard: a doctor is a single self-contained file. Every module
2
+ // it tries to reach — node builtins, helper files beside it, npm packages
3
+ // in the target's node_modules — routes through this resolve hook and is
4
+ // refused. Static, dynamic, and computed imports all land here, because
5
+ // this is the mechanism Node itself uses to resolve them.
6
+ //
7
+ // This file is deliberately hand-maintained in bin/ (the one exception to
8
+ // the tsc build): a resolve hook must be standalone — zero imports — or it
9
+ // could not load to guard anything. Everything else in the trusted base
10
+ // compiles from src/.
11
+ //
12
+ // The allow rule is role-based, not location-based: only the loader module
13
+ // itself may resolve things here (it imports exactly two modules after
14
+ // registration — the doctor program and its fixtures), plus the guard's
15
+ // own initial load. Everything else — a doctor's imports, anonymous imports
16
+ // from Function-constructor scripts — is refused with a contract error the
17
+ // author sees at verify time.
18
+
19
+ const LOADER_URL = new URL("./doctor-loader.mjs", import.meta.url).href;
20
+
21
+ export function resolve(specifier, context, nextResolve) {
22
+ const message = `any-doctor: a doctor is a single self-contained file — import of "${specifier}" is refused (everything reaches you through ctx)`;
23
+ if (specifier === import.meta.url) return nextResolve(specifier, context);
24
+ if (specifier.startsWith("node:")) {
25
+ throw new Error(message);
26
+ }
27
+ if ((context.parentURL ?? "") === LOADER_URL) {
28
+ return nextResolve(specifier, context);
29
+ }
30
+ throw new Error(message);
31
+ }
package/bin/keys.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export type KeyHandler = (key: string) => void;
2
+ export declare function createKeyFeed(onKey: KeyHandler): (chunk: string | Buffer) => void;
package/bin/keys.js ADDED
@@ -0,0 +1,72 @@
1
+ const FLUSH_MS = 30;
2
+ export function createKeyFeed(onKey) {
3
+ let buf = "";
4
+ let holdTimer = null;
5
+ const clearHold = () => {
6
+ if (holdTimer) {
7
+ clearTimeout(holdTimer);
8
+ holdTimer = null;
9
+ }
10
+ };
11
+ const flushHeldEsc = () => {
12
+ clearHold();
13
+ const held = buf;
14
+ buf = "";
15
+ onKey(held === "\x1b" ? "esc" : held);
16
+ };
17
+ return (chunk) => {
18
+ clearHold();
19
+ buf += typeof chunk === "string" ? chunk : chunk.toString("utf8");
20
+ let i = 0;
21
+ for (;;) {
22
+ if (i >= buf.length) {
23
+ buf = "";
24
+ return;
25
+ }
26
+ const ch = buf[i];
27
+ if (ch !== "\x1b") {
28
+ onKey(ch);
29
+ i++;
30
+ continue;
31
+ }
32
+ if (i === buf.length - 1) {
33
+ buf = buf.slice(i);
34
+ holdTimer = setTimeout(flushHeldEsc, FLUSH_MS);
35
+ return;
36
+ }
37
+ if (buf[i + 1] === "[" || buf[i + 1] === "O") {
38
+ let j = i + 2;
39
+ while (j < buf.length && !/[A-Za-z~]/.test(buf[j]))
40
+ j++;
41
+ if (j >= buf.length) {
42
+ buf = buf.slice(i);
43
+ holdTimer = setTimeout(flushHeldEsc, FLUSH_MS);
44
+ return;
45
+ }
46
+ const seq = buf.slice(i, j + 1);
47
+ if (seq === "\x1b[A" || seq === "\x1bOA")
48
+ onKey("up");
49
+ else if (seq === "\x1b[B" || seq === "\x1bOB")
50
+ onKey("down");
51
+ else if (seq === "\x1b[C" || seq === "\x1bOC")
52
+ onKey("right");
53
+ else if (seq === "\x1b[D" || seq === "\x1bOD")
54
+ onKey("left");
55
+ else
56
+ onKey("ignore");
57
+ i = j + 1;
58
+ continue;
59
+ }
60
+ // Alt-chords (\x1b followed by a printable) arrive as one chunk:
61
+ // ignore the pair rather than decoding it as esc + keystroke, which
62
+ // would cancel the session on alt-<letter>.
63
+ if (i + 1 < buf.length && buf[i + 1] >= " " && buf[i + 1] !== "\x7f") {
64
+ onKey("ignore");
65
+ i += 2;
66
+ continue;
67
+ }
68
+ onKey("esc");
69
+ i++;
70
+ }
71
+ };
72
+ }
@@ -0,0 +1,7 @@
1
+ import { Severity } from "./contract.js";
2
+ export declare const RED = "\u001B[31m", GREEN = "\u001B[32m", YELLOW = "\u001B[33m", CYAN = "\u001B[36m", ORANGE = "\u001B[38;5;208m", DIM = "\u001B[2m", BOLD = "\u001B[1m", RESET = "\u001B[0m";
3
+ export declare const GLYPH: Record<Severity, string>;
4
+ export declare const SEVERITY_COLOR: Record<Severity, string>;
5
+ export type Colorizer = (s: string, wrap?: string) => string;
6
+ export declare function colorizer(useColor: boolean): Colorizer;
7
+ export declare function gradeColor(score: number): string;
package/bin/palette.js ADDED
@@ -0,0 +1,14 @@
1
+ import { gradeFor } from "./score.js";
2
+ // The severity palette: one home for how findings are glyphed and colored
3
+ // across the picker, report, and dashboard. Grade colors derive from
4
+ // score.ts's bands — nothing shadows them here.
5
+ export const RED = "\x1b[31m", GREEN = "\x1b[32m", YELLOW = "\x1b[33m", CYAN = "\x1b[36m", ORANGE = "\x1b[38;5;208m", DIM = "\x1b[2m", BOLD = "\x1b[1m", RESET = "\x1b[0m";
6
+ export const GLYPH = { error: "✖", warning: "⚠", info: "ℹ" };
7
+ export const SEVERITY_COLOR = { error: RED, warning: ORANGE, info: YELLOW };
8
+ export function colorizer(useColor) {
9
+ return (s, wrap) => (useColor && wrap ? wrap + s + RESET : s);
10
+ }
11
+ export function gradeColor(score) {
12
+ const grade = gradeFor(score);
13
+ return grade === "Excellent" || grade === "Good" ? GREEN : grade === "Fair" ? YELLOW : RED;
14
+ }
@@ -0,0 +1,12 @@
1
+ import { Severity } from "./contract.js";
2
+ import { TtyEnv } from "./tty.js";
3
+ export interface PickerItem {
4
+ id: string;
5
+ label: string;
6
+ sub?: string;
7
+ severity?: Severity;
8
+ }
9
+ export declare function pickerFrame(title: string, items: PickerItem[], selected: number, query: string, useColor: boolean, notice?: string): string;
10
+ export declare function filterPickerItems(items: PickerItem[], query: string): PickerItem[];
11
+ export declare function isPrintable(s: string): boolean;
12
+ export declare function pickItemOn(env: TtyEnv, items: PickerItem[], useColor: boolean, title?: string, notice?: string): Promise<PickerItem | null>;
package/bin/picker.js ADDED
@@ -0,0 +1,82 @@
1
+ import { fuzzyFilter } from "./fuzzy.js";
2
+ import { BOLD, colorizer, DIM, GLYPH, GREEN, SEVERITY_COLOR } from "./palette.js";
3
+ import { canRunTui, runTty } from "./tty.js";
4
+ export function pickerFrame(title, items, selected, query, useColor, notice) {
5
+ const c = colorizer(useColor);
6
+ const lines = [];
7
+ lines.push(c(title, BOLD) + c(" (type to filter · ↑↓ move · enter select · esc cancel)", DIM));
8
+ lines.push("");
9
+ lines.push(c("❯ " + query, BOLD) + c("▏", DIM));
10
+ lines.push("");
11
+ if (items.length === 0) {
12
+ lines.push(c(" no matching doctors", DIM));
13
+ }
14
+ else {
15
+ const cap = Math.min(items.length, 12);
16
+ for (let i = 0; i < cap; i++) {
17
+ const it = items[i];
18
+ const glyph = it.severity ? c(GLYPH[it.severity] + " ", SEVERITY_COLOR[it.severity]) : "";
19
+ const row = `${glyph}${it.label}${it.sub ? c(" " + it.sub, DIM) : ""}`;
20
+ lines.push(i === selected ? c("❯ " + row, BOLD) : " " + row);
21
+ }
22
+ if (items.length > cap)
23
+ lines.push(c(` … +${items.length - cap} more`, DIM));
24
+ }
25
+ if (notice) {
26
+ lines.push("");
27
+ lines.push(c("✔ " + notice, GREEN));
28
+ }
29
+ return lines.join("\n");
30
+ }
31
+ export function filterPickerItems(items, query) {
32
+ return fuzzyFilter(items, it => { var _a; return `${it.id} ${it.label} ${(_a = it.sub) !== null && _a !== void 0 ? _a : ""}`; }, query);
33
+ }
34
+ export function isPrintable(s) {
35
+ return s.length === 1 && s >= " " && s !== "\x7f";
36
+ }
37
+ export async function pickItemOn(env, items, useColor, title = "Select an option", notice) {
38
+ if (items.length === 0 || !canRunTui(env))
39
+ return null;
40
+ let query = "";
41
+ let selected = 0;
42
+ const filtered = () => filterPickerItems(items, query);
43
+ const frame = () => {
44
+ const list = filtered();
45
+ if (selected >= list.length)
46
+ selected = Math.max(0, list.length - 1);
47
+ return pickerFrame(title, list, selected, query, useColor, notice);
48
+ };
49
+ return runTty({
50
+ stdin: env.stdin,
51
+ stdout: env.stdout,
52
+ frame,
53
+ onKey: (key, finish) => {
54
+ if (key === "\x03" || key === "esc")
55
+ return finish(null);
56
+ if (key === "\x7f" || key === "\b") {
57
+ query = query.slice(0, -1);
58
+ selected = 0;
59
+ return;
60
+ }
61
+ if (key === "up" || key === "k") {
62
+ selected = Math.max(0, selected - 1);
63
+ return;
64
+ }
65
+ if (key === "down" || key === "j") {
66
+ selected = Math.min(filtered().length - 1, selected + 1);
67
+ return;
68
+ }
69
+ if (key === "\r" || key === "\n") {
70
+ const list = filtered();
71
+ if (list.length > 0)
72
+ finish(list[Math.min(selected, list.length - 1)]);
73
+ return;
74
+ }
75
+ if (isPrintable(key)) {
76
+ query += key;
77
+ selected = 0;
78
+ return;
79
+ }
80
+ },
81
+ });
82
+ }
@@ -0,0 +1,18 @@
1
+ import { ReportGroup, VerifyRunResult } from "./contract.js";
2
+ export interface RunOutcome {
3
+ groups: ReportGroup[];
4
+ crashed: string[];
5
+ skippedUnsafe: string[];
6
+ doctorPaths: ReadonlyMap<string, string>;
7
+ fileCount: number;
8
+ durationMs: number;
9
+ targetDir: string;
10
+ }
11
+ export declare function unsafeSkipLine(names: string[]): string;
12
+ export declare function unsafeRefusalLine(name: string, capabilities: readonly string[]): string;
13
+ export declare function dedupeGroups(groups: ReportGroup[]): {
14
+ groups: ReportGroup[];
15
+ hidden: number;
16
+ };
17
+ export declare function renderReport(input: RunOutcome, useColor: boolean): string;
18
+ export declare function renderVerifyResult(result: VerifyRunResult, useColor: boolean): string;