any-doctor 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +36 -4
- package/README.md +9 -2
- package/bin/cli.d.ts +1 -0
- package/bin/cli.js +41 -16
- package/bin/contract.d.ts +3 -0
- package/bin/contract.js +25 -3
- package/bin/dashboard.d.ts +2 -1
- package/bin/dashboard.js +22 -19
- package/bin/discover.d.ts +4 -1
- package/bin/discover.js +25 -5
- package/bin/doctor-loader.mjs +8 -5
- package/bin/report.d.ts +1 -0
- package/bin/report.js +29 -8
- package/bin/runner.d.ts +2 -0
- package/bin/runner.js +1 -1
- package/bin/score.d.ts +7 -2
- package/bin/score.js +36 -9
- package/bin/sdk.d.ts +3 -1
- package/bin/sdk.js +13 -3
- package/bin/search-host.js +16 -3
- package/bin/select.d.ts +1 -0
- package/bin/select.js +8 -2
- package/docs/decisions.md +96 -0
- package/docs/research-openrouter-patterns.md +43 -0
- package/docs/research-shadcn-registry.md +61 -0
- package/doctors/async-doctor.fixtures.mjs +14 -0
- package/doctors/async-doctor.mjs +7 -2
- package/doctors/convex-doctor.fixtures.mjs +424 -13
- package/doctors/convex-doctor.mjs +377 -38
- package/doctors/effect-doctor.fixtures.mjs +303 -0
- package/doctors/effect-doctor.mjs +322 -0
- package/doctors/openrouter-doctor.fixtures.mjs +169 -0
- package/doctors/openrouter-doctor.mjs +211 -0
- package/package.json +1 -1
- package/skill/any-doctor.skill.md +3 -2
- package/doctors/AGENTS.md +0 -103
package/CONTEXT.md
CHANGED
|
@@ -67,6 +67,17 @@ repo-scoped file access, structural search, and a finding emitter.
|
|
|
67
67
|
Confinement enforces it: outside ctx there is nothing — no imports, no
|
|
68
68
|
writes, no subprocesses, no network.
|
|
69
69
|
|
|
70
|
+
`ctx.files.list()` and `ctx.search` exclude test paths by default
|
|
71
|
+
(test-named code files — `*.test.*`/`*.spec.*` with a code extension —
|
|
72
|
+
and `test/`, `tests/`, `__tests__/` directories): tests mimic production
|
|
73
|
+
shapes without being production reads. The one law (`isTestPath`) and
|
|
74
|
+
the one derivation (`includeTestsFor`) live in contract.ts; every read
|
|
75
|
+
capability applies them. A Doctor run opts back in with
|
|
76
|
+
`--include-tests`; `ctx.files.read()` is never filtered — an explicit
|
|
77
|
+
path is a deliberate choice. Verify always sees everything its fixtures
|
|
78
|
+
seed: the sandbox is the doctor's own world, and a seed named
|
|
79
|
+
`*.test.ts` is deliberate test data.
|
|
80
|
+
|
|
70
81
|
## Engine
|
|
71
82
|
|
|
72
83
|
The structural-search backend a DoctorCtx uses to answer ctx.search.
|
|
@@ -76,6 +87,23 @@ program runs unchanged on any engine. One module owns the invocation
|
|
|
76
87
|
(src/engine.ts); the search host sits on it, and the sdk asks the host —
|
|
77
88
|
there is exactly one path, with no unconfined fallback.
|
|
78
89
|
|
|
90
|
+
## Score
|
|
91
|
+
|
|
92
|
+
The share of scanned files with no findings, weighted by each affected
|
|
93
|
+
file's worst severity (error 1, warning 0.5, info 0.1). One sentence,
|
|
94
|
+
locally computed: "491/628 files clean" is a 78. Zero findings is 100 by
|
|
95
|
+
anchor; an empty scan is also 100. The denominator is the target's file
|
|
96
|
+
count as the doctors scanned it (default extensions); findings naming
|
|
97
|
+
files outside that count can push the raw value negative, so the result
|
|
98
|
+
is floored into 0–100 — and floored, never rounded, so any finding costs
|
|
99
|
+
at least one point. Findings duplicated across doctors at the same
|
|
100
|
+
file:line are deduplicated before scoring — the first-sorted copy wins
|
|
101
|
+
(groups sort by the first finding carrying an explicit severity
|
|
102
|
+
override, else the doctor's declared default; equal-severity groups
|
|
103
|
+
fall back to input order) and a hidden duplicate's severity does not
|
|
104
|
+
contribute. The score summarizes health — the findings are the work;
|
|
105
|
+
the two are reported together, never conflated.
|
|
106
|
+
|
|
79
107
|
## Meta
|
|
80
108
|
|
|
81
109
|
A doctor program's declared data: id, description, default severity,
|
|
@@ -116,13 +144,17 @@ discovery reads it directly, with no separate index or cache.
|
|
|
116
144
|
## Scope
|
|
117
145
|
|
|
118
146
|
Where a doctor program lives: repo-local (`./doctors/`, committed with
|
|
119
|
-
the consuming repo)
|
|
120
|
-
in every repo)
|
|
147
|
+
the consuming repo), user-global (`~/.any-doctor/doctors/`, available
|
|
148
|
+
in every repo), or bundled (the first-party pack inside the package,
|
|
149
|
+
read-only — a starting point, not a dependency). Repo-local wins slug
|
|
150
|
+
collisions, then user-global, then bundled. Scanning a target repo
|
|
121
151
|
never writes to any scope.
|
|
122
152
|
|
|
123
153
|
## Skill
|
|
124
154
|
|
|
125
155
|
The instructions any-doctor provides so an agent can create a doctor that
|
|
126
|
-
fits the contract. Planted as `AGENTS.md`
|
|
127
|
-
|
|
156
|
+
fits the contract. Planted as `AGENTS.md` behind a one-line provenance
|
|
157
|
+
marker; `generate` refreshes a copy it planted (the marker is the
|
|
158
|
+
boundary) and never touches a copy without one. The generation prompt
|
|
159
|
+
embeds the skill verbatim. Any Doctor equips agents with the skill;
|
|
128
160
|
it never launches, deploys, or speaks for an agent.
|
package/README.md
CHANGED
|
@@ -22,13 +22,20 @@ any-doctor run doctors/fetch-without-abort-signal.mjs path/to/repo # scan + sc
|
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
`generate` plants the skill as `AGENTS.md` in the scope dir (agents load it
|
|
25
|
-
natively
|
|
25
|
+
natively — a planted copy refreshes on the next generate; a copy with your
|
|
26
|
+
own edits is never touched) and copies the generation prompt — paste it into your own agent
|
|
26
27
|
session, any agent, GUI or CLI. When it has written the doctor + fixtures,
|
|
27
28
|
`verify` gates it: missing expected findings fail recall, unexpected ones
|
|
28
29
|
fail precision. `run` and `verify` never touch a model or an agent — pipe
|
|
29
30
|
the output (or set `ANY_DOCTOR_HEADLESS=1`) for stable CI output. Requires
|
|
30
31
|
Node ≥ 18.
|
|
31
32
|
|
|
33
|
+
`run` excludes test files from scanning by default (test-named code files
|
|
34
|
+
and `test/`, `tests/`, `__tests__/` directories — for `ctx.files.list` and
|
|
35
|
+
`ctx.search` alike) — mocks and fixture data mimic production shapes
|
|
36
|
+
without being production reads. Pass `--include-tests` to scan them;
|
|
37
|
+
`verify` always scans everything its fixtures seed.
|
|
38
|
+
|
|
32
39
|
A doctor program is `<name>.mjs` (exports `meta` + `doctor(ctx)`) next to
|
|
33
40
|
its fixture module `<name>.fixtures.mjs` (seeds + expected findings).
|
|
34
41
|
See [CONTEXT.md](CONTEXT.md) for the vocabulary and
|
|
@@ -41,7 +48,7 @@ per-check fixtures and the interactive check tree in `run`.
|
|
|
41
48
|
| Doc | What it holds |
|
|
42
49
|
|---|---|
|
|
43
50
|
| [CONTEXT.md](CONTEXT.md) | Domain glossary — canonical terms |
|
|
44
|
-
| [docs/decisions.md](docs/decisions.md) | Decision log (D1–
|
|
51
|
+
| [docs/decisions.md](docs/decisions.md) | Decision log (D1–D19). Read first; don't relitigate |
|
|
45
52
|
| [docs/vision.md](docs/vision.md) | Product idea and the lifecycle novelty |
|
|
46
53
|
| [docs/features.md](docs/features.md) | Doctor discovery & registry spec + status |
|
|
47
54
|
| [docs/research.md](docs/research.md) | Landscape, React Doctor teardown |
|
package/bin/cli.d.ts
CHANGED
package/bin/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import * as fs from "fs";
|
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "url";
|
|
5
5
|
import { DOCTOR_FILE_RE } from "./contract.js";
|
|
6
|
-
import { renderReport, renderVerifyResult, unsafeSkipLine } from "./report.js";
|
|
6
|
+
import { cohortFileCount, renderReport, renderVerifyResult, unsafeSkipLine } from "./report.js";
|
|
7
7
|
import { copyToClipboard } from "./clipboard.js";
|
|
8
8
|
import { runDashboard } from "./dashboard.js";
|
|
9
9
|
import { brokenDoctors, discoverDoctors, globalDoctorsDir, unsafeSlugs } from "./discover.js";
|
|
@@ -97,7 +97,7 @@ function selectionOutcome(sel) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
function parseArgs(args) {
|
|
100
|
-
const out = { targetDir: path.resolve("."), all: false, global: false };
|
|
100
|
+
const out = { targetDir: path.resolve("."), all: false, global: false, includeTests: false };
|
|
101
101
|
let targetDirSet = false;
|
|
102
102
|
for (let i = 0; i < args.length; i++) {
|
|
103
103
|
const a = args[i];
|
|
@@ -105,6 +105,8 @@ function parseArgs(args) {
|
|
|
105
105
|
out.all = true;
|
|
106
106
|
else if (a === "--global")
|
|
107
107
|
out.global = true;
|
|
108
|
+
else if (a === "--include-tests")
|
|
109
|
+
out.includeTests = true;
|
|
108
110
|
else if (out.doctorPath === undefined && DOCTOR_FILE_RE.test(a))
|
|
109
111
|
out.doctorPath = a;
|
|
110
112
|
else if (!targetDirSet) {
|
|
@@ -125,10 +127,10 @@ async function gatherDoctors() {
|
|
|
125
127
|
broken: brokenDoctors(all),
|
|
126
128
|
};
|
|
127
129
|
}
|
|
128
|
-
async function scanOnce(
|
|
129
|
-
const result = await runOrReport(runDoctor(
|
|
130
|
+
async function scanOnce(options) {
|
|
131
|
+
const result = await runOrReport(runDoctor(options));
|
|
130
132
|
return {
|
|
131
|
-
group: { programName: path.basename(
|
|
133
|
+
group: { programName: path.basename(options.programPath), meta: result.meta, findings: result.findings },
|
|
132
134
|
fileCount: result.fileCount,
|
|
133
135
|
};
|
|
134
136
|
}
|
|
@@ -167,7 +169,7 @@ async function cmdRun(args) {
|
|
|
167
169
|
if ("exit" in selection)
|
|
168
170
|
return selection.exit;
|
|
169
171
|
const runStarted = Date.now();
|
|
170
|
-
const scan = await scanOnce(selection.doctorPath, parsed.targetDir);
|
|
172
|
+
const scan = await scanOnce({ programPath: selection.doctorPath, targetDir: parsed.targetDir, includeTests: parsed.includeTests });
|
|
171
173
|
outcome = {
|
|
172
174
|
groups: [scan.group],
|
|
173
175
|
crashed: [],
|
|
@@ -189,11 +191,11 @@ async function cmdRun(args) {
|
|
|
189
191
|
const groups = [];
|
|
190
192
|
const crashed = [];
|
|
191
193
|
const doctorPaths = new Map();
|
|
192
|
-
|
|
194
|
+
const fileCounts = [];
|
|
193
195
|
for (const d of discovered) {
|
|
194
196
|
try {
|
|
195
|
-
const scan = await scanOnce(d.path, parsed.targetDir);
|
|
196
|
-
|
|
197
|
+
const scan = await scanOnce({ programPath: d.path, targetDir: parsed.targetDir, includeTests: parsed.includeTests });
|
|
198
|
+
fileCounts.push(scan.fileCount);
|
|
197
199
|
doctorPaths.set(d.meta.id, d.path);
|
|
198
200
|
groups.push(scan.group);
|
|
199
201
|
}
|
|
@@ -208,7 +210,7 @@ async function cmdRun(args) {
|
|
|
208
210
|
crashed,
|
|
209
211
|
skippedUnsafe,
|
|
210
212
|
doctorPaths,
|
|
211
|
-
fileCount,
|
|
213
|
+
fileCount: cohortFileCount(fileCounts),
|
|
212
214
|
durationMs: Date.now() - runStarted,
|
|
213
215
|
targetDir: parsed.targetDir,
|
|
214
216
|
};
|
|
@@ -243,6 +245,9 @@ async function cmdVerify(args) {
|
|
|
243
245
|
fail("--global is a generate-only flag");
|
|
244
246
|
return 1;
|
|
245
247
|
}
|
|
248
|
+
if (parsed.includeTests) {
|
|
249
|
+
warn("--include-tests applies to run only — verify always scans everything its fixtures seed");
|
|
250
|
+
}
|
|
246
251
|
if (parsed.all) {
|
|
247
252
|
const cohort = await gatherDoctors();
|
|
248
253
|
warnBrokenDoctors(cohort.broken);
|
|
@@ -301,6 +306,21 @@ async function cmdVerify(args) {
|
|
|
301
306
|
console.log(dim(`${result.results.length - failures}/${result.results.length} fixtures passed for ${result.meta.id}`));
|
|
302
307
|
return failures > 0 ? 1 : 0;
|
|
303
308
|
}
|
|
309
|
+
// The planted copy of the skill once went three decisions stale
|
|
310
|
+
// (doctors/AGENTS.md still taught "builtins allowed" after Confinement
|
|
311
|
+
// refused every import), so planting refreshes: a copy any-doctor planted
|
|
312
|
+
// carries the provenance marker and is overwritten on generate; a copy
|
|
313
|
+
// without it is the user's and is never touched.
|
|
314
|
+
const PLANT_MARKER = "<!-- any-doctor skill plant -->\n";
|
|
315
|
+
export function plantSkill(scopeDir, skill) {
|
|
316
|
+
const agentsPath = path.join(scopeDir, "AGENTS.md");
|
|
317
|
+
const existing = fs.existsSync(agentsPath) ? fs.readFileSync(agentsPath, "utf8") : null;
|
|
318
|
+
if (existing === null || existing.startsWith(PLANT_MARKER)) {
|
|
319
|
+
fs.writeFileSync(agentsPath, PLANT_MARKER + skill);
|
|
320
|
+
return existing === null ? "planted" : "refreshed";
|
|
321
|
+
}
|
|
322
|
+
return "left-user-copy";
|
|
323
|
+
}
|
|
304
324
|
async function cmdGenerate(args) {
|
|
305
325
|
let intent;
|
|
306
326
|
let global = false;
|
|
@@ -324,9 +344,9 @@ async function cmdGenerate(args) {
|
|
|
324
344
|
? (fs.mkdirSync(globalDoctorsDir(), { recursive: true }), globalDoctorsDir())
|
|
325
345
|
: path.resolve("doctors");
|
|
326
346
|
fs.mkdirSync(scopeDir, { recursive: true });
|
|
327
|
-
const
|
|
328
|
-
if (
|
|
329
|
-
|
|
347
|
+
const planted = plantSkill(scopeDir, skill);
|
|
348
|
+
if (planted === "left-user-copy") {
|
|
349
|
+
warn("AGENTS.md exists with edits of your own — left untouched (delete it to re-plant)");
|
|
330
350
|
}
|
|
331
351
|
const cliJs = fileURLToPath(new URL("cli.js", import.meta.url));
|
|
332
352
|
const doctorAbs = path.join(scopeDir, slug + ".mjs");
|
|
@@ -372,10 +392,10 @@ function usage() {
|
|
|
372
392
|
console.log(BOLD + "any-doctor" + RESET + dim(" — your agent writes the analyzer, fixtures prove it, CI reruns it forever"));
|
|
373
393
|
console.log("");
|
|
374
394
|
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");
|
|
395
|
+
console.log(" run [--all] [--include-tests] [doctor.(m)js] [dir] scan; no argument = every doctor in one review tree");
|
|
376
396
|
console.log(" verify [--all] [doctor.(m)js] fixture gate (no doctor: fuzzy picker; --all: every doctor)");
|
|
377
397
|
console.log("");
|
|
378
|
-
console.log(dim("doctors live in ./doctors/ (repo)
|
|
398
|
+
console.log(dim("doctors live in ./doctors/ (repo), ~/.any-doctor/doctors/ (global), and the bundled pack (lowest priority)."));
|
|
379
399
|
console.log(dim("generation delegates to your installed agent — run and verify never touch a model."));
|
|
380
400
|
}
|
|
381
401
|
export async function main(argv = process.argv.slice(2)) {
|
|
@@ -386,10 +406,15 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
386
406
|
}
|
|
387
407
|
const cmd = argv[0];
|
|
388
408
|
const rest = argv.slice(1);
|
|
389
|
-
if (
|
|
409
|
+
if (cmd === "help" || cmd === "--help") {
|
|
390
410
|
usage();
|
|
391
411
|
return 0;
|
|
392
412
|
}
|
|
413
|
+
// D15: bare `npx any-doctor` is the cold start — every discovered
|
|
414
|
+
// doctor (bundled included), straight into the report/tree, no usage
|
|
415
|
+
// wall. `help` remains the explicit usage door.
|
|
416
|
+
if (!cmd)
|
|
417
|
+
return await cmdRun(rest);
|
|
393
418
|
try {
|
|
394
419
|
if (cmd === "generate")
|
|
395
420
|
return await cmdGenerate(rest);
|
package/bin/contract.d.ts
CHANGED
|
@@ -62,6 +62,7 @@ export declare const SEARCH_RESULT = "###ANY_DOCTOR_SEARCH_RESULT###";
|
|
|
62
62
|
export type Mode = {
|
|
63
63
|
kind: "run";
|
|
64
64
|
root: string;
|
|
65
|
+
includeTests?: boolean;
|
|
65
66
|
} | {
|
|
66
67
|
kind: "verify";
|
|
67
68
|
fixtures: string;
|
|
@@ -112,6 +113,8 @@ export type Frame = RunResult | VerifyRunResult | MetaResult;
|
|
|
112
113
|
export declare const DOCTOR_FILE_RE: RegExp;
|
|
113
114
|
export declare const FIXTURES_FILE_RE: RegExp;
|
|
114
115
|
export declare function fixturesPathFor(programPath: string): string;
|
|
116
|
+
export declare function isTestPath(relativePath: string): boolean;
|
|
117
|
+
export declare function includeTestsFor(mode: Mode): boolean;
|
|
115
118
|
export declare function runCommandFor(doctorPath: string, root: string, invoker?: string): string;
|
|
116
119
|
export declare function compareFindings(expected: {
|
|
117
120
|
file: string;
|
package/bin/contract.js
CHANGED
|
@@ -7,7 +7,9 @@ export const SEARCH_REQUEST = "###ANY_DOCTOR_SEARCH###";
|
|
|
7
7
|
export const SEARCH_RESULT = "###ANY_DOCTOR_SEARCH_RESULT###";
|
|
8
8
|
export function modeArgs(mode, programPath) {
|
|
9
9
|
switch (mode.kind) {
|
|
10
|
-
case "run": return
|
|
10
|
+
case "run": return mode.includeTests === true
|
|
11
|
+
? [programPath, mode.root, "--include-tests"]
|
|
12
|
+
: [programPath, mode.root];
|
|
11
13
|
case "verify": return [programPath, "--verify", mode.fixtures];
|
|
12
14
|
case "meta": return [programPath, "--meta"];
|
|
13
15
|
}
|
|
@@ -20,8 +22,8 @@ export function decodeLoaderArgs(argv) {
|
|
|
20
22
|
return { program, mode: { kind: "verify", fixtures: third } };
|
|
21
23
|
if (second === "--meta" && third === undefined)
|
|
22
24
|
return { program, mode: { kind: "meta" } };
|
|
23
|
-
if (second !== undefined && !second.startsWith("-") && third === undefined) {
|
|
24
|
-
return { program, mode: { kind: "run", root: second } };
|
|
25
|
+
if (second !== undefined && !second.startsWith("-") && (third === undefined || third === "--include-tests")) {
|
|
26
|
+
return { program, mode: { kind: "run", root: second, ...(third === "--include-tests" ? { includeTests: true } : {}) } };
|
|
25
27
|
}
|
|
26
28
|
return null;
|
|
27
29
|
}
|
|
@@ -36,6 +38,26 @@ export const FIXTURES_FILE_RE = /\.fixtures\.(m|c)?js$/;
|
|
|
36
38
|
export function fixturesPathFor(programPath) {
|
|
37
39
|
return programPath.replace(DOCTOR_FILE_RE, "") + ".fixtures.mjs";
|
|
38
40
|
}
|
|
41
|
+
// D18's one law, in the conventions' home: test files and test directories
|
|
42
|
+
// are not production reads. Every read capability applies this predicate —
|
|
43
|
+
// the sdk walk prunes by it, the search host filters matches by it. Test
|
|
44
|
+
// FILES are test-named code files (.test./.spec. with a code extension);
|
|
45
|
+
// test DIRECTORIES are test/tests/__tests__ anywhere in the path.
|
|
46
|
+
const TEST_FILE_RE = /(?:\.test|\.spec)\.[cm]?[jt]sx?$/i;
|
|
47
|
+
const TEST_DIR_NAMES = new Set(["test", "tests", "__tests__"]);
|
|
48
|
+
export function isTestPath(relativePath) {
|
|
49
|
+
if (TEST_FILE_RE.test(relativePath))
|
|
50
|
+
return true;
|
|
51
|
+
return relativePath.split(/[\\/]+/).some((seg) => TEST_DIR_NAMES.has(seg));
|
|
52
|
+
}
|
|
53
|
+
// One derivation, one home: a run scans test files only when --include-tests
|
|
54
|
+
// asks; a verify always sees everything its fixtures seed (D18) — the
|
|
55
|
+
// sandbox is the doctor's own world.
|
|
56
|
+
export function includeTestsFor(mode) {
|
|
57
|
+
if (mode.kind === "verify")
|
|
58
|
+
return true;
|
|
59
|
+
return mode.kind === "run" && mode.includeTests === true;
|
|
60
|
+
}
|
|
39
61
|
// The re-run command embedded in copied prompts. invoker defaults to
|
|
40
62
|
// the installed binary name; callers running via node or npx pass their own.
|
|
41
63
|
export function runCommandFor(doctorPath, root, invoker = "any-doctor") {
|
package/bin/dashboard.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Finding, JoinedFinding, ReportGroup, Severity } from "./contract.js";
|
|
2
|
+
import { ScoreResult } from "./score.js";
|
|
2
3
|
import { TtyStdin, TtyStdout } from "./tty.js";
|
|
3
4
|
import { RunOutcome } from "./report.js";
|
|
4
5
|
export declare function highlightCode(line: string, useColor: boolean): string;
|
|
@@ -88,7 +89,7 @@ export interface DashboardFrameState {
|
|
|
88
89
|
readKeys: Set<string>;
|
|
89
90
|
readSource: FrameSource;
|
|
90
91
|
expanded?: ReadonlySet<string>;
|
|
91
|
-
|
|
92
|
+
score: ScoreResult;
|
|
92
93
|
durationMs: number;
|
|
93
94
|
useColor: boolean;
|
|
94
95
|
notice?: string;
|
package/bin/dashboard.js
CHANGED
|
@@ -2,12 +2,12 @@ import * as fs from "fs";
|
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { copyToClipboard } from "./clipboard.js";
|
|
4
4
|
import { resolveFinding, runCommandFor } from "./contract.js";
|
|
5
|
-
import {
|
|
5
|
+
import { computeScore, scoreHeaderLines } from "./score.js";
|
|
6
6
|
import { processTtyEnv } from "./tty.js";
|
|
7
7
|
import * as tty from "./tty.js";
|
|
8
8
|
import { runTty, truncateVisible, visibleWidth } from "./tty.js";
|
|
9
9
|
import { BOLD, colorizer, DIM, GLYPH, gradeColor, GREEN, ORANGE, RESET, SEVERITY_COLOR, YELLOW } from "./palette.js";
|
|
10
|
-
import { unsafeSkipLine } from "./report.js";
|
|
10
|
+
import { dedupeGroups, unsafeSkipLine } from "./report.js";
|
|
11
11
|
const SPLIT_MIN_COLS = 100;
|
|
12
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
13
|
export function highlightCode(line, useColor) {
|
|
@@ -402,22 +402,22 @@ function itemRowText(it, isSelected, readKeys, c, showCheckId = true) {
|
|
|
402
402
|
return `${isSelected ? c("›", BOLD) : " "}${glyph} ${c(it.site.file + ":" + it.site.line, wrap)}${suffix}`;
|
|
403
403
|
}
|
|
404
404
|
export function dashboardFrame(state) {
|
|
405
|
-
var _a, _b, _c, _d, _e;
|
|
405
|
+
var _a, _b, _c, _d, _e, _f;
|
|
406
406
|
const { tree, selectedRow, readKeys, useColor, cols, rows } = state;
|
|
407
407
|
const c = colorizer(useColor);
|
|
408
408
|
const findings = tree.flatMap(d => d.checks.flatMap(g => g.items));
|
|
409
409
|
const layout = resolveDashboardLayout(cols, rows, findings.length);
|
|
410
|
-
const
|
|
410
|
+
const header = scoreHeaderLines(state.score);
|
|
411
411
|
const barWidth = Math.min(46, Math.max(16, cols - 60));
|
|
412
|
-
const
|
|
413
|
-
c(
|
|
414
|
-
c(scoreBar(score, barWidth), gradeColor(score)),
|
|
415
|
-
c(`${findings.length} finding${findings.length === 1 ? "" : "s"} · ${state.
|
|
412
|
+
const headerLines = [
|
|
413
|
+
c(header.scoreLine, BOLD + gradeColor(state.score.score)),
|
|
414
|
+
c(scoreBar(state.score.score, barWidth), gradeColor(state.score.score)),
|
|
415
|
+
c(`${findings.length} finding${findings.length === 1 ? "" : "s"} · ${(_a = header.cleanLine) !== null && _a !== void 0 ? _a : state.score.filesTotal + " files"} · ${state.durationMs}ms`, DIM),
|
|
416
416
|
];
|
|
417
417
|
if (state.skippedUnsafe !== undefined && state.skippedUnsafe.length > 0) {
|
|
418
|
-
|
|
418
|
+
headerLines.push(c(`\u26a0 ${unsafeSkipLine(state.skippedUnsafe)}`, YELLOW));
|
|
419
419
|
}
|
|
420
|
-
|
|
420
|
+
headerLines.push("");
|
|
421
421
|
const rowsData = buildListRows(tree, useColor, selectedRow, readKeys, state.expanded);
|
|
422
422
|
const viewport = Math.max(1, Math.min(layout.listHeight, layout.bodyRows));
|
|
423
423
|
let firstVisible = Math.max(0, Math.min(selectedRow - viewport + 1, Math.max(0, rowsData.length - viewport)));
|
|
@@ -437,11 +437,11 @@ export function dashboardFrame(state) {
|
|
|
437
437
|
detail.push(c(`${sel.site.file}:${sel.site.line}`, BOLD));
|
|
438
438
|
detail.push(c(`${cap(sel.category)} · ${sel.severity}`, DIM));
|
|
439
439
|
detail.push("");
|
|
440
|
-
const impact = (
|
|
440
|
+
const impact = (_b = sel.impact) !== null && _b !== void 0 ? _b : sel.description;
|
|
441
441
|
for (const l of wordWrap(impact, layout.detailWidth - 2))
|
|
442
442
|
detail.push(c(l, SEVERITY_COLOR[sel.severity]));
|
|
443
443
|
detail.push("");
|
|
444
|
-
proseSection(detail, "Why", (
|
|
444
|
+
proseSection(detail, "Why", (_c = sel.why) !== null && _c !== void 0 ? _c : "Not documented for this check.", layout.detailWidth - 2, c);
|
|
445
445
|
detail.push("");
|
|
446
446
|
detail.push(c("Code", DIM));
|
|
447
447
|
for (const l of codeFrameLines(state.readSource(sel.site.file), sel.site.line, layout.detailWidth - 2, useColor))
|
|
@@ -490,7 +490,7 @@ export function dashboardFrame(state) {
|
|
|
490
490
|
const body = [];
|
|
491
491
|
if (layout.mode === "split") {
|
|
492
492
|
for (let i = 0; i < layout.bodyRows; i++) {
|
|
493
|
-
body.push(padVisible(truncateVisible((
|
|
493
|
+
body.push(padVisible(truncateVisible((_d = listLines[i]) !== null && _d !== void 0 ? _d : "", layout.listWidth), layout.listWidth) + " " + ((_e = detail[i]) !== null && _e !== void 0 ? _e : ""));
|
|
494
494
|
}
|
|
495
495
|
}
|
|
496
496
|
else {
|
|
@@ -501,7 +501,7 @@ export function dashboardFrame(state) {
|
|
|
501
501
|
...detail,
|
|
502
502
|
];
|
|
503
503
|
for (let i = 0; i < layout.bodyRows; i++)
|
|
504
|
-
body.push((
|
|
504
|
+
body.push((_f = stacked[i]) !== null && _f !== void 0 ? _f : "");
|
|
505
505
|
}
|
|
506
506
|
// Fixed-shape footer: the notice line is always present (blank when idle)
|
|
507
507
|
// so showing or clearing a notice never changes the frame height.
|
|
@@ -509,7 +509,7 @@ export function dashboardFrame(state) {
|
|
|
509
509
|
state.notice ? c("✔ " + state.notice, GREEN) : "",
|
|
510
510
|
c("↑↓ move · →← expand · enter copy finding · c copy group · q quit", DIM),
|
|
511
511
|
];
|
|
512
|
-
return [...
|
|
512
|
+
return [...headerLines, "", ...body, "", ...footer].join("\n");
|
|
513
513
|
}
|
|
514
514
|
function cap(s) {
|
|
515
515
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
@@ -553,9 +553,12 @@ export async function runDashboardOn(env, input, deps = {}) {
|
|
|
553
553
|
if (!tty.canRunTui(env))
|
|
554
554
|
return;
|
|
555
555
|
const useColor = input.useColor;
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
|
|
556
|
+
// One RunOutcome, one story on every surface: the tree AND the score
|
|
557
|
+
// consume the same deduplicated groups the report renders — counts and
|
|
558
|
+
// score can never disagree between surfaces.
|
|
559
|
+
const { groups: deduped } = dedupeGroups(input.outcome.groups);
|
|
560
|
+
const tree = buildTree(buildItems(deduped));
|
|
561
|
+
const score = computeScore(deduped, input.outcome.fileCount);
|
|
559
562
|
const expanded = initialExpanded(tree);
|
|
560
563
|
const readKeys = new Set();
|
|
561
564
|
let notice;
|
|
@@ -604,7 +607,7 @@ export async function runDashboardOn(env, input, deps = {}) {
|
|
|
604
607
|
readKeys,
|
|
605
608
|
readSource,
|
|
606
609
|
expanded,
|
|
607
|
-
|
|
610
|
+
score,
|
|
608
611
|
durationMs: input.outcome.durationMs,
|
|
609
612
|
useColor,
|
|
610
613
|
notice,
|
package/bin/discover.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DoctorMeta } from "./contract.js";
|
|
2
2
|
import { RunnerError } from "./runner.js";
|
|
3
|
-
export type Scope = "repo" | "global";
|
|
3
|
+
export type Scope = "repo" | "global" | "bundled";
|
|
4
4
|
export interface DiscoveredDoctor {
|
|
5
5
|
slug: string;
|
|
6
6
|
scope: Scope;
|
|
@@ -15,10 +15,13 @@ export interface BrokenDoctor {
|
|
|
15
15
|
export declare function unsafeSlugs(discovered: DiscoveredDoctor[]): string[];
|
|
16
16
|
export declare function brokenDoctors(discovered: DiscoveredDoctor[]): BrokenDoctor[];
|
|
17
17
|
export declare function globalDoctorsDir(): string;
|
|
18
|
+
export declare function bundledDoctorsDir(): string;
|
|
18
19
|
export declare function findRepoDoctorsDir(cwd: string): string | null;
|
|
19
20
|
export declare function discoverDoctors(cwd: string, opts?: {
|
|
20
21
|
globalDir?: string;
|
|
22
|
+
bundledDir?: string;
|
|
21
23
|
}): Promise<DiscoveredDoctor[]>;
|
|
22
24
|
export declare function resolveDoctorPath(arg: string, cwd: string, opts?: {
|
|
23
25
|
globalDir?: string;
|
|
26
|
+
bundledDir?: string;
|
|
24
27
|
}): string | null;
|
package/bin/discover.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as os from "os";
|
|
3
3
|
import * as path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
4
5
|
import { DOCTOR_FILE_RE, FIXTURES_FILE_RE } from "./contract.js";
|
|
5
6
|
import { metaDoctor } from "./runner.js";
|
|
6
7
|
// The gate's partition over discovery — owned by the data so every surface
|
|
@@ -18,6 +19,12 @@ export function brokenDoctors(discovered) {
|
|
|
18
19
|
export function globalDoctorsDir() {
|
|
19
20
|
return path.join(os.homedir(), ".any-doctor", "doctors");
|
|
20
21
|
}
|
|
22
|
+
// The bundled pack: the package's own doctors/, a sibling of bin/ wherever
|
|
23
|
+
// the package lives (repo dev, node_modules, or the npx cache). Read-only
|
|
24
|
+
// and lowest priority (D15) — repo-local and user-global win collisions.
|
|
25
|
+
export function bundledDoctorsDir() {
|
|
26
|
+
return path.join(path.resolve(fileURLToPath(new URL("..", import.meta.url))), "doctors");
|
|
27
|
+
}
|
|
21
28
|
export function findRepoDoctorsDir(cwd) {
|
|
22
29
|
let dir = path.resolve(cwd);
|
|
23
30
|
for (;;) {
|
|
@@ -31,14 +38,22 @@ export function findRepoDoctorsDir(cwd) {
|
|
|
31
38
|
}
|
|
32
39
|
}
|
|
33
40
|
export async function discoverDoctors(cwd, opts) {
|
|
34
|
-
var _a;
|
|
41
|
+
var _a, _b;
|
|
35
42
|
const repoDir = findRepoDoctorsDir(cwd);
|
|
36
43
|
const scopes = [
|
|
37
44
|
...(repoDir ? [{ scope: "repo", dir: repoDir }] : []),
|
|
38
45
|
{ scope: "global", dir: (_a = opts === null || opts === void 0 ? void 0 : opts.globalDir) !== null && _a !== void 0 ? _a : globalDoctorsDir() },
|
|
46
|
+
{ scope: "bundled", dir: (_b = opts === null || opts === void 0 ? void 0 : opts.bundledDir) !== null && _b !== void 0 ? _b : bundledDoctorsDir() },
|
|
39
47
|
];
|
|
40
48
|
const bySlug = new Map();
|
|
49
|
+
const seenDirs = [];
|
|
41
50
|
for (const { scope, dir } of scopes) {
|
|
51
|
+
// Running inside this repo, the bundled dir IS the repo dir — scan it
|
|
52
|
+
// once, as the repo scope.
|
|
53
|
+
const resolved = path.resolve(dir);
|
|
54
|
+
if (seenDirs.includes(resolved))
|
|
55
|
+
continue;
|
|
56
|
+
seenDirs.push(resolved);
|
|
42
57
|
if (!fs.existsSync(dir))
|
|
43
58
|
continue;
|
|
44
59
|
const files = fs.readdirSync(dir)
|
|
@@ -60,10 +75,11 @@ export async function discoverDoctors(cwd, opts) {
|
|
|
60
75
|
return [...bySlug.values()];
|
|
61
76
|
}
|
|
62
77
|
// Explicit paths (absolute, or containing separators) resolve directly.
|
|
63
|
-
// Bare filenames are slugs: scopes win
|
|
64
|
-
// slug.mjs in the working directory cannot
|
|
78
|
+
// Bare filenames are slugs: scopes win — repo-local first, then global,
|
|
79
|
+
// then bundled — so a stray slug.mjs in the working directory cannot
|
|
80
|
+
// shadow an installed doctor.
|
|
65
81
|
export function resolveDoctorPath(arg, cwd, opts) {
|
|
66
|
-
var _a;
|
|
82
|
+
var _a, _b;
|
|
67
83
|
const bare = path.basename(arg) === arg && !path.isAbsolute(arg);
|
|
68
84
|
if (!bare) {
|
|
69
85
|
const direct = path.resolve(cwd, arg);
|
|
@@ -73,7 +89,11 @@ export function resolveDoctorPath(arg, cwd, opts) {
|
|
|
73
89
|
if (bare) {
|
|
74
90
|
const base = path.basename(arg);
|
|
75
91
|
const repoDir = findRepoDoctorsDir(cwd);
|
|
76
|
-
const scopes = [
|
|
92
|
+
const scopes = [
|
|
93
|
+
repoDir,
|
|
94
|
+
(_a = opts === null || opts === void 0 ? void 0 : opts.globalDir) !== null && _a !== void 0 ? _a : globalDoctorsDir(),
|
|
95
|
+
(_b = opts === null || opts === void 0 ? void 0 : opts.bundledDir) !== null && _b !== void 0 ? _b : bundledDoctorsDir(),
|
|
96
|
+
].filter((d) => Boolean(d));
|
|
77
97
|
for (const dir of scopes) {
|
|
78
98
|
const candidate = path.join(dir, base);
|
|
79
99
|
if (fs.existsSync(candidate))
|
package/bin/doctor-loader.mjs
CHANGED
|
@@ -30,7 +30,7 @@ export function confineProcess() {
|
|
|
30
30
|
// No loader hooks on this runtime — the other confinement layers still apply.
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
const USAGE = "usage: doctor-loader.mjs <program.(m)js> <root> | <program.(m)js> --verify <fixtures.(m)js> | <program.(m)js> --meta";
|
|
33
|
+
const USAGE = "usage: doctor-loader.mjs <program.(m)js> <root> [--include-tests] | <program.(m)js> --verify <fixtures.(m)js> | <program.(m)js> --meta";
|
|
34
34
|
const SEVERITIES = new Set(["error", "warning", "info"]);
|
|
35
35
|
function validateMeta(mod) {
|
|
36
36
|
const m = mod.meta;
|
|
@@ -48,9 +48,9 @@ function validateMeta(mod) {
|
|
|
48
48
|
process.exit(3);
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
function runOnce(root, mod) {
|
|
51
|
+
function runOnce(root, mod, opts) {
|
|
52
52
|
const started = Date.now();
|
|
53
|
-
const { ctx, getFindings } = buildCtx(root);
|
|
53
|
+
const { ctx, getFindings } = buildCtx(root, opts);
|
|
54
54
|
const fileCount = ctx.files.list().length;
|
|
55
55
|
const result = mod.doctor(ctx);
|
|
56
56
|
if (!result || typeof result.then !== "function") {
|
|
@@ -101,7 +101,7 @@ async function main() {
|
|
|
101
101
|
}) + "\n");
|
|
102
102
|
}
|
|
103
103
|
else if (mode.kind === "run") {
|
|
104
|
-
const result = await runOnce(mode.root, mod);
|
|
104
|
+
const result = await runOnce(mode.root, mod, { includeTests: contract.includeTestsFor(mode) });
|
|
105
105
|
process.stdout.write("\n" + contract.RESULT_SENTINEL + JSON.stringify(result) + "\n");
|
|
106
106
|
}
|
|
107
107
|
else {
|
|
@@ -118,7 +118,10 @@ async function main() {
|
|
|
118
118
|
for (const [rel, content] of Object.entries(fixture.seed)) {
|
|
119
119
|
materializeSeed(tmp, rel, content);
|
|
120
120
|
}
|
|
121
|
-
|
|
121
|
+
// Verify always lists everything (includeTestsFor): the sandbox is
|
|
122
|
+
// the doctor's own world — a seed named *.test.ts is deliberate
|
|
123
|
+
// test data (effect-doctor's sleep-in-test depends on it).
|
|
124
|
+
const result = await runOnce(tmp, mod, { includeTests: contract.includeTestsFor(mode) });
|
|
122
125
|
const diff = contract.compareFindings(fixture.expected, result.findings);
|
|
123
126
|
results.push({ name: fixture.name, ok: diff.missing.length === 0 && diff.unexpected.length === 0, ...diff });
|
|
124
127
|
}
|
package/bin/report.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface RunOutcome {
|
|
|
8
8
|
durationMs: number;
|
|
9
9
|
targetDir: string;
|
|
10
10
|
}
|
|
11
|
+
export declare function cohortFileCount(counts: number[]): number;
|
|
11
12
|
export declare function unsafeSkipLine(names: string[]): string;
|
|
12
13
|
export declare function unsafeRefusalLine(name: string, capabilities: readonly string[]): string;
|
|
13
14
|
export declare function dedupeGroups(groups: ReportGroup[]): {
|