@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.
Files changed (143) hide show
  1. package/.agents/skills/flawless-a11y/SKILL.md +113 -0
  2. package/.agents/skills/flawless-api/SKILL.md +159 -0
  3. package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
  4. package/.agents/skills/flawless-e2e/SKILL.md +155 -0
  5. package/.agents/skills/flawless-expo/SKILL.md +283 -0
  6. package/.agents/skills/flawless-findings/SKILL.md +116 -0
  7. package/.agents/skills/flawless-load/SKILL.md +112 -0
  8. package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
  9. package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
  10. package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
  11. package/.agents/skills/flawless-react/SKILL.md +403 -0
  12. package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
  13. package/.agents/skills/flawless-regen/SKILL.md +135 -0
  14. package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
  15. package/LICENSE +21 -0
  16. package/README.md +611 -2
  17. package/bin/flawless-react +4 -0
  18. package/package.json +67 -4
  19. package/scripts/postinstall.d.mts +46 -0
  20. package/scripts/postinstall.mjs +127 -0
  21. package/src/analyse/authShims.ts +220 -0
  22. package/src/analyse/bootability.ts +257 -0
  23. package/src/analyse/childBridge.ts +302 -0
  24. package/src/analyse/childrenIntoSlot.ts +66 -0
  25. package/src/analyse/compoundFamily.ts +100 -0
  26. package/src/analyse/dataExports.ts +200 -0
  27. package/src/analyse/extractChildAttrs.ts +112 -0
  28. package/src/analyse/extractDataDeps.ts +857 -0
  29. package/src/analyse/extractDisclosures.ts +186 -0
  30. package/src/analyse/extractFetchCalls.ts +81 -0
  31. package/src/analyse/extractFlows.ts +239 -0
  32. package/src/analyse/extractGuards.ts +270 -0
  33. package/src/analyse/extractHooks.ts +237 -0
  34. package/src/analyse/extractInteractions.ts +1421 -0
  35. package/src/analyse/extractLogging.ts +45 -0
  36. package/src/analyse/extractNativeA11y.ts +138 -0
  37. package/src/analyse/extractPredicates.ts +784 -0
  38. package/src/analyse/extractPropUsage.ts +162 -0
  39. package/src/analyse/extractProps.ts +1212 -0
  40. package/src/analyse/extractRenderMode.ts +165 -0
  41. package/src/analyse/extractRootTag.ts +117 -0
  42. package/src/analyse/extractState.ts +361 -0
  43. package/src/analyse/extractStaticText.ts +212 -0
  44. package/src/analyse/extractVariants.ts +186 -0
  45. package/src/analyse/harvestApiEndpoints.ts +284 -0
  46. package/src/analyse/hostProps.ts +185 -0
  47. package/src/analyse/hostTag.ts +72 -0
  48. package/src/analyse/htmlContentModel.ts +94 -0
  49. package/src/analyse/index.ts +744 -0
  50. package/src/analyse/mountPathCalls.ts +442 -0
  51. package/src/analyse/nativeContentModel.ts +129 -0
  52. package/src/analyse/nativeDialect.ts +83 -0
  53. package/src/analyse/nativeProps.ts +339 -0
  54. package/src/analyse/ownReturns.ts +31 -0
  55. package/src/analyse/parseFile.ts +919 -0
  56. package/src/analyse/pos.ts +8 -0
  57. package/src/analyse/providerWraps.ts +1977 -0
  58. package/src/analyse/pureModule.ts +88 -0
  59. package/src/analyse/reexportTable.ts +148 -0
  60. package/src/analyse/refs.ts +195 -0
  61. package/src/analyse/renderPath.ts +441 -0
  62. package/src/analyse/resolveDepReturns.ts +370 -0
  63. package/src/analyse/resolveIndexKeys.ts +98 -0
  64. package/src/analyse/resolveModule.ts +431 -0
  65. package/src/analyse/resolveTagRootTags.ts +103 -0
  66. package/src/analyse/serverActionReturns.ts +213 -0
  67. package/src/analyse/sourceCache.ts +263 -0
  68. package/src/analyse/spreadSiblings.ts +98 -0
  69. package/src/analyse/walkChildren.ts +98 -0
  70. package/src/analyse/zodSchema.ts +424 -0
  71. package/src/bin.ts +23 -0
  72. package/src/changed.ts +221 -0
  73. package/src/cli.ts +560 -0
  74. package/src/cliFlags.ts +281 -0
  75. package/src/core.ts +1229 -0
  76. package/src/docLine.ts +122 -0
  77. package/src/emitPaths.ts +51 -0
  78. package/src/expo/detect.ts +184 -0
  79. package/src/expo/refusal.ts +62 -0
  80. package/src/expo/routes.ts +145 -0
  81. package/src/index.ts +95 -0
  82. package/src/mcp/analysisTools.ts +720 -0
  83. package/src/mcp/infoTools.ts +135 -0
  84. package/src/mcp/protocol.ts +130 -0
  85. package/src/mcp/run.ts +269 -0
  86. package/src/mcp/runTools.ts +362 -0
  87. package/src/mcp/server.ts +279 -0
  88. package/src/mcp/spec.ts +86 -0
  89. package/src/mcp/support.ts +268 -0
  90. package/src/mcp/tools.ts +75 -0
  91. package/src/nextjs/apiRoutes.ts +116 -0
  92. package/src/nextjs/detect.ts +397 -0
  93. package/src/nextjs/emit.ts +106 -0
  94. package/src/nextjs/liveSpec.ts +79 -0
  95. package/src/nextjs/mswHandlers.ts +55 -0
  96. package/src/nextjs/providerWrapper.ts +603 -0
  97. package/src/nextjs/routes.ts +86 -0
  98. package/src/nextjs/swagger.ts +32 -0
  99. package/src/packageJson.ts +414 -0
  100. package/src/platform.ts +18 -0
  101. package/src/prompt.ts +132 -0
  102. package/src/reactCompiler.ts +165 -0
  103. package/src/reporters/findings.ts +156 -0
  104. package/src/reporters/report.ts +463 -0
  105. package/src/runner/runTests.ts +402 -0
  106. package/src/scaffold/adoptStyle.ts +181 -0
  107. package/src/scaffold/index.ts +253 -0
  108. package/src/scaffold/openapi.ts +183 -0
  109. package/src/scaffold/templates.ts +2379 -0
  110. package/src/scaffold-tests/index.ts +463 -0
  111. package/src/scaffold-tests/mockValues.ts +208 -0
  112. package/src/scaffold-tests/mocks.ts +794 -0
  113. package/src/scaffold-tests/nativeIndex.ts +52 -0
  114. package/src/scaffold-tests/nativeTemplates.ts +1528 -0
  115. package/src/scaffold-tests/postDispatch.ts +152 -0
  116. package/src/scaffold-tests/registry.ts +443 -0
  117. package/src/scaffold-tests/selectors.ts +493 -0
  118. package/src/scaffold-tests/selectorsNative.ts +273 -0
  119. package/src/scaffold-tests/stamp.ts +79 -0
  120. package/src/scaffold-tests/templates.ts +3578 -0
  121. package/src/skills/cli.ts +248 -0
  122. package/src/skills/compat.ts +166 -0
  123. package/src/skills/data.ts +192 -0
  124. package/src/skills/md.d.ts +7 -0
  125. package/src/skills/menu.ts +55 -0
  126. package/src/skills/spec.ts +227 -0
  127. package/src/suiteScripts.ts +110 -0
  128. package/src/tasks/a11yNative.ts +281 -0
  129. package/src/tasks/a11yScan.ts +67 -0
  130. package/src/tasks/biomeRunner.ts +301 -0
  131. package/src/tasks/biomeSweep.ts +166 -0
  132. package/src/tasks/e2eExpo.ts +436 -0
  133. package/src/tasks/e2eRoutes.ts +756 -0
  134. package/src/tasks/eslintReactRules.ts +67 -0
  135. package/src/tasks/index.ts +24 -0
  136. package/src/tasks/loadExpo.ts +273 -0
  137. package/src/tasks/loadPipes.ts +750 -0
  138. package/src/tasks/perfHarness.ts +145 -0
  139. package/src/tasks/staticPerfAnalysis.ts +510 -0
  140. package/src/tasks/types.ts +1 -0
  141. package/src/tasks/unitAndBrowser.ts +462 -0
  142. package/src/types.ts +1419 -0
  143. package/src/version.ts +12 -0
@@ -0,0 +1,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
+ }