@lariocpt/flawless-react 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +611 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- package/src/version.ts +12 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { RenderMode } from "../types.ts";
|
|
3
|
+
import type { ImportBinding } from "./parseFile.ts";
|
|
4
|
+
import { emptyEnv, initialRenderExpression, type RenderEnv } from "./renderPath.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How a component can be mounted in a test, and why.
|
|
8
|
+
*
|
|
9
|
+
* Some components genuinely cannot be rendered standalone, and generating a
|
|
10
|
+
* render-based assertion for them produces a red suite that says nothing. A
|
|
11
|
+
* design-system "part" (`<Dialog.Content>`, `<Select.Item>`) throws
|
|
12
|
+
* "SelectRootContext is missing" outside its Root; a root layout returns
|
|
13
|
+
* `<html>`, which React cannot mount inside a container div; a hydrator
|
|
14
|
+
* returns null. Naming the situation lets the scaffolder assert what IS true
|
|
15
|
+
* instead of pretending.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Headless-UI packages whose parts require a Root ancestor. */
|
|
19
|
+
const COMPOUND_PACKAGES = /^@(base-ui|radix-ui|ariakit)(\/|$)/;
|
|
20
|
+
|
|
21
|
+
/** Members that ARE the provider — these render fine on their own. */
|
|
22
|
+
const ROOT_MEMBERS = new Set(["Root", "Provider"]);
|
|
23
|
+
|
|
24
|
+
export interface RenderModeResult {
|
|
25
|
+
mode: RenderMode;
|
|
26
|
+
/** Human-readable reason, emitted into the spec and the run report. */
|
|
27
|
+
note?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function extractRenderMode(
|
|
31
|
+
bodyNode: ts.Node,
|
|
32
|
+
imports: Map<string, ImportBinding>,
|
|
33
|
+
isAsync: boolean,
|
|
34
|
+
env: RenderEnv = emptyEnv(),
|
|
35
|
+
/** Render modes of the other components declared in the same file. */
|
|
36
|
+
localModes?: Map<string, RenderMode>,
|
|
37
|
+
): RenderModeResult {
|
|
38
|
+
const expr = initialRenderExpression(bodyNode, env)?.expr;
|
|
39
|
+
|
|
40
|
+
if (expr && isNullLiteral(expr)) {
|
|
41
|
+
return {
|
|
42
|
+
mode: "null",
|
|
43
|
+
note: "returns null — asserted as an empty container rather than visible output",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The part is often nested rather than the root: DialogContent returns
|
|
48
|
+
// <DialogPortal><DialogPrimitive.Popup>…, so checking only the root tag
|
|
49
|
+
// misses it. Scan the whole unconditional render subtree instead.
|
|
50
|
+
const part = findUnrootedCompoundPart(expr, imports);
|
|
51
|
+
if (part) {
|
|
52
|
+
return {
|
|
53
|
+
mode: "compound-part",
|
|
54
|
+
note:
|
|
55
|
+
`renders <${part.root}.${part.member}> from ${part.specifier}, which throws outside its ` +
|
|
56
|
+
`Root. Wrap renderTarget() in the matching <Root open> to assert its content.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A part can also be reached one hop away: DropdownMenuSubContent returns
|
|
61
|
+
// <DropdownMenuContent>, which is itself a part declared in the same file. The
|
|
62
|
+
// scan above only sees `X.Member` tags, so without this the wrapper looks
|
|
63
|
+
// perfectly renderable and its spec fails with "MenuRootContext is missing".
|
|
64
|
+
const localPart = findLocalCompoundPart(expr, localModes);
|
|
65
|
+
if (localPart) {
|
|
66
|
+
return {
|
|
67
|
+
mode: "compound-part",
|
|
68
|
+
note:
|
|
69
|
+
`renders <${localPart}>, itself a compound part that throws outside its Root. ` +
|
|
70
|
+
`Wrap renderTarget() in the matching <Root open> to assert its content.`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const intrinsic = intrinsicTagOf(expr);
|
|
75
|
+
if (intrinsic === "html") {
|
|
76
|
+
return {
|
|
77
|
+
mode: "document-root",
|
|
78
|
+
note: "returns a document root (<html>) — React cannot mount that inside a container",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { mode: isAsync ? "rsc" : "client" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isNullLiteral(expr: ts.Expression): boolean {
|
|
86
|
+
return expr.kind === ts.SyntaxKind.NullKeyword;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A compound-package part rendered without its own Root anywhere in the same
|
|
91
|
+
* subtree. A component that renders `<X.Root>` around its parts supplies its
|
|
92
|
+
* own context and is perfectly renderable, so it must not be flagged.
|
|
93
|
+
*/
|
|
94
|
+
function findUnrootedCompoundPart(
|
|
95
|
+
expr: ts.Expression | undefined,
|
|
96
|
+
imports: Map<string, ImportBinding>,
|
|
97
|
+
): { root: string; member: string; specifier: string } | undefined {
|
|
98
|
+
if (!expr) return undefined;
|
|
99
|
+
|
|
100
|
+
const parts: Array<{ root: string; member: string; specifier: string }> = [];
|
|
101
|
+
const rootsProvided = new Set<string>();
|
|
102
|
+
|
|
103
|
+
const visit = (n: ts.Node): void => {
|
|
104
|
+
let tagName: ts.JsxTagNameExpression | undefined;
|
|
105
|
+
if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) tagName = n.tagName;
|
|
106
|
+
if (tagName && ts.isPropertyAccessExpression(tagName)) {
|
|
107
|
+
let root: ts.Expression = tagName.expression;
|
|
108
|
+
while (ts.isPropertyAccessExpression(root)) root = root.expression;
|
|
109
|
+
if (ts.isIdentifier(root)) {
|
|
110
|
+
const binding = imports.get(root.text);
|
|
111
|
+
if (binding && COMPOUND_PACKAGES.test(binding.specifier)) {
|
|
112
|
+
const member = tagName.name.text;
|
|
113
|
+
if (ROOT_MEMBERS.has(member)) rootsProvided.add(root.text);
|
|
114
|
+
else parts.push({ root: root.text, member, specifier: binding.specifier });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
ts.forEachChild(n, visit);
|
|
119
|
+
};
|
|
120
|
+
visit(expr);
|
|
121
|
+
|
|
122
|
+
return parts.find((p) => !rootsProvided.has(p.root));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The first same-file component in this subtree that is itself a compound part.
|
|
127
|
+
*
|
|
128
|
+
* Resolution is one hop and same-file only: a cross-file wrapper would need the
|
|
129
|
+
* child tree, which is built later and not available here.
|
|
130
|
+
*/
|
|
131
|
+
function findLocalCompoundPart(
|
|
132
|
+
expr: ts.Expression | undefined,
|
|
133
|
+
localModes: Map<string, RenderMode> | undefined,
|
|
134
|
+
): string | undefined {
|
|
135
|
+
if (!expr || !localModes || localModes.size === 0) return undefined;
|
|
136
|
+
|
|
137
|
+
let found: string | undefined;
|
|
138
|
+
const visit = (n: ts.Node): void => {
|
|
139
|
+
if (found) return;
|
|
140
|
+
let tagName: ts.JsxTagNameExpression | undefined;
|
|
141
|
+
if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) tagName = n.tagName;
|
|
142
|
+
if (tagName && ts.isIdentifier(tagName) && localModes.get(tagName.text) === "compound-part") {
|
|
143
|
+
found = tagName.text;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
ts.forEachChild(n, visit);
|
|
147
|
+
};
|
|
148
|
+
visit(expr);
|
|
149
|
+
return found;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function intrinsicTagOf(expr: ts.Expression | undefined): string | undefined {
|
|
153
|
+
const tagName = jsxTagName(expr);
|
|
154
|
+
if (!tagName || !ts.isIdentifier(tagName)) return undefined;
|
|
155
|
+
return /^[a-z]/.test(tagName.text) ? tagName.text : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function jsxTagName(expr: ts.Expression | undefined): ts.JsxTagNameExpression | undefined {
|
|
159
|
+
if (!expr) return undefined;
|
|
160
|
+
let node: ts.Expression = expr;
|
|
161
|
+
while (ts.isParenthesizedExpression(node)) node = node.expression;
|
|
162
|
+
if (ts.isJsxElement(node)) return node.openingElement.tagName;
|
|
163
|
+
if (ts.isJsxSelfClosingElement(node)) return node.tagName;
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { domOnlyResolver, type HostTagResolver } from "./hostTag.ts";
|
|
3
|
+
import { emptyEnv, initialRenderExpression, type RenderEnv } from "./renderPath.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The JSX root a component renders on its happy path, as a tag name.
|
|
7
|
+
*
|
|
8
|
+
* Consumers turn this into an assertion about the rendered container, so a
|
|
9
|
+
* wrong answer is a red spec in someone else's repo. Every case where the root
|
|
10
|
+
* is not pinned down by syntax alone — a fragment, a conditional, a qualified
|
|
11
|
+
* tag, a second return with a different root — gives up instead of guessing.
|
|
12
|
+
* The tag is returned as written and may be uppercase (`ButtonPrimitive`); the
|
|
13
|
+
* caller decides whether a component root is useful to it.
|
|
14
|
+
*/
|
|
15
|
+
export function extractRootTag(
|
|
16
|
+
bodyNode: ts.Node,
|
|
17
|
+
env: RenderEnv = emptyEnv(),
|
|
18
|
+
/**
|
|
19
|
+
* Which tags count as host elements. Defaults to the lowercase-intrinsic rule
|
|
20
|
+
* this function has always used, so every existing caller is unchanged.
|
|
21
|
+
*/
|
|
22
|
+
hostTag: HostTagResolver = domOnlyResolver,
|
|
23
|
+
): string | undefined {
|
|
24
|
+
// The INITIAL render, not the last return: a component that opens collapsed
|
|
25
|
+
// renders its guard branch, and describing it by the expanded branch below
|
|
26
|
+
// would name a tag the spec never produces.
|
|
27
|
+
const root = initialRenderExpression(bodyNode, env)?.expr;
|
|
28
|
+
if (!root) return undefined;
|
|
29
|
+
// A fragment renders no host element to assert against, and a conditional
|
|
30
|
+
// root depends on props the spec picks independently of this analysis.
|
|
31
|
+
if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) return undefined;
|
|
32
|
+
|
|
33
|
+
const tag = plainTagName(root);
|
|
34
|
+
if (tag === undefined) return undefined;
|
|
35
|
+
// Host elements only, as the field's own contract says. A component root is
|
|
36
|
+
// not an answer to "what element does this render" — shadcn's Button returns
|
|
37
|
+
// <ButtonPrimitive>, and reporting that let a caller conclude the render
|
|
38
|
+
// contained an element called "ButtonPrimitive" and no button at all. Every
|
|
39
|
+
// consumer wants a real tag: the spec's querySelector, the content-model
|
|
40
|
+
// wrapper, the ambiguity census.
|
|
41
|
+
//
|
|
42
|
+
// The predicate moved to hostTag.ts, and with it the answer for React Native:
|
|
43
|
+
// <View> is a host element even though it is capitalised, because THIS FILE
|
|
44
|
+
// imports it from "react-native". Under the default resolver the test is the
|
|
45
|
+
// same lowercase one it has always been. Without this, rootTag was undefined
|
|
46
|
+
// for every RN component, which silently took the root-tag contract test, the
|
|
47
|
+
// WRAP_IN table and the ambiguity census down with it.
|
|
48
|
+
const resolved = hostTag(tag);
|
|
49
|
+
if (resolved === undefined) return undefined;
|
|
50
|
+
|
|
51
|
+
// The MODULE-SIDE name, so `import { View as Box }` reports "View". Agreement
|
|
52
|
+
// between returns is checked on the tag as written, which is what the source
|
|
53
|
+
// actually repeats.
|
|
54
|
+
return everyReturnAgrees(bodyNode, root, tag) ? resolved.name : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function unwrapParens(expr: ts.Expression): ts.Expression {
|
|
58
|
+
let current = expr;
|
|
59
|
+
while (ts.isParenthesizedExpression(current)) current = current.expression;
|
|
60
|
+
return current;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function plainTagName(el: ts.JsxElement | ts.JsxSelfClosingElement): string | undefined {
|
|
64
|
+
const tagName = ts.isJsxElement(el) ? el.openingElement.tagName : el.tagName;
|
|
65
|
+
// <UI.Badge /> renders whatever the namespace object holds under that key;
|
|
66
|
+
// "Badge" and "UI.Badge" are both lies about the emitted element.
|
|
67
|
+
return ts.isIdentifier(tagName) ? tagName.text : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every other return in the component must render the same tag or nothing at
|
|
72
|
+
* all. `if (!user) return null` leaves the happy-path root unambiguous, while
|
|
73
|
+
* `if (loading) return <Skeleton />` means the rendered root is decided by
|
|
74
|
+
* props and no single tag describes the component.
|
|
75
|
+
*/
|
|
76
|
+
function everyReturnAgrees(bodyNode: ts.Node, root: ts.Expression, tag: string): boolean {
|
|
77
|
+
let agrees = true;
|
|
78
|
+
|
|
79
|
+
const compatible = (expr: ts.Expression | undefined): boolean => {
|
|
80
|
+
if (expr === undefined || expr === root) return true;
|
|
81
|
+
if (rendersNothing(expr)) return true;
|
|
82
|
+
if (ts.isJsxElement(expr) || ts.isJsxSelfClosingElement(expr)) {
|
|
83
|
+
return plainTagName(expr) === tag;
|
|
84
|
+
}
|
|
85
|
+
// `return content` may hold any element; unknowable without a type checker.
|
|
86
|
+
return false;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const visit = (n: ts.Node): void => {
|
|
90
|
+
if (!agrees) return;
|
|
91
|
+
// Returns inside a nested callback belong to that callback — a `.map` row,
|
|
92
|
+
// a render prop — not to the component.
|
|
93
|
+
if (n !== bodyNode && isFunctionBoundary(n)) return;
|
|
94
|
+
if (ts.isReturnStatement(n)) {
|
|
95
|
+
const expr = n.expression ? unwrapParens(n.expression) : undefined;
|
|
96
|
+
if (!compatible(expr)) {
|
|
97
|
+
agrees = false;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
ts.forEachChild(n, visit);
|
|
102
|
+
};
|
|
103
|
+
visit(bodyNode);
|
|
104
|
+
|
|
105
|
+
return agrees;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function rendersNothing(expr: ts.Expression): boolean {
|
|
109
|
+
if (expr.kind === ts.SyntaxKind.NullKeyword || expr.kind === ts.SyntaxKind.FalseKeyword) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
return ts.isIdentifier(expr) && expr.text === "undefined";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isFunctionBoundary(n: ts.Node): boolean {
|
|
116
|
+
return ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n);
|
|
117
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { SetterCallSite, StateInfo, StateJsxFlow, StateKind } from "../types.ts";
|
|
3
|
+
import { posOf } from "./pos.ts";
|
|
4
|
+
import { collectRefs, hookCalleeName } from "./refs.ts";
|
|
5
|
+
import { literalToValue } from "./renderPath.ts";
|
|
6
|
+
|
|
7
|
+
const INITIAL_RAW_MAX = 120;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extracts useState/useReducer/useRef declarations from a component body,
|
|
11
|
+
* with setter call sites (context + guard classification) and JSX flows.
|
|
12
|
+
* Purely syntactic — companion to extractInteractions/extractProps.
|
|
13
|
+
*/
|
|
14
|
+
export function extractState(bodyNode: ts.Node, sourceFile: ts.SourceFile): StateInfo[] {
|
|
15
|
+
const out: StateInfo[] = [];
|
|
16
|
+
|
|
17
|
+
const visit = (n: ts.Node): void => {
|
|
18
|
+
if (ts.isVariableDeclaration(n) && n.initializer && ts.isCallExpression(n.initializer)) {
|
|
19
|
+
const hook = hookCalleeName(n.initializer.expression);
|
|
20
|
+
// useActionState(action, initialState) has useReducer's shape exactly, and
|
|
21
|
+
// its initial state is a literal in the source. Without it, `state` was
|
|
22
|
+
// opaque, every guard on it was undecidable, and — once an undecidable
|
|
23
|
+
// render started refusing rather than guessing — two correct React 19 form
|
|
24
|
+
// components lost their whole spec to a refusal that was simply wrong.
|
|
25
|
+
if (
|
|
26
|
+
hook === "useState" ||
|
|
27
|
+
hook === "useReducer" ||
|
|
28
|
+
hook === "useRef" ||
|
|
29
|
+
hook === "useActionState"
|
|
30
|
+
) {
|
|
31
|
+
const info = buildStateInfo(n, n.initializer, hook, bodyNode, sourceFile);
|
|
32
|
+
if (info) out.push(info);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
ts.forEachChild(n, visit);
|
|
36
|
+
};
|
|
37
|
+
ts.forEachChild(bodyNode, visit);
|
|
38
|
+
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildStateInfo(
|
|
43
|
+
decl: ts.VariableDeclaration,
|
|
44
|
+
call: ts.CallExpression,
|
|
45
|
+
kind: StateKind,
|
|
46
|
+
bodyNode: ts.Node,
|
|
47
|
+
sourceFile: ts.SourceFile,
|
|
48
|
+
): StateInfo | undefined {
|
|
49
|
+
let varName: string | undefined;
|
|
50
|
+
let setterName: string | undefined;
|
|
51
|
+
if (kind !== "useRef" && ts.isArrayBindingPattern(decl.name)) {
|
|
52
|
+
const el0 = decl.name.elements[0];
|
|
53
|
+
const el1 = decl.name.elements[1];
|
|
54
|
+
if (el0 && ts.isBindingElement(el0) && ts.isIdentifier(el0.name)) varName = el0.name.text;
|
|
55
|
+
if (el1 && ts.isBindingElement(el1) && ts.isIdentifier(el1.name)) setterName = el1.name.text;
|
|
56
|
+
} else if (ts.isIdentifier(decl.name)) {
|
|
57
|
+
varName = decl.name.text;
|
|
58
|
+
}
|
|
59
|
+
if (varName === undefined) return undefined;
|
|
60
|
+
|
|
61
|
+
// useReducer(reducer, initialState) and useActionState(action, initialState) —
|
|
62
|
+
// the init value is the second argument in both.
|
|
63
|
+
const initExpr =
|
|
64
|
+
kind === "useReducer" || kind === "useActionState" ? call.arguments[1] : call.arguments[0];
|
|
65
|
+
const initialValue = initExpr ? literalToValue(initExpr) : undefined;
|
|
66
|
+
const initialRaw = initExpr ? initExpr.getText(sourceFile).slice(0, INITIAL_RAW_MAX) : "";
|
|
67
|
+
const typeKind = classifyTypeKind(call.typeArguments?.[0], initExpr);
|
|
68
|
+
|
|
69
|
+
const setterCalls: SetterCallSite[] = [];
|
|
70
|
+
if (kind === "useRef") {
|
|
71
|
+
collectRefAssignments(bodyNode, sourceFile, varName, setterCalls);
|
|
72
|
+
} else if (setterName !== undefined) {
|
|
73
|
+
collectSetterCalls(bodyNode, sourceFile, setterName, setterCalls);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
kind,
|
|
78
|
+
varName,
|
|
79
|
+
setterName,
|
|
80
|
+
initialValue,
|
|
81
|
+
initialRaw,
|
|
82
|
+
typeKind,
|
|
83
|
+
pos: posOf(decl, sourceFile),
|
|
84
|
+
setterCalls,
|
|
85
|
+
flowsToJsx: collectJsxFlows(bodyNode, sourceFile, varName),
|
|
86
|
+
setterCalledInEffect: setterCalls.some((c) => c.context === "effect"),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Initial value / type classification
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
function classifyTypeKind(
|
|
95
|
+
typeArg: ts.TypeNode | undefined,
|
|
96
|
+
initExpr: ts.Expression | undefined,
|
|
97
|
+
): StateInfo["typeKind"] {
|
|
98
|
+
if (typeArg) return typeKindFromTypeNode(typeArg);
|
|
99
|
+
if (initExpr) return typeKindFromExpression(initExpr);
|
|
100
|
+
return "unknown";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function typeKindFromTypeNode(t: ts.TypeNode): StateInfo["typeKind"] {
|
|
104
|
+
switch (t.kind) {
|
|
105
|
+
case ts.SyntaxKind.StringKeyword:
|
|
106
|
+
return "string";
|
|
107
|
+
case ts.SyntaxKind.NumberKeyword:
|
|
108
|
+
return "number";
|
|
109
|
+
case ts.SyntaxKind.BooleanKeyword:
|
|
110
|
+
return "boolean";
|
|
111
|
+
default:
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
if (ts.isLiteralTypeNode(t) && t.literal.kind === ts.SyntaxKind.NullKeyword) return "null";
|
|
115
|
+
if (ts.isArrayTypeNode(t)) return "array";
|
|
116
|
+
if (ts.isTypeLiteralNode(t)) return "object";
|
|
117
|
+
return "unknown";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function typeKindFromExpression(e: ts.Expression): StateInfo["typeKind"] {
|
|
121
|
+
if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) return "string";
|
|
122
|
+
if (ts.isNumericLiteral(e)) return "number";
|
|
123
|
+
if (e.kind === ts.SyntaxKind.TrueKeyword || e.kind === ts.SyntaxKind.FalseKeyword) {
|
|
124
|
+
return "boolean";
|
|
125
|
+
}
|
|
126
|
+
if (e.kind === ts.SyntaxKind.NullKeyword) return "null";
|
|
127
|
+
if (ts.isArrayLiteralExpression(e)) return "array";
|
|
128
|
+
if (ts.isObjectLiteralExpression(e)) return "object";
|
|
129
|
+
if (isNegativeNumber(e)) return "number";
|
|
130
|
+
return "unknown";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isNegativeNumber(e: ts.Expression): e is ts.PrefixUnaryExpression {
|
|
134
|
+
return (
|
|
135
|
+
ts.isPrefixUnaryExpression(e) &&
|
|
136
|
+
e.operator === ts.SyntaxKind.MinusToken &&
|
|
137
|
+
ts.isNumericLiteral(e.operand)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Setter call sites
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
function collectSetterCalls(
|
|
146
|
+
bodyNode: ts.Node,
|
|
147
|
+
sourceFile: ts.SourceFile,
|
|
148
|
+
setterName: string,
|
|
149
|
+
out: SetterCallSite[],
|
|
150
|
+
): void {
|
|
151
|
+
const visit = (n: ts.Node): void => {
|
|
152
|
+
if (
|
|
153
|
+
ts.isCallExpression(n) &&
|
|
154
|
+
ts.isIdentifier(n.expression) &&
|
|
155
|
+
n.expression.text === setterName
|
|
156
|
+
) {
|
|
157
|
+
out.push({
|
|
158
|
+
pos: posOf(n, sourceFile),
|
|
159
|
+
...classifyContext(n, bodyNode),
|
|
160
|
+
argKind: classifyArgKind(n),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
ts.forEachChild(n, visit);
|
|
164
|
+
};
|
|
165
|
+
ts.forEachChild(bodyNode, visit);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `ref.current = ...` assignments are the useRef equivalent of setter calls. */
|
|
169
|
+
function collectRefAssignments(
|
|
170
|
+
bodyNode: ts.Node,
|
|
171
|
+
sourceFile: ts.SourceFile,
|
|
172
|
+
varName: string,
|
|
173
|
+
out: SetterCallSite[],
|
|
174
|
+
): void {
|
|
175
|
+
const visit = (n: ts.Node): void => {
|
|
176
|
+
if (
|
|
177
|
+
ts.isBinaryExpression(n) &&
|
|
178
|
+
n.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
179
|
+
ts.isPropertyAccessExpression(n.left) &&
|
|
180
|
+
ts.isIdentifier(n.left.expression) &&
|
|
181
|
+
n.left.expression.text === varName &&
|
|
182
|
+
n.left.name.text === "current"
|
|
183
|
+
) {
|
|
184
|
+
out.push({
|
|
185
|
+
pos: posOf(n, sourceFile),
|
|
186
|
+
...classifyContext(n, bodyNode),
|
|
187
|
+
argKind: "expression",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
ts.forEachChild(n, visit);
|
|
191
|
+
};
|
|
192
|
+
ts.forEachChild(bodyNode, visit);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function classifyArgKind(call: ts.CallExpression): SetterCallSite["argKind"] {
|
|
196
|
+
const arg = call.arguments[0];
|
|
197
|
+
if (!arg) return "none";
|
|
198
|
+
if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) return "updater";
|
|
199
|
+
if (literalToValue(arg) !== undefined) return "literal";
|
|
200
|
+
return "expression";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function classifyContext(
|
|
204
|
+
node: ts.Node,
|
|
205
|
+
bodyNode: ts.Node,
|
|
206
|
+
): Pick<SetterCallSite, "context" | "enclosingName" | "guarded"> {
|
|
207
|
+
// Outermost function-like strictly below the component body node.
|
|
208
|
+
let outermostFn: ts.Node | undefined;
|
|
209
|
+
for (let p = node.parent; p && p !== bodyNode; p = p.parent) {
|
|
210
|
+
if (isFunctionBoundary(p)) outermostFn = p;
|
|
211
|
+
}
|
|
212
|
+
const guarded = hasConditionalBetween(node, outermostFn ?? bodyNode);
|
|
213
|
+
if (!outermostFn) return { context: "render", guarded };
|
|
214
|
+
|
|
215
|
+
const parent = outermostFn.parent;
|
|
216
|
+
|
|
217
|
+
if (parent && ts.isCallExpression(parent) && parent.arguments.some((a) => a === outermostFn)) {
|
|
218
|
+
const hook = hookCalleeName(parent.expression);
|
|
219
|
+
if (hook === "useEffect" || hook === "useLayoutEffect") {
|
|
220
|
+
return { context: "effect", enclosingName: hook, guarded };
|
|
221
|
+
}
|
|
222
|
+
if (hook === "useCallback" || hook === "useMemo") {
|
|
223
|
+
return { context: "callback", enclosingName: hook, guarded };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// const handleAdd = () => ... — variable-initializer handlers.
|
|
228
|
+
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
|
|
229
|
+
const name = parent.name.text;
|
|
230
|
+
const context = /^(handle|on)[A-Z]/.test(name) ? ("handler" as const) : ("unknown" as const);
|
|
231
|
+
return { context, enclosingName: name, guarded };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// onClick={() => ...} — inline JSX attribute handlers.
|
|
235
|
+
if (parent && ts.isJsxExpression(parent) && parent.parent && ts.isJsxAttribute(parent.parent)) {
|
|
236
|
+
const attrName = parent.parent.name.getText();
|
|
237
|
+
const context = /^on[A-Z]/.test(attrName) ? ("handler" as const) : ("unknown" as const);
|
|
238
|
+
return { context, enclosingName: attrName, guarded };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { context: "unknown", enclosingName: nearestName(outermostFn), guarded };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function nearestName(fn: ts.Node): string | undefined {
|
|
245
|
+
if ((ts.isFunctionDeclaration(fn) || ts.isFunctionExpression(fn)) && fn.name) return fn.name.text;
|
|
246
|
+
const parent = fn.parent;
|
|
247
|
+
if (parent && ts.isCallExpression(parent)) return hookCalleeName(parent.expression);
|
|
248
|
+
if (parent && ts.isPropertyAssignment(parent) && ts.isIdentifier(parent.name)) {
|
|
249
|
+
return parent.name.text;
|
|
250
|
+
}
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function hasConditionalBetween(node: ts.Node, stop: ts.Node): boolean {
|
|
255
|
+
for (let p = node.parent; p && p !== stop; p = p.parent) {
|
|
256
|
+
if (ts.isIfStatement(p) || ts.isConditionalExpression(p)) return true;
|
|
257
|
+
if (
|
|
258
|
+
ts.isBinaryExpression(p) &&
|
|
259
|
+
(p.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ||
|
|
260
|
+
p.operatorToken.kind === ts.SyntaxKind.BarBarToken)
|
|
261
|
+
) {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isFunctionBoundary(n: ts.Node): boolean {
|
|
269
|
+
return ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// JSX flows
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
function collectJsxFlows(
|
|
277
|
+
bodyNode: ts.Node,
|
|
278
|
+
sourceFile: ts.SourceFile,
|
|
279
|
+
varName: string,
|
|
280
|
+
): StateJsxFlow[] {
|
|
281
|
+
const flows: StateJsxFlow[] = [];
|
|
282
|
+
// collectRefs also covers useRef `x.current` reads: the receiver `x` of the
|
|
283
|
+
// property access is a reference position, so no separate walk is needed.
|
|
284
|
+
const refs = collectRefs(bodyNode, sourceFile, new Set([varName]), undefined);
|
|
285
|
+
for (const ref of refs) {
|
|
286
|
+
const flow = classifyJsxFlow(ref.node, bodyNode, sourceFile);
|
|
287
|
+
if (flow) flows.push(flow);
|
|
288
|
+
}
|
|
289
|
+
return flows;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function classifyJsxFlow(
|
|
293
|
+
refNode: ts.Node,
|
|
294
|
+
bodyNode: ts.Node,
|
|
295
|
+
sourceFile: ts.SourceFile,
|
|
296
|
+
): StateJsxFlow | undefined {
|
|
297
|
+
let crossedFunction = false;
|
|
298
|
+
// The value SELECTS the child here rather than rendering:
|
|
299
|
+
// `{mode === 'write' ? <A/> : <B/>}` and `{mode && <X/>}` never put
|
|
300
|
+
// 'write' on screen. Only the raw value in text position is a witness.
|
|
301
|
+
// Chain-position tests — `refNode === p.condition` misses every nested
|
|
302
|
+
// read (`items.find((i) => i.id === mode) ? …`), so containment decides.
|
|
303
|
+
const selectsBranch = (n: ts.Node): boolean => {
|
|
304
|
+
if (ts.isConditionalExpression(n) && containsNode(n.condition, refNode)) return true;
|
|
305
|
+
if (
|
|
306
|
+
ts.isBinaryExpression(n) &&
|
|
307
|
+
n.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken &&
|
|
308
|
+
containsNode(n.left, refNode)
|
|
309
|
+
)
|
|
310
|
+
return true;
|
|
311
|
+
return false;
|
|
312
|
+
};
|
|
313
|
+
let selectsOnly = false;
|
|
314
|
+
for (let p = refNode.parent; p && p !== bodyNode; p = p.parent) {
|
|
315
|
+
if (!selectsOnly && selectsBranch(p)) selectsOnly = true;
|
|
316
|
+
if (
|
|
317
|
+
ts.isJsxExpression(p) &&
|
|
318
|
+
p.parent &&
|
|
319
|
+
(ts.isJsxElement(p.parent) || ts.isJsxFragment(p.parent))
|
|
320
|
+
) {
|
|
321
|
+
const tag = ts.isJsxElement(p.parent)
|
|
322
|
+
? p.parent.openingElement.tagName.getText(sourceFile)
|
|
323
|
+
: "<fragment>";
|
|
324
|
+
return {
|
|
325
|
+
via: selectsOnly ? "condition" : "child",
|
|
326
|
+
tag,
|
|
327
|
+
pos: posOf(refNode, sourceFile),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (ts.isJsxAttribute(p) && !crossedFunction) {
|
|
331
|
+
const owner = p.parent.parent;
|
|
332
|
+
const tag =
|
|
333
|
+
owner && (ts.isJsxOpeningElement(owner) || ts.isJsxSelfClosingElement(owner))
|
|
334
|
+
? owner.tagName.getText(sourceFile)
|
|
335
|
+
: "<dynamic>";
|
|
336
|
+
return {
|
|
337
|
+
via: "attr",
|
|
338
|
+
tag,
|
|
339
|
+
attr: p.name.getText(sourceFile),
|
|
340
|
+
pos: posOf(refNode, sourceFile),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (isFunctionBoundary(p)) crossedFunction = true;
|
|
344
|
+
}
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Subtree containment — TS nodes carry no position-based ancestry helper. */
|
|
349
|
+
function containsNode(root: ts.Node, target: ts.Node): boolean {
|
|
350
|
+
if (root === target) return true;
|
|
351
|
+
let found = false;
|
|
352
|
+
const visit = (n: ts.Node): void => {
|
|
353
|
+
if (found || n === target) {
|
|
354
|
+
found = true;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
ts.forEachChild(n, visit);
|
|
358
|
+
};
|
|
359
|
+
ts.forEachChild(root, visit);
|
|
360
|
+
return found;
|
|
361
|
+
}
|