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.
- package/EXAMPLES.md +44 -5
- package/README.md +12 -97
- package/REPORT.md +244 -0
- package/dist/src/analysis/assumptions/async-command-hypothesis.d.ts +4 -0
- package/dist/src/analysis/assumptions/async-command-hypothesis.d.ts.map +1 -0
- package/dist/src/analysis/assumptions/async-command-hypothesis.js +101 -0
- package/dist/src/analysis/assumptions/async-command-hypothesis.js.map +1 -0
- package/dist/src/analysis/assumptions/group-assumptions.js +3 -3
- package/dist/src/analysis/assumptions/group-assumptions.js.map +1 -1
- package/dist/src/analysis/assumptions/hypotheses.d.ts.map +1 -1
- package/dist/src/analysis/assumptions/hypotheses.js +2 -0
- package/dist/src/analysis/assumptions/hypotheses.js.map +1 -1
- package/dist/src/analysis/clusters/cowritten-clusters.js +1 -1
- package/dist/src/analysis/clusters/cowritten-clusters.js.map +1 -1
- package/dist/src/analysis/findings.d.ts.map +1 -1
- package/dist/src/analysis/findings.js +6 -1
- package/dist/src/analysis/findings.js.map +1 -1
- package/dist/src/analysis/review-guidance.d.ts +4 -0
- package/dist/src/analysis/review-guidance.d.ts.map +1 -0
- package/dist/src/analysis/review-guidance.js +69 -0
- package/dist/src/analysis/review-guidance.js.map +1 -0
- package/dist/src/analysis/transition-evidence.d.ts +6 -0
- package/dist/src/analysis/transition-evidence.d.ts.map +1 -0
- package/dist/src/analysis/transition-evidence.js +144 -0
- package/dist/src/analysis/transition-evidence.js.map +1 -0
- package/dist/src/analysis/verdicts/intrinsic-verdicts.js +1 -1
- package/dist/src/analysis/verdicts/intrinsic-verdicts.js.map +1 -1
- package/dist/src/cli/help.d.ts +1 -1
- package/dist/src/cli/help.d.ts.map +1 -1
- package/dist/src/cli/help.js +6 -0
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/options.d.ts.map +1 -1
- package/dist/src/cli/options.js +1 -0
- package/dist/src/cli/options.js.map +1 -1
- package/dist/src/cli.js +5 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/src/core/state-transitions.d.ts +28 -0
- package/dist/src/core/state-transitions.d.ts.map +1 -0
- package/dist/src/core/state-transitions.js +2 -0
- package/dist/src/core/state-transitions.js.map +1 -0
- package/dist/src/core/subscriptions.d.ts +93 -0
- package/dist/src/core/subscriptions.d.ts.map +1 -0
- package/dist/src/core/subscriptions.js +2 -0
- package/dist/src/core/subscriptions.js.map +1 -0
- package/dist/src/core/types.d.ts +19 -4
- package/dist/src/core/types.d.ts.map +1 -1
- package/dist/src/core/types.js.map +1 -1
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/practices/analyze-legend-practices.d.ts +4 -1
- package/dist/src/practices/analyze-legend-practices.d.ts.map +1 -1
- package/dist/src/practices/analyze-legend-practices.js +3 -1
- package/dist/src/practices/analyze-legend-practices.js.map +1 -1
- package/dist/src/practices/model.d.ts +3 -0
- package/dist/src/practices/model.d.ts.map +1 -1
- package/dist/src/practices/practice-rules.d.ts.map +1 -1
- package/dist/src/practices/practice-rules.js +3 -1
- package/dist/src/practices/practice-rules.js.map +1 -1
- package/dist/src/project/analysis-coverage.d.ts +3 -0
- package/dist/src/project/analysis-coverage.d.ts.map +1 -1
- package/dist/src/project/analysis-coverage.js.map +1 -1
- package/dist/src/project/analyze-path/analysis-context.d.ts.map +1 -1
- package/dist/src/project/analyze-path/analysis-context.js +3 -1
- package/dist/src/project/analyze-path/analysis-context.js.map +1 -1
- package/dist/src/project/analyze-path/analysis-pass.d.ts +2 -0
- package/dist/src/project/analyze-path/analysis-pass.d.ts.map +1 -1
- package/dist/src/project/analyze-path/analysis-pass.js +13 -2
- package/dist/src/project/analyze-path/analysis-pass.js.map +1 -1
- package/dist/src/project/analyze-path/analyze-path.d.ts +2 -0
- package/dist/src/project/analyze-path/analyze-path.d.ts.map +1 -1
- package/dist/src/project/analyze-path/analyze-path.js +25 -5
- package/dist/src/project/analyze-path/analyze-path.js.map +1 -1
- package/dist/src/project/source-components/module-resolution.d.ts +18 -1
- package/dist/src/project/source-components/module-resolution.d.ts.map +1 -1
- package/dist/src/project/source-components/module-resolution.js +27 -6
- package/dist/src/project/source-components/module-resolution.js.map +1 -1
- package/dist/src/project/source-components/observable-primitive-paths.d.ts +3 -0
- package/dist/src/project/source-components/observable-primitive-paths.d.ts.map +1 -0
- package/dist/src/project/source-components/observable-primitive-paths.js +58 -0
- package/dist/src/project/source-components/observable-primitive-paths.js.map +1 -0
- package/dist/src/project/source-components/source-components.d.ts +5 -1
- package/dist/src/project/source-components/source-components.d.ts.map +1 -1
- package/dist/src/project/source-components/source-components.js +12 -4
- package/dist/src/project/source-components/source-components.js.map +1 -1
- package/dist/src/project/source-components/source-context.d.ts +16 -0
- package/dist/src/project/source-components/source-context.d.ts.map +1 -0
- package/dist/src/project/source-components/source-context.js +79 -0
- package/dist/src/project/source-components/source-context.js.map +1 -0
- package/dist/src/project/state-flow/state-flow.js +8 -1
- package/dist/src/project/state-flow/state-flow.js.map +1 -1
- package/dist/src/project/subscription-measurements.d.ts +4 -0
- package/dist/src/project/subscription-measurements.d.ts.map +1 -0
- package/dist/src/project/subscription-measurements.js +67 -0
- package/dist/src/project/subscription-measurements.js.map +1 -0
- package/dist/src/project/workspace/packages.d.ts +9 -0
- package/dist/src/project/workspace/packages.d.ts.map +1 -0
- package/dist/src/project/workspace/packages.js +49 -0
- package/dist/src/project/workspace/packages.js.map +1 -0
- package/dist/src/project/workspace/resolution-host.d.ts +5 -0
- package/dist/src/project/workspace/resolution-host.d.ts.map +1 -0
- package/dist/src/project/workspace/resolution-host.js +25 -0
- package/dist/src/project/workspace/resolution-host.js.map +1 -0
- package/dist/src/project/workspace/source-closure.d.ts +3 -0
- package/dist/src/project/workspace/source-closure.d.ts.map +1 -0
- package/dist/src/project/workspace/source-closure.js +51 -0
- package/dist/src/project/workspace/source-closure.js.map +1 -0
- package/dist/src/report/subscription-measurements.d.ts +3 -0
- package/dist/src/report/subscription-measurements.d.ts.map +1 -0
- package/dist/src/report/subscription-measurements.js +59 -0
- package/dist/src/report/subscription-measurements.js.map +1 -0
- package/dist/src/report/subscription-plans.d.ts +5 -0
- package/dist/src/report/subscription-plans.d.ts.map +1 -0
- package/dist/src/report/subscription-plans.js +123 -0
- package/dist/src/report/subscription-plans.js.map +1 -0
- package/dist/src/rules/child-contract/base-ui-render-events.d.ts +8 -0
- package/dist/src/rules/child-contract/base-ui-render-events.d.ts.map +1 -0
- package/dist/src/rules/child-contract/base-ui-render-events.js +159 -0
- package/dist/src/rules/child-contract/base-ui-render-events.js.map +1 -0
- package/dist/src/rules/child-contract/expression-stages.d.ts.map +1 -1
- package/dist/src/rules/child-contract/expression-stages.js +2 -0
- package/dist/src/rules/child-contract/expression-stages.js.map +1 -1
- package/dist/src/rules/observable-reads/flow-expressions.d.ts +11 -0
- package/dist/src/rules/observable-reads/flow-expressions.d.ts.map +1 -0
- package/dist/src/rules/observable-reads/flow-expressions.js +159 -0
- package/dist/src/rules/observable-reads/flow-expressions.js.map +1 -0
- package/dist/src/rules/observable-reads/fresh-selector-results.d.ts +3 -4
- package/dist/src/rules/observable-reads/fresh-selector-results.d.ts.map +1 -1
- package/dist/src/rules/observable-reads/fresh-selector-results.js +38 -11
- package/dist/src/rules/observable-reads/fresh-selector-results.js.map +1 -1
- package/dist/src/rules/observable-reads/model.d.ts +1 -0
- package/dist/src/rules/observable-reads/model.d.ts.map +1 -1
- package/dist/src/rules/observable-reads/move-down.d.ts.map +1 -1
- package/dist/src/rules/observable-reads/move-down.js +62 -24
- package/dist/src/rules/observable-reads/move-down.js.map +1 -1
- package/dist/src/rules/observable-reads/move-into-child.d.ts.map +1 -1
- package/dist/src/rules/observable-reads/move-into-child.js +16 -2
- package/dist/src/rules/observable-reads/move-into-child.js.map +1 -1
- package/dist/src/rules/observable-reads/observable-reads.d.ts +3 -0
- package/dist/src/rules/observable-reads/observable-reads.d.ts.map +1 -1
- package/dist/src/rules/observable-reads/observable-reads.js +7 -0
- package/dist/src/rules/observable-reads/observable-reads.js.map +1 -1
- package/dist/src/rules/observable-reads/owner-subscription-work.d.ts +5 -0
- package/dist/src/rules/observable-reads/owner-subscription-work.d.ts.map +1 -0
- package/dist/src/rules/observable-reads/owner-subscription-work.js +61 -0
- package/dist/src/rules/observable-reads/owner-subscription-work.js.map +1 -0
- package/dist/src/rules/observable-reads/primitive-paths.d.ts +6 -0
- package/dist/src/rules/observable-reads/primitive-paths.d.ts.map +1 -0
- package/dist/src/rules/observable-reads/primitive-paths.js +126 -0
- package/dist/src/rules/observable-reads/primitive-paths.js.map +1 -0
- package/dist/src/rules/observable-reads/stable-effect-dependencies.d.ts +7 -0
- package/dist/src/rules/observable-reads/stable-effect-dependencies.d.ts.map +1 -0
- package/dist/src/rules/observable-reads/stable-effect-dependencies.js +114 -0
- package/dist/src/rules/observable-reads/stable-effect-dependencies.js.map +1 -0
- package/dist/src/rules/observable-reads/subscription-cut.d.ts +10 -0
- package/dist/src/rules/observable-reads/subscription-cut.d.ts.map +1 -0
- package/dist/src/rules/observable-reads/subscription-cut.js +81 -0
- package/dist/src/rules/observable-reads/subscription-cut.js.map +1 -0
- package/dist/src/rules/observable-reads/subscription-flow.d.ts +22 -0
- package/dist/src/rules/observable-reads/subscription-flow.d.ts.map +1 -0
- package/dist/src/rules/observable-reads/subscription-flow.js +142 -0
- package/dist/src/rules/observable-reads/subscription-flow.js.map +1 -0
- package/dist/src/rules/observable-reads/subscription-inventory.d.ts +5 -0
- package/dist/src/rules/observable-reads/subscription-inventory.d.ts.map +1 -0
- package/dist/src/rules/observable-reads/subscription-inventory.js +64 -0
- package/dist/src/rules/observable-reads/subscription-inventory.js.map +1 -0
- package/dist/src/rules/observable-reads/subscription-leaf-targets.d.ts +15 -0
- package/dist/src/rules/observable-reads/subscription-leaf-targets.d.ts.map +1 -0
- package/dist/src/rules/observable-reads/subscription-leaf-targets.js +84 -0
- package/dist/src/rules/observable-reads/subscription-leaf-targets.js.map +1 -0
- package/dist/src/rules/observable-reads/use-value-inputs.d.ts.map +1 -1
- package/dist/src/rules/observable-reads/use-value-inputs.js +14 -7
- package/dist/src/rules/observable-reads/use-value-inputs.js.map +1 -1
- package/dist/src/rules/observable-tracking/helper-summary.d.ts +26 -0
- package/dist/src/rules/observable-tracking/helper-summary.d.ts.map +1 -0
- package/dist/src/rules/observable-tracking/helper-summary.js +247 -0
- package/dist/src/rules/observable-tracking/helper-summary.js.map +1 -0
- package/dist/src/rules/observable-tracking/helper-tracking.d.ts +5 -0
- package/dist/src/rules/observable-tracking/helper-tracking.d.ts.map +1 -0
- package/dist/src/rules/observable-tracking/helper-tracking.js +77 -0
- package/dist/src/rules/observable-tracking/helper-tracking.js.map +1 -0
- package/dist/src/rules/observable-tracking/observable-tracking.d.ts.map +1 -1
- package/dist/src/rules/observable-tracking/observable-tracking.js +2 -1
- package/dist/src/rules/observable-tracking/observable-tracking.js.map +1 -1
- package/dist/src/rules/state-proofs/render-purpose.d.ts +2 -1
- package/dist/src/rules/state-proofs/render-purpose.d.ts.map +1 -1
- package/dist/src/rules/state-proofs/render-purpose.js +14 -7
- package/dist/src/rules/state-proofs/render-purpose.js.map +1 -1
- package/package.json +5 -2
- 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
|
-
|
|
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`
|
|
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
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
[](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
|
-
|
|
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
|
|
58
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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: `
|
|
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
|
|
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
|
|
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;
|