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 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) or user-global (`~/.any-doctor/doctors/`, available
120
- in every repo). Repo-local wins slug collisions. Scanning a target 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` in a scope directory and also
127
- served verbatim by `generate`. Any Doctor equips agents with the skill;
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) and copies the generation prompt paste it into your own agent
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–D16). Read first; don't relitigate |
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
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
+ export declare function plantSkill(scopeDir: string, skill: string): "planted" | "refreshed" | "left-user-copy";
2
3
  export declare function main(argv?: string[]): Promise<number>;
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(doctorAbs, targetDir) {
129
- const result = await runOrReport(runDoctor({ programPath: doctorAbs, targetDir }));
130
+ async function scanOnce(options) {
131
+ const result = await runOrReport(runDoctor(options));
130
132
  return {
131
- group: { programName: path.basename(doctorAbs), meta: result.meta, findings: result.findings },
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
- let fileCount = 0;
194
+ const fileCounts = [];
193
195
  for (const d of discovered) {
194
196
  try {
195
- const scan = await scanOnce(d.path, parsed.targetDir);
196
- fileCount = Math.max(fileCount, scan.fileCount);
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 agentsPath = path.join(scopeDir, "AGENTS.md");
328
- if (!fs.existsSync(agentsPath)) {
329
- fs.writeFileSync(agentsPath, skill);
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) and ~/.any-doctor/doctors/ (global)."));
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 (!cmd || cmd === "help" || cmd === "--help") {
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 [programPath, mode.root];
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") {
@@ -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
- fileCount: number;
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 { scoreFromSeverities } from "./score.js";
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 { score, grade } = scoreFromSeverities(findings.map(it => it.severity));
410
+ const header = scoreHeaderLines(state.score);
411
411
  const barWidth = Math.min(46, Math.max(16, cols - 60));
412
- const header = [
413
- c(`Score: ${score} / 100 — ${grade}`, BOLD + gradeColor(score)),
414
- c(scoreBar(score, barWidth), gradeColor(score)),
415
- c(`${findings.length} finding${findings.length === 1 ? "" : "s"} · ${state.fileCount} files · ${state.durationMs}ms`, DIM),
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
- header.push(c(`\u26a0 ${unsafeSkipLine(state.skippedUnsafe)}`, YELLOW));
418
+ headerLines.push(c(`\u26a0 ${unsafeSkipLine(state.skippedUnsafe)}`, YELLOW));
419
419
  }
420
- header.push("");
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 = (_a = sel.impact) !== null && _a !== void 0 ? _a : sel.description;
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", (_b = sel.why) !== null && _b !== void 0 ? _b : "Not documented for this check.", layout.detailWidth - 2, c);
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((_c = listLines[i]) !== null && _c !== void 0 ? _c : "", layout.listWidth), layout.listWidth) + " " + ((_d = detail[i]) !== null && _d !== void 0 ? _d : ""));
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((_e = stacked[i]) !== null && _e !== void 0 ? _e : "");
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 [...header, "", ...body, "", ...footer].join("\n");
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
- // The tree is computed once from immutable items; everything downstream
557
- // rows, prompts, expansion, detail reads this frozen shape.
558
- const tree = buildTree(buildItems(input.outcome.groups));
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
- fileCount: input.outcome.fileCount,
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 - repo-local first - so a stray
64
- // slug.mjs in the working directory cannot shadow an installed doctor.
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 = [repoDir, (_a = opts === null || opts === void 0 ? void 0 : opts.globalDir) !== null && _a !== void 0 ? _a : globalDoctorsDir()].filter((d) => Boolean(d));
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))
@@ -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
- const result = await runOnce(tmp, mod);
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[]): {