@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,152 @@
1
+ import type { ClickHandler, ComponentInfo, HandlerEffect } from "../types.ts";
2
+
3
+ /**
4
+ * What a spec may assert after dispatching an event — and, where the answer is
5
+ * "nothing", why.
6
+ *
7
+ * It replaces a floor assertion that was vacuous or wrong depending on
8
+ * configuration:
9
+ *
10
+ * expect(container.firstElementChild).not.toBeNull(); // DOM
11
+ * expect(screen.toJSON()).not.toBeNull(); // native
12
+ *
13
+ * On the dogfood app that line is answered by something the component never
14
+ * rendered, so it cannot fail — 30 of 30 spec files carrying it. Without that,
15
+ * and on the whole native plane, the same line goes RED on a component that
16
+ * legitimately unmounts. Executed both ways before this was written.
17
+ *
18
+ * The MECHANISM is worth stating precisely, because a first reading of it here
19
+ * was wrong. The derived provider renders no wrapper element of its own — it is
20
+ * context providers all the way down. What answers the assertion is next-themes'
21
+ * ThemeProvider injecting a <script> tag, which `container.firstElementChild`
22
+ * then finds. Verified by rendering a component whose whole body is
23
+ * `return null` under that provider: the emitted assertion passes.
24
+ *
25
+ * So the floor was never reliably one thing or the other. Whether it was vacuous
26
+ * or a false red depended on whether some provider in the tree happened to emit
27
+ * a DOM node — which is exactly the kind of accidental dependence an assertion
28
+ * must not have, and a stronger reason to remove it than either half alone.
29
+ *
30
+ * Four independent designs for a smarter survival check were proposed and every
31
+ * one was broken by an adversarial reviewer, in both directions. The surviving
32
+ * answer is not a better floor: it is to assert a boundary the handler provably
33
+ * crosses — a call into a module the spec mocks, or a router navigation — and
34
+ * to say plainly when there is none.
35
+ *
36
+ * Plane-agnostic on purpose. The DOM and native emitters have drifted before
37
+ * (`importedNames`, `hints.disabled`), and a shared decision is the cheapest way
38
+ * to stop them drifting again.
39
+ */
40
+ export type PostDispatchClaim =
41
+ | { kind: "prop-spy"; propName: string }
42
+ | { kind: "module-spy"; binding: string }
43
+ | { kind: "router"; method: "push" | "replace" | "navigate" | "back"; target?: string }
44
+ | { kind: "canary"; reason: NoClaimReason }
45
+ | { kind: "skip"; reason: NoClaimReason };
46
+
47
+ /**
48
+ * Why nothing precise can be asserted. Each maps to one sentence in the emitted
49
+ * TODO, so a reader learns what would make the test real rather than that it is
50
+ * not one.
51
+ */
52
+ export type NoClaimReason =
53
+ | "opaque-callback"
54
+ | "foreign-setter"
55
+ | "guarded-call"
56
+ | "disabled-control"
57
+ | "undecidable-render";
58
+
59
+ export function describeNoClaim(reason: NoClaimReason): string {
60
+ switch (reason) {
61
+ case "opaque-callback":
62
+ return "the handler crosses no boundary this spec controls";
63
+ case "foreign-setter":
64
+ return "the setter comes from a hook this spec does not control";
65
+ case "guarded-call":
66
+ return "the work is behind a guard that returns first at these props";
67
+ case "disabled-control":
68
+ return "its control is disabled at these props";
69
+ case "undecidable-render":
70
+ return "render() branches on a condition this analysis could not decide, so nothing is known to be on screen to press";
71
+ }
72
+ }
73
+
74
+ /** What the caller knows about the spec it is writing. */
75
+ export interface PostDispatchContext {
76
+ /**
77
+ * Local binding name → true when the module it came from is mocked by THIS
78
+ * spec. Keyed by binding because that is what the handler names; the caller
79
+ * intersects specifiers itself, since only it knows the spec's directory.
80
+ */
81
+ mockedBindings: Set<string>;
82
+ /** The router is mocked and its calls are observable. */
83
+ routerMocked: boolean;
84
+ }
85
+
86
+ /**
87
+ * The ladder, first match wins.
88
+ *
89
+ * Order is the point: a prop spy is the most precise claim available and the
90
+ * cheapest to verify, a module spy and a router assertion are provable
91
+ * crossings, and everything after them is an admission.
92
+ */
93
+ export function decidePostDispatch(
94
+ info: ComponentInfo,
95
+ click: ClickHandler,
96
+ ctx: PostDispatchContext,
97
+ ): PostDispatchClaim {
98
+ // 0b — nothing is known to be on screen at all.
99
+ if (info.renderUndecidable === true) {
100
+ return { kind: "skip", reason: "undecidable-render" };
101
+ }
102
+ // 0c — user-event and RNTL both refuse to fire on a disabled control, so any
103
+ // claim would be about a press that never happened. `"conditional"` means the
104
+ // expression did not reduce, which is not permission to click.
105
+ if (click.hints.disabled !== undefined) {
106
+ return { kind: "skip", reason: "disabled-control" };
107
+ }
108
+ // 1 — a handler bound to a prop: pass a spy, assert it ran.
109
+ if (click.binding.kind === "prop") {
110
+ return { kind: "prop-spy", propName: click.binding.propName };
111
+ }
112
+
113
+ const effects = click.effects ?? [];
114
+ const dominated = effects.filter((e) => e.dominated);
115
+
116
+ // 2 — a call into a module this spec mocks.
117
+ const call = dominated.find(
118
+ (e): e is Extract<HandlerEffect, { kind: "call" }> =>
119
+ e.kind === "call" && ctx.mockedBindings.has(e.root),
120
+ );
121
+ if (call) return { kind: "module-spy", binding: call.root };
122
+
123
+ // 3 — a router navigation.
124
+ const nav = dominated.find(
125
+ (e): e is Extract<HandlerEffect, { kind: "router" }> => e.kind === "router",
126
+ );
127
+ if (nav && ctx.routerMocked) {
128
+ return { kind: "router", method: nav.method, ...(nav.target ? { target: nav.target } : {}) };
129
+ }
130
+
131
+ // 5 — drive it and assert only that nothing escaped, naming why that is all.
132
+ return { kind: "canary", reason: whyNothing(effects, ctx) };
133
+ }
134
+
135
+ /**
136
+ * The most specific true reason, not the first one that fits.
137
+ *
138
+ * A reader who is told "no boundary" when the truth is "the call is behind a
139
+ * guard" goes looking in the wrong place.
140
+ */
141
+ function whyNothing(effects: HandlerEffect[], ctx: PostDispatchContext): NoClaimReason {
142
+ const undominated = effects.filter((e) => !e.dominated);
143
+ if (
144
+ undominated.some(
145
+ (e) => (e.kind === "call" && ctx.mockedBindings.has(e.root)) || e.kind === "router",
146
+ )
147
+ ) {
148
+ return "guarded-call";
149
+ }
150
+ if (effects.some((e) => e.kind === "setState")) return "foreign-setter";
151
+ return "opaque-callback";
152
+ }
@@ -0,0 +1,443 @@
1
+ import { bridgePropValues } from "../analyse/childBridge.ts";
2
+ import {
3
+ type ComponentInfo,
4
+ type ComponentTree,
5
+ DATE_PROP_ISO,
6
+ DATE_PROP_SENTINEL,
7
+ type PropsShape,
8
+ type PropValue,
9
+ } from "../types.ts";
10
+
11
+ /**
12
+ * The one place that decides what a component is CALLED and whether it can be
13
+ * mounted, shared by both scaffolders.
14
+ *
15
+ * They previously kept separate copies, and the copies disagreed. The unit
16
+ * scaffolder deduped on filePath::componentName and allocated unique spec names;
17
+ * the perf host deduped on filePath alone — silently dropping every sibling
18
+ * component in a multi-component file, which in the dogfood app is 84 of 181 —
19
+ * and keyed its REGISTRY on the bare component name, so two components called
20
+ * CommentForm produced duplicate import identifiers that would not compile.
21
+ *
22
+ * INVARIANT, and it is easy to break: allocate names over the FULL flattened
23
+ * list and filter afterwards. Filtering first makes the two scaffolders assign
24
+ * different names to the same component, and the browser specs — which look
25
+ * components up in the perf host's registry by name — stop finding them.
26
+ */
27
+
28
+ export interface FlatComponent {
29
+ info: ComponentInfo;
30
+ /** Component name of the nearest parent in the tree (undefined for roots). */
31
+ parentName: string | undefined;
32
+ }
33
+
34
+ /** Dedupe on filePath::componentName — siblings in one file are distinct components. */
35
+ export function flattenTrees(trees: ComponentTree[]): FlatComponent[] {
36
+ const out: FlatComponent[] = [];
37
+ const seen = new Set<string>();
38
+ const walk = (t: ComponentTree, parentName: string | undefined): void => {
39
+ const key = `${t.entry.filePath}::${t.entry.componentName}`;
40
+ if (!seen.has(key)) {
41
+ seen.add(key);
42
+ out.push({ info: t.entry, parentName });
43
+ }
44
+ for (const c of t.children) walk(c, t.entry.componentName);
45
+ };
46
+ for (const t of trees) walk(t, undefined);
47
+ return out;
48
+ }
49
+
50
+ /** First claimant keeps the bare name; same-named later components get Parent__Name, then __2. */
51
+ export function allocateSpecNames(flat: FlatComponent[]): Map<FlatComponent, string> {
52
+ const taken = new Set<string>();
53
+ const out = new Map<FlatComponent, string>();
54
+ for (const c of flat) {
55
+ let name = c.info.componentName;
56
+ if (taken.has(name) && c.parentName) name = `${c.parentName}__${c.info.componentName}`;
57
+ let i = 2;
58
+ while (taken.has(name)) name = `${c.info.componentName}__${i++}`;
59
+ taken.add(name);
60
+ out.set(c, name);
61
+ }
62
+ return out;
63
+ }
64
+
65
+ export interface HostEligibility {
66
+ eligible: boolean;
67
+ /** Why not, in words a run report can print verbatim. */
68
+ reason?: string;
69
+ }
70
+
71
+ /**
72
+ * Compound families whose Root opens on a prop — the packages where
73
+ * `<Root open>` is a real, documented state and the wrap is mechanical.
74
+ * Value-driven families (accordion, tabs, radio-group, toggle-group) are
75
+ * deliberately absent: their parts mount against a `value` the wrapper
76
+ * would have to invent, and a wrong wrapper is worse than an honest skip.
77
+ */
78
+ const OPEN_ROOT_FAMILIES = new Set([
79
+ "dialog",
80
+ "alert-dialog",
81
+ "popover",
82
+ "dropdown-menu",
83
+ "select",
84
+ "tooltip",
85
+ "hover-card",
86
+ "context-menu",
87
+ "menubar",
88
+ "collapsible",
89
+ ]);
90
+
91
+ /**
92
+ * Whether a compound part's family can actually wrap it: a Root sibling
93
+ * exists, the primitive is an open-state family, and non-content parts have
94
+ * a Content sibling to mount inside. One predicate, consulted by the unit
95
+ * spec emitter, the host registry and hostEligibility — three call sites
96
+ * disagreeing on wrappability would strand a part between templates.
97
+ */
98
+ export function compoundWrappable(info: ComponentInfo): boolean {
99
+ const family = info.compoundFamily;
100
+ if (!family) return false;
101
+ const base = (family.specifier.split("/").pop() ?? "").replace(/^react-/, "");
102
+ if (!OPEN_ROOT_FAMILIES.has(base)) return false;
103
+ return family.part === "content" || family.part === "trigger" || family.content !== undefined;
104
+ }
105
+
106
+ /**
107
+ * Whether a component can be mounted in the browser profiling host.
108
+ *
109
+ * The host is one bundle with one #root: a component that cannot mount there
110
+ * does not merely fail its own profile spec, it can take the bundle down. Saying
111
+ * why out loud is the difference between "175 components covered" and a number
112
+ * that means something.
113
+ */
114
+ export function hostEligibility(info: ComponentInfo): HostEligibility {
115
+ if (info.exportKind === "none") {
116
+ return { eligible: false, reason: "not exported — the host cannot import it" };
117
+ }
118
+ // No decidable initial render: profiling it measures whichever branch the host
119
+ // happens to take, which is the thing that could not be decided. The host also
120
+ // has to mount it, and for a class with required props there is nothing to pass.
121
+ if (info.renderUndecidable === true) {
122
+ return {
123
+ eligible: false,
124
+ reason:
125
+ "render() branches on a condition this analysis could not decide — no render to profile",
126
+ };
127
+ }
128
+ // React Native source. The host imports the component, so the host's own
129
+ // module graph gains react-native — whose shipped source is Flow and
130
+ // Metro-targeted, so vite cannot parse it, let alone mount it (measured on
131
+ // ospfranco/sol: `SyntaxError: Unexpected token 'typeof'` at collect, zero
132
+ // tests). The evidence is the file's dialect, read off its imports, which is
133
+ // the same evidence the native plane is chosen by — see nativeDialect.ts.
134
+ if (info.hostDialect === "native") {
135
+ return {
136
+ eligible: false,
137
+ reason: "React Native source — react-native cannot load in a browser host",
138
+ };
139
+ }
140
+ const mode = info.renderMode ?? (info.isAsync ? "rsc" : "client");
141
+ switch (mode) {
142
+ case "rsc":
143
+ return {
144
+ eligible: false,
145
+ reason: "async server component — it awaits data and cannot mount in a browser",
146
+ };
147
+ case "compound-part":
148
+ // A part whose own file exports an open-state family mounts through the
149
+ // wrapper hostRegistry composes into its own REGISTRY entry.
150
+ if (compoundWrappable(info)) break;
151
+ return {
152
+ eligible: false,
153
+ reason: info.renderNotes?.[0] ?? "renders a compound part that needs its Root",
154
+ };
155
+ case "document-root":
156
+ return {
157
+ eligible: false,
158
+ reason: "returns a document root (<html>) — it cannot mount inside #root",
159
+ };
160
+ case "null":
161
+ return { eligible: false, reason: "renders null — there is nothing to profile" };
162
+ default:
163
+ break;
164
+ }
165
+ // No exclusion for server-action imports of any binding shape (namespace
166
+ // included): the host stubs those modules by absolute path with their FULL
167
+ // export surface (serverActionExports), so nothing drags in the database
168
+ // driver and every ESM binding — named or namespace member — resolves.
169
+ // next/og is the exception that proves the rule: ImageResponse is an
170
+ // edge-runtime renderer (satori + resvg.wasm + sharp), not a DOM component
171
+ // — app/icon.tsx and opengraph-image.tsx are route handlers in clothing.
172
+ for (const mi of info.moduleImports ?? []) {
173
+ if (mi.specifier === "next/og" || mi.specifier.startsWith("next/og/")) {
174
+ return {
175
+ eligible: false,
176
+ reason: "renders through next/og (edge ImageResponse) — not a browser component",
177
+ };
178
+ }
179
+ }
180
+ return { eligible: true };
181
+ }
182
+
183
+ /**
184
+ * Props the component invokes, which a spec has to supply as functions.
185
+ *
186
+ * Three independent pieces of evidence, any one of which is enough: the prop is
187
+ * called (`propUsage` kind "call"), it is bound to an event attribute, or it is
188
+ * named `onSomething` — React's own convention, and the only signal available
189
+ * for a handler passed straight through to a child.
190
+ *
191
+ * The unit specs cover this with vi.fn() spies. The browser host cannot: props
192
+ * reach it through page.evaluate's structured clone, which drops functions
193
+ * silently, so the component receives undefined and throws on the first call.
194
+ */
195
+ export function functionProps(info: ComponentInfo): string[] {
196
+ const declared = new Set([...Object.keys(info.propsShape), ...info.unresolvedProps]);
197
+ const names = new Set<string>();
198
+ for (const u of info.propUsage ?? []) {
199
+ if (u.kind === "call") names.add(u.propName);
200
+ }
201
+ for (const c of info.interactions?.clicks ?? []) {
202
+ if (c.binding.kind === "prop") names.add(c.binding.propName);
203
+ // `onClick={reset}` where reset is a prop. classifyBinding calls that a
204
+ // "local" because the binding classifier only promotes names matching
205
+ // /^on[A-Z]/ — but being bound to an event attribute is itself proof the
206
+ // value has to be callable, whatever it is named. AuthError's `reset` is
207
+ // this exact shape, and it threw the moment the click landed.
208
+ if (c.binding.kind === "local" && declared.has(c.binding.localName)) {
209
+ names.add(c.binding.localName);
210
+ }
211
+ }
212
+ for (const f of info.interactions?.forms ?? []) {
213
+ if (f.submit?.kind === "prop") names.add(f.submit.propName);
214
+ }
215
+ for (const p of declared) {
216
+ if (/^on[A-Z]/.test(p)) names.add(p);
217
+ }
218
+ // Sorted: the host file has to be byte-identical across runs or the
219
+ // idempotence gate churns on Set insertion order.
220
+ return [...names].sort();
221
+ }
222
+
223
+ /** Members whose presence proves a value has to be a collection, not an object. */
224
+ const ARRAY_MEMBERS = new Set([
225
+ "length",
226
+ "map",
227
+ "filter",
228
+ "forEach",
229
+ "find",
230
+ "slice",
231
+ "some",
232
+ "every",
233
+ "reduce",
234
+ ]);
235
+
236
+ /** Method names only Date values answer — a chain ending in one floors a live Date. */
237
+ const DATE_MEMBERS = new Set([
238
+ "getTime",
239
+ "toISOString",
240
+ "toLocaleDateString",
241
+ "toLocaleTimeString",
242
+ "toLocaleString",
243
+ "getFullYear",
244
+ "getMonth",
245
+ "getDate",
246
+ "getDay",
247
+ "getHours",
248
+ "getMinutes",
249
+ "getSeconds",
250
+ "setTime",
251
+ ]);
252
+
253
+ /**
254
+ * What the LAST segment of a chain demands of the value it was read off:
255
+ * [] for collection methods, a live Date for the date methods, "" for
256
+ * length and every other read (charAt, split, an unknown field — "" answers
257
+ * length, string methods and JSX children alike).
258
+ */
259
+ function chainTerminal(member: string): PropValue {
260
+ if (member === "length") return "";
261
+ if (ARRAY_MEMBERS.has(member)) return [];
262
+ if (DATE_MEMBERS.has(member)) return DATE_PROP_SENTINEL;
263
+ return "";
264
+ }
265
+
266
+ /**
267
+ * Stand-in richness: "" answers length but not map or getTime; [] answers
268
+ * collections; a Date answers the date methods; a nested object is deeper
269
+ * evidence than any primitive. A richer terminal seen anywhere wins — and
270
+ * the DATE sentinel is a string, so ranking it by typeof would be wrong.
271
+ */
272
+ function terminalRank(v: unknown): number {
273
+ if (v === DATE_PROP_SENTINEL) return 3;
274
+ if (v === null || typeof v !== "object") return typeof v === "string" ? 1 : 0;
275
+ if (Array.isArray(v)) return 2;
276
+ return 4;
277
+ }
278
+
279
+ /**
280
+ * Fold `prop.a.b`-style chains into one nested stand-in. The terminal
281
+ * segment is NOT a key to store — it is a read off the value one hop up:
282
+ * `drop.body.length` wants a body that HAS a length, so body becomes "",
283
+ * not `{ length: [] }`. Hops before that are walked through and must be
284
+ * objects; a lone field read (`embeddedCode.lang`) only guarantees the
285
+ * holder exists.
286
+ */
287
+ function foldChains(chains: string[][]): PropsShape {
288
+ const out: PropsShape = {};
289
+ for (const segs of chains) {
290
+ const holderPath = segs.slice(0, -1);
291
+ const terminalSeg = segs[segs.length - 1];
292
+ const key = holderPath[holderPath.length - 1];
293
+ // A chain is always holder + terminal, which is what the caller tested.
294
+ if (terminalSeg === undefined || key === undefined) continue;
295
+ let cur: PropsShape = out;
296
+ for (const hop of holderPath.slice(0, -1)) {
297
+ const ex = cur[hop];
298
+ // The read went THROUGH this hop, so it has to be an object — an
299
+ // earlier "" or [] terminal here died on the hop after it.
300
+ const node = typeof ex === "object" && ex !== null ? ex : {};
301
+ cur[hop] = node;
302
+ cur = node as PropsShape;
303
+ }
304
+ const v = chainTerminal(terminalSeg);
305
+ const ex = cur[key];
306
+ if (ex === undefined || terminalRank(v) > terminalRank(ex)) cur[key] = v;
307
+ }
308
+ return out;
309
+ }
310
+
311
+ /**
312
+ * The props a generated test should pass, including stand-ins for the ones no
313
+ * type resolved.
314
+ *
315
+ * A prop that could not be resolved is absent from propsShape, so the component
316
+ * receives undefined and `prop.field` throws before anything renders. Where the
317
+ * analyser SAW a member access, the read itself says which empty value survives
318
+ * it: {} still throws on .length/.map, so anything used as a collection gets [].
319
+ *
320
+ * A CHAIN read survives even fewer: `drop.body.length` walks two hops, and {}
321
+ * satisfies the first only — Drop died exactly there, and ProfileFeed reproduced
322
+ * it through the same component. Chains therefore floor to a nested stand-in
323
+ * whose terminal value answers the last hop ("", [] or a live Date).
324
+ *
325
+ * The unit scaffolder did this and the perf host did not, so a component like
326
+ * `ProfileForm({ user }: { user: User })` mounted fine under jsdom and failed in
327
+ * the browser host with "did not render within 5000ms" — the same defect wearing
328
+ * a different error message.
329
+ */
330
+ export function synthesiseDefaults(info: ComponentInfo): PropsShape {
331
+ const objectish: PropsShape = {};
332
+ const chains = new Map<string, string[][]>();
333
+ for (const u of info.propUsage ?? []) {
334
+ if (!u.member) continue;
335
+ if (!info.unresolvedProps.includes(u.propName)) continue;
336
+ if (u.chain && u.chain.length > 1) {
337
+ const list = chains.get(u.propName) ?? [];
338
+ if (!list.some((c) => c.join(".") === u.chain?.join("."))) list.push(u.chain);
339
+ chains.set(u.propName, list);
340
+ objectish[u.propName] ??= {};
341
+ continue;
342
+ }
343
+ if (ARRAY_MEMBERS.has(u.member)) objectish[u.propName] = [];
344
+ else objectish[u.propName] ??= {};
345
+ }
346
+ // Chains fold last: their nested stand-ins must not be overwritten by the
347
+ // plain {} floor a sibling single-hop access would give the same prop —
348
+ // and a prop whose only evidence is []-via-ARRAY_MEMBERS keeps that.
349
+ for (const [propName, list] of chains) {
350
+ if (Array.isArray(objectish[propName])) continue;
351
+ const folded = foldChains(list);
352
+ objectish[propName] =
353
+ Object.keys(objectish[propName] as PropsShape).length > 0
354
+ ? { ...(objectish[propName] as PropsShape), ...folded }
355
+ : folded;
356
+ }
357
+ // The child bridge: `<RelativeTime date={drop.createdAt}/>` is evidence
358
+ // about drop.createdAt from the CHILD's side — RelativeTime's own defaults
359
+ // say its date is a live Date, so drop grows a createdAt Date even though
360
+ // no read in THIS body ever asked. Rank-guarded: a child's expectation
361
+ // only ever upgrades the stand-in, never downgrades real evidence.
362
+ const exp = info.childExpected;
363
+ if (exp) {
364
+ for (const u of info.propUsage ?? []) {
365
+ if (!u.tag || !u.attr) continue;
366
+ if (!info.unresolvedProps.includes(u.propName)) continue;
367
+ const expected = exp[u.tag]?.[u.attr];
368
+ if (expected === undefined) continue;
369
+ const bridged = bridgePropValues(objectish[u.propName], u.member, expected);
370
+ if (bridged !== undefined) objectish[u.propName] = bridged as PropsShape[string];
371
+ }
372
+ }
373
+ // A Promise prop is wrapped by the spec (`Promise.resolve(v)`), but the wrap
374
+ // skips missing keys — so an unresolved `page: Promise<Page>` reached
375
+ // `use(page)` as undefined ("An unsupported type was passed to use()").
376
+ // Floor every promise prop at {}: the await resolves to an object, and
377
+ // destructured fields read as undefined, which renders as nothing.
378
+ for (const p of info.promiseProps ?? []) objectish[p] ??= {};
379
+ const merged = { ...objectish, ...info.propsShape };
380
+ // An identifier, not copy. A required string prop spelled `…Id` is fed to
381
+ // something that looks the value up or validates it — Liveblocks threw
382
+ // "RoomProvider id property is required" over the empty string Room passes
383
+ // straight through. Same reasoning as withRouteParams below, one layer up:
384
+ // the empty string is neutral for a caption and never for an id.
385
+ for (const [k, v] of Object.entries(merged)) {
386
+ if (v === "" && /Id$/.test(k)) merged[k] = `flawless-${k.toLowerCase()}`;
387
+ }
388
+ return merged;
389
+ }
390
+
391
+ /**
392
+ * JSON.stringify for a PropsShape that may carry `DATE_PROP_SENTINEL`. Every
393
+ * caller embeds the result in GENERATED SOURCE, so rewrite the sentinel into
394
+ * a live `new Date(...)`: an ISO string would crash exactly the methods
395
+ * (getTime, toLocaleTimeString) a Date-typed prop is written to be called
396
+ * with, and JSON has no Date.
397
+ */
398
+ export function defaultsJson(shape: PropsShape): string {
399
+ return JSON.stringify(shape).replaceAll(
400
+ JSON.stringify(DATE_PROP_SENTINEL),
401
+ `new Date(${JSON.stringify(DATE_PROP_ISO)})`,
402
+ );
403
+ }
404
+
405
+ /**
406
+ * A Next.js page's `params`, with real segment values.
407
+ *
408
+ * mapType's default for a string prop is `""`, and it is the right default: an
409
+ * empty string is a legitimate neutral value for a caption or a placeholder.
410
+ * It is never a route param. Next does not match a dynamic segment against an
411
+ * empty string, so `{ params: { id: "" } }` is a request that cannot happen —
412
+ * and the pages then act like it: `getBuildingById(db, "")` finds nothing,
413
+ * `notFound()` fires, and the spec records a guard exit as a pass.
414
+ *
415
+ * Twenty-two of the dogfood app's thirty-two page specs never rendered
416
+ * anything. On the one measured directly, filling this in took the page from 41
417
+ * of 158 lines to 151.
418
+ *
419
+ * `params` only, and only its empty-string leaves — a value the analyser
420
+ * resolved from a literal union or a nested type is evidence and is left alone.
421
+ */
422
+ export function withRouteParams(shape: PropsShape): PropsShape {
423
+ const params = shape.params;
424
+ if (!params || typeof params !== "object" || Array.isArray(params)) return shape;
425
+ const filled: Record<string, PropValue> = {};
426
+ let changed = false;
427
+ for (const [k, v] of Object.entries(params as Record<string, PropValue>)) {
428
+ if (v === "") {
429
+ // /Id$/ too: the caller may synthesiseDefaults first, but the fill here
430
+ // is the last pass before the literal — an id leaf must not stay empty.
431
+ filled[k] = /Id$/.test(k) ? `flawless-${k.toLowerCase()}` : `flawless-${k}`;
432
+ changed = true;
433
+ } else if (Array.isArray(v) && v.length === 0) {
434
+ // A catch-all segment (`[...slug]`) is an array of segments, and an empty
435
+ // one is the same non-request as an empty string.
436
+ filled[k] = [`flawless-${k}`];
437
+ changed = true;
438
+ } else {
439
+ filled[k] = v;
440
+ }
441
+ }
442
+ return changed ? { ...shape, params: filled } : shape;
443
+ }