legend-doctor 0.2.0 → 0.4.0

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 (189) hide show
  1. package/EXAMPLES.md +44 -5
  2. package/README.md +12 -97
  3. package/REPORT.md +244 -0
  4. package/dist/src/analysis/assumptions/async-command-hypothesis.d.ts +4 -0
  5. package/dist/src/analysis/assumptions/async-command-hypothesis.d.ts.map +1 -0
  6. package/dist/src/analysis/assumptions/async-command-hypothesis.js +101 -0
  7. package/dist/src/analysis/assumptions/async-command-hypothesis.js.map +1 -0
  8. package/dist/src/analysis/assumptions/group-assumptions.js +3 -3
  9. package/dist/src/analysis/assumptions/group-assumptions.js.map +1 -1
  10. package/dist/src/analysis/assumptions/hypotheses.d.ts.map +1 -1
  11. package/dist/src/analysis/assumptions/hypotheses.js +2 -0
  12. package/dist/src/analysis/assumptions/hypotheses.js.map +1 -1
  13. package/dist/src/analysis/clusters/cowritten-clusters.js +1 -1
  14. package/dist/src/analysis/clusters/cowritten-clusters.js.map +1 -1
  15. package/dist/src/analysis/findings.d.ts.map +1 -1
  16. package/dist/src/analysis/findings.js +6 -1
  17. package/dist/src/analysis/findings.js.map +1 -1
  18. package/dist/src/analysis/review-guidance.d.ts +4 -0
  19. package/dist/src/analysis/review-guidance.d.ts.map +1 -0
  20. package/dist/src/analysis/review-guidance.js +69 -0
  21. package/dist/src/analysis/review-guidance.js.map +1 -0
  22. package/dist/src/analysis/transition-evidence.d.ts +6 -0
  23. package/dist/src/analysis/transition-evidence.d.ts.map +1 -0
  24. package/dist/src/analysis/transition-evidence.js +144 -0
  25. package/dist/src/analysis/transition-evidence.js.map +1 -0
  26. package/dist/src/analysis/verdicts/intrinsic-verdicts.js +1 -1
  27. package/dist/src/analysis/verdicts/intrinsic-verdicts.js.map +1 -1
  28. package/dist/src/cli/help.d.ts +1 -1
  29. package/dist/src/cli/help.d.ts.map +1 -1
  30. package/dist/src/cli/help.js +6 -0
  31. package/dist/src/cli/help.js.map +1 -1
  32. package/dist/src/cli/options.d.ts.map +1 -1
  33. package/dist/src/cli/options.js +1 -0
  34. package/dist/src/cli/options.js.map +1 -1
  35. package/dist/src/cli.js +5 -1
  36. package/dist/src/cli.js.map +1 -1
  37. package/dist/src/core/state-transitions.d.ts +28 -0
  38. package/dist/src/core/state-transitions.d.ts.map +1 -0
  39. package/dist/src/core/state-transitions.js +2 -0
  40. package/dist/src/core/state-transitions.js.map +1 -0
  41. package/dist/src/core/subscriptions.d.ts +93 -0
  42. package/dist/src/core/subscriptions.d.ts.map +1 -0
  43. package/dist/src/core/subscriptions.js +2 -0
  44. package/dist/src/core/subscriptions.js.map +1 -0
  45. package/dist/src/core/types.d.ts +19 -4
  46. package/dist/src/core/types.d.ts.map +1 -1
  47. package/dist/src/core/types.js.map +1 -1
  48. package/dist/src/index.d.ts +2 -1
  49. package/dist/src/index.d.ts.map +1 -1
  50. package/dist/src/practices/analyze-legend-practices.d.ts +4 -1
  51. package/dist/src/practices/analyze-legend-practices.d.ts.map +1 -1
  52. package/dist/src/practices/analyze-legend-practices.js +3 -1
  53. package/dist/src/practices/analyze-legend-practices.js.map +1 -1
  54. package/dist/src/practices/model.d.ts +3 -0
  55. package/dist/src/practices/model.d.ts.map +1 -1
  56. package/dist/src/practices/practice-rules.d.ts.map +1 -1
  57. package/dist/src/practices/practice-rules.js +3 -1
  58. package/dist/src/practices/practice-rules.js.map +1 -1
  59. package/dist/src/project/analysis-coverage.d.ts +3 -0
  60. package/dist/src/project/analysis-coverage.d.ts.map +1 -1
  61. package/dist/src/project/analysis-coverage.js.map +1 -1
  62. package/dist/src/project/analyze-path/analysis-context.d.ts.map +1 -1
  63. package/dist/src/project/analyze-path/analysis-context.js +3 -1
  64. package/dist/src/project/analyze-path/analysis-context.js.map +1 -1
  65. package/dist/src/project/analyze-path/analysis-pass.d.ts +2 -0
  66. package/dist/src/project/analyze-path/analysis-pass.d.ts.map +1 -1
  67. package/dist/src/project/analyze-path/analysis-pass.js +13 -2
  68. package/dist/src/project/analyze-path/analysis-pass.js.map +1 -1
  69. package/dist/src/project/analyze-path/analyze-path.d.ts +2 -0
  70. package/dist/src/project/analyze-path/analyze-path.d.ts.map +1 -1
  71. package/dist/src/project/analyze-path/analyze-path.js +25 -5
  72. package/dist/src/project/analyze-path/analyze-path.js.map +1 -1
  73. package/dist/src/project/source-components/module-resolution.d.ts +18 -1
  74. package/dist/src/project/source-components/module-resolution.d.ts.map +1 -1
  75. package/dist/src/project/source-components/module-resolution.js +27 -6
  76. package/dist/src/project/source-components/module-resolution.js.map +1 -1
  77. package/dist/src/project/source-components/observable-primitive-paths.d.ts +3 -0
  78. package/dist/src/project/source-components/observable-primitive-paths.d.ts.map +1 -0
  79. package/dist/src/project/source-components/observable-primitive-paths.js +58 -0
  80. package/dist/src/project/source-components/observable-primitive-paths.js.map +1 -0
  81. package/dist/src/project/source-components/source-components.d.ts +5 -1
  82. package/dist/src/project/source-components/source-components.d.ts.map +1 -1
  83. package/dist/src/project/source-components/source-components.js +12 -4
  84. package/dist/src/project/source-components/source-components.js.map +1 -1
  85. package/dist/src/project/source-components/source-context.d.ts +16 -0
  86. package/dist/src/project/source-components/source-context.d.ts.map +1 -0
  87. package/dist/src/project/source-components/source-context.js +79 -0
  88. package/dist/src/project/source-components/source-context.js.map +1 -0
  89. package/dist/src/project/state-flow/state-flow.js +8 -1
  90. package/dist/src/project/state-flow/state-flow.js.map +1 -1
  91. package/dist/src/project/subscription-measurements.d.ts +4 -0
  92. package/dist/src/project/subscription-measurements.d.ts.map +1 -0
  93. package/dist/src/project/subscription-measurements.js +67 -0
  94. package/dist/src/project/subscription-measurements.js.map +1 -0
  95. package/dist/src/project/workspace/packages.d.ts +9 -0
  96. package/dist/src/project/workspace/packages.d.ts.map +1 -0
  97. package/dist/src/project/workspace/packages.js +49 -0
  98. package/dist/src/project/workspace/packages.js.map +1 -0
  99. package/dist/src/project/workspace/resolution-host.d.ts +5 -0
  100. package/dist/src/project/workspace/resolution-host.d.ts.map +1 -0
  101. package/dist/src/project/workspace/resolution-host.js +25 -0
  102. package/dist/src/project/workspace/resolution-host.js.map +1 -0
  103. package/dist/src/project/workspace/source-closure.d.ts +3 -0
  104. package/dist/src/project/workspace/source-closure.d.ts.map +1 -0
  105. package/dist/src/project/workspace/source-closure.js +51 -0
  106. package/dist/src/project/workspace/source-closure.js.map +1 -0
  107. package/dist/src/report/subscription-measurements.d.ts +3 -0
  108. package/dist/src/report/subscription-measurements.d.ts.map +1 -0
  109. package/dist/src/report/subscription-measurements.js +59 -0
  110. package/dist/src/report/subscription-measurements.js.map +1 -0
  111. package/dist/src/report/subscription-plans.d.ts +5 -0
  112. package/dist/src/report/subscription-plans.d.ts.map +1 -0
  113. package/dist/src/report/subscription-plans.js +123 -0
  114. package/dist/src/report/subscription-plans.js.map +1 -0
  115. package/dist/src/rules/child-contract/base-ui-render-events.d.ts +8 -0
  116. package/dist/src/rules/child-contract/base-ui-render-events.d.ts.map +1 -0
  117. package/dist/src/rules/child-contract/base-ui-render-events.js +159 -0
  118. package/dist/src/rules/child-contract/base-ui-render-events.js.map +1 -0
  119. package/dist/src/rules/child-contract/expression-stages.d.ts.map +1 -1
  120. package/dist/src/rules/child-contract/expression-stages.js +2 -0
  121. package/dist/src/rules/child-contract/expression-stages.js.map +1 -1
  122. package/dist/src/rules/observable-reads/flow-expressions.d.ts +11 -0
  123. package/dist/src/rules/observable-reads/flow-expressions.d.ts.map +1 -0
  124. package/dist/src/rules/observable-reads/flow-expressions.js +159 -0
  125. package/dist/src/rules/observable-reads/flow-expressions.js.map +1 -0
  126. package/dist/src/rules/observable-reads/fresh-selector-results.d.ts +3 -4
  127. package/dist/src/rules/observable-reads/fresh-selector-results.d.ts.map +1 -1
  128. package/dist/src/rules/observable-reads/fresh-selector-results.js +38 -11
  129. package/dist/src/rules/observable-reads/fresh-selector-results.js.map +1 -1
  130. package/dist/src/rules/observable-reads/model.d.ts +1 -0
  131. package/dist/src/rules/observable-reads/model.d.ts.map +1 -1
  132. package/dist/src/rules/observable-reads/move-down.d.ts.map +1 -1
  133. package/dist/src/rules/observable-reads/move-down.js +62 -24
  134. package/dist/src/rules/observable-reads/move-down.js.map +1 -1
  135. package/dist/src/rules/observable-reads/move-into-child.d.ts.map +1 -1
  136. package/dist/src/rules/observable-reads/move-into-child.js +16 -2
  137. package/dist/src/rules/observable-reads/move-into-child.js.map +1 -1
  138. package/dist/src/rules/observable-reads/observable-reads.d.ts +3 -0
  139. package/dist/src/rules/observable-reads/observable-reads.d.ts.map +1 -1
  140. package/dist/src/rules/observable-reads/observable-reads.js +7 -0
  141. package/dist/src/rules/observable-reads/observable-reads.js.map +1 -1
  142. package/dist/src/rules/observable-reads/owner-subscription-work.d.ts +5 -0
  143. package/dist/src/rules/observable-reads/owner-subscription-work.d.ts.map +1 -0
  144. package/dist/src/rules/observable-reads/owner-subscription-work.js +61 -0
  145. package/dist/src/rules/observable-reads/owner-subscription-work.js.map +1 -0
  146. package/dist/src/rules/observable-reads/primitive-paths.d.ts +6 -0
  147. package/dist/src/rules/observable-reads/primitive-paths.d.ts.map +1 -0
  148. package/dist/src/rules/observable-reads/primitive-paths.js +126 -0
  149. package/dist/src/rules/observable-reads/primitive-paths.js.map +1 -0
  150. package/dist/src/rules/observable-reads/stable-effect-dependencies.d.ts +7 -0
  151. package/dist/src/rules/observable-reads/stable-effect-dependencies.d.ts.map +1 -0
  152. package/dist/src/rules/observable-reads/stable-effect-dependencies.js +114 -0
  153. package/dist/src/rules/observable-reads/stable-effect-dependencies.js.map +1 -0
  154. package/dist/src/rules/observable-reads/subscription-cut.d.ts +10 -0
  155. package/dist/src/rules/observable-reads/subscription-cut.d.ts.map +1 -0
  156. package/dist/src/rules/observable-reads/subscription-cut.js +81 -0
  157. package/dist/src/rules/observable-reads/subscription-cut.js.map +1 -0
  158. package/dist/src/rules/observable-reads/subscription-flow.d.ts +22 -0
  159. package/dist/src/rules/observable-reads/subscription-flow.d.ts.map +1 -0
  160. package/dist/src/rules/observable-reads/subscription-flow.js +142 -0
  161. package/dist/src/rules/observable-reads/subscription-flow.js.map +1 -0
  162. package/dist/src/rules/observable-reads/subscription-inventory.d.ts +5 -0
  163. package/dist/src/rules/observable-reads/subscription-inventory.d.ts.map +1 -0
  164. package/dist/src/rules/observable-reads/subscription-inventory.js +64 -0
  165. package/dist/src/rules/observable-reads/subscription-inventory.js.map +1 -0
  166. package/dist/src/rules/observable-reads/subscription-leaf-targets.d.ts +15 -0
  167. package/dist/src/rules/observable-reads/subscription-leaf-targets.d.ts.map +1 -0
  168. package/dist/src/rules/observable-reads/subscription-leaf-targets.js +84 -0
  169. package/dist/src/rules/observable-reads/subscription-leaf-targets.js.map +1 -0
  170. package/dist/src/rules/observable-reads/use-value-inputs.d.ts.map +1 -1
  171. package/dist/src/rules/observable-reads/use-value-inputs.js +14 -7
  172. package/dist/src/rules/observable-reads/use-value-inputs.js.map +1 -1
  173. package/dist/src/rules/observable-tracking/helper-summary.d.ts +26 -0
  174. package/dist/src/rules/observable-tracking/helper-summary.d.ts.map +1 -0
  175. package/dist/src/rules/observable-tracking/helper-summary.js +247 -0
  176. package/dist/src/rules/observable-tracking/helper-summary.js.map +1 -0
  177. package/dist/src/rules/observable-tracking/helper-tracking.d.ts +5 -0
  178. package/dist/src/rules/observable-tracking/helper-tracking.d.ts.map +1 -0
  179. package/dist/src/rules/observable-tracking/helper-tracking.js +77 -0
  180. package/dist/src/rules/observable-tracking/helper-tracking.js.map +1 -0
  181. package/dist/src/rules/observable-tracking/observable-tracking.d.ts.map +1 -1
  182. package/dist/src/rules/observable-tracking/observable-tracking.js +2 -1
  183. package/dist/src/rules/observable-tracking/observable-tracking.js.map +1 -1
  184. package/dist/src/rules/state-proofs/render-purpose.d.ts +2 -1
  185. package/dist/src/rules/state-proofs/render-purpose.d.ts.map +1 -1
  186. package/dist/src/rules/state-proofs/render-purpose.js +14 -7
  187. package/dist/src/rules/state-proofs/render-purpose.js.map +1 -1
  188. package/package.json +5 -2
  189. package/skills/legend-doctor/SKILL.md +10 -1
package/EXAMPLES.md CHANGED
@@ -234,6 +234,37 @@ function Avatar({ url$ }: { url$: Observable<string> }) {
234
234
  This also shows `move-use-value-down` and `move-use-value-into-child`: the parent passes observable references, not
235
235
  rendered values.
236
236
 
237
+ ### Move one subscription into several children
238
+
239
+ When one observable feeds separate small parts of a large owner, `move-use-value-down` can name several
240
+ boundaries in one instruction. Move every named read together so the parent no longer subscribes.
241
+
242
+ ```tsx
243
+ function Settings({ enabled$ }: { enabled$: Observable<boolean> }) {
244
+ return (
245
+ <main>
246
+ <UnrelatedSettings />
247
+ <section>
248
+ <EnabledInput enabled$={enabled$} label="Vertical" />
249
+ </section>
250
+ <aside>
251
+ <EnabledInput enabled$={enabled$} label="Horizontal" />
252
+ </aside>
253
+ </main>
254
+ );
255
+ }
256
+
257
+ function EnabledInput({ enabled$, label }: { enabled$: Observable<boolean>; label: string }) {
258
+ const enabled = useValue(enabled$);
259
+ return <input aria-label={label} disabled={!enabled} />;
260
+ }
261
+ ```
262
+
263
+ Define the children outside the parent, keep observable ownership unchanged, and pass other inputs as ordinary
264
+ props. Keep the evaluation of those inputs in the parent. If a named boundary contains a conditional, keep the
265
+ whole condition inside its always-mounted child. Prefer one cohesive child when it already isolates the reads;
266
+ separate subscriptions add overhead and are justified only when their combined render work stays small.
267
+
237
268
  ### Remove selector work and legacy names
238
269
 
239
270
  Use `pass-observable-to-use-value` for a direct value and `replace-legacy-use-value` for old APIs.
@@ -246,7 +277,12 @@ useSelector(profile$.name); // Before
246
277
  useValue(profile$.name); // After
247
278
  ```
248
279
 
249
- Keep `useValue(() => ...)` when the selector derives a value from one or more observables.
280
+ The same-node synchronous selector rewrite without options is `style`: it selects the same value, with no proven
281
+ render or lifecycle saving. Inside `observer`, direct input can use the enclosing observer's tracking instead of a
282
+ separate selector hook. Async selectors and calls with options remain unchanged because their Promise or tracking
283
+ contracts can differ. An eager `useValue(profile$.name.get())` is still `change`: direct input establishes tracking in
284
+ an ordinary component or avoids redundant selector hooks inside `observer`. Keep `useValue(() => ...)` when the
285
+ selector derives a value from one or more observables, including boolean projections and formatted computed values.
250
286
 
251
287
  ### Compute a derived primitive as an observable
252
288
 
@@ -477,7 +513,7 @@ receives, so a raw array breaks it. `useValue(x$.get())` stays with `pass-observ
477
513
 
478
514
  ### Split a selector that only builds a literal
479
515
 
480
- `split-use-value-result` changes a destructured selector whose members are direct reads or inert expressions.
516
+ `split-use-value-result` offers a `style` rewrite for a const destructured selector whose members are direct reads or inert expressions.
481
517
 
482
518
  ```tsx
483
519
  const { a, b } = useValue(() => ({ a: state$.a.get(), b: state$.b.get() })); // Before
@@ -485,9 +521,12 @@ const a = useValue(state$.a); // After
485
521
  const b = useValue(state$.b);
486
522
  ```
487
523
 
488
- The selector returns a new object on every tracked change, so each destructured consumer sees a fresh identity.
489
- Per-path subscriptions render on exactly the same changes and compare by value. Results used whole, block bodies,
490
- spreads, defaults, rest elements, computed members, and calls stay as they are.
524
+ The aggregate object is fresh; its destructured values retain their own identities. This rewrite removes the result
525
+ allocation and adds per-path subscriptions. It does not prove fewer owner renders, lower CPU cost, or less native
526
+ work. Every observable read must remain represented, including reads in otherwise unused fields: they may invalidate
527
+ ref-backed render snapshots. Omitted effects, duplicate properties, mutable declarations, results used whole, block
528
+ bodies, async selectors, prototype-setting properties, reordered reads, spreads, defaults, rest elements, computed
529
+ members, and calls stay as they are.
491
530
 
492
531
  ## Keep effect timing correct
493
532
 
package/README.md CHANGED
@@ -1,25 +1,8 @@
1
1
  # Legend Doctor
2
2
 
3
- Your React app renders more than it needs to. Legend Doctor proves where, and shows the exact edit that cuts it.
4
-
5
- It is a read-only scanner for TypeScript and JavaScript. It reads your `useState`, `useEffect`, and
6
- [Legend State](https://legendapp.com/open-source/state/) code and reports:
7
-
8
- - proven edits that remove a render or an effect
9
- - edits that need one more fact before they are safe
10
- - code that is already right and should stay as it is
11
-
12
- It never edits files. It never guesses. It emits `change` only when the TypeScript structure proves the edit.
13
-
14
- ## Why fewer renders
15
-
16
- Most React apps re-render whole trees when one value changes. Legend State fixes this by letting each leaf subscribe
17
- to exactly the value it renders. Jay Meistrich, who built Legend State, explains the idea and the numbers in his
18
- App.js Conf 2026 talk:
19
-
20
- [![How to Build the Fastest Apps: Break the Rules](https://i.ytimg.com/vi/K3flMIHS-cI/hqdefault.jpg)](https://youtu.be/K3flMIHS-cI)
21
-
22
- Legend Doctor is the tool that finds those cuts in an existing codebase, and holds every one of them to a proof.
3
+ A read-only analyzer for React and Legend State in TypeScript and JavaScript.
4
+ It reports suggested render and effect optimizations, unresolved opportunities, and code to keep.
5
+ Results are JSON; source files are never modified.
23
6
 
24
7
  ## Quick start
25
8
 
@@ -46,17 +29,12 @@ Copy the skill into your project, then the agent scans before and after every st
46
29
  cp -r node_modules/legend-doctor/skills/legend-doctor /path/to/app/.claude/skills/
47
30
  ```
48
31
 
49
- The report JSON is stable and versioned. Agents apply `change` findings, read the named source for `candidate`
50
- findings, and rescan.
51
-
52
- To see the effect at runtime, pair it with [genie-react](https://github.com/Genie-sa/genie-react). Genie counts real
53
- renders in the running app, so the agent can record a render count before the edit and check it dropped after.
32
+ Apply related findings together, validate the changes, and rescan. See [EXAMPLES.md](EXAMPLES.md).
54
33
 
55
34
  ## Run in CI
56
35
 
57
- The GitHub Action reviews every pull request and reports only the findings the change introduced, not the existing
58
- backlog. It posts one sticky summary comment, inline review comments on the changed lines, and a commit status. It is
59
- advisory by default. Add `.github/workflows/legend-doctor.yml`:
36
+ The GitHub Action reports findings introduced by a pull request. It is advisory by default.
37
+ Add `.github/workflows/legend-doctor.yml`:
60
38
 
61
39
  ```yaml
62
40
  name: Legend Doctor
@@ -80,29 +58,14 @@ jobs:
80
58
  - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
81
59
  with:
82
60
  fetch-depth: 0
83
- - uses: Genie-sa/legend-doctor@v0.1.1
61
+ - uses: Genie-sa/legend-doctor@v0.3.0
84
62
  with:
85
63
  directory: src
86
64
  ```
87
65
 
88
66
  Keep `fetch-depth: 0`. The action diffs the pull request against its base branch and needs the history.
89
67
 
90
- Or paste this to your coding agent:
91
-
92
- > Add Legend Doctor to this repository so it reviews every pull request. Create a branch, add the workflow file shown
93
- > in the "Run in CI" section of https://github.com/Genie-sa/legend-doctor exactly as written, set `directory` to the
94
- > folder that holds our React components, commit only that file, and open a pull request titled "Add Legend Doctor
95
- > CI". Do not change application code.
96
-
97
- | Input | Default | Meaning |
98
- | ---------------- | --------- | ---------------------------------------------------------------------------------------- |
99
- | `directory` | `.` | Folder to scan |
100
- | `scope` | `changed` | `changed` reports what the PR introduced, `files` every finding in changed files, `full` |
101
- | `blocking` | `none` | Fail the check on `change`, `candidate`, or `style` findings |
102
- | `materiality` | config | `compact` also reports render cuts in components with 8 to 11 JSX elements |
103
- | `ignore-actions` | config | Comma-separated actions to hide, added to the config file's list |
104
-
105
- The action skips pull requests that change no React files. [action.yml](action.yml) lists every input and output.
68
+ See [action.yml](action.yml) for scope, blocking, filtering, and other options.
106
69
 
107
70
  ## Configure
108
71
 
@@ -141,36 +104,8 @@ Every finding has a disposition:
141
104
  | `keep` | Correct as is. Leave the React or lifecycle boundary alone. |
142
105
  | `style` | Cleaner form. Apply only when the installed Legend State API supports it. |
143
106
 
144
- Stdout is always one JSON document and stays valid JSON when the scan fails, so a caller parses a single channel and
145
- never prose. Nothing is written to stderr.
146
-
147
- `legend-doctor --help` documents every report field. [REPORT.md](REPORT.md) goes further: the `assumption` a
148
- `review-state` finding carries when one yes/no fact would turn it into a `change`, and how to record the answer.
149
-
150
- ## The loop
151
-
152
- 1. Scan.
153
- 2. Apply one group of `change` findings.
154
- 3. Read the source for each `candidate`. Edit only when the missing fact is proven.
155
- 4. Run your formatter, typecheck, and tests.
156
- 5. Scan again. Stop when every remaining finding is `keep` or an understood `candidate`.
157
-
158
- Check [EXAMPLES.md](EXAMPLES.md) before applying an action you have not seen before.
159
-
160
- ## What it cuts
161
-
162
- | Cost | Typical action |
163
- | -------------------------------------------------- | ------------------------------------------------------ |
164
- | A parent render caused by one child's local state | `move-state-down`, `use-observable` |
165
- | A render for a value only commands or cleanup read | `use-ref` |
166
- | Derived state kept in sync by an effect | `delete-derived-state`, `delete-effect` |
167
- | An effect that runs after an event it could join | `move-to-event` |
168
- | A render used only to run an external reaction | `use-observe-effect` |
169
- | A subscription wider than the value rendered | `narrow-use-value-subscription`, `move-use-value-down` |
170
- | A render read that never subscribes | `use-value-for-render-read` |
171
- | A parent clone where one path changed | `narrow-observable-write`, `assign-observable-fields` |
172
-
173
- [EXAMPLES.md](EXAMPLES.md) has one worked example per action.
107
+ See [REPORT.md](REPORT.md) for review blockers, grouped transitions, subscription plans, and runtime measurements.
108
+ Validate suggested edits with your formatter, typecheck, and tests, then rescan.
174
109
 
175
110
  ## Commands
176
111
 
@@ -192,28 +127,8 @@ legend-doctor <root> --actionable --staged # staged files
192
127
  legend-doctor <root> --actionable --since origin/main # this branch
193
128
  ```
194
129
 
195
- By default a render cut proven by owner size needs a component with 12 or more JSX elements. `--materiality compact`
196
- lowers that to 8. Cuts proven another way — a transported read reaching a child that subscribes, or a custom hook
197
- owner — do not depend on the tier. Run `--help` for every flag and exit code.
198
-
199
- ## Safety rules
200
-
201
- - One owner per value. Do not mirror React state and an observable.
202
- - `useObservable` for state tied to a component's lifetime.
203
- - `useValue` at the smallest stable leaf that renders the value.
204
- - `.peek()` only in a proven non-tracking command.
205
- - `.assign()` or `batch()` when several writes are one update.
206
- - Keep small, one-control state in React when an observable removes no render.
207
- - Keep an effect when its timing, replay, or cleanup is not proven equivalent.
208
- - Apply grouped findings together.
209
- - Check the installed `@legendapp/state` version before changing an API.
210
-
211
- Under `@legendapp/state` 2.x the tracking rule is off, because `enableReactTracking({ auto: true })` can make render
212
- reads track app-wide. In React Compiler projects, keep clone writes unless the report proves the in-place write safe.
213
-
214
- These rules follow the [Legend State React API](https://legendapp.com/open-source/state/v3/react/react-api/), the
215
- [reactivity guide](https://legendapp.com/open-source/state/v3/usage/reactivity/), and the
216
- [Legend State best-practices skill](https://github.com/LegendApp/legend-skills/tree/main/legend-state-best-practices).
130
+ See `legend-doctor --help` for all flags and exit codes, [ACTIONS.md](ACTIONS.md) for actions, and
131
+ [EXAMPLES.md](EXAMPLES.md) for worked examples.
217
132
 
218
133
  ## Develop
219
134
 
package/REPORT.md ADDED
@@ -0,0 +1,244 @@
1
+ # Report reference
2
+
3
+ Field-level detail for the JSON report. Read [README.md](README.md) first.
4
+
5
+ Version-gate consumers with `schemaVersion`, currently `4`.
6
+
7
+ Grouped state findings may also include additive `transitions` evidence. `writes` lists direct
8
+ setter calls with state names, line/column positions, handler identities, and enclosing control
9
+ contexts. `relations` refers to zero-based write indices in the same handler. `coexecution: proven`
10
+ means a shared synchronous path exists; it does **not** mean all executions are one atomic update.
11
+ `disproven` includes separate suspension phases and mutually exclusive/unreachable paths; `unknown`
12
+ retains an explicit proof gap. No relation is inferred between different handlers.
13
+
14
+ Only `fusion: adjacent-literals` identifies adjacent replacements of distinct fields with literal
15
+ values that can form one `assign`. `preserve-source` means retain evaluation order, branches, and
16
+ exception/suspension boundaries; it does not authorize moving those expressions into an object
17
+ literal. Group reviews name unresolved pairs by exact source location. These are bounded source
18
+ facts, not a complete migration plan or new permission to convert a review finding. Transported
19
+ setters still depend on the existing child-contract proofs and are not listed as direct writes.
20
+
21
+ Schema 4 removes the unused `diagnostics.semantic` field. Coverage schema 2 reports only `parser`, `lowering`,
22
+ and `detector`: no detector consumed the former semantic stage. The experimental `createSemanticContext`,
23
+ `AnalysisContextOptions.configFilePath`, and semantic context types have been removed.
24
+
25
+ Important fields:
26
+
27
+ | Field | Meaning |
28
+ | -------------- | --------------------------------------------------------------------------- |
29
+ | `status` | `ok` or `error` |
30
+ | `root` | Base directory for every finding path |
31
+ | `analyzer` | Tool `version` and compiled `build` |
32
+ | `findings` | React state and effect findings |
33
+ | `practices` | Legend State practice findings |
34
+ | `hidden` | Findings removed by filters |
35
+ | `capabilities` | Legend State version and exports, React Compiler status, and disabled rules |
36
+ | `scope` | Active scope flag and loaded context file count |
37
+
38
+ Compare reports only when `analyzer.build` matches.
39
+
40
+ Every `review-state` and `review-effect` finding has an `abstentionReason`. It names the main fact or safety rule that
41
+ blocked a proven edit.
42
+
43
+ Every review also carries `review: { kind, blockers, next }`. This is additive guidance in schema 4;
44
+ the action and disposition remain authoritative. `blockers` combines the current reason, question facts,
45
+ and any group members' known next blockers. It is not an exhaustive list of every missing proof.
46
+
47
+ | `review.kind` | Next step |
48
+ | ------------------- | ------------------------------------------------------------------------------- |
49
+ | `confirm` | Research and answer the attached open question. |
50
+ | `recheck` | Re-read changed source before renewing a stale answer. |
51
+ | `declined` | Preserve the current behavior; the recorded answer rejected the conversion. |
52
+ | `dependency` | Resolve the question ids in `waitsOn`, then rescan. |
53
+ | `unsupported` | Resolve a binding, callback, or callable-state shape the analyzer cannot model. |
54
+ | `no-proven-benefit` | Establish a render or lifecycle saving before proposing a migration. |
55
+ | `investigate` | Follow `review.next`; no supported yes/no answer currently yields an edit. |
56
+
57
+ `async-command-origin-unresolved` means an async pending interval and leaf boundary are proven, but
58
+ the command's event origin is not. This differs from `callback-timing-unresolved`, which concerns
59
+ captured-value reads. Eligible direct JSX event references and inline adapters receive an event-origin
60
+ question; known direct render calls do not. Confirming it preserves the existing async command and
61
+ changes its pending writes and subscriber boundary. Old answers keyed to the former reason do not
62
+ silently confirm this new question.
63
+
64
+ Use an unfiltered scan to inventory all review kinds. `--actionable` still hides reviews without a
65
+ confirmable question; guidance does not override that filter or make a review actionable.
66
+
67
+ ## Answer a review question
68
+
69
+ When one yes/no fact is all that blocks a `review-state` finding, the finding also carries an `assumption`:
70
+
71
+ | Field | Meaning |
72
+ | ------------- | -------------------------------------------------------------------------------------------- |
73
+ | `id` | Stable across line shifts: report file, owner, state name, blocker |
74
+ | `question` | The concrete fact to confirm, naming the states, targets, or read sites involved |
75
+ | `facts` | The blockers a "yes" assumes away; one, or two when a second blocker stands behind the first |
76
+ | `research` | Distinct checks with `file`, first `line`, all `lines`, and the full source-site `total` |
77
+ | `ifConfirmed` | The action a confirmed answer produces; the tool re-ran its proofs with that fact assumed |
78
+ | `fingerprint` | Digest of the owner's source; an answer recorded for a different digest is reported `stale` |
79
+ | `renderCost` | JSX elements the owner renders per update of this state |
80
+ | `updateSites` | Setter call sites; `renderCost × updateSites` is the `priority` used by `report.questions` |
81
+ | `status` | `open`, `confirmed`, `rejected`, or `stale` |
82
+
83
+ A question is asked only when the hypothetical run yields a conversion, so every "yes" has a concrete instruction.
84
+ `report.questions` lists the open ones by `rank`. A group reports the first converting member's action in
85
+ `ifConfirmed` and the number that convert in `convertingCount`; individual outcomes remain in `members`.
86
+ Repeated research instructions list every relevant line and a full site count, including multiple sites on one line. A step without `lines` or `total` describes one site at `line`.
87
+ Record answers in `<root>/.legend-doctor/confirmations.json`, which
88
+ every scan of that root reads, or in any file passed with `--confirm`:
89
+
90
+ ```json
91
+ {
92
+ "confirmations": [
93
+ {
94
+ "id": "src/panel.tsx::Panel::open::atomic-transition-unproven",
95
+ "fingerprint": "8444a887430f",
96
+ "answer": "yes",
97
+ "note": "both writes sit in fail(); Drawer renders open directly (drawer.tsx:12)"
98
+ },
99
+ {
100
+ "id": "src/panel.tsx::Panel::filter::render-cut-unproven",
101
+ "fingerprint": "4f22f4aca655",
102
+ "answer": "no"
103
+ }
104
+ ]
105
+ }
106
+ ```
107
+
108
+ ```bash
109
+ legend-doctor <root>
110
+ ```
111
+
112
+ Or let the tool write the entry, fingerprint included, and report the scan that honours it in one command:
113
+
114
+ ```bash
115
+ legend-doctor <root> --answer "src/panel.tsx::Panel::open::atomic-transition-unproven=yes" --note "both writes sit in fail()"
116
+ ```
117
+
118
+ A `review-effect` finding can carry the same block: an empty-dependency setup effect asks whether `useMount`'s once-only
119
+ semantics are intended. An effect whose verdict waits on a React state instead lists that state's open question ids in
120
+ `waitsOn`, so the answer that settles the state settles the effect.
121
+
122
+ When assuming the first blocker away still leaves a review verdict, the tool assumes the next one too and asks both
123
+ facts in one question; the id then joins both reasons with `+`, and `facts` lists them in order. Two facts is the cap.
124
+
125
+ States a handler writes together share one question and one id, `file::Owner::{a,b}::atomic-transition-unproven`.
126
+ Its `members` list says what a "yes" does to each: members whose standalone proof already passes convert together under
127
+ one cluster instruction written with `assign`, and a member another blocker still holds is re-examined without the
128
+ co-write blocker and gets its next question on the same scan.
129
+
130
+ A confirmed individual question turns its finding into `ifConfirmed` with disposition `change`; a group converts only the members whose outcome is actionable. The answer is recorded in each converted finding's evidence. Such a finding also carries `verification`: the conversion rests on an answer rather than a proof, so the
131
+ recipe names the jsdom harness exported as `legend-doctor/runtime` (`mountDom`, `count`), the before/after comparison
132
+ to run, and the render-count and DOM expectations that must hold; a failed comparison means the answer was wrong. A rejected id keeps the review verdict and stops the question from being asked again. When the owner's code
133
+ changes, the fingerprint no longer matches: the answer is reported `stale`, not applied, and the question is asked
134
+ again. The report's `confirmations` block counts applied, rejected, and stale answers, names the file they came from,
135
+ and lists ids no finding produced.
136
+
137
+ Failures are also valid JSON. They include `status: "error"`, a stable `reason`, a useful `message`, and
138
+ sometimes a `next` command.
139
+
140
+ ## Compact provenance
141
+
142
+ `materiality: "compact"` means compact mode changed the finding's action or made a new conversion confirmable.
143
+ Kept findings and outcomes already available in broad mode are untagged. This comparison includes consumer-size
144
+ thresholds for hook-owned state. Compact mode evaluates both policies on the same parsed source; it adds analysis
145
+ work but does not reread or reparse files.
146
+
147
+ ## Coordinated subscriptions (version 1)
148
+
149
+ `subscriptionAnalysis` is an additive section of schema 4. Its own `version` is `1`.
150
+
151
+ - `inventory` records each recognized imported `useValue` call in eligible scanned files, including aliases.
152
+ Each entry contains its source location, binding, observable, classified reads, derivations, and status:
153
+ `planned`, `other-action`, or `unresolved`. Unresolved entries have explicit reasons; they are not findings.
154
+ - `coverage` counts those three statuses and their total. This is subscription inventory coverage, separate
155
+ from hook coverage and manually labeled corpus recall. It does not count hidden subscriptions inside
156
+ arbitrary custom hooks or unrecognized imports.
157
+ - `plans` groups actionable subscription cuts by owner. Overlapping JSX boundaries merge into one child.
158
+ Each plan lists subscriptions, complete derivation chains, child locations, remaining parent inputs,
159
+ implementation steps, and behavioral verification. Define new children at module scope and retain their
160
+ mount slots. Keep observable creation and atomic writes in their existing owner.
161
+ - `impact.basis: "static-jsx"` ranks by owner JSX elements outside the proposed children. These are source
162
+ counts, not render counts, elapsed time, or a promised speedup. `rank` starts at 1.
163
+ - `impact.basis: "provided-runtime-measurement"` identifies externally supplied before/after render counts.
164
+ Measurements do not bypass detector proofs or create findings.
165
+ - `rejectedMeasurements` reports malformed, stale, duplicate, or unmatched measurement entries.
166
+
167
+ A practice finding with a coordinated cut also has `subscription` metadata. Report filters remove matching
168
+ plans and mark filtered inventory entries `excluded-by-report-filter`, so ignored actions do not reappear
169
+ as implementation instructions. Filtering away any part of a plan also removes its runtime measurement;
170
+ measurements of a complete edit cannot rank a partial edit.
171
+
172
+ To attach runtime evidence, create `<analysis-root>/.legend-doctor/subscription-measurements.json`:
173
+
174
+ ```json
175
+ [
176
+ {
177
+ "planId": "copy plans[i].id from the baseline report",
178
+ "fingerprint": "copy plans[i].fingerprint from the baseline report",
179
+ "scenario": "toggle the setting ten times with unrelated UI mounted",
180
+ "samples": 10,
181
+ "before": { "ownerRenders": 10, "siblingRenders": 30 },
182
+ "after": { "ownerRenders": 0, "siblingRenders": 0 },
183
+ "behaviorEquivalent": true
184
+ }
185
+ ]
186
+ ```
187
+
188
+ Record equal interaction samples before and after in the same runtime configuration, excluding initial
189
+ mounts. Verify visible values, drafts, callback snapshots, identity, effect cleanup, and atomic updates
190
+ before setting `behaviorEquivalent`. The analyzer trusts this supplied assertion; it does not run the app.
191
+ Attach the evidence when scanning the **baseline source**: the fingerprint hashes the owner's source text,
192
+ so a scan of the edited owner rejects it as stale. External modules and runtime settings are not included
193
+ in that fingerprint; repeat measurements when either changes. Programmatic `analyzePath` callers may pass
194
+ `subscriptionMeasurements` instead of creating the file.
195
+
196
+ Measured positive savings rank first, unmeasured static plans next, and measured zero/negative savings last.
197
+ Within measured groups, ranking uses total owner-plus-sibling renders saved per sample. This ordering is a
198
+ triage aid; scenario frequency and render duration still require application profiling.
199
+
200
+ Closed `const` aliases/defaults and supported `useMemo` projections move with their subscriptions. Memo
201
+ identity and dependencies remain intact. Literal primitive effect dependencies and explicitly typed primitive
202
+ props can prove that an independent effect will not rerun on subscription-only updates. Missing/unstable
203
+ or unresolved dependencies, callback snapshots, refs, overlapping parent subscriptions, repeated render
204
+ callbacks, and unsupported expressions remain conservative blockers. General selector relocation is not
205
+ implied by inventory coverage.
206
+
207
+ ### Imported source coverage
208
+
209
+ With `--coverage`, `coverage.sourceContext` lists each target file's `requestedProofs` and
210
+ `unavailable` runtime import/re-export edges reachable through indexed source. Each edge names the
211
+ `importer`, `specifier`, selected `resolvedFile` when present, and one reason:
212
+
213
+ - `module-unresolved`: TypeScript did not select a module under the current installation/configuration.
214
+ - `declaration-only`: TypeScript selected a declaration file, which cannot supply implementation behavior.
215
+ - `source-not-indexed`: the selected implementation is outside the source index or was rejected after parser recovery.
216
+
217
+ Paths are relative to the scan root. Installed dependencies and package export conditions retain their
218
+ normal precedence; this diagnostic never substitutes a same-named workspace implementation.
219
+ `requestedProofs` names file-level source-symbol queries used by current consumers (such as observable,
220
+ observable-factory, component, and callback contracts). An unavailable edge can block these proofs;
221
+ it does not establish that any particular recommendation was missed. Known framework API contracts
222
+ may still work without implementation source. Detector stage `analyzed` describes execution, not
223
+ complete imported semantics. Empty `unavailable` does not prove export compatibility or successful
224
+ symbol proofs. Type-only, side-effect-only, dynamic imports and CommonJS require edges are outside
225
+ this static symbol-edge inventory. Ordinary reports and action scoring are unchanged.
226
+
227
+ ## Helper tracking reviews
228
+
229
+ `review-helper-tracking` practices have `disposition: candidate`. They identify a direct local helper
230
+ called from an imported `useValue`, `useObserve`, `useObserveEffect`, or `observe` selector. The
231
+ selector must have independent direct reads. A direct parent read already covers a helper's child
232
+ read; a helper's broader parent read can introduce sibling dependencies beyond a direct child read.
233
+ Evidence lists helper reads, writes, and synchronous `batch` boundaries.
234
+ Additional dependencies can repeat selector work; the review does not establish React render savings,
235
+ a measured execution count, or permission to replace shared helper reads with `peek`.
236
+
237
+ This first phase abstains on imported helpers, call chains, recursion, mutable or shadowed dispatch,
238
+ async/generator functions, parameter defaults, conditional or abrupt control flow, unproven helper
239
+ initialization, and unresolved calls. Nested
240
+ callback bodies do not inherit tracking merely by lexical containment. Separate reaction arguments
241
+ remain separate. These limits can miss opportunities; absence of a review is not proof of no tracking.
242
+
243
+ Use `--disposition candidate` to inspect these reviews. `--actionable` hides them and counts them under
244
+ `hidden.practices`. They are listed separately from optimization precision in corpus output.
@@ -0,0 +1,4 @@
1
+ import type { Hypothesis, HypothesisScope } from "./hypotheses.js";
2
+ /** The async interval and leaf cut have already passed; only command origins may be assumed. */
3
+ export declare function asyncCommandHypothesis(scope: HypothesisScope): Hypothesis | null;
4
+ //# sourceMappingURL=async-command-hypothesis.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"async-command-hypothesis.d.ts","sourceRoot":"","sources":["../../../../src/analysis/assumptions/async-command-hypothesis.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAkGnE,gGAAgG;AAChG,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,eAAe,GAAG,UAAU,GAAG,IAAI,CAiChF"}
@@ -0,0 +1,101 @@
1
+ import { bindingDeclarationCount, isNonValueIdentifier, unwrapTransparentExpression, } from "../../core/analysis-ast.js";
2
+ import { findAncestorUntil, identifiersNamed, nearestNestedFunction } from "../../core/ast.js";
3
+ import { isBindingName } from "../../rules/child-contract/prop-bindings.js";
4
+ import { runtimeFunctionName } from "../ast-helpers.js";
5
+ import ts from "typescript";
6
+ /** Timing confirmation cannot establish execution through arbitrary control flow. */
7
+ function directAdapterCall(expression) {
8
+ const { body } = expression;
9
+ if (!ts.isBlock(body)) {
10
+ return body;
11
+ }
12
+ const [statement] = body.statements;
13
+ if (body.statements.length !== 1 || !statement) {
14
+ return undefined;
15
+ }
16
+ return ts.isExpressionStatement(statement) || ts.isReturnStatement(statement)
17
+ ? statement.expression
18
+ : undefined;
19
+ }
20
+ /** A direct JSX event reference or inline event adapter can be researched; an eager call cannot. */
21
+ function eventReference(reference, scope) {
22
+ const attribute = findAncestorUntil(reference, ts.isJsxAttribute, scope.inputs.state.owner);
23
+ const initializer = attribute?.initializer;
24
+ if (!attribute ||
25
+ !/^on[A-Z]/u.test(attribute.name.getText()) ||
26
+ !initializer ||
27
+ !ts.isJsxExpression(initializer) ||
28
+ !initializer.expression) {
29
+ return null;
30
+ }
31
+ const expression = unwrapTransparentExpression(initializer.expression);
32
+ if (expression === reference) {
33
+ return attribute;
34
+ }
35
+ return isDirectAdapter(reference, expression, scope) ? attribute : null;
36
+ }
37
+ function isDirectAdapter(reference, expression, scope) {
38
+ if (!ts.isCallExpression(reference.parent) ||
39
+ reference.parent.expression !== reference ||
40
+ nearestNestedFunction(reference, scope.inputs.state.owner) !== expression ||
41
+ (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression)) ||
42
+ expression.asteriskToken) {
43
+ return false;
44
+ }
45
+ const call = directAdapterCall(expression);
46
+ return Boolean(call && unwrapTransparentExpression(call) === reference.parent);
47
+ }
48
+ function commandEventReferences(scope) {
49
+ const { state, usage } = scope.inputs;
50
+ const commands = new Set(usage.setterCallNodes.map((call) => nearestNestedFunction(call, state.owner)));
51
+ const sites = [];
52
+ for (const command of commands) {
53
+ const events = command ? eventsForCommand(runtimeFunctionName(command), scope) : null;
54
+ if (!events) {
55
+ return null;
56
+ }
57
+ sites.push(...events);
58
+ }
59
+ return sites.length > 0 ? sites : null;
60
+ }
61
+ function eventsForCommand(name, scope) {
62
+ if (!name || bindingDeclarationCount(scope.inputs.state.owner, name) !== 1) {
63
+ return null;
64
+ }
65
+ const references = identifiersNamed(scope.inputs.state.owner.body, name).filter((reference) => !isBindingName(reference) && !isNonValueIdentifier(reference));
66
+ const events = references.map((reference) => eventReference(reference, scope));
67
+ return events.length === 0 || events.some((event) => event === null)
68
+ ? null
69
+ : events.filter((event) => event !== null);
70
+ }
71
+ /** The async interval and leaf cut have already passed; only command origins may be assumed. */
72
+ export function asyncCommandHypothesis(scope) {
73
+ if (!scope.inputs.isUnprovenAsyncStatus) {
74
+ return null;
75
+ }
76
+ const sites = commandEventReferences(scope);
77
+ if (!sites) {
78
+ return null;
79
+ }
80
+ const lines = [
81
+ ...new Set(sites.map((site) => scope.inputs.sourceFile.getLineAndCharacterOfPosition(site.getStart()).line + 1)),
82
+ ].toSorted((left, right) => left - right);
83
+ const [line] = lines;
84
+ if (line === undefined) {
85
+ return null;
86
+ }
87
+ return {
88
+ inputs: { ...scope.inputs, isAsyncLeafStatus: true, isUnprovenAsyncStatus: false },
89
+ question: `The pending interval and leaf render boundary of \`${scope.inputs.state.valueName}\` are proven. Confirm every listed callback prop invokes its command only from a user event, never during render, memo calculation, effect setup, or subscription registration; keep the command and its async completion boundary unchanged.`,
90
+ research: [
91
+ {
92
+ file: scope.reportFile,
93
+ line,
94
+ lines,
95
+ total: sites.length,
96
+ check: "Open every receiving component and follow this callback prop through wrappers to its event handler; a prop name alone does not prove event timing.",
97
+ },
98
+ ],
99
+ };
100
+ }
101
+ //# sourceMappingURL=async-command-hypothesis.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"async-command-hypothesis.js","sourceRoot":"","sources":["../../../../src/analysis/assumptions/async-command-hypothesis.ts"],"names":[],"mappings":"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"}
@@ -75,7 +75,7 @@ function memberResearch(scope, { alone, member }) {
75
75
  ? []
76
76
  : [
77
77
  {
78
- check: `this write to \`${member.valueName}\` runs in a handler that also writes other members of ${names}; confirm a render could never observe one member updated without the others`,
78
+ check: `inspect each write to \`${member.valueName}\` in the connected group ${names}; preserve its branch, await, and exception phase, and verify which same-phase writes must publish together`,
79
79
  file: scope.reportFile,
80
80
  line: lines[0],
81
81
  lines,
@@ -89,13 +89,13 @@ function groupQuestion(owner, outcomes) {
89
89
  const converting = convertingOutcomes(outcomes);
90
90
  const blocked = outcomes.filter((outcome) => groupConversion(outcome.alone) === null);
91
91
  const converts = converting.length > 0
92
- ? ` A "yes" converts ${quotedList(converting.map(({ member }) => member.valueName))} into one observable object written with a single \`assign\`.`
92
+ ? ` A "yes" converts ${quotedList(converting.map(({ member }) => member.valueName))} into one observable object with a separate atomic update for each proven synchronous transition.`
93
93
  : "";
94
94
  const reasons = [...new Set(blocked.map(({ alone }) => remainingLabel(alone)))].join(", ");
95
95
  const remains = blocked.length > 0
96
96
  ? ` ${quotedList(blocked.map(({ member }) => member.valueName))} ${blocked.length === 1 ? "is" : "are"} not converted by this answer (${reasons}); a remaining blocker gets its own question next.`
97
97
  : "";
98
- return `${names} are written together in ${owner}'s handlers; confirm they change as one atomic transition that no render may observe half-applied.${converts}${remains}`;
98
+ return `${names} are written together in ${owner}'s handlers; verify the individual write relations in \`transitions\` and confirm a migration that preserves each branch, await, and catch/finally phase. A connected group is not a single atomic transition; synchronous coexecution alone does not authorize merging expressions into one \`assign\`.${converts}${remains}`;
99
99
  }
100
100
  function confirmedVerdict(outcome, converting, hookOwned) {
101
101
  const { alone } = outcome;