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/report.js ADDED
@@ -0,0 +1,159 @@
1
+ import { resolveFinding } from "./contract.js";
2
+ import { BOLD, colorizer, DIM, GLYPH, gradeColor, GREEN, RED, SEVERITY_COLOR, YELLOW } from "./palette.js";
3
+ import { categoryRollup, computeScore, findingSeverity } from "./score.js";
4
+ // The one place the skip-note copy lives; report, dashboard, and the CLI
5
+ // all render this sentence so the story is identical everywhere. The count
6
+ // is always the true total; only the name list caps — three names, then
7
+ // "… and N more" — so a hundred malicious doctors still cost one line.
8
+ const SKIP_NAMES_SHOWN = 3;
9
+ export function unsafeSkipLine(names) {
10
+ const shown = names.slice(0, SKIP_NAMES_SHOWN).join(", ");
11
+ const rest = names.length - SKIP_NAMES_SHOWN;
12
+ const list = rest > 0 ? `${shown} \u2026 and ${rest} more` : shown;
13
+ return `${names.length} doctor${names.length === 1 ? "" : "s"} could be malicious — skipped: ${list}`;
14
+ }
15
+ // The refusal for a doctor you explicitly asked to run: the file, its
16
+ // capabilities, one line. The runner's DoctorUnsafe renderer and every
17
+ // caller share this so the refusal reads identically everywhere; detail
18
+ // lines ride beneath it when there are any.
19
+ export function unsafeRefusalLine(name, capabilities) {
20
+ return `${name} could be malicious (${capabilities.join(", ")}) — not running it.`;
21
+ }
22
+ const SEVERITY_ORDER = ["error", "warning", "info"];
23
+ function groupSeverity(g) {
24
+ var _a;
25
+ const explicit = g.findings.find(f => f.severity);
26
+ return (_a = (explicit ? findingSeverity(g, explicit) : undefined)) !== null && _a !== void 0 ? _a : g.meta.severity;
27
+ }
28
+ function expandChecks(g) {
29
+ var _a;
30
+ const buckets = new Map();
31
+ for (const f of g.findings) {
32
+ const j = resolveFinding(g.meta, f);
33
+ if (!buckets.has(j.checkKey)) {
34
+ buckets.set(j.checkKey, {
35
+ ruleId: (_a = f.rule) !== null && _a !== void 0 ? _a : null,
36
+ heading: j.description,
37
+ severity: j.declaredSeverity,
38
+ findings: [],
39
+ });
40
+ }
41
+ buckets.get(j.checkKey).findings.push(f);
42
+ }
43
+ return [...buckets.values()];
44
+ }
45
+ export function dedupeGroups(groups) {
46
+ const seen = new Set();
47
+ const key = (f) => `${f.file}:${f.line}`;
48
+ const ordered = [...groups].sort((a, b) => SEVERITY_ORDER.indexOf(groupSeverity(a)) - SEVERITY_ORDER.indexOf(groupSeverity(b)));
49
+ const out = [];
50
+ let hidden = 0;
51
+ for (const g of ordered) {
52
+ if (g.findings.length === 0) {
53
+ out.push(g);
54
+ continue;
55
+ }
56
+ const kept = [];
57
+ for (const f of g.findings) {
58
+ const k = key(f);
59
+ if (seen.has(k)) {
60
+ hidden++;
61
+ continue;
62
+ }
63
+ seen.add(k);
64
+ kept.push(f);
65
+ }
66
+ if (kept.length > 0)
67
+ out.push({ ...g, findings: kept });
68
+ }
69
+ return { groups: out, hidden };
70
+ }
71
+ export function renderReport(input, useColor) {
72
+ const c = colorizer(useColor);
73
+ const lines = [];
74
+ const { groups, hidden } = dedupeGroups(input.groups);
75
+ const total = groups.reduce((n, g) => n + g.findings.length, 0);
76
+ const { score, grade } = computeScore(groups);
77
+ lines.push(`✔ Scanned ${input.fileCount} files in ${input.durationMs}ms`);
78
+ lines.push("");
79
+ const doctorWord = groups.length === 1 ? "doctor" : "doctors";
80
+ lines.push(c(`Any Doctor — ${groups.length} ${doctorWord}`, BOLD));
81
+ lines.push(c(`Score: ${score} / 100 — ${grade}`, BOLD + gradeColor(score)));
82
+ if (input.skippedUnsafe !== undefined && input.skippedUnsafe.length > 0) {
83
+ lines.push(c(`\u26a0 ${unsafeSkipLine(input.skippedUnsafe)}`, YELLOW));
84
+ }
85
+ if (total === 0) {
86
+ lines.push(c("No findings", BOLD + GREEN));
87
+ if (groups.length > 1) {
88
+ lines.push("");
89
+ for (const g of groups) {
90
+ lines.push(`${c("✔", GREEN)} ${c(g.meta.id, DIM)} — clean`);
91
+ }
92
+ }
93
+ return lines.join("\n");
94
+ }
95
+ const bySeverity = { error: 0, warning: 0, info: 0 };
96
+ for (const g of groups) {
97
+ for (const f of g.findings)
98
+ bySeverity[findingSeverity(g, f)]++;
99
+ }
100
+ const rollup = SEVERITY_ORDER
101
+ .filter(s => bySeverity[s] > 0)
102
+ .map(s => c(`${bySeverity[s]} ${s}`, SEVERITY_COLOR[s]))
103
+ .join(", ");
104
+ lines.push("");
105
+ lines.push(`${c(`${total} finding${total === 1 ? "" : "s"}`, BOLD)} ${c(`(${rollup})`, DIM)}`);
106
+ for (const { category, counts } of categoryRollup(groups)) {
107
+ const catParts = SEVERITY_ORDER.filter(s => counts[s] > 0).map(s => c(`${counts[s]} ${s}`, SEVERITY_COLOR[s]));
108
+ if (catParts.length > 0) {
109
+ const cap = category.charAt(0).toUpperCase() + category.slice(1);
110
+ lines.push(`${c(cap + ":", BOLD)} ${catParts.join(", ")}`);
111
+ }
112
+ }
113
+ lines.push("");
114
+ // dedupeGroups already orders by severity; sorting again would duplicate it.
115
+ for (const g of groups) {
116
+ for (const bucket of expandChecks(g)) {
117
+ const n = bucket.findings.length;
118
+ lines.push(`${c(GLYPH[bucket.severity], SEVERITY_COLOR[bucket.severity])} ${c(bucket.heading, n > 1 ? BOLD : "")}${n > 1 ? c(` ×${n}`, SEVERITY_COLOR[bucket.severity]) : ""}`);
119
+ lines.push(` ${c(bucket.ruleId ? `${g.meta.id}/${bucket.ruleId}` : g.meta.id, DIM)}`);
120
+ for (const f of bucket.findings.slice(0, 20)) {
121
+ lines.push(` ${f.file}:${f.line}`);
122
+ if (f.message)
123
+ lines.push(` ${c(f.message, DIM)}`);
124
+ }
125
+ if (n > 20)
126
+ lines.push(` ${c(`… and ${n - 20} more`, DIM)}`);
127
+ lines.push("");
128
+ }
129
+ if (g.findings.length > 0 && g.meta.blindSpots && g.meta.blindSpots.length > 0) {
130
+ lines.push(` ${c("blind spots: " + g.meta.blindSpots.join("; "), DIM)}`);
131
+ lines.push("");
132
+ }
133
+ }
134
+ if (hidden > 0) {
135
+ lines.push(c(`${hidden} duplicate finding${hidden === 1 ? "" : "s"} hidden (same location, different doctor)`, DIM));
136
+ }
137
+ return lines.join("\n").replace(/\n+$/, "");
138
+ }
139
+ // Verify-gate rendering: pure state -> string, colored on request. The
140
+ // command layer prints it and counts failures from the data.
141
+ export function renderVerifyResult(result, useColor) {
142
+ const c = colorizer(useColor);
143
+ const lines = [];
144
+ for (const fixture of result.results) {
145
+ if (fixture.ok) {
146
+ lines.push(c(" ✔ " + fixture.name, GREEN));
147
+ }
148
+ else {
149
+ lines.push(c(" ✖ " + fixture.name, RED));
150
+ for (const m of fixture.missing)
151
+ lines.push(" " + c("missing expected finding", RED) + " " + m.file + ":" + m.line);
152
+ for (const u of fixture.unexpected)
153
+ lines.push(" " + c("unexpected finding", RED) + " " + u.file + ":" + u.line);
154
+ if (fixture.error)
155
+ lines.push(" " + c("crashed: ", RED) + fixture.error);
156
+ }
157
+ }
158
+ return lines.join("\n");
159
+ }
@@ -0,0 +1,58 @@
1
+ import { Cause, Schema } from "effect";
2
+ import { DoctorMeta, RunResult, VerifyRunResult } from "./contract.js";
3
+ declare const ProgramMissing_base: Schema.Class<ProgramMissing, Schema.TaggedStruct<"ProgramMissing", {
4
+ readonly programPath: Schema.String;
5
+ }>, Cause.YieldableError>;
6
+ export declare class ProgramMissing extends ProgramMissing_base {
7
+ }
8
+ declare const FixturesMissing_base: Schema.Class<FixturesMissing, Schema.TaggedStruct<"FixturesMissing", {
9
+ readonly programPath: Schema.String;
10
+ readonly fixturesPath: Schema.String;
11
+ }>, Cause.YieldableError>;
12
+ export declare class FixturesMissing extends FixturesMissing_base {
13
+ }
14
+ declare const DoctorCrashed_base: Schema.Class<DoctorCrashed, Schema.TaggedStruct<"DoctorCrashed", {
15
+ readonly programPath: Schema.String;
16
+ readonly detail: Schema.String;
17
+ }>, Cause.YieldableError>;
18
+ export declare class DoctorCrashed extends DoctorCrashed_base {
19
+ }
20
+ declare const NoFramedResult_base: Schema.Class<NoFramedResult, Schema.TaggedStruct<"NoFramedResult", {
21
+ readonly programPath: Schema.String;
22
+ readonly stdout: Schema.String;
23
+ }>, Cause.YieldableError>;
24
+ export declare class NoFramedResult extends NoFramedResult_base {
25
+ }
26
+ declare const DoctorUnsafe_base: Schema.Class<DoctorUnsafe, Schema.TaggedStruct<"DoctorUnsafe", {
27
+ readonly programPath: Schema.String;
28
+ readonly capabilities: Schema.$Array<Schema.String>;
29
+ readonly findings: Schema.$Array<Schema.String>;
30
+ }>, Cause.YieldableError>;
31
+ export declare class DoctorUnsafe extends DoctorUnsafe_base {
32
+ }
33
+ export type RunnerError = ProgramMissing | FixturesMissing | DoctorCrashed | NoFramedResult | DoctorUnsafe;
34
+ export declare function isRunnerError(e: unknown): e is RunnerError;
35
+ export declare function describeRunnerError(e: RunnerError): string;
36
+ export declare function causeSummaryLine(e: RunnerError | undefined): string;
37
+ export interface RunOptions {
38
+ programPath: string;
39
+ targetDir: string;
40
+ }
41
+ export interface VerifyOptions {
42
+ programPath: string;
43
+ fixturesPath?: string;
44
+ }
45
+ export interface MetaRead {
46
+ meta: DoctorMeta | null;
47
+ cause?: RunnerError;
48
+ }
49
+ export declare function supportsPermissionModel(): Promise<boolean>;
50
+ export declare function permissionArgs(allowTmpWrites: boolean): Promise<string[]>;
51
+ export declare function deniedByPermissionModel(stderr: string): boolean;
52
+ export declare function denialCapability(stderr: string): string;
53
+ export declare function runDoctor(options: RunOptions): Promise<RunResult>;
54
+ export declare function verifyDoctor(options: VerifyOptions): Promise<VerifyRunResult>;
55
+ export declare function metaDoctor({ programPath }: {
56
+ programPath: string;
57
+ }): Promise<MetaRead>;
58
+ export {};
package/bin/runner.js ADDED
@@ -0,0 +1,271 @@
1
+ import { spawn } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { Cause, Effect, Exit, Schema } from "effect";
7
+ import { fixturesPathFor, modeArgs, RESULT_SENTINEL, SEARCH_RESULT } from "./contract.js";
8
+ import { scanDoctorFile } from "./capabilities.js";
9
+ import { unsafeRefusalLine } from "./report.js";
10
+ import { handleSearchLine } from "./search-host.js";
11
+ // The Runner: the single owner of the doctor-loader protocol. Everything
12
+ // that executes a doctor program — run, verify, meta, count — crosses this
13
+ // interface: the argv shapes (built from a Mode value), the sentinel
14
+ // framing, the timeout policy, and the typed failures. The parent side of
15
+ // Confinement lives here too (permission flags, the runtime-denial
16
+ // mapping); the Engine and the search host sit behind their own modules.
17
+ export class ProgramMissing extends Schema.TaggedError()("ProgramMissing", {
18
+ programPath: Schema.String,
19
+ }) {
20
+ }
21
+ export class FixturesMissing extends Schema.TaggedError()("FixturesMissing", {
22
+ programPath: Schema.String,
23
+ fixturesPath: Schema.String,
24
+ }) {
25
+ }
26
+ export class DoctorCrashed extends Schema.TaggedError()("DoctorCrashed", {
27
+ programPath: Schema.String,
28
+ detail: Schema.String,
29
+ }) {
30
+ }
31
+ export class NoFramedResult extends Schema.TaggedError()("NoFramedResult", {
32
+ programPath: Schema.String,
33
+ stdout: Schema.String,
34
+ }) {
35
+ }
36
+ // The doctor references capabilities no doctor legitimately has (network,
37
+ // file writes, subprocesses, imports). This is a hard refuse: every execution
38
+ // path (run, verify, meta, count) passes through it, and there is no override.
39
+ // The typed error carries the capability set, so callers render the refusal
40
+ // from it — nobody re-scans to re-derive what this already knows.
41
+ export class DoctorUnsafe extends Schema.TaggedError()("DoctorUnsafe", {
42
+ programPath: Schema.String,
43
+ capabilities: Schema.Array(Schema.String),
44
+ findings: Schema.Array(Schema.String),
45
+ }) {
46
+ }
47
+ export function isRunnerError(e) {
48
+ return e instanceof ProgramMissing || e instanceof FixturesMissing
49
+ || e instanceof DoctorCrashed || e instanceof NoFramedResult || e instanceof DoctorUnsafe;
50
+ }
51
+ export function describeRunnerError(e) {
52
+ switch (e._tag) {
53
+ case "ProgramMissing": return "no such doctor program: " + e.programPath;
54
+ case "FixturesMissing": return "no fixtures found for this doctor — expected " + e.fixturesPath;
55
+ case "DoctorCrashed": return "doctor crashed:\n" + e.detail;
56
+ case "NoFramedResult": return "doctor produced no framed result — stdout was:\n" + e.stdout;
57
+ case "DoctorUnsafe": return "\ud83d\uded1 " + unsafeRefusalLine(path.basename(e.programPath), e.capabilities)
58
+ + "\n " + e.findings.join("\n ");
59
+ }
60
+ }
61
+ // One line for listings — the broken-doctor warnings and failure parts —
62
+ // where the full detail belongs to describeRunnerError's renderers. Lives
63
+ // beside the error taxonomy so rendering rules for one type stay in one
64
+ // place.
65
+ export function causeSummaryLine(e) {
66
+ if (e === undefined)
67
+ return "invalid meta";
68
+ if (e._tag === "DoctorCrashed")
69
+ return e.detail.split("\n")[0];
70
+ return describeRunnerError(e).split("\n")[0];
71
+ }
72
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
73
+ const META_TIMEOUT_MS = 30 * 1000;
74
+ // Feature probe, run once per process: does this runtime know --permission?
75
+ // Runtimes without it degrade to the static gate alone. Exported so tests
76
+ // gate on the same answer the runner uses — no copied probes.
77
+ export function supportsPermissionModel() {
78
+ return (probe !== null && probe !== void 0 ? probe : (probe = new Promise((resolve) => {
79
+ const child = spawn(process.execPath, ["--permission", "-e", "0"], { stdio: "ignore" });
80
+ child.on("error", () => resolve(false));
81
+ child.on("close", (code) => resolve(code === 0));
82
+ })));
83
+ }
84
+ let probe;
85
+ // Node's permission model, applied to every doctor execution: filesystem
86
+ // writes, subprocesses, and native addons are denied by the runtime itself
87
+ // — an eval'd, obfuscated, or helper-module payload is stopped by the
88
+ // process, whatever the static scan missed. Reads stay open (reading the
89
+ // repo is a doctor's job); verify mode also writes to the temp dir, where
90
+ // the loader seeds and cleans up fixture sandboxes. --allow-worker exists
91
+ // for the import guard's hook thread only — denials propagate into worker
92
+ // threads (verified: fs write and subprocess are denied inside them).
93
+ // Network is not part of the permission model on current Node; the loader
94
+ // strips the network globals instead. This never throws: an unprobeable
95
+ // runtime just gets no flags.
96
+ const PERMISSION_BASE = ["--permission", "--allow-fs-read=*", "--allow-worker", "--disable-warning=SecurityWarning"];
97
+ // Exported so the loader tests can spawn children in exactly the
98
+ // configuration production creates — no fictional flag sets.
99
+ export async function permissionArgs(allowTmpWrites) {
100
+ try {
101
+ if (!(await supportsPermissionModel()))
102
+ return [];
103
+ return allowTmpWrites ? [...PERMISSION_BASE, `--allow-fs-write=${os.tmpdir()}`] : [...PERMISSION_BASE];
104
+ }
105
+ catch {
106
+ return [];
107
+ }
108
+ }
109
+ // Exported with its table: Node's denial phrasings vary across versions,
110
+ // and what the refusal line names for an evading doctor depends on this
111
+ // mapping — it deserves direct tests, not coverage by e2e accident.
112
+ export function deniedByPermissionModel(stderr) {
113
+ return /ErrAccessDenied|not allowed by the permission model|access to this api has been restricted/i.test(stderr);
114
+ }
115
+ export function denialCapability(stderr) {
116
+ if (/--allow-fs-write/i.test(stderr))
117
+ return "file write";
118
+ if (/--allow-child-process/i.test(stderr))
119
+ return "subprocess";
120
+ if (/--allow-worker/i.test(stderr))
121
+ return "worker";
122
+ if (/--allow-addon/i.test(stderr))
123
+ return "native addon";
124
+ return "forbidden capability";
125
+ }
126
+ function spawnLoader(programPath, mode, timeoutMs, nodeFlags) {
127
+ return new Promise((resolve) => {
128
+ var _a, _b, _c;
129
+ const loader = fileURLToPath(new URL("doctor-loader.mjs", import.meta.url));
130
+ const child = spawn(process.execPath, [...nodeFlags, loader, ...modeArgs(mode, programPath)], {
131
+ stdio: ["pipe", "pipe", "pipe", "pipe"],
132
+ timeout: timeoutMs,
133
+ });
134
+ let stdout = "";
135
+ let stderr = "";
136
+ (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on("data", (c) => { stdout += c.toString("utf8"); });
137
+ (_b = child.stderr) === null || _b === void 0 ? void 0 : _b.on("data", (c) => { stderr += c.toString("utf8"); });
138
+ // fd 3 carries ctx.search requests; the search host answers on stdin.
139
+ let pending = "";
140
+ (_c = child.stdio[3]) === null || _c === void 0 ? void 0 : _c.on("data", (c) => {
141
+ var _a;
142
+ pending += c.toString("utf8");
143
+ let nl;
144
+ while ((nl = pending.indexOf("\n")) !== -1) {
145
+ const line = pending.slice(0, nl);
146
+ pending = pending.slice(nl + 1);
147
+ const response = handleSearchLine(line, mode);
148
+ if (response !== null)
149
+ (_a = child.stdin) === null || _a === void 0 ? void 0 : _a.write(SEARCH_RESULT + response + "\n");
150
+ }
151
+ });
152
+ child.on("error", () => resolve({ status: null, stdout, stderr: stderr + "(loader failed to start)" }));
153
+ child.on("close", (status) => resolve({ status, stdout, stderr }));
154
+ });
155
+ }
156
+ function lastLines(s, n = 8) {
157
+ return s.trim().split("\n").slice(-n).join("\n");
158
+ }
159
+ // Loader frames are authored by our own doctor-loader — trusted construction.
160
+ // The guards below separate "a usable frame" from "not a frame"; they are not
161
+ // schema validation of the doctor contract.
162
+ const execLoader = (programPath, mode, timeoutMs = DEFAULT_TIMEOUT_MS) => Effect.gen(function* () {
163
+ const abs = path.resolve(programPath);
164
+ if (!fs.existsSync(abs)) {
165
+ return yield* new ProgramMissing({ programPath: abs });
166
+ }
167
+ const gate = scanDoctorFile(abs);
168
+ if (gate.red.length > 0) {
169
+ return yield* new DoctorUnsafe({
170
+ programPath: abs,
171
+ capabilities: [...new Set(gate.red.map(f => f.capability))],
172
+ findings: gate.red.map(f => `${f.capability}: ${f.detail}`),
173
+ });
174
+ }
175
+ const nodeFlags = yield* Effect.promise(() => permissionArgs(mode.kind === "verify"));
176
+ const out = yield* Effect.tryPromise({
177
+ try: () => spawnLoader(abs, mode, timeoutMs, nodeFlags),
178
+ catch: (e) => new DoctorCrashed({ programPath: abs, detail: String(e) }),
179
+ });
180
+ if (out.status !== 0) {
181
+ if (deniedByPermissionModel(out.stderr)) {
182
+ return yield* new DoctorUnsafe({
183
+ programPath: abs,
184
+ capabilities: [denialCapability(out.stderr)],
185
+ findings: ["the runtime refused a forbidden capability:", ...lastLines(out.stderr, 2).split("\n")],
186
+ });
187
+ }
188
+ return yield* new DoctorCrashed({ programPath: abs, detail: lastLines(out.stderr || "exit " + out.status) });
189
+ }
190
+ const lines = out.stdout.split("\n");
191
+ // The last sentinel line wins. A doctor writing directly to
192
+ // process.stdout can forge a frame — accepted under the bug-not-
193
+ // adversary trust model (doctor programs are our own agents' output).
194
+ const idx = lines.findLastIndex(l => l.startsWith(RESULT_SENTINEL));
195
+ const stdout = out.stdout.slice(0, 500);
196
+ if (idx === -1) {
197
+ return yield* new NoFramedResult({ programPath: abs, stdout });
198
+ }
199
+ let frame;
200
+ try {
201
+ frame = JSON.parse(lines[idx].slice(RESULT_SENTINEL.length));
202
+ }
203
+ catch {
204
+ return yield* new NoFramedResult({ programPath: abs, stdout });
205
+ }
206
+ if (frame === null || typeof frame !== "object") {
207
+ return yield* new NoFramedResult({ programPath: abs, stdout });
208
+ }
209
+ return frame;
210
+ });
211
+ // Frames decode by their declared kind — the contract's Frame union is the
212
+ // discriminator; the casts below are keyed to it, not blind.
213
+ const asRunResult = (frame) => Effect.gen(function* () {
214
+ var _a;
215
+ if (frame.kind !== "run" || !Array.isArray(frame.findings) || frame.meta === null || typeof frame.meta !== "object") {
216
+ return yield* new NoFramedResult({ programPath: String((_a = frame.root) !== null && _a !== void 0 ? _a : ""), stdout: JSON.stringify(frame).slice(0, 500) });
217
+ }
218
+ return frame;
219
+ });
220
+ const asVerifyResult = (frame) => Effect.gen(function* () {
221
+ var _a;
222
+ if (frame.kind !== "verify" || !Array.isArray(frame.results) || frame.meta === null || typeof frame.meta !== "object") {
223
+ return yield* new NoFramedResult({ programPath: String((_a = frame.programPath) !== null && _a !== void 0 ? _a : ""), stdout: JSON.stringify(frame).slice(0, 500) });
224
+ }
225
+ return frame;
226
+ });
227
+ // ---- the public interface: plain async, typed failures thrown ----
228
+ //
229
+ // Effect is an implementation detail of this module. The public functions
230
+ // throw the tagged errors above (they extend Error, so callers get _tag
231
+ // matching and a stack); concurrency and composition stay inside.
232
+ const squash = (cause) => {
233
+ const e = Cause.squash(cause);
234
+ // Defects (interrupt/die) never occur in this module's code paths, but a
235
+ // defect must not escape the typed channel.
236
+ return isRunnerError(e) ? e : new DoctorCrashed({ programPath: "", detail: String(e) });
237
+ };
238
+ async function drain(effect) {
239
+ const exit = await Effect.runPromiseExit(effect);
240
+ return Exit.match(exit, {
241
+ onFailure: (cause) => { throw squash(cause); },
242
+ onSuccess: (value) => value,
243
+ });
244
+ }
245
+ const runDoctorE = ({ programPath, targetDir }) => Effect.flatMap(execLoader(programPath, { kind: "run", root: path.resolve(targetDir) }, DEFAULT_TIMEOUT_MS), asRunResult);
246
+ const verifyDoctorE = ({ programPath, fixturesPath }) => Effect.gen(function* () {
247
+ const abs = path.resolve(programPath);
248
+ const fixtures = path.resolve(fixturesPath !== null && fixturesPath !== void 0 ? fixturesPath : fixturesPathFor(abs));
249
+ if (!fs.existsSync(fixtures)) {
250
+ return yield* new FixturesMissing({ programPath: abs, fixturesPath: fixtures });
251
+ }
252
+ const frame = yield* execLoader(abs, { kind: "verify", fixtures }, DEFAULT_TIMEOUT_MS);
253
+ return yield* asVerifyResult(frame);
254
+ });
255
+ export async function runDoctor(options) {
256
+ return drain(runDoctorE(options));
257
+ }
258
+ export async function verifyDoctor(options) {
259
+ return drain(verifyDoctorE(options));
260
+ }
261
+ // A doctor whose meta cannot be read is data (a broken doctor), not a
262
+ // failure: metaDoctor never throws, it returns { meta: null, error }.
263
+ export async function metaDoctor({ programPath }) {
264
+ const frame = await Effect.runPromise(Effect.flatMap(Effect.exit(execLoader(programPath, { kind: "meta" }, META_TIMEOUT_MS)), (exit) => Effect.succeed(Exit.match(exit, {
265
+ onFailure: (cause) => ({ meta: null, cause: squash(cause) }),
266
+ onSuccess: (f) => ({
267
+ meta: f.kind === "meta" && f.meta !== null && typeof f.meta === "object" ? f.meta : null,
268
+ }),
269
+ }))));
270
+ return frame;
271
+ }
package/bin/score.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { Finding, ReportGroup, Severity } from "./contract.js";
2
+ export interface ScoreResult {
3
+ score: number;
4
+ grade: string;
5
+ }
6
+ export declare function findingSeverity(g: ReportGroup, f: Finding): Severity;
7
+ export declare function gradeFor(score: number): string;
8
+ export declare function scoreFromSeverities(sevs: Severity[]): ScoreResult;
9
+ export declare function computeScore(groups: ReportGroup[]): ScoreResult;
10
+ export declare function categoryRollup(groups: ReportGroup[]): {
11
+ category: string;
12
+ counts: Record<Severity, number>;
13
+ }[];
package/bin/score.js ADDED
@@ -0,0 +1,39 @@
1
+ import { resolveFinding } from "./contract.js";
2
+ const WEIGHTS = { error: 10, warning: 4, info: 1 };
3
+ export function findingSeverity(g, f) {
4
+ return resolveFinding(g.meta, f).severity;
5
+ }
6
+ export function gradeFor(score) {
7
+ if (score >= 90)
8
+ return "Excellent";
9
+ if (score >= 75)
10
+ return "Good";
11
+ if (score >= 50)
12
+ return "Fair";
13
+ if (score >= 25)
14
+ return "Poor";
15
+ return "Critical";
16
+ }
17
+ export function scoreFromSeverities(sevs) {
18
+ let score = 100;
19
+ for (const s of sevs)
20
+ score -= WEIGHTS[s];
21
+ score = Math.max(0, Math.min(100, score));
22
+ return { score, grade: gradeFor(score) };
23
+ }
24
+ export function computeScore(groups) {
25
+ return scoreFromSeverities(groups.flatMap(g => g.findings.map(f => findingSeverity(g, f))));
26
+ }
27
+ export function categoryRollup(groups) {
28
+ var _a;
29
+ const map = new Map();
30
+ for (const g of groups) {
31
+ const category = (_a = g.meta.category) !== null && _a !== void 0 ? _a : "general";
32
+ if (!map.has(category))
33
+ map.set(category, { error: 0, warning: 0, info: 0 });
34
+ for (const f of g.findings) {
35
+ map.get(category)[findingSeverity(g, f)]++;
36
+ }
37
+ }
38
+ return [...map.entries()].map(([category, counts]) => ({ category, counts }));
39
+ }
package/bin/sdk.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { DoctorCtx, Finding } from "./contract.js";
2
+ export declare function buildCtx(root: string): {
3
+ ctx: DoctorCtx;
4
+ getFindings(): Finding[];
5
+ };
package/bin/sdk.js ADDED
@@ -0,0 +1,95 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { SEARCH_REQUEST, SEARCH_RESULT } from "./contract.js";
4
+ const DEFAULT_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs"];
5
+ export function buildCtx(root) {
6
+ const findings = [];
7
+ function walk(dir, exts, out) {
8
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
9
+ if (entry.name === "node_modules" || entry.name.startsWith("."))
10
+ continue;
11
+ const abs = path.join(dir, entry.name);
12
+ if (entry.isDirectory())
13
+ walk(abs, exts, out);
14
+ else if (exts.has(path.extname(entry.name)))
15
+ out.push(path.relative(root, abs));
16
+ }
17
+ }
18
+ const ctx = {
19
+ root,
20
+ files: {
21
+ list(exts) {
22
+ const extSet = new Set((exts && exts.length ? exts : DEFAULT_EXTS)
23
+ .map(e => (e.startsWith(".") ? e : "." + e)));
24
+ const out = [];
25
+ walk(root, extSet, out);
26
+ return out.sort();
27
+ },
28
+ read(relativePath) {
29
+ const abs = path.resolve(root, relativePath);
30
+ if (abs !== root && !abs.startsWith(root + path.sep)) {
31
+ throw new Error(`ctx.files.read escapes the repo root: ${relativePath}`);
32
+ }
33
+ return fs.readFileSync(abs, "utf8");
34
+ },
35
+ },
36
+ search: {
37
+ pattern(pattern, language = "TypeScript") {
38
+ return runSearch(pattern, language, root);
39
+ },
40
+ },
41
+ report: {
42
+ finding(f) {
43
+ findings.push(f);
44
+ },
45
+ },
46
+ };
47
+ return { ctx, getFindings: () => findings.slice() };
48
+ }
49
+ function escapeRegExp(s) {
50
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
+ }
52
+ function runSearch(pattern, language, root) {
53
+ var _a;
54
+ let response;
55
+ try {
56
+ fs.writeSync(3, SEARCH_REQUEST + JSON.stringify({ pattern, language, root }) + "\n");
57
+ response = readSearchResponse();
58
+ }
59
+ catch (e) {
60
+ throw new Error(`ctx.search is unavailable — no search host on this channel (${e instanceof Error ? e.message : String(e)}). `
61
+ + "Doctors run through any-doctor; a bare doctor-loader.mjs invocation has no host.");
62
+ }
63
+ if (response.error !== undefined)
64
+ throw new Error(response.error);
65
+ return toMatches((_a = response.matches) !== null && _a !== void 0 ? _a : [], root);
66
+ }
67
+ function readSearchResponse() {
68
+ const chunk = Buffer.alloc(65536);
69
+ let buffer = "";
70
+ for (;;) {
71
+ const n = fs.readSync(0, chunk, 0, chunk.length, null);
72
+ if (n === 0)
73
+ throw new Error("search host channel closed");
74
+ buffer += chunk.toString("utf8", 0, n);
75
+ const nl = buffer.indexOf("\n");
76
+ if (nl !== -1) {
77
+ const line = buffer.slice(0, nl);
78
+ if (line.startsWith(SEARCH_RESULT)) {
79
+ return JSON.parse(line.slice(SEARCH_RESULT.length));
80
+ }
81
+ buffer = buffer.slice(nl + 1);
82
+ }
83
+ }
84
+ }
85
+ function toMatches(raw, root) {
86
+ return raw.map(m => {
87
+ var _a, _b, _c, _d, _e, _f;
88
+ return ({
89
+ file: (m.file || "").replace(new RegExp("^" + escapeRegExp(root) + "/"), ""),
90
+ line: ((_c = (_b = (_a = m.range) === null || _a === void 0 ? void 0 : _a.start) === null || _b === void 0 ? void 0 : _b.line) !== null && _c !== void 0 ? _c : 0) + 1,
91
+ column: ((_f = (_e = (_d = m.range) === null || _d === void 0 ? void 0 : _d.start) === null || _e === void 0 ? void 0 : _e.column) !== null && _f !== void 0 ? _f : 1),
92
+ text: m.text || "",
93
+ });
94
+ });
95
+ }
@@ -0,0 +1,6 @@
1
+ import { Mode } from "./contract.js";
2
+ import { runEngineSearch } from "./engine.js";
3
+ export declare function searchBase(mode: Mode): string;
4
+ type Engine = typeof runEngineSearch;
5
+ export declare function handleSearchLine(line: string, mode: Mode, engine?: Engine): string | null;
6
+ export {};