@lariocpt/flawless-react 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +608 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- package/src/version.ts +12 -0
|
@@ -0,0 +1,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
|
+
}
|