@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.
- 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 +611 -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,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
|
+
}
|