any-doctor 0.0.4 → 0.0.7

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.
Files changed (43) hide show
  1. package/CONTEXT.md +16 -6
  2. package/bin/analysis-host.d.ts +6 -3
  3. package/bin/analysis-host.js +36 -25
  4. package/bin/analysis.d.ts +10 -2
  5. package/bin/analysis.js +181 -7
  6. package/bin/certify.d.ts +15 -0
  7. package/bin/certify.js +377 -0
  8. package/bin/cli.js +2 -2
  9. package/bin/cohort.d.ts +1 -0
  10. package/bin/cohort.js +1 -3
  11. package/bin/contract.d.ts +32 -1
  12. package/bin/doctor-loader.mjs +17 -79
  13. package/bin/mask.js +7 -2
  14. package/bin/sdk.js +12 -1
  15. package/docs/HANDOFF.md +64 -0
  16. package/docs/decisions.md +347 -0
  17. package/doctors/async-doctor.mjs +7 -0
  18. package/doctors/convex-doctor.fixtures.mjs +462 -599
  19. package/doctors/convex-doctor.mjs +162 -100
  20. package/doctors/effect-v4-doctor.mjs +21 -46
  21. package/doctors/openrouter-doctor.mjs +11 -46
  22. package/doctors/slop-doctor.fixtures.mjs +86 -0
  23. package/doctors/slop-doctor.mjs +151 -63
  24. package/fixtures/innocent/dynamic-import.ts +6 -0
  25. package/fixtures/innocent/index.ts +22 -0
  26. package/fixtures/innocent/jsx-use.tsx +13 -0
  27. package/fixtures/innocent/namespaced-api.ts +9 -0
  28. package/fixtures/innocent/normalize-dash.ts +4 -0
  29. package/fixtures/innocent/normalize-underscore.ts +4 -0
  30. package/fixtures/innocent/parts.tsx +3 -0
  31. package/fixtures/innocent/promise-batching.ts +22 -0
  32. package/fixtures/innocent/qa-guard.ts +6 -0
  33. package/fixtures/innocent/rest-exclusion.ts +6 -0
  34. package/fixtures/innocent/type-semicolons.ts +3 -0
  35. package/fixtures/innocent/widget.ts +3 -0
  36. package/fixtures/sensitivity/billing-triple/billing.ts +32 -0
  37. package/fixtures/sensitivity/billing-triple/expect.json +11 -0
  38. package/fixtures/sensitivity/dead-prompt-builders/expect.json +10 -0
  39. package/fixtures/sensitivity/dead-prompt-builders/prompts.ts +13 -0
  40. package/fixtures/sensitivity/ratelimiter-unbounded-collect/expect.json +9 -0
  41. package/fixtures/sensitivity/ratelimiter-unbounded-collect/rateLimiter.ts +18 -0
  42. package/package.json +3 -2
  43. package/skill/any-doctor.skill.md +93 -33
package/CONTEXT.md CHANGED
@@ -39,9 +39,9 @@ call: the runner's bounded pool, the crash fold (a crash is data — id
39
39
  plus full error detail, never a throw), the process-wide analysis fold,
40
40
  the per-doctor paths, the file-count policy, and the timing. Progress
41
41
  events (settle order) are the only side channel — the live line renders
42
- them, it never joins the fold. Skips are a discovery fact, so the
43
- command layer, which owns discovery, patches `skippedUnsafe` onto the
44
- outcome.
42
+ them, it never joins the fold. Skips are a discovery fact, so they ride
43
+ in with the CohortSpec: the command layer, which owns discovery, hands
44
+ them to the run and the outcome comes back complete.
45
45
 
46
46
  ## Summary
47
47
 
@@ -160,9 +160,14 @@ An identity question asked through `ctx.analysis`: which **Binding** a
160
160
  name resolves to, and every **Reference** to it. `ctx.analysis.bindings(file)`
161
161
  returns the file's whole identity model in one answer — every binding
162
162
  with its declaration span and its references (positions in ctx.search's
163
- convention, plus read/write). The engine is optional (oxc-parser +
163
+ convention, plus read/write). `ctx.analysis.spans(file)` (D26) returns
164
+ every function-like **Span** — functions, methods, arrows, classes with
165
+ name, asyncness, and extent — as an AST fact, replacing the brace-counting
166
+ scans doctors once carried privately (the audit truncation bugs lived in
167
+ those approximations). The engine is optional (oxc-parser +
164
168
  eslint-scope behind the Engine seam's second adapter): checks declare
165
- the analysis they need on their CheckMeta (`needs`), narrow without it,
169
+ the analysis they need on their CheckMeta (`needs` — vocabulary:
170
+ "bindings", "spans"), narrow without it,
166
171
  and the report renders "narrowed" — a degraded run is visible, never
167
172
  silent. References answer by position, so analysis queries compose with
168
173
  rule queries: shapes from one engine, identities from the other.
@@ -242,7 +247,12 @@ workflow's second pass; a doctor ships only after surviving its attack.
242
247
 
243
248
  Running a doctor program against its fixtures and diffing the findings.
244
249
  The trust gate: a doctor program is not considered working until verify
245
- passes.
250
+ passes. The policies that compose the gate — the claim contract, the
251
+ per-fixture diff, the shared innocent corpus, the duplicate-location
252
+ probe, the shared sensitivity corpus — live in one module, the
253
+ Certification harness (`src/certify.ts`), behind one interface:
254
+ `certify(mod, fixtures) -> result rows`. The doctor loader calls it once;
255
+ prevention tiers land there, not in loader choreography.
246
256
 
247
257
  ## Runner
248
258
 
@@ -1,6 +1,7 @@
1
- import { AnalysisFile, Mode } from "./contract.js";
2
- import { analysisStatus, analyzeBindings } from "./analysis.js";
1
+ import { AnalysisFile, AnalysisSpans, Mode } from "./contract.js";
2
+ import { analysisStatus, analyzeBindings, analyzeSpans } from "./analysis.js";
3
3
  type Analyzer = typeof analyzeBindings;
4
+ type SpansAnalyzer = typeof analyzeSpans;
4
5
  type Status = typeof analysisStatus;
5
6
  export declare function clearAnalysisCache(): void;
6
7
  export interface AnalysisRequestBody {
@@ -13,8 +14,10 @@ export type AnalysisResponse = {
13
14
  reason?: string;
14
15
  } | {
15
16
  file: AnalysisFile;
17
+ } | {
18
+ file: AnalysisSpans;
16
19
  } | {
17
20
  error: string;
18
21
  };
19
- export declare function handleAnalysisRequest(req: AnalysisRequestBody, mode: Mode, analyzer?: Analyzer, status?: Status): AnalysisResponse;
22
+ export declare function handleAnalysisRequest(req: AnalysisRequestBody, mode: Mode, analyzer?: Analyzer, status?: Status, spansAnalyzer?: SpansAnalyzer): AnalysisResponse;
20
23
  export {};
@@ -1,18 +1,20 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { analysisStatus, analyzeBindings } from "./analysis.js";
3
+ import { analysisStatus, analyzeBindings, analyzeSpans } from "./analysis.js";
4
4
  import { searchBase, withinBase } from "./search-host.js";
5
5
  // One cache per host process. The host lives in the runner process, so
6
6
  // the lifetime is the any-doctor invocation; across a cohort's doctors
7
7
  // the same unchanged file answers from memory.
8
8
  const modelCache = new Map();
9
+ const spansCache = new Map();
9
10
  // Test seam: the model cache is keyed by mtime+size for the process
10
11
  // lifetime; tests bust it between cases. Invisible to slop-doctor's
11
12
  // default run (test-file consumers are the documented narrowing).
12
13
  export function clearAnalysisCache() {
13
14
  modelCache.clear();
15
+ spansCache.clear();
14
16
  }
15
- export function handleAnalysisRequest(req, mode, analyzer = analyzeBindings, status = analysisStatus) {
17
+ export function handleAnalysisRequest(req, mode, analyzer = analyzeBindings, status = analysisStatus, spansAnalyzer = analyzeSpans) {
16
18
  const base = searchBase(mode);
17
19
  const root = typeof req.root === "string" ? path.resolve(req.root) : "";
18
20
  if (base === "" || !withinBase(root, base)) {
@@ -22,35 +24,44 @@ export function handleAnalysisRequest(req, mode, analyzer = analyzeBindings, sta
22
24
  const s = status();
23
25
  return s.available ? { available: true } : { available: false, reason: s.reason };
24
26
  }
25
- if (req.kind === "bindings") {
27
+ if (req.kind === "bindings" || req.kind === "spans") {
26
28
  if (typeof req.file !== "string" || req.file === "") {
27
- return { error: 'ctx.analysis.bindings needs a "file" path' };
29
+ return { error: `ctx.analysis.${req.kind} needs a "file" path` };
28
30
  }
29
31
  const abs = path.resolve(root, req.file);
30
32
  if (!withinBase(abs, root)) {
31
33
  return { error: `ctx.analysis failed: file is outside the search root: ${req.file}` };
32
34
  }
33
- let source;
34
- let mtimeMs;
35
- let size;
36
- try {
37
- const stat = fs.statSync(abs);
38
- mtimeMs = stat.mtimeMs;
39
- size = stat.size;
40
- const cached = modelCache.get(abs);
41
- if (cached && cached.mtimeMs === mtimeMs && cached.size === size)
42
- return { file: cached.file };
43
- source = fs.readFileSync(abs, "utf8");
35
+ if (req.kind === "bindings") {
36
+ return cachedModel(abs, root, modelCache, analyzer, req.file);
44
37
  }
45
- catch {
46
- return { error: `ctx.analysis failed: cannot read ${req.file}` };
47
- }
48
- const rel = path.relative(root, abs);
49
- const r = analyzer(rel, source);
50
- if (!r.ok)
51
- return { error: r.error };
52
- modelCache.set(abs, { mtimeMs, size, file: r.file });
53
- return { file: r.file };
38
+ return cachedModel(abs, root, spansCache, spansAnalyzer, req.file);
39
+ }
40
+ return { error: `unknown analysis kind ${JSON.stringify(req.kind)} — known kinds: available, bindings, spans` };
41
+ }
42
+ // The shared per-file model lifecycle: stat (cache hit on mtime+size),
43
+ // read, compute, cache. Bindings and spans are the same policy over two
44
+ // analyzers and two caches.
45
+ function cachedModel(abs, root, cache, compute, relFile) {
46
+ let source;
47
+ let mtimeMs;
48
+ let size;
49
+ try {
50
+ const stat = fs.statSync(abs);
51
+ mtimeMs = stat.mtimeMs;
52
+ size = stat.size;
53
+ const cached = cache.get(abs);
54
+ if (cached && cached.mtimeMs === mtimeMs && cached.size === size)
55
+ return { file: cached.file };
56
+ source = fs.readFileSync(abs, "utf8");
57
+ }
58
+ catch {
59
+ return { error: `ctx.analysis failed: cannot read ${relFile}` };
54
60
  }
55
- return { error: `unknown analysis kind ${JSON.stringify(req.kind)} — known kinds: available, bindings` };
61
+ const rel = path.relative(root, abs);
62
+ const r = compute(rel, source);
63
+ if (!r.ok)
64
+ return { error: r.error };
65
+ cache.set(abs, { mtimeMs, size, file: r.file });
66
+ return { file: r.file };
56
67
  }
package/bin/analysis.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { AnalysisFile, BindingInfo, BindingRef } from "./contract.js";
2
- export type { AnalysisFile, BindingInfo, BindingRef };
1
+ import { AnalysisFile, AnalysisSpans, BindingInfo, BindingRef, SpanInfo } from "./contract.js";
2
+ export type { AnalysisFile, AnalysisSpans, BindingInfo, BindingRef, SpanInfo };
3
3
  export interface AnalysisStatus {
4
4
  available: true;
5
5
  }
@@ -15,4 +15,12 @@ export type AnalysisResult = {
15
15
  ok: false;
16
16
  error: string;
17
17
  };
18
+ export type SpansResult = {
19
+ ok: true;
20
+ file: AnalysisSpans;
21
+ } | {
22
+ ok: false;
23
+ error: string;
24
+ };
25
+ export declare function analyzeSpans(file: string, source: string): SpansResult;
18
26
  export declare function analyzeBindings(file: string, source: string): AnalysisResult;
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` };
@@ -22,6 +20,89 @@ export function analysisStatus() {
22
20
  const stack = loadStack();
23
21
  return stack.error !== undefined ? { available: false, reason: stack.error } : { available: true };
24
22
  }
23
+ // One file in, every function-like span out (D26): an AST fact answering
24
+ // what the doctors' private brace-counting copies could only approximate.
25
+ // Semicolons inside multi-line callbacks, strings containing braces, JSX —
26
+ // none of it can truncate a span that the parser already knows. Named
27
+ // declarations carry their id; arrows and anonymous expressions carry null.
28
+ export function analyzeSpans(file, source) {
29
+ const stack = loadStack();
30
+ if (stack.error !== undefined)
31
+ return { ok: false, error: stack.error };
32
+ let program;
33
+ try {
34
+ program = stack.parseSync(file, source, { sourceType: "module" }).program;
35
+ }
36
+ catch (e) {
37
+ return { ok: false, error: `analysis failed to parse ${file}: ${e instanceof Error ? e.message : String(e)}` };
38
+ }
39
+ const pos = positioner(source);
40
+ const spans = [];
41
+ const visit = (node) => {
42
+ if (!node || typeof node !== "object")
43
+ return;
44
+ const n = node;
45
+ let span = null;
46
+ if (n.type === "FunctionDeclaration" || n.type === "TSDeclareFunction") {
47
+ span = spanOf(n, "function", idName(n.id));
48
+ }
49
+ else if (n.type === "FunctionExpression") {
50
+ span = spanOf(n, "function-expression", idName(n.id));
51
+ }
52
+ else if (n.type === "ArrowFunctionExpression") {
53
+ span = spanOf(n, "arrow", null);
54
+ }
55
+ else if (n.type === "ClassDeclaration" || n.type === "ClassExpression") {
56
+ span = spanOf(n, "class", idName(n.id));
57
+ }
58
+ else if (n.type === "MethodDefinition" || n.type === "TSAbstractMethodDefinition") {
59
+ span = spanOf(n.value, "method", propertyName(n.key));
60
+ }
61
+ if (span !== null)
62
+ spans.push(span);
63
+ for (const key of Object.keys(n)) {
64
+ if (key === "range" || key === "start" || key === "end")
65
+ continue;
66
+ const v = n[key];
67
+ if (Array.isArray(v)) {
68
+ for (const child of v)
69
+ if (isNode(child))
70
+ visit(child);
71
+ }
72
+ else if (isNode(v))
73
+ visit(v);
74
+ }
75
+ };
76
+ visit(program);
77
+ return { ok: true, file: { file, spans } };
78
+ function spanOf(n, kind, name) {
79
+ if (typeof n.start !== "number" || typeof n.end !== "number")
80
+ return null;
81
+ return {
82
+ kind,
83
+ name,
84
+ async: n.async === true,
85
+ line: pos.line(n.start),
86
+ column: pos.column(n.start),
87
+ endLine: pos.line(n.end),
88
+ endColumn: pos.column(n.end),
89
+ };
90
+ }
91
+ }
92
+ function idName(id) {
93
+ return id && typeof id.name === "string" ? id.name : null;
94
+ }
95
+ function propertyName(key) {
96
+ const k = key;
97
+ if (k && typeof k.name === "string")
98
+ return k.name;
99
+ if (k && typeof k.value === "string")
100
+ return k.value; // computed / literal keys
101
+ return null;
102
+ }
103
+ function isNode(v) {
104
+ return Boolean(v) && typeof v === "object" && typeof v.type === "string";
105
+ }
25
106
  // One file in, one identity model out: every binding (declarations,
26
107
  // parameters, imports) with the span of its declaring node and every
27
108
  // reference to it, read or write. Type-position identifiers never become
@@ -45,8 +126,6 @@ export function analyzeBindings(file, source) {
45
126
  try {
46
127
  scopeManager = stack.analyze(program, {
47
128
  sourceType: "module",
48
- ecmaVersion: 2026,
49
- childVisitorKeys: stack.keys.KEYS,
50
129
  });
51
130
  }
52
131
  catch (e) {
@@ -57,6 +136,10 @@ export function analyzeBindings(file, source) {
57
136
  const global = scopeManager.globalScope;
58
137
  if (global === null)
59
138
  return { ok: false, error: `analysis failed to resolve scopes in ${file}` };
139
+ // Language facts computed from the AST once, so doctors never re-derive
140
+ // them with regexes: what is exported, and what is an intentional
141
+ // object-rest exclusion.
142
+ const facts = languageFacts(program);
60
143
  for (const scope of allScopes(global)) {
61
144
  for (const variable of scope.variables) {
62
145
  const def = variable.defs[0];
@@ -64,7 +147,7 @@ export function analyzeBindings(file, source) {
64
147
  continue; // builtins and implicit globals carry no def
65
148
  // The declaration's own extent: for variables the declarator (so a
66
149
  // binding's span contains its initializer), for parameters the
67
- // identifier itself (eslint-scope hands the whole function node for
150
+ // identifier itself (scope managers hand the whole function node for
68
151
  // params, which would swallow the body).
69
152
  const node = def.node;
70
153
  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 +169,102 @@ export function analyzeBindings(file, source) {
86
169
  endColumn: pos.column(r.identifier.range[1]),
87
170
  write: r.isWrite(),
88
171
  })),
172
+ exported: facts.exported.has(variable.name) || undefined,
173
+ excluded: facts.excluded.has(variable.name) || undefined,
89
174
  });
90
175
  }
91
176
  }
92
177
  return { ok: true, file: { file, bindings } };
93
178
  }
179
+ function languageFacts(program) {
180
+ const exported = new Set();
181
+ const excluded = new Set();
182
+ const visit = (node) => {
183
+ if (!node || typeof node !== "object")
184
+ return;
185
+ const n = node;
186
+ if (n.type === "ExportNamedDeclaration" && n.declaration) {
187
+ collectDeclaredNames(n.declaration, exported);
188
+ }
189
+ if (n.type === "ExportNamedDeclaration" && Array.isArray(n.specifiers)) {
190
+ for (const spec of n.specifiers) {
191
+ if (spec.local && typeof spec.local.name === "string") {
192
+ exported.add(spec.local.name);
193
+ }
194
+ }
195
+ }
196
+ if (n.type === "ExportDefaultDeclaration") {
197
+ const d = n.declaration;
198
+ if (d && typeof d.id === "object" && d.id && typeof d.id.name === "string") {
199
+ exported.add(d.id.name);
200
+ }
201
+ }
202
+ if (n.type === "ObjectPattern" && Array.isArray(n.properties)) {
203
+ const hasRest = n.properties.some((p) => p.type === "RestElement");
204
+ if (hasRest) {
205
+ for (const p of n.properties) {
206
+ if (p.type === "Property" && p.value && p.value.type === "Identifier") {
207
+ excluded.add(p.value.name);
208
+ }
209
+ }
210
+ }
211
+ }
212
+ for (const key of Object.keys(n)) {
213
+ if (key === "range" || key === "start" || key === "end" || key === "tokens" || key === "comments")
214
+ continue;
215
+ const v = n[key];
216
+ if (Array.isArray(v)) {
217
+ for (const child of v) {
218
+ if (child && typeof child === "object" && typeof child.type === "string")
219
+ visit(child);
220
+ }
221
+ }
222
+ else if (v && typeof v === "object" && typeof v.type === "string") {
223
+ visit(v);
224
+ }
225
+ }
226
+ };
227
+ visit(program);
228
+ return { exported, excluded };
229
+ }
230
+ // Every name a declaration binds: function/class ids, every declarator of
231
+ // a variable statement (multi-declarator, destructured patterns), and the
232
+ // property names of nested object/array patterns.
233
+ function collectDeclaredNames(decl, into) {
234
+ if (decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration" || decl.type === "TSDeclareFunction") {
235
+ if (decl.id && typeof decl.id.name === "string") {
236
+ into.add(decl.id.name);
237
+ }
238
+ return;
239
+ }
240
+ if (decl.type === "VariableDeclaration" && Array.isArray(decl.declarations)) {
241
+ for (const d of decl.declarations) {
242
+ collectPatternNames(d.id, into);
243
+ }
244
+ }
245
+ }
246
+ function collectPatternNames(pattern, into) {
247
+ var _a, _b;
248
+ if (pattern.type === "Identifier") {
249
+ into.add(pattern.name);
250
+ return;
251
+ }
252
+ if ((pattern.type === "ObjectPattern" || pattern.type === "ArrayPattern") && Array.isArray((_a = pattern.properties) !== null && _a !== void 0 ? _a : pattern.elements)) {
253
+ const items = ((_b = pattern.properties) !== null && _b !== void 0 ? _b : pattern.elements);
254
+ for (const item of items) {
255
+ if (!item)
256
+ continue;
257
+ if (item.type === "Property")
258
+ collectPatternNames(item.value, into);
259
+ else if (item.type === "RestElement")
260
+ collectPatternNames(item.argument, into);
261
+ else
262
+ collectPatternNames(item, into);
263
+ }
264
+ }
265
+ if (pattern.type === "AssignmentPattern")
266
+ collectPatternNames(pattern.left, into);
267
+ }
94
268
  // eslint-scope expects `range: [start, end]` on nodes; oxc emits start/end.
95
269
  function addRanges(node) {
96
270
  if (!node || typeof node !== "object")
@@ -0,0 +1,15 @@
1
+ import { buildCtx } from "./sdk.js";
2
+ import type { Fixture, FixtureResult, RunResult } from "./contract.js";
3
+ export interface DoctorModule {
4
+ meta?: unknown;
5
+ doctor?: (ctx: ReturnType<typeof buildCtx>["ctx"]) => unknown;
6
+ }
7
+ export declare function runOnce(root: string, mod: DoctorModule, opts: {
8
+ includeTests: boolean;
9
+ }): Promise<RunResult>;
10
+ export declare class ClaimContractViolation extends Error {
11
+ readonly problems: string[];
12
+ constructor(problems: string[]);
13
+ }
14
+ export declare function validateClaimContract(mod: DoctorModule): void;
15
+ export declare function certify(mod: DoctorModule, fixtures: Fixture[]): Promise<FixtureResult[]>;