@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,431 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+
5
+ /**
6
+ * Project-local module resolution for the analyser: relative specifiers,
7
+ * tsconfig `paths` aliases (with `extends` chains) and `baseUrl` fallback,
8
+ * then the monorepo's own workspace packages (`@scope/shared`) resolved to
9
+ * their SOURCE trees. Bare THIRD-PARTY specifiers (react, next/link, …)
10
+ * still intentionally resolve to undefined — node_modules is never walked
11
+ * into; only directories the workspace map enumerates are entered, and all
12
+ * of those live inside the repo.
13
+ */
14
+
15
+ export interface PathPattern {
16
+ /** Key split on its single `*`; exact keys have no star. */
17
+ prefix: string;
18
+ suffix: string;
19
+ exact: boolean;
20
+ /** Raw target strings with `*` preserved, in declared order. */
21
+ targets: string[];
22
+ /** Directory targets resolve against. */
23
+ baseDir: string;
24
+ }
25
+
26
+ interface ResolvedTsconfig {
27
+ configPath: string;
28
+ baseUrlDir: string | undefined;
29
+ patterns: PathPattern[];
30
+ }
31
+
32
+ const MODULE_SUFFIXES = [".tsx", ".ts", "/index.tsx", "/index.ts"];
33
+
34
+ /** Try `base` as a module file: as-is, extension-swapped, then index files. */
35
+ export function probeModuleFile(base: string): string | undefined {
36
+ if (existsSync(base) && !isDirectory(base)) return base;
37
+ const stripped = base.replace(/\.(jsx?|tsx?)$/, "");
38
+ for (const ext of MODULE_SUFFIXES) {
39
+ const candidate = `${stripped}${ext}`;
40
+ if (existsSync(candidate)) return candidate;
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ function isDirectory(p: string): boolean {
46
+ try {
47
+ return statSync(p).isDirectory();
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ interface WorkspaceMap {
54
+ rootDir: string;
55
+ /** package.json `name` → absolute package dir. */
56
+ packages: Map<string, string>;
57
+ }
58
+
59
+ /**
60
+ * The workspace glob list declared at `dir`, or undefined when `dir` is not
61
+ * a workspace root. package.json `workspaces` (array or `{ packages: [] }`)
62
+ * and pnpm-workspace.yaml are unioned when both exist — resolving a true
63
+ * member is never wrong whichever manager owns the repo.
64
+ */
65
+ function workspaceGlobs(dir: string): string[] | undefined {
66
+ const globs: string[] = [];
67
+ const pkgPath = path.join(dir, "package.json");
68
+ if (existsSync(pkgPath)) {
69
+ try {
70
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
71
+ workspaces?: string[] | { packages?: string[] };
72
+ };
73
+ const ws = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages;
74
+ if (ws) globs.push(...ws.filter((g): g is string => typeof g === "string"));
75
+ } catch {
76
+ // Unparseable package.json: not a workspace root we can trust.
77
+ }
78
+ }
79
+ const pnpmPath = path.join(dir, "pnpm-workspace.yaml");
80
+ if (existsSync(pnpmPath)) globs.push(...pnpmWorkspacePackages(pnpmPath));
81
+ return globs.length > 0 ? globs : undefined;
82
+ }
83
+
84
+ /**
85
+ * The top-level `packages:` list of a pnpm-workspace.yaml, without a YAML
86
+ * dependency: consume `- entry` lines until the first non-list line. Fancy
87
+ * YAML (anchors, flow lists) yields nothing — the bail-safe direction.
88
+ */
89
+ function pnpmWorkspacePackages(file: string): string[] {
90
+ let text: string;
91
+ try {
92
+ text = readFileSync(file, "utf8");
93
+ } catch {
94
+ return [];
95
+ }
96
+ const out: string[] = [];
97
+ let inList = false;
98
+ for (const line of text.split("\n")) {
99
+ if (/^packages\s*:\s*(#.*)?$/.test(line)) {
100
+ inList = true;
101
+ continue;
102
+ }
103
+ if (!inList) continue;
104
+ // Zero-indent items (`- 'packages/*'` at column 0) are valid YAML and
105
+ // pnpm accepts them; only a new top-level key ends the list.
106
+ const m = /^\s*-\s*['"]?([^'"#]+?)['"]?\s*(#.*)?$/.exec(line);
107
+ if (m?.[1]) out.push(m[1]);
108
+ else if (/^\S/.test(line)) break;
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * The supported glob subset, classified: a literal dir, or a single
115
+ * TRAILING-star dir glob (`packages/*`, bare `*`). Anything else — `**`,
116
+ * mid-pattern or multiple stars — is undefined (unsupported).
117
+ */
118
+ function dirGlobShape(
119
+ glob: string,
120
+ ): { kind: "literal"; dir: string } | { kind: "star"; parent: string } | undefined {
121
+ const star = glob.indexOf("*");
122
+ if (star === -1) return { kind: "literal", dir: glob };
123
+ if (glob !== "*" && !glob.endsWith("/*")) return undefined;
124
+ if (star !== glob.lastIndexOf("*")) return undefined;
125
+ return { kind: "star", parent: glob === "*" ? "." : glob.slice(0, -2) };
126
+ }
127
+
128
+ /**
129
+ * Enumerate the packages the globs name. The supported subset is literal
130
+ * dirs and single-star dir globs (`packages/*`) — a fancier POSITIVE glob
131
+ * (`**`, mid-pattern stars) is silently skipped, because resolution
132
+ * returning undefined is the bail-safe direction. Negations are APPLIED,
133
+ * not skipped: ignoring an exclusion would widen the map — the one
134
+ * direction that is not bail-safe — so an unsupported negation disables
135
+ * this root entirely. node_modules is refused even when a glob names it:
136
+ * the never-walk invariant outranks the map.
137
+ */
138
+ function buildWorkspaceMap(rootDir: string, globs: string[]): WorkspaceMap {
139
+ const packages = new Map<string, string>();
140
+
141
+ const negations: Array<{ kind: "literal"; dir: string } | { kind: "star"; parent: string }> = [];
142
+ for (const glob of globs) {
143
+ if (!glob.startsWith("!")) continue;
144
+ const shape = dirGlobShape(glob.slice(1));
145
+ if (!shape) return { rootDir, packages };
146
+ negations.push(shape);
147
+ }
148
+ const excluded = (pkgDir: string): boolean =>
149
+ negations.some((neg) =>
150
+ neg.kind === "literal"
151
+ ? path.resolve(rootDir, neg.dir) === pkgDir
152
+ : path.dirname(pkgDir) === path.resolve(rootDir, neg.parent),
153
+ );
154
+
155
+ const addPackage = (pkgDir: string): void => {
156
+ if (excluded(pkgDir)) return;
157
+ const pkgJson = path.join(pkgDir, "package.json");
158
+ if (!existsSync(pkgJson)) return;
159
+ try {
160
+ const pkg = JSON.parse(readFileSync(pkgJson, "utf8")) as { name?: string };
161
+ if (typeof pkg.name === "string" && pkg.name.length > 0 && !packages.has(pkg.name)) {
162
+ packages.set(pkg.name, pkgDir);
163
+ }
164
+ } catch {
165
+ // Unreadable member manifest: skip the package, keep the map.
166
+ }
167
+ };
168
+ for (const glob of globs) {
169
+ if (glob.startsWith("!") || glob.includes("node_modules")) continue;
170
+ const shape = dirGlobShape(glob);
171
+ if (!shape) continue;
172
+ if (shape.kind === "literal") {
173
+ addPackage(path.resolve(rootDir, shape.dir));
174
+ continue;
175
+ }
176
+ const parent = path.resolve(rootDir, shape.parent);
177
+ if (path.basename(parent) === "node_modules" || !isDirectory(parent)) continue;
178
+ let entries: string[];
179
+ try {
180
+ entries = readdirSync(parent);
181
+ } catch {
182
+ continue;
183
+ }
184
+ for (const entry of entries.sort()) {
185
+ if (entry === "node_modules" || entry.startsWith(".")) continue;
186
+ const pkgDir = path.join(parent, entry);
187
+ if (isDirectory(pkgDir)) addPackage(pkgDir);
188
+ }
189
+ }
190
+ return { rootDir, packages };
191
+ }
192
+
193
+ export class ModuleResolver {
194
+ /** dir → nearest resolved tsconfig (null = no tsconfig above this dir). */
195
+ private dirCache = new Map<string, ResolvedTsconfig | null>();
196
+ /** configPath → parsed chain result. */
197
+ private configCache = new Map<string, ResolvedTsconfig>();
198
+ /** dir → nearest workspace map (null = no workspace root above this dir). */
199
+ private workspaceDirCache = new Map<string, WorkspaceMap | null>();
200
+ /** workspace root dir → built map. */
201
+ private workspaceCache = new Map<string, WorkspaceMap>();
202
+
203
+ /** The tsconfig path patterns in effect for files under `dir` (for alias re-emission). */
204
+ aliasPatternsFor(dir: string): PathPattern[] {
205
+ return this.nearestConfig(dir)?.patterns ?? [];
206
+ }
207
+
208
+ /** Resolve `spec` as imported from `importerAbsPath`. Undefined for bare/unresolvable. */
209
+ resolve(spec: string, importerAbsPath: string): string | undefined {
210
+ if (spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("/")) {
211
+ return probeModuleFile(path.resolve(path.dirname(importerAbsPath), spec));
212
+ }
213
+ const importerDir = path.dirname(importerAbsPath);
214
+ const config = this.nearestConfig(importerDir);
215
+ if (config) {
216
+ const match = this.matchPattern(config, spec);
217
+ if (match) return match;
218
+
219
+ if (config.baseUrlDir) {
220
+ const hit = probeModuleFile(path.resolve(config.baseUrlDir, spec));
221
+ if (hit) return hit;
222
+ }
223
+ }
224
+ // Both non-workspace exits (no tsconfig at all, and a paths/baseUrl
225
+ // miss) fall through here: a workspace app often has no tsconfig of its
226
+ // own, and gating the branch on one would silently keep it failing.
227
+ return this.resolveWorkspace(spec, importerDir);
228
+ }
229
+
230
+ /**
231
+ * A bare specifier as one of the monorepo's own workspace packages,
232
+ * resolved to its SOURCE tree — never `exports`/`main`/`types`: dist is
233
+ * routinely stale or absent, and `.d.ts` files have no function bodies for
234
+ * the fetch-wrapper path to read. probeModuleFile only knows .ts/.tsx, so
235
+ * a .js-source or .d.ts-only package misses here exactly like a package
236
+ * that does not exist — bail-safe, and deliberate.
237
+ */
238
+ private resolveWorkspace(spec: string, importerDir: string): string | undefined {
239
+ const ws = this.nearestWorkspace(importerDir);
240
+ if (!ws) return undefined;
241
+ let best: { name: string; dir: string } | undefined;
242
+ for (const [name, dir] of ws.packages) {
243
+ if (spec !== name && !spec.startsWith(`${name}/`)) continue;
244
+ if (!best || name.length > best.name.length) best = { name, dir };
245
+ }
246
+ if (!best) return undefined;
247
+ const subpath = spec === best.name ? undefined : spec.slice(best.name.length + 1);
248
+ const bases =
249
+ subpath === undefined
250
+ ? [path.join(best.dir, "src", "index"), path.join(best.dir, "index")]
251
+ : [path.join(best.dir, "src", subpath), path.join(best.dir, subpath)];
252
+ for (const base of bases) {
253
+ const hit = probeModuleFile(base);
254
+ if (hit) return hit;
255
+ }
256
+ return undefined;
257
+ }
258
+
259
+ private nearestWorkspace(fromDir: string): WorkspaceMap | null {
260
+ const visited: string[] = [];
261
+ let dir = path.resolve(fromDir);
262
+ for (;;) {
263
+ const cached = this.workspaceDirCache.get(dir);
264
+ if (cached !== undefined) {
265
+ for (const v of visited) this.workspaceDirCache.set(v, cached);
266
+ return cached;
267
+ }
268
+ visited.push(dir);
269
+ const globs = workspaceGlobs(dir);
270
+ if (globs) {
271
+ const built = this.workspaceCache.get(dir) ?? buildWorkspaceMap(dir, globs);
272
+ this.workspaceCache.set(dir, built);
273
+ for (const v of visited) this.workspaceDirCache.set(v, built);
274
+ return built;
275
+ }
276
+ const parent = path.dirname(dir);
277
+ if (parent === dir) {
278
+ for (const v of visited) this.workspaceDirCache.set(v, null);
279
+ return null;
280
+ }
281
+ dir = parent;
282
+ }
283
+ }
284
+
285
+ private matchPattern(config: ResolvedTsconfig, spec: string): string | undefined {
286
+ for (const p of config.patterns) {
287
+ if (p.exact) {
288
+ if (spec !== p.prefix) continue;
289
+ for (const target of p.targets) {
290
+ const hit = probeModuleFile(path.resolve(p.baseDir, target));
291
+ if (hit) return hit;
292
+ }
293
+ continue;
294
+ }
295
+ if (
296
+ spec.startsWith(p.prefix) &&
297
+ spec.endsWith(p.suffix) &&
298
+ spec.length >= p.prefix.length + p.suffix.length
299
+ ) {
300
+ const captured = spec.slice(p.prefix.length, spec.length - p.suffix.length);
301
+ for (const target of p.targets) {
302
+ const substituted = target.replace("*", captured);
303
+ const hit = probeModuleFile(path.resolve(p.baseDir, substituted));
304
+ if (hit) return hit;
305
+ }
306
+ }
307
+ }
308
+ return undefined;
309
+ }
310
+
311
+ private nearestConfig(fromDir: string): ResolvedTsconfig | null {
312
+ const visited: string[] = [];
313
+ let dir = path.resolve(fromDir);
314
+ for (;;) {
315
+ const cached = this.dirCache.get(dir);
316
+ if (cached !== undefined) {
317
+ for (const v of visited) this.dirCache.set(v, cached);
318
+ return cached;
319
+ }
320
+ visited.push(dir);
321
+ const candidate = path.join(dir, "tsconfig.json");
322
+ if (existsSync(candidate)) {
323
+ const resolved = this.parseConfigChain(candidate);
324
+ for (const v of visited) this.dirCache.set(v, resolved);
325
+ return resolved;
326
+ }
327
+ const parent = path.dirname(dir);
328
+ if (parent === dir) {
329
+ for (const v of visited) this.dirCache.set(v, null);
330
+ return null;
331
+ }
332
+ dir = parent;
333
+ }
334
+ }
335
+
336
+ private parseConfigChain(configPath: string): ResolvedTsconfig {
337
+ const cached = this.configCache.get(configPath);
338
+ if (cached) return cached;
339
+
340
+ // Walk the extends chain collecting raw configs root-first (child last),
341
+ // so later entries override earlier ones with TS semantics: `paths` and
342
+ // `baseUrl` are replaced wholesale by whichever config declares them last.
343
+ const chain: Array<{ dir: string; options: Record<string, unknown> }> = [];
344
+ const seen = new Set<string>();
345
+ const load = (p: string): void => {
346
+ const abs = path.resolve(p);
347
+ if (seen.has(abs)) return;
348
+ seen.add(abs);
349
+ const read = ts.readConfigFile(abs, ts.sys.readFile);
350
+ const config = (read.config ?? {}) as {
351
+ extends?: string | string[];
352
+ compilerOptions?: Record<string, unknown>;
353
+ };
354
+ const dir = path.dirname(abs);
355
+ const bases = config.extends
356
+ ? Array.isArray(config.extends)
357
+ ? config.extends
358
+ : [config.extends]
359
+ : [];
360
+ for (const base of bases) {
361
+ const resolved = this.resolveExtends(base, dir);
362
+ if (resolved) load(resolved);
363
+ }
364
+ chain.push({ dir, options: config.compilerOptions ?? {} });
365
+ };
366
+ load(configPath);
367
+
368
+ let baseUrlDir: string | undefined;
369
+ let pathsEntry:
370
+ | { dir: string; paths: Record<string, unknown>; baseUrlDir?: string }
371
+ | undefined;
372
+ for (const { dir, options } of chain) {
373
+ const baseUrl = typeof options.baseUrl === "string" ? options.baseUrl : undefined;
374
+ if (baseUrl !== undefined) baseUrlDir = path.resolve(dir, baseUrl);
375
+ const paths = options.paths;
376
+ if (paths && typeof paths === "object") {
377
+ pathsEntry = { dir, paths: paths as Record<string, unknown>, baseUrlDir };
378
+ }
379
+ }
380
+
381
+ const patterns: PathPattern[] = [];
382
+ if (pathsEntry) {
383
+ // Without baseUrl (TS >= 4.1), paths resolve against the declaring config's dir.
384
+ const baseDir = pathsEntry.baseUrlDir ?? pathsEntry.dir;
385
+ for (const [key, rawTargets] of Object.entries(pathsEntry.paths)) {
386
+ if (!Array.isArray(rawTargets)) continue;
387
+ const targets = rawTargets.filter((t): t is string => typeof t === "string");
388
+ if (targets.length === 0) continue;
389
+ const star = key.indexOf("*");
390
+ if (star === -1) {
391
+ patterns.push({ prefix: key, suffix: "", exact: true, targets, baseDir });
392
+ } else {
393
+ patterns.push({
394
+ prefix: key.slice(0, star),
395
+ suffix: key.slice(star + 1),
396
+ exact: false,
397
+ targets,
398
+ baseDir,
399
+ });
400
+ }
401
+ }
402
+ // Longest prefix wins.
403
+ patterns.sort((a, b) => b.prefix.length - a.prefix.length);
404
+ }
405
+
406
+ const result: ResolvedTsconfig = { configPath, baseUrlDir, patterns };
407
+ this.configCache.set(configPath, result);
408
+ return result;
409
+ }
410
+
411
+ private resolveExtends(spec: string, fromDir: string): string | undefined {
412
+ if (spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("/")) {
413
+ const base = path.resolve(fromDir, spec);
414
+ if (existsSync(base) && !isDirectory(base)) return base;
415
+ if (existsSync(`${base}.json`)) return `${base}.json`;
416
+ if (existsSync(path.join(base, "tsconfig.json"))) return path.join(base, "tsconfig.json");
417
+ return undefined;
418
+ }
419
+ // Bare specifier (@tsconfig/foo): probe node_modules upward from the config dir.
420
+ let dir = fromDir;
421
+ for (;;) {
422
+ const root = path.join(dir, "node_modules", spec);
423
+ for (const candidate of [root, `${root}.json`, path.join(root, "tsconfig.json")]) {
424
+ if (existsSync(candidate) && !isDirectory(candidate)) return candidate;
425
+ }
426
+ const parent = path.dirname(dir);
427
+ if (parent === dir) return undefined;
428
+ dir = parent;
429
+ }
430
+ }
431
+ }
@@ -0,0 +1,103 @@
1
+ import ts from "typescript";
2
+ import { extractRootTag } from "./extractRootTag.ts";
3
+ import type { ImportBinding } from "./parseFile.ts";
4
+ import { parseFile } from "./parseFile.ts";
5
+ import type { ModuleResolver } from "./resolveModule.ts";
6
+ import type { SourceFileCache } from "./sourceCache.ts";
7
+
8
+ /**
9
+ * What host element each component tag in a render actually produces.
10
+ *
11
+ * This exists to replace a naming convention. Deciding whether a bare
12
+ * `page.locator("button")` names one element meant counting how many things in
13
+ * the render could answer to it, and a `<Button>` obviously can — but "obviously"
14
+ * was implemented as `tag.toLowerCase().endsWith("button")`, which is a guess in
15
+ * a codebase whose rule is to bail rather than guess. `<LinkButton>` may well
16
+ * render an `<a>`.
17
+ *
18
+ * The evidence is one hop away: resolve the tag to its module, parse it, and
19
+ * read the root tag the analyser already computes for every component. Same-file
20
+ * components resolve without any I/O at all.
21
+ *
22
+ * Unresolved tags are simply absent from the map. The caller falls back to the
23
+ * convention for those, which only ever makes it MORE cautious — a false
24
+ * positive costs a test, a false negative clicks the wrong element.
25
+ */
26
+ export async function resolveTagRootTags(
27
+ bodyNode: ts.Node,
28
+ sameFile: ts.SourceFile,
29
+ imports: Map<string, ImportBinding>,
30
+ fromPath: string,
31
+ resolver: ModuleResolver,
32
+ /** Shared parse cache — without it every tag target re-parses once per entry. */
33
+ cache?: SourceFileCache,
34
+ ): Promise<Map<string, string>> {
35
+ const out = new Map<string, string>();
36
+ const tags = componentTagsIn(bodyNode);
37
+ if (tags.size === 0) return out;
38
+
39
+ // Same-file first: no resolution, no read.
40
+ const localBodies = componentBodiesIn(sameFile);
41
+ for (const tag of tags) {
42
+ const body = localBodies.get(tag);
43
+ if (!body) continue;
44
+ const root = extractRootTag(body);
45
+ if (root) out.set(tag, root);
46
+ }
47
+
48
+ for (const tag of tags) {
49
+ if (out.has(tag)) continue;
50
+ const binding = imports.get(tag);
51
+ if (!binding || binding.isTypeOnly) continue;
52
+ const target = resolver.resolve(binding.specifier, fromPath);
53
+ // Bare THIRD-PARTY specifiers resolve to undefined by design (workspace
54
+ // packages resolve to their source since B-8) — node_modules is never
55
+ // walked, so a third-party <Button> stays unknown rather than guessed.
56
+ if (!target) continue;
57
+ try {
58
+ const parsed = await parseFile(target, cache);
59
+ const wanted = binding.importedName ?? tag;
60
+ const unit =
61
+ parsed.components.find((c) => c.componentName === wanted) ??
62
+ (binding.kind === "default" ? parsed.components[0] : undefined);
63
+ if (!unit) continue;
64
+ const root = extractRootTag(unit.bodyNode);
65
+ if (root) out.set(tag, root);
66
+ } catch {
67
+ // Unparseable target: absent from the map, so the caller stays cautious.
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** Capitalised JSX tag names used anywhere in this render. */
74
+ function componentTagsIn(root: ts.Node): Set<string> {
75
+ const tags = new Set<string>();
76
+ const visit = (n: ts.Node): void => {
77
+ if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
78
+ if (ts.isIdentifier(n.tagName) && /^[A-Z]/.test(n.tagName.text)) tags.add(n.tagName.text);
79
+ }
80
+ ts.forEachChild(n, visit);
81
+ };
82
+ visit(root);
83
+ return tags;
84
+ }
85
+
86
+ /** Component name → body node, for the components declared in this same file. */
87
+ function componentBodiesIn(sourceFile: ts.SourceFile): Map<string, ts.Node> {
88
+ const out = new Map<string, ts.Node>();
89
+ const visit = (n: ts.Node): void => {
90
+ if (ts.isFunctionDeclaration(n) && n.name && n.body && /^[A-Z]/.test(n.name.text)) {
91
+ out.set(n.name.text, n.body);
92
+ }
93
+ if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && /^[A-Z]/.test(n.name.text)) {
94
+ const init = n.initializer;
95
+ if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
96
+ out.set(n.name.text, init.body);
97
+ }
98
+ }
99
+ ts.forEachChild(n, visit);
100
+ };
101
+ visit(sourceFile);
102
+ return out;
103
+ }