@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.
Files changed (143) hide show
  1. package/.agents/skills/flawless-a11y/SKILL.md +113 -0
  2. package/.agents/skills/flawless-api/SKILL.md +159 -0
  3. package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
  4. package/.agents/skills/flawless-e2e/SKILL.md +155 -0
  5. package/.agents/skills/flawless-expo/SKILL.md +283 -0
  6. package/.agents/skills/flawless-findings/SKILL.md +116 -0
  7. package/.agents/skills/flawless-load/SKILL.md +112 -0
  8. package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
  9. package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
  10. package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
  11. package/.agents/skills/flawless-react/SKILL.md +403 -0
  12. package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
  13. package/.agents/skills/flawless-regen/SKILL.md +135 -0
  14. package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
  15. package/LICENSE +21 -0
  16. package/README.md +611 -2
  17. package/bin/flawless-react +4 -0
  18. package/package.json +67 -4
  19. package/scripts/postinstall.d.mts +46 -0
  20. package/scripts/postinstall.mjs +127 -0
  21. package/src/analyse/authShims.ts +220 -0
  22. package/src/analyse/bootability.ts +257 -0
  23. package/src/analyse/childBridge.ts +302 -0
  24. package/src/analyse/childrenIntoSlot.ts +66 -0
  25. package/src/analyse/compoundFamily.ts +100 -0
  26. package/src/analyse/dataExports.ts +200 -0
  27. package/src/analyse/extractChildAttrs.ts +112 -0
  28. package/src/analyse/extractDataDeps.ts +857 -0
  29. package/src/analyse/extractDisclosures.ts +186 -0
  30. package/src/analyse/extractFetchCalls.ts +81 -0
  31. package/src/analyse/extractFlows.ts +239 -0
  32. package/src/analyse/extractGuards.ts +270 -0
  33. package/src/analyse/extractHooks.ts +237 -0
  34. package/src/analyse/extractInteractions.ts +1421 -0
  35. package/src/analyse/extractLogging.ts +45 -0
  36. package/src/analyse/extractNativeA11y.ts +138 -0
  37. package/src/analyse/extractPredicates.ts +784 -0
  38. package/src/analyse/extractPropUsage.ts +162 -0
  39. package/src/analyse/extractProps.ts +1212 -0
  40. package/src/analyse/extractRenderMode.ts +165 -0
  41. package/src/analyse/extractRootTag.ts +117 -0
  42. package/src/analyse/extractState.ts +361 -0
  43. package/src/analyse/extractStaticText.ts +212 -0
  44. package/src/analyse/extractVariants.ts +186 -0
  45. package/src/analyse/harvestApiEndpoints.ts +284 -0
  46. package/src/analyse/hostProps.ts +185 -0
  47. package/src/analyse/hostTag.ts +72 -0
  48. package/src/analyse/htmlContentModel.ts +94 -0
  49. package/src/analyse/index.ts +744 -0
  50. package/src/analyse/mountPathCalls.ts +442 -0
  51. package/src/analyse/nativeContentModel.ts +129 -0
  52. package/src/analyse/nativeDialect.ts +83 -0
  53. package/src/analyse/nativeProps.ts +339 -0
  54. package/src/analyse/ownReturns.ts +31 -0
  55. package/src/analyse/parseFile.ts +919 -0
  56. package/src/analyse/pos.ts +8 -0
  57. package/src/analyse/providerWraps.ts +1977 -0
  58. package/src/analyse/pureModule.ts +88 -0
  59. package/src/analyse/reexportTable.ts +148 -0
  60. package/src/analyse/refs.ts +195 -0
  61. package/src/analyse/renderPath.ts +441 -0
  62. package/src/analyse/resolveDepReturns.ts +370 -0
  63. package/src/analyse/resolveIndexKeys.ts +98 -0
  64. package/src/analyse/resolveModule.ts +431 -0
  65. package/src/analyse/resolveTagRootTags.ts +103 -0
  66. package/src/analyse/serverActionReturns.ts +213 -0
  67. package/src/analyse/sourceCache.ts +263 -0
  68. package/src/analyse/spreadSiblings.ts +98 -0
  69. package/src/analyse/walkChildren.ts +98 -0
  70. package/src/analyse/zodSchema.ts +424 -0
  71. package/src/bin.ts +23 -0
  72. package/src/changed.ts +221 -0
  73. package/src/cli.ts +560 -0
  74. package/src/cliFlags.ts +281 -0
  75. package/src/core.ts +1229 -0
  76. package/src/docLine.ts +122 -0
  77. package/src/emitPaths.ts +51 -0
  78. package/src/expo/detect.ts +184 -0
  79. package/src/expo/refusal.ts +62 -0
  80. package/src/expo/routes.ts +145 -0
  81. package/src/index.ts +95 -0
  82. package/src/mcp/analysisTools.ts +720 -0
  83. package/src/mcp/infoTools.ts +135 -0
  84. package/src/mcp/protocol.ts +130 -0
  85. package/src/mcp/run.ts +269 -0
  86. package/src/mcp/runTools.ts +362 -0
  87. package/src/mcp/server.ts +279 -0
  88. package/src/mcp/spec.ts +86 -0
  89. package/src/mcp/support.ts +268 -0
  90. package/src/mcp/tools.ts +75 -0
  91. package/src/nextjs/apiRoutes.ts +116 -0
  92. package/src/nextjs/detect.ts +397 -0
  93. package/src/nextjs/emit.ts +106 -0
  94. package/src/nextjs/liveSpec.ts +79 -0
  95. package/src/nextjs/mswHandlers.ts +55 -0
  96. package/src/nextjs/providerWrapper.ts +603 -0
  97. package/src/nextjs/routes.ts +86 -0
  98. package/src/nextjs/swagger.ts +32 -0
  99. package/src/packageJson.ts +414 -0
  100. package/src/platform.ts +18 -0
  101. package/src/prompt.ts +132 -0
  102. package/src/reactCompiler.ts +165 -0
  103. package/src/reporters/findings.ts +156 -0
  104. package/src/reporters/report.ts +463 -0
  105. package/src/runner/runTests.ts +402 -0
  106. package/src/scaffold/adoptStyle.ts +181 -0
  107. package/src/scaffold/index.ts +253 -0
  108. package/src/scaffold/openapi.ts +183 -0
  109. package/src/scaffold/templates.ts +2379 -0
  110. package/src/scaffold-tests/index.ts +463 -0
  111. package/src/scaffold-tests/mockValues.ts +208 -0
  112. package/src/scaffold-tests/mocks.ts +794 -0
  113. package/src/scaffold-tests/nativeIndex.ts +52 -0
  114. package/src/scaffold-tests/nativeTemplates.ts +1528 -0
  115. package/src/scaffold-tests/postDispatch.ts +152 -0
  116. package/src/scaffold-tests/registry.ts +443 -0
  117. package/src/scaffold-tests/selectors.ts +493 -0
  118. package/src/scaffold-tests/selectorsNative.ts +273 -0
  119. package/src/scaffold-tests/stamp.ts +79 -0
  120. package/src/scaffold-tests/templates.ts +3578 -0
  121. package/src/skills/cli.ts +248 -0
  122. package/src/skills/compat.ts +166 -0
  123. package/src/skills/data.ts +192 -0
  124. package/src/skills/md.d.ts +7 -0
  125. package/src/skills/menu.ts +55 -0
  126. package/src/skills/spec.ts +227 -0
  127. package/src/suiteScripts.ts +110 -0
  128. package/src/tasks/a11yNative.ts +281 -0
  129. package/src/tasks/a11yScan.ts +67 -0
  130. package/src/tasks/biomeRunner.ts +301 -0
  131. package/src/tasks/biomeSweep.ts +166 -0
  132. package/src/tasks/e2eExpo.ts +436 -0
  133. package/src/tasks/e2eRoutes.ts +756 -0
  134. package/src/tasks/eslintReactRules.ts +67 -0
  135. package/src/tasks/index.ts +24 -0
  136. package/src/tasks/loadExpo.ts +273 -0
  137. package/src/tasks/loadPipes.ts +750 -0
  138. package/src/tasks/perfHarness.ts +145 -0
  139. package/src/tasks/staticPerfAnalysis.ts +510 -0
  140. package/src/tasks/types.ts +1 -0
  141. package/src/tasks/unitAndBrowser.ts +462 -0
  142. package/src/types.ts +1419 -0
  143. 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
+ }