@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.
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 +608 -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,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&apos;t` generates a
96
+ * getByText("couldn&apos;t") that can never match the DOM's "couldn't".
97
+ * (&amp; must be replaced last, or `&amp;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
+ }