@eventra_dev/eventra-cli 0.1.5 → 0.1.7

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.
@@ -5,6 +5,7 @@ import fs from "fs";
5
5
  export class DependencyGraph {
6
6
  private reverse = new Map<string, Set<string>>();
7
7
  private exports = new Map<string, Set<string>>();
8
+ private fileCache = new Map<string, string | null>();
8
9
 
9
10
  constructor(
10
11
  private baseUrl: string,
@@ -15,42 +16,43 @@ export class DependencyGraph {
15
16
  return path.resolve(file);
16
17
  }
17
18
 
18
- // TS-like resolver
19
19
  private resolveModule(module: string, from: string): string | null {
20
- // relative
21
20
  if (module.startsWith(".")) {
22
21
  const abs = path.resolve(path.dirname(from), module);
23
22
  return this.resolveFile(abs);
24
23
  }
25
24
 
26
- // paths mapping
27
25
  for (const key in this.paths) {
28
- const pattern = key.replace("*", "(.*)");
26
+ const pattern = key.replace(/\*/g, "(.*)");
29
27
  const regex = new RegExp("^" + pattern + "$");
30
28
  const match = module.match(regex);
31
29
 
32
30
  if (!match) continue;
33
31
 
34
- const wildcard = match[1] || "";
32
+ const wildcard = match.slice(1);
35
33
 
36
34
  for (const target of this.paths[key]) {
37
- const replaced = target.replace("*", wildcard);
38
- const abs = path.resolve(this.baseUrl, replaced);
35
+ let replaced = target;
36
+
37
+ wildcard.forEach(w => {
38
+ replaced = replaced.replace("*", w);
39
+ });
39
40
 
41
+ const abs = path.resolve(this.baseUrl, replaced);
40
42
  const resolved = this.resolveFile(abs);
41
43
  if (resolved) return resolved;
42
44
  }
43
45
  }
44
46
 
45
- // baseUrl fallback
46
47
  const abs = path.resolve(this.baseUrl, module);
47
- const resolved = this.resolveFile(abs);
48
- if (resolved) return resolved;
49
-
50
- return null;
48
+ return this.resolveFile(abs);
51
49
  }
52
50
 
53
51
  private resolveFile(base: string): string | null {
52
+ if (this.fileCache.has(base)) {
53
+ return this.fileCache.get(base)!;
54
+ }
55
+
54
56
  const tryFiles = [
55
57
  base,
56
58
  base + ".ts",
@@ -64,10 +66,13 @@ export class DependencyGraph {
64
66
 
65
67
  for (const f of tryFiles) {
66
68
  if (fs.existsSync(f)) {
67
- return this.normalize(f);
69
+ const normalized = this.normalize(f);
70
+ this.fileCache.set(base, normalized);
71
+ return normalized;
68
72
  }
69
73
  }
70
74
 
75
+ this.fileCache.set(base, null);
71
76
  return null;
72
77
  }
73
78
 
@@ -77,11 +82,10 @@ export class DependencyGraph {
77
82
  this.exports.set(normalizedFile, new Set());
78
83
 
79
84
  for (const stmt of source.statements) {
80
- // IMPORT
81
85
  if (ts.isImportDeclaration(stmt)) {
82
86
  const module = stmt.moduleSpecifier.getText().replace(/['"]/g, "");
83
-
84
87
  const resolved = this.resolveModule(module, normalizedFile);
88
+
85
89
  if (!resolved) continue;
86
90
 
87
91
  if (!this.reverse.has(resolved)) {
@@ -91,18 +95,13 @@ export class DependencyGraph {
91
95
  this.reverse.get(resolved)!.add(normalizedFile);
92
96
  }
93
97
 
94
- // EXPORT *
95
98
  if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier) {
96
99
  const module = stmt.moduleSpecifier.getText().replace(/['"]/g, "");
97
-
98
100
  const resolved = this.resolveModule(module, normalizedFile);
99
- if (!resolved) continue;
100
101
 
101
- if (!this.exports.has(normalizedFile)) {
102
- this.exports.set(normalizedFile, new Set());
103
- }
102
+ if (!resolved) continue;
104
103
 
105
- this.exports.get(normalizedFile)!.add(resolved);
104
+ this.exports.get(normalizedFile)?.add(resolved);
106
105
 
107
106
  if (!this.reverse.has(resolved)) {
108
107
  this.reverse.set(resolved, new Set());
@@ -113,17 +112,43 @@ export class DependencyGraph {
113
112
  }
114
113
  }
115
114
 
116
- getDependents(file: string) {
117
- return [...(this.reverse.get(this.normalize(file)) ?? [])];
115
+ getAllDependentsDeep(file: string): string[] {
116
+ const start = this.normalize(file);
117
+ const visited = new Set<string>();
118
+ const result = new Set<string>();
119
+
120
+ const walk = (f: string) => {
121
+ if (visited.has(f)) return;
122
+ visited.add(f);
123
+
124
+ const deps = this.reverse.get(f);
125
+ deps?.forEach(d => {
126
+ result.add(d);
127
+ walk(d);
128
+ });
129
+
130
+ const ex = this.exports.get(f);
131
+ ex?.forEach(e => walk(e));
132
+ };
133
+
134
+ walk(start);
135
+
136
+ return [...result];
118
137
  }
119
138
 
120
139
  remove(file: string) {
121
140
  const normalized = this.normalize(file);
122
141
 
142
+ this.reverse.delete(normalized);
123
143
  this.exports.delete(normalized);
144
+ this.fileCache.delete(normalized);
124
145
 
125
146
  for (const set of this.reverse.values()) {
126
147
  set.delete(normalized);
127
148
  }
149
+
150
+ for (const set of this.exports.values()) {
151
+ set.delete(normalized);
152
+ }
128
153
  }
129
154
  }
@@ -52,7 +52,11 @@ export class TSService {
52
52
  this.compilerOptions = compilerOptions;
53
53
 
54
54
  const host: ts.LanguageServiceHost = {
55
- getScriptFileNames: () => [...this.fileNames],
55
+ getScriptFileNames: () => {
56
+ return [...new Set(this.fileNames)].sort((a, b) =>
57
+ a.localeCompare(b)
58
+ );
59
+ },
56
60
 
57
61
  getScriptVersion: (file) =>
58
62
  this.files.get(file)?.version.toString() ?? "0",
@@ -1,4 +1,5 @@
1
1
  import ts from "typescript";
2
+ import {resolveExportedSymbol} from "./exportResolver";
2
3
 
3
4
  export type ResolveResult = {
4
5
  values: string[];
@@ -52,6 +53,18 @@ export function resolveNodeValue(
52
53
  if (ts.isIdentifier(node)) {
53
54
  let symbol = checker.getSymbolAtLocation(node);
54
55
  if (!symbol) return null;
56
+ for (const decl of symbol.getDeclarations() ?? []) {
57
+ if (ts.isVariableDeclaration(decl) && decl.initializer) {
58
+ return resolveNodeValue(
59
+ decl.initializer,
60
+ checker,
61
+ paramMap,
62
+ seen
63
+ );
64
+ }
65
+ }
66
+
67
+ symbol = resolveExportedSymbol(symbol, checker) ?? symbol;
55
68
 
56
69
  if (symbol.flags & ts.SymbolFlags.Alias) {
57
70
  symbol = checker.getAliasedSymbol(symbol);
@@ -1,120 +1,267 @@
1
1
  import ts from "typescript";
2
2
  import { resolveNodeValue } from "./resolver";
3
3
  import { resolveFunctionFromCall } from "./callResolver";
4
+ import { resolveExportedSymbol } from "./exportResolver";
4
5
  import { findTrackParamIndex } from "./functionAnalyzer";
6
+ import { EventraConfig } from "../types";
7
+
8
+ const TRACKING_NAMES = new Set(["track", "event", "capture", "send"]);
5
9
 
6
10
  function getCallName(expr: ts.Expression): string {
7
11
  if (ts.isIdentifier(expr)) return expr.text;
8
-
9
- if (ts.isPropertyAccessExpression(expr)) {
10
- return expr.name.text;
11
- }
12
-
12
+ if (ts.isPropertyAccessExpression(expr)) return expr.name.text;
13
13
  return "";
14
14
  }
15
15
 
16
- function pickBestArg(
17
- args: readonly ts.Expression[],
18
- checker: ts.TypeChecker,
19
- paramMap: Map<string, ts.Expression>
20
- ) {
21
- let best: { score: number; node: ts.Expression } | null = null;
16
+ function getJsxAttrName(name: ts.JsxAttributeName): string | null {
17
+ if (ts.isIdentifier(name)) return name.text;
18
+ if (ts.isJsxNamespacedName(name)) return name.name.text;
19
+ return null;
20
+ }
22
21
 
23
- for (const arg of args) {
24
- const res = resolveNodeValue(arg, checker, paramMap);
22
+ function isValidEvent(v: string) {
23
+ return (
24
+ v &&
25
+ v.length > 1 &&
26
+ v.length < 100 &&
27
+ !v.includes(" ") &&
28
+ /^[a-zA-Z0-9._:-]+$/.test(v)
29
+ );
30
+ }
25
31
 
26
- if (!res?.values?.length) continue;
32
+ function isPropUsedInTracking(
33
+ fn: ts.FunctionLikeDeclaration,
34
+ prop: string
35
+ ) {
36
+ let found = false;
27
37
 
28
- let score = 0;
38
+ function visit(node: ts.Node) {
39
+ if (ts.isCallExpression(node)) {
40
+ const name = getCallName(node.expression);
29
41
 
30
- if (!res.dynamic) score += 3;
31
- if (res.values.every(v => v.length < 50)) score += 2;
32
- if (res.values.some(v => v.includes("*"))) score -= 2;
42
+ if (!TRACKING_NAMES.has(name)) return;
33
43
 
34
- if (!best || score > best.score) {
35
- best = { score, node: arg };
44
+ for (const arg of node.arguments) {
45
+ if (
46
+ (ts.isIdentifier(arg) && arg.text === prop) ||
47
+ (ts.isPropertyAccessExpression(arg) &&
48
+ arg.name.text === prop)
49
+ ) {
50
+ found = true;
51
+ }
52
+ }
36
53
  }
54
+
55
+ ts.forEachChild(node, visit);
37
56
  }
38
57
 
39
- return best?.node;
58
+ visit(fn);
59
+ return found;
40
60
  }
41
61
 
62
+ export type ScanResult = {
63
+ events: Set<string>;
64
+ detectedFunctionWrappers: Set<string>;
65
+ detectedComponentWrappers: Map<string, string>;
66
+ };
67
+
42
68
  export function scanSource(
43
69
  source: ts.SourceFile,
44
70
  checker: ts.TypeChecker,
71
+ config: EventraConfig,
45
72
  visited = new Set<string>(),
46
73
  depth = 0
47
- ): Set<string> {
48
- if (depth > 5) return new Set();
49
-
74
+ ): ScanResult {
50
75
  const events = new Set<string>();
76
+ const detectedFunctionWrappers = new Set<string>();
77
+ const detectedComponentWrappers = new Map<string, string>();
78
+
79
+ if (depth > 5 || visited.has(source.fileName)) {
80
+ return { events, detectedFunctionWrappers, detectedComponentWrappers };
81
+ }
51
82
 
52
- if (visited.has(source.fileName)) return events;
53
83
  visited.add(source.fileName);
54
84
 
55
85
  function visit(node: ts.Node) {
86
+ // CALL EXPRESSIONS
56
87
  if (ts.isCallExpression(node)) {
57
88
  const name = getCallName(node.expression);
58
89
 
59
- if (/(track|event|capture|send)/i.test(name)) {
60
- const fn = resolveFunctionFromCall(node.expression, checker);
90
+ const isKnownWrapper = config.functionWrappers.some(
91
+ w => w.name === name
92
+ );
93
+
94
+ const fn = resolveFunctionFromCall(node.expression, checker);
95
+
96
+ const isAutoWrapper =
97
+ fn && findTrackParamIndex(fn) !== null;
98
+
99
+ const isTracking =
100
+ isKnownWrapper ||
101
+ TRACKING_NAMES.has(name) ||
102
+ isAutoWrapper;
103
+
104
+ if (!isTracking) return;
105
+
106
+ // AUTO DETECT WRAPPER
107
+ if (
108
+ !isKnownWrapper &&
109
+ fn &&
110
+ !TRACKING_NAMES.has(name)
111
+ ) {
112
+ detectedFunctionWrappers.add(name);
113
+ }
114
+
115
+ const paramMap = new Map<string, ts.Expression>();
116
+
117
+ if (fn && ts.isFunctionLike(fn)) {
118
+ fn.parameters.forEach((param, i) => {
119
+ const arg = node.arguments[i];
120
+ if (!arg) return;
121
+
122
+ if (ts.isIdentifier(param.name)) {
123
+ paramMap.set(param.name.text, arg);
124
+ }
125
+ });
126
+ }
127
+
128
+ let handled = false;
129
+
130
+ if (fn) {
131
+ const idx = findTrackParamIndex(fn);
132
+
133
+ if (idx !== null && node.arguments[idx]) {
134
+ const res = resolveNodeValue(
135
+ node.arguments[idx],
136
+ checker,
137
+ paramMap
138
+ );
139
+
140
+ res?.values.forEach(v => {
141
+ if (isValidEvent(v)) events.add(v);
142
+ });
61
143
 
62
- const paramMap = new Map<string, ts.Expression>();
144
+ handled = true;
145
+ }
146
+ }
63
147
 
64
- if (fn && ts.isFunctionLike(fn)) {
65
- fn.parameters.forEach((param, index) => {
66
- const arg = node.arguments[index];
67
- if (!arg) return;
148
+ // fallback
149
+ if (!handled) {
150
+ for (const arg of node.arguments) {
151
+ const res = resolveNodeValue(arg, checker, paramMap);
68
152
 
69
- if (ts.isIdentifier(param.name)) {
70
- paramMap.set(param.name.text, arg);
71
- }
153
+ res?.values.forEach(v => {
154
+ if (isValidEvent(v)) events.add(v);
72
155
  });
73
156
  }
157
+ }
158
+
159
+ // cross-file traversal
160
+ if (fn) {
161
+ const sf = fn.getSourceFile();
162
+
163
+ if (!visited.has(sf.fileName)) {
164
+ const res = scanSource(
165
+ sf,
166
+ checker,
167
+ config,
168
+ visited,
169
+ depth + 1
170
+ );
171
+
172
+ res.events.forEach(e => events.add(e));
173
+ }
174
+ }
175
+ }
176
+
177
+ // JSX COMPONENTS
178
+ if (
179
+ ts.isJsxSelfClosingElement(node) ||
180
+ ts.isJsxOpeningElement(node)
181
+ ) {
182
+ const tagNode =
183
+ ts.isPropertyAccessExpression(node.tagName)
184
+ ? node.tagName.name
185
+ : node.tagName;
186
+
187
+ let symbol = checker.getSymbolAtLocation(tagNode);
188
+
189
+ if (symbol) {
190
+ const resolved = resolveExportedSymbol(symbol, checker);
191
+ if (resolved) symbol = resolved;
192
+ }
193
+
194
+ if (symbol && (symbol.flags & ts.SymbolFlags.Alias)) {
195
+ const aliased = checker.getAliasedSymbol(symbol);
196
+ if (aliased) symbol = aliased;
197
+ }
74
198
 
75
- if (fn && ts.isFunctionLike(fn)) {
76
- const idx = findTrackParamIndex(fn);
77
-
78
- if (idx !== null) {
79
- const realArg = node.arguments[idx];
80
-
81
- if (realArg) {
82
- const res = resolveNodeValue(realArg, checker, paramMap);
83
-
84
- res?.values.forEach(v => {
85
- if (
86
- v &&
87
- v.length > 1 &&
88
- v.length < 100 &&
89
- !v.includes(" ") &&
90
- /^[a-zA-Z0-9._:-]+$/.test(v)
91
- ) {
92
- events.add(v);
93
- }
94
- });
95
-
96
- return;
97
- }
199
+ if (!symbol) return;
200
+
201
+ const tagName =
202
+ ts.isPropertyAccessExpression(node.tagName)
203
+ ? node.tagName.name.getText()
204
+ : node.tagName.getText();
205
+
206
+ const declarations = symbol.getDeclarations();
207
+ if (!declarations) return;
208
+
209
+ for (const decl of declarations) {
210
+ let fn: ts.FunctionLikeDeclaration | null = null;
211
+
212
+ if (ts.isFunctionDeclaration(decl)) {
213
+ fn = decl;
214
+ }
215
+
216
+ if (ts.isVariableDeclaration(decl) && decl.initializer) {
217
+ const init = decl.initializer;
218
+
219
+ if (
220
+ ts.isArrowFunction(init) ||
221
+ ts.isFunctionExpression(init)
222
+ ) {
223
+ fn = init;
98
224
  }
99
225
  }
100
226
 
101
- // fallback (heuristic)
102
- const target = pickBestArg(node.arguments, checker, paramMap);
227
+ if (!fn) continue;
228
+
229
+ const param = fn.parameters[0];
230
+ if (!param) continue;
231
+
232
+ if (!ts.isObjectBindingPattern(param.name)) continue;
103
233
 
104
- if (target) {
105
- const res = resolveNodeValue(target, checker, paramMap);
234
+ for (const el of param.name.elements) {
235
+ const prop = el.name.getText();
236
+
237
+ const isEvent =
238
+ isPropUsedInTracking(fn, prop) ||
239
+ config.wrappers.some(
240
+ w => w.name === tagName && w.prop === prop
241
+ );
242
+
243
+ if (!isEvent) continue;
244
+
245
+ const attr = node.attributes.properties.find(p => {
246
+ if (!ts.isJsxAttribute(p)) return false;
247
+ return getJsxAttrName(p.name) === prop;
248
+ }) as ts.JsxAttribute | undefined;
249
+
250
+ if (!attr || !attr.initializer) continue;
251
+
252
+ const expr = ts.isJsxExpression(attr.initializer)
253
+ ? attr.initializer.expression
254
+ : attr.initializer;
255
+
256
+ if (!expr) continue;
257
+
258
+ const res = resolveNodeValue(expr, checker);
106
259
 
107
260
  res?.values.forEach(v => {
108
- if (
109
- v &&
110
- v.length > 1 &&
111
- v.length < 100 &&
112
- !v.includes(" ") &&
113
- /^[a-zA-Z0-9._:-]+$/.test(v)
114
- ) {
115
- events.add(v);
116
- }
261
+ if (isValidEvent(v)) events.add(v);
117
262
  });
263
+
264
+ detectedComponentWrappers.set(tagName, prop);
118
265
  }
119
266
  }
120
267
  }
@@ -124,5 +271,9 @@ export function scanSource(
124
271
 
125
272
  visit(source);
126
273
 
127
- return events;
274
+ return {
275
+ events,
276
+ detectedFunctionWrappers,
277
+ detectedComponentWrappers,
278
+ };
128
279
  }