@lariocpt/flawless-react 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +611 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- package/src/version.ts +12 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { expoNotImplemented } from "../expo/refusal.ts";
|
|
3
|
+
import { scaffold } from "../scaffold/index.ts";
|
|
4
|
+
import { flattenTrees, hostEligibility } from "../scaffold-tests/registry.ts";
|
|
5
|
+
import type { RunContext, Task, TaskResult } from "../types.ts";
|
|
6
|
+
import { VERSION } from "../version.ts";
|
|
7
|
+
|
|
8
|
+
export const perfHarness: Task = {
|
|
9
|
+
id: "perf-harness",
|
|
10
|
+
label: "Generate performance testing harness",
|
|
11
|
+
description: "Playwright + React.Profiler + react-scan suite",
|
|
12
|
+
needsTree: true,
|
|
13
|
+
async run(ctx: RunContext): Promise<TaskResult> {
|
|
14
|
+
// Wave 4 was measured and DECLINED, not deferred (2026-09-12, census in
|
|
15
|
+
// docs/archive/backlog-completed-2026-10.md B-16 + docs/archive/2026-09-expo-probes.md): on
|
|
16
|
+
// an expo-router app the component population is route screens, the host
|
|
17
|
+
// cannot mount anything that needs a navigator (renderRouter is
|
|
18
|
+
// jest-bound), and the profile click scenarios are onClick-only — so the
|
|
19
|
+
// measured ceiling on the dogfood app was ~0 of 32 components. A host
|
|
20
|
+
// built anyway would be a red spec on healthy components or a suite that
|
|
21
|
+
// tests nothing. Revisit condition, recorded in the backlog: an adopter
|
|
22
|
+
// with a real components/ directory of non-route, navigator-free
|
|
23
|
+
// components. The one opening — a Metro-served real-app perf lane, using
|
|
24
|
+
// wave 6's infrastructure — is mapped and probe-gated in
|
|
25
|
+
// docs/archive/wave4-metro-perf-plan.md; it stays declined until a probe
|
|
26
|
+
// clears, because on a React-Compiler app the only honest signal is nulled.
|
|
27
|
+
if (ctx.expo) {
|
|
28
|
+
return expoNotImplemented(
|
|
29
|
+
"perf-harness",
|
|
30
|
+
"measured-and-declined 2026-09-12 — ~0 host-mountable components on an " +
|
|
31
|
+
"all-route-screens app; docs/archive/backlog-completed-2026-10.md B-16 records the evidence " +
|
|
32
|
+
"and the revisit condition",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const [first, ...rest] = ctx.trees;
|
|
36
|
+
if (!first) {
|
|
37
|
+
return {
|
|
38
|
+
id: "perf-harness",
|
|
39
|
+
ok: false,
|
|
40
|
+
outputs: [],
|
|
41
|
+
findings: [],
|
|
42
|
+
summary: "no components to scaffold",
|
|
43
|
+
error: "no components",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const suiteName = path.basename(path.resolve(ctx.outDir)).replace(/[^a-zA-Z0-9_-]+/g, "-");
|
|
47
|
+
const res = await scaffold(first, {
|
|
48
|
+
outDir: ctx.outDir,
|
|
49
|
+
extraTrees: rest,
|
|
50
|
+
suiteName,
|
|
51
|
+
variants: ctx.variants,
|
|
52
|
+
heaps: ctx.heaps,
|
|
53
|
+
force: ctx.force,
|
|
54
|
+
// An explicit --force outranks changed-mode's keep: forcing IS asking
|
|
55
|
+
// for the regeneration that changed-mode otherwise declines to churn.
|
|
56
|
+
keepExisting: ctx.changed !== undefined && !ctx.force,
|
|
57
|
+
report: ctx.report,
|
|
58
|
+
provider: ctx.provider,
|
|
59
|
+
apiSpec: ctx.apiSpec,
|
|
60
|
+
nextjsHost: ctx.nextjs !== undefined,
|
|
61
|
+
mswSetup: ctx.nextjs !== undefined,
|
|
62
|
+
providerWraps: ctx.providerWraps,
|
|
63
|
+
});
|
|
64
|
+
if (res.status === "kept") {
|
|
65
|
+
return {
|
|
66
|
+
id: "perf-harness",
|
|
67
|
+
ok: true,
|
|
68
|
+
outputs: [],
|
|
69
|
+
findings: [],
|
|
70
|
+
status: "kept",
|
|
71
|
+
summary: `harness kept (generated by flawless-react ${res.existingVersion ?? "unknown version"}, tool is ${VERSION})`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// The number of components the host can actually mount, not the number of
|
|
75
|
+
// entry trees. Those differ by more than half in a real app — most of what
|
|
76
|
+
// the sweep finds is a server component or a compound part — and reporting
|
|
77
|
+
// the tree count claimed a coverage the suite never had.
|
|
78
|
+
const flat = flattenTrees([first, ...rest]);
|
|
79
|
+
const hosted = flat.filter((c) => hostEligibility(c.info).eligible);
|
|
80
|
+
const names = hosted.map((c) => c.info.componentName);
|
|
81
|
+
const covered = `${names.length} of ${flat.length} component(s): ${names.join(", ")}`;
|
|
82
|
+
const keptNote =
|
|
83
|
+
res.kept.length > 0
|
|
84
|
+
? `; kept ${res.kept.map((f) => path.relative(path.resolve(ctx.outDir), f)).join(", ")} (delete one to regenerate it)`
|
|
85
|
+
: "";
|
|
86
|
+
// One-time migration for pre-split suites. writeOnce is absolute (--force
|
|
87
|
+
// included — budgets.json rests on that promise), so the tool cannot fix
|
|
88
|
+
// the monolith itself: host/registry.ts was written inert beside it, and
|
|
89
|
+
// delete-to-regenerate is the escape.
|
|
90
|
+
const migrationNote = res.preSplitMain
|
|
91
|
+
? "; host/main.tsx predates the registry split and cannot mount newly analysed components" +
|
|
92
|
+
" — delete it and re-run (provider wiring regenerates from --provider), or port your" +
|
|
93
|
+
' hand wiring and import "./registry"'
|
|
94
|
+
: "";
|
|
95
|
+
if (res.preSplitMain) {
|
|
96
|
+
console.warn(
|
|
97
|
+
`[flawless] ${path.join(ctx.outDir, "host/main.tsx")} predates the registry split:` +
|
|
98
|
+
" it bakes in its own component tables, so newly analysed components cannot mount" +
|
|
99
|
+
" through it, and no flag may rewrite a write-once file — delete host/main.tsx and" +
|
|
100
|
+
" re-run (provider wiring regenerates from --provider), or port your hand wiring" +
|
|
101
|
+
' and import "./registry".',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
// A component that left the host set stops being rendered, but its profile
|
|
105
|
+
// spec would otherwise sit in profile/ forever, failing at mount with
|
|
106
|
+
// "unknown component" — the suite red by the tool's own doing. The sweep
|
|
107
|
+
// retires the ones whose stamp proves them untouched tool output, and
|
|
108
|
+
// names the ones it refused to touch (hand work or unprovable).
|
|
109
|
+
const staleRemovedNote =
|
|
110
|
+
res.staleRemoved.length > 0
|
|
111
|
+
? `; retired ${res.staleRemoved.length} stale profile spec(s) whose component left the host — ` +
|
|
112
|
+
res.staleRemoved.map((f) => path.relative(path.resolve(ctx.outDir), f)).join(", ")
|
|
113
|
+
: "";
|
|
114
|
+
const staleKeptNote =
|
|
115
|
+
res.staleKept.length > 0
|
|
116
|
+
? `; kept ${res.staleKept.length} stale profile spec(s) — ` +
|
|
117
|
+
res.staleKept
|
|
118
|
+
.map((f) => `${path.relative(path.resolve(ctx.outDir), f.abs)} (${f.verdict})`)
|
|
119
|
+
.join(", ") +
|
|
120
|
+
" (delete to retire)"
|
|
121
|
+
: "";
|
|
122
|
+
// The provider lives in a file the tool owns, but main.tsx's import of it
|
|
123
|
+
// is kept — so when the provider moves, the kept harness mounts the OLD
|
|
124
|
+
// provider and every mount fails on an opaque 5000ms timeout. scaffold
|
|
125
|
+
// re-points that one line; the run has to say which host it re-pointed.
|
|
126
|
+
const providerNote = res.providerImportRefresh
|
|
127
|
+
? `; re-pointed host/main.tsx's provider import ${res.providerImportRefresh.from} → ` +
|
|
128
|
+
`${res.providerImportRefresh.to} (the harness is kept, this line is not)`
|
|
129
|
+
: "";
|
|
130
|
+
if (res.providerImportWarning) {
|
|
131
|
+
console.warn(`[flawless] ${res.providerImportWarning}.`);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
id: "perf-harness",
|
|
135
|
+
ok: true,
|
|
136
|
+
outputs: res.written,
|
|
137
|
+
findings: [],
|
|
138
|
+
status: res.status,
|
|
139
|
+
summary:
|
|
140
|
+
res.status === "refreshed"
|
|
141
|
+
? `refreshed perf suite for ${covered}${keptNote}${providerNote}${migrationNote}${staleRemovedNote}${staleKeptNote}`
|
|
142
|
+
: `scaffolded perf suite for ${covered}${migrationNote}${staleRemovedNote}${staleKeptNote}`,
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
};
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
ChildAttrInfo,
|
|
5
|
+
ComponentInfo,
|
|
6
|
+
ComponentTree,
|
|
7
|
+
EffectFeatures,
|
|
8
|
+
Finding,
|
|
9
|
+
ReactCompilerProbe,
|
|
10
|
+
RunContext,
|
|
11
|
+
SourcePosition,
|
|
12
|
+
SpecQualityBucket,
|
|
13
|
+
Task,
|
|
14
|
+
TaskResult,
|
|
15
|
+
} from "../types.ts";
|
|
16
|
+
|
|
17
|
+
const MEMO_HOOKS = new Set(["useMemo", "useCallback", "memo", "useRef"]);
|
|
18
|
+
const CHILD_RERENDER_THRESHOLD = 3;
|
|
19
|
+
const MEMO_HOOK_NAMES = new Set(["useMemo", "useCallback"]);
|
|
20
|
+
const EFFECT_HOOK_NAMES = new Set(["useEffect", "useLayoutEffect"]);
|
|
21
|
+
const PLAIN_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
22
|
+
const MEMO_WRAPPED_NOTE = " — child is React.memo-wrapped; this prop defeats memoisation.";
|
|
23
|
+
|
|
24
|
+
/** Attr value kinds recreated on every render (new reference each time). */
|
|
25
|
+
const UNSTABLE_ATTR_KINDS = new Set<ChildAttrInfo["valueKind"]>([
|
|
26
|
+
"object-literal",
|
|
27
|
+
"array-literal",
|
|
28
|
+
"arrow",
|
|
29
|
+
"function",
|
|
30
|
+
]);
|
|
31
|
+
/** Attrs that are not regular props (memoisation-irrelevant or unanalysable). */
|
|
32
|
+
const NON_PROP_ATTRS = new Set(["key", "ref", "children", "...spread"]);
|
|
33
|
+
/** Attr value kinds that are referentially stable across parent rerenders. */
|
|
34
|
+
const STABLE_ATTR_KINDS = new Set<ChildAttrInfo["valueKind"]>([
|
|
35
|
+
"string",
|
|
36
|
+
"number",
|
|
37
|
+
"boolean-true",
|
|
38
|
+
"null",
|
|
39
|
+
"member",
|
|
40
|
+
"identifier",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const ATTR_KIND_LABEL: Partial<Record<ChildAttrInfo["valueKind"], string>> = {
|
|
44
|
+
"object-literal": "an inline object literal",
|
|
45
|
+
"array-literal": "an inline array literal",
|
|
46
|
+
arrow: "an inline arrow function",
|
|
47
|
+
function: "an inline function expression",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
interface PerfReport {
|
|
51
|
+
component: string;
|
|
52
|
+
file: string;
|
|
53
|
+
findings: Finding[];
|
|
54
|
+
/** Per component in this tree: props no fixture could synthesise. */
|
|
55
|
+
specQuality: SpecQualityBucket[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What one walk of one tree collects. */
|
|
59
|
+
interface Collected {
|
|
60
|
+
findings: Finding[];
|
|
61
|
+
specQuality: SpecQualityBucket[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One rule and how many findings of it the compiler made redundant. */
|
|
65
|
+
interface SuppressedTally {
|
|
66
|
+
rule: string;
|
|
67
|
+
count: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The three rules whose advice the React Compiler implements by itself.
|
|
72
|
+
*
|
|
73
|
+
* It memoises props and JSX at the call site, so "a new reference on every
|
|
74
|
+
* render" is false post-compilation and a hand-written `memo()` wrapper is work
|
|
75
|
+
* the build already did — the React docs go further and call adding one contrary
|
|
76
|
+
* to guidance. The other six rules are untouched by it: correct dependency
|
|
77
|
+
* arrays are what the compiler RELIES on, effects run per render whatever got
|
|
78
|
+
* memoised, and `set-state-in-render` is a correctness bug, not a scale.
|
|
79
|
+
*
|
|
80
|
+
* Bail-outs are deliberately not modelled. A component that violates the Rules
|
|
81
|
+
* of React is not compiled — but the react-rules task owns that detection, and
|
|
82
|
+
* these flags are global, so perf-static treats compiler state as global instead
|
|
83
|
+
* of second-guessing per-file compilation success.
|
|
84
|
+
*/
|
|
85
|
+
const MEMO_FAMILY_RULES: readonly string[] = [
|
|
86
|
+
"perf/no-memoisation-with-children",
|
|
87
|
+
"perf/unstable-child-prop",
|
|
88
|
+
"perf/missing-memo-leaf",
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/** The rule that is spec quality, not app perf, and so never a finding. */
|
|
92
|
+
export const SPEC_QUALITY_RULE = "perf/unresolved-prop";
|
|
93
|
+
|
|
94
|
+
export const staticPerfAnalysis: Task = {
|
|
95
|
+
id: "perf-static",
|
|
96
|
+
label: "Static React perf analysis",
|
|
97
|
+
description:
|
|
98
|
+
"Surface rerender / memoisation risks from the parsed tree, and the props no fixture could synthesise",
|
|
99
|
+
needsTree: true,
|
|
100
|
+
async run(ctx: RunContext): Promise<TaskResult> {
|
|
101
|
+
const reportsDir = path.join(ctx.outDir, "reports");
|
|
102
|
+
await mkdir(reportsDir, { recursive: true });
|
|
103
|
+
|
|
104
|
+
// Absent only on a hand-built context (a task run outside runCore). Say the
|
|
105
|
+
// run did not look rather than assuming either way — `undetermined` runs
|
|
106
|
+
// every rule, which is the safe direction, and says so.
|
|
107
|
+
const compiler: ReactCompilerProbe = ctx.reactCompiler ?? {
|
|
108
|
+
state: "undetermined",
|
|
109
|
+
evidence: "this run did not read the project's configs",
|
|
110
|
+
};
|
|
111
|
+
const suppress = compiler.state === "on" ? MEMO_FAMILY_RULES : [];
|
|
112
|
+
|
|
113
|
+
const allFindings: Finding[] = [];
|
|
114
|
+
const written: string[] = [];
|
|
115
|
+
const total = new Map<string, number>(suppress.map((rule) => [rule, 0]));
|
|
116
|
+
// Keyed `file::component` — the registry's own dedupe convention. A leaf
|
|
117
|
+
// rendered by forty parents is forty trees, and per-prop counting turned
|
|
118
|
+
// that into 148 "perf findings" for one button.
|
|
119
|
+
const gaps = new Map<string, { component: string; file: string; props: Set<string> }>();
|
|
120
|
+
|
|
121
|
+
for (const tree of ctx.trees) {
|
|
122
|
+
const report = analyseTree(tree);
|
|
123
|
+
const kept: Finding[] = [];
|
|
124
|
+
const here = new Map<string, number>();
|
|
125
|
+
for (const finding of report.findings) {
|
|
126
|
+
if (!suppress.includes(finding.rule)) {
|
|
127
|
+
kept.push(finding);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
here.set(finding.rule, (here.get(finding.rule) ?? 0) + 1);
|
|
131
|
+
}
|
|
132
|
+
for (const [rule, n] of here) total.set(rule, (total.get(rule) ?? 0) + n);
|
|
133
|
+
for (const finding of kept) allFindings.push(finding);
|
|
134
|
+
for (const gap of report.specQuality) {
|
|
135
|
+
const key = `${gap.file}::${gap.component}`;
|
|
136
|
+
const bucket = gaps.get(key) ?? {
|
|
137
|
+
component: gap.component,
|
|
138
|
+
file: gap.file,
|
|
139
|
+
props: new Set<string>(),
|
|
140
|
+
};
|
|
141
|
+
for (const prop of gap.props) bucket.props.add(prop);
|
|
142
|
+
gaps.set(key, bucket);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const out = path.join(reportsDir, `${report.component}.perf-static.json`);
|
|
146
|
+
// The state and the gaps ride along with every report file, because the
|
|
147
|
+
// numbers they explain are in there: a zero memo-finding count written by a
|
|
148
|
+
// compiler-on run means "the compiler does this", and neither it nor a
|
|
149
|
+
// component whose props all synthesised can be told apart by the counts.
|
|
150
|
+
await Bun.write(
|
|
151
|
+
out,
|
|
152
|
+
`${JSON.stringify(
|
|
153
|
+
{
|
|
154
|
+
component: report.component,
|
|
155
|
+
file: report.file,
|
|
156
|
+
findings: kept,
|
|
157
|
+
reactCompiler: compiler,
|
|
158
|
+
specQuality: report.specQuality,
|
|
159
|
+
...(here.size > 0 ? { suppressed: tally(here) } : {}),
|
|
160
|
+
},
|
|
161
|
+
null,
|
|
162
|
+
2,
|
|
163
|
+
)}\n`,
|
|
164
|
+
);
|
|
165
|
+
written.push(out);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const suppressed = tally(total);
|
|
169
|
+
const components = [...gaps.values()]
|
|
170
|
+
.map((gap) => ({ component: gap.component, file: gap.file, props: [...gap.props].sort() }))
|
|
171
|
+
.sort((a, b) =>
|
|
172
|
+
a.file === b.file ? (a.component < b.component ? -1 : 1) : a.file < b.file ? -1 : 1,
|
|
173
|
+
);
|
|
174
|
+
const specQuality: TaskResult["specQuality"] =
|
|
175
|
+
components.length > 0
|
|
176
|
+
? {
|
|
177
|
+
rule: SPEC_QUALITY_RULE,
|
|
178
|
+
props: components.reduce((n, c) => n + c.props.length, 0),
|
|
179
|
+
components,
|
|
180
|
+
}
|
|
181
|
+
: undefined;
|
|
182
|
+
|
|
183
|
+
const notes = [compilerNote(compiler, suppressed), specQualityNote(specQuality)].filter(
|
|
184
|
+
(note) => note !== "",
|
|
185
|
+
);
|
|
186
|
+
return {
|
|
187
|
+
id: "perf-static",
|
|
188
|
+
ok: true,
|
|
189
|
+
outputs: written,
|
|
190
|
+
findings: allFindings,
|
|
191
|
+
summary:
|
|
192
|
+
`static perf analysis: ${allFindings.length} finding(s) across ` +
|
|
193
|
+
`${ctx.trees.length} root(s)` +
|
|
194
|
+
(notes.length > 0 ? ` — ${notes.join("; ")}` : ""),
|
|
195
|
+
reactCompiler: {
|
|
196
|
+
state: compiler.state,
|
|
197
|
+
evidence: compiler.evidence,
|
|
198
|
+
...(suppress.length > 0 ? { suppressed } : {}),
|
|
199
|
+
},
|
|
200
|
+
...(specQuality ? { specQuality } : {}),
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** Sorted so two runs of one tree diff clean, and a rule is never dropped. */
|
|
206
|
+
function tally(counts: Map<string, number>): SuppressedTally[] {
|
|
207
|
+
return [...counts.entries()]
|
|
208
|
+
.map(([rule, count]) => ({ rule, count }))
|
|
209
|
+
.sort((a, b) => (a.rule < b.rule ? -1 : 1));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The one honest line for the two compiler states that change what the number
|
|
214
|
+
* means.
|
|
215
|
+
*
|
|
216
|
+
* `off` says nothing: nothing was suppressed, and a task summary that reports the
|
|
217
|
+
* absence of its own special case on every run teaches nobody anything. `on` is
|
|
218
|
+
* suppression, and suppression is never silent. `undetermined` is the one a
|
|
219
|
+
* reader could otherwise mistake for a checked `off` — the memo findings it
|
|
220
|
+
* printed may be work a compiler-on build already did.
|
|
221
|
+
*/
|
|
222
|
+
function compilerNote(compiler: ReactCompilerProbe, suppressed: SuppressedTally[]): string {
|
|
223
|
+
if (compiler.state === "on") {
|
|
224
|
+
const findings = suppressed.reduce((n, s) => n + s.count, 0);
|
|
225
|
+
return (
|
|
226
|
+
`${suppressed.length} memo rule(s) suppressed, ${findings} finding(s) the ` +
|
|
227
|
+
`React Compiler already memoises (${compiler.evidence})`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (compiler.state === "undetermined") {
|
|
231
|
+
return "react-compiler state undetermined; memo-family findings may be pre-compiled noise on a compiler-on app";
|
|
232
|
+
}
|
|
233
|
+
return "";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The spec-quality half of the count, said apart.
|
|
238
|
+
*
|
|
239
|
+
* The finding number above means "rerender work worth doing in your app", and
|
|
240
|
+
* keeping it that way is the whole point: the dogfood run's 926 were 127 of that
|
|
241
|
+
* plus 713 of this, which read as an alarming backlog and were really the
|
|
242
|
+
* analyser's own fixture limits.
|
|
243
|
+
*/
|
|
244
|
+
function specQualityNote(specQuality: TaskResult["specQuality"]): string {
|
|
245
|
+
if (!specQuality) return "";
|
|
246
|
+
return (
|
|
247
|
+
`${specQuality.props} spec-quality prop gap(s) across ` +
|
|
248
|
+
`${specQuality.components.length} component(s), counted apart from the findings`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function analyseTree(tree: ComponentTree): PerfReport {
|
|
253
|
+
const out: Collected = { findings: [], specQuality: [] };
|
|
254
|
+
walk(tree, out);
|
|
255
|
+
return {
|
|
256
|
+
component: tree.entry.componentName,
|
|
257
|
+
file: tree.entry.filePath,
|
|
258
|
+
findings: out.findings,
|
|
259
|
+
specQuality: out.specQuality,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function walk(node: ComponentTree, out: Collected): void {
|
|
264
|
+
inspect(node, out);
|
|
265
|
+
for (const c of node.children) walk(c, out);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function inspect(node: ComponentTree, out: Collected): void {
|
|
269
|
+
const info = node.entry;
|
|
270
|
+
const names = knownNamesOf(info);
|
|
271
|
+
inspectMemoisation(info, node.children.length, out.findings);
|
|
272
|
+
collectSpecQuality(info, out);
|
|
273
|
+
inspectHookDeps(info, names, out.findings);
|
|
274
|
+
inspectChildAttrs(node, names, out.findings);
|
|
275
|
+
inspectRenderSetters(info, out.findings);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Names recoverable from a ComponentInfo alone — no AST scope is available here. */
|
|
279
|
+
interface KnownNames {
|
|
280
|
+
propNames: Set<string>;
|
|
281
|
+
stateVars: Set<string>;
|
|
282
|
+
setterNames: Set<string>;
|
|
283
|
+
refNames: Set<string>;
|
|
284
|
+
memoResultNames: Set<string>;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function knownNamesOf(info: ComponentInfo): KnownNames {
|
|
288
|
+
const propNames = new Set([...Object.keys(info.propsShape), ...info.unresolvedProps]);
|
|
289
|
+
const stateVars = new Set<string>();
|
|
290
|
+
const setterNames = new Set<string>();
|
|
291
|
+
const refNames = new Set<string>();
|
|
292
|
+
for (const s of info.state ?? []) {
|
|
293
|
+
if (s.kind === "useRef") refNames.add(s.varName);
|
|
294
|
+
else stateVars.add(s.varName);
|
|
295
|
+
if (s.setterName) setterNames.add(s.setterName);
|
|
296
|
+
}
|
|
297
|
+
const memoResultNames = new Set<string>();
|
|
298
|
+
for (const d of info.hookDetails ?? []) {
|
|
299
|
+
if (d.resultName) memoResultNames.add(d.resultName);
|
|
300
|
+
}
|
|
301
|
+
return { propNames, stateVars, setterNames, refNames, memoResultNames };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function at(
|
|
305
|
+
pos: SourcePosition | undefined,
|
|
306
|
+
fallbackFile: string,
|
|
307
|
+
): Pick<Finding, "file" | "line" | "column"> {
|
|
308
|
+
return pos
|
|
309
|
+
? { file: pos.file, line: pos.line, column: pos.column }
|
|
310
|
+
: { file: fallbackFile, line: 1, column: 1 };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function inspectMemoisation(info: ComponentInfo, childCount: number, findings: Finding[]): void {
|
|
314
|
+
const memoised =
|
|
315
|
+
info.hooks.some((h) => MEMO_HOOKS.has(h)) || (info.wrappers ?? []).includes("memo");
|
|
316
|
+
if (childCount < CHILD_RERENDER_THRESHOLD || memoised) return;
|
|
317
|
+
findings.push({
|
|
318
|
+
rule: "perf/no-memoisation-with-children",
|
|
319
|
+
severity: "warn",
|
|
320
|
+
...at(info.pos, info.filePath),
|
|
321
|
+
message:
|
|
322
|
+
`${info.componentName} renders ${childCount} child component(s) but uses none of ` +
|
|
323
|
+
`useMemo/useCallback/memo/useRef — every parent rerender will rerender all children.`,
|
|
324
|
+
source: "perf-static",
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The ninth check, and the only one that is not about the app.
|
|
330
|
+
*
|
|
331
|
+
* One bucket per component naming the props no fixture could synthesise — NOT a
|
|
332
|
+
* finding. As a per-prop finding it said "button.tsx: 148 perf findings" about a
|
|
333
|
+
* component with one prop family the analyser could not resolve, and the rerender
|
|
334
|
+
* findings that needed someone's attention hid underneath it.
|
|
335
|
+
*
|
|
336
|
+
* The prop NAMES are the actionable half: `--props '{"x":1}'` or the spec's
|
|
337
|
+
* `DEFAULTS` address a prop by name, never by the line of the type literal it was
|
|
338
|
+
* declared on, which is the only thing the old per-prop positions added.
|
|
339
|
+
*/
|
|
340
|
+
function collectSpecQuality(info: ComponentInfo, out: Collected): void {
|
|
341
|
+
if (info.unresolvedProps.length === 0) return;
|
|
342
|
+
out.specQuality.push({
|
|
343
|
+
component: info.componentName,
|
|
344
|
+
file: info.filePath,
|
|
345
|
+
props: [...info.unresolvedProps].sort(),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function inspectHookDeps(info: ComponentInfo, names: KnownNames, findings: Finding[]): void {
|
|
350
|
+
const details = info.hookDetails;
|
|
351
|
+
if (!details || details.length === 0) return;
|
|
352
|
+
const exempt = new Set([...names.setterNames, ...names.refNames]);
|
|
353
|
+
// Identifiers we can attribute confidently without a scope; derived locals
|
|
354
|
+
// are deliberately excluded (too noisy from ComponentInfo alone).
|
|
355
|
+
const trackable = new Set([...names.propNames, ...names.stateVars, ...names.memoResultNames]);
|
|
356
|
+
|
|
357
|
+
for (const d of details) {
|
|
358
|
+
// Bail: the extractor flagged this hook as unreliable to reason about.
|
|
359
|
+
if (d.depsHasComplex || !d.callbackIsInline || d.readsBareParamsObject) continue;
|
|
360
|
+
const where = at(d.pos, info.filePath);
|
|
361
|
+
|
|
362
|
+
if (MEMO_HOOK_NAMES.has(d.name) && d.missingDepsArray) {
|
|
363
|
+
findings.push({
|
|
364
|
+
rule: "perf/hook-missing-deps-array",
|
|
365
|
+
severity: "warn",
|
|
366
|
+
...where,
|
|
367
|
+
message:
|
|
368
|
+
`${info.componentName}: ${d.name} has no dependency array — it recomputes on ` +
|
|
369
|
+
`every render, so the hook buys nothing.`,
|
|
370
|
+
source: "perf-static",
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (EFFECT_HOOK_NAMES.has(d.name) && d.missingDepsArray && d.effectFeatures) {
|
|
375
|
+
const f = d.effectFeatures;
|
|
376
|
+
if (f.fetch || f.setInterval || f.setTimeout || f.addEventListener) {
|
|
377
|
+
findings.push({
|
|
378
|
+
rule: "perf/effect-every-render",
|
|
379
|
+
severity: "warn",
|
|
380
|
+
...where,
|
|
381
|
+
message:
|
|
382
|
+
`${info.componentName}: ${d.name} has no dependency array yet starts ` +
|
|
383
|
+
`${effectFeatureList(f)} — it re-runs on every single render.`,
|
|
384
|
+
source: "perf-static",
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (d.deps === undefined) continue;
|
|
390
|
+
const plainDeps = new Set(d.deps.filter((x) => PLAIN_IDENTIFIER.test(x)));
|
|
391
|
+
|
|
392
|
+
const missing = d.callbackReads.filter(
|
|
393
|
+
(r) => !plainDeps.has(r) && !exempt.has(r) && trackable.has(r),
|
|
394
|
+
);
|
|
395
|
+
if (missing.length > 0) {
|
|
396
|
+
const list = missing.map((m) => `"${m}"`).join(", ");
|
|
397
|
+
findings.push({
|
|
398
|
+
rule: "perf/hook-missing-dependency",
|
|
399
|
+
severity: "warn",
|
|
400
|
+
...where,
|
|
401
|
+
message:
|
|
402
|
+
`${info.componentName}: ${d.name} reads ${list} but omits ` +
|
|
403
|
+
`${missing.length === 1 ? "it" : "them"} from its deps — the callback closes over ` +
|
|
404
|
+
`stale values (stale-closure risk).`,
|
|
405
|
+
source: "perf-static",
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
for (const dep of d.deps) {
|
|
410
|
+
if (!PLAIN_IDENTIFIER.test(dep)) continue;
|
|
411
|
+
if (!trackable.has(dep) || exempt.has(dep)) continue;
|
|
412
|
+
if (d.callbackReads.includes(dep)) continue;
|
|
413
|
+
findings.push({
|
|
414
|
+
rule: "perf/hook-extraneous-dependency",
|
|
415
|
+
severity: "warn",
|
|
416
|
+
...where,
|
|
417
|
+
message:
|
|
418
|
+
`${info.componentName}: ${d.name} lists "${dep}" in its deps but never reads it — ` +
|
|
419
|
+
`the hook re-fires whenever "${dep}" changes, for no benefit.`,
|
|
420
|
+
source: "perf-static",
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function effectFeatureList(f: EffectFeatures): string {
|
|
427
|
+
const parts: string[] = [];
|
|
428
|
+
if (f.fetch) parts.push("a fetch");
|
|
429
|
+
if (f.setInterval) parts.push("setInterval");
|
|
430
|
+
if (f.setTimeout) parts.push("setTimeout");
|
|
431
|
+
if (f.addEventListener) parts.push("addEventListener");
|
|
432
|
+
return parts.join(" + ");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function inspectChildAttrs(node: ComponentTree, names: KnownNames, findings: Finding[]): void {
|
|
436
|
+
const info = node.entry;
|
|
437
|
+
const attrs = info.childAttrs;
|
|
438
|
+
if (!attrs) return;
|
|
439
|
+
|
|
440
|
+
const childByTag = new Map<string, ComponentTree>();
|
|
441
|
+
for (const c of node.children) {
|
|
442
|
+
childByTag.set(c.entry.localTag ?? c.entry.componentName, c);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
for (const a of attrs) {
|
|
446
|
+
if (!UNSTABLE_ATTR_KINDS.has(a.valueKind)) continue;
|
|
447
|
+
if (NON_PROP_ATTRS.has(a.attr)) continue;
|
|
448
|
+
const childIsMemo = (childByTag.get(a.tag)?.entry.wrappers ?? []).includes("memo");
|
|
449
|
+
const kind = ATTR_KIND_LABEL[a.valueKind] ?? "an unstable value";
|
|
450
|
+
findings.push({
|
|
451
|
+
rule: "perf/unstable-child-prop",
|
|
452
|
+
severity: "warn",
|
|
453
|
+
...at(a.pos, info.filePath),
|
|
454
|
+
message:
|
|
455
|
+
`${info.componentName} passes ${kind} as "${a.attr}" to <${a.tag}> — a new reference ` +
|
|
456
|
+
`on every render${childIsMemo ? MEMO_WRAPPED_NOTE : "."}`,
|
|
457
|
+
source: "perf-static",
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if ((info.state?.length ?? 0) === 0) return;
|
|
462
|
+
const stableIds = new Set([
|
|
463
|
+
...names.propNames,
|
|
464
|
+
...names.stateVars,
|
|
465
|
+
...names.setterNames,
|
|
466
|
+
...names.memoResultNames,
|
|
467
|
+
]);
|
|
468
|
+
for (const c of node.children) {
|
|
469
|
+
if (c.children.length !== 0) continue;
|
|
470
|
+
if ((c.entry.wrappers ?? []).includes("memo")) continue;
|
|
471
|
+
const tag = c.entry.localTag ?? c.entry.componentName;
|
|
472
|
+
const childAttrs = attrs.filter((a) => a.tag === tag);
|
|
473
|
+
// Only flag when every prop is provably stable; spreads, unstable kinds and
|
|
474
|
+
// identifiers we cannot attribute all bail (zero-attr children qualify).
|
|
475
|
+
const allStable = childAttrs.every(
|
|
476
|
+
(a) =>
|
|
477
|
+
a.valueKind !== "spread" &&
|
|
478
|
+
STABLE_ATTR_KINDS.has(a.valueKind) &&
|
|
479
|
+
(a.valueKind !== "identifier" ||
|
|
480
|
+
(a.identifier !== undefined && stableIds.has(a.identifier))),
|
|
481
|
+
);
|
|
482
|
+
if (!allStable) continue;
|
|
483
|
+
findings.push({
|
|
484
|
+
rule: "perf/missing-memo-leaf",
|
|
485
|
+
severity: "warn",
|
|
486
|
+
...at(childAttrs[0]?.pos ?? c.entry.pos, info.filePath),
|
|
487
|
+
message:
|
|
488
|
+
`<${tag}> is a leaf receiving only stable props from stateful ${info.componentName} ` +
|
|
489
|
+
`but is not React.memo-wrapped — every state change rerenders it for nothing.`,
|
|
490
|
+
source: "perf-static",
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function inspectRenderSetters(info: ComponentInfo, findings: Finding[]): void {
|
|
496
|
+
for (const s of info.state ?? []) {
|
|
497
|
+
for (const call of s.setterCalls) {
|
|
498
|
+
if (call.context !== "render" || call.guarded) continue;
|
|
499
|
+
findings.push({
|
|
500
|
+
rule: "perf/set-state-in-render",
|
|
501
|
+
severity: "error",
|
|
502
|
+
...at(call.pos, info.filePath),
|
|
503
|
+
message:
|
|
504
|
+
`${info.componentName} calls ${s.setterName ?? `the "${s.varName}" setter`}() ` +
|
|
505
|
+
`unguarded during render — this triggers an infinite re-render loop.`,
|
|
506
|
+
source: "perf-static",
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { Finding, RunContext, Task, TaskId, TaskResult } from "../types.ts";
|