@lariocpt/flawless-react 0.0.0-stage → 0.3.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/.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 +608 -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,186 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { LabelSource } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Elements that only exist in the DOM after something is clicked.
|
|
6
|
+
*
|
|
7
|
+
* A menu, dialog or popover mounts its content lazily, so a spec that queries an
|
|
8
|
+
* item straight after render finds nothing — and the failure looks like a wrong
|
|
9
|
+
* selector rather than a missing step. The shape is the same across
|
|
10
|
+
* base-ui/radix/shadcn and is recognisable by structure alone:
|
|
11
|
+
*
|
|
12
|
+
* <DropdownMenu> <- disclosure root
|
|
13
|
+
* <DropdownMenuTrigger>…</…> <- the thing to click
|
|
14
|
+
* <DropdownMenuContent> <- gated subtree
|
|
15
|
+
* <DropdownMenuItem>Light</…> <- only mounted once open
|
|
16
|
+
*
|
|
17
|
+
* A `*Trigger` with a sibling subtree rooted at `*Content` / `*Popup` / `*Panel`
|
|
18
|
+
* / `*Portal` / `*Popover` — that is the whole rule. No package allowlist, so it
|
|
19
|
+
* also covers a project's own wrappers.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const TRIGGER_TAG = /Trigger$/;
|
|
23
|
+
const GATED_ROOT_TAG = /(Content|Popup|Panel|Portal|Overlay|Popover)$/;
|
|
24
|
+
|
|
25
|
+
export interface Disclosure {
|
|
26
|
+
/** How a spec finds the element to click first. */
|
|
27
|
+
trigger: LabelSource;
|
|
28
|
+
/** Every node that only mounts after that click. */
|
|
29
|
+
gated: Set<ts.Node>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function findDisclosures(root: ts.Node): Disclosure[] {
|
|
33
|
+
const out: Disclosure[] = [];
|
|
34
|
+
|
|
35
|
+
const visit = (n: ts.Node): void => {
|
|
36
|
+
const tag = openingTagName(n);
|
|
37
|
+
if (tag && TRIGGER_TAG.test(tag)) {
|
|
38
|
+
const found = disclosureFor(n, root);
|
|
39
|
+
if (found) out.push(found);
|
|
40
|
+
}
|
|
41
|
+
ts.forEachChild(n, visit);
|
|
42
|
+
};
|
|
43
|
+
visit(root);
|
|
44
|
+
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The gated subtrees that sit beside `trigger`, and how to click it. */
|
|
49
|
+
function disclosureFor(trigger: ts.Node, root: ts.Node): Disclosure | undefined {
|
|
50
|
+
const handle = triggerHandle(trigger);
|
|
51
|
+
if (handle.kind === "none") return undefined;
|
|
52
|
+
|
|
53
|
+
for (let a = trigger.parent; a && a !== root.parent; a = a.parent) {
|
|
54
|
+
if (!ts.isJsxElement(a) && !ts.isJsxFragment(a)) continue;
|
|
55
|
+
const gatedRoots = a.children.filter((child) => {
|
|
56
|
+
if (contains(child, trigger)) return false;
|
|
57
|
+
const tag = openingTagName(child);
|
|
58
|
+
return tag !== undefined && GATED_ROOT_TAG.test(tag);
|
|
59
|
+
});
|
|
60
|
+
if (gatedRoots.length === 0) continue;
|
|
61
|
+
// An enclosing root written `open` / `open={true}` / `defaultOpen` mounts
|
|
62
|
+
// its content right away — nothing to open first, so nothing is gated.
|
|
63
|
+
// (An expression like `open={isOpen}` stays gated: its initial value
|
|
64
|
+
// belongs to render-state this structural pass cannot see.)
|
|
65
|
+
if (ts.isJsxElement(a) && isAlwaysOpen(a.openingElement)) return undefined;
|
|
66
|
+
|
|
67
|
+
const gated = new Set<ts.Node>();
|
|
68
|
+
for (const subtree of gatedRoots) collectAll(subtree, gated);
|
|
69
|
+
return { trigger: handle, gated };
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `open`/`defaultOpen` written as a bare attribute or a literal `true`. */
|
|
75
|
+
function isAlwaysOpen(opening: ts.JsxOpeningElement): boolean {
|
|
76
|
+
for (const p of opening.attributes.properties) {
|
|
77
|
+
if (!ts.isJsxAttribute(p) || !ts.isIdentifier(p.name)) continue;
|
|
78
|
+
if (p.name.text !== "open" && p.name.text !== "defaultOpen") continue;
|
|
79
|
+
const init = p.initializer;
|
|
80
|
+
if (init === undefined) return true;
|
|
81
|
+
if (ts.isJsxExpression(init) && init.expression?.kind === ts.SyntaxKind.TrueKeyword) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Something a spec can use to click the trigger.
|
|
90
|
+
*
|
|
91
|
+
* Literal text is taken from anywhere in the trigger's subtree, INCLUDING the
|
|
92
|
+
* JSX inside attributes: base-ui's `render={<Button/>}` slot and shadcn's
|
|
93
|
+
* `<span className="sr-only">` both put the only human-readable label somewhere
|
|
94
|
+
* a direct-children scan never looks. Clicking that span bubbles to the button,
|
|
95
|
+
* so it is a valid handle.
|
|
96
|
+
*/
|
|
97
|
+
function triggerHandle(trigger: ts.Node): LabelSource {
|
|
98
|
+
const attrs = jsxAttributes(trigger);
|
|
99
|
+
const testid = stringAttr(attrs, "data-testid");
|
|
100
|
+
if (testid) return { kind: "testid", value: testid };
|
|
101
|
+
const aria = stringAttr(attrs, "aria-label");
|
|
102
|
+
if (aria) return { kind: "aria", value: aria };
|
|
103
|
+
|
|
104
|
+
const texts: string[] = [];
|
|
105
|
+
const visit = (n: ts.Node): void => {
|
|
106
|
+
if (ts.isJsxText(n)) {
|
|
107
|
+
const t = n.text.replace(/\s+/g, " ").trim();
|
|
108
|
+
if (t) texts.push(t);
|
|
109
|
+
}
|
|
110
|
+
ts.forEachChild(n, visit);
|
|
111
|
+
};
|
|
112
|
+
visit(trigger);
|
|
113
|
+
|
|
114
|
+
// More than one candidate and we cannot tell which identifies the trigger.
|
|
115
|
+
const unique = [...new Set(texts)];
|
|
116
|
+
const only = unique.length === 1 ? unique[0] : undefined;
|
|
117
|
+
if (only && only.length >= 2) return { kind: "text", value: only };
|
|
118
|
+
// Nothing names the trigger because the APP CONSUMER is its content —
|
|
119
|
+
// `<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>`. A unit
|
|
120
|
+
// spec passes CHILD_TEXT as children, so that text is the click handle.
|
|
121
|
+
if (childrenExprInSubtree(trigger)) return { kind: "children" };
|
|
122
|
+
return { kind: "none" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** True when the subtree renders the `{children}` identifier expression. */
|
|
126
|
+
function childrenExprInSubtree(n: ts.Node): boolean {
|
|
127
|
+
if (
|
|
128
|
+
ts.isJsxExpression(n) &&
|
|
129
|
+
n.expression !== undefined &&
|
|
130
|
+
ts.isIdentifier(n.expression) &&
|
|
131
|
+
n.expression.text === "children"
|
|
132
|
+
) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
let found = false;
|
|
136
|
+
ts.forEachChild(n, (c) => {
|
|
137
|
+
if (!found && childrenExprInSubtree(c)) found = true;
|
|
138
|
+
});
|
|
139
|
+
return found;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function contains(haystack: ts.Node, needle: ts.Node): boolean {
|
|
143
|
+
for (let n: ts.Node | undefined = needle; n; n = n.parent) {
|
|
144
|
+
if (n === haystack) return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function collectAll(n: ts.Node, into: Set<ts.Node>): void {
|
|
150
|
+
into.add(n);
|
|
151
|
+
ts.forEachChild(n, (c) => collectAll(c, into));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function openingTagName(n: ts.Node): string | undefined {
|
|
155
|
+
const tagName = ts.isJsxElement(n)
|
|
156
|
+
? n.openingElement.tagName
|
|
157
|
+
: ts.isJsxSelfClosingElement(n)
|
|
158
|
+
? n.tagName
|
|
159
|
+
: undefined;
|
|
160
|
+
return tagName && ts.isIdentifier(tagName) ? tagName.text : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function jsxAttributes(n: ts.Node): ts.JsxAttributes | undefined {
|
|
164
|
+
if (ts.isJsxElement(n)) return n.openingElement.attributes;
|
|
165
|
+
if (ts.isJsxSelfClosingElement(n)) return n.attributes;
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function stringAttr(attrs: ts.JsxAttributes | undefined, name: string): string | undefined {
|
|
170
|
+
if (!attrs) return undefined;
|
|
171
|
+
for (const a of attrs.properties) {
|
|
172
|
+
if (!ts.isJsxAttribute(a)) continue;
|
|
173
|
+
if (!ts.isIdentifier(a.name) || a.name.text !== name) continue;
|
|
174
|
+
const init = a.initializer;
|
|
175
|
+
if (init && ts.isStringLiteral(init)) return init.text;
|
|
176
|
+
if (
|
|
177
|
+
init &&
|
|
178
|
+
ts.isJsxExpression(init) &&
|
|
179
|
+
init.expression &&
|
|
180
|
+
ts.isStringLiteral(init.expression)
|
|
181
|
+
) {
|
|
182
|
+
return init.expression.text;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { FetchCallInfo } from "../types.ts";
|
|
3
|
+
import { posOf } from "./pos.ts";
|
|
4
|
+
import { hookCalleeName } from "./refs.ts";
|
|
5
|
+
|
|
6
|
+
const EFFECT_HOOKS = new Set(["useEffect", "useLayoutEffect"]);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Collect fetch() call sites with their URL shape. Interpolations become
|
|
10
|
+
* :holes named after the interpolated identifier (or the property name of a
|
|
11
|
+
* member access); anything unnameable becomes :param. Non-literal URLs
|
|
12
|
+
* (variables, function results) are skipped — better no spec than a wrong one.
|
|
13
|
+
*/
|
|
14
|
+
export function extractFetchCalls(bodyNode: ts.Node, sourceFile: ts.SourceFile): FetchCallInfo[] {
|
|
15
|
+
const out: FetchCallInfo[] = [];
|
|
16
|
+
|
|
17
|
+
const visit = (n: ts.Node): void => {
|
|
18
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === "fetch") {
|
|
19
|
+
const urlArg = n.arguments[0];
|
|
20
|
+
const parsed = urlArg ? parseUrlArg(urlArg) : undefined;
|
|
21
|
+
if (parsed) {
|
|
22
|
+
out.push({
|
|
23
|
+
urlTemplate: parsed.template,
|
|
24
|
+
holes: parsed.holes,
|
|
25
|
+
method: readMethod(n.arguments[1]),
|
|
26
|
+
pos: posOf(n, sourceFile),
|
|
27
|
+
inEffect: isInsideEffect(n, bodyNode),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
ts.forEachChild(n, visit);
|
|
32
|
+
};
|
|
33
|
+
ts.forEachChild(bodyNode, visit);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseUrlArg(arg: ts.Expression): { template: string; holes: string[] } | undefined {
|
|
38
|
+
if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) {
|
|
39
|
+
return { template: arg.text, holes: [] };
|
|
40
|
+
}
|
|
41
|
+
if (ts.isTemplateExpression(arg)) {
|
|
42
|
+
let template = arg.head.text;
|
|
43
|
+
const holes: string[] = [];
|
|
44
|
+
for (const span of arg.templateSpans) {
|
|
45
|
+
const hole = holeName(span.expression, holes.length);
|
|
46
|
+
holes.push(hole);
|
|
47
|
+
template += `:${hole}${span.literal.text}`;
|
|
48
|
+
}
|
|
49
|
+
return { template, holes };
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function holeName(expr: ts.Expression, index: number): string {
|
|
55
|
+
if (ts.isIdentifier(expr)) return expr.text;
|
|
56
|
+
if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.name)) return expr.name.text;
|
|
57
|
+
return `param${index}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function readMethod(init: ts.Expression | undefined): string {
|
|
61
|
+
if (!init || !ts.isObjectLiteralExpression(init)) return "GET";
|
|
62
|
+
for (const p of init.properties) {
|
|
63
|
+
if (!ts.isPropertyAssignment(p)) continue;
|
|
64
|
+
const name = ts.isIdentifier(p.name) || ts.isStringLiteral(p.name) ? p.name.text : undefined;
|
|
65
|
+
if (name !== "method") continue;
|
|
66
|
+
if (ts.isStringLiteral(p.initializer)) return p.initializer.text.toUpperCase();
|
|
67
|
+
}
|
|
68
|
+
return "GET";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isInsideEffect(call: ts.Node, bodyNode: ts.Node): boolean {
|
|
72
|
+
let cur: ts.Node | undefined = call.parent;
|
|
73
|
+
while (cur && cur !== bodyNode) {
|
|
74
|
+
if (ts.isCallExpression(cur)) {
|
|
75
|
+
const name = hookCalleeName(cur.expression);
|
|
76
|
+
if (name && EFFECT_HOOKS.has(name)) return true;
|
|
77
|
+
}
|
|
78
|
+
cur = cur.parent;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { VOID_ELEMENTS } from "./hostProps.ts";
|
|
3
|
+
import { domOnlyResolver, type HostTagResolver } from "./hostTag.ts";
|
|
4
|
+
import { nativeAcceptsChildrenByName } from "./nativeProps.ts";
|
|
5
|
+
import { initialRenderExpression, type RenderEnv, reachability } from "./renderPath.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether `children` and `className` actually reach the DOM.
|
|
9
|
+
*
|
|
10
|
+
* Both are the basis for assertions on components that render no literal copy of
|
|
11
|
+
* their own — the whole shadcn/ui layer, which was 40% of the generated specs and
|
|
12
|
+
* every one of them assertion-free. Accepting a prop is not the same as
|
|
13
|
+
* rendering it, so each rule below insists on seeing the value flow somewhere it
|
|
14
|
+
* can be observed, and gives up otherwise.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface RenderFlows {
|
|
18
|
+
/** A sentinel child passed in will appear in the markup. */
|
|
19
|
+
children: boolean;
|
|
20
|
+
/** A className passed in will land on some element. */
|
|
21
|
+
className: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function extractFlows(
|
|
25
|
+
bodyNode: ts.Node,
|
|
26
|
+
env: RenderEnv,
|
|
27
|
+
hostTag: HostTagResolver = domOnlyResolver,
|
|
28
|
+
): RenderFlows {
|
|
29
|
+
const initial = initialRenderExpression(bodyNode, env);
|
|
30
|
+
if (!initial) return { children: false, className: false };
|
|
31
|
+
const { expr: root, scope } = initial;
|
|
32
|
+
const reached = (n: ts.Node): boolean => reachability(n, root, scope) === "yes";
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
children: childrenFlow(root, reached, hostTag),
|
|
36
|
+
// NativeWind compiles className into a `style` at BUILD time, through babel.
|
|
37
|
+
// The string does not survive to the rendered tree under jest-expo, and
|
|
38
|
+
// react-native-web consumes it without forwarding anything either (measured:
|
|
39
|
+
// docs/archive/2026-08-expo-probes.md, P1). So "a className passed in will
|
|
40
|
+
// land on some element" is both unfalsifiable AND unsatisfiable on native,
|
|
41
|
+
// and asserting it would be a test that can never pass.
|
|
42
|
+
//
|
|
43
|
+
// childrenFlow is unaffected: a sentinel child under a <View> genuinely renders.
|
|
44
|
+
className:
|
|
45
|
+
hostTag("View")?.dialect === "native" ? false : classNameFlow(root, reached, hostTag),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `{children}` rendered directly, or a `{...props}` spread onto a host element
|
|
51
|
+
* that can hold children.
|
|
52
|
+
*
|
|
53
|
+
* The spread case is how the entire shadcn layer works — `<div {...props} />`
|
|
54
|
+
* never mentions children by name — so leaving it out would miss most of the
|
|
55
|
+
* components this rule exists for. Void elements are excluded: <input {...props}/>
|
|
56
|
+
* accepts the prop and renders nothing.
|
|
57
|
+
*/
|
|
58
|
+
function childrenFlow(
|
|
59
|
+
root: ts.Node,
|
|
60
|
+
reached: (n: ts.Node) => boolean,
|
|
61
|
+
hostTag: HostTagResolver,
|
|
62
|
+
): boolean {
|
|
63
|
+
let found = false;
|
|
64
|
+
const visit = (n: ts.Node): void => {
|
|
65
|
+
if (found) return;
|
|
66
|
+
if (
|
|
67
|
+
ts.isJsxExpression(n) &&
|
|
68
|
+
n.expression &&
|
|
69
|
+
ts.isIdentifier(n.expression) &&
|
|
70
|
+
n.expression.text === "children" &&
|
|
71
|
+
n.parent &&
|
|
72
|
+
(ts.isJsxElement(n.parent) || ts.isJsxFragment(n.parent)) &&
|
|
73
|
+
reached(n)
|
|
74
|
+
) {
|
|
75
|
+
found = true;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (ts.isJsxSpreadAttribute(n) && reached(n)) {
|
|
79
|
+
const owner = n.parent?.parent;
|
|
80
|
+
const owned = owner ? resolvedOf(owner, hostTag) : undefined;
|
|
81
|
+
if (owned && acceptsChildren(owned) && !explicitChildrenOverrideSpread(owner)) {
|
|
82
|
+
found = true;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
ts.forEachChild(n, visit);
|
|
87
|
+
};
|
|
88
|
+
visit(root);
|
|
89
|
+
return found;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* JSX explicit children REPLACE spread children — `<svg {...props}><path/></svg>`
|
|
94
|
+
* never renders `props.children`, and every icon in the corpus is exactly that
|
|
95
|
+
* shape (SVGProps-typed, children accepted via the spread, fixed paths rendered).
|
|
96
|
+
* The sentinel-child test against one is not a failing component, it is a test
|
|
97
|
+
* asserting a JSX precedence rule backwards, so the flow must not report true.
|
|
98
|
+
*/
|
|
99
|
+
function explicitChildrenOverrideSpread(owner: ts.Node | undefined): boolean {
|
|
100
|
+
if (!owner || !ts.isJsxElement(owner.parent)) return false;
|
|
101
|
+
return owner.parent.children.some(
|
|
102
|
+
(c) =>
|
|
103
|
+
(ts.isJsxText(c) && c.getText().trim().length > 0) ||
|
|
104
|
+
ts.isJsxElement(c) ||
|
|
105
|
+
ts.isJsxSelfClosingElement(c) ||
|
|
106
|
+
ts.isJsxFragment(c) ||
|
|
107
|
+
(ts.isJsxExpression(c) && c.expression !== undefined),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The `className` identifier appearing inside a `className=` attribute on a HOST
|
|
113
|
+
* element — directly, as an argument to any call (cn/clsx/twMerge), or inside a
|
|
114
|
+
* template span.
|
|
115
|
+
*
|
|
116
|
+
* Host-only is the load-bearing part. `<SeparatorPrimitive className={cn(…)} />`
|
|
117
|
+
* hands the class to another component, and whether that component puts it on a
|
|
118
|
+
* DOM node is its business, not something this file can see. Asserting on it
|
|
119
|
+
* would be a claim about a third-party implementation.
|
|
120
|
+
*
|
|
121
|
+
* The other way this goes wrong is a `{...props}` spread positioned AFTER the
|
|
122
|
+
* attribute on the same element, which overwrites it. That is checked rather
|
|
123
|
+
* than assumed.
|
|
124
|
+
*/
|
|
125
|
+
function classNameFlow(
|
|
126
|
+
root: ts.Node,
|
|
127
|
+
reached: (n: ts.Node) => boolean,
|
|
128
|
+
hostTag: HostTagResolver,
|
|
129
|
+
): boolean {
|
|
130
|
+
let found = false;
|
|
131
|
+
const visit = (n: ts.Node): void => {
|
|
132
|
+
if (found) return;
|
|
133
|
+
if (
|
|
134
|
+
ts.isJsxAttribute(n) &&
|
|
135
|
+
ts.isIdentifier(n.name) &&
|
|
136
|
+
n.name.text === "className" &&
|
|
137
|
+
hostTagOf(n.parent?.parent, hostTag) !== undefined &&
|
|
138
|
+
reached(n) &&
|
|
139
|
+
mentionsClassName(n) &&
|
|
140
|
+
!overwrittenBySpread(n)
|
|
141
|
+
) {
|
|
142
|
+
found = true;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
ts.forEachChild(n, visit);
|
|
146
|
+
};
|
|
147
|
+
visit(root);
|
|
148
|
+
return found;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function mentionsClassName(attr: ts.JsxAttribute): boolean {
|
|
152
|
+
let found = false;
|
|
153
|
+
const visit = (n: ts.Node): void => {
|
|
154
|
+
if (found) return;
|
|
155
|
+
if (ts.isIdentifier(n) && n.text === "className" && n !== attr.name) {
|
|
156
|
+
found = true;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
ts.forEachChild(n, visit);
|
|
160
|
+
};
|
|
161
|
+
if (attr.initializer) visit(attr.initializer);
|
|
162
|
+
return found;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A spread later in the same attribute list wins, so the className is lost. */
|
|
166
|
+
function overwrittenBySpread(attr: ts.JsxAttribute): boolean {
|
|
167
|
+
const list = attr.parent;
|
|
168
|
+
if (!list || !ts.isJsxAttributes(list)) return false;
|
|
169
|
+
const index = list.properties.indexOf(attr);
|
|
170
|
+
if (index < 0) return false;
|
|
171
|
+
return list.properties
|
|
172
|
+
.slice(index + 1)
|
|
173
|
+
.some((p) => ts.isJsxSpreadAttribute(p) && !isClassNameSafeSpread(p));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* `{...props}` after className is only harmful when props can carry className.
|
|
178
|
+
* A rest binding that had className destructured out of it cannot.
|
|
179
|
+
*/
|
|
180
|
+
function isClassNameSafeSpread(spread: ts.JsxSpreadAttribute): boolean {
|
|
181
|
+
if (!ts.isIdentifier(spread.expression)) return false;
|
|
182
|
+
const name = spread.expression.text;
|
|
183
|
+
// `const { className, ...props }` — the rest holds everything BUT className.
|
|
184
|
+
for (let n: ts.Node | undefined = spread; n; n = n.parent) {
|
|
185
|
+
if (!ts.isFunctionDeclaration(n) && !ts.isArrowFunction(n) && !ts.isFunctionExpression(n)) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const first = n.parameters[0];
|
|
189
|
+
if (!first || !ts.isObjectBindingPattern(first.name)) return false;
|
|
190
|
+
let sawClassName = false;
|
|
191
|
+
let restMatches = false;
|
|
192
|
+
for (const el of first.name.elements) {
|
|
193
|
+
if (!ts.isBindingElement(el)) continue;
|
|
194
|
+
if (el.dotDotDotToken) {
|
|
195
|
+
restMatches = ts.isIdentifier(el.name) && el.name.text === name;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (ts.isIdentifier(el.name) && el.name.text === "className") sawClassName = true;
|
|
199
|
+
}
|
|
200
|
+
return sawClassName && restMatches;
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Whether a resolved host element can hold children, in either dialect. */
|
|
206
|
+
function acceptsChildren(el: { name: string; dialect: "dom" | "native" }): boolean {
|
|
207
|
+
return el.dialect === "native"
|
|
208
|
+
? nativeAcceptsChildrenByName(el.name)
|
|
209
|
+
: !VOID_ELEMENTS.has(el.name);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function resolvedOf(node: ts.Node, hostTag: HostTagResolver) {
|
|
213
|
+
const tagName = ts.isJsxOpeningElement(node)
|
|
214
|
+
? node.tagName
|
|
215
|
+
: ts.isJsxSelfClosingElement(node)
|
|
216
|
+
? node.tagName
|
|
217
|
+
: undefined;
|
|
218
|
+
if (!tagName || !ts.isIdentifier(tagName)) return undefined;
|
|
219
|
+
return hostTag(tagName.text);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function hostTagOf(node: ts.Node | undefined, hostTag: HostTagResolver): string | undefined {
|
|
223
|
+
if (!node) return undefined;
|
|
224
|
+
const tagName = ts.isJsxOpeningElement(node)
|
|
225
|
+
? node.tagName
|
|
226
|
+
: ts.isJsxSelfClosingElement(node)
|
|
227
|
+
? node.tagName
|
|
228
|
+
: undefined;
|
|
229
|
+
if (!tagName || !ts.isIdentifier(tagName)) return undefined;
|
|
230
|
+
return hostTag(tagName.text)?.name;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The SECOND copy of VOID_ELEMENTS lived here, three lines below its own use,
|
|
235
|
+
* while hostProps.ts exported the authoritative one — and the two had already
|
|
236
|
+
* drifted (this copy was missing "param"). Deleted in favour of the table that
|
|
237
|
+
* also answers the question for React Native, where "does this element take
|
|
238
|
+
* children" is a per-element fact rather than a list of fourteen tag names.
|
|
239
|
+
*/
|