any-doctor 0.0.4 → 0.0.6
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/bin/analysis.js +98 -7
- package/bin/contract.d.ts +14 -0
- package/bin/doctor-loader.mjs +64 -0
- package/bin/mask.js +7 -2
- package/docs/decisions.md +175 -0
- package/doctors/async-doctor.mjs +7 -0
- package/doctors/convex-doctor.fixtures.mjs +462 -599
- package/doctors/convex-doctor.mjs +140 -52
- package/doctors/effect-v4-doctor.mjs +20 -0
- package/doctors/openrouter-doctor.mjs +10 -0
- package/doctors/slop-doctor.fixtures.mjs +57 -0
- package/doctors/slop-doctor.mjs +126 -62
- package/fixtures/innocent/dynamic-import.ts +6 -0
- package/fixtures/innocent/index.ts +22 -0
- package/fixtures/innocent/jsx-use.tsx +13 -0
- package/fixtures/innocent/namespaced-api.ts +9 -0
- package/fixtures/innocent/normalize-dash.ts +4 -0
- package/fixtures/innocent/normalize-underscore.ts +4 -0
- package/fixtures/innocent/parts.tsx +3 -0
- package/fixtures/innocent/promise-batching.ts +22 -0
- package/fixtures/innocent/qa-guard.ts +6 -0
- package/fixtures/innocent/rest-exclusion.ts +6 -0
- package/fixtures/innocent/type-semicolons.ts +3 -0
- package/fixtures/innocent/widget.ts +3 -0
- package/package.json +3 -2
- package/skill/any-doctor.skill.md +70 -35
package/bin/analysis.js
CHANGED
|
@@ -8,10 +8,8 @@ function loadStack() {
|
|
|
8
8
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
9
9
|
const { parseSync } = require_("oxc-parser");
|
|
10
10
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
11
|
-
const { analyze } = require_("eslint-
|
|
12
|
-
|
|
13
|
-
const keys = require_("eslint-visitor-keys");
|
|
14
|
-
loaded = { parseSync, analyze, keys };
|
|
11
|
+
const { analyze } = require_("@typescript-eslint/scope-manager");
|
|
12
|
+
loaded = { parseSync, analyze };
|
|
15
13
|
}
|
|
16
14
|
catch (e) {
|
|
17
15
|
loaded = { error: `the analysis engine is not installed (${e instanceof Error ? e.message : String(e)}) — npm install oxc-parser` };
|
|
@@ -45,8 +43,6 @@ export function analyzeBindings(file, source) {
|
|
|
45
43
|
try {
|
|
46
44
|
scopeManager = stack.analyze(program, {
|
|
47
45
|
sourceType: "module",
|
|
48
|
-
ecmaVersion: 2026,
|
|
49
|
-
childVisitorKeys: stack.keys.KEYS,
|
|
50
46
|
});
|
|
51
47
|
}
|
|
52
48
|
catch (e) {
|
|
@@ -57,6 +53,10 @@ export function analyzeBindings(file, source) {
|
|
|
57
53
|
const global = scopeManager.globalScope;
|
|
58
54
|
if (global === null)
|
|
59
55
|
return { ok: false, error: `analysis failed to resolve scopes in ${file}` };
|
|
56
|
+
// Language facts computed from the AST once, so doctors never re-derive
|
|
57
|
+
// them with regexes: what is exported, and what is an intentional
|
|
58
|
+
// object-rest exclusion.
|
|
59
|
+
const facts = languageFacts(program);
|
|
60
60
|
for (const scope of allScopes(global)) {
|
|
61
61
|
for (const variable of scope.variables) {
|
|
62
62
|
const def = variable.defs[0];
|
|
@@ -64,7 +64,7 @@ export function analyzeBindings(file, source) {
|
|
|
64
64
|
continue; // builtins and implicit globals carry no def
|
|
65
65
|
// The declaration's own extent: for variables the declarator (so a
|
|
66
66
|
// binding's span contains its initializer), for parameters the
|
|
67
|
-
// identifier itself (
|
|
67
|
+
// identifier itself (scope managers hand the whole function node for
|
|
68
68
|
// params, which would swallow the body).
|
|
69
69
|
const node = def.node;
|
|
70
70
|
const span = def.type === "Parameter" ? def.name.range : ((_a = node === null || node === void 0 ? void 0 : node.range) !== null && _a !== void 0 ? _a : def.name.range);
|
|
@@ -86,11 +86,102 @@ export function analyzeBindings(file, source) {
|
|
|
86
86
|
endColumn: pos.column(r.identifier.range[1]),
|
|
87
87
|
write: r.isWrite(),
|
|
88
88
|
})),
|
|
89
|
+
exported: facts.exported.has(variable.name) || undefined,
|
|
90
|
+
excluded: facts.excluded.has(variable.name) || undefined,
|
|
89
91
|
});
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
return { ok: true, file: { file, bindings } };
|
|
93
95
|
}
|
|
96
|
+
function languageFacts(program) {
|
|
97
|
+
const exported = new Set();
|
|
98
|
+
const excluded = new Set();
|
|
99
|
+
const visit = (node) => {
|
|
100
|
+
if (!node || typeof node !== "object")
|
|
101
|
+
return;
|
|
102
|
+
const n = node;
|
|
103
|
+
if (n.type === "ExportNamedDeclaration" && n.declaration) {
|
|
104
|
+
collectDeclaredNames(n.declaration, exported);
|
|
105
|
+
}
|
|
106
|
+
if (n.type === "ExportNamedDeclaration" && Array.isArray(n.specifiers)) {
|
|
107
|
+
for (const spec of n.specifiers) {
|
|
108
|
+
if (spec.local && typeof spec.local.name === "string") {
|
|
109
|
+
exported.add(spec.local.name);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (n.type === "ExportDefaultDeclaration") {
|
|
114
|
+
const d = n.declaration;
|
|
115
|
+
if (d && typeof d.id === "object" && d.id && typeof d.id.name === "string") {
|
|
116
|
+
exported.add(d.id.name);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (n.type === "ObjectPattern" && Array.isArray(n.properties)) {
|
|
120
|
+
const hasRest = n.properties.some((p) => p.type === "RestElement");
|
|
121
|
+
if (hasRest) {
|
|
122
|
+
for (const p of n.properties) {
|
|
123
|
+
if (p.type === "Property" && p.value && p.value.type === "Identifier") {
|
|
124
|
+
excluded.add(p.value.name);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const key of Object.keys(n)) {
|
|
130
|
+
if (key === "range" || key === "start" || key === "end" || key === "tokens" || key === "comments")
|
|
131
|
+
continue;
|
|
132
|
+
const v = n[key];
|
|
133
|
+
if (Array.isArray(v)) {
|
|
134
|
+
for (const child of v) {
|
|
135
|
+
if (child && typeof child === "object" && typeof child.type === "string")
|
|
136
|
+
visit(child);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else if (v && typeof v === "object" && typeof v.type === "string") {
|
|
140
|
+
visit(v);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
visit(program);
|
|
145
|
+
return { exported, excluded };
|
|
146
|
+
}
|
|
147
|
+
// Every name a declaration binds: function/class ids, every declarator of
|
|
148
|
+
// a variable statement (multi-declarator, destructured patterns), and the
|
|
149
|
+
// property names of nested object/array patterns.
|
|
150
|
+
function collectDeclaredNames(decl, into) {
|
|
151
|
+
if (decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration" || decl.type === "TSDeclareFunction") {
|
|
152
|
+
if (decl.id && typeof decl.id.name === "string") {
|
|
153
|
+
into.add(decl.id.name);
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (decl.type === "VariableDeclaration" && Array.isArray(decl.declarations)) {
|
|
158
|
+
for (const d of decl.declarations) {
|
|
159
|
+
collectPatternNames(d.id, into);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function collectPatternNames(pattern, into) {
|
|
164
|
+
var _a, _b;
|
|
165
|
+
if (pattern.type === "Identifier") {
|
|
166
|
+
into.add(pattern.name);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if ((pattern.type === "ObjectPattern" || pattern.type === "ArrayPattern") && Array.isArray((_a = pattern.properties) !== null && _a !== void 0 ? _a : pattern.elements)) {
|
|
170
|
+
const items = ((_b = pattern.properties) !== null && _b !== void 0 ? _b : pattern.elements);
|
|
171
|
+
for (const item of items) {
|
|
172
|
+
if (!item)
|
|
173
|
+
continue;
|
|
174
|
+
if (item.type === "Property")
|
|
175
|
+
collectPatternNames(item.value, into);
|
|
176
|
+
else if (item.type === "RestElement")
|
|
177
|
+
collectPatternNames(item.argument, into);
|
|
178
|
+
else
|
|
179
|
+
collectPatternNames(item, into);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (pattern.type === "AssignmentPattern")
|
|
183
|
+
collectPatternNames(pattern.left, into);
|
|
184
|
+
}
|
|
94
185
|
// eslint-scope expects `range: [start, end]` on nodes; oxc emits start/end.
|
|
95
186
|
function addRanges(node) {
|
|
96
187
|
if (!node || typeof node !== "object")
|
package/bin/contract.d.ts
CHANGED
|
@@ -77,6 +77,12 @@ export interface BindingInfo {
|
|
|
77
77
|
endLine: number;
|
|
78
78
|
endColumn: number;
|
|
79
79
|
references: BindingRef[];
|
|
80
|
+
/** Named-exported from this module — computed from the export AST, not
|
|
81
|
+
* text: every declarator of an export statement, specifiers, defaults. */
|
|
82
|
+
exported?: boolean;
|
|
83
|
+
/** Declared in an object pattern with a rest sibling — the intentional
|
|
84
|
+
* omission idiom (`const { secret: _s, ...safe } = x`). Not dead code. */
|
|
85
|
+
excluded?: boolean;
|
|
80
86
|
}
|
|
81
87
|
export interface AnalysisFile {
|
|
82
88
|
file: string;
|
|
@@ -119,6 +125,14 @@ export interface Fixture {
|
|
|
119
125
|
name: string;
|
|
120
126
|
seed: Record<string, string>;
|
|
121
127
|
expected: ExpectedFinding[];
|
|
128
|
+
/** One sentence: the observable condition this check establishes. Not the
|
|
129
|
+
* consequence ("this is unsafe") - the thing actually detected. */
|
|
130
|
+
claim?: string;
|
|
131
|
+
/** Innocent lookalike shapes that must remain silent (corpus candidates). */
|
|
132
|
+
lookalikes?: string[];
|
|
133
|
+
/** When analysis the check needs is unavailable: "narrow" (report says
|
|
134
|
+
* narrowed) or "skip" (silent, declared in blindSpots). */
|
|
135
|
+
onUnknown?: "narrow" | "skip";
|
|
122
136
|
/** Which analysis mode this fixture pins (D20 Stage 2): "on" (default)
|
|
123
137
|
* runs with the identity engine — and skips with a named notice when it
|
|
124
138
|
* is not installed in the environment; "off" forces the degraded path,
|
package/bin/doctor-loader.mjs
CHANGED
|
@@ -75,6 +75,7 @@ function materializeSeed(tmp, rel, content) {
|
|
|
75
75
|
fs.writeFileSync(abs, content);
|
|
76
76
|
}
|
|
77
77
|
async function main() {
|
|
78
|
+
var _a;
|
|
78
79
|
confineProcess();
|
|
79
80
|
// The mode arrives as argv and is decoded exactly once, here, into a value.
|
|
80
81
|
const decoded = contract.decodeLoaderArgs(process.argv.slice(2));
|
|
@@ -111,6 +112,30 @@ async function main() {
|
|
|
111
112
|
console.error("fixture module must export `fixtures` (array) — got: " + Object.keys(fixturesMod).join(", "));
|
|
112
113
|
process.exit(3);
|
|
113
114
|
}
|
|
115
|
+
// The claim contract (D23): certification requires each declared
|
|
116
|
+
// check to state the observable condition it establishes, its
|
|
117
|
+
// innocent lookalikes, and - when it needs the identity engine -
|
|
118
|
+
// what happens on unknown. Prose impact is not a testable claim.
|
|
119
|
+
const checks = (_a = mod.meta) === null || _a === void 0 ? void 0 : _a.checks;
|
|
120
|
+
if (Array.isArray(checks)) {
|
|
121
|
+
const problems = [];
|
|
122
|
+
for (const c of checks) {
|
|
123
|
+
if (typeof c.claim !== "string" || c.claim.trim().length === 0) {
|
|
124
|
+
problems.push(`check "${String(c.id)}": claim is required — one sentence, the observable condition detected, not the consequence`);
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(c.lookalikes) || c.lookalikes.length === 0) {
|
|
127
|
+
problems.push(`check "${String(c.id)}": lookalikes is required — at least one innocent shape that must stay silent`);
|
|
128
|
+
}
|
|
129
|
+
if (Array.isArray(c.needs) && c.needs.length > 0
|
|
130
|
+
&& (c.onUnknown !== "narrow" && c.onUnknown !== "skip")) {
|
|
131
|
+
problems.push(`check "${String(c.id)}": onUnknown is required when needs is declared — "narrow" or "skip"`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (problems.length > 0) {
|
|
135
|
+
console.error("claim contract violations:\n " + problems.join("\n "));
|
|
136
|
+
process.exit(3);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
114
139
|
const results = [];
|
|
115
140
|
// Only a doctor whose checks declare analysis needs can have
|
|
116
141
|
// analysis-on fixtures — probing anyone else would make a
|
|
@@ -158,6 +183,45 @@ async function main() {
|
|
|
158
183
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
159
184
|
}
|
|
160
185
|
}
|
|
186
|
+
// The shared innocent corpus (D23): files that look guilty but
|
|
187
|
+
// aren't — the audit counterexamples as commons. Every doctor runs
|
|
188
|
+
// against them with expected: []; a finding here is a false positive
|
|
189
|
+
// by definition, whoever wrote the check.
|
|
190
|
+
const innocentDir = fs.realpathSync(new URL("../fixtures/innocent", import.meta.url));
|
|
191
|
+
if (fs.existsSync(innocentDir)) {
|
|
192
|
+
const seed = {};
|
|
193
|
+
const collect = (dir, prefix) => {
|
|
194
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
195
|
+
const abs = path.join(dir, entry.name);
|
|
196
|
+
const rel = prefix ? prefix + "/" + entry.name : entry.name;
|
|
197
|
+
if (entry.isDirectory())
|
|
198
|
+
collect(abs, rel);
|
|
199
|
+
else
|
|
200
|
+
seed[rel] = fs.readFileSync(abs, "utf8");
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
collect(innocentDir, "");
|
|
204
|
+
try {
|
|
205
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "any-doctor-verify-innocent-"));
|
|
206
|
+
try {
|
|
207
|
+
for (const [rel, content] of Object.entries(seed))
|
|
208
|
+
materializeSeed(tmp, rel, content);
|
|
209
|
+
const result = await runOnce(tmp, mod, { includeTests: true });
|
|
210
|
+
const diff = contract.compareFindings([], result.findings);
|
|
211
|
+
results.push({
|
|
212
|
+
name: "shared innocent corpus (" + Object.keys(seed).length + " files)",
|
|
213
|
+
ok: diff.missing.length === 0 && diff.unexpected.length === 0,
|
|
214
|
+
...diff,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
results.push({ name: "shared innocent corpus", ok: false, missing: [], unexpected: [], error: e instanceof Error ? e.message : String(e) });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
161
225
|
process.stdout.write("\n" + contract.RESULT_SENTINEL + JSON.stringify({
|
|
162
226
|
protocolVersion: contract.PROTOCOL_VERSION,
|
|
163
227
|
kind: "verify",
|
package/bin/mask.js
CHANGED
|
@@ -15,8 +15,13 @@
|
|
|
15
15
|
// ctx. First-party host code has no such constraint, so there is exactly
|
|
16
16
|
// one copy, here.
|
|
17
17
|
// A `/` opens a regex literal (not division) after these characters or
|
|
18
|
-
// after these keywords; anywhere else it divides.
|
|
19
|
-
|
|
18
|
+
// after these keywords; anywhere else it divides. `<` and `>` are
|
|
19
|
+
// deliberately absent: a `/` right after `<` is a JSX CLOSING TAG
|
|
20
|
+
// (`</Link>`), and treating it as a regex opener phantom-masks the rest
|
|
21
|
+
// of the component — the 0.0.4 false-positive mechanism. Comparison
|
|
22
|
+
// operators before a regex (`a < /re/.test(x)`) are vanishingly rare
|
|
23
|
+
// next to JSX.
|
|
24
|
+
const REGEX_PRECEDER_CHARS = "=([{,;:!&|?+-*%^~";
|
|
20
25
|
const REGEX_PRECEDER_WORDS = new Set([
|
|
21
26
|
"return", "typeof", "instanceof", "in", "of", "case", "delete", "void",
|
|
22
27
|
"throw", "new", "do", "else", "yield", "await",
|
package/docs/decisions.md
CHANGED
|
@@ -677,6 +677,181 @@ skill teaches the rule (many questions → one batch) beside the op.
|
|
|
677
677
|
|
|
678
678
|
---
|
|
679
679
|
|
|
680
|
+
## D21 — The semantic repair: claims may not exceed the analysis, and the analysis now sees TypeScript
|
|
681
|
+
|
|
682
|
+
**Date:** 2026-09-09
|
|
683
|
+
|
|
684
|
+
**Context:** Two external audits of slop-doctor on a real 630-file
|
|
685
|
+
TypeScript/TSX codebase found systematic false positives across specific
|
|
686
|
+
identifiable classes (unused-import, unread-binding, and hostname checks
|
|
687
|
+
each produced findings that were overwhelmingly or entirely wrong; the
|
|
688
|
+
audits confirmed specific true findings in the dead-export and duplicate
|
|
689
|
+
checks). The mechanisms: the identity engine
|
|
690
|
+
(oxc-parser + eslint-scope) did not resolve JSX references or type
|
|
691
|
+
positions, so JSX-used imports read as unused; the masker treated the
|
|
692
|
+
slash in JSX closing tags (`</Link>`) as a regex opener, phantom-masking
|
|
693
|
+
subsequent code and defeating even the textual backstop; the duplicate
|
|
694
|
+
check compared masked bodies, erasing the literal values that
|
|
695
|
+
distinguish implementations; export detection was regex over text and
|
|
696
|
+
broke on semicolons inside type annotations; the object-rest exclusion
|
|
697
|
+
idiom (`const { secret: _s, ...safe } = x`) was flagged as unread; QA
|
|
698
|
+
throw-guards were described as environment routing. The deepest
|
|
699
|
+
finding: fixtures validate examples, not reliability — historical
|
|
700
|
+
review evidence proves a problem occurs, not that a detector's pattern
|
|
701
|
+
recognizes it.
|
|
702
|
+
|
|
703
|
+
**Decision:** Claims may not exceed the analysis, and the analysis was
|
|
704
|
+
upgraded until the claims were cheap:
|
|
705
|
+
|
|
706
|
+
- **The scope layer is @typescript-eslint/scope-manager** (replacing
|
|
707
|
+
eslint-scope over the same oxc AST): JSX references and type-position
|
|
708
|
+
references now RESOLVE. Two language facts are computed from the AST
|
|
709
|
+
and carried on every binding — `exported` (every declarator,
|
|
710
|
+
destructured pattern, and specifier under an export statement; no
|
|
711
|
+
text matching) and `excluded` (the object-rest omission idiom).
|
|
712
|
+
Doctors consume facts; they never re-derive them with regexes.
|
|
713
|
+
- **The masker no longer treats a slash after `<` as a regex opener**
|
|
714
|
+
(JSX closing tags are not regexes; comparison-before-regex is
|
|
715
|
+
vanishingly rare next to JSX).
|
|
716
|
+
- **The duplicate check compares bodies with literal values preserved**
|
|
717
|
+
(comments dropped via the masked twin) — `replace(/\\s+/g, "-")` and
|
|
718
|
+
`replace(/\\s+/g, "_")` are different implementations. Severity
|
|
719
|
+
demoted to info: duplication establishes maintenance risk, not a bug;
|
|
720
|
+
each finding names its counterpart files.
|
|
721
|
+
- **Dead exports are candidates, not verdicts** (info): the consumer
|
|
722
|
+
index collects named imports, namespace members, and DYNAMIC import
|
|
723
|
+
specifiers (read from raw source — masking blanks path strings);
|
|
724
|
+
generated files (`.gen.`, `__generated__/`, `generated/`) are
|
|
725
|
+
exempt; the finding message says "candidate".
|
|
726
|
+
- **The hostname check claims routing only when routing is proven** —
|
|
727
|
+
the branch must assign or return a URL-shaped constant; throw guards
|
|
728
|
+
are enforcement, not routing.
|
|
729
|
+
- **The reviewer's seven counterexamples are frozen fixtures** (29
|
|
730
|
+
total) and engine/masker regression tests pin the facts.
|
|
731
|
+
- **prepublishOnly runs the full suite**, not just the build: the
|
|
732
|
+
fixture gates are the registry's promise, so they fire before every
|
|
733
|
+
publish.
|
|
734
|
+
- **The skill's authoring guidance was rewritten**: the regex-export
|
|
735
|
+
and occurrence-count workarounds are gone, replaced by the engine-
|
|
736
|
+
facts law and the unknown-is-not-unused rule — newly generated
|
|
737
|
+
doctors must not reproduce 0.0.4's failure modes.
|
|
738
|
+
|
|
739
|
+
**Consequences:** On the audit codebase: 264 findings → 169; unused
|
|
740
|
+
imports 51 → 1 (a generated file, now exempt), unread bindings 37 → 0,
|
|
741
|
+
hostname 4 → 0; every audit-confirmed false positive cleared and every
|
|
742
|
+
audit-confirmed real finding (two dead prompt builders, the duplicated
|
|
743
|
+
schema helper family) still reported. Remaining honest noise: dead
|
|
744
|
+
export candidates in repos whose consumers are string-built or
|
|
745
|
+
test-only (blind spots declare both). The deeper process rule stands
|
|
746
|
+
for every future doctor: measure per-rule precision on real unfamiliar
|
|
747
|
+
repositories before shipping warning severity — examples passing is
|
|
748
|
+
not precision.
|
|
749
|
+
|
|
750
|
+
---
|
|
751
|
+
|
|
752
|
+
## D22 — convex-doctor: the same law, applied to the second doctor
|
|
753
|
+
|
|
754
|
+
**Date:** 2026-09-09
|
|
755
|
+
|
|
756
|
+
**Context:** The external audit of convex-doctor on the same 630-file
|
|
757
|
+
codebase found 387 findings with at least 118 false positives: 80
|
|
758
|
+
"unawaited" calls that were members of awaited Promise.all arrays (the
|
|
759
|
+
check had no consumption context at all); 27 transaction-clock warnings
|
|
760
|
+
inside actions, where the deterministic-clock restriction does not
|
|
761
|
+
apply; all 5 "index without range" findings had real bounds inside
|
|
762
|
+
multi-line callbacks whose internal semicolons truncated statement
|
|
763
|
+
reconstruction; all 4 "write in query" findings were ctx.runQuery,
|
|
764
|
+
which QueryCtx supports (a wrong platform premise, not a parsing bug);
|
|
765
|
+
both missing-validator findings were chrome.tabs.query — a browser API
|
|
766
|
+
mistaken for a Convex function because function recognition was
|
|
767
|
+
namespace-blind.
|
|
768
|
+
|
|
769
|
+
**Decision:** D21's law — claims may not exceed the analysis — applied
|
|
770
|
+
check by check:
|
|
771
|
+
|
|
772
|
+
- **Consumption context before "unawaited":** a bare ctx call line is
|
|
773
|
+
exempt when its statement context contains a promise combiner
|
|
774
|
+
(Promise.all/allSettled/race/any, .then/.catch/.finally,
|
|
775
|
+
Effect.runPromise) or a promise/array assignment, walking back to the
|
|
776
|
+
statement start and forward across one array-close boundary.
|
|
777
|
+
- **Function kinds are facts:** function spans carry their kind; the
|
|
778
|
+
clock check skips actions (live clock, no restriction). The Math.random
|
|
779
|
+
wording is corrected — a seeded sequence, not one repeated value.
|
|
780
|
+
- **Platform premise fixed:** ctx.runQuery is legal inside queries (same
|
|
781
|
+
read snapshot); only mutations, actions, and scheduling are flagged.
|
|
782
|
+
- **Server-side checks are import-gated:** files must import from
|
|
783
|
+
convex/server or _generated/server — chrome.tabs.query is not a Convex
|
|
784
|
+
function. Client checks (useQuery) and chain checks (ctx.db.query
|
|
785
|
+
implies Convex) stay ungated.
|
|
786
|
+
- **Range detection reads a look-ahead/look-behind window** joined with
|
|
787
|
+
single spaces (squashing to nothing glued `return q.eq(` into
|
|
788
|
+
`returnq.eq(` and destroyed the word boundary): unparseable spans are
|
|
789
|
+
UNKNOWN, not unbounded.
|
|
790
|
+
- **One finding per chain, the most specific one:** filter-table-scan >
|
|
791
|
+
index-without-range > index-filter-combo, with the generic
|
|
792
|
+
unbounded-collect only when no specific diagnosis applies — the
|
|
793
|
+
audit's rateLimiter 73/74/75 triple is now one finding whose fix
|
|
794
|
+
(bound the index) is the reviewer's own recommendation.
|
|
795
|
+
- **Severity honesty:** spread-into-patch, public-api-in-server-call,
|
|
796
|
+
and sequential-run-in-loop are info review candidates — presence is
|
|
797
|
+
visible, consequence is not. The seven audit counterexample shapes
|
|
798
|
+
are frozen fixtures (49 total).
|
|
799
|
+
|
|
800
|
+
**Consequences:** On the audit codebase: 387 findings to 233; unawaited
|
|
801
|
+
116 to 17, clock 48 to 21, index-without-range 5 to 2 (both true
|
|
802
|
+
positives the reviewer's own fixes confirm), write-in-query 4 to 0,
|
|
803
|
+
missing-validator 2 to 0; every audited false-positive site cleared and
|
|
804
|
+
every audited real finding (rateLimiter, the billing lifecycle scans)
|
|
805
|
+
kept. The remaining large groups are declared-info review candidates
|
|
806
|
+
(subscriptions 108, spreads 28, public-api 24) whose blind spots say
|
|
807
|
+
plainly what they cannot establish.
|
|
808
|
+
|
|
809
|
+
---
|
|
810
|
+
|
|
811
|
+
## D23 — The claim contract and the shared innocent corpus: prevention as executable
|
|
812
|
+
|
|
813
|
+
**Date:** 2026-09-09
|
|
814
|
+
|
|
815
|
+
**Context:** D21 and D22 repaired the same disease twice: checks whose
|
|
816
|
+
claims exceeded their analysis, validated by fixtures that tested
|
|
817
|
+
examples rather than reliability. The external reviewers' deepest point:
|
|
818
|
+
"fixtures validate examples, not general reliability" — and their
|
|
819
|
+
strongest recommendation: "make the important requirements executable."
|
|
820
|
+
|
|
821
|
+
**Decision:** Two mechanisms, both enforced by the verify gate:
|
|
822
|
+
|
|
823
|
+
- **The claim contract.** Every declared check states `claim` (one
|
|
824
|
+
sentence: the observable condition detected, not the consequence),
|
|
825
|
+
`lookalikes` (innocent shapes that must stay silent — corpus
|
|
826
|
+
candidates), and `onUnknown` ("narrow" or "skip", required when the
|
|
827
|
+
check declares engine `needs`). Verify refuses to certify a doctor
|
|
828
|
+
whose checks lack these — exit 3 with guidance. "This asynchronous
|
|
829
|
+
code is unsafe" is not a claim; "this expression discards a promise"
|
|
830
|
+
is. The gate cannot prove reasoning is correct, but it can make
|
|
831
|
+
unstatable claims unshippable.
|
|
832
|
+
|
|
833
|
+
- **The shared innocent corpus.** `fixtures/innocent/` holds files that
|
|
834
|
+
look guilty but aren't — the audit counterexamples as commons. Every
|
|
835
|
+
doctor's verify automatically runs against the corpus with
|
|
836
|
+
expected: []; a finding there is a false positive by definition,
|
|
837
|
+
whoever wrote the check. The corpus ships in the npm package and
|
|
838
|
+
grows with every audit's discoveries.
|
|
839
|
+
|
|
840
|
+
All 41 checks across the five bundled doctors now carry contracts.
|
|
841
|
+
The skill teaches the contract as a required section. The D21 wording
|
|
842
|
+
was corrected: the audits measured class-level false-positive rates,
|
|
843
|
+
not report-level precision — the decision log now says so.
|
|
844
|
+
|
|
845
|
+
**Consequences:** A new doctor cannot be certified without stating what
|
|
846
|
+
it establishes; a regression against any known-innocent shape fails the
|
|
847
|
+
gate; and the corpus compounds — every future audit's counterexamples
|
|
848
|
+
enlarge the commons. What this does NOT do: guarantee correctness on
|
|
849
|
+
unfamiliar repos (the reviewers' caveat, adopted verbatim), or replace
|
|
850
|
+
independent evaluation — the held-out set and per-check precision
|
|
851
|
+
measurement remain the tier-2 process.
|
|
852
|
+
|
|
853
|
+
---
|
|
854
|
+
|
|
680
855
|
## Open questions
|
|
681
856
|
|
|
682
857
|
- Opt-in metrics/score API (parked, D19): count doctors run and findings
|
package/doctors/async-doctor.mjs
CHANGED
|
@@ -19,6 +19,8 @@ export const meta = {
|
|
|
19
19
|
impact: "Requests cannot be cancelled: navigating away, unmounting, or superseding leaves fetches running to completion.",
|
|
20
20
|
why: "A fetch without a signal has no path to cancellation, so component-driven requests outlive the components that issued them.",
|
|
21
21
|
fix: "Pass an AbortController's signal via the request options and abort it on cleanup or supersede.",
|
|
22
|
+
claim: "A fetch call whose options contain no signal.",
|
|
23
|
+
lookalikes: ["signal threaded through a variable the pattern cannot see"],
|
|
22
24
|
},
|
|
23
25
|
{
|
|
24
26
|
id: "unawaited-async-map",
|
|
@@ -28,6 +30,9 @@ export const meta = {
|
|
|
28
30
|
impact: "The async work starts but nothing waits for it: errors vanish silently and the results are lost mid-flight.",
|
|
29
31
|
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.",
|
|
30
32
|
fix: "Wrap the mapped array in Promise.all and await it — or drop the async if the work should actually be sequential.",
|
|
33
|
+
claim: "A .map(async ...) result whose binding has no consuming reference (combiner, per-element await, or return) and no inline combiner at the call site.",
|
|
34
|
+
lookalikes: ["inline Promise.all around the map", "for-await over results", "results returned to the caller"],
|
|
35
|
+
onUnknown: "narrow",
|
|
31
36
|
},
|
|
32
37
|
{
|
|
33
38
|
id: "uncleared-settimeout-in-effect",
|
|
@@ -36,6 +41,8 @@ export const meta = {
|
|
|
36
41
|
impact: "The callback fires after the component is gone: state updates on unmounted components, work the user cancelled, and hard-to-trace bugs.",
|
|
37
42
|
why: "Every timer started inside an effect must be cleared in that effect's cleanup; an uncleared setTimeout outlives the render that created it.",
|
|
38
43
|
fix: "Assign the timer and return a cleanup that calls clearTimeout with the same identifier.",
|
|
44
|
+
claim: "A setTimeout inside a useEffect span with no clearTimeout of the same identifier in that effect.",
|
|
45
|
+
lookalikes: ["timers cleared in cleanup", "lookalike names outside effects"],
|
|
39
46
|
},
|
|
40
47
|
],
|
|
41
48
|
};
|