@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,424 @@
1
+ import ts from "typescript";
2
+ import type { PropValue, SourcePosition } from "../types.ts";
3
+ import { collectImports, type ImportBinding } from "./parseFile.ts";
4
+ import { posOf } from "./pos.ts";
5
+ import { findExportedDecl, type SourceFileCache } from "./sourceCache.ts";
6
+
7
+ /**
8
+ * B-9: `z.infer<typeof schema>` / `z.input` / `z.output` aliases resolved by
9
+ * reading the zod schema initializer's AST. Zod is never installed, imported
10
+ * or executed — this is syntax, which is why the policy is TYPE-level
11
+ * honesty: refinements that do not change the TypeScript type (.min, .email,
12
+ * .trim, .regex, …) are ignored, because the house value is still a member
13
+ * of the declared type; constructs whose OUTPUT type is not
14
+ * JSON-representable (z.date, z.bigint, z.map, …) bail, because
15
+ * JSON.stringify at emission would produce a value that lies about the
16
+ * type; and any construct or modifier outside the recognised set bails —
17
+ * .transform/.refine change semantics, .merge/.pick/.omit/.strict are
18
+ * evidence-gated follow-ups (no corpus sightings yet), and inventing is
19
+ * worse than a TODO.
20
+ */
21
+
22
+ /** Exact set — `zod-form-data` and friends export a `z` that is not zod's. */
23
+ const ZOD_SPECIFIERS = new Set(["zod", "zod/v3", "zod/v4"]);
24
+
25
+ const INFER_HEADS = new Set(["infer", "input", "output"]);
26
+
27
+ /** Type-invariant refinements and metadata: safe to ignore, value unchanged. */
28
+ const NOOP_MODIFIERS = new Set([
29
+ "optional",
30
+ "nullable",
31
+ "nullish",
32
+ "int",
33
+ "min",
34
+ "max",
35
+ "length",
36
+ "positive",
37
+ "nonnegative",
38
+ "trim",
39
+ "toLowerCase",
40
+ "email",
41
+ "url",
42
+ "uuid",
43
+ "regex",
44
+ "describe",
45
+ ]);
46
+
47
+ /** Ctx shape structurally satisfied by extractProps' PropsResolveCtx. */
48
+ export interface ZodResolveCtx {
49
+ sf: ts.SourceFile;
50
+ file: string;
51
+ imports: Map<string, ImportBinding>;
52
+ resolver: { resolve(spec: string, importer: string): string | undefined } | undefined;
53
+ cache: SourceFileCache | undefined;
54
+ hops: number;
55
+ visited: Set<string>;
56
+ }
57
+
58
+ export interface ZodObjectField {
59
+ name: string;
60
+ value: PropValue;
61
+ /** Present iff the field's base construct is an enum — feeds variantOptions. */
62
+ options?: PropValue[];
63
+ /** The PropertyAssignment's position in the SCHEMA file. */
64
+ pos: SourcePosition;
65
+ }
66
+
67
+ export type ZodSchemaResult =
68
+ | { kind: "object"; fields: ZodObjectField[] }
69
+ | { kind: "enum"; values: PropValue[] }
70
+ | { kind: "value"; value: PropValue };
71
+
72
+ /**
73
+ * Is this type reference provably zod's infer/input/output alias? Pure
74
+ * syntax: `<head>.infer<typeof <Identifier>>` where `<head>`'s import
75
+ * binding comes from a zod specifier. Never resolves anything.
76
+ */
77
+ export function asZodInferRef(
78
+ node: ts.TypeReferenceNode,
79
+ imports: Map<string, ImportBinding>,
80
+ ): { schemaName: string; typeKind: "output" | "input" } | undefined {
81
+ const name = node.typeName;
82
+ if (!ts.isQualifiedName(name)) return undefined;
83
+ if (!ts.isIdentifier(name.left)) return undefined;
84
+ if (!INFER_HEADS.has(name.right.text)) return undefined;
85
+ if (!isZodBinding(imports.get(name.left.text))) return undefined;
86
+ if (node.typeArguments?.length !== 1) return undefined;
87
+ const arg = node.typeArguments[0];
88
+ if (!arg || !ts.isTypeQueryNode(arg)) return undefined;
89
+ // `typeof schemas.Foo` (namespace value refs): unseen in the corpus, bail.
90
+ if (!ts.isIdentifier(arg.exprName)) return undefined;
91
+ return {
92
+ schemaName: arg.exprName.text,
93
+ typeKind: name.right.text === "input" ? "input" : "output",
94
+ };
95
+ }
96
+
97
+ function isZodBinding(binding: ImportBinding | undefined): boolean {
98
+ if (!binding || !ZOD_SPECIFIERS.has(binding.specifier)) return false;
99
+ if (binding.kind === "namespace" || binding.kind === "default") return true;
100
+ return binding.importedName === "z";
101
+ }
102
+
103
+ /**
104
+ * Resolve `schemaName` to its const initializer (local statements first,
105
+ * non-exported included — module-local schemas referenced by exported ones
106
+ * are the PasswordSchema idiom; then imports + resolver + barrel chase with
107
+ * an export-enforcing matcher) and read it. undefined = bail.
108
+ */
109
+ export function resolveZodSchemaConst(
110
+ schemaName: string,
111
+ seed: string,
112
+ ctx: ZodResolveCtx,
113
+ ): ZodSchemaResult | undefined {
114
+ // Namespaced: the dominant idiom pairs `const X` and `type X` in one
115
+ // file, and the TYPE lookup has already seeded `${file}::X` before the
116
+ // alias RHS reaches this reader — a shared key would false-bail it.
117
+ const key = `${ctx.file}::zodconst:${schemaName}`;
118
+ if (ctx.visited.has(key)) return undefined;
119
+ const next: ZodResolveCtx = { ...ctx, visited: new Set([...ctx.visited, key]) };
120
+
121
+ const local = findSchemaConst(ctx.sf, schemaName, false);
122
+ if (local?.initializer) return resolveZodExpr(local.initializer, seed, next);
123
+
124
+ const binding = ctx.imports.get(schemaName);
125
+ if (!binding || ctx.hops <= 0) return undefined;
126
+ const target = ctx.resolver?.resolve(binding.specifier, ctx.file);
127
+ if (!target || !ctx.cache) return undefined;
128
+ const located = findExportedDecl(
129
+ target,
130
+ binding.importedName ?? schemaName,
131
+ ctx.cache,
132
+ ctx.resolver,
133
+ findSchemaConst,
134
+ true,
135
+ );
136
+ if (!located?.decl.initializer) return undefined;
137
+ return resolveZodExpr(located.decl.initializer, seed, {
138
+ ...next,
139
+ sf: located.sf,
140
+ file: located.file,
141
+ imports: collectImports(located.sf),
142
+ hops: ctx.hops - 1,
143
+ });
144
+ }
145
+
146
+ /** Collapse a result to one fixture value. */
147
+ export function zodResultToValue(r: ZodSchemaResult): PropValue {
148
+ if (r.kind === "object") {
149
+ const out: { [k: string]: PropValue } = {};
150
+ for (const f of r.fields) out[f.name] = f.value;
151
+ return out;
152
+ }
153
+ if (r.kind === "enum") return r.values[0] as PropValue;
154
+ return r.value;
155
+ }
156
+
157
+ /**
158
+ * Top-level `const <name> = …` in `sf`. `let` is rejected (reassignable),
159
+ * and files reached through a re-export chain vouch only for their EXPORTED
160
+ * consts — same rule as findExportedTypeDecl.
161
+ */
162
+ function findSchemaConst(
163
+ sf: ts.SourceFile,
164
+ name: string,
165
+ viaReexport: boolean,
166
+ ): ts.VariableDeclaration | undefined {
167
+ for (const stmt of sf.statements) {
168
+ if (!ts.isVariableStatement(stmt)) continue;
169
+ if (!(stmt.declarationList.flags & ts.NodeFlags.Const)) continue;
170
+ if (viaReexport) {
171
+ const exported =
172
+ ts.getModifiers(stmt)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
173
+ if (!exported) continue;
174
+ }
175
+ for (const d of stmt.declarationList.declarations) {
176
+ if (ts.isIdentifier(d.name) && d.name.text === name && d.initializer) return d;
177
+ }
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ /** One chain link: `.name(args)`, peeled outermost-first. */
183
+ interface ChainLink {
184
+ name: string;
185
+ args: readonly ts.Expression[];
186
+ }
187
+
188
+ /**
189
+ * Read a zod schema expression. The chain is peeled to its base — a call
190
+ * rooted at the zod binding (`z.enum(…)`, `z.coerce.number()`), or a bare
191
+ * schema-const reference (`RsvpStatusSchema`, `Base.partial()`) — then the
192
+ * remaining links are applied innermost→outermost against the modifier
193
+ * allowlist. Any unrecognised base or modifier is undefined.
194
+ */
195
+ function resolveZodExpr(
196
+ expr: ts.Expression,
197
+ seed: string,
198
+ ctx: ZodResolveCtx,
199
+ ): ZodSchemaResult | undefined {
200
+ let e = unwrapExpr(expr);
201
+
202
+ if (ts.isIdentifier(e)) return resolveZodSchemaConst(e.text, seed, ctx);
203
+
204
+ // Peel `.name(args)` links until the callee root.
205
+ const chain: ChainLink[] = [];
206
+ while (ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression)) {
207
+ chain.push({ name: e.expression.name.text, args: e.arguments });
208
+ e = unwrapExpr(e.expression.expression);
209
+ }
210
+
211
+ let result: ZodSchemaResult | undefined;
212
+ let modifiers: ChainLink[];
213
+ if (ts.isIdentifier(e) && isZodBinding(ctx.imports.get(e.text))) {
214
+ // Innermost link is the base construct: z.<base>(…).
215
+ const base = chain.pop();
216
+ if (!base) return undefined;
217
+ result = resolveZodBase(base.name, base.args, seed, ctx);
218
+ modifiers = chain.reverse();
219
+ } else if (
220
+ ts.isPropertyAccessExpression(e) &&
221
+ ts.isIdentifier(e.expression) &&
222
+ isZodBinding(ctx.imports.get(e.expression.text)) &&
223
+ e.name.text === "coerce"
224
+ ) {
225
+ // z.coerce.<kind>(…): the innermost link names the coerced OUTPUT type.
226
+ const base = chain.pop();
227
+ if (!base) return undefined;
228
+ result = resolveCoerceBase(base.name, seed);
229
+ modifiers = chain.reverse();
230
+ } else if (ts.isIdentifier(e)) {
231
+ // Schema-const base: every link is a modifier on the resolved schema.
232
+ result = resolveZodSchemaConst(e.text, seed, ctx);
233
+ modifiers = chain.reverse();
234
+ } else {
235
+ return undefined;
236
+ }
237
+
238
+ for (const mod of modifiers) {
239
+ if (result === undefined) return undefined;
240
+ result = applyModifier(result, mod, seed, ctx);
241
+ }
242
+ return result;
243
+ }
244
+
245
+ function unwrapExpr(e: ts.Expression): ts.Expression {
246
+ let cur = e;
247
+ for (;;) {
248
+ if (ts.isParenthesizedExpression(cur)) cur = cur.expression;
249
+ else if (ts.isAsExpression(cur) || ts.isSatisfiesExpression(cur)) cur = cur.expression;
250
+ else return cur;
251
+ }
252
+ }
253
+
254
+ function resolveZodBase(
255
+ name: string,
256
+ args: readonly ts.Expression[],
257
+ seed: string,
258
+ ctx: ZodResolveCtx,
259
+ ): ZodSchemaResult | undefined {
260
+ switch (name) {
261
+ case "string":
262
+ return { kind: "value", value: `flawless-${seed}` };
263
+ case "number":
264
+ return { kind: "value", value: 1 };
265
+ case "boolean":
266
+ return { kind: "value", value: true };
267
+ case "null":
268
+ return { kind: "value", value: null };
269
+ case "literal": {
270
+ const lit = staticLiteral(args[0]);
271
+ return lit === undefined ? undefined : { kind: "value", value: lit };
272
+ }
273
+ case "enum": {
274
+ const values = enumValues(args[0]);
275
+ return values ? { kind: "enum", values } : undefined;
276
+ }
277
+ case "object": {
278
+ const fields = objectFields(args[0], ctx);
279
+ return fields ? { kind: "object", fields } : undefined;
280
+ }
281
+ case "array": {
282
+ const arg = args[0];
283
+ if (!arg) return undefined;
284
+ const el = resolveZodExpr(arg, seed, ctx);
285
+ return el === undefined ? undefined : { kind: "value", value: [zodResultToValue(el)] };
286
+ }
287
+ default:
288
+ // z.date/bigint/map/set/any/unknown/union/discriminatedUnion/tuple/
289
+ // record/nativeEnum/lazy/…: not JSON-representable, or a guess.
290
+ return undefined;
291
+ }
292
+ }
293
+
294
+ function resolveCoerceBase(name: string, seed: string): ZodSchemaResult | undefined {
295
+ // The coerced OUTPUT type is what the fixture must inhabit. z.coerce.date
296
+ // outputs a Date, which JSON.stringify would flatten into a lying string.
297
+ switch (name) {
298
+ case "string":
299
+ return { kind: "value", value: `flawless-${seed}` };
300
+ case "number":
301
+ return { kind: "value", value: 1 };
302
+ case "boolean":
303
+ return { kind: "value", value: true };
304
+ default:
305
+ return undefined;
306
+ }
307
+ }
308
+
309
+ function applyModifier(
310
+ result: ZodSchemaResult,
311
+ mod: ChainLink,
312
+ seed: string,
313
+ ctx: ZodResolveCtx,
314
+ ): ZodSchemaResult | undefined {
315
+ if (NOOP_MODIFIERS.has(mod.name)) return result;
316
+ if (mod.name === "default") {
317
+ const lit = staticLiteral(mod.args[0]);
318
+ // Non-static default (a function, an object): the inner value is
319
+ // type-legal either way — keep it.
320
+ if (lit === undefined) return result;
321
+ if (result.kind === "value") return { kind: "value", value: lit };
322
+ if (result.kind === "enum") {
323
+ // The declared default leads; a default OUTSIDE the enum is suspect.
324
+ if (!result.values.includes(lit)) return undefined;
325
+ return { kind: "enum", values: [lit, ...result.values.filter((v) => v !== lit)] };
326
+ }
327
+ // Object defaults are object literals — non-static; keep the fields.
328
+ return result;
329
+ }
330
+ if (mod.name === "partial") {
331
+ // All-optional fields, values still included (the optional-members rule).
332
+ return result.kind === "object" ? result : undefined;
333
+ }
334
+ if (mod.name === "extend") {
335
+ if (result.kind !== "object") return undefined;
336
+ const extension = extendFields(mod.args[0], seed, ctx);
337
+ if (!extension) return undefined;
338
+ const merged = new Map<string, ZodObjectField>();
339
+ for (const f of result.fields) merged.set(f.name, f);
340
+ for (const f of extension) merged.set(f.name, f);
341
+ return { kind: "object", fields: [...merged.values()] };
342
+ }
343
+ // .transform/.refine/.superRefine/.pipe/.brand/.catch/.merge/.pick/
344
+ // .omit/.strict/.passthrough/…: semantics change or evidence-gated.
345
+ return undefined;
346
+ }
347
+
348
+ /** `.extend({...})` or `.extend(OtherSchema.shape)`. */
349
+ function extendFields(
350
+ arg: ts.Expression | undefined,
351
+ seed: string,
352
+ ctx: ZodResolveCtx,
353
+ ): ZodObjectField[] | undefined {
354
+ if (!arg) return undefined;
355
+ const e = unwrapExpr(arg);
356
+ if (ts.isObjectLiteralExpression(e)) return objectFields(e, ctx);
357
+ if (
358
+ ts.isPropertyAccessExpression(e) &&
359
+ ts.isIdentifier(e.expression) &&
360
+ e.name.text === "shape"
361
+ ) {
362
+ const other = resolveZodSchemaConst(e.expression.text, seed, ctx);
363
+ return other?.kind === "object" ? other.fields : undefined;
364
+ }
365
+ return undefined;
366
+ }
367
+
368
+ /** Inline string-literal array only — a const-ref member list is unreadable
369
+ * here and a partial union would be fabricated, not resolved. */
370
+ function enumValues(arg: ts.Expression | undefined): PropValue[] | undefined {
371
+ if (!arg) return undefined;
372
+ const e = unwrapExpr(arg);
373
+ if (!ts.isArrayLiteralExpression(e)) return undefined;
374
+ const out: PropValue[] = [];
375
+ for (const el of e.elements) {
376
+ if (!ts.isStringLiteral(el)) return undefined;
377
+ out.push(el.text);
378
+ }
379
+ return out.length > 0 ? out : undefined;
380
+ }
381
+
382
+ /** Every field resolves or the whole object bails — no silent holes. */
383
+ function objectFields(
384
+ arg: ts.Expression | undefined,
385
+ ctx: ZodResolveCtx,
386
+ ): ZodObjectField[] | undefined {
387
+ if (!arg) return undefined;
388
+ const e = unwrapExpr(arg);
389
+ if (!ts.isObjectLiteralExpression(e)) return undefined;
390
+ const out: ZodObjectField[] = [];
391
+ for (const prop of e.properties) {
392
+ if (!ts.isPropertyAssignment(prop)) return undefined;
393
+ if (!ts.isIdentifier(prop.name) && !ts.isStringLiteral(prop.name)) return undefined;
394
+ const name = prop.name.text;
395
+ const r = resolveZodExpr(prop.initializer, name, ctx);
396
+ if (r === undefined) return undefined;
397
+ out.push({
398
+ name,
399
+ value: zodResultToValue(r),
400
+ options: r.kind === "enum" ? r.values : undefined,
401
+ pos: posOf(prop, ctx.sf),
402
+ });
403
+ }
404
+ return out.length > 0 ? out : undefined;
405
+ }
406
+
407
+ /** JSON-only static literal: string/number/boolean/null, prefix-minus. */
408
+ function staticLiteral(arg: ts.Expression | undefined): PropValue | undefined {
409
+ if (!arg) return undefined;
410
+ const e = unwrapExpr(arg);
411
+ if (ts.isStringLiteral(e)) return e.text;
412
+ if (ts.isNumericLiteral(e)) {
413
+ const n = Number(e.text);
414
+ return Number.isFinite(n) ? n : undefined;
415
+ }
416
+ if (e.kind === ts.SyntaxKind.TrueKeyword) return true;
417
+ if (e.kind === ts.SyntaxKind.FalseKeyword) return false;
418
+ if (e.kind === ts.SyntaxKind.NullKeyword) return null;
419
+ if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken) {
420
+ const inner = staticLiteral(e.operand);
421
+ return typeof inner === "number" ? -inner : undefined;
422
+ }
423
+ return undefined;
424
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,23 @@
1
+ // Compile entry point for `bun build --compile`.
2
+ //
3
+ // bin/flawless-react is the npm entry and stays as-is: a `#!/usr/bin/env bun` shim that
4
+ // imports ../src/cli.ts. It cannot double as the compile entry, because it has no file
5
+ // extension — `bun build --compile bin/flawless-react` cannot pick a loader for it, treats it
6
+ // as an opaque asset, reports "bundle 1 modules", and emits a binary that silently does
7
+ // nothing at all. Every flag, including --help, exits 0 with no output.
8
+ //
9
+ // src/cli.ts cannot be the compile entry either: it only *exports* main() and never calls it,
10
+ // so compiling it produces the same silent no-op binary.
11
+ //
12
+ // Hence this file. It exists solely so the compiled artifact has a real, extensioned entry
13
+ // that actually invokes main().
14
+ import { main } from "./cli.ts";
15
+
16
+ try {
17
+ await main(Bun.argv.slice(2));
18
+ } catch (err) {
19
+ // A rejection escaping main() must land on the documented exit-1 contract,
20
+ // not surface as the runtime's uncaught-error dump.
21
+ process.stderr.write(`${String((err as Error | undefined)?.stack ?? err)}\n`);
22
+ process.exit(1);
23
+ }
package/src/changed.ts ADDED
@@ -0,0 +1,221 @@
1
+ import path from "node:path";
2
+ import { expoFileToRoute } from "./expo/routes.ts";
3
+ import { findRouterRoot, pageFileToRoute } from "./nextjs/routes.ts";
4
+ import { allocateSpecNames, flattenTrees } from "./scaffold-tests/registry.ts";
5
+ import { type ComponentTree, UsageError } from "./types.ts";
6
+
7
+ /**
8
+ * Which specs a set of edited files actually affects.
9
+ *
10
+ * Running 470 profile tests to check a change to three components is the wrong
11
+ * unit of work, and the whole-app analysis that answers "which specs" costs
12
+ * half a second — so narrowing the RUN is both possible and where the time is.
13
+ * Generation is not narrowed: the profiling host is a single bundle of every
14
+ * component, so a partial regeneration would produce a host that no longer
15
+ * matches its specs.
16
+ *
17
+ * The part a path filter gets wrong is e2e. Editing a leaf `<StatusPill>`
18
+ * changes every page that renders it, and those pages' files did not change.
19
+ * The child walk already knows that relationship; this reads it back.
20
+ */
21
+
22
+ export interface AffectedSpecs {
23
+ /** Absolute paths of the changed source files that map to a component. */
24
+ changedFiles: string[];
25
+ /** Spec names (registry keys) for components declared in those files. */
26
+ components: string[];
27
+ /**
28
+ * URL templates ("/things/[id]") of the pages whose tree renders one of
29
+ * those components — the exact text the emitted e2e titles embed, so an e2e
30
+ * --grep can be built from them (grepForRoutes).
31
+ */
32
+ routes: string[];
33
+ /** Components that changed but have no spec of their own. */
34
+ withoutSpecs: string[];
35
+ }
36
+
37
+ /**
38
+ * Files changed since `ref`, as absolute paths.
39
+ *
40
+ * Covers three states, because all three are "work you have not verified":
41
+ * committed since the ref, modified in the worktree, and untracked-but-not-
42
+ * ignored. A new component file is the case a plain `git diff` misses.
43
+ */
44
+ export async function changedFiles(repoRoot: string, ref = "HEAD"): Promise<string[]> {
45
+ // A ref git cannot resolve — a typo, or a ref absent from a shallow CI
46
+ // clone — used to make every `git diff` exit nonzero, each skipped with a
47
+ // bare `continue`. The run then reported "nothing affected" and exited 0:
48
+ // a green result for a comparison that never happened.
49
+ const inRepo = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], {
50
+ cwd: repoRoot,
51
+ stdout: "pipe",
52
+ stderr: "pipe",
53
+ });
54
+ if ((await inRepo.exited) !== 0) {
55
+ throw new UsageError(
56
+ `--changed needs a git checkout: ${repoRoot} is not one. Run it inside the repository, or drop --changed to run the full suite.`,
57
+ );
58
+ }
59
+ const check = Bun.spawn(["git", "rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
60
+ cwd: repoRoot,
61
+ stdout: "pipe",
62
+ stderr: "pipe",
63
+ });
64
+ if ((await check.exited) !== 0) {
65
+ throw new UsageError(
66
+ `git cannot resolve the ref "${ref}" in ${repoRoot}. Pass a ref that exists here (a shallow CI clone may not have it).`,
67
+ );
68
+ }
69
+ const out = new Set<string>();
70
+ const runs: string[][] = [
71
+ ["diff", "--name-only", ref],
72
+ ["diff", "--name-only", "--cached", ref],
73
+ ["ls-files", "--others", "--exclude-standard"],
74
+ ];
75
+ for (const args of runs) {
76
+ const proc = Bun.spawn(["git", ...args], {
77
+ cwd: repoRoot,
78
+ stdout: "pipe",
79
+ stderr: "pipe",
80
+ });
81
+ if ((await proc.exited) !== 0) continue;
82
+ const text = await new Response(proc.stdout).text();
83
+ for (const line of text.split("\n")) {
84
+ const rel = line.trim();
85
+ if (rel) out.add(path.resolve(repoRoot, rel));
86
+ }
87
+ }
88
+ return [...out].sort();
89
+ }
90
+
91
+ /** The repository root containing `from`, or undefined when it is not a git checkout. */
92
+ export async function findRepoRoot(from: string): Promise<string | undefined> {
93
+ const proc = Bun.spawn(["git", "rev-parse", "--show-toplevel"], {
94
+ cwd: from,
95
+ stdout: "pipe",
96
+ stderr: "pipe",
97
+ });
98
+ if ((await proc.exited) !== 0) return undefined;
99
+ const text = (await new Response(proc.stdout).text()).trim();
100
+ return text || undefined;
101
+ }
102
+
103
+ /**
104
+ * Map changed files onto the specs that cover them.
105
+ *
106
+ * `trees` must be the FULL analysis, not just the changed entries — the reverse
107
+ * index below is the whole point, and it needs every page's children.
108
+ */
109
+ export function affectedSpecs(
110
+ trees: ComponentTree[],
111
+ changed: readonly string[],
112
+ /**
113
+ * Router context, so "route" means an actual page.
114
+ *
115
+ * Without it every affected tree was counted as a route — a leaf button in
116
+ * src/components counted the same as app/dashboard/page.tsx — and the number
117
+ * printed in the `changed:` line was simply wrong. Absent outside --nextjs,
118
+ * where there are no e2e routes to narrow to.
119
+ */
120
+ nextjs?: { router: "app" | "pages"; root: string },
121
+ /**
122
+ * The expo-router equivalent, mutually exclusive with `nextjs` by
123
+ * construction (core enforces the mode split). Same contract: routes come
124
+ * back as the exact URL templates the emitted expo e2e titles embed.
125
+ */
126
+ expo?: { routerRoot: string },
127
+ ): AffectedSpecs {
128
+ const changedSet = new Set(changed.map((f) => path.resolve(f)));
129
+ const flat = flattenTrees(trees);
130
+ const specNames = allocateSpecNames(flat);
131
+
132
+ const components: string[] = [];
133
+ const withoutSpecs: string[] = [];
134
+ const touchedFiles = new Set<string>();
135
+ for (const c of flat) {
136
+ if (!changedSet.has(path.resolve(c.info.filePath))) continue;
137
+ touchedFiles.add(path.resolve(c.info.filePath));
138
+ // Not exported means no spec imports it; it is covered through its parent.
139
+ if (c.info.exportKind === "none") {
140
+ withoutSpecs.push(c.info.componentName);
141
+ continue;
142
+ }
143
+ components.push(specNames.get(c) as string);
144
+ }
145
+
146
+ // A page is affected when its tree renders a changed component anywhere,
147
+ // which is not the same as the page's own file having changed.
148
+ const routes: string[] = [];
149
+ if (nextjs) {
150
+ const routerRoot = findRouterRoot(nextjs.root);
151
+ for (const tree of trees) {
152
+ const route = pageFileToRoute(tree.entry.filePath, nextjs.router, routerRoot);
153
+ if (!route) continue;
154
+ if (rendersAnyOf(tree, changedSet)) routes.push(route.urlPath);
155
+ }
156
+ } else if (expo) {
157
+ for (const tree of trees) {
158
+ const route = expoFileToRoute(tree.entry.filePath, expo.routerRoot);
159
+ if (!route) continue;
160
+ if (rendersAnyOf(tree, changedSet)) routes.push(route.urlPath);
161
+ }
162
+ }
163
+
164
+ return {
165
+ changedFiles: [...touchedFiles].sort(),
166
+ components: [...new Set(components)].sort(),
167
+ routes: [...new Set(routes)].sort(),
168
+ withoutSpecs: [...new Set(withoutSpecs)].sort(),
169
+ };
170
+ }
171
+
172
+ function rendersAnyOf(tree: ComponentTree, changed: ReadonlySet<string>): boolean {
173
+ if (changed.has(path.resolve(tree.entry.filePath))) return true;
174
+ for (const child of tree.children) {
175
+ if (rendersAnyOf(child, changed)) return true;
176
+ }
177
+ return false;
178
+ }
179
+
180
+ /**
181
+ * A Playwright `--grep` that matches exactly these components' tests.
182
+ *
183
+ * Every emitted title begins with the component's registry name and ": ", but
184
+ * Playwright greps the whole title chain — `browser/Card.spec.ts › Card: mounts…`
185
+ * — so `^` anchors against the file path and matches nothing. A word boundary
186
+ * is what actually wants saying: `\bCard: ` selects "Card: mounts" and rejects
187
+ * both "CardHeader: mounts" (no ": " after Card) and "MyCard: mounts" (no
188
+ * boundary before it).
189
+ */
190
+ export function grepFor(components: readonly string[]): string {
191
+ // `(?!)` is the pattern that matches nothing at all. The obvious `$^` does
192
+ // not: at position 0 of an empty string both anchors hold, so an untitled
193
+ // test would slip through — and a grep that accidentally matches is how
194
+ // "run nothing" becomes "run everything".
195
+ if (components.length === 0) return "(?!)";
196
+ const alt = components.map(escapeRegex).join("|");
197
+ return `\\b(${alt}): `;
198
+ }
199
+
200
+ /** Escape `s` so it matches only itself inside a RegExp alternation. */
201
+ function escapeRegex(s: string): string {
202
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
203
+ }
204
+
205
+ /**
206
+ * A Playwright `--grep` that matches exactly these routes' e2e specs.
207
+ *
208
+ * Every emitted e2e title embeds its route template — `GET /things/[id]
209
+ * renders without console or page errors` — greppable only with the same
210
+ * discipline as grepFor: no `^` (Playwright greps the whole title chain, so it
211
+ * would anchor against the file path), the literal "GET " on the left, and the
212
+ * title's following space on the right so `/a` does not also select `/ab`.
213
+ * The brackets in a dynamic segment MUST be escaped: unescaped, `[id]` is a
214
+ * character class matching single letters, not the literal text in the title.
215
+ */
216
+ export function grepForRoutes(routes: readonly string[]): string {
217
+ // `(?!)` matches nothing at all; an empty pattern would match everything.
218
+ if (routes.length === 0) return "(?!)";
219
+ const alt = routes.map(escapeRegex).join("|");
220
+ return `\\bGET (${alt}) `;
221
+ }