any-doctor 0.0.3 → 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 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-scope");
12
- // eslint-disable-next-line @typescript-eslint/no-require-imports
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 (eslint-scope hands the whole function node for
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/cli.js CHANGED
@@ -10,7 +10,7 @@ import { runDiff } from "./diff.js";
10
10
  import { deriveSummary } from "./summary.js";
11
11
  import { copyToClipboard } from "./clipboard.js";
12
12
  import { runDashboard } from "./dashboard.js";
13
- import { brokenDoctors, discoverDoctors, globalDoctorsDir, unsafeSlugs, scopeLabel } from "./discover.js";
13
+ import { brokenDoctors, discoverDoctors, globalDoctorsDir, resolveDoctorPath, unsafeSlugs, scopeLabel } from "./discover.js";
14
14
  import { causeSummaryLine, describeRunnerError, isRunnerError, verifyDoctor } from "./runner.js";
15
15
  import { scanDoctorFile, capabilitySummary } from "./capabilities.js";
16
16
  import { selectDoctor } from "./select.js";
@@ -105,6 +105,15 @@ function selectionOutcome(sel) {
105
105
  return { exit: 0 };
106
106
  }
107
107
  }
108
+ // An extensionless bare token is a doctor slug ONLY when it resolves in a
109
+ // scope (repo, global, bundled) - never merely because it looks like one,
110
+ // so `run src` still means the target directory unless a doctors/src.mjs
111
+ // exists somewhere. Scope precedence is resolveDoctorPath's law.
112
+ function isBareDoctorSlug(arg) {
113
+ if (path.basename(arg) !== arg || path.isAbsolute(arg))
114
+ return false;
115
+ return resolveDoctorPath(arg, process.cwd()) !== null;
116
+ }
108
117
  function parseArgs(args) {
109
118
  const out = { targetDir: path.resolve("."), all: false, global: false, includeTests: false, format: "report", failOn: "none" };
110
119
  let targetDirSet = false;
@@ -134,7 +143,7 @@ function parseArgs(args) {
134
143
  i += 1;
135
144
  }
136
145
  }
137
- else if (out.doctorPath === undefined && DOCTOR_FILE_RE.test(a))
146
+ else if (out.doctorPath === undefined && (DOCTOR_FILE_RE.test(a) || isBareDoctorSlug(a)))
138
147
  out.doctorPath = a;
139
148
  else if (!targetDirSet) {
140
149
  out.targetDir = path.resolve(a);
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/discover.js CHANGED
@@ -100,10 +100,15 @@ export function resolveDoctorPath(arg, cwd, opts) {
100
100
  (_a = opts === null || opts === void 0 ? void 0 : opts.globalDir) !== null && _a !== void 0 ? _a : globalDoctorsDir(),
101
101
  (_b = opts === null || opts === void 0 ? void 0 : opts.bundledDir) !== null && _b !== void 0 ? _b : bundledDoctorsDir(),
102
102
  ].filter((d) => Boolean(d));
103
+ // A bare slug names the doctor, not the file: try it verbatim (for
104
+ // callers who typed the extension) and then the doctor extensions,
105
+ // canonical .mjs first.
103
106
  for (const dir of scopes) {
104
- const candidate = path.join(dir, base);
105
- if (fs.existsSync(candidate))
106
- return candidate;
107
+ for (const suffix of ["", ".mjs", ".cjs", ".js"]) {
108
+ const candidate = path.join(dir, base + suffix);
109
+ if (fs.existsSync(candidate))
110
+ return candidate;
111
+ }
107
112
  }
108
113
  const direct = path.resolve(cwd, arg);
109
114
  if (fs.existsSync(direct))
@@ -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
- const REGEX_PRECEDER_CHARS = "=([{,;:!&|?+-*%^~<>";
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
@@ -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
  };