@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,213 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { PropValue } from "../types.ts";
|
|
3
|
+
import { ownReturns } from "./ownReturns.ts";
|
|
4
|
+
import { literalToValue } from "./renderPath.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What each exported action of a "use server" module actually returns, when
|
|
8
|
+
* that can be read off the source without evaluating anything.
|
|
9
|
+
*
|
|
10
|
+
* The bail policy is literalToValue's, deliberately: a mock built from a wrong
|
|
11
|
+
* guess is worse than the honest {} stub, because the spec goes green against
|
|
12
|
+
* data the action can never produce. So a name is recorded only when every
|
|
13
|
+
* non-exit return collapses to ONE literal-derivable value:
|
|
14
|
+
*
|
|
15
|
+
* - `redirect(...)` / `permanentRedirect(...)` / `notFound()` returns are
|
|
16
|
+
* exits, not values — Next aborts rendering there, so they say nothing
|
|
17
|
+
* about what a caller receives. Only when the callee is provably
|
|
18
|
+
* next/navigation's (extractGuards learned this first): a project-local
|
|
19
|
+
* redirect() that RETURNS something would otherwise have its value
|
|
20
|
+
* silently discounted and the recorded literal would disagree with what
|
|
21
|
+
* the action really returns.
|
|
22
|
+
* - Two distinct literal values bail (which one would the mock pick?);
|
|
23
|
+
* byte-identical duplicates from two branches collapse fine.
|
|
24
|
+
* - Any non-literal return bails the whole name, even beside a literal one:
|
|
25
|
+
* a literal happy path next to a computed error path is two shapes, and
|
|
26
|
+
* mocking just the literal hides the one the component crashes on.
|
|
27
|
+
* - A bare `return;` is a value ({} ≠ undefined) the mock cannot express,
|
|
28
|
+
* so it bails too.
|
|
29
|
+
*
|
|
30
|
+
* Keyed by MODULE-side export name ("default" for the default export) —
|
|
31
|
+
* that is the name a vi.mock factory or a host stub has to provide.
|
|
32
|
+
*/
|
|
33
|
+
export function extractServerActionReturns(sf: ts.SourceFile): Record<string, PropValue> {
|
|
34
|
+
const exits = exitBindings(sf);
|
|
35
|
+
const out: Record<string, PropValue> = {};
|
|
36
|
+
for (const [name, fn] of exportedFunctions(sf)) {
|
|
37
|
+
const value = singleReturnValue(fn, exits);
|
|
38
|
+
if (value !== undefined) out[name] = value;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const EXIT_CALLEES = new Set(["redirect", "permanentRedirect", "notFound"]);
|
|
44
|
+
|
|
45
|
+
interface ExitBindings {
|
|
46
|
+
/** Local names bound to a next/navigation exit, aliases resolved. */
|
|
47
|
+
locals: Set<string>;
|
|
48
|
+
/** Namespace locals of next/navigation, for `nav.redirect(...)`. */
|
|
49
|
+
namespaces: Set<string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function exitBindings(sf: ts.SourceFile): ExitBindings {
|
|
53
|
+
const locals = new Set<string>();
|
|
54
|
+
const namespaces = new Set<string>();
|
|
55
|
+
for (const stmt of sf.statements) {
|
|
56
|
+
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
57
|
+
if (stmt.moduleSpecifier.text !== "next/navigation") continue;
|
|
58
|
+
const bindings = stmt.importClause?.namedBindings;
|
|
59
|
+
if (!bindings) continue;
|
|
60
|
+
if (ts.isNamedImports(bindings)) {
|
|
61
|
+
for (const el of bindings.elements) {
|
|
62
|
+
if (EXIT_CALLEES.has((el.propertyName ?? el.name).text)) locals.add(el.name.text);
|
|
63
|
+
}
|
|
64
|
+
} else if (ts.isNamespaceImport(bindings)) {
|
|
65
|
+
namespaces.add(bindings.name.text);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { locals, namespaces };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isExitCall(expr: ts.Expression, exits: ExitBindings): boolean {
|
|
72
|
+
if (!ts.isCallExpression(expr)) return false;
|
|
73
|
+
const callee = expr.expression;
|
|
74
|
+
if (ts.isIdentifier(callee)) return exits.locals.has(callee.text);
|
|
75
|
+
return (
|
|
76
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
77
|
+
ts.isIdentifier(callee.expression) &&
|
|
78
|
+
exits.namespaces.has(callee.expression.text) &&
|
|
79
|
+
EXIT_CALLEES.has(callee.name.text)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type ActionFn = ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Calls that hand back their function argument with the SAME call signature.
|
|
87
|
+
*
|
|
88
|
+
* `export const getCurrentUser = cache(async (): Promise<User> => …)` is the
|
|
89
|
+
* standard Next.js DAL idiom — the dogfood app's `lib/dal.ts` uses it three
|
|
90
|
+
* times — and a `CallExpression` initializer was skipped outright, so every
|
|
91
|
+
* consumer of this list saw the module as having no such export. Downstream that
|
|
92
|
+
* is a `{}` mock plus an uninferred-return TODO on a function whose return type
|
|
93
|
+
* is written down two characters away.
|
|
94
|
+
*
|
|
95
|
+
* A CLOSED table, not "any call with one function argument", because the reason
|
|
96
|
+
* for the original skip is real for most wrappers: `withPagination(async ():
|
|
97
|
+
* Promise<User[]> => …)` may well return `Promise<Page<User>>`, and unwrapping it
|
|
98
|
+
* hands the emitter the inner shape for a function that never produces it — a
|
|
99
|
+
* wrong fixture, which this repo treats as worse than none.
|
|
100
|
+
*
|
|
101
|
+
* These two are typed `<T>(fn: T) => T` and `(cb, keyParts?, opts?) => typeof cb`
|
|
102
|
+
* respectively, so for them the inner annotation IS the effective return type.
|
|
103
|
+
* Widening this set means showing the same of whatever is added — there is no
|
|
104
|
+
* type checker here to work it out.
|
|
105
|
+
*/
|
|
106
|
+
const IDENTITY_WRAPPERS = new Set(["cache", "unstable_cache"]);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The function inside an allowlisted wrapper call, or undefined.
|
|
110
|
+
*
|
|
111
|
+
* Exactly one function-like argument, so `unstable_cache(fn, keyParts, opts)`
|
|
112
|
+
* resolves (only argument 0 is function-like) while anything passing two
|
|
113
|
+
* callbacks bails rather than pick one.
|
|
114
|
+
*/
|
|
115
|
+
function unwrapIdentityWrapper(init: ts.Expression): ActionFn | undefined {
|
|
116
|
+
if (!ts.isCallExpression(init)) return undefined;
|
|
117
|
+
// `cache(…)` and `React.cache(…)` both, keyed on the member name — the import
|
|
118
|
+
// could be aliased either way and this list is about the callee's identity.
|
|
119
|
+
const callee = init.expression;
|
|
120
|
+
const name = ts.isIdentifier(callee)
|
|
121
|
+
? callee.text
|
|
122
|
+
: ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)
|
|
123
|
+
? callee.name.text
|
|
124
|
+
: undefined;
|
|
125
|
+
if (name === undefined || !IDENTITY_WRAPPERS.has(name)) return undefined;
|
|
126
|
+
const fns = init.arguments.filter(
|
|
127
|
+
(a): a is ts.ArrowFunction | ts.FunctionExpression =>
|
|
128
|
+
ts.isArrowFunction(a) || ts.isFunctionExpression(a),
|
|
129
|
+
);
|
|
130
|
+
return fns.length === 1 ? fns[0] : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Exported name → its function node, local declarations resolved in-file. */
|
|
134
|
+
export function exportedFunctions(sf: ts.SourceFile): Array<[string, ActionFn]> {
|
|
135
|
+
// Every top-level function-ish declaration, exported or not: an export
|
|
136
|
+
// clause (`export { doSave as save }`) points at a local one.
|
|
137
|
+
const locals = new Map<string, ActionFn>();
|
|
138
|
+
const out: Array<[string, ActionFn]> = [];
|
|
139
|
+
for (const stmt of sf.statements) {
|
|
140
|
+
const modifiers = ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined;
|
|
141
|
+
const exported = modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
|
142
|
+
const isDefault = modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ?? false;
|
|
143
|
+
if (ts.isFunctionDeclaration(stmt)) {
|
|
144
|
+
if (stmt.name) locals.set(stmt.name.text, stmt);
|
|
145
|
+
if (exported && isDefault) out.push(["default", stmt]);
|
|
146
|
+
else if (exported && stmt.name) out.push([stmt.name.text, stmt]);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (ts.isVariableStatement(stmt)) {
|
|
150
|
+
for (const d of stmt.declarationList.declarations) {
|
|
151
|
+
if (!ts.isIdentifier(d.name) || d.initializer === undefined) continue;
|
|
152
|
+
const init = d.initializer;
|
|
153
|
+
const fn =
|
|
154
|
+
ts.isArrowFunction(init) || ts.isFunctionExpression(init)
|
|
155
|
+
? init
|
|
156
|
+
: unwrapIdentityWrapper(init);
|
|
157
|
+
if (!fn) continue;
|
|
158
|
+
locals.set(d.name.text, fn);
|
|
159
|
+
if (exported) out.push([d.name.text, fn]);
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
// `export { doSave as save }` — the exported name is what importers bind
|
|
164
|
+
// to. A re-export from another file has no local to resolve; skipped
|
|
165
|
+
// HERE — sourceCache's findExportedDecl is the cross-file chase, and
|
|
166
|
+
// callers that want it (resolveDepReturns) walk barrels through that.
|
|
167
|
+
if (
|
|
168
|
+
ts.isExportDeclaration(stmt) &&
|
|
169
|
+
stmt.moduleSpecifier === undefined &&
|
|
170
|
+
stmt.exportClause &&
|
|
171
|
+
ts.isNamedExports(stmt.exportClause)
|
|
172
|
+
) {
|
|
173
|
+
for (const el of stmt.exportClause.elements) {
|
|
174
|
+
const local = locals.get((el.propertyName ?? el.name).text);
|
|
175
|
+
if (local) out.push([el.name.text, local]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The one literal value every non-exit return yields, else undefined. */
|
|
183
|
+
function singleReturnValue(fn: ActionFn, exits: ExitBindings): PropValue | undefined {
|
|
184
|
+
// A generator's `return` sets the iterator's final result — calling the
|
|
185
|
+
// action yields an AsyncGenerator object, never a promise of that literal.
|
|
186
|
+
if (!ts.isArrowFunction(fn) && fn.asteriskToken) return undefined;
|
|
187
|
+
// A concise arrow body IS the single return expression.
|
|
188
|
+
if (ts.isArrowFunction(fn) && !ts.isBlock(fn.body)) {
|
|
189
|
+
const expr = unparenthesize(fn.body);
|
|
190
|
+
return isExitCall(expr, exits) ? undefined : literalToValue(expr);
|
|
191
|
+
}
|
|
192
|
+
if (!fn.body || !ts.isBlock(fn.body)) return undefined;
|
|
193
|
+
|
|
194
|
+
let value: PropValue | undefined;
|
|
195
|
+
let seen = false;
|
|
196
|
+
for (const ret of ownReturns(fn.body)) {
|
|
197
|
+
const expr = ret.expression === undefined ? undefined : unparenthesize(ret.expression);
|
|
198
|
+
if (expr !== undefined && isExitCall(expr, exits)) continue;
|
|
199
|
+
// Bare `return;` or a non-literal expression: a value we cannot express.
|
|
200
|
+
const v = expr === undefined ? undefined : literalToValue(expr);
|
|
201
|
+
if (v === undefined) return undefined;
|
|
202
|
+
if (seen && JSON.stringify(v) !== JSON.stringify(value)) return undefined;
|
|
203
|
+
value = v;
|
|
204
|
+
seen = true;
|
|
205
|
+
}
|
|
206
|
+
return seen ? value : undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function unparenthesize(expr: ts.Expression): ts.Expression {
|
|
210
|
+
let e = expr;
|
|
211
|
+
while (ts.isParenthesizedExpression(e)) e = e.expression;
|
|
212
|
+
return e;
|
|
213
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parsed-source cache for the secondary files the analyser reads while
|
|
6
|
+
* resolving types across modules. Without it, every `ComponentProps<typeof X>`
|
|
7
|
+
* and every imported props type re-reads and re-parses the same file — the
|
|
8
|
+
* shadcn pattern makes that happen dozens of times per run.
|
|
9
|
+
*
|
|
10
|
+
* Failures are cached as misses too, so an unreadable path is attempted once.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The leading prologue directive of a module, tolerating a license header or
|
|
14
|
+
* any run of comments before it. Shared so "use client" and "use server" cannot
|
|
15
|
+
* drift apart.
|
|
16
|
+
*
|
|
17
|
+
* The scan is head-bounded, deliberately: this runs once per IMPORTED module
|
|
18
|
+
* in a sweep, not once per file, and full-parsing to find a one-line directive
|
|
19
|
+
* would multiply the parse count by the import fan-out. 64KB covers any
|
|
20
|
+
* realistic license banner (the longest common ones run ~2–4KB) with slack; a
|
|
21
|
+
* directive hidden behind a LARGER header is the acknowledged miss of the
|
|
22
|
+
* bound — misclassified as not carrying it — and would need the bound raised,
|
|
23
|
+
* not the regex changed.
|
|
24
|
+
*/
|
|
25
|
+
export function readPrologueDirective(file: string): string | undefined {
|
|
26
|
+
try {
|
|
27
|
+
const head = readFileSync(file, "utf8").slice(0, 65536);
|
|
28
|
+
const m = /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*["']([^"']+)["']\s*;?/.exec(head);
|
|
29
|
+
return m?.[1];
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Top-level export names of a module.
|
|
37
|
+
*
|
|
38
|
+
* Needed to stub a "use server" module for the browser bundle: exporting only
|
|
39
|
+
* the names a component imports directly leaves a transitive importer — the
|
|
40
|
+
* zustand store importing logoutAction, say — with "No matching export", and the
|
|
41
|
+
* bundle fails just as hard as it did unstubbed.
|
|
42
|
+
*/
|
|
43
|
+
export function exportedNames(sf: ts.SourceFile): string[] {
|
|
44
|
+
const out = new Set<string>();
|
|
45
|
+
for (const stmt of sf.statements) {
|
|
46
|
+
const exported = ts.canHaveModifiers(stmt)
|
|
47
|
+
? ts.getModifiers(stmt)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
|
|
48
|
+
: false;
|
|
49
|
+
if (ts.isVariableStatement(stmt) && exported) {
|
|
50
|
+
for (const d of stmt.declarationList.declarations) {
|
|
51
|
+
if (ts.isIdentifier(d.name)) out.add(d.name.text);
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if ((ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt)) && exported) {
|
|
56
|
+
// `export default function doSave()` exports "default", not "doSave" —
|
|
57
|
+
// the name is only local. Recording the name handed the server-action
|
|
58
|
+
// stub a named export the module never had, so a real default import
|
|
59
|
+
// failed to bind against it.
|
|
60
|
+
const isDefault = ts.getModifiers(stmt)?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
|
|
61
|
+
if (isDefault) out.add("default");
|
|
62
|
+
else if (stmt.name) out.add(stmt.name.text);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
// `export { a, b as c }` — the exported name is what importers bind to.
|
|
66
|
+
if (ts.isExportDeclaration(stmt) && stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
|
|
67
|
+
for (const el of stmt.exportClause.elements) out.add(el.name.text);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...out].sort();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The module and module-side name a local binding was imported under.
|
|
75
|
+
*
|
|
76
|
+
* `import type { User as U } from "./x"` looked up as "U" gives
|
|
77
|
+
* `{ specifier: "./x", importedName: "User" }`. Default and namespace imports
|
|
78
|
+
* are not reported: neither names a single exported declaration this chase can
|
|
79
|
+
* continue to.
|
|
80
|
+
*/
|
|
81
|
+
function importedFrom(
|
|
82
|
+
sf: ts.SourceFile,
|
|
83
|
+
local: string,
|
|
84
|
+
): { specifier: string; importedName: string } | undefined {
|
|
85
|
+
for (const stmt of sf.statements) {
|
|
86
|
+
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
87
|
+
const bindings = stmt.importClause?.namedBindings;
|
|
88
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
89
|
+
for (const el of bindings.elements) {
|
|
90
|
+
if (el.name.text !== local) continue;
|
|
91
|
+
return {
|
|
92
|
+
specifier: stmt.moduleSpecifier.text,
|
|
93
|
+
importedName: (el.propertyName ?? el.name).text,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Where a re-export chase landed: the declaring file and its parse. */
|
|
101
|
+
export interface RelocatedExport<T> {
|
|
102
|
+
sf: ts.SourceFile;
|
|
103
|
+
file: string;
|
|
104
|
+
decl: T;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Barrel chains deeper than this are machine-generated; stop looking. */
|
|
108
|
+
const MAX_RELOCATION_DEPTH = 8;
|
|
109
|
+
/** Runaway backstop across the whole walk — cycles are caught by `visited`,
|
|
110
|
+
* so this only stops pathological generated fan-outs. It counts file::name
|
|
111
|
+
* pairs, NOT breadth per level: a wide shadcn-style barrel (40+ star arms)
|
|
112
|
+
* must still reach its last arm. */
|
|
113
|
+
const MAX_RELOCATION_FILES = 512;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Find `name`'s declaration starting at `file`, chasing barrel re-exports.
|
|
117
|
+
* ESM semantics drive the order inside each file: a matching declaration
|
|
118
|
+
* first, then local export clauses (`export { Real as Renamed };` — the
|
|
119
|
+
* clause itself makes the local declaration exported), then explicit
|
|
120
|
+
* re-export clauses (`export { A as B } from "./x"`, rename-aware — these
|
|
121
|
+
* SHADOW star exports), then star re-exports (`export * from "./x"`) in
|
|
122
|
+
* declaration order. `export * as ns from` exports one namespace object,
|
|
123
|
+
* never the names behind it, and is not followed.
|
|
124
|
+
*
|
|
125
|
+
* `find` receives `viaReexport: true` for every file reached through a
|
|
126
|
+
* re-export chain — matchers use it to refuse non-exported declarations,
|
|
127
|
+
* which a star chase must never bind (a private same-named interface in an
|
|
128
|
+
* earlier arm would silently produce a wrong-shape fixture).
|
|
129
|
+
*
|
|
130
|
+
* Depth-bounded and cycle-guarded, deliberately NOT the props hop budget:
|
|
131
|
+
* component → barrel → types.ts would exhaust MAX_HOPS before a single
|
|
132
|
+
* member resolved, and relocation is finding where a name LIVES, not a
|
|
133
|
+
* semantic resolution hop.
|
|
134
|
+
*/
|
|
135
|
+
export function findExportedDecl<T>(
|
|
136
|
+
file: string,
|
|
137
|
+
name: string,
|
|
138
|
+
cache: SourceFileCache,
|
|
139
|
+
resolver: { resolve(spec: string, importer: string): string | undefined } | undefined,
|
|
140
|
+
find: (sf: ts.SourceFile, name: string, viaReexport: boolean) => T | undefined,
|
|
141
|
+
// For lookups reached through an IMPORT binding, the root file must vouch
|
|
142
|
+
// only for its exports too: TS never binds a private declaration to an
|
|
143
|
+
// import, so a private same-named decl in the directly-resolved module
|
|
144
|
+
// shadowing a star re-export would produce a type-illegal result. Local
|
|
145
|
+
// export clauses stay permissive — the clause itself vouches.
|
|
146
|
+
requireExportAtRoot = false,
|
|
147
|
+
): RelocatedExport<T> | undefined {
|
|
148
|
+
const visited = new Set<string>();
|
|
149
|
+
const walk = (f: string, wanted: string, depth: number): RelocatedExport<T> | undefined => {
|
|
150
|
+
const key = `${f}::${wanted}`;
|
|
151
|
+
if (visited.has(key) || depth > MAX_RELOCATION_DEPTH || visited.size >= MAX_RELOCATION_FILES)
|
|
152
|
+
return undefined;
|
|
153
|
+
visited.add(key);
|
|
154
|
+
const sf = cache.get(f);
|
|
155
|
+
if (!sf) return undefined;
|
|
156
|
+
const hit = find(sf, wanted, depth > 0 || requireExportAtRoot);
|
|
157
|
+
if (hit !== undefined) return { sf, file: f, decl: hit };
|
|
158
|
+
|
|
159
|
+
const localClauses: ts.NamedExports[] = [];
|
|
160
|
+
const named: Array<{ clause: ts.NamedExports; spec: string }> = [];
|
|
161
|
+
const stars: string[] = [];
|
|
162
|
+
for (const stmt of sf.statements) {
|
|
163
|
+
if (!ts.isExportDeclaration(stmt)) continue;
|
|
164
|
+
if (!stmt.moduleSpecifier) {
|
|
165
|
+
if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
|
|
166
|
+
localClauses.push(stmt.exportClause);
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
171
|
+
if (stmt.exportClause) {
|
|
172
|
+
if (ts.isNamedExports(stmt.exportClause)) {
|
|
173
|
+
named.push({ clause: stmt.exportClause, spec: stmt.moduleSpecifier.text });
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
stars.push(stmt.moduleSpecifier.text);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const clause of localClauses) {
|
|
181
|
+
for (const el of clause.elements) {
|
|
182
|
+
if (el.name.text !== wanted) continue;
|
|
183
|
+
const local = (el.propertyName ?? el.name).text;
|
|
184
|
+
const localHit = find(sf, local, false);
|
|
185
|
+
if (localHit !== undefined) return { sf, file: f, decl: localHit };
|
|
186
|
+
// No local DECLARATION of that name — but the file may have imported it
|
|
187
|
+
// and re-exported the binding:
|
|
188
|
+
//
|
|
189
|
+
// import type { User } from "@scope/shared";
|
|
190
|
+
// export type { User };
|
|
191
|
+
//
|
|
192
|
+
// ESM treats that as an export of this module, and it is how a package
|
|
193
|
+
// republishes a type it does not own. The chase stopped here, so a
|
|
194
|
+
// function annotated `Promise<User>` behind such a file resolved to
|
|
195
|
+
// nothing — which on the dogfood app is every export of lib/dal.ts.
|
|
196
|
+
const from = importedFrom(sf, local);
|
|
197
|
+
if (from) {
|
|
198
|
+
const target = resolver?.resolve(from.specifier, f);
|
|
199
|
+
if (target) {
|
|
200
|
+
const relocated = walk(target, from.importedName, depth + 1);
|
|
201
|
+
if (relocated) return relocated;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const { clause, spec } of named) {
|
|
207
|
+
for (const el of clause.elements) {
|
|
208
|
+
if (el.name.text !== wanted) continue;
|
|
209
|
+
const target = resolver?.resolve(spec, f);
|
|
210
|
+
if (!target) continue;
|
|
211
|
+
const relocated = walk(target, (el.propertyName ?? el.name).text, depth + 1);
|
|
212
|
+
if (relocated) return relocated;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const spec of stars) {
|
|
216
|
+
const target = resolver?.resolve(spec, f);
|
|
217
|
+
if (!target) continue;
|
|
218
|
+
const relocated = walk(target, wanted, depth + 1);
|
|
219
|
+
if (relocated) return relocated;
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
};
|
|
223
|
+
return walk(file, name, 0);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export class SourceFileCache {
|
|
227
|
+
private files = new Map<string, ts.SourceFile | null>();
|
|
228
|
+
private directives = new Map<string, string | null>();
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* True iff `abs` opens with the given prologue directive. Reads the file head
|
|
232
|
+
* only — full-parsing every imported module to answer this would multiply the
|
|
233
|
+
* parse count by the import fan-out of every entry file in a sweep.
|
|
234
|
+
*/
|
|
235
|
+
hasPrologueDirective(abs: string, directive: string): boolean {
|
|
236
|
+
let hit = this.directives.get(abs);
|
|
237
|
+
if (hit === undefined) {
|
|
238
|
+
hit = readPrologueDirective(abs) ?? null;
|
|
239
|
+
this.directives.set(abs, hit);
|
|
240
|
+
}
|
|
241
|
+
return hit === directive;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Parse `abs` as TSX. Undefined on any read/parse failure. */
|
|
245
|
+
get(abs: string): ts.SourceFile | undefined {
|
|
246
|
+
const hit = this.files.get(abs);
|
|
247
|
+
if (hit !== undefined) return hit ?? undefined;
|
|
248
|
+
let sf: ts.SourceFile | null = null;
|
|
249
|
+
try {
|
|
250
|
+
sf = ts.createSourceFile(
|
|
251
|
+
abs,
|
|
252
|
+
readFileSync(abs, "utf8"),
|
|
253
|
+
ts.ScriptTarget.ES2022,
|
|
254
|
+
true,
|
|
255
|
+
ts.ScriptKind.TSX,
|
|
256
|
+
);
|
|
257
|
+
} catch {
|
|
258
|
+
sf = null;
|
|
259
|
+
}
|
|
260
|
+
this.files.set(abs, sf);
|
|
261
|
+
return sf ?? undefined;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { PropUsage } from "../types.ts";
|
|
3
|
+
import type { ParsedComponentUnit } from "./parseFile.ts";
|
|
4
|
+
import { posOf } from "./pos.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Props spread into a same-file sibling are READ there. NavLink's
|
|
8
|
+
* `Props<T> = Omit<...> & {...}` defeats the syntactic type layer — nothing
|
|
9
|
+
* resolves, nothing destructures — and NavLink's own body only forwards
|
|
10
|
+
* `props` to NavLinkInner, where `props.href.toString()` dies on
|
|
11
|
+
* `undefined.toString` in the default-mount spec. When a component hands its
|
|
12
|
+
* props param to components defined in the same file, those siblings' prop
|
|
13
|
+
* names and member reads become evidence for the {} floor in
|
|
14
|
+
* synthesiseDefaults. Transitively: NavLink → NavLinkInner → NavLinkShell.
|
|
15
|
+
*/
|
|
16
|
+
export function inheritSpreadSiblingProps(
|
|
17
|
+
unit: ParsedComponentUnit,
|
|
18
|
+
siblings: readonly ParsedComponentUnit[],
|
|
19
|
+
known: ReadonlySet<string>,
|
|
20
|
+
unresolvedProps: string[],
|
|
21
|
+
propUsage: PropUsage[],
|
|
22
|
+
sourceFile: ts.SourceFile,
|
|
23
|
+
): void {
|
|
24
|
+
const byName = new Map<string, ParsedComponentUnit>();
|
|
25
|
+
for (const c of siblings) if (!byName.has(c.componentName)) byName.set(c.componentName, c);
|
|
26
|
+
|
|
27
|
+
const claim = (name: string, member: string | undefined, node: ts.Node): void => {
|
|
28
|
+
// These two never get a value floor: `{}` renders "[object Object]" as a
|
|
29
|
+
// className and is an invalid child as children. Left undefined, they
|
|
30
|
+
// render as nothing — which is what a defaults-only mount expects.
|
|
31
|
+
if (name === "children" || name === "className") return;
|
|
32
|
+
if (known.has(name)) return;
|
|
33
|
+
if (!unresolvedProps.includes(name)) unresolvedProps.push(name);
|
|
34
|
+
if (member !== undefined) {
|
|
35
|
+
propUsage.push({
|
|
36
|
+
propName: name,
|
|
37
|
+
kind: "member-access",
|
|
38
|
+
member,
|
|
39
|
+
pos: posOf(node, sourceFile),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const queue: ParsedComponentUnit[] = [unit];
|
|
45
|
+
const seen = new Set<ParsedComponentUnit>([unit]);
|
|
46
|
+
|
|
47
|
+
const scan = (c: ParsedComponentUnit): void => {
|
|
48
|
+
const visit = (n: ts.Node): void => {
|
|
49
|
+
if (c.paramName !== undefined) {
|
|
50
|
+
if (
|
|
51
|
+
ts.isJsxSpreadAttribute(n) &&
|
|
52
|
+
ts.isIdentifier(n.expression) &&
|
|
53
|
+
n.expression.text === c.paramName
|
|
54
|
+
) {
|
|
55
|
+
const owner = n.parent?.parent;
|
|
56
|
+
const tag =
|
|
57
|
+
owner && (ts.isJsxOpeningElement(owner) || ts.isJsxSelfClosingElement(owner))
|
|
58
|
+
? owner.tagName
|
|
59
|
+
: undefined;
|
|
60
|
+
if (tag !== undefined && ts.isIdentifier(tag)) {
|
|
61
|
+
const target = byName.get(tag.text);
|
|
62
|
+
if (target !== undefined && !seen.has(target)) {
|
|
63
|
+
seen.add(target);
|
|
64
|
+
queue.push(target);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression)) {
|
|
70
|
+
if (n.expression.text === c.paramName) {
|
|
71
|
+
// props.href — and props.href.toString() reads a member off it.
|
|
72
|
+
const member =
|
|
73
|
+
n.parent !== undefined &&
|
|
74
|
+
ts.isPropertyAccessExpression(n.parent) &&
|
|
75
|
+
n.parent.expression === n
|
|
76
|
+
? n.parent.name.text
|
|
77
|
+
: undefined;
|
|
78
|
+
claim(n.name.text, member, n);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// href.toString() on the component's OWN destructured binding — the
|
|
84
|
+
// sibling that destructures (NavLinkShell) carries the same evidence.
|
|
85
|
+
if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression)) {
|
|
86
|
+
if (c.destructuredProps.includes(n.expression.text)) {
|
|
87
|
+
claim(c.destructuredAliases.get(n.expression.text) ?? n.expression.text, n.name.text, n);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
ts.forEachChild(n, visit);
|
|
91
|
+
};
|
|
92
|
+
ts.forEachChild(c.bodyNode, visit);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
while (queue.length > 0) {
|
|
96
|
+
scan(queue.shift() as ParsedComponentUnit);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { SourcePosition } from "../types.ts";
|
|
3
|
+
import type { ImportBinding } from "./parseFile.ts";
|
|
4
|
+
import { posOf } from "./pos.ts";
|
|
5
|
+
import type { ModuleResolver } from "./resolveModule.ts";
|
|
6
|
+
|
|
7
|
+
export interface ChildRef {
|
|
8
|
+
/** Full tag as written in JSX: "Card", "UI.Badge". */
|
|
9
|
+
localName: string;
|
|
10
|
+
/** Absolute path to the module holding the component. */
|
|
11
|
+
absPath: string;
|
|
12
|
+
/** Preferred component name inside the target file (named import / member tag). */
|
|
13
|
+
exportedName?: string;
|
|
14
|
+
/** First JSX usage site. */
|
|
15
|
+
pos: SourcePosition;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Walk JSX in `bodyNode`, collect referenced child components that resolve to a
|
|
20
|
+
* project-local module — via relative import, tsconfig path alias, or a
|
|
21
|
+
* same-file component declaration. Bare package imports are skipped.
|
|
22
|
+
*/
|
|
23
|
+
export function walkChildren(
|
|
24
|
+
bodyNode: ts.Node,
|
|
25
|
+
imports: Map<string, ImportBinding>,
|
|
26
|
+
entryFile: string,
|
|
27
|
+
sourceFile: ts.SourceFile,
|
|
28
|
+
resolver: ModuleResolver,
|
|
29
|
+
localComponents: Set<string>,
|
|
30
|
+
selfName: string,
|
|
31
|
+
): ChildRef[] {
|
|
32
|
+
const seen = new Set<string>();
|
|
33
|
+
const out: ChildRef[] = [];
|
|
34
|
+
|
|
35
|
+
const visit = (n: ts.Node): void => {
|
|
36
|
+
if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
|
|
37
|
+
const tag = n.tagName;
|
|
38
|
+
let rootName: string | undefined;
|
|
39
|
+
let fullName: string | undefined;
|
|
40
|
+
let memberName: string | undefined;
|
|
41
|
+
|
|
42
|
+
if (ts.isIdentifier(tag)) {
|
|
43
|
+
rootName = tag.text;
|
|
44
|
+
fullName = tag.text;
|
|
45
|
+
} else if (ts.isPropertyAccessExpression(tag)) {
|
|
46
|
+
let root: ts.Expression = tag.expression;
|
|
47
|
+
const segments: string[] = [tag.name.text];
|
|
48
|
+
while (ts.isPropertyAccessExpression(root)) {
|
|
49
|
+
segments.unshift(root.name.text);
|
|
50
|
+
root = root.expression;
|
|
51
|
+
}
|
|
52
|
+
if (ts.isIdentifier(root)) {
|
|
53
|
+
rootName = root.text;
|
|
54
|
+
fullName = `${root.text}.${segments.join(".")}`;
|
|
55
|
+
memberName = segments[segments.length - 1];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (rootName && fullName && /^[A-Z]/.test(rootName) && !seen.has(fullName)) {
|
|
60
|
+
const binding = imports.get(rootName);
|
|
61
|
+
if (binding && !binding.isTypeOnly) {
|
|
62
|
+
const abs = resolver.resolve(binding.specifier, entryFile);
|
|
63
|
+
if (abs) {
|
|
64
|
+
seen.add(fullName);
|
|
65
|
+
out.push({
|
|
66
|
+
localName: fullName,
|
|
67
|
+
absPath: abs,
|
|
68
|
+
exportedName: resolveExportedName(binding, rootName, memberName),
|
|
69
|
+
pos: posOf(n, sourceFile),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
} else if (!binding && localComponents.has(rootName) && rootName !== selfName) {
|
|
73
|
+
// Same-file secondary component.
|
|
74
|
+
seen.add(fullName);
|
|
75
|
+
out.push({
|
|
76
|
+
localName: fullName,
|
|
77
|
+
absPath: entryFile,
|
|
78
|
+
exportedName: rootName,
|
|
79
|
+
pos: posOf(n, sourceFile),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
ts.forEachChild(n, visit);
|
|
85
|
+
};
|
|
86
|
+
ts.forEachChild(bodyNode, visit);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveExportedName(
|
|
91
|
+
binding: ImportBinding,
|
|
92
|
+
rootName: string,
|
|
93
|
+
memberName: string | undefined,
|
|
94
|
+
): string | undefined {
|
|
95
|
+
if (binding.kind === "namespace") return memberName;
|
|
96
|
+
if (binding.kind === "named") return binding.importedName ?? rootName;
|
|
97
|
+
return undefined; // default import — primary component of the target file
|
|
98
|
+
}
|