any-doctor 0.1.0 → 0.1.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/README.md CHANGED
@@ -32,7 +32,7 @@ each with impact, why, fix, and the honest blind spots. `enter` copies a
32
32
  fix prompt for your agent.
33
33
 
34
34
  ```bash
35
- npx any-doctor@latest run slop-doctor # one doctor, straight to the report
35
+ npx any-doctor@latest run slop # one doctor, straight to the report
36
36
  ```
37
37
 
38
38
  Non-terminals and CI never see a prompt — output is stable and pipeable
@@ -42,11 +42,11 @@ Non-terminals and CI never see a prompt — output is stable and pipeable
42
42
 
43
43
  | Doctor | Discipline | Checks |
44
44
  |---|---|---:|
45
- | **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 |
46
- | **convex-doctor** | Convex discipline: indexed reads, bounded collects, validated args, awaited writes, honest runtime boundaries | 15 |
47
- | **effect-v4-doctor** | Effect v4 discipline — the mechanical rules of the [kitlangton Effect skill](https://www.ui-skills.com/skills/kitlangton/effect), enforced | 10 |
48
- | **openrouter-doctor** | OpenRouter discipline: stream errors surfaced, keep-alives skipped, cancellations that stop billing | 5 |
49
- | **async-doctor** | Async and concurrency: dropped promise results, uncleared timers, fetch hygiene | 3 |
45
+ | **slop** | 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 |
46
+ | **convex** | Convex discipline: indexed reads, bounded collects, validated args, awaited writes, honest runtime boundaries | 15 |
47
+ | **effect-v4-kitlangton** | Effect v4 discipline — the mechanical rules of the [kitlangton Effect skill](https://www.ui-skills.com/skills/kitlangton/effect), enforced | 10 |
48
+ | **openrouter** | OpenRouter discipline: stream errors surfaced, keep-alives skipped, cancellations that stop billing | 5 |
49
+ | **async** | Async and concurrency: dropped promise results, uncleared timers, fetch hygiene | 3 |
50
50
 
51
51
  Checks ship positive and innocent-lookalike fixtures. `verify` compares an
52
52
  exact multiset of rule/file/line and optional column, then runs shared innocent
@@ -9,7 +9,7 @@ const modelCache = new Map();
9
9
  const callsCache = new Map();
10
10
  const spansCache = new Map();
11
11
  // Test seam: the model cache is keyed by mtime+size for the process
12
- // lifetime; tests bust it between cases. Invisible to slop-doctor's
12
+ // lifetime; tests bust it between cases. Invisible to slop's
13
13
  // default run (test-file consumers are the documented narrowing).
14
14
  export function clearAnalysisCache() {
15
15
  modelCache.clear();
package/bin/certify.js CHANGED
@@ -161,7 +161,7 @@ export async function certify(mod, fixtures) {
161
161
  }
162
162
  // Verify always lists everything (includeTestsFor): the sandbox is
163
163
  // the doctor's own world — a seed named *.test.ts is deliberate
164
- // test data (effect-v4-doctor's sleep-in-test depends on it).
164
+ // test data (effect-v4-kitlangton's sleep-in-test depends on it).
165
165
  const result = await inSandbox(fixture.seed, (tmp) => runOnce(tmp, mod, { includeTests: true }));
166
166
  const diff = contract.compareFindings(fixture.expected, result.findings);
167
167
  const row = { name: fixture.name, ok: diff.missing.length === 0 && diff.unexpected.length === 0, ...diff };
package/bin/cli.js CHANGED
@@ -16,7 +16,7 @@ import { deriveSummary } from "./summary.js";
16
16
  import { copyToClipboard } from "./clipboard.js";
17
17
  import { runDashboard } from "./dashboard.js";
18
18
  import { brokenDoctors, discoverDoctors, globalDoctorsDir, resolveDoctorPath, unsafeSlugs, scopeLabel } from "./discover.js";
19
- import { causeSummaryLine, describeRunnerError, isRunnerError, verifyDoctor } from "./runner.js";
19
+ import { causeSummaryLine, describeRunnerError, isRunnerError, metaDoctor, verifyDoctor } from "./runner.js";
20
20
  import { scanDoctorFile, capabilitySummary } from "./capabilities.js";
21
21
  import { selectDoctor } from "./select.js";
22
22
  import { pickItemsOn } from "./picker.js";
@@ -151,8 +151,10 @@ async function runOrReport(work) {
151
151
  }
152
152
  }
153
153
  function warnBrokenDoctors(skipped) {
154
+ // stderr, always: a console.log here put the warning INSIDE --format
155
+ // json stdout, corrupting the machine surface (agent-review probe).
154
156
  for (const b of skipped) {
155
- console.log(YELLOW + "\u26a0 skipping broken doctor " + b.slug + RESET + dim(" — " + causeSummaryLine(b.cause)));
157
+ warn("\u26a0 skipping broken doctor " + b.slug + dim(" — " + causeSummaryLine(b.cause)));
156
158
  }
157
159
  }
158
160
  function selectionOutcome(sel) {
@@ -306,14 +308,19 @@ function wantsTui(parsed) {
306
308
  // partial), the gate's bar judges findings, skips fail quietly. Each
307
309
  // surface calls this where its timing wants the lines printed.
308
310
  function exitAfterSurface(outcome, gate) {
309
- var _a;
311
+ var _a, _b, _c, _d;
310
312
  if (outcome.crashed.length > 0) {
311
313
  for (const c of outcome.crashed)
312
314
  fail(`doctor crashed (results above are partial): ${c.id}`);
313
315
  return 1;
314
316
  }
317
+ if (((_b = (_a = outcome.broken) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) > 0) {
318
+ for (const b of (_c = outcome.broken) !== null && _c !== void 0 ? _c : [])
319
+ fail(`broken doctor (not scanned): ${b.id}`);
320
+ return 1;
321
+ }
315
322
  if (gate.fails) {
316
- fail((_a = gate.reason) !== null && _a !== void 0 ? _a : "gate failed");
323
+ fail((_d = gate.reason) !== null && _d !== void 0 ? _d : "gate failed");
317
324
  return 1;
318
325
  }
319
326
  return outcome.skippedUnsafe.length > 0 ? 1 : 0;
@@ -348,6 +355,7 @@ async function cmdRun(args) {
348
355
  // Cohort, which owns everything from first spawn to last settle.
349
356
  let doctors;
350
357
  let skippedUnsafe;
358
+ let broken = [];
351
359
  // The live line is mode-based, not count-based: an explicit path is a
352
360
  // scan of one (label + elapsed, no counts, no settle notes); picker
353
361
  // and --all are cohort runs (counts + per-settle notes) even when the
@@ -367,14 +375,27 @@ async function cmdRun(args) {
367
375
  // report use.
368
376
  doctors = [{ id: path.basename(selection.doctorPath, ".mjs"), programPath: selection.doctorPath }];
369
377
  skippedUnsafe = [];
378
+ broken = sel.kind === "doctor" ? sel.skipped : [];
370
379
  }
371
380
  else {
372
381
  const cohort = await gatherDoctors();
373
382
  warnBrokenDoctors(cohort.broken);
374
- if (cohortUnusable(cohort))
383
+ if (cohortUnusable(cohort)) {
384
+ // Structured failure even here: the JSON surface still gets its
385
+ // object with the broken array (agent-review probe: stdout was
386
+ // empty when every doctor was broken).
387
+ if (parsed.format === "json") {
388
+ const failed = {
389
+ groups: [], crashed: [], broken: cohort.broken.map(b => ({ id: b.slug, detail: causeSummaryLine(b.cause) })),
390
+ skippedUnsafe: cohort.skippedUnsafe, doctorPaths: new Map(), fileCount: 0, durationMs: 0, targetDir: parsed.targetDir,
391
+ };
392
+ console.log(renderJson(failed, deriveSummary(failed), gateVerdict(parsed.failOn, { error: 0, warning: 0, info: 0 }, "full")));
393
+ }
375
394
  return 1;
395
+ }
376
396
  let valid = cohort.valid;
377
397
  skippedUnsafe = cohort.skippedUnsafe;
398
+ broken = cohort.broken;
378
399
  // The cold start is opt-in: the selector opens with nothing
379
400
  // pre-selected, space selects, a selects every filtered row, and
380
401
  // Enter runs the selection — narrowing to one doctor is one space,
@@ -399,7 +420,7 @@ async function cmdRun(args) {
399
420
  // rejects the batch must not leave a hidden cursor behind. Settle
400
421
  // notes name doctors by the spec's ids — one naming rule, shared with
401
422
  // the crash report.
402
- const spec = { doctors, targetDir: parsed.targetDir, includeTests: parsed.includeTests, skippedUnsafe };
423
+ const spec = { doctors, targetDir: parsed.targetDir, includeTests: parsed.includeTests, skippedUnsafe, broken };
403
424
  const idOf = new Map(doctors.map(d => [d.programPath, d.id]));
404
425
  // JSON mode paints nothing on stdout — not even the live line. The
405
426
  // picker and dashboard get the same refusal from wantsTui; the
@@ -429,6 +450,13 @@ async function cmdRun(args) {
429
450
  finally {
430
451
  spin === null || spin === void 0 ? void 0 : spin.stop();
431
452
  }
453
+ // Broken doctors fail ALWAYS, like crashes: a doctor whose program
454
+ // cannot even be read is an infrastructure failure, and a local broken
455
+ // file shadowing a bundled doctor must never read as that doctor
456
+ // scanning clean (the agent-review probe: exit 0, score 100).
457
+ for (const b of broken) {
458
+ warn("\u26a0 broken doctor " + b.slug + " — " + causeSummaryLine(b.cause));
459
+ }
432
460
  // Crash detail prints before any surface: "details above" in the
433
461
  // report's every-crashed line stays true, and the dashboard's own
434
462
  // rendering (findings and skips, not crashes) stays clean.
@@ -510,12 +538,16 @@ async function cmdRun(args) {
510
538
  // renders findings and skips, not crashes — and the dashboard ignores
511
539
  // diff mode: it is the review experience, not the gate.
512
540
  const code = exitAfterSurface(outcome, gate);
541
+ const stateExistedBeforeDashboard = fs.existsSync(decisionsPath(parsed.targetDir));
513
542
  await runDashboard({
514
543
  outcome,
515
544
  invoker,
516
545
  useColor: useColor(),
517
546
  ...(review !== undefined ? { view: review } : {}),
518
547
  });
548
+ if (!stateExistedBeforeDashboard && fs.existsSync(decisionsPath(parsed.targetDir))) {
549
+ console.log(dim("tip: add the agent workflow to this repo's AGENTS.md so your agents use decisions — 'any-doctor help agents' prints ready-to-paste markdown"));
550
+ }
519
551
  return code;
520
552
  }
521
553
  async function cmdVerify(args) {
@@ -724,7 +756,7 @@ function parseDecideArgs(args) {
724
756
  return out;
725
757
  }
726
758
  async function cmdDecide(args) {
727
- var _a, _b, _c, _d, _e;
759
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
728
760
  const parsed = parseDecideArgs(args);
729
761
  if ("error" in parsed) {
730
762
  fail("any-doctor decide: " + parsed.error);
@@ -762,7 +794,15 @@ async function cmdDecide(args) {
762
794
  : doctorId !== undefined ? resolveDoctorPath(doctorId, process.cwd()) : null;
763
795
  if (slug !== null) {
764
796
  const bytes = digestTextFile(slug);
765
- scanProvenanceAtDecide = { programDigest: doctorDigests([{ id: doctorId !== null && doctorId !== void 0 ? doctorId : "x", programPath: slug }], () => bytes)[0].digest };
797
+ // Prefer the check's declared semantic revision (the churn escape):
798
+ // a --key decision must survive cosmetic doctor edits exactly like
799
+ // a scan-resolved one — digest-only recording churned (smoke catch).
800
+ const metaRead = await metaDoctor({ programPath: slug });
801
+ const checkId = (_b = key.split("\u0000")[0]) === null || _b === void 0 ? void 0 : _b.split("/")[1];
802
+ const declared = (_e = (_d = (_c = metaRead.meta) === null || _c === void 0 ? void 0 : _c.checks) === null || _d === void 0 ? void 0 : _d.find(ch => ch.id === checkId)) === null || _e === void 0 ? void 0 : _e.revision;
803
+ scanProvenanceAtDecide = declared !== undefined
804
+ ? { revision: declared }
805
+ : { programDigest: doctorDigests([{ id: doctorId !== null && doctorId !== void 0 ? doctorId : "x", programPath: slug }], () => bytes)[0].digest };
766
806
  }
767
807
  }
768
808
  let checkKey = "";
@@ -803,7 +843,7 @@ async function cmdDecide(args) {
803
843
  return 1;
804
844
  }
805
845
  const groups = deriveSummary(ran).groups;
806
- const capture = captureScan(parsed.targetDir, groups, (_b = ran.analysisAvailable) !== null && _b !== void 0 ? _b : false, []);
846
+ const capture = captureScan(parsed.targetDir, groups, (_f = ran.analysisAvailable) !== null && _f !== void 0 ? _f : false, []);
807
847
  const scanProv = scanProvenanceOf(spec, deriveSummary(ran).groups);
808
848
  const view = reviewOf(capture, [], encodeDecisionKey, scanProv);
809
849
  const candidates = capture.entries
@@ -838,8 +878,8 @@ async function cmdDecide(args) {
838
878
  const recorded = recordDecision(parsed.targetDir, {
839
879
  key: key,
840
880
  checkKey: checkKey !== "" ? checkKey : keyCheck,
841
- file: (_d = (_c = parsed.file) !== null && _c !== void 0 ? _c : keyFile) !== null && _d !== void 0 ? _d : "",
842
- line: (_e = parsed.line) !== null && _e !== void 0 ? _e : 0,
881
+ file: (_h = (_g = parsed.file) !== null && _g !== void 0 ? _g : keyFile) !== null && _h !== void 0 ? _h : "",
882
+ line: (_j = parsed.line) !== null && _j !== void 0 ? _j : 0,
843
883
  disposition: parsed.disposition,
844
884
  reason: parsed.reason,
845
885
  actor: parsed.actor,
package/bin/cohort.d.ts CHANGED
@@ -9,6 +9,7 @@ export interface CohortSpec {
9
9
  targetDir: string;
10
10
  includeTests: boolean;
11
11
  skippedUnsafe?: readonly string[];
12
+ broken?: readonly import("./discover.js").BrokenDoctor[];
12
13
  }
13
14
  export type DoctorExecutor = typeof runDoctorCohort;
14
15
  export declare function runCohort(spec: CohortSpec, onProgress?: (p: CohortProgress) => void, exec?: DoctorExecutor): Promise<RunOutcome>;
package/bin/cohort.js CHANGED
@@ -2,7 +2,7 @@ import * as path from "path";
2
2
  import { cohortFileCount } from "./contract.js";
3
3
  import { describeRunnerError, runDoctorCohort } from "./runner.js";
4
4
  export async function runCohort(spec, onProgress, exec = runDoctorCohort) {
5
- var _a;
5
+ var _a, _b;
6
6
  const runStarted = Date.now();
7
7
  // The executor preserves options order; the fold pairs runs with
8
8
  // their doctor by index.
@@ -31,6 +31,10 @@ export async function runCohort(spec, onProgress, exec = runDoctorCohort) {
31
31
  return {
32
32
  groups,
33
33
  crashed,
34
+ broken: ((_b = spec.broken) !== null && _b !== void 0 ? _b : []).map(b => ({
35
+ id: b.slug,
36
+ detail: b.cause !== undefined ? describeRunnerError(b.cause) : `broken doctor ${b.slug}`,
37
+ })),
34
38
  skippedUnsafe: spec.skippedUnsafe ? [...spec.skippedUnsafe] : [],
35
39
  doctorPaths,
36
40
  fileCount: cohortFileCount(fileCounts),
package/bin/contract.d.ts CHANGED
@@ -50,6 +50,7 @@ export interface CrashedDoctor {
50
50
  export interface RunOutcome {
51
51
  groups: ReportGroup[];
52
52
  crashed: CrashedDoctor[];
53
+ broken?: CrashedDoctor[];
53
54
  skippedUnsafe: string[];
54
55
  doctorPaths: ReadonlyMap<string, string>;
55
56
  fileCount: number;
package/bin/engine.js CHANGED
@@ -90,7 +90,7 @@ export function resolveAstGrepBinary() {
90
90
  return cachedBinary;
91
91
  }
92
92
  // One batched query over a real repo emits tens of megabytes of JSON (the
93
- // async-doctor pilot's ten patterns produce 24MB over a 979-file repo) —
93
+ // async pilot's ten patterns produce 24MB over a 979-file repo) —
94
94
  // spawnSync's 1MB default truncates that mid-array, and the crash reads as
95
95
  // an ast-grep bug instead of a buffer bug. The ceiling exists so a runaway
96
96
  // pattern (matching near-every node of a monorepo) fails loudly here
package/bin/mask.js CHANGED
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // Regex literals must be masked because they may contain quote
7
7
  // characters: an unmasked /["']/ opens a phantom string that swallows
8
- // every line after it (found by slop-doctor flagging imports whose
8
+ // every line after it (found by slop flagging imports whose
9
9
  // type-position uses had been masked out of existence).
10
10
  //
11
11
  // Two host-side consumers share it: ctx.files.readMasked (the sdk — what
package/bin/report.js CHANGED
@@ -164,14 +164,19 @@ export function renderReport(input, useColor, diff, review) {
164
164
  return lines.join("\n").replace(/\n+$/, "");
165
165
  }
166
166
  export function renderJson(input, summary, gate, diff, review) {
167
- var _a;
167
+ var _a, _b;
168
168
  return JSON.stringify({
169
169
  schema: 1,
170
170
  tool: "any-doctor",
171
171
  target: input.targetDir,
172
172
  fileCount: input.fileCount,
173
173
  durationMs: input.durationMs,
174
- score: summary.score,
174
+ // An invalidated grade is withheld in JSON exactly as in prose: a
175
+ // partial (crashed/broken) or empty scan emits null score/grade —
176
+ // never a vacuous 100/Excellent the flag then contradicts.
177
+ score: summary.score.partialScan === true || summary.score.emptyScan === true
178
+ ? { ...summary.score, score: null, grade: null }
179
+ : summary.score,
175
180
  counts: { ...summary.severityCounts, total: summary.total, hiddenDuplicates: summary.hidden },
176
181
  analysisAvailable: (_a = input.analysisAvailable) !== null && _a !== void 0 ? _a : false,
177
182
  emptyScan: summary.emptyScan,
@@ -181,6 +186,9 @@ export function renderJson(input, summary, gate, diff, review) {
181
186
  ...(b.ruleId !== null ? { rule: b.ruleId } : {}),
182
187
  heading: b.heading,
183
188
  severity: b.severity,
189
+ ...(b.impact !== undefined ? { impact: b.impact } : {}),
190
+ ...(b.why !== undefined ? { why: b.why } : {}),
191
+ ...(b.fix !== undefined ? { fix: b.fix } : {}),
184
192
  findings: b.findings.map(f => {
185
193
  var _a;
186
194
  const readKey = readKeyFor(`${gc.group.meta.id}/${(_a = b.ruleId) !== null && _a !== void 0 ? _a : gc.group.meta.id}`, f.file, f.line, f.column);
@@ -199,6 +207,9 @@ export function renderJson(input, summary, gate, diff, review) {
199
207
  ...(gc.group.meta.blindSpots !== undefined && gc.group.meta.blindSpots.length > 0 ? { blindSpots: gc.group.meta.blindSpots } : {}),
200
208
  })),
201
209
  crashed: input.crashed.map(cr => ({ id: cr.id, detail: cr.detail })),
210
+ // Discovery failures — an unreadable program is infrastructure, not
211
+ // a finding; it fails the run always and says so here.
212
+ broken: ((_b = input.broken) !== null && _b !== void 0 ? _b : []).map(b => ({ id: b.id, detail: b.detail })),
202
213
  skippedUnsafe: input.skippedUnsafe,
203
214
  gate: {
204
215
  failOn: gate.failOn,
package/bin/score.d.ts CHANGED
@@ -4,6 +4,7 @@ export interface ScoreResult {
4
4
  grade: string;
5
5
  filesClean: number;
6
6
  filesTotal: number;
7
+ emptyScan: boolean;
7
8
  partialScan?: boolean;
8
9
  }
9
10
  export declare function findingSeverity(g: ReportGroup, f: Finding): Severity;
package/bin/score.js CHANGED
@@ -39,7 +39,7 @@ export function scoreFromFileHealth(perFile, filesTotal) {
39
39
  const score = filesTotal <= 0
40
40
  ? 100
41
41
  : Math.max(0, Math.min(100, Math.floor(100 * (1 - burden / filesTotal))));
42
- return { score, grade: gradeFor(score), filesClean: Math.max(0, filesTotal - worst.size), filesTotal };
42
+ return { score, grade: gradeFor(score), filesClean: Math.max(0, filesTotal - worst.size), filesTotal, emptyScan: filesTotal <= 0 };
43
43
  }
44
44
  export function computeScore(groups, filesTotal) {
45
45
  return scoreFromFileHealth(groups.flatMap(g => g.findings.map(f => ({ file: f.file, severity: findingSeverity(g, f) }))), filesTotal);
@@ -64,7 +64,7 @@ export function scoreHeaderLines(s) {
64
64
  return { scoreLine: "Score: n/a — no files scanned", cleanLine: null, emptyScan: true, partialScan: false };
65
65
  }
66
66
  if (s.partialScan === true) {
67
- return { scoreLine: "Score: n/a — partial scan (a doctor crashed; results above are incomplete)", cleanLine: null, emptyScan: false, partialScan: true };
67
+ return { scoreLine: "Score: n/a — partial scan (a doctor failed — crashed or broken; results above are incomplete)", cleanLine: null, emptyScan: false, partialScan: true };
68
68
  }
69
69
  return {
70
70
  scoreLine: `Score: ${s.score} / 100 — ${s.grade}`,
package/bin/summary.d.ts CHANGED
@@ -4,6 +4,9 @@ export interface CheckBucket {
4
4
  ruleId: string | null;
5
5
  heading: string;
6
6
  severity: Severity;
7
+ impact?: string;
8
+ why?: string;
9
+ fix?: string;
7
10
  findings: Finding[];
8
11
  }
9
12
  export interface GroupChecks {
package/bin/summary.js CHANGED
@@ -54,14 +54,14 @@ function dedupeGroups(groups) {
54
54
  return { groups: out, hidden };
55
55
  }
56
56
  export function deriveSummary(outcome) {
57
- var _a, _b;
57
+ var _a, _b, _c, _d;
58
58
  const { groups, hidden } = dedupeGroups(outcome.groups);
59
59
  const total = groups.reduce((n, g) => n + g.findings.length, 0);
60
60
  const score = computeScore(groups, outcome.fileCount);
61
61
  // A crashed doctor contributes no findings, so the raw score reads its
62
62
  // silence as cleanliness — the derivation is where crashes are known,
63
63
  // and where the partial flag is set for every surface to honor.
64
- if (((_b = (_a = outcome.crashed) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) > 0)
64
+ if (((_b = (_a = outcome.crashed) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) > 0 || ((_d = (_c = outcome.broken) === null || _c === void 0 ? void 0 : _c.length) !== null && _d !== void 0 ? _d : 0) > 0)
65
65
  score.partialScan = true;
66
66
  const header = scoreHeaderLines(score);
67
67
  const severityCounts = { error: 0, warning: 0, info: 0 };
@@ -92,6 +92,9 @@ function expandChecks(g) {
92
92
  ruleId: (_a = f.rule) !== null && _a !== void 0 ? _a : null,
93
93
  heading: j.description,
94
94
  severity: j.declaredSeverity,
95
+ ...(j.impact !== undefined ? { impact: j.impact } : {}),
96
+ ...(j.why !== undefined ? { why: j.why } : {}),
97
+ ...(j.fix !== undefined ? { fix: j.fix } : {}),
95
98
  findings: [],
96
99
  });
97
100
  }
@@ -88,6 +88,45 @@ changed doctor can support an explicit detector comparison, not a code-fix claim
88
88
  Expired detailed history must be identified as unavailable, not reconstructed as
89
89
  complete from summary counters.
90
90
 
91
+ ## Doctor identity across authors and renames (planned, M3)
92
+
93
+ Decisions crossing trust boundaries — committed project records shared
94
+ through Git — need doctor identity that is unique across authors and
95
+ stable across display renames. Two mechanisms, both visible, both
96
+ authored; no hidden self-asserted IDs:
97
+
98
+ - **Namespace/id.** A doctor's full key is `namespace/id` when it
99
+ declares a `namespace` (kebab-case author or origin), bare `id`
100
+ otherwise. Bundled doctors stay bare forever — the curated first-party
101
+ pack — so the 0.1.2 rename never churns again; third-party doctors
102
+ MUST declare a namespace, making `alice/convex` and `bob/convex`
103
+ distinct by construction while keys stay human-readable (surfaces may
104
+ show the friendly id; machine surfaces carry the full key). A copy
105
+ that keeps the same namespace/id is honestly the same doctor; a fork
106
+ that changes meaning diverges through provenance (revision/digest).
107
+ Self-generated UUIDs are ruled out: they travel with file copies,
108
+ producing invisible collisions — worse than name collisions because
109
+ nobody can read the key. The doctor identity key never drops its
110
+ namespace component: matching decisions on code evidence alone would
111
+ let one doctor's decision suppress another doctor's finding at the
112
+ same location — the cross-doctor suppression the identity layer
113
+ exists to prevent.
114
+ - **`supersedes` for renames.** Renaming is a semantic event only the
115
+ doctor's owner can assert: `supersedes: "convex-doctor"` (old full
116
+ key). On load, the state layer migrates decisions from superseded
117
+ keys to the new ones, gated on provenance continuity — revision or
118
+ program digest must still match; a rename combined with a meaning
119
+ change resurfaces decisions for reassessment rather than silently
120
+ carrying. This is the explicit, authored version of "identity
121
+ survives renames": no invisible magic, and the migration is visible
122
+ in state.
123
+
124
+ Both land with M3 (they are load-bearing only once decisions are
125
+ committed and shared); the doctor contract and the authoring skill
126
+ teach the declaration from day one so authored doctors arrive
127
+ namespaced. The 0.1.1→0.1.2 bundled rename churns once, deliberately,
128
+ while exposure is near zero; `supersedes` is not retrofitted for it.
129
+
91
130
  ## Team convergence
92
131
 
93
132
  For the same source, compatible doctors/engines, configuration, and committed
@@ -1,5 +1,5 @@
1
1
  export const meta = {
2
- id: "async-doctor",
2
+ id: "async",
3
3
  description: "Async and concurrency discipline: fetch hygiene, promise handling, timer cleanup.",
4
4
  severity: "warning",
5
5
  category: "async",
@@ -16,6 +16,7 @@ export const meta = {
16
16
  id: "fetch-calls-without-abortsignal",
17
17
  description: "Fetch call does not provide an AbortSignal.",
18
18
  severity: "warning",
19
+ revision: 1,
19
20
  impact: "Requests cannot be cancelled: navigating away, unmounting, or superseding leaves fetches running to completion.",
20
21
  why: "A fetch without a signal has no path to cancellation, so component-driven requests outlive the components that issued them.",
21
22
  fix: "Pass an AbortController's signal via the request options and abort it on cleanup or supersede.",
@@ -26,6 +27,7 @@ export const meta = {
26
27
  id: "unawaited-async-map",
27
28
  description: ".map(async ...) result is never awaited — the promises are dropped.",
28
29
  severity: "warning",
30
+ revision: 1,
29
31
  needs: ["bindings"],
30
32
  impact: "The async work starts but nothing waits for it: errors vanish silently and the results are lost mid-flight.",
31
33
  why: "Array.map returns a new array of promises. Without Promise.all or an await on the result, the async callbacks run fire-and-forget.",
@@ -38,6 +40,7 @@ export const meta = {
38
40
  id: "uncleared-settimeout-in-effect",
39
41
  description: "setTimeout inside useEffect is not cleared with clearTimeout.",
40
42
  severity: "warning",
43
+ revision: 1,
41
44
  impact: "The callback fires after the component is gone: state updates on unmounted components, work the user cancelled, and hard-to-trace bugs.",
42
45
  why: "Every timer started inside an effect must be cleared in that effect's cleanup; an uncleared setTimeout outlives the render that created it.",
43
46
  fix: "Assign the timer and return a cleanup that calls clearTimeout with the same identifier.",
@@ -1,5 +1,5 @@
1
1
  export const meta = {
2
- id: "convex-doctor",
2
+ id: "convex",
3
3
  description: "Convex discipline: indexed reads, bounded collects, query clocks, discarded promises, validated args, awaited writes, honest runtime boundaries.",
4
4
  severity: "warning",
5
5
  category: "convex",
@@ -26,6 +26,7 @@ export const meta = {
26
26
  needs: ["calls"], onUnknown: "skip", reportingUnit: "occurrence",
27
27
  description: "A database filter without an index may scan many documents to find matching results.",
28
28
  severity: "warning",
29
+ revision: 1,
29
30
  impact: "Without an index restriction, finding matching results may read many table documents. Cost grows with the scanned candidate set, even when few results are returned.",
30
31
  why: ".filter() runs after documents are read - it cannot reduce reads. Only an index range (.withIndex with q.eq/q.gt/...) restricts how many documents the query touches.",
31
32
  fix: "Define an index covering the filtered fields in schema.ts and use .withIndex(\"by_field\", q => q.eq(\"field\", value)) instead of .filter().",
@@ -37,6 +38,7 @@ export const meta = {
37
38
  needs: ["calls"], onUnknown: "skip", reportingUnit: "occurrence",
38
39
  description: "An index without a range feeds an unbounded consumer.",
39
40
  severity: "warning",
41
+ revision: 1,
40
42
  impact: "The index does not restrict the candidate set. An unbounded collect can grow with the table; filters may read many candidates before returning a few results.",
41
43
  why: "Choosing index order does not narrow its range. This check reports unbounded consumers; filtered bounded-result consumers remain index review candidates.",
42
44
  fix: "Pass a range expression: .withIndex(\"by_x\", q => q.eq(\"x\", value)) - or bound the chain with .take(n) when recent-items ordering is the intent.",
@@ -46,7 +48,8 @@ export const meta = {
46
48
  {
47
49
  id: "query-clock-reactivity",
48
50
  description: "Date.now() in a query can produce stale time-dependent results and reduce cache reuse.",
49
- severity: "warning", needs: ["calls"], onUnknown: "skip", reportingUnit: "occurrence",
51
+ severity: "warning",
52
+ revision: 1, needs: ["calls"], onUnknown: "skip", reportingUnit: "occurrence",
50
53
  claim: "A global Date.now() call directly inside an import-resolved Convex query handler.",
51
54
  lookalikes: ["mutation expiry timestamps", "action clocks", "shadowed Date", "nested or unresolved helper functions"],
52
55
  impact: "Time passing does not itself rerun a subscribed query; time-dependent results may become stale and cache reuse can suffer.",
@@ -56,7 +59,8 @@ export const meta = {
56
59
  {
57
60
  id: "transaction-clock-duration",
58
61
  description: "Subtracting two Date.now() readings in one transaction produces zero elapsed time.",
59
- severity: "warning", needs: ["calls"], onUnknown: "skip", reportingUnit: "occurrence",
62
+ severity: "warning",
63
+ revision: 1, needs: ["calls"], onUnknown: "skip", reportingUnit: "occurrence",
60
64
  claim: "A subtraction of two global Date.now() calls, or immutable direct aliases, in the same inline query/mutation handler.",
61
65
  lookalikes: ["historical timestamps or expiry cutoffs", "action duration measurement", "random branching", "reassigned timestamps"],
62
66
  impact: "The transaction clock is fixed at function start, so this calculation cannot measure work duration.",
@@ -68,6 +72,7 @@ export const meta = {
68
72
  needs: ["calls"], onUnknown: "skip", reportingUnit: "occurrence",
69
73
  description: ".collect() on a query chain with no take, no paginate, and no index range bounding it.",
70
74
  severity: "warning",
75
+ revision: 1,
71
76
  impact: "Unbounded reads grow with the table and eventually hit Convex's per-transaction document read limit - the query works at demo scale and fails in production.",
72
77
  why: "Convex has no query planner: reads follow the chain exactly as written. The scaling guide is explicit that every .collect() must be provably small or index-narrowed (stack.convex.dev/queries-that-scale).",
73
78
  fix: 'Bound the chain - .take(50) for recent-items UIs, .paginate(args.paginationOpts) for incremental loading - or narrow it with .withIndex("by_field", q => q.eq(...)).',
@@ -81,6 +86,7 @@ export const meta = {
81
86
  lookalikes: ["multi-field indexes serving both bounds"],
82
87
  description: "An indexed chain that also filters - a multi-field index candidate whether the index is range-narrowed or the results are bounded.",
83
88
  severity: "warning",
89
+ revision: 1,
84
90
  impact: "The index range still reads every document the filter then discards; when the discarded slice is large, the query pays for it on every call.",
85
91
  why: "When one index field plus a filter still reads too much, Convex's guidance is to promote to a multi-field index so both conditions become range bounds instead of post-read filters.",
86
92
  fix: '.index("by_teamId_status", ["teamId", "status"]) and query it with both .eq() bounds instead of filtering.',
@@ -90,6 +96,7 @@ export const meta = {
90
96
  reportingUnit: "occurrence",
91
97
  description: "A presence field (lastSeen/heartbeat-shaped) patched onto a document.",
92
98
  severity: "warning",
99
+ revision: 1,
93
100
  impact: "Convex re-runs every subscribed query that read the document. A 10-second heartbeat on a widely-read user document invalidates those queries continuously - including queries that never touch the field.",
94
101
  why: "Frequently-updated fields on widely-referenced documents cause fan-out invalidation; the scaling guide's fix is document segmentation, not smarter queries.",
95
102
  fix: 'Split presence into its own table (heartbeats) and patch that; patch the parent document only on meaningful transitions (online to offline).',
@@ -101,6 +108,7 @@ export const meta = {
101
108
  reportingUnit: "occurrence",
102
109
  description: "A query/mutation/action defined without argument validators.",
103
110
  severity: "warning",
111
+ revision: 1,
104
112
  impact: "Args arrive unvalidated and untyped: any payload the client sends is accepted at runtime, and the handler's args parameter is any instead of the inferred literal type - typos surface later as undefined fields instead of immediately as validation errors.",
105
113
  why: "Validators are the contract: Convex checks every call against args at runtime and generates the handler's TypeScript types from the same definition. A function without args gets neither the check nor the types.",
106
114
  fix: "Declare the shape: export const create = mutation({ args: { body: v.string() }, handler: ... }) - an explicit args: {} for no-arg functions keeps the contract visible.",
@@ -112,6 +120,7 @@ export const meta = {
112
120
  reportingUnit: "occurrence",
113
121
  description: "A server-side run call referencing the public api namespace - a review candidate; namespace alone does not establish an authorization flaw.",
114
122
  severity: "info",
123
+ revision: 1,
115
124
  impact: "Everything reachable through api is callable by any client that can reach the deployment. A server-only workflow invoked via api is an exposed surface that clients can call directly, with any arguments the validators accept.",
116
125
  why: "internal.* is the server-to-server namespace: the same functions, unreachable from clients. A run* call that names api.* is either exposing a function by mistake or announcing it should be internal.",
117
126
  fix: "Define the target as internalQuery/internalMutation/internalAction and reference it as internal.module.function in the run* call.",
@@ -123,6 +132,7 @@ export const meta = {
123
132
  reportingUnit: "occurrence",
124
133
  description: "A write, scheduler, or mutation/action call inside a query.",
125
134
  severity: "warning",
135
+ revision: 1,
126
136
  impact: "Queries are read-only transactions - the write methods do not exist on a query's context, so the function fails on its first real call rather than at deploy time. ctx.runQuery IS allowed (same read snapshot); only mutations, actions, and scheduling are forbidden.",
127
137
  why: "A query body runs inside a deterministic read transaction: its context carries db reads, auth and storage, runQuery, nothing else. Writes and scheduling belong in a mutation.",
128
138
  fix: "Move the write into a mutation the client or an action invokes; if the read and the write must be atomic, the whole operation is a mutation that reads first.",
@@ -134,6 +144,7 @@ export const meta = {
134
144
  reportingUnit: "occurrence",
135
145
  description: "ctx.db used inside an action.",
136
146
  severity: "warning",
147
+ revision: 1,
137
148
  impact: "Actions have no db on their context - the call throws at runtime, usually on the first request that reaches that path.",
138
149
  why: "Actions run outside the transaction: their context offers runQuery/runMutation/runAction, scheduler, storage and auth. Database access goes through a function the action invokes.",
139
150
  fix: "Replace ctx.db.<x> with await ctx.runQuery(...) for reads or await ctx.runMutation(...) for writes.",
@@ -143,7 +154,8 @@ export const meta = {
143
154
  {
144
155
  id: "unawaited-convex-call",
145
156
  description: "A known Promise-returning Convex context call is discarded as a standalone expression.",
146
- severity: "warning", needs: ["calls"], onUnknown: "skip", reportingUnit: "occurrence",
157
+ severity: "warning",
158
+ revision: 1, needs: ["calls"], onUnknown: "skip", reportingUnit: "occurrence",
147
159
  claim: "A direct discarded call to a context method of an import-resolved handler's context parameter - db or storage, reads included, scheduler and run* functions too - resolved by binding identity.",
148
160
  lookalikes: ["returned callbacks", "arguments to helpers", "stored promises", "query builders", "shadowed context bindings"],
149
161
  impact: "Discarding the promise can lose errors or leave work unfinished when the function returns.",
@@ -155,6 +167,7 @@ export const meta = {
155
167
  reportingUnit: "occurrence",
156
168
  description: 'A query or mutation defined in a "use node" file.',
157
169
  severity: "warning",
170
+ revision: 1,
158
171
  impact: "Deploy fails: queries and mutations must run in Convex's deterministic runtime, which is what makes their transaction guarantees replayable.",
159
172
  why: '"use node" opts the file into the Node runtime, which only actions can use. Queries and mutations must be deterministic so re-execution produces identical results.',
160
173
  fix: "Split the file: keep the query/mutation in the default runtime and move the Node-dependent work into an action it schedules.",
@@ -166,6 +179,7 @@ export const meta = {
166
179
  reportingUnit: "occurrence",
167
180
  description: "A run call awaited inside a for/while loop - a batching review candidate; deliberate retry loops (OCC with backoff) share this shape.",
168
181
  severity: "info",
182
+ revision: 1,
169
183
  impact: "N iterations become N separate transactions, each with its own round trip and commit - a 1000-item backfill is 1000 sequential transactions, and the caller's timeout budget pays for all of them.",
170
184
  why: "Each run* call is a complete transaction of its own. A loop of awaited runs is the slowest possible batch; the batching guidance is to do the work inside one mutation instead.",
171
185
  fix: "Pass the ids to a mutation that loops internally over ctx.db writes - one transaction - or chunk the loop into bounded batches of run* calls.",
@@ -177,6 +191,7 @@ export const meta = {
177
191
  reportingUnit: "occurrence",
178
192
  description: "ctx.db.patch/replace called with a spread - a review candidate; presence alone does not prove client-controlled fields.",
179
193
  severity: "info",
194
+ revision: 1,
180
195
  impact: "Every field the client included gets written: the validators constrain the mutation's args, but the spread forwards them all, so fields the mutation never named (ownership, role, timestamps) become client-writable.",
181
196
  why: "patch merges whatever object it is given. Spreading args into it delegates field selection to the caller - the opposite of what a validated mutation is for.",
182
197
  fix: "Name the fields: ctx.db.patch(args.id, { title: args.title }) - build the patch object server-side from explicitly validated values.",
@@ -1,5 +1,5 @@
1
1
  export const meta = {
2
- id: "effect-v4-doctor",
2
+ id: "effect-v4-kitlangton",
3
3
  description: "Effect v4 discipline: typed errors over hand-rolled tags, Config over direct env reads, named Effect.fn, deterministic generators, validated boundaries, no casts that silence the type system. The mechanical rules of the kitlangton Effect skill, enforced.",
4
4
  severity: "warning",
5
5
  category: "effect",
@@ -23,6 +23,7 @@ export const meta = {
23
23
  id: "type-silencing-cast",
24
24
  description: "`as any`, a double cast, or a non-null assertion used in Effect code.",
25
25
  severity: "warning",
26
+ revision: 1,
26
27
  impact: "The cast disables exactly the guarantee Effect's types exist to give - that every failure is typed and every value's context is known. The next reader inherits an unsound spot that neither the compiler nor the runtime will ever flag.",
27
28
  why: "Effect's type system is the discipline: errors are values, contexts are tracked. The Effect skill's first Do Not names this directly - no `as any`, no non-null assertions, no unchecked casts to silence typing problems.",
28
29
  fix: "Answer the type instead: narrow with Schema decoding at the boundary, model the absence (Option/nullable field) instead of asserting it away, or scope one `Effect.castTo`-style escape with a comment if it is truly unavoidable.",
@@ -33,6 +34,7 @@ export const meta = {
33
34
  id: "schema-class-as-default",
34
35
  description: "Schema.Class / Schema.TaggedClass used for application data modeling.",
35
36
  severity: "warning",
37
+ revision: 1,
36
38
  impact: "Class-based schemas pull inheritance and identity semantics into what is usually a plain record, and they accrete: one class schema becomes the base of a hierarchy the codebase never needed.",
37
39
  why: "The Effect skill's model is records as `Schema.Struct(...)` plus a same-name interface, with tagged variants reserved for boundary-crossing unions - Schema.Class and Schema.TaggedClass are explicitly not the default modeling pattern.",
38
40
  fix: "Model the record with Schema.Struct({...}) and an interface of the same name; reach for Schema.TaggedStruct/TaggedUnion only at boundaries that need discriminants.",
@@ -43,6 +45,7 @@ export const meta = {
43
45
  id: "handrolled-tagged-error",
44
46
  description: "A _tag error class hand-rolled (extends Error with a _tag, or Data.TaggedError) instead of Schema.TaggedErrorClass.",
45
47
  severity: "warning",
48
+ revision: 1,
46
49
  impact: "Hand-rolled error classes drift from the schema ecosystem: no decoder at boundaries, no derived interface, and each one re-implements tagging slightly differently - exhaustiveness checking gets weaker the more exist.",
47
50
  why: "The skill's rule: expected typed failures are `Schema.TaggedErrorClass`; do not hand-roll `_tag` error classes when it fits.",
48
51
  fix: "Declare the failure with Schema.TaggedErrorClass<Name>()(\"Name\", { field: Schema.String }) - it carries the tag, the payload schema, and the interface in one definition.",
@@ -53,6 +56,7 @@ export const meta = {
53
56
  id: "cause-level-recovery",
54
57
  description: "Effect.catchCause / catchAllCause / sandbox used where typed-error recovery is the default.",
55
58
  severity: "info",
59
+ revision: 1,
56
60
  impact: "Cause-level recovery sees defects (failures the code was not prepared for) as recoverable, which quietly swallows programming errors into fallback paths instead of letting them surface.",
57
61
  why: "The skill's ordering: typed-error recovery first; cause-level recovery only when the boundary truthfully wants defects too. This check records every cause-level use at info severity because the legitimate cases exist.",
58
62
  fix: "Recover on the typed tag (catchTag / catchAll over the error union); reserve cause-level handling for boundaries that genuinely observe defects (logging, supervision).",
@@ -63,6 +67,7 @@ export const meta = {
63
67
  id: "direct-process-env-read",
64
68
  description: "process.env read directly inside Effect application code.",
65
69
  severity: "warning",
70
+ revision: 1,
66
71
  impact: "Env reads scattered through logic cannot be overridden in tests, differ between layers, and hide configuration from the ConfigProvider - the app's settings become untestable ambient state.",
67
72
  why: "The skill's core default: runtime configuration is read through `Config` recipes in layers, not direct process.env access in application logic.",
68
73
  fix: "Declare Config.schema/redacted/string for the variable, read it with yield* inside the owning layer, and swap ConfigProvider layers in tests.",
@@ -73,6 +78,7 @@ export const meta = {
73
78
  id: "unnamed-effect-fn",
74
79
  description: "Effect.fn defined without a span/tracing name.",
75
80
  severity: "warning",
81
+ revision: 1,
76
82
  impact: "Unnamed Effect.fn functions show up in traces and stack frames as anonymous noise - the observability the API exists to provide is silently discarded.",
77
83
  why: "The skill: public service methods and non-trivial internal methods are defined with `Effect.fn(\"Domain.operation\")` - the name is the point; Effect.fnUntraced is the explicit opt-out, not the default.",
78
84
  fix: "Pass the dotted name first: Effect.fn(\"User.load\")((userId) => ...) - or use Effect.fnUntraced deliberately when span metadata is intentionally unnecessary.",
@@ -83,6 +89,7 @@ export const meta = {
83
89
  id: "date-now-in-gen",
84
90
  description: "Date.now() called inside an Effect.gen body.",
85
91
  severity: "warning",
92
+ revision: 1,
86
93
  impact: "The generator reads wall-clock time directly, so the workflow is untestable with TestClock and non-reproducible across runs - time-sensitive branches flip depending on when the code executes.",
87
94
  why: "Effect generators run against the runtime's Clock service precisely so time can be controlled (TestClock in tests); Date.now() steps around that contract. The ecosystem's rule: read time through Clock, or pass it in.",
88
95
  fix: "yield* Clock.currentTimeMillisNow() (TestClock controls it in tests), or accept the timestamp as a parameter from the caller.",
@@ -93,6 +100,7 @@ export const meta = {
93
100
  id: "zod-single-record",
94
101
  description: "z.record called with a single argument.",
95
102
  severity: "warning",
103
+ revision: 1,
96
104
  impact: "The single-argument form leaves the record's keys unconstrained - a schema that validates values but accepts any key shape, which is exactly the drift the boundary was meant to stop.",
97
105
  why: "z.record's single-argument call is the legacy loose form; the explicit form names both halves of the contract (z.record(keySchema, valueSchema)) and keeps key validation honest. In Effect apps, boundary validation is the discipline - Schema or zod, either way both halves are named.",
98
106
  fix: "Name both halves: z.record(z.string(), valueType) - or migrate the boundary to Effect Schema (Schema.Struct plus decoding at the edge).",
@@ -103,6 +111,7 @@ export const meta = {
103
111
  id: "sleep-in-test",
104
112
  description: "Effect.sleep used inside a test file.",
105
113
  severity: "warning",
114
+ revision: 1,
106
115
  impact: "Real sleeps make tests slow and flaky: they encode a guess about timing instead of a synchronization fact, so they pass until the machine is busy - then fail spuriously.",
107
116
  why: "The skill's testing rule: no arbitrary Effect.sleep in tests when a deterministic primitive is available - TestClock controls time, and Deferred/Queue/Latch/Ref synchronize for real.",
108
117
  fix: "Advance time with TestClock, or synchronize on a Deferred/Latch the code under test completes; sleep only when pacing itself is the behavior under test.",
@@ -113,6 +122,7 @@ export const meta = {
113
122
  id: "blind-layer-merge",
114
123
  description: "Layer.mergeAll / provideMerge used as a make-it-compile composition tool.",
115
124
  severity: "info",
125
+ revision: 1,
116
126
  impact: "Merging everything blurs which layer provides which dependency; requirement errors surface as far-away runtime resolution failures instead of close-to-the-code build errors.",
117
127
  why: "The skill names these as blind make-it-compile tools: composition should express the dependency structure, not flatten it. Recorded at info severity because legitimate merges exist.",
118
128
  fix: "Express the wiring: build layers from their dependencies (Layer.provide / composition at the layer that needs them) so the graph is readable in code.",
@@ -302,7 +312,7 @@ function checkSchemaClass(ctx, file, lines) {
302
312
 
303
313
  // Two shapes: the Data.TaggedError class-builder (any usage), and any class
304
314
  // declaring its own _tag member - the body is brace-tracked from the class
305
- // line the same way convex-doctor tracks function bodies.
315
+ // line the same way convex tracks function bodies.
306
316
  function checkTaggedError(ctx, file, lines) {
307
317
  for (let i = 0; i < lines.length; i++) {
308
318
  if (/\bData\.TaggedError\b/.test(lines[i])) {
@@ -1,5 +1,5 @@
1
1
  export const meta = {
2
- id: "openrouter-doctor",
2
+ id: "openrouter",
3
3
  description: "OpenRouter discipline: stream errors surfaced, keep-alives skipped, cancellations that stop billing.",
4
4
  severity: "warning",
5
5
  category: "openrouter",
@@ -15,6 +15,7 @@ export const meta = {
15
15
  id: "midstream-error-ignored",
16
16
  description: "A streamed OpenRouter response is consumed without ever checking for mid-stream errors",
17
17
  severity: "warning",
18
+ revision: 1,
18
19
  impact: "After headers commit, OpenRouter keeps the status at 200 and delivers failures as SSE events — the docs note the error chunk 'can be the first and only event'. A loop that only reads delta.content records a silent empty reply as success.",
19
20
  why: "OpenRouter's stream protocol puts errors inside the 200-OK body: a top-level error field on the chunk, with choices[0].finish_reason === \"error\". Neither the HTTP status nor the types say anything is wrong.",
20
21
  fix: "Check each chunk: `if (chunk.error ?? parsed.choices?.[0]?.finish_reason === \"error\") throw new Error(chunk.error?.message)` before reading delta.content.",
@@ -25,6 +26,7 @@ export const meta = {
25
26
  id: "sse-comment-parse-crash",
26
27
  description: "A hand-rolled SSE reader will feed OpenRouter's keep-alive comments to JSON.parse",
27
28
  severity: "warning",
29
+ revision: 1,
28
30
  impact: "OpenRouter sends `: OPENROUTER PROCESSING` comment lines while routing; the docs warn hand parsers must skip them. A loop that JSON.parses every data-bearing line crashes mid-generation.",
29
31
  why: "SSE keep-alive comments start with `:` and are legal on any stream, but OpenRouter sends them as a matter of course during provider routing — a naive `split(\"\\n\")` + JSON.parse loop meets one on the first slow request.",
30
32
  fix: "Skip comment lines before parsing: `if (line.startsWith(\":\")) continue;` — or use an SDK stream helper that handles SSE framing.",
@@ -35,6 +37,7 @@ export const meta = {
35
37
  id: "missing-abort-signal",
36
38
  description: "An OpenRouter request is sent without an AbortSignal, so cancellation keeps billing",
37
39
  severity: "warning",
40
+ revision: 1,
38
41
  impact: "For non-streaming requests and several providers (Bedrock, Groq, Google, Mistral, Replicate, …) the docs are explicit: aborting without a signal means 'the model will continue processing and you will be billed for the complete response'.",
39
42
  why: "Cancellation stops processing and billing only when the connection is actually aborted. No signal, no abort — the user who navigates away still pays for the full completion.",
40
43
  fix: "Thread an AbortController through: `fetch(url, { ..., signal: controller.signal })`, and abort it on cancellation, navigation, or timeout.",
@@ -45,6 +48,7 @@ export const meta = {
45
48
  id: "retry-after-ignored",
46
49
  description: "A retry loop around an OpenRouter call never reads the Retry-After header",
47
50
  severity: "warning",
51
+ revision: 1,
48
52
  impact: "429 and 503 responses carry Retry-After, and raw fetch gets no SDK backoff — immediate retries thundering-herd into the same limit and can exhaust the daily caps on :free variants (20 RPM / 50–1000 RPD).",
49
53
  why: "The official SDKs honor Retry-After automatically; hand-rolled catch-and-retry loops don't. The header is the only backoff signal a raw fetch client receives.",
50
54
  fix: "Read the header and wait: `const wait = Number(res.headers.get(\"retry-after\") ?? 1); await sleep(wait * 1000);` before retrying.",
@@ -55,6 +59,7 @@ export const meta = {
55
59
  id: "hardcoded-dated-model-slug",
56
60
  description: "A dated model slug is hardcoded where a maintained alias would survive provider removals",
57
61
  severity: "info",
62
+ revision: 1,
58
63
  impact: "Model availability is separate from API versioning — the docs state models are added and removed by providers independently, and a removed slug starts returning 404s with zero code changes around it.",
59
64
  why: "OpenRouter maintains ~family-latest aliases and per-slug routing variants (:nitro, :floor, :free). A versioned slug is sometimes a deliberate reproducibility pin — this is advice to pin consciously, not a defect.",
60
65
  fix: "Prefer `~author/family-latest` aliases or read slugs from config; if the pin is deliberate, keep it and note why.",
@@ -1,5 +1,5 @@
1
1
  export const meta = {
2
- id: "slop-doctor",
2
+ id: "slop",
3
3
  description: "LLM slop discipline: duplicated helpers, dead exports and unread bindings, hostname-sniffed environments, careless text matching, collapsed boolean states. Born from 1,368 Bugbot findings across 182 reviewed PRs.",
4
4
  severity: "warning",
5
5
  category: "slop",
@@ -19,6 +19,7 @@ export const meta = {
19
19
  id: "identical-helper-body-in-two-modules",
20
20
  description: "A function body identical (literals included) to one in another module - a consolidation suggestion, not a proven defect.",
21
21
  severity: "info",
22
+ revision: 1,
22
23
  impact: "Two copies of one contract drift independently: the next edit fixes one and silently leaves the other behind. In the corpus this came from, reviewers raised it 16 times across 14 PRs and every finding was actioned - but duplication alone establishes maintenance risk, not a bug.",
23
24
  why: "Generated code copies what worked instead of importing it. The bodies are identical including literal values, which is what makes later divergence invisible - but intentional copies exist, so this is a review suggestion.",
24
25
  fix: "Consolidate into one shared module and import it on both sides - or, if the domains must stay separate, make the separation explicit in the name and a comment saying why they differ.",
@@ -31,6 +32,7 @@ export const meta = {
31
32
  id: "environment-guessed-from-hostname-substring",
32
33
  description: "Environment routing decided by a hostname substring with a fixed fallback.",
33
34
  severity: "info",
35
+ revision: 1,
34
36
  impact: "Every deployment host the substring does not anticipate falls through to the fallback environment - production traffic silently using local/dev settings, or vice versa. The corpus caught this 8 times across 2 PRs.",
35
37
  why: "Guessing environment from `host.includes(\"staging\")` encodes a naming convention as a behavior switch; opaque custom domains and renamed deployments break the guess with no error anywhere.",
36
38
  fix: "Read the environment from configuration (an explicit env var or build-time constant) and treat unknown values as errors, with the hostname heuristic at most a last-resort default.",
@@ -41,6 +43,7 @@ export const meta = {
41
43
  id: "boolean-collapsed-into-three-state",
42
44
  description: "A nullish-defaulted boolean collapsed into a two-way ternary that feeds a three-state domain.",
43
45
  severity: "info",
46
+ revision: 1,
44
47
  impact: "false from 'not detected' and false from 'explicitly refused' become the same state - downstream logic treats unknown as negative, 8 findings across 4 PRs in the corpus.",
45
48
  why: "`x ?? detect()` yields boolean | undefined, but `x === true ? A : B` maps both false and undefined to B. The three-state union was written knowing the difference; the collapse forgets it.",
46
49
  fix: "Branch on the actual three states: `typeof x === \"boolean\" ? (x ? A : B) : C` - or model the source as an explicit tri-state from the start.",
@@ -51,6 +54,7 @@ export const meta = {
51
54
  id: "prefix-overlapping-substring-match",
52
55
  description: "An OR-chain of substring tests whose literals overlap by prefix.",
53
56
  severity: "warning",
57
+ revision: 1,
54
58
  impact: "`includes(\"referral\") || includes(\"refer\")` also matches 'reference', 'referee', 'preferred' - the classifier accepts unrelated words and the stem list grows by accretion. 5 findings across 5 PRs.",
55
59
  why: "The shorter stem subsumes the longer one entirely and both subsume words nobody meant. Substring matching has no word boundary, so every added stem widens the net silently.",
56
60
  fix: "Match whole tokens: split the text and compare exact membership, or use a word-bounded regex (\\breferral\\b) - one explicit list, no accidental vocabulary.",
@@ -61,6 +65,7 @@ export const meta = {
61
65
  id: "export-without-any-consumer",
62
66
  description: "A named export with no consumer found - a deletion candidate, pending the blind spots below.",
63
67
  severity: "info",
68
+ revision: 1,
64
69
  impact: "Likely dead public surface: code that looks load-bearing, is maintained, reviewed, and shipped - but no consumer was found. The corpus caught this 5 times across 4 PRs.",
65
70
  why: "Generated code over-exports ('might be useful'), and nothing in the toolchain reports an export with zero consumers. Dynamic consumers are detected heuristically - see blind spots - so this is a candidate, not a verdict.",
66
71
  fix: "Verify against the blind spots, then delete it - or consume it. If it is a genuine public API entry point, say so in a comment and exempt it deliberately.",
@@ -73,6 +78,7 @@ export const meta = {
73
78
  id: "named-import-without-reference",
74
79
  description: "A named import with zero references (value, JSX, and type positions all resolve) and no textual trace.",
75
80
  severity: "warning",
81
+ revision: 1,
76
82
  impact: "Dead dependency surface: the import suggests usage the module does not have, and removing the last real import from a module can change its initialization order. 5 findings across 3 PRs.",
77
83
  why: "Imports accumulate during generation and refactoring; TypeScript only reports these with noUnusedLocals enabled, which most repos never turn on. The identity engine resolves JSX and type-position references, and a whole-word occurrence guard backstops what no resolver sees - a finding means BOTH layers found nothing.",
78
84
  fix: "Remove the specifier (keep the import statement if other specifiers remain or the module has side effects).",
@@ -85,6 +91,7 @@ export const meta = {
85
91
  id: "unread-local-binding",
86
92
  description: "A non-exported local binding that is never read anywhere in its file.",
87
93
  severity: "info",
94
+ revision: 1,
88
95
  impact: "Computation whose result nobody uses - constants, derived values, whole call results assigned and forgotten. The corpus found these as rate-limit constants and computed guards left behind by refactors.",
89
96
  why: "Bindings created for a plan the code abandoned. Side-effecting initializers are deliberately exempt (the call may matter even when the value does not).",
90
97
  fix: "Delete the binding; if its initializer has side effects, keep the expression and drop the assignment.",
@@ -97,6 +104,7 @@ export const meta = {
97
104
  id: "unanchored-abbreviation-regex",
98
105
  description: "A short case-insensitive regex tested against text without word boundaries.",
99
106
  severity: "info",
107
+ revision: 1,
100
108
  impact: "/ai/i matches 'said', 'wait', 'chair' - an abbreviation filter that accepts common words wholesale. 4 findings across 4 PRs.",
101
109
  why: "Short stems need boundaries; without them the regex is a substring test wearing a regex costume, and case-insensitivity widens it further.",
102
110
  fix: "Anchor it: /\\bai\\b/i - or match against whole tokens after splitting.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "any-doctor",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Your agent writes the analyzer, fixtures prove it, CI reruns it forever.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -11,15 +11,28 @@ npx any-doctor@latest run --all <dir> --format json
11
11
  ```
12
12
 
13
13
  One JSON object on stdout (diagnostics on stderr — ignore stderr unless
14
- the exit code is nonzero). Every finding carries:
15
-
16
- - `file`, `line`, `column`, `severity`, `message`
17
- - `decisionKey` — the finding's stable identity; use it to record decisions
18
- - `decision` — present only when a decision already applies to it
19
-
20
- Exit codes: crashes and skipped doctors fail ALWAYS. `--fail-on
21
- error|warning|info` sets a bar over RAW findings — recorded decisions
22
- never flip a gate, so dismissing findings cannot fake a clean run.
14
+ the exit code is nonzero). Structure: `groups` → `checks` → `findings`.
15
+ Replace `<dir>` with the SAME target directory in every command — state
16
+ and decisions live per directory, and omitting it targets your current
17
+ working directory.
18
+
19
+ Each check carries `heading`, `severity`, and the explanation fields
20
+ `impact`, `why`, `fix` (present when the doctor declared them) — read
21
+ these to investigate. Each finding carries:
22
+
23
+ - `file`, `line` — always; `column`, `severity`, `message` — optional
24
+ - `decisionKey` — the finding's stable identity; use it to record
25
+ decisions. ABSENT when `staleEvidence` is true (the source could not
26
+ be read): such findings are not decidable this run — report them, and
27
+ note that decisions never attach to unreadable evidence
28
+ - `decision` — present only when a decision already applies (decided
29
+ findings stay in this raw list, annotated; the human report hides them)
30
+
31
+ Exit codes: crashes, broken doctors (unreadable programs), and skipped
32
+ doctors fail ALWAYS, and appear in the JSON as `crashed`, `broken`, and
33
+ `skippedUnsafe`. `--fail-on error|warning|info` sets a bar over RAW
34
+ findings — recorded decisions never flip a gate, so dismissing findings
35
+ cannot fake a clean run.
23
36
 
24
37
  ## The workflow
25
38
 
@@ -41,10 +54,13 @@ decide.
41
54
  ## Recording decisions
42
55
 
43
56
  ```bash
44
- npx any-doctor@latest decide --key <decisionKey> --accepted --reason "<why>"
45
- npx any-doctor@latest decide --key <decisionKey> --not-applicable --reason "<why>"
57
+ npx any-doctor@latest decide --key <decisionKey> --accepted --reason "<why>" <dir>
58
+ npx any-doctor@latest decide --key <decisionKey> --not-applicable --reason "<why>" <dir>
46
59
  ```
47
60
 
61
+ (With `--file`/`--line` resolution instead of `--key`, pass the doctor
62
+ path too: `decide --file <f> --line <n> --accepted --reason "…" <doctor> <dir>`.)
63
+
48
64
  - `--accepted`: the concern is real but the code is intentional.
49
65
  - `--not-applicable`: the check's interpretation is wrong for this code.
50
66
  - `--reason` is required and must be specific enough for a human to
@@ -60,6 +76,9 @@ npx any-doctor@latest decisions <dir> --json # inspect state
60
76
  npx any-doctor@latest decisions <dir> --reverse <key>
61
77
  ```
62
78
 
79
+ State lives in `<dir>/.any-doctor/decisions.local.json` — the directory
80
+ you scanned, not the one you run from.
81
+
63
82
  ## Diff against a base
64
83
 
65
84
  ```bash