@lariocpt/flawless-react 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +611 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- package/src/version.ts +12 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { flattenTrees } from "../scaffold-tests/registry.ts";
|
|
4
|
+
import type { ComponentTree } from "../types.ts";
|
|
5
|
+
import { isAuthLibSpecifier } from "./authShims.ts";
|
|
6
|
+
import { providerShimModules } from "./providerWraps.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Will this suite boot at all?
|
|
10
|
+
*
|
|
11
|
+
* Vitest resolves a spec's imports when it COLLECTS the file, before a single
|
|
12
|
+
* test runs, and a module it cannot resolve takes the whole file down with the
|
|
13
|
+
* message `Failed to resolve import "x"`. That failure is loud in the run log
|
|
14
|
+
* and absent from REPORT.md, so lightswind-ui-library's first run read as
|
|
15
|
+
* "401 files failed, no tests" with nothing explaining itself — while every one
|
|
16
|
+
* of those files imported `framer-motion`, a package the app declares nowhere
|
|
17
|
+
* and has nowhere on disk, because it expects its CONSUMERS to provide it.
|
|
18
|
+
*
|
|
19
|
+
* This module answers the question at generation time: for every bare package
|
|
20
|
+
* specifier the analysed sources import, is the package installed anywhere the
|
|
21
|
+
* suite will look for it? It is a diagnostic, not a gate — a suite can still be
|
|
22
|
+
* worth generating for the components that DO boot.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** One module that the analysed sources import and the suite cannot resolve. */
|
|
26
|
+
export interface UnresolvableImport {
|
|
27
|
+
/** Specifier as written in the source (`framer-motion`, `@scope/pkg/sub`). */
|
|
28
|
+
specifier: string;
|
|
29
|
+
/** The package whose presence was tested (`framer-motion`, `@scope/pkg`). */
|
|
30
|
+
pkg: string;
|
|
31
|
+
/** How many analysed components import it. */
|
|
32
|
+
count: number;
|
|
33
|
+
/** Up to this many component names, for the report's examples column. */
|
|
34
|
+
examples: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Vite owns these: loaders, not node resolution, so they are never a miss. */
|
|
38
|
+
const ASSET_SUFFIXES = [
|
|
39
|
+
".css",
|
|
40
|
+
".module.css",
|
|
41
|
+
".scss",
|
|
42
|
+
".sass",
|
|
43
|
+
".less",
|
|
44
|
+
".styl",
|
|
45
|
+
".svg",
|
|
46
|
+
".png",
|
|
47
|
+
".jpg",
|
|
48
|
+
".jpeg",
|
|
49
|
+
".gif",
|
|
50
|
+
".webp",
|
|
51
|
+
".avif",
|
|
52
|
+
".ico",
|
|
53
|
+
".bmp",
|
|
54
|
+
".mp3",
|
|
55
|
+
".mp4",
|
|
56
|
+
".webm",
|
|
57
|
+
".wav",
|
|
58
|
+
".ogg",
|
|
59
|
+
".mov",
|
|
60
|
+
".woff",
|
|
61
|
+
".woff2",
|
|
62
|
+
".ttf",
|
|
63
|
+
".eot",
|
|
64
|
+
".otf",
|
|
65
|
+
".md",
|
|
66
|
+
".mdx",
|
|
67
|
+
".txt",
|
|
68
|
+
".csv",
|
|
69
|
+
".wasm",
|
|
70
|
+
".graphql",
|
|
71
|
+
".gql",
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Specifier → package name, or undefined when the specifier is not a bare
|
|
76
|
+
* package reference at all: relative, absolute, a runtime scheme (`node:`,
|
|
77
|
+
* `bun:`, `virtual:`), or an asset vite resolves with a loader. `@scope/pkg/sub`
|
|
78
|
+
* answers `@scope/pkg`.
|
|
79
|
+
*/
|
|
80
|
+
export function packageNameOf(specifier: string): string | undefined {
|
|
81
|
+
if (!specifier) return undefined;
|
|
82
|
+
if (specifier.startsWith(".") || specifier.startsWith("/")) return undefined;
|
|
83
|
+
// `@/…` is the most common path alias in React, and a scope of nothing is not
|
|
84
|
+
// a package: npm requires a name after the `@`.
|
|
85
|
+
if (specifier.startsWith("@/")) return undefined;
|
|
86
|
+
if (/^[a-z]+:/.test(specifier) && !specifier.startsWith("@")) return undefined;
|
|
87
|
+
const bare = specifier.split(/[?#]/)[0] ?? specifier;
|
|
88
|
+
const lower = bare.toLowerCase();
|
|
89
|
+
if (ASSET_SUFFIXES.some((ext) => lower.endsWith(ext))) return undefined;
|
|
90
|
+
const parts = bare.split("/");
|
|
91
|
+
if (parts[0]?.startsWith("@")) return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined;
|
|
92
|
+
return parts[0];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Specifiers the suite already answers without the package being installed:
|
|
97
|
+
* the provider shims (canonical name and alias modules) and the auth stubs.
|
|
98
|
+
* `next/*`, `server-only` and `client-only` join because the specs `vi.mock`
|
|
99
|
+
* the framework's own namespace and every Next app resolves those through
|
|
100
|
+
* Next's bundler, not through a package on disk.
|
|
101
|
+
*/
|
|
102
|
+
function isStubsIt(specifier: string, pkg: string): boolean {
|
|
103
|
+
if (pkg === "server-only" || pkg === "client-only") return true;
|
|
104
|
+
if (specifier.startsWith("next/")) return true;
|
|
105
|
+
if (providerShimModules().includes(pkg)) return true;
|
|
106
|
+
return isAuthLibSpecifier(specifier);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Names of the packages in `<dir>/node_modules`, with `@scope/pkg` entries
|
|
111
|
+
* expanded (a scope directory is not a package). Cached per directory: a sweep
|
|
112
|
+
* walks the same two or three ancestors thousands of times.
|
|
113
|
+
*/
|
|
114
|
+
function installedNames(
|
|
115
|
+
dir: string,
|
|
116
|
+
cache: Map<string, Set<string> | undefined>,
|
|
117
|
+
): Set<string> | undefined {
|
|
118
|
+
const nm = path.join(dir, "node_modules");
|
|
119
|
+
const seen = cache.get(nm);
|
|
120
|
+
if (seen !== undefined) return seen;
|
|
121
|
+
let out: Set<string> | undefined;
|
|
122
|
+
try {
|
|
123
|
+
out = new Set<string>();
|
|
124
|
+
for (const entry of readdirSync(nm)) {
|
|
125
|
+
if (entry.startsWith(".")) continue;
|
|
126
|
+
// A scope directory is not a package: `@acme` answers to no import, and
|
|
127
|
+
// adding it would make every `@acme/*` specifier look installed.
|
|
128
|
+
if (entry.startsWith("@")) {
|
|
129
|
+
try {
|
|
130
|
+
for (const inner of readdirSync(path.join(nm, entry))) out.add(`${entry}/${inner}`);
|
|
131
|
+
} catch {
|
|
132
|
+
/* unreadable scope: nothing to add */
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
out.add(entry);
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
out = undefined;
|
|
140
|
+
}
|
|
141
|
+
cache.set(nm, out);
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Is this specifier present in the node_modules of `dir` or any ancestor?
|
|
147
|
+
* Checked by prefix so a subpath (`framer-motion/dom`) counts as its package:
|
|
148
|
+
* exports-map and file-level correctness is not what this reports, and a false
|
|
149
|
+
* "missing" is worse than a false "present".
|
|
150
|
+
*/
|
|
151
|
+
function isInstalled(
|
|
152
|
+
pkg: string,
|
|
153
|
+
fromDirs: string[],
|
|
154
|
+
cache: Map<string, Set<string> | undefined>,
|
|
155
|
+
): boolean {
|
|
156
|
+
for (const start of fromDirs) {
|
|
157
|
+
let dir = start;
|
|
158
|
+
for (let hop = 0; hop < 12; hop++) {
|
|
159
|
+
const names = installedNames(dir, cache);
|
|
160
|
+
if (names) {
|
|
161
|
+
const parts = pkg.split("/");
|
|
162
|
+
for (let n = parts.length; n > 0; n--) {
|
|
163
|
+
if (names.has(parts.slice(0, n).join("/"))) return true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const up = path.dirname(dir);
|
|
167
|
+
if (up === dir) break;
|
|
168
|
+
dir = up;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Whether a component gets a spec of its own.
|
|
176
|
+
*
|
|
177
|
+
* `exportKind: "none"` is a same-file secondary — the module never exports it, so
|
|
178
|
+
* no spec can import it and the scaffolder writes none. TWO places read that fact
|
|
179
|
+
* through this one predicate: the scaffolder's spec targets and
|
|
180
|
+
* `findUnresolvableImports` below. They must not disagree, because the warning is
|
|
181
|
+
* a promise about a spec — naming an unresolvable import in a component that gets
|
|
182
|
+
* no spec promises a collect failure that cannot happen. (Witness,
|
|
183
|
+
* react-router-devtools: the run warned `clsx` for the unexported `Icon` in
|
|
184
|
+
* `plugins/icon-library/icon-library.tsx`, whose file the host cannot import
|
|
185
|
+
* either — and the plane has no clsx failure, because there is no clsx-importing
|
|
186
|
+
* spec to fail.)
|
|
187
|
+
*/
|
|
188
|
+
export function hasSpec(info: { exportKind?: "default" | "named" | "none" }): boolean {
|
|
189
|
+
return info.exportKind !== "none";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The bare packages the components that GET A SPEC import, installed nowhere the
|
|
194
|
+
* suite will resolve them.
|
|
195
|
+
*
|
|
196
|
+
* Resolution is per IMPORTER, because that is what vite does: the walk-up
|
|
197
|
+
* starts at the file holding the import, so a package installed by a nested
|
|
198
|
+
* package is found by that package's components and missed by everyone else —
|
|
199
|
+
* whose specs really do fail, which is exactly what this reports. `extraDirs`
|
|
200
|
+
* adds roots that are not source directories: the suite directory (its own
|
|
201
|
+
* node_modules holds the test stack) and the project root.
|
|
202
|
+
*
|
|
203
|
+
* Returns nothing when NO node_modules tree is visible from any of those
|
|
204
|
+
* directories. A Yarn-PnP or never-installed checkout cannot be judged by
|
|
205
|
+
* looking for a directory layout it does not use, and a report that called
|
|
206
|
+
* every import missing would be a lie with a straight face.
|
|
207
|
+
*/
|
|
208
|
+
export function findUnresolvableImports(
|
|
209
|
+
trees: ComponentTree[],
|
|
210
|
+
extraDirs: string[] = [],
|
|
211
|
+
): UnresolvableImport[] {
|
|
212
|
+
const cache = new Map<string, Set<string> | undefined>();
|
|
213
|
+
// Bail condition first: is there anything to judge against at all?
|
|
214
|
+
const sourceDirs = flattenTrees(trees).map((c) => path.dirname(c.info.filePath));
|
|
215
|
+
const anyTree = [...sourceDirs, ...extraDirs].some((dir) => {
|
|
216
|
+
let at = dir;
|
|
217
|
+
for (let hop = 0; hop < 12; hop++) {
|
|
218
|
+
if (installedNames(at, cache) !== undefined) return true;
|
|
219
|
+
const up = path.dirname(at);
|
|
220
|
+
if (up === at) break;
|
|
221
|
+
at = up;
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
});
|
|
225
|
+
if (!anyTree) return [];
|
|
226
|
+
|
|
227
|
+
const bySpec = new Map<string, { pkg: string; names: Set<string> }>();
|
|
228
|
+
for (const { info } of flattenTrees(trees)) {
|
|
229
|
+
// Only what will have a spec: see hasSpec. An unexported component's import
|
|
230
|
+
// is a fact about the app, not about this run — no spec imports it, so no
|
|
231
|
+
// spec fails collecting it.
|
|
232
|
+
if (!hasSpec(info)) continue;
|
|
233
|
+
const fromDirs = [path.dirname(info.filePath), ...extraDirs];
|
|
234
|
+
for (const mi of info.moduleImports ?? []) {
|
|
235
|
+
// `import type` is erased before vite sees it; a side-effect import has
|
|
236
|
+
// no binding to lose, and neither is collected here anyway.
|
|
237
|
+
if (mi.isTypeOnly) continue;
|
|
238
|
+
// The analyser already resolved it — through a tsconfig `paths` alias or a
|
|
239
|
+
// relative path — so the file is on disk and vite will find it too. This
|
|
240
|
+
// is what keeps `@/components/ui` off the list in the app that aliases it.
|
|
241
|
+
if (mi.resolvedPath) continue;
|
|
242
|
+
const pkg = packageNameOf(mi.specifier);
|
|
243
|
+
if (!pkg || isStubsIt(mi.specifier, pkg)) continue;
|
|
244
|
+
if (isInstalled(pkg, fromDirs, cache)) continue;
|
|
245
|
+
const row = bySpec.get(mi.specifier) ?? { pkg, names: new Set<string>() };
|
|
246
|
+
row.names.add(info.componentName);
|
|
247
|
+
bySpec.set(mi.specifier, row);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const out: UnresolvableImport[] = [];
|
|
252
|
+
for (const [specifier, row] of bySpec) {
|
|
253
|
+
const examples = [...row.names].sort().slice(0, 3);
|
|
254
|
+
out.push({ specifier, pkg: row.pkg, count: row.names.size, examples });
|
|
255
|
+
}
|
|
256
|
+
return out.sort((a, b) => b.count - a.count || (a.specifier < b.specifier ? -1 : 1));
|
|
257
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { DATE_PROP_SENTINEL, type DataDepInfo, type MockField, type MockShape } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The bridge: what a rendered CHILD needs on a prop flows BACK into what
|
|
6
|
+
* the parent's fixtures carry.
|
|
7
|
+
*
|
|
8
|
+
* A spec renders its children for real, and the child reads fields nobody
|
|
9
|
+
* upstream thought to fill. Drop passes `date={drop.createdAt}` to a
|
|
10
|
+
* RelativeTime that calls date.getTime() — the chain read on drop stops at
|
|
11
|
+
* `.createdAt`, the dep fixture has no idea the value is a Date, and the
|
|
12
|
+
* mount dies inside the child with `undefined is not an object`, or worse a
|
|
13
|
+
* plausible ISO STRING where `toLocaleTimeString` was expected. The child
|
|
14
|
+
* knew — its own typed prop resolved to a Date stand-in — but that knowledge
|
|
15
|
+
* never crossed the JSX boundary. It does now, two ways:
|
|
16
|
+
*
|
|
17
|
+
* bridgePropValues — attr sites rooted at a PROP of the unit, applied by
|
|
18
|
+
* synthesiseDefaults: the unit's DEFAULTS gain nested
|
|
19
|
+
* values (drop.createdAt becomes a live Date).
|
|
20
|
+
* bridgeDataShapes — attr sites rooted at a data dep or the element of
|
|
21
|
+
* `dep.map(item => …)`: the mock fixture grows the
|
|
22
|
+
* fields the child will read off what it is handed.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** tag → attr → the value that child's own defaults put on that prop. */
|
|
26
|
+
export type ChildExpected = Record<string, Record<string, unknown>>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Propagate a child's expected prop value into an unresolved prop's
|
|
30
|
+
* stand-in. `member` marks a `date={drop.createdAt}` site (the value is one
|
|
31
|
+
* hop INTO the prop); without it the whole prop flows in (`drop={item}`).
|
|
32
|
+
* Returns a fresh value when anything changed, undefined when not.
|
|
33
|
+
*/
|
|
34
|
+
export function bridgePropValues(
|
|
35
|
+
current: unknown,
|
|
36
|
+
member: string | undefined,
|
|
37
|
+
expected: unknown,
|
|
38
|
+
): unknown {
|
|
39
|
+
if (expected === undefined) return undefined;
|
|
40
|
+
if (member === undefined) return richer(expected, current) ? expected : undefined;
|
|
41
|
+
const base =
|
|
42
|
+
current !== null && typeof current === "object" && !Array.isArray(current)
|
|
43
|
+
? (current as Record<string, unknown>)
|
|
44
|
+
: {};
|
|
45
|
+
if (!richer(expected, base[member])) return undefined;
|
|
46
|
+
return { ...base, [member]: expected };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Would `want` survive more reads than `have`? The sentinel Date ranks as Date. */
|
|
50
|
+
function richer(want: unknown, have: unknown): boolean {
|
|
51
|
+
return rank(want) > rank(have);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rank(v: unknown): number {
|
|
55
|
+
if (v === DATE_PROP_SENTINEL) return 3;
|
|
56
|
+
if (v === null || typeof v !== "object") return typeof v === "string" ? 1 : 0;
|
|
57
|
+
if (Array.isArray(v)) return 2;
|
|
58
|
+
return 4;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A child's expected VALUE as a fixture SHAPE. Booleans are skipped: no
|
|
63
|
+
* boolean kind exists, and injecting a truthy stand-in could flip a branch
|
|
64
|
+
* every later assertion reads.
|
|
65
|
+
*
|
|
66
|
+
* Strings split in two. A synth-seeded placeholder ("flawless-handle", "")
|
|
67
|
+
* carries no evidence about WHICH string fits, so it merges as the generic
|
|
68
|
+
* string kind. A string that survived synthesis VERBATIM is type evidence —
|
|
69
|
+
* an arm of a literal union ('like' | 'repost' | …) — and the mock must
|
|
70
|
+
* emit exactly that: NotificationList indexes ICONS by n.kind, and a
|
|
71
|
+
* plausible-but-wrong 'flawless-kind' looks up undefined and crashes the
|
|
72
|
+
* render. Verbatim strings ride as the field's `equals` value.
|
|
73
|
+
*/
|
|
74
|
+
interface Wanted {
|
|
75
|
+
shape: MockShape;
|
|
76
|
+
equals?: unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function valueToShape(v: unknown): Wanted | undefined {
|
|
80
|
+
if (v === DATE_PROP_SENTINEL) return { shape: { kind: "date" } };
|
|
81
|
+
if (typeof v === "number") return { shape: { kind: "number" } };
|
|
82
|
+
if (typeof v === "string") {
|
|
83
|
+
if (v === "" || v.startsWith("flawless-")) return { shape: { kind: "string" } };
|
|
84
|
+
return { shape: { kind: "string" }, equals: v };
|
|
85
|
+
}
|
|
86
|
+
// A MISSING element means "no evidence" and mockValues renders []; an
|
|
87
|
+
// UNKNOWN element means "render {}" — Replies learned the difference the
|
|
88
|
+
// hard way when [] became [{}] and started mounting a crashing child.
|
|
89
|
+
if (Array.isArray(v)) return { shape: { kind: "array" } };
|
|
90
|
+
if (v !== null && typeof v === "object") {
|
|
91
|
+
const fields: MockField[] = [];
|
|
92
|
+
for (const [name, fv] of Object.entries(v as Record<string, unknown>)) {
|
|
93
|
+
const w = valueToShape(fv);
|
|
94
|
+
if (!w) continue;
|
|
95
|
+
fields.push({
|
|
96
|
+
name,
|
|
97
|
+
shape: w.shape,
|
|
98
|
+
rendersAsText: false,
|
|
99
|
+
...(w.equals !== undefined ? { equals: w.equals as MockField["equals"] } : {}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return { shape: { kind: "object", fields } };
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Fold `wanted` into `existing`; existing evidence wins unless it is vaguer. */
|
|
108
|
+
function mergeShape(existing: MockShape | undefined, wanted: MockShape): MockShape {
|
|
109
|
+
// Unknown carries no evidence; it may never overwrite (or replace) a shape
|
|
110
|
+
// that knows more.
|
|
111
|
+
if (wanted.kind === "unknown") return existing ?? wanted;
|
|
112
|
+
if (!existing) return wanted;
|
|
113
|
+
if (existing.kind === "unknown" || existing.kind === "truthy-object") return wanted;
|
|
114
|
+
if (existing.kind === "array") {
|
|
115
|
+
if (wanted.kind === "array" && wanted.element && !existing.element)
|
|
116
|
+
return { kind: "array", element: wanted.element };
|
|
117
|
+
return existing;
|
|
118
|
+
}
|
|
119
|
+
if (existing.kind === "object" && wanted.kind === "object") {
|
|
120
|
+
const fields = [...existing.fields];
|
|
121
|
+
for (const w of wanted.fields) {
|
|
122
|
+
const at = fields.findIndex((f) => f.name === w.name);
|
|
123
|
+
const prev = at === -1 ? undefined : fields[at];
|
|
124
|
+
if (prev === undefined) {
|
|
125
|
+
fields.push(w);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const merged = { ...prev, shape: mergeShape(prev.shape, w.shape) };
|
|
129
|
+
// A literal the parent never knew about is pure gain; a literal the
|
|
130
|
+
// parent inferred from a comparison site (`kind === "repost"`) stays.
|
|
131
|
+
if (merged.equals === undefined && w.equals !== undefined) merged.equals = w.equals;
|
|
132
|
+
fields[at] = merged;
|
|
133
|
+
}
|
|
134
|
+
return { kind: "object", fields };
|
|
135
|
+
}
|
|
136
|
+
// A child that calls Date methods outranks a rendered ISO string.
|
|
137
|
+
if (wanted.kind === "date" && (existing.kind === "string" || existing.kind === "date-string"))
|
|
138
|
+
return wanted;
|
|
139
|
+
return existing;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const ELEM_METHODS = new Set(["map", "filter", "find", "some", "every", "forEach", "flatMap"]);
|
|
143
|
+
|
|
144
|
+
interface Site {
|
|
145
|
+
tag: string;
|
|
146
|
+
attr: string;
|
|
147
|
+
root: string;
|
|
148
|
+
path: string[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Fill a data dep's fixture with what the rendered children read off the
|
|
153
|
+
* values handed to them. `items` reaches `<Drop drop={item.drop}>` through
|
|
154
|
+
* `items.map(item => …)`: the Drop child's own defaults say drop carries a
|
|
155
|
+
* body and a Date createdAt, so the array element's `drop` field grows them.
|
|
156
|
+
*/
|
|
157
|
+
export function bridgeDataShapes(
|
|
158
|
+
bodyNode: ts.Node,
|
|
159
|
+
dataDeps: DataDepInfo[],
|
|
160
|
+
childExpected: ChildExpected,
|
|
161
|
+
): void {
|
|
162
|
+
const depVars = new Set(
|
|
163
|
+
dataDeps.map((d) => d.varName).filter((v): v is string => v !== undefined),
|
|
164
|
+
);
|
|
165
|
+
if (depVars.size === 0) return;
|
|
166
|
+
|
|
167
|
+
// `items.map(item => …)` binds item to the ELEMENT of items.
|
|
168
|
+
const elemAliases = new Map<string, string>();
|
|
169
|
+
const collectAliases = (n: ts.Node): void => {
|
|
170
|
+
if (
|
|
171
|
+
ts.isCallExpression(n) &&
|
|
172
|
+
ts.isPropertyAccessExpression(n.expression) &&
|
|
173
|
+
ts.isIdentifier(n.expression.expression) &&
|
|
174
|
+
depVars.has(n.expression.expression.text) &&
|
|
175
|
+
ELEM_METHODS.has(n.expression.name.text)
|
|
176
|
+
) {
|
|
177
|
+
const cb = n.arguments[0];
|
|
178
|
+
const param =
|
|
179
|
+
cb && (ts.isArrowFunction(cb) || ts.isFunctionExpression(cb))
|
|
180
|
+
? cb.parameters[0]
|
|
181
|
+
: undefined;
|
|
182
|
+
if (param && ts.isIdentifier(param.name)) {
|
|
183
|
+
elemAliases.set(param.name.text, n.expression.expression.text);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
ts.forEachChild(n, collectAliases);
|
|
187
|
+
};
|
|
188
|
+
ts.forEachChild(bodyNode, collectAliases);
|
|
189
|
+
|
|
190
|
+
// JSX attr sites whose value is rooted at a dep var or an element alias.
|
|
191
|
+
const sites: Site[] = [];
|
|
192
|
+
const rootedAt = (expr: ts.Expression): { root: string; path: string[] } | undefined => {
|
|
193
|
+
let n: ts.Node = expr;
|
|
194
|
+
const path: string[] = [];
|
|
195
|
+
while (ts.isPropertyAccessExpression(n)) {
|
|
196
|
+
path.unshift(n.name.text);
|
|
197
|
+
n = n.expression;
|
|
198
|
+
}
|
|
199
|
+
if (ts.isIdentifier(n) && (depVars.has(n.text) || elemAliases.has(n.text))) {
|
|
200
|
+
return { root: n.text, path };
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
};
|
|
204
|
+
const collectSites = (n: ts.Node): void => {
|
|
205
|
+
if (ts.isJsxAttribute(n)) {
|
|
206
|
+
const init = n.initializer;
|
|
207
|
+
const expr = init ? (ts.isJsxExpression(init) ? init.expression : init) : undefined;
|
|
208
|
+
const tag = owningTag(n);
|
|
209
|
+
const attr = ts.isIdentifier(n.name) ? n.name.text : n.name.getText();
|
|
210
|
+
if (expr && tag) {
|
|
211
|
+
// Ternary/logical branches all reach the child — take every leaf.
|
|
212
|
+
const queue: ts.Node[] = [expr];
|
|
213
|
+
while (queue.length > 0) {
|
|
214
|
+
const e = queue.shift() as ts.Node;
|
|
215
|
+
if (
|
|
216
|
+
ts.isConditionalExpression(e) ||
|
|
217
|
+
ts.isBinaryExpression(e) ||
|
|
218
|
+
ts.isParenthesizedExpression(e) ||
|
|
219
|
+
ts.isJsxExpression(e)
|
|
220
|
+
) {
|
|
221
|
+
e.getChildren().forEach((c) => queue.push(c));
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const found = rootedAt(e as ts.Expression);
|
|
225
|
+
if (found) sites.push({ tag, attr, root: found.root, path: found.path });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
ts.forEachChild(n, collectSites);
|
|
230
|
+
};
|
|
231
|
+
collectSites(bodyNode);
|
|
232
|
+
if (sites.length === 0) return;
|
|
233
|
+
|
|
234
|
+
for (const site of sites) {
|
|
235
|
+
const wanted = valueToShape(childExpected[site.tag]?.[site.attr]);
|
|
236
|
+
if (!wanted) continue;
|
|
237
|
+
const aliasOf = elemAliases.get(site.root);
|
|
238
|
+
for (const dep of dataDeps) {
|
|
239
|
+
const isElem = aliasOf !== undefined && dep.varName === aliasOf;
|
|
240
|
+
const isVar = dep.varName === site.root;
|
|
241
|
+
if (!isVar && !isElem) continue;
|
|
242
|
+
if (isElem) {
|
|
243
|
+
// A `.map()` site writes the ELEMENT, so only an array dep can take it.
|
|
244
|
+
if (dep.shape.kind !== "array") continue;
|
|
245
|
+
const element = applyPath(dep.shape.element ?? { kind: "unknown" }, wanted, site.path);
|
|
246
|
+
dep.shape = { kind: "array", element };
|
|
247
|
+
} else {
|
|
248
|
+
dep.shape = applyPath(dep.shape, wanted, site.path);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Merge `wanted` at `path` below `root`, rebuilding objects on the way so a
|
|
256
|
+
* shape shared between two deps cannot leak. A path hop that does not exist
|
|
257
|
+
* yet grows an object field; the merge lands at the end of the path.
|
|
258
|
+
*/
|
|
259
|
+
function applyPath(root: MockShape, wanted: Wanted, path: string[]): MockShape {
|
|
260
|
+
if (path.length === 0) return mergeShape(root, wanted.shape);
|
|
261
|
+
if (root.kind !== "object") {
|
|
262
|
+
// The read went THROUGH this hop, so it has to be an object whatever
|
|
263
|
+
// the body inferred here — unless what stands here already answers it.
|
|
264
|
+
if (root.kind !== "unknown" && root.kind !== "truthy-object") return root;
|
|
265
|
+
root = { kind: "object", fields: [] };
|
|
266
|
+
}
|
|
267
|
+
// `path.length === 0` returned above, so the hop exists. Named because the
|
|
268
|
+
// indexed reads below need `string`, not `string | undefined`.
|
|
269
|
+
const head = path[0];
|
|
270
|
+
if (head === undefined) return root;
|
|
271
|
+
const tail = path.slice(1);
|
|
272
|
+
const fields = [...root.fields];
|
|
273
|
+
const at = fields.findIndex((f) => f.name === head);
|
|
274
|
+
const prev = at === -1 ? undefined : fields[at];
|
|
275
|
+
// A literal rides on the FIELD standing at the END of the path.
|
|
276
|
+
const atEnd = tail.length === 0 && wanted.equals !== undefined;
|
|
277
|
+
if (prev === undefined) {
|
|
278
|
+
const made = applyPath({ kind: "unknown" }, wanted, tail);
|
|
279
|
+
const field: MockField = { name: head, shape: made, rendersAsText: false };
|
|
280
|
+
if (atEnd) field.equals = wanted.equals as MockField["equals"];
|
|
281
|
+
fields.push(field);
|
|
282
|
+
} else {
|
|
283
|
+
const merged = { ...prev, shape: applyPath(prev.shape, wanted, tail) };
|
|
284
|
+
if (atEnd && merged.equals === undefined) merged.equals = wanted.equals as MockField["equals"];
|
|
285
|
+
fields[at] = merged;
|
|
286
|
+
}
|
|
287
|
+
return { kind: "object", fields };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The JSX element an attribute belongs to — its tag text, for child lookups. */
|
|
291
|
+
function owningTag(n: ts.Node): string | undefined {
|
|
292
|
+
let cur: ts.Node = n.parent;
|
|
293
|
+
while (
|
|
294
|
+
cur &&
|
|
295
|
+
!(ts.isJsxOpeningElement(cur) || ts.isJsxSelfClosingElement(cur) || ts.isJsxElement(cur))
|
|
296
|
+
) {
|
|
297
|
+
cur = cur.parent;
|
|
298
|
+
}
|
|
299
|
+
if (!cur) return undefined;
|
|
300
|
+
if (ts.isJsxElement(cur)) cur = cur.openingElement;
|
|
301
|
+
return ts.isJsxOpeningLikeElement(cur) ? cur.tagName.getText() : undefined;
|
|
302
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True when the component hands its `children` prop to an element that only
|
|
5
|
+
* accepts ELEMENT children: an `asChild` element (Radix Slot semantics — the
|
|
6
|
+
* element is replaced by its child, and Slot clones an element, never a text
|
|
7
|
+
* node) or a bare `<Slot>` / `<Slottable>`.
|
|
8
|
+
*
|
|
9
|
+
* The app always passes a real element to these, but a unit fixture passes
|
|
10
|
+
* plain text, and every such mount dies at render with
|
|
11
|
+
* "Primitive.button failed to slot onto its children. Expected a single React
|
|
12
|
+
* element child or Slottable" (miro-clone: Hint, Actions, ConfirmModal —
|
|
13
|
+
* each one a Trigger-wrap). The flag makes the unit template pass a
|
|
14
|
+
* <span>{CHILD_TEXT}</span> instead of the bare string — which renders
|
|
15
|
+
* identically through every non-slot children site too.
|
|
16
|
+
*/
|
|
17
|
+
export function childrenIntoSlot(bodyNode: ts.Node): boolean {
|
|
18
|
+
let found = false;
|
|
19
|
+
const walk = (n: ts.Node): void => {
|
|
20
|
+
if (found) return;
|
|
21
|
+
if (ts.isJsxElement(n)) {
|
|
22
|
+
const opening = n.openingElement;
|
|
23
|
+
const tag = plainTagName(opening);
|
|
24
|
+
// Qualified tags (<Radix.Slot>) are skipped: the local name is
|
|
25
|
+
// unknowable, and asChild detection does not need the name at all.
|
|
26
|
+
const isSlotEl = tag === "Slot" || tag === "Slottable";
|
|
27
|
+
const asChild = opening.attributes.properties.some(isAsChildAttr);
|
|
28
|
+
if ((isSlotEl || asChild) && n.children.some(isChildrenExpr)) {
|
|
29
|
+
found = true;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
ts.forEachChild(n, walk);
|
|
34
|
+
};
|
|
35
|
+
walk(bodyNode);
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The JSX children slot holding exactly the `{children}` identifier expression. */
|
|
40
|
+
function isChildrenExpr(n: ts.Node): boolean {
|
|
41
|
+
return (
|
|
42
|
+
ts.isJsxExpression(n) &&
|
|
43
|
+
n.expression !== undefined &&
|
|
44
|
+
ts.isIdentifier(n.expression) &&
|
|
45
|
+
n.expression.text === "children"
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `asChild`, `asChild={true}`, `asChild={cond}` — everything except an explicit false. */
|
|
50
|
+
function isAsChildAttr(p: ts.JsxAttributeLike): boolean {
|
|
51
|
+
if (!ts.isJsxAttribute(p) || !ts.isIdentifier(p.name) || p.name.text !== "asChild") return false;
|
|
52
|
+
const init = p.initializer;
|
|
53
|
+
if (init === undefined) return true; // bare `asChild`
|
|
54
|
+
if (ts.isStringLiteral(init)) return init.text !== "false";
|
|
55
|
+
if (ts.isJsxExpression(init) && init.expression) {
|
|
56
|
+
const e = init.expression;
|
|
57
|
+
if (e.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The JSX opening tag as a plain identifier, if it is one. */
|
|
64
|
+
function plainTagName(opening: ts.JsxOpeningElement): string | undefined {
|
|
65
|
+
return ts.isIdentifier(opening.tagName) ? opening.tagName.text : undefined;
|
|
66
|
+
}
|