@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,213 @@
1
+ import ts from "typescript";
2
+ import type { PropValue } from "../types.ts";
3
+ import { ownReturns } from "./ownReturns.ts";
4
+ import { literalToValue } from "./renderPath.ts";
5
+
6
+ /**
7
+ * What each exported action of a "use server" module actually returns, when
8
+ * that can be read off the source without evaluating anything.
9
+ *
10
+ * The bail policy is literalToValue's, deliberately: a mock built from a wrong
11
+ * guess is worse than the honest {} stub, because the spec goes green against
12
+ * data the action can never produce. So a name is recorded only when every
13
+ * non-exit return collapses to ONE literal-derivable value:
14
+ *
15
+ * - `redirect(...)` / `permanentRedirect(...)` / `notFound()` returns are
16
+ * exits, not values — Next aborts rendering there, so they say nothing
17
+ * about what a caller receives. Only when the callee is provably
18
+ * next/navigation's (extractGuards learned this first): a project-local
19
+ * redirect() that RETURNS something would otherwise have its value
20
+ * silently discounted and the recorded literal would disagree with what
21
+ * the action really returns.
22
+ * - Two distinct literal values bail (which one would the mock pick?);
23
+ * byte-identical duplicates from two branches collapse fine.
24
+ * - Any non-literal return bails the whole name, even beside a literal one:
25
+ * a literal happy path next to a computed error path is two shapes, and
26
+ * mocking just the literal hides the one the component crashes on.
27
+ * - A bare `return;` is a value ({} ≠ undefined) the mock cannot express,
28
+ * so it bails too.
29
+ *
30
+ * Keyed by MODULE-side export name ("default" for the default export) —
31
+ * that is the name a vi.mock factory or a host stub has to provide.
32
+ */
33
+ export function extractServerActionReturns(sf: ts.SourceFile): Record<string, PropValue> {
34
+ const exits = exitBindings(sf);
35
+ const out: Record<string, PropValue> = {};
36
+ for (const [name, fn] of exportedFunctions(sf)) {
37
+ const value = singleReturnValue(fn, exits);
38
+ if (value !== undefined) out[name] = value;
39
+ }
40
+ return out;
41
+ }
42
+
43
+ const EXIT_CALLEES = new Set(["redirect", "permanentRedirect", "notFound"]);
44
+
45
+ interface ExitBindings {
46
+ /** Local names bound to a next/navigation exit, aliases resolved. */
47
+ locals: Set<string>;
48
+ /** Namespace locals of next/navigation, for `nav.redirect(...)`. */
49
+ namespaces: Set<string>;
50
+ }
51
+
52
+ function exitBindings(sf: ts.SourceFile): ExitBindings {
53
+ const locals = new Set<string>();
54
+ const namespaces = new Set<string>();
55
+ for (const stmt of sf.statements) {
56
+ if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
57
+ if (stmt.moduleSpecifier.text !== "next/navigation") continue;
58
+ const bindings = stmt.importClause?.namedBindings;
59
+ if (!bindings) continue;
60
+ if (ts.isNamedImports(bindings)) {
61
+ for (const el of bindings.elements) {
62
+ if (EXIT_CALLEES.has((el.propertyName ?? el.name).text)) locals.add(el.name.text);
63
+ }
64
+ } else if (ts.isNamespaceImport(bindings)) {
65
+ namespaces.add(bindings.name.text);
66
+ }
67
+ }
68
+ return { locals, namespaces };
69
+ }
70
+
71
+ function isExitCall(expr: ts.Expression, exits: ExitBindings): boolean {
72
+ if (!ts.isCallExpression(expr)) return false;
73
+ const callee = expr.expression;
74
+ if (ts.isIdentifier(callee)) return exits.locals.has(callee.text);
75
+ return (
76
+ ts.isPropertyAccessExpression(callee) &&
77
+ ts.isIdentifier(callee.expression) &&
78
+ exits.namespaces.has(callee.expression.text) &&
79
+ EXIT_CALLEES.has(callee.name.text)
80
+ );
81
+ }
82
+
83
+ export type ActionFn = ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression;
84
+
85
+ /**
86
+ * Calls that hand back their function argument with the SAME call signature.
87
+ *
88
+ * `export const getCurrentUser = cache(async (): Promise<User> => …)` is the
89
+ * standard Next.js DAL idiom — the dogfood app's `lib/dal.ts` uses it three
90
+ * times — and a `CallExpression` initializer was skipped outright, so every
91
+ * consumer of this list saw the module as having no such export. Downstream that
92
+ * is a `{}` mock plus an uninferred-return TODO on a function whose return type
93
+ * is written down two characters away.
94
+ *
95
+ * A CLOSED table, not "any call with one function argument", because the reason
96
+ * for the original skip is real for most wrappers: `withPagination(async ():
97
+ * Promise<User[]> => …)` may well return `Promise<Page<User>>`, and unwrapping it
98
+ * hands the emitter the inner shape for a function that never produces it — a
99
+ * wrong fixture, which this repo treats as worse than none.
100
+ *
101
+ * These two are typed `<T>(fn: T) => T` and `(cb, keyParts?, opts?) => typeof cb`
102
+ * respectively, so for them the inner annotation IS the effective return type.
103
+ * Widening this set means showing the same of whatever is added — there is no
104
+ * type checker here to work it out.
105
+ */
106
+ const IDENTITY_WRAPPERS = new Set(["cache", "unstable_cache"]);
107
+
108
+ /**
109
+ * The function inside an allowlisted wrapper call, or undefined.
110
+ *
111
+ * Exactly one function-like argument, so `unstable_cache(fn, keyParts, opts)`
112
+ * resolves (only argument 0 is function-like) while anything passing two
113
+ * callbacks bails rather than pick one.
114
+ */
115
+ function unwrapIdentityWrapper(init: ts.Expression): ActionFn | undefined {
116
+ if (!ts.isCallExpression(init)) return undefined;
117
+ // `cache(…)` and `React.cache(…)` both, keyed on the member name — the import
118
+ // could be aliased either way and this list is about the callee's identity.
119
+ const callee = init.expression;
120
+ const name = ts.isIdentifier(callee)
121
+ ? callee.text
122
+ : ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)
123
+ ? callee.name.text
124
+ : undefined;
125
+ if (name === undefined || !IDENTITY_WRAPPERS.has(name)) return undefined;
126
+ const fns = init.arguments.filter(
127
+ (a): a is ts.ArrowFunction | ts.FunctionExpression =>
128
+ ts.isArrowFunction(a) || ts.isFunctionExpression(a),
129
+ );
130
+ return fns.length === 1 ? fns[0] : undefined;
131
+ }
132
+
133
+ /** Exported name → its function node, local declarations resolved in-file. */
134
+ export function exportedFunctions(sf: ts.SourceFile): Array<[string, ActionFn]> {
135
+ // Every top-level function-ish declaration, exported or not: an export
136
+ // clause (`export { doSave as save }`) points at a local one.
137
+ const locals = new Map<string, ActionFn>();
138
+ const out: Array<[string, ActionFn]> = [];
139
+ for (const stmt of sf.statements) {
140
+ const modifiers = ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined;
141
+ const exported = modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
142
+ const isDefault = modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ?? false;
143
+ if (ts.isFunctionDeclaration(stmt)) {
144
+ if (stmt.name) locals.set(stmt.name.text, stmt);
145
+ if (exported && isDefault) out.push(["default", stmt]);
146
+ else if (exported && stmt.name) out.push([stmt.name.text, stmt]);
147
+ continue;
148
+ }
149
+ if (ts.isVariableStatement(stmt)) {
150
+ for (const d of stmt.declarationList.declarations) {
151
+ if (!ts.isIdentifier(d.name) || d.initializer === undefined) continue;
152
+ const init = d.initializer;
153
+ const fn =
154
+ ts.isArrowFunction(init) || ts.isFunctionExpression(init)
155
+ ? init
156
+ : unwrapIdentityWrapper(init);
157
+ if (!fn) continue;
158
+ locals.set(d.name.text, fn);
159
+ if (exported) out.push([d.name.text, fn]);
160
+ }
161
+ continue;
162
+ }
163
+ // `export { doSave as save }` — the exported name is what importers bind
164
+ // to. A re-export from another file has no local to resolve; skipped
165
+ // HERE — sourceCache's findExportedDecl is the cross-file chase, and
166
+ // callers that want it (resolveDepReturns) walk barrels through that.
167
+ if (
168
+ ts.isExportDeclaration(stmt) &&
169
+ stmt.moduleSpecifier === undefined &&
170
+ stmt.exportClause &&
171
+ ts.isNamedExports(stmt.exportClause)
172
+ ) {
173
+ for (const el of stmt.exportClause.elements) {
174
+ const local = locals.get((el.propertyName ?? el.name).text);
175
+ if (local) out.push([el.name.text, local]);
176
+ }
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+
182
+ /** The one literal value every non-exit return yields, else undefined. */
183
+ function singleReturnValue(fn: ActionFn, exits: ExitBindings): PropValue | undefined {
184
+ // A generator's `return` sets the iterator's final result — calling the
185
+ // action yields an AsyncGenerator object, never a promise of that literal.
186
+ if (!ts.isArrowFunction(fn) && fn.asteriskToken) return undefined;
187
+ // A concise arrow body IS the single return expression.
188
+ if (ts.isArrowFunction(fn) && !ts.isBlock(fn.body)) {
189
+ const expr = unparenthesize(fn.body);
190
+ return isExitCall(expr, exits) ? undefined : literalToValue(expr);
191
+ }
192
+ if (!fn.body || !ts.isBlock(fn.body)) return undefined;
193
+
194
+ let value: PropValue | undefined;
195
+ let seen = false;
196
+ for (const ret of ownReturns(fn.body)) {
197
+ const expr = ret.expression === undefined ? undefined : unparenthesize(ret.expression);
198
+ if (expr !== undefined && isExitCall(expr, exits)) continue;
199
+ // Bare `return;` or a non-literal expression: a value we cannot express.
200
+ const v = expr === undefined ? undefined : literalToValue(expr);
201
+ if (v === undefined) return undefined;
202
+ if (seen && JSON.stringify(v) !== JSON.stringify(value)) return undefined;
203
+ value = v;
204
+ seen = true;
205
+ }
206
+ return seen ? value : undefined;
207
+ }
208
+
209
+ function unparenthesize(expr: ts.Expression): ts.Expression {
210
+ let e = expr;
211
+ while (ts.isParenthesizedExpression(e)) e = e.expression;
212
+ return e;
213
+ }
@@ -0,0 +1,263 @@
1
+ import { readFileSync } from "node:fs";
2
+ import ts from "typescript";
3
+
4
+ /**
5
+ * Parsed-source cache for the secondary files the analyser reads while
6
+ * resolving types across modules. Without it, every `ComponentProps<typeof X>`
7
+ * and every imported props type re-reads and re-parses the same file — the
8
+ * shadcn pattern makes that happen dozens of times per run.
9
+ *
10
+ * Failures are cached as misses too, so an unreadable path is attempted once.
11
+ */
12
+ /**
13
+ * The leading prologue directive of a module, tolerating a license header or
14
+ * any run of comments before it. Shared so "use client" and "use server" cannot
15
+ * drift apart.
16
+ *
17
+ * The scan is head-bounded, deliberately: this runs once per IMPORTED module
18
+ * in a sweep, not once per file, and full-parsing to find a one-line directive
19
+ * would multiply the parse count by the import fan-out. 64KB covers any
20
+ * realistic license banner (the longest common ones run ~2–4KB) with slack; a
21
+ * directive hidden behind a LARGER header is the acknowledged miss of the
22
+ * bound — misclassified as not carrying it — and would need the bound raised,
23
+ * not the regex changed.
24
+ */
25
+ export function readPrologueDirective(file: string): string | undefined {
26
+ try {
27
+ const head = readFileSync(file, "utf8").slice(0, 65536);
28
+ const m = /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*["']([^"']+)["']\s*;?/.exec(head);
29
+ return m?.[1];
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Top-level export names of a module.
37
+ *
38
+ * Needed to stub a "use server" module for the browser bundle: exporting only
39
+ * the names a component imports directly leaves a transitive importer — the
40
+ * zustand store importing logoutAction, say — with "No matching export", and the
41
+ * bundle fails just as hard as it did unstubbed.
42
+ */
43
+ export function exportedNames(sf: ts.SourceFile): string[] {
44
+ const out = new Set<string>();
45
+ for (const stmt of sf.statements) {
46
+ const exported = ts.canHaveModifiers(stmt)
47
+ ? ts.getModifiers(stmt)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
48
+ : false;
49
+ if (ts.isVariableStatement(stmt) && exported) {
50
+ for (const d of stmt.declarationList.declarations) {
51
+ if (ts.isIdentifier(d.name)) out.add(d.name.text);
52
+ }
53
+ continue;
54
+ }
55
+ if ((ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt)) && exported) {
56
+ // `export default function doSave()` exports "default", not "doSave" —
57
+ // the name is only local. Recording the name handed the server-action
58
+ // stub a named export the module never had, so a real default import
59
+ // failed to bind against it.
60
+ const isDefault = ts.getModifiers(stmt)?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
61
+ if (isDefault) out.add("default");
62
+ else if (stmt.name) out.add(stmt.name.text);
63
+ continue;
64
+ }
65
+ // `export { a, b as c }` — the exported name is what importers bind to.
66
+ if (ts.isExportDeclaration(stmt) && stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
67
+ for (const el of stmt.exportClause.elements) out.add(el.name.text);
68
+ }
69
+ }
70
+ return [...out].sort();
71
+ }
72
+
73
+ /**
74
+ * The module and module-side name a local binding was imported under.
75
+ *
76
+ * `import type { User as U } from "./x"` looked up as "U" gives
77
+ * `{ specifier: "./x", importedName: "User" }`. Default and namespace imports
78
+ * are not reported: neither names a single exported declaration this chase can
79
+ * continue to.
80
+ */
81
+ function importedFrom(
82
+ sf: ts.SourceFile,
83
+ local: string,
84
+ ): { specifier: string; importedName: string } | undefined {
85
+ for (const stmt of sf.statements) {
86
+ if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
87
+ const bindings = stmt.importClause?.namedBindings;
88
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
89
+ for (const el of bindings.elements) {
90
+ if (el.name.text !== local) continue;
91
+ return {
92
+ specifier: stmt.moduleSpecifier.text,
93
+ importedName: (el.propertyName ?? el.name).text,
94
+ };
95
+ }
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ /** Where a re-export chase landed: the declaring file and its parse. */
101
+ export interface RelocatedExport<T> {
102
+ sf: ts.SourceFile;
103
+ file: string;
104
+ decl: T;
105
+ }
106
+
107
+ /** Barrel chains deeper than this are machine-generated; stop looking. */
108
+ const MAX_RELOCATION_DEPTH = 8;
109
+ /** Runaway backstop across the whole walk — cycles are caught by `visited`,
110
+ * so this only stops pathological generated fan-outs. It counts file::name
111
+ * pairs, NOT breadth per level: a wide shadcn-style barrel (40+ star arms)
112
+ * must still reach its last arm. */
113
+ const MAX_RELOCATION_FILES = 512;
114
+
115
+ /**
116
+ * Find `name`'s declaration starting at `file`, chasing barrel re-exports.
117
+ * ESM semantics drive the order inside each file: a matching declaration
118
+ * first, then local export clauses (`export { Real as Renamed };` — the
119
+ * clause itself makes the local declaration exported), then explicit
120
+ * re-export clauses (`export { A as B } from "./x"`, rename-aware — these
121
+ * SHADOW star exports), then star re-exports (`export * from "./x"`) in
122
+ * declaration order. `export * as ns from` exports one namespace object,
123
+ * never the names behind it, and is not followed.
124
+ *
125
+ * `find` receives `viaReexport: true` for every file reached through a
126
+ * re-export chain — matchers use it to refuse non-exported declarations,
127
+ * which a star chase must never bind (a private same-named interface in an
128
+ * earlier arm would silently produce a wrong-shape fixture).
129
+ *
130
+ * Depth-bounded and cycle-guarded, deliberately NOT the props hop budget:
131
+ * component → barrel → types.ts would exhaust MAX_HOPS before a single
132
+ * member resolved, and relocation is finding where a name LIVES, not a
133
+ * semantic resolution hop.
134
+ */
135
+ export function findExportedDecl<T>(
136
+ file: string,
137
+ name: string,
138
+ cache: SourceFileCache,
139
+ resolver: { resolve(spec: string, importer: string): string | undefined } | undefined,
140
+ find: (sf: ts.SourceFile, name: string, viaReexport: boolean) => T | undefined,
141
+ // For lookups reached through an IMPORT binding, the root file must vouch
142
+ // only for its exports too: TS never binds a private declaration to an
143
+ // import, so a private same-named decl in the directly-resolved module
144
+ // shadowing a star re-export would produce a type-illegal result. Local
145
+ // export clauses stay permissive — the clause itself vouches.
146
+ requireExportAtRoot = false,
147
+ ): RelocatedExport<T> | undefined {
148
+ const visited = new Set<string>();
149
+ const walk = (f: string, wanted: string, depth: number): RelocatedExport<T> | undefined => {
150
+ const key = `${f}::${wanted}`;
151
+ if (visited.has(key) || depth > MAX_RELOCATION_DEPTH || visited.size >= MAX_RELOCATION_FILES)
152
+ return undefined;
153
+ visited.add(key);
154
+ const sf = cache.get(f);
155
+ if (!sf) return undefined;
156
+ const hit = find(sf, wanted, depth > 0 || requireExportAtRoot);
157
+ if (hit !== undefined) return { sf, file: f, decl: hit };
158
+
159
+ const localClauses: ts.NamedExports[] = [];
160
+ const named: Array<{ clause: ts.NamedExports; spec: string }> = [];
161
+ const stars: string[] = [];
162
+ for (const stmt of sf.statements) {
163
+ if (!ts.isExportDeclaration(stmt)) continue;
164
+ if (!stmt.moduleSpecifier) {
165
+ if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
166
+ localClauses.push(stmt.exportClause);
167
+ }
168
+ continue;
169
+ }
170
+ if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
171
+ if (stmt.exportClause) {
172
+ if (ts.isNamedExports(stmt.exportClause)) {
173
+ named.push({ clause: stmt.exportClause, spec: stmt.moduleSpecifier.text });
174
+ }
175
+ continue;
176
+ }
177
+ stars.push(stmt.moduleSpecifier.text);
178
+ }
179
+
180
+ for (const clause of localClauses) {
181
+ for (const el of clause.elements) {
182
+ if (el.name.text !== wanted) continue;
183
+ const local = (el.propertyName ?? el.name).text;
184
+ const localHit = find(sf, local, false);
185
+ if (localHit !== undefined) return { sf, file: f, decl: localHit };
186
+ // No local DECLARATION of that name — but the file may have imported it
187
+ // and re-exported the binding:
188
+ //
189
+ // import type { User } from "@scope/shared";
190
+ // export type { User };
191
+ //
192
+ // ESM treats that as an export of this module, and it is how a package
193
+ // republishes a type it does not own. The chase stopped here, so a
194
+ // function annotated `Promise<User>` behind such a file resolved to
195
+ // nothing — which on the dogfood app is every export of lib/dal.ts.
196
+ const from = importedFrom(sf, local);
197
+ if (from) {
198
+ const target = resolver?.resolve(from.specifier, f);
199
+ if (target) {
200
+ const relocated = walk(target, from.importedName, depth + 1);
201
+ if (relocated) return relocated;
202
+ }
203
+ }
204
+ }
205
+ }
206
+ for (const { clause, spec } of named) {
207
+ for (const el of clause.elements) {
208
+ if (el.name.text !== wanted) continue;
209
+ const target = resolver?.resolve(spec, f);
210
+ if (!target) continue;
211
+ const relocated = walk(target, (el.propertyName ?? el.name).text, depth + 1);
212
+ if (relocated) return relocated;
213
+ }
214
+ }
215
+ for (const spec of stars) {
216
+ const target = resolver?.resolve(spec, f);
217
+ if (!target) continue;
218
+ const relocated = walk(target, wanted, depth + 1);
219
+ if (relocated) return relocated;
220
+ }
221
+ return undefined;
222
+ };
223
+ return walk(file, name, 0);
224
+ }
225
+
226
+ export class SourceFileCache {
227
+ private files = new Map<string, ts.SourceFile | null>();
228
+ private directives = new Map<string, string | null>();
229
+
230
+ /**
231
+ * True iff `abs` opens with the given prologue directive. Reads the file head
232
+ * only — full-parsing every imported module to answer this would multiply the
233
+ * parse count by the import fan-out of every entry file in a sweep.
234
+ */
235
+ hasPrologueDirective(abs: string, directive: string): boolean {
236
+ let hit = this.directives.get(abs);
237
+ if (hit === undefined) {
238
+ hit = readPrologueDirective(abs) ?? null;
239
+ this.directives.set(abs, hit);
240
+ }
241
+ return hit === directive;
242
+ }
243
+
244
+ /** Parse `abs` as TSX. Undefined on any read/parse failure. */
245
+ get(abs: string): ts.SourceFile | undefined {
246
+ const hit = this.files.get(abs);
247
+ if (hit !== undefined) return hit ?? undefined;
248
+ let sf: ts.SourceFile | null = null;
249
+ try {
250
+ sf = ts.createSourceFile(
251
+ abs,
252
+ readFileSync(abs, "utf8"),
253
+ ts.ScriptTarget.ES2022,
254
+ true,
255
+ ts.ScriptKind.TSX,
256
+ );
257
+ } catch {
258
+ sf = null;
259
+ }
260
+ this.files.set(abs, sf);
261
+ return sf ?? undefined;
262
+ }
263
+ }
@@ -0,0 +1,98 @@
1
+ import ts from "typescript";
2
+ import type { PropUsage } from "../types.ts";
3
+ import type { ParsedComponentUnit } from "./parseFile.ts";
4
+ import { posOf } from "./pos.ts";
5
+
6
+ /**
7
+ * Props spread into a same-file sibling are READ there. NavLink's
8
+ * `Props<T> = Omit<...> & {...}` defeats the syntactic type layer — nothing
9
+ * resolves, nothing destructures — and NavLink's own body only forwards
10
+ * `props` to NavLinkInner, where `props.href.toString()` dies on
11
+ * `undefined.toString` in the default-mount spec. When a component hands its
12
+ * props param to components defined in the same file, those siblings' prop
13
+ * names and member reads become evidence for the {} floor in
14
+ * synthesiseDefaults. Transitively: NavLink → NavLinkInner → NavLinkShell.
15
+ */
16
+ export function inheritSpreadSiblingProps(
17
+ unit: ParsedComponentUnit,
18
+ siblings: readonly ParsedComponentUnit[],
19
+ known: ReadonlySet<string>,
20
+ unresolvedProps: string[],
21
+ propUsage: PropUsage[],
22
+ sourceFile: ts.SourceFile,
23
+ ): void {
24
+ const byName = new Map<string, ParsedComponentUnit>();
25
+ for (const c of siblings) if (!byName.has(c.componentName)) byName.set(c.componentName, c);
26
+
27
+ const claim = (name: string, member: string | undefined, node: ts.Node): void => {
28
+ // These two never get a value floor: `{}` renders "[object Object]" as a
29
+ // className and is an invalid child as children. Left undefined, they
30
+ // render as nothing — which is what a defaults-only mount expects.
31
+ if (name === "children" || name === "className") return;
32
+ if (known.has(name)) return;
33
+ if (!unresolvedProps.includes(name)) unresolvedProps.push(name);
34
+ if (member !== undefined) {
35
+ propUsage.push({
36
+ propName: name,
37
+ kind: "member-access",
38
+ member,
39
+ pos: posOf(node, sourceFile),
40
+ });
41
+ }
42
+ };
43
+
44
+ const queue: ParsedComponentUnit[] = [unit];
45
+ const seen = new Set<ParsedComponentUnit>([unit]);
46
+
47
+ const scan = (c: ParsedComponentUnit): void => {
48
+ const visit = (n: ts.Node): void => {
49
+ if (c.paramName !== undefined) {
50
+ if (
51
+ ts.isJsxSpreadAttribute(n) &&
52
+ ts.isIdentifier(n.expression) &&
53
+ n.expression.text === c.paramName
54
+ ) {
55
+ const owner = n.parent?.parent;
56
+ const tag =
57
+ owner && (ts.isJsxOpeningElement(owner) || ts.isJsxSelfClosingElement(owner))
58
+ ? owner.tagName
59
+ : undefined;
60
+ if (tag !== undefined && ts.isIdentifier(tag)) {
61
+ const target = byName.get(tag.text);
62
+ if (target !== undefined && !seen.has(target)) {
63
+ seen.add(target);
64
+ queue.push(target);
65
+ }
66
+ }
67
+ return;
68
+ }
69
+ if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression)) {
70
+ if (n.expression.text === c.paramName) {
71
+ // props.href — and props.href.toString() reads a member off it.
72
+ const member =
73
+ n.parent !== undefined &&
74
+ ts.isPropertyAccessExpression(n.parent) &&
75
+ n.parent.expression === n
76
+ ? n.parent.name.text
77
+ : undefined;
78
+ claim(n.name.text, member, n);
79
+ }
80
+ return;
81
+ }
82
+ }
83
+ // href.toString() on the component's OWN destructured binding — the
84
+ // sibling that destructures (NavLinkShell) carries the same evidence.
85
+ if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression)) {
86
+ if (c.destructuredProps.includes(n.expression.text)) {
87
+ claim(c.destructuredAliases.get(n.expression.text) ?? n.expression.text, n.name.text, n);
88
+ }
89
+ }
90
+ ts.forEachChild(n, visit);
91
+ };
92
+ ts.forEachChild(c.bodyNode, visit);
93
+ };
94
+
95
+ while (queue.length > 0) {
96
+ scan(queue.shift() as ParsedComponentUnit);
97
+ }
98
+ }
@@ -0,0 +1,98 @@
1
+ import ts from "typescript";
2
+ import type { SourcePosition } from "../types.ts";
3
+ import type { ImportBinding } from "./parseFile.ts";
4
+ import { posOf } from "./pos.ts";
5
+ import type { ModuleResolver } from "./resolveModule.ts";
6
+
7
+ export interface ChildRef {
8
+ /** Full tag as written in JSX: "Card", "UI.Badge". */
9
+ localName: string;
10
+ /** Absolute path to the module holding the component. */
11
+ absPath: string;
12
+ /** Preferred component name inside the target file (named import / member tag). */
13
+ exportedName?: string;
14
+ /** First JSX usage site. */
15
+ pos: SourcePosition;
16
+ }
17
+
18
+ /**
19
+ * Walk JSX in `bodyNode`, collect referenced child components that resolve to a
20
+ * project-local module — via relative import, tsconfig path alias, or a
21
+ * same-file component declaration. Bare package imports are skipped.
22
+ */
23
+ export function walkChildren(
24
+ bodyNode: ts.Node,
25
+ imports: Map<string, ImportBinding>,
26
+ entryFile: string,
27
+ sourceFile: ts.SourceFile,
28
+ resolver: ModuleResolver,
29
+ localComponents: Set<string>,
30
+ selfName: string,
31
+ ): ChildRef[] {
32
+ const seen = new Set<string>();
33
+ const out: ChildRef[] = [];
34
+
35
+ const visit = (n: ts.Node): void => {
36
+ if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
37
+ const tag = n.tagName;
38
+ let rootName: string | undefined;
39
+ let fullName: string | undefined;
40
+ let memberName: string | undefined;
41
+
42
+ if (ts.isIdentifier(tag)) {
43
+ rootName = tag.text;
44
+ fullName = tag.text;
45
+ } else if (ts.isPropertyAccessExpression(tag)) {
46
+ let root: ts.Expression = tag.expression;
47
+ const segments: string[] = [tag.name.text];
48
+ while (ts.isPropertyAccessExpression(root)) {
49
+ segments.unshift(root.name.text);
50
+ root = root.expression;
51
+ }
52
+ if (ts.isIdentifier(root)) {
53
+ rootName = root.text;
54
+ fullName = `${root.text}.${segments.join(".")}`;
55
+ memberName = segments[segments.length - 1];
56
+ }
57
+ }
58
+
59
+ if (rootName && fullName && /^[A-Z]/.test(rootName) && !seen.has(fullName)) {
60
+ const binding = imports.get(rootName);
61
+ if (binding && !binding.isTypeOnly) {
62
+ const abs = resolver.resolve(binding.specifier, entryFile);
63
+ if (abs) {
64
+ seen.add(fullName);
65
+ out.push({
66
+ localName: fullName,
67
+ absPath: abs,
68
+ exportedName: resolveExportedName(binding, rootName, memberName),
69
+ pos: posOf(n, sourceFile),
70
+ });
71
+ }
72
+ } else if (!binding && localComponents.has(rootName) && rootName !== selfName) {
73
+ // Same-file secondary component.
74
+ seen.add(fullName);
75
+ out.push({
76
+ localName: fullName,
77
+ absPath: entryFile,
78
+ exportedName: rootName,
79
+ pos: posOf(n, sourceFile),
80
+ });
81
+ }
82
+ }
83
+ }
84
+ ts.forEachChild(n, visit);
85
+ };
86
+ ts.forEachChild(bodyNode, visit);
87
+ return out;
88
+ }
89
+
90
+ function resolveExportedName(
91
+ binding: ImportBinding,
92
+ rootName: string,
93
+ memberName: string | undefined,
94
+ ): string | undefined {
95
+ if (binding.kind === "namespace") return memberName;
96
+ if (binding.kind === "named") return binding.importedName ?? rootName;
97
+ return undefined; // default import — primary component of the target file
98
+ }