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.
- package/CONTEXT.md +172 -21
- package/README.md +111 -48
- package/bin/analysis-host.d.ts +20 -0
- package/bin/analysis-host.js +56 -0
- package/bin/analysis.d.ts +18 -0
- package/bin/analysis.js +143 -0
- package/bin/capabilities.js +1 -47
- package/bin/cli.d.ts +3 -0
- package/bin/cli.js +274 -87
- package/bin/cohort.d.ts +12 -0
- package/bin/cohort.js +43 -0
- package/bin/contract.d.ts +95 -16
- package/bin/contract.js +76 -8
- package/bin/dashboard.d.ts +5 -2
- package/bin/dashboard.js +75 -34
- package/bin/diff.d.ts +16 -0
- package/bin/diff.js +109 -0
- package/bin/discover.d.ts +5 -1
- package/bin/discover.js +31 -5
- package/bin/doctor-loader.mjs +35 -6
- package/bin/engine.d.ts +35 -6
- package/bin/engine.js +82 -10
- package/bin/gate.d.ts +16 -0
- package/bin/gate.js +35 -0
- package/bin/mask.d.ts +1 -0
- package/bin/mask.js +131 -0
- package/bin/palette.d.ts +4 -0
- package/bin/palette.js +11 -1
- package/bin/picker.d.ts +2 -1
- package/bin/picker.js +90 -4
- package/bin/report.d.ts +20 -6
- package/bin/report.js +157 -70
- package/bin/runner.d.ts +19 -0
- package/bin/runner.js +39 -2
- package/bin/score.d.ts +16 -2
- package/bin/score.js +51 -9
- package/bin/sdk.d.ts +6 -1
- package/bin/sdk.js +250 -17
- package/bin/search-host.d.ts +3 -2
- package/bin/search-host.js +40 -11
- package/bin/select.d.ts +1 -0
- package/bin/select.js +10 -4
- package/bin/spinner.d.ts +21 -0
- package/bin/spinner.js +63 -0
- package/bin/summary.d.ts +30 -0
- package/bin/summary.js +98 -0
- package/bin/tty.d.ts +2 -1
- package/bin/tty.js +12 -3
- package/docs/decisions.md +241 -0
- package/docs/research-openrouter-patterns.md +43 -0
- package/docs/research-shadcn-registry.md +61 -0
- package/doctors/async-doctor.fixtures.mjs +282 -1
- package/doctors/async-doctor.mjs +257 -84
- package/doctors/convex-doctor.fixtures.mjs +445 -20
- package/doctors/convex-doctor.mjs +377 -38
- package/doctors/effect-v4-doctor.fixtures.mjs +380 -0
- package/doctors/effect-v4-doctor.mjs +445 -0
- package/doctors/openrouter-doctor.fixtures.mjs +169 -0
- package/doctors/openrouter-doctor.mjs +211 -0
- package/doctors/slop-doctor.fixtures.mjs +417 -0
- package/doctors/slop-doctor.mjs +386 -0
- package/package.json +8 -2
- package/skill/any-doctor.skill.md +198 -13
- package/doctors/AGENTS.md +0 -103
- package/doctors/api-route-files-do-import.fixtures.mjs +0 -61
- package/doctors/api-route-files-do-import.mjs +0 -26
- package/doctors/date-now-used-inside-effect.fixtures.mjs +0 -46
- package/doctors/date-now-used-inside-effect.mjs +0 -132
- package/doctors/json-parse-calls-llm-api.fixtures.mjs +0 -37
- package/doctors/json-parse-calls-llm-api.mjs +0 -85
- package/doctors/route-handlers-touch-database-before.fixtures.mjs +0 -58
- package/doctors/route-handlers-touch-database-before.mjs +0 -98
- package/doctors/z-record-called-with-single.fixtures.mjs +0 -28
- 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
|
|
23
|
-
report and the dashboard alike: the ReportGroups that
|
|
24
|
-
that crashed (data
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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)
|
|
96
|
-
finding is a recall failure; an unexpected
|
|
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)
|
|
120
|
-
in every 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`
|
|
127
|
-
|
|
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
|
-
|
|
6
|
-
that
|
|
7
|
-
|
|
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 wrong — and any-doctor ships with
|
|
7
|
+
five of them covering the disciplines LLMs fumble most.
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
14
|
+
Everything runs locally. No account, no API key, no telemetry, no network.
|
|
15
|
+
|
|
16
|
+
[](https://www.npmjs.com/package/any-doctor)
|
|
17
|
+
[](#)
|
|
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
|
|
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`
|
|
25
|
-
natively) and copies
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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–
|
|
45
|
-
| [docs/vision.md](docs/vision.md) |
|
|
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
|
|
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
|
-
##
|
|
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
|
-
-
|
|
65
|
-
|
|
66
|
-
|
|
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;
|
package/bin/analysis.js
ADDED
|
@@ -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
|
+
}
|