@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,212 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { findDisclosures } from "./extractDisclosures.ts";
|
|
3
|
+
import { emptyEnv, initialRenderExpression, type RenderEnv, reachability } from "./renderPath.ts";
|
|
4
|
+
|
|
5
|
+
const MAX_TEXTS = 8;
|
|
6
|
+
const MIN_LENGTH = 2;
|
|
7
|
+
/** Separators and icon glyphs — "·", "→", "—" — are decoration, not copy. */
|
|
8
|
+
const PUNCTUATION_ONLY = /^[\p{P}\p{S}\s]+$/u;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Literal copy a component renders unconditionally, ready to be asserted on
|
|
12
|
+
* with Testing-Library's getByText.
|
|
13
|
+
*
|
|
14
|
+
* Everything here exists to keep a false positive out of a generated spec: text
|
|
15
|
+
* that only sometimes renders (a ternary branch, a `.map` row, a guard path)
|
|
16
|
+
* produces a spec that fails on someone's CI for no reason, which is worse than
|
|
17
|
+
* emitting no assertion at all. Purely syntactic — no type checker.
|
|
18
|
+
*/
|
|
19
|
+
export function extractStaticText(bodyNode: ts.Node, env: RenderEnv = emptyEnv()): string[] {
|
|
20
|
+
// The JSX this component renders FIRST, which is not always the tail return —
|
|
21
|
+
// a guard we can decide points somewhere else entirely.
|
|
22
|
+
const initial = initialRenderExpression(bodyNode, env);
|
|
23
|
+
if (!initial) return [];
|
|
24
|
+
const root = initial.expr;
|
|
25
|
+
if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root) && !ts.isJsxFragment(root)) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const found: string[] = [];
|
|
30
|
+
// Disclosure content is the purest form of "text that only sometimes
|
|
31
|
+
// renders": a Radix Dialog/Menu/Popover Content subtree is not in the DOM
|
|
32
|
+
// until its trigger is clicked, and asserting on it straight after render
|
|
33
|
+
// reddens every static-content test (ConfirmModal's "Cancel"/"Confirm").
|
|
34
|
+
// The interaction tests open the disclosure themselves; static copy does
|
|
35
|
+
// not get that step, so gated subtrees are skipped outright.
|
|
36
|
+
const gated = new Set<ts.Node>();
|
|
37
|
+
for (const d of findDisclosures(root)) for (const n of d.gated) gated.add(n);
|
|
38
|
+
const visit = (n: ts.Node): void => {
|
|
39
|
+
if (gated.has(n)) return;
|
|
40
|
+
const text = literalText(n);
|
|
41
|
+
if (
|
|
42
|
+
text !== undefined &&
|
|
43
|
+
reachability(n, root, initial.scope) === "yes" &&
|
|
44
|
+
!underJsxAttribute(n, root) &&
|
|
45
|
+
isSoleRenderedChild(n)
|
|
46
|
+
) {
|
|
47
|
+
found.push(text);
|
|
48
|
+
}
|
|
49
|
+
ts.forEachChild(n, visit);
|
|
50
|
+
};
|
|
51
|
+
visit(root);
|
|
52
|
+
|
|
53
|
+
return normalise(found);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** JSX text, or an expression container holding nothing but a string literal. */
|
|
57
|
+
function literalText(n: ts.Node): string | undefined {
|
|
58
|
+
if (ts.isJsxText(n)) return decodeJsxEntities(n.text);
|
|
59
|
+
if (ts.isJsxExpression(n) && n.expression) {
|
|
60
|
+
const expr = n.expression;
|
|
61
|
+
// A string literal inside an expression container is plain JS: the JSX
|
|
62
|
+
// compiler does not decode entities in it, so nothing to decode here.
|
|
63
|
+
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return expr.text;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const JSX_ENTITIES: Record<string, string> = {
|
|
69
|
+
amp: "&",
|
|
70
|
+
lt: "<",
|
|
71
|
+
gt: ">",
|
|
72
|
+
quot: '"',
|
|
73
|
+
apos: "'",
|
|
74
|
+
nbsp: "\u00a0",
|
|
75
|
+
ensp: "\u2002",
|
|
76
|
+
emsp: "\u2003",
|
|
77
|
+
thinsp: "\u2009",
|
|
78
|
+
shy: "\u00ad",
|
|
79
|
+
mdash: "\u2014",
|
|
80
|
+
ndash: "\u2013",
|
|
81
|
+
hellip: "\u2026",
|
|
82
|
+
bull: "\u2022",
|
|
83
|
+
middot: "\u00b7",
|
|
84
|
+
times: "\u00d7",
|
|
85
|
+
laquo: "\u00ab",
|
|
86
|
+
raquo: "\u00bb",
|
|
87
|
+
ldquo: "\u201c",
|
|
88
|
+
rdquo: "\u201d",
|
|
89
|
+
lsquo: "\u2018",
|
|
90
|
+
rsquo: "\u2019",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* JSX text carries HTML entities; the compiler decodes them before React ever
|
|
95
|
+
* sees the string. An extractor that keeps the raw `couldn't` generates a
|
|
96
|
+
* getByText("couldn't") that can never match the DOM's "couldn't".
|
|
97
|
+
* (& must be replaced last, or `&amp;` decodes twice.)
|
|
98
|
+
*/
|
|
99
|
+
function decodeJsxEntities(text: string): string {
|
|
100
|
+
if (!text.includes("&")) return text;
|
|
101
|
+
return text
|
|
102
|
+
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
|
|
103
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex: string) =>
|
|
104
|
+
String.fromCodePoint(Number.parseInt(hex, 16)),
|
|
105
|
+
)
|
|
106
|
+
.replace(/&([a-zA-Z]+);/g, (whole, name: string) => JSX_ENTITIES[name] ?? whole);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* aria-label / title / placeholder text is not the element's text content, so
|
|
111
|
+
* getByText will never find it. reachability has nothing to say about this —
|
|
112
|
+
* the attribute IS rendered, just not as copy.
|
|
113
|
+
*/
|
|
114
|
+
function underJsxAttribute(node: ts.Node, root: ts.Node): boolean {
|
|
115
|
+
for (let p = node.parent; p && p !== root; p = p.parent) {
|
|
116
|
+
if (ts.isJsxAttribute(p)) return true;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* getByText matches an element's WHOLE normalised text content, so a text node
|
|
123
|
+
* is only assertable when it is its parent element's only rendered child. In
|
|
124
|
+
* `<p>From <em>Asha</em></p>` the DOM holds no element whose text is "From" —
|
|
125
|
+
* the <p> reads "From Asha" — so asserting on "From" fails at runtime; only
|
|
126
|
+
* "Asha", the sole child of <em>, is safe to emit.
|
|
127
|
+
*/
|
|
128
|
+
function isSoleRenderedChild(node: ts.Node): boolean {
|
|
129
|
+
const parent = node.parent;
|
|
130
|
+
if (!parent || !ts.isJsxElement(parent)) return false;
|
|
131
|
+
const rendered = parent.children.filter((child) => !isLayoutWhitespace(child));
|
|
132
|
+
return rendered.length === 1 && rendered[0] === node;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Indentation between JSX children; React drops it, so it is not a sibling. */
|
|
136
|
+
function isLayoutWhitespace(child: ts.JsxChild): boolean {
|
|
137
|
+
return ts.isJsxText(child) && child.text.trim() === "";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function normalise(found: string[]): string[] {
|
|
141
|
+
const out: string[] = [];
|
|
142
|
+
const seen = new Set<string>();
|
|
143
|
+
for (const value of found) {
|
|
144
|
+
const text = value.replace(/\s+/g, " ").trim();
|
|
145
|
+
if (text.length < MIN_LENGTH) continue;
|
|
146
|
+
if (PUNCTUATION_ONLY.test(text)) continue;
|
|
147
|
+
// A brace survived into the literal (`{"{count} items"}`), meaning the copy
|
|
148
|
+
// is a template the runtime fills in — the rendered text differs.
|
|
149
|
+
if (text.includes("{") || text.includes("}")) continue;
|
|
150
|
+
if (seen.has(text)) continue;
|
|
151
|
+
seen.add(text);
|
|
152
|
+
out.push(text);
|
|
153
|
+
if (out.length === MAX_TEXTS) break;
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Literal copy in the branch an error boundary renders once it has caught.
|
|
160
|
+
*
|
|
161
|
+
* extractStaticText walks the INITIAL render, and an error boundary has none —
|
|
162
|
+
* its render branches on `this.state`, which is exactly why the whole component
|
|
163
|
+
* refuses. But this branch is not undecidable in the way the others are: React's
|
|
164
|
+
* own contract says getDerivedStateFromError puts the component into that state,
|
|
165
|
+
* so a spec reaches it by rendering a child that throws.
|
|
166
|
+
*
|
|
167
|
+
* The branch is the consequent of the first `if` whose condition reads
|
|
168
|
+
* `this.state`. Anything else — a guard on props, a loading flag — is not the
|
|
169
|
+
* error branch and is left alone.
|
|
170
|
+
*/
|
|
171
|
+
export function errorFallbackText(renderBody: ts.Node): string[] {
|
|
172
|
+
let branch: ts.Node | undefined;
|
|
173
|
+
const find = (n: ts.Node): void => {
|
|
174
|
+
if (branch) return;
|
|
175
|
+
if (ts.isIfStatement(n) && readsThisState(n.expression)) {
|
|
176
|
+
branch = n.thenStatement;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
ts.forEachChild(n, find);
|
|
180
|
+
};
|
|
181
|
+
find(renderBody);
|
|
182
|
+
if (!branch) return [];
|
|
183
|
+
const out: string[] = [];
|
|
184
|
+
const collect = (n: ts.Node): void => {
|
|
185
|
+
if (ts.isJsxText(n)) {
|
|
186
|
+
const t = decodeJsxEntities(n.text).trim();
|
|
187
|
+
if (t) out.push(t);
|
|
188
|
+
}
|
|
189
|
+
ts.forEachChild(n, collect);
|
|
190
|
+
};
|
|
191
|
+
collect(branch);
|
|
192
|
+
return [...new Set(out)];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** `this.state` / `this.state.x` anywhere in an expression. */
|
|
196
|
+
function readsThisState(expr: ts.Expression): boolean {
|
|
197
|
+
let found = false;
|
|
198
|
+
const walk = (n: ts.Node): void => {
|
|
199
|
+
if (found) return;
|
|
200
|
+
if (
|
|
201
|
+
ts.isPropertyAccessExpression(n) &&
|
|
202
|
+
n.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
203
|
+
n.name.text === "state"
|
|
204
|
+
) {
|
|
205
|
+
found = true;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
ts.forEachChild(n, walk);
|
|
209
|
+
};
|
|
210
|
+
walk(expr);
|
|
211
|
+
return found;
|
|
212
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { PropValue, SourcePosition } from "../types.ts";
|
|
3
|
+
import type { ImportBinding } from "./parseFile.ts";
|
|
4
|
+
import { posOf } from "./pos.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `cva()` / `tv()` variant tables read straight from the source.
|
|
8
|
+
*
|
|
9
|
+
* This is the highest-value inference for shadcn-style codebases: a component
|
|
10
|
+
* typed `VariantProps<typeof buttonVariants>` has no discoverable props at all
|
|
11
|
+
* without it, but the variant keys and their option names are sitting in a
|
|
12
|
+
* plain object literal one declaration above.
|
|
13
|
+
*
|
|
14
|
+
* Option values ARE read, but only to compare them with each other. They are
|
|
15
|
+
* Tailwind class strings, and cn()/tailwind-merge drops base classes that a
|
|
16
|
+
* later variant conflicts with — verified against the real DOM — so asserting a
|
|
17
|
+
* specific token is a false-failure waiting to happen. What IS sound is that two
|
|
18
|
+
* options whose class strings differ must render differently.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface VariantDefinition {
|
|
22
|
+
/** Local const name: "buttonVariants". */
|
|
23
|
+
name: string;
|
|
24
|
+
factory: "cva" | "tv";
|
|
25
|
+
/** key → literal options, in source order. */
|
|
26
|
+
options: Record<string, PropValue[]>;
|
|
27
|
+
/** From defaultVariants. */
|
|
28
|
+
defaults: Record<string, PropValue>;
|
|
29
|
+
keyPositions: Record<string, SourcePosition>;
|
|
30
|
+
/** key → option → its class string, for distinctness checks only. */
|
|
31
|
+
classes: Record<string, Record<string, string>>;
|
|
32
|
+
pos: SourcePosition;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const FACTORY_SPECIFIER: Record<string, "cva" | "tv"> = {
|
|
36
|
+
"class-variance-authority": "cva",
|
|
37
|
+
"tailwind-variants": "tv",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Beyond these a config is machine-generated and reading it is guessing. */
|
|
41
|
+
const MAX_KEYS = 12;
|
|
42
|
+
const MAX_OPTIONS_PER_KEY = 24;
|
|
43
|
+
|
|
44
|
+
/** Top-level cva()/tv() consts declared in `sf`, keyed by local const name. */
|
|
45
|
+
export function extractVariantDefs(
|
|
46
|
+
sf: ts.SourceFile,
|
|
47
|
+
imports: Map<string, ImportBinding>,
|
|
48
|
+
): Map<string, VariantDefinition> {
|
|
49
|
+
const out = new Map<string, VariantDefinition>();
|
|
50
|
+
|
|
51
|
+
for (const stmt of sf.statements) {
|
|
52
|
+
if (!ts.isVariableStatement(stmt)) continue;
|
|
53
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
54
|
+
if (!ts.isIdentifier(decl.name)) continue;
|
|
55
|
+
const init = decl.initializer;
|
|
56
|
+
if (!init || !ts.isCallExpression(init)) continue;
|
|
57
|
+
|
|
58
|
+
// Provenance: the callee must actually be cva/tv from its real package.
|
|
59
|
+
// Alias-safe — `import { cva as classes }` keeps importedName "cva".
|
|
60
|
+
if (!ts.isIdentifier(init.expression)) continue;
|
|
61
|
+
const binding = imports.get(init.expression.text);
|
|
62
|
+
if (!binding) continue;
|
|
63
|
+
const factory = FACTORY_SPECIFIER[binding.specifier];
|
|
64
|
+
if (!factory) continue;
|
|
65
|
+
const importedName = binding.importedName ?? init.expression.text;
|
|
66
|
+
if (factory === "cva" && importedName !== "cva") continue;
|
|
67
|
+
if (factory === "tv" && importedName !== "tv") continue;
|
|
68
|
+
|
|
69
|
+
// cva(base, config) vs tv(config)
|
|
70
|
+
const configArg = factory === "cva" ? init.arguments[1] : init.arguments[0];
|
|
71
|
+
if (!configArg || !ts.isObjectLiteralExpression(configArg)) continue;
|
|
72
|
+
|
|
73
|
+
const def = readConfig(decl.name.text, factory, configArg, sf, posOf(decl, sf));
|
|
74
|
+
if (def) out.set(decl.name.text, def);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readConfig(
|
|
81
|
+
name: string,
|
|
82
|
+
factory: "cva" | "tv",
|
|
83
|
+
config: ts.ObjectLiteralExpression,
|
|
84
|
+
sf: ts.SourceFile,
|
|
85
|
+
pos: SourcePosition,
|
|
86
|
+
): VariantDefinition | undefined {
|
|
87
|
+
let variantsObj: ts.ObjectLiteralExpression | undefined;
|
|
88
|
+
let defaultsObj: ts.ObjectLiteralExpression | undefined;
|
|
89
|
+
|
|
90
|
+
for (const p of config.properties) {
|
|
91
|
+
if (!ts.isPropertyAssignment(p)) continue;
|
|
92
|
+
const key = propertyName(p.name);
|
|
93
|
+
// A tv() slots config changes the option-value shape and the prop
|
|
94
|
+
// semantics entirely — bail on the whole definition rather than guess.
|
|
95
|
+
if (key === "slots") return undefined;
|
|
96
|
+
if (key === "variants" && ts.isObjectLiteralExpression(p.initializer)) {
|
|
97
|
+
variantsObj = p.initializer;
|
|
98
|
+
}
|
|
99
|
+
if (key === "defaultVariants" && ts.isObjectLiteralExpression(p.initializer)) {
|
|
100
|
+
defaultsObj = p.initializer;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (!variantsObj) return undefined;
|
|
104
|
+
|
|
105
|
+
const options: Record<string, PropValue[]> = {};
|
|
106
|
+
const keyPositions: Record<string, SourcePosition> = {};
|
|
107
|
+
const classes: Record<string, Record<string, string>> = {};
|
|
108
|
+
|
|
109
|
+
for (const prop of variantsObj.properties) {
|
|
110
|
+
if (Object.keys(options).length >= MAX_KEYS) break;
|
|
111
|
+
// A spread or computed key means we cannot know the full option set for
|
|
112
|
+
// that key — skip the key, keep the rest.
|
|
113
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
114
|
+
const key = propertyName(prop.name);
|
|
115
|
+
if (!key) continue;
|
|
116
|
+
if (!ts.isObjectLiteralExpression(prop.initializer)) continue;
|
|
117
|
+
|
|
118
|
+
const names: string[] = [];
|
|
119
|
+
const classFor: Record<string, string> = {};
|
|
120
|
+
let poisoned = false;
|
|
121
|
+
for (const opt of prop.initializer.properties) {
|
|
122
|
+
if (!ts.isPropertyAssignment(opt) && !ts.isShorthandPropertyAssignment(opt)) {
|
|
123
|
+
poisoned = true;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
const optName = propertyName(opt.name);
|
|
127
|
+
if (!optName) {
|
|
128
|
+
poisoned = true;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
names.push(optName);
|
|
132
|
+
if (ts.isPropertyAssignment(opt)) {
|
|
133
|
+
const cls = literalValue(opt.initializer);
|
|
134
|
+
if (typeof cls === "string") classFor[optName] = cls.replace(/\s+/g, " ").trim();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (poisoned || names.length === 0) continue;
|
|
138
|
+
if (names.length > MAX_OPTIONS_PER_KEY) continue;
|
|
139
|
+
|
|
140
|
+
options[key] = asOptionValues(names);
|
|
141
|
+
keyPositions[key] = posOf(prop, sf);
|
|
142
|
+
classes[key] = classFor;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (Object.keys(options).length === 0) return undefined;
|
|
146
|
+
|
|
147
|
+
const defaults: Record<string, PropValue> = {};
|
|
148
|
+
for (const p of defaultsObj?.properties ?? []) {
|
|
149
|
+
if (!ts.isPropertyAssignment(p)) continue;
|
|
150
|
+
const key = propertyName(p.name);
|
|
151
|
+
if (!key || !(key in options)) continue;
|
|
152
|
+
const v = literalValue(p.initializer);
|
|
153
|
+
if (v !== undefined) defaults[key] = v;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { name, factory, options, defaults, keyPositions, classes, pos };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A key whose options are exactly true/false is a real boolean prop. */
|
|
160
|
+
function asOptionValues(names: string[]): PropValue[] {
|
|
161
|
+
const isBoolean = names.every((n) => n === "true" || n === "false");
|
|
162
|
+
if (isBoolean) {
|
|
163
|
+
const seen = new Set(names);
|
|
164
|
+
const out: PropValue[] = [];
|
|
165
|
+
if (seen.has("true")) out.push(true);
|
|
166
|
+
if (seen.has("false")) out.push(false);
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
return names;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function propertyName(name: ts.PropertyName): string | undefined {
|
|
173
|
+
if (ts.isIdentifier(name)) return name.text;
|
|
174
|
+
// "icon-xs" and friends are string literals, and they are real option names.
|
|
175
|
+
if (ts.isStringLiteral(name)) return name.text;
|
|
176
|
+
if (ts.isNumericLiteral(name)) return name.text;
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function literalValue(node: ts.Expression): PropValue | undefined {
|
|
181
|
+
if (ts.isStringLiteral(node)) return node.text;
|
|
182
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
183
|
+
if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
184
|
+
if (ts.isNumericLiteral(node)) return Number(node.text);
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { FetchCallInfo } from "../types.ts";
|
|
3
|
+
import { parseUrlArg, readMethod } from "./extractFetchCalls.ts";
|
|
4
|
+
import type { ImportBinding } from "./parseFile.ts";
|
|
5
|
+
import { posOf } from "./pos.ts";
|
|
6
|
+
import { hookCalleeName } from "./refs.ts";
|
|
7
|
+
import type { ModuleResolver } from "./resolveModule.ts";
|
|
8
|
+
import { exportedFunctions } from "./serverActionReturns.ts";
|
|
9
|
+
import { findExportedDecl, type SourceFileCache } from "./sourceCache.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Endpoints a component reaches through the app's OWN fetch wrapper, resolved
|
|
13
|
+
* one hop — the harvest the load task needs on React Native and could never get
|
|
14
|
+
* from `extractFetchCalls`.
|
|
15
|
+
*
|
|
16
|
+
* A React Native screen never writes `fetch()`: it calls a named api function
|
|
17
|
+
* (`listBuildings()`) imported from `lib/api`, whose body calls a module-local
|
|
18
|
+
* wrapper with a literal path (`apiFetch("/api/buildings")`), and the wrapper is
|
|
19
|
+
* the only thing that touches `fetch`. So `fetchCalls` is empty on every screen
|
|
20
|
+
* (0 endpoints on the whole dogfood app), and the endpoint URL lives two hops
|
|
21
|
+
* away: screen → api function → apiFetch.
|
|
22
|
+
*
|
|
23
|
+
* This reads exactly that chain, and no deeper:
|
|
24
|
+
*
|
|
25
|
+
* 1. the screen CALLS an imported name (imported-but-uncalled is dead, and
|
|
26
|
+
* load-testing an endpoint the screen never hits would be a fiction);
|
|
27
|
+
* 2. the name resolves — through barrels — to an exported function whose body
|
|
28
|
+
* calls a module-LOCAL fetch wrapper with a string-literal or template
|
|
29
|
+
* path;
|
|
30
|
+
* 3. the wrapper is proven to be one: its single `fetch()` addresses one of
|
|
31
|
+
* its own parameters. A function merely NAMED apiFetch is not trusted; a
|
|
32
|
+
* helper that happens to take a string is not mistaken for the client.
|
|
33
|
+
*
|
|
34
|
+
* Anything it cannot see it drops rather than guesses: a call mediated by a
|
|
35
|
+
* store, a path built from a non-literal, a wrapper two modules deep. A missing
|
|
36
|
+
* endpoint costs a pipe; a wrong one measures a URL the app never requests.
|
|
37
|
+
*/
|
|
38
|
+
export function harvestApiEndpoints(
|
|
39
|
+
bodyNode: ts.Node,
|
|
40
|
+
sourceFile: ts.SourceFile,
|
|
41
|
+
imports: Map<string, ImportBinding>,
|
|
42
|
+
file: string,
|
|
43
|
+
resolver: ModuleResolver,
|
|
44
|
+
cache: SourceFileCache,
|
|
45
|
+
): FetchCallInfo[] {
|
|
46
|
+
// Every imported name the component actually invokes, with the call node for
|
|
47
|
+
// its position (imported-but-uncalled contributes nothing).
|
|
48
|
+
const invoked = new Map<string, ts.CallExpression>();
|
|
49
|
+
const visit = (n: ts.Node): void => {
|
|
50
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
|
|
51
|
+
const name = n.expression.text;
|
|
52
|
+
if (imports.has(name) && !invoked.has(name)) invoked.set(name, n);
|
|
53
|
+
}
|
|
54
|
+
ts.forEachChild(n, visit);
|
|
55
|
+
};
|
|
56
|
+
ts.forEachChild(bodyNode, visit);
|
|
57
|
+
|
|
58
|
+
const out: FetchCallInfo[] = [];
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
for (const [local, callNode] of invoked) {
|
|
61
|
+
const binding = imports.get(local);
|
|
62
|
+
if (!binding || binding.isTypeOnly || binding.kind === "namespace") continue;
|
|
63
|
+
// Default-imported api clients exist, but the named-export idiom is the one
|
|
64
|
+
// every RN app uses and the only one worth the resolution cost until a
|
|
65
|
+
// real default-exported client turns up to measure against.
|
|
66
|
+
if (binding.kind === "default") continue;
|
|
67
|
+
const target = resolver.resolve(binding.specifier, file);
|
|
68
|
+
if (!target) continue;
|
|
69
|
+
const wanted = binding.importedName ?? local;
|
|
70
|
+
const located = findExportedDecl(
|
|
71
|
+
target,
|
|
72
|
+
wanted,
|
|
73
|
+
cache,
|
|
74
|
+
resolver,
|
|
75
|
+
(sf, name) => new Map(exportedFunctions(sf)).get(name),
|
|
76
|
+
true,
|
|
77
|
+
);
|
|
78
|
+
if (!located) continue;
|
|
79
|
+
const endpoint = wrapperCallIn(located.decl, located.sf);
|
|
80
|
+
if (!endpoint) continue;
|
|
81
|
+
const key = `${endpoint.method} ${endpoint.template}`;
|
|
82
|
+
if (seen.has(key)) continue;
|
|
83
|
+
seen.add(key);
|
|
84
|
+
out.push({
|
|
85
|
+
urlTemplate: endpoint.template,
|
|
86
|
+
holes: endpoint.holes,
|
|
87
|
+
method: endpoint.method,
|
|
88
|
+
// The screen's own call site, not the api-module declaration: the endpoint
|
|
89
|
+
// is a fact about THIS component, and its position is where the component
|
|
90
|
+
// asks for it.
|
|
91
|
+
pos: posOf(callNode, sourceFile),
|
|
92
|
+
inEffect: isInsideEffect(callNode, bodyNode),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const EFFECT_HOOKS = new Set(["useEffect", "useLayoutEffect", "useFocusEffect"]);
|
|
99
|
+
|
|
100
|
+
function isInsideEffect(call: ts.Node, bodyNode: ts.Node): boolean {
|
|
101
|
+
let cur: ts.Node | undefined = call.parent;
|
|
102
|
+
while (cur && cur !== bodyNode) {
|
|
103
|
+
if (ts.isCallExpression(cur)) {
|
|
104
|
+
const name = hookCalleeName(cur.expression);
|
|
105
|
+
if (name && EFFECT_HOOKS.has(name)) return true;
|
|
106
|
+
}
|
|
107
|
+
cur = cur.parent;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface WrapperEndpoint {
|
|
113
|
+
template: string;
|
|
114
|
+
holes: string[];
|
|
115
|
+
method: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Drop everything from the first NON-path interpolation onward, keeping the
|
|
120
|
+
* base endpoint.
|
|
121
|
+
*
|
|
122
|
+
* A path parameter always follows a `/`: `/api/buildings/${id}` →
|
|
123
|
+
* `…/buildings/:id`. A query string built inline — `…/invoices${scope ?
|
|
124
|
+
* "?scope=mine" : ""}` — produces a hole glued straight onto the previous
|
|
125
|
+
* segment (`…/invoices:q`), which is not a path parameter and cannot be filled
|
|
126
|
+
* from params.json. The base path IS the load target: the query is an optional
|
|
127
|
+
* filter, and load-testing `…/invoices` is exactly right. So the template is
|
|
128
|
+
* truncated at that hole and the hole dropped.
|
|
129
|
+
*
|
|
130
|
+
* A query is never part of the load target, literal or interpolated: its
|
|
131
|
+
* values come from runtime state the pipe cannot carry. The template is cut at
|
|
132
|
+
* the first `?` on every path through this function — which is also what
|
|
133
|
+
* clears the edge the truncation alone left behind: a path parameter BEFORE an
|
|
134
|
+
* inline query (`/things/${id}?tab=${t}` → `/things/:id?tab=:t`) used to ship
|
|
135
|
+
* a dangling `?tab=` the app never requests, because the truncation cut after
|
|
136
|
+
* the `?` it had already passed.
|
|
137
|
+
*/
|
|
138
|
+
function pathOnly(template: string, holes: string[]): { template: string; holes: string[] } {
|
|
139
|
+
const stripQuery = (t: string): string => {
|
|
140
|
+
const at = t.indexOf("?");
|
|
141
|
+
return at < 0 ? t : t.slice(0, at);
|
|
142
|
+
};
|
|
143
|
+
const kept: string[] = [];
|
|
144
|
+
let cursor = 0;
|
|
145
|
+
for (const hole of holes) {
|
|
146
|
+
const at = template.indexOf(`:${hole}`, cursor);
|
|
147
|
+
if (at < 0) {
|
|
148
|
+
kept.push(hole);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (template[at - 1] !== "/") {
|
|
152
|
+
const base = stripQuery(template.slice(0, at)).replace(/\/$/, "");
|
|
153
|
+
return { template: base, holes: kept };
|
|
154
|
+
}
|
|
155
|
+
kept.push(hole);
|
|
156
|
+
cursor = at + hole.length + 1;
|
|
157
|
+
}
|
|
158
|
+
return { template: stripQuery(template), holes: kept };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The endpoint an api function declares: the first call in its body to a
|
|
163
|
+
* module-local fetch wrapper, with a literal/template path. undefined when the
|
|
164
|
+
* body calls no wrapper, or calls one with a non-literal path (a joined URL, a
|
|
165
|
+
* variable) — which the harvest cannot turn into a pipe honestly.
|
|
166
|
+
*/
|
|
167
|
+
function wrapperCallIn(fn: FnLike, sf: ts.SourceFile): WrapperEndpoint | undefined {
|
|
168
|
+
const wrappers = fetchWrappersOf(sf);
|
|
169
|
+
if (wrappers.size === 0) return undefined;
|
|
170
|
+
const body = fn.body;
|
|
171
|
+
if (!body) return undefined;
|
|
172
|
+
|
|
173
|
+
let found: WrapperEndpoint | undefined;
|
|
174
|
+
const visit = (n: ts.Node): void => {
|
|
175
|
+
if (found) return;
|
|
176
|
+
if (
|
|
177
|
+
ts.isCallExpression(n) &&
|
|
178
|
+
ts.isIdentifier(n.expression) &&
|
|
179
|
+
wrappers.has(n.expression.text)
|
|
180
|
+
) {
|
|
181
|
+
const urlArg = n.arguments[0];
|
|
182
|
+
const parsed = urlArg ? parseUrlArg(urlArg) : undefined;
|
|
183
|
+
if (parsed) {
|
|
184
|
+
const norm = pathOnly(parsed.template, parsed.holes);
|
|
185
|
+
found = { template: norm.template, holes: norm.holes, method: readMethod(n.arguments[1]) };
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
ts.forEachChild(n, visit);
|
|
190
|
+
};
|
|
191
|
+
visit(body);
|
|
192
|
+
return found;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
type FnLike =
|
|
196
|
+
| ts.FunctionDeclaration
|
|
197
|
+
| ts.ArrowFunction
|
|
198
|
+
| ts.FunctionExpression
|
|
199
|
+
| { body?: ts.Node };
|
|
200
|
+
|
|
201
|
+
const WRAPPER_CACHE = new WeakMap<ts.SourceFile, Set<string>>();
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The names of the module-local functions that ARE fetch wrappers: a function
|
|
205
|
+
* whose body performs a `fetch()` whose URL argument references one of the
|
|
206
|
+
* function's own parameters (`apiFetch(path) => fetch(\`${BASE}${path}\`)`).
|
|
207
|
+
*
|
|
208
|
+
* Structural, never by name: the point is to trust `apiFetch` because it wraps
|
|
209
|
+
* fetch, not because of what it is called — an app's client may be `request`,
|
|
210
|
+
* `http`, `call`. A function that takes a string and does something else is not
|
|
211
|
+
* a client, and its literal-string callers are not endpoints.
|
|
212
|
+
*/
|
|
213
|
+
function fetchWrappersOf(sf: ts.SourceFile): Set<string> {
|
|
214
|
+
const cached = WRAPPER_CACHE.get(sf);
|
|
215
|
+
if (cached) return cached;
|
|
216
|
+
const names = new Set<string>();
|
|
217
|
+
for (const [name, fn] of localFunctions(sf)) {
|
|
218
|
+
if (isFetchWrapper(fn)) names.add(name);
|
|
219
|
+
}
|
|
220
|
+
WRAPPER_CACHE.set(sf, names);
|
|
221
|
+
return names;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** name → function node for every top-level function-ish declaration, exported
|
|
225
|
+
* or not — the wrapper is almost always a NON-exported module-local. */
|
|
226
|
+
function localFunctions(sf: ts.SourceFile): Map<string, FnLike> {
|
|
227
|
+
const out = new Map<string, FnLike>();
|
|
228
|
+
for (const stmt of sf.statements) {
|
|
229
|
+
if (ts.isFunctionDeclaration(stmt) && stmt.name) {
|
|
230
|
+
out.set(stmt.name.text, stmt);
|
|
231
|
+
} else if (ts.isVariableStatement(stmt)) {
|
|
232
|
+
for (const d of stmt.declarationList.declarations) {
|
|
233
|
+
if (!ts.isIdentifier(d.name) || !d.initializer) continue;
|
|
234
|
+
if (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer)) {
|
|
235
|
+
out.set(d.name.text, d.initializer);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isFetchWrapper(fn: FnLike): boolean {
|
|
244
|
+
const params = paramNames(fn);
|
|
245
|
+
if (params.size === 0) return false;
|
|
246
|
+
const body = fn.body;
|
|
247
|
+
if (!body) return false;
|
|
248
|
+
let wraps = false;
|
|
249
|
+
const visit = (n: ts.Node): void => {
|
|
250
|
+
if (wraps) return;
|
|
251
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === "fetch") {
|
|
252
|
+
const urlArg = n.arguments[0];
|
|
253
|
+
if (urlArg && referencesAnyParam(urlArg, params)) wraps = true;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
ts.forEachChild(n, visit);
|
|
257
|
+
};
|
|
258
|
+
visit(body);
|
|
259
|
+
return wraps;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function paramNames(fn: FnLike): Set<string> {
|
|
263
|
+
const names = new Set<string>();
|
|
264
|
+
const params = (fn as ts.FunctionLikeDeclarationBase).parameters;
|
|
265
|
+
if (!params) return names;
|
|
266
|
+
for (const p of params) {
|
|
267
|
+
if (ts.isIdentifier(p.name)) names.add(p.name.text);
|
|
268
|
+
}
|
|
269
|
+
return names;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function referencesAnyParam(node: ts.Node, params: Set<string>): boolean {
|
|
273
|
+
let hit = false;
|
|
274
|
+
const visit = (n: ts.Node): void => {
|
|
275
|
+
if (hit) return;
|
|
276
|
+
if (ts.isIdentifier(n) && params.has(n.text)) {
|
|
277
|
+
hit = true;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
ts.forEachChild(n, visit);
|
|
281
|
+
};
|
|
282
|
+
visit(node);
|
|
283
|
+
return hit;
|
|
284
|
+
}
|