@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,370 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { DataDepInfo, PropValue } from "../types.ts";
|
|
3
|
+
import { extractFetchCalls } from "./extractFetchCalls.ts";
|
|
4
|
+
import { resolveTypeNodeValue } from "./extractProps.ts";
|
|
5
|
+
import { ownReturns } from "./ownReturns.ts";
|
|
6
|
+
import type { ImportBinding } from "./parseFile.ts";
|
|
7
|
+
import type { ModuleResolver } from "./resolveModule.ts";
|
|
8
|
+
import { exportedFunctions } from "./serverActionReturns.ts";
|
|
9
|
+
import { findExportedDecl, type SourceFileCache } from "./sourceCache.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Second-chance enrichment for data deps whose USAGE told us nothing: when
|
|
13
|
+
* the component only hands the awaited value onward, extractDataDeps lands
|
|
14
|
+
* on unknown/uncertain and the mock is an honest {} plus a TODO. This pass
|
|
15
|
+
* reads the dep function's own DECLARATION instead:
|
|
16
|
+
*
|
|
17
|
+
* - Path A: a `Promise<T>` return annotation — T resolved to one concrete
|
|
18
|
+
* fixture value (resolveTypeNodeValue's bail-on-any-hole policy), stored
|
|
19
|
+
* as `inferredReturn` with provenance "annotation".
|
|
20
|
+
* - Path B: an UNANNOTATED wrapper whose body performs exactly one
|
|
21
|
+
* fetch() — recorded as `wrapsFetch` (method + path). The join to the
|
|
22
|
+
* OpenAPI response example happens at emit time, where the route table
|
|
23
|
+
* lives; provenance there is "openapi".
|
|
24
|
+
*
|
|
25
|
+
* Runs only for uncertain/unknown deps — a usage-derived shape is the
|
|
26
|
+
* component's own testimony and outranks any declaration. Dotted callees
|
|
27
|
+
* (`db.query.findMany`) stay TODOs: chasing member functions is FR-014,
|
|
28
|
+
* deliberately deferred.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveDepReturns(
|
|
31
|
+
deps: DataDepInfo[],
|
|
32
|
+
imports: Map<string, ImportBinding>,
|
|
33
|
+
file: string,
|
|
34
|
+
resolver: ModuleResolver,
|
|
35
|
+
cache: SourceFileCache,
|
|
36
|
+
): DataDepInfo[] {
|
|
37
|
+
return deps.map((dep) => {
|
|
38
|
+
if (!dep.uncertain && dep.shape.kind !== "unknown") return dep;
|
|
39
|
+
if (dep.callee !== dep.calleeRoot) return dep;
|
|
40
|
+
const binding = imports.get(dep.calleeRoot);
|
|
41
|
+
if (!binding || binding.isTypeOnly || binding.kind === "namespace") return dep;
|
|
42
|
+
const target = resolver.resolve(binding.specifier, file);
|
|
43
|
+
if (!target) return dep;
|
|
44
|
+
// A "use server" module's mock is serverActionMock's channel, and it
|
|
45
|
+
// never reads dataDeps — enriching a dep here would suppress the RSC
|
|
46
|
+
// skip escape for a fixture that is never emitted, turning the honest
|
|
47
|
+
// skip into a false RED that blames the application.
|
|
48
|
+
if (cache.hasPrologueDirective(target, "use server")) return dep;
|
|
49
|
+
const wanted =
|
|
50
|
+
binding.kind === "default" ? "default" : (binding.importedName ?? dep.calleeRoot);
|
|
51
|
+
// Barrel-aware: chase re-exports to the DECLARING file, so an annotation
|
|
52
|
+
// behind `export * from "./api"` still enriches the dep.
|
|
53
|
+
const located = findExportedDecl(
|
|
54
|
+
target,
|
|
55
|
+
wanted,
|
|
56
|
+
cache,
|
|
57
|
+
resolver,
|
|
58
|
+
// exportedFunctions already lists only exported names, so the root
|
|
59
|
+
// flag is moot here — passed for uniformity of the import-reached rule.
|
|
60
|
+
(sf, name) => new Map(exportedFunctions(sf)).get(name),
|
|
61
|
+
true,
|
|
62
|
+
);
|
|
63
|
+
if (!located) return dep;
|
|
64
|
+
const { sf, file: declFile, decl: fn } = located;
|
|
65
|
+
// The barrel itself rarely carries the directive — the declaring module
|
|
66
|
+
// is what decides whether this is serverActionMock's channel.
|
|
67
|
+
if (declFile !== target && cache.hasPrologueDirective(declFile, "use server")) return dep;
|
|
68
|
+
|
|
69
|
+
const promiseArg = promiseTypeArg(fn.type);
|
|
70
|
+
if (promiseArg) {
|
|
71
|
+
const value = resolveTypeNodeValue(promiseArg, sf, declFile, resolver, cache);
|
|
72
|
+
if (value === undefined) return dep;
|
|
73
|
+
return {
|
|
74
|
+
...dep,
|
|
75
|
+
inferredReturn: {
|
|
76
|
+
provenance: "annotation" as const,
|
|
77
|
+
value,
|
|
78
|
+
...annotationFlags(promiseArg),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// A sync annotation on an AWAITED dep is somebody's refactor in progress —
|
|
83
|
+
// not a thing to build a fixture on, and the original bail stands for it.
|
|
84
|
+
//
|
|
85
|
+
// On a dep that is NOT awaited, the same annotation is simply the truth.
|
|
86
|
+
// Refusing it mocked every such call as `async () => ({})`, and the dogfood
|
|
87
|
+
// web app shows both ways that goes wrong:
|
|
88
|
+
//
|
|
89
|
+
// formatZAR(cents: number): string every money value rendered as an
|
|
90
|
+
// object, from a mock that was also
|
|
91
|
+
// async for a synchronous function
|
|
92
|
+
// isTrustee(roles): boolean `{}` is truthy, so the trustee
|
|
93
|
+
// branch was taken unconditionally and
|
|
94
|
+
// the member branch never ran
|
|
95
|
+
//
|
|
96
|
+
// Recorded as sync so the emitter can write `vi.fn(() => …)` rather than
|
|
97
|
+
// wrapping a plain value in a promise nothing awaits.
|
|
98
|
+
if (fn.type) {
|
|
99
|
+
if (dep.awaited) return dep;
|
|
100
|
+
const value = resolveTypeNodeValue(fn.type, sf, declFile, resolver, cache);
|
|
101
|
+
if (value === undefined) return dep;
|
|
102
|
+
return {
|
|
103
|
+
...dep,
|
|
104
|
+
inferredReturn: {
|
|
105
|
+
provenance: "annotation" as const,
|
|
106
|
+
value,
|
|
107
|
+
sync: true,
|
|
108
|
+
...annotationFlags(fn.type),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!fn.body) return dep;
|
|
114
|
+
const fetches = extractFetchCalls(fn.body, sf);
|
|
115
|
+
const f = fetches[0];
|
|
116
|
+
if (!f || fetches.length !== 1) return dep;
|
|
117
|
+
// The wrapper must RETURN the fetched json — an envelope unwrapper
|
|
118
|
+
// (`return envelope.data`) or a side-effect fetch would join an OpenAPI
|
|
119
|
+
// example to a value the function never returns, false-pass capable for
|
|
120
|
+
// a shape-tolerant component.
|
|
121
|
+
if (!returnsFetchedJson(fn)) return dep;
|
|
122
|
+
return { ...dep, wrapsFetch: { method: f.method.toUpperCase(), path: f.urlTemplate } };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* True iff the wrapper's single return IS the fetch response's json():
|
|
128
|
+
* `return res.json()` / `return await res.json()` where `res` was bound to
|
|
129
|
+
* (an awaited) fetch; `const data = await res.json(); return data;`; or the
|
|
130
|
+
* direct `return (await fetch(...)).json()`. Anything else — a second
|
|
131
|
+
* return, a transformed value, a different binding — is not a wrapper.
|
|
132
|
+
*/
|
|
133
|
+
function returnsFetchedJson(fn: { body?: ts.Node }): boolean {
|
|
134
|
+
const body = fn.body;
|
|
135
|
+
if (!body) return false;
|
|
136
|
+
// A concise arrow body IS the single return expression.
|
|
137
|
+
const returns = ts.isBlock(body)
|
|
138
|
+
? ownReturns(body).map((r) => r.expression)
|
|
139
|
+
: [body as ts.Expression];
|
|
140
|
+
if (returns.length !== 1 || returns[0] === undefined) return false;
|
|
141
|
+
|
|
142
|
+
// Bindings: `const res = await fetch(...)` and `const data = await res.json()`.
|
|
143
|
+
const fetchVars = new Set<string>();
|
|
144
|
+
const jsonVars = new Set<string>();
|
|
145
|
+
if (ts.isBlock(body)) {
|
|
146
|
+
for (const stmt of body.statements) {
|
|
147
|
+
if (!ts.isVariableStatement(stmt)) continue;
|
|
148
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
149
|
+
if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
|
|
150
|
+
const init = unwrapAwait(decl.initializer);
|
|
151
|
+
if (isFetchCall(init)) fetchVars.add(decl.name.text);
|
|
152
|
+
else if (isJsonCallOn(init, fetchVars)) jsonVars.add(decl.name.text);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const expr = unwrapAwait(returns[0]);
|
|
158
|
+
if (ts.isIdentifier(expr)) return jsonVars.has(expr.text);
|
|
159
|
+
return isJsonCallOn(expr, fetchVars);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function unwrapAwait(expr: ts.Expression): ts.Expression {
|
|
163
|
+
let e = expr;
|
|
164
|
+
for (;;) {
|
|
165
|
+
if (ts.isParenthesizedExpression(e)) e = e.expression;
|
|
166
|
+
else if (ts.isAwaitExpression(e)) e = e.expression;
|
|
167
|
+
else return e;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isFetchCall(expr: ts.Expression): boolean {
|
|
172
|
+
return (
|
|
173
|
+
ts.isCallExpression(expr) &&
|
|
174
|
+
ts.isIdentifier(expr.expression) &&
|
|
175
|
+
expr.expression.text === "fetch"
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `<receiver>.json()` where the receiver is a fetch binding or a fetch call. */
|
|
180
|
+
function isJsonCallOn(expr: ts.Expression, fetchVars: Set<string>): boolean {
|
|
181
|
+
if (!ts.isCallExpression(expr) || expr.arguments.length !== 0) return false;
|
|
182
|
+
const callee = expr.expression;
|
|
183
|
+
if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "json") return false;
|
|
184
|
+
const receiver = unwrapAwait(callee.expression);
|
|
185
|
+
if (ts.isIdentifier(receiver)) return fetchVars.has(receiver.text);
|
|
186
|
+
return isFetchCall(receiver);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The fixture value an exported function's `Promise<T>` annotation describes, or
|
|
191
|
+
* undefined when it cannot be resolved to one concrete value.
|
|
192
|
+
*
|
|
193
|
+
* Extracted from Path A above so the native mock emitter reads the SAME
|
|
194
|
+
* annotation by the SAME rules. A second implementation of "what does this
|
|
195
|
+
* function return" would drift from this one, and the two would disagree about
|
|
196
|
+
* the fixture a spec is built on.
|
|
197
|
+
*/
|
|
198
|
+
export function annotatedReturnValue(
|
|
199
|
+
module: string,
|
|
200
|
+
exportName: string,
|
|
201
|
+
resolver: ModuleResolver,
|
|
202
|
+
cache: SourceFileCache,
|
|
203
|
+
): PropValue | undefined {
|
|
204
|
+
const located = findExportedDecl(
|
|
205
|
+
module,
|
|
206
|
+
exportName,
|
|
207
|
+
cache,
|
|
208
|
+
resolver,
|
|
209
|
+
(sf, name) => new Map(exportedFunctions(sf)).get(name),
|
|
210
|
+
true,
|
|
211
|
+
);
|
|
212
|
+
if (!located) return undefined;
|
|
213
|
+
const { sf, file: declFile, decl: fn } = located;
|
|
214
|
+
const promiseArg = promiseTypeArg(fn.type);
|
|
215
|
+
if (!promiseArg) return undefined;
|
|
216
|
+
// `Promise<void>` resolves to nothing AND needs nothing: a mock returning
|
|
217
|
+
// undefined is exactly right, and the caller's "give this a realistic return
|
|
218
|
+
// value" marker was advice with no work behind it. Reported separately from
|
|
219
|
+
// "could not infer" so the caller can tell them apart.
|
|
220
|
+
if (
|
|
221
|
+
promiseArg.kind === ts.SyntaxKind.VoidKeyword ||
|
|
222
|
+
promiseArg.kind === ts.SyntaxKind.UndefinedKeyword
|
|
223
|
+
) {
|
|
224
|
+
return VOID_RETURN;
|
|
225
|
+
}
|
|
226
|
+
return resolveTypeNodeValue(promiseArg, sf, declFile, resolver, cache);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The answer for a function annotated `Promise<void>`.
|
|
231
|
+
*
|
|
232
|
+
* A distinct object rather than `undefined`, because undefined already means
|
|
233
|
+
* "could not be inferred" and the two want different mock bodies and different
|
|
234
|
+
* markers. Compared by identity — never serialise it.
|
|
235
|
+
*/
|
|
236
|
+
export const VOID_RETURN = Object.freeze({ __flawlessVoid: true }) as unknown as PropValue;
|
|
237
|
+
|
|
238
|
+
function promiseTypeArg(type: ts.TypeNode | undefined): ts.TypeNode | undefined {
|
|
239
|
+
if (
|
|
240
|
+
type !== undefined &&
|
|
241
|
+
ts.isTypeReferenceNode(type) &&
|
|
242
|
+
ts.isIdentifier(type.typeName) &&
|
|
243
|
+
type.typeName.text === "Promise"
|
|
244
|
+
) {
|
|
245
|
+
return type.typeArguments?.[0];
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Return annotations for the names one import declaration binds.
|
|
252
|
+
*
|
|
253
|
+
* The peer of `resolveDepReturns` for functions that never became a dataDep —
|
|
254
|
+
* a formatter called inside JSX, a predicate read straight into a condition.
|
|
255
|
+
* Those reached the emitter with no evidence at all and were stubbed
|
|
256
|
+
* `vi.fn(async () => ({}))`, which is wrong in three ways at once for
|
|
257
|
+
* `formatZAR(cents: number): string`: async, an object, and a value the page
|
|
258
|
+
* renders directly.
|
|
259
|
+
*
|
|
260
|
+
* Only names with a resolvable annotation appear. An absent name keeps the
|
|
261
|
+
* honest {} stub and the TODO that goes with it, exactly as before.
|
|
262
|
+
*/
|
|
263
|
+
/**
|
|
264
|
+
* What the return TypeNode itself admits — read at the declaration because
|
|
265
|
+
* the RESOLVED value cannot say: a `(): true` literal annotation and a
|
|
266
|
+
* `(): boolean` both store `true`, and only the second makes `false` a legal
|
|
267
|
+
* return a spec may flip to (B-19's provably-reachable rule). The nullable
|
|
268
|
+
* flag mirrors it for `T | null` unions, whose discounted arm is exactly the
|
|
269
|
+
* value that un-fires a truthy guard.
|
|
270
|
+
*/
|
|
271
|
+
function annotationFlags(node: ts.TypeNode): {
|
|
272
|
+
booleanAnnotated?: true;
|
|
273
|
+
nullableAnnotated?: true;
|
|
274
|
+
} {
|
|
275
|
+
const flags: { booleanAnnotated?: true; nullableAnnotated?: true } = {};
|
|
276
|
+
if (node.kind === ts.SyntaxKind.BooleanKeyword) flags.booleanAnnotated = true;
|
|
277
|
+
if (ts.isUnionTypeNode(node)) {
|
|
278
|
+
if (
|
|
279
|
+
node.types.some(
|
|
280
|
+
(t) =>
|
|
281
|
+
t.kind === ts.SyntaxKind.UndefinedKeyword ||
|
|
282
|
+
(ts.isLiteralTypeNode(t) && t.literal.kind === ts.SyntaxKind.NullKeyword),
|
|
283
|
+
)
|
|
284
|
+
) {
|
|
285
|
+
flags.nullableAnnotated = true;
|
|
286
|
+
}
|
|
287
|
+
if (node.types.some((t) => t.kind === ts.SyntaxKind.BooleanKeyword)) {
|
|
288
|
+
flags.booleanAnnotated = true;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return flags;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Which of this import's names declare ASYNC functions — the evidence the RSC
|
|
296
|
+
* emitter needs to stub an async server CHILD instead of mounting it: React's
|
|
297
|
+
* client renderer refuses an async function component, and the page that
|
|
298
|
+
* contains one renders an empty container. Returns module-side names
|
|
299
|
+
* ("default" for a default export), same convention as importedReturns.
|
|
300
|
+
*/
|
|
301
|
+
export function asyncComponentExports(
|
|
302
|
+
mi: { names: string[]; importedNames?: string[]; isTypeOnly: boolean; hasDefault: boolean },
|
|
303
|
+
resolvedPath: string,
|
|
304
|
+
resolver: ModuleResolver,
|
|
305
|
+
cache: SourceFileCache,
|
|
306
|
+
): string[] {
|
|
307
|
+
const out: string[] = [];
|
|
308
|
+
if (mi.isTypeOnly) return out;
|
|
309
|
+
for (const [i, local] of mi.names.entries()) {
|
|
310
|
+
const exported = mi.hasDefault && i === 0 ? "default" : (mi.importedNames?.[i] ?? local);
|
|
311
|
+
if (!exported) continue;
|
|
312
|
+
const located = findExportedDecl(
|
|
313
|
+
resolvedPath,
|
|
314
|
+
exported,
|
|
315
|
+
cache,
|
|
316
|
+
resolver,
|
|
317
|
+
(sf, name) => new Map(exportedFunctions(sf)).get(name),
|
|
318
|
+
true,
|
|
319
|
+
);
|
|
320
|
+
if (!located) continue;
|
|
321
|
+
const decl = located.decl;
|
|
322
|
+
const mods = ts.canHaveModifiers(decl) ? ts.getModifiers(decl) : undefined;
|
|
323
|
+
if (mods?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) out.push(exported);
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function annotatedExportReturns(
|
|
329
|
+
mi: { names: string[]; importedNames?: string[]; isTypeOnly: boolean },
|
|
330
|
+
resolvedPath: string,
|
|
331
|
+
resolver: ModuleResolver,
|
|
332
|
+
cache: SourceFileCache,
|
|
333
|
+
): Record<string, { value: PropValue; sync: boolean }> {
|
|
334
|
+
const out: Record<string, { value: PropValue; sync: boolean }> = {};
|
|
335
|
+
if (mi.isTypeOnly) return out;
|
|
336
|
+
for (const [i, local] of mi.names.entries()) {
|
|
337
|
+
const exported = mi.importedNames?.[i] ?? local;
|
|
338
|
+
if (!exported || exported === "default") continue;
|
|
339
|
+
const located = findExportedDecl(
|
|
340
|
+
resolvedPath,
|
|
341
|
+
exported,
|
|
342
|
+
cache,
|
|
343
|
+
resolver,
|
|
344
|
+
(sf, name) => new Map(exportedFunctions(sf)).get(name),
|
|
345
|
+
true,
|
|
346
|
+
);
|
|
347
|
+
if (!located) continue;
|
|
348
|
+
const { sf, file: declFile, decl: fn } = located;
|
|
349
|
+
if (!fn.type) continue;
|
|
350
|
+
const promiseArg = promiseTypeArg(fn.type);
|
|
351
|
+
if (promiseArg) {
|
|
352
|
+
// Void is a real answer, not a gap — see VOID_RETURN.
|
|
353
|
+
if (
|
|
354
|
+
promiseArg.kind === ts.SyntaxKind.VoidKeyword ||
|
|
355
|
+
promiseArg.kind === ts.SyntaxKind.UndefinedKeyword
|
|
356
|
+
) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const value = resolveTypeNodeValue(promiseArg, sf, declFile, resolver, cache);
|
|
360
|
+
if (value !== undefined) {
|
|
361
|
+
out[exported] = { value, sync: false, ...annotationFlags(promiseArg) };
|
|
362
|
+
}
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (fn.type.kind === ts.SyntaxKind.VoidKeyword) continue;
|
|
366
|
+
const value = resolveTypeNodeValue(fn.type, sf, declFile, resolver, cache);
|
|
367
|
+
if (value !== undefined) out[exported] = { value, sync: true, ...annotationFlags(fn.type) };
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { PropValue } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Values for props whose type could not be resolved, recovered from how the
|
|
6
|
+
* component indexes with them.
|
|
7
|
+
*
|
|
8
|
+
* `currentStatus: JobStatus` where `JobStatus = z.infer<typeof Schema>`
|
|
9
|
+
* resolves since B-9 when the schema is syntactic (zodSchema.ts); this module
|
|
10
|
+
* is the fallback for schemas that bail (.transform, unions, custom), where
|
|
11
|
+
* the prop arrives undefined and `ALLOWED_TRANSITIONS[currentStatus].length`
|
|
12
|
+
* throws before anything renders.
|
|
13
|
+
* But the same file usually declares a lookup keyed by exactly that union:
|
|
14
|
+
*
|
|
15
|
+
* const STATUS_LABEL: Record<JobStatus, string> = { requested: "Requested", … };
|
|
16
|
+
* …
|
|
17
|
+
* STATUS_LABEL[currentStatus]
|
|
18
|
+
*
|
|
19
|
+
* Those keys ARE the union, written out literally. Taking the first is evidence,
|
|
20
|
+
* not inference — and the whole key list makes a real variant matrix.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately narrow: the indexed object must be a top-level const in the same
|
|
23
|
+
* file with an object-literal initialiser and only literal keys. An imported
|
|
24
|
+
* table, a computed key, or a spread inside the literal gives up, because a
|
|
25
|
+
* partial key list would produce a default that is not actually in the union.
|
|
26
|
+
*/
|
|
27
|
+
export interface IndexKeyResolution {
|
|
28
|
+
/** Prop name → its first observed key. */
|
|
29
|
+
defaults: Record<string, PropValue>;
|
|
30
|
+
/** Prop name → every key, for the variant matrix. */
|
|
31
|
+
options: Record<string, PropValue[]>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveIndexKeyProps(
|
|
35
|
+
bodyNode: ts.Node,
|
|
36
|
+
sourceFile: ts.SourceFile,
|
|
37
|
+
unresolvedProps: string[],
|
|
38
|
+
): IndexKeyResolution {
|
|
39
|
+
const out: IndexKeyResolution = { defaults: {}, options: {} };
|
|
40
|
+
if (unresolvedProps.length === 0) return out;
|
|
41
|
+
const wanted = new Set(unresolvedProps);
|
|
42
|
+
|
|
43
|
+
const tables = topLevelObjectLiterals(sourceFile);
|
|
44
|
+
if (tables.size === 0) return out;
|
|
45
|
+
|
|
46
|
+
const visit = (n: ts.Node): void => {
|
|
47
|
+
if (
|
|
48
|
+
ts.isElementAccessExpression(n) &&
|
|
49
|
+
ts.isIdentifier(n.expression) &&
|
|
50
|
+
ts.isIdentifier(n.argumentExpression) &&
|
|
51
|
+
wanted.has(n.argumentExpression.text)
|
|
52
|
+
) {
|
|
53
|
+
const keys = tables.get(n.expression.text);
|
|
54
|
+
const prop = n.argumentExpression.text;
|
|
55
|
+
if (keys && keys.length > 0 && out.defaults[prop] === undefined) {
|
|
56
|
+
out.defaults[prop] = keys[0] as PropValue;
|
|
57
|
+
out.options[prop] = [...keys];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
ts.forEachChild(n, visit);
|
|
61
|
+
};
|
|
62
|
+
visit(bodyNode);
|
|
63
|
+
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Top-level `const X = { a: …, b: … }` → its literal key list. */
|
|
68
|
+
function topLevelObjectLiterals(sourceFile: ts.SourceFile): Map<string, string[]> {
|
|
69
|
+
const out = new Map<string, string[]>();
|
|
70
|
+
for (const stmt of sourceFile.statements) {
|
|
71
|
+
if (!ts.isVariableStatement(stmt)) continue;
|
|
72
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
73
|
+
if (!ts.isIdentifier(decl.name)) continue;
|
|
74
|
+
const init = decl.initializer;
|
|
75
|
+
if (!init || !ts.isObjectLiteralExpression(init)) continue;
|
|
76
|
+
const keys = literalKeys(init);
|
|
77
|
+
if (keys) out.set(decl.name.text, keys);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every key as written, or undefined when any of them is not a literal. */
|
|
84
|
+
function literalKeys(obj: ts.ObjectLiteralExpression): string[] | undefined {
|
|
85
|
+
const keys: string[] = [];
|
|
86
|
+
for (const prop of obj.properties) {
|
|
87
|
+
// A spread means the real key set is larger than what is written here, so
|
|
88
|
+
// the first key might not be the first member of the union.
|
|
89
|
+
if (ts.isSpreadAssignment(prop)) return undefined;
|
|
90
|
+
if (!prop.name) return undefined;
|
|
91
|
+
if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) {
|
|
92
|
+
keys.push(prop.name.text);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
return keys.length > 0 ? keys : undefined;
|
|
98
|
+
}
|