any-doctor 0.0.1 → 0.0.3

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 +172 -21
  2. package/README.md +111 -48
  3. package/bin/analysis-host.d.ts +20 -0
  4. package/bin/analysis-host.js +56 -0
  5. package/bin/analysis.d.ts +18 -0
  6. package/bin/analysis.js +143 -0
  7. package/bin/capabilities.js +1 -47
  8. package/bin/cli.d.ts +3 -0
  9. package/bin/cli.js +274 -87
  10. package/bin/cohort.d.ts +12 -0
  11. package/bin/cohort.js +43 -0
  12. package/bin/contract.d.ts +95 -16
  13. package/bin/contract.js +76 -8
  14. package/bin/dashboard.d.ts +5 -2
  15. package/bin/dashboard.js +75 -34
  16. package/bin/diff.d.ts +16 -0
  17. package/bin/diff.js +109 -0
  18. package/bin/discover.d.ts +5 -1
  19. package/bin/discover.js +31 -5
  20. package/bin/doctor-loader.mjs +35 -6
  21. package/bin/engine.d.ts +35 -6
  22. package/bin/engine.js +82 -10
  23. package/bin/gate.d.ts +16 -0
  24. package/bin/gate.js +35 -0
  25. package/bin/mask.d.ts +1 -0
  26. package/bin/mask.js +131 -0
  27. package/bin/palette.d.ts +4 -0
  28. package/bin/palette.js +11 -1
  29. package/bin/picker.d.ts +2 -1
  30. package/bin/picker.js +90 -4
  31. package/bin/report.d.ts +20 -6
  32. package/bin/report.js +157 -70
  33. package/bin/runner.d.ts +19 -0
  34. package/bin/runner.js +39 -2
  35. package/bin/score.d.ts +16 -2
  36. package/bin/score.js +51 -9
  37. package/bin/sdk.d.ts +6 -1
  38. package/bin/sdk.js +250 -17
  39. package/bin/search-host.d.ts +3 -2
  40. package/bin/search-host.js +40 -11
  41. package/bin/select.d.ts +1 -0
  42. package/bin/select.js +10 -4
  43. package/bin/spinner.d.ts +21 -0
  44. package/bin/spinner.js +63 -0
  45. package/bin/summary.d.ts +30 -0
  46. package/bin/summary.js +98 -0
  47. package/bin/tty.d.ts +2 -1
  48. package/bin/tty.js +12 -3
  49. package/docs/decisions.md +241 -0
  50. package/docs/research-openrouter-patterns.md +43 -0
  51. package/docs/research-shadcn-registry.md +61 -0
  52. package/doctors/async-doctor.fixtures.mjs +282 -1
  53. package/doctors/async-doctor.mjs +257 -84
  54. package/doctors/convex-doctor.fixtures.mjs +445 -20
  55. package/doctors/convex-doctor.mjs +377 -38
  56. package/doctors/effect-v4-doctor.fixtures.mjs +380 -0
  57. package/doctors/effect-v4-doctor.mjs +445 -0
  58. package/doctors/openrouter-doctor.fixtures.mjs +169 -0
  59. package/doctors/openrouter-doctor.mjs +211 -0
  60. package/doctors/slop-doctor.fixtures.mjs +417 -0
  61. package/doctors/slop-doctor.mjs +386 -0
  62. package/package.json +8 -2
  63. package/skill/any-doctor.skill.md +198 -13
  64. package/doctors/AGENTS.md +0 -103
  65. package/doctors/api-route-files-do-import.fixtures.mjs +0 -61
  66. package/doctors/api-route-files-do-import.mjs +0 -26
  67. package/doctors/date-now-used-inside-effect.fixtures.mjs +0 -46
  68. package/doctors/date-now-used-inside-effect.mjs +0 -132
  69. package/doctors/json-parse-calls-llm-api.fixtures.mjs +0 -37
  70. package/doctors/json-parse-calls-llm-api.mjs +0 -85
  71. package/doctors/route-handlers-touch-database-before.fixtures.mjs +0 -58
  72. package/doctors/route-handlers-touch-database-before.mjs +0 -98
  73. package/doctors/z-record-called-with-single.fixtures.mjs +0 -28
  74. package/doctors/z-record-called-with-single.mjs +0 -19
package/CONTEXT.md CHANGED
@@ -19,11 +19,65 @@ results can vary across engine upgrades.
19
19
 
20
20
  ## RunOutcome
21
21
 
22
- One scan invocation's batch of results, assembled once and rendered by the
23
- report and the dashboard alike: the ReportGroups that ran, the doctor ids
24
- that crashed (data, named), the slugs Confinement skipped, the doctor
22
+ One scan invocation's batch of results, assembled once by the Cohort and
23
+ rendered by the report and the dashboard alike: the ReportGroups that
24
+ ran, the doctors that crashed (data — each named, with the full error
25
+ detail riding along), the slugs Confinement skipped, the doctor
25
26
  id → program path map for re-run commands, and the target's file count and
26
- the batch's wall-clock duration — one defined meaning per field.
27
+ the batch's wall-clock duration — one defined meaning per field. A run also
28
+ records whether the identity engine could power it
29
+ (`analysisAvailable`) — the data behind "narrowed" rendering.
30
+
31
+ ## Cohort
32
+
33
+ The module that turns chosen doctor programs plus a target into one
34
+ RunOutcome — a single doctor is a cohort of one, so the single-path and
35
+ batch commands share one semantics. The command layer chooses the
36
+ doctors (a path, the picker, --all) and picks the surface (report or
37
+ dashboard); everything from first spawn to last settle lives behind one
38
+ call: the runner's bounded pool, the crash fold (a crash is data — id
39
+ plus full error detail, never a throw), the process-wide analysis fold,
40
+ the per-doctor paths, the file-count policy, and the timing. Progress
41
+ events (settle order) are the only side channel — the live line renders
42
+ them, it never joins the fold. Skips are a discovery fact, so the
43
+ command layer, which owns discovery, patches `skippedUnsafe` onto the
44
+ outcome.
45
+
46
+ ## Summary
47
+
48
+ The derived view of a RunOutcome — everything a surface renders,
49
+ computed once: the deduplicated severity-ordered groups, the total and
50
+ hidden-duplicate counts, the Score and its header lines, the severity
51
+ counts and category rollup, each group's check buckets, the narrowed
52
+ check ids, and the empty-scan flag. One derivation, N adapters: the
53
+ report string, the dashboard tree, and the JSON surface render it —
54
+ never re-deriving. Pure —
55
+ deriving twice from one RunOutcome yields one Summary; rendering
56
+ (colors, prose, trees) belongs to the adapters, never to the
57
+ derivation. The facts a gate needs (`--fail-on` severity counts,
58
+ baseline-diffable shapes) live here as data, not inside rendering.
59
+
60
+ ## Gate
61
+
62
+ A run's exit policy — one module (src/gate.ts), one law. Findings are
63
+ advisory by default: `--fail-on none` (the default) reports everything
64
+ and exits 0, because a scanner that reds CI on first adoption gets
65
+ uninstalled. `--fail-on error|warning|info` sets a severity bar ("at or
66
+ above") that findings must clear for the run to pass. Crashes and
67
+ Confinement skips fail ALWAYS, regardless of the bar — an
68
+ infrastructure failure is not a finding and must never paint a run
69
+ green. Diff mode (`--base <ref>`) judges only what a change ADDED: the
70
+ same cohort scans the merge base of the ref and HEAD (a stateless
71
+ baseline — nothing committed, nothing stale), the two deduped finding
72
+ sets compare through the verify gate's own rule-aware multiset, and the
73
+ bar counts added findings only; a change is not blamed for the debt it
74
+ was born into. A partial base never gates: any base-scan crash aborts
75
+ the run loudly (exit 1, no report, no JSON), because a baseline
76
+ missing findings would dress pre-existing debt up as "added" — and a
77
+ crashed HEAD doctor skips the diff for the same reason: its findings
78
+ are absent, and absence must never read as "resolved". Machine
79
+ output rides `--format json` — one schema-tagged object on stdout,
80
+ diagnostics on stderr.
27
81
 
28
82
  ## Confinement
29
83
 
@@ -48,11 +102,14 @@ lives in the program's meta, not in individual findings.
48
102
  ## Check
49
103
 
50
104
  One rule within a doctor program. A finding names its check via `rule`;
51
- the check's meta supplies description, severity, impact, why, and fix;
52
- the doctor's meta supplies the defaults when a finding names no check.
53
- A check id is a short kebab-case noun phrase over [a-z0-9-], unique
54
- within its doctor, naming the defect (fetch-calls-without-abortsignal,
55
- filter-table-scan). One doctor program, many checks.
105
+ the check's meta supplies description, severity, impact, why, and fix
106
+ and, when the check uses the identity engine at full power, its
107
+ declaration of that need (`needs`), which is what renders "narrowed"
108
+ when the engine is absent; the doctor's meta supplies the defaults when
109
+ a finding names no check. A check id is a short kebab-case noun phrase
110
+ over [a-z0-9-], unique within its doctor, naming the defect
111
+ (fetch-calls-without-abortsignal, filter-table-scan). One doctor
112
+ program, many checks.
56
113
 
57
114
  ## Doctor contract
58
115
 
@@ -67,14 +124,87 @@ repo-scoped file access, structural search, and a finding emitter.
67
124
  Confinement enforces it: outside ctx there is nothing — no imports, no
68
125
  writes, no subprocesses, no network.
69
126
 
127
+ `ctx.files.list()` and `ctx.search` exclude test paths by default
128
+ (test-named code files — `*.test.*`/`*.spec.*` with a code extension —
129
+ and `test/`, `tests/`, `__tests__/` directories): tests mimic production
130
+ shapes without being production reads. The one law (`isTestPath`) and
131
+ the one derivation (`includeTestsFor`) live in contract.ts; every read
132
+ capability applies them. A Doctor run opts back in with
133
+ `--include-tests`; `ctx.files.read()` is never filtered — an explicit
134
+ path is a deliberate choice. `ctx.files.readMasked()` is the one
135
+ masking implementation (comments and strings blanked, offsets and
136
+ length preserved — a masked position addresses the same char in the
137
+ source); doctors carry no private copies — the bundled pack's remaining
138
+ copies migrate on the recorded triggers. Verify always sees everything
139
+ its fixtures seed: the sandbox is the doctor's own world, and a seed
140
+ named `*.test.ts` is deliberate test data.
141
+
142
+ ## Rule query
143
+
144
+ A composite structural question asked through `ctx.search.rule`: a
145
+ pattern to match, optionally constrained by `inside` (the enclosing
146
+ construct, scanned to that node's end by default — `stopBy`,
147
+ deliberately not ast-grep's own neighbor default). Answers arrive as
148
+ Matches carrying end positions and metavariable captures;
149
+ multi-metavariables (`$$$NAME`) arrive as arrays of nodes under the bare
150
+ NAME, separator commas filtered at the seam. The surface is curated —
151
+ pattern + inside — and validated: unknown keys fail loudly with the
152
+ allowed list. The plural form (`ctx.search.rules`) asks many named
153
+ rules in one engine invocation — every match tagged with its ruleId —
154
+ because each call is a process spawn and batching is how a many-shape
155
+ check stays fast.
156
+
157
+ ## Analysis query
158
+
159
+ An identity question asked through `ctx.analysis`: which **Binding** a
160
+ name resolves to, and every **Reference** to it. `ctx.analysis.bindings(file)`
161
+ returns the file's whole identity model in one answer — every binding
162
+ with its declaration span and its references (positions in ctx.search's
163
+ convention, plus read/write). The engine is optional (oxc-parser +
164
+ eslint-scope behind the Engine seam's second adapter): checks declare
165
+ the analysis they need on their CheckMeta (`needs`), narrow without it,
166
+ and the report renders "narrowed" — a degraded run is visible, never
167
+ silent. References answer by position, so analysis queries compose with
168
+ rule queries: shapes from one engine, identities from the other.
169
+
70
170
  ## Engine
71
171
 
72
172
  The structural-search backend a DoctorCtx uses to answer ctx.search.
73
- ast-grep is the engine today; oxc is a candidate for TypeScript-heavy
74
- repos. Engine selection is invisible to doctor programs: one doctor
75
- program runs unchanged on any engine. One module owns the invocation
76
- (src/engine.ts); the search host sits on it, and the sdk asks the host —
77
- there is exactly one path, with no unconfined fallback.
173
+ ast-grep is the engine today; the identity engine (ctx.analysis) is its
174
+ second adapter oxc-parser + eslint-scope, optional by design. Engine
175
+ selection is invisible to doctor programs: one doctor program runs
176
+ unchanged on any engine. One module owns each invocation
177
+ (src/engine.ts, src/analysis.ts); the search and analysis hosts sit on
178
+ them, and the sdk asks the hosts — there is exactly one path, with no
179
+ unconfined fallback.
180
+
181
+ ## Score
182
+
183
+ The share of scanned files with no findings, weighted by each affected
184
+ file's worst severity (error 1, warning 0.5, info 0.1). One sentence,
185
+ locally computed: "491/628 files clean" is a 78. Zero findings is 100 by
186
+ anchor; an empty scan computes 100 but renders n/a — "Score: n/a — no
187
+ files scanned," yellow tone, empty bar, and no clean claim anywhere
188
+ (nothing was measured, so nothing is Excellent); the report also carries
189
+ the one-copy empty-scan warning naming the extensions and the skips.
190
+ The denominator is the target's file
191
+ count as the doctors scanned it (default extensions); findings naming
192
+ files outside that count can push the raw value negative, so the result
193
+ is floored into 0–100 — and floored, never rounded, so any finding costs
194
+ at least one point. Findings duplicated across doctors at the same
195
+ file:line are deduplicated before scoring — the first-sorted copy wins
196
+ (groups sort by the first finding carrying an explicit severity
197
+ override, else the doctor's declared default; equal-severity groups
198
+ fall back to input order) and a hidden duplicate's severity does not
199
+ contribute. Each doctor also carries its own score — its findings
200
+ against the same denominator — shown on its dashboard row; the
201
+ dashboard header carries the selected doctor's score, never a cohort
202
+ total (a total mixed doctors into one number that read as whichever row
203
+ was on screen). A doctor's score is its own health, not a share of the
204
+ repo score (a file's worst finding counts once repo-wide). The report
205
+ header keeps the repo-wide score — one number for a run is the job
206
+ there. The score summarizes health — the findings are the work; the two
207
+ are reported together, never conflated.
78
208
 
79
209
  ## Meta
80
210
 
@@ -92,8 +222,21 @@ known input.
92
222
  ## Fixture
93
223
 
94
224
  One seed plus the findings expected from running a doctor program against
95
- it. Expected findings match exactly on (file, line): a missing expected
96
- finding is a recall failure; an unexpected finding is a precision failure.
225
+ it. Expected findings match exactly on (rule, file, line), duplicates
226
+ counted: a missing expected finding is a recall failure; an unexpected
227
+ finding is a precision failure. The rule in an expectation is part of the
228
+ match — a wrong-check finding at the right line fails the gate. A fixture
229
+ also declares its analysis mode (D20 Stage 2): "on" (default) pins the
230
+ full-power path and skips with a named notice where the engine is not
231
+ installed; "off" forces the degraded path, whose expectations may
232
+ legitimately differ.
233
+
234
+ ## Counter-fixture
235
+
236
+ A fixture authored adversarially after the doctor is green: lookalikes,
237
+ same-line variants, and semantic traps, each expectation reasoned from the
238
+ intent alone — never from what the doctor currently reports. The generate
239
+ workflow's second pass; a doctor ships only after surviving its attack.
97
240
 
98
241
  ## Verify
99
242
 
@@ -105,7 +248,11 @@ passes.
105
248
 
106
249
  The process boundary that loads a doctor program, injects ctx, and
107
250
  returns framed results. Today a local node child process; the same
108
- contract must hold for any future sandbox.
251
+ contract must hold for any future sandbox. A cohort run also exposes a
252
+ settle-order progress side channel — one event per doctor as it settles
253
+ (out of order under concurrency), carrying programPath, ok, durationMs,
254
+ total — consumed by presentation (the live line); the runner never
255
+ blocks on it and results stay order-preserved.
109
256
 
110
257
  ## Registry
111
258
 
@@ -116,13 +263,17 @@ discovery reads it directly, with no separate index or cache.
116
263
  ## Scope
117
264
 
118
265
  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
266
+ the consuming repo), user-global (`~/.any-doctor/doctors/`, available
267
+ in every repo), or bundled (the first-party pack inside the package,
268
+ read-only — a starting point, not a dependency). Repo-local wins slug
269
+ collisions, then user-global, then bundled. Scanning a target repo
121
270
  never writes to any scope.
122
271
 
123
272
  ## Skill
124
273
 
125
274
  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;
275
+ fits the contract. Planted as `AGENTS.md` behind a one-line provenance
276
+ marker; `generate` refreshes a copy it planted (the marker is the
277
+ boundary) and never touches a copy without one. The generation prompt
278
+ embeds the skill verbatim. Any Doctor equips agents with the skill;
128
279
  it never launches, deploys, or speaks for an agent.
package/README.md CHANGED
@@ -2,67 +2,130 @@
2
2
 
3
3
  > Your agent writes the analyzer. Fixtures prove it. CI reruns it forever.
4
4
 
5
- Any Doctor turns a one-sentence convention ("find `.map(async ...)` results
6
- that are never awaited") into a **doctor program** a small piece of
7
- analysis code your LLM writes against our typed `ctx` API. The CLI runs it
8
- and renders a React-Doctor-style report: score, grouped findings with
9
- file:line evidence, declared blind spots, an interactive review browser,
10
- and a copy-paste handoff so your agent can fix what was found. The saved
11
- program reruns deterministically in CI with zero inference.
5
+ Your LLM writes code fast and roughly. **Doctors** are small deterministic
6
+ programs that catch what it keeps getting wrongand any-doctor ships with
7
+ five of them covering the disciplines LLMs fumble most.
12
8
 
13
- Any Doctor equips agents; it never deploys them. Everything runs locally.
14
- No Cloudflare, no API keys, no server.
9
+ When none of them covers the convention you keep explaining in code review,
10
+ your agent writes a new doctor for it — against a typed `ctx` API, gated by
11
+ fixtures, rerun forever in CI with zero inference. **A skill without a doctor
12
+ is a suggestion.**
15
13
 
16
- ## Usage
14
+ Everything runs locally. No account, no API key, no telemetry, no network.
15
+
16
+ [![npm version](https://img.shields.io/npm/v/any-doctor.svg)](https://www.npmjs.com/package/any-doctor)
17
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](#)
18
+
19
+ ## Quick start
20
+
21
+ ```bash
22
+ npx any-doctor@latest
23
+ ```
24
+
25
+ That's it — a selector lists every doctor (bundled plus anything in your
26
+ repo's `doctors/`), you pick with the space bar, `a` takes all, and the
27
+ findings land in an interactive review tree: doctors → checks → findings,
28
+ each with impact, why, fix, and the honest blind spots. `enter` copies a
29
+ fix prompt for your agent.
30
+
31
+ ```bash
32
+ npx any-doctor@latest run slop-doctor # one doctor, straight to the report
33
+ ```
34
+
35
+ Non-terminals and CI never see a prompt — output is stable and pipeable
36
+ (`--format json`, or `ANY_DOCTOR_HEADLESS=1`).
37
+
38
+ ## The bundled pack
39
+
40
+ | Doctor | Discipline | Checks |
41
+ |---|---|---:|
42
+ | **slop-doctor** | The recurring failures of LLM-written code: identical helpers copied across modules, dead exports, unread bindings, hostname-sniffed environments, careless substring matching, collapsed boolean states | 8 |
43
+ | **convex-doctor** | Convex discipline: indexed reads, bounded collects, validated args, awaited writes, honest runtime boundaries | 15 |
44
+ | **effect-v4-doctor** | Effect v4 discipline — the mechanical rules of the [kitlangton Effect skill](https://www.ui-skills.com/skills/kitlangton/effect), enforced | 10 |
45
+ | **openrouter-doctor** | OpenRouter discipline: stream errors surfaced, keep-alives skipped, cancellations that stop billing | 5 |
46
+ | **async-doctor** | Async and concurrency: dropped promise results, uncleared timers, fetch hygiene | 3 |
47
+
48
+ Every check ships with fixtures proving both directions — it fires on the
49
+ shapes it claims (recall) and stays silent on innocent lookalikes
50
+ (precision). 144 fixtures across the pack, run by `verify` as an exact
51
+ multiset on `rule:file:line`. slop-doctor's checks were born from 1,368
52
+ Bugbot findings across 182 reviewed PRs — every check carries its evidence.
53
+
54
+ ## Write your own
17
55
 
18
56
  ```bash
19
- any-doctor generate "find fetch calls without an AbortSignal" # copies the exact prompt for your agent
20
- any-doctor verify doctors/fetch-without-abort-signal.mjs # fixture gate (exact-set)
21
- any-doctor run doctors/fetch-without-abort-signal.mjs path/to/repo # scan + score + report + review menu
57
+ npx any-doctor@latest generate "find .map(async ...) results that are never awaited"
22
58
  ```
23
59
 
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
26
- session, any agent, GUI or CLI. When it has written the doctor + fixtures,
27
- `verify` gates it: missing expected findings fail recall, unexpected ones
28
- fail precision. `run` and `verify` never touch a model or an agent — pipe
29
- the output (or set `ANY_DOCTOR_HEADLESS=1`) for stable CI output. Requires
30
- Node 18.
31
-
32
- A doctor program is `<name>.mjs` (exports `meta` + `doctor(ctx)`) next to
33
- its fixture module `<name>.fixtures.mjs` (seeds + expected findings).
34
- See [CONTEXT.md](CONTEXT.md) for the vocabulary and
35
- [doctors/async-doctor.mjs](doctors/async-doctor.mjs) for a working
36
- example: one doctor, many checks — async hygiene as a category, with
37
- per-check fixtures and the interactive check tree in `run`.
60
+ `generate` plants the authoring skill as `AGENTS.md` (agents load it
61
+ natively) and copies a prompt. Paste it into any agent session. The agent
62
+ writes two files:
63
+
64
+ - `doctors/your-doctor.mjs` the analyzer: `meta` (checks, blind spots,
65
+ severity) + `doctor(ctx)`. One self-contained file; `ctx` is its entire
66
+ world (files, structural search via ast-grep, binding analysis, findings).
67
+ - `doctors/your-doctor.fixtures.mjs` — the proof: seed codebases plus the
68
+ findings the doctor must produce, and the lookalikes it must ignore.
69
+
70
+ Then the loop agents love:
71
+
72
+ ```bash
73
+ npx any-doctor@latest verify doctors/your-doctor.mjs
74
+ # ✖ flags a bare discarded map result
75
+ # missing expected finding src/a.ts:3 ← your error list
76
+ # ✔ accepts an awaited result
77
+ ```
78
+
79
+ `missing` fails recall, `unexpected` fails precision, the exit code stays
80
+ non-zero until every fixture passes — and the skill teaches the sharp edges
81
+ (scope-analysis semantics, fixture-seed worlds, the adversarial
82
+ counter-fixture pass) so the loop converges fast. No install needed;
83
+ `verify` sandboxes everything.
84
+
85
+ ## CI: the gate
86
+
87
+ ```bash
88
+ npx any-doctor@latest run --all --fail-on warning --base origin/main
89
+ ```
90
+
91
+ - `--fail-on none|error|warning|info` — the severity bar
92
+ - `--base <git ref>` — a stateless diff baseline: **only findings your
93
+ change introduced fail the build**, not your existing backlog
94
+ - `--format json` — machine output for pipelines
95
+
96
+ ```yaml
97
+ # .github/workflows/doctor.yml
98
+ - run: npx any-doctor@latest run --all --fail-on warning --base origin/main
99
+ ```
100
+
101
+ ## Why trust a finding
102
+
103
+ - **Deterministic.** The same doctor on the same commit produces the same
104
+ report — no model runs at scan time, ever.
105
+ - **Fixtures gate everything.** A doctor that hasn't passed `verify` doesn't
106
+ exist; discovery lists it as broken, not as a tool.
107
+ - **Blind spots are data.** Every doctor declares what it cannot see, and
108
+ the report renders those declarations beside the findings.
109
+ - **Doctors are confined.** The runtime capability gate refuses to execute
110
+ a doctor that imports, writes, spawns, or touches the network — a
111
+ malicious doctor is refused before it runs, with no override.
112
+ - **The score is the share of clean files**, per doctor and overall —
113
+ health and work are reported together, never conflated.
38
114
 
39
115
  ## Docs
40
116
 
41
117
  | Doc | What it holds |
42
118
  |---|---|
119
+ | [skill/any-doctor.skill.md](skill/any-doctor.skill.md) | The authoring contract — what your agent reads to write doctors |
43
120
  | [CONTEXT.md](CONTEXT.md) | Domain glossary — canonical terms |
44
- | [docs/decisions.md](docs/decisions.md) | Decision log (D1–D16). Read first; don't relitigate |
45
- | [docs/vision.md](docs/vision.md) | Product idea and the lifecycle novelty |
46
- | [docs/features.md](docs/features.md) | Doctor discovery & registry spec + status |
47
- | [docs/research.md](docs/research.md) | Landscape, React Doctor teardown |
121
+ | [docs/decisions.md](docs/decisions.md) | Decision log (D1–D20). Read first; don't relitigate |
122
+ | [docs/vision.md](docs/vision.md) | The product idea and lifecycle novelty |
48
123
  | [docs/kill-test.md](docs/kill-test.md) + [docs/RESULTS.md](docs/RESULTS.md) | The validation experiment and its numbers |
49
- | [docs/REPAIR-LOG.md](docs/REPAIR-LOG.md) | Generation-bug categories — feeds the generation skill |
124
+ | [docs/REPAIR-LOG.md](docs/REPAIR-LOG.md) | Generation-bug categories — feeds the authoring skill |
50
125
  | [docs/example-catalog.md](docs/example-catalog.md) | Rule intents across the JS ecosystem |
51
126
 
52
- ## Where we are (2026-09-03)
53
-
54
- 1. ✅ Contract v0, verify harness, pilot doctor, kill test, packaging
55
- 2. ✅ Generation: skill + prompt handoff (D14 — copy-based, agent-agnostic)
56
- 3. ✅ UI layer: score header, category rollup, review browser, post-report menu
57
- 4. ✅ Discovery & registry: fuzzy picker, repo-local + global scopes, `--all` batch modes
58
- 5. ⬜ Frozen-fixture protocol + authored eval corpus (the trust upgrade — first-shot 10/10 is currently self-graded)
59
- 6. ⬜ Grow `ctx`: symbols/imports resolution, JS-family languages
60
- 7. ⬜ Publish: npm, GitHub, CI workflow, launch post
61
-
62
- ## Principles
127
+ ## Status
63
128
 
64
- - The report comes from the generated program's evidence, not the LLM's opinion.
65
- - Fixtures gate everything: a doctor that hasn't passed `verify` doesn't exist.
66
- - Programs declare their blind spots as data.
67
- - Any Doctor equips agents; it never launches, deploys, or speaks for them.
68
- - Everything runs locally. The runner seam (today a node child process) is where any future sandbox plugs in.
129
+ Pre-1.0 and moving fast the decision log is the honest history. Next on
130
+ the ladder: the registry (`any-doctor add <slug>`), adoption (`init`), and
131
+ a growing pack. MIT.
@@ -0,0 +1,20 @@
1
+ import { AnalysisFile, Mode } from "./contract.js";
2
+ import { analysisStatus, analyzeBindings } from "./analysis.js";
3
+ type Analyzer = typeof analyzeBindings;
4
+ type Status = typeof analysisStatus;
5
+ export declare function clearAnalysisCache(): void;
6
+ export interface AnalysisRequestBody {
7
+ kind?: unknown;
8
+ file?: unknown;
9
+ root?: unknown;
10
+ }
11
+ export type AnalysisResponse = {
12
+ available: boolean;
13
+ reason?: string;
14
+ } | {
15
+ file: AnalysisFile;
16
+ } | {
17
+ error: string;
18
+ };
19
+ export declare function handleAnalysisRequest(req: AnalysisRequestBody, mode: Mode, analyzer?: Analyzer, status?: Status): AnalysisResponse;
20
+ export {};
@@ -0,0 +1,56 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { analysisStatus, analyzeBindings } from "./analysis.js";
4
+ import { searchBase, withinBase } from "./search-host.js";
5
+ // One cache per host process. The host lives in the runner process, so
6
+ // the lifetime is the any-doctor invocation; across a cohort's doctors
7
+ // the same unchanged file answers from memory.
8
+ const modelCache = new Map();
9
+ // Test seam: the model cache is keyed by mtime+size for the process
10
+ // lifetime; tests bust it between cases. Invisible to slop-doctor's
11
+ // default run (test-file consumers are the documented narrowing).
12
+ export function clearAnalysisCache() {
13
+ modelCache.clear();
14
+ }
15
+ export function handleAnalysisRequest(req, mode, analyzer = analyzeBindings, status = analysisStatus) {
16
+ const base = searchBase(mode);
17
+ const root = typeof req.root === "string" ? path.resolve(req.root) : "";
18
+ if (base === "" || !withinBase(root, base)) {
19
+ return { error: "ctx.analysis failed: analysis root is outside the allowed target" };
20
+ }
21
+ if (req.kind === "available") {
22
+ const s = status();
23
+ return s.available ? { available: true } : { available: false, reason: s.reason };
24
+ }
25
+ if (req.kind === "bindings") {
26
+ if (typeof req.file !== "string" || req.file === "") {
27
+ return { error: 'ctx.analysis.bindings needs a "file" path' };
28
+ }
29
+ const abs = path.resolve(root, req.file);
30
+ if (!withinBase(abs, root)) {
31
+ return { error: `ctx.analysis failed: file is outside the search root: ${req.file}` };
32
+ }
33
+ let source;
34
+ let mtimeMs;
35
+ let size;
36
+ try {
37
+ const stat = fs.statSync(abs);
38
+ mtimeMs = stat.mtimeMs;
39
+ size = stat.size;
40
+ const cached = modelCache.get(abs);
41
+ if (cached && cached.mtimeMs === mtimeMs && cached.size === size)
42
+ return { file: cached.file };
43
+ source = fs.readFileSync(abs, "utf8");
44
+ }
45
+ catch {
46
+ return { error: `ctx.analysis failed: cannot read ${req.file}` };
47
+ }
48
+ const rel = path.relative(root, abs);
49
+ const r = analyzer(rel, source);
50
+ if (!r.ok)
51
+ return { error: r.error };
52
+ modelCache.set(abs, { mtimeMs, size, file: r.file });
53
+ return { file: r.file };
54
+ }
55
+ return { error: `unknown analysis kind ${JSON.stringify(req.kind)} — known kinds: available, bindings` };
56
+ }
@@ -0,0 +1,18 @@
1
+ import { AnalysisFile, BindingInfo, BindingRef } from "./contract.js";
2
+ export type { AnalysisFile, BindingInfo, BindingRef };
3
+ export interface AnalysisStatus {
4
+ available: true;
5
+ }
6
+ export type AnalysisStatusResult = AnalysisStatus | {
7
+ available: false;
8
+ reason: string;
9
+ };
10
+ export declare function analysisStatus(): AnalysisStatusResult;
11
+ export type AnalysisResult = {
12
+ ok: true;
13
+ file: AnalysisFile;
14
+ } | {
15
+ ok: false;
16
+ error: string;
17
+ };
18
+ export declare function analyzeBindings(file: string, source: string): AnalysisResult;
@@ -0,0 +1,143 @@
1
+ import { createRequire } from "module";
2
+ let loaded = null;
3
+ const require_ = createRequire(import.meta.url);
4
+ function loadStack() {
5
+ if (loaded !== null)
6
+ return loaded;
7
+ try {
8
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
9
+ const { parseSync } = require_("oxc-parser");
10
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
11
+ const { analyze } = require_("eslint-scope");
12
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
13
+ const keys = require_("eslint-visitor-keys");
14
+ loaded = { parseSync, analyze, keys };
15
+ }
16
+ catch (e) {
17
+ loaded = { error: `the analysis engine is not installed (${e instanceof Error ? e.message : String(e)}) — npm install oxc-parser` };
18
+ }
19
+ return loaded;
20
+ }
21
+ export function analysisStatus() {
22
+ const stack = loadStack();
23
+ return stack.error !== undefined ? { available: false, reason: stack.error } : { available: true };
24
+ }
25
+ // One file in, one identity model out: every binding (declarations,
26
+ // parameters, imports) with the span of its declaring node and every
27
+ // reference to it, read or write. Type-position identifiers never become
28
+ // references (eslint-scope only resolves value positions); TS-only
29
+ // declarations (enums, namespaces) are not modeled — declared blind-spot
30
+ // territory for checks that care.
31
+ export function analyzeBindings(file, source) {
32
+ var _a;
33
+ const stack = loadStack();
34
+ if (stack.error !== undefined)
35
+ return { ok: false, error: stack.error };
36
+ let program;
37
+ try {
38
+ program = stack.parseSync(file, source, { sourceType: "module" }).program;
39
+ }
40
+ catch (e) {
41
+ return { ok: false, error: `analysis failed to parse ${file}: ${e instanceof Error ? e.message : String(e)}` };
42
+ }
43
+ addRanges(program);
44
+ let scopeManager;
45
+ try {
46
+ scopeManager = stack.analyze(program, {
47
+ sourceType: "module",
48
+ ecmaVersion: 2026,
49
+ childVisitorKeys: stack.keys.KEYS,
50
+ });
51
+ }
52
+ catch (e) {
53
+ return { ok: false, error: `analysis failed to resolve scopes in ${file}: ${e instanceof Error ? e.message : String(e)}` };
54
+ }
55
+ const pos = positioner(source);
56
+ const bindings = [];
57
+ const global = scopeManager.globalScope;
58
+ if (global === null)
59
+ return { ok: false, error: `analysis failed to resolve scopes in ${file}` };
60
+ for (const scope of allScopes(global)) {
61
+ for (const variable of scope.variables) {
62
+ const def = variable.defs[0];
63
+ if (def === undefined)
64
+ continue; // builtins and implicit globals carry no def
65
+ // The declaration's own extent: for variables the declarator (so a
66
+ // binding's span contains its initializer), for parameters the
67
+ // identifier itself (eslint-scope hands the whole function node for
68
+ // params, which would swallow the body).
69
+ const node = def.node;
70
+ const span = def.type === "Parameter" ? def.name.range : ((_a = node === null || node === void 0 ? void 0 : node.range) !== null && _a !== void 0 ? _a : def.name.range);
71
+ if (span === undefined)
72
+ continue;
73
+ bindings.push({
74
+ name: variable.name,
75
+ kind: def.type,
76
+ line: pos.line(span[0]),
77
+ column: pos.column(span[0]),
78
+ endLine: pos.line(span[1]),
79
+ endColumn: pos.column(span[1]),
80
+ references: variable.references
81
+ .filter((r) => r.identifier.range !== undefined)
82
+ .map((r) => ({
83
+ line: pos.line(r.identifier.range[0]),
84
+ column: pos.column(r.identifier.range[0]),
85
+ endLine: pos.line(r.identifier.range[1]),
86
+ endColumn: pos.column(r.identifier.range[1]),
87
+ write: r.isWrite(),
88
+ })),
89
+ });
90
+ }
91
+ }
92
+ return { ok: true, file: { file, bindings } };
93
+ }
94
+ // eslint-scope expects `range: [start, end]` on nodes; oxc emits start/end.
95
+ function addRanges(node) {
96
+ if (!node || typeof node !== "object")
97
+ return;
98
+ const n = node;
99
+ if (typeof n.start === "number" && typeof n.end === "number")
100
+ n.range = [n.start, n.end];
101
+ for (const key of Object.keys(n)) {
102
+ if (key === "range" || key === "start" || key === "end")
103
+ continue;
104
+ const v = n[key];
105
+ if (Array.isArray(v)) {
106
+ for (const child of v)
107
+ addRanges(child);
108
+ }
109
+ else if (v && typeof v === "object" && typeof v.type === "string") {
110
+ addRanges(v);
111
+ }
112
+ }
113
+ }
114
+ function allScopes(scope, out = []) {
115
+ out.push(scope);
116
+ for (const child of scope.childScopes)
117
+ allScopes(child, out);
118
+ return out;
119
+ }
120
+ // Byte offset → ctx position conventions, one index per file.
121
+ function positioner(source) {
122
+ const starts = [0];
123
+ for (let i = 0; i < source.length; i += 1) {
124
+ if (source[i] === "\n")
125
+ starts.push(i + 1);
126
+ }
127
+ return {
128
+ line: (offset) => lowerBound(starts, offset) + 1,
129
+ column: (offset) => offset - starts[lowerBound(starts, offset)],
130
+ };
131
+ }
132
+ function lowerBound(sorted, value) {
133
+ let lo = 0;
134
+ let hi = sorted.length - 1;
135
+ while (lo < hi) {
136
+ const mid = (lo + hi + 1) >> 1;
137
+ if (sorted[mid] <= value)
138
+ lo = mid;
139
+ else
140
+ hi = mid - 1;
141
+ }
142
+ return lo;
143
+ }