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 +6 -6
- package/bin/analysis-host.js +1 -1
- package/bin/certify.js +1 -1
- package/bin/cli.js +51 -11
- package/bin/cohort.d.ts +1 -0
- package/bin/cohort.js +5 -1
- package/bin/contract.d.ts +1 -0
- package/bin/engine.js +1 -1
- package/bin/mask.js +1 -1
- package/bin/report.js +13 -2
- package/bin/score.d.ts +1 -0
- package/bin/score.js +2 -2
- package/bin/summary.d.ts +3 -0
- package/bin/summary.js +5 -2
- package/docs/plans/finding-lifecycle/design.md +39 -0
- package/doctors/{async-doctor.mjs → async.mjs} +4 -1
- package/doctors/{convex-doctor.mjs → convex.mjs} +19 -4
- package/doctors/{effect-v4-doctor.mjs → effect-v4-kitlangton.mjs} +12 -2
- package/doctors/{openrouter-doctor.mjs → openrouter.mjs} +6 -1
- package/doctors/{slop-doctor.mjs → slop.mjs} +9 -1
- package/package.json +1 -1
- package/skill/agent-usage.md +30 -11
- /package/doctors/{async-doctor.fixtures.mjs → async.fixtures.mjs} +0 -0
- /package/doctors/{convex-doctor.fixtures.mjs → convex.fixtures.mjs} +0 -0
- /package/doctors/{effect-v4-doctor.fixtures.mjs → effect-v4-kitlangton.fixtures.mjs} +0 -0
- /package/doctors/{openrouter-doctor.fixtures.mjs → openrouter.fixtures.mjs} +0 -0
- /package/doctors/{slop-doctor.fixtures.mjs → slop.fixtures.mjs} +0 -0
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
|
|
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
|
|
46
|
-
| **convex
|
|
47
|
-
| **effect-v4-
|
|
48
|
-
| **openrouter
|
|
49
|
-
| **async
|
|
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
|
package/bin/analysis-host.js
CHANGED
|
@@ -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
|
|
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-
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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, (
|
|
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: (
|
|
842
|
-
line: (
|
|
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
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
|
|
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
|
|
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
|
-
|
|
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
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
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
|
|
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
|
|
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",
|
|
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",
|
|
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",
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
package/skill/agent-usage.md
CHANGED
|
@@ -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).
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|