@lariocpt/flawless-react 0.0.0-stage → 0.3.1

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