any-doctor 0.1.2 → 0.2.1

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 (99) hide show
  1. package/CONTEXT.md +87 -27
  2. package/README.md +25 -11
  3. package/bin/analysis-host.d.ts +20 -1
  4. package/bin/analysis-host.js +147 -23
  5. package/bin/analysis.d.ts +13 -0
  6. package/bin/analysis.js +60 -6
  7. package/bin/authoring.d.ts +97 -0
  8. package/bin/authoring.js +269 -0
  9. package/bin/call-structure.d.ts +4 -0
  10. package/bin/call-structure.js +575 -0
  11. package/bin/certify.d.ts +5 -1
  12. package/bin/certify.js +69 -17
  13. package/bin/cli.js +337 -39
  14. package/bin/cohort.js +1 -1
  15. package/bin/contract.d.ts +268 -4
  16. package/bin/contract.js +12 -8
  17. package/bin/dashboard.d.ts +11 -1
  18. package/bin/dashboard.js +64 -23
  19. package/bin/doctor-sdk.d.ts +41 -0
  20. package/bin/doctor-sdk.js +823 -0
  21. package/bin/doctor-tree.js +2 -1
  22. package/bin/file-scope.d.ts +17 -0
  23. package/bin/file-scope.js +72 -0
  24. package/bin/function-structure.d.ts +11 -0
  25. package/bin/function-structure.js +89 -0
  26. package/bin/palette.d.ts +1 -0
  27. package/bin/palette.js +1 -1
  28. package/bin/project-consumers.d.ts +27 -0
  29. package/bin/project-consumers.js +1054 -0
  30. package/bin/recipe-definitions.d.ts +27 -0
  31. package/bin/recipe-definitions.js +47 -0
  32. package/bin/recipes/forbidden-call.d.ts +15 -0
  33. package/bin/recipes/forbidden-call.js +147 -0
  34. package/bin/recipes/required-option.d.ts +8 -0
  35. package/bin/recipes/required-option.js +116 -0
  36. package/bin/recipes/resource-without-release.d.ts +12 -0
  37. package/bin/recipes/resource-without-release.js +182 -0
  38. package/bin/recipes/support.d.ts +12 -0
  39. package/bin/recipes/support.js +54 -0
  40. package/bin/recipes/types.d.ts +61 -0
  41. package/bin/recipes/types.js +1 -0
  42. package/bin/recipes/unhandled-value.d.ts +12 -0
  43. package/bin/recipes/unhandled-value.js +161 -0
  44. package/bin/report.js +18 -11
  45. package/bin/runner.d.ts +6 -1
  46. package/bin/runner.js +14 -2
  47. package/bin/score.d.ts +3 -0
  48. package/bin/score.js +6 -2
  49. package/bin/sdk.d.ts +4 -1
  50. package/bin/sdk.js +246 -40
  51. package/bin/search-host.js +5 -2
  52. package/bin/semantic-query-parsers.d.ts +3 -0
  53. package/bin/semantic-query-parsers.js +18 -0
  54. package/bin/summary.d.ts +1 -0
  55. package/bin/summary.js +38 -2
  56. package/bin/tty.js +49 -9
  57. package/bin/value-flow.d.ts +111 -0
  58. package/bin/value-flow.js +261 -0
  59. package/docs/HANDOFF.md +90 -113
  60. package/docs/call-structure.md +78 -0
  61. package/docs/decisions.md +89 -0
  62. package/docs/doctor-modernization.md +44 -0
  63. package/docs/doctor-reliability.md +68 -20
  64. package/docs/doctor-sdk.md +666 -0
  65. package/docs/features.md +83 -71
  66. package/docs/openrouter-reliability.md +75 -0
  67. package/docs/project-consumer-analysis.md +195 -0
  68. package/docs/research-openrouter-patterns.md +35 -39
  69. package/docs/vision.md +13 -8
  70. package/doctors/AGENTS.md +507 -0
  71. package/doctors/async.fixtures.mjs +106 -61
  72. package/doctors/async.mjs +47 -467
  73. package/doctors/convex.fixtures.mjs +81 -19
  74. package/doctors/convex.mjs +763 -487
  75. package/doctors/deepgram.fixtures.mjs +527 -0
  76. package/doctors/deepgram.mjs +622 -0
  77. package/doctors/effect-v4-kitlangton.fixtures.mjs +91 -4
  78. package/doctors/effect-v4-kitlangton.mjs +234 -273
  79. package/doctors/openrouter.fixtures.mjs +67 -4
  80. package/doctors/openrouter.mjs +442 -135
  81. package/doctors/slop.fixtures.mjs +68 -12
  82. package/doctors/slop.mjs +171 -248
  83. package/fixtures/doctor-sdk-recipe-only.fixtures.mjs +2 -0
  84. package/fixtures/doctor-sdk-recipe-only.mjs +12 -0
  85. package/fixtures/doctor-sdk-reference.fixtures.mjs +14 -0
  86. package/fixtures/doctor-sdk-reference.mjs +23 -0
  87. package/package.json +6 -5
  88. package/skill/any-doctor.skill.md +47 -6
  89. package/skill/author-workflow.md +90 -0
  90. package/docs/evidence/repair-after-0.0.7/convex-verify.txt +0 -103
  91. package/docs/evidence/repair-after-0.0.7/sift-convex-packed.json +0 -583
  92. package/docs/evidence/repair-after-0.0.7/sift-convex-published.json +0 -1023
  93. package/docs/evidence/repair-after-0.0.7/sift-slop-packed.json +0 -956
  94. package/docs/evidence/repair-after-0.0.7/sift-slop-published.json +0 -951
  95. package/docs/evidence/repair-after-0.0.7/tests.txt +0 -307
  96. package/docs/plans/analysis-improvements.md +0 -234
  97. package/docs/plans/finding-lifecycle/design.md +0 -209
  98. package/docs/plans/finding-lifecycle/milestones.md +0 -120
  99. package/docs/plans/finding-lifecycle/proposal.md +0 -104
package/CONTEXT.md CHANGED
@@ -5,11 +5,10 @@ When a term here conflicts with language elsewhere, this file wins.
5
5
 
6
6
  Current product intent lives in [docs/vision.md](docs/vision.md). For work on
7
7
  persistent decisions, history, identity, or team convergence, read the
8
- [lifecycle design](docs/plans/finding-lifecycle/design.md); the first identity
9
- delivery (lifecycle M1, slices A1+A2) is planned in
10
- [analysis improvements](docs/plans/analysis-improvements.md). Their proposed records
11
- are not implemented interfaces; the glossary below describes current behavior
12
- unless a term is explicitly marked planned.
8
+ [lifecycle design](docs/plans/finding-lifecycle/design.md). Identity (M1) and
9
+ remembered local decisions (M2) have landed; Git-shared decisions (M3) and
10
+ bounded history (M4) remain planned. The glossary below describes current
11
+ behavior unless a term is explicitly marked planned.
13
12
 
14
13
  ## Doctor program
15
14
 
@@ -88,8 +87,8 @@ tree without rebuilding it. A SiteFinding's readKey
88
87
  Text the dashboard copies to the clipboard as one unit of agent work: one
89
88
  finding (fixPrompt), every finding of one check (checkFixPrompt), or a
90
89
  doctor's whole batch (doctorFixPrompt) — pure functions of Doctor-tree
91
- types plus the verify command, no terminal required. The lifecycle plan
92
- (M2) reworks this family toward investigation-first framing and
90
+ types plus the verify command, no terminal required. The local-decision
91
+ delivery (M2) reworked this family toward investigation-first framing and
93
92
  authorized decision paths.
94
93
 
95
94
  ## Gate
@@ -163,6 +162,51 @@ The interface shared by doctor programs, the runner, the report, the
163
162
  fixture harness, and the generator prompt. The single place where the
164
163
  shape of ctx, meta, findings, and the runner protocol is defined.
165
164
 
165
+ ## Doctor SDK
166
+
167
+ The deeper semantic portion of the DoctorCtx interface: host-owned queries for
168
+ identity, value disposition, resource lifetime and option presence. It hides the
169
+ parser/scope/type adapters and returns bounded evidence plus an explicit known or
170
+ unknown result. It is not initially an importable package; Confinement still gives
171
+ doctor programs one route to product capabilities through `ctx`. See the
172
+ [Doctor SDK design](docs/plans/doctor-sdk/design.md).
173
+
174
+ ## Semantic result
175
+
176
+ The answer from one Doctor SDK query: a known value with supporting evidence, or
177
+ an unknown result with a named reason. Unknown never means absent, discarded,
178
+ unreleased or safe. A check that cannot support its claim from a known result
179
+ abstains and exposes narrowed coverage.
180
+
181
+ ## Value Path
182
+
183
+ A bounded Doctor SDK query that establishes whether one static property path is
184
+ present or absent at one source observation. A present result identifies the
185
+ terminal expression and may include a primitive constant. Unsupported flow is
186
+ unknown; it never implies absence or safety.
187
+
188
+ ## Check recipe
189
+
190
+ A host-owned composition of Doctor SDK queries for a recurring check family, such
191
+ as an unhandled value, a resource without release, or a required/recommended call
192
+ option. The recipe owns recurring mechanics, uncertainty, evidence and challenge
193
+ selection; the doctor owns technology-specific selectors, meaning and copy.
194
+
195
+ Each maintained recipe is locally complete under `src/recipes/`: its module owns
196
+ the host kind and implied needs, wire-query parser, semantic composition,
197
+ authoring catalog entry and generated challenge profile. `recipe-definitions.ts`
198
+ is the one registry consumed by the host, SDK, authoring catalog and certifier.
199
+ Adding or changing a recipe happens in its recipe module rather than by editing
200
+ parallel switches in those consumers.
201
+
202
+ ## Challenge profile
203
+
204
+ A maintained certification corpus selected by a check recipe or declared semantic
205
+ capability. It pairs genuine positives with lookalikes, shadowing, aliases,
206
+ transfers, unknown cases, positive neighbors, unavailable analysis and exact
207
+ occurrence witnesses. It supplements author fixtures and independent evaluation;
208
+ it does not replace either.
209
+
166
210
  ## DoctorCtx (ctx)
167
211
 
168
212
  The capability boundary a doctor program is expected to use: read-only,
@@ -174,16 +218,28 @@ writes, no subprocesses, no network.
174
218
  (test-named code files — `*.test.*`/`*.spec.*` with a code extension —
175
219
  and `test/`, `tests/`, `__tests__/` directories): tests mimic production
176
220
  shapes without being production reads. The one law (`isTestPath`) and
177
- the one derivation (`includeTestsFor`) live in contract.ts; every read
178
- capability applies them. A Doctor run opts back in with
221
+ the one derivation (`includeTestsFor`) live in contract.ts; diagnostic
222
+ inventory and search apply them. Reference evidence includes tests. A Doctor run opts back in with
179
223
  `--include-tests`; `ctx.files.read()` is never filtered — an explicit
180
224
  path is a deliberate choice. `ctx.files.readMasked()` is the one
181
225
  masking implementation (comments and strings blanked, offsets and
182
226
  length preserved — a masked position addresses the same char in the
183
227
  source); doctors carry no private copies — the bundled pack's remaining
184
- copies migrate on the recorded triggers. Verify always sees everything
185
- its fixtures seed: the sandbox is the doctor's own world, and a seed
186
- named `*.test.ts` is deliberate test data.
228
+ copies migrate on the recorded triggers. Verify defaults to the deliberately seeded
229
+ test scope; fixtures can also exercise ordinary diagnostic defaults.
230
+ See [project consumer facts](docs/project-consumer-analysis.md) for the interfaces.
231
+
232
+ ## Diagnostic scope
233
+
234
+ The authorized files eligible for findings in a run. Tests are opt-in and generated
235
+ modules are omitted; this scope is distinct from the evidence used to analyze them.
236
+
237
+ ## Consumer evidence
238
+
239
+ An observed reference, declaration dependency, public exposure, or uncertainty
240
+ associated with an exported binding by module identity. Test and generated modules
241
+ may supply this evidence without becoming diagnostic targets; absence within
242
+ supported coverage is a review candidate, never deletion authorization.
187
243
 
188
244
  ## Rule query
189
245
 
@@ -217,8 +273,9 @@ the analysis they need on their CheckMeta (`needs` — vocabulary:
217
273
  and the report renders "narrowed" — a degraded run is visible, never
218
274
  silent. `ctx.analysis.calls(file)` provides immediate call use, receiver identity,
219
275
  inline callback registration, and linked call ranges. These are syntax facts;
220
- stored, passed, or returned is not a promise-settlement verdict. Parse/adapter
221
- errors fail the doctor run explicitly. References answer by position, so analysis queries compose with
276
+ stored, passed, or returned is not a promise-settlement verdict. A parse or
277
+ adapter error narrows the affected file while analyzable neighboring files
278
+ continue; the partial scan receives no score or grade. References answer by position, so analysis queries compose with
222
279
  rule queries: shapes from one engine, identities from the other.
223
280
 
224
281
  ## Engine
@@ -326,9 +383,10 @@ Where a doctor program lives: repo-local (`./doctors/`, committed with
326
383
  the consuming repo), user-global (`~/.any-doctor/doctors/`, available
327
384
  in every repo), or bundled (the first-party pack inside the package,
328
385
  read-only — a starting point, not a dependency). Repo-local wins slug
329
- collisions, then user-global, then bundled. Scanning a target repo
330
- currently does not persist state in any scope. Planned CLI-owned state is
331
- separate from doctor discovery and does not grant doctors write capabilities.
386
+ collisions, then user-global, then bundled. Scanning itself remains read-only.
387
+ Recording a review decision creates CLI-owned local state under the target's
388
+ `.any-doctor/` directory; that state is separate from doctor discovery and does
389
+ not grant doctors write capabilities.
332
390
 
333
391
  ## Skill
334
392
 
@@ -348,18 +406,19 @@ positive witness. Unspecified legacy units and unavailable analysis are
348
406
  reported as not exercised, not counted as passing. Fixture expectations
349
407
  establish tested coverage, not general correctness or independence of labels.
350
408
 
351
- ## Lifecycle vocabulary (planned, except where marked landed)
409
+ ## Lifecycle vocabulary
352
410
 
353
- These terms describe the accepted direction, not current fields on Finding or
354
- DoctorCtx. The [design](docs/plans/finding-lifecycle/design.md) owns their data
355
- and applicability rules.
411
+ These terms describe shipped and planned lifecycle behavior. They are CLI-owned,
412
+ not DoctorCtx capabilities. The [design](docs/plans/finding-lifecycle/design.md)
413
+ owns their data and applicability rules.
356
414
 
357
- - **Finding identity** *(landed in the diff path, D30)*: continuity of one
415
+ - **Finding identity** *(landed in diff and local decisions, D30/D31)*: continuity of one
358
416
  occurrence across comparable scans, distinct from its current source
359
- coordinates. Host-derived today — check key, file, normalized flagged-line
360
- digest, indentation-relative column, and innermost enclosing function span
361
- computed per comparison from one post-scan read, never persisted.
362
- - **Observation:** evidence that a finding was detected in a particular scan.
417
+ coordinates. Host-derived today — check key, file, normalized evidence digest,
418
+ indentation-relative column, and innermost enclosing function span. The CLI
419
+ computes it from source-bound capture; local decisions persist the resulting key.
420
+ - **Observation** *(planned, M4)*: evidence that a finding was detected in a
421
+ particular retained scan.
363
422
  - **Decision** *(landed locally, D31)*: a reasoned accepted/not-applicable
364
423
  disposition with a required reason, stored in the local decisions file,
365
424
  reversible, attached to a Finding identity — it changes review state
@@ -371,7 +430,8 @@ and applicability rules.
371
430
  matched by cardinality are flagged ambiguous.
372
431
  - **No longer detected** *(landed in the diff path)*: absence established by
373
432
  compatible, completed coverage.
374
- - **Claimed fix:** a recorded explanation of remediation, separate from rescan evidence.
433
+ - **Claimed fix** *(planned, M4)*: a recorded explanation of remediation,
434
+ separate from rescan evidence.
375
435
  - **Reassessment** *(landed locally)*: a decision requires review because
376
436
  its evidence changed — the finding resurfaces with a warning; the
377
437
  decision is never silently carried.
package/README.md CHANGED
@@ -13,8 +13,9 @@ rescan after changes. Saved doctors run without model inference.
13
13
 
14
14
  Analysis runs locally without an account, API key, or telemetry. Package/tool
15
15
  installation can require downloads; the doctor runtime has no network access.
16
- Remembered decisions and finding history are [planned](docs/plans/finding-lifecycle/proposal.md),
17
- not available yet.
16
+ Remembered local decisions persist between runs and resurface when their source
17
+ evidence or doctor meaning changes. Shared project decisions and finding history
18
+ remain [planned](docs/plans/finding-lifecycle/proposal.md).
18
19
 
19
20
  [![npm version](https://img.shields.io/npm/v/any-doctor.svg)](https://www.npmjs.com/package/any-doctor)
20
21
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](#)
@@ -38,6 +39,13 @@ npx any-doctor@latest run slop # one doctor, straight to the report
38
39
  Non-terminals and CI never see a prompt — output is stable and pipeable
39
40
  (`--format json`, or `ANY_DOCTOR_HEADLESS=1`).
40
41
 
42
+ During review, press `a` to accept an applicable concern or `x` to mark it not
43
+ applicable; both require a reason. The decision is stored locally under
44
+ `.any-doctor/`, hides the matching occurrence from later active lists, and can
45
+ be inspected or reversed with `any-doctor decisions`. Changed evidence resurfaces
46
+ the finding for reassessment. Local decisions never suppress raw JSON findings or
47
+ change CI gates; keep `.any-doctor/decisions.local.json` untracked.
48
+
41
49
  ## The bundled pack
42
50
 
43
51
  | Doctor | Discipline | Checks |
@@ -46,12 +54,13 @@ Non-terminals and CI never see a prompt — output is stable and pipeable
46
54
  | **convex** | Convex discipline: indexed reads, bounded collects, validated args, awaited writes, honest runtime boundaries | 15 |
47
55
  | **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
56
  | **openrouter** | OpenRouter discipline: stream errors surfaced, keep-alives skipped, cancellations that stop billing | 5 |
57
+ | **deepgram** | Deepgram discipline: endpoint/model compatibility, streaming options, Read shapes, hosts, and proven browser key exposure | 5 |
49
58
  | **async** | Async and concurrency: dropped promise results, uncleared timers, fetch hygiene | 3 |
50
59
 
51
60
  Checks ship positive and innocent-lookalike fixtures. `verify` compares an
52
61
  exact multiset of rule/file/line and optional column, then runs shared innocent
53
- and sensitivity corpora. Convex checks also declare per-check location coverage;
54
- legacy undeclared coverage is reported as not exercised. Passing fixtures show
62
+ and sensitivity corpora. Every bundled check declares its reporting unit, and
63
+ occurrence checks carry per-check location coverage. Passing fixtures show
55
64
  agreement on those cases, not a general accuracy guarantee. See
56
65
  [the reliability protocol](docs/doctor-reliability.md).
57
66
 
@@ -123,15 +132,18 @@ npx any-doctor@latest run --all --fail-on warning --base origin/main
123
132
 
124
133
  ## Docs
125
134
 
135
+ - [Doctor SDK semantic queries and recipes](docs/doctor-sdk.md) — confined,
136
+ evidence-bearing analysis and automatic recipe challenge profiles
137
+
126
138
  | Doc | What it holds |
127
139
  |---|---|
128
140
  | [skill/any-doctor.skill.md](skill/any-doctor.skill.md) | The authoring contract — what your agent reads to write doctors |
129
141
  | [CONTEXT.md](CONTEXT.md) | Domain glossary — canonical terms |
130
142
  | [docs/vision.md](docs/vision.md) | Current goals and product direction |
131
143
  | [docs/features.md](docs/features.md) | Available features versus planned work |
132
- | [docs/plans/analysis-improvements.md](docs/plans/analysis-improvements.md) | Next slice: source evidence, identity, and reliable Git-base comparisons |
133
- | [docs/plans/finding-lifecycle/proposal.md](docs/plans/finding-lifecycle/proposal.md) | Planned decisions, history, and team workflows |
134
- | [docs/plans/finding-lifecycle/design.md](docs/plans/finding-lifecycle/design.md) | State ownership, SQLite, Git convergence, and open choices |
144
+ | [docs/plans/analysis-improvements.md](docs/plans/analysis-improvements.md) | Delivered identity work and later analysis opportunities |
145
+ | [docs/plans/finding-lifecycle/proposal.md](docs/plans/finding-lifecycle/proposal.md) | Current local decisions plus planned history and team workflows |
146
+ | [docs/plans/finding-lifecycle/design.md](docs/plans/finding-lifecycle/design.md) | State ownership, Git convergence, future history storage, and open choices |
135
147
  | [docs/plans/finding-lifecycle/milestones.md](docs/plans/finding-lifecycle/milestones.md) | Implementation slices and acceptance evidence |
136
148
  | [docs/HANDOFF.md](docs/HANDOFF.md) | Current handoff and next bounded task |
137
149
  | [docs/decisions.md](docs/decisions.md) | Historical choices and explicit supersessions |
@@ -141,7 +153,9 @@ npx any-doctor@latest run --all --fail-on warning --base origin/main
141
153
 
142
154
  ## Status
143
155
 
144
- Pre-1.0. The next direction is reliable finding identity, remembered decisions,
145
- team sharing, and bounded local history. Scanning stays available through npx
146
- without mandatory initialization. See the [feature map](docs/features.md) for
147
- current availability. MIT.
156
+ Pre-1.0. Finding identity and remembered local decisions are available. The next
157
+ product phase is real-world adoption and reliability feedback; the next planned
158
+ lifecycle feature is Git-shared project decisions, followed separately by bounded
159
+ local history. Scanning stays available through npx without mandatory
160
+ initialization. See the [feature map](docs/features.md) for current availability.
161
+ MIT.
@@ -1,4 +1,6 @@
1
- import { AnalysisFile, AnalysisSpans, AnalysisCalls, Mode } from "./contract.js";
1
+ import { ProjectConsumers } from "./project-consumers.js";
2
+ import { FunctionStructure } from "./function-structure.js";
3
+ import { AnalysisFile, AnalysisSpans, AnalysisCalls, IdentityValue, Mode, OptionPresence, RecipeDecision, ResourceLifetime, SemanticResult, SourceRange, ValueDisposition, ValuePathValue } from "./contract.js";
2
4
  import { analysisStatus, analyzeBindings, analyzeSpans, analyzeCalls } from "./analysis.js";
3
5
  type Analyzer = typeof analyzeBindings;
4
6
  type SpansAnalyzer = typeof analyzeSpans;
@@ -8,16 +10,33 @@ export interface AnalysisRequestBody {
8
10
  kind?: unknown;
9
11
  file?: unknown;
10
12
  root?: unknown;
13
+ sourceDigest?: unknown;
14
+ expression?: unknown;
15
+ query?: unknown;
11
16
  }
12
17
  export type AnalysisResponse = {
18
+ project: ProjectConsumers;
19
+ } | {
20
+ structures: FunctionStructure[];
21
+ } | {
13
22
  available: boolean;
14
23
  reason?: string;
24
+ provider?: import("./contract.js").SemanticProviderProvenance;
15
25
  } | {
16
26
  file: AnalysisFile;
17
27
  } | {
18
28
  file: AnalysisSpans;
19
29
  } | {
20
30
  file: AnalysisCalls;
31
+ } | {
32
+ semantic: SemanticResult<IdentityValue | {
33
+ matches: boolean;
34
+ } | ValuePathValue | ValueDisposition | ResourceLifetime | OptionPresence | RecipeDecision>;
35
+ execution?: {
36
+ modelRequests: number;
37
+ modelCacheHits: number;
38
+ };
39
+ subject?: SourceRange;
21
40
  } | {
22
41
  error: string;
23
42
  };
@@ -1,22 +1,50 @@
1
+ import { createHash } from "node:crypto";
2
+ import { projectConsumers } from "./project-consumers.js";
3
+ import { functionStructures } from "./function-structure.js";
1
4
  import * as fs from "fs";
2
5
  import * as path from "path";
3
- import { searchBase, withinBase, withinDir } from "./contract.js";
4
- import { analysisStatus, analyzeBindings, analyzeSpans, analyzeCalls } from "./analysis.js";
6
+ import { searchBase, SEMANTIC_RESULT_VERSION, withinBase, withinDir } from "./contract.js";
7
+ import { analysisStatus, analyzeBindings, analyzeSpans, analyzeCalls, semanticProviderProvenance } from "./analysis.js";
8
+ import { callIdentityResult, identityResult, optionPresenceResult, recipeEvaluationRuntime, resourceLifetimeResult, valueAtPathResult, valueDispositionResult } from "./doctor-sdk.js";
9
+ import { RECIPE_DEFINITIONS } from "./recipe-definitions.js";
10
+ import { parseIdentityQuery, parseOptionQuery } from "./semantic-query-parsers.js";
5
11
  // One cache per host process. The host lives in the runner process, so
6
12
  // the lifetime is the any-doctor invocation; across a cohort's doctors
7
13
  // the same unchanged file answers from memory.
8
14
  const modelCache = new Map();
9
15
  const callsCache = new Map();
10
16
  const spansCache = new Map();
11
- // Test seam: the model cache is keyed by mtime+size for the process
17
+ // Test seam: the model cache is keyed by content digest for the process
12
18
  // lifetime; tests bust it between cases. Invisible to slop's
13
- // default run (test-file consumers are the documented narrowing).
19
+ // default run. Consumer graphs include test evidence independently.
14
20
  export function clearAnalysisCache() {
15
21
  modelCache.clear();
16
22
  spansCache.clear();
17
23
  callsCache.clear();
18
24
  }
25
+ // Pair parsing with evaluation so each semantic kind has one typed owner.
26
+ function semanticHandler(parse, evaluate) {
27
+ return (value) => {
28
+ const query = parse(value);
29
+ return query === null ? null : (file, source, facts, expression) => evaluate(file, source, facts, expression, query);
30
+ };
31
+ }
32
+ const recipeSemanticHandlers = {};
33
+ for (const candidate of Object.values(RECIPE_DEFINITIONS)) {
34
+ const definition = candidate;
35
+ recipeSemanticHandlers[definition.kind] = semanticHandler(definition.parse, (file, source, facts, expression, query) => definition.evaluate(recipeEvaluationRuntime, file, source, facts, expression, query));
36
+ }
37
+ const semanticHandlers = {
38
+ "call-identity": semanticHandler(parseIdentityQuery, callIdentityResult),
39
+ identity: semanticHandler(parseIdentityQuery, identityResult),
40
+ "value-path": semanticHandler(parseValuePathQuery, valueAtPathResult),
41
+ "value-disposition": semanticHandler(parseDispositionQuery, valueDispositionResult),
42
+ "resource-lifetime": semanticHandler(parseResourceQuery, resourceLifetimeResult),
43
+ "option-presence": semanticHandler(parseOptionQuery, optionPresenceResult),
44
+ ...recipeSemanticHandlers,
45
+ };
19
46
  export function handleAnalysisRequest(req, mode, analyzer = analyzeBindings, status = analysisStatus, spansAnalyzer = analyzeSpans, callsAnalyzer = analyzeCalls) {
47
+ var _a;
20
48
  const base = searchBase(mode);
21
49
  const root = typeof req.root === "string" ? path.resolve(req.root) : "";
22
50
  if (base === "" || !withinBase(root, base)) {
@@ -24,9 +52,56 @@ export function handleAnalysisRequest(req, mode, analyzer = analyzeBindings, sta
24
52
  }
25
53
  if (req.kind === "available") {
26
54
  const s = status();
27
- return s.available ? { available: true } : { available: false, reason: s.reason };
55
+ const provider = status === analysisStatus ? semanticProviderProvenance() : undefined;
56
+ return s.available
57
+ ? { available: true, ...(provider ? { provider } : {}) }
58
+ : { available: false, reason: s.reason, ...(provider ? { provider } : {}) };
59
+ }
60
+ const semantic = typeof req.kind === "string" && Object.hasOwn(semanticHandlers, req.kind)
61
+ ? semanticHandlers[req.kind] : undefined;
62
+ if (semantic && !status().available) {
63
+ return {
64
+ semantic: { version: SEMANTIC_RESULT_VERSION, status: "unknown", reason: "analysis-unavailable" },
65
+ execution: { modelRequests: 0, modelCacheHits: 0 },
66
+ };
67
+ }
68
+ if (semantic && typeof req.sourceDigest !== "string") {
69
+ return {
70
+ semantic: { version: SEMANTIC_RESULT_VERSION, status: "unknown", reason: "source-changed" },
71
+ execution: { modelRequests: 0, modelCacheHits: 0 },
72
+ };
73
+ }
74
+ if ((req.kind === "structures") && typeof req.file === "string" && req.sourceDigest !== undefined) {
75
+ try {
76
+ const abs = path.resolve(root, req.file);
77
+ if (!withinDir(abs, root) || !withinDir(fs.realpathSync(abs), fs.realpathSync(root)))
78
+ return { error: "analysis file outside root" };
79
+ const digest = createHash("sha256").update(fs.readFileSync(abs)).digest("hex");
80
+ if (digest !== req.sourceDigest)
81
+ return { error: `source changed during analysis: ${req.file}` };
82
+ }
83
+ catch (e) {
84
+ return { error: String(e) };
85
+ }
86
+ }
87
+ if (req.kind === "project" || req.kind === "structures") {
88
+ try {
89
+ if (!status().available)
90
+ return { error: "consumer/structure analysis unavailable" };
91
+ if (req.kind === "project")
92
+ return { project: projectConsumers(root) };
93
+ if (typeof req.file !== "string" || !req.file)
94
+ return { error: "structures needs a file" };
95
+ const abs = path.resolve(root, req.file);
96
+ if (!withinDir(abs, root) || !withinDir(fs.realpathSync(abs), fs.realpathSync(root)))
97
+ return { error: "structure file is outside root" };
98
+ return { structures: functionStructures(req.file, fs.readFileSync(abs, "utf8")) };
99
+ }
100
+ catch (e) {
101
+ return { error: String(e) };
102
+ }
28
103
  }
29
- if (req.kind === "bindings" || req.kind === "spans" || req.kind === "calls") {
104
+ if (req.kind === "bindings" || req.kind === "spans" || req.kind === "calls" || semantic) {
30
105
  if (typeof req.file !== "string" || req.file === "") {
31
106
  return { error: `ctx.analysis.${req.kind} needs a "file" path` };
32
107
  }
@@ -34,30 +109,76 @@ export function handleAnalysisRequest(req, mode, analyzer = analyzeBindings, sta
34
109
  if (!withinDir(abs, root)) {
35
110
  return { error: `ctx.analysis failed: file is outside the search root: ${req.file}` };
36
111
  }
112
+ if (semantic) {
113
+ const expression = parseExpression(req.expression);
114
+ const evaluate = semantic(req.query);
115
+ if (!expression || !evaluate)
116
+ return {
117
+ semantic: { version: SEMANTIC_RESULT_VERSION, status: "unknown", reason: "unsupported-expression" },
118
+ execution: { modelRequests: 0, modelCacheHits: 0 },
119
+ };
120
+ const model = cachedModel(abs, root, callsCache, callsAnalyzer, req.file, req.sourceDigest);
121
+ if ("error" in model)
122
+ return {
123
+ semantic: { version: SEMANTIC_RESULT_VERSION, status: "unknown", reason: model.sourceChanged ? "source-changed" : "provider-failure" },
124
+ execution: { modelRequests: model.sourceChanged ? 0 : 1, modelCacheHits: 0 },
125
+ };
126
+ const execution = { modelRequests: model.cacheHit ? 0 : 1, modelCacheHits: model.cacheHit ? 1 : 0 };
127
+ const value = (_a = model.file.structure.flow.values.find(item => item.id === expression.id && item.start === expression.start && item.end === expression.end)) !== null && _a !== void 0 ? _a : model.file.structure.flow.values.find(item => item.start === expression.start && item.end === expression.end);
128
+ const subject = value === undefined ? undefined : rangeOf(value);
129
+ try {
130
+ return { semantic: evaluate(req.file, model.source, model.file, expression), execution, ...(subject ? { subject } : {}) };
131
+ }
132
+ catch {
133
+ return { semantic: { version: SEMANTIC_RESULT_VERSION, status: "unknown", reason: "provider-failure" }, execution, ...(subject ? { subject } : {}) };
134
+ }
135
+ }
37
136
  if (req.kind === "bindings") {
38
- return cachedModel(abs, root, modelCache, analyzer, req.file);
137
+ const model = cachedModel(abs, root, modelCache, analyzer, req.file, typeof req.sourceDigest === "string" ? req.sourceDigest : undefined);
138
+ return "error" in model ? model : { file: model.file };
139
+ }
140
+ if (req.kind === "calls") {
141
+ const model = cachedModel(abs, root, callsCache, callsAnalyzer, req.file, typeof req.sourceDigest === "string" ? req.sourceDigest : undefined);
142
+ return "error" in model ? model : { file: model.file };
39
143
  }
40
- if (req.kind === "calls")
41
- return cachedModel(abs, root, callsCache, callsAnalyzer, req.file);
42
- return cachedModel(abs, root, spansCache, spansAnalyzer, req.file);
144
+ const model = cachedModel(abs, root, spansCache, spansAnalyzer, req.file, typeof req.sourceDigest === "string" ? req.sourceDigest : undefined);
145
+ return "error" in model ? model : { file: model.file };
43
146
  }
44
- return { error: `unknown analysis kind ${JSON.stringify(req.kind)} — known kinds: available, bindings, spans, calls` };
147
+ return { error: `unknown analysis kind ${JSON.stringify(req.kind)} — known kinds: ${["available", "bindings", "spans", "calls", "project", "structures", ...Object.keys(semanticHandlers)].join(", ")}` };
148
+ }
149
+ function parseDispositionQuery(value) {
150
+ if (!value || typeof value !== "object")
151
+ return null;
152
+ const consumers = value.consumers;
153
+ return Array.isArray(consumers) && consumers.every(item => typeof item === "string") ? { consumers } : null;
154
+ }
155
+ function parseValuePathQuery(value) { if (!value || typeof value !== "object")
156
+ return null; const record = value, at = parseExpression(record.at), path = record.path; return at && Array.isArray(path) && path.length > 0 && path.every(item => typeof item === "string") ? { at, path: path } : null; }
157
+ function parseResourceQuery(value) { if (!value || typeof value !== 'object')
158
+ return null; const record = value, owner = parseExpression(record.owner), release = record.release; return owner && Array.isArray(release) && release.every(item => typeof item === 'string') ? { owner, release } : null; }
159
+ function parseExpression(value) {
160
+ if (!value || typeof value !== "object")
161
+ return null;
162
+ const ref = value;
163
+ return [ref.id, ref.start, ref.end].every(Number.isInteger) ? ref : null;
45
164
  }
46
- // The shared per-file model lifecycle: stat (cache hit on mtime+size),
165
+ // The shared per-file model lifecycle: read (cache hit on content digest),
47
166
  // read, compute, cache. Bindings and spans are the same policy over two
48
167
  // analyzers and two caches.
49
- function cachedModel(abs, root, cache, compute, relFile) {
168
+ function cachedModel(abs, root, cache, compute, relFile, expectedDigest) {
50
169
  let source;
51
- let mtimeMs;
52
- let size;
170
+ let digest;
53
171
  try {
54
- const stat = fs.statSync(abs);
55
- mtimeMs = stat.mtimeMs;
56
- size = stat.size;
57
- const cached = cache.get(abs);
58
- if (cached && cached.mtimeMs === mtimeMs && cached.size === size)
59
- return { file: cached.file };
172
+ if (!withinDir(fs.realpathSync(abs), fs.realpathSync(root)))
173
+ return { error: "analysis file outside root" };
60
174
  source = fs.readFileSync(abs, "utf8");
175
+ digest = createHash("sha256").update(source).digest("hex");
176
+ if (expectedDigest !== undefined && digest !== expectedDigest) {
177
+ return { error: `source changed during analysis: ${relFile}`, sourceChanged: true };
178
+ }
179
+ const cached = cache.get(abs);
180
+ if (cached && cached.digest === digest)
181
+ return { file: cached.file, source, cacheHit: true };
61
182
  }
62
183
  catch {
63
184
  return { error: `ctx.analysis failed: cannot read ${relFile}` };
@@ -66,6 +187,9 @@ function cachedModel(abs, root, cache, compute, relFile) {
66
187
  const r = compute(rel, source);
67
188
  if (!r.ok)
68
189
  return { error: r.error };
69
- cache.set(abs, { mtimeMs, size, file: r.file });
70
- return { file: r.file };
190
+ cache.set(abs, { digest, file: r.file });
191
+ return { file: r.file, source, cacheHit: false };
192
+ }
193
+ function rangeOf(value) {
194
+ return { start: value.start, end: value.end, line: value.line, column: value.column, endLine: value.endLine, endColumn: value.endColumn };
71
195
  }
package/bin/analysis.d.ts CHANGED
@@ -7,6 +7,9 @@ export type AnalysisStatusResult = AnalysisStatus | {
7
7
  available: false;
8
8
  reason: string;
9
9
  };
10
+ export declare const SEMANTIC_PROVIDER_ID = "any-doctor/syntax-flow";
11
+ export declare const SEMANTIC_PROVIDER_VERSION = "1";
12
+ export declare function semanticProviderProvenance(): import("./contract.js").SemanticProviderProvenance;
10
13
  export declare function analysisStatus(): AnalysisStatusResult;
11
14
  export type AnalysisResult = {
12
15
  ok: true;
@@ -31,4 +34,14 @@ export type SpansResult = {
31
34
  };
32
35
  export declare function analyzeSpans(file: string, source: string): SpansResult;
33
36
  export declare function analyzeBindings(file: string, source: string): AnalysisResult;
37
+ export type Node = {
38
+ type: string;
39
+ range?: [number, number];
40
+ [k: string]: unknown;
41
+ };
34
42
  export declare function analyzeCalls(file: string, source: string): CallsResult;
43
+ /** Host-only AST/scope seam. Doctors receive bounded derived facts, never ASTs. */
44
+ export declare function analyzeSyntax(file: string, source: string): {
45
+ program: Node;
46
+ scopes: import("@typescript-eslint/scope-manager").ScopeManager;
47
+ };
package/bin/analysis.js CHANGED
@@ -1,6 +1,30 @@
1
+ import { callStructure } from "./call-structure.js";
1
2
  import { createRequire } from "module";
2
3
  let loaded = null;
3
4
  const require_ = createRequire(import.meta.url);
5
+ export const SEMANTIC_PROVIDER_ID = "any-doctor/syntax-flow";
6
+ export const SEMANTIC_PROVIDER_VERSION = "1";
7
+ function dependencyVersion(id) {
8
+ try {
9
+ return require_(`${id}/package.json`).version;
10
+ }
11
+ catch {
12
+ return "unavailable";
13
+ }
14
+ }
15
+ export function semanticProviderProvenance() {
16
+ const state = analysisStatus();
17
+ return {
18
+ id: SEMANTIC_PROVIDER_ID,
19
+ version: SEMANTIC_PROVIDER_VERSION,
20
+ available: state.available,
21
+ ...(!state.available ? { reason: state.reason } : {}),
22
+ dependencies: [
23
+ { id: "oxc-parser", version: dependencyVersion("oxc-parser") },
24
+ { id: "@typescript-eslint/scope-manager", version: dependencyVersion("@typescript-eslint/scope-manager") },
25
+ ],
26
+ };
27
+ }
4
28
  function loadStack() {
5
29
  if (loaded !== null)
6
30
  return loaded;
@@ -24,7 +48,12 @@ function parseProgram(stack, file, source) {
24
48
  if (stack.error !== undefined)
25
49
  return { ok: false, error: stack.error };
26
50
  try {
27
- const parsed = stack.parseSync(file, source, { sourceType: "module" });
51
+ const lang = /\.tsx$/i.test(file) ? "tsx"
52
+ : /\.(?:jsx?|mjs|cjs)$/i.test(file) ? "jsx"
53
+ : /\.d\.ts$/i.test(file) ? "dts"
54
+ : /\.(?:ts|mts|cts)$/i.test(file) ? "ts"
55
+ : "js";
56
+ const parsed = stack.parseSync(file, source, { sourceType: "module", lang });
28
57
  if (parsed.errors !== undefined && parsed.errors.length > 0) {
29
58
  return { ok: false, error: `analysis failed to parse ${file}: ${parsed.errors[0].message}` };
30
59
  }
@@ -332,7 +361,7 @@ function lowerBound(sorted, value) {
332
361
  // Call relationships and receiver identities are language facts. The doctor
333
362
  // decides which imported factory and methods belong to its framework.
334
363
  export function analyzeCalls(file, source) {
335
- var _a, _b;
364
+ var _a, _b, _c;
336
365
  const stack = loadStack();
337
366
  if (stack.error !== undefined)
338
367
  return { ok: false, error: stack.error };
@@ -401,14 +430,17 @@ export function analyzeCalls(file, source) {
401
430
  var _a;
402
431
  let n = unwrap(expr);
403
432
  const members = [];
404
- while (n.type === "MemberExpression" && !n.computed && isNode(n.object) && isNode(n.property)) {
405
- const name = idName(n.property);
433
+ while ((n.type === "MemberExpression" || n.type === "JSXMemberExpression") && (!n.computed || unwrap(n.property).type === "Literal") && isNode(n.object) && isNode(n.property)) {
434
+ const name = n.computed ? propertyName(unwrap(n.property)) : idName(n.property);
406
435
  if (!name)
407
436
  break;
408
437
  members.unshift(name);
409
438
  n = unwrap(n.object);
410
439
  }
411
- if (n.type !== "Identifier")
440
+ if (n.type === "MetaProperty") {
441
+ return { root: `${idName(n.meta)}.${idName(n.property)}`, members, binding: null };
442
+ }
443
+ if (n.type !== "Identifier" && n.type !== "JSXIdentifier")
412
444
  return { root: null, members, binding: null };
413
445
  const identity = identities.get(n);
414
446
  return { root: idName(n), members, binding: (_a = identity === null || identity === void 0 ? void 0 : identity.binding) !== null && _a !== void 0 ? _a : null,
@@ -478,7 +510,14 @@ export function analyzeCalls(file, source) {
478
510
  ...range(n), functionStart: functionStart(n), left: operand(n.left), right: operand(n.right),
479
511
  });
480
512
  }
481
- return { ok: true, file: { file, calls, functions, differences } };
513
+ const structure = callStructure(nodes, parents, target, range, unwrap, functionStart);
514
+ const receiverValues = new Map((_c = structure.flow) === null || _c === void 0 ? void 0 : _c.values.filter(value => value.kind === "call" && value.receiver !== undefined).map(value => [value.end, value.receiver]));
515
+ for (const call of calls) {
516
+ const receiverValue = receiverValues.get(call.end);
517
+ if (receiverValue !== undefined)
518
+ call.receiverValue = receiverValue;
519
+ }
520
+ return { ok: true, file: { file, calls, functions, differences, structure } };
482
521
  }
483
522
  catch (e) {
484
523
  return { ok: false, error: `analysis failed for ${file}: ${e instanceof Error ? e.message : String(e)}` };
@@ -486,3 +525,18 @@ export function analyzeCalls(file, source) {
486
525
  }
487
526
  const FUNCTION_EXPRESSIONS = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
488
527
  const TRANSPARENT_EXPRESSIONS = new Set(["TSAsExpression", "TSTypeAssertion", "TSNonNullExpression", "TSSatisfiesExpression", "ChainExpression", "ParenthesizedExpression"]);
528
+ /** Host-only AST/scope seam. Doctors receive bounded derived facts, never ASTs. */
529
+ export function analyzeSyntax(file, source) {
530
+ const stack = loadStack();
531
+ const parsed = parseProgram(stack, file, source);
532
+ if (!parsed.ok)
533
+ throw new Error(parsed.error);
534
+ if (stack.error !== undefined)
535
+ throw new Error(stack.error);
536
+ try {
537
+ return { program: parsed.program, scopes: stack.analyze(parsed.program, { sourceType: "module" }) };
538
+ }
539
+ catch (e) {
540
+ throw new Error(`analysis failed to resolve scopes in ${file}: ${String(e)}`);
541
+ }
542
+ }